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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- 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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
-
*
|
|
212
|
-
*
|
|
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
|