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