@phnx-labs/agents-cli 1.22.111 → 1.22.113
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 +12 -0
- package/dist/commands/exec.d.ts +106 -1
- package/dist/commands/exec.js +10 -10
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +12 -0
- package/dist/lib/daemon/daemon.d.ts +0 -8
- package/dist/lib/daemon/daemon.js +41 -22
- package/dist/lib/daemon/testdata/process-view-daemon.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-daemon.js +14 -0
- package/dist/lib/daemon/testdata/process-view-start.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-start.js +43 -0
- package/dist/lib/event-provenance.d.ts +2 -0
- package/dist/lib/event-provenance.js +4 -0
- package/dist/lib/exec.js +27 -7
- package/dist/lib/feed/activity-stream.js +9 -0
- package/dist/lib/feed/activity.d.ts +8 -0
- package/dist/lib/feed/activity.js +61 -18
- package/dist/lib/feed/watch.d.ts +9 -0
- package/dist/lib/feed/watch.js +91 -3
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +0 -10
- package/dist/lib/hosts/dispatch.js +2 -19
- package/dist/lib/hosts/remote-session-id.d.ts +5 -27
- package/dist/lib/hosts/remote-session-id.js +18 -61
- package/dist/lib/launch-identity.d.ts +10 -0
- package/dist/lib/launch-identity.js +31 -0
- package/dist/lib/session/active.js +9 -3
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +39 -4
- package/dist/lib/session/discover.js +7 -2
- package/dist/lib/session/hook-sessions.d.ts +10 -0
- package/dist/lib/session/hook-sessions.js +139 -6
- package/dist/lib/session/pid-registry.d.ts +18 -5
- package/dist/lib/session/pid-registry.js +164 -17
- package/dist/lib/session/process-view.d.ts +25 -0
- package/dist/lib/session/process-view.js +176 -0
- package/dist/lib/session/projection.d.ts +13 -0
- package/dist/lib/session/projection.js +101 -0
- package/dist/lib/session/remote/watch.d.ts +8 -0
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/session-cache.js +15 -0
- package/dist/lib/session/testdata/pid-registry-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/pid-registry-namespace.js +27 -0
- package/dist/lib/session/testdata/process-view-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-namespace.js +56 -0
- package/dist/lib/session/testdata/process-view-private.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-private.js +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.113
|
|
4
|
+
|
|
5
|
+
- **Remote sessions have one execution-owned row (PHNX-3939).** Fleet session and feed streams reconcile launcher observations with the worker's state, retaining terminal/viewing metadata separately. Reconnects and stale history cannot replace a genuine worker row. Registry cleanup retains records whose PID namespace cannot be verified; nested agent runs keep launch lineage without claiming their parent's editor terminal.
|
|
6
|
+
- **Codex forks retain their child identity.** Full and incremental scans keep the first native metadata record. Reindexing repairs cold files and removes a stale parent binding to child bytes even when the parent rollout is absent. Team descendants also keep their immediate parent lineage without inheriting its editor ownership.
|
|
7
|
+
- **Nested daemon starts preserve shared session state.** Legacy migration and reboot recovery require the initial host namespace or kernel-verified live daemon ownership before touching singleton state. Foreign snapshots remain read-only, and invisible processes are never treated as dead. Automatic reuse of a private-container HOME across namespaces is unsupported, including after the prior namespace exits; writers fail safely with a diagnostic.
|
|
8
|
+
|
|
9
|
+
- Repair session indexes whose schema version is current but whose identity columns are missing, preserving existing session metadata (PHNX-3940).
|
|
10
|
+
|
|
11
|
+
## 1.22.112
|
|
12
|
+
|
|
13
|
+
- **Runs expand portable home paths on the executing device (PHNX-3940).** A remote resume terminal could open successfully while its child failed to start because `--cwd '$HOME'` was treated as a literal directory. The shared run path now expands `$HOME` and `~` locally, while preserving those anchors until remote dispatch reaches its target. This fixes direct runs and resumed conversations alike.
|
|
14
|
+
|
|
3
15
|
## 1.22.111
|
|
4
16
|
|
|
5
17
|
- **Remote session resume accepts the resolver’s launch metadata (PHNX-3940).** A reachable peer’s valid session row could be rejected because it included a mode or custom harness, leaving the session undiscoverable. The reader now accepts both fields while keeping private transcript paths out of the resolver contract. Malformed responses are reported as invalid data instead of an unreachable device.
|
package/dist/commands/exec.d.ts
CHANGED
|
@@ -6,8 +6,97 @@
|
|
|
6
6
|
* injection, and multi-agent fallback chains for rate-limit resilience.
|
|
7
7
|
*/
|
|
8
8
|
import { type Command } from 'commander';
|
|
9
|
-
import type { ExecEffort } from '../lib/exec.js';
|
|
9
|
+
import type { ExecMode, ExecEffort } from '../lib/exec.js';
|
|
10
10
|
import { RUN_AUTO_KEYWORD } from '../lib/types.js';
|
|
11
|
+
interface ExecCommandActionOptions {
|
|
12
|
+
mode: ExecMode;
|
|
13
|
+
effort: ExecEffort;
|
|
14
|
+
model?: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
/** --project <slug>[@worktree]: resolve cwd from the projects root shorthand. */
|
|
17
|
+
project?: string;
|
|
18
|
+
addDir: string[];
|
|
19
|
+
env: string[];
|
|
20
|
+
secrets: string[];
|
|
21
|
+
/** Commander maps `--no-auto-secrets` to `autoSecrets` (default true, false when passed). */
|
|
22
|
+
autoSecrets?: boolean;
|
|
23
|
+
json?: boolean;
|
|
24
|
+
quiet?: boolean;
|
|
25
|
+
headless?: boolean;
|
|
26
|
+
interactive?: boolean;
|
|
27
|
+
/** --no-auth-check: Commander maps it to `authCheck` (default true, false when passed). Silences the logged-out preflight warning on an interactive launch. */
|
|
28
|
+
authCheck?: boolean;
|
|
29
|
+
/** --resume [id]: string id/prefix, or `true` for the bare flag (interactive picker). */
|
|
30
|
+
resume?: string | boolean;
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
/** --name <slug>: durable launch handle, resolvable via `agents sessions <name>`. */
|
|
33
|
+
name?: string;
|
|
34
|
+
/** --notify: post a desktop notification when a headless run finishes. */
|
|
35
|
+
notify?: boolean;
|
|
36
|
+
/** `--no-trace-sync` → commander maps it to `traceSync` (default true, false when passed). Skips the run-exit trace auto-sync for this run (PHNX-3628). */
|
|
37
|
+
traceSync?: boolean;
|
|
38
|
+
/** --terminal [backend]: open this run in a terminal tab; `true` = auto-detect. */
|
|
39
|
+
terminal?: string | boolean;
|
|
40
|
+
verbose?: boolean;
|
|
41
|
+
raw?: boolean;
|
|
42
|
+
/** `--no-tmux` → commander sets this false (default true) to bypass the tmux wrapper. */
|
|
43
|
+
tmux?: boolean;
|
|
44
|
+
/** `--disable-tmux` → explicit alias of --no-tmux. */
|
|
45
|
+
disableTmux?: boolean;
|
|
46
|
+
timeout?: string;
|
|
47
|
+
fallback?: string;
|
|
48
|
+
balanced?: boolean;
|
|
49
|
+
strategy?: string;
|
|
50
|
+
/** Restrict selection to a named provider account discovered from live version homes. */
|
|
51
|
+
account?: string;
|
|
52
|
+
/**
|
|
53
|
+
* @deprecated Hidden alias for `--device auto`. Resolved before host dispatch.
|
|
54
|
+
* Remove after one release.
|
|
55
|
+
*/
|
|
56
|
+
smart?: boolean;
|
|
57
|
+
acp?: boolean;
|
|
58
|
+
yes?: boolean;
|
|
59
|
+
loop?: boolean;
|
|
60
|
+
resumeCheckpoint?: string;
|
|
61
|
+
maxIterations?: string;
|
|
62
|
+
budget?: string;
|
|
63
|
+
until?: string;
|
|
64
|
+
interval?: string;
|
|
65
|
+
where?: string;
|
|
66
|
+
host?: string;
|
|
67
|
+
device?: string;
|
|
68
|
+
on?: string;
|
|
69
|
+
computer?: string;
|
|
70
|
+
remoteCwd?: string;
|
|
71
|
+
follow?: boolean;
|
|
72
|
+
any?: boolean;
|
|
73
|
+
copyCreds?: boolean;
|
|
74
|
+
lease?: string | boolean;
|
|
75
|
+
box?: string;
|
|
76
|
+
keepBox?: boolean;
|
|
77
|
+
fresh?: boolean;
|
|
78
|
+
reuse?: boolean;
|
|
79
|
+
bare?: boolean;
|
|
80
|
+
tailscale?: boolean;
|
|
81
|
+
cloud?: boolean;
|
|
82
|
+
provider?: string;
|
|
83
|
+
repo?: string[];
|
|
84
|
+
branch?: string;
|
|
85
|
+
cloudEnv?: string;
|
|
86
|
+
secretsKeys?: string;
|
|
87
|
+
allowExpired?: boolean;
|
|
88
|
+
emitSessionId?: boolean;
|
|
89
|
+
/** --broadcast: matrix-run the same prompt/task across agents × models. */
|
|
90
|
+
broadcast?: boolean;
|
|
91
|
+
/** --task <id>: house broadcast task (with --broadcast). */
|
|
92
|
+
task?: string;
|
|
93
|
+
/** --list-tasks: list broadcast tasks. */
|
|
94
|
+
listTasks?: boolean;
|
|
95
|
+
/** --results [run-id]: show saved broadcast runs. */
|
|
96
|
+
results?: string | true;
|
|
97
|
+
/** --concurrency <n>: broadcast cell parallelism. */
|
|
98
|
+
concurrency?: string;
|
|
99
|
+
}
|
|
11
100
|
/** Validate a caller-supplied session id before it reaches tmux, paths, indexes, or remote dispatch. */
|
|
12
101
|
export declare function parseExplicitSessionId(value: string): string;
|
|
13
102
|
export interface RunPickerMarkers {
|
|
@@ -210,5 +299,21 @@ export declare function runWorkflowForEach(spec: import('../lib/workflows.js').F
|
|
|
210
299
|
cwd: string;
|
|
211
300
|
effort?: ExecEffort;
|
|
212
301
|
}): Promise<number>;
|
|
302
|
+
/**
|
|
303
|
+
* The run's working directory from `--cwd` / `--project`. `--project <slug>` owns
|
|
304
|
+
* the directory and is mutually exclusive with `--cwd`/`--remote-cwd`; both the
|
|
305
|
+
* main dispatch and the `--terminal` handoff need the same answer, so the rule
|
|
306
|
+
* and its error live here once instead of in two places that can drift.
|
|
307
|
+
*
|
|
308
|
+
* A project that binds several directories also contributes the ones it is not
|
|
309
|
+
* landing in as `--add-dir` grants, so an agent on a multi-repo project can
|
|
310
|
+
* actually reach the sibling checkouts. Claude / Cursor / Kimi take the native
|
|
311
|
+
* flag; Codex folds them into workspace_roots; Grok widens its sandbox profile
|
|
312
|
+
* (and always injects a rules note). Other harnesses have no multi-root surface
|
|
313
|
+
* and ignore the grants (cwd only).
|
|
314
|
+
*/
|
|
315
|
+
export declare function resolveRunCwd(options: Pick<ExecCommandActionOptions, 'cwd' | 'project' | 'remoteCwd' | 'addDir'>, opts: {
|
|
316
|
+
forRemote: boolean;
|
|
317
|
+
}): Promise<string | undefined>;
|
|
213
318
|
/** Register the `agents run <agent> [prompt]` command. */
|
|
214
319
|
export declare function registerRunCommand(program: Command): void;
|
package/dist/commands/exec.js
CHANGED
|
@@ -467,9 +467,13 @@ export async function runWorkflowForEach(spec, opts) {
|
|
|
467
467
|
* (and always injects a rules note). Other harnesses have no multi-root surface
|
|
468
468
|
* and ignore the grants (cwd only).
|
|
469
469
|
*/
|
|
470
|
-
async function resolveRunCwd(options, opts) {
|
|
471
|
-
if (!options.project)
|
|
472
|
-
|
|
470
|
+
export async function resolveRunCwd(options, opts) {
|
|
471
|
+
if (!options.project) {
|
|
472
|
+
if (!options.cwd || opts.forRemote)
|
|
473
|
+
return options.cwd;
|
|
474
|
+
const { expandLocalHome } = await import('../lib/project-root.js');
|
|
475
|
+
return expandLocalHome(options.cwd);
|
|
476
|
+
}
|
|
473
477
|
if (options.cwd || options.remoteCwd) {
|
|
474
478
|
console.error(chalk.red('Pass --project alone — not with --cwd or --remote-cwd.'));
|
|
475
479
|
process.exit(1);
|
|
@@ -1614,13 +1618,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
1614
1618
|
const { armRunFinishTraceSync } = await import('../lib/run-trace-sync.js');
|
|
1615
1619
|
armRunFinishTraceSync({ disabled: options.traceSync === false });
|
|
1616
1620
|
}
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
|
|
1620
|
-
// so it is mutually exclusive with both --cwd and --remote-cwd.
|
|
1621
|
-
if (options.project) {
|
|
1622
|
-
options.cwd = await resolveRunCwd(options, { forRemote: hostGiven.length > 0 });
|
|
1623
|
-
}
|
|
1621
|
+
// Home anchors belong to the executing host; preserve them until a remote
|
|
1622
|
+
// invocation reaches its local execution path.
|
|
1623
|
+
options.cwd = await resolveRunCwd(options, { forRemote: hostGiven.length > 0 });
|
|
1624
1624
|
if (hostGiven.length > 0) {
|
|
1625
1625
|
if (new Set(hostGiven).size > 1) {
|
|
1626
1626
|
console.error(chalk.red('Conflicting --device values values — pass just one.'));
|
|
@@ -31,6 +31,7 @@ interface SessionsOptions extends SessionFilterOptions {
|
|
|
31
31
|
resolve?: string;
|
|
32
32
|
/** Versioned internal peer protocol; old/unsafe peers must reject it. */
|
|
33
33
|
resolveSafeV1?: string;
|
|
34
|
+
resolveLaunchId?: string;
|
|
34
35
|
limit?: string;
|
|
35
36
|
sort?: string;
|
|
36
37
|
json?: boolean;
|
|
@@ -2367,6 +2367,17 @@ limitSource) {
|
|
|
2367
2367
|
process.stdout.write(JSON.stringify(getSessionRoots(), null, 2) + '\n');
|
|
2368
2368
|
return;
|
|
2369
2369
|
}
|
|
2370
|
+
if (options.resolveLaunchId !== undefined) {
|
|
2371
|
+
if (!options.json)
|
|
2372
|
+
throw new Error('--resolve-launch-id requires --json.');
|
|
2373
|
+
const launchId = options.resolveLaunchId.trim();
|
|
2374
|
+
if (!launchId)
|
|
2375
|
+
throw new Error('--resolve-launch-id requires a non-empty launch id.');
|
|
2376
|
+
const { loadHookSessionIndex } = await import('../lib/session/hook-sessions.js');
|
|
2377
|
+
const record = loadHookSessionIndex().byLaunchId.get(launchId);
|
|
2378
|
+
process.stdout.write(JSON.stringify({ launchId, sessionId: record?.session_id ?? null }) + '\n');
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2370
2381
|
// --resolve is the metadata-only contract for downstream context workflows.
|
|
2371
2382
|
// It reads indexed SessionMeta rows and never calls renderSession, buildPreview,
|
|
2372
2383
|
// parseSession, or any other transcript renderer/parser.
|
|
@@ -5160,6 +5171,7 @@ export function registerSessionsCommands(program) {
|
|
|
5160
5171
|
.option('--query <clause>', 'Search text; repeat with --include tools to require distinct matching calls', collectQueryClause, [])
|
|
5161
5172
|
.option('--resolve <selector>', 'Resolve one full ID, unique prefix, or keyword query to safe session metadata (requires --json; searches the fleet unless --local)')
|
|
5162
5173
|
.addOption(new Option('--resolve-safe-v1 <selector>').hideHelp())
|
|
5174
|
+
.addOption(new Option('--resolve-launch-id <id>').hideHelp())
|
|
5163
5175
|
.description('Find, browse, and read agent conversation transcripts. Live roster: `agents sessions --active`.')
|
|
5164
5176
|
.option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
|
|
5165
5177
|
.addOption(new Option('--session-version <version>', 'Internal spelling for the public sessions --version filter')
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Daemon lifecycle management for the routines scheduler.
|
|
3
|
-
*
|
|
4
|
-
* The daemon is a long-running process that holds a JobScheduler and
|
|
5
|
-
* triggers jobs on their cron schedules. It can be managed via launchd
|
|
6
|
-
* (macOS), systemd (Linux), or as a plain detached process. PID tracking,
|
|
7
|
-
* log output, reload (SIGHUP), and graceful shutdown are handled here.
|
|
8
|
-
*/
|
|
9
1
|
import { isolatedHomeSuffix } from '../service-manifest.js';
|
|
10
2
|
import { getAgentsBinPath } from '../cli-entry.js';
|
|
11
3
|
import type { ServiceHealth } from './service.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { daemonProcessViewAllowed, recordDaemonProcessView } from '../session/process-view.js';
|
|
1
2
|
/**
|
|
2
3
|
* Daemon lifecycle management for the routines scheduler.
|
|
3
4
|
*
|
|
@@ -22,7 +23,6 @@ import { detectOverdueJobs, notifyOverdue } from '../overdue.js';
|
|
|
22
23
|
import { runCatchup } from '../catchup.js';
|
|
23
24
|
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from '../routine-notify.js';
|
|
24
25
|
import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from '../routine-notify-owner.js';
|
|
25
|
-
import { BrowserService } from '../browser/service.js';
|
|
26
26
|
import { getSocketPath as getBrowserIpcSocketPath } from '../browser/ipc.js';
|
|
27
27
|
import { redactSecrets } from '../redact.js';
|
|
28
28
|
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from '../cli-entry.js';
|
|
@@ -30,27 +30,6 @@ import { localBinDir } from '../platform/posixpath.js';
|
|
|
30
30
|
import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from '../device-config.js';
|
|
31
31
|
import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
|
|
32
32
|
import { ServiceSupervisor } from './supervisor.js';
|
|
33
|
-
import { SessionIndexService } from './session-index-service.js';
|
|
34
|
-
import { SessionSummarizerService } from './session-summarizer-service.js';
|
|
35
|
-
import { SessionTitleService } from './session-title-service.js';
|
|
36
|
-
import { MonitorEngineService } from './monitor-engine-service.js';
|
|
37
|
-
import { AccountUsageService, AccountAuthService } from './account-state-daemon-service.js';
|
|
38
|
-
import { CatchupService } from './catchup-service.js';
|
|
39
|
-
import { BrowserIPCService } from './browser-ipc-service.js';
|
|
40
|
-
import { WatchdogService } from './watchdog-service.js';
|
|
41
|
-
import { DeviceProbeService } from './device-probe-service.js';
|
|
42
|
-
import { SelfHealService } from './self-heal-service.js';
|
|
43
|
-
import { SelfUpdateService } from './self-update-service.js';
|
|
44
|
-
import { HarnessUpdateService } from './harness-update-service.js';
|
|
45
|
-
import { AuthSyncService } from './auth-sync-service.js';
|
|
46
|
-
import { UsageSyncService } from './usage-sync-service.js';
|
|
47
|
-
import { StateDirCheckService } from './state-dir-check-service.js';
|
|
48
|
-
import { SessionStateService } from './session-state-service.js';
|
|
49
|
-
import { AttentionNotifyService } from './attention-notify-service.js';
|
|
50
|
-
import { WebhookReceiverService } from './webhook-receiver-service.js';
|
|
51
|
-
import { HeartbeatService } from './heartbeat-service.js';
|
|
52
|
-
import { TmuxReapService } from './tmux-reap-service.js';
|
|
53
|
-
import { BrowserTaskReapService } from './browser-task-reap-service.js';
|
|
54
33
|
import { emit, emitAsync, emitRoutineEnd } from '../feed/events.js';
|
|
55
34
|
import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
|
|
56
35
|
import { sleepSync } from '../fs-atomic.js';
|
|
@@ -478,6 +457,12 @@ export function isDaemonRunning() {
|
|
|
478
457
|
* liveness check and the write.
|
|
479
458
|
*/
|
|
480
459
|
export function claimDaemonInstance() {
|
|
460
|
+
// A nested caller cannot interpret legacy numeric lock/PID files. Authenticate
|
|
461
|
+
// before acquiring the lock (whose stale-PID cleanup itself mutates state).
|
|
462
|
+
if (!daemonProcessViewAllowed()) {
|
|
463
|
+
console.error('Daemon startup requires the owning process namespace. Automatic reuse of a private-container HOME across namespaces is unsupported; run in its owning namespace or use a fresh HOME.');
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
481
466
|
// A stop owns this same lock through teardown. Waiting here is load-bearing:
|
|
482
467
|
// returning false while stopDaemon() holds it lets this replacement exit 0,
|
|
483
468
|
// then the stop completes with no singleton left alive. The bounded lifecycle
|
|
@@ -488,6 +473,10 @@ export function claimDaemonInstance() {
|
|
|
488
473
|
if (!release)
|
|
489
474
|
return false;
|
|
490
475
|
try {
|
|
476
|
+
// Recheck under lifecycle serialization; invocation is not provenance.
|
|
477
|
+
if (!daemonProcessViewAllowed())
|
|
478
|
+
return false;
|
|
479
|
+
recordDaemonProcessView();
|
|
491
480
|
// Do not overwrite a live-but-uninspectable owner. This is the non-
|
|
492
481
|
// destructive side of the same fail-closed rule stopDaemon applies.
|
|
493
482
|
if (unverifiedLiveDaemonPid() !== null)
|
|
@@ -887,6 +876,32 @@ export async function runDaemon() {
|
|
|
887
876
|
// HOME override must refuse to run against the operator's real state rather than
|
|
888
877
|
// schedule against the real host. No-op in production (the marker is never set).
|
|
889
878
|
assertTestDaemonHome();
|
|
879
|
+
// Lifecycle readers and launchers do not run services. Load their code only
|
|
880
|
+
// in the daemon process, before it claims or publishes lifecycle state.
|
|
881
|
+
const [{ BrowserService }, { SessionIndexService }, { SessionSummarizerService }, { SessionTitleService }, { MonitorEngineService }, { AccountUsageService, AccountAuthService }, { CatchupService }, { BrowserIPCService }, { WatchdogService }, { DeviceProbeService }, { SelfHealService }, { SelfUpdateService }, { HarnessUpdateService }, { AuthSyncService }, { UsageSyncService }, { StateDirCheckService }, { SessionStateService }, { AttentionNotifyService }, { WebhookReceiverService }, { HeartbeatService }, { TmuxReapService }, { BrowserTaskReapService },] = await Promise.all([
|
|
882
|
+
import('../browser/service.js'),
|
|
883
|
+
import('./session-index-service.js'),
|
|
884
|
+
import('./session-summarizer-service.js'),
|
|
885
|
+
import('./session-title-service.js'),
|
|
886
|
+
import('./monitor-engine-service.js'),
|
|
887
|
+
import('./account-state-daemon-service.js'),
|
|
888
|
+
import('./catchup-service.js'),
|
|
889
|
+
import('./browser-ipc-service.js'),
|
|
890
|
+
import('./watchdog-service.js'),
|
|
891
|
+
import('./device-probe-service.js'),
|
|
892
|
+
import('./self-heal-service.js'),
|
|
893
|
+
import('./self-update-service.js'),
|
|
894
|
+
import('./harness-update-service.js'),
|
|
895
|
+
import('./auth-sync-service.js'),
|
|
896
|
+
import('./usage-sync-service.js'),
|
|
897
|
+
import('./state-dir-check-service.js'),
|
|
898
|
+
import('./session-state-service.js'),
|
|
899
|
+
import('./attention-notify-service.js'),
|
|
900
|
+
import('./webhook-receiver-service.js'),
|
|
901
|
+
import('./heartbeat-service.js'),
|
|
902
|
+
import('./tmux-reap-service.js'),
|
|
903
|
+
import('./browser-task-reap-service.js'),
|
|
904
|
+
]);
|
|
890
905
|
// Install the shared-daemon reload signal boundary BEFORE publishing our PID
|
|
891
906
|
// in claimDaemonInstance(). Browser/routines clients use that PID to decide a
|
|
892
907
|
// daemon exists and may request a service reload immediately. POSIX otherwise
|
|
@@ -1739,6 +1754,10 @@ function assertDaemonLaunchHomeAllowed() {
|
|
|
1739
1754
|
}
|
|
1740
1755
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
1741
1756
|
export function startDaemon(agentsBin) {
|
|
1757
|
+
// The public launcher must obey the same namespace boundary as its child:
|
|
1758
|
+
// even probing/repairing legacy PID state or pruning a lock can mutate it.
|
|
1759
|
+
if (!daemonProcessViewAllowed())
|
|
1760
|
+
throw new Error('Daemon startup requires the owning process namespace. Automatic reuse of a private-container HOME across namespaces is unsupported; run in its owning namespace or use a fresh HOME.');
|
|
1742
1761
|
if (isDaemonRunning()) {
|
|
1743
1762
|
const pid = readDaemonPid();
|
|
1744
1763
|
return { pid, method: 'already-running' };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Runs the production daemon entrypoint against an isolated HOME. No installed
|
|
2
|
+
// CLI, service manager, scheduler, or production socket participates.
|
|
3
|
+
import { runDaemon, startDaemon } from '../daemon.js';
|
|
4
|
+
if (process.argv[2] === '__start-daemon') {
|
|
5
|
+
try {
|
|
6
|
+
startDaemon();
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
console.log(error.message);
|
|
10
|
+
process.exitCode = 2;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else
|
|
14
|
+
await runDaemon();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { serviceManagerRegistrationAllowed } from '../../service-manifest.js';
|
|
7
|
+
import { startDaemon, stopDaemon } from '../daemon.js';
|
|
8
|
+
import { getDaemonDir, getTerminalsDir, getCacheDir } from '../../state.js';
|
|
9
|
+
// Tripwire precedes ALL lifecycle calls. Some older Bun versions return HOME
|
|
10
|
+
// from os.userInfo(), which would incorrectly allow the real service manager.
|
|
11
|
+
assert.equal(serviceManagerRegistrationAllowed().allowed, false, 'isolated fixture must never access the production service manager');
|
|
12
|
+
const cold = process.argv[3] === 'cold';
|
|
13
|
+
const child = cold ? spawn('bun', [process.argv[4], '__daemon-run'], { stdio: 'ignore', env: process.env }) : undefined;
|
|
14
|
+
const started = child ? { pid: child.pid, method: 'detached' } : startDaemon(process.argv[2]);
|
|
15
|
+
assert.equal(started.method, 'detached');
|
|
16
|
+
assert.ok(started.pid);
|
|
17
|
+
fs.writeFileSync(path.join(process.env.HOME, 'test-child.pid'), String(started.pid));
|
|
18
|
+
try {
|
|
19
|
+
if (!cold)
|
|
20
|
+
assert.ok(fs.existsSync(path.join(getDaemonDir(), 'health.json')), 'ordinary launcher publishes health before daemon starts');
|
|
21
|
+
const socket = path.join(getCacheDir(), 'helpers', 'browser', 'browser.sock');
|
|
22
|
+
let ready = false;
|
|
23
|
+
for (let i = 0; i < 80 && !ready; i++) {
|
|
24
|
+
ready = await new Promise(resolve => {
|
|
25
|
+
const client = net.createConnection(socket);
|
|
26
|
+
const done = (value) => { client.destroy(); resolve(value); };
|
|
27
|
+
client.setTimeout(100, () => done(false));
|
|
28
|
+
client.once('connect', () => done(true));
|
|
29
|
+
client.once('error', () => done(false));
|
|
30
|
+
});
|
|
31
|
+
if (!ready)
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
33
|
+
}
|
|
34
|
+
assert.ok(ready, 'ordinary fresh-home daemon must serve its actual browser socket');
|
|
35
|
+
assert.equal(fs.readFileSync(path.join(getDaemonDir(), 'daemon.pid'), 'utf8').trim(), String(started.pid));
|
|
36
|
+
const owner = JSON.parse(fs.readFileSync(path.join(getTerminalsDir(), 'process-view.json'), 'utf8'));
|
|
37
|
+
assert.equal(owner.pidNamespace, fs.readlinkSync('/proc/self/ns/pid'));
|
|
38
|
+
assert.equal(owner.bootId, fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim());
|
|
39
|
+
console.log(`${cold ? 'cold runDaemon' : 'ordinary startDaemon: health published'}; canonical socket ready; namespace owner verified`);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
stopDaemon();
|
|
43
|
+
}
|
|
@@ -40,6 +40,10 @@ export function stampProvenance(env = process.env) {
|
|
|
40
40
|
provenance.launchId = env.AGENT_LAUNCH_ID;
|
|
41
41
|
if (env.AGENTS_PARENT_SESSION_ID)
|
|
42
42
|
provenance.parentSessionId = env.AGENTS_PARENT_SESSION_ID;
|
|
43
|
+
if (env.AGENTS_PARENT_LAUNCH_ID)
|
|
44
|
+
provenance.parentLaunchId = env.AGENTS_PARENT_LAUNCH_ID;
|
|
45
|
+
if (env.AGENTS_ORIGIN_TERMINAL_ID)
|
|
46
|
+
provenance.originTerminalId = env.AGENTS_ORIGIN_TERMINAL_ID;
|
|
43
47
|
return provenance;
|
|
44
48
|
}
|
|
45
49
|
export function resetEventProvenanceForTest() {
|
package/dist/lib/exec.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureLaunchBinding, recordCompletedLaunch } from './session/hook-sessions.js';
|
|
1
2
|
/**
|
|
2
3
|
* Agent execution -- command building, process spawning, and rate-limit fallback.
|
|
3
4
|
*
|
|
@@ -17,6 +18,7 @@ import { isTierToken, resolveTier } from './model-tiers.js';
|
|
|
17
18
|
import { emit, createTimer, redactPrompt, redactArgs } from './feed/events.js';
|
|
18
19
|
import { sanitizeProcessEnv } from './secrets-client.js';
|
|
19
20
|
import { resolveActor, actorEnv } from './actor.js';
|
|
21
|
+
import { launchIdentityEnv, LAUNCH_IDENTITY_KEYS } from './launch-identity.js';
|
|
20
22
|
import { expandLocalHome } from './project-root.js';
|
|
21
23
|
import { getShimsDir, getHistoryDir, getUserAgentsDir, getRuntimeStateDir } from './state.js';
|
|
22
24
|
import { readCodexConfiguredModel } from './installations/shims.js';
|
|
@@ -378,6 +380,14 @@ export function buildExecEnv(options) {
|
|
|
378
380
|
else {
|
|
379
381
|
stripForeignConfigDir(result);
|
|
380
382
|
}
|
|
383
|
+
// A new harness must not inherit its spawner's own session/mailbox or editor
|
|
384
|
+
// ownership. Keep those relationships as lineage, including across SSH.
|
|
385
|
+
delete result.AGENT_SESSION_ID;
|
|
386
|
+
delete result.AGENTS_SESSION_ID;
|
|
387
|
+
delete result.AGENTS_MAILBOX_DIR;
|
|
388
|
+
delete result.AGENT_TERMINAL_ID;
|
|
389
|
+
const launchIdentity = launchIdentityEnv();
|
|
390
|
+
Object.assign(result, launchIdentity);
|
|
381
391
|
// Point the agent at its own mailbox so the PreToolUse `mailbox-inject` hook
|
|
382
392
|
// knows which box to drain and inject mid-run. Keyed by the session id — the
|
|
383
393
|
// same id the writer resolves via mailboxIdForActiveSession(). A loop run
|
|
@@ -394,9 +404,9 @@ export function buildExecEnv(options) {
|
|
|
394
404
|
// (events.ts::resolveProvenance) reads AGENTS_PARENT_SESSION_ID and stamps it on
|
|
395
405
|
// every event the child emits. `options.sessionId` is the CHILD's id, so read the
|
|
396
406
|
// spawner from the live env; guard a same-session resume from naming itself parent.
|
|
397
|
-
//
|
|
407
|
+
// An SSH continuation has no runtime marker; keep its forwarded parent edge.
|
|
398
408
|
delete result.AGENTS_PARENT_SESSION_ID;
|
|
399
|
-
const spawnerSessionId =
|
|
409
|
+
const spawnerSessionId = launchIdentity.AGENTS_PARENT_SESSION_ID;
|
|
400
410
|
if (spawnerSessionId && spawnerSessionId !== options.sessionId) {
|
|
401
411
|
result.AGENTS_PARENT_SESSION_ID = spawnerSessionId;
|
|
402
412
|
}
|
|
@@ -1210,7 +1220,7 @@ async function execShimPassthroughLeased(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1210
1220
|
actor: resolveActor().id,
|
|
1211
1221
|
initiatedBy: resolveActor().kind,
|
|
1212
1222
|
launchId,
|
|
1213
|
-
terminalId:
|
|
1223
|
+
terminalId: launchIdentityEnv().AGENT_TERMINAL_ID,
|
|
1214
1224
|
startedAtMs: Date.now(),
|
|
1215
1225
|
});
|
|
1216
1226
|
// Durable sessionId -> actor record (RUSH-2019) so the scanner can attribute
|
|
@@ -1347,6 +1357,7 @@ export function isHarnessKnownSessionId(agent, sessionId, resume) {
|
|
|
1347
1357
|
*/
|
|
1348
1358
|
export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
1349
1359
|
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
1360
|
+
const absentIdentity = LAUNCH_IDENTITY_KEYS.filter(key => env[key] === undefined);
|
|
1350
1361
|
// envFile: source the values instead of inlining them, so no VALUE ever lands
|
|
1351
1362
|
// in the pane's argv. `exec env K=V …` put every resolved secret into the
|
|
1352
1363
|
// process table, readable by any process of this user — on one fleet box six
|
|
@@ -1361,13 +1372,13 @@ export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
|
|
|
1361
1372
|
// strands the plaintext env (incl. the secrets-store master passphrase) on
|
|
1362
1373
|
// disk on any source failure, worse than the argv leak this replaces
|
|
1363
1374
|
// (RUSH-2100). Capture the source rc, unlink, then honor it.
|
|
1364
|
-
return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
|
|
1375
|
+
return `${absentIdentity.length ? `unset ${absentIdentity.join(' ')}; ` : ''}set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
|
|
1365
1376
|
}
|
|
1366
1377
|
const envPrefix = Object.entries(env)
|
|
1367
1378
|
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
1368
1379
|
.map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
|
|
1369
1380
|
.join(' ');
|
|
1370
|
-
return `exec env ${envPrefix} ${agentCmd}`;
|
|
1381
|
+
return `exec env ${absentIdentity.map(key => `-u ${key}`).join(' ')} ${envPrefix} ${agentCmd}`;
|
|
1371
1382
|
}
|
|
1372
1383
|
/**
|
|
1373
1384
|
* Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
|
|
@@ -1493,9 +1504,14 @@ async function runInTmux(options, executable, args) {
|
|
|
1493
1504
|
* "the session went away underneath us", so an attach that skips this cannot
|
|
1494
1505
|
* do better than assume success — which is exactly the defect EXEC-23b fixes.
|
|
1495
1506
|
*/
|
|
1507
|
+
let paneLaunchBinding;
|
|
1508
|
+
const bindCompletedPane = () => {
|
|
1509
|
+
recordCompletedLaunch(paneLaunchBinding);
|
|
1510
|
+
};
|
|
1496
1511
|
const resolveAfterAttach = async (pane) => {
|
|
1497
1512
|
const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1498
1513
|
if (after.dead) {
|
|
1514
|
+
bindCompletedPane();
|
|
1499
1515
|
// Positive proof of a genuinely completed pane (paneExitStatus confirmed
|
|
1500
1516
|
// dead) — capture its scrollback as refusal evidence for the caller
|
|
1501
1517
|
// (classifyClaudeRunRefusal etc.) BEFORE tearing the session down.
|
|
@@ -1633,10 +1649,11 @@ async function runInTmux(options, executable, args) {
|
|
|
1633
1649
|
// pid) reconciles by launchId. This pane's pid usually IS the agent pid,
|
|
1634
1650
|
// but the launchId join is robust even when it isn't.
|
|
1635
1651
|
launchId: options.env?.AGENT_LAUNCH_ID,
|
|
1636
|
-
terminalId:
|
|
1652
|
+
terminalId: launchIdentityEnv().AGENT_TERMINAL_ID,
|
|
1637
1653
|
tmuxPane: pane,
|
|
1638
1654
|
startedAtMs: Date.now(),
|
|
1639
1655
|
});
|
|
1656
|
+
paneLaunchBinding = captureLaunchBinding(panePid, options.env?.AGENT_LAUNCH_ID ?? '');
|
|
1640
1657
|
if (options.sessionId) {
|
|
1641
1658
|
writeSessionActorRecord({
|
|
1642
1659
|
sessionId: options.sessionId,
|
|
@@ -1653,6 +1670,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1653
1670
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1654
1671
|
const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1655
1672
|
if (before.dead) {
|
|
1673
|
+
bindCompletedPane();
|
|
1656
1674
|
// Positive proof of a genuinely completed pane — capture scrollback as
|
|
1657
1675
|
// refusal evidence before tearing the session down (see the matching
|
|
1658
1676
|
// comment in resolveAfterAttach).
|
|
@@ -2071,10 +2089,11 @@ async function spawnAgentLeased(options) {
|
|
|
2071
2089
|
actor: resolveActor().id,
|
|
2072
2090
|
initiatedBy: resolveActor().kind,
|
|
2073
2091
|
launchId,
|
|
2074
|
-
terminalId:
|
|
2092
|
+
terminalId: launchIdentityEnv().AGENT_TERMINAL_ID,
|
|
2075
2093
|
tmuxPane: process.env.TMUX_PANE,
|
|
2076
2094
|
startedAtMs: Date.now(),
|
|
2077
2095
|
});
|
|
2096
|
+
const launchBinding = captureLaunchBinding(child.pid, launchId);
|
|
2078
2097
|
if (options.sessionId) {
|
|
2079
2098
|
writeSessionActorRecord({
|
|
2080
2099
|
sessionId: options.sessionId,
|
|
@@ -2146,6 +2165,7 @@ async function spawnAgentLeased(options) {
|
|
|
2146
2165
|
reject(err);
|
|
2147
2166
|
});
|
|
2148
2167
|
child.on('close', (code) => {
|
|
2168
|
+
recordCompletedLaunch(launchBinding);
|
|
2149
2169
|
if (timeoutTimer)
|
|
2150
2170
|
clearTimeout(timeoutTimer);
|
|
2151
2171
|
// Clear the budget-kill SIGKILL escalation timer (mirror the --timeout
|
|
@@ -56,6 +56,15 @@ export class ActivityStream {
|
|
|
56
56
|
this.maxBytesPerRead = options.maxBytesPerRead ?? ACTIVITY_TAIL_BYTES;
|
|
57
57
|
this.sweepMs = options.sweepMs ?? ACTIVITY_SWEEP_MS;
|
|
58
58
|
this.watchRequested = options.watch ?? true;
|
|
59
|
+
// A log directory that does not exist yet (nothing has logged activity
|
|
60
|
+
// since boot) means fs.watch below throws ENOENT and never retries except
|
|
61
|
+
// on the bounded sweep cadence — the very first session's activity can
|
|
62
|
+
// then sit unwatched for up to `sweepMs` before it is even noticed.
|
|
63
|
+
// Creating it up front lets the watcher arm immediately.
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
catch { /* best-effort: sweep() covers a dir that still isn't there */ }
|
|
59
68
|
this.sweep(Date.now());
|
|
60
69
|
this.armWatcher();
|
|
61
70
|
}
|
|
@@ -136,6 +136,14 @@ export declare function appendActivityEvent(event: Omit<ActivityEvent, 'v' | 'ti
|
|
|
136
136
|
export declare function parseActivityLine(line: string): ActivityEvent | undefined;
|
|
137
137
|
/** Per-session byte budget for a bounded tail read of an activity log. */
|
|
138
138
|
export declare const ACTIVITY_TAIL_BYTES: number;
|
|
139
|
+
/** Read-only process-local diagnostics; no event content or file paths. */
|
|
140
|
+
export declare function getActivityCacheStats(): {
|
|
141
|
+
entries: number;
|
|
142
|
+
parsedTails: number;
|
|
143
|
+
bytes: number;
|
|
144
|
+
maxBytes: number;
|
|
145
|
+
tailReads: number;
|
|
146
|
+
};
|
|
139
147
|
/** Read all events for one session (bounded tail). */
|
|
140
148
|
export declare function readSessionActivity(sessionId: string, root?: string, maxBytes?: number): ActivityEvent[];
|
|
141
149
|
/** List session ids that have an activity log. */
|