switchroom 0.19.17 → 0.19.19

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 (91) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -32,11 +32,24 @@
32
32
  *
33
33
  * File format: JSON array of PersistedApprovalCard objects. Written
34
34
  * synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
35
- * production rate is a handful of cards, so the file stays tiny.
35
+ * production rate is a handful of cards, so the file stays tiny. The write
36
+ * goes through `atomicWriteFileSync` (tmp + fsync + rename) so a crash
37
+ * mid-persist can never leave a torn file behind — the destination holds
38
+ * either the whole previous array or the whole new one. A file that IS
39
+ * corrupt (from a pre-fix write, or anything else) is quarantined and logged
40
+ * loudly rather than silently read as "no pending cards" — see
41
+ * `store-file.ts`. NOTE: atomic REPLACEMENT only — whole-old-or-whole-new, not
42
+ * power-loss durability; the missing parent-directory fsync is tracked in #3603.
36
43
  */
37
44
 
38
- import { readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs'
45
+ import { unlinkSync } from 'node:fs'
39
46
  import { join } from 'node:path'
47
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
48
+ import {
49
+ preserveUnreadableStoreFile,
50
+ quarantineCorruptStoreFile,
51
+ readStoreJsonSync,
52
+ } from './store-file.js'
40
53
 
41
54
  /** The four agent-initiated approval-card families we persist. */
42
55
  export type ApprovalCardFamily =
@@ -111,30 +124,47 @@ export interface PendingCardStore {
111
124
  clear(): void
112
125
  }
113
126
 
114
- export function createPendingCardStore(stateDir: string): PendingCardStore {
127
+ export function createPendingCardStore(
128
+ stateDir: string,
129
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
130
+ log: (line: string) => void = l => process.stderr.write(l),
131
+ ): PendingCardStore {
115
132
  const filePath = join(stateDir, 'pending-approval-cards.json')
116
133
 
134
+ /** Set when the last read failed for a non-ENOENT reason — the next write
135
+ * must preserve the file it could not read instead of clobbering it. */
136
+ let unreadable = false
137
+
117
138
  function read(): PersistedApprovalCard[] {
118
- try {
119
- const raw = readFileSync(filePath, 'utf-8')
120
- const parsed = JSON.parse(raw)
121
- return Array.isArray(parsed) ? (parsed as PersistedApprovalCard[]) : []
122
- } catch {
139
+ const result = readStoreJsonSync(filePath, 'pending-card-store', log)
140
+ unreadable = result.status === 'unreadable'
141
+ if (result.status !== 'ok') return []
142
+ if (!Array.isArray(result.value)) {
143
+ quarantineCorruptStoreFile(
144
+ filePath,
145
+ 'pending-card-store',
146
+ 'parsed to a non-array — not a pending-card file',
147
+ log,
148
+ )
123
149
  return []
124
150
  }
151
+ return result.value as PersistedApprovalCard[]
125
152
  }
126
153
 
127
154
  function write(entries: PersistedApprovalCard[]): void {
128
155
  try {
129
- writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
130
- // `mode` only applies when writeFileSync CREATES the file; an existing
131
- // file keeps its prior perms. Re-assert 0600 on every write so the file
132
- // can never stay laxer than intended.
133
- chmodSync(filePath, 0o600)
156
+ // Fail closed: never let an overwrite be what destroys state we merely
157
+ // failed to READ (flaky mount, transient EACCES) — throws if it can't.
158
+ if (unreadable) {
159
+ preserveUnreadableStoreFile(filePath, 'pending-card-store', log)
160
+ unreadable = false
161
+ }
162
+ // tmp + fsync + rename, mode pinned to 0600 on the tempfile fd (so an
163
+ // existing file can't keep laxer perms, and a crash mid-write leaves
164
+ // the previous good file untouched).
165
+ atomicWriteFileSync(filePath, JSON.stringify(entries), 0o600)
134
166
  } catch (err) {
135
- process.stderr.write(
136
- `telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
137
- )
167
+ log(`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`)
138
168
  }
139
169
  }
140
170
 
@@ -458,9 +458,13 @@ export function buildResumeDeferredReportInbound(
458
458
  * the gateway calls this with the classified `ended_via` so the
459
459
  * report-vs-resume policy lives in one testable place.
460
460
  *
461
- * - 'timeout' → 'report' (watchdog kill)
462
- * - 'restart' | 'sigterm' | 'unknown' → 'resume' (clean interrupt)
463
- * - 'stop' → null (finished; nothing to do)
461
+ * - 'timeout' → 'report' (watchdog kill)
462
+ * - 'restart'|'reaped_stale'|'sigterm'|'unknown' → 'resume' (clean interrupt)
463
+ * - 'stop' → null (finished; nothing to do)
464
+ *
465
+ * #3555: `'reaped_stale'` (mid-session stale-row sweep) is treated exactly
466
+ * like `'restart'` here — it is the same clean interrupt, just no longer
467
+ * mislabelled as a restart in the data.
464
468
  */
465
469
  export function selectResumeBuilder(
466
470
  endedVia: TurnEndedVia | null,
@@ -473,7 +477,12 @@ export function selectResumeBuilder(
473
477
  ): 'resume' | 'report' | null {
474
478
  let kind: 'resume' | 'report' | null
475
479
  if (endedVia === 'timeout') kind = 'report'
476
- else if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') kind = 'resume'
480
+ else if (
481
+ endedVia === 'restart' ||
482
+ endedVia === 'reaped_stale' ||
483
+ endedVia === 'sigterm' ||
484
+ endedVia === 'unknown'
485
+ ) kind = 'resume'
477
486
  else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
478
487
  else kind = null
479
488
  if (
@@ -8,7 +8,11 @@
8
8
  * immediately. It reads as "my approval didn't stick."
9
9
  *
10
10
  * Fix: mirror the store to a tiny JSON file in STATE_DIR (same shape as
11
- * permission-card-store.ts — synchronous writes, mode 0o600, one small file).
11
+ * permission-card-store.ts — synchronous ATOMIC writes (tmp + fsync + rename),
12
+ * mode 0o600, one small file; a corrupt file is quarantined and logged loudly
13
+ * rather than silently read as "no grants" — see store-file.ts). NOTE: atomic
14
+ * REPLACEMENT only — whole-old-or-whole-new, not power-loss durability; the
15
+ * missing parent-directory fsync is tracked in #3603.
12
16
  * Write-through on every grant and on sweep-expiry removal; reload at boot,
13
17
  * dropping entries already past their ABSOLUTE expiry.
14
18
  *
@@ -23,8 +27,13 @@
23
27
  * and never write (any pre-existing file is ignored).
24
28
  */
25
29
 
26
- import { readFileSync, writeFileSync } from 'node:fs'
27
30
  import { join } from 'node:path'
31
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
32
+ import {
33
+ preserveUnreadableStoreFile,
34
+ quarantineCorruptStoreFile,
35
+ readStoreJsonSync,
36
+ } from './store-file.js'
28
37
  import {
29
38
  serializeScopedGrants,
30
39
  deserializeScopedGrants,
@@ -50,18 +59,31 @@ export function scopedGrantPersistEnabled(
50
59
  export function createScopedGrantStore(
51
60
  stateDir: string,
52
61
  env: Record<string, string | undefined> = process.env,
62
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
63
+ log: (line: string) => void = l => process.stderr.write(l),
53
64
  ): ScopedGrantPersistence {
54
65
  const filePath = join(stateDir, 'scoped-grants.json')
55
66
  const enabled = scopedGrantPersistEnabled(env)
56
67
 
68
+ /** Set when the last read failed for a non-ENOENT reason — the next write
69
+ * must preserve the file it could not read instead of clobbering it. */
70
+ let unreadable = false
71
+
57
72
  function read(): unknown[] {
58
- try {
59
- const raw = readFileSync(filePath, 'utf-8')
60
- const parsed = JSON.parse(raw)
61
- return Array.isArray(parsed) ? parsed : []
62
- } catch {
73
+ const result = readStoreJsonSync(filePath, 'scoped-grant-store', log)
74
+ unreadable = result.status === 'unreadable'
75
+ if (result.status !== 'ok') return []
76
+ const parsed = result.value
77
+ if (!Array.isArray(parsed)) {
78
+ quarantineCorruptStoreFile(
79
+ filePath,
80
+ 'scoped-grant-store',
81
+ 'parsed to a non-array — not a scoped-grants file',
82
+ log,
83
+ )
63
84
  return []
64
85
  }
86
+ return parsed
65
87
  }
66
88
 
67
89
  return {
@@ -75,14 +97,17 @@ export function createScopedGrantStore(
75
97
  save(store) {
76
98
  if (!enabled) return
77
99
  try {
78
- writeFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), {
79
- encoding: 'utf-8',
80
- mode: 0o600,
81
- })
100
+ // Fail closed: never let an overwrite be what destroys grants we
101
+ // merely failed to READ (flaky mount, transient EACCES).
102
+ if (unreadable) {
103
+ preserveUnreadableStoreFile(filePath, 'scoped-grant-store', log)
104
+ unreadable = false
105
+ }
106
+ // tmp + fsync + rename — a crash mid-persist leaves the previous
107
+ // grant set intact rather than a torn file that reads as "no grants".
108
+ atomicWriteFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
82
109
  } catch (err) {
83
- process.stderr.write(
84
- `telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`,
85
- )
110
+ log(`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`)
86
111
  }
87
112
  },
88
113
  }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Shared read side for the gateway's small JSON state stores
3
+ * (`pending-card-store.ts`, `scoped-grant-store.ts`,
4
+ * `missed-approvals-store.ts`, `always-allow-persist-queue.ts`).
5
+ *
6
+ * Problem: each of those stores read its backing file with a bare
7
+ * `try { JSON.parse(readFileSync(...)) } catch { return [] }`. Combined
8
+ * with a non-atomic `writeFileSync` straight over the destination (fixed
9
+ * separately — every writer now goes through `atomicWriteFileSync`), a
10
+ * crash between truncate and write left a TORN file. On the next boot the
11
+ * parse threw, the catch swallowed it, and the store came up EMPTY: every
12
+ * pending approval card and every live scoped grant silently forgotten,
13
+ * with nothing in the logs. The operator just saw dead buttons.
14
+ *
15
+ * Losing security state must be OBSERVABLE. So a parse failure here is
16
+ * never silent:
17
+ * - the corrupt bytes are preserved by renaming them aside to
18
+ * `<file>.corrupt-<epoch-ms>-<seq>-<rand>` (never deleted while they are the
19
+ * newest few — they're the forensic record of what was lost, and the
20
+ * only chance of manual recovery);
21
+ * - a loud `telegram gateway: <store> CORRUPT …` line goes to stderr,
22
+ * the same channel the surrounding stores already log write failures
23
+ * on (the gateway's stderr is captured in the agent's runtime log);
24
+ * - the caller gets a non-`ok` result and starts from an empty store —
25
+ * the process still boots, it just no longer does so in silence.
26
+ *
27
+ * Three read outcomes, deliberately distinguished:
28
+ * - `missing` — the normal cold start. Silent, no quarantine.
29
+ * - `corrupt` — unparseable JSON (or a shape the store rejects, via
30
+ * `quarantineCorruptStoreFile`). Quarantine + loud log.
31
+ * - `unreadable` — any other fs error (EACCES, EIO, EISDIR, …). Loud log,
32
+ * but NO quarantine: renaming a file we merely failed to
33
+ * read would destroy good state over a transient fault.
34
+ * Instead the caller FAILS CLOSED on its next write —
35
+ * see `preserveUnreadableStoreFile` below.
36
+ *
37
+ * ── Known hazard: two writers ──────────────────────────────────────────
38
+ * Quarantine is a destructive-looking move (it renames the destination
39
+ * away), and it is NOT safe against a second process writing the same
40
+ * file: process A can parse-fail on a torn file, process B can then
41
+ * atomically rename GOOD state into place, and A's quarantine would move
42
+ * B's good file aside. Pre-fix a parse failure was inert, so this hazard is
43
+ * introduced here. It is accepted because the gateway is (near-)singleton
44
+ * per agent — see the `withLock` note in `always-allow-persist-queue.ts`
45
+ * for why "near", and note the window is the few microseconds between the
46
+ * failed parse and the rename. If a genuine multi-writer arrangement ever
47
+ * appears, quarantine must become an flock-guarded compare-and-rename.
48
+ */
49
+
50
+ import { randomBytes } from 'node:crypto'
51
+ import { readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
52
+ import { basename, dirname, join } from 'node:path'
53
+
54
+ /** Default sink — matches the stores' existing `process.stderr.write` use. */
55
+ const defaultLog = (line: string): void => {
56
+ process.stderr.write(line)
57
+ }
58
+
59
+ /**
60
+ * How many `<file>.corrupt-*` forensic copies to keep per store file.
61
+ * They're tiny, but a store that corrupts on a loop must not fill the
62
+ * state dir — so the oldest are reaped past this cap. Newest wins.
63
+ */
64
+ export const MAX_QUARANTINED_COPIES = 5
65
+
66
+ /** Outcome of a store-file read. See the module docblock. */
67
+ export type StoreReadResult =
68
+ | { status: 'ok'; value: unknown }
69
+ | { status: 'missing' }
70
+ | { status: 'corrupt' }
71
+ | { status: 'unreadable' }
72
+
73
+ /**
74
+ * Monotonic within this process. Purely a name-uniqueness aid now — the
75
+ * reaper orders by mtime, NOT by name (see `reapOldQuarantines`), because a
76
+ * name-based order is only monotonic within one process: a restart resets
77
+ * this counter to 0, and a fresh copy written in the same epoch-ms as a
78
+ * prior process's burst would sort as the OLDEST and be reaped first.
79
+ */
80
+ let quarantineSeq = 0
81
+
82
+ /**
83
+ * `<file>.corrupt-<epoch-ms>-<seq>-<rand>`.
84
+ *
85
+ * All three tail components exist to make the name UNIQUE: two quarantines
86
+ * in the same millisecond, or from two processes, would otherwise collide
87
+ * and the second `renameSync` would silently clobber the first forensic copy
88
+ * — same reasoning as the tempfile naming in `src/util/atomic.ts`. The name
89
+ * is deliberately NOT the ordering key; mtime is.
90
+ */
91
+ function quarantinePath(filePath: string): string {
92
+ const seq = String(quarantineSeq++).padStart(6, '0')
93
+ return `${filePath}.corrupt-${Date.now()}-${seq}-${randomBytes(4).toString('hex')}`
94
+ }
95
+
96
+ /**
97
+ * Reap all but the newest {@link MAX_QUARANTINED_COPIES} forensic copies.
98
+ *
99
+ * Ordered by the filesystem's own mtime (nanosecond resolution via the
100
+ * bigint stat) — unlike the embedded `<epoch-ms>-<seq>`, whose sequence
101
+ * restarts at 0 on every boot and would make a fresh copy sort as the oldest
102
+ * whenever a restart lands in the same millisecond as the previous process's
103
+ * burst. The name is used only as a stable tie-break.
104
+ *
105
+ * Where the guarantee ends: this is exact only on filesystems that record
106
+ * mtime at (sub-)nanosecond granularity — ext4, xfs, apfs, btrfs all do. On a
107
+ * coarse-granularity filesystem (older ext3, some network/FUSE mounts, where
108
+ * mtime rounds to a whole second) a burst of more than MAX_QUARANTINED_COPIES
109
+ * quarantines inside one second all tie on `mtimeNs`, the sort falls through
110
+ * to the name, and the restart-reset ordering above comes back: the prior
111
+ * process burns seq 000001-000005 in second T, a restart quarantines at
112
+ * 000000, all six tie, and the freshest copy is reaped first. That costs a
113
+ * forensic copy on a rare filesystem and never live state, so it is not
114
+ * defended against here — but do not read this reaper as unconditionally
115
+ * restart-safe.
116
+ */
117
+ function reapOldQuarantines(filePath: string, log: (line: string) => void): void {
118
+ const dir = dirname(filePath)
119
+ const prefix = `${basename(filePath)}.corrupt-`
120
+ try {
121
+ const copies = readdirSync(dir)
122
+ .filter(f => f.startsWith(prefix))
123
+ .map(name => {
124
+ let mtimeNs = 0n
125
+ try {
126
+ mtimeNs = statSync(join(dir, name), { bigint: true }).mtimeNs
127
+ } catch {
128
+ // Vanished under us (a concurrent reap) — sorts oldest; the rmSync
129
+ // below is `force`, so a missing file is a no-op either way.
130
+ }
131
+ return { name, mtimeNs }
132
+ })
133
+ .sort((a, b) => (a.mtimeNs === b.mtimeNs ? a.name.localeCompare(b.name) : a.mtimeNs < b.mtimeNs ? -1 : 1))
134
+ for (const stale of copies.slice(0, Math.max(0, copies.length - MAX_QUARANTINED_COPIES))) {
135
+ rmSync(join(dir, stale.name), { force: true })
136
+ }
137
+ } catch (err) {
138
+ // Reaping is opportunistic — never let it mask the corruption itself.
139
+ log(`telegram gateway: quarantine reap failed dir=${dir}: ${(err as Error).message}\n`)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Move a corrupt/unusable store file aside so its bytes survive, and log
145
+ * loudly. Exported for stores whose corruption is a SHAPE failure (valid
146
+ * JSON, wrong structure) that only the store itself can detect.
147
+ */
148
+ export function quarantineCorruptStoreFile(
149
+ filePath: string,
150
+ store: string,
151
+ reason: string,
152
+ log: (line: string) => void = defaultLog,
153
+ ): void {
154
+ const target = quarantinePath(filePath)
155
+ let preserved = target
156
+ try {
157
+ renameSync(filePath, target)
158
+ } catch (err) {
159
+ preserved = `NOT preserved (${(err as Error).message})`
160
+ }
161
+ log(
162
+ `telegram gateway: ${store} CORRUPT — ${reason}. ` +
163
+ `Persisted state was LOST and the store is starting EMPTY; ` +
164
+ `corrupt file ${preserved}\n`,
165
+ )
166
+ reapOldQuarantines(filePath, log)
167
+ }
168
+
169
+ /**
170
+ * FAIL-CLOSED guard for the `unreadable` read outcome.
171
+ *
172
+ * A read that failed for a non-ENOENT reason (a flaky mount returning EIO,
173
+ * a transient EACCES) leaves the store in memory EMPTY while the on-disk
174
+ * file may still hold perfectly good state. The next `save()` would then
175
+ * rename a fresh, near-empty file over it and destroy that state
176
+ * permanently — quietly, and for a fault that may have lasted one syscall.
177
+ *
178
+ * So the stores call this immediately BEFORE their first write following an
179
+ * unreadable read: the existing bytes are renamed aside (same
180
+ * `.corrupt-<ts>-<seq>-<rand>` forensic convention) so the overwrite can never be
181
+ * the thing that loses them. If the rename itself fails the write is
182
+ * ABORTED by throwing — better a loud failed write than a silent
183
+ * destruction of the only copy.
184
+ */
185
+ export function preserveUnreadableStoreFile(
186
+ filePath: string,
187
+ store: string,
188
+ log: (line: string) => void = defaultLog,
189
+ ): void {
190
+ const target = quarantinePath(filePath)
191
+ try {
192
+ renameSync(filePath, target)
193
+ } catch (err) {
194
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
195
+ // Nothing there to lose — the read failure wasn't about existing bytes.
196
+ return
197
+ }
198
+ log(
199
+ `telegram gateway: ${store} REFUSING to overwrite an unreadable file at ` +
200
+ `${filePath} — could not preserve it first: ${(err as Error).message}\n`,
201
+ )
202
+ throw err
203
+ }
204
+ log(
205
+ `telegram gateway: ${store} could not READ its file but is about to write it. ` +
206
+ `Preserved the previous (unreadable) bytes as ${target} rather than ` +
207
+ `overwriting them; the store continues from EMPTY\n`,
208
+ )
209
+ reapOldQuarantines(filePath, log)
210
+ }
211
+
212
+ /**
213
+ * Read + parse a store's backing JSON file. See {@link StoreReadResult} and
214
+ * the module docblock for what each outcome means and what it triggers.
215
+ */
216
+ export function readStoreJsonSync(
217
+ filePath: string,
218
+ store: string,
219
+ log: (line: string) => void = defaultLog,
220
+ ): StoreReadResult {
221
+ let raw: string
222
+ try {
223
+ raw = readFileSync(filePath, 'utf-8')
224
+ } catch (err) {
225
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }
226
+ log(
227
+ `telegram gateway: ${store} read FAILED path=${filePath}: ` +
228
+ `${(err as Error).message} — starting EMPTY for this read; the next write ` +
229
+ `will preserve the unreadable file rather than overwrite it\n`,
230
+ )
231
+ return { status: 'unreadable' }
232
+ }
233
+ try {
234
+ return { status: 'ok', value: JSON.parse(raw) }
235
+ } catch (err) {
236
+ quarantineCorruptStoreFile(
237
+ filePath,
238
+ store,
239
+ `unparseable JSON (${(err as Error).message}) — likely a torn write from a crash mid-persist`,
240
+ log,
241
+ )
242
+ return { status: 'corrupt' }
243
+ }
244
+ }
@@ -372,10 +372,6 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
372
372
  // `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
373
373
  // EDITS the existing card instead of opening a second one, and so the
374
374
  // turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
375
- // The handback turn also gets the turn-long typing loop it never had —
376
- // whether or not a card was painted (the debounce may not have fired) —
377
- // stopped by the canonical turn-end (`purgeReactionTracking →
378
- // stopTurnTypingLoop`).
379
375
  if (HANDBACK_PRETURN_ENABLED) {
380
376
  const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
381
377
  if (handbackAdoption != null) {
@@ -383,9 +379,32 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
383
379
  next.activityMessageId = handbackAdoption.activityMessageId
384
380
  next.activityEverOpened = true
385
381
  }
386
- startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
382
+ // Observability (#3544): adoption is the rare, previously-silent
383
+ // branch — one line per adopted handback turn, not per turn.
384
+ process.stderr.write(
385
+ `telegram gateway: handback pre-turn adopted turnId=${turnId} ` +
386
+ `key=${handbackAdoption.statusKey} card=${handbackAdoption.activityMessageId ?? 'none'}\n`,
387
+ )
387
388
  }
388
389
  }
390
+ // #3544 — arm the turn-long `typing…` loop for EVERY minted turn,
391
+ // unconditionally. It used to hang off the handback ADOPTION above,
392
+ // which misses whenever the pre-turn entry was deduped (parallel
393
+ // workers on one topic), had no derivable turn id, or was already
394
+ // reaped — and the whole compose window went dark. Only the real-inbound
395
+ // path (`turn-start-surfaces.ts`) armed a loop, so a synthetic turn
396
+ // (handback / cron / wake) could have none at all. Unconditional is safe
397
+ // and costs nothing extra on the wire:
398
+ // - `turnTypingLoop.start` is restart-safe (stops any prior loop on
399
+ // the key first, so a real inbound's loop is replaced, not doubled);
400
+ // - every send goes through the SHARED per-chat-key emitter floor
401
+ // (`typing-emitter.ts`, TYPING_FLOOR_MS) so N arms on one chat still
402
+ // cost at most one chat action per floor window — the 2026-07-11
403
+ // flood-ban guard is what makes arming more loops free;
404
+ // - `turn-end.ts` (`purgeReactionTracking → stopTurnTypingLoop`) is
405
+ // already the single stop-owner for ALL turns, and a start is
406
+ // self-healing anyway, so this cannot leak an interval.
407
+ startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
389
408
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
390
409
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
391
410
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is