privateer-agent 0.12.38 → 0.12.39

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.
@@ -25,6 +25,7 @@ import { makeSaveCargoTool } from "../src/tools/cargo.ts";
25
25
  import { makeChartTools } from "../src/tools/charts.ts";
26
26
  import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
27
27
  import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
28
+ import { resolveMentions, searchFiles } from "../src/util/fileMentions.ts";
28
29
  import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
29
30
  import { makeSkillsControl } from "../src/remote/skillsControl.ts";
30
31
  import { agentDir } from "../src/config/paths.ts";
@@ -48,7 +49,9 @@ const allowedOutsideRoots: string[] = [];
48
49
  // SECOND prompt arriving while Pi is still processing — which throws "Agent is already
49
50
  // processing" and wedges the session. This happens in normal use when the app drops
50
51
  // (backgrounded → socket suspended) and re-sends its prompt on reconnect. Mirrors the
51
- // REPL's `turnActive` guard. Set on a successful sendUserMessage, cleared on agent_end.
52
+ // REPL's `turnActive` guard. Claimed as soon as a prompt is accepted — ahead of the
53
+ // mention expansion, which reads files and so opens a window a second prompt could
54
+ // slip through — released again if the send fails, cleared on agent_end.
52
55
  let remoteTurnActive = false;
53
56
 
54
57
  let piRef: any = null;
@@ -149,7 +152,7 @@ async function switchModelRemote(spec: string): Promise<void> {
149
152
  const ok = await piRef?.setModel?.(model);
150
153
  if (ok === false) { relay?.sendNotice(`No API key for ${p} — can't switch to ${sp}.`); return; }
151
154
  currentSpec = sp;
152
- relay?.sendContext({ model: currentSpec, version: agentVersion() }); // banner follows
155
+ relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() }); // banner follows
153
156
  relay?.sendNotice(`model → ${sp}`);
154
157
  } catch (e) {
155
158
  relay?.sendNotice(`Couldn't switch model: ${(e as Error).message}`);
@@ -344,6 +347,12 @@ const bridge = new RemoteBridge({
344
347
  relay?.sendNotice("busy — a turn is already running; wait for it to finish.");
345
348
  return;
346
349
  }
350
+ // Claim the turn BEFORE the awaits below, not after the send. Expanding mentions
351
+ // reads files, so the send is no longer synchronous with this callback — a second
352
+ // prompt arriving in that window would pass the guard above and land Pi with two
353
+ // turns. Released again on any failure path, so a refused send can't wedge the
354
+ // bridge; the success path leaves it set until agent_end.
355
+ remoteTurnActive = true;
347
356
  // Fold any files the app sent since the last prompt into a reference note so the
348
357
  // model knows they exist and can save_attachment them.
349
358
  const atts = sinceLastPrompt;
@@ -352,14 +361,32 @@ const bridge = new RemoteBridge({
352
361
  ? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
353
362
  `Use the save_attachment tool with the ref number to write one to disk.]`
354
363
  : "";
355
- try {
356
- piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
357
- remoteTurnActive = true; // cleared on agent_end
358
- } catch (e) {
359
- // A synchronous "already processing" (or any send failure) must not wedge the
360
- // bridge — surface it and stay idle so the next prompt still works.
361
- relay?.sendNotice(`couldn't start turn: ${(e as Error).message}`);
362
- }
364
+ void (async () => {
365
+ try {
366
+ // Expand any `@path` mentions into appended <file> blocks + image attachments,
367
+ // resolved against this terminal's cwd (constrained to the cwd subtree). The
368
+ // REPL (src/cli/chat.ts) and the desktop session (agentSession.ts) both do this;
369
+ // the shipped TUI did not, so the one surface with no Tab key — a phone driving
370
+ // this terminal — was the only one where `@file` did nothing at all.
371
+ const cwd = process.cwd();
372
+ const mentions = await resolveMentions(text, cwd);
373
+ if (mentions.skipped.length) {
374
+ relay?.sendNotice(`Couldn't attach: ${mentions.skipped.join(", ")} (must be a file inside ${cwd})`);
375
+ }
376
+ const body = mentions.text + note;
377
+ // Images ride as content parts — pi's sendUserMessage takes the same
378
+ // {type:"image",data,mimeType} shape resolveMentions already emits, so a
379
+ // mentioned screenshot reaches the model as a real attachment, not a path.
380
+ piRef?.sendUserMessage?.(
381
+ mentions.images.length ? [{ type: "text", text: body }, ...mentions.images] : body,
382
+ ); // drive a turn in Pi's TUI
383
+ } catch (e) {
384
+ // An "already processing" (or any send/expansion failure) must not wedge the
385
+ // bridge — surface it and stay idle so the next prompt still works.
386
+ remoteTurnActive = false;
387
+ relay?.sendNotice(`couldn't start turn: ${(e as Error).message}`);
388
+ }
389
+ })();
363
390
  },
364
391
  onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
365
392
  // The app asked to end remote access from its side — stop the relay locally too so
@@ -383,10 +410,23 @@ const bridge = new RemoteBridge({
383
410
  // terminal, and advertise the slash commands for the composer's autocomplete.
384
411
  setRemoteState("connected");
385
412
  relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
386
- relay?.sendContext({ model: currentSpec, version: agentVersion() });
413
+ // cwd rides along here (home-collapsed on the way out, see RelayClient.sendContext).
414
+ // Without it the app's composer shows no working-directory strip at all — and that
415
+ // strip is the only place a driver can see which folder the prompts they type are
416
+ // reading, writing and `@`-mentioning against.
417
+ relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() });
387
418
  relay?.sendCommands(advertiseCommands());
388
419
  },
389
420
  onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
421
+ // The app composer is autocompleting an `@file` mention — list the cwd entries
422
+ // matching the query and reply. Read-only + cwd-constrained (searchFiles never
423
+ // escapes the subtree); resolution of the picked path happens in onPrompt above.
424
+ // Unanswered, the app's searchFiles() times out to [] after 4s and the palette
425
+ // reads "no files" — indistinguishable from an empty project.
426
+ onFilesSearch: (id, query) => void (async () => {
427
+ try { bridge.sendFileMatches(id, await searchFiles(query, process.cwd())); }
428
+ catch { bridge.sendFileMatches(id, []); }
429
+ })(),
390
430
  // Drive the indicator from the relay's own status stream: "connected" → green;
391
431
  // its reconnect/retry notices → yellow "connecting…". Ignored once we're off.
392
432
  onStatus: (text) => {
@@ -528,7 +568,7 @@ export default function privateerControl(pi: any): void {
528
568
  pi.on("model_select", (ev: any) => {
529
569
  if (ev?.model) {
530
570
  currentSpec = modelSpec(ev.model);
531
- relay?.sendContext({ model: currentSpec, version: agentVersion() });
571
+ relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() });
532
572
  }
533
573
  });
534
574
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.38",
3
+ "version": "0.12.39",
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",
@@ -225,6 +225,9 @@ const MEDIA_TITLES: Record<string, string> = {
225
225
  video_compose: "Compose video/audio locally",
226
226
  media_capabilities: "Read media capabilities",
227
227
  };
228
+ // Not in MEDIA_TITLES: it is the same tool, told apart by its arguments rather
229
+ // than its name (see `resuming` below).
230
+ const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
228
231
 
229
232
  export function classifyToolCall(
230
233
  toolName: string,
@@ -478,6 +481,14 @@ export function classifyToolCall(
478
481
  // even when the output lands neatly in cwd.
479
482
  if (MEDIA_TOOLS.has(name)) {
480
483
  const compose = name === "video_compose";
484
+ // A generate_video RESUME submits nothing and bills nothing — it goes back to
485
+ // waiting on a job the account has already paid for and writes the file. So it
486
+ // is an ordinary write, not a billed one: the title must not claim a charge
487
+ // that isn't happening, and `alwaysAsk` must not make re-prompting the cheaper
488
+ // path than re-generating. Getting that backwards is what teaches a model to
489
+ // pay twice.
490
+ const resuming = name === "generate_video" && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
491
+ const mediaTitle = resuming ? RESUME_VIDEO_TITLE : MEDIA_TITLES[name];
481
492
  const inputs = [
482
493
  ...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
483
494
  str(obj.input),
@@ -549,13 +560,11 @@ export function classifyToolCall(
549
560
  return {
550
561
  tool: toolName,
551
562
  kind: "write",
552
- title: outside
553
- ? `${MEDIA_TITLES[name]} outside working directory`
554
- : MEDIA_TITLES[name],
563
+ title: outside ? `${mediaTitle} outside working directory` : mediaTitle,
555
564
  detail: `${outputOutside ? absOut : outPath}${inputNote}`,
556
565
  protected: isProtectedPath(absOut) || protectedInputs.length > 0,
557
566
  outside,
558
- alwaysAsk: BILLED_MEDIA_TOOLS.has(name),
567
+ alwaysAsk: BILLED_MEDIA_TOOLS.has(name) && !resuming,
559
568
  path: absOut,
560
569
  };
561
570
  }
@@ -56,8 +56,23 @@ export const MEDIA_TOOL_NAMES = [
56
56
 
57
57
  // A video job can legitimately take minutes. Bound the wait so a wedged provider
58
58
  // doesn't pin an unattended run forever; the job id is reported on timeout so the
59
- // caller can resume the poll rather than pay for another generation.
60
- const VIDEO_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_VIDEO_TIMEOUT_MS) || 12 * 60_000;
59
+ // caller can resume the poll (`resumeJobId`) rather than pay for another generation.
60
+ //
61
+ // TWENTY-FIVE, AND THE RESUME PARAM, ARE ONE FIX. At twelve this was the only
62
+ // surface that gave up on a job at all — the app's own poller (ChatScreen,
63
+ // libraryService) runs on a bare setInterval with no deadline, so the identical
64
+ // job that lands fine in a chat was abandoned in a terminal — and there was
65
+ // nothing to resume WITH: `generate_video` took no job id, so the sentence above
66
+ // described a recovery the tool could not perform. The model's only move was to
67
+ // call generate_video again, which bills a second generation and starts a second
68
+ // wait, which is what "video generation hangs" looks like from the outside.
69
+ //
70
+ // The ceiling stays because an unattended run must not be pinned forever, and 25
71
+ // is not arbitrary: the desktop's turn supervisor abandons a turn whose open tool
72
+ // has been silent for 30 minutes (desktop/src/main/turnSupervisor.ts toolStallMs),
73
+ // and a tool that outlives its own supervisor is abandoned mid-poll with the job
74
+ // uncollected — exactly the failure this is fixing. Keep it under that budget.
75
+ const VIDEO_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_VIDEO_TIMEOUT_MS) || 25 * 60_000;
61
76
  const VIDEO_POLL_INTERVAL_MS = 5_000;
62
77
  // A mesh job runs about a minute at the provider's stated typical time, and
63
78
  // several for a large face count. Same bounded-wait contract as video: the job
@@ -334,10 +349,18 @@ export const generateVideoToolDefinition = {
334
349
  "extracted with video_compose, then start the next from it. Generation takes minutes and this tool " +
335
350
  "waits for it. Expensive (roughly $0.10-$1 a clip) and billed to the user's Privateer account, so " +
336
351
  "plan the shot before calling. Clip lengths and aspect ratios are model-specific — check " +
337
- "media_capabilities first if unsure. Stitch the finished clips with video_compose.",
352
+ "media_capabilities first if unsure. Stitch the finished clips with video_compose.\n" +
353
+ "If a call comes back saying the job is still running, DO NOT call this again with the same " +
354
+ "prompt — that bills a second generation. Call it with `resumeJobId` set to the job id it " +
355
+ "reported (and the same `path`) to keep waiting on the clip the account has already paid for.",
338
356
  parameters: Type.Object({
339
357
  prompt: Type.String({ description: "What happens in the shot: subject, action, camera move, style." }),
340
358
  path: Type.String({ description: "Where to write the video, relative to cwd or absolute (e.g. 'clips/01-opening.mp4')." }),
359
+ resumeJobId: Type.Optional(Type.String({
360
+ description:
361
+ "Resume waiting on a job already submitted (from a previous call that timed out). " +
362
+ "Nothing is generated and nothing is billed: it only polls and saves. `prompt` is ignored.",
363
+ })),
341
364
  firstFrame: Type.Optional(Type.String({ description: "Path to an image to use as the opening frame (image-to-video)." })),
342
365
  lastFrame: Type.Optional(Type.String({ description: "Path to an image to use as the closing frame. Requires firstFrame." })),
343
366
  seconds: Type.Optional(Type.Number({ description: "Clip length in seconds. Only certain values are legal per model — see media_capabilities." })),
@@ -349,7 +372,7 @@ export const generateVideoToolDefinition = {
349
372
  async execute(
350
373
  _toolCallId: string,
351
374
  params: {
352
- prompt: string; path: string; firstFrame?: string; lastFrame?: string;
375
+ prompt: string; path: string; resumeJobId?: string; firstFrame?: string; lastFrame?: string;
353
376
  seconds?: number; aspectRatio?: string; resolution?: string; audio?: boolean; model?: string;
354
377
  },
355
378
  signal?: AbortSignal,
@@ -357,9 +380,18 @@ export const generateVideoToolDefinition = {
357
380
  ctx?: { cwd?: string },
358
381
  ) {
359
382
  const cwd = ctx?.cwd ?? process.cwd();
383
+ if (!params.path) return text("Error: path is required — say where to save the video.");
384
+
385
+ // RESUME. Submit nothing, bill nothing — just go back to waiting on a job the
386
+ // account has already paid for. Everything below the poll is identical, which
387
+ // is why the loop lives in awaitVideoJob() rather than being duplicated here.
388
+ const resumeJobId = String(params.resumeJobId ?? "").trim();
389
+ if (resumeJobId) {
390
+ return awaitVideoJob(resumeJobId, cwd, params.path, null, signal);
391
+ }
392
+
360
393
  const prompt = String(params.prompt ?? "").trim();
361
394
  if (!prompt) return text("Error: prompt is required.");
362
- if (!params.path) return text("Error: path is required — say where to save the video.");
363
395
  if (params.lastFrame && !params.firstFrame) return text("Error: lastFrame needs firstFrame alongside it.");
364
396
 
365
397
  let firstFrame: { data: string; mimeType: string } | undefined;
@@ -389,45 +421,72 @@ export const generateVideoToolDefinition = {
389
421
  const jobId = submitted.data.jobId;
390
422
  if (!jobId) return text("Video generation failed: Privateer did not return a job id.");
391
423
 
392
- // Poll to completion. The account is charged when the provider delivers, so an
393
- // abandoned poll still costs money — hence the timeout message names the job id.
394
- const deadline = Date.now() + VIDEO_POLL_TIMEOUT_MS;
395
- const cancelled = () =>
396
- text(`Video job ${jobId} was submitted but the wait was cancelled. It is still running and will still be billed.`);
397
- for (;;) {
398
- if (signal?.aborted) return cancelled();
399
- await sleep(VIDEO_POLL_INTERVAL_MS, signal);
400
- // sleep() resolves early on abort, so re-check before spending a request on a
401
- // signal that is already dead — otherwise the cancel surfaces as a network error.
402
- if (signal?.aborted) return cancelled();
403
- const poll = await callAccount<VideoStatusResponse>(`/api/agent/media/videos/${encodeURIComponent(jobId)}`, {
404
- method: "GET",
405
- signal,
406
- });
407
- if (!poll.ok) return text(`Video job ${jobId} could not be polled: ${poll.message}`);
424
+ return awaitVideoJob(jobId, cwd, params.path, submitted.data.model ?? null, signal);
425
+ },
426
+ };
408
427
 
409
- const status = String(poll.data.status ?? "").toLowerCase();
410
- if (status === "failed") return text(`Video generation failed: ${poll.data.message ?? "the provider reported a failure"}.`);
411
- if (status === "completed") {
412
- if (!poll.data.data) {
413
- // The bytes were handed out on an earlier poll and are not stored anywhere.
414
- return text(`Video job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the file is missing.`);
415
- }
416
- const target = abs(cwd, params.path);
417
- const ext = extname(target) || extForMime(poll.data.mimeType ?? "", ".mp4");
418
- const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
419
- const summary = writeOut(out, Buffer.from(poll.data.data, "base64"));
420
- return text(`Generated video with ${poll.data.model ?? submitted.data.model ?? "the account video model"}: ${summary}`);
421
- }
422
- if (Date.now() > deadline) {
423
- return text(
424
- `Video job ${jobId} is still ${status || "running"} after ${Math.round(VIDEO_POLL_TIMEOUT_MS / 60000)} minutes. ` +
425
- "It will still complete and still be billed; nothing was saved here.",
426
- );
428
+ /**
429
+ * Wait on a submitted video job and write its bytes to `path`.
430
+ *
431
+ * Split out of execute() so the RESUME path is the same code rather than a second
432
+ * copy of it: the bytes are delivered exactly once and are stored nowhere, so a
433
+ * resume that polled differently from the original wait would be the one place a
434
+ * paid clip could be dropped.
435
+ *
436
+ * `submittedModel` is null on a resume — we did not submit, so we have no model
437
+ * name of our own. The poll reports one anyway; the fallback only covers a server
438
+ * that returns neither.
439
+ */
440
+ async function awaitVideoJob(
441
+ jobId: string,
442
+ cwd: string,
443
+ path: string,
444
+ submittedModel: string | null,
445
+ signal?: AbortSignal,
446
+ ) {
447
+ // The account is charged when the provider delivers, so an abandoned poll still
448
+ // costs money — every exit below names the job id, and `resumeJobId` is what
449
+ // turns that id back into the file.
450
+ const deadline = Date.now() + VIDEO_POLL_TIMEOUT_MS;
451
+ const resumeHint = `Resume it with generate_video { resumeJobId: "${jobId}", path: "${path}" } — that waits on this same clip and bills nothing further.`;
452
+ const cancelled = () =>
453
+ text(`Video job ${jobId} is still running and will still be billed; the wait was cancelled. ${resumeHint}`);
454
+ for (;;) {
455
+ if (signal?.aborted) return cancelled();
456
+ await sleep(VIDEO_POLL_INTERVAL_MS, signal);
457
+ // sleep() resolves early on abort, so re-check before spending a request on a
458
+ // signal that is already dead — otherwise the cancel surfaces as a network error.
459
+ if (signal?.aborted) return cancelled();
460
+ const poll = await callAccount<VideoStatusResponse>(`/api/agent/media/videos/${encodeURIComponent(jobId)}`, {
461
+ method: "GET",
462
+ signal,
463
+ });
464
+ // A poll that fails is NOT the job failing — the clip is still coming and is
465
+ // still billed, so this has to point at the resume too. Without that the model
466
+ // reads a dropped request as a dead job and generates the whole thing again.
467
+ if (!poll.ok) return text(`Video job ${jobId} could not be polled: ${poll.message}. ${resumeHint}`);
468
+
469
+ const status = String(poll.data.status ?? "").toLowerCase();
470
+ if (status === "failed") return text(`Video generation failed: ${poll.data.message ?? "the provider reported a failure"}.`);
471
+ if (status === "completed") {
472
+ if (!poll.data.data) {
473
+ // The bytes were handed out on an earlier poll and are not stored anywhere.
474
+ return text(`Video job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the file is missing.`);
427
475
  }
476
+ const target = abs(cwd, path);
477
+ const ext = extname(target) || extForMime(poll.data.mimeType ?? "", ".mp4");
478
+ const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
479
+ const summary = writeOut(out, Buffer.from(poll.data.data, "base64"));
480
+ return text(`Generated video with ${poll.data.model ?? submittedModel ?? "the account video model"}: ${summary}`);
428
481
  }
429
- },
430
- };
482
+ if (Date.now() > deadline) {
483
+ return text(
484
+ `Video job ${jobId} is still ${status || "running"} after ${Math.round(VIDEO_POLL_TIMEOUT_MS / 60000)} minutes. ` +
485
+ `It will still complete and is already billed — do NOT generate it again. ${resumeHint}`,
486
+ );
487
+ }
488
+ }
489
+ }
431
490
 
432
491
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
433
492
  return new Promise((resolve_) => {