switchroom 0.15.3 → 0.15.5
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/bin/turn-pacing-hook.sh +112 -0
- package/bin/workspace-dynamic-hook.sh +105 -15
- package/bin/workspace-stable-hook.sh +2 -2
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +442 -394
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +100 -39
- package/telegram-plugin/gateway/gateway.ts +45 -9
- package/telegram-plugin/gateway/inbound-spool.ts +107 -16
- package/telegram-plugin/gateway/model-command.ts +89 -21
- package/telegram-plugin/tests/inbound-spool.test.ts +101 -0
- package/telegram-plugin/tests/model-command.test.ts +41 -6
- package/telegram-plugin/tests/welcome-text.test.ts +11 -0
- package/telegram-plugin/welcome-text.ts +16 -1
- package/profiles/default/workspace/HEARTBEAT.md.hbs +0 -40
|
@@ -13825,7 +13825,8 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
13825
13825
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
13826
13826
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
13827
13827
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
|
|
13828
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
13828
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
13829
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
13829
13830
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
13830
13831
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
13831
13832
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
|
@@ -11411,7 +11411,8 @@ var init_schema = __esm(() => {
|
|
|
11411
11411
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
11412
11412
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
11413
11413
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
|
|
11414
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
11414
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
11415
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
11415
11416
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
11416
11417
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
11417
11418
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
|
@@ -11411,7 +11411,8 @@ var init_schema = __esm(() => {
|
|
|
11411
11411
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
11412
11412
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
11413
11413
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
|
|
11414
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
11414
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
11415
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
11415
11416
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
11416
11417
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
11417
11418
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
package/package.json
CHANGED
|
@@ -620,8 +620,8 @@ APPEND_PROMPT={{#if systemPromptAppendShellQuoted}}{{{systemPromptAppendShellQuo
|
|
|
620
620
|
# Inject AGENTS.md / SOUL.md / IDENTITY.md / USER.md / TOOLS.md /
|
|
621
621
|
# BOOTSTRAP.md from the agent's workspace/ dir into --append-system-prompt.
|
|
622
622
|
# These files are stable across a session, so baking them into the system
|
|
623
|
-
# prompt is cache-friendly. Dynamic files (MEMORY.md, today's daily
|
|
624
|
-
#
|
|
623
|
+
# prompt is cache-friendly. Dynamic files (MEMORY.md, today's daily)
|
|
624
|
+
# are injected via the UserPromptSubmit hook, not here.
|
|
625
625
|
#
|
|
626
626
|
# When channels.telegram.hotReloadStable is true, this injection moves to
|
|
627
627
|
# a UserPromptSubmit hook instead (see workspace-stable-hook.sh).
|
|
@@ -23936,7 +23936,8 @@ var init_schema = __esm(() => {
|
|
|
23936
23936
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI \u2014 " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events \u2014 stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
23937
23937
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive stream edits (or " + "sendMessageDraft tics) during a turn. Lower = more responsive " + "stream, higher = fewer API calls. Floored at 250 by draft-stream " + "itself. Default 300 for draft transport (DMs) and 1000 for " + "message transport (groups/forums). Override per-agent if a " + "particular agent needs snappier or quieter streaming."),
|
|
23938
23938
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message \u2014 Reading X, Searching the web for Y, \u2026) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) \u2014 no post-then-delete. Per-agent " + "override; cascades defaults \u2192 profile \u2192 agent (per-key)."),
|
|
23939
|
-
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md
|
|
23939
|
+
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
23940
|
+
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes \u2014 suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
23940
23941
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
23941
23942
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
23942
23943
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
|
@@ -42942,7 +42943,8 @@ function formatAuthLine(auth) {
|
|
|
42942
42943
|
function formatAgentLine(meta) {
|
|
42943
42944
|
const m = meta.model && meta.model.length > 0 ? meta.model : "default";
|
|
42944
42945
|
const topic = meta.topicName ? ` \u00b7 topic: ${escapeHtml6([meta.topicEmoji, meta.topicName].filter(Boolean).join(" "))}` : "";
|
|
42945
|
-
|
|
42946
|
+
const session = meta.sessionModel && meta.sessionModel.length > 0 ? ` \u00b7 live session: <code>${escapeHtml6(meta.sessionModel)}</code>` : "";
|
|
42947
|
+
return `<b>${escapeHtml6(meta.agentName)}</b> \u00b7 model: <code>${escapeHtml6(m)}</code>${session}${topic}`;
|
|
42946
42948
|
}
|
|
42947
42949
|
function startText(agentName3, dmDisabled) {
|
|
42948
42950
|
if (dmDisabled)
|
|
@@ -45016,13 +45018,13 @@ async function buildModelMenu(deps) {
|
|
|
45016
45018
|
}
|
|
45017
45019
|
if (current) {
|
|
45018
45020
|
const detail = current.detail ? ` \u00b7 ${deps.escapeHtml(current.detail)}` : "";
|
|
45019
|
-
lines.push(`
|
|
45021
|
+
lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`);
|
|
45020
45022
|
} else {
|
|
45021
|
-
lines.push("
|
|
45023
|
+
lines.push("Default (new sessions): <i>unknown (no \u2714 row in picker)</i>");
|
|
45022
45024
|
}
|
|
45023
45025
|
if (quota)
|
|
45024
45026
|
lines.push(`Quota: ${deps.escapeHtml(quota)}`);
|
|
45025
|
-
lines.push("", "Tap to switch
|
|
45027
|
+
lines.push("", "Tap a model to switch the <b>live session</b>:");
|
|
45026
45028
|
lines.push(PERSIST_NOTE);
|
|
45027
45029
|
return { text: lines.join(`
|
|
45028
45030
|
`), html: true, keyboard: menuKeyboard(discovered.options) };
|
|
@@ -45035,46 +45037,51 @@ async function handleModelMenuCallback(data, deps) {
|
|
|
45035
45037
|
return { answer: "Unknown action", reply: await buildModelMenu(deps) };
|
|
45036
45038
|
}
|
|
45037
45039
|
if (deps.isBusy()) {
|
|
45038
|
-
return {
|
|
45040
|
+
return {
|
|
45041
|
+
answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
|
|
45042
|
+
reply: busyReply(deps),
|
|
45043
|
+
toastOnly: true
|
|
45044
|
+
};
|
|
45039
45045
|
}
|
|
45040
45046
|
const tag = data.slice(MODEL_CALLBACK_SELECT.length);
|
|
45041
45047
|
const discovered = await deps.discover(deps.getAgentName());
|
|
45042
45048
|
if (!discovered.ok) {
|
|
45043
45049
|
return {
|
|
45044
45050
|
answer: "Picker unavailable",
|
|
45045
|
-
reply: {
|
|
45046
|
-
text: `\u274c Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
|
|
45047
|
-
html: true
|
|
45048
|
-
}
|
|
45051
|
+
reply: await menuWithBanner(deps, `\u274c Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`)
|
|
45049
45052
|
};
|
|
45050
45053
|
}
|
|
45051
45054
|
const target = discovered.options.find((o) => labelTag(o.label) === tag);
|
|
45052
45055
|
if (!target) {
|
|
45053
|
-
const
|
|
45054
|
-
return { answer: "Model list changed \u2014 menu refreshed", reply:
|
|
45055
|
-
}
|
|
45056
|
-
if (target.current) {
|
|
45057
|
-
const fresh2 = await buildModelMenu(deps);
|
|
45058
|
-
return { answer: `Already on ${target.label}`, reply: fresh2 };
|
|
45056
|
+
const fresh = await buildModelMenu(deps);
|
|
45057
|
+
return { answer: "Model list changed \u2014 menu refreshed", reply: fresh };
|
|
45059
45058
|
}
|
|
45060
45059
|
const result = await deps.select(deps.getAgentName(), target.label);
|
|
45061
45060
|
if (!result.ok) {
|
|
45062
45061
|
return {
|
|
45063
|
-
answer: "Switch failed",
|
|
45064
|
-
reply: {
|
|
45065
|
-
text: `\u274c Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
|
|
45066
|
-
html: true
|
|
45067
|
-
}
|
|
45062
|
+
answer: "Switch failed \u2014 see the menu",
|
|
45063
|
+
reply: await menuWithBanner(deps, `\u274c Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`)
|
|
45068
45064
|
};
|
|
45069
45065
|
}
|
|
45066
|
+
return {
|
|
45067
|
+
answer: deps.escapeHtml(result.confirmation),
|
|
45068
|
+
reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(result.confirmation)}`),
|
|
45069
|
+
selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label
|
|
45070
|
+
};
|
|
45071
|
+
}
|
|
45072
|
+
function sessionModelFromConfirmation(confirmation) {
|
|
45073
|
+
const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(confirmation.trim());
|
|
45074
|
+
const name = m?.[1]?.trim();
|
|
45075
|
+
return name && name.length > 0 ? name : null;
|
|
45076
|
+
}
|
|
45077
|
+
async function menuWithBanner(deps, banner) {
|
|
45070
45078
|
const fresh = await buildModelMenu(deps);
|
|
45071
|
-
|
|
45072
|
-
text: [
|
|
45079
|
+
return {
|
|
45080
|
+
text: [banner, "", fresh.text].join(`
|
|
45073
45081
|
`),
|
|
45074
45082
|
html: true,
|
|
45075
45083
|
...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
|
|
45076
45084
|
};
|
|
45077
|
-
return { answer: result.confirmation, reply: confirmed };
|
|
45078
45085
|
}
|
|
45079
45086
|
|
|
45080
45087
|
// ../src/agents/model-picker.ts
|
|
@@ -48353,13 +48360,20 @@ function spoolId(msg) {
|
|
|
48353
48360
|
const src = msg.meta?.source ?? "-";
|
|
48354
48361
|
return `s:${msg.chatId}:${src}:${msg.ts}`;
|
|
48355
48362
|
}
|
|
48363
|
+
function escChatKey(msg) {
|
|
48364
|
+
const threadRaw = msg.meta?.threadId;
|
|
48365
|
+
const thread = typeof threadRaw === "string" && threadRaw.length > 0 ? threadRaw : "-";
|
|
48366
|
+
return `${msg.chatId}:${thread}`;
|
|
48367
|
+
}
|
|
48356
48368
|
function createInboundSpool(opts) {
|
|
48357
48369
|
const { path, fs: fs2 } = opts;
|
|
48358
48370
|
const now = opts.now ?? Date.now;
|
|
48359
48371
|
const log = opts.log ?? ((l) => process.stderr.write(l));
|
|
48360
48372
|
const escalateAfterMs = opts.escalateAfterMs ?? 15 * 60 * 1000;
|
|
48361
48373
|
const compactAtBytes = opts.compactAtBytes ?? 256 * 1024;
|
|
48374
|
+
const escalateNoticeCooldownMs = opts.escalateNoticeCooldownMs ?? 30 * 60 * 1000;
|
|
48362
48375
|
const live = new Map;
|
|
48376
|
+
const escAttemptByChat = new Map;
|
|
48363
48377
|
function parseLine(line) {
|
|
48364
48378
|
const s = line.trim();
|
|
48365
48379
|
if (!s)
|
|
@@ -48373,8 +48387,15 @@ function createInboundSpool(opts) {
|
|
|
48373
48387
|
if (rec == null || typeof rec !== "object")
|
|
48374
48388
|
return null;
|
|
48375
48389
|
const r = rec;
|
|
48376
|
-
if (r.t !== "put" && r.t !== "ack")
|
|
48390
|
+
if (r.t !== "put" && r.t !== "ack" && r.t !== "esc")
|
|
48377
48391
|
return null;
|
|
48392
|
+
if (r.t === "esc") {
|
|
48393
|
+
if (typeof r.chat !== "string" && typeof r.chat !== "number")
|
|
48394
|
+
return null;
|
|
48395
|
+
if (typeof r.at !== "number")
|
|
48396
|
+
return null;
|
|
48397
|
+
return r;
|
|
48398
|
+
}
|
|
48378
48399
|
if (typeof r.id !== "string" || r.id.length === 0)
|
|
48379
48400
|
return null;
|
|
48380
48401
|
if (r.t === "put") {
|
|
@@ -48389,6 +48410,7 @@ function createInboundSpool(opts) {
|
|
|
48389
48410
|
}
|
|
48390
48411
|
function hydrate() {
|
|
48391
48412
|
live.clear();
|
|
48413
|
+
escAttemptByChat.clear();
|
|
48392
48414
|
if (!fs2.existsSync(path))
|
|
48393
48415
|
return;
|
|
48394
48416
|
let raw = "";
|
|
@@ -48408,6 +48430,8 @@ function createInboundSpool(opts) {
|
|
|
48408
48430
|
msg: rec.msg,
|
|
48409
48431
|
firstAt: rec.firstAt
|
|
48410
48432
|
});
|
|
48433
|
+
} else if (rec.t === "esc") {
|
|
48434
|
+
escAttemptByChat.set(`${rec.chat}:${rec.thread ?? "-"}`, rec.at);
|
|
48411
48435
|
} else {
|
|
48412
48436
|
live.delete(rec.id);
|
|
48413
48437
|
}
|
|
@@ -48435,6 +48459,17 @@ function createInboundSpool(opts) {
|
|
|
48435
48459
|
for (const [id, e] of live) {
|
|
48436
48460
|
lines.push(JSON.stringify({ t: "put", id, agent: e.agent, msg: e.msg, firstAt: e.firstAt }));
|
|
48437
48461
|
}
|
|
48462
|
+
for (const [key, at] of escAttemptByChat) {
|
|
48463
|
+
const sep3 = key.lastIndexOf(":");
|
|
48464
|
+
const chat = key.slice(0, sep3);
|
|
48465
|
+
const thread = key.slice(sep3 + 1);
|
|
48466
|
+
lines.push(JSON.stringify({
|
|
48467
|
+
t: "esc",
|
|
48468
|
+
chat,
|
|
48469
|
+
...thread !== "-" ? { thread } : {},
|
|
48470
|
+
at
|
|
48471
|
+
}));
|
|
48472
|
+
}
|
|
48438
48473
|
const tmp = path + ".compact.tmp";
|
|
48439
48474
|
try {
|
|
48440
48475
|
fs2.writeFileSync(tmp, lines.length ? lines.join(`
|
|
@@ -48496,27 +48531,39 @@ function createInboundSpool(opts) {
|
|
|
48496
48531
|
return n;
|
|
48497
48532
|
},
|
|
48498
48533
|
sweepEscalations(onEscalate) {
|
|
48499
|
-
const
|
|
48500
|
-
|
|
48534
|
+
const tNow = now();
|
|
48535
|
+
const cutoff = tNow - escalateAfterMs;
|
|
48536
|
+
let dropped = 0;
|
|
48537
|
+
let posted = 0;
|
|
48501
48538
|
for (const [id, e] of [...live.entries()]) {
|
|
48502
48539
|
if (e.firstAt > cutoff)
|
|
48503
48540
|
continue;
|
|
48504
48541
|
live.delete(id);
|
|
48505
48542
|
appendRecord({ t: "ack", id });
|
|
48543
|
+
const key = escChatKey(e.msg);
|
|
48544
|
+
const lastAttempt = escAttemptByChat.get(key);
|
|
48545
|
+
const postNotice = lastAttempt === undefined || tNow - lastAttempt >= escalateNoticeCooldownMs;
|
|
48546
|
+
escAttemptByChat.set(key, tNow);
|
|
48547
|
+
const threadRaw = e.msg.meta?.threadId;
|
|
48548
|
+
const thread = typeof threadRaw === "string" && threadRaw.length > 0 ? threadRaw : undefined;
|
|
48549
|
+
appendRecord({ t: "esc", chat: e.msg.chatId, thread, at: tNow });
|
|
48506
48550
|
try {
|
|
48507
|
-
onEscalate({ agent: e.agent, msg: e.msg });
|
|
48551
|
+
onEscalate({ agent: e.agent, msg: e.msg }, { postNotice });
|
|
48508
48552
|
} catch (err) {
|
|
48509
48553
|
log(`inbound-spool: onEscalate threw id=${id}: ${err.message}
|
|
48510
48554
|
`);
|
|
48511
48555
|
}
|
|
48512
|
-
|
|
48556
|
+
if (postNotice)
|
|
48557
|
+
posted++;
|
|
48558
|
+
dropped++;
|
|
48513
48559
|
}
|
|
48514
|
-
if (
|
|
48515
|
-
|
|
48560
|
+
if (dropped > 0) {
|
|
48561
|
+
const suppressed = dropped - posted;
|
|
48562
|
+
log(`inbound-spool: escalated+dropped ${dropped} undelivered entr${dropped === 1 ? "y" : "ies"} ` + `(older than ${escalateAfterMs}ms; ${posted} notice${posted === 1 ? "" : "s"} posted` + `${suppressed > 0 ? `, ${suppressed} coalesced` : ""})
|
|
48516
48563
|
`);
|
|
48517
48564
|
maybeCompact();
|
|
48518
48565
|
}
|
|
48519
|
-
return
|
|
48566
|
+
return dropped;
|
|
48520
48567
|
},
|
|
48521
48568
|
liveCount() {
|
|
48522
48569
|
return live.size;
|
|
@@ -53554,10 +53601,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
53554
53601
|
}
|
|
53555
53602
|
|
|
53556
53603
|
// ../src/build-info.ts
|
|
53557
|
-
var VERSION = "0.15.
|
|
53558
|
-
var COMMIT_SHA = "
|
|
53559
|
-
var COMMIT_DATE = "2026-06-
|
|
53560
|
-
var LATEST_PR =
|
|
53604
|
+
var VERSION = "0.15.5";
|
|
53605
|
+
var COMMIT_SHA = "fcc9d7b7";
|
|
53606
|
+
var COMMIT_DATE = "2026-06-11T21:49:30Z";
|
|
53607
|
+
var LATEST_PR = 2282;
|
|
53561
53608
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
53562
53609
|
|
|
53563
53610
|
// gateway/boot-version.ts
|
|
@@ -57008,11 +57055,13 @@ if (!STATIC) {
|
|
|
57008
57055
|
process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
|
|
57009
57056
|
`);
|
|
57010
57057
|
}
|
|
57011
|
-
inboundSpool?.sweepEscalations((e) => {
|
|
57058
|
+
inboundSpool?.sweepEscalations((e, { postNotice }) => {
|
|
57012
57059
|
const chat = e.msg.chatId;
|
|
57013
57060
|
const escThread = typeof e.msg.meta?.threadId === "string" && e.msg.meta.threadId ? Number(e.msg.meta.threadId) : undefined;
|
|
57014
57061
|
const threadOpts = escThread != null ? { message_thread_id: escThread } : {};
|
|
57015
57062
|
reapQueuedStatus(chat, escThread);
|
|
57063
|
+
if (!postNotice)
|
|
57064
|
+
return;
|
|
57016
57065
|
swallowingApiCall(() => bot.api.sendMessage(chat, "\u26A0\uFE0F I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-spool-escalation" });
|
|
57017
57066
|
});
|
|
57018
57067
|
}, IDLE_DRAIN_INTERVAL_MS).unref();
|
|
@@ -61068,6 +61117,7 @@ function buildAgentAudit(agentName3) {
|
|
|
61068
61117
|
return;
|
|
61069
61118
|
}
|
|
61070
61119
|
}
|
|
61120
|
+
var activeSessionModelOverride = null;
|
|
61071
61121
|
async function buildAgentMetadata(agentName3) {
|
|
61072
61122
|
const list2 = switchroomExecJson(["agent", "list"]);
|
|
61073
61123
|
const brokerState = switchroomExecJson(["auth", "show"]);
|
|
@@ -61084,6 +61134,7 @@ async function buildAgentMetadata(agentName3) {
|
|
|
61084
61134
|
return {
|
|
61085
61135
|
agentName: agentName3,
|
|
61086
61136
|
model: a?.model ?? null,
|
|
61137
|
+
sessionModel: activeSessionModelOverride,
|
|
61087
61138
|
extendsProfile: a?.extends ?? a?.template ?? null,
|
|
61088
61139
|
topicName: a?.topic_name ?? null,
|
|
61089
61140
|
topicEmoji: a?.topic_emoji ?? null,
|
|
@@ -63999,9 +64050,19 @@ bot.on("callback_query:data", async (ctx) => {
|
|
|
63999
64050
|
}).catch(() => {});
|
|
64000
64051
|
return;
|
|
64001
64052
|
}
|
|
64002
|
-
|
|
64053
|
+
const modelDeps = buildModelDeps();
|
|
64054
|
+
if (modelDeps.isBusy()) {
|
|
64055
|
+
await ctx.answerCallbackQuery({ text: "\u23F3 Agent is mid-turn \u2014 tap again when it\u2019s idle", show_alert: false }).catch(() => {});
|
|
64056
|
+
return;
|
|
64057
|
+
}
|
|
64058
|
+
await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
|
|
64003
64059
|
try {
|
|
64004
|
-
const outcome = await handleModelMenuCallback(data,
|
|
64060
|
+
const outcome = await handleModelMenuCallback(data, modelDeps);
|
|
64061
|
+
if (outcome.selectedModel) {
|
|
64062
|
+
activeSessionModelOverride = outcome.selectedModel;
|
|
64063
|
+
}
|
|
64064
|
+
if (outcome.toastOnly)
|
|
64065
|
+
return;
|
|
64005
64066
|
await ctx.editMessageText(outcome.reply.text, {
|
|
64006
64067
|
parse_mode: "HTML",
|
|
64007
64068
|
reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
|
|
@@ -6609,7 +6609,7 @@ if (!STATIC) {
|
|
|
6609
6609
|
// promise EXPLICITLY (honest failure) instead of letting it sit
|
|
6610
6610
|
// forever. This is what makes the guarantee deterministic: every
|
|
6611
6611
|
// queued message ends either delivered or visibly retracted.
|
|
6612
|
-
inboundSpool?.sweepEscalations((e) => {
|
|
6612
|
+
inboundSpool?.sweepEscalations((e, { postNotice }) => {
|
|
6613
6613
|
const chat = e.msg.chatId
|
|
6614
6614
|
const escThread =
|
|
6615
6615
|
typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
|
|
@@ -6620,7 +6620,14 @@ if (!STATIC) {
|
|
|
6620
6620
|
// the message is being declared undeliverable, so the queued-status must
|
|
6621
6621
|
// not dangle beside the "couldn't deliver" notice (idempotent best-effort;
|
|
6622
6622
|
// a normal turn-start/turn-end reaps far sooner — this is the 15-min edge).
|
|
6623
|
+
// Reaping happens for EVERY dropped entry; only the user-facing notice is
|
|
6624
|
+
// coalesced (postNotice), so a burst of undeliverable inbounds doesn't
|
|
6625
|
+
// leave dangling placeholders even when its notice is suppressed.
|
|
6623
6626
|
reapQueuedStatus(chat, escThread)
|
|
6627
|
+
// Coalesced per chat by the spool's sliding window — a multi-restart
|
|
6628
|
+
// outage that re-ages a synthetic into the bound every 15 min posts ONE
|
|
6629
|
+
// notice, not one per cycle (the 2026-06-09 marko "please resend" spam).
|
|
6630
|
+
if (!postNotice) return
|
|
6624
6631
|
void swallowingApiCall(
|
|
6625
6632
|
() =>
|
|
6626
6633
|
bot.api.sendMessage(
|
|
@@ -13767,6 +13774,14 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
|
|
|
13767
13774
|
// broker's fleet-wide `ListStateData` payload via
|
|
13768
13775
|
// `buildAuthSummaryFromBroker`, with billingType pulled from the
|
|
13769
13776
|
// agent's `.claude.json` (the broker doesn't track plan tier).
|
|
13777
|
+
/**
|
|
13778
|
+
* Live session-model override set by the `/model` picker (session-only). Held
|
|
13779
|
+
* in gateway memory so it clears on restart, the same point at which claude's
|
|
13780
|
+
* session reverts to the configured model — keeping `/status` honest without
|
|
13781
|
+
* a persisted store. Null when no session switch is active.
|
|
13782
|
+
*/
|
|
13783
|
+
let activeSessionModelOverride: string | null = null
|
|
13784
|
+
|
|
13770
13785
|
async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
13771
13786
|
type AgentListResp = {
|
|
13772
13787
|
agents: Array<{
|
|
@@ -13795,6 +13810,7 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
|
13795
13810
|
return {
|
|
13796
13811
|
agentName,
|
|
13797
13812
|
model: a?.model ?? null,
|
|
13813
|
+
sessionModel: activeSessionModelOverride,
|
|
13798
13814
|
extendsProfile: (a?.extends ?? a?.template) ?? null,
|
|
13799
13815
|
topicName: a?.topic_name ?? null,
|
|
13800
13816
|
topicEmoji: a?.topic_emoji ?? null,
|
|
@@ -18409,15 +18425,35 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18409
18425
|
.catch(() => {})
|
|
18410
18426
|
return
|
|
18411
18427
|
}
|
|
18412
|
-
|
|
18413
|
-
//
|
|
18414
|
-
//
|
|
18415
|
-
//
|
|
18416
|
-
//
|
|
18417
|
-
//
|
|
18418
|
-
|
|
18428
|
+
const modelDeps = buildModelDeps()
|
|
18429
|
+
// Mid-turn refusal is INSTANT (a sync isBusy() check, no picker drive),
|
|
18430
|
+
// so handle it before the "Working…" ack: toast WHY and leave the menu
|
|
18431
|
+
// message untouched (buttons intact) so the operator taps again when
|
|
18432
|
+
// idle. Editing the menu into a button-less "try again" line was the
|
|
18433
|
+
// "nothing happened" report — the menu looked dead.
|
|
18434
|
+
if (modelDeps.isBusy()) {
|
|
18435
|
+
await ctx
|
|
18436
|
+
.answerCallbackQuery({ text: '⏳ Agent is mid-turn — tap again when it’s idle', show_alert: false })
|
|
18437
|
+
.catch(() => {})
|
|
18438
|
+
return
|
|
18439
|
+
}
|
|
18440
|
+
// Ack IMMEDIATELY — the select path drives the picker (multi-second);
|
|
18441
|
+
// leaving the tap spinning invites a double-tap, which queues a second
|
|
18442
|
+
// drive behind the pane lock. A callback can only be answered once, so
|
|
18443
|
+
// the rich result (what was set / why it failed) is conveyed by the
|
|
18444
|
+
// message edit — which now ALWAYS keeps the menu buttons.
|
|
18445
|
+
await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
|
|
18419
18446
|
try {
|
|
18420
|
-
const outcome = await handleModelMenuCallback(data,
|
|
18447
|
+
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
18448
|
+
// Record a successful session switch so /status reflects what's
|
|
18449
|
+
// actually running. In-memory only → clears when the gateway (and thus
|
|
18450
|
+
// claude's session) restarts, exactly matching the session-only scope.
|
|
18451
|
+
if (outcome.selectedModel) {
|
|
18452
|
+
activeSessionModelOverride = outcome.selectedModel
|
|
18453
|
+
}
|
|
18454
|
+
// toastOnly: a no-op outcome that should not disturb the menu (defence
|
|
18455
|
+
// in depth — the isBusy() short-circuit above is the live path).
|
|
18456
|
+
if (outcome.toastOnly) return
|
|
18421
18457
|
await ctx
|
|
18422
18458
|
.editMessageText(outcome.reply.text, {
|
|
18423
18459
|
parse_mode: 'HTML',
|