switchroom 0.17.5 → 0.17.10

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 (158) hide show
  1. package/dist/agent-scheduler/index.js +39 -5
  2. package/dist/auth-broker/index.js +386 -208
  3. package/dist/cli/notion-write-pretool.mjs +36 -3
  4. package/dist/cli/switchroom.js +1185 -585
  5. package/dist/host-control/main.js +149 -15
  6. package/dist/vault/approvals/kernel-server.js +141 -56
  7. package/dist/vault/broker/server.js +143 -58
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +50 -2
  13. package/telegram-plugin/bridge/bridge.ts +80 -1
  14. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  15. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  16. package/telegram-plugin/consolidation-legibility.ts +279 -0
  17. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  18. package/telegram-plugin/dist/gateway/gateway.js +2597 -645
  19. package/telegram-plugin/dist/server.js +86 -2
  20. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  21. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  22. package/telegram-plugin/gateway/auth-command.ts +1 -1
  23. package/telegram-plugin/gateway/gateway.ts +1414 -119
  24. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  26. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  27. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  28. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  29. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  30. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  31. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  32. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  33. package/telegram-plugin/memory-legibility.ts +217 -0
  34. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  35. package/telegram-plugin/scoped-approval.ts +59 -0
  36. package/telegram-plugin/silent-end.ts +78 -0
  37. package/telegram-plugin/subagent-watcher.ts +60 -6
  38. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  40. package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
  41. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  42. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  43. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  44. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  45. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  46. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  48. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  49. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  50. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  51. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  52. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  53. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  54. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  55. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  56. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  57. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  58. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  59. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  60. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  61. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  62. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  63. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  64. package/telegram-plugin/uat/assertions.ts +88 -4
  65. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  66. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  71. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  72. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  74. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  75. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  76. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  77. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  97. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  98. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  99. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  100. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  101. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  129. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  130. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  131. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  132. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  157. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  158. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -23876,6 +23876,7 @@ function createIpcClient(options) {
23876
23876
  onInbound,
23877
23877
  onPermission,
23878
23878
  onStatus,
23879
+ onConnect,
23879
23880
  log = () => {},
23880
23881
  reconnectDelayMs = 2000,
23881
23882
  maxReconnectDelayMs = 30000,
@@ -24011,6 +24012,13 @@ function createIpcClient(options) {
24011
24012
  sendRegister();
24012
24013
  startHeartbeat();
24013
24014
  log(`connected to ${socketPath}`);
24015
+ if (onConnect) {
24016
+ try {
24017
+ onConnect();
24018
+ } catch (err) {
24019
+ log(`onConnect hook threw: ${err}`);
24020
+ }
24021
+ }
24014
24022
  resolve();
24015
24023
  },
24016
24024
  data(sock, data) {
@@ -24232,10 +24240,52 @@ var init_permission_rule = __esm(() => {
24232
24240
  ]);
24233
24241
  });
24234
24242
 
24243
+ // bridge/permission-ledger.ts
24244
+ function createOutstandingPermissionLedger() {
24245
+ const map2 = new Map;
24246
+ return {
24247
+ add(params) {
24248
+ map2.set(params.request_id, params);
24249
+ },
24250
+ delete(requestId) {
24251
+ map2.delete(requestId);
24252
+ },
24253
+ has(requestId) {
24254
+ return map2.has(requestId);
24255
+ },
24256
+ all() {
24257
+ return [...map2.values()];
24258
+ },
24259
+ get size() {
24260
+ return map2.size;
24261
+ }
24262
+ };
24263
+ }
24264
+
24235
24265
  // bridge/bridge.ts
24236
24266
  var exports_bridge = {};
24237
24267
  import { dirname as dirname3, join as join5 } from "path";
24238
24268
  import { homedir as homedir4 } from "os";
24269
+ function flushOutstandingPermissionRequests() {
24270
+ if (!permissionRearmEnabled)
24271
+ return;
24272
+ if (!ipc || !ipc.isConnected())
24273
+ return;
24274
+ const pending = outstandingPermissions.all();
24275
+ if (pending.length === 0)
24276
+ return;
24277
+ process.stderr.write(`telegram bridge: re-sending ${pending.length} outstanding permission request(s) on gateway (re)connect
24278
+ `);
24279
+ for (const p of pending) {
24280
+ ipc.sendPermissionRequest({
24281
+ type: "permission_request",
24282
+ requestId: p.request_id,
24283
+ toolName: p.tool_name,
24284
+ description: p.description,
24285
+ inputPreview: p.input_preview
24286
+ });
24287
+ }
24288
+ }
24239
24289
  function onInbound(msg) {
24240
24290
  mcp.notification({
24241
24291
  method: "notifications/claude/channel",
@@ -24252,6 +24302,7 @@ function onPermission(msg) {
24252
24302
  if (msg.rule) {
24253
24303
  sessionAllowRules.add(msg.rule);
24254
24304
  }
24305
+ outstandingPermissions.delete(msg.requestId);
24255
24306
  mcp.notification({
24256
24307
  method: "notifications/claude/channel/permission",
24257
24308
  params: {
@@ -24305,6 +24356,7 @@ async function main() {
24305
24356
  onInbound,
24306
24357
  onPermission,
24307
24358
  onStatus,
24359
+ onConnect: flushOutstandingPermissionRequests,
24308
24360
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
24309
24361
  `),
24310
24362
  livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join5(STATE_DIR, ".bridge-alive")
@@ -24318,7 +24370,7 @@ async function main() {
24318
24370
  }
24319
24371
  await mcp.connect(new StdioServerTransport);
24320
24372
  }
24321
- var STATE_DIR, SOCKET_PATH, TOPIC_ID, AGENT_NAME, mcp, TOOL_SCHEMAS, EFFECTIVE_TOOL_SCHEMAS, sessionAllowRules, ipc = null, sessionTailEnabled, sessionTailHandle = null, ptyTailEnabled, ptyTailHandle = null;
24373
+ var STATE_DIR, SOCKET_PATH, TOPIC_ID, AGENT_NAME, mcp, TOOL_SCHEMAS, EFFECTIVE_TOOL_SCHEMAS, sessionAllowRules, outstandingPermissions, permissionRearmEnabled, ipc = null, sessionTailEnabled, sessionTailHandle = null, ptyTailEnabled, ptyTailHandle = null;
24322
24374
  var init_bridge = __esm(async () => {
24323
24375
  init_server2();
24324
24376
  init_stdio2();
@@ -24681,6 +24733,23 @@ var init_bridge = __esm(async () => {
24681
24733
  required: ["chat_id", "key"]
24682
24734
  }
24683
24735
  },
24736
+ {
24737
+ name: "mental_model_propose",
24738
+ description: "Propose a Hindsight MENTAL MODEL for the operator to approve (agent-proposes \u2192 human-approves, hindsight Phase 5). Use this when \u2014 over real work \u2014 you notice a recurring, domain-specific question worth maintaining a standing, semantically-refreshed answer to from YOUR bank (e.g. a coach's `training-plan-state`, a lawyer's `open-matters`). You may PROPOSE but can NEVER self-approve: this renders a Telegram [Approve]/[Deny] card to the operator. On Approve the model is DECLARED \u2014 appended to your `memory.mental_models[]` in switchroom.yaml via the operator-approved config-edit path \u2014 and ensured in your bank (it then refreshes from your bank content). On Deny nothing is written. This is NOT for identity/'who is the user' (dedicated profile banks own that) and NOT a substitute for `retain` (store a fact) or `create_mental_model` where you already have direct Hindsight tools \u2014 it is the leashed, human-gated way to add a DURABLE declared model to your config. After firing this tool, END YOUR TURN cleanly \u2014 a fresh inbound arrives (`<channel source=\"mental_model_proposal_applied\">` / `mental_model_proposal_denied`) once the operator decides. Do NOT propose a model whose name is already declared (it is rejected), and do NOT spam (one card per proposal; the operator sees every one).",
24739
+ inputSchema: {
24740
+ type: "object",
24741
+ properties: {
24742
+ chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
24743
+ name: { type: "string", description: "Stable model name \u2014 the idempotent-ensure identity key (lowercase kebab/snake, e.g. `training-plan-state`). Must be UNIQUE among your already-declared models or the proposal is rejected." },
24744
+ source_query: { type: "string", description: `The reflection query the model answers, semantically refreshed from your bank content (e.g. "What is the athlete's current training plan, recent sessions, and open adjustments?"). Frame it as a DOMAIN question, never an identity question.` },
24745
+ reason: { type: "string", description: 'REQUIRED in practice \u2014 one-line rationale rendered on the card (e.g. "I keep re-deriving the plan state every session; a standing model would save the lookup"). Omitting it renders "why: not provided" and the operator will usually Deny.' },
24746
+ refresh_after_consolidation: { type: "boolean", description: "Refresh this model after each consolidation. Defaults OFF \u2014 refresh adds bounded background model-spend + timeout risk (RFC Phase 5). Only set true when the model genuinely needs to track fast-moving state." },
24747
+ max_tokens: { type: "number", description: "Optional cap on the synthesized model's token size." },
24748
+ message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
24749
+ },
24750
+ required: ["chat_id", "name", "source_query"]
24751
+ }
24752
+ },
24684
24753
  {
24685
24754
  name: "linear_agent_activity",
24686
24755
  description: 'Emit a structured Linear AgentActivity against an agent session (#2298). Use this ONLY inside a turn that was woken by a Linear agent session (the inbound carries meta.source="linear" and meta.agent_session_id) \u2014 pass that agent_session_id back here. Linear renders activities as status chips + a timeline on the issue, so the human sees acknowledge \u2192 work \u2192 result. Emit a `thought` within ~10s of being woken so the session does not look dead, then `message`(s) as you make progress, and finally exactly one terminal `complete` (work done) or `error` (you could not proceed). body is required for thought/message/error and optional for complete. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns an error instructing you to vault_request_access for `linear/<agent>/token`.',
@@ -24804,6 +24873,8 @@ var init_bridge = __esm(async () => {
24804
24873
  }
24805
24874
  });
24806
24875
  sessionAllowRules = new Set;
24876
+ outstandingPermissions = createOutstandingPermissionLedger();
24877
+ permissionRearmEnabled = process.env.SWITCHROOM_PERMISSION_REARM !== "0";
24807
24878
  mcp.setNotificationHandler(exports_external.object({
24808
24879
  method: exports_external.literal("notifications/claude/channel/permission_request"),
24809
24880
  params: exports_external.object({
@@ -24825,9 +24896,22 @@ var init_bridge = __esm(async () => {
24825
24896
  return;
24826
24897
  }
24827
24898
  }
24899
+ if (permissionRearmEnabled) {
24900
+ outstandingPermissions.add({
24901
+ request_id: params.request_id,
24902
+ tool_name: params.tool_name,
24903
+ description: params.description,
24904
+ input_preview: params.input_preview
24905
+ });
24906
+ }
24828
24907
  if (!ipc || !ipc.isConnected()) {
24829
- process.stderr.write(`telegram bridge: permission_request received but not connected to gateway
24908
+ if (permissionRearmEnabled) {
24909
+ process.stderr.write(`telegram bridge: permission_request buffered (gateway offline), will re-send on reconnect ` + `request_id=${params.request_id}
24910
+ `);
24911
+ } else {
24912
+ process.stderr.write(`telegram bridge: permission_request received but not connected to gateway
24830
24913
  `);
24914
+ }
24831
24915
  return;
24832
24916
  }
24833
24917
  ipc.sendPermissionRequest({
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Deterministic silent-turn card climb — the framework owns "alive" and "for how
3
+ * long"; the model owns "what".
4
+ *
5
+ * Implements Phase 1 (the keystone) of `reference/rfcs/deterministic-turn-liveness.md`.
6
+ * Serves `reference/jobs/know-what-my-agent-is-doing.md` (outcome: at any moment
7
+ * during a turn the user can see the agent is alive and how long it has been
8
+ * working, without asking).
9
+ *
10
+ * ## The bug this closes
11
+ *
12
+ * On the 0-label path of `feedHeartbeatTick` (a turn that has surfaced NO tool
13
+ * label — a long single `Bash`, or only suppressed-by-design tools), the tick
14
+ * delegated to `openLivenessFeedIfDue`, whose WHEN-gate `shouldEarlyOpenLiveness`
15
+ * returns `false` the moment a card is already open (`activityMessageId != null`).
16
+ * Result: once the "Working…" card opened, the 0-label branch NEVER edited it
17
+ * again during a silent tool — the card froze on its opening elapsed while the
18
+ * agent was busy for tens of seconds. The only remaining signal was the ambient
19
+ * "stuck" reaction flip, which reads as *done*, not *working*.
20
+ *
21
+ * ## The fix
22
+ *
23
+ * Give the 0-label branch the SAME climbing-elapsed EDIT the labelled branch
24
+ * already performs: when a card is open and `mirrorLines` is empty, re-render the
25
+ * minimal "Working…" card with a fresh wall-clock elapsed on every heartbeat tick.
26
+ * The render is derived PURELY from `ageMs` (`now - turn.startedAt`), so a tool
27
+ * call blocked mid-flight — emitting nothing — cannot starve it. Worst-case gap
28
+ * between visible edits is one to two `FEED_HEARTBEAT_MIN_STALE_MS` windows
29
+ * (~6-12s), versus the observed ~50s freeze.
30
+ *
31
+ * This is a card EDIT, not a text send: it edits the card the chat already owns,
32
+ * and Telegram card edits do not push-notify — squarely the Ambient layer of
33
+ * `reference/rfcs/conversational-pacing.md`, crossing no invariant (no parallel
34
+ * surface, no mid-turn device buzz). When the model DOES narrate, its words land
35
+ * on the same card chronologically (`mirrorLines` becomes non-empty and the
36
+ * labelled-feed heartbeat takes over) — the climb fills only the gaps the model
37
+ * leaves and never overwrites narration.
38
+ *
39
+ * Kept as a small pure function so the guarantee can be proven at the transport
40
+ * boundary (Phase 4b regression test) rather than by grepping the gateway source.
41
+ */
42
+ import {
43
+ renderActivityFeedWithNested,
44
+ formatStepSuffix,
45
+ type SessionActivityHeader,
46
+ } from './tool-activity-summary.js'
47
+
48
+ /** The minimal placeholder line the silent-turn card carries when no tool label
49
+ * has surfaced. Matches `openLivenessFeedIfDue`'s open render so the climb edit
50
+ * is a clean continuation of the placeholder the OPEN path produced. */
51
+ export const SILENT_TURN_PLACEHOLDER = 'Working…'
52
+
53
+ export interface SilentTurnClimbInput {
54
+ /** `turn.mirrorLines.length`. 0 ⇒ no tool label has surfaced this turn (pure
55
+ * thinking / only suppressed tools) — the case this climb owns. >0 ⇒ a real
56
+ * label drives the labelled-feed heartbeat, which owns the climb instead. */
57
+ mirrorLineCount: number
58
+ /** `turn.activityMessageId`. `null` ⇒ no card is open yet, so the OPEN path
59
+ * (`openLivenessFeedIfDue`) owns this tick; non-null ⇒ a card is open and this
60
+ * branch must keep it climbing (the exact freeze this fix closes). */
61
+ activityMessageId: number | null
62
+ /** `turn.labeledToolCount` — surfaced in the card header's tool count. */
63
+ labeledToolCount: number
64
+ /** `now - turn.startedAt`: wall-clock elapsed since turn start. The single
65
+ * signal the model can never supply while blocked inside a tool. */
66
+ ageMs: number
67
+ }
68
+
69
+ /**
70
+ * The deterministic 0-label climb render. Returns the card body HTML to EDIT the
71
+ * already-open "Working…" card with a climbing wall-clock elapsed, or `null` when
72
+ * this branch must NOT edit:
73
+ * - `mirrorLineCount > 0` — a tool label surfaced; the labelled-feed heartbeat
74
+ * owns the climb (its edit cleanly replaces this placeholder).
75
+ * - `activityMessageId == null` — no card is open yet; the OPEN path
76
+ * (`openLivenessFeedIfDue`) owns this tick.
77
+ *
78
+ * The header's `elapsedMs` carries the climbing clock, so each tick's edit shows
79
+ * a higher "Working · Ns" than the last. Model-independent by construction: it
80
+ * reads only wall-clock `ageMs`.
81
+ */
82
+ export function silentTurnClimbRender(input: SilentTurnClimbInput): string | null {
83
+ if (input.mirrorLineCount > 0) return null
84
+ if (input.activityMessageId == null) return null
85
+ const header: SessionActivityHeader = {
86
+ label: 'Agent',
87
+ elapsedMs: input.ageMs,
88
+ toolCount: input.labeledToolCount,
89
+ state: 'running',
90
+ }
91
+ // Single "step" whose start is the turn start, so `ageMs` IS the step's own
92
+ // elapsed. formatStepSuffix keeps the `→` line timer-free until STEP_TIMER_MIN_MS;
93
+ // the header total still climbs every tick — that is the visible clock.
94
+ return renderActivityFeedWithNested(
95
+ [SILENT_TURN_PLACEHOLDER],
96
+ [],
97
+ false,
98
+ formatStepSuffix(input.ageMs),
99
+ undefined,
100
+ header,
101
+ )
102
+ }
103
+
104
+ /**
105
+ * The per-turn view `runSilentTurnHeartbeatTick` reads — a structural subset of
106
+ * the gateway's `CurrentTurn`, kept transport-free so this module (and its test)
107
+ * never imports the gateway.
108
+ */
109
+ export interface SilentTurnTickView {
110
+ /** `turn.mirrorLines.length` at tick time. */
111
+ mirrorLineCount: number
112
+ /** `turn.activityMessageId` at tick time (null ⇒ no card open yet). */
113
+ activityMessageId: number | null
114
+ /** `turn.labeledToolCount` — surfaced in the card header. */
115
+ labeledToolCount: number
116
+ /** `now - turn.startedAt` at tick time. */
117
+ ageMs: number
118
+ /** The labelled branch's stale floor (`FEED_HEARTBEAT_MIN_STALE_MS`). The
119
+ * climb applies the SAME guard for symmetry: a turn younger than one stale
120
+ * window is fresh (its card just opened ~1.2s in), so no climb edit yet.
121
+ * Without this the climb relied on the tick interval == the stale window
122
+ * (both 6s today) — an implicit coupling this guard makes explicit. */
123
+ minStaleMs: number
124
+ }
125
+
126
+ /**
127
+ * The gateway seams the tick drives, injected as thunks so THIS function — the
128
+ * shipped 0-label branch of `feedHeartbeatTick` — is directly executable in a
129
+ * test with spies at each seam. The gateway wires the real implementations
130
+ * (`openLivenessFeedIfDue`, `turn.activityPendingRender = …`, `cardDrainGate`,
131
+ * `ea.mayDrain`, `ea.openOrEditCard('liveness', …)`,
132
+ * `drainActivitySummary(turn, 'liveness')`); the wiring is pinned structurally
133
+ * in tests/feed-heartbeat-liveness-open.test.ts.
134
+ */
135
+ export interface SilentTurnTickDeps {
136
+ /** The one shared OPEN path (`openLivenessFeedIfDue(turn)`). Owns the open;
137
+ * the climb never opens (reply-is-last gating lives on the open path). */
138
+ openLivenessFeedIfDue: () => void
139
+ /** Stage the climb render (`turn.activityPendingRender = rendered`). */
140
+ setPendingRender: (rendered: string) => void
141
+ /** The centralized chatLock-serialized card-drain gate
142
+ * (`cardDrainGate(turn, ea, run)`). */
143
+ cardDrainGate: (run: () => void) => void
144
+ /** The single-flight pure read (`ea.mayDrain(turn)`). */
145
+ mayDrain: () => boolean
146
+ /** The emission-authority façade routing (`ea.openOrEditCard('liveness', apply)`).
147
+ * With a card open this only ever EDITs — Telegram edits don't push-notify. */
148
+ openOrEditCard: (apply: () => void) => void
149
+ /** The drain kick-off (`turn.activityInFlight = drainActivitySummary(turn,
150
+ * 'liveness')`) — the transport writer (sendMessage / editMessageText via
151
+ * robustApiCall) lives behind this seam. */
152
+ drain: () => void
153
+ }
154
+
155
+ /**
156
+ * The REAL 0-label branch of `feedHeartbeatTick` (deterministic-turn-liveness.md
157
+ * Phase 1) — extracted here so the shipped decision logic is directly under
158
+ * outcome-based test (`tests/silent-turn-climb-transport.test.ts`); the gateway
159
+ * IIFE cannot be imported in-process, so the tick body lives in this module and
160
+ * the gateway calls it with its real deps.
161
+ *
162
+ * Returns true when the tick was handled by the 0-label path (the caller must
163
+ * stop — the labelled-feed heartbeat does not run), false when a tool label has
164
+ * surfaced (`mirrorLineCount > 0`) and the labelled branch owns the tick.
165
+ *
166
+ * Behaviour:
167
+ * - no card open yet → delegate to the one shared OPEN path (never opens here);
168
+ * - card open + turn younger than `minStaleMs` → fresh, no edit yet (the same
169
+ * stale-guard the labelled branch applies to its step elapsed);
170
+ * - card open + stale → stage the climbing "Working · Ns" render and EDIT it
171
+ * through the same cardDrainGate → mayDrain → openOrEditCard → drain gate
172
+ * sequence the labelled branch uses. Deterministic + framework-owned: reads
173
+ * only wall-clock `ageMs`, so a blocked tool call cannot starve it.
174
+ */
175
+ export function runSilentTurnHeartbeatTick(
176
+ view: SilentTurnTickView,
177
+ deps: SilentTurnTickDeps,
178
+ ): boolean {
179
+ if (view.mirrorLineCount !== 0) return false
180
+ if (view.activityMessageId == null) {
181
+ deps.openLivenessFeedIfDue()
182
+ return true
183
+ }
184
+ // Stale-guard (symmetry with the labelled branch's `elapsed <
185
+ // FEED_HEARTBEAT_MIN_STALE_MS` check): the 0-label "step" starts at turn
186
+ // start, so `ageMs` IS its elapsed. A younger turn's card just opened.
187
+ if (view.ageMs < view.minStaleMs) return true
188
+ const rendered = silentTurnClimbRender({
189
+ mirrorLineCount: view.mirrorLineCount,
190
+ activityMessageId: view.activityMessageId,
191
+ labeledToolCount: view.labeledToolCount,
192
+ ageMs: view.ageMs,
193
+ })
194
+ if (rendered == null) return true
195
+ deps.setPendingRender(rendered)
196
+ deps.cardDrainGate(() => {
197
+ if (deps.mayDrain()) {
198
+ // Maintains an already-open card (guarded above on activityMessageId !=
199
+ // null) → only ever EDITs. 'liveness' producer is correct.
200
+ deps.openOrEditCard(() => {
201
+ deps.drain()
202
+ })
203
+ }
204
+ })
205
+ return true
206
+ }
@@ -0,0 +1,293 @@
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
+ * Honest finalize text for an orphaned card — the single edit the boot
282
+ * reaper applies. Mirrors `silentEndFallbackText`'s honesty-about-elapsed
283
+ * convention (`telegram-plugin/silent-end.ts`): degenerate/unknown durations
284
+ * omit the elapsed clause rather than printing a nonsensical "(0s)".
285
+ */
286
+ export function restartOrphanCardFinalizeText(startedAt: number): string {
287
+ const elapsedMs = Date.now() - startedAt
288
+ const elapsed =
289
+ Number.isFinite(elapsedMs) && elapsedMs > 0
290
+ ? ` (was running ${Math.round(elapsedMs / 1000)}s)`
291
+ : ''
292
+ return `⚠️ Interrupted by a gateway restart${elapsed} — this turn did not finish.`
293
+ }
@@ -403,7 +403,7 @@ export async function handleAuthCommand(
403
403
  let keyboard: AuthCommandReply['keyboard']
404
404
  if (liveQuotas && liveQuotas.length === state.accounts.length) {
405
405
  const snapshots = buildSnapshotsFromState(state, liveQuotas)
406
- keyboard = buildSnapshotKeyboard(snapshots, { now: new Date() })
406
+ keyboard = buildSnapshotKeyboard(snapshots, { now: new Date(), demo: ctx.demo })
407
407
  }
408
408
  return {
409
409
  text: renderShowText(state, Date.now(), {