@raysonmeng/agentbridge 0.1.11 → 0.1.13

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/cli.js CHANGED
@@ -17,10 +17,67 @@ var __export = (target, all) => {
17
17
  };
18
18
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
19
19
 
20
+ // src/cli-invocation.ts
21
+ import { basename } from "path";
22
+ function cliInvocationName(argv = process.argv) {
23
+ const raw = argv[1];
24
+ if (typeof raw !== "string" || raw.length === 0)
25
+ return DEFAULT_CLI_NAME;
26
+ const name = basename(raw).replace(/\.(ts|js|mjs|cjs)$/, "");
27
+ return isCliName(name) ? name : DEFAULT_CLI_NAME;
28
+ }
29
+ function isCliName(value) {
30
+ return CLI_NAMES.includes(value);
31
+ }
32
+ var CLI_NAMES, DEFAULT_CLI_NAME = "abg";
33
+ var init_cli_invocation = __esm(() => {
34
+ CLI_NAMES = ["abg", "agentbridge"];
35
+ });
36
+
37
+ // src/atomic-json.ts
38
+ import * as fs from "fs";
39
+ import { randomUUID } from "crypto";
40
+ import { dirname } from "path";
41
+ function tmpPathFor(targetPath) {
42
+ return `${targetPath}.tmp.${process.pid}.${randomUUID()}`;
43
+ }
44
+ function atomicWriteText(path, content, options = {}) {
45
+ fs.mkdirSync(dirname(path), { recursive: true });
46
+ const tmp = tmpPathFor(path);
47
+ let renamed = false;
48
+ const fd = fs.openSync(tmp, "w", options.mode ?? 438);
49
+ try {
50
+ try {
51
+ fs.writeFileSync(fd, content, "utf-8");
52
+ if (options.fsync)
53
+ fs.fsyncSync(fd);
54
+ } finally {
55
+ fs.closeSync(fd);
56
+ }
57
+ fs.renameSync(tmp, path);
58
+ renamed = true;
59
+ } finally {
60
+ if (!renamed) {
61
+ try {
62
+ fs.unlinkSync(tmp);
63
+ } catch {}
64
+ }
65
+ }
66
+ }
67
+ function atomicWriteJson(path, value, options = {}) {
68
+ atomicWriteText(path, JSON.stringify(value, null, 2) + `
69
+ `, options);
70
+ }
71
+ var init_atomic_json = () => {};
72
+
20
73
  // src/state-dir.ts
21
- import { mkdirSync, existsSync } from "fs";
74
+ import { mkdirSync as mkdirSync2, existsSync } from "fs";
22
75
  import { join } from "path";
23
76
  import { homedir, platform } from "os";
77
+ function resolveXdgStateBase(rawXdg = process.env.XDG_STATE_HOME) {
78
+ const xdgState = rawXdg && rawXdg.length > 0 ? rawXdg : join(homedir(), ".local", "state");
79
+ return join(xdgState, "agentbridge");
80
+ }
24
81
 
25
82
  class StateDirResolver {
26
83
  stateDir;
@@ -28,8 +85,7 @@ class StateDirResolver {
28
85
  if (platform() === "darwin") {
29
86
  return join(homedir(), "Library", "Application Support", "AgentBridge");
30
87
  }
31
- const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
32
- return join(xdgState, "agentbridge");
88
+ return resolveXdgStateBase(process.env.XDG_STATE_HOME);
33
89
  }
34
90
  constructor(envOverride) {
35
91
  const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
@@ -37,7 +93,7 @@ class StateDirResolver {
37
93
  }
38
94
  ensure() {
39
95
  if (!existsSync(this.stateDir)) {
40
- mkdirSync(this.stateDir, { recursive: true });
96
+ mkdirSync2(this.stateDir, { recursive: true });
41
97
  }
42
98
  }
43
99
  get dir() {
@@ -55,8 +111,8 @@ class StateDirResolver {
55
111
  get statusFile() {
56
112
  return join(this.stateDir, "status.json");
57
113
  }
58
- get portsFile() {
59
- return join(this.stateDir, "ports.json");
114
+ get daemonRecordFile() {
115
+ return join(this.stateDir, "daemon.json");
60
116
  }
61
117
  get currentThreadFile() {
62
118
  return join(this.stateDir, "current-thread.json");
@@ -120,7 +176,7 @@ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env)
120
176
  var require_package = __commonJS((exports, module) => {
121
177
  module.exports = {
122
178
  name: "@raysonmeng/agentbridge",
123
- version: "0.1.11",
179
+ version: "0.1.13",
124
180
  description: "Bridge between Claude Code and Codex \u2014 bidirectional agent communication via MCP Channel + JSON-RPC",
125
181
  type: "module",
126
182
  packageManager: "bun@1.3.11",
@@ -151,6 +207,8 @@ var require_package = __commonJS((exports, module) => {
151
207
  prepublishOnly: "bun run build:cli && bun run build:plugin && bun run verify:plugin-sync && bun scripts/check-plugin-versions.js",
152
208
  "validate:plugin": "claude plugin validate plugins/agentbridge && claude plugin validate .claude-plugin/marketplace.json",
153
209
  test: "bun test src",
210
+ "test:unit": "bun test src/unit-test",
211
+ "test:integration": "bun test src/integration-test",
154
212
  "e2e:transport": "bun scripts/e2e-codex-transport.mjs",
155
213
  "install:global": "node scripts/install-global.mjs local",
156
214
  "install:global:local": "node scripts/install-global.mjs local",
@@ -199,7 +257,7 @@ __export(exports_update_notifier, {
199
257
  buildUpdateNotice: () => buildUpdateNotice,
200
258
  PACKAGE_NAME: () => PACKAGE_NAME
201
259
  });
202
- import { readFileSync, writeFileSync } from "fs";
260
+ import { readFileSync } from "fs";
203
261
  function getCurrentVersion() {
204
262
  try {
205
263
  return require_package().version;
@@ -235,9 +293,7 @@ function readCache(stateDir) {
235
293
  }
236
294
  function writeCache(stateDir, cache) {
237
295
  try {
238
- stateDir.ensure();
239
- writeFileSync(stateDir.updateCheckFile, JSON.stringify(cache, null, 2) + `
240
- `, "utf-8");
296
+ atomicWriteJson(stateDir.updateCheckFile, cache);
241
297
  } catch {}
242
298
  }
243
299
  function parseLatestFromRegistry(body) {
@@ -311,6 +367,7 @@ function maybeNotifyUpdate(deps = {}) {
311
367
  }
312
368
  var PACKAGE_NAME = "@raysonmeng/agentbridge", REGISTRY_URL, ABBREVIATED_ACCEPT = "application/vnd.npm.install-v1+json", DEFAULT_CHECK_INTERVAL_MS, FETCH_TIMEOUT_MS = 2500, CHECK_INTERVAL_ENV = "AGENTBRIDGE_UPDATE_CHECK_INTERVAL_MS";
313
369
  var init_update_notifier = __esm(() => {
370
+ init_atomic_json();
314
371
  init_state_dir();
315
372
  init_version_utils();
316
373
  REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`;
@@ -318,11 +375,53 @@ var init_update_notifier = __esm(() => {
318
375
  });
319
376
 
320
377
  // src/config-service.ts
321
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
378
+ import { readFileSync as readFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync2 } from "fs";
322
379
  import { join as join2 } from "path";
323
380
  function isRecord(value) {
324
381
  return typeof value === "object" && value !== null && !Array.isArray(value);
325
382
  }
383
+ function isCoercibleNumber(value) {
384
+ if (typeof value === "number")
385
+ return Number.isFinite(value);
386
+ if (typeof value === "string")
387
+ return Number.isFinite(Number(value));
388
+ return false;
389
+ }
390
+ function findShapeViolation(raw) {
391
+ if ("idleShutdownSeconds" in raw && !isCoercibleNumber(raw.idleShutdownSeconds)) {
392
+ return "idleShutdownSeconds is present but not a number";
393
+ }
394
+ if ("budget" in raw) {
395
+ const budget = raw.budget;
396
+ if (!isRecord(budget)) {
397
+ return "budget is present but not an object";
398
+ }
399
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
400
+ for (const key of numericKeys) {
401
+ if (key in budget && !isCoercibleNumber(budget[key])) {
402
+ return `budget.${key} is present but not a number`;
403
+ }
404
+ }
405
+ if ("parallel" in budget) {
406
+ const parallel = budget.parallel;
407
+ if (!isRecord(parallel)) {
408
+ return "budget.parallel is present but not an object";
409
+ }
410
+ for (const key of ["minRemainingPct", "timeWindowSec"]) {
411
+ if (key in parallel && !isCoercibleNumber(parallel[key])) {
412
+ return `budget.parallel.${key} is present but not a number`;
413
+ }
414
+ }
415
+ }
416
+ }
417
+ return null;
418
+ }
419
+ function hasCustomDecisionValues(config) {
420
+ const d = DEFAULT_CONFIG;
421
+ const b = config.budget;
422
+ const db = d.budget;
423
+ return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl;
424
+ }
326
425
  function normalizeInteger(value, fallback) {
327
426
  if (typeof value === "number" && Number.isFinite(value))
328
427
  return value;
@@ -358,35 +457,35 @@ function normalizeCodexOverride(raw) {
358
457
  override.effort = raw.effort.trim();
359
458
  return Object.keys(override).length > 0 ? override : null;
360
459
  }
361
- function normalizeCodexTiers(raw) {
460
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
362
461
  const tiers = isRecord(raw) ? raw : {};
363
462
  return {
364
463
  full: normalizeCodexOverride(tiers.full),
365
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
366
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
464
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
465
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
367
466
  };
368
467
  }
369
- function normalizeBudgetConfig(raw) {
468
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
370
469
  const budget = isRecord(raw) ? raw : {};
371
470
  const parallel = isRecord(budget.parallel) ? budget.parallel : {};
372
- const codexTiers = normalizeCodexTiers(budget.codexTiers);
373
- let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
374
- let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
471
+ const codexTiers = normalizeCodexTiers(budget.codexTiers, fallback.codexTiers);
472
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, fallback.pauseAt, 1, 100);
473
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, fallback.resumeBelow, 0, 99);
375
474
  if (pauseAt <= resumeBelow) {
376
475
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
377
476
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
378
477
  }
379
478
  return {
380
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
381
- pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
479
+ enabled: normalizeBoolean(budget.enabled, fallback.enabled),
480
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
382
481
  pauseAt,
383
482
  resumeBelow,
384
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
483
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
385
484
  parallel: {
386
- minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
387
- timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
485
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
486
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
388
487
  },
389
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
488
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
390
489
  codexTiers
391
490
  };
392
491
  }
@@ -400,13 +499,13 @@ function normalizeConfig(raw) {
400
499
  return {
401
500
  version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version,
402
501
  codex: {
403
- appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
404
- proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
502
+ appPort: normalizeBoundedInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort, 1, 65535),
503
+ proxyPort: normalizeBoundedInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort, 1, 65535)
405
504
  },
406
505
  turnCoordination: {
407
- attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
506
+ attentionWindowSeconds: normalizeBoundedInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds, 0, Number.MAX_SAFE_INTEGER)
408
507
  },
409
- idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
508
+ idleShutdownSeconds: normalizeBoundedInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds, 1, Number.MAX_SAFE_INTEGER),
410
509
  budget: normalizeBudgetConfig(config.budget)
411
510
  };
412
511
  }
@@ -423,20 +522,62 @@ class ConfigService {
423
522
  return existsSync2(this.configPath);
424
523
  }
425
524
  load() {
525
+ let raw;
426
526
  try {
427
- const raw = readFileSync2(this.configPath, "utf-8");
428
- return normalizeConfig(JSON.parse(raw));
429
- } catch {
430
- return null;
527
+ raw = readFileSync2(this.configPath, "utf-8");
528
+ } catch (err) {
529
+ if (err?.code === "ENOENT") {
530
+ return { state: "absent" };
531
+ }
532
+ return { state: "corrupt", reason: `config.json is unreadable: ${err.message}` };
533
+ }
534
+ let parsed;
535
+ try {
536
+ parsed = JSON.parse(raw);
537
+ } catch (err) {
538
+ return {
539
+ state: "corrupt",
540
+ reason: `config.json is not valid JSON: ${err.message}`
541
+ };
542
+ }
543
+ if (!isRecord(parsed)) {
544
+ return { state: "corrupt", reason: "config.json is not a JSON object" };
545
+ }
546
+ const violation = findShapeViolation(parsed);
547
+ if (violation) {
548
+ return { state: "corrupt", reason: `config.json is shape-invalid: ${violation}` };
549
+ }
550
+ const config = normalizeConfig(parsed);
551
+ if (!config) {
552
+ return { state: "corrupt", reason: "config.json could not be normalized" };
431
553
  }
554
+ return { state: "parsed", config };
432
555
  }
433
- loadOrDefault() {
434
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
556
+ loadOrDefault(log = NOOP_LOGGER) {
557
+ const result = this.load();
558
+ if (result.state === "parsed")
559
+ return result.config;
560
+ if (result.state === "corrupt") {
561
+ log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget thresholds / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them.");
562
+ }
563
+ return structuredClone(DEFAULT_CONFIG);
564
+ }
565
+ describeConfig() {
566
+ const result = this.load();
567
+ if (result.state === "absent") {
568
+ return { state: "absent", path: this.configPath, customValues: false };
569
+ }
570
+ if (result.state === "corrupt") {
571
+ return { state: "corrupt", path: this.configPath, reason: result.reason, customValues: false };
572
+ }
573
+ return {
574
+ state: "parsed",
575
+ path: this.configPath,
576
+ customValues: hasCustomDecisionValues(result.config)
577
+ };
435
578
  }
436
579
  save(config) {
437
- this.ensureConfigDir();
438
- writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
439
- `, "utf-8");
580
+ atomicWriteJson(this.configPath, config);
440
581
  }
441
582
  initDefaults() {
442
583
  this.ensureConfigDir();
@@ -452,15 +593,16 @@ class ConfigService {
452
593
  }
453
594
  ensureConfigDir() {
454
595
  if (!existsSync2(this.configDir)) {
455
- mkdirSync2(this.configDir, { recursive: true });
596
+ mkdirSync3(this.configDir, { recursive: true });
456
597
  }
457
598
  }
458
599
  }
459
- var DEFAULT_BUDGET_CONFIG, DEFAULT_CONFIG, CONFIG_DIR = ".agentbridge", CONFIG_FILE = "config.json";
600
+ var DEFAULT_BUDGET_CONFIG, DEFAULT_CONFIG, CONFIG_DIR = ".agentbridge", CONFIG_FILE = "config.json", NOOP_LOGGER = () => {};
460
601
  var init_config_service = __esm(() => {
602
+ init_atomic_json();
461
603
  DEFAULT_BUDGET_CONFIG = {
462
604
  enabled: true,
463
- pollSeconds: 60,
605
+ pollSeconds: 300,
464
606
  pauseAt: 90,
465
607
  resumeBelow: 30,
466
608
  syncDriftPct: 10,
@@ -490,7 +632,7 @@ var init_config_service = __esm(() => {
490
632
  });
491
633
 
492
634
  // src/cli/pkg-root.ts
493
- import { dirname, join as join3 } from "path";
635
+ import { dirname as dirname2, join as join3 } from "path";
494
636
  import { existsSync as existsSync3 } from "fs";
495
637
  import { execFileSync } from "child_process";
496
638
  function findPackageRoot() {
@@ -499,7 +641,7 @@ function findPackageRoot() {
499
641
  if (existsSync3(join3(dir, "package.json"))) {
500
642
  return dir;
501
643
  }
502
- const parent = dirname(dir);
644
+ const parent = dirname2(dir);
503
645
  if (parent === dir) {
504
646
  throw new Error("Could not find package.json in any parent directory");
505
647
  }
@@ -513,6 +655,30 @@ function registerMarketplace(marketplaceRoot) {
513
655
  }
514
656
  var init_pkg_root = () => {};
515
657
 
658
+ // src/cli/plugin-cache.ts
659
+ import { existsSync as existsSync4 } from "fs";
660
+ import { homedir as homedir2 } from "os";
661
+ import { join as join4, resolve } from "path";
662
+ function pluginCacheRoot(home = homedir2()) {
663
+ return join4(home, ".claude", "plugins", "cache", MARKETPLACE_NAME, PLUGIN_NAME);
664
+ }
665
+ function isInsideRepoCheckout(projectRoot) {
666
+ const buildScript = resolve(projectRoot, "scripts", "build-bundles.mjs");
667
+ return existsSync4(buildScript);
668
+ }
669
+ function shouldWarnMissingPluginCache(cacheExists) {
670
+ return !cacheExists;
671
+ }
672
+ var MARKETPLACE_STEPS;
673
+ var init_plugin_cache = __esm(() => {
674
+ init_cli();
675
+ MARKETPLACE_STEPS = [
676
+ `/plugin marketplace add raysonmeng/agent-bridge`,
677
+ `/plugin install ${PLUGIN_NAME}@${MARKETPLACE_NAME}`,
678
+ `/reload-plugins`
679
+ ];
680
+ });
681
+
516
682
  // src/marker-section.ts
517
683
  function upsertMarkedSection(content, sectionId, section) {
518
684
  const startMarker = MARKER_START(sectionId);
@@ -649,18 +815,25 @@ var exports_init = {};
649
815
  __export(exports_init, {
650
816
  writeCollaborationSections: () => writeCollaborationSections,
651
817
  runInit: () => runInit,
818
+ pluginInstallFallbackGuidance: () => pluginInstallFallbackGuidance,
819
+ formatDepChecks: () => formatDepChecks,
652
820
  compareVersions: () => compareVersions
653
821
  });
654
822
  import { execSync, execFileSync as execFileSync2 } from "child_process";
655
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
656
- import { join as join4 } from "path";
823
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
824
+ import { join as join5 } from "path";
657
825
  async function runInit() {
658
826
  console.log(`AgentBridge Init
659
827
  `);
828
+ const cli = cliInvocationName();
660
829
  console.log("Checking dependencies...");
661
- checkBun();
662
- checkClaude();
663
- checkCodex();
830
+ const depChecks = [checkBun(), checkClaude(), checkCodex()];
831
+ for (const line of formatDepChecks(depChecks, cli)) {
832
+ console.log(line);
833
+ }
834
+ if (depChecks.some((check) => check.status === "fail")) {
835
+ process.exit(1);
836
+ }
664
837
  console.log("");
665
838
  console.log("Generating project config...");
666
839
  const configService = new ConfigService;
@@ -681,72 +854,123 @@ async function runInit() {
681
854
  }
682
855
  console.log("");
683
856
  console.log("Installing AgentBridge plugin...");
857
+ let pluginInstalled = false;
684
858
  try {
685
- registerMarketplace(findPackageRoot());
859
+ const packageRoot = findPackageRoot();
860
+ registerMarketplace(packageRoot);
686
861
  execFileSync2("claude", ["plugin", "install", `${PLUGIN_NAME}@${MARKETPLACE_NAME}`], {
687
862
  stdio: "inherit"
688
863
  });
689
864
  console.log(" Plugin installed successfully.");
865
+ pluginInstalled = true;
690
866
  } catch {
691
867
  console.log(" Plugin install skipped (marketplace registration or install failed).");
692
- console.log(" You can install it later with:");
693
- console.log(` abg dev # registers marketplace and installs plugin`);
868
+ for (const line of pluginInstallFallbackGuidance(detectRepoCheckout(), cli)) {
869
+ console.log(line);
870
+ }
694
871
  }
695
872
  console.log("");
696
- console.log(`Setup complete!
873
+ if (pluginInstalled) {
874
+ console.log(`Setup complete!
875
+ `);
876
+ } else {
877
+ console.log(`Setup incomplete \u2014 plugin not installed.
697
878
  `);
879
+ process.exitCode = 1;
880
+ }
698
881
  console.log("Next steps:");
699
882
  console.log(" 1. If Claude Code is already running, execute /reload-plugins in your session");
700
- console.log(" 2. Start Claude Code: agentbridge claude");
701
- console.log(" 3. Start Codex TUI: agentbridge codex");
883
+ console.log(` 2. Start Claude Code: ${cli} claude`);
884
+ console.log(` 3. Start Codex TUI: ${cli} codex`);
885
+ }
886
+ function detectRepoCheckout() {
887
+ try {
888
+ return isInsideRepoCheckout(findPackageRoot());
889
+ } catch {
890
+ return false;
891
+ }
892
+ }
893
+ function pluginInstallFallbackGuidance(insideRepo, cli = cliInvocationName()) {
894
+ if (insideRepo) {
895
+ return [
896
+ " You can install it later with:",
897
+ ` ${cli} dev # registers marketplace and installs plugin`
898
+ ];
899
+ }
900
+ return [
901
+ " Install the plugin from Claude Code with these steps:",
902
+ ...MARKETPLACE_STEPS.map((step) => ` ${step}`)
903
+ ];
904
+ }
905
+ function formatDepChecks(checks, cli) {
906
+ const lines = [];
907
+ for (const check of checks) {
908
+ lines.push(` ${check.status.toUpperCase().padEnd(4)} ${check.name}: ${check.detail}`);
909
+ if ((check.status === "warn" || check.status === "fail") && check.hint) {
910
+ lines.push(` \u21B3 ${check.hint}`);
911
+ }
912
+ }
913
+ lines.push(` \u9A8C\u8BC1\u5B89\u88C5: ${cli} doctor`);
914
+ return lines;
702
915
  }
703
916
  function checkBun() {
704
917
  try {
705
918
  const version = execSync("bun --version", { encoding: "utf-8" }).trim();
706
- console.log(` bun: ${version}`);
919
+ return { name: "bun", status: "ok", detail: version };
707
920
  } catch {
708
- console.error(" ERROR: bun not found in PATH.");
709
- console.error(" Install Bun: https://bun.sh");
710
- process.exit(1);
921
+ return {
922
+ name: "bun",
923
+ status: "fail",
924
+ detail: "not found in PATH",
925
+ hint: "Install Bun: https://bun.sh"
926
+ };
711
927
  }
712
928
  }
713
929
  function checkClaude() {
930
+ let versionOutput;
714
931
  try {
715
- const versionOutput = execSync("claude --version", { encoding: "utf-8" }).trim();
716
- const match = versionOutput.match(/(\d+\.\d+\.\d+)/);
717
- if (match) {
718
- const version = match[1];
719
- console.log(` claude: ${version}`);
720
- if (compareVersions(version, MIN_CLAUDE_VERSION) < 0) {
721
- console.error(` ERROR: Claude Code version ${version} is too old.`);
722
- console.error(` Channels require >= ${MIN_CLAUDE_VERSION}.`);
723
- console.error(" Update: npm update -g @anthropic-ai/claude-code");
724
- process.exit(1);
725
- }
726
- } else {
727
- console.log(` claude: ${versionOutput} (version check skipped)`);
728
- }
932
+ versionOutput = execSync("claude --version", { encoding: "utf-8" }).trim();
729
933
  } catch {
730
- console.error(" ERROR: claude not found in PATH.");
731
- console.error(" Install Claude Code: npm install -g @anthropic-ai/claude-code");
732
- process.exit(1);
934
+ return {
935
+ name: "claude",
936
+ status: "fail",
937
+ detail: "not found in PATH",
938
+ hint: "Install Claude Code: npm install -g @anthropic-ai/claude-code"
939
+ };
940
+ }
941
+ const match = versionOutput.match(/(\d+\.\d+\.\d+)/);
942
+ if (!match) {
943
+ return { name: "claude", status: "ok", detail: `${versionOutput} (version check skipped)` };
944
+ }
945
+ const version = match[1];
946
+ if (compareVersions(version, MIN_CLAUDE_VERSION) < 0) {
947
+ return {
948
+ name: "claude",
949
+ status: "fail",
950
+ detail: `${version} is too old (channels require >= ${MIN_CLAUDE_VERSION})`,
951
+ hint: "Update: npm update -g @anthropic-ai/claude-code"
952
+ };
733
953
  }
954
+ return { name: "claude", status: "ok", detail: version };
734
955
  }
735
956
  function checkCodex() {
736
957
  try {
737
958
  const version = execSync("codex --version", { encoding: "utf-8" }).trim();
738
- console.log(` codex: ${version}`);
959
+ return { name: "codex", status: "ok", detail: version };
739
960
  } catch {
740
- console.error(" ERROR: codex not found in PATH.");
741
- console.error(" Install Codex: https://github.com/openai/codex");
742
- process.exit(1);
961
+ return {
962
+ name: "codex",
963
+ status: "warn",
964
+ detail: "not found in PATH (the Codex side will be unavailable until installed)",
965
+ hint: "Install Codex when you want to pair: https://github.com/openai/codex"
966
+ };
743
967
  }
744
968
  }
745
969
  function writeCollaborationSections(projectRoot) {
746
970
  const results = [];
747
971
  const files = [
748
- { name: "CLAUDE.md", path: join4(projectRoot, "CLAUDE.md"), section: CLAUDE_MD_SECTION },
749
- { name: "AGENTS.md", path: join4(projectRoot, "AGENTS.md"), section: AGENTS_MD_SECTION }
972
+ { name: "CLAUDE.md", path: join5(projectRoot, "CLAUDE.md"), section: CLAUDE_MD_SECTION },
973
+ { name: "AGENTS.md", path: join5(projectRoot, "AGENTS.md"), section: AGENTS_MD_SECTION }
750
974
  ];
751
975
  for (const { name, path, section } of files) {
752
976
  let existing = "";
@@ -765,7 +989,7 @@ function writeCollaborationSections(projectRoot) {
765
989
  results.push(`${name}: unchanged (section already up to date)`);
766
990
  continue;
767
991
  }
768
- writeFileSync3(path, updated, "utf-8");
992
+ writeFileSync2(path, updated, "utf-8");
769
993
  if (existing === "") {
770
994
  results.push(`${name}: created with collaboration section`);
771
995
  } else if (existing.includes(`<!-- ${MARKER_ID}:start -->`)) {
@@ -778,9 +1002,11 @@ function writeCollaborationSections(projectRoot) {
778
1002
  }
779
1003
  var MIN_CLAUDE_VERSION = "2.1.80";
780
1004
  var init_init = __esm(() => {
1005
+ init_cli_invocation();
781
1006
  init_config_service();
782
1007
  init_cli();
783
1008
  init_pkg_root();
1009
+ init_plugin_cache();
784
1010
  init_version_utils();
785
1011
  });
786
1012
 
@@ -790,19 +1016,17 @@ __export(exports_dev, {
790
1016
  runDev: () => runDev
791
1017
  });
792
1018
  import { execFileSync as execFileSync3, spawnSync } from "child_process";
793
- import { resolve } from "path";
794
- import { existsSync as existsSync4, cpSync, rmSync } from "fs";
795
- import { homedir as homedir2 } from "os";
1019
+ import { resolve as resolve2 } from "path";
1020
+ import { existsSync as existsSync5, cpSync, rmSync } from "fs";
796
1021
  async function runDev(args = []) {
797
1022
  console.log(`AgentBridge Dev Setup
798
1023
  `);
799
1024
  const skipBuild = args.includes("--skip-build");
800
1025
  const projectRoot = findPackageRoot();
801
- const marketplacePath = resolve(projectRoot, ".claude-plugin", "marketplace.json");
802
- const pluginDir = resolve(projectRoot, "plugins", "agentbridge");
803
- const pluginManifest = resolve(pluginDir, ".claude-plugin", "plugin.json");
804
- const buildScript = resolve(projectRoot, "scripts", "build-bundles.mjs");
805
- if (!existsSync4(buildScript)) {
1026
+ const marketplacePath = resolve2(projectRoot, ".claude-plugin", "marketplace.json");
1027
+ const pluginDir = resolve2(projectRoot, "plugins", "agentbridge");
1028
+ const pluginManifest = resolve2(pluginDir, ".claude-plugin", "plugin.json");
1029
+ if (!isInsideRepoCheckout(projectRoot)) {
806
1030
  console.error(" ERROR: 'agentbridge dev' must run inside an AgentBridge repository checkout \u2014");
807
1031
  console.error(" the published package does not ship the build scripts.");
808
1032
  console.error("");
@@ -839,12 +1063,12 @@ async function runDev(args = []) {
839
1063
  console.log(` \u2713 Plugin built successfully
840
1064
  `);
841
1065
  }
842
- if (!existsSync4(pluginManifest)) {
1066
+ if (!existsSync5(pluginManifest)) {
843
1067
  console.error(` ERROR: Plugin manifest not found at ${pluginManifest}`);
844
1068
  console.error(" Run 'bun run build:plugin' first, or check your working tree.");
845
1069
  process.exit(1);
846
1070
  }
847
- if (!existsSync4(marketplacePath)) {
1071
+ if (!existsSync5(marketplacePath)) {
848
1072
  console.error(` ERROR: Marketplace manifest not found at ${marketplacePath}`);
849
1073
  process.exit(1);
850
1074
  }
@@ -873,12 +1097,12 @@ Installing plugin...`);
873
1097
  }
874
1098
  console.log(`
875
1099
  Syncing local plugin to cache...`);
876
- const cacheDir = resolve(homedir2(), ".claude", "plugins", "cache", MARKETPLACE_NAME, PLUGIN_NAME);
877
- if (existsSync4(cacheDir)) {
1100
+ const cacheDir = pluginCacheRoot();
1101
+ if (existsSync5(cacheDir)) {
878
1102
  const versionDirs = Bun.spawnSync(["ls", cacheDir]).stdout.toString().trim().split(`
879
1103
  `).filter(Boolean);
880
1104
  for (const ver of versionDirs) {
881
- const targetDir = resolve(cacheDir, ver);
1105
+ const targetDir = resolve2(cacheDir, ver);
882
1106
  rmSync(targetDir, { recursive: true, force: true });
883
1107
  cpSync(pluginDir, targetDir, { recursive: true });
884
1108
  console.log(` Synced to ${targetDir}`);
@@ -898,22 +1122,101 @@ Syncing local plugin to cache...`);
898
1122
  var init_dev = __esm(() => {
899
1123
  init_cli();
900
1124
  init_pkg_root();
1125
+ init_plugin_cache();
901
1126
  });
902
1127
 
903
1128
  // src/control-protocol.ts
904
- var CLOSE_CODE_REPLACED = 4001, CLOSE_CODE_EVICTED_STALE = 4002, CLOSE_CODE_PROBE_IN_PROGRESS = 4003, CLOSE_CODE_PAIR_MISMATCH = 4004;
1129
+ var CLOSE_CODE_REPLACED = 4001, CLOSE_CODE_EVICTED_STALE = 4002, CLOSE_CODE_PROBE_IN_PROGRESS = 4003, CLOSE_CODE_PAIR_MISMATCH = 4004, CLOSE_CODE_TOKEN_MISMATCH = 4005, CLOSE_CODE_CONTRACT_MISMATCH = 4006;
1130
+
1131
+ // src/interrupt-timing.ts
1132
+ var CLIENT_REPLY_TIMEOUT_MS = 15000, INTERRUPT_CLIENT_MARGIN_MS = 2000, MAX_INTERRUPT_TIMEOUT_MS;
1133
+ var init_interrupt_timing = __esm(() => {
1134
+ MAX_INTERRUPT_TIMEOUT_MS = CLIENT_REPLY_TIMEOUT_MS - INTERRUPT_CLIENT_MARGIN_MS;
1135
+ });
1136
+
1137
+ // src/pending-request-registry.ts
1138
+ class PendingRequestRegistry {
1139
+ entries = new Map;
1140
+ setTimer;
1141
+ clearTimer;
1142
+ constructor(deps = {}) {
1143
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
1144
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
1145
+ }
1146
+ get size() {
1147
+ return this.entries.size;
1148
+ }
1149
+ has(id) {
1150
+ return this.entries.has(id);
1151
+ }
1152
+ register(id, options) {
1153
+ const existing = this.entries.get(id);
1154
+ if (existing) {
1155
+ this.clearTimer(existing.timer);
1156
+ this.entries.delete(id);
1157
+ }
1158
+ return new Promise((resolve3, reject) => {
1159
+ const timer = this.setTimer(() => {
1160
+ if (!this.entries.has(id))
1161
+ return;
1162
+ this.entries.delete(id);
1163
+ options.onTimeout({ resolve: resolve3, reject });
1164
+ }, options.timeoutMs);
1165
+ if (options.unref) {
1166
+ timer.unref?.();
1167
+ }
1168
+ this.entries.set(id, { resolve: resolve3, reject, timer });
1169
+ });
1170
+ }
1171
+ settle(id, value) {
1172
+ const entry = this.entries.get(id);
1173
+ if (!entry)
1174
+ return false;
1175
+ this.clearTimer(entry.timer);
1176
+ this.entries.delete(id);
1177
+ entry.resolve(value);
1178
+ return true;
1179
+ }
1180
+ reject(id, error) {
1181
+ const entry = this.entries.get(id);
1182
+ if (!entry)
1183
+ return false;
1184
+ this.clearTimer(entry.timer);
1185
+ this.entries.delete(id);
1186
+ entry.reject(error);
1187
+ return true;
1188
+ }
1189
+ settleAll(value) {
1190
+ const make = typeof value === "function" ? value : () => value;
1191
+ for (const [id, entry] of this.entries) {
1192
+ this.clearTimer(entry.timer);
1193
+ this.entries.delete(id);
1194
+ entry.resolve(make(id));
1195
+ }
1196
+ }
1197
+ rejectAll(error) {
1198
+ const make = typeof error === "function" ? error : () => error;
1199
+ for (const [id, entry] of this.entries) {
1200
+ this.clearTimer(entry.timer);
1201
+ this.entries.delete(id);
1202
+ entry.reject(make(id));
1203
+ }
1204
+ }
1205
+ }
905
1206
 
906
1207
  // src/daemon-client.ts
907
1208
  import { EventEmitter } from "events";
908
1209
  var nextSocketId = 0, DaemonClient;
909
1210
  var init_daemon_client = __esm(() => {
1211
+ init_interrupt_timing();
910
1212
  DaemonClient = class DaemonClient extends EventEmitter {
911
1213
  url;
912
1214
  options;
913
1215
  ws = null;
914
1216
  wsId = 0;
915
1217
  nextRequestId = 1;
916
- pendingReplies = new Map;
1218
+ pendingReplies = new PendingRequestRegistry;
1219
+ pendingEventWaiters = new PendingRequestRegistry;
917
1220
  constructor(url, options = {}) {
918
1221
  super();
919
1222
  this.url = url;
@@ -933,7 +1236,7 @@ var init_daemon_client = __esm(() => {
933
1236
  this.ws = null;
934
1237
  }
935
1238
  const socketId = ++nextSocketId;
936
- await new Promise((resolve2, reject) => {
1239
+ await new Promise((resolve3, reject) => {
937
1240
  const ws = new WebSocket(this.url);
938
1241
  let settled = false;
939
1242
  ws.onopen = () => {
@@ -942,7 +1245,7 @@ var init_daemon_client = __esm(() => {
942
1245
  this.wsId = socketId;
943
1246
  this.attachSocketHandlers(ws, socketId);
944
1247
  this.log(`ws#${socketId} opened and attached`);
945
- resolve2();
1248
+ resolve3();
946
1249
  };
947
1250
  ws.onerror = () => {
948
1251
  if (settled)
@@ -959,82 +1262,73 @@ var init_daemon_client = __esm(() => {
959
1262
  });
960
1263
  }
961
1264
  attachClaude() {
1265
+ const identity = this.resolveIdentity();
962
1266
  this.send({
963
1267
  type: "claude_connect",
964
- ...this.options.identity ? { identity: this.options.identity } : {}
1268
+ ...identity ? { identity } : {}
965
1269
  });
966
1270
  }
1271
+ resolveIdentity() {
1272
+ const opt = this.options.identity;
1273
+ return typeof opt === "function" ? opt() : opt;
1274
+ }
967
1275
  async attachClaudeAndWaitForStatus(timeoutMs = 1000) {
968
1276
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
969
1277
  return null;
970
1278
  }
971
- return await new Promise((resolve2) => {
972
- let settled = false;
973
- let timer = null;
974
- const cleanup = () => {
975
- if (settled)
976
- return;
977
- settled = true;
978
- if (timer) {
979
- clearTimeout(timer);
980
- timer = null;
981
- }
982
- this.off("status", onStatus);
983
- this.off("rejected", onRejected);
984
- this.off("disconnect", onDisconnect);
985
- };
986
- const finish = (value) => {
987
- cleanup();
988
- resolve2(value);
989
- };
990
- const onStatus = (status) => finish(status);
991
- const onRejected = () => finish(null);
992
- const onDisconnect = () => finish(null);
993
- this.on("status", onStatus);
994
- this.on("rejected", onRejected);
995
- this.on("disconnect", onDisconnect);
996
- timer = setTimeout(() => {
997
- finish(null);
998
- }, timeoutMs);
999
- try {
1000
- this.attachClaude();
1001
- } catch {
1002
- finish(null);
1003
- }
1279
+ return this.awaitTypedResponse({
1280
+ key: "status",
1281
+ successEvent: "status",
1282
+ successValue: (status) => status,
1283
+ failValue: null,
1284
+ timeoutMs,
1285
+ send: () => this.attachClaude()
1004
1286
  });
1005
1287
  }
1006
1288
  async probeIncumbent(timeoutMs = 3000) {
1007
1289
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1008
1290
  return { connected: false, alive: false };
1009
1291
  }
1010
- return await new Promise((resolve2) => {
1011
- let settled = false;
1012
- let timer = null;
1013
- const finish = (value) => {
1014
- if (settled)
1015
- return;
1016
- settled = true;
1017
- if (timer)
1018
- clearTimeout(timer);
1019
- this.off("incumbentStatus", onStatus);
1020
- this.off("disconnect", onDisconnect);
1021
- this.off("rejected", onRejected);
1022
- resolve2(value);
1023
- };
1024
- const onStatus = (s) => finish(s);
1025
- const onDisconnect = () => finish({ connected: false, alive: false });
1026
- const onRejected = () => finish({ connected: false, alive: false });
1027
- this.on("incumbentStatus", onStatus);
1028
- this.on("disconnect", onDisconnect);
1029
- this.on("rejected", onRejected);
1030
- timer = setTimeout(() => finish({ connected: false, alive: false }), timeoutMs);
1031
- try {
1032
- this.send({ type: "probe_incumbent" });
1033
- } catch {
1034
- finish({ connected: false, alive: false });
1035
- }
1292
+ return this.awaitTypedResponse({
1293
+ key: "incumbent_status",
1294
+ successEvent: "incumbentStatus",
1295
+ successValue: (s) => s,
1296
+ failValue: { connected: false, alive: false },
1297
+ timeoutMs,
1298
+ send: () => this.send({ type: "probe_incumbent" })
1036
1299
  });
1037
1300
  }
1301
+ awaitTypedResponse(opts) {
1302
+ const { key, successEvent, successValue, failValue, timeoutMs, send } = opts;
1303
+ const onSuccess = (payload) => {
1304
+ this.pendingEventWaiters.settle(key, successValue(payload));
1305
+ };
1306
+ const onRejected = () => {
1307
+ this.pendingEventWaiters.settle(key, failValue);
1308
+ };
1309
+ const onDisconnect = () => {
1310
+ this.pendingEventWaiters.settle(key, failValue);
1311
+ };
1312
+ const pending = this.pendingEventWaiters.register(key, {
1313
+ timeoutMs,
1314
+ onTimeout: ({ resolve: resolve3 }) => resolve3(failValue)
1315
+ });
1316
+ const cleanup = () => {
1317
+ this.off(successEvent, onSuccess);
1318
+ this.off("rejected", onRejected);
1319
+ this.off("disconnect", onDisconnect);
1320
+ };
1321
+ pending.finally(cleanup);
1322
+ this.on(successEvent, onSuccess);
1323
+ this.on("rejected", onRejected);
1324
+ this.on("disconnect", onDisconnect);
1325
+ try {
1326
+ send();
1327
+ } catch {
1328
+ this.pendingEventWaiters.settle(key, failValue);
1329
+ }
1330
+ return pending;
1331
+ }
1038
1332
  async disconnect() {
1039
1333
  if (!this.ws)
1040
1334
  return;
@@ -1047,25 +1341,24 @@ var init_daemon_client = __esm(() => {
1047
1341
  this.ws = null;
1048
1342
  this.rejectPendingReplies("Daemon connection closed");
1049
1343
  }
1050
- async sendReply(message, requireReply, onBusy) {
1344
+ async sendReply(message, requireReply, onBusy, idempotencyKey) {
1051
1345
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1052
1346
  return { success: false, error: "AgentBridge daemon is not connected." };
1053
1347
  }
1054
1348
  const requestId = `reply_${Date.now()}_${this.nextRequestId++}`;
1055
- return new Promise((resolve2) => {
1056
- const timer = setTimeout(() => {
1057
- this.pendingReplies.delete(requestId);
1058
- resolve2({ success: false, error: "Timed out waiting for AgentBridge daemon reply." });
1059
- }, 15000);
1060
- this.pendingReplies.set(requestId, { resolve: resolve2, timer });
1061
- this.send({
1062
- type: "claude_to_codex",
1063
- requestId,
1064
- message,
1065
- ...requireReply ? { requireReply: true } : {},
1066
- ...onBusy && onBusy !== "reject" ? { onBusy } : {}
1067
- });
1349
+ const pending = this.pendingReplies.register(requestId, {
1350
+ timeoutMs: CLIENT_REPLY_TIMEOUT_MS,
1351
+ onTimeout: ({ resolve: resolve3 }) => resolve3({ success: false, error: "Timed out waiting for AgentBridge daemon reply." })
1068
1352
  });
1353
+ this.send({
1354
+ type: "claude_to_codex",
1355
+ requestId,
1356
+ message,
1357
+ ...requireReply ? { requireReply: true } : {},
1358
+ ...onBusy && onBusy !== "reject" ? { onBusy } : {},
1359
+ ...idempotencyKey ? { idempotencyKey } : {}
1360
+ });
1361
+ return pending;
1069
1362
  }
1070
1363
  attachSocketHandlers(ws, socketId) {
1071
1364
  ws.onmessage = (event) => {
@@ -1081,14 +1374,23 @@ var init_daemon_client = __esm(() => {
1081
1374
  this.emit("codexMessage", message.message);
1082
1375
  return;
1083
1376
  case "claude_to_codex_result": {
1084
- const pending = this.pendingReplies.get(message.requestId);
1085
- if (!pending)
1086
- return;
1087
- clearTimeout(pending.timer);
1088
- this.pendingReplies.delete(message.requestId);
1089
- pending.resolve({ success: message.success, error: message.error });
1377
+ this.pendingReplies.settle(message.requestId, {
1378
+ success: message.success,
1379
+ error: message.error,
1380
+ ...message.code !== undefined ? { code: message.code } : {},
1381
+ ...message.phase !== undefined ? { phase: message.phase } : {},
1382
+ ...message.retryAfterMs !== undefined ? { retryAfterMs: message.retryAfterMs } : {}
1383
+ });
1090
1384
  return;
1091
1385
  }
1386
+ case "turn_started":
1387
+ this.emit("turnStarted", {
1388
+ requestId: message.requestId,
1389
+ ...message.idempotencyKey !== undefined ? { idempotencyKey: message.idempotencyKey } : {},
1390
+ threadId: message.threadId,
1391
+ turnId: message.turnId
1392
+ });
1393
+ return;
1092
1394
  case "status":
1093
1395
  this.emit("status", message.status);
1094
1396
  return;
@@ -1103,7 +1405,7 @@ var init_daemon_client = __esm(() => {
1103
1405
  if (isCurrent) {
1104
1406
  this.ws = null;
1105
1407
  this.rejectPendingReplies("AgentBridge daemon disconnected.");
1106
- 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) {
1408
+ 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 || event.code === CLOSE_CODE_TOKEN_MISMATCH || event.code === CLOSE_CODE_CONTRACT_MISMATCH) {
1107
1409
  this.emit("rejected", event.code);
1108
1410
  } else {
1109
1411
  this.emit("disconnect");
@@ -1113,11 +1415,7 @@ var init_daemon_client = __esm(() => {
1113
1415
  ws.onerror = () => {};
1114
1416
  }
1115
1417
  rejectPendingReplies(error) {
1116
- for (const [requestId, pending] of this.pendingReplies.entries()) {
1117
- clearTimeout(pending.timer);
1118
- pending.resolve({ success: false, error });
1119
- this.pendingReplies.delete(requestId);
1120
- }
1418
+ this.pendingReplies.settleAll(() => ({ success: false, error }));
1121
1419
  }
1122
1420
  send(message) {
1123
1421
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -1136,6 +1434,10 @@ var init_daemon_client = __esm(() => {
1136
1434
  var CONTRACT_VERSION = 1;
1137
1435
 
1138
1436
  // src/build-info.ts
1437
+ function hasValidCodeHash(build) {
1438
+ const hash = build?.codeHash;
1439
+ return typeof hash === "string" && hash.length > 0 && hash !== CODE_HASH_SENTINEL;
1440
+ }
1139
1441
  function defineString(value, fallback) {
1140
1442
  return typeof value === "string" && value.length > 0 ? value : fallback;
1141
1443
  }
@@ -1150,7 +1452,14 @@ function defineNumber(value, fallback) {
1150
1452
  function sameRuntimeContract(a, b) {
1151
1453
  if (!a || !b)
1152
1454
  return false;
1153
- return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
1455
+ if (a.version !== b.version || a.contractVersion !== b.contractVersion)
1456
+ return false;
1457
+ if (hasValidCodeHash(a) && hasValidCodeHash(b))
1458
+ return a.codeHash === b.codeHash;
1459
+ return a.commit === b.commit;
1460
+ }
1461
+ function runtimeContractComparisonBasis(a, b) {
1462
+ return hasValidCodeHash(a) && hasValidCodeHash(b) ? "codeHash" : "commit";
1154
1463
  }
1155
1464
  function compatibleContractVersion(a, b) {
1156
1465
  if (!a || !b)
@@ -1160,21 +1469,23 @@ function compatibleContractVersion(a, b) {
1160
1469
  function formatBuildInfo(build) {
1161
1470
  if (!build)
1162
1471
  return "<unknown>";
1163
- return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
1472
+ const codeHash = hasValidCodeHash(build) ? `/code-${build.codeHash}` : "";
1473
+ return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}${codeHash}`;
1164
1474
  }
1165
- var BUILD_INFO;
1475
+ var CODE_HASH_SENTINEL = "source", BUILD_INFO;
1166
1476
  var init_build_info = __esm(() => {
1167
1477
  BUILD_INFO = Object.freeze({
1168
- version: defineString("0.1.11", "0.0.0-source"),
1169
- commit: defineString("48eb0ed", "source"),
1478
+ version: defineString("0.1.13", "0.0.0-source"),
1479
+ commit: defineString("7a71869", "source"),
1170
1480
  bundle: defineBundle("dist"),
1171
- contractVersion: defineNumber(1, CONTRACT_VERSION)
1481
+ contractVersion: defineNumber(1, CONTRACT_VERSION),
1482
+ codeHash: defineString("e1fd67d07c62", "source")
1172
1483
  });
1173
1484
  });
1174
1485
 
1175
1486
  // src/process-lifecycle.ts
1176
1487
  import { execFileSync as execFileSync4 } from "child_process";
1177
- import { basename } from "path";
1488
+ import { basename as basename2 } from "path";
1178
1489
  function parsePsProcessList(output) {
1179
1490
  const entries = [];
1180
1491
  for (const line of output.split(/\r?\n/)) {
@@ -1190,11 +1501,11 @@ function parsePsProcessList(output) {
1190
1501
  }
1191
1502
  function invokesCodexBinary(command) {
1192
1503
  const tokens = command.trim().split(/\s+/);
1193
- const exe = tokens[0] ? basename(tokens[0]) : "";
1504
+ const exe = tokens[0] ? basename2(tokens[0]) : "";
1194
1505
  if (exe === "codex")
1195
1506
  return true;
1196
1507
  if ((exe === "node" || exe === "bun") && tokens[1]) {
1197
- return basename(tokens[1]) === "codex";
1508
+ return basename2(tokens[1]) === "codex";
1198
1509
  }
1199
1510
  return false;
1200
1511
  }
@@ -1330,19 +1641,205 @@ var init_process_lifecycle = __esm(() => {
1330
1641
  isProcessAlive = pidLooksAlive;
1331
1642
  });
1332
1643
 
1644
+ // src/daemon-record.ts
1645
+ import { readFileSync as readFileSync4 } from "fs";
1646
+ function writeDaemonRecord(path, record) {
1647
+ atomicWriteJson(path, record);
1648
+ }
1649
+ function sanitizePorts(value) {
1650
+ if (typeof value !== "object" || value === null)
1651
+ return;
1652
+ const raw = value;
1653
+ const ports = {};
1654
+ if (typeof raw.appPort === "number")
1655
+ ports.appPort = raw.appPort;
1656
+ if (typeof raw.proxyPort === "number")
1657
+ ports.proxyPort = raw.proxyPort;
1658
+ if (typeof raw.controlPort === "number")
1659
+ ports.controlPort = raw.controlPort;
1660
+ return Object.keys(ports).length > 0 ? ports : undefined;
1661
+ }
1662
+ function readDaemonRecord(path, read = defaultRead) {
1663
+ let parsed;
1664
+ try {
1665
+ parsed = JSON.parse(read(path));
1666
+ } catch {
1667
+ return null;
1668
+ }
1669
+ if (typeof parsed !== "object" || parsed === null)
1670
+ return null;
1671
+ const obj = parsed;
1672
+ if (typeof obj.pid !== "number" || !Number.isFinite(obj.pid))
1673
+ return null;
1674
+ const phase = obj.phase === "ready" ? "ready" : "booting";
1675
+ const record = { pid: obj.pid, phase };
1676
+ if (typeof obj.startedAt === "number")
1677
+ record.startedAt = obj.startedAt;
1678
+ if (typeof obj.nonce === "string")
1679
+ record.nonce = obj.nonce;
1680
+ if (obj.pairId === null || typeof obj.pairId === "string")
1681
+ record.pairId = obj.pairId;
1682
+ if (obj.cwd === null || typeof obj.cwd === "string")
1683
+ record.cwd = obj.cwd;
1684
+ if (obj.stateDir === null || typeof obj.stateDir === "string")
1685
+ record.stateDir = obj.stateDir;
1686
+ if (typeof obj.proxyUrl === "string")
1687
+ record.proxyUrl = obj.proxyUrl;
1688
+ if (typeof obj.appServerUrl === "string")
1689
+ record.appServerUrl = obj.appServerUrl;
1690
+ const ports = sanitizePorts(obj.ports);
1691
+ if (ports !== undefined)
1692
+ record.ports = ports;
1693
+ if (typeof obj.build === "object" && obj.build !== null) {
1694
+ record.build = obj.build;
1695
+ }
1696
+ if (typeof obj.turnPhase === "string")
1697
+ record.turnPhase = obj.turnPhase;
1698
+ if (typeof obj.turnInProgress === "boolean")
1699
+ record.turnInProgress = obj.turnInProgress;
1700
+ if (typeof obj.attentionWindowActive === "boolean") {
1701
+ record.attentionWindowActive = obj.attentionWindowActive;
1702
+ }
1703
+ return record;
1704
+ }
1705
+ function synthesizeLegacyRecord(pidFilePath, statusFilePath, read = defaultRead) {
1706
+ let pidFromPidFile = null;
1707
+ try {
1708
+ const raw = read(pidFilePath).trim();
1709
+ const n = Number.parseInt(raw, 10);
1710
+ if (Number.isFinite(n))
1711
+ pidFromPidFile = n;
1712
+ } catch {}
1713
+ let status = null;
1714
+ try {
1715
+ const parsed = JSON.parse(read(statusFilePath));
1716
+ if (typeof parsed === "object" && parsed !== null)
1717
+ status = parsed;
1718
+ } catch {}
1719
+ const pidFromStatus = status && typeof status.pid === "number" && Number.isFinite(status.pid) ? status.pid : null;
1720
+ const pid = pidFromPidFile ?? pidFromStatus;
1721
+ if (pid === null)
1722
+ return null;
1723
+ const record = {
1724
+ pid,
1725
+ phase: status ? "ready" : "booting"
1726
+ };
1727
+ if (status) {
1728
+ if (typeof status.proxyUrl === "string")
1729
+ record.proxyUrl = status.proxyUrl;
1730
+ if (typeof status.appServerUrl === "string")
1731
+ record.appServerUrl = status.appServerUrl;
1732
+ const controlPort = typeof status.controlPort === "number" ? status.controlPort : undefined;
1733
+ const proxyPort = portFromUrl(status.proxyUrl);
1734
+ const appPort = portFromUrl(status.appServerUrl);
1735
+ if (controlPort !== undefined || proxyPort !== undefined || appPort !== undefined) {
1736
+ record.ports = {};
1737
+ if (appPort !== undefined)
1738
+ record.ports.appPort = appPort;
1739
+ if (proxyPort !== undefined)
1740
+ record.ports.proxyPort = proxyPort;
1741
+ if (controlPort !== undefined)
1742
+ record.ports.controlPort = controlPort;
1743
+ }
1744
+ if (status.pairId === null || typeof status.pairId === "string")
1745
+ record.pairId = status.pairId;
1746
+ if (status.cwd === null || typeof status.cwd === "string")
1747
+ record.cwd = status.cwd;
1748
+ if (status.stateDir === null || typeof status.stateDir === "string")
1749
+ record.stateDir = status.stateDir;
1750
+ if (typeof status.build === "object" && status.build !== null) {
1751
+ record.build = status.build;
1752
+ }
1753
+ if (typeof status.turnPhase === "string")
1754
+ record.turnPhase = status.turnPhase;
1755
+ if (typeof status.turnInProgress === "boolean")
1756
+ record.turnInProgress = status.turnInProgress;
1757
+ if (typeof status.attentionWindowActive === "boolean") {
1758
+ record.attentionWindowActive = status.attentionWindowActive;
1759
+ }
1760
+ }
1761
+ return record;
1762
+ }
1763
+ function readUnifiedDaemonRecord(paths, read = defaultRead) {
1764
+ return readDaemonRecord(paths.daemonRecordFile, read) ?? synthesizeLegacyRecord(paths.pidFile, paths.statusFile, read);
1765
+ }
1766
+ function portFromUrl(url) {
1767
+ if (typeof url !== "string")
1768
+ return;
1769
+ const match = url.match(/:(\d+)(?:[/?]|$)/);
1770
+ return match ? Number.parseInt(match[1], 10) : undefined;
1771
+ }
1772
+ var defaultRead = (path) => readFileSync4(path, "utf-8");
1773
+ var init_daemon_record = __esm(() => {
1774
+ init_atomic_json();
1775
+ });
1776
+
1333
1777
  // src/daemon-lifecycle.ts
1334
1778
  import { spawn } from "child_process";
1335
- import { existsSync as existsSync5, readFileSync as readFileSync4, statSync, unlinkSync, writeFileSync as writeFileSync4, openSync, closeSync, constants } from "fs";
1779
+ import { existsSync as existsSync6, readFileSync as readFileSync5, statSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3, openSync as openSync2, closeSync as closeSync2, constants } from "fs";
1336
1780
  import { fileURLToPath } from "url";
1781
+ function isReuseVerdict(verdict) {
1782
+ return verdict === "reuse" || verdict === "reuse-despite-drift";
1783
+ }
1784
+ function classifyDaemon(expectedPairId, status, buildInfo) {
1785
+ if (!status) {
1786
+ return { verdict: "unreachable", reason: "daemon status is unavailable or unparseable" };
1787
+ }
1788
+ const reportedPairId = status.pairId;
1789
+ if (!expectedPairId && reportedPairId != null) {
1790
+ return {
1791
+ verdict: "manual-conflict",
1792
+ reason: `manual mode must not adopt registered pair ${reportedPairId}`
1793
+ };
1794
+ }
1795
+ if (expectedPairId) {
1796
+ if (reportedPairId == null) {
1797
+ return {
1798
+ verdict: "replace-foreign",
1799
+ reason: `pair ${expectedPairId} found daemon without pair identity`
1800
+ };
1801
+ }
1802
+ if (reportedPairId !== expectedPairId) {
1803
+ return {
1804
+ verdict: "replace-foreign",
1805
+ reason: `pair ${expectedPairId} found daemon for pair ${reportedPairId}`
1806
+ };
1807
+ }
1808
+ }
1809
+ if (!sameRuntimeContract(status.build, buildInfo)) {
1810
+ if (compatibleContractVersion(status.build, buildInfo) && status.tuiConnected === true) {
1811
+ return {
1812
+ verdict: "reuse-despite-drift",
1813
+ reason: "runtime build drift has a compatible contract and a live Codex TUI is attached"
1814
+ };
1815
+ }
1816
+ const basis = runtimeContractComparisonBasis(status.build, buildInfo) === "codeHash" ? "compared by codeHash" : "compared by commit stamp; legacy build without codeHash";
1817
+ return {
1818
+ verdict: "replace-drifted",
1819
+ reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ` + `${formatBuildInfo(buildInfo)} (${basis})`
1820
+ };
1821
+ }
1822
+ return { verdict: "reuse", reason: "daemon pair and runtime contract match" };
1823
+ }
1824
+ function resolveTiming(timing) {
1825
+ return {
1826
+ reuseReadyRetries: timing?.reuseReadyRetries ?? REUSE_READY_RETRIES,
1827
+ reuseReadyDelayMs: timing?.reuseReadyDelayMs ?? REUSE_READY_DELAY_MS,
1828
+ waitReadyRetries: timing?.waitReadyRetries ?? WAIT_READY_RETRIES,
1829
+ waitReadyDelayMs: timing?.waitReadyDelayMs ?? WAIT_READY_DELAY_MS
1830
+ };
1831
+ }
1337
1832
 
1338
1833
  class DaemonLifecycle {
1339
1834
  stateDir;
1340
1835
  controlPort;
1341
1836
  log;
1837
+ timing;
1342
1838
  constructor(opts) {
1343
1839
  this.stateDir = opts.stateDir;
1344
1840
  this.controlPort = opts.controlPort;
1345
1841
  this.log = opts.log;
1842
+ this.timing = resolveTiming(opts.timing);
1346
1843
  }
1347
1844
  get healthUrl() {
1348
1845
  return `http://127.0.0.1:${this.controlPort}/healthz`;
@@ -1366,55 +1863,40 @@ class DaemonLifecycle {
1366
1863
  return null;
1367
1864
  }
1368
1865
  }
1369
- isForeignDaemon(status) {
1370
- const expected = this.expectedPairId;
1371
- if (!expected)
1372
- return false;
1373
- if (!status)
1374
- return false;
1375
- const reported = status.pairId;
1376
- if (reported == null)
1377
- return true;
1378
- return reported !== expected;
1379
- }
1380
- isRegisteredPairDaemonInManualMode(status) {
1381
- return !this.expectedPairId && status?.pairId != null;
1382
- }
1383
- isBuildDrifted(status) {
1384
- if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
1385
- return false;
1386
- const runtime = status?.build;
1387
- if (!runtime)
1388
- return true;
1389
- return !sameRuntimeContract(runtime, BUILD_INFO);
1866
+ classifyDaemon(status) {
1867
+ const classification = classifyDaemon(this.expectedPairId, status, BUILD_INFO);
1868
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1" && (classification.verdict === "replace-drifted" || classification.verdict === "unreachable")) {
1869
+ return { verdict: "reuse", reason: "build drift replacement disabled by AGENTBRIDGE_ALLOW_BUILD_DRIFT" };
1870
+ }
1871
+ return classification;
1390
1872
  }
1391
- canReuseDespiteDrift(status) {
1392
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
1393
- return false;
1394
- return status?.tuiConnected === true;
1873
+ manualConflictError(status) {
1874
+ return 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.`);
1395
1875
  }
1396
1876
  async ensureRunning() {
1397
1877
  if (await this.isHealthy()) {
1398
1878
  const status = await this.fetchStatus();
1399
- if (this.isRegisteredPairDaemonInManualMode(status)) {
1400
- 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.`);
1401
- }
1402
- if (this.isForeignDaemon(status)) {
1403
- 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`);
1404
- await this.replaceUnhealthyDaemon(status?.pid);
1405
- return;
1406
- }
1407
- if (this.isBuildDrifted(status)) {
1408
- if (this.canReuseDespiteDrift(status)) {
1409
- 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)`);
1410
- } else {
1879
+ const classification = this.classifyDaemon(status);
1880
+ switch (classification.verdict) {
1881
+ case "manual-conflict":
1882
+ throw this.manualConflictError(status);
1883
+ case "replace-foreign":
1884
+ 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`);
1885
+ await this.replaceUnhealthyDaemon(status?.pid);
1886
+ return;
1887
+ case "replace-drifted":
1888
+ case "unreachable":
1411
1889
  this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
1412
1890
  await this.replaceUnhealthyDaemon(status?.pid);
1413
1891
  return;
1414
- }
1892
+ case "reuse-despite-drift":
1893
+ 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)`);
1894
+ break;
1895
+ case "reuse":
1896
+ break;
1415
1897
  }
1416
1898
  try {
1417
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
1899
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
1418
1900
  return;
1419
1901
  } catch {
1420
1902
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
@@ -1427,7 +1909,7 @@ class DaemonLifecycle {
1427
1909
  if (isProcessAlive(existingPid)) {
1428
1910
  if (isAgentBridgeDaemon(existingPid)) {
1429
1911
  try {
1430
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
1912
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
1431
1913
  return;
1432
1914
  } catch {
1433
1915
  this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
@@ -1441,18 +1923,21 @@ class DaemonLifecycle {
1441
1923
  }
1442
1924
  await this.withStartupLockStrict(async (locked) => {
1443
1925
  if (!locked) {
1444
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
1445
- await this.waitForReadyAndOurs();
1926
+ await this.waitForContendedStartupLock();
1446
1927
  return;
1447
1928
  }
1448
1929
  if (await this.isHealthy()) {
1449
1930
  const status = await this.fetchStatus();
1450
- if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
1451
- this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
1931
+ const classification = this.classifyDaemon(status);
1932
+ if (classification.verdict === "manual-conflict") {
1933
+ throw this.manualConflictError(status);
1934
+ }
1935
+ if (!isReuseVerdict(classification.verdict)) {
1936
+ this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}, ` + `reason=${classification.reason}) \u2014 replacing`);
1452
1937
  await this.kill(3000, status?.pid);
1453
1938
  } else {
1454
1939
  try {
1455
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
1940
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
1456
1941
  return;
1457
1942
  } catch {
1458
1943
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
@@ -1461,7 +1946,7 @@ class DaemonLifecycle {
1461
1946
  }
1462
1947
  }
1463
1948
  this.launch();
1464
- await this.waitForReady();
1949
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
1465
1950
  });
1466
1951
  }
1467
1952
  async isHealthy() {
@@ -1476,7 +1961,7 @@ class DaemonLifecycle {
1476
1961
  for (let attempt = 0;attempt < maxRetries; attempt++) {
1477
1962
  if (await this.isHealthy())
1478
1963
  return;
1479
- await new Promise((resolve2) => setTimeout(resolve2, delayMs));
1964
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
1480
1965
  }
1481
1966
  throw new Error(`Timed out waiting for AgentBridge daemon health on ${this.healthUrl}`);
1482
1967
  }
@@ -1488,42 +1973,59 @@ class DaemonLifecycle {
1488
1973
  return false;
1489
1974
  }
1490
1975
  }
1491
- async waitForReady(maxRetries = 40, delayMs = 250) {
1976
+ async waitForReady(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
1492
1977
  for (let attempt = 0;attempt < maxRetries; attempt++) {
1493
1978
  if (await this.isReady())
1494
1979
  return;
1495
- await new Promise((resolve2) => setTimeout(resolve2, delayMs));
1980
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
1496
1981
  }
1497
1982
  throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
1498
1983
  }
1499
- async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
1984
+ async waitForReadyAndOurs(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
1500
1985
  for (let attempt = 0;attempt < maxRetries; attempt++) {
1501
1986
  if (await this.isReady()) {
1502
1987
  const status = await this.fetchStatus();
1503
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
1988
+ const classification = this.classifyDaemon(status);
1989
+ if (classification.verdict === "manual-conflict") {
1990
+ throw this.manualConflictError(status);
1991
+ }
1992
+ if (isReuseVerdict(classification.verdict)) {
1504
1993
  return;
1505
1994
  }
1506
1995
  }
1507
- await new Promise((resolve2) => setTimeout(resolve2, delayMs));
1996
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
1508
1997
  }
1509
1998
  throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
1510
1999
  }
2000
+ readDaemonRecord() {
2001
+ return readUnifiedDaemonRecord({
2002
+ daemonRecordFile: this.stateDir.daemonRecordFile,
2003
+ pidFile: this.stateDir.pidFile,
2004
+ statusFile: this.stateDir.statusFile
2005
+ });
2006
+ }
2007
+ writeDaemonRecord(record) {
2008
+ writeDaemonRecord(this.stateDir.daemonRecordFile, record);
2009
+ }
2010
+ removeDaemonRecord() {
2011
+ try {
2012
+ unlinkSync2(this.stateDir.daemonRecordFile);
2013
+ } catch {}
2014
+ }
1511
2015
  readStatus() {
1512
2016
  try {
1513
- const raw = readFileSync4(this.stateDir.statusFile, "utf-8");
2017
+ const raw = readFileSync5(this.stateDir.statusFile, "utf-8");
1514
2018
  return JSON.parse(raw);
1515
2019
  } catch {
1516
2020
  return null;
1517
2021
  }
1518
2022
  }
1519
2023
  writeStatus(status) {
1520
- this.stateDir.ensure();
1521
- writeFileSync4(this.stateDir.statusFile, JSON.stringify(status, null, 2) + `
1522
- `, "utf-8");
2024
+ atomicWriteJson(this.stateDir.statusFile, status);
1523
2025
  }
1524
2026
  readPid() {
1525
2027
  try {
1526
- const raw = readFileSync4(this.stateDir.pidFile, "utf-8").trim();
2028
+ const raw = readFileSync5(this.stateDir.pidFile, "utf-8").trim();
1527
2029
  if (!raw)
1528
2030
  return null;
1529
2031
  const pid = Number.parseInt(raw, 10);
@@ -1533,32 +2035,31 @@ class DaemonLifecycle {
1533
2035
  }
1534
2036
  }
1535
2037
  writePid(pid) {
1536
- this.stateDir.ensure();
1537
- writeFileSync4(this.stateDir.pidFile, `${pid ?? process.pid}
1538
- `, "utf-8");
2038
+ atomicWriteText(this.stateDir.pidFile, `${pid ?? process.pid}
2039
+ `);
1539
2040
  }
1540
2041
  removePidFile() {
1541
2042
  try {
1542
- unlinkSync(this.stateDir.pidFile);
2043
+ unlinkSync2(this.stateDir.pidFile);
1543
2044
  } catch {}
1544
2045
  }
1545
2046
  removeStatusFile() {
1546
2047
  try {
1547
- unlinkSync(this.stateDir.statusFile);
2048
+ unlinkSync2(this.stateDir.statusFile);
1548
2049
  } catch {}
1549
2050
  }
1550
2051
  markKilled() {
1551
2052
  this.stateDir.ensure();
1552
- writeFileSync4(this.stateDir.killedFile, `${Date.now()}
2053
+ writeFileSync3(this.stateDir.killedFile, `${Date.now()}
1553
2054
  `, "utf-8");
1554
2055
  }
1555
2056
  clearKilled() {
1556
2057
  try {
1557
- unlinkSync(this.stateDir.killedFile);
2058
+ unlinkSync2(this.stateDir.killedFile);
1558
2059
  } catch {}
1559
2060
  }
1560
2061
  wasKilled() {
1561
- return existsSync5(this.stateDir.killedFile);
2062
+ return existsSync6(this.stateDir.killedFile);
1562
2063
  }
1563
2064
  launch() {
1564
2065
  this.stateDir.ensure();
@@ -1576,21 +2077,26 @@ class DaemonLifecycle {
1576
2077
  daemonProc.unref();
1577
2078
  }
1578
2079
  removeStalePidFile() {
1579
- this.log("Removing stale pid file");
2080
+ this.log("Removing stale daemon identity files");
1580
2081
  this.removePidFile();
2082
+ this.removeStatusFile();
2083
+ this.removeDaemonRecord();
1581
2084
  }
1582
2085
  async replaceUnhealthyDaemon(statusPid) {
1583
2086
  await this.withStartupLockStrict(async (locked) => {
1584
2087
  if (!locked) {
1585
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
1586
- await this.waitForReadyAndOurs();
2088
+ await this.waitForContendedStartupLock();
1587
2089
  return;
1588
2090
  }
1589
2091
  if (await this.isHealthy()) {
1590
2092
  const status = await this.fetchStatus();
1591
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
2093
+ const classification = this.classifyDaemon(status);
2094
+ if (classification.verdict === "manual-conflict") {
2095
+ throw this.manualConflictError(status);
2096
+ }
2097
+ if (isReuseVerdict(classification.verdict)) {
1592
2098
  try {
1593
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2099
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
1594
2100
  return;
1595
2101
  } catch {}
1596
2102
  }
@@ -1598,9 +2104,13 @@ class DaemonLifecycle {
1598
2104
  this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
1599
2105
  await this.kill(3000, statusPid);
1600
2106
  this.launch();
1601
- await this.waitForReady();
2107
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
1602
2108
  });
1603
2109
  }
2110
+ async waitForContendedStartupLock() {
2111
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
2112
+ await this.waitForReadyAndOurs(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
2113
+ }
1604
2114
  async withStartupLockStrict(fn) {
1605
2115
  const locked = this.acquireLockStrict();
1606
2116
  try {
@@ -1614,15 +2124,15 @@ class DaemonLifecycle {
1614
2124
  this.stateDir.ensure();
1615
2125
  let fd = null;
1616
2126
  try {
1617
- fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
1618
- writeFileSync4(fd, `${process.pid}
2127
+ fd = openSync2(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
2128
+ writeFileSync3(fd, `${process.pid}
1619
2129
  `);
1620
- closeSync(fd);
2130
+ closeSync2(fd);
1621
2131
  return true;
1622
2132
  } catch (err) {
1623
2133
  if (fd !== null && err.code !== "EEXIST") {
1624
2134
  try {
1625
- closeSync(fd);
2135
+ closeSync2(fd);
1626
2136
  } catch {}
1627
2137
  this.releaseLock();
1628
2138
  }
@@ -1630,7 +2140,7 @@ class DaemonLifecycle {
1630
2140
  if (reclaimed)
1631
2141
  return false;
1632
2142
  try {
1633
- const holderPid = Number.parseInt(readFileSync4(this.stateDir.lockFile, "utf-8").trim(), 10);
2143
+ const holderPid = Number.parseInt(readFileSync5(this.stateDir.lockFile, "utf-8").trim(), 10);
1634
2144
  if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
1635
2145
  this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
1636
2146
  this.releaseLock();
@@ -1659,7 +2169,7 @@ class DaemonLifecycle {
1659
2169
  }
1660
2170
  releaseLock() {
1661
2171
  try {
1662
- unlinkSync(this.stateDir.lockFile);
2172
+ unlinkSync2(this.stateDir.lockFile);
1663
2173
  } catch {}
1664
2174
  }
1665
2175
  async kill(gracefulTimeoutMs = 3000, pidOverride) {
@@ -1693,7 +2203,7 @@ class DaemonLifecycle {
1693
2203
  this.cleanup();
1694
2204
  return true;
1695
2205
  }
1696
- await new Promise((resolve2) => setTimeout(resolve2, 200));
2206
+ await new Promise((resolve3) => setTimeout(resolve3, 200));
1697
2207
  }
1698
2208
  this.log(`Daemon pid ${pid} did not stop gracefully, sending SIGKILL`);
1699
2209
  try {
@@ -1705,6 +2215,7 @@ class DaemonLifecycle {
1705
2215
  cleanup() {
1706
2216
  this.removePidFile();
1707
2217
  this.removeStatusFile();
2218
+ this.removeDaemonRecord();
1708
2219
  }
1709
2220
  }
1710
2221
  async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
@@ -1716,10 +2227,12 @@ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
1716
2227
  clearTimeout(timer);
1717
2228
  }
1718
2229
  }
1719
- var DEFAULT_DAEMON_ENTRY, DAEMON_ENTRY, DAEMON_PATH, REUSE_READY_RETRIES, REUSE_READY_DELAY_MS = 250, HEALTH_FETCH_TIMEOUT_MS = 500, LOCK_IDENTITY_GRACE_MS;
2230
+ var DEFAULT_DAEMON_ENTRY, DAEMON_ENTRY, DAEMON_PATH, REUSE_READY_RETRIES, REUSE_READY_DELAY_MS = 250, WAIT_READY_RETRIES = 40, WAIT_READY_DELAY_MS = 250, HEALTH_FETCH_TIMEOUT_MS = 500, LOCK_IDENTITY_GRACE_MS;
1720
2231
  var init_daemon_lifecycle = __esm(() => {
2232
+ init_atomic_json();
1721
2233
  init_build_info();
1722
2234
  init_process_lifecycle();
2235
+ init_daemon_record();
1723
2236
  DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
1724
2237
  DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
1725
2238
  DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
@@ -1730,26 +2243,22 @@ var init_daemon_lifecycle = __esm(() => {
1730
2243
  // src/pair-registry.ts
1731
2244
  import { execFileSync as execFileSync5 } from "child_process";
1732
2245
  import {
1733
- closeSync as closeSync2,
1734
- existsSync as existsSync6,
1735
- fsyncSync,
2246
+ existsSync as existsSync7,
1736
2247
  linkSync,
1737
2248
  lstatSync,
1738
- mkdirSync as mkdirSync3,
1739
- openSync as openSync2,
2249
+ mkdirSync as mkdirSync4,
1740
2250
  readdirSync,
1741
- readFileSync as readFileSync5,
2251
+ readFileSync as readFileSync6,
1742
2252
  realpathSync,
1743
- renameSync,
1744
2253
  rmSync as rmSync2,
1745
2254
  statSync as statSync2,
1746
- unlinkSync as unlinkSync2,
1747
- writeFileSync as writeFileSync5
2255
+ unlinkSync as unlinkSync3,
2256
+ writeFileSync as writeFileSync4
1748
2257
  } from "fs";
1749
2258
  import { createServer } from "net";
1750
- import { createHash, randomUUID } from "crypto";
2259
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
1751
2260
  import { hostname, userInfo } from "os";
1752
- import { basename as basename2, join as join5, resolve as resolve2, sep } from "path";
2261
+ import { basename as basename3, join as join6, resolve as resolve3, sep } from "path";
1753
2262
  function portsForSlot(slot) {
1754
2263
  if (!Number.isInteger(slot) || slot < 0) {
1755
2264
  throw new PairError("PAIR_ID_INVALID", `Invalid slot: ${slot}`);
@@ -1787,18 +2296,18 @@ function pickLowestFreeSlot(entries) {
1787
2296
  return slot;
1788
2297
  }
1789
2298
  function pairsDir(base) {
1790
- return join5(base, "pairs");
2299
+ return join6(base, "pairs");
1791
2300
  }
1792
2301
  function registryPath(base) {
1793
- return join5(pairsDir(base), REGISTRY_FILE_NAME);
2302
+ return join6(pairsDir(base), REGISTRY_FILE_NAME);
1794
2303
  }
1795
2304
  function readRegistry(base) {
1796
2305
  const path = registryPath(base);
1797
- if (!existsSync6(path))
2306
+ if (!existsSync7(path))
1798
2307
  return { version: 1, pairs: [] };
1799
2308
  let parsed;
1800
2309
  try {
1801
- parsed = JSON.parse(readFileSync5(path, "utf-8"));
2310
+ parsed = JSON.parse(readFileSync6(path, "utf-8"));
1802
2311
  } catch (err) {
1803
2312
  throw new PairError("PAIR_REGISTRY_CORRUPT", `Registry JSON is not parseable at ${path}: ${err.message}`, {
1804
2313
  path
@@ -1829,26 +2338,14 @@ function readRegistry(base) {
1829
2338
  return parsed;
1830
2339
  }
1831
2340
  function writeRegistry(base, reg) {
1832
- mkdirSync3(pairsDir(base), { recursive: true });
1833
- const target = registryPath(base);
1834
- const tmp = `${target}.tmp.${process.pid}`;
1835
- const data = JSON.stringify(reg, null, 2) + `
1836
- `;
1837
- const fd = openSync2(tmp, "w");
1838
- try {
1839
- writeFileSync5(fd, data);
1840
- fsyncSync(fd);
1841
- } finally {
1842
- closeSync2(fd);
1843
- }
1844
- renameSync(tmp, target);
2341
+ atomicWriteJson(registryPath(base), reg, { fsync: true });
1845
2342
  }
1846
2343
  function lockFilePath(base) {
1847
- return join5(pairsDir(base), LOCK_FILE_NAME);
2344
+ return join6(pairsDir(base), LOCK_FILE_NAME);
1848
2345
  }
1849
2346
  function readLockOwner(lockFile) {
1850
2347
  try {
1851
- const parsed = JSON.parse(readFileSync5(lockFile, "utf-8"));
2348
+ const parsed = JSON.parse(readFileSync6(lockFile, "utf-8"));
1852
2349
  if (typeof parsed.pid === "number" && typeof parsed.nonce === "string")
1853
2350
  return parsed;
1854
2351
  return null;
@@ -1878,7 +2375,7 @@ function safeUid() {
1878
2375
  }
1879
2376
  }
1880
2377
  function sleep(ms) {
1881
- return new Promise((resolve3) => setTimeout(resolve3, ms));
2378
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
1882
2379
  }
1883
2380
  function lockIsStale(lockFile) {
1884
2381
  const owner = readLockOwner(lockFile);
@@ -1888,7 +2385,7 @@ function lockIsStale(lockFile) {
1888
2385
  }
1889
2386
  function attemptReclaim(lockFile) {
1890
2387
  const reclaimLock = `${lockFile}.reclaim`;
1891
- const myNonce = randomUUID();
2388
+ const myNonce = randomUUID2();
1892
2389
  const ownerJson = JSON.stringify({
1893
2390
  pid: process.pid,
1894
2391
  createdAt: Date.now(),
@@ -1896,10 +2393,10 @@ function attemptReclaim(lockFile) {
1896
2393
  hostname: safeHostname(),
1897
2394
  uid: safeUid()
1898
2395
  });
1899
- const tmp = `${reclaimLock}.acq.${process.pid}.${randomUUID()}`;
2396
+ const tmp = `${reclaimLock}.acq.${process.pid}.${randomUUID2()}`;
1900
2397
  let held = false;
1901
2398
  try {
1902
- writeFileSync5(tmp, ownerJson);
2399
+ writeFileSync4(tmp, ownerJson);
1903
2400
  try {
1904
2401
  linkSync(tmp, reclaimLock);
1905
2402
  held = true;
@@ -1907,7 +2404,7 @@ function attemptReclaim(lockFile) {
1907
2404
  if (err?.code === "EEXIST") {
1908
2405
  if (lockIsStale(reclaimLock)) {
1909
2406
  try {
1910
- unlinkSync2(reclaimLock);
2407
+ unlinkSync3(reclaimLock);
1911
2408
  } catch {}
1912
2409
  }
1913
2410
  return;
@@ -1916,7 +2413,7 @@ function attemptReclaim(lockFile) {
1916
2413
  }
1917
2414
  } finally {
1918
2415
  try {
1919
- unlinkSync2(tmp);
2416
+ unlinkSync3(tmp);
1920
2417
  } catch {}
1921
2418
  }
1922
2419
  if (!held)
@@ -1926,22 +2423,22 @@ function attemptReclaim(lockFile) {
1926
2423
  return;
1927
2424
  if (lockIsStale(lockFile)) {
1928
2425
  try {
1929
- unlinkSync2(lockFile);
2426
+ unlinkSync3(lockFile);
1930
2427
  } catch {}
1931
2428
  }
1932
2429
  } finally {
1933
2430
  if (readLockOwner(reclaimLock)?.nonce === myNonce) {
1934
2431
  try {
1935
- unlinkSync2(reclaimLock);
2432
+ unlinkSync3(reclaimLock);
1936
2433
  } catch {}
1937
2434
  }
1938
2435
  }
1939
2436
  }
1940
2437
  async function withRegistryLock(base, fn) {
1941
- mkdirSync3(pairsDir(base), { recursive: true });
2438
+ mkdirSync4(pairsDir(base), { recursive: true });
1942
2439
  const lockFile = lockFilePath(base);
1943
2440
  const deadline = Date.now() + LOCK_DEADLINE_MS;
1944
- const myNonce = randomUUID();
2441
+ const myNonce = randomUUID2();
1945
2442
  const ownerJson = JSON.stringify({
1946
2443
  pid: process.pid,
1947
2444
  createdAt: Date.now(),
@@ -1950,10 +2447,10 @@ async function withRegistryLock(base, fn) {
1950
2447
  uid: safeUid()
1951
2448
  });
1952
2449
  for (;; ) {
1953
- const tmp = `${lockFile}.acq.${process.pid}.${randomUUID()}`;
2450
+ const tmp = `${lockFile}.acq.${process.pid}.${randomUUID2()}`;
1954
2451
  let acquired = false;
1955
2452
  try {
1956
- writeFileSync5(tmp, ownerJson);
2453
+ writeFileSync4(tmp, ownerJson);
1957
2454
  try {
1958
2455
  linkSync(tmp, lockFile);
1959
2456
  acquired = true;
@@ -1963,7 +2460,7 @@ async function withRegistryLock(base, fn) {
1963
2460
  }
1964
2461
  } finally {
1965
2462
  try {
1966
- unlinkSync2(tmp);
2463
+ unlinkSync3(tmp);
1967
2464
  } catch {}
1968
2465
  }
1969
2466
  if (acquired) {
@@ -1973,7 +2470,7 @@ async function withRegistryLock(base, fn) {
1973
2470
  const current = readLockOwner(lockFile);
1974
2471
  if (!current || current.nonce === myNonce) {
1975
2472
  try {
1976
- unlinkSync2(lockFile);
2473
+ unlinkSync3(lockFile);
1977
2474
  } catch {}
1978
2475
  }
1979
2476
  }
@@ -1990,12 +2487,12 @@ async function withRegistryLock(base, fn) {
1990
2487
  }
1991
2488
  }
1992
2489
  function detectLegacyRootDaemon(base) {
1993
- const rootPidFile = join5(base, "daemon.pid");
1994
- if (!existsSync6(rootPidFile))
2490
+ const rootPidFile = join6(base, "daemon.pid");
2491
+ if (!existsSync7(rootPidFile))
1995
2492
  return null;
1996
2493
  let pid;
1997
2494
  try {
1998
- const raw = readFileSync5(rootPidFile, "utf-8").trim();
2495
+ const raw = readFileSync6(rootPidFile, "utf-8").trim();
1999
2496
  pid = Number.parseInt(raw, 10);
2000
2497
  } catch {
2001
2498
  return null;
@@ -2005,11 +2502,11 @@ function detectLegacyRootDaemon(base) {
2005
2502
  return { pid, controlPort: LEGACY_ROOT_CONTROL_PORT };
2006
2503
  }
2007
2504
  function probePortFree(port) {
2008
- return new Promise((resolve3) => {
2505
+ return new Promise((resolve4) => {
2009
2506
  const server = createServer();
2010
- server.once("error", () => resolve3(false));
2507
+ server.once("error", () => resolve4(false));
2011
2508
  server.once("listening", () => {
2012
- server.close(() => resolve3(true));
2509
+ server.close(() => resolve4(true));
2013
2510
  });
2014
2511
  server.listen(port, "127.0.0.1");
2015
2512
  });
@@ -2086,7 +2583,7 @@ async function resolvePair(base, opts) {
2086
2583
  pairId: entry.pairId,
2087
2584
  slot,
2088
2585
  ports,
2089
- stateDir: join5(pairsDir(base), entry.pairId),
2586
+ stateDir: join6(pairsDir(base), entry.pairId),
2090
2587
  name: entry.name ?? name,
2091
2588
  entry,
2092
2589
  warning
@@ -2094,7 +2591,7 @@ async function resolvePair(base, opts) {
2094
2591
  }
2095
2592
  async function removeAllocatedPairIfUnchanged(base, pairId, slot) {
2096
2593
  await withRegistryLock(base, () => {
2097
- if (existsSync6(pairDirPath(base, pairId)) || pairDirDaemonAlive(base, pairId))
2594
+ if (existsSync7(pairDirPath(base, pairId)) || pairDirDaemonAlive(base, pairId))
2098
2595
  return;
2099
2596
  const reg = readRegistry(base);
2100
2597
  const nextPairs = reg.pairs.filter((pair) => !(pair.pairId === pairId && pair.slot === slot));
@@ -2103,21 +2600,24 @@ async function removeAllocatedPairIfUnchanged(base, pairId, slot) {
2103
2600
  writeRegistry(base, { version: 1, pairs: nextPairs });
2104
2601
  });
2105
2602
  }
2603
+ function pairsRootDir(base) {
2604
+ return pairsDir(base);
2605
+ }
2106
2606
  function pairDirPath(base, pairId) {
2107
2607
  const id = validatePairId(pairId);
2108
- return join5(pairsDir(base), id);
2608
+ return join6(pairsDir(base), id);
2109
2609
  }
2110
2610
  function removePairDir(base, pairId) {
2111
2611
  const id = validatePairId(pairId);
2112
2612
  const root = pairsDir(base);
2113
- const dir = join5(root, id);
2114
- const canonicalRoot = resolve2(root);
2115
- const canonicalDir = resolve2(dir);
2613
+ const dir = join6(root, id);
2614
+ const canonicalRoot = resolve3(root);
2615
+ const canonicalDir = resolve3(dir);
2116
2616
  if (canonicalDir === canonicalRoot || !canonicalDir.startsWith(canonicalRoot + sep)) {
2117
2617
  throw new PairError("PAIR_ID_INVALID", `Refusing to remove a pair dir outside ${canonicalRoot}: ${canonicalDir}`, { pairId });
2118
2618
  }
2119
2619
  assertPairsRootNotSymlinked(root);
2120
- if (!existsSync6(canonicalDir))
2620
+ if (!existsSync7(canonicalDir))
2121
2621
  return false;
2122
2622
  rmSync2(canonicalDir, { recursive: true, force: true });
2123
2623
  return true;
@@ -2135,22 +2635,27 @@ function assertPairsRootNotSymlinked(root) {
2135
2635
  }
2136
2636
  function listPairDirs(base) {
2137
2637
  const root = pairsDir(base);
2138
- if (!existsSync6(root))
2638
+ if (!existsSync7(root))
2139
2639
  return [];
2140
2640
  if (lstatSync(root).isSymbolicLink())
2141
2641
  return [];
2142
2642
  return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2143
2643
  }
2144
2644
  function pairDirDaemonAlive(base, pairId) {
2145
- const dir = join5(pairsDir(base), pairId);
2645
+ const dir = join6(pairsDir(base), pairId);
2146
2646
  const pids = [];
2147
2647
  try {
2148
- const pid = Number.parseInt(readFileSync5(join5(dir, "daemon.pid"), "utf-8").trim(), 10);
2648
+ const record = JSON.parse(readFileSync6(join6(dir, "daemon.json"), "utf-8"));
2649
+ if (typeof record?.pid === "number" && Number.isFinite(record.pid))
2650
+ pids.push(record.pid);
2651
+ } catch {}
2652
+ try {
2653
+ const pid = Number.parseInt(readFileSync6(join6(dir, "daemon.pid"), "utf-8").trim(), 10);
2149
2654
  if (Number.isFinite(pid))
2150
2655
  pids.push(pid);
2151
2656
  } catch {}
2152
2657
  try {
2153
- const status = JSON.parse(readFileSync5(join5(dir, "status.json"), "utf-8"));
2658
+ const status = JSON.parse(readFileSync6(join6(dir, "status.json"), "utf-8"));
2154
2659
  if (typeof status?.pid === "number")
2155
2660
  pids.push(status.pid);
2156
2661
  } catch {}
@@ -2184,10 +2689,54 @@ async function removeUnregisteredPairDir(base, pairId) {
2184
2689
  return { removed: removePairDir(base, pairId) };
2185
2690
  });
2186
2691
  }
2187
- var PAIR_BASE_PORT = 4500, PAIR_SLOT_STRIDE = 10, PAIR_ID_REGEX, DEFAULT_PAIR_NAME = "main", LOCK_FILE_NAME = ".registry.lock", REGISTRY_FILE_NAME = "registry.json", LOCK_DEADLINE_MS = 1e4, ORPHAN_GRACE_MS = 3000, LEGACY_ROOT_CONTROL_PORT = 4502, WINDOWS_RESERVED_RE, PairError, MAX_PAIR_SLOT;
2692
+ async function removeOrphanPairDirIgnoringRegistry(base, pairId) {
2693
+ return withRegistryLock(base, () => {
2694
+ if (pairDirDaemonAlive(base, pairId)) {
2695
+ return { removed: false, reason: "live" };
2696
+ }
2697
+ return { removed: removePairDir(base, pairId) };
2698
+ });
2699
+ }
2700
+ function isEntryReclaimable(signals) {
2701
+ return signals.cwdGone && signals.dead && signals.old;
2702
+ }
2703
+ function cwdMissing(cwd) {
2704
+ try {
2705
+ statSync2(cwd);
2706
+ return false;
2707
+ } catch (err) {
2708
+ return err?.code === "ENOENT";
2709
+ }
2710
+ }
2711
+ function parseCreatedAtMs(createdAt) {
2712
+ if (typeof createdAt !== "string")
2713
+ return null;
2714
+ const ms = Date.parse(createdAt);
2715
+ return Number.isFinite(ms) ? ms : null;
2716
+ }
2717
+ function classifyReclaimableEntries(base, now = Date.now()) {
2718
+ const reg = readRegistry(base);
2719
+ const out = [];
2720
+ for (const entry of reg.pairs) {
2721
+ const createdMs = parseCreatedAtMs(entry.createdAt);
2722
+ const ageMs = createdMs === null ? null : Math.max(0, now - createdMs);
2723
+ const signals = {
2724
+ cwdGone: cwdMissing(entry.cwd),
2725
+ dead: !pairDirDaemonAlive(base, entry.pairId),
2726
+ old: ageMs !== null && ageMs >= RECLAIMABLE_MIN_AGE_MS,
2727
+ ageMs
2728
+ };
2729
+ if (isEntryReclaimable(signals))
2730
+ out.push({ entry, signals });
2731
+ }
2732
+ return out;
2733
+ }
2734
+ var PAIR_BASE_PORT = 4500, PAIR_SLOT_STRIDE = 10, PAIR_ID_REGEX, DEFAULT_PAIR_NAME = "main", RECLAIMABLE_MIN_AGE_MS, LOCK_FILE_NAME = ".registry.lock", REGISTRY_FILE_NAME = "registry.json", LOCK_DEADLINE_MS = 1e4, ORPHAN_GRACE_MS = 3000, LEGACY_ROOT_CONTROL_PORT = 4502, WINDOWS_RESERVED_RE, PairError, MAX_PAIR_SLOT;
2188
2735
  var init_pair_registry = __esm(() => {
2736
+ init_atomic_json();
2189
2737
  init_process_lifecycle();
2190
2738
  PAIR_ID_REGEX = /^[A-Za-z0-9._-]{1,64}$/;
2739
+ RECLAIMABLE_MIN_AGE_MS = 24 * 60 * 60 * 1000;
2191
2740
  WINDOWS_RESERVED_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
2192
2741
  PairError = class PairError extends Error {
2193
2742
  code;
@@ -2283,7 +2832,7 @@ var init_env_guard = __esm(() => {
2283
2832
 
2284
2833
  // src/pair-resolver.ts
2285
2834
  import { realpathSync as realpathSync2 } from "fs";
2286
- import { join as join6, resolve as resolve3 } from "path";
2835
+ import { join as join7, resolve as resolve4 } from "path";
2287
2836
  function computeBaseDir() {
2288
2837
  return process.env.AGENTBRIDGE_BASE_DIR || process.env.AGENTBRIDGE_STATE_DIR || StateDirResolver.platformBaseDir();
2289
2838
  }
@@ -2410,7 +2959,7 @@ function resolvePairReadOnly(pairFlag) {
2410
2959
  pairId: entry.pairId,
2411
2960
  slot: entry.slot,
2412
2961
  ports: portsForEntry(entry),
2413
- stateDir: new StateDirResolver(join6(base, "pairs", entry.pairId)),
2962
+ stateDir: new StateDirResolver(join7(base, "pairs", entry.pairId)),
2414
2963
  name: entry.name ?? name,
2415
2964
  manual: false
2416
2965
  }
@@ -2423,7 +2972,7 @@ function resolvePairReadOnly(pairFlag) {
2423
2972
  pairId,
2424
2973
  slot: null,
2425
2974
  ports: { appPort: 0, proxyPort: 0, controlPort: 0 },
2426
- stateDir: new StateDirResolver(join6(base, "pairs", pairId)),
2975
+ stateDir: new StateDirResolver(join7(base, "pairs", pairId)),
2427
2976
  name,
2428
2977
  manual: false
2429
2978
  }
@@ -2453,7 +3002,7 @@ function portsForEntry(entry) {
2453
3002
  return portsForSlot(entry.slot);
2454
3003
  }
2455
3004
  function canonicalizeCwd(cwd) {
2456
- const absolute = resolve3(cwd);
3005
+ const absolute = resolve4(cwd);
2457
3006
  try {
2458
3007
  return realpathSync2.native(absolute);
2459
3008
  } catch {
@@ -2470,8 +3019,8 @@ var init_pair_resolver = __esm(() => {
2470
3019
  });
2471
3020
 
2472
3021
  // src/trace-log.ts
2473
- import { appendFileSync, mkdirSync as mkdirSync4 } from "fs";
2474
- import { join as join7 } from "path";
3022
+ import { appendFileSync, existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync3, unlinkSync as unlinkSync4 } from "fs";
3023
+ import { join as join8 } from "path";
2475
3024
  function pickRelevantEnv(env) {
2476
3025
  const picked = {};
2477
3026
  for (const [key, value] of Object.entries(env)) {
@@ -2506,7 +3055,7 @@ function redactArgv(argv) {
2506
3055
  }
2507
3056
  function traceLogPath(cwd, timestamp) {
2508
3057
  const day = timestamp.slice(0, 10);
2509
- return join7(cwd, ".agentbridge", "logs", `trace-${day}.jsonl`);
3058
+ return join8(cwd, ".agentbridge", "logs", `trace-${day}.jsonl`);
2510
3059
  }
2511
3060
  function appendTraceEvent(input) {
2512
3061
  const timestamp = input.timestamp ?? new Date().toISOString();
@@ -2520,11 +3069,39 @@ function appendTraceEvent(input) {
2520
3069
  ...input.env ? { env: pickRelevantEnv(input.env) } : {},
2521
3070
  ...input.data ? { data: redactData(input.data) } : {}
2522
3071
  };
2523
- mkdirSync4(join7(input.cwd, ".agentbridge", "logs"), { recursive: true });
3072
+ const logsDir = join8(input.cwd, ".agentbridge", "logs");
3073
+ const isNewDayFile = !existsSync8(path);
3074
+ mkdirSync5(logsDir, { recursive: true });
3075
+ if (isNewDayFile) {
3076
+ pruneOldTraceLogs(logsDir, path, Date.parse(timestamp));
3077
+ }
2524
3078
  appendFileSync(path, JSON.stringify(event) + `
2525
3079
  `, "utf-8");
2526
3080
  return path;
2527
3081
  }
3082
+ function pruneOldTraceLogs(logsDir, keepPath, nowMs) {
3083
+ if (!Number.isFinite(nowMs))
3084
+ return;
3085
+ const cutoff = nowMs - TRACE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
3086
+ let entries;
3087
+ try {
3088
+ entries = readdirSync2(logsDir);
3089
+ } catch {
3090
+ return;
3091
+ }
3092
+ for (const name of entries) {
3093
+ if (!TRACE_FILE_RE.test(name))
3094
+ continue;
3095
+ const filePath = join8(logsDir, name);
3096
+ if (filePath === keepPath)
3097
+ continue;
3098
+ try {
3099
+ if (statSync3(filePath).mtimeMs < cutoff) {
3100
+ unlinkSync4(filePath);
3101
+ }
3102
+ } catch {}
3103
+ }
3104
+ }
2528
3105
  function isEnvSnapshot(key, value) {
2529
3106
  return /env$/i.test(key) && !!value && typeof value === "object" && !Array.isArray(value);
2530
3107
  }
@@ -2550,8 +3127,9 @@ function redactData(value, key = "") {
2550
3127
  }
2551
3128
  return value;
2552
3129
  }
2553
- var SECRET_KEY_RE, SECRET_ARG_RE, RELEVANT_ENV_RE;
3130
+ var TRACE_RETENTION_DAYS = 7, TRACE_FILE_RE, SECRET_KEY_RE, SECRET_ARG_RE, RELEVANT_ENV_RE;
2554
3131
  var init_trace_log = __esm(() => {
3132
+ TRACE_FILE_RE = /^trace-\d{4}-\d{2}-\d{2}\.jsonl$/;
2555
3133
  SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|auth|cookie|session)/i;
2556
3134
  SECRET_ARG_RE = /^--?(?:token|secret|password|passwd|apikey|api-key|api_key|auth|cookie|session)(?:=.*)?$/i;
2557
3135
  RELEVANT_ENV_RE = /^(AGENTBRIDGE_|CODEX_)/;
@@ -2600,10 +3178,14 @@ var init_max_permissions = __esm(() => {
2600
3178
  // src/cli/claude.ts
2601
3179
  var exports_claude = {};
2602
3180
  __export(exports_claude, {
3181
+ warnIfPluginCacheMissing: () => warnIfPluginCacheMissing,
2603
3182
  runClaude: () => runClaude,
3183
+ mapChildExitCode: () => mapChildExitCode,
2604
3184
  checkOwnedFlagConflicts: () => checkOwnedFlagConflicts
2605
3185
  });
2606
3186
  import { spawn as spawn2 } from "child_process";
3187
+ import { existsSync as existsSync9 } from "fs";
3188
+ import { constants as osConstants } from "os";
2607
3189
  async function runClaude(args) {
2608
3190
  const originalEnv = { ...process.env };
2609
3191
  const envGuardResult = guardAgentBridgeEnv({
@@ -2641,6 +3223,7 @@ async function runClaude(args) {
2641
3223
  }
2642
3224
  await assertPairNotLive(lifecycle, pair);
2643
3225
  lifecycle.clearKilled();
3226
+ warnIfPluginCacheMissing();
2644
3227
  const channelEntry = `plugin:${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
2645
3228
  if (permissionPlan.inject) {
2646
3229
  console.error(`[agentbridge] running with ${CLAUDE_MAX_PERMISSION_FLAG} (default; opt out with --safe or AGENTBRIDGE_SAFE=1)`);
@@ -2655,8 +3238,8 @@ async function runClaude(args) {
2655
3238
  stdio: "inherit",
2656
3239
  env: process.env
2657
3240
  });
2658
- child.on("exit", (code) => {
2659
- process.exit(code ?? 0);
3241
+ child.on("exit", (code, signal) => {
3242
+ process.exit(mapChildExitCode(code, signal));
2660
3243
  });
2661
3244
  child.on("error", (err) => {
2662
3245
  if (err.code === "ENOENT") {
@@ -2668,6 +3251,24 @@ async function runClaude(args) {
2668
3251
  process.exit(1);
2669
3252
  });
2670
3253
  }
3254
+ function mapChildExitCode(code, signal) {
3255
+ if (signal) {
3256
+ return 128 + (osConstants.signals[signal] ?? 0);
3257
+ }
3258
+ return code ?? 0;
3259
+ }
3260
+ function warnIfPluginCacheMissing(cacheRoot = pluginCacheRoot(), log = (msg) => console.error(msg)) {
3261
+ let cacheExists;
3262
+ try {
3263
+ cacheExists = existsSync9(cacheRoot);
3264
+ } catch {
3265
+ return false;
3266
+ }
3267
+ if (!shouldWarnMissingPluginCache(cacheExists))
3268
+ return false;
3269
+ log("[agentbridge] \u26A0\uFE0F Plugin not installed (no plugin cache found). Run `abg init`, " + `or in Claude Code: ${MARKETPLACE_STEPS.join(" \u2192 ")}. Launching anyway\u2026`);
3270
+ return true;
3271
+ }
2671
3272
  function traceCliStart(event, args, originalEnv, envGuardAction, pair) {
2672
3273
  try {
2673
3274
  appendTraceEvent({
@@ -2744,6 +3345,7 @@ function checkOwnedFlagConflicts(args, commandName, ownedFlags) {
2744
3345
  var OWNED_FLAGS;
2745
3346
  var init_claude = __esm(() => {
2746
3347
  init_cli();
3348
+ init_plugin_cache();
2747
3349
  init_daemon_client();
2748
3350
  init_daemon_lifecycle();
2749
3351
  init_build_info();
@@ -2755,15 +3357,15 @@ var init_claude = __esm(() => {
2755
3357
  });
2756
3358
 
2757
3359
  // src/agents-contract.ts
2758
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
2759
- import { join as join8 } from "path";
3360
+ import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
3361
+ import { join as join9 } from "path";
2760
3362
  function checkAgentsMdContract(cwd) {
2761
- const path = join8(cwd, "AGENTS.md");
2762
- const exists = existsSync7(path);
3363
+ const path = join9(cwd, "AGENTS.md");
3364
+ const exists = existsSync10(path);
2763
3365
  let content = "";
2764
3366
  if (exists) {
2765
3367
  try {
2766
- content = readFileSync6(path, "utf-8");
3368
+ content = readFileSync7(path, "utf-8");
2767
3369
  } catch {
2768
3370
  return {
2769
3371
  fresh: false,
@@ -2790,8 +3392,8 @@ function isFreshAgentsMdContract(content) {
2790
3392
  var init_agents_contract = () => {};
2791
3393
 
2792
3394
  // src/wrapper-exit-observability.ts
2793
- import { readFileSync as readFileSync7, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
2794
- import { join as join9 } from "path";
3395
+ import { readFileSync as readFileSync8, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
3396
+ import { join as join10 } from "path";
2795
3397
  function discoverNativeChildPid(launcherPid, run) {
2796
3398
  try {
2797
3399
  const out = run("pgrep", ["-P", String(launcherPid)]);
@@ -2801,17 +3403,15 @@ function discoverNativeChildPid(launcherPid, run) {
2801
3403
  return null;
2802
3404
  }
2803
3405
  }
2804
- function readTurnInProgress(statusFilePath, read = (p) => readFileSync7(p, "utf-8"), isPidAlive = defaultIsPidAlive) {
2805
- try {
2806
- const status = JSON.parse(read(statusFilePath));
2807
- if (typeof status.turnInProgress !== "boolean")
2808
- return null;
2809
- if (typeof status.pid === "number" && !isPidAlive(status.pid))
2810
- return null;
2811
- return status.turnInProgress;
2812
- } catch {
3406
+ function readUnifiedTurnInProgress(paths, read = (p) => readFileSync8(p, "utf-8"), isPidAlive = defaultIsPidAlive) {
3407
+ const record = readUnifiedDaemonRecord(paths, read);
3408
+ if (!record)
2813
3409
  return null;
2814
- }
3410
+ if (typeof record.turnInProgress !== "boolean")
3411
+ return null;
3412
+ if (typeof record.pid === "number" && !isPidAlive(record.pid))
3413
+ return null;
3414
+ return record.turnInProgress;
2815
3415
  }
2816
3416
  function refineCleanExitClassification(turnInProgress) {
2817
3417
  if (turnInProgress === true)
@@ -2820,14 +3420,14 @@ function refineCleanExitClassification(turnInProgress) {
2820
3420
  return "exit_0_idle";
2821
3421
  return "exit_0_turn_unknown";
2822
3422
  }
2823
- function findCodexSqliteLog(codexHome, fs = { readdir: readdirSync2, stat: statSync3 }) {
3423
+ function findCodexSqliteLog(codexHome, fs2 = { readdir: readdirSync3, stat: statSync4 }) {
2824
3424
  try {
2825
- const entries = fs.readdir(codexHome).filter((name) => /^logs.*\.sqlite$/.test(String(name)));
3425
+ const entries = fs2.readdir(codexHome).filter((name) => /^logs.*\.sqlite$/.test(String(name)));
2826
3426
  let best = null;
2827
3427
  for (const name of entries) {
2828
- const path = join9(codexHome, String(name));
3428
+ const path = join10(codexHome, String(name));
2829
3429
  try {
2830
- const mtime = fs.stat(path).mtimeMs;
3430
+ const mtime = fs2.stat(path).mtimeMs;
2831
3431
  if (!best || mtime > best.mtime)
2832
3432
  best = { path, mtime };
2833
3433
  } catch {}
@@ -2862,14 +3462,15 @@ function captureTuiLogTail(options) {
2862
3462
  var defaultIsPidAlive;
2863
3463
  var init_wrapper_exit_observability = __esm(() => {
2864
3464
  init_process_lifecycle();
3465
+ init_daemon_record();
2865
3466
  defaultIsPidAlive = pidLooksAlive;
2866
3467
  });
2867
3468
 
2868
3469
  // src/pair-command.ts
2869
- function pairScopedCommand(cmd) {
3470
+ function pairScopedCommand(cmd, name = cliInvocationName()) {
2870
3471
  const pairId = process.env.AGENTBRIDGE_PAIR_ID;
2871
3472
  if (!pairId)
2872
- return `agentbridge ${cmd}`;
3473
+ return `${name} ${cmd}`;
2873
3474
  let selector = process.env.AGENTBRIDGE_PAIR_NAME;
2874
3475
  if (!selector) {
2875
3476
  try {
@@ -2878,22 +3479,23 @@ function pairScopedCommand(cmd) {
2878
3479
  selector = pairId;
2879
3480
  }
2880
3481
  }
2881
- return `agentbridge --pair ${selector} ${cmd}`;
3482
+ return `${name} --pair ${selector} ${cmd}`;
2882
3483
  }
2883
3484
  var init_pair_command = __esm(() => {
3485
+ init_cli_invocation();
2884
3486
  init_pair_resolver();
2885
3487
  });
2886
3488
 
2887
3489
  // src/rotating-log.ts
2888
- import { appendFileSync as appendFileSync2, existsSync as existsSync8, renameSync as renameSync2, statSync as statSync4, unlinkSync as unlinkSync3 } from "fs";
2889
- import { dirname as dirname2 } from "path";
2890
- function appendRotatingLog(path, content, options = {}) {
3490
+ import { appendFileSync as appendFileSync2, existsSync as existsSync11, renameSync as renameSync2, statSync as statSync5, unlinkSync as unlinkSync5 } from "fs";
3491
+ import { dirname as dirname3 } from "path";
3492
+ function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) {
2891
3493
  const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
2892
3494
  const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
2893
- if (!existsSync8(dirname2(path)))
3495
+ if (!fsOps.existsSync(dirname3(path)))
2894
3496
  return;
2895
- rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
2896
- appendFileSync2(path, content, "utf-8");
3497
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep, fsOps);
3498
+ fsOps.appendFileSync(path, content, "utf-8");
2897
3499
  }
2898
3500
  function positiveIntFromEnv(name, fallback) {
2899
3501
  const value = process.env[name];
@@ -2902,30 +3504,53 @@ function positiveIntFromEnv(name, fallback) {
2902
3504
  const parsed = Number(value);
2903
3505
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
2904
3506
  }
2905
- function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
3507
+ function isEnoent(error) {
3508
+ return !!error && error.code === "ENOENT";
3509
+ }
3510
+ function renameIfPresent(from, to, fsOps) {
3511
+ try {
3512
+ fsOps.renameSync(from, to);
3513
+ } catch (error) {
3514
+ if (!isEnoent(error))
3515
+ throw error;
3516
+ }
3517
+ }
3518
+ function unlinkIfPresent(path, fsOps) {
3519
+ try {
3520
+ fsOps.unlinkSync(path);
3521
+ } catch (error) {
3522
+ if (!isEnoent(error))
3523
+ throw error;
3524
+ }
3525
+ }
3526
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep, fsOps) {
2906
3527
  if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
2907
3528
  return;
2908
- if (!existsSync8(path))
2909
- return;
2910
- const size = statSync4(path).size;
3529
+ let size;
3530
+ try {
3531
+ size = fsOps.statSync(path).size;
3532
+ } catch (error) {
3533
+ if (isEnoent(error))
3534
+ return;
3535
+ throw error;
3536
+ }
2911
3537
  if (size + incomingBytes <= maxBytes)
2912
3538
  return;
2913
3539
  for (let index = keep;index >= 1; index--) {
2914
3540
  const current = `${path}.${index}`;
2915
3541
  const next = `${path}.${index + 1}`;
2916
- if (!existsSync8(current))
2917
- continue;
2918
3542
  if (index === keep) {
2919
- unlinkSync3(current);
3543
+ unlinkIfPresent(current, fsOps);
2920
3544
  } else {
2921
- renameSync2(current, next);
3545
+ renameIfPresent(current, next, fsOps);
2922
3546
  }
2923
3547
  }
2924
- renameSync2(path, `${path}.1`);
3548
+ renameIfPresent(path, `${path}.1`, fsOps);
2925
3549
  }
2926
- var DEFAULT_MAX_BYTES, DEFAULT_KEEP = 3;
3550
+ var DEFAULT_MAX_BYTES, DEFAULT_KEEP = 3, REAL_FS_OPS;
2927
3551
  var init_rotating_log = __esm(() => {
2928
3552
  DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
3553
+ REAL_FS_OPS = { statSync: statSync5, renameSync: renameSync2, unlinkSync: unlinkSync5, appendFileSync: appendFileSync2, existsSync: existsSync11 };
2929
3554
  });
2930
3555
 
2931
3556
  // src/stderr-ring-buffer.ts
@@ -2978,31 +3603,21 @@ var init_stderr_ring_buffer = __esm(() => {
2978
3603
 
2979
3604
  // src/thread-state.ts
2980
3605
  import {
2981
- existsSync as existsSync9,
2982
- mkdirSync as mkdirSync5,
2983
- readdirSync as readdirSync3,
2984
- readFileSync as readFileSync8,
2985
- renameSync as renameSync3,
2986
- writeFileSync as writeFileSync6
3606
+ existsSync as existsSync12,
3607
+ readdirSync as readdirSync4,
3608
+ readFileSync as readFileSync9
2987
3609
  } from "fs";
2988
3610
  import { homedir as homedir3 } from "os";
2989
- import { basename as basename3, dirname as dirname3, join as join10 } from "path";
3611
+ import { basename as basename4, join as join11 } from "path";
2990
3612
  function nowIso() {
2991
3613
  return new Date().toISOString();
2992
3614
  }
2993
3615
  function codexHome(env = process.env) {
2994
- return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join10(homedir3(), ".codex");
2995
- }
2996
- function atomicWriteJson(path, value) {
2997
- mkdirSync5(dirname3(path), { recursive: true });
2998
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
2999
- writeFileSync6(tmp, JSON.stringify(value, null, 2) + `
3000
- `, "utf-8");
3001
- renameSync3(tmp, path);
3616
+ return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join11(homedir3(), ".codex");
3002
3617
  }
3003
3618
  function readRawCurrentThread(stateDir) {
3004
3619
  try {
3005
- const parsed = JSON.parse(readFileSync8(stateDir.currentThreadFile, "utf-8"));
3620
+ const parsed = JSON.parse(readFileSync9(stateDir.currentThreadFile, "utf-8"));
3006
3621
  if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
3007
3622
  return parsed;
3008
3623
  }
@@ -3010,8 +3625,8 @@ function readRawCurrentThread(stateDir) {
3010
3625
  return null;
3011
3626
  }
3012
3627
  function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
3013
- const sessionsDir = join10(codexHome(env), "sessions");
3014
- if (!threadId || !existsSync9(sessionsDir))
3628
+ const sessionsDir = join11(codexHome(env), "sessions");
3629
+ if (!threadId || !existsSync12(sessionsDir))
3015
3630
  return null;
3016
3631
  const exactName = `rollout-${threadId}.jsonl`;
3017
3632
  const stack = [sessionsDir];
@@ -3020,20 +3635,20 @@ function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
3020
3635
  const dir = stack.pop();
3021
3636
  let entries;
3022
3637
  try {
3023
- entries = readdirSync3(dir, { withFileTypes: true });
3638
+ entries = readdirSync4(dir, { withFileTypes: true });
3024
3639
  } catch {
3025
3640
  continue;
3026
3641
  }
3027
3642
  for (const entry of entries) {
3028
3643
  visited++;
3029
- const path = join10(dir, entry.name);
3644
+ const path = join11(dir, entry.name);
3030
3645
  if (entry.isDirectory()) {
3031
3646
  stack.push(path);
3032
3647
  continue;
3033
3648
  }
3034
3649
  if (!entry.isFile())
3035
3650
  continue;
3036
- const name = basename3(entry.name);
3651
+ const name = basename4(entry.name);
3037
3652
  if (name === exactName || name.startsWith("rollout-") && name.endsWith(".jsonl") && name.includes(threadId)) {
3038
3653
  return path;
3039
3654
  }
@@ -3051,7 +3666,7 @@ function readUsableCurrentThread(identity, env = process.env) {
3051
3666
  return null;
3052
3667
  if (state.cwd !== identity.cwd)
3053
3668
  return null;
3054
- if (state.rolloutPath && existsSync9(state.rolloutPath))
3669
+ if (state.rolloutPath && existsSync12(state.rolloutPath))
3055
3670
  return state;
3056
3671
  const rolloutPath = findCodexRolloutFile(state.threadId, env);
3057
3672
  if (!rolloutPath)
@@ -3065,7 +3680,9 @@ function readUsableCurrentThread(identity, env = process.env) {
3065
3680
  atomicWriteJson(identity.stateDir.currentThreadFile, repaired);
3066
3681
  return repaired;
3067
3682
  }
3068
- var init_thread_state = () => {};
3683
+ var init_thread_state = __esm(() => {
3684
+ init_atomic_json();
3685
+ });
3069
3686
 
3070
3687
  // src/cli/codex.ts
3071
3688
  var exports_codex = {};
@@ -3080,18 +3697,18 @@ import {
3080
3697
  openSync as openSync3,
3081
3698
  writeSync,
3082
3699
  closeSync as closeSync3,
3083
- writeFileSync as writeFileSync7,
3084
- readFileSync as readFileSync9,
3085
- unlinkSync as unlinkSync4,
3086
- existsSync as existsSync10,
3700
+ writeFileSync as writeFileSync5,
3701
+ readFileSync as readFileSync10,
3702
+ unlinkSync as unlinkSync6,
3703
+ existsSync as existsSync13,
3087
3704
  mkdirSync as mkdirSync6
3088
3705
  } from "fs";
3089
3706
  import { homedir as homedir4 } from "os";
3090
- import { dirname as dirname4, join as join11 } from "path";
3707
+ import { dirname as dirname4, join as join12 } from "path";
3091
3708
  function appendWrapperLog(path, entry) {
3092
3709
  try {
3093
3710
  const dir = dirname4(path);
3094
- if (!existsSync10(dir)) {
3711
+ if (!existsSync13(dir)) {
3095
3712
  mkdirSync6(dir, { recursive: true });
3096
3713
  }
3097
3714
  appendRotatingLog(path, `[${new Date().toISOString()}] ${entry}
@@ -3254,11 +3871,11 @@ async function runCodex(args) {
3254
3871
  process.exit(1);
3255
3872
  }
3256
3873
  let proxyUrl;
3257
- const status = lifecycle.readStatus();
3258
- if (status?.proxyUrl) {
3259
- proxyUrl = status.proxyUrl;
3874
+ const record = lifecycle.readDaemonRecord();
3875
+ if (typeof record?.proxyUrl === "string" && record.proxyUrl.length > 0) {
3876
+ proxyUrl = record.proxyUrl;
3260
3877
  } else {
3261
- const fallbackProxyPort = process.env.CODEX_PROXY_PORT ?? String(new ConfigService().loadOrDefault().codex.proxyPort);
3878
+ const fallbackProxyPort = process.env.CODEX_PROXY_PORT ?? String(new ConfigService().loadOrDefault((msg) => console.error(`[agentbridge] ${msg}`)).codex.proxyPort);
3262
3879
  proxyUrl = `ws://127.0.0.1:${fallbackProxyPort}`;
3263
3880
  console.error(`[agentbridge] No daemon status found, using fallback proxy port: ${proxyUrl}`);
3264
3881
  }
@@ -3338,7 +3955,7 @@ async function runCodex(args) {
3338
3955
  env: buildChildEnv()
3339
3956
  });
3340
3957
  if (typeof child.pid === "number") {
3341
- writeFileSync7(stateDir.tuiPidFile, `${child.pid}
3958
+ writeFileSync5(stateDir.tuiPidFile, `${child.pid}
3342
3959
  `, "utf-8");
3343
3960
  appendWrapperLog(wrapperLogPath, `child pid=${child.pid}`);
3344
3961
  }
@@ -3378,7 +3995,7 @@ async function runCodex(args) {
3378
3995
  return;
3379
3996
  cleanedTuiPid = true;
3380
3997
  try {
3381
- unlinkSync4(stateDir.tuiPidFile);
3998
+ unlinkSync6(stateDir.tuiPidFile);
3382
3999
  } catch {}
3383
4000
  }
3384
4001
  function requestChildTermination(reason) {
@@ -3449,10 +4066,14 @@ async function runCodex(args) {
3449
4066
  else if (typeof code === "number" && code !== 0)
3450
4067
  classification = `nonzero_exit:${code}`;
3451
4068
  else if (code === 0 && tail.trim().length === 0) {
3452
- classification = refineCleanExitClassification(readTurnInProgress(stateDir.statusFile));
4069
+ classification = refineCleanExitClassification(readUnifiedTurnInProgress({
4070
+ daemonRecordFile: stateDir.daemonRecordFile,
4071
+ pidFile: stateDir.pidFile,
4072
+ statusFile: stateDir.statusFile
4073
+ }));
3453
4074
  }
3454
4075
  const tuiLogTail = captureTuiLogTail({
3455
- codexHome: join11(homedir4(), ".codex"),
4076
+ codexHome: join12(homedir4(), ".codex"),
3456
4077
  nativePid: nativeChildPid,
3457
4078
  run: (cmd, args2) => execFileSync6(cmd, args2, { encoding: "utf-8", timeout: 2000 })
3458
4079
  });
@@ -3508,12 +4129,12 @@ function guardNoLiveManagedTui(stateDir, proxyUrl) {
3508
4129
  if (pid) {
3509
4130
  if (!isProcessAlive(pid)) {
3510
4131
  try {
3511
- unlinkSync4(stateDir.tuiPidFile);
4132
+ unlinkSync6(stateDir.tuiPidFile);
3512
4133
  } catch {}
3513
4134
  } else if (!isManagedCodexTuiProcess(pid, proxyUrl)) {
3514
4135
  appendWrapperLog(stateDir.codexWrapperLogFile, `stale tui pid file pointed at unmanaged live pid=${pid}; removing`);
3515
4136
  try {
3516
- unlinkSync4(stateDir.tuiPidFile);
4137
+ unlinkSync6(stateDir.tuiPidFile);
3517
4138
  } catch {}
3518
4139
  } else {
3519
4140
  console.error(`[agentbridge] This pair already has a managed Codex TUI running (pid ${pid}).`);
@@ -3530,7 +4151,7 @@ function guardNoLiveManagedTui(stateDir, proxyUrl) {
3530
4151
  }
3531
4152
  function readTuiPid(stateDir) {
3532
4153
  try {
3533
- const raw = readFileSync9(stateDir.tuiPidFile, "utf-8").trim();
4154
+ const raw = readFileSync10(stateDir.tuiPidFile, "utf-8").trim();
3534
4155
  if (!raw)
3535
4156
  return null;
3536
4157
  const pid = Number.parseInt(raw, 10);
@@ -3544,7 +4165,12 @@ function isManagedCodexTuiProcess(pid, proxyUrl) {
3544
4165
  return cmd !== null && commandMatchesManagedCodexTui(cmd, proxyUrl);
3545
4166
  }
3546
4167
  function proxyHealthUrl(proxyUrl) {
3547
- const url = new URL(proxyUrl);
4168
+ let url;
4169
+ try {
4170
+ url = new URL(proxyUrl);
4171
+ } catch {
4172
+ throw new Error(`Malformed Codex proxy URL: ${JSON.stringify(proxyUrl)}`);
4173
+ }
3548
4174
  url.protocol = url.protocol === "wss:" ? "https:" : "http:";
3549
4175
  url.pathname = "/healthz";
3550
4176
  url.search = "";
@@ -3560,7 +4186,7 @@ async function waitForProxyReady(proxyUrl, maxRetries = 20, delayMs = 100) {
3560
4186
  return;
3561
4187
  }
3562
4188
  } catch {}
3563
- await new Promise((resolve4) => setTimeout(resolve4, delayMs));
4189
+ await new Promise((resolve5) => setTimeout(resolve5, delayMs));
3564
4190
  }
3565
4191
  throw new Error(`Timed out waiting for Codex proxy readiness on ${healthUrl}`);
3566
4192
  }
@@ -3609,17 +4235,17 @@ var init_codex = __esm(() => {
3609
4235
  });
3610
4236
 
3611
4237
  // src/claude-session.ts
3612
- import { readdirSync as readdirSync4, statSync as statSync5 } from "fs";
4238
+ import { readdirSync as readdirSync5, statSync as statSync6 } from "fs";
3613
4239
  import { homedir as homedir5 } from "os";
3614
- import { join as join12 } from "path";
4240
+ import { join as join13 } from "path";
3615
4241
  function encodeClaudeProjectDir(cwd) {
3616
4242
  return cwd.replace(/[^a-zA-Z0-9]/g, "-");
3617
4243
  }
3618
- function findLatestClaudeSession(cwd, claudeHome = process.env.CLAUDE_CONFIG_DIR || join12(homedir5(), ".claude")) {
3619
- const dir = join12(claudeHome, "projects", encodeClaudeProjectDir(cwd));
4244
+ function findLatestClaudeSession(cwd, claudeHome = process.env.CLAUDE_CONFIG_DIR || join13(homedir5(), ".claude")) {
4245
+ const dir = join13(claudeHome, "projects", encodeClaudeProjectDir(cwd));
3620
4246
  let entries;
3621
4247
  try {
3622
- entries = readdirSync4(dir);
4248
+ entries = readdirSync5(dir);
3623
4249
  } catch {
3624
4250
  return null;
3625
4251
  }
@@ -3630,10 +4256,10 @@ function findLatestClaudeSession(cwd, claudeHome = process.env.CLAUDE_CONFIG_DIR
3630
4256
  const sessionId = name.slice(0, -".jsonl".length);
3631
4257
  if (!SESSION_ID_PATTERN.test(sessionId))
3632
4258
  continue;
3633
- const file = join12(dir, name);
4259
+ const file = join13(dir, name);
3634
4260
  let mtimeMs;
3635
4261
  try {
3636
- const st = statSync5(file);
4262
+ const st = statSync6(file);
3637
4263
  if (!st.isFile())
3638
4264
  continue;
3639
4265
  mtimeMs = st.mtimeMs;
@@ -3739,8 +4365,8 @@ __export(exports_kill, {
3739
4365
  runKill: () => runKill,
3740
4366
  formatKillReport: () => formatKillReport
3741
4367
  });
3742
- import { readFileSync as readFileSync10, unlinkSync as unlinkSync5 } from "fs";
3743
- import { join as join13 } from "path";
4368
+ import { readFileSync as readFileSync11, unlinkSync as unlinkSync7 } from "fs";
4369
+ import { join as join14 } from "path";
3744
4370
  async function runKill(args = []) {
3745
4371
  const argError = validateKillArgs(args);
3746
4372
  if (argError === "help") {
@@ -3760,8 +4386,9 @@ async function runKill(args = []) {
3760
4386
  const base = computeBaseDir();
3761
4387
  console.log(`AgentBridge Kill \u2014 stopping AgentBridge pair processes
3762
4388
  `);
4389
+ const cli = cliInvocationName();
3763
4390
  const results = [];
3764
- let restartCommand = "agentbridge claude";
4391
+ let restartCommand = `${cli} claude`;
3765
4392
  if (parsed.pairFlag !== undefined) {
3766
4393
  let pair;
3767
4394
  try {
@@ -3775,7 +4402,7 @@ async function runKill(args = []) {
3775
4402
  printKnownPairs(base);
3776
4403
  return;
3777
4404
  }
3778
- restartCommand = `agentbridge --pair ${pair.name ?? parsed.pairFlag} claude`;
4405
+ restartCommand = `${cli} --pair ${pair.name ?? parsed.pairFlag} claude`;
3779
4406
  results.push(await stopPairEntry(base, pair));
3780
4407
  } else if (parsed.all) {
3781
4408
  let registered = [];
@@ -3792,7 +4419,7 @@ async function runKill(args = []) {
3792
4419
  for (const dirName of listPairDirsSafe(base)) {
3793
4420
  if (registeredIds.has(dirName))
3794
4421
  continue;
3795
- const stateDir = new StateDirResolver(join13(base, "pairs", dirName));
4422
+ const stateDir = new StateDirResolver(join14(base, "pairs", dirName));
3796
4423
  results.push(await stopStateDir(`${dirName} (unregistered)`, stateDir, portsFromStateDir(stateDir)));
3797
4424
  }
3798
4425
  const legacy = detectLegacyRootDaemon(base);
@@ -3809,7 +4436,7 @@ async function runKill(args = []) {
3809
4436
  cwdPairs = listPairsForCwd(base, process.cwd());
3810
4437
  } catch (error) {
3811
4438
  const message = error instanceof Error ? error.message : String(error);
3812
- console.log(`\u26A0\uFE0F pair registry \u4E0D\u53EF\u8BFB\uFF08${message}\uFF09\u2014\u2014\u65E0\u6CD5\u6309\u76EE\u5F55\u5B9A\u4F4D pair\u3002` + "\u8FD0\u884C `abg kill --all` \u53EF\u964D\u7EA7\u4E3A\u5168\u76D8\u72B6\u6001\u76EE\u5F55\u626B\u63CF\uFF0C\u505C\u6B62\u6240\u6709\u80FD\u627E\u5230\u7684 pair\u3002");
4439
+ console.log(`\u26A0\uFE0F pair registry \u4E0D\u53EF\u8BFB\uFF08${message}\uFF09\u2014\u2014\u65E0\u6CD5\u6309\u76EE\u5F55\u5B9A\u4F4D pair\u3002` + `\u8FD0\u884C \`${cli} kill --all\` \u53EF\u964D\u7EA7\u4E3A\u5168\u76D8\u72B6\u6001\u76EE\u5F55\u626B\u63CF\uFF0C\u505C\u6B62\u6240\u6709\u80FD\u627E\u5230\u7684 pair\u3002`);
3813
4440
  process.exitCode = 2;
3814
4441
  }
3815
4442
  for (const pair of cwdPairs) {
@@ -3825,7 +4452,7 @@ async function runKill(args = []) {
3825
4452
  }
3826
4453
  if (results.length === 0) {
3827
4454
  console.log(`No AgentBridge pairs registered for current directory: ${process.cwd()}`);
3828
- console.log("Use `abg kill all` or `abg kill --all` to stop pairs from every directory.");
4455
+ console.log(`Use \`${cli} kill all\` or \`${cli} kill --all\` to stop pairs from every directory.`);
3829
4456
  return;
3830
4457
  }
3831
4458
  }
@@ -3877,7 +4504,7 @@ No arguments stop this directory's registered pairs and any legacy-root daemon.
3877
4504
  }
3878
4505
  async function stopPairEntry(base, pair) {
3879
4506
  const ports = portsForEntry(pair);
3880
- const stateDir = new StateDirResolver(join13(base, "pairs", pair.pairId));
4507
+ const stateDir = new StateDirResolver(join14(base, "pairs", pair.pairId));
3881
4508
  return stopStateDir(pair.pairId, stateDir, ports);
3882
4509
  }
3883
4510
  function listPairDirsSafe(base) {
@@ -3888,22 +4515,18 @@ function listPairDirsSafe(base) {
3888
4515
  }
3889
4516
  }
3890
4517
  function portsFromStateDir(stateDir) {
3891
- try {
3892
- const raw = JSON.parse(readFileSync10(stateDir.statusFile, "utf-8"));
3893
- return {
3894
- appPort: portFromUrl(raw?.appServerUrl) ?? 0,
3895
- proxyPort: portFromUrl(raw?.proxyUrl) ?? 0,
3896
- controlPort: typeof raw?.controlPort === "number" ? raw.controlPort : 0
3897
- };
3898
- } catch {
4518
+ const record = readUnifiedDaemonRecord({
4519
+ daemonRecordFile: stateDir.daemonRecordFile,
4520
+ pidFile: stateDir.pidFile,
4521
+ statusFile: stateDir.statusFile
4522
+ });
4523
+ if (!record)
3899
4524
  return { appPort: 0, proxyPort: 0, controlPort: 0 };
3900
- }
3901
- }
3902
- function portFromUrl(url) {
3903
- if (typeof url !== "string")
3904
- return null;
3905
- const match = url.match(/:(\d+)(?:[/?]|$)/);
3906
- return match ? Number.parseInt(match[1], 10) : null;
4525
+ return {
4526
+ appPort: record.ports?.appPort ?? portFromUrl(record.appServerUrl) ?? 0,
4527
+ proxyPort: record.ports?.proxyPort ?? portFromUrl(record.proxyUrl) ?? 0,
4528
+ controlPort: record.ports?.controlPort ?? 0
4529
+ };
3907
4530
  }
3908
4531
  async function stopStateDir(label, stateDir, ports) {
3909
4532
  const portsLabel = `${ports.appPort}/${ports.proxyPort}/${ports.controlPort}`;
@@ -3916,8 +4539,8 @@ async function stopStateDir(label, stateDir, ports) {
3916
4539
  log
3917
4540
  });
3918
4541
  lifecycle.markKilled();
3919
- const status = lifecycle.readStatus();
3920
- const proxyUrl = typeof status?.proxyUrl === "string" && status.proxyUrl.length > 0 ? status.proxyUrl : `ws://127.0.0.1:${ports.proxyPort}`;
4542
+ const record = lifecycle.readDaemonRecord();
4543
+ const proxyUrl = typeof record?.proxyUrl === "string" && record.proxyUrl.length > 0 ? record.proxyUrl : `ws://127.0.0.1:${ports.proxyPort}`;
3921
4544
  const tuiKilled = await killManagedCodexTui(stateDir, proxyUrl, log);
3922
4545
  const daemonKilled = await lifecycle.kill();
3923
4546
  return { label, portsLabel, daemonKilled, tuiKilled, details };
@@ -3982,9 +4605,10 @@ function formatKillReport(results, frontends, restartCommand) {
3982
4605
  }
3983
4606
  lines.push("");
3984
4607
  if (stopped.length > 0) {
4608
+ const cliName = restartCommand.split(" ")[0] ?? "abg";
3985
4609
  lines.push("AgentBridge stopped.");
3986
4610
  lines.push(`Please restart Claude Code (\`${restartCommand}\`), switch to a new conversation, or run \`/resume\` to fully disconnect.`);
3987
- lines.push("\u2139\uFE0F \u5DF2\u5199\u5165 killed \u54E8\u5175\uFF1A\u88AB\u505C\u6B62\u7684 pair \u4E0D\u4F1A\u88AB\u81EA\u52A8\u590D\u6D3B\uFF1B" + `\u4E0B\u6B21 \`${restartCommand}\` / \`agentbridge codex\` \u4F1A\u6E05\u9664\u54E8\u5175\u5E76\u7528\u5F53\u524D\u5B89\u88C5\u7248\u672C\u542F\u52A8\u5168\u65B0 daemon\u3002`);
4611
+ lines.push("\u2139\uFE0F \u5DF2\u5199\u5165 killed \u54E8\u5175\uFF1A\u88AB\u505C\u6B62\u7684 pair \u4E0D\u4F1A\u88AB\u81EA\u52A8\u590D\u6D3B\uFF1B" + `\u4E0B\u6B21 \`${restartCommand}\` / \`${cliName} codex\` \u4F1A\u6E05\u9664\u54E8\u5175\u5E76\u7528\u5F53\u524D\u5B89\u88C5\u7248\u672C\u542F\u52A8\u5168\u65B0 daemon\u3002`);
3988
4612
  } else {
3989
4613
  lines.push("No running AgentBridge daemon or managed Codex TUI found.");
3990
4614
  lines.push("\u2139\uFE0F \u76EE\u6807 pair \u90FD\u6CA1\u6709\u5728\u8FD0\u884C\u7684\u8FDB\u7A0B\u2014\u2014\u5982\u679C\u4F60\u4ECD\u770B\u5230 AgentBridge \u6D3B\u52A8\uFF0C\u89C1\u4E0B\u65B9\u524D\u7AEF\u63D0\u793A\u3002");
@@ -4035,7 +4659,7 @@ async function killManagedCodexTui(stateDir, proxyUrl, log, gracefulTimeoutMs =
4035
4659
  }
4036
4660
  function readTuiPid2(stateDir) {
4037
4661
  try {
4038
- const raw = readFileSync10(stateDir.tuiPidFile, "utf-8").trim();
4662
+ const raw = readFileSync11(stateDir.tuiPidFile, "utf-8").trim();
4039
4663
  if (!raw)
4040
4664
  return null;
4041
4665
  const pid = Number.parseInt(raw, 10);
@@ -4046,7 +4670,7 @@ function readTuiPid2(stateDir) {
4046
4670
  }
4047
4671
  function removeTuiPidFile(stateDir) {
4048
4672
  try {
4049
- unlinkSync5(stateDir.tuiPidFile);
4673
+ unlinkSync7(stateDir.tuiPidFile);
4050
4674
  } catch {}
4051
4675
  }
4052
4676
  function isManagedCodexTuiProcess2(pid, proxyUrl) {
@@ -4054,7 +4678,9 @@ function isManagedCodexTuiProcess2(pid, proxyUrl) {
4054
4678
  return cmd !== null && commandMatchesManagedCodexTui(cmd, proxyUrl);
4055
4679
  }
4056
4680
  var init_kill = __esm(() => {
4681
+ init_cli_invocation();
4057
4682
  init_daemon_lifecycle();
4683
+ init_daemon_record();
4058
4684
  init_pair_registry();
4059
4685
  init_pair_resolver();
4060
4686
  init_process_lifecycle();
@@ -4066,7 +4692,14 @@ var exports_pairs = {};
4066
4692
  __export(exports_pairs, {
4067
4693
  runPairs: () => runPairs
4068
4694
  });
4069
- import { join as join14 } from "path";
4695
+ import { join as join15 } from "path";
4696
+ function isRegistryCorruptError(error) {
4697
+ return error instanceof PairError && error.code === "PAIR_REGISTRY_CORRUPT";
4698
+ }
4699
+ function registryPathForNotice(base, error) {
4700
+ const fromDetails = error.details?.path;
4701
+ return typeof fromDetails === "string" && fromDetails.length > 0 ? fromDetails : join15(pairsRootDir(base), "registry.json");
4702
+ }
4070
4703
  async function runPairs(args = []) {
4071
4704
  const [command, ...rest] = args;
4072
4705
  if (command === "rm") {
@@ -4079,7 +4712,7 @@ async function runPairs(args = []) {
4079
4712
  }
4080
4713
  if (command && command !== "list" && command !== "--json" && command !== "--threads") {
4081
4714
  console.error(`Unknown pairs command: ${command}`);
4082
- console.error("Usage: abg pairs [--json] [--threads] | abg pairs rm <name|id> | abg pairs prune [--dry-run]");
4715
+ console.error("Usage: abg pairs [--json] [--threads] | abg pairs rm <name|id> | abg pairs prune [--apply]");
4083
4716
  process.exit(1);
4084
4717
  }
4085
4718
  const json = command === "--json" || rest.includes("--json");
@@ -4134,16 +4767,40 @@ async function runRemove(args) {
4134
4767
  }
4135
4768
  }
4136
4769
  async function runPrune(args) {
4137
- const dryRun = args.includes("--dry-run");
4770
+ const apply = args.includes("--apply");
4138
4771
  for (const arg of args) {
4139
- if (arg !== "--dry-run") {
4772
+ if (arg !== "--apply" && arg !== "--dry-run") {
4140
4773
  console.error(`Unknown prune argument: ${arg}`);
4141
- console.error("Usage: abg pairs prune [--dry-run]");
4774
+ console.error("Usage: abg pairs prune [--apply]");
4142
4775
  process.exit(1);
4143
4776
  }
4144
4777
  }
4778
+ if (apply && args.includes("--dry-run")) {
4779
+ console.error("Error: --apply and --dry-run are mutually exclusive.");
4780
+ console.error("Usage: abg pairs prune [--apply]");
4781
+ process.exit(1);
4782
+ }
4145
4783
  const base = computeBaseDir();
4146
- const registered = new Set(listPairs(base).map((pair) => pair.pairId.toLowerCase()));
4784
+ let reclaimable;
4785
+ let registryReadable = true;
4786
+ try {
4787
+ reclaimable = classifyReclaimableEntries(base);
4788
+ } catch (error) {
4789
+ if (!isRegistryCorruptError(error))
4790
+ throw error;
4791
+ registryReadable = false;
4792
+ reclaimable = [];
4793
+ console.error(`\u26A0\uFE0F pair registry \u4E0D\u53EF\u8BFB\uFF08${error.message}\uFF09\u2014\u2014` + `\u4F4D\u4E8E ${registryPathForNotice(base, error)}\u3002` + `\u8DF3\u8FC7 registry \u6761\u76EE\u56DE\u6536\uFF0C\u964D\u7EA7\u4E3A\u78C1\u76D8\u626B\u63CF\u6E05\u7406\u5B64\u513F\u76EE\u5F55\uFF08\u65E0\u9700\u53EF\u89E3\u6790\u7684 registry\uFF09\u3002` + `\u4FEE\u590D\u6216\u5220\u9664\u8BE5\u6587\u4EF6\u540E\u53EF\u6062\u590D\u5B8C\u6574 prune\u3002`);
4794
+ process.exitCode = 2;
4795
+ }
4796
+ const reclaimableIds = new Set(reclaimable.map((c) => c.entry.pairId.toLowerCase()));
4797
+ const dirResult = pruneOrphanDirs(base, apply, reclaimableIds, registryReadable);
4798
+ const entryResult = await pruneReclaimableEntries(reclaimable, base, apply);
4799
+ const resolvedDirResult = await dirResult;
4800
+ printPruneSummary(resolvedDirResult, entryResult, apply);
4801
+ }
4802
+ async function pruneOrphanDirs(base, apply, reclaimableIds, registryReadable) {
4803
+ const registered = registryReadable ? new Set(listPairs(base).map((pair) => pair.pairId.toLowerCase())) : new Set;
4147
4804
  const removed = [];
4148
4805
  const kept = [];
4149
4806
  for (const name of listPairDirs(base)) {
@@ -4158,6 +4815,9 @@ async function runPrune(args) {
4158
4815
  kept.push({ name, reason: "directory name is not a canonical pair id" });
4159
4816
  continue;
4160
4817
  }
4818
+ if (reclaimableIds.has(id.toLowerCase())) {
4819
+ continue;
4820
+ }
4161
4821
  if (registered.has(id.toLowerCase())) {
4162
4822
  kept.push({ name, reason: "registered \u2014 use `abg pairs rm`" });
4163
4823
  continue;
@@ -4166,12 +4826,12 @@ async function runPrune(args) {
4166
4826
  kept.push({ name, reason: "daemon still alive" });
4167
4827
  continue;
4168
4828
  }
4169
- if (dryRun) {
4829
+ if (!apply) {
4170
4830
  removed.push(name);
4171
4831
  continue;
4172
4832
  }
4173
4833
  try {
4174
- const outcome = await removeUnregisteredPairDir(base, id);
4834
+ const outcome = registryReadable ? await removeUnregisteredPairDir(base, id) : await removeOrphanPairDirIgnoringRegistry(base, id);
4175
4835
  if (outcome.removed) {
4176
4836
  removed.push(name);
4177
4837
  } else if (outcome.reason === "registered") {
@@ -4185,33 +4845,90 @@ async function runPrune(args) {
4185
4845
  kept.push({ name, reason: `error: ${err instanceof Error ? err.message : String(err)}` });
4186
4846
  }
4187
4847
  }
4188
- printPruneSummary(removed, kept, dryRun);
4848
+ return { removed, kept };
4849
+ }
4850
+ async function pruneReclaimableEntries(candidates, base, apply) {
4851
+ const reclaimed = [];
4852
+ const kept = [];
4853
+ for (const candidate of candidates) {
4854
+ const reason = describeReclaimReason(candidate);
4855
+ if (!apply) {
4856
+ reclaimed.push({ pairId: candidate.entry.pairId, slot: candidate.entry.slot, reason });
4857
+ continue;
4858
+ }
4859
+ try {
4860
+ const res = await removePairEntryAndDir(base, candidate.entry.pairId);
4861
+ if (res.keptLive) {
4862
+ kept.push({ pairId: candidate.entry.pairId, reason: "became live during prune" });
4863
+ } else {
4864
+ reclaimed.push({ pairId: candidate.entry.pairId, slot: candidate.entry.slot, reason });
4865
+ }
4866
+ } catch (err) {
4867
+ kept.push({
4868
+ pairId: candidate.entry.pairId,
4869
+ reason: `error: ${err instanceof Error ? err.message : String(err)}`
4870
+ });
4871
+ }
4872
+ }
4873
+ return { reclaimed, kept };
4189
4874
  }
4190
- function printPruneSummary(removed, kept, dryRun) {
4191
- if (removed.length === 0 && kept.length === 0) {
4192
- console.log("No pair directories found.");
4875
+ function describeReclaimReason(candidate) {
4876
+ const { signals } = candidate;
4877
+ const age = signals.ageMs === null ? "age?" : `age ${formatAgeDays(signals.ageMs)}`;
4878
+ return `cwd-gone, dead, ${age}`;
4879
+ }
4880
+ function formatAgeDays(ageMs) {
4881
+ const days = ageMs / (24 * 60 * 60 * 1000);
4882
+ return days >= 10 ? `${Math.round(days)}d` : `${days.toFixed(1)}d`;
4883
+ }
4884
+ function printPruneSummary(dirResult, entryResult, apply) {
4885
+ const { removed: dirsRemoved, kept: dirsKept } = dirResult;
4886
+ const { reclaimed: entriesReclaimed, kept: entriesKept } = entryResult;
4887
+ const nothingFound = dirsRemoved.length === 0 && dirsKept.length === 0 && entriesReclaimed.length === 0 && entriesKept.length === 0;
4888
+ if (nothingFound) {
4889
+ console.log("Nothing to prune: no orphan pair directories or reclaimable entries found.");
4193
4890
  return;
4194
4891
  }
4195
- if (removed.length > 0) {
4196
- console.log(dryRun ? "Would remove orphan pair directories:" : "Removed orphan pair directories:");
4197
- for (const name of removed)
4892
+ if (dirsRemoved.length > 0) {
4893
+ console.log(apply ? "Removed orphan pair directories:" : "Would remove orphan pair directories:");
4894
+ for (const name of dirsRemoved)
4198
4895
  console.log(` ${name}`);
4199
- } else {
4200
- console.log(dryRun ? "No orphan pair directories to remove." : "No orphan pair directories removed.");
4201
4896
  }
4202
- if (kept.length > 0) {
4897
+ if (entriesReclaimed.length > 0) {
4898
+ console.log(apply ? "Reclaimed registry entries:" : "Would reclaim registry entries:");
4899
+ for (const { pairId, slot, reason } of entriesReclaimed) {
4900
+ console.log(` ${pairId} (slot ${slot}) \u2014 ${reason}`);
4901
+ }
4902
+ }
4903
+ if (dirsRemoved.length === 0 && entriesReclaimed.length === 0) {
4904
+ console.log(apply ? "Nothing was reclaimed." : "Nothing to reclaim.");
4905
+ }
4906
+ const keptLines = [
4907
+ ...dirsKept.map(({ name, reason }) => ` ${name} (${reason})`),
4908
+ ...entriesKept.map(({ pairId, reason }) => ` ${pairId} (${reason})`)
4909
+ ];
4910
+ if (keptLines.length > 0) {
4203
4911
  console.log("Kept:");
4204
- for (const { name, reason } of kept)
4205
- console.log(` ${name} (${reason})`);
4912
+ for (const line of keptLines)
4913
+ console.log(line);
4206
4914
  }
4207
- if (dryRun) {
4915
+ if (!apply) {
4208
4916
  console.log(`
4209
- (dry run \u2014 nothing was deleted. Re-run without --dry-run to apply.)`);
4917
+ (dry run \u2014 nothing was deleted. Re-run with --apply to reclaim.)`);
4210
4918
  }
4211
4919
  }
4212
4920
  async function collectRows() {
4213
4921
  const base = computeBaseDir();
4214
- const rows = await Promise.all(listPairs(base).map((pair) => rowForPair(base, pair)));
4922
+ let rows;
4923
+ try {
4924
+ rows = await Promise.all(listPairs(base).map((pair) => rowForPair(base, pair)));
4925
+ } catch (error) {
4926
+ if (!isRegistryCorruptError(error))
4927
+ throw error;
4928
+ console.error(`\u26A0\uFE0F pair registry \u4E0D\u53EF\u8BFB\uFF08${error.message}\uFF09\u2014\u2014` + `\u4F4D\u4E8E ${registryPathForNotice(base, error)}\u3002` + `\u964D\u7EA7\u4E3A\u78C1\u76D8\u626B\u63CF\u5217\u51FA ${pairsRootDir(base)} \u4E0B\u7684 pair \u76EE\u5F55\uFF08slot/name/cwd \u7B49\u9700 registry \u7684\u5B57\u6BB5\u663E\u793A\u4E3A -\uFF09\u3002` + `\u4FEE\u590D\u6216\u5220\u9664\u8BE5\u6587\u4EF6\u540E\u53EF\u6062\u590D\u5B8C\u6574\u5217\u8868\uFF1B\u7528 \`abg pairs prune\` \u6E05\u7406\u5B64\u513F\u76EE\u5F55\u3002`);
4929
+ process.exitCode = 2;
4930
+ rows = await collectDiskScanRows(base);
4931
+ }
4215
4932
  const legacy = detectLegacyRootDaemon(base);
4216
4933
  if (legacy) {
4217
4934
  rows.push({
@@ -4232,15 +4949,15 @@ async function collectRows() {
4232
4949
  }
4233
4950
  async function rowForPair(base, pair) {
4234
4951
  const ports = portsForEntry(pair);
4235
- const stateDir = new StateDirResolver(join14(base, "pairs", pair.pairId));
4952
+ const stateDir = new StateDirResolver(join15(base, "pairs", pair.pairId));
4236
4953
  const lifecycle = new DaemonLifecycle({
4237
4954
  stateDir,
4238
4955
  controlPort: ports.controlPort,
4239
4956
  log: () => {}
4240
4957
  });
4241
- const [running, status] = await Promise.all([
4958
+ const [running, record] = await Promise.all([
4242
4959
  lifecycle.isHealthy(),
4243
- Promise.resolve(lifecycle.readStatus())
4960
+ Promise.resolve(lifecycle.readDaemonRecord())
4244
4961
  ]);
4245
4962
  const thread = readRawCurrentThread(stateDir);
4246
4963
  return {
@@ -4251,7 +4968,42 @@ async function rowForPair(base, pair) {
4251
4968
  source: pair.source,
4252
4969
  cwd: pair.cwd,
4253
4970
  running,
4254
- pid: typeof status?.pid === "number" ? status.pid : null,
4971
+ pid: typeof record?.pid === "number" ? record.pid : null,
4972
+ threadId: thread?.threadId ?? null,
4973
+ threadStatus: thread?.status ?? null,
4974
+ threadUpdatedAt: thread?.updatedAt ?? null
4975
+ };
4976
+ }
4977
+ async function collectDiskScanRows(base) {
4978
+ const names = listPairDirsSafe2(base);
4979
+ return Promise.all(names.map((name) => rowForDiskScanDir(base, name)));
4980
+ }
4981
+ function listPairDirsSafe2(base) {
4982
+ try {
4983
+ return listPairDirs(base);
4984
+ } catch {
4985
+ return [];
4986
+ }
4987
+ }
4988
+ async function rowForDiskScanDir(base, dirName) {
4989
+ const stateDir = new StateDirResolver(join15(base, "pairs", dirName));
4990
+ const record = new DaemonLifecycle({ stateDir, controlPort: 0, log: () => {} }).readDaemonRecord();
4991
+ const ports = {
4992
+ appPort: record?.ports?.appPort ?? 0,
4993
+ proxyPort: record?.ports?.proxyPort ?? 0,
4994
+ controlPort: record?.ports?.controlPort ?? 0
4995
+ };
4996
+ const running = ports.controlPort > 0 ? await new DaemonLifecycle({ stateDir, controlPort: ports.controlPort, log: () => {} }).isHealthy() : false;
4997
+ const thread = readRawCurrentThread(stateDir);
4998
+ return {
4999
+ pairId: dirName,
5000
+ name: "-",
5001
+ slot: null,
5002
+ ports,
5003
+ source: "cwd",
5004
+ cwd: "-",
5005
+ running,
5006
+ pid: typeof record?.pid === "number" ? record.pid : null,
4255
5007
  threadId: thread?.threadId ?? null,
4256
5008
  threadStatus: thread?.status ?? null,
4257
5009
  threadUpdatedAt: thread?.updatedAt ?? null
@@ -4339,20 +5091,20 @@ var DAEMON_STATUS_FETCH_TIMEOUT_MS = 1000;
4339
5091
  import { Database } from "bun:sqlite";
4340
5092
  import {
4341
5093
  copyFileSync,
4342
- existsSync as existsSync11,
5094
+ existsSync as existsSync14,
4343
5095
  mkdirSync as mkdirSync7,
4344
- readFileSync as readFileSync11
5096
+ readFileSync as readFileSync12
4345
5097
  } from "fs";
4346
- import { dirname as dirname5, join as join15 } from "path";
5098
+ import { dirname as dirname5, join as join16 } from "path";
4347
5099
  function isKickoffText(text) {
4348
5100
  if (!text)
4349
5101
  return false;
4350
5102
  return KICKOFF_FINGERPRINTS.some((fingerprint) => text.includes(fingerprint));
4351
5103
  }
4352
5104
  function extractFirstRealUserMessage(rolloutPath) {
4353
- if (!existsSync11(rolloutPath))
5105
+ if (!existsSync14(rolloutPath))
4354
5106
  return null;
4355
- const raw = readFileSync11(rolloutPath, "utf-8");
5107
+ const raw = readFileSync12(rolloutPath, "utf-8");
4356
5108
  for (const line of raw.split(`
4357
5109
  `)) {
4358
5110
  if (!line.trim())
@@ -4374,8 +5126,8 @@ function extractFirstRealUserMessage(rolloutPath) {
4374
5126
  }
4375
5127
  function scanResumePollution(options = {}) {
4376
5128
  const codexHome2 = options.codexHome ?? codexHome();
4377
- const dbPath = options.dbPath ?? join15(codexHome2, "state_5.sqlite");
4378
- if (!existsSync11(dbPath)) {
5129
+ const dbPath = options.dbPath ?? join16(codexHome2, "state_5.sqlite");
5130
+ if (!existsSync14(dbPath)) {
4379
5131
  return { codexHome: codexHome2, dbPath, scanned: 0, candidates: [], applied: 0, renamed: 0, deleted: 0 };
4380
5132
  }
4381
5133
  const db = options.apply ? new Database(dbPath) : new Database(dbPath, { readonly: true });
@@ -4468,12 +5220,12 @@ function scanResumePollution(options = {}) {
4468
5220
  }
4469
5221
  function backupCodexStateFiles(dbPath, now = new Date().toISOString()) {
4470
5222
  const safeStamp = now.replace(/[:.]/g, "-");
4471
- const base = join15(dirname5(dbPath), "agentbridge-backups", `resume-pollution-${safeStamp}`);
5223
+ const base = join16(dirname5(dbPath), "agentbridge-backups", `resume-pollution-${safeStamp}`);
4472
5224
  mkdirSync7(base, { recursive: true });
4473
5225
  for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
4474
- if (!existsSync11(path))
5226
+ if (!existsSync14(path))
4475
5227
  continue;
4476
- const target = join15(base, path.split("/").pop());
5228
+ const target = join16(base, path.split("/").pop());
4477
5229
  mkdirSync7(dirname5(target), { recursive: true });
4478
5230
  copyFileSync(path, target);
4479
5231
  }
@@ -4519,11 +5271,12 @@ var init_resume_pollution = __esm(() => {
4519
5271
  var exports_doctor = {};
4520
5272
  __export(exports_doctor, {
4521
5273
  runDoctor: () => runDoctor,
4522
- formatDoctorReport: () => formatDoctorReport
5274
+ formatDoctorReport: () => formatDoctorReport,
5275
+ evaluateArtifactAlignment: () => evaluateArtifactAlignment,
5276
+ describeBuildDrift: () => describeBuildDrift
4523
5277
  });
4524
- import { existsSync as existsSync12, readFileSync as readFileSync12, readdirSync as readdirSync5, realpathSync as realpathSync3, statSync as statSync6 } from "fs";
4525
- import { homedir as homedir6 } from "os";
4526
- import { join as join16 } from "path";
5278
+ import { existsSync as existsSync15, readFileSync as readFileSync13, readdirSync as readdirSync6, realpathSync as realpathSync3, statSync as statSync7 } from "fs";
5279
+ import { join as join17 } from "path";
4527
5280
  async function runDoctor(args = []) {
4528
5281
  if (args[0] === "resume-pollution") {
4529
5282
  runResumePollution(args.slice(1));
@@ -4599,6 +5352,7 @@ function runResumePollution(args) {
4599
5352
  }
4600
5353
  async function buildDoctorReport(pair, registered) {
4601
5354
  const cwd = process.cwd();
5355
+ const cli = cliInvocationName();
4602
5356
  const env = inspectAgentBridgeEnv({ cwd, env: process.env });
4603
5357
  const [health, ready] = registered ? await Promise.all([
4604
5358
  fetchDaemonStatus(pair.ports.controlPort, "/healthz"),
@@ -4618,19 +5372,20 @@ async function buildDoctorReport(pair, registered) {
4618
5372
  name: "pair registration",
4619
5373
  status: registered ? "ok" : "warn",
4620
5374
  detail: registered ? pair.manual ? "manual mode (explicit env)" : `registered as ${pair.pairId}` : `not registered yet \u2014 would be ${pair.pairId} (created on first launch)`,
4621
- hint: registered ? undefined : "\u8BE5\u76EE\u5F55\u8FD8\u6CA1\u6709\u6CE8\u518C\u8FC7 pair\uFF1A\u8FD0\u884C `agentbridge claude` \u5373\u4F1A\u521B\u5EFA\u3002\u4EE5\u4E0B\u68C0\u67E5\u6309\u672A\u542F\u52A8\u72B6\u6001\u89E3\u8BFB\u3002"
5375
+ hint: registered ? undefined : `\u8BE5\u76EE\u5F55\u8FD8\u6CA1\u6709\u6CE8\u518C\u8FC7 pair\uFF1A\u8FD0\u884C \`${cli} claude\` \u5373\u4F1A\u521B\u5EFA\u3002\u4EE5\u4E0B\u68C0\u67E5\u6309\u672A\u542F\u52A8\u72B6\u6001\u89E3\u8BFB\u3002`
4622
5376
  });
4623
5377
  checks.push({
4624
5378
  name: "env",
4625
5379
  status: env.ok ? "ok" : "fail",
4626
5380
  detail: env.ok ? "AgentBridge env matches cwd" : env.reasons.join("; "),
4627
- hint: env.ok ? undefined : "\u73AF\u5883\u53D8\u91CF\u4E0E\u5F53\u524D\u76EE\u5F55\u4E0D\u5339\u914D\uFF1A\u8BF7\u5728\u6B63\u786E\u7684\u9879\u76EE\u76EE\u5F55\u91CC\u91CD\u65B0\u8FD0\u884C `agentbridge claude`\uFF0C\u4E0D\u8981\u590D\u7528\u5176\u4ED6\u76EE\u5F55\u7684\u4F1A\u8BDD\u73AF\u5883\u3002"
5381
+ hint: env.ok ? undefined : `\u73AF\u5883\u53D8\u91CF\u4E0E\u5F53\u524D\u76EE\u5F55\u4E0D\u5339\u914D\uFF1A\u8BF7\u5728\u6B63\u786E\u7684\u9879\u76EE\u76EE\u5F55\u91CC\u91CD\u65B0\u8FD0\u884C \`${cli} claude\`\uFF0C\u4E0D\u8981\u590D\u7528\u5176\u4ED6\u76EE\u5F55\u7684\u4F1A\u8BDD\u73AF\u5883\u3002`
4628
5382
  });
5383
+ checks.push(configParseabilityCheck(cwd, cli));
4629
5384
  checks.push({
4630
5385
  name: "daemon health",
4631
5386
  status: health ? "ok" : "warn",
4632
5387
  detail: health ? `healthz reachable pid=${health.pid}` : registered ? `no daemon reachable on :${pair.ports.controlPort}` : "n/a \u2014 pair not registered",
4633
- hint: health ? undefined : "daemon \u672A\u8FD0\u884C\u3002\u8FD0\u884C `agentbridge claude`\uFF08\u6216 `agentbridge codex`\uFF09\u4F1A\u81EA\u52A8\u542F\u52A8\u5B83\u3002"
5388
+ hint: health ? undefined : `daemon \u672A\u8FD0\u884C\u3002\u8FD0\u884C \`${cli} claude\`\uFF08\u6216 \`${cli} codex\`\uFF09\u4F1A\u81EA\u52A8\u542F\u52A8\u5B83\u3002`
4634
5389
  });
4635
5390
  checks.push({
4636
5391
  name: "daemon readiness",
@@ -4638,18 +5393,26 @@ async function buildDoctorReport(pair, registered) {
4638
5393
  detail: ready ? `ready thread=${ready.threadId ?? "none"}` : health ? "readyz is not OK" : "n/a \u2014 daemon not running",
4639
5394
  hint: !ready && health ? "daemon \u5728\u8FD0\u884C\u4F46 codex app-server \u5C1A\u672A\u5C31\u7EEA\uFF1B\u7A0D\u5019\u7247\u523B\u91CD\u8BD5\uFF0C\u6301\u7EED\u4E0D\u5C31\u7EEA\u8BF7\u67E5\u770B\u4E0B\u65B9 daemon log\u3002" : undefined
4640
5395
  });
5396
+ const appServerInfo = health?.appServerInfo ?? null;
5397
+ checks.push({
5398
+ name: "codex app-server",
5399
+ status: health ? "ok" : "skip",
5400
+ detail: !health ? "n/a \u2014 daemon not running" : appServerInfo ? `version=${appServerInfo.version ?? "unknown"}` + (appServerInfo.platformOs ? ` platform=${appServerInfo.platformOs}` : "") : "not captured yet \u2014 connect Codex (initialize handshake) to populate",
5401
+ hint: health && appServerInfo && appServerInfo.version === null ? "app-server \u672A\u8FD4\u56DE\u53EF\u89E3\u6790\u7684\u7248\u672C\u53F7\uFF08userAgent \u5F02\u5E38\uFF09\u3002\u82E5\u521A\u5347\u7EA7\u8FC7 Codex\uFF0C\u8BF7\u6838\u5BF9 codex-adapter \u7684 version-coupling checklist\u3002" : undefined
5402
+ });
5403
+ const drift = buildDrift === true ? describeBuildDrift(health?.build, BUILD_INFO, cli) : null;
4641
5404
  checks.push({
4642
5405
  name: "build drift",
4643
5406
  status: buildDrift === false ? "ok" : buildDrift === true ? "fail" : "skip",
4644
- detail: buildDrift === false ? `runtime matches launcher ${formatBuildInfo(BUILD_INFO)}` : buildDrift === true ? `runtime ${formatBuildInfo(health?.build)} differs from launcher ${formatBuildInfo(BUILD_INFO)}` : launcherStamped ? "n/a \u2014 daemon not running" : "n/a \u2014 launcher running from source (unstamped)",
4645
- hint: buildDrift === true ? "daemon \u8FD0\u884C\u7684\u662F\u65E7\u6784\u5EFA\uFF08\u901A\u5E38\u7531\u65E7\u7248 CLI \u6216\u672A\u91CD\u5F00\u7684 Claude Code \u7A97\u53E3\u542F\u52A8\uFF09\u3002" + "\u6CA1\u6709\u8FDB\u884C\u4E2D\u7684 Codex \u4F1A\u8BDD\u65F6\uFF0C\u8FD0\u884C `abg kill` \u540E\u91CD\u65B0 `agentbridge claude` \u5373\u53EF\u5BF9\u9F50\uFF1B" + "\u6709\u6D3B\u8DC3\u4F1A\u8BDD\u5219\u7B49\u6536\u5C3E\u540E\u518D\u91CD\u542F\u2014\u2014\u7248\u672C\u5DEE\u5F02\u4E0D\u4F1A\u5F3A\u6740\u6D3B\u8DC3\u4F1A\u8BDD\uFF0C\u53EF\u4EE5\u7EE7\u7EED\u7528\u3002" : undefined
5407
+ detail: buildDrift === false ? `runtime matches launcher ${formatBuildInfo(BUILD_INFO)}` : drift ? drift.detail : launcherStamped ? "n/a \u2014 daemon not running" : "n/a \u2014 launcher running from source (unstamped)",
5408
+ hint: drift?.hint
4646
5409
  });
4647
5410
  checks.push(artifactAlignmentCheck());
4648
5411
  checks.push({
4649
5412
  name: "current thread",
4650
5413
  status: usableThread ? "ok" : rawThread ? "warn" : registered ? "warn" : "skip",
4651
5414
  detail: usableThread ? `current=${usableThread.threadId}` : rawThread ? rawThread.status === "current" ? `stored ${rawThread.threadId} has no rollout file yet` : `stored ${rawThread.threadId} is still ${rawThread.status} (no first response yet)` : registered ? "no current-thread.json for this pair" : "n/a \u2014 pair not registered",
4652
- hint: usableThread ? undefined : rawThread ? "\u901A\u5E38\u65E0\u5BB3\uFF1A\u7EBF\u7A0B\u8FD8\u6CA1\u6709\u4EA7\u751F\u9996\u6761\u56DE\u5E94\u3001\u6216 rollout \u6587\u4EF6\u5C1A\u672A\u843D\u76D8\u3002" + "\u4EC5\u5F53 `abg codex`\uFF08resume\uFF09\u5931\u8D25\u65F6\u624D\u9700\u8981\u5904\u7406\uFF1A\u7528 `abg codex --new` \u5F00\u65B0\u7EBF\u7A0B\u3002" : registered ? "\u5C1A\u65E0\u7EBF\u7A0B\u8BB0\u5F55\uFF1A\u8FDE\u63A5 Codex \u540E\u5EFA\u7ACB\u9996\u4E2A\u7EBF\u7A0B\u65F6\u4F1A\u81EA\u52A8\u5199\u5165\uFF0C\u65E0\u9700\u5904\u7406\u3002" : undefined
5415
+ hint: usableThread ? undefined : rawThread ? "\u901A\u5E38\u65E0\u5BB3\uFF1A\u7EBF\u7A0B\u8FD8\u6CA1\u6709\u4EA7\u751F\u9996\u6761\u56DE\u5E94\u3001\u6216 rollout \u6587\u4EF6\u5C1A\u672A\u843D\u76D8\u3002" + `\u4EC5\u5F53 \`${cli} codex\`\uFF08resume\uFF09\u5931\u8D25\u65F6\u624D\u9700\u8981\u5904\u7406\uFF1A\u7528 \`${cli} codex --new\` \u5F00\u65B0\u7EBF\u7A0B\u3002` : registered ? "\u5C1A\u65E0\u7EBF\u7A0B\u8BB0\u5F55\uFF1A\u8FDE\u63A5 Codex \u540E\u5EFA\u7ACB\u9996\u4E2A\u7EBF\u7A0B\u65F6\u4F1A\u81EA\u52A8\u5199\u5165\uFF0C\u65E0\u9700\u5904\u7406\u3002" : undefined
4653
5416
  });
4654
5417
  const pairProxyUrl = `ws://127.0.0.1:${pair.ports.proxyPort}`;
4655
5418
  const managedTuis = listManagedCodexTuiProcesses();
@@ -4666,19 +5429,19 @@ async function buildDoctorReport(pair, registered) {
4666
5429
  name: "codex tui (this pair)",
4667
5430
  status: attachedHere.length > 0 ? "ok" : "warn",
4668
5431
  detail: attachedHere.length > 0 ? `${attachedHere.length} attached to ${pairProxyUrl} (pid ${attachedHere.map((t) => t.pid).join(", ")})` : `no managed Codex TUI attached to this pair's proxy ${pairProxyUrl}`,
4669
- hint: attachedHere.length > 0 ? undefined : "\u53E6\u5F00\u4E00\u4E2A\u7EC8\u7AEF\u3001\u5728\u540C\u4E00\u76EE\u5F55\u8FD0\u884C `agentbridge codex` \u8FDE\u63A5\u672C pair\u3002"
5432
+ hint: attachedHere.length > 0 ? undefined : `\u53E6\u5F00\u4E00\u4E2A\u7EC8\u7AEF\u3001\u5728\u540C\u4E00\u76EE\u5F55\u8FD0\u884C \`${cli} codex\` \u8FDE\u63A5\u672C pair\u3002`
4670
5433
  });
4671
5434
  checks.push({
4672
5435
  name: "codex tui (other pairs)",
4673
5436
  status: attachedElsewhere.length > 0 ? "warn" : "ok",
4674
5437
  detail: attachedElsewhere.length > 0 ? `${attachedElsewhere.length} managed Codex TUI(s) attached to a DIFFERENT pair/proxy \u2014 likely started from another cwd, will not bridge here: ` + attachedElsewhere.map((t) => `pid ${t.pid}\u2192${t.remoteUrl ?? "?"}`).join(", ") : "no managed Codex TUI attached to another pair",
4675
- hint: attachedElsewhere.length > 0 ? "\u8FD9\u4E9B TUI \u5C5E\u4E8E\u5176\u4ED6\u76EE\u5F55\u7684 pair\uFF0C\u4E0D\u5F71\u54CD\u672C pair\uFF1B\u5B83\u4EEC\u4E0D\u4F1A\u6865\u63A5\u5230\u8FD9\u91CC\u3002\u5982\u4E0D\u518D\u9700\u8981\uFF0C\u53BB\u5BF9\u5E94\u76EE\u5F55\u8FD0\u884C `abg kill`\u3002" : undefined
5438
+ hint: attachedElsewhere.length > 0 ? `\u8FD9\u4E9B TUI \u5C5E\u4E8E\u5176\u4ED6\u76EE\u5F55\u7684 pair\uFF0C\u4E0D\u5F71\u54CD\u672C pair\uFF1B\u5B83\u4EEC\u4E0D\u4F1A\u6865\u63A5\u5230\u8FD9\u91CC\u3002\u5982\u4E0D\u518D\u9700\u8981\uFF0C\u53BB\u5BF9\u5E94\u76EE\u5F55\u8FD0\u884C \`${cli} kill\`\u3002` : undefined
4676
5439
  });
4677
5440
  for (const [name, path] of [
4678
5441
  ["daemon log", pair.stateDir.logFile],
4679
5442
  ["codex wrapper log", pair.stateDir.codexWrapperLogFile]
4680
5443
  ]) {
4681
- checks.push(logCheck(name, path));
5444
+ checks.push(logCheck(name, path, cli));
4682
5445
  }
4683
5446
  return {
4684
5447
  cwd,
@@ -4699,62 +5462,126 @@ async function buildDoctorReport(pair, registered) {
4699
5462
  checks
4700
5463
  };
4701
5464
  }
5465
+ function describeBuildDrift(runtime, launcher, cli = "abg") {
5466
+ const basis = runtimeContractComparisonBasis(runtime, launcher);
5467
+ const baseDetail = `runtime ${formatBuildInfo(runtime)} differs from launcher ${formatBuildInfo(launcher)}`;
5468
+ const baseHint = "daemon \u8FD0\u884C\u7684\u662F\u65E7\u6784\u5EFA\uFF08\u901A\u5E38\u7531\u65E7\u7248 CLI \u6216\u672A\u91CD\u5F00\u7684 Claude Code \u7A97\u53E3\u542F\u52A8\uFF09\u3002" + `\u6CA1\u6709\u8FDB\u884C\u4E2D\u7684 Codex \u4F1A\u8BDD\u65F6\uFF0C\u8FD0\u884C \`${cli} kill\` \u540E\u91CD\u65B0 \`${cli} claude\` \u5373\u53EF\u5BF9\u9F50\uFF1B` + "\u6709\u6D3B\u8DC3\u4F1A\u8BDD\u5219\u7B49\u6536\u5C3E\u540E\u518D\u91CD\u542F\u2014\u2014\u7248\u672C\u5DEE\u5F02\u4E0D\u4F1A\u5F3A\u6740\u6D3B\u8DC3\u4F1A\u8BDD\uFF0C\u53EF\u4EE5\u7EE7\u7EED\u7528\u3002";
5469
+ if (basis === "codeHash") {
5470
+ return { detail: `${baseDetail} [compared by codeHash \u2014 real code difference]`, hint: baseHint };
5471
+ }
5472
+ return {
5473
+ detail: `${baseDetail} [compared by commit stamp \u2014 legacy build without codeHash]`,
5474
+ hint: baseHint + "\uFF08\u6CE8\u610F\uFF1A\u672C\u5224\u5B9A\u57FA\u4E8E commit stamp \u53E3\u5F84\u2014\u2014\u6709\u4E00\u4FA7\u662F\u7F3A codeHash \u7684\u65E7\u6784\u5EFA\uFF1Bsquash \u5408\u5E76\u4F1A\u8BA9 stamp \u6EDE\u540E\u4E00\u683C\uFF0C" + "\u6E90\u7801\u4E00\u81F4\u65F6\u4E5F\u53EF\u80FD\u8BEF\u62A5\u3002\u5347\u7EA7\u4E24\u7AEF\u5230\u5E26 codeHash \u7684\u6784\u5EFA\u540E\u5C06\u6309\u4EE3\u7801\u5185\u5BB9\u5224\u5B9A\u3002\uFF09"
5475
+ };
5476
+ }
5477
+ function isUsableCodeHash(hash) {
5478
+ return typeof hash === "string" && hash.length > 0 && hash !== "source";
5479
+ }
5480
+ function evaluateArtifactAlignment(stamps) {
5481
+ if (stamps.length < 2) {
5482
+ return {
5483
+ name: "artifact alignment",
5484
+ status: "skip",
5485
+ detail: "n/a \u2014 fewer than two stamped artifacts found"
5486
+ };
5487
+ }
5488
+ if (stamps.every((stamp) => isUsableCodeHash(stamp.codeHash))) {
5489
+ const rendered2 = stamps.map((stamp) => `${stamp.label}=${stamp.codeHash}`).join(", ");
5490
+ if (new Set(stamps.map((stamp) => stamp.codeHash)).size === 1) {
5491
+ return { name: "artifact alignment", status: "ok", detail: `codeHash basis: ${rendered2}` };
5492
+ }
5493
+ return {
5494
+ name: "artifact alignment",
5495
+ status: "fail",
5496
+ detail: `deployed artifacts contain DIFFERENT code (codeHash basis): ${rendered2}`,
5497
+ hint: "\u90E8\u7F72\u7269\u4EE3\u7801\u5206\u88C2\u4F1A\u5BFC\u81F4\u4E92\u76F8\u66FF\u6362 daemon\uFF08\u6740\u6389\u6D3B\u4F1A\u8BDD\uFF09\u3002\u5728\u4ED3\u5E93\u76EE\u5F55\u8FD0\u884C `bun run install:global` " + "\u4E00\u6B21\u6027\u5BF9\u9F50\u5168\u5C40 CLI \u4E0E\u63D2\u4EF6\u7F13\u5B58\uFF0C\u7136\u540E\u5173\u95ED\u5E76\u91CD\u5F00\u4ECD\u5728\u4F7F\u7528\u65E7\u63D2\u4EF6\u7684 Claude Code \u7A97\u53E3\u3002"
5498
+ };
5499
+ }
5500
+ const rendered = stamps.map((stamp) => `${stamp.label}=${stamp.commit}`).join(", ");
5501
+ if (new Set(stamps.map((stamp) => stamp.commit)).size === 1) {
5502
+ return {
5503
+ name: "artifact alignment",
5504
+ status: "ok",
5505
+ detail: `legacy commit-stamp basis: ${rendered}`
5506
+ };
5507
+ }
5508
+ return {
5509
+ name: "artifact alignment",
5510
+ status: "fail",
5511
+ detail: `deployed artifacts are at DIFFERENT builds (legacy commit-stamp basis): ${rendered}`,
5512
+ hint: "\uFF08stamp \u53E3\u5F84\uFF1A\u5B58\u5728\u7F3A codeHash \u7684\u65E7\u90E8\u7F72\u7269\uFF0C\u4E14 squash \u5408\u5E76\u4F1A\u8BA9 stamp \u6EDE\u540E\u4E00\u683C\uFF0C\u6E90\u7801\u4E00\u81F4\u65F6\u4E5F\u53EF\u80FD\u8BEF\u62A5\u3002\uFF09" + "\u90E8\u7F72\u7269\u7248\u672C\u5206\u88C2\u4F1A\u5BFC\u81F4\u4E92\u76F8\u66FF\u6362 daemon\uFF08\u6740\u6389\u6D3B\u4F1A\u8BDD\uFF09\u3002\u5728\u4ED3\u5E93\u76EE\u5F55\u8FD0\u884C `bun run install:global` " + "\u4E00\u6B21\u6027\u5BF9\u9F50\u5168\u5C40 CLI \u4E0E\u63D2\u4EF6\u7F13\u5B58\u5E76\u5347\u7EA7\u5230\u5E26 codeHash \u7684\u6784\u5EFA\uFF0C\u7136\u540E\u5173\u95ED\u5E76\u91CD\u5F00\u4ECD\u5728\u4F7F\u7528\u65E7\u63D2\u4EF6\u7684 Claude Code \u7A97\u53E3\uFF1B" + "\u5BF9\u9F50\u540E\u6B64\u68C0\u67E5\u5C06\u6309\u4EE3\u7801\u5185\u5BB9\uFF08codeHash\uFF09\u5224\u5B9A\uFF0Cstamp \u6EDE\u540E\u4E0D\u518D\u8BEF\u62A5\u3002"
5513
+ };
5514
+ }
4702
5515
  function artifactAlignmentCheck() {
4703
5516
  const stamps = [];
4704
5517
  if (BUILD_INFO.commit !== "source") {
4705
- stamps.push({ label: `launcher(${BUILD_INFO.bundle})`, commit: BUILD_INFO.commit });
5518
+ stamps.push({
5519
+ label: `launcher(${BUILD_INFO.bundle})`,
5520
+ commit: BUILD_INFO.commit,
5521
+ codeHash: hasValidCodeHash(BUILD_INFO) ? BUILD_INFO.codeHash ?? null : null
5522
+ });
4706
5523
  }
4707
5524
  const bin = Bun.which("agentbridge") ?? Bun.which("abg");
4708
5525
  if (bin) {
4709
5526
  try {
4710
- const commit = extractBundleCommit(realpathSync3(bin));
4711
- if (commit)
4712
- stamps.push({ label: "global-cli", commit });
5527
+ const stamp = extractBundleStamp(realpathSync3(bin));
5528
+ if (stamp)
5529
+ stamps.push({ label: "global-cli", ...stamp });
4713
5530
  } catch {}
4714
5531
  }
4715
- const cacheRoot = join16(homedir6(), ".claude", "plugins", "cache", "agentbridge", "agentbridge");
5532
+ const cacheRoot = pluginCacheRoot();
4716
5533
  try {
4717
- for (const version of readdirSync5(cacheRoot)) {
4718
- const commit = extractBundleCommit(join16(cacheRoot, version, "server", "daemon.js"));
4719
- if (commit)
4720
- stamps.push({ label: `plugin-cache@${version}`, commit });
5534
+ for (const version of readdirSync6(cacheRoot)) {
5535
+ const stamp = extractBundleStamp(join17(cacheRoot, version, "server", "daemon.js"));
5536
+ if (stamp)
5537
+ stamps.push({ label: `plugin-cache@${version}`, ...stamp });
4721
5538
  }
4722
5539
  } catch {}
4723
- const repoBundle = join16(process.cwd(), "plugins", "agentbridge", "server", "daemon.js");
4724
- if (existsSync12(repoBundle)) {
4725
- const commit = extractBundleCommit(repoBundle);
4726
- if (commit)
4727
- stamps.push({ label: "repo-bundle", commit });
5540
+ const repoBundle = join17(process.cwd(), "plugins", "agentbridge", "server", "daemon.js");
5541
+ if (existsSync15(repoBundle)) {
5542
+ const stamp = extractBundleStamp(repoBundle);
5543
+ if (stamp)
5544
+ stamps.push({ label: "repo-bundle", ...stamp });
4728
5545
  }
4729
- if (stamps.length < 2) {
5546
+ return evaluateArtifactAlignment(stamps);
5547
+ }
5548
+ function extractBundleStamp(path) {
5549
+ try {
5550
+ const text = readFileSync13(path, "utf-8");
5551
+ const commit = text.match(/commit:\s*defineString\("([^"]+)",\s*"source"\)/)?.[1] ?? null;
5552
+ if (!commit)
5553
+ return null;
5554
+ const codeHash = text.match(/codeHash:\s*defineString\("([^"]+)",\s*"source"\)/)?.[1] ?? null;
5555
+ return { commit, codeHash };
5556
+ } catch {
5557
+ return null;
5558
+ }
5559
+ }
5560
+ function configParseabilityCheck(cwd, cli) {
5561
+ const desc = new ConfigService(cwd).describeConfig();
5562
+ if (desc.state === "absent") {
4730
5563
  return {
4731
- name: "artifact alignment",
4732
- status: "skip",
4733
- detail: "n/a \u2014 fewer than two stamped artifacts found"
5564
+ name: "config.json",
5565
+ status: "ok",
5566
+ detail: `no project config at ${desc.path} \u2014 built-in defaults in effect`
4734
5567
  };
4735
5568
  }
4736
- const commits = new Set(stamps.map((s) => s.commit));
4737
- const rendered = stamps.map((s) => `${s.label}=${s.commit}`).join(", ");
4738
- if (commits.size === 1) {
4739
- return { name: "artifact alignment", status: "ok", detail: rendered };
5569
+ if (desc.state === "corrupt") {
5570
+ return {
5571
+ name: "config.json",
5572
+ status: "warn",
5573
+ detail: `unparseable at ${desc.path} (${desc.reason}) \u2014 custom thresholds NOT in effect, using defaults`,
5574
+ hint: "config.json \u635F\u574F\u6216\u5B57\u6BB5\u7C7B\u578B\u9519\u8BEF\uFF1Abridge \u5DF2\u56DE\u9000\u5230\u9ED8\u8BA4\u9608\u503C\uFF0C\u4F60\u7684\u81EA\u5B9A\u4E49 budget/idle \u8BBE\u7F6E\u672A\u751F\u6548\u3002" + `\u4FEE\u6B63\u8BE5\u6587\u4EF6\u7684 JSON \u8BED\u6CD5/\u5B57\u6BB5\u7C7B\u578B\u540E\u91CD\u542F \`${cli} claude\` \u5373\u53EF\u91CD\u65B0\u751F\u6548\u3002`
5575
+ };
4740
5576
  }
4741
5577
  return {
4742
- name: "artifact alignment",
4743
- status: "fail",
4744
- detail: `deployed artifacts are at DIFFERENT builds: ${rendered}`,
4745
- hint: "\u90E8\u7F72\u7269\u7248\u672C\u5206\u88C2\u4F1A\u5BFC\u81F4\u4E92\u76F8\u66FF\u6362 daemon\uFF08\u6740\u6389\u6D3B\u4F1A\u8BDD\uFF09\u3002\u5728\u4ED3\u5E93\u76EE\u5F55\u8FD0\u884C `bun run install:global` " + "\u4E00\u6B21\u6027\u5BF9\u9F50\u5168\u5C40 CLI \u4E0E\u63D2\u4EF6\u7F13\u5B58\uFF0C\u7136\u540E\u5173\u95ED\u5E76\u91CD\u5F00\u4ECD\u5728\u4F7F\u7528\u65E7\u63D2\u4EF6\u7684 Claude Code \u7A97\u53E3\u3002"
5578
+ name: "config.json",
5579
+ status: "ok",
5580
+ detail: desc.customValues ? `parsed at ${desc.path} \u2014 custom values in effect` : `parsed at ${desc.path} \u2014 all values match defaults`
4746
5581
  };
4747
5582
  }
4748
- function extractBundleCommit(path) {
4749
- try {
4750
- const match = readFileSync12(path, "utf-8").match(/commit:\s*defineString\("([^"]+)",\s*"source"\)/);
4751
- return match ? match[1] : null;
4752
- } catch {
4753
- return null;
4754
- }
4755
- }
4756
- function logCheck(name, path) {
4757
- if (!existsSync12(path)) {
5583
+ function logCheck(name, path, cli) {
5584
+ if (!existsSync15(path)) {
4758
5585
  return {
4759
5586
  name,
4760
5587
  status: "warn",
@@ -4762,13 +5589,13 @@ function logCheck(name, path) {
4762
5589
  hint: "\u65E5\u5FD7\u4F1A\u5728\u76F8\u5E94\u8FDB\u7A0B\u9996\u6B21\u542F\u52A8\u65F6\u521B\u5EFA\uFF1B\u8FDB\u7A0B\u4ECE\u672A\u542F\u52A8\u8FC7\u65F6\u8FD9\u662F\u6B63\u5E38\u7684\u3002"
4763
5590
  };
4764
5591
  }
4765
- const stat = statSync6(path);
5592
+ const stat = statSync7(path);
4766
5593
  if (stat.size > LARGE_LOG_WARN_BYTES) {
4767
5594
  return {
4768
5595
  name,
4769
5596
  status: "warn",
4770
5597
  detail: `${path} (${stat.size} bytes, oversized; stop the pair, rebuild/reinstall, then rotate or remove this log)`,
4771
- hint: "\u65E5\u5FD7\u8FC7\u5927\uFF1A`abg kill` \u505C\u6B62 pair \u540E\u5220\u9664\u8BE5\u6587\u4EF6\u518D\u91CD\u542F\u5373\u53EF\u3002"
5598
+ hint: `\u65E5\u5FD7\u8FC7\u5927\uFF1A\`${cli} kill\` \u505C\u6B62 pair \u540E\u5220\u9664\u8BE5\u6587\u4EF6\u518D\u91CD\u542F\u5373\u53EF\u3002`
4772
5599
  };
4773
5600
  }
4774
5601
  return { name, status: "ok", detail: `${path} (${stat.size} bytes)` };
@@ -4804,7 +5631,10 @@ function printDoctorReport(report) {
4804
5631
  }
4805
5632
  var LARGE_LOG_WARN_BYTES;
4806
5633
  var init_doctor = __esm(() => {
5634
+ init_plugin_cache();
4807
5635
  init_build_info();
5636
+ init_cli_invocation();
5637
+ init_config_service();
4808
5638
  init_env_guard();
4809
5639
  init_pair_resolver();
4810
5640
  init_thread_state();
@@ -4836,6 +5666,9 @@ function formatAgent(name, usage, snapshotAt) {
4836
5666
  if (usage.rateLimitedUntil > 0) {
4837
5667
  parts.push(`\u9650\u6D41\u81F3 ${formatEpoch(usage.rateLimitedUntil)}`);
4838
5668
  }
5669
+ if (usage.parsedVia === "positional") {
5670
+ parts.push("\u26A0\uFE0F \u7A97\u53E3\u8BC6\u522B\u4F7F\u7528\u4F4D\u7F6E\u515C\u5E95");
5671
+ }
4839
5672
  const ageSec = usage.fetchedAt > 0 ? snapshotAt - usage.fetchedAt : 0;
4840
5673
  if (ageSec > 300) {
4841
5674
  parts.push(`\u26A0\uFE0F \u6570\u636E\u91C7\u96C6\u4E8E ${Math.round(ageSec / 60)} \u5206\u949F\u524D`);
@@ -4902,6 +5735,7 @@ __export(exports_budget, {
4902
5735
  });
4903
5736
  async function runBudget(args) {
4904
5737
  const json = args.includes("--json");
5738
+ const cli = cliInvocationName();
4905
5739
  const { pairFlag } = parsePairFlag(args.filter((arg) => arg !== "--json"));
4906
5740
  let resolution;
4907
5741
  try {
@@ -4921,7 +5755,7 @@ async function runBudget(args) {
4921
5755
  if (json) {
4922
5756
  console.log(JSON.stringify({ ok: false, error: "pair_not_registered" }));
4923
5757
  } else {
4924
- console.error("\u8BE5\u76EE\u5F55\u5C1A\u65E0 pair\uFF0C\u5148\u8FD0\u884C abg claude");
5758
+ console.error(`\u8BE5\u76EE\u5F55\u5C1A\u65E0 pair\uFF0C\u5148\u8FD0\u884C ${cli} claude`);
4925
5759
  }
4926
5760
  process.exit(1);
4927
5761
  return;
@@ -4931,7 +5765,7 @@ async function runBudget(args) {
4931
5765
  if (json) {
4932
5766
  console.log(JSON.stringify({ ok: false, pairId: pair.pairId, error: "daemon_unreachable" }));
4933
5767
  } else {
4934
- console.error(`AgentBridge daemon \u672A\u8FD0\u884C\uFF08pair ${pair.pairId}\uFF0C\u63A7\u5236\u7AEF\u53E3 ${pair.ports.controlPort}\uFF09\u3002` + "\u5148\u8FD0\u884C `abg claude` \u542F\u52A8\u4F1A\u8BDD\u3002");
5768
+ console.error(`AgentBridge daemon \u672A\u8FD0\u884C\uFF08pair ${pair.pairId}\uFF0C\u63A7\u5236\u7AEF\u53E3 ${pair.ports.controlPort}\uFF09\u3002` + `\u5148\u8FD0\u884C \`${cli} claude\` \u542F\u52A8\u4F1A\u8BDD\u3002`);
4935
5769
  }
4936
5770
  process.exit(1);
4937
5771
  }
@@ -4943,10 +5777,165 @@ async function runBudget(args) {
4943
5777
  console.log(status.budget ? renderBudgetSnapshot(status.budget) : BUDGET_UNAVAILABLE_TEXT);
4944
5778
  }
4945
5779
  var init_budget = __esm(() => {
5780
+ init_cli_invocation();
4946
5781
  init_pair_resolver();
4947
5782
  init_render();
4948
5783
  });
4949
5784
 
5785
+ // src/cli/logs.ts
5786
+ var exports_logs = {};
5787
+ __export(exports_logs, {
5788
+ tailLines: () => tailLines,
5789
+ runLogs: () => runLogs,
5790
+ parseLogsArgs: () => parseLogsArgs,
5791
+ followLog: () => followLog
5792
+ });
5793
+ import { existsSync as existsSync16, readFileSync as readFileSync14 } from "fs";
5794
+ import { spawn as spawn4 } from "child_process";
5795
+ function parseLogsArgs(args) {
5796
+ let codex = false;
5797
+ let follow = false;
5798
+ let lines = DEFAULT_LINES;
5799
+ for (let i = 0;i < args.length; i++) {
5800
+ const a = args[i];
5801
+ if (a === "--codex") {
5802
+ codex = true;
5803
+ continue;
5804
+ }
5805
+ if (a === "-f" || a === "--follow") {
5806
+ follow = true;
5807
+ continue;
5808
+ }
5809
+ if (a === "-n" || a === "--lines") {
5810
+ const next = args[i + 1];
5811
+ if (next === undefined) {
5812
+ throw new Error(`${a} requires a positive integer (e.g. ${a} 200)`);
5813
+ }
5814
+ lines = parsePositiveInt(next, a);
5815
+ i++;
5816
+ continue;
5817
+ }
5818
+ if (a.startsWith("-n")) {
5819
+ lines = parsePositiveInt(a.slice(2), "-n");
5820
+ continue;
5821
+ }
5822
+ if (a.startsWith("--lines=")) {
5823
+ lines = parsePositiveInt(a.slice("--lines=".length), "--lines");
5824
+ continue;
5825
+ }
5826
+ throw new Error(`Unknown logs flag: ${a}`);
5827
+ }
5828
+ return { codex, follow, lines };
5829
+ }
5830
+ function parsePositiveInt(raw, flag) {
5831
+ if (!/^\d+$/.test(raw)) {
5832
+ throw new Error(`${flag} must be a positive integer, got "${raw}"`);
5833
+ }
5834
+ const n = Number.parseInt(raw, 10);
5835
+ if (!Number.isInteger(n) || n <= 0) {
5836
+ throw new Error(`${flag} must be a positive integer, got "${raw}"`);
5837
+ }
5838
+ return n;
5839
+ }
5840
+ function tailLines(text, count) {
5841
+ const body = text.endsWith(`
5842
+ `) ? text.slice(0, -1) : text;
5843
+ if (body.length === 0)
5844
+ return [];
5845
+ const all = body.split(`
5846
+ `);
5847
+ return all.length <= count ? all : all.slice(all.length - count);
5848
+ }
5849
+ async function runLogs(args) {
5850
+ const { pairFlag } = parsePairFlag(args);
5851
+ const rest = stripPairTokens(args);
5852
+ let options;
5853
+ try {
5854
+ options = parseLogsArgs(rest);
5855
+ } catch (err) {
5856
+ console.error(`[agentbridge] ${err instanceof Error ? err.message : String(err)}`);
5857
+ process.exit(1);
5858
+ return;
5859
+ }
5860
+ let resolution;
5861
+ try {
5862
+ resolution = resolvePairReadOnly(pairFlag);
5863
+ } catch (err) {
5864
+ console.error(`[agentbridge] ${err instanceof Error ? err.message : String(err)}`);
5865
+ process.exit(1);
5866
+ return;
5867
+ }
5868
+ const { pair } = resolution;
5869
+ const logPath = options.codex ? pair.stateDir.codexWrapperLogFile : pair.stateDir.logFile;
5870
+ const logLabel = options.codex ? "codex wrapper log" : "daemon log";
5871
+ if (!existsSync16(logPath)) {
5872
+ const which = options.codex ? "codex wrapper log" : "daemon log";
5873
+ console.error(`no ${which} for pair ${pair.name} yet \u2014 start it with \`abg claude\` (${logPath})`);
5874
+ process.exit(1);
5875
+ return;
5876
+ }
5877
+ if (options.follow) {
5878
+ await followLog(logPath, options.lines);
5879
+ return;
5880
+ }
5881
+ printTail(logPath, options.lines, logLabel, pair.name);
5882
+ }
5883
+ function printTail(logPath, count, label, pairName) {
5884
+ let text;
5885
+ try {
5886
+ text = readFileSync14(logPath, "utf8");
5887
+ } catch (err) {
5888
+ console.error(`[agentbridge] failed to read ${label} for pair ${pairName}: ` + `${err instanceof Error ? err.message : String(err)} (${logPath})`);
5889
+ process.exit(1);
5890
+ return;
5891
+ }
5892
+ const lines = tailLines(text, count);
5893
+ for (const line of lines)
5894
+ console.log(line);
5895
+ }
5896
+ function followLog(logPath, count) {
5897
+ return new Promise((resolvePromise) => {
5898
+ const child = spawn4("tail", ["-f", "-n", String(count), logPath], {
5899
+ stdio: "inherit"
5900
+ });
5901
+ child.on("error", (err) => {
5902
+ console.error(`[agentbridge] failed to follow log: ${err.message}`);
5903
+ process.exit(1);
5904
+ });
5905
+ child.on("exit", (code, signal) => {
5906
+ if (signal === "SIGINT" || signal === "SIGTERM") {
5907
+ resolvePromise();
5908
+ return;
5909
+ }
5910
+ if (code != null && code !== 0) {
5911
+ process.exit(code);
5912
+ return;
5913
+ }
5914
+ resolvePromise();
5915
+ });
5916
+ });
5917
+ }
5918
+ function stripPairTokens(args) {
5919
+ const out = [];
5920
+ for (let i = 0;i < args.length; i++) {
5921
+ const a = args[i];
5922
+ if (a === "--pair") {
5923
+ const next = args[i + 1];
5924
+ if (next !== undefined && !next.startsWith("-"))
5925
+ i++;
5926
+ continue;
5927
+ }
5928
+ if (a.startsWith("--pair="))
5929
+ continue;
5930
+ out.push(a);
5931
+ }
5932
+ return out;
5933
+ }
5934
+ var DEFAULT_LINES = 100;
5935
+ var init_logs = __esm(() => {
5936
+ init_pair_resolver();
5937
+ });
5938
+
4950
5939
  // src/cli.ts
4951
5940
  function parseTopLevel(args) {
4952
5941
  const pairTokens = [];
@@ -5019,6 +6008,10 @@ async function main(command, restArgs) {
5019
6008
  const { runBudget: runBudget2 } = await Promise.resolve().then(() => (init_budget(), exports_budget));
5020
6009
  await runBudget2(restArgs);
5021
6010
  break;
6011
+ case "logs":
6012
+ const { runLogs: runLogs2 } = await Promise.resolve().then(() => (init_logs(), exports_logs));
6013
+ await runLogs2(restArgs);
6014
+ break;
5022
6015
  case "--help":
5023
6016
  case "-h":
5024
6017
  case undefined:
@@ -5035,6 +6028,7 @@ async function main(command, restArgs) {
5035
6028
  }
5036
6029
  }
5037
6030
  function printHelp() {
6031
+ const cli = cliInvocationName();
5038
6032
  console.log(`
5039
6033
  AgentBridge \u2014 Multi-agent collaboration bridge
5040
6034
 
@@ -5052,11 +6046,15 @@ Commands:
5052
6046
  No target: print resume commands for this directory's last
5053
6047
  Claude session + this pair's current Codex thread.
5054
6048
  With target: resume that side directly.
5055
- pairs [rm <name|id> | prune [--dry-run]]
5056
- List pairs; remove one (rm), or delete orphan state dirs (prune)
6049
+ pairs [rm <name|id> | prune [--apply]]
6050
+ List pairs; remove one (rm), or reclaim orphan dirs + stranded
6051
+ entries (prune previews by default; --apply to delete)
5057
6052
  doctor [--json] Diagnose env, daemon, build drift, logs, and current thread
5058
6053
  doctor resume-pollution [--apply] Find/fix old AgentBridge kickoff metadata
5059
6054
  budget [--json] Show both agents' subscription quota snapshot (5h/weekly, drift, pause state)
6055
+ logs [--codex] [-f] [-n N]
6056
+ Tail this pair's daemon log (or the codex wrapper log with
6057
+ --codex). -n N: last N lines (default 100). -f: follow/stream.
5060
6058
  kill [all | --all | --pair <name|id>]
5061
6059
  Stop this directory's pairs (default), every pair (all/--all), or one (--pair)
5062
6060
 
@@ -5082,26 +6080,30 @@ Multi-pair:
5082
6080
  contesting it \u2014 pick another --pair name (or kill the live one first).
5083
6081
 
5084
6082
  Examples:
5085
- abg init # First-time setup
5086
- abg claude # Start the "main" pair for this directory
5087
- abg codex # Connect Codex to this directory's "main" pair
5088
- abg resume # Print resume commands for both sides
5089
- abg resume claude # Resume the last Claude Code session here
5090
- abg resume codex # Resume this pair's current Codex thread
5091
- abg claude --safe # One launch without the max-permission default
5092
- abg --pair work claude # Start a named pair "work" (this directory)
5093
- abg --pair work codex # Connect Codex to the "work" pair
5094
- abg --pair review claude # A second, parallel pair
5095
- abg pairs # List all pairs and their ports/status
5096
- abg pairs --threads # Include current thread mapping
5097
- abg doctor --json # Emit a structured diagnostics report
5098
- abg pairs rm work # Stop this directory's "work" pair and free its slot
5099
- abg pairs rm work-1a2b3c4d # ...or by its full id (from that pair's directory)
5100
- abg pairs prune --dry-run # Preview orphan pair dirs (no registry entry, not live)
5101
- abg pairs prune # ...delete those orphan state directories
5102
- abg --pair work kill # Stop only this directory's "work" pair
5103
- abg kill # Stop this directory's pairs (+ any legacy-root daemon)
5104
- abg kill all # Stop every pair in every directory (+ legacy-root)
6083
+ ${cli} init # First-time setup
6084
+ ${cli} claude # Start the "main" pair for this directory
6085
+ ${cli} codex # Connect Codex to this directory's "main" pair
6086
+ ${cli} resume # Print resume commands for both sides
6087
+ ${cli} resume claude # Resume the last Claude Code session here
6088
+ ${cli} resume codex # Resume this pair's current Codex thread
6089
+ ${cli} claude --safe # One launch without the max-permission default
6090
+ ${cli} --pair work claude # Start a named pair "work" (this directory)
6091
+ ${cli} --pair work codex # Connect Codex to the "work" pair
6092
+ ${cli} --pair review claude # A second, parallel pair
6093
+ ${cli} pairs # List all pairs and their ports/status
6094
+ ${cli} pairs --threads # Include current thread mapping
6095
+ ${cli} doctor --json # Emit a structured diagnostics report
6096
+ ${cli} logs # Tail the last 100 lines of this pair's daemon log
6097
+ ${cli} logs -f -n 200 # Follow the log, starting from the last 200 lines
6098
+ ${cli} logs --codex # Tail the codex wrapper log instead
6099
+ ${cli} --pair work logs # Tail the "work" pair's daemon log
6100
+ ${cli} pairs rm work # Stop this directory's "work" pair and free its slot
6101
+ ${cli} pairs rm work-1a2b3c4d # ...or by its full id (from that pair's directory)
6102
+ ${cli} pairs prune # Preview reclaimable: orphan dirs + stranded entries (cwd-gone, dead, >1d)
6103
+ ${cli} pairs prune --apply # ...actually delete the previewed dirs + entries
6104
+ ${cli} --pair work kill # Stop only this directory's "work" pair
6105
+ ${cli} kill # Stop this directory's pairs (+ any legacy-root daemon)
6106
+ ${cli} kill all # Stop every pair in every directory (+ legacy-root)
5105
6107
  `.trim());
5106
6108
  }
5107
6109
  function printVersion() {
@@ -5114,9 +6116,10 @@ function printVersion() {
5114
6116
  }
5115
6117
  var MARKETPLACE_NAME = "agentbridge", PLUGIN_NAME = "agentbridge", REFRESH_COMMANDS, NOTIFY_COMMANDS, PAIR_AWARE_COMMANDS;
5116
6118
  var init_cli = __esm(() => {
6119
+ init_cli_invocation();
5117
6120
  REFRESH_COMMANDS = new Set(["claude", "codex", "resume"]);
5118
6121
  NOTIFY_COMMANDS = new Set(["claude", "codex", "init", "dev", "resume"]);
5119
- PAIR_AWARE_COMMANDS = new Set(["claude", "codex", "kill", "doctor", "budget", "resume"]);
6122
+ PAIR_AWARE_COMMANDS = new Set(["claude", "codex", "kill", "doctor", "budget", "resume", "logs"]);
5120
6123
  if (import.meta.main) {
5121
6124
  const { command, restArgs } = parseTopLevel(process.argv.slice(2));
5122
6125
  main(command, restArgs).catch((err) => {
@@ -5129,6 +6132,9 @@ init_cli();
5129
6132
 
5130
6133
  export {
5131
6134
  parseTopLevel,
6135
+ REFRESH_COMMANDS,
5132
6136
  PLUGIN_NAME,
6137
+ PAIR_AWARE_COMMANDS,
6138
+ NOTIFY_COMMANDS,
5133
6139
  MARKETPLACE_NAME
5134
6140
  };