switchroom 0.14.19 → 0.14.21

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.
Files changed (32) hide show
  1. package/dist/agent-scheduler/index.js +6 -1
  2. package/dist/auth-broker/index.js +6 -1
  3. package/dist/cli/notion-write-pretool.mjs +6 -1
  4. package/dist/cli/switchroom.js +17 -3
  5. package/dist/host-control/main.js +6 -1
  6. package/dist/vault/approvals/kernel-server.js +6 -1
  7. package/dist/vault/broker/server.js +6 -1
  8. package/package.json +2 -2
  9. package/telegram-plugin/README.md +7 -3
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  12. package/telegram-plugin/dist/gateway/gateway.js +368 -153
  13. package/telegram-plugin/dist/server.js +1 -1
  14. package/telegram-plugin/gateway/coalesce-attachments.ts +79 -0
  15. package/telegram-plugin/gateway/gateway.ts +257 -39
  16. package/telegram-plugin/gateway/interrupt-defer.ts +106 -0
  17. package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -4
  18. package/telegram-plugin/tests/coalesce-attachments.test.ts +170 -0
  19. package/telegram-plugin/tests/interrupt-defer.test.ts +160 -0
  20. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +36 -0
  21. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
  22. package/telegram-plugin/tests/worker-activity-feed.test.ts +127 -0
  23. package/telegram-plugin/uat/assertions.ts +53 -0
  24. package/telegram-plugin/uat/driver.ts +28 -0
  25. package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
  26. package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
  27. package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
  28. package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
  29. package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
  30. package/telegram-plugin/uat/scenarios/jtbd-forwarded-burst-dm.test.ts +158 -0
  31. package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
  32. package/telegram-plugin/worker-activity-feed.ts +65 -9
@@ -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 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. 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 (the default), 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. Set false to " + "fire 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,54 @@ 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
+ function resolveSafeBoundaryEnabled(configured) {
31078
+ return configured !== false;
31079
+ }
31080
+
31028
31081
  // sticker-aliases.ts
31029
31082
  function looksLikeFileId(s) {
31030
31083
  return /^[A-Za-z0-9_-]{10,200}$/.test(s);
@@ -31562,6 +31615,37 @@ function inboundCoalesceKey(chatId, threadId, userId) {
31562
31615
  return `${chatId}:${t}:${userId}`;
31563
31616
  }
31564
31617
 
31618
+ // gateway/coalesce-attachments.ts
31619
+ var DEFAULT_MAX_ATTACHMENTS = 10;
31620
+ function resolveCoalesceMaxAttachments(configured) {
31621
+ return Math.max(1, configured ?? DEFAULT_MAX_ATTACHMENTS);
31622
+ }
31623
+ function splitCoalescedAttachments(entries, hasAttachment, maxAttachments) {
31624
+ const withAttachment = entries.filter(hasAttachment);
31625
+ const capped = withAttachment.slice(0, Math.max(1, maxAttachments));
31626
+ const [primary, ...extras] = capped;
31627
+ return { primary, extras };
31628
+ }
31629
+ function buildExtraAttachmentMeta(resolved) {
31630
+ const out = {};
31631
+ resolved.forEach((ex, i) => {
31632
+ const n = i + 2;
31633
+ if (ex.imagePath)
31634
+ out[`image_path_${n}`] = ex.imagePath;
31635
+ if (ex.attachment) {
31636
+ out[`attachment_kind_${n}`] = ex.attachment.kind;
31637
+ out[`attachment_file_id_${n}`] = ex.attachment.file_id;
31638
+ if (ex.attachment.size != null)
31639
+ out[`attachment_size_${n}`] = String(ex.attachment.size);
31640
+ if (ex.attachment.mime)
31641
+ out[`attachment_mime_${n}`] = ex.attachment.mime;
31642
+ if (ex.attachment.name)
31643
+ out[`attachment_name_${n}`] = ex.attachment.name;
31644
+ }
31645
+ });
31646
+ return out;
31647
+ }
31648
+
31565
31649
  // status-reactions.ts
31566
31650
  var TELEGRAM_REACTION_WHITELIST = new Set([
31567
31651
  "\uD83D\uDC4D",
@@ -31879,9 +31963,13 @@ class DeferredDoneReactions {
31879
31963
  }
31880
31964
 
31881
31965
  // worker-activity-feed.ts
31966
+ function isWorkerActivityFeedEnabled(envVal) {
31967
+ return envVal !== "0";
31968
+ }
31882
31969
  var DESC_MAX = 80;
31883
31970
  var TOOL_ARG_MAX = 64;
31884
31971
  var SUMMARY_MAX = 100;
31972
+ var NARRATIVE_MAX_LINES = 6;
31885
31973
  function renderWorkerActivity(v) {
31886
31974
  const desc = truncate(v.description.trim() || "background task", DESC_MAX);
31887
31975
  const elapsed = formatDuration(v.elapsedMs);
@@ -31900,10 +31988,17 @@ function renderWorkerActivity(v) {
31900
31988
  } else {
31901
31989
  activity = `<i>starting\u2026 (${elapsed})</i>`;
31902
31990
  }
31903
- const summary = v.latestSummary.trim();
31904
31991
  const lines = [header, activity];
31905
- if (summary.length > 0) {
31906
- lines.push(` \u21b3 <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`);
31992
+ const narrative = (v.narrativeLines ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
31993
+ if (narrative.length > 0) {
31994
+ for (const line of narrative) {
31995
+ lines.push(` \u21b3 <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`);
31996
+ }
31997
+ } else {
31998
+ const summary = v.latestSummary.trim();
31999
+ if (summary.length > 0) {
32000
+ lines.push(` \u21b3 <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`);
32001
+ }
31907
32002
  }
31908
32003
  return lines.join(`
31909
32004
  `);
@@ -31940,10 +32035,22 @@ function createWorkerActivityFeed(opts) {
31940
32035
  h.cooldownUntil = nowFn() + retryAfter * 1000 + COOLDOWN_JITTER_MS;
31941
32036
  log(`worker-feed: ${label} 429 \u2014 backing off ${retryAfter}s`);
31942
32037
  }
32038
+ function accumulateNarrative(h, view) {
32039
+ const line = view.latestSummary.trim();
32040
+ if (line.length === 0)
32041
+ return;
32042
+ if (h.narrative[h.narrative.length - 1] === line)
32043
+ return;
32044
+ h.narrative.push(line);
32045
+ if (h.narrative.length > NARRATIVE_MAX_LINES) {
32046
+ h.narrative.splice(0, h.narrative.length - NARRATIVE_MAX_LINES);
32047
+ }
32048
+ }
31943
32049
  async function doUpdate(h, view) {
32050
+ accumulateNarrative(h, view);
31944
32051
  if (nowFn() < h.cooldownUntil)
31945
32052
  return;
31946
- const body = renderWorkerActivity(view);
32053
+ const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative });
31947
32054
  if (h.messageId == null) {
31948
32055
  if (view.elapsedMs < firstPaintMin)
31949
32056
  return;
@@ -32010,6 +32117,7 @@ function createWorkerActivityFeed(opts) {
32010
32117
  lastBody: null,
32011
32118
  lastEditAt: 0,
32012
32119
  cooldownUntil: 0,
32120
+ narrative: [],
32013
32121
  chain: Promise.resolve()
32014
32122
  };
32015
32123
  handles.set(agentId, h);
@@ -46399,6 +46507,7 @@ function planBufferedRedelivery(pending) {
46399
46507
  flush();
46400
46508
  return out;
46401
46509
  }
46510
+ var ATTACHMENT_META_RE = /^(image_path|attachment_)/;
46402
46511
  function mergeRun(run2) {
46403
46512
  const last = run2[run2.length - 1];
46404
46513
  const mediaEntry = run2.find(inboundHasMedia);
@@ -46409,6 +46518,14 @@ function mergeRun(run2) {
46409
46518
  };
46410
46519
  delete merged.imagePath;
46411
46520
  delete merged.attachment;
46521
+ if (mediaEntry != null && mediaEntry !== last) {
46522
+ const splicedMeta = { ...merged.meta };
46523
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
46524
+ if (ATTACHMENT_META_RE.test(k))
46525
+ splicedMeta[k] = v;
46526
+ }
46527
+ merged.meta = splicedMeta;
46528
+ }
46412
46529
  if (mediaEntry?.imagePath != null)
46413
46530
  merged.imagePath = mediaEntry.imagePath;
46414
46531
  if (mediaEntry?.attachment != null)
@@ -47037,6 +47154,7 @@ function planBufferedRedelivery2(pending) {
47037
47154
  flush();
47038
47155
  return out;
47039
47156
  }
47157
+ var ATTACHMENT_META_RE2 = /^(image_path|attachment_)/;
47040
47158
  function mergeRun2(run2) {
47041
47159
  const last = run2[run2.length - 1];
47042
47160
  const mediaEntry = run2.find(inboundHasMedia2);
@@ -47047,6 +47165,14 @@ function mergeRun2(run2) {
47047
47165
  };
47048
47166
  delete merged.imagePath;
47049
47167
  delete merged.attachment;
47168
+ if (mediaEntry != null && mediaEntry !== last) {
47169
+ const splicedMeta = { ...merged.meta };
47170
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
47171
+ if (ATTACHMENT_META_RE2.test(k))
47172
+ splicedMeta[k] = v;
47173
+ }
47174
+ merged.meta = splicedMeta;
47175
+ }
47050
47176
  if (mediaEntry?.imagePath != null)
47051
47177
  merged.imagePath = mediaEntry.imagePath;
47052
47178
  if (mediaEntry?.attachment != null)
@@ -51244,10 +51370,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51244
51370
  }
51245
51371
 
51246
51372
  // ../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;
51373
+ var VERSION = "0.14.21";
51374
+ var COMMIT_SHA = "62ddded0";
51375
+ var COMMIT_DATE = "2026-05-31T02:54:29Z";
51376
+ var LATEST_PR = 2024;
51251
51377
  var COMMITS_AHEAD_OF_TAG = 0;
51252
51378
 
51253
51379
  // gateway/boot-version.ts
@@ -52015,6 +52141,9 @@ function readAccessFile() {
52015
52141
  parseMode: parsed.parseMode,
52016
52142
  disableLinkPreview: parsed.disableLinkPreview,
52017
52143
  coalescingGapMs: parsed.coalescingGapMs,
52144
+ coalesceMaxAttachments: parsed.coalesceMaxAttachments,
52145
+ interruptSafeBoundary: parsed.interruptSafeBoundary,
52146
+ interruptMaxWaitMs: parsed.interruptMaxWaitMs,
52018
52147
  statusReactions: parsed.statusReactions,
52019
52148
  historyEnabled: parsed.historyEnabled,
52020
52149
  historyRetentionDays: parsed.historyRetentionDays,
@@ -52222,6 +52351,40 @@ var lastPtyPreviewByChat = new Map;
52222
52351
  var progressUpdateLastSent = new Map;
52223
52352
  var progressUpdateTurnCount = new Map;
52224
52353
  var currentTurn = null;
52354
+ var toolFlightTracker = new ToolFlightTracker;
52355
+ var pendingDeferredInterrupt = null;
52356
+ async function fireDeferredInterrupt(reason) {
52357
+ const pending2 = pendingDeferredInterrupt;
52358
+ if (pending2 == null)
52359
+ return;
52360
+ pendingDeferredInterrupt = null;
52361
+ clearTimeout(pending2.deadlineTimer);
52362
+ const waitedMs = Date.now() - pending2.registeredAt;
52363
+ process.stderr.write(`telegram gateway: deferred-interrupt firing reason=${reason} agent=${pending2.agentName} chat=${pending2.chatId} waited_ms=${waitedMs} in_flight=${toolFlightTracker.inFlightCount()}
52364
+ `);
52365
+ try {
52366
+ const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
52367
+ const r = sendAgentInterrupt2({ agentName: pending2.agentName });
52368
+ if ("ok" in r) {
52369
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT delivered via tmux send-keys agent=${pending2.agentName}
52370
+ `);
52371
+ } else {
52372
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT via tmux failed agent=${pending2.agentName}: ${r.error}
52373
+ `);
52374
+ }
52375
+ } catch (err) {
52376
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT failed: ${err.message}
52377
+ `);
52378
+ }
52379
+ const delivered = ipcServer.sendToAgent(pending2.agentName, pending2.inboundMsg);
52380
+ if (delivered) {
52381
+ markClaudeBusyForInbound(pending2.inboundMsg);
52382
+ } else {
52383
+ pendingInboundBuffer.push(pending2.agentName, pending2.inboundMsg);
52384
+ process.stderr.write(`telegram gateway: deferred-interrupt body buffered (bridge miss) agent=${pending2.agentName} chat=${pending2.chatId}
52385
+ `);
52386
+ }
52387
+ }
52225
52388
  var preambleSuppressor = new PreambleSuppressor({
52226
52389
  emitAnswer: (cumulative) => {
52227
52390
  const stream = currentTurn?.answerStream ?? null;
@@ -52963,23 +53126,27 @@ function looksLikeAuthCode(text) {
52963
53126
  return true;
52964
53127
  return false;
52965
53128
  }
52966
- var bufferedAttachmentKeys = new Set;
53129
+ var bufferedAttachmentKeys = new Map;
53130
+ function coalesceMaxAttachments() {
53131
+ return resolveCoalesceMaxAttachments(loadAccess().coalesceMaxAttachments);
53132
+ }
52967
53133
  var inboundCoalescer = createInboundCoalescer({
52968
53134
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
52969
53135
  merge: (entries) => {
52970
53136
  const last = entries[entries.length - 1];
52971
- const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null);
53137
+ const { primary, extras } = splitCoalescedAttachments(entries, (e) => e.downloadImage != null || e.attachment != null, coalesceMaxAttachments());
52972
53138
  return {
52973
- text: entries.map((e) => e.text).join(`
53139
+ text: entries.map((e) => e.text).filter((t) => t.length > 0).join(`
52974
53140
  `),
52975
53141
  ctx: last.ctx,
52976
- downloadImage: withAttachment?.downloadImage,
52977
- attachment: withAttachment?.attachment
53142
+ downloadImage: primary?.downloadImage,
53143
+ attachment: primary?.attachment,
53144
+ extraAttachments: extras.length > 0 ? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment })) : undefined
52978
53145
  };
52979
53146
  },
52980
53147
  onFlush: (key, merged) => {
52981
53148
  bufferedAttachmentKeys.delete(key);
52982
- handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment);
53149
+ handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment, merged.extraAttachments);
52983
53150
  }
52984
53151
  });
52985
53152
  function emitGatewayOperatorEvent(event) {
@@ -53498,6 +53665,10 @@ var ipcServer = createIpcServer({
53498
53665
  const threadHint = msg.threadId != null ? String(msg.threadId) : undefined;
53499
53666
  progressDriver?.ingest(ev, chatHint, threadHint);
53500
53667
  handleSessionEvent(ev);
53668
+ toolFlightTracker.onEvent(ev);
53669
+ if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
53670
+ fireDeferredInterrupt("boundary");
53671
+ }
53501
53672
  if (currentTurn != null) {
53502
53673
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId);
53503
53674
  if (ev.kind === "thinking") {
@@ -56141,7 +56312,8 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56141
56312
  return handleInbound(ctx, text, downloadImage, attachment);
56142
56313
  }
56143
56314
  const hasAttachment = downloadImage != null || attachment != null;
56144
- if (hasAttachment && ctx.message?.media_group_id != null) {
56315
+ const maxAttachments = coalesceMaxAttachments();
56316
+ if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
56145
56317
  return handleInbound(ctx, text, downloadImage, attachment);
56146
56318
  }
56147
56319
  const from = ctx.from;
@@ -56149,7 +56321,7 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56149
56321
  return;
56150
56322
  if (hasAttachment) {
56151
56323
  const probeKey = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56152
- if (bufferedAttachmentKeys.has(probeKey)) {
56324
+ if ((bufferedAttachmentKeys.get(probeKey) ?? 0) >= maxAttachments) {
56153
56325
  return handleInbound(ctx, text, downloadImage, attachment);
56154
56326
  }
56155
56327
  }
@@ -56159,7 +56331,7 @@ async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56159
56331
  if (result.bypass)
56160
56332
  return handleInbound(ctx, text, downloadImage, attachment);
56161
56333
  if (hasAttachment)
56162
- bufferedAttachmentKeys.add(key);
56334
+ bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1);
56163
56335
  }
56164
56336
  function maybeEarlyAckReaction(ctx, from) {
56165
56337
  const msgId = ctx.message?.message_id;
@@ -56180,7 +56352,7 @@ function maybeEarlyAckReaction(ctx, from) {
56180
56352
  ]).catch(() => {});
56181
56353
  bot.api.sendChatAction(chatId, "typing").catch(() => {});
56182
56354
  }
56183
- async function handleInbound(ctx, text, downloadImage, attachment) {
56355
+ async function handleInbound(ctx, text, downloadImage, attachment, extraAttachments) {
56184
56356
  const isTopicMessage = ctx.message?.is_topic_message ?? false;
56185
56357
  const messageThreadId = ctx.message?.message_thread_id;
56186
56358
  if (TOPIC_ID != null) {
@@ -56240,16 +56412,22 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
56240
56412
  `);
56241
56413
  }
56242
56414
  const interrupt = parseInterruptMarker(text);
56415
+ let deferInterrupt = false;
56243
56416
  if (interrupt.isInterrupt) {
56244
56417
  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}
56418
+ const access2 = loadAccess();
56419
+ deferInterrupt = !interrupt.emptyBody && decideInterruptTiming({
56420
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access2.interruptSafeBoundary),
56421
+ midToolCall: toolFlightTracker.isMidToolCall()
56422
+ }) === "defer";
56423
+ 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
56424
  `);
56247
56425
  if (msgId != null) {
56248
56426
  bot.api.setMessageReaction(chat_id, msgId, [
56249
56427
  { type: "emoji", emoji: "\u26A1" }
56250
56428
  ]).catch(() => {});
56251
56429
  }
56252
- if (agentName3) {
56430
+ if (agentName3 && !deferInterrupt) {
56253
56431
  try {
56254
56432
  const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
56255
56433
  const r = sendAgentInterrupt2({ agentName: agentName3 });
@@ -56688,6 +56866,16 @@ ${preBlock(write.output)}`;
56688
56866
  }
56689
56867
  }
56690
56868
  const imagePath = downloadImage ? await downloadImage() : undefined;
56869
+ const extraResolved = [];
56870
+ if (extraAttachments && extraAttachments.length > 0) {
56871
+ for (const ex of extraAttachments) {
56872
+ const exImagePath = ex.downloadImage ? await ex.downloadImage() : undefined;
56873
+ extraResolved.push({ imagePath: exImagePath, attachment: ex.attachment });
56874
+ }
56875
+ }
56876
+ const extraMeta = buildExtraAttachmentMeta(extraResolved);
56877
+ const primaryHasAttachment = imagePath != null || attachment != null;
56878
+ const attachmentCount = (primaryHasAttachment ? 1 : 0) + extraResolved.length;
56691
56879
  const replyToMsg = ctx.message?.reply_to_message;
56692
56880
  const replyToMessageId = replyToMsg?.message_id;
56693
56881
  const replyToTextRaw = replyToMsg ? replyToMsg.text ?? replyToMsg.caption ?? undefined : undefined;
@@ -56771,10 +56959,37 @@ ${preBlock(write.output)}`;
56771
56959
  ...attachment.size != null ? { attachment_size: String(attachment.size) } : {},
56772
56960
  ...attachment.mime ? { attachment_mime: attachment.mime } : {},
56773
56961
  ...attachment.name ? { attachment_name: attachment.name } : {}
56774
- } : {}
56962
+ } : {},
56963
+ ...attachmentCount > 1 ? { attachment_count: String(attachmentCount) } : {},
56964
+ ...extraMeta
56775
56965
  }
56776
56966
  };
56777
56967
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
56968
+ if (deferInterrupt) {
56969
+ const selfAgentDefer = process.env.SWITCHROOM_AGENT_NAME ?? "";
56970
+ if (pendingDeferredInterrupt != null) {
56971
+ pendingDeferredInterrupt.inboundMsg = inboundMsg;
56972
+ pendingDeferredInterrupt.msgId = msgId ?? null;
56973
+ process.stderr.write(`telegram gateway: deferred-interrupt coalesced (replacing pending body) agent=${selfAgentDefer} chat=${chat_id} msg=${msgId ?? "-"}
56974
+ `);
56975
+ } else {
56976
+ const maxWaitMs = resolveInterruptMaxWaitMs(loadAccess().interruptMaxWaitMs);
56977
+ pendingDeferredInterrupt = {
56978
+ agentName: selfAgentDefer,
56979
+ inboundMsg,
56980
+ chatId: chat_id,
56981
+ msgId: msgId ?? null,
56982
+ threadId: messageThreadId ?? undefined,
56983
+ registeredAt: Date.now(),
56984
+ deadlineTimer: setTimeout(() => {
56985
+ fireDeferredInterrupt("timeout");
56986
+ }, maxWaitMs)
56987
+ };
56988
+ process.stderr.write(`telegram gateway: deferred-interrupt parked agent=${selfAgentDefer} chat=${chat_id} msg=${msgId ?? "-"} max_wait_ms=${maxWaitMs} in_flight=${toolFlightTracker.inFlightCount()}
56989
+ `);
56990
+ }
56991
+ return;
56992
+ }
56778
56993
  if (decideInboundDelivery({
56779
56994
  turnInFlight: turnInFlightAtReceipt,
56780
56995
  isSteering,
@@ -61449,7 +61664,7 @@ var didOneTimeSetup = false;
61449
61664
  if (streamMode === "checklist") {
61450
61665
  const watcherAgentDir = resolveAgentDirFromEnv();
61451
61666
  if (watcherAgentDir != null) {
61452
- const workerFeedEnabled = process.env.SWITCHROOM_WORKER_ACTIVITY_FEED === "1";
61667
+ const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED);
61453
61668
  const workerActivityFeed = createWorkerActivityFeed({
61454
61669
  bot: {
61455
61670
  sendMessage: async (cid, text, sendOpts) => {