@phnx-labs/agents-cli 1.22.112 → 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 +8 -0
- 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
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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.
|
|
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",
|