privateer-agent 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/bin/model-args.d.mts +9 -0
- package/bin/model-args.mjs +43 -0
- package/bin/privateer-channels.mjs +30 -0
- package/bin/privateer-launch.mjs +20 -5
- package/extensions/privateer-gate.ts +35 -1
- package/extensions/privateer-hints.ts +3 -2
- package/extensions/privateer-speak.ts +5 -1
- package/extensions/privateer-truncation.ts +43 -0
- package/package.json +2 -2
- package/src/channels/run.ts +24 -5
- package/src/cli/channelsCli.ts +67 -0
- package/src/config/mediaModels.ts +3 -2
- package/src/config/moatManifest.json +1 -0
- package/src/config/piiAllow.ts +3 -2
- package/src/config/whatsNew.json +4 -4
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/engine/truncationRecovery.ts +91 -0
- package/src/harbor/index.ts +4 -0
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/providers/account.ts +71 -17
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/terminals/backends.ts +187 -10
- package/src/terminals/index.ts +23 -0
- package/src/tools/terminal.ts +2 -1
- package/src/util/atomicWrite.ts +46 -0
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
|
}
|
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
22
22
|
* frame plumbing and the running-presence read.
|
|
23
23
|
*/
|
|
24
|
-
import { readFileSync
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
25
|
import { configPath } from "../config/paths.ts";
|
|
26
26
|
// The platform roster, per-platform secret field names, and the "is this block
|
|
27
27
|
// startable" predicate all live in ONE place, shared with the channels runtime —
|
|
28
28
|
// see channels/platforms.ts. Re-exported here so existing importers of
|
|
29
29
|
// CHANNEL_PLATFORMS/ChannelPlatform from this module keep working unchanged.
|
|
30
30
|
import { CHANNEL_PLATFORMS, SECRET_FIELDS, isChannelPlatform, type ChannelPlatform } from "../channels/platforms.ts";
|
|
31
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
31
32
|
|
|
32
33
|
export { CHANNEL_PLATFORMS };
|
|
33
34
|
export type { ChannelPlatform };
|
|
@@ -161,7 +162,7 @@ export function makeChannelsControl(opts: {
|
|
|
161
162
|
|
|
162
163
|
cfg.channels[draft.platform] = block;
|
|
163
164
|
try {
|
|
164
|
-
|
|
165
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
165
166
|
} catch (e) {
|
|
166
167
|
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
167
168
|
}
|
|
@@ -174,7 +175,7 @@ export function makeChannelsControl(opts: {
|
|
|
174
175
|
if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
|
|
175
176
|
delete cfg.channels[platform];
|
|
176
177
|
try {
|
|
177
|
-
|
|
178
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
178
179
|
} catch (e) {
|
|
179
180
|
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
180
181
|
}
|
package/src/remote/mcpControl.ts
CHANGED
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
36
36
|
* frame plumbing and the sealed-secret open.
|
|
37
37
|
*/
|
|
38
|
-
import { readFileSync,
|
|
38
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
39
39
|
import { join, dirname } from "node:path";
|
|
40
40
|
import { agentDir } from "../config/paths.ts";
|
|
41
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
41
42
|
|
|
42
43
|
export type McpTransport = "stdio" | "http";
|
|
43
44
|
|
|
@@ -200,7 +201,7 @@ export function makeMcpControl(opts?: {
|
|
|
200
201
|
|
|
201
202
|
function writeSource(src: SourceFile): void {
|
|
202
203
|
mkdirSync(dirname(sourcePath()), { recursive: true });
|
|
203
|
-
|
|
204
|
+
writeFileAtomic(sourcePath(), JSON.stringify(src, null, 2) + "\n");
|
|
204
205
|
project(src);
|
|
205
206
|
}
|
|
206
207
|
|
|
@@ -220,7 +221,7 @@ export function makeMcpControl(opts?: {
|
|
|
220
221
|
mcpServers[name] = toStandard(e);
|
|
221
222
|
}
|
|
222
223
|
mkdirSync(dirname(projectionPath()), { recursive: true });
|
|
223
|
-
|
|
224
|
+
writeFileAtomic(
|
|
224
225
|
projectionPath(),
|
|
225
226
|
JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
|
|
226
227
|
);
|
package/src/routines/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { globalDir } from "../config/paths.ts";
|
|
@@ -64,11 +65,9 @@ export function saveRoutines(routines: Routine[]): void {
|
|
|
64
65
|
mkdirSync(dir, { recursive: true });
|
|
65
66
|
tryChmod(dir, 0o700);
|
|
66
67
|
const payload: RoutineFile = { routines };
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
tryChmod(routinesFilePath(), 0o600);
|
|
68
|
+
// Atomic: a half-written routines.json reads as "no routines" (loadRoutines), and the
|
|
69
|
+
// next save would then persist only the one routine it was given.
|
|
70
|
+
writeFileAtomic(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
|
72
71
|
}
|
|
73
72
|
|
|
74
73
|
// Look up by id first, then by (case-insensitive) name for CLI convenience.
|
|
@@ -99,18 +98,48 @@ export function removeRoutine(idOrName: string): Routine | null {
|
|
|
99
98
|
return target;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
//
|
|
101
|
+
// Dated result files kept per routine. An hourly routine on an always-on box wrote one
|
|
102
|
+
// every hour, forever; latest.md is what anything reads, and this many is history enough.
|
|
103
|
+
export const ROUTINE_OUTPUT_KEEP = Number(process.env.PRIVATEER_ROUTINE_OUTPUT_KEEP) || 30;
|
|
104
|
+
|
|
105
|
+
// Write a run's result to the routine's output dir: a dated file plus latest.md, then
|
|
106
|
+
// prune the dated files to the newest ROUTINE_OUTPUT_KEEP. Owner-only, like the rest of
|
|
107
|
+
// the global dir: a result can quote whatever the routine read.
|
|
103
108
|
// Returns the absolute path of latest.md.
|
|
104
109
|
export function writeRoutineOutput(name: string, content: string): string {
|
|
105
110
|
const dir = routineOutputDir(name);
|
|
106
111
|
mkdirSync(dir, { recursive: true });
|
|
107
112
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
108
|
-
writeFileSync(join(dir, `${stamp}.md`), content, "utf8");
|
|
113
|
+
writeFileSync(join(dir, `${stamp}.md`), content, { encoding: "utf8", mode: 0o600 });
|
|
109
114
|
const latest = join(dir, "latest.md");
|
|
110
|
-
|
|
115
|
+
writeFileAtomic(latest, content, { mode: 0o600 });
|
|
116
|
+
pruneRoutineOutput(dir, ROUTINE_OUTPUT_KEEP);
|
|
111
117
|
return latest;
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
// Our dated names (ISO with ':' and '.' → '-') sort chronologically as strings, so the
|
|
121
|
+
// oldest are first. Only files of that shape are touched — anything else a user put in
|
|
122
|
+
// the folder is theirs. Best-effort: a prune failure never fails a delivery.
|
|
123
|
+
const DATED_OUTPUT = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/;
|
|
124
|
+
|
|
125
|
+
export function pruneRoutineOutput(dir: string, keep: number): number {
|
|
126
|
+
let removed = 0;
|
|
127
|
+
try {
|
|
128
|
+
const dated = readdirSync(dir).filter((f) => DATED_OUTPUT.test(f)).sort();
|
|
129
|
+
for (const f of dated.slice(0, Math.max(0, dated.length - keep))) {
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(join(dir, f));
|
|
132
|
+
removed++;
|
|
133
|
+
} catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
/* unreadable dir */
|
|
139
|
+
}
|
|
140
|
+
return removed;
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
// A pending routine result queued for the next interactive session ("notice"
|
|
115
144
|
// delivery). The TUI drains these on startup so results surface even when no
|
|
116
145
|
// terminal was attached at fire time.
|
|
@@ -144,7 +173,7 @@ export function addNotice(notice: RoutineNotice): void {
|
|
|
144
173
|
notices.push(notice);
|
|
145
174
|
// Keep the queue bounded so an offline stretch can't grow it without limit.
|
|
146
175
|
const trimmed = notices.slice(-50);
|
|
147
|
-
|
|
176
|
+
writeFileAtomic(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
|
|
148
177
|
tryChmod(noticesPath(), 0o600);
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -189,7 +218,7 @@ export function addPendingRelay(entry: PendingRelay): void {
|
|
|
189
218
|
const queue = loadPendingRelay();
|
|
190
219
|
queue.push(entry);
|
|
191
220
|
const trimmed = queue.slice(-50); // bound the backlog
|
|
192
|
-
|
|
221
|
+
writeFileAtomic(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
|
|
193
222
|
tryChmod(pendingRelayPath(), 0o600);
|
|
194
223
|
}
|
|
195
224
|
|
|
@@ -271,6 +300,6 @@ export function addPendingCloud(entry: PendingCloud): void {
|
|
|
271
300
|
export function savePendingCloud(entries: PendingCloud[]): void {
|
|
272
301
|
const dir = join(globalDir(), "routines");
|
|
273
302
|
mkdirSync(dir, { recursive: true });
|
|
274
|
-
|
|
303
|
+
writeFileAtomic(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
|
|
275
304
|
tryChmod(pendingCloudPath(), 0o600);
|
|
276
305
|
}
|
|
@@ -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/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(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Replace a file all at once: write a sibling temp file, then rename it over the target.
|
|
2
|
+
//
|
|
3
|
+
// A plain writeFileSync truncates first and writes second, so a crash, a full disk or a
|
|
4
|
+
// kill in between leaves a short or empty file. For the files this is used on that is
|
|
5
|
+
// worse than losing the write: loadRoutines() reads a half-written routines.json as "no
|
|
6
|
+
// routines", and the next save from the app then writes just the one routine it holds —
|
|
7
|
+
// every other routine gone. rename() is atomic on one filesystem, so a reader sees the
|
|
8
|
+
// old file or the new one and never anything between.
|
|
9
|
+
//
|
|
10
|
+
// Permissions carry over from the file being replaced (a config.json someone chmod'ed
|
|
11
|
+
// 0600 stays 0600). A new file gets `mode`, default 0600: everything written through
|
|
12
|
+
// here is config that can hold tokens.
|
|
13
|
+
//
|
|
14
|
+
// IMPORT-SAFETY: node builtins only.
|
|
15
|
+
import { chmodSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export function writeFileAtomic(path: string, data: string, opts: { mode?: number } = {}): void {
|
|
19
|
+
// Follow a symlinked config to the file it names, so we replace the file rather than
|
|
20
|
+
// the link (which would quietly detach a dotfiles-managed config).
|
|
21
|
+
let target = path;
|
|
22
|
+
let mode = opts.mode ?? 0o600;
|
|
23
|
+
try {
|
|
24
|
+
target = realpathSync(path);
|
|
25
|
+
if (opts.mode === undefined) mode = statSync(target).mode & 0o777;
|
|
26
|
+
} catch {
|
|
27
|
+
/* new file */
|
|
28
|
+
}
|
|
29
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(tmp, data, { encoding: "utf8", mode });
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(tmp, mode); // writeFileSync's mode is filtered by the umask
|
|
34
|
+
} catch {
|
|
35
|
+
/* non-POSIX filesystem */
|
|
36
|
+
}
|
|
37
|
+
renameSync(tmp, target);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(tmp);
|
|
41
|
+
} catch {
|
|
42
|
+
/* never created */
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|