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 +27 -11
- package/bin/privateer-launch.mjs +1 -0
- package/extensions/privateer-gate.ts +97 -5
- package/extensions/privateer-privacy.ts +21 -0
- package/package.json +2 -2
- package/src/cli/chat.ts +55 -3
- package/src/harbor/index.ts +5 -1
- package/src/mcp/catalog.ts +56 -0
- package/src/permissions/noQuarter.ts +42 -0
- package/src/providers/account.ts +3 -2
- package/src/remote/liveTaskSession.ts +9 -0
- package/src/remote/relayClient.ts +63 -1
- package/src/ui/palette.ts +3 -0
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-
|
|
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
|
-
###
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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) |
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
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.
|
|
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.
|
|
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
|
|
311
|
-
|
|
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" },
|
package/src/harbor/index.ts
CHANGED
|
@@ -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
|
|
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
|
})();
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/account.ts
CHANGED
|
@@ -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-
|
|
44
|
-
"
|
|
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)
|
|
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
|
|