switchroom 0.18.24 → 0.18.26

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 (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -24,7 +24,7 @@
24
24
  * unit-testable without booting the bot.
25
25
  */
26
26
 
27
- import type { InjectResult } from '../../src/agents/inject.js'
27
+ import type { InjectResult, InjectOpts } from '../../src/agents/inject.js'
28
28
  import {
29
29
  labelTag,
30
30
  type DiscoverResult,
@@ -185,8 +185,17 @@ export function modelCommandReceiptLine(
185
185
  }
186
186
 
187
187
  export interface ModelCommandDeps {
188
- /** Inject primitive — wired to injectSlashCommand in the gateway. */
189
- inject: (agent: string, command: string) => Promise<InjectResult>
188
+ /**
189
+ * Inject primitive wired to injectSlashCommand in the gateway. The optional
190
+ * third argument forwards the #3241 poll-until-signal opts (successPattern /
191
+ * errorPattern / settleBeforeSendMs); the set path passes them so the `/model`
192
+ * confirmation scrape is deterministic instead of racing a fixed window.
193
+ */
194
+ inject: (
195
+ agent: string,
196
+ command: string,
197
+ opts?: Pick<InjectOpts, 'successPattern' | 'errorPattern' | 'settleBeforeSendMs'>,
198
+ ) => Promise<InjectResult>
190
199
  /**
191
200
  * True while the agent is mid-turn. A typed `/model <name>` switch drives
192
201
  * claude's session (either an inject into the input box, or a carrier-backed
@@ -248,6 +257,17 @@ export interface ModelCommandReply {
248
257
  * lies to `/status`.
249
258
  */
250
259
  selectedModel?: string
260
+ /**
261
+ * True when `selectedModel` was recorded OPTIMISTICALLY (#3241 part B): the
262
+ * inject SEND succeeded and NO explicit error line was scraped, but claude's
263
+ * confirmation line was not read either. Poll-until-signal already waited the
264
+ * full window, so a missing line means a silent switch (or a confirmation
265
+ * that scrolled off) — NOT a failure — and we record the requested model so
266
+ * `/status` is right. The switch is retracted (no `selectedModel`) only when
267
+ * an error line IS scraped. Purely a wording hint; the gateway records
268
+ * `selectedModel` the same way whether confirmed or optimistic.
269
+ */
270
+ optimistic?: boolean
251
271
  }
252
272
 
253
273
  const PERSIST_NOTE =
@@ -379,7 +399,18 @@ export async function handleModelCommand(
379
399
  const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
380
400
  let result: InjectResult
381
401
  try {
382
- result = await deps.inject(deps.getAgentName(), `/model ${model}`)
402
+ // #3241 part A — poll-until-signal. Hand the inject primitive the exact
403
+ // confirmation / error line shapes so its capture loop keeps polling until
404
+ // claude's "Set model to …" (or an error) actually lands, instead of
405
+ // breaking at a fixed settle window on the first pane change (which the
406
+ // async access banner tripped, capturing the banner and missing the
407
+ // confirmation). settleBeforeSendMs waits for a clean prompt so the keys
408
+ // aren't typed into a still-animating pane (symptom 2's silent no-op).
409
+ result = await deps.inject(deps.getAgentName(), `/model ${model}`, {
410
+ successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
411
+ errorPattern: MODEL_SWITCH_ERROR_RE,
412
+ settleBeforeSendMs: 1500,
413
+ })
383
414
  } catch (err) {
384
415
  const msg = err instanceof Error ? err.message : String(err)
385
416
  return {
@@ -388,33 +419,43 @@ export async function handleModelCommand(
388
419
  }
389
420
  }
390
421
 
391
- if (result.outcome === 'ok') {
392
- // claude's `/model <name>` either prints a "Set model to X" acknowledgement
393
- // or switches SILENTLY (no line). `result.output` on the silent path is just
394
- // whatever pane scrollback sat below the command echo (the agent's previous
395
- // prose answer) NOT a confirmation. We MUST NOT claim success on that
396
- // scrollback, and we must never dump it back as a code block (screenshot-
422
+ if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
423
+ // claude's `/model <name>` prints a "Set model to X" acknowledgement, an
424
+ // error line ("Model not found"), a "Kept model as X" no-op, or (rarely)
425
+ // switches with the confirmation scrolled off. `result.output` on a silent
426
+ // path is just pane scrollback (the agent's previous prose) NEVER a
427
+ // confirmation, and it must not be dumped back as a code block (screenshot-
397
428
  // confirmed leak on klanker, v0.16.47).
398
429
  //
399
- // Honest reporting: (1) if claude printed an error ("Model not found" /
400
- // "Invalid model"), the switch FAILED say so. (2) if an anchored
401
- // confirmation line is present, the switch is verified relay it and record
402
- // the live model for /status. (3) otherwise we cannot positively verify the
403
- // switch (silent success or nothing) say "sent, but couldn't confirm" and
404
- // record NOTHING, so /status is never lied to.
405
- const errLine = modelSwitchErrorLine(result.output)
406
- if (errLine) {
407
- return {
408
- text: [
409
- `❌ ${verbHtml} the switch did not take:`,
410
- deps.preBlock(errLine),
411
- 'Check \`/model\` for valid model names.',
412
- ].join('\n'),
413
- html: true,
414
- }
415
- }
416
- const confirmation = modelSwitchConfirmationLine(result.output)
430
+ // Honest reporting (#3241 part B inverts the old "record nothing unless
431
+ // confirmed" to "record optimistically, retract only on a scraped error").
432
+ // Order matters (#3242 review MEDIUM 1): check the CONFIRMATION line FIRST
433
+ // a genuine switch always prints one, so it can never be flipped to a failure
434
+ // by a stray availability/denial word (the widened MODEL_SWITCH_ERROR_RE) in
435
+ // the same region. Then:
436
+ // (1) "Kept model as X" → genuine no-op; report it, record NOTHING.
437
+ // (2) other confirmation → verified switch; relay it, record the display
438
+ // name for /status.
439
+ // (3) error/denial line scraped (bad id OR access denial) → switch FAILED.
440
+ // Report it; record NOTHING (the retract /status keeps the prior model).
441
+ // (4) no line either way → poll-until-signal already waited the full
442
+ // window, so this is a SILENT success, not a failure. Record the
443
+ // requested model OPTIMISTICALLY (normalized to display form) so
444
+ // /status is right, and say so.
445
+ const confirmation = result.outcome === 'ok' ? modelSwitchConfirmationLine(result.output) : null
417
446
  if (confirmation) {
447
+ if (isKeptModelConfirmation(confirmation)) {
448
+ // "Kept model as X" — nothing changed. Relay it, record no override.
449
+ return {
450
+ text: [
451
+ `${verbHtml}`,
452
+ deps.preBlock(confirmation),
453
+ ...(result.truncated ? ['_truncated_'] : []),
454
+ PERSIST_NOTE,
455
+ ].join('\n'),
456
+ html: true,
457
+ }
458
+ }
418
459
  const confirmed = sessionModelFromConfirmation(confirmation) ?? model
419
460
  return {
420
461
  text: [
@@ -424,27 +465,35 @@ export async function handleModelCommand(
424
465
  PERSIST_NOTE,
425
466
  ].join('\n'),
426
467
  html: true,
427
- // Only record when the confirmation carries a real switch (Set/Switched);
428
- // a "Kept model as" line means no change — don't overwrite the override.
429
- ...(isKeptModelConfirmation(confirmation) ? {} : { selectedModel: confirmed }),
468
+ selectedModel: confirmed,
430
469
  }
431
470
  }
432
- return {
433
- text: [
434
- `${verbHtml} — sent, but couldn't confirm the switch — check \`/status\`.`,
435
- PERSIST_NOTE,
436
- ].join('\n'),
437
- html: true,
471
+ const errLine = result.outcome === 'ok' ? modelSwitchErrorLine(result.output) : null
472
+ if (errLine) {
473
+ return {
474
+ text: [
475
+ `❌ ${verbHtml} — the switch did not take:`,
476
+ deps.preBlock(errLine),
477
+ 'Check \`/model\` for a valid, available model.',
478
+ ].join('\n'),
479
+ html: true,
480
+ }
438
481
  }
439
- }
440
-
441
- if (result.outcome === 'ok_no_output') {
482
+ // No confirmation and no error — optimistic record (#3241 part B). The
483
+ // Telegram copy stays PROVISIONAL (#3242 review FIX 2): we couldn't read a
484
+ // confirmation, and if the CLI denied the switch with wording our error
485
+ // regex misses, an affirmative "recorded X" would be a lie that never
486
+ // self-corrects. `/status` DOES self-heal (the override is reclaimed by the
487
+ // next transcript line), so point the user there rather than assert success.
488
+ const optimisticLabel = optimisticModelRecordLabel(model)
442
489
  return {
443
490
  text: [
444
- `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check \`/inject /status\` to confirm the active model.`,
491
+ `${verbHtml} — sent, but couldn't read a confirmation line. \`/status\` will show the live model once it's confirmed.`,
445
492
  PERSIST_NOTE,
446
493
  ].join('\n'),
447
494
  html: true,
495
+ selectedModel: optimisticLabel,
496
+ optimistic: true,
448
497
  }
449
498
  }
450
499
 
@@ -617,6 +666,32 @@ export function srFriendlyLabel(srName: string): string {
617
666
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
618
667
  }
619
668
 
669
+ /**
670
+ * #3242 review LOW 4 — display normalization for the OPTIMISTIC `/status` record.
671
+ * The confirmed path records the display name claude printed (e.g. "Fable 5" via
672
+ * `sessionModelFromConfirmation`); the optimistic path only has the requested
673
+ * arg. Without a confirmation we can't know the version suffix, so we normalize
674
+ * a bare Claude alias to the same DISPLAY style — Title-case ("fable" → "Fable")
675
+ * — and leave a full `claude-*` id as-is (already canonical).
676
+ *
677
+ * #3242 review FIX 1 (MEDIUM) — sr-* tokens are returned UNCHANGED (with the
678
+ * `sr-` prefix). The stored `selectedModel` doubles as the sr-*→Claude sentinel:
679
+ * `gateway.ts` `isSrToClaudeTransition` checks `prevModel?.startsWith('sr-')` to
680
+ * decide whether a later Claude switch needs the graceful restart that tears
681
+ * down LiteLLM routing. De-prefixing here (as the earlier LOW-4 pass did via
682
+ * `srFriendlyLabel`) would silently break that restart. So this helper never
683
+ * de-prefixes: the caller normalizes only the DISPLAY text separately (see
684
+ * `srFriendlyLabel`), never the stored token.
685
+ */
686
+ export function optimisticModelRecordLabel(token: string): string {
687
+ if (isSrModel(token)) return token
688
+ const lower = token.toLowerCase()
689
+ if ((MODEL_ALIASES as readonly string[]).includes(lower)) {
690
+ return lower.charAt(0).toUpperCase() + lower.slice(1)
691
+ }
692
+ return token
693
+ }
694
+
620
695
  /**
621
696
  * Split picker-discovered options into native Claude options and sr-*
622
697
  * (LiteLLM non-Anthropic) options. Options with "/" in the label or
@@ -973,7 +1048,14 @@ export async function handleModelMenuCallback(
973
1048
  }
974
1049
  let aliasResult: InjectResult
975
1050
  try {
976
- aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`)
1051
+ // #3241 part A same poll-until-signal + clean-prompt opts as the typed
1052
+ // set path so the alias (e.g. Fable) confirmation scrape is deterministic
1053
+ // and immune to the async access banner.
1054
+ aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`, {
1055
+ successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
1056
+ errorPattern: MODEL_SWITCH_ERROR_RE,
1057
+ settleBeforeSendMs: 1500,
1058
+ })
977
1059
  } catch (err) {
978
1060
  const msg = err instanceof Error ? err.message : String(err)
979
1061
  return {
@@ -981,20 +1063,54 @@ export async function handleModelMenuCallback(
981
1063
  reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`),
982
1064
  }
983
1065
  }
984
- if (aliasResult.outcome === 'ok') {
985
- // Anchored confirmation only a loose /set model|switched/ match false-
986
- // positives on ordinary scrollback prose ("I switched the deploy…").
987
- const confirmation =
988
- modelSwitchConfirmationLine(aliasResult.output) ?? `Switched to ${alias} (session)`
989
- // "Kept model as X" means no change don't overwrite the override.
990
- const kept = isKeptModelConfirmation(confirmation)
1066
+ // #3242 review MEDIUM 2 — the alias BUTTON (the primary Fable UI, the exact
1067
+ // async-banner scenario) must be symmetric with the typed set path: handle
1068
+ // BOTH `ok` and `ok_no_output` with record-on-send / retract-on-scraped-error.
1069
+ // Previously `ok_no_output` fell through to "Switch failed — agent may be
1070
+ // mid-turn" and dropped the override, so a silent successful button-switch
1071
+ // was reported as a failure while the identical typed command recorded.
1072
+ if (aliasResult.outcome === 'ok' || aliasResult.outcome === 'ok_no_output') {
1073
+ // Confirmation first (a genuine switch always prints one) so a stray
1074
+ // availability/denial word can't flip it to a failure.
1075
+ const confirmation = aliasResult.outcome === 'ok'
1076
+ ? modelSwitchConfirmationLine(aliasResult.output)
1077
+ : null
1078
+ if (confirmation) {
1079
+ // "Kept model as X" means no change — don't overwrite the override.
1080
+ const kept = isKeptModelConfirmation(confirmation)
1081
+ return {
1082
+ answer: confirmation,
1083
+ reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
1084
+ ...(kept ? {} : {
1085
+ selectedModel: sessionModelFromConfirmation(confirmation) ?? optimisticModelRecordLabel(alias),
1086
+ selectedModelToken: alias,
1087
+ }),
1088
+ }
1089
+ }
1090
+ // Scraped error/denial (bad id OR access denial) → genuine failure, record
1091
+ // nothing (retract).
1092
+ const aliasErr = aliasResult.outcome === 'ok' ? modelSwitchErrorLine(aliasResult.output) : null
1093
+ if (aliasErr) {
1094
+ return {
1095
+ answer: 'Switch failed',
1096
+ reply: await menuWithBanner(
1097
+ deps,
1098
+ `❌ Switch to **${deps.escapeHtml(alias)}** did not take: ${deps.escapeHtml(aliasErr)}`,
1099
+ ),
1100
+ }
1101
+ }
1102
+ // Silent success (no confirmation, no error) → optimistic record, same as
1103
+ // the typed path. PROVISIONAL copy (#3242 review FIX 2): don't assert the
1104
+ // switch succeeded — we couldn't read a confirmation, and /status self-heals.
1105
+ const optimisticLabel = optimisticModelRecordLabel(alias)
991
1106
  return {
992
- answer: confirmation,
993
- reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
994
- ...(kept ? {} : {
995
- selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
996
- selectedModelToken: alias,
997
- }),
1107
+ answer: `Sent /model ${alias} — check /status`,
1108
+ reply: await menuWithBannerStatic(
1109
+ deps,
1110
+ `Sent \`/model ${deps.escapeHtml(alias)}\` couldn’t read a confirmation line. \`/status\` will show the live model once it’s confirmed.`,
1111
+ ),
1112
+ selectedModel: optimisticLabel,
1113
+ selectedModelToken: alias,
998
1114
  }
999
1115
  }
1000
1116
  return {
@@ -1185,9 +1301,25 @@ export function modelSwitchConfirmationLine(output: string): string | null {
1185
1301
  * 2026-07-10): `/model claude-bogus-99` prints
1186
1302
  * `⎿ Model 'claude-bogus-99' not found` — glyph prefix `⎿`, quoted model
1187
1303
  * name between "Model" and "not found". Both shapes are covered.
1304
+ *
1305
+ * #3242 review MEDIUM 1 — ACCESS/ENTITLEMENT DENIAL. A bad-id shape is not the
1306
+ * only failure: `/model fable` on a plan that lacks it prints an availability /
1307
+ * access-denial line ("Fable is not available on your plan", "access denied",
1308
+ * "requires a subscription", "not enabled for your account", "no access to …").
1309
+ * Those match neither the bad-id shapes nor the confirmation prefix, so the poll
1310
+ * loop would expire and the optimistic branch would falsely record the switch —
1311
+ * exactly the Fable-entitlement case this PR is about. The second alternation
1312
+ * group covers those phrasings. It allows up to four leading words (a model
1313
+ * name + a linking adverb etc.) BEFORE the denial phrase — unlike the bad-id
1314
+ * branches, which keep
1315
+ * their original tight line-start anchoring so ordinary scrollback that merely
1316
+ * says "model not found" mid-sentence still can't false-fail a silent switch.
1317
+ * The handler checks the confirmation line FIRST (below), so a genuine switch —
1318
+ * which always prints a confirmation — is never flipped to a failure by a stray
1319
+ * availability word in the same region.
1188
1320
  */
1189
1321
  const MODEL_SWITCH_ERROR_RE =
1190
- /^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model)\b/i
1322
+ /^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model|(?:[\w'’.\-]+\s+){0,4}(?:(?:is |are )?(?:not available|unavailable|not enabled|not supported)|access denied|requires\b[^\n]{0,40}\b(?:subscription|plan)|no access)\b)/i
1191
1323
 
1192
1324
  /** The single capture line that reads as a claude model-switch error, or null. */
1193
1325
  export function modelSwitchErrorLine(output: string): string | null {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Crash-survival redelivery — the PURE decision predicate.
3
+ *
4
+ * When the gateway process crashes (or the hang-watchdog kills it) BEFORE the
5
+ * in-memory flush timer fires, the model's completed final answer — captured
6
+ * only in the in-memory buffer — is lost, and the user gets permanent silence.
7
+ * The answer text is, however, still durable on disk in the claude session
8
+ * transcript. On the next boot we can re-project that trailing text and re-send
9
+ * it. This module owns the "should we re-send, and how" decision so it is
10
+ * unit-testable without booting a gateway (the boot block only wires I/O to it).
11
+ *
12
+ * The delivery oracle is DURABLE TEXT-IDENTITY, not a chat+time window: we
13
+ * redeliver only if the PROJECTED final-answer text was NOT already delivered
14
+ * (matched against delivered `messages` rows — see `hasOutboundWithText`). A
15
+ * time-windowed "any assistant row since started_at" oracle is UNSOUND here:
16
+ * an interim `progress_update` or a streamed partial chunk lands a mid-turn
17
+ * `role='assistant'` row and would false-positive "delivered", suppressing a
18
+ * genuinely-undelivered final answer (the exact MISS this fix exists to close).
19
+ *
20
+ * The redelivered text is FRAMED as a recovered/interrupted draft rather than
21
+ * presented as a clean final answer: the transcript cannot durably distinguish
22
+ * "final answer complete" from "answer-so-far, more tools intended", so we never
23
+ * assert it is the finished answer.
24
+ */
25
+
26
+ /** The short preamble that frames a redelivered draft. */
27
+ export const REDELIVERY_PREFIX = 'Recovered from an interrupted turn:'
28
+
29
+ export interface RedeliverDecisionInput {
30
+ /**
31
+ * The trailing assistant text re-projected from the interrupted turn's own
32
+ * transcript (after the last tool_use). Empty string when the transcript had
33
+ * no trailing text to redeliver.
34
+ */
35
+ capturedText: string
36
+ /**
37
+ * True when the trailing transcript content AFTER the last tool_use is text
38
+ * (not a dangling tool_use). Bounds the preamble-vs-final ambiguity (R2): a
39
+ * turn whose last on-disk event was a tool_use is mid-stream, not a finished
40
+ * answer, so we do not redeliver it.
41
+ */
42
+ trailingIsText: boolean
43
+ /**
44
+ * Result of the DURABLE text-identity oracle: true iff the projected answer
45
+ * text was already delivered to this chat/thread (`hasOutboundWithText`).
46
+ */
47
+ hasDeliveredText: boolean
48
+ /** True iff `answer_redelivered_at` is already stamped (at-most-once ledger). */
49
+ alreadyRedelivered: boolean
50
+ /** Age of the interrupted turn in ms (`now - started_at`). */
51
+ ageMs: number
52
+ /** Staleness failsafe (`RESUME_MAX_AGE_MS`, default 3h). */
53
+ maxAgeMs: number
54
+ }
55
+
56
+ export type RedeliverSkipReason =
57
+ | 'empty-text'
58
+ | 'trailing-not-text'
59
+ | 'already-delivered'
60
+ | 'already-redelivered'
61
+ | 'stale'
62
+
63
+ export interface RedeliverDecision {
64
+ redeliver: boolean
65
+ /** Present iff `redeliver` is true — the framed text to send. */
66
+ framedText?: string
67
+ /** Present iff `redeliver` is false — why we declined. */
68
+ skipReason?: RedeliverSkipReason
69
+ }
70
+
71
+ /**
72
+ * Whether to CAPTURE an interrupted turn as a redelivery candidate at boot.
73
+ * This is the mutual-exclusion gate against the resume synthetic — decided
74
+ * BEFORE any transcript projection, purely from the boot-resume outcome.
75
+ */
76
+ export interface RedeliverCaptureInput {
77
+ /**
78
+ * True iff the boot-resume path will RE-RUN this turn's work in a fresh
79
+ * session (bootResumeKind === 'resume'). A resumed turn emits a fresh answer
80
+ * that supersedes any recovered draft, so redelivering as well is a
81
+ * double-send. Every non-resume outcome (watchdog report, defer-suppressed,
82
+ * defer-loop, none) does NOT auto-re-answer, so redelivery is the correct and
83
+ * only recovery send there.
84
+ */
85
+ willBeResumed: boolean
86
+ /**
87
+ * True iff the interrupted turn has a durably-pinned `session_id`. Without it
88
+ * we cannot resolve the exact transcript to re-project, so we cannot redeliver.
89
+ */
90
+ hasSessionId: boolean
91
+ }
92
+
93
+ export type RedeliverCaptureSkip = 'will-be-resumed' | 'no-session-id'
94
+
95
+ export interface RedeliverCaptureDecision {
96
+ capture: boolean
97
+ /** Present iff `capture` is false — why we declined to stage redelivery. */
98
+ skipReason?: RedeliverCaptureSkip
99
+ }
100
+
101
+ /**
102
+ * Decide whether to stage an interrupted turn for crash-survival redelivery.
103
+ * Pure: the mutual-exclusion rule with resume lives here so it is testable
104
+ * without booting a gateway. `will-be-resumed` takes precedence over the
105
+ * session-id check because a to-be-resumed turn must never redeliver even if it
106
+ * has a session_id — the fresh re-answer is the send.
107
+ */
108
+ export function decideRedeliverCapture(input: RedeliverCaptureInput): RedeliverCaptureDecision {
109
+ if (input.willBeResumed) return { capture: false, skipReason: 'will-be-resumed' }
110
+ if (!input.hasSessionId) return { capture: false, skipReason: 'no-session-id' }
111
+ return { capture: true }
112
+ }
113
+
114
+ /**
115
+ * Frame a captured draft for redelivery — a short recovered-draft preamble
116
+ * above the model's own words. Pure; exported for the boot wiring + tests.
117
+ */
118
+ export function frameRedelivery(capturedText: string): string {
119
+ return `${REDELIVERY_PREFIX}\n\n${capturedText.trim()}`
120
+ }
121
+
122
+ /**
123
+ * Decide whether — and with what framed text — to redeliver an interrupted
124
+ * turn's captured-but-undelivered final answer. Pure: no clock, no I/O.
125
+ *
126
+ * Precedence of skip reasons is deliberate (most-specific first): a turn with
127
+ * no trailing text can never redeliver regardless of delivery/age; a delivered
128
+ * or already-redelivered answer is skipped before the age failsafe so the
129
+ * at-most-once guarantee never depends on staleness.
130
+ */
131
+ export function decideRedeliver(input: RedeliverDecisionInput): RedeliverDecision {
132
+ const text = input.capturedText.trim()
133
+ if (text.length === 0) return { redeliver: false, skipReason: 'empty-text' }
134
+ if (!input.trailingIsText) return { redeliver: false, skipReason: 'trailing-not-text' }
135
+ if (input.alreadyRedelivered) return { redeliver: false, skipReason: 'already-redelivered' }
136
+ if (input.hasDeliveredText) return { redeliver: false, skipReason: 'already-delivered' }
137
+ if (input.ageMs > input.maxAgeMs) return { redeliver: false, skipReason: 'stale' }
138
+ return { redeliver: true, framedText: frameRedelivery(text) }
139
+ }
@@ -149,21 +149,62 @@ export function buildVaultGrantDeniedInbound(opts: {
149
149
  * @param key Vault key (rendered inline-code).
150
150
  * @param days Grant TTL in whole days.
151
151
  * @param grantId Broker-returned grant id.
152
+ * @param reasonEscaped Optional original request reason the agent gave,
153
+ * already run through `escapeHtmlForTg` (it is
154
+ * agent-supplied free text). Rendered as a trailing
155
+ * italic clause for audit visibility. Omitted when
156
+ * absent/empty so no dangling "Reason:" label shows.
157
+ * Placed BEFORE the footer so the auth-mode note
158
+ * stays last.
152
159
  * @param footer Optional trailing footer (e.g. the telegram-id
153
160
  * auth-mode note). Empty string when absent.
154
161
  */
162
+ /** Max characters of agent-supplied reason rendered on the grant card.
163
+ * Keeps the edited card well under Telegram's 4096-char editMessageText
164
+ * limit — the edit is fire-and-forget (`.catch(() => {})`), so an
165
+ * over-long reason would silently fail the edit and leave the pending
166
+ * approve/deny buttons showing even though the grant already succeeded. */
167
+ export const MAX_GRANT_REASON_CHARS = 300
168
+
169
+ /**
170
+ * Normalize an agent-supplied grant reason for single-line rendering
171
+ * inside the GFM italic clause `_Reason: …_`:
172
+ * - collapse ALL whitespace runs to a single space (a newline breaks
173
+ * the `_…_` emphasis — the opening `_` renders literal),
174
+ * - trim, so a whitespace-only reason cleanly no-ops (returns ''),
175
+ * - cap length with a trailing ellipsis so the card edit can't blow
176
+ * past Telegram's message-length limit.
177
+ *
178
+ * Returns UNescaped text — the caller MUST HTML-escape the result before
179
+ * rendering (it is agent-supplied free text). Returns '' for
180
+ * empty/whitespace-only/undefined input so the caller can skip the clause.
181
+ */
182
+ export function normalizeGrantReason(raw: string | undefined | null): string {
183
+ if (raw == null) return ''
184
+ const collapsed = raw.replace(/\s+/g, ' ').trim()
185
+ if (collapsed.length === 0) return ''
186
+ return collapsed.length > MAX_GRANT_REASON_CHARS
187
+ ? collapsed.slice(0, MAX_GRANT_REASON_CHARS - 1) + '…'
188
+ : collapsed
189
+ }
190
+
155
191
  export function buildVaultGrantApprovedCardText(opts: {
156
192
  agentEscaped: string
157
193
  scope: 'read' | 'write'
158
194
  key: string
159
195
  days: number
160
196
  grantId: string
197
+ reasonEscaped?: string
161
198
  footer?: string
162
199
  }): string {
200
+ const reasonClause =
201
+ opts.reasonEscaped != null && opts.reasonEscaped.length > 0
202
+ ? ` _Reason: ${opts.reasonEscaped}_`
203
+ : ''
163
204
  return (
164
205
  `✅ Granted **${opts.agentEscaped}** ${opts.scope} access to ` +
165
206
  `\`${opts.key}\` for ${opts.days}d. ` +
166
- `(grant \`${opts.grantId}\`)` + (opts.footer ?? '')
207
+ `(grant \`${opts.grantId}\`)` + reasonClause + (opts.footer ?? '')
167
208
  )
168
209
  }
169
210
 
@@ -740,6 +740,124 @@ export function hasOutboundDeliveredSince(
740
740
  }
741
741
  }
742
742
 
743
+ /**
744
+ * DURABLE text-identity delivery oracle for crash-survival redelivery.
745
+ *
746
+ * Returns true iff a substantive outbound (`role='assistant'`) row whose text
747
+ * matches `text` has been delivered to `chatId` (and optionally `threadId`).
748
+ * Unlike `hasOutboundDeliveredSince`, this keys on the ANSWER TEXT itself, not
749
+ * a chat+time window — so an interim `progress_update` ("on it…") or a streamed
750
+ * partial chunk sent earlier in the same turn does NOT false-positive as "the
751
+ * final answer was delivered" (the exact MISS bug that would otherwise suppress
752
+ * a genuinely-undelivered final answer and re-open the permanent-silence hole).
753
+ *
754
+ * Matching is on the NORMALIZED first chunk (see `normalizeDeliveryText`): a
755
+ * multi-chunk answer's chunk-1 is compared against delivered rows so a crash
756
+ * after chunk-1 is detected as delivered (avoids double-sending chunk-1). Both
757
+ * sides are normalized identically. Empty/whitespace text never matches.
758
+ *
759
+ * Durable (reads committed SQLite), survives restart — unlike the in-memory
760
+ * `outboundDedup` ring, whose window is destroyed by the crash.
761
+ *
762
+ * SCOPING (crash-redelivery diff-review defect #1). Two guards keep a match from
763
+ * being over-broad and suppressing a genuine redelivery (a MISS = permanent
764
+ * silence):
765
+ * 1. `sinceMs` — when provided (the interrupted turn's `started_at`), only
766
+ * rows delivered AT/AFTER that instant are considered, so an UNRELATED
767
+ * earlier turn's message can never satisfy the match. This is the primary
768
+ * scope: a redelivery candidate only cares whether THIS turn's own answer
769
+ * already went out.
770
+ * 2. Short-text exactness — the bidirectional-prefix rule (which lets a
771
+ * delivered multi-chunk chunk-1 satisfy a longer projected answer) is only
772
+ * applied when the shorter side is at least `MIN_PREFIX_MATCH_CHARS`. For a
773
+ * SHORT final answer (e.g. "Done.") a prefix match against a longer row
774
+ * ("Done, deploying now.") would false-positive; short texts therefore
775
+ * require full normalized EQUALITY. Real answer chunks are far longer than
776
+ * the floor, so multi-chunk detection is unaffected.
777
+ *
778
+ * Falls back to false (safe: never suppresses a needed redelivery) if history
779
+ * is not initialised or the query fails — the `answer_redelivered_at` marker is
780
+ * the second guard against a double-send in that degraded case.
781
+ */
782
+ export function hasOutboundWithText(
783
+ chatId: string,
784
+ text: string,
785
+ threadId?: number | null,
786
+ sinceMs?: number,
787
+ ): boolean {
788
+ const needle = normalizeDeliveryText(text)
789
+ if (needle.length === 0) return false
790
+ try {
791
+ // Compare on the normalized prefix so trailing-whitespace / spacer
792
+ // differences between the rendered outbound and the re-projected answer do
793
+ // not defeat the match. We fetch candidate assistant rows and normalize in
794
+ // JS (SQLite lacks the same normalize) — bounded by chat/thread scope.
795
+ const params: unknown[] = [chatId]
796
+ let sql = "SELECT text FROM messages WHERE chat_id = ? AND role = 'assistant'"
797
+ if (threadId !== undefined) {
798
+ if (threadId === null) {
799
+ sql += ' AND thread_id IS NULL'
800
+ } else {
801
+ sql += ' AND thread_id = ?'
802
+ params.push(threadId)
803
+ }
804
+ }
805
+ if (sinceMs != null && Number.isFinite(sinceMs)) {
806
+ // history `ts` is unix SECONDS (recordOutbound); started_at is wall-clock
807
+ // ms. Scope out any prior turn's rows so an unrelated earlier message can
808
+ // never satisfy the match.
809
+ sql += ' AND ts >= ?'
810
+ params.push(Math.floor(sinceMs / 1000))
811
+ }
812
+ // Newest first: a redelivery candidate's match is almost always recent.
813
+ sql += ' ORDER BY ts DESC LIMIT 500'
814
+ const rows = requireDb()
815
+ .prepare(sql)
816
+ .all(...(params as [unknown, ...unknown[]])) as { text: string | null }[]
817
+ for (const r of rows) {
818
+ const hay = normalizeDeliveryText(r.text ?? '')
819
+ if (hay.length === 0) continue
820
+ if (deliveryTextMatch(hay, needle)) return true
821
+ }
822
+ return false
823
+ } catch {
824
+ return false
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Normalize outbound text for durable text-identity delivery matching. Collapses
830
+ * all whitespace runs to single spaces and trims — so paragraph-spacer / render
831
+ * differences between a stored outbound and a re-projected transcript answer do
832
+ * not defeat an otherwise-identical match. Pure; shared by the redelivery path.
833
+ */
834
+ export function normalizeDeliveryText(text: string): string {
835
+ return text.replace(/\s+/g, ' ').trim()
836
+ }
837
+
838
+ /**
839
+ * Minimum length (of the SHORTER side) at which the bidirectional-prefix rule is
840
+ * allowed. Below this, a delivery match requires full normalized equality — so a
841
+ * short final answer can't be falsely suppressed by an unrelated longer row that
842
+ * merely shares a prefix. Real answer chunks (chunk-1 of a multi-chunk send) are
843
+ * far longer than this floor, so multi-chunk detection is unaffected.
844
+ */
845
+ export const MIN_PREFIX_MATCH_CHARS = 40
846
+
847
+ /**
848
+ * Text-identity match for the delivery oracle. Exact normalized equality always
849
+ * matches. The bidirectional-prefix relaxation (which lets a delivered chunk-1
850
+ * satisfy a longer projected answer, and vice-versa) applies ONLY when the
851
+ * shorter side is at least `MIN_PREFIX_MATCH_CHARS` — otherwise a short answer
852
+ * would false-positive against any longer row sharing its prefix. Pure.
853
+ */
854
+ export function deliveryTextMatch(hay: string, needle: string): boolean {
855
+ if (hay === needle) return true
856
+ const shorter = Math.min(hay.length, needle.length)
857
+ if (shorter < MIN_PREFIX_MATCH_CHARS) return false
858
+ return hay.startsWith(needle) || needle.startsWith(hay)
859
+ }
860
+
743
861
  export function query(opts: QueryOptions): RecordedMessage[] {
744
862
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
745
863
  const params: unknown[] = [opts.chat_id]