@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.js +68 -12
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/agent-token-header.d.ts +13 -0
  8. package/dist/agent-token-header.js +16 -0
  9. package/dist/agent-token-header.js.map +1 -0
  10. package/dist/backend/antigravity.js +4 -3
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/grok.js +1 -1
  13. package/dist/backend/grok.js.map +1 -1
  14. package/dist/backend/kiro.d.ts +7 -0
  15. package/dist/backend/kiro.js +82 -33
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/muse.js +1 -1
  18. package/dist/backend/muse.js.map +1 -1
  19. package/dist/backend/types.d.ts +6 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/cache-analysis.d.ts +92 -0
  22. package/dist/cache-analysis.js +147 -0
  23. package/dist/cache-analysis.js.map +1 -0
  24. package/dist/cache-ledger.d.ts +108 -0
  25. package/dist/cache-ledger.js +230 -0
  26. package/dist/cache-ledger.js.map +1 -0
  27. package/dist/cache-prices.d.ts +32 -0
  28. package/dist/cache-prices.js +73 -0
  29. package/dist/cache-prices.js.map +1 -0
  30. package/dist/cache-scan.d.ts +39 -0
  31. package/dist/cache-scan.js +115 -0
  32. package/dist/cache-scan.js.map +1 -0
  33. package/dist/cache-service.d.ts +115 -0
  34. package/dist/cache-service.js +417 -0
  35. package/dist/cache-service.js.map +1 -0
  36. package/dist/cli-entry.d.ts +19 -0
  37. package/dist/cli-entry.js +36 -0
  38. package/dist/cli-entry.js.map +1 -0
  39. package/dist/cli.js +251 -24
  40. package/dist/cli.js.map +1 -1
  41. package/dist/daemon.d.ts +6 -0
  42. package/dist/daemon.js +90 -37
  43. package/dist/daemon.js.map +1 -1
  44. package/dist/event-log.d.ts +4 -1
  45. package/dist/event-log.js +6 -3
  46. package/dist/event-log.js.map +1 -1
  47. package/dist/fleet-control-audit.d.ts +5 -0
  48. package/dist/fleet-control-audit.js +7 -0
  49. package/dist/fleet-control-audit.js.map +1 -1
  50. package/dist/fleet-level-config.js +22 -0
  51. package/dist/fleet-level-config.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +46 -0
  53. package/dist/fleet-manager.js +287 -40
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/full-restart.js +2 -3
  56. package/dist/full-restart.js.map +1 -1
  57. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  58. package/dist/install-lock.d.ts +49 -0
  59. package/dist/install-lock.js +200 -0
  60. package/dist/install-lock.js.map +1 -0
  61. package/dist/instance-config-impact.js +3 -2
  62. package/dist/instance-config-impact.js.map +1 -1
  63. package/dist/locale.js +14 -0
  64. package/dist/locale.js.map +1 -1
  65. package/dist/outbound-schemas.js +1 -1
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/package-preimage.d.ts +26 -0
  68. package/dist/package-preimage.js +96 -0
  69. package/dist/package-preimage.js.map +1 -0
  70. package/dist/public-web-gateway.js +2 -2
  71. package/dist/public-web-gateway.js.map +1 -1
  72. package/dist/quickstart.js +10 -4
  73. package/dist/quickstart.js.map +1 -1
  74. package/dist/restart-guard.d.ts +52 -0
  75. package/dist/restart-guard.js +109 -0
  76. package/dist/restart-guard.js.map +1 -0
  77. package/dist/scheduler/db.js +6 -2
  78. package/dist/scheduler/db.js.map +1 -1
  79. package/dist/service-activation.d.ts +47 -5
  80. package/dist/service-activation.js +156 -51
  81. package/dist/service-activation.js.map +1 -1
  82. package/dist/service-installer.d.ts +56 -1
  83. package/dist/service-installer.js +134 -12
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/service-plan.d.ts +65 -0
  86. package/dist/service-plan.js +108 -0
  87. package/dist/service-plan.js.map +1 -0
  88. package/dist/settings-api.js +46 -5
  89. package/dist/settings-api.js.map +1 -1
  90. package/dist/settings-change.js +34 -6
  91. package/dist/settings-change.js.map +1 -1
  92. package/dist/setup-host.js +2 -3
  93. package/dist/setup-host.js.map +1 -1
  94. package/dist/setup-wizard.js +2 -1
  95. package/dist/setup-wizard.js.map +1 -1
  96. package/dist/sqlite-open-errors.d.ts +12 -0
  97. package/dist/sqlite-open-errors.js +13 -0
  98. package/dist/sqlite-open-errors.js.map +1 -0
  99. package/dist/tmux-control.d.ts +22 -0
  100. package/dist/tmux-control.js +89 -5
  101. package/dist/tmux-control.js.map +1 -1
  102. package/dist/topic-commands.js +14 -5
  103. package/dist/topic-commands.js.map +1 -1
  104. package/dist/ui/chat-commands.js +43 -0
  105. package/dist/ui/chat-render.js +2 -2
  106. package/dist/ui/chat-store.js +30 -1
  107. package/dist/ui/chat-strings.js +22 -2
  108. package/dist/ui/chat-thread.js +47 -10
  109. package/dist/ui/fleet-cache.js +168 -0
  110. package/dist/ui/panel-chat.js +104 -18
  111. package/dist/ui/panel-fleet.js +26 -12
  112. package/dist/ui/panel-settings.js +12 -8
  113. package/dist/ui/preview-panel.js +202 -0
  114. package/dist/ui/preview.js +7 -3
  115. package/dist/ui/settings-strings.js +4 -2
  116. package/dist/ui/shared/app-route.js +1 -1
  117. package/dist/ui/shared/app-shell.js +6 -4
  118. package/dist/ui/shared/app.css +147 -3
  119. package/dist/ui/shared/signin.js +13 -1
  120. package/dist/ui/shared/ui-icons.js +2 -0
  121. package/dist/ui/signin.html +2 -1
  122. package/dist/update-dispatch.d.ts +28 -0
  123. package/dist/update-dispatch.js +73 -0
  124. package/dist/update-dispatch.js.map +1 -0
  125. package/dist/update-install.d.ts +65 -7
  126. package/dist/update-install.js +126 -26
  127. package/dist/update-install.js.map +1 -1
  128. package/dist/web-api.d.ts +14 -0
  129. package/dist/web-api.js +65 -0
  130. package/dist/web-api.js.map +1 -1
  131. package/dist/web-commands.d.ts +77 -0
  132. package/dist/web-commands.js +81 -0
  133. package/dist/web-commands.js.map +1 -0
  134. package/dist/web-shell-routes.d.ts +1 -1
  135. package/dist/web-shell-routes.js +1 -1
  136. package/dist/web-shell-routes.js.map +1 -1
  137. package/launcher/agend +110 -0
  138. package/launcher/agend-agent +110 -0
  139. package/launcher/agend-agent.cjs +4 -0
  140. package/launcher/agend.cjs +4 -0
  141. package/launcher/install-admission.cjs +105 -0
  142. package/launcher/launch.cjs +64 -0
  143. package/launcher/postinstall.cjs +114 -0
  144. package/launcher/run-in-process.cjs +13 -0
  145. package/launcher/runtime-platform.cjs +86 -0
  146. package/launcher/runtime-select.cjs +217 -0
  147. package/package.json +12 -4
  148. package/scripts/preinstall-guard.cjs +40 -21
  149. package/templates/launchd.plist.ejs +2 -2
  150. package/templates/systemd.service.ejs +1 -1
package/dist/cli.js CHANGED
@@ -19,13 +19,13 @@ import { Cron } from "croner";
19
19
  import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync, mkdirSync, chmodSync, readdirSync, rmSync, statSync, realpathSync, } from "node:fs";
20
20
  import { homedir, totalmem, freemem, userInfo } from "node:os";
21
21
  import { fileURLToPath } from "node:url";
22
- import { spawn, execSync, execFileSync } from "node:child_process";
22
+ import { spawn, spawnSync, execSync, execFileSync } from "node:child_process";
23
23
  import { getAgendHome, getTmuxSocketName } from "./paths.js";
24
24
  import { hasPausedMarker } from "./pause-marker.js";
25
25
  import { COMPLETION_SHELLS, completionScript } from "./completion.js";
26
26
  import { getUpdateSelector, isExactVersion, isUnrequestedDowngrade, lookupTargetVersion, reportUpdateRestart, shouldSkipUpdate, runningFleetPredatesInstall, processStartMs, } from "./update-check.js";
27
27
  import { clearUpdateMarker, markUpdateInProgress, setUpdateProgressStage } from "./update-marker.js";
28
- import { describeSignalSource, gateFleetControl, recordInstanceControl, withOrigin } from "./fleet-control-audit.js";
28
+ import { describeSignalSource, forceAllowed, gateFleetControl, recordInstanceControl, withOrigin } from "./fleet-control-audit.js";
29
29
  import { acquireFleetLock, isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock, setProcessFleetLock } from "./fleet-lock.js";
30
30
  import { limitFleetCoreDumps } from "./coredump-filter.js";
31
31
  import { SYSTEMD_RESTART_TIMEOUT_MS } from "./service-installer.js";
@@ -36,6 +36,7 @@ import { DeliveryOutbox, deliveryStatusSelector } from "./delivery-outbox.js";
36
36
  import { DeliveryStatusArgs } from "./outbound-schemas.js";
37
37
  import { requestCpuProfile } from "./profile-control.js";
38
38
  import { requestSettingsConfirmation } from "./settings-control.js";
39
+ import { canonicalCliEntry, selfCommand } from "./cli-entry.js";
39
40
  /** Prefix tmux args with -L when socket isolation is active. */
40
41
  function tmuxArgs(args) {
41
42
  const socket = getTmuxSocketName();
@@ -44,6 +45,39 @@ function tmuxArgs(args) {
44
45
  const __filename = fileURLToPath(import.meta.url);
45
46
  const __dirname = dirname(__filename);
46
47
  const DATA_DIR = getAgendHome();
48
+ /** Real-filesystem, real-process deps for the launchd plan (service-plan.ts) and its activation. */
49
+ function planDeps() {
50
+ return {
51
+ run: (command, args) => {
52
+ const r = spawnSync(command, args, { encoding: "utf-8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] });
53
+ return { status: r.status, signal: r.signal, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
54
+ },
55
+ readFile: (path) => { try {
56
+ return readFileSync(path, "utf-8");
57
+ }
58
+ catch {
59
+ return null;
60
+ } },
61
+ writeFile: (path, content) => writeFileSync(path, content),
62
+ removeFile: (path) => { try {
63
+ unlinkSync(path);
64
+ }
65
+ catch { /* absent */ } },
66
+ realpath: (path) => { try {
67
+ return realpathSync(path);
68
+ }
69
+ catch {
70
+ return null;
71
+ } },
72
+ isExecutable: (path) => { try {
73
+ return statSync(path).isFile() && (statSync(path).mode & 0o111) !== 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ } },
78
+ now: () => new Date(),
79
+ };
80
+ }
47
81
  const FLEET_CONFIG_PATH = join(DATA_DIR, "fleet.yaml");
48
82
  // CLI commands run outside FleetManager. Honor an explicit fleet locale while
49
83
  // keeping the historical English CLI default when config is missing/unset.
@@ -1007,13 +1041,20 @@ backend
1007
1041
  const svcPath = join(homedir(), ".config/systemd/user/com.agend.fleet.service");
1008
1042
  if (existsSync(svcPath)) {
1009
1043
  const svc = readFileSync(svcPath, "utf-8");
1010
- const match = svc.match(/ExecStart=(\S+)/);
1011
- const svcBin = match?.[1] ?? "";
1012
- const currentBin = es("which agend", { stdio: "pipe" }).toString().trim();
1013
- if (svcBin === currentBin)
1044
+ // The CLI the unit starts — `<entry> fleet start`, or `<node> <entry> fleet start` — against this CLI's own
1045
+ // canonical entry (#1450: `agend` on PATH is the sh launcher, never what a unit records).
1046
+ const { unitCliEntry } = await import("./service-installer.js");
1047
+ const svcEntry = unitCliEntry(svc);
1048
+ let svcReal = svcEntry;
1049
+ try {
1050
+ svcReal = realpathSync(svcEntry);
1051
+ }
1052
+ catch { /* reported as is */ }
1053
+ const current = canonicalCliEntry();
1054
+ if (svcReal === current)
1014
1055
  ok(`service ExecStart${" ".repeat(3)} matches current binary`);
1015
1056
  else
1016
- fail(`service ExecStart${" ".repeat(3)} ${svcBin} ≠ ${currentBin}`);
1057
+ fail(`service ExecStart${" ".repeat(3)} ${svcEntry || "(none)"} ≠ ${current}`);
1017
1058
  // Check Restart=on-failure
1018
1059
  if (svc.includes("Restart=on-failure")) {
1019
1060
  ok(`service restart${" ".repeat(5)} Restart=on-failure configured`);
@@ -1438,6 +1479,7 @@ program
1438
1479
  }
1439
1480
  process.exitCode = 1;
1440
1481
  }
1482
+ return restartOutcome;
1441
1483
  };
1442
1484
  const nvmSh = join(homedir(), ".nvm", "nvm.sh");
1443
1485
  /**
@@ -1447,7 +1489,9 @@ program
1447
1489
  * (process.argv[1] may be another checkout). A failed target check leaves the running fleet alone.
1448
1490
  */
1449
1491
  const activateVerified = async (verified, viaNvm) => {
1450
- const { newAgendInvocation } = await import("./update-install.js");
1492
+ const { newAgendInvocation, retireSystemCopy, activationSettled } = await import("./update-install.js");
1493
+ // What the restart came to — carried, never inferred from the exit code (pending ≠ restarted).
1494
+ let restartResult = null;
1451
1495
  const { activateService } = await import("./service-activation.js");
1452
1496
  const { getServicePath, getSystemServicePath, detectPlatform } = await import("./service-installer.js");
1453
1497
  const { accessSync, constants } = await import("node:fs");
@@ -1455,10 +1499,17 @@ program
1455
1499
  // The authoritative manager, chosen as `agend restart` chooses: system unit > user unit / launchd plist > none.
1456
1500
  const systemUnit = getSystemServicePath();
1457
1501
  const ownService = systemUnit ? null : getServicePath();
1458
- const manager = systemUnit ? { kind: "systemd", unit: "agend", user: false }
1502
+ const manager = systemUnit ? { kind: "systemd", unit: "agend", user: false, unitPath: systemUnit }
1459
1503
  : ownService && detectPlatform() === "macos" ? { kind: "launchd", label: "com.agend.fleet", plistPath: ownService, domain: `gui/${process.getuid?.() ?? 501}` }
1460
- : ownService ? { kind: "systemd", unit: "com.agend.fleet", user: true }
1504
+ : ownService ? { kind: "systemd", unit: "com.agend.fleet", user: true, unitPath: ownService }
1461
1505
  : { kind: "detached" };
1506
+ // C6: a failure before the fleet runs the new install puts the previous package back too (taken before npm).
1507
+ const { restorePackagePreimage, prunePreimages } = await import("./package-preimage.js");
1508
+ const rollback = verified.rollback;
1509
+ const restorePackage = rollback?.preimage ? () => {
1510
+ const back = restorePackagePreimage(rollback.root, rollback.prefix, rollback.preimage);
1511
+ return back.ok ? `The previous package (v${rollback.preimage.version}) is back in place` : `The previous package could NOT be put back: ${back.reason}`;
1512
+ } : undefined;
1462
1513
  const capture = (command, args) => {
1463
1514
  const result = spawnSync(command, args, { encoding: "utf-8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] });
1464
1515
  return { status: result.status, signal: result.signal, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
@@ -1505,10 +1556,30 @@ program
1505
1556
  spawnSync(newAgend.command, [...newAgend.args, "completion", "install", "--refresh"], { encoding: "utf-8", timeout: 15_000, stdio: "ignore" });
1506
1557
  }
1507
1558
  catch { /* cosmetic */ }
1508
- restartFleetForUpdate(newAgend.command, newAgend.args, verified.version);
1559
+ restartResult = restartFleetForUpdate(newAgend.command, newAgend.args, verified.version);
1509
1560
  },
1510
1561
  log: message => console.log(message),
1562
+ restorePackage,
1511
1563
  });
1564
+ // Settled: after a success only this transition's preimage is kept (for a repair); a failure consumed it.
1565
+ if (outcome.ok && rollback)
1566
+ prunePreimages(rollback.prefix, rollback.preimage);
1567
+ // nvm transition: the old system copy is what the previous service ran. Only once the fleet POSITIVELY runs the
1568
+ // new install (activationSettled) is it removed; pending, failed or refused, it stays, so the old unit still starts.
1569
+ if (verified.retireSystemCopy) {
1570
+ if (activationSettled(outcome, restartResult)) {
1571
+ retireSystemCopy({ run: (command, args) => capture(command, args), log: message => console.log(message) });
1572
+ }
1573
+ else {
1574
+ console.log(" Note: the old system install was kept, since the new one is not running yet.");
1575
+ }
1576
+ }
1577
+ // The refresh above (`install --no-activate`) records a launchd plan; this activation consumed it, whatever it
1578
+ // came to — never leave one for a later `agend restart` to act on.
1579
+ if (manager.kind === "launchd") {
1580
+ const { planPath } = await import("./service-plan.js");
1581
+ planDeps().removeFile(planPath(DATA_DIR));
1582
+ }
1512
1583
  if (outcome.ok && outcome.via === "launchd-activation") {
1513
1584
  console.log(" ✓ Service restarted (launchd loaded the verified job)");
1514
1585
  // The new fleet clears the update marker once it is up.
@@ -1625,8 +1696,34 @@ program
1625
1696
  console.log(" Using nvm to install Node 22...");
1626
1697
  }
1627
1698
  // ── Install, then verify, then clean up (#1446): nothing is removed before the new install is proven ──
1699
+ // #1450 C6: npm replaces THIS package in place. Everything the rest of the update runs — verification, rollback,
1700
+ // service work — is loaded now, before npm; a later import would load the NEW package's files into this process.
1701
+ await Promise.all([
1702
+ import("./service-activation.js"), import("./service-installer.js"), import("./service-plan.js"),
1703
+ import("./restart-guard.js"), import("./package-preimage.js"), import("./install-lock.js"), import("./update-check.js"),
1704
+ ]);
1628
1705
  const { runUpdateInstall } = await import("./update-install.js");
1629
- const installed = runUpdateInstall({ pkg, targetVersion, viaNvm: needsSudo, nvmSh }, {
1706
+ // #1450 C1: this update owns the npm prefix from before npm runs until it settles (verify, service, restart) —
1707
+ // released when this process exits, however it exits; a crash leaves a lock the next update reclaims.
1708
+ const { acquireInstallLock } = await import("./install-lock.js");
1709
+ const { randomBytes } = await import("node:crypto");
1710
+ const { createRequire } = await import("node:module");
1711
+ const admission = createRequire(import.meta.url)("../launcher/install-admission.cjs");
1712
+ let releaseLock = () => { };
1713
+ process.once("exit", () => releaseLock());
1714
+ const installed = runUpdateInstall({
1715
+ pkg, targetVersion, viaNvm: needsSudo, nvmSh, rollback: true,
1716
+ lock: prefix => {
1717
+ const lock = acquireInstallLock(prefix, { spec: pkg, agendHome: DATA_DIR }, {
1718
+ pid: process.pid, processStart: admission.processStart, newToken: () => randomBytes(16).toString("hex"),
1719
+ now: () => new Date(), log: message => console.log(message),
1720
+ });
1721
+ if (!lock.ok)
1722
+ return lock;
1723
+ releaseLock = lock.release;
1724
+ return { ok: true, token: lock.token };
1725
+ },
1726
+ }, {
1630
1727
  run: (command, args, options = {}) => {
1631
1728
  if (options.inherit)
1632
1729
  console.log("");
@@ -1634,6 +1731,7 @@ program
1634
1731
  encoding: "utf-8",
1635
1732
  timeout: options.timeoutMs,
1636
1733
  stdio: options.inherit ? "inherit" : ["ignore", "pipe", "pipe"],
1734
+ ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
1637
1735
  });
1638
1736
  return { status: result.status, signal: result.signal, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1639
1737
  },
@@ -1673,15 +1771,45 @@ program
1673
1771
  .option("--activate", "Deprecated; service activation is now the default")
1674
1772
  .option("--no-activate", "Write the service file without activating it")
1675
1773
  .action(async (opts) => {
1676
- const { installService, activateService, detectPlatform } = await import("./service-installer.js");
1677
- const execPath = process.argv[1];
1678
- const svcPath = installService({
1774
+ const { installService, activateService, detectPlatform, renderLaunchdPlist, launchdPlistPath } = await import("./service-installer.js");
1775
+ const execPath = canonicalCliEntry();
1776
+ const serviceVars = {
1679
1777
  label: "com.agend.fleet",
1680
1778
  execPath,
1681
1779
  path: process.env.PATH,
1682
1780
  workingDirectory: DATA_DIR,
1683
1781
  logPath: join(DATA_DIR, "fleet.log"),
1684
- });
1782
+ };
1783
+ // #1450 C6: on launchd, writing a new plist without activating is a PLANNED activation — proven, recorded, and
1784
+ // left for `agend restart` (a reload there would itself start the job). See service-plan.ts.
1785
+ if (opts.activate === false && detectPlatform() === "macos") {
1786
+ const { refreshLaunchdWithoutActivating, planPath } = await import("./service-plan.js");
1787
+ // A refresh revokes any earlier plan first, whatever it comes to (a failed refresh leaves nothing to activate).
1788
+ planDeps().removeFile(planPath(DATA_DIR));
1789
+ const { expectedTuple } = await import("./restart-guard.js");
1790
+ const expectation = expectedTuple();
1791
+ if (!expectation.ok) {
1792
+ console.error(` ✗ ${expectation.reason}`);
1793
+ process.exitCode = 1;
1794
+ return;
1795
+ }
1796
+ const uid = process.getuid?.() ?? 501;
1797
+ const plistPath = launchdPlistPath(serviceVars.label);
1798
+ mkdirSync(dirname(plistPath), { recursive: true });
1799
+ const outcome = refreshLaunchdWithoutActivating({
1800
+ label: serviceVars.label, target: `gui/${uid}/${serviceVars.label}`, plistPath, newPlist: renderLaunchdPlist(serviceVars),
1801
+ expected: expectation.expected, pkgDir: dirname(dirname(execPath)), agendHome: DATA_DIR,
1802
+ }, planDeps());
1803
+ if (!outcome.ok) {
1804
+ console.error(` ✗ ${outcome.message}`);
1805
+ process.exitCode = 1;
1806
+ return;
1807
+ }
1808
+ console.log(t("install.path", plistPath));
1809
+ console.log(` ${outcome.message}`);
1810
+ return;
1811
+ }
1812
+ const svcPath = installService(serviceVars);
1685
1813
  console.log(t("install.path", svcPath));
1686
1814
  if (opts.activate !== false) {
1687
1815
  const pidPath = join(DATA_DIR, "fleet.pid");
@@ -1816,7 +1944,9 @@ program
1816
1944
  if (!userServicePath && !systemServicePath) {
1817
1945
  console.log("No service installed. Starting fleet directly...");
1818
1946
  const { spawn } = await import("node:child_process");
1819
- const child = spawn("sh", ["-c", "agend fleet start"], { detached: true, stdio: "ignore" });
1947
+ // This Node on this CLI, not `sh -c "agend fleet start"` through PATH (#1450 C5).
1948
+ const start = selfCommand(["fleet", "start"]);
1949
+ const child = spawn(start.command, start.args, { detached: true, stdio: "ignore" });
1820
1950
  child.unref();
1821
1951
  console.log("Fleet starting in background.");
1822
1952
  return;
@@ -1837,9 +1967,50 @@ program
1837
1967
  .command("restart")
1838
1968
  .description("Restart the AgEnD service (auto-detects systemd/launchd/detached)")
1839
1969
  .option("-y, --yes", "Confirm when run from a fleet agent session (this affects every instance)")
1970
+ .option("--force", "Restart even when the loaded service does not start this install on its selected Node (operators only)")
1840
1971
  .action(async (opts) => {
1972
+ // --force overrides the C6 guard: an operator's own decision, never a fleet agent's or a fleet-internal spawn's.
1973
+ if (opts.force && !forceAllowed()) {
1974
+ console.error(" ✗ Not restarting: --force is for an operator's own shell, not a fleet agent session or the fleet's own commands. Nothing was stopped.");
1975
+ process.exit(1);
1976
+ }
1841
1977
  if (!gateFleetControl(DATA_DIR, "restart", { yes: opts.yes }))
1842
1978
  process.exit(1);
1979
+ // #1450 C6: what the restarted fleet must run, by this package's own selection (C2) — decided before anything stops.
1980
+ const guard = await import("./restart-guard.js");
1981
+ const guardDeps = {
1982
+ realpath: (path) => { try {
1983
+ return realpathSync(path);
1984
+ }
1985
+ catch {
1986
+ return null;
1987
+ } },
1988
+ isExecutable: (path) => { try {
1989
+ return statSync(path).isFile() && (statSync(path).mode & 0o111) !== 0;
1990
+ }
1991
+ catch {
1992
+ return false;
1993
+ } },
1994
+ };
1995
+ const captureRun = (command, args) => {
1996
+ const r = spawnSync(command, args, { encoding: "utf-8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] });
1997
+ return { status: r.status, signal: r.signal, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
1998
+ };
1999
+ const expectation = guard.expectedTuple();
2000
+ /** Refuse (nothing stopped) unless the definition about to run is proven; `--force` overrides for an operator. */
2001
+ const refuses = (judged) => {
2002
+ if (judged.ok)
2003
+ return false;
2004
+ if (opts.force) {
2005
+ console.log(` ⚠ --force: ${judged.reason}; restarting anyway.`);
2006
+ return false;
2007
+ }
2008
+ console.error(` ✗ Not restarting: ${judged.reason}. Nothing was stopped.`);
2009
+ process.exitCode = 1;
2010
+ return true;
2011
+ };
2012
+ if (!expectation.ok && refuses(expectation))
2013
+ return;
1843
2014
  // Try each runtime environment in order and stop at the first that succeeds.
1844
2015
  // Don't gate on fleet.pid: a fleet under systemd/launchd writes its pid in the
1845
2016
  // service's own HOME, which may differ from what this command resolves (sudo,
@@ -1989,6 +2160,8 @@ program
1989
2160
  }
1990
2161
  if (filterCustom)
1991
2162
  console.log(` ⚠ ${unitPath} sets its own CoredumpFilter; left as is. CoredumpFilter=0 keeps crash dumps to a few KB (#1113).`);
2163
+ if (expectation.ok && refuses(guard.guardSystemd(captureRun, systemdTarget.user, systemdTarget.unit, expectation.expected, guardDeps)))
2164
+ return;
1992
2165
  // Clears the failed state AND the start-limit counter (#1113:
1993
2166
  // StartLimitBurst=4 in 30min) for whichever unit this restart targets,
1994
2167
  // so an operator's restart is never refused by the limit.
@@ -2015,19 +2188,68 @@ program
2015
2188
  process.exitCode = 1;
2016
2189
  return;
2017
2190
  }
2018
- // 3. launchd (macOS)
2191
+ // 3. launchd (macOS). "Loaded" is launchctl print's own answer (113 = not loaded); nothing here may fall through
2192
+ // to the detached restart once a job is known to be loaded.
2019
2193
  if (plat === "macos") {
2020
2194
  const uid = process.getuid?.() ?? 501;
2021
2195
  const label = "com.agend.fleet";
2022
- try {
2023
- // print succeeds only if the service is loaded in launchd
2024
- execSync(`launchctl print gui/${uid}/${label}`, { stdio: "pipe", timeout: 5000 });
2025
- if (run(`launchctl kickstart -k gui/${uid}/${label}`)) {
2196
+ const target = `gui/${uid}/${label}`;
2197
+ const deps = planDeps();
2198
+ const printed = deps.run("launchctl", ["print", target]);
2199
+ if (printed.status === 0) {
2200
+ const plistPath = getServicePath() ?? join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
2201
+ // Path 2 (#1450 C6): a proven new plist waits on disk with its plan — exactly one bootout + bootstrap.
2202
+ const { readPlan, admitPlan, planPath } = await import("./service-plan.js");
2203
+ const plan = opts.force ? null : readPlan(DATA_DIR, deps);
2204
+ if (plan && expectation.ok) {
2205
+ const pkgDir = dirname(dirname(expectation.expected.entry));
2206
+ const admitted = admitPlan(plan, { target, expected: expectation.expected, pkgDir }, deps);
2207
+ if (!admitted.ok) {
2208
+ console.error(` ✗ Not activating the planned launchd job: ${admitted.reason}. Nothing was stopped.`);
2209
+ process.exitCode = 1;
2210
+ return;
2211
+ }
2212
+ const { activateService } = await import("./service-activation.js");
2213
+ const outcome = activateService({ kind: "launchd", label, plistPath: plan.plistPath, domain: `gui/${uid}` }, { bin: expectation.expected.entry, entry: expectation.expected.entry, node: expectation.expected.node, dir: pkgDir }, {
2214
+ ...deps,
2215
+ readFirstLine: path => deps.readFile(path)?.split("\n", 1)[0] ?? null,
2216
+ isExecutable: path => { try {
2217
+ return statSync(path).isFile() && (statSync(path).mode & 0o111) !== 0;
2218
+ }
2219
+ catch {
2220
+ return false;
2221
+ } },
2222
+ refresh: () => ({ status: 0, signal: null, stdout: "", stderr: "" }),
2223
+ restart: () => { },
2224
+ log: message => console.log(message),
2225
+ launchdPreimage: plan.preimage.plist,
2226
+ });
2227
+ deps.removeFile(planPath(DATA_DIR));
2228
+ if (outcome.ok) {
2229
+ console.log("Service restarted (launchd loaded the planned job).");
2230
+ return;
2231
+ }
2232
+ console.error(outcome.message);
2233
+ process.exitCode = 1;
2234
+ return;
2235
+ }
2236
+ // Path 1: an unchanged loaded job.
2237
+ if (expectation.ok && refuses(guard.guardLaunchd(captureRun, target, plistPath, deps.readFile, expectation.expected, guardDeps)))
2238
+ return;
2239
+ if (run(`launchctl kickstart -k ${target}`)) {
2026
2240
  console.log("Service restarted.");
2027
2241
  return;
2028
2242
  }
2243
+ console.error(` ✗ launchctl kickstart -k ${target} failed.`);
2244
+ process.exitCode = 1;
2245
+ return;
2246
+ }
2247
+ const { LAUNCHD_NOT_LOADED } = await import("./service-plan.js");
2248
+ if (!LAUNCHD_NOT_LOADED.includes(printed.status ?? -1) || printed.signal !== null) {
2249
+ console.error(` ✗ Could not tell whether ${label} is loaded (launchctl print ${printed.signal ? `killed by ${printed.signal}` : `exited ${printed.status}`}); refusing a detached restart that could duplicate the fleet.`);
2250
+ process.exitCode = 1;
2251
+ return;
2029
2252
  }
2030
- catch { /* not loaded — fall through */ }
2031
2253
  }
2032
2254
  // 4. Detached process tracked by fleet.pid (no service manager)
2033
2255
  if (existsSync(pidPath)) {
@@ -2045,6 +2267,9 @@ program
2045
2267
  return isFleetStartCommandLine(readProcessCommandLine(oldPid));
2046
2268
  };
2047
2269
  if (oldPid && isOurFleet()) {
2270
+ // The replacement is `<this Node> <entry> fleet start` (C5): this Node must be the selected one.
2271
+ if (expectation.ok && refuses(guard.guardDetached(process.execPath, expectation.expected, guardDeps)))
2272
+ return;
2048
2273
  try {
2049
2274
  process.kill(oldPid, "SIGTERM");
2050
2275
  }
@@ -2070,7 +2295,9 @@ program
2070
2295
  unlinkSync(pidPath);
2071
2296
  }
2072
2297
  catch { /* best effort */ }
2073
- const child = spawn("sh", ["-c", "agend fleet start"], { detached: true, stdio: "ignore" });
2298
+ // This Node on this CLI, not `sh -c "agend fleet start"` through PATH (#1450 C5).
2299
+ const start = selfCommand(["fleet", "start"]);
2300
+ const child = spawn(start.command, start.args, { detached: true, stdio: "ignore" });
2074
2301
  child.unref();
2075
2302
  console.log("Fleet restarting in background.");
2076
2303
  return;