switchroom 0.18.6 → 0.18.7

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 (71) 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 +269 -56
  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 +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pure, injectable core for expiring an agent-initiated approval card
3
+ * (vault_request_access / vault_request_save / request_secret /
4
+ * mental_model_propose) whose TTL elapsed with no operator tap.
5
+ *
6
+ * Extracted from gateway.ts so the ORDERING and FAULT-ISOLATION contract is
7
+ * unit-testable behaviorally (pending-card-expiry.test.ts), not just pinned by
8
+ * source-text regex:
9
+ *
10
+ * 1. remove() FIRST — the in-memory map entry + durable store record are
11
+ * dropped before anything else, so the expiry is single-shot: a second
12
+ * reaper tick (or a concurrent lazy sweep) can never double-fire the
13
+ * synthetic wake for the same card.
14
+ * 2. editCard() — best-effort ⌛ card strip; a Telegram failure never blocks
15
+ * the wake.
16
+ * 3. recordMiss() — the missed-approvals re-offer entry is written BEFORE
17
+ * the deliver attempt, so a throwing IPC socket can't lose the re-offer:
18
+ * even if the wake never lands, the operator's return re-surfaces it.
19
+ * 4. deliver() — the timeout synthetic, wrapped in try/catch. A half-dead
20
+ * client socket that throws on write is contained here: the error is
21
+ * logged, `delivered: false` is returned, and the caller's sweep loop
22
+ * continues to the remaining entries/families.
23
+ *
24
+ * Every step is individually guarded — one failing dependency never skips the
25
+ * later steps or escapes to the caller (the reaper's setInterval callback,
26
+ * where an escaped throw would take the whole gateway down via
27
+ * uncaughtException).
28
+ */
29
+
30
+ import type { InboundMessage } from './ipc-protocol.js'
31
+
32
+ export interface ExpireCardDeps {
33
+ /** Drop the in-memory map entry AND the durable store record. Runs first. */
34
+ remove: () => void
35
+ /** Best-effort ⌛ card edit (strip keyboard). Failures are swallowed. */
36
+ editCard: () => void
37
+ /** Build the timeout synthetic inbound for this card's family. */
38
+ buildInbound: () => InboundMessage
39
+ /** Inject the synthetic (turn-safe gate). May throw on a dead socket. */
40
+ deliver: (inbound: InboundMessage) => boolean
41
+ /** Record the missed-approvals re-offer entry. Runs BEFORE deliver. */
42
+ recordMiss: () => void
43
+ /** Error sink (stderr in production). */
44
+ log: (msg: string) => void
45
+ }
46
+
47
+ export interface ExpireCardResult {
48
+ delivered: boolean
49
+ }
50
+
51
+ export function expirePendingCard(deps: ExpireCardDeps): ExpireCardResult {
52
+ // 1. Single-shot: entry gone before any fallible side effect.
53
+ deps.remove()
54
+ // 2. Card strip is cosmetic — never let it block the wake.
55
+ try {
56
+ deps.editCard()
57
+ } catch (err) {
58
+ deps.log(`card-expiry: card edit failed: ${(err as Error).message}`)
59
+ }
60
+ // 3. Re-offer entry BEFORE the deliver attempt so a throwing deliver can't
61
+ // lose it (the operator's return still re-surfaces the missed card).
62
+ try {
63
+ deps.recordMiss()
64
+ } catch (err) {
65
+ deps.log(`card-expiry: missed-approval record failed: ${(err as Error).message}`)
66
+ }
67
+ // 4. The wake itself — contained so one dead socket doesn't skip the
68
+ // remaining entries in the caller's sweep loop.
69
+ let delivered = false
70
+ try {
71
+ delivered = deps.deliver(deps.buildInbound())
72
+ } catch (err) {
73
+ deps.log(`card-expiry: timeout synthetic delivery failed: ${(err as Error).message}`)
74
+ }
75
+ return { delivered }
76
+ }
77
+
78
+ /**
79
+ * Sweep one pending-card map: expire every entry past its TTL via `expire`,
80
+ * guarding each entry so one throwing expiry can't skip the rest of the map
81
+ * (or, at the caller, the remaining families).
82
+ */
83
+ export function sweepExpiredEntries<T>(
84
+ map: Map<string, T>,
85
+ isExpired: (value: T, now: number) => boolean,
86
+ expire: (stageId: string, value: T, now: number) => void,
87
+ now: number,
88
+ log: (msg: string) => void,
89
+ ): void {
90
+ for (const [k, v] of map) {
91
+ if (!isExpired(v, now)) continue
92
+ try {
93
+ expire(k, v, now)
94
+ } catch (err) {
95
+ log(`card-expiry: expire threw for stage=${k}: ${(err as Error).message}`)
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Persistence store for in-flight AGENT-INITIATED approval cards that park
3
+ * the requesting agent until the operator taps: `vault_request_access`,
4
+ * `vault_request_save`, `request_secret`, and `mental_model_propose`.
5
+ *
6
+ * Problem (mirror of the permission-card `permission-card-store.ts` bug): the
7
+ * gateway holds each staged request in an in-memory Map only
8
+ * (`pendingVaultRequestAccesses`, `pendingVaultRequestSaves`,
9
+ * `pendingSecretRequests`, `pendingMentalModelProposes`). When the gateway
10
+ * restarts (crash OR container restart), every entry is lost. The card in
11
+ * Telegram keeps its live inline keyboard, so when the operator taps it later
12
+ * they hit the "Card expired — ask the agent to re-request" tombstone: no
13
+ * grant, no injected inbound, and the agent that ended its turn to WAIT on the
14
+ * card stays parked forever.
15
+ *
16
+ * Fix: persist the METADATA for every posted card to a JSON file in STATE_DIR.
17
+ * On each resolution (tap / TTL-expire) remove the entry. On gateway boot,
18
+ * restore surviving entries into the in-memory maps so a post-restart tap on a
19
+ * still-valid (unexpired) card works exactly like a pre-restart tap.
20
+ *
21
+ * SECRETS HYGIENE — LOAD-BEARING: this file is written to disk unencrypted.
22
+ * It MUST NOT carry any secret VALUE. `vault_request_save` stages a secret
23
+ * value in gateway memory; only its key/metadata is persisted here (never the
24
+ * value), so a restored save card is re-associated with a tap but cannot
25
+ * complete the write — the caller degrades gracefully (tells the agent the
26
+ * value was lost to a restart). `request_secret` never holds a value at
27
+ * staging time (the value arrives after the tap), so its metadata is safe to
28
+ * persist. The "no value field" invariant is enforced by the record types
29
+ * below (PersistedVaultSaveCard has no `value` member, so a callsite can't
30
+ * compile one in) and pinned by the on-disk sentinel test in
31
+ * pending-card-store.test.ts.
32
+ *
33
+ * File format: JSON array of PersistedApprovalCard objects. Written
34
+ * synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
35
+ * production rate is a handful of cards, so the file stays tiny.
36
+ */
37
+
38
+ import { readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs'
39
+ import { join } from 'node:path'
40
+
41
+ /** The four agent-initiated approval-card families we persist. */
42
+ export type ApprovalCardFamily =
43
+ | 'vault_request_access'
44
+ | 'vault_request_save'
45
+ | 'request_secret'
46
+ | 'mental_model_propose'
47
+
48
+ interface BasePersistedCard {
49
+ family: ApprovalCardFamily
50
+ /** The staging id embedded in the card's callback_data (dedup + re-associate). */
51
+ stageId: string
52
+ /** Agent that requested (process.env.SWITCHROOM_AGENT_NAME). */
53
+ agent: string
54
+ /** Chat the card was rendered into; edited on tap / expiry. */
55
+ chatId: string
56
+ /** Card message id (filled after the card is sent). */
57
+ cardMessageId?: number
58
+ /** Forum topic the agent was working in, if any. */
59
+ threadId?: number
60
+ /** Unix-ms staging timestamp — the TTL clock. Preserved across restart. */
61
+ stagedAt: number
62
+ }
63
+
64
+ export interface PersistedVaultAccessCard extends BasePersistedCard {
65
+ family: 'vault_request_access'
66
+ key: string
67
+ scope: 'read' | 'write'
68
+ reason?: string
69
+ ttlSeconds: number
70
+ }
71
+
72
+ export interface PersistedVaultSaveCard extends BasePersistedCard {
73
+ family: 'vault_request_save'
74
+ key: string
75
+ kind: 'string' | 'binary'
76
+ why?: string
77
+ // NOTE: NO `value` — the staged secret never touches disk.
78
+ }
79
+
80
+ export interface PersistedSecretRequestCard extends BasePersistedCard {
81
+ family: 'request_secret'
82
+ key: string
83
+ reason?: string
84
+ }
85
+
86
+ export interface PersistedMentalModelCard extends BasePersistedCard {
87
+ family: 'mental_model_propose'
88
+ spec: {
89
+ name: string
90
+ source_query: string
91
+ refresh_after_consolidation?: boolean
92
+ max_tokens?: number
93
+ }
94
+ reason?: string
95
+ }
96
+
97
+ export type PersistedApprovalCard =
98
+ | PersistedVaultAccessCard
99
+ | PersistedVaultSaveCard
100
+ | PersistedSecretRequestCard
101
+ | PersistedMentalModelCard
102
+
103
+ export interface PendingCardStore {
104
+ /** Record a newly-posted card. Idempotent on stageId (replaces in place). */
105
+ add(entry: PersistedApprovalCard): void
106
+ /** Remove the entry for this stageId (resolved — tap or TTL). */
107
+ remove(stageId: string): void
108
+ /** All persisted entries (for boot-time restore). */
109
+ loadAll(): PersistedApprovalCard[]
110
+ /** Delete the backing file entirely. */
111
+ clear(): void
112
+ }
113
+
114
+ export function createPendingCardStore(stateDir: string): PendingCardStore {
115
+ const filePath = join(stateDir, 'pending-approval-cards.json')
116
+
117
+ function read(): PersistedApprovalCard[] {
118
+ try {
119
+ const raw = readFileSync(filePath, 'utf-8')
120
+ const parsed = JSON.parse(raw)
121
+ return Array.isArray(parsed) ? (parsed as PersistedApprovalCard[]) : []
122
+ } catch {
123
+ return []
124
+ }
125
+ }
126
+
127
+ function write(entries: PersistedApprovalCard[]): void {
128
+ try {
129
+ writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
130
+ // `mode` only applies when writeFileSync CREATES the file; an existing
131
+ // file keeps its prior perms. Re-assert 0600 on every write so the file
132
+ // can never stay laxer than intended.
133
+ chmodSync(filePath, 0o600)
134
+ } catch (err) {
135
+ process.stderr.write(
136
+ `telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
137
+ )
138
+ }
139
+ }
140
+
141
+ return {
142
+ add(entry) {
143
+ const entries = read()
144
+ const idx = entries.findIndex(e => e.stageId === entry.stageId)
145
+ if (idx >= 0) {
146
+ entries[idx] = entry
147
+ } else {
148
+ entries.push(entry)
149
+ }
150
+ write(entries)
151
+ },
152
+
153
+ remove(stageId) {
154
+ const entries = read()
155
+ const filtered = entries.filter(e => e.stageId !== stageId)
156
+ if (filtered.length !== entries.length) {
157
+ write(filtered)
158
+ }
159
+ },
160
+
161
+ loadAll() {
162
+ return read()
163
+ },
164
+
165
+ clear() {
166
+ try {
167
+ unlinkSync(filePath)
168
+ } catch {
169
+ // File may not exist — that's fine.
170
+ }
171
+ },
172
+ }
173
+ }
@@ -150,9 +150,19 @@ export function redeliverBufferedInbound(
150
150
  * approvals, subagent handbacks, warmup, reaction triggers) all tag a
151
151
  * `meta.source`; the user-message inbound built in gateway.ts sets none.
152
152
  * Restricting to source-less inbounds keeps merge-on-drain away from the
153
- * #1150 wake-up class entirely. */
153
+ * #1150 wake-up class entirely.
154
+ *
155
+ * Button taps (#271, `meta.button_callback`) are ALSO excluded even though
156
+ * they carry no `meta.source`: `mergeRun` keeps only the anchor (last)
157
+ * message's meta, so a tap merged with an adjacent buffered user text would
158
+ * silently drop its `button_callback_data`/`button_text` whenever the text
159
+ * is last — the agent would see the `[user tapped button: …]` line without
160
+ * the machine-readable payload. Taps deliver individually. */
154
161
  function isMergeableUserInbound(msg: InboundMessage): boolean {
155
- return msg.type === 'inbound' && (msg.meta == null || msg.meta.source == null)
162
+ return (
163
+ msg.type === 'inbound' &&
164
+ (msg.meta == null || (msg.meta.source == null && msg.meta.button_callback == null))
165
+ )
156
166
  }
157
167
 
158
168
  function inboundHasMedia(msg: InboundMessage): boolean {
@@ -49,12 +49,127 @@ export function humanizeElapsed(ms: number): string {
49
49
  return `~${days} day${days === 1 ? '' : 's'}`
50
50
  }
51
51
 
52
+ /**
53
+ * A sub-agent that was still in flight when the turn was interrupted. The
54
+ * gateway derives these from the registry (`listNonTerminalSubagentsForTurn`)
55
+ * — every non-terminal (`running` / `stalled`) worker of the interrupted turn.
56
+ * Kept to just the display fields so the builder stays pure (no SQLite import).
57
+ */
58
+ export interface InterruptedSubagent {
59
+ /** Agent type / label (e.g. 'worker', 'researcher'). */
60
+ agentType?: string | null
61
+ /** Human-readable dispatch prompt / task description. */
62
+ description?: string | null
63
+ /** Current registry status ('running' | 'stalled'). Informational only. */
64
+ status?: string | null
65
+ }
66
+
67
+ /** Max chars of each sub-agent's dispatch prompt included in the inbound. */
68
+ const SUBAGENT_PROMPT_MAX = 200
69
+ /** Max number of sub-agents listed (the rest are summarised as a count). */
70
+ const SUBAGENT_LIST_CAP = 10
71
+
72
+ function truncatePrompt(s: string, max: number): string {
73
+ const t = s.trim()
74
+ if (t.length <= max) return t
75
+ // Codepoint-safe truncation: slice by code POINTS, not UTF-16 code units —
76
+ // a naive `.slice()` can split a surrogate pair (emoji, astral-plane CJK)
77
+ // and leave a lone surrogate that renders as U+FFFD in the inbound.
78
+ const points = Array.from(t)
79
+ if (points.length <= max) return t
80
+ return points.slice(0, max - 1).join('').trimEnd() + '…'
81
+ }
82
+
83
+ /**
84
+ * Render the compact "these workers died with the restart" block appended to
85
+ * an interrupted-turn inbound. Empty string when there were no in-flight
86
+ * sub-agents (so the inbound is unchanged in the common case). Bounded: at
87
+ * most `SUBAGENT_LIST_CAP` entries, each prompt truncated to
88
+ * `SUBAGENT_PROMPT_MAX` chars, with an overflow count for the remainder.
89
+ *
90
+ * The closing instruction is mode-aware, mirroring the two builders'
91
+ * contracts:
92
+ * - `assertive: true` (the `resume_interrupted` path, whose inbound already
93
+ * says "just resume") → imperative: re-dispatch the ones still needed
94
+ * before declaring the task done.
95
+ * - `assertive: false` (the `resume_watchdog_timeout` path — an ask-first
96
+ * contract: "Do NOT silently resume … ask whether to retry") → deferred:
97
+ * IF the user asks to retry, the workers will need re-dispatching. An
98
+ * unconditional re-dispatch imperative here would contradict that
99
+ * hang-safety gate.
100
+ */
101
+ export function renderInterruptedSubagentsBlock(
102
+ subs: InterruptedSubagent[] | undefined,
103
+ opts: { assertive: boolean } = { assertive: true },
104
+ ): string {
105
+ if (!subs || subs.length === 0) return ''
106
+ const shown = subs.slice(0, SUBAGENT_LIST_CAP)
107
+ const lines = shown.map((s, i) => {
108
+ const type = s.agentType?.trim() ? s.agentType.trim() : 'sub-agent'
109
+ const desc = s.description?.trim()
110
+ ? truncatePrompt(s.description, SUBAGENT_PROMPT_MAX)
111
+ : '(no task description recorded)'
112
+ return ` ${i + 1}. [${type}] ${desc}`
113
+ })
114
+ const overflow =
115
+ subs.length > SUBAGENT_LIST_CAP
116
+ ? `\n …and ${subs.length - SUBAGENT_LIST_CAP} more.`
117
+ : ''
118
+ const count = subs.length
119
+ const closing = opts.assertive
120
+ ? `\nRe-dispatch the ones still needed before declaring the task done — ` +
121
+ `don't assume their work landed.`
122
+ : `\nIf the user asks you to retry, they'll need re-dispatching — ` +
123
+ `don't assume their work landed.`
124
+ return (
125
+ `\n\nWhen the restart hit, ${count} sub-agent${count === 1 ? ' was' : 's were'} still ` +
126
+ `in flight. These sub-agents were killed by the restart and did NOT complete:\n` +
127
+ lines.join('\n') +
128
+ overflow +
129
+ closing
130
+ )
131
+ }
132
+
52
133
  export interface ResumeInboundContext {
53
134
  /** The interrupted turn, straight from the registry. */
54
135
  turn: Turn
55
136
  /** Wall-clock ms. Drives `ts`, `messageId`, and the elapsed framing.
56
137
  * Defaults to Date.now(). */
57
138
  nowMs?: number
139
+ /** Sub-agents that were still non-terminal when the turn was interrupted.
140
+ * Rendered into the inbound so the resumed session knows what to
141
+ * re-dispatch. Omitted / empty → the inbound is unchanged. */
142
+ subagents?: InterruptedSubagent[]
143
+ }
144
+
145
+ /**
146
+ * Machine-stable leading token shared by EVERY synthetic boot inbound this
147
+ * module mints (resume, watchdog-report, deferred-report). It is the anchor
148
+ * the loop-guard keys on: a turn whose `user_prompt_preview` starts with
149
+ * this string was itself started by a synthetic boot inbound, so building
150
+ * ANOTHER resume for it would replay already-resumed work and could chain
151
+ * unboundedly across repeated restarts.
152
+ *
153
+ * `extractUserPromptPreview` strips the `<channel …>` wrapper before storing
154
+ * the preview, so the stored text begins with this prose prefix (not the
155
+ * `meta.source` attribute, which the preview drops). Every builder below
156
+ * MUST keep this as the first characters of its `text`; `resume-inbound-
157
+ * builder.test.ts` pins that contract so a prose edit can't silently break
158
+ * the loop-guard.
159
+ */
160
+ export const RESUME_SYNTHETIC_PROMPT_PREFIX = 'You just restarted.'
161
+
162
+ /**
163
+ * Loop-guard predicate: was this interrupted turn ITSELF started by one of
164
+ * this module's synthetic boot inbounds? Detected via the stored
165
+ * `user_prompt_preview` prefix. Used at boot to cap the auto-resume chain at
166
+ * depth 1 — a resume turn that is itself interrupted is NOT re-resumed
167
+ * (which would double-execute the resumed side effects and could loop
168
+ * forever); the caller downgrades to a passive deferred-report instead.
169
+ */
170
+ export function isResumeSyntheticTurn(turn: Turn): boolean {
171
+ const p = turn.user_prompt_preview
172
+ return typeof p === 'string' && p.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)
58
173
  }
59
174
 
60
175
  function threadIdNum(turn: Turn): number | undefined {
@@ -131,7 +246,8 @@ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): Inboun
131
246
  `plain language) so they're not left wondering — then carry on with the ` +
132
247
  `actual task. Do not ask whether to resume; just resume. If even after ` +
133
248
  `reading the recent messages you genuinely can't tell what the work was, ` +
134
- `say so and ask.`,
249
+ `say so and ask.` +
250
+ renderInterruptedSubagentsBlock(ctx.subagents),
135
251
  meta,
136
252
  }
137
253
  }
@@ -190,7 +306,129 @@ export function buildResumeWatchdogReportInbound(
190
306
  `after ${idle} of no progress, and roughly what it was doing. Then ask ` +
191
307
  `whether they want you to retry it or take a different angle. Report ` +
192
308
  `only the honest cause — no observable progress for that long — don't ` +
193
- `speculate about a deeper root cause you can't see.`,
309
+ `speculate about a deeper root cause you can't see.` +
310
+ // Deferred (non-assertive) form: this is the ask-first path — the killed
311
+ // workers are listed as facts, but re-dispatch waits on the user's call.
312
+ renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
313
+ meta,
314
+ }
315
+ }
316
+
317
+ /** Why an auto-resume was declined in favour of a passive notice. */
318
+ export type ResumeDeferReason = 'clean-restart-suppressed' | 'loop-guard'
319
+
320
+ /**
321
+ * The inbound the boot-resume block should mint for an interrupted turn:
322
+ * - 'resume' → active resume (buildResumeInterruptedInbound)
323
+ * - 'report' → watchdog report (buildResumeWatchdogReportInbound)
324
+ * - 'defer-loop' → loop-guard passive notice (deferred-report)
325
+ * - 'defer-suppressed' → boot_resume:never passive notice (deferred-report)
326
+ * - null → nothing (turn finished cleanly)
327
+ */
328
+ export type BootResumeKind = 'resume' | 'report' | 'defer-loop' | 'defer-suppressed' | null
329
+
330
+ /**
331
+ * Pure boot-resume decision, extracted from the gateway so the precedence
332
+ * (esp. the bounded resume-chain loop-guard) is unit-testable without
333
+ * booting a gateway. Precedence, highest first:
334
+ *
335
+ * 1. loop-guard — the interrupted turn was ITSELF a synthetic resume/report
336
+ * turn (`isResumeSyntheticTurn`). NEVER mint another resume for it: that
337
+ * would replay already-partly-executed work and could chain
338
+ * restart→resume→restart forever. The at-most-once `resumed_at` ledger
339
+ * bounds each individual turn, but each resume spawns a NEW turn, so
340
+ * without this the CHAIN is unbounded. Cap it at one auto-resume by
341
+ * downgrading to a passive deferred-report ('defer-loop').
342
+ * 2. suppressed — `boot_resume: never` on a fresh clean restart. Downgrade
343
+ * to a passive deferred-report ('defer-suppressed') — a notice, never
344
+ * silence.
345
+ * 3. otherwise — the normal age/ended_via policy (`selectResumeBuilder`).
346
+ */
347
+ export function decideBootResumeKind(args: {
348
+ pending: Turn
349
+ suppressed: boolean
350
+ ageMs: number
351
+ maxAgeMs: number
352
+ }): BootResumeKind {
353
+ if (isResumeSyntheticTurn(args.pending)) return 'defer-loop'
354
+ if (args.suppressed) return 'defer-suppressed'
355
+ return selectResumeBuilder(args.pending.ended_via, {
356
+ ageMs: args.ageMs,
357
+ maxAgeMs: args.maxAgeMs,
358
+ })
359
+ }
360
+
361
+ /**
362
+ * Build the `resume_deferred` inbound — a passive notice delivered when the
363
+ * framework declines to AUTO-resume interrupted work but must NOT stay
364
+ * silent (silence when work was in flight is never acceptable). Two reasons:
365
+ *
366
+ * - 'clean-restart-suppressed' — `session_continuity.boot_resume: never`
367
+ * is set and the prior shutdown was a fresh, deliberate restart. Per
368
+ * that opt-in posture we don't replay the work unprompted, but we still
369
+ * tell the user what was in flight so they can ask us to continue.
370
+ *
371
+ * - 'loop-guard' — the interrupted turn was ITSELF a synthetic resume/
372
+ * report turn (see `isResumeSyntheticTurn`). Auto-resuming a resume that
373
+ * was interrupted again risks an unbounded restart→resume→restart chain
374
+ * and double-executed side effects, so we cap the chain: report instead
375
+ * of resuming, and let the user decide whether to keep going.
376
+ *
377
+ * Like the watchdog report, this is a REPORT (source `resume_deferred`), not
378
+ * a resume: the agent must not silently pick the work back up. The text
379
+ * starts with RESUME_SYNTHETIC_PROMPT_PREFIX so a further restart during
380
+ * THIS turn is itself detected as a synthetic turn and stays capped.
381
+ */
382
+ export function buildResumeDeferredReportInbound(
383
+ ctx: ResumeInboundContext & { reason: ResumeDeferReason },
384
+ ): InboundMessage {
385
+ const ts = ctx.nowMs ?? Date.now()
386
+ const elapsed = humanizeElapsed(ts - ctx.turn.started_at)
387
+ const threadId = threadIdNum(ctx.turn)
388
+ const meta: Record<string, string> = {
389
+ source: 'resume_deferred',
390
+ // Origin chat/topic as channel attributes so the report turn gets a
391
+ // currentTurn (progress card + silence-poke) and the notice lands in the
392
+ // topic the work lived in. Same rationale as the other builders.
393
+ chat_id: ctx.turn.chat_id,
394
+ ...(threadId != null ? { message_thread_id: String(threadId) } : {}),
395
+ resume_turn_key: ctx.turn.turn_key,
396
+ interrupted_via: ctx.turn.ended_via ?? 'restart',
397
+ defer_reason: ctx.reason,
398
+ started_at: String(ctx.turn.started_at),
399
+ }
400
+ if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
401
+ const cause =
402
+ ctx.reason === 'loop-guard'
403
+ ? `Your previous turn was ALREADY a resume of earlier interrupted work, ` +
404
+ `and it was interrupted again by another restart ${elapsed} ago. To ` +
405
+ `avoid an endless restart→resume loop (and re-running work that may ` +
406
+ `have already partly executed), the framework has stopped ` +
407
+ `auto-resuming this chain.`
408
+ : `Your previous turn was interrupted ${elapsed} ago by a deliberate ` +
409
+ `restart, before it finished. This agent is configured NOT to ` +
410
+ `auto-resume work across deliberate restarts (boot_resume: never), so ` +
411
+ `it was not replayed automatically.`
412
+ return {
413
+ type: 'inbound',
414
+ chatId: ctx.turn.chat_id,
415
+ ...(threadId != null ? { threadId } : {}),
416
+ messageId: ts,
417
+ user: 'switchroom',
418
+ userId: 0,
419
+ ts,
420
+ text:
421
+ `${RESUME_SYNTHETIC_PROMPT_PREFIX} ${cause}` +
422
+ promptClause(ctx.turn) +
423
+ ` Do NOT silently resume it. Instead, briefly tell the user what was in ` +
424
+ `flight (call get_recent_messages for this chat if you need the full ` +
425
+ `original request — the quoted preview is only the first ~200 ` +
426
+ `characters), then ask whether they want you to pick it back up or drop ` +
427
+ `it. If you genuinely can't tell what the work was, say so and ask.` +
428
+ // Deferred (non-assertive) form, same as the watchdog path: this is an
429
+ // ask-first inbound — killed workers are named as facts, but
430
+ // re-dispatch waits on the user's call.
431
+ renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
194
432
  meta,
195
433
  }
196
434
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Freshness-aware source of truth for the /status session model.
3
+ *
4
+ * Two independent signals report what model the live session is running:
5
+ *
6
+ * - the TRANSCRIPT: `message.model` on each assistant line (session-tail's
7
+ * `model` event) — ground truth for the model that served the LAST API
8
+ * call, but only advances when a new assistant line lands;
9
+ * - the OVERRIDE: the in-memory `/model` switch record (#2982) — set the
10
+ * moment a switch is positively confirmed, ahead of any assistant line.
11
+ *
12
+ * Neither is unconditionally right. Preferring the transcript regresses the
13
+ * idle-after-switch window (#2982's core fix): a native `/model` inject while
14
+ * idle sets the override to the NEW model, but the transcript still holds the
15
+ * OLD one until the next assistant line — /status would lie. Preferring the
16
+ * override goes stale the other way (a relaunch carrier that never applied).
17
+ *
18
+ * The invariant is "prefer the NEWER observation": every write from either
19
+ * source is stamped with a shared monotonic sequence, and `resolve()` returns
20
+ * whichever was observed last. A fresh assistant line always reclaims the
21
+ * transcript as the source; a confirmed switch always beats an older
22
+ * transcript line. Pinned by tests/session-model-source.test.ts.
23
+ */
24
+
25
+ export interface SessionModelResolution {
26
+ /** The raw value from the winning source. Transcript entries are resolved
27
+ * model ids (`claude-opus-4-8`); override entries may be friendly labels
28
+ * ("Opus 4.8") or sr-* ids depending on the /model path that set them. */
29
+ model: string
30
+ source: 'transcript' | 'override'
31
+ }
32
+
33
+ export interface SessionModelSource {
34
+ /** Record a transcript observation (an assistant line's `message.model`,
35
+ * already sentinel-filtered by the session-tail projection). */
36
+ noteTranscriptModel(model: string): void
37
+ /** Record an override set (a positively-confirmed /model switch), or clear
38
+ * it with null. Setting stamps a fresh sequence, so the override wins over
39
+ * every EARLIER transcript observation until a new assistant line lands. */
40
+ setOverride(model: string | null): void
41
+ /** Current override value (the #2982 in-memory record), independent of
42
+ * freshness — for callers that need the override itself (e.g. the model
43
+ * menu's "session" marker), not the /status resolution. */
44
+ getOverride(): string | null
45
+ /** The freshest observation across both sources, or null when neither has
46
+ * reported yet. */
47
+ resolve(): SessionModelResolution | null
48
+ }
49
+
50
+ export function createSessionModelSource(): SessionModelSource {
51
+ let seq = 0
52
+ let transcript: { model: string; seq: number } | null = null
53
+ let override: { model: string; seq: number } | null = null
54
+ return {
55
+ noteTranscriptModel(model: string): void {
56
+ transcript = { model, seq: ++seq }
57
+ },
58
+ setOverride(model: string | null): void {
59
+ override = model == null ? null : { model, seq: ++seq }
60
+ },
61
+ getOverride(): string | null {
62
+ return override?.model ?? null
63
+ },
64
+ resolve(): SessionModelResolution | null {
65
+ if (transcript == null && override == null) return null
66
+ if (override == null) return { model: transcript!.model, source: 'transcript' }
67
+ if (transcript == null || transcript.seq < override.seq) {
68
+ return { model: override.model, source: 'override' }
69
+ }
70
+ return { model: transcript.model, source: 'transcript' }
71
+ },
72
+ }
73
+ }
@@ -25,6 +25,15 @@ export interface WorkerFeedDispatch {
25
25
  * result returns to its dispatching worker as the Task tool result).
26
26
  */
27
27
  isNested: boolean
28
+ /**
29
+ * Dispatch-time / last-persisted model for the worker, from the registry
30
+ * row's `model` column (seeded by the pretool hook from `tool_input.model`,
31
+ * later updated by the watcher from the worker's transcript). The FIRST-PAINT
32
+ * fallback the worker card renders before the live watcher entry has observed
33
+ * a transcript model. Null when the row is missing or never carried a model —
34
+ * the card then omits the model rather than guessing from config.
35
+ */
36
+ feedModel: string | null
28
37
  }
29
38
 
30
39
  /**
@@ -43,14 +52,28 @@ export interface WorkerFeedDispatch {
43
52
  * decision again: a regression here silently reverts the feed header to
44
53
  * "· sub-agent".
45
54
  */
55
+ /**
56
+ * `entryBackground` (fix #1(+#2)): the in-memory watcher entry's own cached
57
+ * `background` flag (`WorkerEntry.background`), passed by the gateway as a
58
+ * graceful-degradation fallback for when the registry row (`sub`) is
59
+ * missing — most often because `jsonl_agent_id` never linked (unreadable
60
+ * meta.json, or an ambiguous fuzzy backfill — see fix #3). Without this, a
61
+ * missing row hard-defaults `isBackground` to `false`, silently dropping a
62
+ * completed background worker's handback (the gateway's `onFinish` treats
63
+ * `false` as "nothing to deliver — it returns inline"). Ignored entirely
64
+ * when `sub` resolves — the registry row is always the authoritative
65
+ * source once it links.
66
+ */
46
67
  export function resolveWorkerFeedDispatch(
47
68
  sub: Subagent | null,
48
69
  watcherDescription: string,
70
+ entryBackground?: boolean,
49
71
  ): WorkerFeedDispatch {
50
72
  return {
51
- isBackground: sub?.background ?? false,
73
+ isBackground: sub?.background ?? entryBackground ?? false,
52
74
  feedDescription: (sub?.description ?? '') || watcherDescription,
53
75
  hasRow: sub != null,
54
76
  isNested: sub?.parent_agent_id != null,
77
+ feedModel: sub?.model ?? null,
55
78
  }
56
79
  }