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/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
|
|
|
@@ -479,10 +483,30 @@ privateer harbor uninstall
|
|
|
479
483
|
```
|
|
480
484
|
|
|
481
485
|
Installs as a **launchd user agent** on macOS or a **`systemd --user` unit** on Linux — no
|
|
482
|
-
root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.)
|
|
486
|
+
root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.) Logs go to
|
|
487
|
+
`~/.privateer/harbor.log` on macOS (kept under 5 MB, one previous copy as `harbor.log.1`) and
|
|
488
|
+
to the journal on Linux: `journalctl --user -u privateer-harbor`.
|
|
483
489
|
|
|
484
490
|
Everything the harbor does still runs through the permission gate. Actions needing approval
|
|
485
|
-
surface in the app; routines you approved once run on their own schedule.
|
|
491
|
+
surface in the app; routines you approved once run on their own schedule. Unattended runs
|
|
492
|
+
are confined to their working directory and can't read credential stores (`~/.ssh`,
|
|
493
|
+
`~/.aws`, Privateer's own login and config files), whatever tools they're allowed.
|
|
494
|
+
|
|
495
|
+
**Chat channels** (Telegram, Slack, Discord, WhatsApp) run beside it, as their own service:
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
privateer channels install # login service; re-run after changing channels from the app
|
|
499
|
+
privateer channels status # which platforms are live right now
|
|
500
|
+
privateer channels run # or in the foreground
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
**On a VPS:** run both as a dedicated, unprivileged user, never root. A routine you allow
|
|
504
|
+
`bash` runs with that user's full rights. Sign in over SSH with `privateer` and `/login` (it
|
|
505
|
+
prints a code to approve in the app). Nothing needs an inbound port, except WhatsApp's
|
|
506
|
+
webhook. The Linux unit sets `NoNewPrivileges`, `UMask=0077` and a restart limit. It skips
|
|
507
|
+
systemd's namespace sandboxing on purpose, because that fails to start on kernels without
|
|
508
|
+
unprivileged user namespaces. In a container with no systemd, use `privateer harbor run`
|
|
509
|
+
under the container's own restart policy.
|
|
486
510
|
|
|
487
511
|
## Workflows
|
|
488
512
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Which `--model` the launcher hands Pi, if any.
|
|
2
|
+
//
|
|
3
|
+
// Plain .mjs for the same reason as headless-flags.mjs: bin/ runs under a bare `node`,
|
|
4
|
+
// and the launcher runs on import, so the rule lives where a test can reach it.
|
|
5
|
+
// See tests/modelArgs.test.ts.
|
|
6
|
+
//
|
|
7
|
+
// Precedence:
|
|
8
|
+
// 1. a --model the user typed (already in the args) → add nothing
|
|
9
|
+
// 2. PRIVATEER_MODEL → the computed model
|
|
10
|
+
// 3. a saved pick in settings.json → usually add nothing and let Pi resolve it, which
|
|
11
|
+
// falls back sanely when a BYO model has vanished from the registry. EXCEPT a
|
|
12
|
+
// `privateer/*` pick on a signed-in machine, which is passed explicitly (below).
|
|
13
|
+
// 4. nothing saved → the computed model
|
|
14
|
+
//
|
|
15
|
+
// Why the exception. Pi resolves a saved default only when that provider HAS AUTH at
|
|
16
|
+
// the moment the session starts (model-resolver.js findInitialModel, step 3), and if not
|
|
17
|
+
// it silently takes the first model that does: with an OPENROUTER_API_KEY around, that
|
|
18
|
+
// is Pi's openrouter default, moonshotai/kimi-k2.6. The account provider's auth is the
|
|
19
|
+
// one `privateer` entry in auth.json, which is machine-global and removed whenever ANY
|
|
20
|
+
// Privateer process exits (it revokes its own account session on the way out). So a
|
|
21
|
+
// launch that happened to follow another process's exit came up on OpenRouter with no
|
|
22
|
+
// message at all, while settings.json still said privateer/near/…. A `-p` run then sent
|
|
23
|
+
// its work to a ZDR provider instead of the enclave the user picked; under --private it
|
|
24
|
+
// refused every tool and said the model was "zdr-policy", which is how this was found.
|
|
25
|
+
//
|
|
26
|
+
// An explicit --model resolves against every REGISTERED model, auth or not
|
|
27
|
+
// (resolveCliModel → getModels()), and the account credential is armed at
|
|
28
|
+
// session_start, before the first prompt. Being signed in is the condition because
|
|
29
|
+
// that is when arming will succeed; signed out, the saved pick couldn't run anyway and
|
|
30
|
+
// Pi's own resolution is no worse.
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ launchArgs: string[], envModel?: string, savedDefault?: string | null, signedIn: boolean, computed: string }} o
|
|
34
|
+
* @returns {string[]} args to prepend: [] or ["--model", spec]
|
|
35
|
+
*/
|
|
36
|
+
export function modelArgs({ launchArgs, envModel, savedDefault, signedIn, computed }) {
|
|
37
|
+
if (launchArgs.includes("--model")) return [];
|
|
38
|
+
if (envModel) return ["--model", computed];
|
|
39
|
+
if (savedDefault) {
|
|
40
|
+
return signedIn && savedDefault.startsWith("privateer/") ? ["--model", savedDefault] : [];
|
|
41
|
+
}
|
|
42
|
+
return ["--model", computed];
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher for the channels runner (Telegram/Slack/Discord/WhatsApp → agent turns).
|
|
3
|
+
// Mirrors bin/privateer-harbor.mjs: load dev keys from the repo .env WITHOUT changing
|
|
4
|
+
// cwd, register tsx so TS resolves regardless of the invocation cwd, then hand off to
|
|
5
|
+
// the channels CLI dispatcher (whose `run` imports ./boot.ts before any Pi code).
|
|
6
|
+
//
|
|
7
|
+
// Invoked two ways: interactively via the launcher (`privateer channels …`), and by the
|
|
8
|
+
// installed launchd/systemd service (`node privateer-channels.mjs run`). This is what
|
|
9
|
+
// makes channels runnable from an npm install; it used to need a source checkout
|
|
10
|
+
// (`npm run channels`, tsx and a .env).
|
|
11
|
+
import { register } from "tsx/esm/api";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const repo = resolve(here, "..");
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
process.loadEnvFile(resolve(repo, ".env"));
|
|
20
|
+
} catch {
|
|
21
|
+
/* no .env — rely on the ambient environment / ~/.privateer */
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Same reason as the harbor: channel sessions load the moat in code, so subagent
|
|
25
|
+
// children need the moat-injecting wrapper rather than a bare `pi`.
|
|
26
|
+
process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
|
|
27
|
+
|
|
28
|
+
register();
|
|
29
|
+
const { runChannelsCli } = await import(pathToFileURL(resolve(repo, "src/cli/channelsCli.ts")).href);
|
|
30
|
+
await runChannelsCli(process.argv.slice(2));
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
32
|
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
|
|
33
33
|
import { runToCompletion } from "./run-to-completion.mjs";
|
|
34
|
+
import { modelArgs } from "./model-args.mjs";
|
|
34
35
|
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
35
36
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
36
37
|
|
|
@@ -555,6 +556,16 @@ else if (sub === "harbor" || sub === "daemon") {
|
|
|
555
556
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
556
557
|
}
|
|
557
558
|
|
|
559
|
+
// --- `privateer channels [run|install|uninstall|status]` -------------------
|
|
560
|
+
// The chat-platform bridges (Telegram/Slack/Discord/WhatsApp). Same shape as the
|
|
561
|
+
// harbor: a resident process, its own launcher, its own optional login service.
|
|
562
|
+
else if (sub === "channels") {
|
|
563
|
+
sweepLegacyShims();
|
|
564
|
+
sweepBashLogs();
|
|
565
|
+
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
566
|
+
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-channels.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
567
|
+
}
|
|
568
|
+
|
|
558
569
|
// --- `privateer verify` ----------------------------------------------------
|
|
559
570
|
// Check the install that is on this disk right now. Every other trust signal we
|
|
560
571
|
// publish is an install-time one (npm provenance at `npm i`, a checksum inside
|
|
@@ -817,14 +828,15 @@ else {
|
|
|
817
828
|
// 1. a --model the user typed on the privateer command line (already in `args`)
|
|
818
829
|
// 2. PRIVATEER_MODEL — deliberate override, folded into MODEL above
|
|
819
830
|
// 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
|
|
820
|
-
// falls back sanely if that model has vanished from the registry)
|
|
831
|
+
// falls back sanely if that model has vanished from the registry). Except a
|
|
832
|
+
// signed-in `privateer/*` pick, passed explicitly — see bin/model-args.mjs for
|
|
833
|
+
// the silent OpenRouter fallback that prevents
|
|
821
834
|
// 4. nothing saved (first run / fresh home) → the computed MODEL above
|
|
822
835
|
//
|
|
823
836
|
// A FUNCTION, called per launch: a fresh agent (bin/fresh-supervisor.mjs) re-reads it,
|
|
824
837
|
// so a model picked in the old session boots the new one instead of being stomped by
|
|
825
838
|
// a first-run --model computed before that pick existed.
|
|
826
839
|
const modelArgsFor = (launchArgs) => {
|
|
827
|
-
const userPassedModel = launchArgs.includes("--model");
|
|
828
840
|
let savedDefault = null;
|
|
829
841
|
try {
|
|
830
842
|
const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
|
|
@@ -835,7 +847,7 @@ else {
|
|
|
835
847
|
savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
|
|
836
848
|
}
|
|
837
849
|
} catch { /* absent/unreadable → no saved pick */ }
|
|
838
|
-
return
|
|
850
|
+
return modelArgs({ launchArgs, envModel: process.env.PRIVATEER_MODEL, savedDefault, signedIn, computed: MODEL });
|
|
839
851
|
};
|
|
840
852
|
|
|
841
853
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
@@ -866,7 +878,9 @@ else {
|
|
|
866
878
|
// command, or `privateer fresh` from another terminal can swap this agent for a new
|
|
867
879
|
// process with the old tree reaped. Print/export/version runs have nothing to swap.
|
|
868
880
|
const tuiArgs = (launchArgs) => [...nodeArgs, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
|
|
869
|
-
|
|
881
|
+
// forwardSignals: a `kill` reaches only this launcher, and a -p run left behind keeps
|
|
882
|
+
// working (and spending) with nobody waiting for its answer.
|
|
883
|
+
if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args), { forwardSignals: true });
|
|
870
884
|
else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
|
|
871
885
|
}
|
|
872
886
|
|
|
@@ -1047,7 +1061,8 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
|
|
|
1047
1061
|
"Commands:",
|
|
1048
1062
|
` ${cmd} init [directory] Create a starter PRIVATEER.md project-context file`,
|
|
1049
1063
|
` ${cmd} update [--all] Fetch the latest release or newer tool packs`,
|
|
1050
|
-
` ${cmd} harbor <command> Manage the
|
|
1064
|
+
` ${cmd} harbor <command> Manage Harbor, the always-on agent behind the app (run/install/uninstall/status)`,
|
|
1065
|
+
` ${cmd} channels <command> Run chat channels: Telegram, Slack, Discord, WhatsApp (run/install/uninstall/status)`,
|
|
1051
1066
|
` ${cmd} verify Check local installation integrity and patch state`,
|
|
1052
1067
|
` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
|
|
1053
1068
|
` ${cmd} fresh [n] Replace a running terminal's agent with a new one (stops everything it started)`,
|
|
@@ -41,6 +41,7 @@ import { childSpendAllows } from "../src/permissions/childSpend.ts";
|
|
|
41
41
|
import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
|
|
42
42
|
import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
|
|
43
43
|
import { privateModeFromEnv } from "../src/permissions/privateMode.ts";
|
|
44
|
+
import { REPLY_TIMEOUT_ENV, StallWatchdog, replyTimeoutMs } from "../src/engine/stallWatchdog.ts";
|
|
44
45
|
import type { PermissionRequest } from "../src/permissions/gate.ts";
|
|
45
46
|
import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
|
|
46
47
|
import { describeErrorText } from "../src/engine/errors.ts";
|
|
@@ -772,11 +773,44 @@ export default function privateerControl(pi: any): void {
|
|
|
772
773
|
};
|
|
773
774
|
});
|
|
774
775
|
|
|
776
|
+
// A headless run must not wait forever on a provider that went silent (see
|
|
777
|
+
// src/engine/stallWatchdog.ts). Armed per request, re-armed by every streamed event,
|
|
778
|
+
// off between requests. On a stall: say so on stderr, abort the turn, exit non-zero.
|
|
779
|
+
let stallCtx: any;
|
|
780
|
+
const stallMs = replyTimeoutMs();
|
|
781
|
+
const watchdog = new StallWatchdog(stallMs, () => {
|
|
782
|
+
const secs = Math.round(stallMs / 1000);
|
|
783
|
+
process.stderr.write(
|
|
784
|
+
`privateer: no reply from ${currentSpec || "the model"} for ${secs}s — stopped. The provider accepted the ` +
|
|
785
|
+
`request and then sent nothing.\n Re-run, or pick another model with --model. ` +
|
|
786
|
+
`${REPLY_TIMEOUT_ENV}=<seconds> changes the limit (0 turns it off).\n`,
|
|
787
|
+
);
|
|
788
|
+
process.exitCode = 1;
|
|
789
|
+
try {
|
|
790
|
+
stallCtx?.abort?.();
|
|
791
|
+
} catch {
|
|
792
|
+
/* the turn is already ending */
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
pi.on("before_provider_request", (_e: any, ctx: any) => {
|
|
796
|
+
if (!headlessRun) return;
|
|
797
|
+
stallCtx = ctx;
|
|
798
|
+
watchdog.arm();
|
|
799
|
+
});
|
|
800
|
+
pi.on("message_start", (ev: any) => {
|
|
801
|
+
if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
|
|
802
|
+
});
|
|
803
|
+
pi.on("message_update", () => {
|
|
804
|
+
if (headlessRun) watchdog.arm();
|
|
805
|
+
});
|
|
806
|
+
pi.on("message_end", () => watchdog.disarm());
|
|
807
|
+
|
|
775
808
|
// Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
|
|
776
809
|
// "Connection error." — and exits. Follow it with what that means and what to do,
|
|
777
810
|
// on stderr so stdout stays the answer (or the JSON stream).
|
|
778
811
|
pi.on("agent_end", (ev: any) => {
|
|
779
|
-
|
|
812
|
+
watchdog.disarm();
|
|
813
|
+
if (!headlessRun || watchdog.stalled) return; // a stall already said what happened
|
|
780
814
|
const msgs: any[] = Array.isArray(ev?.messages) ? ev.messages : [];
|
|
781
815
|
const last = [...msgs].reverse().find((m) => m?.role === "assistant");
|
|
782
816
|
if (last?.stopReason !== "error" || typeof last.errorMessage !== "string") return;
|
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
// /hints on|off persists { hints: { enabled } } in ~/.privateer/config.json (ours;
|
|
22
22
|
// Pi never reads that file) — read-modify-write, preserving unrelated keys.
|
|
23
23
|
|
|
24
|
-
import { readFileSync,
|
|
24
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { configPath, globalDir } from "../src/config/paths.ts";
|
|
28
28
|
import { desktopAppPath } from "../src/config/desktopApp.ts";
|
|
29
|
+
import { writeFileAtomic } from "../src/util/atomicWrite.ts";
|
|
29
30
|
|
|
30
31
|
const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
|
|
31
32
|
const EVERY_MS = 12_000;
|
|
@@ -120,7 +121,7 @@ function persistEnabled(enabled: boolean): void {
|
|
|
120
121
|
cfg.hints = { ...(cfg.hints ?? {}), enabled };
|
|
121
122
|
try {
|
|
122
123
|
mkdirSync(globalDir(), { recursive: true });
|
|
123
|
-
|
|
124
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
124
125
|
} catch {
|
|
125
126
|
/* best effort — the toggle still holds for this session */
|
|
126
127
|
}
|
|
@@ -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.2",
|
|
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",
|
package/src/channels/run.ts
CHANGED
|
@@ -53,6 +53,8 @@ import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
|
53
53
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
54
54
|
import { CRYPTO_TOOL_NAMES } from "../tools/crypto.ts";
|
|
55
55
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
56
|
+
import { rotateLogIfLarge } from "../harbor/logRotate.ts";
|
|
57
|
+
import { logFilePath } from "../harbor/service.ts";
|
|
56
58
|
|
|
57
59
|
// Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
|
|
58
60
|
// a turn nobody is watching can't mutate the filesystem or shell out. Now that the
|
|
@@ -147,10 +149,19 @@ async function main() {
|
|
|
147
149
|
|
|
148
150
|
// ── config ──────────────────────────────────────────────────────────────────
|
|
149
151
|
let cfg: any = {};
|
|
152
|
+
let raw: string;
|
|
150
153
|
try {
|
|
151
|
-
|
|
154
|
+
raw = readFileSync(configPath(), "utf8");
|
|
152
155
|
} catch {
|
|
153
|
-
|
|
156
|
+
// Nothing configured yet is a clean exit (see the no-bridges case below for why).
|
|
157
|
+
log(`no config at ${configPath()} — add a channels block (see run.ts header), or set channels up from the app.`);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
cfg = JSON.parse(raw);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// A config that exists but won't parse IS a failure: say so, and exit non-zero.
|
|
164
|
+
log(`can't parse ${configPath()}: ${e instanceof Error ? e.message : String(e)}`);
|
|
154
165
|
process.exit(1);
|
|
155
166
|
}
|
|
156
167
|
const ch = cfg.channels ?? {};
|
|
@@ -404,15 +415,23 @@ async function main() {
|
|
|
404
415
|
}
|
|
405
416
|
|
|
406
417
|
if (bridges.length === 0) {
|
|
407
|
-
|
|
408
|
-
|
|
418
|
+
// Exit 0, not 1: under the login service (`privateer channels install`) a failure
|
|
419
|
+
// exit is restarted, and "nothing configured yet" doesn't get better by retrying —
|
|
420
|
+
// it got restarted until systemd's start limit, filling the log with this line.
|
|
421
|
+
// Configure a channel (from the app or config.json), then start it again.
|
|
422
|
+
log("no channels started — configure a channels.<platform> block in config.json (or from the app), then run `privateer channels install` again.");
|
|
423
|
+
process.exit(0);
|
|
409
424
|
}
|
|
410
425
|
|
|
411
426
|
// Heartbeat: announce the live platforms now and refresh on a cadence so the app's
|
|
412
427
|
// channels manager can tell running from merely-configured. A stale/absent file
|
|
413
428
|
// reads as offline (see channels/status.ts).
|
|
414
429
|
writeChannelsStatus(startedPlatforms);
|
|
415
|
-
const heartbeat = setInterval(() =>
|
|
430
|
+
const heartbeat = setInterval(() => {
|
|
431
|
+
writeChannelsStatus(startedPlatforms);
|
|
432
|
+
// Bound the log launchd appends to (macOS service; a no-op elsewhere). See logRotate.ts.
|
|
433
|
+
if (rotateLogIfLarge(logFilePath("channels"))) log("rotated channels.log (previous kept as channels.log.1)");
|
|
434
|
+
}, HEARTBEAT_MS);
|
|
416
435
|
heartbeat.unref?.();
|
|
417
436
|
|
|
418
437
|
const shutdown = () => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `privateer channels [run|install|uninstall|status]` dispatcher.
|
|
2
|
+
//
|
|
3
|
+
// ORDERING CONTRACT: `run` dynamically imports channels/run.ts, which imports
|
|
4
|
+
// ./boot.ts first (env pin + attestation dispatcher) and starts on import. The service
|
|
5
|
+
// subcommands touch no Pi code (harbor/service.ts is import-safe), so they don't pay
|
|
6
|
+
// the session-stack import cost.
|
|
7
|
+
|
|
8
|
+
function usage(): string {
|
|
9
|
+
return [
|
|
10
|
+
"Usage: privateer channels [command]",
|
|
11
|
+
"",
|
|
12
|
+
" run Run the channels in the foreground (default). Bridges every",
|
|
13
|
+
" channels.<platform> block in ~/.privateer/config.json.",
|
|
14
|
+
" install Install it as a login service so it starts at login and stays up.",
|
|
15
|
+
" Re-run after changing channels from the app: it restarts them.",
|
|
16
|
+
" uninstall Remove the login service.",
|
|
17
|
+
" status Show whether the service is installed and which channels are live.",
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runChannelsCli(argv: string[]): Promise<void> {
|
|
22
|
+
const sub = argv[0] ?? "run";
|
|
23
|
+
switch (sub) {
|
|
24
|
+
case "run": {
|
|
25
|
+
await import("../channels/run.ts"); // starts on import, installs its own signal handlers
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
case "install": {
|
|
29
|
+
const { installService } = await import("../harbor/service.ts");
|
|
30
|
+
try {
|
|
31
|
+
const info = installService("channels");
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
`Channels installed as a login service.\n unit: ${info.unitPath}\n logs: ${info.logPath}\n` +
|
|
34
|
+
"They start now and at every login. Manage with `privateer channels status|uninstall`.\n",
|
|
35
|
+
);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
case "uninstall": {
|
|
43
|
+
const { uninstallService } = await import("../harbor/service.ts");
|
|
44
|
+
try {
|
|
45
|
+
uninstallService("channels");
|
|
46
|
+
process.stdout.write("Channels login service removed.\n");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
case "status": {
|
|
54
|
+
const { channelsStatusReport } = await import("../harbor/service.ts");
|
|
55
|
+
process.stdout.write((await channelsStatusReport()) + "\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
case "-h":
|
|
59
|
+
case "--help":
|
|
60
|
+
case "help":
|
|
61
|
+
process.stdout.write(usage() + "\n");
|
|
62
|
+
return;
|
|
63
|
+
default:
|
|
64
|
+
process.stderr.write(`Unknown channels command: ${sub}\n\n${usage()}\n`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* Missing file, unreadable file and malformed JSON all mean "no choice" — the account
|
|
19
19
|
* default — never a crash in the middle of a tool call.
|
|
20
20
|
*/
|
|
21
|
-
import { readFileSync,
|
|
21
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
22
22
|
import { dirname } from "node:path";
|
|
23
23
|
import { configPath } from "./paths.ts";
|
|
24
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
24
25
|
|
|
25
26
|
export type MediaKind = "image" | "video";
|
|
26
27
|
|
|
@@ -55,7 +56,7 @@ export function setMediaModelPref(kind: MediaKind, id: string | null): void {
|
|
|
55
56
|
const next: Record<string, unknown> = { ...cfg, media };
|
|
56
57
|
if (Object.keys(media).length === 0) delete next.media;
|
|
57
58
|
mkdirSync(dirname(configPath()), { recursive: true });
|
|
58
|
-
|
|
59
|
+
writeFileAtomic(configPath(), JSON.stringify(next, null, 2) + "\n");
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** The model a call should send: its own `model`, else this machine's choice, else none. */
|
|
@@ -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/piiAllow.ts
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
// made in an editor is picked up on the next scan, exactly like one made through the
|
|
29
29
|
// command — there is no "restart to apply" step to explain.
|
|
30
30
|
|
|
31
|
-
import { readFileSync, statSync
|
|
31
|
+
import { readFileSync, statSync } from "node:fs";
|
|
32
32
|
import { configPath } from "./paths.ts";
|
|
33
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
33
34
|
|
|
34
35
|
let cached: string[] = [];
|
|
35
36
|
let cachedStamp = "";
|
|
@@ -89,7 +90,7 @@ function write(entries: string[]): void {
|
|
|
89
90
|
/* new or unreadable — a fresh object, so one bad file doesn't lose the edit */
|
|
90
91
|
}
|
|
91
92
|
const privacy = { ...((cfg.privacy as Record<string, unknown>) ?? {}), piiAllow: entries };
|
|
92
|
-
|
|
93
|
+
writeFileAtomic(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
|
|
93
94
|
invalidate();
|
|
94
95
|
}
|
|
95
96
|
|
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.2",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" },
|
|
6
|
+
{ "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
|
|
5
7
|
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
|
|
6
|
-
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" }
|
|
7
|
-
{ "text": "Voice follows the window you're in", "since": "0.13.0" },
|
|
8
|
-
{ "text": "Searchable model picker —", "cmd": "/models", "since": "0.12.53" }
|
|
8
|
+
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A stall watchdog for headless runs.
|
|
2
|
+
//
|
|
3
|
+
// `privateer -p` waits on the model with no limit. A provider that accepts the request
|
|
4
|
+
// and then sends nothing (measured on the account channel: glm-5-2 stalled 5 of 14
|
|
5
|
+
// requests, see providers/defaultModel.ts) leaves the run hanging forever with nothing
|
|
6
|
+
// on stdout. That is what a 14-minute "hang" on 2026-10-02 looked like from the outside;
|
|
7
|
+
// the session file never appeared, because Pi writes it only once a first reply lands.
|
|
8
|
+
//
|
|
9
|
+
// So: armed when a request goes out, re-armed by every streamed event (text, thinking,
|
|
10
|
+
// tool-call deltas all count, so a slow but live stream never trips it), disarmed when
|
|
11
|
+
// the message ends. Between requests (tool execution) it is off — a long local command
|
|
12
|
+
// is not a provider stall. If it fires, the caller aborts the turn and says why.
|
|
13
|
+
//
|
|
14
|
+
// The limit: PRIVATEER_REPLY_TIMEOUT seconds, default 180, 0 turns it off. Silence, not
|
|
15
|
+
// total time, is what is measured, so a long answer is never cut off for being long.
|
|
16
|
+
|
|
17
|
+
export const REPLY_TIMEOUT_ENV = "PRIVATEER_REPLY_TIMEOUT";
|
|
18
|
+
export const DEFAULT_REPLY_TIMEOUT_MS = 180_000;
|
|
19
|
+
|
|
20
|
+
/** The stall limit in ms from the environment; 0 means off. Garbage falls back to the default. */
|
|
21
|
+
export function replyTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
22
|
+
const raw = (env[REPLY_TIMEOUT_ENV] ?? "").trim();
|
|
23
|
+
if (!raw) return DEFAULT_REPLY_TIMEOUT_MS;
|
|
24
|
+
const n = Number(raw);
|
|
25
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_REPLY_TIMEOUT_MS;
|
|
26
|
+
return Math.round(n * 1000);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class StallWatchdog {
|
|
30
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
private fired = false;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly ms: number,
|
|
35
|
+
private readonly onStall: () => void,
|
|
36
|
+
private readonly timers: { set: typeof setTimeout; clear: typeof clearTimeout } = { set: setTimeout, clear: clearTimeout },
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/** A request went out, or something streamed: (re)start the silence clock. */
|
|
40
|
+
arm(): void {
|
|
41
|
+
if (this.ms <= 0 || this.fired) return;
|
|
42
|
+
this.disarm();
|
|
43
|
+
this.timer = this.timers.set(() => {
|
|
44
|
+
this.timer = undefined;
|
|
45
|
+
this.fired = true;
|
|
46
|
+
this.onStall();
|
|
47
|
+
}, this.ms);
|
|
48
|
+
// Never the reason a finished run stays alive (see docs/one-shot-exit.md).
|
|
49
|
+
(this.timer as { unref?: () => void })?.unref?.();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The message ended, or the run did: nothing to wait on. */
|
|
53
|
+
disarm(): void {
|
|
54
|
+
if (this.timer !== undefined) this.timers.clear(this.timer);
|
|
55
|
+
this.timer = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get stalled(): boolean {
|
|
59
|
+
return this.fired;
|
|
60
|
+
}
|
|
61
|
+
}
|