SLOPSHOPPER

fleet-tracker

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet-tracker
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ No sessions tracked yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ ⎿ fleet-tracker: Fleet dashboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
No sessions tracked yet.
README

bbm-mods

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 user
Source 3 files
hooks/register.tsx 594 lines
1import { 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}
594
hooks/logic.ts 279 lines
1// 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}
279
types/index.d.ts 36 lines
1export 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