privateer-agent 0.2.1 → 0.3.0

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 +135 -443
  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 +73 -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,55 +0,0 @@
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
- }
@@ -1,44 +0,0 @@
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
- }
package/src/tools/task.ts DELETED
@@ -1,52 +0,0 @@
1
- import { tool } from "ai";
2
- import { z } from "zod";
3
- import type { ToolContext } from "./context.ts";
4
- import { loadAgents, findAgent } from "../agents/loader.ts";
5
-
6
- // Delegation tool: hand an open-ended investigation to a sub-agent that runs its own
7
- // loop and returns just a summary, keeping the parent conversation small. Without a
8
- // subagent_type it runs the default read-only agent (read/glob/grep); with one it uses
9
- // that custom agent's tools/model/instructions. Sub-agents cannot spawn further
10
- // sub-agents (no recursion).
11
- //
12
- // When the model emits several `task` calls in one turn the AI SDK runs their
13
- // execute()s concurrently, so sub-agents fan out in parallel — bounded by the
14
- // session's `maxSubagents` limiter.
15
- export function taskTool(ctx: ToolContext) {
16
- const agents = loadAgents(ctx.cwd);
17
- const agentList = agents.length
18
- ? ` Available subagent_type values: ${agents.map((a) => `${a.name} (${a.description})`).join("; ")}.`
19
- : "";
20
- return tool({
21
- description:
22
- "Delegate a search or task to a sub-agent. Give it a complete, self-contained prompt; it " +
23
- "explores and returns a text summary, keeping the main thread focused. Omit subagent_type " +
24
- "for the default read-only agent (read/glob/grep, cannot modify files)." +
25
- agentList,
26
- inputSchema: z.object({
27
- description: z.string().describe("Short 3–6 word description of the task."),
28
- prompt: z.string().describe("The full, self-contained task for the sub-agent."),
29
- subagent_type: z.string().optional().describe("Name of a custom sub-agent to use."),
30
- }),
31
- execute: async ({ description, prompt, subagent_type }, { toolCallId }) => {
32
- if (!ctx.runSubAgent) return "Sub-agents are not available in this context.";
33
- let agent;
34
- if (subagent_type) {
35
- agent = findAgent(subagent_type, ctx.cwd);
36
- if (!agent) {
37
- const names = loadAgents(ctx.cwd).map((a) => a.name).join(", ") || "(none defined)";
38
- return `No sub-agent named "${subagent_type}". Available: ${names}.`;
39
- }
40
- }
41
- try {
42
- const { text, toolUses, tokens } = await ctx.runSubAgent({ description, prompt, agent });
43
- // Report run metrics out-of-band (keyed by this call's id) for the grouped
44
- // agents view; the model itself only ever sees the text summary.
45
- ctx.onSubAgentMetrics?.(toolCallId, { toolUses, tokens });
46
- return text;
47
- } catch (err) {
48
- return `Sub-agent failed: ${err instanceof Error ? err.message : String(err)}`;
49
- }
50
- },
51
- });
52
- }
package/src/tools/todo.ts DELETED
@@ -1,36 +0,0 @@
1
- import { tool } from "ai";
2
- import { z } from "zod";
3
- import type { ToolContext } from "./context.ts";
4
-
5
- // The planning tool: the model maintains a single flat task list, rewriting the
6
- // whole list each call. State lives in the session's
7
- // TodoStore so the TUI can render it live. No filesystem mutation, so it isn't gated.
8
- export function todoTool(ctx: ToolContext) {
9
- return tool({
10
- description:
11
- "Record and update your task list for multi-step work. Pass the COMPLETE list every " +
12
- "time (it replaces the previous one). Keep exactly one item 'in_progress'; mark items " +
13
- "'completed' as you finish them. Use this to plan non-trivial tasks and keep the user oriented.",
14
- inputSchema: z.object({
15
- todos: z
16
- .array(
17
- z.object({
18
- content: z.string().describe("Imperative task description, e.g. 'Add auth middleware'."),
19
- status: z.enum(["pending", "in_progress", "completed"]),
20
- activeForm: z
21
- .string()
22
- .optional()
23
- .describe("Present-continuous label shown while running, e.g. 'Adding auth middleware'."),
24
- }),
25
- )
26
- .describe("The full task list, in order."),
27
- }),
28
- execute: async ({ todos }) => {
29
- ctx.todos?.set(todos);
30
- const done = todos.filter((t) => t.status === "completed").length;
31
- const active = todos.find((t) => t.status === "in_progress");
32
- const summary = `Updated todo list (${done}/${todos.length} done).`;
33
- return active ? `${summary} In progress: ${active.content}` : summary;
34
- },
35
- });
36
- }
@@ -1,31 +0,0 @@
1
- // Session-scoped todo list. The `todo` tool replaces it; the TUI subscribes to it to
2
- // render the live task panel. Kept deliberately tiny — it's just an observable array.
3
-
4
- export type TodoStatus = "pending" | "in_progress" | "completed";
5
-
6
- export interface TodoItem {
7
- content: string;
8
- status: TodoStatus;
9
- activeForm?: string; // present-continuous label shown while in_progress
10
- }
11
-
12
- export class TodoStore {
13
- private items: TodoItem[] = [];
14
- private listeners = new Set<(items: TodoItem[]) => void>();
15
-
16
- get(): TodoItem[] {
17
- return this.items;
18
- }
19
-
20
- set(items: TodoItem[]): void {
21
- this.items = items;
22
- for (const l of this.listeners) l(items);
23
- }
24
-
25
- subscribe(fn: (items: TodoItem[]) => void): () => void {
26
- this.listeners.add(fn);
27
- return () => {
28
- this.listeners.delete(fn);
29
- };
30
- }
31
- }
package/src/tools/walk.ts DELETED
@@ -1,44 +0,0 @@
1
- import { readdirSync } from "node:fs";
2
- import { join, relative, sep } from "node:path";
3
-
4
- // Directories we never descend into, so searches stay fast and relevant without
5
- // needing a full .gitignore parser. (A richer ignore story can come later.)
6
- const SKIP_DIRS = new Set([
7
- ".git",
8
- "node_modules",
9
- "dist",
10
- "build",
11
- ".next",
12
- ".cache",
13
- "coverage",
14
- ".venv",
15
- "__pycache__",
16
- ".turbo",
17
- ]);
18
-
19
- const MAX_FILES = 20_000;
20
-
21
- // Recursively list files under `root`, returning paths relative to `root` with
22
- // forward slashes (so glob patterns behave consistently across platforms).
23
- export function walkFiles(root: string): string[] {
24
- const out: string[] = [];
25
- const stack: string[] = [root];
26
- while (stack.length && out.length < MAX_FILES) {
27
- const dir = stack.pop()!;
28
- let entries;
29
- try {
30
- entries = readdirSync(dir, { withFileTypes: true });
31
- } catch {
32
- continue; // unreadable dir — skip
33
- }
34
- for (const e of entries) {
35
- if (e.isDirectory()) {
36
- if (SKIP_DIRS.has(e.name)) continue;
37
- stack.push(join(dir, e.name));
38
- } else if (e.isFile()) {
39
- out.push(relative(root, join(dir, e.name)).split(sep).join("/"));
40
- }
41
- }
42
- }
43
- return out;
44
- }
package/src/tools/web.ts DELETED
@@ -1,145 +0,0 @@
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
-
6
- const MAX_BYTES = 2_000_000; // cap download size
7
- const MAX_TEXT = 20_000; // cap returned text
8
- const TIMEOUT_MS = 20_000;
9
-
10
- // Fetch a URL's body as text, with a timeout and size cap. Returns null fields on failure.
11
- async function fetchText(url: string): Promise<{ status: number; contentType: string; body: string }> {
12
- const ac = new AbortController();
13
- const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
14
- try {
15
- const res = await fetch(url, {
16
- signal: ac.signal,
17
- redirect: "follow",
18
- headers: { "user-agent": "privateer-agent/0.1.0 (+https://github.com/privateer-agent/privateer-agent)" },
19
- });
20
- const body = (await res.text()).slice(0, MAX_BYTES);
21
- return { status: res.status, contentType: res.headers.get("content-type") ?? "", body };
22
- } finally {
23
- clearTimeout(timer);
24
- }
25
- }
26
-
27
- // Crude HTML→text: drop scripts/styles, turn block-closers into newlines, strip tags,
28
- // decode the common entities. Good enough to feed page content to the model.
29
- function htmlToText(html: string): string {
30
- return html
31
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
32
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
33
- .replace(/<\/(p|div|h[1-6]|li|tr|section|article|header|footer)>/gi, "\n")
34
- .replace(/<br\s*\/?>/gi, "\n")
35
- .replace(/<[^>]+>/g, " ")
36
- .replace(/&nbsp;/gi, " ")
37
- .replace(/&amp;/gi, "&")
38
- .replace(/&lt;/gi, "<")
39
- .replace(/&gt;/gi, ">")
40
- .replace(/&quot;/gi, '"')
41
- .replace(/&#39;/gi, "'")
42
- .replace(/[ \t]+/g, " ")
43
- .replace(/\n{3,}/g, "\n\n")
44
- .trim();
45
- }
46
-
47
- function isHttpUrl(u: string): boolean {
48
- try {
49
- const parsed = new URL(u);
50
- return parsed.protocol === "http:" || parsed.protocol === "https:";
51
- } catch {
52
- return false;
53
- }
54
- }
55
-
56
- export function webFetchTool(ctx: ToolContext) {
57
- return tool({
58
- description:
59
- "Fetch a URL and return its content as text (HTML is stripped to readable text). Use when " +
60
- "the user gives a link or you need to read online docs. Network egress, so it may prompt.",
61
- inputSchema: z.object({
62
- url: z.string().describe("Absolute http(s) URL to fetch."),
63
- prompt: z.string().optional().describe("What you're looking for (recorded for context; not a separate model call)."),
64
- }),
65
- execute: async ({ url, prompt }) => {
66
- if (!isHttpUrl(url)) return `Error: not a valid http(s) URL: ${url}`;
67
- const decision = await ctx.gate.request({
68
- tool: "web_fetch",
69
- kind: "fetch",
70
- title: "Fetch URL",
71
- detail: url,
72
- });
73
- if (decision === "deny") throw new PermissionDeniedError("web_fetch");
74
-
75
- let result;
76
- try {
77
- result = await fetchText(url);
78
- } catch (err) {
79
- return `Error fetching ${url}: ${err instanceof Error ? err.message : String(err)}`;
80
- }
81
- const text = /html/i.test(result.contentType) ? htmlToText(result.body) : result.body.trim();
82
- const capped = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + "\n… (truncated)" : text;
83
- const header = `[${result.status} · ${result.contentType || "?"}]${prompt ? ` looking for: ${prompt}` : ""}`;
84
- return `${header}\n\n${capped || "(empty response)"}`;
85
- },
86
- });
87
- }
88
-
89
- // DuckDuckGo's keyless HTML endpoint, scraped for result titles + links. No API key
90
- // required, which keeps web_search working out of the box.
91
- // Caveat: it scrapes HTML, so it's best-effort and can break if DDG changes markup.
92
- function parseDdg(html: string, limit: number): string[] {
93
- const out: string[] = [];
94
- const re = /<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
95
- let m: RegExpExecArray | null;
96
- while ((m = re.exec(html)) && out.length < limit) {
97
- const href = decodeDdgHref(m[1]);
98
- const title = htmlToText(m[2]);
99
- if (title) out.push(`${title}\n ${href}`);
100
- }
101
- return out;
102
- }
103
-
104
- // DDG wraps results as //duckduckgo.com/l/?uddg=<encoded real url>.
105
- function decodeDdgHref(href: string): string {
106
- try {
107
- const u = new URL(href, "https://duckduckgo.com");
108
- const uddg = u.searchParams.get("uddg");
109
- return uddg ? decodeURIComponent(uddg) : u.toString();
110
- } catch {
111
- return href;
112
- }
113
- }
114
-
115
- export function webSearchTool(ctx: ToolContext) {
116
- return tool({
117
- description:
118
- "Search the web and return the top results (title + URL) via DuckDuckGo. Follow up with " +
119
- "web_fetch to read a result. Network egress, so it may prompt.",
120
- inputSchema: z.object({
121
- query: z.string().describe("Search query."),
122
- }),
123
- execute: async ({ query }) => {
124
- const decision = await ctx.gate.request({
125
- tool: "web_search",
126
- kind: "fetch",
127
- title: "Web search",
128
- detail: query,
129
- });
130
- if (decision === "deny") throw new PermissionDeniedError("web_search");
131
-
132
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
133
- let result;
134
- try {
135
- result = await fetchText(url);
136
- } catch (err) {
137
- return `Error searching: ${err instanceof Error ? err.message : String(err)}`;
138
- }
139
- const results = parseDdg(result.body, 8);
140
- return results.length
141
- ? `Results for "${query}":\n\n${results.join("\n\n")}`
142
- : `No results parsed for "${query}" (DuckDuckGo markup may have changed). Try web_fetch with a direct URL.`;
143
- },
144
- });
145
- }
@@ -1,145 +0,0 @@
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
- }
@@ -1,40 +0,0 @@
1
- import { writeFileSync, mkdirSync, existsSync } 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";
9
-
10
- export function writeTool(ctx: ToolContext) {
11
- return tool({
12
- description:
13
- "Write a file, creating parent directories as needed. Overwrites existing files. " +
14
- "Prefer the edit tool for small changes to existing files.",
15
- inputSchema: z.object({
16
- path: z.string().describe("File path to write."),
17
- content: z.string().describe("Full file contents."),
18
- }),
19
- execute: async ({ path, content }) => {
20
- const abs = resolveInCwd(ctx, path);
21
- const exists = existsSync(abs);
22
- const outside = isOutsideScope(ctx, abs);
23
- const decision = await ctx.gate.request({
24
- tool: "write",
25
- kind: "write",
26
- title: outside ? "Write outside working directory" : exists ? "Overwrite file" : "Create file",
27
- detail: `${outside ? abs : displayPath(ctx, abs)} (${content.split("\n").length} lines)`,
28
- protected: isProtectedPath(abs),
29
- outside,
30
- path: abs,
31
- });
32
- if (decision === "deny") throw new PermissionDeniedError("write");
33
-
34
- ctx.recordMutation?.(abs);
35
- mkdirSync(dirname(abs), { recursive: true });
36
- writeFileSync(abs, content, "utf8");
37
- return `${exists ? "Wrote" : "Created"} ${displayPath(ctx, abs)} (${content.length} bytes).`;
38
- },
39
- });
40
- }