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
@@ -125,6 +125,29 @@ export function hostdRequestId(prefix: string): string {
125
125
  return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
126
126
  }
127
127
 
128
+ /**
129
+ * #1841 — attach an operator-passphrase attestation to a hostd request.
130
+ *
131
+ * Mirrors the existing gateway→broker plaintext-forward used for
132
+ * `vault_request_save`: after `/vault unlock` the gateway caches the
133
+ * operator passphrase per-chat, and forwards it on the wire when an admin
134
+ * agent invokes a privileged hostd verb. hostd never holds the passphrase —
135
+ * it re-forwards this value to the vault broker and treats a broker DENIED
136
+ * as a gate failure (see `AttestVerifier` in `src/host-control/server.ts`).
137
+ *
138
+ * Returns the request unchanged when no passphrase is cached (the
139
+ * feature-off / not-yet-unlocked posture stays byte-identical to today).
140
+ * Pure — returns a new object rather than mutating in place, so a caller
141
+ * can pass a request literal.
142
+ */
143
+ export function withOperatorAttestation<T extends HostdRequest>(
144
+ req: T,
145
+ passphrase: string | undefined,
146
+ ): T {
147
+ if (!passphrase) return req;
148
+ return { ...req, operator_passphrase: passphrase };
149
+ }
150
+
128
151
  /**
129
152
  * Single-shot `get_status` snapshot for `targetRequestId` (NOT a
130
153
  * poll-until-terminal — {@link pollHostdStatus} does that). Used by
@@ -4,22 +4,63 @@
4
4
  * instead of resuming a stale, context-heavy thread. Long-term memory lives in
5
5
  * Hindsight, so a clear loses only the in-session scratch.
6
6
  *
7
+ * WHAT "IDLE" MEANS HERE (the #3084/#3107 semantics, fixed 2026-07-11):
8
+ *
9
+ * Idle = nothing has happened since the last thing happened.
10
+ *
11
+ * "Something happened" is ANY observable event on this agent:
12
+ * - an inbound Telegram message,
13
+ * - a cron / inject_inbound fire,
14
+ * - ANY event off the claude session stream — turn start, thinking, a tool
15
+ * call, a tool result, streamed text, a sub-agent event, turn end.
16
+ *
17
+ * It emphatically does NOT mean "no turn has *started* recently". That was the
18
+ * original reading and it inverted the invariant: the idle window was measured
19
+ * from a turn's START, so the longer and harder an agent worked, the more
20
+ * certain the wipe. On 2026-07-11 `overlord` worked for three hours straight
21
+ * (subagents, ~25 PRs) after its last recorded turn boundary and was `/clear`ed
22
+ * the moment the window elapsed — its whole working context destroyed for being
23
+ * productive. See `reference/jobs/always-available.md`.
24
+ *
25
+ * Two independent clocks feed the decision, and the window is measured from
26
+ * whichever is later:
27
+ * - `lastActivityAt` — stamped on every event above (the load-bearing fix:
28
+ * an agent that is DOING something is not idle, turn boundaries or not).
29
+ * - `lastTurnEndedAt` — stamped when a turn ends (defence in depth: the
30
+ * instant a turn ends, `turnInFlight` stops suppressing the clear, so a
31
+ * turn that ran longer than the window would otherwise be wiped on the very
32
+ * next tick — the timer must never be "already expired" at turn end).
33
+ *
34
+ * A genuinely idle session — no inbound, no cron, no model activity for the
35
+ * whole window — is still cleared. That is the feature and it stays.
36
+ *
7
37
  * Sibling of proactive-compact.ts (occupancy-driven /compact at the turn-end
8
38
  * idle gate). This one is wall-clock-driven: it fires `/clear` from a periodic
9
39
  * interval because a fully-idle agent never ends a turn, so the turn-end gate
10
40
  * alone would never see it. Both inject via the same primitive and both refuse
11
41
  * to fire mid-turn (turnInFlight guard).
12
42
  *
13
- * The decider is pure so the fire-once / re-arm / not-mid-turn / disabled logic
14
- * is unit-tested without the gateway.
43
+ * The decider and the event classifier are pure so the whole
44
+ * fire-once / re-arm / not-mid-turn / not-just-worked / disabled logic is
45
+ * unit-tested without the gateway.
15
46
  */
16
47
 
17
48
  /** Default idle window when `session.idle_clear_after` is unset (3h). ON by default. */
18
49
  export const DEFAULT_IDLE_CLEAR_MS = 3 * 60 * 60 * 1000;
19
50
 
20
51
  export interface IdleClearState {
21
- /** Epoch ms of the last activity (inbound, turn start, cron fire). */
52
+ /**
53
+ * Epoch ms of the last activity: inbound, cron fire, or ANY claude session
54
+ * stream event (turn start, tool call, tool result, text, sub-agent event,
55
+ * turn end). Not just turn starts — see the header.
56
+ */
22
57
  lastActivityAt: number;
58
+ /**
59
+ * Epoch ms when a turn last ended (null = no turn has ended this process).
60
+ * The window is measured from `max(lastActivityAt, lastTurnEndedAt)` so a turn
61
+ * that outran the idle window is not cleared the instant it finishes.
62
+ */
63
+ lastTurnEndedAt: number | null;
23
64
  /** Idle window in ms. <= 0 disables auto-clear. */
24
65
  idleClearMs: number;
25
66
  /** Already auto-cleared since the last activity? Prevents re-clearing every tick. */
@@ -32,11 +73,17 @@ export interface IdleClearDecision {
32
73
  clear: boolean;
33
74
  }
34
75
 
76
+ /** Epoch ms of the last thing that happened — activity or a turn ending. */
77
+ export function lastEventAt(state: IdleClearState): number {
78
+ return Math.max(state.lastActivityAt, state.lastTurnEndedAt ?? 0);
79
+ }
80
+
35
81
  /**
36
82
  * Decide whether to auto-clear this evaluation. Fires exactly once per idle
37
83
  * period: only when enabled, not mid-turn, not already cleared, and the idle
38
- * window has elapsed. The caller sets `alreadyCleared` on fire and resets it
39
- * (with `lastActivityAt`) on the next activity to re-arm.
84
+ * window has elapsed since the last thing that happened. The caller sets
85
+ * `alreadyCleared` on fire and resets it (with `lastActivityAt`) on the next
86
+ * activity to re-arm.
40
87
  */
41
88
  export function decideIdleClear(
42
89
  state: IdleClearState,
@@ -45,10 +92,47 @@ export function decideIdleClear(
45
92
  if (state.idleClearMs <= 0) return { clear: false }; // disabled
46
93
  if (state.turnInFlight) return { clear: false }; // never mid-turn
47
94
  if (state.alreadyCleared) return { clear: false }; // once per idle period
48
- if (now - state.lastActivityAt < state.idleClearMs) return { clear: false };
95
+ // Measured from the LAST thing that happened activity or a turn ending —
96
+ // never from a turn's start. A turn that ran longer than the window ends with
97
+ // a fresh turn-end stamp, so it is not idle the moment it finishes.
98
+ if (now - lastEventAt(state) < state.idleClearMs) return { clear: false };
49
99
  return { clear: true };
50
100
  }
51
101
 
102
+ /** What a claude session-stream event means for the idle clocks. */
103
+ export interface IdleEventSignal {
104
+ /** Genuine agent activity → stamp `lastActivityAt` (and re-arm). */
105
+ activity: boolean;
106
+ /** A turn ended → stamp `lastTurnEndedAt`. */
107
+ turnEnded: boolean;
108
+ }
109
+
110
+ /**
111
+ * Classify a session-stream event for the idle clocks. Pure, so the gateway's
112
+ * dispatcher stays a one-liner and the semantics are testable.
113
+ *
114
+ * EVERY genuine stream event is activity — thinking, tool_use, tool_result,
115
+ * text, model, enqueue (turn start), turn_end, and all `sub_agent_*` kinds.
116
+ * The claude session emits nothing at all while the agent is truly idle, so
117
+ * "any event = activity" is exactly the intended reading and cannot keep a
118
+ * dormant session alive forever.
119
+ *
120
+ * The one exception is the gateway's OWN synthetic `turn_end` (durationMs ===
121
+ * -1), re-dispatched by the orphaned-reply backstop: it is the gateway talking
122
+ * to itself, not the model doing something, so it does not stamp activity — but
123
+ * it DOES end the turn (the in-flight gate opens), so it stamps the turn-end
124
+ * clock. Same `!(turn_end && durationMs === -1)` predicate the per-turn liveness
125
+ * stamp already uses for "genuine stream event".
126
+ */
127
+ export function classifyIdleEvent(
128
+ kind: string,
129
+ durationMs?: number,
130
+ ): IdleEventSignal {
131
+ const turnEnded = kind === "turn_end";
132
+ const synthetic = turnEnded && durationMs === -1;
133
+ return { activity: !synthetic, turnEnded };
134
+ }
135
+
52
136
  /**
53
137
  * Parse a `^\d+[smh]$` duration (the SessionSchema format, e.g. "3h", "30m",
54
138
  * "7200s") to ms. Returns null on a malformed string so the caller can fall
@@ -27,6 +27,16 @@
27
27
  * buffer: agent=X buffered ...` etc.) is the validation that the
28
28
  * machine is bit-identical with reality.
29
29
  *
30
+ * Trace verbosity (#3025): the TTL `tick` event fires every ~30s and on a
31
+ * healthy idle agent emits a zero-signal `event=tick effects=[]
32
+ * global=bridge_alive_idle` line. Those no-op ticks are SUPPRESSED by
33
+ * default (they filled gateway-supervisor.log to 555MB on clerk). Set
34
+ * `SWITCHROOM_GW_TRACE=1` to restore the full firehose — every tick, poll,
35
+ * and event line — when debugging the delivery/poll path. The gate lives
36
+ * in `../shared/gw-trace-gate.ts` and also governs the `tg-post` poll
37
+ * heartbeats. Real events, turn-expiring ticks, and in-turn ticks always
38
+ * log regardless of the flag.
39
+ *
30
40
  * Toggle off via `SWITCHROOM_DELIVERY_MACHINE_SHADOW=0` — a kill
31
41
  * switch for the case where the shadow emits prove problematic
32
42
  * (e.g., trace volume too high). The default is ON.
@@ -39,6 +49,7 @@ import {
39
49
  initialState,
40
50
  transition,
41
51
  } from './inbound-delivery-machine.js'
52
+ import { shouldEmitShadowTrace } from '../shared/gw-trace-gate.js'
42
53
 
43
54
  let state: State = initialState()
44
55
  const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== '0'
@@ -101,11 +112,21 @@ export function shadowEmit(event: Event): readonly Effect[] {
101
112
  // low volume (one line per gateway event, not per effect). The
102
113
  // format matches gateway.ts's existing `tg-post method=...` shape
103
114
  // so log aggregation can pick it up without a new parser.
104
- const effectKinds = result.effects.map((e) => e.kind).join(',')
105
- const eventDetail = formatEventDetail(event)
106
- process.stderr.write(
107
- `gw-trace shadow event=${event.kind}${eventDetail} effects=[${effectKinds}] global=${state.global.kind} perKeySize=${state.perKey.size}\n`,
108
- )
115
+ // #3025: the TTL `tick` event fires every ~30s. On a healthy idle
116
+ // agent it produces no effects and leaves the machine idle — a
117
+ // zero-signal heartbeat that (unrotated) grew this log to 555MB on
118
+ // clerk. Suppress those no-op ticks unless the operator opted into
119
+ // the full firehose (SWITCHROOM_GW_TRACE=1). Real events, ticks that
120
+ // expire a turn (non-empty effects), and in-turn ticks still log.
121
+ if (
122
+ shouldEmitShadowTrace(event.kind, result.effects.length, state.global.kind)
123
+ ) {
124
+ const effectKinds = result.effects.map((e) => e.kind).join(',')
125
+ const eventDetail = formatEventDetail(event)
126
+ process.stderr.write(
127
+ `gw-trace shadow event=${event.kind}${eventDetail} effects=[${effectKinds}] global=${state.global.kind} perKeySize=${state.perKey.size}\n`,
128
+ )
129
+ }
109
130
  return result.effects
110
131
  } catch (err) {
111
132
  process.stderr.write(
@@ -103,6 +103,14 @@ function shapeReply(
103
103
  accent: 'issue',
104
104
  }
105
105
  }
106
+ if (code === 'args_not_allowed') {
107
+ // #730 — a bare-verb-only command was injected with trailing args.
108
+ // errorMessage is already actionable ("args not permitted for /memory …").
109
+ return {
110
+ body: `${verbHtml} — ${deps.escapeHtml(msg)}`,
111
+ accent: 'issue',
112
+ }
113
+ }
106
114
  if (code === 'session_missing') {
107
115
  return {
108
116
  body: 'tmux session not found — agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.',
@@ -153,6 +153,22 @@ export interface PendingPermissionStatusEvent {
153
153
  requestId?: string;
154
154
  }
155
155
 
156
+ /**
157
+ * #2975 Stage 2 — the gateway's answer to a hostd `check_pre_approved` query.
158
+ * Read-only: `preApproved: true` means the queried `(agentName, unifiedDiff)`
159
+ * pair byte-matches a correlation the gateway pre-registered when the operator
160
+ * approved a mental-model proposal (or "🔁 Always allow"), so hostd may skip
161
+ * the `config_propose_edit` rate limit for that persist. Sourced from the
162
+ * gateway's in-memory correlation maps WITHOUT mutating them (the single-use
163
+ * auto-resolve delete happens later on the real `request_config_approval`).
164
+ */
165
+ export interface PreApprovedResultEvent {
166
+ type: "pre_approved_result";
167
+ /** Echoes the correlationId from the originating check_pre_approved query. */
168
+ correlationId: string;
169
+ preApproved: boolean;
170
+ }
171
+
156
172
  export type GatewayToClient =
157
173
  | InboundMessage
158
174
  | PermissionEvent
@@ -163,7 +179,8 @@ export type GatewayToClient =
163
179
  | Ms365ApprovalPostedEvent
164
180
  | ConfigApprovalResolvedEvent
165
181
  | RolloutStatusPostedEvent
166
- | PendingPermissionStatusEvent;
182
+ | PendingPermissionStatusEvent
183
+ | PreApprovedResultEvent;
167
184
 
168
185
  // === Bridge (Client) -> Gateway messages ===
169
186
 
@@ -421,7 +438,7 @@ export interface RequestConfigApprovalMessage {
421
438
  export interface RequestConfigFinalizeMessage {
422
439
  type: "request_config_finalize";
423
440
  requestId: string;
424
- outcome: "applied" | "reconcile_failed_rolled_back";
441
+ outcome: "applied" | "aborted_config_changed" | "reconcile_failed_rolled_back";
425
442
  /** Optional short diagnostic appended to the card body. */
426
443
  detail?: string;
427
444
  /**
@@ -596,6 +613,31 @@ export interface QueryPendingPermissionMessage {
596
613
  correlationId: string;
597
614
  }
598
615
 
616
+ /**
617
+ * #2975 Stage 2 — hostd → gateway read-only pre-approval query. hostd asks
618
+ * whether this EXACT `(agentName, unifiedDiff)` pair is already operator-
619
+ * consented (pre-registered on the gateway when the operator approved a
620
+ * mental-model proposal / tapped "🔁 Always allow"), so it can skip the
621
+ * `config_propose_edit` per-hour rate limit for that persist.
622
+ *
623
+ * Trust model: identical to `query_pending_permission` — the socket is
624
+ * per-agent inside the container; `agentName` is validated server-side AND
625
+ * checked against the gateway's own SWITCHROOM_AGENT_NAME in the handler. This
626
+ * message can NEVER mutate gateway state: the handler only *reads* the
627
+ * correlation maps (byte-exact match), the same forge-resistance gate the
628
+ * auto-resolve path enforces. hostd answers `pre_approved_result`; the query
629
+ * is not reachable via any agent-facing hostd verb (it only ever travels
630
+ * hostd → gateway over the approval-gateway socket).
631
+ */
632
+ export interface CheckPreApprovedMessage {
633
+ type: "check_pre_approved";
634
+ agentName: string;
635
+ /** Caller-generated correlation id, echoed in the reply. */
636
+ correlationId: string;
637
+ /** Full unified diff to byte-match against the pre-registered correlations. */
638
+ unifiedDiff: string;
639
+ }
640
+
599
641
  export type ClientToGateway =
600
642
  | RegisterMessage
601
643
  | ToolCallMessage
@@ -616,4 +658,5 @@ export type ClientToGateway =
616
658
  | PostSkillProposalMessage
617
659
  | RolloutStatusPostMessage
618
660
  | RolloutStatusEditMessage
619
- | QueryPendingPermissionMessage;
661
+ | QueryPendingPermissionMessage
662
+ | CheckPreApprovedMessage;
@@ -7,6 +7,7 @@ import type {
7
7
  SendOutboundMessage,
8
8
  QuotaWallDetectedMessage,
9
9
  QueryPendingPermissionMessage,
10
+ CheckPreApprovedMessage,
10
11
  PostSkillProposalMessage,
11
12
  OperatorEventForward,
12
13
  PermissionRequestForward,
@@ -80,6 +81,15 @@ export interface IpcServerOptions {
80
81
  * feature (mixed-version safety).
81
82
  */
82
83
  onQueryPendingPermission?: (client: IpcClient, msg: QueryPendingPermissionMessage) => void;
84
+ /**
85
+ * #2975 Stage 2 — hostd's read-only pre-approval query. Handler answers from
86
+ * the gateway's correlation maps by EXACT diff byte-match and replies with a
87
+ * `pre_approved_result` event. MUST NOT mutate gateway state. Optional: when
88
+ * no handler is wired (older gateway build, or a fixture that doesn't need
89
+ * it), the server replies `preApproved: false` immediately so hostd fails
90
+ * closed to today's rate-limited behaviour (mixed-version safety).
91
+ */
92
+ onCheckPreApproved?: (client: IpcClient, msg: CheckPreApprovedMessage) => void;
83
93
  /**
84
94
  * #2670 one-tap self-improvement — persist a skill-improvement proposal
85
95
  * and post its Approve/Dismiss card. Handler persists the draft bundle to
@@ -352,6 +362,19 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
352
362
  || (m.correlationId as string).length > 64) return false;
353
363
  return true;
354
364
  }
365
+ case "check_pre_approved": {
366
+ // #2975 Stage 2 — hostd read-only pre-approval query. Wire shape only;
367
+ // the handler byte-matches the diff against the correlation maps and
368
+ // never mutates state.
369
+ if (typeof m.agentName !== "string"
370
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
371
+ if (typeof m.correlationId !== "string"
372
+ || (m.correlationId as string).length === 0
373
+ || (m.correlationId as string).length > 64) return false;
374
+ if (typeof m.unifiedDiff !== "string"
375
+ || (m.unifiedDiff as string).length === 0) return false;
376
+ return true;
377
+ }
355
378
  case "request_config_approval": {
356
379
  // #1623 — hostd-initiated config-edit approval card. Wire shape
357
380
  // only; the handler module validates the diff content.
@@ -375,6 +398,7 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
375
398
  || (m.requestId as string).length === 0
376
399
  || (m.requestId as string).length > 64) return false;
377
400
  if (m.outcome !== "applied"
401
+ && m.outcome !== "aborted_config_changed"
378
402
  && m.outcome !== "reconcile_failed_rolled_back") return false;
379
403
  if (m.detail !== undefined
380
404
  && (typeof m.detail !== "string"
@@ -472,6 +496,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
472
496
  onSendOutbound,
473
497
  onQuotaWallDetected,
474
498
  onQueryPendingPermission,
499
+ onCheckPreApproved,
475
500
  onPostSkillProposal,
476
501
  onRequestDriveApproval,
477
502
  onRequestMs365Approval,
@@ -609,6 +634,24 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
609
634
  }
610
635
  }
611
636
  break;
637
+ case "check_pre_approved":
638
+ if (onCheckPreApproved) {
639
+ onCheckPreApproved(client, msg as CheckPreApprovedMessage);
640
+ } else {
641
+ // No handler wired — fail closed to "not pre-approved" so hostd
642
+ // applies the ordinary rate limit (byte-identical to pre-#2975-S2
643
+ // behaviour; the Stage 1 retry then covers an approved persist).
644
+ try {
645
+ client.send({
646
+ type: "pre_approved_result",
647
+ correlationId: (msg as CheckPreApprovedMessage).correlationId,
648
+ preApproved: false,
649
+ });
650
+ } catch {
651
+ /* best effort */
652
+ }
653
+ }
654
+ break;
612
655
  case "request_drive_approval":
613
656
  if (onRequestDriveApproval) {
614
657
  // Handler is async — fire-and-forget here; the handler
@@ -46,6 +46,14 @@ export interface MentalModelPendingProposal {
46
46
  export type ConfigEditDispatchResult =
47
47
  | { state: "applied" }
48
48
  | { state: "denied"; reason: string }
49
+ /**
50
+ * hostd threw `E_RATE_LIMITED` (the agent's config_propose_edit bucket is
51
+ * exhausted). `retryAtMs` is the epoch-ms parsed from the structured
52
+ * `fix.retry_after` hostd emits — the moment the sliding window frees a
53
+ * slot. #2975 Stage 1 schedules exactly ONE re-dispatch at that time so an
54
+ * operator-APPROVED persist is never silently lost to the throttle.
55
+ */
56
+ | { state: "rate_limited"; reason: string; retryAtMs: number }
49
57
  | { state: "error"; reason: string };
50
58
 
51
59
  export interface ResolveDeps {
@@ -79,6 +87,33 @@ export interface ResolveDeps {
79
87
  ensureModel?: (spec: MentalModelProposeSpec) => Promise<void>;
80
88
  /** Deliver a synthetic inbound to wake the agent with the outcome. */
81
89
  injectInbound: (inbound: InboundMessage) => void;
90
+ /**
91
+ * #2975 Stage 1 — schedule EXACTLY ONE bounded re-dispatch of an
92
+ * operator-approved persist that hostd rate-limited (`E_RATE_LIMITED`).
93
+ * `delayMs` is the wait until hostd's `fix.retry_after` window opens; `fn`
94
+ * re-runs the persist ONCE (it never re-schedules — bounded, no loop).
95
+ * Injectable so tests drive it with a mocked clock. When absent, a rate-
96
+ * limited persist can't be retried and falls straight to the loud-failure
97
+ * path (no silent loss).
98
+ *
99
+ * Stage-1 caveat (documented, accepted): the timer lives only in gateway
100
+ * memory. A gateway restart between schedule and fire LOSES the retry —
101
+ * acceptable for Stage 1; Stage 2 (hostd `checkPreApproved` bypass) removes
102
+ * the rate-limit collision entirely so no retry is needed.
103
+ */
104
+ scheduleRetry?: (delayMs: number, fn: () => void | Promise<void>) => void;
105
+ /**
106
+ * #2975 Stage 1 — edit the operator's proposal card to the
107
+ * "approved — applying at HH:MM (rate window)" state so a rate-deferred
108
+ * (but approved) change is visibly not lost while the retry is pending.
109
+ */
110
+ editProposalCardRateWindow?: (retryAtMs: number) => void;
111
+ /**
112
+ * #2975 Stage 1 — loud, operator-visible failure (operator-events path)
113
+ * when the single scheduled retry ALSO fails, so an approved change is
114
+ * never silently dropped. Must not throw.
115
+ */
116
+ notifyPersistFailed?: (reason: string) => void;
82
117
  /** Injectable clock for deterministic tests. */
83
118
  now?: () => number;
84
119
  /** Injectable git binary for the diff subprocess (tests). */
@@ -90,7 +125,14 @@ export type ResolveOutcome =
90
125
  | { outcome: "applied" }
91
126
  | { outcome: "denied" }
92
127
  | { outcome: "rejected"; reason: string }
93
- | { outcome: "failed"; reason: string };
128
+ | { outcome: "failed"; reason: string }
129
+ /**
130
+ * #2975 Stage 1 — the persist was rate-limited but the operator already
131
+ * approved, so exactly one re-dispatch is scheduled at `retryAtMs`. The
132
+ * proposal card was edited to the "applying at HH:MM" state; the agent's
133
+ * turn resolves later when the retry injects its own applied/failed inbound.
134
+ */
135
+ | { outcome: "scheduled_retry"; retryAtMs: number };
94
136
 
95
137
  /**
96
138
  * Resolve an operator's Approve / Deny decision on a mental-model proposal.
@@ -145,57 +187,123 @@ export async function resolveMentalModelProposal(
145
187
  return { outcome: "rejected", reason: built.detail };
146
188
  }
147
189
 
148
- // Pre-register the single-tap correlation so hostd auto-approves the edit
149
- // (operator already approved on this proposal card — no second card).
150
- deps.registerPreApproval(pending.agent, built.diff);
151
- let dispatch: ConfigEditDispatchResult;
152
- try {
153
- dispatch = await deps.dispatchConfigEdit({
154
- agent: pending.agent,
155
- diff: built.diff,
156
- reason:
157
- `Declare agent-proposed mental model "${pending.spec.name}"` +
158
- (pending.reason ? ` — ${pending.reason}` : ""),
159
- });
160
- } catch (err) {
161
- dispatch = { state: "error", reason: (err as Error).message };
162
- } finally {
163
- // Single-shot: drop the correlation whether or not hostd consumed it.
164
- deps.clearPreApproval(pending.agent, built.diff);
165
- }
190
+ const persistReason =
191
+ `Declare agent-proposed mental model "${pending.spec.name}"` +
192
+ (pending.reason ? ` — ${pending.reason}` : "");
193
+ const clock = deps.now ?? Date.now;
166
194
 
167
- if (dispatch.state !== "applied") {
195
+ // ONE dispatch attempt. Atomically re-registers the single-tap correlation
196
+ // with the SAME diff bytes each call BEFORE dispatching — the register and
197
+ // dispatch MUST use identical bytes (forge-resistance contract, file header
198
+ // lines 54-63): hostd's config-approval callback auto-resolves only on an
199
+ // exact byte-match, so a rebuilt/whitespace-drifted diff would fail to
200
+ // auto-approve and post a SECOND operator card. Because the initial and the
201
+ // #2975 retry both re-run this closure over the same captured `built.diff`,
202
+ // they are byte-identical by construction.
203
+ const dispatchOnce = async (): Promise<ConfigEditDispatchResult> => {
204
+ deps.registerPreApproval(pending.agent, built.diff);
205
+ try {
206
+ return await deps.dispatchConfigEdit({
207
+ agent: pending.agent,
208
+ diff: built.diff,
209
+ reason: persistReason,
210
+ });
211
+ } catch (err) {
212
+ return { state: "error", reason: (err as Error).message };
213
+ } finally {
214
+ // Single-shot: drop the correlation whether or not hostd consumed it.
215
+ deps.clearPreApproval(pending.agent, built.diff);
216
+ }
217
+ };
218
+
219
+ // Applied — belt-and-suspenders ensure (reconcile already ensures via #2874;
220
+ // this makes the model available without waiting for the restart). Never let
221
+ // an ensure failure flip a successful declaration into a "failed" inbound —
222
+ // the model IS declared and will be ensured at reconcile regardless.
223
+ const finishApplied = async (): Promise<void> => {
224
+ if (deps.ensureModel) {
225
+ try {
226
+ await deps.ensureModel(pending.spec);
227
+ } catch (err) {
228
+ deps.log?.(
229
+ `mental_model_propose: best-effort ensure threw (declaration still applied) for ${pending.agent} "${pending.spec.name}": ${(err as Error).message}`,
230
+ );
231
+ }
232
+ }
233
+ deps.injectInbound(
234
+ buildMentalModelProposeAppliedInbound({
235
+ ctx,
236
+ stageId,
237
+ operatorId,
238
+ nowMs: clock(),
239
+ }),
240
+ );
241
+ };
242
+
243
+ const finishFailed = (reason: string, loud: boolean): void => {
168
244
  deps.log?.(
169
- `mental_model_propose: config edit ${dispatch.state} for ${pending.agent} "${pending.spec.name}": ${dispatch.reason}`,
245
+ `mental_model_propose: config edit failed for ${pending.agent} "${pending.spec.name}": ${reason}`,
170
246
  );
247
+ // Loud, operator-visible failure only on the RETRY exhaustion path — the
248
+ // initial-failure path already surfaces via the card edit + agent inbound.
249
+ if (loud) {
250
+ try {
251
+ deps.notifyPersistFailed?.(reason);
252
+ } catch {
253
+ // notify must never break the failed-inbound delivery below.
254
+ }
255
+ }
171
256
  deps.injectInbound(
172
257
  buildMentalModelProposeFailedInbound({
173
258
  ctx,
174
259
  stageId,
175
260
  operatorId,
176
- reason: dispatch.reason,
177
- nowMs,
261
+ reason,
262
+ nowMs: clock(),
178
263
  }),
179
264
  );
180
- return { outcome: "failed", reason: dispatch.reason };
181
- }
265
+ };
182
266
 
183
- // Applied. Belt-and-suspenders ensure (reconcile already ensures via #2874;
184
- // this makes the model available without waiting for the restart). Never let
185
- // an ensure failure flip a successful declaration into a "failed" inbound —
186
- // the model IS declared and will be ensured at reconcile regardless.
187
- if (deps.ensureModel) {
188
- try {
189
- await deps.ensureModel(pending.spec);
190
- } catch (err) {
267
+ const dispatch = await dispatchOnce();
268
+
269
+ // #2975 Stage 1 backstop an operator-APPROVED persist that hostd rate-
270
+ // limited must NOT be silently lost. Schedule EXACTLY ONE re-dispatch at the
271
+ // window-open time hostd handed back in `fix.retry_after`, and flip the card
272
+ // to the "applying at HH:MM (rate window)" state so the operator sees it's
273
+ // deferred, not dropped.
274
+ if (dispatch.state === "rate_limited") {
275
+ if (deps.scheduleRetry) {
276
+ const { retryAtMs } = dispatch;
277
+ deps.editProposalCardRateWindow?.(retryAtMs);
278
+ const delayMs = Math.max(0, retryAtMs - clock());
191
279
  deps.log?.(
192
- `mental_model_propose: best-effort ensure threw (declaration still applied) for ${pending.agent} "${pending.spec.name}": ${(err as Error).message}`,
280
+ `mental_model_propose: config edit rate-limited for ${pending.agent} "${pending.spec.name}"; scheduling one retry in ${delayMs}ms (window opens ${new Date(retryAtMs).toISOString()})`,
193
281
  );
282
+ deps.scheduleRetry(delayMs, async () => {
283
+ // Bounded: this is the ONE and ONLY retry — it never re-schedules.
284
+ const retryResult = await dispatchOnce();
285
+ if (retryResult.state === "applied") {
286
+ await finishApplied();
287
+ } else {
288
+ // Every non-applied state (denied / rate_limited / error) carries a
289
+ // reason. A second rate_limit is treated as a terminal failure — the
290
+ // retry is bounded to one; we do NOT re-schedule.
291
+ finishFailed(retryResult.reason, true);
292
+ }
293
+ });
294
+ return { outcome: "scheduled_retry", retryAtMs };
194
295
  }
296
+ // No scheduler wired — can't defer the retry; fail loudly rather than
297
+ // silently drop the approved change.
298
+ finishFailed(dispatch.reason, true);
299
+ return { outcome: "failed", reason: dispatch.reason };
300
+ }
301
+
302
+ if (dispatch.state !== "applied") {
303
+ finishFailed(dispatch.reason, false);
304
+ return { outcome: "failed", reason: dispatch.reason };
195
305
  }
196
306
 
197
- deps.injectInbound(
198
- buildMentalModelProposeAppliedInbound({ ctx, stageId, operatorId, nowMs }),
199
- );
307
+ await finishApplied();
200
308
  return { outcome: "applied" };
201
309
  }
@@ -521,9 +521,15 @@ export const SR_MODEL_ALIASES: Record<string, string> = {
521
521
  * session to verify, so the operator is asked to re-issue after boot.
522
522
  */
523
523
  export function isOfflineTrustedModelToken(token: string): boolean {
524
- if ((MODEL_ALIASES as readonly string[]).includes(token)) return true
525
- if (token in SR_MODEL_ALIASES) return true
526
- return Object.values(SR_MODEL_ALIASES).includes(token)
524
+ // #3043 item 1: the live accept path normalizes case (isClaudeModel /
525
+ // expandSrAlias lowercase before matching), so a queued `/model OPUS` is
526
+ // accepted live — but this persist-time gate compared case-sensitively and
527
+ // then refused it with the over-conservative "couldn't verify" card. Lowercase
528
+ // once so the persist decision matches what the live path already accepted.
529
+ const lower = token.toLowerCase()
530
+ if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
531
+ if (lower in SR_MODEL_ALIASES) return true
532
+ return Object.values(SR_MODEL_ALIASES).includes(lower)
527
533
  }
528
534
 
529
535
  export function expandSrAlias(arg: string): string {