pi-agent-replicate 0.0.0-stage → 1.4.1
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/AGENTS.md +27 -0
- package/CHANGELOG.md +152 -0
- package/CONTEXT.md +63 -0
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/RELEASING.md +132 -0
- package/assets/banner.png +0 -0
- package/config.json.example +6 -0
- package/docs/README.md +23 -0
- package/docs/adr/0003-installable-role-packs.md +29 -0
- package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
- package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
- package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
- package/docs/configuration.md +14 -0
- package/docs/development.md +27 -0
- package/docs/public-subagent-tree-api.md +58 -0
- package/docs/retained-roles.md +71 -0
- package/docs/settled-lifecycle-contract.md +71 -0
- package/docs/worktree-subagents.md +58 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/index.d.ts +116 -0
- package/index.js +1064 -0
- package/index.ts +155 -0
- package/package.json +70 -4
- package/pi-extension/subagents/activity.js +575 -0
- package/pi-extension/subagents/activity.ts +980 -0
- package/pi-extension/subagents/completion.js +184 -0
- package/pi-extension/subagents/completion.ts +229 -0
- package/pi-extension/subagents/delivery-drain.js +15 -0
- package/pi-extension/subagents/delivery-drain.ts +20 -0
- package/pi-extension/subagents/delivery-files.js +86 -0
- package/pi-extension/subagents/delivery-files.ts +105 -0
- package/pi-extension/subagents/delivery-log.js +51 -0
- package/pi-extension/subagents/delivery-log.ts +51 -0
- package/pi-extension/subagents/delivery-payload.ts +55 -0
- package/pi-extension/subagents/delivery.ts +219 -0
- package/pi-extension/subagents/execution-owner.js +30 -0
- package/pi-extension/subagents/execution-owner.ts +38 -0
- package/pi-extension/subagents/herdr.js +645 -0
- package/pi-extension/subagents/herdr.ts +927 -0
- package/pi-extension/subagents/index.ts +3400 -0
- package/pi-extension/subagents/launch.js +774 -0
- package/pi-extension/subagents/launch.ts +1124 -0
- package/pi-extension/subagents/lifecycle.js +477 -0
- package/pi-extension/subagents/lifecycle.ts +582 -0
- package/pi-extension/subagents/lineage.js +806 -0
- package/pi-extension/subagents/lineage.ts +841 -0
- package/pi-extension/subagents/replica-background.js +32 -0
- package/pi-extension/subagents/replica-background.ts +38 -0
- package/pi-extension/subagents/replica-branch.ts +45 -0
- package/pi-extension/subagents/replica-directory-settings.ts +51 -0
- package/pi-extension/subagents/replica-history.js +22 -0
- package/pi-extension/subagents/replica-history.ts +21 -0
- package/pi-extension/subagents/replica-work.js +326 -0
- package/pi-extension/subagents/replica-work.ts +336 -0
- package/pi-extension/subagents/replica-worker.js +74 -0
- package/pi-extension/subagents/replica-worker.ts +71 -0
- package/pi-extension/subagents/replication.ts +99 -0
- package/pi-extension/subagents/result-delivery-queue.ts +33 -0
- package/pi-extension/subagents/role-catalog.js +123 -0
- package/pi-extension/subagents/role-catalog.ts +24 -0
- package/pi-extension/subagents/runtime-routing.js +154 -0
- package/pi-extension/subagents/runtime-routing.ts +239 -0
- package/pi-extension/subagents/session-binding.js +346 -0
- package/pi-extension/subagents/session-binding.ts +339 -0
- package/pi-extension/subagents/session-work.js +219 -0
- package/pi-extension/subagents/session-work.ts +167 -0
- package/pi-extension/subagents/session.js +318 -0
- package/pi-extension/subagents/session.ts +448 -0
- package/pi-extension/subagents/settled-contract.js +47 -0
- package/pi-extension/subagents/settled-contract.ts +175 -0
- package/pi-extension/subagents/source-state.js +180 -0
- package/pi-extension/subagents/source-state.ts +184 -0
- package/pi-extension/subagents/status.ts +493 -0
- package/pi-extension/subagents/subagent-done.js +805 -0
- package/pi-extension/subagents/subagent-done.ts +878 -0
- package/pi-extension/subagents/terminal.js +111 -0
- package/pi-extension/subagents/terminal.ts +208 -0
- package/pi-extension/subagents/watchdog.js +122 -0
- package/pi-extension/subagents/watchdog.ts +171 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, fsyncSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, fstatSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { getPaneProcessInfo } from "./terminal.js";
|
|
6
|
+
export const SESSION_BINDING_TYPE = "subagent-session-binding";
|
|
7
|
+
/** Bind the freshly opened context and restore only session-owned child settings. */
|
|
8
|
+
export function bindSessionRuntime(pi, ctx) {
|
|
9
|
+
const manager = ctx.sessionManager;
|
|
10
|
+
const sessionFile = manager?.getSessionFile?.();
|
|
11
|
+
const sessionId = manager?.getSessionId?.();
|
|
12
|
+
if (!sessionFile || !sessionId)
|
|
13
|
+
return undefined;
|
|
14
|
+
let binding = readSessionBinding(sessionFile);
|
|
15
|
+
if (!binding) {
|
|
16
|
+
binding = { version: 1, sessionId, storeDir: sessionBindingStore() };
|
|
17
|
+
pi.appendEntry(SESSION_BINDING_TYPE, binding);
|
|
18
|
+
}
|
|
19
|
+
if (binding.child) {
|
|
20
|
+
for (const [key, value] of Object.entries(binding.child.environment)) {
|
|
21
|
+
if ((key.startsWith("PI_SUBAGENT_") || key === "PI_DENY_TOOLS") && typeof value === "string")
|
|
22
|
+
process.env[key] = value;
|
|
23
|
+
}
|
|
24
|
+
process.env.PI_SUBAGENT_SESSION = sessionFile;
|
|
25
|
+
process.env.PI_SUBAGENT_LAUNCHER_PID = String(process.ppid);
|
|
26
|
+
process.env.PI_SUBAGENT_SURFACE = process.env.HERDR_PANE_ID;
|
|
27
|
+
}
|
|
28
|
+
let shellPid;
|
|
29
|
+
try {
|
|
30
|
+
if (process.env.HERDR_PANE_ID)
|
|
31
|
+
shellPid = getPaneProcessInfo(process.env.HERDR_PANE_ID).shellPid;
|
|
32
|
+
}
|
|
33
|
+
catch { /* No verified pane means no destructive cleanup. */ }
|
|
34
|
+
publishSessionLocation(binding, {
|
|
35
|
+
shellPid, shellStart: shellPid ? startTime(shellPid) : undefined,
|
|
36
|
+
serverPid: shellPid ? parentPid(shellPid) : undefined,
|
|
37
|
+
serverStart: shellPid && parentPid(shellPid) ? startTime(parentPid(shellPid)) : undefined,
|
|
38
|
+
sessionFile, cwd: ctx.cwd,
|
|
39
|
+
paneId: process.env.HERDR_PANE_ID,
|
|
40
|
+
tabId: process.env.HERDR_TAB_ID,
|
|
41
|
+
workspaceId: process.env.HERDR_WORKSPACE_ID,
|
|
42
|
+
});
|
|
43
|
+
return binding;
|
|
44
|
+
}
|
|
45
|
+
/** Atomically save state and sync both its bytes and directory before returning. */
|
|
46
|
+
export function writeBindingFile(path, value) {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
49
|
+
let fd;
|
|
50
|
+
try {
|
|
51
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
52
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
53
|
+
fsyncSync(fd);
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
fd = undefined;
|
|
56
|
+
renameSync(temporary, path);
|
|
57
|
+
const directory = openSync(dirname(path), "r");
|
|
58
|
+
try {
|
|
59
|
+
fsyncSync(directory);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
closeSync(directory);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
if (fd !== undefined)
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
try {
|
|
69
|
+
unlinkSync(temporary);
|
|
70
|
+
}
|
|
71
|
+
catch { /* Already published or not created. */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function readJson(path) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Bound memory to observed sessions, not the full conversation or session directory.
|
|
83
|
+
const bindingCursors = new Map();
|
|
84
|
+
const READ_CHUNK = 4096;
|
|
85
|
+
function readHeader(fd) {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
for (let position = 0; position < 64 * 1024; position += READ_CHUNK) {
|
|
88
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK);
|
|
89
|
+
const count = readSync(fd, buffer, 0, buffer.length, position);
|
|
90
|
+
const newline = buffer.subarray(0, count).indexOf(10);
|
|
91
|
+
chunks.push(buffer.subarray(0, newline < 0 ? count : newline));
|
|
92
|
+
if (newline >= 0 || count < READ_CHUNK) {
|
|
93
|
+
const header = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
94
|
+
return header.type === "session" && typeof header.id === "string" ? header : undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Read only the session header, never the conversation that follows it. */
|
|
100
|
+
export function readSessionHeader(sessionFile) {
|
|
101
|
+
let fd;
|
|
102
|
+
try {
|
|
103
|
+
fd = openSync(sessionFile, "r");
|
|
104
|
+
return readHeader(fd);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (fd !== undefined)
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function readBoundary(fd, size) {
|
|
115
|
+
const buffer = Buffer.alloc(Math.min(64, size));
|
|
116
|
+
const count = readSync(fd, buffer, 0, buffer.length, size - buffer.length);
|
|
117
|
+
return buffer.subarray(0, count);
|
|
118
|
+
}
|
|
119
|
+
function bindingRecordUnchanged(fd, cursor) {
|
|
120
|
+
if (!cursor.record)
|
|
121
|
+
return true;
|
|
122
|
+
const buffer = Buffer.alloc(cursor.record.bytes.length);
|
|
123
|
+
return readSync(fd, buffer, 0, buffer.length, cursor.record.offset) === buffer.length && buffer.equals(cursor.record.bytes);
|
|
124
|
+
}
|
|
125
|
+
/** Read complete appended binding records, validating ownership against the current header. */
|
|
126
|
+
export function readSessionBinding(sessionFile) {
|
|
127
|
+
let fd;
|
|
128
|
+
try {
|
|
129
|
+
fd = openSync(sessionFile, "r");
|
|
130
|
+
// Keep identity and records on one opened file if the path is replaced.
|
|
131
|
+
const stat = fstatSync(fd);
|
|
132
|
+
const previous = bindingCursors.get(sessionFile);
|
|
133
|
+
if (previous && previous.dev === stat.dev && previous.ino === stat.ino && previous.size === stat.size && previous.mtimeMs === stat.mtimeMs && previous.ctimeMs === stat.ctimeMs) {
|
|
134
|
+
return previous.binding && structuredClone(previous.binding);
|
|
135
|
+
}
|
|
136
|
+
const header = readHeader(fd);
|
|
137
|
+
if (!header) {
|
|
138
|
+
bindingCursors.delete(sessionFile);
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
// A replaced, truncated or rewritten journal must not retain cached ownership.
|
|
142
|
+
const append = previous && previous.dev === stat.dev && previous.ino === stat.ino && previous.sessionId === header.id && stat.size > previous.size && readBoundary(fd, previous.size).equals(previous.boundary) && bindingRecordUnchanged(fd, previous);
|
|
143
|
+
const cursor = append ? previous : {
|
|
144
|
+
dev: stat.dev, ino: stat.ino, size: 0, mtimeMs: 0, ctimeMs: 0,
|
|
145
|
+
sessionId: header.id, boundary: Buffer.alloc(0), pending: [], lineStart: 0,
|
|
146
|
+
};
|
|
147
|
+
let position = cursor.size;
|
|
148
|
+
while (position < stat.size) {
|
|
149
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, stat.size - position));
|
|
150
|
+
const count = readSync(fd, buffer, 0, buffer.length, position);
|
|
151
|
+
if (!count)
|
|
152
|
+
break;
|
|
153
|
+
let start = 0;
|
|
154
|
+
for (let index = 0; index < count; index++) {
|
|
155
|
+
if (buffer[index] !== 10)
|
|
156
|
+
continue;
|
|
157
|
+
cursor.pending.push(buffer.subarray(start, index));
|
|
158
|
+
const line = Buffer.concat(cursor.pending).toString("utf8");
|
|
159
|
+
const lineStart = cursor.lineStart;
|
|
160
|
+
cursor.lineStart = position + index + 1;
|
|
161
|
+
cursor.pending = [];
|
|
162
|
+
start = index + 1;
|
|
163
|
+
if (!line.includes(SESSION_BINDING_TYPE))
|
|
164
|
+
continue;
|
|
165
|
+
try {
|
|
166
|
+
const row = JSON.parse(line), binding = row.data;
|
|
167
|
+
if (row.type !== "custom" || row.customType !== SESSION_BINDING_TYPE)
|
|
168
|
+
continue;
|
|
169
|
+
if (binding?.version !== 1 || binding.sessionId !== header.id || typeof binding.storeDir !== "string" || !isAbsolute(binding.storeDir))
|
|
170
|
+
continue;
|
|
171
|
+
if (binding.child && (!binding.child.lineage?.rootDir || !binding.child.lineage?.nodeId || !binding.child.environment))
|
|
172
|
+
continue;
|
|
173
|
+
cursor.binding = binding;
|
|
174
|
+
cursor.record = { offset: lineStart, bytes: Buffer.from(line + "\n") };
|
|
175
|
+
}
|
|
176
|
+
catch { /* Ignore malformed complete records, as before. */ }
|
|
177
|
+
}
|
|
178
|
+
if (start < count)
|
|
179
|
+
cursor.pending.push(buffer.subarray(start, count));
|
|
180
|
+
position += count;
|
|
181
|
+
}
|
|
182
|
+
cursor.size = position;
|
|
183
|
+
cursor.mtimeMs = stat.mtimeMs;
|
|
184
|
+
cursor.ctimeMs = stat.ctimeMs;
|
|
185
|
+
cursor.boundary = readBoundary(fd, position);
|
|
186
|
+
bindingCursors.delete(sessionFile);
|
|
187
|
+
bindingCursors.set(sessionFile, cursor);
|
|
188
|
+
if (bindingCursors.size > 128)
|
|
189
|
+
bindingCursors.delete(bindingCursors.keys().next().value);
|
|
190
|
+
return cursor.binding && structuredClone(cursor.binding);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
bindingCursors.delete(sessionFile);
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
if (fd !== undefined)
|
|
198
|
+
closeSync(fd);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Use the store carried by the session, even when it moved to another agent directory. */
|
|
202
|
+
export function sessionBindingStore(sessionFile, agentDir) {
|
|
203
|
+
return (sessionFile ? readSessionBinding(sessionFile)?.storeDir : undefined)
|
|
204
|
+
?? join(agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "subagent-sessions");
|
|
205
|
+
}
|
|
206
|
+
function ownerDir(storeDir, sessionId) {
|
|
207
|
+
return join(storeDir, "owners", encodeURIComponent(sessionId));
|
|
208
|
+
}
|
|
209
|
+
/** Index each lineage independently so simultaneous launches cannot overwrite siblings. */
|
|
210
|
+
export function registerSessionLineage(storeDir, sessionId, rootDir) {
|
|
211
|
+
writeBindingFile(join(ownerDir(storeDir, sessionId), `${createHash("sha256").update(rootDir).digest("hex")}.json`), { version: 1, sessionId, rootDir });
|
|
212
|
+
}
|
|
213
|
+
/** Find ownership by the durable session key, not the current cwd or file path. */
|
|
214
|
+
export function sessionLineageRoots(storeDir, sessionId) {
|
|
215
|
+
const directory = ownerDir(storeDir, sessionId);
|
|
216
|
+
let names;
|
|
217
|
+
try {
|
|
218
|
+
names = readdirSync(directory);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
return names.filter((name) => name.endsWith(".json")).flatMap((name) => {
|
|
224
|
+
const record = readJson(join(directory, name));
|
|
225
|
+
return record?.version === 1 && record.sessionId === sessionId && typeof record.rootDir === "string" && isAbsolute(record.rootDir) ? [record.rootDir] : [];
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Seed child identity before starting Pi. A running session must use pi.appendEntry instead. */
|
|
229
|
+
export function seedSessionBinding(sessionFile, binding) {
|
|
230
|
+
const raw = readFileSync(sessionFile, "utf8");
|
|
231
|
+
const rows = raw.trim().split("\n").map((line) => JSON.parse(line));
|
|
232
|
+
const header = rows[0];
|
|
233
|
+
if (header.type !== "session" || typeof header.id !== "string")
|
|
234
|
+
throw new Error("Cannot bind a child without a session header");
|
|
235
|
+
const data = { ...binding, sessionId: header.id };
|
|
236
|
+
appendFileSync(sessionFile, JSON.stringify({ type: "custom", id: randomUUID(), parentId: rows.at(-1)?.id ?? null, timestamp: new Date().toISOString(), customType: SESSION_BINDING_TYPE, data }) + "\n");
|
|
237
|
+
const fd = openSync(sessionFile, "r");
|
|
238
|
+
try {
|
|
239
|
+
fsyncSync(fd);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
closeSync(fd);
|
|
243
|
+
}
|
|
244
|
+
return data;
|
|
245
|
+
}
|
|
246
|
+
function parentPid(pid) {
|
|
247
|
+
try {
|
|
248
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
249
|
+
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[1]) || undefined;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function startTime(pid) {
|
|
256
|
+
try {
|
|
257
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
258
|
+
return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[19];
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Capture the allocated shell without claiming that its Pi session has started. */
|
|
265
|
+
export function captureLaunchPaneOwnership(paneId, shellPid) {
|
|
266
|
+
if (!shellPid || !Number.isInteger(shellPid) || shellPid <= 0)
|
|
267
|
+
return undefined;
|
|
268
|
+
try {
|
|
269
|
+
const shellStart = startTime(shellPid);
|
|
270
|
+
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
271
|
+
return shellStart && bootId ? { paneId, shellPid, shellStart, bootId } : undefined;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** An old launch cannot take ownership of a reused shell or another machine boot. */
|
|
278
|
+
export function launchOwnsPane(owner, paneId, shellPid) {
|
|
279
|
+
const current = captureLaunchPaneOwnership(paneId, shellPid);
|
|
280
|
+
return !!current && owner.paneId === current.paneId && owner.shellPid === current.shellPid &&
|
|
281
|
+
owner.shellStart === current.shellStart && owner.bootId === current.bootId;
|
|
282
|
+
}
|
|
283
|
+
/** Publish one startup's exact file and pane, surviving both Teleport and Herdr restarts. */
|
|
284
|
+
export function publishSessionLocation(binding, input) {
|
|
285
|
+
const location = {
|
|
286
|
+
version: 1, sessionId: binding.sessionId, ...input, pid: process.pid, processStart: startTime(process.pid),
|
|
287
|
+
...(binding.child ? { childNodeId: binding.child.lineage.nodeId, childRootDir: binding.child.lineage.rootDir } : {}),
|
|
288
|
+
};
|
|
289
|
+
writeBindingFile(join(binding.storeDir, "locations", `${encodeURIComponent(binding.sessionId)}.json`), location);
|
|
290
|
+
if (binding.child)
|
|
291
|
+
writeBindingFile(join(binding.child.lineage.rootDir, "locations", `${encodeURIComponent(binding.child.lineage.nodeId)}.json`), location);
|
|
292
|
+
return location;
|
|
293
|
+
}
|
|
294
|
+
/** Hold child completion while Teleport starts a replacement, with a bounded failure gate. */
|
|
295
|
+
export function beginChildSessionMove(binding) {
|
|
296
|
+
if (!binding.child)
|
|
297
|
+
return;
|
|
298
|
+
const path = join(binding.child.lineage.rootDir, "locations", `${encodeURIComponent(binding.child.lineage.nodeId)}.json`);
|
|
299
|
+
const location = readJson(path);
|
|
300
|
+
if (validLocation(location) && location.pid === process.pid)
|
|
301
|
+
writeBindingFile(path, { ...location, moveDeadline: Date.now() + 15_000 });
|
|
302
|
+
}
|
|
303
|
+
/** A dead process still supplies a recoverable saved location, never a live receiver. */
|
|
304
|
+
export function sessionLocationAlive(location) {
|
|
305
|
+
try {
|
|
306
|
+
process.kill(location.pid, 0);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
return location.processStart !== undefined && startTime(location.pid) === location.processStart;
|
|
312
|
+
}
|
|
313
|
+
/** A lost pane is not a user cancellation when its Herdr incarnation also died. */
|
|
314
|
+
export function sessionServerAlive(location) {
|
|
315
|
+
if (!location.serverPid || !location.serverStart)
|
|
316
|
+
return undefined;
|
|
317
|
+
return startTime(location.serverPid) === location.serverStart;
|
|
318
|
+
}
|
|
319
|
+
/** Allow pane cleanup only while its original shell incarnation still owns it. */
|
|
320
|
+
export function sessionOwnsPane(location, paneId, shellPid) {
|
|
321
|
+
return location.paneId === paneId && location.shellPid === shellPid && shellPid !== undefined
|
|
322
|
+
&& location.shellStart !== undefined && startTime(shellPid) === location.shellStart;
|
|
323
|
+
}
|
|
324
|
+
function validLocation(value) {
|
|
325
|
+
return value?.version === 1 && typeof value.sessionId === "string" && typeof value.sessionFile === "string" && isAbsolute(value.sessionFile) && Number.isInteger(value.pid) && value.pid > 0 && typeof value.cwd === "string";
|
|
326
|
+
}
|
|
327
|
+
/** Resolve the last registered incarnation of a child. */
|
|
328
|
+
export function childSessionLocation(rootDir, nodeId) {
|
|
329
|
+
const value = readJson(join(rootDir, "locations", `${encodeURIComponent(nodeId)}.json`));
|
|
330
|
+
if (!validLocation(value))
|
|
331
|
+
return undefined;
|
|
332
|
+
const binding = readSessionBinding(value.sessionFile);
|
|
333
|
+
if (binding?.child?.lineage.rootDir === rootDir && binding.child.lineage.nodeId === nodeId)
|
|
334
|
+
return value;
|
|
335
|
+
// Teleport may remove the source before the replacement finishes extension startup.
|
|
336
|
+
if (value.childRootDir === rootDir && value.childNodeId === nodeId && !existsSync(value.sessionFile))
|
|
337
|
+
return value;
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
/** Resolve a parent with matching session evidence; a copied branch is not a receiver. */
|
|
341
|
+
export function parentSessionLocation(storeDir, sessionId) {
|
|
342
|
+
const value = readJson(join(storeDir, "locations", `${encodeURIComponent(sessionId)}.json`));
|
|
343
|
+
if (!validLocation(value) || value.sessionId !== sessionId)
|
|
344
|
+
return undefined;
|
|
345
|
+
return readSessionHeader(value.sessionFile)?.id === sessionId ? value : undefined;
|
|
346
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, fsyncSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, fstatSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import type { LineageRegistration } from "./lineage.ts";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { getPaneProcessInfo } from "./terminal.ts";
|
|
8
|
+
|
|
9
|
+
export const SESSION_BINDING_TYPE = "subagent-session-binding";
|
|
10
|
+
|
|
11
|
+
/** Bind the freshly opened context and restore only session-owned child settings. */
|
|
12
|
+
export function bindSessionRuntime(pi: ExtensionAPI, ctx: ExtensionContext): SessionBinding | undefined {
|
|
13
|
+
const manager = ctx.sessionManager;
|
|
14
|
+
const sessionFile = manager?.getSessionFile?.();
|
|
15
|
+
const sessionId = manager?.getSessionId?.();
|
|
16
|
+
if (!sessionFile || !sessionId) return undefined;
|
|
17
|
+
let binding = readSessionBinding(sessionFile);
|
|
18
|
+
if (!binding) {
|
|
19
|
+
binding = { version: 1, sessionId, storeDir: sessionBindingStore() };
|
|
20
|
+
pi.appendEntry(SESSION_BINDING_TYPE, binding);
|
|
21
|
+
}
|
|
22
|
+
if (binding.child) {
|
|
23
|
+
for (const [key, value] of Object.entries(binding.child.environment)) {
|
|
24
|
+
if ((key.startsWith("PI_SUBAGENT_") || key === "PI_DENY_TOOLS") && typeof value === "string") process.env[key] = value;
|
|
25
|
+
}
|
|
26
|
+
process.env.PI_SUBAGENT_SESSION = sessionFile;
|
|
27
|
+
process.env.PI_SUBAGENT_LAUNCHER_PID = String(process.ppid);
|
|
28
|
+
process.env.PI_SUBAGENT_SURFACE = process.env.HERDR_PANE_ID;
|
|
29
|
+
}
|
|
30
|
+
let shellPid: number | undefined;
|
|
31
|
+
try { if (process.env.HERDR_PANE_ID) shellPid = getPaneProcessInfo(process.env.HERDR_PANE_ID).shellPid; } catch { /* No verified pane means no destructive cleanup. */ }
|
|
32
|
+
publishSessionLocation(binding, {
|
|
33
|
+
shellPid, shellStart: shellPid ? startTime(shellPid) : undefined,
|
|
34
|
+
serverPid: shellPid ? parentPid(shellPid) : undefined,
|
|
35
|
+
serverStart: shellPid && parentPid(shellPid) ? startTime(parentPid(shellPid)!) : undefined,
|
|
36
|
+
sessionFile, cwd: ctx.cwd,
|
|
37
|
+
paneId: process.env.HERDR_PANE_ID,
|
|
38
|
+
tabId: process.env.HERDR_TAB_ID,
|
|
39
|
+
workspaceId: process.env.HERDR_WORKSPACE_ID,
|
|
40
|
+
});
|
|
41
|
+
return binding;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Session-owned identity. Copies with another header ID cannot inherit ownership. */
|
|
45
|
+
export interface SessionBinding {
|
|
46
|
+
version: 1;
|
|
47
|
+
sessionId: string;
|
|
48
|
+
storeDir: string;
|
|
49
|
+
child?: {
|
|
50
|
+
lineage: LineageRegistration;
|
|
51
|
+
environment: Record<string, string>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parent-captured shell ownership, available before the child runtime initializes. */
|
|
56
|
+
export interface LaunchPaneOwnership {
|
|
57
|
+
paneId: string;
|
|
58
|
+
shellPid: number;
|
|
59
|
+
shellStart: string;
|
|
60
|
+
bootId: string;
|
|
61
|
+
}
|
|
62
|
+
/** Latest verified session incarnation; Herdr pane IDs alone are not durable identity. */
|
|
63
|
+
export interface SessionLocation {
|
|
64
|
+
version: 1;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
sessionFile: string;
|
|
67
|
+
pid: number;
|
|
68
|
+
processStart?: string;
|
|
69
|
+
shellPid?: number;
|
|
70
|
+
shellStart?: string;
|
|
71
|
+
serverPid?: number;
|
|
72
|
+
serverStart?: string;
|
|
73
|
+
childNodeId?: string;
|
|
74
|
+
childRootDir?: string;
|
|
75
|
+
moveDeadline?: number;
|
|
76
|
+
paneId?: string;
|
|
77
|
+
tabId?: string;
|
|
78
|
+
workspaceId?: string;
|
|
79
|
+
cwd: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Atomically save state and sync both its bytes and directory before returning. */
|
|
83
|
+
export function writeBindingFile(path: string, value: unknown): void {
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
86
|
+
let fd: number | undefined;
|
|
87
|
+
try {
|
|
88
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
89
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
90
|
+
fsyncSync(fd);
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
fd = undefined;
|
|
93
|
+
renameSync(temporary, path);
|
|
94
|
+
const directory = openSync(dirname(path), "r");
|
|
95
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
96
|
+
} finally {
|
|
97
|
+
if (fd !== undefined) closeSync(fd);
|
|
98
|
+
try { unlinkSync(temporary); } catch { /* Already published or not created. */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readJson(path: string): any {
|
|
103
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface BindingCursor {
|
|
107
|
+
dev: number;
|
|
108
|
+
ino: number;
|
|
109
|
+
size: number;
|
|
110
|
+
mtimeMs: number;
|
|
111
|
+
ctimeMs: number;
|
|
112
|
+
sessionId: string;
|
|
113
|
+
boundary: Buffer;
|
|
114
|
+
pending: Buffer[];
|
|
115
|
+
lineStart: number;
|
|
116
|
+
record?: { offset: number; bytes: Buffer };
|
|
117
|
+
binding?: SessionBinding;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Bound memory to observed sessions, not the full conversation or session directory.
|
|
121
|
+
const bindingCursors = new Map<string, BindingCursor>();
|
|
122
|
+
const READ_CHUNK = 4096;
|
|
123
|
+
|
|
124
|
+
function readHeader(fd: number): { type: "session"; id: string } | undefined {
|
|
125
|
+
const chunks: Buffer[] = [];
|
|
126
|
+
for (let position = 0; position < 64 * 1024; position += READ_CHUNK) {
|
|
127
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK);
|
|
128
|
+
const count = readSync(fd, buffer, 0, buffer.length, position);
|
|
129
|
+
const newline = buffer.subarray(0, count).indexOf(10);
|
|
130
|
+
chunks.push(buffer.subarray(0, newline < 0 ? count : newline));
|
|
131
|
+
if (newline >= 0 || count < READ_CHUNK) {
|
|
132
|
+
const header = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
133
|
+
return header.type === "session" && typeof header.id === "string" ? header : undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read only the session header, never the conversation that follows it. */
|
|
140
|
+
export function readSessionHeader(sessionFile: string): { type: "session"; id: string } | undefined {
|
|
141
|
+
let fd: number | undefined;
|
|
142
|
+
try {
|
|
143
|
+
fd = openSync(sessionFile, "r");
|
|
144
|
+
return readHeader(fd);
|
|
145
|
+
} catch { return undefined; }
|
|
146
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readBoundary(fd: number, size: number): Buffer {
|
|
150
|
+
const buffer = Buffer.alloc(Math.min(64, size));
|
|
151
|
+
const count = readSync(fd, buffer, 0, buffer.length, size - buffer.length);
|
|
152
|
+
return buffer.subarray(0, count);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bindingRecordUnchanged(fd: number, cursor: BindingCursor): boolean {
|
|
156
|
+
if (!cursor.record) return true;
|
|
157
|
+
const buffer = Buffer.alloc(cursor.record.bytes.length);
|
|
158
|
+
return readSync(fd, buffer, 0, buffer.length, cursor.record.offset) === buffer.length && buffer.equals(cursor.record.bytes);
|
|
159
|
+
}
|
|
160
|
+
/** Read complete appended binding records, validating ownership against the current header. */
|
|
161
|
+
export function readSessionBinding(sessionFile: string): SessionBinding | undefined {
|
|
162
|
+
let fd: number | undefined;
|
|
163
|
+
try {
|
|
164
|
+
fd = openSync(sessionFile, "r");
|
|
165
|
+
// Keep identity and records on one opened file if the path is replaced.
|
|
166
|
+
const stat = fstatSync(fd);
|
|
167
|
+
const previous = bindingCursors.get(sessionFile);
|
|
168
|
+
if (previous && previous.dev === stat.dev && previous.ino === stat.ino && previous.size === stat.size && previous.mtimeMs === stat.mtimeMs && previous.ctimeMs === stat.ctimeMs) {
|
|
169
|
+
return previous.binding && structuredClone(previous.binding);
|
|
170
|
+
}
|
|
171
|
+
const header = readHeader(fd);
|
|
172
|
+
if (!header) { bindingCursors.delete(sessionFile); return undefined; }
|
|
173
|
+
// A replaced, truncated or rewritten journal must not retain cached ownership.
|
|
174
|
+
const append = previous && previous.dev === stat.dev && previous.ino === stat.ino && previous.sessionId === header.id && stat.size > previous.size && readBoundary(fd, previous.size).equals(previous.boundary) && bindingRecordUnchanged(fd, previous);
|
|
175
|
+
const cursor: BindingCursor = append ? previous : {
|
|
176
|
+
dev: stat.dev, ino: stat.ino, size: 0, mtimeMs: 0, ctimeMs: 0,
|
|
177
|
+
sessionId: header.id, boundary: Buffer.alloc(0), pending: [], lineStart: 0,
|
|
178
|
+
};
|
|
179
|
+
let position = cursor.size;
|
|
180
|
+
while (position < stat.size) {
|
|
181
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, stat.size - position));
|
|
182
|
+
const count = readSync(fd, buffer, 0, buffer.length, position);
|
|
183
|
+
if (!count) break;
|
|
184
|
+
let start = 0;
|
|
185
|
+
for (let index = 0; index < count; index++) {
|
|
186
|
+
if (buffer[index] !== 10) continue;
|
|
187
|
+
cursor.pending.push(buffer.subarray(start, index));
|
|
188
|
+
const line = Buffer.concat(cursor.pending).toString("utf8");
|
|
189
|
+
const lineStart = cursor.lineStart;
|
|
190
|
+
cursor.lineStart = position + index + 1;
|
|
191
|
+
cursor.pending = [];
|
|
192
|
+
start = index + 1;
|
|
193
|
+
if (!line.includes(SESSION_BINDING_TYPE)) continue;
|
|
194
|
+
try {
|
|
195
|
+
const row = JSON.parse(line), binding = row.data;
|
|
196
|
+
if (row.type !== "custom" || row.customType !== SESSION_BINDING_TYPE) continue;
|
|
197
|
+
if (binding?.version !== 1 || binding.sessionId !== header.id || typeof binding.storeDir !== "string" || !isAbsolute(binding.storeDir)) continue;
|
|
198
|
+
if (binding.child && (!binding.child.lineage?.rootDir || !binding.child.lineage?.nodeId || !binding.child.environment)) continue;
|
|
199
|
+
cursor.binding = binding;
|
|
200
|
+
cursor.record = { offset: lineStart, bytes: Buffer.from(line + "\n") };
|
|
201
|
+
} catch { /* Ignore malformed complete records, as before. */ }
|
|
202
|
+
}
|
|
203
|
+
if (start < count) cursor.pending.push(buffer.subarray(start, count));
|
|
204
|
+
position += count;
|
|
205
|
+
}
|
|
206
|
+
cursor.size = position;
|
|
207
|
+
cursor.mtimeMs = stat.mtimeMs; cursor.ctimeMs = stat.ctimeMs;
|
|
208
|
+
cursor.boundary = readBoundary(fd, position);
|
|
209
|
+
bindingCursors.delete(sessionFile);
|
|
210
|
+
bindingCursors.set(sessionFile, cursor);
|
|
211
|
+
if (bindingCursors.size > 128) bindingCursors.delete(bindingCursors.keys().next().value!);
|
|
212
|
+
return cursor.binding && structuredClone(cursor.binding);
|
|
213
|
+
} catch { bindingCursors.delete(sessionFile); return undefined; }
|
|
214
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Use the store carried by the session, even when it moved to another agent directory. */
|
|
218
|
+
export function sessionBindingStore(sessionFile?: string, agentDir?: string): string {
|
|
219
|
+
return (sessionFile ? readSessionBinding(sessionFile)?.storeDir : undefined)
|
|
220
|
+
?? join(agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "subagent-sessions");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ownerDir(storeDir: string, sessionId: string): string {
|
|
224
|
+
return join(storeDir, "owners", encodeURIComponent(sessionId));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Index each lineage independently so simultaneous launches cannot overwrite siblings. */
|
|
228
|
+
export function registerSessionLineage(storeDir: string, sessionId: string, rootDir: string): void {
|
|
229
|
+
writeBindingFile(join(ownerDir(storeDir, sessionId), `${createHash("sha256").update(rootDir).digest("hex")}.json`), { version: 1, sessionId, rootDir });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Find ownership by the durable session key, not the current cwd or file path. */
|
|
233
|
+
export function sessionLineageRoots(storeDir: string, sessionId: string): string[] {
|
|
234
|
+
const directory = ownerDir(storeDir, sessionId);
|
|
235
|
+
let names: string[];
|
|
236
|
+
try { names = readdirSync(directory); } catch { return []; }
|
|
237
|
+
return names.filter((name) => name.endsWith(".json")).flatMap((name) => {
|
|
238
|
+
const record = readJson(join(directory, name));
|
|
239
|
+
return record?.version === 1 && record.sessionId === sessionId && typeof record.rootDir === "string" && isAbsolute(record.rootDir) ? [record.rootDir] : [];
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Seed child identity before starting Pi. A running session must use pi.appendEntry instead. */
|
|
244
|
+
export function seedSessionBinding(sessionFile: string, binding: Omit<SessionBinding, "sessionId">): SessionBinding {
|
|
245
|
+
const raw = readFileSync(sessionFile, "utf8");
|
|
246
|
+
const rows = raw.trim().split("\n").map((line) => JSON.parse(line));
|
|
247
|
+
const header = rows[0];
|
|
248
|
+
if (header.type !== "session" || typeof header.id !== "string") throw new Error("Cannot bind a child without a session header");
|
|
249
|
+
const data: SessionBinding = { ...binding, sessionId: header.id };
|
|
250
|
+
appendFileSync(sessionFile, JSON.stringify({ type: "custom", id: randomUUID(), parentId: rows.at(-1)?.id ?? null, timestamp: new Date().toISOString(), customType: SESSION_BINDING_TYPE, data }) + "\n");
|
|
251
|
+
const fd = openSync(sessionFile, "r");
|
|
252
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parentPid(pid: number): number | undefined {
|
|
257
|
+
try {
|
|
258
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
259
|
+
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[1]) || undefined;
|
|
260
|
+
} catch { return undefined; }
|
|
261
|
+
}
|
|
262
|
+
function startTime(pid: number): string | undefined {
|
|
263
|
+
try {
|
|
264
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
265
|
+
return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[19];
|
|
266
|
+
} catch { return undefined; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Capture the allocated shell without claiming that its Pi session has started. */
|
|
270
|
+
export function captureLaunchPaneOwnership(paneId: string, shellPid: number | undefined): LaunchPaneOwnership | undefined {
|
|
271
|
+
if (!shellPid || !Number.isInteger(shellPid) || shellPid <= 0) return undefined;
|
|
272
|
+
try {
|
|
273
|
+
const shellStart = startTime(shellPid);
|
|
274
|
+
const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
275
|
+
return shellStart && bootId ? { paneId, shellPid, shellStart, bootId } : undefined;
|
|
276
|
+
} catch { return undefined; }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** An old launch cannot take ownership of a reused shell or another machine boot. */
|
|
280
|
+
export function launchOwnsPane(owner: LaunchPaneOwnership, paneId: string, shellPid: number | undefined): boolean {
|
|
281
|
+
const current = captureLaunchPaneOwnership(paneId, shellPid);
|
|
282
|
+
return !!current && owner.paneId === current.paneId && owner.shellPid === current.shellPid &&
|
|
283
|
+
owner.shellStart === current.shellStart && owner.bootId === current.bootId;
|
|
284
|
+
}
|
|
285
|
+
/** Publish one startup's exact file and pane, surviving both Teleport and Herdr restarts. */
|
|
286
|
+
export function publishSessionLocation(binding: SessionBinding, input: Omit<SessionLocation, "version" | "sessionId" | "pid" | "processStart">): SessionLocation {
|
|
287
|
+
const location: SessionLocation = {
|
|
288
|
+
version: 1, sessionId: binding.sessionId, ...input, pid: process.pid, processStart: startTime(process.pid),
|
|
289
|
+
...(binding.child ? { childNodeId: binding.child.lineage.nodeId, childRootDir: binding.child.lineage.rootDir } : {}),
|
|
290
|
+
};
|
|
291
|
+
writeBindingFile(join(binding.storeDir, "locations", `${encodeURIComponent(binding.sessionId)}.json`), location);
|
|
292
|
+
if (binding.child) writeBindingFile(join(binding.child.lineage.rootDir, "locations", `${encodeURIComponent(binding.child.lineage.nodeId)}.json`), location);
|
|
293
|
+
return location;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Hold child completion while Teleport starts a replacement, with a bounded failure gate. */
|
|
297
|
+
export function beginChildSessionMove(binding: SessionBinding): void {
|
|
298
|
+
if (!binding.child) return;
|
|
299
|
+
const path = join(binding.child.lineage.rootDir, "locations", `${encodeURIComponent(binding.child.lineage.nodeId)}.json`);
|
|
300
|
+
const location = readJson(path);
|
|
301
|
+
if (validLocation(location) && location.pid === process.pid) writeBindingFile(path, { ...location, moveDeadline: Date.now() + 15_000 });
|
|
302
|
+
}
|
|
303
|
+
/** A dead process still supplies a recoverable saved location, never a live receiver. */
|
|
304
|
+
export function sessionLocationAlive(location: SessionLocation): boolean {
|
|
305
|
+
try { process.kill(location.pid, 0); } catch { return false; }
|
|
306
|
+
return location.processStart !== undefined && startTime(location.pid) === location.processStart;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A lost pane is not a user cancellation when its Herdr incarnation also died. */
|
|
310
|
+
export function sessionServerAlive(location: SessionLocation): boolean | undefined {
|
|
311
|
+
if (!location.serverPid || !location.serverStart) return undefined;
|
|
312
|
+
return startTime(location.serverPid) === location.serverStart;
|
|
313
|
+
}
|
|
314
|
+
/** Allow pane cleanup only while its original shell incarnation still owns it. */
|
|
315
|
+
export function sessionOwnsPane(location: SessionLocation, paneId: string, shellPid: number | undefined): boolean {
|
|
316
|
+
return location.paneId === paneId && location.shellPid === shellPid && shellPid !== undefined
|
|
317
|
+
&& location.shellStart !== undefined && startTime(shellPid) === location.shellStart;
|
|
318
|
+
}
|
|
319
|
+
function validLocation(value: any): value is SessionLocation {
|
|
320
|
+
return value?.version === 1 && typeof value.sessionId === "string" && typeof value.sessionFile === "string" && isAbsolute(value.sessionFile) && Number.isInteger(value.pid) && value.pid > 0 && typeof value.cwd === "string";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resolve the last registered incarnation of a child. */
|
|
324
|
+
export function childSessionLocation(rootDir: string, nodeId: string): SessionLocation | undefined {
|
|
325
|
+
const value = readJson(join(rootDir, "locations", `${encodeURIComponent(nodeId)}.json`));
|
|
326
|
+
if (!validLocation(value)) return undefined;
|
|
327
|
+
const binding = readSessionBinding(value.sessionFile);
|
|
328
|
+
if (binding?.child?.lineage.rootDir === rootDir && binding.child.lineage.nodeId === nodeId) return value;
|
|
329
|
+
// Teleport may remove the source before the replacement finishes extension startup.
|
|
330
|
+
if (value.childRootDir === rootDir && value.childNodeId === nodeId && !existsSync(value.sessionFile)) return value;
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Resolve a parent with matching session evidence; a copied branch is not a receiver. */
|
|
335
|
+
export function parentSessionLocation(storeDir: string, sessionId: string): SessionLocation | undefined {
|
|
336
|
+
const value = readJson(join(storeDir, "locations", `${encodeURIComponent(sessionId)}.json`));
|
|
337
|
+
if (!validLocation(value) || value.sessionId !== sessionId) return undefined;
|
|
338
|
+
return readSessionHeader(value.sessionFile)?.id === sessionId ? value : undefined;
|
|
339
|
+
}
|