switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * activity-card-store.ts — durable handle for the mid-turn activity card, so a
3
+ * gateway restart mid-turn can finalize the orphaned card instead of leaving it
4
+ * frozen forever.
5
+ *
6
+ * Why this exists (reference/rfcs/deterministic-turn-liveness.md Known Gap 1):
7
+ * `CurrentTurn.activityMessageId` / `startedAt` and the live `currentTurn`
8
+ * itself live ONLY in gateway memory. If the gateway restarts while a turn's
9
+ * activity card is open, the new process has no handle to the old card — it
10
+ * is never edited again and sits frozen on its last pre-restart text forever.
11
+ * The resumed turn (if any) opens a FRESH card; the old one is orphaned.
12
+ *
13
+ * The fix mirrors `status-pin-store.ts` byte-for-byte in shape: a tiny durable
14
+ * snapshot on the per-agent state volume (STATE_DIR), written whenever a card
15
+ * OPENS, cleared the moment it closes/finalizes normally. On boot, AFTER this
16
+ * gateway wins the startup mutex (same ordering constraint as the status-pin
17
+ * cleanup — this is a shared per-agent file, so a losing double-boot must
18
+ * never touch it), a one-shot reaper reads any leftover record and finalizes
19
+ * the orphaned card with a SINGLE honest edit (never a new message — edits do
20
+ * not notify, so no ping). We do NOT attempt to resume climbing the old card
21
+ * across the restart; the resumed turn (if any) owns a fresh card. Honest
22
+ * finalization — not resumption — is the goal.
23
+ *
24
+ * Crash-safety: write-tmp + atomic rename (identical convention to
25
+ * status-pin-store.ts / obligation-store.ts). A corrupt or unreadable file
26
+ * fails open to `[]` — never crashes boot, worst case an orphan isn't cleaned
27
+ * up this boot, no worse than pre-fix behaviour.
28
+ *
29
+ * DELIVERED GUARANTEE — AT-MOST-ONCE finalization (be honest about it):
30
+ * the reaper deletes a record from the on-disk store BEFORE attempting its
31
+ * finalizing edit (not after) and swallows every Telegram failure with no
32
+ * retry. That ordering buys strict idempotency — a crash mid-reap, or a second
33
+ * boot re-reading the file, can never double-edit or double-unpin the same
34
+ * message — at the cost of durability: if the single edit fails (flood-wait
35
+ * exhausted, transient 5xx, chat momentarily unreachable), the orphaned card is
36
+ * FORFEIT and stays frozen, because its record is already gone. So the promise
37
+ * is NOT "finalized on the very next boot" — it is "finalized AT MOST ONCE, on
38
+ * the next boot, best-effort". We deliberately chose at-most-once over
39
+ * at-least-once: a retry-until-success design cannot preserve the
40
+ * second-boot-zero-edits property across a crash between a successful edit and
41
+ * its post-edit delete (that window would re-edit an already-finalized card),
42
+ * and a stale re-finalize on a chat the user has since moved on from is a worse
43
+ * failure than one frozen orphan. A benign-400 (message already
44
+ * deleted/vanished, or "not modified") is NOT counted as a finalize — nothing
45
+ * was delivered — see the `vanished` tally in `runActivityCardBootReaper`.
46
+ */
47
+
48
+ export interface ActivityCardStoreFsSeam {
49
+ readFileSync: (path: string) => string
50
+ writeFileSync: (path: string, data: string) => void
51
+ /** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
52
+ * the snapshot. */
53
+ renameSync: (from: string, to: string) => void
54
+ existsSync: (path: string) => boolean
55
+ }
56
+
57
+ /** One persisted card handle: enough to identify and finalize the orphaned
58
+ * Telegram message on a future boot, without any live turn state. */
59
+ export interface ActivityCardRecord {
60
+ /** Stable per-topic key (`statusKey(chatId, threadId)` shape) — also the
61
+ * upsert key: a fresh OPEN for the same topic replaces any stale row. */
62
+ turnKey: string
63
+ chatId: string
64
+ threadId: number | null
65
+ activityMessageId: number
66
+ /** Wall-clock ms the turn started — carried through so the finalize text
67
+ * can be honest about elapsed time even after a restart. */
68
+ startedAt: number
69
+ /** Whether this card was silently pinned when it opened (the gateway pins
70
+ * every fresh activity card via `reconcileStatusPin('fg:'+turnKey, …)`).
71
+ * The boot reaper must unpin it too — a frozen card that's ALSO still
72
+ * pinned is a worse orphan (it stays glued to the top of the chat
73
+ * forever). Optional so a v1-shape record (pre-unpin-tracking) still
74
+ * loads and degrades to "don't attempt an unpin", never a crash. */
75
+ pinned?: boolean
76
+ }
77
+
78
+ interface SnapshotEnvelope {
79
+ v: 1
80
+ cards: ActivityCardRecord[]
81
+ }
82
+
83
+ function isCardRow(x: unknown): x is ActivityCardRecord {
84
+ if (x == null || typeof x !== 'object') return false
85
+ const o = x as Record<string, unknown>
86
+ return (
87
+ typeof o.turnKey === 'string' &&
88
+ o.turnKey.length > 0 &&
89
+ typeof o.chatId === 'string' &&
90
+ o.chatId.length > 0 &&
91
+ (o.threadId === null || typeof o.threadId === 'number') &&
92
+ typeof o.activityMessageId === 'number' &&
93
+ typeof o.startedAt === 'number' &&
94
+ (o.pinned === undefined || typeof o.pinned === 'boolean')
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Load the persisted card-handle set. Returns [] on a missing, unreadable, or
100
+ * malformed file — fail-open, never throws.
101
+ */
102
+ export function loadActivityCards(
103
+ path: string,
104
+ fs: ActivityCardStoreFsSeam,
105
+ ): ActivityCardRecord[] {
106
+ if (!fs.existsSync(path)) return []
107
+ let raw = ''
108
+ try {
109
+ raw = fs.readFileSync(path)
110
+ } catch {
111
+ return []
112
+ }
113
+ let parsed: unknown
114
+ try {
115
+ parsed = JSON.parse(raw)
116
+ } catch {
117
+ return []
118
+ }
119
+ if (parsed == null || typeof parsed !== 'object') return []
120
+ const env = parsed as Record<string, unknown>
121
+ if (env.v !== 1 || !Array.isArray(env.cards)) return []
122
+ return env.cards.filter(isCardRow)
123
+ }
124
+
125
+ /**
126
+ * Persist the card-handle set atomically (write sibling tmp → rename). Never
127
+ * throws — a write failure is logged and the store degrades to in-memory-only
128
+ * for this process (same contract as `status-pin-store.ts`).
129
+ */
130
+ export function persistActivityCards(
131
+ path: string,
132
+ fs: ActivityCardStoreFsSeam,
133
+ snapshot: readonly ActivityCardRecord[],
134
+ log: (line: string) => void = (l) => process.stderr.write(l),
135
+ ): void {
136
+ const env: SnapshotEnvelope = { v: 1, cards: [...snapshot] }
137
+ const tmp = path + '.tmp'
138
+ try {
139
+ fs.writeFileSync(tmp, JSON.stringify(env))
140
+ fs.renameSync(tmp, path)
141
+ } catch (err) {
142
+ log(
143
+ `activity-card-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
144
+ `durability degraded to in-memory\n`,
145
+ )
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Upsert the card-handle row for `turnKey` (replacing any stale row for the
151
+ * same topic — e.g. a prior turn's card that was never cleared). Called the
152
+ * moment a card OPENs (activityMessageId transitions null → set).
153
+ */
154
+ export function writeActivityCardRecord(
155
+ path: string,
156
+ fs: ActivityCardStoreFsSeam,
157
+ record: ActivityCardRecord,
158
+ log: (line: string) => void = (l) => process.stderr.write(l),
159
+ ): void {
160
+ const current = loadActivityCards(path, fs)
161
+ const others = current.filter((c) => c.turnKey !== record.turnKey)
162
+ persistActivityCards(path, fs, [...others, record], log)
163
+ }
164
+
165
+ /**
166
+ * Remove the card-handle row for `turnKey`, if present. Called the moment a
167
+ * card closes/finalizes normally (clearActivitySummary), and by the boot
168
+ * reaper (BEFORE attempting the finalizing edit — the idempotency guard).
169
+ *
170
+ * REAP-RACE guard: when `activityMessageId` is supplied, only a row matching
171
+ * BOTH `turnKey` AND `activityMessageId` is removed. During a slow multi-record
172
+ * reap (e.g. a 429 flood-wait between records), a fresh live turn can upsert a
173
+ * NEW record under the same `turnKey` (its own, different `activityMessageId`).
174
+ * A turnKey-only clear would then delete the LIVE card's durable protection.
175
+ * Scoping the clear to the exact message id the reaper (or the closing turn) is
176
+ * handling leaves the live card's record intact. Omit `activityMessageId` only
177
+ * when you genuinely mean "clear whatever row exists for this topic".
178
+ */
179
+ export function clearActivityCardRecord(
180
+ path: string,
181
+ fs: ActivityCardStoreFsSeam,
182
+ turnKey: string,
183
+ activityMessageId?: number,
184
+ log: (line: string) => void = (l) => process.stderr.write(l),
185
+ ): void {
186
+ const current = loadActivityCards(path, fs)
187
+ if (current.length === 0) return
188
+ const next = current.filter(
189
+ (c) =>
190
+ c.turnKey !== turnKey ||
191
+ (activityMessageId !== undefined && c.activityMessageId !== activityMessageId),
192
+ )
193
+ if (next.length === current.length) return
194
+ persistActivityCards(path, fs, next, log)
195
+ }
196
+
197
+ /**
198
+ * Boot-time orphan reaper, extracted as a pure routine over injected seams
199
+ * (mirrors `runStatusPinBootCleanup`). The gateway's thin wrapper binds the
200
+ * live fs, a Telegram edit call, and the logger.
201
+ *
202
+ * CRITICAL ordering, identical constraint to the status-pin cleanup: the
203
+ * caller MUST only invoke this AFTER winning the startup mutex. The store is
204
+ * a shared per-agent file; a losing double-boot running this would finalize
205
+ * cards the still-alive holder's in-flight turn still legitimately owns.
206
+ *
207
+ * Deletes each record from disk BEFORE attempting its edit/unpin (not
208
+ * after), so a crash mid-reap, or a second boot re-reading the file, can
209
+ * never re-finalize (and thus never double-edit, never double-unpin) the
210
+ * same message. `finalizeCard` / `unpinCard` failures (message already
211
+ * deleted, chat unreachable, permissions changed, already-unpinned, etc.)
212
+ * are swallowed — non-fatal, logged, and never re-attempted since the
213
+ * record is already gone.
214
+ *
215
+ * Unpin runs AFTER the finalizing edit, only for a record with `pinned:
216
+ * true` (a v1-shape record with no `pinned` field is left un-unpinned —
217
+ * conservative degrade, not a crash), and its failure is independent of the
218
+ * edit's outcome (an edit failure — e.g. message already deleted — must not
219
+ * skip the unpin attempt, since Telegram allows unpinning a deleted
220
+ * message's id to no-op harmlessly, and conversely an unpin failure must
221
+ * never re-attempt the edit).
222
+ */
223
+ export async function runActivityCardBootReaper(args: {
224
+ path: string
225
+ fs: ActivityCardStoreFsSeam
226
+ /** Returns the Telegram result: a truthy value (the edited Message) on a
227
+ * real delivered edit, or `null`/`undefined` when the underlying
228
+ * `robustApiCall` swallowed a benign 400 (message already deleted /
229
+ * vanished / "not modified") — i.e. nothing was actually finalized. */
230
+ finalizeCard: (record: ActivityCardRecord) => Promise<unknown>
231
+ unpinCard: (record: ActivityCardRecord) => Promise<unknown>
232
+ log?: (line: string) => void
233
+ }): Promise<{ finalized: number; vanished: number; unpinned: number; total: number }> {
234
+ const log = args.log ?? ((l: string) => process.stderr.write(l))
235
+ const persisted = loadActivityCards(args.path, args.fs)
236
+ if (persisted.length === 0) return { finalized: 0, vanished: 0, unpinned: 0, total: 0 }
237
+ let finalized = 0
238
+ let vanished = 0
239
+ let unpinned = 0
240
+ for (const record of persisted) {
241
+ // Delete BEFORE attempting the edit/unpin — the idempotency guard. If the
242
+ // gateway crashes here, or a second boot re-reads the file, this record
243
+ // no longer exists on disk, so it can never be finalized or unpinned
244
+ // twice. Scoped to the exact activityMessageId (reap-race guard): a live
245
+ // turn that upserted a fresh card under the same turnKey mid-reap keeps
246
+ // its own (different-id) record.
247
+ clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
248
+ try {
249
+ // Count a finalize ONLY when the edit actually landed. robustApiCall
250
+ // resolves to undefined on a benign-400 (the card was already deleted or
251
+ // the chat is gone) — that delivered nothing, so it is a `vanished`
252
+ // orphan, not a `finalized` one. Counting it as finalized (the pre-fix
253
+ // behaviour) over-reported the guarantee in the boot log.
254
+ const res = await args.finalizeCard(record)
255
+ if (res != null) finalized++
256
+ else vanished++
257
+ } catch (err) {
258
+ log(
259
+ `activity-card-store: boot reaper finalize failed ` +
260
+ `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
261
+ `${(err as Error).message}\n`,
262
+ )
263
+ }
264
+ if (record.pinned) {
265
+ try {
266
+ await args.unpinCard(record)
267
+ unpinned++
268
+ } catch (err) {
269
+ log(
270
+ `activity-card-store: boot reaper unpin failed ` +
271
+ `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
272
+ `${(err as Error).message}\n`,
273
+ )
274
+ }
275
+ }
276
+ }
277
+ return { finalized, vanished, unpinned, total: persisted.length }
278
+ }
279
+
280
+ /**
281
+ * Mid-session periodic orphan reaper (#2918). The boot reaper above only runs
282
+ * once at startup, so a card whose owning turn's process dies MID-session (SDK
283
+ * subprocess SIGKILL / OOM / crash) without a clean finalize stays frozen on
284
+ * its last "working…" frame until the NEXT gateway boot — hours later. This
285
+ * variant runs inside the LIVE gateway on a timer and finalizes ONLY records
286
+ * that are demonstrably orphaned:
287
+ *
288
+ * - `isLive(record)` — the caller's liveness predicate (gateway:
289
+ * `currentTurnMap.byKey.has(record.turnKey)` OR the singleton `currentTurn`
290
+ * mirror owns this topic). A record whose topic is still live is NEVER
291
+ * touched, so the currently-spinning card of an in-flight turn is safe.
292
+ * - age gate — `now - startedAt >= ttlMs`, a secondary guard so a card that
293
+ * JUST opened (before its turn populated the live map) is protected.
294
+ *
295
+ * Same AT-MOST-ONCE contract as the boot reaper: each record is deleted from
296
+ * the store BEFORE its finalize/unpin is attempted, and failures are swallowed
297
+ * without retry. The two reapers share `runOneCardFinalize` semantics via the
298
+ * identical delete-first ordering — a boot reaper and a mid-session sweep can
299
+ * never double-finalize the same message (whichever deletes the record first
300
+ * wins; the other sees an empty/short list).
301
+ */
302
+ export async function runActivityCardMidSessionReaper(args: {
303
+ path: string
304
+ fs: ActivityCardStoreFsSeam
305
+ /** True IFF the record's topic is still owned by a live in-flight turn. */
306
+ isLive: (record: ActivityCardRecord) => boolean
307
+ ttlMs: number
308
+ now?: number
309
+ finalizeCard: (record: ActivityCardRecord) => Promise<unknown>
310
+ unpinCard: (record: ActivityCardRecord) => Promise<unknown>
311
+ log?: (line: string) => void
312
+ }): Promise<{ finalized: number; vanished: number; unpinned: number; total: number }> {
313
+ const log = args.log ?? ((l: string) => process.stderr.write(l))
314
+ const now = args.now ?? Date.now()
315
+ const cutoff = now - args.ttlMs
316
+ const persisted = loadActivityCards(args.path, args.fs)
317
+ // Only ownerless cards aged past the TTL. Liveness does the real work; the
318
+ // age gate only closes the just-opened-not-yet-tracked race window.
319
+ const stale = persisted.filter(
320
+ (r) => !args.isLive(r) && r.startedAt <= cutoff,
321
+ )
322
+ if (stale.length === 0) return { finalized: 0, vanished: 0, unpinned: 0, total: 0 }
323
+ let finalized = 0
324
+ let vanished = 0
325
+ let unpinned = 0
326
+ for (const record of stale) {
327
+ // Delete BEFORE the edit — the same idempotency guard the boot reaper uses.
328
+ clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
329
+ try {
330
+ const res = await args.finalizeCard(record)
331
+ if (res != null) finalized++
332
+ else vanished++
333
+ } catch (err) {
334
+ log(
335
+ `activity-card-store: mid-session reaper finalize failed ` +
336
+ `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
337
+ `${(err as Error).message}\n`,
338
+ )
339
+ }
340
+ if (record.pinned) {
341
+ try {
342
+ await args.unpinCard(record)
343
+ unpinned++
344
+ } catch (err) {
345
+ log(
346
+ `activity-card-store: mid-session reaper unpin failed ` +
347
+ `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
348
+ `${(err as Error).message}\n`,
349
+ )
350
+ }
351
+ }
352
+ }
353
+ return { finalized, vanished, unpinned, total: stale.length }
354
+ }
355
+
356
+ /**
357
+ * Honest finalize text for an orphaned card — the single edit the boot
358
+ * reaper applies. Mirrors `silentEndFallbackText`'s honesty-about-elapsed
359
+ * convention (`telegram-plugin/silent-end.ts`): degenerate/unknown durations
360
+ * omit the elapsed clause rather than printing a nonsensical "(0s)".
361
+ */
362
+ export function restartOrphanCardFinalizeText(startedAt: number): string {
363
+ const elapsedMs = Date.now() - startedAt
364
+ const elapsed =
365
+ Number.isFinite(elapsedMs) && elapsedMs > 0
366
+ ? ` (was running ${Math.round(elapsedMs / 1000)}s)`
367
+ : ''
368
+ return `⚠️ Interrupted by a gateway restart${elapsed} — this turn did not finish.`
369
+ }