privateer-agent 0.6.3 → 0.6.4

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
@@ -248,6 +248,21 @@ redactor before it leaves.
248
248
  Switch with **`/mode`**. Even in `bypass`, a danger filter blocks destructive shell commands,
249
249
  and protected files (`.env`, shell rc files…) are guarded — the gate is never fully off.
250
250
 
251
+ ### `--no-quarter` — lower the moat entirely
252
+
253
+ For an unattended run in a directory and on a task you fully trust, launch with:
254
+
255
+ ```bash
256
+ privateer --no-quarter
257
+ ```
258
+
259
+ This is the one exception to "the gate is never fully off." It disables the permission
260
+ gate for the **whole session** — every action auto-approves with no prompt, including
261
+ destructive shell commands, out-of-cwd access, and protected files. Subagents spawned
262
+ by the session inherit it. There is no `/mode` equivalent; it's a deliberate launch-time
263
+ opt-out (env `PRIVATEER_NO_QUARTER=1`) and prints a red warning banner so it's never a
264
+ surprise. Use it sparingly.
265
+
251
266
  ## Extend it
252
267
 
253
268
  Everything below is a **Pi extension** loaded by discovery (see [Built on Pi](#built-on-pi)) —
@@ -41,6 +41,29 @@ const NODE_BIN = BUNDLED ? bundledNode : process.execPath;
41
41
  process.env.PATH = path.dirname(NODE_BIN) + path.delimiter + (process.env.PATH || "");
42
42
 
43
43
  const args = process.argv.slice(2);
44
+
45
+ // `--no-quarter` — total permission bypass ("take no prisoners"). Strip it from the
46
+ // args BEFORE anything else so it never reaches Pi's cli.js (which doesn't know it)
47
+ // and so `sub`/`args.slice(1)` see only real subcommands. When present we export
48
+ // PRIVATEER_NO_QUARTER=1; the permission gate (extensions/privateer-gate.ts, and any
49
+ // subagent child that inherits this env) then auto-approves EVERY action with no
50
+ // prompt — dangerous shell, destructive tools, out-of-cwd, protected files, all of
51
+ // it. This is the moat fully lowered; only pass it when you trust the whole session.
52
+ const NO_QUARTER = args.some((a) => a === "--no-quarter");
53
+ if (NO_QUARTER) {
54
+ for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-quarter") args.splice(i, 1);
55
+ process.env.PRIVATEER_NO_QUARTER = "1";
56
+ process.stderr.write(
57
+ [
58
+ "",
59
+ " ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
60
+ " Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt.",
61
+ " Only use this in a directory and with a task you fully trust.",
62
+ "",
63
+ ].join("\n") + "\n",
64
+ );
65
+ }
66
+
44
67
  const sub = args[0];
45
68
 
46
69
  // `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
@@ -169,17 +192,41 @@ else {
169
192
  // TEE, strongest tier) when a Tinfoil key is present; else the signed-in account's
170
193
  // NEAR channel; else a cheap OpenRouter fallback.
171
194
  const CRED = path.join(PRIVATEER_HOME, "credentials.json");
195
+ const signedIn = fs.existsSync(CRED);
172
196
  const MODEL = process.env.PRIVATEER_MODEL
173
197
  ? process.env.PRIVATEER_MODEL
174
198
  : haveTinfoilKey()
175
199
  ? "tinfoil/glm-5-2"
176
- : fs.existsSync(CRED)
200
+ : signedIn
177
201
  ? "privateer/near/zai-org/GLM-5.1-FP8"
178
202
  : "openrouter/openai/gpt-4o-mini";
179
203
 
204
+ // Guard the keyless dead-end. We land on the OpenRouter fallback ONLY when the user
205
+ // named no model, has no Tinfoil key, AND isn't signed in (no credentials.json). If
206
+ // they also have no OpenRouter/other BYO key, the very first prompt errors with a bare
207
+ // "No API key found for openrouter" and nothing explains why. Worse, if this machine
208
+ // was signed in before (other ~/.privateer state exists but the login file is gone),
209
+ // that bare error hides a vanished session. Surface a clear, branded notice BEFORE the
210
+ // TUI loads — but still boot it, so `/login` inside works (and activateSignedInModel
211
+ // switches the live session onto the account channel the moment they sign back in).
212
+ if (MODEL === "openrouter/openai/gpt-4o-mini" && !haveByoKey()) {
213
+ warnKeylessLaunch();
214
+ }
215
+
216
+ // Privateer's own bundled skills. Loaded by explicit path (Pi's `--skill`, which
217
+ // takes a file or directory) rather than seeded into the agent dir, so they load
218
+ // read-only from the shipped release — always matching this version, never
219
+ // clobbering or resurrecting anything in the user's own editable skills dir. Each
220
+ // is a directory holding a SKILL.md. Skip any that aren't present (e.g. a partial
221
+ // dev checkout) so a missing dir can't wedge launch.
222
+ const SKILL_DIRS = ["resolve-dependencies"]
223
+ .map((name) => path.join(REPO, "skills", name))
224
+ .filter((dir) => fs.existsSync(dir));
225
+ const skillArgs = SKILL_DIRS.flatMap((dir) => ["--skill", dir]);
226
+
180
227
  // Dev convenience: load provider keys from the repo's .env if present.
181
228
  const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
182
- runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...args]);
229
+ runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...skillArgs, ...args]);
183
230
  }
184
231
 
185
232
  // --- helpers ---------------------------------------------------------------
@@ -240,11 +287,62 @@ function findWindowsBash() {
240
287
  }
241
288
 
242
289
  function haveTinfoilKey() {
243
- if (process.env.TINFOIL_API_KEY) return true;
244
- try { return /^TINFOIL_API_KEY=.+/m.test(fs.readFileSync(ENV_FILE, "utf8")); }
290
+ return haveKey("TINFOIL_API_KEY");
291
+ }
292
+
293
+ // True if `name` is set in the environment or (dev convenience) present and non-empty in
294
+ // the repo .env — the same two sources the child inherits, so this matches what Pi will
295
+ // actually see for the provider key at request time.
296
+ function haveKey(name) {
297
+ if (process.env[name]) return true;
298
+ try { return new RegExp(`^${name}=.+`, "m").test(fs.readFileSync(ENV_FILE, "utf8")); }
245
299
  catch { return false; }
246
300
  }
247
301
 
302
+ // Any BYO provider key that would make the keyless OpenRouter launch model usable (or at
303
+ // least give the runtime SOME working provider). Mirrors defaultModel.ts's BYO_BY_KEY.
304
+ function haveByoKey() {
305
+ return ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "TINFOIL_API_KEY"]
306
+ .some(haveKey);
307
+ }
308
+
309
+ // Explain the keyless launch instead of letting the first prompt dead-end on a bare
310
+ // "No API key found for openrouter". Distinguishes a returning user whose login file is
311
+ // missing (other ~/.privateer state exists → likely signed out unexpectedly) from a
312
+ // genuine first run. Non-fatal: we print and carry on so `/login` inside still works.
313
+ function warnKeylessLaunch() {
314
+ // Heuristic "was signed in before": the agent dir or our own config.json exists even
315
+ // though credentials.json doesn't. A true first run has neither yet.
316
+ let returning = false;
317
+ try {
318
+ returning =
319
+ fs.existsSync(path.join(PRIVATEER_HOME, "agent")) ||
320
+ fs.existsSync(path.join(PRIVATEER_HOME, "config.json"));
321
+ } catch { /* best-effort — default to the first-run wording */ }
322
+
323
+ const lines = returning
324
+ ? [
325
+ "",
326
+ " ⚓ Your Privateer login is missing — this terminal isn't signed in.",
327
+ ` (no ${path.join(PRIVATEER_HOME, "credentials.json")})`,
328
+ "",
329
+ " If you were signed in before, your session was cleared. Run /login to sign",
330
+ " back in — you'll return to your subscription models right away. Until then,",
331
+ " prompting fails with \"No API key found\" because no model key is set.",
332
+ "",
333
+ ]
334
+ : [
335
+ "",
336
+ " ⚓ You're not signed in to Privateer and no provider API key is set.",
337
+ "",
338
+ " Run /login to use your subscription, or set a provider key (e.g.",
339
+ " ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY). Until then,",
340
+ " prompting fails with \"No API key found\".",
341
+ "",
342
+ ];
343
+ process.stderr.write(lines.join("\n") + "\n");
344
+ }
345
+
248
346
  function refreshUpdateCache() {
249
347
  const cache = path.join(PRIVATEER_HOME, "update-check.json");
250
348
  try {
@@ -353,6 +353,10 @@ const gate = makePermissionGate({
353
353
  localAsk,
354
354
  getRemote: bridge.getRemote,
355
355
  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",
356
360
  remoteAsk: bridge.remoteAsk,
357
361
  });
358
362
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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",
@@ -37,6 +37,7 @@
37
37
  "bin",
38
38
  "src",
39
39
  "extensions",
40
+ "skills",
40
41
  "patches",
41
42
  "README.md",
42
43
  "LICENSE"
@@ -1,3 +1,23 @@
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..2bdab10 100644
3
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
4
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
5
+ @@ -711,6 +711,15 @@ export class AgentSession {
6
+ finalError: msg.errorMessage,
7
+ });
8
+ this._retryAttempt = 0;
9
+ + // Privateer patch: a transient error that survived the full retry budget
10
+ + // is terminal. Ending the turn here (instead of falling through to
11
+ + // compaction / queued-message continuation) prevents the agent loop from
12
+ + // re-entering agent.continue(), hitting the identical error, and — because
13
+ + // the retry counter was just reset to 0 — starting a fresh burst of 3.
14
+ + // That re-entry is what produced the endless "retrying 1/3…2/3…3/3" loop
15
+ + // on a persistently-failing provider/tool. Context-overflow errors never
16
+ + // reach this branch (_isRetryableError → false), so compaction is untouched.
17
+ + return false;
18
+ }
19
+ if (await this._checkCompaction(msg)) {
20
+ return true;
1
21
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
2
22
  index 5d65200..a997ad7 100644
3
23
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: "resolve-dependencies"
3
+ description: "Resolve a missing system binary/CLI tool or language package (npm/pip/cargo/go/gem) in the current environment. Use when a command fails with 'command not found', a build/test fails on a missing tool, or code fails on an unresolved import/module. Detects the right package manager, prefers a user-writable install, and runs the concrete install command through the permission gate."
4
+ ---
5
+
6
+ # Resolving missing dependencies
7
+
8
+ When work is blocked because a **system tool** (e.g. `ripgrep`, `jq`, `cmake`) or a
9
+ **language package** (e.g. `zod`, `requests`, `serde`) is not installed, resolve it
10
+ deliberately. You are running on the user's own machine — installs are real and
11
+ persist — so pick the smallest, least-privileged command that unblocks the task and
12
+ let the user approve it.
13
+
14
+ Never install silently to "just make it work." Confirm what's missing, choose the
15
+ narrowest install, and run it through the normal `bash` tool so the permission gate
16
+ shows the user the exact command. Do not pipe remote scripts into a shell
17
+ (`curl … | sh`) — the gate flags that as dangerous and it is rarely the right way to
18
+ get a dependency.
19
+
20
+ ## 1. Confirm it's actually missing
21
+
22
+ Before installing anything, verify the gap:
23
+
24
+ - System tool: `command -v <tool>` (or `which <tool>`). Empty output → missing.
25
+ - Language package: check the project manifest first (`package.json`,
26
+ `requirements.txt` / `pyproject.toml`, `Cargo.toml`, `go.mod`, `Gemfile`). If the
27
+ package is already declared, the fix is usually **install project deps**
28
+ (`npm install`, `pip install -r requirements.txt`), not adding a new one.
29
+
30
+ If a tool is missing only for this one command, consider whether an already-present
31
+ alternative works (e.g. `rg` → `grep -r`, `jq` → a small `node`/`python` one-liner)
32
+ before installing.
33
+
34
+ ## 2. Detect the right package manager
35
+
36
+ **Language packages** — pick from the manifest that exists in the project (do not
37
+ guess the ecosystem):
38
+
39
+ | Manifest present | Add a package | Install declared deps |
40
+ | --------------------------- | ------------------------------------------- | -------------------------------- |
41
+ | `package.json` | `npm install <pkg>` (or `pnpm add`/`yarn add` if that lockfile is present) | `npm install` |
42
+ | `requirements.txt` | `pip install <pkg>` | `pip install -r requirements.txt`|
43
+ | `pyproject.toml` (Poetry) | `poetry add <pkg>` | `poetry install` |
44
+ | `pyproject.toml` (uv) | `uv add <pkg>` | `uv sync` |
45
+ | `Cargo.toml` | `cargo add <pkg>` | `cargo build` |
46
+ | `go.mod` | `go get <pkg>` | `go mod download` |
47
+ | `Gemfile` | `bundle add <pkg>` | `bundle install` |
48
+
49
+ Match the lockfile, not just the manifest: `pnpm-lock.yaml` → use `pnpm`,
50
+ `yarn.lock` → `yarn`, `bun.lockb` → `bun`. Adding a package edits the manifest —
51
+ that's usually wanted, but say so if the user only asked to run something.
52
+
53
+ **System binaries** — detect the OS package manager by probing, in order, and use the
54
+ first one available:
55
+
56
+ | `command -v` hit | Install command | Notes |
57
+ | ---------------- | ---------------------------------- | ---------------------------------- |
58
+ | `brew` | `brew install <pkg>` | macOS/Linuxbrew — no sudo, preferred |
59
+ | `apt-get` | `sudo apt-get install -y <pkg>` | Debian/Ubuntu; run `apt-get update` first if the install 404s |
60
+ | `dnf` / `yum` | `sudo dnf install -y <pkg>` | Fedora/RHEL |
61
+ | `apk` | `apk add <pkg>` (`sudo` if needed) | Alpine |
62
+ | `pacman` | `sudo pacman -S --noconfirm <pkg>` | Arch |
63
+ | `nix-env` | `nix-env -iA nixpkgs.<pkg>` | Nix — no sudo |
64
+ | `zypper` | `sudo zypper install -y <pkg>` | openSUSE |
65
+
66
+ The package name is not always the command name (e.g. the `fd` command ships as
67
+ `fd-find` on apt, `fd` on brew). If unsure of the exact package name, use the
68
+ `web_search` / `web_fetch` tools to confirm it for the detected package manager
69
+ before running the install.
70
+
71
+ ## 3. Prefer the least-privileged install
72
+
73
+ - Reach for a **no-sudo** manager first (`brew`, `nix`, language package managers)
74
+ before a system one that needs `sudo`.
75
+ - For language packages outside a project, prefer a **user or isolated install**
76
+ over a global one:
77
+ - Python: a virtualenv (`python -m venv .venv && . .venv/bin/activate`) or
78
+ `pip install --user <pkg>` rather than a system-wide `sudo pip`.
79
+ - Node CLIs you only need to run once: `npx <pkg>` instead of `npm install -g`.
80
+ - Only escalate to `sudo` / system-wide when there is no user-writable option and the
81
+ task genuinely needs it. Explain why in the same message so the user's approval is
82
+ informed.
83
+
84
+ ## 4. Run it and verify
85
+
86
+ Run the chosen command via the `bash` tool (never fabricate success). The permission
87
+ gate will surface the exact command to the user — that is intended, so keep the
88
+ command to the single thing you need.
89
+
90
+ After it completes:
91
+
92
+ 1. Re-check availability (`command -v <tool>`, or re-run the failing import/build).
93
+ 2. Retry the original task.
94
+ 3. If the install was denied or failed, **stop and tell the user** what's missing and
95
+ the command you would run — do not loop on variants of the same install.
96
+
97
+ ## Notes
98
+
99
+ - If the environment looks locked-down (read-only filesystem, no package manager
100
+ found, no network), say so plainly and ask how the user wants to proceed rather than
101
+ hunting for a workaround.
102
+ - One dependency at a time when debugging a broken environment — install, verify,
103
+ then move on — so a failure is easy to attribute.
package/src/cli/chat.ts CHANGED
@@ -281,6 +281,8 @@ async function main() {
281
281
  },
282
282
  getRemote: bridge.getRemote,
283
283
  getNoQuarter: bridge.getNoQuarter,
284
+ // `--no-quarter` at launch (env PRIVATEER_NO_QUARTER) → total gate bypass, no prompts.
285
+ getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
284
286
  remoteAsk: bridge.remoteAsk,
285
287
  // Subagents (and their child-only intercom tools) can't be driven from the app
286
288
  // yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
@@ -44,6 +44,10 @@ export interface GateController {
44
44
  confineToCwd?: boolean;
45
45
  getRemote?(): boolean;
46
46
  getNoQuarter?(): boolean;
47
+ // Total bypass — see ModeGate.getSkipAllPermissions. Set by the `--no-quarter`
48
+ // launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
49
+ // action with no prompt.
50
+ getSkipAllPermissions?(): boolean;
47
51
  // Block a tool outright while the turn is remote-driven (only consulted when
48
52
  // getRemote() is true). For tools whose own prompts render on the host terminal
49
53
  // rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
@@ -132,20 +136,26 @@ export async function decideToolCall(
132
136
  ask,
133
137
  getRemote: ctrl.getRemote,
134
138
  getNoQuarter: ctrl.getNoQuarter,
139
+ getSkipAllPermissions: ctrl.getSkipAllPermissions,
135
140
  });
136
141
 
137
142
  let decision: "allow" | "deny";
138
143
  try {
139
144
  decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
140
145
  } catch (err) {
141
- // Fail closed: a thrown/aborted/timed-out approval blocks the tool.
146
+ // Fail closed: a thrown/aborted/timed-out approval blocks the tool. Phrase it
147
+ // as terminal — re-issuing the identical call will hit the same closed gate, so
148
+ // tell the model to stop retrying and take a different path (or ask the user).
142
149
  return {
143
150
  block: true,
144
- reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default`,
151
+ reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it.`,
145
152
  };
146
153
  }
147
154
  if (decision === "deny") {
148
- return { block: true, reason: `${req.title} denied by permission gate` };
155
+ return {
156
+ block: true,
157
+ reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves.`,
158
+ };
149
159
  }
150
160
  return undefined;
151
161
  }
@@ -34,6 +34,13 @@ export interface ModeGateDeps {
34
34
  // pinging the phone. Dangerous shell and alwaysAsk-destructive actions rank
35
35
  // above bypass in decideAuto, so those still relay for an explicit Allow/Deny.
36
36
  getNoQuarter?: () => boolean;
37
+ // True when the operator launched with `--no-quarter` (env PRIVATEER_NO_QUARTER):
38
+ // a session-wide TOTAL bypass of the gate. Every request auto-approves — including
39
+ // dangerous shell, destructive tools, out-of-cwd and protected-file access — with
40
+ // no prompt, local or remote. This sits ABOVE everything else (mode, allowlist,
41
+ // the remote branch, even the dangerous-command denylist): the operator has
42
+ // explicitly opted the whole session out of the moat. Off unless the flag is set.
43
+ getSkipAllPermissions?: () => boolean;
37
44
  }
38
45
 
39
46
  // The permission gate used by the live TUI. It first applies the mode/allowlist
@@ -43,6 +50,13 @@ export class ModeGate implements PermissionGate {
43
50
  constructor(private readonly deps: ModeGateDeps) {}
44
51
 
45
52
  async request(req: PermissionRequest): Promise<PermissionDecision> {
53
+ // No-quarter: the operator launched with `--no-quarter`, opting the whole
54
+ // session out of the gate. Total bypass — auto-allow EVERY request (dangerous
55
+ // shell, destructive tools, outside-cwd, protected files) with no prompt, before
56
+ // any mode/allowlist/remote/denylist policy is consulted. Deliberately the very
57
+ // first check so nothing below can force an "ask" back on.
58
+ if (this.deps.getSkipAllPermissions?.()) return "allow";
59
+
46
60
  const denylist = this.deps.denylist ?? [];
47
61
  const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
48
62