privateer-agent 0.12.5 → 0.12.7
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/LICENSE +21 -0
- package/README.md +44 -2
- package/bin/privateer-acp.mjs +2 -2
- package/bin/privateer-harbor.mjs +2 -2
- package/bin/privateer-launch.mjs +2 -2
- package/bin/privateer.mjs +9 -2
- package/extensions/privateer-brand.ts +79 -6
- package/extensions/privateer-connect.ts +22 -2
- package/extensions/privateer-hints.ts +20 -0
- package/extensions/privateer-spawn-skills.ts +39 -0
- package/extensions/privateer-speak.ts +15 -0
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +68 -0
- package/src/acp/run.ts +1 -1
- package/src/channels/run.ts +1 -1
- package/src/cli/chat.ts +23 -8
- package/src/config/spawns.ts +187 -0
- package/src/ext/permissionGate.ts +8 -1
- package/src/harbor/ipc.ts +27 -1
- package/src/mcp/catalog.ts +67 -8
- package/src/permissions/modeGate.ts +26 -7
- package/src/providers/account.ts +5 -4
- package/src/providers/defaultModel.ts +36 -15
- package/src/providers/modelCatalog.ts +115 -0
- package/src/remote/skillsControl.ts +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hayden Bulk Tech Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -125,6 +125,10 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
125
125
|
shots stay continuous, cut them together, then score and narrate the result. Generated media
|
|
126
126
|
is handed straight back as files on your machine; none of it is stored in our cloud. See
|
|
127
127
|
[docs/media-generation.md](docs/media-generation.md).
|
|
128
|
+
- **Talk to it.** `/speak on` reads answers aloud as they're written; **ctrl+x** is push-to-talk
|
|
129
|
+
and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
|
|
130
|
+
machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
|
|
131
|
+
makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
|
|
128
132
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
129
133
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
130
134
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
@@ -288,6 +292,42 @@ with `/signout`; manage linked terminals from the app.
|
|
|
288
292
|
> to spend on your account. If someone sends you a code and asks you to approve it, don't —
|
|
289
293
|
> that hands *them* a billed session on *your* account.
|
|
290
294
|
|
|
295
|
+
## Talk to it — voice, both directions
|
|
296
|
+
|
|
297
|
+
`/speak on` and answers are read aloud **as they are written**, sentence by sentence, with
|
|
298
|
+
code blocks, tables and URLs stripped. Press **ctrl+x** and talk; the mic closes when you
|
|
299
|
+
stop talking and the transcript lands in the composer for you to read before Enter sends it.
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
/speak on read answers aloud ctrl+x push to talk (press again to send)
|
|
303
|
+
/speak voice <name> pick a speaker /talk the same thing, typed
|
|
304
|
+
/speak rate <n> 0.5–3× pace /talk loop on conversation mode, hands-free
|
|
305
|
+
/speak stream off wait for the full answer instead of speaking as it arrives
|
|
306
|
+
/speak provider list engines (→ marks the active one); /talk provider does the same
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Out of the box it uses your **OS voice** (`say`, `espeak-ng`, System.Speech) and nothing
|
|
310
|
+
leaves the machine. Signed in, both directions quietly upgrade to your account's
|
|
311
|
+
**confidential-compute TTS and STT** — attested-enclave models, the same ones the app's voice
|
|
312
|
+
features use, inherited entitlement and billing. That is an upgrade, not a stomp: a provider
|
|
313
|
+
you picked deliberately stays picked, and `/speak provider local` pins the offline voice for
|
|
314
|
+
good. Be clear-eyed about the difference — the local voice never speaks to the network, while
|
|
315
|
+
the account voice sends the utterance text to an enclave we can't read into but that is still
|
|
316
|
+
off your machine.
|
|
317
|
+
|
|
318
|
+
One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab` are honored
|
|
319
|
+
by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
|
|
320
|
+
|
|
321
|
+
Voice is **off by default and interactive-only** — harbor, ACP and channel sessions never
|
|
322
|
+
speak. The mic opens only on something you did (`/talk`, the push-to-talk key, or a
|
|
323
|
+
conversation turn you switched on); there is no wake word, capture is hard-capped, audio is
|
|
324
|
+
held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
|
|
325
|
+
every tool call it leads to hits the same permission gate as anything you type. Recording
|
|
326
|
+
needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
|
|
327
|
+
`ffmpeg` on macOS. Everything is stored in `~/.privateer/speak.json`; the engine itself is the
|
|
328
|
+
standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
|
|
329
|
+
in any Pi agent.
|
|
330
|
+
|
|
291
331
|
## The Privateer app
|
|
292
332
|
|
|
293
333
|
The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
|
|
@@ -530,8 +570,9 @@ posts a warning to the transcript, and while it's on the footer carries a perman
|
|
|
530
570
|
Toggling takes effect from the next gated action — an approval already on screen still
|
|
531
571
|
needs an answer. It's a physical-terminal switch: a phone driving this terminal over
|
|
532
572
|
`/remote-access` can't reach it. The app has its own no-quarter toggle for driven turns,
|
|
533
|
-
|
|
534
|
-
|
|
573
|
+
and it means the same thing this flag does: the moat down, dangerous shell and destructive
|
|
574
|
+
actions included — stronger than `/mode bypass`, which keeps those two above it. A hard
|
|
575
|
+
plan-mode deny is the one thing it doesn't talk around.
|
|
535
576
|
|
|
536
577
|
> shift+tab is Pi's default "cycle thinking level" chord; Privateer takes it for this.
|
|
537
578
|
> Thinking level is still under `/settings`, or bind `app.thinking.cycle` to another key
|
|
@@ -569,6 +610,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
569
610
|
| `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
|
|
570
611
|
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
|
571
612
|
| `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
|
|
613
|
+
| `/speak` · `/talk` | read answers aloud / voice input (**ctrl+x** is push-to-talk) |
|
|
572
614
|
| `/extensions` | list loaded Pi extensions |
|
|
573
615
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
574
616
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
package/bin/privateer-acp.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// process.loadEnvFile and tsx's register() are both silent; keep it that way, and
|
|
19
19
|
// send any diagnostic you add to stderr.
|
|
20
20
|
import { register } from "tsx/esm/api";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
22
|
import { dirname, resolve } from "node:path";
|
|
23
23
|
|
|
24
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -36,5 +36,5 @@ try {
|
|
|
36
36
|
process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
|
|
37
37
|
|
|
38
38
|
register();
|
|
39
|
-
const { runAcp } = await import(resolve(repo, "src/acp/run.ts"));
|
|
39
|
+
const { runAcp } = await import(pathToFileURL(resolve(repo, "src/acp/run.ts")).href);
|
|
40
40
|
await runAcp();
|
package/bin/privateer-harbor.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Invoked two ways: interactively via the bash launcher (`privateer harbor …`), and
|
|
8
8
|
// by the installed launchd/systemd service (`node privateer-harbor.mjs run`).
|
|
9
9
|
import { register } from "tsx/esm/api";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
import { dirname, resolve } from "node:path";
|
|
12
12
|
|
|
13
13
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -26,5 +26,5 @@ try {
|
|
|
26
26
|
process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
|
|
27
27
|
|
|
28
28
|
register();
|
|
29
|
-
const { runHarborCli } = await import(resolve(repo, "src/cli/harborCli.ts"));
|
|
29
|
+
const { runHarborCli } = await import(pathToFileURL(resolve(repo, "src/cli/harborCli.ts")).href);
|
|
30
30
|
await runHarborCli(process.argv.slice(2));
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -341,11 +341,11 @@ else {
|
|
|
341
341
|
const signedIn = fs.existsSync(CRED);
|
|
342
342
|
// Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
|
|
343
343
|
// file carries the measurements behind the choice.
|
|
344
|
-
const ACCOUNT_MODEL = "privateer/tinfoil/
|
|
344
|
+
const ACCOUNT_MODEL = "privateer/tinfoil/gpt-oss-120b";
|
|
345
345
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
346
346
|
? process.env.PRIVATEER_MODEL
|
|
347
347
|
: haveTinfoilKey()
|
|
348
|
-
? "tinfoil/
|
|
348
|
+
? "tinfoil/gpt-oss-120b"
|
|
349
349
|
: signedIn
|
|
350
350
|
? ACCOUNT_MODEL
|
|
351
351
|
: haveKey("ANTHROPIC_API_KEY")
|
package/bin/privateer.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// code and dev keys from the repo. Prefer the `bin/pv` wrapper, which also picks a
|
|
5
5
|
// Node >= 22 (the Pi stack's floor).
|
|
6
6
|
import { register } from "tsx/esm/api";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
8
|
import { dirname, resolve } from "node:path";
|
|
9
9
|
|
|
10
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -19,4 +19,11 @@ try {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
register(); // resolves the repo's tsx regardless of the invocation cwd
|
|
22
|
-
|
|
22
|
+
// pathToFileURL, NOT the bare path: on Windows an absolute path starts with a
|
|
23
|
+
// drive letter, and dynamic import() reads "D:\..." as the URL scheme "d:" —
|
|
24
|
+
// ERR_UNSUPPORTED_ESM_URL_SCHEME, before a single line of ours runs. POSIX
|
|
25
|
+
// absolute paths happen to work, which is exactly why this survived so long:
|
|
26
|
+
// every launcher here had it, and `--version` is intercepted upstream in
|
|
27
|
+
// privateer-launch.mjs, so the Windows smoke test booted fine while the actual
|
|
28
|
+
// command was dead. Pinned by tests/launcherImports.test.ts.
|
|
29
|
+
await import(pathToFileURL(resolve(repo, "src/cli/chat.ts")).href);
|
|
@@ -107,10 +107,80 @@ function buildMark(p: Palette): string[] {
|
|
|
107
107
|
return rows;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
110
|
+
// Code points that terminals render two cells wide. Two groups matter to us: the CJK /
|
|
111
|
+
// fullwidth ranges, and the symbols Unicode gives *emoji presentation by default* — the
|
|
112
|
+
// second group is the trap, because they look like ordinary BMP dingbats. ⚓ (U+2693) is
|
|
113
|
+
// one, and counting it as one cell pushed the banner's right border a column past the
|
|
114
|
+
// frame on the PRIVATEER.md row. The list is the complete BMP Emoji_Presentation set plus
|
|
115
|
+
// the astral emoji planes, so swapping in another glyph can't quietly reintroduce that.
|
|
116
|
+
const WIDE_RANGES: Array<[number, number]> = [
|
|
117
|
+
[0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0],
|
|
118
|
+
[0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
|
|
119
|
+
[0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5],
|
|
120
|
+
[0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
|
|
121
|
+
[0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728],
|
|
122
|
+
[0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
|
|
123
|
+
[0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
124
|
+
[0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0xa4cf], [0xa960, 0xa97f],
|
|
125
|
+
[0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60],
|
|
126
|
+
[0xffe0, 0xffe6], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff],
|
|
127
|
+
[0x1fa70, 0x1faff],
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
function isWide(cp: number): boolean {
|
|
131
|
+
return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Visible width in terminal cells: SGR escapes are free, combining marks are free, and
|
|
135
|
+
// wide glyphs cost two. A variation selector re-negotiates the *previous* glyph's
|
|
136
|
+
// presentation (FE0F → emoji/wide, FE0E → text/narrow), so it adjusts the running total
|
|
137
|
+
// rather than contributing width of its own.
|
|
112
138
|
function vlen(s: string): number {
|
|
113
|
-
|
|
139
|
+
const plain = s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
140
|
+
let w = 0;
|
|
141
|
+
let prevWide = false;
|
|
142
|
+
for (const ch of plain) {
|
|
143
|
+
const cp = ch.codePointAt(0)!;
|
|
144
|
+
if (cp === 0xfe0f) {
|
|
145
|
+
if (!prevWide) { w += 1; prevWide = true; }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (cp === 0xfe0e) {
|
|
149
|
+
if (prevWide) { w -= 1; prevWide = false; }
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Combining marks and zero-width joiners/spaces stack onto the previous cell.
|
|
153
|
+
if ((cp >= 0x0300 && cp <= 0x036f) || cp === 0x200b || cp === 0x200d || cp === 0xfeff) continue;
|
|
154
|
+
prevWide = isWide(cp);
|
|
155
|
+
w += prevWide ? 2 : 1;
|
|
156
|
+
}
|
|
157
|
+
return w;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Clip a colored line to `max` cells. The banner caps its inner width to the terminal, so
|
|
161
|
+
// on a narrow window a long row (a deep cwd, a long update notice) would otherwise run
|
|
162
|
+
// past the right border and wrap — the same broken frame, from the other direction. SGR
|
|
163
|
+
// escapes are copied through free of charge and a RESET is appended so the truncation
|
|
164
|
+
// can't leak a color into the border. A wide glyph straddling the boundary is dropped and
|
|
165
|
+
// replaced by a space, so the count still lands exactly on `max`.
|
|
166
|
+
function vclip(s: string, max: number, reset: string): string {
|
|
167
|
+
if (vlen(s) <= max) return s;
|
|
168
|
+
const parts = s.split(/(\x1b\[[0-9;]*m)/);
|
|
169
|
+
let out = "";
|
|
170
|
+
let w = 0;
|
|
171
|
+
for (const part of parts) {
|
|
172
|
+
if (part.startsWith("\x1b[")) {
|
|
173
|
+
out += part;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const ch of part) {
|
|
177
|
+
const cw = vlen(ch);
|
|
178
|
+
if (w + cw > max) return `${out}${w < max ? " " : ""}${reset}`;
|
|
179
|
+
out += ch;
|
|
180
|
+
w += cw;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return `${out}${reset}`;
|
|
114
184
|
}
|
|
115
185
|
|
|
116
186
|
// Strip ESC/C0/C1 control bytes from any value we did NOT author before it lands in a
|
|
@@ -256,8 +326,9 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
|
|
|
256
326
|
const bar = "─".repeat(inner + 2);
|
|
257
327
|
const out = [`${p.BORDER}╭${bar}╮${p.RESET}`];
|
|
258
328
|
for (const row of rows) {
|
|
259
|
-
const
|
|
260
|
-
|
|
329
|
+
const line = vclip(row, inner, p.RESET);
|
|
330
|
+
const pad = Math.max(0, inner - vlen(line));
|
|
331
|
+
out.push(`${p.BORDER}│${p.RESET} ${line}${" ".repeat(pad)} ${p.BORDER}│${p.RESET}`);
|
|
261
332
|
}
|
|
262
333
|
out.push(`${p.BORDER}╰${bar}╯${p.RESET}`);
|
|
263
334
|
return out;
|
|
@@ -267,7 +338,9 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
|
|
|
267
338
|
// provider so the account line reflects the picked model, and the live theme so every
|
|
268
339
|
// colour tracks the terminal background (dark ink on light, light ink on dark). The
|
|
269
340
|
// palette and mark are resolved once here (per theme), not per frame.
|
|
270
|
-
|
|
341
|
+
// Exported for tests/banner.test.ts, which renders the real banner and checks the frame
|
|
342
|
+
// is square — the failure mode is a one-cell drift no typecheck can catch.
|
|
343
|
+
export function headerComponent(theme: any, modelProvider?: string) {
|
|
271
344
|
const p = paletteFor(theme);
|
|
272
345
|
const mark = buildMark(p);
|
|
273
346
|
return {
|
|
@@ -297,6 +297,20 @@ function pendingFromCatalog(e: CatalogEntry): Pending {
|
|
|
297
297
|
hint: `Replaces the placeholder ${e.fill}`,
|
|
298
298
|
initial: e.fill,
|
|
299
299
|
});
|
|
300
|
+
} else if (e.needs === "url") {
|
|
301
|
+
// A server hosted by an app already running HERE. Nothing to paste, but the
|
|
302
|
+
// endpoint is the one thing that can differ per machine (Unreal's port and path
|
|
303
|
+
// are editable in Editor Preferences), and a wrong port is a connector that
|
|
304
|
+
// saves cleanly and never answers — the failure this catalog exists to avoid.
|
|
305
|
+
// Pre-filled with the documented default, so <enter> is the common case.
|
|
306
|
+
steps.push({
|
|
307
|
+
key: "url",
|
|
308
|
+
prompt: "Server URL",
|
|
309
|
+
hint: `Default is ${e.url} — change it only if you moved it.`,
|
|
310
|
+
initial: e.url,
|
|
311
|
+
validate: (v) =>
|
|
312
|
+
/^https?:\/\//i.test(v.trim()) ? undefined : "That needs to be an http:// or https:// URL.",
|
|
313
|
+
});
|
|
300
314
|
}
|
|
301
315
|
return {
|
|
302
316
|
title: e.label,
|
|
@@ -307,12 +321,17 @@ function pendingFromCatalog(e: CatalogEntry): Pending {
|
|
|
307
321
|
for (const [k, v] of Object.entries(answers)) {
|
|
308
322
|
if (k.startsWith("env:")) env[k.slice(4)] = v;
|
|
309
323
|
}
|
|
310
|
-
return draftFromCatalog(e, { env, fill: answers.fill });
|
|
324
|
+
return draftFromCatalog(e, { env, fill: answers.fill, url: answers.url });
|
|
311
325
|
},
|
|
312
326
|
note:
|
|
313
327
|
e.needs === "oauth"
|
|
314
328
|
? `Authorize it in a browser on THIS machine: /mcp-auth ${e.name}`
|
|
315
|
-
:
|
|
329
|
+
: e.localHttp
|
|
330
|
+
// Not a warning — a localHttp connector is correct the moment it saves. But
|
|
331
|
+
// it answers only while its host app is up, so a later failure means "the
|
|
332
|
+
// app is closed", and saying that now beats debugging it then.
|
|
333
|
+
? `Works whenever ${e.label} is running on this machine. Nothing to authorize.`
|
|
334
|
+
: undefined,
|
|
316
335
|
};
|
|
317
336
|
}
|
|
318
337
|
|
|
@@ -329,6 +348,7 @@ const NEEDS_LABEL: Record<string, string> = {
|
|
|
329
348
|
token: "needs a token",
|
|
330
349
|
path: "needs a path",
|
|
331
350
|
oauth: "browser sign-in",
|
|
351
|
+
url: "confirm the URL",
|
|
332
352
|
none: "no setup",
|
|
333
353
|
};
|
|
334
354
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// Pi never reads that file) — read-modify-write, preserving unrelated keys.
|
|
23
23
|
|
|
24
24
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
25
26
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
26
27
|
import { configPath, globalDir } from "../src/config/paths.ts";
|
|
27
28
|
|
|
@@ -38,6 +39,19 @@ function key(binding: Parameters<typeof keyText>[0], fallback: string): string {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Push-to-talk is pi-speak's own binding, not a Pi keybinding action, so keyText
|
|
43
|
+
// can't see it — it lives in speak.json beside config.json and /talk key rebinds
|
|
44
|
+
// it. Read it the same defensive way (any failure = the default).
|
|
45
|
+
function talkKey(): string {
|
|
46
|
+
try {
|
|
47
|
+
const shortcut = JSON.parse(readFileSync(join(globalDir(), "speak.json"), "utf8"))?.input?.shortcut;
|
|
48
|
+
// An explicit "" is "unbound", not "missing" — only an absent field defaults.
|
|
49
|
+
return typeof shortcut === "string" ? shortcut : "ctrl+x";
|
|
50
|
+
} catch {
|
|
51
|
+
return "ctrl+x";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
// Lazy thunks, not strings: keys resolve at display time, after the app has
|
|
42
56
|
// loaded (and possibly remapped) its keybindings.
|
|
43
57
|
const HINTS: Array<() => string> = [
|
|
@@ -50,6 +64,12 @@ const HINTS: Array<() => string> = [
|
|
|
50
64
|
() => `/init writes a PRIVATEER.md so the agent knows this project`,
|
|
51
65
|
() => `/connect adds MCP connectors to this terminal`,
|
|
52
66
|
() => `${key("app.editor.external", "ctrl+g")} drafts long prompts in your $EDITOR`,
|
|
67
|
+
() => {
|
|
68
|
+
const k = talkKey();
|
|
69
|
+
return k
|
|
70
|
+
? `${k} is push-to-talk — /speak on reads the answer back`
|
|
71
|
+
: `/talk types what you say — /speak on reads the answer back`;
|
|
72
|
+
},
|
|
53
73
|
() => `these tips are /hints — /hints off silences them`,
|
|
54
74
|
];
|
|
55
75
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Per-spawn skills — the skills a folder carries, without touching the folder.
|
|
2
|
+
//
|
|
3
|
+
// Pi discovers project skills at `<cwd>/.privateer/skills` (and `<cwd>/.pi/skills`),
|
|
4
|
+
// resolved lexically from cwd with no override — see projectConfigDirCandidates. That
|
|
5
|
+
// is the right home for skills a TEAM shares through the repo, and the wrong one for
|
|
6
|
+
// "the skills I use in this folder on this machine": giving a folder its own skills
|
|
7
|
+
// would mean writing into the user's tree, where it lands in their next `git status`.
|
|
8
|
+
//
|
|
9
|
+
// So per-spawn skills live under the global dir with that folder's other defaults
|
|
10
|
+
// (src/config/spawns.ts), and this extension hands the directory to Pi through the
|
|
11
|
+
// `resources_discover` hook — the supported way to contribute skill paths. Nothing is
|
|
12
|
+
// patched, and the skills are loaded by the session itself, so the model can actually
|
|
13
|
+
// invoke them rather than merely having them listed in the app's Skills manager.
|
|
14
|
+
//
|
|
15
|
+
// Note this contributes PATHS, not skills: Pi still owns discovery, precedence and
|
|
16
|
+
// the disable-model-invocation flag inside them.
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { spawnSkillsDir } from "../src/config/spawns.ts";
|
|
20
|
+
|
|
21
|
+
export default function privateerSpawnSkills(pi: any): void {
|
|
22
|
+
pi.on("resources_discover", (event: any) => {
|
|
23
|
+
// `cwd` comes from the event rather than a captured value: the desktop rebuilds
|
|
24
|
+
// its session on a folder switch, and a stale closure would keep feeding the old
|
|
25
|
+
// folder's skills to the new one.
|
|
26
|
+
const cwd = event?.cwd ?? process.cwd();
|
|
27
|
+
let dir: string;
|
|
28
|
+
try {
|
|
29
|
+
dir = spawnSkillsDir(cwd);
|
|
30
|
+
} catch {
|
|
31
|
+
return; // no global dir (PRIVATEER_HOME unset in a stripped environment)
|
|
32
|
+
}
|
|
33
|
+
// Only offer a directory that exists. Pi tolerates a missing path, but an empty
|
|
34
|
+
// contribution is also how `emitResourcesDiscover` decides there is nothing to
|
|
35
|
+
// reload — so staying silent keeps a spawn with no skills of its own free.
|
|
36
|
+
if (!existsSync(dir)) return;
|
|
37
|
+
return { skillPaths: [dir] };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
// confidential STT (/api/audio/transcribe, same tinfoil/near routing the app's voice
|
|
6
6
|
// features use). Both inherit the account's entitlement, caps and billing.
|
|
7
7
|
//
|
|
8
|
+
// STREAMING, AND WHAT IT COSTS. Since pi-speak 0.2.0 a turn is spoken while it is still
|
|
9
|
+
// being written: the stream is cut at sentence boundaries and each piece is synthesized
|
|
10
|
+
// a clip or two ahead of playback. For this provider that means SEVERAL /api/audio/speech
|
|
11
|
+
// calls per answer instead of one — same characters, same billing basis, more round
|
|
12
|
+
// trips — which is the trade that makes the reply start in about the time one sentence
|
|
13
|
+
// takes to speak. /speak stream off puts it back to one call at the end of the turn.
|
|
14
|
+
//
|
|
15
|
+
// NOT WIRED, DELIBERATELY. pi-speak passes two hints this account path has nowhere to
|
|
16
|
+
// put: `rate` (0.5–3× pace) and the transcriber's vocabulary `prompt`. /api/audio/speech
|
|
17
|
+
// takes { text, voice, format, ttsModelId } and /api/audio/transcribe takes
|
|
18
|
+
// { audioBase64, format, language, sttModelId } — neither has a speed or bias field — so
|
|
19
|
+
// sending them would be dead weight on the wire (and, for the vocabulary hint, project
|
|
20
|
+
// nouns leaving the machine to be ignored). They stay unsent until the endpoints grow
|
|
21
|
+
// them; both work today on the local and openai-compatible providers.
|
|
22
|
+
//
|
|
8
23
|
// PROVIDER ATTUNEMENT, NOT STOMPING. The provider registers with preferWhen: signed-in,
|
|
9
24
|
// which the registry ranks ABOVE the built-in local voice but BELOW a deliberate /speak
|
|
10
25
|
// provider pick — sign in and speech quietly upgrades from the OS voice to confidential
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.7",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"pi-privacy": "^0.11.0",
|
|
85
85
|
"pi-subagents": "^0.34.0",
|
|
86
86
|
"picomatch": "^4.0.4",
|
|
87
|
-
"privateer-speak": "0.
|
|
87
|
+
"privateer-speak": "0.2.0",
|
|
88
88
|
"privateer-workflow": "^0.1.0",
|
|
89
89
|
"tinfoil": "^1.1.11",
|
|
90
90
|
"tsx": "^4.16.0",
|
|
@@ -1055,3 +1055,71 @@ index f81b4b4..9712a37 100644
|
|
|
1055
1055
|
-a, --approve Trust project-local files for this command
|
|
1056
1056
|
-na, --no-approve Ignore project-local files for this command
|
|
1057
1057
|
|
|
1058
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
1059
|
+
index 68d0308..f7208f1 100644
|
|
1060
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
1061
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
1062
|
+
@@ -1,5 +1,5 @@
|
|
1063
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
1064
|
+
-import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
1065
|
+
+import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
1066
|
+
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
|
|
1067
|
+
import { theme } from "../theme/theme.js";
|
|
1068
|
+
/**
|
|
1069
|
+
@@ -13,6 +13,43 @@ function sanitizeStatusText(text) {
|
|
1070
|
+
.replace(/ +/g, " ")
|
|
1071
|
+
.trim();
|
|
1072
|
+
}
|
|
1073
|
+
+/**
|
|
1074
|
+
+ * Lay extension statuses out over as many lines as the terminal needs, keeping
|
|
1075
|
+
+ * each status whole. Upstream joined them into ONE line and truncated it to the
|
|
1076
|
+
+ * width, which silently hid whatever didn't fit — and what didn't fit was often
|
|
1077
|
+
+ * the loudest indicator on screen (Privateer's "permission gate OFF" flag). A
|
|
1078
|
+
+ * status too wide to fit on a line of its own is wrapped, never clipped.
|
|
1079
|
+
+ */
|
|
1080
|
+
+function packStatusLines(statuses, width) {
|
|
1081
|
+
+ if (width <= 0)
|
|
1082
|
+
+ return [];
|
|
1083
|
+
+ const lines = [];
|
|
1084
|
+
+ let current = "";
|
|
1085
|
+
+ let currentWidth = 0;
|
|
1086
|
+
+ const flush = () => {
|
|
1087
|
+
+ if (current)
|
|
1088
|
+
+ lines.push(current);
|
|
1089
|
+
+ current = "";
|
|
1090
|
+
+ currentWidth = 0;
|
|
1091
|
+
+ };
|
|
1092
|
+
+ for (const status of statuses) {
|
|
1093
|
+
+ if (!status)
|
|
1094
|
+
+ continue;
|
|
1095
|
+
+ const statusWidth = visibleWidth(status);
|
|
1096
|
+
+ if (statusWidth > width) {
|
|
1097
|
+
+ flush();
|
|
1098
|
+
+ lines.push(...wrapTextWithAnsi(status, width));
|
|
1099
|
+
+ continue;
|
|
1100
|
+
+ }
|
|
1101
|
+
+ const separator = current ? 1 : 0;
|
|
1102
|
+
+ if (currentWidth + separator + statusWidth > width)
|
|
1103
|
+
+ flush();
|
|
1104
|
+
+ current = current ? `${current} ${status}` : status;
|
|
1105
|
+
+ currentWidth += separator + statusWidth;
|
|
1106
|
+
+ }
|
|
1107
|
+
+ flush();
|
|
1108
|
+
+ return lines;
|
|
1109
|
+
+}
|
|
1110
|
+
/**
|
|
1111
|
+
* Format token counts for compact footer display.
|
|
1112
|
+
*/
|
|
1113
|
+
@@ -211,9 +248,9 @@ export class FooterComponent {
|
|
1114
|
+
const sortedStatuses = Array.from(extensionStatuses.entries())
|
|
1115
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1116
|
+
.map(([, text]) => sanitizeStatusText(text));
|
|
1117
|
+
- const statusLine = sortedStatuses.join(" ");
|
|
1118
|
+
- // Truncate to terminal width with dim ellipsis for consistency with footer style
|
|
1119
|
+
- lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
1120
|
+
+ // Wrap onto extra lines instead of truncating: a status is there because
|
|
1121
|
+
+ // something wants to be seen, so none of them may be cut off.
|
|
1122
|
+
+ lines.push(...packStatusLines(sortedStatuses, width));
|
|
1123
|
+
}
|
|
1124
|
+
return lines;
|
|
1125
|
+
}
|
package/src/acp/run.ts
CHANGED
|
@@ -125,7 +125,7 @@ export async function runAcp(): Promise<void> {
|
|
|
125
125
|
cwd: baseCwd,
|
|
126
126
|
confineToCwd: true,
|
|
127
127
|
getRemote: () => true,
|
|
128
|
-
|
|
128
|
+
getAutoApprove: () => posture === "auto",
|
|
129
129
|
async localAsk() {
|
|
130
130
|
return "deny";
|
|
131
131
|
},
|
package/src/channels/run.ts
CHANGED
package/src/cli/chat.ts
CHANGED
|
@@ -47,6 +47,7 @@ async function main() {
|
|
|
47
47
|
dropPersistedAccountCredential,
|
|
48
48
|
} = await import("../providers/account.ts");
|
|
49
49
|
const { agentVersion } = await import("../config/version.ts");
|
|
50
|
+
const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
|
|
50
51
|
const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
|
|
51
52
|
const { postOutbox } = await import("../outbox/cloudOutbox.ts");
|
|
52
53
|
const { addPendingCloud } = await import("../routines/store.ts");
|
|
@@ -625,11 +626,14 @@ async function main() {
|
|
|
625
626
|
console.log(`${DIM}shift+tab toggles no quarter — unattended mode, no approval prompts.${RESET}`);
|
|
626
627
|
await showPosture();
|
|
627
628
|
|
|
628
|
-
// The available model catalog as sorted "provider/id" specs
|
|
629
|
-
// /models list and the app's picker
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
629
|
+
// The available model catalog as sorted "provider/id" specs, plus what the
|
|
630
|
+
// registry had to leave out. Same source the /models list and the app's picker
|
|
631
|
+
// draw from — see providers/modelCatalog.ts for the two things that make this
|
|
632
|
+
// more than a map+sort (sealed models register late; the whole account catalog
|
|
633
|
+
// is filtered out until the credential is armed).
|
|
634
|
+
const SIGN_IN_HINT = "Run /login.";
|
|
635
|
+
async function modelCatalog() {
|
|
636
|
+
return pickerCatalog(services.modelRegistry as any);
|
|
633
637
|
}
|
|
634
638
|
|
|
635
639
|
// Switch the live session's model in place (history preserved — see
|
|
@@ -660,9 +664,17 @@ async function main() {
|
|
|
660
664
|
// as a selection prompt and switch to whatever the driver picks. This is the
|
|
661
665
|
// remote /model flow — the terminal owns the options, the app just renders them.
|
|
662
666
|
async function pickModelRemote(filter: string): Promise<void> {
|
|
663
|
-
const
|
|
667
|
+
const cat = await modelCatalog();
|
|
668
|
+
const specs = cat.specs.filter((sp) => !filter || sp.toLowerCase().includes(filter));
|
|
669
|
+
// Say why the list is short BEFORE opening it — a driver looking at a catalog
|
|
670
|
+
// with no confidential models has no way to tell "we don't carry them" from
|
|
671
|
+
// "this terminal is signed out", and only one of those has a fix.
|
|
672
|
+
const notice = hiddenAccountNotice(cat, SIGN_IN_HINT);
|
|
673
|
+
if (notice) relay?.sendNotice(notice);
|
|
664
674
|
const choice = await bridge.selectRemote({
|
|
665
|
-
|
|
675
|
+
// The suffix puts the same fact in the sheet itself, where the driver is
|
|
676
|
+
// actually looking. Terminal chrome, so English like the rest of the title.
|
|
677
|
+
title: `Choose a model${hiddenAccountTitleSuffix(cat)}`,
|
|
666
678
|
options: specs.map((sp) => ({ value: sp, label: sp })),
|
|
667
679
|
current: currentSpec,
|
|
668
680
|
});
|
|
@@ -689,8 +701,11 @@ async function main() {
|
|
|
689
701
|
if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
|
|
690
702
|
const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
|
|
691
703
|
if (remote) { await pickModelRemote(filter); return true; }
|
|
692
|
-
const
|
|
704
|
+
const cat = await modelCatalog();
|
|
705
|
+
const rows = cat.specs.filter((sp) => !filter || sp.toLowerCase().includes(filter));
|
|
693
706
|
console.log(rows.slice(0, 40).join("\n") + (rows.length > 40 ? `\n${DIM}… ${rows.length - 40} more (try /models <filter>)${RESET}` : ""));
|
|
707
|
+
const hidden = hiddenAccountNotice(cat, SIGN_IN_HINT);
|
|
708
|
+
if (hidden) console.log(`${DIM}${hidden}${RESET}`);
|
|
694
709
|
return true;
|
|
695
710
|
}
|
|
696
711
|
// Extensions: remote drives the app's manager (list frame); local prints the list.
|