SLOPSHOPPER

beacon

Beacon: prefixes the session title with one glyph that says what the session is doing (working, waiting on background work, needs your answer, done), so the…

newguardpromptmodeltimer
v0.1.0MITupdated 2026-10-25VaitaR/claude-code-beacon
A shopper browsing a rack in a slop shop
README

Beacon

A Claude Code mod for the desktop app's Code tab. It keeps one glyph at the front of the session title, so the sidebar says what every session is doing without opening it:

GlyphStateSet when
🔵workinga turn is running
⏳waitingthe turn ended with background tasks or a session cron still in flight, or one foreground tool call has run longer than slowSeconds
❓needs youa permission request, AskUserQuestion, plan approval or a start_session "New session / Do it here" card is open; the turn left a proposed-session card or ended on a wrap_up with an ask; or the last reply asks you for a decision, answer or confirmation
✅donethe turn ended, nothing is in flight, the last reply asks for nothing
⚠️errorthe turn ended on an API error, rate limit, overflow or a refusal, or a ⏳ session has waited staleMinutes without a wake-up; an interrupt by you stays ✅

A settled glyph (❓ ✅ ⚠️) is followed by the local time the session settled, ✅ 14:05 Fix the parser: a title cannot tick, so it is a clock time, not an age. A reload keeps the time already in the title. With markUnread on, a turn that ends ❓, ✅ or ⚠️ also sets the sidebar's blue unread dot, so the dot marks results not yet opened; an interrupt by you sets none.

The sidebar's own icon column shows the session's kind (pull request, worktree) and only one dot for "needs input"; it cannot tell a prose question from a report, nor a session paused on background work from one that is finished. The title is the one row element a mod can write.

How "needs you" is read

A turn that ends on the desktop app's wrap_up tool has no closing text of its own: a non-empty ask or blocked makes it ❓ outright, and otherwise its recap is read as the reply.

In order, on the last reply's closing lines (a trailing postmortem block of friction: / key: value lines is dropped first):

  1. the convention line NEEDS YOU: <what> (see below), at the start of a line anywhere in the last ~2,000 characters, so a code block after it does not hide it;
  2. a closing line that ends with ?, or English/Russian phrasings of a request for a decision;
  3. a reply with neither is done. With classify on, a reply over 400 characters goes to the engine's small model ($.model.classify) instead; off by default, because that model reads a long report that merely mentions a possible choice as a question.

Tool-driven asks (permission dialogs, AskUserQuestion, ExitPlanMode, a start_session call with initiation: "offer") are seen directly and need no reading. A start_session card proposed with initiation: "own_initiative" and not withdrawn by dismiss_task in the same turn makes the turn end ❓.

The first rule is the reliable one. Add this to your ~/.claude/CLAUDE.md so the model writes the line whenever it stops for you:

- Stop only for what needs the person (a decision, an answer, a required approval). End that
  stop with `NEEDS YOU: <what, one line>`. A reply that needs nothing ends without a question.

Install

Inside Claude Code (2.1.287 or later):

/plugin marketplace add VaitaR/claude-code-beacon
/plugin install beacon@claude-code-beacon
/reload-plugins

From a clone, for the desktop app: it starts sessions without --plugin-dir, so add the clone to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (env block, :-separated with other mod folders). A session picks it up when the app starts its process: a new session, or an idle one on its next message. A process already running when the setting changed never does.

In a terminal session the app's ccd_session_mgmt server is absent: the first rename fails with "no connected MCP tool", is logged once, and the mod stays quiet. A desktop session (CLAUDE_CODE_ENTRYPOINT=claude-desktop) resumed with history fails the same way at start, because the server connects later; there the 10-second tick retries until it is up.

Desktop widget

Each state change also writes ~/.claude/state/beacon/<engine session id>.json, which widget/ lists in a floating macOS panel. Setup, behaviour and the facts behind it: widget/README.md.

Options (pluginConfigs.beacon.options)

OptionDefaultMeaning
slowSeconds60foreground tool call length that turns 🔵 into ⏳
staleMinutes60how long a ⏳ session may wait for its wake-up before it turns ⚠️ (keep it above the longest wait you expect a session to schedule)
classifyfalseask the small model when the heuristics cannot read a long reply
markUnreadtrueset the sidebar's unread dot when a turn settles
debugLog""absolute path for the mod's diagnostics; each session writes its own file, the first 8 characters of its id inserted before .log

Limits

  • The rename is an MCP call made under your own PreToolUse hooks. A hook that denies a repeated identical MCP call refuses the first attempt; the mod retries once. Exempt ccd_session_mgmt__get_session and ccd_session_mgmt__set_session_title from such a hook to save the extra calls.
  • A reload of the mod (an edit, a lost hooks worker) keeps the background task ids in host state ($.state, beacon.pending) and rereads the last reply, but not how the last turn ended: a ⚠️ session shows ✅ or ❓ until its next turn. A new process starts with no ids, as its tasks died with the old one.
  • A session card's fate is not visible to the mod: a card started or left alone after the turn keeps ❓ until the session's next turn. The open-card count is lost on a reload.
  • Background task ids are read only from Bash and Agent results; any other tool's output may quote the announcement text.
  • A rename at session.end fails when the process is already unbound from the app, so a closed session can keep 🔵 or ⏳ until it is opened again.
  • The glyph is not written before the app has generated the session's title, during a new session's first turn; it follows within one 10-second tick.

Develop

claude plugin validate .
claude plugin test .

License

MIT, see LICENSE.

Source 3 files
hooks/register.ts 415 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import {
4  type CmuxInfo,
5  type Config,
6  type State,
7  DISMISS_TASK,
8  WRAP_UP,
9  type WrapUp,
10  asksInline,
11  classifyTail,
12  clockOf,
13  cmuxOf,
14  endingOf,
15  inFlightTasks,
16  isPlaceholderTitle,
17  isSlow,
18  isStale,
19  launchesTasks,
20  mcpFailureOf,
21  notifiedTasks,
22  parseConfig,
23  proposes,
24  type SessionInfo,
25  sessionOf,
26  startedTasks,
27  statusFileName,
28  statusRecord,
29  titleOf,
30  turnVerdictOf,
31  unansweredState,
32  withGlyph,
33  wrapUpOf,
34} from './core'
35
36// The desktop app's session server, as `$.mcp.call` names it. A terminal session has none: the
37// first call then fails with "no connected MCP tool", logged once, and the mod goes quiet. A
38// desktop session resumed with history fails the same way at session.start, because the server
39// connects after it: there the failure is transient and the tick retries. A headless child of a
40// desktop session inherits its entrypoint but has no session bound: it goes quiet too.
41const SERVER = 'ccd_session_mgmt'
42const DESKTOP_ENTRYPOINT = 'claude-desktop'
43const SIDEBAR = 'ccd_sidebar'
44const TICK_MS = 10_000
45
46// Module state is lost on a hot reload; session.start then runs again and recomputes it.
47let cfg: Config = parseConfig({})
48let state: State | null = null
49let turnActive = false
50let asking = false
51let inFlight: { tool: string; at: number } | null = null
52let waitingSince: number | null = null
53// The settled state's clock stamp; undefined keeps the one already in the title (a reload).
54let stamp: string | undefined
55// Session cards this turn left for the person to start: the turn then ends on a decision.
56let proposals = 0
57// The turn's wrap_up call, if it ended on one: its ask decides, its recap stands in for the answer.
58let wrapUp: WrapUp | null = null
59let renaming: Promise<void> = Promise.resolve()
60let mcpBroken = false
61// A headless child (an eval, a worker run) is no session the person sees: no widget entry either.
62let unbound = false
63let desktop = false
64// A new session's title is generated during its first turn, after turn.start, and a resumed
65// session's server connects after session.start: the tick retries the rename until both exist.
66let untitled = false
67let serverLate = false
68// What the status file for the desktop widget needs beyond the state: the app's session id and
69// link come from get_session; a terminal session (no app server) has neither. The file itself is
70// named by the engine's session id, the id a cleanup script can match to ~/.claude/projects/*/<id>.jsonl.
71let info: SessionInfo | null = null
72let lastTitle: string | null = null
73let ended = false
74let statusDir: Promise<string | null> | null = null
75let cmux: Promise<CmuxInfo | null> | null = null
76const lines: string[] = []
77// The file outlives a hot reload while `lines` does not: the first write seeds from its tail.
78// One file per session: every session rewrites its whole file, so a shared one loses lines.
79let logPath: Promise<string> | null = null
80
81function debug($: Engine, text: string) {
82  $.ui.log(`beacon: ${text}`, { to: 'debug' })
83  if (cfg.debugLog === '') return
84  logPath ??= $.session
85    .id()
86    .then(id => cfg.debugLog.replace(/(\.log)?$/, `.${id.slice(0, 8)}$1`))
87    .then(path =>
88      $.fs
89        .read(path)
90        .then(old => { lines.unshift(...old.split('\n').filter(l => l !== '').slice(-150)) })
91        .catch(() => undefined)
92        .then(() => path),
93    )
94  lines.push(`${new Date().toISOString()} ${text}`)
95  void logPath.then(path => $.fs.write(path, `${lines.slice(-200).join('\n')}\n`)).catch(() => undefined)
96}
97
98// The call runs under the person's own PreToolUse hooks. One of them denies a repeated identical
99// MCP call once and lets the immediate repeat through, so a refusal is retried exactly once.
100async function callSession($: Engine, tool: string, args: Record<string, unknown>) {
101  try {
102    return await $.mcp.call(SERVER, tool, args)
103  } catch (err) {
104    const message = err instanceof Error ? err.message : String(err)
105    const failure = mcpFailureOf(message)
106    if (failure === 'no-session') {
107      mcpBroken = true
108      unbound = true
109      throw err
110    }
111    if (failure === 'no-server') {
112      if (desktop) {
113        if (!serverLate) debug($, `${SERVER} not connected yet, retrying on the tick`)
114        serverLate = true
115        untitled = true
116      } else mcpBroken = true
117      throw err
118    }
119    debug($, `${tool} refused once, retrying: ${message.slice(0, 160)}`)
120    return $.mcp.call(SERVER, tool, args)
121  }
122}
123
124// One rename at a time, in order: two overlapping get/set pairs would race on the title.
125function rename($: Engine, next: State | null) {
126  renaming = renaming.then(async () => {
127    if (!mcpBroken) await retitle($, next)
128    await writeStatus($, next)
129  })
130}
131
132async function retitle($: Engine, next: State | null) {
133  try {
134    const got = await callSession($, 'get_session', { session_id: 'self' })
135    if (got.isError) {
136      debug($, `get_session failed: ${JSON.stringify(got.content).slice(0, 200)}`)
137      return
138    }
139    if (serverLate) debug($, `${SERVER} connected`)
140    serverLate = false
141    info = sessionOf(got) ?? info
142    const title = titleOf(got)
143    if (title !== null) lastTitle = title
144    if (title === null || isPlaceholderTitle(title)) {
145      if (!untitled) debug($, `no title to prefix: ${JSON.stringify(got.content).slice(0, 120)}`)
146      untitled = true
147      return
148    }
149    untitled = false
150    const wanted = withGlyph(title, next, stamp)
151    if (wanted === title) return
152    const set = await callSession($, 'set_session_title', { session_id: 'self', title: wanted })
153    if (set.isError) debug($, `set_session_title failed: ${JSON.stringify(set.content).slice(0, 200)}`)
154    else debug($, `title set: ${wanted}`)
155  } catch (err) {
156    if (serverLate) return
157    debug($, `rename failed${mcpBroken ? ', going quiet' : ''}: ${err instanceof Error ? err.message : String(err)}`)
158  }
159}
160
161async function writeStatus($: Engine, next: State | null) {
162  try {
163    statusDir ??= $.env.get('HOME').then(home => (home ? `${home}/.claude/state/beacon` : null))
164    const dir = await statusDir
165    if (dir === null || unbound) return
166    if (info === null && mcpBroken) {
167      info = { id: await $.session.id(), cwd: await $.session.cwd(), link: '', archived: false }
168    }
169    if (info === null) return
170    const title = lastTitle ?? info.cwd.split('/').pop() ?? ''
171    cmux ??= Promise.all([
172      $.env.get('CMUX_WORKSPACE_ID'),
173      $.env.get('CMUX_PANEL_ID'),
174      $.env.get('CMUX_SURFACE_ID'),
175      $.env.get('CMUX_SOCKET_PATH'),
176      $.env.get('CMUX_SOCKET_CAPABILITY'),
177      $.env.get('CMUX_BUNDLE_ID'),
178    ]).then(([w, p, s, path, cap, bundle]) =>
179      cmuxOf({ CMUX_WORKSPACE_ID: w, CMUX_PANEL_ID: p, CMUX_SURFACE_ID: s, CMUX_SOCKET_PATH: path, CMUX_SOCKET_CAPABILITY: cap, CMUX_BUNDLE_ID: bundle }),
180    )
181    const file = statusFileName(await $.session.id())
182    await $.fs.write(`${dir}/${file}`, statusRecord(info, title, next, ended, Date.now(), await cmux))
183  } catch (err) {
184    debug($, `status write failed: ${err instanceof Error ? err.message : String(err)}`)
185  }
186}
187
188function publish($: Engine, next: State | null, reloaded = false) {
189  if (next === state) return
190  state = next
191  const now = Date.now()
192  waitingSince = next === 'waiting' ? now : null
193  stamp = reloaded ? undefined : clockOf(now)
194  rename($, next)
195}
196
197// The app clears the dot when the session is opened, so a settled result reads as unseen until then.
198function markUnread($: Engine) {
199  if (!cfg.markUnread || !desktop || mcpBroken) return
200  void $.mcp
201    .call(SIDEBAR, 'set_unread', { session_id: 'self', unread: true })
202    .then(r => { if (r.isError) debug($, `set_unread failed: ${JSON.stringify(r.content).slice(0, 200)}`) })
203    .catch(err => {
204      const message = err instanceof Error ? err.message : String(err)
205      if (mcpFailureOf(message) === 'no-session') {
206        mcpBroken = true
207        unbound = true
208      } else debug($, `set_unread failed: ${message}`)
209    })
210}
211
212async function settle($: Engine, reply: string | undefined, reloaded: boolean) {
213  const answer = endingOf(reply, wrapUp)
214  let verdict = turnVerdictOf(reply, wrapUp)
215  if (wrapUp !== null) debug($, `wrap_up: ask=${wrapUp.ask.length} blocked=${wrapUp.blocked.length} chars: ${verdict}`)
216  if (proposals > 0 && verdict !== 'needs-you') {
217    debug($, `${proposals} session card(s) left open: needs-you`)
218    verdict = 'needs-you'
219  }
220  if (verdict === 'unsure') {
221    verdict = 'done'
222    if (cfg.classify && answer) {
223      try {
224        const label = await $.model.classify(classifyTail(answer), [
225          'asks-the-user-a-question-or-for-a-decision',
226          'reports-without-needing-a-reply',
227        ])
228        if (label === 'asks-the-user-a-question-or-for-a-decision') verdict = 'needs-you'
229      } catch (err) {
230        debug($, `classify failed: ${err instanceof Error ? err.message : String(err)}`)
231      }
232    }
233  }
234  publish($, verdict, reloaded)
235  if (!reloaded) markUnread($)
236}
237
238function endTurn() {
239  turnActive = false
240  asking = false
241  inFlight = null
242}
243
244// Background work the main loop started and has not yet been notified about: ids from the Bash
245// and Agent results, cleared by the completion notice. That notice is a prompt.submit of
246// origin `task-notification`: the next turn's text when the session is idle, a mid-turn
247// submission when it is not (it never shows in a tool result). Mirrored to host state so a hot
248// reload does not forget them.
249const pending = new Set<string>()
250const PENDING = { plugin: 'beacon', key: 'pending' } as const
251const LIVE_AGENT = new Set(['pending', 'running', 'waiting'])
252
253function savePending($: Engine) {
254  void $.state.set(PENDING, [...pending]).catch(err => {
255    debug($, `pending not saved: ${err instanceof Error ? err.message : String(err)}`)
256  })
257}
258
259async function inFlightCount($: Engine) {
260  let agents = 0
261  try {
262    agents = (await $.agent.list()).filter(a => LIVE_AGENT.has(a.status) && !pending.has(a.id)).length
263  } catch (err) {
264    debug($, `agent.list failed: ${err instanceof Error ? err.message : String(err)}`)
265  }
266  return pending.size + agents
267}
268
269// The end of a main-loop turn: what is still running decides between waiting and a verdict.
270async function finish($: Engine, answer: string | undefined, via: string) {
271  endTurn()
272  const count = await inFlightCount($)
273  debug($, `${via}: pending=${count} ids=${[...pending].join(',')} answer=${(answer ?? '').length} chars`)
274  const reloaded = via === 'load'
275  if (count > 0) publish($, 'waiting', reloaded)
276  else await settle($, answer, reloaded)
277}
278
279export const register: Register = (on, options) => {
280  cfg = parseConfig(options)
281
282  on('session.start', async ($, e, next) => {
283    desktop = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === DESKTOP_ENTRYPOINT
284    try {
285      const { value = [] } = await $.state.get(PENDING)
286      for (const id of value) pending.add(id)
287    } catch (err) {
288      debug($, `pending not restored: ${err instanceof Error ? err.message : String(err)}`)
289    }
290    const messages = await $.session.messages()
291    const lastAnswer = [...messages].reverse().find(m => m.role === 'assistant')?.text
292    debug($, `loaded: ${messages.length} messages, pending restored: ${[...pending].join(',')}`)
293    if (messages.length > 0) await finish($, lastAnswer, 'load')
294    $.clock.every(TICK_MS, () => {
295      const now = Date.now()
296      if (untitled && state !== null) rename($, state)
297      if (turnActive && !asking && isSlow(inFlight, now, cfg.slowMs)) publish($, 'waiting')
298      // A wake-up that has not come within the hour is not coming: the wait is broken, not long.
299      if (!turnActive && state === 'waiting' && isStale(waitingSince, now, cfg.staleMs)) {
300        debug($, `waiting ${Math.round((now - (waitingSince ?? now)) / 60_000)} min, ids=${[...pending].join(',')}: stale`)
301        publish($, 'error')
302      }
303    })
304    return next(e)
305  })
306
307  on('prompt.submit', ($, e, next) => {
308    const ids = notifiedTasks(e.text)
309    for (const id of ids) pending.delete(id)
310    if (ids.length > 0) {
311      savePending($)
312      debug($, `notified ${e.origin.kind}${e.turnId === undefined ? '' : ' mid-turn'}: ${ids.join(',')}`)
313    }
314    return next(e)
315  }).catch(($, e, next) => next(e))
316
317  on('turn.start', ($, e, next) => {
318    turnActive = true
319    asking = false
320    inFlight = null
321    proposals = 0
322    wrapUp = null
323    const ids = notifiedTasks(e.text)
324    for (const id of ids) pending.delete(id)
325    if (ids.length > 0) savePending($)
326    publish($, 'working')
327    return next(e)
328  })
329
330  on('tool.call', async ($, e, next) => {
331    if (e.agentId !== undefined) return next(e)
332    const tool = String(e.tool)
333    inFlight = { tool, at: Date.now() }
334    const input = e as { initiation?: unknown }
335    if (tool === WRAP_UP) wrapUp = wrapUpOf(e as Record<string, unknown>)
336    if (asksInline(tool, input)) {
337      debug($, `asking in the app UI: ${tool}`)
338      asking = true
339      publish($, 'needs-you')
340    }
341    if (e.tool === 'TaskStop' && e.task_id !== undefined && pending.delete(e.task_id)) savePending($)
342    const ran = await next(e)
343    inFlight = null
344    if (ran.deny === undefined && ran.isError !== true && launchesTasks(tool)) {
345      const text = `${ran.text ?? ''}\n${typeof ran.result === 'string' ? ran.result : JSON.stringify(ran.result ?? '')}`
346      const started = startedTasks(text)
347      for (const id of started) pending.add(id)
348      if (started.length > 0) savePending($)
349    }
350    if (ran.deny === undefined && ran.isError !== true) {
351      if (proposes(tool, input)) {
352        proposals++
353        debug($, `session card left open (${proposals})`)
354      } else if (tool === DISMISS_TASK && proposals > 0) proposals--
355    }
356    if (asking) {
357      asking = false
358      if (turnActive) publish($, 'working')
359    } else if (turnActive && state === 'waiting') {
360      publish($, 'working')
361    }
362    return ran
363  }).catch(($, e, next) => next(e))
364
365  on('tool.check', async ($, e, next) => {
366    const verdict = await next(e)
367    if (e.agentId === undefined && verdict.decision === 'ask') {
368      asking = true
369      publish($, 'needs-you')
370    }
371    return verdict
372  }).catch(($, e, next) => next(e))
373
374  on('classic.PermissionRequest', ($, e, next) => {
375    if (e.agent_id === undefined) {
376      asking = true
377      publish($, 'needs-you')
378    }
379    return next(e)
380  }).catch(($, e, next) => next(e))
381
382  // The desktop app was not seen to raise classic.Stop; turn.complete is the trigger that fires.
383  // Stop stays hooked for builds that do raise it: its task list is authoritative there.
384  on('classic.Stop', async ($, e, next) => {
385    if (e.agent_id !== undefined) return next(e)
386    const listed = inFlightTasks(e.background_tasks) + (e.session_crons?.length ?? 0)
387    if (listed > 0) {
388      endTurn()
389      publish($, 'waiting')
390    } else await finish($, e.last_assistant_message, 'Stop')
391    return next(e)
392  }).catch(($, e, next) => next(e))
393
394  on('turn.complete', async ($, e, next) => {
395    if (e.agentId !== undefined) return next(e)
396    // An interrupt is the person's own doing and needs no flag; an API error, rate limit,
397    // overflow or refusal ended the work unfinished and would otherwise pass as done.
398    const unanswered = unansweredState(e.reason)
399    if (unanswered !== null) {
400      endTurn()
401      publish($, unanswered)
402      if (unanswered === 'error') markUnread($)
403    } else await finish($, e.answer, `turn.complete(${e.reason})`)
404    return next(e)
405  })
406
407  on('session.end', ($, e, next) => {
408    // A closed session keeps a verdict glyph (still true of its transcript) but not a live one.
409    ended = true
410    if (e.reason === 'clear' || state === 'working' || state === 'waiting') publish($, null)
411    else rename($, state)
412    return next(e)
413  })
414}
415
hooks/core.ts 261 lines
1// Pure data: the state table, the title glyph, and the reading of a reply's ending.
2// Nothing here touches `$`, so every rule is testable directly.
3
4export type State = 'working' | 'waiting' | 'needs-you' | 'done' | 'error'
5
6// Blue for the live state: a green dot sat next to the green check and read as the same thing.
7export const GLYPH: Record<State, string> = {
8  working: '🔵',
9  waiting: '⏳',
10  'needs-you': '❓',
11  done: '✅',
12  error: '⚠️',
13}
14
15// Every glyph this mod ever wrote, so a title written by an older version still strips clean. The
16// clock stamp is stripped only behind a glyph: a title of its own may start with a time.
17const LEADING_GLYPH = /^(?:🔵|🟢|⏳|❓|✅|⚠|🔴|⏸)️?\s*(?:\d{1,2}:\d{2}\s+)?/u
18const STAMP = /^(?:🔵|🟢|⏳|❓|✅|⚠|🔴|⏸)️?\s*(\d{1,2}:\d{2})\s/u
19
20export const stripGlyph = (title: string) => title.replace(LEADING_GLYPH, '')
21
22// Live states change too often to stamp; a settled one keeps the time it settled at.
23const STAMPED = new Set<State>(['needs-you', 'done', 'error'])
24
25/** The local `H:MM` a settled state is stamped with. A title cannot tick, so a clock time, not an age. */
26export const clockOf = (ms: number) => {
27  const d = new Date(ms)
28  return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
29}
30
31/** `stamp` undefined keeps the title's own stamp: a reload re-reads a state, it did not settle it again. */
32export const withGlyph = (title: string, state: State | null, stamp?: string) => {
33  const base = stripGlyph(title).trim()
34  if (state === null) return base
35  const at = STAMPED.has(state) ? (stamp ?? STAMP.exec(title)?.[1]) : undefined
36  return at === undefined ? `${GLYPH[state]} ${base}` : `${GLYPH[state]} ${at} ${base}`
37}
38
39// The app's session-handoff tool. `offer` holds the call open on a "New session / Do it here"
40// card; `own_initiative` leaves a card the person may start after the turn has ended.
41export const START_SESSION = 'mcp__ccd_session__start_session'
42export const DISMISS_TASK = 'mcp__ccd_session__dismiss_task'
43const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
44
45/** Whether a tool call is open on a question to the person in the app's own UI. */
46export const asksInline = (tool: string, input: { initiation?: unknown }) =>
47  ASKING_TOOLS.has(tool) || (tool === START_SESSION && input.initiation === 'offer')
48
49/** Whether a call left a session card for the person to start or ignore. */
50export const proposes = (tool: string, input: { initiation?: unknown }) =>
51  tool === START_SESSION && input.initiation === 'own_initiative'
52
53export type Config = { slowMs: number; staleMs: number; classify: boolean; markUnread: boolean; debugLog: string }
54
55export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => ({
56  slowMs: Math.max(5, typeof o.slowSeconds === 'number' ? o.slowSeconds : 60) * 1000,
57  // A self-chosen wait is capped at 55 minutes by the operating rules; a session still waiting
58  // past the hour has lost its wake-up, so the default sits just above that cap.
59  staleMs: Math.max(1, typeof o.staleMinutes === 'number' ? o.staleMinutes : 60) * 60_000,
60  // Off by default: the classifier called a long report that merely mentioned a choice a question,
61  // twice in one afternoon. The marker line and the question rules are the contract.
62  classify: typeof o.classify === 'boolean' ? o.classify : false,
63  markUnread: typeof o.markUnread === 'boolean' ? o.markUnread : true,
64  debugLog: typeof o.debugLog === 'string' ? o.debugLog : '',
65})
66
67export type Verdict = 'needs-you' | 'done' | 'unsure'
68
69// The explicit convention (README, "How needs you is read"): a reply that needs the person ends with this line.
70const MARKER = /^\s*(?:[*_#>\-\s]*)(?:NEEDS YOU|NEED(?:S)? YOUR (?:DECISION|ANSWER|INPUT|CONFIRMATION|APPROVAL))\b/im
71// Text after the marker (a code block, a closing remark) pushes it out of the closing lines, so the
72// marker alone is looked for over a wider stretch; anchored at line start, a quoted one never counts.
73const MARKER_WINDOW = 2000
74
75// Phrasings of a request for a decision that do not end in a question mark.
76const ASKING_EN =
77  /\b(?:should i|shall i|do you want(?: me)?|want me to|would you like|let me know (?:if|whether|which|when)|which (?:option|one|approach|way)|your call|up to you|please (?:approve|confirm|choose|decide|pick|review)|waiting for (?:your|you)|awaiting your|need(?:s)? your (?:approval|decision|confirmation|go-ahead|ok)|confirm (?:before|that|whether|the)|say the word|give me the go-ahead|choose (?:one|between))\b/i
78// Cyrillic kept literal on purpose: these are match patterns for Russian replies. `\b` does not
79// see Cyrillic letters, so the imperative ends at a lookahead: "подтвердил" is a statement.
80const ASKING_RU =
81  /(?:подтверди(?:те)?(?![а-яё])|продолж(?:ать|ить)\s*\?|хочешь|хотите|нужно ли|стоит ли|какой (?:вариант|подход|из)|выбери|выберите|жду (?:твоего|вашего|подтверждения|ответа|решения|отмашки)|тво[её] решение|ваше решение|реши(?:те)?,|скажи(?:те)?,? (?:если|какой|что|как)|дай(?:те)? (?:знать|добро)|нужно (?:тво[её]|ваше) (?:решение|подтверждение))/i
82
83// A session postmortem appended after the reply proper: `friction:` first, then `key: value` lines.
84const POSTMORTEM_START = /^[>*_`\-\s]*friction\s*:/i
85const POSTMORTEM_LINE = /^[>*_`\-\s]*[a-z][a-z_-]{0,20}\s*:/i
86
87/** The reply without a trailing postmortem block; unchanged unless every line after `friction:` is a key line. */
88export const withoutPostmortem = (text: string) => {
89  const lines = text.split('\n')
90  const start = lines.findLastIndex(l => POSTMORTEM_START.test(l))
91  if (start < 0) return text
92  const rest = lines.slice(start).filter(l => l.trim() !== '')
93  return rest.every(l => POSTMORTEM_LINE.test(l)) ? lines.slice(0, start).join('\n') : text
94}
95
96const lastLines = (text: string, n: number) =>
97  text
98    .split('\n')
99    .map(l => l.trim())
100    .filter(l => l !== '' && !/^```/.test(l))
101    .slice(-n)
102
103/**
104 * Reads the ending of a reply: `needs-you` when it asks the person for anything, `done` when it is
105 * a plain report, `unsure` when neither rule fires (a classifier decides then).
106 */
107export const verdictOf = (text: string | undefined): Verdict => {
108  const t = withoutPostmortem((text ?? '').trim()).trim()
109  if (t === '') return 'done'
110  const tail = lastLines(t, 6)
111  if (tail.length === 0) return 'done'
112  const window = t.length <= MARKER_WINDOW ? t : t.slice(t.indexOf('\n', t.length - MARKER_WINDOW) + 1)
113  if (MARKER.test(window)) return 'needs-you'
114  const last = tail[tail.length - 1] ?? ''
115  if (/\?\s*(?:\*\*|_)?\s*$/.test(last)) return 'needs-you'
116  // Unwrapped paragraphs are single lines, so three of them can be most of a reply: only the
117  // closing stretch counts, or a report that merely describes a choice reads as asking one.
118  const end = tail.slice(-3).join('\n').slice(-400)
119  if (ASKING_EN.test(end) || ASKING_RU.test(end)) return 'needs-you'
120  if (t.length < 400) return 'done'
121  return 'unsure'
122}
123
124// The desktop app's end-of-turn tool. A turn that ends on it has no closing text (`turn.complete`
125// carries an empty answer): the recap is the reply, and `ask` / `blocked` are the request.
126export const WRAP_UP = 'mcp__ccd_turn__wrap_up'
127
128export type WrapUp = { recap: string; ask: string; blocked: string }
129
130const field = (v: unknown) => (typeof v === 'string' ? v.trim() : v === true ? 'yes' : '')
131
132export const wrapUpOf = (input: Readonly<Record<string, unknown>>): WrapUp => ({
133  recap: field(input.recap),
134  ask: field(input.ask),
135  blocked: field(input.blocked),
136})
137
138/** The text a turn ended on: its answer, or the wrap_up recap and ask when the answer is empty. */
139export const endingOf = (answer: string | undefined, wrapUp: WrapUp | null) =>
140  (answer ?? '').trim() !== '' || wrapUp === null ? answer : [wrapUp.recap, wrapUp.ask].filter(s => s !== '').join('\n\n')
141
142/** The verdict of a whole turn: a wrap_up that asks or is blocked needs the person outright. */
143export const turnVerdictOf = (answer: string | undefined, wrapUp: WrapUp | null): Verdict =>
144  wrapUp !== null && (wrapUp.ask !== '' || wrapUp.blocked !== '') ? 'needs-you' : verdictOf(endingOf(answer, wrapUp))
145
146/**
147 * The state a turn that ended without an answer settles in, or null when it answered. An interrupt
148 * is the person's own doing; an API error or a refusal ended the work unfinished.
149 */
150export const unansweredState = (reason: string): State | null =>
151  reason === 'aborted' ? 'done' : reason === 'error' || reason === 'refusal' ? 'error' : null
152
153// No app server to call: a terminal session ("no connected MCP tool"), or a headless child that
154// inherited the desktop entrypoint (an eval, a worker run) and has no session bound.
155const NO_SERVER = /no connected MCP tool/i
156const NO_SESSION = /no session is bound in this process/i
157
158/** How an MCP call failure reads: the server is not there yet, never will be, or it refused this call. */
159export const mcpFailureOf = (message: string): 'no-server' | 'no-session' | 'refused' =>
160  NO_SESSION.test(message) ? 'no-session' : NO_SERVER.test(message) ? 'no-server' : 'refused'
161
162/** The text a classifier reads: the reply's last 1,500 characters, enough for its closing paragraph. */
163export const classifyTail = (text: string) => text.trim().slice(-1500)
164
165const ENDED = new Set(['completed', 'failed', 'killed', 'done', 'cancelled', 'canceled', 'stopped'])
166
167/** Background tasks still in flight when a turn ends, by the Stop hook's own summary list. */
168export const inFlightTasks = (tasks: ReadonlyArray<{ status: string }> | undefined) =>
169  (tasks ?? []).filter(t => !ENDED.has(String(t.status).toLowerCase())).length
170
171// A Bash call started in the background, or moved there on timeout, and an Agent spawn name
172// their task in the result text; the engine's completion notice carries it as <task-id>.
173const STARTED_ID = /(?:background with ID|background \(ID|agentId): ([\w-]+)/g
174const NOTIFIED_ID = /<task-id>([\w-]+)<\/task-id>/g
175const idsIn = (text: string, re: RegExp) => [...text.matchAll(re)].map(m => m[1] ?? '').filter(Boolean)
176
177// Only these tools start background work. Any other result can quote the announcement (a Read of
178// this mod's own tests did), and a phantom id holds the session in waiting until it goes stale.
179const LAUNCHERS = new Set(['Bash', 'Agent', 'Task'])
180export const launchesTasks = (tool: string) => LAUNCHERS.has(tool)
181
182/** Task ids a tool result announces as started in the background. */
183export const startedTasks = (text: string) => idsIn(text, STARTED_ID)
184
185/** Task ids a completion notice names; it arrives in a turn's opening text or inside a tool
186 *  result, whichever the model was in when the task ended. */
187export const notifiedTasks = (text: string) => idsIn(text, NOTIFIED_ID)
188
189/** Whether a session that ended its turn waiting has waited past the point where a wake-up is still expected. */
190export const isStale = (waitingSince: number | null, now: number, staleMs: number) =>
191  waitingSince !== null && now - waitingSince >= staleMs
192
193/** Whether a foreground call has run long enough to count as waiting rather than working. */
194export const isSlow = (call: { tool: string; at: number } | null, now: number, slowMs: number) =>
195  call !== null && call.tool !== 'Agent' && call.tool !== 'Task' && now - call.at >= slowMs
196
197/** The title an MCP `get_session` result carries, or null when the result cannot be read. */
198export const titleOf = (result: { content: ReadonlyArray<{ type: string; text?: string }> }): string | null => {
199  const text = result.content.find(c => c.type === 'text')?.text
200  if (!text) return null
201  try {
202    const parsed = JSON.parse(text) as { title?: unknown }
203    return typeof parsed.title === 'string' && parsed.title.trim() !== '' ? parsed.title : null
204  } catch {
205    return null
206  }
207}
208
209export type SessionInfo = { id: string; cwd: string; link: string; archived: boolean }
210
211/** The identity fields of a `get_session` result, or null when it carries no session id. */
212export const sessionOf = (result: { content: ReadonlyArray<{ type: string; text?: string }> }): SessionInfo | null => {
213  const text = result.content.find(c => c.type === 'text')?.text
214  if (!text) return null
215  try {
216    const p = JSON.parse(text) as { sessionId?: unknown; cwd?: unknown; link?: unknown; isArchived?: unknown }
217    if (typeof p.sessionId !== 'string' || p.sessionId === '') return null
218    return {
219      id: p.sessionId,
220      cwd: typeof p.cwd === 'string' ? p.cwd : '',
221      link: typeof p.link === 'string' ? p.link : '',
222      archived: p.isArchived === true,
223    }
224  } catch {
225    return null
226  }
227}
228
229/**
230 * The line a status file holds for the desktop widget (`widget/`). The file name is the id with
231 * every character outside `[A-Za-z0-9_-]` replaced, so an id can never escape the directory.
232 */
233export const statusFileName = (id: string) => `${id.replace(/[^A-Za-z0-9_-]/g, '_')}.json`
234
235/**
236 * Where a session started inside the cmux terminal lives, so the widget can focus its tab. cmux's
237 * socket admits only processes started inside cmux; the capability token cmux hands those
238 * processes is what lets the widget, started outside it, through.
239 */
240export type CmuxInfo = { workspace: string; panel: string; socket: string; capability: string; bundle: string }
241
242export const cmuxOf = (env: Readonly<Record<string, string | undefined>>): CmuxInfo | null => {
243  const workspace = env.CMUX_WORKSPACE_ID ?? ''
244  const panel = env.CMUX_PANEL_ID ?? env.CMUX_SURFACE_ID ?? ''
245  const capability = env.CMUX_SOCKET_CAPABILITY ?? ''
246  if (workspace === '' || panel === '' || capability === '') return null
247  return { workspace, panel, socket: env.CMUX_SOCKET_PATH ?? '', capability, bundle: env.CMUX_BUNDLE_ID ?? 'com.cmuxterm.app' }
248}
249
250export const statusRecord = (
251  info: SessionInfo,
252  title: string,
253  state: State | null,
254  ended: boolean,
255  now: number,
256  cmux: CmuxInfo | null = null,
257) => `${JSON.stringify({ ...info, title: stripGlyph(title), state, ended, updatedAt: now, ...(cmux ? { cmux } : {}) })}\n`
258
259// Titles the app has not named yet: renaming one would freeze it before the app's own title lands.
260export const isPlaceholderTitle = (title: string) => /^(?:new session|untitled|new conversation)$/i.test(stripGlyph(title).trim())
261
types/index.d.ts 15 lines
1/** Background task ids the main loop started and has not been notified about. */
2export type Pending = string[]
3
4declare module 'claude-code' {
5  interface PluginState {
6    beacon: {
7      /**
8       * Host state lives as long as the process, as the tasks do: a hot reload keeps it, a
9       * restart drops both.
10       */
11      pending: Pending
12    }
13  }
14}
15