@phnx-labs/agents-cli 1.22.111 → 1.22.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/commands/exec.d.ts +106 -1
- package/dist/commands/exec.js +10 -10
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +12 -0
- package/dist/lib/daemon/daemon.d.ts +0 -8
- package/dist/lib/daemon/daemon.js +41 -22
- package/dist/lib/daemon/testdata/process-view-daemon.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-daemon.js +14 -0
- package/dist/lib/daemon/testdata/process-view-start.d.ts +1 -0
- package/dist/lib/daemon/testdata/process-view-start.js +43 -0
- package/dist/lib/event-provenance.d.ts +2 -0
- package/dist/lib/event-provenance.js +4 -0
- package/dist/lib/exec.js +27 -7
- package/dist/lib/feed/activity-stream.js +9 -0
- package/dist/lib/feed/activity.d.ts +8 -0
- package/dist/lib/feed/activity.js +61 -18
- package/dist/lib/feed/watch.d.ts +9 -0
- package/dist/lib/feed/watch.js +91 -3
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/hosts/dispatch.d.ts +0 -10
- package/dist/lib/hosts/dispatch.js +2 -19
- package/dist/lib/hosts/remote-session-id.d.ts +5 -27
- package/dist/lib/hosts/remote-session-id.js +18 -61
- package/dist/lib/launch-identity.d.ts +10 -0
- package/dist/lib/launch-identity.js +31 -0
- package/dist/lib/session/active.js +9 -3
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +39 -4
- package/dist/lib/session/discover.js +7 -2
- package/dist/lib/session/hook-sessions.d.ts +10 -0
- package/dist/lib/session/hook-sessions.js +139 -6
- package/dist/lib/session/pid-registry.d.ts +18 -5
- package/dist/lib/session/pid-registry.js +164 -17
- package/dist/lib/session/process-view.d.ts +25 -0
- package/dist/lib/session/process-view.js +176 -0
- package/dist/lib/session/projection.d.ts +13 -0
- package/dist/lib/session/projection.js +101 -0
- package/dist/lib/session/remote/watch.d.ts +8 -0
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/session-cache.js +15 -0
- package/dist/lib/session/testdata/pid-registry-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/pid-registry-namespace.js +27 -0
- package/dist/lib/session/testdata/process-view-namespace.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-namespace.js +56 -0
- package/dist/lib/session/testdata/process-view-private.d.ts +1 -0
- package/dist/lib/session/testdata/process-view-private.js +12 -0
- package/package.json +1 -1
|
@@ -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?.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
-
|
|
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' &&
|
|
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
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
|
313
|
+
let raw;
|
|
314
|
+
let entry;
|
|
191
315
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
322
|
+
continue;
|
|
198
323
|
}
|
|
199
|
-
if (
|
|
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
|
-
|
|
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;
|