@phnx-labs/agents-cli 1.22.69 → 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 (130) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -9
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +2 -2
  6. package/dist/commands/import.js +2 -2
  7. package/dist/commands/models.js +2 -2
  8. package/dist/commands/permissions.js +2 -2
  9. package/dist/commands/repo.js +2 -2
  10. package/dist/commands/rules.js +1 -1
  11. package/dist/commands/send.js +9 -3
  12. package/dist/commands/sessions-export.d.ts +5 -1
  13. package/dist/commands/sessions-export.js +100 -24
  14. package/dist/commands/sessions-import.d.ts +2 -1
  15. package/dist/commands/sessions-import.js +85 -21
  16. package/dist/commands/traces.js +1 -1
  17. package/dist/lib/account-capabilities.js +0 -2
  18. package/dist/lib/account-registry.d.ts +3 -3
  19. package/dist/lib/account-registry.js +25 -7
  20. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  21. package/dist/lib/accounting/usage-sync.js +3 -3
  22. package/dist/lib/acp/client.d.ts +1 -1
  23. package/dist/lib/acp/client.js +12 -1
  24. package/dist/lib/acp/harnesses.js +1 -1
  25. package/dist/lib/add-dir.js +0 -2
  26. package/dist/lib/agent-cli-commands.js +0 -2
  27. package/dist/lib/agent-spec/agents.d.ts +1 -1
  28. package/dist/lib/agent-spec/agents.js +2 -83
  29. package/dist/lib/browser/ipc.d.ts +34 -0
  30. package/dist/lib/browser/ipc.js +149 -20
  31. package/dist/lib/browser/remote-control.d.ts +6 -3
  32. package/dist/lib/browser/remote-control.js +6 -3
  33. package/dist/lib/browser/service.d.ts +4 -1
  34. package/dist/lib/browser/service.js +7 -1
  35. package/dist/lib/browser/types.d.ts +3 -1
  36. package/dist/lib/channels/registry.d.ts +6 -0
  37. package/dist/lib/channels/send.d.ts +11 -2
  38. package/dist/lib/channels/send.js +11 -2
  39. package/dist/lib/cloud/rush.d.ts +10 -2
  40. package/dist/lib/cloud/rush.js +15 -9
  41. package/dist/lib/daemon/auth-sync-service.js +1 -1
  42. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  43. package/dist/lib/daemon/daemon.js +41 -11
  44. package/dist/lib/daemon/heartbeat-service.js +3 -3
  45. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  46. package/dist/lib/daemon/runner.d.ts +18 -1
  47. package/dist/lib/daemon/runner.js +237 -80
  48. package/dist/lib/daemon/self-heal-service.js +13 -3
  49. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  50. package/dist/lib/daemon/self-update-service.js +353 -0
  51. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +1 -1
  53. package/dist/lib/daemon/watchdog-service.js +4 -4
  54. package/dist/lib/daemon-services.d.ts +1 -1
  55. package/dist/lib/daemon-services.js +5 -0
  56. package/dist/lib/device-config.d.ts +12 -1
  57. package/dist/lib/device-config.js +63 -13
  58. package/dist/lib/exec-bounded.d.ts +52 -0
  59. package/dist/lib/exec-bounded.js +113 -0
  60. package/dist/lib/exec.d.ts +2 -2
  61. package/dist/lib/exec.js +3 -34
  62. package/dist/lib/feed/events.d.ts +22 -14
  63. package/dist/lib/feed/events.js +84 -44
  64. package/dist/lib/feed-broadcast.d.ts +12 -29
  65. package/dist/lib/feed-broadcast.js +28 -27
  66. package/dist/lib/fleet-shared-state.d.ts +12 -5
  67. package/dist/lib/fleet-shared-state.js +50 -20
  68. package/dist/lib/fs-atomic.d.ts +11 -0
  69. package/dist/lib/fs-atomic.js +60 -0
  70. package/dist/lib/hooks/install.js +0 -87
  71. package/dist/lib/hosts/reconcile.d.ts +11 -4
  72. package/dist/lib/hosts/reconcile.js +31 -5
  73. package/dist/lib/installations/strategies.js +1 -1
  74. package/dist/lib/mcp-registry.js +0 -13
  75. package/dist/lib/mcp.js +2 -2
  76. package/dist/lib/model-tiers.js +1 -1
  77. package/dist/lib/models.js +0 -63
  78. package/dist/lib/notify.d.ts +11 -0
  79. package/dist/lib/notify.js +17 -4
  80. package/dist/lib/owner-message.d.ts +46 -3
  81. package/dist/lib/owner-message.js +26 -6
  82. package/dist/lib/permissions-registry.d.ts +0 -2
  83. package/dist/lib/permissions-registry.js +3 -50
  84. package/dist/lib/permissions.d.ts +3 -17
  85. package/dist/lib/permissions.js +4 -73
  86. package/dist/lib/project-resources.d.ts +12 -0
  87. package/dist/lib/project-resources.js +129 -0
  88. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  89. package/dist/lib/routine-process-cleanup.js +45 -34
  90. package/dist/lib/rush-session.d.ts +19 -0
  91. package/dist/lib/rush-session.js +24 -0
  92. package/dist/lib/secrets/drivers/rush.js +2 -1
  93. package/dist/lib/secrets/reaper.d.ts +2 -2
  94. package/dist/lib/secrets/reaper.js +13 -10
  95. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  96. package/dist/lib/secrets/reserved-sync.js +4 -4
  97. package/dist/lib/self-update.d.ts +21 -8
  98. package/dist/lib/self-update.js +54 -31
  99. package/dist/lib/session/cloud.js +2 -1
  100. package/dist/lib/session/sync/backend.d.ts +61 -0
  101. package/dist/lib/session/sync/backend.js +89 -0
  102. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  103. package/dist/lib/session/sync/managed-config.js +23 -0
  104. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  105. package/dist/lib/session/sync/managed-key.js +128 -0
  106. package/dist/lib/session/sync/net-client.d.ts +65 -0
  107. package/dist/lib/session/sync/net-client.js +117 -0
  108. package/dist/lib/session/sync/provision.d.ts +19 -0
  109. package/dist/lib/session/sync/provision.js +38 -0
  110. package/dist/lib/session/sync/r2.d.ts +5 -2
  111. package/dist/lib/session/sync/r2.js +5 -2
  112. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  113. package/dist/lib/session/sync/worker-template.js +847 -0
  114. package/dist/lib/sink-format.d.ts +34 -0
  115. package/dist/lib/sink-format.js +17 -0
  116. package/dist/lib/staleness/detectors/permissions.js +0 -20
  117. package/dist/lib/staleness/writers/commands.js +1 -1
  118. package/dist/lib/staleness/writers/hooks.js +2 -2
  119. package/dist/lib/subagents-registry.js +2 -12
  120. package/dist/lib/subagents.d.ts +0 -10
  121. package/dist/lib/subagents.js +0 -37
  122. package/dist/lib/tmux/orphan-reap.js +6 -4
  123. package/dist/lib/tmux/session.js +4 -1
  124. package/dist/lib/traces/classify.d.ts +8 -1
  125. package/dist/lib/traces/insights.d.ts +13 -1
  126. package/dist/lib/traces/insights.js +78 -3
  127. package/dist/lib/traces/sync.js +8 -3
  128. package/dist/lib/traces/worker-template.js +9 -5
  129. package/dist/lib/types.d.ts +1 -1
  130. 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);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rush session freshness — the ONE judgment shared by every consumer of
3
+ * ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
4
+ *
5
+ * A Rush session stores `expires_at` as Unix seconds. Two values mean "never
6
+ * expires" and MUST NOT be read as an absolute timestamp:
7
+ * - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
8
+ * `0` = non-expiring by contract (mirrors rush/cli's isFresh,
9
+ * the same fix RUSH-1310 landed for the daemon-less freshness
10
+ * check and PHNX-3637 landed for the release endpoints).
11
+ * - `undefined` — a token written without an expiry.
12
+ *
13
+ * Reading `expires_at: 0` as epoch-1970 was PHNX-3645: every Phoenix-authed box
14
+ * had its valid, non-expiring session rejected as "Rush session expired at
15
+ * 1970-01-01", so `agents run --cloud`, `agents secrets push/pull` over Rush,
16
+ * and cloud session discovery all refused to run. Every consumer that parses
17
+ * freshness routes through this predicate so the special-case lives in one place.
18
+ */
19
+ export declare function isRushSessionExpired(expiresAt: number | undefined): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Rush session freshness — the ONE judgment shared by every consumer of
3
+ * ~/.rush/user.yaml (cloud dispatch, cloud session source, secrets sync driver).
4
+ *
5
+ * A Rush session stores `expires_at` as Unix seconds. Two values mean "never
6
+ * expires" and MUST NOT be read as an absolute timestamp:
7
+ * - `0` — an opaque Phoenix `pid_` bearer written by `rush login`;
8
+ * `0` = non-expiring by contract (mirrors rush/cli's isFresh,
9
+ * the same fix RUSH-1310 landed for the daemon-less freshness
10
+ * check and PHNX-3637 landed for the release endpoints).
11
+ * - `undefined` — a token written without an expiry.
12
+ *
13
+ * Reading `expires_at: 0` as epoch-1970 was PHNX-3645: every Phoenix-authed box
14
+ * had its valid, non-expiring session rejected as "Rush session expired at
15
+ * 1970-01-01", so `agents run --cloud`, `agents secrets push/pull` over Rush,
16
+ * and cloud session discovery all refused to run. Every consumer that parses
17
+ * freshness routes through this predicate so the special-case lives in one place.
18
+ */
19
+ export function isRushSessionExpired(expiresAt) {
20
+ // `0` (non-expiring pid_ bearer) and a missing value are never expired.
21
+ if (typeof expiresAt !== 'number' || expiresAt === 0)
22
+ return false;
23
+ return expiresAt <= Date.now() / 1000;
24
+ }
@@ -13,6 +13,7 @@ import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
15
  import * as yaml from 'yaml';
16
+ import { isRushSessionExpired } from '../../rush-session.js';
16
17
  const PROXY_BASE = 'https://api.prix.dev';
17
18
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
18
19
  const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
@@ -27,7 +28,7 @@ function readRushToken() {
27
28
  throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
28
29
  }
29
30
  const expiresAt = data.session?.expires_at;
30
- if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
31
+ if (isRushSessionExpired(expiresAt)) {
31
32
  const expiredAt = new Date(expiresAt * 1000).toISOString();
32
33
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
33
34
  }
@@ -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;