switchroom 0.18.3 → 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.
- package/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- 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/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- 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/model-command.ts +212 -51
- 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/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -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/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -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-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- 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 +115 -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/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -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
|
+
}
|
|
@@ -126,7 +126,27 @@ export function classifyRejection(
|
|
|
126
126
|
// handler shut the process down. reconcileStatusPin now absorbs its own
|
|
127
127
|
// errors (primary fix); this entry is defense-in-depth so ANY leaked
|
|
128
128
|
// pin-rights 400 from any path is log-only, not fatal.
|
|
129
|
-
desc.includes('not enough rights')
|
|
129
|
+
desc.includes('not enough rights') ||
|
|
130
|
+
// 'group chat was upgraded to a supergroup chat' fires when a send
|
|
131
|
+
// targets a basic-group chat_id that Telegram has since migrated to
|
|
132
|
+
// a supergroup (new id format -100xxxxxxxxxx). Telegram sometimes
|
|
133
|
+
// surfaces the replacement id via `error.parameters.migrate_to_chat_id`
|
|
134
|
+
// but not reliably for every send method, so this handler can't always
|
|
135
|
+
// auto-repair the stale id — it can only make the failure non-fatal.
|
|
136
|
+
// This crashed marko's gateway on 2026-07-09 (and recurred from the
|
|
137
|
+
// same stale-id root cause on 2026-06-07 and 2026-06-09): a send to an
|
|
138
|
+
// old pre-migration group id crashed the WHOLE gateway process for
|
|
139
|
+
// every agent/chat, not just the one stale chat. A single unmigrated
|
|
140
|
+
// chat_id in cached state must never be fatal — log it so the stale id
|
|
141
|
+
// can be tracked down and fixed, but keep serving every other chat.
|
|
142
|
+
desc.includes('group chat was upgraded to a supergroup chat') ||
|
|
143
|
+
// Broader class: any 400 whose description signals the target
|
|
144
|
+
// chat_id itself is no longer valid (migrated, deactivated, or
|
|
145
|
+
// otherwise unresolvable). These are all "this one destination is
|
|
146
|
+
// broken", never "the gateway is broken" — same log-only posture as
|
|
147
|
+
// 'chat not found' above.
|
|
148
|
+
desc.includes('chat_id is empty') ||
|
|
149
|
+
desc.includes('group chat was deactivated')
|
|
130
150
|
) {
|
|
131
151
|
return 'log_only'
|
|
132
152
|
}
|
|
@@ -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
|
}
|