switchroom 0.14.18 → 0.14.20

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.
@@ -23738,8 +23738,13 @@ var init_schema = __esm(() => {
23738
23738
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
23739
23739
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
23740
23740
  coalesce: exports_external.object({
23741
- window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
23741
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
23742
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 \u2014 a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
23742
23743
  }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
23744
+ interrupt: exports_external.object({
23745
+ safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false \u2014 the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
23746
+ max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
23747
+ }).optional().describe("Interrupt timing \u2014 how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
23743
23748
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
23744
23749
  webhook_dispatch: exports_external.object({
23745
23750
  github: exports_external.array(exports_external.object({
@@ -30154,6 +30159,136 @@ var init_materialize_bot_token = __esm(() => {
30154
30159
  };
30155
30160
  });
30156
30161
 
30162
+ // ../src/agents/tmux.ts
30163
+ var exports_tmux = {};
30164
+ __export(exports_tmux, {
30165
+ sendAgentInterrupt: () => sendAgentInterrupt,
30166
+ captureAgentPane: () => captureAgentPane
30167
+ });
30168
+ import { execFileSync as execFileSync4 } from "node:child_process";
30169
+ import { mkdirSync as mkdirSync25, readdirSync as readdirSync6, statSync as statSync12, unlinkSync as unlinkSync14, writeFileSync as writeFileSync24 } from "node:fs";
30170
+ import { resolve as resolve7 } from "node:path";
30171
+ function captureAgentPane(opts) {
30172
+ const { agentName: agentName3, agentDir, reason } = opts;
30173
+ const scrollback = opts.scrollback !== false;
30174
+ const retain = typeof opts.retain === "number" && opts.retain > 0 ? opts.retain : 20;
30175
+ const socket = `switchroom-${agentName3}`;
30176
+ const outDir = resolve7(agentDir, "crash-reports");
30177
+ const ts = isoStamp(new Date);
30178
+ const reasonSlug = sanitizeReason(reason);
30179
+ const outPath = resolve7(outDir, `${ts}-${reasonSlug}.txt`);
30180
+ try {
30181
+ mkdirSync25(outDir, { recursive: true, mode: 493 });
30182
+ } catch (err) {
30183
+ const msg = `mkdir crash-reports failed: ${err.message}`;
30184
+ console.error(`[tmux-capture] ${agentName3}: ${msg}`);
30185
+ return { error: msg };
30186
+ }
30187
+ const args = ["-L", socket, "capture-pane", "-p"];
30188
+ if (scrollback) {
30189
+ args.push("-S", "-");
30190
+ }
30191
+ args.push("-t", agentName3);
30192
+ let pane;
30193
+ try {
30194
+ pane = execFileSync4("tmux", args, {
30195
+ timeout: 5000,
30196
+ maxBuffer: 64 * 1024 * 1024,
30197
+ stdio: ["ignore", "pipe", "pipe"]
30198
+ });
30199
+ } catch (err) {
30200
+ const msg = `tmux capture-pane failed: ${err.message}`;
30201
+ console.error(`[tmux-capture] ${agentName3}: ${msg}`);
30202
+ return { error: msg };
30203
+ }
30204
+ let body = pane;
30205
+ if (body.byteLength > MAX_BYTES) {
30206
+ body = body.subarray(body.byteLength - MAX_BYTES);
30207
+ }
30208
+ const header = `# agent: ${agentName3}
30209
+ ` + `# reason: ${reason}
30210
+ ` + `# captured-at: ${ts}
30211
+ ` + `# tmux-socket: ${socket}
30212
+ ` + `
30213
+ `;
30214
+ try {
30215
+ writeFileSync24(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
30216
+ mode: 420
30217
+ });
30218
+ } catch (err) {
30219
+ const msg = `write crash-report failed: ${err.message}`;
30220
+ console.error(`[tmux-capture] ${agentName3}: ${msg}`);
30221
+ return { error: msg };
30222
+ }
30223
+ try {
30224
+ pruneOldReports(outDir, retain);
30225
+ } catch (err) {
30226
+ console.error(`[tmux-capture] ${agentName3}: retention prune failed: ${err.message}`);
30227
+ }
30228
+ return { path: outPath };
30229
+ }
30230
+ function isoStamp(d) {
30231
+ return d.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
30232
+ }
30233
+ function sanitizeReason(reason) {
30234
+ const slug = reason.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
30235
+ return slug || "unknown";
30236
+ }
30237
+ function sendAgentInterrupt(opts) {
30238
+ const { agentName: agentName3 } = opts;
30239
+ const attempts = typeof opts.attempts === "number" && opts.attempts > 0 ? opts.attempts : 1;
30240
+ const retryDelayMs = typeof opts.retryDelayMs === "number" && opts.retryDelayMs >= 0 ? opts.retryDelayMs : 100;
30241
+ const socket = `switchroom-${agentName3}`;
30242
+ const args = ["-L", socket, "send-keys", "-t", agentName3, "C-c"];
30243
+ let lastError = null;
30244
+ for (let i = 0;i < attempts; i++) {
30245
+ try {
30246
+ execFileSync4("tmux", args, {
30247
+ timeout: 3000,
30248
+ stdio: ["ignore", "pipe", "pipe"]
30249
+ });
30250
+ return { ok: true };
30251
+ } catch (err) {
30252
+ lastError = `tmux send-keys C-c failed: ${err.message}`;
30253
+ console.error(`[tmux-interrupt] ${agentName3}: ${lastError}`);
30254
+ }
30255
+ if (i < attempts - 1 && retryDelayMs > 0) {
30256
+ sleepSync2(retryDelayMs);
30257
+ }
30258
+ }
30259
+ return { error: lastError ?? "tmux send-keys C-c failed" };
30260
+ }
30261
+ function sleepSync2(ms) {
30262
+ const sab = new SharedArrayBuffer(4);
30263
+ const view = new Int32Array(sab);
30264
+ Atomics.wait(view, 0, 0, ms);
30265
+ }
30266
+ function pruneOldReports(dir, retain) {
30267
+ let entries;
30268
+ try {
30269
+ entries = readdirSync6(dir);
30270
+ } catch {
30271
+ return;
30272
+ }
30273
+ const files = entries.filter((n) => n.endsWith(".txt")).map((n) => {
30274
+ const full = resolve7(dir, n);
30275
+ let mtimeMs = 0;
30276
+ try {
30277
+ mtimeMs = statSync12(full).mtimeMs;
30278
+ } catch {}
30279
+ return { full, mtimeMs };
30280
+ }).sort((a, b) => b.mtimeMs - a.mtimeMs);
30281
+ for (const stale of files.slice(retain)) {
30282
+ try {
30283
+ unlinkSync14(stale.full);
30284
+ } catch {}
30285
+ }
30286
+ }
30287
+ var MAX_BYTES;
30288
+ var init_tmux = __esm(() => {
30289
+ MAX_BYTES = 10 * 1024 * 1024;
30290
+ });
30291
+
30157
30292
  // gateway/config-approval-handler.ts
30158
30293
  var exports_config_approval_handler = {};
30159
30294
  __export(exports_config_approval_handler, {
@@ -30372,136 +30507,6 @@ var init_config_approval_handler = __esm(() => {
30372
30507
  pending = new Map;
30373
30508
  });
30374
30509
 
30375
- // ../src/agents/tmux.ts
30376
- var exports_tmux = {};
30377
- __export(exports_tmux, {
30378
- sendAgentInterrupt: () => sendAgentInterrupt,
30379
- captureAgentPane: () => captureAgentPane
30380
- });
30381
- import { execFileSync as execFileSync4 } from "node:child_process";
30382
- import { mkdirSync as mkdirSync25, readdirSync as readdirSync6, statSync as statSync12, unlinkSync as unlinkSync14, writeFileSync as writeFileSync24 } from "node:fs";
30383
- import { resolve as resolve7 } from "node:path";
30384
- function captureAgentPane(opts) {
30385
- const { agentName: agentName3, agentDir, reason } = opts;
30386
- const scrollback = opts.scrollback !== false;
30387
- const retain = typeof opts.retain === "number" && opts.retain > 0 ? opts.retain : 20;
30388
- const socket = `switchroom-${agentName3}`;
30389
- const outDir = resolve7(agentDir, "crash-reports");
30390
- const ts = isoStamp(new Date);
30391
- const reasonSlug = sanitizeReason(reason);
30392
- const outPath = resolve7(outDir, `${ts}-${reasonSlug}.txt`);
30393
- try {
30394
- mkdirSync25(outDir, { recursive: true, mode: 493 });
30395
- } catch (err) {
30396
- const msg = `mkdir crash-reports failed: ${err.message}`;
30397
- console.error(`[tmux-capture] ${agentName3}: ${msg}`);
30398
- return { error: msg };
30399
- }
30400
- const args = ["-L", socket, "capture-pane", "-p"];
30401
- if (scrollback) {
30402
- args.push("-S", "-");
30403
- }
30404
- args.push("-t", agentName3);
30405
- let pane;
30406
- try {
30407
- pane = execFileSync4("tmux", args, {
30408
- timeout: 5000,
30409
- maxBuffer: 64 * 1024 * 1024,
30410
- stdio: ["ignore", "pipe", "pipe"]
30411
- });
30412
- } catch (err) {
30413
- const msg = `tmux capture-pane failed: ${err.message}`;
30414
- console.error(`[tmux-capture] ${agentName3}: ${msg}`);
30415
- return { error: msg };
30416
- }
30417
- let body = pane;
30418
- if (body.byteLength > MAX_BYTES) {
30419
- body = body.subarray(body.byteLength - MAX_BYTES);
30420
- }
30421
- const header = `# agent: ${agentName3}
30422
- ` + `# reason: ${reason}
30423
- ` + `# captured-at: ${ts}
30424
- ` + `# tmux-socket: ${socket}
30425
- ` + `
30426
- `;
30427
- try {
30428
- writeFileSync24(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
30429
- mode: 420
30430
- });
30431
- } catch (err) {
30432
- const msg = `write crash-report failed: ${err.message}`;
30433
- console.error(`[tmux-capture] ${agentName3}: ${msg}`);
30434
- return { error: msg };
30435
- }
30436
- try {
30437
- pruneOldReports(outDir, retain);
30438
- } catch (err) {
30439
- console.error(`[tmux-capture] ${agentName3}: retention prune failed: ${err.message}`);
30440
- }
30441
- return { path: outPath };
30442
- }
30443
- function isoStamp(d) {
30444
- return d.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
30445
- }
30446
- function sanitizeReason(reason) {
30447
- const slug = reason.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
30448
- return slug || "unknown";
30449
- }
30450
- function sendAgentInterrupt(opts) {
30451
- const { agentName: agentName3 } = opts;
30452
- const attempts = typeof opts.attempts === "number" && opts.attempts > 0 ? opts.attempts : 1;
30453
- const retryDelayMs = typeof opts.retryDelayMs === "number" && opts.retryDelayMs >= 0 ? opts.retryDelayMs : 100;
30454
- const socket = `switchroom-${agentName3}`;
30455
- const args = ["-L", socket, "send-keys", "-t", agentName3, "C-c"];
30456
- let lastError = null;
30457
- for (let i = 0;i < attempts; i++) {
30458
- try {
30459
- execFileSync4("tmux", args, {
30460
- timeout: 3000,
30461
- stdio: ["ignore", "pipe", "pipe"]
30462
- });
30463
- return { ok: true };
30464
- } catch (err) {
30465
- lastError = `tmux send-keys C-c failed: ${err.message}`;
30466
- console.error(`[tmux-interrupt] ${agentName3}: ${lastError}`);
30467
- }
30468
- if (i < attempts - 1 && retryDelayMs > 0) {
30469
- sleepSync2(retryDelayMs);
30470
- }
30471
- }
30472
- return { error: lastError ?? "tmux send-keys C-c failed" };
30473
- }
30474
- function sleepSync2(ms) {
30475
- const sab = new SharedArrayBuffer(4);
30476
- const view = new Int32Array(sab);
30477
- Atomics.wait(view, 0, 0, ms);
30478
- }
30479
- function pruneOldReports(dir, retain) {
30480
- let entries;
30481
- try {
30482
- entries = readdirSync6(dir);
30483
- } catch {
30484
- return;
30485
- }
30486
- const files = entries.filter((n) => n.endsWith(".txt")).map((n) => {
30487
- const full = resolve7(dir, n);
30488
- let mtimeMs = 0;
30489
- try {
30490
- mtimeMs = statSync12(full).mtimeMs;
30491
- } catch {}
30492
- return { full, mtimeMs };
30493
- }).sort((a, b) => b.mtimeMs - a.mtimeMs);
30494
- for (const stale of files.slice(retain)) {
30495
- try {
30496
- unlinkSync14(stale.full);
30497
- } catch {}
30498
- }
30499
- }
30500
- var MAX_BYTES;
30501
- var init_tmux = __esm(() => {
30502
- MAX_BYTES = 10 * 1024 * 1024;
30503
- });
30504
-
30505
30510
  // ../src/vault/approvals/client.ts
30506
30511
  function resolveKernelSocketPath2(opts) {
30507
30512
  if (opts?.socket)
@@ -31025,6 +31030,51 @@ function parseInterruptMarker(text) {
31025
31030
  };
31026
31031
  }
31027
31032
 
31033
+ // gateway/interrupt-defer.ts
31034
+ class ToolFlightTracker {
31035
+ inFlight = new Set;
31036
+ onEvent(ev) {
31037
+ switch (ev.kind) {
31038
+ case "tool_use":
31039
+ if (typeof ev.toolUseId === "string" && ev.toolUseId.length > 0) {
31040
+ this.inFlight.add(ev.toolUseId);
31041
+ }
31042
+ break;
31043
+ case "tool_result":
31044
+ if (typeof ev.toolUseId === "string" && ev.toolUseId.length > 0) {
31045
+ this.inFlight.delete(ev.toolUseId);
31046
+ }
31047
+ break;
31048
+ case "turn_end":
31049
+ case "enqueue":
31050
+ this.inFlight.clear();
31051
+ break;
31052
+ default:
31053
+ break;
31054
+ }
31055
+ }
31056
+ isMidToolCall() {
31057
+ return this.inFlight.size > 0;
31058
+ }
31059
+ inFlightCount() {
31060
+ return this.inFlight.size;
31061
+ }
31062
+ clear() {
31063
+ this.inFlight.clear();
31064
+ }
31065
+ }
31066
+ function decideInterruptTiming(opts) {
31067
+ if (!opts.safeBoundaryEnabled)
31068
+ return "fire-now";
31069
+ return opts.midToolCall ? "defer" : "fire-now";
31070
+ }
31071
+ var DEFAULT_INTERRUPT_MAX_WAIT_MS = 8000;
31072
+ function resolveInterruptMaxWaitMs(configured) {
31073
+ if (typeof configured === "number" && configured > 0)
31074
+ return configured;
31075
+ return DEFAULT_INTERRUPT_MAX_WAIT_MS;
31076
+ }
31077
+
31028
31078
  // sticker-aliases.ts
31029
31079
  function looksLikeFileId(s) {
31030
31080
  return /^[A-Za-z0-9_-]{10,200}$/.test(s);
@@ -31562,6 +31612,33 @@ function inboundCoalesceKey(chatId, threadId, userId) {
31562
31612
  return `${chatId}:${t}:${userId}`;
31563
31613
  }
31564
31614
 
31615
+ // gateway/coalesce-attachments.ts
31616
+ function splitCoalescedAttachments(entries, hasAttachment, maxAttachments) {
31617
+ const withAttachment = entries.filter(hasAttachment);
31618
+ const capped = withAttachment.slice(0, Math.max(1, maxAttachments));
31619
+ const [primary, ...extras] = capped;
31620
+ return { primary, extras };
31621
+ }
31622
+ function buildExtraAttachmentMeta(resolved) {
31623
+ const out = {};
31624
+ resolved.forEach((ex, i) => {
31625
+ const n = i + 2;
31626
+ if (ex.imagePath)
31627
+ out[`image_path_${n}`] = ex.imagePath;
31628
+ if (ex.attachment) {
31629
+ out[`attachment_kind_${n}`] = ex.attachment.kind;
31630
+ out[`attachment_file_id_${n}`] = ex.attachment.file_id;
31631
+ if (ex.attachment.size != null)
31632
+ out[`attachment_size_${n}`] = String(ex.attachment.size);
31633
+ if (ex.attachment.mime)
31634
+ out[`attachment_mime_${n}`] = ex.attachment.mime;
31635
+ if (ex.attachment.name)
31636
+ out[`attachment_name_${n}`] = ex.attachment.name;
31637
+ }
31638
+ });
31639
+ return out;
31640
+ }
31641
+
31565
31642
  // status-reactions.ts
31566
31643
  var TELEGRAM_REACTION_WHITELIST = new Set([
31567
31644
  "\uD83D\uDC4D",
@@ -31645,6 +31722,7 @@ var REACTION_VARIANTS = {
31645
31722
  coding: ["\uD83D\uDC68\u200d\uD83D\uDCBB", "\u270d", "\u26a1"],
31646
31723
  web: ["\u26a1", "\uD83E\uDD14", "\uD83D\uDC4C"],
31647
31724
  compacting: ["\u270d", "\uD83E\uDD14", "\uD83D\uDC40"],
31725
+ awaiting: ["\uD83D\uDE4F", "\uD83E\uDD14", "\uD83D\uDC40"],
31648
31726
  done: ["\uD83D\uDC4D", "\uD83D\uDCAF", "\uD83C\uDF89"],
31649
31727
  error: ["\uD83D\uDE31", "\uD83D\uDE28", "\uD83E\uDD2F"],
31650
31728
  stallSoft: ["\uD83E\uDD71", "\uD83D\uDE34", "\uD83E\uDD14"],
@@ -31697,6 +31775,12 @@ class StatusReactionController {
31697
31775
  setCompacting() {
31698
31776
  this.scheduleState("compacting");
31699
31777
  }
31778
+ setAwaiting() {
31779
+ if (this.finished)
31780
+ return;
31781
+ this.scheduleState("awaiting", { immediate: true, skipStallReset: true });
31782
+ this.clearStallTimers();
31783
+ }
31700
31784
  setError() {
31701
31785
  this.scheduleState("error");
31702
31786
  }
@@ -31875,6 +31959,7 @@ class DeferredDoneReactions {
31875
31959
  var DESC_MAX = 80;
31876
31960
  var TOOL_ARG_MAX = 64;
31877
31961
  var SUMMARY_MAX = 100;
31962
+ var NARRATIVE_MAX_LINES = 6;
31878
31963
  function renderWorkerActivity(v) {
31879
31964
  const desc = truncate(v.description.trim() || "background task", DESC_MAX);
31880
31965
  const elapsed = formatDuration(v.elapsedMs);
@@ -31893,10 +31978,17 @@ function renderWorkerActivity(v) {
31893
31978
  } else {
31894
31979
  activity = `<i>starting\u2026 (${elapsed})</i>`;
31895
31980
  }
31896
- const summary = v.latestSummary.trim();
31897
31981
  const lines = [header, activity];
31898
- if (summary.length > 0) {
31899
- lines.push(` \u21b3 <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`);
31982
+ const narrative = (v.narrativeLines ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
31983
+ if (narrative.length > 0) {
31984
+ for (const line of narrative) {
31985
+ lines.push(` \u21b3 <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`);
31986
+ }
31987
+ } else {
31988
+ const summary = v.latestSummary.trim();
31989
+ if (summary.length > 0) {
31990
+ lines.push(` \u21b3 <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`);
31991
+ }
31900
31992
  }
31901
31993
  return lines.join(`
31902
31994
  `);
@@ -31933,10 +32025,22 @@ function createWorkerActivityFeed(opts) {
31933
32025
  h.cooldownUntil = nowFn() + retryAfter * 1000 + COOLDOWN_JITTER_MS;
31934
32026
  log(`worker-feed: ${label} 429 \u2014 backing off ${retryAfter}s`);
31935
32027
  }
32028
+ function accumulateNarrative(h, view) {
32029
+ const line = view.latestSummary.trim();
32030
+ if (line.length === 0)
32031
+ return;
32032
+ if (h.narrative[h.narrative.length - 1] === line)
32033
+ return;
32034
+ h.narrative.push(line);
32035
+ if (h.narrative.length > NARRATIVE_MAX_LINES) {
32036
+ h.narrative.splice(0, h.narrative.length - NARRATIVE_MAX_LINES);
32037
+ }
32038
+ }
31936
32039
  async function doUpdate(h, view) {
32040
+ accumulateNarrative(h, view);
31937
32041
  if (nowFn() < h.cooldownUntil)
31938
32042
  return;
31939
- const body = renderWorkerActivity(view);
32043
+ const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative });
31940
32044
  if (h.messageId == null) {
31941
32045
  if (view.elapsedMs < firstPaintMin)
31942
32046
  return;
@@ -32003,6 +32107,7 @@ function createWorkerActivityFeed(opts) {
32003
32107
  lastBody: null,
32004
32108
  lastEditAt: 0,
32005
32109
  cooldownUntil: 0,
32110
+ narrative: [],
32006
32111
  chain: Promise.resolve()
32007
32112
  };
32008
32113
  handles.set(agentId, h);
@@ -46392,6 +46497,7 @@ function planBufferedRedelivery(pending) {
46392
46497
  flush();
46393
46498
  return out;
46394
46499
  }
46500
+ var ATTACHMENT_META_RE = /^(image_path|attachment_)/;
46395
46501
  function mergeRun(run2) {
46396
46502
  const last = run2[run2.length - 1];
46397
46503
  const mediaEntry = run2.find(inboundHasMedia);
@@ -46402,6 +46508,14 @@ function mergeRun(run2) {
46402
46508
  };
46403
46509
  delete merged.imagePath;
46404
46510
  delete merged.attachment;
46511
+ if (mediaEntry != null && mediaEntry !== last) {
46512
+ const splicedMeta = { ...merged.meta };
46513
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
46514
+ if (ATTACHMENT_META_RE.test(k))
46515
+ splicedMeta[k] = v;
46516
+ }
46517
+ merged.meta = splicedMeta;
46518
+ }
46405
46519
  if (mediaEntry?.imagePath != null)
46406
46520
  merged.imagePath = mediaEntry.imagePath;
46407
46521
  if (mediaEntry?.attachment != null)
@@ -47030,6 +47144,7 @@ function planBufferedRedelivery2(pending) {
47030
47144
  flush();
47031
47145
  return out;
47032
47146
  }
47147
+ var ATTACHMENT_META_RE2 = /^(image_path|attachment_)/;
47033
47148
  function mergeRun2(run2) {
47034
47149
  const last = run2[run2.length - 1];
47035
47150
  const mediaEntry = run2.find(inboundHasMedia2);
@@ -47040,6 +47155,14 @@ function mergeRun2(run2) {
47040
47155
  };
47041
47156
  delete merged.imagePath;
47042
47157
  delete merged.attachment;
47158
+ if (mediaEntry != null && mediaEntry !== last) {
47159
+ const splicedMeta = { ...merged.meta };
47160
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
47161
+ if (ATTACHMENT_META_RE2.test(k))
47162
+ splicedMeta[k] = v;
47163
+ }
47164
+ merged.meta = splicedMeta;
47165
+ }
47043
47166
  if (mediaEntry?.imagePath != null)
47044
47167
  merged.imagePath = mediaEntry.imagePath;
47045
47168
  if (mediaEntry?.attachment != null)
@@ -51237,10 +51360,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51237
51360
  }
51238
51361
 
51239
51362
  // ../src/build-info.ts
51240
- var VERSION = "0.14.18";
51241
- var COMMIT_SHA = "dddb8617";
51242
- var COMMIT_DATE = "2026-05-30T23:35:26Z";
51243
- var LATEST_PR = 2010;
51363
+ var VERSION = "0.14.20";
51364
+ var COMMIT_SHA = "c8b965b2";
51365
+ var COMMIT_DATE = "2026-05-31T01:51:10Z";
51366
+ var LATEST_PR = 2018;
51244
51367
  var COMMITS_AHEAD_OF_TAG = 0;
51245
51368
 
51246
51369
  // gateway/boot-version.ts
@@ -52008,6 +52131,9 @@ function readAccessFile() {
52008
52131
  parseMode: parsed.parseMode,
52009
52132
  disableLinkPreview: parsed.disableLinkPreview,
52010
52133
  coalescingGapMs: parsed.coalescingGapMs,
52134
+ coalesceMaxAttachments: parsed.coalesceMaxAttachments,
52135
+ interruptSafeBoundary: parsed.interruptSafeBoundary,
52136
+ interruptMaxWaitMs: parsed.interruptMaxWaitMs,
52011
52137
  statusReactions: parsed.statusReactions,
52012
52138
  historyEnabled: parsed.historyEnabled,
52013
52139
  historyRetentionDays: parsed.historyRetentionDays,
@@ -52215,6 +52341,40 @@ var lastPtyPreviewByChat = new Map;
52215
52341
  var progressUpdateLastSent = new Map;
52216
52342
  var progressUpdateTurnCount = new Map;
52217
52343
  var currentTurn = null;
52344
+ var toolFlightTracker = new ToolFlightTracker;
52345
+ var pendingDeferredInterrupt = null;
52346
+ async function fireDeferredInterrupt(reason) {
52347
+ const pending2 = pendingDeferredInterrupt;
52348
+ if (pending2 == null)
52349
+ return;
52350
+ pendingDeferredInterrupt = null;
52351
+ clearTimeout(pending2.deadlineTimer);
52352
+ const waitedMs = Date.now() - pending2.registeredAt;
52353
+ process.stderr.write(`telegram gateway: deferred-interrupt firing reason=${reason} agent=${pending2.agentName} chat=${pending2.chatId} waited_ms=${waitedMs} in_flight=${toolFlightTracker.inFlightCount()}
52354
+ `);
52355
+ try {
52356
+ const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
52357
+ const r = sendAgentInterrupt2({ agentName: pending2.agentName });
52358
+ if ("ok" in r) {
52359
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT delivered via tmux send-keys agent=${pending2.agentName}
52360
+ `);
52361
+ } else {
52362
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT via tmux failed agent=${pending2.agentName}: ${r.error}
52363
+ `);
52364
+ }
52365
+ } catch (err) {
52366
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT failed: ${err.message}
52367
+ `);
52368
+ }
52369
+ const delivered = ipcServer.sendToAgent(pending2.agentName, pending2.inboundMsg);
52370
+ if (delivered) {
52371
+ markClaudeBusyForInbound(pending2.inboundMsg);
52372
+ } else {
52373
+ pendingInboundBuffer.push(pending2.agentName, pending2.inboundMsg);
52374
+ process.stderr.write(`telegram gateway: deferred-interrupt body buffered (bridge miss) agent=${pending2.agentName} chat=${pending2.chatId}
52375
+ `);
52376
+ }
52377
+ }
52218
52378
  var preambleSuppressor = new PreambleSuppressor({
52219
52379
  emitAnswer: (cumulative) => {
52220
52380
  const stream = currentTurn?.answerStream ?? null;
@@ -52458,6 +52618,12 @@ function countRunningWorkers() {
52458
52618
  }
52459
52619
  return n;
52460
52620
  }
52621
+ function resumeReactionAfterVerdict() {
52622
+ const turn = currentTurn;
52623
+ if (turn == null)
52624
+ return;
52625
+ activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
52626
+ }
52461
52627
  function resolveThreadId(chat_id, explicit) {
52462
52628
  if (explicit != null)
52463
52629
  return Number(explicit);
@@ -52891,6 +53057,7 @@ var pendingStateReaper = setInterval(() => {
52891
53057
  for (const [k, v] of pendingPermissions) {
52892
53058
  if (now - v.startedAt > PERMISSION_TTL_MS) {
52893
53059
  dispatchPermissionVerdict({ type: "permission", requestId: k, behavior: "deny" });
53060
+ resumeReactionAfterVerdict();
52894
53061
  process.stderr.write(`telegram gateway: permission TTL expired \u2014 auto-deny request=${k} tool=${v.tool_name} (no operator response in ${Math.round(PERMISSION_TTL_MS / 60000)}m)
52895
53062
  `);
52896
53063
  pendingPermissions.delete(k);
@@ -52949,23 +53116,27 @@ function looksLikeAuthCode(text) {
52949
53116
  return true;
52950
53117
  return false;
52951
53118
  }
52952
- var bufferedAttachmentKeys = new Set;
53119
+ var bufferedAttachmentKeys = new Map;
53120
+ function coalesceMaxAttachments() {
53121
+ return Math.max(1, loadAccess().coalesceMaxAttachments ?? 1);
53122
+ }
52953
53123
  var inboundCoalescer = createInboundCoalescer({
52954
53124
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
52955
53125
  merge: (entries) => {
52956
53126
  const last = entries[entries.length - 1];
52957
- const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null);
53127
+ const { primary, extras } = splitCoalescedAttachments(entries, (e) => e.downloadImage != null || e.attachment != null, coalesceMaxAttachments());
52958
53128
  return {
52959
- text: entries.map((e) => e.text).join(`
53129
+ text: entries.map((e) => e.text).filter((t) => t.length > 0).join(`
52960
53130
  `),
52961
53131
  ctx: last.ctx,
52962
- downloadImage: withAttachment?.downloadImage,
52963
- attachment: withAttachment?.attachment
53132
+ downloadImage: primary?.downloadImage,
53133
+ attachment: primary?.attachment,
53134
+ extraAttachments: extras.length > 0 ? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment })) : undefined
52964
53135
  };
52965
53136
  },
52966
53137
  onFlush: (key, merged) => {
52967
53138
  bufferedAttachmentKeys.delete(key);
52968
- handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment);
53139
+ handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment, merged.extraAttachments);
52969
53140
  }
52970
53141
  });
52971
53142
  function emitGatewayOperatorEvent(event) {
@@ -53484,6 +53655,10 @@ var ipcServer = createIpcServer({
53484
53655
  const threadHint = msg.threadId != null ? String(msg.threadId) : undefined;
53485
53656
  progressDriver?.ingest(ev, chatHint, threadHint);
53486
53657
  handleSessionEvent(ev);
53658
+ toolFlightTracker.onEvent(ev);
53659
+ if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
53660
+ fireDeferredInterrupt("boundary");
53661
+ }
53487
53662
  if (currentTurn != null) {
53488
53663
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId);
53489
53664
  if (ev.kind === "thinking") {
@@ -53532,6 +53707,9 @@ var ipcServer = createIpcServer({
53532
53707
  `);
53533
53708
  });
53534
53709
  }
53710
+ if (activeTurn != null) {
53711
+ activeStatusReactions.get(statusKey(activeTurn.sessionChatId, activeTurn.sessionThreadId))?.setAwaiting();
53712
+ }
53535
53713
  },
53536
53714
  onHeartbeat(_client, _msg) {},
53537
53715
  onScheduleRestart(client3, msg) {
@@ -56124,7 +56302,8 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56124
56302
  return handleInbound(ctx, text, downloadImage, attachment);
56125
56303
  }
56126
56304
  const hasAttachment = downloadImage != null || attachment != null;
56127
- if (hasAttachment && ctx.message?.media_group_id != null) {
56305
+ const maxAttachments = coalesceMaxAttachments();
56306
+ if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
56128
56307
  return handleInbound(ctx, text, downloadImage, attachment);
56129
56308
  }
56130
56309
  const from = ctx.from;
@@ -56132,7 +56311,7 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56132
56311
  return;
56133
56312
  if (hasAttachment) {
56134
56313
  const probeKey = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56135
- if (bufferedAttachmentKeys.has(probeKey)) {
56314
+ if ((bufferedAttachmentKeys.get(probeKey) ?? 0) >= maxAttachments) {
56136
56315
  return handleInbound(ctx, text, downloadImage, attachment);
56137
56316
  }
56138
56317
  }
@@ -56142,7 +56321,7 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56142
56321
  if (result.bypass)
56143
56322
  return handleInbound(ctx, text, downloadImage, attachment);
56144
56323
  if (hasAttachment)
56145
- bufferedAttachmentKeys.add(key);
56324
+ bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1);
56146
56325
  }
56147
56326
  function maybeEarlyAckReaction(ctx, from) {
56148
56327
  const msgId = ctx.message?.message_id;
@@ -56163,7 +56342,7 @@ function maybeEarlyAckReaction(ctx, from) {
56163
56342
  ]).catch(() => {});
56164
56343
  bot.api.sendChatAction(chatId, "typing").catch(() => {});
56165
56344
  }
56166
- async function handleInbound(ctx, text, downloadImage, attachment) {
56345
+ async function handleInbound(ctx, text, downloadImage, attachment, extraAttachments) {
56167
56346
  const isTopicMessage = ctx.message?.is_topic_message ?? false;
56168
56347
  const messageThreadId = ctx.message?.message_thread_id;
56169
56348
  if (TOPIC_ID != null) {
@@ -56223,16 +56402,22 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
56223
56402
  `);
56224
56403
  }
56225
56404
  const interrupt = parseInterruptMarker(text);
56405
+ let deferInterrupt = false;
56226
56406
  if (interrupt.isInterrupt) {
56227
56407
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
56228
- process.stderr.write(`telegram gateway: interrupt-marker received chat_id=${chat_id} agent=${agentName3 ?? "-"} body_len=${interrupt.body.length} empty=${interrupt.emptyBody}
56408
+ const access2 = loadAccess();
56409
+ deferInterrupt = !interrupt.emptyBody && decideInterruptTiming({
56410
+ safeBoundaryEnabled: access2.interruptSafeBoundary === true,
56411
+ midToolCall: toolFlightTracker.isMidToolCall()
56412
+ }) === "defer";
56413
+ process.stderr.write(`telegram gateway: interrupt-marker received chat_id=${chat_id} agent=${agentName3 ?? "-"} body_len=${interrupt.body.length} empty=${interrupt.emptyBody} defer=${deferInterrupt} in_flight=${toolFlightTracker.inFlightCount()}
56229
56414
  `);
56230
56415
  if (msgId != null) {
56231
56416
  bot.api.setMessageReaction(chat_id, msgId, [
56232
56417
  { type: "emoji", emoji: "\u26A1" }
56233
56418
  ]).catch(() => {});
56234
56419
  }
56235
- if (agentName3) {
56420
+ if (agentName3 && !deferInterrupt) {
56236
56421
  try {
56237
56422
  const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
56238
56423
  const r = sendAgentInterrupt2({ agentName: agentName3 });
@@ -56283,6 +56468,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
56283
56468
  requestId: request_id,
56284
56469
  behavior
56285
56470
  });
56471
+ resumeReactionAfterVerdict();
56286
56472
  if (msgId != null) {
56287
56473
  const emoji = behavior === "allow" ? "\u2705" : "\u274C";
56288
56474
  bot.api.setMessageReaction(chat_id, msgId, [
@@ -56670,6 +56856,16 @@ ${preBlock(write.output)}`;
56670
56856
  }
56671
56857
  }
56672
56858
  const imagePath = downloadImage ? await downloadImage() : undefined;
56859
+ const extraResolved = [];
56860
+ if (extraAttachments && extraAttachments.length > 0) {
56861
+ for (const ex of extraAttachments) {
56862
+ const exImagePath = ex.downloadImage ? await ex.downloadImage() : undefined;
56863
+ extraResolved.push({ imagePath: exImagePath, attachment: ex.attachment });
56864
+ }
56865
+ }
56866
+ const extraMeta = buildExtraAttachmentMeta(extraResolved);
56867
+ const primaryHasAttachment = imagePath != null || attachment != null;
56868
+ const attachmentCount = (primaryHasAttachment ? 1 : 0) + extraResolved.length;
56673
56869
  const replyToMsg = ctx.message?.reply_to_message;
56674
56870
  const replyToMessageId = replyToMsg?.message_id;
56675
56871
  const replyToTextRaw = replyToMsg ? replyToMsg.text ?? replyToMsg.caption ?? undefined : undefined;
@@ -56753,10 +56949,37 @@ ${preBlock(write.output)}`;
56753
56949
  ...attachment.size != null ? { attachment_size: String(attachment.size) } : {},
56754
56950
  ...attachment.mime ? { attachment_mime: attachment.mime } : {},
56755
56951
  ...attachment.name ? { attachment_name: attachment.name } : {}
56756
- } : {}
56952
+ } : {},
56953
+ ...attachmentCount > 1 ? { attachment_count: String(attachmentCount) } : {},
56954
+ ...extraMeta
56757
56955
  }
56758
56956
  };
56759
56957
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
56958
+ if (deferInterrupt) {
56959
+ const selfAgentDefer = process.env.SWITCHROOM_AGENT_NAME ?? "";
56960
+ if (pendingDeferredInterrupt != null) {
56961
+ pendingDeferredInterrupt.inboundMsg = inboundMsg;
56962
+ pendingDeferredInterrupt.msgId = msgId ?? null;
56963
+ process.stderr.write(`telegram gateway: deferred-interrupt coalesced (replacing pending body) agent=${selfAgentDefer} chat=${chat_id} msg=${msgId ?? "-"}
56964
+ `);
56965
+ } else {
56966
+ const maxWaitMs = resolveInterruptMaxWaitMs(loadAccess().interruptMaxWaitMs);
56967
+ pendingDeferredInterrupt = {
56968
+ agentName: selfAgentDefer,
56969
+ inboundMsg,
56970
+ chatId: chat_id,
56971
+ msgId: msgId ?? null,
56972
+ threadId: messageThreadId ?? undefined,
56973
+ registeredAt: Date.now(),
56974
+ deadlineTimer: setTimeout(() => {
56975
+ fireDeferredInterrupt("timeout");
56976
+ }, maxWaitMs)
56977
+ };
56978
+ process.stderr.write(`telegram gateway: deferred-interrupt parked agent=${selfAgentDefer} chat=${chat_id} msg=${msgId ?? "-"} max_wait_ms=${maxWaitMs} in_flight=${toolFlightTracker.inFlightCount()}
56979
+ `);
56980
+ }
56981
+ return;
56982
+ }
56760
56983
  if (decideInboundDelivery({
56761
56984
  turnInFlight: turnInFlightAtReceipt,
56762
56985
  isSteering,
@@ -57988,6 +58211,7 @@ async function handlePermissionSlash(ctx, behavior) {
57988
58211
  return;
57989
58212
  }
57990
58213
  dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior });
58214
+ resumeReactionAfterVerdict();
57991
58215
  pendingPermissions.delete(request_id);
57992
58216
  process.stderr.write(`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}
57993
58217
  `);
@@ -60284,6 +60508,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
60284
60508
  behavior: "allow",
60285
60509
  rule: chosen.rule
60286
60510
  });
60511
+ resumeReactionAfterVerdict();
60287
60512
  let durable = false;
60288
60513
  let legacy = false;
60289
60514
  let failReason = "";
@@ -60385,7 +60610,9 @@ ${editLabel}` : editLabel,
60385
60610
  return;
60386
60611
  }
60387
60612
  pendingPermissions.delete(request_id);
60388
- const label = behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied";
60613
+ const resumeAgent = process.env.SWITCHROOM_AGENT_NAME;
60614
+ const resumeBeat = resumeAgent ? `\u25B6\uFE0F ${escapeHtmlForTg(resumeAgent)} resuming\u2026` : "\u25B6\uFE0F resuming\u2026";
60615
+ const label = `${behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied"} \xB7 ${resumeBeat}`;
60389
60616
  const msg = ctx.callbackQuery?.message;
60390
60617
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
60391
60618
  await finalizeCallback(ctx, {
@@ -60400,6 +60627,7 @@ ${label}` : label,
60400
60627
  requestId: request_id,
60401
60628
  behavior
60402
60629
  });
60630
+ resumeReactionAfterVerdict();
60403
60631
  }
60404
60632
  });
60405
60633
  });