privateer-agent 0.15.4 → 0.15.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/bin/fresh-supervisor.d.mts +5 -1
- package/bin/fresh-supervisor.mjs +47 -10
- package/bin/privateer-launch.mjs +13 -1
- package/bin/privateer-subagent.mjs +5 -2
- package/bin/session-recovery.d.mts +53 -0
- package/bin/session-recovery.mjs +240 -0
- package/bin/session-seal-preload.mjs +17 -0
- package/bin/session-seal.d.mts +25 -0
- package/bin/session-seal.mjs +242 -0
- package/extensions/privateer-gate.ts +14 -1
- package/extensions/privateer-recovery.ts +175 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+1.3.0.patch +89 -0
- package/src/cli/chat.ts +7 -1
- package/src/config/moatManifest.json +1 -0
- package/src/config/whatsNew.json +3 -3
- package/src/remote/attentionPush.ts +40 -0
- package/src/remote/liveTaskSession.ts +7 -1
- package/src/remote/relayClient.ts +38 -4
- package/src/remote/remoteBridge.ts +17 -0
- package/src/remote/transcriptSnapshot.ts +104 -0
|
@@ -32,4 +32,8 @@ export function runDir(home: string): string;
|
|
|
32
32
|
export function listTerminals(home: string): TerminalEntry[];
|
|
33
33
|
export function requestFresh(socket: string, token: string, timeoutMs?: number): Promise<{ ok: true; pid: number }>;
|
|
34
34
|
/** Run the TUI under supervision; `buildArgs(true)` for the first launch, false after. */
|
|
35
|
-
export function runSupervised(
|
|
35
|
+
export function runSupervised(
|
|
36
|
+
cmd: string,
|
|
37
|
+
buildArgs: (first: boolean, extra?: string[]) => string[],
|
|
38
|
+
opts: { home: string; interactive?: boolean },
|
|
39
|
+
): void;
|
package/bin/fresh-supervisor.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import fs from "node:fs";
|
|
|
44
44
|
import net from "node:net";
|
|
45
45
|
import os from "node:os";
|
|
46
46
|
import path from "node:path";
|
|
47
|
+
import { RECOVERY_ENV, encodeRecovery, readMarker, removeMarker, shouldAutoResume } from "./session-recovery.mjs";
|
|
47
48
|
|
|
48
49
|
const isWin = process.platform === "win32";
|
|
49
50
|
|
|
@@ -254,9 +255,15 @@ export function requestFresh(socket, token, timeoutMs = 3000) {
|
|
|
254
255
|
// ── supervisor ──────────────────────────────────────────────────────────────
|
|
255
256
|
|
|
256
257
|
/** Put a terminal a killed TUI left in raw mode / hidden cursor / paste mode back. */
|
|
257
|
-
function resetTerminal() {
|
|
258
|
+
function resetTerminal({ clear = true } = {}) {
|
|
258
259
|
if (!process.stdout.isTTY) return;
|
|
259
260
|
if (!isWin) spawnSync("stty", ["sane"], { stdio: ["inherit", "ignore", "ignore"] });
|
|
261
|
+
// After a CRASH the screen is kept: the error the old agent printed is the only
|
|
262
|
+
// record of why it died, and the resumed agent redraws the conversation anyway.
|
|
263
|
+
if (!clear) {
|
|
264
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l\x1b[?2004l\x1b[<u\x1b[0m\n");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
260
267
|
// Show cursor, leave the alternate screen, bracketed paste off, pop the kitty keyboard
|
|
261
268
|
// protocol, reset attributes, then clear screen + scrollback so the new agent starts on
|
|
262
269
|
// a blank page rather than under the old conversation.
|
|
@@ -305,12 +312,20 @@ async function reap(child) {
|
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
/**
|
|
308
|
-
* Run the TUI under supervision. `buildArgs(first)` returns the node argv for
|
|
309
|
-
* launch — the first uses the user's args as typed, later ones go through
|
|
310
|
-
* filterRespawnArgs
|
|
311
|
-
*
|
|
315
|
+
* Run the TUI under supervision. `buildArgs(first, extra)` returns the node argv for
|
|
316
|
+
* each launch — the first uses the user's args as typed, later ones go through
|
|
317
|
+
* filterRespawnArgs, with `extra` appended (a crash resume adds --session <file>) — and
|
|
318
|
+
* is called fresh each time so a model the user picked in the old session (saved to
|
|
319
|
+
* settings.json) is what the new one boots on.
|
|
320
|
+
*
|
|
321
|
+
* Crash resume (bin/session-recovery.mjs): when the agent dies like a crash — a
|
|
322
|
+
* non-requested exit code or a crash signal — while its live marker says it was in a
|
|
323
|
+
* session, the supervisor relaunches straight back into that session and hands the
|
|
324
|
+
* marker over in $PRIVATEER_RECOVERY so the new agent can tell the model what was
|
|
325
|
+
* interrupted. Bounded: RESUME_BUDGET per RESUME_WINDOW_MS, and an agent that dies
|
|
326
|
+
* before its session loaded has no marker, so a session that kills it on load can't loop.
|
|
312
327
|
*/
|
|
313
|
-
export function runSupervised(cmd, buildArgs, { home }) {
|
|
328
|
+
export function runSupervised(cmd, buildArgs, { home, interactive } = {}) {
|
|
314
329
|
const token = crypto.randomBytes(24).toString("hex");
|
|
315
330
|
const dir = runDir(home);
|
|
316
331
|
const regFile = path.join(dir, `${process.pid}.json`);
|
|
@@ -318,6 +333,7 @@ export function runSupervised(cmd, buildArgs, { home }) {
|
|
|
318
333
|
let child = null;
|
|
319
334
|
let replacing = false;
|
|
320
335
|
let registered = false;
|
|
336
|
+
const restarts = [];
|
|
321
337
|
|
|
322
338
|
const register = () => {
|
|
323
339
|
try {
|
|
@@ -334,13 +350,32 @@ export function runSupervised(cmd, buildArgs, { home }) {
|
|
|
334
350
|
if (!isWin) fs.rmSync(sock, { force: true });
|
|
335
351
|
};
|
|
336
352
|
|
|
337
|
-
const launch = (first) => {
|
|
338
|
-
|
|
353
|
+
const launch = (first, extra = [], extraEnv = {}) => {
|
|
354
|
+
const me = spawn(cmd, buildArgs(first, extra), {
|
|
339
355
|
stdio: "inherit",
|
|
340
|
-
env: { ...process.env, [FRESH_SOCKET_ENV]: sock, [FRESH_TOKEN_ENV]: token },
|
|
356
|
+
env: { ...process.env, [FRESH_SOCKET_ENV]: sock, [FRESH_TOKEN_ENV]: token, ...extraEnv },
|
|
341
357
|
});
|
|
342
|
-
child
|
|
358
|
+
child = me;
|
|
359
|
+
me.on("exit", (code, signal) => {
|
|
343
360
|
if (replacing) return;
|
|
361
|
+
const marker = readMarker(home, me.pid);
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
if (shouldAutoResume({
|
|
364
|
+
code, signal, marker, restarts, now,
|
|
365
|
+
sessionExists: !!marker && fs.existsSync(marker.sessionFile),
|
|
366
|
+
interactive: interactive ?? !!process.stdin.isTTY,
|
|
367
|
+
})) {
|
|
368
|
+
restarts.push(now);
|
|
369
|
+
resetTerminal({ clear: false });
|
|
370
|
+
const why = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
371
|
+
process.stdout.write(`\x1b[2m⚓ The agent stopped unexpectedly (${why}). Resuming your session…\x1b[0m\n`);
|
|
372
|
+
// The marker stays on disk until the new agent consumes it, so a resume that
|
|
373
|
+
// itself fails still leaves the next launch something to point at.
|
|
374
|
+
launch(false, ["--session", marker.sessionFile], { [RECOVERY_ENV]: encodeRecovery(marker) });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Asked to stop with nothing in flight: no reason to keep it.
|
|
378
|
+
if (marker && !marker.turnOpen && (code === 0 || signal === "SIGINT" || signal === "SIGTERM")) removeMarker(home, me.pid);
|
|
344
379
|
server.close();
|
|
345
380
|
unregister();
|
|
346
381
|
if (signal) process.kill(process.pid, signal);
|
|
@@ -361,6 +396,8 @@ export function runSupervised(cmd, buildArgs, { home }) {
|
|
|
361
396
|
const exited = new Promise((r) => (old.exitCode !== null || old.signalCode !== null ? r() : old.once("exit", r)));
|
|
362
397
|
const count = await reap(old);
|
|
363
398
|
await exited;
|
|
399
|
+
// /fresh is a requested stop, even mid-turn: never offer that turn back later.
|
|
400
|
+
if (old.pid) removeMarker(home, old.pid);
|
|
364
401
|
resetTerminal();
|
|
365
402
|
const extra = count > 1 ? ` and ${count - 1} process${count - 1 === 1 ? "" : "es"} it started` : "";
|
|
366
403
|
process.stdout.write(`\x1b[2m⚓ Fresh agent — the previous one${extra} stopped.\x1b[0m\n`);
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { runToCompletion } from "./run-to-completion.mjs";
|
|
|
34
34
|
import { modelArgs } from "./model-args.mjs";
|
|
35
35
|
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
36
36
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
37
|
+
import { sweepSessions } from "./session-seal.mjs";
|
|
37
38
|
|
|
38
39
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
39
40
|
const REPO = path.resolve(HERE, "..");
|
|
@@ -895,6 +896,9 @@ else {
|
|
|
895
896
|
|
|
896
897
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
897
898
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
899
|
+
// Session sealing (bin/session-seal.mjs): the codec has to be in place before Pi's
|
|
900
|
+
// main() opens a session, so it rides in as a preload on EVERY run that can write one.
|
|
901
|
+
nodeArgs.push("--import", pathToFileURL(path.join(HERE, "session-seal-preload.mjs")).href);
|
|
898
902
|
|
|
899
903
|
// The boot splash. `--import` so it runs before Pi's entry module — most of the wait
|
|
900
904
|
// it covers IS that module graph loading, so a splash started any later would miss it.
|
|
@@ -924,7 +928,15 @@ else {
|
|
|
924
928
|
// forwardSignals: a `kill` reaches only this launcher, and a -p run left behind keeps
|
|
925
929
|
// working (and spending) with nobody waiting for its answer.
|
|
926
930
|
if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args), { forwardSignals: true });
|
|
927
|
-
|
|
931
|
+
// `extra` is how a crash resume re-enters the session (--session <file>).
|
|
932
|
+
else {
|
|
933
|
+
// Seal the sessions written before sealing existed — once, in this (the
|
|
934
|
+
// supervisor's) process, in the background, while the agent boots.
|
|
935
|
+
setTimeout(() => {
|
|
936
|
+
sweepSessions(PRIVATEER_HOME, path.join(AGENT_DIR, "sessions")).catch(() => {});
|
|
937
|
+
}, 3000).unref();
|
|
938
|
+
runSupervised(NODE_BIN, (first, extra = []) => tuiArgs([...(first ? args : filterRespawnArgs(args)), ...extra]), { home: PRIVATEER_HOME });
|
|
939
|
+
}
|
|
928
940
|
}
|
|
929
941
|
|
|
930
942
|
// --- helpers ---------------------------------------------------------------
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// spawn only runs when this file is invoked as a binary.
|
|
19
19
|
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
22
|
import { dirname, resolve, join, delimiter } from "node:path";
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
24
|
|
|
@@ -91,7 +91,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|
|
91
91
|
if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
|
|
92
92
|
if (!process.env.PI_BG_DISABLE_UPDATE_CHECK) process.env.PI_BG_DISABLE_UPDATE_CHECK = "1";
|
|
93
93
|
const args = buildChildArgs(process.argv.slice(2));
|
|
94
|
-
|
|
94
|
+
// The session codec, as for the TUI (bin/session-seal-preload.mjs): a child writes
|
|
95
|
+
// its own session file, and it must be sealed like the parent's.
|
|
96
|
+
const preload = pathToFileURL(join(HERE, "session-seal-preload.mjs")).href;
|
|
97
|
+
const child = spawn(process.execPath, ["--import", preload, piCliPath(), ...args], { stdio: "inherit", env: process.env });
|
|
95
98
|
// Propagate the child's exit faithfully so pi-subagents' parent reads the real
|
|
96
99
|
// outcome (a signal re-raises; otherwise exit with the same code).
|
|
97
100
|
child.on("exit", (code, signal) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Types for session recovery (bin/session-recovery.mjs). Same reason as the other
|
|
2
|
+
// bin/*.d.mts: bin/ runs under a bare `node`, but the recovery extension and its tests
|
|
3
|
+
// are TypeScript.
|
|
4
|
+
|
|
5
|
+
export const RECOVERY_ENV: "PRIVATEER_RECOVERY";
|
|
6
|
+
export const MARKER_MAX_AGE_MS: number;
|
|
7
|
+
export const RESUME_BUDGET: number;
|
|
8
|
+
export const RESUME_WINDOW_MS: number;
|
|
9
|
+
|
|
10
|
+
export interface MarkerTool {
|
|
11
|
+
name: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A live marker, as kept in PRIVATEER_HOME/run/live/<pid>.json. */
|
|
17
|
+
export interface LiveMarker {
|
|
18
|
+
v: 1;
|
|
19
|
+
pid: number;
|
|
20
|
+
sessionFile: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
turnOpen: boolean;
|
|
25
|
+
tool: MarkerTool | null;
|
|
26
|
+
hinted?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function liveDir(home: string): string;
|
|
30
|
+
export function markerPath(home: string, pid: number): string;
|
|
31
|
+
export function writeMarker(home: string, marker: Omit<LiveMarker, "v"> & { v: 1 } | LiveMarker): void;
|
|
32
|
+
export function readMarker(home: string, pid: number): LiveMarker | null;
|
|
33
|
+
export function removeMarker(home: string, pid: number): void;
|
|
34
|
+
export function parseMarker(text: string | null): LiveMarker | null;
|
|
35
|
+
export function isAlive(pid: number): boolean;
|
|
36
|
+
export function listStale(home: string, opts?: { cwd?: string; now?: number; alive?: (pid: number) => boolean }): LiveMarker[];
|
|
37
|
+
export function staleForSession(home: string, sessionFile: string, opts?: { now?: number; alive?: (pid: number) => boolean }): LiveMarker | null;
|
|
38
|
+
export function shouldAutoResume(input: {
|
|
39
|
+
code: number | null;
|
|
40
|
+
signal: string | null;
|
|
41
|
+
marker: LiveMarker | null;
|
|
42
|
+
restarts?: number[];
|
|
43
|
+
now?: number;
|
|
44
|
+
sessionExists?: boolean;
|
|
45
|
+
interactive?: boolean;
|
|
46
|
+
}): boolean;
|
|
47
|
+
export function summarizeToolCall(name: unknown, args: unknown): string;
|
|
48
|
+
export function recoveryNote(marker: LiveMarker | null, opts?: { reason?: string }): string | null;
|
|
49
|
+
export function recoveryNotice(marker: LiveMarker, opts?: { now?: number }): string;
|
|
50
|
+
export function sessionIdFromFile(file: string): string | null;
|
|
51
|
+
export function resumeHint(marker: LiveMarker, opts?: { cmd?: string; now?: number }): string;
|
|
52
|
+
export function encodeRecovery(marker: LiveMarker): string;
|
|
53
|
+
export function decodeRecovery(value: string | undefined | null): LiveMarker | null;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Session recovery — what lets a terminal survive its agent dying mid-task.
|
|
2
|
+
//
|
|
3
|
+
// Pi already writes the session to disk as it goes (~/.privateer/agent/sessions,
|
|
4
|
+
// one JSONL file per session, the user's prompt persisted BEFORE the first reply),
|
|
5
|
+
// and it already patches a tool call that never got a result ("No result
|
|
6
|
+
// provided"). What nothing did was notice the death and act on it:
|
|
7
|
+
// - an uncaught exception was logged (crashes.json) and the next launch said
|
|
8
|
+
// "Run /bug" — it never offered the session back;
|
|
9
|
+
// - SIGKILL, the OOM killer, a closed terminal or a power cut left no trace at all;
|
|
10
|
+
// - a resumed session gave the model no hint that its last tool may have half-run.
|
|
11
|
+
//
|
|
12
|
+
// So each agent process keeps a LIVE MARKER (run/live/<pid>.json, 0600): which
|
|
13
|
+
// session file it is in, whether a turn is open, and which tool is running. The
|
|
14
|
+
// agent writes it (extensions/privateer-recovery.ts); whoever outlives the agent
|
|
15
|
+
// reads it:
|
|
16
|
+
// - the supervisor (fresh-supervisor.mjs), when its child dies like a crash,
|
|
17
|
+
// relaunches into the same session at once;
|
|
18
|
+
// - the launcher, on the next `privateer` in that folder, prints how to resume a
|
|
19
|
+
// session that died with a turn open (it never resumes on its own there — the
|
|
20
|
+
// user may have come back to start something new);
|
|
21
|
+
// - the resumed agent turns the marker into ONE note for the model: the previous
|
|
22
|
+
// process stopped during `<tool>`, the work may be partly applied, check before
|
|
23
|
+
// continuing. Never an automatic re-run: replaying `git push` or a migration is
|
|
24
|
+
// worse than losing the turn.
|
|
25
|
+
//
|
|
26
|
+
// A marker is removed when the agent shuts down cleanly with NO turn open. A
|
|
27
|
+
// graceful shutdown with a turn open (the terminal closed, SIGTERM) leaves it: the
|
|
28
|
+
// work was interrupted all the same.
|
|
29
|
+
//
|
|
30
|
+
// Dependency-free on purpose: the launcher reads this before anything is patched,
|
|
31
|
+
// and the supervisor runs it with no TS loader.
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
export const RECOVERY_ENV = "PRIVATEER_RECOVERY";
|
|
37
|
+
|
|
38
|
+
/** Markers older than this are garbage: nobody is coming back for that turn. */
|
|
39
|
+
export const MARKER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
40
|
+
/** Auto-resume at most this many times… */
|
|
41
|
+
export const RESUME_BUDGET = 2;
|
|
42
|
+
/** …within this window, so a session that crashes on load can't loop forever. */
|
|
43
|
+
export const RESUME_WINDOW_MS = 3 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// Deaths that look like the process failing rather than being asked to stop.
|
|
46
|
+
// SIGKILL is in: it is what the OOM killer sends. SIGINT/SIGTERM/SIGHUP are not —
|
|
47
|
+
// those are a person, a terminal or a service manager saying stop.
|
|
48
|
+
const CRASH_SIGNALS = new Set(["SIGKILL", "SIGSEGV", "SIGABRT", "SIGBUS", "SIGILL", "SIGFPE", "SIGTRAP"]);
|
|
49
|
+
// Exit codes that mean "stopped on request" (128 + SIGINT / SIGTERM / SIGHUP).
|
|
50
|
+
const REQUESTED_CODES = new Set([0, 129, 130, 143]);
|
|
51
|
+
|
|
52
|
+
export function liveDir(home) {
|
|
53
|
+
return path.join(home, "run", "live");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function markerPath(home, pid) {
|
|
57
|
+
return path.join(liveDir(home), `${pid}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Write atomically (tmp + rename) and owner-only: it names files and commands. */
|
|
61
|
+
export function writeMarker(home, marker) {
|
|
62
|
+
const dir = liveDir(home);
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
64
|
+
const file = markerPath(home, marker.pid);
|
|
65
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
66
|
+
fs.writeFileSync(tmp, JSON.stringify(marker) + "\n", { mode: 0o600 });
|
|
67
|
+
fs.renameSync(tmp, file);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readMarker(home, pid) {
|
|
71
|
+
return parseMarker(safeRead(markerPath(home, pid)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeMarker(home, pid) {
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(markerPath(home, pid), { force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeRead(file) {
|
|
83
|
+
try {
|
|
84
|
+
return fs.readFileSync(file, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shape-check a marker. Anything malformed reads as absent, never as a crash. */
|
|
91
|
+
export function parseMarker(text) {
|
|
92
|
+
if (typeof text !== "string") return null;
|
|
93
|
+
let m;
|
|
94
|
+
try {
|
|
95
|
+
m = JSON.parse(text);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!m || m.v !== 1 || !Number.isInteger(m.pid) || typeof m.sessionFile !== "string" || !m.sessionFile) return null;
|
|
100
|
+
return {
|
|
101
|
+
v: 1,
|
|
102
|
+
pid: m.pid,
|
|
103
|
+
sessionFile: m.sessionFile,
|
|
104
|
+
cwd: typeof m.cwd === "string" ? m.cwd : "",
|
|
105
|
+
startedAt: Number(m.startedAt) || 0,
|
|
106
|
+
updatedAt: Number(m.updatedAt) || 0,
|
|
107
|
+
turnOpen: m.turnOpen === true,
|
|
108
|
+
tool: m.tool && typeof m.tool.name === "string"
|
|
109
|
+
? { name: m.tool.name, summary: typeof m.tool.summary === "string" ? m.tool.summary : "", startedAt: Number(m.tool.startedAt) || 0 }
|
|
110
|
+
: null,
|
|
111
|
+
hinted: m.hinted === true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isAlive(pid) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 0);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return e?.code === "EPERM";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Markers whose agent died MID-TURN, newest first. Prunes the rest of the dead: idle
|
|
126
|
+
* ones, ones past MARKER_MAX_AGE_MS, and ones whose session file is gone.
|
|
127
|
+
*/
|
|
128
|
+
export function listStale(home, { cwd, now = Date.now(), alive = isAlive } = {}) {
|
|
129
|
+
let names = [];
|
|
130
|
+
try {
|
|
131
|
+
names = fs.readdirSync(liveDir(home)).filter((n) => /^\d+\.json$/.test(n));
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
const file = path.join(liveDir(home), name);
|
|
138
|
+
const m = parseMarker(safeRead(file));
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
if (alive(m.pid)) continue;
|
|
141
|
+
// Dead while IDLE says nothing worth saying (a closed terminal can kill Pi on its
|
|
142
|
+
// dead-tty path, which skips session_shutdown, so these do happen). The
|
|
143
|
+
// supervisor's crash resume reads its child's marker directly, not through here.
|
|
144
|
+
if (!m.turnOpen || now - (m.updatedAt || m.startedAt) > MARKER_MAX_AGE_MS || !fs.existsSync(m.sessionFile)) {
|
|
145
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best-effort */ }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (cwd && path.resolve(m.cwd) !== path.resolve(cwd)) continue;
|
|
149
|
+
out.push(m);
|
|
150
|
+
}
|
|
151
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The stale marker (if any) for one session file — what a resumed agent consumes. */
|
|
155
|
+
export function staleForSession(home, sessionFile, opts = {}) {
|
|
156
|
+
const want = path.resolve(sessionFile);
|
|
157
|
+
return listStale(home, opts).find((m) => path.resolve(m.sessionFile) === want) ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Should the supervisor relaunch into the session right now? Pure.
|
|
162
|
+
* `restarts` = timestamps of earlier auto-resumes by this supervisor.
|
|
163
|
+
*/
|
|
164
|
+
// There is deliberately no minimum-lifetime floor. The marker is written only once a
|
|
165
|
+
// session has LOADED (session_start), so an agent that crashes while booting or while
|
|
166
|
+
// loading the session has no marker and is never resumed — that, plus the budget, is
|
|
167
|
+
// the loop guard. A 5s floor was tried and wrongly refused a real kill -9 of a warm
|
|
168
|
+
// agent that had been up for 3s.
|
|
169
|
+
export function shouldAutoResume({ code, signal, marker, restarts = [], now = Date.now(), sessionExists = true, interactive = true }) {
|
|
170
|
+
if (!marker || !interactive || !sessionExists) return false;
|
|
171
|
+
const crashed = signal ? CRASH_SIGNALS.has(signal) : !REQUESTED_CODES.has(code ?? 0);
|
|
172
|
+
if (!crashed) return false;
|
|
173
|
+
return restarts.filter((t) => now - t < RESUME_WINDOW_MS).length < RESUME_BUDGET;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One line describing a tool call, safe to keep in a 0600 file and show the model. */
|
|
177
|
+
export function summarizeToolCall(name, args) {
|
|
178
|
+
const flat = (s) => String(s).replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
179
|
+
const clip = (s, n = 160) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
180
|
+
if (!args || typeof args !== "object") return clip(flat(name));
|
|
181
|
+
const pick = args.command ?? args.cmd ?? args.path ?? args.file_path ?? args.file ?? args.url ?? args.query;
|
|
182
|
+
if (typeof pick === "string" && pick.trim()) return clip(flat(pick));
|
|
183
|
+
return clip(Object.keys(args).slice(0, 6).join(", "));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ago = (ms) => {
|
|
187
|
+
const m = Math.round(ms / 60000);
|
|
188
|
+
if (m < 1) return "moments ago";
|
|
189
|
+
if (m < 60) return `${m} min ago`;
|
|
190
|
+
const h = Math.round(m / 60);
|
|
191
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** The note the resumed agent hands the model. Null when no turn was open. */
|
|
195
|
+
export function recoveryNote(marker, { reason = "stopped unexpectedly" } = {}) {
|
|
196
|
+
if (!marker?.turnOpen) return null;
|
|
197
|
+
const during = marker.tool
|
|
198
|
+
? `while it was running the \`${marker.tool.name}\` tool${marker.tool.summary ? ` (\`${marker.tool.summary}\`)` : ""}`
|
|
199
|
+
: "in the middle of a turn, between tool calls";
|
|
200
|
+
return [
|
|
201
|
+
`[Privateer] The previous agent process for this session ${reason} ${during}.`,
|
|
202
|
+
"Its last action may have run fully, partly, or not at all, and anything it started (a server, a build, a migration) may still be running or may have been killed.",
|
|
203
|
+
"Do not repeat that action blindly. Check the current state first (files, git status, running processes, the remote) and tell the user briefly what you found before carrying on.",
|
|
204
|
+
].join(" ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** What the user sees in the resumed terminal. */
|
|
208
|
+
export function recoveryNotice(marker, { now = Date.now() } = {}) {
|
|
209
|
+
const when = ago(now - (marker.updatedAt || marker.startedAt || now));
|
|
210
|
+
if (!marker.turnOpen) return `Resumed your session — the previous agent stopped ${when}.`;
|
|
211
|
+
const what = marker.tool ? ` during ${marker.tool.name}${marker.tool.summary ? `: ${marker.tool.summary}` : ""}` : "";
|
|
212
|
+
return `Resumed your session — the previous agent stopped ${when} mid-task${what}. It may have been partly done; the agent will check before continuing.`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Pi's session id from its file name (`<timestamp>_<id>.jsonl`). */
|
|
216
|
+
export function sessionIdFromFile(file) {
|
|
217
|
+
const m = path.basename(file).match(/_([0-9a-f-]{8,})\.jsonl$/i);
|
|
218
|
+
return m ? m[1] : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Copy-pasteable resume hint for the next launch in that folder. */
|
|
222
|
+
export function resumeHint(marker, { cmd = "privateer", now = Date.now() } = {}) {
|
|
223
|
+
const id = sessionIdFromFile(marker.sessionFile);
|
|
224
|
+
const target = id ?? marker.sessionFile;
|
|
225
|
+
const what = marker.tool ? ` (during ${marker.tool.name}${marker.tool.summary ? `: ${marker.tool.summary}` : ""})` : "";
|
|
226
|
+
return `⚓ Your last session in this folder stopped mid-task ${ago(now - (marker.updatedAt || now))}${what}.\n Resume it: ${cmd} --session ${target}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Encode a marker for the relaunched child's environment (and decode it there). */
|
|
230
|
+
export function encodeRecovery(marker) {
|
|
231
|
+
return Buffer.from(JSON.stringify(marker)).toString("base64");
|
|
232
|
+
}
|
|
233
|
+
export function decodeRecovery(value) {
|
|
234
|
+
if (typeof value !== "string" || !value) return null;
|
|
235
|
+
try {
|
|
236
|
+
return parseMarker(Buffer.from(value, "base64").toString("utf8"));
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `node --import` preload for every Pi process privateer starts (the TUI, -p runs,
|
|
2
|
+
// subagent children): installs the session codec (bin/session-seal.mjs) BEFORE
|
|
3
|
+
// Pi's main() opens a session, which happens before any extension loads — so an
|
|
4
|
+
// extension is too late for this, and a preload is the only place it fits.
|
|
5
|
+
//
|
|
6
|
+
// A key file that exists but can't be read is NOT papered over with a new key (that
|
|
7
|
+
// would orphan every sealed session). Sealing stays off for this run, the agent
|
|
8
|
+
// tells the user (extensions/privateer-recovery.ts reads the reason below), and Pi
|
|
9
|
+
// writes plaintext exactly as it did before sealing existed.
|
|
10
|
+
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { installCodec } from "./session-seal.mjs";
|
|
14
|
+
|
|
15
|
+
const home = process.env.PRIVATEER_HOME || path.join(os.homedir(), ".privateer");
|
|
16
|
+
const result = installCodec(home);
|
|
17
|
+
if (!result.ok) globalThis[Symbol.for("privateer.sessionCodecError")] = result.error;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Types for session sealing (bin/session-seal.mjs). Same reason as the other
|
|
2
|
+
// bin/*.d.mts: bin/ runs under a bare `node`, but its tests are TypeScript.
|
|
3
|
+
|
|
4
|
+
export const CODEC_KEY: unique symbol;
|
|
5
|
+
export const QUIET_MS: number;
|
|
6
|
+
|
|
7
|
+
export interface SessionCodec {
|
|
8
|
+
failures: number;
|
|
9
|
+
encode(entry: unknown): string;
|
|
10
|
+
decode(obj: unknown): any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function keyPath(home: string): string;
|
|
14
|
+
export function loadOrCreateKey(home: string): Buffer;
|
|
15
|
+
export function isSealed(obj: unknown): boolean;
|
|
16
|
+
export function encodeEntry(entry: unknown, key: Buffer): string;
|
|
17
|
+
export function decodeEntry(obj: unknown, key: Buffer): any;
|
|
18
|
+
export function installCodec(home: string): { ok: true } | { ok: false; error: string };
|
|
19
|
+
export function sealFile(file: string, key: Buffer): "sealed" | "unchanged" | "busy" | "failed";
|
|
20
|
+
export function listSessionFiles(sessionsDir: string): string[];
|
|
21
|
+
export function sweepSessions(
|
|
22
|
+
home: string,
|
|
23
|
+
sessionsDir: string,
|
|
24
|
+
opts?: { now?: number; quietMs?: number },
|
|
25
|
+
): Promise<{ skipped: true } | { error: true } | { sealed: number; unchanged: number; busy: number; failed: number; recent: number }>;
|