@phren/agent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent-loop.js +328 -0
  2. package/dist/bin.js +3 -0
  3. package/dist/checkpoint.js +103 -0
  4. package/dist/commands.js +292 -0
  5. package/dist/config.js +139 -0
  6. package/dist/context/pruner.js +62 -0
  7. package/dist/context/token-counter.js +28 -0
  8. package/dist/cost.js +71 -0
  9. package/dist/index.js +284 -0
  10. package/dist/mcp-client.js +168 -0
  11. package/dist/memory/anti-patterns.js +69 -0
  12. package/dist/memory/auto-capture.js +72 -0
  13. package/dist/memory/context-flush.js +24 -0
  14. package/dist/memory/context.js +170 -0
  15. package/dist/memory/error-recovery.js +58 -0
  16. package/dist/memory/project-context.js +77 -0
  17. package/dist/memory/session.js +100 -0
  18. package/dist/multi/agent-colors.js +41 -0
  19. package/dist/multi/child-entry.js +173 -0
  20. package/dist/multi/coordinator.js +263 -0
  21. package/dist/multi/diff-renderer.js +175 -0
  22. package/dist/multi/markdown.js +96 -0
  23. package/dist/multi/presets.js +107 -0
  24. package/dist/multi/progress.js +32 -0
  25. package/dist/multi/spawner.js +219 -0
  26. package/dist/multi/tui-multi.js +626 -0
  27. package/dist/multi/types.js +7 -0
  28. package/dist/permissions/allowlist.js +61 -0
  29. package/dist/permissions/checker.js +111 -0
  30. package/dist/permissions/prompt.js +190 -0
  31. package/dist/permissions/sandbox.js +95 -0
  32. package/dist/permissions/shell-safety.js +74 -0
  33. package/dist/permissions/types.js +2 -0
  34. package/dist/plan.js +38 -0
  35. package/dist/providers/anthropic.js +170 -0
  36. package/dist/providers/codex-auth.js +197 -0
  37. package/dist/providers/codex.js +265 -0
  38. package/dist/providers/ollama.js +142 -0
  39. package/dist/providers/openai-compat.js +163 -0
  40. package/dist/providers/openrouter.js +116 -0
  41. package/dist/providers/resolve.js +39 -0
  42. package/dist/providers/retry.js +55 -0
  43. package/dist/providers/types.js +2 -0
  44. package/dist/repl.js +180 -0
  45. package/dist/spinner.js +46 -0
  46. package/dist/system-prompt.js +31 -0
  47. package/dist/tools/edit-file.js +31 -0
  48. package/dist/tools/git.js +98 -0
  49. package/dist/tools/glob.js +65 -0
  50. package/dist/tools/grep.js +108 -0
  51. package/dist/tools/lint-test.js +76 -0
  52. package/dist/tools/phren-finding.js +35 -0
  53. package/dist/tools/phren-search.js +44 -0
  54. package/dist/tools/phren-tasks.js +71 -0
  55. package/dist/tools/read-file.js +44 -0
  56. package/dist/tools/registry.js +46 -0
  57. package/dist/tools/shell.js +48 -0
  58. package/dist/tools/types.js +2 -0
  59. package/dist/tools/write-file.js +27 -0
  60. package/dist/tui.js +451 -0
  61. package/package.json +39 -0
@@ -0,0 +1,98 @@
1
+ import { execFileSync } from "child_process";
2
+ function git(args, cwd) {
3
+ return execFileSync("git", args, {
4
+ cwd,
5
+ encoding: "utf-8",
6
+ timeout: 30_000,
7
+ maxBuffer: 200_000,
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ }).trim();
10
+ }
11
+ export const gitStatusTool = {
12
+ name: "git_status",
13
+ description: "Show the working tree status (git status --short).",
14
+ input_schema: {
15
+ type: "object",
16
+ properties: {
17
+ cwd: { type: "string", description: "Working directory. Defaults to process cwd." },
18
+ },
19
+ required: [],
20
+ },
21
+ async execute(input) {
22
+ const cwd = input.cwd || process.cwd();
23
+ try {
24
+ const output = git(["status", "--short"], cwd);
25
+ return { output: output || "(working tree clean)" };
26
+ }
27
+ catch (err) {
28
+ return { output: err instanceof Error ? err.message : String(err), is_error: true };
29
+ }
30
+ },
31
+ };
32
+ export const gitDiffTool = {
33
+ name: "git_diff",
34
+ description: "Show file changes (git diff). Use cached=true for staged changes.",
35
+ input_schema: {
36
+ type: "object",
37
+ properties: {
38
+ cwd: { type: "string", description: "Working directory. Defaults to process cwd." },
39
+ cached: { type: "boolean", description: "Show staged changes (--cached)." },
40
+ path: { type: "string", description: "Limit diff to a specific file path." },
41
+ },
42
+ required: [],
43
+ },
44
+ async execute(input) {
45
+ const cwd = input.cwd || process.cwd();
46
+ const args = ["diff"];
47
+ if (input.cached)
48
+ args.push("--cached");
49
+ if (input.path)
50
+ args.push("--", input.path);
51
+ try {
52
+ const output = git(args, cwd);
53
+ return { output: output || "(no changes)" };
54
+ }
55
+ catch (err) {
56
+ return { output: err instanceof Error ? err.message : String(err), is_error: true };
57
+ }
58
+ },
59
+ };
60
+ export const gitCommitTool = {
61
+ name: "git_commit",
62
+ description: "Stage files and commit with a message. Returns the commit hash.",
63
+ input_schema: {
64
+ type: "object",
65
+ properties: {
66
+ cwd: { type: "string", description: "Working directory. Defaults to process cwd." },
67
+ message: { type: "string", description: "Commit message." },
68
+ files: {
69
+ type: "array",
70
+ items: { type: "string" },
71
+ description: "Files to stage. If empty, stages all changed files.",
72
+ },
73
+ },
74
+ required: ["message"],
75
+ },
76
+ async execute(input) {
77
+ const cwd = input.cwd || process.cwd();
78
+ const message = input.message;
79
+ const files = input.files || [];
80
+ try {
81
+ // Stage files
82
+ if (files.length > 0) {
83
+ git(["add", ...files], cwd);
84
+ }
85
+ else {
86
+ git(["add", "-A"], cwd);
87
+ }
88
+ // Commit
89
+ git(["commit", "-m", message], cwd);
90
+ // Return the hash
91
+ const hash = git(["rev-parse", "--short", "HEAD"], cwd);
92
+ return { output: `Committed: ${hash}` };
93
+ }
94
+ catch (err) {
95
+ return { output: err instanceof Error ? err.message : String(err), is_error: true };
96
+ }
97
+ },
98
+ };
@@ -0,0 +1,65 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { validatePath } from "../permissions/sandbox.js";
4
+ /** Simple glob matching without external dependencies. Supports * and ** patterns. */
5
+ function matchGlob(pattern, filePath) {
6
+ const regex = pattern
7
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
8
+ .replace(/\*\*/g, "{{GLOBSTAR}}")
9
+ .replace(/\*/g, "[^/]*")
10
+ .replace(/{{GLOBSTAR}}/g, ".*");
11
+ return new RegExp(`^${regex}$`).test(filePath);
12
+ }
13
+ function walkDir(dir, base, results, maxResults) {
14
+ if (results.length >= maxResults)
15
+ return;
16
+ let entries;
17
+ try {
18
+ entries = fs.readdirSync(dir, { withFileTypes: true });
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ for (const entry of entries) {
24
+ if (results.length >= maxResults)
25
+ return;
26
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
27
+ continue;
28
+ const full = path.join(dir, entry.name);
29
+ const rel = path.relative(base, full);
30
+ if (entry.isDirectory()) {
31
+ walkDir(full, base, results, maxResults);
32
+ }
33
+ else {
34
+ results.push(rel);
35
+ }
36
+ }
37
+ }
38
+ export const globTool = {
39
+ name: "glob",
40
+ description: "Find files by glob pattern. Use to discover project structure, locate files by extension or name. Examples: '**/*.ts', 'src/**/*.test.js', '**/config.*'. Skips node_modules and dotfiles.",
41
+ input_schema: {
42
+ type: "object",
43
+ properties: {
44
+ pattern: { type: "string", description: "Glob pattern to match." },
45
+ path: { type: "string", description: "Directory to search in. Default: cwd." },
46
+ },
47
+ required: ["pattern"],
48
+ },
49
+ async execute(input) {
50
+ const pattern = input.pattern;
51
+ const searchPath = input.path || process.cwd();
52
+ const maxResults = 500;
53
+ // Defense-in-depth: validate search path against sandbox
54
+ const pathResult = validatePath(searchPath, process.cwd(), []);
55
+ if (!pathResult.ok) {
56
+ return { output: `Path outside sandbox: ${pathResult.error}`, is_error: true };
57
+ }
58
+ const allFiles = [];
59
+ walkDir(searchPath, searchPath, allFiles, 10000);
60
+ const matches = allFiles.filter((f) => matchGlob(pattern, f)).slice(0, maxResults);
61
+ if (matches.length === 0)
62
+ return { output: "No files found." };
63
+ return { output: matches.join("\n") };
64
+ },
65
+ };
@@ -0,0 +1,108 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { validatePath } from "../permissions/sandbox.js";
4
+ function searchFile(filePath, regex, context) {
5
+ let content;
6
+ try {
7
+ content = fs.readFileSync(filePath, "utf-8");
8
+ }
9
+ catch {
10
+ return [];
11
+ }
12
+ const lines = content.split("\n");
13
+ const results = [];
14
+ for (let i = 0; i < lines.length; i++) {
15
+ if (regex.test(lines[i])) {
16
+ const start = Math.max(0, i - context);
17
+ const end = Math.min(lines.length - 1, i + context);
18
+ for (let j = start; j <= end; j++) {
19
+ results.push(`${j + 1}\t${lines[j]}`);
20
+ }
21
+ if (end < lines.length - 1)
22
+ results.push("--");
23
+ }
24
+ }
25
+ return results;
26
+ }
27
+ function walkDir(dir, results, maxFiles) {
28
+ if (results.length >= maxFiles)
29
+ return;
30
+ let entries;
31
+ try {
32
+ entries = fs.readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const entry of entries) {
38
+ if (results.length >= maxFiles)
39
+ return;
40
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
41
+ continue;
42
+ const full = path.join(dir, entry.name);
43
+ if (entry.isDirectory())
44
+ walkDir(full, results, maxFiles);
45
+ else
46
+ results.push(full);
47
+ }
48
+ }
49
+ export const grepTool = {
50
+ name: "grep",
51
+ description: "Search file contents by regex pattern. Use to find function definitions, imports, error messages, or any text across the codebase. Returns matching lines with surrounding context. Use glob param to limit to specific file types.",
52
+ input_schema: {
53
+ type: "object",
54
+ properties: {
55
+ pattern: { type: "string", description: "Regex pattern to search for." },
56
+ path: { type: "string", description: "File or directory to search. Default: cwd." },
57
+ context: { type: "number", description: "Lines of context around matches. Default: 2." },
58
+ glob: { type: "string", description: "File glob filter (e.g. '*.ts'). Default: all files." },
59
+ },
60
+ required: ["pattern"],
61
+ },
62
+ async execute(input) {
63
+ const pattern = input.pattern;
64
+ const searchPath = input.path || process.cwd();
65
+ const context = input.context ?? 2;
66
+ const fileGlob = input.glob;
67
+ // Defense-in-depth: validate search path against sandbox
68
+ const pathResult = validatePath(searchPath, process.cwd(), []);
69
+ if (!pathResult.ok) {
70
+ return { output: `Path outside sandbox: ${pathResult.error}`, is_error: true };
71
+ }
72
+ let regex;
73
+ try {
74
+ regex = new RegExp(pattern, "i");
75
+ }
76
+ catch {
77
+ return { output: `Invalid regex: ${pattern}`, is_error: true };
78
+ }
79
+ const stat = fs.statSync(searchPath, { throwIfNoEntry: false });
80
+ if (!stat)
81
+ return { output: `Path not found: ${searchPath}`, is_error: true };
82
+ if (stat.isFile()) {
83
+ const results = searchFile(searchPath, regex, context);
84
+ return { output: results.length > 0 ? `${searchPath}:\n${results.join("\n")}` : "No matches." };
85
+ }
86
+ const files = [];
87
+ walkDir(searchPath, files, 5000);
88
+ if (fileGlob) {
89
+ const globRegex = new RegExp(fileGlob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"));
90
+ const filtered = files.filter((f) => globRegex.test(path.basename(f)));
91
+ files.length = 0;
92
+ files.push(...filtered);
93
+ }
94
+ const output = [];
95
+ let matchCount = 0;
96
+ for (const file of files) {
97
+ if (matchCount > 100)
98
+ break;
99
+ const results = searchFile(file, regex, context);
100
+ if (results.length > 0) {
101
+ const rel = path.relative(searchPath, file);
102
+ output.push(`${rel}:\n${results.join("\n")}`);
103
+ matchCount++;
104
+ }
105
+ }
106
+ return { output: output.length > 0 ? output.join("\n\n") : "No matches." };
107
+ },
108
+ };
@@ -0,0 +1,76 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execFileSync } from "child_process";
4
+ /** Detect test command from project config files. */
5
+ export function detectTestCommand(cwd) {
6
+ // package.json scripts.test
7
+ const pkgPath = path.join(cwd, "package.json");
8
+ if (fs.existsSync(pkgPath)) {
9
+ try {
10
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
11
+ if (pkg.scripts?.test && pkg.scripts.test !== "echo \"Error: no test specified\" && exit 1") {
12
+ return "npm test";
13
+ }
14
+ }
15
+ catch { /* ignore */ }
16
+ }
17
+ // pytest
18
+ if (fs.existsSync(path.join(cwd, "pytest.ini")) ||
19
+ fs.existsSync(path.join(cwd, "pyproject.toml")) ||
20
+ fs.existsSync(path.join(cwd, "setup.cfg"))) {
21
+ return "pytest";
22
+ }
23
+ // cargo test
24
+ if (fs.existsSync(path.join(cwd, "Cargo.toml")))
25
+ return "cargo test";
26
+ // go test
27
+ if (fs.existsSync(path.join(cwd, "go.mod")))
28
+ return "go test ./...";
29
+ return null;
30
+ }
31
+ /** Detect lint command from project config files. */
32
+ export function detectLintCommand(cwd) {
33
+ const pkgPath = path.join(cwd, "package.json");
34
+ if (fs.existsSync(pkgPath)) {
35
+ try {
36
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
37
+ if (pkg.scripts?.lint)
38
+ return "npm run lint";
39
+ }
40
+ catch { /* ignore */ }
41
+ }
42
+ // biome
43
+ if (fs.existsSync(path.join(cwd, "biome.json")) ||
44
+ fs.existsSync(path.join(cwd, "biome.jsonc"))) {
45
+ return "npx biome check .";
46
+ }
47
+ // eslint
48
+ if (fs.existsSync(path.join(cwd, ".eslintrc.json")) ||
49
+ fs.existsSync(path.join(cwd, ".eslintrc.js")) ||
50
+ fs.existsSync(path.join(cwd, "eslint.config.js")) ||
51
+ fs.existsSync(path.join(cwd, "eslint.config.mjs"))) {
52
+ return "npx eslint .";
53
+ }
54
+ return null;
55
+ }
56
+ /** Run a command and return pass/fail + output. */
57
+ export function runPostEditCheck(command, cwd) {
58
+ try {
59
+ const output = execFileSync("bash", ["-c", command], {
60
+ cwd,
61
+ encoding: "utf-8",
62
+ timeout: 60_000,
63
+ maxBuffer: 200_000,
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ });
66
+ return { passed: true, output: output.trim() || "(passed)" };
67
+ }
68
+ catch (err) {
69
+ if (err && typeof err === "object" && "stdout" in err) {
70
+ const e = err;
71
+ const combined = [e.stdout, e.stderr].filter(Boolean).join("\n").trim();
72
+ return { passed: false, output: combined || "Check failed" };
73
+ }
74
+ return { passed: false, output: err instanceof Error ? err.message : String(err) };
75
+ }
76
+ }
@@ -0,0 +1,35 @@
1
+ import { addFinding } from "@phren/cli/core/finding";
2
+ import { incrementSessionCounter } from "../memory/session.js";
3
+ export function createPhrenFindingTool(ctx, sessionId) {
4
+ return {
5
+ name: "phren_add_finding",
6
+ description: "Save a finding to phren memory for future sessions. Good: architecture decisions with rationale, non-obvious bug causes, workarounds, gotchas, tradeoffs. Bad: obvious facts, narration of steps taken, secrets/PII. Keep findings concise and actionable.",
7
+ input_schema: {
8
+ type: "object",
9
+ properties: {
10
+ finding: { type: "string", description: "The finding to save." },
11
+ project: { type: "string", description: "Project name. Default: detected project." },
12
+ },
13
+ required: ["finding"],
14
+ },
15
+ async execute(input) {
16
+ const finding = input.finding;
17
+ const project = input.project || ctx.project;
18
+ if (!project)
19
+ return { output: "No project context. Specify a project name.", is_error: true };
20
+ try {
21
+ const result = await addFinding(ctx.phrenPath, project, finding);
22
+ if (result.ok) {
23
+ if (sessionId)
24
+ incrementSessionCounter(ctx.phrenPath, sessionId, "findingsAdded");
25
+ return { output: `Finding saved to ${project}.` };
26
+ }
27
+ return { output: result.message ?? "Failed to save finding.", is_error: true };
28
+ }
29
+ catch (err) {
30
+ const msg = err instanceof Error ? err.message : String(err);
31
+ return { output: `Failed: ${msg}`, is_error: true };
32
+ }
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,44 @@
1
+ import { buildIndex } from "@phren/cli/shared";
2
+ import { searchKnowledgeRows, rankResults } from "@phren/cli/shared/retrieval";
3
+ export function createPhrenSearchTool(ctx) {
4
+ return {
5
+ name: "phren_search",
6
+ description: "Search phren knowledge base for past findings, tasks, and reference docs. Use BEFORE starting work to check for relevant context, error resolutions, or architecture notes from prior sessions. Also use when you encounter an unfamiliar pattern or error.",
7
+ input_schema: {
8
+ type: "object",
9
+ properties: {
10
+ query: { type: "string", description: "Search query." },
11
+ project: { type: "string", description: "Limit to a specific project." },
12
+ limit: { type: "number", description: "Max results. Default: 10." },
13
+ },
14
+ required: ["query"],
15
+ },
16
+ async execute(input) {
17
+ const query = input.query;
18
+ const project = input.project || ctx.project;
19
+ const limit = input.limit || 10;
20
+ try {
21
+ const db = await buildIndex(ctx.phrenPath, ctx.profile);
22
+ const result = await searchKnowledgeRows(db, {
23
+ query,
24
+ maxResults: limit,
25
+ filterProject: project || null,
26
+ filterType: null,
27
+ phrenPath: ctx.phrenPath,
28
+ });
29
+ const ranked = rankResults(result.rows ?? [], query, null, project || null, ctx.phrenPath, db);
30
+ if (ranked.length === 0)
31
+ return { output: "No results found." };
32
+ const lines = ranked.slice(0, limit).map((r, i) => {
33
+ const snippet = r.content?.slice(0, 300) ?? "";
34
+ return `${i + 1}. [${r.project}/${r.filename}] ${snippet}`;
35
+ });
36
+ return { output: lines.join("\n\n") };
37
+ }
38
+ catch (err) {
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ return { output: `Search failed: ${msg}`, is_error: true };
41
+ }
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,71 @@
1
+ import { readTasks, completeTasks } from "@phren/cli/data/tasks";
2
+ import { incrementSessionCounter } from "../memory/session.js";
3
+ export function createPhrenGetTasksTool(ctx) {
4
+ return {
5
+ name: "phren_get_tasks",
6
+ description: "Read the current task list from phren. Shows active and queued tasks for a project.",
7
+ input_schema: {
8
+ type: "object",
9
+ properties: {
10
+ project: { type: "string", description: "Project name. Omit to use current project." },
11
+ },
12
+ },
13
+ async execute(input) {
14
+ const project = input.project || ctx.project;
15
+ if (!project)
16
+ return { output: "No project context. Specify a project name.", is_error: true };
17
+ try {
18
+ const result = readTasks(ctx.phrenPath, project);
19
+ if (!result.ok)
20
+ return { output: result.error ?? "Failed to read tasks.", is_error: true };
21
+ const sections = [];
22
+ for (const [section, items] of Object.entries(result.data.items)) {
23
+ if (section === "Done")
24
+ continue;
25
+ if (items.length === 0)
26
+ continue;
27
+ const lines = items.map((t) => `- [${t.checked ? "x" : " "}] ${t.line}`);
28
+ sections.push(`## ${section}\n${lines.join("\n")}`);
29
+ }
30
+ return { output: sections.length > 0 ? sections.join("\n\n") : "No active tasks." };
31
+ }
32
+ catch (err) {
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ return { output: `Failed: ${msg}`, is_error: true };
35
+ }
36
+ },
37
+ };
38
+ }
39
+ export function createPhrenCompleteTaskTool(ctx, sessionId) {
40
+ return {
41
+ name: "phren_complete_task",
42
+ description: "Mark a task as completed in phren by matching its text.",
43
+ input_schema: {
44
+ type: "object",
45
+ properties: {
46
+ item: { type: "string", description: "Task text to match." },
47
+ project: { type: "string", description: "Project name. Omit to use current project." },
48
+ },
49
+ required: ["item"],
50
+ },
51
+ async execute(input) {
52
+ const item = input.item;
53
+ const project = input.project || ctx.project;
54
+ if (!project)
55
+ return { output: "No project context. Specify a project name.", is_error: true };
56
+ try {
57
+ const result = completeTasks(ctx.phrenPath, project, [item]);
58
+ if (result.ok) {
59
+ if (sessionId)
60
+ incrementSessionCounter(ctx.phrenPath, sessionId, "tasksCompleted");
61
+ return { output: `Task completed in ${project}.` };
62
+ }
63
+ return { output: result.error ?? "Failed to complete task.", is_error: true };
64
+ }
65
+ catch (err) {
66
+ const msg = err instanceof Error ? err.message : String(err);
67
+ return { output: `Failed: ${msg}`, is_error: true };
68
+ }
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,44 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { checkSensitivePath, validatePath } from "../permissions/sandbox.js";
4
+ export const readFileTool = {
5
+ name: "read_file",
6
+ description: "Read file contents with numbered lines. Always read a file before editing it. Use offset/limit for large files to avoid overwhelming context.",
7
+ input_schema: {
8
+ type: "object",
9
+ properties: {
10
+ path: { type: "string", description: "Absolute or relative file path." },
11
+ offset: { type: "number", description: "Line number to start from (1-based). Default: 1." },
12
+ limit: { type: "number", description: "Max lines to read. Default: 2000." },
13
+ },
14
+ required: ["path"],
15
+ },
16
+ async execute(input) {
17
+ const filePath = input.path;
18
+ const offset = Math.max(1, input.offset || 1);
19
+ const limit = Math.min(5000, input.limit || 2000);
20
+ // Defense-in-depth: sensitive path check
21
+ const resolved = path.resolve(filePath);
22
+ const sensitive = checkSensitivePath(resolved);
23
+ if (sensitive.sensitive) {
24
+ return { output: `Access denied: ${sensitive.reason}`, is_error: true };
25
+ }
26
+ // Defense-in-depth: sandbox check
27
+ const sandboxResult = validatePath(filePath, process.cwd(), []);
28
+ if (!sandboxResult.ok) {
29
+ return { output: `Path outside sandbox: ${sandboxResult.error}`, is_error: true };
30
+ }
31
+ if (!fs.existsSync(filePath))
32
+ return { output: `File not found: ${filePath}`, is_error: true };
33
+ const content = fs.readFileSync(filePath, "utf-8");
34
+ const lines = content.split("\n");
35
+ const selected = lines.slice(offset - 1, offset - 1 + limit);
36
+ const numbered = selected.map((line, i) => `${offset + i}\t${line}`).join("\n");
37
+ const truncated = selected.length < lines.length - (offset - 1);
38
+ return {
39
+ output: truncated
40
+ ? `${numbered}\n\n(${lines.length} total lines, showing ${offset}-${offset + selected.length - 1})`
41
+ : numbered,
42
+ };
43
+ },
44
+ };
@@ -0,0 +1,46 @@
1
+ import { checkPermission } from "../permissions/checker.js";
2
+ import { askUser } from "../permissions/prompt.js";
3
+ export class ToolRegistry {
4
+ tools = new Map();
5
+ permissionConfig = {
6
+ mode: "suggest",
7
+ projectRoot: process.cwd(),
8
+ allowedPaths: [],
9
+ };
10
+ register(tool) {
11
+ this.tools.set(tool.name, tool);
12
+ }
13
+ setPermissions(config) {
14
+ this.permissionConfig = config;
15
+ }
16
+ getDefinitions() {
17
+ return [...this.tools.values()].map((t) => ({
18
+ name: t.name,
19
+ description: t.description,
20
+ input_schema: t.input_schema,
21
+ }));
22
+ }
23
+ async execute(name, input) {
24
+ const tool = this.tools.get(name);
25
+ if (!tool)
26
+ return { output: `Unknown tool: ${name}`, is_error: true };
27
+ // Permission check — always enforced
28
+ const rule = checkPermission(this.permissionConfig, name, input);
29
+ if (rule.verdict === "deny") {
30
+ return { output: `Permission denied: ${rule.reason}`, is_error: true };
31
+ }
32
+ if (rule.verdict === "ask") {
33
+ const allowed = await askUser(name, input, rule.reason);
34
+ if (!allowed) {
35
+ return { output: "User denied permission.", is_error: true };
36
+ }
37
+ }
38
+ try {
39
+ return await tool.execute(input);
40
+ }
41
+ catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ return { output: `Tool error: ${msg}`, is_error: true };
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from "child_process";
2
+ import { checkShellSafety, scrubEnv } from "../permissions/shell-safety.js";
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ const MAX_TIMEOUT_MS = 120_000;
5
+ const MAX_OUTPUT_BYTES = 100_000;
6
+ export const shellTool = {
7
+ name: "shell",
8
+ description: "Run a shell command via bash -c and return stdout + stderr. Use for: running tests, linters, build commands, git operations, and exploring the environment. Prefer specific tools (read_file, glob, grep) over shell equivalents when available.",
9
+ input_schema: {
10
+ type: "object",
11
+ properties: {
12
+ command: { type: "string", description: "Shell command to execute." },
13
+ cwd: { type: "string", description: "Working directory. Defaults to process cwd." },
14
+ timeout: { type: "number", description: "Timeout in ms. Default: 30000, max: 120000." },
15
+ },
16
+ required: ["command"],
17
+ },
18
+ async execute(input) {
19
+ const command = input.command;
20
+ const cwd = input.cwd || process.cwd();
21
+ const timeout = Math.min(MAX_TIMEOUT_MS, input.timeout || DEFAULT_TIMEOUT_MS);
22
+ // Block dangerous commands immediately
23
+ const safety = checkShellSafety(command);
24
+ if (!safety.safe && safety.severity === "block") {
25
+ return { output: `Blocked: ${safety.reason}`, is_error: true };
26
+ }
27
+ try {
28
+ const output = execFileSync("bash", ["-c", command], {
29
+ cwd,
30
+ encoding: "utf-8",
31
+ timeout,
32
+ maxBuffer: MAX_OUTPUT_BYTES,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ env: scrubEnv(),
35
+ });
36
+ return { output: output.trim() || "(no output)" };
37
+ }
38
+ catch (err) {
39
+ if (err && typeof err === "object" && "stdout" in err) {
40
+ const e = err;
41
+ const combined = [e.stdout, e.stderr].filter(Boolean).join("\n").trim();
42
+ return { output: `Exit code ${e.status ?? 1}\n${combined}`, is_error: true };
43
+ }
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ return { output: msg, is_error: true };
46
+ }
47
+ },
48
+ };
@@ -0,0 +1,2 @@
1
+ /** Agent tool types. */
2
+ export {};
@@ -0,0 +1,27 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { encodeDiffPayload } from "../multi/diff-renderer.js";
4
+ export const writeFileTool = {
5
+ name: "write_file",
6
+ description: "Write content to a file, creating parent directories as needed. Use for new files only — prefer edit_file for modifying existing files. Overwrites existing content entirely.",
7
+ input_schema: {
8
+ type: "object",
9
+ properties: {
10
+ path: { type: "string", description: "File path to write." },
11
+ content: { type: "string", description: "Content to write." },
12
+ },
13
+ required: ["path", "content"],
14
+ },
15
+ async execute(input) {
16
+ const filePath = input.path;
17
+ const content = input.content;
18
+ const oldContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
19
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
20
+ fs.writeFileSync(filePath, content);
21
+ const msg = `Wrote ${content.length} bytes to ${filePath}`;
22
+ if (oldContent) {
23
+ return { output: msg + encodeDiffPayload(filePath, oldContent, content) };
24
+ }
25
+ return { output: msg };
26
+ },
27
+ };