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
@@ -33,6 +33,28 @@
33
33
  * delivery obligation.
34
34
  */
35
35
 
36
+ // Verified complete (2026-07-09, adversarial-review follow-up): `reply`
37
+ // and `stream_reply` are the ONLY two MCP tools whose payload is the
38
+ // model's free-text final-answer content reaching the user — the exact
39
+ // scope `final-answer-detect.ts`'s own docstring claims ("plain assistant
40
+ // transcript text instead of a `reply` / `stream_reply` tool call").
41
+ // Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
42
+ // (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
43
+ // explicitly does NOT ping/deliver a fresh answer (its own description says
44
+ // "send a new reply when a long task completes"); `react`, `pin_message`,
45
+ // `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
46
+ // `get_recent_messages` carry no model-authored answer text at all;
47
+ // `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
48
+ // `update_checklist` deliver structured/templated content, not the turn's
49
+ // prose answer, and are intentionally a different interaction pattern (a
50
+ // question or a fixed artifact, not "the answer"). `stream_reply` sends its
51
+ // FULL cumulative text snapshot on every call (not incremental chunks —
52
+ // see `stream-reply-handler.ts` docstring), and each call is its own
53
+ // `tool_use` block in the transcript in chronological order, so the
54
+ // "last delivery event wins" walk below already treats a stream's final
55
+ // (`done:true`) call as the qualifying one regardless of how many
56
+ // intermediate non-final `stream_reply` calls preceded it. No gap found;
57
+ // re-verify only if a new outbound-delivery tool is added to bridge.ts.
36
58
  const REPLY_TOOLS = new Set([
37
59
  'mcp__switchroom-telegram__reply',
38
60
  'mcp__switchroom-telegram__stream_reply',
@@ -126,24 +148,47 @@ function buildTurnKey(chatId, threadId) {
126
148
  return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
127
149
  }
128
150
 
151
+ /**
152
+ * Build the `{ decided: 'block', ... }` result shape, populating
153
+ * `turnKey`/`chatId`/`threadId` from the enqueue envelope when
154
+ * available. Shared by both block branches below.
155
+ *
156
+ * @param {ReturnType<typeof parseChannelEnvelope>} envelope
157
+ * @param {string} reason
158
+ */
159
+ function buildBlockResult(envelope, reason) {
160
+ const block = { decided: 'block', reason }
161
+ if (envelope.chatId) {
162
+ block.chatId = envelope.chatId
163
+ block.threadId = envelope.threadId
164
+ block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
165
+ }
166
+ return block
167
+ }
168
+
129
169
  /**
130
170
  * Scan a JSONL transcript and decide whether the current turn ended
131
171
  * with a final reply delivered.
132
172
  *
133
173
  * Returns:
134
- * { decided: 'allow', reason } — qualifying reply OR silent marker found
174
+ * { decided: 'allow', reason } — qualifying reply OR silent marker found,
175
+ * and nothing undelivered was written
176
+ * after it
135
177
  * { decided: 'block', reason, turnKey?, chatId?, threadId? }
136
- * — turn-start found, no qualifying reply,
137
- * no marker. `turnKey`/`chatId`/`threadId`
138
- * populated from the enqueue's channel
139
- * envelope so the hook can write a state
140
- * file shape that matches what the
141
- * gateway's `recordSilentTurnEnd` would
142
- * write keeping the retry-count
143
- * preservation gate at
144
- * `silent-end.ts:114` happy when the
145
- * gateway's later write reads back the
146
- * hook's state.
178
+ * — turn-start found, no qualifying reply
179
+ * delivered (or a qualifying reply
180
+ * happened, but the model kept writing
181
+ * plain-text content afterward that was
182
+ * never sent through a delivery tool).
183
+ * `turnKey`/`chatId`/`threadId` populated
184
+ * from the enqueue's channel envelope so
185
+ * the hook can write a state file shape
186
+ * that matches what the gateway's
187
+ * `recordSilentTurnEnd` would write
188
+ * keeping the retry-count preservation
189
+ * gate at `silent-end.ts:114` happy when
190
+ * the gateway's later write reads back
191
+ * the hook's state.
147
192
  * { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
148
193
  *
149
194
  * Turn-start anchor: the most recent `queue-operation`/`enqueue` line
@@ -154,6 +199,38 @@ function buildTurnKey(chatId, threadId) {
154
199
  * edge case where the model replied combined ahead of the second
155
200
  * enqueue's append; accepted residual.)
156
201
  *
202
+ * IMPORTANT — trailing-content check (fixes the "at least once" bug):
203
+ * a naive scan that returns 'allow' on the FIRST qualifying reply it
204
+ * finds is wrong. A turn can legitimately call `reply` early (e.g. a
205
+ * notification-bearing interim ack — `disable_notification` unset/false
206
+ * always qualifies as "final" under `isFinalAnswerReply`, regardless of
207
+ * how short the text is) and then keep working, eventually writing a
208
+ * SUBSTANTIVE plain-text verdict that never goes through `reply` again.
209
+ * The early ack satisfied "reply was called somewhere in this turn",
210
+ * but the user never saw the actual answer. So this scan does NOT
211
+ * short-circuit on the first match: it walks the ENTIRE turn in
212
+ * chronological order, remembers the position of the LAST qualifying
213
+ * delivery event (a final-answer reply/stream_reply call, or an
214
+ * explicit silent-marker), and then checks whether any plain assistant
215
+ * text block appears AFTER that position. If one does, that content was
216
+ * written but never delivered — block, same as the zero-reply case.
217
+ *
218
+ * This deliberately does NOT flag a turn that ends on a delivery
219
+ * tool_use with nothing after it (the normal, healthy shape), nor a
220
+ * turn where all assistant text precedes the final delivering reply
221
+ * (the model narrating before it sends) — only text that comes AFTER
222
+ * the last delivery event counts as a drop.
223
+ *
224
+ * Substance floor (#2956 review): the trailing-text check only BLOCKS
225
+ * when the trailing text is SUBSTANTIVE — at least FINAL_ANSWER_MIN_CHARS
226
+ * (the same bar `isFinalAnswerReply` uses to recognise a real answer). A
227
+ * SHORT trailing pleasantry / closer after a delivered reply ("Let me
228
+ * know if you need anything else.") is not a dropped answer and must not
229
+ * trigger a re-prompt (no-spam / single-answer invariant). A long
230
+ * trailing verdict the model forgot to send still blocks. The floor keeps
231
+ * the "at least once" guarantee for real dropped answers while stopping a
232
+ * false-positive that burned retry budget on healthy turns.
233
+ *
157
234
  * @param {string} jsonl
158
235
  * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
159
236
  */
@@ -178,8 +255,13 @@ export function scanTurnForFinalReply(jsonl) {
178
255
  return { decided: 'unknown', reason: 'no-turn-start' }
179
256
  }
180
257
 
181
- // 2. Scan forward from the turn start; look for qualifying tool_use
182
- // or silent-marker text.
258
+ // 2. Flatten every assistant content block (text and tool_use) from
259
+ // the turn into a single chronologically-ordered list. Classify
260
+ // each block as it's collected: a "delivery" event (qualifying
261
+ // final-answer reply/stream_reply, or an explicit silent marker —
262
+ // whether emitted as plain text or as a reply-tool payload) or
263
+ // plain undelivered text.
264
+ const blocks = []
183
265
  for (let i = startIdx + 1; i < lines.length; i++) {
184
266
  const line = lines[i]
185
267
  if (!line || line[0] !== '{') continue
@@ -193,16 +275,28 @@ export function scanTurnForFinalReply(jsonl) {
193
275
  const content = obj?.message?.content
194
276
  if (!Array.isArray(content)) continue
195
277
  for (const c of content) {
196
- // Plain assistant text carve-out (#2053): a turn that ends with a
197
- // trailing bare NO_REPLY / HEARTBEAT_OK line emitted as plain
198
- // transcript text, NOT through the reply toolis the model
199
- // explicitly signalling "intentionally silent". The anchored
200
- // SILENT_MARKER_RE below only fires when the ENTIRE reply-tool
201
- // text is the bare marker, so a plain-text prose+NO_REPLY turn
202
- // matched nothing here block → nag → sentinel leak. Treat a
203
- // trailing-marker text block as a valid silent end.
204
- if (c?.type === 'text' && endsWithSilentMarker(String(c.text ?? ''))) {
205
- return { decided: 'allow', reason: 'silent-marker-text' }
278
+ if (c?.type === 'text') {
279
+ // Plain assistant text carve-out (#2053): a turn that ends with
280
+ // a trailing bare NO_REPLY / HEARTBEAT_OK lineemitted as
281
+ // plain transcript text, NOT through the reply tool — is the
282
+ // model explicitly signalling "intentionally silent". Treat a
283
+ // trailing-marker text block as a delivery/silence event;
284
+ // anything else is candidate undelivered content.
285
+ if (endsWithSilentMarker(String(c.text ?? ''))) {
286
+ blocks.push({ kind: 'deliver', reason: 'silent-marker-text' })
287
+ } else if (String(c.text ?? '').trim().length > 0) {
288
+ // Carry the trimmed char count so the trailing-content check
289
+ // (step 3) can apply a substance floor: a SHORT trailing text
290
+ // after a delivered reply (a pleasantry / closer like "Let me
291
+ // know if you need anything else.") is NOT a dropped answer and
292
+ // must not trigger a re-prompt (no-spam invariant). Only
293
+ // SUBSTANTIVE trailing text — at least FINAL_ANSWER_MIN_CHARS,
294
+ // the same bar `isFinalAnswerReply` uses to recognise a real
295
+ // answer — counts as "undelivered content the user was waiting
296
+ // on". #2956 review finding.
297
+ blocks.push({ kind: 'text', chars: String(c.text ?? '').trim().length })
298
+ }
299
+ continue
206
300
  }
207
301
  if (c?.type !== 'tool_use') continue
208
302
  if (!REPLY_TOOLS.has(c.name)) continue
@@ -214,33 +308,63 @@ export function scanTurnForFinalReply(jsonl) {
214
308
  // prose+trailing-marker shape (#2053). Same posture as the
215
309
  // gateway's silent-marker suppression at gateway.ts:6692.
216
310
  if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
217
- return { decided: 'allow', reason: 'silent-marker' }
311
+ blocks.push({ kind: 'deliver', reason: 'silent-marker' })
312
+ continue
218
313
  }
219
314
  if (isFinalAnswerReply({
220
315
  text,
221
316
  disableNotification: input.disable_notification === true,
222
317
  done: input.done === true,
223
318
  })) {
224
- return { decided: 'allow', reason: 'final-reply' }
319
+ blocks.push({ kind: 'deliver', reason: 'final-reply' })
320
+ continue
225
321
  }
322
+ // Non-qualifying reply call (interim ack) — delivered to the
323
+ // user, but not a "final answer". It's neither a delivery event
324
+ // nor undelivered text, so it doesn't affect the decision either
325
+ // way; simply not pushed.
226
326
  }
227
327
  }
228
328
 
229
- // Cron-fired turns (#2053): a scheduled turn that produced no
230
- // qualifying reply is NOT a delivery failure the user is waiting on —
231
- // nagging it only pushes the model to escape the loop by shoving a
232
- // NO_REPLY sentinel through the reply tool, which leaks to chat. A
233
- // cron turn that genuinely needs to speak will have called reply
234
- // (caught above); otherwise let it end silently.
235
- if (envelope.source === 'cron') {
236
- return { decided: 'allow', reason: 'cron-source' }
329
+ // 3. Find the LAST delivery event's position, then check whether any
330
+ // plain-text block appears strictly after it. This is the fix for
331
+ // the "at least once" bug: a naive scan that stops at the FIRST
332
+ // qualifying reply misses substantive content the model wrote
333
+ // afterward and never (re-)sent.
334
+ let lastAllowBlockIdx = -1
335
+ let lastAllowReason = null
336
+ for (let i = 0; i < blocks.length; i++) {
337
+ if (blocks[i].kind === 'deliver') {
338
+ lastAllowBlockIdx = i
339
+ lastAllowReason = blocks[i].reason
340
+ }
237
341
  }
342
+ const sawUndeliveredTextAfterAllow = blocks
343
+ .slice(lastAllowBlockIdx + 1)
344
+ .some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
238
345
 
239
- const block = { decided: 'block', reason: 'no-final-reply' }
240
- if (envelope.chatId) {
241
- block.chatId = envelope.chatId
242
- block.threadId = envelope.threadId
243
- block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
346
+ if (lastAllowBlockIdx === -1) {
347
+ // No qualifying delivery/silence event anywhere in the turn.
348
+ // Cron-fired turns (#2053): a scheduled turn that produced no
349
+ // qualifying reply is NOT a delivery failure the user is waiting
350
+ // on nagging it only pushes the model to escape the loop by
351
+ // shoving a NO_REPLY sentinel through the reply tool, which leaks
352
+ // to chat. A cron turn that genuinely needs to speak will have
353
+ // called reply (caught above); otherwise let it end silently.
354
+ if (envelope.source === 'cron') {
355
+ return { decided: 'allow', reason: 'cron-source' }
356
+ }
357
+ return buildBlockResult(envelope, 'no-final-reply')
244
358
  }
245
- return block
359
+
360
+ if (sawUndeliveredTextAfterAllow) {
361
+ // A qualifying delivery DID happen somewhere in the turn, but the
362
+ // model kept writing after it and that trailing content was never
363
+ // sent through a delivery tool. This is the "at least once" bug:
364
+ // an early ack (or any qualifying reply) must not amnesty
365
+ // everything written afterward.
366
+ return buildBlockResult(envelope, 'trailing-text-after-reply')
367
+ }
368
+
369
+ return { decided: 'allow', reason: lastAllowReason }
246
370
  }
@@ -55,7 +55,8 @@ const SCHEMA_SQL = `
55
55
  status TEXT NOT NULL,
56
56
  result_summary TEXT,
57
57
  jsonl_agent_id TEXT,
58
- parent_agent_id TEXT
58
+ parent_agent_id TEXT,
59
+ model TEXT
59
60
  );
60
61
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
61
62
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -157,14 +158,14 @@ function spawnSql(dbPath, sql, cb) {
157
158
  // DB write
158
159
  // ---------------------------------------------------------------------------
159
160
 
160
- function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, now }, done) {
161
+ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, model, now }, done) {
161
162
  const INSERT_SQL = `
162
163
  INSERT OR IGNORE INTO subagents
163
164
  (id, parent_session_id, parent_turn_key, agent_type, description,
164
- background, started_at, last_activity_at, status)
165
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running')
165
+ background, started_at, last_activity_at, status, model)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
166
167
  `
167
- const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now]
168
+ const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now, model ?? null]
168
169
 
169
170
  // Resolve a synchronous SQLite binding. Try in order:
170
171
  // 1. node:sqlite (Node 22+, production path) — exposes DatabaseSync
@@ -207,6 +208,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
207
208
  if (hasParentAgentCol == null) {
208
209
  db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
209
210
  }
211
+ // Migrate older DBs that pre-date the live-model column.
212
+ const hasModelCol = db
213
+ .prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'model'")
214
+ .get()
215
+ if (hasModelCol == null) {
216
+ db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
217
+ }
210
218
  // Verify the marker-derived parent_turn_key (snapParams[2]) actually has
211
219
  // a row in the turns table before trusting it. The gateway writes the
212
220
  // turn-active marker even when recordTurnStart's INSERT failed (the two
@@ -235,7 +243,7 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
235
243
  return
236
244
  }
237
245
 
238
- // sqlite3 CLI fallback — two non-blocking spawns sequenced via callbacks.
246
+ // sqlite3 CLI fallback — non-blocking spawns sequenced via callbacks.
239
247
  // This legacy path (neither node:sqlite nor bun:sqlite available) can't
240
248
  // cheaply verify the marker's turn_key against the turns table, so drop
241
249
  // parent_turn_key and let the gateway's window backfill attribute it.
@@ -244,7 +252,15 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
244
252
  params[2] = null
245
253
  spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
246
254
  if (err) { done(err); return }
247
- spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
255
+ // Best-effort model-column migration for a legacy DB: the INSERT below
256
+ // references the model column, which a pre-model table lacks (CREATE TABLE
257
+ // IF NOT EXISTS is a no-op there, so the schema exec doesn't add it). The
258
+ // ALTER fails with "duplicate column name" when the column already exists —
259
+ // that error is EXPECTED and deliberately ignored; on any failure we still
260
+ // proceed to the INSERT, which surfaces a real problem via `done`.
261
+ spawnSql(dbPath, 'ALTER TABLE subagents ADD COLUMN model TEXT', () => {
262
+ spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
263
+ })
248
264
  })
249
265
  }
250
266
 
@@ -363,6 +379,13 @@ function main() {
363
379
  agentType: input.subagent_type ?? null,
364
380
  description: input.description ?? null,
365
381
  background: input.run_in_background === true ? 1 : 0,
382
+ // First-paint model for the worker card: the Agent tool payload carries
383
+ // the model the sub-agent will run under (`tool_input.model`), available
384
+ // BEFORE the sub-agent writes its first assistant line. Persisted so the
385
+ // card can render the model from dispatch; the watcher later overwrites it
386
+ // from the worker's own transcript (transcript wins). Only a non-empty
387
+ // string is stored — never guess from config.
388
+ model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
366
389
  now: Date.now(),
367
390
  },
368
391
  (err) => {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared friendly-model formatter for progress / activity surfaces.
3
+ *
4
+ * Every progress card renders the model actually in use, sourced LIVE from the
5
+ * session transcripts (`message.model` on each `type:"assistant"` line — the
6
+ * exact resolved model for that API call). This module owns the two pure
7
+ * decisions both the capture side (session-tail projection) and the render side
8
+ * (activity/worker cards) depend on:
9
+ *
10
+ * - `isModelSentinel(v)` — is this a value we must NOT treat as a real model?
11
+ * Compaction / synthetic transcript lines carry `model:"<synthetic>"`, and
12
+ * test fixtures may carry junk. Anything starting with `<`, empty, or not
13
+ * shaped like a model id is a sentinel: the capture side SKIPS it and keeps
14
+ * the previous value; it is never rendered.
15
+ * - `formatModelLabel(v)` — the short friendly form shown on a card's metrics
16
+ * line (e.g. `claude-opus-4-8` → `opus 4.8`). Returns null for sentinels so
17
+ * the caller simply omits the model field (never guesses from config).
18
+ *
19
+ * Friendly-form rules (verified against live fleet transcript values):
20
+ * - `claude-opus-4-8` → `opus 4.8`
21
+ * - `claude-sonnet-5` → `sonnet 5`
22
+ * - `claude-haiku-4-5-20251001` → `haiku 4.5` (trailing YYYYMMDD dropped)
23
+ * - `sr-glm-5`, `sr-gpt-5.5` → verbatim (LiteLLM routing ids stay literal)
24
+ * - anything else → verbatim (unknown but model-shaped)
25
+ */
26
+
27
+ /** Trailing 8-digit date stamp (YYYYMMDD) on a claude model id — dropped. */
28
+ const DATE_SUFFIX = /^\d{8}$/
29
+
30
+ /**
31
+ * True when `model` must NOT be treated as a real resolved model. Compaction /
32
+ * synthetic lines write `model:"<synthetic>"`; fixtures may carry junk. Callers
33
+ * that see a sentinel keep the previously-seen model (or omit the field).
34
+ */
35
+ export function isModelSentinel(model: unknown): boolean {
36
+ if (typeof model !== 'string') return true
37
+ const m = model.trim()
38
+ if (m.length === 0) return true
39
+ // Synthetic / placeholder sentinels: `<synthetic>`, `<compact>`, etc.
40
+ if (m.startsWith('<')) return true
41
+ // Model ids are simple tokens (letters, digits, dot, dash, underscore,
42
+ // slash). Anything with whitespace or leading punctuation is junk.
43
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m)) return true
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Short friendly label for a resolved model id, or null when `model` is absent
49
+ * or a sentinel (caller omits the field — never guesses). See module header for
50
+ * the rules.
51
+ */
52
+ export function formatModelLabel(model: string | null | undefined): string | null {
53
+ if (model == null) return null
54
+ const m = model.trim()
55
+ if (isModelSentinel(m)) return null
56
+ // LiteLLM / self-routed ids stay verbatim — the sr- prefix is meaningful.
57
+ if (m.startsWith('sr-')) return m
58
+ if (m.startsWith('claude-')) {
59
+ const rest = m.slice('claude-'.length)
60
+ const parts = rest.split('-').filter((p) => p.length > 0)
61
+ if (parts.length === 0) return m
62
+ const family = parts[0]
63
+ const version = parts.slice(1).filter((p) => !DATE_SUFFIX.test(p))
64
+ if (version.length === 0) return family
65
+ return `${family} ${version.join('.')}`
66
+ }
67
+ // Unknown but model-shaped (a future family, a bare alias) — show verbatim.
68
+ return m
69
+ }
@@ -26,6 +26,8 @@ export type OperatorEventKind =
26
26
  | 'agent-restarted-unexpectedly'
27
27
  | 'unknown-4xx'
28
28
  | 'unknown-5xx'
29
+ | 'config-warning'
30
+ | 'always-allow-persist-failed'
29
31
 
30
32
  export interface OperatorEvent {
31
33
  kind: OperatorEventKind
@@ -375,6 +377,49 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
375
377
  ],
376
378
  },
377
379
  }
380
+
381
+ // Deliberately low-severity framing (ℹ️, Dismiss-only — no Restart /
382
+ // Reauth / Show-logs actions): config-warning is for boot-time config
383
+ // problems (e.g. a dropped `person_id` entry) that must be visible to
384
+ // the operator but MUST NOT read like a real outage or page anyone.
385
+ case 'config-warning':
386
+ return {
387
+ text: [
388
+ `ℹ️ **Config warning** for **${agent}**.`,
389
+ detail ? `_${detail}_` : '',
390
+ `Non-urgent — config will keep working with today's fallback behavior.`,
391
+ ]
392
+ .filter(Boolean)
393
+ .join('\n'),
394
+ keyboard: {
395
+ inline_keyboard: [
396
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
397
+ ],
398
+ },
399
+ }
400
+
401
+ // #2973 pt.3 — a durable "Always allow" persist exhausted its retry
402
+ // budget (always-allow-persist-queue.ts) or hit a non-retryable error
403
+ // (e.g. E_CONFIG_EDIT_DISABLED). MUST be a NEW message, not a card
404
+ // edit — the original permission card was already edited to the
405
+ // interim "saving durably in background…" state and card edits don't
406
+ // ping the operator, so a silent edit here would leave the failure
407
+ // unnoticed indefinitely.
408
+ case 'always-allow-persist-failed':
409
+ return {
410
+ text: [
411
+ `⚠️ Your "Always allow" for **${agent}** didn't stick.`,
412
+ detail ? `_${detail}_` : '',
413
+ `It will ask again.`,
414
+ ]
415
+ .filter(Boolean)
416
+ .join('\n'),
417
+ keyboard: {
418
+ inline_keyboard: [
419
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
420
+ ],
421
+ },
422
+ }
378
423
  }
379
424
  }
380
425
 
@@ -419,9 +419,15 @@ function tick(now: number): void {
419
419
  newText,
420
420
  literalText: s.anchorLiteralText,
421
421
  }
422
- // Fire-and-forget so a slow edit doesn't block the tick loop.
423
- // Errors are logged but never bubble (a 429 / "message not modified"
424
- // / chat-deleted is a soft failure).
422
+ // Fire-and-forget so a slow edit doesn't block the tick loop. The
423
+ // production `editMessage` dep is `swallowingApiCall`-wrapped, which
424
+ // catches every transport outcome (not-modified / not-found / 429 /
425
+ // network) upstream and never rejects — so in production this `.catch`
426
+ // is a backstop that rarely fires. It is kept as a contract-level guard
427
+ // (a throwing dep, or a future non-swallowing wiring, must not log a
428
+ // scary "edit failed" warning for a best-effort liveness surface nor
429
+ // keep hammering a dead anchor). Only a genuinely unexpected error
430
+ // reaches the fallthrough; transport classes are silent.
425
431
  void Promise.resolve()
426
432
  .then(() => activeDeps!.editMessage(editCtx))
427
433
  .then(() => {
@@ -432,10 +438,39 @@ function tick(now: number): void {
432
438
  })
433
439
  })
434
440
  .catch((err) => {
435
- process.stderr.write(
436
- `pending-work-progress: edit failed key=${key} ` +
437
- `msg=${editCtx.messageId}: ${(err as Error).message}\n`,
438
- )
441
+ const desc =
442
+ err instanceof Error ? err.message : err != null && typeof err === 'object' && 'description' in err
443
+ ? String((err as { description?: unknown }).description)
444
+ : String(err)
445
+ const low = desc.toLowerCase()
446
+ // "message is not modified" — the anchor already shows this suffix.
447
+ // The card is correct; count it as an edit and move on silently.
448
+ if (low.includes('not modified')) {
449
+ activeDeps!.emitMetric?.({
450
+ kind: 'pending_progress_edited',
451
+ chatKey: key,
452
+ elapsedMs: elapsed,
453
+ })
454
+ return
455
+ }
456
+ // Message / chat gone, or the 48h edit window closed. The anchor
457
+ // is dead — stop retrying it (clear state) so the tick isn't
458
+ // hammering a non-existent message every EDIT_INTERVAL_MS. The
459
+ // next outbound reply re-establishes a fresh anchor. Silent: no
460
+ // card to update is not a liveness-logic error.
461
+ if (
462
+ low.includes('not found') ||
463
+ low.includes("can't be edited") ||
464
+ low.includes('cannot be edited') ||
465
+ low.includes('not enough rights')
466
+ ) {
467
+ clearPending(key, 'stale_turn')
468
+ return
469
+ }
470
+ // 429 / transient network blip — leave state intact; the next tick
471
+ // retries. No stderr: a transport hiccup on a best-effort card is
472
+ // not a logic error, and the production wiring already applies
473
+ // retry_after backoff upstream.
439
474
  })
440
475
  }
441
476
  }