@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
@@ -113,6 +113,16 @@ export declare function readFleetConfigDefaults(): Record<string, unknown>;
113
113
  export declare function readDeviceConfigValues(device: string): Record<string, unknown>;
114
114
  /** Get one config key's effective value and the layer that set it. */
115
115
  export declare function getConfigValue(name: string, opts?: ConfigTarget): ConfigEntry;
116
+ /**
117
+ * Async, non-blocking twin of {@link getConfigValue} for the daemon's tick paths
118
+ * (PHNX-3695) — e.g. the watchdog tick reading `watchdog.enabled` every ~3min.
119
+ * Same layer precedence (device doc → fleet defaults → default), but the
120
+ * per-device doc READ is async. The user/fleet layers go through `readMeta`,
121
+ * which serves from an mtime-stamped in-memory cache (≈ 2 stat syscalls on the
122
+ * hot path, no file read), and `ensureDeviceConfigMigrated` is a one-shot no-op
123
+ * after the first process-wide fold — neither blocks the loop meaningfully.
124
+ */
125
+ export declare function getConfigValueAsync(name: string, opts?: ConfigTarget): Promise<ConfigEntry>;
116
126
  /**
117
127
  * List every known key with its effective value and the layer that set it.
118
128
  *
@@ -250,7 +260,8 @@ export declare function assertDaemonEnabled(): void;
250
260
  * minutes. Read by the daemon's periodic tick and, as the fallback when a
251
261
  * caller omits `--idle-minutes`, by the `gc` IPC action.
252
262
  */
253
- export declare function resolveBrowserTaskIdleMs(): number | null;
263
+ /** Async so the daemon's browser-task-reap tick reads the config off the shared event loop (PHNX-3695). */
264
+ export declare function resolveBrowserTaskIdleMs(): Promise<number | null>;
254
265
  /**
255
266
  * Read the effective `agents.max-concurrent` cap for each named device (fleet
256
267
  * defaults layered under the per-device doc; no SSH). Devices without a cap
@@ -31,6 +31,7 @@
31
31
  * Unset always means today's behavior (the documented default).
32
32
  */
33
33
  import * as fs from 'fs';
34
+ import * as fsp from 'fs/promises';
34
35
  import * as path from 'path';
35
36
  import * as yaml from 'yaml';
36
37
  import { META_HEADER, getUserAgentsDir, readMeta, updateMeta, withMetaLock } from './state.js';
@@ -372,17 +373,8 @@ function deviceDocPath(device) {
372
373
  * file is a hard error — silently returning null would let the next write wipe
373
374
  * the device's routines/config (same contract as routine-activation's reader).
374
375
  */
375
- function readDeviceDoc(device) {
376
- const p = deviceDocPath(device);
377
- let raw;
378
- try {
379
- raw = fs.readFileSync(p, 'utf-8');
380
- }
381
- catch (err) {
382
- if (err && err.code === 'ENOENT')
383
- return null;
384
- throw err;
385
- }
376
+ /** Parse + validate a device doc's raw YAML. Shared by the sync and async readers. */
377
+ function parseDeviceDoc(raw, p) {
386
378
  const corrupted = (detail) => new Error(`Device config corrupted at ${p}: ${detail}. Inspect and restore from backup.`);
387
379
  let parsed;
388
380
  try {
@@ -402,6 +394,33 @@ function readDeviceDoc(device) {
402
394
  }
403
395
  return doc;
404
396
  }
397
+ function readDeviceDoc(device) {
398
+ const p = deviceDocPath(device);
399
+ let raw;
400
+ try {
401
+ raw = fs.readFileSync(p, 'utf-8');
402
+ }
403
+ catch (err) {
404
+ if (err && err.code === 'ENOENT')
405
+ return null;
406
+ throw err;
407
+ }
408
+ return parseDeviceDoc(raw, p);
409
+ }
410
+ /** Async twin of {@link readDeviceDoc} for the daemon's tick paths (PHNX-3695) — the device-doc read must not block the shared event loop. */
411
+ async function readDeviceDocAsync(device) {
412
+ const p = deviceDocPath(device);
413
+ let raw;
414
+ try {
415
+ raw = await fsp.readFile(p, 'utf-8');
416
+ }
417
+ catch (err) {
418
+ if (err && err.code === 'ENOENT')
419
+ return null;
420
+ throw err;
421
+ }
422
+ return parseDeviceDoc(raw, p);
423
+ }
405
424
  /** Write a device doc (atomic), preserving keys this module does not own
406
425
  * (`routines:`). A doc left empty is removed instead of leaving an empty
407
426
  * tracked file behind. */
@@ -476,6 +495,36 @@ export function getConfigValue(name, opts) {
476
495
  return { spec, value: fleetConfig[spec.yamlKey], source: 'fleet' };
477
496
  return { spec, value: undefined, source: 'default' };
478
497
  }
498
+ /**
499
+ * Async, non-blocking twin of {@link getConfigValue} for the daemon's tick paths
500
+ * (PHNX-3695) — e.g. the watchdog tick reading `watchdog.enabled` every ~3min.
501
+ * Same layer precedence (device doc → fleet defaults → default), but the
502
+ * per-device doc READ is async. The user/fleet layers go through `readMeta`,
503
+ * which serves from an mtime-stamped in-memory cache (≈ 2 stat syscalls on the
504
+ * hot path, no file read), and `ensureDeviceConfigMigrated` is a one-shot no-op
505
+ * after the first process-wide fold — neither blocks the loop meaningfully.
506
+ */
507
+ export async function getConfigValueAsync(name, opts) {
508
+ ensureDeviceConfigMigrated();
509
+ const spec = configKeySpec(name);
510
+ if (spec.scope === 'user') {
511
+ const value = readMeta().config?.[spec.yamlKey];
512
+ return { spec, value, source: value !== undefined ? 'user' : 'default' };
513
+ }
514
+ if (opts?.fleet) {
515
+ const value = readFleetConfigDefaults()[spec.yamlKey];
516
+ return { spec, value, source: value !== undefined ? 'fleet' : 'default' };
517
+ }
518
+ const device = targetDevice(opts);
519
+ assertLocalTarget(spec, device);
520
+ const docConfig = (await readDeviceDocAsync(device))?.config ?? {};
521
+ if (spec.yamlKey in docConfig)
522
+ return { spec, value: docConfig[spec.yamlKey], source: 'device' };
523
+ const fleetConfig = readFleetConfigDefaults();
524
+ if (spec.yamlKey in fleetConfig)
525
+ return { spec, value: fleetConfig[spec.yamlKey], source: 'fleet' };
526
+ return { spec, value: undefined, source: 'default' };
527
+ }
479
528
  /**
480
529
  * List every known key with its effective value and the layer that set it.
481
530
  *
@@ -844,8 +893,9 @@ export function assertDaemonEnabled() {
844
893
  * minutes. Read by the daemon's periodic tick and, as the fallback when a
845
894
  * caller omits `--idle-minutes`, by the `gc` IPC action.
846
895
  */
847
- export function resolveBrowserTaskIdleMs() {
848
- const minutes = getConfigValue('browser.task-idle-minutes').value ?? 30;
896
+ /** Async so the daemon's browser-task-reap tick reads the config off the shared event loop (PHNX-3695). */
897
+ export async function resolveBrowserTaskIdleMs() {
898
+ const minutes = (await getConfigValueAsync('browser.task-idle-minutes')).value ?? 30;
849
899
  return minutes === 0 ? null : minutes * 60_000;
850
900
  }
851
901
  /**
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Async, deadline-bounded, process-group-killable subprocess exec (PHNX-3695).
3
+ *
4
+ * The daemon runs every background service on ONE Node event loop. A
5
+ * synchronous `execFileSync`/`spawnSync` on a service tick blocks that loop for
6
+ * the whole life of the child — and while it is blocked NOTHING else on the loop
7
+ * runs, including the supervisor's per-tick deadline timer and the browser IPC
8
+ * server's socket handlers. That is the wedge PHNX-3411 fixed the *symptom* of:
9
+ * the browser `version` probe (a trivial synchronous handler) "accepts but never
10
+ * replies" because the loop that would reply is frozen inside a sync spawn.
11
+ *
12
+ * `execFileBounded` is the non-blocking replacement for those tick-path spawns.
13
+ * It spawns with async `child_process.spawn`, so the event loop keeps serving
14
+ * other work while the child runs, and it bounds the child two ways:
15
+ *
16
+ * - a `timeoutMs` deadline: on expiry it SIGTERMs the child, then SIGKILLs it
17
+ * after {@link KILL_GRACE_MS} if it ignored the term — the same escalation
18
+ * `sshExecAsync` uses (ssh-exec.ts).
19
+ * - a process GROUP kill: the child is spawned as its own group leader
20
+ * (`detached` on POSIX) so the signal reaches the whole subtree, not just the
21
+ * direct child. A bare `child.kill()` would leave grandchildren running — the
22
+ * orphaned-`ps`/`powershell` leak class. Windows uses `taskkill /T`.
23
+ *
24
+ * It never throws for a non-zero exit, a signal, or a spawn error: every outcome
25
+ * is reported in the returned {@link BoundedExecResult} so a caller on a tick
26
+ * path can branch on it instead of wrapping every call in try/catch.
27
+ */
28
+ /** Grace between SIGTERM and SIGKILL for a child that overran its deadline. */
29
+ export declare const KILL_GRACE_MS = 250;
30
+ export interface ExecFileBoundedOptions {
31
+ /** Hard wall-clock cap. On expiry the child's process group is SIGTERMed, then SIGKILLed after {@link KILL_GRACE_MS}. Required — an unbounded tick-path spawn is the bug this helper exists to prevent. */
32
+ timeoutMs: number;
33
+ /** Working directory for the child. */
34
+ cwd?: string;
35
+ /** Environment for the child (defaults to the current process env). */
36
+ env?: NodeJS.ProcessEnv;
37
+ /** Optional stdin to write to the child. */
38
+ input?: string;
39
+ }
40
+ export interface BoundedExecResult {
41
+ stdout: string;
42
+ stderr: string;
43
+ /** Exit code, or null when the process was killed by a signal (including our timeout kill) or never spawned. */
44
+ code: number | null;
45
+ /** True when the deadline elapsed and we killed the child, distinguishing a timeout from an ordinary non-zero exit. */
46
+ timedOut: boolean;
47
+ }
48
+ /**
49
+ * Run `file args` with a hard deadline, killing the whole process group on
50
+ * timeout. Resolves (never rejects) with the captured output and outcome.
51
+ */
52
+ export declare function execFileBounded(file: string, args: string[], opts: ExecFileBoundedOptions): Promise<BoundedExecResult>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Async, deadline-bounded, process-group-killable subprocess exec (PHNX-3695).
3
+ *
4
+ * The daemon runs every background service on ONE Node event loop. A
5
+ * synchronous `execFileSync`/`spawnSync` on a service tick blocks that loop for
6
+ * the whole life of the child — and while it is blocked NOTHING else on the loop
7
+ * runs, including the supervisor's per-tick deadline timer and the browser IPC
8
+ * server's socket handlers. That is the wedge PHNX-3411 fixed the *symptom* of:
9
+ * the browser `version` probe (a trivial synchronous handler) "accepts but never
10
+ * replies" because the loop that would reply is frozen inside a sync spawn.
11
+ *
12
+ * `execFileBounded` is the non-blocking replacement for those tick-path spawns.
13
+ * It spawns with async `child_process.spawn`, so the event loop keeps serving
14
+ * other work while the child runs, and it bounds the child two ways:
15
+ *
16
+ * - a `timeoutMs` deadline: on expiry it SIGTERMs the child, then SIGKILLs it
17
+ * after {@link KILL_GRACE_MS} if it ignored the term — the same escalation
18
+ * `sshExecAsync` uses (ssh-exec.ts).
19
+ * - a process GROUP kill: the child is spawned as its own group leader
20
+ * (`detached` on POSIX) so the signal reaches the whole subtree, not just the
21
+ * direct child. A bare `child.kill()` would leave grandchildren running — the
22
+ * orphaned-`ps`/`powershell` leak class. Windows uses `taskkill /T`.
23
+ *
24
+ * It never throws for a non-zero exit, a signal, or a spawn error: every outcome
25
+ * is reported in the returned {@link BoundedExecResult} so a caller on a tick
26
+ * path can branch on it instead of wrapping every call in try/catch.
27
+ */
28
+ import { spawn } from 'child_process';
29
+ /** Grace between SIGTERM and SIGKILL for a child that overran its deadline. */
30
+ export const KILL_GRACE_MS = 250;
31
+ /**
32
+ * Run `file args` with a hard deadline, killing the whole process group on
33
+ * timeout. Resolves (never rejects) with the captured output and outcome.
34
+ */
35
+ export function execFileBounded(file, args, opts) {
36
+ const isWin = process.platform === 'win32';
37
+ return new Promise((resolve) => {
38
+ const child = spawn(file, args, {
39
+ cwd: opts.cwd,
40
+ env: opts.env ?? process.env,
41
+ // POSIX: lead a new process group so a timeout kill(-pid) reaches the
42
+ // whole subtree, not just the direct child. Windows has no process
43
+ // groups here — taskkill /T handles the tree instead.
44
+ detached: !isWin,
45
+ stdio: ['pipe', 'pipe', 'pipe'],
46
+ windowsHide: true,
47
+ });
48
+ let stdout = '';
49
+ let stderr = '';
50
+ let settled = false;
51
+ let timedOut = false;
52
+ let killTimer = null;
53
+ // Signal the whole group on POSIX (negative pid); on Windows tear the tree
54
+ // down with taskkill. Best-effort — an already-dead child is fine.
55
+ const killGroup = (signal) => {
56
+ if (child.pid === undefined)
57
+ return;
58
+ try {
59
+ if (isWin) {
60
+ // taskkill is itself a child; spawn it detached and forget it.
61
+ spawn('taskkill', ['/F', '/T', '/PID', String(child.pid)], { stdio: 'ignore', windowsHide: true }).on('error', () => { });
62
+ }
63
+ else {
64
+ process.kill(-child.pid, signal);
65
+ }
66
+ }
67
+ catch { /* group already gone */ }
68
+ };
69
+ const timer = setTimeout(() => {
70
+ timedOut = true;
71
+ killGroup('SIGTERM');
72
+ // SIGTERM is advisory; a wedged child can ignore it and keep the Promise
73
+ // (and the caller's tick) pending forever. Enforce a hard SIGKILL bound.
74
+ killTimer = setTimeout(() => {
75
+ if (!settled)
76
+ killGroup('SIGKILL');
77
+ }, KILL_GRACE_MS);
78
+ killTimer.unref?.();
79
+ }, opts.timeoutMs);
80
+ timer.unref?.();
81
+ const clearTimers = () => {
82
+ clearTimeout(timer);
83
+ if (killTimer)
84
+ clearTimeout(killTimer);
85
+ };
86
+ child.stdout?.setEncoding('utf-8');
87
+ child.stderr?.setEncoding('utf-8');
88
+ child.stdout?.on('data', (chunk) => { stdout += chunk; });
89
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
90
+ // A child that closes stdin early makes end()/write emit EPIPE; with no
91
+ // listener Node escalates it to an uncaught exception. Swallow it — the real
92
+ // outcome is still reported by 'close'/'error' below.
93
+ child.stdin?.on('error', () => { });
94
+ if (opts.input !== undefined)
95
+ child.stdin?.end(opts.input);
96
+ else
97
+ child.stdin?.end();
98
+ child.on('error', (err) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ clearTimers();
103
+ resolve({ code: null, stdout, stderr: stderr + err.message, timedOut });
104
+ });
105
+ child.on('close', (code) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ clearTimers();
110
+ resolve({ code, stdout, stderr, timedOut });
111
+ });
112
+ });
113
+ }
@@ -44,7 +44,7 @@ export declare function headlessPlanStallCommand(args: {
44
44
  * (every agent supports edit-like behavior as its default).
45
45
  * - `plan` on an agent without a read-only mode degrades to the agent's
46
46
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
47
- * like antigravity/kiro have no plan flag; hard-failing made
47
+ * like antigravity have no plan flag; hard-failing made
48
48
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
49
49
  * from `agents teams add`, which already defaults to `edit`. Callers that
50
50
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -64,7 +64,7 @@ export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
64
64
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
65
65
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
66
66
  * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
67
- * and kiro get for having no plan flag at all. Interactive runs are never
67
+ * get for having no plan flag at all. Interactive runs are never
68
68
  * downgraded. This is the single source of truth shared by buildExecCommand
69
69
  * (agents run / teams) and the routine runner.
70
70
  */
package/dist/lib/exec.js CHANGED
@@ -96,7 +96,7 @@ export function headlessPlanStallCommand(args) {
96
96
  * (every agent supports edit-like behavior as its default).
97
97
  * - `plan` on an agent without a read-only mode degrades to the agent's
98
98
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
99
- * like antigravity/kiro have no plan flag; hard-failing made
99
+ * like antigravity have no plan flag; hard-failing made
100
100
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
101
101
  * from `agents teams add`, which already defaults to `edit`. Callers that
102
102
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -116,7 +116,7 @@ export function resolveMode(agent, requested) {
116
116
  }
117
117
  if (requested === 'plan') {
118
118
  // No read-only mode on this agent. modes[0] is the declared safest mode
119
- // (edit for antigravity/kiro/…). Prefer that over hard-fail so
119
+ // (edit for antigravity/…). Prefer that over hard-fail so
120
120
  // uniform multi-agent `--mode plan` dispatches still run.
121
121
  return supported[0];
122
122
  }
@@ -132,7 +132,7 @@ export function resolveMode(agent, requested) {
132
132
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
133
133
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
134
134
  * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
135
- * and kiro get for having no plan flag at all. Interactive runs are never
135
+ * get for having no plan flag at all. Interactive runs are never
136
136
  * downgraded. This is the single source of truth shared by buildExecCommand
137
137
  * (agents run / teams) and the routine runner.
138
138
  */
@@ -522,25 +522,6 @@ export const AGENT_COMMANDS = {
522
522
  jsonFlags: ['--format', 'json'],
523
523
  modelFlag: '--model',
524
524
  },
525
- // Oh My Pi (`omp`). Headless is the positional MESSAGES arg + `-p/--print`.
526
- // Approval modes map to omp's `--approval-mode`: always-ask (read-only tools
527
- // auto-approved, writes gated -> our `plan`), write (read + workspace writes
528
- // auto-approved -> `edit`), yolo (all tiers auto-approved -> `skip`). JSON is
529
- // omp's `--mode json` event stream. `--model` fuzzy-matches a provider/model
530
- // selector. Native resume is `-r/--resume <id-prefix>`.
531
- pi: {
532
- base: ['omp'],
533
- promptFlag: 'positional',
534
- modeFlags: {
535
- plan: ['--approval-mode', 'always-ask'],
536
- edit: ['--approval-mode', 'write'],
537
- skip: ['--approval-mode', 'yolo'],
538
- },
539
- jsonFlags: ['--mode', 'json'],
540
- modelFlag: '--model',
541
- printFlags: ['-p'],
542
- resume: { flag: '--resume' },
543
- },
544
525
  openclaw: {
545
526
  base: ['openclaw'],
546
527
  promptFlag: 'positional',
@@ -584,18 +565,6 @@ export const AGENT_COMMANDS = {
584
565
  },
585
566
  modelFlag: '--model',
586
567
  },
587
- kiro: {
588
- // Standalone hooks live under ~/.kiro/hooks/*.json and only fire on the
589
- // v3 engine (opt-in via --v3; see https://kiro.dev/docs/cli/v3/hooks/).
590
- // Without this flag agents-cli would write v3 hook files that never run.
591
- base: ['kiro-cli', '--v3'],
592
- promptFlag: 'positional',
593
- modeFlags: {
594
- // kiro-cli has no permission flags — edit is the default behavior.
595
- edit: [],
596
- },
597
- modelFlag: '--model',
598
- },
599
568
  goose: {
600
569
  base: ['goose', 'run'],
601
570
  promptFlag: 'positional',
@@ -110,21 +110,21 @@ export interface CallerIdentity {
110
110
  }
111
111
  /** Identify the environment that invoked agents-cli, not the source callsite. */
112
112
  export declare function detectCaller(env?: NodeJS.ProcessEnv, stdoutIsTTY?: boolean): CallerIdentity;
113
- /**
114
- * Emit a structured event to the append-only audit log.
115
- *
116
- * @param event - The event type
117
- * @param payload - Event-specific data (agent, version, cwd, etc.)
118
- * @param overrides - Envelope fields the CALLER owns rather than the writer.
119
- * Only `ts` today: a batched out-of-process producer (`agents events emit`)
120
- * records when each event HAPPENED, but flushes them together later, so
121
- * stamping write-time would collapse a whole batch onto the flush instant and
122
- * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
123
- * *payload* still cannot inject it — this explicit channel is the only way in.
124
- */
125
113
  export declare function emit(event: EventType, payload?: EventPayload, overrides?: {
126
114
  ts?: string;
127
115
  }): void;
116
+ /**
117
+ * Async, non-blocking counterpart of {@link emit} for callers on the daemon's
118
+ * shared event loop (PHNX-3695). `emit` acquires the event-log lock with
119
+ * `withFileLock` → `sleepSync` (`Atomics.wait`), which HALTS the loop for up to
120
+ * 30s under contention (a peer appending to the same log). `emitAsync` acquires
121
+ * it with `withFileLockAsync`, so the loop keeps turning. The under-lock body is
122
+ * identical (a µs-scale append + a rare rotate). Best-effort like `emit`: never
123
+ * throws; a caller on a tick fires it and moves on.
124
+ */
125
+ export declare function emitAsync(event: EventType, payload?: EventPayload, overrides?: {
126
+ ts?: string;
127
+ }): Promise<void>;
128
128
  /**
129
129
  * Convenience wrapper for timed operations.
130
130
  * Returns a function to call when the operation completes.
@@ -135,14 +135,21 @@ export declare function emit(event: EventType, payload?: EventPayload, overrides
135
135
  * done({ exitCode: 0 }); // emits agent.run.end with durationMs
136
136
  */
137
137
  export declare function emitStart(startEvent: EventType, payload?: EventPayload): (endPayload?: EventPayload) => void;
138
- export declare function emitRoutineEnd(meta: {
138
+ interface RoutineEndMeta {
139
139
  jobName: string;
140
140
  runId?: string;
141
141
  status: string;
142
142
  duration?: number;
143
143
  exitCode?: number | null;
144
144
  detail?: string;
145
- }): void;
145
+ }
146
+ export declare function emitRoutineEnd(meta: RoutineEndMeta): void;
147
+ /**
148
+ * Async, non-blocking `routine.end` for the daemon heartbeat tick (PHNX-3695):
149
+ * the reaper (`reapExitedRunningJobs`) runs on the shared event loop, so it emits
150
+ * through {@link emitAsync} rather than the `sleepSync`-locked {@link emit}.
151
+ */
152
+ export declare function emitRoutineEndAsync(meta: RoutineEndMeta): Promise<void>;
146
153
  /**
147
154
  * Measure execution time of a synchronous function.
148
155
  * Emits a perf.timing event with the duration.
@@ -272,3 +279,4 @@ export declare function stats(options?: {
272
279
  }): EventStats;
273
280
  export declare function getLogsPath(): string;
274
281
  export declare function _resetForTest(overrideEventsPath?: string, overrideUserAgentsDir?: string): void;
282
+ export {};
@@ -16,7 +16,7 @@ import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { gzipSync, gunzipSync } from 'node:zlib';
19
- import { ensureLockTarget, withFileLock } from '../fs-atomic.js';
19
+ import { ensureLockTarget, withFileLock, withFileLockAsync } from '../fs-atomic.js';
20
20
  import { getUserAgentsDir } from '../state.js';
21
21
  import { stampProvenance, resetEventProvenanceForTest } from '../event-provenance.js';
22
22
  import { recordSample } from '../perf/spool.js';
@@ -493,48 +493,77 @@ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.st
493
493
  * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
494
494
  * *payload* still cannot inject it — this explicit channel is the only way in.
495
495
  */
496
- export function emit(event, payload = {}, overrides = {}) {
496
+ /** Build the JSONL line + target path for an event, or null when logging is disabled. Shared by {@link emit} and {@link emitAsync}. */
497
+ function prepareEventWrite(event, payload, overrides) {
497
498
  if (isDisabled())
498
- return;
499
+ return null;
500
+ ensureLogsDir();
501
+ const caller = detectCaller();
502
+ const safePayload = sanitizePayload(payload);
503
+ const record = {
504
+ // Provenance floor first: env-sourced defaults an explicit payload overrides.
505
+ ...stampProvenance(),
506
+ ...safePayload,
507
+ ts: overrides.ts ?? new Date().toISOString(),
508
+ tz: getTimezoneOffset(),
509
+ tzName: getTimezoneName(),
510
+ hostname: os.hostname(),
511
+ platform: os.platform(),
512
+ arch: os.arch(),
513
+ pid: process.pid,
514
+ ppid: process.ppid,
515
+ event,
516
+ level: levelFor(event),
517
+ caller: caller.kind,
518
+ ...(caller.session ? { session: caller.session } : {}),
519
+ };
520
+ const line = JSON.stringify(record) + '\n';
521
+ const logPath = eventsPath();
522
+ const isNew = !fs.existsSync(logPath);
523
+ ensureLockTarget(logPath, '', DIR_MODE);
524
+ return { logPath, line, isNew };
525
+ }
526
+ /** The under-lock append + chmod + rotate/prune. Tiny local writes (µs), safe inside either the sync or async lock. */
527
+ function appendEventLocked(logPath, line, isNew) {
528
+ fs.appendFileSync(logPath, line, { mode: FILE_MODE });
529
+ if (isNew || logPath !== _chmoddedPath) {
530
+ _chmoddedPath = logPath;
531
+ try {
532
+ fs.chmodSync(logPath, FILE_MODE);
533
+ }
534
+ catch {
535
+ // May fail if not owner
536
+ }
537
+ }
538
+ const rotated = maybeGzipRotateLocked(logPath);
539
+ maybePruneLocked(rotated);
540
+ }
541
+ export function emit(event, payload = {}, overrides = {}) {
499
542
  try {
500
- ensureLogsDir();
501
- const caller = detectCaller();
502
- const safePayload = sanitizePayload(payload);
503
- const record = {
504
- // Provenance floor first: env-sourced defaults an explicit payload overrides.
505
- ...stampProvenance(),
506
- ...safePayload,
507
- ts: overrides.ts ?? new Date().toISOString(),
508
- tz: getTimezoneOffset(),
509
- tzName: getTimezoneName(),
510
- hostname: os.hostname(),
511
- platform: os.platform(),
512
- arch: os.arch(),
513
- pid: process.pid,
514
- ppid: process.ppid,
515
- event,
516
- level: levelFor(event),
517
- caller: caller.kind,
518
- ...(caller.session ? { session: caller.session } : {}),
519
- };
520
- const line = JSON.stringify(record) + '\n';
521
- const logPath = eventsPath();
522
- const isNew = !fs.existsSync(logPath);
523
- ensureLockTarget(logPath, '', DIR_MODE);
524
- withFileLock(logPath, () => {
525
- fs.appendFileSync(logPath, line, { mode: FILE_MODE });
526
- if (isNew || logPath !== _chmoddedPath) {
527
- _chmoddedPath = logPath;
528
- try {
529
- fs.chmodSync(logPath, FILE_MODE);
530
- }
531
- catch {
532
- // May fail if not owner
533
- }
534
- }
535
- const rotated = maybeGzipRotateLocked(logPath);
536
- maybePruneLocked(rotated);
537
- });
543
+ const prepared = prepareEventWrite(event, payload, overrides);
544
+ if (!prepared)
545
+ return;
546
+ withFileLock(prepared.logPath, () => appendEventLocked(prepared.logPath, prepared.line, prepared.isNew));
547
+ }
548
+ catch {
549
+ // Silent failure - logging should never break the CLI
550
+ }
551
+ }
552
+ /**
553
+ * Async, non-blocking counterpart of {@link emit} for callers on the daemon's
554
+ * shared event loop (PHNX-3695). `emit` acquires the event-log lock with
555
+ * `withFileLock` → `sleepSync` (`Atomics.wait`), which HALTS the loop for up to
556
+ * 30s under contention (a peer appending to the same log). `emitAsync` acquires
557
+ * it with `withFileLockAsync`, so the loop keeps turning. The under-lock body is
558
+ * identical (a µs-scale append + a rare rotate). Best-effort like `emit`: never
559
+ * throws; a caller on a tick fires it and moves on.
560
+ */
561
+ export async function emitAsync(event, payload = {}, overrides = {}) {
562
+ try {
563
+ const prepared = prepareEventWrite(event, payload, overrides);
564
+ if (!prepared)
565
+ return;
566
+ await withFileLockAsync(prepared.logPath, () => appendEventLocked(prepared.logPath, prepared.line, prepared.isNew));
538
567
  }
539
568
  catch {
540
569
  // Silent failure - logging should never break the CLI
@@ -558,8 +587,8 @@ export function emitStart(startEvent, payload = {}) {
558
587
  emit(startEvent.replace('.start', '.end'), { ...payload, ...endPayload, durationMs: Date.now() - startTime });
559
588
  };
560
589
  }
561
- export function emitRoutineEnd(meta) {
562
- emit('routine.end', {
590
+ function routineEndPayload(meta) {
591
+ return {
563
592
  module: 'routine',
564
593
  name: meta.jobName,
565
594
  status: meta.status,
@@ -567,7 +596,18 @@ export function emitRoutineEnd(meta) {
567
596
  ...(meta.duration != null ? { durationMs: meta.duration } : {}),
568
597
  ...(meta.exitCode != null ? { exitCode: meta.exitCode } : {}),
569
598
  ...(meta.detail ? { detail: meta.detail } : {}),
570
- });
599
+ };
600
+ }
601
+ export function emitRoutineEnd(meta) {
602
+ emit('routine.end', routineEndPayload(meta));
603
+ }
604
+ /**
605
+ * Async, non-blocking `routine.end` for the daemon heartbeat tick (PHNX-3695):
606
+ * the reaper (`reapExitedRunningJobs`) runs on the shared event loop, so it emits
607
+ * through {@link emitAsync} rather than the `sleepSync`-locked {@link emit}.
608
+ */
609
+ export async function emitRoutineEndAsync(meta) {
610
+ await emitAsync('routine.end', routineEndPayload(meta));
571
611
  }
572
612
  // ─── Timing Utilities ─────────────────────────────────────────────────────────
573
613
  /**