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,128 +0,0 @@
1
- import { resolve, isAbsolute, relative, sep } from "node:path";
2
- import type { PermissionGate, PermissionKind } from "../permissions/gate.ts";
3
- import type { TodoStore } from "./todoStore.ts";
4
- import type { AgentDefinition } from "../agents/loader.ts";
5
- import type { ProcessRegistry } from "./processRegistry.ts";
6
- import type { AttachmentStore } from "../util/attachmentStore.ts";
7
- import type { UserAsker } from "./askUser.ts";
8
-
9
- // A finished sub-agent's result: its final text answer plus run metrics (how many
10
- // tools it called and how many tokens it spent), so the UI can show a per-agent
11
- // summary in the grouped "N agents finished" view.
12
- export interface SubAgentResult {
13
- text: string;
14
- toolUses: number;
15
- tokens: number;
16
- }
17
-
18
- // Runs a child agent and resolves to its final text answer + metrics. With no `agent`
19
- // it runs the default read-only sub-agent; with one it uses that agent's
20
- // tools/model/instructions. Supplied by the session (which has the model + config);
21
- // absent in bare tool contexts.
22
- export type SubAgentRunner = (input: {
23
- description: string;
24
- prompt: string;
25
- agent?: AgentDefinition;
26
- }) => Promise<SubAgentResult>;
27
-
28
- // Shared state handed to every tool's execute().
29
- export interface ToolContext {
30
- cwd: string;
31
- gate: PermissionGate;
32
- // When true (the default), the tools confine file access to `cwd`: a path that
33
- // resolves outside it (an absolute path elsewhere, or a `../` escape) is only
34
- // touched after the user explicitly approves it via the gate. Set false to let the
35
- // agent roam (e.g. the user launched with --no-confine / set confineToCwd:false).
36
- confineToCwd?: boolean;
37
- // Out-of-cwd directories the user has approved this session ("always" on an outside
38
- // prompt). A shared array, also held by the gate, so an approved sibling directory
39
- // isn't re-prompted on every file inside it. Paths under any of these count as
40
- // in-scope.
41
- allowedOutsideRoots?: string[];
42
- todos?: TodoStore; // session todo list, for the `todo` tool + TUI panel
43
- runSubAgent?: SubAgentRunner; // spawns a `task` sub-agent
44
- // Reports a finished `task` sub-agent's run metrics, keyed by the originating
45
- // tool-call id, so the TUI can annotate the grouped agents view with each agent's
46
- // tool-use and token counts. Best-effort; absent outside the interactive session.
47
- onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
48
- // Called by write/edit just before they mutate a file, so the checkpoint store
49
- // can capture its pre-modification state for /rewind.
50
- recordMutation?: (abs: string) => void;
51
- // Background-shell registry, for bash run_in_background + bash_output/kill_shell.
52
- processes?: ProcessRegistry;
53
- // Session attachment store (decoded bytes of pasted/dropped files, by "#n"), for the
54
- // save_attachment tool to write one back to disk.
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 }>;
69
- }
70
-
71
- // Resolve a possibly-relative path against the session cwd. The cwd is a *soft*
72
- // anchor: relative paths are interpreted from it, but absolute paths and `../`
73
- // escapes are allowed through — the model is nudged (via the system prompt) to
74
- // stay inside cwd rather than being walled in here.
75
- export function resolveInCwd(ctx: ToolContext, p: string): string {
76
- return isAbsolute(p) ? p : resolve(ctx.cwd, p);
77
- }
78
-
79
- // Display a path relative to cwd when possible (nicer for tool output / UI).
80
- export function displayPath(ctx: ToolContext, abs: string): string {
81
- const rel = relative(ctx.cwd, abs);
82
- return rel === "" ? "." : rel;
83
- }
84
-
85
- // Is `abs` the directory `root` itself, or contained within it? Uses the resolved
86
- // relative path so `..` escapes are caught regardless of how the path was written.
87
- export function isInsideDir(root: string, abs: string): boolean {
88
- if (abs === root) return true;
89
- const rel = relative(root, abs);
90
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
91
- }
92
-
93
- // Does this path fall outside the agent's working-directory scope? True only when
94
- // confinement is on and the path is neither inside cwd nor inside a directory the
95
- // user already approved this session.
96
- export function isOutsideScope(ctx: ToolContext, abs: string): boolean {
97
- if (ctx.confineToCwd === false) return false;
98
- if (isInsideDir(ctx.cwd, abs)) return false;
99
- return !(ctx.allowedOutsideRoots ?? []).some((root) => isInsideDir(root, abs));
100
- }
101
-
102
- // Gate access to a path that may sit outside cwd. Returns null when the path is in
103
- // scope or the user approves the out-of-scope access; returns an error string (for the
104
- // tool to hand back to the model) when confinement blocks it. In-scope paths never
105
- // prompt, so ordinary work inside cwd is untouched.
106
- export async function guardScope(
107
- ctx: ToolContext,
108
- abs: string,
109
- opts: { kind: PermissionKind; title: string },
110
- ): Promise<string | null> {
111
- if (!isOutsideScope(ctx, abs)) return null;
112
- const decision = await ctx.gate.request({
113
- tool: opts.kind,
114
- kind: opts.kind,
115
- title: opts.title,
116
- detail: abs,
117
- path: abs,
118
- outside: true,
119
- });
120
- if (decision === "deny") {
121
- return (
122
- `Error: ${abs} is outside the working directory (${ctx.cwd}). ` +
123
- `By default I stay within the working directory; access here was declined. ` +
124
- `Ask me explicitly to work in this location to allow it.`
125
- );
126
- }
127
- return null;
128
- }
package/src/tools/edit.ts DELETED
@@ -1,67 +0,0 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { tool } from "ai";
3
- import { z } from "zod";
4
- import type { ToolContext } from "./context.ts";
5
- import { resolveInCwd, displayPath, isOutsideScope } from "./context.ts";
6
- import { PermissionDeniedError } from "../permissions/gate.ts";
7
- import { isProtectedPath } from "../permissions/protected.ts";
8
-
9
- // Count occurrences of a substring (non-overlapping).
10
- function countOccurrences(haystack: string, needle: string): number {
11
- if (needle === "") return 0;
12
- let count = 0;
13
- let i = haystack.indexOf(needle);
14
- while (i !== -1) {
15
- count++;
16
- i = haystack.indexOf(needle, i + needle.length);
17
- }
18
- return count;
19
- }
20
-
21
- export function editTool(ctx: ToolContext) {
22
- return tool({
23
- description:
24
- "Replace an exact string in a file. old_string must match uniquely unless replace_all is set. " +
25
- "Include enough surrounding context to make the match unique.",
26
- inputSchema: z.object({
27
- path: z.string().describe("File to edit."),
28
- old_string: z.string().describe("Exact text to replace."),
29
- new_string: z.string().describe("Replacement text."),
30
- replace_all: z.boolean().optional().describe("Replace every occurrence."),
31
- }),
32
- execute: async ({ path, old_string, new_string, replace_all }) => {
33
- const abs = resolveInCwd(ctx, path);
34
- if (!existsSync(abs)) return `Error: file not found: ${displayPath(ctx, abs)}`;
35
- if (old_string === new_string) return `Error: old_string and new_string are identical.`;
36
-
37
- const original = readFileSync(abs, "utf8");
38
- const occ = countOccurrences(original, old_string);
39
- if (occ === 0) return `Error: old_string not found in ${displayPath(ctx, abs)}.`;
40
- if (occ > 1 && !replace_all) {
41
- return `Error: old_string matches ${occ} places in ${displayPath(ctx, abs)}. Add context or set replace_all.`;
42
- }
43
-
44
- const updated = replace_all
45
- ? original.split(old_string).join(new_string)
46
- : original.replace(old_string, new_string);
47
-
48
- const removed = old_string.split("\n").length;
49
- const added = new_string.split("\n").length;
50
- const outside = isOutsideScope(ctx, abs);
51
- const decision = await ctx.gate.request({
52
- tool: "edit",
53
- kind: "edit",
54
- title: outside ? "Edit outside working directory" : "Edit file",
55
- detail: `${outside ? abs : displayPath(ctx, abs)} (${replace_all ? occ + "×, " : ""}-${removed} +${added})`,
56
- protected: isProtectedPath(abs),
57
- outside,
58
- path: abs,
59
- });
60
- if (decision === "deny") throw new PermissionDeniedError("edit");
61
-
62
- ctx.recordMutation?.(abs);
63
- writeFileSync(abs, updated, "utf8");
64
- return `Edited ${displayPath(ctx, abs)} (${replace_all ? occ + " replacements" : "1 replacement"}).`;
65
- },
66
- });
67
- }
package/src/tools/exec.ts DELETED
@@ -1,60 +0,0 @@
1
- import { spawn } from "node:child_process";
2
-
3
- export interface ExecResult {
4
- stdout: string;
5
- stderr: string;
6
- code: number | null;
7
- timedOut: boolean;
8
- }
9
-
10
- const MAX_OUTPUT = 30_000; // chars per stream, to keep tool results bounded
11
-
12
- // Run a command and capture output with a timeout. When `shell` is true the
13
- // command string is interpreted by the shell (used by the bash tool); otherwise
14
- // args are passed directly (used for ripgrep).
15
- export function exec(
16
- cmd: string,
17
- args: string[],
18
- opts: { cwd: string; timeoutMs: number; shell?: boolean; input?: string },
19
- ): Promise<ExecResult> {
20
- return new Promise((resolveP) => {
21
- const child = spawn(opts.shell ? cmd : cmd, opts.shell ? [] : args, {
22
- cwd: opts.cwd,
23
- shell: opts.shell ?? false,
24
- env: process.env,
25
- });
26
-
27
- // Feed the optional payload on stdin (used by hooks), then close it.
28
- if (opts.input !== undefined) {
29
- child.stdin?.on("error", () => {});
30
- child.stdin?.write(opts.input);
31
- child.stdin?.end();
32
- }
33
-
34
- let stdout = "";
35
- let stderr = "";
36
- let timedOut = false;
37
-
38
- const timer = setTimeout(() => {
39
- timedOut = true;
40
- child.kill("SIGKILL");
41
- }, opts.timeoutMs);
42
-
43
- child.stdout?.on("data", (d) => {
44
- if (stdout.length < MAX_OUTPUT) stdout += d.toString();
45
- });
46
- child.stderr?.on("data", (d) => {
47
- if (stderr.length < MAX_OUTPUT) stderr += d.toString();
48
- });
49
- child.on("error", (err) => {
50
- clearTimeout(timer);
51
- resolveP({ stdout, stderr: stderr + String(err), code: null, timedOut });
52
- });
53
- child.on("close", (code) => {
54
- clearTimeout(timer);
55
- const cap = (s: string) =>
56
- s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + "\n… (output truncated)" : s;
57
- resolveP({ stdout: cap(stdout), stderr: cap(stderr), code, timedOut });
58
- });
59
- });
60
- }
package/src/tools/glob.ts DELETED
@@ -1,39 +0,0 @@
1
- import { statSync } from "node:fs";
2
- import picomatch from "picomatch";
3
- import { tool } from "ai";
4
- import { z } from "zod";
5
- import type { ToolContext } from "./context.ts";
6
- import { resolveInCwd, guardScope } from "./context.ts";
7
- import { walkFiles } from "./walk.ts";
8
-
9
- // Pure-Node glob: walk the tree and match relative paths with picomatch. No
10
- // external binary dependency, so it works anywhere Privateer is installed.
11
- export function globTool(ctx: ToolContext) {
12
- return tool({
13
- description:
14
- "Find files matching a glob pattern (e.g. '**/*.ts', 'src/**/test_*.py'). " +
15
- "Skips node_modules/.git/build dirs. Returns matching file paths. Read-only.",
16
- inputSchema: z.object({
17
- pattern: z.string().describe("Glob pattern to match file paths (relative to the search dir)."),
18
- path: z.string().optional().describe("Directory to search in (default: working directory)."),
19
- }),
20
- execute: async ({ pattern, path }) => {
21
- const root = path ? resolveInCwd(ctx, path) : ctx.cwd;
22
- const blocked = await guardScope(ctx, root, { kind: "read", title: "Search outside working directory" });
23
- if (blocked) return blocked;
24
- try {
25
- if (!statSync(root).isDirectory()) return `Error: ${path} is not a directory.`;
26
- } catch {
27
- return `Error: directory not found: ${path}`;
28
- }
29
-
30
- const isMatch = picomatch(pattern, { dot: true });
31
- const files = walkFiles(root).filter((f) => isMatch(f));
32
- if (files.length === 0) return "No files matched.";
33
- files.sort();
34
- const shown = files.slice(0, 200);
35
- const more = files.length > shown.length ? `\n… (${files.length - shown.length} more)` : "";
36
- return shown.join("\n") + more;
37
- },
38
- });
39
- }
package/src/tools/grep.ts DELETED
@@ -1,86 +0,0 @@
1
- import { readFileSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import picomatch from "picomatch";
4
- import { tool } from "ai";
5
- import { z } from "zod";
6
- import type { ToolContext } from "./context.ts";
7
- import { resolveInCwd, guardScope } from "./context.ts";
8
- import { walkFiles } from "./walk.ts";
9
-
10
- const MAX_MATCHES = 200;
11
-
12
- // Treat a file as binary (and skip it) if an early chunk contains a NUL byte.
13
- function looksBinary(buf: string): boolean {
14
- const head = buf.slice(0, 1024);
15
- for (let i = 0; i < head.length; i++) {
16
- if (head.charCodeAt(i) === 0) return true;
17
- }
18
- return false;
19
- }
20
-
21
- // Pure-Node content search. Walks files, optionally filtered by a glob, and reports
22
- // regex matches as "file:line:text". No external binary dependency.
23
- export function grepTool(ctx: ToolContext) {
24
- return tool({
25
- description:
26
- "Search file contents with a regular expression. Returns matching lines as " +
27
- "file:line:text. Optionally restrict to files matching a glob. Read-only.",
28
- inputSchema: z.object({
29
- pattern: z.string().describe("Regex pattern to search for (JavaScript regex syntax)."),
30
- path: z.string().optional().describe("File or directory to search (default: working directory)."),
31
- glob: z.string().optional().describe("Only search files matching this glob, e.g. '**/*.ts'."),
32
- ignore_case: z.boolean().optional().describe("Case-insensitive search."),
33
- }),
34
- execute: async ({ pattern, path, glob, ignore_case }) => {
35
- let re: RegExp;
36
- try {
37
- re = new RegExp(pattern, ignore_case ? "i" : "");
38
- } catch (err) {
39
- return `Error: invalid regex: ${(err as Error).message}`;
40
- }
41
-
42
- const root = path ? resolveInCwd(ctx, path) : ctx.cwd;
43
- const blocked = await guardScope(ctx, root, { kind: "read", title: "Search outside working directory" });
44
- if (blocked) return blocked;
45
- let files: string[];
46
- let baseDir: string;
47
- try {
48
- if (statSync(root).isFile()) {
49
- baseDir = ctx.cwd;
50
- files = [root];
51
- } else {
52
- baseDir = root;
53
- const rel = walkFiles(root);
54
- const isMatch = glob ? picomatch(glob, { dot: true }) : () => true;
55
- files = rel.filter((f) => isMatch(f)).map((f) => join(root, f));
56
- }
57
- } catch {
58
- return `Error: path not found: ${path}`;
59
- }
60
-
61
- const results: string[] = [];
62
- for (const file of files) {
63
- if (results.length >= MAX_MATCHES) break;
64
- let content: string;
65
- try {
66
- content = readFileSync(file, "utf8");
67
- } catch {
68
- continue;
69
- }
70
- if (looksBinary(content)) continue;
71
- const rel = file.startsWith(baseDir) ? file.slice(baseDir.length).replace(/^[/\\]/, "") : file;
72
- const lines = content.split("\n");
73
- for (let i = 0; i < lines.length; i++) {
74
- if (re.test(lines[i])) {
75
- results.push(`${rel}:${i + 1}:${lines[i].slice(0, 300)}`);
76
- if (results.length >= MAX_MATCHES) break;
77
- }
78
- }
79
- }
80
-
81
- if (results.length === 0) return "No matches.";
82
- const capped = results.length >= MAX_MATCHES ? "\n… (more matches truncated)" : "";
83
- return results.join("\n") + capped;
84
- },
85
- });
86
- }
@@ -1,83 +0,0 @@
1
- import type { ToolSet } from "ai";
2
- import type { ToolContext } from "./context.ts";
3
- import { readTool } from "./read.ts";
4
- import { writeTool } from "./write.ts";
5
- import { editTool } from "./edit.ts";
6
- import { globTool } from "./glob.ts";
7
- import { grepTool } from "./grep.ts";
8
- import { bashTool, bashOutputTool, killShellTool } from "./bash.ts";
9
- import { todoTool } from "./todo.ts";
10
- import { taskTool } from "./task.ts";
11
- import { webFetchTool, webSearchTool } from "./web.ts";
12
- import { saveAttachmentTool } from "./saveAttachment.ts";
13
- import { sendFileToClientTool } from "./sendFileToClient.ts";
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";
20
-
21
- export type { ToolContext } from "./context.ts";
22
-
23
- // Build the full toolset bound to a session context (cwd + permission gate + todo store).
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);
28
- return {
29
- ...(skills.length > 0 ? { skill: skillTool(ctx, skills) } : {}),
30
- read: readTool(ctx),
31
- write: writeTool(ctx),
32
- edit: editTool(ctx),
33
- glob: globTool(ctx),
34
- grep: grepTool(ctx),
35
- bash: bashTool(ctx),
36
- bash_output: bashOutputTool(ctx),
37
- kill_shell: killShellTool(ctx),
38
- todo: todoTool(ctx),
39
- task: taskTool(ctx),
40
- web_fetch: webFetchTool(ctx),
41
- web_search: webSearchTool(ctx),
42
- save_attachment: saveAttachmentTool(ctx),
43
- send_file_to_client: sendFileToClientTool(ctx),
44
- memory: memoryTool(ctx),
45
- ask_user: askUserTool(ctx),
46
- worktree: worktreeTool(ctx),
47
- routine: routineTool(ctx),
48
- };
49
- }
50
-
51
- // The read-only subset given to `task` sub-agents: search + inspect, no mutation, no
52
- // recursion (no `task`/`todo`). Safe to run with an auto-approve gate.
53
- export function createReadOnlyTools(ctx: ToolContext): ToolSet {
54
- return {
55
- read: readTool(ctx),
56
- glob: globTool(ctx),
57
- grep: grepTool(ctx),
58
- };
59
- }
60
-
61
- // Factories a sub-agent may be granted. Deliberately excludes `task` and `todo` so
62
- // sub-agents can't recurse or mutate the parent's todo list.
63
- const AGENT_TOOL_FACTORIES: Record<string, (ctx: ToolContext) => ToolSet[string]> = {
64
- read: readTool,
65
- write: writeTool,
66
- edit: editTool,
67
- glob: globTool,
68
- grep: grepTool,
69
- bash: bashTool,
70
- web_fetch: webFetchTool,
71
- web_search: webSearchTool,
72
- };
73
-
74
- // Build a sub-agent's toolset from a list of tool names; unknown names are ignored.
75
- // Falls back to the read-only set when the list is empty or yields nothing.
76
- export function createToolSubset(ctx: ToolContext, names?: string[]): ToolSet {
77
- const set: ToolSet = {};
78
- for (const n of names ?? []) {
79
- const factory = AGENT_TOOL_FACTORIES[n];
80
- if (factory) set[n] = factory(ctx);
81
- }
82
- return Object.keys(set).length > 0 ? set : createReadOnlyTools(ctx);
83
- }
@@ -1,53 +0,0 @@
1
- import { tool } from "ai";
2
- import { z } from "zod";
3
- import type { ToolContext } from "./context.ts";
4
- import { saveMemory, deleteMemory } from "../memory/auto.ts";
5
-
6
- // Records durable facts across runs (auto-memory). Unlike write/edit this is not gated:
7
- // memory lives in the agent's own store, and the returned string surfaces in the
8
- // transcript as a visible notice of what was saved.
9
- export function memoryTool(ctx: ToolContext) {
10
- return tool({
11
- description:
12
- "Record or remove a durable memory that persists across sessions. Use it for facts " +
13
- "worth remembering long-term — user preferences, stable project conventions, and " +
14
- "feedback on how to work — not transient details. Memories are recalled into your " +
15
- "context automatically via the memory index. Prefer updating an existing memory " +
16
- "(same name) over creating a near-duplicate.",
17
- inputSchema: z.object({
18
- action: z.enum(["write", "delete"]).describe("Write/update a memory, or delete one."),
19
- name: z
20
- .string()
21
- .describe("Short kebab-case identifier, e.g. 'user-prefers-tabs'. Reuse to update."),
22
- description: z
23
- .string()
24
- .optional()
25
- .describe("One-line summary (required for write). Shown in the recalled index."),
26
- type: z
27
- .enum(["user", "feedback", "project", "reference"])
28
- .optional()
29
- .describe("user=about the user, feedback=how to work, project=ongoing work, reference=pointer."),
30
- scope: z
31
- .enum(["project", "global"])
32
- .optional()
33
- .describe("project (default) recalls only here; global recalls in every project."),
34
- content: z
35
- .string()
36
- .optional()
37
- .describe("The fact to remember (required for write). Link others with [[name]]."),
38
- }),
39
- execute: async ({ action, name, description, type, scope, content }) => {
40
- if (action === "delete") {
41
- const removed = deleteMemory(ctx.cwd, name);
42
- return removed
43
- ? `Deleted memory "${removed.name}" (${removed.scope}).`
44
- : `No memory named "${name}" to delete.`;
45
- }
46
- if (!description || !content) {
47
- return 'To write a memory, provide both "description" and "content".';
48
- }
49
- const rec = saveMemory(ctx.cwd, { name, description, type, scope, body: content });
50
- return `Saved memory "${rec.name}" (${rec.scope}, ${rec.type}).`;
51
- },
52
- });
53
- }
@@ -1,77 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
-
3
- export interface BgProcess {
4
- id: string;
5
- command: string;
6
- status: "running" | "exited";
7
- code: number | null;
8
- startedAt: number;
9
- }
10
-
11
- const MAX_OUTPUT = 200_000; // cap retained output per process
12
-
13
- // Tracks background shells started by the bash tool (run_in_background). Output
14
- // accumulates as it streams; `read` returns only what's new since the last read so
15
- // the model can poll a long-running command.
16
- export class ProcessRegistry {
17
- private procs = new Map<string, { meta: BgProcess; child: ChildProcess; output: string; offset: number }>();
18
- private seq = 0;
19
-
20
- spawn(command: string, cwd: string): string {
21
- const id = `bash_${++this.seq}`;
22
- const child = spawn(command, [], { cwd, shell: true, env: process.env });
23
- const entry: { meta: BgProcess; child: ChildProcess; output: string; offset: number } = {
24
- meta: { id, command, status: "running", code: null, startedAt: Date.now() },
25
- child,
26
- output: "",
27
- offset: 0,
28
- };
29
- const append = (d: Buffer) => {
30
- if (entry.output.length < MAX_OUTPUT) entry.output += d.toString();
31
- };
32
- child.stdout?.on("data", append);
33
- child.stderr?.on("data", append);
34
- child.on("error", (e) => {
35
- entry.output += `\n[error] ${e.message}`;
36
- entry.meta.status = "exited";
37
- });
38
- child.on("close", (code) => {
39
- entry.meta.status = "exited";
40
- entry.meta.code = code;
41
- });
42
- this.procs.set(id, entry);
43
- return id;
44
- }
45
-
46
- // New output since the last read, plus current status.
47
- read(id: string): { output: string; status: BgProcess["status"]; code: number | null } | null {
48
- const e = this.procs.get(id);
49
- if (!e) return null;
50
- const fresh = e.output.slice(e.offset);
51
- e.offset = e.output.length;
52
- return { output: fresh, status: e.meta.status, code: e.meta.code };
53
- }
54
-
55
- kill(id: string): boolean {
56
- const e = this.procs.get(id);
57
- if (!e) return false;
58
- e.child.kill("SIGTERM");
59
- e.meta.status = "exited";
60
- return true;
61
- }
62
-
63
- list(): BgProcess[] {
64
- return [...this.procs.values()].map((e) => e.meta);
65
- }
66
-
67
- // Best-effort teardown of all background shells (called on app exit).
68
- killAll(): void {
69
- for (const e of this.procs.values()) {
70
- try {
71
- e.child.kill("SIGKILL");
72
- } catch {
73
- /* already gone */
74
- }
75
- }
76
- }
77
- }
package/src/tools/read.ts DELETED
@@ -1,42 +0,0 @@
1
- import { readFileSync, existsSync, statSync } from "node:fs";
2
- import { tool } from "ai";
3
- import { z } from "zod";
4
- import type { ToolContext } from "./context.ts";
5
- import { resolveInCwd, displayPath, guardScope } from "./context.ts";
6
-
7
- const MAX_LINES = 2000;
8
- const MAX_LINE_LEN = 2000;
9
-
10
- export function readTool(ctx: ToolContext) {
11
- return tool({
12
- description:
13
- "Read a file from the working directory. Returns the contents with line numbers. " +
14
- "Use offset/limit for large files. Read-only.",
15
- inputSchema: z.object({
16
- path: z.string().describe("File path, absolute or relative to the working directory."),
17
- offset: z.number().int().min(1).optional().describe("1-based line to start from."),
18
- limit: z.number().int().positive().optional().describe("Max lines to read."),
19
- }),
20
- execute: async ({ path, offset, limit }) => {
21
- const abs = resolveInCwd(ctx, path);
22
- const blocked = await guardScope(ctx, abs, { kind: "read", title: "Read outside working directory" });
23
- if (blocked) return blocked;
24
- if (!existsSync(abs)) return `Error: file not found: ${displayPath(ctx, abs)}`;
25
- if (statSync(abs).isDirectory()) return `Error: ${displayPath(ctx, abs)} is a directory.`;
26
-
27
- const start = (offset ?? 1) - 1;
28
- const max = limit ?? MAX_LINES;
29
- const lines = readFileSync(abs, "utf8").split("\n");
30
- const slice = lines.slice(start, start + max);
31
- const body = slice
32
- .map((line, i) => {
33
- const n = start + i + 1;
34
- const text = line.length > MAX_LINE_LEN ? line.slice(0, MAX_LINE_LEN) + "…" : line;
35
- return `${String(n).padStart(6)}\t${text}`;
36
- })
37
- .join("\n");
38
- const more = start + max < lines.length ? `\n… (${lines.length - (start + max)} more lines)` : "";
39
- return body + more;
40
- },
41
- });
42
- }