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,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
|
|
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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session-model stickiness — file helpers shared by the gateway.
|
|
3
|
+
*
|
|
4
|
+
* Two files in the bind-mounted agent state dir carry the contract
|
|
5
|
+
* (reference/rfcs/session-model-stickiness.md):
|
|
6
|
+
*
|
|
7
|
+
* - `.session-model` — the DURABLE session override written on every
|
|
8
|
+
* positively-confirmed `/model` switch. One-line JSON
|
|
9
|
+
* `{"model","configuredDefaultAtWrite","ts"}`. It is NOT consumed by a
|
|
10
|
+
* keep-path boot; start.sh deletes it on revert / invalidation /
|
|
11
|
+
* corruption / 7-day staleness, and the gateway deletes it on
|
|
12
|
+
* `/model default`.
|
|
13
|
+
*
|
|
14
|
+
* - `.relaunch-model-intent` — the ONE-SHOT intent bit for the next boot.
|
|
15
|
+
* One-line JSON `{"intent":"keep"|"revert","reason","ts"}`, atomic
|
|
16
|
+
* write, last-writer-wins. Boot default is REVERT (operator decision:
|
|
17
|
+
* a raw `docker restart` / host reboot / crash must revert to the yaml
|
|
18
|
+
* model), so every switchroom-managed KEEP path must stamp keep-intent
|
|
19
|
+
* BEFORE the bounce. start.sh consumes it (rm -f) every boot; a stale
|
|
20
|
+
* (>10 min by the embedded ts) or corrupt intent counts as no intent.
|
|
21
|
+
*
|
|
22
|
+
* The `model` token is always a canonical `claude --model` token (alias,
|
|
23
|
+
* `claude-*` id, or `sr-*` id) — NEVER a display label like "Opus 4.8".
|
|
24
|
+
* Shape-gated on write with the same regex start.sh greps with.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import { isValidModelArg } from './model-command.js'
|
|
30
|
+
|
|
31
|
+
export const SESSION_MODEL_FILE = '.session-model'
|
|
32
|
+
export const RELAUNCH_MODEL_INTENT_FILE = '.relaunch-model-intent'
|
|
33
|
+
export const CONFIGURED_DEFAULT_MODEL_FILE = '.configured-default-model'
|
|
34
|
+
|
|
35
|
+
export type RelaunchModelIntent = 'keep' | 'revert'
|
|
36
|
+
|
|
37
|
+
export interface SessionModelRecord {
|
|
38
|
+
model: string
|
|
39
|
+
configuredDefaultAtWrite: string
|
|
40
|
+
ts: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Restart reasons whose semantics are "revert the session model to the
|
|
45
|
+
* configured default". Everything else `triggerSelfRestart` fires with is a
|
|
46
|
+
* switchroom-managed relaunch (watchdog recovery, drain-cap bounce,
|
|
47
|
+
* turn-complete deferred restart, fleet-fallback resume, sr-to-claude model
|
|
48
|
+
* switch, grant restarts) and KEEPS the override — the whole point of the
|
|
49
|
+
* stickiness contract. Enumerated in the RFC §3; default-keep here is safe
|
|
50
|
+
* because only gateway code calls triggerSelfRestart, and a bounce nobody
|
|
51
|
+
* stamped (crash, raw docker restart, deploy) reverts by boot default anyway.
|
|
52
|
+
*/
|
|
53
|
+
const REVERT_RESTART_REASONS: ReadonlySet<string> = new Set(['inline-button-restart'])
|
|
54
|
+
|
|
55
|
+
/** Classify a triggerSelfRestart reason into the intent the boot should honor. */
|
|
56
|
+
export function intentForRestartReason(reason: string): RelaunchModelIntent {
|
|
57
|
+
return REVERT_RESTART_REASONS.has(reason) ? 'revert' : 'keep'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function atomicWrite(path: string, content: string): void {
|
|
61
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
62
|
+
writeFileSync(tmp, content, 'utf8')
|
|
63
|
+
renameSync(tmp, path)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Serialize a `.session-model` record (one line + trailing newline). */
|
|
67
|
+
export function serializeSessionModel(rec: SessionModelRecord): string {
|
|
68
|
+
return `${JSON.stringify({
|
|
69
|
+
model: rec.model,
|
|
70
|
+
configuredDefaultAtWrite: rec.configuredDefaultAtWrite,
|
|
71
|
+
ts: rec.ts,
|
|
72
|
+
})}\n`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse `.session-model` content. Returns null on corrupt JSON, a missing /
|
|
77
|
+
* non-string field, or a model token that fails the MODEL_ARG_RE shape gate
|
|
78
|
+
* (never trust an unvalidated string near `claude --model`).
|
|
79
|
+
*/
|
|
80
|
+
export function parseSessionModel(text: string): SessionModelRecord | null {
|
|
81
|
+
try {
|
|
82
|
+
const raw = JSON.parse(text) as Partial<SessionModelRecord>
|
|
83
|
+
if (
|
|
84
|
+
typeof raw.model !== 'string' ||
|
|
85
|
+
typeof raw.configuredDefaultAtWrite !== 'string' ||
|
|
86
|
+
typeof raw.ts !== 'number' ||
|
|
87
|
+
!isValidModelArg(raw.model)
|
|
88
|
+
) {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
return { model: raw.model, configuredDefaultAtWrite: raw.configuredDefaultAtWrite, ts: raw.ts }
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write the durable session override. Throws on a non-canonical token —
|
|
99
|
+
* callers must pass a `claude --model` token, never a display label
|
|
100
|
+
* (regression guard for the "Opus 4.5 persisted" class).
|
|
101
|
+
*/
|
|
102
|
+
export function writeSessionModelFile(
|
|
103
|
+
agentDir: string,
|
|
104
|
+
model: string,
|
|
105
|
+
configuredDefaultAtWrite: string,
|
|
106
|
+
): void {
|
|
107
|
+
if (!isValidModelArg(model)) {
|
|
108
|
+
throw new Error(`refusing to persist non-canonical session model token: ${JSON.stringify(model)}`)
|
|
109
|
+
}
|
|
110
|
+
atomicWrite(
|
|
111
|
+
join(agentDir, SESSION_MODEL_FILE),
|
|
112
|
+
serializeSessionModel({ model, configuredDefaultAtWrite, ts: Date.now() }),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Raw file text (for rollback snapshots), or null when absent/unreadable. */
|
|
117
|
+
export function readSessionModelFileRaw(agentDir: string): string | null {
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(join(agentDir, SESSION_MODEL_FILE), 'utf8')
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Parsed durable override, or null when absent/corrupt. */
|
|
126
|
+
export function readSessionModelFile(agentDir: string): SessionModelRecord | null {
|
|
127
|
+
const raw = readSessionModelFileRaw(agentDir)
|
|
128
|
+
return raw == null ? null : parseSessionModel(raw)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Delete the durable override (`/model default`, rollback). Best-effort. */
|
|
132
|
+
export function clearSessionModelFile(agentDir: string): void {
|
|
133
|
+
try {
|
|
134
|
+
rmSync(join(agentDir, SESSION_MODEL_FILE), { force: true })
|
|
135
|
+
} catch {
|
|
136
|
+
/* best-effort */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Restore a rollback snapshot taken with readSessionModelFileRaw. */
|
|
141
|
+
export function restoreSessionModelFileRaw(agentDir: string, raw: string | null): void {
|
|
142
|
+
if (raw == null) {
|
|
143
|
+
clearSessionModelFile(agentDir)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
atomicWrite(join(agentDir, SESSION_MODEL_FILE), raw)
|
|
148
|
+
} catch {
|
|
149
|
+
/* best-effort */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Stamp the one-shot relaunch intent. MUST be called synchronously BEFORE
|
|
155
|
+
* the restart signal/dispatch it describes (write-before-kill invariant —
|
|
156
|
+
* the next start.sh boot reads this to decide keep vs revert). Best-effort:
|
|
157
|
+
* a failed write means the boot falls back to the default (revert), which
|
|
158
|
+
* is the safe side.
|
|
159
|
+
*/
|
|
160
|
+
export function writeRelaunchModelIntent(
|
|
161
|
+
agentDir: string,
|
|
162
|
+
intent: RelaunchModelIntent,
|
|
163
|
+
reason: string,
|
|
164
|
+
): void {
|
|
165
|
+
try {
|
|
166
|
+
atomicWrite(
|
|
167
|
+
join(agentDir, RELAUNCH_MODEL_INTENT_FILE),
|
|
168
|
+
`${JSON.stringify({ intent, reason, ts: Date.now() })}\n`,
|
|
169
|
+
)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
process.stderr.write(
|
|
172
|
+
`telegram gateway: relaunch-model-intent write failed (boot will revert): ${(err as Error)?.message ?? String(err)}\n`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Remove a stamped intent (rollback of a failed dispatch). Best-effort. */
|
|
178
|
+
export function clearRelaunchModelIntent(agentDir: string): void {
|
|
179
|
+
try {
|
|
180
|
+
rmSync(join(agentDir, RELAUNCH_MODEL_INTENT_FILE), { force: true })
|
|
181
|
+
} catch {
|
|
182
|
+
/* best-effort */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The resolved configured default start.sh recorded this boot
|
|
188
|
+
* (`.configured-default-model`, written before override resolution — the
|
|
189
|
+
* same resolver output the invalidation compare uses). Null when missing.
|
|
190
|
+
*/
|
|
191
|
+
export function readConfiguredDefaultModel(agentDir: string): string | null {
|
|
192
|
+
try {
|
|
193
|
+
const v = readFileSync(join(agentDir, CONFIGURED_DEFAULT_MODEL_FILE), 'utf8').trim()
|
|
194
|
+
return v.length > 0 ? v : null
|
|
195
|
+
} catch {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|