privateer-agent 0.14.1 → 0.14.3
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 +6 -2
- package/extensions/privateer-speak.ts +5 -1
- package/extensions/privateer-truncation.ts +43 -0
- package/package.json +2 -2
- package/src/auth/accountSessions.ts +116 -60
- package/src/auth/privateer.ts +10 -2
- package/src/config/moatManifest.json +1 -0
- package/src/config/paths.ts +12 -0
- package/src/config/whatsNew.json +4 -4
- package/src/engine/errors.ts +1 -1
- package/src/engine/truncationRecovery.ts +91 -0
- package/src/providers/account.ts +71 -17
- package/src/terminals/backends.ts +187 -10
- package/src/terminals/index.ts +23 -0
- package/src/tools/media.ts +12 -2
- package/src/tools/terminal.ts +2 -1
- package/src/tools/web.ts +17 -2
package/README.md
CHANGED
|
@@ -402,8 +402,12 @@ including a new agent the moment its window opens in front. The window you left
|
|
|
402
402
|
its mic. (That's [privateer-speak](https://www.npmjs.com/package/privateer-speak)'s focus
|
|
403
403
|
awareness; it needs a terminal that reports focus.)
|
|
404
404
|
|
|
405
|
-
Works in **macOS Terminal**, **iTerm2**,
|
|
406
|
-
get told so. macOS may ask once for permission
|
|
405
|
+
Works in **macOS Terminal**, **iTerm2**, **Git Bash's own window (mintty)** on Windows, and
|
|
406
|
+
inside **tmux** on any platform; other terminals get told so. macOS may ask once for permission
|
|
407
|
+
to let the terminal control itself. In Git Bash, new windows are mintty windows like the one
|
|
408
|
+
you started in, and since mintty doesn't tell the program inside when it gains or loses focus,
|
|
409
|
+
Privateer watches which window Windows has in front (a small PowerShell loop, one per window)
|
|
410
|
+
so the voice still follows you. Names are
|
|
407
411
|
shared across every Privateer on the machine (`~/.privateer/terminals.json`), and a window
|
|
408
412
|
that's been closed drops off the list on its own.
|
|
409
413
|
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
} from "privateer-speak";
|
|
62
62
|
import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
|
|
63
63
|
import { globalDir } from "../src/config/paths.ts";
|
|
64
|
-
import { thisTerminalIsFront } from "../src/terminals/index.ts";
|
|
64
|
+
import { thisTerminalIsFront, watchThisTerminalFocus } from "../src/terminals/index.ts";
|
|
65
65
|
import { VOICE_DESCRIPTIONS } from "./speak-voices.data.ts";
|
|
66
66
|
|
|
67
67
|
const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
|
|
@@ -362,10 +362,14 @@ export default function privateerSpeak(pi: any): void {
|
|
|
362
362
|
// isFocused: a window that has just started (say, one the agent opened with the
|
|
363
363
|
// terminal tool) asks Terminal/iTerm2 whether it's in front, so a hands-free mode
|
|
364
364
|
// carries on in the new agent without a keypress — see src/terminals/.
|
|
365
|
+
// watchFocus: Git Bash's window (mintty) never tells the program inside when it gains
|
|
366
|
+
// or loses focus, so there Windows is watched instead, or the mic wouldn't follow you
|
|
367
|
+
// between windows.
|
|
365
368
|
makePiSpeakExtension({
|
|
366
369
|
configFile: join(globalDir(), "speak.json"),
|
|
367
370
|
voiceCommands: VOICE_COMMANDS,
|
|
368
371
|
isFocused: thisTerminalIsFront,
|
|
372
|
+
watchFocus: watchThisTerminalFocus,
|
|
369
373
|
})(pi);
|
|
370
374
|
}
|
|
371
375
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Keep the agent going when a reply is cut off at the output limit.
|
|
2
|
+
//
|
|
3
|
+
// A run that ends on a full-budget length stop ("Response was truncated before
|
|
4
|
+
// completion.") just stops in stock pi — the agent abandons the task until the user
|
|
5
|
+
// types "continue". This queues that follow-up for them, at most MAX_AUTO_CONTINUES
|
|
6
|
+
// times in a row. The decision, and why it is shaped this way, lives in
|
|
7
|
+
// src/engine/truncationRecovery.ts.
|
|
8
|
+
|
|
9
|
+
import { MAX_AUTO_CONTINUES, decideTruncationRecovery } from "../src/engine/truncationRecovery.ts";
|
|
10
|
+
|
|
11
|
+
export default function privateerTruncation(pi: any): void {
|
|
12
|
+
let consecutive = 0;
|
|
13
|
+
|
|
14
|
+
// Something the user typed starts a fresh count: giving up once must not disarm
|
|
15
|
+
// recovery for the rest of the session.
|
|
16
|
+
pi.on("input", () => {
|
|
17
|
+
consecutive = 0;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
pi.on("agent_end", (ev: any, ctx: any) => {
|
|
21
|
+
const decision = decideTruncationRecovery(ev?.messages, Number(ctx?.model?.maxTokens) || 0, consecutive);
|
|
22
|
+
if (decision.action === "none") {
|
|
23
|
+
consecutive = 0;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (decision.action === "give-up") {
|
|
27
|
+
ctx?.ui?.notify?.(
|
|
28
|
+
`The reply was cut off at the output limit ${MAX_AUTO_CONTINUES + 1} times in a row — stopped. ` +
|
|
29
|
+
`Type "continue" to try again, lower the thinking level (shift+tab), or pick another model.`,
|
|
30
|
+
"warning",
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
consecutive = decision.attempt;
|
|
35
|
+
ctx?.ui?.notify?.(`Reply cut off at the output limit — continuing (${decision.attempt}/${MAX_AUTO_CONTINUES}).`, "info");
|
|
36
|
+
// Queued from agent_end while the run is still active, so pi continues it in the
|
|
37
|
+
// same run (agent-session _handlePostAgentRun) instead of waiting for the user.
|
|
38
|
+
pi.sendMessage(
|
|
39
|
+
{ customType: "privateer-truncation", content: decision.prompt, display: true, details: { attempt: decision.attempt } },
|
|
40
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
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",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"pi-privacy": "0.14.1",
|
|
88
88
|
"pi-subagents": "0.70.0",
|
|
89
89
|
"picomatch": "4.0.5",
|
|
90
|
-
"privateer-speak": "0.
|
|
90
|
+
"privateer-speak": "0.8.0",
|
|
91
91
|
"privateer-workflow": "0.1.0",
|
|
92
92
|
"tinfoil": "1.1.11",
|
|
93
93
|
"tsx": "4.23.0",
|
|
@@ -21,21 +21,50 @@
|
|
|
21
21
|
// RECYCLED pid makes a dead owner look alive, so we skip a reclaimable session and
|
|
22
22
|
// spawn a fresh one — the old behaviour, no harm. The dangerous direction (a live
|
|
23
23
|
// process reported dead) can't happen: a running pid never reports ESRCH.
|
|
24
|
+
//
|
|
25
|
+
// LAYOUT — one file per owning pid (`<pid>.json` in accountSessionsDir), and an orphan
|
|
26
|
+
// is CLAIMED by renaming its file before anyone rotates it. This used to be a single
|
|
27
|
+
// JSON file that every process read, modified and wrote back whole, and that was a real
|
|
28
|
+
// outage under concurrency (an agent firing several `privateer -p` one-shots at once):
|
|
29
|
+
//
|
|
30
|
+
// A reads {orphan: R} · B reads {orphan: R} · B adopts the orphan (R → R′) and
|
|
31
|
+
// writes it out · A writes ITS stale copy back, orphan entry and all.
|
|
32
|
+
//
|
|
33
|
+
// The resurrected entry names a refresh token that was already rotated. The next launch
|
|
34
|
+
// "reclaims" it, the server sees a spent token replayed past its 30s grace window, calls
|
|
35
|
+
// it theft, and revokes the whole lineage — which is the session B (or whoever adopted
|
|
36
|
+
// down the chain) is using right now, and possibly the one in the shared auth.json that
|
|
37
|
+
// other running terminals read. They all 401 mid-run, and a one-shot run has no next
|
|
38
|
+
// message to recover on, so the user is told to /login while signed in.
|
|
39
|
+
//
|
|
40
|
+
// Per-pid files mean no process ever writes another's entry, and rename is atomic on
|
|
41
|
+
// every platform we ship on, so exactly one claimer wins an orphan and the rest get
|
|
42
|
+
// ENOENT and move on. A claim whose claimant died is deleted rather than released: its
|
|
43
|
+
// token may already have been rotated, and offering it again is the same hazard.
|
|
24
44
|
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
26
|
-
import {
|
|
27
|
-
import { accountSessionsPath, globalDir } from "../config/paths.ts";
|
|
45
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import { accountSessionsDir, accountSessionsPath, globalDir } from "../config/paths.ts";
|
|
28
48
|
|
|
29
|
-
// One spawned session, keyed
|
|
30
|
-
//
|
|
31
|
-
//
|
|
49
|
+
// One spawned session, keyed by the pid of its owning terminal. `refresh` is what lets
|
|
50
|
+
// a later launch adopt or revoke it; `expires` is its access token's exp (see
|
|
51
|
+
// jwtExpMs), used only to prune entries that are dead server-side anyway.
|
|
32
52
|
export interface OwnedSession {
|
|
33
53
|
pid: number;
|
|
34
54
|
refresh: string;
|
|
35
55
|
expires: number;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
const ENTRY = /^(\d+)\.json$/;
|
|
59
|
+
const CLAIM = /^(\d+)\.claim-(\d+)$/;
|
|
60
|
+
|
|
61
|
+
function entryPath(pid: number): string {
|
|
62
|
+
return join(accountSessionsDir(), `${pid}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function claimPath(pid: number, claimant = process.pid): string {
|
|
66
|
+
return join(accountSessionsDir(), `${pid}.claim-${claimant}`);
|
|
67
|
+
}
|
|
39
68
|
|
|
40
69
|
function tryChmod(path: string, mode: number): void {
|
|
41
70
|
try {
|
|
@@ -45,31 +74,21 @@ function tryChmod(path: string, mode: number): void {
|
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
|
|
48
|
-
function
|
|
49
|
-
const path = accountSessionsPath();
|
|
50
|
-
if (!existsSync(path)) return {};
|
|
77
|
+
function tryRm(path: string): void {
|
|
51
78
|
try {
|
|
52
|
-
|
|
53
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Registry) : {};
|
|
79
|
+
rmSync(path, { force: true });
|
|
54
80
|
} catch {
|
|
55
|
-
|
|
81
|
+
/* already gone, or unremovable — nothing more to do */
|
|
56
82
|
}
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
|
|
60
|
-
// terminals racing can still lose one entry (last writer wins); the cost is one
|
|
61
|
-
// unreclaimable orphan, not a broken launch, so a lock file isn't worth it here.
|
|
62
|
-
function writeRegistry(reg: Registry): void {
|
|
63
|
-
const path = accountSessionsPath();
|
|
85
|
+
function readEntry(path: string, pid: number): OwnedSession | null {
|
|
64
86
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
69
|
-
tryChmod(tmp, 0o600);
|
|
70
|
-
renameSync(tmp, path);
|
|
87
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as { refresh?: unknown; expires?: unknown };
|
|
88
|
+
if (typeof raw?.refresh !== "string" || !raw.refresh) return null;
|
|
89
|
+
return { pid, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
|
|
71
90
|
} catch {
|
|
72
|
-
|
|
91
|
+
return null; // corrupt/truncated — treated as malformed and pruned
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
|
|
@@ -86,72 +105,109 @@ function isAlive(pid: number): boolean {
|
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
107
|
|
|
89
|
-
function parseEntry(pid: string, raw: { refresh?: unknown; expires?: unknown }): OwnedSession | null {
|
|
90
|
-
const n = Number(pid);
|
|
91
|
-
if (!Number.isInteger(n) || typeof raw?.refresh !== "string" || !raw.refresh) return null;
|
|
92
|
-
return { pid: n, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
108
|
// Claim (or re-claim) a session for THIS process. Called wherever the account
|
|
96
109
|
// credential is minted or rotated — spawnAccountCredentials and
|
|
97
110
|
// refreshAccountCredentials — so the registry always holds the token that would
|
|
98
|
-
// actually work, including the rotations Pi drives on its own.
|
|
111
|
+
// actually work, including the rotations Pi drives on its own. Written via temp +
|
|
112
|
+
// rename so a concurrent reader never sees a half-written file.
|
|
99
113
|
export function recordOwnedSession(cred: { refresh: string; expires: number }): void {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(accountSessionsDir(), { recursive: true });
|
|
116
|
+
tryChmod(globalDir(), 0o700);
|
|
117
|
+
tryChmod(accountSessionsDir(), 0o700);
|
|
118
|
+
const path = entryPath(process.pid);
|
|
119
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
120
|
+
writeFileSync(tmp, JSON.stringify({ refresh: cred.refresh, expires: cred.expires }) + "\n", {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
mode: 0o600,
|
|
123
|
+
});
|
|
124
|
+
tryChmod(tmp, 0o600);
|
|
125
|
+
renameSync(tmp, path);
|
|
126
|
+
} catch {
|
|
127
|
+
/* best effort — losing the registry costs reclamation, never correctness */
|
|
128
|
+
}
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
// Drop this process's entry — the session is being revoked (clean exit, /signout), so
|
|
106
132
|
// it is about to stop existing server-side. Leaving it behind would advertise a dead
|
|
107
133
|
// session as a reclaimable orphan to the next launch.
|
|
108
134
|
export function forgetOwnedSession(): void {
|
|
109
|
-
|
|
110
|
-
if (!(String(process.pid) in reg)) return;
|
|
111
|
-
delete reg[String(process.pid)];
|
|
112
|
-
writeRegistry(reg);
|
|
135
|
+
tryRm(entryPath(process.pid));
|
|
113
136
|
}
|
|
114
137
|
|
|
115
|
-
// Sessions whose owning terminal is gone: candidates to adopt or revoke.
|
|
116
|
-
//
|
|
117
|
-
//
|
|
138
|
+
// Sessions whose owning terminal is gone: candidates to adopt or revoke. Listing is NOT
|
|
139
|
+
// claiming — call claimOrphan before rotating one. Prunes entries that are unusable
|
|
140
|
+
// anyway (malformed, or past their expiry) and claims left by a dead claimant, so the
|
|
141
|
+
// directory can't grow without bound. Our own pid is never a candidate.
|
|
118
142
|
export function orphanedSessions(now = Date.now()): OwnedSession[] {
|
|
119
|
-
|
|
143
|
+
let names: string[];
|
|
144
|
+
try {
|
|
145
|
+
names = readdirSync(accountSessionsDir());
|
|
146
|
+
} catch {
|
|
147
|
+
return []; // no registry yet
|
|
148
|
+
}
|
|
120
149
|
const orphans: OwnedSession[] = [];
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
const path = join(accountSessionsDir(), name);
|
|
152
|
+
const claim = CLAIM.exec(name);
|
|
153
|
+
if (claim) {
|
|
154
|
+
// Mid-adoption by a live process: theirs. Claimant died: its token may already
|
|
155
|
+
// be rotated, so it is junk — never put back up for reclamation.
|
|
156
|
+
if (!isAlive(Number(claim[2]))) tryRm(path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const m = ENTRY.exec(name);
|
|
160
|
+
if (!m) continue; // a temp file mid-write, or something that isn't ours
|
|
161
|
+
const pid = Number(m[1]);
|
|
162
|
+
if (pid === process.pid || isAlive(pid)) continue; // ours, or a live terminal's
|
|
163
|
+
const entry = readEntry(path, pid);
|
|
125
164
|
if (!entry || (entry.expires > 0 && entry.expires <= now)) {
|
|
126
|
-
|
|
127
|
-
pruned = true;
|
|
165
|
+
tryRm(path); // malformed, or dead server-side — nothing to reclaim
|
|
128
166
|
continue;
|
|
129
167
|
}
|
|
130
|
-
if (entry.pid === process.pid || isAlive(entry.pid)) continue; // ours, or a live terminal's
|
|
131
168
|
orphans.push(entry);
|
|
132
169
|
}
|
|
133
|
-
|
|
134
|
-
if (pruned) writeRegistry(reg);
|
|
135
170
|
return orphans;
|
|
136
171
|
}
|
|
137
172
|
|
|
173
|
+
// Take an orphan for THIS process, atomically. True only for the one caller whose rename
|
|
174
|
+
// landed; everyone else racing for the same orphan gets false and must leave it alone.
|
|
175
|
+
// Pair with dropOwnedSession (handled) or releaseOrphan (couldn't reach the server).
|
|
176
|
+
export function claimOrphan(pid: number): boolean {
|
|
177
|
+
try {
|
|
178
|
+
renameSync(entryPath(pid), claimPath(pid));
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Put back an orphan we claimed but couldn't settle: its refresh never reached the
|
|
186
|
+
// server, so the token is still the live one and the row is still worth reclaiming.
|
|
187
|
+
export function releaseOrphan(pid: number): void {
|
|
188
|
+
try {
|
|
189
|
+
renameSync(claimPath(pid), entryPath(pid));
|
|
190
|
+
} catch {
|
|
191
|
+
tryRm(claimPath(pid));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
138
195
|
// Forget one orphan, once it has been definitively handled (adopted, or confirmed dead
|
|
139
|
-
// server-side).
|
|
140
|
-
//
|
|
196
|
+
// server-side). An entry whose refresh merely FAILED TO REACH the server goes back via
|
|
197
|
+
// releaseOrphan instead: dropping it on a network blip would leak that row until its TTL.
|
|
141
198
|
export function dropOwnedSession(pid: number): void {
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
delete reg[String(pid)];
|
|
145
|
-
writeRegistry(reg);
|
|
199
|
+
tryRm(claimPath(pid));
|
|
200
|
+
if (pid !== process.pid) tryRm(entryPath(pid));
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
// Wipe the registry. Used by logout(), where the server has just revoked this
|
|
149
204
|
// machine's whole token family: every entry now names a dead session, and leaving
|
|
150
205
|
// them behind would offer the next login a list of orphans to "reclaim" that can
|
|
151
|
-
// only fail. Also a test seam.
|
|
206
|
+
// only fail. Also removes the legacy single-file registry. Also a test seam.
|
|
152
207
|
export function clearOwnedSessions(): void {
|
|
208
|
+
tryRm(accountSessionsPath());
|
|
153
209
|
try {
|
|
154
|
-
rmSync(
|
|
210
|
+
if (existsSync(accountSessionsDir())) rmSync(accountSessionsDir(), { recursive: true, force: true });
|
|
155
211
|
} catch {
|
|
156
212
|
/* nothing to remove */
|
|
157
213
|
}
|
package/src/auth/privateer.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
recordOwnedSession,
|
|
21
21
|
forgetOwnedSession,
|
|
22
22
|
orphanedSessions,
|
|
23
|
+
claimOrphan,
|
|
24
|
+
releaseOrphan,
|
|
23
25
|
dropOwnedSession,
|
|
24
26
|
clearOwnedSessions,
|
|
25
27
|
} from "./accountSessions.ts";
|
|
@@ -811,13 +813,17 @@ export async function acquireAccountCredential(): Promise<AccountCredential> {
|
|
|
811
813
|
|
|
812
814
|
while (attempted < orphans.length && !adopted) {
|
|
813
815
|
const orphan = orphans[attempted++];
|
|
816
|
+
// Another launch racing us may already have it. Rotating it anyway would replay a
|
|
817
|
+
// token that launch just spent — reuse detection, and its session revoked mid-run.
|
|
818
|
+
if (!claimOrphan(orphan.pid)) continue;
|
|
814
819
|
try {
|
|
815
820
|
adopted = await refreshAccountCredentials(orphan.refresh);
|
|
816
821
|
dropOwnedSession(orphan.pid); // the rotation above re-recorded it under OUR pid
|
|
817
822
|
} catch (e) {
|
|
818
|
-
// Refused → the session is gone; stop tracking it. Unreachable →
|
|
819
|
-
// network blip doesn't strand a live row we could have reclaimed next launch.
|
|
823
|
+
// Refused → the session is gone; stop tracking it. Unreachable → put it back, so
|
|
824
|
+
// a network blip doesn't strand a live row we could have reclaimed next launch.
|
|
820
825
|
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
826
|
+
else releaseOrphan(orphan.pid);
|
|
821
827
|
}
|
|
822
828
|
}
|
|
823
829
|
|
|
@@ -836,12 +842,14 @@ export async function acquireAccountCredential(): Promise<AccountCredential> {
|
|
|
836
842
|
// overwrite the entry for the credential this terminal is actually using.
|
|
837
843
|
async function revokeOrphanedSessions(orphans: OwnedSession[], timeoutMs = 1500): Promise<void> {
|
|
838
844
|
for (const orphan of orphans) {
|
|
845
|
+
if (!claimOrphan(orphan.pid)) continue; // another launch got there first
|
|
839
846
|
try {
|
|
840
847
|
const cred = await rotateSession(orphan.refresh);
|
|
841
848
|
await deleteSession(cred.access, timeoutMs);
|
|
842
849
|
dropOwnedSession(orphan.pid);
|
|
843
850
|
} catch (e) {
|
|
844
851
|
if (isRefreshRejection(e)) dropOwnedSession(orphan.pid);
|
|
852
|
+
else releaseOrphan(orphan.pid);
|
|
845
853
|
}
|
|
846
854
|
}
|
|
847
855
|
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
{ "entry": "extensions/privateer-terminals.ts", "name": "privateer-terminals", "note": "named terminal windows — the terminal tool + /term to switch between them" },
|
|
24
24
|
{ "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
|
|
25
25
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
26
|
+
{ "entry": "extensions/privateer-truncation.ts", "name": "privateer-truncation", "note": "picks the task back up when a reply is cut off at the output limit" },
|
|
26
27
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
27
28
|
{ "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
28
29
|
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
|
package/src/config/paths.ts
CHANGED
|
@@ -45,6 +45,18 @@ export function configPath(): string {
|
|
|
45
45
|
// session belonging to a STILL-RUNNING terminal from one orphaned by a crash, so it
|
|
46
46
|
// can reclaim the orphan instead of spawning another and walking into the server's
|
|
47
47
|
// per-device terminal cap. Holds refresh tokens — written 0600, like credentials.json.
|
|
48
|
+
//
|
|
49
|
+
// One file per owning pid in a directory, not one shared JSON file: concurrent launches
|
|
50
|
+
// (an agent firing several `privateer -p` runs at once) each read-modify-wrote the whole
|
|
51
|
+
// file, so one could write back an orphan another had already adopted (see
|
|
52
|
+
// auth/accountSessions.ts).
|
|
53
|
+
export function accountSessionsDir(): string {
|
|
54
|
+
return join(globalDir(), "account-sessions");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The pre-directory single-file registry. Only ever deleted now (clearOwnedSessions);
|
|
58
|
+
// its entries are never offered for reclamation, because a stale one is exactly the
|
|
59
|
+
// token that trips the server's reuse detection.
|
|
48
60
|
export function accountSessionsPath(): string {
|
|
49
61
|
return join(globalDir(), "account-sessions.json");
|
|
50
62
|
}
|
package/src/config/whatsNew.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
-
"reviewedFor": "0.14.
|
|
3
|
+
"reviewedFor": "0.14.3",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
|
|
6
|
+
{ "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" },
|
|
5
7
|
{ "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
|
|
6
|
-
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" }
|
|
7
|
-
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
|
|
8
|
-
{ "text": "Voice follows the window you're in", "since": "0.13.0" }
|
|
8
|
+
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" }
|
|
9
9
|
]
|
|
10
10
|
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -597,7 +597,7 @@ export function describeErrorText(
|
|
|
597
597
|
bare ? `${who} ${refused} this request was sent with (${status}).` : compactProviderError(s),
|
|
598
598
|
),
|
|
599
599
|
hint: account
|
|
600
|
-
? "This
|
|
600
|
+
? "This run's Privateer session was refused. A fresh one is opened automatically — send the message again, or re-run a one-shot (`-p`) command. Only if it keeps failing, check `privateer auth status`."
|
|
601
601
|
: `Check the API key for ${who} — run /login, or set the provider's API key env var.`,
|
|
602
602
|
};
|
|
603
603
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// What to do when a run ends on a reply the model never finished.
|
|
2
|
+
//
|
|
3
|
+
// A provider that stops a reply with `finish_reason: "length"` hit the turn's output
|
|
4
|
+
// cap. The TUI renders it as "Response was truncated before completion." and pi then
|
|
5
|
+
// does one of two things:
|
|
6
|
+
//
|
|
7
|
+
// - output ended BELOW the model's maxTokens: pi reads it as context pressure and
|
|
8
|
+
// compacts and retries the turn once (agent-session _checkCompaction,
|
|
9
|
+
// isRecoverableLength). That case is pi's, and this module stays out of it.
|
|
10
|
+
// - output used the WHOLE budget: pi treats it as final and the run just ends. The
|
|
11
|
+
// agent stops mid-task with nothing to resume it but the user typing "continue".
|
|
12
|
+
//
|
|
13
|
+
// The second case is what real sessions hit, and nearly always the same way: a
|
|
14
|
+
// thinking model spends the entire budget reasoning (measured: z-ai/glm-5.3-flash,
|
|
15
|
+
// 16384 of 16384 output tokens, 45–67k characters of reasoning, no text and no tool
|
|
16
|
+
// call — nine times across one user's logs). Raising maxTokens to the model's real cap
|
|
17
|
+
// (providers/account.ts) makes that rarer; this makes it recoverable when it happens.
|
|
18
|
+
//
|
|
19
|
+
// So: when a run ends on a full-budget length stop, queue one follow-up asking the
|
|
20
|
+
// model to pick up where it was cut off, and keep its reasoning short. Pi continues a
|
|
21
|
+
// message queued from an agent_end handler inside the same run (_handlePostAgentRun →
|
|
22
|
+
// hasQueuedMessages), so headless `-p` runs wait for it too.
|
|
23
|
+
//
|
|
24
|
+
// Bounded: at most MAX_AUTO_CONTINUES in a row. A model that truncates every time
|
|
25
|
+
// would otherwise loop forever, billing a full budget per lap. Any reply that ends
|
|
26
|
+
// some other way resets the count.
|
|
27
|
+
//
|
|
28
|
+
// IMPORT-SAFETY: no Pi imports — the shapes below are the few fields read.
|
|
29
|
+
|
|
30
|
+
export const MAX_AUTO_CONTINUES = 2;
|
|
31
|
+
|
|
32
|
+
export interface TruncationAssistant {
|
|
33
|
+
role?: string;
|
|
34
|
+
stopReason?: string;
|
|
35
|
+
usage?: { output?: number };
|
|
36
|
+
content?: { type?: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TruncationDecision =
|
|
40
|
+
| { action: "none" }
|
|
41
|
+
| { action: "continue"; attempt: number; prompt: string }
|
|
42
|
+
| { action: "give-up" };
|
|
43
|
+
|
|
44
|
+
export function lastAssistant(messages: unknown): TruncationAssistant | undefined {
|
|
45
|
+
if (!Array.isArray(messages)) return undefined;
|
|
46
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
47
|
+
const m = messages[i] as TruncationAssistant | undefined;
|
|
48
|
+
if (m?.role === "assistant") return m;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a length stop used the model's whole output budget. Mirrors the negation of
|
|
55
|
+
* pi-ai's isRecoverableLength: below the budget is pi's compact-and-retry, at it is
|
|
56
|
+
* ours. An unknown budget (0) means we cannot tell, so we leave it alone.
|
|
57
|
+
*/
|
|
58
|
+
export function isFullBudgetLengthStop(message: TruncationAssistant | undefined, modelMaxTokens: number): boolean {
|
|
59
|
+
if (message?.stopReason !== "length") return false;
|
|
60
|
+
if (!(modelMaxTokens > 0)) return false;
|
|
61
|
+
const output = message.usage?.output;
|
|
62
|
+
return typeof output === "number" && output >= modelMaxTokens;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The follow-up text. Says what happened in terms the model can act on: a cut-off
|
|
67
|
+
* spent on reasoning needs a terser plan, a cut-off mid-tool-call needs the call
|
|
68
|
+
* re-issued (its arguments were incomplete and never ran).
|
|
69
|
+
*/
|
|
70
|
+
export function continuationPrompt(message: TruncationAssistant): string {
|
|
71
|
+
const kinds = new Set((message.content ?? []).map((c) => c?.type));
|
|
72
|
+
const base = "Your previous reply hit the output limit and was cut off before it finished.";
|
|
73
|
+
if (kinds.has("toolCall")) {
|
|
74
|
+
return `${base} The tool call at the end was incomplete and did not run — issue it again in full. Keep any reasoning brief.`;
|
|
75
|
+
}
|
|
76
|
+
if (!kinds.has("text")) {
|
|
77
|
+
return `${base} It was spent entirely on reasoning. Don't restate it: keep thinking short and go straight to the next tool call or your answer.`;
|
|
78
|
+
}
|
|
79
|
+
return `${base} Continue exactly where you left off, without repeating what you already wrote. Keep any reasoning brief.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function decideTruncationRecovery(
|
|
83
|
+
messages: unknown,
|
|
84
|
+
modelMaxTokens: number,
|
|
85
|
+
consecutive: number,
|
|
86
|
+
): TruncationDecision {
|
|
87
|
+
const last = lastAssistant(messages);
|
|
88
|
+
if (!isFullBudgetLengthStop(last, modelMaxTokens)) return { action: "none" };
|
|
89
|
+
if (consecutive >= MAX_AUTO_CONTINUES) return { action: "give-up" };
|
|
90
|
+
return { action: "continue", attempt: consecutive + 1, prompt: continuationPrompt(last!) };
|
|
91
|
+
}
|
package/src/providers/account.ts
CHANGED
|
@@ -100,6 +100,27 @@ function validContextWindow(value: unknown): number | undefined {
|
|
|
100
100
|
: undefined;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// What to ask for per turn when the server publishes no output cap for a model — the
|
|
104
|
+
// flat value the whole catalog used to be registered with, so "unpublished" behaves
|
|
105
|
+
// exactly as it always has.
|
|
106
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
|
107
|
+
|
|
108
|
+
// The server's own ceiling on a proxied turn (treeview inferenceService
|
|
109
|
+
// PROXY_MAX_COMPLETION_TOKENS). Asking past it buys nothing — the proxy sizes its
|
|
110
|
+
// balance gate to at most this — so a published cap above it is clamped here.
|
|
111
|
+
const ACCOUNT_MAX_OUTPUT_TOKENS = 32000;
|
|
112
|
+
|
|
113
|
+
// Below this a turn cannot fit a complete tool call; same floor pi-ai uses for an
|
|
114
|
+
// answer (contextBudget.ts MIN_ANSWER_TOKENS). A smaller published cap is treated as
|
|
115
|
+
// garbled rather than strangling every turn.
|
|
116
|
+
const MIN_PUBLISHED_MAX_OUTPUT = 1024;
|
|
117
|
+
|
|
118
|
+
function validMaxOutputTokens(value: unknown): number | undefined {
|
|
119
|
+
return typeof value === "number" && Number.isFinite(value) && value >= MIN_PUBLISHED_MAX_OUTPUT
|
|
120
|
+
? Math.min(Math.floor(value), ACCOUNT_MAX_OUTPUT_TOKENS)
|
|
121
|
+
: undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
function seedModel(id: string) {
|
|
104
125
|
return {
|
|
105
126
|
id,
|
|
@@ -119,7 +140,13 @@ function seedModel(id: string) {
|
|
|
119
140
|
// that under-states the model's real one therefore throttles the answer — and
|
|
120
141
|
// used to collapse it to a single token — while the model still has room.
|
|
121
142
|
contextWindow: accountContextWindow(id) ?? DEFAULT_CONTEXT_WINDOW,
|
|
122
|
-
|
|
143
|
+
// The model's real output cap when published. Not cosmetic either: reasoning and
|
|
144
|
+
// the answer share this one budget, and pi only compact-and-retries a length stop
|
|
145
|
+
// that ended BELOW it — a thinking model that spends all of a too-small budget
|
|
146
|
+
// reasoning ends the run with "Response was truncated before completion."
|
|
147
|
+
// (measured: glm-5.3-flash spending all 16384 on 50k+ chars of reasoning, whose
|
|
148
|
+
// real cap is far higher). See also ext/truncationRecovery.ts.
|
|
149
|
+
maxTokens: accountMaxOutputTokens(id) ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
|
123
150
|
};
|
|
124
151
|
}
|
|
125
152
|
|
|
@@ -296,14 +323,19 @@ function saveCachedCatalog(infos: AccountModelInfo[]): void {
|
|
|
296
323
|
mkdirSync(globalDir(), { recursive: true });
|
|
297
324
|
const kept = infos.slice(0, CATALOG_CACHE_MAX);
|
|
298
325
|
const windows: Record<string, number> = {};
|
|
326
|
+
const maxOutputs: Record<string, number> = {};
|
|
299
327
|
for (const info of kept) {
|
|
300
328
|
if (info.contextWindow !== undefined) windows[info.id] = info.contextWindow;
|
|
329
|
+
if (info.maxOutputTokens !== undefined) maxOutputs[info.id] = info.maxOutputTokens;
|
|
301
330
|
}
|
|
302
331
|
const payload = {
|
|
303
332
|
v: 2,
|
|
304
333
|
fetchedAt: new Date().toISOString(),
|
|
305
334
|
ids: kept.map((info) => info.id),
|
|
306
335
|
windows,
|
|
336
|
+
// Same reasoning as `windows`: a capability needed at launch. Absent in older
|
|
337
|
+
// files, which then fall back per model.
|
|
338
|
+
maxOutputs,
|
|
307
339
|
};
|
|
308
340
|
writeFileSync(catalogCachePath(), JSON.stringify(payload) + "\n", "utf8");
|
|
309
341
|
forgetCachedContextWindows();
|
|
@@ -316,33 +348,42 @@ function saveCachedCatalog(infos: AccountModelInfo[]): void {
|
|
|
316
348
|
// the catalog on every registration, and the catalog re-registers whenever the shim
|
|
317
349
|
// state changes — re-reading and re-parsing a 284-entry file each time would turn a
|
|
318
350
|
// cheap lookup into hundreds of syscalls on the launch path.
|
|
319
|
-
let
|
|
351
|
+
let cachedCapabilities: { windows: Map<string, number>; maxOutputs: Map<string, number> } | null = null;
|
|
320
352
|
|
|
321
|
-
function
|
|
322
|
-
if (
|
|
323
|
-
|
|
353
|
+
function cachedCapabilityMaps(): { windows: Map<string, number>; maxOutputs: Map<string, number> } {
|
|
354
|
+
if (cachedCapabilities) return cachedCapabilities;
|
|
355
|
+
cachedCapabilities = { windows: new Map(), maxOutputs: new Map() };
|
|
324
356
|
try {
|
|
325
357
|
const path = catalogCachePath();
|
|
326
358
|
if (existsSync(path)) {
|
|
327
|
-
const parsed = JSON.parse(readFileSync(path, "utf8")) as { windows?: unknown };
|
|
328
|
-
// A v1 file has no `windows`
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (window !== undefined && cachedWindows.size < CATALOG_CACHE_MAX) cachedWindows.set(id, window);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
359
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { windows?: unknown; maxOutputs?: unknown };
|
|
360
|
+
// A v1 file has no `windows` (and an older v2 no `maxOutputs`); every id then
|
|
361
|
+
// falls back, exactly as before. Re-validated on read: the file is input too.
|
|
362
|
+
readCapability(parsed.windows, validContextWindow, cachedCapabilities.windows);
|
|
363
|
+
readCapability(parsed.maxOutputs, validMaxOutputTokens, cachedCapabilities.maxOutputs);
|
|
335
364
|
}
|
|
336
365
|
} catch {
|
|
337
|
-
/* absent, unreadable, or garbage — every model just uses the
|
|
366
|
+
/* absent, unreadable, or garbage — every model just uses the fallbacks */
|
|
367
|
+
}
|
|
368
|
+
return cachedCapabilities;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readCapability(raw: unknown, valid: (value: unknown) => number | undefined, into: Map<string, number>): void {
|
|
372
|
+
if (!raw || typeof raw !== "object") return;
|
|
373
|
+
for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
374
|
+
const n = valid(value);
|
|
375
|
+
if (n !== undefined && into.size < CATALOG_CACHE_MAX) into.set(id, n);
|
|
338
376
|
}
|
|
339
|
-
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function cachedContextWindows(): Map<string, number> {
|
|
380
|
+
return cachedCapabilityMaps().windows;
|
|
340
381
|
}
|
|
341
382
|
|
|
342
383
|
// Drop the memo so the next read sees what was just written (and so a test can rewrite
|
|
343
384
|
// the cache between assertions).
|
|
344
385
|
function forgetCachedContextWindows(): void {
|
|
345
|
-
|
|
386
|
+
cachedCapabilities = null;
|
|
346
387
|
}
|
|
347
388
|
|
|
348
389
|
export function loadCachedCatalogIds(): string[] {
|
|
@@ -408,6 +449,9 @@ export interface AccountModelInfo {
|
|
|
408
449
|
// Absent means "unknown" — seedModel falls back to DEFAULT_CONTEXT_WINDOW rather
|
|
409
450
|
// than to a guess, so an older server behaves exactly as before.
|
|
410
451
|
contextWindow?: number;
|
|
452
|
+
// The server's published per-turn output cap, already clamped to the proxy's
|
|
453
|
+
// ceiling. Absent means unknown — seedModel falls back to DEFAULT_MAX_OUTPUT_TOKENS.
|
|
454
|
+
maxOutputTokens?: number;
|
|
411
455
|
}
|
|
412
456
|
|
|
413
457
|
// The set of tier strings pi-privacy defines (posture/tiers.ts). We only trust a
|
|
@@ -454,6 +498,13 @@ export function accountContextWindow(id: string): number | undefined {
|
|
|
454
498
|
return accountWindowMap.get(id) ?? cachedContextWindows().get(id);
|
|
455
499
|
}
|
|
456
500
|
|
|
501
|
+
// Published output caps, keyed by modelId — live catalog first, then the disk cache.
|
|
502
|
+
const accountMaxOutputMap = new Map<string, number>();
|
|
503
|
+
|
|
504
|
+
export function accountMaxOutputTokens(id: string): number | undefined {
|
|
505
|
+
return accountMaxOutputMap.get(id) ?? cachedCapabilityMaps().maxOutputs.get(id);
|
|
506
|
+
}
|
|
507
|
+
|
|
457
508
|
// The server-asserted baseline tier for an account model, or undefined if we haven't
|
|
458
509
|
// seen it in a catalog fetch. Used by the /models picker (privateer-models.ts).
|
|
459
510
|
export function accountBaselineTier(modelId: string): PrivacyTier | undefined {
|
|
@@ -483,7 +534,7 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
|
|
|
483
534
|
infos = fallback();
|
|
484
535
|
} else {
|
|
485
536
|
const data = (await res.json()) as {
|
|
486
|
-
models?: { modelId?: string; privacy?: { tier?: string }; contextLength?: unknown }[];
|
|
537
|
+
models?: { modelId?: string; privacy?: { tier?: string }; contextLength?: unknown; maxCompletionTokens?: unknown }[];
|
|
487
538
|
};
|
|
488
539
|
const parsed = (data.models ?? [])
|
|
489
540
|
.map((m): AccountModelInfo | null =>
|
|
@@ -495,6 +546,7 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
|
|
|
495
546
|
// absent entirely on a server older than the field — both mean
|
|
496
547
|
// "unknown", and validContextWindow collapses them to undefined.
|
|
497
548
|
contextWindow: validContextWindow(m.contextLength),
|
|
549
|
+
maxOutputTokens: validMaxOutputTokens(m.maxCompletionTokens),
|
|
498
550
|
}
|
|
499
551
|
: null,
|
|
500
552
|
)
|
|
@@ -511,9 +563,11 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
|
|
|
511
563
|
}
|
|
512
564
|
accountTierMap.clear();
|
|
513
565
|
accountWindowMap.clear();
|
|
566
|
+
accountMaxOutputMap.clear();
|
|
514
567
|
for (const info of infos) {
|
|
515
568
|
accountTierMap.set(info.id, info.tier);
|
|
516
569
|
if (info.contextWindow !== undefined) accountWindowMap.set(info.id, info.contextWindow);
|
|
570
|
+
if (info.maxOutputTokens !== undefined) accountMaxOutputMap.set(info.id, info.maxOutputTokens);
|
|
517
571
|
}
|
|
518
572
|
return infos;
|
|
519
573
|
}
|
|
@@ -14,20 +14,28 @@
|
|
|
14
14
|
// easier to match and are worthless as identity: the shell rewrites them on every
|
|
15
15
|
// prompt.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// kitty, WezTerm,
|
|
21
|
-
// rather than a guess: focusing a window
|
|
22
|
-
// window manager contract we can't count on, and tmux runs
|
|
17
|
+
// ONE BACKEND ON PURPOSE PER PLACE. tmux wins whenever we are inside it, because there
|
|
18
|
+
// the "window" the user sees is tmux's, whatever app draws it. Outside tmux: macOS
|
|
19
|
+
// Terminal and iTerm2 through AppleScript, and Git Bash's own window (mintty) on Windows
|
|
20
|
+
// through PowerShell. Everything else (Ghostty, kitty, WezTerm, Windows Terminal, Linux
|
|
21
|
+
// terminals with no tmux) gets a clear "not here" rather than a guess: focusing a window
|
|
22
|
+
// by name there needs a window manager contract we can't count on, and tmux runs
|
|
23
|
+
// everywhere.
|
|
24
|
+
//
|
|
25
|
+
// On Windows there is no tty to go by, so a mintty window's id is its Windows process
|
|
26
|
+
// id: each window is one mintty.exe, and a Privateer inside finds it by walking up its
|
|
27
|
+
// parent processes.
|
|
23
28
|
//
|
|
24
29
|
// AppleScript runs as the terminal app controlling ITSELF (Privateer is a child of the
|
|
25
30
|
// window it draws in). Terminal does this without an Automation prompt (verified);
|
|
26
31
|
// iTerm2 is untested here and may ask once.
|
|
27
32
|
|
|
28
|
-
import { execFile } from "node:child_process";
|
|
33
|
+
import { execFile, spawn } from "node:child_process";
|
|
34
|
+
import { writeFileSync } from "node:fs";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
29
37
|
|
|
30
|
-
export type TerminalApp = "tmux" | "Apple_Terminal" | "iTerm.app";
|
|
38
|
+
export type TerminalApp = "tmux" | "Apple_Terminal" | "iTerm.app" | "mintty";
|
|
31
39
|
|
|
32
40
|
export interface OpenRequest {
|
|
33
41
|
name: string;
|
|
@@ -54,6 +62,12 @@ export interface TerminalBackend {
|
|
|
54
62
|
selfId(): Promise<string | undefined>;
|
|
55
63
|
/** Is this window the one in front, app and all? undefined when it can't tell. */
|
|
56
64
|
isFront(id: string): Promise<boolean | undefined>;
|
|
65
|
+
/**
|
|
66
|
+
* Call `onChange` each time the window with this id comes to the front or goes
|
|
67
|
+
* behind. Only where the terminal can't say so itself (mintty: see watchFront below).
|
|
68
|
+
* Returns what stops watching.
|
|
69
|
+
*/
|
|
70
|
+
watchFront?(id: string, onChange: (front: boolean) => void): () => void;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
/** Run a program and hand back stdout; injectable so tests can see the exact calls. */
|
|
@@ -355,6 +369,168 @@ export function tmux(exec: Exec = execProgram, env: NodeJS.ProcessEnv = process.
|
|
|
355
369
|
};
|
|
356
370
|
}
|
|
357
371
|
|
|
372
|
+
// ── mintty (Git Bash on Windows) ────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/** Start a program in its own window and hand back its process id. Injectable for tests. */
|
|
375
|
+
export type Launch = (file: string, args: string[], opts: { cwd: string; env: NodeJS.ProcessEnv }) => Promise<number>;
|
|
376
|
+
|
|
377
|
+
export const launchDetached: Launch = (file, args, opts) =>
|
|
378
|
+
new Promise((resolve, reject) => {
|
|
379
|
+
const child = spawn(file, args, { ...opts, detached: true, stdio: "ignore" });
|
|
380
|
+
child.once("error", reject);
|
|
381
|
+
child.once("spawn", () => {
|
|
382
|
+
child.unref();
|
|
383
|
+
if (child.pid) resolve(child.pid);
|
|
384
|
+
else reject(new Error(`${file} started without a process id`));
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* PowerShell, handed its script base64-encoded: the one way to get quotes, braces and
|
|
390
|
+
* newlines through Windows' command line untouched.
|
|
391
|
+
*/
|
|
392
|
+
export function powershellArgs(script: string): string[] {
|
|
393
|
+
return ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The few user32 calls PowerShell can't make on its own.
|
|
397
|
+
const USER32 = `
|
|
398
|
+
Add-Type -Namespace PV -Name U -MemberDefinition @'
|
|
399
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
400
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
401
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
402
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
|
403
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
404
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
|
|
405
|
+
'@
|
|
406
|
+
function FrontPid { $p = [uint32]0; [PV.U]::GetWindowThreadProcessId([PV.U]::GetForegroundWindow(), [ref]$p) | Out-Null; $p }
|
|
407
|
+
`;
|
|
408
|
+
|
|
409
|
+
/** Walk up from `pid` to the first mintty.exe; prints "<pid>|<exe path>" or nothing. */
|
|
410
|
+
export const minttyAncestorScript = (pid: number): string => `
|
|
411
|
+
$all = @{}
|
|
412
|
+
foreach ($p in Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,ExecutablePath) { $all[[int]$p.ProcessId] = $p }
|
|
413
|
+
$id = ${Math.trunc(pid)}
|
|
414
|
+
for ($i = 0; $i -lt 32 -and $all.ContainsKey($id); $i++) {
|
|
415
|
+
$p = $all[$id]
|
|
416
|
+
if ($p.Name -ieq 'mintty.exe') { Write-Output "$($p.ProcessId)|$($p.ExecutablePath)"; exit }
|
|
417
|
+
$id = [int]$p.ParentProcessId
|
|
418
|
+
}`;
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Bring a window forward. Windows only lets the program in front hand the foreground
|
|
422
|
+
* over; a tap of Alt is the long-standing way for a helper to be allowed to.
|
|
423
|
+
*/
|
|
424
|
+
export const minttyFocusScript = (pid: number): string => `${USER32}
|
|
425
|
+
$p = Get-Process -Id ${Math.trunc(pid)} -ErrorAction SilentlyContinue
|
|
426
|
+
if (-not $p -or $p.MainWindowHandle -eq 0) { Write-Output 'missing'; exit }
|
|
427
|
+
$h = $p.MainWindowHandle
|
|
428
|
+
if ([PV.U]::IsIconic($h)) { [PV.U]::ShowWindow($h, 9) | Out-Null }
|
|
429
|
+
[PV.U]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero); [PV.U]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
|
430
|
+
[PV.U]::SetForegroundWindow($h) | Out-Null
|
|
431
|
+
Write-Output 'ok'`;
|
|
432
|
+
|
|
433
|
+
export const frontPidScript = `${USER32}
|
|
434
|
+
Write-Output (FrontPid)`;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Print the foreground window's process id whenever it changes, until `owner` exits —
|
|
438
|
+
* a process left behind on Windows isn't ended with its parent. 0 is skipped: there is
|
|
439
|
+
* no foreground window for a moment during alt-tab, and reporting it would pause the
|
|
440
|
+
* mic mid-word.
|
|
441
|
+
*/
|
|
442
|
+
export const frontWatchScript = (owner: number): string => `${USER32}
|
|
443
|
+
$last = -1; $n = 0
|
|
444
|
+
while ($true) {
|
|
445
|
+
$p = FrontPid
|
|
446
|
+
if ($p -ne 0 -and $p -ne $last) { $last = $p; [Console]::Out.WriteLine($p); [Console]::Out.Flush() }
|
|
447
|
+
if ((++$n % 10) -eq 0 -and -not (Get-Process -Id ${Math.trunc(owner)} -ErrorAction SilentlyContinue)) { exit }
|
|
448
|
+
Start-Sleep -Milliseconds 200
|
|
449
|
+
}`;
|
|
450
|
+
|
|
451
|
+
/** mintty's CSV process list → the ids of every mintty window. */
|
|
452
|
+
export function parseTasklist(out: string): Set<string> {
|
|
453
|
+
const ids = new Set<string>();
|
|
454
|
+
for (const line of out.split(/\r?\n/)) {
|
|
455
|
+
const m = /^"mintty\.exe","(\d+)"/i.exec(line.trim());
|
|
456
|
+
if (m) ids.add(m[1]!);
|
|
457
|
+
}
|
|
458
|
+
return ids;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A Windows path as Git Bash reads it: forward slashes. */
|
|
462
|
+
const msysPath = (p: string): string => p.replace(/\\/g, "/");
|
|
463
|
+
|
|
464
|
+
export function mintty(
|
|
465
|
+
exec: Exec = execProgram,
|
|
466
|
+
launch: Launch = launchDetached,
|
|
467
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
468
|
+
scripts: { dir: string; write: (path: string, body: string) => void } = { dir: tmpdir(), write: (p, b) => writeFileSync(p, b, { mode: 0o600 }) },
|
|
469
|
+
): TerminalBackend {
|
|
470
|
+
const ps = (script: string) => exec("powershell.exe", powershellArgs(script));
|
|
471
|
+
let self: Promise<{ pid: string; exe: string } | undefined> | undefined;
|
|
472
|
+
const findSelf = () =>
|
|
473
|
+
(self ??= ps(minttyAncestorScript(process.pid))
|
|
474
|
+
.then((out) => {
|
|
475
|
+
const [pid, exe] = out.trim().split("|");
|
|
476
|
+
return pid && /^\d+$/.test(pid) ? { pid, exe: exe?.trim() || "mintty.exe" } : undefined;
|
|
477
|
+
})
|
|
478
|
+
.catch(() => undefined));
|
|
479
|
+
const live = async () => parseTasklist(await exec("tasklist", ["/FI", "IMAGENAME eq mintty.exe", "/FO", "CSV", "/NH"]));
|
|
480
|
+
return {
|
|
481
|
+
app: "mintty",
|
|
482
|
+
label: "Git Bash",
|
|
483
|
+
async open(req) {
|
|
484
|
+
const exe = (await findSelf())?.exe ?? "mintty.exe";
|
|
485
|
+
const shell = env.SHELL || "/usr/bin/bash";
|
|
486
|
+
// The command goes in a script file rather than on mintty's command line, where
|
|
487
|
+
// Windows and Cygwin each have their own idea of quoting. It deletes itself, runs,
|
|
488
|
+
// and leaves a login shell behind, so the window outlives the command.
|
|
489
|
+
const file = join(scripts.dir, `privateer-term-${process.pid}-${Date.now()}.sh`);
|
|
490
|
+
scripts.write(file, `rm -f "$0"\n${shellLine(req.cwd, req.command)}\nexec ${shQuote(shell)} -l\n`);
|
|
491
|
+
// CHERE_INVOKING keeps Git Bash's login profile from moving the shell to $HOME.
|
|
492
|
+
const pid = await launch(exe, ["-t", req.name, "-e", shell, "-l", msysPath(file)], {
|
|
493
|
+
cwd: req.cwd,
|
|
494
|
+
env: { ...env, CHERE_INVOKING: "1" },
|
|
495
|
+
});
|
|
496
|
+
return String(pid);
|
|
497
|
+
},
|
|
498
|
+
async focus(id) {
|
|
499
|
+
return (await ps(minttyFocusScript(Number(id)))).trim() === "ok";
|
|
500
|
+
},
|
|
501
|
+
async close(id) {
|
|
502
|
+
if (!(await live()).has(id)) return false;
|
|
503
|
+
// /T takes the shell and whatever it runs along with the window.
|
|
504
|
+
await exec("taskkill", ["/PID", id, "/T", "/F"]);
|
|
505
|
+
return true;
|
|
506
|
+
},
|
|
507
|
+
live,
|
|
508
|
+
selfId: async () => (await findSelf())?.pid,
|
|
509
|
+
async isFront(id) {
|
|
510
|
+
const out = (await ps(frontPidScript)).trim();
|
|
511
|
+
return /^\d+$/.test(out) ? out === id : undefined;
|
|
512
|
+
},
|
|
513
|
+
watchFront(id, onChange) {
|
|
514
|
+
const child = spawn("powershell.exe", powershellArgs(frontWatchScript(process.pid)), { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
515
|
+
let last: boolean | undefined;
|
|
516
|
+
let buf = "";
|
|
517
|
+
child.stdout.setEncoding("utf8");
|
|
518
|
+
child.stdout.on("data", (chunk: string) => {
|
|
519
|
+
buf += chunk;
|
|
520
|
+
const lines = buf.split(/\r?\n/);
|
|
521
|
+
buf = lines.pop() ?? "";
|
|
522
|
+
for (const line of lines) {
|
|
523
|
+
if (!/^\d+$/.test(line.trim())) continue;
|
|
524
|
+
const front = line.trim() === id;
|
|
525
|
+
if (front !== last) onChange((last = front));
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
child.on("error", () => undefined); // no PowerShell: focus just isn't tracked
|
|
529
|
+
return () => void child.kill();
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
358
534
|
/**
|
|
359
535
|
* The backend for the window this process is in, or why there isn't one. tmux first:
|
|
360
536
|
* inside it, TERM_PROGRAM names the app that draws tmux, not the thing we can drive.
|
|
@@ -367,10 +543,11 @@ export function detectBackend(
|
|
|
367
543
|
if (env.TMUX) return tmux(exec, env);
|
|
368
544
|
if (platform === "darwin" && env.TERM_PROGRAM === "Apple_Terminal") return appleTerminal(exec);
|
|
369
545
|
if (platform === "darwin" && env.TERM_PROGRAM === "iTerm.app") return iTerm(exec);
|
|
546
|
+
if (platform === "win32" && env.TERM_PROGRAM === "mintty") return mintty(exec);
|
|
370
547
|
const where = env.TERM_PROGRAM ? `${env.TERM_PROGRAM}` : platform === "win32" ? "this Windows terminal" : "this terminal";
|
|
371
548
|
return {
|
|
372
549
|
unsupported:
|
|
373
|
-
`Named terminals work inside tmux, macOS Terminal and
|
|
374
|
-
`Run privateer inside tmux to use them here
|
|
550
|
+
`Named terminals work inside tmux, macOS Terminal, iTerm2 and Git Bash's own window (mintty) — not in ${where}. ` +
|
|
551
|
+
(platform === "win32" ? `Run privateer from the Git Bash window to use them here.` : `Run privateer inside tmux to use them here.`),
|
|
375
552
|
};
|
|
376
553
|
}
|
package/src/terminals/index.ts
CHANGED
|
@@ -169,6 +169,29 @@ export async function thisTerminalIsFront(): Promise<boolean | undefined> {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Report whether this window is in front each time that changes, for terminals that
|
|
174
|
+
* can't tell the program inside themselves (mintty on Windows — see backends.ts). For
|
|
175
|
+
* privateer-speak's watchFocus; elsewhere the terminal's own focus reports do this, and
|
|
176
|
+
* it watches nothing.
|
|
177
|
+
*/
|
|
178
|
+
export function watchThisTerminalFocus(report: (front: boolean) => void): () => void {
|
|
179
|
+
const b = backend();
|
|
180
|
+
if ("unsupported" in b || !b.watchFront) return () => undefined;
|
|
181
|
+
let stop: (() => void) | undefined;
|
|
182
|
+
let stopped = false;
|
|
183
|
+
void b
|
|
184
|
+
.selfId()
|
|
185
|
+
.then((id) => {
|
|
186
|
+
if (id && !stopped) stop = b.watchFront!(id, report);
|
|
187
|
+
})
|
|
188
|
+
.catch(() => undefined);
|
|
189
|
+
return () => {
|
|
190
|
+
stopped = true;
|
|
191
|
+
stop?.();
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
export interface TerminalListing {
|
|
173
196
|
records: TerminalRecord[];
|
|
174
197
|
selfId?: string;
|
package/src/tools/media.ts
CHANGED
|
@@ -227,12 +227,22 @@ async function callAccount<T>(
|
|
|
227
227
|
if (res.status === 402 || /INSUFFICIENT|QUOTA|TOP_?UP/i.test(code)) {
|
|
228
228
|
return { ok: false, status: res.status, message: serverMessage || "the account is out of credit for media generation — top up or upgrade to continue" };
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
// Same split as callRag (tools/web.ts): a 401 survived authedFetch's retry, a 403 is
|
|
231
|
+
// a refusal that signing in again won't change.
|
|
232
|
+
if (res.status === 401) {
|
|
231
233
|
return {
|
|
232
234
|
ok: false, status: res.status,
|
|
233
235
|
message:
|
|
234
236
|
serverMessage ||
|
|
235
|
-
"this agent
|
|
237
|
+
"Privateer refused this agent's session (401) — running the command again opens a fresh one; if it keeps failing, check `privateer auth status`",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (res.status === 403) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false, status: res.status,
|
|
243
|
+
message:
|
|
244
|
+
serverMessage ||
|
|
245
|
+
"Privateer refused this media request (403) — the plan may not include it; this is not a sign-in problem",
|
|
236
246
|
};
|
|
237
247
|
}
|
|
238
248
|
if (res.status === 400 || res.status === 413) {
|
package/src/tools/terminal.ts
CHANGED
|
@@ -33,7 +33,8 @@ export const terminalToolDefinition = {
|
|
|
33
33
|
"`list` shows every named terminal and marks this one. `close` closes a window and ends what runs in it. " +
|
|
34
34
|
"You can NOT read a terminal's output: for commands whose output you need, use bash or a background task. " +
|
|
35
35
|
"Use this when the user wants to watch or use something themselves, or asks to switch terminals. " +
|
|
36
|
-
"Works in macOS Terminal, iTerm2, and inside tmux."
|
|
36
|
+
"Works in macOS Terminal, iTerm2, Git Bash's own window (mintty) on Windows, and inside tmux. " +
|
|
37
|
+
"Don't improvise with bash (`start`, `mintty`, `open -a`) when this says it can't: tell the user instead.",
|
|
37
38
|
parameters: Type.Object({
|
|
38
39
|
action: Type.Union(ACTIONS.map((a) => Type.Literal(a)), { description: "What to do." }),
|
|
39
40
|
name: Type.Optional(
|
package/src/tools/web.ts
CHANGED
|
@@ -129,8 +129,23 @@ export async function callRag<T>(path: string, body: unknown): Promise<({ ok: tr
|
|
|
129
129
|
if (res.status === 402 || /CAP|QUOTA|INSUFFICIENT|TOP_?UP/i.test(code)) {
|
|
130
130
|
return { ok: false, message: serverMessage || "the account is out of credit for web access — top up or upgrade to continue" };
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
// A 401 here has already survived authedFetch's refresh-and-retry, so the session
|
|
133
|
+
// really couldn't be renewed. A 403 is NOT a sign-in problem — a plan or account
|
|
134
|
+
// refusal, or the edge firewall in front of the API — and calling it "not signed in"
|
|
135
|
+
// had models telling signed-in users to log in. Pass the server's reason through.
|
|
136
|
+
if (res.status === 401) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
message:
|
|
140
|
+
serverMessage ||
|
|
141
|
+
"Privateer refused this agent's session for web access (401) — running the command again opens a fresh one; if it keeps failing, check `privateer auth status`",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 403) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
message: serverMessage || "Privateer refused this web request (403) — not a sign-in problem, so logging in again won't change it",
|
|
148
|
+
};
|
|
134
149
|
}
|
|
135
150
|
if (res.status === 400) {
|
|
136
151
|
return { ok: false, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };
|