privateer-agent 0.1.0 → 0.2.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 +86 -33
- package/package.json +1 -1
- package/src/auth/privateer.ts +71 -1
- package/src/commands/custom.ts +52 -4
- package/src/commands/registry.ts +124 -5
- package/src/components/App.tsx +268 -18
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +21 -1
- package/src/components/ModelPicker.tsx +45 -12
- package/src/components/OptionPicker.tsx +134 -0
- package/src/components/Root.tsx +30 -9
- package/src/components/StatusBar.tsx +11 -1
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/figures.ts +1 -0
- package/src/components/theme.ts +2 -0
- package/src/config/paths.ts +2 -0
- package/src/context/systemPrompt.ts +9 -0
- package/src/daemon/index.ts +322 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +10 -0
- package/src/main.tsx +43 -1
- package/src/mcp/client.ts +16 -1
- package/src/permissions/gate.ts +5 -0
- package/src/permissions/mode.ts +4 -0
- package/src/permissions/uiGate.ts +4 -3
- package/src/remote/relayClient.ts +161 -6
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +75 -0
- package/src/routines/schema.ts +65 -0
- package/src/routines/store.ts +205 -0
- package/src/routines/toolSelect.ts +48 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +37 -12
- package/src/skills/installer.ts +222 -0
- package/src/skills/loader.ts +88 -0
- package/src/tools/askUser.ts +92 -0
- package/src/tools/context.ts +14 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/routine.ts +110 -0
- package/src/tools/sendFileToClient.ts +55 -0
- package/src/tools/skill.ts +44 -0
- package/src/tools/worktree.ts +145 -0
- package/src/util/images.ts +35 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { ToolContext } from "./context.ts";
|
|
4
|
+
|
|
5
|
+
// A single selectable approach presented to the user.
|
|
6
|
+
export interface UserChoiceOption {
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A decision the model surfaces to the user via the `ask_user` tool.
|
|
12
|
+
export interface UserQuestion {
|
|
13
|
+
question: string;
|
|
14
|
+
options: UserChoiceOption[];
|
|
15
|
+
multiSelect: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The user's answer: chosen option index(es), free-form custom text, or a dismissal.
|
|
19
|
+
export type UserAnswer =
|
|
20
|
+
| { kind: "selected"; indices: number[] }
|
|
21
|
+
| { kind: "custom"; text: string }
|
|
22
|
+
| { kind: "dismissed" };
|
|
23
|
+
|
|
24
|
+
// Bridge supplied by the interactive session: surface a question to the TUI and
|
|
25
|
+
// resolve with the user's choice. Absent in non-interactive contexts (sub-agents,
|
|
26
|
+
// remote-driven turns, headless runs), where the tool reports it couldn't ask.
|
|
27
|
+
export type UserAsker = (q: UserQuestion) => Promise<UserAnswer>;
|
|
28
|
+
|
|
29
|
+
// The `ask_user` tool: when the right implementation direction is genuinely
|
|
30
|
+
// ambiguous, the model puts a small menu of approaches to the user and blocks until
|
|
31
|
+
// they pick one (or write their own answer). No filesystem mutation, so it isn't
|
|
32
|
+
// gated — but it does pause the turn on the human, exactly like the approval prompt.
|
|
33
|
+
export function askUserTool(ctx: ToolContext) {
|
|
34
|
+
return tool({
|
|
35
|
+
description:
|
|
36
|
+
"Ask the user to choose between competing implementation directions when the right " +
|
|
37
|
+
"approach is genuinely ambiguous and the choice materially changes the work (architecture, " +
|
|
38
|
+
"a library, a data model, the scope of a change). Present 2–4 concrete options, " +
|
|
39
|
+
"most-recommended first, each with its trade-offs; the user picks one (or writes their own " +
|
|
40
|
+
"answer). Prefer this over silently guessing on a consequential fork. Do NOT use it for " +
|
|
41
|
+
"trivial choices you can reasonably make yourself, or to request permission for an action — " +
|
|
42
|
+
"the approval gate handles permissions.",
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
question: z.string().describe("The decision to put to the user, phrased as a question."),
|
|
45
|
+
options: z
|
|
46
|
+
.array(
|
|
47
|
+
z.object({
|
|
48
|
+
label: z
|
|
49
|
+
.string()
|
|
50
|
+
.describe("Short name for this approach, e.g. 'Server-side rendering'."),
|
|
51
|
+
description: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("One or two sentences on what it entails and its trade-offs."),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
.min(2)
|
|
58
|
+
.max(4)
|
|
59
|
+
.describe("The competing approaches, most-recommended first."),
|
|
60
|
+
multiSelect: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Allow the user to choose more than one option (default false)."),
|
|
64
|
+
}),
|
|
65
|
+
execute: async ({ question, options, multiSelect }) => {
|
|
66
|
+
if (!ctx.askUser) {
|
|
67
|
+
return (
|
|
68
|
+
"Cannot ask the user interactively in this context. Pick the best option using your " +
|
|
69
|
+
"own judgment, then state which you chose and why before proceeding."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const answer = await ctx.askUser({ question, options, multiSelect: multiSelect ?? false });
|
|
73
|
+
if (answer.kind === "custom") {
|
|
74
|
+
return `The user answered: ${answer.text}`;
|
|
75
|
+
}
|
|
76
|
+
if (answer.kind === "dismissed") {
|
|
77
|
+
return (
|
|
78
|
+
"The user dismissed the question without choosing. Either restate the choice briefly in " +
|
|
79
|
+
"plain prose, or proceed with the best option and say which you picked."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const chosen = answer.indices.map((i) => options[i]).filter(Boolean);
|
|
83
|
+
if (chosen.length === 0) {
|
|
84
|
+
return "The user made no selection. Proceed with the best option and state which you chose.";
|
|
85
|
+
}
|
|
86
|
+
const rendered = chosen
|
|
87
|
+
.map((o) => (o.description ? `"${o.label}" (${o.description})` : `"${o.label}"`))
|
|
88
|
+
.join(", ");
|
|
89
|
+
return `The user chose: ${rendered}. Proceed with this direction.`;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/tools/context.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { TodoStore } from "./todoStore.ts";
|
|
|
4
4
|
import type { AgentDefinition } from "../agents/loader.ts";
|
|
5
5
|
import type { ProcessRegistry } from "./processRegistry.ts";
|
|
6
6
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
7
|
+
import type { UserAsker } from "./askUser.ts";
|
|
7
8
|
|
|
8
9
|
// A finished sub-agent's result: its final text answer plus run metrics (how many
|
|
9
10
|
// tools it called and how many tokens it spent), so the UI can show a per-agent
|
|
@@ -52,6 +53,19 @@ export interface ToolContext {
|
|
|
52
53
|
// Session attachment store (decoded bytes of pasted/dropped files, by "#n"), for the
|
|
53
54
|
// save_attachment tool to write one back to disk.
|
|
54
55
|
attachments?: AttachmentStore;
|
|
56
|
+
// Surfaces an `ask_user` question to the interactive UI and resolves with the
|
|
57
|
+
// user's choice. Absent outside the live TUI (sub-agents, remote-driven turns,
|
|
58
|
+
// headless runs) — the tool then reports it couldn't ask and the model proceeds.
|
|
59
|
+
askUser?: UserAsker;
|
|
60
|
+
// Streams a file to the connected remote controller (the Privateer app) over the
|
|
61
|
+
// relay, for the send_file_to_client tool. Absent when remote access is off or in
|
|
62
|
+
// bare/sub-agent/daemon contexts — the tool then reports it can't send.
|
|
63
|
+
sendFileToController?: (file: {
|
|
64
|
+
name: string;
|
|
65
|
+
mediaType: string;
|
|
66
|
+
base64: string;
|
|
67
|
+
size: number;
|
|
68
|
+
}) => Promise<{ ok: boolean; reason?: string }>;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
// Resolve a possibly-relative path against the session cwd. The cwd is a *soft*
|
package/src/tools/index.ts
CHANGED
|
@@ -10,13 +10,23 @@ import { todoTool } from "./todo.ts";
|
|
|
10
10
|
import { taskTool } from "./task.ts";
|
|
11
11
|
import { webFetchTool, webSearchTool } from "./web.ts";
|
|
12
12
|
import { saveAttachmentTool } from "./saveAttachment.ts";
|
|
13
|
+
import { sendFileToClientTool } from "./sendFileToClient.ts";
|
|
13
14
|
import { memoryTool } from "./memory.ts";
|
|
15
|
+
import { askUserTool } from "./askUser.ts";
|
|
16
|
+
import { worktreeTool } from "./worktree.ts";
|
|
17
|
+
import { routineTool } from "./routine.ts";
|
|
18
|
+
import { skillTool } from "./skill.ts";
|
|
19
|
+
import { loadSkills } from "../skills/loader.ts";
|
|
14
20
|
|
|
15
21
|
export type { ToolContext } from "./context.ts";
|
|
16
22
|
|
|
17
23
|
// Build the full toolset bound to a session context (cwd + permission gate + todo store).
|
|
18
24
|
export function createTools(ctx: ToolContext): ToolSet {
|
|
25
|
+
// Registered only when skills exist — an empty catalog would be dead weight in
|
|
26
|
+
// every request.
|
|
27
|
+
const { skills } = loadSkills(ctx.cwd);
|
|
19
28
|
return {
|
|
29
|
+
...(skills.length > 0 ? { skill: skillTool(ctx, skills) } : {}),
|
|
20
30
|
read: readTool(ctx),
|
|
21
31
|
write: writeTool(ctx),
|
|
22
32
|
edit: editTool(ctx),
|
|
@@ -30,7 +40,11 @@ export function createTools(ctx: ToolContext): ToolSet {
|
|
|
30
40
|
web_fetch: webFetchTool(ctx),
|
|
31
41
|
web_search: webSearchTool(ctx),
|
|
32
42
|
save_attachment: saveAttachmentTool(ctx),
|
|
43
|
+
send_file_to_client: sendFileToClientTool(ctx),
|
|
33
44
|
memory: memoryTool(ctx),
|
|
45
|
+
ask_user: askUserTool(ctx),
|
|
46
|
+
worktree: worktreeTool(ctx),
|
|
47
|
+
routine: routineTool(ctx),
|
|
34
48
|
};
|
|
35
49
|
}
|
|
36
50
|
|
|
@@ -0,0 +1,110 @@
|
|
|
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";
|
|
6
|
+
import { triggerError, computeNextRun, describeTrigger } from "../routines/trigger.ts";
|
|
7
|
+
import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
8
|
+
import { upsertRoutine } from "../routines/store.ts";
|
|
9
|
+
import { sendToDaemon, DaemonNotRunningError } from "../daemon/ipc.ts";
|
|
10
|
+
|
|
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}`;
|
|
53
|
+
|
|
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(",")}`;
|
|
60
|
+
|
|
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");
|
|
77
|
+
|
|
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
|
+
};
|
|
91
|
+
|
|
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)}`;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { tool } from "ai";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { ToolContext } from "./context.ts";
|
|
6
|
+
import { resolveInCwd, displayPath, guardScope } from "./context.ts";
|
|
7
|
+
import { mediaTypeForPath } from "../util/images.ts";
|
|
8
|
+
|
|
9
|
+
// Per-file ceiling for the relay's chunked file channel; mirrors the relay
|
|
10
|
+
// client's MAX_ATTACH_BYTES so the tool fails fast with a friendly message
|
|
11
|
+
// instead of letting the transfer be rejected mid-flight.
|
|
12
|
+
const MAX_SEND_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
export function sendFileToClientTool(ctx: ToolContext) {
|
|
15
|
+
return tool({
|
|
16
|
+
description:
|
|
17
|
+
"Send a file from disk to the connected Privateer app (the phone/web client driving this " +
|
|
18
|
+
"terminal remotely), where the user can preview it and save/share it on their device. Use " +
|
|
19
|
+
"when the user asks to see, download, or receive a file on their phone or browser. Only " +
|
|
20
|
+
"works while remote access is on and the app is connected; max 10 MB per file.",
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
path: z.string().describe("Path of the file to send, relative to cwd or absolute."),
|
|
23
|
+
}),
|
|
24
|
+
execute: async ({ path }) => {
|
|
25
|
+
if (!ctx.sendFileToController) {
|
|
26
|
+
return "Cannot send: remote access is not enabled in this session (/remote-access to enable).";
|
|
27
|
+
}
|
|
28
|
+
const abs = resolveInCwd(ctx, path);
|
|
29
|
+
if (!existsSync(abs)) return `File not found: ${displayPath(ctx, abs)}`;
|
|
30
|
+
const stat = statSync(abs);
|
|
31
|
+
if (stat.isDirectory()) return `${displayPath(ctx, abs)} is a directory — send a single file.`;
|
|
32
|
+
if (stat.size === 0) return `${displayPath(ctx, abs)} is empty — nothing to send.`;
|
|
33
|
+
if (stat.size > MAX_SEND_BYTES) {
|
|
34
|
+
return (
|
|
35
|
+
`${displayPath(ctx, abs)} is ${(stat.size / (1024 * 1024)).toFixed(1)} MB; ` +
|
|
36
|
+
`the remote channel caps at ${MAX_SEND_BYTES / (1024 * 1024)} MB per file.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const blocked = await guardScope(ctx, abs, { kind: "read", title: "Send file to connected app" });
|
|
40
|
+
if (blocked) return blocked;
|
|
41
|
+
|
|
42
|
+
const bytes = readFileSync(abs);
|
|
43
|
+
const mediaType = mediaTypeForPath(abs);
|
|
44
|
+
const res = await ctx.sendFileToController({
|
|
45
|
+
name: basename(abs),
|
|
46
|
+
mediaType,
|
|
47
|
+
base64: bytes.toString("base64"),
|
|
48
|
+
size: bytes.length,
|
|
49
|
+
});
|
|
50
|
+
return res.ok
|
|
51
|
+
? `Sent ${basename(abs)} (${bytes.length} bytes, ${mediaType}) to the connected app.`
|
|
52
|
+
: `Couldn't send ${basename(abs)}: ${res.reason ?? "unknown error"}. Is the app currently connected?`;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { ToolContext } from "./context.ts";
|
|
4
|
+
import { loadSkills, type SkillDefinition } from "../skills/loader.ts";
|
|
5
|
+
|
|
6
|
+
// Progressive disclosure for skills: the model sees only the catalog (names +
|
|
7
|
+
// descriptions, embedded in this tool's description — same pattern as `task`
|
|
8
|
+
// advertising sub-agents) and pulls a skill's full instructions into the
|
|
9
|
+
// conversation by calling the tool. Loading a skill also unlocks reads of its
|
|
10
|
+
// bundled files, which live outside cwd for user-scoped skills.
|
|
11
|
+
export function skillTool(ctx: ToolContext, skills?: SkillDefinition[]) {
|
|
12
|
+
const list = skills ?? loadSkills(ctx.cwd).skills;
|
|
13
|
+
return tool({
|
|
14
|
+
description:
|
|
15
|
+
"Load a skill — a package of expert instructions for a specific kind of task. " +
|
|
16
|
+
"When a listed skill matches the user's request, load it BEFORE attempting the " +
|
|
17
|
+
"task and follow its instructions. Available skills:\n" +
|
|
18
|
+
list.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
skill: z.string().describe("Name of the skill to load."),
|
|
21
|
+
}),
|
|
22
|
+
execute: async ({ skill }) => {
|
|
23
|
+
const def = list.find((s) => s.name === skill);
|
|
24
|
+
if (!def) {
|
|
25
|
+
const names = list.map((s) => s.name).join(", ") || "(none)";
|
|
26
|
+
return `No skill named "${skill}". Available: ${names}.`;
|
|
27
|
+
}
|
|
28
|
+
// Bundled files (scripts/, references/, ...) sit next to SKILL.md — for a
|
|
29
|
+
// user-scoped skill that's outside cwd, so mark the directory in-scope for
|
|
30
|
+
// this session rather than prompting on every read.
|
|
31
|
+
const roots = (ctx.allowedOutsideRoots ??= []);
|
|
32
|
+
if (!roots.includes(def.dir)) roots.push(def.dir);
|
|
33
|
+
const advisory = def.allowedTools?.length
|
|
34
|
+
? `\nDeclared allowed-tools: ${def.allowedTools.join(", ")} (advisory in this version).`
|
|
35
|
+
: "";
|
|
36
|
+
return (
|
|
37
|
+
`Skill "${def.name}" loaded (base directory: ${def.dir}). ` +
|
|
38
|
+
`Bundled files can be read with the read tool using paths under that directory.` +
|
|
39
|
+
advisory +
|
|
40
|
+
`\n\n${def.body}`
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import type { ToolContext } from "./context.ts";
|
|
5
|
+
import { exec } from "./exec.ts";
|
|
6
|
+
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
7
|
+
|
|
8
|
+
const GIT_TIMEOUT = 60_000;
|
|
9
|
+
|
|
10
|
+
// Strip a worktree/branch name down to a filesystem- and git-safe slug.
|
|
11
|
+
function sanitizeName(name: string): string {
|
|
12
|
+
return name
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "")
|
|
16
|
+
.slice(0, 60);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The sibling path a named worktree lives at: ../<repo>-wt-<name>.
|
|
20
|
+
function worktreePath(cwd: string, slug: string): string {
|
|
21
|
+
return resolve(cwd, "..", `${basename(cwd)}-wt-${slug}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function git(cwd: string, args: string[]) {
|
|
25
|
+
return exec("git", args, { cwd, timeoutMs: GIT_TIMEOUT });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WorktreeEntry {
|
|
29
|
+
path: string;
|
|
30
|
+
branch?: string;
|
|
31
|
+
head?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parse `git worktree list --porcelain` into structured entries.
|
|
35
|
+
function parseWorktrees(porcelain: string): WorktreeEntry[] {
|
|
36
|
+
const entries: WorktreeEntry[] = [];
|
|
37
|
+
let cur: WorktreeEntry | null = null;
|
|
38
|
+
for (const line of porcelain.split("\n")) {
|
|
39
|
+
if (line.startsWith("worktree ")) {
|
|
40
|
+
if (cur) entries.push(cur);
|
|
41
|
+
cur = { path: line.slice("worktree ".length) };
|
|
42
|
+
} else if (line.startsWith("branch ") && cur) {
|
|
43
|
+
cur.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
44
|
+
} else if (line.startsWith("HEAD ") && cur) {
|
|
45
|
+
cur.head = line.slice("HEAD ".length, "HEAD ".length + 8);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (cur) entries.push(cur);
|
|
49
|
+
return entries;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The `worktree` tool: lets the agent develop a candidate approach in an isolated
|
|
53
|
+
// git worktree (its own branch + working copy) so a risky or exploratory direction
|
|
54
|
+
// never touches the main tree. Combined with `ask_user`, this is the building block
|
|
55
|
+
// for "try an approach, compare the diff, keep or discard it". Mutating actions
|
|
56
|
+
// (create/remove) run git and are routed through the permission gate.
|
|
57
|
+
export function worktreeTool(ctx: ToolContext) {
|
|
58
|
+
return tool({
|
|
59
|
+
description:
|
|
60
|
+
"Manage git worktrees to develop a candidate approach in isolation (its own branch + " +
|
|
61
|
+
"working copy), so an exploratory or risky direction doesn't touch the main tree. " +
|
|
62
|
+
"Actions: 'create' a new worktree on a fresh branch, 'list' existing worktrees, 'remove' " +
|
|
63
|
+
"one when done. Use this to try an approach the user can later compare via its diff and " +
|
|
64
|
+
"keep or discard. The repository must be a git repo.",
|
|
65
|
+
inputSchema: z.object({
|
|
66
|
+
action: z.enum(["create", "list", "remove"]).describe("What to do."),
|
|
67
|
+
name: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Worktree/branch name for create and remove (e.g. 'redis-cache')."),
|
|
71
|
+
base: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("For create: the ref to branch from (default: current HEAD)."),
|
|
75
|
+
deleteBranch: z
|
|
76
|
+
.boolean()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe("For remove: also delete the worktree's branch (default false)."),
|
|
79
|
+
}),
|
|
80
|
+
execute: async ({ action, name, base, deleteBranch }) => {
|
|
81
|
+
if (action === "list") {
|
|
82
|
+
const { stdout, code } = await git(ctx.cwd, ["worktree", "list", "--porcelain"]);
|
|
83
|
+
if (code !== 0) return "Not a git repository, or `git worktree` failed.";
|
|
84
|
+
const entries = parseWorktrees(stdout.trim());
|
|
85
|
+
if (entries.length <= 1) return "No additional worktrees. Only the main working tree exists.";
|
|
86
|
+
return entries
|
|
87
|
+
.map((e) => `${e.path}${e.branch ? ` [${e.branch}]` : ""}${e.head ? ` @${e.head}` : ""}`)
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const slug = sanitizeName(name ?? "");
|
|
92
|
+
if (!slug) return "A non-empty `name` is required for this action.";
|
|
93
|
+
const path = worktreePath(ctx.cwd, slug);
|
|
94
|
+
|
|
95
|
+
if (action === "create") {
|
|
96
|
+
const args = ["worktree", "add", "-b", slug, path];
|
|
97
|
+
if (base) args.push(base);
|
|
98
|
+
const cmd = `git ${args.join(" ")}`;
|
|
99
|
+
const decision = await ctx.gate.request({
|
|
100
|
+
tool: "worktree",
|
|
101
|
+
kind: "bash",
|
|
102
|
+
title: "Create git worktree",
|
|
103
|
+
detail: cmd,
|
|
104
|
+
});
|
|
105
|
+
if (decision === "deny") throw new PermissionDeniedError("worktree");
|
|
106
|
+
const { stderr, code } = await git(ctx.cwd, args);
|
|
107
|
+
if (code !== 0) return `git worktree add failed:\n${stderr.trim()}`;
|
|
108
|
+
// Let the agent edit inside the new worktree without the confinement gate
|
|
109
|
+
// re-prompting on every file — the user just approved creating it here.
|
|
110
|
+
if (ctx.allowedOutsideRoots && !ctx.allowedOutsideRoots.includes(path)) {
|
|
111
|
+
ctx.allowedOutsideRoots.push(path);
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
`Created worktree at ${path} on new branch '${slug}'.\n` +
|
|
115
|
+
`Work there with absolute paths under that directory; it's isolated from the main tree. ` +
|
|
116
|
+
`Compare later with: git -C "${path}" diff ${base ?? "HEAD"}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// remove
|
|
121
|
+
const cmd = `git worktree remove ${path}${deleteBranch ? ` && git branch -D ${slug}` : ""}`;
|
|
122
|
+
const decision = await ctx.gate.request({
|
|
123
|
+
tool: "worktree",
|
|
124
|
+
kind: "bash",
|
|
125
|
+
title: "Remove git worktree",
|
|
126
|
+
detail: cmd,
|
|
127
|
+
});
|
|
128
|
+
if (decision === "deny") throw new PermissionDeniedError("worktree");
|
|
129
|
+
const rm = await git(ctx.cwd, ["worktree", "remove", path]);
|
|
130
|
+
if (rm.code !== 0) {
|
|
131
|
+
return `git worktree remove failed (commit or discard changes first, or it doesn't exist):\n${rm.stderr.trim()}`;
|
|
132
|
+
}
|
|
133
|
+
if (ctx.allowedOutsideRoots) {
|
|
134
|
+
const i = ctx.allowedOutsideRoots.indexOf(path);
|
|
135
|
+
if (i >= 0) ctx.allowedOutsideRoots.splice(i, 1);
|
|
136
|
+
}
|
|
137
|
+
let msg = `Removed worktree ${path}.`;
|
|
138
|
+
if (deleteBranch) {
|
|
139
|
+
const br = await git(ctx.cwd, ["branch", "-D", slug]);
|
|
140
|
+
msg += br.code === 0 ? ` Deleted branch '${slug}'.` : ` (branch '${slug}' not deleted: ${br.stderr.trim()})`;
|
|
141
|
+
}
|
|
142
|
+
return msg;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
package/src/util/images.ts
CHANGED
|
@@ -26,6 +26,41 @@ const MEDIA_TYPES: Record<string, { mediaType: string; modality: Modality }> = {
|
|
|
26
26
|
".mkv": { mediaType: "video/x-matroska", modality: "video" },
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// Classify a media type into one of our binary modalities, or null when it's a
|
|
30
|
+
// text-like file that should be inlined as plain text rather than attached. Used by
|
|
31
|
+
// the relay path (App.tsx) to decide what to do with a file received from the app:
|
|
32
|
+
// a null result means "decode and inline the text"; otherwise it's a binary
|
|
33
|
+
// attachment the model reads directly.
|
|
34
|
+
export function mediaModality(mediaType: string): Modality | null {
|
|
35
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
36
|
+
if (mediaType === "application/pdf") return "document";
|
|
37
|
+
if (mediaType.startsWith("audio/")) return "audio";
|
|
38
|
+
if (mediaType.startsWith("video/")) return "video";
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Text-like extensions whose concrete media type matters to the app (rendering /
|
|
43
|
+
// save-as). Everything else in TEXT_EXTS is close enough to text/plain.
|
|
44
|
+
const TEXT_MEDIA_TYPES: Record<string, string> = {
|
|
45
|
+
".json": "application/json",
|
|
46
|
+
".html": "text/html",
|
|
47
|
+
".csv": "text/csv",
|
|
48
|
+
".xml": "application/xml",
|
|
49
|
+
".svg": "image/svg+xml",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Media type for a file at `p`, for files sent over the relay to the app: binary
|
|
53
|
+
// kinds from MEDIA_TYPES, recognized text kinds as text, else octet-stream (the
|
|
54
|
+
// app renders those as a generic file card).
|
|
55
|
+
export function mediaTypeForPath(p: string): string {
|
|
56
|
+
const ext = extname(p).toLowerCase();
|
|
57
|
+
const meta = MEDIA_TYPES[ext];
|
|
58
|
+
if (meta) return meta.mediaType;
|
|
59
|
+
const text = TEXT_MEDIA_TYPES[ext];
|
|
60
|
+
if (text) return text;
|
|
61
|
+
return TEXT_EXTS.has(ext) ? "text/plain" : "application/octet-stream";
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
// Magic-byte checks per media type, used to reject placeholder/corrupt files at capture
|
|
30
65
|
// time. The motivating case: macOS delivers a drag from a screenshot thumbnail as a
|
|
31
66
|
// *file promise*, so the terminal's …/T/drop-XXXXXX/ file can be a 4-byte stub holding
|