@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
@@ -823,7 +823,8 @@ function collectChangedFilesInLeafDirs(leafDirs, ext) {
823
823
  let hot = isLiveRoot;
824
824
  if (!hot && stamps) {
825
825
  const s = stamps.get(filePath);
826
- hot = s?.scannedAt !== undefined && now - s.scannedAt <= HOT_FILE_WINDOW_MS;
826
+ hot = s?.extractorVersion !== CONTENT_INDEX_VERSION
827
+ || (s?.scannedAt !== undefined && now - s.scannedAt <= HOT_FILE_WINDOW_MS);
827
828
  }
828
829
  if (!hot)
829
830
  continue;
@@ -3884,7 +3885,11 @@ function applyCodexLine(state, parsed) {
3884
3885
  }
3885
3886
  if (parsed.type === 'session_meta') {
3886
3887
  const payload = parsed.payload || {};
3887
- state.sessionId = payload.id || state.sessionId;
3888
+ // Native forks prepend child ownership before inherited parent metadata.
3889
+ // The first valid identity owns this file in both full and resumed scans.
3890
+ if (state.sessionId || typeof payload.id !== 'string' || !payload.id.trim())
3891
+ return;
3892
+ state.sessionId = payload.id;
3888
3893
  state.timestamp = payload.timestamp || parsed.timestamp || state.timestamp;
3889
3894
  state.cwd = payload.cwd || state.cwd;
3890
3895
  state.gitBranch = payload.git?.branch || state.gitBranch;
@@ -1,3 +1,4 @@
1
+ import { type PidSessionEntry } from './pid-registry.js';
1
2
  /** The subset of the hook's on-disk record this reader relies on. Extra fields are ignored. */
2
3
  export interface HookSessionRecord {
3
4
  session_id: string;
@@ -7,6 +8,7 @@ export interface HookSessionRecord {
7
8
  launch_id?: string;
8
9
  terminal_id?: string;
9
10
  ts?: number;
11
+ completed?: boolean;
10
12
  }
11
13
  /** Pre-built lookup maps over one scan of the hook state dir. */
12
14
  export interface HookSessionIndex {
@@ -23,6 +25,14 @@ export interface HookSessionIndex {
23
25
  * Unix SECONDS (the hook stamps `date +%s`); `startedAtMs` is millis.
24
26
  */
25
27
  export declare function readStateSessionRecord(pid: number, startedAtMs?: number): HookSessionRecord | undefined;
28
+ /** Capture ownership while the launched wrapper is alive. The deployed hook
29
+ * walks ancestors to this registry entry and preserves these launcher fields. */
30
+ export declare function captureLaunchBinding(pid: number | undefined, launchId: string): PidSessionEntry | undefined;
31
+ /** Completion consumes the hook's launcher-keyed registry update, never a
32
+ * numeric-PID metadata guess. The hook PID can be any descendant of the wrapper.
33
+ * Ownership was measured before exit; matching the unique launch and preserved
34
+ * start timestamp prevents a reused slot from supplying another run's id. */
35
+ export declare function recordCompletedLaunch(binding: PidSessionEntry | undefined): void;
26
36
  /**
27
37
  * Scan the hook state dir once and index every record by launch_id, terminal_id,
28
38
  * and pid. Returns empty maps if the dir is absent. Newest-by-`ts` wins a key
@@ -19,7 +19,8 @@
19
19
  * NOT import the session-tracker package (a separate, workspace-less package;
20
20
  * see packages/session-tracker/CLAUDE.md) — mirroring AGI EXT's own
21
21
  * hand-copied reader in apps/ext/src/core/liveSession.ts. It only READS the
22
- * existing dir/schema; it never writes or moves anything, so it is safe across a
22
+ * existing dir/schema. The owning launcher also records a completed launch
23
+ * binding in that schema before returning; readers remain read-only across a
23
24
  * fleet where old hooks and a new CLI coexist.
24
25
  *
25
26
  * The whole state dir is scanned ONCE per active-scan into an index (the listing
@@ -27,8 +28,13 @@
27
28
  * re-scan per candidate.
28
29
  */
29
30
  import fs from 'fs';
31
+ import { createHash } from 'node:crypto';
32
+ import { execFileSync } from 'node:child_process';
33
+ import { atomicWriteFileSync } from '../fs-atomic.js';
34
+ import { hostProcessView } from './process-view.js';
30
35
  import path from 'path';
31
36
  import { getTerminalsDir, getRuntimeStateDir } from '../state.js';
37
+ import { listPidSessionEntries, pidSessionEntryMatchesLiveProcess, readLivePidSessionEntry, readPidSessionEntry } from './pid-registry.js';
32
38
  function hookSessionsDir() {
33
39
  // Sibling of the pid-registry's by-pid/ dir. The hook hardcodes this same path
34
40
  // (packages/session-tracker/src/hook.sh); we read it, never move it.
@@ -97,6 +103,98 @@ function keepNewest(map, key, rec) {
97
103
  if (!prev || (rec.ts ?? 0) > (prev.ts ?? 0))
98
104
  map.set(key, rec);
99
105
  }
106
+ let clockTicks;
107
+ /** Recover an already-running launch after an old reader removed its registry.
108
+ * Enumerate live PIDs, not the hook's unbounded graveyard. Only decode the one
109
+ * allowed environment key; no unrelated environment value escapes this reader.
110
+ * Kernel incarnation and namespace are checked on both sides of the reads. */
111
+ function liveUnregisteredHookRecords() {
112
+ const scope = hostProcessView();
113
+ if (process.platform !== 'linux' || !scope?.pidNamespace)
114
+ return [];
115
+ try {
116
+ clockTicks ??= Number(execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1000 }));
117
+ if (!Number.isFinite(clockTicks) || clockTicks <= 0)
118
+ return [];
119
+ const bootSeconds = Number(fs.readFileSync('/proc/stat', 'utf8').match(/^btime (\d+)$/m)?.[1]);
120
+ if (!Number.isFinite(bootSeconds))
121
+ return [];
122
+ const result = [];
123
+ const identity = (pid) => {
124
+ if (fs.readlinkSync(`/proc/${pid}/ns/pid`) !== scope.pidNamespace)
125
+ return undefined;
126
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
127
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
128
+ return fields[0] !== 'Z' && /^\d+$/.test(fields[19]) ? fields[19] : undefined;
129
+ };
130
+ const prefix = Buffer.from('AGENT_LAUNCH_ID=');
131
+ for (const name of fs.readdirSync('/proc')) {
132
+ if (!/^\d+$/.test(name))
133
+ continue;
134
+ const pid = Number(name);
135
+ try {
136
+ if (!fs.existsSync(stateSessionRecordPath(pid)))
137
+ continue;
138
+ const before = identity(pid);
139
+ if (!before)
140
+ continue;
141
+ const rec = readStateSessionRecord(pid);
142
+ const startedSeconds = bootSeconds + Number(before) / clockTicks;
143
+ if (!rec || rec.pid !== pid || typeof rec.ts !== 'number'
144
+ || rec.ts < Math.floor(startedSeconds) || rec.ts > Date.now() / 1000)
145
+ continue;
146
+ const environment = fs.readFileSync(`/proc/${pid}/environ`);
147
+ let launchId;
148
+ for (let start = 0; start < environment.length;) {
149
+ const end = environment.indexOf(0, start);
150
+ const limit = end < 0 ? environment.length : end;
151
+ if (environment.subarray(start, start + prefix.length).equals(prefix)) {
152
+ launchId = environment.subarray(start + prefix.length, limit).toString('utf8').trim();
153
+ break;
154
+ }
155
+ start = limit + 1;
156
+ }
157
+ if (!launchId || launchId.length > 512 || /[\x00-\x20]/.test(launchId)
158
+ || (rec.launch_id && rec.launch_id !== launchId) || identity(pid) !== before)
159
+ continue;
160
+ result.push({ ...rec, launch_id: launchId });
161
+ }
162
+ catch { /* exited, inaccessible, or procfs raced: unknown, never prune */ }
163
+ }
164
+ return result;
165
+ }
166
+ catch {
167
+ return [];
168
+ }
169
+ }
170
+ /** Capture ownership while the launched wrapper is alive. The deployed hook
171
+ * walks ancestors to this registry entry and preserves these launcher fields. */
172
+ export function captureLaunchBinding(pid, launchId) {
173
+ if (!pid)
174
+ return undefined;
175
+ const entry = readLivePidSessionEntry(pid);
176
+ return entry?.launchId === launchId ? structuredClone(entry) : undefined;
177
+ }
178
+ /** Completion consumes the hook's launcher-keyed registry update, never a
179
+ * numeric-PID metadata guess. The hook PID can be any descendant of the wrapper.
180
+ * Ownership was measured before exit; matching the unique launch and preserved
181
+ * start timestamp prevents a reused slot from supplying another run's id. */
182
+ export function recordCompletedLaunch(binding) {
183
+ if (!binding?.launchId || !hostProcessView())
184
+ return;
185
+ const entry = readPidSessionEntry(binding.pid);
186
+ if (!entry?.sessionId || entry.pid !== binding.pid || entry.launchId !== binding.launchId
187
+ || entry.startedAtMs !== binding.startedAtMs || entry.agent !== binding.agent)
188
+ return;
189
+ const rec = { session_id: entry.sessionId, pid: entry.pid, agent: entry.agent,
190
+ launch_id: binding.launchId, terminal_id: binding.terminalId, ts: Date.now() / 1000, completed: true };
191
+ try {
192
+ fs.mkdirSync(hookSessionsDir(), { recursive: true });
193
+ const name = createHash('sha256').update(binding.launchId).digest('hex');
194
+ atomicWriteFileSync(path.join(hookSessionsDir(), `launch-${name}.json`), JSON.stringify(rec), 'utf8');
195
+ }
196
+ catch { /* completion must preserve the child exit status */ }
197
+ }
100
198
  /**
101
199
  * Scan the hook state dir once and index every record by launch_id, terminal_id,
102
200
  * and pid. Returns empty maps if the dir is absent. Newest-by-`ts` wins a key
@@ -111,7 +209,7 @@ export function loadHookSessionIndex() {
111
209
  files = fs.readdirSync(hookSessionsDir()).filter(f => f.endsWith('.json'));
112
210
  }
113
211
  catch {
114
- return { byLaunchId, byTerminalId, byPid };
212
+ files = [];
115
213
  }
116
214
  for (const f of files) {
117
215
  let rec;
@@ -123,13 +221,47 @@ export function loadHookSessionIndex() {
123
221
  }
124
222
  if (!rec)
125
223
  continue;
126
- if (typeof rec.pid === 'number')
224
+ if (!rec.completed && typeof rec.pid === 'number')
127
225
  keepNewest(byPid, rec.pid, rec);
128
226
  if (rec.launch_id)
129
227
  keepNewest(byLaunchId, rec.launch_id, rec);
130
- if (rec.terminal_id)
228
+ if (!rec.completed && rec.terminal_id)
131
229
  keepNewest(byTerminalId, rec.terminal_id, rec);
132
230
  }
231
+ // Deployed legacy hooks contain only a pid/session id. Join their targeted
232
+ // records with launch facts instead of requiring a terminal_id they never
233
+ // wrote. This is read-only: an observer cannot infer host PID liveness.
234
+ for (const entry of listPidSessionEntries()) {
235
+ if (!entry.launchId || pidSessionEntryMatchesLiveProcess(entry) !== true)
236
+ continue;
237
+ const rec = entry.sessionId
238
+ ? { session_id: entry.sessionId, pid: entry.pid, ts: entry.startedAtMs / 1000, agent: entry.agent }
239
+ : readStateSessionRecord(entry.pid, entry.startedAtMs);
240
+ if (!rec || !kindMatches(rec.agent, entry.agent))
241
+ continue;
242
+ if (rec.launch_id && rec.launch_id !== entry.launchId)
243
+ continue;
244
+ const joined = { ...rec, agent: rec.agent ?? entry.agent, launch_id: entry.launchId, terminal_id: entry.terminalId };
245
+ if (!byLaunchId.has(entry.launchId))
246
+ byLaunchId.set(entry.launchId, joined);
247
+ if (!byPid.has(entry.pid))
248
+ byPid.set(entry.pid, joined);
249
+ if (entry.terminalId && !byTerminalId.has(entry.terminalId))
250
+ byTerminalId.set(entry.terminalId, joined);
251
+ }
252
+ const recovered = new Map();
253
+ for (const rec of liveUnregisteredHookRecords()) {
254
+ if (!byPid.has(rec.pid))
255
+ byPid.set(rec.pid, rec);
256
+ const previous = recovered.get(rec.launch_id);
257
+ // Inherited launch metadata cannot disambiguate two different native
258
+ // sessions. Keep their exact PID identities but refuse the ambiguous join.
259
+ recovered.set(rec.launch_id, previous === null || (previous && previous.session_id !== rec.session_id) ? null : rec);
260
+ }
261
+ for (const [launchId, rec] of recovered) {
262
+ if (rec && !byLaunchId.has(launchId))
263
+ byLaunchId.set(launchId, rec);
264
+ }
133
265
  return { byLaunchId, byTerminalId, byPid };
134
266
  }
135
267
  /**
@@ -156,13 +288,14 @@ function kindMatches(recordAgent, kind) {
156
288
  */
157
289
  export function resolveHookSessionRecord(index, opts) {
158
290
  const { pid, kind, launchId, terminalId, childPids } = opts;
159
- const take = (rec) => rec?.session_id && kindMatches(rec.agent, kind) ? rec : undefined;
291
+ const take = (rec) => rec?.session_id && kindMatches(rec.agent, kind)
292
+ && (!launchId || !rec.launch_id || rec.launch_id === launchId) ? rec : undefined;
160
293
  if (launchId) {
161
294
  const hit = take(index.byLaunchId.get(launchId));
162
295
  if (hit)
163
296
  return hit;
164
297
  }
165
- if (terminalId) {
298
+ if (terminalId && !launchId) {
166
299
  const hit = take(index.byTerminalId.get(terminalId));
167
300
  if (hit)
168
301
  return hit;
@@ -49,7 +49,17 @@ export interface PidSessionEntry {
49
49
  */
50
50
  tmuxPane?: string;
51
51
  startedAtMs: number;
52
+ /** Kernel provenance, captured by the writer; absent on legacy records. */
53
+ processIdentity?: {
54
+ bootId?: string;
55
+ pidNamespace?: string;
56
+ initStartTicks?: string;
57
+ startTicks?: string;
58
+ startTime?: string;
59
+ };
52
60
  }
61
+ /** Read-only identity proof for exact joins. Unknown is never a positive match. */
62
+ export declare function pidSessionEntryMatchesLiveProcess(entry: PidSessionEntry): boolean | undefined;
53
63
  export declare function isSessionIdShape(value: string): boolean;
54
64
  export declare function extractSessionIdArg(args: string[]): string | undefined;
55
65
  /**
@@ -76,6 +86,8 @@ export declare function sessionIdFromLivePid(pid: number): string | undefined;
76
86
  export declare function writePidSessionEntry(entry: PidSessionEntry): void;
77
87
  /** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
78
88
  export declare function readPidSessionEntry(pid: number): PidSessionEntry | undefined;
89
+ /** Live consumers require identity proof; raw history is not a PID binding. */
90
+ export declare function readLivePidSessionEntry(pid: number): PidSessionEntry | undefined;
79
91
  /**
80
92
  * Every recorded launch, one entry per live-or-dead pid. Used to index launches
81
93
  * by their `tmuxPane` so the authoritative tmux source can attribute a pane it did
@@ -87,9 +99,10 @@ export declare function listPidSessionEntries(): PidSessionEntry[];
87
99
  /**
88
100
  * Remove entries whose pid is no longer alive. Best-effort housekeeping.
89
101
  *
90
- * `isAlive` receives the entry's recorded `startedAtMs` so it can reject a pid
91
- * that a newer process recycled (a "zombie" registry entry), not just one that
92
- * no longer exists. An unreadable/corrupt entry falls back to the pid-only
93
- * check.
102
+ * Linux deletion requires the writer's boot and PID namespace to match our own
103
+ * trustworthy proc view. ESRCH in another namespace says nothing about the
104
+ * recorded process. Legacy/corrupt/unreadable ownership is unknown, never dead.
105
+ * Kernel start ticks reject PID reuse without wall-clock heuristics. Other
106
+ * platforms retain the caller's start-time check after checking kernel liveness.
94
107
  */
95
- export declare function prunePidSessionRegistry(isAlive: (pid: number, startedAtMs?: number) => boolean): void;
108
+ export declare function prunePidSessionRegistry(isAlive?: (pid: number, startedAtMs?: number) => boolean | undefined): void;
@@ -19,7 +19,69 @@
19
19
  import fs from 'fs';
20
20
  import path from 'path';
21
21
  import { execFileSync } from 'child_process';
22
+ import { hostProcessView, writerProcessView } from './process-view.js';
22
23
  import { getTerminalsDir } from '../state.js';
24
+ import { atomicWriteFileSync, withFileLock } from '../fs-atomic.js';
25
+ function linuxStartTicks(pid) {
26
+ try {
27
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
28
+ // comm (field 2) may contain spaces or parentheses; starttime is field 22.
29
+ const value = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
30
+ return value && /^\d+$/.test(value) ? value : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ function pidExists(pid) {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ const code = error.code;
43
+ if (code === 'ESRCH')
44
+ return false;
45
+ if (code === 'EPERM')
46
+ return true;
47
+ return undefined;
48
+ }
49
+ }
50
+ function darwinStartTime(pid) {
51
+ try {
52
+ return execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /** Read-only identity proof for exact joins. Unknown is never a positive match. */
59
+ export function pidSessionEntryMatchesLiveProcess(entry) {
60
+ if (!Number.isInteger(entry.pid) || entry.pid < 1)
61
+ return undefined;
62
+ if (process.platform === 'darwin') {
63
+ const exists = pidExists(entry.pid);
64
+ if (exists !== true)
65
+ return exists;
66
+ const start = darwinStartTime(entry.pid);
67
+ return start && entry.processIdentity?.startTime ? start === entry.processIdentity.startTime : undefined;
68
+ }
69
+ if (process.platform !== 'linux')
70
+ return undefined;
71
+ const scope = hostProcessView();
72
+ if (!scope || entry.processIdentity?.bootId !== scope.bootId
73
+ || entry.processIdentity?.pidNamespace !== scope.pidNamespace
74
+ || entry.processIdentity?.initStartTicks !== scope.initStartTicks)
75
+ return undefined;
76
+ const exists = pidExists(entry.pid);
77
+ if (exists !== true)
78
+ return exists;
79
+ const start = linuxStartTicks(entry.pid);
80
+ const recordedStart = entry.processIdentity?.startTicks;
81
+ if (!start || typeof recordedStart !== 'string' || !/^\d+$/.test(recordedStart))
82
+ return undefined;
83
+ return start === recordedStart;
84
+ }
23
85
  /**
24
86
  * Pull an explicit `--session-id <uuid>` (or `--session-id=<uuid>`) out of a
25
87
  * raw agent arg vector. The transparent shim forwards args untouched, but when
@@ -106,13 +168,46 @@ function pidRegistryDir() {
106
168
  function entryPath(pid) {
107
169
  return path.join(pidRegistryDir(), `${pid}.json`);
108
170
  }
171
+ // The deployed SessionStart hook reconstructs by-pid entries from an allowlist.
172
+ // Keep launcher ownership separately, bound to the immutable launch coordinates.
173
+ function ownershipPath(pid) {
174
+ return path.join(getTerminalsDir(), 'by-pid-ownership', `${pid}.json`);
175
+ }
176
+ function persistOwnership(entry) {
177
+ fs.mkdirSync(path.dirname(ownershipPath(entry.pid)), { recursive: true });
178
+ atomicWriteFileSync(ownershipPath(entry.pid), JSON.stringify(entry), 'utf8');
179
+ }
180
+ function restoreOwnership(entry) {
181
+ if (entry.processIdentity)
182
+ return entry;
183
+ try {
184
+ const owner = JSON.parse(fs.readFileSync(ownershipPath(entry.pid), 'utf8'));
185
+ if (owner.pid === entry.pid && owner.launchId === entry.launchId && owner.startedAtMs === entry.startedAtMs) {
186
+ return { ...entry, processIdentity: owner.processIdentity };
187
+ }
188
+ }
189
+ catch { /* legacy launch */ }
190
+ return entry;
191
+ }
109
192
  /** Record a launched agent process. Never throws — the registry is an optimization. */
110
193
  export function writePidSessionEntry(entry) {
111
- if (!entry.pid || entry.pid < 1)
194
+ if (!Number.isInteger(entry.pid) || entry.pid < 1)
112
195
  return;
113
196
  try {
197
+ const scope = process.platform === 'linux' ? writerProcessView() : undefined;
198
+ if (process.platform === 'linux' && !scope)
199
+ return;
114
200
  fs.mkdirSync(pidRegistryDir(), { recursive: true });
115
- fs.writeFileSync(entryPath(entry.pid), JSON.stringify(entry), 'utf8');
201
+ const processIdentity = scope?.bootId && scope.pidNamespace ? { bootId: scope.bootId, pidNamespace: scope.pidNamespace, initStartTicks: scope.initStartTicks, startTicks: linuxStartTicks(entry.pid) }
202
+ : process.platform === 'darwin' ? { startTime: darwinStartTime(entry.pid) } : undefined;
203
+ const file = entryPath(entry.pid);
204
+ withFileLock(file, () => {
205
+ // The host writer owns numeric PID slots. Old-boot and legacy records
206
+ // must not reserve a recycled slot; restricted namespaces never get here.
207
+ const owned = { ...entry, processIdentity };
208
+ persistOwnership(owned);
209
+ atomicWriteFileSync(file, JSON.stringify(owned), 'utf8');
210
+ }, { realpath: false, acquireTimeoutMs: 0 });
116
211
  }
117
212
  catch {
118
213
  /* degrade to the newest-jsonl heuristic */
@@ -129,8 +224,8 @@ export function readPidSessionEntry(pid) {
129
224
  }
130
225
  try {
131
226
  const parsed = JSON.parse(raw);
132
- if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
133
- return parsed;
227
+ if (parsed && typeof parsed === 'object' && parsed.pid === pid) {
228
+ return restoreOwnership(parsed);
134
229
  }
135
230
  }
136
231
  catch {
@@ -138,6 +233,32 @@ export function readPidSessionEntry(pid) {
138
233
  }
139
234
  return undefined;
140
235
  }
236
+ /** Live consumers require identity proof; raw history is not a PID binding. */
237
+ export function readLivePidSessionEntry(pid) {
238
+ const entry = readPidSessionEntry(pid);
239
+ if (!entry || !hostProcessView())
240
+ return undefined;
241
+ if (process.platform === 'win32')
242
+ return pidExists(pid) === true ? entry : undefined;
243
+ if (!entry.processIdentity && pidExists(pid) === true) {
244
+ // Upgrade a native legacy launch only after the home namespace is proven,
245
+ // and the actual process start excludes a recycled PID. Hook timestamps may
246
+ // be later than process start, but cannot predate this process incarnation.
247
+ const start = darwinStartTime(pid);
248
+ const startMs = start ? Date.parse(start) : NaN;
249
+ if (Number.isFinite(startMs) && entry.startedAtMs >= startMs - 1000 && entry.startedAtMs <= Date.now()) {
250
+ const scope = hostProcessView();
251
+ entry.processIdentity = process.platform === 'linux'
252
+ ? { ...scope, startTicks: linuxStartTicks(pid) }
253
+ : { startTime: start };
254
+ try {
255
+ persistOwnership(entry);
256
+ }
257
+ catch { /* read remains useful */ }
258
+ }
259
+ }
260
+ return pidSessionEntryMatchesLiveProcess(entry) === true ? entry : undefined;
261
+ }
141
262
  /**
142
263
  * Every recorded launch, one entry per live-or-dead pid. Used to index launches
143
264
  * by their `tmuxPane` so the authoritative tmux source can attribute a pane it did
@@ -158,7 +279,7 @@ export function listPidSessionEntries() {
158
279
  try {
159
280
  const parsed = JSON.parse(fs.readFileSync(path.join(pidRegistryDir(), f), 'utf8'));
160
281
  if (parsed && typeof parsed === 'object' && typeof parsed.pid === 'number') {
161
- out.push(parsed);
282
+ out.push(restoreOwnership(parsed));
162
283
  }
163
284
  }
164
285
  catch {
@@ -170,10 +291,11 @@ export function listPidSessionEntries() {
170
291
  /**
171
292
  * Remove entries whose pid is no longer alive. Best-effort housekeeping.
172
293
  *
173
- * `isAlive` receives the entry's recorded `startedAtMs` so it can reject a pid
174
- * that a newer process recycled (a "zombie" registry entry), not just one that
175
- * no longer exists. An unreadable/corrupt entry falls back to the pid-only
176
- * check.
294
+ * Linux deletion requires the writer's boot and PID namespace to match our own
295
+ * trustworthy proc view. ESRCH in another namespace says nothing about the
296
+ * recorded process. Legacy/corrupt/unreadable ownership is unknown, never dead.
297
+ * Kernel start ticks reject PID reuse without wall-clock heuristics. Other
298
+ * platforms retain the caller's start-time check after checking kernel liveness.
177
299
  */
178
300
  export function prunePidSessionRegistry(isAlive) {
179
301
  let files;
@@ -183,23 +305,48 @@ export function prunePidSessionRegistry(isAlive) {
183
305
  catch {
184
306
  return;
185
307
  }
308
+ const scope = process.platform === 'linux' ? hostProcessView() : undefined;
186
309
  for (const f of files) {
187
310
  const pid = Number(f.slice(0, -'.json'.length));
188
- if (!Number.isInteger(pid))
311
+ if (!Number.isInteger(pid) || pid < 1)
189
312
  continue;
190
- let startedAtMs;
313
+ let raw;
314
+ let entry;
191
315
  try {
192
- const parsed = JSON.parse(fs.readFileSync(path.join(pidRegistryDir(), f), 'utf8'));
193
- if (typeof parsed?.startedAtMs === 'number')
194
- startedAtMs = parsed.startedAtMs;
316
+ raw = fs.readFileSync(path.join(pidRegistryDir(), f), 'utf8');
317
+ entry = restoreOwnership(JSON.parse(raw));
318
+ if (entry?.pid !== pid)
319
+ continue;
195
320
  }
196
321
  catch {
197
- /* unreadable/corrupt — fall back to the pid-only liveness check */
322
+ continue;
198
323
  }
199
- if (isAlive(pid, startedAtMs))
324
+ if (process.platform === 'linux' && (!scope
325
+ || entry.processIdentity?.bootId !== scope.bootId
326
+ || entry.processIdentity?.pidNamespace !== scope.pidNamespace
327
+ || entry.processIdentity?.initStartTicks !== scope.initStartTicks))
328
+ continue;
329
+ const exists = pidExists(pid);
330
+ if (exists === undefined)
200
331
  continue;
332
+ if (exists) {
333
+ if (process.platform === 'linux') {
334
+ const start = linuxStartTicks(pid);
335
+ const recordedStart = entry.processIdentity?.startTicks;
336
+ if (!start || typeof recordedStart !== 'string' || !/^\d+$/.test(recordedStart) || start === recordedStart)
337
+ continue;
338
+ }
339
+ else if (!isAlive || isAlive(pid, entry.startedAtMs) !== false)
340
+ continue;
341
+ }
201
342
  try {
202
- fs.unlinkSync(path.join(pidRegistryDir(), f));
343
+ const file = path.join(pidRegistryDir(), f);
344
+ // Share the writer's lock so comparison and deletion cannot remove a
345
+ // successor published after the liveness check.
346
+ withFileLock(file, () => {
347
+ if (fs.readFileSync(file, 'utf8') === raw)
348
+ fs.unlinkSync(file);
349
+ }, { realpath: false, acquireTimeoutMs: 0 });
203
350
  }
204
351
  catch {
205
352
  /* raced with another writer/pruner */
@@ -0,0 +1,25 @@
1
+ export interface HostProcessView {
2
+ bootId?: string;
3
+ pidNamespace?: string;
4
+ /** Namespace inodes can be reused within one boot; its init identifies the incarnation. */
5
+ initStartTicks?: string;
6
+ ownerPid?: number;
7
+ ownerStartTicks?: string;
8
+ }
9
+ /** Measure the calling process, never infer authority from a process name. */
10
+ export declare function currentProcessView(): HostProcessView | undefined;
11
+ /** Read-only ownership check. Observing a HOME never claims or migrates it. */
12
+ export declare function hostProcessView(): HostProcessView | undefined;
13
+ /** Explicit writers may enroll a fresh HOME in their measured namespace.
14
+ * Foreign ownership remains protected even when the prior writer is invisible.
15
+ * The initial host may enroll legacy state before daemon startup. */
16
+ export declare function writerProcessView(): HostProcessView | undefined;
17
+ /** Preflight BEFORE even the PID-keyed legacy lifecycle lock is inspected.
18
+ * The same decision is repeated under that lock before daemon state changes. */
19
+ export declare function daemonProcessViewAllowed(): boolean;
20
+ /** Called by the canonical daemon writer before lifecycle mutation.
21
+ * This is a writer statement, not an observer's guess about a numeric PID.
22
+ * Existing same-boot ownership cannot be taken over from another namespace. */
23
+ export declare function recordDaemonProcessView(): void;
24
+ export declare function requireHostProcessView(): void;
25
+ export declare function requireWriterProcessView(): void;