switchroom 0.14.19 → 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",
@@ -31882,6 +31959,7 @@ class DeferredDoneReactions {
31882
31959
  var DESC_MAX = 80;
31883
31960
  var TOOL_ARG_MAX = 64;
31884
31961
  var SUMMARY_MAX = 100;
31962
+ var NARRATIVE_MAX_LINES = 6;
31885
31963
  function renderWorkerActivity(v) {
31886
31964
  const desc = truncate(v.description.trim() || "background task", DESC_MAX);
31887
31965
  const elapsed = formatDuration(v.elapsedMs);
@@ -31900,10 +31978,17 @@ function renderWorkerActivity(v) {
31900
31978
  } else {
31901
31979
  activity = `<i>starting\u2026 (${elapsed})</i>`;
31902
31980
  }
31903
- const summary = v.latestSummary.trim();
31904
31981
  const lines = [header, activity];
31905
- if (summary.length > 0) {
31906
- 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
+ }
31907
31992
  }
31908
31993
  return lines.join(`
31909
31994
  `);
@@ -31940,10 +32025,22 @@ function createWorkerActivityFeed(opts) {
31940
32025
  h.cooldownUntil = nowFn() + retryAfter * 1000 + COOLDOWN_JITTER_MS;
31941
32026
  log(`worker-feed: ${label} 429 \u2014 backing off ${retryAfter}s`);
31942
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
+ }
31943
32039
  async function doUpdate(h, view) {
32040
+ accumulateNarrative(h, view);
31944
32041
  if (nowFn() < h.cooldownUntil)
31945
32042
  return;
31946
- const body = renderWorkerActivity(view);
32043
+ const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative });
31947
32044
  if (h.messageId == null) {
31948
32045
  if (view.elapsedMs < firstPaintMin)
31949
32046
  return;
@@ -32010,6 +32107,7 @@ function createWorkerActivityFeed(opts) {
32010
32107
  lastBody: null,
32011
32108
  lastEditAt: 0,
32012
32109
  cooldownUntil: 0,
32110
+ narrative: [],
32013
32111
  chain: Promise.resolve()
32014
32112
  };
32015
32113
  handles.set(agentId, h);
@@ -46399,6 +46497,7 @@ function planBufferedRedelivery(pending) {
46399
46497
  flush();
46400
46498
  return out;
46401
46499
  }
46500
+ var ATTACHMENT_META_RE = /^(image_path|attachment_)/;
46402
46501
  function mergeRun(run2) {
46403
46502
  const last = run2[run2.length - 1];
46404
46503
  const mediaEntry = run2.find(inboundHasMedia);
@@ -46409,6 +46508,14 @@ function mergeRun(run2) {
46409
46508
  };
46410
46509
  delete merged.imagePath;
46411
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
+ }
46412
46519
  if (mediaEntry?.imagePath != null)
46413
46520
  merged.imagePath = mediaEntry.imagePath;
46414
46521
  if (mediaEntry?.attachment != null)
@@ -47037,6 +47144,7 @@ function planBufferedRedelivery2(pending) {
47037
47144
  flush();
47038
47145
  return out;
47039
47146
  }
47147
+ var ATTACHMENT_META_RE2 = /^(image_path|attachment_)/;
47040
47148
  function mergeRun2(run2) {
47041
47149
  const last = run2[run2.length - 1];
47042
47150
  const mediaEntry = run2.find(inboundHasMedia2);
@@ -47047,6 +47155,14 @@ function mergeRun2(run2) {
47047
47155
  };
47048
47156
  delete merged.imagePath;
47049
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
+ }
47050
47166
  if (mediaEntry?.imagePath != null)
47051
47167
  merged.imagePath = mediaEntry.imagePath;
47052
47168
  if (mediaEntry?.attachment != null)
@@ -51244,10 +51360,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51244
51360
  }
51245
51361
 
51246
51362
  // ../src/build-info.ts
51247
- var VERSION = "0.14.19";
51248
- var COMMIT_SHA = "21863276";
51249
- var COMMIT_DATE = "2026-05-31T00:15:08Z";
51250
- var LATEST_PR = 2013;
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;
51251
51367
  var COMMITS_AHEAD_OF_TAG = 0;
51252
51368
 
51253
51369
  // gateway/boot-version.ts
@@ -52015,6 +52131,9 @@ function readAccessFile() {
52015
52131
  parseMode: parsed.parseMode,
52016
52132
  disableLinkPreview: parsed.disableLinkPreview,
52017
52133
  coalescingGapMs: parsed.coalescingGapMs,
52134
+ coalesceMaxAttachments: parsed.coalesceMaxAttachments,
52135
+ interruptSafeBoundary: parsed.interruptSafeBoundary,
52136
+ interruptMaxWaitMs: parsed.interruptMaxWaitMs,
52018
52137
  statusReactions: parsed.statusReactions,
52019
52138
  historyEnabled: parsed.historyEnabled,
52020
52139
  historyRetentionDays: parsed.historyRetentionDays,
@@ -52222,6 +52341,40 @@ var lastPtyPreviewByChat = new Map;
52222
52341
  var progressUpdateLastSent = new Map;
52223
52342
  var progressUpdateTurnCount = new Map;
52224
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
+ }
52225
52378
  var preambleSuppressor = new PreambleSuppressor({
52226
52379
  emitAnswer: (cumulative) => {
52227
52380
  const stream = currentTurn?.answerStream ?? null;
@@ -52963,23 +53116,27 @@ function looksLikeAuthCode(text) {
52963
53116
  return true;
52964
53117
  return false;
52965
53118
  }
52966
- var bufferedAttachmentKeys = new Set;
53119
+ var bufferedAttachmentKeys = new Map;
53120
+ function coalesceMaxAttachments() {
53121
+ return Math.max(1, loadAccess().coalesceMaxAttachments ?? 1);
53122
+ }
52967
53123
  var inboundCoalescer = createInboundCoalescer({
52968
53124
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
52969
53125
  merge: (entries) => {
52970
53126
  const last = entries[entries.length - 1];
52971
- 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());
52972
53128
  return {
52973
- text: entries.map((e) => e.text).join(`
53129
+ text: entries.map((e) => e.text).filter((t) => t.length > 0).join(`
52974
53130
  `),
52975
53131
  ctx: last.ctx,
52976
- downloadImage: withAttachment?.downloadImage,
52977
- 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
52978
53135
  };
52979
53136
  },
52980
53137
  onFlush: (key, merged) => {
52981
53138
  bufferedAttachmentKeys.delete(key);
52982
- handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment);
53139
+ handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment, merged.extraAttachments);
52983
53140
  }
52984
53141
  });
52985
53142
  function emitGatewayOperatorEvent(event) {
@@ -53498,6 +53655,10 @@ var ipcServer = createIpcServer({
53498
53655
  const threadHint = msg.threadId != null ? String(msg.threadId) : undefined;
53499
53656
  progressDriver?.ingest(ev, chatHint, threadHint);
53500
53657
  handleSessionEvent(ev);
53658
+ toolFlightTracker.onEvent(ev);
53659
+ if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
53660
+ fireDeferredInterrupt("boundary");
53661
+ }
53501
53662
  if (currentTurn != null) {
53502
53663
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId);
53503
53664
  if (ev.kind === "thinking") {
@@ -56141,7 +56302,8 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56141
56302
  return handleInbound(ctx, text, downloadImage, attachment);
56142
56303
  }
56143
56304
  const hasAttachment = downloadImage != null || attachment != null;
56144
- if (hasAttachment && ctx.message?.media_group_id != null) {
56305
+ const maxAttachments = coalesceMaxAttachments();
56306
+ if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
56145
56307
  return handleInbound(ctx, text, downloadImage, attachment);
56146
56308
  }
56147
56309
  const from = ctx.from;
@@ -56149,7 +56311,7 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56149
56311
  return;
56150
56312
  if (hasAttachment) {
56151
56313
  const probeKey = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56152
- if (bufferedAttachmentKeys.has(probeKey)) {
56314
+ if ((bufferedAttachmentKeys.get(probeKey) ?? 0) >= maxAttachments) {
56153
56315
  return handleInbound(ctx, text, downloadImage, attachment);
56154
56316
  }
56155
56317
  }
@@ -56159,7 +56321,7 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56159
56321
  if (result.bypass)
56160
56322
  return handleInbound(ctx, text, downloadImage, attachment);
56161
56323
  if (hasAttachment)
56162
- bufferedAttachmentKeys.add(key);
56324
+ bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1);
56163
56325
  }
56164
56326
  function maybeEarlyAckReaction(ctx, from) {
56165
56327
  const msgId = ctx.message?.message_id;
@@ -56180,7 +56342,7 @@ function maybeEarlyAckReaction(ctx, from) {
56180
56342
  ]).catch(() => {});
56181
56343
  bot.api.sendChatAction(chatId, "typing").catch(() => {});
56182
56344
  }
56183
- async function handleInbound(ctx, text, downloadImage, attachment) {
56345
+ async function handleInbound(ctx, text, downloadImage, attachment, extraAttachments) {
56184
56346
  const isTopicMessage = ctx.message?.is_topic_message ?? false;
56185
56347
  const messageThreadId = ctx.message?.message_thread_id;
56186
56348
  if (TOPIC_ID != null) {
@@ -56240,16 +56402,22 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
56240
56402
  `);
56241
56403
  }
56242
56404
  const interrupt = parseInterruptMarker(text);
56405
+ let deferInterrupt = false;
56243
56406
  if (interrupt.isInterrupt) {
56244
56407
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
56245
- 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()}
56246
56414
  `);
56247
56415
  if (msgId != null) {
56248
56416
  bot.api.setMessageReaction(chat_id, msgId, [
56249
56417
  { type: "emoji", emoji: "\u26A1" }
56250
56418
  ]).catch(() => {});
56251
56419
  }
56252
- if (agentName3) {
56420
+ if (agentName3 && !deferInterrupt) {
56253
56421
  try {
56254
56422
  const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
56255
56423
  const r = sendAgentInterrupt2({ agentName: agentName3 });
@@ -56688,6 +56856,16 @@ ${preBlock(write.output)}`;
56688
56856
  }
56689
56857
  }
56690
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;
56691
56869
  const replyToMsg = ctx.message?.reply_to_message;
56692
56870
  const replyToMessageId = replyToMsg?.message_id;
56693
56871
  const replyToTextRaw = replyToMsg ? replyToMsg.text ?? replyToMsg.caption ?? undefined : undefined;
@@ -56771,10 +56949,37 @@ ${preBlock(write.output)}`;
56771
56949
  ...attachment.size != null ? { attachment_size: String(attachment.size) } : {},
56772
56950
  ...attachment.mime ? { attachment_mime: attachment.mime } : {},
56773
56951
  ...attachment.name ? { attachment_name: attachment.name } : {}
56774
- } : {}
56952
+ } : {},
56953
+ ...attachmentCount > 1 ? { attachment_count: String(attachmentCount) } : {},
56954
+ ...extraMeta
56775
56955
  }
56776
56956
  };
56777
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
+ }
56778
56983
  if (decideInboundDelivery({
56779
56984
  turnInFlight: turnInFlightAtReceipt,
56780
56985
  isSteering,