@phnx-labs/agents-cli 1.22.77 → 1.22.79

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 (95) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +28 -1
  3. package/dist/bootstrap.js +40 -12
  4. package/dist/commands/accounts.d.ts +22 -0
  5. package/dist/commands/accounts.js +158 -35
  6. package/dist/commands/auth.js +4 -1
  7. package/dist/commands/config.js +37 -0
  8. package/dist/commands/exec.js +21 -10
  9. package/dist/commands/update.js +169 -18
  10. package/dist/commands/versions.d.ts +11 -0
  11. package/dist/commands/versions.js +30 -4
  12. package/dist/commands/view.d.ts +4 -0
  13. package/dist/commands/view.js +114 -19
  14. package/dist/index.js +35 -2
  15. package/dist/lib/account-catalog.d.ts +97 -1
  16. package/dist/lib/account-catalog.js +134 -6
  17. package/dist/lib/account-registry.d.ts +43 -0
  18. package/dist/lib/account-registry.js +97 -2
  19. package/dist/lib/accounting/rotate.d.ts +2 -2
  20. package/dist/lib/accounting/rotate.js +5 -5
  21. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  22. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  23. package/dist/lib/accounts/connect.d.ts +170 -0
  24. package/dist/lib/accounts/connect.js +383 -0
  25. package/dist/lib/actor.d.ts +17 -0
  26. package/dist/lib/actor.js +26 -2
  27. package/dist/lib/capabilities.js +2 -0
  28. package/dist/lib/commands.js +2 -0
  29. package/dist/lib/config-keys.d.ts +11 -2
  30. package/dist/lib/config-keys.js +21 -1
  31. package/dist/lib/daemon/daemon.js +5 -0
  32. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  33. package/dist/lib/daemon/harness-update-service.js +216 -0
  34. package/dist/lib/daemon-services.d.ts +1 -1
  35. package/dist/lib/daemon-services.js +5 -0
  36. package/dist/lib/device-config.d.ts +0 -1
  37. package/dist/lib/device-config.js +50 -5
  38. package/dist/lib/exec.js +26 -2
  39. package/dist/lib/fs-atomic.d.ts +2 -0
  40. package/dist/lib/fs-atomic.js +2 -0
  41. package/dist/lib/hooks/install.js +7 -2
  42. package/dist/lib/identity/index.d.ts +10 -8
  43. package/dist/lib/identity/index.js +15 -11
  44. package/dist/lib/installations/active-check.d.ts +48 -0
  45. package/dist/lib/installations/active-check.js +84 -0
  46. package/dist/lib/installations/index.d.ts +5 -1
  47. package/dist/lib/installations/index.js +4 -0
  48. package/dist/lib/installations/installation-lock.d.ts +6 -0
  49. package/dist/lib/installations/installation-lock.js +29 -0
  50. package/dist/lib/installations/launch-gate.d.ts +69 -0
  51. package/dist/lib/installations/launch-gate.js +133 -0
  52. package/dist/lib/installations/native-command.d.ts +5 -0
  53. package/dist/lib/installations/native-command.js +52 -0
  54. package/dist/lib/installations/shims.d.ts +8 -2
  55. package/dist/lib/installations/shims.js +105 -2
  56. package/dist/lib/installations/store.d.ts +5 -1
  57. package/dist/lib/installations/store.js +24 -3
  58. package/dist/lib/installations/strategies.js +55 -35
  59. package/dist/lib/installations/types.d.ts +17 -0
  60. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  61. package/dist/lib/installations/update-cancellation.js +122 -0
  62. package/dist/lib/installations/update-policy.d.ts +69 -0
  63. package/dist/lib/installations/update-policy.js +114 -0
  64. package/dist/lib/installations/update-runtime.d.ts +118 -0
  65. package/dist/lib/installations/update-runtime.js +321 -0
  66. package/dist/lib/installations/update.d.ts +25 -0
  67. package/dist/lib/installations/update.js +141 -2
  68. package/dist/lib/installations/versions.d.ts +1 -0
  69. package/dist/lib/installations/versions.js +166 -131
  70. package/dist/lib/platform/process.d.ts +3 -1
  71. package/dist/lib/platform/process.js +2 -2
  72. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  75. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  76. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  77. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  78. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  79. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  80. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  81. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  82. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  83. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  84. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  85. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  86. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  87. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  88. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  89. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  90. package/dist/lib/state.d.ts +3 -1
  91. package/dist/lib/state.js +38 -13
  92. package/dist/lib/types.d.ts +26 -1
  93. package/dist/lib/types.js +5 -0
  94. package/dist/lib/view-types.d.ts +6 -0
  95. package/package.json +1 -1
@@ -25,6 +25,8 @@ export declare function mintInstallationId(): string;
25
25
  * Never mints — use {@link ensureInstallation} for the migrating read.
26
26
  */
27
27
  export declare function readInstallation(agent: AgentId, label: string): Installation | null;
28
+ /** Semantic feature checks use the release, while paths and settings keep the label. */
29
+ export declare function installedReleaseFor(agent: AgentId, label: string): string;
28
30
  export declare function writeInstallation(installation: Installation): void;
29
31
  /**
30
32
  * Read the record for an existing version dir, minting and persisting one on
@@ -37,12 +39,14 @@ export declare function writeInstallation(installation: Installation): void;
37
39
  * describe an install that isn't there.
38
40
  */
39
41
  export declare function ensureInstallation(agent: AgentId, label: string): Installation;
42
+ /** Migration for callers already holding the canonical installation lock. */
43
+ export declare function ensureInstallationLocked(agent: AgentId, label: string, legacyCreatedAt?: string): Installation;
40
44
  /**
41
45
  * Create the record for a freshly-installed version dir. Idempotent: a repeat
42
46
  * `agents add` of the same label keeps the original id (identity is frozen) and
43
47
  * only records the release if it actually moved.
44
48
  */
45
- export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string): Installation;
49
+ export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string, initialPolicy?: Installation['updatePolicy']): Installation;
46
50
  /**
47
51
  * Move an installation's recorded release forward, preserving identity. Returns
48
52
  * the persisted record. Call only AFTER the new release is live on disk — the
@@ -4,7 +4,8 @@ import * as path from 'path';
4
4
  import { execFile } from 'child_process';
5
5
  import { promisify } from 'util';
6
6
  import * as yaml from 'yaml';
7
- import { atomicWriteFileSync } from '../fs-atomic.js';
7
+ import { atomicWriteFileSync, withFileLock } from '../fs-atomic.js';
8
+ import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
8
9
  import { getHomeDir, getUserAgentsDir, getVersionsDir, readMeta } from '../state.js';
9
10
  import { VERSION_RE, compareVersions } from '../agent-spec/primitives.js';
10
11
  import { AGENTS, findInPath } from '../agents.js';
@@ -60,6 +61,9 @@ function assertValidRecord(value, file) {
60
61
  if (!Array.isArray(record.history) || record.history.length === 0) {
61
62
  throw new Error(`Installation record corrupted at ${file}: "history" must be a non-empty array.`);
62
63
  }
64
+ if (record.updatePolicy !== undefined && record.updatePolicy !== 'latest' && record.updatePolicy !== 'pinned') {
65
+ throw new Error(`Installation record corrupted at ${file}: unknown update policy.`);
66
+ }
63
67
  return record;
64
68
  }
65
69
  /**
@@ -84,6 +88,12 @@ export function readInstallation(agent, label) {
84
88
  }
85
89
  return assertValidRecord(parsed, file);
86
90
  }
91
+ /** Semantic feature checks use the release, while paths and settings keep the label. */
92
+ export function installedReleaseFor(agent, label) {
93
+ if (!Object.hasOwn(AGENTS, agent) || !VERSION_RE.test(label))
94
+ return label;
95
+ return readInstallation(agent, label)?.releaseVersion ?? label;
96
+ }
87
97
  export function writeInstallation(installation) {
88
98
  const file = installationRecordPath(installation.agent, installation.label);
89
99
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -100,6 +110,16 @@ export function writeInstallation(installation) {
100
110
  * describe an install that isn't there.
101
111
  */
102
112
  export function ensureInstallation(agent, label) {
113
+ const existing = readInstallation(agent, label);
114
+ if (existing)
115
+ return existing;
116
+ if (!fs.existsSync(installationDir(agent, label)))
117
+ throw new Error(`No installation directory for ${agent}@${label}.`);
118
+ const createdAt = fs.statSync(installationDir(agent, label)).mtime.toISOString();
119
+ return withFileLock(installationLockTarget(agent, label), () => ensureInstallationLocked(agent, label, createdAt), { ...INSTALLATION_LOCK_OPTIONS, acquireTimeoutMs: 0 });
120
+ }
121
+ /** Migration for callers already holding the canonical installation lock. */
122
+ export function ensureInstallationLocked(agent, label, legacyCreatedAt) {
103
123
  const existing = readInstallation(agent, label);
104
124
  if (existing)
105
125
  return existing;
@@ -109,7 +129,7 @@ export function ensureInstallation(agent, label) {
109
129
  }
110
130
  let createdAt;
111
131
  try {
112
- createdAt = fs.statSync(dir).mtime.toISOString();
132
+ createdAt = legacyCreatedAt ?? fs.statSync(dir).mtime.toISOString();
113
133
  }
114
134
  catch {
115
135
  createdAt = nowIso();
@@ -132,7 +152,7 @@ export function ensureInstallation(agent, label) {
132
152
  * `agents add` of the same label keeps the original id (identity is frozen) and
133
153
  * only records the release if it actually moved.
134
154
  */
135
- export function createInstallation(agent, label, releaseVersion) {
155
+ export function createInstallation(agent, label, releaseVersion, initialPolicy = 'latest') {
136
156
  if (!VERSION_RE.test(label)) {
137
157
  throw new Error(`Invalid installation label: ${JSON.stringify(label)}`);
138
158
  }
@@ -152,6 +172,7 @@ export function createInstallation(agent, label, releaseVersion) {
152
172
  createdAt: at,
153
173
  updatedAt: at,
154
174
  history: [{ releaseVersion, at }],
175
+ updatePolicy: initialPolicy,
155
176
  };
156
177
  writeInstallation(created);
157
178
  return created;
@@ -17,19 +17,10 @@ function runId() {
17
17
  }
18
18
  function moveDir(from, to) {
19
19
  fs.mkdirSync(path.dirname(to), { recursive: true });
20
- try {
21
- fs.renameSync(from, to);
22
- }
23
- catch (err) {
24
- // Windows refuses a rename while any file in the tree is open, and a
25
- // cross-device staging dir cannot be renamed at all. Copy+remove is the same
26
- // observable move; it is slower, so it is the fallback, not the default.
27
- const code = err.code;
28
- if (code !== 'EPERM' && code !== 'EACCES' && code !== 'EXDEV')
29
- throw err;
30
- fs.cpSync(from, to, { recursive: true });
31
- fs.rmSync(from, { recursive: true, force: true });
32
- }
20
+ // Staging and rollback are siblings on the installation filesystem. Never
21
+ // turn an atomic rename failure into a partially successful copy-and-delete:
22
+ // an open Windows file or unexpected mount must leave the source intact.
23
+ fs.renameSync(from, to);
33
24
  }
34
25
  /**
35
26
  * Entries a swap replaces: everything npm owns inside a version dir. The lockfile
@@ -101,32 +92,61 @@ const npmPackageStrategy = {
101
92
  const dir = installationDir(ctx.agent, ctx.installation.label);
102
93
  const rollbackDir = path.join(dir, `.rollback-${runId()}`);
103
94
  const displaced = [];
104
- // Move the live tree aside first, then move the staged tree in. Doing it in
105
- // this order means the failure window contains no half-merged tree: either
106
- // the old entries are all aside (undo restores them) or the new ones are all
107
- // in place.
108
- for (const entry of NPM_LIVE_ENTRIES) {
109
- const live = path.join(dir, entry);
110
- if (!fs.existsSync(live))
111
- continue;
112
- moveDir(live, path.join(rollbackDir, entry));
113
- displaced.push(entry);
114
- }
115
- for (const entry of NPM_LIVE_ENTRIES) {
116
- const from = path.join(staged.stagingDir, entry);
117
- if (fs.existsSync(from))
118
- moveDir(from, path.join(dir, entry));
119
- }
120
- return {
121
- undo: () => {
122
- for (const entry of NPM_LIVE_ENTRIES) {
95
+ const stagedIn = [];
96
+ // Restore each displaced entry. If restoration itself fails, retain every
97
+ // remaining backup and surface its path; never erase the only good copy.
98
+ const restorePreCommitState = () => {
99
+ const errors = [];
100
+ for (const entry of [...stagedIn].reverse()) {
101
+ try {
123
102
  fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
124
103
  }
125
- for (const entry of displaced) {
104
+ catch (err) {
105
+ errors.push(`${entry}: ${err.message}`);
106
+ }
107
+ }
108
+ for (const entry of [...displaced].reverse()) {
109
+ try {
126
110
  moveDir(path.join(rollbackDir, entry), path.join(dir, entry));
127
111
  }
128
- fs.rmSync(rollbackDir, { recursive: true, force: true });
129
- },
112
+ catch (err) {
113
+ errors.push(`${entry}: ${err.message}`);
114
+ }
115
+ }
116
+ if (errors.length)
117
+ throw new Error(`Rollback incomplete; recovery files retained at ${rollbackDir}: ${errors.join('; ')}`);
118
+ fs.rmSync(rollbackDir, { recursive: true, force: true });
119
+ };
120
+ try {
121
+ // Move the live tree aside first, then move the staged tree in. Doing it
122
+ // in this order means the failure window contains no half-merged tree on
123
+ // its own: either the old entries are all aside (restorable) or the new
124
+ // ones are all in place — the try/catch below is what makes a throw
125
+ // BETWEEN those two loops (or partway through the second one) recover
126
+ // to the pre-commit state instead of leaving whichever entries already
127
+ // moved exactly where the exception left them.
128
+ for (const entry of NPM_LIVE_ENTRIES) {
129
+ const live = path.join(dir, entry);
130
+ if (!fs.existsSync(live))
131
+ continue;
132
+ moveDir(live, path.join(rollbackDir, entry));
133
+ displaced.push(entry);
134
+ }
135
+ for (const entry of NPM_LIVE_ENTRIES) {
136
+ const from = path.join(staged.stagingDir, entry);
137
+ if (fs.existsSync(from)) {
138
+ moveDir(from, path.join(dir, entry));
139
+ stagedIn.push(entry);
140
+ }
141
+ }
142
+ }
143
+ catch (err) {
144
+ restorePreCommitState();
145
+ throw new Error(`${err.message} The swap was interrupted partway through and has been rolled back to `
146
+ + `${ctx.installation.releaseVersion}.`);
147
+ }
148
+ return {
149
+ undo: restorePreCommitState,
130
150
  finalize: () => fs.rmSync(rollbackDir, { recursive: true, force: true }),
131
151
  };
132
152
  },
@@ -50,7 +50,22 @@ export interface Installation {
50
50
  updatedAt: string;
51
51
  /** Newest last. Always non-empty: creation seeds it with the first release. */
52
52
  history: InstallationRelease[];
53
+ /**
54
+ * How the automatic-update pass (`installations/update-runtime.ts`) treats
55
+ * this installation. `'latest'` (the default) lets it ride the automatic
56
+ * pass; `'pinned'` excludes it — set implicitly by `agents update
57
+ * <agent>@<label> --to <concrete-release>` and cleared by `--to latest`.
58
+ *
59
+ * Absent means `'latest'`: every installation created before this field
60
+ * existed is legacy data, not an opt-out, so a missing key must resolve the
61
+ * same as an explicit `'latest'` rather than being treated as unset/invalid.
62
+ * Read through {@link effectiveUpdatePolicy} in `update-policy.ts` — never
63
+ * compare this field directly, so that default stays in one place.
64
+ */
65
+ updatePolicy?: UpdatePolicy;
53
66
  }
67
+ /** See {@link Installation.updatePolicy}. */
68
+ export type UpdatePolicy = 'latest' | 'pinned';
54
69
  /**
55
70
  * How an installation's release is replaced. Selected from the agent registry's
56
71
  * capabilities, never from an agent id — see `selectUpdateStrategy`.
@@ -70,6 +85,8 @@ export interface UpdateOutcome {
70
85
  toRelease: string;
71
86
  /** True when the resolved target already matched the installed release. */
72
87
  unchanged: boolean;
88
+ /** No swap occurred because activity, cancellation, or policy prevented it. */
89
+ deferred?: string;
73
90
  /**
74
91
  * Installations other than the target whose recorded release also moved,
75
92
  * because the strategy replaced a binary they share (global-binary only).
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Cross-platform cooperative cancellation for the harness auto-update pass
3
+ * (PHNX-3940).
4
+ *
5
+ * The pass mutates the filesystem transactionally — stage an npm install into a
6
+ * sibling dir, launch-probe it, swap it in, record the new release, drop the
7
+ * rollback material (see `update.ts`). Interrupting it mid-swap can leave an
8
+ * installation whose directory and its own metadata disagree, with no way back.
9
+ * So it must be cancellable from OUTSIDE the process — on the daemon's per-tick
10
+ * deadline, or on daemon shutdown — WITHOUT terminating a process that is mid-swap.
11
+ *
12
+ * `execFile`'s `timeout`/`AbortSignal` do exactly the wrong thing here: they
13
+ * FORCE-terminate the child. On Windows that is unconditional (there are no POSIX
14
+ * signals to cooperate with — the SIGTERM listener the pass installs never runs),
15
+ * so a Windows daemon would kill its own update worker mid-rename. Even on POSIX
16
+ * the forced kill races the swap.
17
+ *
18
+ * The fix is a request, not a kill: the daemon spawns the pass as a child over a
19
+ * Node IPC channel and asks it to stop by SENDING A MESSAGE. The child flips a
20
+ * flag that `update.ts`'s existing `shouldCancel` reads at each safe boundary
21
+ * (before the next installation, before staging, and immediately before commit)
22
+ * and finishes what it is doing cleanly. The channel closing (parent gone) and a
23
+ * received SIGTERM/SIGINT are treated as the same request, so a force-killed
24
+ * daemon can never strand a child mutating forever, and a manual `agents update
25
+ * --auto` interrupted with Ctrl+C also stops at a boundary rather than mid-swap.
26
+ *
27
+ * This module is a dependency-free LEAF on purpose: `index.ts` reads the guard
28
+ * depth below through the shared {@link GUARDED_AUTO_UPDATE_SYMBOL} registry key
29
+ * (never importing this module, so the slim CLI shell stays slim), and the daemon
30
+ * child fixture in tests imports {@link withGuardedUpdateCancellation} without
31
+ * pulling the installations graph.
32
+ */
33
+ /**
34
+ * The hidden verb the daemon spawns to run one auto-update pass with IPC-driven
35
+ * cancellation (dispatched in `index.ts`, handled by
36
+ * `update-runtime.ts`'s `runHarnessUpdateChild`). Internal protocol — not a
37
+ * public command.
38
+ */
39
+ export declare const HARNESS_UPDATE_CHILD_CMD = "__harness-update-run";
40
+ /** IPC message `type` the daemon sends to request a cooperative stop. */
41
+ export declare const HARNESS_UPDATE_CANCEL_MSG = "harness-update:cancel";
42
+ /**
43
+ * Well-known cross-realm key naming the count of guarded auto-update passes
44
+ * running IN THIS PROCESS right now. `index.ts`'s top-level SIGINT handler reads
45
+ * it via `Symbol.for(...)` WITHOUT importing this module (keeping the slim entry
46
+ * shell free of a heavy static import — see `agent.test.ts`'s import guard), so
47
+ * the guard state has to live somewhere both can reach: the global symbol
48
+ * registry.
49
+ */
50
+ export declare const GUARDED_AUTO_UPDATE_SYMBOL: unique symbol;
51
+ /** The IPC payload the daemon sends; `child.send(cancelMessage())`. */
52
+ export declare function cancelMessage(): {
53
+ type: typeof HARNESS_UPDATE_CANCEL_MSG;
54
+ };
55
+ /**
56
+ * True while a guarded auto-update pass is mutating this process. Read by
57
+ * `index.ts`'s SIGINT handler (which reads the same registry symbol directly) to
58
+ * DEFER its default `process.exit(130)` so a Ctrl+C can't tear a swap apart; the
59
+ * pass still observes the SIGINT cooperatively and stops at its next boundary.
60
+ * Ref-counted (a depth, not a boolean) so it is correct even if a pass ever nests.
61
+ */
62
+ export declare function isGuardedAutoUpdateActive(): boolean;
63
+ /**
64
+ * Run `run(cancelled)` with cooperative cancellation wired from every source
65
+ * that can reach this process, and the SIGINT guard held for its duration:
66
+ *
67
+ * - **IPC message** `{ type: HARNESS_UPDATE_CANCEL_MSG }` — the primary,
68
+ * cross-platform request the daemon sends. Works on Windows, where a signal
69
+ * would force-kill.
70
+ * - **`disconnect`** — the IPC channel closed (the daemon went away). Stop
71
+ * rather than continue mutating orphaned.
72
+ * - **SIGTERM / SIGINT** — a direct `kill`/Ctrl+C on this process. Cooperative,
73
+ * not fatal; paired with `index.ts`'s guard, which defers its hard exit while
74
+ * {@link isGuardedAutoUpdateActive} holds.
75
+ *
76
+ * `cancelled()` never un-sets once set. Listeners are installed for the call's
77
+ * lifetime only and removed in `finally`, and the process-wide `message`/
78
+ * `disconnect` listeners are harmless when there is no IPC channel (they simply
79
+ * never fire), so this is safe on the in-process manual `agents update --auto`
80
+ * path too.
81
+ */
82
+ export declare function withGuardedUpdateCancellation<T>(run: (cancelled: () => boolean) => Promise<T>): Promise<T>;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Cross-platform cooperative cancellation for the harness auto-update pass
3
+ * (PHNX-3940).
4
+ *
5
+ * The pass mutates the filesystem transactionally — stage an npm install into a
6
+ * sibling dir, launch-probe it, swap it in, record the new release, drop the
7
+ * rollback material (see `update.ts`). Interrupting it mid-swap can leave an
8
+ * installation whose directory and its own metadata disagree, with no way back.
9
+ * So it must be cancellable from OUTSIDE the process — on the daemon's per-tick
10
+ * deadline, or on daemon shutdown — WITHOUT terminating a process that is mid-swap.
11
+ *
12
+ * `execFile`'s `timeout`/`AbortSignal` do exactly the wrong thing here: they
13
+ * FORCE-terminate the child. On Windows that is unconditional (there are no POSIX
14
+ * signals to cooperate with — the SIGTERM listener the pass installs never runs),
15
+ * so a Windows daemon would kill its own update worker mid-rename. Even on POSIX
16
+ * the forced kill races the swap.
17
+ *
18
+ * The fix is a request, not a kill: the daemon spawns the pass as a child over a
19
+ * Node IPC channel and asks it to stop by SENDING A MESSAGE. The child flips a
20
+ * flag that `update.ts`'s existing `shouldCancel` reads at each safe boundary
21
+ * (before the next installation, before staging, and immediately before commit)
22
+ * and finishes what it is doing cleanly. The channel closing (parent gone) and a
23
+ * received SIGTERM/SIGINT are treated as the same request, so a force-killed
24
+ * daemon can never strand a child mutating forever, and a manual `agents update
25
+ * --auto` interrupted with Ctrl+C also stops at a boundary rather than mid-swap.
26
+ *
27
+ * This module is a dependency-free LEAF on purpose: `index.ts` reads the guard
28
+ * depth below through the shared {@link GUARDED_AUTO_UPDATE_SYMBOL} registry key
29
+ * (never importing this module, so the slim CLI shell stays slim), and the daemon
30
+ * child fixture in tests imports {@link withGuardedUpdateCancellation} without
31
+ * pulling the installations graph.
32
+ */
33
+ /**
34
+ * The hidden verb the daemon spawns to run one auto-update pass with IPC-driven
35
+ * cancellation (dispatched in `index.ts`, handled by
36
+ * `update-runtime.ts`'s `runHarnessUpdateChild`). Internal protocol — not a
37
+ * public command.
38
+ */
39
+ export const HARNESS_UPDATE_CHILD_CMD = '__harness-update-run';
40
+ /** IPC message `type` the daemon sends to request a cooperative stop. */
41
+ export const HARNESS_UPDATE_CANCEL_MSG = 'harness-update:cancel';
42
+ /**
43
+ * Well-known cross-realm key naming the count of guarded auto-update passes
44
+ * running IN THIS PROCESS right now. `index.ts`'s top-level SIGINT handler reads
45
+ * it via `Symbol.for(...)` WITHOUT importing this module (keeping the slim entry
46
+ * shell free of a heavy static import — see `agent.test.ts`'s import guard), so
47
+ * the guard state has to live somewhere both can reach: the global symbol
48
+ * registry.
49
+ */
50
+ export const GUARDED_AUTO_UPDATE_SYMBOL = Symbol.for('agents.guardedAutoUpdateDepth');
51
+ /** The IPC payload the daemon sends; `child.send(cancelMessage())`. */
52
+ export function cancelMessage() {
53
+ return { type: HARNESS_UPDATE_CANCEL_MSG };
54
+ }
55
+ function isCancelMessage(msg) {
56
+ return (typeof msg === 'object' &&
57
+ msg !== null &&
58
+ msg.type === HARNESS_UPDATE_CANCEL_MSG);
59
+ }
60
+ /**
61
+ * True while a guarded auto-update pass is mutating this process. Read by
62
+ * `index.ts`'s SIGINT handler (which reads the same registry symbol directly) to
63
+ * DEFER its default `process.exit(130)` so a Ctrl+C can't tear a swap apart; the
64
+ * pass still observes the SIGINT cooperatively and stops at its next boundary.
65
+ * Ref-counted (a depth, not a boolean) so it is correct even if a pass ever nests.
66
+ */
67
+ export function isGuardedAutoUpdateActive() {
68
+ return (globalThis[GUARDED_AUTO_UPDATE_SYMBOL] ?? 0) > 0;
69
+ }
70
+ function beginGuardedAutoUpdate() {
71
+ const holder = globalThis;
72
+ holder[GUARDED_AUTO_UPDATE_SYMBOL] = (holder[GUARDED_AUTO_UPDATE_SYMBOL] ?? 0) + 1;
73
+ }
74
+ function endGuardedAutoUpdate() {
75
+ const holder = globalThis;
76
+ const depth = holder[GUARDED_AUTO_UPDATE_SYMBOL] ?? 0;
77
+ holder[GUARDED_AUTO_UPDATE_SYMBOL] = depth > 0 ? depth - 1 : 0;
78
+ }
79
+ /**
80
+ * Run `run(cancelled)` with cooperative cancellation wired from every source
81
+ * that can reach this process, and the SIGINT guard held for its duration:
82
+ *
83
+ * - **IPC message** `{ type: HARNESS_UPDATE_CANCEL_MSG }` — the primary,
84
+ * cross-platform request the daemon sends. Works on Windows, where a signal
85
+ * would force-kill.
86
+ * - **`disconnect`** — the IPC channel closed (the daemon went away). Stop
87
+ * rather than continue mutating orphaned.
88
+ * - **SIGTERM / SIGINT** — a direct `kill`/Ctrl+C on this process. Cooperative,
89
+ * not fatal; paired with `index.ts`'s guard, which defers its hard exit while
90
+ * {@link isGuardedAutoUpdateActive} holds.
91
+ *
92
+ * `cancelled()` never un-sets once set. Listeners are installed for the call's
93
+ * lifetime only and removed in `finally`, and the process-wide `message`/
94
+ * `disconnect` listeners are harmless when there is no IPC channel (they simply
95
+ * never fire), so this is safe on the in-process manual `agents update --auto`
96
+ * path too.
97
+ */
98
+ export async function withGuardedUpdateCancellation(run) {
99
+ let cancelled = typeof process.send === 'function' && process.connected === false;
100
+ const requestStop = () => {
101
+ cancelled = true;
102
+ };
103
+ const onMessage = (msg) => {
104
+ if (isCancelMessage(msg))
105
+ requestStop();
106
+ };
107
+ process.on('SIGTERM', requestStop);
108
+ process.on('SIGINT', requestStop);
109
+ process.on('message', onMessage);
110
+ process.on('disconnect', requestStop);
111
+ beginGuardedAutoUpdate();
112
+ try {
113
+ return await run(() => cancelled);
114
+ }
115
+ finally {
116
+ endGuardedAutoUpdate();
117
+ process.removeListener('SIGTERM', requestStop);
118
+ process.removeListener('SIGINT', requestStop);
119
+ process.removeListener('message', onMessage);
120
+ process.removeListener('disconnect', requestStop);
121
+ }
122
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Settings that gate the AUTOMATIC update pass (PHNX-3940): whether it runs at
3
+ * all (global + per-harness), and whether one installation participates.
4
+ *
5
+ * Two independent axes, deliberately kept apart:
6
+ * - `updates.auto` / `updates.<agent>.auto` — an OPERATOR switch, global and
7
+ * per-harness, stored centrally (`~/.agents/agents.yaml` `config:`) so it
8
+ * syncs fleet-wide like `summarizer.*`. Both are registered as ordinary
9
+ * TYPED entries in `device-config.ts`'s `CONFIG_KEYS` — the one canonical
10
+ * store for user-scope config — rather than a second untyped read/write
11
+ * path against `Meta.config` directly, so a config rewrite that only knows
12
+ * about the registry (validation, `agents config list`, the fleet sync
13
+ * that round-trips `config:`) can't silently drop these keys.
14
+ * - `Installation.updatePolicy` — an INSTALLATION property. Manual
15
+ * `agents update <agent>@<label> --to <concrete>` pins it; `--to latest`
16
+ * (or a fresh install) leaves/returns it to `'latest'`.
17
+ *
18
+ * The global switch is a HARD KILL SWITCH, not a default an explicit
19
+ * per-harness `true` can override: `updates.auto=false` must stop every
20
+ * harness's automatic pass even when an operator separately turned one
21
+ * harness's switch on, because the global switch is the "something is
22
+ * wrong, stop touching installations fleet-wide" lever and a forgotten
23
+ * per-harness override must never defeat it. See
24
+ * {@link isAutoUpdateEnabledForAgent}.
25
+ */
26
+ import type { AgentId } from '../types.js';
27
+ import type { Installation, UpdatePolicy } from './types.js';
28
+ /** The raw, explicitly-set global switch, or `undefined` when never set (default: on). */
29
+ export declare function rawGlobalAutoUpdateSetting(): boolean | undefined;
30
+ export declare function setGlobalAutoUpdateEnabled(enabled: boolean): void;
31
+ export declare function unsetGlobalAutoUpdateEnabled(): void;
32
+ /** True unless the operator explicitly turned automatic updates off globally. */
33
+ export declare function isGlobalAutoUpdateEnabled(): boolean;
34
+ /** The raw, explicitly-set per-harness switch, or `undefined` when it defers to the global one. */
35
+ export declare function rawAgentAutoUpdateSetting(agent: AgentId): boolean | undefined;
36
+ export declare function setAgentAutoUpdateEnabled(agent: AgentId, enabled: boolean): void;
37
+ export declare function unsetAgentAutoUpdateEnabled(agent: AgentId): void;
38
+ /**
39
+ * Whether the automatic-update pass may consider this agent at all.
40
+ *
41
+ * `updates.auto=false` is a hard kill switch: it wins even over an explicit
42
+ * `updates.<agent>.auto=true` (see the module docblock). With the global
43
+ * switch on (the default), the per-harness switch refines it when explicitly
44
+ * set, else the harness is enabled too.
45
+ */
46
+ export declare function isAutoUpdateEnabledForAgent(agent: AgentId): boolean;
47
+ /**
48
+ * The effective policy for one installation. Absent (legacy data, or a fresh
49
+ * install that never set it) means `'latest'` — see {@link Installation.updatePolicy}.
50
+ * Read through this everywhere rather than comparing the field directly.
51
+ */
52
+ export declare function effectiveUpdatePolicy(installation: Pick<Installation, 'updatePolicy'>): UpdatePolicy;
53
+ /**
54
+ * Persist an installation's update policy. Takes the SAME per-installation
55
+ * lock `updateInstallation` (`update.ts`) holds for its whole transaction —
56
+ * without it, a manual `--to <release>`/`--to latest` pin racing an automatic
57
+ * pass's own `recordRelease` write (both a read-modify-write of the same
58
+ * `installation.json`) could lose whichever wrote second, silently reverting
59
+ * a just-applied pin or an update's own release bump. Reloads the record
60
+ * fresh from disk under the lock (never trusts a possibly-stale in-memory
61
+ * copy) and writes back only the policy field — never touches
62
+ * `history`/`releaseVersion`, since pinning or unpinning is not a release
63
+ * change. Locks with the SAME `staleMs`/`acquireTimeoutMs`
64
+ * ({@link INSTALLATION_LOCK_OPTIONS}) as that transaction — the fs-atomic
65
+ * default (5s stale) is far shorter than an update can legitimately run, so
66
+ * using it here would let this write break the transaction's
67
+ * still-legitimately-held lock out from under it.
68
+ */
69
+ export declare function setInstallationUpdatePolicy(agent: AgentId, label: string, policy: UpdatePolicy): Promise<Installation>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Settings that gate the AUTOMATIC update pass (PHNX-3940): whether it runs at
3
+ * all (global + per-harness), and whether one installation participates.
4
+ *
5
+ * Two independent axes, deliberately kept apart:
6
+ * - `updates.auto` / `updates.<agent>.auto` — an OPERATOR switch, global and
7
+ * per-harness, stored centrally (`~/.agents/agents.yaml` `config:`) so it
8
+ * syncs fleet-wide like `summarizer.*`. Both are registered as ordinary
9
+ * TYPED entries in `device-config.ts`'s `CONFIG_KEYS` — the one canonical
10
+ * store for user-scope config — rather than a second untyped read/write
11
+ * path against `Meta.config` directly, so a config rewrite that only knows
12
+ * about the registry (validation, `agents config list`, the fleet sync
13
+ * that round-trips `config:`) can't silently drop these keys.
14
+ * - `Installation.updatePolicy` — an INSTALLATION property. Manual
15
+ * `agents update <agent>@<label> --to <concrete>` pins it; `--to latest`
16
+ * (or a fresh install) leaves/returns it to `'latest'`.
17
+ *
18
+ * The global switch is a HARD KILL SWITCH, not a default an explicit
19
+ * per-harness `true` can override: `updates.auto=false` must stop every
20
+ * harness's automatic pass even when an operator separately turned one
21
+ * harness's switch on, because the global switch is the "something is
22
+ * wrong, stop touching installations fleet-wide" lever and a forgotten
23
+ * per-harness override must never defeat it. See
24
+ * {@link isAutoUpdateEnabledForAgent}.
25
+ */
26
+ import { getConfigValue, setConfigValue, unsetConfigValue } from '../device-config.js';
27
+ import { withFileLockAsync } from '../fs-atomic.js';
28
+ import * as fs from 'node:fs';
29
+ import { ensureInstallationLocked, installationDir, writeInstallation } from './store.js';
30
+ import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
31
+ function agentAutoKey(agent) {
32
+ return `updates.${agent}.auto`;
33
+ }
34
+ /** The raw, explicitly-set global switch, or `undefined` when never set (default: on). */
35
+ export function rawGlobalAutoUpdateSetting() {
36
+ return getConfigValue('updates.auto').value;
37
+ }
38
+ export function setGlobalAutoUpdateEnabled(enabled) {
39
+ setConfigValue('updates.auto', enabled);
40
+ }
41
+ export function unsetGlobalAutoUpdateEnabled() {
42
+ unsetConfigValue('updates.auto');
43
+ }
44
+ /** True unless the operator explicitly turned automatic updates off globally. */
45
+ export function isGlobalAutoUpdateEnabled() {
46
+ return rawGlobalAutoUpdateSetting() !== false;
47
+ }
48
+ /** The raw, explicitly-set per-harness switch, or `undefined` when it defers to the global one. */
49
+ export function rawAgentAutoUpdateSetting(agent) {
50
+ return getConfigValue(agentAutoKey(agent)).value;
51
+ }
52
+ export function setAgentAutoUpdateEnabled(agent, enabled) {
53
+ setConfigValue(agentAutoKey(agent), enabled);
54
+ }
55
+ export function unsetAgentAutoUpdateEnabled(agent) {
56
+ unsetConfigValue(agentAutoKey(agent));
57
+ }
58
+ /**
59
+ * Whether the automatic-update pass may consider this agent at all.
60
+ *
61
+ * `updates.auto=false` is a hard kill switch: it wins even over an explicit
62
+ * `updates.<agent>.auto=true` (see the module docblock). With the global
63
+ * switch on (the default), the per-harness switch refines it when explicitly
64
+ * set, else the harness is enabled too.
65
+ */
66
+ export function isAutoUpdateEnabledForAgent(agent) {
67
+ if (!isGlobalAutoUpdateEnabled())
68
+ return false;
69
+ const perAgent = rawAgentAutoUpdateSetting(agent);
70
+ return perAgent !== false;
71
+ }
72
+ /**
73
+ * The effective policy for one installation. Absent (legacy data, or a fresh
74
+ * install that never set it) means `'latest'` — see {@link Installation.updatePolicy}.
75
+ * Read through this everywhere rather than comparing the field directly.
76
+ */
77
+ export function effectiveUpdatePolicy(installation) {
78
+ return installation.updatePolicy ?? 'latest';
79
+ }
80
+ /**
81
+ * Persist an installation's update policy. Takes the SAME per-installation
82
+ * lock `updateInstallation` (`update.ts`) holds for its whole transaction —
83
+ * without it, a manual `--to <release>`/`--to latest` pin racing an automatic
84
+ * pass's own `recordRelease` write (both a read-modify-write of the same
85
+ * `installation.json`) could lose whichever wrote second, silently reverting
86
+ * a just-applied pin or an update's own release bump. Reloads the record
87
+ * fresh from disk under the lock (never trusts a possibly-stale in-memory
88
+ * copy) and writes back only the policy field — never touches
89
+ * `history`/`releaseVersion`, since pinning or unpinning is not a release
90
+ * change. Locks with the SAME `staleMs`/`acquireTimeoutMs`
91
+ * ({@link INSTALLATION_LOCK_OPTIONS}) as that transaction — the fs-atomic
92
+ * default (5s stale) is far shorter than an update can legitimately run, so
93
+ * using it here would let this write break the transaction's
94
+ * still-legitimately-held lock out from under it.
95
+ */
96
+ export async function setInstallationUpdatePolicy(agent, label, policy) {
97
+ if (!fs.existsSync(installationDir(agent, label)))
98
+ throw new Error(`No installation directory for ${agent}@${label}.`);
99
+ // Guarantees `installation.json` exists and is VALID before locking on it,
100
+ // migrating a legacy pre-frozen version dir when needed — same reasoning as
101
+ // `launch-gate.ts`'s identical call. Throws its own clear
102
+ // "no installation directory" error when `label` was never installed at
103
+ // all, which is a real caller bug, not a race to reconcile under the lock.
104
+ const recordPath = installationLockTarget(agent, label);
105
+ return withFileLockAsync(recordPath, () => {
106
+ const current = ensureInstallationLocked(agent, label);
107
+ if (!current) {
108
+ throw new Error(`No installation record for ${agent}@${label} — cannot set its update policy.`);
109
+ }
110
+ const next = { ...current, updatePolicy: policy, updatedAt: new Date().toISOString() };
111
+ writeInstallation(next);
112
+ return next;
113
+ }, INSTALLATION_LOCK_OPTIONS);
114
+ }