@phnx-labs/agents-cli 1.20.67 → 1.20.68

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 (47) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/doctor.js +37 -9
  6. package/dist/commands/exec.js +39 -1
  7. package/dist/commands/plugins.js +11 -2
  8. package/dist/commands/repo.js +47 -2
  9. package/dist/commands/ssh.js +103 -2
  10. package/dist/commands/teams.d.ts +11 -0
  11. package/dist/commands/teams.js +39 -1
  12. package/dist/commands/view.d.ts +1 -0
  13. package/dist/commands/view.js +12 -8
  14. package/dist/lib/agents.d.ts +11 -5
  15. package/dist/lib/agents.js +27 -24
  16. package/dist/lib/browser/profiles.js +4 -25
  17. package/dist/lib/cli-entry.d.ts +23 -0
  18. package/dist/lib/cli-entry.js +116 -0
  19. package/dist/lib/daemon.d.ts +2 -2
  20. package/dist/lib/daemon.js +8 -89
  21. package/dist/lib/devices/fleet.d.ts +23 -0
  22. package/dist/lib/devices/fleet.js +38 -0
  23. package/dist/lib/devices/health-report.d.ts +46 -0
  24. package/dist/lib/devices/health-report.js +159 -0
  25. package/dist/lib/devices/health.d.ts +14 -4
  26. package/dist/lib/devices/health.js +49 -8
  27. package/dist/lib/git.d.ts +10 -0
  28. package/dist/lib/git.js +23 -0
  29. package/dist/lib/hosts/option.js +1 -1
  30. package/dist/lib/hosts/ready.js +5 -3
  31. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  32. package/dist/lib/hosts/remote-cmd.js +17 -1
  33. package/dist/lib/mcp.d.ts +21 -0
  34. package/dist/lib/mcp.js +278 -13
  35. package/dist/lib/resources/mcp.js +20 -342
  36. package/dist/lib/routines.d.ts +13 -0
  37. package/dist/lib/routines.js +21 -0
  38. package/dist/lib/runner.js +18 -40
  39. package/dist/lib/secrets/agent.js +11 -15
  40. package/dist/lib/share/capture.js +21 -3
  41. package/dist/lib/signin-badge.d.ts +41 -0
  42. package/dist/lib/signin-badge.js +64 -0
  43. package/dist/lib/ssh-exec.d.ts +5 -0
  44. package/dist/lib/ssh-exec.js +55 -1
  45. package/dist/lib/usage.d.ts +17 -3
  46. package/dist/lib/usage.js +63 -16
  47. package/package.json +1 -1
@@ -12,8 +12,9 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as yaml from 'yaml';
15
- import * as TOML from 'smol-toml';
16
15
  import { capableAgents } from '../capabilities.js';
16
+ import { getProjectMcpConfigPath } from '../agents.js';
17
+ import { writeMcpConfig } from '../mcp.js';
17
18
  import { getSystemMcpDir, getUserMcpDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
18
19
  /**
19
20
  * Parse an MCP YAML file into an McpItem.
@@ -132,316 +133,10 @@ export function getMcpConfigPath(agent, versionHome) {
132
133
  }
133
134
  }
134
135
  /**
135
- * Strip JSON comments (for JSONC files).
136
+ * Dispatch MCP items to the agent-specific config writer.
136
137
  */
137
- function stripJsonComments(content) {
138
- let result = '';
139
- let inString = false;
140
- let escape = false;
141
- let i = 0;
142
- while (i < content.length) {
143
- const char = content[i];
144
- const next = content[i + 1];
145
- if (escape) {
146
- result += char;
147
- escape = false;
148
- i++;
149
- continue;
150
- }
151
- if (char === '\\' && inString) {
152
- result += char;
153
- escape = true;
154
- i++;
155
- continue;
156
- }
157
- if (char === '"') {
158
- inString = !inString;
159
- result += char;
160
- i++;
161
- continue;
162
- }
163
- if (!inString) {
164
- if (char === '/' && next === '/') {
165
- while (i < content.length && content[i] !== '\n') {
166
- i++;
167
- }
168
- continue;
169
- }
170
- if (char === '/' && next === '*') {
171
- i += 2;
172
- while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
173
- i++;
174
- }
175
- i += 2;
176
- continue;
177
- }
178
- }
179
- result += char;
180
- i++;
181
- }
182
- return result;
183
- }
184
- /**
185
- * Write MCP servers to Claude settings.json format.
186
- */
187
- function syncToClaudeConfig(configPath, items) {
188
- let config = {};
189
- if (fs.existsSync(configPath)) {
190
- try {
191
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
192
- }
193
- catch {
194
- config = {};
195
- }
196
- }
197
- const mcpServers = {};
198
- for (const item of items) {
199
- if (item.transport === 'stdio') {
200
- mcpServers[item.name] = {
201
- command: item.command,
202
- args: item.args || [],
203
- env: item.env || {},
204
- };
205
- }
206
- else {
207
- mcpServers[item.name] = {
208
- url: item.url,
209
- ...(item.headers && { headers: item.headers }),
210
- };
211
- }
212
- }
213
- config.mcpServers = mcpServers;
214
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
215
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
216
- }
217
- /**
218
- * Write MCP servers to Codex config.toml format.
219
- */
220
- function syncToCodexConfig(configPath, items) {
221
- let config = {};
222
- if (fs.existsSync(configPath)) {
223
- try {
224
- config = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
225
- }
226
- catch {
227
- config = {};
228
- }
229
- }
230
- const mcpServers = {};
231
- for (const item of items) {
232
- if (item.transport === 'stdio') {
233
- mcpServers[item.name] = {
234
- command: item.command,
235
- args: item.args || [],
236
- ...(item.env && { env: item.env }),
237
- };
238
- }
239
- // Codex may not support HTTP MCPs
240
- }
241
- config.mcp_servers = mcpServers;
242
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
243
- fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
244
- }
245
- /**
246
- * Write MCP servers to Grok config.toml format ([mcp_servers] section).
247
- */
248
- function syncToGrokConfig(configPath, items) {
249
- let config = {};
250
- if (fs.existsSync(configPath)) {
251
- try {
252
- config = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
253
- }
254
- catch {
255
- config = {};
256
- }
257
- }
258
- const mcpServers = {};
259
- for (const item of items) {
260
- if (item.transport === 'stdio') {
261
- mcpServers[item.name] = {
262
- command: item.command,
263
- args: item.args || [],
264
- ...(item.env && { env: item.env }),
265
- };
266
- }
267
- else if (item.transport === 'http' || item.transport === 'sse') {
268
- mcpServers[item.name] = {
269
- url: item.url,
270
- ...(item.headers && { headers: item.headers }),
271
- };
272
- }
273
- }
274
- config.mcp_servers = mcpServers;
275
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
276
- fs.writeFileSync(configPath, TOML.stringify(config), 'utf-8');
277
- }
278
- function syncToHermesConfig(configPath, items) {
279
- let config = {};
280
- if (fs.existsSync(configPath)) {
281
- try {
282
- const parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8'));
283
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
284
- config = parsed;
285
- }
286
- }
287
- catch {
288
- config = {};
289
- }
290
- }
291
- const mcpServers = {};
292
- for (const item of items) {
293
- if (item.transport === 'stdio') {
294
- mcpServers[item.name] = {
295
- command: item.command,
296
- args: item.args || [],
297
- ...(item.env && { env: item.env }),
298
- };
299
- }
300
- else {
301
- mcpServers[item.name] = {
302
- url: item.url,
303
- };
304
- }
305
- }
306
- config.mcp_servers = mcpServers;
307
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
308
- fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
309
- }
310
- /**
311
- * Write MCP servers to OpenCode opencode.jsonc format.
312
- */
313
- function syncToOpenCodeConfig(configPath, items) {
314
- let config = {};
315
- if (fs.existsSync(configPath)) {
316
- try {
317
- const content = stripJsonComments(fs.readFileSync(configPath, 'utf-8'));
318
- config = JSON.parse(content);
319
- }
320
- catch {
321
- config = {};
322
- }
323
- }
324
- const mcp = {};
325
- for (const item of items) {
326
- if (item.transport === 'stdio') {
327
- // OpenCode uses command as array
328
- const commandArray = [item.command, ...(item.args || [])];
329
- mcp[item.name] = {
330
- type: 'local',
331
- command: commandArray,
332
- ...(item.env && { env: item.env }),
333
- };
334
- }
335
- else {
336
- mcp[item.name] = {
337
- type: 'remote',
338
- url: item.url,
339
- };
340
- }
341
- }
342
- config.mcp = mcp;
343
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
344
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
345
- }
346
- /**
347
- * Write MCP servers to Cursor mcp.json format.
348
- */
349
- function syncToCursorConfig(configPath, items) {
350
- let config = {};
351
- if (fs.existsSync(configPath)) {
352
- try {
353
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
354
- }
355
- catch {
356
- config = {};
357
- }
358
- }
359
- const mcpServers = {};
360
- for (const item of items) {
361
- if (item.transport === 'stdio') {
362
- mcpServers[item.name] = {
363
- command: item.command,
364
- args: item.args || [],
365
- env: item.env || {},
366
- };
367
- }
368
- else {
369
- mcpServers[item.name] = {
370
- url: item.url,
371
- };
372
- }
373
- }
374
- config.mcpServers = mcpServers;
375
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
376
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
377
- }
378
- /**
379
- * Write MCP servers to Gemini settings.json format.
380
- */
381
- function syncToGeminiConfig(configPath, items) {
382
- let config = {};
383
- if (fs.existsSync(configPath)) {
384
- try {
385
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
386
- }
387
- catch {
388
- config = {};
389
- }
390
- }
391
- const mcpServers = {};
392
- for (const item of items) {
393
- if (item.transport === 'stdio') {
394
- mcpServers[item.name] = {
395
- command: item.command,
396
- args: item.args || [],
397
- env: item.env || {},
398
- };
399
- }
400
- else {
401
- mcpServers[item.name] = {
402
- url: item.url,
403
- };
404
- }
405
- }
406
- config.mcpServers = mcpServers;
407
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
408
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
409
- }
410
- /**
411
- * Write MCP servers to OpenClaw openclaw.json format.
412
- */
413
- function syncToOpenClawConfig(configPath, items) {
414
- let config = {};
415
- if (fs.existsSync(configPath)) {
416
- try {
417
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
418
- }
419
- catch {
420
- config = {};
421
- }
422
- }
423
- if (!config.mcp || typeof config.mcp !== 'object') {
424
- config.mcp = {};
425
- }
426
- const servers = {};
427
- for (const item of items) {
428
- if (item.transport === 'stdio') {
429
- servers[item.name] = {
430
- command: item.command,
431
- args: item.args,
432
- env: item.env,
433
- };
434
- }
435
- else {
436
- servers[item.name] = {
437
- url: item.url,
438
- transport: item.transport,
439
- };
440
- }
441
- }
442
- config.mcp.servers = servers;
443
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
444
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
138
+ function syncToAgentConfig(agent, configPath, items, mode = 'overwrite') {
139
+ writeMcpConfig(agent, configPath, items, mode);
445
140
  }
446
141
  /**
447
142
  * MCP resource handler implementing ResourceHandler<McpItem>.
@@ -511,39 +206,22 @@ export const McpHandler = {
511
206
  if (items.length === 0) {
512
207
  return;
513
208
  }
209
+ // Sync resolved MCPs to the version home (user-level agent config).
514
210
  const configPath = getMcpConfigPath(agent, versionHome);
515
- if (!configPath) {
516
- return;
517
- }
518
- const mcpItems = items.map((r) => r.item);
519
- switch (agent) {
520
- case 'claude':
521
- syncToClaudeConfig(configPath, mcpItems);
522
- break;
523
- case 'codex':
524
- syncToCodexConfig(configPath, mcpItems);
525
- break;
526
- case 'opencode':
527
- syncToOpenCodeConfig(configPath, mcpItems);
528
- break;
529
- case 'cursor':
530
- syncToCursorConfig(configPath, mcpItems);
531
- break;
532
- case 'gemini':
533
- syncToGeminiConfig(configPath, mcpItems);
534
- break;
535
- case 'openclaw':
536
- syncToOpenClawConfig(configPath, mcpItems);
537
- break;
538
- case 'grok':
539
- syncToGrokConfig(configPath, mcpItems);
540
- break;
541
- case 'hermes':
542
- syncToHermesConfig(configPath, mcpItems);
543
- break;
544
- case 'forge':
545
- syncToCursorConfig(configPath, mcpItems);
546
- break;
211
+ if (configPath) {
212
+ const mcpItems = items.map((r) => r.item);
213
+ syncToAgentConfig(agent, configPath, mcpItems, 'overwrite');
214
+ }
215
+ // Sync project-layer MCPs to the agent's project-level config path so each
216
+ // agent CLI can discover them alongside its user-level config. Merge so we
217
+ // do not clobber entries added manually or by the agent's own CLI.
218
+ const projectAgentsDir = cwd ? getProjectAgentsDir(cwd) : null;
219
+ if (projectAgentsDir) {
220
+ const projectItems = items.filter((r) => r.layer === 'project').map((r) => r.item);
221
+ if (projectItems.length > 0) {
222
+ const projectConfigPath = getProjectMcpConfigPath(agent, path.dirname(projectAgentsDir));
223
+ syncToAgentConfig(agent, projectConfigPath, projectItems, 'merge');
224
+ }
547
225
  }
548
226
  },
549
227
  format(agent) {
@@ -134,12 +134,25 @@ export interface RunMeta {
134
134
  startedAt: string;
135
135
  completedAt: string | null;
136
136
  exitCode: number | null;
137
+ /** Machine-readable failure reason when the run did not complete successfully. */
138
+ errorMessage?: string;
139
+ /** Wall-clock duration of the run in milliseconds (set when the run finishes). */
140
+ duration?: number;
137
141
  /** Set for `host:`-placed runs — where the job body executes (no local pid). */
138
142
  host?: string;
139
143
  /** The host-task sidecar id backing a `host:` run; the daemon monitor
140
144
  * finalizes the run by reconciling it against the remote `.exit`. */
141
145
  hostTaskId?: string;
142
146
  }
147
+ /**
148
+ * Finalize a run record with a terminal status, computing `duration` from
149
+ * `startedAt` and the completion timestamp. Keeps failure-reason population
150
+ * centralized so every completion path writes the same machine-readable fields.
151
+ */
152
+ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status'], exitCode: number | null, opts?: {
153
+ errorMessage?: string;
154
+ completedAt?: string;
155
+ }): void;
143
156
  /**
144
157
  * True when the job may execute on this machine: no `devices` allowlist (or
145
158
  * empty), or the allowlist includes this device. Both sides go through
@@ -50,6 +50,27 @@ export function normalizeTriggerEvent(input) {
50
50
  };
51
51
  return aliases[key] ?? null;
52
52
  }
53
+ /**
54
+ * Finalize a run record with a terminal status, computing `duration` from
55
+ * `startedAt` and the completion timestamp. Keeps failure-reason population
56
+ * centralized so every completion path writes the same machine-readable fields.
57
+ */
58
+ export function finalizeRunMeta(meta, status, exitCode, opts) {
59
+ meta.status = status;
60
+ meta.exitCode = exitCode;
61
+ meta.completedAt = opts?.completedAt ?? new Date().toISOString();
62
+ const started = Date.parse(meta.startedAt);
63
+ const completed = Date.parse(meta.completedAt);
64
+ meta.duration = Number.isFinite(started) && Number.isFinite(completed) && completed >= started
65
+ ? completed - started
66
+ : 0;
67
+ if (opts?.errorMessage) {
68
+ meta.errorMessage = opts.errorMessage;
69
+ }
70
+ else {
71
+ delete meta.errorMessage;
72
+ }
73
+ }
53
74
  /**
54
75
  * True when the job may execute on this machine: no `devices` allowlist (or
55
76
  * empty), or the allowlist includes this device. Both sides go through
@@ -17,7 +17,7 @@ import { spawn, execFileSync } from 'child_process';
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
- import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, } from './routines.js';
20
+ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, } from './routines.js';
21
21
  import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
@@ -529,9 +529,8 @@ export async function executeJob(config, deps) {
529
529
  agent: effectiveAgent,
530
530
  version: primaryVersion,
531
531
  }, deps);
532
- meta.status = loopResult.stoppedBy === 'error' ? 'failed' : 'completed';
533
- meta.completedAt = new Date().toISOString();
534
- meta.exitCode = loopResult.stoppedBy === 'error' ? 1 : 0;
532
+ const loopFailed = loopResult.stoppedBy === 'error';
533
+ finalizeRunMeta(meta, loopFailed ? 'failed' : 'completed', loopFailed ? 1 : 0, loopFailed ? { errorMessage: `loop stopped: ${loopResult.stoppedBy}` } : undefined);
535
534
  writeRunMeta(meta);
536
535
  timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
537
536
  return { meta, reportPath: null };
@@ -574,17 +573,14 @@ export async function executeJob(config, deps) {
574
573
  meta.pid = attempt.pid;
575
574
  writeRunMeta(meta);
576
575
  if (attempt.status === 'timeout') {
577
- meta.status = 'timeout';
578
- meta.completedAt = new Date().toISOString();
576
+ finalizeRunMeta(meta, 'timeout', null, { errorMessage: 'run timed out' });
579
577
  writeRunMeta(meta);
580
578
  timer.end({ status: 'timeout', runId });
581
579
  const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
582
580
  return { meta, reportPath };
583
581
  }
584
582
  if (attempt.status === 'completed') {
585
- meta.exitCode = 0;
586
- meta.status = 'completed';
587
- meta.completedAt = new Date().toISOString();
583
+ finalizeRunMeta(meta, 'completed', 0);
588
584
  writeRunMeta(meta);
589
585
  timer.end({ status: 'completed', exitCode: 0, runId });
590
586
  const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
@@ -603,9 +599,7 @@ export async function executeJob(config, deps) {
603
599
  if (attempt.error) {
604
600
  process.stderr.write(`[agents] routine ${config.name}: spawn failed for ${label}: ${attempt.error}\n`);
605
601
  }
606
- meta.exitCode = attempt.exitCode ?? 1;
607
- meta.status = 'failed';
608
- meta.completedAt = new Date().toISOString();
602
+ finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, attempt.error ? { errorMessage: attempt.error } : undefined);
609
603
  writeRunMeta(meta);
610
604
  timer.end({
611
605
  status: 'failed',
@@ -617,9 +611,7 @@ export async function executeJob(config, deps) {
617
611
  return { meta, reportPath };
618
612
  }
619
613
  // Unreachable: chain is always non-empty, but keep a safe fallback.
620
- meta.status = 'failed';
621
- meta.exitCode = 1;
622
- meta.completedAt = new Date().toISOString();
614
+ finalizeRunMeta(meta, 'failed', 1);
623
615
  writeRunMeta(meta);
624
616
  timer.end({ status: 'failed', exitCode: 1, runId });
625
617
  return { meta, reportPath: null };
@@ -676,9 +668,7 @@ async function executeJobOnHost(config, opts) {
676
668
  // Sync path: a real exit code finalizes now. -1 (follow window closed) and
677
669
  // the detached path leave the meta `running` for the monitor to reconcile.
678
670
  if (!opts.detached && exitCode !== null && exitCode !== undefined && exitCode !== -1) {
679
- meta.status = exitCode === 0 ? 'completed' : 'failed';
680
- meta.exitCode = exitCode;
681
- meta.completedAt = new Date().toISOString();
671
+ finalizeRunMeta(meta, exitCode === 0 ? 'completed' : 'failed', exitCode);
682
672
  }
683
673
  writeRunMeta(meta);
684
674
  timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
@@ -755,9 +745,7 @@ async function executeCommandJobForeground(config) {
755
745
  finish({ exitCode: 1, status: 'failed', error: err.message });
756
746
  });
757
747
  });
758
- meta.status = result.status;
759
- meta.exitCode = result.exitCode ?? (result.status === 'completed' ? 0 : 1);
760
- meta.completedAt = new Date().toISOString();
748
+ finalizeRunMeta(meta, result.status, result.exitCode ?? (result.status === 'completed' ? 0 : 1), result.error ? { errorMessage: result.error } : undefined);
761
749
  writeRunMeta(meta);
762
750
  if (result.error) {
763
751
  process.stderr.write(`[agents] routine ${config.name}: command spawn failed: ${result.error}\n`);
@@ -847,9 +835,7 @@ export async function executeJobDetached(config) {
847
835
  fs.closeSync(stdoutFd);
848
836
  }
849
837
  catch { /* fd already closed */ }
850
- meta.status = 'failed';
851
- meta.exitCode = 1;
852
- meta.completedAt = new Date().toISOString();
838
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: err.message });
853
839
  writeRunMeta(meta);
854
840
  process.stderr.write(`[agents] daemon: spawn failed for job "${config.name}": ${err.message}\n`);
855
841
  });
@@ -907,18 +893,16 @@ function executeCommandJobDetached(config) {
907
893
  // fire-and-forget call, so the exit event fires here. (monitorRunningJobs no
908
894
  // longer force-fails command jobs; it reads exit-code only on the restart edge.)
909
895
  let settled = false;
910
- const settle = (status, exitCode) => {
896
+ const settle = (status, exitCode, errorMessage) => {
911
897
  if (settled)
912
898
  return;
913
899
  settled = true;
914
- meta.status = status;
915
- meta.exitCode = exitCode;
916
- meta.completedAt = new Date().toISOString();
900
+ finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
917
901
  writeRunMeta(meta);
918
902
  };
919
903
  child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
920
904
  child.on('error', (err) => {
921
- settle('failed', 1);
905
+ settle('failed', 1, err.message);
922
906
  process.stderr.write(`[agents] daemon: command spawn failed for job "${config.name}": ${err.message}\n`);
923
907
  });
924
908
  child.unref();
@@ -1072,9 +1056,7 @@ function finalizeHostRun(meta) {
1072
1056
  const healed = reconcileHostTask(task);
1073
1057
  if (healed.status !== 'completed' && healed.status !== 'failed')
1074
1058
  return;
1075
- meta.status = healed.status;
1076
- meta.exitCode = healed.exitCode ?? (healed.status === 'completed' ? 0 : 1);
1077
- meta.completedAt = healed.finishedAt ?? new Date().toISOString();
1059
+ finalizeRunMeta(meta, healed.status, healed.exitCode ?? (healed.status === 'completed' ? 0 : 1), { completedAt: healed.finishedAt ?? undefined });
1078
1060
  writeRunMeta(meta);
1079
1061
  }
1080
1062
  catch { /* unreachable host or unreadable sidecar — retry next sweep */ }
@@ -1114,8 +1096,7 @@ export function monitorRunningJobs() {
1114
1096
  const isCommandRun = Boolean(meta.command) || !meta.agent;
1115
1097
  const wallClockMs = Date.now() - Date.parse(meta.startedAt);
1116
1098
  if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
1117
- meta.status = 'timeout';
1118
- meta.completedAt = new Date().toISOString();
1099
+ finalizeRunMeta(meta, 'timeout', null, { errorMessage: 'exceeded max wall clock' });
1119
1100
  writeRunMeta(meta);
1120
1101
  if (!isCommandRun)
1121
1102
  extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
@@ -1129,20 +1110,17 @@ export function monitorRunningJobs() {
1129
1110
  // missed the exit event — recover the true code from the exit-code file
1130
1111
  // the child wrote; its absence means the child was killed/crashed.
1131
1112
  const ec = readCommandExitCode(runDirPath);
1132
- meta.status = ec === 0 ? 'completed' : 'failed';
1133
- meta.exitCode = ec;
1113
+ finalizeRunMeta(meta, ec === 0 ? 'completed' : 'failed', ec);
1134
1114
  }
1135
1115
  else {
1136
1116
  const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
1137
1117
  if (inferred) {
1138
- meta.status = inferred.status;
1139
- meta.exitCode = inferred.exitCode;
1118
+ finalizeRunMeta(meta, inferred.status, inferred.exitCode);
1140
1119
  }
1141
1120
  else {
1142
- meta.status = 'failed';
1121
+ finalizeRunMeta(meta, 'failed', null, { errorMessage: 'process exited before final status could be inferred' });
1143
1122
  }
1144
1123
  }
1145
- meta.completedAt = new Date().toISOString();
1146
1124
  writeRunMeta(meta);
1147
1125
  if (!isCommandRun)
1148
1126
  extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
@@ -33,6 +33,7 @@ import { getHelpersDir, readMeta } from '../state.js';
33
33
  import { isAlive } from '../platform/process.js';
34
34
  import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
+ import { getCliLaunch } from '../cli-entry.js';
36
37
  /** Bumped when the wire protocol changes; a client that pings a mismatched
37
38
  * server kills and respawns it rather than talking a stale dialect. */
38
39
  const PROTOCOL_VERSION = 1;
@@ -148,23 +149,18 @@ function writeAgentToken() {
148
149
  }
149
150
  /**
150
151
  * Argv for re-invoking THIS cli with a hidden subcommand, so a side-by-side dev
151
- * build spawns its own helpers rather than the registry-installed one. We always
152
- * go through `process.execPath` (the node binary) with the JS entrypoint as the
153
- * first arg the entrypoint isn't reliably executable in dev builds (invoked as
154
- * `node dist/index.js`, no +x), so spawning it directly EACCES'd.
152
+ * build spawns its own helpers rather than the registry-installed one. Routed
153
+ * through the shared getCliLaunch so it handles both install shapes — a JS entry
154
+ * (`node dist/index.js …`) and a Bun standalone binary (run directly). The old
155
+ * hand-rolled `[process.execPath, process.argv[1], …]` broke on standalone builds:
156
+ * process.argv[1] is the bun virtual entry `/$bunfs/root/agents` (fs.existsSync
157
+ * reports it as present), so it was passed as an argv element and the broker died
158
+ * with `unknown command '/$bunfs/root/agents'` — the daemon-hosted broker never
159
+ * bound its socket and every unlock reported "Could not start the secrets broker".
155
160
  */
156
161
  function cliSpawn(sub) {
157
- const argv1 = process.argv[1];
158
- const entry = argv1 && fs.existsSync(argv1) ? argv1 : null;
159
- if (entry)
160
- return { cmd: process.execPath, args: [entry, ...sub] };
161
- // No resolvable entrypoint (unusual) — fall back to the PATH shim.
162
- let bin = 'agents';
163
- try {
164
- bin = execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
165
- }
166
- catch { /* default */ }
167
- return { cmd: bin, args: sub };
162
+ const { command, args } = getCliLaunch(sub);
163
+ return { cmd: command, args };
168
164
  }
169
165
  function brokerSpawn() {
170
166
  return cliSpawn(['secrets', '_agent-run']);