privateer-agent 0.15.3 → 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.
@@ -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(cmd: string, buildArgs: (first: boolean) => string[], opts: { home: string }): void;
35
+ export function runSupervised(
36
+ cmd: string,
37
+ buildArgs: (first: boolean, extra?: string[]) => string[],
38
+ opts: { home: string; interactive?: boolean },
39
+ ): void;
@@ -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 each
309
- * launch — the first uses the user's args as typed, later ones go through
310
- * filterRespawnArgs — and is called fresh each time so a model the user picked in the old
311
- * session (saved to settings.json) is what the new one boots on.
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
- child = spawn(cmd, buildArgs(first), {
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.on("exit", (code, signal) => {
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`);
@@ -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
- else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
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
- const child = spawn(process.execPath, [piCliPath(), ...args], { stdio: "inherit", env: process.env });
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 }>;