Live dashboard of the cloud sessions this orchestrator spins up, with a wake-up when one needs attention.

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.
Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.
Install (what the cloud environment setup script runs):
claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope userhooks/register.tsx 594 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { FleetRow } from '../types'
5import {
6 childIdOf, CLOSABLE_BUCKETS, closeoutLine, decideCloseout, decideStall, decideWaitingReminder, decideWake, ESCALATE_MS, humanWrap, newestActivity, NEEDS_STAN,
7 NUDGE_TEXT, STALL_MS, ticketOf, WAITING_BUCKETS, waitingLine, withChildTools,
8} from './logic'
9
10// Fleet tracker: one line per cloud session the orchestrator spins up.
11// Why it exists: a child session's report back to its parent is not reliable
12// (it only happens if the child chooses to send one), so the orchestrator has
13// to poll. This mod does the polling on a timer, draws the
14// result, and wakes the orchestrator only when a row changes into a state that
15// needs a decision, so nobody has to remember to look.
16
17// Callout convention (LC-1711): every message that changes something (a row added, flagged or removed,
18// the wake-up turn's text) starts with `[mod:fleet-tracker]` (that text reaches the model in the tool
19// result or the queued turn) and fires a toast naming the mod and the reason. The dashboard itself
20// is informational and needs no toast; a routine wake-up turn is prefixed but not toasted (a toast
21// per REVIEW_READY would be noise; the ones that need the operator already toast through `say`). A plain
22// note is routine bookkeeping: prefixed for the model, silent for the operator. Only a needs_you flag toasts.
23const MOD = 'fleet-tracker'
24function say($: any, reason: string, toast = true): string {
25 const text = `[mod:${MOD}] ${reason}`
26 try {
27 if (toast) $.ui.toast(text.length > 160 ? `${text.slice(0, 157)}...` : text)
28 } catch { /* a toast must never be the reason a tool call fails */ }
29 return text
30}
31
32const PANE = 'fleet'
33const SERVER = 'claude-code-remote'
34const POLL_MS = 90_000
35const STORE_KEY = 'rows'
36
37// Buckets that mean "a human has to do something" (the operator gets flagged).
38
39const lastWoken = new Map<string, string>()
40// Stall state per session, the source of truth while the module lives. Two polls can
41// overlap (timer + track/status) and each saves its own copy of the rows, so a nudge is
42// claimed here synchronously, between the decision and the first await: the second
43// poll to decide sees the claim and never sends a duplicate (caught by stall.test.ts).
44// After a reload the map is empty and falls back to the row's saved fields.
45const stallOf = new Map<string, { nudgedAt: number; escalated: boolean }>()
46// Close-out reminder claims (LC-1835), same reason as stallOf: the episode (bucket @ entry time)
47// whose one reminder has been claimed, set synchronously so two overlapping polls cannot both send it.
48const closeoutClaim = new Map<string, string>()
49// Waiting-reminder claims (LC-1881), same reason: episode (bucket @ entry time) -> when its last
50// reminder was claimed, set synchronously so two overlapping polls cannot both send it.
51const waitClaim = new Map<string, { episode: string; at: number }>()
52
53const rows = atom({ plugin: 'fleet-tracker', key: 'rows' } as const, [] as FleetRow[])
54
55const shortBucket = (raw: unknown): string =>
56 String(raw ?? 'UNKNOWN').replace('SESSION_STATUS_BUCKET_', '')
57
58const textOf = (res: { content: readonly unknown[] }): string =>
59 res.content
60 .map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
61 .join('')
62
63const ago = (iso: string, now: number): string => {
64 const t = Date.parse(iso)
65 if (Number.isNaN(t)) return '?'
66 const m = Math.max(0, Math.round((now - t) / 60_000))
67 return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`
68}
69
70// Every change to the list is ONE functional update on the current value, never
71// read-then-write: a read followed by an await and a write lost whatever another
72// handler wrote in between. Measured on v1 (2026-10-15): a `note` was wiped by the
73// poll `track` had queued, because that poll saved the list it read before the note.
74async function mutate($: any, fn: (cur: FleetRow[]) => FleetRow[]): Promise<FleetRow[]> {
75 let out: FleetRow[] = []
76 await update($, rows, (cur: FleetRow[]) => (out = fn(cur)))
77 await $.store.set(STORE_KEY, out)
78 return out
79}
80
81// One poll: refresh every row from get_session, then react to transitions.
82async function poll($: any): Promise<FleetRow[]> {
83 const before: FleetRow[] = await read($, rows)
84 if (before.length === 0) return before
85 const now = await $.clock.now()
86 const wakes: string[] = []
87 const stanToasts: string[] = []
88
89 const after = await Promise.all(
90 before.map(async (row): Promise<FleetRow> => {
91 try {
92 const res = await $.mcp.call(SERVER, 'get_session', { session_id: row.sessionId })
93 if (res.isError) throw new Error(textOf(res).slice(0, 120))
94 const raw = textOf(res)
95 // The server wraps its JSON in an <other-session> tag; take the outermost object.
96 const start = raw.indexOf('{'), end = raw.lastIndexOf('}')
97 if (start < 0 || end <= start) throw new Error(`no JSON in reply: ${raw.slice(0, 80)}`)
98 const ccr = JSON.parse(raw.slice(start, end + 1)).ccr ?? {}
99 // post_turn_summary is the session's own read of its last turn; a
100 // pending needs_action means it waits on a decision even when the
101 // bucket still says COMPLETED.
102 const pts = ccr.post_turn_summary ?? ccr.external_metadata?.post_turn_summary ?? {}
103 const needsAction = String(pts.needs_action ?? '')
104 const bucket =
105 needsAction || pts.status_category === 'need_input' ? 'NEEDS_INPUT' : shortBucket(ccr.status_bucket)
106 const fresh: FleetRow = {
107 ...row,
108 bucket,
109 status: String(ccr.session_status ?? '').replace('SESSION_STATUS_', ''),
110 needsAction,
111 statusDetail: String(pts.status_detail ?? ''),
112 summary: needsAction || String(pts.status_detail || pts.recent_action || ccr.task_summary || ''),
113 costUsd: Number(ccr.external_metadata?.usage?.cost_usd ?? row.costUsd ?? 0),
114 updatedAt: String(ccr.updated_at ?? row.updatedAt),
115 checkedAt: now,
116 error: '',
117 // A manual needs-you note sticks until cleared; a bucket flag tracks the bucket.
118 needsYou: NEEDS_STAN.has(bucket) || (row.needsYou && row.note !== ''),
119 }
120 const { wake, holdPrevBucket } = decideWake(lastWoken, row.sessionId, row.bucket, bucket, fresh.summary)
121 if (holdPrevBucket) fresh.bucket = row.bucket
122 if (wake) {
123 const entry = `${row.ticket} (${row.label}) ${row.bucket || 'NEW'} -> ${bucket}: ${fresh.summary}`
124 wakes.push(
125 WAITING_BUCKETS.has(bucket) ? humanWrap(entry, row.sessionId, needsAction, fresh.statusDetail ?? '') : entry,
126 )
127 }
128 if (fresh.needsYou && !row.needsYou) stanToasts.push(`${row.ticket} needs you: ${bucket}`)
129
130 // Close-out clock (LC-1835). bucketSince moves only when the stored bucket changes
131 // (a held, suppressed entry is not a change); a row saved before this field existed
132 // starts its clock at its first poll. One reminder per bucket episode.
133 if (fresh.bucket !== row.bucket || !row.bucketSince) {
134 fresh.bucketSince = now
135 if (fresh.bucket !== row.bucket) delete fresh.closeoutWokeAt
136 }
137 const episode = `${fresh.bucket}@${fresh.bucketSince}`
138 const claimed = closeoutClaim.get(row.sessionId) === episode || !!fresh.closeoutWokeAt
139 if (decideCloseout(fresh.bucket, fresh.bucketSince ?? 0, now, claimed)) {
140 closeoutClaim.set(row.sessionId, episode)
141 fresh.closeoutWokeAt = now
142 wakes.push(closeoutLine(row.ticket, row.label, fresh.bucket, fresh.bucketSince ?? now, now))
143 }
144
145 // Waiting clock (LC-1881): a child that waits on a human is reminded every
146 // WAITING_REMIND_MS until its bucket changes. UNKNOWN (a bad poll) leaves the clock alone;
147 // any other bucket change restarts it.
148 if (fresh.bucket !== 'UNKNOWN') {
149 if (!WAITING_BUCKETS.has(fresh.bucket)) {
150 delete fresh.waitBucket
151 delete fresh.waitSince
152 delete fresh.waitRemindedAt
153 } else if (fresh.waitBucket !== fresh.bucket || !fresh.waitSince) {
154 fresh.waitBucket = fresh.bucket
155 fresh.waitSince = now
156 delete fresh.waitRemindedAt
157 }
158 }
159 const waitEpisode = `${fresh.waitBucket}@${fresh.waitSince}`
160 const claim = waitClaim.get(row.sessionId)
161 const remindedAt = Math.max(claim?.episode === waitEpisode ? claim.at : 0, fresh.waitRemindedAt ?? 0)
162 if (decideWaitingReminder(fresh.bucket, fresh.waitSince ?? 0, remindedAt, now)) {
163 waitClaim.set(row.sessionId, { episode: waitEpisode, at: now })
164 fresh.waitRemindedAt = now
165 wakes.push(
166 waitingLine(row.ticket, row.label, row.sessionId, fresh.bucket, now - (fresh.waitSince ?? now), needsAction, fresh.statusDetail ?? ''),
167 )
168 }
169
170 // Stall check (LC-1834). Only fetch the transcript when the turn-level
171 // updated_at is itself stale: a healthy fleet costs no extra calls.
172 const prior = stallOf.get(row.sessionId) ?? { nudgedAt: row.nudgedAt ?? 0, escalated: row.stallEscalated ?? false }
173 let lastMs: number | null = Date.parse(fresh.updatedAt)
174 if (Number.isNaN(lastMs)) lastMs = null
175 // Once nudged, updated_at is not evidence: delivering our own nudge may bump it,
176 // which would clear the stall and re-nudge every 15 min without ever escalating
177 // (review finding 2). Only a transcript event that is not the nudge counts.
178 if (prior.nudgedAt) lastMs = null
179 if (bucket === 'WORKING' && (prior.nudgedAt || lastMs === null || now - lastMs >= STALL_MS)) {
180 const ev = await $.mcp
181 .call(SERVER, 'list_events', { session_id: row.sessionId, limit: 5 })
182 .catch(() => null)
183 const seen = !ev || ev.isError ? null : newestActivity(textOf(ev))
184 // An unreadable transcript is unknown activity (decideStall decides what that means).
185 lastMs = seen === null ? null : Math.max(seen, lastMs ?? 0)
186 }
187 if (lastMs !== null) fresh.lastActivity = new Date(lastMs).toISOString()
188 const st = stallOf.get(row.sessionId) ?? prior
189 const step = decideStall(bucket, lastMs, now, st.nudgedAt, st.escalated)
190 const quietMin = lastMs === null ? 0 : Math.round((now - lastMs) / 60_000)
191 if (step === 'clear') stallOf.set(row.sessionId, { nudgedAt: 0, escalated: false })
192 else if (step === 'nudge') stallOf.set(row.sessionId, { nudgedAt: now, escalated: false })
193 else if (step === 'escalate') stallOf.set(row.sessionId, { ...st, escalated: true })
194 else stallOf.set(row.sessionId, st)
195 if (step === 'nudge') {
196 const sent = await $.mcp
197 .call(SERVER, 'send_message', { session_id: row.sessionId, message: NUDGE_TEXT, priority: 'next' })
198 .catch((err: unknown) => ({ isError: true, content: [{ type: 'text', text: String(err) }] }))
199 if (sent.isError) {
200 // No nudge delivered: the orchestrator hears now rather than in 10 minutes.
201 stallOf.set(row.sessionId, { nudgedAt: now, escalated: true })
202 wakes.push(`${row.ticket} (${row.label}) STALLED: no activity for ${quietMin}m and the nudge failed (${textOf(sent).slice(0, 80)})`)
203 stanToasts.push(`${row.ticket} stalled ${quietMin}m; nudge failed`)
204 } else {
205 say($, `${row.ticket} quiet ${quietMin}m; nudged it to continue`, false)
206 }
207 } else if (step === 'escalate') {
208 wakes.push(
209 `${row.ticket} (${row.label}) STALLED: ${lastMs === null ? 'activity unreadable' : `no activity for ${quietMin}m`}; an automatic nudge ` +
210 `${Math.round(ESCALATE_MS / 60_000)}m ago got no response. Inspect it (list_events), then interrupt/re-brief or archive.`,
211 )
212 stanToasts.push(`${row.ticket} stalled ${quietMin}m after a nudge`)
213 }
214 const final = stallOf.get(row.sessionId)!
215 fresh.nudgedAt = final.nudgedAt
216 fresh.stallEscalated = final.escalated
217 return fresh
218 } catch (err) {
219 // A failed poll is evidence about the poll, not the session: show it, don't flag the operator.
220 return { ...row, checkedAt: now, error: String(err).slice(0, 120) }
221 }
222 }),
223 )
224
225 // Merge, don't overwrite (review finding 4): the poll awaited the network, and a
226 // track / untrack / note / auto-track may have changed the list meanwhile. Rows
227 // removed meanwhile stay removed, rows added meanwhile are kept, and the fields a
228 // person or the orchestrator owns come from the current list.
229 const polled = new Map(after.map(r => [r.sessionId, r]))
230 const merged = await mutate($, current =>
231 current.map(cur => {
232 const p = polled.get(cur.sessionId)
233 if (!p) return cur
234 return {
235 ...p,
236 ticket: cur.ticket,
237 label: cur.label,
238 note: cur.note,
239 needsYou: NEEDS_STAN.has(p.bucket) || (cur.needsYou && cur.note !== ''),
240 }
241 }),
242 )
243 // A row untracked mid-poll must not keep stall state a later re-track would inherit.
244 for (const id of [...stallOf.keys()]) if (!merged.some(r => r.sessionId === id)) stallOf.delete(id)
245 for (const id of [...closeoutClaim.keys()]) if (!merged.some(r => r.sessionId === id)) closeoutClaim.delete(id)
246 for (const id of [...waitClaim.keys()]) if (!merged.some(r => r.sessionId === id)) waitClaim.delete(id)
247 for (const t of stanToasts) say($, t)
248 pendingWakes.push(...wakes)
249 flushWakes($)
250 return merged
251}
252
253// A stalled row reads STALLED (nudged / escalated) instead of the server's WORKING.
254const shown = (r: FleetRow): string =>
255 r.stallEscalated ? 'STALLED' : r.nudgedAt ? 'NUDGED' : r.bucket || 'pending'
256
257// This session's own id, so auto-track can never pick it out of a result. One
258// get_session with no id (it describes the caller), cached for the module's life.
259// Only a SUCCESSFUL lookup is cached: a failed one returns '' and is retried next time
260// (PR #1808 review). With '' childIdOf demands exactly one candidate in the whole result.
261let ownIdCache = ''
262async function ownId($: any): Promise<string> {
263 if (ownIdCache !== '') return ownIdCache
264 try {
265 const res = await $.mcp.call(SERVER, 'get_session', {})
266 const raw = textOf(res)
267 const start = raw.indexOf('{'), end = raw.lastIndexOf('}')
268 if (res.isError || start < 0) return ''
269 ownIdCache = String(JSON.parse(raw.slice(start, end + 1)).ccr?.id ?? '')
270 } catch {
271 return ''
272 }
273 return ownIdCache
274}
275
276// The session's own status, structured: true only on an explicit SESSION_STATUS_ARCHIVED.
277// Anything unreadable is false, so the row is kept.
278async function isArchived($: any, id: string): Promise<boolean> {
279 try {
280 const res = await $.mcp.call(SERVER, 'get_session', { session_id: id })
281 if (res.isError) return false
282 const raw = textOf(res)
283 const start = raw.indexOf('{'), end = raw.lastIndexOf('}')
284 if (start < 0 || end <= start) return false
285 return JSON.parse(raw.slice(start, end + 1)).ccr?.session_status === 'SESSION_STATUS_ARCHIVED'
286 } catch {
287 return false
288 }
289}
290
291function blankRow(sessionId: string, ticket: string, label: string): FleetRow {
292 return {
293 sessionId, ticket, label, bucket: '', status: '', summary: '', costUsd: 0,
294 updatedAt: '', checkedAt: 0, note: '', needsYou: false, error: '',
295 }
296}
297
298// Wakes waiting for a moment the host lets us queue a turn. A poll run from inside a
299// tool call (track, status, an auto-tracked create_session) may not submit a prompt:
300// the host refuses it, and before LC-1834 that wake was silently lost (stall.test.ts
301// surfaced the refusal). A refused submit keeps its lines here; the next timer poll
302// or the end of the orchestrator's turn delivers them.
303const pendingWakes: string[] = []
304
305function flushWakes($: any) {
306 if (pendingWakes.length === 0) return
307 const lines = pendingWakes.splice(0)
308 // Queues a turn once the session is idle, so the orchestrator triages and,
309 // if the operator is needed, tells them in chat.
310 Promise.resolve()
311 .then(() =>
312 $.prompt.submit({
313 text:
314 '[mod:fleet-tracker] Session state changed:\n' +
315 lines.map(w => `- ${w}`).join('\n') +
316 '\nTriage each: inspect the session, act or relay. If it needs the operator, tell them in chat starting with the NEEDS YOU banner and set the note needs_you=true. Never use PushNotification.',
317 }),
318 )
319 .catch(() => {
320 pendingWakes.unshift(...lines)
321 })
322}
323
324function render(list: FleetRow[], now: number): string {
325 return list.length === 0
326 ? 'No sessions tracked.'
327 : list
328 .map(
329 r =>
330 `${r.needsYou ? '!!' : ' '} ${r.ticket.padEnd(9)} ${shown(r).padEnd(12)} $${r.costUsd.toFixed(2)} ` +
331 `${ago(r.updatedAt, now)} ${r.label}${r.note ? ` | ${r.note}` : ''}${r.error ? ` | poll error: ${r.error}` : ''}`,
332 )
333 .join('\n')
334}
335
336export const register: Register = on => {
337 on('session.start', async ($, e, next) => {
338 // Restore across a process restart: $.state is the session's, $.store
339 // outlives it. Prefer whichever has rows.
340 const saved = (await $.store.get(STORE_KEY)) as FleetRow[] | undefined
341 if (saved?.length && (await read($, rows)).length === 0) {
342 await update($, rows, () => saved)
343 }
344
345 await $.command.register({ name: 'fleet', description: 'Open the session fleet dashboard' })
346 await $.tool.register({
347 name: 'track',
348 description:
349 'Add (or relabel) a cloud session on the fleet dashboard. Call right after create_session.',
350 inputSchema: {
351 type: 'object',
352 properties: {
353 session_id: { type: 'string' },
354 ticket: { type: 'string', description: 'Jira key the session serves' },
355 label: { type: 'string', description: 'Few-word description of the work' },
356 },
357 required: ['session_id', 'ticket', 'label'],
358 },
359 })
360 await $.tool.register({
361 name: 'note',
362 description:
363 "Set a row's note. needs_you=true flags it as waiting on the operator (e.g. merge approval, a decision); false clears the flag.",
364 inputSchema: {
365 type: 'object',
366 properties: {
367 session_id: { type: 'string' },
368 note: { type: 'string' },
369 needs_you: { type: 'boolean' },
370 },
371 required: ['session_id', 'note'],
372 },
373 })
374 await $.tool.register({
375 name: 'untrack',
376 description:
377 'Close out a child session: ARCHIVES it (archive_session) and removes it from the fleet dashboard. ' +
378 'Claude is responsible for the sessions it spins up and closes them when their work is merged or abandoned. ' +
379 'Refused while the session is WORKING or NEEDS_INPUT (archiving loses unpushed work). ' +
380 'If archiving fails the row stays and its note carries the error. ' +
381 'Pass keep_reason to remove the row WITHOUT archiving (the session stays open on purpose); the reason is recorded in the result.',
382 inputSchema: {
383 type: 'object',
384 properties: {
385 session_id: { type: 'string' },
386 keep_reason: {
387 type: 'string',
388 description: 'Why the session stays open. Omit to archive it; set to untrack it without archiving.',
389 },
390 },
391 required: ['session_id'],
392 },
393 })
394 await $.tool.register({
395 name: 'open_children',
396 description:
397 'List the child sessions not yet closed out (still tracked), as text: a header line `Open fleet children: N` then one `- ` line per session. Read-only; session-close uses it.',
398 })
399 await $.tool.register({
400 name: 'status',
401 description: 'Return the fleet dashboard as text (forces a fresh poll first).',
402 })
403
404 // Poll on a timer that outlives any single dispatch; it is cancelled
405 // automatically when the module reloads.
406 $.clock.every(POLL_MS, () => {
407 void poll($)
408 })
409 void poll($)
410 void $.ui.open({ id: PANE, title: 'Fleet' })
411
412 return next(e)
413 })
414
415 // Auto-track (LC-1834): every session this orchestrator creates lands on the
416 // dashboard without a step to forget. An explicit `track` later relabels it.
417 on('tool.call', { tool: 'mcp__claude-code-remote__create_session' }, async ($, e: any, next) => {
418 // LC-1843: the child may report back without stalling on an unwatched permission prompt.
419 const res: any = await next(withChildTools(e))
420 try {
421 if (res.isError || res.deny) return res
422 const id = childIdOf(String(res.text ?? JSON.stringify(res.result ?? '')), await ownId($))
423 if (!id) return res
424 const ticket = ticketOf(e.title, e.prompt)
425 const label = String(e.title || e.prompt || 'auto-tracked').replace(/\s+/g, ' ').slice(0, 60)
426 let added = false
427 await mutate($, cur => {
428 if (cur.some(r => r.sessionId === id)) return cur
429 added = true
430 return [...cur, blankRow(id, ticket, label)]
431 })
432 if (!added) return res
433 void poll($)
434 const msg = say($, `Auto-tracking ${id} as ${ticket}; call mcp__fleet-tracker__track to relabel it.`)
435 return { ...res, context: [...(res.context ?? []), msg] }
436 } catch {
437 return res // tracking must never cost the orchestrator its create_session result
438 }
439 }).catch(($, e, next) => next(e)) // replay-safe: never re-runs create_session, never refuses it
440
441 on('tool.call', { tool: 'mcp__fleet-tracker__track' }, async ($, e: any) => {
442 await mutate($, list => {
443 const existing = list.find(r => r.sessionId === e.session_id)
444 const row: FleetRow = {
445 ...existing,
446 sessionId: e.session_id,
447 ticket: e.ticket,
448 label: e.label,
449 bucket: existing?.bucket ?? '',
450 status: existing?.status ?? '',
451 summary: existing?.summary ?? '',
452 costUsd: existing?.costUsd ?? 0,
453 updatedAt: existing?.updatedAt ?? '',
454 checkedAt: 0,
455 note: existing?.note ?? '',
456 needsYou: existing?.needsYou ?? false,
457 error: '',
458 }
459 return [...list.filter(r => r.sessionId !== e.session_id), row]
460 })
461 void poll($)
462 return { result: say($, `Tracking ${e.session_id} as ${e.ticket}.`) }
463 })
464
465 on('tool.call', { tool: 'mcp__fleet-tracker__note' }, async ($, e: any) => {
466 const flag = e.needs_you === true
467 let found = false
468 await mutate($, list =>
469 list.map(r => {
470 if (r.sessionId !== e.session_id) return r
471 found = true
472 return { ...r, note: e.note, needsYou: e.needs_you === undefined ? r.needsYou : flag }
473 }),
474 )
475 if (!found) return { result: say($, `Not tracked: ${e.session_id}; the note was not saved.`) }
476 return { result: say($, flag ? `Needs you: ${e.note}` : `Noted on ${e.session_id}.`, flag) }
477 })
478
479 on('tool.call', { tool: 'mcp__fleet-tracker__untrack' }, async ($, e: any) => {
480 const id = String(e.session_id)
481 const keep = typeof e.keep_reason === 'string' ? e.keep_reason.trim() : ''
482 const drop = () => {
483 lastWoken.delete(id)
484 stallOf.delete(id)
485 closeoutClaim.delete(id)
486 waitClaim.delete(id)
487 return mutate($, list => list.filter(r => r.sessionId !== id))
488 }
489 // Deliberately kept open: no archive, the reason goes in the result for the record.
490 if (keep) {
491 await drop()
492 return { result: say($, `Untracked ${id} WITHOUT archiving; kept open because: ${keep}`) }
493 }
494 // Judge the bucket from a fresh poll, not a row up to 90 s old.
495 const row = (await poll($)).find(r => r.sessionId === id)
496 if (!row) return { result: say($, `Not tracked: ${id}; nothing archived. Archive it directly with archive_session if it is yours.`) }
497 if (row.error) {
498 return {
499 result: say($, `Refused: could not read ${row.ticket}'s current state (${row.error}); archiving blind could destroy unpushed work. Not archived, still tracked. Retry, or pass keep_reason to untrack without archiving.`),
500 }
501 }
502 if (!CLOSABLE_BUCKETS.has(row.bucket)) {
503 return {
504 result: say($, `Refused: ${row.ticket} is ${row.bucket || 'not yet polled'}; only a COMPLETED or REVIEW_READY session is archived (anything else may still hold unpushed work). Not archived, still tracked. Wait for it to finish, or pass keep_reason to untrack without archiving.`),
505 }
506 }
507 const archived: { isError?: boolean } & { content: readonly unknown[] } = await $.mcp
508 .call(SERVER, 'archive_session', { session_id: id })
509 .catch((err: unknown) => ({ isError: true, content: [{ type: 'text', text: String(err) }] }))
510 if (archived.isError) {
511 const err = textOf(archived).slice(0, 200) || 'archive_session failed'
512 // Never read meaning into error text (CI review, PR #1816). Archiving an archived
513 // session succeeds outright (measured 2026-10-15), so an error is a real failure,
514 // unless the session's OWN status now reads ARCHIVED (e.g. it was archived meanwhile).
515 if (await isArchived($, id)) {
516 await drop()
517 return { result: say($, `${id} is archived (its status says so); untracked.`) }
518 }
519 // In `error`, not `note`: a failed archive must not clobber a person's note.
520 await mutate($, list => list.map(r => (r.sessionId === id ? { ...r, error: `archive failed: ${err}` } : r)))
521 return { result: say($, `Archive of ${id} failed, row kept: ${err}`) }
522 }
523 await drop()
524 return { result: say($, `Archived and untracked ${id}.`) }
525 })
526
527 // Rows not yet closed out, for session-close's obligation. Read-only, so no callout prefix.
528 on('tool.call', { tool: 'mcp__fleet-tracker__open_children' }, async $ => {
529 const list: FleetRow[] = await read($, rows)
530 return {
531 result:
532 `Open fleet children: ${list.length}` +
533 list.map(r => `\n- ${r.ticket} (${r.label}) ${r.bucket || 'pending'} ${r.sessionId}`).join(''),
534 }
535 })
536
537 on('tool.call', { tool: 'mcp__fleet-tracker__status' }, async $ => {
538 const list = await poll($)
539 return { result: render(list, await $.clock.now()) }
540 })
541
542 // Deliver wakes a tool-call poll could not submit, as soon as the turn ends.
543 on('turn.complete', async ($, e, next) => {
544 const res = await next(e)
545 flushWakes($)
546 return res
547 })
548
549 on('command.run', { command: 'fleet' }, async $ => {
550 await $.ui.open({ id: PANE, title: 'Fleet' })
551 return { text: 'Fleet dashboard opened.' }
552 })
553
554 // The full dashboard.
555 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
556 const { Box, Text } = $.ui.resolve(e)
557 const list: FleetRow[] = await read($, rows)
558 const now = await $.clock.now()
559 if (list.length === 0) return <Text dimColor>No sessions tracked yet.</Text>
560 return (
561 <Box flexDirection="column">
562 {list.map(r => (
563 <Box flexDirection="column" key={r.sessionId}>
564 <Text bold color={r.needsYou || r.stallEscalated ? 'error' : r.nudgedAt ? 'warning' : r.bucket === 'WORKING' ? 'suggestion' : 'text'} wrap="truncate-end">
565 {r.needsYou ? 'NEEDS YOU ' : ''}{r.ticket} {shown(r)} ${r.costUsd.toFixed(2)} {ago(r.lastActivity || r.updatedAt, now)} ago
566 </Text>
567 <Text dimColor wrap="truncate-end"> {r.label}{r.summary ? ` — ${r.summary}` : ''}</Text>
568 {r.note ? <Text color={r.needsYou ? 'warning' : 'subtle'} wrap="wrap"> {r.note}</Text> : null}
569 {r.error ? <Text color="warning" wrap="truncate-end"> poll error: {r.error}</Text> : null}
570 </Box>
571 ))}
572 </Box>
573 )
574 })
575
576 // One-line band above the prompt so the fleet is visible without the pane.
577 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
578 const list: FleetRow[] = await read($, rows)
579 if (list.length === 0 || e.props.hasSurvey) return next(e)
580 const { Box, Text } = $.ui.resolve(e)
581 const count = (b: string) => list.filter(r => r.bucket === b).length
582 const needs = list.filter(r => r.needsYou)
583 return (
584 <Box>
585 <Text color={needs.length ? 'error' : 'subtle'}>
586 Fleet: {count('WORKING')} working · {count('REVIEW_READY')} review · {count('COMPLETED')} done
587 {list.some(r => r.stallEscalated) ? ` · STALLED: ${list.filter(r => r.stallEscalated).map(r => r.ticket).join(', ')}` : ''}
588 {needs.length ? ` · NEEDS YOU: ${needs.map(r => r.ticket).join(', ')}` : ''}
589 </Text>
590 </Box>
591 )
592 })
593}
594hooks/logic.ts 279 lines1// Pure wake decision for fleet-tracker, split out so leave-and-return cycles
2// can be tested directly (the mod harness re-arms its poll timer only once per
3// test, so a multi-transition sequence cannot be driven through the timer).
4
5export const NEEDS_STAN = new Set(['BLOCKED', 'FAILED'])
6const WAKE_ORCHESTRATOR = new Set(['BLOCKED', 'FAILED', 'REVIEW_READY', 'COMPLETED', 'NEEDS_INPUT'])
7
8// SUPPRESSION IS EXACT PHRASES ONLY; ANYTHING ELSE WAKES.
9// An extra wake costs the orchestrator one cheap turn; a missed wake loses a READY
10// report for good. Four rounds of widening regexes each produced a new false
11// suppression, so the filter is an anchored allowlist: a summary is "just waiting
12// on CI" only when its WHOLE trimmed text is one of these short phrases, with an
13// optional leading "PR #N" and trailing "on <sha|number|#N>" reference. Do NOT widen
14// this with free-text patterns; add a new exact phrase and a test instead.
15const WAIT_PHRASE = new RegExp(
16 '^(?:pr\\s*#\\d+[,:]?\\s+)?(?:' + [
17 'waiting (?:on|for) (?:ci|checks|ci checks|re-?run)(?: to (?:pass|finish|complete|go green))?',
18 'awaiting (?:ci|checks|re-?run)',
19 '(?:ci|checks?)(?: is| are)?(?: still)? (?:running|re-?running|restarting|pending|queued|in progress|polling)',
20 'polling for completion',
21 ].join('|') + ')(?:\\s+(?:on|for)\\s+(?:#\\d+|\\d+|[0-9a-f]{3,40}))?\\s*[.!]?$',
22 'i',
23)
24// `READY:` / `NEEDS STAN:` are the protocol our briefs require at the end of a turn.
25// Anywhere in a summary they mean "not just waiting", checked before the allowlist,
26// so they can never be filtered.
27const PROTOCOL = /\b(READY|NEEDS STAN):/i
28
29function justWaitingOn(summary: string): boolean {
30 if (PROTOCOL.test(summary)) return false
31 return WAIT_PHRASE.test(summary.trim())
32}
33
34export function decideWake(
35 lastWoken: Map<string, string>,
36 sessionId: string,
37 prevBucket: string,
38 bucket: string,
39 summary: string,
40): { wake: boolean; holdPrevBucket: boolean } {
41 // Two polls can overlap (timer + a status/track call) and both see the old
42 // bucket; the memory covers only that overlap. Leaving the bucket clears it,
43 // so a later re-entry (BLOCKED -> RUNNING -> BLOCKED) wakes again.
44 if (lastWoken.has(sessionId) && lastWoken.get(sessionId) !== bucket) lastWoken.delete(sessionId)
45 // A session that says it is just waiting on CI has nothing for the
46 // orchestrator yet (seen 2026-10-07). Never applies to BLOCKED/FAILED.
47 const justWaiting = !NEEDS_STAN.has(bucket) && justWaitingOn(summary)
48 const alreadyWoken = lastWoken.get(sessionId) === bucket
49 // A just-created session briefly reports REVIEW_READY/COMPLETED with an
50 // empty summary before its first turn ends (seen 2026-10-07); not a signal.
51 const startupBlip = prevBucket === '' && !summary.trim() && !NEEDS_STAN.has(bucket)
52 // A suppressed wake must not record the new bucket on the row: otherwise the
53 // next poll (now with a real summary) sees no transition and the wake is lost.
54 // Hold the PREVIOUS bucket (not ''): '' would make a later empty-summary poll
55 // look like a brand-new session and get filtered as a blip. For a blip the
56 // previous bucket is already ''.
57 const wakeBucketEntry = bucket !== prevBucket && WAKE_ORCHESTRATOR.has(bucket)
58 if (wakeBucketEntry && (startupBlip || justWaiting)) return { wake: false, holdPrevBucket: true }
59 if (wakeBucketEntry && !alreadyWoken) {
60 lastWoken.set(sessionId, bucket)
61 return { wake: true, holdPrevBucket: false }
62 }
63 return { wake: false, holdPrevBucket: false }
64}
65
66// ---- Stall detection (LC-1834) ----
67// A hung session never changes bucket: it sits in WORKING, so the wake gate above
68// never fires (operator report, 2026-10-15: a hung child sat for an hour with nobody nudging it).
69// get_session's updated_at only moves at turn boundaries (measured 2026-10-15: it
70// stayed put across a dozen tool calls in one turn), so progress is read from the
71// newest transcript event instead, which moves on every tool call and reply.
72// Presence is not progress (CLAUDE.md §4): WORKING with no new event IS the stall.
73export const STALL_MS = 15 * 60_000 // a single long test run can sit quiet ~10 min
74export const ESCALATE_MS = 10 * 60_000 // how long a nudge gets before the orchestrator hears
75
76export type StallStep = 'none' | 'clear' | 'nudge' | 'escalate'
77
78export function decideStall(
79 bucket: string,
80 lastActivityMs: number | null,
81 now: number,
82 nudgedAt: number,
83 escalated: boolean,
84): StallStep {
85 const flagged = nudgedAt > 0 || escalated
86 // A bad poll's UNKNOWN is not a bucket change: clearing on it would restart the
87 // nudge cycle on a flapping session (review finding 6).
88 if (bucket === 'UNKNOWN') return 'none'
89 // Only a session mid-turn can be hung; every other bucket already wakes on entry.
90 if (bucket !== 'WORKING') return flagged ? 'clear' : 'none'
91 if (lastActivityMs === null) {
92 // Unknown activity never STARTS a stall (it is evidence about our poll). But once
93 // nudged, a transcript we cannot read for the whole window must not hide the
94 // stall forever: the orchestrator hears (review finding 5).
95 if (nudgedAt && !escalated && now - nudgedAt >= ESCALATE_MS) return 'escalate'
96 return 'none'
97 }
98 if (now - lastActivityMs < STALL_MS) return flagged ? 'clear' : 'none'
99 if (!nudgedAt) return 'nudge'
100 if (!escalated && now - nudgedAt >= ESCALATE_MS) return 'escalate'
101 return 'none'
102}
103
104// Newest event time from a list_events reply, skipping our own nudge (its delivery
105// is an event too, and must not read as the session waking up). null = unreadable.
106export function newestActivity(listEventsText: string): number | null {
107 const start = listEventsText.indexOf('{'), end = listEventsText.lastIndexOf('}')
108 if (start < 0 || end <= start) return null
109 let data: unknown
110 try {
111 const parsed = JSON.parse(listEventsText.slice(start, end + 1))
112 data = (parsed.ccr ?? parsed).data
113 } catch {
114 return null
115 }
116 if (!Array.isArray(data)) return null
117 let best: number | null = null
118 for (const ev of data) {
119 // Only the delivered message (a user event) is skipped: a reply quoting it is real work.
120 if ((ev as { user?: unknown })?.user && JSON.stringify(ev).includes(NUDGE_MARK)) continue
121 const t = Date.parse(String((ev as { created_at?: unknown })?.created_at ?? ''))
122 if (!Number.isNaN(t) && (best === null || t > best)) best = t
123 }
124 return best
125}
126
127export const NUDGE_MARK = '[mod:fleet-tracker] stall-nudge'
128export const NUDGE_TEXT =
129 `${NUDGE_MARK}: automated check from your orchestrator's fleet monitor. This session has shown ` +
130 'no new activity for 15+ minutes. If you stopped to ask whether this is really your task: it is, ' +
131 'continue it. If you are waiting on a long command that is still making progress (a CI run, a ' +
132 'sweep), do NOT stop it: reply with one line saying what you are waiting on and keep waiting. ' +
133 'Stop a command only if you have confirmed it is hung. If something genuinely blocks you, end ' +
134 'your turn with a line starting `NEEDS STAN:` saying what; that line is how your orchestrator hears you.'
135
136// Jira key from a session title or brief, for auto-tracked rows.
137export function ticketOf(...texts: unknown[]): string {
138 for (const t of texts) {
139 const m = String(t ?? '').match(/\b[A-Z][A-Z0-9]{1,9}-\d+\b/)
140 if (m) return m[0]
141 }
142 return 'untagged'
143}
144
145// The new child's id from a create_session result. Structured fields first; the text
146// fallback takes only real-length ids and never the caller's own (review finding 1:
147// a first-match regex caught the literal key `session_id`, or the parent's id, and
148// the mod then watched -- and could nudge -- the orchestrator itself).
149const ID = /\bsession_[0-9A-Za-z]{16,}\b/g
150
151export function childIdOf(resultText: string, ownId: string): string | null {
152 const ok = (v: unknown) => typeof v === 'string' && /^session_[0-9A-Za-z]{16,}$/.test(v) && v !== ownId
153 const start = resultText.indexOf('{'), end = resultText.lastIndexOf('}')
154 // Without our own id the child cannot be told from the parent in a structured field,
155 // so the structured path is skipped and the text scan must find exactly one id.
156 if (ownId !== '' && start >= 0 && end > start) {
157 try {
158 const j = JSON.parse(resultText.slice(start, end + 1))
159 // ALL valid ids in the structured fields, not the first (PR #1808 review): a result
160 // naming two different sessions must not be settled by field order.
161 const found = [...new Set([j?.id, j?.session_id, j?.ccr?.id, j?.session?.id, j?.ccr?.session_id].filter(ok) as string[])]
162 if (found.length === 1) return found[0]
163 } catch { /* fall through to the text scan */ }
164 }
165 const ids = [...new Set(resultText.match(ID) ?? [])].filter(ok)
166 // Exactly one candidate or nothing: a guess would track the wrong session.
167 return ids.length === 1 ? ids[0] : null
168}
169
170// ---- Close-out reminder (LC-1835) ----
171// Claude is responsible for the sessions it spins up (the operator, 2026-10-15): a child that
172// finished and was never archived is a leak. A row that has sat in COMPLETED or
173// REVIEW_READY for CLOSEOUT_MS without changing bucket gets ONE wake per bucket episode.
174export const CLOSEOUT_MS = 60 * 60_000
175export const CLOSEOUT_BUCKETS = new Set(['COMPLETED', 'REVIEW_READY'])
176
177export function decideCloseout(bucket: string, bucketSince: number, now: number, alreadyWoken: boolean): boolean {
178 if (alreadyWoken || !CLOSEOUT_BUCKETS.has(bucket) || !bucketSince) return false
179 return now - bucketSince >= CLOSEOUT_MS
180}
181
182export function closeoutLine(ticket: string, label: string, bucket: string, bucketSince: number, now: number): string {
183 const mins = Math.floor((now - bucketSince) / 60_000)
184 return `${ticket} (${label}) idle in ${bucket} for ${mins}m: close it out — untrack (archives) or untrack with keep_reason`
185}
186
187// ---- Waiting reminder (LC-1881) ----
188// A child that waits on a human produces no further signal after its entry wake: decideStall
189// only reads WORKING and decideCloseout only COMPLETED / REVIEW_READY. Measured 2026-10-20:
190// session_01GqXYnPc475jd5Lfxmt9CJN sat in NEEDS_INPUT/BLOCKED from ~15:58 UTC for an hour
191// (it needed the operator to type a confirmation in that session) and the operator noticed before the orchestrator did.
192// A row that stays in NEEDS_INPUT or BLOCKED gets a wake every WAITING_REMIND_MS, measured from
193// the later of entering the bucket and the last reminder, until the bucket changes.
194export const WAITING_REMIND_MS = 20 * 60_000
195export const WAITING_BUCKETS = new Set(['NEEDS_INPUT', 'BLOCKED'])
196
197// `bucketSince` is when the row entered this waiting episode, `lastRemindedAt` when it was last
198// reminded in it (0 = never). The caller owns the clock: it must not restart it on UNKNOWN (a bad
199// poll is not a bucket change) and must restart it on any other bucket change.
200export function decideWaitingReminder(bucket: string, bucketSince: number, lastRemindedAt: number, now: number): boolean {
201 if (!WAITING_BUCKETS.has(bucket) || !bucketSince) return false
202 return now - Math.max(bucketSince, lastRemindedAt) >= WAITING_REMIND_MS
203}
204
205// The child names a person, not a machine, as the one who must act. A person word on its own,
206// or a phrase that only a prompt waiting on a person produces. Bare "types", "approve" and
207// "permission" are NOT enough: "permission denied reading the lockfile" and "several types of
208// merge conflicts" are machine text (CI review of PR #1909).
209const HUMAN_NEEDED =
210 /\b(?:stan|the operator|user|human|owner)\b|\boperator (?:approval|decision|input)\b|\bwaiting (?:on|for) operator\b|\bpermission (?:prompt|rule)\b|\bneeds (?:your )?approval\b|\bapproval needed\b/i
211
212export function needsHuman(...texts: unknown[]): boolean {
213 return texts.some(t => HUMAN_NEEDED.test(String(t ?? '')))
214}
215
216export const HUMAN_ACTION =
217 'A relayed message cannot satisfy this. Tell the operator in chat now, starting the reply with the NEEDS YOU banner, include the session link, and set the note needs_you=true. Do not use PushNotification.'
218
219// A human-needed child leads its wake line with `NEEDS STAN:` and says what the orchestrator must do.
220export function humanWrap(line: string, sessionId: string, needsAction: string, statusDetail: string): string {
221 if (!needsHuman(needsAction, statusDetail)) return line
222 return `NEEDS STAN: ${line} (https://claude.ai/code/${sessionId}). ${HUMAN_ACTION}`
223}
224
225// Reminder line for a child still waiting: how long, and what it is waiting for.
226export function waitingLine(
227 ticket: string, label: string, sessionId: string, bucket: string, waitedMs: number,
228 needsAction: string, statusDetail: string,
229): string {
230 const text = (needsAction || statusDetail).trim()
231 const mins = Math.floor(waitedMs / 60_000)
232 return humanWrap(`${ticket} (${label}) still waiting in ${bucket} for ${mins}m: ${text}`, sessionId, needsAction, statusDetail)
233}
234
235// Buckets in which archiving would lose live work (unpushed commits, an unanswered question).
236export const LIVE_BUCKETS = new Set(['WORKING', 'NEEDS_INPUT'])
237// Archive is an ALLOWLIST (review of LC-1835): only a child that a fresh, error-free
238// poll shows as finished may be archived. A denylist of live buckets let a stale row
239// (poll error kept the old COMPLETED), UNKNOWN, '' (not yet polled) or BLOCKED through,
240// and archiving a session that still holds unpushed work destroys it.
241export const CLOSABLE_BUCKETS = new Set(['COMPLETED', 'REVIEW_READY'])
242
243
244// LC-1843: a child spawned in default permission mode that calls a claude-code-remote
245// tool (report back with send_message, arm a check-in with send_later) opens a permission
246// prompt nobody is watching, and sits in NEEDS_INPUT until the orchestrator notices. Two
247// children stalled this way in one hour on 2026-10-19. Every create_session therefore
248// pre-approves the tools a child legitimately uses to talk to its parent. The platform drops
249// any entry the parent itself has not pre-approved, so this can never widen a grant.
250export const CHILD_REMOTE_TOOLS = [
251 'mcp__claude-code-remote__send_message',
252 'mcp__claude-code-remote__send_later',
253]
254
255// The pre-approval alone is not enough: probes on 2026-10-19 showed the child still prompted
256// (session_01Qdxi4smDu2NopVYu5iXfDE: the parent lacks the grant, so the platform drops it;
257// session_01TLmP2Mv2ig879JrahPF1nB: a user-settings allow written mid-session did not apply).
258// The child never NEEDS these tools: fleet-tracker already wakes the parent on every state
259// change and the parent reads the child's transcript. So every child is also told, in its
260// system prompt, not to call them and to end each turn with its result instead.
261export const CHILD_NOTE =
262 'Orchestrator note (fleet-tracker, LC-1843): your parent session watches your transcript and is woken ' +
263 'when your state changes. Do NOT call claude-code-remote tools (send_message, send_later, create_session, ' +
264 'create_trigger): in this session they open a permission prompt nobody is watching and you stall. End each ' +
265 'turn with your result in your final message (PR URL, head SHA, CI state, anything unverified).'
266
267// Returns the create_session input with CHILD_REMOTE_TOOLS merged into extra_allowed_tools
268// (caller's entries first, no duplicates) and CHILD_NOTE appended to append_system_prompt
269// (after the caller's text, once).
270export function withChildTools<T extends { extra_allowed_tools?: unknown; append_system_prompt?: unknown }>(input: T): T {
271 const given = Array.isArray(input.extra_allowed_tools)
272 ? input.extra_allowed_tools.filter((t): t is string => typeof t === 'string')
273 : []
274 const merged = [...given, ...CHILD_REMOTE_TOOLS.filter(t => !given.includes(t))]
275 const prior = typeof input.append_system_prompt === 'string' ? input.append_system_prompt : ''
276 const note = prior.includes(CHILD_NOTE) ? prior : [prior, CHILD_NOTE].filter(Boolean).join('\n\n')
277 return { ...input, extra_allowed_tools: merged, append_system_prompt: note }
278}
279types/index.d.ts 36 lines1export type FleetRow = {
2 sessionId: string
3 ticket: string
4 label: string
5 bucket: string
6 status: string
7 summary: string
8 costUsd: number
9 updatedAt: string
10 checkedAt: number
11 note: string
12 needsYou: boolean
13 error: string
14 // Stall state (optional so rows saved by the previous version still load).
15 lastActivity?: string
16 nudgedAt?: number
17 stallEscalated?: boolean
18 // Close-out reminder state (LC-1835; optional so older rows load): when the row
19 // entered its current bucket, and when its one reminder for this episode was sent.
20 bucketSince?: number
21 closeoutWokeAt?: number
22 // Waiting reminder state (LC-1881; optional so older rows load): the waiting bucket the clock
23 // runs for, when the row entered it, when it was last reminded, and the child's own words.
24 waitBucket?: string
25 waitSince?: number
26 waitRemindedAt?: number
27 needsAction?: string
28 statusDetail?: string
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'fleet-tracker': { rows: FleetRow[] }
34 }
35}
36