@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.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.
- package/LICENSE +4 -0
- package/README.md +50 -17
- package/dist/agent/approval-json.d.ts +43 -0
- package/dist/agent/approval-json.js +69 -0
- package/dist/agent/approval-native.d.ts +3 -0
- package/dist/agent/approval-native.js +165 -0
- package/dist/agent/approval-store.d.ts +16 -0
- package/dist/agent/approval-store.js +21 -0
- package/dist/agent/approval.d.ts +33 -0
- package/dist/agent/approval.js +40 -5
- package/dist/agent/compaction.d.ts +2 -0
- package/dist/agent/compaction.js +60 -17
- package/dist/agent/guards.d.ts +16 -16
- package/dist/agent/guards.js +978 -24
- package/dist/agent/policy/defaults.d.ts +16 -0
- package/dist/agent/policy/defaults.js +17 -0
- package/dist/agent/policy/egress.d.ts +32 -0
- package/dist/agent/policy/egress.js +280 -0
- package/dist/agent/process-tree.d.ts +20 -0
- package/dist/agent/process-tree.js +81 -10
- package/dist/agent/protocol.d.ts +20 -1
- package/dist/agent/protocol.js +165 -38
- package/dist/agent/registry.d.ts +3 -1
- package/dist/agent/registry.js +168 -11
- package/dist/agent/runner.d.ts +21 -0
- package/dist/agent/runner.js +44 -5
- package/dist/agent/shell-ast.d.ts +11 -0
- package/dist/agent/shell-ast.js +204 -0
- package/dist/agent/shell-session.d.ts +14 -0
- package/dist/agent/shell-session.js +251 -40
- package/dist/agent/tool-schemas.js +26 -7
- package/dist/agent/tools/append_file.js +3 -1
- package/dist/agent/tools/apply_patch.js +44 -4
- package/dist/agent/tools/copy_file.js +26 -3
- package/dist/agent/tools/delete_file.js +22 -3
- package/dist/agent/tools/grep.js +35 -4
- package/dist/agent/tools/make_dir.js +3 -1
- package/dist/agent/tools/move_path.d.ts +7 -0
- package/dist/agent/tools/move_path.js +31 -5
- package/dist/agent/tools/read_file.js +63 -3
- package/dist/agent/tools/read_many.js +21 -3
- package/dist/agent/tools/readers.d.ts +53 -1
- package/dist/agent/tools/readers.js +236 -26
- package/dist/agent/tools/replace_in_file.js +38 -5
- package/dist/agent/tools/shell.js +2 -2
- package/dist/agent/tools/types.d.ts +26 -0
- package/dist/agent/tools/validate.d.ts +15 -2
- package/dist/agent/tools/validate.js +66 -3
- package/dist/agent/tools/write_file.js +3 -1
- package/dist/app.d.ts +2 -1
- package/dist/app.js +526 -85
- package/dist/cli-main.js +363 -0
- package/dist/cli.js +397 -337
- package/dist/commands/about.js +1 -1
- package/dist/commands/clear.d.ts +1 -0
- package/dist/commands/clear.js +1 -1
- package/dist/commands/compact.d.ts +12 -0
- package/dist/commands/compact.js +14 -2
- package/dist/commands/context.js +5 -4
- package/dist/commands/index.js +2 -0
- package/dist/commands/jobs.js +3 -2
- package/dist/commands/kill.js +3 -1
- package/dist/commands/login.d.ts +2 -2
- package/dist/commands/login.js +15 -15
- package/dist/commands/model.d.ts +1 -13
- package/dist/commands/model.js +6 -38
- package/dist/commands/provider.d.ts +1 -15
- package/dist/commands/provider.js +4 -22
- package/dist/commands/skills.js +34 -4
- package/dist/commands/threads.d.ts +9 -0
- package/dist/commands/threads.js +26 -0
- package/dist/config/store.d.ts +21 -0
- package/dist/config/store.js +47 -9
- package/dist/config/trust.d.ts +9 -0
- package/dist/config/trust.js +41 -2
- package/dist/headless.d.ts +7 -0
- package/dist/headless.js +213 -0
- package/dist/index.js +19 -3
- package/dist/lib/debug.d.ts +2 -0
- package/dist/lib/debug.js +22 -0
- package/dist/lib/diagnostics.d.ts +19 -0
- package/dist/lib/diagnostics.js +32 -6
- package/dist/lib/highlight-subset.d.ts +21 -0
- package/dist/lib/highlight-subset.js +88 -0
- package/dist/lib/lifecycle.d.ts +5 -0
- package/dist/lib/lifecycle.js +62 -0
- package/dist/lib/markdown.js +22 -4
- package/dist/providers/deepseek.js +1 -1
- package/dist/providers/openaiCompatible.d.ts +24 -2
- package/dist/providers/openaiCompatible.js +20 -6
- package/dist/providers/spec.d.ts +12 -0
- package/dist/providers/spec.js +29 -0
- package/dist/session/log.d.ts +14 -2
- package/dist/session/log.js +56 -27
- package/dist/session/projection.d.ts +34 -1
- package/dist/session/projection.js +315 -46
- package/dist/skills/manager.d.ts +15 -0
- package/dist/skills/manager.js +135 -18
- package/dist/types.d.ts +15 -0
- package/dist/ui/StatusBar.js +1 -1
- package/package.json +3 -2
- package/src/agent/approval-json.ts +103 -0
- package/src/agent/approval-native.ts +168 -0
- package/src/agent/approval-store.ts +38 -0
- package/src/agent/approval.ts +58 -11
- package/src/agent/compaction.ts +62 -17
- package/src/agent/guards.ts +1049 -25
- package/src/agent/policy/defaults.ts +34 -0
- package/src/agent/policy/egress.ts +335 -0
- package/src/agent/process-tree.ts +105 -23
- package/src/agent/protocol.ts +187 -39
- package/src/agent/registry.ts +186 -14
- package/src/agent/runner.ts +50 -7
- package/src/agent/shell-ast.ts +227 -0
- package/src/agent/shell-session.ts +265 -41
- package/src/agent/tool-schemas.ts +26 -7
- package/src/agent/tools/append_file.ts +3 -1
- package/src/agent/tools/apply_patch.ts +45 -4
- package/src/agent/tools/copy_file.ts +27 -3
- package/src/agent/tools/delete_file.ts +22 -3
- package/src/agent/tools/grep.ts +36 -4
- package/src/agent/tools/make_dir.ts +3 -1
- package/src/agent/tools/move_path.ts +38 -5
- package/src/agent/tools/read_file.ts +64 -3
- package/src/agent/tools/read_many.ts +21 -3
- package/src/agent/tools/readers.ts +289 -26
- package/src/agent/tools/replace_in_file.ts +39 -5
- package/src/agent/tools/shell.ts +2 -2
- package/src/agent/tools/types.ts +43 -1
- package/src/agent/tools/validate.ts +73 -4
- package/src/agent/tools/write_file.ts +3 -1
- package/src/app.ts +533 -82
- package/src/{cli.ts → cli-main.ts} +34 -6
- package/src/commands/about.ts +1 -1
- package/src/commands/clear.ts +1 -1
- package/src/commands/compact.ts +14 -2
- package/src/commands/context.ts +5 -4
- package/src/commands/index.ts +2 -0
- package/src/commands/jobs.ts +3 -2
- package/src/commands/kill.ts +3 -1
- package/src/commands/login.ts +15 -15
- package/src/commands/model.ts +6 -38
- package/src/commands/provider.ts +4 -22
- package/src/commands/skills.ts +34 -4
- package/src/commands/threads.ts +28 -0
- package/src/config/store.ts +47 -9
- package/src/config/trust.ts +45 -2
- package/src/headless.ts +225 -0
- package/src/index.ts +19 -3
- package/src/lib/debug.ts +24 -0
- package/src/lib/diagnostics.ts +32 -6
- package/src/lib/highlight-subset.ts +93 -0
- package/src/lib/lifecycle.ts +69 -0
- package/src/lib/markdown.ts +22 -4
- package/src/providers/deepseek.ts +1 -1
- package/src/providers/openaiCompatible.ts +47 -10
- package/src/providers/spec.ts +42 -0
- package/src/session/log.ts +70 -27
- package/src/session/projection.ts +360 -47
- package/src/skills/manager.ts +140 -18
- package/src/types/highlight-shims.d.ts +14 -0
- package/src/types.ts +16 -1
- package/src/ui/StatusBar.ts +1 -1
- package/tsconfig.json +1 -0
- /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limited bash AST second opinion for Guardrails.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately not a replacement for the shape matcher. It only
|
|
5
|
+
* extracts the three entry classes we can safely re-feed into the existing
|
|
6
|
+
* classifier:
|
|
7
|
+
*
|
|
8
|
+
* 1. shell `-c` literal strings
|
|
9
|
+
* 2. `find ... -exec ... ;/+`
|
|
10
|
+
* 3. eval / source / `.` literal command strings
|
|
11
|
+
*
|
|
12
|
+
* plus one same-scope assignment (`X=rm; $X -rf /`). It only reports Hard
|
|
13
|
+
* Block upgrades; parse failures return no findings so the shape matcher's
|
|
14
|
+
* result is never downgraded.
|
|
15
|
+
*/
|
|
16
|
+
import parse from "bash-parser";
|
|
17
|
+
const MAX_DEPTH = 4;
|
|
18
|
+
export function analyzeShellAst(command) {
|
|
19
|
+
return analyze(command, 0);
|
|
20
|
+
}
|
|
21
|
+
function analyze(command, depth) {
|
|
22
|
+
if (depth > MAX_DEPTH)
|
|
23
|
+
return { derived: [] };
|
|
24
|
+
let ast;
|
|
25
|
+
try {
|
|
26
|
+
ast = parse(command, { mode: "bash" });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { derived: [] };
|
|
30
|
+
}
|
|
31
|
+
const derived = [];
|
|
32
|
+
let hard;
|
|
33
|
+
const scope = new Map();
|
|
34
|
+
const addDerived = (value) => {
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
if (trimmed && !derived.includes(trimmed))
|
|
37
|
+
derived.push(trimmed);
|
|
38
|
+
};
|
|
39
|
+
const addHard = (finding) => {
|
|
40
|
+
if (!hard)
|
|
41
|
+
hard = finding;
|
|
42
|
+
};
|
|
43
|
+
const wordText = (word) => typeof word?.text === "string" ? word.text : "";
|
|
44
|
+
const wordIsDynamic = (word) => {
|
|
45
|
+
if (Array.isArray(word?.expansion) && word.expansion.length > 0)
|
|
46
|
+
return true;
|
|
47
|
+
const text = wordText(word);
|
|
48
|
+
return /\$|`/.test(text);
|
|
49
|
+
};
|
|
50
|
+
const words = (value) => Array.isArray(value) ? value : [];
|
|
51
|
+
const walkWord = (word) => {
|
|
52
|
+
for (const expansion of words(word?.expansion)) {
|
|
53
|
+
if (expansion?.type === "CommandExpansion") {
|
|
54
|
+
if (typeof expansion.command === "string")
|
|
55
|
+
addDerived(expansion.command);
|
|
56
|
+
if (expansion.commandAST)
|
|
57
|
+
walkNode(expansion.commandAST, scope, depth + 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const walkList = (commands, assignments, level) => {
|
|
62
|
+
for (const node of commands)
|
|
63
|
+
walkNode(node, assignments, level);
|
|
64
|
+
};
|
|
65
|
+
const handleCommand = (node, assignments) => {
|
|
66
|
+
const prefix = words(node?.prefix);
|
|
67
|
+
for (const assignment of prefix) {
|
|
68
|
+
const text = wordText(assignment);
|
|
69
|
+
const eq = text.indexOf("=");
|
|
70
|
+
if (eq <= 0)
|
|
71
|
+
continue;
|
|
72
|
+
const name = text.slice(0, eq);
|
|
73
|
+
const value = text.slice(eq + 1);
|
|
74
|
+
// One same-scope assignment, literal values only. Nested command
|
|
75
|
+
// substitution / variable values are intentionally not tracked.
|
|
76
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && value && !/[$`]/.test(value))
|
|
77
|
+
assignments.set(name, value);
|
|
78
|
+
}
|
|
79
|
+
const nameWord = node?.name;
|
|
80
|
+
const name = wordText(nameWord);
|
|
81
|
+
const suffix = words(node?.suffix);
|
|
82
|
+
const parameter = words(nameWord?.expansion).find((entry) => entry?.type === "ParameterExpansion")?.parameter;
|
|
83
|
+
if (parameter && assignments.has(parameter) && (name === `$${parameter}` || name === `\${${parameter}}`)) {
|
|
84
|
+
const suffixText = suffix.map(wordText);
|
|
85
|
+
// bash-parser strips double quotes; a suffix that looks like a
|
|
86
|
+
// redirect/operator may be a quoted literal argument. Do not turn
|
|
87
|
+
// it into a false Hard Block.
|
|
88
|
+
if (suffixText.every((text) => !/[<>|;&]/.test(text))) {
|
|
89
|
+
const resolved = `${assignments.get(parameter)} ${suffixText.join(" ")}`.trim();
|
|
90
|
+
if (resolved)
|
|
91
|
+
addDerived(resolved);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (name === "eval" || name === "source" || name === ".") {
|
|
95
|
+
if (suffix.length === 0)
|
|
96
|
+
return;
|
|
97
|
+
if (suffix.some(wordIsDynamic)) {
|
|
98
|
+
addHard({
|
|
99
|
+
code: "dynamic_execution",
|
|
100
|
+
target: name,
|
|
101
|
+
reason: "non-literal-eval-source",
|
|
102
|
+
alternatives: [
|
|
103
|
+
"Use a literal, reviewed command string; dynamic eval/source cannot be audited.",
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
addDerived(suffix.map(wordText).join(" "));
|
|
109
|
+
}
|
|
110
|
+
if (SHELL_COMMAND_NAMES.has(name)) {
|
|
111
|
+
const cIndex = suffix.findIndex((word) => /^-[a-z]*c$/i.test(wordText(word)));
|
|
112
|
+
if (cIndex >= 0 && suffix[cIndex + 1]) {
|
|
113
|
+
const script = suffix[cIndex + 1];
|
|
114
|
+
if (wordIsDynamic(script)) {
|
|
115
|
+
addHard({
|
|
116
|
+
code: "dynamic_execution",
|
|
117
|
+
target: `${name} -c`,
|
|
118
|
+
reason: "non-literal-shell-command",
|
|
119
|
+
alternatives: [
|
|
120
|
+
"Pass a literal, reviewed script string instead of a variable/command substitution.",
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
addDerived(wordText(script));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (name === "env") {
|
|
129
|
+
const start = suffix.findIndex((word) => {
|
|
130
|
+
const value = wordText(word);
|
|
131
|
+
return value && !value.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(value);
|
|
132
|
+
});
|
|
133
|
+
if (start >= 0)
|
|
134
|
+
addDerived(suffix.slice(start).map(wordText).join(" "));
|
|
135
|
+
}
|
|
136
|
+
if (name === "find") {
|
|
137
|
+
const execIndex = suffix.findIndex((word) => {
|
|
138
|
+
const value = wordText(word);
|
|
139
|
+
return value === "-exec" || value === "-execdir";
|
|
140
|
+
});
|
|
141
|
+
if (execIndex >= 0) {
|
|
142
|
+
const inner = [];
|
|
143
|
+
for (const word of suffix.slice(execIndex + 1)) {
|
|
144
|
+
const value = wordText(word);
|
|
145
|
+
if (value === ";" || value === "+")
|
|
146
|
+
break;
|
|
147
|
+
inner.push(value);
|
|
148
|
+
}
|
|
149
|
+
const executable = (inner[0] ?? "").replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
|
|
150
|
+
if (inner.length > 0 && INSPECT_EXECUTABLES.has(executable))
|
|
151
|
+
addDerived(inner.join(" "));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const word of words(node?.prefix))
|
|
155
|
+
walkWord(word);
|
|
156
|
+
for (const word of words(node?.suffix))
|
|
157
|
+
walkWord(word);
|
|
158
|
+
};
|
|
159
|
+
const walkNode = (node, assignments, level) => {
|
|
160
|
+
if (!node || typeof node !== "object" || level > MAX_DEPTH)
|
|
161
|
+
return;
|
|
162
|
+
if (Array.isArray(node)) {
|
|
163
|
+
for (const item of node)
|
|
164
|
+
walkNode(item, assignments, level);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
switch (node.type) {
|
|
168
|
+
case "Command":
|
|
169
|
+
handleCommand(node, assignments);
|
|
170
|
+
return;
|
|
171
|
+
case "Script":
|
|
172
|
+
case "CompoundList":
|
|
173
|
+
walkList(words(node.commands), new Map(assignments), level);
|
|
174
|
+
return;
|
|
175
|
+
case "Pipeline":
|
|
176
|
+
walkList(words(node.commands), assignments, level);
|
|
177
|
+
return;
|
|
178
|
+
case "LogicalExpression":
|
|
179
|
+
walkNode(node.left, assignments, level);
|
|
180
|
+
walkNode(node.right, assignments, level);
|
|
181
|
+
return;
|
|
182
|
+
case "Subshell":
|
|
183
|
+
walkNode(node.list, new Map(assignments), level + 1);
|
|
184
|
+
return;
|
|
185
|
+
case "CommandExpansion":
|
|
186
|
+
walkNode(node.commandAST, assignments, level + 1);
|
|
187
|
+
return;
|
|
188
|
+
default:
|
|
189
|
+
for (const value of Object.values(node))
|
|
190
|
+
walkNode(value, assignments, level);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
walkNode(ast, scope, depth);
|
|
194
|
+
return hard ? { derived, hard } : { derived };
|
|
195
|
+
}
|
|
196
|
+
const SHELL_COMMAND_NAMES = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish"]);
|
|
197
|
+
const INSPECT_EXECUTABLES = new Set([
|
|
198
|
+
"sh", "bash", "zsh", "dash", "ksh", "fish", "eval", "source", ".",
|
|
199
|
+
"rm", "rmdir", "rd", "del", "erase", "unlink", "shred", "truncate",
|
|
200
|
+
"git", "dd", "mkfs", "mke2fs", "fdisk", "diskpart", "format",
|
|
201
|
+
"docker", "kubectl", "chmod", "chown", "chgrp", "shutdown", "reboot",
|
|
202
|
+
"poweroff", "halt", "systemctl", "find", "curl", "wget", "base64",
|
|
203
|
+
"nc", "ncat", "netcat", "socat", "mv", "cp", "install", "tar", "rsync",
|
|
204
|
+
]);
|
|
@@ -16,6 +16,9 @@ export interface ShellJobView {
|
|
|
16
16
|
bytes: number;
|
|
17
17
|
startedAt: number;
|
|
18
18
|
pid?: number;
|
|
19
|
+
/** Lifecycle visibility for /jobs and regression tests. */
|
|
20
|
+
timeoutArmed?: boolean;
|
|
21
|
+
foregroundAttached?: boolean;
|
|
19
22
|
}
|
|
20
23
|
interface ExecOptions {
|
|
21
24
|
signal?: AbortSignal;
|
|
@@ -23,15 +26,26 @@ interface ExecOptions {
|
|
|
23
26
|
timeoutMs?: number;
|
|
24
27
|
backgroundAfterMs?: number;
|
|
25
28
|
}
|
|
29
|
+
export declare const DEFAULT_SHELL_POOL_SIZE = 2;
|
|
30
|
+
export declare const MAX_SHELL_POOL_SIZE = 6;
|
|
26
31
|
export declare function executeShellCommand(command: string, cwd: string, options?: ExecOptions): Promise<ShellExecResult>;
|
|
27
32
|
export declare function pollShellSession(id: string, waitMs?: number): Promise<ShellExecResult | undefined>;
|
|
28
33
|
export declare function killShellSession(id: string): boolean;
|
|
29
34
|
export declare function listShellSessions(): ShellJobView[];
|
|
30
35
|
export declare function shutdownShells(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Exit-safe stop: send SIGTERM, wait briefly for close, then force SIGKILL.
|
|
38
|
+
* Use this when the process is about to exit and must not leave children.
|
|
39
|
+
*/
|
|
40
|
+
export declare function shutdownShellsAsync(graceMs?: number): Promise<void>;
|
|
41
|
+
export declare function shellPoolSize(): number;
|
|
42
|
+
/** Clamp and apply the resident shell concurrency (1..6, default 2). */
|
|
43
|
+
export declare function setShellPoolSize(size: number): number;
|
|
31
44
|
export declare function shellPoolStats(): {
|
|
32
45
|
spawns: number;
|
|
33
46
|
active: number;
|
|
34
47
|
pool: number;
|
|
48
|
+
maxPool: number;
|
|
35
49
|
maxSpawnPerTurn: number;
|
|
36
50
|
};
|
|
37
51
|
export {};
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import
|
|
2
|
+
import parse from "bash-parser";
|
|
3
|
+
import { terminateProcessTree } from "./process-tree.js";
|
|
4
|
+
import { normalizeTimeoutMs } from "./tools/validate.js";
|
|
3
5
|
const OUTPUT_LIMIT = 1024 * 1024;
|
|
4
6
|
const DEFAULT_BACKGROUND_MS = 2000;
|
|
5
7
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
6
|
-
const
|
|
8
|
+
export const DEFAULT_SHELL_POOL_SIZE = 2;
|
|
9
|
+
export const MAX_SHELL_POOL_SIZE = 6;
|
|
10
|
+
let poolLimit = DEFAULT_SHELL_POOL_SIZE;
|
|
7
11
|
const jobs = new Map();
|
|
8
12
|
const pool = [];
|
|
9
13
|
let spawns = 0;
|
|
@@ -25,6 +29,10 @@ class ShellJob {
|
|
|
25
29
|
donePromise;
|
|
26
30
|
resolveDone;
|
|
27
31
|
waiters = [];
|
|
32
|
+
timeoutTimer;
|
|
33
|
+
abortSignal;
|
|
34
|
+
abortListener;
|
|
35
|
+
cleaned = false;
|
|
28
36
|
constructor(command, cwd) {
|
|
29
37
|
this.id = "sh_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
|
|
30
38
|
this.key = this.id.replace(/[^a-z0-9]/gi, "");
|
|
@@ -58,9 +66,45 @@ class ShellJob {
|
|
|
58
66
|
return;
|
|
59
67
|
this.status = status;
|
|
60
68
|
this.exitCode = exitCode;
|
|
69
|
+
this.cleanupLifecycle();
|
|
61
70
|
this.resolveDone();
|
|
62
71
|
this.notify();
|
|
63
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Task-level timeout lives until the job actually finishes. Returning to
|
|
75
|
+
* the foreground only detaches the AbortSignal; it must not disarm this.
|
|
76
|
+
*/
|
|
77
|
+
armTimeout(ms) {
|
|
78
|
+
if (this.timeoutTimer || this.status !== "running")
|
|
79
|
+
return;
|
|
80
|
+
this.timeoutTimer = setTimeout(() => this.kill("timeout"), ms);
|
|
81
|
+
this.timeoutTimer.unref?.();
|
|
82
|
+
}
|
|
83
|
+
attachAbort(signal) {
|
|
84
|
+
if (!signal || this.abortListener)
|
|
85
|
+
return;
|
|
86
|
+
const listener = () => this.kill("killed");
|
|
87
|
+
this.abortSignal = signal;
|
|
88
|
+
this.abortListener = listener;
|
|
89
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
90
|
+
}
|
|
91
|
+
/** Detach Esc/foreground cancellation but keep the timeout armed. */
|
|
92
|
+
detachForeground() {
|
|
93
|
+
if (this.abortSignal && this.abortListener)
|
|
94
|
+
this.abortSignal.removeEventListener("abort", this.abortListener);
|
|
95
|
+
this.abortSignal = undefined;
|
|
96
|
+
this.abortListener = undefined;
|
|
97
|
+
}
|
|
98
|
+
cleanupLifecycle() {
|
|
99
|
+
if (this.cleaned)
|
|
100
|
+
return;
|
|
101
|
+
this.cleaned = true;
|
|
102
|
+
if (this.timeoutTimer) {
|
|
103
|
+
clearTimeout(this.timeoutTimer);
|
|
104
|
+
this.timeoutTimer = undefined;
|
|
105
|
+
}
|
|
106
|
+
this.detachForeground();
|
|
107
|
+
}
|
|
64
108
|
waitForChange(ms) {
|
|
65
109
|
if (this.status !== "running" || this.delivered < this.output.length)
|
|
66
110
|
return Promise.resolve();
|
|
@@ -93,18 +137,23 @@ class ShellJob {
|
|
|
93
137
|
exitCode: this.exitCode,
|
|
94
138
|
bytes: this.output.length,
|
|
95
139
|
startedAt: this.startedAt,
|
|
96
|
-
pid: this.child?.pid,
|
|
140
|
+
pid: this.child?.pid ?? this.resident?.child.pid,
|
|
141
|
+
timeoutArmed: Boolean(this.timeoutTimer),
|
|
142
|
+
foregroundAttached: Boolean(this.abortListener),
|
|
97
143
|
};
|
|
98
144
|
}
|
|
99
145
|
kill(reason) {
|
|
100
146
|
if (this.status !== "running")
|
|
101
147
|
return;
|
|
102
|
-
this.append(reason === "timeout"
|
|
148
|
+
this.append(reason === "timeout"
|
|
149
|
+
? "\n[command timed out: process group killed; setsid descendants may escape]"
|
|
150
|
+
: "\n[command aborted: process group killed; setsid descendants may escape]");
|
|
103
151
|
this.status = reason;
|
|
152
|
+
this.cleanupLifecycle();
|
|
104
153
|
if (this.resident)
|
|
105
154
|
this.resident.kill();
|
|
106
155
|
else if (this.child)
|
|
107
|
-
|
|
156
|
+
terminateProcessTree(this.child);
|
|
108
157
|
this.resolveDone();
|
|
109
158
|
this.notify();
|
|
110
159
|
}
|
|
@@ -114,6 +163,8 @@ class ResidentShell {
|
|
|
114
163
|
job;
|
|
115
164
|
dead = false;
|
|
116
165
|
buffer = "";
|
|
166
|
+
background = false;
|
|
167
|
+
claimed = false;
|
|
117
168
|
constructor(cwd) {
|
|
118
169
|
const env = { ...process.env, FASTCODE_SANDBOX: "1", FASTCODE_SANDBOX_ROOT: cwd };
|
|
119
170
|
this.child = spawn("sh", ["-s"], {
|
|
@@ -137,13 +188,23 @@ class ResidentShell {
|
|
|
137
188
|
pool.splice(index, 1);
|
|
138
189
|
});
|
|
139
190
|
}
|
|
140
|
-
|
|
191
|
+
claimIfIdle() {
|
|
192
|
+
if (this.dead || this.job || this.claimed)
|
|
193
|
+
return false;
|
|
194
|
+
this.claimed = true;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
run(job, timeoutMs, background = false) {
|
|
198
|
+
this.claimed = false;
|
|
141
199
|
this.job = job;
|
|
200
|
+
this.background = background;
|
|
201
|
+
this.buffer = "";
|
|
142
202
|
job.resident = this;
|
|
143
203
|
const marker = `__FC_END_${job.key}__`;
|
|
204
|
+
const body = job.command.trim() ? ["(", job.command, ")"] : [":"];
|
|
144
205
|
const script = [
|
|
145
206
|
`if cd ${shellQuote(job.cwd)}; then`,
|
|
146
|
-
|
|
207
|
+
...body,
|
|
147
208
|
"__fc_status=$?",
|
|
148
209
|
"else",
|
|
149
210
|
"__fc_status=1",
|
|
@@ -163,9 +224,13 @@ class ResidentShell {
|
|
|
163
224
|
consume(chunk) {
|
|
164
225
|
if (this.dead)
|
|
165
226
|
return;
|
|
166
|
-
|
|
167
|
-
|
|
227
|
+
// Output that arrives while no job owns the shell belongs to an
|
|
228
|
+
// already-finished background child. Never carry it into the next job.
|
|
229
|
+
if (!this.job) {
|
|
230
|
+
this.buffer = "";
|
|
168
231
|
return;
|
|
232
|
+
}
|
|
233
|
+
this.buffer += chunk;
|
|
169
234
|
const marker = `__FC_END_${this.job.key}__:`;
|
|
170
235
|
const index = this.buffer.indexOf(marker);
|
|
171
236
|
if (index < 0) {
|
|
@@ -194,20 +259,39 @@ class ResidentShell {
|
|
|
194
259
|
const code = Number(rest.slice(0, newline).trim());
|
|
195
260
|
this.buffer = rest.slice(newline + 1);
|
|
196
261
|
this.job = undefined;
|
|
262
|
+
const background = this.background;
|
|
263
|
+
this.background = false;
|
|
197
264
|
job.finish(Number.isFinite(code) && code === 0 ? "done" : "failed", Number.isFinite(code) ? code : 1);
|
|
265
|
+
// A background operator leaves a detached child writing into this
|
|
266
|
+
// shell's stdout pipe. Retire the resident so late output cannot be
|
|
267
|
+
// attributed to the next command.
|
|
268
|
+
if (background)
|
|
269
|
+
this.kill();
|
|
198
270
|
}
|
|
199
271
|
fail(message, code) {
|
|
272
|
+
this.claimed = false;
|
|
200
273
|
if (!this.job)
|
|
201
274
|
return;
|
|
202
275
|
const job = this.job;
|
|
203
276
|
this.job = undefined;
|
|
277
|
+
this.background = false;
|
|
278
|
+
if (this.buffer) {
|
|
279
|
+
job.append(this.buffer);
|
|
280
|
+
this.buffer = "";
|
|
281
|
+
}
|
|
282
|
+
if (code === 0) {
|
|
283
|
+
job.finish("done", 0);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
204
286
|
job.append(`\n${message}`);
|
|
205
287
|
job.finish("failed", code ?? 1);
|
|
206
288
|
}
|
|
207
289
|
kill() {
|
|
208
290
|
this.dead = true;
|
|
209
|
-
|
|
210
|
-
|
|
291
|
+
this.claimed = false;
|
|
292
|
+
this.background = false;
|
|
293
|
+
this.buffer = "";
|
|
294
|
+
terminateProcessTree(this.child);
|
|
211
295
|
const index = pool.indexOf(this);
|
|
212
296
|
if (index >= 0)
|
|
213
297
|
pool.splice(index, 1);
|
|
@@ -217,18 +301,21 @@ export async function executeShellCommand(command, cwd, options = {}) {
|
|
|
217
301
|
pruneJobs();
|
|
218
302
|
if (options.signal?.aborted)
|
|
219
303
|
return { ok: false, status: "killed", output: "command aborted before start" };
|
|
220
|
-
const timeoutMs = options.timeoutMs
|
|
304
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
221
305
|
const backgroundAfterMs = options.backgroundAfterMs ?? DEFAULT_BACKGROUND_MS;
|
|
222
|
-
const
|
|
306
|
+
const shape = analyzeShellShape(command);
|
|
307
|
+
const useResident = process.platform !== "win32" && !shape.oneShot;
|
|
223
308
|
let job;
|
|
224
309
|
if (useResident) {
|
|
225
|
-
const resident = acquireResident(options.turn, cwd);
|
|
226
|
-
if (resident === "busy")
|
|
227
|
-
return { ok: false, status: "failed", output: "both pool shells are busy; poll or kill a session first" };
|
|
310
|
+
const resident = await acquireResident(options.turn, cwd, timeoutMs, options.signal);
|
|
228
311
|
if (resident === "limit")
|
|
229
312
|
return { ok: false, status: "failed", output: spawnLimitMessage() };
|
|
313
|
+
if (resident === "timeout")
|
|
314
|
+
return { ok: false, status: "timeout", output: "shell pool is busy; timed out waiting for a free resident shell" };
|
|
315
|
+
if (resident === "killed")
|
|
316
|
+
return { ok: false, status: "killed", output: "command aborted before start" };
|
|
230
317
|
job = new ShellJob(command, cwd);
|
|
231
|
-
resident.run(job, timeoutMs);
|
|
318
|
+
resident.run(job, timeoutMs, shape.background);
|
|
232
319
|
}
|
|
233
320
|
else {
|
|
234
321
|
if (!maySpawn(options.turn))
|
|
@@ -236,16 +323,15 @@ export async function executeShellCommand(command, cwd, options = {}) {
|
|
|
236
323
|
job = startOneShot(command, cwd);
|
|
237
324
|
}
|
|
238
325
|
jobs.set(job.id, job);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
options.signal?.
|
|
242
|
-
|
|
243
|
-
clearTimeout(timer);
|
|
244
|
-
options.signal?.removeEventListener("abort", onAbort);
|
|
245
|
-
};
|
|
326
|
+
job.armTimeout(timeoutMs);
|
|
327
|
+
job.attachAbort(options.signal);
|
|
328
|
+
if (options.signal?.aborted)
|
|
329
|
+
job.kill("killed");
|
|
246
330
|
await Promise.race([job.done(), delay(backgroundAfterMs)]);
|
|
247
331
|
if (job.status === "running") {
|
|
248
|
-
|
|
332
|
+
// Task keeps running: only the foreground Esc/abort association is
|
|
333
|
+
// released. The timeout stays armed until the job really finishes.
|
|
334
|
+
job.detachForeground();
|
|
249
335
|
return {
|
|
250
336
|
ok: true,
|
|
251
337
|
status: "running",
|
|
@@ -254,7 +340,6 @@ export async function executeShellCommand(command, cwd, options = {}) {
|
|
|
254
340
|
nextPollMs: job.nextPollMs,
|
|
255
341
|
};
|
|
256
342
|
}
|
|
257
|
-
cleanup();
|
|
258
343
|
return syncResult(job);
|
|
259
344
|
}
|
|
260
345
|
export async function pollShellSession(id, waitMs = 0) {
|
|
@@ -303,11 +388,70 @@ export function shutdownShells() {
|
|
|
303
388
|
pool.length = 0;
|
|
304
389
|
jobs.clear();
|
|
305
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Exit-safe stop: send SIGTERM, wait briefly for close, then force SIGKILL.
|
|
393
|
+
* Use this when the process is about to exit and must not leave children.
|
|
394
|
+
*/
|
|
395
|
+
export async function shutdownShellsAsync(graceMs = 400) {
|
|
396
|
+
const running = Array.from(jobs.values()).filter((job) => job.status === "running");
|
|
397
|
+
const residents = pool.filter((resident) => !resident.dead);
|
|
398
|
+
for (const job of running)
|
|
399
|
+
job.kill("killed");
|
|
400
|
+
for (const resident of residents)
|
|
401
|
+
resident.kill();
|
|
402
|
+
pool.length = 0;
|
|
403
|
+
jobs.clear();
|
|
404
|
+
const children = [];
|
|
405
|
+
for (const job of running) {
|
|
406
|
+
if (job.child)
|
|
407
|
+
children.push(job.child);
|
|
408
|
+
}
|
|
409
|
+
for (const resident of residents)
|
|
410
|
+
children.push(resident.child);
|
|
411
|
+
if (children.length === 0)
|
|
412
|
+
return;
|
|
413
|
+
await Promise.race([
|
|
414
|
+
Promise.all(children.map((child) => waitForChildClose(child, graceMs))),
|
|
415
|
+
delay(graceMs + 50),
|
|
416
|
+
]);
|
|
417
|
+
for (const child of children) {
|
|
418
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
419
|
+
terminateProcessTree(child, { graceMs: 0 });
|
|
420
|
+
}
|
|
421
|
+
await delay(50);
|
|
422
|
+
}
|
|
423
|
+
function waitForChildClose(child, ms) {
|
|
424
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
425
|
+
return Promise.resolve();
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
let timer;
|
|
428
|
+
const done = () => {
|
|
429
|
+
if (timer)
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
child.removeListener("close", done);
|
|
432
|
+
resolve();
|
|
433
|
+
};
|
|
434
|
+
timer = setTimeout(done, ms);
|
|
435
|
+
child.once("close", done);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
export function shellPoolSize() {
|
|
439
|
+
return poolLimit;
|
|
440
|
+
}
|
|
441
|
+
/** Clamp and apply the resident shell concurrency (1..6, default 2). */
|
|
442
|
+
export function setShellPoolSize(size) {
|
|
443
|
+
const parsed = Number(size);
|
|
444
|
+
poolLimit = Number.isFinite(parsed)
|
|
445
|
+
? Math.min(MAX_SHELL_POOL_SIZE, Math.max(1, Math.floor(parsed)))
|
|
446
|
+
: DEFAULT_SHELL_POOL_SIZE;
|
|
447
|
+
return poolLimit;
|
|
448
|
+
}
|
|
306
449
|
export function shellPoolStats() {
|
|
307
450
|
return {
|
|
308
451
|
spawns,
|
|
309
452
|
active: Array.from(jobs.values()).filter((job) => job.status === "running").length,
|
|
310
453
|
pool: pool.filter((resident) => !resident.dead).length,
|
|
454
|
+
maxPool: poolLimit,
|
|
311
455
|
maxSpawnPerTurn: maxSpawnPerTurn(),
|
|
312
456
|
};
|
|
313
457
|
}
|
|
@@ -333,21 +477,88 @@ function startOneShot(command, cwd) {
|
|
|
333
477
|
});
|
|
334
478
|
return job;
|
|
335
479
|
}
|
|
336
|
-
function acquireResident(turn, cwd) {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
480
|
+
async function acquireResident(turn, cwd, waitMs, signal) {
|
|
481
|
+
const deadline = Date.now() + waitMs;
|
|
482
|
+
while (true) {
|
|
483
|
+
for (const resident of pool) {
|
|
484
|
+
if (resident.claimIfIdle())
|
|
485
|
+
return resident;
|
|
486
|
+
}
|
|
487
|
+
const live = pool.filter((resident) => !resident.dead);
|
|
488
|
+
if (live.length < poolLimit && maySpawn(turn)) {
|
|
489
|
+
const resident = new ResidentShell(cwd);
|
|
490
|
+
resident.claimIfIdle();
|
|
491
|
+
pool.push(resident);
|
|
492
|
+
return resident;
|
|
493
|
+
}
|
|
494
|
+
// No free shell and no spawn budget: wait for a running command to
|
|
495
|
+
// finish instead of failing the third call with "both shells busy".
|
|
496
|
+
if (live.length === 0)
|
|
497
|
+
return "limit";
|
|
498
|
+
if (signal?.aborted)
|
|
499
|
+
return "killed";
|
|
500
|
+
const remaining = deadline - Date.now();
|
|
501
|
+
if (remaining <= 0)
|
|
502
|
+
return "timeout";
|
|
503
|
+
await delay(Math.min(remaining, 50));
|
|
504
|
+
}
|
|
347
505
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
506
|
+
const INTERACTIVE_COMMANDS = new Set(["vim", "nano", "less", "more", "ssh", "su", "login", "read", "top", "htop", "tmux", "screen"]);
|
|
507
|
+
const REPL_COMMANDS = new Set(["python", "python3", "node", "bash", "zsh", "fish", "irb", "sqlite3"]);
|
|
508
|
+
const WRAPPER_COMMANDS = new Set(["sudo", "env", "nohup", "command", "time", "exec"]);
|
|
509
|
+
function analyzeShellShape(command) {
|
|
510
|
+
let ast;
|
|
511
|
+
try {
|
|
512
|
+
ast = parse(command, { mode: "bash" });
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Fall back to the historical heuristic only when the parser cannot
|
|
516
|
+
// classify the command at all.
|
|
517
|
+
return {
|
|
518
|
+
oneShot: /(^|\s)(vim|nano|less|more|ssh|su|login|read|top|htop|tmux|screen)(\s|$)/.test(command)
|
|
519
|
+
|| /(^|\s)(python|python3|node|bash|zsh|fish|irb|sqlite3)(\s|$)/.test(command) && !/\s-(c|e|m)\s/.test(command),
|
|
520
|
+
background: /(^|[^&>])&([^&>]|$)/.test(command),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
let oneShot = false;
|
|
524
|
+
let background = false;
|
|
525
|
+
const walk = (node, depth = 0) => {
|
|
526
|
+
if (!node || typeof node !== "object" || depth > 32)
|
|
527
|
+
return;
|
|
528
|
+
if (Array.isArray(node)) {
|
|
529
|
+
for (const entry of node)
|
|
530
|
+
walk(entry, depth + 1);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// bash-parser also marks `cmd &> file` as async; only retire the
|
|
534
|
+
// resident for a real background operator, not a redirect.
|
|
535
|
+
if (node.async === true && /(^|[^&>])&([^&>]|$)/.test(command))
|
|
536
|
+
background = true;
|
|
537
|
+
if (node.type === "Command" && typeof node.name?.text === "string") {
|
|
538
|
+
const name = node.name.text;
|
|
539
|
+
const args = Array.isArray(node.suffix)
|
|
540
|
+
? node.suffix.map((word) => typeof word?.text === "string" ? word.text : "").filter(Boolean)
|
|
541
|
+
: [];
|
|
542
|
+
const replOptions = (values) => values.some((arg) => /^(-c|-e|-m|-p|--eval|--print|--command)$/.test(arg));
|
|
543
|
+
if (INTERACTIVE_COMMANDS.has(name) || REPL_COMMANDS.has(name) && !replOptions(args))
|
|
544
|
+
oneShot = true;
|
|
545
|
+
if (WRAPPER_COMMANDS.has(name)) {
|
|
546
|
+
const wrappedIndex = args.findIndex((arg) => INTERACTIVE_COMMANDS.has(arg) || REPL_COMMANDS.has(arg));
|
|
547
|
+
if (wrappedIndex >= 0) {
|
|
548
|
+
const wrappedName = args[wrappedIndex];
|
|
549
|
+
if (INTERACTIVE_COMMANDS.has(wrappedName) ||
|
|
550
|
+
REPL_COMMANDS.has(wrappedName) && !replOptions(args.slice(wrappedIndex + 1)))
|
|
551
|
+
oneShot = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const value of Object.values(node)) {
|
|
556
|
+
if (value && typeof value === "object")
|
|
557
|
+
walk(value, depth + 1);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
walk(ast);
|
|
561
|
+
return { oneShot, background };
|
|
351
562
|
}
|
|
352
563
|
function maySpawn(turn) {
|
|
353
564
|
const current = typeof turn === "number" ? turn : 0;
|