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 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**, and inside **tmux** on any platform; other terminals
406
- get told so. macOS may ask once for permission to let the terminal control itself. Names are
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,9 @@
1
+ // Types for bin/model-args.mjs — plain .mjs because bin/ runs under a bare `node`.
2
+
3
+ export function modelArgs(o: {
4
+ launchArgs: string[];
5
+ envModel?: string;
6
+ savedDefault?: string | null;
7
+ signedIn: boolean;
8
+ computed: string;
9
+ }): string[];
@@ -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));
@@ -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 userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
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
- if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args));
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 resident background Harbor daemon (run/install/uninstall/status)`,
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
- if (!headlessRun) return;
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, writeFileSync, mkdirSync } from "node:fs";
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
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
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.0",
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.7.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",
@@ -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
- cfg = JSON.parse(readFileSync(configPath(), "utf8"));
154
+ raw = readFileSync(configPath(), "utf8");
152
155
  } catch {
153
- log(`no config at ${configPath()} — add a channels block (see run.ts header).`);
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
- log("no channels started — configure a channels.<platform> block in config.json.");
408
- process.exit(1);
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(() => writeChannelsStatus(startedPlatforms), HEARTBEAT_MS);
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, writeFileSync, mkdirSync } from "node:fs";
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
- writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
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" },
@@ -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, writeFileSync } from "node:fs";
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
- writeFileSync(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
+ writeFileAtomic(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
94
  invalidate();
94
95
  }
95
96
 
@@ -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.0",
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
+ }