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.
- package/README.md +167 -442
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +77 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/tools/routine.ts
CHANGED
|
@@ -1,110 +1,129 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
11
|
-
return
|
|
15
|
+
export function makeSaveAttachmentTool(store: AttachmentStore) {
|
|
16
|
+
return {
|
|
17
|
+
name: "save_attachment",
|
|
18
|
+
label: "Save Attachment",
|
|
12
19
|
description:
|
|
13
|
-
"Save a
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
34
|
-
const
|
|
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}
|
|
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
|
-
//
|
|
7
|
-
// "#n" reference the
|
|
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
|
-
|
|
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
|
|
31
|
-
//
|
|
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
|
|
37
|
-
//
|
|
38
|
-
register(
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
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
|
-
//
|
|
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 });
|
package/src/util/redact.ts
CHANGED
|
@@ -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-…`,
|
|
10
|
-
//
|
|
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
|
-
/\
|
|
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.
|
package/src/agents/loader.ts
DELETED
|
@@ -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
|
-
}
|