privateer-agent 0.8.0 → 0.9.0

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 CHANGED
@@ -210,7 +210,7 @@ export PRIVATEER_API_KEY=sk-priv-... # Privateer developer API (privateer.p
210
210
  ```
211
211
 
212
212
  Pick a model with **`/model`** (browse each configured provider's live catalog) or pass one
213
- directly as `provider/model` — e.g. `openrouter/anthropic/claude-opus-4.8`,
213
+ directly as `provider/model` — e.g. `openrouter/anthropic/claude-opus-5`,
214
214
  `ollama/qwen3-coder`, `nearai/zai-org/GLM-5.1-FP8`. Any OpenAI-compatible server (LM Studio,
215
215
  vLLM, llama.cpp) works as a custom provider — just give it a base URL.
216
216
 
@@ -388,20 +388,35 @@ live in plaintext in `config.json` on your machine, and every channel action is
388
388
  Switch with **`/mode`**. Even in `bypass`, a danger filter blocks destructive shell commands,
389
389
  and protected files (`.env`, shell rc files…) are guarded — the gate is never fully off.
390
390
 
391
- ### `--no-quarter` — lower the moat entirely
392
-
393
- For an unattended run in a directory and on a task you fully trust, launch with:
394
-
395
- ```bash
396
- privateer --no-quarter
397
- ```
391
+ ### No quarter — lower the moat entirely
398
392
 
399
393
  This is the one exception to "the gate is never fully off." It disables the permission
400
394
  gate for the **whole session** — every action auto-approves with no prompt, including
401
395
  destructive shell commands, out-of-cwd access, and protected files. Subagents spawned
402
- by the session inherit it. There is no `/mode` equivalent; it's a deliberate launch-time
403
- opt-out (env `PRIVATEER_NO_QUARTER=1`) and prints a red warning banner so it's never a
404
- surprise. Use it sparingly.
396
+ after it goes on inherit it (env `PRIVATEER_NO_QUARTER=1`). Only for a directory and a
397
+ task you fully trust.
398
+
399
+ Three ways in, all the same switch:
400
+
401
+ | | |
402
+ |---|---|
403
+ | **shift+tab** | toggle it mid-session — hit it and walk away, and the agent runs the task to the end instead of stopping at the next approval |
404
+ | `/no-quarter [on\|off]` | the typed equivalent |
405
+ | `privateer --no-quarter` | start a session with the moat already down |
406
+
407
+ It's never quietly in effect: the launch flag prints a red warning banner, the toggle
408
+ posts a warning to the transcript, and while it's on the footer carries a permanent red
409
+ `⚑ no quarter — permission gate OFF` indicator. shift+tab again raises the moat.
410
+
411
+ Toggling takes effect from the next gated action — an approval already on screen still
412
+ needs an answer. It's a physical-terminal switch: a phone driving this terminal over
413
+ `/remote-access` can't reach it. The app has its own no-quarter toggle for driven turns,
414
+ which is `bypass` exactly — never less — so dangerous shell and destructive actions
415
+ still surface an Allow there, precisely as they do under `/mode bypass` locally.
416
+
417
+ > shift+tab is Pi's default "cycle thinking level" chord; Privateer takes it for this.
418
+ > Thinking level is still under `/settings`, or bind `app.thinking.cycle` to another key
419
+ > in `~/.privateer/agent/keybindings.json`.
405
420
 
406
421
  ## Extend it
407
422
 
@@ -428,6 +443,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
428
443
  |---|---|
429
444
  | `/model` · `/models` | switch model; `/models` is a searchable picker with TEE/ZDR privacy shields |
430
445
  | `/mode` | switch permission mode |
446
+ | `/no-quarter` | lower the moat for this session and run unattended (**shift+tab**) |
431
447
  | `/verify` | fetch and check the TEE attestation for the current model |
432
448
  | `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
433
449
  | `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
@@ -60,6 +60,7 @@ if (NO_QUARTER) {
60
60
  " ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
61
61
  " Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt.",
62
62
  " Only use this in a directory and with a task you fully trust.",
63
+ " shift+tab (or /no-quarter off) raises the moat again.",
63
64
  "",
64
65
  ].join("\n") + "\n",
65
66
  );
@@ -28,7 +28,12 @@ import { readFileSync, appendFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
30
  import * as priv from "../src/auth/privateer.ts";
31
- import { armAccountCredential, makeAccountProvider } from "../src/providers/account.ts";
31
+ import {
32
+ armAccountCredential,
33
+ dropPersistedAccountCredential,
34
+ makeAccountProvider,
35
+ verificationLink,
36
+ } from "../src/providers/account.ts";
32
37
  import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
33
38
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
34
39
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
@@ -317,12 +322,15 @@ export default function privateerBrand(pi: any): void {
317
322
  // and dead-ends on the first inference. Removing it makes the next /signin spawn a
318
323
  // fresh session. Reached via the model registry (constructed with the auth
319
324
  // storage; see session.ts). Best-effort: nothing persisted → nothing to do.
320
- const dropPersistedAccount = (ctx: any): void => {
321
- try {
322
- ctx?.modelRegistry?.authStorage?.remove?.("privateer");
323
- } catch {
324
- /* no persisted credential / older Pi without this shape — nothing to do */
325
- }
325
+ //
326
+ // Two flavours, because auth.json is machine-global and shared by every Privateer
327
+ // terminal (see providers/account.ts dropPersistedAccountCredential):
328
+ // - `force` for sign-out / expiry, where the whole token family is revoked and the
329
+ // entry is dead for every terminal;
330
+ // - the default ownership-checked drop for THIS process's exit, which must not
331
+ // delete the credential another running terminal is using.
332
+ const dropPersistedAccount = (ctx: any, opts: { force?: boolean } = {}): void => {
333
+ dropPersistedAccountCredential(ctx, opts);
326
334
  };
327
335
 
328
336
  // /update — run the global npm install in a child process and report the outcome via
@@ -370,7 +378,9 @@ export default function privateerBrand(pi: any): void {
370
378
  const p = paletteFor(ctx?.ui?.theme);
371
379
  const user = await priv.runDeviceLogin({
372
380
  onCode: (code: any) => {
373
- const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
381
+ // Absolute url: the server sends it scheme-less, and this line is what the
382
+ // user copies into a browser. See providers/account.ts verificationLink.
383
+ const uri = clean(verificationLink(code.verification_uri_complete ?? code.verification_uri));
374
384
  const userCode = clean(code.user_code);
375
385
  ctx?.ui?.setWidget?.(
376
386
  "privateer-signin",
@@ -412,7 +422,7 @@ export default function privateerBrand(pi: any): void {
412
422
  const u = priv.currentUser();
413
423
  ctx?.ui?.notify?.("Signing out of Privateer…", "info");
414
424
  await priv.logout(); // never throws: local state is wiped whatever the network did
415
- dropPersistedAccount(ctx);
425
+ dropPersistedAccount(ctx, { force: true }); // the whole family is revoked, not just ours
416
426
  refresh(ctx);
417
427
  ctx?.ui?.notify?.(
418
428
  `Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
@@ -594,6 +604,8 @@ export default function privateerBrand(pi: any): void {
594
604
  // server-side, so leaving the persisted copy behind would make the NEXT launch
595
605
  // reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
596
606
  // refresh on a 401). Mirrors the harbor's shutdown (harbor/index.ts).
607
+ // Ownership-checked (no force): revokeLocalSessions killed OUR sessions only, so a
608
+ // persisted entry belonging to another running terminal must survive our exit.
597
609
  dropPersistedAccount(ctxRef);
598
610
  });
599
611
 
@@ -602,8 +614,9 @@ export default function privateerBrand(pi: any): void {
602
614
  priv.onSessionExpired(() => {
603
615
  // clearCredentials() has already wiped the local machine login; also drop Pi's
604
616
  // persisted account credential so the next prompt/launch doesn't reuse a token
605
- // that's now dead server-side (see dropPersistedAccount).
606
- dropPersistedAccount(ctxRef);
617
+ // that's now dead server-side (see dropPersistedAccount). Forced: the machine login
618
+ // is gone, so the entry can't be useful to any terminal on this machine.
619
+ dropPersistedAccount(ctxRef, { force: true });
607
620
  refresh(ctxRef);
608
621
  ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
609
622
  });
@@ -37,9 +37,11 @@ import {
37
37
  import {
38
38
  MCP_CATALOG,
39
39
  draftFromCatalog,
40
+ hostedCapable,
40
41
  promptOrder,
41
42
  type CatalogEntry,
42
43
  } from "../src/mcp/catalog.ts";
44
+ import { isHosted } from "../src/config/hosted.ts";
43
45
 
44
46
  // Minimal views of Pi's theme + TUI handle, mirroring privateer-models.ts — enough to
45
47
  // color text and request a redraw without coupling this file to Pi internals.
@@ -126,6 +128,15 @@ interface Step {
126
128
  // A step that may be submitted empty. For an edit, an empty secret means "keep
127
129
  // the value already on disk" — mcpControl's env merge rule makes that free.
128
130
  optional?: boolean;
131
+ // Ask this step only when the answers so far say it's relevant — the auth questions
132
+ // are meaningless for a local command, and three dead optional prompts in a row is
133
+ // how a wizard teaches people to hammer Enter without reading. Predicates may only
134
+ // read answers from EARLIER steps.
135
+ when?: (answers: Record<string, string>) => boolean;
136
+ // Reject an answer before it becomes an answer, returning the reason to show. For
137
+ // rules mcpControl can't check because it never sees the raw form input, or that it
138
+ // would only catch after the user has typed three more fields for nothing.
139
+ validate?: (value: string) => string | undefined;
129
140
  }
130
141
 
131
142
  type View = "list" | "catalog" | "form";
@@ -147,7 +158,15 @@ interface Pending {
147
158
  // from the answer rather than asked as a separate question: an https:// answer is an
148
159
  // http server, anything else is a stdio command line. That matches mcpControl's own
149
160
  // inference (`draft.url || prev.url ? "http" : "stdio"`), so there's one rule, not two.
161
+ const isHttpTarget = (answers: Record<string, string>) => /^https?:\/\//i.test((answers.target ?? "").trim());
162
+
150
163
  function customSteps(existing?: RemoteMcpServer): Step[] {
164
+ // A hosted agent takes remote OAuth connectors and nothing else (Option B —
165
+ // treeview/docs/HARBOR_CONNECTORS_PLAN.md §2), so the custom form drops to that
166
+ // shape: no local command, and no stored token. Both are refused at the question
167
+ // rather than at first call, because a connector that saves cleanly and then never
168
+ // works is the worse of the two failures.
169
+ const hosted = isHosted();
151
170
  const target = existing
152
171
  ? existing.transport === "http"
153
172
  ? existing.url
@@ -162,15 +181,48 @@ function customSteps(existing?: RemoteMcpServer): Step[] {
162
181
  },
163
182
  {
164
183
  key: "target",
165
- prompt: "Launch command, or an https:// URL",
166
- hint: "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
184
+ prompt: hosted ? "Server URL" : "Launch command, or an https:// URL",
185
+ hint: hosted
186
+ ? "e.g. https://mcp.example.com/sse — a hosted agent can't run a local command."
187
+ : "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
167
188
  initial: target,
189
+ validate: hosted
190
+ ? (v) =>
191
+ /^https?:\/\//i.test(v.trim())
192
+ ? undefined
193
+ : "A hosted agent can only reach remote connectors — this needs an https:// URL."
194
+ : undefined,
168
195
  },
169
196
  {
170
197
  key: "env",
171
198
  prompt: "Environment variables (optional)",
172
199
  hint: "KEY=value, comma-separated. Leave blank for none.",
173
200
  optional: true,
201
+ // Nothing to set them ON when there is no local process to spawn, and their
202
+ // values are credentials at rest — both reasons a hosted agent skips this.
203
+ when: () => !hosted,
204
+ },
205
+ {
206
+ key: "bearerToken",
207
+ prompt: "Bearer token (optional)",
208
+ hint: existing?.bearerTokenSet
209
+ ? "Leave blank to keep the token already saved."
210
+ : "Leave blank to sign in through the browser instead (OAuth).",
211
+ secret: true,
212
+ optional: true,
213
+ // Never asked on a hosted agent: the home is tmpfs and a stored token would have
214
+ // to rest somewhere we can read. OAuth is the only credential it may hold.
215
+ when: (a) => !hosted && isHttpTarget(a),
216
+ },
217
+ {
218
+ key: "headers",
219
+ prompt: "Extra HTTP headers (optional)",
220
+ hint: "KEY=value, comma-separated — e.g. X-Api-Version=2. Leave blank for none.",
221
+ // Header values are credentials as often as not, so this is masked like a token
222
+ // — and for the same reason a hosted agent is never offered it.
223
+ secret: true,
224
+ optional: true,
225
+ when: (a) => !hosted && isHttpTarget(a),
174
226
  },
175
227
  ];
176
228
  }
@@ -196,7 +248,22 @@ function buildCustomDraft(answers: Record<string, string>): McpDraft {
196
248
  if (/^https?:\/\//i.test(target)) {
197
249
  draft.transport = "http";
198
250
  draft.url = target;
199
- draft.oauth = true;
251
+ const token = (answers.bearerToken ?? "").trim();
252
+ const headers = parseEnv(answers.headers ?? "");
253
+ const hasHeaders = Object.keys(headers).length > 0;
254
+ if (hasHeaders) draft.headers = headers;
255
+ if (token) {
256
+ // A static token: the adapter only sends the Authorization header when auth is
257
+ // explicitly "bearer", so this pairing is not optional.
258
+ draft.auth = "bearer";
259
+ draft.bearerToken = token;
260
+ } else if (hasHeaders) {
261
+ // Custom headers ARE the credential here — the adapter's supportsOAuth() refuses
262
+ // OAuth once headers are configured, so claiming "oauth" would be a lie.
263
+ draft.auth = "none";
264
+ } else {
265
+ draft.auth = "oauth";
266
+ }
200
267
  } else {
201
268
  draft.transport = "stdio";
202
269
  const parts = target.split(/\s+/).filter(Boolean);
@@ -265,8 +332,16 @@ const NEEDS_LABEL: Record<string, string> = {
265
332
  none: "no setup",
266
333
  };
267
334
 
335
+ // On a hosted (Harbor) agent the picker offers only what the runtime can actually
336
+ // hold — see hostedCapable(). Offering the other 16 would be offering a connector that
337
+ // installs nothing, keeps nothing, and fails at first call: the tenant is `--read-only`
338
+ // with no uv/uvx/Python and a tmpfs home wiped on every suspend. Filtering here rather
339
+ // than letting them fail later is the difference between "not available on a hosted
340
+ // agent" and "I set it up and it just doesn't work."
268
341
  function catalogRows(): CatalogRow[] {
269
- const rows: CatalogRow[] = MCP_CATALOG.map((e) => ({
342
+ const hosted = isHosted();
343
+ const entries = hosted ? MCP_CATALOG.filter(hostedCapable) : MCP_CATALOG;
344
+ const rows: CatalogRow[] = entries.map((e) => ({
270
345
  entry: e,
271
346
  label: e.label,
272
347
  blurb: e.blurb,
@@ -274,7 +349,9 @@ function catalogRows(): CatalogRow[] {
274
349
  }));
275
350
  rows.push({
276
351
  label: "Custom…",
277
- blurb: "Any stdio command or http URL.",
352
+ // Custom survives the filter because a custom REMOTE OAuth connector is fine here;
353
+ // it's stdio and stored tokens that aren't. customSteps enforces that.
354
+ blurb: hosted ? "Any https:// URL with browser sign-in." : "Any stdio command or http URL.",
278
355
  needsLabel: "",
279
356
  });
280
357
  return rows;
@@ -359,6 +436,36 @@ class ConnectPanel extends Container {
359
436
  return this.pending?.steps[this.stepIndex];
360
437
  }
361
438
 
439
+ // A step is asked only when its `when` predicate (if any) passes against the answers
440
+ // collected so far. Navigation walks over the hidden ones in both directions, and
441
+ // the position counter reports the VISIBLE steps so "2/3" doesn't count a question
442
+ // the user will never see.
443
+ private stepVisible(i: number): boolean {
444
+ const step = this.pending?.steps[i];
445
+ if (!step) return false;
446
+ return step.when ? step.when(this.answers) : true;
447
+ }
448
+
449
+ private seekStep(from: number, dir: 1 | -1): number | undefined {
450
+ const total = this.pending?.steps.length ?? 0;
451
+ for (let i = from; i >= 0 && i < total; i += dir) {
452
+ if (this.stepVisible(i)) return i;
453
+ }
454
+ return undefined;
455
+ }
456
+
457
+ private visiblePosition(): { at: number; of: number } {
458
+ const total = this.pending?.steps.length ?? 0;
459
+ let at = 0;
460
+ let of = 0;
461
+ for (let i = 0; i < total; i++) {
462
+ if (!this.stepVisible(i)) continue;
463
+ of++;
464
+ if (i <= this.stepIndex) at = of;
465
+ }
466
+ return { at, of };
467
+ }
468
+
362
469
  // -- rendering ------------------------------------------------------------
363
470
 
364
471
  private refresh(): void {
@@ -429,6 +536,13 @@ class ConnectPanel extends Container {
429
536
  private renderCatalog(): void {
430
537
  const t = this.theme;
431
538
  this.body.addChild(new Text(t.fg("accent", t.bold("Add a connector")), 1, 0));
539
+ // Say why the list is short before the user goes looking for GitHub. A filter with
540
+ // no explanation reads as a missing connector, which is a support question.
541
+ if (isHosted()) {
542
+ this.body.addChild(
543
+ new Text(t.fg("muted", " Hosted agent — remote connectors with browser sign-in only."), 1, 0),
544
+ );
545
+ }
432
546
  this.body.addChild(new Spacer(1));
433
547
  this.body.addChild(this.search);
434
548
  this.body.addChild(new Spacer(1));
@@ -458,7 +572,8 @@ class ConnectPanel extends Container {
458
572
  const t = this.theme;
459
573
  const p = this.pending!;
460
574
  const step = this.currentStep()!;
461
- const counter = p.steps.length > 1 ? ` ${this.stepIndex + 1}/${p.steps.length}` : "";
575
+ const pos = this.visiblePosition();
576
+ const counter = pos.of > 1 ? ` ${pos.at}/${pos.of}` : "";
462
577
  this.body.addChild(new Text(t.fg("accent", t.bold(p.title)) + t.fg("muted", counter), 1, 0));
463
578
  this.body.addChild(new Spacer(1));
464
579
  this.body.addChild(new Text(` ${t.fg("text", step.prompt)}`, 1, 0));
@@ -555,10 +670,19 @@ class ConnectPanel extends Container {
555
670
  this.refresh();
556
671
  return;
557
672
  }
673
+ // Validate only what was actually typed: a blank optional answer means "skip", and
674
+ // a rule about the shape of a value has nothing to say about its absence.
675
+ const bad = value && step.validate ? step.validate(value) : undefined;
676
+ if (bad) {
677
+ this.status = bad;
678
+ this.refresh();
679
+ return;
680
+ }
558
681
  this.answers[step.key] = value;
559
682
  this.status = "";
560
- if (this.stepIndex < this.pending!.steps.length - 1) {
561
- this.stepIndex++;
683
+ const next = this.seekStep(this.stepIndex + 1, 1);
684
+ if (next !== undefined) {
685
+ this.stepIndex = next;
562
686
  this.loadStep();
563
687
  return;
564
688
  }
@@ -701,8 +825,9 @@ class ConnectPanel extends Container {
701
825
  const step = this.currentStep()!;
702
826
  if (kb.matches(data, "tui.select.cancel")) {
703
827
  // Back a step, or out of the form entirely from the first one.
704
- if (this.stepIndex > 0) {
705
- this.stepIndex--;
828
+ const prev = this.stepIndex > 0 ? this.seekStep(this.stepIndex - 1, -1) : undefined;
829
+ if (prev !== undefined) {
830
+ this.stepIndex = prev;
706
831
  this.loadStep();
707
832
  } else {
708
833
  this.view = this.pending?.origin ?? "list";
@@ -28,8 +28,10 @@ import { makeSkillsControl } from "../src/remote/skillsControl.ts";
28
28
  import { agentDir } from "../src/config/paths.ts";
29
29
  import { agentVersion } from "../src/config/version.ts";
30
30
  import { SettingsManager } from "@earendil-works/pi-coding-agent";
31
+ import { matchesKey } from "@earendil-works/pi-tui";
31
32
  import * as priv from "../src/auth/privateer.ts";
32
33
  import { paletteFor } from "../src/ui/palette.ts";
34
+ import { noQuarterActive, setNoQuarter } from "../src/permissions/noQuarter.ts";
33
35
  import type { PermissionMode } from "../src/config/permissionMode.ts";
34
36
 
35
37
  const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
@@ -169,6 +171,17 @@ async function pickModelRemote(filter: string): Promise<void> {
169
171
  // the REPL's runCommand fall-through.
170
172
  async function runRemoteCommand(text: string): Promise<void> {
171
173
  const line = text.trim();
174
+ // No quarter is a PHYSICAL-terminal action, like /remote-access. It's stronger than
175
+ // any mode — it also switches off the dangerous-command denylist and the protected-
176
+ // file guard — so a remote controller must not be able to reach it. (The app's own
177
+ // no_quarter toggle covers driven turns: ModeGate re-decides those through
178
+ // decideAuto(req, "bypass", …), so it IS /mode bypass — never weaker. Dangerous and
179
+ // destructive actions sit above bypass, so they come back to the phone for an
180
+ // explicit Allow there, exactly as bypass surfaces them locally.)
181
+ if (line === "/no-quarter" || line.startsWith("/no-quarter ")) {
182
+ relay?.sendNotice("/no-quarter is terminal-only — run it at the machine, or use the app's own no-quarter toggle.");
183
+ return;
184
+ }
172
185
  if (line.startsWith("/model ")) { await switchModelRemote(line.slice(7)); return; }
173
186
  if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
174
187
  const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
@@ -213,7 +226,9 @@ function advertiseCommands(): { name: string; description?: string }[] {
213
226
  })
214
227
  .filter(Boolean);
215
228
  } catch { /* no commands registered yet */ }
216
- const seen = new Set(builtins.map((c) => c.name));
229
+ // Terminal-only commands are withheld rather than advertised-then-refused, so the
230
+ // app's composer never offers something it can't run. See runRemoteCommand.
231
+ const seen = new Set([...builtins.map((c) => c.name), "/no-quarter"]);
217
232
  return [...builtins, ...ext.filter((c: any) => !seen.has(c.name))];
218
233
  }
219
234
 
@@ -250,6 +265,61 @@ function setRemoteState(s: typeof remoteState): void {
250
265
  refreshRemoteStatus();
251
266
  }
252
267
 
268
+ // ── no quarter (shift+tab) ────────────────────────────────────────────────────
269
+ // The "step away from the keyboard" switch. On, the gate is fully lowered for the
270
+ // rest of the session: every action auto-approves with no prompt, so a long task
271
+ // runs to completion instead of stalling on the next approval. Off by default and
272
+ // reversible with the same key; the state itself lives in src/permissions/noQuarter.ts
273
+ // (shared with the launch flag and inherited by subagent children).
274
+ //
275
+ // The footer carries a permanent RED indicator while it's on — this is the one
276
+ // setting that turns the whole moat off, so it must never be quietly in effect.
277
+ const NO_QUARTER_STATUS_KEY = "privateer:no-quarter";
278
+
279
+ function refreshNoQuarterStatus(): void {
280
+ const ui = uiRef;
281
+ if (!ui?.setStatus) return;
282
+ if (!noQuarterActive()) {
283
+ ui.setStatus(NO_QUARTER_STATUS_KEY, undefined);
284
+ return;
285
+ }
286
+ const p = paletteFor(ui.theme);
287
+ ui.setStatus(
288
+ NO_QUARTER_STATUS_KEY,
289
+ `${p.RED}${p.BOLD}⚑ no quarter — permission gate OFF${p.RESET} ${p.DIM}· shift+tab to raise the moat${p.RESET}`,
290
+ );
291
+ }
292
+
293
+ // Flip the state and tell the user, loudly on the way down. Takes effect from the
294
+ // next gated action — an approval already on screen still needs an answer.
295
+ function applyNoQuarter(on: boolean, ui: any): void {
296
+ setNoQuarter(on);
297
+ refreshNoQuarterStatus();
298
+ ui?.notify?.(
299
+ on
300
+ ? "⚑ No quarter — the permission gate is OFF for this session. Every action (shell, edits, destructive tools, out-of-cwd, protected files) now runs without asking. shift+tab to raise the moat again."
301
+ : "⚓ Moat raised — the permission gate is back on.",
302
+ on ? "warning" : "info",
303
+ );
304
+ }
305
+
306
+ // shift+tab, intercepted at the raw-input layer. Pi reserves that chord for
307
+ // `app.thinking.cycle`, so pi.registerShortcut("shift+tab") would be dropped as a
308
+ // conflict — a TUI input listener runs ahead of every component instead, and
309
+ // consuming the key stops it reaching the thinking-level cycler. (Thinking level
310
+ // stays reachable from /settings, or by binding app.thinking.cycle to another key.)
311
+ let unsubscribeKeys: (() => void) | undefined;
312
+
313
+ function bindNoQuarterKey(ui: any): void {
314
+ if (typeof ui?.onTerminalInput !== "function") return; // older Pi / non-TUI mode
315
+ unsubscribeKeys?.(); // a session replacement clears listeners — rebind, never double-bind
316
+ unsubscribeKeys = ui.onTerminalInput((data: string) => {
317
+ if (!matchesKey(data, "shift+tab")) return undefined;
318
+ applyNoQuarter(!noQuarterActive(), ui);
319
+ return { consume: true };
320
+ });
321
+ }
322
+
253
323
  // Tear down the relay and clear the indicator. Used by `/remote-access off` AND by
254
324
  // the app's own "End remote access" action (onTerminate), so both paths converge.
255
325
  function disableRemote(): void {
@@ -353,10 +423,11 @@ const gate = makePermissionGate({
353
423
  localAsk,
354
424
  getRemote: bridge.getRemote,
355
425
  getNoQuarter: bridge.getNoQuarter,
356
- // `--no-quarter` at launch (see bin/privateer-launch.mjs) sets PRIVATEER_NO_QUARTER
357
- // and opts this whole session TUI and any subagent children that inherit the env —
358
- // out of the gate entirely: every action auto-approves, no prompt.
359
- getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
426
+ // Session-wide TOTAL bypass: every action auto-approves, no prompt. Set either by
427
+ // `--no-quarter` at launch (see bin/privateer-launch.mjs, which exports
428
+ // PRIVATEER_NO_QUARTER) or by shift+tab mid-session both land in the same state,
429
+ // which subagent children inherit through the env. See src/permissions/noQuarter.ts.
430
+ getSkipAllPermissions: noQuarterActive,
360
431
  remoteAsk: bridge.remoteAsk,
361
432
  });
362
433
 
@@ -401,6 +472,13 @@ export default function privateerControl(pi: any): void {
401
472
  if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
402
473
  if (!currentSpec && ctx?.model) currentSpec = modelSpec(ctx.model);
403
474
  refreshRemoteStatus();
475
+ // shift+tab → no quarter, and the red footer indicator when it's already on (a
476
+ // `--no-quarter` launch, or a session replacement mid-run). Interactive TUI only:
477
+ // headless modes and subagent children have no terminal to listen to.
478
+ if (ctx?.mode === "tui" && ctx?.ui) {
479
+ bindNoQuarterKey(ctx.ui);
480
+ refreshNoQuarterStatus();
481
+ }
404
482
  if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
405
483
  mode = "bypass";
406
484
  }
@@ -443,6 +521,20 @@ export default function privateerControl(pi: any): void {
443
521
  },
444
522
  });
445
523
 
524
+ // The typed equivalent of shift+tab, for anyone who'd rather not trust a chord with
525
+ // the whole moat. Deliberately NOT reachable from the app — see runRemoteCommand.
526
+ pi.registerCommand?.("no-quarter", {
527
+ description: "Lower the moat for this session — run unattended with no approval prompts: /no-quarter [on|off]",
528
+ handler: (args: string, ctx: any) => {
529
+ if (ctx?.ui) uiRef = ctx.ui;
530
+ const arg = String(args ?? "").trim().toLowerCase();
531
+ if (arg && arg !== "on" && arg !== "off") {
532
+ return ctx.ui?.notify?.(`usage: /no-quarter [on|off] (currently ${noQuarterActive() ? "on" : "off"})`, "warning");
533
+ }
534
+ applyNoQuarter(arg ? arg === "on" : !noQuarterActive(), ctx.ui);
535
+ },
536
+ });
537
+
446
538
  // Local extension management. Mirrors what the app's extensions screen does over
447
539
  // the relay, but here we CAN hot-activate: ctx.reload() rebuilds the live runner,
448
540
  // so a just-added/removed extension takes effect without relaunching (a luxury the
@@ -46,6 +46,27 @@ const privacy = makePiPrivacyExtension({
46
46
  if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
47
47
  return (await accountPosture(modelId)).tier;
48
48
  },
49
+ // pi-privacy 0.8 added an INGEST gate: credentials arriving in a tool result are
50
+ // redacted before they enter context (they'd otherwise be re-sent every turn and
51
+ // written to the session file on disk). We already redact tool output in
52
+ // src/ext/permissionGate.ts, so its default "warn" would put an interactive prompt
53
+ // in front of something this app has always handled silently — "redact" keeps our
54
+ // UX and still takes the added coverage.
55
+ //
56
+ // The two redactors are COMPLEMENTARY, not duplicative, which is why we run both:
57
+ // ours masks the configured provider keys by exact value (from env/config) plus the
58
+ // provider-specific shapes (sk-/AIza/xai-/gsk_/csk-/vapi_/fw_/Z.ai, auth headers);
59
+ // pi-privacy's catches what shows up in USER code and shell output — AWS AKIA/ASIA,
60
+ // GitHub gh[pousr]_, JWTs, PEM private-key blocks, Slack, Stripe — none of which
61
+ // our patterns match.
62
+ //
63
+ // Order between the two is NOT guaranteed: pi discovers extensions with a bare
64
+ // readdirSync and never sorts, so it's filesystem-dependent (alphabetical on this
65
+ // box today, not by contract). "redact" makes that moot — both handlers run
66
+ // unconditionally and each masks its own patterns, so the surviving content is the
67
+ // same either way. Under "warn" the order WOULD matter, since it decides whether
68
+ // the prompt is raised on a raw key or one we already masked.
69
+ toolResultPolicy: "redact",
49
70
  });
50
71
 
51
72
  export default function privateerPrivacy(pi: any): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,7 +70,7 @@
70
70
  "@phala/dcap-qvl": "^0.5.2",
71
71
  "patch-package": "^8.0.1",
72
72
  "pi-mcp-adapter": "^2.11.0",
73
- "pi-privacy": "^0.7.0",
73
+ "pi-privacy": "^0.9.0",
74
74
  "pi-subagents": "^0.34.0",
75
75
  "picomatch": "^4.0.4",
76
76
  "privateer-workflow": "^0.1.0",
@@ -1,5 +1,5 @@
1
1
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
2
- index e223ce1..3615d74 100644
2
+ index e223ce1..cc853e7 100644
3
3
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
4
4
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
5
5
  @@ -163,6 +163,14 @@ export class AgentSession {
@@ -33,6 +33,25 @@ index e223ce1..3615d74 100644
33
33
  }
34
34
  if (await this._checkCompaction(msg)) {
35
35
  return true;
36
+ @@ -1996,6 +2013,18 @@ export class AgentSession {
37
+ // Context overflow is handled by compaction, not retry.
38
+ if (isContextOverflow(message, this.model?.contextWindow ?? 0))
39
+ return false;
40
+ + // Privateer patch: a Privateer ACCOUNT CAP is not a throttle. Daily/monthly
41
+ + // message or token limits (and an exhausted balance) come back from the account
42
+ + // channel as a 429 carrying the backend's machine `code` and a ready-to-show
43
+ + // message. pi classifies anything containing "429" as transient, so the agent
44
+ + // spent its whole retry budget — with exponential backoff — on a limit that
45
+ + // cannot clear, and only then showed the user the one message that says what to
46
+ + // do (upgrade, top up, or /login keys for a BYO key). Scoped to the `privateer`
47
+ + // provider and to the backend's own cap wording so no other provider's transient
48
+ + // rate limit is affected. Mirrors isAccountCapCode in src/engine/errors.ts.
49
+ + if (this.model?.provider === "privateer" &&
50
+ + /"code"\s*:\s*"[A-Z0-9_]*(?:CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP)[A-Z0-9_]*"|limit of [^.]*reached|upgrade or top ?up|usage limit reached/i.test(message.errorMessage ?? ""))
51
+ + return false;
52
+ return isRetryableAssistantError(message);
53
+ }
54
+ /**
36
55
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
37
56
  index 197bccc..27f6429 100644
38
57
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js