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
|
@@ -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";
|
|
@@ -471,7 +472,19 @@ const bridge = new RemoteBridge({
|
|
|
471
472
|
// snapshot, push live context (model + version) so the app banner reflects this
|
|
472
473
|
// terminal, and advertise the slash commands for the composer's autocomplete.
|
|
473
474
|
setRemoteState("connected");
|
|
474
|
-
|
|
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
|
+
);
|
|
475
488
|
// cwd rides along here (home-collapsed on the way out, see RelayClient.sendContext).
|
|
476
489
|
// Without it the app's composer shows no working-directory strip at all — and that
|
|
477
490
|
// strip is the only place a driver can see which folder the prompts they type are
|
|
@@ -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
|
},
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
27
27
|
{ "entry": "extensions/privateer-truncation.ts", "name": "privateer-truncation", "note": "picks the task back up when a reply is cut off at the output limit" },
|
|
28
28
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
29
|
+
{ "entry": "extensions/privateer-recovery.ts", "name": "privateer-recovery", "note": "crash recovery — the live marker the supervisor resumes from, the note that tells the model its last action may have half-run, and session-sealing warnings" },
|
|
29
30
|
{ "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
30
31
|
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
|
|
31
32
|
{ "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
|
package/src/config/whatsNew.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
-
"reviewedFor": "0.15.
|
|
3
|
+
"reviewedFor": "0.15.5",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "If the agent crashes mid-task it comes back into your session, and saved sessions are now encrypted on disk", "since": "0.15.5" },
|
|
5
6
|
{ "text": "Slow images, speech and music run as background jobs — a dropped connection no longer loses a paid result", "since": "0.15.4" },
|
|
6
7
|
{ "text": "Driving from the app: a message sent mid-turn steers it, and questions survive a dropped link", "since": "0.15.1" },
|
|
7
|
-
{ "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" }
|
|
8
|
-
{ "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" }
|
|
8
|
+
{ "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// "Your agent is asking you something" — a push to the account's phones when a
|
|
2
|
+
// question (ask_user_question, a select, a text prompt) is put to an app that isn't
|
|
3
|
+
// there to see it.
|
|
4
|
+
//
|
|
5
|
+
// A question sent up the relay only reaches a LIVE socket. A phone that backgrounded
|
|
6
|
+
// a minute ago has none, so the question sat held on this machine (remoteBridge's
|
|
7
|
+
// holdOpenQuestions) with nothing telling anyone it was waiting — the user found it
|
|
8
|
+
// by chance, or after the hold ran out and the tool came back as an error.
|
|
9
|
+
//
|
|
10
|
+
// Content-free on purpose, like the Inbox nudge (server services/pushService.js): the
|
|
11
|
+
// question itself never leaves the relay, so the push says only that one is waiting.
|
|
12
|
+
// Fire-and-forget and throttled — a failed or skipped push must never touch the turn.
|
|
13
|
+
|
|
14
|
+
const COOLDOWN_MS = 30_000;
|
|
15
|
+
let lastAt = 0;
|
|
16
|
+
let sink: (() => void) | null = null;
|
|
17
|
+
|
|
18
|
+
export function requestAttentionPush(): void {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
if (now - lastAt < COOLDOWN_MS) return;
|
|
21
|
+
lastAt = now;
|
|
22
|
+
if (sink) return sink();
|
|
23
|
+
void (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const { apiRequest, hasCredentials } = await import("../auth/privateer.ts");
|
|
26
|
+
if (!hasCredentials()) return;
|
|
27
|
+
await apiRequest("/api/push/attention", { method: "POST", signal: AbortSignal.timeout(10_000) });
|
|
28
|
+
} catch {
|
|
29
|
+
/* best effort — the question is still held and re-sent when the app comes back */
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test seam: count pushes instead of sending them (null restores the real one), and
|
|
35
|
+
* forget the cooldown. Tests run on machines that are signed in — without this a
|
|
36
|
+
* bridge test would buzz the developer's phone. */
|
|
37
|
+
export function setAttentionPushSink(fn: (() => void) | null): void {
|
|
38
|
+
sink = fn;
|
|
39
|
+
lastAt = 0;
|
|
40
|
+
}
|
|
@@ -25,6 +25,7 @@ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccou
|
|
|
25
25
|
import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
26
26
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
27
27
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
28
|
+
import { transcriptSnapshot, type SnapshotEntry } from "./transcriptSnapshot.ts";
|
|
28
29
|
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
29
30
|
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
30
31
|
import { createUIContext } from "../ext/headlessUi.ts";
|
|
@@ -190,7 +191,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
190
191
|
onControllerAttached: () => {
|
|
191
192
|
attached = true;
|
|
192
193
|
if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
|
|
193
|
-
|
|
194
|
+
// The session's transcript, so an app back from sleep catches up (transcriptSnapshot.ts).
|
|
195
|
+
{
|
|
196
|
+
let entries: SnapshotEntry[] = [];
|
|
197
|
+
try { entries = transcriptSnapshot(session?.sessionManager?.getBranch?.() ?? []); } catch { /* no session yet */ }
|
|
198
|
+
relay?.sendSnapshot(entries, { full: entries.length > 0, running: turnActive });
|
|
199
|
+
}
|
|
194
200
|
// cwd rides along here (and nowhere else — see RelayClient.sendContext): this
|
|
195
201
|
// session's working directory was either named by the driver in the spawn form
|
|
196
202
|
// or defaulted to the harbor's own, and they have no other way to see which.
|