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…

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:
| Glyph | State | Set when |
|---|---|---|
| 🔵 | working | a turn is running |
| ⏳ | waiting | the turn ended with background tasks or a session cron still in flight, or one foreground tool call has run longer than slowSeconds |
| ❓ | needs you | a 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 |
| ✅ | done | the turn ended, nothing is in flight, the last reply asks for nothing |
| ⚠️ | error | the 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.
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):
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;?, or English/Russian phrasings of a request for a decision;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.
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.
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.
pluginConfigs.beacon.options)| Option | Default | Meaning |
|---|---|---|
slowSeconds | 60 | foreground tool call length that turns 🔵 into ⏳ |
staleMinutes | 60 | how long a ⏳ session may wait for its wake-up before it turns ⚠️ (keep it above the longest wait you expect a session to schedule) |
classify | false | ask the small model when the heuristics cannot read a long reply |
markUnread | true | set 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 |
ccd_session_mgmt__get_session and ccd_session_mgmt__set_session_title from such a hook to save the extra calls.$.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.session.end fails when the process is already unbound from the app, so a closed session can keep 🔵 or ⏳ until it is opened again.claude plugin validate .
claude plugin test .
MIT, see LICENSE.
hooks/register.ts 415 lines1import 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}
415hooks/core.ts 261 lines1// 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())
261types/index.d.ts 15 lines1/** 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