@phnx-labs/agents-cli 1.22.70 → 1.22.71

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 (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +31 -1
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/repo.js +2 -2
  5. package/dist/commands/sessions-export.d.ts +5 -1
  6. package/dist/commands/sessions-export.js +100 -24
  7. package/dist/commands/sessions-import.d.ts +2 -1
  8. package/dist/commands/sessions-import.js +85 -21
  9. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  10. package/dist/lib/accounting/usage-sync.js +3 -3
  11. package/dist/lib/browser/ipc.d.ts +34 -0
  12. package/dist/lib/browser/ipc.js +140 -19
  13. package/dist/lib/browser/types.d.ts +3 -1
  14. package/dist/lib/daemon/auth-sync-service.js +1 -1
  15. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  16. package/dist/lib/daemon/daemon.js +13 -3
  17. package/dist/lib/daemon/heartbeat-service.js +3 -3
  18. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  19. package/dist/lib/daemon/runner.d.ts +18 -1
  20. package/dist/lib/daemon/runner.js +231 -78
  21. package/dist/lib/daemon/self-heal-service.js +13 -3
  22. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  23. package/dist/lib/daemon/self-update-service.js +353 -0
  24. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  25. package/dist/lib/daemon/usage-sync-service.js +1 -1
  26. package/dist/lib/daemon/watchdog-service.js +4 -4
  27. package/dist/lib/daemon-services.d.ts +1 -1
  28. package/dist/lib/daemon-services.js +5 -0
  29. package/dist/lib/device-config.d.ts +12 -1
  30. package/dist/lib/device-config.js +63 -13
  31. package/dist/lib/exec-bounded.d.ts +52 -0
  32. package/dist/lib/exec-bounded.js +113 -0
  33. package/dist/lib/feed/events.d.ts +22 -14
  34. package/dist/lib/feed/events.js +84 -44
  35. package/dist/lib/fleet-shared-state.d.ts +12 -5
  36. package/dist/lib/fleet-shared-state.js +50 -20
  37. package/dist/lib/fs-atomic.d.ts +11 -0
  38. package/dist/lib/fs-atomic.js +60 -0
  39. package/dist/lib/hosts/reconcile.d.ts +11 -4
  40. package/dist/lib/hosts/reconcile.js +31 -5
  41. package/dist/lib/project-resources.d.ts +12 -0
  42. package/dist/lib/project-resources.js +129 -0
  43. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  44. package/dist/lib/routine-process-cleanup.js +45 -34
  45. package/dist/lib/secrets/reaper.d.ts +2 -2
  46. package/dist/lib/secrets/reaper.js +13 -10
  47. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  48. package/dist/lib/secrets/reserved-sync.js +4 -4
  49. package/dist/lib/self-update.d.ts +21 -8
  50. package/dist/lib/self-update.js +54 -31
  51. package/dist/lib/session/sync/backend.d.ts +61 -0
  52. package/dist/lib/session/sync/backend.js +89 -0
  53. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  54. package/dist/lib/session/sync/managed-config.js +23 -0
  55. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  56. package/dist/lib/session/sync/managed-key.js +128 -0
  57. package/dist/lib/session/sync/net-client.d.ts +65 -0
  58. package/dist/lib/session/sync/net-client.js +117 -0
  59. package/dist/lib/session/sync/provision.d.ts +19 -0
  60. package/dist/lib/session/sync/provision.js +38 -0
  61. package/dist/lib/session/sync/r2.d.ts +5 -2
  62. package/dist/lib/session/sync/r2.js +5 -2
  63. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  64. package/dist/lib/session/sync/worker-template.js +847 -0
  65. package/dist/lib/tmux/orphan-reap.js +6 -4
  66. package/dist/lib/tmux/session.js +4 -1
  67. package/dist/lib/traces/classify.d.ts +8 -1
  68. package/dist/lib/traces/insights.d.ts +13 -1
  69. package/dist/lib/traces/insights.js +78 -3
  70. package/dist/lib/traces/sync.js +8 -3
  71. package/dist/lib/traces/worker-template.js +9 -5
  72. package/package.json +1 -1
@@ -34,6 +34,15 @@ export function syncProjectResourcesToAgent(agent, version, projectAgentsDir) {
34
34
  syncProjectWorkflows(agent, version, projectAgentsDir, projectRoot, agentRoot, result, next);
35
35
  if (next.size > 0 || manifest) {
36
36
  writeProjectManifest(agentRoot, Array.from(next).sort());
37
+ // The sync is a code generator: the per-harness dir it writes into the
38
+ // project tree (.factory/, .opencode/, …) is a regenerable copy of
39
+ // .agents/{commands,skills,…}, refreshed on every launch. Left untracked it
40
+ // dirties `git status` and can block `git merge`/`checkout` when a stray
41
+ // commit of the same path collides. So the generator owns its ignore rule:
42
+ // reconcile a per-agent marker block in <projectRoot>/.gitignore listing
43
+ // exactly the paths it manages. Passing the manifest set (empty when a sync
44
+ // clears a harness) also prunes the block. See PHNX-3717.
45
+ reconcileProjectGitignore(projectRoot, agent, agentRoot, Array.from(next).sort());
37
46
  }
38
47
  return result;
39
48
  }
@@ -57,6 +66,126 @@ function writeProjectManifest(agentRoot, paths) {
57
66
  fs.writeFileSync(tmp, JSON.stringify({ v: MANIFEST_VERSION, paths }, null, 2));
58
67
  fs.renameSync(tmp, p);
59
68
  }
69
+ const GITIGNORE_MARKER = 'agents-cli project resources';
70
+ function gitignoreMarkers(agent) {
71
+ return {
72
+ begin: `# >>> ${GITIGNORE_MARKER}: ${agent} (generated on launch — do not edit) >>>`,
73
+ end: `# <<< ${GITIGNORE_MARKER}: ${agent} <<<`,
74
+ };
75
+ }
76
+ /**
77
+ * Turn the manifest's managed paths (relative to agentRoot) into anchored,
78
+ * POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
79
+ * - drop any path that escapes the harness config dir (e.g. grok writes
80
+ * commands back into the tracked `.agents/` tree via a `../` subdir —
81
+ * ignoring that would hide tracked source; separate bug, PHNX-3718);
82
+ * - the manifest only ever holds paths the sync itself generated (pre-existing
83
+ * user/committed files are skipped and never recorded), so ignoring exactly
84
+ * these never masks a hand-authored or committed file (e.g. a repo that
85
+ * commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
86
+ */
87
+ export function managedGitignoreEntries(agentRoot, projectRoot, managed) {
88
+ const root = path.resolve(agentRoot);
89
+ const entries = new Set();
90
+ for (const rel of managed) {
91
+ if (path.isAbsolute(rel))
92
+ continue;
93
+ const abs = path.resolve(agentRoot, rel);
94
+ if (abs !== root && !abs.startsWith(root + path.sep))
95
+ continue;
96
+ const fromProject = toPosixRel(path.relative(projectRoot, abs));
97
+ if (!fromProject || fromProject === '..' || fromProject.startsWith('../'))
98
+ continue;
99
+ entries.add('/' + fromProject);
100
+ }
101
+ return Array.from(entries).sort();
102
+ }
103
+ /** True when `dir` is inside a git working tree — walks up to the filesystem
104
+ * root looking for a `.git` entry. `projectRoot` (the parent of the resolved
105
+ * `.agents/` dir) is not guaranteed to be the git root: a monorepo subdir can
106
+ * carry its own `.agents/` while `.git` lives several levels up. A root-only
107
+ * check would silently no-op the whole feature there. */
108
+ function isInsideGitRepo(dir) {
109
+ let cur = path.resolve(dir);
110
+ for (;;) {
111
+ if (pathExists(path.join(cur, '.git')))
112
+ return true;
113
+ const parent = path.dirname(cur);
114
+ if (parent === cur)
115
+ return false;
116
+ cur = parent;
117
+ }
118
+ }
119
+ /**
120
+ * Apply this agent's managed block to `.gitignore` content, IN PLACE.
121
+ *
122
+ * In-place replacement (not strip-then-append) is load-bearing: appending would
123
+ * move this agent's block behind every other agent's block on each resync, so in
124
+ * a repo synced by 2+ harnesses whichever one launched last would get bumped to
125
+ * the end — rewriting `.gitignore` on every launch forever. Replacing the block
126
+ * where it already sits keeps the file byte-stable once written.
127
+ *
128
+ * Returns the new content, `content` unchanged when there is nothing to do, or
129
+ * `null` when the block is unparseable (a begin marker with no matching end —
130
+ * hand-truncated or a botched merge). In that case we refuse to edit rather than
131
+ * treat everything to EOF as the block and silently delete the user's rules
132
+ * below the orphaned marker.
133
+ */
134
+ function applyManagedBlock(content, begin, end, entries) {
135
+ const lines = content.split('\n');
136
+ const bi = lines.indexOf(begin);
137
+ if (bi !== -1) {
138
+ const ei = lines.indexOf(end, bi + 1);
139
+ if (ei === -1)
140
+ return null; // orphaned begin marker — never truncate to EOF
141
+ if (entries.length > 0) {
142
+ return [...lines.slice(0, bi), begin, ...entries, end, ...lines.slice(ei + 1)].join('\n');
143
+ }
144
+ // Prune the block, tidying the blank lines that hugged it.
145
+ const before = lines.slice(0, bi);
146
+ const after = lines.slice(ei + 1);
147
+ while (before.length && before[before.length - 1].trim() === '')
148
+ before.pop();
149
+ while (after.length && after[0].trim() === '')
150
+ after.shift();
151
+ const rest = [...before, ...after].join('\n').replace(/\n+$/, '');
152
+ return rest.length > 0 ? `${rest}\n` : '';
153
+ }
154
+ if (entries.length === 0)
155
+ return content; // no block, nothing to add
156
+ const body = content.replace(/\n+$/, '');
157
+ const block = [begin, ...entries, end].join('\n');
158
+ return body.length > 0 ? `${body}\n\n${block}\n` : `${block}\n`;
159
+ }
160
+ /**
161
+ * Reconcile a per-agent managed block in `<projectRoot>/.gitignore` so the
162
+ * generated per-harness resource dir never shows as untracked dirt. Idempotent
163
+ * and convergent: replaces the block in place and writes only when the content
164
+ * actually changes, so the launch hot path does not churn the file (or its
165
+ * watchers) every run — even in a project synced by several harnesses. Called
166
+ * with an empty `managed` set when a sync clears a harness, which prunes the
167
+ * block. Never creates a `.gitignore` outside a git working tree.
168
+ */
169
+ function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
170
+ const gitignorePath = path.join(projectRoot, '.gitignore');
171
+ if (!isInsideGitRepo(projectRoot) && !pathExists(gitignorePath))
172
+ return;
173
+ const { begin, end } = gitignoreMarkers(agent);
174
+ const entries = managedGitignoreEntries(agentRoot, projectRoot, managed);
175
+ let original = '';
176
+ try {
177
+ original = fs.readFileSync(gitignorePath, 'utf-8');
178
+ }
179
+ catch {
180
+ original = '';
181
+ }
182
+ const next = applyManagedBlock(original, begin, end, entries);
183
+ if (next === null || next === original)
184
+ return;
185
+ const tmp = gitignorePath + '.tmp';
186
+ fs.writeFileSync(tmp, next);
187
+ fs.renameSync(tmp, gitignorePath);
188
+ }
60
189
  function removeManagedPath(agentRoot, rel) {
61
190
  if (path.isAbsolute(rel) || rel.includes('..'))
62
191
  return;
@@ -2,8 +2,8 @@ import type { RunMeta } from './scheduling/routines.js';
2
2
  export interface RoutineProcessCleanupOptions {
3
3
  runsDir?: string;
4
4
  alive?: (pid: number) => boolean;
5
- owns?: (meta: RunMeta) => boolean;
5
+ owns?: (meta: RunMeta) => Promise<boolean> | boolean;
6
6
  terminate?: (pid: number) => void;
7
7
  }
8
8
  /** Reap process groups whose durable run record is already terminal. */
9
- export declare function reapTerminalRoutineProcesses(opts?: RoutineProcessCleanupOptions): number[];
9
+ export declare function reapTerminalRoutineProcesses(opts?: RoutineProcessCleanupOptions): Promise<number[]>;
@@ -1,57 +1,68 @@
1
- import * as fs from 'fs';
1
+ import * as fsp from 'fs/promises';
2
2
  import * as path from 'path';
3
- import { execFileSync } from 'child_process';
3
+ import { execFileBounded } from './exec-bounded.js';
4
4
  import { isAlive, killTree } from './platform/index.js';
5
5
  import { getRunsDir } from './state.js';
6
- function processMatchesRun(meta) {
6
+ /** Bound the identity probe: a `ps`/`powershell` spawn on the heartbeat tick must never freeze the daemon's event loop. */
7
+ const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
8
+ async function processMatchesRun(meta) {
7
9
  if (!meta.pid || !meta.spawnedAt)
8
10
  return false;
9
- try {
10
- if (process.platform === 'win32') {
11
- const startedAt = execFileSync('powershell.exe', [
12
- '-NoProfile',
13
- '-NonInteractive',
14
- '-Command',
15
- `(Get-Process -Id ${meta.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString("o")`,
16
- ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
17
- const processStart = Date.parse(startedAt);
18
- return Number.isFinite(processStart) && Math.abs(processStart - meta.spawnedAt) < 30_000;
19
- }
20
- const elapsed = execFileSync('ps', ['-p', String(meta.pid), '-o', 'etime='], {
21
- encoding: 'utf-8',
22
- stdio: ['ignore', 'pipe', 'ignore'],
23
- }).trim();
24
- if (!elapsed)
11
+ if (process.platform === 'win32') {
12
+ const res = await execFileBounded('powershell.exe', [
13
+ '-NoProfile',
14
+ '-NonInteractive',
15
+ '-Command',
16
+ `(Get-Process -Id ${meta.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString("o")`,
17
+ ], { timeoutMs: IDENTITY_PROBE_TIMEOUT_MS });
18
+ if (res.code !== 0)
25
19
  return false;
26
- const fields = elapsed.replace(/-/g, ':').split(':').reverse();
27
- const seconds = Number(fields[0] ?? 0)
28
- + Number(fields[1] ?? 0) * 60
29
- + Number(fields[2] ?? 0) * 3600
30
- + Number(fields[3] ?? 0) * 86400;
31
- return Math.abs((Date.now() - seconds * 1000) - meta.spawnedAt) < 30_000;
20
+ const processStart = Date.parse(res.stdout.trim());
21
+ return Number.isFinite(processStart) && Math.abs(processStart - meta.spawnedAt) < 30_000;
32
22
  }
33
- catch {
23
+ const res = await execFileBounded('ps', ['-p', String(meta.pid), '-o', 'etime='], { timeoutMs: IDENTITY_PROBE_TIMEOUT_MS });
24
+ if (res.code !== 0)
34
25
  return false;
35
- }
26
+ const elapsed = res.stdout.trim();
27
+ if (!elapsed)
28
+ return false;
29
+ const fields = elapsed.replace(/-/g, ':').split(':').reverse();
30
+ const seconds = Number(fields[0] ?? 0)
31
+ + Number(fields[1] ?? 0) * 60
32
+ + Number(fields[2] ?? 0) * 3600
33
+ + Number(fields[3] ?? 0) * 86400;
34
+ return Math.abs((Date.now() - seconds * 1000) - meta.spawnedAt) < 30_000;
36
35
  }
37
36
  /** Reap process groups whose durable run record is already terminal. */
38
- export function reapTerminalRoutineProcesses(opts = {}) {
37
+ export async function reapTerminalRoutineProcesses(opts = {}) {
39
38
  const runsDir = opts.runsDir ?? getRunsDir();
40
39
  const alive = opts.alive ?? isAlive;
41
40
  const owns = opts.owns ?? processMatchesRun;
42
41
  const terminate = opts.terminate ?? ((pid) => killTree(process.platform === 'win32' ? pid : -pid));
43
- if (!fs.existsSync(runsDir))
44
- return [];
42
+ let jobs;
43
+ try {
44
+ jobs = await fsp.readdir(runsDir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return []; // runs dir absent — nothing to reap
48
+ }
45
49
  const reaped = [];
46
- for (const job of fs.readdirSync(runsDir, { withFileTypes: true })) {
50
+ for (const job of jobs) {
47
51
  if (!job.isDirectory())
48
52
  continue;
49
53
  const jobDir = path.join(runsDir, job.name);
50
- for (const run of fs.readdirSync(jobDir, { withFileTypes: true })) {
54
+ let runs;
55
+ try {
56
+ runs = await fsp.readdir(jobDir, { withFileTypes: true });
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ for (const run of runs) {
51
62
  if (!run.isDirectory())
52
63
  continue;
53
64
  try {
54
- const meta = JSON.parse(fs.readFileSync(path.join(jobDir, run.name, 'meta.json'), 'utf-8'));
65
+ const meta = JSON.parse(await fsp.readFile(path.join(jobDir, run.name, 'meta.json'), 'utf-8'));
55
66
  if (!['failed', 'timeout'].includes(meta.status) || !meta.pid || meta.hostTaskId || meta.cloudTaskId)
56
67
  continue;
57
68
  const completedAt = Date.parse(meta.completedAt ?? '');
@@ -59,7 +70,7 @@ export function reapTerminalRoutineProcesses(opts = {}) {
59
70
  continue;
60
71
  if (!alive(meta.pid))
61
72
  continue;
62
- if (!owns(meta))
73
+ if (!(await owns(meta)))
63
74
  continue;
64
75
  terminate(meta.pid);
65
76
  reaped.push(meta.pid);
@@ -83,8 +83,8 @@ export declare function resetKeychainReaperCandidatesForTest(): void;
83
83
  * Path-matches the full helper path so an unrelated binary named "Agents CLI" is
84
84
  * never targeted. Returns on non-darwin without shelling anything.
85
85
  */
86
- export declare function reapOrphanedKeychainProcesses(): {
86
+ export declare function reapOrphanedKeychainProcesses(): Promise<{
87
87
  reaped: number;
88
88
  details: string[];
89
89
  plan: ReapPlan;
90
- };
90
+ }>;
@@ -6,8 +6,10 @@
6
6
  * without a real `ps`; the driver ({@link reapOrphanedKeychainProcesses}) shells
7
7
  * `ps` once per tick and kills through {@link killTree}.
8
8
  */
9
- import { execFileSync } from 'child_process';
10
9
  import { killTree, captureProcessStartTime } from '../platform/process.js';
10
+ import { execFileBounded } from '../exec-bounded.js';
11
+ /** Deadline for the whole-process-table `ps` on the keychain-reap tick. */
12
+ const KEYCHAIN_PS_TIMEOUT_MS = 5_000;
11
13
  import { getKeychainHelperPath } from './install-helper.js';
12
14
  /** Grace before a helper whose parent exited is considered an orphan. */
13
15
  export const ORPHAN_GRACE_SEC = 30;
@@ -169,7 +171,7 @@ export function resetKeychainReaperCandidatesForTest() {
169
171
  * Path-matches the full helper path so an unrelated binary named "Agents CLI" is
170
172
  * never targeted. Returns on non-darwin without shelling anything.
171
173
  */
172
- export function reapOrphanedKeychainProcesses() {
174
+ export async function reapOrphanedKeychainProcesses() {
173
175
  const details = [];
174
176
  if (process.platform !== 'darwin') {
175
177
  return { reaped: 0, details, plan: { kill: [], nextCandidates: new Map() } };
@@ -182,14 +184,15 @@ export function reapOrphanedKeychainProcesses() {
182
184
  return { reaped: 0, details: [`helper path resolution failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
183
185
  }
184
186
  let out;
185
- try {
186
- out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etime=,command='], {
187
- encoding: 'utf-8',
188
- stdio: ['ignore', 'pipe', 'ignore'],
189
- });
190
- }
191
- catch (err) {
192
- return { reaped: 0, details: [`ps failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
187
+ {
188
+ // Async, deadline-bounded: this whole-process-table `ps` runs on the daemon's
189
+ // shared event loop every keychain-reap tick, so a synchronous `execFileSync`
190
+ // (unbounded) would freeze it (PHNX-3695).
191
+ const res = await execFileBounded('ps', ['-ax', '-o', 'pid=,ppid=,etime=,command='], { timeoutMs: KEYCHAIN_PS_TIMEOUT_MS });
192
+ if (res.code !== 0) {
193
+ return { reaped: 0, details: [`ps failed${res.timedOut ? ' (timed out)' : ''}: ${res.stderr.trim() || `exit ${res.code}`}`], plan: { kill: [], nextCandidates: new Map() } };
194
+ }
195
+ out = res.stdout;
193
196
  }
194
197
  const rows = [];
195
198
  for (const line of out.split('\n')) {
@@ -58,7 +58,7 @@ export interface PublishAuthVerdictResult {
58
58
  error: string | null;
59
59
  }
60
60
  /** Publish only safe readiness metadata; useful immediately before a repo push. */
61
- export declare function publishReservedAuthVerdict(options?: PublishAuthVerdictOptions): PublishAuthVerdictResult;
61
+ export declare function publishReservedAuthVerdict(options?: PublishAuthVerdictOptions): Promise<PublishAuthVerdictResult>;
62
62
  /**
63
63
  * Publish local readiness, elect one ready source, then asynchronously provision
64
64
  * only peers whose shared verdict says the bundle is missing.
@@ -13,7 +13,7 @@ import { isDialableDevice, loadDevicesSync } from '../devices/registry.js';
13
13
  import { sshTargetFor } from '../devices/connect.js';
14
14
  import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../devices/known-hosts.js';
15
15
  import { machineId, normalizeHost } from '../session/sync/config.js';
16
- import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
16
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
17
17
  import { getUserAgentsDir } from '../state.js';
18
18
  /** Each import/read-back SSH operation gets this deadline plus the SSH hard-kill grace. */
19
19
  export const AUTH_SYNC_PUSH_DEADLINE_MS = 20_000;
@@ -49,12 +49,12 @@ function authStatus(local) {
49
49
  return local.ok ? 'ready' : 'invalid';
50
50
  }
51
51
  /** Publish only safe readiness metadata; useful immediately before a repo push. */
52
- export function publishReservedAuthVerdict(options = {}) {
52
+ export async function publishReservedAuthVerdict(options = {}) {
53
53
  const local = (options.inspectLocal ?? inspectReservedAuthBundle)();
54
54
  const status = authStatus(local);
55
55
  const device = options.localName ?? machineId();
56
56
  try {
57
- const write = updateFleetSharedDeviceState(device, { auth: { status } }, options.userAgentsDir ?? getUserAgentsDir());
57
+ const write = await updateFleetSharedDeviceStateAsync(device, { auth: { status } }, options.userAgentsDir ?? getUserAgentsDir());
58
58
  return { device, status, changed: write.changed, error: null };
59
59
  }
60
60
  catch (err) {
@@ -73,7 +73,7 @@ export async function syncReservedAuthBundle(deps = {}) {
73
73
  skipped: [],
74
74
  errors: [],
75
75
  };
76
- const published = publishReservedAuthVerdict(deps);
76
+ const published = await publishReservedAuthVerdict(deps);
77
77
  const localStatus = published.status;
78
78
  const localName = published.device;
79
79
  const localNorm = normalizeHost(localName);
@@ -152,14 +152,21 @@ export declare function deriveGlobalPrefix(packageRoot: string): string;
152
152
  * basename), so an unrelated dotfile in the same directory is left alone.
153
153
  * Best-effort per entry: one unremovable sibling must not block the rest.
154
154
  */
155
- export declare function sweepStaleInstallStaging(packageRoot: string): string[];
155
+ export declare function sweepStaleInstallStaging(packageRoot: string): Promise<string[]>;
156
156
  /**
157
157
  * Install `spec` into an explicit global prefix. `--prefix` pins the
158
158
  * destination no matter which npm binary PATH resolves. `--ignore-scripts`
159
159
  * skips lifecycle scripts; the caller refreshes alias shims afterwards via
160
160
  * refreshAliasShims().
161
+ *
162
+ * `signal`, when passed, is wired into `execFile`'s own `signal` option —
163
+ * Node kills the child process on abort and the returned promise rejects,
164
+ * rather than the caller merely giving up on awaiting an orphaned process
165
+ * (self-update-service.ts's daemon tick needs a real kill here: on a
166
+ * deadline abort an un-killed `npm install -g` keeps writing into the same
167
+ * global prefix a subsequent retry then installs into concurrently).
161
168
  */
162
- export declare function installPackageIntoPrefix(spec: string, prefix: string): Promise<void>;
169
+ export declare function installPackageIntoPrefix(spec: string, prefix: string, signal?: AbortSignal): Promise<void>;
163
170
  /**
164
171
  * Install `spec` into bun's global store with `bun add -g`. bun writes to
165
172
  * `<bunGlobalDir>/node_modules/<pkg>`, which is exactly the running package
@@ -167,8 +174,10 @@ export declare function installPackageIntoPrefix(spec: string, prefix: string):
167
174
  * in place. bun skips untrusted lifecycle scripts, so the caller refreshes
168
175
  * alias shims afterwards via refreshAliasShims() rather than relying on the
169
176
  * package's postinstall hook.
177
+ *
178
+ * `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
170
179
  */
171
- export declare function installPackageWithBun(spec: string): Promise<void>;
180
+ export declare function installPackageWithBun(spec: string, signal?: AbortSignal): Promise<void>;
172
181
  /**
173
182
  * Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
174
183
  * string of the form `sha512-<base64>` — npm's `dist.integrity`. Recomputes the
@@ -187,15 +196,19 @@ export declare function verifyTarballIntegrity(tarball: Buffer, integrity: strin
187
196
  * against the registry attestation. Fails closed: a non-200, a download error,
188
197
  * or a hash mismatch throws and no file path is returned, so the caller never
189
198
  * installs an unverified artifact.
199
+ *
200
+ * `signal`, when passed, aborts the fetch alongside the own `timeoutMs` timer
201
+ * (whichever fires first) — a real cancellation of the in-flight request, not
202
+ * just an abandoned await.
190
203
  */
191
- export declare function downloadVerifiedTarball(tarballUrl: string, integrity: string, timeoutMs?: number): Promise<string>;
204
+ export declare function downloadVerifiedTarball(tarballUrl: string, integrity: string, timeoutMs?: number, signal?: AbortSignal): Promise<string>;
192
205
  /** Read the version field of the package.json at `packageRoot`, fresh from disk. */
193
- export declare function readInstalledVersion(packageRoot: string): string;
206
+ export declare function readInstalledVersion(packageRoot: string): Promise<string>;
194
207
  /**
195
208
  * Assert that the install at `packageRoot` now carries `expectedVersion`.
196
209
  * npm exiting 0 only proves it wrote *somewhere*; this proves it wrote *here*.
197
210
  */
198
- export declare function verifyInstalledVersion(packageRoot: string, expectedVersion: string): void;
211
+ export declare function verifyInstalledVersion(packageRoot: string, expectedVersion: string): Promise<void>;
199
212
  /**
200
213
  * Re-run the freshly installed copy's postinstall in shims-only mode so the
201
214
  * bare-command aliases (secrets, sessions, ...) pick up the new entrypoint
@@ -203,7 +216,7 @@ export declare function verifyInstalledVersion(packageRoot: string, expectedVers
203
216
  * leaves the previous shims in place, which still point at the (now
204
217
  * upgraded) package root.
205
218
  */
206
- export declare function refreshAliasShims(packageRoot: string): void;
219
+ export declare function refreshAliasShims(packageRoot: string, signal?: AbortSignal): Promise<void>;
207
220
  /** One global bin link the upgrade reconciled: what it is and what happened. */
208
221
  export interface BinLinkRepair {
209
222
  /** The `package.json#bin` key (`agents`, `ag`, `browser`, `computer`). */
@@ -245,7 +258,7 @@ export interface BinLinkRepair {
245
258
  * npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
246
259
  * bin layout and is out of scope.
247
260
  */
248
- export declare function ensureGlobalBinLinks(packageRoot: string, prefix: string): BinLinkRepair[];
261
+ export declare function ensureGlobalBinLinks(packageRoot: string, prefix: string): Promise<BinLinkRepair[]>;
249
262
  export interface AgentsCliInstall {
250
263
  /** The PATH entry (`<dir>/agents`) that resolves to this install, when found through PATH. */
251
264
  binPath?: string;
@@ -12,10 +12,13 @@
12
12
  * to PATH resolution.
13
13
  */
14
14
  import * as fs from 'fs';
15
+ import * as fsp from 'fs/promises';
15
16
  import * as os from 'os';
16
17
  import * as path from 'path';
17
18
  import { createHash, timingSafeEqual } from 'crypto';
18
- import { spawnSync } from 'child_process';
19
+ import { execFile } from 'child_process';
20
+ import { promisify } from 'util';
21
+ const execFileAsync = promisify(execFile);
19
22
  // Leaf comparator only — do not pull the full versions.ts graph into every
20
23
  // bootstrap that imports self-update (RUSH-2331).
21
24
  import { compareVersions } from './agent-spec/primitives.js';
@@ -314,7 +317,7 @@ export function deriveGlobalPrefix(packageRoot) {
314
317
  * basename), so an unrelated dotfile in the same directory is left alone.
315
318
  * Best-effort per entry: one unremovable sibling must not block the rest.
316
319
  */
317
- export function sweepStaleInstallStaging(packageRoot) {
320
+ export async function sweepStaleInstallStaging(packageRoot) {
318
321
  const resolved = path.resolve(packageRoot);
319
322
  const dir = path.dirname(resolved);
320
323
  const base = path.basename(resolved);
@@ -322,7 +325,7 @@ export function sweepStaleInstallStaging(packageRoot) {
322
325
  const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
323
326
  let entries;
324
327
  try {
325
- entries = fs.readdirSync(dir);
328
+ entries = await fsp.readdir(dir);
326
329
  }
327
330
  catch {
328
331
  return [];
@@ -333,7 +336,7 @@ export function sweepStaleInstallStaging(packageRoot) {
333
336
  continue;
334
337
  const full = path.join(dir, entry);
335
338
  try {
336
- fs.rmSync(full, { recursive: true, force: true });
339
+ await fsp.rm(full, { recursive: true, force: true });
337
340
  swept.push(full);
338
341
  }
339
342
  catch {
@@ -347,14 +350,22 @@ export function sweepStaleInstallStaging(packageRoot) {
347
350
  * destination no matter which npm binary PATH resolves. `--ignore-scripts`
348
351
  * skips lifecycle scripts; the caller refreshes alias shims afterwards via
349
352
  * refreshAliasShims().
353
+ *
354
+ * `signal`, when passed, is wired into `execFile`'s own `signal` option —
355
+ * Node kills the child process on abort and the returned promise rejects,
356
+ * rather than the caller merely giving up on awaiting an orphaned process
357
+ * (self-update-service.ts's daemon tick needs a real kill here: on a
358
+ * deadline abort an un-killed `npm install -g` keeps writing into the same
359
+ * global prefix a subsequent retry then installs into concurrently).
350
360
  */
351
- export async function installPackageIntoPrefix(spec, prefix) {
361
+ export async function installPackageIntoPrefix(spec, prefix, signal) {
352
362
  const { execFile } = await import('child_process');
353
363
  const { promisify } = await import('util');
354
364
  const execFileAsync = promisify(execFile);
355
365
  // On Windows `npm` is `npm.cmd`; execFile cannot run it without a shell (ENOENT).
356
366
  await execFileAsync('npm', ['install', '-g', '--prefix', prefix, spec, '--ignore-scripts'], {
357
367
  shell: needsWindowsShell('npm'),
368
+ signal,
358
369
  });
359
370
  }
360
371
  /**
@@ -364,8 +375,10 @@ export async function installPackageIntoPrefix(spec, prefix) {
364
375
  * in place. bun skips untrusted lifecycle scripts, so the caller refreshes
365
376
  * alias shims afterwards via refreshAliasShims() rather than relying on the
366
377
  * package's postinstall hook.
378
+ *
379
+ * `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
367
380
  */
368
- export async function installPackageWithBun(spec) {
381
+ export async function installPackageWithBun(spec, signal) {
369
382
  const { execFile } = await import('child_process');
370
383
  const { promisify } = await import('util');
371
384
  const execFileAsync = promisify(execFile);
@@ -373,7 +386,7 @@ export async function installPackageWithBun(spec) {
373
386
  // --ignore-scripts: the tarball has already been integrity-verified, but its
374
387
  // lifecycle scripts must not run at install time (the caller refreshes shims
375
388
  // explicitly via refreshAliasShims()) — same fail-closed posture as the npm path.
376
- await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun') });
389
+ await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun'), signal });
377
390
  }
378
391
  /**
379
392
  * Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
@@ -411,9 +424,14 @@ export function verifyTarballIntegrity(tarball, integrity) {
411
424
  * against the registry attestation. Fails closed: a non-200, a download error,
412
425
  * or a hash mismatch throws and no file path is returned, so the caller never
413
426
  * installs an unverified artifact.
427
+ *
428
+ * `signal`, when passed, aborts the fetch alongside the own `timeoutMs` timer
429
+ * (whichever fires first) — a real cancellation of the in-flight request, not
430
+ * just an abandoned await.
414
431
  */
415
- export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000) {
416
- const response = await fetch(tarballUrl, { signal: AbortSignal.timeout(timeoutMs) });
432
+ export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000, signal) {
433
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
434
+ const response = await fetch(tarballUrl, { signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal });
417
435
  if (!response.ok) {
418
436
  throw new Error(`could not download tarball from ${tarballUrl} (HTTP ${response.status})`);
419
437
  }
@@ -425,15 +443,15 @@ export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs =
425
443
  return file;
426
444
  }
427
445
  /** Read the version field of the package.json at `packageRoot`, fresh from disk. */
428
- export function readInstalledVersion(packageRoot) {
429
- return JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')).version;
446
+ export async function readInstalledVersion(packageRoot) {
447
+ return JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf-8')).version;
430
448
  }
431
449
  /**
432
450
  * Assert that the install at `packageRoot` now carries `expectedVersion`.
433
451
  * npm exiting 0 only proves it wrote *somewhere*; this proves it wrote *here*.
434
452
  */
435
- export function verifyInstalledVersion(packageRoot, expectedVersion) {
436
- const actual = readInstalledVersion(packageRoot);
453
+ export async function verifyInstalledVersion(packageRoot, expectedVersion) {
454
+ const actual = await readInstalledVersion(packageRoot);
437
455
  if (actual !== expectedVersion) {
438
456
  const manager = detectPackageManager(packageRoot);
439
457
  const hint = manualInstallHint(manager, packageRoot, `${NPM_PACKAGE_NAME}@${expectedVersion}`);
@@ -448,16 +466,21 @@ export function verifyInstalledVersion(packageRoot, expectedVersion) {
448
466
  * leaves the previous shims in place, which still point at the (now
449
467
  * upgraded) package root.
450
468
  */
451
- export function refreshAliasShims(packageRoot) {
452
- spawnSync(process.execPath, [path.join(packageRoot, 'scripts', 'postinstall.js')], {
453
- env: { ...process.env, AGENTS_POSTINSTALL_SHIMS_ONLY: '1' },
454
- stdio: 'ignore',
455
- });
469
+ export async function refreshAliasShims(packageRoot, signal) {
470
+ try {
471
+ await execFileAsync(process.execPath, [path.join(packageRoot, 'scripts', 'postinstall.js')], {
472
+ env: { ...process.env, AGENTS_POSTINSTALL_SHIMS_ONLY: '1' },
473
+ signal,
474
+ });
475
+ }
476
+ catch {
477
+ /* best-effort — a failure here leaves the previous shims in place, same as the prior spawnSync (which ignored its exit code/stdio too) */
478
+ }
456
479
  }
457
480
  /** Resolve `p` through symlinks, or null when it does not resolve (missing/dangling). */
458
- function realpathOrNull(p) {
481
+ async function realpathOrNull(p) {
459
482
  try {
460
- return fs.realpathSync(p);
483
+ return await fsp.realpath(p);
461
484
  }
462
485
  catch {
463
486
  return null;
@@ -472,18 +495,18 @@ function realpathOrNull(p) {
472
495
  * repair that still does not resolve (target missing, unwritable bin dir) is
473
496
  * reported `failed` with the reason rather than silently swallowed.
474
497
  */
475
- function reconcileBinLink(name, linkPath, target) {
476
- const wanted = realpathOrNull(target);
477
- if (wanted !== null && realpathOrNull(linkPath) === wanted) {
498
+ async function reconcileBinLink(name, linkPath, target) {
499
+ const wanted = await realpathOrNull(target);
500
+ if (wanted !== null && (await realpathOrNull(linkPath)) === wanted) {
478
501
  return { name, linkPath, target, action: 'ok' };
479
502
  }
480
503
  try {
481
- fs.mkdirSync(path.dirname(linkPath), { recursive: true });
504
+ await fsp.mkdir(path.dirname(linkPath), { recursive: true });
482
505
  // Replace whatever is there (a dangling link, a stale link, or nothing).
483
- fs.rmSync(linkPath, { force: true });
484
- fs.symlinkSync(path.relative(path.dirname(linkPath), target), linkPath);
485
- const resolved = realpathOrNull(linkPath);
486
- if (resolved !== null && resolved === realpathOrNull(target)) {
506
+ await fsp.rm(linkPath, { force: true });
507
+ await fsp.symlink(path.relative(path.dirname(linkPath), target), linkPath);
508
+ const resolved = await realpathOrNull(linkPath);
509
+ if (resolved !== null && resolved === (await realpathOrNull(target))) {
487
510
  return { name, linkPath, target, action: 'repaired' };
488
511
  }
489
512
  return {
@@ -524,10 +547,10 @@ function reconcileBinLink(name, linkPath, target) {
524
547
  * npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
525
548
  * bin layout and is out of scope.
526
549
  */
527
- export function ensureGlobalBinLinks(packageRoot, prefix) {
550
+ export async function ensureGlobalBinLinks(packageRoot, prefix) {
528
551
  let bin;
529
552
  try {
530
- const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
553
+ const pkg = JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
531
554
  bin = pkg && typeof pkg.bin === 'object' && pkg.bin !== null ? pkg.bin : {};
532
555
  }
533
556
  catch (err) {
@@ -538,7 +561,7 @@ export function ensureGlobalBinLinks(packageRoot, prefix) {
538
561
  for (const [name, rel] of Object.entries(bin)) {
539
562
  if (typeof rel !== 'string' || !rel)
540
563
  continue;
541
- repairs.push(reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
564
+ repairs.push(await reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
542
565
  }
543
566
  return repairs;
544
567
  }