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.
- 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 +75 -3
- 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 +4 -4
- package/src/permissions/classify.ts +11 -4
- 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
- package/src/tools/media.ts +291 -49
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Session sealing — Pi's session transcripts, encrypted at rest.
|
|
2
|
+
//
|
|
3
|
+
// Pi writes every conversation to ~/.privateer/agent/sessions/<cwd>/<ts>_<id>.jsonl,
|
|
4
|
+
// one JSON entry per line, and until now in plaintext: every prompt, every reply,
|
|
5
|
+
// every file the agent read and every command's output, readable by anything that
|
|
6
|
+
// can read the home directory — backup and sync tools, indexers, a stray `grep -r`,
|
|
7
|
+
// any other program the user runs.
|
|
8
|
+
//
|
|
9
|
+
// Now each line except the header is sealed with AES-256-GCM:
|
|
10
|
+
// {"pvs":1,"iv":"<b64 12 bytes>","ct":"<b64 ciphertext‖tag>"}
|
|
11
|
+
// The HEADER (type "session": id, timestamp, cwd) stays readable on purpose. Pi
|
|
12
|
+
// finds and lists sessions by reading only the first line of each file, and the cwd
|
|
13
|
+
// it holds is already spelled out in the directory name, so sealing it would cost
|
|
14
|
+
// session discovery and hide nothing.
|
|
15
|
+
//
|
|
16
|
+
// The patch in patches/@earendil-works+pi-coding-agent+*.patch routes Pi's reads
|
|
17
|
+
// (parseSessionEntryLine / parseSessionEntries) and writes (every JSON.stringify of
|
|
18
|
+
// an entry) through the codec this module installs on globalThis. Without the codec
|
|
19
|
+
// — Pi run by hand, outside `privateer` — Pi writes plaintext as before, and sealed
|
|
20
|
+
// lines read as absent rather than as garbage. Readers accept both forms forever, so
|
|
21
|
+
// files written before this change, and files of mixed form, keep working.
|
|
22
|
+
//
|
|
23
|
+
// THE KEY, AND WHAT THIS DOES NOT DO. One random 32-byte key per machine, in
|
|
24
|
+
// $PRIVATEER_HOME/session-key.json (0600) — next to the data, like this tool's other
|
|
25
|
+
// secrets (terminal-key.json, credentials). So this protects transcripts from
|
|
26
|
+
// anything that reads the session FILES (indexers, a shared or synced sessions
|
|
27
|
+
// folder, an attachment pasted from the wrong directory, tools that scan for
|
|
28
|
+
// .jsonl), and it does NOT protect them from someone who can read the whole
|
|
29
|
+
// ~/.privateer as this user: that reader has the key too. Say exactly that, never
|
|
30
|
+
// more. An OS keychain would move the key out of the folder, at the price of
|
|
31
|
+
// sessions that can't open over ssh or with the keychain locked — a different
|
|
32
|
+
// trade, not a free upgrade.
|
|
33
|
+
//
|
|
34
|
+
// Never mint a key over one that exists: a second key orphans every session sealed
|
|
35
|
+
// under the first. A key file that is present but unreadable or malformed is an
|
|
36
|
+
// error (sealing is switched off and the user is told), never a reason to start over.
|
|
37
|
+
|
|
38
|
+
import crypto from "node:crypto";
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
|
|
42
|
+
export const CODEC_KEY = Symbol.for("privateer.sessionCodec");
|
|
43
|
+
const KEY_FILE = "session-key.json";
|
|
44
|
+
|
|
45
|
+
export function keyPath(home) {
|
|
46
|
+
return path.join(home, KEY_FILE);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseKeyFile(text) {
|
|
50
|
+
const parsed = JSON.parse(text);
|
|
51
|
+
const key = Buffer.from(String(parsed?.key ?? ""), "base64");
|
|
52
|
+
if (parsed?.v !== 1 || key.length !== 32) throw new Error("session key file is malformed");
|
|
53
|
+
return key;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load this machine's session key, minting it only when no key file exists at all.
|
|
58
|
+
* Two terminals starting at once race on `wx`: the loser re-reads the winner's key.
|
|
59
|
+
*/
|
|
60
|
+
export function loadOrCreateKey(home) {
|
|
61
|
+
const file = keyPath(home);
|
|
62
|
+
try {
|
|
63
|
+
return parseKeyFile(fs.readFileSync(file, "utf8"));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (e?.code !== "ENOENT") throw e;
|
|
66
|
+
}
|
|
67
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
68
|
+
const key = crypto.randomBytes(32);
|
|
69
|
+
try {
|
|
70
|
+
fs.writeFileSync(file, JSON.stringify({ v: 1, key: key.toString("base64"), created: new Date().toISOString() }) + "\n", { mode: 0o600, flag: "wx" });
|
|
71
|
+
return key;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e?.code === "EEXIST") return parseKeyFile(fs.readFileSync(file, "utf8"));
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isSealed(obj) {
|
|
79
|
+
return !!obj && typeof obj === "object" && obj.pvs === 1 && typeof obj.iv === "string" && typeof obj.ct === "string";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One entry → one line (no newline). The session header is never sealed. */
|
|
83
|
+
export function encodeEntry(entry, key) {
|
|
84
|
+
if (entry?.type === "session") return JSON.stringify(entry);
|
|
85
|
+
const iv = crypto.randomBytes(12);
|
|
86
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
87
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(entry), "utf8"), cipher.final(), cipher.getAuthTag()]);
|
|
88
|
+
return JSON.stringify({ pvs: 1, iv: iv.toString("base64"), ct: ct.toString("base64") });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A parsed line → the entry. Plaintext passes through; a line that fails to open → null. */
|
|
92
|
+
export function decodeEntry(obj, key) {
|
|
93
|
+
if (!isSealed(obj)) return obj;
|
|
94
|
+
try {
|
|
95
|
+
const iv = Buffer.from(obj.iv, "base64");
|
|
96
|
+
const raw = Buffer.from(obj.ct, "base64");
|
|
97
|
+
if (iv.length !== 12 || raw.length < 17) return null;
|
|
98
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
99
|
+
decipher.setAuthTag(raw.subarray(raw.length - 16));
|
|
100
|
+
const text = Buffer.concat([decipher.update(raw.subarray(0, raw.length - 16)), decipher.final()]).toString("utf8");
|
|
101
|
+
return JSON.parse(text);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Install the codec Pi's patched session manager calls. Returns { ok, error? }.
|
|
109
|
+
* `failures` counts lines that would not open (a different machine's key, a damaged
|
|
110
|
+
* file), so the agent can say so instead of showing a silently shorter session.
|
|
111
|
+
*/
|
|
112
|
+
export function installCodec(home) {
|
|
113
|
+
let key;
|
|
114
|
+
try {
|
|
115
|
+
key = loadOrCreateKey(home);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
delete globalThis[CODEC_KEY];
|
|
118
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
119
|
+
}
|
|
120
|
+
const codec = {
|
|
121
|
+
failures: 0,
|
|
122
|
+
encode: (entry) => encodeEntry(entry, key),
|
|
123
|
+
decode: (obj) => {
|
|
124
|
+
const out = decodeEntry(obj, key);
|
|
125
|
+
if (out === null) codec.failures++;
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
globalThis[CODEC_KEY] = codec;
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Seal one existing file in place. Lines that are already sealed, the header, and
|
|
135
|
+
* lines that don't parse are carried over byte-for-byte (a malformed line is Pi's to
|
|
136
|
+
* skip, not ours to drop). Written to a sibling temp file, VERIFIED by reading it
|
|
137
|
+
* back, then renamed over the original only if the original hasn't changed since it
|
|
138
|
+
* was read — a session another terminal is appending to is left for a later pass.
|
|
139
|
+
* The file keeps its original atime/mtime (Pi sorts sessions by mtime).
|
|
140
|
+
* Returns "sealed" | "unchanged" | "busy" | "failed".
|
|
141
|
+
*/
|
|
142
|
+
export function sealFile(file, key) {
|
|
143
|
+
let before, text;
|
|
144
|
+
try {
|
|
145
|
+
before = fs.statSync(file);
|
|
146
|
+
text = fs.readFileSync(file, "utf8");
|
|
147
|
+
} catch {
|
|
148
|
+
return "failed";
|
|
149
|
+
}
|
|
150
|
+
const lines = text.split("\n");
|
|
151
|
+
let changed = false;
|
|
152
|
+
const out = lines.map((line) => {
|
|
153
|
+
if (!line.trim()) return line;
|
|
154
|
+
let obj;
|
|
155
|
+
try {
|
|
156
|
+
obj = JSON.parse(line);
|
|
157
|
+
} catch {
|
|
158
|
+
return line;
|
|
159
|
+
}
|
|
160
|
+
if (isSealed(obj) || obj?.type === "session") return line;
|
|
161
|
+
changed = true;
|
|
162
|
+
return encodeEntry(obj, key);
|
|
163
|
+
});
|
|
164
|
+
if (!changed) return "unchanged";
|
|
165
|
+
|
|
166
|
+
// Verify: every original entry must come back identical from what we will write.
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
if (out[i] === lines[i]) continue;
|
|
169
|
+
const back = decodeEntry(JSON.parse(out[i]), key);
|
|
170
|
+
if (JSON.stringify(back) !== JSON.stringify(JSON.parse(lines[i]))) return "failed";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tmp = `${file}.sealing-${process.pid}`;
|
|
174
|
+
try {
|
|
175
|
+
fs.writeFileSync(tmp, out.join("\n"), { mode: 0o600 });
|
|
176
|
+
const now = fs.statSync(file);
|
|
177
|
+
if (now.size !== before.size || now.mtimeMs !== before.mtimeMs) {
|
|
178
|
+
fs.rmSync(tmp, { force: true });
|
|
179
|
+
return "busy";
|
|
180
|
+
}
|
|
181
|
+
// Keep the original times: Pi orders sessions by mtime (`privateer -c` continues
|
|
182
|
+
// the most recent one), so a conversion must not make an old session look new.
|
|
183
|
+
fs.utimesSync(tmp, before.atime, before.mtime);
|
|
184
|
+
fs.renameSync(tmp, file);
|
|
185
|
+
return "sealed";
|
|
186
|
+
} catch {
|
|
187
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort */ }
|
|
188
|
+
return "failed";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every session file under Pi's sessions dir. */
|
|
193
|
+
export function listSessionFiles(sessionsDir) {
|
|
194
|
+
const out = [];
|
|
195
|
+
let dirs = [];
|
|
196
|
+
try {
|
|
197
|
+
dirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
198
|
+
} catch {
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
for (const d of dirs) {
|
|
202
|
+
if (!d.isDirectory()) continue;
|
|
203
|
+
const dir = path.join(sessionsDir, d.name);
|
|
204
|
+
try {
|
|
205
|
+
for (const f of fs.readdirSync(dir)) if (f.endsWith(".jsonl")) out.push(path.join(dir, f));
|
|
206
|
+
} catch { /* unreadable folder: skip */ }
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Seal every plaintext session on this machine — once, then never again. Files
|
|
213
|
+
* touched in the last QUIET_MS are skipped (another terminal may be writing them);
|
|
214
|
+
* the done-marker is only written when nothing was skipped or failed, so a later
|
|
215
|
+
* launch finishes the job. Async-yielding so the launcher's event loop stays live.
|
|
216
|
+
*/
|
|
217
|
+
export const QUIET_MS = 10 * 60 * 1000;
|
|
218
|
+
export async function sweepSessions(home, sessionsDir, { now = Date.now(), quietMs = QUIET_MS } = {}) {
|
|
219
|
+
const done = path.join(home, "run", "sessions-sealed-v1");
|
|
220
|
+
if (fs.existsSync(done)) return { skipped: true };
|
|
221
|
+
let key;
|
|
222
|
+
try {
|
|
223
|
+
key = loadOrCreateKey(home);
|
|
224
|
+
} catch {
|
|
225
|
+
return { error: true };
|
|
226
|
+
}
|
|
227
|
+
const tally = { sealed: 0, unchanged: 0, busy: 0, failed: 0, recent: 0 };
|
|
228
|
+
for (const file of listSessionFiles(sessionsDir)) {
|
|
229
|
+
try {
|
|
230
|
+
if (now - fs.statSync(file).mtimeMs < quietMs) { tally.recent++; continue; }
|
|
231
|
+
} catch { continue; }
|
|
232
|
+
tally[sealFile(file, key)]++;
|
|
233
|
+
await new Promise((r) => setImmediate(r));
|
|
234
|
+
}
|
|
235
|
+
if (!tally.busy && !tally.failed && !tally.recent) {
|
|
236
|
+
try {
|
|
237
|
+
fs.mkdirSync(path.dirname(done), { recursive: true, mode: 0o700 });
|
|
238
|
+
fs.writeFileSync(done, new Date().toISOString() + "\n", { mode: 0o600 });
|
|
239
|
+
} catch { /* retried next launch */ }
|
|
240
|
+
}
|
|
241
|
+
return tally;
|
|
242
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
startParentApprovalRelay,
|
|
21
21
|
} from "../src/remote/subagentRelay.ts";
|
|
22
22
|
import { RelayClient } from "../src/remote/relayClient.ts";
|
|
23
|
+
import { transcriptSnapshot } from "../src/remote/transcriptSnapshot.ts";
|
|
23
24
|
import { makeSendFileTool } from "../src/tools/sendFile.ts";
|
|
24
25
|
import { makeSaveCargoTool } from "../src/tools/cargo.ts";
|
|
25
26
|
import { makeChartTools } from "../src/tools/charts.ts";
|
|
@@ -63,6 +64,41 @@ function piBusy(): boolean {
|
|
|
63
64
|
try { return ctxRef?.isIdle?.() === false; } catch { return false; }
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// Messages the app sent mid-turn that Pi has queued as steers but the model has not
|
|
68
|
+
// read yet. Pi only reads a steer between steps, and a turn that ends on an error or
|
|
69
|
+
// a Stop leaves it in the queue — the app showed "Added to the running turn" and then
|
|
70
|
+
// nothing, so a driver had no way to tell a read message from a lost one. Each entry
|
|
71
|
+
// is cleared (and the app told) when Pi puts it in the transcript.
|
|
72
|
+
let unreadSteers: string[] = [];
|
|
73
|
+
let stopRequested = false;
|
|
74
|
+
const userText = (m: any): string =>
|
|
75
|
+
typeof m?.content === "string"
|
|
76
|
+
? m.content
|
|
77
|
+
: Array.isArray(m?.content) ? m.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("\n") : "";
|
|
78
|
+
|
|
79
|
+
// After a run ends, wait for Pi to settle (it may start another run to drain the
|
|
80
|
+
// queue itself), then say so if an app message is still sitting unread.
|
|
81
|
+
function reportStrandedSteers(): void {
|
|
82
|
+
const stopped = stopRequested;
|
|
83
|
+
stopRequested = false;
|
|
84
|
+
if (!unreadSteers.length) return;
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
const check = () => {
|
|
87
|
+
if (!unreadSteers.length) return;
|
|
88
|
+
if (piBusy()) {
|
|
89
|
+
if (Date.now() - startedAt < 10_000) setTimeout(check, 250).unref?.();
|
|
90
|
+
return; // still running: the next step or agent_end reads/reports it
|
|
91
|
+
}
|
|
92
|
+
const n = unreadSteers.length;
|
|
93
|
+
const what = n === 1 ? "Your last message" : `Your last ${n} messages`;
|
|
94
|
+
relay?.sendNotice(
|
|
95
|
+
`${what} arrived after the agent's final step${stopped ? " (you stopped the turn)" : ""}, so it wasn't read. ` +
|
|
96
|
+
"It will go to the agent with your next prompt.",
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
setTimeout(check, 250).unref?.();
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
let piRef: any = null;
|
|
67
103
|
let relay: any = null;
|
|
68
104
|
// Pi-extension manager for the app's extensions screen. Built lazily on first use
|
|
@@ -381,7 +417,10 @@ const bridge = new RemoteBridge({
|
|
|
381
417
|
relay?.sendNotice(`Couldn't attach: ${mentions.skipped.join(", ")} (must be a file inside ${cwd})`);
|
|
382
418
|
}
|
|
383
419
|
const body = mentions.text + note;
|
|
384
|
-
if (piBusy())
|
|
420
|
+
if (piBusy()) {
|
|
421
|
+
unreadSteers.push(body);
|
|
422
|
+
relay?.sendNotice("Added to the running turn — the agent reads it before its next step.");
|
|
423
|
+
}
|
|
385
424
|
// A message sent mid-turn STEERS it, as in the REPL and the desktop session.
|
|
386
425
|
// This used to be refused with "busy — a turn is already running" behind a flag
|
|
387
426
|
// that only agent_end cleared — but the extension API's sendUserMessage is
|
|
@@ -402,7 +441,17 @@ const bridge = new RemoteBridge({
|
|
|
402
441
|
}
|
|
403
442
|
});
|
|
404
443
|
},
|
|
405
|
-
|
|
444
|
+
// The app's Stop. This was a no-op, so Stop only closed the turn in the app while the
|
|
445
|
+
// agent kept working — and the next message from the app landed as a steer on a turn
|
|
446
|
+
// the driver believed was over.
|
|
447
|
+
onInterrupt: () => {
|
|
448
|
+
try {
|
|
449
|
+
if (ctxRef?.isIdle?.() === false) {
|
|
450
|
+
stopRequested = true;
|
|
451
|
+
ctxRef.abort();
|
|
452
|
+
}
|
|
453
|
+
} catch { /* a replaced session's context throws — nothing to stop */ }
|
|
454
|
+
},
|
|
406
455
|
// The app asked to end remote access from its side — stop the relay locally too so
|
|
407
456
|
// the terminal doesn't keep reconnecting, and clear the green indicator.
|
|
408
457
|
onTerminate: () => disableRemote(),
|
|
@@ -423,7 +472,19 @@ const bridge = new RemoteBridge({
|
|
|
423
472
|
// snapshot, push live context (model + version) so the app banner reflects this
|
|
424
473
|
// terminal, and advertise the slash commands for the composer's autocomplete.
|
|
425
474
|
setRemoteState("connected");
|
|
426
|
-
|
|
475
|
+
// The session's transcript, not a "connected" notice: an app coming back from
|
|
476
|
+
// sleep holds a feed frozen where its socket died, and this is how it learns what
|
|
477
|
+
// happened since — including that the turn it last saw is over (`running`).
|
|
478
|
+
let entries: ReturnType<typeof transcriptSnapshot> = [];
|
|
479
|
+
let running = false;
|
|
480
|
+
try {
|
|
481
|
+
entries = transcriptSnapshot(ctxRef?.sessionManager?.getBranch?.() ?? []);
|
|
482
|
+
running = ctxRef?.isIdle?.() === false;
|
|
483
|
+
} catch { /* a replaced session's context throws — fall back to the notice */ }
|
|
484
|
+
relay?.sendSnapshot(
|
|
485
|
+
entries.length ? entries : [{ kind: "notice", text: "Privateer terminal connected." }],
|
|
486
|
+
{ full: entries.length > 0, running },
|
|
487
|
+
);
|
|
427
488
|
// cwd rides along here (home-collapsed on the way out, see RelayClient.sendContext).
|
|
428
489
|
// Without it the app's composer shows no working-directory strip at all — and that
|
|
429
490
|
// strip is the only place a driver can see which folder the prompts they type are
|
|
@@ -667,6 +728,8 @@ export default function privateerControl(pi: any): void {
|
|
|
667
728
|
// remote access was already on when the session (re)started.
|
|
668
729
|
if (ctx?.ui) uiRef = ctx.ui;
|
|
669
730
|
ctxRef = ctx;
|
|
731
|
+
unreadSteers = []; // a new session has a new queue
|
|
732
|
+
stopRequested = false;
|
|
670
733
|
// Capture the model registry + launch model so the app's /model picker has this
|
|
671
734
|
// machine's real catalog and the banner shows the current spec from the start.
|
|
672
735
|
if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
|
|
@@ -796,6 +859,14 @@ export default function privateerControl(pi: any): void {
|
|
|
796
859
|
});
|
|
797
860
|
pi.on("message_start", (ev: any) => {
|
|
798
861
|
if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
|
|
862
|
+
// A queued app message entering the transcript — the model reads it this step.
|
|
863
|
+
// Match by text; an input handler (the privacy filter) may have rewritten it, in
|
|
864
|
+
// which case the oldest unread one is the one Pi just delivered (FIFO).
|
|
865
|
+
if (ev?.message?.role === "user" && unreadSteers.length) {
|
|
866
|
+
const i = unreadSteers.indexOf(userText(ev.message));
|
|
867
|
+
unreadSteers.splice(i >= 0 ? i : 0, 1);
|
|
868
|
+
relay?.sendNotice("The agent read your message.");
|
|
869
|
+
}
|
|
799
870
|
});
|
|
800
871
|
pi.on("message_update", () => {
|
|
801
872
|
if (headlessRun) watchdog.arm();
|
|
@@ -840,6 +911,7 @@ export default function privateerControl(pi: any): void {
|
|
|
840
911
|
pi.on("agent_end", (ev: any) => {
|
|
841
912
|
fwd(ev);
|
|
842
913
|
bridge.settleTurn();
|
|
914
|
+
reportStrandedSteers();
|
|
843
915
|
});
|
|
844
916
|
|
|
845
917
|
pi.registerCommand?.("mode", {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Session recovery, agent side — keeps this process's live marker current and, when
|
|
2
|
+
// this session is a resumption of one whose agent died, tells the model and the user.
|
|
3
|
+
// Also the one place that tells the user when session sealing (bin/session-seal.mjs)
|
|
4
|
+
// is off for this run, or when lines of the session couldn't be decrypted.
|
|
5
|
+
//
|
|
6
|
+
// The marker (bin/session-recovery.mjs has the whole design) is written at:
|
|
7
|
+
// session_start → which session file, no turn open
|
|
8
|
+
// agent_start → a turn is open
|
|
9
|
+
// tool_execution_start → which tool, and a one-line summary of its target
|
|
10
|
+
// tool_execution_end → no tool running (the turn may still be open)
|
|
11
|
+
// agent_end → turn closed
|
|
12
|
+
// session_shutdown → removed, UNLESS a turn is still open — a terminal closed
|
|
13
|
+
// mid-task is an interrupted task, and the next launch in
|
|
14
|
+
// that folder should say so
|
|
15
|
+
//
|
|
16
|
+
// On session_start this agent also looks for a dead agent's marker for the SAME
|
|
17
|
+
// session file — handed over by the supervisor in $PRIVATEER_RECOVERY when it
|
|
18
|
+
// relaunched us after a crash, or found on disk when the user resumed by hand
|
|
19
|
+
// (--session / -c / -r) — and turns it into one note for the model (delivered with
|
|
20
|
+
// the user's next message, never triggering a turn on its own) plus one line for
|
|
21
|
+
// the user. Nothing is re-run.
|
|
22
|
+
//
|
|
23
|
+
// TUI only: print/json runs are one-shot and scripted, and the desktop and harbor
|
|
24
|
+
// sessions are in-memory, with no session file to resume into.
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import { globalDir } from "../src/config/paths.ts";
|
|
28
|
+
import {
|
|
29
|
+
RECOVERY_ENV,
|
|
30
|
+
decodeRecovery,
|
|
31
|
+
listStale,
|
|
32
|
+
recoveryNote,
|
|
33
|
+
recoveryNotice,
|
|
34
|
+
removeMarker,
|
|
35
|
+
resumeHint,
|
|
36
|
+
staleForSession,
|
|
37
|
+
summarizeToolCall,
|
|
38
|
+
writeMarker,
|
|
39
|
+
} from "../bin/session-recovery.mjs";
|
|
40
|
+
|
|
41
|
+
// Read once and taken out of the environment, like the fresh-agent key: every bash
|
|
42
|
+
// command the agent runs inherits process.env, and none of them should see this.
|
|
43
|
+
const HANDOFF = Symbol.for("privateer.recovery.handoff");
|
|
44
|
+
function takeHandoff(): string | undefined {
|
|
45
|
+
const g = globalThis as any;
|
|
46
|
+
if (!(HANDOFF in g)) {
|
|
47
|
+
g[HANDOFF] = process.env[RECOVERY_ENV];
|
|
48
|
+
delete process.env[RECOVERY_ENV];
|
|
49
|
+
}
|
|
50
|
+
const v = g[HANDOFF];
|
|
51
|
+
g[HANDOFF] = undefined; // one resumption, one note
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let sealingWarned = false;
|
|
56
|
+
function sealingNotice(ctx: any): void {
|
|
57
|
+
const g = globalThis as any;
|
|
58
|
+
const codec = g[Symbol.for("privateer.sessionCodec")];
|
|
59
|
+
const error = g[Symbol.for("privateer.sessionCodecError")];
|
|
60
|
+
if (!codec) {
|
|
61
|
+
if (sealingWarned || !error) return; // no preload at all = not launched by privateer
|
|
62
|
+
sealingWarned = true;
|
|
63
|
+
ctx?.ui?.notify?.(`Session encryption is off for this run (${error}). This session is being saved unencrypted. Your key file was left untouched — sessions sealed with it are unreadable until it's fixed.`, "warning");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (codec.failures > 0) {
|
|
67
|
+
const n = codec.failures;
|
|
68
|
+
codec.failures = 0;
|
|
69
|
+
ctx?.ui?.notify?.(`${n} saved line${n === 1 ? "" : "s"} of this session couldn't be decrypted with this machine's session key and ${n === 1 ? "isn't" : "aren't"} shown. The file itself is unchanged.`, "warning");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default function privateerRecovery(pi: any): void {
|
|
74
|
+
const home = globalDir();
|
|
75
|
+
const pid = process.pid;
|
|
76
|
+
let state: {
|
|
77
|
+
sessionFile: string;
|
|
78
|
+
cwd: string;
|
|
79
|
+
startedAt: number;
|
|
80
|
+
turnOpen: boolean;
|
|
81
|
+
tool: { name: string; summary: string; startedAt: number } | null;
|
|
82
|
+
} | null = null;
|
|
83
|
+
|
|
84
|
+
const save = () => {
|
|
85
|
+
if (!state) return;
|
|
86
|
+
try {
|
|
87
|
+
writeMarker(home, { v: 1, pid, ...state, updatedAt: Date.now() });
|
|
88
|
+
} catch {
|
|
89
|
+
// A marker we can't write costs only the recovery, never the session.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
94
|
+
if (ctx?.mode !== "tui") return;
|
|
95
|
+
const sessionFile: string | undefined = ctx?.sessionManager?.getSessionFile?.();
|
|
96
|
+
if (!sessionFile) {
|
|
97
|
+
state = null;
|
|
98
|
+
removeMarker(home, pid);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
state = { sessionFile, cwd: ctx?.cwd ?? process.cwd(), startedAt: Date.now(), turnOpen: false, tool: null };
|
|
102
|
+
save();
|
|
103
|
+
|
|
104
|
+
// Session sealing (bin/session-seal.mjs) is installed by a preload; say when it
|
|
105
|
+
// isn't working rather than let a session silently save in plaintext, or open
|
|
106
|
+
// shorter than it is.
|
|
107
|
+
sealingNotice(ctx);
|
|
108
|
+
|
|
109
|
+
// Sessions written before Pi tightened its file mode are world-readable.
|
|
110
|
+
try {
|
|
111
|
+
if (fs.existsSync(sessionFile)) fs.chmodSync(sessionFile, 0o600);
|
|
112
|
+
} catch { /* not ours to fail over */ }
|
|
113
|
+
|
|
114
|
+
const handed = decodeRecovery(takeHandoff());
|
|
115
|
+
const dead = handed && handed.sessionFile === sessionFile ? handed : staleForSession(home, sessionFile);
|
|
116
|
+
if (!dead || dead.pid === pid) {
|
|
117
|
+
if (_event?.reason === "startup") hintInterrupted(ctx, sessionFile);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
removeMarker(home, dead.pid);
|
|
121
|
+
|
|
122
|
+
const note = recoveryNote(dead);
|
|
123
|
+
if (note) {
|
|
124
|
+
pi.sendMessage?.(
|
|
125
|
+
{ customType: "privateer-recovery", content: note, display: false },
|
|
126
|
+
{ deliverAs: "nextTurn" },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
ctx?.ui?.notify?.(recoveryNotice(dead), dead.turnOpen ? "warning" : "info");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A NEW session in a folder whose last session died mid-task (the terminal closed,
|
|
133
|
+
// the machine slept and was killed, the supervisor died with it): say how to get it
|
|
134
|
+
// back, once. Never resumed on our own here — the user may have come back on purpose
|
|
135
|
+
// to start something else.
|
|
136
|
+
const hintInterrupted = (ctx: any, current: string) => {
|
|
137
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
138
|
+
const m = listStale(home, { cwd }).find((x) => x.turnOpen && !x.hinted && x.sessionFile !== current);
|
|
139
|
+
if (!m) return;
|
|
140
|
+
ctx?.ui?.notify?.(resumeHint(m, { cmd: process.env.PRIVATEER_CMD || "privateer" }), "info");
|
|
141
|
+
try { writeMarker(home, { ...m, hinted: true }); } catch { /* shown once is a nicety */ }
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
pi.on("agent_start", () => {
|
|
145
|
+
if (!state) return;
|
|
146
|
+
state.turnOpen = true;
|
|
147
|
+
save();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.on("tool_execution_start", (event: any) => {
|
|
151
|
+
if (!state || event?.parentToolCallId) return; // the outer call is what the user saw
|
|
152
|
+
state.tool = { name: String(event?.toolName ?? "tool"), summary: summarizeToolCall(event?.toolName, event?.args), startedAt: Date.now() };
|
|
153
|
+
save();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pi.on("tool_execution_end", (event: any) => {
|
|
157
|
+
if (!state || event?.parentToolCallId) return;
|
|
158
|
+
state.tool = null;
|
|
159
|
+
save();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
pi.on("agent_end", () => {
|
|
163
|
+
if (!state) return;
|
|
164
|
+
state.turnOpen = false;
|
|
165
|
+
state.tool = null;
|
|
166
|
+
save();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
pi.on("session_shutdown", () => {
|
|
170
|
+
if (!state) return;
|
|
171
|
+
if (state.turnOpen) save(); // interrupted: leave it for whoever comes next
|
|
172
|
+
else removeMarker(home, pid);
|
|
173
|
+
state = null;
|
|
174
|
+
});
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.5",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -826,6 +826,95 @@ index 563d7d5..4f669b6 100644
|
|
|
826
826
|
return projectPath;
|
|
827
827
|
}
|
|
828
828
|
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
|
|
829
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js
|
|
830
|
+
index 62b469b..7e3c2c1 100644
|
|
831
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js
|
|
832
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js
|
|
833
|
+
@@ -1,6 +1,22 @@
|
|
834
|
+
import { getCurrentSystemMessage, uuidv7, } from "@earendil-works/pi-ai";
|
|
835
|
+
import { randomUUID } from "crypto";
|
|
836
|
+
import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
|
|
837
|
+
+// [privateer] Session sealing (privateer-agent bin/session-seal.mjs): every entry
|
|
838
|
+
+// line except the header is written and read through a codec the launcher installs
|
|
839
|
+
+// on globalThis before Pi starts. Absent codec → plaintext, as upstream; a sealed
|
|
840
|
+
+// line read without the codec is skipped like any other line Pi can't parse.
|
|
841
|
+
+const PRIVATEER_SESSION_CODEC = Symbol.for("privateer.sessionCodec");
|
|
842
|
+
+function serializeSessionEntry(entry) {
|
|
843
|
+
+ const codec = globalThis[PRIVATEER_SESSION_CODEC];
|
|
844
|
+
+ return codec ? codec.encode(entry) : JSON.stringify(entry);
|
|
845
|
+
+}
|
|
846
|
+
+function decodeSessionEntry(entry) {
|
|
847
|
+
+ if (entry && typeof entry === "object" && entry.pvs !== undefined) {
|
|
848
|
+
+ const codec = globalThis[PRIVATEER_SESSION_CODEC];
|
|
849
|
+
+ return codec ? codec.decode(entry) : null;
|
|
850
|
+
+ }
|
|
851
|
+
+ return entry;
|
|
852
|
+
+}
|
|
853
|
+
import { readdir, stat } from "fs/promises";
|
|
854
|
+
import { basename, join, resolve } from "path";
|
|
855
|
+
import { createInterface } from "readline";
|
|
856
|
+
@@ -95,8 +111,9 @@ export function parseSessionEntries(content) {
|
|
857
|
+
if (!line.trim())
|
|
858
|
+
continue;
|
|
859
|
+
try {
|
|
860
|
+
- const entry = JSON.parse(line);
|
|
861
|
+
- entries.push(entry);
|
|
862
|
+
+ const entry = decodeSessionEntry(JSON.parse(line));
|
|
863
|
+
+ if (entry)
|
|
864
|
+
+ entries.push(entry);
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
// Skip malformed lines
|
|
868
|
+
@@ -314,7 +331,7 @@ function parseSessionEntryLine(line) {
|
|
869
|
+
if (!line.trim())
|
|
870
|
+
return null;
|
|
871
|
+
try {
|
|
872
|
+
- return JSON.parse(line);
|
|
873
|
+
+ return decodeSessionEntry(JSON.parse(line));
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
// Skip malformed lines
|
|
877
|
+
@@ -757,7 +774,7 @@ export class SessionManager {
|
|
878
|
+
const fd = openSync(this.sessionFile, "w");
|
|
879
|
+
try {
|
|
880
|
+
for (const entry of this.fileEntries) {
|
|
881
|
+
- writeFileSync(fd, `${JSON.stringify(entry)}\n`);
|
|
882
|
+
+ writeFileSync(fd, `${serializeSessionEntry(entry)}\n`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
finally {
|
|
886
|
+
@@ -800,7 +817,7 @@ export class SessionManager {
|
|
887
|
+
const fd = openSync(this.sessionFile, "wx");
|
|
888
|
+
try {
|
|
889
|
+
for (const e of this.fileEntries) {
|
|
890
|
+
- writeFileSync(fd, `${JSON.stringify(e)}\n`);
|
|
891
|
+
+ writeFileSync(fd, `${serializeSessionEntry(e)}\n`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
finally {
|
|
895
|
+
@@ -809,7 +826,7 @@ export class SessionManager {
|
|
896
|
+
this.flushed = true;
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
900
|
+
+ appendFileSync(this.sessionFile, `${serializeSessionEntry(entry)}\n`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
_appendEntry(entry) {
|
|
904
|
+
@@ -1403,11 +1420,11 @@ export class SessionManager {
|
|
905
|
+
cwd: resolvedTargetCwd,
|
|
906
|
+
parentSession: resolvedSourcePath,
|
|
907
|
+
};
|
|
908
|
+
- writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
|
|
909
|
+
+ writeFileSync(newSessionFile, `${serializeSessionEntry(newHeader)}\n`, { flag: "wx" });
|
|
910
|
+
// Copy all non-header entries from source
|
|
911
|
+
for (const entry of sourceEntries) {
|
|
912
|
+
if (entry.type !== "session") {
|
|
913
|
+
- appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
|
914
|
+
+ appendFileSync(newSessionFile, `${serializeSessionEntry(entry)}\n`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
|
|
829
918
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
|
|
830
919
|
index d5ce9cd..a456908 100644
|
|
831
920
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js
|
package/src/cli/chat.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { privacyDisabled } from "../config/privacyDisabled.ts"; // no Pi deps
|
|
|
16
16
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
17
17
|
import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
|
|
18
18
|
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts"; // node:child_process only → safe pre-boot
|
|
19
|
+
import { transcriptSnapshot, type SnapshotEntry } from "../remote/transcriptSnapshot.ts"; // no deps → safe pre-boot
|
|
19
20
|
|
|
20
21
|
// This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
|
|
21
22
|
// itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
|
|
@@ -181,7 +182,12 @@ async function main() {
|
|
|
181
182
|
// the driver learns which folder they're driving without the OS username
|
|
182
183
|
// crossing the relay.
|
|
183
184
|
onControllerAttached: () => {
|
|
184
|
-
|
|
185
|
+
// The session's transcript, so an app back from sleep catches up (transcriptSnapshot.ts).
|
|
186
|
+
{
|
|
187
|
+
let entries: SnapshotEntry[] = [];
|
|
188
|
+
try { entries = transcriptSnapshot(session?.sessionManager?.getBranch?.() ?? []); } catch { /* no session yet */ }
|
|
189
|
+
relay?.sendSnapshot(entries, { full: entries.length > 0, running: turnActive });
|
|
190
|
+
}
|
|
185
191
|
relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() });
|
|
186
192
|
relay?.sendCommands(availableCommands());
|
|
187
193
|
},
|