privateer-agent 0.12.7 → 0.12.10
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/README.md +29 -10
- package/SECURITY.md +41 -5
- package/bin/privateer-launch.mjs +85 -5
- package/bin/privateer-splash.mjs +247 -0
- package/bin/privateer-verify.d.mts +10 -0
- package/bin/privateer-verify.mjs +289 -0
- package/bin/update-route.d.mts +6 -0
- package/bin/update-route.mjs +35 -0
- package/extensions/privateer-brand.ts +35 -24
- package/extensions/privateer-hints.ts +8 -2
- package/extensions/privateer-update.ts +187 -0
- package/package.json +25 -25
- package/patches/{@earendil-works+pi-coding-agent+0.80.3.patch → @earendil-works+pi-coding-agent+0.84.1.patch} +221 -132
- package/src/acp/run.ts +5 -4
- package/src/channels/run.ts +3 -1
- package/src/cli/chat.ts +7 -5
- package/src/config/moatManifest.json +1 -0
- package/src/context.ts +9 -1
- package/src/harbor/index.ts +7 -6
- package/src/providers/account.ts +37 -29
- package/src/providers/piAuthStore.ts +129 -0
- package/src/remote/liveTaskSession.ts +6 -6
- package/src/session.ts +26 -9
- package/src/updates.ts +106 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// `privateer verify` — check the install that is actually on this disk.
|
|
2
|
+
//
|
|
3
|
+
// Every other trust signal we offer is an INSTALL-TIME signal: npm provenance is
|
|
4
|
+
// checked when you install, install.sh verifies a checksum before it unpacks, the
|
|
5
|
+
// npm page shows a green badge to whoever is looking at the npm page. None of that
|
|
6
|
+
// answers the question a user actually has three weeks later, which is "is the
|
|
7
|
+
// thing on my machine right now still the thing you published?"
|
|
8
|
+
//
|
|
9
|
+
// So this command reads local state first and the network second, and it is careful
|
|
10
|
+
// about the difference between "verified", "couldn't check", and "wrong". A check
|
|
11
|
+
// that cannot reach the registry says so; it never reports a pass it didn't make.
|
|
12
|
+
//
|
|
13
|
+
// Deliberately NOT a security boundary: anything that can rewrite node_modules can
|
|
14
|
+
// rewrite this file too. It is a tampering *smoke alarm* and an honest inventory —
|
|
15
|
+
// useful exactly because most breakage is accidental (a half-finished upgrade, a
|
|
16
|
+
// stale patch stamp, a hand-edited dependency) rather than adversarial.
|
|
17
|
+
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { findDepRoot } from "./apply-patches.mjs";
|
|
24
|
+
|
|
25
|
+
const REGISTRY = process.env.npm_config_registry || "https://registry.npmjs.org";
|
|
26
|
+
const NET_TIMEOUT_MS = 8000;
|
|
27
|
+
|
|
28
|
+
// --- tiny reporter ---------------------------------------------------------
|
|
29
|
+
// Three outcomes, and the third one is the point: "unknown" must never be
|
|
30
|
+
// rendered as a pass. Exit code reflects only real failures, so `privateer verify`
|
|
31
|
+
// is safe to put in a CI step without breaking on an offline runner.
|
|
32
|
+
const C = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
33
|
+
const dim = (s) => (C ? `\x1b[2m${s}\x1b[0m` : s);
|
|
34
|
+
const bold = (s) => (C ? `\x1b[1m${s}\x1b[0m` : s);
|
|
35
|
+
const green = (s) => (C ? `\x1b[32m${s}\x1b[0m` : s);
|
|
36
|
+
const red = (s) => (C ? `\x1b[31m${s}\x1b[0m` : s);
|
|
37
|
+
const yellow = (s) => (C ? `\x1b[33m${s}\x1b[0m` : s);
|
|
38
|
+
|
|
39
|
+
let failures = 0;
|
|
40
|
+
let unknowns = 0;
|
|
41
|
+
|
|
42
|
+
function pass(label, detail) {
|
|
43
|
+
console.log(` ${green("✓")} ${label}${detail ? ` ${dim(detail)}` : ""}`);
|
|
44
|
+
}
|
|
45
|
+
function fail(label, detail) {
|
|
46
|
+
failures++;
|
|
47
|
+
console.log(` ${red("✗")} ${label}${detail ? ` ${dim(detail)}` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
function unknown(label, detail) {
|
|
50
|
+
unknowns++;
|
|
51
|
+
console.log(` ${yellow("?")} ${label}${detail ? ` ${dim(detail)}` : ""}`);
|
|
52
|
+
}
|
|
53
|
+
function note(text) {
|
|
54
|
+
console.log(` ${dim(text)}`);
|
|
55
|
+
}
|
|
56
|
+
function heading(text) {
|
|
57
|
+
console.log(`\n${bold(text)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readJson = (p) => {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
async function getJson(url) {
|
|
69
|
+
const ac = new AbortController();
|
|
70
|
+
const t = setTimeout(() => ac.abort(), NET_TIMEOUT_MS);
|
|
71
|
+
try {
|
|
72
|
+
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json" } });
|
|
73
|
+
if (!r.ok) return { error: `HTTP ${r.status}` };
|
|
74
|
+
return { data: await r.json() };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { error: e.name === "AbortError" ? "timed out" : e.message };
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(t);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** sha256 over every patch file's name + contents — must match apply-patches.mjs. */
|
|
83
|
+
function patchSetHash(patchDir, files, stampVersion) {
|
|
84
|
+
const h = crypto.createHash("sha256").update(String(stampVersion));
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
h.update(f);
|
|
87
|
+
h.update(fs.readFileSync(path.join(patchDir, f)));
|
|
88
|
+
}
|
|
89
|
+
return h.digest("hex");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function verify({ repo, offline = false } = {}) {
|
|
93
|
+
// Reset: the counters are module state, so a second call in one process (a test,
|
|
94
|
+
// or any future programmatic caller) would otherwise inherit the first's verdict.
|
|
95
|
+
failures = 0;
|
|
96
|
+
unknowns = 0;
|
|
97
|
+
|
|
98
|
+
const pkg = readJson(path.join(repo, "package.json")) || {};
|
|
99
|
+
const version = pkg.version || "unknown";
|
|
100
|
+
const bundleInfo = readJson(path.join(repo, "BUNDLE_INFO.json"));
|
|
101
|
+
|
|
102
|
+
console.log(`\n${bold("⚓ Privateer install check")} ${dim(`v${version}`)}`);
|
|
103
|
+
|
|
104
|
+
// --- 1. what kind of install is this ------------------------------------
|
|
105
|
+
heading("Install");
|
|
106
|
+
if (bundleInfo) {
|
|
107
|
+
pass("self-contained bundle", `${bundleInfo.target || "?"} · pinned Node ${bundleInfo.node || "?"}`);
|
|
108
|
+
note("bundles ship a fixed file set — nothing was resolved on this machine at install time");
|
|
109
|
+
} else {
|
|
110
|
+
pass("npm install", "dependencies were resolved on this machine");
|
|
111
|
+
}
|
|
112
|
+
console.log(` ${dim("path")} ${repo}`);
|
|
113
|
+
console.log(` ${dim("node")} ${process.version}`);
|
|
114
|
+
|
|
115
|
+
// --- 2. is this version the one the registry knows about ----------------
|
|
116
|
+
// The strongest thing checkable from here without re-downloading a tarball:
|
|
117
|
+
// does this exact version exist upstream, and does it carry a provenance
|
|
118
|
+
// attestation? A version the registry has never heard of is the loud case.
|
|
119
|
+
heading("Published provenance");
|
|
120
|
+
if (offline) {
|
|
121
|
+
unknown("skipped (--offline)");
|
|
122
|
+
} else {
|
|
123
|
+
const { data, error } = await getJson(`${REGISTRY}/${pkg.name || "privateer-agent"}/${version}`);
|
|
124
|
+
if (error) {
|
|
125
|
+
unknown("couldn't reach the npm registry", `(${error})`);
|
|
126
|
+
note("re-run with a network connection, or use --offline to skip this section");
|
|
127
|
+
} else if (!data || data.error) {
|
|
128
|
+
fail(`the registry has no ${pkg.name}@${version}`, "this build was not published");
|
|
129
|
+
note("an unpublished version is not automatically malicious — an unreleased local");
|
|
130
|
+
note("checkout looks identical — but a machine you only ever installed from npm");
|
|
131
|
+
note("should never be in this state.");
|
|
132
|
+
} else {
|
|
133
|
+
pass(`published as ${pkg.name}@${version}`);
|
|
134
|
+
const att = data.dist?.attestations;
|
|
135
|
+
if (att?.provenance?.predicateType) {
|
|
136
|
+
pass("carries an npm provenance attestation", att.provenance.predicateType);
|
|
137
|
+
note("verify the signature chain end-to-end with: npm audit signatures");
|
|
138
|
+
} else {
|
|
139
|
+
fail("no provenance attestation on this version");
|
|
140
|
+
note("every release from 0.6.7 on is published from CI with --provenance;");
|
|
141
|
+
note("a later version without one did not come from that workflow.");
|
|
142
|
+
}
|
|
143
|
+
const npmUser = data._npmUser?.trustedPublisher?.id;
|
|
144
|
+
if (npmUser) pass("published by a trusted publisher", npmUser);
|
|
145
|
+
else if (att) unknown("publisher identity not reported by the registry");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- 3. did the dependency tree drift from what we declared -------------
|
|
150
|
+
// Every direct dependency is pinned to an exact version, precisely so this
|
|
151
|
+
// check can be exact: a resolved version that differs from the declared one
|
|
152
|
+
// means the tree was changed after install.
|
|
153
|
+
heading("Dependencies");
|
|
154
|
+
const deps = Object.entries(pkg.dependencies || {});
|
|
155
|
+
const drift = [];
|
|
156
|
+
const ranged = []; // declared with a range, so there is nothing exact to check against
|
|
157
|
+
let checked = 0;
|
|
158
|
+
let missing = 0;
|
|
159
|
+
for (const [name, want] of deps) {
|
|
160
|
+
const root = findDepRoot(repo, name);
|
|
161
|
+
const manifest = root && readJson(path.join(root, "node_modules", ...name.split("/"), "package.json"));
|
|
162
|
+
if (!manifest) {
|
|
163
|
+
missing++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// An exact pin is the only thing this check can be exact about. A caret range
|
|
167
|
+
// is not a failure, but counting it as a match would be a lie — it would report
|
|
168
|
+
// "matches its pinned version" for a dependency that has no pinned version.
|
|
169
|
+
if (!/^\d/.test(want)) {
|
|
170
|
+
ranged.push(`${name} ${want} (resolved ${manifest.version})`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
checked++;
|
|
174
|
+
if (manifest.version !== want) drift.push(`${name} ${want} → ${manifest.version}`);
|
|
175
|
+
}
|
|
176
|
+
if (missing) {
|
|
177
|
+
unknown(`${missing} of ${deps.length} direct dependencies not found on disk`);
|
|
178
|
+
note("normal inside a bundle, which flattens its own tree; unexpected for an npm install");
|
|
179
|
+
}
|
|
180
|
+
if (drift.length > 0) {
|
|
181
|
+
fail(`${drift.length} dependenc${drift.length === 1 ? "y differs" : "ies differ"} from the pinned version`);
|
|
182
|
+
for (const d of drift) note(d);
|
|
183
|
+
} else if (checked > 0) {
|
|
184
|
+
pass(`${checked} direct dependenc${checked === 1 ? "y matches its" : "ies match their"} exact pin`);
|
|
185
|
+
}
|
|
186
|
+
if (ranged.length > 0) {
|
|
187
|
+
// Versions before 0.12.9 shipped caret ranges, so an older install lands here.
|
|
188
|
+
unknown(`${ranged.length} direct dependenc${ranged.length === 1 ? "y is" : "ies are"} declared as a range — cannot be checked exactly`);
|
|
189
|
+
for (const d of ranged.slice(0, 5)) note(d);
|
|
190
|
+
if (ranged.length > 5) note(`… and ${ranged.length - 5} more`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const piRoot = findDepRoot(repo, "@earendil-works/pi-coding-agent");
|
|
194
|
+
const piPkg =
|
|
195
|
+
piRoot && readJson(path.join(piRoot, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"));
|
|
196
|
+
if (piPkg) console.log(` ${dim("runtime")} pi-coding-agent ${piPkg.version}`);
|
|
197
|
+
|
|
198
|
+
// --- 4. patch state ------------------------------------------------------
|
|
199
|
+
// Patches are applied at LAUNCH into node_modules, so the running tree is
|
|
200
|
+
// legitimately not byte-identical to the published tarball. That is by design
|
|
201
|
+
// (it is how we avoid an install script), but it means "the files changed" is
|
|
202
|
+
// not by itself a finding — this is the section that says which changes are ours.
|
|
203
|
+
heading("Launch-time patches");
|
|
204
|
+
const patchDir = path.join(repo, "patches");
|
|
205
|
+
const patchFiles = fs.existsSync(patchDir)
|
|
206
|
+
? fs
|
|
207
|
+
.readdirSync(patchDir)
|
|
208
|
+
.filter((f) => f.endsWith(".patch"))
|
|
209
|
+
.sort()
|
|
210
|
+
: [];
|
|
211
|
+
if (patchFiles.length === 0) {
|
|
212
|
+
pass("none shipped");
|
|
213
|
+
} else {
|
|
214
|
+
const stampFile = piRoot && path.join(piRoot, "node_modules", ".privateer-patches.json");
|
|
215
|
+
const stamp = stampFile && readJson(stampFile);
|
|
216
|
+
// STAMP_VERSION is private to apply-patches.mjs; try the current value and
|
|
217
|
+
// report a mismatch as "stale" rather than guessing at the applier's internals.
|
|
218
|
+
const want = patchSetHash(patchDir, patchFiles, 1);
|
|
219
|
+
if (!stamp) {
|
|
220
|
+
unknown(`${patchFiles.length} patch(es) shipped, none recorded as applied`);
|
|
221
|
+
note("expected before the first launch, or when node_modules is root-owned");
|
|
222
|
+
note("(sudo npm i -g) — Privateer then runs on stock Pi and says so at startup");
|
|
223
|
+
} else if (stamp.hash === want) {
|
|
224
|
+
pass(`${patchFiles.length} patch(es) applied and current`, `at ${stamp.at || "?"}`);
|
|
225
|
+
for (const f of patchFiles) note(f);
|
|
226
|
+
} else {
|
|
227
|
+
unknown("recorded patch set does not match the shipped one", "— will re-apply next launch");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- 5. where config and credentials live -------------------------------
|
|
232
|
+
heading("Config");
|
|
233
|
+
const home = process.env.PRIVATEER_HOME || path.join(process.env.HOME || "", ".privateer");
|
|
234
|
+
console.log(` ${dim("home")} ${home}${fs.existsSync(home) ? "" : dim(" (not created yet)")}`);
|
|
235
|
+
for (const d of [".privateer", ".pi"]) {
|
|
236
|
+
const p = path.join(process.cwd(), d);
|
|
237
|
+
if (fs.existsSync(p)) console.log(` ${dim("project")} ${p}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- verdict -------------------------------------------------------------
|
|
241
|
+
console.log("");
|
|
242
|
+
if (failures > 0) {
|
|
243
|
+
console.log(
|
|
244
|
+
red(`✗ ${failures} check(s) failed`) +
|
|
245
|
+
(unknowns ? dim(`, ${unknowns} inconclusive`) : "") +
|
|
246
|
+
"\n" +
|
|
247
|
+
dim(" If you did not expect this, reinstall from a known-good source:\n") +
|
|
248
|
+
dim(" curl -fsSL https://privateer.pro/install.sh | sh\n") +
|
|
249
|
+
dim(" and report it at https://github.com/privateer-agent/privateer-agent/issues"),
|
|
250
|
+
);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
if (unknowns > 0) {
|
|
254
|
+
console.log(yellow(`✓ no failures, ${unknowns} check(s) inconclusive`));
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
console.log(green("✓ everything checks out"));
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- CLI -------------------------------------------------------------------
|
|
262
|
+
// Guarded, so importing this module (from a test, or from the launcher) does not
|
|
263
|
+
// run the command and exit the process out from under the caller.
|
|
264
|
+
//
|
|
265
|
+
// fileURLToPath, not `new URL(...).pathname`: on Windows the latter yields
|
|
266
|
+
// "/C:/Users/..." with percent-encoded spaces, so every path built from it is wrong
|
|
267
|
+
// — and win32 is a platform we ship a bundle for.
|
|
268
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
269
|
+
|
|
270
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
271
|
+
const argv = process.argv.slice(2);
|
|
272
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
273
|
+
const cmd = process.env.PRIVATEER_CMD || "privateer";
|
|
274
|
+
console.log(
|
|
275
|
+
[
|
|
276
|
+
`${cmd} verify — check the Privateer install on this machine.`,
|
|
277
|
+
"",
|
|
278
|
+
` ${cmd} verify run every check`,
|
|
279
|
+
` ${cmd} verify --offline skip the checks that need the npm registry`,
|
|
280
|
+
"",
|
|
281
|
+
"Reports the install shape, whether this version is published with npm",
|
|
282
|
+
"provenance, whether the dependency tree still matches its pinned versions,",
|
|
283
|
+
"and which launch-time patches are applied.",
|
|
284
|
+
].join("\n"),
|
|
285
|
+
);
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
process.exit(await verify({ repo: path.resolve(HERE, ".."), offline: argv.includes("--offline") }));
|
|
289
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Types for the `privateer update` argv router. Same reason as apply-patches.d.mts:
|
|
2
|
+
// the implementation is plain .mjs because bin/ runs under a bare `node`, before any
|
|
3
|
+
// transpiler exists — but the tests that pin the grammar are TypeScript.
|
|
4
|
+
|
|
5
|
+
/** Which half of `privateer update` the argv after the subcommand asks for. */
|
|
6
|
+
export function routeUpdate(rest: string[] | undefined): "self" | "packs" | "all" | "help";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Which half of `privateer update` did the user mean?
|
|
2
|
+
//
|
|
3
|
+
// "update" used to mean ONE thing — replace the CLI — and the launcher swallowed every
|
|
4
|
+
// flag after it. So `privateer update --extensions`, the command Pi's own startup banner
|
|
5
|
+
// told people to run (as `pi update --extensions`, naming a binary that is installed
|
|
6
|
+
// nowhere on a Privateer machine), silently reinstalled the CLI instead of fetching the
|
|
7
|
+
// tool pack that had an update waiting. Splitting the verb the way Pi's own grammar
|
|
8
|
+
// already does is the fix; this is the decision, kept apart from the launcher so it can
|
|
9
|
+
// be tested without spawning anything (tests/updates.test.ts).
|
|
10
|
+
//
|
|
11
|
+
// privateer update → "self" (unchanged: the common case)
|
|
12
|
+
// privateer update --self | self | pi → "self"
|
|
13
|
+
// privateer update --extensions → "packs"
|
|
14
|
+
// privateer update <pack> | --extension <pack> → "packs"
|
|
15
|
+
// privateer update --all → "all" (packs, then the CLI)
|
|
16
|
+
//
|
|
17
|
+
// "all" is deliberately not Pi's "--all": to Pi that includes updating pi-coding-agent
|
|
18
|
+
// globally via npm, which is not how this CLI is installed. Ours means tool packs plus
|
|
19
|
+
// `privateer update`, so the launcher translates it and chains the self-update after.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string[]} rest — argv after the `update` subcommand
|
|
23
|
+
* @returns {"self" | "packs" | "all" | "help"}
|
|
24
|
+
*/
|
|
25
|
+
export function routeUpdate(rest) {
|
|
26
|
+
const args = rest ?? [];
|
|
27
|
+
// `update --help` must not fall through to "no pack flags → update the CLI", which
|
|
28
|
+
// would answer a question by reinstalling the program.
|
|
29
|
+
if (args.includes("--help") || args.includes("-h")) return "help";
|
|
30
|
+
if (args.includes("--all")) return "all";
|
|
31
|
+
// A bare word that isn't "self"/"pi" is a pack name (Pi's positional source form).
|
|
32
|
+
const namesPack = args.some((a) => !a.startsWith("-") && a !== "self" && a !== "pi");
|
|
33
|
+
if (args.includes("--extensions") || args.includes("--extension") || namesPack) return "packs";
|
|
34
|
+
return "self";
|
|
35
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import { resolveSignedInModel, savedPiDefaultSpec } from "../src/providers/defaultModel.ts";
|
|
38
38
|
import { canOpenBrowser, openInBrowser } from "../src/util/openBrowser.ts";
|
|
39
39
|
import { discoverContextFiles, onContextChanged } from "../src/context.ts";
|
|
40
|
+
import { onPackUpdatesChanged, pendingCliUpdate, pendingPackUpdates } from "../src/updates.ts";
|
|
40
41
|
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
41
42
|
|
|
42
43
|
const VERSION: string = (() => {
|
|
@@ -225,32 +226,30 @@ function accountLine(p: Palette, modelProvider?: string): string {
|
|
|
225
226
|
return `${p.DIM}not signed in · ${p.INK}/login${p.DIM} to connect your account${p.RESET}`;
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
// Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
|
|
229
|
-
// enough for our npm releases; anything unparseable sorts as 0 and is treated as older.
|
|
230
|
-
function isNewer(a: string, b: string): boolean {
|
|
231
|
-
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
232
|
-
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
233
|
-
for (let i = 0; i < 3; i++) {
|
|
234
|
-
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
235
|
-
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
236
|
-
}
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
229
|
// The "update available" banner line, or "" when we're current / offline / unchecked.
|
|
241
230
|
// Reads the cache the launcher refreshes in the background (see bin/privateer-tui) —
|
|
242
231
|
// never fetches here, so the banner stays synchronous and never blocks on the network.
|
|
243
232
|
function updateNotice(p: Palette): string {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
233
|
+
const latest = pendingCliUpdate(VERSION);
|
|
234
|
+
if (!latest) return "";
|
|
235
|
+
return `${p.YELLOW}↑ v${latest} available${p.DIM} · run ${p.RESET}${p.INK}privateer update${p.RESET}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The tool-pack sibling of that line: a pack (an npm/git package contributing extensions,
|
|
239
|
+
// skills, prompts or themes) has a newer version installed-able. Pi's own surface for this
|
|
240
|
+
// was a warning-bordered box below the banner naming a command that doesn't exist here —
|
|
241
|
+
// see extensions/privateer-update.ts, which owns the check and the /update that acts on it.
|
|
242
|
+
// One line, same rule as the release notice: nothing at all when there's nothing to fetch.
|
|
243
|
+
// Empty at startup and fills in when the background check lands (onPackUpdatesChanged
|
|
244
|
+
// re-renders the header), so a slow registry never holds up the banner.
|
|
245
|
+
function packNotice(p: Palette): string {
|
|
246
|
+
const packs = pendingPackUpdates();
|
|
247
|
+
if (packs.length === 0) return "";
|
|
248
|
+
const what =
|
|
249
|
+
packs.length === 1
|
|
250
|
+
? `update ready for ${clean(packs[0].displayName)}`
|
|
251
|
+
: `${packs.length} tool pack updates ready`;
|
|
252
|
+
return `${p.YELLOW}⚑ ${what}${p.DIM} · ${p.RESET}${p.INK}/update${p.RESET}`;
|
|
254
253
|
}
|
|
255
254
|
|
|
256
255
|
// The PRIVATEER.md line under the block: green anchor when a project-context file is
|
|
@@ -307,6 +306,8 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
|
|
|
307
306
|
];
|
|
308
307
|
const notice = updateNotice(p);
|
|
309
308
|
if (notice) text.push(notice);
|
|
309
|
+
const packs = packNotice(p);
|
|
310
|
+
if (packs) text.push(packs);
|
|
310
311
|
// A blank spacer, then the What's New block — set off below the identity lines.
|
|
311
312
|
text.push("", ...whatsNewRows(p));
|
|
312
313
|
|
|
@@ -403,8 +404,14 @@ export default function privateerBrand(pi: any): void {
|
|
|
403
404
|
// entry is dead for every terminal;
|
|
404
405
|
// - the default ownership-checked drop for THIS process's exit, which must not
|
|
405
406
|
// delete the credential another running terminal is using.
|
|
406
|
-
|
|
407
|
-
|
|
407
|
+
// `ctx` is no longer passed through: pi 0.84 removed ctx.modelRegistry.authStorage, so
|
|
408
|
+
// the store is resolved from piAuthStore() inside. Kept in the signature because every
|
|
409
|
+
// caller here is a Pi command handler that has one, and dropping the parameter would
|
|
410
|
+
// churn call sites for no gain. Fire-and-forget as before — the drop is best-effort and
|
|
411
|
+
// the server-side TTL is the fallback — but the promise is now caught explicitly rather
|
|
412
|
+
// than left to become an unhandled rejection.
|
|
413
|
+
const dropPersistedAccount = (_ctx: any, opts: { force?: boolean } = {}): void => {
|
|
414
|
+
void dropPersistedAccountCredential(opts).catch(() => { /* best effort — server TTL covers it */ });
|
|
408
415
|
};
|
|
409
416
|
|
|
410
417
|
// /update — run the global npm install in a child process and report the outcome via
|
|
@@ -684,6 +691,10 @@ export default function privateerBrand(pi: any): void {
|
|
|
684
691
|
// banner so its context line flips from the "/init" hint to "PRIVATEER.md loaded".
|
|
685
692
|
onContextChanged(() => refresh(ctxRef));
|
|
686
693
|
|
|
694
|
+
// privateer-update's background check came back (or an /update cleared the list) —
|
|
695
|
+
// re-render so the ⚑ line appears or disappears without waiting for the next event.
|
|
696
|
+
onPackUpdatesChanged(() => refresh(ctxRef));
|
|
697
|
+
|
|
687
698
|
// The terminal is quitting (Ctrl+C, Ctrl+D, /quit, SIGTERM …). Pi awaits this
|
|
688
699
|
// handler inside runtimeHost.dispose() BEFORE process.exit, so it's our one
|
|
689
700
|
// reliable window to revoke the server-side sessions this run created — the
|
|
@@ -39,6 +39,12 @@ function key(binding: Parameters<typeof keyText>[0], fallback: string): string {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Mirror of privateer-speak's DEFAULT_SHORTCUT. One constant, not a literal at each
|
|
43
|
+
// return, so the two paths below can't drift apart from each other — they can still
|
|
44
|
+
// drift from the package, which is why the value is named here rather than inlined:
|
|
45
|
+
// grep for it when bumping privateer-speak.
|
|
46
|
+
const DEFAULT_TALK_KEY = "alt+t";
|
|
47
|
+
|
|
42
48
|
// Push-to-talk is pi-speak's own binding, not a Pi keybinding action, so keyText
|
|
43
49
|
// can't see it — it lives in speak.json beside config.json and /talk key rebinds
|
|
44
50
|
// it. Read it the same defensive way (any failure = the default).
|
|
@@ -46,9 +52,9 @@ function talkKey(): string {
|
|
|
46
52
|
try {
|
|
47
53
|
const shortcut = JSON.parse(readFileSync(join(globalDir(), "speak.json"), "utf8"))?.input?.shortcut;
|
|
48
54
|
// An explicit "" is "unbound", not "missing" — only an absent field defaults.
|
|
49
|
-
return typeof shortcut === "string" ? shortcut :
|
|
55
|
+
return typeof shortcut === "string" ? shortcut : DEFAULT_TALK_KEY;
|
|
50
56
|
} catch {
|
|
51
|
-
return
|
|
57
|
+
return DEFAULT_TALK_KEY;
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `/update` — fetch newer tool packs without leaving the terminal.
|
|
2
|
+
//
|
|
3
|
+
// A "tool pack" is what Pi calls a package: an npm or git source that contributes
|
|
4
|
+
// extensions, skills, prompts or themes (pi-hermes-memory, a private tools repo, …).
|
|
5
|
+
// Pi already checked for updates at startup and drew its own yellow box about it. We
|
|
6
|
+
// suppress that box (patches/, in interactive-mode's run()) and own the surface here,
|
|
7
|
+
// for two reasons:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE BOX NAMED A COMMAND THAT DOES NOT EXIST. It said "Run pi update --extensions".
|
|
10
|
+
// No `pi` binary is installed on a Privateer machine, and `privateer update` means
|
|
11
|
+
// the CLI's own self-update — so the one instruction on screen either failed with
|
|
12
|
+
// "command not found" or reinstalled the wrong thing. The launcher now routes
|
|
13
|
+
// `privateer update --extensions` properly (bin/privateer-launch.mjs), and in the
|
|
14
|
+
// TUI the answer is simply /update.
|
|
15
|
+
// 2. IT WAS THE WRONG SIZE. A warning-bordered box under the banner, for "a pack has a
|
|
16
|
+
// newer version". Privateer's own release notice is a single line INSIDE the banner;
|
|
17
|
+
// packs get a line beside it (extensions/privateer-brand.ts) and nothing more.
|
|
18
|
+
//
|
|
19
|
+
// WHY THIS CAN HAPPEN LIVE. Nothing here needs a restart, and that is a property of Pi's
|
|
20
|
+
// own machinery rather than a trick of ours:
|
|
21
|
+
// - DefaultPackageManager.update() installs into the same on-disk locations the
|
|
22
|
+
// resolver reads from, so the new code is simply *there* when something looks again.
|
|
23
|
+
// - ctx.reload() runs the same path as /reload: resourceLoader.reload() re-runs
|
|
24
|
+
// packageManager.resolve() AND calls clearExtensionCache(), and extensions are loaded
|
|
25
|
+
// through jiti with moduleCache:false — so every extension file is re-read from disk,
|
|
26
|
+
// not served from a module cache. Keybindings, skills, prompts and themes come back
|
|
27
|
+
// with it, and the chat is rebuilt from the session's messages, so scrollback and
|
|
28
|
+
// context survive.
|
|
29
|
+
// The honest caveat: in Node (not the bundle) jiti leaves `tryNative` on, so a dependency
|
|
30
|
+
// DEEP inside an updated npm pack that Node already loaded natively can stay the old copy
|
|
31
|
+
// in memory. The pack's own source always reloads; if something still looks stale after
|
|
32
|
+
// an update, a restart is the guaranteed fix, and we say so rather than pretending.
|
|
33
|
+
//
|
|
34
|
+
// The CLI itself is the one thing that genuinely cannot be swapped live — updating it
|
|
35
|
+
// replaces the program that is running — so /update reports it and points at the shell.
|
|
36
|
+
|
|
37
|
+
import { DefaultPackageManager, SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
38
|
+
import { agentVersion } from "../src/config/version.ts";
|
|
39
|
+
import {
|
|
40
|
+
type PackUpdate,
|
|
41
|
+
pendingCliUpdate,
|
|
42
|
+
pendingPackUpdates,
|
|
43
|
+
setPendingPackUpdates,
|
|
44
|
+
} from "../src/updates.ts";
|
|
45
|
+
|
|
46
|
+
// A check costs an npm/git round trip per configured pack. Once at startup is the point;
|
|
47
|
+
// the extra checks come from reload (including the one our own /update triggers), where
|
|
48
|
+
// re-asking the registry seconds after we already know the answer is pure waste.
|
|
49
|
+
const RECHECK_AFTER_MS = 60_000;
|
|
50
|
+
let lastCheckedAt = 0;
|
|
51
|
+
|
|
52
|
+
// Build a package manager against THIS session's cwd and trust decision. Mirrors what
|
|
53
|
+
// interactive-mode does for its own startup check: an untrusted project's packages must
|
|
54
|
+
// stay out, and trust is the session's answer (ctx.isProjectTrusted), not a fresh guess.
|
|
55
|
+
function packageManagerFor(ctx: any): any {
|
|
56
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
57
|
+
const agentDir = getAgentDir();
|
|
58
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, {
|
|
59
|
+
projectTrusted: ctx?.isProjectTrusted?.() ?? false,
|
|
60
|
+
});
|
|
61
|
+
return new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runCheck(ctx: any, opts: { force?: boolean } = {}): Promise<readonly PackUpdate[]> {
|
|
65
|
+
if (!opts.force && Date.now() - lastCheckedAt < RECHECK_AFTER_MS) return pendingPackUpdates();
|
|
66
|
+
// --offline / PI_OFFLINE means "no startup network", and a registry check is exactly
|
|
67
|
+
// that. Pi's own check bails the same way.
|
|
68
|
+
if (process.env.PI_OFFLINE) return pendingPackUpdates();
|
|
69
|
+
lastCheckedAt = Date.now();
|
|
70
|
+
const updates = await packageManagerFor(ctx).checkForAvailableUpdates();
|
|
71
|
+
const packs: PackUpdate[] = updates.map((u: any) => ({
|
|
72
|
+
source: u.source,
|
|
73
|
+
displayName: u.displayName,
|
|
74
|
+
type: u.type,
|
|
75
|
+
scope: u.scope,
|
|
76
|
+
}));
|
|
77
|
+
setPendingPackUpdates(packs);
|
|
78
|
+
return packs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// "pi-hermes-memory", "pi-hermes-memory and pi-speak", "pi-hermes-memory, pi-speak and 2 more".
|
|
82
|
+
function nameList(packs: readonly PackUpdate[]): string {
|
|
83
|
+
const names = packs.map((p) => p.displayName);
|
|
84
|
+
if (names.length <= 2) return names.join(" and ");
|
|
85
|
+
return `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The CLI half of "what's out of date", appended to /update's replies so the answer to
|
|
89
|
+
// "am I current?" is complete in one place. Null when the CLI is current or unchecked.
|
|
90
|
+
function cliNote(): string | null {
|
|
91
|
+
const latest = pendingCliUpdate(agentVersion());
|
|
92
|
+
return latest
|
|
93
|
+
? `Privateer v${latest} is out too — that one replaces the running program, so run \`privateer update\` from a shell.`
|
|
94
|
+
: null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function privateerUpdate(pi: any): void {
|
|
98
|
+
// Check in the background on startup and after a reload. Never on a headless surface
|
|
99
|
+
// (harbor, ACP, print, channels): there's no banner to flag it on and nobody to type
|
|
100
|
+
// /update, so the network call would buy nothing.
|
|
101
|
+
pi.on("session_start", (event: any, ctx: any) => {
|
|
102
|
+
if (!ctx?.hasUI) return;
|
|
103
|
+
if (event?.reason !== "startup" && event?.reason !== "reload") return;
|
|
104
|
+
void runCheck(ctx).catch(() => {
|
|
105
|
+
// Offline, a private registry that won't answer, a git remote behind a VPN — an
|
|
106
|
+
// update check is never worth an error in the user's face. The flag just stays down.
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.registerCommand?.("update", {
|
|
111
|
+
description: "Fetch tool pack updates in place: /update [<pack> | check]",
|
|
112
|
+
handler: async (args: string, ctx: any) => {
|
|
113
|
+
const arg = String(args ?? "").trim();
|
|
114
|
+
const notify = (msg: string, kind: "info" | "warning" | "error" = "info") =>
|
|
115
|
+
ctx?.ui?.notify?.(msg, kind);
|
|
116
|
+
|
|
117
|
+
if (arg === "check") {
|
|
118
|
+
try {
|
|
119
|
+
const packs = await runCheck(ctx, { force: true });
|
|
120
|
+
const cli = cliNote();
|
|
121
|
+
notify(
|
|
122
|
+
packs.length
|
|
123
|
+
? `${packs.length} tool pack update${packs.length === 1 ? "" : "s"} ready: ${nameList(packs)} — /update to fetch${cli ? `. ${cli}` : ""}`
|
|
124
|
+
: `Tool packs are current.${cli ? ` ${cli}` : ""}`,
|
|
125
|
+
);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
notify(`Update check failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A named pack is passed straight through to the package manager, which matches it
|
|
133
|
+
// against configured sources and reports a better "no such pack" than we could.
|
|
134
|
+
// With no argument we act on what the check found, re-checking first if it never
|
|
135
|
+
// ran (a terminal that started offline, or a pack installed since launch).
|
|
136
|
+
let targets = pendingPackUpdates();
|
|
137
|
+
if (!arg) {
|
|
138
|
+
try {
|
|
139
|
+
targets = await runCheck(ctx, { force: pendingPackUpdates().length === 0 });
|
|
140
|
+
} catch {
|
|
141
|
+
// fall through with whatever the last check knew — update() re-checks anyway.
|
|
142
|
+
}
|
|
143
|
+
if (targets.length === 0) {
|
|
144
|
+
const cli = cliNote();
|
|
145
|
+
notify(`Tool packs are current.${cli ? ` ${cli}` : ""}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const label = arg || nameList(targets);
|
|
151
|
+
ctx?.ui?.setStatus?.("update", `⚑ updating ${arg || `${targets.length} pack${targets.length === 1 ? "" : "s"}`}`);
|
|
152
|
+
try {
|
|
153
|
+
const pm = packageManagerFor(ctx);
|
|
154
|
+
// Progress goes to the footer, not the transcript: an install emits several
|
|
155
|
+
// events per pack and each one as a notification would bury the chat.
|
|
156
|
+
pm.setProgressCallback((e: any) => {
|
|
157
|
+
if (e?.type === "error") return; // update() throws; the catch below reports it
|
|
158
|
+
ctx?.ui?.setStatus?.("update", `⚑ ${e?.action ?? "update"} ${e?.source ?? ""}`.trimEnd());
|
|
159
|
+
});
|
|
160
|
+
await pm.update(arg || undefined);
|
|
161
|
+
pm.setProgressCallback(undefined);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
ctx?.ui?.setStatus?.("update", undefined);
|
|
164
|
+
notify(`Update failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
ctx?.ui?.setStatus?.("update", undefined);
|
|
168
|
+
// Whatever was pending is on disk now. Clear the flag before the reload so the
|
|
169
|
+
// banner that comes back is already correct, rather than flying a stale one until
|
|
170
|
+
// the post-reload check answers.
|
|
171
|
+
setPendingPackUpdates([]);
|
|
172
|
+
lastCheckedAt = 0;
|
|
173
|
+
|
|
174
|
+
// The reload is refused mid-turn (Pi guards on streaming/compaction), so don't ask
|
|
175
|
+
// for one — say plainly that the new code is on disk and waiting.
|
|
176
|
+
if (ctx?.isIdle?.() === false) {
|
|
177
|
+
notify(`Updated ${label}. Run /reload when this turn finishes to load it.`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
notify(`Updated ${label} — reloading.`);
|
|
182
|
+
// ⚠️ ctx is STALE after this line (Pi invalidates it when the runtime is replaced).
|
|
183
|
+
// Nothing may touch it, so this is the last statement in the handler.
|
|
184
|
+
await ctx.reload();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|