privateer-agent 0.2.1 → 0.3.1

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.
Files changed (111) hide show
  1. package/README.md +167 -442
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +77 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -1,110 +1,129 @@
1
- import { tool } from "ai";
2
- import { z } from "zod";
3
- import type { ToolContext } from "./context.ts";
4
- import { PermissionDeniedError } from "../permissions/gate.ts";
5
- import { DELIVERY_CHANNELS, newRoutineId, type Routine } from "../routines/schema.ts";
1
+ // The `create_routine` tool lets the agent turn "summarize the news every morning"
2
+ // or "remind me at 3pm tomorrow" into a saved routine the scheduler daemon runs
3
+ // unattended (see src/daemon). Ported from tree-cli/src/tools/routine.ts, adapted to
4
+ // Pi's registerTool (TypeBox schema) with the in-tool ctx.gate.request removed: in
5
+ // the Pi model our permission-gate extension gates the tool_call itself (classify.ts
6
+ // gives it a "Create routine" prompt + flags email/webhook egress).
7
+
8
+ import { Type } from "typebox";
9
+ import { readFileSync } from "node:fs";
10
+ import { configPath } from "../config/paths.ts";
11
+ import { newRoutineId, webhookName, type Routine } from "../routines/schema.ts";
6
12
  import { triggerError, computeNextRun, describeTrigger } from "../routines/trigger.ts";
7
- import { splitRoutineTools } from "../routines/toolSelect.ts";
8
13
  import { upsertRoutine } from "../routines/store.ts";
9
14
  import { sendToDaemon, DaemonNotRunningError } from "../daemon/ipc.ts";
10
15
 
11
- // Lets the agent turn a request like "summarize world news every morning" or
12
- // "remind me at 3pm tomorrow" into a saved routine. Creating one is a persistent
13
- // mutation, so it routes through the permission gate — the agent proposes, the user
14
- // approves, then it lands in routines.json and the running daemon picks it up.
15
- export function routineTool(ctx: ToolContext) {
16
- return tool({
17
- description:
18
- "Create a routine: a saved task the scheduler runs unattended, either recurring (a cron " +
19
- "expression) or one-off (a specific datetime), and delivers the result. Use when the user " +
20
- "asks to be notified/updated on a cadence ('every morning', 'nightly') or at a future time " +
21
- "('at 3pm tomorrow'). Set exactly one of `cron` or `at`. Confirm timing + delivery with the " +
22
- "user first; this prompts for approval before saving. Runs use a safe read/web toolset (no " +
23
- "writing or shell) unless `tools` grants more.",
24
- inputSchema: z.object({
25
- name: z.string().describe("Short unique label, e.g. 'morning-news'."),
26
- cron: z
27
- .string()
28
- .optional()
29
- .describe("Recurring: standard 5-field cron, e.g. '0 8 * * *' for 08:00 daily. Omit for one-off."),
30
- at: z
31
- .string()
32
- .optional()
33
- .describe("One-off: ISO-8601 datetime, e.g. '2026-07-02T15:00:00'. Omit for recurring."),
34
- prompt: z.string().describe("Self-contained instruction the agent runs when it fires."),
35
- delivery: z
36
- .array(z.enum(DELIVERY_CHANNELS))
37
- .optional()
38
- .describe("How to deliver the result. Defaults to ['file']. 'email' leaves the machine (opt-in)."),
39
- cwd: z.string().optional().describe("Working directory for the run. Defaults to the current one."),
40
- model: z.string().optional().describe("Optional 'provider:model' override."),
41
- tools: z
42
- .array(z.string())
43
- .optional()
44
- .describe(
45
- "Tool allow-list: builtin names ('read') and/or MCP selectors '<server>__<tool>' or " +
46
- "'<server>__*'. MCP tools run unattended with no approval prompts — grant only what " +
47
- "the task needs. Omit for the default safe read/web set.",
48
- ),
49
- }),
50
- execute: async ({ name, cron, at, prompt, delivery, cwd, model, tools }) => {
51
- const err = triggerError({ cron, at });
52
- if (err) return `Error: ${err}`;
16
+ const KNOWN_CHANNELS = new Set(["file", "relay", "notice", "cloud", "email"]);
17
+
18
+ function loadWebhooks(): Record<string, { url?: string }> {
19
+ try {
20
+ return JSON.parse(readFileSync(configPath(), "utf8")).webhooks ?? {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function text(t: string) {
27
+ return { content: [{ type: "text", text: t }], details: {} };
28
+ }
53
29
 
54
- const chans = delivery && delivery.length > 0 ? delivery : ["file" as const];
55
- const next = computeNextRun({ cron, at });
56
- const detail =
57
- `${name}: ${describeTrigger({ cron, at })}` +
58
- (next ? ` (next ${next.toLocaleString()})` : "") +
59
- ` ${chans.join(",")}`;
30
+ export const routineToolDefinition = {
31
+ name: "create_routine",
32
+ label: "Create Routine",
33
+ description:
34
+ "Create a routine: a saved task the scheduler runs unattended, either recurring (a cron " +
35
+ "expression) or one-off (a specific datetime), and delivers the result. Use when the user asks " +
36
+ "to be notified/updated on a cadence ('every morning', 'nightly') or at a future time ('at 3pm " +
37
+ "tomorrow'). Set exactly one of `cron` or `at`. Confirm timing + delivery with the user first; " +
38
+ "creating a routine prompts for approval. Runs use a safe read-only toolset unless `tools` grants more.",
39
+ parameters: Type.Object({
40
+ name: Type.String({ description: "Short unique label, e.g. 'morning-news'." }),
41
+ cron: Type.Optional(Type.String({ description: "Recurring: 5-field cron, e.g. '0 8 * * *' (08:00 daily). Omit for one-off." })),
42
+ at: Type.Optional(Type.String({ description: "One-off: ISO-8601 datetime, e.g. '2026-07-02T15:00:00'. Omit for recurring." })),
43
+ prompt: Type.String({ description: "Self-contained instruction the agent runs when it fires." }),
44
+ delivery: Type.Optional(
45
+ Type.Array(Type.String(), {
46
+ description:
47
+ "Where to deliver: 'file' (default), 'notice', 'relay', 'cloud' (E2E-encrypted to your Privateer " +
48
+ "account), 'email' (plaintext, opt-in), or 'webhook:<name>' (name must exist in config webhooks).",
49
+ }),
50
+ ),
51
+ cwd: Type.Optional(Type.String({ description: "Working directory for the run. Defaults to the current one." })),
52
+ model: Type.Optional(Type.String({ description: "Optional 'provider:model' or 'provider/model' override." })),
53
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Tool allow-list; omit for the default safe set." })),
54
+ }),
55
+ async execute(
56
+ _toolCallId: string,
57
+ params: {
58
+ name: string;
59
+ cron?: string;
60
+ at?: string;
61
+ prompt: string;
62
+ delivery?: string[];
63
+ cwd?: string;
64
+ model?: string;
65
+ tools?: string[];
66
+ },
67
+ _signal?: AbortSignal,
68
+ _onUpdate?: unknown,
69
+ ctx?: { cwd?: string },
70
+ ) {
71
+ const { name, cron, at, prompt, delivery, cwd, model, tools } = params;
60
72
 
61
- // Confirm with the user. Egress grants are flagged so the human sees them:
62
- // email delivery, and any MCP tools — those run unattended under the daemon's
63
- // auto-approve gate, so this approval is the only human decision they get.
64
- const split = splitRoutineTools(tools);
65
- const flags: string[] = [];
66
- if (chans.includes("email")) flags.push("email leaves your machine");
67
- if (split.mcp.length > 0) flags.push(`grants external MCP tools, unattended: ${split.mcp.join(", ")}`);
68
- const decision = await ctx.gate.request({
69
- tool: "routine",
70
- kind: "write",
71
- title: "Create routine",
72
- detail: flags.length > 0 ? `${detail} [${flags.join("] [")}]` : detail,
73
- // An MCP grant must always reach the human, above bypass mode/allowlists.
74
- alwaysAsk: split.mcp.length > 0,
75
- });
76
- if (decision === "deny") throw new PermissionDeniedError("routine");
73
+ const err = triggerError({ cron, at });
74
+ if (err) return text(`Error: ${err}`);
77
75
 
78
- const routine: Routine = {
79
- id: newRoutineId(),
80
- name,
81
- cron,
82
- at,
83
- prompt,
84
- cwd: cwd ?? ctx.cwd,
85
- model,
86
- delivery: chans,
87
- tools,
88
- enabled: true,
89
- nextRun: next?.toISOString(),
90
- };
76
+ const chans = delivery && delivery.length > 0 ? delivery : ["file"];
77
+ // Validate channels: known names or webhook:<name>.
78
+ const bad = chans.filter((c) => !KNOWN_CHANNELS.has(c) && webhookName(c) === null);
79
+ if (bad.length > 0) return text(`Error: unknown delivery channel(s): ${bad.join(", ")}.`);
91
80
 
92
- // Prefer handing it to the running daemon (it validates + schedules); fall back
93
- // to writing the file directly so the routine persists until the daemon starts.
94
- try {
95
- const res = await sendToDaemon({ cmd: "add", routine });
96
- if (!res.ok) return `Error saving routine: ${res.message ?? "unknown"}`;
97
- return `Created routine "${name}" (${describeTrigger({ cron, at })}). Next run ${next ? next.toLocaleString() : "unknown"}, delivery: ${chans.join(", ")}.`;
98
- } catch (e) {
99
- if (e instanceof DaemonNotRunningError) {
100
- upsertRoutine(routine);
101
- return (
102
- `Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler daemon isn't ` +
103
- `running yet, so it won't fire until you start it: run \`privateer daemon\` (or \`privateer daemon --detach\`).`
104
- );
105
- }
106
- return `Error contacting the scheduler: ${e instanceof Error ? e.message : String(e)}`;
81
+ // Webhook targets must be pre-declared in config (the routine never carries a URL).
82
+ const hooks = chans.map(webhookName).filter((n): n is string => n !== null);
83
+ if (hooks.length > 0) {
84
+ const configured = loadWebhooks();
85
+ const unknown = hooks.filter((n) => !configured[n]);
86
+ if (unknown.length > 0) {
87
+ return text(
88
+ `Error: webhook${unknown.length > 1 ? "s" : ""} not configured: ${unknown.join(", ")}. ` +
89
+ `Declare them under "webhooks" in ~/.privateer/config.json first.`,
90
+ );
107
91
  }
108
- },
109
- });
110
- }
92
+ }
93
+
94
+ const next = computeNextRun({ cron, at });
95
+ const routine: Routine = {
96
+ id: newRoutineId(),
97
+ name,
98
+ cron,
99
+ at,
100
+ prompt,
101
+ cwd: cwd ?? ctx?.cwd ?? process.cwd(),
102
+ model,
103
+ delivery: chans as Routine["delivery"],
104
+ tools,
105
+ enabled: true,
106
+ nextRun: next?.toISOString(),
107
+ };
108
+
109
+ // Hand to the running daemon (it validates + schedules); fall back to writing the
110
+ // file so the routine persists until the daemon starts.
111
+ try {
112
+ const res = await sendToDaemon({ cmd: "add", routine });
113
+ if (!res.ok) return text(`Error saving routine: ${res.message ?? "unknown"}`);
114
+ return text(
115
+ `Created routine "${name}" (${describeTrigger({ cron, at })}). ` +
116
+ `Next run ${next ? next.toLocaleString() : "unknown"}, delivery: ${chans.join(", ")}.`,
117
+ );
118
+ } catch (e) {
119
+ if (e instanceof DaemonNotRunningError) {
120
+ upsertRoutine(routine);
121
+ return text(
122
+ `Saved routine "${name}" (${describeTrigger({ cron, at })}), but the scheduler daemon isn't ` +
123
+ `running yet, so it won't fire until you start it.`,
124
+ );
125
+ }
126
+ return text(`Error contacting the scheduler: ${e instanceof Error ? e.message : String(e)}`);
127
+ }
128
+ },
129
+ };
@@ -1,53 +1,50 @@
1
+ // The `save_attachment` tool — persist a file the user attached from the app (shown
2
+ // in the prompt as "[Attachment #n] name") to a real path on disk. Ported from
3
+ // tree-cli to Pi registerTool; the in-tool ctx.gate.request is dropped (our gate
4
+ // extension gates the tool_call, classified as a write against the destination path).
5
+
6
+ import { Type } from "typebox";
1
7
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { tool } from "ai";
4
- import { z } from "zod";
5
- import type { ToolContext } from "./context.ts";
6
- import { resolveInCwd, displayPath, isOutsideScope } from "./context.ts";
7
- import { PermissionDeniedError } from "../permissions/gate.ts";
8
- import { isProtectedPath } from "../permissions/protected.ts";
8
+ import { dirname, isAbsolute, resolve } from "node:path";
9
+ import type { AttachmentStore } from "../util/attachmentStore.ts";
10
+
11
+ function text(t: string) {
12
+ return { content: [{ type: "text", text: t }], details: {} };
13
+ }
9
14
 
10
- export function saveAttachmentTool(ctx: ToolContext) {
11
- return tool({
15
+ export function makeSaveAttachmentTool(store: AttachmentStore) {
16
+ return {
17
+ name: "save_attachment",
18
+ label: "Save Attachment",
12
19
  description:
13
- "Save a user-attached file (image, PDF, audio, or video shown in the prompt as a " +
14
- '"[Image #n]" / "[PDF #n]" / etc. chip) to a path on disk. Use this to persist a pasted ' +
15
- "or dragged-in attachment instead of trying to copy it from a temp or drop path, which is " +
16
- "unreliable and may be an empty placeholder. `ref` is the number n from the chip.",
17
- inputSchema: z.object({
18
- ref: z
19
- .number()
20
- .int()
21
- .describe("The attachment reference number n, taken from its [Kind #n] chip."),
22
- path: z.string().describe("Destination file path to write the attachment to."),
20
+ "Save a file the user attached from the Privateer app (shown in the prompt as a \"[Attachment #n] " +
21
+ "name\" chip) to a path on disk. Use this to persist a received attachment. `ref` is the number n.",
22
+ parameters: Type.Object({
23
+ ref: Type.Integer({ description: "The attachment reference number n from its [Attachment #n] chip." }),
24
+ path: Type.String({ description: "Destination file path to write the attachment to." }),
23
25
  }),
24
- execute: async ({ ref, path }) => {
25
- const store = ctx.attachments;
26
- const entry = store?.get(ref);
26
+ async execute(
27
+ _toolCallId: string,
28
+ params: { ref: number; path: string },
29
+ _signal?: AbortSignal,
30
+ _onUpdate?: unknown,
31
+ ctx?: { cwd?: string },
32
+ ) {
33
+ const entry = store.get(params.ref);
27
34
  if (!entry) {
28
- const avail = store?.refs() ?? [];
29
- return avail.length
30
- ? `No attachment #${ref}. Available this session: ${avail.map((n) => `#${n}`).join(", ")}.`
31
- : `No attachment #${ref}: nothing has been attached this session.`;
35
+ const avail = store.refs();
36
+ return text(
37
+ avail.length
38
+ ? `No attachment #${params.ref}. Available this session: ${avail.map((n) => `#${n}`).join(", ")}.`
39
+ : `No attachment #${params.ref}: nothing has been attached this session.`,
40
+ );
32
41
  }
33
- const abs = resolveInCwd(ctx, path);
34
- const outside = isOutsideScope(ctx, abs);
35
- const decision = await ctx.gate.request({
36
- tool: "save_attachment",
37
- kind: "write",
38
- title: outside ? "Save attachment outside working directory" : "Save attachment",
39
- detail: `[#${ref}] → ${outside ? abs : displayPath(ctx, abs)} (${entry.mediaType})`,
40
- protected: isProtectedPath(abs),
41
- outside,
42
- path: abs,
43
- });
44
- if (decision === "deny") throw new PermissionDeniedError("save_attachment");
45
-
46
- ctx.recordMutation?.(abs);
42
+ const cwd = ctx?.cwd ?? process.cwd();
43
+ const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
47
44
  mkdirSync(dirname(abs), { recursive: true });
48
45
  const bytes = readFileSync(entry.path);
49
46
  writeFileSync(abs, bytes);
50
- return `Saved attachment #${ref} to ${displayPath(ctx, abs)} (${bytes.length} bytes).`;
47
+ return text(`Saved attachment #${params.ref} (${entry.name}, ${entry.mediaType}) to ${params.path} (${bytes.length} bytes).`);
51
48
  },
52
- });
49
+ };
53
50
  }
@@ -0,0 +1,75 @@
1
+ // The `send_file_to_client` tool — stream a file from disk up to the connected
2
+ // Privateer app (the phone/web client driving this terminal via /remote-access) so
3
+ // the user can preview/save it on their device. Ported from tree-cli, adapted to Pi
4
+ // registerTool + the RemoteBridge (which owns the relay's chunked sendFile). Gated by
5
+ // our permission-gate extension like any tool.
6
+
7
+ import { Type } from "typebox";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { basename, isAbsolute, resolve } from "node:path";
10
+
11
+ const MAX_SEND_BYTES = 10 * 1024 * 1024; // mirrors the relay's per-file cap
12
+
13
+ const MEDIA: Record<string, string> = {
14
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
15
+ svg: "image/svg+xml", pdf: "application/pdf", txt: "text/plain", md: "text/markdown",
16
+ json: "application/json", csv: "text/csv", html: "text/html", mp4: "video/mp4",
17
+ mov: "video/quicktime", mp3: "audio/mpeg", wav: "audio/wav", zip: "application/zip",
18
+ };
19
+ function mediaTypeForPath(p: string): string {
20
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
21
+ return MEDIA[ext] ?? "application/octet-stream";
22
+ }
23
+
24
+ function text(t: string) {
25
+ return { content: [{ type: "text", text: t }], details: {} };
26
+ }
27
+
28
+ export interface SendFileBridge {
29
+ isConnected(): boolean;
30
+ sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
31
+ }
32
+
33
+ export function makeSendFileTool(bridge: SendFileBridge) {
34
+ return {
35
+ name: "send_file_to_client",
36
+ label: "Send File to App",
37
+ description:
38
+ "Send a file from disk to the connected Privateer app (the phone/web client driving this terminal " +
39
+ "remotely), where the user can preview it and save/share it on their device. Use when the user asks " +
40
+ "to see, download, or receive a file on their phone or browser. Only works while remote access is on " +
41
+ "and the app is connected; max 10 MB per file.",
42
+ parameters: Type.Object({
43
+ path: Type.String({ description: "Path of the file to send, relative to cwd or absolute." }),
44
+ }),
45
+ async execute(
46
+ _toolCallId: string,
47
+ params: { path: string },
48
+ _signal?: AbortSignal,
49
+ _onUpdate?: unknown,
50
+ ctx?: { cwd?: string },
51
+ ) {
52
+ if (!bridge.isConnected()) {
53
+ return text(
54
+ "Cannot send: remote access is off or the app isn't connected right now. " +
55
+ "Turn it on with /remote-access and open the Privateer app, then try again.",
56
+ );
57
+ }
58
+ const cwd = ctx?.cwd ?? process.cwd();
59
+ const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
60
+ if (!existsSync(abs)) return text(`File not found: ${params.path}`);
61
+ const stat = statSync(abs);
62
+ if (stat.isDirectory()) return text(`${params.path} is a directory — send a single file.`);
63
+ if (stat.size === 0) return text(`${params.path} is empty — nothing to send.`);
64
+ if (stat.size > MAX_SEND_BYTES) {
65
+ return text(`${params.path} is ${(stat.size / 1048576).toFixed(1)} MB; the relay caps at ${MAX_SEND_BYTES / 1048576} MB per file.`);
66
+ }
67
+ const bytes = readFileSync(abs);
68
+ const mediaType = mediaTypeForPath(abs);
69
+ const res = await bridge.sendFile({ name: basename(abs), mediaType, base64: bytes.toString("base64"), size: bytes.length });
70
+ return res.ok
71
+ ? text(`Sent ${basename(abs)} (${bytes.length} bytes, ${mediaType}) to the connected app.`)
72
+ : text(`Couldn't send ${basename(abs)}: ${res.reason ?? "unknown error"}. Is the app currently connected?`);
73
+ },
74
+ };
75
+ }
@@ -1,54 +1,39 @@
1
1
  import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, extname } from "node:path";
4
- import type { Attachment, Modality } from "./images.ts";
5
4
 
6
- // One stored attachment: its bytes persisted to a stable scratch file, keyed by the
7
- // "#n" reference the user sees in the prompt.
5
+ // Holds the decoded bytes of files the app sends down over the relay, in a private
6
+ // scratch dir keyed by the "#n" reference the model sees, so the save_attachment tool
7
+ // can write one back out to a real path on demand. Ported/adapted from tree-cli
8
+ // (which persisted paste/drop bytes); here the source is inbound relay attachments.
9
+
8
10
  export interface StoredAttachment {
9
11
  n: number;
10
12
  path: string; // absolute scratch-file path holding the decoded bytes
11
13
  mediaType: string;
12
- modality: Modality;
13
- origin: string; // the token the user referenced (for display/debugging)
14
+ name: string; // original filename from the app
14
15
  }
15
16
 
16
- // Keeps the decoded bytes of every attachment seen this session in a private scratch
17
- // dir, keyed by its "[Kind #n]" reference, so the `save_attachment` tool can write one
18
- // back out to a real path on demand.
19
- //
20
- // This exists to close the macOS file-promise drop gap: the bytes we capture at
21
- // paste-time (in resolveAttachments) are the only durable copy — the terminal's
22
- // …/T/drop-XXXXXX/ file is a volatile stub that may already be gone or truncated by the
23
- // time the agent goes looking. We copy what we captured into a place we control, once,
24
- // instead of trusting that path twice.
25
17
  export class AttachmentStore {
26
18
  private dir: string | null = null;
27
19
  private readonly byN = new Map<number, StoredAttachment>();
20
+ private nextN = 1;
28
21
 
29
22
  private ensureDir(): string {
30
- // mkdtemp gives a 0700 dir; created lazily so a session that never attaches
31
- // anything leaves no temp files behind.
23
+ // mkdtemp gives a 0700 dir; created lazily so a session that never receives an
24
+ // attachment leaves no temp files behind.
32
25
  if (!this.dir) this.dir = mkdtempSync(join(tmpdir(), "privateer-att-"));
33
26
  return this.dir;
34
27
  }
35
28
 
36
- // Persist an attachment's bytes to the scratch dir, keyed by n. Idempotent: the same
37
- // n (a file referenced twice in a session) is written once and reused.
38
- register(att: Attachment): StoredAttachment | undefined {
39
- if (att.n == null) return undefined;
40
- const existing = this.byN.get(att.n);
41
- if (existing) return existing;
42
- const file = join(this.ensureDir(), `att-${att.n}${extname(att.path)}`);
43
- writeFileSync(file, Buffer.from(att.data, "base64"));
44
- const stored: StoredAttachment = {
45
- n: att.n,
46
- path: file,
47
- mediaType: att.mediaType,
48
- modality: att.modality,
49
- origin: att.path,
50
- };
51
- this.byN.set(att.n, stored);
29
+ // Persist an inbound attachment's bytes, assign it the next ref number, and return
30
+ // the stored record (its ref + scratch path).
31
+ register(file: { name: string; mediaType: string; base64: string }): StoredAttachment {
32
+ const n = this.nextN++;
33
+ const path = join(this.ensureDir(), `att-${n}${extname(file.name) || ""}`);
34
+ writeFileSync(path, Buffer.from(file.base64, "base64"));
35
+ const stored: StoredAttachment = { n, path, mediaType: file.mediaType, name: file.name };
36
+ this.byN.set(n, stored);
52
37
  return stored;
53
38
  }
54
39
 
@@ -56,13 +41,11 @@ export class AttachmentStore {
56
41
  return this.byN.get(n);
57
42
  }
58
43
 
59
- // The reference numbers currently held, ascending — for a helpful error when the
60
- // model asks for one that isn't there.
44
+ // Held reference numbers, ascending — for a helpful error when a missing one is asked for.
61
45
  refs(): number[] {
62
46
  return [...this.byN.keys()].sort((a, b) => a - b);
63
47
  }
64
48
 
65
- // Remove the scratch dir (e.g. on session exit). Safe to call when nothing was stored.
66
49
  cleanup(): void {
67
50
  if (!this.dir) return;
68
51
  rmSync(this.dir, { recursive: true, force: true });
@@ -6,11 +6,22 @@
6
6
  const PLACEHOLDER = "«redacted»";
7
7
 
8
8
  // Common API-key shapes, masked even when we don't have the exact value on hand:
9
- // OpenAI `sk-…`, Anthropic `sk-ant-…`, OpenRouter `sk-or-v1-…`, and bare
10
- // "Bearer <token>" / "x-api-key: <token>" header fragments.
9
+ // OpenAI `sk-…`, Anthropic `sk-ant-…`, OpenRouter `sk-or-v1-…`, Google `AIza…`,
10
+ // xAI `xai-…`, Groq `gsk_…`, Z.ai/Zhipu `<32 hex>.<suffix>`, Cerebras `csk-…`,
11
+ // Venice `vapi_…`, Fireworks `fw_…`, and bare
12
+ // "Bearer <token>" / "x-api-key: <token>" / "x-goog-api-key: <token>" header
13
+ // fragments. (Mistral and Together keys are prefix-less, so they rely on
14
+ // exact-value masking and the header patterns.)
11
15
  const KEY_PATTERNS: RegExp[] = [
12
16
  /\bsk-(ant|or|proj|live|test)?-?[A-Za-z0-9_-]{16,}\b/g,
13
- /\b(authorization|x-api-key)\b\s*[:=]\s*(bearer\s+)?["']?[A-Za-z0-9_\-.]{16,}["']?/gi,
17
+ /\bAIza[A-Za-z0-9_-]{20,}\b/g,
18
+ /\bxai-[A-Za-z0-9_-]{16,}\b/g,
19
+ /\bgsk_[A-Za-z0-9_-]{16,}\b/g,
20
+ /\bcsk-[A-Za-z0-9_-]{16,}\b/g,
21
+ /\bvapi_[A-Za-z0-9_-]{16,}\b/g,
22
+ /\bfw_[A-Za-z0-9_-]{16,}\b/g,
23
+ /\b[a-f0-9]{32}\.[A-Za-z0-9]{10,}\b/g,
24
+ /\b(authorization|x-api-key|x-goog-api-key)\b\s*[:=]\s*(bearer\s+)?["']?[A-Za-z0-9_\-.]{16,}["']?/gi,
14
25
  ];
15
26
 
16
27
  // Exact secret strings to mask, gathered from the resolved config + environment.
@@ -26,8 +37,27 @@ export function collectSecrets(providers?: Record<string, { apiKey?: string } |
26
37
  "OPENROUTER_API_KEY",
27
38
  "ANTHROPIC_API_KEY",
28
39
  "OPENAI_API_KEY",
40
+ "GOOGLE_GENERATIVE_AI_API_KEY",
41
+ "GEMINI_API_KEY",
42
+ "GOOGLE_API_KEY",
43
+ "XAI_API_KEY",
44
+ "GROQ_API_KEY",
45
+ "MISTRAL_API_KEY",
46
+ "ZAI_API_KEY",
47
+ "Z_AI_API_KEY",
48
+ "MOONSHOT_API_KEY",
49
+ "CEREBRAS_API_KEY",
50
+ "FIREWORKS_API_KEY",
51
+ "TOGETHER_API_KEY",
52
+ "TOGETHER_AI_API_KEY",
53
+ "DEEPSEEK_API_KEY",
54
+ "MINIMAX_API_KEY",
55
+ "DASHSCOPE_API_KEY",
56
+ "QWEN_API_KEY",
29
57
  "NEAR_AI_API_KEY",
30
58
  "NEARAI_API_KEY",
59
+ "TINFOIL_API_KEY",
60
+ "VENICE_API_KEY",
31
61
  ])
32
62
  add(process.env[k]);
33
63
  return [...out];
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Patrick
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,49 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { globalPaths, projectPaths } from "../config/paths.ts";
4
- import { walkFiles } from "../tools/walk.ts";
5
- import { parseFrontmatter } from "../commands/custom.ts";
6
-
7
- // A user-defined sub-agent loaded from .privateer/agents/<name>.md. The body is the
8
- // agent's instructions (system prompt); frontmatter narrows its tools and model.
9
- export interface AgentDefinition {
10
- name: string;
11
- description: string;
12
- tools?: string[]; // tool names the agent may use (default: read/glob/grep)
13
- model?: string; // optional "provider:model" override
14
- prompt: string; // the agent's instructions
15
- scope: "project" | "user";
16
- }
17
-
18
- function loadFromDir(dir: string, scope: "project" | "user"): AgentDefinition[] {
19
- if (!existsSync(dir)) return [];
20
- const out: AgentDefinition[] = [];
21
- for (const rel of walkFiles(dir)) {
22
- if (!rel.endsWith(".md")) continue;
23
- const { meta, body } = parseFrontmatter(readFileSync(join(dir, rel), "utf8"));
24
- out.push({
25
- name: meta.name || rel.replace(/\.md$/, "").split("/").join(":"),
26
- description: meta.description ?? `custom ${scope} sub-agent`,
27
- tools: meta.tools
28
- ?.split(",")
29
- .map((s) => s.trim())
30
- .filter(Boolean),
31
- model: meta.model,
32
- prompt: body.trim(),
33
- scope,
34
- });
35
- }
36
- return out;
37
- }
38
-
39
- // User (~/.privateer) then project (./.privateer) agents; project overrides user.
40
- export function loadAgents(cwd: string = process.cwd()): AgentDefinition[] {
41
- const byName = new Map<string, AgentDefinition>();
42
- for (const a of loadFromDir(globalPaths().agents, "user")) byName.set(a.name, a);
43
- for (const a of loadFromDir(projectPaths(cwd).agents, "project")) byName.set(a.name, a);
44
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
45
- }
46
-
47
- export function findAgent(name: string, cwd: string = process.cwd()): AgentDefinition | undefined {
48
- return loadAgents(cwd).find((a) => a.name === name);
49
- }