privateer-agent 0.7.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.7.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",
@@ -67,12 +67,14 @@
67
67
  "@noble/ciphers": "^2.1.1",
68
68
  "@noble/curves": "^1.9.7",
69
69
  "@noble/hashes": "^1.7.1",
70
+ "@phala/dcap-qvl": "^0.5.2",
70
71
  "patch-package": "^8.0.1",
71
72
  "pi-mcp-adapter": "^2.11.0",
72
- "pi-privacy": "^0.3.0",
73
+ "pi-privacy": "^0.8.0",
73
74
  "pi-subagents": "^0.34.0",
74
75
  "picomatch": "^4.0.4",
75
76
  "privateer-workflow": "^0.1.0",
77
+ "tinfoil": "^1.1.11",
76
78
  "tsx": "^4.16.0",
77
79
  "typebox": "^1.3.4",
78
80
  "undici": "^7.28.0",
@@ -109,7 +109,8 @@ async function main() {
109
109
  const { makePermissionGate } = await import("../ext/permissionGate.ts");
110
110
  type GateController = import("../ext/permissionGate.ts").GateController;
111
111
  const { makePiPrivacyExtension } = await import("pi-privacy");
112
- const { makeAccountProvider } = await import("../providers/account.ts");
112
+ const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
113
+ const { hasCredentials } = await import("../auth/privateer.ts");
113
114
  const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
114
115
  const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
115
116
  const { redactText, collectSecrets } = await import("../util/redact.ts");
@@ -181,7 +182,15 @@ async function main() {
181
182
  cwd,
182
183
  agentDir: agentDir(),
183
184
  resourceLoaderOptions: {
184
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
185
+ extensionFactories: [
186
+ makePermissionGate(gate),
187
+ // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
188
+ // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
189
+ makePiPrivacyExtension({
190
+ privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
191
+ }),
192
+ makeAccountProvider(),
193
+ ] as any,
185
194
  },
186
195
  });
187
196
 
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
@@ -38,7 +39,7 @@ async function main() {
38
39
  const { authorizeControl } = await import("../remote/controlAuth.ts");
39
40
  const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
40
41
  const priv = await import("../auth/privateer.ts");
41
- const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
42
+ const { makeAccountProvider, accountPosture, privateerChannel } = await import("../providers/account.ts");
42
43
  const { agentVersion } = await import("../config/version.ts");
43
44
  const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
44
45
 
@@ -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,14 +324,52 @@ 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,
329
361
  agentDir: agentDir(),
330
362
  resourceLoaderOptions: {
331
363
  // Structural ext types are intentionally narrow; cast to Pi's ExtensionFactory.
332
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
364
+ extensionFactories: [
365
+ makePermissionGate(gate),
366
+ // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
367
+ // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
368
+ makePiPrivacyExtension({
369
+ privateerVerifiedTee: (m) => priv.hasCredentials() && privateerChannel(m.id ?? "") === "tee",
370
+ }),
371
+ makeAccountProvider(),
372
+ ] as any,
333
373
  },
334
374
  });
335
375
  for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
@@ -525,8 +565,9 @@ async function main() {
525
565
  : `${DIM}Not signed in. /login to enable remote access & the account provider.${RESET}`,
526
566
  );
527
567
 
528
- 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";
529
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}`);
530
571
  await showPosture();
531
572
 
532
573
  // The available model catalog as sorted "provider/id" specs. Same source the
@@ -612,6 +653,24 @@ async function main() {
612
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}`);
613
654
  return true;
614
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
+ }
615
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; }
616
675
  // Bare /mode → the picker (remote) or a hint (local).
617
676
  if (line === "/mode") {
@@ -640,6 +699,7 @@ async function main() {
640
699
  const builtins = [
641
700
  { name: "/model", description: "Switch the model" },
642
701
  { name: "/mode", description: "Change the approval mode (default/acceptEdits/plan/bypass)" },
702
+ // /no-quarter is deliberately absent — terminal-only, see runCommand.
643
703
  { name: "/models", description: "List available models" },
644
704
  { name: "/extensions", description: "Manage installed Pi extensions" },
645
705
  { name: "/skills", description: "Manage the terminal's skills" },
@@ -14,7 +14,7 @@ import { agentVersion } from "../config/version.ts";
14
14
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
15
15
  import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
16
16
  import { makePiPrivacyExtension } from "pi-privacy";
17
- import { makeAccountProvider } from "../providers/account.ts";
17
+ import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
18
18
  import { resolveDefaultModel } from "../providers/defaultModel.ts";
19
19
  import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
20
20
  import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
@@ -30,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
30
30
  import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
31
31
  import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
32
32
  import { authorizeControl } from "../remote/controlAuth.ts";
33
- import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
33
+ import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke, defaultDeviceLabel } from "../auth/privateer.ts";
34
34
  import {
35
35
  loadRoutines,
36
36
  upsertRoutine,
@@ -50,7 +50,7 @@ import { splitRoutineTools } from "../routines/toolSelect.ts";
50
50
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
51
51
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
52
52
  import { redactText, collectSecrets } from "../util/redact.ts";
53
- import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
53
+ import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
54
54
  import { isHosted, publishRelayPub } from "../config/hosted.ts";
55
55
 
56
56
  // The safe, read-only toolset for unattended runs — Pi builtins with no
@@ -226,13 +226,19 @@ export class Harbor {
226
226
  return "queued";
227
227
  };
228
228
 
229
- start(): void {
229
+ async start(): Promise<void> {
230
+ // Single-instance lock FIRST, before any other side effect: binding the IPC
231
+ // socket is the machine's mutex. If a live harbor already holds it this throws
232
+ // HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
233
+ // routineRelayId(), so a second would collide on the relay and double-fire
234
+ // routines. Doing this first means a rejected second instance never publishes a
235
+ // relay key, connects, or fires a tick.
236
+ this.server = await startIpcServer((req) => this.handleIpc(req));
230
237
  // Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
231
238
  // report. Before syncRelay() so the key exists by the time we're reachable.
232
239
  publishRelayPub();
233
240
  this.primeSchedule();
234
241
  this.timer = setInterval(() => void this.tick(), TICK_MS);
235
- this.server = startIpcServer((req) => this.handleIpc(req));
236
242
  this.syncRelay();
237
243
  void this.flushPendingCloud();
238
244
  const count = loadRoutines().filter((r) => r.enabled).length;
@@ -517,11 +523,22 @@ export class Harbor {
517
523
  }
518
524
  }
519
525
 
526
+ // This machine's origin tag, embedded (E2EE) in every sealed result so the app can
527
+ // show WHICH box/environment produced it — the outbox record itself is account-only,
528
+ // so attribution can only live inside the sealed blob (where hostnames are allowed;
529
+ // the server never sees it). `id` is this install's stable relay id; `label` is the
530
+ // hostname-based device name. Cached — it never changes for the process lifetime.
531
+ private originCache?: { id: string; label: string };
532
+ private machineOrigin(): { id: string; label: string } {
533
+ if (!this.originCache) this.originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
534
+ return this.originCache;
535
+ }
536
+
520
537
  private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: "routine" | "task" = "routine"): Promise<boolean> {
521
538
  const pub = await this.ensureOutboxPub();
522
539
  if (!pub) return false;
523
540
  const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
524
- const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body });
541
+ const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: this.machineOrigin() });
525
542
  try {
526
543
  const res = await apiRequest("/api/outbox", {
527
544
  method: "POST",
@@ -727,7 +744,18 @@ export class Harbor {
727
744
  cwd: spec.cwd,
728
745
  agentDir: agentDir(),
729
746
  resourceLoaderOptions: {
730
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider(), mcpAdapter] as any,
747
+ extensionFactories: [
748
+ makePermissionGate(gate),
749
+ // Per-model verified-TEE capability for pi-privacy's /models picker: show
750
+ // Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
751
+ // when logged in; ZDR-channel models stay at their honest floor. The live
752
+ // verdict still comes from accountPosture on select — this only lifts the label.
753
+ makePiPrivacyExtension({
754
+ privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
755
+ }),
756
+ makeAccountProvider(),
757
+ mcpAdapter,
758
+ ] as any,
731
759
  },
732
760
  });
733
761
  servicesRef = services as any;
@@ -834,8 +862,12 @@ export class Harbor {
834
862
  this.relay?.sendTaskSpawned(handle.termId, handle.label);
835
863
  log(`live task spawned: ${handle.termId} (${handle.label})`);
836
864
  } catch (e) {
837
- 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}`;
838
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);
839
871
  this.relay?.sendNotice(msg);
840
872
  }
841
873
  })();
@@ -1034,7 +1066,6 @@ export class Harbor {
1034
1066
  // Entry point for `privateer harbor`. Caller must have imported ./boot.ts first.
1035
1067
  export function runHarbor(): void {
1036
1068
  const harbor = new Harbor();
1037
- harbor.start();
1038
1069
  const shutdown = () => {
1039
1070
  log("shutting down");
1040
1071
  harbor.stop();
@@ -1042,4 +1073,14 @@ export function runHarbor(): void {
1042
1073
  };
1043
1074
  process.on("SIGINT", shutdown);
1044
1075
  process.on("SIGTERM", shutdown);
1076
+ harbor.start().catch((err) => {
1077
+ if (err instanceof HarborAlreadyRunningError) {
1078
+ // A resident harbor already owns this machine — leave it in charge. Exit 0 so a
1079
+ // manual `privateer harbor run` beside the installed login service isn't an error.
1080
+ process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
1081
+ process.exit(0);
1082
+ }
1083
+ process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
1084
+ process.exit(1);
1085
+ });
1045
1086
  }