switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -17160,6 +17160,21 @@ function createToolLabelSidecar(opts) {
17160
17160
  }
17161
17161
  var init_tool_label_sidecar = () => {};
17162
17162
 
17163
+ // model-label.ts
17164
+ function isModelSentinel(model) {
17165
+ if (typeof model !== "string")
17166
+ return true;
17167
+ const m = model.trim();
17168
+ if (m.length === 0)
17169
+ return true;
17170
+ if (m.startsWith("<"))
17171
+ return true;
17172
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m))
17173
+ return true;
17174
+ return false;
17175
+ }
17176
+ var init_model_label = () => {};
17177
+
17163
17178
  // session-tail.ts
17164
17179
  import {
17165
17180
  closeSync,
@@ -17300,6 +17315,10 @@ function projectTranscriptLine(line) {
17300
17315
  if (!Array.isArray(content))
17301
17316
  return [];
17302
17317
  const events = [];
17318
+ const mainModel = message?.model;
17319
+ if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
17320
+ events.push({ kind: "model", model: mainModel });
17321
+ }
17303
17322
  const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({ kind: "text", text, blockIndex, lastInMessage }));
17304
17323
  content.forEach((c, i) => {
17305
17324
  const ct = c.type;
@@ -17402,6 +17421,10 @@ function projectSubagentLine(line, agentId, state) {
17402
17421
  if (!Array.isArray(content))
17403
17422
  return [];
17404
17423
  const events = [];
17424
+ const subModel = message?.model;
17425
+ if (typeof subModel === "string" && !isModelSentinel(subModel)) {
17426
+ events.push({ kind: "sub_agent_model", agentId, model: subModel });
17427
+ }
17405
17428
  const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({
17406
17429
  kind: "sub_agent_text",
17407
17430
  agentId,
@@ -17903,6 +17926,7 @@ var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500;
17903
17926
  var init_session_tail = __esm(() => {
17904
17927
  init_operator_events();
17905
17928
  init_tool_label_sidecar();
17929
+ init_model_label();
17906
17930
  MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024;
17907
17931
  });
17908
17932
 
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Durable retry queue for the "🔁 Always allow" durable-persist flow
3
+ * (#1977, hardened in #2973).
4
+ *
5
+ * Problem (#2973): a `config_propose_edit` dispatch for an always-allow
6
+ * rule can fail for RETRYABLE reasons (a transient hostd error, a stale
7
+ * container-side config view producing `E_PATCH_APPLY_FAILED`, a rate
8
+ * limit with a structured `retry_after`) — today that failure is simply
9
+ * reported to the operator ("did NOT save") and the tap is lost; the rule
10
+ * only lands if the operator notices and re-taps after the next restart.
11
+ *
12
+ * Fix: on a retryable dispatch failure, enqueue `{agent, rule, ...}` here
13
+ * BEFORE giving up. The gateway drains this queue at boot (so a restart
14
+ * mid-persist doesn't lose the entry) and on a periodic timer. Each drain
15
+ * pass:
16
+ * 1. re-checks `isRulePersisted` against a FRESH config read first — if
17
+ * the rule already landed (e.g. the original attempt actually
18
+ * succeeded server-side but the ack was lost), dequeue as a no-op;
19
+ * 2. otherwise re-synthesizes the diff from that fresh read (fixes the
20
+ * stale-view failure class) and redispatches;
21
+ * 3. on success, dequeues; on failure, backs off exponentially (honoring
22
+ * a structured `retryAfterMs` when the failure supplies one) and
23
+ * re-tries later;
24
+ * 4. once `attempts` reaches {@link MAX_ATTEMPTS} or the entry has aged
25
+ * past {@link MAX_AGE_MS}, the entry is dropped and the caller is
26
+ * told to post a LOUD terminal-failure notice (a new message, not a
27
+ * card edit — card edits don't ping).
28
+ *
29
+ * HARD RULE (standing fleet rule, restated in #2973): nothing here may
30
+ * retry indefinitely. Bounds are enforced structurally, not just by
31
+ * convention — see {@link MAX_ATTEMPTS}, {@link MAX_AGE_MS}, and
32
+ * {@link MAX_QUEUE_SIZE} below, and the tests that pin them.
33
+ *
34
+ * File format mirrors `missed-approvals-store.ts`: a single bounded JSON
35
+ * array, written synchronously, mode 0o600.
36
+ *
37
+ * Failure semantics (hardened post-#2973 adversarial review): a failed
38
+ * READ degrades to an empty list — a corrupt/missing queue file is not
39
+ * fatal, it just means "nothing queued yet". A failed WRITE is a
40
+ * different story: silently swallowing it would mean `enqueue()` tells
41
+ * its caller "queued for retry" when nothing was actually persisted to
42
+ * disk, and a concurrent `recordAttempt()`/`remove()` would silently
43
+ * fail to advance the queue's on-disk state. So `write()` PROPAGATES
44
+ * (throws) on failure, and `enqueue()` / `recordAttempt()` / `remove()`
45
+ * let that throw reach their own caller rather than pretending the
46
+ * mutation landed. `drainAlwaysAllowPersistQueue` below catches around
47
+ * each entry so one entry's write failure can't take down the rest of
48
+ * the drain pass; callers in the gateway (the interactive "Always
49
+ * allow" tap path) are responsible for catching enqueue() failures and
50
+ * reflecting them in the operator-facing message rather than claiming
51
+ * success.
52
+ */
53
+
54
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
55
+ import { join } from 'node:path'
56
+
57
+ /** Hard cap on retry attempts per entry — never retry indefinitely. */
58
+ export const MAX_ATTEMPTS = 5
59
+
60
+ /** Hard cap on how long an entry may live in the queue before being
61
+ * dropped as terminally failed, regardless of attempts made. */
62
+ export const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24h
63
+
64
+ /** Bound the on-disk file even under a pathological failure storm. */
65
+ export const MAX_QUEUE_SIZE = 50
66
+
67
+ /** Base backoff for attempt 1; doubles each subsequent attempt (capped). */
68
+ export const BASE_BACKOFF_MS = 60_000 // 1 minute
69
+
70
+ /** Ceiling on the computed backoff, independent of attempt count. */
71
+ export const MAX_BACKOFF_MS = 30 * 60_000 // 30 minutes
72
+
73
+ export interface AlwaysAllowPersistEntry {
74
+ /** Dedup key — one live queue entry per (agent, rule) pair. */
75
+ id: string
76
+ agentName: string
77
+ rule: string
78
+ /** Human-readable grant description, for the terminal-failure notice. */
79
+ grantPhrase: string
80
+ /** Origin chat/thread, so a terminal-failure notice can be targeted
81
+ * (falls back to the operator broadcast allowlist if absent). */
82
+ chatId?: string
83
+ threadId?: number | null
84
+ attempts: number
85
+ createdAt: number
86
+ nextAttemptAt: number
87
+ lastError?: string
88
+ }
89
+
90
+ interface FileShape {
91
+ entries: AlwaysAllowPersistEntry[]
92
+ }
93
+
94
+ export interface AlwaysAllowPersistQueue {
95
+ /** Enqueue (or refresh) a failed persist for retry. Idempotent on
96
+ * `(agentName, rule)` — an existing entry is updated in place rather
97
+ * than duplicated. Serialized (via an in-process lock) relative to
98
+ * every other read-modify-write on the queue file, and REJECTS if the
99
+ * underlying write fails — callers must not treat a settled promise
100
+ * as "persisted" without checking for rejection. */
101
+ enqueue(entry: {
102
+ agentName: string
103
+ rule: string
104
+ grantPhrase: string
105
+ chatId?: string
106
+ threadId?: number | null
107
+ error?: string
108
+ }): Promise<void>
109
+ /** All entries currently due for a retry attempt (nextAttemptAt <= now),
110
+ * oldest first. Does NOT filter by attempts/age — callers should apply
111
+ * {@link isExhausted} to decide terminal-failure vs. retry. */
112
+ listDue(now?: number): AlwaysAllowPersistEntry[]
113
+ /** Every entry in the queue, regardless of due-ness. */
114
+ listAll(): AlwaysAllowPersistEntry[]
115
+ /** Record the outcome of a retry attempt for `id`. Serialized relative
116
+ * to `enqueue`/`remove`/other `recordAttempt` calls on the same queue
117
+ * file — never races a concurrent mutation into a lost update.
118
+ * - `success: true` → dequeue.
119
+ * - `success: false` → increment attempts, compute the next backoff
120
+ * (honoring `retryAfterMs` when supplied), and re-persist — UNLESS
121
+ * the entry is now exhausted (see {@link isExhausted}), in which case
122
+ * it is dropped and the caller should have already emitted the
123
+ * terminal-failure notice.
124
+ * REJECTS if the underlying write fails — the caller must not assume
125
+ * the state change was durably persisted just because this returned.
126
+ */
127
+ recordAttempt(
128
+ id: string,
129
+ outcome: { success: true } | { success: false; error: string; retryAfterMs?: number },
130
+ ): Promise<void>
131
+ /** Remove an entry outright (e.g. after emitting its terminal notice).
132
+ * Serialized like the other mutators; REJECTS if the write fails. */
133
+ remove(id: string): Promise<void>
134
+ /** Delete the backing file entirely. */
135
+ clear(): void
136
+ }
137
+
138
+ /** Stable id for a (agent, rule) pair — one live retry entry per pair. */
139
+ export function makeEntryId(agentName: string, rule: string): string {
140
+ return `${agentName}::${rule}`
141
+ }
142
+
143
+ /** True when `entry` has exhausted its retry budget — attempts maxed OR
144
+ * aged out — and should be dropped with a terminal-failure notice rather
145
+ * than retried again. Pure so callers/tests can reason about the bound
146
+ * without needing the store. */
147
+ export function isExhausted(entry: Pick<AlwaysAllowPersistEntry, 'attempts' | 'createdAt'>, now = Date.now()): boolean {
148
+ return entry.attempts >= MAX_ATTEMPTS || now - entry.createdAt >= MAX_AGE_MS
149
+ }
150
+
151
+ /**
152
+ * Exponential backoff with a hard ceiling, honoring a structured
153
+ * `retry_after` hint when the failure supplied one (never retries SOONER
154
+ * than that hint, but still respects {@link MAX_BACKOFF_MS}).
155
+ */
156
+ export function computeBackoffMs(attempts: number, retryAfterMs?: number): number {
157
+ const exp = Math.min(BASE_BACKOFF_MS * 2 ** Math.max(0, attempts - 1), MAX_BACKOFF_MS)
158
+ if (retryAfterMs != null && retryAfterMs > 0) {
159
+ return Math.min(Math.max(exp, retryAfterMs), MAX_BACKOFF_MS)
160
+ }
161
+ return exp
162
+ }
163
+
164
+ export function createAlwaysAllowPersistQueue(
165
+ stateDir: string,
166
+ /** Injectable for tests to force a write failure (disk full / permissions /
167
+ * read-only fs) without real filesystem faults — we run as root in CI/
168
+ * containers, so chmod-based permission tricks don't reliably fail, and
169
+ * bun's test runner doesn't support mocking node:fs built-ins. */
170
+ writeFileSyncFn: typeof writeFileSync = writeFileSync,
171
+ ): AlwaysAllowPersistQueue {
172
+ const filePath = join(stateDir, 'always-allow-persist-queue.json')
173
+
174
+ // ── In-process mutex ──────────────────────────────────────────────────
175
+ // enqueue / recordAttempt / remove are each a read-modify-write cycle
176
+ // against the SAME file. Node is single-threaded, but these operations
177
+ // are exposed as async (their callers `await` a hostd round trip, a
178
+ // drain pass, etc.), so two calls CAN interleave across a microtask/
179
+ // macrotask boundary — a naive "read, mutate, write" per call is a
180
+ // classic lost-update race (#2973 pt.3): call A reads, call B reads the
181
+ // same pre-A-write snapshot, B writes, A writes — B's update vanishes.
182
+ // Fix: every read-modify-write against `filePath` is queued through this
183
+ // single promise chain, so at most one is ever in flight at a time,
184
+ // regardless of how many callers invoke enqueue/recordAttempt/remove
185
+ // "concurrently".
186
+ let lock: Promise<unknown> = Promise.resolve()
187
+ function withLock<T>(fn: () => T): Promise<T> {
188
+ const result = lock.then(fn, fn) // run fn even if the previous link rejected
189
+ // Swallow the rejection on the CHAIN (not on `result`) so one failed
190
+ // mutation doesn't wedge the lock for everything queued after it.
191
+ lock = result.catch(() => {})
192
+ return result
193
+ }
194
+
195
+ function read(): FileShape {
196
+ try {
197
+ const raw = readFileSync(filePath, 'utf-8')
198
+ const parsed = JSON.parse(raw) as Partial<FileShape>
199
+ return { entries: Array.isArray(parsed?.entries) ? parsed.entries : [] }
200
+ } catch {
201
+ return { entries: [] }
202
+ }
203
+ }
204
+
205
+ /** Unlike `read()`, a write failure is NOT swallowed — it propagates so
206
+ * the caller (enqueue/recordAttempt/remove) finds out its mutation did
207
+ * not actually land on disk (disk full, permissions, etc.) instead of
208
+ * silently proceeding as if it had. */
209
+ function write(f: FileShape): void {
210
+ writeFileSyncFn(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
211
+ }
212
+
213
+ return {
214
+ enqueue(args) {
215
+ return withLock(() => {
216
+ const id = makeEntryId(args.agentName, args.rule)
217
+ const f = read()
218
+ const now = Date.now()
219
+ const idx = f.entries.findIndex(e => e.id === id)
220
+ if (idx >= 0) {
221
+ // Already queued (e.g. a second tap failed the same way) — keep
222
+ // its attempt history/createdAt (age bound is from FIRST failure),
223
+ // just refresh the description fields and last error.
224
+ f.entries[idx] = {
225
+ ...f.entries[idx]!,
226
+ grantPhrase: args.grantPhrase,
227
+ chatId: args.chatId,
228
+ threadId: args.threadId,
229
+ lastError: args.error,
230
+ }
231
+ } else {
232
+ if (f.entries.length >= MAX_QUEUE_SIZE) {
233
+ // Bounded — drop the oldest to make room rather than growing
234
+ // without limit under a failure storm.
235
+ f.entries.sort((a, b) => a.createdAt - b.createdAt)
236
+ f.entries = f.entries.slice(f.entries.length - MAX_QUEUE_SIZE + 1)
237
+ }
238
+ f.entries.push({
239
+ id,
240
+ agentName: args.agentName,
241
+ rule: args.rule,
242
+ grantPhrase: args.grantPhrase,
243
+ chatId: args.chatId,
244
+ threadId: args.threadId,
245
+ attempts: 1,
246
+ createdAt: now,
247
+ nextAttemptAt: now + computeBackoffMs(1),
248
+ lastError: args.error,
249
+ })
250
+ }
251
+ write(f)
252
+ })
253
+ },
254
+
255
+ listDue(now = Date.now()) {
256
+ return read()
257
+ .entries.filter(e => e.nextAttemptAt <= now)
258
+ .sort((a, b) => a.nextAttemptAt - b.nextAttemptAt)
259
+ },
260
+
261
+ listAll() {
262
+ return read().entries
263
+ },
264
+
265
+ recordAttempt(id, outcome) {
266
+ return withLock(() => {
267
+ const f = read()
268
+ const idx = f.entries.findIndex(e => e.id === id)
269
+ if (idx === -1) return
270
+ if (outcome.success) {
271
+ f.entries.splice(idx, 1)
272
+ write(f)
273
+ return
274
+ }
275
+ const entry = f.entries[idx]!
276
+ const attempts = entry.attempts + 1
277
+ const updated: AlwaysAllowPersistEntry = {
278
+ ...entry,
279
+ attempts,
280
+ lastError: outcome.error,
281
+ nextAttemptAt: Date.now() + computeBackoffMs(attempts, outcome.retryAfterMs),
282
+ }
283
+ if (isExhausted(updated)) {
284
+ // Exhausted — drop it. The caller is responsible for having
285
+ // emitted (or being about to emit) the terminal-failure notice;
286
+ // this store only owns the bounded-retry bookkeeping.
287
+ f.entries.splice(idx, 1)
288
+ } else {
289
+ f.entries[idx] = updated
290
+ }
291
+ write(f)
292
+ })
293
+ },
294
+
295
+ remove(id) {
296
+ return withLock(() => {
297
+ const f = read()
298
+ const filtered = f.entries.filter(e => e.id !== id)
299
+ if (filtered.length !== f.entries.length) {
300
+ f.entries = filtered
301
+ write(f)
302
+ }
303
+ })
304
+ },
305
+
306
+ clear() {
307
+ try {
308
+ unlinkSync(filePath)
309
+ } catch {
310
+ // File may not exist — fine.
311
+ }
312
+ },
313
+ }
314
+ }
315
+
316
+ // ─── Drain ──────────────────────────────────────────────────────────────────
317
+
318
+ /** Injected dependencies for {@link drainAlwaysAllowPersistQueue} — kept
319
+ * abstract so the drain loop is unit-testable without gateway/network/hostd
320
+ * deps. The gateway wires the real implementations at call sites. */
321
+ export interface AlwaysAllowDrainDeps {
322
+ /** Read the CURRENT (fresh, not cached) config file text. Throwing here
323
+ * is treated as a retryable failure for the entry being processed. */
324
+ readConfigText(): string
325
+ /** Resolve the agent's current, fully-merged `tools.allow` list from the
326
+ * freshly-read config — used to no-op a retry whose rule already
327
+ * landed (e.g. the original dispatch actually succeeded). */
328
+ resolveAllowList(configText: string, agentName: string): string[]
329
+ isRulePersisted(allowList: string[], rule: string): boolean
330
+ /** Re-synthesize the diff from the fresh config text. Returns null if
331
+ * the agent block can no longer be located (treated as retryable —
332
+ * config may be mid-edit). */
333
+ synthesizeDiff(agentName: string, rule: string, configText: string): string | null
334
+ /** Dispatch the (re-synthesized) diff for durable persistence. */
335
+ dispatchConfigEdit(
336
+ entry: AlwaysAllowPersistEntry,
337
+ unifiedDiff: string,
338
+ ): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }>
339
+ /** Called once for an entry that has exhausted its retry budget — the
340
+ * "loud terminal failure" requirement (#2973 pt.3). Must not throw. */
341
+ notifyTerminalFailure(entry: AlwaysAllowPersistEntry, reason: string): void
342
+ /** Optional logger sink (defaults to a no-op) — kept separate from
343
+ * stderr writes so tests can assert on it without capturing stdio. */
344
+ log?(line: string): void
345
+ }
346
+
347
+ /**
348
+ * Process every currently-due entry once. Safe to call at gateway boot
349
+ * (drains anything left over from a mid-persist restart) and on a
350
+ * periodic timer (~10 min in the gateway). Each entry is handled
351
+ * independently — one entry's dispatch throwing never blocks its
352
+ * siblings, and this function itself never throws.
353
+ */
354
+ export async function drainAlwaysAllowPersistQueue(
355
+ queue: AlwaysAllowPersistQueue,
356
+ deps: AlwaysAllowDrainDeps,
357
+ ): Promise<void> {
358
+ const log = deps.log ?? (() => {})
359
+ const due = queue.listDue()
360
+ for (const entry of due) {
361
+ try {
362
+ let configText: string
363
+ try {
364
+ configText = deps.readConfigText()
365
+ } catch (err) {
366
+ recordFailure(entry, `config read failed: ${(err as Error).message}`)
367
+ continue
368
+ }
369
+
370
+ // Re-check FIRST: if the rule already landed (original attempt
371
+ // actually succeeded, or a concurrent tap persisted it), this is a
372
+ // no-op dequeue — never re-append and risk a duplicate rule.
373
+ const allowList = deps.resolveAllowList(configText, entry.agentName)
374
+ if (deps.isRulePersisted(allowList, entry.rule)) {
375
+ log(
376
+ `always-allow-persist-queue: rule already persisted, no-op dequeue agent=${entry.agentName} rule=${entry.rule}\n`,
377
+ )
378
+ await recordAttemptSafely(entry, { success: true })
379
+ continue
380
+ }
381
+
382
+ const diff = deps.synthesizeDiff(entry.agentName, entry.rule, configText)
383
+ if (diff == null) {
384
+ await recordFailure(entry, 'could not re-synthesize diff from fresh config read')
385
+ continue
386
+ }
387
+
388
+ const result = await deps.dispatchConfigEdit(entry, diff)
389
+ if (result.ok) {
390
+ log(
391
+ `always-allow-persist-queue: retry succeeded agent=${entry.agentName} rule=${entry.rule} attempts=${entry.attempts}\n`,
392
+ )
393
+ await recordAttemptSafely(entry, { success: true })
394
+ } else {
395
+ await recordFailure(entry, result.error, result.retryAfterMs)
396
+ }
397
+ } catch (err) {
398
+ // Never let one entry's unexpected throw take down the drain pass.
399
+ await recordFailure(entry, `unexpected error: ${(err as Error).message}`)
400
+ }
401
+ }
402
+
403
+ /** `queue.recordAttempt` now REJECTS on a write failure (disk full,
404
+ * permissions, …) rather than silently pretending the state change
405
+ * persisted. The drain loop must not let that reject take down the
406
+ * whole pass (or the other due entries) — log it and move on; the
407
+ * entry stays in whatever state the last successful write left it in,
408
+ * and will be picked up again on the next drain pass. */
409
+ async function recordAttemptSafely(
410
+ entry: AlwaysAllowPersistEntry,
411
+ outcome: { success: true } | { success: false; error: string; retryAfterMs?: number },
412
+ ): Promise<void> {
413
+ try {
414
+ await queue.recordAttempt(entry.id, outcome)
415
+ } catch (err) {
416
+ log(
417
+ `always-allow-persist-queue: recordAttempt write failed (state change NOT persisted) agent=${entry.agentName} rule=${entry.rule}: ${(err as Error).message}\n`,
418
+ )
419
+ }
420
+ }
421
+
422
+ async function recordFailure(entry: AlwaysAllowPersistEntry, error: string, retryAfterMs?: number): Promise<void> {
423
+ const willBeExhausted = isExhausted({ attempts: entry.attempts + 1, createdAt: entry.createdAt })
424
+ log(
425
+ `always-allow-persist-queue: retry failed agent=${entry.agentName} rule=${entry.rule} attempts=${entry.attempts + 1} exhausted=${willBeExhausted} error=${error}\n`,
426
+ )
427
+ if (willBeExhausted) {
428
+ try {
429
+ deps.notifyTerminalFailure(entry, error)
430
+ } catch (notifyErr) {
431
+ log(
432
+ `always-allow-persist-queue: notifyTerminalFailure threw agent=${entry.agentName} rule=${entry.rule}: ${(notifyErr as Error).message}\n`,
433
+ )
434
+ }
435
+ }
436
+ await recordAttemptSafely(entry, { success: false, error, retryAfterMs })
437
+ }
438
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pure builders for the synthetic inbounds the gateway injects when an
3
+ * agent-initiated approval card TTL-expires with no operator tap — one per
4
+ * family: `vault_request_access`, `vault_request_save`, `request_secret`,
5
+ * `mental_model_propose`.
6
+ *
7
+ * Before this, the only way these families woke the parked agent was an
8
+ * explicit tap. A card that expired unanswered left the agent (which ended its
9
+ * turn to wait) parked forever. These builders mirror the tap-outcome builders
10
+ * in `vault-grant-inbound-builders.ts`, but carry TIMEOUT wording:
11
+ *
12
+ * "This is a TIMEOUT, not a denial — you may re-request or degrade
13
+ * gracefully."
14
+ *
15
+ * matching the permission-card timeout philosophy (#2411 / #2862) so the model
16
+ * can tell an expiry from a real denial and NOT spam an identical re-request
17
+ * into an absent operator.
18
+ *
19
+ * The `meta.source` string is load-bearing (the bridge keys on it) and each is
20
+ * distinct from its tap-outcome sibling. Pinned by
21
+ * `approval-timeout-inbound-builders.test.ts`.
22
+ */
23
+
24
+ import type { InboundMessage } from './ipc-protocol.js'
25
+
26
+ interface TimeoutInboundBase {
27
+ agent: string
28
+ /** Chat the card lived in — keeps the resumed turn on the same conversation. */
29
+ chatId: string
30
+ /** Forum topic, if the request came from one — so the resumed reply routes back. */
31
+ threadId?: number
32
+ stageId: string
33
+ /** TTL window in whole minutes, for the message copy. */
34
+ timeoutMinutes: number
35
+ nowMs?: number
36
+ }
37
+
38
+ function envelope(opts: {
39
+ chatId: string
40
+ threadId?: number
41
+ ts: number
42
+ text: string
43
+ source: string
44
+ agent: string
45
+ stageId: string
46
+ extraMeta?: Record<string, string>
47
+ }): InboundMessage {
48
+ return {
49
+ type: 'inbound',
50
+ chatId: opts.chatId,
51
+ ...(opts.threadId != null ? { threadId: opts.threadId } : {}),
52
+ messageId: opts.ts,
53
+ user: 'vault-broker',
54
+ userId: 0,
55
+ ts: opts.ts,
56
+ text: opts.text,
57
+ meta: {
58
+ source: opts.source,
59
+ agent: opts.agent,
60
+ ...(opts.threadId != null ? { message_thread_id: String(opts.threadId) } : {}),
61
+ stage_id: opts.stageId,
62
+ ...(opts.extraMeta ?? {}),
63
+ },
64
+ }
65
+ }
66
+
67
+ /** `vault_request_access` card expired unanswered. */
68
+ export function buildVaultAccessTimeoutInbound(
69
+ opts: TimeoutInboundBase & { key: string; scope: 'read' | 'write' },
70
+ ): InboundMessage {
71
+ const ts = opts.nowMs ?? Date.now()
72
+ return envelope({
73
+ chatId: opts.chatId,
74
+ threadId: opts.threadId,
75
+ ts,
76
+ source: 'vault_grant_timeout',
77
+ agent: opts.agent,
78
+ stageId: opts.stageId,
79
+ extraMeta: { key: opts.key, scope: opts.scope },
80
+ text:
81
+ `⌛ Your vault access request for \`${opts.key}\` (scope=${opts.scope}) ` +
82
+ `timed out after ${opts.timeoutMinutes} min — the operator did not tap. ` +
83
+ `This is a TIMEOUT, not a denial. Pick a fallback for the original task ` +
84
+ `(tell the user, try a different approach, or skip the feature), or ` +
85
+ `re-request later if it still matters. Do NOT re-request in a loop.`,
86
+ })
87
+ }
88
+
89
+ /** `vault_request_save` card expired unanswered. */
90
+ export function buildVaultSaveTimeoutInbound(
91
+ opts: TimeoutInboundBase & { key: string },
92
+ ): InboundMessage {
93
+ const ts = opts.nowMs ?? Date.now()
94
+ return envelope({
95
+ chatId: opts.chatId,
96
+ threadId: opts.threadId,
97
+ ts,
98
+ source: 'vault_save_timeout',
99
+ agent: opts.agent,
100
+ stageId: opts.stageId,
101
+ extraMeta: { key: opts.key },
102
+ text:
103
+ `⌛ Your vault_request_save for \`${opts.key}\` timed out after ` +
104
+ `${opts.timeoutMinutes} min — the operator did not tap, so the secret was ` +
105
+ `NOT stored and \`vault:${opts.key}\` will not resolve. This is a TIMEOUT, ` +
106
+ `not a denial. Pick a fallback or re-request the save later. Do NOT loop.`,
107
+ })
108
+ }
109
+
110
+ /** `request_secret` card expired unanswered. */
111
+ export function buildSecretRequestTimeoutInbound(
112
+ opts: TimeoutInboundBase & { key: string },
113
+ ): InboundMessage {
114
+ const ts = opts.nowMs ?? Date.now()
115
+ return envelope({
116
+ chatId: opts.chatId,
117
+ threadId: opts.threadId,
118
+ ts,
119
+ source: 'secret_request_timeout',
120
+ agent: opts.agent,
121
+ stageId: opts.stageId,
122
+ extraMeta: { key: opts.key },
123
+ text:
124
+ `⌛ Your request_secret for \`${opts.key}\` timed out after ` +
125
+ `${opts.timeoutMinutes} min — the operator did not provide it. This is a ` +
126
+ `TIMEOUT, not a denial. Proceed without it, ask the user how they want to ` +
127
+ `handle the task, or re-request later. Do NOT re-request in a loop.`,
128
+ })
129
+ }
130
+
131
+ /** `mental_model_propose` card expired unanswered. */
132
+ export function buildMentalModelProposeTimeoutInbound(
133
+ opts: TimeoutInboundBase & { name: string },
134
+ ): InboundMessage {
135
+ const ts = opts.nowMs ?? Date.now()
136
+ return envelope({
137
+ chatId: opts.chatId,
138
+ threadId: opts.threadId,
139
+ ts,
140
+ source: 'mental_model_propose_timeout',
141
+ agent: opts.agent,
142
+ stageId: opts.stageId,
143
+ extraMeta: { name: opts.name },
144
+ text:
145
+ `⌛ Your mental_model_propose for \`${opts.name}\` timed out after ` +
146
+ `${opts.timeoutMinutes} min — the operator did not tap, so nothing was ` +
147
+ `declared. This is a TIMEOUT, not a denial. Re-propose later if it still ` +
148
+ `stands. Do NOT re-propose in a loop.`,
149
+ })
150
+ }