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.
- package/dist/agent-scheduler/index.js +105 -11
- package/dist/auth-broker/index.js +68 -7
- package/dist/cli/notion-write-pretool.mjs +67 -6
- package/dist/cli/switchroom.js +384 -28
- package/dist/host-control/main.js +69 -8
- package/dist/vault/approvals/kernel-server.js +68 -7
- package/dist/vault/broker/server.js +68 -7
- package/package.json +1 -1
- package/telegram-plugin/bridge/ipc-client.ts +17 -1
- package/telegram-plugin/dist/bridge/bridge.js +5 -2
- package/telegram-plugin/dist/gateway/gateway.js +237 -122
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/gateway/boot-reason.ts +61 -0
- package/telegram-plugin/gateway/cron-session.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +28 -30
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
- package/telegram-plugin/gateway/stream-render.ts +11 -2
- package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
- package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
- package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
- package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/represent-guard.test.ts +45 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/turn-flush-safety.ts +79 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
102585
|
-
var COMMIT_SHA = "
|
|
102586
|
-
var COMMIT_DATE = "2026-08-
|
|
102587
|
-
var LATEST_PR =
|
|
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
|
|
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 =
|
|
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
|
+
}
|