switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -32,11 +32,18 @@
32
32
  * {@link MAX_QUEUE_SIZE} below, and the tests that pin them.
33
33
  *
34
34
  * File format mirrors `missed-approvals-store.ts`: a single bounded JSON
35
- * array, written synchronously, mode 0o600.
35
+ * array, written synchronously and ATOMICALLY (tmp + fsync + rename),
36
+ * mode 0o600 — a crash mid-persist leaves the previous queue intact
37
+ * instead of a torn file. NOTE: atomic REPLACEMENT only —
38
+ * whole-old-or-whole-new, not power-loss durability; the missing
39
+ * parent-directory fsync is tracked in #3603.
36
40
  *
37
41
  * 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
42
+ * READ degrades to an empty list so the gateway still boots but a
43
+ * CORRUPT file is no longer silent: the bytes are quarantined to
44
+ * `<file>.corrupt-<ts>` and a loud line goes to the log (see
45
+ * `store-file.ts`), because "queue silently came up empty" is exactly
46
+ * how queued retries disappeared unnoticed. A failed WRITE is a
40
47
  * different story: silently swallowing it would mean `enqueue()` tells
41
48
  * its caller "queued for retry" when nothing was actually persisted to
42
49
  * disk, and a concurrent `recordAttempt()`/`remove()` would silently
@@ -51,8 +58,14 @@
51
58
  * success.
52
59
  */
53
60
 
54
- import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
61
+ import { writeFileSync, unlinkSync } from 'node:fs'
55
62
  import { join } from 'node:path'
63
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
64
+ import {
65
+ preserveUnreadableStoreFile,
66
+ quarantineCorruptStoreFile,
67
+ readStoreJsonSync,
68
+ } from './store-file.js'
56
69
 
57
70
  /** Hard cap on retry attempts per entry — never retry indefinitely. */
58
71
  export const MAX_ATTEMPTS = 5
@@ -161,13 +174,33 @@ export function computeBackoffMs(attempts: number, retryAfterMs?: number): numbe
161
174
  return exp
162
175
  }
163
176
 
177
+ /**
178
+ * The real writer: `atomicWriteFileSync` (tmp + fsync + rename) behind a
179
+ * `writeFileSync`-shaped signature, so the injectable seam below keeps its
180
+ * existing type and the fault-injection tests are unaffected. Only `mode`
181
+ * from the options bag is meaningful here; the store always passes 0o600.
182
+ */
183
+ export const atomicWriteSeam = ((path, data, opts) => {
184
+ const mode = typeof opts === 'object' && opts !== null && typeof opts.mode === 'number' ? opts.mode : 0o600
185
+ atomicWriteFileSync(path as string, data as string, mode)
186
+ }) as typeof writeFileSync
187
+
164
188
  export function createAlwaysAllowPersistQueue(
165
189
  stateDir: string,
166
190
  /** Injectable for tests to force a write failure (disk full / permissions /
167
191
  * read-only fs) without real filesystem faults — we run as root in CI/
168
192
  * 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,
193
+ * bun's test runner doesn't support mocking node:fs built-ins.
194
+ *
195
+ * CAUTION: a test that injects a seam replaces the ATOMIC writer. Such a
196
+ * test proves failure PROPAGATION, never atomicity — the injected function
197
+ * is whatever the test supplies (typically a plain `writeFileSync`, which
198
+ * is exactly the non-atomic writer this store moved off). Tests that mean
199
+ * to exercise the real write path must either leave this defaulted or wrap
200
+ * the exported {@link atomicWriteSeam}. */
201
+ writeFileSyncFn: typeof writeFileSync = atomicWriteSeam,
202
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
203
+ log: (line: string) => void = l => process.stderr.write(l),
171
204
  ): AlwaysAllowPersistQueue {
172
205
  const filePath = join(stateDir, 'always-allow-persist-queue.json')
173
206
 
@@ -183,6 +216,26 @@ export function createAlwaysAllowPersistQueue(
183
216
  // single promise chain, so at most one is ever in flight at a time,
184
217
  // regardless of how many callers invoke enqueue/recordAttempt/remove
185
218
  // "concurrently".
219
+ //
220
+ // SCOPE (verified, not assumed): this is an IN-PROCESS promise chain, not
221
+ // an OS file lock. It serializes callers inside ONE gateway process only.
222
+ // Two gateway processes sharing a STATE_DIR WOULD still lose updates to
223
+ // each other, and that is not impossible — only rare. `startup-mutex.ts`
224
+ // makes concurrent gateways UNLIKELY, not unreachable: its bootMismatch
225
+ // path steals the lock with NO liveness check when the holder's bootId
226
+ // differs from the current one (exactly the restart-overlap case on a
227
+ // shared STATE_DIR — see the `boot.lock_stale_recovered_boot_mismatch`
228
+ // revert referenced at gateway.ts), `readCurrentBootId()` returns null
229
+ // off-Linux which disables the gate entirely, the lock is taken once at
230
+ // boot and never revalidated, and `isGatewayMain` lets harnesses bypass
231
+ // it. So: rare, not guaranteed.
232
+ //
233
+ // Cross-process mutual exclusion is deliberately OUT OF SCOPE for this
234
+ // change (which is about torn writes, not lost updates), and every write
235
+ // here is now atomic so an overlap can lose an update but can never
236
+ // corrupt the file. A real `flock` (cf. src/vault/flock-concurrent.test.ts)
237
+ // is the durable fix — tracked as follow-up. Do not read the startup mutex
238
+ // as a hard singleton invariant.
186
239
  let lock: Promise<unknown> = Promise.resolve()
187
240
  function withLock<T>(fn: () => T): Promise<T> {
188
241
  const result = lock.then(fn, fn) // run fn even if the previous link rejected
@@ -192,14 +245,39 @@ export function createAlwaysAllowPersistQueue(
192
245
  return result
193
246
  }
194
247
 
248
+ /** Set when the last read failed for a non-ENOENT reason — the next write
249
+ * must preserve the file it could not read instead of clobbering it. */
250
+ let unreadable = false
251
+
195
252
  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 {
253
+ const result = readStoreJsonSync(filePath, 'always-allow-persist-queue', log)
254
+ unreadable = result.status === 'unreadable'
255
+ if (result.status !== 'ok') return { entries: [] }
256
+ const parsed = result.value as Partial<FileShape>
257
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
258
+ quarantineCorruptStoreFile(
259
+ filePath,
260
+ 'always-allow-persist-queue',
261
+ 'parsed to a non-object — not a persist-queue file',
262
+ log,
263
+ )
201
264
  return { entries: [] }
202
265
  }
266
+ // A PRESENT-but-non-array `entries` is corruption, not "empty queue".
267
+ // Coercing it to [] would resurrect the silent-loss bug: a half-written
268
+ // `{"entries": {}}` parses fine, so quarantine would never fire and the
269
+ // queued retries would vanish unnoticed. An ABSENT `entries` is the
270
+ // legitimate cold-start/partial-shape case and stays silent.
271
+ if (parsed.entries !== undefined && !Array.isArray(parsed.entries)) {
272
+ quarantineCorruptStoreFile(
273
+ filePath,
274
+ 'always-allow-persist-queue',
275
+ '`entries` is present but not an array — truncated or malformed write',
276
+ log,
277
+ )
278
+ return { entries: [] }
279
+ }
280
+ return { entries: parsed.entries ?? [] }
203
281
  }
204
282
 
205
283
  /** Unlike `read()`, a write failure is NOT swallowed — it propagates so
@@ -207,6 +285,14 @@ export function createAlwaysAllowPersistQueue(
207
285
  * not actually land on disk (disk full, permissions, etc.) instead of
208
286
  * silently proceeding as if it had. */
209
287
  function write(f: FileShape): void {
288
+ // Fail closed: never let an overwrite be what destroys a queue we merely
289
+ // failed to READ (flaky mount, transient EACCES) — this throws if the
290
+ // previous bytes can't be preserved, and that throw is exactly the
291
+ // propagate-don't-swallow contract above.
292
+ if (unreadable) {
293
+ preserveUnreadableStoreFile(filePath, 'always-allow-persist-queue', log)
294
+ unreadable = false
295
+ }
210
296
  writeFileSyncFn(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
211
297
  }
212
298
 
@@ -0,0 +1,164 @@
1
+ /**
2
+ * boot-sweep-gate.ts — control flow for the boot pin sweep (#3664): WHEN it may
3
+ * start (`createBootSweepGate`, the two-condition arming gate) and HOW its steps
4
+ * are sequenced once it does (`runBootPinSweepSteps`, per-step isolation).
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * The boot orphan sweep (`runBootPinCleanupAndDmSweep` → `statusPinBootCleanup`
9
+ * → `unpinChatMessage`) has TWO independent preconditions, and they are
10
+ * satisfied at two different points of the gateway's boot:
11
+ *
12
+ * 1. **Ownership** — this process won the startup mutex, so it is the sole
13
+ * live owner of the shared per-agent pin state. Established during module
14
+ * evaluation (`acquireStartupLock`, plus the non-atomic fallback path).
15
+ * A LOSING double-boot must never unpin the live holder's pins.
16
+ * 2. **A Telegram handle** — `lockedBot` is assigned, so an unpin can
17
+ * actually reach Telegram. Established much later, inside
18
+ * `initGatewayBot()` at boot.
19
+ *
20
+ * Before this gate the sweep was kicked off fire-and-forget the moment (1) was
21
+ * satisfied, i.e. at module-eval time — thousands of lines before (2). Every
22
+ * boot-cleanup unpin therefore threw `undefined is not an object (evaluating
23
+ * 'lockedBot.api')` before touching the network, the row was retained with an
24
+ * incremented attempt counter, and after `BOOT_UNPIN_MAX_ATTEMPTS` the only
25
+ * record of a still-pinned message was forfeited. That was a regression from
26
+ * `caa4d7568` (#3310), which moved the Bot construction — and with it the
27
+ * `lockedBot` assignment — out of module scope into `initGatewayBot()` while
28
+ * leaving the sweep's kick-off where it was.
29
+ *
30
+ * The gate makes the ordering DETERMINISTIC rather than a race: the sweep runs
31
+ * exactly once, when BOTH signals have arrived, in whichever order they arrive.
32
+ * It cannot regress by code motion — moving either signal site around cannot
33
+ * make the sweep fire before the other signal.
34
+ *
35
+ * Deliberately dependency-free and side-effect-free so it is provable in
36
+ * isolation (`telegram-plugin/tests/boot-sweep-gate.test.ts`); the gateway owns
37
+ * the wiring.
38
+ */
39
+
40
+ export interface BootSweepGate {
41
+ /** Signal (1): this gateway owns the shared per-agent pin state. */
42
+ arm(): void
43
+ /** Signal (2): the Telegram bot handle exists — API calls can be made. */
44
+ botReady(): void
45
+ /** True once the sweep has been dispatched. Test/introspection seam. */
46
+ hasRun(): boolean
47
+ }
48
+
49
+ /**
50
+ * Build a gate that dispatches `run` exactly once, after BOTH `arm()` and
51
+ * `botReady()` have been called. Both signals are idempotent and
52
+ * order-independent. `run` is dispatched fire-and-forget; a rejection is routed
53
+ * to `onError` so it can never surface as an unhandled rejection (the sweep is
54
+ * best-effort and must never take the gateway down).
55
+ */
56
+ export function createBootSweepGate(args: {
57
+ run: () => Promise<void>
58
+ onError?: (err: unknown) => void
59
+ }): BootSweepGate {
60
+ let armed = false
61
+ let ready = false
62
+ let started = false
63
+
64
+ const maybeRun = (): void => {
65
+ if (started || !armed || !ready) return
66
+ started = true
67
+ void (async () => {
68
+ try {
69
+ await args.run()
70
+ } catch (err) {
71
+ args.onError?.(err)
72
+ }
73
+ })()
74
+ }
75
+
76
+ return {
77
+ arm(): void {
78
+ armed = true
79
+ maybeRun()
80
+ },
81
+ botReady(): void {
82
+ ready = true
83
+ maybeRun()
84
+ },
85
+ hasRun(): boolean {
86
+ return started
87
+ },
88
+ }
89
+ }
90
+
91
+ /** Dependencies of {@link runBootPinSweepSteps}. All side effects are injected
92
+ * so the sequencing contract is provable without a gateway. */
93
+ export interface BootPinSweepSteps {
94
+ /** Pure fs scan for DM chat ids with a prior-session pin record. MUST run
95
+ * BEFORE the reapers, which empty those same stores. */
96
+ scanDmChatIds: () => string[]
97
+ statusPinCleanup: () => Promise<unknown>
98
+ activityCardReaper: () => Promise<unknown>
99
+ queuedCardReaper: () => Promise<unknown>
100
+ /** Flips the flag authorising the DM unpin-all path — for this sweep AND for
101
+ * later lazy first-inbound sweeps. */
102
+ enableDmSweep: () => void
103
+ sweepDm: (chatId: string) => Promise<unknown>
104
+ log?: (line: string) => void
105
+ }
106
+
107
+ /**
108
+ * Run one sweep step, absorbing (and logging) a throw. See
109
+ * {@link runBootPinSweepSteps} for why isolation is mandatory here.
110
+ *
111
+ * Absorbs unconditionally: a non-`Error` throw is stringified rather than
112
+ * logged as `undefined`, and a `log` that itself throws (a closed stderr on a
113
+ * dying process) is swallowed too — otherwise the one function whose whole job
114
+ * is "never let a step take the sweep down" could take the sweep down.
115
+ */
116
+ async function step(name: string, fn: () => Promise<unknown>, log: (line: string) => void) {
117
+ try {
118
+ await fn()
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err)
121
+ try {
122
+ log(`telegram gateway: boot pin sweep step '${name}' failed: ${msg}\n`)
123
+ } catch {
124
+ /* stderr is gone — nothing useful left to do */
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Sequence the boot sweep's steps, each individually absorbed.
131
+ *
132
+ * Why isolation is mandatory (#3664 salvage S2). The steps used to run as a
133
+ * bare sequential `await` chain. `runStatusPinBootCleanup` absorbs its own
134
+ * per-row throws, but the two card reapers issue real Bot API calls and can
135
+ * reject outright — and a rejection there did not merely skip the later
136
+ * reapers, it skipped `enableDmSweep()`. That flag authorises the DM
137
+ * unpin-all path for the WHOLE SESSION (the boot sweep AND every later lazy
138
+ * first-inbound sweep), so one throwing reaper silently disabled DM stale-pin
139
+ * cleanup entirely.
140
+ *
141
+ * That was unreachable before #3664 only because the sweep never actually ran
142
+ * — every step died on `undefined is not an object (evaluating
143
+ * 'lockedBot.api')` before doing anything. Making the sweep work for the first
144
+ * time is precisely what makes an earlier step's throw newly reachable, so the
145
+ * isolation lands with the fix rather than after it.
146
+ *
147
+ * Never rejects: every step is best-effort and the caller fire-and-forgets it.
148
+ */
149
+ export async function runBootPinSweepSteps(deps: BootPinSweepSteps): Promise<void> {
150
+ const log = deps.log ?? ((l: string) => process.stderr.write(l))
151
+
152
+ let dmChatIds: string[] = []
153
+ await step('dm-chat-scan', async () => {
154
+ dmChatIds = deps.scanDmChatIds()
155
+ }, log)
156
+
157
+ await step('status-pin-cleanup', deps.statusPinCleanup, log)
158
+ await step('activity-card-reaper', deps.activityCardReaper, log)
159
+ await step('queued-card-reaper', deps.queuedCardReaper, log)
160
+
161
+ // Unconditional: reached even when every step above threw. See the docblock.
162
+ await step('enable-dm-sweep', async () => deps.enableDmSweep(), log)
163
+ for (const id of dmChatIds) await step(`dm-sweep:${id}`, () => deps.sweepDm(id), log)
164
+ }