privateer-agent 0.8.0 → 0.8.2

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,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.8.2",
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.8.0",
74
74
  "pi-subagents": "^0.34.0",
75
75
  "picomatch": "^4.0.4",
76
76
  "privateer-workflow": "^0.1.0",
package/src/cli/chat.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import "../boot.ts"; // env + attestation dispatcher, before any Pi import
12
12
  import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
13
13
  import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
14
+ import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
14
15
  import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
15
16
 
16
17
  // This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
@@ -307,8 +308,9 @@ async function main() {
307
308
  },
308
309
  getRemote: bridge.getRemote,
309
310
  getNoQuarter: bridge.getNoQuarter,
310
- // `--no-quarter` at launch (env PRIVATEER_NO_QUARTER) → total gate bypass, no prompts.
311
- getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
311
+ // Total gate bypass, no prompts — from `--no-quarter` at launch or shift+tab /
312
+ // `/no-quarter` mid-session. One shared state (../permissions/noQuarter.ts).
313
+ getSkipAllPermissions: noQuarterActive,
312
314
  remoteAsk: bridge.remoteAsk,
313
315
  // Subagents (and their child-only intercom tools) can't be driven from the app
314
316
  // yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
@@ -322,7 +324,37 @@ async function main() {
322
324
  },
323
325
  };
324
326
 
327
+ // ── no quarter (shift+tab) ──────────────────────────────────────────────────
328
+ // The "step away from the keyboard" switch: lower the moat for the rest of the
329
+ // session so a long task runs to completion instead of stalling on the next
330
+ // approval prompt. Same state the `--no-quarter` launch flag sets, and the same
331
+ // chord the full TUI uses (extensions/privateer-gate.ts). Reversible with the
332
+ // same key; takes effect from the next gated action, so an approval already on
333
+ // screen still needs an answer.
334
+ function applyNoQuarter(on: boolean): void {
335
+ setNoQuarter(on);
336
+ flushOut(); // land streamed output above the notice
337
+ console.log(
338
+ on
339
+ ? `\n${RED}⚑ No quarter — the permission gate is OFF for this session.${RESET}\n` +
340
+ `${DIM} Every action (shell, edits, destructive tools, out-of-cwd, protected files) runs without asking.\n` +
341
+ ` shift+tab (or /no-quarter off) raises the moat again.${RESET}`
342
+ : `\n${GREEN}⚓ Moat raised — the permission gate is back on.${RESET}`,
343
+ );
344
+ }
345
+
346
+ // readline already emits keypress events for a TTY input, so we just listen. Node
347
+ // decodes shift+tab (CSI Z) as {name: "tab", shift: true}. We don't swallow it —
348
+ // readline still runs its own tab handler, which for a non-`@mention` line
349
+ // completes to nothing.
350
+ if (process.stdin.isTTY) {
351
+ process.stdin.on("keypress", (_s: string, key: any) => {
352
+ if (key?.name === "tab" && key.shift && !key.ctrl && !key.meta) applyNoQuarter(!noQuarterActive());
353
+ });
354
+ }
355
+
325
356
  console.log(`${DIM}privateer-agent — lean REPL. Loading ${provider}/${modelId}…${RESET}`);
357
+ if (noQuarterActive()) applyNoQuarter(true); // launched with --no-quarter: say so up front
326
358
 
327
359
  const services = await createAgentSessionServices({
328
360
  cwd,
@@ -533,8 +565,9 @@ async function main() {
533
565
  : `${DIM}Not signed in. /login to enable remote access & the account provider.${RESET}`,
534
566
  );
535
567
 
536
- const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /quit";
568
+ const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /no-quarter [on|off] /quit";
537
569
  console.log(`${DIM}Ready. Type a prompt — reference a file with @path (Tab completes). ${HELP}${RESET}`);
570
+ console.log(`${DIM}shift+tab toggles no quarter — unattended mode, no approval prompts.${RESET}`);
538
571
  await showPosture();
539
572
 
540
573
  // The available model catalog as sorted "provider/id" specs. Same source the
@@ -620,6 +653,24 @@ async function main() {
620
653
  console.log(items.length ? items.map((s) => ` ${s.name}${s.disabled ? ` ${DIM}(disabled)${RESET}` : ""}${s.editable ? "" : ` ${DIM}(read-only)${RESET}`} — ${s.description}`).join("\n") : `${DIM}No skills yet. Create them from the Privateer app.${RESET}`);
621
654
  return true;
622
655
  }
656
+ // The typed equivalent of shift+tab. A PHYSICAL-terminal action, like
657
+ // /remote-access: it's stronger than any mode (it also switches off the
658
+ // dangerous-command denylist and the protected-file guard), so a remote
659
+ // controller must not be able to reach it. The app's own no-quarter toggle
660
+ // covers driven turns and is `bypass` exactly — never weaker than /mode bypass.
661
+ if (line === "/no-quarter" || line.startsWith("/no-quarter ")) {
662
+ if (remote) {
663
+ relay?.sendNotice("/no-quarter is terminal-only — run it at the machine, or use the app's own no-quarter toggle.");
664
+ return true;
665
+ }
666
+ const arg = line.slice(11).trim().toLowerCase();
667
+ if (arg && arg !== "on" && arg !== "off") {
668
+ console.log(`${YELLOW}usage: /no-quarter [on|off] (currently ${noQuarterActive() ? "on" : "off"})${RESET}`);
669
+ return true;
670
+ }
671
+ applyNoQuarter(arg ? arg === "on" : !noQuarterActive());
672
+ return true;
673
+ }
623
674
  if (line.startsWith("/mode ")) { mode = line.slice(6).trim() as typeof mode; const m = `mode → ${mode}`; console.log(`${DIM}${m}${RESET}`); if (remote) relay?.sendNotice(m); return true; }
624
675
  // Bare /mode → the picker (remote) or a hint (local).
625
676
  if (line === "/mode") {
@@ -648,6 +699,7 @@ async function main() {
648
699
  const builtins = [
649
700
  { name: "/model", description: "Switch the model" },
650
701
  { name: "/mode", description: "Change the approval mode (default/acceptEdits/plan/bypass)" },
702
+ // /no-quarter is deliberately absent — terminal-only, see runCommand.
651
703
  { name: "/models", description: "List available models" },
652
704
  { name: "/extensions", description: "Manage installed Pi extensions" },
653
705
  { name: "/skills", description: "Manage the terminal's skills" },
@@ -862,8 +862,12 @@ export class Harbor {
862
862
  this.relay?.sendTaskSpawned(handle.termId, handle.label);
863
863
  log(`live task spawned: ${handle.termId} (${handle.label})`);
864
864
  } catch (e) {
865
- const msg = `Couldn't spawn a live session: ${(e as Error).message}`;
865
+ const reason = (e as Error).message;
866
+ const msg = `Couldn't spawn a live session: ${reason}`;
866
867
  log(msg);
868
+ // Typed failure reply so the app's spawn screen stops waiting and shows why. Also send
869
+ // a notice as a back-compat fallback for older app builds that don't handle the frame.
870
+ this.relay?.sendTaskSpawnError(reason);
867
871
  this.relay?.sendNotice(msg);
868
872
  }
869
873
  })();
@@ -226,6 +226,62 @@ export const MCP_CATALOG: CatalogEntry[] = [
226
226
  fill: "FIGMA_API_KEY",
227
227
  credUrl: "https://www.figma.com/developers/api#access-tokens",
228
228
  },
229
+ // ── Google Workspace (needs a Google Cloud OAuth client, then browser sign-in) ─
230
+ // One server (workspace-mcp) scoped per service via --tools. Runs on uv (uvx),
231
+ // not npx. Create an OAuth client in Google Cloud once and paste its ID + secret;
232
+ // the first request opens Google's consent screen on THIS machine.
233
+ {
234
+ id: "gmail",
235
+ name: "gmail",
236
+ label: "Gmail",
237
+ blurb: "Read, search, and send email. Google sign-in.",
238
+ transport: "stdio",
239
+ command: "uvx",
240
+ args: ["workspace-mcp", "--tools", "gmail"],
241
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
242
+ needs: "token",
243
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
244
+ credUrl: "https://console.cloud.google.com/apis/credentials",
245
+ },
246
+ {
247
+ id: "google-calendar",
248
+ name: "google-calendar",
249
+ label: "Google Calendar",
250
+ blurb: "Events and scheduling. Google sign-in.",
251
+ transport: "stdio",
252
+ command: "uvx",
253
+ args: ["workspace-mcp", "--tools", "calendar"],
254
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
255
+ needs: "token",
256
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
257
+ credUrl: "https://console.cloud.google.com/apis/credentials",
258
+ },
259
+ {
260
+ id: "google-drive",
261
+ name: "google-drive",
262
+ label: "Google Drive",
263
+ blurb: "Files and folders in your Drive. Google sign-in.",
264
+ transport: "stdio",
265
+ command: "uvx",
266
+ args: ["workspace-mcp", "--tools", "drive"],
267
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
268
+ needs: "token",
269
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
270
+ credUrl: "https://console.cloud.google.com/apis/credentials",
271
+ },
272
+ {
273
+ id: "google-docs",
274
+ name: "google-docs",
275
+ label: "Google Docs",
276
+ blurb: "Read and edit your documents. Google sign-in.",
277
+ transport: "stdio",
278
+ command: "uvx",
279
+ args: ["workspace-mcp", "--tools", "docs"],
280
+ env: { GOOGLE_OAUTH_CLIENT_ID: "", GOOGLE_OAUTH_CLIENT_SECRET: "" },
281
+ needs: "token",
282
+ fill: "GOOGLE_OAUTH_CLIENT_ID",
283
+ credUrl: "https://console.cloud.google.com/apis/credentials",
284
+ },
229
285
  {
230
286
  id: "sequential-thinking",
231
287
  name: "sequential-thinking",
@@ -0,0 +1,42 @@
1
+ // No-quarter: the moat fully lowered for a session — every action auto-approves
2
+ // with no prompt (dangerous shell, destructive tools, out-of-cwd, protected files).
3
+ // This is the single source of truth for that state; the gate reads it through
4
+ // ModeGate.getSkipAllPermissions, which sits above every other policy check.
5
+ //
6
+ // Two ways in, one state:
7
+ // 1. `privateer --no-quarter` at launch → PRIVATEER_NO_QUARTER=1 (see
8
+ // bin/privateer-launch.mjs), which seeds `active` below.
9
+ // 2. shift+tab in a live session → toggleNoQuarter(). This is the "step away from
10
+ // the keyboard" switch: flip it on and the agent runs to completion instead of
11
+ // stopping on the next approval prompt.
12
+ //
13
+ // Toggling MIRRORS the env var, because that's how the state reaches subagents: a
14
+ // pi-subagents child is a `pi` subprocess that inherits this process's env and reads
15
+ // PRIVATEER_NO_QUARTER in its own gate. Children spawned after a toggle therefore
16
+ // match the parent; ones already running keep the posture they started with.
17
+ //
18
+ // IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere,
19
+ // including boot-ordered entrypoints (see boot.ts's ORDERING CONTRACT).
20
+
21
+ const ENV = "PRIVATEER_NO_QUARTER";
22
+
23
+ // Seeded from the launch flag so `--no-quarter` and the toggle share one state.
24
+ let active = process.env[ENV] === "1";
25
+
26
+ /** True while the gate is fully lowered for this session. */
27
+ export function noQuarterActive(): boolean {
28
+ return active;
29
+ }
30
+
31
+ /** Set the state (and mirror it to the env for future subagent children). Returns the new state. */
32
+ export function setNoQuarter(on: boolean): boolean {
33
+ active = on;
34
+ if (on) process.env[ENV] = "1";
35
+ else delete process.env[ENV];
36
+ return active;
37
+ }
38
+
39
+ /** Flip the state. Returns the new state. */
40
+ export function toggleNoQuarter(): boolean {
41
+ return setNoQuarter(!active);
42
+ }
@@ -40,8 +40,9 @@ import {
40
40
  const DEFAULT_MODELS = [
41
41
  ACCOUNT_DEFAULT_MODEL_ID,
42
42
  ACCOUNT_NEAR_MODEL_ID,
43
- "anthropic/claude-sonnet-4.6",
44
- "openai/gpt-5.5",
43
+ "anthropic/claude-opus-5",
44
+ "anthropic/claude-sonnet-5",
45
+ "openai/gpt-5.6-sol",
45
46
  "deepseek/deepseek-v4-flash",
46
47
  ];
47
48
 
@@ -43,6 +43,10 @@ export interface LiveTaskDeps {
43
43
  // abandoned spawn can't run the account meter or hold resources forever).
44
44
  const ATTACH_GRACE_MS = 180_000; // 3 min to attach after spawn
45
45
  const MAX_LIFETIME_MS = 30 * 60_000; // 30 min absolute cap
46
+ // How long to wait for the spawned terminal to actually register on the relay before we give
47
+ // up and report the spawn as failed. `start()` resolves before the socket opens, so without
48
+ // this confirmation the harbor would announce a terminal the app can never attach to.
49
+ const REGISTER_TIMEOUT_MS = 20_000;
46
50
 
47
51
  export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps): Promise<LiveTaskHandle> {
48
52
  const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
@@ -213,6 +217,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
213
217
  relay = new RelayClient(bridge.callbacks, { termId, label });
214
218
  bridge.attachRelay(relay);
215
219
  await relay.start();
220
+ // start() resolves before the socket registers; confirm the terminal is actually live on
221
+ // the relay BEFORE returning (→ the harbor announces task_spawned). Rejects on a hard
222
+ // failure (e.g. the concurrency cap) or timeout → the catch below tears down and propagates,
223
+ // so the harbor reports task_spawn_error instead of pointing the app at a dead terminal.
224
+ await relay.awaitRegistered(REGISTER_TIMEOUT_MS);
216
225
 
217
226
  // Reap if nobody ever attaches, and cap the absolute lifetime regardless.
218
227
  attachTimer = setTimeout(() => { if (!attached) void stop(); }, ATTACH_GRACE_MS);
@@ -295,6 +295,14 @@ export class RelayClient {
295
295
  // random one each time).
296
296
  private readonly termId: string;
297
297
  private readonly label: string;
298
+ // First-registration signal (opt-in via awaitRegistered): `start()` resolves before the
299
+ // socket actually opens, so a caller that must not act until the terminal is truly live on
300
+ // the relay — e.g. a live-task spawn that announces `task_spawned` — waits on this instead.
301
+ // Settled once: resolved on the first ws `open`, rejected on a hard (4xx) ticket-mint
302
+ // failure. Reconnect blips after a successful first open do NOT re-settle it.
303
+ private firstConnectSettled = false;
304
+ private firstConnectError: Error | null = null;
305
+ private firstConnectWaiters: Array<{ resolve: () => void; reject: (e: Error) => void }> = [];
298
306
  // In-progress file transfers from the app, keyed by the controller's attachment
299
307
  // id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
300
308
  private readonly incoming = new Map<
@@ -322,8 +330,43 @@ export class RelayClient {
322
330
  await this.connect();
323
331
  }
324
332
 
333
+ // Resolve once this terminal has actually registered on the relay (ws `open`), reject on a
334
+ // hard registration failure (e.g. a 403 concurrency-cap denial) or after `timeoutMs`.
335
+ // `start()` resolves before the socket opens, so a live-task spawn calls this to confirm the
336
+ // app can attach BEFORE it announces the terminal — otherwise the app is told to drive a
337
+ // terminal that never came up and hangs. Idempotent; settling stores its result so a caller
338
+ // that awaits after the fact still gets it (no dangling/unhandled rejection).
339
+ awaitRegistered(timeoutMs: number): Promise<void> {
340
+ if (this.firstConnectSettled) {
341
+ return this.firstConnectError ? Promise.reject(this.firstConnectError) : Promise.resolve();
342
+ }
343
+ return new Promise<void>((resolve, reject) => {
344
+ const waiter = {
345
+ resolve: () => { clearTimeout(timer); resolve(); },
346
+ reject: (e: Error) => { clearTimeout(timer); reject(e); },
347
+ };
348
+ const timer = setTimeout(() => {
349
+ this.firstConnectWaiters = this.firstConnectWaiters.filter((w) => w !== waiter);
350
+ reject(new Error(`relay registration timed out after ${timeoutMs}ms`));
351
+ }, timeoutMs);
352
+ this.firstConnectWaiters.push(waiter);
353
+ });
354
+ }
355
+
356
+ private settleFirstConnect(err?: Error): void {
357
+ if (this.firstConnectSettled) return;
358
+ this.firstConnectSettled = true;
359
+ this.firstConnectError = err ?? null;
360
+ const waiters = this.firstConnectWaiters;
361
+ this.firstConnectWaiters = [];
362
+ for (const w of waiters) err ? w.reject(err) : w.resolve();
363
+ }
364
+
325
365
  stop(): void {
326
366
  this.closed = true;
367
+ // Fail any awaitRegistered() waiter promptly instead of leaving it to time out — a
368
+ // terminal stopped before it ever registered is never coming up.
369
+ this.settleFirstConnect(new Error("relay stopped before registering"));
327
370
  if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
328
371
  if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
329
372
  this.bufKind = null;
@@ -344,7 +387,11 @@ export class RelayClient {
344
387
  headers: { "Content-Type": "application/json" },
345
388
  body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
346
389
  });
347
- if (!res.ok) throw new Error(`relay ticket HTTP ${res.status}`);
390
+ if (!res.ok) {
391
+ const e: Error & { status?: number } = new Error(`relay ticket HTTP ${res.status}`);
392
+ e.status = res.status;
393
+ throw e;
394
+ }
348
395
  const { ticket } = (await res.json()) as { ticket: string };
349
396
 
350
397
  const wsUrl =
@@ -357,6 +404,7 @@ export class RelayClient {
357
404
 
358
405
  ws.on("open", () => {
359
406
  opened = true;
407
+ this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
360
408
  this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
361
409
  });
362
410
  ws.on("message", (data) => this.handle(data));
@@ -381,6 +429,13 @@ export class RelayClient {
381
429
  // Ticket mint failed (auth/network/route) — surface it; a silent failure
382
430
  // looks identical to "connected but ignoring me".
383
431
  const msg = err instanceof Error ? err.message : String(err);
432
+ // A 4xx (e.g. 403 concurrency cap) won't self-heal by retrying the same request —
433
+ // fail-fast any awaitRegistered() caller (a live-task spawn) so it stops hanging.
434
+ // The management terminal ignores this signal, so its reconnect behavior is unchanged.
435
+ const status = (err as { status?: number })?.status;
436
+ if (typeof status === "number" && status >= 400 && status < 500) {
437
+ this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
438
+ }
384
439
  this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
385
440
  this.scheduleReconnect();
386
441
  } finally {
@@ -691,6 +746,13 @@ export class RelayClient {
691
746
  this.rawSend({ type: "task_spawned", termId, label });
692
747
  }
693
748
 
749
+ // Tell the app a live task spawn FAILED (reply to task_spawn), with a short reason, so the
750
+ // spawn screen can stop waiting and show it — a live spawn otherwise has no failure signal
751
+ // and the app would spin until its own timeout. Fire-and-forget over the management relay.
752
+ sendTaskSpawnError(reason: string): void {
753
+ this.rawSend({ type: "task_spawn_error", reason: safe(reason, 300) });
754
+ }
755
+
694
756
  sendEvent(ev: EngineEvent): void {
695
757
  if (ev.type === "text") return this.bufferDelta("text", ev.text);
696
758
  if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
package/src/ui/palette.ts CHANGED
@@ -38,6 +38,7 @@ export type Palette = {
38
38
  DIM: string; // secondary / muted prose
39
39
  GREEN: string; // success (connected, verified, context loaded)
40
40
  YELLOW: string; // warning (not-signed-in, unconfirmed, update available)
41
+ RED: string; // alarm (the moat lowered — no-quarter)
41
42
  };
42
43
 
43
44
  // Last-resort palette for when no theme is reachable (headless surfaces have no banner,
@@ -52,6 +53,7 @@ export const FALLBACK: Palette = {
52
53
  DIM: `${ESC}90m`,
53
54
  GREEN: `${ESC}32m`,
54
55
  YELLOW: `${ESC}33m`,
56
+ RED: `${ESC}31m`,
55
57
  };
56
58
 
57
59
  // Build a Palette from a Pi Theme. getFgAnsi(name) returns the raw SGR foreground escape
@@ -76,6 +78,7 @@ export function paletteFor(theme: any): Palette {
76
78
  DIM: g("dim", FALLBACK.DIM),
77
79
  GREEN: g("success", FALLBACK.GREEN),
78
80
  YELLOW: g("warning", FALLBACK.YELLOW),
81
+ RED: g("error", FALLBACK.RED),
79
82
  };
80
83
  }
81
84