SLOPSHOPPER

handoff

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

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-09mikelane/thrift/plugins/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ handoff: Writing a handoff for a fresh session... ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

handoff

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.

Install

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

Modes

handoffMode is off by default. Change it in /config.

ModeWhat it does
offActs on nothing. Logs a shadow record for every evaluation, so you can see what ask or act would have done.
askShows a band above the prompt with buttons. Nothing happens until you press one.
actHands off or compacts on its own.

/handoff hands off at any time, in every mode, including off.

How it works

  1. It measures the context. After each step of the main loop it takes the step's input, cache creation, and cache read tokens. A step that included advisor calls reports the main model's calls summed, so the total is divided by 1 + the advisor calls, and the smaller of that and the session's reported size is the context size. Subagent steps are ignored.
  2. It finds a stopping point. At the end of each answered turn, and when a prompt arrives, it asks whether this is a quiet moment to move on. See the signal table.
  3. It responds by mode. Over the threshold, a quiet moment is a strong signal and anything else is a weak one.
  4. It hands off. The steps, in order:
  5. Take down the band and show the toast "Writing a handoff for a fresh session...".
  6. Fork the session with a fixed prompt. The fork reads the warm cache. It asks for the task and its goal, what is done (commits, branches, PRs, files, with paths), decisions and why, open threads, the next concrete step, and facts the next session would otherwise rediscover, in at most 400 words of plain Markdown.
  7. If compactBeforeClear is on, compact the old session. A failed or vetoed compaction is logged and the handoff goes on.
  8. Run /clear.
  9. Append the handoff as a message the model reads and you do not see, prefixed "Handoff from the previous session (<id>), written by Claude just before a /clear".
  10. Write a transcript line and a toast naming the old session id and claude --resume <id>.
  11. If a prompt was held, submit it in the fresh session.

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.

Signals

The signal is none under the threshold. At or over it:

SignalWhen
strongA finished task, or a prompt that names new work, and no background work is running
weakAnything 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.
  • The command is split at unquoted ;, |, &, &&, ||, 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 command that was moved to the background has not finished, so it does not count.

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.

ReferenceCounts asRule
ENG-12A ticketOnly 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 12A PR or issueCase-insensitive.
.../pull/12, .../issues/12A PR or issueAny URL.
#12NothingA 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.

What each mode does

Signaloffaskact
Strong, finished taskLog onlyBand: Hand off and clear, Not nowHand off
Strong, prompt names new workLog onlyHold 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 runningLog onlyBand: Compact, Not now. No handoff button.Compact
Weak, otherwiseLog onlyBand: Hand off and clear, Compact, Not nowCompact

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:

  • Unattended prompts. A prompt from a scheduled task, routine, or /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.
  • Non-interactive runs. Under claude -p or the SDK the plugin only logs. The process can exit before a handoff ends, and a held prompt would be lost.
  • Prompts never held. A prompt that carries context or attachments (only its text can be resent), a prompt typed while a turn is running, a prompt from a plugin or another session (only the person origins composer, bridge, sdk, and scheduled-trigger count), and any prompt that arrives while a handoff or compaction is pending.
  • One at a time. A handoff or compaction is claimed when it is scheduled, not when it starts. A turn that ends while one is pending is not evaluated, and a second Compact press is ignored.

The band

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.

  • The band comes down when the next prompt or task notification starts a turn, so a button press can never clear a session in the middle of a turn.
  • A band still up when the plugin reloads (a code change, or a setting changed in /config) comes down when the reload fires session.start (not yet verified live for a /config change).
  • Pressing Send here or Hand off and send it takes the held prompt out of the box and leaves any draft typed beside it.

Backoff

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.

When something fails

  • The fork returns no answer, or anything throws before the clear. Nothing is cleared. A toast says no handoff was written and the session is unchanged. A held prompt goes back in the box when someone is at the prompt. When nobody is, or the box will not take it, the prompt is submitted in the unchanged session.
  • /clear throws after the fork succeeded. The session is unchanged and the prompt is handled the same way. A scheduled prompt still runs.
  • The append is refused after the clear. The handoff exists nowhere else, so it is submitted as a prompt, joined to any held prompt.
  • The prompt cannot be sent after the clear, or Send here cannot send it. When someone is at the prompt, it goes back in the box (joined to the handoff if the append was refused). A box that already holds it as a whole line is left alone.
  • A prompt can be neither sent nor put back in the box. It is written to the transcript (not the decision log), joined to the handoff if the append was refused, so it can be copied back.
  • Two triggers at once. The first to claim the handoff wins. The other prompt is not dropped.
  • The decision log cannot be written. The failure goes to the debug log and the turn goes on.

Settings

Set these in /config.

SettingTypeDefaultWhat it does
handoffModeoff, ask, actoffSee Modes.
handoffContextTokensnumber, 80,000 to 2,000,000150000The context size at which a signal becomes strong or weak. A fresh session starts near 48,000 tokens.
compactBeforeClearbooleanfalseCompact 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.

Decision log

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}}
FieldValues
tsISO 8601 UTC
session_idThe session the decision is about. A cleared record carries the old session's id.
componentAlways handoff
modeshadow 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.
actionnone, advised (a band was shown), cleared, compacted, or untested_engine
engine_versionThe 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.pointturn-end, prompt, command, button, or session-start
trigger_values.signalnone, weak, or strong
trigger_values.context_tokensThe context size when the decision was made
trigger_values.thresholdhandoffContextTokens
trigger_values.is_background_busyWhether background work was running
trigger_values.settingThe configured handoffMode (the plugin may act as off anyway; see Compatibility)
trigger_values.cache_read_tokensCache-read tokens of the last answered turn
trigger_values.reasonPresent 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.

Compatibility

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:

  1. CI. Every pull request runs 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.
  2. Engine assumptions. Behavior the types do not state is pinned in 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.
  3. A runtime fallback. 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.
  4. engine_version on every log record.
ReleaseTested through Claude Code
0.1.02.1.295

What has not been verified

Everything above is covered by tests against the engine's own test kit. These need a live terminal and have not been watched yet:

  • How the band renders, and that each button does what its label says.
  • That /handoff survives a /clear, and that the plugin's re-registration after a clear works.
  • That the advisor's call count is 1 + the advisor entries in serverToolUses, and that $.session.usage().context.tokens is not a step behind the step's own total.
  • That claude --resume <old id> reopens the old session after a handoff.
  • That a held prompt refills the box, and what the box does across a /clear.
  • That a background shell turns a handoff into a compact.

Development

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.

FileWhat it holds
hooks/register.tsxEngine wiring. Every function that takes $ lives here, because the engine follows $ only through functions declared at the top of this file.
hooks/signals.tsPure signal logic: command parsing, work references, classification, the mode table
hooks/engine-version.tsTESTED_THROUGH and the untested-build check
hooks/decision-record.tsThe log record and the log location
hooks/handoff-note.tsHANDOFF_PROMPT and the handoff message
hooks/band-text.tsThe band's wording
hooks/tasks.tsBackground task id helpers
hooks/session-state.tsThe per-session state object
types/index.d.tsThe band's Offer atom
Source 9 files
hooks/register.tsx 652 lines
1import { 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}
652
hooks/band-text.ts 30 lines
1import 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)}`
30
hooks/decision-record.ts 44 lines
1import 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}
44
hooks/engine-version.ts 18 lines
1export 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}
18
hooks/handoff-note.ts 27 lines
1export 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}
27
hooks/session-state.ts 53 lines
1import 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}
53
hooks/signals.ts 278 lines
1export 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}
278
hooks/tasks.ts 11 lines
1const 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])
11
types/index.d.ts 13 lines
1export 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