@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,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 { 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
  const OUTPUT_LIMIT = 1024 * 1024;
4
6
  const DEFAULT_BACKGROUND_MS = 2000;
5
7
  const DEFAULT_TIMEOUT_MS = 120_000;
6
- const MAX_POOL = 2;
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" ? "\n[command timed out: process tree killed]" : "\n[command aborted: process tree killed]");
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
- killProcessTree(this.child, "SIGTERM");
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
- run(job, timeoutMs) {
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
- job.command,
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
- this.buffer += chunk;
167
- if (!this.job)
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
- killProcessTree(this.child, "SIGTERM");
210
- setTimeout(() => killProcessTree(this.child, "SIGKILL"), 2000);
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 ?? DEFAULT_TIMEOUT_MS;
304
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS);
221
305
  const backgroundAfterMs = options.backgroundAfterMs ?? DEFAULT_BACKGROUND_MS;
222
- const useResident = process.platform !== "win32" && !needsOneShot(command);
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
- const timer = setTimeout(() => job.kill("timeout"), timeoutMs);
240
- const onAbort = () => job.kill("killed");
241
- options.signal?.addEventListener("abort", onAbort, { once: true });
242
- const cleanup = () => {
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
- cleanup();
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 idle = pool.find((resident) => !resident.dead && !resident.job);
338
- if (idle)
339
- return idle;
340
- if (pool.filter((resident) => !resident.dead).length >= MAX_POOL)
341
- return "busy";
342
- if (!maySpawn(turn))
343
- return "limit";
344
- const resident = new ResidentShell(cwd);
345
- pool.push(resident);
346
- return resident;
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
- function needsOneShot(command) {
349
- return /(^|\s)(vim|nano|less|more|ssh|su|login|read|top|htop|tmux|screen)(\s|$)/.test(command)
350
- || /(^|\s)(python|python3|node|bash|zsh|fish|irb|sqlite3)(\s|$)/.test(command) && !/\s-(c|e|m)\s/.test(command);
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;