switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -17,6 +17,7 @@ function makeSub(over: Partial<Subagent>): Subagent {
17
17
  result_summary: null,
18
18
  jsonl_agent_id: 'a37ad7639ae61476c',
19
19
  parent_agent_id: null,
20
+ model: null,
20
21
  ...over,
21
22
  }
22
23
  }
@@ -61,6 +62,85 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
61
62
  // flip a foreground turn into a background one.
62
63
  expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
63
64
  })
65
+
66
+ it('surfaces the persisted registry model as feedModel (first-paint fallback)', () => {
67
+ const sub = makeSub({ background: true, model: 'claude-opus-4-8' })
68
+ expect(resolveWorkerFeedDispatch(sub, 'sub-agent').feedModel).toBe('claude-opus-4-8')
69
+ })
70
+
71
+ it('feedModel is null when the row is missing or carries no model', () => {
72
+ expect(resolveWorkerFeedDispatch(null, 'sub-agent').feedModel).toBeNull()
73
+ expect(resolveWorkerFeedDispatch(makeSub({ model: null }), 'sub-agent').feedModel).toBeNull()
74
+ })
75
+ })
76
+
77
+ describe('gateway onFinish — fix #1: resultText-driven handback fallback (model)', () => {
78
+ // Mirrors the exact fallback gateway.ts's onFinish handler applies on top
79
+ // of `resolveWorkerFeedDispatch`'s own result (gateway.ts, onFinish
80
+ // handler, right after computing `dispatch`):
81
+ //
82
+ // let isBackground = dispatch.isBackground
83
+ // if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
84
+ // isBackground = true
85
+ // }
86
+ //
87
+ // This is the worst case of the #1 bug: the DB row was never linked
88
+ // (unlinked `jsonl_agent_id`) AND the watcher's own cached
89
+ // `entry.background` was also never observed (both `resolveWorkerFeedDispatch`
90
+ // fallbacks came up empty). A finished worker with actual narrative
91
+ // result text is far more likely a dropped background handback than a
92
+ // legitimate foreground no-op, so this degrades to background rather
93
+ // than silently discarding the result.
94
+ function applyOnFinishBackgroundFallback(
95
+ dispatch: { isBackground: boolean; hasRow: boolean },
96
+ entryBackground: boolean | undefined,
97
+ resultText: string,
98
+ ): boolean {
99
+ let isBackground = dispatch.isBackground
100
+ if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
101
+ isBackground = true
102
+ }
103
+ return isBackground
104
+ }
105
+
106
+ it('meta-missing path: no row, no entry.background, non-empty resultText → routes a handback', () => {
107
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
108
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Here is what I found...')
109
+ expect(result).toBe(true)
110
+ })
111
+
112
+ it('no-fuzzy-candidate path: no row, no entry.background, non-empty resultText → routes a handback', () => {
113
+ // Same shape as above — the ambiguous-fuzzy-match-refused case (fix #3)
114
+ // also leaves jsonl_agent_id unlinked, landing in the same no-row state.
115
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
116
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Task complete: 3 files changed.')
117
+ expect(result).toBe(true)
118
+ })
119
+
120
+ it('empty resultText with no row/no entry.background does NOT escalate (true foreground no-op preserved)', () => {
121
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
122
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, ' ')
123
+ expect(result).toBe(false)
124
+ })
125
+
126
+ it('entry.background=false (watcher DID observe it as foreground) is trusted over resultText', () => {
127
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', false)
128
+ const result = applyOnFinishBackgroundFallback(dispatch, false, 'Some prose result anyway.')
129
+ expect(result).toBe(false)
130
+ })
131
+
132
+ it('entry.background=true (watcher DID observe it) is trusted directly, no resultText fallback needed', () => {
133
+ const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', true)
134
+ const result = applyOnFinishBackgroundFallback(dispatch, true, '')
135
+ expect(result).toBe(true)
136
+ })
137
+
138
+ it('a present DB row is authoritative — the resultText fallback never overrides a real foreground row', () => {
139
+ const sub = makeSub({ background: false })
140
+ const dispatch = resolveWorkerFeedDispatch(sub, 'sub-agent', undefined)
141
+ const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Long narrative result...')
142
+ expect(result).toBe(false)
143
+ })
64
144
  })
65
145
 
66
146
  // Deterministic PRNG (mulberry32) so a failing case is reproducible from the
@@ -140,6 +220,52 @@ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
140
220
  })
141
221
  })
142
222
 
223
+ describe('resolveWorkerFeedDispatch — fix #1(+#2): entryBackground fallback', () => {
224
+ it('falls back to entryBackground=true when the registry row is missing (unlinked jsonl_agent_id)', () => {
225
+ // Simulates onFinish firing for a background worker whose DB row was
226
+ // never linked via `jsonl_agent_id` (meta.json missing, or the fuzzy
227
+ // backfill match never fired) — the watcher's own cached
228
+ // `entry.background` (seeded at dispatch time / kept fresh by
229
+ // `readSubTail`'s liveness lookup) is the only signal left.
230
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent', true)
231
+ expect(out.isBackground).toBe(true)
232
+ expect(out.hasRow).toBe(false)
233
+ })
234
+
235
+ it('falls back to entryBackground=false when the row is missing and the entry was foreground', () => {
236
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent', false)
237
+ expect(out.isBackground).toBe(false)
238
+ })
239
+
240
+ it('defaults to false when the row is missing AND entryBackground was never observed (undefined)', () => {
241
+ // Both fallbacks come up empty — worst case, matches pre-fix
242
+ // behaviour (isBackground=false) at the resolver level. The gateway's
243
+ // onFinish handler applies its OWN further fallback on top of this
244
+ // (routing a handback anyway when resultText is non-empty) — that's
245
+ // pinned separately in the gateway-level test, not here.
246
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
247
+ expect(out.isBackground).toBe(false)
248
+ expect(out.hasRow).toBe(false)
249
+ })
250
+
251
+ it('a present registry row always wins over entryBackground, even when they disagree', () => {
252
+ // The DB row is the authoritative source whenever it's actually
253
+ // linked — entryBackground is a pure fallback for the unlinked case,
254
+ // never a second vote.
255
+ const sub = makeSub({ background: true })
256
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent', false)
257
+ expect(out.isBackground).toBe(true)
258
+ })
259
+
260
+ it('omitting entryBackground entirely preserves prior 2-arg call-site behaviour', () => {
261
+ // #2002-era callers pass only 2 args — the optional 3rd param must not
262
+ // change their observed result.
263
+ expect(resolveWorkerFeedDispatch(null, 'sub-agent')).toEqual(
264
+ resolveWorkerFeedDispatch(null, 'sub-agent', undefined),
265
+ )
266
+ })
267
+ })
268
+
143
269
  describe('resolveWorkerFeedDispatch — nested/row-presence signals (unified progress cards)', () => {
144
270
  it('hasRow=false for a missing registry row (never silently foreground-nest it)', () => {
145
271
  const out = resolveWorkerFeedDispatch(null, 'sub-agent')
@@ -91,6 +91,7 @@ import {
91
91
  } from './status-no-truncate.js'
92
92
  import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
93
93
  import { isTelegramSurfaceTool } from './tool-names.js'
94
+ import { formatModelLabel } from './model-label.js'
94
95
 
95
96
  /**
96
97
  * Optional header for the main-session activity card, matching the worker
@@ -106,6 +107,14 @@ export interface SessionActivityHeader {
106
107
  elapsedMs: number
107
108
  toolCount: number
108
109
  state: 'running' | 'done'
110
+ /**
111
+ * Live model in use, as a raw resolved model id (e.g. `claude-opus-4-8`,
112
+ * `sr-glm-5`) sourced from the transcript. Rendered as a short friendly form
113
+ * appended to the metrics line (`… · opus 4.8`). Omitted when unknown — the
114
+ * card never guesses from config. Formatting + sentinel-filtering live in
115
+ * `formatModelLabel` (model-label.ts).
116
+ */
117
+ model?: string
109
118
  }
110
119
 
111
120
  /**
@@ -173,14 +182,19 @@ export function renderActivityHeader(
173
182
  elapsedMs: number,
174
183
  toolCount: number,
175
184
  state: 'running' | 'done' | 'failed',
185
+ model?: string,
176
186
  ): [string, string] {
177
187
  const toolWord = toolCount === 1 ? 'tool' : 'tools'
178
188
  const elapsed = formatFeedElapsed(elapsedMs)
179
189
  const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
180
190
  const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
191
+ // Subtle live-model tag: joins the existing dot-separated metrics (never a new
192
+ // line). formatModelLabel returns null for absent/sentinel values → no suffix.
193
+ const modelLabel = formatModelLabel(model)
194
+ const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
181
195
  const line2 = state === 'running'
182
- ? `_${elapsed} · ${toolCount} ${toolWord}_`
183
- : `_${state} · ${toolCount} ${toolWord} · ${elapsed}_`
196
+ ? `_${elapsed} · ${toolCount} ${toolWord}${modelPart}_`
197
+ : `_${state} · ${toolCount} ${toolWord} · ${elapsed}${modelPart}_`
184
198
  return [line1, line2]
185
199
  }
186
200
 
@@ -270,6 +284,9 @@ export interface StatusCardHeader {
270
284
  elapsedMs: number
271
285
  toolCount: number
272
286
  state: 'running' | 'done' | 'failed'
287
+ /** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
288
+ * tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
289
+ model?: string
273
290
  }
274
291
 
275
292
  /** Inputs to the unified status-card renderer. */
@@ -321,6 +338,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
321
338
  // when the result block is empty. The agent surface never passes
322
339
  // 'failed', so only the worker card is affected.
323
340
  header.state,
341
+ header.model,
324
342
  )
325
343
  : []
326
344
 
@@ -476,6 +494,7 @@ export function renderActivityFeed(
476
494
  elapsedMs: header.elapsedMs,
477
495
  toolCount: header.toolCount,
478
496
  state: header.state,
497
+ model: header.model,
479
498
  }
480
499
  : undefined,
481
500
  steps: lines,
@@ -527,6 +546,7 @@ export function renderActivityFeedWithNested(
527
546
  elapsedMs: header.elapsedMs,
528
547
  toolCount: header.toolCount,
529
548
  state: header.state,
549
+ model: header.model,
530
550
  }
531
551
  : undefined,
532
552
  steps: lines,
@@ -1,10 +1,15 @@
1
1
  // Auto-wrap tool dispatch with a Telegram typing-indicator loop so the user
2
- // sees a live "agent is working" signal during the 3–30s gap where the
3
- // progress card is deliberately suppressed (its initialDelayMs is 3s).
4
- // The first tool call on a given (chat, thread) fires the typing loop
5
- // immediately so there's no silent dead window before the progress card
6
- // appears. Subsequent calls on the same lane honour the debounce to avoid
7
- // churn. Surface tools own their own loopsee isSurfaceTool.
2
+ // sees a live "agent is working" signal for the run of a turn. This is now
3
+ // the PRIMARY liveness signal for short tool-using turns, not a bridge to a
4
+ // soon-to-appear pinned progress card: the pinned card is retired
5
+ // (`unpinProgressCardForChat = null`, see
6
+ // `docs/diagrams/deterministic-status-anatomy.spec.md`), and the worker
7
+ // progress card only gates in at `elapsed >= 60s OR sub-agent appeared` for
8
+ // most short turns it never appears at all. The first tool call on a given
9
+ // (chat, thread) fires the typing loop immediately so there's no silent dead
10
+ // window at turn start. Subsequent calls on the same lane honour the
11
+ // debounce to avoid churn. Surface tools own their own loop — see
12
+ // isSurfaceTool.
8
13
  //
9
14
  // Keying changed from `chatId` to `(chatId, threadId)` in PR3 of the
10
15
  // supergroup-mode rollout. In supergroup mode one agent owns many topics
@@ -38,17 +43,55 @@ export interface TypingWrapper {
38
43
  interface Entry {
39
44
  chatId: string
40
45
  threadId: number | null
46
+ lane: string
41
47
  timer: ReturnType<typeof setTimeout>
48
+ }
49
+
50
+ /**
51
+ * Per-lane bookkeeping: `count` is the number of outstanding (dispatched but
52
+ * not yet resulted) tool-uses on the lane; `started` is whether the typing
53
+ * loop is currently believed to be running for it. Ref-counted rather than a
54
+ * boolean-per-lane `Set` (pre-fix#7 shape) because two PARALLEL tool_use
55
+ * blocks on one lane must both hold the lane open: with a plain Set,
56
+ * `onToolResult` for the first one deleted the lane and stopped the loop
57
+ * while the second tool was still running, flickering the typing indicator
58
+ * off until the next tool re-fired it. Only the count reaching zero may stop
59
+ * the loop.
60
+ */
61
+ interface LaneState {
62
+ count: number
42
63
  started: boolean
43
64
  }
44
65
 
45
66
  export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
46
67
  const debounceMs = deps.debounceMs ?? 500
47
68
  const pending = new Map<string, Entry>()
48
- // Track per-(chat,thread) lanes that already have an active typing loop
49
- // so the first tool call on a lane fires immediately while subsequent
50
- // calls on the same lane use the debounce.
51
- const activeLanes = new Set<string>()
69
+ const lanes = new Map<string, LaneState>()
70
+
71
+ function laneFor(lane: string): LaneState {
72
+ let s = lanes.get(lane)
73
+ if (s == null) {
74
+ s = { count: 0, started: false }
75
+ lanes.set(lane, s)
76
+ }
77
+ return s
78
+ }
79
+
80
+ // Decrement the lane's outstanding count. Returns true iff the count just
81
+ // reached zero AND the loop was running — i.e. iff the CALLER must now
82
+ // call `stopTypingLoop`. This is the ref-count release: the loop only
83
+ // stops once every outstanding tool-use on the lane has resulted.
84
+ function release(lane: string): boolean {
85
+ const s = lanes.get(lane)
86
+ if (s == null) return false
87
+ s.count = Math.max(0, s.count - 1)
88
+ if (s.count === 0) {
89
+ const wasStarted = s.started
90
+ lanes.delete(lane)
91
+ return wasStarted
92
+ }
93
+ return false
94
+ }
52
95
 
53
96
  return {
54
97
  onToolUse(toolUseId, chatId, toolName, threadId) {
@@ -60,19 +103,21 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
60
103
  const prior = pending.get(toolUseId)
61
104
  if (prior) {
62
105
  clearTimeout(prior.timer)
63
- if (prior.started) deps.stopTypingLoop(prior.chatId, prior.threadId)
106
+ if (release(prior.lane)) deps.stopTypingLoop(prior.chatId, prior.threadId)
64
107
  pending.delete(toolUseId)
65
108
  }
66
- // First tool on this lane: fire immediately rather than waiting for
67
- // the debounce — this closes the silent dead window before the first
68
- // progress card appears.
69
- if (!activeLanes.has(lane)) {
109
+ const state = laneFor(lane)
110
+ state.count += 1
111
+ // First outstanding tool-use on this lane: fire immediately rather
112
+ // than waiting for the debounce — this closes the silent dead window
113
+ // at turn start.
114
+ if (state.count === 1) {
70
115
  deps.startTypingLoop(chatId, tid)
71
- activeLanes.add(lane)
116
+ state.started = true
72
117
  const entry: Entry = {
73
118
  chatId,
74
119
  threadId: tid,
75
- started: true,
120
+ lane,
76
121
  timer: setTimeout(() => {}, 0), // no-op sentinel
77
122
  }
78
123
  pending.set(toolUseId, entry)
@@ -81,10 +126,10 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
81
126
  const entry: Entry = {
82
127
  chatId,
83
128
  threadId: tid,
84
- started: false,
129
+ lane,
85
130
  timer: setTimeout(() => {
86
131
  deps.startTypingLoop(chatId, tid)
87
- entry.started = true
132
+ state.started = true
88
133
  }, debounceMs),
89
134
  }
90
135
  pending.set(toolUseId, entry)
@@ -95,20 +140,22 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
95
140
  const entry = pending.get(toolUseId)
96
141
  if (!entry) return
97
142
  clearTimeout(entry.timer)
98
- if (entry.started) {
99
- deps.stopTypingLoop(entry.chatId, entry.threadId)
100
- activeLanes.delete(chatKey(entry.chatId, entry.threadId) as string)
101
- }
143
+ if (release(entry.lane)) deps.stopTypingLoop(entry.chatId, entry.threadId)
102
144
  pending.delete(toolUseId)
103
145
  },
104
146
 
105
147
  drainAll() {
148
+ const stoppedLanes = new Set<string>()
106
149
  for (const entry of pending.values()) {
107
150
  clearTimeout(entry.timer)
108
- if (entry.started) deps.stopTypingLoop(entry.chatId, entry.threadId)
151
+ const state = lanes.get(entry.lane)
152
+ if (state?.started && !stoppedLanes.has(entry.lane)) {
153
+ deps.stopTypingLoop(entry.chatId, entry.threadId)
154
+ stoppedLanes.add(entry.lane)
155
+ }
109
156
  }
110
157
  pending.clear()
111
- activeLanes.clear()
158
+ lanes.clear()
112
159
  },
113
160
  }
114
161
  }