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.
@@ -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
- relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
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.4",
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
- relay?.sendSnapshot([]);
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" },
@@ -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.4",
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
- relay?.sendSnapshot([]);
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.