privateer-agent 0.14.4 → 0.14.6

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.
@@ -30,3 +30,13 @@ export function extractPrivateFlag(args: string[]): boolean;
30
30
  export function privateProblem(args: string[]): string | null;
31
31
 
32
32
  export function authProblem(args: string[], cmd?: string): string | null;
33
+
34
+ /** `auth check|print-*` for the privateer provider, answered by the launcher; null for Pi. */
35
+ export function privateerAuthAnswer(
36
+ args: string[],
37
+ signedIn: boolean,
38
+ cmd?: string,
39
+ ): { out: string; err: string; code: number } | null;
40
+
41
+ /** `auth --help` with our command name and the provider agents look for. */
42
+ export function authHelp(cmd?: string): string;
@@ -204,3 +204,74 @@ export function authProblem(args, cmd = "privateer") {
204
204
  `Nothing was sent to a model. To sign in, run \`${cmd}\` and type /login.`,
205
205
  ].join("\n");
206
206
  }
207
+
208
+ // The value of --provider / --model in `args`, in either `--flag v` or `--flag=v` form.
209
+ function flagValue(args, flag) {
210
+ for (let i = 0; i < args.length; i++) {
211
+ if (args[i] === flag) return args[i + 1];
212
+ if (args[i].startsWith(flag + "=")) return args[i].slice(flag.length + 1);
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ /**
218
+ * `auth check|print-api-key|print-bearer-token` for the `privateer` provider, answered
219
+ * here. Pi runs these with no extensions loaded, and `privateer` is registered by ours,
220
+ * so Pi always said `provider_not_found` — and agents probing a signed-in machine with
221
+ * the exact command `auth status` suggests concluded it was signed out. Readiness comes
222
+ * from the account login (the same file `auth status` reads); the per-launch session
223
+ * in auth.json is minted from it. The account token is never printed: it's the login
224
+ * itself, and nothing outside Privateer can use it as a provider key anyway.
225
+ *
226
+ * Returns { out, err, code } to print, or null for Pi to handle.
227
+ */
228
+ export function privateerAuthAnswer(args, signedIn, cmd = "privateer") {
229
+ if (args[0] !== "auth" || !AUTH_SUBCOMMANDS.includes(args[1])) return null;
230
+ if (args.includes("--help") || args.includes("-h")) return null;
231
+ const provider = flagValue(args, "--provider");
232
+ const model = flagValue(args, "--model");
233
+ if (provider !== "privateer" && !(provider === undefined && model?.startsWith("privateer/"))) return null;
234
+
235
+ const run = `${cmd} -p "<task>"`;
236
+ if (args[1] !== "check") {
237
+ return {
238
+ out: "",
239
+ err: [
240
+ `${cmd} auth ${args[1]}: the privateer provider's credential is your account login, and it isn't printed.`,
241
+ signedIn ? `This machine is signed in — run a task with: ${run}` : `Sign in first: run \`${cmd}\` and type /login.`,
242
+ ].join("\n"),
243
+ code: 1,
244
+ };
245
+ }
246
+ if (args.includes("--credentials")) {
247
+ // Pi's contract: --credentials emits the credential or reports it unavailable.
248
+ const result = { status: "not_ready", provider: "privateer", reason: "credential_not_available" };
249
+ return { out: args.includes("--json") ? JSON.stringify(result) : result.status, err: "", code: 1 };
250
+ }
251
+ const result = signedIn
252
+ ? { status: "ready", provider: "privateer", authType: "oauth" }
253
+ : { status: "not_ready", provider: "privateer", reason: "credentials_not_configured" };
254
+ return {
255
+ out: args.includes("--json") ? JSON.stringify(result) : result.status,
256
+ err: signedIn ? "" : `Not signed in: run \`${cmd}\` and type /login.`,
257
+ code: signedIn ? 0 : 1,
258
+ };
259
+ }
260
+
261
+ /** `auth --help` with our command name, the provider agents look for, and how to run a task. */
262
+ export function authHelp(cmd = "privateer") {
263
+ return [
264
+ "Usage:",
265
+ ` ${cmd} auth status`,
266
+ ` ${cmd} auth check [--provider <provider>] [--model <model>] [--json] [--credentials] [--no-refresh]`,
267
+ ` ${cmd} auth print-api-key [--provider <provider>] [--model <model>]`,
268
+ ` ${cmd} auth print-bearer-token [--provider <provider>] [--model <model>] [--min-expiry <duration>]`,
269
+ "",
270
+ "status reports whether this machine is signed in to a Privateer account.",
271
+ "check and print-* need --provider or --model. Your account's models are provider",
272
+ `\`privateer\`: \`${cmd} auth check --provider privateer\`. Its credential is the account`,
273
+ "login and is never printed. Other providers (openai, anthropic, …) use their own keys.",
274
+ "",
275
+ `To run a task non-interactively once signed in: ${cmd} -p "<task>"`,
276
+ ].join("\n");
277
+ }
@@ -29,7 +29,7 @@ import path from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
31
  import { routeUpdate } from "./update-route.mjs";
32
- import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
32
+ import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authHelp, authProblem, extractHeadlessFlags, privateerAuthAnswer, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
33
33
  import { runToCompletion } from "./run-to-completion.mjs";
34
34
  import { modelArgs } from "./model-args.mjs";
35
35
  import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
@@ -669,6 +669,16 @@ else if (PI_SUBCOMMANDS.includes(sub)) {
669
669
  printAuthStatus(cmd);
670
670
  process.exit(0);
671
671
  }
672
+ if (sub === "auth" && (args[1] === undefined || args[1] === "help" || args.includes("--help") || args.includes("-h"))) {
673
+ console.log(authHelp(cmd));
674
+ process.exit(0);
675
+ }
676
+ const own = sub === "auth" ? privateerAuthAnswer(args, accountSignedIn(), cmd) : null;
677
+ if (own) {
678
+ if (own.out) console.log(own.out);
679
+ if (own.err) process.stderr.write(own.err + "\n");
680
+ process.exit(own.code);
681
+ }
672
682
  const CLI = resolveDep(REPO, "@earendil-works/pi-coding-agent", "dist", "cli.js");
673
683
  if (!CLI || !fs.existsSync(CLI)) {
674
684
  console.error(
@@ -1030,13 +1040,21 @@ function warnKeylessLaunch() {
1030
1040
 
1031
1041
  // `privateer auth status` — the question people (and agents) actually ask, answered
1032
1042
  // from local state only: no network, nothing sent anywhere, and no secret printed.
1033
- function printAuthStatus(cmd) {
1034
- let creds = null;
1043
+ function readAccountCredentials() {
1035
1044
  try {
1036
- creds = JSON.parse(fs.readFileSync(path.join(PRIVATEER_HOME, "credentials.json"), "utf8"));
1037
- } catch { /* absent or unreadable → signed out */ }
1045
+ const creds = JSON.parse(fs.readFileSync(path.join(PRIVATEER_HOME, "credentials.json"), "utf8"));
1046
+ return creds && typeof creds.accessToken === "string" ? creds : null;
1047
+ } catch { return null; /* absent or unreadable → signed out */ }
1048
+ }
1049
+
1050
+ function accountSignedIn() {
1051
+ return readAccountCredentials() !== null;
1052
+ }
1053
+
1054
+ function printAuthStatus(cmd) {
1055
+ const creds = readAccountCredentials();
1038
1056
  const lines = [];
1039
- if (creds && typeof creds.accessToken === "string") {
1057
+ if (creds) {
1040
1058
  const who = creds.user?.email || "your account";
1041
1059
  lines.push(`Privateer account: signed in as ${who}${creds.serverBaseUrl ? ` (${creds.serverBaseUrl})` : ""}`);
1042
1060
  lines.push(" This checks the local login only; a session revoked from the app shows up on the next request.");
@@ -1046,7 +1064,8 @@ function printAuthStatus(cmd) {
1046
1064
  }
1047
1065
  const keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "TINFOIL_API_KEY", "PRIVATEER_API_KEY"].filter(haveKey);
1048
1066
  lines.push(`Provider keys in the environment: ${keys.length ? keys.join(", ") : "none"}`);
1049
- lines.push(`To check one provider end to end: ${cmd} auth check --provider <name>`);
1067
+ lines.push(`Your account's models are provider \`privateer\`: ${cmd} auth check --provider privateer`);
1068
+ if (creds) lines.push(`To run a task non-interactively: ${cmd} -p "<task>"`);
1050
1069
  console.log(lines.join("\n"));
1051
1070
  }
1052
1071
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
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",
@@ -73,11 +73,17 @@ export function createEngineEventAdapter() {
73
73
  let turnTotal: UsageTotals = emptyUsage();
74
74
  let lastMessage: any;
75
75
  let terminalSent = false;
76
+ // Between agent_start and agent_settled. Compaction maps to `step-finish` to keep
77
+ // the app's busy pill lit through an auto-compaction INSIDE a run — but a manual
78
+ // `/compact` runs with no run around it, and a `step-finish` there opened a turn
79
+ // in the app that no `finish` would ever close: the pill spun until Stop.
80
+ let inRun = false;
76
81
 
77
82
  function toEngineEvents(ev: PiSessionEvent): EngineEvent[] {
78
83
  switch (ev.type) {
79
84
  case "agent_start":
80
85
  terminalSent = false;
86
+ inRun = true;
81
87
  return [];
82
88
 
83
89
  case "message_update": {
@@ -136,6 +142,7 @@ export function createEngineEventAdapter() {
136
142
  }
137
143
 
138
144
  case "agent_settled": {
145
+ inRun = false;
139
146
  if (terminalSent) return [];
140
147
  terminalSent = true;
141
148
  const msg = lastMessage;
@@ -158,7 +165,7 @@ export function createEngineEventAdapter() {
158
165
  }
159
166
 
160
167
  case "compaction_start":
161
- return [{ type: "step-finish" }];
168
+ return inRun ? [{ type: "step-finish" }] : [];
162
169
  case "compaction_end":
163
170
  return [
164
171
  {
package/src/cli/chat.ts CHANGED
@@ -349,6 +349,7 @@ async function main() {
349
349
  return;
350
350
  }
351
351
  turnActive = true;
352
+ let failure: string | undefined;
352
353
  if (remote && echo) console.log(`\n${DIM}⟿ [app] ${text.slice(0, 80)}${RESET}`);
353
354
  try {
354
355
  // Re-arm the account channel BEFORE the prompt, because nothing downstream can.
@@ -375,10 +376,13 @@ async function main() {
375
376
  }
376
377
  await session.prompt(mentions.text, mentions.images.length ? { images: mentions.images } : undefined);
377
378
  } catch (e) {
378
- console.log(`\n${RED}${(e as Error).message}${RESET}`);
379
+ failure = (e as Error).message || "The turn failed.";
380
+ console.log(`\n${RED}${failure}${RESET}`);
379
381
  } finally {
380
382
  turnActive = false;
381
- if (remote) bridge.settleTurn();
383
+ // The failure goes to the app too: a throw here (no model, no key, auth
384
+ // precheck) used to print on this console only while the app's pill spun on.
385
+ if (remote) bridge.settleTurn(failure);
382
386
  }
383
387
  }
384
388
 
@@ -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.14.4",
3
+ "reviewedFor": "0.14.6",
4
4
  "items": [
5
+ { "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" },
5
6
  { "text": "Save to Cargo with no app attached — it lands on your next app open", "since": "0.14.4" },
6
7
  { "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
7
- { "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" },
8
- { "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" }
8
+ { "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" }
9
9
  ]
10
10
  }
@@ -140,6 +140,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
140
140
  const runTurn = async (text: string): Promise<void> => {
141
141
  if (turnActive || stopped) return;
142
142
  turnActive = true;
143
+ let failure: string | undefined;
143
144
  lastAnswer = ""; // keep the CLOSING answer, not the whole session
144
145
  turnErrored = false;
145
146
  try {
@@ -161,10 +162,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
161
162
  await session.prompt(text + note);
162
163
  } catch (e) {
163
164
  turnErrored = true;
164
- deps.log(`live task ${termId} turn error: ${(e as Error).message}`);
165
+ failure = (e as Error).message || "The turn failed.";
166
+ deps.log(`live task ${termId} turn error: ${failure}`);
165
167
  } finally {
166
168
  turnActive = false;
167
- bridge.settleTurn();
169
+ bridge.settleTurn(failure);
168
170
  }
169
171
  };
170
172
 
@@ -15,7 +15,8 @@ import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts";
15
15
  // Pure env-var accessors (see config/privacyDisabled.ts) — no Pi, no node builtins,
16
16
  // so this stays safe to import from the bridge, which boot-ordered code reaches.
17
17
  import { onPrivacyDisabledChange, privacyDisabled, setPrivacyDisabled } from "../config/privacyDisabled.ts";
18
- import type { EngineEvent } from "../engine/events.ts";
18
+ import { emptyUsage, type EngineEvent } from "../engine/events.ts";
19
+ import { redactText } from "../util/redact.ts";
19
20
  import type { PermissionRequest } from "../permissions/gate.ts";
20
21
  import type { AskOutcome } from "../permissions/modeGate.ts";
21
22
  import type { RelayCallbacks } from "./relayClient.ts";
@@ -196,6 +197,14 @@ export class RemoteBridge {
196
197
  private turnDriven = false;
197
198
  private turnPrompt = "";
198
199
  private turnText = "";
200
+ // Did this turn already put finish/aborted/error on the wire? The app lights its
201
+ // working pill the moment it SENDS a prompt and only ever puts it out on one of
202
+ // those three, but Pi's prompt() has paths that never run the agent at all — an
203
+ // extension command or input hook that handles the text, a throw on the
204
+ // no-model / no-key / auth precheck, "compaction in progress". Each returned (or
205
+ // threw into a catch that only printed locally) with no terminal event, and the
206
+ // driver watched a spinner nothing would ever clear. settleTurn closes the gap.
207
+ private turnTerminalSent = false;
199
208
 
200
209
  // Unsubscribes the privacy watcher below. Held so a bridge that outlives its window
201
210
  // (the desktop builds one per window, in one process) stops writing to a transport
@@ -232,6 +241,7 @@ export class RemoteBridge {
232
241
  this.turnDriven = true;
233
242
  this.turnPrompt = text;
234
243
  this.turnText = "";
244
+ this.turnTerminalSent = false;
235
245
  const attachments = this.pendingAttachments;
236
246
  this.pendingAttachments = [];
237
247
  this.cfg.onPrompt(text, attachments);
@@ -460,20 +470,33 @@ export class RemoteBridge {
460
470
  // ── turn lifecycle + event forwarding ───────────────────────────────────────
461
471
 
462
472
  // Mark the end of a turn so the next (possibly local) turn isn't treated as
463
- // remote. Call after each driven turn completes.
473
+ // remote. Call after each driven turn completes, passing the rejection message
474
+ // if prompt() threw.
475
+ //
476
+ // A DRIVEN turn always leaves here with exactly one terminal event on the wire
477
+ // (see turnTerminalSent): if the session never sent one, this sends it — `error`
478
+ // carrying the throw, or a plain `finish` for a prompt Pi handled without
479
+ // running the agent. Desktop's turnSupervisor emits its own terminal before it
480
+ // settles, so it is seen here and never doubled.
464
481
  //
465
482
  // Also the one moment we can tell whether the turn had an audience. If the app
466
483
  // drove it and is now gone, everything the turn streamed up was dropped by the
467
484
  // relay — so hand the answer to the owner for durable delivery rather than letting
468
485
  // a completed piece of work evaporate because someone closed their phone.
469
- settleTurn(): void {
486
+ settleTurn(error?: string): void {
470
487
  const driven = this.turnDriven;
471
488
  const prompt = this.turnPrompt;
472
489
  const content = this.turnText.trim();
490
+ if (driven && !this.turnTerminalSent) {
491
+ this.forwardEvent(error
492
+ ? { type: "error", error: redactText(error) }
493
+ : { type: "finish", usage: emptyUsage(), finishReason: "stop" });
494
+ }
473
495
  this.remote = false;
474
496
  this.turnDriven = false;
475
497
  this.turnPrompt = "";
476
498
  this.turnText = "";
499
+ this.turnTerminalSent = false;
477
500
  if (!driven || !content) return;
478
501
  // Unknown (a relay that can't report presence) counts as watched: better to skip
479
502
  // delivery than to duplicate something the app already showed in its feed.
@@ -488,6 +511,7 @@ export class RemoteBridge {
488
511
  // Keep the driven turn's reply as it streams, in case settleTurn finds nobody
489
512
  // was there to read it. Text only: the outbox item is the answer, not a
490
513
  // transcript of every tool call.
514
+ if (ev.type === "finish" || ev.type === "aborted" || ev.type === "error") this.turnTerminalSent = true;
491
515
  if (this.turnDriven && ev.type === "text" && this.turnText.length < MAX_TURN_CAPTURE) {
492
516
  this.turnText += String(ev.text ?? "");
493
517
  }