Past a context size you set, at a good stopping point, moves the work to a fresh session with a handoff Claude writes.

Every model call re-reads the whole session, mostly as cache reads, and accuracy drops as the context grows. handoff watches the context size. Past a size you set, at a good stopping point, it has Claude write a short handoff over the warm cache, runs /clear, and seeds the fresh session with that handoff. The old session stays resumable. You never lose a prompt, a draft, or the old session.
Success means fewer cache-read tokens per turn in long sessions, measured against the shadow baseline that off mode logs.
Status: early. The tests pass against Claude Code 2.1.295, but nothing here has been watched in a live terminal yet. See What has not been verified.
Type this at the prompt of a Claude Code terminal session:
/plugin install handoff --marketplace mikelane/thrift
Answer y to add the marketplace, then choose a scope. To run it from a checkout instead:
claude --plugin-dir ~/dev/thrift/plugins/handoff
handoffMode is off by default. Change it in /config.
| Mode | What it does |
|---|---|
off | Acts on nothing. Logs a shadow record for every evaluation, so you can see what ask or act would have done. |
ask | Shows a band above the prompt with buttons. Nothing happens until you press one. |
act | Hands off or compacts on its own. |
/handoff hands off at any time, in every mode, including off.
1 + the advisor calls, and the smaller of that and the session's reported size is the context size. Subagent steps are ignored.compactBeforeClear is on, compact the old session. A failed or vetoed compaction is logged and the handoff goes on./clear.claude --resume <id>.Every step that runs a command, a compaction, or a prompt submission is scheduled through the clock, because Claude Code refuses them inside a hook that the turn is waiting on.
The signal is none under the threshold. At or over it:
| Signal | When |
|---|---|
| strong | A finished task, or a prompt that names new work, and no background work is running |
| weak | Anything else over the threshold, including a stopping point while background work is running |
A finished task is a main-session Bash call in this turn that succeeded with git commit, git push, gh pr create, or gh pr merge.
git -C <dir> and leading VAR=value assignments are accepted.--dry-run is rejected. -n is rejected for push only, because commit -n still commits.;, |, &, &&, ||, and newlines. Text in quotes, a # comment, or a heredoc body is not a command, and a <<EOF inside quotes or a comment opens no heredoc.A prompt that names new work is a prompt typed by a person whose tickets and PRs are all new: earlier prompts named work, and this prompt names only references that none of them named.
| Reference | Counts as | Rule |
|---|---|---|
ENG-12 | A ticket | Only under a prefix the current git branch names (alice/eng-42-fix names ENG) or that a Linear (/issue/) or Jira (/browse/) URL in any prompt named. So UTF-8 then UTF-16 is never new work. |
PR #12, pr 12, issue #12, pull request 12 | A PR or issue | Case-insensitive. |
.../pull/12, .../issues/12 | A PR or issue | Any URL. |
#12 | Nothing | A bare number does not count. |
Outside a git repo only URLs name prefixes. The branch is read with a five-second timeout, and only when the prompt holds a ticket-shaped token.
Background work is a subagent with status pending, running, or waiting, or a background Bash or Monitor task that has not reported back. A <task-notification> prompt that carries the task id marks it done, and so does a TaskStop or KillShell call that was not denied. Task ids are validated against ^[A-Za-z0-9_-]+$. If the agent list cannot be read, the plugin assumes background work is running. A handoff would orphan the session that background work reports to, so it turns a strong signal into a weak one.
| Signal | off | ask | act |
|---|---|---|---|
| Strong, finished task | Log only | Band: Hand off and clear, Not now | Hand off |
| Strong, prompt names new work | Log only | Hold the prompt and refill the box. Band: Hand off and send it, Send here. Enter again sends it here. | Drop the prompt, hand off, resend it in the fresh session |
| Weak, background work running | Log only | Band: Compact, Not now. No handoff button. | Compact |
| Weak, otherwise | Log only | Band: Hand off and clear, Compact, Not now | Compact |
A weak signal is acted on at the end of a turn. A prompt that names new work while the signal is weak only logs.
Special cases:
/loop acts on a strong signal in every mode except off, and ignores a weak signal. That covers the prompt itself and the end of the turn it started.claude -p or the SDK the plugin only logs. The process can exit before a handoff ends, and a held prompt would be lost.composer, bridge, sdk, and scheduled-trigger count), and any prompt that arrives while a handoff or compaction is pending.The band draws above the prompt from a typed atom. It states the context in thousands, says that accuracy drops as context grows, and varies its wording for a held prompt, a finished task, background work, or plain size. In ask mode a dim line says that handoffMode act does this without asking. It yields to a survey. A band that cannot be taken down does not stop later turns from being evaluated.
/config) comes down when the reload fires session.start (not yet verified live for a /config change).After Not now, after each compaction, and after a failed or vetoed compaction, the plugin does not show the weak band or compact again until the context grows another 50,000 tokens. The growth is measured from the compaction's tokensAfter, or from the size before the compaction when it failed, was vetoed, or reported no size. A strong signal is not held back.
/clear throws after the fork succeeded. The session is unchanged and the prompt is handled the same way. A scheduled prompt still runs.Set these in /config.
| Setting | Type | Default | What it does |
|---|---|---|---|
handoffMode | off, ask, act | off | See Modes. |
handoffContextTokens | number, 80,000 to 2,000,000 | 150000 | The context size at which a signal becomes strong or weak. A fresh session starts near 48,000 tokens. |
compactBeforeClear | boolean | false | Compact the old session before clearing, so resuming it later costs less. It costs one compaction per handoff. When it is on, claude --resume <old id> reopens the compacted transcript, not the full one. |
One JSON object per line, one per evaluation, in $THRIFT_HOME/decisions.jsonl (default ~/.claude/thrift/decisions.jsonl). The file is created readable by its owner only. Evaluations that decide nothing are logged too, so you can compute a trigger rate. Prompt text never goes in the log, and nothing from it is written into the session.
{"ts":"2026-10-08T20:49:10.438Z","session_id":"abc","component":"handoff","mode":"active","action":"cleared","engine_version":"2.1.295","trigger_values":{"point":"turn-end","signal":"strong","context_tokens":200000,"threshold":150000,"is_background_busy":false,"setting":"act","cache_read_tokens":180000}}
| Field | Values |
|---|---|
ts | ISO 8601 UTC |
session_id | The session the decision is about. A cleared record carries the old session's id. |
component | Always handoff |
mode | shadow only when action is none and the plugin ran in off, otherwise active. The plugin runs in off when the setting is off and also when a fallback holds it there: an untested Claude Code build, a non-interactive session, or the start of a session before Claude Code reports it started, including right after a plugin reload. trigger_values.setting still shows what you configured. A record for /handoff is always active, because you asked for it. |
action | none, advised (a band was shown), cleared, compacted, or untested_engine |
engine_version | The Claude Code release (base), or the full version when there is no base. A change in behavior shows up as a split between versions. |
trigger_values.point | turn-end, prompt, command, button, or session-start |
trigger_values.signal | none, weak, or strong |
trigger_values.context_tokens | The context size when the decision was made |
trigger_values.threshold | handoffContextTokens |
trigger_values.is_background_busy | Whether background work was running |
trigger_values.setting | The configured handoffMode (the plugin may act as off anyway; see Compatibility) |
trigger_values.cache_read_tokens | Cache-read tokens of the last answered turn |
trigger_values.reason | Present when it explains a none: backoff, compaction_vetoed, compaction_failed, no_handoff_written, handoff_failed, clear_failed, band_failed, not_now, or send_here |
A prompt is evaluated, and so logged, only when it names new work.
To see whether it helped, compare cache_read_tokens per turn in off shadow records against the turns after a cleared record.
The plugin API is early access and changes between Claude Code releases, so an update can break a plugin without any error. Four layers guard against that:
claude plugin validate, tsc -p, and claude plugin test against a pinned Claude Code build and against latest. Only the pinned build blocks a change. A weekly run of latest opens an issue titled Claude Code <version> breaks handoff.test/engine-assumptions.test.ts, one test per assumption, with the build it was last confirmed on. The mocks come from test/helpers.ts, so a broken assumption is fixed in one place.TESTED_THROUGH in hooks/engine-version.ts names the newest release the plugin was tested through. At session start, if $.session.version() reports a base that is newer, ends in -dev, is missing, or is malformed, the plugin acts as off for the session, shows one toast (when handoffMode is not off), and logs untested_engine once. The gate fails closed: the plugin acts as off until session.start has confirmed a tested engine and an interactive run, so a hot reload mid-session stays off until the next session start. /handoff still works, because you asked for it at a moment you chose.engine_version on every log record.| Release | Tested through Claude Code |
|---|---|
| 0.1.0 | 2.1.295 |
Everything above is covered by tests against the engine's own test kit. These need a live terminal and have not been watched yet:
/handoff survives a /clear, and that the plugin's re-registration after a clear works.1 + the advisor entries in serverToolUses, and that $.session.usage().context.tokens is not a step behind the step's own total.claude --resume <old id> reopens the old session after a handoff./clear.claude plugin validate plugins/handoff
tsc -p plugins/handoff
claude plugin test plugins/handoff
claude plugin test measures no coverage. Review enforces it: every exported function and every branch in hooks/ has a test whose name says which.
| File | What it holds |
|---|---|
hooks/register.tsx | Engine wiring. Every function that takes $ lives here, because the engine follows $ only through functions declared at the top of this file. |
hooks/signals.ts | Pure signal logic: command parsing, work references, classification, the mode table |
hooks/engine-version.ts | TESTED_THROUGH and the untested-build check |
hooks/decision-record.ts | The log record and the log location |
hooks/handoff-note.ts | HANDOFF_PROMPT and the handoff message |
hooks/band-text.ts | The band's wording |
hooks/tasks.ts | Background task id helpers |
hooks/session-state.ts | The per-session state object |
types/index.d.ts | The band's Offer atom |
hooks/register.tsx 652 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 Register,
5 RenderInput,
6 ToolCallInput,
7 ToolCallResult,
8 PromptSubmitInput,
9 TurnCompleteInput,
10} from 'claude-code'
11
12import type { Offer } from '../types'
13import { BAND_HINT, BUTTON_LABELS, bandMessage } from './band-text'
14import { decisionRecord, LOG_WRITER, logLocation, type DecisionAction, type TriggerValues } from './decision-record'
15import { isUntestedEngine } from './engine-version'
16import { HANDOFF_PROMPT, handoffMessage, holdsPrompt, joinPrompts, resumeCommand, withoutPrompt } from './handoff-note'
17import { createState, resetForNewSession, type SessionState } from './session-state'
18import {
19 asMode,
20 asThreshold,
21 bandButtons,
22 branchTicketPrefix,
23 classify,
24 contextFromStep,
25 finishesTask,
26 hasTicketShapedToken,
27 namesNewWork,
28 respond,
29 ticketUrlPrefixes,
30 workRefs,
31 type Button,
32 type Signal,
33} from './signals'
34import { fieldOf, notifiedTaskId, validTaskId } from './tasks'
35
36const BACKOFF_TOKENS = 50_000
37const BRANCH_READ_TIMEOUT_MS = 5000
38const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk', 'scheduled-trigger'])
39const BUSY_AGENT_STATUSES = new Set(['pending', 'running', 'waiting'])
40const WRITING_TOAST = 'Writing a handoff for a fresh session...'
41const HANDING_OFF_FIRST = 'handoff: handing off first. Your prompt will be sent in the fresh session.'
42const HELD_PROMPT = 'handoff: held your prompt. Choose below, or press Enter again to send it here.'
43const ALREADY_PENDING = 'A handoff or compaction is already in progress.'
44const UNSENT_PROMPT = 'handoff: your prompt could not be sent or put back in the box. Here it is:'
45
46const offerAtom = atom({ plugin: 'handoff', key: 'offer' } as const, null)
47
48type Trigger = {
49 point: TriggerValues['point']
50 signal: Signal
51 isBusy: boolean
52 contextTokens: number
53 cacheReadTokens: number
54}
55
56type Evaluation = Trigger & {
57 action: DecisionAction
58 reason?: string
59 sessionId?: string
60}
61
62type HandoffRequest = {
63 trigger: Trigger
64 heldPrompt?: string
65 isUnattended: boolean
66}
67
68const snapshot = (state: SessionState, point: Trigger['point'], signal: Signal, isBusy: boolean): Trigger => ({
69 point,
70 signal,
71 isBusy,
72 contextTokens: state.contextTokens,
73 cacheReadTokens: state.cacheReadTokens,
74})
75
76const debug = ($: EngineInterface, line: string) => $.ui.log(`handoff: ${line}`, { to: 'debug' })
77
78const writeRecord = async ($: EngineInterface, state: SessionState, evaluation: Evaluation) => {
79 const { action, point, signal, isBusy, contextTokens, cacheReadTokens, reason, sessionId } = evaluation
80 try {
81 const [now, id, thriftHome, home] = await Promise.all([
82 $.clock.now(),
83 sessionId ?? $.session.id(),
84 $.env.get('THRIFT_HOME'),
85 $.env.get('HOME'),
86 ])
87 const location = logLocation(thriftHome, home)
88 if (location === null) return debug($, 'no log location: neither THRIFT_HOME nor HOME is set')
89 const record = decisionRecord({
90 now,
91 sessionId: id,
92 action,
93 engineVersion: state.engineVersion,
94 effectiveMode: state.mode,
95 triggerValues: {
96 point,
97 signal,
98 context_tokens: contextTokens,
99 threshold: state.threshold,
100 is_background_busy: isBusy,
101 setting: state.setting,
102 cache_read_tokens: cacheReadTokens,
103 ...(reason ? { reason } : {}),
104 },
105 })
106 const ran = await $.process.run([...LOG_WRITER, location.dir, location.file], {
107 stdin: `${JSON.stringify(record)}\n`,
108 })
109 if (ran.exitCode !== 0) debug($, `log write failed: ${ran.stderr.trim()}`)
110 } catch (error) {
111 debug($, `log write failed: ${String(error)}`)
112 }
113}
114
115const isBackgroundBusy = async ($: EngineInterface, state: SessionState): Promise<boolean> => {
116 if (state.backgroundTasks.size > 0) return true
117 try {
118 return (await $.agent.list()).some(agent => BUSY_AGENT_STATUSES.has(agent.status))
119 } catch (error) {
120 debug($, `could not list agents, treating background work as busy: ${String(error)}`)
121 return true
122 }
123}
124
125const readVersion = async ($: EngineInterface) => {
126 try {
127 const { version, base } = await $.session.version()
128 return { engineVersion: base ?? version, base }
129 } catch (error) {
130 debug($, `could not read the engine version: ${String(error)}`)
131 return { engineVersion: 'unknown', base: undefined }
132 }
133}
134
135const reportedContext = async ($: EngineInterface): Promise<number | undefined> => {
136 try {
137 return (await $.session.usage()).context.tokens
138 } catch (error) {
139 debug($, `could not read session usage: ${String(error)}`)
140 return undefined
141 }
142}
143
144const noteToolCall = (state: SessionState, e: ToolCallInput, ran: ToolCallResult) => {
145 const didSucceed = ran.deny === undefined && ran.isError !== true
146 if (e.tool === 'Bash') {
147 const started = validTaskId(fieldOf(ran.result, 'backgroundTaskId'))
148 if (started !== null) state.backgroundTasks.add(started)
149 else if (didSucceed && finishesTask(e.command)) state.hasFinishedTask = true
150 } else if (e.tool === 'Monitor') {
151 const started = validTaskId(fieldOf(ran.result, 'taskId'))
152 if (started !== null) state.backgroundTasks.add(started)
153 } else if (ran.deny === undefined && (e.tool === 'TaskStop' || String(e.tool) === 'KillShell')) {
154 const input: Readonly<Record<string, unknown>> = e
155 const stopped = validTaskId(input.task_id ?? input.shell_id)
156 if (stopped !== null) state.backgroundTasks.delete(stopped)
157 }
158}
159
160const isBackedOff = (state: SessionState) =>
161 state.backoffFrom !== null && state.contextTokens < state.backoffFrom + BACKOFF_TOKENS
162
163const runCompaction = async ($: EngineInterface, state: SessionState, trigger: Trigger) => {
164 const sizeBefore = state.contextTokens
165 try {
166 const compaction = await $.session.compact()
167 if (compaction.skip !== undefined) {
168 state.backoffFrom = sizeBefore
169 await writeRecord($, state, { ...trigger, action: 'none', reason: 'compaction_vetoed' })
170 } else {
171 await writeRecord($, state, { ...trigger, action: 'compacted' })
172 state.backoffFrom = compaction.tokensAfter ?? sizeBefore
173 state.contextTokens = compaction.tokensAfter ?? sizeBefore
174 }
175 } catch (error) {
176 state.backoffFrom = sizeBefore
177 debug($, `compaction failed: ${String(error)}`)
178 await writeRecord($, state, { ...trigger, action: 'none', reason: 'compaction_failed' })
179 } finally {
180 state.pending = null
181 }
182}
183
184const claim = (state: SessionState, kind: NonNullable<SessionState['pending']>): boolean => {
185 if (state.pending !== null) return false
186 state.pending = kind
187 return true
188}
189
190const scheduleCompaction = ($: EngineInterface, state: SessionState, trigger: Trigger): boolean => {
191 if (!claim(state, 'compact')) return false
192 $.clock.after(0, () => {
193 void logFailure($, runCompaction($, state, trigger))
194 })
195 return true
196}
197
198const takeDownBand = async ($: EngineInterface, state: SessionState) => {
199 if (!state.hasBand) return
200 try {
201 await update($, offerAtom, () => null)
202 state.hasBand = false
203 } catch (error) {
204 debug($, `could not take down the band: ${String(error)}`)
205 }
206}
207
208// The offer atom outlives a reload but `state.hasBand` does not, so a band left up would stick.
209const takeDownBandFromBeforeReload = async ($: EngineInterface, state: SessionState) => {
210 try {
211 if ((await read($, offerAtom)) !== null) state.hasBand = true
212 } catch (error) {
213 debug($, `could not read the band: ${String(error)}`)
214 }
215 await takeDownBand($, state)
216}
217
218const offerBand = async ($: EngineInterface, state: SessionState, trigger: Trigger, heldPrompt: boolean) => {
219 const offer: Offer = {
220 signal: trigger.signal === 'strong' ? 'strong' : 'weak',
221 contextTokens: trigger.contextTokens,
222 heldPrompt,
223 isBusy: trigger.isBusy,
224 }
225 try {
226 await update($, offerAtom, () => offer)
227 state.hasBand = true
228 await writeRecord($, state, { ...trigger, action: 'advised' })
229 } catch (error) {
230 debug($, `could not show the band: ${String(error)}`)
231 await writeRecord($, state, { ...trigger, action: 'none', reason: 'band_failed' })
232 }
233}
234
235const registerHandoffCommand = async ($: EngineInterface) => {
236 try {
237 await $.command.register({
238 name: 'handoff',
239 description: 'Write a handoff, clear, and continue in a fresh session',
240 })
241 } catch (error) {
242 debug($, `could not register /handoff: ${String(error)}`)
243 }
244}
245
246// SAFETY: every current caller catches its own engine failures, so no test reaches this catch.
247// Keep it: it is the only guard against an unhandled rejection if a future caller forgets.
248const logFailure = ($: EngineInterface, work: Promise<unknown>) =>
249 work.catch(error => debug($, `background work failed: ${String(error)}`))
250
251const refillBox = async ($: EngineInterface, text: string): Promise<boolean> => {
252 try {
253 const draft = (await $.prompt.read()).text
254 if (holdsPrompt(draft, text)) return true
255 const filled = await $.prompt.fill({ text: draft === '' ? text : `${text}\n${draft}`, mode: 'replace' })
256 return filled.isFilled
257 } catch (error) {
258 debug($, `could not refill the prompt box: ${String(error)}`)
259 return false
260 }
261}
262
263const keepInTranscript = ($: EngineInterface, text: string) => $.ui.log(`${UNSENT_PROMPT}\n${text}`)
264
265const submitOrKeepInTranscript = async ($: EngineInterface, text: string) => {
266 try {
267 await $.prompt.submit({ text })
268 } catch (error) {
269 debug($, `could not restore the held prompt: ${String(error)}`)
270 keepInTranscript($, text)
271 }
272}
273
274const restorePrompt = async ($: EngineInterface, text: string, isUnattended: boolean) => {
275 if (!isUnattended && (await refillBox($, text))) return
276 await submitOrKeepInTranscript($, text)
277}
278
279const abandonHandoff = async (
280 $: EngineInterface,
281 state: SessionState,
282 request: HandoffRequest,
283 reason: string,
284 message: string,
285) => {
286 $.ui.toast(message)
287 await writeRecord($, state, { ...request.trigger, action: 'none', reason })
288 if (request.heldPrompt !== undefined) await restorePrompt($, request.heldPrompt, request.isUnattended)
289}
290
291const writeNote = async ($: EngineInterface): Promise<string | null> => {
292 try {
293 const answer = await $.model.fork({ prompt: HANDOFF_PROMPT })
294 return answer.isAnswered && answer.text.trim() !== '' ? answer.text.trim() : null
295 } catch (error) {
296 debug($, `could not write the handoff note: ${String(error)}`)
297 return null
298 }
299}
300
301const compactBeforeClearing = async ($: EngineInterface, state: SessionState, trigger: Trigger) => {
302 try {
303 const compaction = await $.session.compact()
304 if (compaction.skip !== undefined) await writeRecord($, state, { ...trigger, action: 'none', reason: 'compaction_vetoed' })
305 } catch (error) {
306 debug($, `compaction before the clear failed: ${String(error)}`)
307 await writeRecord($, state, { ...trigger, action: 'none', reason: 'compaction_failed' })
308 }
309}
310
311const clearSession = async ($: EngineInterface): Promise<boolean> => {
312 try {
313 await $.command.run({ command: 'clear' })
314 return true
315 } catch (error) {
316 debug($, `/clear failed: ${String(error)}`)
317 return false
318 }
319}
320
321const appendNote = async ($: EngineInterface, message: string): Promise<boolean> => {
322 try {
323 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: message }] } })
324 return appended.deny === undefined
325 // The test kit cannot make the default append reject: a throwing session.append hook is treated as a hook failure,
326 // and the plugin sees a successful append. Whether the live default append can reject is unverified.
327 // Keep it: without it, a rejected append skips the prompt fallback and drops the handoff note.
328 } catch (error) {
329 debug($, `append failed: ${String(error)}`)
330 return false
331 }
332}
333
334const sendOrKeepInBox = async ($: EngineInterface, text: string, isUnattended: boolean) => {
335 try {
336 await $.prompt.submit({ text })
337 } catch (error) {
338 debug($, `could not send the prompt: ${String(error)}`)
339 if (isUnattended || !(await refillBox($, text))) keepInTranscript($, text)
340 }
341}
342
343const continueInFreshSession = async (
344 $: EngineInterface,
345 state: SessionState,
346 request: HandoffRequest,
347 oldId: string,
348 note: string,
349) => {
350 await registerHandoffCommand($)
351 const message = handoffMessage(oldId, note)
352 const isStored = await appendNote($, message)
353 const resume = resumeCommand(oldId)
354 $.ui.log(`handoff: the previous session is ${oldId}. Reopen it with ${resume}`)
355 $.ui.toast(`Handoff written. Reopen ${oldId} with ${resume}`)
356 await writeRecord($, state, { ...request.trigger, action: 'cleared', sessionId: oldId })
357 if (request.heldPrompt !== undefined) await inspectPrompt($, state, request.heldPrompt)
358 const prompt = isStored ? request.heldPrompt : joinPrompts(message, request.heldPrompt)
359 if (prompt !== undefined) await sendOrKeepInBox($, prompt, request.isUnattended)
360}
361
362const prepareHandoff = async ($: EngineInterface, state: SessionState, request: HandoffRequest) => {
363 try {
364 await takeDownBand($, state)
365 $.ui.toast(WRITING_TOAST)
366 const oldId = await $.session.id()
367 const note = await writeNote($)
368 if (note === null) {
369 await abandonHandoff($, state, request, 'no_handoff_written', 'No handoff was written. This session is unchanged.')
370 return null
371 }
372 if (state.compactBeforeClear) await compactBeforeClearing($, state, request.trigger)
373 return { oldId, note }
374 } catch (error) {
375 debug($, `handoff failed before the clear: ${String(error)}`)
376 await abandonHandoff($, state, request, 'handoff_failed', 'No handoff was written. This session is unchanged.')
377 return null
378 }
379}
380
381const runHandoff = async ($: EngineInterface, state: SessionState, request: HandoffRequest) => {
382 try {
383 const prepared = await prepareHandoff($, state, request)
384 if (prepared === null) return
385 if (!(await clearSession($))) {
386 return await abandonHandoff(
387 $,
388 state,
389 request,
390 'clear_failed',
391 'The handoff was written but /clear failed. This session is unchanged.',
392 )
393 }
394 await continueInFreshSession($, state, request, prepared.oldId, prepared.note)
395 } finally {
396 state.pending = null
397 }
398}
399
400const scheduleHandoff = ($: EngineInterface, state: SessionState, request: HandoffRequest): boolean => {
401 if (!claim(state, 'handoff')) return false
402 $.clock.after(0, () => {
403 void logFailure($, runHandoff($, state, request))
404 })
405 return true
406}
407
408const evaluateTurnEnd = async (
409 $: EngineInterface,
410 state: SessionState,
411 e: TurnCompleteInput,
412 hasFinishedTask: boolean,
413) => {
414 if (state.pending !== null) return
415 state.cacheReadTokens = e.usage?.cache_read_input_tokens ?? 0
416 const isBusy = await isBackgroundBusy($, state)
417 const signal = classify({
418 contextTokens: state.contextTokens,
419 threshold: state.threshold,
420 isStoppingPoint: hasFinishedTask,
421 isBackgroundBusy: isBusy,
422 })
423 const trigger = snapshot(state, 'turn-end', signal, isBusy)
424 const response = respond({
425 mode: state.mode,
426 signal,
427 stoppingPoint: hasFinishedTask ? 'finished-task' : null,
428 isBackgroundBusy: isBusy,
429 isUnattended: state.isTurnUnattended,
430 })
431 if (signal === 'weak' && isBackedOff(state)) {
432 await takeDownBand($, state)
433 return writeRecord($, state, { ...trigger, action: 'none', reason: 'backoff' })
434 }
435 if (response.kind === 'advise') return offerBand($, state, trigger, response.holdsPrompt)
436 await takeDownBand($, state)
437 if (response.kind === 'compact') return scheduleCompaction($, state, trigger)
438 if (response.kind === 'handoff') return scheduleHandoff($, state, { trigger, isUnattended: state.isTurnUnattended })
439 return writeRecord($, state, { ...trigger, action: 'none' })
440}
441
442const readBranch = async ($: EngineInterface): Promise<string> => {
443 try {
444 const ran = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: BRANCH_READ_TIMEOUT_MS })
445 return ran.exitCode === 0 ? ran.stdout.trim() : ''
446 } catch (error) {
447 debug($, `could not read the git branch: ${String(error)}`)
448 return ''
449 }
450}
451
452const inspectPrompt = async ($: EngineInterface, state: SessionState, text: string): Promise<boolean> => {
453 for (const prefix of ticketUrlPrefixes(text)) state.urlPrefixes.add(prefix)
454 const branchPrefixes = hasTicketShapedToken(text) ? branchTicketPrefix(await readBranch($)) : []
455 const prefixes = [...state.urlPrefixes, ...branchPrefixes]
456 const isNewWork = namesNewWork(text, state.seenRefs, prefixes)
457 for (const ref of workRefs(text, prefixes)) state.seenRefs.add(ref)
458 return isNewWork
459}
460
461const emptyBoxIfHolding = async ($: EngineInterface, text: string) => {
462 try {
463 const remainingDraft = withoutPrompt((await $.prompt.read()).text, text)
464 if (remainingDraft !== null) await $.prompt.fill({ text: remainingDraft, mode: 'replace' })
465 } catch (error) {
466 debug($, `could not empty the prompt box: ${String(error)}`)
467 }
468}
469
470const refillHeldPrompt = async ($: EngineInterface, state: SessionState, text: string) => {
471 if (state.heldPrompt !== text) return
472 if (await refillBox($, text)) return
473 if (state.heldPrompt !== text) return
474 state.heldPrompt = null
475 await takeDownBand($, state)
476 await submitOrKeepInTranscript($, text)
477}
478
479const holdPrompt = async ($: EngineInterface, state: SessionState, text: string, trigger: Trigger) => {
480 state.heldPrompt = text
481 $.clock.after(0, () => {
482 void logFailure($, refillHeldPrompt($, state, text))
483 })
484 await offerBand($, state, trigger, true)
485 return { drop: HELD_PROMPT }
486}
487
488const decidePrompt = async ($: EngineInterface, state: SessionState, e: PromptSubmitInput) => {
489 const isUnattended = e.origin.kind === 'scheduled-trigger'
490 state.heldPrompt = null
491 state.isTurnUnattended = isUnattended
492 const isNewWork = await inspectPrompt($, state, e.text)
493 const canHold =
494 isNewWork &&
495 state.pending === null &&
496 e.turnId === undefined &&
497 (e.attachments?.length ?? 0) === 0 &&
498 (e.context?.length ?? 0) === 0
499 if (!canHold) return null
500 const isBusy = await isBackgroundBusy($, state)
501 const signal = classify({
502 contextTokens: state.contextTokens,
503 threshold: state.threshold,
504 isStoppingPoint: true,
505 isBackgroundBusy: isBusy,
506 })
507 const trigger = snapshot(state, 'prompt', signal, isBusy)
508 const response = respond({ mode: state.mode, signal, stoppingPoint: 'new-work', isBackgroundBusy: isBusy, isUnattended })
509 if (response.kind === 'handoff') {
510 const isClaimed = scheduleHandoff($, state, { trigger, heldPrompt: e.text, isUnattended })
511 return isClaimed ? { drop: HANDING_OFF_FIRST } : null
512 }
513 if (response.kind === 'advise' && response.holdsPrompt) return holdPrompt($, state, e.text, trigger)
514 await writeRecord($, state, { ...trigger, action: 'none' })
515 return null
516}
517
518const pressHeldPromptButton = async ($: EngineInterface, state: SessionState, trigger: Trigger, button: Button) => {
519 const held = state.heldPrompt
520 if (held === null) return
521 state.heldPrompt = null
522 await takeDownBand($, state)
523 await emptyBoxIfHolding($, held)
524 if (button === 'handoff-send' && scheduleHandoff($, state, { trigger, heldPrompt: held, isUnattended: false })) return
525 await writeRecord($, state, { ...trigger, action: 'none', reason: 'send_here' })
526 $.clock.after(0, () => {
527 void logFailure($, sendOrKeepInBox($, held, false))
528 })
529}
530
531const pressButton = async ($: EngineInterface, state: SessionState, offer: Offer, button: Button) => {
532 if (state.pending !== null) return
533 const trigger = snapshot(state, 'button', offer.signal, offer.isBusy)
534 if (button === 'handoff-send' || button === 'send-here') return pressHeldPromptButton($, state, trigger, button)
535 if (button === 'compact') scheduleCompaction($, state, trigger)
536 else if (button !== 'not-now') scheduleHandoff($, state, { trigger, isUnattended: false })
537 else state.backoffFrom = state.contextTokens
538 await takeDownBand($, state)
539 if (button === 'not-now') await writeRecord($, state, { ...trigger, action: 'none', reason: 'not_now' })
540}
541
542const drawBand = ($: EngineInterface, state: SessionState, e: RenderInput<'AbovePrompt'>, offer: Offer) => {
543 const { Box, Text, Button } = $.ui.resolve(e)
544 return (
545 <Box flexDirection="column">
546 <Text>{bandMessage(offer)}</Text>
547 <Box flexWrap="wrap" columnGap={1}>
548 {bandButtons(offer).map(button => (
549 <Button
550 key={button}
551 label={BUTTON_LABELS[button]}
552 onPress={() => logFailure($, pressButton($, state, offer, button))}
553 />
554 ))}
555 </Box>
556 <Text dimColor>{BAND_HINT}</Text>
557 </Box>
558 )
559}
560
561const startHandoffCommand = async ($: EngineInterface, state: SessionState) => {
562 const trigger = snapshot(state, 'command', 'none', await isBackgroundBusy($, state))
563 const isClaimed = scheduleHandoff($, state, { trigger, isUnattended: false })
564 return { text: isClaimed ? WRITING_TOAST : ALREADY_PENDING }
565}
566
567export const register: Register = (on, options) => {
568 const setting = asMode(options.handoffMode)
569 const state = createState({
570 setting,
571 threshold: asThreshold(options.handoffContextTokens),
572 compactBeforeClear: options.compactBeforeClear === true,
573 })
574
575 on('session.start', async ($, e, next) => {
576 const started = await next(e)
577 await takeDownBandFromBeforeReload($, state)
578 const { engineVersion, base } = await readVersion($)
579 const isUntested = isUntestedEngine(base)
580 state.engineVersion = engineVersion
581 state.mode = e.isInteractive && !isUntested ? setting : 'off'
582 if (e.isInteractive) await registerHandoffCommand($)
583 if (isUntested) {
584 if (setting !== 'off') $.ui.toast(`handoff: untested on Claude Code ${engineVersion}, so it only logs this session. /handoff still works.`)
585 await writeRecord($, state, { ...snapshot(state, 'session-start', 'none', false), action: 'untested_engine' })
586 }
587 return started
588 }).catch(($, e, next) => next(e))
589
590 on('turn.step', async function* ($, e, next) {
591 const step = yield* next(e)
592 if (e.agentId === undefined && step.usage) {
593 const reported = await reportedContext($)
594 state.contextTokens = contextFromStep({
595 input: step.usage.input_tokens,
596 created: step.usage.cache_creation_input_tokens,
597 read: step.usage.cache_read_input_tokens,
598 advisorCalls: (step.serverToolUses ?? []).filter(use => use.name === 'advisor').length,
599 reported,
600 })
601 }
602 return step
603 }).catch(async function* ($, e, next) {
604 return yield* next(e)
605 })
606
607 on('tool.call', async ($, e, next) => {
608 const ran = await next(e)
609 if (e.agentId === undefined) noteToolCall(state, e, ran)
610 return ran
611 }).catch(($, e, next) => next(e))
612
613 on('prompt.submit', async ($, e, next) => {
614 await takeDownBand($, state)
615 const finishedTaskId = e.origin.kind === 'task-notification' ? notifiedTaskId(e.text) : null
616 if (finishedTaskId !== null) state.backgroundTasks.delete(finishedTaskId)
617 const dropped = PERSON_ORIGINS.has(e.origin.kind) ? await decidePrompt($, state, e) : null
618 return dropped ?? next(e)
619 }).catch(($, e, next) => next(e))
620
621 on('turn.complete', async ($, e, next) => {
622 const result = await next(e)
623 if (e.agentId !== undefined) return result
624 const hasFinishedTask = state.hasFinishedTask
625 state.hasFinishedTask = false
626 if (e.reason === 'answer') await evaluateTurnEnd($, state, e, hasFinishedTask)
627 return result
628 }).catch(($, e, next) => next(e))
629
630 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
631 const offer = await read($, offerAtom)
632 return e.props.hasSurvey || offer === null ? next(e) : drawBand($, state, e, offer)
633 }).catch(($, e, next) => next(e))
634
635 // SAFETY: startHandoffCommand's only await is isBackgroundBusy, which catches its own failures, so no test reaches this catch.
636 // Keep it: every hook must never break a turn.
637 on('command.run', { command: 'handoff' }, $ => startHandoffCommand($, state)).catch(() => ({
638 text: 'handoff: could not start a handoff.',
639 }))
640
641 on('session.end', async ($, e, next) => {
642 await takeDownBand($, state)
643 resetForNewSession(state)
644 if (e.reason === 'clear') {
645 $.clock.after(0, () => {
646 void logFailure($, registerHandoffCommand($))
647 })
648 }
649 return next(e)
650 }).catch(($, e, next) => next(e))
651}
652hooks/band-text.ts 30 lines1import type { Offer } from '../types'
2import { formatTokens, type Button } from './signals'
3
4export const BUTTON_LABELS: Readonly<Record<Button, string>> = {
5 handoff: 'Hand off and clear',
6 'handoff-send': 'Hand off and send it',
7 'send-here': 'Send here',
8 compact: 'Compact',
9 'not-now': 'Not now',
10}
11
12export const BAND_HINT = 'Set handoffMode to act in /config to do this without asking.'
13
14const question = ({ signal, heldPrompt, isBusy }: Offer): string => {
15 if (heldPrompt) return 'Hand off and send it in a fresh session, or send it here?'
16 if (signal === 'strong') return 'Hand off to a fresh session now?'
17 if (isBusy) return 'A handoff would orphan that work, so compact instead?'
18 return 'Hand off to a fresh session, or compact?'
19}
20
21const situation = ({ signal, contextTokens, heldPrompt, isBusy }: Offer): string => {
22 const context = `Context is ${formatTokens(contextTokens)}`
23 if (heldPrompt) return `${context} and this prompt starts new work.`
24 if (signal === 'strong') return `${context} and the task just finished.`
25 return isBusy ? `${context} and background work is still running.` : `${context}.`
26}
27
28export const bandMessage = (offer: Offer): string =>
29 `${situation(offer)} Accuracy drops as context grows. ${question(offer)}`
30hooks/decision-record.ts 44 lines1import type { Mode, Signal } from './signals'
2
3export type DecisionAction = 'none' | 'advised' | 'cleared' | 'compacted' | 'untested_engine'
4
5export type TriggerValues = {
6 point: 'turn-end' | 'prompt' | 'command' | 'button' | 'session-start'
7 signal: Signal
8 context_tokens: number
9 threshold: number
10 is_background_busy: boolean
11 setting: Mode
12 cache_read_tokens: number
13 reason?: string
14}
15
16type RecordInput = {
17 now: number
18 sessionId: string
19 action: DecisionAction
20 engineVersion: string
21 effectiveMode: Mode
22 triggerValues: TriggerValues
23}
24
25export const LOG_WRITER = ['sh', '-c', 'umask 077 && mkdir -p "$1" && cat >> "$2"', 'sh'] as const
26
27export const decisionRecord = ({ now, sessionId, action, engineVersion, effectiveMode, triggerValues }: RecordInput) => ({
28 ts: new Date(now).toISOString(),
29 session_id: sessionId,
30 component: 'handoff',
31 mode: action === 'none' && effectiveMode === 'off' && triggerValues.point !== 'command' ? 'shadow' : 'active',
32 action,
33 engine_version: engineVersion,
34 trigger_values: triggerValues,
35})
36
37export const logLocation = (
38 thriftHome: string | undefined,
39 home: string | undefined,
40): { dir: string; file: string } | null => {
41 const dir = thriftHome ? thriftHome.replace(/\/+$/, '') : home ? `${home.replace(/\/+$/, '')}/.claude/thrift` : null
42 return dir === null ? null : { dir, file: `${dir}/decisions.jsonl` }
43}
44hooks/engine-version.ts 18 lines1export const TESTED_THROUGH = '2.1.295'
2
3const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/
4
5const core = (version: string): number[] | null => {
6 const match = RELEASE.exec(version)
7 return match ? match.slice(1).map(Number) : null
8}
9
10const isNewer = (candidate: readonly number[], reference: readonly number[]): boolean =>
11 candidate.map((part, index) => Math.sign(part - Number(reference[index]))).find(order => order !== 0) === 1
12
13export const isUntestedEngine = (base: string | undefined, testedThrough: string = TESTED_THROUGH): boolean => {
14 const release = base === undefined ? null : core(base)
15 const tested = core(testedThrough)
16 return release === null || tested === null || isNewer(release, tested)
17}
18hooks/handoff-note.ts 27 lines1export const HANDOFF_PROMPT = `Write a handoff for the next session, which will start fresh with only this note.
2Cover, under short headings:
3- The task and its goal.
4- What is done: commits, branches, PRs, and files, with paths.
5- Decisions made, and why.
6- Open threads.
7- The next concrete step.
8- Facts the next session would otherwise rediscover.
9Use plain Markdown, at most 400 words, and no preamble.`
10
11export const handoffMessage = (oldSessionId: string, note: string): string =>
12 `Handoff from the previous session (${oldSessionId}), written by Claude just before a /clear:\n\n${note}`
13
14export const resumeCommand = (sessionId: string): string => `claude --resume ${sessionId}`
15
16export const joinPrompts = (handoff: string, held: string | undefined): string =>
17 held ? `${handoff}\n\n${held}` : handoff
18
19export const holdsPrompt = (draft: string, prompt: string): boolean => `\n${draft}\n`.includes(`\n${prompt}\n`)
20
21export const withoutPrompt = (draft: string, prompt: string): string | null => {
22 const padded = `\n${draft}\n`
23 const promptStart = padded.indexOf(`\n${prompt}\n`)
24 if (promptStart === -1) return null
25 return `${padded.slice(0, promptStart)}${padded.slice(promptStart + prompt.length + 1)}`.slice(1, -1)
26}
27hooks/session-state.ts 53 lines1import type { Mode } from './signals'
2
3type Settings = {
4 setting: Mode
5 threshold: number
6 compactBeforeClear: boolean
7}
8
9export type SessionState = Settings & {
10 mode: Mode
11 engineVersion: string
12 contextTokens: number
13 cacheReadTokens: number
14 seenRefs: Set<string>
15 urlPrefixes: Set<string>
16 backgroundTasks: Set<string>
17 hasFinishedTask: boolean
18 isTurnUnattended: boolean
19 backoffFrom: number | null
20 pending: 'handoff' | 'compact' | null
21 heldPrompt: string | null
22 hasBand: boolean
23}
24
25export const createState = (settings: Settings): SessionState => ({
26 ...settings,
27 mode: 'off',
28 engineVersion: 'unknown',
29 contextTokens: 0,
30 cacheReadTokens: 0,
31 seenRefs: new Set(),
32 urlPrefixes: new Set(),
33 backgroundTasks: new Set(),
34 hasFinishedTask: false,
35 isTurnUnattended: false,
36 backoffFrom: null,
37 pending: null,
38 heldPrompt: null,
39 hasBand: false,
40})
41
42export const resetForNewSession = (state: SessionState): void => {
43 state.contextTokens = 0
44 state.cacheReadTokens = 0
45 state.seenRefs.clear()
46 state.urlPrefixes.clear()
47 state.backgroundTasks.clear()
48 state.hasFinishedTask = false
49 state.isTurnUnattended = false
50 state.backoffFrom = null
51 state.heldPrompt = null
52}
53hooks/signals.ts 278 lines1export type Mode = 'off' | 'ask' | 'act'
2export type Signal = 'none' | 'strong' | 'weak'
3type StoppingPoint = 'finished-task' | 'new-work' | null
4export type Button = 'handoff' | 'handoff-send' | 'send-here' | 'compact' | 'not-now'
5
6type Response =
7 | { kind: 'none' }
8 | { kind: 'handoff'; holdsPrompt: boolean }
9 | { kind: 'compact' }
10 | { kind: 'advise'; holdsPrompt: boolean; buttons: readonly Button[] }
11
12const MODES: readonly Mode[] = ['off', 'ask', 'act']
13const isMode = (value: unknown): value is Mode => MODES.some(mode => mode === value)
14const TICKET_TOKEN = /\b([A-Za-z][A-Za-z0-9]{1,9})-(\d+)\b/g
15const TICKET_URL = /https?:\/\/\S*?\/(?:issue|browse)\/([A-Za-z][A-Za-z0-9]{1,9})-\d+/gi
16const PR_REFERENCE = /\b(?:pr|issue|pull request)\s*#?(\d+)\b/gi
17const PR_URL = /\/(?:pull|issues)\/(\d+)\b/g
18const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
19const MESSAGE_FLAG = /^(?:-[A-Za-z]*m|--message)$/
20const DRY_RUN_SHORT_FLAG = /^-[A-Za-z]*n[A-Za-z]*$/
21const HEREDOC_WORD_END = /[\s;|&<>()]/
22const SEPARATORS = new Set([';', '|', '&'])
23
24export const asMode = (value: unknown): Mode =>
25 isMode(value) ? value : 'off'
26
27const DEFAULT_THRESHOLD = 150_000
28const MIN_THRESHOLD = 80_000
29const MAX_THRESHOLD = 2_000_000
30
31export const asThreshold = (value: unknown): number =>
32 typeof value === 'number' && Number.isFinite(value)
33 ? Math.min(MAX_THRESHOLD, Math.max(MIN_THRESHOLD, value))
34 : DEFAULT_THRESHOLD
35
36type StepSize = {
37 input: number
38 created: number
39 read: number
40 advisorCalls: number
41 reported: number | undefined
42}
43
44export const contextFromStep = ({ input, created, read, advisorCalls, reported }: StepSize): number => {
45 const perCall = (input + created + read) / (1 + advisorCalls)
46 return Math.round(reported === undefined ? perCall : Math.min(reported, perCall))
47}
48
49export const formatTokens = (tokens: number): string => `${Math.round(tokens / 1000)}k`
50
51type Heredoc = { word: string; stripsTabs: boolean }
52
53const readHeredoc = (command: string, start: number): { heredoc: Heredoc; end: number } | null => {
54 let index = start + 2
55 const stripsTabs = command[index] === '-'
56 if (stripsTabs) index += 1
57 while (command[index] === ' ' || command[index] === '\t') index += 1
58 const quote = command[index] === "'" || command[index] === '"' ? command[index] : null
59 if (quote) index += 1
60 let word = ''
61 while (index < command.length) {
62 const char = command.charAt(index)
63 if (quote ? char === quote : HEREDOC_WORD_END.test(char)) break
64 word += char
65 index += 1
66 }
67 if (word === '') return null
68 return { heredoc: { word, stripsTabs }, end: quote ? index + 1 : index }
69}
70
71const skipHeredocBody = (command: string, start: number, heredoc: Heredoc): number => {
72 let index = start
73 while (index < command.length) {
74 const lineEnd = command.indexOf('\n', index)
75 const end = lineEnd === -1 ? command.length : lineEnd
76 const line = command.slice(index, end)
77 index = end + 1
78 if ((heredoc.stripsTabs ? line.replace(/^\t+/, '') : line) === heredoc.word) break
79 }
80 return Math.min(index, command.length)
81}
82
83export const commandSegments = (command: string): string[] => {
84 const segments: string[] = []
85 const pending: Heredoc[] = []
86 let current = ''
87 let quote: string | null = null
88 let index = 0
89
90 const endSegment = () => {
91 if (current.trim() !== '') segments.push(current.trim())
92 current = ''
93 }
94 const isWordStart = () => index === 0 || /\s/.test(command.charAt(index - 1)) || SEPARATORS.has(command.charAt(index - 1))
95
96 while (index < command.length) {
97 const char = command.charAt(index)
98 const next = command[index + 1]
99
100 if (quote === "'" || (quote === '"' && char !== '\\')) {
101 current += char
102 if (char === quote) quote = null
103 index += 1
104 } else if (char === '\\' && next === '\n') {
105 index += 2
106 } else if (char === '\\' && next !== undefined) {
107 current += char + next
108 index += 2
109 } else if (quote) {
110 current += char
111 index += 1
112 } else if (char === "'" || char === '"') {
113 quote = char
114 current += char
115 index += 1
116 } else if (char === '#' && isWordStart()) {
117 while (index < command.length && command[index] !== '\n') index += 1
118 } else if (char === '\n') {
119 endSegment()
120 index += 1
121 for (const heredoc of pending.splice(0)) index = skipHeredocBody(command, index, heredoc)
122 } else if (SEPARATORS.has(char)) {
123 endSegment()
124 index += 1
125 } else if (command.startsWith('<<', index)) {
126 const opened = readHeredoc(command, index)
127 if (opened) {
128 pending.push(opened.heredoc)
129 current += command.slice(index, opened.end)
130 index = opened.end
131 } else {
132 current += '<<'
133 index += 2
134 }
135 } else {
136 current += char
137 index += 1
138 }
139 }
140 endSegment()
141 return segments
142}
143
144const words = (segment: string): string[] => {
145 const parsedWords: string[] = []
146 let word = ''
147 let hasWord = false
148 let quote: string | null = null
149
150 for (let index = 0; index < segment.length; index += 1) {
151 const char = segment.charAt(index)
152 if (quote === "'") {
153 if (char === "'") quote = null
154 else word += char
155 } else if (char === '\\' && index + 1 < segment.length) {
156 index += 1
157 word += segment.charAt(index)
158 hasWord = true
159 } else if (quote === '"') {
160 if (char === '"') quote = null
161 else word += char
162 } else if (char === "'" || char === '"') {
163 quote = char
164 hasWord = true
165 } else if (/\s/.test(char)) {
166 if (hasWord) parsedWords.push(word)
167 word = ''
168 hasWord = false
169 } else {
170 word += char
171 hasWord = true
172 }
173 }
174 if (hasWord) parsedWords.push(word)
175 return parsedWords
176}
177
178const withoutMessageValues = (args: readonly string[]): string[] =>
179 args.filter((_, index) => !MESSAGE_FLAG.test(args[index - 1] ?? ''))
180
181const finishesWithGit = (rest: readonly string[]): boolean => {
182 let index = 0
183 while (rest[index] === '-C') index += 2
184 const subcommand = rest[index]
185 const args = rest.slice(index + 1)
186 if (subcommand === 'commit') return !withoutMessageValues(args).includes('--dry-run')
187 if (subcommand === 'push') return !args.includes('--dry-run') && !args.some(arg => DRY_RUN_SHORT_FLAG.test(arg))
188 return false
189}
190
191const finishesWithGh = (rest: readonly string[]): boolean =>
192 rest[0] === 'pr' && (rest[1] === 'create' || rest[1] === 'merge') && !rest.includes('--dry-run')
193
194const finishesSegment = (segment: string): boolean => {
195 const segmentWords = words(segment)
196 const start = segmentWords.findIndex(word => !ASSIGNMENT.test(word))
197 if (start === -1) return false
198 const [program, ...rest] = segmentWords.slice(start)
199 if (program === 'git') return finishesWithGit(rest)
200 if (program === 'gh') return finishesWithGh(rest)
201 return false
202}
203
204export const finishesTask = (command: string): boolean => commandSegments(command).some(finishesSegment)
205
206const unique = (values: Iterable<string>): string[] => [...new Set(values)]
207
208export const ticketUrlPrefixes = (text: string): string[] =>
209 unique([...text.matchAll(TICKET_URL)].map(([, prefix = '']) => prefix.toUpperCase()))
210
211export const branchTicketPrefix = (branch: string): string[] =>
212 unique([...branch.matchAll(TICKET_TOKEN)].map(([, prefix = '']) => prefix.toUpperCase()))
213
214export const hasTicketShapedToken = (text: string): boolean => new RegExp(TICKET_TOKEN.source).test(text)
215
216export const workRefs = (text: string, prefixes: readonly string[]): string[] => {
217 const known = new Set(prefixes.map(prefix => prefix.toUpperCase()))
218 const tickets = [...text.matchAll(TICKET_TOKEN)]
219 .map(([, prefix = '', digits = '']) => [prefix.toUpperCase(), digits] as const)
220 .filter(([prefix]) => known.has(prefix))
221 .map(([prefix, digits]) => `${prefix}-${digits}`)
222 const pullRequests = [...text.matchAll(PR_REFERENCE), ...text.matchAll(PR_URL)].map(([, digits = '']) => `#${digits}`)
223 return unique([...tickets, ...pullRequests])
224}
225
226export const namesNewWork = (text: string, seen: ReadonlySet<string>, prefixes: readonly string[]): boolean => {
227 const named = workRefs(text, prefixes)
228 return seen.size > 0 && named.length > 0 && named.every(ref => !seen.has(ref))
229}
230
231type ClassifyInput = {
232 contextTokens: number
233 threshold: number
234 isStoppingPoint: boolean
235 isBackgroundBusy: boolean
236}
237
238export const classify = ({ contextTokens, threshold, isStoppingPoint, isBackgroundBusy }: ClassifyInput): Signal => {
239 if (contextTokens < threshold) return 'none'
240 return isStoppingPoint && !isBackgroundBusy ? 'strong' : 'weak'
241}
242
243type RespondInput = {
244 mode: Mode
245 signal: Signal
246 stoppingPoint: StoppingPoint
247 isBackgroundBusy: boolean
248 isUnattended: boolean
249}
250
251const NONE: Response = { kind: 'none' }
252
253type BandShape = { signal: Signal; heldPrompt: boolean; isBusy: boolean }
254
255export const bandButtons = ({ signal, heldPrompt, isBusy }: BandShape): readonly Button[] => {
256 if (heldPrompt) return ['handoff-send', 'send-here']
257 if (signal === 'strong') return ['handoff', 'not-now']
258 return isBusy ? ['compact', 'not-now'] : ['handoff', 'compact', 'not-now']
259}
260
261const respondToStrong = (mode: 'ask' | 'act', stoppingPoint: StoppingPoint): Response => {
262 const holdsPrompt = stoppingPoint === 'new-work'
263 if (mode === 'act') return { kind: 'handoff', holdsPrompt }
264 const buttons = bandButtons({ signal: 'strong', heldPrompt: holdsPrompt, isBusy: false })
265 return { kind: 'advise', holdsPrompt, buttons }
266}
267
268const respondToWeak = (mode: 'ask' | 'act', isBusy: boolean): Response => {
269 if (mode === 'act') return { kind: 'compact' }
270 return { kind: 'advise', holdsPrompt: false, buttons: bandButtons({ signal: 'weak', heldPrompt: false, isBusy }) }
271}
272
273export const respond = ({ mode, signal, stoppingPoint, isBackgroundBusy, isUnattended }: RespondInput): Response => {
274 if (mode === 'off' || signal === 'none') return NONE
275 if (isUnattended) return signal === 'strong' ? { kind: 'handoff', holdsPrompt: stoppingPoint === 'new-work' } : NONE
276 return signal === 'strong' ? respondToStrong(mode, stoppingPoint) : respondToWeak(mode, isBackgroundBusy)
277}
278hooks/tasks.ts 11 lines1const TASK_ID = /^[A-Za-z0-9_-]+$/
2const NOTIFIED_TASK = /<task-id>([^<]*)<\/task-id>/
3
4export const validTaskId = (value: unknown): string | null =>
5 typeof value === 'string' && TASK_ID.test(value) ? value : null
6
7export const fieldOf = (value: unknown, key: string): unknown =>
8 typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
9
10export const notifiedTaskId = (promptText: string): string | null => validTaskId(NOTIFIED_TASK.exec(promptText)?.[1])
11types/index.d.ts 13 lines1export type Offer = {
2 signal: 'strong' | 'weak'
3 contextTokens: number
4 heldPrompt: boolean
5 isBusy: boolean
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 handoff: { offer: Offer | null }
11 }
12}
13