privateer-agent 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -11
- package/bin/privateer-launch.mjs +1 -0
- package/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/extensions/privateer-gate.ts +97 -5
- package/extensions/privateer-privacy.ts +21 -0
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +66 -5
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +260 -75
- package/src/mcp/catalog.ts +88 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/permissions/noQuarter.ts +42 -0
- package/src/providers/account.ts +321 -11
- package/src/remote/liveTaskSession.ts +20 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +103 -4
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
- 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,7 +28,12 @@ import { readFileSync, appendFileSync } from "node:fs";
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import * as priv from "../src/auth/privateer.ts";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
armAccountCredential,
|
|
33
|
+
dropPersistedAccountCredential,
|
|
34
|
+
makeAccountProvider,
|
|
35
|
+
verificationLink,
|
|
36
|
+
} from "../src/providers/account.ts";
|
|
32
37
|
import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
|
|
33
38
|
import { discoverContextFiles, onContextChanged } from "../src/context.ts";
|
|
34
39
|
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
@@ -317,12 +322,15 @@ export default function privateerBrand(pi: any): void {
|
|
|
317
322
|
// and dead-ends on the first inference. Removing it makes the next /signin spawn a
|
|
318
323
|
// fresh session. Reached via the model registry (constructed with the auth
|
|
319
324
|
// storage; see session.ts). Best-effort: nothing persisted → nothing to do.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
//
|
|
326
|
+
// Two flavours, because auth.json is machine-global and shared by every Privateer
|
|
327
|
+
// terminal (see providers/account.ts dropPersistedAccountCredential):
|
|
328
|
+
// - `force` for sign-out / expiry, where the whole token family is revoked and the
|
|
329
|
+
// entry is dead for every terminal;
|
|
330
|
+
// - the default ownership-checked drop for THIS process's exit, which must not
|
|
331
|
+
// delete the credential another running terminal is using.
|
|
332
|
+
const dropPersistedAccount = (ctx: any, opts: { force?: boolean } = {}): void => {
|
|
333
|
+
dropPersistedAccountCredential(ctx, opts);
|
|
326
334
|
};
|
|
327
335
|
|
|
328
336
|
// /update — run the global npm install in a child process and report the outcome via
|
|
@@ -370,7 +378,9 @@ export default function privateerBrand(pi: any): void {
|
|
|
370
378
|
const p = paletteFor(ctx?.ui?.theme);
|
|
371
379
|
const user = await priv.runDeviceLogin({
|
|
372
380
|
onCode: (code: any) => {
|
|
373
|
-
|
|
381
|
+
// Absolute url: the server sends it scheme-less, and this line is what the
|
|
382
|
+
// user copies into a browser. See providers/account.ts verificationLink.
|
|
383
|
+
const uri = clean(verificationLink(code.verification_uri_complete ?? code.verification_uri));
|
|
374
384
|
const userCode = clean(code.user_code);
|
|
375
385
|
ctx?.ui?.setWidget?.(
|
|
376
386
|
"privateer-signin",
|
|
@@ -412,7 +422,7 @@ export default function privateerBrand(pi: any): void {
|
|
|
412
422
|
const u = priv.currentUser();
|
|
413
423
|
ctx?.ui?.notify?.("Signing out of Privateer…", "info");
|
|
414
424
|
await priv.logout(); // never throws: local state is wiped whatever the network did
|
|
415
|
-
dropPersistedAccount(ctx);
|
|
425
|
+
dropPersistedAccount(ctx, { force: true }); // the whole family is revoked, not just ours
|
|
416
426
|
refresh(ctx);
|
|
417
427
|
ctx?.ui?.notify?.(
|
|
418
428
|
`Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
|
|
@@ -594,6 +604,8 @@ export default function privateerBrand(pi: any): void {
|
|
|
594
604
|
// server-side, so leaving the persisted copy behind would make the NEXT launch
|
|
595
605
|
// reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
|
|
596
606
|
// refresh on a 401). Mirrors the harbor's shutdown (harbor/index.ts).
|
|
607
|
+
// Ownership-checked (no force): revokeLocalSessions killed OUR sessions only, so a
|
|
608
|
+
// persisted entry belonging to another running terminal must survive our exit.
|
|
597
609
|
dropPersistedAccount(ctxRef);
|
|
598
610
|
});
|
|
599
611
|
|
|
@@ -602,8 +614,9 @@ export default function privateerBrand(pi: any): void {
|
|
|
602
614
|
priv.onSessionExpired(() => {
|
|
603
615
|
// clearCredentials() has already wiped the local machine login; also drop Pi's
|
|
604
616
|
// persisted account credential so the next prompt/launch doesn't reuse a token
|
|
605
|
-
// that's now dead server-side (see dropPersistedAccount).
|
|
606
|
-
|
|
617
|
+
// that's now dead server-side (see dropPersistedAccount). Forced: the machine login
|
|
618
|
+
// is gone, so the entry can't be useful to any terminal on this machine.
|
|
619
|
+
dropPersistedAccount(ctxRef, { force: true });
|
|
607
620
|
refresh(ctxRef);
|
|
608
621
|
ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
|
|
609
622
|
});
|
|
@@ -37,9 +37,11 @@ import {
|
|
|
37
37
|
import {
|
|
38
38
|
MCP_CATALOG,
|
|
39
39
|
draftFromCatalog,
|
|
40
|
+
hostedCapable,
|
|
40
41
|
promptOrder,
|
|
41
42
|
type CatalogEntry,
|
|
42
43
|
} from "../src/mcp/catalog.ts";
|
|
44
|
+
import { isHosted } from "../src/config/hosted.ts";
|
|
43
45
|
|
|
44
46
|
// Minimal views of Pi's theme + TUI handle, mirroring privateer-models.ts — enough to
|
|
45
47
|
// color text and request a redraw without coupling this file to Pi internals.
|
|
@@ -126,6 +128,15 @@ interface Step {
|
|
|
126
128
|
// A step that may be submitted empty. For an edit, an empty secret means "keep
|
|
127
129
|
// the value already on disk" — mcpControl's env merge rule makes that free.
|
|
128
130
|
optional?: boolean;
|
|
131
|
+
// Ask this step only when the answers so far say it's relevant — the auth questions
|
|
132
|
+
// are meaningless for a local command, and three dead optional prompts in a row is
|
|
133
|
+
// how a wizard teaches people to hammer Enter without reading. Predicates may only
|
|
134
|
+
// read answers from EARLIER steps.
|
|
135
|
+
when?: (answers: Record<string, string>) => boolean;
|
|
136
|
+
// Reject an answer before it becomes an answer, returning the reason to show. For
|
|
137
|
+
// rules mcpControl can't check because it never sees the raw form input, or that it
|
|
138
|
+
// would only catch after the user has typed three more fields for nothing.
|
|
139
|
+
validate?: (value: string) => string | undefined;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
type View = "list" | "catalog" | "form";
|
|
@@ -147,7 +158,15 @@ interface Pending {
|
|
|
147
158
|
// from the answer rather than asked as a separate question: an https:// answer is an
|
|
148
159
|
// http server, anything else is a stdio command line. That matches mcpControl's own
|
|
149
160
|
// inference (`draft.url || prev.url ? "http" : "stdio"`), so there's one rule, not two.
|
|
161
|
+
const isHttpTarget = (answers: Record<string, string>) => /^https?:\/\//i.test((answers.target ?? "").trim());
|
|
162
|
+
|
|
150
163
|
function customSteps(existing?: RemoteMcpServer): Step[] {
|
|
164
|
+
// A hosted agent takes remote OAuth connectors and nothing else (Option B —
|
|
165
|
+
// treeview/docs/HARBOR_CONNECTORS_PLAN.md §2), so the custom form drops to that
|
|
166
|
+
// shape: no local command, and no stored token. Both are refused at the question
|
|
167
|
+
// rather than at first call, because a connector that saves cleanly and then never
|
|
168
|
+
// works is the worse of the two failures.
|
|
169
|
+
const hosted = isHosted();
|
|
151
170
|
const target = existing
|
|
152
171
|
? existing.transport === "http"
|
|
153
172
|
? existing.url
|
|
@@ -162,15 +181,48 @@ function customSteps(existing?: RemoteMcpServer): Step[] {
|
|
|
162
181
|
},
|
|
163
182
|
{
|
|
164
183
|
key: "target",
|
|
165
|
-
prompt: "Launch command, or an https:// URL",
|
|
166
|
-
hint:
|
|
184
|
+
prompt: hosted ? "Server URL" : "Launch command, or an https:// URL",
|
|
185
|
+
hint: hosted
|
|
186
|
+
? "e.g. https://mcp.example.com/sse — a hosted agent can't run a local command."
|
|
187
|
+
: "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
|
|
167
188
|
initial: target,
|
|
189
|
+
validate: hosted
|
|
190
|
+
? (v) =>
|
|
191
|
+
/^https?:\/\//i.test(v.trim())
|
|
192
|
+
? undefined
|
|
193
|
+
: "A hosted agent can only reach remote connectors — this needs an https:// URL."
|
|
194
|
+
: undefined,
|
|
168
195
|
},
|
|
169
196
|
{
|
|
170
197
|
key: "env",
|
|
171
198
|
prompt: "Environment variables (optional)",
|
|
172
199
|
hint: "KEY=value, comma-separated. Leave blank for none.",
|
|
173
200
|
optional: true,
|
|
201
|
+
// Nothing to set them ON when there is no local process to spawn, and their
|
|
202
|
+
// values are credentials at rest — both reasons a hosted agent skips this.
|
|
203
|
+
when: () => !hosted,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
key: "bearerToken",
|
|
207
|
+
prompt: "Bearer token (optional)",
|
|
208
|
+
hint: existing?.bearerTokenSet
|
|
209
|
+
? "Leave blank to keep the token already saved."
|
|
210
|
+
: "Leave blank to sign in through the browser instead (OAuth).",
|
|
211
|
+
secret: true,
|
|
212
|
+
optional: true,
|
|
213
|
+
// Never asked on a hosted agent: the home is tmpfs and a stored token would have
|
|
214
|
+
// to rest somewhere we can read. OAuth is the only credential it may hold.
|
|
215
|
+
when: (a) => !hosted && isHttpTarget(a),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
key: "headers",
|
|
219
|
+
prompt: "Extra HTTP headers (optional)",
|
|
220
|
+
hint: "KEY=value, comma-separated — e.g. X-Api-Version=2. Leave blank for none.",
|
|
221
|
+
// Header values are credentials as often as not, so this is masked like a token
|
|
222
|
+
// — and for the same reason a hosted agent is never offered it.
|
|
223
|
+
secret: true,
|
|
224
|
+
optional: true,
|
|
225
|
+
when: (a) => !hosted && isHttpTarget(a),
|
|
174
226
|
},
|
|
175
227
|
];
|
|
176
228
|
}
|
|
@@ -196,7 +248,22 @@ function buildCustomDraft(answers: Record<string, string>): McpDraft {
|
|
|
196
248
|
if (/^https?:\/\//i.test(target)) {
|
|
197
249
|
draft.transport = "http";
|
|
198
250
|
draft.url = target;
|
|
199
|
-
|
|
251
|
+
const token = (answers.bearerToken ?? "").trim();
|
|
252
|
+
const headers = parseEnv(answers.headers ?? "");
|
|
253
|
+
const hasHeaders = Object.keys(headers).length > 0;
|
|
254
|
+
if (hasHeaders) draft.headers = headers;
|
|
255
|
+
if (token) {
|
|
256
|
+
// A static token: the adapter only sends the Authorization header when auth is
|
|
257
|
+
// explicitly "bearer", so this pairing is not optional.
|
|
258
|
+
draft.auth = "bearer";
|
|
259
|
+
draft.bearerToken = token;
|
|
260
|
+
} else if (hasHeaders) {
|
|
261
|
+
// Custom headers ARE the credential here — the adapter's supportsOAuth() refuses
|
|
262
|
+
// OAuth once headers are configured, so claiming "oauth" would be a lie.
|
|
263
|
+
draft.auth = "none";
|
|
264
|
+
} else {
|
|
265
|
+
draft.auth = "oauth";
|
|
266
|
+
}
|
|
200
267
|
} else {
|
|
201
268
|
draft.transport = "stdio";
|
|
202
269
|
const parts = target.split(/\s+/).filter(Boolean);
|
|
@@ -265,8 +332,16 @@ const NEEDS_LABEL: Record<string, string> = {
|
|
|
265
332
|
none: "no setup",
|
|
266
333
|
};
|
|
267
334
|
|
|
335
|
+
// On a hosted (Harbor) agent the picker offers only what the runtime can actually
|
|
336
|
+
// hold — see hostedCapable(). Offering the other 16 would be offering a connector that
|
|
337
|
+
// installs nothing, keeps nothing, and fails at first call: the tenant is `--read-only`
|
|
338
|
+
// with no uv/uvx/Python and a tmpfs home wiped on every suspend. Filtering here rather
|
|
339
|
+
// than letting them fail later is the difference between "not available on a hosted
|
|
340
|
+
// agent" and "I set it up and it just doesn't work."
|
|
268
341
|
function catalogRows(): CatalogRow[] {
|
|
269
|
-
const
|
|
342
|
+
const hosted = isHosted();
|
|
343
|
+
const entries = hosted ? MCP_CATALOG.filter(hostedCapable) : MCP_CATALOG;
|
|
344
|
+
const rows: CatalogRow[] = entries.map((e) => ({
|
|
270
345
|
entry: e,
|
|
271
346
|
label: e.label,
|
|
272
347
|
blurb: e.blurb,
|
|
@@ -274,7 +349,9 @@ function catalogRows(): CatalogRow[] {
|
|
|
274
349
|
}));
|
|
275
350
|
rows.push({
|
|
276
351
|
label: "Custom…",
|
|
277
|
-
|
|
352
|
+
// Custom survives the filter because a custom REMOTE OAuth connector is fine here;
|
|
353
|
+
// it's stdio and stored tokens that aren't. customSteps enforces that.
|
|
354
|
+
blurb: hosted ? "Any https:// URL with browser sign-in." : "Any stdio command or http URL.",
|
|
278
355
|
needsLabel: "",
|
|
279
356
|
});
|
|
280
357
|
return rows;
|
|
@@ -359,6 +436,36 @@ class ConnectPanel extends Container {
|
|
|
359
436
|
return this.pending?.steps[this.stepIndex];
|
|
360
437
|
}
|
|
361
438
|
|
|
439
|
+
// A step is asked only when its `when` predicate (if any) passes against the answers
|
|
440
|
+
// collected so far. Navigation walks over the hidden ones in both directions, and
|
|
441
|
+
// the position counter reports the VISIBLE steps so "2/3" doesn't count a question
|
|
442
|
+
// the user will never see.
|
|
443
|
+
private stepVisible(i: number): boolean {
|
|
444
|
+
const step = this.pending?.steps[i];
|
|
445
|
+
if (!step) return false;
|
|
446
|
+
return step.when ? step.when(this.answers) : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private seekStep(from: number, dir: 1 | -1): number | undefined {
|
|
450
|
+
const total = this.pending?.steps.length ?? 0;
|
|
451
|
+
for (let i = from; i >= 0 && i < total; i += dir) {
|
|
452
|
+
if (this.stepVisible(i)) return i;
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private visiblePosition(): { at: number; of: number } {
|
|
458
|
+
const total = this.pending?.steps.length ?? 0;
|
|
459
|
+
let at = 0;
|
|
460
|
+
let of = 0;
|
|
461
|
+
for (let i = 0; i < total; i++) {
|
|
462
|
+
if (!this.stepVisible(i)) continue;
|
|
463
|
+
of++;
|
|
464
|
+
if (i <= this.stepIndex) at = of;
|
|
465
|
+
}
|
|
466
|
+
return { at, of };
|
|
467
|
+
}
|
|
468
|
+
|
|
362
469
|
// -- rendering ------------------------------------------------------------
|
|
363
470
|
|
|
364
471
|
private refresh(): void {
|
|
@@ -429,6 +536,13 @@ class ConnectPanel extends Container {
|
|
|
429
536
|
private renderCatalog(): void {
|
|
430
537
|
const t = this.theme;
|
|
431
538
|
this.body.addChild(new Text(t.fg("accent", t.bold("Add a connector")), 1, 0));
|
|
539
|
+
// Say why the list is short before the user goes looking for GitHub. A filter with
|
|
540
|
+
// no explanation reads as a missing connector, which is a support question.
|
|
541
|
+
if (isHosted()) {
|
|
542
|
+
this.body.addChild(
|
|
543
|
+
new Text(t.fg("muted", " Hosted agent — remote connectors with browser sign-in only."), 1, 0),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
432
546
|
this.body.addChild(new Spacer(1));
|
|
433
547
|
this.body.addChild(this.search);
|
|
434
548
|
this.body.addChild(new Spacer(1));
|
|
@@ -458,7 +572,8 @@ class ConnectPanel extends Container {
|
|
|
458
572
|
const t = this.theme;
|
|
459
573
|
const p = this.pending!;
|
|
460
574
|
const step = this.currentStep()!;
|
|
461
|
-
const
|
|
575
|
+
const pos = this.visiblePosition();
|
|
576
|
+
const counter = pos.of > 1 ? ` ${pos.at}/${pos.of}` : "";
|
|
462
577
|
this.body.addChild(new Text(t.fg("accent", t.bold(p.title)) + t.fg("muted", counter), 1, 0));
|
|
463
578
|
this.body.addChild(new Spacer(1));
|
|
464
579
|
this.body.addChild(new Text(` ${t.fg("text", step.prompt)}`, 1, 0));
|
|
@@ -555,10 +670,19 @@ class ConnectPanel extends Container {
|
|
|
555
670
|
this.refresh();
|
|
556
671
|
return;
|
|
557
672
|
}
|
|
673
|
+
// Validate only what was actually typed: a blank optional answer means "skip", and
|
|
674
|
+
// a rule about the shape of a value has nothing to say about its absence.
|
|
675
|
+
const bad = value && step.validate ? step.validate(value) : undefined;
|
|
676
|
+
if (bad) {
|
|
677
|
+
this.status = bad;
|
|
678
|
+
this.refresh();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
558
681
|
this.answers[step.key] = value;
|
|
559
682
|
this.status = "";
|
|
560
|
-
|
|
561
|
-
|
|
683
|
+
const next = this.seekStep(this.stepIndex + 1, 1);
|
|
684
|
+
if (next !== undefined) {
|
|
685
|
+
this.stepIndex = next;
|
|
562
686
|
this.loadStep();
|
|
563
687
|
return;
|
|
564
688
|
}
|
|
@@ -701,8 +825,9 @@ class ConnectPanel extends Container {
|
|
|
701
825
|
const step = this.currentStep()!;
|
|
702
826
|
if (kb.matches(data, "tui.select.cancel")) {
|
|
703
827
|
// Back a step, or out of the form entirely from the first one.
|
|
704
|
-
|
|
705
|
-
|
|
828
|
+
const prev = this.stepIndex > 0 ? this.seekStep(this.stepIndex - 1, -1) : undefined;
|
|
829
|
+
if (prev !== undefined) {
|
|
830
|
+
this.stepIndex = prev;
|
|
706
831
|
this.loadStep();
|
|
707
832
|
} else {
|
|
708
833
|
this.view = this.pending?.origin ?? "list";
|
|
@@ -28,8 +28,10 @@ import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
|
28
28
|
import { agentDir } from "../src/config/paths.ts";
|
|
29
29
|
import { agentVersion } from "../src/config/version.ts";
|
|
30
30
|
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
31
32
|
import * as priv from "../src/auth/privateer.ts";
|
|
32
33
|
import { paletteFor } from "../src/ui/palette.ts";
|
|
34
|
+
import { noQuarterActive, setNoQuarter } from "../src/permissions/noQuarter.ts";
|
|
33
35
|
import type { PermissionMode } from "../src/config/permissionMode.ts";
|
|
34
36
|
|
|
35
37
|
const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
|
|
@@ -169,6 +171,17 @@ async function pickModelRemote(filter: string): Promise<void> {
|
|
|
169
171
|
// the REPL's runCommand fall-through.
|
|
170
172
|
async function runRemoteCommand(text: string): Promise<void> {
|
|
171
173
|
const line = text.trim();
|
|
174
|
+
// No quarter is a PHYSICAL-terminal action, like /remote-access. It's stronger than
|
|
175
|
+
// any mode — it also switches off the dangerous-command denylist and the protected-
|
|
176
|
+
// file guard — so a remote controller must not be able to reach it. (The app's own
|
|
177
|
+
// no_quarter toggle covers driven turns: ModeGate re-decides those through
|
|
178
|
+
// decideAuto(req, "bypass", …), so it IS /mode bypass — never weaker. Dangerous and
|
|
179
|
+
// destructive actions sit above bypass, so they come back to the phone for an
|
|
180
|
+
// explicit Allow there, exactly as bypass surfaces them locally.)
|
|
181
|
+
if (line === "/no-quarter" || line.startsWith("/no-quarter ")) {
|
|
182
|
+
relay?.sendNotice("/no-quarter is terminal-only — run it at the machine, or use the app's own no-quarter toggle.");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
172
185
|
if (line.startsWith("/model ")) { await switchModelRemote(line.slice(7)); return; }
|
|
173
186
|
if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
|
|
174
187
|
const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
|
|
@@ -213,7 +226,9 @@ function advertiseCommands(): { name: string; description?: string }[] {
|
|
|
213
226
|
})
|
|
214
227
|
.filter(Boolean);
|
|
215
228
|
} catch { /* no commands registered yet */ }
|
|
216
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@phala/dcap-qvl": "^0.5.2",
|
|
71
71
|
"patch-package": "^8.0.1",
|
|
72
72
|
"pi-mcp-adapter": "^2.11.0",
|
|
73
|
-
"pi-privacy": "^0.
|
|
73
|
+
"pi-privacy": "^0.9.0",
|
|
74
74
|
"pi-subagents": "^0.34.0",
|
|
75
75
|
"picomatch": "^4.0.4",
|
|
76
76
|
"privateer-workflow": "^0.1.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
2
|
-
index e223ce1..
|
|
2
|
+
index e223ce1..cc853e7 100644
|
|
3
3
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
4
4
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
5
5
|
@@ -163,6 +163,14 @@ export class AgentSession {
|
|
@@ -33,6 +33,25 @@ index e223ce1..3615d74 100644
|
|
|
33
33
|
}
|
|
34
34
|
if (await this._checkCompaction(msg)) {
|
|
35
35
|
return true;
|
|
36
|
+
@@ -1996,6 +2013,18 @@ export class AgentSession {
|
|
37
|
+
// Context overflow is handled by compaction, not retry.
|
|
38
|
+
if (isContextOverflow(message, this.model?.contextWindow ?? 0))
|
|
39
|
+
return false;
|
|
40
|
+
+ // Privateer patch: a Privateer ACCOUNT CAP is not a throttle. Daily/monthly
|
|
41
|
+
+ // message or token limits (and an exhausted balance) come back from the account
|
|
42
|
+
+ // channel as a 429 carrying the backend's machine `code` and a ready-to-show
|
|
43
|
+
+ // message. pi classifies anything containing "429" as transient, so the agent
|
|
44
|
+
+ // spent its whole retry budget — with exponential backoff — on a limit that
|
|
45
|
+
+ // cannot clear, and only then showed the user the one message that says what to
|
|
46
|
+
+ // do (upgrade, top up, or /login keys for a BYO key). Scoped to the `privateer`
|
|
47
|
+
+ // provider and to the backend's own cap wording so no other provider's transient
|
|
48
|
+
+ // rate limit is affected. Mirrors isAccountCapCode in src/engine/errors.ts.
|
|
49
|
+
+ if (this.model?.provider === "privateer" &&
|
|
50
|
+
+ /"code"\s*:\s*"[A-Z0-9_]*(?:CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP)[A-Z0-9_]*"|limit of [^.]*reached|upgrade or top ?up|usage limit reached/i.test(message.errorMessage ?? ""))
|
|
51
|
+
+ return false;
|
|
52
|
+
return isRetryableAssistantError(message);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
36
55
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
37
56
|
index 197bccc..27f6429 100644
|
|
38
57
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|