@datename/fastcode-termux 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +400 -2
- package/bin/fastcode.mjs +59 -0
- package/dist/agent/guards.d.ts +35 -0
- package/dist/agent/guards.js +74 -0
- package/dist/agent/limits.d.ts +24 -0
- package/dist/agent/limits.js +71 -0
- package/dist/agent/protocol.d.ts +4 -0
- package/dist/agent/protocol.js +126 -0
- package/dist/agent/tools.d.ts +1 -0
- package/dist/agent/tools.js +377 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +476 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +518 -0
- package/dist/commands/about.d.ts +8 -0
- package/dist/commands/about.js +16 -0
- package/dist/commands/agent.d.ts +11 -0
- package/dist/commands/agent.js +18 -0
- package/dist/commands/clear.d.ts +11 -0
- package/dist/commands/clear.js +9 -0
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +24 -0
- package/dist/commands/cost.d.ts +9 -0
- package/dist/commands/cost.js +13 -0
- package/dist/commands/cwd.d.ts +7 -0
- package/dist/commands/cwd.js +7 -0
- package/dist/commands/exit.d.ts +9 -0
- package/dist/commands/exit.js +8 -0
- package/dist/commands/help.d.ts +6 -0
- package/dist/commands/help.js +18 -0
- package/dist/commands/index.d.ts +9 -0
- package/dist/commands/index.js +58 -0
- package/dist/commands/login.d.ts +16 -0
- package/dist/commands/login.js +61 -0
- package/dist/commands/logout.d.ts +9 -0
- package/dist/commands/logout.js +53 -0
- package/dist/commands/model.d.ts +11 -0
- package/dist/commands/model.js +30 -0
- package/dist/commands/plugin.d.ts +11 -0
- package/dist/commands/plugin.js +74 -0
- package/dist/commands/provider.d.ts +11 -0
- package/dist/commands/provider.js +28 -0
- package/dist/commands/reset.d.ts +9 -0
- package/dist/commands/reset.js +13 -0
- package/dist/commands/skills.d.ts +11 -0
- package/dist/commands/skills.js +106 -0
- package/dist/commands/system.d.ts +11 -0
- package/dist/commands/system.js +20 -0
- package/dist/commands/theme.d.ts +11 -0
- package/dist/commands/theme.js +22 -0
- package/dist/commands/tools.d.ts +7 -0
- package/dist/commands/tools.js +29 -0
- package/dist/commands/whoami.d.ts +9 -0
- package/dist/commands/whoami.js +51 -0
- package/dist/config/paths.d.ts +4 -0
- package/dist/config/paths.js +59 -0
- package/dist/config/store.d.ts +69 -0
- package/dist/config/store.js +208 -0
- package/dist/config/themes.d.ts +73 -0
- package/dist/config/themes.js +77 -0
- package/dist/config/trust.d.ts +9 -0
- package/dist/config/trust.js +67 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +31 -0
- package/dist/lib/diagnostics.d.ts +41 -0
- package/dist/lib/diagnostics.js +139 -0
- package/dist/lib/http.d.ts +13 -0
- package/dist/lib/http.js +70 -0
- package/dist/lib/markdown.d.ts +1 -0
- package/dist/lib/markdown.js +32 -0
- package/dist/lib/pkg.d.ts +5 -0
- package/dist/lib/pkg.js +27 -0
- package/dist/lib/platform.d.ts +7 -0
- package/dist/lib/platform.js +57 -0
- package/dist/lib/startup.d.ts +6 -0
- package/dist/lib/startup.js +25 -0
- package/dist/lib/uiMode.d.ts +2 -0
- package/dist/lib/uiMode.js +7 -0
- package/dist/plugins/manager.d.ts +23 -0
- package/dist/plugins/manager.js +101 -0
- package/dist/providers/anthropic.d.ts +21 -0
- package/dist/providers/anthropic.js +107 -0
- package/dist/providers/custom.d.ts +24 -0
- package/dist/providers/custom.js +25 -0
- package/dist/providers/deepseek.d.ts +24 -0
- package/dist/providers/deepseek.js +15 -0
- package/dist/providers/google.d.ts +24 -0
- package/dist/providers/google.js +19 -0
- package/dist/providers/groq.d.ts +24 -0
- package/dist/providers/groq.js +20 -0
- package/dist/providers/index.d.ts +201 -0
- package/dist/providers/index.js +37 -0
- package/dist/providers/mistral.d.ts +24 -0
- package/dist/providers/mistral.js +19 -0
- package/dist/providers/moonshot.d.ts +24 -0
- package/dist/providers/moonshot.js +22 -0
- package/dist/providers/ollama.d.ts +24 -0
- package/dist/providers/ollama.js +22 -0
- package/dist/providers/openai.d.ts +24 -0
- package/dist/providers/openai.js +25 -0
- package/dist/providers/openaiCompatible.d.ts +17 -0
- package/dist/providers/openaiCompatible.js +110 -0
- package/dist/providers/openrouter.d.ts +24 -0
- package/dist/providers/openrouter.js +26 -0
- package/dist/providers/qwen.d.ts +24 -0
- package/dist/providers/qwen.js +23 -0
- package/dist/providers/xai.d.ts +24 -0
- package/dist/providers/xai.js +18 -0
- package/dist/providers/zhipu.d.ts +24 -0
- package/dist/providers/zhipu.js +23 -0
- package/dist/session/log.d.ts +29 -0
- package/dist/session/log.js +135 -0
- package/dist/skills/manager.d.ts +18 -0
- package/dist/skills/manager.js +201 -0
- package/dist/skills/parser.d.ts +18 -0
- package/dist/skills/parser.js +65 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.js +1 -0
- package/dist/ui/Banner.d.ts +5 -0
- package/dist/ui/Banner.js +65 -0
- package/dist/ui/Message.d.ts +7 -0
- package/dist/ui/Message.js +13 -0
- package/dist/ui/Prompt.d.ts +10 -0
- package/dist/ui/Prompt.js +38 -0
- package/dist/ui/Spinner.d.ts +5 -0
- package/dist/ui/Spinner.js +33 -0
- package/dist/ui/StatusBar.d.ts +10 -0
- package/dist/ui/StatusBar.js +19 -0
- package/dist/ui/TrustGate.d.ts +5 -0
- package/dist/ui/TrustGate.js +26 -0
- package/package.json +96 -4
- package/src/agent/guards.ts +74 -0
- package/src/agent/limits.ts +71 -0
- package/src/agent/protocol.ts +126 -0
- package/src/agent/tools.ts +377 -0
- package/src/app.ts +476 -0
- package/src/cli.ts +308 -0
- package/src/commands/about.ts +16 -0
- package/src/commands/agent.ts +18 -0
- package/src/commands/clear.ts +9 -0
- package/src/commands/config.ts +24 -0
- package/src/commands/cost.ts +13 -0
- package/src/commands/cwd.ts +7 -0
- package/src/commands/exit.ts +8 -0
- package/src/commands/help.ts +18 -0
- package/src/commands/index.ts +58 -0
- package/src/commands/login.ts +61 -0
- package/src/commands/logout.ts +53 -0
- package/src/commands/model.ts +30 -0
- package/src/commands/plugin.ts +74 -0
- package/src/commands/provider.ts +28 -0
- package/src/commands/reset.ts +13 -0
- package/src/commands/skills.ts +106 -0
- package/src/commands/system.ts +20 -0
- package/src/commands/theme.ts +22 -0
- package/src/commands/tools.ts +29 -0
- package/src/commands/whoami.ts +51 -0
- package/src/config/paths.ts +59 -0
- package/src/config/store.ts +240 -0
- package/src/config/themes.ts +77 -0
- package/src/config/trust.ts +67 -0
- package/src/index.ts +31 -0
- package/src/lib/diagnostics.ts +139 -0
- package/src/lib/http.ts +70 -0
- package/src/lib/markdown.ts +32 -0
- package/src/lib/pkg.ts +35 -0
- package/src/lib/platform.ts +57 -0
- package/src/lib/startup.ts +25 -0
- package/src/lib/uiMode.ts +7 -0
- package/src/plugins/manager.ts +101 -0
- package/src/providers/anthropic.ts +107 -0
- package/src/providers/custom.ts +25 -0
- package/src/providers/deepseek.ts +15 -0
- package/src/providers/google.ts +19 -0
- package/src/providers/groq.ts +20 -0
- package/src/providers/index.ts +37 -0
- package/src/providers/mistral.ts +19 -0
- package/src/providers/moonshot.ts +22 -0
- package/src/providers/ollama.ts +22 -0
- package/src/providers/openai.ts +25 -0
- package/src/providers/openaiCompatible.ts +110 -0
- package/src/providers/openrouter.ts +26 -0
- package/src/providers/qwen.ts +23 -0
- package/src/providers/xai.ts +18 -0
- package/src/providers/zhipu.ts +23 -0
- package/src/session/log.ts +135 -0
- package/src/skills/manager.ts +201 -0
- package/src/skills/parser.ts +65 -0
- package/src/types.ts +143 -0
- package/src/ui/Banner.ts +68 -0
- package/src/ui/Message.ts +13 -0
- package/src/ui/Prompt.ts +38 -0
- package/src/ui/Spinner.ts +33 -0
- package/src/ui/StatusBar.ts +19 -0
- package/src/ui/TrustGate.ts +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const TOOL_RE = /<fastcode_tool>([\s\S]*?)<\/fastcode_tool>/g;
|
|
2
|
+
// Some models (notably DeepSeek) emit their native "DSML" tool-call markup
|
|
3
|
+
// instead of <fastcode_tool>. The markers look like `<|DSML|invoke ...>`
|
|
4
|
+
// (U+FF5C fullwidth bar, sometimes doubled or spaced). We canonicalize those
|
|
5
|
+
// markers back to plain angle-bracket tags and then parse the
|
|
6
|
+
// calls -> invoke -> parameter structure as a second dialect.
|
|
7
|
+
const DSML_OPEN_RE = /<\s*(?:[||]\s*)+DSML\s*(?:[||]\s*)+/gi;
|
|
8
|
+
const DSML_CLOSE_RE = /<\/\s*(?:[||]\s*)+DSML\s*(?:[||]\s*)+/gi;
|
|
9
|
+
const DSML_BLOCK_RE = /<([a-z_]*calls?)>([\s\S]*?)<\/\1>/gi;
|
|
10
|
+
const DSML_INVOKE_RE = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/gi;
|
|
11
|
+
const DSML_PARAM_RE = /<parameter\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/parameter>/gi;
|
|
12
|
+
/** Turn the DSML glyph markers into ordinary `<tag>` / `</tag>` tags. */
|
|
13
|
+
function canonicalizeDsml(text) {
|
|
14
|
+
return text.replace(DSML_CLOSE_RE, "</").replace(DSML_OPEN_RE, "<");
|
|
15
|
+
}
|
|
16
|
+
function coerceDsmlValue(attrs, raw) {
|
|
17
|
+
const value = raw.replace(/^\n/, "").replace(/\n$/, "");
|
|
18
|
+
if (/\bstring\s*=\s*"true"/i.test(attrs))
|
|
19
|
+
return value;
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (trimmed === "")
|
|
22
|
+
return "";
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(trimmed);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
if (trimmed === "true")
|
|
28
|
+
return true;
|
|
29
|
+
if (trimmed === "false")
|
|
30
|
+
return false;
|
|
31
|
+
const n = Number(trimmed);
|
|
32
|
+
return Number.isNaN(n) ? value : n;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseDsmlCalls(text) {
|
|
36
|
+
const canonical = canonicalizeDsml(text);
|
|
37
|
+
const bodies = [];
|
|
38
|
+
for (const block of canonical.matchAll(DSML_BLOCK_RE))
|
|
39
|
+
bodies.push(block[2] ?? "");
|
|
40
|
+
if (bodies.length === 0)
|
|
41
|
+
bodies.push(canonical);
|
|
42
|
+
const calls = [];
|
|
43
|
+
for (const body of bodies) {
|
|
44
|
+
for (const invoke of body.matchAll(DSML_INVOKE_RE)) {
|
|
45
|
+
const call = { tool: invoke[1] };
|
|
46
|
+
for (const param of (invoke[2] ?? "").matchAll(DSML_PARAM_RE))
|
|
47
|
+
call[param[1]] = coerceDsmlValue(param[2] ?? "", param[3] ?? "");
|
|
48
|
+
calls.push(call);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return calls;
|
|
52
|
+
}
|
|
53
|
+
export function parseToolCalls(text) {
|
|
54
|
+
const calls = [];
|
|
55
|
+
for (const match of text.matchAll(TOOL_RE)) {
|
|
56
|
+
const raw = match[1]?.trim();
|
|
57
|
+
if (!raw)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (typeof parsed.tool === "string")
|
|
62
|
+
calls.push(parsed);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore malformed tool blocks; the model can correct itself next turn.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
calls.push(...parseDsmlCalls(text));
|
|
69
|
+
return calls;
|
|
70
|
+
}
|
|
71
|
+
export function stripToolCalls(text) {
|
|
72
|
+
return canonicalizeDsml(text).replace(TOOL_RE, "").replace(DSML_BLOCK_RE, "").trim();
|
|
73
|
+
}
|
|
74
|
+
export function formatToolResults(results) {
|
|
75
|
+
const blocks = results.map((r, index) => {
|
|
76
|
+
const status = r.ok ? "ok" : "error";
|
|
77
|
+
return [
|
|
78
|
+
`Tool result ${index + 1} (${status})`,
|
|
79
|
+
`call: ${JSON.stringify(r.call)}`,
|
|
80
|
+
"output:",
|
|
81
|
+
fence(r.output),
|
|
82
|
+
].join("\n");
|
|
83
|
+
});
|
|
84
|
+
return [
|
|
85
|
+
"FastCode executed the requested local tools. Use these results to continue.",
|
|
86
|
+
...blocks,
|
|
87
|
+
].join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
export const AGENT_SYSTEM_PROMPT = `
|
|
90
|
+
You are FastCode Agent, a local coding agent running inside the user's terminal.
|
|
91
|
+
Tools run inside a filesystem sandbox rooted at the current working directory.
|
|
92
|
+
File tools cannot access paths outside that root. Shell commands run with FASTCODE_SANDBOX=1 and FASTCODE_SANDBOX_ROOT set.
|
|
93
|
+
|
|
94
|
+
You can inspect and change the user's machine by emitting exactly one or more tool calls in this format:
|
|
95
|
+
<fastcode_tool>{"tool":"shell","command":"npm test"}</fastcode_tool>
|
|
96
|
+
|
|
97
|
+
Available tools:
|
|
98
|
+
- shell: {"tool":"shell","command":"command to run"}
|
|
99
|
+
- shell with timeout: {"tool":"shell","command":"npm test","timeoutMs":120000}
|
|
100
|
+
- pwd: {"tool":"pwd"}
|
|
101
|
+
- read_file: {"tool":"read_file","path":"path/to/file"}
|
|
102
|
+
- read_many: {"tool":"read_many","paths":["file1","file2"]}
|
|
103
|
+
- write_file: {"tool":"write_file","path":"path/to/file","content":"full file contents"}
|
|
104
|
+
- append_file: {"tool":"append_file","path":"path/to/file","content":"text to append"}
|
|
105
|
+
- apply_patch: {"tool":"apply_patch","patch":"*** Begin Patch\\n*** Update File: path\\n@@\\n-old\\n+new\\n*** End Patch\\n"}
|
|
106
|
+
- list_files: {"tool":"list_files","path":"optional/path","maxDepth":2}
|
|
107
|
+
- grep: {"tool":"grep","pattern":"text or regex","path":"optional/path"}
|
|
108
|
+
- replace_in_file: {"tool":"replace_in_file","path":"path/to/file","find":"exact text","replace":"replacement"}
|
|
109
|
+
- stat_path: {"tool":"stat_path","path":"path/to/file"}
|
|
110
|
+
- make_dir: {"tool":"make_dir","path":"path/to/dir"}
|
|
111
|
+
- copy_file: {"tool":"copy_file","from":"src","to":"dest"}
|
|
112
|
+
- move_path: {"tool":"move_path","from":"src","to":"dest"}
|
|
113
|
+
- delete_file: {"tool":"delete_file","path":"path/to/file"}
|
|
114
|
+
|
|
115
|
+
Rules:
|
|
116
|
+
- Prefer inspecting files before editing.
|
|
117
|
+
- Use shell for tests, builds, package scripts, git inspection, and OS commands.
|
|
118
|
+
- Use apply_patch or replace_in_file for focused edits.
|
|
119
|
+
- Use write_file only when replacing the whole file is appropriate.
|
|
120
|
+
- Run verification commands after code changes.
|
|
121
|
+
- After tool results come back, continue working until the user's request is done.
|
|
122
|
+
- When done, answer normally without tool calls.
|
|
123
|
+
`.trim();
|
|
124
|
+
function fence(text) {
|
|
125
|
+
return `\`\`\`\n${text}\n\`\`\``;
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runAgentTool(call: any, cwd?: string): Promise<unknown>;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendFile, copyFile, mkdir, readdir, readFile, realpath, rm, rename, stat, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { detectPlatform } from "../lib/platform.js";
|
|
6
|
+
import { evaluateShellCommand } from "./guards.js";
|
|
7
|
+
import { createOutputBuffer } from "./limits.js";
|
|
8
|
+
export async function runAgentTool(call, cwd = process.cwd()) {
|
|
9
|
+
const sandbox = resolve(cwd);
|
|
10
|
+
try {
|
|
11
|
+
switch (call.tool) {
|
|
12
|
+
case "shell":
|
|
13
|
+
return await runShell(requiredString(call, "command"), sandbox, numberOption(call.timeoutMs, 120000));
|
|
14
|
+
case "pwd":
|
|
15
|
+
return { ok: true, output: sandbox };
|
|
16
|
+
case "read_file":
|
|
17
|
+
return { ok: true, output: await readFile(resolvePath(sandbox, call), "utf8") };
|
|
18
|
+
case "read_many":
|
|
19
|
+
return { ok: true, output: await readMany(sandbox, stringArray(call, "paths")) };
|
|
20
|
+
case "write_file": {
|
|
21
|
+
const file = resolvePath(sandbox, call);
|
|
22
|
+
await mkdir(dirname(file), { recursive: true });
|
|
23
|
+
await writeFile(file, requiredString(call, "content"), "utf8");
|
|
24
|
+
return { ok: true, output: `wrote ${file}` };
|
|
25
|
+
}
|
|
26
|
+
case "append_file": {
|
|
27
|
+
const file = resolvePath(sandbox, call);
|
|
28
|
+
await mkdir(dirname(file), { recursive: true });
|
|
29
|
+
await appendFile(file, requiredString(call, "content"), "utf8");
|
|
30
|
+
return { ok: true, output: `appended ${file}` };
|
|
31
|
+
}
|
|
32
|
+
case "apply_patch":
|
|
33
|
+
return await applyPatch(sandbox, requiredString(call, "patch"));
|
|
34
|
+
case "copy_file": {
|
|
35
|
+
const from = sandboxPath(sandbox, requiredString(call, "from"));
|
|
36
|
+
const to = sandboxPath(sandbox, requiredString(call, "to"));
|
|
37
|
+
await mkdir(dirname(to), { recursive: true });
|
|
38
|
+
await copyFile(from, to);
|
|
39
|
+
return { ok: true, output: `copied ${from} to ${to}` };
|
|
40
|
+
}
|
|
41
|
+
case "delete_file": {
|
|
42
|
+
const file = resolvePath(sandbox, call);
|
|
43
|
+
await rm(file, { recursive: true, force: true });
|
|
44
|
+
return { ok: true, output: `deleted ${file}` };
|
|
45
|
+
}
|
|
46
|
+
case "list_files":
|
|
47
|
+
return { ok: true, output: await listFiles(sandbox, resolvePath(sandbox, call, "."), numberOption(call.maxDepth, 2)) };
|
|
48
|
+
case "make_dir": {
|
|
49
|
+
const dir = resolvePath(sandbox, call);
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
return { ok: true, output: `created ${dir}` };
|
|
52
|
+
}
|
|
53
|
+
case "move_path": {
|
|
54
|
+
const from = sandboxPath(sandbox, requiredString(call, "from"));
|
|
55
|
+
const to = sandboxPath(sandbox, requiredString(call, "to"));
|
|
56
|
+
await mkdir(dirname(to), { recursive: true });
|
|
57
|
+
await rename(from, to);
|
|
58
|
+
return { ok: true, output: `moved ${from} to ${to}` };
|
|
59
|
+
}
|
|
60
|
+
case "grep":
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
output: await grepFiles(sandbox, resolvePath(sandbox, call, "."), requiredString(call, "pattern"), numberOption(call.maxDepth, 4)),
|
|
64
|
+
};
|
|
65
|
+
case "replace_in_file": {
|
|
66
|
+
const file = resolvePath(sandbox, call);
|
|
67
|
+
const find = requiredString(call, "find");
|
|
68
|
+
const replacement = requiredString(call, "replace");
|
|
69
|
+
const source = await readFile(file, "utf8");
|
|
70
|
+
if (!source.includes(find))
|
|
71
|
+
return { ok: false, output: `text not found in ${file}` };
|
|
72
|
+
await writeFile(file, source.split(find).join(replacement), "utf8");
|
|
73
|
+
return { ok: true, output: `replaced text in ${file}` };
|
|
74
|
+
}
|
|
75
|
+
case "stat_path": {
|
|
76
|
+
const file = resolvePath(sandbox, call);
|
|
77
|
+
const info = await stat(file);
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
output: JSON.stringify({
|
|
81
|
+
path: file,
|
|
82
|
+
type: info.isDirectory() ? "dir" : info.isFile() ? "file" : "other",
|
|
83
|
+
size: info.size,
|
|
84
|
+
modified: info.mtime.toISOString(),
|
|
85
|
+
}, null, 2),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
default:
|
|
89
|
+
return { ok: false, output: `unknown tool: ${String(call.tool)}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
return { ok: false, output: err.message };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function runShell(command, cwd, timeoutMs) {
|
|
97
|
+
const verdict = evaluateShellCommand(command);
|
|
98
|
+
if (!verdict.allow)
|
|
99
|
+
return { ok: false, output: verdict.output };
|
|
100
|
+
return await new Promise((resolvePromise) => {
|
|
101
|
+
const child = spawn(command, {
|
|
102
|
+
cwd,
|
|
103
|
+
shell: true,
|
|
104
|
+
env: { ...process.env, FASTCODE_SANDBOX: "1", FASTCODE_SANDBOX_ROOT: cwd },
|
|
105
|
+
windowsHide: true,
|
|
106
|
+
});
|
|
107
|
+
const timeout = setTimeout(() => {
|
|
108
|
+
child.kill("SIGTERM");
|
|
109
|
+
resolvePromise({ ok: false, output: `command timed out after ${timeoutMs}ms\n${output.trimEnd()}` });
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
const output = createOutputBuffer(undefined, "tail");
|
|
112
|
+
const collect = (chunk) => {
|
|
113
|
+
output.push(chunk.toString("utf8"));
|
|
114
|
+
};
|
|
115
|
+
child.stdout.on("data", collect);
|
|
116
|
+
child.stderr.on("data", collect);
|
|
117
|
+
child.on("error", (err) => {
|
|
118
|
+
clearTimeout(timeout);
|
|
119
|
+
resolvePromise({ ok: false, output: err.message });
|
|
120
|
+
});
|
|
121
|
+
child.on("close", (code) => {
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
resolvePromise({
|
|
124
|
+
ok: code === 0,
|
|
125
|
+
output: output.trimEnd(),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function readMany(cwd, paths) {
|
|
131
|
+
const blocks = [];
|
|
132
|
+
for (const path of paths) {
|
|
133
|
+
const file = sandboxPath(cwd, path);
|
|
134
|
+
blocks.push(`### ${file}\n${await readFile(file, "utf8")}`);
|
|
135
|
+
}
|
|
136
|
+
return blocks.join("\n\n");
|
|
137
|
+
}
|
|
138
|
+
async function listFiles(sandbox, root, maxDepth) {
|
|
139
|
+
const out = createOutputBuffer();
|
|
140
|
+
await walkWithin(sandbox, root, maxDepth, async (file, info) => {
|
|
141
|
+
out.line((info.isDirectory() ? "dir " : "file") + " " + file);
|
|
142
|
+
return !out.full;
|
|
143
|
+
});
|
|
144
|
+
return out.value();
|
|
145
|
+
}
|
|
146
|
+
async function grepFiles(sandbox, root, pattern, maxDepth) {
|
|
147
|
+
const re = new RegExp(pattern, "i");
|
|
148
|
+
const out = createOutputBuffer();
|
|
149
|
+
await walkWithin(sandbox, root, maxDepth, async (file, info) => {
|
|
150
|
+
if (!info.isFile())
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
const text = await readFile(file, "utf8");
|
|
154
|
+
const lines = text.split(/\r?\n/);
|
|
155
|
+
lines.forEach((line, index) => {
|
|
156
|
+
if (re.test(line))
|
|
157
|
+
out.line(file + ":" + (index + 1) + ": " + line);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Binary or unreadable files are skipped.
|
|
162
|
+
}
|
|
163
|
+
return !out.full;
|
|
164
|
+
});
|
|
165
|
+
return out.value();
|
|
166
|
+
}
|
|
167
|
+
/** Start a walk bounded to the sandbox plus every allowed extra root. */
|
|
168
|
+
async function walkWithin(sandbox, root, maxDepth, visit) {
|
|
169
|
+
const roots = allowedRoots(sandbox).map((entry) => realpathOf(entry)).filter(Boolean);
|
|
170
|
+
await walk(root, maxDepth, visit, 0, { roots, visited: 0, maxVisited: 5000 }, []);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Tolerant recursive walk.
|
|
174
|
+
*
|
|
175
|
+
* stat() follows symlinks, so a dangling link (common on Termux: after
|
|
176
|
+
* termux-setup-storage the ~/storage/* links break once the phone-side folder
|
|
177
|
+
* is deleted) throws ENOENT. One bad entry must never abort the whole walk, so
|
|
178
|
+
* every stat/readdir/visit is guarded individually.
|
|
179
|
+
*
|
|
180
|
+
* Valid symlinks are followed. Recursion stops only where the real target is
|
|
181
|
+
* outside every allowed root: the sandbox, FASTCODE_ALLOW_PATHS, and on Termux
|
|
182
|
+
* ~/storage/shared (allowed by default). A per-branch chain of real paths breaks
|
|
183
|
+
* symlink cycles (loop -> ancestor) without hiding legitimate sibling symlinks.
|
|
184
|
+
*/
|
|
185
|
+
async function walk(node, maxDepth, visit, depth = 0, state, chain = []) {
|
|
186
|
+
if (state.visited >= state.maxVisited)
|
|
187
|
+
return;
|
|
188
|
+
let info;
|
|
189
|
+
try {
|
|
190
|
+
info = await stat(node);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return; // dangling symlink or unreadable entry: skip it, keep going
|
|
194
|
+
}
|
|
195
|
+
let real;
|
|
196
|
+
try {
|
|
197
|
+
real = await realpath(node);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
real = node;
|
|
201
|
+
}
|
|
202
|
+
if (chain.includes(real))
|
|
203
|
+
return;
|
|
204
|
+
state.visited += 1;
|
|
205
|
+
const insideSandbox = !state.roots || state.roots.length === 0 || isInsideAny(real, state.roots);
|
|
206
|
+
let keepGoing = true;
|
|
207
|
+
try {
|
|
208
|
+
keepGoing = (await visit(node, info)) !== false;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// A visitor failure must not abort the surrounding walk.
|
|
212
|
+
}
|
|
213
|
+
if (!keepGoing)
|
|
214
|
+
return false;
|
|
215
|
+
if (!info.isDirectory() || depth >= maxDepth || !insideSandbox)
|
|
216
|
+
return;
|
|
217
|
+
let entries;
|
|
218
|
+
try {
|
|
219
|
+
entries = await readdir(node, { withFileTypes: true });
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const nextChain = chain.concat(real);
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist")
|
|
227
|
+
continue;
|
|
228
|
+
if (state.visited >= state.maxVisited)
|
|
229
|
+
return;
|
|
230
|
+
try {
|
|
231
|
+
const keepWalking = await walk(join(node, entry.name), maxDepth, visit, depth + 1, state, nextChain);
|
|
232
|
+
if (keepWalking === false)
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// Keep walking siblings.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Roots the agent may touch: the sandbox (cwd) plus configured extras.
|
|
242
|
+
*
|
|
243
|
+
* Termux users keep projects and downloads in shared storage, so on Termux
|
|
244
|
+
* ~/storage/shared (-> /storage/emulated/0) is allowed by default.
|
|
245
|
+
*
|
|
246
|
+
* FASTCODE_ALLOW_PATHS=/sdcard/projects:/sdcard/Download extra roots
|
|
247
|
+
* FASTCODE_ALLOW_SHARED_STORAGE=0 disable the default
|
|
248
|
+
*/
|
|
249
|
+
function allowedRoots(cwd) {
|
|
250
|
+
const roots = [resolve(cwd)];
|
|
251
|
+
const configured = (process.env["FASTCODE_ALLOW_PATHS"] ?? "")
|
|
252
|
+
.split(delimiter)
|
|
253
|
+
.map((entry) => entry.trim())
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
for (const entry of configured)
|
|
256
|
+
roots.push(resolve(entry));
|
|
257
|
+
if (detectPlatform() === "termux" && !isDisabled(process.env["FASTCODE_ALLOW_SHARED_STORAGE"])) {
|
|
258
|
+
const home = process.env["HOME"];
|
|
259
|
+
if (home) {
|
|
260
|
+
const shared = join(home, "storage", "shared");
|
|
261
|
+
if (existsSync(shared))
|
|
262
|
+
roots.push(shared);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return roots;
|
|
266
|
+
}
|
|
267
|
+
function isDisabled(value) {
|
|
268
|
+
if (value === undefined)
|
|
269
|
+
return false;
|
|
270
|
+
return ["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
271
|
+
}
|
|
272
|
+
function isInsideAny(child, roots) {
|
|
273
|
+
return roots.some((root) => {
|
|
274
|
+
const rel = relative(root, child);
|
|
275
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function resolvePath(cwd, call, fallback = "") {
|
|
279
|
+
const raw = typeof call.path === "string" ? call.path : fallback;
|
|
280
|
+
return sandboxPath(cwd, raw);
|
|
281
|
+
}
|
|
282
|
+
function sandboxPath(cwd, raw) {
|
|
283
|
+
const target = resolve(cwd, raw);
|
|
284
|
+
const roots = allowedRoots(cwd);
|
|
285
|
+
// Real-path containment is authoritative: it is the only check that sees
|
|
286
|
+
// through symlinks, so a link planted inside the sandbox cannot escape.
|
|
287
|
+
const realRoots = roots.map((root) => realpathOf(root)).filter(Boolean);
|
|
288
|
+
const realTarget = realpathOf(target);
|
|
289
|
+
if (realTarget) {
|
|
290
|
+
if (isInsideAny(realTarget, realRoots))
|
|
291
|
+
return target;
|
|
292
|
+
throw new Error("path outside sandbox: " + raw);
|
|
293
|
+
}
|
|
294
|
+
// realpathOf() returns undefined only when nothing on the chain exists yet,
|
|
295
|
+
// so a lexical check against the configured roots is safe here.
|
|
296
|
+
if (isInsideAny(target, roots))
|
|
297
|
+
return target;
|
|
298
|
+
throw new Error("path outside sandbox: " + raw);
|
|
299
|
+
}
|
|
300
|
+
/** Resolve a path that may not exist yet by realpath-ing its deepest existing ancestor. */
|
|
301
|
+
function realpathOf(input) {
|
|
302
|
+
let current = resolve(input);
|
|
303
|
+
const missing = [];
|
|
304
|
+
for (;;) {
|
|
305
|
+
if (existsSync(current)) {
|
|
306
|
+
try {
|
|
307
|
+
return join(realpathSync(current), ...missing.slice().reverse());
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const parent = dirname(current);
|
|
314
|
+
if (parent === current)
|
|
315
|
+
return undefined;
|
|
316
|
+
missing.push(basename(current));
|
|
317
|
+
current = parent;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function requiredString(call, key) {
|
|
321
|
+
const value = call[key];
|
|
322
|
+
if (typeof value !== "string")
|
|
323
|
+
throw new Error(`${key} must be a string`);
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
function stringArray(call, key) {
|
|
327
|
+
const value = call[key];
|
|
328
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
329
|
+
throw new Error(`${key} must be an array of strings`);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function numberOption(value, fallback) {
|
|
334
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
335
|
+
}
|
|
336
|
+
async function applyPatch(cwd, patch) {
|
|
337
|
+
const lines = patch.split(/\r?\n/);
|
|
338
|
+
if (lines[0] !== "*** Begin Patch")
|
|
339
|
+
return { ok: false, output: "patch must start with *** Begin Patch" };
|
|
340
|
+
let i = 1;
|
|
341
|
+
let changed = 0;
|
|
342
|
+
while (i < lines.length) {
|
|
343
|
+
const line = lines[i];
|
|
344
|
+
if (line === "*** End Patch" || line === "")
|
|
345
|
+
break;
|
|
346
|
+
if (!line?.startsWith("*** Update File: ")) {
|
|
347
|
+
return { ok: false, output: `unsupported patch directive: ${line ?? ""}` };
|
|
348
|
+
}
|
|
349
|
+
const relative = line.slice("*** Update File: ".length).trim();
|
|
350
|
+
const file = resolve(cwd, relative);
|
|
351
|
+
let source = await readFile(file, "utf8");
|
|
352
|
+
i += 1;
|
|
353
|
+
while (i < lines.length && !lines[i]?.startsWith("*** ")) {
|
|
354
|
+
if (lines[i] === "@@")
|
|
355
|
+
i += 1;
|
|
356
|
+
const remove = [];
|
|
357
|
+
const add = [];
|
|
358
|
+
while (i < lines.length && lines[i] !== "@@" && !lines[i]?.startsWith("*** ")) {
|
|
359
|
+
const hunkLine = lines[i] ?? "";
|
|
360
|
+
if (hunkLine.startsWith("-"))
|
|
361
|
+
remove.push(hunkLine.slice(1));
|
|
362
|
+
if (hunkLine.startsWith("+"))
|
|
363
|
+
add.push(hunkLine.slice(1));
|
|
364
|
+
i += 1;
|
|
365
|
+
}
|
|
366
|
+
const needle = `${remove.join("\n")}${remove.length > 0 ? "\n" : ""}`;
|
|
367
|
+
const replacement = `${add.join("\n")}${add.length > 0 ? "\n" : ""}`;
|
|
368
|
+
if (needle && !source.includes(needle)) {
|
|
369
|
+
return { ok: false, output: `patch context not found in ${file}: ${JSON.stringify(needle)}` };
|
|
370
|
+
}
|
|
371
|
+
source = needle ? source.replace(needle, replacement) : source + replacement;
|
|
372
|
+
changed += 1;
|
|
373
|
+
}
|
|
374
|
+
await writeFile(file, source, "utf8");
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, output: `applied ${changed} patch hunk(s)` };
|
|
377
|
+
}
|
package/dist/app.d.ts
ADDED