switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -162,14 +162,26 @@ export interface DrainIo {
162
162
  * both a model AND an effort command; breaking after re-enqueueing only the
163
163
  * current one would silently drop the other with its ack card stuck at
164
164
  * "queued" forever.
165
+ *
166
+ * Restart-pending is SNAPSHOTTED once at drain entry (#3021): the caller's
167
+ * `restartPending()` reads a `pendingRestarts` map that the turn-end idle gate
168
+ * empties in a synchronous loop dispatched right alongside this (async) drain.
169
+ * For the FIRST command the per-iteration read still sees the entry, but by the
170
+ * time an `await` yields to the second command the sync loop has emptied the
171
+ * map — so a naive per-command re-check would let the second command apply live
172
+ * into a session that is ~100ms from triggerSelfRestart and falsely confirm.
173
+ * Latching the entry state fixes that. We still OR in the live re-check so a
174
+ * restart that BECOMES pending mid-drain (a model apply can itself enqueue one)
175
+ * is honored for the commands that follow it.
165
176
  */
166
177
  export async function drainTakenCommands(
167
178
  taken: readonly PendingSessionCommand[],
168
179
  io: DrainIo,
169
180
  ): Promise<void> {
181
+ const restartPendingAtEntry = io.restartPending()
170
182
  for (let i = 0; i < taken.length; i++) {
171
183
  const cmd = taken[i]
172
- if (io.restartPending()) {
184
+ if (restartPendingAtEntry || io.restartPending()) {
173
185
  // The live session is going away — carry the choice across the bounce
174
186
  // via the durable carriers (or the re-issue fallback).
175
187
  await io.editCard(cmd, io.resolveForRestartText(cmd))
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The permission TTL sweep — the code path that used to auto-deny approvals the
3
+ * operator never saw.
4
+ *
5
+ * WHY THIS IS A MODULE AND NOT A LOOP INSIDE gateway.ts:
6
+ *
7
+ * gateway.ts has top-level side effects and is not importable from a test. The
8
+ * first version of the outcome test worked around that by letting its harness
9
+ * carry a PRIVATE re-implementation of this sweep. The result was a placebo:
10
+ * deleting the real guard from gateway.ts left every behavioural assertion GREEN,
11
+ * because the double silently compensated — only a source-text grep noticed.
12
+ *
13
+ * A test that cannot fail is not a test, and this is the sweep that implements the
14
+ * `no-self-escalation` / `on-leash` invariant. So the DECISION lives here, in one
15
+ * importable place, and both gateway.ts and the outcome test drive this exact
16
+ * function. Delete the leash check inside `shouldExpirePermission` and the outcome
17
+ * test goes red — because there is only one implementation to delete.
18
+ *
19
+ * @see reference/jobs/approve-what-my-agent-can-touch.md
20
+ * @see reference/invariants.md § no-self-escalation, § on-leash
21
+ */
22
+
23
+ import { shouldExpirePermission, type UndeliverableMark } from './approval-hold.js'
24
+
25
+ /** The fields of a pending permission this sweep actually reads. */
26
+ export interface TtlSweepEntry {
27
+ tool_name: string
28
+ startedAt: number
29
+ undeliverable?: UndeliverableMark | null
30
+ }
31
+
32
+ export interface TtlSweepDeps<T extends TtlSweepEntry> {
33
+ /** The live pending-permission map. */
34
+ entries: Iterable<readonly [string, T]>
35
+ now: number
36
+ /** Per-tool TTL (hostd fleet-mutation verbs get a longer human-scale window). */
37
+ ttlForTool: (toolName: string) => number
38
+ /**
39
+ * Expire this request: dispatch the deny verdict, strip the card's keyboard,
40
+ * wake the parked turn, record the miss, and drop the entry. Everything the
41
+ * gateway does on a timeout — the sweep decides WHETHER, the caller decides HOW.
42
+ */
43
+ onExpire: (requestId: string, pend: T, ttlMs: number) => void
44
+ }
45
+
46
+ /**
47
+ * Expire every pending permission past its TTL — except the HELD ones, which
48
+ * never expire.
49
+ *
50
+ * Returns the ids it expired (for logging/tests).
51
+ */
52
+ export function sweepPermissionTtl<T extends TtlSweepEntry>(
53
+ deps: TtlSweepDeps<T>,
54
+ ): string[] {
55
+ const expired: string[] = []
56
+ for (const [requestId, pend] of deps.entries) {
57
+ const ttlMs = deps.ttlForTool(pend.tool_name)
58
+ // THE LEASH. `shouldExpirePermission` returns false for any entry marked
59
+ // undeliverable — an ask the operator was never shown. Never auto-approve,
60
+ // never auto-deny, not even on a deadline.
61
+ if (!shouldExpirePermission(pend, deps.now, ttlMs)) continue
62
+ deps.onExpire(requestId, pend, ttlMs)
63
+ expired.push(requestId)
64
+ }
65
+ return expired
66
+ }
@@ -0,0 +1,74 @@
1
+ // #2975 Stage 2 — read-only pre-approval predicate.
2
+ //
3
+ // hostd asks (over the approval-gateway socket, via `check_pre_approved`)
4
+ // whether an EXACT `(agent, diff)` pair is already operator-consented, so it
5
+ // can skip the `config_propose_edit` per-hour rate limit for that persist. This
6
+ // is the READ-ONLY sibling of the `tryAutoResolve` auto-resolve hook wired into
7
+ // `onRequestConfigApproval`: it runs the SAME forge-resistance gate — an EXACT
8
+ // byte-match of the incoming diff against a diff the gateway itself
9
+ // pre-registered when the operator tapped Approve — but it MUST NOT delete the
10
+ // correlation. The single-use delete stays on the real
11
+ // `request_config_approval` auto-resolve, so a peek can never consume the
12
+ // consent (and a rate-limit bypass can never spend a correlation that the
13
+ // actual persist then needs).
14
+ //
15
+ // Extracted as a pure function (stores + helpers injected) so the exact
16
+ // contract — true only for a registered byte-exact pair, and NEVER a mutation —
17
+ // is unit-testable without importing the whole gateway module.
18
+
19
+ /** Minimal read surface this predicate needs from a correlation store. It only
20
+ * ever calls `sweep` (evict-expired, which is not a semantic mutation of a
21
+ * live entry) and `get` (read). It deliberately has NO `delete`/`set` so a
22
+ * mis-edit that mutates the store won't typecheck here. */
23
+ export interface ReadonlyCorrelationStore {
24
+ sweep(now: number): void;
25
+ get(key: string): { unifiedDiff: string } | undefined;
26
+ }
27
+
28
+ export interface PreApprovalCheckDeps {
29
+ /** "🔁 Always allow" correlations, keyed `${agentName}::${rule}`. */
30
+ alwaysAllow: ReadonlyCorrelationStore;
31
+ /** Mental-model-proposal correlations, keyed by mentalModelCorrelationKey. */
32
+ mentalModel: ReadonlyCorrelationStore;
33
+ /** Locate a CANDIDATE always-allow entry by the rule token the diff adds.
34
+ * Shape-based, NOT the security gate — the gate is the byte-match below. */
35
+ extractAddedAllowRule: (unifiedDiff: string) => string | null;
36
+ /** Compose the mental-model correlation key for `(agent, diff)`. */
37
+ mentalModelCorrelationKey: (agentName: string, unifiedDiff: string) => string;
38
+ /** Clock seam (defaults to Date.now()). */
39
+ now?: () => number;
40
+ }
41
+
42
+ /**
43
+ * Returns true iff `(agentName, unifiedDiff)` byte-matches a still-live
44
+ * pre-registered correlation (always-allow OR mental-model). Read-only: it
45
+ * never deletes a correlation. A forged diff — the consented token under a
46
+ * different field, or ANY byte difference — finds no byte-exact entry and
47
+ * returns false, so hostd falls back to the ordinary rate-limited path.
48
+ */
49
+ export function isDiffPreApproved(
50
+ agentName: string,
51
+ unifiedDiff: string,
52
+ deps: PreApprovalCheckDeps,
53
+ ): boolean {
54
+ const now = (deps.now ?? Date.now)();
55
+
56
+ // "🔁 Always allow": locate a candidate by the added rule token, then require
57
+ // an EXACT byte-match of the diff (the token match alone is NOT the gate).
58
+ deps.alwaysAllow.sweep(now);
59
+ const added = deps.extractAddedAllowRule(unifiedDiff);
60
+ if (added) {
61
+ const entry = deps.alwaysAllow.get(`${agentName}::${added}`);
62
+ if (entry && entry.unifiedDiff === unifiedDiff) return true;
63
+ }
64
+
65
+ // Mental-model proposal the operator approved on the proposal card: byte-exact
66
+ // match against the pre-registered diff (the security gate).
67
+ deps.mentalModel.sweep(now);
68
+ const mmEntry = deps.mentalModel.get(
69
+ deps.mentalModelCorrelationKey(agentName, unifiedDiff),
70
+ );
71
+ if (mmEntry && mmEntry.unifiedDiff === unifiedDiff) return true;
72
+
73
+ return false;
74
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * queued-card-store.ts — durable handle for the component-5 queued-status
3
+ * placeholder and the #2995 mid-flight busy-ack card, so a gateway restart
4
+ * mid-turn can reap the orphaned card instead of leaving a permanent stale
5
+ * "⏳ Queued…" line in the chat.
6
+ *
7
+ * Why this exists (#3002): both cards track their sent Telegram message id ONLY
8
+ * in the in-memory `queuedStatusMsgIds` Map in gateway.ts. A gateway/container
9
+ * restart between posting a card and its in-process promote/reap empties that
10
+ * Map, so the card is stranded: a permanent "⏳ Queued — replying in another
11
+ * topic first…" (or busy-ack) line that never gets promoted or deleted, sitting
12
+ * above the real answer.
13
+ *
14
+ * The fix mirrors `activity-card-store.ts` / `status-pin-store.ts` in shape: a
15
+ * tiny durable snapshot on the per-agent state volume (STATE_DIR), written
16
+ * whenever a card is POSTED, cleared the moment it is reaped/deleted in-process.
17
+ * On boot, AFTER this gateway wins the startup mutex (same ordering constraint
18
+ * as the status-pin/activity-card cleanups — this is a shared per-agent file, so
19
+ * a losing double-boot must never touch it), a one-shot reaper DELETES any
20
+ * leftover card via the Bot API and clears the store.
21
+ *
22
+ * DELETION is the honest terminal (deliberate — reap-on-boot only, NO
23
+ * promote-across-restart): a "Queued" / "On it — replying now" claim is ALWAYS
24
+ * wrong after a restart (the turn it referred to is over or being re-run from
25
+ * scratch), so the only correct move is to remove the card. We do NOT attempt to
26
+ * re-promote or re-adopt it across the restart; the resumed/replayed turn owns
27
+ * fresh surfaces.
28
+ *
29
+ * Crash-safety: write-tmp + atomic rename (identical convention to
30
+ * activity-card-store.ts / status-pin-store.ts). A corrupt or unreadable file
31
+ * fails open to `[]` — never crashes boot, worst case an orphan isn't reaped
32
+ * this boot, no worse than pre-fix behaviour.
33
+ *
34
+ * IDEMPOTENCY: the reaper deletes each record from the on-disk store BEFORE
35
+ * attempting its Telegram delete (delete-first, mirroring the activity-card
36
+ * reaper), so a crash mid-reap or a second boot re-reading the file can never
37
+ * re-issue a delete for a message already handled. A Telegram delete of an
38
+ * already-gone message is itself harmless (the caller tolerates "message not
39
+ * found"); delete-first simply keeps the store honest.
40
+ */
41
+
42
+ export interface QueuedCardStoreFsSeam {
43
+ readFileSync: (path: string) => string
44
+ writeFileSync: (path: string, data: string) => void
45
+ /** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
46
+ * the snapshot. */
47
+ renameSync: (from: string, to: string) => void
48
+ existsSync: (path: string) => boolean
49
+ }
50
+
51
+ /** One persisted queued/busy-ack card: enough to delete the orphaned Telegram
52
+ * message on a future boot, without any live turn state. */
53
+ export interface QueuedCardRecord {
54
+ /** Stable per-topic key (`statusKey(chatId, threadId)` shape) — also the
55
+ * upsert key: a fresh post for the same topic replaces any stale row. */
56
+ key: string
57
+ chatId: string
58
+ /** Forum topic id, or null for a DM / General-topic card. */
59
+ threadId: number | null
60
+ messageId: number
61
+ }
62
+
63
+ interface SnapshotEnvelope {
64
+ v: 1
65
+ cards: QueuedCardRecord[]
66
+ }
67
+
68
+ function isCardRow(x: unknown): x is QueuedCardRecord {
69
+ if (x == null || typeof x !== 'object') return false
70
+ const o = x as Record<string, unknown>
71
+ return (
72
+ typeof o.key === 'string' &&
73
+ o.key.length > 0 &&
74
+ typeof o.chatId === 'string' &&
75
+ o.chatId.length > 0 &&
76
+ (o.threadId === null || typeof o.threadId === 'number') &&
77
+ typeof o.messageId === 'number'
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Load the persisted card set. Returns [] on a missing, unreadable, or
83
+ * malformed file — fail-open, never throws.
84
+ */
85
+ export function loadQueuedCards(
86
+ path: string,
87
+ fs: QueuedCardStoreFsSeam,
88
+ ): QueuedCardRecord[] {
89
+ if (!fs.existsSync(path)) return []
90
+ let raw = ''
91
+ try {
92
+ raw = fs.readFileSync(path)
93
+ } catch {
94
+ return []
95
+ }
96
+ let parsed: unknown
97
+ try {
98
+ parsed = JSON.parse(raw)
99
+ } catch {
100
+ return []
101
+ }
102
+ if (parsed == null || typeof parsed !== 'object') return []
103
+ const env = parsed as Record<string, unknown>
104
+ if (env.v !== 1 || !Array.isArray(env.cards)) return []
105
+ return env.cards.filter(isCardRow)
106
+ }
107
+
108
+ /**
109
+ * Persist the card set atomically (write sibling tmp → rename). Never throws —
110
+ * a write failure is logged and the store degrades to in-memory-only for this
111
+ * process (same contract as `activity-card-store.ts`).
112
+ */
113
+ export function persistQueuedCards(
114
+ path: string,
115
+ fs: QueuedCardStoreFsSeam,
116
+ snapshot: readonly QueuedCardRecord[],
117
+ log: (line: string) => void = (l) => process.stderr.write(l),
118
+ ): void {
119
+ const env: SnapshotEnvelope = { v: 1, cards: [...snapshot] }
120
+ const tmp = path + '.tmp'
121
+ try {
122
+ fs.writeFileSync(tmp, JSON.stringify(env))
123
+ fs.renameSync(tmp, path)
124
+ } catch (err) {
125
+ log(
126
+ `queued-card-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
127
+ `durability degraded to in-memory\n`,
128
+ )
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Upsert the card row for `key` (replacing any stale row for the same topic).
134
+ * Called the moment a queued-status / busy-ack card is POSTED (its message id
135
+ * is recorded into the in-memory `queuedStatusMsgIds` Map).
136
+ */
137
+ export function writeQueuedCardRecord(
138
+ path: string,
139
+ fs: QueuedCardStoreFsSeam,
140
+ record: QueuedCardRecord,
141
+ log: (line: string) => void = (l) => process.stderr.write(l),
142
+ ): void {
143
+ const current = loadQueuedCards(path, fs)
144
+ const others = current.filter((c) => c.key !== record.key)
145
+ persistQueuedCards(path, fs, [...others, record], log)
146
+ }
147
+
148
+ /**
149
+ * Remove the card row for `key`, if present. Called the moment the card is
150
+ * reaped/deleted in-process (reapQueuedStatus), and by the boot reaper (BEFORE
151
+ * attempting the Telegram delete — the idempotency guard).
152
+ *
153
+ * When `messageId` is supplied, only a row matching BOTH `key` AND `messageId`
154
+ * is removed (reap-race guard): during a slow multi-record reap a fresh live
155
+ * turn could post a NEW card under the same key (its own, different messageId);
156
+ * a key-only clear would then drop the live card's durable protection. Omit
157
+ * `messageId` only when you mean "clear whatever row exists for this topic".
158
+ */
159
+ export function clearQueuedCardRecord(
160
+ path: string,
161
+ fs: QueuedCardStoreFsSeam,
162
+ key: string,
163
+ messageId?: number,
164
+ log: (line: string) => void = (l) => process.stderr.write(l),
165
+ ): void {
166
+ const current = loadQueuedCards(path, fs)
167
+ if (current.length === 0) return
168
+ const next = current.filter(
169
+ (c) => c.key !== key || (messageId !== undefined && c.messageId !== messageId),
170
+ )
171
+ if (next.length === current.length) return
172
+ persistQueuedCards(path, fs, next, log)
173
+ }
174
+
175
+ /**
176
+ * Boot-time orphan reaper, extracted as a pure routine over injected seams
177
+ * (mirrors `runActivityCardBootReaper`). The gateway's thin wrapper binds the
178
+ * live fs, a Telegram delete call, and the logger.
179
+ *
180
+ * CRITICAL ordering, identical constraint to the status-pin / activity-card
181
+ * cleanups: the caller MUST only invoke this AFTER winning the startup mutex.
182
+ * The store is a shared per-agent file; a losing double-boot running this would
183
+ * delete a card the still-alive holder's in-flight turn still legitimately owns.
184
+ *
185
+ * Deletes each record from disk BEFORE attempting its Telegram delete (not
186
+ * after), so a crash mid-reap, or a second boot re-reading the file, can never
187
+ * re-issue a delete. A `deleteCard` failure (message already gone, chat
188
+ * unreachable, permissions changed) is swallowed — non-fatal, logged, and never
189
+ * re-attempted since the record is already gone. Returns counts for
190
+ * logging/testing.
191
+ */
192
+ export async function runQueuedCardBootReaper(args: {
193
+ path: string
194
+ fs: QueuedCardStoreFsSeam
195
+ deleteCard: (record: QueuedCardRecord) => Promise<unknown>
196
+ log?: (line: string) => void
197
+ }): Promise<{ deleted: number; total: number }> {
198
+ const log = args.log ?? ((l: string) => process.stderr.write(l))
199
+ const persisted = loadQueuedCards(args.path, args.fs)
200
+ if (persisted.length === 0) return { deleted: 0, total: 0 }
201
+ let deleted = 0
202
+ for (const record of persisted) {
203
+ // Delete BEFORE attempting the Telegram delete — the idempotency guard.
204
+ clearQueuedCardRecord(args.path, args.fs, record.key, record.messageId, log)
205
+ try {
206
+ await args.deleteCard(record)
207
+ deleted++
208
+ } catch (err) {
209
+ log(
210
+ `queued-card-store: boot reaper delete failed ` +
211
+ `(chat=${record.chatId} msg=${record.messageId}): ` +
212
+ `${(err as Error).message}\n`,
213
+ )
214
+ }
215
+ }
216
+ return { deleted, total: persisted.length }
217
+ }
@@ -40,6 +40,8 @@ import { isValidModelArg } from './model-command.js'
40
40
  export const SESSION_MODEL_FILE = '.session-model'
41
41
  export const RELAUNCH_MODEL_INTENT_FILE = '.relaunch-model-intent'
42
42
  export const CONFIGURED_DEFAULT_MODEL_FILE = '.configured-default-model'
43
+ /** Crashloop self-heal counter (start.sh stamps `<count> <epoch>` per fast boot). */
44
+ export const SESSION_MODEL_BOOT_ATTEMPTS_FILE = '.session-model-boot-attempts'
43
45
 
44
46
  export type RelaunchModelIntent = 'keep' | 'revert'
45
47
 
@@ -142,7 +144,30 @@ export function clearSessionModelFile(agentDir: string): void {
142
144
  // #3042 item 4: also drop the kept-alert dedup sentinel so a future
143
145
  // override of the same name re-alerts on its first kept boot.
144
146
  rmSync(join(agentDir, '.session-model-kept-notified'), { force: true })
145
- rmSync(join(agentDir, '.session-model-boot-attempts'), { force: true })
147
+ rmSync(join(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true })
148
+ } catch {
149
+ /* best-effort */
150
+ }
151
+ }
152
+
153
+ /**
154
+ * #3043 item 2: clear ONLY the crashloop boot-attempts counter — a positive
155
+ * health signal from the gateway, not a carrier change.
156
+ *
157
+ * start.sh's self-heal (start.sh.hbs "Override crashloop self-heal") increments
158
+ * the counter on every boot that re-enters within 150s with the override still
159
+ * active, and clears a healthy override after 3 fast boots. That window can't
160
+ * tell a genuine crashloop from three quick OPERATOR hand-bounces of a healthy
161
+ * agent — both look like fast successive boots — so three deliberate restarts
162
+ * would spuriously wipe a working override. A boot that reaches bridge
163
+ * registration is proven healthy (the session came all the way up and the
164
+ * bridge connected), so the gateway deletes the counter there. Only boots that
165
+ * genuinely FAIL before the bridge registers now accumulate toward the 3-strike
166
+ * clear. Best-effort; absent file is fine.
167
+ */
168
+ export function clearSessionModelBootAttempts(agentDir: string): void {
169
+ try {
170
+ rmSync(join(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true })
146
171
  } catch {
147
172
  /* best-effort */
148
173
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * turn-end gate-wedge backstop (#2094 finding 1).
3
+ *
4
+ * The `turn_end` session-event handler in `gateway.ts` runs a long
5
+ * synchronous prelude (narrative dedup, answer-stream finalize,
6
+ * `redactOutboundText`, `progressDriver?.takeOverCard`, …) before it reaches
7
+ * the branch that calls `endCurrentTurnAtomic` → `purgeReactionTracking` —
8
+ * the canonical op that deletes this turn's `activeTurnStartedAt` entry and
9
+ * re-opens the #1556 inbound buffer gate.
10
+ *
11
+ * If ANY of those pre-purge ops throws, the handler exits before the canonical
12
+ * purge runs, leaving `activeTurnStartedAt` (and the mirrored `claudeBusyKeys`
13
+ * that `purgeReactionTracking` drains) populated. The #1556 inbound gate then
14
+ * wedges CLOSED: every subsequent inbound buffers, and only the 300 s
15
+ * silence-poke fallback eventually recovers it (degraded, not permanent).
16
+ *
17
+ * `withTurnEndGateBackstop` wraps the handler body in a guarded `try/finally`.
18
+ * The `finally` acts ONLY when a throw skipped the canonical purge — detected
19
+ * by the turn's key still being present in `activeTurnStartedAt`. On the happy
20
+ * path the canonical purge already deleted the key, so the backstop is a
21
+ * no-op. The original error is NOT swallowed: `finally` re-raises it after the
22
+ * backstop purge, so upstream error handling / unhandled-rejection policy is
23
+ * unchanged.
24
+ */
25
+ export interface TurnEndGateBackstopDeps<T> {
26
+ /** True while `key`'s `activeTurnStartedAt` entry is still populated. */
27
+ hasActiveTurn: (key: string) => boolean
28
+ /** The canonical purge (production wires this to `purgeReactionTracking`). */
29
+ purge: (key: string, endingTurn: T | undefined) => void
30
+ /** Optional structured log emitted only when the backstop actually fires. */
31
+ log?: (msg: string) => void
32
+ }
33
+
34
+ /**
35
+ * Run `body` (the synchronous turn_end handler body) under a guarded finally.
36
+ *
37
+ * @param key the turn's status key, or null when there was no live
38
+ * turn to end (then the backstop can never fire).
39
+ * @param endingTurn the captured turn atom, forwarded to `purge` so the
40
+ * shadow trace sees the authoritative `replyCalled` flag.
41
+ */
42
+ export function withTurnEndGateBackstop<T>(
43
+ key: string | null,
44
+ endingTurn: T | null,
45
+ body: () => void,
46
+ deps: TurnEndGateBackstopDeps<T>,
47
+ ): void {
48
+ try {
49
+ body()
50
+ } finally {
51
+ if (key != null && deps.hasActiveTurn(key)) {
52
+ deps.log?.(
53
+ `telegram gateway: turn_end backstop-purge — a pre-purge throw left ` +
54
+ `the #1556 inbound gate wedged (key=${key}); forcing purge (#2094)`,
55
+ )
56
+ deps.purge(key, endingTurn ?? undefined)
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,95 @@
1
+ // #1667 — pure decision core for the `turn_end` handler's answer-delivery
2
+ // gate (originally landed inline in `gateway.ts` by #1664).
3
+ //
4
+ // The gateway's `case 'turn_end':` block resolves, in strict order, which of
5
+ // four terminal dispositions a just-ended turn takes:
6
+ //
7
+ // 1. silent-marker suppression — the model's only output was a silent-turn
8
+ // sentinel (NO_REPLY / HEARTBEAT_OK, or composite/ trailing silent
9
+ // noise). `decideTurnFlush` returns `{kind:'skip', reason:'silent-marker'}`.
10
+ // The gateway suppresses the card without a "Done" edit and returns.
11
+ // 2. turn-flush — the model skipped reply/stream_reply but
12
+ // left substantive terminal prose; `decideTurnFlush` returns
13
+ // `{kind:'flush', text}`. The gateway delivers that prose as the answer
14
+ // (setting `finalAnswerDelivered = true`) and returns.
15
+ // 3. re-prompt (#1664 gate) — the reply-called tail. The turn neither
16
+ // suppressed nor flushed, and `finalAnswerDelivered === false`: the model
17
+ // sent only an interim ack (or nothing) and never delivered a real
18
+ // answer. The silent-end machinery re-prompts so the answer lands.
19
+ // 4. none — the reply-called tail with
20
+ // `finalAnswerDelivered === true`: a genuine final answer was delivered;
21
+ // no re-prompt.
22
+ //
23
+ // This module hoists that ordering into a single pure function so it is
24
+ // testable against the code the gateway actually runs (the gateway's
25
+ // `turn_end` case delegates to `decideTurnEndGate`). It is a strictly
26
+ // behaviour-preserving extraction — the exact conditionals and their order,
27
+ // no logic change.
28
+ //
29
+ // What deliberately remains inline in the gateway (side effects, not
30
+ // decisions): the synthetic-backstop `durationMs === -1` suppression, the
31
+ // `turn == null` guard, the answer-stream materialize/retract block (which may
32
+ // set `finalAnswerDelivered = true` BEFORE this gate is consulted), and every
33
+ // disposition's actual teardown / send / telemetry. Those are not part of the
34
+ // decision — they are what each decision drives. The gate is computed once,
35
+ // immediately after `flushDecision`, reading `finalAnswerDelivered` at its
36
+ // tail value (the answer-stream branch has already run by then; the flush
37
+ // branch, which also sets it, is not yet entered and does not influence the
38
+ // gate's own outcome).
39
+
40
+ import type { FlushDecision } from '../turn-flush-safety.js'
41
+
42
+ /**
43
+ * The terminal disposition of a `turn_end`, in the gateway's evaluation order.
44
+ * - `silent_end` → silent-marker suppression path (no reply, no card edit).
45
+ * - `flush` → turn-flush delivers the model's terminal prose as the answer.
46
+ * - `reprompt` → #1664 gate: no final answer delivered → silent-end re-prompt.
47
+ * - `none` → final answer delivered; normal terminal, no re-prompt.
48
+ */
49
+ export type TurnEndDecision = 'silent_end' | 'flush' | 'reprompt' | 'none'
50
+
51
+ export interface TurnEndGateSnapshot {
52
+ /** The result of `decideTurnFlush(...)` for this turn (or the synthetic
53
+ * `{kind:'skip', reason:'reply-called'}` the gateway substitutes when the
54
+ * answer-stream already materialised the answer). */
55
+ flushDecision: FlushDecision
56
+ /**
57
+ * `turn.finalAnswerDelivered` at the point the gate is consulted — i.e. after
58
+ * the answer-stream materialize branch may have set it true, but reflecting
59
+ * whether a genuine final answer (not a bare interim ack) has been delivered.
60
+ * Only consulted on the reply-called tail (neither silent-marker nor flush).
61
+ */
62
+ finalAnswerDelivered: boolean
63
+ }
64
+
65
+ /**
66
+ * Pure decision core for the `turn_end` answer-delivery gate. Mirrors the
67
+ * gateway's branch ordering exactly: silent-marker early-return, then
68
+ * turn-flush early-return, then the `finalAnswerDelivered === false` re-prompt
69
+ * gate, else no re-prompt. No side effects.
70
+ */
71
+ export function decideTurnEndGate(snapshot: TurnEndGateSnapshot): TurnEndDecision {
72
+ const { flushDecision, finalAnswerDelivered } = snapshot
73
+
74
+ // 1. Silent-marker suppression takes precedence: the model's only output was
75
+ // a silent-turn sentinel. Suppress without a card edit; never re-prompt.
76
+ if (flushDecision.kind === 'skip' && flushDecision.reason === 'silent-marker') {
77
+ return 'silent_end'
78
+ }
79
+
80
+ // 2. Turn-flush: the model left substantive terminal prose without calling
81
+ // the reply tool. Deliver it as the answer; the turn IS answered.
82
+ if (flushDecision.kind === 'flush') {
83
+ return 'flush'
84
+ }
85
+
86
+ // 3. Reply-called tail. #1664: the trigger is "no final answer delivered",
87
+ // not "zero outbound". An interim-ack-only turn (reply called, but
88
+ // finalAnswerDelivered still false) re-prompts; a delivered final answer
89
+ // does not. A zero-outbound turn also lands here with
90
+ // finalAnswerDelivered === false and re-prompts.
91
+ if (finalAnswerDelivered === false) {
92
+ return 'reprompt'
93
+ }
94
+ return 'none'
95
+ }
@@ -16,8 +16,16 @@
16
16
  * a mid-turn reply's `finally { stopTypingLoop }` would kill it and the chat
17
17
  * would go dark for the rest of the turn. A dedicated map makes the turn loop
18
18
  * structurally immune to those stops — only `stop` (the canonical turn-end)
19
- * clears it. The redundant `typing` pings while a reply is mid-flight are
20
- * harmless (same action, and `sendChatAction` is cheap).
19
+ * clears it.
20
+ *
21
+ * The map is separate; the SENDS are not (#3084). This file used to claim the
22
+ * redundant pings were "harmless — same action, and `sendChatAction` is cheap".
23
+ * They are not cheap: they spend the per-bot flood budget the REPLIES need, and
24
+ * on 2026-07-11 the two loops together earned a 4.6-hour flood ban. The gateway
25
+ * now injects a `sendChatAction` that routes through the SHARED typing emitter
26
+ * (`typing-emitter.ts`), which enforces one action per chat key per refresh
27
+ * window across BOTH loops. This factory keeps its lifecycle semantics —
28
+ * fire-on-start, refresh, stop-at-turn-end — and the floor lives in the emitter.
21
29
  *
22
30
  * Extracted into a factory so the lifecycle (fires on start, refreshes on the
23
31
  * interval, stops on turn-end, NEVER leaks a refresh interval after the turn
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { GrammyError, HttpError } from 'grammy'
14
14
 
15
+ import { FLOOD_WAIT_ACTIVE } from '../retry-api-call.js'
16
+
15
17
  export type RejectionAction = 'shutdown' | 'log_only'
16
18
 
17
19
  export interface RejectionPolicyOptions {
@@ -65,6 +67,17 @@ export function classifyRejection(
65
67
  : err instanceof HttpError
66
68
  if (isHttp) return 'log_only'
67
69
 
70
+ // FLOOD_WAIT_ACTIVE (#3084): retry-api-call throws this plain Error marker
71
+ // instead of sleeping through a multi-hour per-bot flood ban (overlord saw
72
+ // retry_after = 16739s ≈ 4.6h). It is the SAME condition as the GrammyError
73
+ // 429 handled below — just surfaced as a non-retryable marker rather than a
74
+ // raw grammy error — so it takes the same posture. A leaked one (a
75
+ // fire-and-forget send that wasn't wrapped in swallowingApiCall) must NOT
76
+ // crash the gateway: the bot is already rate-limited, and a crash→restart
77
+ // fires MORE sends into the open window and extends the ban, which is the
78
+ // exact amplification the #2923 circuit breaker exists to stop.
79
+ if (err instanceof Error && err.message === FLOOD_WAIT_ACTIVE) return 'log_only'
80
+
68
81
  if (!isGrammy) return 'shutdown'
69
82
 
70
83
  const e = err as { error_code?: number; description?: string }