@raysonmeng/agentbridge 0.1.6 → 0.1.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/.claude-plugin/marketplace.json +1 -1
- package/README.md +53 -6
- package/README.zh-CN.md +37 -1
- package/dist/cli.js +3983 -440
- package/dist/daemon.js +4713 -0
- package/package.json +18 -5
- package/plugins/agentbridge/.claude-plugin/plugin.json +1 -1
- package/plugins/agentbridge/README.md +2 -2
- package/plugins/agentbridge/scripts/health-check.sh +22 -3
- package/plugins/agentbridge/scripts/plugin-update-notice.mjs +73 -0
- package/plugins/agentbridge/server/bridge-server.js +1170 -142
- package/plugins/agentbridge/server/daemon.js +2690 -358
- package/scripts/install-safety.cjs +209 -0
- package/scripts/postinstall.cjs +114 -34
|
@@ -6518,7 +6518,7 @@ var require_dist = __commonJS((exports, module) => {
|
|
|
6518
6518
|
});
|
|
6519
6519
|
|
|
6520
6520
|
// src/bridge.ts
|
|
6521
|
-
import {
|
|
6521
|
+
import { existsSync as existsSync6 } from "fs";
|
|
6522
6522
|
|
|
6523
6523
|
// node_modules/zod/v4/core/core.js
|
|
6524
6524
|
var NEVER = Object.freeze({
|
|
@@ -13662,28 +13662,142 @@ class StdioServerTransport {
|
|
|
13662
13662
|
// src/claude-adapter.ts
|
|
13663
13663
|
import { EventEmitter } from "events";
|
|
13664
13664
|
import { randomUUID } from "crypto";
|
|
13665
|
-
|
|
13665
|
+
|
|
13666
|
+
// src/rotating-log.ts
|
|
13667
|
+
import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "fs";
|
|
13668
|
+
import { dirname } from "path";
|
|
13669
|
+
var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
13670
|
+
var DEFAULT_KEEP = 3;
|
|
13671
|
+
function appendRotatingLog(path, content, options = {}) {
|
|
13672
|
+
const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
|
|
13673
|
+
const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
|
|
13674
|
+
if (!existsSync(dirname(path)))
|
|
13675
|
+
return;
|
|
13676
|
+
rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
|
|
13677
|
+
appendFileSync(path, content, "utf-8");
|
|
13678
|
+
}
|
|
13679
|
+
function positiveIntFromEnv(name, fallback) {
|
|
13680
|
+
const value = process.env[name];
|
|
13681
|
+
if (!value)
|
|
13682
|
+
return fallback;
|
|
13683
|
+
const parsed = Number(value);
|
|
13684
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
13685
|
+
}
|
|
13686
|
+
function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
|
|
13687
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
|
|
13688
|
+
return;
|
|
13689
|
+
if (!existsSync(path))
|
|
13690
|
+
return;
|
|
13691
|
+
const size = statSync(path).size;
|
|
13692
|
+
if (size + incomingBytes <= maxBytes)
|
|
13693
|
+
return;
|
|
13694
|
+
for (let index = keep;index >= 1; index--) {
|
|
13695
|
+
const current = `${path}.${index}`;
|
|
13696
|
+
const next = `${path}.${index + 1}`;
|
|
13697
|
+
if (!existsSync(current))
|
|
13698
|
+
continue;
|
|
13699
|
+
if (index === keep) {
|
|
13700
|
+
unlinkSync(current);
|
|
13701
|
+
} else {
|
|
13702
|
+
renameSync(current, next);
|
|
13703
|
+
}
|
|
13704
|
+
}
|
|
13705
|
+
renameSync(path, `${path}.1`);
|
|
13706
|
+
}
|
|
13707
|
+
|
|
13708
|
+
// src/process-log.ts
|
|
13709
|
+
var stderrStates = new WeakMap;
|
|
13710
|
+
function createProcessLogger(options) {
|
|
13711
|
+
let fatalInProgress = false;
|
|
13712
|
+
const stderr = options.stderr ?? process.stderr;
|
|
13713
|
+
const stderrState = stateForStderr(stderr);
|
|
13714
|
+
const write = (message) => {
|
|
13715
|
+
const line = `[${new Date().toISOString()}] [${options.component}] ${message}
|
|
13716
|
+
`;
|
|
13717
|
+
if (options.logFile) {
|
|
13718
|
+
try {
|
|
13719
|
+
appendRotatingLog(options.logFile, line);
|
|
13720
|
+
} catch {}
|
|
13721
|
+
}
|
|
13722
|
+
if (!stderrState.enabled)
|
|
13723
|
+
return;
|
|
13724
|
+
try {
|
|
13725
|
+
stderr.write(line);
|
|
13726
|
+
} catch (error2) {
|
|
13727
|
+
if (error2?.code === "EPIPE")
|
|
13728
|
+
stderrState.enabled = false;
|
|
13729
|
+
}
|
|
13730
|
+
};
|
|
13731
|
+
return {
|
|
13732
|
+
log: write,
|
|
13733
|
+
fatal(label, error2) {
|
|
13734
|
+
if (fatalInProgress)
|
|
13735
|
+
return;
|
|
13736
|
+
fatalInProgress = true;
|
|
13737
|
+
try {
|
|
13738
|
+
write(`${label}: ${safeFormatError(error2)}`);
|
|
13739
|
+
} finally {
|
|
13740
|
+
fatalInProgress = false;
|
|
13741
|
+
}
|
|
13742
|
+
}
|
|
13743
|
+
};
|
|
13744
|
+
}
|
|
13745
|
+
function stateForStderr(stderr) {
|
|
13746
|
+
const key = stderr;
|
|
13747
|
+
let state = stderrStates.get(key);
|
|
13748
|
+
if (state)
|
|
13749
|
+
return state;
|
|
13750
|
+
state = { enabled: true };
|
|
13751
|
+
stderrStates.set(key, state);
|
|
13752
|
+
if (typeof stderr.on === "function") {
|
|
13753
|
+
stderr.on("error", (error2) => {
|
|
13754
|
+
if (error2?.code === "EPIPE") {
|
|
13755
|
+
state.enabled = false;
|
|
13756
|
+
return;
|
|
13757
|
+
}
|
|
13758
|
+
setTimeout(() => {
|
|
13759
|
+
throw error2;
|
|
13760
|
+
}, 0);
|
|
13761
|
+
});
|
|
13762
|
+
}
|
|
13763
|
+
return state;
|
|
13764
|
+
}
|
|
13765
|
+
function safeFormatError(error2) {
|
|
13766
|
+
try {
|
|
13767
|
+
return formatError2(error2);
|
|
13768
|
+
} catch {
|
|
13769
|
+
return "<failed to format error>";
|
|
13770
|
+
}
|
|
13771
|
+
}
|
|
13772
|
+
function formatError2(error2) {
|
|
13773
|
+
if (error2 instanceof Error)
|
|
13774
|
+
return error2.stack ?? error2.message;
|
|
13775
|
+
if (typeof error2 === "object" && error2 !== null && "stack" in error2) {
|
|
13776
|
+
return String(error2.stack);
|
|
13777
|
+
}
|
|
13778
|
+
return String(error2);
|
|
13779
|
+
}
|
|
13666
13780
|
|
|
13667
13781
|
// src/state-dir.ts
|
|
13668
|
-
import { mkdirSync, existsSync } from "fs";
|
|
13782
|
+
import { mkdirSync, existsSync as existsSync2 } from "fs";
|
|
13669
13783
|
import { join } from "path";
|
|
13670
13784
|
import { homedir, platform } from "os";
|
|
13671
13785
|
|
|
13672
13786
|
class StateDirResolver {
|
|
13673
13787
|
stateDir;
|
|
13788
|
+
static platformBaseDir() {
|
|
13789
|
+
if (platform() === "darwin") {
|
|
13790
|
+
return join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
13791
|
+
}
|
|
13792
|
+
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
13793
|
+
return join(xdgState, "agentbridge");
|
|
13794
|
+
}
|
|
13674
13795
|
constructor(envOverride) {
|
|
13675
13796
|
const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
|
|
13676
|
-
|
|
13677
|
-
this.stateDir = override;
|
|
13678
|
-
} else if (platform() === "darwin") {
|
|
13679
|
-
this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
13680
|
-
} else {
|
|
13681
|
-
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
13682
|
-
this.stateDir = join(xdgState, "agentbridge");
|
|
13683
|
-
}
|
|
13797
|
+
this.stateDir = override && override.length > 0 ? override : StateDirResolver.platformBaseDir();
|
|
13684
13798
|
}
|
|
13685
13799
|
ensure() {
|
|
13686
|
-
if (!
|
|
13800
|
+
if (!existsSync2(this.stateDir)) {
|
|
13687
13801
|
mkdirSync(this.stateDir, { recursive: true });
|
|
13688
13802
|
}
|
|
13689
13803
|
}
|
|
@@ -13705,6 +13819,9 @@ class StateDirResolver {
|
|
|
13705
13819
|
get portsFile() {
|
|
13706
13820
|
return join(this.stateDir, "ports.json");
|
|
13707
13821
|
}
|
|
13822
|
+
get currentThreadFile() {
|
|
13823
|
+
return join(this.stateDir, "current-thread.json");
|
|
13824
|
+
}
|
|
13708
13825
|
get logFile() {
|
|
13709
13826
|
return join(this.stateDir, "agentbridge.log");
|
|
13710
13827
|
}
|
|
@@ -13714,16 +13831,98 @@ class StateDirResolver {
|
|
|
13714
13831
|
get killedFile() {
|
|
13715
13832
|
return join(this.stateDir, "killed");
|
|
13716
13833
|
}
|
|
13834
|
+
get updateCheckFile() {
|
|
13835
|
+
return join(this.stateDir, "update-check.json");
|
|
13836
|
+
}
|
|
13717
13837
|
}
|
|
13718
13838
|
|
|
13839
|
+
// src/budget/render.ts
|
|
13840
|
+
function formatEpoch(epochSeconds) {
|
|
13841
|
+
if (!epochSeconds || epochSeconds <= 0)
|
|
13842
|
+
return "\u672A\u77E5";
|
|
13843
|
+
return new Date(epochSeconds * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
|
|
13844
|
+
}
|
|
13845
|
+
function formatWindow(window, label) {
|
|
13846
|
+
if (!window)
|
|
13847
|
+
return `${label} \u672A\u77E5`;
|
|
13848
|
+
return `${label} ${window.util}%\uFF08\u91CD\u7F6E ${formatEpoch(window.resetEpoch)}\uFF09`;
|
|
13849
|
+
}
|
|
13850
|
+
function formatAgent(name, usage, snapshotAt) {
|
|
13851
|
+
if (!usage)
|
|
13852
|
+
return `${name}\uFF1A\u672A\u77E5\uFF08\u63A2\u6D4B\u4E0D\u53EF\u7528\uFF09`;
|
|
13853
|
+
const parts = [
|
|
13854
|
+
formatWindow(usage.fiveHour, "5h"),
|
|
13855
|
+
formatWindow(usage.weekly, "\u5468"),
|
|
13856
|
+
`\u95E8\u63A7 ${usage.gateUtil}%`,
|
|
13857
|
+
`\u9884\u8B66 ${usage.warnUtil}%`
|
|
13858
|
+
];
|
|
13859
|
+
if (usage.rateLimitedUntil > 0) {
|
|
13860
|
+
parts.push(`\u9650\u6D41\u81F3 ${formatEpoch(usage.rateLimitedUntil)}`);
|
|
13861
|
+
}
|
|
13862
|
+
const ageSec = usage.fetchedAt > 0 ? snapshotAt - usage.fetchedAt : 0;
|
|
13863
|
+
if (ageSec > 300) {
|
|
13864
|
+
parts.push(`\u26A0\uFE0F \u6570\u636E\u91C7\u96C6\u4E8E ${Math.round(ageSec / 60)} \u5206\u949F\u524D`);
|
|
13865
|
+
} else if (usage.stale) {
|
|
13866
|
+
parts.push("\uFF08\u7F13\u5B58\u6570\u636E\uFF09");
|
|
13867
|
+
}
|
|
13868
|
+
return `${name}\uFF1A${parts.join(" \xB7 ")}`;
|
|
13869
|
+
}
|
|
13870
|
+
var PHASE_LABELS = {
|
|
13871
|
+
normal: "normal\uFF08\u6B63\u5E38\uFF09",
|
|
13872
|
+
balance: "balance\uFF08\u9700\u5747\u8861\uFF09",
|
|
13873
|
+
parallel: "parallel\uFF08\u5EFA\u8BAE\u5E76\u884C\u63D0\u901F\uFF09",
|
|
13874
|
+
paused: "paused\uFF08\u9884\u7B97\u5E72\u9884\u4E2D\uFF09"
|
|
13875
|
+
};
|
|
13876
|
+
function renderBudgetSnapshot(snapshot) {
|
|
13877
|
+
const lines = [];
|
|
13878
|
+
lines.push(`\u3010\u9884\u7B97\u5FEB\u7167 \xB7 \u8D26\u53F7\u7EA7\u3011\u9636\u6BB5\uFF1A${PHASE_LABELS[snapshot.phase]} \xB7 \u66F4\u65B0\u4E8E ${formatEpoch(snapshot.updatedAt)}`);
|
|
13879
|
+
lines.push(formatAgent("Claude", snapshot.claude, snapshot.updatedAt));
|
|
13880
|
+
lines.push(formatAgent("Codex", snapshot.codex, snapshot.updatedAt));
|
|
13881
|
+
if (snapshot.claude && snapshot.codex) {
|
|
13882
|
+
const abs = Math.abs(snapshot.driftPct);
|
|
13883
|
+
if (abs > 0) {
|
|
13884
|
+
const heavier = snapshot.driftPct > 0 ? "Claude" : "Codex";
|
|
13885
|
+
const lighter = snapshot.driftPct > 0 ? "Codex" : "Claude";
|
|
13886
|
+
lines.push(`\u6F02\u79FB\uFF1A${heavier} \u6BD4 ${lighter} \u9AD8 ${abs} \u4E2A\u767E\u5206\u70B9`);
|
|
13887
|
+
} else {
|
|
13888
|
+
lines.push("\u6F02\u79FB\uFF1A\u53CC\u65B9\u6301\u5E73");
|
|
13889
|
+
}
|
|
13890
|
+
}
|
|
13891
|
+
if (snapshot.paused) {
|
|
13892
|
+
const resume = snapshot.resumeAfterEpoch ? `\uFF1B\u9884\u8BA1\u6062\u590D ${formatEpoch(snapshot.resumeAfterEpoch)}\uFF08\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09` : "";
|
|
13893
|
+
const reason = snapshot.pauseReason ?? "\u989D\u5EA6\u63A5\u8FD1\u8017\u5C3D";
|
|
13894
|
+
if (snapshot.pauseSide === "claude" && !snapshot.gateClosed) {
|
|
13895
|
+
lines.push(`\u63A5\u529B\u4E2D\uFF1AClaude \u4FA7\u989D\u5EA6\u8017\u5C3D\uFF0C\u5DF2\u4EA4\u63A5 Codex \u7EE7\u7EED\u63A8\u8FDB\uFF08\u95F8\u95E8\u5F00\u653E\uFF09 \u2014 ${reason}${resume}`);
|
|
13896
|
+
} else if (snapshot.pauseSide === "codex") {
|
|
13897
|
+
lines.push(`\u6682\u505C\uFF1ACodex \u4FA7\u989D\u5EA6\u8017\u5C3D\uFF08\u95F8\u95E8\u5173\u95ED\uFF0CClaude \u53EF solo \u63A8\u8FDB\u72EC\u7ACB\u90E8\u5206\uFF09 \u2014 ${reason}${resume}`);
|
|
13898
|
+
} else {
|
|
13899
|
+
lines.push(`\u6682\u505C\uFF1A\u53CC\u4FA7\u8054\u5408\u6682\u505C\uFF08\u95F8\u95E8\u5173\u95ED\uFF09 \u2014 ${reason}${resume}`);
|
|
13900
|
+
}
|
|
13901
|
+
} else {
|
|
13902
|
+
lines.push("\u6682\u505C\uFF1A\u5426");
|
|
13903
|
+
}
|
|
13904
|
+
if (snapshot.parallelRecommended) {
|
|
13905
|
+
lines.push("\u5E76\u884C\u5EFA\u8BAE\uFF1A\u989D\u5EA6\u5BCC\u4F59\u4E14\u4E34\u8FD1\u7ED3\u7B97\uFF0C\u5EFA\u8BAE\u62C6\u5206\u66F4\u591A\u5E76\u884C\u5B50\u4EFB\u52A1");
|
|
13906
|
+
}
|
|
13907
|
+
if (snapshot.codexTier !== "full") {
|
|
13908
|
+
lines.push(`Codex \u6863\u4F4D\uFF1A${snapshot.codexTier}`);
|
|
13909
|
+
}
|
|
13910
|
+
if (snapshot.claudeAdvice) {
|
|
13911
|
+
lines.push(`Claude \u5EFA\u8BAE\uFF1A${snapshot.claudeAdvice}`);
|
|
13912
|
+
}
|
|
13913
|
+
lines.push("\u6CE8\uFF1A\u767E\u5206\u6BD4\u4E3A\u8BA2\u9605\u8D26\u53F7\u7EA7\u7528\u91CF\uFF08\u540C\u673A\u5176\u4ED6\u4F1A\u8BDD\u5171\u4EAB\u540C\u4E00\u989D\u5EA6\u6C60\uFF09\u3002");
|
|
13914
|
+
return lines.join(`
|
|
13915
|
+
`);
|
|
13916
|
+
}
|
|
13917
|
+
var BUDGET_UNAVAILABLE_TEXT = "\u9884\u7B97\u611F\u77E5\u4E0D\u53EF\u7528\uFF1A\u672A\u68C0\u6D4B\u5230 agent-quota-guard \u63A2\u9488\uFF08~/.budget-guard/bin/budget-probe\uFF09\u6216 budget \u529F\u80FD\u5DF2\u7981\u7528\u3002\u534F\u4F5C\u4E0D\u53D7\u5F71\u54CD\u3002";
|
|
13918
|
+
|
|
13719
13919
|
// src/claude-adapter.ts
|
|
13720
13920
|
var CLAUDE_INSTRUCTIONS = [
|
|
13721
13921
|
"Codex is an AI coding agent (OpenAI) running in a separate session on the same machine.",
|
|
13722
13922
|
"",
|
|
13723
13923
|
"## Message delivery",
|
|
13724
|
-
|
|
13725
|
-
|
|
13726
|
-
"- Via the get_messages tool (pull mode)",
|
|
13924
|
+
'Messages from Codex arrive as <channel source="agentbridge" chat_id="..." user="Codex" ...> tags (push).',
|
|
13925
|
+
"If a push fails, the message is queued \u2014 call get_messages to drain the fallback queue.",
|
|
13727
13926
|
"",
|
|
13728
13927
|
"## Collaboration roles",
|
|
13729
13928
|
"Default roles in this setup:",
|
|
@@ -13748,7 +13947,11 @@ var CLAUDE_INSTRUCTIONS = [
|
|
|
13748
13947
|
"## Turn coordination",
|
|
13749
13948
|
"- When you see '\u23F3 Codex is working', do NOT call the reply tool \u2014 wait for '\u2705 Codex finished'.",
|
|
13750
13949
|
"- After Codex finishes a turn, you have an attention window to review and respond before new messages arrive.",
|
|
13751
|
-
|
|
13950
|
+
'- If the reply tool returns a busy error, Codex is still executing. You decide: wait and retry later, or resend with on_busy="steer" to feed the message INTO the running turn (good for mid-course corrections; it does not interrupt or restart the work).',
|
|
13951
|
+
"",
|
|
13952
|
+
"## Budget awareness",
|
|
13953
|
+
"- Use the get_budget tool to check both agents' subscription quota (5h/weekly windows, drift, pause state).",
|
|
13954
|
+
"- If the reply tool returns a budget-pause error, do NOT retry; checkpoint your work and wait for the resume notice."
|
|
13752
13955
|
].join(`
|
|
13753
13956
|
`);
|
|
13754
13957
|
|
|
@@ -13760,20 +13963,22 @@ class ClaudeAdapter extends EventEmitter {
|
|
|
13760
13963
|
instanceId;
|
|
13761
13964
|
replySender = null;
|
|
13762
13965
|
logFile;
|
|
13763
|
-
|
|
13764
|
-
resolvedMode = null;
|
|
13966
|
+
logger;
|
|
13765
13967
|
pendingMessages = [];
|
|
13766
13968
|
maxBufferedMessages;
|
|
13767
13969
|
droppedMessageCount = 0;
|
|
13970
|
+
budgetSnapshot = null;
|
|
13768
13971
|
constructor(logFile = new StateDirResolver().logFile) {
|
|
13769
13972
|
super();
|
|
13770
13973
|
this.logFile = logFile;
|
|
13974
|
+
this.logger = createProcessLogger({ component: "ClaudeAdapter", logFile: this.logFile });
|
|
13771
13975
|
this.instanceId = randomUUID().slice(0, 8);
|
|
13772
13976
|
this.sessionId = `codex_${Date.now()}`;
|
|
13773
13977
|
this.notificationIdPrefix = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
13774
13978
|
this.log(`ClaudeAdapter created (instance=${this.instanceId})`);
|
|
13775
|
-
|
|
13776
|
-
|
|
13979
|
+
if (process.env.AGENTBRIDGE_MODE) {
|
|
13980
|
+
this.log(`AGENTBRIDGE_MODE="${process.env.AGENTBRIDGE_MODE}" is no longer supported \u2014 ` + "pull mode was removed; push delivery (with per-message fallback queue) is always used.");
|
|
13981
|
+
}
|
|
13777
13982
|
this.maxBufferedMessages = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
|
|
13778
13983
|
this.server = new Server({ name: "agentbridge", version: "0.1.0" }, {
|
|
13779
13984
|
capabilities: {
|
|
@@ -13786,38 +13991,22 @@ class ClaudeAdapter extends EventEmitter {
|
|
|
13786
13991
|
}
|
|
13787
13992
|
async start() {
|
|
13788
13993
|
const transport = new StdioServerTransport;
|
|
13789
|
-
this.resolveMode();
|
|
13790
13994
|
await this.server.connect(transport);
|
|
13791
|
-
this.log(
|
|
13995
|
+
this.log("MCP server connected (push delivery)");
|
|
13792
13996
|
this.emit("ready");
|
|
13793
13997
|
}
|
|
13794
13998
|
setReplySender(sender) {
|
|
13795
13999
|
this.replySender = sender;
|
|
13796
14000
|
}
|
|
13797
|
-
getDeliveryMode() {
|
|
13798
|
-
return this.resolvedMode ?? "pull";
|
|
13799
|
-
}
|
|
13800
14001
|
getPendingMessageCount() {
|
|
13801
14002
|
return this.pendingMessages.length;
|
|
13802
14003
|
}
|
|
13803
|
-
|
|
13804
|
-
|
|
13805
|
-
return;
|
|
13806
|
-
if (this.configuredMode === "push" || this.configuredMode === "pull") {
|
|
13807
|
-
this.resolvedMode = this.configuredMode;
|
|
13808
|
-
this.log(`Delivery mode set by AGENTBRIDGE_MODE: ${this.resolvedMode}`);
|
|
13809
|
-
} else {
|
|
13810
|
-
this.resolvedMode = "push";
|
|
13811
|
-
this.log("Delivery mode defaulting to push (set AGENTBRIDGE_MODE=pull to use polling instead)");
|
|
13812
|
-
}
|
|
14004
|
+
setBudgetSnapshot(snapshot) {
|
|
14005
|
+
this.budgetSnapshot = snapshot;
|
|
13813
14006
|
}
|
|
13814
14007
|
async pushNotification(message) {
|
|
13815
|
-
this.log(`pushNotification (instance=${this.instanceId},
|
|
13816
|
-
|
|
13817
|
-
await this.pushViaChannel(message);
|
|
13818
|
-
} else {
|
|
13819
|
-
this.queueForPull(message);
|
|
13820
|
-
}
|
|
14008
|
+
this.log(`pushNotification (instance=${this.instanceId}, msgId=${message.id}, len=${message.content.length})`);
|
|
14009
|
+
await this.pushViaChannel(message);
|
|
13821
14010
|
}
|
|
13822
14011
|
async pushViaChannel(message) {
|
|
13823
14012
|
const msgId = `codex_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`;
|
|
@@ -13840,17 +14029,17 @@ class ClaudeAdapter extends EventEmitter {
|
|
|
13840
14029
|
this.log(`Pushed notification: ${msgId}`);
|
|
13841
14030
|
} catch (e) {
|
|
13842
14031
|
this.log(`Push notification failed: ${e.message}`);
|
|
13843
|
-
this.
|
|
14032
|
+
this.queueFallbackMessage(message);
|
|
13844
14033
|
}
|
|
13845
14034
|
}
|
|
13846
|
-
|
|
14035
|
+
queueFallbackMessage(message) {
|
|
13847
14036
|
if (this.pendingMessages.length >= this.maxBufferedMessages) {
|
|
13848
14037
|
this.pendingMessages.shift();
|
|
13849
14038
|
this.droppedMessageCount++;
|
|
13850
|
-
this.log(`
|
|
14039
|
+
this.log(`Fallback queue full, dropped oldest message (total dropped: ${this.droppedMessageCount})`);
|
|
13851
14040
|
}
|
|
13852
14041
|
this.pendingMessages.push(message);
|
|
13853
|
-
this.log(`Queued message
|
|
14042
|
+
this.log(`Queued fallback message (${this.pendingMessages.length} pending, instance=${this.instanceId})`);
|
|
13854
14043
|
}
|
|
13855
14044
|
drainMessages() {
|
|
13856
14045
|
this.log(`get_messages called (instance=${this.instanceId}, pending=${this.pendingMessages.length}, dropped=${this.droppedMessageCount})`);
|
|
@@ -13910,6 +14099,11 @@ ${formatted}`
|
|
|
13910
14099
|
require_reply: {
|
|
13911
14100
|
type: "boolean",
|
|
13912
14101
|
description: "When true, Codex is required to send a reply. All Codex messages from this turn will be forwarded immediately (bypassing STATUS buffering). Use this when you need a direct answer from Codex."
|
|
14102
|
+
},
|
|
14103
|
+
on_busy: {
|
|
14104
|
+
type: "string",
|
|
14105
|
+
enum: ["reject", "steer"],
|
|
14106
|
+
description: `What to do when Codex is mid-turn. "reject" (default): fail with a busy error \u2014 wait and retry. "steer": feed this message INTO the running turn \u2014 Codex sees it immediately and integrates it without losing work. Use steer for mid-course corrections, added constraints, or updated acceptance criteria; it does NOT start a new turn, so don't combine it with require_reply. If you need Codex to STOP and do something else, wait for the turn to finish (interrupt support is coming separately).`
|
|
13913
14107
|
}
|
|
13914
14108
|
},
|
|
13915
14109
|
required: ["text"]
|
|
@@ -13923,6 +14117,15 @@ ${formatted}`
|
|
|
13923
14117
|
properties: {},
|
|
13924
14118
|
required: []
|
|
13925
14119
|
}
|
|
14120
|
+
},
|
|
14121
|
+
{
|
|
14122
|
+
name: "get_budget",
|
|
14123
|
+
description: "Check both agents' subscription quota usage (Claude + Codex): 5h/weekly window percentages, drift between the two sides, joint-pause state and model/effort tier recommendation.",
|
|
14124
|
+
inputSchema: {
|
|
14125
|
+
type: "object",
|
|
14126
|
+
properties: {},
|
|
14127
|
+
required: []
|
|
14128
|
+
}
|
|
13926
14129
|
}
|
|
13927
14130
|
]
|
|
13928
14131
|
}));
|
|
@@ -13934,12 +14137,22 @@ ${formatted}`
|
|
|
13934
14137
|
if (name === "get_messages") {
|
|
13935
14138
|
return this.drainMessages();
|
|
13936
14139
|
}
|
|
14140
|
+
if (name === "get_budget") {
|
|
14141
|
+
return this.handleGetBudget();
|
|
14142
|
+
}
|
|
13937
14143
|
return {
|
|
13938
14144
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
13939
14145
|
isError: true
|
|
13940
14146
|
};
|
|
13941
14147
|
});
|
|
13942
14148
|
}
|
|
14149
|
+
handleGetBudget() {
|
|
14150
|
+
this.log(`get_budget called (instance=${this.instanceId}, hasSnapshot=${this.budgetSnapshot !== null})`);
|
|
14151
|
+
const text = this.budgetSnapshot ? renderBudgetSnapshot(this.budgetSnapshot) : BUDGET_UNAVAILABLE_TEXT;
|
|
14152
|
+
return {
|
|
14153
|
+
content: [{ type: "text", text }]
|
|
14154
|
+
};
|
|
14155
|
+
}
|
|
13943
14156
|
async handleReply(args) {
|
|
13944
14157
|
const text = args?.text;
|
|
13945
14158
|
if (!text) {
|
|
@@ -13949,6 +14162,20 @@ ${formatted}`
|
|
|
13949
14162
|
};
|
|
13950
14163
|
}
|
|
13951
14164
|
const requireReply = args?.require_reply === true;
|
|
14165
|
+
const onBusyRaw = args?.on_busy;
|
|
14166
|
+
const onBusy = onBusyRaw === "steer" ? "steer" : "reject";
|
|
14167
|
+
if (onBusyRaw !== undefined && onBusyRaw !== "reject" && onBusyRaw !== "steer") {
|
|
14168
|
+
return {
|
|
14169
|
+
content: [{ type: "text", text: `Error: invalid on_busy value ${JSON.stringify(onBusyRaw)} \u2014 use "reject" or "steer".` }],
|
|
14170
|
+
isError: true
|
|
14171
|
+
};
|
|
14172
|
+
}
|
|
14173
|
+
if (onBusy === "steer" && requireReply) {
|
|
14174
|
+
return {
|
|
14175
|
+
content: [{ type: "text", text: 'Error: require_reply cannot be combined with on_busy="steer" yet \u2014 a steer joins the RUNNING turn instead of starting a new one, so reply tracking would mis-arm. Send the steer without require_reply.' }],
|
|
14176
|
+
isError: true
|
|
14177
|
+
};
|
|
14178
|
+
}
|
|
13952
14179
|
const bridgeMsg = {
|
|
13953
14180
|
id: args?.chat_id ?? `reply_${Date.now()}`,
|
|
13954
14181
|
source: "claude",
|
|
@@ -13962,7 +14189,7 @@ ${formatted}`
|
|
|
13962
14189
|
isError: true
|
|
13963
14190
|
};
|
|
13964
14191
|
}
|
|
13965
|
-
const result = await this.replySender(bridgeMsg, requireReply);
|
|
14192
|
+
const result = await this.replySender(bridgeMsg, requireReply, onBusy);
|
|
13966
14193
|
if (!result.success) {
|
|
13967
14194
|
this.log(`Reply delivery failed: ${result.error}`);
|
|
13968
14195
|
return {
|
|
@@ -13971,7 +14198,7 @@ ${formatted}`
|
|
|
13971
14198
|
};
|
|
13972
14199
|
}
|
|
13973
14200
|
const pending = this.pendingMessages.length;
|
|
13974
|
-
let responseText = "Reply sent to Codex.";
|
|
14201
|
+
let responseText = onBusy === "steer" ? "Reply sent to Codex (will be steered into the running turn if one is active; watch for a system_steer_failed notice if the app-server rejects it)." : "Reply sent to Codex.";
|
|
13975
14202
|
if (pending > 0) {
|
|
13976
14203
|
responseText += ` Note: ${pending} unread Codex message${pending > 1 ? "s" : ""} already waiting \u2014 call get_messages to read them.`;
|
|
13977
14204
|
}
|
|
@@ -13980,33 +14207,70 @@ ${formatted}`
|
|
|
13980
14207
|
};
|
|
13981
14208
|
}
|
|
13982
14209
|
log(msg) {
|
|
13983
|
-
|
|
13984
|
-
`;
|
|
13985
|
-
process.stderr.write(line);
|
|
13986
|
-
try {
|
|
13987
|
-
appendFileSync(this.logFile, line);
|
|
13988
|
-
} catch {}
|
|
14210
|
+
this.logger.log(msg);
|
|
13989
14211
|
}
|
|
13990
14212
|
}
|
|
13991
14213
|
|
|
14214
|
+
// src/contract-version.ts
|
|
14215
|
+
var CONTRACT_VERSION = 1;
|
|
14216
|
+
|
|
14217
|
+
// src/build-info.ts
|
|
14218
|
+
function defineString(value, fallback) {
|
|
14219
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
14220
|
+
}
|
|
14221
|
+
function defineBundle(value) {
|
|
14222
|
+
if (value === "source" || value === "dist" || value === "plugin")
|
|
14223
|
+
return value;
|
|
14224
|
+
return import.meta.url.endsWith(".ts") ? "source" : "dist";
|
|
14225
|
+
}
|
|
14226
|
+
function defineNumber(value, fallback) {
|
|
14227
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
14228
|
+
}
|
|
14229
|
+
var BUILD_INFO = Object.freeze({
|
|
14230
|
+
version: defineString("0.1.8", "0.0.0-source"),
|
|
14231
|
+
commit: defineString("c80a7fd", "source"),
|
|
14232
|
+
bundle: defineBundle("plugin"),
|
|
14233
|
+
contractVersion: defineNumber(1, CONTRACT_VERSION)
|
|
14234
|
+
});
|
|
14235
|
+
function sameRuntimeContract(a, b) {
|
|
14236
|
+
if (!a || !b)
|
|
14237
|
+
return false;
|
|
14238
|
+
return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
|
|
14239
|
+
}
|
|
14240
|
+
function compatibleContractVersion(a, b) {
|
|
14241
|
+
if (!a || !b)
|
|
14242
|
+
return false;
|
|
14243
|
+
return a.contractVersion === b.contractVersion;
|
|
14244
|
+
}
|
|
14245
|
+
function formatBuildInfo(build) {
|
|
14246
|
+
if (!build)
|
|
14247
|
+
return "<unknown>";
|
|
14248
|
+
return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
|
|
14249
|
+
}
|
|
14250
|
+
|
|
13992
14251
|
// src/daemon-client.ts
|
|
13993
14252
|
import { EventEmitter as EventEmitter2 } from "events";
|
|
13994
14253
|
|
|
13995
14254
|
// src/control-protocol.ts
|
|
13996
14255
|
var CLOSE_CODE_REPLACED = 4001;
|
|
14256
|
+
var CLOSE_CODE_EVICTED_STALE = 4002;
|
|
14257
|
+
var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
|
|
14258
|
+
var CLOSE_CODE_PAIR_MISMATCH = 4004;
|
|
13997
14259
|
|
|
13998
14260
|
// src/daemon-client.ts
|
|
13999
14261
|
var nextSocketId = 0;
|
|
14000
14262
|
|
|
14001
14263
|
class DaemonClient extends EventEmitter2 {
|
|
14002
14264
|
url;
|
|
14265
|
+
options;
|
|
14003
14266
|
ws = null;
|
|
14004
14267
|
wsId = 0;
|
|
14005
14268
|
nextRequestId = 1;
|
|
14006
14269
|
pendingReplies = new Map;
|
|
14007
|
-
constructor(url) {
|
|
14270
|
+
constructor(url, options = {}) {
|
|
14008
14271
|
super();
|
|
14009
14272
|
this.url = url;
|
|
14273
|
+
this.options = options;
|
|
14010
14274
|
}
|
|
14011
14275
|
async connect() {
|
|
14012
14276
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
@@ -14048,7 +14312,81 @@ class DaemonClient extends EventEmitter2 {
|
|
|
14048
14312
|
});
|
|
14049
14313
|
}
|
|
14050
14314
|
attachClaude() {
|
|
14051
|
-
this.send({
|
|
14315
|
+
this.send({
|
|
14316
|
+
type: "claude_connect",
|
|
14317
|
+
...this.options.identity ? { identity: this.options.identity } : {}
|
|
14318
|
+
});
|
|
14319
|
+
}
|
|
14320
|
+
async attachClaudeAndWaitForStatus(timeoutMs = 1000) {
|
|
14321
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
14322
|
+
return null;
|
|
14323
|
+
}
|
|
14324
|
+
return await new Promise((resolve) => {
|
|
14325
|
+
let settled = false;
|
|
14326
|
+
let timer = null;
|
|
14327
|
+
const cleanup = () => {
|
|
14328
|
+
if (settled)
|
|
14329
|
+
return;
|
|
14330
|
+
settled = true;
|
|
14331
|
+
if (timer) {
|
|
14332
|
+
clearTimeout(timer);
|
|
14333
|
+
timer = null;
|
|
14334
|
+
}
|
|
14335
|
+
this.off("status", onStatus);
|
|
14336
|
+
this.off("rejected", onRejected);
|
|
14337
|
+
this.off("disconnect", onDisconnect);
|
|
14338
|
+
};
|
|
14339
|
+
const finish = (value) => {
|
|
14340
|
+
cleanup();
|
|
14341
|
+
resolve(value);
|
|
14342
|
+
};
|
|
14343
|
+
const onStatus = (status) => finish(status);
|
|
14344
|
+
const onRejected = () => finish(null);
|
|
14345
|
+
const onDisconnect = () => finish(null);
|
|
14346
|
+
this.on("status", onStatus);
|
|
14347
|
+
this.on("rejected", onRejected);
|
|
14348
|
+
this.on("disconnect", onDisconnect);
|
|
14349
|
+
timer = setTimeout(() => {
|
|
14350
|
+
finish(null);
|
|
14351
|
+
}, timeoutMs);
|
|
14352
|
+
try {
|
|
14353
|
+
this.attachClaude();
|
|
14354
|
+
} catch {
|
|
14355
|
+
finish(null);
|
|
14356
|
+
}
|
|
14357
|
+
});
|
|
14358
|
+
}
|
|
14359
|
+
async probeIncumbent(timeoutMs = 3000) {
|
|
14360
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
14361
|
+
return { connected: false, alive: false };
|
|
14362
|
+
}
|
|
14363
|
+
return await new Promise((resolve) => {
|
|
14364
|
+
let settled = false;
|
|
14365
|
+
let timer = null;
|
|
14366
|
+
const finish = (value) => {
|
|
14367
|
+
if (settled)
|
|
14368
|
+
return;
|
|
14369
|
+
settled = true;
|
|
14370
|
+
if (timer)
|
|
14371
|
+
clearTimeout(timer);
|
|
14372
|
+
this.off("incumbentStatus", onStatus);
|
|
14373
|
+
this.off("disconnect", onDisconnect);
|
|
14374
|
+
this.off("rejected", onRejected);
|
|
14375
|
+
resolve(value);
|
|
14376
|
+
};
|
|
14377
|
+
const onStatus = (s) => finish(s);
|
|
14378
|
+
const onDisconnect = () => finish({ connected: false, alive: false });
|
|
14379
|
+
const onRejected = () => finish({ connected: false, alive: false });
|
|
14380
|
+
this.on("incumbentStatus", onStatus);
|
|
14381
|
+
this.on("disconnect", onDisconnect);
|
|
14382
|
+
this.on("rejected", onRejected);
|
|
14383
|
+
timer = setTimeout(() => finish({ connected: false, alive: false }), timeoutMs);
|
|
14384
|
+
try {
|
|
14385
|
+
this.send({ type: "probe_incumbent" });
|
|
14386
|
+
} catch {
|
|
14387
|
+
finish({ connected: false, alive: false });
|
|
14388
|
+
}
|
|
14389
|
+
});
|
|
14052
14390
|
}
|
|
14053
14391
|
async disconnect() {
|
|
14054
14392
|
if (!this.ws)
|
|
@@ -14062,7 +14400,7 @@ class DaemonClient extends EventEmitter2 {
|
|
|
14062
14400
|
this.ws = null;
|
|
14063
14401
|
this.rejectPendingReplies("Daemon connection closed");
|
|
14064
14402
|
}
|
|
14065
|
-
async sendReply(message, requireReply) {
|
|
14403
|
+
async sendReply(message, requireReply, onBusy) {
|
|
14066
14404
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
14067
14405
|
return { success: false, error: "AgentBridge daemon is not connected." };
|
|
14068
14406
|
}
|
|
@@ -14077,7 +14415,8 @@ class DaemonClient extends EventEmitter2 {
|
|
|
14077
14415
|
type: "claude_to_codex",
|
|
14078
14416
|
requestId,
|
|
14079
14417
|
message,
|
|
14080
|
-
...requireReply ? { requireReply: true } : {}
|
|
14418
|
+
...requireReply ? { requireReply: true } : {},
|
|
14419
|
+
...onBusy && onBusy !== "reject" ? { onBusy } : {}
|
|
14081
14420
|
});
|
|
14082
14421
|
});
|
|
14083
14422
|
}
|
|
@@ -14106,6 +14445,9 @@ class DaemonClient extends EventEmitter2 {
|
|
|
14106
14445
|
case "status":
|
|
14107
14446
|
this.emit("status", message.status);
|
|
14108
14447
|
return;
|
|
14448
|
+
case "incumbent_status":
|
|
14449
|
+
this.emit("incumbentStatus", { connected: message.connected, alive: message.alive });
|
|
14450
|
+
return;
|
|
14109
14451
|
}
|
|
14110
14452
|
};
|
|
14111
14453
|
ws.onclose = (event) => {
|
|
@@ -14114,8 +14456,8 @@ class DaemonClient extends EventEmitter2 {
|
|
|
14114
14456
|
if (isCurrent) {
|
|
14115
14457
|
this.ws = null;
|
|
14116
14458
|
this.rejectPendingReplies("AgentBridge daemon disconnected.");
|
|
14117
|
-
if (event.code === CLOSE_CODE_REPLACED) {
|
|
14118
|
-
this.emit("rejected");
|
|
14459
|
+
if (event.code === CLOSE_CODE_REPLACED || event.code === CLOSE_CODE_EVICTED_STALE || event.code === CLOSE_CODE_PROBE_IN_PROGRESS || event.code === CLOSE_CODE_PAIR_MISMATCH) {
|
|
14460
|
+
this.emit("rejected", event.code);
|
|
14119
14461
|
} else {
|
|
14120
14462
|
this.emit("disconnect");
|
|
14121
14463
|
}
|
|
@@ -14144,10 +14486,30 @@ class DaemonClient extends EventEmitter2 {
|
|
|
14144
14486
|
|
|
14145
14487
|
// src/daemon-lifecycle.ts
|
|
14146
14488
|
import { spawn, execFileSync } from "child_process";
|
|
14147
|
-
import { existsSync as
|
|
14489
|
+
import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
14148
14490
|
import { fileURLToPath } from "url";
|
|
14149
|
-
|
|
14491
|
+
|
|
14492
|
+
// src/env-utils.ts
|
|
14493
|
+
function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
|
|
14494
|
+
const raw = env[name];
|
|
14495
|
+
if (raw == null || raw === "")
|
|
14496
|
+
return fallback;
|
|
14497
|
+
const parsed = Number(raw);
|
|
14498
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
14499
|
+
log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
|
|
14500
|
+
return fallback;
|
|
14501
|
+
}
|
|
14502
|
+
return parsed;
|
|
14503
|
+
}
|
|
14504
|
+
|
|
14505
|
+
// src/daemon-lifecycle.ts
|
|
14506
|
+
var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
|
|
14507
|
+
var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
|
|
14150
14508
|
var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
|
|
14509
|
+
var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
|
|
14510
|
+
var REUSE_READY_DELAY_MS = 250;
|
|
14511
|
+
var HEALTH_FETCH_TIMEOUT_MS = 500;
|
|
14512
|
+
var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
|
|
14151
14513
|
|
|
14152
14514
|
class DaemonLifecycle {
|
|
14153
14515
|
stateDir;
|
|
@@ -14167,42 +14529,120 @@ class DaemonLifecycle {
|
|
|
14167
14529
|
get controlWsUrl() {
|
|
14168
14530
|
return `ws://127.0.0.1:${this.controlPort}/ws`;
|
|
14169
14531
|
}
|
|
14532
|
+
get expectedPairId() {
|
|
14533
|
+
return process.env.AGENTBRIDGE_PAIR_ID || null;
|
|
14534
|
+
}
|
|
14535
|
+
async fetchStatus() {
|
|
14536
|
+
try {
|
|
14537
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
14538
|
+
if (!response.ok)
|
|
14539
|
+
return null;
|
|
14540
|
+
return await response.json();
|
|
14541
|
+
} catch {
|
|
14542
|
+
return null;
|
|
14543
|
+
}
|
|
14544
|
+
}
|
|
14545
|
+
isForeignDaemon(status) {
|
|
14546
|
+
const expected = this.expectedPairId;
|
|
14547
|
+
if (!expected)
|
|
14548
|
+
return false;
|
|
14549
|
+
if (!status)
|
|
14550
|
+
return false;
|
|
14551
|
+
const reported = status.pairId;
|
|
14552
|
+
if (reported == null)
|
|
14553
|
+
return true;
|
|
14554
|
+
return reported !== expected;
|
|
14555
|
+
}
|
|
14556
|
+
isRegisteredPairDaemonInManualMode(status) {
|
|
14557
|
+
return !this.expectedPairId && status?.pairId != null;
|
|
14558
|
+
}
|
|
14559
|
+
isBuildDrifted(status) {
|
|
14560
|
+
if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
|
|
14561
|
+
return false;
|
|
14562
|
+
const runtime = status?.build;
|
|
14563
|
+
if (!runtime)
|
|
14564
|
+
return true;
|
|
14565
|
+
return !sameRuntimeContract(runtime, BUILD_INFO);
|
|
14566
|
+
}
|
|
14567
|
+
canReuseDespiteDrift(status) {
|
|
14568
|
+
if (!compatibleContractVersion(status?.build, BUILD_INFO))
|
|
14569
|
+
return false;
|
|
14570
|
+
return status?.tuiConnected === true;
|
|
14571
|
+
}
|
|
14170
14572
|
async ensureRunning() {
|
|
14171
14573
|
if (await this.isHealthy()) {
|
|
14172
|
-
await this.
|
|
14173
|
-
|
|
14574
|
+
const status = await this.fetchStatus();
|
|
14575
|
+
if (this.isRegisteredPairDaemonInManualMode(status)) {
|
|
14576
|
+
throw new Error(`Control port ${this.controlPort} is owned by registered pair ${status?.pairId}. ` + `This session has no pair identity (manual mode) and will not reuse or replace it \u2014 ` + `start with \`agentbridge claude\` from that pair's directory, or set AGENTBRIDGE_CONTROL_PORT to a free port.`);
|
|
14577
|
+
}
|
|
14578
|
+
if (this.isForeignDaemon(status)) {
|
|
14579
|
+
this.log(`Control port ${this.controlPort} held by a daemon for pair ${status?.pairId ?? "<none>"}, ` + `but this pair is ${this.expectedPairId} \u2014 replacing foreign daemon`);
|
|
14580
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
14581
|
+
return;
|
|
14582
|
+
}
|
|
14583
|
+
if (this.isBuildDrifted(status)) {
|
|
14584
|
+
if (this.canReuseDespiteDrift(status)) {
|
|
14585
|
+
this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `(launcher ${formatBuildInfo(BUILD_INFO)}) but a live Codex TUI is attached \u2014 reusing instead of ` + `replacing; the new build is picked up at the next restart (abg kill, then relaunch)`);
|
|
14586
|
+
} else {
|
|
14587
|
+
this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
|
|
14588
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
14589
|
+
return;
|
|
14590
|
+
}
|
|
14591
|
+
}
|
|
14592
|
+
try {
|
|
14593
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
14594
|
+
return;
|
|
14595
|
+
} catch {
|
|
14596
|
+
this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
|
|
14597
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
14598
|
+
return;
|
|
14599
|
+
}
|
|
14174
14600
|
}
|
|
14175
14601
|
const existingPid = this.readPid();
|
|
14176
14602
|
if (existingPid) {
|
|
14177
14603
|
if (isProcessAlive(existingPid)) {
|
|
14178
14604
|
if (this.isDaemonProcess(existingPid)) {
|
|
14179
14605
|
try {
|
|
14180
|
-
await this.waitForReady(
|
|
14606
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
14181
14607
|
return;
|
|
14182
14608
|
} catch {
|
|
14183
|
-
|
|
14609
|
+
this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
|
|
14610
|
+
await this.replaceUnhealthyDaemon(existingPid);
|
|
14611
|
+
return;
|
|
14184
14612
|
}
|
|
14185
14613
|
}
|
|
14186
14614
|
this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
|
|
14187
14615
|
}
|
|
14188
14616
|
this.removeStalePidFile();
|
|
14189
14617
|
}
|
|
14190
|
-
|
|
14191
|
-
|
|
14192
|
-
|
|
14193
|
-
|
|
14194
|
-
|
|
14195
|
-
|
|
14196
|
-
|
|
14618
|
+
await this.withStartupLockStrict(async (locked) => {
|
|
14619
|
+
if (!locked) {
|
|
14620
|
+
this.log("Another process holds the startup lock, waiting for readiness+identity...");
|
|
14621
|
+
await this.waitForReadyAndOurs();
|
|
14622
|
+
return;
|
|
14623
|
+
}
|
|
14624
|
+
if (await this.isHealthy()) {
|
|
14625
|
+
const status = await this.fetchStatus();
|
|
14626
|
+
if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
|
|
14627
|
+
this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
|
|
14628
|
+
await this.kill(3000, status?.pid);
|
|
14629
|
+
} else {
|
|
14630
|
+
try {
|
|
14631
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
14632
|
+
return;
|
|
14633
|
+
} catch {
|
|
14634
|
+
this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
|
|
14635
|
+
await this.kill(3000, status?.pid);
|
|
14636
|
+
}
|
|
14637
|
+
}
|
|
14638
|
+
}
|
|
14197
14639
|
this.launch();
|
|
14198
14640
|
await this.waitForReady();
|
|
14199
|
-
}
|
|
14200
|
-
this.releaseLock();
|
|
14201
|
-
}
|
|
14641
|
+
});
|
|
14202
14642
|
}
|
|
14203
14643
|
async isHealthy() {
|
|
14204
14644
|
try {
|
|
14205
|
-
const response = await
|
|
14645
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
14206
14646
|
return response.ok;
|
|
14207
14647
|
} catch {
|
|
14208
14648
|
return false;
|
|
@@ -14218,7 +14658,7 @@ class DaemonLifecycle {
|
|
|
14218
14658
|
}
|
|
14219
14659
|
async isReady() {
|
|
14220
14660
|
try {
|
|
14221
|
-
const response = await
|
|
14661
|
+
const response = await fetchWithTimeout(this.readyUrl);
|
|
14222
14662
|
return response.ok;
|
|
14223
14663
|
} catch {
|
|
14224
14664
|
return false;
|
|
@@ -14232,6 +14672,18 @@ class DaemonLifecycle {
|
|
|
14232
14672
|
}
|
|
14233
14673
|
throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
|
|
14234
14674
|
}
|
|
14675
|
+
async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
|
|
14676
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
14677
|
+
if (await this.isReady()) {
|
|
14678
|
+
const status = await this.fetchStatus();
|
|
14679
|
+
if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
|
|
14680
|
+
return;
|
|
14681
|
+
}
|
|
14682
|
+
}
|
|
14683
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
14684
|
+
}
|
|
14685
|
+
throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
|
|
14686
|
+
}
|
|
14235
14687
|
readStatus() {
|
|
14236
14688
|
try {
|
|
14237
14689
|
const raw = readFileSync(this.stateDir.statusFile, "utf-8");
|
|
@@ -14263,12 +14715,12 @@ class DaemonLifecycle {
|
|
|
14263
14715
|
}
|
|
14264
14716
|
removePidFile() {
|
|
14265
14717
|
try {
|
|
14266
|
-
|
|
14718
|
+
unlinkSync2(this.stateDir.pidFile);
|
|
14267
14719
|
} catch {}
|
|
14268
14720
|
}
|
|
14269
14721
|
removeStatusFile() {
|
|
14270
14722
|
try {
|
|
14271
|
-
|
|
14723
|
+
unlinkSync2(this.stateDir.statusFile);
|
|
14272
14724
|
} catch {}
|
|
14273
14725
|
}
|
|
14274
14726
|
markKilled() {
|
|
@@ -14278,11 +14730,11 @@ class DaemonLifecycle {
|
|
|
14278
14730
|
}
|
|
14279
14731
|
clearKilled() {
|
|
14280
14732
|
try {
|
|
14281
|
-
|
|
14733
|
+
unlinkSync2(this.stateDir.killedFile);
|
|
14282
14734
|
} catch {}
|
|
14283
14735
|
}
|
|
14284
14736
|
wasKilled() {
|
|
14285
|
-
return
|
|
14737
|
+
return existsSync3(this.stateDir.killedFile);
|
|
14286
14738
|
}
|
|
14287
14739
|
launch() {
|
|
14288
14740
|
this.stateDir.ensure();
|
|
@@ -14303,45 +14755,99 @@ class DaemonLifecycle {
|
|
|
14303
14755
|
this.log("Removing stale pid file");
|
|
14304
14756
|
this.removePidFile();
|
|
14305
14757
|
}
|
|
14306
|
-
|
|
14307
|
-
|
|
14308
|
-
|
|
14309
|
-
|
|
14758
|
+
async replaceUnhealthyDaemon(statusPid) {
|
|
14759
|
+
await this.withStartupLockStrict(async (locked) => {
|
|
14760
|
+
if (!locked) {
|
|
14761
|
+
this.log("Another process holds the startup lock, waiting for readiness+identity...");
|
|
14762
|
+
await this.waitForReadyAndOurs();
|
|
14763
|
+
return;
|
|
14764
|
+
}
|
|
14765
|
+
if (await this.isHealthy()) {
|
|
14766
|
+
const status = await this.fetchStatus();
|
|
14767
|
+
if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
|
|
14768
|
+
try {
|
|
14769
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
14770
|
+
return;
|
|
14771
|
+
} catch {}
|
|
14772
|
+
}
|
|
14773
|
+
}
|
|
14774
|
+
this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
|
|
14775
|
+
await this.kill(3000, statusPid);
|
|
14776
|
+
this.launch();
|
|
14777
|
+
await this.waitForReady();
|
|
14778
|
+
});
|
|
14779
|
+
}
|
|
14780
|
+
async withStartupLockStrict(fn) {
|
|
14781
|
+
const locked = this.acquireLockStrict();
|
|
14782
|
+
try {
|
|
14783
|
+
return await fn(locked);
|
|
14784
|
+
} finally {
|
|
14785
|
+
if (locked)
|
|
14786
|
+
this.releaseLock();
|
|
14310
14787
|
}
|
|
14788
|
+
}
|
|
14789
|
+
acquireLockStrict(reclaimed = false) {
|
|
14311
14790
|
this.stateDir.ensure();
|
|
14791
|
+
let fd = null;
|
|
14312
14792
|
try {
|
|
14313
|
-
|
|
14793
|
+
fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
14314
14794
|
writeFileSync(fd, `${process.pid}
|
|
14315
14795
|
`);
|
|
14316
14796
|
closeSync(fd);
|
|
14317
14797
|
return true;
|
|
14318
14798
|
} catch (err) {
|
|
14799
|
+
if (fd !== null && err.code !== "EEXIST") {
|
|
14800
|
+
try {
|
|
14801
|
+
closeSync(fd);
|
|
14802
|
+
} catch {}
|
|
14803
|
+
this.releaseLock();
|
|
14804
|
+
}
|
|
14319
14805
|
if (err.code === "EEXIST") {
|
|
14806
|
+
if (reclaimed)
|
|
14807
|
+
return false;
|
|
14320
14808
|
try {
|
|
14321
14809
|
const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
|
|
14322
14810
|
if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
|
|
14323
|
-
this.log(`Stale lock
|
|
14811
|
+
this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
|
|
14324
14812
|
this.releaseLock();
|
|
14325
|
-
return this.
|
|
14813
|
+
return this.acquireLockStrict(true);
|
|
14814
|
+
}
|
|
14815
|
+
if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
|
|
14816
|
+
this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
|
|
14817
|
+
this.releaseLock();
|
|
14818
|
+
return this.acquireLockStrict(true);
|
|
14326
14819
|
}
|
|
14327
14820
|
} catch {
|
|
14328
|
-
|
|
14329
|
-
this.releaseLock();
|
|
14330
|
-
return this.acquireLock(depth + 1);
|
|
14821
|
+
return false;
|
|
14331
14822
|
}
|
|
14332
14823
|
return false;
|
|
14333
14824
|
}
|
|
14334
|
-
this.log(`
|
|
14335
|
-
return
|
|
14825
|
+
this.log(`Could not acquire strict startup lock: ${err.message}`);
|
|
14826
|
+
return false;
|
|
14827
|
+
}
|
|
14828
|
+
}
|
|
14829
|
+
lockAgeMs() {
|
|
14830
|
+
try {
|
|
14831
|
+
return Date.now() - statSync2(this.stateDir.lockFile).mtimeMs;
|
|
14832
|
+
} catch {
|
|
14833
|
+
return 0;
|
|
14834
|
+
}
|
|
14835
|
+
}
|
|
14836
|
+
isAgentBridgeProcess(pid) {
|
|
14837
|
+
try {
|
|
14838
|
+
const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
14839
|
+
return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
|
|
14840
|
+
} catch {
|
|
14841
|
+
return false;
|
|
14336
14842
|
}
|
|
14337
14843
|
}
|
|
14338
14844
|
releaseLock() {
|
|
14339
14845
|
try {
|
|
14340
|
-
|
|
14846
|
+
unlinkSync2(this.stateDir.lockFile);
|
|
14341
14847
|
} catch {}
|
|
14342
14848
|
}
|
|
14343
|
-
async kill(gracefulTimeoutMs = 3000) {
|
|
14344
|
-
const pid = this.readPid();
|
|
14849
|
+
async kill(gracefulTimeoutMs = 3000, pidOverride) {
|
|
14850
|
+
const pid = pidOverride ?? this.readPid();
|
|
14345
14851
|
if (!pid) {
|
|
14346
14852
|
this.log("No daemon pid file found");
|
|
14347
14853
|
this.cleanup();
|
|
@@ -14383,7 +14889,9 @@ class DaemonLifecycle {
|
|
|
14383
14889
|
isDaemonProcess(pid) {
|
|
14384
14890
|
try {
|
|
14385
14891
|
const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
14386
|
-
|
|
14892
|
+
const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
|
|
14893
|
+
const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
|
|
14894
|
+
return hasDaemonEntry && hasAgentbridge;
|
|
14387
14895
|
} catch {
|
|
14388
14896
|
return false;
|
|
14389
14897
|
}
|
|
@@ -14391,7 +14899,15 @@ class DaemonLifecycle {
|
|
|
14391
14899
|
cleanup() {
|
|
14392
14900
|
this.removePidFile();
|
|
14393
14901
|
this.removeStatusFile();
|
|
14394
|
-
|
|
14902
|
+
}
|
|
14903
|
+
}
|
|
14904
|
+
async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
|
|
14905
|
+
const controller = new AbortController;
|
|
14906
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
14907
|
+
try {
|
|
14908
|
+
return await fetch(url, { signal: controller.signal });
|
|
14909
|
+
} finally {
|
|
14910
|
+
clearTimeout(timer);
|
|
14395
14911
|
}
|
|
14396
14912
|
}
|
|
14397
14913
|
function isProcessAlive(pid) {
|
|
@@ -14404,8 +14920,25 @@ function isProcessAlive(pid) {
|
|
|
14404
14920
|
}
|
|
14405
14921
|
|
|
14406
14922
|
// src/config-service.ts
|
|
14407
|
-
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as
|
|
14923
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
|
|
14408
14924
|
import { join as join2 } from "path";
|
|
14925
|
+
var DEFAULT_BUDGET_CONFIG = {
|
|
14926
|
+
enabled: true,
|
|
14927
|
+
pollSeconds: 60,
|
|
14928
|
+
pauseAt: 90,
|
|
14929
|
+
resumeBelow: 30,
|
|
14930
|
+
syncDriftPct: 10,
|
|
14931
|
+
parallel: {
|
|
14932
|
+
minRemainingPct: 60,
|
|
14933
|
+
timeWindowSec: 3600
|
|
14934
|
+
},
|
|
14935
|
+
codexTierControl: false,
|
|
14936
|
+
codexTiers: {
|
|
14937
|
+
full: null,
|
|
14938
|
+
balanced: { effort: "medium" },
|
|
14939
|
+
eco: { effort: "low" }
|
|
14940
|
+
}
|
|
14941
|
+
};
|
|
14409
14942
|
var DEFAULT_CONFIG = {
|
|
14410
14943
|
version: "1.0",
|
|
14411
14944
|
codex: {
|
|
@@ -14415,7 +14948,8 @@ var DEFAULT_CONFIG = {
|
|
|
14415
14948
|
turnCoordination: {
|
|
14416
14949
|
attentionWindowSeconds: 15
|
|
14417
14950
|
},
|
|
14418
|
-
idleShutdownSeconds: 30
|
|
14951
|
+
idleShutdownSeconds: 30,
|
|
14952
|
+
budget: DEFAULT_BUDGET_CONFIG
|
|
14419
14953
|
};
|
|
14420
14954
|
var CONFIG_DIR = ".agentbridge";
|
|
14421
14955
|
var CONFIG_FILE = "config.json";
|
|
@@ -14432,6 +14966,63 @@ function normalizeInteger(value, fallback) {
|
|
|
14432
14966
|
}
|
|
14433
14967
|
return fallback;
|
|
14434
14968
|
}
|
|
14969
|
+
function normalizeBoundedInteger(value, fallback, min, max) {
|
|
14970
|
+
const parsed = normalizeInteger(value, fallback);
|
|
14971
|
+
if (parsed < min || parsed > max)
|
|
14972
|
+
return fallback;
|
|
14973
|
+
return parsed;
|
|
14974
|
+
}
|
|
14975
|
+
function normalizeBoolean(value, fallback) {
|
|
14976
|
+
if (typeof value === "boolean")
|
|
14977
|
+
return value;
|
|
14978
|
+
if (value === "true" || value === "1")
|
|
14979
|
+
return true;
|
|
14980
|
+
if (value === "false" || value === "0")
|
|
14981
|
+
return false;
|
|
14982
|
+
return fallback;
|
|
14983
|
+
}
|
|
14984
|
+
function normalizeCodexOverride(raw) {
|
|
14985
|
+
if (!isRecord(raw))
|
|
14986
|
+
return null;
|
|
14987
|
+
const override = {};
|
|
14988
|
+
if (typeof raw.model === "string" && raw.model.trim() !== "")
|
|
14989
|
+
override.model = raw.model.trim();
|
|
14990
|
+
if (typeof raw.effort === "string" && raw.effort.trim() !== "")
|
|
14991
|
+
override.effort = raw.effort.trim();
|
|
14992
|
+
return Object.keys(override).length > 0 ? override : null;
|
|
14993
|
+
}
|
|
14994
|
+
function normalizeCodexTiers(raw) {
|
|
14995
|
+
const tiers = isRecord(raw) ? raw : {};
|
|
14996
|
+
return {
|
|
14997
|
+
full: normalizeCodexOverride(tiers.full),
|
|
14998
|
+
balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
|
|
14999
|
+
eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
|
|
15000
|
+
};
|
|
15001
|
+
}
|
|
15002
|
+
function normalizeBudgetConfig(raw) {
|
|
15003
|
+
const budget = isRecord(raw) ? raw : {};
|
|
15004
|
+
const parallel = isRecord(budget.parallel) ? budget.parallel : {};
|
|
15005
|
+
const codexTiers = normalizeCodexTiers(budget.codexTiers);
|
|
15006
|
+
let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
|
|
15007
|
+
let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
|
|
15008
|
+
if (pauseAt <= resumeBelow) {
|
|
15009
|
+
pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
|
|
15010
|
+
resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
|
|
15011
|
+
}
|
|
15012
|
+
return {
|
|
15013
|
+
enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
|
|
15014
|
+
pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
|
|
15015
|
+
pauseAt,
|
|
15016
|
+
resumeBelow,
|
|
15017
|
+
syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
|
|
15018
|
+
parallel: {
|
|
15019
|
+
minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
|
|
15020
|
+
timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
|
|
15021
|
+
},
|
|
15022
|
+
codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
|
|
15023
|
+
codexTiers
|
|
15024
|
+
};
|
|
15025
|
+
}
|
|
14435
15026
|
function normalizeConfig(raw) {
|
|
14436
15027
|
if (!isRecord(raw))
|
|
14437
15028
|
return null;
|
|
@@ -14448,7 +15039,8 @@ function normalizeConfig(raw) {
|
|
|
14448
15039
|
turnCoordination: {
|
|
14449
15040
|
attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
|
|
14450
15041
|
},
|
|
14451
|
-
idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
|
|
15042
|
+
idleShutdownSeconds: normalizeInteger(config2.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
|
|
15043
|
+
budget: normalizeBudgetConfig(config2.budget)
|
|
14452
15044
|
};
|
|
14453
15045
|
}
|
|
14454
15046
|
|
|
@@ -14461,7 +15053,7 @@ class ConfigService {
|
|
|
14461
15053
|
this.configPath = join2(this.configDir, CONFIG_FILE);
|
|
14462
15054
|
}
|
|
14463
15055
|
hasConfig() {
|
|
14464
|
-
return
|
|
15056
|
+
return existsSync4(this.configPath);
|
|
14465
15057
|
}
|
|
14466
15058
|
load() {
|
|
14467
15059
|
try {
|
|
@@ -14482,7 +15074,7 @@ class ConfigService {
|
|
|
14482
15074
|
initDefaults() {
|
|
14483
15075
|
this.ensureConfigDir();
|
|
14484
15076
|
const created = [];
|
|
14485
|
-
if (!
|
|
15077
|
+
if (!existsSync4(this.configPath)) {
|
|
14486
15078
|
this.save(DEFAULT_CONFIG);
|
|
14487
15079
|
created.push(this.configPath);
|
|
14488
15080
|
}
|
|
@@ -14492,32 +15084,323 @@ class ConfigService {
|
|
|
14492
15084
|
return this.configPath;
|
|
14493
15085
|
}
|
|
14494
15086
|
ensureConfigDir() {
|
|
14495
|
-
if (!
|
|
15087
|
+
if (!existsSync4(this.configDir)) {
|
|
14496
15088
|
mkdirSync2(this.configDir, { recursive: true });
|
|
14497
15089
|
}
|
|
14498
15090
|
}
|
|
14499
15091
|
}
|
|
14500
15092
|
|
|
15093
|
+
// src/pair-registry.ts
|
|
15094
|
+
import {
|
|
15095
|
+
closeSync as closeSync2,
|
|
15096
|
+
existsSync as existsSync5,
|
|
15097
|
+
fsyncSync,
|
|
15098
|
+
linkSync,
|
|
15099
|
+
lstatSync,
|
|
15100
|
+
mkdirSync as mkdirSync3,
|
|
15101
|
+
openSync as openSync2,
|
|
15102
|
+
readdirSync,
|
|
15103
|
+
readFileSync as readFileSync3,
|
|
15104
|
+
realpathSync,
|
|
15105
|
+
renameSync as renameSync2,
|
|
15106
|
+
rmSync,
|
|
15107
|
+
statSync as statSync3,
|
|
15108
|
+
unlinkSync as unlinkSync3,
|
|
15109
|
+
writeFileSync as writeFileSync3
|
|
15110
|
+
} from "fs";
|
|
15111
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
15112
|
+
import { basename, join as join3, resolve, sep } from "path";
|
|
15113
|
+
var PAIR_BASE_PORT = 4500;
|
|
15114
|
+
var PAIR_SLOT_STRIDE = 10;
|
|
15115
|
+
var PAIR_ID_REGEX = /^[A-Za-z0-9._-]{1,64}$/;
|
|
15116
|
+
var REGISTRY_FILE_NAME = "registry.json";
|
|
15117
|
+
class PairError extends Error {
|
|
15118
|
+
code;
|
|
15119
|
+
details;
|
|
15120
|
+
constructor(code, message, details) {
|
|
15121
|
+
super(message);
|
|
15122
|
+
this.name = "PairError";
|
|
15123
|
+
this.code = code;
|
|
15124
|
+
this.details = details;
|
|
15125
|
+
}
|
|
15126
|
+
}
|
|
15127
|
+
var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
|
|
15128
|
+
function derivePairId(cwd, name) {
|
|
15129
|
+
let real;
|
|
15130
|
+
try {
|
|
15131
|
+
real = realpathSync(cwd);
|
|
15132
|
+
} catch {
|
|
15133
|
+
real = cwd;
|
|
15134
|
+
}
|
|
15135
|
+
const hash = createHash("sha256").update(real).update("\x00").update(name.toLowerCase()).digest("hex").slice(0, 8);
|
|
15136
|
+
const slug = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "pair";
|
|
15137
|
+
return `${slug}-${hash}`;
|
|
15138
|
+
}
|
|
15139
|
+
function pairsDir(base) {
|
|
15140
|
+
return join3(base, "pairs");
|
|
15141
|
+
}
|
|
15142
|
+
function registryPath(base) {
|
|
15143
|
+
return join3(pairsDir(base), REGISTRY_FILE_NAME);
|
|
15144
|
+
}
|
|
15145
|
+
function readRegistry(base) {
|
|
15146
|
+
const path = registryPath(base);
|
|
15147
|
+
if (!existsSync5(path))
|
|
15148
|
+
return { version: 1, pairs: [] };
|
|
15149
|
+
let parsed;
|
|
15150
|
+
try {
|
|
15151
|
+
parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
15152
|
+
} catch (err) {
|
|
15153
|
+
throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry JSON is not parseable at ${path}: ${err.message}`, {
|
|
15154
|
+
path
|
|
15155
|
+
});
|
|
15156
|
+
}
|
|
15157
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !Array.isArray(parsed.pairs)) {
|
|
15158
|
+
throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry shape is invalid at ${path}`, { path });
|
|
15159
|
+
}
|
|
15160
|
+
const entries = parsed.pairs;
|
|
15161
|
+
const seenSlots = new Set;
|
|
15162
|
+
const seenIds = new Set;
|
|
15163
|
+
for (const e of entries) {
|
|
15164
|
+
const idValid = e && typeof e.pairId === "string" && e.pairId !== "." && e.pairId !== ".." && PAIR_ID_REGEX.test(e.pairId);
|
|
15165
|
+
if (!idValid || !Number.isInteger(e.slot) || e.slot < 0) {
|
|
15166
|
+
throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry has a malformed entry at ${path}`, { path, entry: e });
|
|
15167
|
+
}
|
|
15168
|
+
const lower = e.pairId.toLowerCase();
|
|
15169
|
+
if (seenSlots.has(e.slot) || seenIds.has(lower)) {
|
|
15170
|
+
throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry has duplicate slot/pairId at ${path}`, {
|
|
15171
|
+
path,
|
|
15172
|
+
pairId: e.pairId,
|
|
15173
|
+
slot: e.slot
|
|
15174
|
+
});
|
|
15175
|
+
}
|
|
15176
|
+
seenSlots.add(e.slot);
|
|
15177
|
+
seenIds.add(lower);
|
|
15178
|
+
}
|
|
15179
|
+
return parsed;
|
|
15180
|
+
}
|
|
15181
|
+
|
|
15182
|
+
// src/pair-resolver.ts
|
|
15183
|
+
function computeBaseDir() {
|
|
15184
|
+
return process.env.AGENTBRIDGE_BASE_DIR || process.env.AGENTBRIDGE_STATE_DIR || StateDirResolver.platformBaseDir();
|
|
15185
|
+
}
|
|
15186
|
+
function findPair(base, pairId) {
|
|
15187
|
+
const lower = pairId.toLowerCase();
|
|
15188
|
+
return readRegistry(base).pairs.find((p) => p.pairId.toLowerCase() === lower) ?? null;
|
|
15189
|
+
}
|
|
15190
|
+
|
|
15191
|
+
// src/pair-command.ts
|
|
15192
|
+
function pairScopedCommand(cmd) {
|
|
15193
|
+
const pairId = process.env.AGENTBRIDGE_PAIR_ID;
|
|
15194
|
+
if (!pairId)
|
|
15195
|
+
return `agentbridge ${cmd}`;
|
|
15196
|
+
let selector = process.env.AGENTBRIDGE_PAIR_NAME;
|
|
15197
|
+
if (!selector) {
|
|
15198
|
+
try {
|
|
15199
|
+
selector = findPair(computeBaseDir(), pairId)?.name || pairId;
|
|
15200
|
+
} catch {
|
|
15201
|
+
selector = pairId;
|
|
15202
|
+
}
|
|
15203
|
+
}
|
|
15204
|
+
return `agentbridge --pair ${selector} ${cmd}`;
|
|
15205
|
+
}
|
|
15206
|
+
|
|
14501
15207
|
// src/bridge-disabled-state.ts
|
|
14502
15208
|
function disabledReplyError(reason) {
|
|
15209
|
+
const claudeCmd = pairScopedCommand("claude");
|
|
14503
15210
|
switch (reason) {
|
|
14504
15211
|
case "rejected":
|
|
14505
|
-
return
|
|
15212
|
+
return `AgentBridge rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run \`${pairScopedCommand("kill")}\` to reset.`;
|
|
15213
|
+
case "evicted":
|
|
15214
|
+
return `AgentBridge evicted this session because it stopped responding to liveness probes \u2014 a newer Claude Code session has taken over. Close this session and start a new one with \`${claudeCmd}\`.`;
|
|
15215
|
+
case "probe_in_progress":
|
|
15216
|
+
return `AgentBridge rejected this session \u2014 a liveness probe is currently checking the incumbent Claude session. Retry in a few seconds with \`${claudeCmd}\`.`;
|
|
15217
|
+
case "auto_recovery_exhausted":
|
|
15218
|
+
return `AgentBridge auto-recovery gave up after exhausting its retry budget for the in-flight liveness probe contention. Retry manually with \`${claudeCmd}\`.`;
|
|
14506
15219
|
case "killed":
|
|
14507
|
-
return
|
|
15220
|
+
return `AgentBridge is disabled by \`agentbridge kill\`. Restart Claude Code (\`${claudeCmd}\`), switch to a new conversation, or run \`/resume\` to reconnect.`;
|
|
15221
|
+
}
|
|
15222
|
+
}
|
|
15223
|
+
|
|
15224
|
+
// src/env-guard.ts
|
|
15225
|
+
var GENERATED_ENV_KEYS = [
|
|
15226
|
+
"AGENTBRIDGE_BASE_DIR",
|
|
15227
|
+
"AGENTBRIDGE_PAIR_ID",
|
|
15228
|
+
"AGENTBRIDGE_PAIR_NAME",
|
|
15229
|
+
"AGENTBRIDGE_STATE_DIR",
|
|
15230
|
+
"AGENTBRIDGE_CONTROL_PORT",
|
|
15231
|
+
"AGENTBRIDGE_MODE",
|
|
15232
|
+
"AGENTBRIDGE_FILTER_MODE",
|
|
15233
|
+
"AGENTBRIDGE_MAX_BUFFERED_MESSAGES",
|
|
15234
|
+
"AGENTBRIDGE_CODEX_TRANSPORT",
|
|
15235
|
+
"CODEX_WS_PORT",
|
|
15236
|
+
"CODEX_PROXY_PORT"
|
|
15237
|
+
];
|
|
15238
|
+
function normalizeEnvGuardMode(raw, fallback = "fix") {
|
|
15239
|
+
if (raw === "off" || raw === "warn" || raw === "fix" || raw === "strict")
|
|
15240
|
+
return raw;
|
|
15241
|
+
return fallback;
|
|
15242
|
+
}
|
|
15243
|
+
function inspectAgentBridgeEnv(opts) {
|
|
15244
|
+
const env = opts.env ?? process.env;
|
|
15245
|
+
const actualPairId = nonEmpty(env.AGENTBRIDGE_PAIR_ID);
|
|
15246
|
+
const pairName = nonEmpty(env.AGENTBRIDGE_PAIR_NAME) ?? "main";
|
|
15247
|
+
const stateDir = nonEmpty(env.AGENTBRIDGE_STATE_DIR);
|
|
15248
|
+
const baseDir = nonEmpty(env.AGENTBRIDGE_BASE_DIR);
|
|
15249
|
+
const manualOptIn = env.AGENTBRIDGE_MANUAL === "1";
|
|
15250
|
+
const manualRuntimeEnv = !!stateDir || !!nonEmpty(env.AGENTBRIDGE_CONTROL_PORT) || !!nonEmpty(env.CODEX_WS_PORT) || !!nonEmpty(env.CODEX_PROXY_PORT);
|
|
15251
|
+
const expectedPairId = actualPairId ? derivePairId(opts.cwd, pairName) : null;
|
|
15252
|
+
const reasons = [];
|
|
15253
|
+
if (!actualPairId && manualRuntimeEnv && !manualOptIn) {
|
|
15254
|
+
reasons.push("AgentBridge runtime env is set without AGENTBRIDGE_PAIR_ID or AGENTBRIDGE_MANUAL=1");
|
|
14508
15255
|
}
|
|
15256
|
+
if (actualPairId && expectedPairId && actualPairId !== expectedPairId) {
|
|
15257
|
+
reasons.push(`AGENTBRIDGE_PAIR_ID=${actualPairId} does not match cwd-derived ${expectedPairId}`);
|
|
15258
|
+
}
|
|
15259
|
+
if (actualPairId && stateDir && !stateDir.endsWith(`/pairs/${actualPairId}`)) {
|
|
15260
|
+
reasons.push(`AGENTBRIDGE_STATE_DIR does not end with /pairs/${actualPairId}`);
|
|
15261
|
+
}
|
|
15262
|
+
if (actualPairId && baseDir && stateDir && !stateDir.startsWith(`${baseDir}/`)) {
|
|
15263
|
+
reasons.push("AGENTBRIDGE_BASE_DIR and AGENTBRIDGE_STATE_DIR disagree");
|
|
15264
|
+
}
|
|
15265
|
+
return {
|
|
15266
|
+
ok: reasons.length === 0,
|
|
15267
|
+
expectedPairId,
|
|
15268
|
+
actualPairId,
|
|
15269
|
+
pairName,
|
|
15270
|
+
reasons
|
|
15271
|
+
};
|
|
15272
|
+
}
|
|
15273
|
+
function guardAgentBridgeEnv(opts) {
|
|
15274
|
+
const env = opts.env ?? process.env;
|
|
15275
|
+
const mode = normalizeEnvGuardMode(opts.mode, "fix");
|
|
15276
|
+
const effectiveMode = mode === "strict" && opts.allowStrict === false ? "fix" : mode;
|
|
15277
|
+
const inspection = inspectAgentBridgeEnv({ cwd: opts.cwd, env });
|
|
15278
|
+
if (effectiveMode === "off" || inspection.ok) {
|
|
15279
|
+
return { ...inspection, action: "none" };
|
|
15280
|
+
}
|
|
15281
|
+
const message = `stale AgentBridge environment detected for ${opts.cwd}: ${inspection.reasons.join("; ")}`;
|
|
15282
|
+
if (effectiveMode === "strict") {
|
|
15283
|
+
throw new Error(message);
|
|
15284
|
+
}
|
|
15285
|
+
opts.log?.(`[agentbridge] ${message}`);
|
|
15286
|
+
if (effectiveMode === "warn") {
|
|
15287
|
+
return { ...inspection, action: "warned" };
|
|
15288
|
+
}
|
|
15289
|
+
for (const key of GENERATED_ENV_KEYS) {
|
|
15290
|
+
delete env[key];
|
|
15291
|
+
}
|
|
15292
|
+
opts.log?.("[agentbridge] cleared stale AgentBridge environment variables");
|
|
15293
|
+
return { ...inspection, action: "fixed" };
|
|
15294
|
+
}
|
|
15295
|
+
function nonEmpty(value) {
|
|
15296
|
+
return value && value.length > 0 ? value : null;
|
|
15297
|
+
}
|
|
15298
|
+
|
|
15299
|
+
// src/trace-log.ts
|
|
15300
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
|
|
15301
|
+
import { join as join4 } from "path";
|
|
15302
|
+
var SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|auth|cookie|session)/i;
|
|
15303
|
+
var SECRET_ARG_RE = /^--?(?:token|secret|password|passwd|apikey|api-key|api_key|auth|cookie|session)(?:=.*)?$/i;
|
|
15304
|
+
var RELEVANT_ENV_RE = /^(AGENTBRIDGE_|CODEX_)/;
|
|
15305
|
+
function pickRelevantEnv(env) {
|
|
15306
|
+
const picked = {};
|
|
15307
|
+
for (const [key, value] of Object.entries(env)) {
|
|
15308
|
+
if (!RELEVANT_ENV_RE.test(key))
|
|
15309
|
+
continue;
|
|
15310
|
+
picked[key] = SECRET_KEY_RE.test(key) && value !== undefined ? "<redacted>" : value;
|
|
15311
|
+
}
|
|
15312
|
+
return picked;
|
|
15313
|
+
}
|
|
15314
|
+
function redactArgv(argv) {
|
|
15315
|
+
const redacted = [];
|
|
15316
|
+
let redactNext = false;
|
|
15317
|
+
for (const arg of argv) {
|
|
15318
|
+
if (redactNext) {
|
|
15319
|
+
redacted.push("<redacted>");
|
|
15320
|
+
redactNext = false;
|
|
15321
|
+
continue;
|
|
15322
|
+
}
|
|
15323
|
+
if (SECRET_ARG_RE.test(arg)) {
|
|
15324
|
+
if (arg.includes("=")) {
|
|
15325
|
+
const [key] = arg.split("=", 1);
|
|
15326
|
+
redacted.push(`${key}=<redacted>`);
|
|
15327
|
+
} else {
|
|
15328
|
+
redacted.push(arg);
|
|
15329
|
+
redactNext = true;
|
|
15330
|
+
}
|
|
15331
|
+
continue;
|
|
15332
|
+
}
|
|
15333
|
+
redacted.push(arg);
|
|
15334
|
+
}
|
|
15335
|
+
return redacted;
|
|
15336
|
+
}
|
|
15337
|
+
function traceLogPath(cwd, timestamp) {
|
|
15338
|
+
const day = timestamp.slice(0, 10);
|
|
15339
|
+
return join4(cwd, ".agentbridge", "logs", `trace-${day}.jsonl`);
|
|
15340
|
+
}
|
|
15341
|
+
function appendTraceEvent(input) {
|
|
15342
|
+
const timestamp = input.timestamp ?? new Date().toISOString();
|
|
15343
|
+
const path = traceLogPath(input.cwd, timestamp);
|
|
15344
|
+
const event = {
|
|
15345
|
+
timestamp,
|
|
15346
|
+
event: input.event,
|
|
15347
|
+
cwd: input.cwd,
|
|
15348
|
+
pid: input.pid ?? process.pid,
|
|
15349
|
+
...input.argv ? { argv: redactArgv(input.argv) } : {},
|
|
15350
|
+
...input.env ? { env: pickRelevantEnv(input.env) } : {},
|
|
15351
|
+
...input.data ? { data: redactData(input.data) } : {}
|
|
15352
|
+
};
|
|
15353
|
+
mkdirSync4(join4(input.cwd, ".agentbridge", "logs"), { recursive: true });
|
|
15354
|
+
appendFileSync2(path, JSON.stringify(event) + `
|
|
15355
|
+
`, "utf-8");
|
|
15356
|
+
return path;
|
|
15357
|
+
}
|
|
15358
|
+
function isEnvSnapshot(key, value) {
|
|
15359
|
+
return /env$/i.test(key) && !!value && typeof value === "object" && !Array.isArray(value);
|
|
15360
|
+
}
|
|
15361
|
+
function redactData(value, key = "") {
|
|
15362
|
+
if (typeof value === "string") {
|
|
15363
|
+
return SECRET_KEY_RE.test(key) ? "<redacted>" : value;
|
|
15364
|
+
}
|
|
15365
|
+
if (Array.isArray(value)) {
|
|
15366
|
+
return value.map((item) => redactData(item, key));
|
|
15367
|
+
}
|
|
15368
|
+
if (value && typeof value === "object") {
|
|
15369
|
+
const redacted = {};
|
|
15370
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
15371
|
+
if (SECRET_KEY_RE.test(childKey)) {
|
|
15372
|
+
redacted[childKey] = "<redacted>";
|
|
15373
|
+
} else if (isEnvSnapshot(childKey, childValue)) {
|
|
15374
|
+
redacted[childKey] = pickRelevantEnv(childValue);
|
|
15375
|
+
} else {
|
|
15376
|
+
redacted[childKey] = redactData(childValue, childKey);
|
|
15377
|
+
}
|
|
15378
|
+
}
|
|
15379
|
+
return redacted;
|
|
15380
|
+
}
|
|
15381
|
+
return value;
|
|
14509
15382
|
}
|
|
14510
15383
|
|
|
14511
15384
|
// src/bridge.ts
|
|
15385
|
+
var originalEnv = { ...process.env };
|
|
15386
|
+
var bootstrapLogger = createProcessLogger({ component: "AgentBridgeFrontend" });
|
|
15387
|
+
var envGuardResult = guardAgentBridgeEnv({
|
|
15388
|
+
cwd: process.cwd(),
|
|
15389
|
+
env: process.env,
|
|
15390
|
+
mode: normalizeEnvGuardMode(process.env.AGENTBRIDGE_ENV_GUARD),
|
|
15391
|
+
allowStrict: false,
|
|
15392
|
+
log: bootstrapLogger.log
|
|
15393
|
+
});
|
|
14512
15394
|
var stateDir = new StateDirResolver;
|
|
14513
15395
|
stateDir.ensure();
|
|
14514
15396
|
var configService = new ConfigService;
|
|
14515
15397
|
var config2 = configService.loadOrDefault();
|
|
14516
15398
|
var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
|
|
15399
|
+
var processLogger = createProcessLogger({ component: "AgentBridgeFrontend", logFile: stateDir.logFile });
|
|
14517
15400
|
var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
|
|
14518
15401
|
var CONTROL_WS_URL = daemonLifecycle.controlWsUrl;
|
|
14519
15402
|
var claude = new ClaudeAdapter(stateDir.logFile);
|
|
14520
|
-
var daemonClient = new DaemonClient(CONTROL_WS_URL);
|
|
15403
|
+
var daemonClient = new DaemonClient(CONTROL_WS_URL, { identity: currentClientIdentity() });
|
|
14521
15404
|
var shuttingDown = false;
|
|
14522
15405
|
var daemonDisabled = false;
|
|
14523
15406
|
var daemonDisabledReason = null;
|
|
@@ -14529,7 +15412,31 @@ var lastDisconnectNotifyTs = 0;
|
|
|
14529
15412
|
var lastReconnectNotifyTs = 0;
|
|
14530
15413
|
var disabledRecoveryTimer = null;
|
|
14531
15414
|
var disabledRecoveryInFlight = false;
|
|
14532
|
-
|
|
15415
|
+
var disabledRecoveryAttempts = 0;
|
|
15416
|
+
var DISABLED_RECOVERY_MAX_ATTEMPTS = 6;
|
|
15417
|
+
var DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS = 1000;
|
|
15418
|
+
if (process.env.AGENTBRIDGE_TRACE === "1") {
|
|
15419
|
+
try {
|
|
15420
|
+
appendTraceEvent({
|
|
15421
|
+
cwd: process.cwd(),
|
|
15422
|
+
event: "bridge.start",
|
|
15423
|
+
pid: process.pid,
|
|
15424
|
+
argv: process.argv,
|
|
15425
|
+
env: process.env,
|
|
15426
|
+
data: {
|
|
15427
|
+
originalEnv: pickRelevantEnv(originalEnv),
|
|
15428
|
+
effectiveEnv: pickRelevantEnv(process.env),
|
|
15429
|
+
envGuardAction: envGuardResult.action,
|
|
15430
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
15431
|
+
pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
|
|
15432
|
+
stateDir: stateDir.dir,
|
|
15433
|
+
controlPort: CONTROL_PORT,
|
|
15434
|
+
build: BUILD_INFO
|
|
15435
|
+
}
|
|
15436
|
+
});
|
|
15437
|
+
} catch {}
|
|
15438
|
+
}
|
|
15439
|
+
claude.setReplySender(async (msg, requireReply, onBusy) => {
|
|
14533
15440
|
if (msg.source !== "claude") {
|
|
14534
15441
|
return { success: false, error: "Invalid message source" };
|
|
14535
15442
|
}
|
|
@@ -14539,7 +15446,7 @@ claude.setReplySender(async (msg, requireReply) => {
|
|
|
14539
15446
|
error: disabledReplyError(daemonDisabledReason ?? "killed")
|
|
14540
15447
|
};
|
|
14541
15448
|
}
|
|
14542
|
-
return daemonClient.sendReply(msg, requireReply);
|
|
15449
|
+
return daemonClient.sendReply(msg, requireReply, onBusy);
|
|
14543
15450
|
});
|
|
14544
15451
|
daemonClient.on("codexMessage", (message) => {
|
|
14545
15452
|
log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`);
|
|
@@ -14547,6 +15454,7 @@ daemonClient.on("codexMessage", (message) => {
|
|
|
14547
15454
|
});
|
|
14548
15455
|
daemonClient.on("status", (status) => {
|
|
14549
15456
|
log(`Daemon status: ready=${status.bridgeReady} tui=${status.tuiConnected} thread=${status.threadId ?? "none"} queued=${status.queuedMessageCount}`);
|
|
15457
|
+
claude.setBudgetSnapshot(status.budget ?? null);
|
|
14550
15458
|
if (!hasSeenTuiConnect && status.tuiConnected && !previousTuiConnected) {
|
|
14551
15459
|
hasSeenTuiConnect = true;
|
|
14552
15460
|
log("First TUI connect detected \u2014 sending kickoff message to Claude");
|
|
@@ -14563,6 +15471,7 @@ daemonClient.on("status", (status) => {
|
|
|
14563
15471
|
daemonClient.on("disconnect", () => {
|
|
14564
15472
|
if (shuttingDown || daemonDisabled)
|
|
14565
15473
|
return;
|
|
15474
|
+
claude.setBudgetSnapshot(null);
|
|
14566
15475
|
log("Daemon control connection closed \u2014 will attempt to reconnect");
|
|
14567
15476
|
const now = Date.now();
|
|
14568
15477
|
if (now - lastDisconnectNotifyTs >= RECONNECT_NOTIFY_COOLDOWN_MS) {
|
|
@@ -14573,22 +15482,55 @@ daemonClient.on("disconnect", () => {
|
|
|
14573
15482
|
}
|
|
14574
15483
|
reconnectToDaemon();
|
|
14575
15484
|
});
|
|
14576
|
-
daemonClient.on("rejected", async () => {
|
|
15485
|
+
daemonClient.on("rejected", async (code) => {
|
|
14577
15486
|
if (shuttingDown || daemonDisabled)
|
|
14578
15487
|
return;
|
|
14579
|
-
|
|
15488
|
+
let reason;
|
|
15489
|
+
let notificationId;
|
|
15490
|
+
let notificationContent;
|
|
15491
|
+
switch (code) {
|
|
15492
|
+
case CLOSE_CODE_EVICTED_STALE:
|
|
15493
|
+
reason = "evicted";
|
|
15494
|
+
notificationId = "system_bridge_evicted";
|
|
15495
|
+
notificationContent = `\u26A0\uFE0F AgentBridge evicted this session because it stopped responding to liveness probes \u2014 a newer Claude Code session has taken over. Close this session and start a new one with \`${pairScopedCommand("claude")}\` if you want to reconnect. AgentBridge \u56E0\u6B64\u4F1A\u8BDD\u672A\u54CD\u5E94\u5B58\u6D3B\u63A2\u6D4B\u800C\u5C06\u5176\u9A71\u9010\u2014\u2014\u66F4\u65B0\u7684 Claude Code \u4F1A\u8BDD\u5DF2\u63A5\u7BA1\u3002\u5982\u9700\u91CD\u8FDE\uFF0C\u8BF7\u5173\u95ED\u6B64\u4F1A\u8BDD\u5E76\u8FD0\u884C \`${pairScopedCommand("claude")}\` \u542F\u52A8\u65B0\u4F1A\u8BDD\u3002`;
|
|
15496
|
+
break;
|
|
15497
|
+
case CLOSE_CODE_PROBE_IN_PROGRESS:
|
|
15498
|
+
reason = "probe_in_progress";
|
|
15499
|
+
notificationId = "system_bridge_probe_in_progress";
|
|
15500
|
+
notificationContent = `\u26A0\uFE0F AgentBridge rejected this session \u2014 a liveness probe is currently checking whether the incumbent Claude session is still alive. Retry in a few seconds with \`${pairScopedCommand("claude")}\`. AgentBridge \u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u6B63\u5728\u901A\u8FC7\u5B58\u6D3B\u63A2\u6D4B\u68C0\u67E5\u73B0\u6709 Claude \u4F1A\u8BDD\u662F\u5426\u4ECD\u7136\u5728\u7EBF\u3002\u8BF7\u7A0D\u540E\u7528 \`${pairScopedCommand("claude")}\` \u91CD\u8BD5\u3002`;
|
|
15501
|
+
break;
|
|
15502
|
+
case CLOSE_CODE_PAIR_MISMATCH:
|
|
15503
|
+
reason = "rejected";
|
|
15504
|
+
notificationId = "system_bridge_pair_mismatch";
|
|
15505
|
+
notificationContent = `\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 pair/cwd identity mismatch (this daemon belongs to a different pair or directory). Do NOT kill it; start Claude Code from the pair's own directory, or pick another pair name with \`agentbridge --pair <name> claude\`. AgentBridge \u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014pair/\u76EE\u5F55\u8EAB\u4EFD\u4E0D\u5339\u914D\uFF08\u8BE5 daemon \u5C5E\u4E8E\u5176\u4ED6 pair \u6216\u76EE\u5F55\uFF09\u3002\u65E0\u9700 kill\uFF1B\u8BF7\u5230\u5BF9\u5E94\u76EE\u5F55\u542F\u52A8\uFF0C\u6216\u6362\u4E00\u4E2A pair \u540D\uFF1A\`agentbridge --pair <\u540D\u5B57> claude\`\u3002`;
|
|
15506
|
+
break;
|
|
15507
|
+
default:
|
|
15508
|
+
reason = "rejected";
|
|
15509
|
+
notificationId = "system_bridge_replaced";
|
|
15510
|
+
notificationContent = `\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run \`${pairScopedCommand("kill")}\` to reset. AgentBridge \u5B88\u62A4\u8FDB\u7A0B\u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u53E6\u4E00\u4E2A Claude Code \u4F1A\u8BDD\u5DF2\u5728\u8FDE\u63A5\u4E2D\u3002\u8BF7\u5148\u5173\u95ED\u53E6\u4E00\u4E2A\u4F1A\u8BDD\uFF0C\u6216\u8FD0\u884C \`${pairScopedCommand("kill")}\` \u91CD\u7F6E\u3002`;
|
|
15511
|
+
break;
|
|
15512
|
+
}
|
|
15513
|
+
log(`Daemon rejected this session (close code ${code}, reason=${reason})`);
|
|
14580
15514
|
daemonDisabled = true;
|
|
14581
|
-
daemonDisabledReason =
|
|
14582
|
-
await claude.pushNotification(systemMessage(
|
|
15515
|
+
daemonDisabledReason = reason;
|
|
15516
|
+
await claude.pushNotification(systemMessage(notificationId, notificationContent));
|
|
14583
15517
|
await daemonClient.disconnect();
|
|
15518
|
+
if (reason === "probe_in_progress") {
|
|
15519
|
+
disabledRecoveryAttempts = 0;
|
|
15520
|
+
startDisabledRecoveryPoller();
|
|
15521
|
+
}
|
|
14584
15522
|
});
|
|
14585
15523
|
claude.on("ready", async () => {
|
|
14586
|
-
log(
|
|
15524
|
+
log("MCP server ready (push delivery) \u2014 ensuring AgentBridge daemon...");
|
|
14587
15525
|
if (daemonLifecycle.wasKilled()) {
|
|
14588
|
-
await enterDisabledState("Killed sentinel found \u2014 bridge staying idle",
|
|
15526
|
+
await enterDisabledState("Killed sentinel found \u2014 bridge staying idle", `\u26D4 AgentBridge was stopped by \`agentbridge kill\`. Bridge is staying idle. Restart Claude Code (\`${pairScopedCommand("claude")}\`), switch to a new conversation, or run \`/resume\` to reconnect.`);
|
|
14589
15527
|
return;
|
|
14590
15528
|
}
|
|
14591
|
-
|
|
15529
|
+
try {
|
|
15530
|
+
await connectToDaemon();
|
|
15531
|
+
} catch {
|
|
15532
|
+
reconnectToDaemon();
|
|
15533
|
+
}
|
|
14592
15534
|
});
|
|
14593
15535
|
async function connectToDaemon(isReconnect = false) {
|
|
14594
15536
|
if (daemonDisabled) {
|
|
@@ -14598,10 +15540,14 @@ async function connectToDaemon(isReconnect = false) {
|
|
|
14598
15540
|
try {
|
|
14599
15541
|
await daemonLifecycle.ensureRunning();
|
|
14600
15542
|
await daemonClient.connect();
|
|
14601
|
-
daemonClient.
|
|
15543
|
+
const status = await daemonClient.attachClaudeAndWaitForStatus(5000);
|
|
15544
|
+
if (!status) {
|
|
15545
|
+
throw new Error("Daemon did not confirm Claude attach.");
|
|
15546
|
+
}
|
|
15547
|
+
assertAttachedToExpectedDaemon(status);
|
|
14602
15548
|
daemonDisabledReason = null;
|
|
14603
15549
|
if (!isReconnect) {
|
|
14604
|
-
claude.pushNotification(systemMessage("system_bridge_ready"
|
|
15550
|
+
claude.pushNotification(systemMessage(status.bridgeReady ? "system_bridge_ready" : "system_bridge_waiting", initialAttachMessage(status)));
|
|
14605
15551
|
}
|
|
14606
15552
|
} catch (err) {
|
|
14607
15553
|
log(`Failed to connect to daemon: ${err.message}`);
|
|
@@ -14609,6 +15555,21 @@ async function connectToDaemon(isReconnect = false) {
|
|
|
14609
15555
|
throw err;
|
|
14610
15556
|
}
|
|
14611
15557
|
}
|
|
15558
|
+
function assertAttachedToExpectedDaemon(status) {
|
|
15559
|
+
const expectedPairId = process.env.AGENTBRIDGE_PAIR_ID || null;
|
|
15560
|
+
if (expectedPairId && status.pairId !== expectedPairId) {
|
|
15561
|
+
throw new Error(`Daemon identity mismatch after attach: expected pair ${expectedPairId}, got ${status.pairId ?? "<none>"}.`);
|
|
15562
|
+
}
|
|
15563
|
+
}
|
|
15564
|
+
function initialAttachMessage(status) {
|
|
15565
|
+
if (status.bridgeReady) {
|
|
15566
|
+
return "\u2705 AgentBridge bridge is ready. Codex TUI is connected.";
|
|
15567
|
+
}
|
|
15568
|
+
if (status.tuiConnected) {
|
|
15569
|
+
return "\u23F3 AgentBridge attached to daemon. Waiting for Codex to finish creating a thread.";
|
|
15570
|
+
}
|
|
15571
|
+
return `\u23F3 AgentBridge attached to daemon. Waiting for Codex TUI. Start Codex in another terminal with: ${pairScopedCommand("codex")}`;
|
|
15572
|
+
}
|
|
14612
15573
|
async function enterDisabledState(logMessage, notificationContent) {
|
|
14613
15574
|
if (daemonDisabled)
|
|
14614
15575
|
return;
|
|
@@ -14624,7 +15585,13 @@ var reconnectTask = null;
|
|
|
14624
15585
|
async function notifyIfDaemonKilled(logMessage) {
|
|
14625
15586
|
if (!daemonLifecycle.wasKilled())
|
|
14626
15587
|
return false;
|
|
14627
|
-
await enterDisabledState(logMessage,
|
|
15588
|
+
await enterDisabledState(logMessage, `\u26D4 AgentBridge was stopped by \`agentbridge kill\`. Bridge is staying idle. Restart Claude Code (\`${pairScopedCommand("claude")}\`), switch to a new conversation, or run \`/resume\` to reconnect.`);
|
|
15589
|
+
return true;
|
|
15590
|
+
}
|
|
15591
|
+
async function notifyIfPairRemoved(logMessage) {
|
|
15592
|
+
if (existsSync6(stateDir.dir))
|
|
15593
|
+
return false;
|
|
15594
|
+
await enterDisabledState(logMessage, `\u26D4 This pair's state directory was removed (\`abg pairs rm\` / \`prune\`). Bridge is staying idle. Start fresh with \`${pairScopedCommand("claude")}\` if you still need this pair. \u8BE5 pair \u7684\u72B6\u6001\u76EE\u5F55\u5DF2\u88AB\u5220\u9664\uFF08pairs rm / prune\uFF09\uFF0C\u6865\u63A5\u4FDD\u6301\u5F85\u673A\uFF1B\u5982\u4ECD\u9700\u8981\u8BF7\u7528 \`${pairScopedCommand("claude")}\` \u91CD\u65B0\u542F\u52A8\u3002`);
|
|
14628
15595
|
return true;
|
|
14629
15596
|
}
|
|
14630
15597
|
function reconnectToDaemon() {
|
|
@@ -14640,11 +15607,14 @@ function reconnectToDaemon() {
|
|
|
14640
15607
|
if (await notifyIfDaemonKilled("Daemon was intentionally killed by user (killed sentinel found) \u2014 not reconnecting")) {
|
|
14641
15608
|
return;
|
|
14642
15609
|
}
|
|
15610
|
+
if (await notifyIfPairRemoved("Pair state directory removed \u2014 not reconnecting")) {
|
|
15611
|
+
return;
|
|
15612
|
+
}
|
|
14643
15613
|
const delayMs = Math.min(1000 * 2 ** attempt, MAX_RECONNECT_DELAY_MS);
|
|
14644
15614
|
if (attempt > 0) {
|
|
14645
15615
|
log(`Reconnect attempt ${attempt + 1}, waiting ${delayMs}ms...`);
|
|
14646
15616
|
}
|
|
14647
|
-
await new Promise((
|
|
15617
|
+
await new Promise((resolve2) => setTimeout(resolve2, delayMs));
|
|
14648
15618
|
if (shuttingDown)
|
|
14649
15619
|
return;
|
|
14650
15620
|
if (await notifyIfDaemonKilled("Daemon was intentionally killed during reconnect backoff \u2014 not reconnecting")) {
|
|
@@ -14697,20 +15667,72 @@ async function pollDisabledRecovery() {
|
|
|
14697
15667
|
if (!healthy) {
|
|
14698
15668
|
return;
|
|
14699
15669
|
}
|
|
14700
|
-
|
|
14701
|
-
|
|
14702
|
-
|
|
14703
|
-
|
|
14704
|
-
|
|
14705
|
-
|
|
14706
|
-
|
|
14707
|
-
|
|
14708
|
-
|
|
14709
|
-
|
|
14710
|
-
|
|
14711
|
-
|
|
14712
|
-
|
|
14713
|
-
|
|
15670
|
+
const recoveredFrom = daemonDisabledReason;
|
|
15671
|
+
switch (recoveredFrom) {
|
|
15672
|
+
case "probe_in_progress": {
|
|
15673
|
+
if (disabledRecoveryAttempts >= DISABLED_RECOVERY_MAX_ATTEMPTS) {
|
|
15674
|
+
log(`Disabled-state auto-recovery gave up after ${DISABLED_RECOVERY_MAX_ATTEMPTS} attempts ` + "\u2014 switching to auto_recovery_exhausted terminal state");
|
|
15675
|
+
daemonDisabledReason = "auto_recovery_exhausted";
|
|
15676
|
+
disabledRecoveryAttempts = 0;
|
|
15677
|
+
stopDisabledRecoveryPoller();
|
|
15678
|
+
claude.pushNotification(systemMessage("system_bridge_auto_recovery_gave_up", `\u26A0\uFE0F AgentBridge auto-recovery gave up after exhausting its retry budget for the in-flight liveness probe contention. Retry manually with \`${pairScopedCommand("claude")}\`. AgentBridge \u81EA\u52A8\u6062\u590D\u5DF2\u653E\u5F03\u2014\u2014\u5B58\u6D3B\u63A2\u6D4B\u4E89\u7528\u7684\u91CD\u8BD5\u9884\u7B97\u5DF2\u7528\u5C3D\u3002\u8BF7\u4F7F\u7528 \`${pairScopedCommand("claude")}\` \u624B\u52A8\u91CD\u8BD5\u3002`));
|
|
15679
|
+
return;
|
|
15680
|
+
}
|
|
15681
|
+
disabledRecoveryAttempts += 1;
|
|
15682
|
+
log(`Disabled-state recovery attempt ${disabledRecoveryAttempts}/${DISABLED_RECOVERY_MAX_ATTEMPTS} ` + "for probe_in_progress \u2014 attempting direct daemon reconnect");
|
|
15683
|
+
try {
|
|
15684
|
+
await daemonClient.connect();
|
|
15685
|
+
const attached = await daemonClient.attachClaudeAndWaitForStatus(DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS);
|
|
15686
|
+
if (!attached) {
|
|
15687
|
+
log(`Disabled-state probe_in_progress recovery attempt ${disabledRecoveryAttempts} did not confirm readiness`);
|
|
15688
|
+
await daemonClient.disconnect();
|
|
15689
|
+
return;
|
|
15690
|
+
}
|
|
15691
|
+
daemonDisabled = false;
|
|
15692
|
+
daemonDisabledReason = null;
|
|
15693
|
+
disabledRecoveryAttempts = 0;
|
|
15694
|
+
stopDisabledRecoveryPoller();
|
|
15695
|
+
claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 AgentBridge recovered after the liveness probe completed. Daemon reconnected."));
|
|
15696
|
+
} catch (err) {
|
|
15697
|
+
log(`Disabled-state probe_in_progress recovery attempt failed: ${err.message}`);
|
|
15698
|
+
await daemonClient.disconnect();
|
|
15699
|
+
}
|
|
15700
|
+
return;
|
|
15701
|
+
}
|
|
15702
|
+
case "killed": {
|
|
15703
|
+
log("Disabled-state recovery conditions met \u2014 attempting direct daemon reconnect");
|
|
15704
|
+
try {
|
|
15705
|
+
await daemonClient.connect();
|
|
15706
|
+
const attached = await daemonClient.attachClaudeAndWaitForStatus(DISABLED_RECOVERY_CONFIRM_TIMEOUT_MS);
|
|
15707
|
+
if (!attached) {
|
|
15708
|
+
throw new Error("daemon did not confirm reconnect");
|
|
15709
|
+
}
|
|
15710
|
+
daemonDisabled = false;
|
|
15711
|
+
daemonDisabledReason = null;
|
|
15712
|
+
disabledRecoveryAttempts = 0;
|
|
15713
|
+
stopDisabledRecoveryPoller();
|
|
15714
|
+
claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 AgentBridge recovered after the killed sentinel was cleared. Daemon reconnected."));
|
|
15715
|
+
} catch (err) {
|
|
15716
|
+
log(`Disabled-state direct reconnect failed: ${err.message}`);
|
|
15717
|
+
daemonDisabled = false;
|
|
15718
|
+
daemonDisabledReason = null;
|
|
15719
|
+
disabledRecoveryAttempts = 0;
|
|
15720
|
+
stopDisabledRecoveryPoller();
|
|
15721
|
+
reconnectToDaemon();
|
|
15722
|
+
}
|
|
15723
|
+
return;
|
|
15724
|
+
}
|
|
15725
|
+
case "evicted":
|
|
15726
|
+
case "rejected":
|
|
15727
|
+
case "auto_recovery_exhausted":
|
|
15728
|
+
case null:
|
|
15729
|
+
log(`Disabled-state recovery poller encountered terminal/unexpected reason ${recoveredFrom ?? "null"} \u2014 stopping`);
|
|
15730
|
+
stopDisabledRecoveryPoller();
|
|
15731
|
+
return;
|
|
15732
|
+
default: {
|
|
15733
|
+
const exhaustive = recoveredFrom;
|
|
15734
|
+
return exhaustive;
|
|
15735
|
+
}
|
|
14714
15736
|
}
|
|
14715
15737
|
} finally {
|
|
14716
15738
|
disabledRecoveryInFlight = false;
|
|
@@ -14724,6 +15746,17 @@ function systemMessage(idPrefix, content) {
|
|
|
14724
15746
|
timestamp: Date.now()
|
|
14725
15747
|
};
|
|
14726
15748
|
}
|
|
15749
|
+
function currentClientIdentity() {
|
|
15750
|
+
return {
|
|
15751
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
15752
|
+
pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
|
|
15753
|
+
cwd: process.cwd(),
|
|
15754
|
+
baseDir: process.env.AGENTBRIDGE_BASE_DIR ?? null,
|
|
15755
|
+
stateDir: stateDir.dir,
|
|
15756
|
+
clientPid: process.pid,
|
|
15757
|
+
contractVersion: BUILD_INFO.contractVersion
|
|
15758
|
+
};
|
|
15759
|
+
}
|
|
14727
15760
|
function shutdown(reason) {
|
|
14728
15761
|
if (shuttingDown)
|
|
14729
15762
|
return;
|
|
@@ -14749,18 +15782,13 @@ process.on("exit", () => {
|
|
|
14749
15782
|
daemonClient.disconnect();
|
|
14750
15783
|
});
|
|
14751
15784
|
process.on("uncaughtException", (err) => {
|
|
14752
|
-
|
|
15785
|
+
processLogger.fatal("UNCAUGHT EXCEPTION", err);
|
|
14753
15786
|
});
|
|
14754
15787
|
process.on("unhandledRejection", (reason) => {
|
|
14755
|
-
|
|
15788
|
+
processLogger.fatal("UNHANDLED REJECTION", reason);
|
|
14756
15789
|
});
|
|
14757
15790
|
function log(msg) {
|
|
14758
|
-
|
|
14759
|
-
`;
|
|
14760
|
-
process.stderr.write(line);
|
|
14761
|
-
try {
|
|
14762
|
-
appendFileSync2(stateDir.logFile, line);
|
|
14763
|
-
} catch {}
|
|
15791
|
+
processLogger.log(msg);
|
|
14764
15792
|
}
|
|
14765
15793
|
log(`Starting AgentBridge frontend (daemon ws ${CONTROL_WS_URL})`);
|
|
14766
15794
|
(async () => {
|