@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
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { getCacheDir, getTerminalsDir, getDaemonDir } from '../state.js';
5
+ import { atomicWriteFileSync, withFileLock } from '../fs-atomic.js';
6
+ // Linux UAPI PID_NS_INIT_INO (include/uapi/linux/nsfs.h; previously
7
+ // PROC_PID_INIT_INO in include/linux/proc_ns.h). This is a kernel-defined
8
+ // namespace identity, independent of PID 1's executable or command name.
9
+ function isInitialPidNamespace(view) {
10
+ return view.pidNamespace === 'pid:[4026531836]';
11
+ }
12
+ function sameProcessView(owner, view) {
13
+ return owner.bootId === view.bootId && owner.pidNamespace === view.pidNamespace
14
+ && owner.initStartTicks === view.initStartTicks;
15
+ }
16
+ /** Authenticate the legacy singleton with kernel socket credentials. A host
17
+ * daemon invisible in a nested namespace has peer PID 0, never a colliding PID.
18
+ * No socket/PID/command-name guess grants migration authority. */
19
+ function verifiedLegacyDaemon() {
20
+ try {
21
+ execFileSync('python3', ['-c', `import os,socket,struct,sys
22
+ s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
23
+ s.settimeout(0.2)
24
+ s.connect(sys.argv[1])
25
+ pid,uid,gid=struct.unpack('3i',s.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
26
+ assert pid>0 and uid==os.getuid()
27
+ assert pid==int(open(sys.argv[2]).read().strip())
28
+ assert os.readlink('/proc/%d/ns/pid'%pid)==os.readlink('/proc/self/ns/pid')
29
+ `, path.join(getCacheDir(), 'helpers', 'browser', 'browser.sock'), path.join(getDaemonDir(), 'daemon.pid')], {
30
+ stdio: 'ignore', timeout: 1000,
31
+ });
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function hasLegacyState() {
39
+ return [path.join(getTerminalsDir(), 'by-pid'), path.join(getCacheDir(), 'state', 'sessions')]
40
+ .some(dir => fs.existsSync(dir) && fs.readdirSync(dir).length > 0)
41
+ // Health/log output records attempted starts, not ownership. The ordinary
42
+ // start path writes health.json BEFORE spawning its first daemon.
43
+ || ['daemon.pid', 'daemon.lifetime', 'heartbeat.json'].some(file => fs.existsSync(path.join(getDaemonDir(), file)))
44
+ || fs.existsSync(path.join(getCacheDir(), 'helpers', 'browser', 'browser.sock'))
45
+ || ['.active-sessions.json', '.active-session-immutable.json'].some(file => fs.existsSync(path.join(getCacheDir(), file)));
46
+ }
47
+ /** Measure the calling process, never infer authority from a process name. */
48
+ export function currentProcessView() {
49
+ if (process.platform !== 'linux')
50
+ return {};
51
+ try {
52
+ if (Number(fs.readFileSync('/proc/self/stat', 'utf8').split(' ', 1)[0]) !== process.pid)
53
+ return undefined;
54
+ const bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
55
+ const pidNamespace = fs.readlinkSync('/proc/self/ns/pid');
56
+ // NSpid is relative to the procfs mount. Multiple coordinates mean this
57
+ // mount exposes an ancestor namespace, even if numeric PIDs happen to match.
58
+ const procPids = fs.readFileSync('/proc/self/status', 'utf8').match(/^NSpid:\s+([^\n]+)$/m)?.[1].trim().split(/\s+/);
59
+ if (procPids?.length !== 1 || Number(procPids[0]) !== process.pid)
60
+ return undefined;
61
+ const initStat = fs.readFileSync('/proc/1/stat', 'utf8');
62
+ const initStartTicks = initStat.slice(initStat.lastIndexOf(')') + 2).split(' ')[19];
63
+ const stat = fs.readFileSync('/proc/self/stat', 'utf8');
64
+ const ownerStartTicks = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
65
+ return bootId && pidNamespace && /^\d+$/.test(initStartTicks) && /^\d+$/.test(ownerStartTicks)
66
+ ? { bootId, pidNamespace, initStartTicks, ownerPid: process.pid, ownerStartTicks } : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /** Read-only ownership check. Observing a HOME never claims or migrates it. */
73
+ export function hostProcessView() {
74
+ const view = currentProcessView();
75
+ if (!view || process.platform !== 'linux')
76
+ return view;
77
+ try {
78
+ const owner = JSON.parse(fs.readFileSync(path.join(getTerminalsDir(), 'process-view.json'), 'utf8'));
79
+ return sameProcessView(owner, view) ? view : undefined;
80
+ }
81
+ catch (error) {
82
+ // The initial kernel namespace is authoritative before any daemon has
83
+ // enrolled this HOME. This observation itself must remain read-only.
84
+ return error.code === 'ENOENT' && isInitialPidNamespace(view) ? view : undefined;
85
+ }
86
+ }
87
+ /** Explicit writers may enroll a fresh HOME in their measured namespace.
88
+ * Foreign ownership remains protected even when the prior writer is invisible.
89
+ * The initial host may enroll legacy state before daemon startup. */
90
+ export function writerProcessView() {
91
+ const view = currentProcessView();
92
+ if (!view || process.platform !== 'linux')
93
+ return view;
94
+ const file = path.join(getTerminalsDir(), 'process-view.json');
95
+ try {
96
+ if (fs.existsSync(file)) {
97
+ const owner = JSON.parse(fs.readFileSync(file, 'utf8'));
98
+ return sameProcessView(owner, view) ? view : undefined;
99
+ }
100
+ // A foreign observer of legacy state must not even create a directory or
101
+ // claim lock. Repeat this preflight under the lock before enrollment.
102
+ if (hasLegacyState() && !isInitialPidNamespace(view))
103
+ return undefined;
104
+ fs.mkdirSync(getTerminalsDir(), { recursive: true });
105
+ return withFileLock(file, () => {
106
+ if (fs.existsSync(file)) {
107
+ const owner = JSON.parse(fs.readFileSync(file, 'utf8'));
108
+ return sameProcessView(owner, view) ? view : undefined;
109
+ }
110
+ // Unowned nonempty registry is not evidence this caller owns the host.
111
+ if (hasLegacyState() && !isInitialPidNamespace(view))
112
+ return undefined;
113
+ atomicWriteFileSync(file, JSON.stringify(view), 'utf8');
114
+ return view;
115
+ }, { realpath: false, acquireTimeoutMs: 0 });
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
121
+ /** Preflight BEFORE even the PID-keyed legacy lifecycle lock is inspected.
122
+ * The same decision is repeated under that lock before daemon state changes. */
123
+ export function daemonProcessViewAllowed() {
124
+ const view = currentProcessView();
125
+ if (!view)
126
+ return false;
127
+ if (process.platform !== 'linux')
128
+ return true;
129
+ try {
130
+ const file = path.join(getTerminalsDir(), 'process-view.json');
131
+ if (!fs.existsSync(file))
132
+ return !hasLegacyState() || isInitialPidNamespace(view) || verifiedLegacyDaemon();
133
+ const owner = JSON.parse(fs.readFileSync(file, 'utf8'));
134
+ if (sameProcessView(owner, view))
135
+ return true;
136
+ return typeof owner.bootId === 'string' && !!owner.bootId && owner.bootId !== view.bootId
137
+ && (isInitialPidNamespace(view) || verifiedLegacyDaemon());
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ /** Called by the canonical daemon writer before lifecycle mutation.
144
+ * This is a writer statement, not an observer's guess about a numeric PID.
145
+ * Existing same-boot ownership cannot be taken over from another namespace. */
146
+ export function recordDaemonProcessView() {
147
+ const view = currentProcessView();
148
+ if (!view)
149
+ throw new Error('Cannot record daemon ownership from an incoherent process namespace');
150
+ if (process.platform !== 'linux')
151
+ return;
152
+ const file = path.join(getTerminalsDir(), 'process-view.json');
153
+ fs.mkdirSync(getTerminalsDir(), { recursive: true });
154
+ withFileLock(file, () => {
155
+ if (fs.existsSync(file)) {
156
+ const owner = JSON.parse(fs.readFileSync(file, 'utf8'));
157
+ if (sameProcessView(owner, view))
158
+ return;
159
+ if (typeof owner.bootId !== 'string' || !owner.bootId || owner.bootId === view.bootId || (!isInitialPidNamespace(view) && !verifiedLegacyDaemon())) {
160
+ throw new Error('Another or unverified process namespace owns host session state; automatic reuse of a private-container HOME across namespaces is unsupported. Run in the owning namespace or use a fresh HOME.');
161
+ }
162
+ }
163
+ else if (hasLegacyState() && !isInitialPidNamespace(view) && !verifiedLegacyDaemon()) {
164
+ throw new Error('Legacy session state requires a verified live canonical daemon before namespace migration');
165
+ }
166
+ atomicWriteFileSync(file, JSON.stringify(view), 'utf8');
167
+ }, { realpath: false, acquireTimeoutMs: 0 });
168
+ }
169
+ export function requireHostProcessView() {
170
+ if (!hostProcessView())
171
+ throw new Error('Host session state is unavailable in this process namespace; read the host daemon snapshot or run sessions on the host.');
172
+ }
173
+ export function requireWriterProcessView() {
174
+ if (!writerProcessView())
175
+ throw new Error('Host session state is unavailable in this process namespace; run the writer in its owning namespace or use a fresh HOME. Automatic reuse of a private-container HOME across namespaces is unsupported.');
176
+ }
@@ -0,0 +1,13 @@
1
+ import type { SessionWatchEnvelope, SessionWatchRow } from './watch.js';
2
+ /** Device observations are not sessions: a launcher may observe a peer's run.
3
+ * Keep those observations separately and publish execution-owned state once. */
4
+ export declare class SessionProjection {
5
+ private readonly observations;
6
+ private projected;
7
+ private selected;
8
+ private sequence;
9
+ private readonly streamId;
10
+ /** Only the selected observation may publish execution-owned attention. */
11
+ rowForObservation(scope: string, rowKey: string): SessionWatchRow | undefined;
12
+ apply(event: SessionWatchEnvelope): SessionWatchEnvelope[];
13
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { normalizeHost } from '../machine-id.js';
3
+ /** Device observations are not sessions: a launcher may observe a peer's run.
4
+ * Keep those observations separately and publish execution-owned state once. */
5
+ export class SessionProjection {
6
+ observations = new Map();
7
+ projected = new Map();
8
+ selected = new Map();
9
+ sequence = 0;
10
+ streamId = randomUUID();
11
+ /** Only the selected observation may publish execution-owned attention. */
12
+ rowForObservation(scope, rowKey) {
13
+ const key = this.selected.get(`${normalizeHost(scope)}\0${rowKey}`);
14
+ return key ? this.projected.get(key) : undefined;
15
+ }
16
+ apply(event) {
17
+ const scope = normalizeHost(event.scope);
18
+ if (event.type === 'reset')
19
+ this.observations.set(scope, new Map(event.rows.map(row => [row.rowKey, row])));
20
+ if (event.type === 'upsert') {
21
+ if (!this.observations.has(scope))
22
+ this.observations.set(scope, new Map());
23
+ this.observations.get(scope).set(event.rowKey, event.row);
24
+ }
25
+ if (event.type === 'remove')
26
+ this.observations.get(scope)?.delete(event.rowKey);
27
+ const stamp = () => ({ version: 1, streamId: this.streamId, sequence: ++this.sequence, capturedAt: event.capturedAt });
28
+ if (event.type === 'scope' || event.type === 'heartbeat') {
29
+ // An unavailable owner retains its last observation until its next reset.
30
+ return [{ ...event, ...stamp(), scope }];
31
+ }
32
+ // A launch exists before a non-Claude harness mints its session id. Join an
33
+ // id-less placeholder only through an exact, unambiguous durable launch id.
34
+ const launchSessions = new Map();
35
+ for (const [observer, rows] of this.observations)
36
+ for (const row of rows.values()) {
37
+ if (!row.launchId || !row.sessionId)
38
+ continue;
39
+ const owner = normalizeHost(row.machine || row.sourceDevice || observer);
40
+ const launch = `${owner}\0${row.kind}\0${row.launchId}`;
41
+ const ids = launchSessions.get(launch) ?? new Set();
42
+ ids.add(row.sessionId);
43
+ launchSessions.set(launch, ids);
44
+ }
45
+ const groups = new Map();
46
+ for (const [observer, rows] of this.observations)
47
+ for (const row of rows.values()) {
48
+ const owner = normalizeHost(row.machine || row.sourceDevice || observer);
49
+ const launchIds = row.launchId ? launchSessions.get(`${owner}\0${row.kind}\0${row.launchId}`) : undefined;
50
+ const sessionId = row.sessionId || (launchIds?.size === 1 ? [...launchIds][0] : undefined);
51
+ const identity = sessionId ? `${row.kind}\0${sessionId}` : `${observer}\0${row.rowKey}`;
52
+ const key = createHash('sha256').update(`${owner}\0${identity}`).digest('base64url').slice(0, 22);
53
+ const group = groups.get(key) ?? [];
54
+ group.push({ scope: observer, row });
55
+ groups.set(key, group);
56
+ }
57
+ const next = new Map();
58
+ const selected = new Map();
59
+ for (const [rowKey, observations] of groups) {
60
+ const owner = normalizeHost(observations[0].row.machine || observations[0].row.sourceDevice || observations[0].scope);
61
+ const authoritative = observations.filter(item => item.scope === owner);
62
+ // Once the owner has answered, its absence is authoritative too. A stale
63
+ // launcher/history mirror cannot resurrect a removed execution.
64
+ const candidates = authoritative.length ? authoritative : this.observations.has(owner) ? [] : observations;
65
+ candidates.sort((a, b) => Number(a.row.previous) - Number(b.row.previous)
66
+ || (b.row.lastActivityMs ?? b.row.startedAtMs ?? 0) - (a.row.lastActivityMs ?? a.row.startedAtMs ?? 0)
67
+ || a.row.rowKey.localeCompare(b.row.rowKey));
68
+ const candidate = candidates[0];
69
+ const chosen = candidate?.row;
70
+ if (!chosen)
71
+ continue;
72
+ selected.set(`${candidate.scope}\0${chosen.rowKey}`, rowKey);
73
+ const observerTerminals = observations.filter(item => !item.row.previous && (item.row.terminalId || item.row.viewingIn || item.row.provenance?.reply))
74
+ .map(({ scope: device, row }) => ({ device, terminalId: row.terminalId, launchId: row.launchId, viewingIn: row.viewingIn, provenance: row.provenance }))
75
+ .sort((a, b) => a.device.localeCompare(b.device) || (a.terminalId ?? '').localeCompare(b.terminalId ?? ''));
76
+ next.set(rowKey, {
77
+ ...chosen, rowKey, sourceDevice: owner, machine: owner,
78
+ observerTerminals,
79
+ recovery: chosen.resumable && chosen.sessionId
80
+ ? { command: 'agents', args: ['sessions', 'resume', chosen.sessionId, '--device', owner], ...(chosen.cwd ? { cwd: chosen.cwd } : {}) }
81
+ : null,
82
+ });
83
+ }
84
+ const result = [];
85
+ // A reset still replaces only its source scope. Cross-scope migrations are
86
+ // explicit removes/upserts, so existing clients converge without a new API.
87
+ if (event.type === 'reset')
88
+ result.push({ ...stamp(), type: 'reset', scope, rows: [...next.values()].filter(row => row.sourceDevice === scope) });
89
+ for (const [key, row] of this.projected)
90
+ if (!next.has(key) && !(event.type === 'reset' && row.sourceDevice === scope)) {
91
+ result.push({ ...stamp(), type: 'remove', scope: row.sourceDevice, rowKey: key });
92
+ }
93
+ for (const [key, row] of next)
94
+ if (!(event.type === 'reset' && row.sourceDevice === scope) && JSON.stringify(this.projected.get(key)) !== JSON.stringify(row)) {
95
+ result.push({ ...stamp(), type: 'upsert', scope: row.sourceDevice, rowKey: key, row });
96
+ }
97
+ this.projected = next;
98
+ this.selected = selected;
99
+ return result;
100
+ }
101
+ }
@@ -49,6 +49,14 @@ export type SessionWatchEnvelope = {
49
49
  };
50
50
  export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn' | 'context'> {
51
51
  context: ActiveSession['context'] | 'recent';
52
+ /** Observer-local terminal/reply facts; never overwrite execution-owner state. */
53
+ observerTerminals?: {
54
+ device: string;
55
+ terminalId?: string;
56
+ launchId?: string;
57
+ viewingIn: string | null;
58
+ provenance?: ActiveSession['provenance'];
59
+ }[];
52
60
  /** Flat durable branch for history rows that are not currently in a worktree. */
53
61
  branch?: string;
54
62
  rowKey: string;
@@ -1,3 +1,4 @@
1
+ import { SessionProjection } from '../projection.js';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import * as fs from 'node:fs';
3
4
  import * as path from 'node:path';
@@ -421,7 +422,10 @@ function remoteWatchCommand(os) {
421
422
  * peer resets only its own scope. There is no recurring fleet list command.
422
423
  */
423
424
  export async function watchFleetSessions(options) {
424
- const local = watchLocalSessions({ scope: machineId(), signal: options.signal, emit: options.emit });
425
+ const projection = new SessionProjection();
426
+ const emit = (event) => { for (const projected of projection.apply(event))
427
+ options.emit(projected); };
428
+ const local = watchLocalSessions({ scope: machineId(), signal: options.signal, emit });
425
429
  let devices;
426
430
  try {
427
431
  devices = await loadDevices();
@@ -447,14 +451,14 @@ export async function watchFleetSessions(options) {
447
451
  const event = JSON.parse(line);
448
452
  if (event.version !== SESSION_WATCH_VERSION || typeof event.sequence !== 'number')
449
453
  return false;
450
- options.emit(event);
454
+ emit(event);
451
455
  return true;
452
456
  }
453
457
  catch {
454
458
  return false; /* incomplete/non-protocol peer output is not state */
455
459
  }
456
460
  },
457
- onUnavailable: (reason) => options.emit(state.scope(scope, 'unavailable', reason)),
461
+ onUnavailable: (reason) => emit(state.scope(scope, 'unavailable', reason)),
458
462
  });
459
463
  });
460
464
  await Promise.all([local, ...peerTasks]);
@@ -24,6 +24,7 @@
24
24
  * through to a live gather.
25
25
  */
26
26
  import * as fs from 'fs';
27
+ import { writerProcessView, requireWriterProcessView } from './process-view.js';
27
28
  import * as path from 'path';
28
29
  import { createMemoryCache } from '../memory-cache.js';
29
30
  import { getCacheDir } from '../state.js';
@@ -248,6 +249,7 @@ export function readActiveSessionsCache(scope) {
248
249
  * so a local warm never drops a fleet snapshot a reader just wrote.
249
250
  */
250
251
  export function writeActiveSessionsCache(scope, sessions, opts = {}) {
252
+ requireWriterProcessView();
251
253
  const snap = {
252
254
  version: 1,
253
255
  scope,
@@ -412,6 +414,7 @@ export function assertNoLiveStatusFields(fields) {
412
414
  * refill identity fields without re-deriving them.
413
415
  */
414
416
  export function updateImmutableMemos(sessions, nowMs = Date.now()) {
417
+ requireWriterProcessView();
415
418
  for (const s of sessions) {
416
419
  if (!s.sessionId)
417
420
  continue;
@@ -534,6 +537,12 @@ export async function loadLocalActiveSessions(opts = {}) {
534
537
  const maxAge = opts.maxAgeMs ?? DEFAULT_ACTIVE_CACHE_MAX_AGE_MS;
535
538
  const readCache = opts.readCache ?? readActiveSessionsCache;
536
539
  const writeCache = opts.writeCache ?? writeActiveSessionsCache;
540
+ if (!writerProcessView()) {
541
+ const cached = readCache('local');
542
+ if (cached)
543
+ return { sessions: cached.sessions, servedFromCache: true, capturedAt: cached.capturedAt };
544
+ requireWriterProcessView();
545
+ }
537
546
  if (!opts.forceRefresh) {
538
547
  const cached = readCache('local');
539
548
  if (cached && isActiveSnapshotFresh(cached.capturedAt, now, maxAge)) {
@@ -570,6 +579,12 @@ export async function loadFleetActiveSessions(opts) {
570
579
  const maxAge = opts.maxAgeMs ?? DEFAULT_ACTIVE_CACHE_MAX_AGE_MS;
571
580
  const readCache = opts.readCache ?? readActiveSessionsCache;
572
581
  const writeCache = opts.writeCache ?? writeActiveSessionsCache;
582
+ if (!writerProcessView()) {
583
+ const cached = readCache('fleet');
584
+ if (cached)
585
+ return { sessions: cached.sessions, remoteDeviceCount: cached.remoteDeviceCount ?? 0, servedFromCache: true, capturedAt: cached.capturedAt };
586
+ requireWriterProcessView();
587
+ }
573
588
  if (!opts.forceRefresh) {
574
589
  const cached = readCache('fleet');
575
590
  if (cached && isActiveSnapshotFresh(cached.capturedAt, now, maxAge)) {
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import { prunePidSessionRegistry, readPidSessionEntry, writePidSessionEntry } from '../pid-registry.js';
4
+ import { getActiveSessions } from '../active.js';
5
+ import { loadLocalActiveSessions, loadFleetActiveSessions, writeActiveSessionsCache, updateImmutableMemos, readActiveSessionsCache } from '../session-cache.js';
6
+ // Invoked only by the isolated-HOME test fork, inside a real unshare namespace.
7
+ const hostPid = Number(process.argv[2]);
8
+ // Names convey no authority: this is PID 1 with its own procfs in one variant.
9
+ fs.writeFileSync('/proc/self/comm', 'init');
10
+ assert.equal(readPidSessionEntry(hostPid)?.sessionId, 'host-session');
11
+ assert.throws(() => process.kill(hostPid, 0), { code: 'ESRCH' });
12
+ writePidSessionEntry({ pid: hostPid, agent: 'codex', sessionId: 'namespace-collision', startedAtMs: Date.now() });
13
+ assert.equal(readPidSessionEntry(hostPid)?.sessionId, 'host-session');
14
+ prunePidSessionRegistry(() => false);
15
+ assert.equal(readPidSessionEntry(hostPid)?.sessionId, 'host-session');
16
+ const local = await loadLocalActiveSessions({ forceRefresh: true, gather: async () => { throw new Error('partial gather must never run'); } });
17
+ assert.equal(local.sessions[0].sessionId, 'host-session');
18
+ assert.equal(local.capturedAt, 1);
19
+ assert.equal(local.servedFromCache, true);
20
+ const fleet = await loadFleetActiveSessions({ forceRefresh: true, gather: async () => { throw new Error('partial fleet gather must never run'); } });
21
+ assert.equal(fleet.remoteDeviceCount, 2);
22
+ assert.equal(fleet.capturedAt, 1);
23
+ assert.equal((await getActiveSessions())[0].sessionId, 'host-session');
24
+ assert.throws(() => writeActiveSessionsCache('local', []), /process namespace/);
25
+ assert.throws(() => updateImmutableMemos([]), /process namespace/);
26
+ assert.equal(readActiveSessionsCache('local')?.capturedAt, 1);
27
+ console.log('host pid: ESRCH; overwrite: refused; registry and snapshots: retained');
@@ -0,0 +1,56 @@
1
+ import fs from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { getTerminalsDir } from '../../state.js';
4
+ import { hostProcessView, requireWriterProcessView, recordDaemonProcessView } from '../process-view.js';
5
+ const mode = process.argv[2];
6
+ try {
7
+ if (mode === 'hook-first') {
8
+ const hook = process.env.AGENTS_SESSION_IDENTITY_TEST_HOOK;
9
+ const child = hook
10
+ ? spawnSync('bash', [hook], { input: JSON.stringify({ session_id: 'init-hook', cwd: process.cwd() }), encoding: 'utf8', env: process.env })
11
+ : spawnSync('bun', [import.meta.filename, 'once'], { encoding: 'utf8', env: process.env });
12
+ if (child.status !== 0)
13
+ throw new Error(child.stderr);
14
+ if (!fs.existsSync(`${getTerminalsDir()}/process-view.json`))
15
+ throw new Error('child did not enroll its namespace');
16
+ }
17
+ if (mode === 'daemon')
18
+ recordDaemonProcessView();
19
+ if (mode === 'read') {
20
+ console.log(hostProcessView() ? 'owned' : 'unowned');
21
+ process.exit(0);
22
+ }
23
+ if (mode === 'publish' || mode === 'snapshot') {
24
+ const { loadLocalActiveSessions, loadFleetActiveSessions, writeActiveSessionsCache } = await import('../session-cache.js');
25
+ if (mode === 'publish') {
26
+ writeActiveSessionsCache('local', [], { capturedAt: 1 });
27
+ writeActiveSessionsCache('fleet', [], { capturedAt: 1 });
28
+ }
29
+ else {
30
+ const gather = async () => { throw new Error('foreign observer gathered'); };
31
+ const local = await loadLocalActiveSessions({ forceRefresh: true, gather });
32
+ const fleet = await loadFleetActiveSessions({ forceRefresh: true, gather });
33
+ if (!local.servedFromCache || !fleet.servedFromCache || local.capturedAt !== 1 || fleet.capturedAt !== 1)
34
+ throw new Error('foreign snapshot changed');
35
+ console.log('observed');
36
+ process.exit(0);
37
+ }
38
+ }
39
+ if (mode === 'sessions') {
40
+ const { loadLocalActiveSessions } = await import('../session-cache.js');
41
+ const result = await loadLocalActiveSessions({ forceRefresh: true });
42
+ if (result.servedFromCache || !Array.isArray(result.sessions))
43
+ throw new Error('cold discovery did not run');
44
+ }
45
+ requireWriterProcessView();
46
+ console.log('owned');
47
+ if (mode === 'hold' || mode === 'hook-first') {
48
+ process.on('SIGTERM', () => process.exit(0));
49
+ setInterval(() => { }, 1000);
50
+ }
51
+ }
52
+ catch (error) {
53
+ console.error(error.message);
54
+ console.log('denied');
55
+ process.exitCode = 2;
56
+ }
@@ -0,0 +1,12 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import { writePidSessionEntry, readLivePidSessionEntry } from '../pid-registry.js';
4
+ import { writeActiveSessionsCache, readActiveSessionsCache } from '../session-cache.js';
5
+ // A container owns its private home regardless of the name of PID 1.
6
+ assert.equal(process.pid, 1);
7
+ fs.writeFileSync('/proc/self/comm', process.argv[2]);
8
+ writePidSessionEntry({ pid: process.pid, agent: 'codex', sessionId: 'private-home', startedAtMs: Date.now() });
9
+ assert.equal(readLivePidSessionEntry(process.pid)?.sessionId, 'private-home');
10
+ writeActiveSessionsCache('local', [{ context: 'headless', kind: 'codex', sessionId: 'private-home', status: 'running' }]);
11
+ assert.equal(readActiveSessionsCache('local')?.sessions[0]?.sessionId, 'private-home');
12
+ console.log('private home: registry and snapshot published');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.111",
3
+ "version": "1.22.113",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",