switchroom 0.20.7 → 0.20.8

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.
@@ -22593,6 +22593,20 @@ var init_overlay_schema = __esm(() => {
22593
22593
  // ../src/config/overlay-loader.ts
22594
22594
  import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync5 } from "node:fs";
22595
22595
  import { basename as basename4, resolve as resolve4 } from "node:path";
22596
+ function recordReadFailure(agentCfg, failure) {
22597
+ const node = agentCfg;
22598
+ const existing = node[OVERLAY_READ_FAILURES];
22599
+ if (Array.isArray(existing)) {
22600
+ existing.push(failure);
22601
+ return;
22602
+ }
22603
+ Object.defineProperty(agentCfg, OVERLAY_READ_FAILURES, {
22604
+ value: [failure],
22605
+ enumerable: false,
22606
+ configurable: true,
22607
+ writable: false
22608
+ });
22609
+ }
22596
22610
  function deriveOverlayTitle(raw, fileName) {
22597
22611
  const titleFromComment = raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m)?.[1];
22598
22612
  if (titleFromComment)
@@ -22602,17 +22616,39 @@ function deriveOverlayTitle(raw, fileName) {
22602
22616
  return;
22603
22617
  return base.length > 0 ? base : undefined;
22604
22618
  }
22619
+ function readOverlayFile(agentName, file, agentCfg, warnings) {
22620
+ try {
22621
+ return readFileSync5(file, "utf-8");
22622
+ } catch (err) {
22623
+ const code = err.code;
22624
+ if (code === "ENOENT")
22625
+ return;
22626
+ const w = {
22627
+ agent: agentName,
22628
+ file,
22629
+ reason: `read error: ${err.message}`,
22630
+ code: code ?? "EUNKNOWN"
22631
+ };
22632
+ recordReadFailure(agentCfg, { file, code: w.code });
22633
+ warnings.push(w);
22634
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${w.reason}`);
22635
+ return;
22636
+ }
22637
+ }
22605
22638
  function overlayDirFor(agentName, subdir) {
22606
22639
  const base = resolveDualPath(`~/.switchroom/agents/${agentName}/${subdir}`);
22607
22640
  return resolve4(base);
22608
22641
  }
22609
- function listYamlFiles(dir) {
22642
+ function listYamlFiles(dir, onUnreadableDir) {
22610
22643
  if (!existsSync5(dir))
22611
22644
  return [];
22612
22645
  let entries;
22613
22646
  try {
22614
22647
  entries = readdirSync3(dir);
22615
- } catch {
22648
+ } catch (err) {
22649
+ const code = err.code;
22650
+ if (code !== "ENOENT")
22651
+ onUnreadableDir?.(code ?? "EUNKNOWN");
22616
22652
  return [];
22617
22653
  }
22618
22654
  const out = [];
@@ -22650,12 +22686,24 @@ function applyAgentOverlays(config) {
22650
22686
  for (const [agentName, agentCfg] of Object.entries(agents)) {
22651
22687
  try {
22652
22688
  const scheduleDir = overlayDirFor(agentName, "schedule.d");
22653
- const files = listYamlFiles(scheduleDir);
22689
+ const files = listYamlFiles(scheduleDir, (code) => {
22690
+ const w = {
22691
+ agent: agentName,
22692
+ file: scheduleDir,
22693
+ reason: `read error: cannot list overlay directory (${code})`,
22694
+ code
22695
+ };
22696
+ recordReadFailure(agentCfg, { file: scheduleDir, code });
22697
+ warnings.push(w);
22698
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${scheduleDir}': ${w.reason}`);
22699
+ });
22654
22700
  if (files.length > 0) {
22655
22701
  const merged = [...agentCfg.schedule ?? []];
22656
22702
  for (const file of files) {
22703
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings);
22704
+ if (raw === undefined)
22705
+ continue;
22657
22706
  try {
22658
- const raw = readFileSync5(file, "utf-8");
22659
22707
  const parsed = import_yaml.parse(raw);
22660
22708
  const doc = OverlayDocSchema.parse(parsed);
22661
22709
  const title = deriveOverlayTitle(raw, basename4(file));
@@ -22690,13 +22738,25 @@ function applyAgentOverlays(config) {
22690
22738
  }
22691
22739
  try {
22692
22740
  const skillsDir = overlayDirFor(agentName, "skills.d");
22693
- const skillFiles = listYamlFiles(skillsDir);
22741
+ const skillFiles = listYamlFiles(skillsDir, (code) => {
22742
+ const w = {
22743
+ agent: agentName,
22744
+ file: skillsDir,
22745
+ reason: `read error: cannot list overlay directory (${code})`,
22746
+ code
22747
+ };
22748
+ recordReadFailure(agentCfg, { file: skillsDir, code });
22749
+ warnings.push(w);
22750
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${skillsDir}': ${w.reason}`);
22751
+ });
22694
22752
  if (skillFiles.length === 0) {} else {
22695
22753
  const merged = [...agentCfg.skills ?? []];
22696
22754
  const seen = new Set(merged);
22697
22755
  for (const file of skillFiles) {
22756
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings);
22757
+ if (raw === undefined)
22758
+ continue;
22698
22759
  try {
22699
- const raw = readFileSync5(file, "utf-8");
22700
22760
  const parsed = import_yaml.parse(raw);
22701
22761
  const doc = OverlayDocSchema.parse(parsed);
22702
22762
  for (const skillName of doc.skills ?? []) {
@@ -22724,7 +22784,7 @@ function applyAgentOverlays(config) {
22724
22784
  }
22725
22785
  return { config, warnings };
22726
22786
  }
22727
- var import_yaml, OVERLAY_SOURCE, OVERLAY_TITLE;
22787
+ var import_yaml, OVERLAY_SOURCE, OVERLAY_TITLE, OVERLAY_READ_FAILURES;
22728
22788
  var init_overlay_loader = __esm(() => {
22729
22789
  init_zod();
22730
22790
  init_overlay_schema();
@@ -22732,6 +22792,7 @@ var init_overlay_loader = __esm(() => {
22732
22792
  import_yaml = __toESM(require_dist(), 1);
22733
22793
  OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
22734
22794
  OVERLAY_TITLE = Symbol.for("switchroom.config.overlay-title");
22795
+ OVERLAY_READ_FAILURES = Symbol.for("switchroom.config.overlay-read-failures");
22735
22796
  });
22736
22797
 
22737
22798
  // ../src/config/merge.ts
@@ -80949,6 +81010,22 @@ function endsWithSilentMarker(text4) {
80949
81010
  return false;
80950
81011
  return isSilentFlushMarker(lines[lines.length - 1]);
80951
81012
  }
81013
+ function isSilentSentinelCardOutcome(input) {
81014
+ if (input.finalAnswerEverDelivered)
81015
+ return false;
81016
+ if (isSilentFlushMarker(input.lastReplyText) || isCompositeSilentNoise(input.lastReplyText)) {
81017
+ return true;
81018
+ }
81019
+ if (!input.replyCalled) {
81020
+ const joined = input.capturedText.join(`
81021
+
81022
+ `).trim();
81023
+ if (joined.length > 0 && (isSilentFlushMarker(joined) || isCompositeSilentNoise(joined) || endsWithSilentMarker(joined))) {
81024
+ return true;
81025
+ }
81026
+ }
81027
+ return false;
81028
+ }
80952
81029
  function decideTurnFlush(input) {
80953
81030
  const flushEnabled = input.flushEnabled !== false;
80954
81031
  if (!flushEnabled)
@@ -82614,8 +82691,7 @@ function handleSessionEvent(deps, ev) {
82614
82691
  }
82615
82692
  if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
82616
82693
  const cardChatId = ev.chatId;
82617
- const replyToRaw = ev.messageId != null ? Number(ev.messageId) : null;
82618
- const replyTo = replyToRaw != null && Number.isFinite(replyToRaw) ? replyToRaw : null;
82694
+ const replyTo = parseSourceMessageId(ev.messageId);
82619
82695
  openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
82620
82696
  if (cardId == null)
82621
82697
  return;
@@ -83899,7 +83975,13 @@ function createNarrativeLane(deps) {
83899
83975
  clearActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, statusKey(chat, thread), id);
83900
83976
  }
83901
83977
  await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false });
83902
- if (CLEAR_STATUS_ON_COMPLETION) {
83978
+ const silentSentinelTurn = isSilentSentinelCardOutcome({
83979
+ replyCalled: turn.replyCalled,
83980
+ lastReplyText: turn.lastReplyText,
83981
+ capturedText: turn.capturedText,
83982
+ finalAnswerEverDelivered: turn.finalAnswerEverDelivered
83983
+ });
83984
+ if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn) {
83903
83985
  try {
83904
83986
  await robustApiCall(() => bot.api.deleteMessage(chat, id), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.delete" });
83905
83987
  } catch (err) {
@@ -92982,19 +93064,121 @@ function makeRepresentRedeliveryGuard(deps) {
92982
93064
  return true;
92983
93065
  };
92984
93066
  }
92985
- function makeSessionBusyDrainDeferral(boundMs) {
93067
+ var DEFAULT_DRAIN_DEFER_STALE_GAP_MS = 15000;
93068
+ function makeSessionBusyDrainDeferral(boundMs, staleGapMs = DEFAULT_DRAIN_DEFER_STALE_GAP_MS) {
92986
93069
  let deferringSince = null;
93070
+ let lastCallAt = null;
92987
93071
  return (busy, now) => {
93072
+ const gapSinceLastCall = lastCallAt == null ? null : now - lastCallAt;
93073
+ lastCallAt = now;
92988
93074
  if (!busy || boundMs <= 0) {
92989
93075
  deferringSince = null;
92990
93076
  return false;
92991
93077
  }
92992
- if (deferringSince == null)
93078
+ if (deferringSince == null || staleGapMs > 0 && gapSinceLastCall != null && gapSinceLastCall > staleGapMs) {
92993
93079
  deferringSince = now;
93080
+ }
92994
93081
  return now - deferringSince < boundMs;
92995
93082
  };
92996
93083
  }
92997
93084
 
93085
+ // gateway/pending-inbound-buffer.ts
93086
+ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
93087
+ const pending = buffer.drain(agent);
93088
+ let redelivered = 0;
93089
+ let rebuffered = 0;
93090
+ let retracted = 0;
93091
+ for (const { merged, originals } of planBufferedRedelivery2(pending)) {
93092
+ let proceed = true;
93093
+ if (buffer.beforeRedeliver != null) {
93094
+ try {
93095
+ proceed = buffer.beforeRedeliver(merged);
93096
+ } catch (e) {
93097
+ proceed = true;
93098
+ process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
93099
+ `);
93100
+ }
93101
+ }
93102
+ if (!proceed) {
93103
+ for (const o of originals)
93104
+ spool?.ack(o);
93105
+ retracted += originals.length;
93106
+ continue;
93107
+ }
93108
+ let delivered = false;
93109
+ try {
93110
+ delivered = send(merged);
93111
+ } catch {
93112
+ delivered = false;
93113
+ }
93114
+ if (delivered) {
93115
+ for (const o of originals)
93116
+ spool?.ack(o);
93117
+ redelivered += originals.length;
93118
+ onDelivered?.(merged, originals);
93119
+ } else {
93120
+ for (const o of originals)
93121
+ buffer.push(agent, o);
93122
+ rebuffered += originals.length;
93123
+ }
93124
+ }
93125
+ return { drained: pending.length, redelivered, rebuffered, retracted };
93126
+ }
93127
+ function isMergeableUserInbound2(msg) {
93128
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
93129
+ }
93130
+ function inboundHasMedia2(msg) {
93131
+ return msg.imagePath != null || msg.attachment != null;
93132
+ }
93133
+ function planBufferedRedelivery2(pending) {
93134
+ const out = [];
93135
+ let run3 = [];
93136
+ let runHasMedia = false;
93137
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
93138
+ const flush = () => {
93139
+ if (run3.length === 0)
93140
+ return;
93141
+ out.push({ merged: run3.length === 1 ? run3[0] : mergeRun2(run3), originals: run3 });
93142
+ run3 = [];
93143
+ runHasMedia = false;
93144
+ };
93145
+ for (const msg of pending) {
93146
+ const msgHasMedia = inboundHasMedia2(msg);
93147
+ const canJoin = run3.length > 0 && isMergeableUserInbound2(msg) && isMergeableUserInbound2(run3[run3.length - 1]) && sameTarget(run3[run3.length - 1], msg) && !(runHasMedia && msgHasMedia);
93148
+ if (!canJoin)
93149
+ flush();
93150
+ run3.push(msg);
93151
+ runHasMedia = runHasMedia || msgHasMedia;
93152
+ }
93153
+ flush();
93154
+ return out;
93155
+ }
93156
+ var ATTACHMENT_META_RE2 = /^(image_path|attachment_)/;
93157
+ function mergeRun2(run3) {
93158
+ const last = run3[run3.length - 1];
93159
+ const mediaEntry = run3.find(inboundHasMedia2);
93160
+ const merged = {
93161
+ ...last,
93162
+ text: run3.map((m) => m.text).join(`
93163
+ `)
93164
+ };
93165
+ delete merged.imagePath;
93166
+ delete merged.attachment;
93167
+ if (mediaEntry != null && mediaEntry !== last) {
93168
+ const splicedMeta = { ...merged.meta };
93169
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
93170
+ if (ATTACHMENT_META_RE2.test(k))
93171
+ splicedMeta[k] = v;
93172
+ }
93173
+ merged.meta = splicedMeta;
93174
+ }
93175
+ if (mediaEntry?.imagePath != null)
93176
+ merged.imagePath = mediaEntry.imagePath;
93177
+ if (mediaEntry?.attachment != null)
93178
+ merged.attachment = mediaEntry.attachment;
93179
+ return merged;
93180
+ }
93181
+
92998
93182
  // gateway/cron-session.ts
92999
93183
  var CRON_IDENTITY_SUFFIX = "-cron";
93000
93184
  function cronIdentity(agent) {
@@ -93024,6 +93208,23 @@ function deliverInjectWithFallback(agentName3, meta, send) {
93024
93208
  }
93025
93209
  return { target, delivered: false, fellBackToMain: false };
93026
93210
  }
93211
+ function drainCronBridgeOnRegister(client3, buffer, spool, log) {
93212
+ client3.send({ type: "status", status: "agent_connected" });
93213
+ const send = (msg) => {
93214
+ try {
93215
+ client3.send(msg);
93216
+ return true;
93217
+ } catch {
93218
+ return false;
93219
+ }
93220
+ };
93221
+ const result = redeliverBufferedInbound2(buffer, client3.agentName ?? "", send, spool);
93222
+ if (result.drained > 0 && log != null) {
93223
+ log(`telegram gateway: cron-bridge drain agent=${client3.agentName} ` + `drained=${result.drained} redelivered=${result.redelivered} ` + `rebuffered=${result.rebuffered}
93224
+ `);
93225
+ }
93226
+ return result;
93227
+ }
93027
93228
 
93028
93229
  // gateway/obligation-ledger.ts
93029
93230
  class ObligationLedger {
@@ -94586,103 +94787,6 @@ function formatEventDetail2(event) {
94586
94787
  }
94587
94788
  }
94588
94789
 
94589
- // gateway/pending-inbound-buffer.ts
94590
- function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
94591
- const pending = buffer.drain(agent);
94592
- let redelivered = 0;
94593
- let rebuffered = 0;
94594
- let retracted = 0;
94595
- for (const { merged, originals } of planBufferedRedelivery2(pending)) {
94596
- let proceed = true;
94597
- if (buffer.beforeRedeliver != null) {
94598
- try {
94599
- proceed = buffer.beforeRedeliver(merged);
94600
- } catch (e) {
94601
- proceed = true;
94602
- process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
94603
- `);
94604
- }
94605
- }
94606
- if (!proceed) {
94607
- for (const o of originals)
94608
- spool?.ack(o);
94609
- retracted += originals.length;
94610
- continue;
94611
- }
94612
- let delivered = false;
94613
- try {
94614
- delivered = send(merged);
94615
- } catch {
94616
- delivered = false;
94617
- }
94618
- if (delivered) {
94619
- for (const o of originals)
94620
- spool?.ack(o);
94621
- redelivered += originals.length;
94622
- onDelivered?.(merged, originals);
94623
- } else {
94624
- for (const o of originals)
94625
- buffer.push(agent, o);
94626
- rebuffered += originals.length;
94627
- }
94628
- }
94629
- return { drained: pending.length, redelivered, rebuffered, retracted };
94630
- }
94631
- function isMergeableUserInbound2(msg) {
94632
- return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
94633
- }
94634
- function inboundHasMedia2(msg) {
94635
- return msg.imagePath != null || msg.attachment != null;
94636
- }
94637
- function planBufferedRedelivery2(pending) {
94638
- const out = [];
94639
- let run3 = [];
94640
- let runHasMedia = false;
94641
- const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
94642
- const flush = () => {
94643
- if (run3.length === 0)
94644
- return;
94645
- out.push({ merged: run3.length === 1 ? run3[0] : mergeRun2(run3), originals: run3 });
94646
- run3 = [];
94647
- runHasMedia = false;
94648
- };
94649
- for (const msg of pending) {
94650
- const msgHasMedia = inboundHasMedia2(msg);
94651
- const canJoin = run3.length > 0 && isMergeableUserInbound2(msg) && isMergeableUserInbound2(run3[run3.length - 1]) && sameTarget(run3[run3.length - 1], msg) && !(runHasMedia && msgHasMedia);
94652
- if (!canJoin)
94653
- flush();
94654
- run3.push(msg);
94655
- runHasMedia = runHasMedia || msgHasMedia;
94656
- }
94657
- flush();
94658
- return out;
94659
- }
94660
- var ATTACHMENT_META_RE2 = /^(image_path|attachment_)/;
94661
- function mergeRun2(run3) {
94662
- const last = run3[run3.length - 1];
94663
- const mediaEntry = run3.find(inboundHasMedia2);
94664
- const merged = {
94665
- ...last,
94666
- text: run3.map((m) => m.text).join(`
94667
- `)
94668
- };
94669
- delete merged.imagePath;
94670
- delete merged.attachment;
94671
- if (mediaEntry != null && mediaEntry !== last) {
94672
- const splicedMeta = { ...merged.meta };
94673
- for (const [k, v] of Object.entries(mediaEntry.meta)) {
94674
- if (ATTACHMENT_META_RE2.test(k))
94675
- splicedMeta[k] = v;
94676
- }
94677
- merged.meta = splicedMeta;
94678
- }
94679
- if (mediaEntry?.imagePath != null)
94680
- merged.imagePath = mediaEntry.imagePath;
94681
- if (mediaEntry?.attachment != null)
94682
- merged.attachment = mediaEntry.attachment;
94683
- return merged;
94684
- }
94685
-
94686
94790
  // gateway/obligation-turn-end.ts
94687
94791
  function decideObligationTurnEnd(finalAnswerDelivered, replyCalled) {
94688
94792
  return finalAnswerDelivered || replyCalled ? "close" : "note-ended";
@@ -99732,6 +99836,14 @@ function determineRestartReason(opts) {
99732
99836
  return "crash";
99733
99837
  return "fresh";
99734
99838
  }
99839
+ var BOOT_REASON_REUSE_WINDOW_MS = 5 * 60000;
99840
+ function determineBridgeReconnectReason(opts) {
99841
+ const { gatewayStartedAtMs, bootReason, bootReasonReuseWindowMs = BOOT_REASON_REUSE_WINDOW_MS } = opts;
99842
+ if (opts.marker == null && opts.cleanMarker == null && bootReason != null && opts.now - gatewayStartedAtMs < bootReasonReuseWindowMs) {
99843
+ return bootReason;
99844
+ }
99845
+ return determineRestartReason(opts);
99846
+ }
99735
99847
 
99736
99848
  // gateway/update-announce.ts
99737
99849
  import { existsSync as existsSync50, mkdirSync as mkdirSync44, openSync as openSync12, closeSync as closeSync12, readFileSync as readFileSync53 } from "node:fs";
@@ -102581,10 +102693,10 @@ function startOutboxSweep(deps) {
102581
102693
  }
102582
102694
 
102583
102695
  // ../src/build-info.ts
102584
- var VERSION2 = "0.20.7";
102585
- var COMMIT_SHA = "bc107185";
102586
- var COMMIT_DATE = "2026-08-04T10:20:28Z";
102587
- var LATEST_PR = 4343;
102696
+ var VERSION2 = "0.20.8";
102697
+ var COMMIT_SHA = "a6efc10f";
102698
+ var COMMIT_DATE = "2026-08-04T21:38:16Z";
102699
+ var LATEST_PR = 4374;
102588
102700
  var COMMITS_AHEAD_OF_TAG = 0;
102589
102701
 
102590
102702
  // gateway/boot-version.ts
@@ -108342,6 +108454,7 @@ var GATEWAY_STARTED_AT_MS = Date.now();
108342
108454
  var BOOT_CARD_ENABLED = process.env.SWITCHROOM_BOOT_CARD !== "false";
108343
108455
  var activeBootCard = null;
108344
108456
  var bootCardPending = false;
108457
+ var bootReasonAtStartup = null;
108345
108458
  var ISSUES_CARD_ENABLED = process.env.SWITCHROOM_ISSUES_CARD !== "false";
108346
108459
  var activeIssuesCard = null;
108347
108460
  var activeIssuesWatcher = null;
@@ -108835,13 +108948,7 @@ if (isGatewayMain)
108835
108948
  process.stderr.write(`telegram gateway: bridge registered \u2014 agent=${client3.agentName}
108836
108949
  `);
108837
108950
  if (isCronIdentity(client3.agentName)) {
108838
- client3.send({ type: "status", status: "agent_connected" });
108839
- const pending2 = pendingInboundBuffer.drain(client3.agentName ?? "");
108840
- for (const m of pending2) {
108841
- try {
108842
- client3.send(m);
108843
- } catch {}
108844
- }
108951
+ drainCronBridgeOnRegister(client3, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l));
108845
108952
  return;
108846
108953
  }
108847
108954
  const bridgeUpEffects = client3.agentName != null ? shadowEmit2({ kind: "bridgeUp", at: Date.now() }) : [];
@@ -108888,7 +108995,14 @@ if (isGatewayMain)
108888
108995
  `);
108889
108996
  clearRestartMarker();
108890
108997
  }
108891
- const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs3 });
108998
+ const reason = determineBridgeReconnectReason({
108999
+ marker,
109000
+ cleanMarker,
109001
+ sessionMarker: storedSession,
109002
+ now: nowMs3,
109003
+ gatewayStartedAtMs: GATEWAY_STARTED_AT_MS,
109004
+ bootReason: bootReasonAtStartup
109005
+ });
108892
109006
  const target = resolveBootChatId(marker, markerAgeMs);
108893
109007
  if (target) {
108894
109008
  const { chatId, threadId, ackMsgId } = target;
@@ -109635,7 +109749,7 @@ if (isGatewayMain)
109635
109749
  }
109636
109750
  })();
109637
109751
  var IDLE_DRAIN_INTERVAL_MS = 5000;
109638
- var idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS);
109752
+ var idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS, IDLE_DRAIN_INTERVAL_MS * 3);
109639
109753
  if (isGatewayMain && !STATIC) {
109640
109754
  setInterval(() => {
109641
109755
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
@@ -116154,6 +116268,7 @@ async function startGateway() {
116154
116268
  } else {
116155
116269
  const markerAgeMs = marker ? nowMs3 - marker.ts : undefined;
116156
116270
  const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs3 });
116271
+ bootReasonAtStartup = reason;
116157
116272
  const target = resolveBootChatId(marker, markerAgeMs);
116158
116273
  if (reason === "crash") {
116159
116274
  const cleanMarkerStale = cleanMarker ? !shouldSuppressRecoveryBanner(cleanMarker, nowMs3, DEFAULT_MAX_AGE_MS) : false;
@@ -24317,11 +24317,14 @@ function createIpcClient(options) {
24317
24317
  function scheduleReconnect() {
24318
24318
  if (closed)
24319
24319
  return;
24320
+ if (reconnectTimer)
24321
+ return;
24320
24322
  log(`reconnecting in ${currentDelay}ms`);
24321
24323
  reconnectTimer = setTimeout(() => {
24322
24324
  reconnectTimer = null;
24323
- if (!closed)
24324
- doConnect();
24325
+ if (!closed) {
24326
+ doConnect().catch(() => {});
24327
+ }
24325
24328
  }, currentDelay);
24326
24329
  currentDelay = Math.min(currentDelay * 2, maxReconnectDelayMs);
24327
24330
  }
@@ -83,3 +83,64 @@ export function determineRestartReason(opts: {
83
83
  if (sessionMarker != null) return 'crash'
84
84
  return 'fresh'
85
85
  }
86
+
87
+ /**
88
+ * Boot-reason window during which a bridge re-register reuses the reason
89
+ * the boot path already determined. Matches the restart-marker and
90
+ * operator-marker freshness windows (5 min) — a bridge that survived a
91
+ * gateway restart reconnects within seconds of the new gateway's boot,
92
+ * comfortably inside it.
93
+ */
94
+ export const BOOT_REASON_REUSE_WINDOW_MS = 5 * 60_000
95
+
96
+ /**
97
+ * Determine the restart reason for a BRIDGE RE-REGISTER (gateway.ts
98
+ * `onClientRegistered`, the `bridge-reconnect` path) — as opposed to the
99
+ * gateway's own boot path.
100
+ *
101
+ * Why this exists (fleet-audit B2, kdogg 2026-08-02 06:27 trace): on a
102
+ * planned gateway restart (`cli: restart` SIGTERM), the bridge — living
103
+ * inside the separate claude process — survives and reconnects a few
104
+ * seconds after the new gateway boots. By then the boot path has already
105
+ * READ AND CLEARED the restart / clean-shutdown markers (the 2026-05-25
106
+ * GC, gateway.ts boot path), so re-deriving the reason from disk falls
107
+ * through to the sessionMarker branch and every such re-register logs —
108
+ * and, when a chat is resolvable, POSTS a boot card claiming —
109
+ * `reason=crash` for a perfectly graceful restart. Hundreds of these per
110
+ * agent fleet-wide (lawgpt 310, reggie 179, ziggy 175, kdogg 174).
111
+ *
112
+ * Decision:
113
+ * 1. Any on-disk marker still present → normal `determineRestartReason`
114
+ * (in-gateway /restart flows where the gateway never went down write
115
+ * a marker the boot path never consumed — keep honoring it).
116
+ * 2. No markers, but the gateway booted recently (<5 min) and recorded
117
+ * the reason it determined at boot → reuse that reason. The bridge
118
+ * is re-registering into the SAME restart episode the boot path
119
+ * already classified.
120
+ * 3. Otherwise (gateway long-lived, markers absent) → fall through to
121
+ * `determineRestartReason` — a marker-less bridge re-register hours
122
+ * into a gateway's life still classifies conservatively as 'crash'
123
+ * (the claude/bridge side genuinely died and came back).
124
+ */
125
+ export function determineBridgeReconnectReason(opts: {
126
+ marker: { ts: number } | null
127
+ cleanMarker: CleanShutdownMarker | null
128
+ sessionMarker: SessionMarker | null
129
+ now: number
130
+ /** `GATEWAY_STARTED_AT_MS` of the running gateway process. */
131
+ gatewayStartedAtMs: number
132
+ /** Reason the gateway's own boot path determined (null if it never ran). */
133
+ bootReason: RestartReason | null
134
+ bootReasonReuseWindowMs?: number
135
+ cleanMaxAgeMs?: number
136
+ markerMaxAgeMs?: number
137
+ operatorMaxAgeMs?: number
138
+ }): RestartReason {
139
+ const { gatewayStartedAtMs, bootReason, bootReasonReuseWindowMs = BOOT_REASON_REUSE_WINDOW_MS } = opts
140
+ if (opts.marker == null && opts.cleanMarker == null
141
+ && bootReason != null
142
+ && opts.now - gatewayStartedAtMs < bootReasonReuseWindowMs) {
143
+ return bootReason
144
+ }
145
+ return determineRestartReason(opts)
146
+ }
@@ -17,6 +17,10 @@
17
17
  * target via `cronIdentity()`. Pure string fns — pinned in cron-session.test.ts.
18
18
  */
19
19
 
20
+ import type { InboundMessage, GatewayToClient } from './ipc-protocol.js'
21
+ import type { InboundSpool } from './inbound-spool.js'
22
+ import { redeliverBufferedInbound, type PendingInboundBuffer } from './pending-inbound-buffer.js'
23
+
20
24
  /** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
21
25
  export const CRON_IDENTITY_SUFFIX = "-cron";
22
26
 
@@ -140,3 +144,65 @@ export function deliverInjectWithFallback(
140
144
  }
141
145
  return { target, delivered: false, fellBackToMain: false };
142
146
  }
147
+
148
+ /** Minimal view of the IPC client the cron-bridge register handler needs.
149
+ * A structural subset of `IpcClient` so this stays unit-testable without the
150
+ * gateway's module-load side effects. */
151
+ export interface CronBridgeRegisterClient {
152
+ agentName: string | null | undefined;
153
+ send: (msg: GatewayToClient) => void;
154
+ }
155
+
156
+ /**
157
+ * Drain a cheap-cron (`<agent>-cron`) bridge's buffered fires when it
158
+ * registers — the Tier-1 §2.4/§3.3 status-silent path (#4348).
159
+ *
160
+ * The cron bridge is STATUS-SILENT: it must NOT drive the gateway's singleton
161
+ * machinery (shadow bridge-state, warmup, boot card). But it MUST still flush
162
+ * any cron fire buffered+spooled during the boot window — a due tick that
163
+ * arrived before the cron bridge registered.
164
+ *
165
+ * THE BUG (#4348): the pre-fix path did a raw `pendingInboundBuffer.drain()` +
166
+ * `client.send()` loop and returned early WITHOUT ever reaching `spool.ack`.
167
+ * `spool.ack` lives ONLY inside `redeliverBufferedInbound` — the one chokepoint
168
+ * every other drain path (bridgeUp, idle-drain, silence-poke, turn-end) routes
169
+ * through. So the durable spool entry stayed live, boot-replay re-pushed it on
170
+ * the next restart, and the SAME cron fire re-fired: a duplicate delivery
171
+ * bounded only by the 15-min escalation sweep. This was the ONLY drain that
172
+ * bypassed the chokepoint.
173
+ *
174
+ * THE FIX: route the drain through `redeliverBufferedInbound` too, so each
175
+ * delivered fire is spool-acked exactly once and cannot re-fire after a
176
+ * restart. `beforeRedeliver` (the represent-veto) rides along by construction;
177
+ * it self-gates and is a no-op for cron-sourced fires. A `send` throw now
178
+ * re-buffers the fire (lossless) instead of dropping it — strictly safer than
179
+ * the pre-fix best-effort drop and identical to every sibling drain path.
180
+ *
181
+ * Returns the `redeliverBufferedInbound` counts for observability.
182
+ */
183
+ export function drainCronBridgeOnRegister(
184
+ client: CronBridgeRegisterClient,
185
+ buffer: PendingInboundBuffer,
186
+ spool?: InboundSpool,
187
+ log?: (line: string) => void,
188
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
189
+ // Status-silent handshake ack (unchanged from the pre-fix path).
190
+ client.send({ type: "status", status: "agent_connected" });
191
+ const send = (msg: InboundMessage): boolean => {
192
+ try {
193
+ client.send(msg);
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
198
+ };
199
+ const result = redeliverBufferedInbound(buffer, client.agentName ?? "", send, spool);
200
+ if (result.drained > 0 && log != null) {
201
+ log(
202
+ `telegram gateway: cron-bridge drain agent=${client.agentName} ` +
203
+ `drained=${result.drained} redelivered=${result.redelivered} ` +
204
+ `rebuffered=${result.rebuffered}\n`,
205
+ );
206
+ }
207
+ return result;
208
+ }