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
@@ -17208,7 +17208,7 @@ function findActiveSessionFile(projectsDir) {
17208
17208
  }
17209
17209
  function parseChannelMeta(content) {
17210
17210
  const grab = (key) => {
17211
- const m = content.match(new RegExp(`${key}="([^"]+)"`));
17211
+ const m = content.match(new RegExp(`(?:^|[\\s"'])${key}="([^"]+)"`));
17212
17212
  return m ? m[1] : null;
17213
17213
  };
17214
17214
  return {
@@ -17414,7 +17414,12 @@ function projectSubagentLine(line, agentId, state) {
17414
17414
  if (ct === "tool_use") {
17415
17415
  const name = c.name ?? "";
17416
17416
  if (name === "Agent" || name === "Task") {
17417
- events.push({ kind: "sub_agent_nested_spawn", agentId });
17417
+ events.push({
17418
+ kind: "sub_agent_nested_spawn",
17419
+ agentId,
17420
+ toolUseId: c.id ?? null,
17421
+ input: c.input ?? undefined
17422
+ });
17418
17423
  } else {
17419
17424
  events.push({
17420
17425
  kind: "sub_agent_tool_use",
@@ -24098,7 +24103,6 @@ var ALWAYS_LOAD_TOOLS, LINEAR_TOOLS, LINEAR_ENV = "SWITCHROOM_TELEGRAM_LINEAR";
24098
24103
  var init_tool_filter = __esm(() => {
24099
24104
  ALWAYS_LOAD_TOOLS = new Set([
24100
24105
  "reply",
24101
- "stream_reply",
24102
24106
  "get_recent_messages",
24103
24107
  "react",
24104
24108
  "edit_message",
@@ -24347,7 +24351,7 @@ var init_bridge = __esm(async () => {
24347
24351
  instructions: [
24348
24352
  "The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool \u2014 your transcript output never reaches their chat.",
24349
24353
  "",
24350
- 'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply and stream_reply tools quote-reply to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24354
+ 'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24351
24355
  "",
24352
24356
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24353
24357
  "",
@@ -24402,45 +24406,6 @@ var init_bridge = __esm(async () => {
24402
24406
  required: ["chat_id", "text"]
24403
24407
  }
24404
24408
  },
24405
- {
24406
- name: "stream_reply",
24407
- description: "Post the final answer for this turn. The plugin renders an event-driven progress card (Plan \u2192 Run \u2192 Done with live tool bullets, elapsed time, and status emoji) for free while the turn is in-flight, so you do not need to narrate intermediate progress. Call `stream_reply` exactly once per turn with done=true and the complete answer text. Hard cap is 32768 chars (the rich-message wire limit) \u2014 longer text is dropped by a defensive guard, so use `reply` for anything that long (it chunks). Calling with done=false is an error in this environment (the progress card already owns the mid-turn surface). inline_keyboard adds tappable buttons under the final message \u2014 see `reply` for shape and constraints.",
24408
- inputSchema: {
24409
- type: "object",
24410
- properties: {
24411
- chat_id: { type: "string" },
24412
- text: { type: "string", description: "Full text snapshot. NOT a delta \u2014 pass the complete current content each call." },
24413
- done: { type: "boolean", description: "Must be true. Posts this text as the final answer for the turn and locks the message." },
24414
- message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." },
24415
- origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24416
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24417
- reply_to: { type: "string", description: "Message ID to quote-reply to. Overrides the default (latest inbound)." },
24418
- quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set." },
24419
- protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
24420
- quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
24421
- disable_notification: { type: "boolean", description: "When true, the INITIAL message send is silent (no device ping). Has no effect on subsequent edits \u2014 Telegram never pings on editMessageText. Default false. Use for mid-turn stream starts you do not want to ping; omit on the final answer." },
24422
- inline_keyboard: {
24423
- type: "array",
24424
- description: '2D array of tappable buttons under the final message. Same shape and constraints as `reply.inline_keyboard` \u2014 each button has `text` and EXACTLY ONE of `url` or `callback_data`, plus optional `ack_text` (custom tap-toast; default "\u2713 received") and `single_use` (default true; set false to keep the keyboard tappable after a tap). Tap on a callback_data button is delivered to this agent as an inbound channel event with meta.button_callback_data set.',
24425
- items: {
24426
- type: "array",
24427
- items: {
24428
- type: "object",
24429
- properties: {
24430
- text: { type: "string" },
24431
- url: { type: "string" },
24432
- callback_data: { type: "string" },
24433
- ack_text: { type: "string", description: 'Toast text shown on tap. Default "\u2713 received".' },
24434
- single_use: { type: "boolean", description: "Default true. Set false to keep the keyboard tappable after this button is tapped." }
24435
- },
24436
- required: ["text"]
24437
- }
24438
- }
24439
- }
24440
- },
24441
- required: ["chat_id", "text"]
24442
- }
24443
- },
24444
24409
  {
24445
24410
  name: "react",
24446
24411
  description: "Add an emoji reaction to a Telegram message. Telegram only accepts a fixed whitelist (\uD83D\uDC4D \uD83D\uDC4E \u2764 \uD83D\uDD25 \uD83D\uDC40 \uD83C\uDF89 etc) \u2014 non-whitelisted emoji will be rejected.",
@@ -0,0 +1,113 @@
1
+ /**
2
+ * #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
3
+ * so the gateway glue that mutates the busy-key set stays in lockstep with its
4
+ * shadow insertion-timestamp map and is unit-testable WITHOUT importing
5
+ * `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
6
+ * run inside a unit test). The gateway keeps ownership of the two containers
7
+ * (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
8
+ * every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
9
+ * The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
10
+ * the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
11
+ * the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
12
+ *
13
+ * Two invariants this module enforces:
14
+ *
15
+ * 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
16
+ * prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
17
+ * key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
18
+ * the set — not on `since.has` — is load-bearing: a disconnect flush clears
19
+ * `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
20
+ * guard would decline to re-stamp on the reconnect→re-mark and leave a
21
+ * stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
22
+ * then reap out from under a live delivery.
23
+ *
24
+ * 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
25
+ * delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
26
+ * under load (#1922). During that eager-mark→enqueue window the gateway's
27
+ * `currentTurn` is null yet the turn is real-and-merely-slow. A bare
28
+ * time-grace reaper cannot tell "slow" from "orphaned" and would reap a
29
+ * genuine delivery, re-opening the idle-drain gate while claude is about to
30
+ * process the very inbound whose key it just reaped (duplicate / concurrent
31
+ * delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
32
+ * reaped ONLY when it has NO entry in the delivery-confirm queue. A
33
+ * slow-but-real inbound is tracked there from delivery until claude's
34
+ * `enqueue` ack (via the never-drop re-deliver loop), so any key still
35
+ * awaiting its turn is present and skipped — no matter how slow. The time
36
+ * grace is retained as defense-in-depth, defaulting well above the observed
37
+ * ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
38
+ * the grace is a true orphan (its turn acked and should have cleared busy at
39
+ * turn_end, or it was a steer/interrupt inbound excluded from tracking that
40
+ * amends a now-absent turn) — reaping those can never clobber a slow one.
41
+ */
42
+
43
+ /** Lockstep mark: add to the busy set and, on the idle→busy transition only,
44
+ * stamp the insertion time. Keyed on set membership so a re-mark after a
45
+ * disconnect flush (which cleared the set directly) always re-stamps fresh. */
46
+ export function markBusyKeyLockstep(
47
+ keys: Set<string>,
48
+ since: Map<string, number>,
49
+ key: string,
50
+ now: number,
51
+ ): void {
52
+ const wasBusy = keys.has(key)
53
+ keys.add(key)
54
+ if (!wasBusy) since.set(key, now)
55
+ }
56
+
57
+ /** Lockstep clear: delete from both containers. */
58
+ export function clearBusyKeyLockstep(
59
+ keys: Set<string>,
60
+ since: Map<string, number>,
61
+ key: string,
62
+ ): void {
63
+ keys.delete(key)
64
+ since.delete(key)
65
+ }
66
+
67
+ export interface ReapOptions {
68
+ /** Grace before a busy-marked key with no pending delivery is a true orphan. */
69
+ ttlMs: number
70
+ /** True iff a delivery-confirm entry is still tracked for this key — i.e. a
71
+ * slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
72
+ * regardless of age (the #1922 slow-delivery guarantee). */
73
+ hasPendingDelivery: (key: string) => boolean
74
+ /** Optional line logger for each reaped key (observability). */
75
+ log?: (msg: string) => void
76
+ }
77
+
78
+ /**
79
+ * Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
80
+ * in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
81
+ * metrics / assertions). Also prunes shadow-map entries whose key already left
82
+ * the set, so the map can't grow unbounded.
83
+ */
84
+ export function reapOrphanBusyKeys(
85
+ keys: Set<string>,
86
+ since: Map<string, number>,
87
+ now: number,
88
+ opts: ReapOptions,
89
+ ): string[] {
90
+ const reaped: string[] = []
91
+ for (const key of [...since.keys()]) {
92
+ if (!keys.has(key)) {
93
+ since.delete(key)
94
+ continue
95
+ }
96
+ // Proof gate: a key with a pending delivery is a slow-but-real inbound still
97
+ // awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
98
+ // an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
99
+ // can't be reaped out from under claude.
100
+ if (opts.hasPendingDelivery(key)) continue
101
+ const stampedAt = since.get(key) ?? now
102
+ if (now - stampedAt >= opts.ttlMs) {
103
+ keys.delete(key)
104
+ since.delete(key)
105
+ reaped.push(key)
106
+ opts.log?.(
107
+ `telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
108
+ `no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
109
+ )
110
+ }
111
+ }
112
+ return reaped
113
+ }
@@ -42,6 +42,12 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
42
42
  * activeTurnStartedAt — the bridge just died, every turn it
43
43
  * was handed is dead by definition. */
44
44
  claudeBusyKeys: Set<string>
45
+ /** #2787: insertion-timestamp map for `claudeBusyKeys`, backing the orphan
46
+ * reaper. MUST stay in lockstep with `claudeBusyKeys` at every clear site —
47
+ * a stale timestamp surviving a clear makes a later re-marked key look
48
+ * >TTL-old and get reaped against a live delivery. Deleted alongside every
49
+ * `claudeBusyKeys.delete`/`.clear` below. */
50
+ claudeBusyKeySince: Map<string, number>
45
51
 
46
52
  /** Open draft-stream handles keyed by chat:thread:replyId. */
47
53
  activeDraftStreams: Map<string, Stream>
@@ -82,6 +88,7 @@ export function flushOnAgentDisconnect<
82
88
  activeReactionMsgIds,
83
89
  activeTurnStartedAt,
84
90
  claudeBusyKeys,
91
+ claudeBusyKeySince,
85
92
  activeDraftStreams,
86
93
  clearActiveReactions,
87
94
  disposeProgressDriver,
@@ -109,6 +116,7 @@ export function flushOnAgentDisconnect<
109
116
  activeReactionMsgIds.delete(key)
110
117
  activeTurnStartedAt.delete(key)
111
118
  claudeBusyKeys.delete(key)
119
+ claudeBusyKeySince.delete(key) // #2787: keep orphan-TTL map in lockstep
112
120
  }
113
121
  clearActiveReactions()
114
122
 
@@ -129,6 +137,7 @@ export function flushOnAgentDisconnect<
129
137
  activeTurnStartedAt.delete(k)
130
138
  activeReactionMsgIds.delete(k)
131
139
  claudeBusyKeys.delete(k)
140
+ claudeBusyKeySince.delete(k) // #2787: keep orphan-TTL map in lockstep
132
141
  }
133
142
  log(
134
143
  `telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
@@ -156,6 +165,8 @@ export function flushOnAgentDisconnect<
156
165
  const orphanCount = claudeBusyKeys.size
157
166
  const orphanKeys = [...claudeBusyKeys]
158
167
  claudeBusyKeys.clear()
168
+ // #2787: keep the orphan-TTL map in lockstep with the set it shadows.
169
+ for (const k of orphanKeys) claudeBusyKeySince.delete(k)
159
170
  log(
160
171
  `telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
161
172
  `entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
@@ -0,0 +1,46 @@
1
+ /**
2
+ * escalation-bridge-gate.ts — the bridge-alive gate for the obligation sweep's
3
+ * ESCALATE branch, extracted from obligationSweep so the "do NOT direct-send the
4
+ * 'I may have missed this' nudge while the Telegram bridge is down" decision
5
+ * (#2788 Gap A) is EXECUTABLE in a pure unit test.
6
+ *
7
+ * The bug (#2788 Gap A): the obligation sweep keeps running while the Telegram
8
+ * bridge is disconnected. Unlike the represent branch (which goes through the
9
+ * bridge → buffer and is naturally stranded behind a dead bridge), the escalate
10
+ * branch DIRECT-SENDS via `bot.api.sendMessage`, bypassing the bridge. So during
11
+ * a transient bridge flap the user could receive a false "I may have missed this"
12
+ * even though the real reply is simply queued behind the outage and will land the
13
+ * moment the bridge recovers.
14
+ *
15
+ * The fix is a deferral, not a drop: obligations already survive bridge death
16
+ * (they live in the durable ledger and are re-evaluated every sweep), so gating
17
+ * the escalate SEND on bridge-alive and returning without closing leaves the
18
+ * obligation OPEN. The next sweep after the bridge reconnects re-drives it — the
19
+ * nudge is deferred until it can reflect a real, post-recovery miss. This adds NO
20
+ * unbounded liveness dependency: the entire gateway's inbound delivery already
21
+ * rests on the bridge eventually reconnecting / the process restarting.
22
+ *
23
+ * PURE — no Telegram, no IPC; the gateway injects `bridgeAlive` (read from
24
+ * `ipcServer.getClient(agent)?.isAlive()`).
25
+ */
26
+
27
+ export interface EscalationBridgeGateDeps {
28
+ /**
29
+ * True when this agent's bridge sidecar is currently registered and alive on
30
+ * the IPC server — i.e. a direct escalate send can actually reflect delivery
31
+ * reality rather than racing a queued-behind-a-dead-bridge reply.
32
+ */
33
+ bridgeAlive: boolean
34
+ }
35
+
36
+ /**
37
+ * Decide whether the escalate branch should DEFER (skip this sweep, leave the
38
+ * obligation OPEN) because the bridge is down.
39
+ *
40
+ * Returns true ⇒ defer: do not send the nudge, do not close — the durable
41
+ * obligation is re-driven on a later sweep once the bridge recovers.
42
+ * Returns false ⇒ the bridge is alive; proceed with escalation.
43
+ */
44
+ export function shouldDeferEscalationForBridge(deps: EscalationBridgeGateDeps): boolean {
45
+ return !deps.bridgeAlive
46
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Gate-parity drift probe — scaffolding for the inbound-delivery
3
+ * state-machine cutover (#2794, RFC PR3b→PR4).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
8
+ *
9
+ * 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
10
+ * 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
11
+ * machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
12
+ * turn-in-flight gate via `isMachineInTurn()`,
13
+ * 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
14
+ * in parallel and read as the kill-switch fallback.
15
+ *
16
+ * Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
17
+ * fact ("is a turn in flight?") is tracked in two live places, a fix in
18
+ * one can silently diverge from the other. The machine is authoritative
19
+ * today, but `claudeBusyKeys` is still wired and still read on the
20
+ * kill-switch path — so the two MUST NOT drift in the dangerous direction.
21
+ *
22
+ * ## What "agree" means (and the ONE intended divergence)
23
+ *
24
+ * The machine and `claudeBusyKeys` are designed to agree on every
25
+ * WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
26
+ * deliberately allowed to diverge in exactly ONE direction on a MALFORMED
27
+ * schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
28
+ * ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
29
+ *
30
+ * - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
31
+ * single-activeTurn reopen) while `claudeBusyKeys`
32
+ * still holds an orphan key. This is EXPECTED and
33
+ * GOOD: it is precisely the wedge the machine was
34
+ * made authoritative to kill. Not a drift alarm.
35
+ *
36
+ * - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
37
+ * is empty. This is the DANGEROUS direction: the
38
+ * now-authoritative gate would hold closed while
39
+ * the imperative view says idle — a NEW wedge
40
+ * class the cutover must never introduce. This
41
+ * is the drift #2794 warns about; surface it.
42
+ *
43
+ * This module is a pure classifier plus a log-only runtime probe. It
44
+ * performs NO I/O of its own beyond an optional injected log sink and
45
+ * changes NO delivery behaviour — it only observes. Deleting it once the
46
+ * `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
47
+ */
48
+
49
+ export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
50
+
51
+ /**
52
+ * Classify the relationship between the machine's authoritative
53
+ * turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
54
+ * Pure — no side effects.
55
+ */
56
+ export function gateParityDivergence(
57
+ machineInTurn: boolean,
58
+ busyKeysSize: number,
59
+ ): GateParityDivergence {
60
+ const busy = busyKeysSize > 0
61
+ if (machineInTurn === busy) return 'none'
62
+ // machine idle, busyKeys non-empty → the orphan dangle the machine heals.
63
+ if (!machineInTurn && busy) return 'busykeys_dangle'
64
+ // machine in-flight, busyKeys empty → the dangerous over-hold.
65
+ return 'machine_over_holds'
66
+ }
67
+
68
+ /**
69
+ * True only for divergences that indicate a real cutover regression
70
+ * (the machine holding a gate the imperative shadow believes is open).
71
+ * The benign `busykeys_dangle` — the very wedge the machine fixes — is
72
+ * NOT flagged, so this probe has zero false positives on the known-good
73
+ * self-heal path.
74
+ */
75
+ export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
76
+ return d === 'machine_over_holds'
77
+ }
78
+
79
+ /**
80
+ * Log-only runtime drift canary. Call at the authoritative gate read.
81
+ * Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
82
+ * dangerous over-hold direction. Returns the machine value UNCHANGED so
83
+ * it can wrap the gate read without altering behaviour:
84
+ *
85
+ * return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
86
+ *
87
+ * @param log optional sink (default stderr) — test hook.
88
+ */
89
+ export function probeGateParity(
90
+ machineInTurn: boolean,
91
+ busyKeysSize: number,
92
+ log: (line: string) => void = (line) => process.stderr.write(line),
93
+ ): boolean {
94
+ const d = gateParityDivergence(machineInTurn, busyKeysSize)
95
+ if (isDangerousGateDivergence(d)) {
96
+ log(
97
+ `gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
98
+ `busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
99
+ )
100
+ }
101
+ return machineInTurn
102
+ }