switchroom 0.18.6 → 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -0,0 +1,106 @@
1
+ // #2995 — mid-flight busy ack.
2
+ //
3
+ // A mid-turn inbound that is neither a steer nor an interrupt is buffered
4
+ // until the running turn goes idle (`buffer-until-idle`). When the turn is
5
+ // sitting inside ONE long blocking tool call (`gh pr checks --watch`, a
6
+ // long `sleep`, a slow build), that wait can be minutes — from the phone
7
+ // the question reads as ignored. The user's message got only a 👀 reaction
8
+ // and then silence until the blocking step returned (observed live
9
+ // 2026-07-10: a trivial "are any agents working?" waited 2 minutes behind
10
+ // a `--watch`).
11
+ //
12
+ // This module is the PURE half of the fix: a deterministic, model-free
13
+ // decision — should the gateway post a silent "⏳ Queued — currently
14
+ // inside <tool> …" card into the inbound's own chat/topic right now? No
15
+ // tokens, no model calls; the gateway supplies live readings (delivery-gate
16
+ // decision, tool-flight state, current step age, dedupe state) and this
17
+ // module answers. Extracted so the policy is unit-testable without the
18
+ // gateway IIFE (same pattern as `interrupt-defer.ts` / `feed-reopen-gate.ts`).
19
+ //
20
+ // Wording contract: the buffered-path card MUST say "Queued" — the
21
+ // steer-or-queue job spec (`reference/jobs/steer-or-queue-mid-flight.md`)
22
+ // requires the chosen classification to be visible in the chat, never
23
+ // inferred. A steer-path card must NOT say "Queued" (it wasn't queued);
24
+ // it says the steer was noted and will fold in at the step boundary.
25
+
26
+ /** How the inbound was classified/handled by the delivery gate. Only the
27
+ * two mid-turn shapes are ack-eligible; a plain fresh-turn `deliver`
28
+ * needs no busy ack (the turn starts immediately). */
29
+ export type BusyAckGateDecision = 'buffer-until-idle' | 'steer' | 'deliver'
30
+
31
+ /**
32
+ * Minimum age of the CURRENT tool step before a busy ack fires. Below
33
+ * this the step is about to return anyway and the buffered inbound will
34
+ * flush in a beat — an ack would fire when the agent was seconds from
35
+ * answering (the job spec's explicit Bad bullet). 12s sits well above the
36
+ * common fast-tool envelope (reads/greps/short bashes finish in <5s) and
37
+ * well below the "reads as ignored" horizon (~30s+); it also matches the
38
+ * UAT latency promise (visible ack <10s of the ping, for a step that has
39
+ * already proven itself long).
40
+ */
41
+ export const BUSY_ACK_STEP_AGE_THRESHOLD_MS = 12_000
42
+
43
+ export interface BusyAckDecisionInput {
44
+ /** Delivery-gate outcome for this inbound ('steer' when it was
45
+ * delivered mid-turn as a steering amend). */
46
+ gateDecision: BusyAckGateDecision
47
+ /** Live `ToolFlightTracker.isMidToolCall()` reading — at least one
48
+ * top-level tool call currently open. */
49
+ midToolCall: boolean
50
+ /** Age (ms) of the LONGEST-running in-flight tool step, or null when no
51
+ * step is tracked (e.g. the turn is thinking between tools). */
52
+ stepAgeMs: number | null
53
+ /** True when a busy-ack/queued-status card already exists (or was
54
+ * already posted this turn) for the inbound's chat/topic — at most one
55
+ * card per turn per chat/topic; a second ping gets no second card. */
56
+ alreadyAcked: boolean
57
+ }
58
+
59
+ /**
60
+ * Pure decision: post the busy-ack card now?
61
+ *
62
+ * - only for mid-turn shapes (buffered, or delivered as a steer)
63
+ * - only while genuinely mid-tool-call
64
+ * - only once the current step is older than the threshold (a young
65
+ * step returns soon; the normal flush covers it)
66
+ * - at most once per turn per chat/topic (dedupe)
67
+ */
68
+ export function shouldPostBusyAck(input: BusyAckDecisionInput): boolean {
69
+ if (input.gateDecision !== 'buffer-until-idle' && input.gateDecision !== 'steer') return false
70
+ if (!input.midToolCall) return false
71
+ if (input.stepAgeMs == null || input.stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS) return false
72
+ if (input.alreadyAcked) return false
73
+ return true
74
+ }
75
+
76
+ export interface BusyAckTextInput {
77
+ gateDecision: 'buffer-until-idle' | 'steer'
78
+ /** Bare tool name as tracked (e.g. "Bash"). Null when unknown. */
79
+ toolName: string | null
80
+ /** Natural-language descriptor from the PreToolUse sidecar's
81
+ * `toolLabel()` (e.g. `sleep 90`), or null. */
82
+ toolLabel: string | null
83
+ }
84
+
85
+ /**
86
+ * Render the card text. Deterministic, no model. The buffered variant
87
+ * says "Queued" (classification-visibility invariant); the steer variant
88
+ * says the steer is noted — never "Queued".
89
+ *
90
+ * Deliberately carries NO elapsed figure: the card is posted once and
91
+ * never re-rendered while the blocking step runs, so any point-in-time
92
+ * number ("2m elapsed") would silently go stale on screen — a decaying
93
+ * claim on a card whose whole point is honesty. The activity name alone
94
+ * is time-invariant.
95
+ */
96
+ export function formatBusyAckText(input: BusyAckTextInput): string {
97
+ const name = input.toolName ?? 'a long-running step'
98
+ const activity =
99
+ input.toolLabel != null && input.toolLabel.length > 0
100
+ ? `${name}: ${input.toolLabel}`
101
+ : name
102
+ if (input.gateDecision === 'steer') {
103
+ return `⏳ Steer noted — currently inside \`${activity}\`; I'll fold it in when this step finishes.`
104
+ }
105
+ return `⏳ Queued — currently inside \`${activity}\`; I'll answer when this step finishes.`
106
+ }
@@ -182,44 +182,92 @@ export function resolveShutdownMarker(
182
182
  return { ts: now, signal, reason: EXTERNAL_RESTART_FALLBACK_REASON };
183
183
  }
184
184
 
185
+ /**
186
+ * Boot-resume policy, from `session_continuity.boot_resume`
187
+ * (SWITCHROOM_BOOT_RESUME env). Governs whether a clean/deliberate
188
+ * shutdown suppresses auto-resume of a genuinely in-flight turn:
189
+ *
190
+ * - 'always' — always resume interrupted work, even after a clean
191
+ * shutdown (equivalent to SWITCHROOM_BOOT_RESUME_ALWAYS=1).
192
+ * - 'in-flight' — (DEFAULT) resume work that was genuinely in flight at
193
+ * shutdown, even after a deliberate/operator restart. This
194
+ * is the whole point: a sanctioned restart that lands mid-
195
+ * turn must not silently drop the work. The boot-resume
196
+ * block only runs when a pending interrupted turn exists —
197
+ * i.e. when there IS in-flight work — so in this mode the
198
+ * clean-shutdown gate never swallows real work.
199
+ * - 'never' — the #2585 quota-saving posture: suppress AUTO-resume on a
200
+ * fresh clean shutdown. NOTE the caller still delivers a
201
+ * passive REPORT inbound in this case (silence is never
202
+ * acceptable when work was in flight); this flag only
203
+ * downgrades an active resume to a passive notice.
204
+ */
205
+ export type BootResumeMode = 'always' | 'in-flight' | 'never';
206
+
207
+ /** The product default (2026-07): resume genuinely in-flight work even
208
+ * after a deliberate restart. Restores the pre-#2585 recovery of real
209
+ * work while keeping #2585's "don't replay abandoned work" intent for the
210
+ * no-in-flight-turn case (which is a no-op — the boot block never runs
211
+ * without a pending turn). */
212
+ export const DEFAULT_BOOT_RESUME_MODE: BootResumeMode = 'in-flight';
213
+
214
+ /**
215
+ * Parse the SWITCHROOM_BOOT_RESUME env value into a BootResumeMode. Unknown
216
+ * / empty / undefined falls back to the default ('in-flight'). Case- and
217
+ * separator-insensitive ("in_flight", "inflight", "IN-FLIGHT" all map to
218
+ * 'in-flight') so a config typo degrades to the safe default rather than a
219
+ * silent misparse.
220
+ */
221
+ export function parseBootResumeMode(raw: string | undefined | null): BootResumeMode {
222
+ if (typeof raw !== 'string') return DEFAULT_BOOT_RESUME_MODE;
223
+ const norm = raw.trim().toLowerCase().replace(/[_\s]+/g, '-');
224
+ if (norm === 'always') return 'always';
225
+ if (norm === 'never') return 'never';
226
+ if (norm === 'in-flight' || norm === 'inflight') return 'in-flight';
227
+ return DEFAULT_BOOT_RESUME_MODE;
228
+ }
229
+
185
230
  /**
186
231
  * Pure decision: should the boot-resume path SUPPRESS the active
187
232
  * resume_interrupted inbound because the prior shutdown was clean?
188
233
  *
189
- * A clean marker present and fresh (<= maxAgeMs, default 60s) means the
190
- * prior shutdown was operator/roll/CLI-initiated NOT a crash. In that
191
- * case auto-resuming interrupted work is wasteful: the agent was asked to
192
- * stop, it stopped cleanly, and the "interrupted" turn was implicitly
193
- * abandoned by that decision. Burning a full model turn to replay it on
194
- * every operator restart wastes subscription quota for no user benefit.
234
+ * This block is only ever consulted when a pending INTERRUPTED turn
235
+ * exists i.e. there IS genuinely in-flight work to recover. The product
236
+ * decision (2026-07, superseding #2585) is that a deliberate restart must
237
+ * NOT silently drop that work, so the DEFAULT mode ('in-flight') never
238
+ * suppresses. #2585's quota-saving posture survives only as the opt-in
239
+ * 'never' mode and even then the caller downgrades to a passive REPORT
240
+ * rather than going silent.
195
241
  *
196
- * Returns true ONLY when:
242
+ * Returns true (suppress the ACTIVE resume) ONLY when:
243
+ * - mode === 'never', AND
197
244
  * - a clean marker is present, AND
198
245
  * - the marker is younger than maxAgeMs (default 60s), AND
199
- * - the SWITCHROOM_BOOT_RESUME_ALWAYS escape hatch is not set.
200
- *
201
- * Returns false when:
202
- * - no marker (crash / OOM / unexpected kill — resume normally), OR
203
- * - marker is stale (>= maxAgeMs — something stalled; treat as crash), OR
204
- * - forceAlways is true (the escape hatch is active).
246
+ * - neither forceAlways nor mode 'always' is set.
205
247
  *
206
- * The forceAlways parameter maps to the env var
207
- * SWITCHROOM_BOOT_RESUME_ALWAYS=1, which restores the pre-gate behaviour
208
- * unconditionally. Pass it in as a parsed boolean so this function stays
209
- * pure and testable without touching process.env.
248
+ * Returns false (resume normally) when:
249
+ * - mode is 'always' or 'in-flight' (the default), OR
250
+ * - forceAlways is true (SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch), OR
251
+ * - no marker (crash / OOM / unexpected kill), OR
252
+ * - marker is stale (>= maxAgeMs — clean shutdown that stalled mid-drain;
253
+ * preserves the ">60s slow-rollout already resumes" behaviour).
210
254
  *
211
- * Keeping this pure makes the decision unit-testable in bun test without
212
- * spinning up the gateway.
255
+ * forceAlways maps to SWITCHROOM_BOOT_RESUME_ALWAYS=1 and is preserved for
256
+ * back-compat: it forces resume regardless of mode. Passed in as a parsed
257
+ * boolean + mode so this function stays pure and testable.
213
258
  */
214
259
  export function shouldSuppressBootResume(
215
260
  marker: CleanShutdownMarker | null,
216
261
  now: number,
217
- { maxAgeMs = DEFAULT_MAX_AGE_MS, forceAlways = false }: {
262
+ { maxAgeMs = DEFAULT_MAX_AGE_MS, forceAlways = false, mode = DEFAULT_BOOT_RESUME_MODE }: {
218
263
  maxAgeMs?: number;
219
264
  forceAlways?: boolean;
265
+ mode?: BootResumeMode;
220
266
  } = {},
221
267
  ): boolean {
222
268
  if (forceAlways) return false;
269
+ if (mode === 'always' || mode === 'in-flight') return false;
270
+ // mode === 'never': the legacy #2585 clean-shutdown gate.
223
271
  if (marker === null) return false;
224
272
  const age = now - marker.ts;
225
273
  if (age < 0) return false; // clock skew defence — treat as stale