@phnx-labs/agents-cli 1.22.78 → 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 (90) hide show
  1. package/CHANGELOG.md +11 -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/config.js +37 -0
  7. package/dist/commands/exec.js +21 -10
  8. package/dist/commands/update.js +169 -18
  9. package/dist/commands/versions.d.ts +11 -0
  10. package/dist/commands/versions.js +30 -4
  11. package/dist/commands/view.d.ts +4 -0
  12. package/dist/commands/view.js +114 -19
  13. package/dist/index.js +35 -2
  14. package/dist/lib/account-catalog.d.ts +97 -1
  15. package/dist/lib/account-catalog.js +134 -6
  16. package/dist/lib/account-registry.d.ts +43 -0
  17. package/dist/lib/account-registry.js +97 -2
  18. package/dist/lib/accounting/rotate.d.ts +2 -2
  19. package/dist/lib/accounting/rotate.js +5 -5
  20. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  21. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  22. package/dist/lib/accounts/connect.d.ts +170 -0
  23. package/dist/lib/accounts/connect.js +383 -0
  24. package/dist/lib/capabilities.js +2 -0
  25. package/dist/lib/commands.js +2 -0
  26. package/dist/lib/config-keys.d.ts +11 -2
  27. package/dist/lib/config-keys.js +21 -1
  28. package/dist/lib/daemon/daemon.js +5 -0
  29. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  30. package/dist/lib/daemon/harness-update-service.js +216 -0
  31. package/dist/lib/daemon-services.d.ts +1 -1
  32. package/dist/lib/daemon-services.js +5 -0
  33. package/dist/lib/device-config.d.ts +0 -1
  34. package/dist/lib/device-config.js +50 -5
  35. package/dist/lib/exec.js +26 -2
  36. package/dist/lib/fs-atomic.d.ts +2 -0
  37. package/dist/lib/fs-atomic.js +2 -0
  38. package/dist/lib/hooks/install.js +7 -2
  39. package/dist/lib/installations/active-check.d.ts +48 -0
  40. package/dist/lib/installations/active-check.js +84 -0
  41. package/dist/lib/installations/index.d.ts +5 -1
  42. package/dist/lib/installations/index.js +4 -0
  43. package/dist/lib/installations/installation-lock.d.ts +6 -0
  44. package/dist/lib/installations/installation-lock.js +29 -0
  45. package/dist/lib/installations/launch-gate.d.ts +69 -0
  46. package/dist/lib/installations/launch-gate.js +133 -0
  47. package/dist/lib/installations/native-command.d.ts +5 -0
  48. package/dist/lib/installations/native-command.js +52 -0
  49. package/dist/lib/installations/shims.d.ts +8 -2
  50. package/dist/lib/installations/shims.js +105 -2
  51. package/dist/lib/installations/store.d.ts +5 -1
  52. package/dist/lib/installations/store.js +24 -3
  53. package/dist/lib/installations/strategies.js +55 -35
  54. package/dist/lib/installations/types.d.ts +17 -0
  55. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  56. package/dist/lib/installations/update-cancellation.js +122 -0
  57. package/dist/lib/installations/update-policy.d.ts +69 -0
  58. package/dist/lib/installations/update-policy.js +114 -0
  59. package/dist/lib/installations/update-runtime.d.ts +118 -0
  60. package/dist/lib/installations/update-runtime.js +321 -0
  61. package/dist/lib/installations/update.d.ts +25 -0
  62. package/dist/lib/installations/update.js +141 -2
  63. package/dist/lib/installations/versions.d.ts +1 -0
  64. package/dist/lib/installations/versions.js +166 -131
  65. package/dist/lib/platform/process.d.ts +3 -1
  66. package/dist/lib/platform/process.js +2 -2
  67. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  68. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  69. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  70. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  71. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  72. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  75. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  76. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  77. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  78. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  79. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  80. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  81. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  82. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  83. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  84. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  85. package/dist/lib/state.d.ts +3 -1
  86. package/dist/lib/state.js +38 -13
  87. package/dist/lib/types.d.ts +26 -1
  88. package/dist/lib/types.js +5 -0
  89. package/dist/lib/view-types.d.ts +6 -0
  90. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The automatic-update pass (PHNX-3940): decide which installations may be
3
+ * moved to their harness's latest release with no operator in the loop, then
4
+ * (optionally) move them.
5
+ *
6
+ * Split into a PLAN (`planAutoUpdates` — the `--check` dry-run and the
7
+ * daemon's own decision step both read it; it reads through
8
+ * {@link listInstallationSnapshots}, which NEVER mutates disk, so a preview is
9
+ * genuinely a preview) and a RUN (`runAutoUpdatePass`, which migrates a
10
+ * legacy installation for real, under its own lock, and regenerates its
11
+ * already-owned shim/versioned-alias, before driving eligible plan entries
12
+ * through the existing `updateInstallation` transaction). Both share one
13
+ * eligibility computation so a dry-run can never report "would update" for
14
+ * something the real run would skip, or vice versa.
15
+ *
16
+ * Eligibility is deliberately narrow:
17
+ * - Only the `npm-package` strategy is transactional AND isolated per
18
+ * installation (stage into a sibling dir, launch-probe it, swap, keep the
19
+ * displaced tree until the swap is proven) — the shape every existing
20
+ * safety property in `update.ts` depends on. A global-binary or
21
+ * install-script harness has no reversible per-installation swap to offer,
22
+ * so it is reported honestly as manual/vendor-managed rather than silently
23
+ * skipped or, worse, "updated" via an irreversible reinstall with no
24
+ * operator watching.
25
+ * - The operator switch (`updates.auto` / `updates.<agent>.auto`,
26
+ * `update-policy.ts`) and the per-installation policy
27
+ * (`Installation.updatePolicy`) must both allow it.
28
+ * - The installation must not look ACTIVE right now (see
29
+ * {@link isInstallationLikelyActive}) — a real OS process-table scan, not
30
+ * just this box's session registry, so a harness launched by a bare
31
+ * generated shim with no session bookkeeping still defers correctly.
32
+ *
33
+ * The latest release for a harness is resolved ONCE per pass (not once per
34
+ * installation) — `resolveSharedTargets` below — so a fleet with a dozen
35
+ * pinned-to-latest Claude installations issues one npm registry read, not a
36
+ * dozen.
37
+ */
38
+ import type { AgentId } from '../types.js';
39
+ import { type Installation, type UpdateOutcome, type UpdatePolicy } from './types.js';
40
+ export interface AutoUpdatePlanEntry {
41
+ agent: AgentId;
42
+ installation: Installation;
43
+ currentRelease: string;
44
+ /** The resolved latest release, or `null` when it could not be resolved (network error, unsupported strategy). */
45
+ targetRelease: string | null;
46
+ policy: UpdatePolicy;
47
+ /** Whether the automatic pass would act on this installation at all (ignoring whether it is currently deferred). */
48
+ eligible: boolean;
49
+ /** Whether a live process for this installation held it back THIS pass. Independent of `eligible`. */
50
+ deferred: boolean;
51
+ /** Human-readable reason `eligible` is false, or `deferred` is true, or `targetRelease` is null. Unset when there is nothing to explain (already current, or a real update would run). */
52
+ reason?: string;
53
+ }
54
+ export interface AutoUpdatePassOutcome {
55
+ entry: AutoUpdatePlanEntry;
56
+ outcome?: UpdateOutcome;
57
+ error?: string;
58
+ }
59
+ export interface AutoUpdatePassResult {
60
+ plan: AutoUpdatePlanEntry[];
61
+ outcomes: AutoUpdatePassOutcome[];
62
+ /** True when the pass stopped early on a cancellation request (deadline, daemon shutdown, IPC, or SIGINT/SIGTERM) rather than exhausting the plan. */
63
+ cancelled?: boolean;
64
+ }
65
+ export interface AutoUpdatePassOptions {
66
+ /** Scope to these agents only. Default: every non-hard-deprecated managed agent. */
67
+ agents?: AgentId[];
68
+ onProgress?: (message: string) => void;
69
+ }
70
+ /**
71
+ * Read-only enumeration of installations — the ONLY listing {@link planAutoUpdates}
72
+ * may use. `listInstallations` (`store.ts`) migrates a legacy version dir's
73
+ * `installation.json` into existence via `ensureInstallation` as a side
74
+ * effect of merely being READ, which made `agents update --check` (a
75
+ * "preview") write to disk. A legacy dir with no persisted record yet gets an
76
+ * {@link ephemeralInstallationSnapshot} instead — real fields, but never
77
+ * written and never a real id. The real run migrates it for real, under this
78
+ * installation's own lock, immediately before acting on it — see
79
+ * {@link runAutoUpdatePass}.
80
+ */
81
+ export declare function listInstallationSnapshots(agent: AgentId): Installation[];
82
+ /**
83
+ * Build the automatic-update plan: one entry per installation of every scoped
84
+ * agent, with eligibility, deferral, and the resolved target release already
85
+ * computed. Never mutates anything — reads only through
86
+ * {@link listInstallationSnapshots} — so it is genuinely safe to call from
87
+ * `--check` or before every real pass.
88
+ */
89
+ export declare function planAutoUpdates(opts?: AutoUpdatePassOptions): Promise<AutoUpdatePlanEntry[]>;
90
+ /**
91
+ * Run the automatic-update pass: plan, then drive every eligible,
92
+ * non-deferred, actually-behind entry through {@link updateInstallation}.
93
+ * Installations update sequentially — this runs from a bounded daemon-spawned
94
+ * child process (`harness-update-service.ts`) on its own schedule, not a
95
+ * user-facing wait, so there is no reason to parallelize and every reason not
96
+ * to (concurrent npm installs sharing this box's npm cache have a history of
97
+ * corrupting each other).
98
+ *
99
+ * `abortIfPinnedBeforeCommit` / `abortIfAutoDisabledBeforeCommit: true` on
100
+ * every call: this is the ONE caller for whom a policy or switch change
101
+ * mid-staging must cancel the commit (see `update.ts`'s docblock on those
102
+ * options) — a manual `agents update` never routes through here.
103
+ */
104
+ export declare function runAutoUpdatePass(opts?: AutoUpdatePassOptions): Promise<AutoUpdatePassResult>;
105
+ /**
106
+ * The hidden `__harness-update-run` verb the daemon spawns with an IPC channel
107
+ * (dispatched in `index.ts`). Runs ONE auto-update pass with the cooperative,
108
+ * cross-platform cancellation above, writes a compact JSON summary to stdout for
109
+ * the daemon's log, and returns the process exit code.
110
+ *
111
+ * Exit code mirrors the manual `agents update --auto` path
112
+ * (`commands/update.ts`): a per-installation error is a NORMAL tick outcome (a
113
+ * bad vendor release) surfaced as a non-zero exit the daemon logs as a warning,
114
+ * not a service failure. A cooperative cancel is not itself an error, so it does
115
+ * not force a non-zero exit — the child exits on its own and the daemon observes
116
+ * that true completion rather than reading a killed process.
117
+ */
118
+ export declare function runHarnessUpdateChild(): Promise<number>;