@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,111 @@
1
+ import { checkShellSafety } from "./shell-safety.js";
2
+ import { validatePath, checkSensitivePath } from "./sandbox.js";
3
+ import { isAllowed } from "./allowlist.js";
4
+ /** Tools that are safe in all modes — read-only, no side effects. */
5
+ const ALWAYS_SAFE_TOOLS = new Set([
6
+ "phren_search",
7
+ "phren_get_tasks",
8
+ ]);
9
+ /** Tools that access file paths and need sensitive-path checks. */
10
+ const FILE_TOOLS = new Set([
11
+ "read_file",
12
+ "write_file",
13
+ "edit_file",
14
+ "glob",
15
+ "grep",
16
+ ]);
17
+ /** Tools that auto-confirm mode allows without prompting. */
18
+ const AUTO_CONFIRM_TOOLS = new Set([
19
+ "edit_file",
20
+ "phren_add_finding",
21
+ "phren_complete_task",
22
+ ]);
23
+ /** Tools that are always denied regardless of mode. */
24
+ const DENY_LIST_TOOLS = new Set([
25
+ // Reserved for future use — e.g. "delete_project"
26
+ ]);
27
+ /**
28
+ * Check whether a tool call should be allowed, asked about, or denied.
29
+ */
30
+ export function checkPermission(config, toolName, input) {
31
+ // Deny-list always wins
32
+ if (DENY_LIST_TOOLS.has(toolName)) {
33
+ return { verdict: "deny", reason: `Tool "${toolName}" is on the deny list.` };
34
+ }
35
+ // Shell commands get extra scrutiny
36
+ if (toolName === "shell") {
37
+ const cmd = input.command || "";
38
+ const safety = checkShellSafety(cmd);
39
+ if (!safety.safe && safety.severity === "block") {
40
+ return { verdict: "deny", reason: safety.reason };
41
+ }
42
+ if (!safety.safe && safety.severity === "warn") {
43
+ // In full-auto, warn becomes ask. In other modes, it's already going to ask.
44
+ if (config.mode === "full-auto") {
45
+ return { verdict: "ask", reason: safety.reason };
46
+ }
47
+ }
48
+ // Check cwd for shell
49
+ const cwd = input.cwd || config.projectRoot;
50
+ const cwdResult = validatePath(cwd, config.projectRoot, config.allowedPaths);
51
+ if (!cwdResult.ok) {
52
+ if (config.mode === "full-auto") {
53
+ return { verdict: "ask", reason: `Shell cwd outside sandbox: ${cwdResult.error}` };
54
+ }
55
+ // suggest and auto-confirm will ask below anyway
56
+ }
57
+ }
58
+ // Path-based tools: validate sandbox + sensitive path
59
+ if (FILE_TOOLS.has(toolName)) {
60
+ const filePath = input.path || "";
61
+ if (filePath) {
62
+ // Sensitive path check applies in ALL modes
63
+ const sensitive = checkSensitivePath(filePath);
64
+ if (sensitive.sensitive) {
65
+ return { verdict: "deny", reason: `Sensitive path: ${sensitive.reason}` };
66
+ }
67
+ // Sandbox check: ask for out-of-sandbox paths in ALL modes (not just full-auto)
68
+ const pathResult = validatePath(filePath, config.projectRoot, config.allowedPaths);
69
+ if (!pathResult.ok) {
70
+ return { verdict: "ask", reason: `Path outside sandbox: ${pathResult.error}` };
71
+ }
72
+ }
73
+ }
74
+ // Always-safe tools pass in all modes
75
+ if (ALWAYS_SAFE_TOOLS.has(toolName)) {
76
+ return { verdict: "allow", reason: "Read-only tool, always allowed." };
77
+ }
78
+ // Session allowlist — user previously approved this tool+pattern via (a)llow-tool or (s)ession-allow.
79
+ // Placed after deny-list, shell-safety blocks, and sensitive-path denials so those are never bypassed.
80
+ if (isAllowed(toolName, input)) {
81
+ return { verdict: "allow", reason: "Session allowlist." };
82
+ }
83
+ // Mode-specific logic
84
+ switch (config.mode) {
85
+ case "suggest":
86
+ // Suggest mode: ask for everything except safe tools
87
+ return { verdict: "ask", reason: `Suggest mode requires confirmation for "${toolName}".` };
88
+ case "auto-confirm":
89
+ if (AUTO_CONFIRM_TOOLS.has(toolName)) {
90
+ // Auto-confirm tools are allowed if path is in sandbox
91
+ return { verdict: "allow", reason: `Auto-confirm mode allows "${toolName}".` };
92
+ }
93
+ if (toolName === "shell") {
94
+ const cwd = input.cwd || config.projectRoot;
95
+ const cwdResult = validatePath(cwd, config.projectRoot, config.allowedPaths);
96
+ if (cwdResult.ok) {
97
+ const cmd = input.command || "";
98
+ const safety = checkShellSafety(cmd);
99
+ if (safety.safe) {
100
+ return { verdict: "allow", reason: "Safe shell command within sandbox." };
101
+ }
102
+ }
103
+ }
104
+ return { verdict: "ask", reason: `Auto-confirm mode requires confirmation for "${toolName}".` };
105
+ case "full-auto":
106
+ // Full-auto: allow everything not denied or warned
107
+ return { verdict: "allow", reason: "Full-auto mode." };
108
+ default:
109
+ return { verdict: "ask", reason: "Unknown permission mode." };
110
+ }
111
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Smart permission prompt — color-coded, full context, keyboard shortcuts.
3
+ *
4
+ * Responses:
5
+ * y = allow once
6
+ * n = deny
7
+ * a = allow this tool for the rest of the session (any input)
8
+ * s = allow this exact tool+pattern for the rest of the session
9
+ */
10
+ import * as readline from "node:readline";
11
+ import { addAllow } from "./allowlist.js";
12
+ // ── ANSI colors ─────────────────────────────────────────────────────────
13
+ const RESET = "\x1b[0m";
14
+ const BOLD = "\x1b[1m";
15
+ const DIM = "\x1b[2m";
16
+ const GREEN = "\x1b[32m";
17
+ const YELLOW = "\x1b[33m";
18
+ const RED = "\x1b[31m";
19
+ const CYAN = "\x1b[36m";
20
+ const READ_TOOLS = new Set(["read_file", "glob", "grep", "git_status", "git_diff", "phren_search", "phren_get_tasks"]);
21
+ const DANGEROUS_TOOLS = new Set(["shell"]);
22
+ function classifyRisk(toolName) {
23
+ if (READ_TOOLS.has(toolName))
24
+ return "read";
25
+ if (DANGEROUS_TOOLS.has(toolName))
26
+ return "dangerous";
27
+ return "write";
28
+ }
29
+ function riskColor(risk) {
30
+ switch (risk) {
31
+ case "read": return GREEN;
32
+ case "write": return YELLOW;
33
+ case "dangerous": return RED;
34
+ }
35
+ }
36
+ function riskLabel(risk) {
37
+ switch (risk) {
38
+ case "read": return "READ";
39
+ case "write": return "WRITE";
40
+ case "dangerous": return "SHELL";
41
+ }
42
+ }
43
+ // ── Summary generation ──────────────────────────────────────────────────
44
+ function summarizeCall(toolName, input) {
45
+ switch (toolName) {
46
+ case "read_file": {
47
+ const p = input.path || "?";
48
+ const offset = input.offset ? ` from line ${input.offset}` : "";
49
+ const limit = input.limit ? ` (${input.limit} lines)` : "";
50
+ return `Read ${p}${offset}${limit}`;
51
+ }
52
+ case "write_file": {
53
+ const p = input.path || "?";
54
+ const content = input.content || "";
55
+ const lines = content.split("\n").length;
56
+ return `Write ${lines} lines to ${p}`;
57
+ }
58
+ case "edit_file": {
59
+ const p = input.path || "?";
60
+ return `Edit ${p}`;
61
+ }
62
+ case "glob": {
63
+ const pattern = input.pattern || "?";
64
+ const dir = input.path || ".";
65
+ return `Glob "${pattern}" in ${dir}`;
66
+ }
67
+ case "grep": {
68
+ const pattern = input.pattern || "?";
69
+ const dir = input.path || ".";
70
+ return `Grep "${pattern}" in ${dir}`;
71
+ }
72
+ case "shell": {
73
+ const cmd = input.command || "?";
74
+ return cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd;
75
+ }
76
+ case "git_commit": {
77
+ const msg = input.message || "";
78
+ return `Commit: ${msg.slice(0, 80)}`;
79
+ }
80
+ case "phren_add_finding":
81
+ return `Save finding to phren`;
82
+ case "phren_complete_task":
83
+ return `Complete phren task`;
84
+ default: {
85
+ const keys = Object.keys(input);
86
+ return keys.length > 0 ? `${toolName}(${keys.join(", ")})` : toolName;
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * Ask the user on stderr whether to allow a tool call.
92
+ * Returns true if user approves (y, a, or s), false if denied (n).
93
+ *
94
+ * Side effect: "a" and "s" responses add to the session allowlist.
95
+ */
96
+ export async function askUser(toolName, input, reason) {
97
+ const risk = classifyRisk(toolName);
98
+ const color = riskColor(risk);
99
+ const label = riskLabel(risk);
100
+ const summary = summarizeCall(toolName, input);
101
+ // Header
102
+ process.stderr.write(`\n${color}${BOLD}[${label}]${RESET} ${BOLD}${toolName}${RESET}\n`);
103
+ process.stderr.write(`${DIM} ${reason}${RESET}\n`);
104
+ process.stderr.write(`${CYAN} ${summary}${RESET}\n`);
105
+ // Show full input for shell commands or when details matter
106
+ if (toolName === "shell") {
107
+ const cmd = input.command || "";
108
+ if (cmd.length > 120) {
109
+ process.stderr.write(`${DIM} Full command:${RESET}\n`);
110
+ process.stderr.write(`${DIM} ${cmd}${RESET}\n`);
111
+ }
112
+ }
113
+ const result = await promptKey();
114
+ // Persist allowlist entries for session/tool scopes
115
+ if (result === "allow-session") {
116
+ addAllow(toolName, input, "session");
117
+ }
118
+ else if (result === "allow-tool") {
119
+ addAllow(toolName, input, "tool");
120
+ }
121
+ return result !== "deny";
122
+ }
123
+ /**
124
+ * Read a single keypress from stdin.
125
+ * Temporarily exits raw mode if the TUI has it enabled, restores after.
126
+ */
127
+ async function promptKey() {
128
+ const hint = `${DIM} [y]es [n]o [a]llow-tool [s]ession-allow${RESET} `;
129
+ process.stderr.write(hint);
130
+ const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
131
+ return new Promise((resolve) => {
132
+ if (process.stdin.isTTY) {
133
+ // Single-keypress mode
134
+ if (!wasRaw) {
135
+ process.stdin.setRawMode(true);
136
+ }
137
+ process.stdin.resume();
138
+ const onData = (data) => {
139
+ process.stdin.removeListener("data", onData);
140
+ if (!wasRaw && process.stdin.isTTY) {
141
+ process.stdin.setRawMode(false);
142
+ }
143
+ process.stdin.pause();
144
+ const key = data.toString().trim().toLowerCase();
145
+ process.stderr.write(key + "\n");
146
+ switch (key) {
147
+ case "y":
148
+ resolve("allow");
149
+ break;
150
+ case "a":
151
+ resolve("allow-tool");
152
+ break;
153
+ case "s":
154
+ resolve("allow-session");
155
+ break;
156
+ case "n":
157
+ resolve("deny");
158
+ break;
159
+ case "\x03": // Ctrl+C
160
+ process.stderr.write("\n");
161
+ resolve("deny");
162
+ break;
163
+ default:
164
+ resolve("deny"); // Unknown key = deny (safe default)
165
+ }
166
+ };
167
+ process.stdin.on("data", onData);
168
+ }
169
+ else {
170
+ // Non-TTY fallback: readline
171
+ const iface = readline.createInterface({ input: process.stdin, output: process.stderr });
172
+ iface.question("", (answer) => {
173
+ iface.close();
174
+ const key = answer.trim().toLowerCase();
175
+ switch (key) {
176
+ case "y":
177
+ resolve("allow");
178
+ break;
179
+ case "a":
180
+ resolve("allow-tool");
181
+ break;
182
+ case "s":
183
+ resolve("allow-session");
184
+ break;
185
+ default: resolve("deny");
186
+ }
187
+ });
188
+ }
189
+ });
190
+ }
@@ -0,0 +1,95 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ /** Patterns that match sensitive files/directories. */
5
+ const SENSITIVE_PATTERNS = [
6
+ "/.ssh/",
7
+ "/.aws/",
8
+ ".env",
9
+ "codex-token.json",
10
+ "id_rsa",
11
+ "id_ed25519",
12
+ "/etc/shadow",
13
+ "/etc/passwd",
14
+ "credentials.json",
15
+ "secrets.json",
16
+ "secrets.yaml",
17
+ ".npmrc",
18
+ ".netrc",
19
+ ".docker/config.json",
20
+ ".kube/config",
21
+ "/.gnupg/",
22
+ ".pypirc",
23
+ ];
24
+ /** File extensions that are always sensitive. */
25
+ const SENSITIVE_EXTENSIONS = [".pem", ".p12", ".pfx", ".key", ".keystore", ".jks"];
26
+ /**
27
+ * Resolve and validate a file path against the sandbox boundary.
28
+ */
29
+ export function validatePath(filePath, projectRoot, allowedPaths) {
30
+ // Resolve ~ to home directory
31
+ let resolved = filePath;
32
+ if (resolved.startsWith("~/") || resolved === "~") {
33
+ resolved = path.join(os.homedir(), resolved.slice(1));
34
+ }
35
+ // Resolve to absolute
36
+ if (!path.isAbsolute(resolved)) {
37
+ resolved = path.resolve(projectRoot, resolved);
38
+ }
39
+ // Normalize (remove .., trailing slashes, etc.)
40
+ resolved = path.normalize(resolved);
41
+ // Resolve symlinks if the path exists
42
+ try {
43
+ if (fs.existsSync(resolved)) {
44
+ resolved = fs.realpathSync(resolved);
45
+ }
46
+ }
47
+ catch {
48
+ // If we can't resolve, proceed with the normalized path
49
+ }
50
+ // Check sandbox boundaries
51
+ if (!isPathInSandbox(resolved, projectRoot, allowedPaths)) {
52
+ return {
53
+ ok: false,
54
+ error: `Path "${resolved}" is outside project root "${projectRoot}" and not in allowed paths.`,
55
+ };
56
+ }
57
+ return { ok: true, resolved };
58
+ }
59
+ /**
60
+ * Check if a resolved path is within the project root or any allowed path.
61
+ */
62
+ export function isPathInSandbox(resolved, projectRoot, allowedPaths) {
63
+ const normalizedResolved = path.normalize(resolved) + path.sep;
64
+ const normalizedRoot = path.normalize(projectRoot) + path.sep;
65
+ if (normalizedResolved.startsWith(normalizedRoot) || resolved === projectRoot) {
66
+ return true;
67
+ }
68
+ for (const allowed of allowedPaths) {
69
+ let normalizedAllowed = allowed;
70
+ if (normalizedAllowed.startsWith("~/") || normalizedAllowed === "~") {
71
+ normalizedAllowed = path.join(os.homedir(), normalizedAllowed.slice(1));
72
+ }
73
+ normalizedAllowed = path.normalize(normalizedAllowed) + path.sep;
74
+ if (normalizedResolved.startsWith(normalizedAllowed) || resolved === path.normalize(allowed)) {
75
+ return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Check if a resolved path matches any known sensitive pattern.
82
+ */
83
+ export function checkSensitivePath(resolved) {
84
+ const normalizedLower = resolved.toLowerCase();
85
+ const ext = path.extname(resolved).toLowerCase();
86
+ if (SENSITIVE_EXTENSIONS.includes(ext)) {
87
+ return { sensitive: true, reason: `Sensitive file extension: ${ext}` };
88
+ }
89
+ for (const pattern of SENSITIVE_PATTERNS) {
90
+ if (normalizedLower.includes(pattern.toLowerCase())) {
91
+ return { sensitive: true, reason: `Matches sensitive pattern: ${pattern}` };
92
+ }
93
+ }
94
+ return { sensitive: false };
95
+ }
@@ -0,0 +1,74 @@
1
+ const DANGEROUS_PATTERNS = [
2
+ // Block: destructive/irreversible (no $ anchors — catch chained commands like `rm -rf /; echo done`)
3
+ { pattern: /rm\s+-[a-z]*r[a-z]*f?\s+\/\s*/i, reason: "Recursive delete of root filesystem", severity: "block" },
4
+ { pattern: /rm\s+-[a-z]*r[a-z]*f?\s+\/[^\/\s]*/i, reason: "Recursive delete of top-level directory", severity: "block" },
5
+ { pattern: /curl\s+.*\|\s*(?:ba)?sh/i, reason: "Piping remote script to shell", severity: "block" },
6
+ { pattern: /wget\s+.*\|\s*(?:ba)?sh/i, reason: "Piping remote script to shell", severity: "block" },
7
+ { pattern: /\bmkfs\b/i, reason: "Filesystem format command", severity: "block" },
8
+ { pattern: /\bdd\b.*\bof=\/dev\//i, reason: "Direct device write with dd", severity: "block" },
9
+ { pattern: />\s*\/dev\/[sh]d[a-z]/i, reason: "Direct write to block device", severity: "block" },
10
+ { pattern: /:(){ :\|:& };:/i, reason: "Fork bomb", severity: "block" },
11
+ { pattern: /\bnohup\b/i, reason: "Detached process may outlive session", severity: "block" },
12
+ { pattern: /\bdisown\b/i, reason: "Detached process may outlive session", severity: "block" },
13
+ { pattern: /\bsetsid\b/i, reason: "Detached process may outlive session", severity: "block" },
14
+ // Warn: potentially dangerous
15
+ { pattern: /\beval\b/i, reason: "Dynamic code execution via eval", severity: "warn" },
16
+ { pattern: /\$\(.*\)/, reason: "Command substitution", severity: "warn" },
17
+ { pattern: /`[^`]+`/, reason: "Command substitution via backticks", severity: "warn" },
18
+ { pattern: /\benv\b/i, reason: "May expose environment variables", severity: "warn" },
19
+ { pattern: /\bprintenv\b/i, reason: "May expose environment variables", severity: "warn" },
20
+ { pattern: /\bsudo\b/i, reason: "Elevated privileges requested", severity: "warn" },
21
+ { pattern: /\bgit\s+push\s+--force\b/i, reason: "Force push can rewrite remote history", severity: "warn" },
22
+ { pattern: /\bgit\s+push\s+-f\b/i, reason: "Force push can rewrite remote history", severity: "warn" },
23
+ { pattern: /\bgit\s+reset\s+--hard\b/i, reason: "Hard reset discards uncommitted changes", severity: "warn" },
24
+ { pattern: /\bchmod\s+777\b/, reason: "World-writable permissions", severity: "warn" },
25
+ { pattern: /\bchown\b.*\broot\b/i, reason: "Changing ownership to root", severity: "warn" },
26
+ ];
27
+ /** API key env var patterns to scrub. */
28
+ const KEY_PATTERNS = [
29
+ "ANTHROPIC_API_KEY",
30
+ "OPENAI_API_KEY",
31
+ "OPENROUTER_API_KEY",
32
+ "AWS_ACCESS_KEY_ID",
33
+ "AWS_SECRET_ACCESS_KEY",
34
+ "DATABASE_URL",
35
+ "KUBECONFIG",
36
+ "DOCKER_AUTH_CONFIG",
37
+ ];
38
+ /** Suffix patterns that also match connection strings and auth configs. */
39
+ const SECRET_SUFFIX_PATTERNS = ["_URI", "_DSN"];
40
+ const SECRET_SUFFIXES = ["_SECRET", "_TOKEN", "_PASSWORD", "_KEY"];
41
+ /**
42
+ * Check a shell command for dangerous patterns.
43
+ */
44
+ export function checkShellSafety(command) {
45
+ for (const dp of DANGEROUS_PATTERNS) {
46
+ if (dp.pattern.test(command)) {
47
+ return { safe: false, reason: dp.reason, severity: dp.severity };
48
+ }
49
+ }
50
+ return { safe: true, reason: "", severity: "ok" };
51
+ }
52
+ /**
53
+ * Return a sanitized copy of process.env with API keys and secrets removed.
54
+ */
55
+ export function scrubEnv() {
56
+ const env = { ...process.env };
57
+ for (const key of Object.keys(env)) {
58
+ // Known API key vars
59
+ if (KEY_PATTERNS.includes(key)) {
60
+ delete env[key];
61
+ continue;
62
+ }
63
+ // Anything ending with _SECRET, _TOKEN, _PASSWORD, _KEY, _URI, _DSN
64
+ const upper = key.toUpperCase();
65
+ if (SECRET_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
66
+ delete env[key];
67
+ continue;
68
+ }
69
+ if (SECRET_SUFFIX_PATTERNS.some((suffix) => upper.endsWith(suffix))) {
70
+ delete env[key];
71
+ }
72
+ }
73
+ return env;
74
+ }
@@ -0,0 +1,2 @@
1
+ /** Permission system types. */
2
+ export {};
package/dist/plan.js ADDED
@@ -0,0 +1,38 @@
1
+ /** Plan mode — ask the LLM for a plan before allowing tool use. */
2
+ import * as readline from "readline";
3
+ const PLAN_SUFFIX = `
4
+
5
+ ## Plan mode
6
+
7
+ Before executing any tools, first describe your plan:
8
+ 1. What you understand about the task
9
+ 2. What files you'll need to read or modify
10
+ 3. What approach you'll take
11
+ 4. Any risks or uncertainties
12
+
13
+ Do NOT call any tools yet. Just describe your plan.`;
14
+ /** Append plan instruction to the system prompt. */
15
+ export function injectPlanPrompt(systemPrompt) {
16
+ return systemPrompt + PLAN_SUFFIX;
17
+ }
18
+ /** Ask the user to approve the plan. Returns true if approved. */
19
+ export async function requestPlanApproval() {
20
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
21
+ return new Promise((resolve) => {
22
+ rl.question("\n\x1b[1mApprove this plan? [Y/n/edit] \x1b[0m", (answer) => {
23
+ rl.close();
24
+ const trimmed = answer.trim().toLowerCase();
25
+ if (trimmed === "n" || trimmed === "no") {
26
+ resolve({ approved: false });
27
+ }
28
+ else if (trimmed.startsWith("edit") || trimmed.length > 3) {
29
+ // Anything longer than "yes" is treated as feedback
30
+ const feedback = trimmed.startsWith("edit") ? trimmed.slice(4).trim() || "Please revise the plan." : trimmed;
31
+ resolve({ approved: false, feedback });
32
+ }
33
+ else {
34
+ resolve({ approved: true });
35
+ }
36
+ });
37
+ });
38
+ }
@@ -0,0 +1,170 @@
1
+ export class AnthropicProvider {
2
+ name = "anthropic";
3
+ contextWindow = 200_000;
4
+ apiKey;
5
+ model;
6
+ constructor(apiKey, model) {
7
+ this.apiKey = apiKey;
8
+ this.model = model ?? "claude-sonnet-4-20250514";
9
+ }
10
+ async chat(system, messages, tools) {
11
+ const body = {
12
+ model: this.model,
13
+ system,
14
+ messages: messages.map((m) => ({
15
+ role: m.role,
16
+ content: m.content,
17
+ })),
18
+ max_tokens: 8192,
19
+ };
20
+ if (tools.length > 0) {
21
+ body.tools = tools.map((t) => ({
22
+ name: t.name,
23
+ description: t.description,
24
+ input_schema: t.input_schema,
25
+ }));
26
+ }
27
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
28
+ method: "POST",
29
+ headers: {
30
+ "Content-Type": "application/json",
31
+ "x-api-key": this.apiKey,
32
+ "anthropic-version": "2023-06-01",
33
+ },
34
+ body: JSON.stringify(body),
35
+ });
36
+ if (!res.ok) {
37
+ const text = await res.text();
38
+ throw new Error(`Anthropic API error ${res.status}: ${text}`);
39
+ }
40
+ const data = await res.json();
41
+ const content = data.content ?? [];
42
+ const stop_reason = data.stop_reason === "tool_use" ? "tool_use"
43
+ : data.stop_reason === "max_tokens" ? "max_tokens"
44
+ : "end_turn";
45
+ const usage = data.usage;
46
+ return {
47
+ content,
48
+ stop_reason: stop_reason,
49
+ usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
50
+ };
51
+ }
52
+ async *chatStream(system, messages, tools) {
53
+ const body = {
54
+ model: this.model,
55
+ system,
56
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
57
+ max_tokens: 8192,
58
+ stream: true,
59
+ };
60
+ if (tools.length > 0) {
61
+ body.tools = tools.map((t) => ({
62
+ name: t.name,
63
+ description: t.description,
64
+ input_schema: t.input_schema,
65
+ }));
66
+ }
67
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ "x-api-key": this.apiKey,
72
+ "anthropic-version": "2023-06-01",
73
+ },
74
+ body: JSON.stringify(body),
75
+ });
76
+ if (!res.ok) {
77
+ const text = await res.text();
78
+ throw new Error(`Anthropic API error ${res.status}: ${text}`);
79
+ }
80
+ let stopReason = "end_turn";
81
+ let usage;
82
+ // Map block index to tool ID for consistent ID across start/delta/end
83
+ const indexToToolId = new Map();
84
+ for await (const event of parseSSE(res)) {
85
+ const type = event.event;
86
+ const data = event.data;
87
+ if (type === "content_block_start") {
88
+ const block = data.content_block;
89
+ if (block.type === "tool_use") {
90
+ const index = data.index;
91
+ const id = block.id;
92
+ indexToToolId.set(index, id);
93
+ yield { type: "tool_use_start", id, name: block.name };
94
+ }
95
+ }
96
+ else if (type === "content_block_delta") {
97
+ const delta = data.delta;
98
+ if (delta.type === "text_delta") {
99
+ yield { type: "text_delta", text: delta.text };
100
+ }
101
+ else if (delta.type === "input_json_delta") {
102
+ const index = data.index;
103
+ const id = indexToToolId.get(index) ?? String(index);
104
+ yield { type: "tool_use_delta", id, json: delta.partial_json };
105
+ }
106
+ }
107
+ else if (type === "content_block_stop") {
108
+ const index = data.index;
109
+ if (indexToToolId.has(index)) {
110
+ yield { type: "tool_use_end", id: indexToToolId.get(index) };
111
+ }
112
+ }
113
+ else if (type === "message_delta") {
114
+ const delta = data.delta;
115
+ if (delta.stop_reason === "tool_use")
116
+ stopReason = "tool_use";
117
+ else if (delta.stop_reason === "max_tokens")
118
+ stopReason = "max_tokens";
119
+ const u = data.usage;
120
+ if (u) {
121
+ usage = {
122
+ input_tokens: u.input_tokens ?? 0,
123
+ output_tokens: u.output_tokens ?? 0,
124
+ };
125
+ }
126
+ }
127
+ else if (type === "message_start") {
128
+ const u = data.message?.usage;
129
+ if (u) {
130
+ usage = {
131
+ input_tokens: u.input_tokens ?? 0,
132
+ output_tokens: u.output_tokens ?? 0,
133
+ };
134
+ }
135
+ }
136
+ }
137
+ yield { type: "done", stop_reason: stopReason, usage };
138
+ }
139
+ }
140
+ /** Parse SSE stream from a fetch Response. */
141
+ async function* parseSSE(res) {
142
+ if (!res.body)
143
+ throw new Error("Provider returned empty response body");
144
+ const reader = res.body.getReader();
145
+ const decoder = new TextDecoder();
146
+ let buf = "";
147
+ let currentEvent = "";
148
+ for (;;) {
149
+ const { done, value } = await reader.read();
150
+ if (done)
151
+ break;
152
+ buf += decoder.decode(value, { stream: true });
153
+ const lines = buf.split("\n");
154
+ buf = lines.pop();
155
+ for (const line of lines) {
156
+ if (line.startsWith("event: ")) {
157
+ currentEvent = line.slice(7).trim();
158
+ }
159
+ else if (line.startsWith("data: ")) {
160
+ const raw = line.slice(6);
161
+ if (raw === "[DONE]")
162
+ return;
163
+ try {
164
+ yield { event: currentEvent, data: JSON.parse(raw) };
165
+ }
166
+ catch { /* skip malformed JSON */ }
167
+ }
168
+ }
169
+ }
170
+ }