switchroom 0.5.0 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +503 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +558 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -26,6 +26,8 @@ import {
26
26
  type SubAgentState,
27
27
  } from './progress-card.js'
28
28
  import { isTelegramReplyTool } from './tool-names.js'
29
+ import { emitCardEvent } from './card-event-log.js'
30
+ import { createHash } from 'crypto'
29
31
  import {
30
32
  applyCapped as fleetApplyCapped,
31
33
  applyToolResult as fleetApplyToolResult,
@@ -217,9 +219,26 @@ export interface ProgressDriverConfig {
217
219
  * starts (see `promoteOnSubAgent`) — long-running tool work and
218
220
  * background dispatches stay visible without waiting the full delay.
219
221
  *
220
- * Default 60000 (60 seconds, #553 PR 4). Set to 0 to disable.
222
+ * Default 45000 (45 seconds, #842). Set to 0 to disable.
221
223
  */
222
224
  initialDelayMs?: number
225
+ /**
226
+ * First-render delay (ms) override for explicit background sub-agent
227
+ * dispatches (#842). When the agent calls
228
+ * `Agent({ run_in_background: true })`, the card is promoted out of
229
+ * the suppression window using this delay instead of `initialDelayMs`.
230
+ * Default 0 (immediate render — backgrounded work should be visible
231
+ * right away).
232
+ *
233
+ * Implementation: at `tool_use` ingest time the driver detects the
234
+ * background flag (existing `cs.backgroundParentToolUseIds` book-
235
+ * keeping). If `initialDelayMsBackground` is 0 the card promotes
236
+ * immediately via `promoteFirstEmit`. If positive, the deferred timer
237
+ * is rescheduled to fire that many ms from turn start (or now, if
238
+ * already past) — but only when shorter than what's currently
239
+ * scheduled. Never lengthens an in-flight delay.
240
+ */
241
+ initialDelayMsBackground?: number
223
242
  /**
224
243
  * Promote the first emit immediately when a sub-agent transitions to
225
244
  * running during the suppression window, when the watcher fires
@@ -417,6 +436,16 @@ interface PerChatState {
417
436
  isFirstEmit: boolean
418
437
  /** Timer for the deferred first emit (initial-delay suppression). */
419
438
  deferredFirstEmitTimer: unknown
439
+ /**
440
+ * #842: per-chat first-emit delay budget in ms. Initialised to
441
+ * `config.initialDelayMs`; lowered to `config.initialDelayMsBackground`
442
+ * the first time the parent dispatches an Agent/Task with
443
+ * `run_in_background: true`. Never increases. flush() reads this
444
+ * (instead of the closure-level `initialDelayMs`) when scheduling the
445
+ * deferred first-emit timer so the background bypass takes effect on
446
+ * the next scheduling pass.
447
+ */
448
+ effectiveInitialDelayMs: number
420
449
  /**
421
450
  * F3 fix (#553): timer for the time-based first-emit promotion.
422
451
  * Scheduled on the first ingest event; fires after `promoteAfterMs`
@@ -751,6 +780,18 @@ export interface ProgressDriver {
751
780
  * No-op if no card is currently tracking this `agentId`.
752
781
  */
753
782
  onSubAgentStall(agentId: string, idleMs: number, description: string): void
783
+ /**
784
+ * Symmetric to `onSubAgentStall`. Fires when the watcher observes
785
+ * JSONL activity returning for a previously-stalled sub-agent. Forces
786
+ * a re-render so the ⚠ Stalled badge clears immediately, instead of
787
+ * waiting on the next heartbeat tick (which the diff-guard might
788
+ * suppress if no chat-level state otherwise changed). The render
789
+ * itself reads the now-current `sa.lastEventAt` (already bumped by
790
+ * the standard event path), so this method is purely a render-trigger.
791
+ *
792
+ * No-op if no card is currently tracking this `agentId`.
793
+ */
794
+ onSubAgentUnstall(agentId: string, description: string): void
754
795
  /**
755
796
  * Test-only accessor exposing the driver's internal Maps so unit tests
756
797
  * can assert TTL eviction and outer-base-key cleanup actually drop
@@ -799,20 +840,29 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
799
840
  const editBudgetThreshold = config.editBudgetThreshold ?? 18
800
841
  const editBudgetCoalesceMs = config.editBudgetCoalesceMs ?? 3000
801
842
  const maxIdleMs = config.maxIdleMs ?? 30 * 60_000
802
- // v2 card-gate (#553 PR 4): card visibility is `(elapsed >= 60s) OR
803
- // (any sub-agent appeared)`. Tools alone never trigger the card.
804
- // - initialDelayMs: 60s (was 30s) — pushes the time-based gate to
805
- // the spec value.
843
+ // v2 card-gate (#553 PR 4 / #842): card visibility is `(elapsed >= 45s)
844
+ // OR (any sub-agent appeared) OR (explicit background dispatch)`.
845
+ // Tools alone never trigger the card.
846
+ // - initialDelayMs: 45s (was 60s, #842) — pushes the time-based gate
847
+ // to the spec value. The lower threshold means more turns flash a
848
+ // card; the explicit-background bypass below offsets that for the
849
+ // "fire-and-forget" case where the user always wants to see the
850
+ // card immediately.
851
+ // - initialDelayMsBackground: 0 (#842) — explicit
852
+ // `Agent({run_in_background:true})` dispatches promote the card
853
+ // immediately. Lets backgrounded work be visible right away
854
+ // without waiting for any other promotion path.
806
855
  // - promoteOnParentToolCount: 0 (was 3) — disabled. The check below
807
856
  // treats 0 (and Infinity) as "never promote on tool count".
808
857
  // - promoteAfterMs: 0 (was 5_000) — disabled. ensureTimePromoteScheduled
809
858
  // no-ops when this is 0, so the timer never schedules. The PR #570
810
859
  // time-promote was a stop-gap when initialDelayMs was 30s; with
811
- // initialDelayMs=60s and the sub-agent promote intact, it is no
860
+ // initialDelayMs=45s and the sub-agent promote intact, it is no
812
861
  // longer needed.
813
862
  // - promoteOnSubAgent: true (unchanged) — sub-agents/background workers
814
863
  // break the suppression immediately.
815
- const initialDelayMs = config.initialDelayMs ?? 60_000
864
+ const initialDelayMs = config.initialDelayMs ?? 45_000
865
+ const initialDelayMsBackground = config.initialDelayMsBackground ?? 0
816
866
  const promoteOnSubAgent = config.promoteOnSubAgent ?? true
817
867
  const promoteOnParentToolCount = config.promoteOnParentToolCount ?? 0
818
868
  const promoteAfterMs = config.promoteAfterMs ?? 0
@@ -985,6 +1035,13 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
985
1035
  }
986
1036
  if (config.onTurnComplete) {
987
1037
  process.stderr.write(`telegram gateway: progress-card: onTurnComplete firing turnKey=${cs.turnKey}\n`)
1038
+ emitCardEvent({
1039
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
1040
+ chatId: cs.chatId ?? '',
1041
+ turnKey: cs.turnKey,
1042
+ event: 'finalized',
1043
+ reason: 'onTurnComplete',
1044
+ })
988
1045
  try {
989
1046
  config.onTurnComplete({
990
1047
  chatId: cs.chatId,
@@ -1050,6 +1107,13 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1050
1107
  if (hasAnyRunningSubAgent(cs.state)) return
1051
1108
  if (hasLiveBackground(cs.fleet)) return
1052
1109
  process.stderr.write(`telegram gateway: progress-card: deferred completion firing turnKey=${cs.turnKey} (last sub-agent finished)\n`)
1110
+ emitCardEvent({
1111
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
1112
+ chatId: cs.chatId ?? '',
1113
+ turnKey: cs.turnKey,
1114
+ event: 'force-completed',
1115
+ reason: 'deferred-completion: last sub-agent finished',
1116
+ })
1053
1117
  // Route through the unified close path (turn-end reason) so the
1054
1118
  // prelude (silentEnd suppression, final flush, tail cleanup) matches
1055
1119
  // every other completion site.
@@ -1505,27 +1569,45 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1505
1569
  // Suppress the card entirely if the turn ends before the initial
1506
1570
  // delay has elapsed — no point flashing a "Working…" card for a
1507
1571
  // turn that completed in under initialDelayMs.
1508
- if (chatState.isFirstEmit && initialDelayMs > 0 && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED) {
1572
+ const effectiveDelayMs = chatState.effectiveInitialDelayMs
1573
+ if (chatState.isFirstEmit && effectiveDelayMs > 0 && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED) {
1509
1574
  if (forceDone || chatState.state.stage === 'done') {
1510
1575
  // Turn ended before the card was ever shown — suppress it.
1511
1576
  if (chatState.deferredFirstEmitTimer != null) {
1512
1577
  clearT(chatState.deferredFirstEmitTimer)
1513
1578
  chatState.deferredFirstEmitTimer = null
1514
1579
  }
1515
- process.stderr.write(`telegram gateway: progress-card: fast-turn suppression turnKey=${chatState.turnKey} (turn ended before initialDelayMs=${initialDelayMs}ms)\n`)
1580
+ process.stderr.write(`telegram gateway: progress-card: fast-turn suppression turnKey=${chatState.turnKey} (turn ended before initialDelayMs=${effectiveDelayMs}ms)\n`)
1581
+ emitCardEvent({
1582
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
1583
+ chatId: chatState.chatId ?? '',
1584
+ turnKey: chatState.turnKey,
1585
+ event: 'suppressed',
1586
+ reason: `fast-turn: ended before initialDelayMs=${effectiveDelayMs}`,
1587
+ })
1516
1588
  return
1517
1589
  }
1518
- // Defer the first emit — schedule it for initialDelayMs from now
1519
- // if not already scheduled.
1590
+ // Defer the first emit — schedule it for the per-chat budget from
1591
+ // turn start if not already scheduled. Uses
1592
+ // `chatState.effectiveInitialDelayMs` so the #842 background-
1593
+ // dispatch bypass (which lowers this number on tool_use) takes
1594
+ // effect on the very next flush.
1520
1595
  if (chatState.deferredFirstEmitTimer == null) {
1521
1596
  const capturedTurnKey = chatState.turnKey
1522
- process.stderr.write(`telegram gateway: progress-card: scheduled initial-delay timer turnKey=${capturedTurnKey} delay=${initialDelayMs}ms\n`)
1597
+ // Schedule from turn start, not from now — multiple flush
1598
+ // attempts during the buffering window must not push the
1599
+ // first-emit clock back.
1600
+ const elapsed = chatState.state.turnStartedAt > 0
1601
+ ? Math.max(0, now() - chatState.state.turnStartedAt)
1602
+ : 0
1603
+ const remaining = Math.max(0, effectiveDelayMs - elapsed)
1604
+ process.stderr.write(`telegram gateway: progress-card: scheduled initial-delay timer turnKey=${capturedTurnKey} delay=${remaining}ms budget=${effectiveDelayMs}ms\n`)
1523
1605
  chatState.deferredFirstEmitTimer = setT(() => {
1524
1606
  if (!chats.has(capturedTurnKey)) return
1525
1607
  chatState.deferredFirstEmitTimer = DELAY_ELAPSED
1526
1608
  process.stderr.write(`telegram gateway: progress-card: initial-delay timer fired turnKey=${capturedTurnKey}\n`)
1527
1609
  flush(chatState, false)
1528
- }, initialDelayMs)
1610
+ }, remaining)
1529
1611
  }
1530
1612
  return
1531
1613
  }
@@ -1613,6 +1695,18 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1613
1695
  ? { replyToMessageId: chatState.replyToMessageId }
1614
1696
  : {}),
1615
1697
  })
1698
+ // #card-audit-log: structured lifecycle entry for retroactive audit.
1699
+ // Mirrors the existing free-text traces but is grep-able by turnKey.
1700
+ emitCardEvent({
1701
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
1702
+ chatId: chatState.chatId ?? '',
1703
+ turnKey: chatState.turnKey,
1704
+ event: isFirst ? 'rendered' : (terminal ? 'finalized' : 'edited'),
1705
+ reason: terminal ? 'flush-terminal' : (isFirst ? 'flush-first' : 'flush-edit'),
1706
+ htmlHash: html.length > 0
1707
+ ? createHash('sha1').update(html).digest('hex').slice(0, 12)
1708
+ : undefined,
1709
+ })
1616
1710
  }
1617
1711
 
1618
1712
  /**
@@ -1758,6 +1852,7 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1758
1852
  role,
1759
1853
  startedAt: now(),
1760
1854
  originatingTurnKey: currentTurnKey ?? cs.turnKey,
1855
+ isBackgroundDispatch: isBackground,
1761
1856
  })
1762
1857
  cs.fleet.set(event.agentId, isBackground ? { ...member, status: 'background' } : member)
1763
1858
  return
@@ -1813,6 +1908,9 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1813
1908
  // a stuck condition. `originatingTurnKey` has no legacy
1814
1909
  // counterpart — fall back to the current/active turn.
1815
1910
  const startedAt = sa.startedAt > 0 ? sa.startedAt : now()
1911
+ const isBg =
1912
+ sa.parentToolUseId != null &&
1913
+ cs.backgroundParentToolUseIds.has(sa.parentToolUseId)
1816
1914
  cs.fleet.set(
1817
1915
  agentId,
1818
1916
  createFleetMember({
@@ -1820,6 +1918,7 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1820
1918
  role: sa.description ?? 'agent',
1821
1919
  startedAt,
1822
1920
  originatingTurnKey: currentTurnKey ?? cs.turnKey,
1921
+ isBackgroundDispatch: isBg,
1823
1922
  }),
1824
1923
  )
1825
1924
  }
@@ -1979,6 +2078,7 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
1979
2078
  pendingTimer: null,
1980
2079
  isFirstEmit: true,
1981
2080
  deferredFirstEmitTimer: null,
2081
+ effectiveInitialDelayMs: initialDelayMs,
1982
2082
  timePromoteTimer: null,
1983
2083
  lastEventAt: now(),
1984
2084
  pendingCompletion: false,
@@ -2125,6 +2225,60 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
2125
2225
  promoteFirstEmit(chatState, 'sub_agent_started')
2126
2226
  }
2127
2227
 
2228
+ // #842: explicit background dispatch bypass. When the parent calls
2229
+ // `Agent({ run_in_background: true })`, swap the active delay
2230
+ // budget over to `initialDelayMsBackground` instead of the longer
2231
+ // `initialDelayMs`. Detection: an Agent/Task tool_use whose
2232
+ // `event.input.run_in_background === true` (the same flag
2233
+ // `updateFleetForEvent` uses to populate
2234
+ // `cs.backgroundParentToolUseIds` for fleet membership).
2235
+ //
2236
+ // - `initialDelayMsBackground === 0` (default) → promote now.
2237
+ // - `initialDelayMsBackground > 0` → set
2238
+ // `cs.effectiveInitialDelayMs` so the next flush() schedules
2239
+ // (or reschedules) the deferred timer at the lower budget.
2240
+ // Never lengthens an existing budget.
2241
+ if (
2242
+ event.kind === 'tool_use'
2243
+ && (event.toolName === 'Agent' || event.toolName === 'Task')
2244
+ && event.toolUseId != null
2245
+ && event.input?.run_in_background === true
2246
+ && chatState.isFirstEmit
2247
+ && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED
2248
+ && !chatState.apiFailures.terminal
2249
+ ) {
2250
+ if (initialDelayMsBackground <= 0) {
2251
+ promoteFirstEmit(chatState, 'background_dispatch')
2252
+ } else if (initialDelayMsBackground < chatState.effectiveInitialDelayMs) {
2253
+ chatState.effectiveInitialDelayMs = initialDelayMsBackground
2254
+ // If a longer-budget timer is already scheduled, cancel and
2255
+ // reschedule against the new budget. Compute the remaining
2256
+ // gap from turn start; if we're already past it, promote.
2257
+ if (chatState.deferredFirstEmitTimer != null) {
2258
+ const elapsed = now() - chatState.state.turnStartedAt
2259
+ const remaining = initialDelayMsBackground - elapsed
2260
+ clearT(chatState.deferredFirstEmitTimer)
2261
+ if (remaining <= 0) {
2262
+ chatState.deferredFirstEmitTimer = null
2263
+ promoteFirstEmit(chatState, 'background_dispatch_elapsed')
2264
+ } else {
2265
+ const capturedTurnKey = chatState.turnKey
2266
+ process.stderr.write(
2267
+ `telegram gateway: progress-card: rescheduled initial-delay timer turnKey=${capturedTurnKey} delay=${remaining}ms reason=background_dispatch\n`,
2268
+ )
2269
+ chatState.deferredFirstEmitTimer = setT(() => {
2270
+ if (!chats.has(capturedTurnKey)) return
2271
+ chatState.deferredFirstEmitTimer = DELAY_ELAPSED
2272
+ process.stderr.write(
2273
+ `telegram gateway: progress-card: initial-delay timer fired turnKey=${capturedTurnKey} reason=background_dispatch\n`,
2274
+ )
2275
+ flush(chatState, false)
2276
+ }, remaining)
2277
+ }
2278
+ }
2279
+ }
2280
+ }
2281
+
2128
2282
  // #478 / #553 PR 4: promote the card when the agent has issued
2129
2283
  // enough parent-side tool calls during the suppression window.
2130
2284
  // Disabled by default in v2 (promoteOnParentToolCount=0 / Infinity)
@@ -2250,6 +2404,14 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
2250
2404
  if (m.status === 'background' && m.terminalAt == null) background.push(k)
2251
2405
  }
2252
2406
  process.stderr.write(`telegram gateway: progress-card: turn_end deferred turnKey=${chatState.turnKey} reason=in-flight-sub-agents correlated=${correlated.length} orphans=${orphans.length} background=${background.length} correlatedAgentIds=[${correlated.join(',')}] orphanAgentIds=[${orphans.join(',')}] backgroundAgentIds=[${background.join(',')}]\n`)
2407
+ emitCardEvent({
2408
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
2409
+ chatId: chatState.chatId ?? '',
2410
+ turnKey: chatState.turnKey,
2411
+ event: 'deferred',
2412
+ reason: `turn_end: in-flight-sub-agents correlated=${correlated.length} orphans=${orphans.length} background=${background.length}`,
2413
+ subagents: [...correlated, ...orphans, ...background],
2414
+ })
2253
2415
  return
2254
2416
  }
2255
2417
  closePerChat(chatState, 'turn-end')
@@ -2363,6 +2525,13 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
2363
2525
  // running sub-agents just because the final reply was sent.
2364
2526
  if (target.completionFired) return
2365
2527
  process.stderr.write(`telegram gateway: progress-card: forceCompleteTurn turnKey=${target.turnKey} (external completion signal, e.g. stream_reply done=true)\n`)
2528
+ emitCardEvent({
2529
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
2530
+ chatId: target.chatId ?? '',
2531
+ turnKey: target.turnKey,
2532
+ event: 'force-completed',
2533
+ reason: 'external completion signal (stream_reply done=true)',
2534
+ })
2366
2535
  const durationMs = Math.max(0, now() - target.state.turnStartedAt)
2367
2536
  beginTurnEnd(target, durationMs)
2368
2537
  target.lastEventAt = now()
@@ -2678,6 +2847,26 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
2678
2847
  }
2679
2848
  },
2680
2849
 
2850
+ onSubAgentUnstall(agentId: string, _description: string) {
2851
+ // Symmetric to onSubAgentStall: watcher saw JSONL activity return.
2852
+ // The standard event path has already bumped sa.lastEventAt and
2853
+ // (for tool events) flipped fleet member status stuck→running via
2854
+ // applyToolUse. All this method needs to do is force a re-render
2855
+ // so the ⚠ badge clears immediately — the diff-guard can otherwise
2856
+ // suppress the heartbeat for several seconds if no chat-level
2857
+ // state changed, which manifests as the badge lingering even
2858
+ // though the underlying state is fresh.
2859
+ for (const cs of chats.values()) {
2860
+ if (!cs.state.subAgents.has(agentId)) continue
2861
+ const sa = cs.state.subAgents.get(agentId)!
2862
+ if (sa.state !== 'running') continue
2863
+ lastHeartbeatBucket.delete(cs.turnKey)
2864
+ lastSubAgentTickBucket.delete(cs.turnKey)
2865
+ if (chats.size > 0) startHeartbeatIfNeeded()
2866
+ break
2867
+ }
2868
+ },
2869
+
2681
2870
  /**
2682
2871
  * Test-only accessor. Returns the live internal Maps so tests can
2683
2872
  * assert TTL eviction and outer-base-key cleanup actually drop
@@ -504,7 +504,7 @@ export function reduce(
504
504
  id: state.items.length,
505
505
  toolUseId: event.toolUseId ?? null,
506
506
  tool: event.toolName,
507
- label: toolLabel(event.toolName, event.input, preamble),
507
+ label: toolLabel(event.toolName, event.input, preamble, event.precomputedLabel),
508
508
  humanAuthored: isHumanDescription(event.toolName, event.input),
509
509
  state: 'running',
510
510
  startedAt: now,
@@ -833,7 +833,7 @@ export function reduce(
833
833
  currentTool: event.toolUseId
834
834
  ? {
835
835
  tool: event.toolName,
836
- label: toolLabel(event.toolName, event.input, preamble),
836
+ label: toolLabel(event.toolName, event.input, preamble, event.precomputedLabel),
837
837
  humanAuthored: isHumanDescription(event.toolName, event.input),
838
838
  toolUseId: event.toolUseId,
839
839
  startedAt: now,
@@ -358,6 +358,7 @@ export async function fetchAccountQuota(
358
358
  writeAccountQuota(
359
359
  label,
360
360
  snapshotFromQuotaUtilization(result.data, new Date(now)),
361
+ opts.home,
361
362
  );
362
363
  } catch {
363
364
  /* best-effort */
@@ -24,6 +24,9 @@
24
24
  *
25
25
  * Status transitions:
26
26
  * running → stalled (via recordSubagentStall — no ended_at, may resume)
27
+ * stalled → running (via recordSubagentResume — JSONL activity returned
28
+ * before terminal; closes the resume edge the watcher
29
+ * documented but never wired)
27
30
  * running → completed (via recordSubagentEnd)
28
31
  * running → failed (via recordSubagentEnd)
29
32
  * stalled → completed (via recordSubagentEnd — terminal beats stalled)
@@ -139,6 +142,14 @@ export interface BumpSubagentActivityArgs {
139
142
  ts: number
140
143
  }
141
144
 
145
+ export interface RecordSubagentResumeArgs {
146
+ id: string
147
+ /** Wall-clock when the resume was observed. Not stored — last_activity_at
148
+ * is updated separately by bumpSubagentActivity. Available for callers
149
+ * that want to log it. */
150
+ resumedAt: number
151
+ }
152
+
142
153
  export interface ReapStuckRunningArgs {
143
154
  /**
144
155
  * Maximum age (ms since `last_activity_at`, or since `started_at` for rows
@@ -458,6 +469,32 @@ export function reapStuckRunningRows(
458
469
  return { reaped: candidates.length, ids: candidates.map((r) => r.id) }
459
470
  }
460
471
 
472
+ /**
473
+ * Reverse the stalled→running edge when JSONL activity returns. Mirror of
474
+ * `recordSubagentStall` for the resume direction the schema doc has always
475
+ * promised but the watcher never implemented (the cause of "card freezes
476
+ * at ⚠ Stalled even after sub-agent resumes / completes" — see
477
+ * subagent-watcher.ts checkStalls + bumpSubagentActivity).
478
+ *
479
+ * Idempotent + safe:
480
+ * - Only flips rows where status is currently 'stalled'. A row that's
481
+ * already 'running' is untouched (no-op UPDATE). A terminal row
482
+ * ('completed' / 'failed') stays terminal — terminal beats both
483
+ * stalled and running.
484
+ * - No-ops gracefully if `id` is not found.
485
+ * - last_activity_at is NOT touched here — callers separately call
486
+ * bumpSubagentActivity for the activity bump on the same tick.
487
+ */
488
+ export function recordSubagentResume(db: SqliteDatabase, args: RecordSubagentResumeArgs): void {
489
+ void args.resumedAt // available for log lines; not persisted (started_at + last_activity_at carry the timing)
490
+ db.prepare(`
491
+ UPDATE subagents
492
+ SET status = 'running'
493
+ WHERE id = ?
494
+ AND status = 'stalled'
495
+ `).run(args.id)
496
+ }
497
+
461
498
  /**
462
499
  * Bump `last_activity_at` for a subagent. Used by the watcher (Phase 3) each
463
500
  * time the subagent's JSONL file mtime advances.
@@ -24,6 +24,7 @@ import {
24
24
  recordSubagentStart,
25
25
  recordSubagentEnd,
26
26
  recordSubagentStall,
27
+ recordSubagentResume,
27
28
  bumpSubagentActivity,
28
29
  getSubagent,
29
30
  reapStuckRunningRows,
@@ -230,6 +231,69 @@ describe('start → stall → end', () => {
230
231
  })
231
232
  })
232
233
 
234
+ // ---------------------------------------------------------------------------
235
+ // Test 4b — recordSubagentResume (stalled → running edge)
236
+ // ---------------------------------------------------------------------------
237
+ //
238
+ // The schema doc (subagents-schema.ts:26) has always promised
239
+ // "running → stalled (may resume)" but the resume edge wasn't
240
+ // implemented — leaving the registry stuck at 'stalled' even when
241
+ // JSONL activity returned. recordSubagentResume closes that gap.
242
+
243
+ describe('recordSubagentResume — stalled → running edge', () => {
244
+ it('flips a stalled row back to running', () => {
245
+ const db = openFreshSubagentsDbInMemory()
246
+ recordSubagentStart(db, { id: 'sa-r1', background: false, startedAt: 1000 })
247
+ recordSubagentStall(db, { id: 'sa-r1', stalledAt: 1500 })
248
+ expect(getSubagent(db, 'sa-r1')!.status).toBe('stalled')
249
+ recordSubagentResume(db, { id: 'sa-r1', resumedAt: 2000 })
250
+ const row = getSubagent(db, 'sa-r1')
251
+ expect(row!.status).toBe('running')
252
+ expect(row!.ended_at).toBeNull()
253
+ db.close()
254
+ })
255
+
256
+ it('is a no-op on a row that is already running', () => {
257
+ // Idempotency: the watcher fires resume on the first activity tick
258
+ // after a stall, but the same code path is also reached during
259
+ // normal activity bumps where stallNotified is already false. We
260
+ // never want a redundant resume to spuriously demote a row.
261
+ const db = openFreshSubagentsDbInMemory()
262
+ recordSubagentStart(db, { id: 'sa-r2', background: false, startedAt: 1000 })
263
+ recordSubagentResume(db, { id: 'sa-r2', resumedAt: 2000 })
264
+ expect(getSubagent(db, 'sa-r2')!.status).toBe('running')
265
+ db.close()
266
+ })
267
+
268
+ it('is a no-op on a completed row — terminal beats resume', () => {
269
+ const db = openFreshSubagentsDbInMemory()
270
+ recordSubagentStart(db, { id: 'sa-r3', background: false, startedAt: 1000 })
271
+ recordSubagentEnd(db, { id: 'sa-r3', endedAt: 2000, status: 'completed' })
272
+ recordSubagentResume(db, { id: 'sa-r3', resumedAt: 3000 })
273
+ const row = getSubagent(db, 'sa-r3')
274
+ expect(row!.status).toBe('completed')
275
+ expect(row!.ended_at).toBe(2000)
276
+ db.close()
277
+ })
278
+
279
+ it('is a no-op on a failed row', () => {
280
+ const db = openFreshSubagentsDbInMemory()
281
+ recordSubagentStart(db, { id: 'sa-r4', background: false, startedAt: 1000 })
282
+ recordSubagentEnd(db, { id: 'sa-r4', endedAt: 2000, status: 'failed' })
283
+ recordSubagentResume(db, { id: 'sa-r4', resumedAt: 3000 })
284
+ expect(getSubagent(db, 'sa-r4')!.status).toBe('failed')
285
+ db.close()
286
+ })
287
+
288
+ it('is a no-op on a missing row (graceful)', () => {
289
+ const db = openFreshSubagentsDbInMemory()
290
+ expect(() =>
291
+ recordSubagentResume(db, { id: 'sa-nope', resumedAt: 1000 }),
292
+ ).not.toThrow()
293
+ db.close()
294
+ })
295
+ })
296
+
233
297
  // ---------------------------------------------------------------------------
234
298
  // Test 5 — Duplicate start is a no-op
235
299
  // ---------------------------------------------------------------------------
@@ -36,6 +36,7 @@ import { homedir } from 'os'
36
36
  import { basename, join } from 'path'
37
37
  import { isMultiAgentEnabled } from './progress-card.js'
38
38
  import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
39
+ import { createToolLabelSidecar, type ToolLabelSidecar } from './tool-label-sidecar.js'
39
40
 
40
41
  /** Match Claude Code's cli.js VX() function. */
41
42
  export function sanitizeCwdToProjectName(cwd: string): string {
@@ -86,7 +87,7 @@ export type SessionEvent =
86
87
  | { kind: 'enqueue'; chatId: string | null; messageId: string | null; threadId: string | null; rawContent: string; isSync?: boolean }
87
88
  | { kind: 'dequeue' }
88
89
  | { kind: 'thinking' }
89
- | { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown> }
90
+ | { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown>; precomputedLabel?: string }
90
91
  | { kind: 'text'; text: string }
91
92
  | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
92
93
  | { kind: 'turn_end'; durationMs: number }
@@ -94,7 +95,7 @@ export type SessionEvent =
94
95
  // filename stem (e.g. "aac6f1…"). Routed through the same ingest path
95
96
  // as parent events; the reducer fans them out to per-sub-agent state.
96
97
  | { kind: 'sub_agent_started'; agentId: string; firstPromptText: string; subagentType?: string }
97
- | { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown> }
98
+ | { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown>; precomputedLabel?: string }
98
99
  | { kind: 'sub_agent_text'; agentId: string; text: string }
99
100
  | { kind: 'sub_agent_narrative'; agentId: string; text: string }
100
101
  | { kind: 'sub_agent_tool_result'; agentId: string; toolUseId: string; isError?: boolean; errorText?: string }
@@ -499,11 +500,53 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
499
500
  const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
500
501
  const rescanMs = config.rescanIntervalMs ?? 500
501
502
  const log = config.log
502
- const onEvent = config.onEvent
503
+ const rawOnEvent = config.onEvent
503
504
  const onOperatorEvent = config.onOperatorEvent
504
505
 
505
506
  log?.(`session-tail: projectsDir=${projectsDir}`)
506
507
 
508
+ // PreToolUse sidecar readers (#783) keyed by sessionId. Created lazily
509
+ // the first time we observe a tool_use / sub_agent_tool_use whose
510
+ // toolUseId could be looked up. The hook writes to
511
+ // $TELEGRAM_STATE_DIR/tool-labels-<session_id>.jsonl. Each sub-agent
512
+ // has its OWN sessionId (its jsonl filename stem), so we key by that.
513
+ const sidecars = new Map<string, ToolLabelSidecar>()
514
+ const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null
515
+ function sessionIdForFile(file: string | null): string | null {
516
+ if (!file) return null
517
+ const b = file.endsWith('.jsonl') ? basename(file, '.jsonl') : null
518
+ return b && b.length > 0 ? b : null
519
+ }
520
+ function ensureSidecar(sessionId: string): ToolLabelSidecar | null {
521
+ if (!stateDirForSidecar) return null
522
+ const existing = sidecars.get(sessionId)
523
+ if (existing) return existing
524
+ try {
525
+ const s = createToolLabelSidecar({ stateDir: stateDirForSidecar, sessionId })
526
+ sidecars.set(sessionId, s)
527
+ return s
528
+ } catch (err) {
529
+ log?.(`session-tail: sidecar create failed: ${(err as Error).message}`)
530
+ return null
531
+ }
532
+ }
533
+ function decorate(ev: SessionEvent, sessionId: string | null): SessionEvent {
534
+ if (!sessionId) return ev
535
+ if (ev.kind !== 'tool_use' && ev.kind !== 'sub_agent_tool_use') return ev
536
+ if (!ev.toolUseId) return ev
537
+ const s = ensureSidecar(sessionId)
538
+ if (!s) return ev
539
+ // One quick poll attempt before lookup — the hook is synchronous from
540
+ // Claude Code's perspective and the sidecar line is typically on disk
541
+ // before the JSONL row is appended, but the file watcher is on a
542
+ // 250ms tick. Forcing a poll closes the race for the common case.
543
+ s.poll()
544
+ const label = s.getLabel(ev.toolUseId)
545
+ if (!label) return ev
546
+ return { ...ev, precomputedLabel: label }
547
+ }
548
+ const onEvent = (ev: SessionEvent): void => rawOnEvent(ev)
549
+
507
550
  let currentFile: string | null = null
508
551
  let cursor = 0 // byte offset of next read
509
552
  let watcher: FSWatcher | null = null
@@ -550,9 +593,10 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
550
593
  for (const line of lines) {
551
594
  if (!line) continue
552
595
  const events = projectTranscriptLine(line)
596
+ const sid = sessionIdForFile(currentFile)
553
597
  for (const ev of events) {
554
598
  try {
555
- onEvent(ev)
599
+ onEvent(decorate(ev, sid))
556
600
  } catch (err) {
557
601
  log?.(`session-tail: onEvent threw: ${(err as Error).message}`)
558
602
  }
@@ -721,7 +765,12 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
721
765
  t.hasSeenTerminal = true
722
766
  }
723
767
  try {
724
- onEvent(ev)
768
+ // Sub-agent JSONLs have their own sessionId (the file's stem
769
+ // — sub-agent files are typically named agent-<id>.jsonl).
770
+ // Hook fires inside the sub-agent process with that
771
+ // session_id, so we look up the sidecar by it.
772
+ const subSid = sessionIdForFile(t.file)
773
+ onEvent(decorate(ev, subSid))
725
774
  } catch (err) {
726
775
  log?.(`session-tail: sub onEvent threw: ${(err as Error).message}`)
727
776
  }
@@ -872,6 +921,10 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
872
921
  }
873
922
  }
874
923
  subTails.clear()
924
+ for (const s of sidecars.values()) {
925
+ try { s.stop() } catch { /* ignore */ }
926
+ }
927
+ sidecars.clear()
875
928
  if (pollTimer) {
876
929
  clearInterval(pollTimer)
877
930
  pollTimer = null