switchroom 0.16.47 → 0.17.0

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 (106) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1358 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +682 -773
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  23. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  24. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  25. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  26. package/telegram-plugin/gateway/gateway.ts +518 -624
  27. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  28. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  29. package/telegram-plugin/gateway/model-command.ts +51 -3
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  31. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  32. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  33. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  34. package/telegram-plugin/history.ts +5 -0
  35. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  36. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  37. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  38. package/telegram-plugin/registry/turns-schema.ts +65 -1
  39. package/telegram-plugin/session-tail.ts +26 -4
  40. package/telegram-plugin/slot-banner-driver.ts +42 -2
  41. package/telegram-plugin/status-query-telemetry.ts +100 -0
  42. package/telegram-plugin/stream-reply-handler.ts +15 -16
  43. package/telegram-plugin/subagent-watcher.ts +182 -30
  44. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  45. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  46. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  47. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  48. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  49. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  51. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  52. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  53. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  54. package/telegram-plugin/tests/model-command.test.ts +54 -1
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  56. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  57. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  58. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  59. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  60. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  61. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  62. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  65. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  66. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  67. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  68. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  69. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  70. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  72. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  73. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  74. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  75. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  76. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  77. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  78. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  79. package/telegram-plugin/tool-activity-summary.ts +19 -0
  80. package/telegram-plugin/turn-flush-safety.ts +16 -1
  81. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  82. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  84. package/telegram-plugin/worker-activity-feed.ts +75 -15
  85. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  86. package/vendor/hindsight-memory/README.md +5 -0
  87. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  88. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  89. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  90. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  91. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  92. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  93. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  94. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  95. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  96. package/vendor/hindsight-memory/settings.json +4 -0
  97. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  98. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  99. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  100. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  101. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  102. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  103. package/telegram-plugin/silent-reply.ts +0 -58
  104. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  105. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  106. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -72,6 +72,36 @@ export function trackDelivery<M>(
72
72
  q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now })
73
73
  }
74
74
 
75
+ /**
76
+ * Extract every `message_id="…"` value carried in a rendered enqueue envelope.
77
+ *
78
+ * The `enqueue` session-event's message id (`enqueueMessageId`) is a SINGLE
79
+ * value re-parsed from the transcript by `parseChannelMeta`, which grabs the
80
+ * FIRST `message_id` it finds. When claude's composer MERGES several inbound
81
+ * envelopes into one turn (or reformats the wrapper), that first-parsed id can
82
+ * be a sibling's — not the tracked message's — even though the tracked
83
+ * message's envelope is right there in the same content. Strict equality then
84
+ * mis-concludes "never delivered" and the sweep re-delivers a turn that is
85
+ * already running (#2786). Scanning ALL ids in the raw content makes the ack
86
+ * tolerant of that reorder/merge: the tracked id is present iff its envelope
87
+ * was delivered, regardless of parse order.
88
+ *
89
+ * The regex is LEFT-ANCHORED on an attribute boundary (start-of-string or a
90
+ * whitespace/quote before the name) so it matches ONLY the real `message_id`
91
+ * attribute — never a same-suffix sibling like `target_message_id`,
92
+ * `reply_to_message_id`, `original_message_id`, or `card_message_id`. This is
93
+ * what makes the tolerant-path match safe on a never-drop code path: a
94
+ * substring collision on some other `*_message_id` attribute cannot false-ack
95
+ * (and thus silently drop) a real user message still waiting to land.
96
+ */
97
+ export function extractEnqueueMessageIds(content: string): string[] {
98
+ const ids: string[] = []
99
+ const re = /(?:^|[\s"'])message_id="([^"]+)"/g
100
+ let m: RegExpExecArray | null
101
+ while ((m = re.exec(content)) != null) ids.push(m[1]!)
102
+ return ids
103
+ }
104
+
75
105
  /**
76
106
  * Ack a delivery — call from the `enqueue` session-event (claude started a
77
107
  * turn). `enqueue` fires for EVERY turn start regardless of source (user
@@ -80,21 +110,36 @@ export function trackDelivery<M>(
80
110
  * silently drop — a real user message still waiting under the same key. So
81
111
  * ack ONLY when the enqueue's source message id matches the tracked one.
82
112
  *
83
- * Matching rule: if we recorded a messageId for the pending entry, require the
84
- * enqueue's `enqueueMessageId` to equal it. If we never recorded one (legacy /
85
- * defensive null), fall back to key-only ack. Returns true if an entry was
86
- * cleared.
113
+ * Matching rule (composer-tolerant, #2786): if we recorded a messageId for the
114
+ * pending entry, ack when EITHER
115
+ * - the single re-parsed `enqueueMessageId` equals it (the fast path), OR
116
+ * - the tracked id appears among the message ids carried in `enqueueContent`
117
+ * (the tolerant path — survives the composer merging/reordering envelopes
118
+ * so the first-parsed id belongs to a sibling, not the tracked message).
119
+ * Matching on the exact `message_id="…"` token — not a loose substring —
120
+ * preserves the cross-source false-ack guard: a synthetic-source turn (cron /
121
+ * resume / vault) never carries the real user message's id, so it still can't
122
+ * clear a user message that is genuinely still waiting to land. If we never
123
+ * recorded a messageId (legacy / defensive null), fall back to key-only ack.
124
+ * Returns true if an entry was cleared.
87
125
  */
88
126
  export function ackDelivery<M>(
89
127
  q: DeliveryQueue<M>,
90
128
  key: string,
91
129
  enqueueMessageId: string | null = null,
130
+ enqueueContent: string | null = null,
92
131
  ): boolean {
93
132
  const entry = q.pending.get(key)
94
133
  if (!entry) return false
95
- // A different message started this turn — don't ack ours (it may still be
96
- // waiting to land; the sweep will re-deliver it if it stranded).
97
- if (entry.messageId != null && entry.messageId !== enqueueMessageId) return false
134
+ if (entry.messageId != null) {
135
+ const matches =
136
+ entry.messageId === enqueueMessageId ||
137
+ (enqueueContent != null &&
138
+ extractEnqueueMessageIds(enqueueContent).includes(entry.messageId))
139
+ // A different message started this turn — don't ack ours (it may still be
140
+ // waiting to land; the sweep will re-deliver it if it stranded).
141
+ if (!matches) return false
142
+ }
98
143
  q.pending.delete(key)
99
144
  return true
100
145
  }
@@ -119,6 +164,43 @@ export function sweep<M>(
119
164
  return redeliver
120
165
  }
121
166
 
167
+ /**
168
+ * #2787 — the set of chats/topics that currently hold a live permission or
169
+ * `ask_user` card, against which a swept entry is tested before re-delivery.
170
+ *
171
+ * - `keys` — full `chatKey(chatId, threadId)` values (topic known). A card
172
+ * routed to a specific forum topic suspends re-delivery ONLY for
173
+ * that topic; sibling topics in the same supergroup keep flowing.
174
+ * - `chats` — bare chatIds (topic unknown — e.g. a permission card fanned to
175
+ * the operator DMs records only the chatId). The conservative
176
+ * fallback is to suspend the whole chat; that chat is almost never
177
+ * a busy multi-topic supergroup, so the global stall is still gone.
178
+ */
179
+ export interface SuspendedTargets {
180
+ readonly keys: ReadonlySet<string>
181
+ readonly chats: ReadonlySet<string>
182
+ }
183
+
184
+ /**
185
+ * #2787 Mechanism A — should re-delivery of a stranded inbound on `key` be
186
+ * suspended because a live permission / `ask_user` card is open for ITS OWN
187
+ * chat/topic?
188
+ *
189
+ * A pending card is a live interaction for its target: re-clearing the composer
190
+ * and re-sending there would clobber it. But the OLD gateway guard suspended the
191
+ * ENTIRE confirm sweep whenever ANY card was pending anywhere — so one card
192
+ * parked in a single topic (or the operator DM, a different chatId) froze
193
+ * re-delivery of every stranded inbound across EVERY topic until it resolved
194
+ * (the #1922 all-topics stall). Scoping the check to the card's own target keeps
195
+ * unrelated topics being re-delivered while the card sits open.
196
+ */
197
+ export function isRedeliverySuspended(key: string, suspended: SuspendedTargets): boolean {
198
+ if (suspended.keys.has(key)) return true
199
+ const idx = key.indexOf(':')
200
+ const chatId = idx < 0 ? key : key.slice(0, idx)
201
+ return suspended.chats.has(chatId)
202
+ }
203
+
122
204
  /** Forget a key without acking (e.g. the bridge went offline and the message
123
205
  * was handed back to the offline buffer, which owns it now). */
124
206
  export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
@@ -159,6 +159,21 @@ export interface InboundSpoolOptions {
159
159
  * `escalateAfterMs` here) or back-to-back attempts wouldn't coalesce.
160
160
  * Default 30 min. */
161
161
  escalateNoticeCooldownMs?: number
162
+ /**
163
+ * #2789 B: fired whenever spool durability changes state — an append
164
+ * fails (durability has silently degraded to in-memory-only, so a
165
+ * later crash loses those messages) or recovers. Latches: called once
166
+ * on the transition into degraded and once on the transition back to
167
+ * healthy, not on every append. Lets the gateway raise a health
168
+ * signal instead of quietly continuing as if the durable promise
169
+ * still held. Best-effort — a throw here never breaks delivery.
170
+ */
171
+ onDegraded?: (info: {
172
+ degraded: boolean
173
+ consecutiveFailures: number
174
+ path: string
175
+ error?: string
176
+ }) => void
162
177
  }
163
178
 
164
179
  export interface ReplayEntry {
@@ -199,13 +214,25 @@ export interface InboundSpool {
199
214
  * `escalateNoticeCooldownMs` — a burst of undeliverable inbounds (e.g.
200
215
  * a synthetic re-created every 15 min while the agent is down, across
201
216
  * restarts) yields ONE notice, not one per entry. The window is
202
- * persisted, so it holds across a gateway restart. Returns the count
203
- * of entries dropped. Safe to call on a timer. */
217
+ * persisted, so it holds across a gateway restart. `droppedCount` is
218
+ * the real number of entries dropped for that entry's chat in THIS
219
+ * sweep, so the coalesced notice reports the true multi-message loss
220
+ * count instead of under-reporting it as one (#2789 C). Returns the
221
+ * total count of entries dropped. Safe to call on a timer. */
204
222
  sweepEscalations: (
205
- onEscalate: (e: ReplayEntry, opts: { postNotice: boolean }) => void,
223
+ onEscalate: (
224
+ e: ReplayEntry,
225
+ opts: { postNotice: boolean; droppedCount: number },
226
+ ) => void,
206
227
  ) => number
207
228
  /** Test/observability: count of live (un-acked) ids. */
208
229
  liveCount: () => number
230
+ /** #2789 B: true while spool appends are failing — durability has
231
+ * degraded to in-memory-only. A health signal the gateway can surface
232
+ * instead of quietly dropping the durability guarantee. */
233
+ isDegraded: () => boolean
234
+ /** #2789 B: consecutive append failures since the last success. */
235
+ appendFailureCount: () => number
209
236
  }
210
237
 
211
238
  export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
@@ -227,6 +254,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
227
254
  // synthetic re-aged into the bound every 15 min across many restarts).
228
255
  const escAttemptByChat = new Map<string, number>()
229
256
 
257
+ // #2789 B: append-durability health. `consecutiveAppendFailures`
258
+ // counts failed appends since the last success; `degraded` latches so
259
+ // the onDegraded callback fires exactly once per state transition
260
+ // (into degraded / back to healthy) rather than on every append.
261
+ let consecutiveAppendFailures = 0
262
+ let degraded = false
263
+
230
264
  function parseLine(line: string): SpoolRecord | null {
231
265
  const s = line.trim()
232
266
  if (!s) return null
@@ -288,15 +322,52 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
288
322
  function appendRecord(rec: SpoolRecord): void {
289
323
  try {
290
324
  fs.appendFileSync(path, JSON.stringify(rec) + '\n')
325
+ // #2789 B: a successful append after a failure run means the spool
326
+ // is durable again — un-latch degraded state and surface recovery
327
+ // so the health signal clears.
328
+ if (consecutiveAppendFailures > 0) {
329
+ log(
330
+ `inbound-spool: append recovered path=${path} after ` +
331
+ `${consecutiveAppendFailures} failure(s) — durability restored\n`,
332
+ )
333
+ consecutiveAppendFailures = 0
334
+ if (degraded) {
335
+ degraded = false
336
+ try {
337
+ opts.onDegraded?.({ degraded: false, consecutiveFailures: 0, path })
338
+ } catch {
339
+ /* health signal is best-effort; never break delivery */
340
+ }
341
+ }
342
+ }
291
343
  } catch (err) {
292
344
  // Durability is best-effort relative to fs availability; a spool
293
- // write failure must NOT break live delivery. Log loudly a
294
- // persistently failing spool means we're back to in-memory-only
295
- // semantics and the operator should know.
345
+ // write failure must NOT break live delivery. But it must NOT be
346
+ // SILENT either (#2789 B) — a persistently failing spool means
347
+ // we're back to in-memory-only semantics and a later crash loses
348
+ // the message. Log loudly on every failure AND raise a latched
349
+ // health signal on the transition into degraded so the operator /
350
+ // health surface knows the durability guarantee is gone.
351
+ consecutiveAppendFailures++
352
+ const message = (err as Error).message
296
353
  log(
297
354
  `inbound-spool: append FAILED path=${path} id=${rec.id} t=${rec.t}: ` +
298
- `${(err as Error).message} — durability degraded to in-memory\n`,
355
+ `${message} — durability degraded to in-memory ` +
356
+ `(consecutive failures=${consecutiveAppendFailures})\n`,
299
357
  )
358
+ if (!degraded) {
359
+ degraded = true
360
+ try {
361
+ opts.onDegraded?.({
362
+ degraded: true,
363
+ consecutiveFailures: consecutiveAppendFailures,
364
+ path,
365
+ error: message,
366
+ })
367
+ } catch {
368
+ /* health signal is best-effort; never break delivery */
369
+ }
370
+ }
300
371
  }
301
372
  }
302
373
 
@@ -399,10 +470,25 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
399
470
  sweepEscalations(onEscalate) {
400
471
  const tNow = now()
401
472
  const cutoff = tNow - escalateAfterMs
473
+ // First pass (#2789 C): identify the entries to drop and count them
474
+ // per chat so the (coalesced) notice reports the REAL number of
475
+ // dropped messages instead of under-counting a multi-message drop
476
+ // as a single one. Iteration order is preserved so the tombstone /
477
+ // esc append order is unchanged.
478
+ const toDrop: {
479
+ id: string
480
+ e: { agent: string; msg: InboundMessage; firstAt: number }
481
+ }[] = []
482
+ const perChatCount = new Map<string, number>()
483
+ for (const [id, e] of live.entries()) {
484
+ if (e.firstAt > cutoff) continue
485
+ toDrop.push({ id, e })
486
+ const key = escChatKey(e.msg)
487
+ perChatCount.set(key, (perChatCount.get(key) ?? 0) + 1)
488
+ }
402
489
  let dropped = 0
403
490
  let posted = 0
404
- for (const [id, e] of [...live.entries()]) {
405
- if (e.firstAt > cutoff) continue
491
+ for (const { id, e } of toDrop) {
406
492
  live.delete(id)
407
493
  appendRecord({ t: 'ack', id }) // tombstone — promise retracted
408
494
  // Coalesce the user-facing notice per chat on a SLIDING window:
@@ -420,7 +506,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
420
506
  typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : undefined
421
507
  appendRecord({ t: 'esc', chat: e.msg.chatId, thread, at: tNow })
422
508
  try {
423
- onEscalate({ agent: e.agent, msg: e.msg }, { postNotice })
509
+ // #2789 C: hand the caller the real per-chat drop count for
510
+ // THIS sweep so the notice it posts can say "N messages" rather
511
+ // than under-reporting a multi-message drop as a single one.
512
+ onEscalate(
513
+ { agent: e.agent, msg: e.msg },
514
+ { postNotice, droppedCount: perChatCount.get(key) ?? 1 },
515
+ )
424
516
  } catch (err) {
425
517
  log(`inbound-spool: onEscalate threw id=${id}: ${(err as Error).message}\n`)
426
518
  }
@@ -441,5 +533,11 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
441
533
  liveCount() {
442
534
  return live.size
443
535
  },
536
+ isDegraded() {
537
+ return degraded
538
+ },
539
+ appendFailureCount() {
540
+ return consecutiveAppendFailures
541
+ },
444
542
  }
445
543
  }
@@ -227,11 +227,30 @@ export async function handleModelCommand(
227
227
  }
228
228
 
229
229
  if (result.outcome === 'ok') {
230
+ // claude's `/model <name>` switches the session SILENTLY — it does not
231
+ // print a confirmation line. So `result.output` on this path is almost
232
+ // always just whatever pane scrollback sat below the command echo (the
233
+ // agent's previous prose answer). `isTuiChromeLine` strips borders/glyphs
234
+ // but NOT ordinary prose, so blindly `preBlock`-ing `result.output` here
235
+ // dumped that unrelated scrollback back to the user as a code block
236
+ // (screenshot-confirmed on klanker, v0.16.47). Only relay output when it
237
+ // actually looks like a model-switch acknowledgement; otherwise suppress
238
+ // it and send a clean confirmation.
239
+ const confirmation = modelSwitchConfirmationLine(result.output)
240
+ if (confirmation) {
241
+ return {
242
+ text: [
243
+ `${verbHtml}`,
244
+ deps.preBlock(confirmation),
245
+ ...(result.truncated ? ['_truncated_'] : []),
246
+ PERSIST_NOTE,
247
+ ].join('\n'),
248
+ html: true,
249
+ }
250
+ }
230
251
  return {
231
252
  text: [
232
- `${verbHtml}`,
233
- deps.preBlock(result.output),
234
- ...(result.truncated ? ['_truncated_'] : []),
253
+ `${verbHtml} — switched (session).`,
235
254
  PERSIST_NOTE,
236
255
  ].join('\n'),
237
256
  html: true,
@@ -808,6 +827,35 @@ export function isSrToClaudeTransition(
808
827
  return !!prevModel?.startsWith('sr-') && !nextModel.startsWith('sr-')
809
828
  }
810
829
 
830
+ /**
831
+ * Return the single line of a pane capture that actually reads as claude's
832
+ * model-switch acknowledgement ("Set model to X…", "Switched to X", or
833
+ * "Kept model as X"), or null when no such line is present. Used by the
834
+ * direct `/model <name>` path to decide whether `result.output` carries a
835
+ * genuine confirmation worth relaying, versus mere scrollback that must NOT
836
+ * be echoed back to chat. Mirrors the line-scan already used by the picker
837
+ * alias/sr-* callback paths.
838
+ */
839
+ export function modelSwitchConfirmationLine(output: string): string | null {
840
+ const line = output
841
+ .split('\n')
842
+ .map((l) => l.trim())
843
+ .find((l) => MODEL_SWITCH_CONFIRMATION_PREFIX.test(l))
844
+ return line && line.length > 0 ? line : null
845
+ }
846
+
847
+ /**
848
+ * claude's real model-switch confirmation always begins the line (optionally
849
+ * behind a status glyph like `⏺` + whitespace) with one of these exact
850
+ * phrasings. Anchoring to the line start keeps ordinary scrollback prose that
851
+ * merely *contains* words like "switched" or "set model" (e.g. "I switched the
852
+ * deploy to blue-green") from false-positiving as a confirmation worth
853
+ * relaying. Shared by `modelSwitchConfirmationLine` (does this line qualify?)
854
+ * and `sessionModelFromConfirmation` (pull the name out).
855
+ */
856
+ const MODEL_SWITCH_CONFIRMATION_PREFIX =
857
+ /^\s*[⏺●•>-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
858
+
811
859
  /**
812
860
  * Pull the model NAME out of claude's session-switch confirmation so it can
813
861
  * be shown in `/status` as the live session model. claude phrases it as
@@ -66,6 +66,19 @@ export interface PendingInboundBufferOptions {
66
66
  * than the old silent in-memory drop).
67
67
  */
68
68
  spool?: InboundSpool
69
+ /**
70
+ * Called when the in-memory cap forces an eviction of the OLDEST
71
+ * entry (#2789 defect A). The evicted message is NOT lost — `push`
72
+ * still records it durably in the spool (boot-replayed / escalated) —
73
+ * but within a live session it will not be re-delivered until boot or
74
+ * escalation, so the eviction must not be SILENT the way it was
75
+ * before. The caller wires this to a coalesced "N messages deferred"
76
+ * user-facing notice tied to the spool, turning a silent in-session
77
+ * drop into a visible deferral (chat-is-the-single-source-of-truth:
78
+ * surface the loss window, don't hide it). Best-effort: a throw here
79
+ * never breaks the push hot path.
80
+ */
81
+ onEvict?: (agent: string, evicted: InboundMessage) => void
69
82
  }
70
83
 
71
84
  /**
@@ -304,6 +317,19 @@ export function createPendingInboundBuffer(
304
317
  `pending-inbound-buffer: agent=${agent} cap=${cap} reached — ` +
305
318
  `dropped oldest entry source=${dropped?.meta?.source ?? '-'} ts=${dropped?.ts ?? '-'}\n`,
306
319
  )
320
+ // #2789 A: the cap eviction is no longer a SILENT in-session
321
+ // drop. `dropped` still lives in the durable spool (it was
322
+ // spool.put on its own push), so it survives to boot-replay /
323
+ // escalation — but it won't be re-delivered THIS session. Hand
324
+ // it to the caller so a coalesced "N messages deferred" notice
325
+ // can be surfaced. Best-effort: never let the notice break push.
326
+ if (dropped != null && opts.onEvict != null) {
327
+ try {
328
+ opts.onEvict(agent, dropped)
329
+ } catch {
330
+ /* user-facing notice is best-effort; never break the hot path */
331
+ }
332
+ }
307
333
  }
308
334
  q.push(msg)
309
335
  // Durable record FIRST-class to the in-memory queue: spool BEFORE
@@ -14,14 +14,22 @@
14
14
  * no Telegram, no SQLite; the gateway injects `hasOutboundDeliveredSince` as a
15
15
  * predicate. The single load-bearing subtlety lives here in one testable place:
16
16
  *
17
- * The cutoff is `lastRepresentedAt` (the time of the PREVIOUS represent), NOT
18
- * `openedAt`. On the FIRST represent (`lastRepresentedAt` undefined) the guard
19
- * is a no-op, so the genuine "agent wrote a plain-text answer and never called
20
- * the reply tool" case still re-presents ONCE. Only the SECOND-and-later
21
- * represent is gated exactly where a reply that landed BETWEEN fires must
22
- * suppress the re-ask. A reply that predates the last represent (e.g. the
23
- * original plain-text answer) does not count, because it is not evidence the
24
- * most recent represent was answered.
17
+ * For the SECOND-and-later represent the cutoff is `lastRepresentedAt` (the
18
+ * time of the PREVIOUS represent), NOT `openedAt` exactly where a reply that
19
+ * landed BETWEEN fires must suppress the re-ask, while a reply that predates
20
+ * the last represent (e.g. the original plain-text answer) does not count,
21
+ * because it is not evidence the most recent represent was answered.
22
+ *
23
+ * For the FIRST represent (`lastRepresentedAt` undefined) the cutoff is
24
+ * `openedAt` (#2788 Gap B). Previously the first represent was an unconditional
25
+ * no-op, leaving a narrow window: if a genuine reply was already delivered
26
+ * since the obligation was RAISED but its routing didn't resolve back to the
27
+ * origin (so the ledger's normal close path missed it), the first represent
28
+ * emitted a false "you never answered". Deduping against outbound history from
29
+ * `openedAt` closes that window WITHOUT breaking the genuine "agent wrote a
30
+ * plain-text answer and never called the reply tool" case: that case records NO
31
+ * outbound row, so `hasOutboundDeliveredSince` reports false and the single
32
+ * re-ask still fires.
25
33
  */
26
34
 
27
35
  /** The obligation fields the represent guard inspects. */
@@ -29,6 +37,8 @@ export interface RepresentGuardObligation {
29
37
  readonly originTurnId: string
30
38
  readonly chatId: string
31
39
  readonly threadId?: number
40
+ /** Wall-clock ms this obligation was RAISED — the FIRST-represent cutoff (#2788). */
41
+ readonly openedAt?: number
32
42
  /** Wall-clock ms this obligation was most recently re-presented, if ever. */
33
43
  readonly lastRepresentedAt?: number
34
44
  }
@@ -65,8 +75,15 @@ export function shouldSuppressRepresent(
65
75
  deps: RepresentGuardDeps,
66
76
  ): boolean {
67
77
  if (!deps.historyEnabled) return false
68
- // First represent: nothing to compare against let the single re-ask fire so
69
- // the genuine plain-text-no-reply case is preserved.
70
- if (o.lastRepresentedAt == null) return false
78
+ // First represent (#2788 Gap B): dedup against outbound history from `openedAt`.
79
+ // A genuine reply already delivered since the obligation was raised means the
80
+ // user WAS answered suppress the false "you never answered". The genuine
81
+ // plain-text-no-reply case records no outbound row, so the predicate reports
82
+ // false there and the single re-ask still fires. If `openedAt` is unknown we
83
+ // cannot dedup safely, so fall back to the prior no-op (never suppress).
84
+ if (o.lastRepresentedAt == null) {
85
+ if (o.openedAt == null) return false
86
+ return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)
87
+ }
71
88
  return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId)
72
89
  }