@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.
- package/CHANGELOG.md +34 -0
- package/README.md +34 -9
- package/dist/bootstrap.js +4 -4
- package/dist/commands/accounts.js +23 -7
- package/dist/commands/exec.js +2 -2
- package/dist/commands/import.js +2 -2
- package/dist/commands/models.js +2 -2
- package/dist/commands/permissions.js +2 -2
- package/dist/commands/repo.js +2 -2
- package/dist/commands/rules.js +1 -1
- package/dist/commands/send.js +9 -3
- package/dist/commands/sessions-export.d.ts +5 -1
- package/dist/commands/sessions-export.js +100 -24
- package/dist/commands/sessions-import.d.ts +2 -1
- package/dist/commands/sessions-import.js +85 -21
- package/dist/commands/traces.js +1 -1
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-registry.d.ts +3 -3
- package/dist/lib/account-registry.js +25 -7
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/acp/client.d.ts +1 -1
- package/dist/lib/acp/client.js +12 -1
- package/dist/lib/acp/harnesses.js +1 -1
- package/dist/lib/add-dir.js +0 -2
- package/dist/lib/agent-cli-commands.js +0 -2
- package/dist/lib/agent-spec/agents.d.ts +1 -1
- package/dist/lib/agent-spec/agents.js +2 -83
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +149 -20
- package/dist/lib/browser/remote-control.d.ts +6 -3
- package/dist/lib/browser/remote-control.js +6 -3
- package/dist/lib/browser/service.d.ts +4 -1
- package/dist/lib/browser/service.js +7 -1
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/channels/registry.d.ts +6 -0
- package/dist/lib/channels/send.d.ts +11 -2
- package/dist/lib/channels/send.js +11 -2
- package/dist/lib/cloud/rush.d.ts +10 -2
- package/dist/lib/cloud/rush.js +15 -9
- package/dist/lib/daemon/auth-sync-service.js +1 -1
- package/dist/lib/daemon/browser-task-reap-service.js +1 -1
- package/dist/lib/daemon/daemon.js +41 -11
- package/dist/lib/daemon/heartbeat-service.js +3 -3
- package/dist/lib/daemon/keychain-reap-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +237 -80
- package/dist/lib/daemon/self-heal-service.js +13 -3
- package/dist/lib/daemon/self-update-service.d.ts +174 -0
- package/dist/lib/daemon/self-update-service.js +353 -0
- package/dist/lib/daemon/state-dir-check-service.js +3 -3
- package/dist/lib/daemon/usage-sync-service.js +1 -1
- package/dist/lib/daemon/watchdog-service.js +4 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +12 -1
- package/dist/lib/device-config.js +63 -13
- package/dist/lib/exec-bounded.d.ts +52 -0
- package/dist/lib/exec-bounded.js +113 -0
- package/dist/lib/exec.d.ts +2 -2
- package/dist/lib/exec.js +3 -34
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/feed-broadcast.d.ts +12 -29
- package/dist/lib/feed-broadcast.js +28 -27
- package/dist/lib/fleet-shared-state.d.ts +12 -5
- package/dist/lib/fleet-shared-state.js +50 -20
- package/dist/lib/fs-atomic.d.ts +11 -0
- package/dist/lib/fs-atomic.js +60 -0
- package/dist/lib/hooks/install.js +0 -87
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/installations/strategies.js +1 -1
- package/dist/lib/mcp-registry.js +0 -13
- package/dist/lib/mcp.js +2 -2
- package/dist/lib/model-tiers.js +1 -1
- package/dist/lib/models.js +0 -63
- package/dist/lib/notify.d.ts +11 -0
- package/dist/lib/notify.js +17 -4
- package/dist/lib/owner-message.d.ts +46 -3
- package/dist/lib/owner-message.js +26 -6
- package/dist/lib/permissions-registry.d.ts +0 -2
- package/dist/lib/permissions-registry.js +3 -50
- package/dist/lib/permissions.d.ts +3 -17
- package/dist/lib/permissions.js +4 -73
- package/dist/lib/project-resources.d.ts +12 -0
- package/dist/lib/project-resources.js +129 -0
- package/dist/lib/routine-process-cleanup.d.ts +2 -2
- package/dist/lib/routine-process-cleanup.js +45 -34
- package/dist/lib/rush-session.d.ts +19 -0
- package/dist/lib/rush-session.js +24 -0
- package/dist/lib/secrets/drivers/rush.js +2 -1
- package/dist/lib/secrets/reaper.d.ts +2 -2
- package/dist/lib/secrets/reaper.js +13 -10
- package/dist/lib/secrets/reserved-sync.d.ts +1 -1
- package/dist/lib/secrets/reserved-sync.js +4 -4
- package/dist/lib/self-update.d.ts +21 -8
- package/dist/lib/self-update.js +54 -31
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/sync/backend.d.ts +61 -0
- package/dist/lib/session/sync/backend.js +89 -0
- package/dist/lib/session/sync/managed-config.d.ts +29 -0
- package/dist/lib/session/sync/managed-config.js +23 -0
- package/dist/lib/session/sync/managed-key.d.ts +45 -0
- package/dist/lib/session/sync/managed-key.js +128 -0
- package/dist/lib/session/sync/net-client.d.ts +65 -0
- package/dist/lib/session/sync/net-client.js +117 -0
- package/dist/lib/session/sync/provision.d.ts +19 -0
- package/dist/lib/session/sync/provision.js +38 -0
- package/dist/lib/session/sync/r2.d.ts +5 -2
- package/dist/lib/session/sync/r2.js +5 -2
- package/dist/lib/session/sync/worker-template.d.ts +6 -0
- package/dist/lib/session/sync/worker-template.js +847 -0
- package/dist/lib/sink-format.d.ts +34 -0
- package/dist/lib/sink-format.js +17 -0
- package/dist/lib/staleness/detectors/permissions.js +0 -20
- package/dist/lib/staleness/writers/commands.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +2 -2
- package/dist/lib/subagents-registry.js +2 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -37
- package/dist/lib/tmux/orphan-reap.js +6 -4
- package/dist/lib/tmux/session.js +4 -1
- package/dist/lib/traces/classify.d.ts +8 -1
- package/dist/lib/traces/insights.d.ts +13 -1
- package/dist/lib/traces/insights.js +78 -3
- package/dist/lib/traces/sync.js +8 -3
- package/dist/lib/traces/worker-template.js +9 -5
- package/dist/lib/types.d.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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
|
+
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|
|
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 {};
|
package/dist/lib/feed/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
/**
|