@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/exec.d.ts +106 -1
  3. package/dist/commands/exec.js +10 -10
  4. package/dist/commands/sessions.d.ts +1 -0
  5. package/dist/commands/sessions.js +12 -0
  6. package/dist/lib/daemon/daemon.d.ts +0 -8
  7. package/dist/lib/daemon/daemon.js +41 -22
  8. package/dist/lib/daemon/testdata/process-view-daemon.d.ts +1 -0
  9. package/dist/lib/daemon/testdata/process-view-daemon.js +14 -0
  10. package/dist/lib/daemon/testdata/process-view-start.d.ts +1 -0
  11. package/dist/lib/daemon/testdata/process-view-start.js +43 -0
  12. package/dist/lib/event-provenance.d.ts +2 -0
  13. package/dist/lib/event-provenance.js +4 -0
  14. package/dist/lib/exec.js +27 -7
  15. package/dist/lib/feed/activity-stream.js +9 -0
  16. package/dist/lib/feed/activity.d.ts +8 -0
  17. package/dist/lib/feed/activity.js +61 -18
  18. package/dist/lib/feed/watch.d.ts +9 -0
  19. package/dist/lib/feed/watch.js +91 -3
  20. package/dist/lib/feed-post.js +1 -1
  21. package/dist/lib/hosts/dispatch.d.ts +0 -10
  22. package/dist/lib/hosts/dispatch.js +2 -19
  23. package/dist/lib/hosts/remote-session-id.d.ts +5 -27
  24. package/dist/lib/hosts/remote-session-id.js +18 -61
  25. package/dist/lib/launch-identity.d.ts +10 -0
  26. package/dist/lib/launch-identity.js +31 -0
  27. package/dist/lib/session/active.js +9 -3
  28. package/dist/lib/session/db.d.ts +2 -1
  29. package/dist/lib/session/db.js +39 -4
  30. package/dist/lib/session/discover.js +7 -2
  31. package/dist/lib/session/hook-sessions.d.ts +10 -0
  32. package/dist/lib/session/hook-sessions.js +139 -6
  33. package/dist/lib/session/pid-registry.d.ts +18 -5
  34. package/dist/lib/session/pid-registry.js +164 -17
  35. package/dist/lib/session/process-view.d.ts +25 -0
  36. package/dist/lib/session/process-view.js +176 -0
  37. package/dist/lib/session/projection.d.ts +13 -0
  38. package/dist/lib/session/projection.js +101 -0
  39. package/dist/lib/session/remote/watch.d.ts +8 -0
  40. package/dist/lib/session/remote/watch.js +7 -3
  41. package/dist/lib/session/session-cache.js +15 -0
  42. package/dist/lib/session/testdata/pid-registry-namespace.d.ts +1 -0
  43. package/dist/lib/session/testdata/pid-registry-namespace.js +27 -0
  44. package/dist/lib/session/testdata/process-view-namespace.d.ts +1 -0
  45. package/dist/lib/session/testdata/process-view-namespace.js +56 -0
  46. package/dist/lib/session/testdata/process-view-private.d.ts +1 -0
  47. package/dist/lib/session/testdata/process-view-private.js +12 -0
  48. 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.
@@ -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;
@@ -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
- return options.cwd;
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
- // --project <slug>[@worktree]: resolve the projects-root shorthand into a
1618
- // cwd. On a host run it resolves home-relative (`~/…`, so the host expands
1619
- // it); locally it becomes an absolute path. It owns the working directory,
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
+ }
@@ -10,6 +10,8 @@ export interface EventProvenance {
10
10
  agent?: string;
11
11
  launchId?: string;
12
12
  parentSessionId?: string;
13
+ parentLaunchId?: string;
14
+ originTerminalId?: string;
13
15
  }
14
16
  /**
15
17
  * Stamp the shared identity floor used by both operational and activity events.
@@ -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
- // Local-spawn scope here; forwarding it across the `--device` SSH hop is Phase 4.
407
+ // An SSH continuation has no runtime marker; keep its forwarded parent edge.
398
408
  delete result.AGENTS_PARENT_SESSION_ID;
399
- const spawnerSessionId = process.env.AGENTS_SESSION_ID || process.env.AGENT_SESSION_ID;
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: process.env.AGENT_TERMINAL_ID,
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: process.env.AGENT_TERMINAL_ID,
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: process.env.AGENT_TERMINAL_ID,
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. */