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
|
@@ -24,6 +24,7 @@ import type { EngineEvent } from "../engine/events.ts";
|
|
|
24
24
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
25
25
|
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
26
26
|
import { authorizeControl } from "./controlAuth.ts";
|
|
27
|
+
import type { SnapshotTool } from "./transcriptSnapshot.ts";
|
|
27
28
|
import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
|
|
28
29
|
import {
|
|
29
30
|
LIBRARY_CHUNK_CHARS,
|
|
@@ -1175,10 +1176,43 @@ export class RelayClient {
|
|
|
1175
1176
|
|
|
1176
1177
|
// Catch-up history sent to a controller on attach. Structured (not markdown) so
|
|
1177
1178
|
// the app renders it with the same styling as the live feed. Bounded: last 80
|
|
1178
|
-
// entries, each clipped
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1179
|
+
// entries, each clipped, and the whole frame kept well under the relay's 256 KB
|
|
1180
|
+
// frame cap by dropping the OLDEST entries first.
|
|
1181
|
+
//
|
|
1182
|
+
// `full` marks the entries as the terminal's whole current transcript (see
|
|
1183
|
+
// transcriptSnapshot.ts), so the app may REPLACE what it holds rather than only
|
|
1184
|
+
// adopting into an empty feed; `running` says whether a turn is still going, which
|
|
1185
|
+
// is how a reattaching app learns that the turn it last saw "Link dropped" on is
|
|
1186
|
+
// over. Both absent = the old notice-only snapshot, which the app keeps treating as
|
|
1187
|
+
// such.
|
|
1188
|
+
sendSnapshot(
|
|
1189
|
+
entries: { kind: string; text: string; tool?: SnapshotTool }[],
|
|
1190
|
+
meta?: { full?: boolean; running?: boolean },
|
|
1191
|
+
): void {
|
|
1192
|
+
const out: Record<string, unknown>[] = [];
|
|
1193
|
+
let budget = 160 * 1024;
|
|
1194
|
+
for (const e of entries.slice(-80).reverse()) {
|
|
1195
|
+
const t = e.tool;
|
|
1196
|
+
const entry: Record<string, unknown> = t
|
|
1197
|
+
? {
|
|
1198
|
+
kind: e.kind,
|
|
1199
|
+
text: "",
|
|
1200
|
+
tool: {
|
|
1201
|
+
id: t.id,
|
|
1202
|
+
name: t.name,
|
|
1203
|
+
input: safe(t.input, 500),
|
|
1204
|
+
...(t.output !== undefined ? { output: safe(t.output, 1500) } : {}),
|
|
1205
|
+
...(t.error !== undefined ? { error: safe(t.error, 1000) } : {}),
|
|
1206
|
+
...(t.running ? { running: true } : {}),
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
: { kind: e.kind, text: safe(String(e.text ?? ""), e.kind === "reasoning" ? 1500 : 4000) };
|
|
1210
|
+
budget -= JSON.stringify(entry).length;
|
|
1211
|
+
if (budget < 0) break;
|
|
1212
|
+
out.push(entry);
|
|
1213
|
+
}
|
|
1214
|
+
out.reverse();
|
|
1215
|
+
this.rawSend({ type: "snapshot", entries: out, ...(meta?.full ? { full: true, running: meta.running === true } : {}) });
|
|
1182
1216
|
}
|
|
1183
1217
|
|
|
1184
1218
|
// ── agent → app file transfer (chunked) ─────────────────────────────────────
|
|
@@ -20,6 +20,7 @@ import { redactText } from "../util/redact.ts";
|
|
|
20
20
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
21
21
|
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
22
22
|
import type { RelayCallbacks } from "./relayClient.ts";
|
|
23
|
+
import { requestAttentionPush } from "./attentionPush.ts";
|
|
23
24
|
import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
|
|
24
25
|
import type { LibrarySaveRequest, LibrarySaveResult } from "./librarySave.ts";
|
|
25
26
|
import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
|
|
@@ -168,6 +169,10 @@ export interface RemoteBridgeConfig {
|
|
|
168
169
|
onFilesSearch?: (id: string, query: string) => void;
|
|
169
170
|
// A controller (re)attached — the owner should push a transcript snapshot.
|
|
170
171
|
onControllerAttached?: () => void;
|
|
172
|
+
// A question is open and no app is attached to see it. Defaults to a content-free
|
|
173
|
+
// push to the account's phones (attentionPush.ts); a test or an owner with its own
|
|
174
|
+
// channel may replace it.
|
|
175
|
+
onQuestionUnwatched?: () => void;
|
|
171
176
|
// The last controller went away. Informational for the owner (the bridge already
|
|
172
177
|
// fails pending approvals closed); a driven turn in flight keeps running.
|
|
173
178
|
onControllerDetached?: () => void;
|
|
@@ -380,6 +385,7 @@ export class RemoteBridge {
|
|
|
380
385
|
this.remote = false;
|
|
381
386
|
this.rejectAllPending();
|
|
382
387
|
this.holdOpenQuestions();
|
|
388
|
+
if (this.hasOpenQuestion()) this.questionUnwatched();
|
|
383
389
|
this.cfg.onControllerDetached?.();
|
|
384
390
|
},
|
|
385
391
|
onAttachment: (file) => {
|
|
@@ -472,6 +478,9 @@ export class RemoteBridge {
|
|
|
472
478
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
473
479
|
}
|
|
474
480
|
this.relay!.requestSelect(id, req);
|
|
481
|
+
// Sent into a socket with no app on the other end: it is held and re-sent on
|
|
482
|
+
// reattach, but nobody knows to come back unless they are told.
|
|
483
|
+
if (this.relay!.hasController?.() === false) this.questionUnwatched();
|
|
475
484
|
});
|
|
476
485
|
};
|
|
477
486
|
|
|
@@ -495,6 +504,9 @@ export class RemoteBridge {
|
|
|
495
504
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
496
505
|
}
|
|
497
506
|
this.relay!.requestInput(id, req);
|
|
507
|
+
// Sent into a socket with no app on the other end: it is held and re-sent on
|
|
508
|
+
// reattach, but nobody knows to come back unless they are told.
|
|
509
|
+
if (this.relay!.hasController?.() === false) this.questionUnwatched();
|
|
498
510
|
});
|
|
499
511
|
};
|
|
500
512
|
|
|
@@ -711,6 +723,11 @@ export class RemoteBridge {
|
|
|
711
723
|
return true;
|
|
712
724
|
}
|
|
713
725
|
|
|
726
|
+
private questionUnwatched(): void {
|
|
727
|
+
if (this.cfg.onQuestionUnwatched) return this.cfg.onQuestionUnwatched();
|
|
728
|
+
requestAttentionPush();
|
|
729
|
+
}
|
|
730
|
+
|
|
714
731
|
private resendOpenQuestions(): void {
|
|
715
732
|
this.releaseQuestionGrace();
|
|
716
733
|
for (const [id, q] of this.pendingSelects) this.relay?.requestSelect(id, q.req);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The catch-up transcript a controller gets when it (re)attaches.
|
|
2
|
+
//
|
|
3
|
+
// The app used to get a single "Privateer terminal connected." notice here, and kept
|
|
4
|
+
// whatever feed it already held. So a phone that slept through the end of a turn came
|
|
5
|
+
// back to a transcript frozen at the moment its socket died — a "Link dropped" marker
|
|
6
|
+
// and nothing after it — while the answer sat in the terminal's session. The session
|
|
7
|
+
// IS the transcript: this projects its current branch into the same entry kinds the
|
|
8
|
+
// live feed uses, so the app can replace its stale copy with the real one.
|
|
9
|
+
//
|
|
10
|
+
// Pure (no Pi import) so it can be tested against plain entry objects.
|
|
11
|
+
|
|
12
|
+
export interface SnapshotTool {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
input: string;
|
|
16
|
+
output?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
// No result in the session yet: the call is running right now (or the turn was cut
|
|
19
|
+
// off mid-call, which `running: false` on the frame tells the app to settle).
|
|
20
|
+
running?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SnapshotEntry {
|
|
24
|
+
kind: "you" | "assistant" | "reasoning" | "tool" | "notice";
|
|
25
|
+
text: string;
|
|
26
|
+
tool?: SnapshotTool;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asText(v: unknown): string {
|
|
30
|
+
if (typeof v === "string") return v;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(v) ?? "";
|
|
33
|
+
} catch {
|
|
34
|
+
return String(v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function partsText(content: unknown): string {
|
|
39
|
+
if (typeof content === "string") return content;
|
|
40
|
+
if (!Array.isArray(content)) return "";
|
|
41
|
+
return content
|
|
42
|
+
.map((p: any) => (p?.type === "text" ? String(p.text ?? "") : p?.type === "image" ? "[image]" : ""))
|
|
43
|
+
.join("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Session entries (SessionManager.getBranch(), root → leaf) → feed entries, oldest
|
|
48
|
+
* first. Tool calls are paired with their results by id into one entry, the way the
|
|
49
|
+
* live feed settles a card in place. Entries that carry nothing a reader would see
|
|
50
|
+
* (model/thinking-level changes, labels, custom state) are skipped.
|
|
51
|
+
*/
|
|
52
|
+
export function transcriptSnapshot(entries: readonly any[]): SnapshotEntry[] {
|
|
53
|
+
const out: SnapshotEntry[] = [];
|
|
54
|
+
const toolAt = new Map<string, number>();
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
if (e?.type === "compaction") {
|
|
57
|
+
out.length = 0; // what came before is no longer the conversation the agent has
|
|
58
|
+
toolAt.clear();
|
|
59
|
+
out.push({ kind: "notice", text: "Earlier conversation was compacted." });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (e?.type !== "message") continue;
|
|
63
|
+
const m = e.message;
|
|
64
|
+
switch (m?.role) {
|
|
65
|
+
case "user": {
|
|
66
|
+
const text = partsText(m.content).trim();
|
|
67
|
+
if (text) out.push({ kind: "you", text });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "assistant": {
|
|
71
|
+
for (const p of Array.isArray(m.content) ? m.content : []) {
|
|
72
|
+
if (p?.type === "text" && String(p.text ?? "").trim()) out.push({ kind: "assistant", text: String(p.text) });
|
|
73
|
+
else if (p?.type === "thinking" && !p.redacted && String(p.thinking ?? "").trim()) {
|
|
74
|
+
out.push({ kind: "reasoning", text: String(p.thinking) });
|
|
75
|
+
} else if (p?.type === "toolCall" && p.id) {
|
|
76
|
+
const input = asText(p.arguments ?? {});
|
|
77
|
+
toolAt.set(String(p.id), out.length);
|
|
78
|
+
out.push({
|
|
79
|
+
kind: "tool",
|
|
80
|
+
text: `▸ ${p.name}${input ? ` ${input}` : ""}`,
|
|
81
|
+
tool: { id: String(p.id), name: String(p.name ?? ""), input, running: true },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (m.stopReason === "error" && typeof m.errorMessage === "string" && m.errorMessage) {
|
|
86
|
+
out.push({ kind: "notice", text: `⚠ ${m.errorMessage}` });
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "toolResult": {
|
|
91
|
+
const at = toolAt.get(String(m.toolCallId));
|
|
92
|
+
const text = partsText(m.content);
|
|
93
|
+
if (at === undefined) break; // a result whose call was compacted away
|
|
94
|
+
const entry = out[at];
|
|
95
|
+
const tool = { ...entry.tool!, running: false };
|
|
96
|
+
if (m.isError) tool.error = text;
|
|
97
|
+
else tool.output = text;
|
|
98
|
+
out[at] = { ...entry, text: `${entry.text}\n ↳ ${text}`, tool };
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|