larkin 0.5.4 → 0.5.5
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 +6 -0
- package/README.md +7 -1
- package/artifacts/release/THIRD_PARTY_NOTICES.txt +1 -100
- package/dist/agent/context-prompt.cjs +16 -17
- package/dist/agent/context-prompt.mjs +15 -16
- package/dist/runtime/pi-state-dir.cjs +105 -0
- package/dist/runtime/pi-state-dir.mjs +94 -0
- package/dist/runtime/pi-tmux-extension.mjs +137 -0
- package/dist/runtime/pi-tmux-injection.mjs +49 -0
- package/dist/runtime/pi-tmux.bundle.js +600 -0
- package/dist/runtime/pi-tmux.mjs +408 -0
- package/dist/runtime/runtime-adapters.mjs +26 -113
- package/dist/runtime/runtime-host.cjs +3 -413
- package/dist/runtime/runtime-host.mjs +2 -412
- package/package.json +2 -8
- package/dist/runtime/pi-bash-timeout-extension.mjs +0 -60
- package/dist/runtime/pi-bash-timeout-injection.mjs +0 -54
- package/dist/runtime/pi-bash-timeout.bundle.js +0 -43
- package/dist/runtime/pi-extension-api.mjs +0 -1
- package/dist/runtime/pi-subagent-injection.mjs +0 -290
- package/dist/runtime/pi-subagent-ledger.cjs +0 -632
- package/dist/runtime/pi-subagent-ledger.mjs +0 -594
- package/dist/runtime/pi-subagent-record-watchdog-injection.mjs +0 -43
- package/dist/runtime/pi-subagent-record-watchdog.bundle.js +0 -183
- package/dist/runtime/pi-subagent-record-watchdog.mjs +0 -46
- package/dist/runtime/pi-subagents-notification.mjs +0 -175
- package/dist/runtime/pi-subagents.bundle.js +0 -9110
- package/dist/runtime/pi-supervised-command-extension.mjs +0 -72
- package/dist/runtime/pi-supervised-command.bundle.js +0 -4735
- package/dist/runtime/pi-supervised-command.mjs +0 -420
- package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +0 -945
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensureOwnedTree, mkdirPrivateExclusive, writePrivateExclusive } from "./pi-state-dir.mjs";
|
|
6
|
+
export const DEFAULT_BASH_WAIT_SECONDS = 30;
|
|
7
|
+
export const OUTPUT_TAIL_BYTES = 32 * 1024;
|
|
8
|
+
export const LARKIN_TMUX_COMPLETION_TYPE = "larkin-tmux-completion";
|
|
9
|
+
export class ForeignTmuxTaskError extends Error {
|
|
10
|
+
taskId;
|
|
11
|
+
constructor(taskId) {
|
|
12
|
+
super(`tmux task ${taskId} is not owned by this Larkin instance`);
|
|
13
|
+
this.name = "ForeignTmuxTaskError";
|
|
14
|
+
this.taskId = taskId;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function tmuxAvailable(env = process.env, platform = process.platform) {
|
|
18
|
+
if (platform === "win32")
|
|
19
|
+
return false;
|
|
20
|
+
const result = spawnSync("tmux", ["-V"], { env: tmuxClientEnv(env), encoding: "utf8", timeout: 5_000 });
|
|
21
|
+
const version = /tmux\s+(\d+)\.(\d+)/i.exec(`${result.stdout || ""} ${result.stderr || ""}`);
|
|
22
|
+
return result.status === 0 && version !== null
|
|
23
|
+
&& (Number(version[1]) > 3 || (Number(version[1]) === 3 && Number(version[2]) >= 2));
|
|
24
|
+
}
|
|
25
|
+
function posixQuote(value) {
|
|
26
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
27
|
+
}
|
|
28
|
+
function sanitizeName(value, fallback) {
|
|
29
|
+
const cleaned = value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24);
|
|
30
|
+
return cleaned || fallback;
|
|
31
|
+
}
|
|
32
|
+
function taskRoot(stateDir, agentId, instanceId) {
|
|
33
|
+
return path.join(stateDir, "pi-tmux", sanitizeName(agentId, "agent"), sanitizeName(instanceId, "inst"));
|
|
34
|
+
}
|
|
35
|
+
function taskDir(stateDir, agentId, instanceId, taskId) {
|
|
36
|
+
return path.join(taskRoot(stateDir, agentId, instanceId), taskId);
|
|
37
|
+
}
|
|
38
|
+
function sessionName(agentId, instanceId, taskId) {
|
|
39
|
+
return `lkn-${sanitizeName(agentId, "agent")}-${sanitizeName(instanceId, "inst")}-${taskId}`;
|
|
40
|
+
}
|
|
41
|
+
function readText(file) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.readFileSync(file, "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function writePrivate(file, body) {
|
|
50
|
+
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
51
|
+
fs.chmodSync(file, 0o600);
|
|
52
|
+
}
|
|
53
|
+
function isRegularFile(file) {
|
|
54
|
+
try {
|
|
55
|
+
const stat = fs.lstatSync(file);
|
|
56
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function isInside(root, candidate) {
|
|
63
|
+
const relative = path.relative(root, candidate);
|
|
64
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
65
|
+
}
|
|
66
|
+
function tailBytes(file, maxBytes) {
|
|
67
|
+
try {
|
|
68
|
+
const fd = fs.openSync(file, "r");
|
|
69
|
+
try {
|
|
70
|
+
const size = fs.fstatSync(fd).size;
|
|
71
|
+
const start = size > maxBytes ? size - maxBytes : 0;
|
|
72
|
+
const buf = Buffer.alloc(size - start);
|
|
73
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
74
|
+
return buf.toString("utf8");
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function writeEnvScript(file, env) {
|
|
85
|
+
const lines = ["#!/bin/bash", "set -eu"];
|
|
86
|
+
for (const [key, value] of Object.entries(env)) {
|
|
87
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
88
|
+
continue;
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
// 不写入继承的 tmux 会话坐标;pane 会带上本次 new-session 的值。
|
|
92
|
+
if (key === "PWD" || key === "TMUX" || key === "TMUX_PANE")
|
|
93
|
+
continue;
|
|
94
|
+
lines.push(`export ${key}=${posixQuote(value)}`);
|
|
95
|
+
}
|
|
96
|
+
writePrivateExclusive(file, `${lines.join("\n")}\n`);
|
|
97
|
+
}
|
|
98
|
+
function tmuxClientEnv(env) {
|
|
99
|
+
const next = { ...env };
|
|
100
|
+
delete next.TMUX;
|
|
101
|
+
delete next.TMUX_PANE;
|
|
102
|
+
delete next.BASH_ENV;
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
function tmuxHasSession(name, env) {
|
|
106
|
+
return spawnSync("tmux", ["has-session", "-t", `=${name}`], { env: tmuxClientEnv(env), encoding: "utf8", timeout: 5_000 }).status === 0;
|
|
107
|
+
}
|
|
108
|
+
function killSession(name, env) {
|
|
109
|
+
spawnSync("tmux", ["kill-session", "-t", `=${name}`], { env: tmuxClientEnv(env), encoding: "utf8", timeout: 5_000 });
|
|
110
|
+
}
|
|
111
|
+
function parseExitCode(raw) {
|
|
112
|
+
if (raw === null)
|
|
113
|
+
return null;
|
|
114
|
+
const value = Number.parseInt(raw.trim(), 10);
|
|
115
|
+
return Number.isInteger(value) ? value : null;
|
|
116
|
+
}
|
|
117
|
+
function processTable() {
|
|
118
|
+
const listed = spawnSync("ps", ["-ax", "-o", "pid=,ppid=,pgid=,state="], { encoding: "utf8", timeout: 5_000 });
|
|
119
|
+
if (listed.status !== 0)
|
|
120
|
+
return [];
|
|
121
|
+
return listed.stdout.split("\n").flatMap((line) => {
|
|
122
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/);
|
|
123
|
+
if (!match)
|
|
124
|
+
return [];
|
|
125
|
+
return [{
|
|
126
|
+
pid: Number.parseInt(match[1], 10),
|
|
127
|
+
ppid: Number.parseInt(match[2], 10),
|
|
128
|
+
pgid: Number.parseInt(match[3], 10),
|
|
129
|
+
state: match[4],
|
|
130
|
+
}];
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function liveOwnedPanePid(session, env) {
|
|
134
|
+
if (!tmuxHasSession(session, env))
|
|
135
|
+
return null;
|
|
136
|
+
const listed = spawnSync("tmux", ["list-panes", "-t", `=${session}`, "-F", "#{pane_pid}"], {
|
|
137
|
+
env: tmuxClientEnv(env), encoding: "utf8", timeout: 5_000,
|
|
138
|
+
});
|
|
139
|
+
if (listed.status !== 0)
|
|
140
|
+
return null;
|
|
141
|
+
const pid = Number.parseInt(listed.stdout.trim().split(/\s+/)[0] ?? "", 10);
|
|
142
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return pid;
|
|
151
|
+
}
|
|
152
|
+
function ownedPids(panePid, rows) {
|
|
153
|
+
const pane = rows.find((row) => row.pid === panePid);
|
|
154
|
+
const owned = new Set([panePid]);
|
|
155
|
+
if (pane) {
|
|
156
|
+
for (const row of rows)
|
|
157
|
+
if (row.pgid === pane.pgid)
|
|
158
|
+
owned.add(row.pid);
|
|
159
|
+
}
|
|
160
|
+
let grew = true;
|
|
161
|
+
while (grew) {
|
|
162
|
+
grew = false;
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
if (owned.has(row.ppid) && !owned.has(row.pid) && !row.state.startsWith("Z")) {
|
|
165
|
+
owned.add(row.pid);
|
|
166
|
+
grew = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...owned].filter((pid) => pid > 1 && pid !== process.pid);
|
|
171
|
+
}
|
|
172
|
+
function pidAlive(pid) {
|
|
173
|
+
const listed = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8", timeout: 5_000 });
|
|
174
|
+
const state = listed.stdout.trim();
|
|
175
|
+
return listed.status === 0 && Boolean(state) && !state.startsWith("Z");
|
|
176
|
+
}
|
|
177
|
+
function signalGroup(pgid, signal) {
|
|
178
|
+
try {
|
|
179
|
+
process.kill(-pgid, signal);
|
|
180
|
+
}
|
|
181
|
+
catch { /* 组已不存在或不是 leader */ }
|
|
182
|
+
}
|
|
183
|
+
/** 只杀当前 pane 进程组及其子孙;不读落盘 pid。SIGKILL 覆盖 trap 忽略 TERM/HUP。 */
|
|
184
|
+
function terminateOwnedPane(session, env) {
|
|
185
|
+
const panePid = liveOwnedPanePid(session, env);
|
|
186
|
+
const rows = processTable();
|
|
187
|
+
const pane = rows.find((row) => row.pid === panePid);
|
|
188
|
+
const pids = panePid ? ownedPids(panePid, rows) : [];
|
|
189
|
+
if (pane)
|
|
190
|
+
signalGroup(pane.pgid, "SIGTERM");
|
|
191
|
+
killSession(session, env);
|
|
192
|
+
if (pane)
|
|
193
|
+
signalGroup(pane.pgid, "SIGKILL");
|
|
194
|
+
for (const pid of pids) {
|
|
195
|
+
try {
|
|
196
|
+
process.kill(pid, "SIGKILL");
|
|
197
|
+
}
|
|
198
|
+
catch { /* already gone */ }
|
|
199
|
+
}
|
|
200
|
+
if (tmuxHasSession(session, env))
|
|
201
|
+
killSession(session, env);
|
|
202
|
+
if (tmuxHasSession(session, env))
|
|
203
|
+
throw new Error(`tmux session ${session} still present after kill`);
|
|
204
|
+
const deadline = Date.now() + 250;
|
|
205
|
+
while (Date.now() < deadline && pids.some(pidAlive)) {
|
|
206
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
207
|
+
}
|
|
208
|
+
if (pids.some(pidAlive))
|
|
209
|
+
throw new Error(`tmux pane descendants still present after kill`);
|
|
210
|
+
}
|
|
211
|
+
function snapshotFromDir(dir, taskId, env, session) {
|
|
212
|
+
// 先探活再读终态:exit_code 在 shell 退出前落盘。先读再 has-session 会把探测窗口里写完的快命令判成 failed+null。
|
|
213
|
+
const live = tmuxHasSession(session, env);
|
|
214
|
+
const readExit = () => parseExitCode(readText(path.join(dir, "exit_code")));
|
|
215
|
+
const cancelled = readText(path.join(dir, "cancelled")) !== null;
|
|
216
|
+
let exitCode = readExit();
|
|
217
|
+
if (!live && exitCode === null)
|
|
218
|
+
exitCode = readExit();
|
|
219
|
+
const startedAt = readText(path.join(dir, "started_at"))?.trim() || null;
|
|
220
|
+
const endedAt = readText(path.join(dir, "ended_at"))?.trim() || null;
|
|
221
|
+
const output = tailBytes(path.join(dir, "output"), OUTPUT_TAIL_BYTES);
|
|
222
|
+
let status;
|
|
223
|
+
if (exitCode === null) {
|
|
224
|
+
if (live)
|
|
225
|
+
status = cancelled ? "cancelled" : "running";
|
|
226
|
+
else
|
|
227
|
+
status = cancelled ? "cancelled" : "failed";
|
|
228
|
+
}
|
|
229
|
+
else if (cancelled)
|
|
230
|
+
status = "cancelled";
|
|
231
|
+
else
|
|
232
|
+
status = exitCode === 0 ? "completed" : "failed";
|
|
233
|
+
return { taskId, status, exitCode, output, startedAt, endedAt };
|
|
234
|
+
}
|
|
235
|
+
export function formatTmuxTaskText(snapshot) {
|
|
236
|
+
const exit = snapshot.exitCode === null ? "null" : String(snapshot.exitCode);
|
|
237
|
+
const body = snapshot.output ? `\n${snapshot.output}` : "";
|
|
238
|
+
return `taskId=${snapshot.taskId} status=${snapshot.status} exitCode=${exit}${body}`;
|
|
239
|
+
}
|
|
240
|
+
export function createLarkinTmux(input) {
|
|
241
|
+
const env = input.env ?? process.env;
|
|
242
|
+
const agentId = input.agentId;
|
|
243
|
+
const instanceId = input.instanceId || crypto.randomUUID();
|
|
244
|
+
const stateDir = path.resolve(input.stateDir);
|
|
245
|
+
const ownedParts = ["pi-tmux", sanitizeName(agentId, "agent"), sanitizeName(instanceId, "inst")];
|
|
246
|
+
const root = taskRoot(stateDir, agentId, instanceId);
|
|
247
|
+
const expectedSession = (taskId) => sessionName(agentId, instanceId, taskId);
|
|
248
|
+
const assertPrivateScripts = (dir) => {
|
|
249
|
+
for (const name of ["env.sh", "command.sh", "run.sh", "meta.json"]) {
|
|
250
|
+
const file = path.join(dir, name);
|
|
251
|
+
if (!fs.existsSync(file)) {
|
|
252
|
+
if (name === "meta.json")
|
|
253
|
+
throw new ForeignTmuxTaskError(path.basename(dir));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!isRegularFile(file))
|
|
257
|
+
throw new ForeignTmuxTaskError(path.basename(dir));
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const requireOwned = (taskId) => {
|
|
261
|
+
if (!/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
262
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
263
|
+
let instanceRoot;
|
|
264
|
+
try {
|
|
265
|
+
instanceRoot = ensureOwnedTree(stateDir, ownedParts, false);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
269
|
+
}
|
|
270
|
+
const dir = taskDir(stateDir, agentId, instanceId, taskId);
|
|
271
|
+
try {
|
|
272
|
+
const stat = fs.lstatSync(dir);
|
|
273
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || !isInside(instanceRoot, dir))
|
|
274
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error instanceof ForeignTmuxTaskError)
|
|
278
|
+
throw error;
|
|
279
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
280
|
+
}
|
|
281
|
+
const metaFile = path.join(dir, "meta.json");
|
|
282
|
+
if (!isRegularFile(metaFile))
|
|
283
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
284
|
+
let meta;
|
|
285
|
+
try {
|
|
286
|
+
meta = JSON.parse(fs.readFileSync(metaFile, "utf8"));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
290
|
+
}
|
|
291
|
+
if (meta.taskId !== taskId
|
|
292
|
+
|| meta.agentId !== agentId
|
|
293
|
+
|| meta.instanceId !== instanceId
|
|
294
|
+
|| meta.session !== expectedSession(taskId)
|
|
295
|
+
|| typeof meta.cwd !== "string")
|
|
296
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
297
|
+
try {
|
|
298
|
+
assertPrivateScripts(dir);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof ForeignTmuxTaskError)
|
|
302
|
+
throw error;
|
|
303
|
+
throw new ForeignTmuxTaskError(taskId);
|
|
304
|
+
}
|
|
305
|
+
return dir;
|
|
306
|
+
};
|
|
307
|
+
const start = (command, cwd) => {
|
|
308
|
+
const taskId = crypto.randomBytes(8).toString("hex");
|
|
309
|
+
const parent = ensureOwnedTree(stateDir, ownedParts);
|
|
310
|
+
const dir = path.join(parent, taskId);
|
|
311
|
+
mkdirPrivateExclusive(dir);
|
|
312
|
+
if (!tmuxAvailable(env))
|
|
313
|
+
throw new Error("tmux is not available");
|
|
314
|
+
const session = expectedSession(taskId);
|
|
315
|
+
writePrivateExclusive(path.join(dir, "meta.json"), `${JSON.stringify({
|
|
316
|
+
taskId, agentId, instanceId, session, cwd,
|
|
317
|
+
})}\n`);
|
|
318
|
+
writeEnvScript(path.join(dir, "env.sh"), env);
|
|
319
|
+
writePrivateExclusive(path.join(dir, "command.sh"), command.endsWith("\n") ? command : `${command}\n`);
|
|
320
|
+
const envFile = path.join(dir, "env.sh");
|
|
321
|
+
const commandFile = path.join(dir, "command.sh");
|
|
322
|
+
const outputFile = path.join(dir, "output");
|
|
323
|
+
const exitFile = path.join(dir, "exit_code");
|
|
324
|
+
const startedFile = path.join(dir, "started_at");
|
|
325
|
+
const endedFile = path.join(dir, "ended_at");
|
|
326
|
+
// 包装器用 /bin/bash;用户命令另启 bash -o pipefail,保留数组 / [[ / pipefail。
|
|
327
|
+
writePrivateExclusive(path.join(dir, "run.sh"), [
|
|
328
|
+
"#!/bin/bash",
|
|
329
|
+
`PANE_TMUX=\${TMUX-}`,
|
|
330
|
+
`PANE_TMUX_PANE=\${TMUX_PANE-}`,
|
|
331
|
+
`. ${posixQuote(envFile)}`,
|
|
332
|
+
"set +eu",
|
|
333
|
+
"unset TMUX TMUX_PANE",
|
|
334
|
+
`if [ -n "$PANE_TMUX" ]; then export TMUX="$PANE_TMUX"; fi`,
|
|
335
|
+
`if [ -n "$PANE_TMUX_PANE" ]; then export TMUX_PANE="$PANE_TMUX_PANE"; fi`,
|
|
336
|
+
`cd ${posixQuote(cwd)} || { date -u +%Y-%m-%dT%H:%M:%SZ > ${posixQuote(endedFile)}; printf '%s\\n' 127 > ${posixQuote(exitFile)}; exit 127; }`,
|
|
337
|
+
`date -u +%Y-%m-%dT%H:%M:%SZ > ${posixQuote(startedFile)}`,
|
|
338
|
+
`/bin/bash -o pipefail ${posixQuote(commandFile)} > ${posixQuote(outputFile)} 2>&1`,
|
|
339
|
+
`status=$?`,
|
|
340
|
+
`date -u +%Y-%m-%dT%H:%M:%SZ > ${posixQuote(endedFile)}`,
|
|
341
|
+
`printf '%s\\n' "$status" > ${posixQuote(exitFile)}`,
|
|
342
|
+
"",
|
|
343
|
+
].join("\n"));
|
|
344
|
+
const created = spawnSync("tmux", ["new-session", "-d", "-s", session, "-n", "bash", "-e", "BASH_ENV=", "/usr/bin/env", "-u", "BASH_ENV", "/bin/bash", path.join(dir, "run.sh")], {
|
|
345
|
+
env: tmuxClientEnv(env),
|
|
346
|
+
encoding: "utf8",
|
|
347
|
+
timeout: 5_000,
|
|
348
|
+
});
|
|
349
|
+
if (created.status !== 0) {
|
|
350
|
+
throw new Error(`tmux new-session failed: ${(created.stderr || created.stdout || "").trim() || created.status}`);
|
|
351
|
+
}
|
|
352
|
+
return snapshotFromDir(dir, taskId, env, session);
|
|
353
|
+
};
|
|
354
|
+
const peek = (taskId) => {
|
|
355
|
+
const dir = requireOwned(taskId);
|
|
356
|
+
return snapshotFromDir(dir, taskId, env, expectedSession(taskId));
|
|
357
|
+
};
|
|
358
|
+
const list = () => {
|
|
359
|
+
let instanceRoot;
|
|
360
|
+
try {
|
|
361
|
+
instanceRoot = ensureOwnedTree(stateDir, ownedParts, false);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
return fs.readdirSync(instanceRoot).sort().flatMap((taskId) => {
|
|
367
|
+
try {
|
|
368
|
+
return [peek(taskId)];
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
const wait = async (taskId, timeoutSeconds, signal) => {
|
|
376
|
+
const deadline = Date.now() + Math.max(0, timeoutSeconds) * 1000;
|
|
377
|
+
while (true) {
|
|
378
|
+
if (signal?.aborted) {
|
|
379
|
+
kill(taskId);
|
|
380
|
+
return peek(taskId);
|
|
381
|
+
}
|
|
382
|
+
const current = peek(taskId);
|
|
383
|
+
// 会话已消失但 exit 尚未落盘时继续等到 WAIT 窗口结束,避免快命令被误判 failed。
|
|
384
|
+
const flushing = current.status === "failed" && current.exitCode === null;
|
|
385
|
+
if (current.status !== "running" && !flushing)
|
|
386
|
+
return current;
|
|
387
|
+
if (Date.now() >= deadline)
|
|
388
|
+
return current;
|
|
389
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const kill = (taskId) => {
|
|
393
|
+
const dir = requireOwned(taskId);
|
|
394
|
+
const session = expectedSession(taskId);
|
|
395
|
+
if (parseExitCode(readText(path.join(dir, "exit_code"))) !== null)
|
|
396
|
+
return snapshotFromDir(dir, taskId, env, session);
|
|
397
|
+
const live = tmuxHasSession(session, env) || liveOwnedPanePid(session, env) !== null;
|
|
398
|
+
if (live) {
|
|
399
|
+
writePrivate(path.join(dir, "cancelled"), "1\n");
|
|
400
|
+
terminateOwnedPane(session, env);
|
|
401
|
+
if (!readText(path.join(dir, "ended_at"))) {
|
|
402
|
+
writePrivate(path.join(dir, "ended_at"), `${new Date().toISOString().slice(0, 19)}Z\n`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return snapshotFromDir(dir, taskId, env, session);
|
|
406
|
+
};
|
|
407
|
+
return { start, peek, list, wait, kill, root, instanceId };
|
|
408
|
+
}
|
|
@@ -6,11 +6,8 @@ import { createInterface } from "node:readline";
|
|
|
6
6
|
import { isPiThinkingLevel } from "./pi-model-catalog.mjs";
|
|
7
7
|
import { PiRpcClient } from "./pi-rpc-client.mjs";
|
|
8
8
|
import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { resolvePiSubagentRecordWatchdogExtensionArg } from "./pi-subagent-record-watchdog-injection.mjs";
|
|
12
|
-
import { effectivePiStateDir, extractBackgroundPiSubagentDispatch } from "./pi-subagent-ledger.mjs";
|
|
13
|
-
import { resolvePiBashTimeoutExtensionArg } from "./pi-bash-timeout-injection.mjs";
|
|
9
|
+
import { effectivePiStateDir, writePrivateAtomic } from "./pi-state-dir.mjs";
|
|
10
|
+
import { resolvePiTmuxExtensionArg } from "./pi-tmux-injection.mjs";
|
|
14
11
|
import { classifyPiMissingCredentialRejection, classifyRuntimePrerequisite, missingProviderCredentialReadiness, probeNativeRuntimeReadiness, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
|
|
15
12
|
import { buildStrictProviderErrorInput, classifyStrictProviderError, INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON, safeProviderDiagnostic, } from "./provider-error-classifier.mjs";
|
|
16
13
|
import { assertEffectivePiCompactionSettings, MINIMUM_PI_VERSION, calculatePiCompactionSettings, parsePiExecutableVersion, projectPiSettingsFile, readOwnedPiSettings, verifyPiCapabilities, writeOwnedPiSettings, } from "./pi-compaction-recovery.mjs";
|
|
@@ -482,9 +479,6 @@ class PiSession extends EventSession {
|
|
|
482
479
|
observedSubmitEpochs = new Set();
|
|
483
480
|
observedAcceptedEpochs = new Set();
|
|
484
481
|
observedCompletedEpochs = new Set();
|
|
485
|
-
observedBackgroundCompletionKeys = new Set();
|
|
486
|
-
pendingUnownedCompletionKeys = new Set();
|
|
487
|
-
pendingUnownedCompletionStatuses = new Map();
|
|
488
482
|
observedAgentEndEpochs = new Set();
|
|
489
483
|
firstOutputObserved = false;
|
|
490
484
|
toolCallOpen = false;
|
|
@@ -533,9 +527,9 @@ class PiSession extends EventSession {
|
|
|
533
527
|
}
|
|
534
528
|
async close(_reason) { this.unsubscribe?.(); await this.sdk.dispose?.(); }
|
|
535
529
|
async enqueue(input, operation) {
|
|
536
|
-
if (this.ownedInputIds.size === 0)
|
|
530
|
+
if (this.ownedInputIds.size === 0 && this.activeEpoch === null)
|
|
537
531
|
this.requestEpoch += 1;
|
|
538
|
-
const epoch = this.requestEpoch;
|
|
532
|
+
const epoch = this.activeEpoch ?? this.requestEpoch;
|
|
539
533
|
this.ownedInputIds.add(input.inputId);
|
|
540
534
|
this.inputEpochs.set(input.inputId, epoch);
|
|
541
535
|
this.awaitingAcknowledgement.add(input.inputId);
|
|
@@ -598,9 +592,6 @@ class PiSession extends EventSession {
|
|
|
598
592
|
this.observedSubmitEpochs.clear();
|
|
599
593
|
this.observedAcceptedEpochs.clear();
|
|
600
594
|
this.observedCompletedEpochs.clear();
|
|
601
|
-
this.observedBackgroundCompletionKeys.clear();
|
|
602
|
-
this.pendingUnownedCompletionKeys.clear();
|
|
603
|
-
this.pendingUnownedCompletionStatuses.clear();
|
|
604
595
|
this.observedAgentEndEpochs.clear();
|
|
605
596
|
this.activeEpoch = null;
|
|
606
597
|
this.settleArmedEpoch = null;
|
|
@@ -620,11 +611,19 @@ class PiSession extends EventSession {
|
|
|
620
611
|
this.emitObservation("retry_progress");
|
|
621
612
|
}
|
|
622
613
|
else if (event?.type === "turn_start") {
|
|
623
|
-
|
|
624
|
-
if (epoch === null || this.activeEpoch !== null)
|
|
614
|
+
if (this.activeEpoch !== null)
|
|
625
615
|
return;
|
|
626
|
-
|
|
627
|
-
|
|
616
|
+
const owned = this.oldestOwnedEpoch();
|
|
617
|
+
if (owned === null) {
|
|
618
|
+
// Native Pi triggerTurn / extension follow-up: occupy busy without a host prompt.
|
|
619
|
+
this.requestEpoch += 1;
|
|
620
|
+
this.activeEpoch = this.requestEpoch;
|
|
621
|
+
this.settleArmedEpoch = this.activeEpoch;
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
this.activeEpoch = owned;
|
|
625
|
+
this.settleArmedEpoch = null;
|
|
626
|
+
}
|
|
628
627
|
this.firstOutputObserved = false;
|
|
629
628
|
this.toolCallOpen = false;
|
|
630
629
|
this.emitObservation("turn_start");
|
|
@@ -650,46 +649,13 @@ class PiSession extends EventSession {
|
|
|
650
649
|
this.observedCompletedEpochs.add(this.activeEpoch);
|
|
651
650
|
this.emitObservation("completed");
|
|
652
651
|
}
|
|
653
|
-
const completionNotification = extractCanonicalPiSubagentNotification(event.messages);
|
|
654
|
-
if (completionNotification && !this.observedBackgroundCompletionKeys.has(completionNotification.key)) {
|
|
655
|
-
const completionStatuses = Object.fromEntries(completionNotification.notifications.map((notification) => [
|
|
656
|
-
notification.taskId,
|
|
657
|
-
ledgerStatusFromPiNotificationStatus(notification.status),
|
|
658
|
-
]));
|
|
659
|
-
const owningTurnFailed = event.willRetry === true
|
|
660
|
-
|| this.finalAssistantStopReason === "error"
|
|
661
|
-
|| this.finalAssistantStopReason === "aborted";
|
|
662
|
-
if (this.activeEpoch === null || owningTurnFailed) {
|
|
663
|
-
// Unowned agent_end still has an active Pi session, so prompting
|
|
664
|
-
// before settle is rejected as "Agent is already processing".
|
|
665
|
-
// A failed or retrying owned turn also did not process the
|
|
666
|
-
// notification; do not emit handledInTurn so RuntimeHost can still
|
|
667
|
-
// wake after input-error, retry, or restart.
|
|
668
|
-
this.pendingUnownedCompletionKeys.add(completionNotification.key);
|
|
669
|
-
this.pendingUnownedCompletionStatuses.set(completionNotification.key, completionStatuses);
|
|
670
|
-
}
|
|
671
|
-
else {
|
|
672
|
-
// Already visible in the owned turn. Persist as acknowledged; do not
|
|
673
|
-
// schedule another wake after the parent turn settles.
|
|
674
|
-
this.observedBackgroundCompletionKeys.add(completionNotification.key);
|
|
675
|
-
this.emitObservation("completed", {
|
|
676
|
-
completionKey: completionNotification.key,
|
|
677
|
-
completionStatuses,
|
|
678
|
-
handledInTurn: true,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
652
|
}
|
|
683
653
|
else if (event?.type === "agent_settled") {
|
|
684
654
|
const epoch = this.activeEpoch;
|
|
685
|
-
if (epoch === null)
|
|
686
|
-
this.flushPendingUnownedCompletions();
|
|
655
|
+
if (epoch === null)
|
|
687
656
|
return;
|
|
688
|
-
|
|
689
|
-
if (this.settleArmedEpoch !== epoch) {
|
|
690
|
-
this.flushPendingUnownedCompletions();
|
|
657
|
+
if (this.settleArmedEpoch !== epoch)
|
|
691
658
|
return;
|
|
692
|
-
}
|
|
693
659
|
this.activeEpoch = null;
|
|
694
660
|
this.settleArmedEpoch = null;
|
|
695
661
|
const error = this.finalAssistantError;
|
|
@@ -730,7 +696,6 @@ class PiSession extends EventSession {
|
|
|
730
696
|
this.observedAcceptedEpochs.delete(epoch);
|
|
731
697
|
this.observedCompletedEpochs.delete(epoch);
|
|
732
698
|
this.observedAgentEndEpochs.delete(epoch);
|
|
733
|
-
this.flushPendingUnownedCompletions();
|
|
734
699
|
}
|
|
735
700
|
else if (event?.type === "tool_execution_start") {
|
|
736
701
|
if (!this.firstOutputObserved) {
|
|
@@ -748,13 +713,6 @@ class PiSession extends EventSession {
|
|
|
748
713
|
this.toolCallOpen = false;
|
|
749
714
|
this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "tool_result" });
|
|
750
715
|
}
|
|
751
|
-
const dispatched = extractBackgroundPiSubagentDispatch(event);
|
|
752
|
-
if (dispatched) {
|
|
753
|
-
this.emitObservation("background_dispatched", {
|
|
754
|
-
taskId: dispatched.taskId,
|
|
755
|
-
...(dispatched.outputFile ? { outputFile: dispatched.outputFile } : {}),
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
716
|
}
|
|
759
717
|
else if (event?.type === "message_update" && event.assistantMessageEvent?.delta) {
|
|
760
718
|
if (!this.firstOutputObserved) {
|
|
@@ -765,20 +723,6 @@ class PiSession extends EventSession {
|
|
|
765
723
|
this.emit({ type: "activity", activity: kind, text: String(event.assistantMessageEvent.delta) });
|
|
766
724
|
}
|
|
767
725
|
}
|
|
768
|
-
flushPendingUnownedCompletions() {
|
|
769
|
-
for (const completionKey of this.pendingUnownedCompletionKeys) {
|
|
770
|
-
if (this.observedBackgroundCompletionKeys.has(completionKey))
|
|
771
|
-
continue;
|
|
772
|
-
this.observedBackgroundCompletionKeys.add(completionKey);
|
|
773
|
-
const completionStatuses = this.pendingUnownedCompletionStatuses.get(completionKey);
|
|
774
|
-
this.emitObservation("completed", {
|
|
775
|
-
completionKey,
|
|
776
|
-
...(completionStatuses ? { completionStatuses } : {}),
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
this.pendingUnownedCompletionKeys.clear();
|
|
780
|
-
this.pendingUnownedCompletionStatuses.clear();
|
|
781
|
-
}
|
|
782
726
|
emitObservation(phase, fields = {}) {
|
|
783
727
|
const inputId = this.oldestOwnedInput();
|
|
784
728
|
const observation = { type: "runtime-observation", runtime: "pi",
|
|
@@ -788,8 +732,6 @@ class PiSession extends EventSession {
|
|
|
788
732
|
Object.defineProperty(observation, "sessionId", { value: this.sessionId, enumerable: false });
|
|
789
733
|
if (inputId)
|
|
790
734
|
Object.defineProperty(observation, "inputId", { value: inputId, enumerable: false });
|
|
791
|
-
if (fields.completionKey)
|
|
792
|
-
Object.defineProperty(observation, "completionKey", { value: fields.completionKey, enumerable: false });
|
|
793
735
|
this.emit(observation);
|
|
794
736
|
}
|
|
795
737
|
oldestOwnedInput() {
|
|
@@ -936,28 +878,6 @@ async function discoverEffectivePiContextWindow(input, command, commandPrefix, r
|
|
|
936
878
|
await client.close();
|
|
937
879
|
}
|
|
938
880
|
}
|
|
939
|
-
function writePrivateAtomic(file, content) {
|
|
940
|
-
try {
|
|
941
|
-
if (fs.lstatSync(file).isSymbolicLink())
|
|
942
|
-
throw new Error("standing prompt must not be a symlink");
|
|
943
|
-
}
|
|
944
|
-
catch (error) {
|
|
945
|
-
if (error.code !== "ENOENT")
|
|
946
|
-
throw error;
|
|
947
|
-
}
|
|
948
|
-
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
949
|
-
try {
|
|
950
|
-
fs.writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
|
|
951
|
-
fs.renameSync(temporary, file);
|
|
952
|
-
fs.chmodSync(file, 0o600);
|
|
953
|
-
}
|
|
954
|
-
finally {
|
|
955
|
-
try {
|
|
956
|
-
fs.unlinkSync(temporary);
|
|
957
|
-
}
|
|
958
|
-
catch { /* renamed or absent */ }
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
881
|
class PiRpcBackend {
|
|
962
882
|
client;
|
|
963
883
|
policy;
|
|
@@ -1012,27 +932,20 @@ class PiRpcBackend {
|
|
|
1012
932
|
return () => { offEvent(); offFailure(); };
|
|
1013
933
|
}
|
|
1014
934
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
// sees AgentManager.getRecord and can bridge consumed or terminal state.
|
|
1020
|
-
const recordWatchdog = (resolvers.recordWatchdog ?? resolvePiSubagentRecordWatchdogExtensionArg)(resolverInput);
|
|
1021
|
-
if (recordWatchdog)
|
|
1022
|
-
args.push("-e", recordWatchdog);
|
|
1023
|
-
const subagents = (resolvers.subagents ?? resolvePiSubagentExtensionArg)(resolverInput);
|
|
1024
|
-
if (subagents)
|
|
1025
|
-
args.push("-e", subagents);
|
|
1026
|
-
const bashTimeout = (resolvers.bashTimeout ?? resolvePiBashTimeoutExtensionArg)(resolverInput);
|
|
1027
|
-
if (bashTimeout)
|
|
1028
|
-
args.push("-e", bashTimeout);
|
|
1029
|
-
return args;
|
|
935
|
+
/** Inject only the Larkin-owned tmux extension. Skip native Windows so stock Pi bash remains. */
|
|
936
|
+
export function resolvePiProcessExtensionArgs(input) {
|
|
937
|
+
const bundle = resolvePiTmuxExtensionArg({ env: input.env, platform: input.platform });
|
|
938
|
+
return bundle ? ["-e", bundle] : [];
|
|
1030
939
|
}
|
|
1031
940
|
async function createPiRpcBackend(input, dependencies, spawn, productionSpawn = true) {
|
|
1032
941
|
const stateRoot = effectivePiStateDir(input);
|
|
1033
942
|
const mergedEnv = stripPiCodingAgentDir({ ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" });
|
|
1034
943
|
if (mergedEnv.LARKIN_PI_DISTRIBUTION === "builtin")
|
|
1035
944
|
delete mergedEnv.LARKIN_PI_DISTRIBUTION;
|
|
945
|
+
mergedEnv.LARKIN_AGENT_ID = input.agentId;
|
|
946
|
+
mergedEnv.LARKIN_STATE_DIR = stateRoot;
|
|
947
|
+
if (!mergedEnv.LARKIN_TMUX_INSTANCE_ID)
|
|
948
|
+
mergedEnv.LARKIN_TMUX_INSTANCE_ID = crypto.randomUUID();
|
|
1036
949
|
const projectSettings = projectPiSettingsFile(input.workspaceDir);
|
|
1037
950
|
traceProcessBoundary(mergedEnv, "pi-rpc:child-env", { configDir: mergedEnv.LARKIN_CONFIG_DIR, agentId: input.agentId, targetDir: projectSettings, childEnvConfigDir: mergedEnv.LARKIN_CONFIG_DIR || null, childEnvHome: mergedEnv.HOME || null });
|
|
1038
951
|
const runtimeDir = path.join(stateRoot, "runtime");
|