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.
@@ -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, HEARTBEAT.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."),
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, HEARTBEAT.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."),
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, HEARTBEAT.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."),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- # HEARTBEAT.md) are injected via the UserPromptSubmit hook, not here.
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, HEARTBEAT.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."),
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
- return `<b>${escapeHtml6(meta.agentName)}</b> \u00b7 model: <code>${escapeHtml6(m)}</code>${topic}`;
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(`Now: <b>${deps.escapeHtml(current.label)}</b>${detail}`);
45021
+ lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`);
45020
45022
  } else {
45021
- lines.push("Now: <i>unknown (no \u2714 row in picker)</i>");
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 (applies to the live session):");
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 { answer: "Agent is mid-turn \u2014 try again shortly", reply: busyReply(deps) };
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 fresh2 = await buildModelMenu(deps);
45054
- return { answer: "Model list changed \u2014 menu refreshed", reply: fresh2 };
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
- const confirmed = {
45072
- text: [`\u2705 ${deps.escapeHtml(result.confirmation)}`, "", fresh.text].join(`
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 cutoff = now() - escalateAfterMs;
48500
- let n = 0;
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
- n++;
48556
+ if (postNotice)
48557
+ posted++;
48558
+ dropped++;
48513
48559
  }
48514
- if (n > 0) {
48515
- log(`inbound-spool: escalated+dropped ${n} undelivered entr${n === 1 ? "y" : "ies"} (older than ${escalateAfterMs}ms)
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 n;
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.3";
53558
- var COMMIT_SHA = "af652154";
53559
- var COMMIT_DATE = "2026-06-10T09:15:23Z";
53560
- var LATEST_PR = 2266;
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
- await ctx.answerCallbackQuery({ text: "Working\u2026" }).catch(() => {});
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, buildModelDeps());
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
- // Answer the callback IMMEDIATELY — the select path drives the
18413
- // picker up to three times (multi-second); leaving the tap
18414
- // spinning invites a double-tap, which queues a second drive
18415
- // behind the pane lock and confuses the user. The final state is
18416
- // conveyed by the message edit (a callback can only be answered
18417
- // once).
18418
- await ctx.answerCallbackQuery({ text: 'Working…' }).catch(() => {})
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, buildModelDeps())
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',