reasonix 0.32.0 → 0.33.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/dist/cli/chat-EIFLHBZ6.js +39 -0
- package/dist/cli/chunk-2AWTGJ2C.js +110 -0
- package/dist/cli/chunk-2AWTGJ2C.js.map +1 -0
- package/dist/cli/chunk-3Q3C4W66.js +30 -0
- package/dist/cli/chunk-3Q3C4W66.js.map +1 -0
- package/dist/cli/chunk-4DCHFFEY.js +149 -0
- package/dist/cli/chunk-4DCHFFEY.js.map +1 -0
- package/dist/cli/chunk-5X7LZJDE.js +36 -0
- package/dist/cli/chunk-5X7LZJDE.js.map +1 -0
- package/dist/cli/chunk-6TMHAK5D.js +576 -0
- package/dist/cli/chunk-6TMHAK5D.js.map +1 -0
- package/dist/cli/chunk-APPB3ZPQ.js +43 -0
- package/dist/cli/chunk-APPB3ZPQ.js.map +1 -0
- package/dist/cli/chunk-BQNUJJN7.js +42 -0
- package/dist/cli/chunk-BQNUJJN7.js.map +1 -0
- package/dist/cli/chunk-CPOV2O73.js +39 -0
- package/dist/cli/chunk-CPOV2O73.js.map +1 -0
- package/dist/cli/chunk-D5DKXIP5.js +368 -0
- package/dist/cli/chunk-D5DKXIP5.js.map +1 -0
- package/dist/cli/chunk-DFP4YSVM.js +247 -0
- package/dist/cli/chunk-DFP4YSVM.js.map +1 -0
- package/dist/cli/chunk-DULSP7JH.js +410 -0
- package/dist/cli/chunk-DULSP7JH.js.map +1 -0
- package/dist/cli/chunk-FM57FNPJ.js +46 -0
- package/dist/cli/chunk-FM57FNPJ.js.map +1 -0
- package/dist/cli/chunk-FWGEHRB7.js +54 -0
- package/dist/cli/chunk-FWGEHRB7.js.map +1 -0
- package/dist/cli/chunk-FXGQ5NHE.js +513 -0
- package/dist/cli/chunk-FXGQ5NHE.js.map +1 -0
- package/dist/cli/chunk-G3XNWSFN.js +53 -0
- package/dist/cli/chunk-G3XNWSFN.js.map +1 -0
- package/dist/cli/chunk-I6YIAK6C.js +757 -0
- package/dist/cli/chunk-I6YIAK6C.js.map +1 -0
- package/dist/cli/chunk-J5VLP23S.js +94 -0
- package/dist/cli/chunk-J5VLP23S.js.map +1 -0
- package/dist/cli/chunk-KMWKGPFZ.js +303 -0
- package/dist/cli/chunk-KMWKGPFZ.js.map +1 -0
- package/dist/cli/chunk-LVQX5KGF.js +14934 -0
- package/dist/cli/chunk-LVQX5KGF.js.map +1 -0
- package/dist/cli/chunk-MHDNZXJJ.js +48 -0
- package/dist/cli/chunk-MHDNZXJJ.js.map +1 -0
- package/dist/cli/chunk-ORM6PK57.js +140 -0
- package/dist/cli/chunk-ORM6PK57.js.map +1 -0
- package/dist/cli/chunk-Q5GRLZJF.js +99 -0
- package/dist/cli/chunk-Q5GRLZJF.js.map +1 -0
- package/dist/cli/chunk-Q6YFXW7H.js +4986 -0
- package/dist/cli/chunk-Q6YFXW7H.js.map +1 -0
- package/dist/cli/chunk-QGE6AF76.js +1467 -0
- package/dist/cli/chunk-QGE6AF76.js.map +1 -0
- package/dist/cli/chunk-RFX7TYVV.js +28 -0
- package/dist/cli/chunk-RFX7TYVV.js.map +1 -0
- package/dist/cli/chunk-RZILUXUC.js +940 -0
- package/dist/cli/chunk-RZILUXUC.js.map +1 -0
- package/dist/cli/chunk-SDE5U32Z.js +535 -0
- package/dist/cli/chunk-SDE5U32Z.js.map +1 -0
- package/dist/cli/chunk-SOZE7V7V.js +340 -0
- package/dist/cli/chunk-SOZE7V7V.js.map +1 -0
- package/dist/cli/chunk-U3V2ZQ5J.js +479 -0
- package/dist/cli/chunk-U3V2ZQ5J.js.map +1 -0
- package/dist/cli/chunk-W4LDFAZ6.js +1544 -0
- package/dist/cli/chunk-W4LDFAZ6.js.map +1 -0
- package/dist/cli/chunk-WBDE4IRI.js +208 -0
- package/dist/cli/chunk-WBDE4IRI.js.map +1 -0
- package/dist/cli/chunk-XHQIK7B6.js +189 -0
- package/dist/cli/chunk-XHQIK7B6.js.map +1 -0
- package/dist/cli/chunk-XJLZ4HKU.js +307 -0
- package/dist/cli/chunk-XJLZ4HKU.js.map +1 -0
- package/dist/cli/chunk-ZPTSJGX5.js +88 -0
- package/dist/cli/chunk-ZPTSJGX5.js.map +1 -0
- package/dist/cli/chunk-ZTLZO42A.js +231 -0
- package/dist/cli/chunk-ZTLZO42A.js.map +1 -0
- package/dist/cli/code-F4KJOE3K.js +151 -0
- package/dist/cli/code-F4KJOE3K.js.map +1 -0
- package/dist/cli/commands-JWT2MWVH.js +352 -0
- package/dist/cli/commands-JWT2MWVH.js.map +1 -0
- package/dist/cli/commit-RPZBOZS2.js +288 -0
- package/dist/cli/commit-RPZBOZS2.js.map +1 -0
- package/dist/cli/diff-NTEHCSDW.js +145 -0
- package/dist/cli/diff-NTEHCSDW.js.map +1 -0
- package/dist/cli/doctor-3TGB2NZN.js +19 -0
- package/dist/cli/doctor-3TGB2NZN.js.map +1 -0
- package/dist/cli/events-P27CX7LN.js +338 -0
- package/dist/cli/events-P27CX7LN.js.map +1 -0
- package/dist/cli/index.js +80 -33693
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-ARTNQ24O.js +266 -0
- package/dist/cli/mcp-ARTNQ24O.js.map +1 -0
- package/dist/cli/mcp-browse-HLO2ENDL.js +163 -0
- package/dist/cli/mcp-browse-HLO2ENDL.js.map +1 -0
- package/dist/cli/mcp-inspect-T2HBR22P.js +103 -0
- package/dist/cli/mcp-inspect-T2HBR22P.js.map +1 -0
- package/dist/cli/{prompt-XHICFAYN.js → prompt-V47QKSAR.js} +3 -2
- package/dist/cli/prompt-V47QKSAR.js.map +1 -0
- package/dist/cli/prune-sessions-ERL6B4G5.js +42 -0
- package/dist/cli/prune-sessions-ERL6B4G5.js.map +1 -0
- package/dist/cli/replay-TMJASRC4.js +273 -0
- package/dist/cli/replay-TMJASRC4.js.map +1 -0
- package/dist/cli/run-JMEOTQCG.js +215 -0
- package/dist/cli/run-JMEOTQCG.js.map +1 -0
- package/dist/cli/server-SYC3OVOP.js +2967 -0
- package/dist/cli/server-SYC3OVOP.js.map +1 -0
- package/dist/cli/sessions-MOJAALJI.js +102 -0
- package/dist/cli/sessions-MOJAALJI.js.map +1 -0
- package/dist/cli/setup-CCJZAWTY.js +404 -0
- package/dist/cli/setup-CCJZAWTY.js.map +1 -0
- package/dist/cli/stats-5RJCATCE.js +12 -0
- package/dist/cli/stats-5RJCATCE.js.map +1 -0
- package/dist/cli/update-4TJWRUIN.js +90 -0
- package/dist/cli/update-4TJWRUIN.js.map +1 -0
- package/dist/cli/version-3MYFE4G6.js +29 -0
- package/dist/cli/version-3MYFE4G6.js.map +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +493 -89
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli/chunk-VWFJNLIK.js +0 -1031
- package/dist/cli/chunk-VWFJNLIK.js.map +0 -1
- /package/dist/cli/{prompt-XHICFAYN.js.map → chat-EIFLHBZ6.js.map} +0 -0
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
addProjectShellAllowed
|
|
4
|
+
} from "./chunk-DULSP7JH.js";
|
|
5
|
+
|
|
6
|
+
// src/tools/jobs.ts
|
|
7
|
+
import { spawn as spawn3 } from "child_process";
|
|
8
|
+
import * as pathMod4 from "path";
|
|
9
|
+
|
|
10
|
+
// src/tools/shell.ts
|
|
11
|
+
import * as pathMod3 from "path";
|
|
12
|
+
|
|
13
|
+
// src/core/pause-gate.ts
|
|
14
|
+
var PauseGate = class {
|
|
15
|
+
_nextId = 0;
|
|
16
|
+
_pending = /* @__PURE__ */ new Map();
|
|
17
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
18
|
+
_auditListener = null;
|
|
19
|
+
/** Block until the user responds. Takes a named options object so the
|
|
20
|
+
* kind and payload fields don't get confused at the call site. */
|
|
21
|
+
ask(opts) {
|
|
22
|
+
const { kind, payload } = opts;
|
|
23
|
+
if (this._listeners.size === 0) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`${kind}: no confirmation listener registered \u2014 cannot prompt the user. This tool can only be used inside an interactive Reasonix session.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return new Promise((resolve4) => {
|
|
29
|
+
const id = this._nextId++;
|
|
30
|
+
const request = { id, kind, payload };
|
|
31
|
+
this._pending.set(id, { resolve: resolve4, request });
|
|
32
|
+
for (const fn of this._listeners) {
|
|
33
|
+
try {
|
|
34
|
+
fn(request);
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Resolve a pending request. Called by the App's modal callback. */
|
|
41
|
+
resolve(id, data) {
|
|
42
|
+
const p = this._pending.get(id);
|
|
43
|
+
if (!p) return;
|
|
44
|
+
this._pending.delete(id);
|
|
45
|
+
this.emitAuditEvent(p.request, data);
|
|
46
|
+
p.resolve(data);
|
|
47
|
+
}
|
|
48
|
+
setAuditListener(fn) {
|
|
49
|
+
this._auditListener = fn;
|
|
50
|
+
}
|
|
51
|
+
/** Subscribe to new pause requests. Returns an unsubscribe function. */
|
|
52
|
+
on(fn) {
|
|
53
|
+
this._listeners.add(fn);
|
|
54
|
+
return () => {
|
|
55
|
+
this._listeners.delete(fn);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Current pending request, if any (polling fallback). */
|
|
59
|
+
get current() {
|
|
60
|
+
for (const [, p] of this._pending) return p.request;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
emitAuditEvent(request, data) {
|
|
64
|
+
if (!this._auditListener) return;
|
|
65
|
+
if (request.kind !== "run_command" && request.kind !== "run_background") return;
|
|
66
|
+
if (!data || typeof data !== "object") return;
|
|
67
|
+
const choice = data;
|
|
68
|
+
try {
|
|
69
|
+
switch (choice.type) {
|
|
70
|
+
case "run_once":
|
|
71
|
+
this._auditListener({
|
|
72
|
+
type: "tool.confirm.allow",
|
|
73
|
+
kind: request.kind,
|
|
74
|
+
payload: request.payload
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
case "deny":
|
|
78
|
+
this._auditListener({
|
|
79
|
+
type: "tool.confirm.deny",
|
|
80
|
+
kind: request.kind,
|
|
81
|
+
payload: request.payload,
|
|
82
|
+
denyContext: choice.denyContext
|
|
83
|
+
});
|
|
84
|
+
break;
|
|
85
|
+
case "always_allow":
|
|
86
|
+
if (typeof choice.prefix !== "string") return;
|
|
87
|
+
this._auditListener({
|
|
88
|
+
type: "tool.confirm.always_allow",
|
|
89
|
+
kind: request.kind,
|
|
90
|
+
payload: request.payload,
|
|
91
|
+
prefix: choice.prefix
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var pauseGate = new PauseGate();
|
|
102
|
+
|
|
103
|
+
// src/tools/shell/exec.ts
|
|
104
|
+
import { spawn as spawn2, spawnSync } from "child_process";
|
|
105
|
+
import { existsSync, statSync } from "fs";
|
|
106
|
+
import * as pathMod2 from "path";
|
|
107
|
+
|
|
108
|
+
// src/tools/shell-chain.ts
|
|
109
|
+
import { spawn } from "child_process";
|
|
110
|
+
import { closeSync, openSync } from "fs";
|
|
111
|
+
import * as pathMod from "path";
|
|
112
|
+
var UnsupportedSyntaxError = class extends Error {
|
|
113
|
+
constructor(detail) {
|
|
114
|
+
super(`run_command: ${detail}`);
|
|
115
|
+
this.name = "UnsupportedSyntaxError";
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
function splitOnChainOps(cmd) {
|
|
119
|
+
const segs = [];
|
|
120
|
+
const ops = [];
|
|
121
|
+
let segStart = 0;
|
|
122
|
+
let i = 0;
|
|
123
|
+
let quote = null;
|
|
124
|
+
let atTokenStart = true;
|
|
125
|
+
while (i < cmd.length) {
|
|
126
|
+
const ch = cmd[i];
|
|
127
|
+
if (quote) {
|
|
128
|
+
if (ch === quote) quote = null;
|
|
129
|
+
else if (quote === '"' && isDqEscape(ch, cmd[i + 1])) i++;
|
|
130
|
+
i++;
|
|
131
|
+
atTokenStart = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (ch === '"' || ch === "'") {
|
|
135
|
+
quote = ch;
|
|
136
|
+
i++;
|
|
137
|
+
atTokenStart = false;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === " " || ch === " ") {
|
|
141
|
+
i++;
|
|
142
|
+
atTokenStart = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (atTokenStart) {
|
|
146
|
+
let op = null;
|
|
147
|
+
let opLen = 0;
|
|
148
|
+
const next = cmd[i + 1];
|
|
149
|
+
if (ch === "|" && next === "|") {
|
|
150
|
+
op = "||";
|
|
151
|
+
opLen = 2;
|
|
152
|
+
} else if (ch === "&" && next === "&") {
|
|
153
|
+
op = "&&";
|
|
154
|
+
opLen = 2;
|
|
155
|
+
} else if (ch === "|") {
|
|
156
|
+
op = "|";
|
|
157
|
+
opLen = 1;
|
|
158
|
+
} else if (ch === ";") {
|
|
159
|
+
op = ";";
|
|
160
|
+
opLen = 1;
|
|
161
|
+
}
|
|
162
|
+
if (op !== null) {
|
|
163
|
+
segs.push(cmd.slice(segStart, i));
|
|
164
|
+
ops.push(op);
|
|
165
|
+
i += opLen;
|
|
166
|
+
segStart = i;
|
|
167
|
+
atTokenStart = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
i++;
|
|
172
|
+
atTokenStart = false;
|
|
173
|
+
}
|
|
174
|
+
segs.push(cmd.slice(segStart));
|
|
175
|
+
return { segs, ops };
|
|
176
|
+
}
|
|
177
|
+
function parseSegment(segStr) {
|
|
178
|
+
const argv = [];
|
|
179
|
+
const redirects = [];
|
|
180
|
+
let cur = "";
|
|
181
|
+
let curHasContent = false;
|
|
182
|
+
let pending = null;
|
|
183
|
+
let quote = null;
|
|
184
|
+
const flush = () => {
|
|
185
|
+
if (!curHasContent && cur.length === 0) return;
|
|
186
|
+
if (pending) {
|
|
187
|
+
redirects.push({ kind: pending, target: cur });
|
|
188
|
+
pending = null;
|
|
189
|
+
} else {
|
|
190
|
+
argv.push(cur);
|
|
191
|
+
}
|
|
192
|
+
cur = "";
|
|
193
|
+
curHasContent = false;
|
|
194
|
+
};
|
|
195
|
+
let i = 0;
|
|
196
|
+
while (i < segStr.length) {
|
|
197
|
+
const ch = segStr[i];
|
|
198
|
+
if (quote) {
|
|
199
|
+
if (ch === quote) {
|
|
200
|
+
quote = null;
|
|
201
|
+
} else if (quote === '"' && isDqEscape(ch, segStr[i + 1])) {
|
|
202
|
+
cur += segStr[++i] ?? "";
|
|
203
|
+
curHasContent = true;
|
|
204
|
+
} else {
|
|
205
|
+
cur += ch;
|
|
206
|
+
curHasContent = true;
|
|
207
|
+
}
|
|
208
|
+
i++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (ch === '"' || ch === "'") {
|
|
212
|
+
quote = ch;
|
|
213
|
+
curHasContent = true;
|
|
214
|
+
i++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (ch === " " || ch === " ") {
|
|
218
|
+
flush();
|
|
219
|
+
i++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (cur.length === 0 && !curHasContent) {
|
|
223
|
+
const remaining = segStr.slice(i);
|
|
224
|
+
let matched = null;
|
|
225
|
+
if (remaining.startsWith("2>&1")) matched = { op: "2>&1", len: 4 };
|
|
226
|
+
else if (remaining.startsWith("&>")) matched = { op: "&>", len: 2 };
|
|
227
|
+
else if (remaining.startsWith("2>>")) matched = { op: "2>>", len: 3 };
|
|
228
|
+
else if (remaining.startsWith("2>")) matched = { op: "2>", len: 2 };
|
|
229
|
+
else if (remaining.startsWith(">>")) matched = { op: ">>", len: 2 };
|
|
230
|
+
else if (remaining.startsWith(">")) matched = { op: ">", len: 1 };
|
|
231
|
+
else if (remaining.startsWith("<<")) {
|
|
232
|
+
throw new UnsupportedSyntaxError(
|
|
233
|
+
`shell operator "<<" is not supported \u2014 heredoc / here-string is not implemented; pass input via a "<" file or the binary's --input flag`
|
|
234
|
+
);
|
|
235
|
+
} else if (remaining.startsWith("<")) matched = { op: "<", len: 1 };
|
|
236
|
+
if (matched) {
|
|
237
|
+
if (pending !== null) {
|
|
238
|
+
throw new UnsupportedSyntaxError(
|
|
239
|
+
`redirect "${pending}" is missing a target file before "${matched.op}"`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (matched.op === "2>&1") {
|
|
243
|
+
redirects.push({ kind: "2>&1", target: "" });
|
|
244
|
+
} else {
|
|
245
|
+
pending = matched.op;
|
|
246
|
+
}
|
|
247
|
+
i += matched.len;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === "&") {
|
|
251
|
+
throw new UnsupportedSyntaxError(
|
|
252
|
+
'shell operator "&" is not supported \u2014 background runs need run_background, not run_command. Wrap a literal `&` arg in quotes.'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
cur += ch;
|
|
257
|
+
curHasContent = true;
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
if (quote) throw new Error(`unclosed ${quote} in command`);
|
|
261
|
+
flush();
|
|
262
|
+
if (pending) throw new UnsupportedSyntaxError(`redirect "${pending}" is missing a target file`);
|
|
263
|
+
if (argv.length === 0 && redirects.length > 0) {
|
|
264
|
+
throw new UnsupportedSyntaxError(
|
|
265
|
+
"redirect without a command \u2014 segment must have at least one program argument"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
validateRedirectFds(redirects);
|
|
269
|
+
return { argv, redirects };
|
|
270
|
+
}
|
|
271
|
+
function validateRedirectFds(redirects) {
|
|
272
|
+
let stdin = 0;
|
|
273
|
+
let stdout = 0;
|
|
274
|
+
let stderr = 0;
|
|
275
|
+
for (const r of redirects) {
|
|
276
|
+
if (r.kind === "<") stdin++;
|
|
277
|
+
else if (r.kind === ">" || r.kind === ">>") stdout++;
|
|
278
|
+
else if (r.kind === "2>" || r.kind === "2>>" || r.kind === "2>&1") stderr++;
|
|
279
|
+
else if (r.kind === "&>") {
|
|
280
|
+
stdout++;
|
|
281
|
+
stderr++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (stdin > 1) throw new UnsupportedSyntaxError("multiple `<` stdin redirects in one segment");
|
|
285
|
+
if (stdout > 1)
|
|
286
|
+
throw new UnsupportedSyntaxError(
|
|
287
|
+
"multiple stdout redirects in one segment (`>` / `>>` / `&>` conflict)"
|
|
288
|
+
);
|
|
289
|
+
if (stderr > 1)
|
|
290
|
+
throw new UnsupportedSyntaxError(
|
|
291
|
+
"multiple stderr redirects in one segment (`2>` / `2>>` / `&>` / `2>&1` conflict)"
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
function parseCommandChain(cmd) {
|
|
295
|
+
const { segs, ops } = splitOnChainOps(cmd);
|
|
296
|
+
const segments = [];
|
|
297
|
+
for (let i = 0; i < segs.length; i++) {
|
|
298
|
+
const trimmed = segs[i].trim();
|
|
299
|
+
if (trimmed.length === 0) {
|
|
300
|
+
const op = i === 0 ? ops[0] : ops[i - 1];
|
|
301
|
+
throw new UnsupportedSyntaxError(
|
|
302
|
+
i === 0 ? `empty segment before "${op}"` : i === segs.length - 1 ? `chain ends with "${op}"` : `empty segment between "${ops[i - 1]}" and "${ops[i]}"`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
segments.push(parseSegment(trimmed));
|
|
306
|
+
}
|
|
307
|
+
for (const seg of segments) {
|
|
308
|
+
const cmdName = seg.argv[0] ?? "";
|
|
309
|
+
if (cmdName.toLowerCase() === "cd") {
|
|
310
|
+
throw new UnsupportedSyntaxError(
|
|
311
|
+
"cd in parsed command chains does not change cwd for later segments. Use a command-native cwd flag instead, such as `npm --prefix <dir> run <script>`, `git -C <dir> ...`, or `cargo -C <dir> ...`."
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (ops.length === 0 && segments[0].redirects.length === 0) return null;
|
|
316
|
+
return { segments, ops };
|
|
317
|
+
}
|
|
318
|
+
function chainAllowed(chain, isAllowed2) {
|
|
319
|
+
for (const seg of chain.segments) {
|
|
320
|
+
if (!isAllowed2(seg.argv.join(" "))) return false;
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
function groupChain(chain) {
|
|
325
|
+
const groups = [{ segments: [chain.segments[0]], opBefore: null }];
|
|
326
|
+
for (let i = 0; i < chain.ops.length; i++) {
|
|
327
|
+
const op = chain.ops[i];
|
|
328
|
+
const next = chain.segments[i + 1];
|
|
329
|
+
if (op === "|") {
|
|
330
|
+
groups[groups.length - 1].segments.push(next);
|
|
331
|
+
} else {
|
|
332
|
+
groups.push({ segments: [next], opBefore: op });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return groups;
|
|
336
|
+
}
|
|
337
|
+
async function runChain(chain, opts) {
|
|
338
|
+
const groups = groupChain(chain);
|
|
339
|
+
const buf = new OutputBuffer(opts.maxOutputChars * 2 * 4);
|
|
340
|
+
const deadline = Date.now() + opts.timeoutSec * 1e3;
|
|
341
|
+
let lastExit = 0;
|
|
342
|
+
let timedOut = false;
|
|
343
|
+
for (const group of groups) {
|
|
344
|
+
if (group.opBefore === "&&" && lastExit !== 0) continue;
|
|
345
|
+
if (group.opBefore === "||" && lastExit === 0) continue;
|
|
346
|
+
const remainingMs = deadline - Date.now();
|
|
347
|
+
if (remainingMs <= 0) {
|
|
348
|
+
timedOut = true;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
const result = await runPipeGroup(group.segments, {
|
|
352
|
+
cwd: opts.cwd,
|
|
353
|
+
timeoutMs: remainingMs,
|
|
354
|
+
buf,
|
|
355
|
+
signal: opts.signal
|
|
356
|
+
});
|
|
357
|
+
lastExit = result.exitCode;
|
|
358
|
+
if (result.timedOut) {
|
|
359
|
+
timedOut = true;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
if (opts.signal?.aborted) break;
|
|
363
|
+
}
|
|
364
|
+
const output = buf.toString();
|
|
365
|
+
const truncated = output.length > opts.maxOutputChars ? `${output.slice(0, opts.maxOutputChars)}
|
|
366
|
+
|
|
367
|
+
[\u2026 truncated ${output.length - opts.maxOutputChars} chars \u2026]` : output;
|
|
368
|
+
return { exitCode: lastExit, output: truncated, timedOut };
|
|
369
|
+
}
|
|
370
|
+
function openRedirects(redirects, cwd) {
|
|
371
|
+
let stdinFd = null;
|
|
372
|
+
let stdoutFd = null;
|
|
373
|
+
let stderrFd = null;
|
|
374
|
+
let mergeStderrToStdout = false;
|
|
375
|
+
let bothFd = null;
|
|
376
|
+
const toClose = [];
|
|
377
|
+
const open = (target, flags) => {
|
|
378
|
+
const resolved = pathMod.resolve(cwd, target);
|
|
379
|
+
const fd = openSync(resolved, flags);
|
|
380
|
+
toClose.push(fd);
|
|
381
|
+
return fd;
|
|
382
|
+
};
|
|
383
|
+
for (const r of redirects) {
|
|
384
|
+
if (r.kind === "<") stdinFd = open(r.target, "r");
|
|
385
|
+
else if (r.kind === ">") stdoutFd = open(r.target, "w");
|
|
386
|
+
else if (r.kind === ">>") stdoutFd = open(r.target, "a");
|
|
387
|
+
else if (r.kind === "2>") stderrFd = open(r.target, "w");
|
|
388
|
+
else if (r.kind === "2>>") stderrFd = open(r.target, "a");
|
|
389
|
+
else if (r.kind === "&>") {
|
|
390
|
+
bothFd = open(r.target, "w");
|
|
391
|
+
stdoutFd = bothFd;
|
|
392
|
+
stderrFd = bothFd;
|
|
393
|
+
} else if (r.kind === "2>&1") {
|
|
394
|
+
mergeStderrToStdout = true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { stdinFd, stdoutFd, stderrFd, mergeStderrToStdout, toClose };
|
|
398
|
+
}
|
|
399
|
+
async function runPipeGroup(segments, opts) {
|
|
400
|
+
const env = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
|
|
401
|
+
const children = [];
|
|
402
|
+
const allFds = [];
|
|
403
|
+
let timedOut = false;
|
|
404
|
+
const killAll = () => {
|
|
405
|
+
for (const c of children) killProcessTree(c);
|
|
406
|
+
};
|
|
407
|
+
const killTimer = setTimeout(() => {
|
|
408
|
+
timedOut = true;
|
|
409
|
+
killAll();
|
|
410
|
+
}, opts.timeoutMs);
|
|
411
|
+
const onAbort = () => killAll();
|
|
412
|
+
if (opts.signal?.aborted) {
|
|
413
|
+
onAbort();
|
|
414
|
+
} else {
|
|
415
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
for (let i = 0; i < segments.length; i++) {
|
|
419
|
+
const isFirst = i === 0;
|
|
420
|
+
const isLast = i === segments.length - 1;
|
|
421
|
+
const seg = segments[i];
|
|
422
|
+
const io = openRedirects(seg.redirects, opts.cwd);
|
|
423
|
+
allFds.push(...io.toClose);
|
|
424
|
+
const { bin, args, spawnOverrides } = prepareSpawn(seg.argv);
|
|
425
|
+
const stdoutSpec = io.stdoutFd !== null ? io.stdoutFd : "pipe";
|
|
426
|
+
const stderrSpec = io.stderrFd !== null ? io.stderrFd : io.mergeStderrToStdout ? stdoutSpec : "pipe";
|
|
427
|
+
const stdinSpec = io.stdinFd !== null ? io.stdinFd : isFirst ? "ignore" : "pipe";
|
|
428
|
+
const spawnOpts = {
|
|
429
|
+
cwd: opts.cwd,
|
|
430
|
+
shell: false,
|
|
431
|
+
windowsHide: true,
|
|
432
|
+
env,
|
|
433
|
+
stdio: [stdinSpec, stdoutSpec, stderrSpec],
|
|
434
|
+
...spawnOverrides
|
|
435
|
+
};
|
|
436
|
+
let child;
|
|
437
|
+
try {
|
|
438
|
+
child = spawn(bin, args, spawnOpts);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
for (const fd of allFds) tryClose(fd);
|
|
441
|
+
killAll();
|
|
442
|
+
clearTimeout(killTimer);
|
|
443
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
children.push(child);
|
|
447
|
+
if (!isFirst && io.stdinFd === null) {
|
|
448
|
+
const prev = children[i - 1];
|
|
449
|
+
prev.stdout?.on("error", () => {
|
|
450
|
+
});
|
|
451
|
+
child.stdin?.on("error", () => {
|
|
452
|
+
});
|
|
453
|
+
const prevMergesStderr = segments[i - 1].redirects.some((r) => r.kind === "2>&1") && !!prev.stderr;
|
|
454
|
+
if (prevMergesStderr && prev.stderr) {
|
|
455
|
+
prev.stderr.on("error", () => {
|
|
456
|
+
});
|
|
457
|
+
let openSources = 2;
|
|
458
|
+
const closeIfDone = () => {
|
|
459
|
+
if (--openSources === 0) child.stdin?.end();
|
|
460
|
+
};
|
|
461
|
+
prev.stdout?.pipe(child.stdin, { end: false });
|
|
462
|
+
prev.stderr.pipe(child.stdin, { end: false });
|
|
463
|
+
prev.stdout?.once("end", closeIfDone);
|
|
464
|
+
prev.stderr.once("end", closeIfDone);
|
|
465
|
+
} else {
|
|
466
|
+
prev.stdout?.pipe(child.stdin);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (child.stderr && io.stderrFd === null && !(io.mergeStderrToStdout && !isLast)) {
|
|
470
|
+
child.stderr.on("data", (chunk) => opts.buf.push(toBuf(chunk)));
|
|
471
|
+
}
|
|
472
|
+
if (isLast && child.stdout && io.stdoutFd === null) {
|
|
473
|
+
child.stdout.on("data", (chunk) => opts.buf.push(toBuf(chunk)));
|
|
474
|
+
if (io.mergeStderrToStdout && child.stderr && io.stderrFd === null) {
|
|
475
|
+
child.stderr.removeAllListeners("data");
|
|
476
|
+
child.stderr.on("data", (chunk) => opts.buf.push(toBuf(chunk)));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const exits = await Promise.all(
|
|
481
|
+
children.map(
|
|
482
|
+
(c) => new Promise((resolve4) => {
|
|
483
|
+
c.once("error", () => resolve4(null));
|
|
484
|
+
c.once("close", (code) => resolve4(code));
|
|
485
|
+
})
|
|
486
|
+
)
|
|
487
|
+
);
|
|
488
|
+
return { exitCode: exits[exits.length - 1] ?? null, timedOut };
|
|
489
|
+
} finally {
|
|
490
|
+
for (const fd of allFds) tryClose(fd);
|
|
491
|
+
clearTimeout(killTimer);
|
|
492
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function tryClose(fd) {
|
|
496
|
+
try {
|
|
497
|
+
closeSync(fd);
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function toBuf(chunk) {
|
|
502
|
+
return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
503
|
+
}
|
|
504
|
+
var OutputBuffer = class {
|
|
505
|
+
constructor(cap) {
|
|
506
|
+
this.cap = cap;
|
|
507
|
+
}
|
|
508
|
+
cap;
|
|
509
|
+
chunks = [];
|
|
510
|
+
bytes = 0;
|
|
511
|
+
push(b) {
|
|
512
|
+
if (this.bytes >= this.cap) return;
|
|
513
|
+
const remaining = this.cap - this.bytes;
|
|
514
|
+
if (b.length > remaining) {
|
|
515
|
+
this.chunks.push(b.subarray(0, remaining));
|
|
516
|
+
this.bytes = this.cap;
|
|
517
|
+
} else {
|
|
518
|
+
this.chunks.push(b);
|
|
519
|
+
this.bytes += b.length;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
toString() {
|
|
523
|
+
return smartDecodeOutput(Buffer.concat(this.chunks));
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// src/tools/shell/parse.ts
|
|
528
|
+
var BUILTIN_ALLOWLIST = [
|
|
529
|
+
// Repo inspection
|
|
530
|
+
"git status",
|
|
531
|
+
"git diff",
|
|
532
|
+
"git log",
|
|
533
|
+
"git show",
|
|
534
|
+
"git blame",
|
|
535
|
+
"git branch",
|
|
536
|
+
"git remote",
|
|
537
|
+
"git rev-parse",
|
|
538
|
+
"git config --get",
|
|
539
|
+
// Filesystem inspection
|
|
540
|
+
"ls",
|
|
541
|
+
"pwd",
|
|
542
|
+
"cat",
|
|
543
|
+
"head",
|
|
544
|
+
"tail",
|
|
545
|
+
"wc",
|
|
546
|
+
"file",
|
|
547
|
+
"tree",
|
|
548
|
+
"find",
|
|
549
|
+
"grep",
|
|
550
|
+
"rg",
|
|
551
|
+
// Language version probes
|
|
552
|
+
"node --version",
|
|
553
|
+
"node -v",
|
|
554
|
+
"npm --version",
|
|
555
|
+
"npx --version",
|
|
556
|
+
"python --version",
|
|
557
|
+
"python3 --version",
|
|
558
|
+
"cargo --version",
|
|
559
|
+
"go version",
|
|
560
|
+
"rustc --version",
|
|
561
|
+
"deno --version",
|
|
562
|
+
"bun --version",
|
|
563
|
+
// Test runners (non-destructive by convention)
|
|
564
|
+
"npm test",
|
|
565
|
+
"npm run test",
|
|
566
|
+
"npx vitest run",
|
|
567
|
+
"npx vitest",
|
|
568
|
+
"npx jest",
|
|
569
|
+
"pytest",
|
|
570
|
+
"python -m pytest",
|
|
571
|
+
"cargo test",
|
|
572
|
+
"cargo check",
|
|
573
|
+
"cargo clippy",
|
|
574
|
+
"go test",
|
|
575
|
+
"go vet",
|
|
576
|
+
"deno test",
|
|
577
|
+
"bun test",
|
|
578
|
+
// Linters / typecheckers (read-only by convention)
|
|
579
|
+
"npm run lint",
|
|
580
|
+
"npm run typecheck",
|
|
581
|
+
"npx tsc --noEmit",
|
|
582
|
+
"npx biome check",
|
|
583
|
+
"npx eslint",
|
|
584
|
+
"npx prettier --check",
|
|
585
|
+
"ruff",
|
|
586
|
+
"mypy"
|
|
587
|
+
];
|
|
588
|
+
function isDqEscape(prev, next) {
|
|
589
|
+
return prev === "\\" && (next === '"' || next === "\\");
|
|
590
|
+
}
|
|
591
|
+
function tokenizeCommand(cmd) {
|
|
592
|
+
const out = [];
|
|
593
|
+
let cur = "";
|
|
594
|
+
let quote = null;
|
|
595
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
596
|
+
const ch = cmd[i];
|
|
597
|
+
if (quote) {
|
|
598
|
+
if (ch === quote) {
|
|
599
|
+
quote = null;
|
|
600
|
+
} else if (quote === '"' && isDqEscape(ch, cmd[i + 1])) {
|
|
601
|
+
cur += cmd[++i];
|
|
602
|
+
} else {
|
|
603
|
+
cur += ch;
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (ch === '"' || ch === "'") {
|
|
608
|
+
quote = ch;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (ch === " " || ch === " ") {
|
|
612
|
+
if (cur.length > 0) {
|
|
613
|
+
out.push(cur);
|
|
614
|
+
cur = "";
|
|
615
|
+
}
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
cur += ch;
|
|
619
|
+
}
|
|
620
|
+
if (quote) throw new Error(`unclosed ${quote} in command`);
|
|
621
|
+
if (cur.length > 0) out.push(cur);
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
function detectShellOperator(cmd) {
|
|
625
|
+
const opPrefix = /^(?:2>&1|&>|\|{1,2}|&{1,2}|2>{1,2}|>{1,2}|<{1,2})/;
|
|
626
|
+
let cur = "";
|
|
627
|
+
let curQuoted = false;
|
|
628
|
+
let quote = null;
|
|
629
|
+
const check = () => {
|
|
630
|
+
if (cur.length === 0 && !curQuoted) return null;
|
|
631
|
+
if (!curQuoted) {
|
|
632
|
+
const m = opPrefix.exec(cur);
|
|
633
|
+
if (m) return m[0] ?? null;
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
};
|
|
637
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
638
|
+
const ch = cmd[i];
|
|
639
|
+
if (quote) {
|
|
640
|
+
if (ch === quote) {
|
|
641
|
+
quote = null;
|
|
642
|
+
} else if (quote === '"' && isDqEscape(ch, cmd[i + 1])) {
|
|
643
|
+
cur += cmd[++i];
|
|
644
|
+
curQuoted = true;
|
|
645
|
+
} else {
|
|
646
|
+
cur += ch;
|
|
647
|
+
curQuoted = true;
|
|
648
|
+
}
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (ch === '"' || ch === "'") {
|
|
652
|
+
quote = ch;
|
|
653
|
+
curQuoted = true;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (ch === " " || ch === " ") {
|
|
657
|
+
const op = check();
|
|
658
|
+
if (op) return op;
|
|
659
|
+
cur = "";
|
|
660
|
+
curQuoted = false;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
cur += ch;
|
|
664
|
+
}
|
|
665
|
+
if (quote) return null;
|
|
666
|
+
return check();
|
|
667
|
+
}
|
|
668
|
+
var RISKY_ARGS = {
|
|
669
|
+
// Branch / remote mutation
|
|
670
|
+
"git branch": ["-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy", "--force"],
|
|
671
|
+
"git remote": ["add", "remove", "rm", "rename", "set-url", "set-head", "prune"],
|
|
672
|
+
// `--output` writes to an arbitrary path; `--ext-diff` invokes user-config'd external programs.
|
|
673
|
+
"git diff": ["--output", "--ext-diff"],
|
|
674
|
+
"git log": ["--output"],
|
|
675
|
+
"git show": ["--output"],
|
|
676
|
+
// `-exec*` / `-ok*` are RCE; `-delete` and `-fprint*` / `-fls` write to arbitrary paths.
|
|
677
|
+
find: [
|
|
678
|
+
"-delete",
|
|
679
|
+
"-exec",
|
|
680
|
+
"-execdir",
|
|
681
|
+
"-ok",
|
|
682
|
+
"-okdir",
|
|
683
|
+
"-fprint",
|
|
684
|
+
"-fprint0",
|
|
685
|
+
"-fprintf",
|
|
686
|
+
"-fls"
|
|
687
|
+
],
|
|
688
|
+
// `-o FILE` writes the tree to an arbitrary path.
|
|
689
|
+
tree: ["-o"],
|
|
690
|
+
// Auto-fix mutates source files.
|
|
691
|
+
"npx eslint": ["--fix", "--fix-dry-run"],
|
|
692
|
+
"npx biome check": ["--write", "--apply", "--apply-unsafe"],
|
|
693
|
+
ruff: ["--fix", "--unsafe-fixes", "format"]
|
|
694
|
+
};
|
|
695
|
+
function tailHasRisky(tail, risky) {
|
|
696
|
+
for (const a of tail) {
|
|
697
|
+
for (const r of risky) {
|
|
698
|
+
if (a === r) return true;
|
|
699
|
+
if (a.startsWith(`${r}=`)) return true;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
function isAllowed(cmd, extra = []) {
|
|
705
|
+
let argv;
|
|
706
|
+
try {
|
|
707
|
+
argv = tokenizeCommand(cmd);
|
|
708
|
+
} catch {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
if (argv.length === 0) return false;
|
|
712
|
+
const allowlist = [...BUILTIN_ALLOWLIST, ...extra];
|
|
713
|
+
for (const prefix of allowlist) {
|
|
714
|
+
const prefixTokens = prefix.split(" ");
|
|
715
|
+
if (argv.length < prefixTokens.length) continue;
|
|
716
|
+
let match = true;
|
|
717
|
+
for (let i = 0; i < prefixTokens.length; i++) {
|
|
718
|
+
if (argv[i] !== prefixTokens[i]) {
|
|
719
|
+
match = false;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (!match) continue;
|
|
724
|
+
const risky = RISKY_ARGS[prefix];
|
|
725
|
+
if (risky && tailHasRisky(argv.slice(prefixTokens.length), risky)) return false;
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
function isCommandAllowed(cmd, extra = []) {
|
|
731
|
+
let chain;
|
|
732
|
+
try {
|
|
733
|
+
chain = parseCommandChain(cmd);
|
|
734
|
+
} catch {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
if (chain === null) return isAllowed(cmd, extra);
|
|
738
|
+
return chainAllowed(chain, (seg) => isAllowed(seg, extra));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/tools/shell/exec.ts
|
|
742
|
+
var DEFAULT_TIMEOUT_SEC = 60;
|
|
743
|
+
var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
|
|
744
|
+
function killProcessTree(child) {
|
|
745
|
+
if (!child.pid || child.killed) return;
|
|
746
|
+
if (process.platform === "win32") {
|
|
747
|
+
try {
|
|
748
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
749
|
+
stdio: "ignore",
|
|
750
|
+
windowsHide: true
|
|
751
|
+
});
|
|
752
|
+
return;
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
process.kill(-child.pid, "SIGKILL");
|
|
758
|
+
return;
|
|
759
|
+
} catch {
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
child.kill("SIGKILL");
|
|
763
|
+
} catch {
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function runCommand(cmd, opts) {
|
|
767
|
+
const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
|
|
768
|
+
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
769
|
+
const argv = tokenizeCommand(cmd);
|
|
770
|
+
if (argv.length === 0) throw new Error("run_command: empty command");
|
|
771
|
+
const chain = parseCommandChain(cmd);
|
|
772
|
+
if (chain !== null) {
|
|
773
|
+
return await runChain(chain, {
|
|
774
|
+
cwd: opts.cwd,
|
|
775
|
+
timeoutSec,
|
|
776
|
+
maxOutputChars: maxChars,
|
|
777
|
+
signal: opts.signal
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
const timeoutMs = timeoutSec * 1e3;
|
|
781
|
+
const spawnOpts = {
|
|
782
|
+
cwd: opts.cwd,
|
|
783
|
+
shell: false,
|
|
784
|
+
// no shell-expansion — see header comment
|
|
785
|
+
windowsHide: true,
|
|
786
|
+
// PYTHONIOENCODING + PYTHONUTF8 force any spawned Python child
|
|
787
|
+
// (run_command running `python script.py`, etc.) to emit UTF-8
|
|
788
|
+
// on stdout/stderr. Without this, Chinese-Windows defaults
|
|
789
|
+
// Python's stdout encoder to GBK and `print("…")` raises
|
|
790
|
+
// UnicodeEncodeError on emoji / non-GBK chars — the model then
|
|
791
|
+
// sees a Python traceback instead of the script's real output
|
|
792
|
+
// and goes around in circles trying to fix the wrong problem.
|
|
793
|
+
// Harmless on non-Python processes (env vars they don't read).
|
|
794
|
+
env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" }
|
|
795
|
+
};
|
|
796
|
+
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
|
797
|
+
const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
|
|
798
|
+
return await new Promise((resolve4, reject) => {
|
|
799
|
+
let child;
|
|
800
|
+
try {
|
|
801
|
+
child = spawn2(bin, args, effectiveSpawnOpts);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
reject(err);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const chunks = [];
|
|
807
|
+
let totalBytes = 0;
|
|
808
|
+
const byteCap = maxChars * 2 * 4;
|
|
809
|
+
let timedOut = false;
|
|
810
|
+
let aborted = false;
|
|
811
|
+
const killChildTree = () => killProcessTree(child);
|
|
812
|
+
const killTimer = setTimeout(() => {
|
|
813
|
+
timedOut = true;
|
|
814
|
+
killChildTree();
|
|
815
|
+
}, timeoutMs);
|
|
816
|
+
const onAbort = () => {
|
|
817
|
+
aborted = true;
|
|
818
|
+
killChildTree();
|
|
819
|
+
};
|
|
820
|
+
if (opts.signal?.aborted) {
|
|
821
|
+
onAbort();
|
|
822
|
+
} else {
|
|
823
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
824
|
+
}
|
|
825
|
+
const onData = (chunk) => {
|
|
826
|
+
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
827
|
+
if (totalBytes >= byteCap) return;
|
|
828
|
+
const remaining = byteCap - totalBytes;
|
|
829
|
+
if (b.length > remaining) {
|
|
830
|
+
chunks.push(b.subarray(0, remaining));
|
|
831
|
+
totalBytes = byteCap;
|
|
832
|
+
} else {
|
|
833
|
+
chunks.push(b);
|
|
834
|
+
totalBytes += b.length;
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
child.stdout?.on("data", onData);
|
|
838
|
+
child.stderr?.on("data", onData);
|
|
839
|
+
child.on("error", (err) => {
|
|
840
|
+
clearTimeout(killTimer);
|
|
841
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
842
|
+
reject(err);
|
|
843
|
+
});
|
|
844
|
+
child.on("close", (code) => {
|
|
845
|
+
clearTimeout(killTimer);
|
|
846
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
847
|
+
const merged = Buffer.concat(chunks);
|
|
848
|
+
const buf = smartDecodeOutput(merged);
|
|
849
|
+
const output = buf.length > maxChars ? `${buf.slice(0, maxChars)}
|
|
850
|
+
|
|
851
|
+
[\u2026 truncated ${buf.length - maxChars} chars \u2026]` : buf;
|
|
852
|
+
resolve4({ exitCode: code, output, timedOut });
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
function smartDecodeOutput(buf) {
|
|
857
|
+
if (buf.length === 0) return "";
|
|
858
|
+
try {
|
|
859
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
860
|
+
} catch {
|
|
861
|
+
}
|
|
862
|
+
if (process.platform === "win32") {
|
|
863
|
+
try {
|
|
864
|
+
return new TextDecoder("gb18030").decode(buf);
|
|
865
|
+
} catch {
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return buf.toString("utf8");
|
|
869
|
+
}
|
|
870
|
+
function resolveExecutable(cmd, opts = {}) {
|
|
871
|
+
const platform = opts.platform ?? process.platform;
|
|
872
|
+
if (platform !== "win32") return cmd;
|
|
873
|
+
if (!cmd) return cmd;
|
|
874
|
+
if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
|
|
875
|
+
if (pathMod2.extname(cmd)) return cmd;
|
|
876
|
+
const env = opts.env ?? process.env;
|
|
877
|
+
const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
|
|
878
|
+
const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
|
|
879
|
+
const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
|
|
880
|
+
const isFile = opts.isFile ?? defaultIsFile;
|
|
881
|
+
for (const dir of pathDirs) {
|
|
882
|
+
for (const ext of pathExt) {
|
|
883
|
+
const full = pathMod2.win32.join(dir, cmd + ext);
|
|
884
|
+
if (isFile(full)) return full;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return cmd;
|
|
888
|
+
}
|
|
889
|
+
function defaultIsFile(full) {
|
|
890
|
+
try {
|
|
891
|
+
return existsSync(full) && statSync(full).isFile();
|
|
892
|
+
} catch {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
function prepareSpawn(argv, opts = {}) {
|
|
897
|
+
const head = argv[0] ?? "";
|
|
898
|
+
const tail = argv.slice(1);
|
|
899
|
+
const platform = opts.platform ?? process.platform;
|
|
900
|
+
const resolved = resolveExecutable(head, opts);
|
|
901
|
+
if (platform !== "win32") {
|
|
902
|
+
return { bin: resolved, args: [...tail], spawnOverrides: {} };
|
|
903
|
+
}
|
|
904
|
+
if (/\.(cmd|bat)$/i.test(resolved)) {
|
|
905
|
+
const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
|
|
906
|
+
return {
|
|
907
|
+
bin: "cmd.exe",
|
|
908
|
+
args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
|
|
909
|
+
// windowsVerbatimArguments prevents Node from re-quoting the /c
|
|
910
|
+
// payload — we've already composed an exact cmd.exe command
|
|
911
|
+
// line. Without this Node wraps our already-quoted string in
|
|
912
|
+
// another round of quotes and cmd.exe can't parse it.
|
|
913
|
+
spawnOverrides: { windowsVerbatimArguments: true }
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
if (isBareWindowsName(resolved) && resolved === head) {
|
|
917
|
+
const cmdline = [head, ...tail].map(quoteForCmdExe).join(" ");
|
|
918
|
+
return {
|
|
919
|
+
bin: "cmd.exe",
|
|
920
|
+
args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
|
|
921
|
+
spawnOverrides: { windowsVerbatimArguments: true }
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
if (isPowerShellExe(resolved)) {
|
|
925
|
+
const patched = injectPowerShellUtf8(tail);
|
|
926
|
+
if (patched) {
|
|
927
|
+
return { bin: resolved, args: patched, spawnOverrides: {} };
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return { bin: resolved, args: [...tail], spawnOverrides: {} };
|
|
931
|
+
}
|
|
932
|
+
function isPowerShellExe(resolved) {
|
|
933
|
+
return /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i.test(resolved);
|
|
934
|
+
}
|
|
935
|
+
function injectPowerShellUtf8(args) {
|
|
936
|
+
const prelude = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;";
|
|
937
|
+
for (let i = 0; i < args.length; i++) {
|
|
938
|
+
const a = args[i] ?? "";
|
|
939
|
+
if (/^-(?:Command|c)$/i.test(a) && i + 1 < args.length) {
|
|
940
|
+
const out = [...args];
|
|
941
|
+
out[i + 1] = `${prelude}${args[i + 1] ?? ""}`;
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
function withUtf8Codepage(cmdline) {
|
|
948
|
+
return `chcp 65001 >nul & ${cmdline}`;
|
|
949
|
+
}
|
|
950
|
+
function isBareWindowsName(s) {
|
|
951
|
+
if (!s) return false;
|
|
952
|
+
if (s.includes("/") || s.includes("\\")) return false;
|
|
953
|
+
if (pathMod2.isAbsolute(s)) return false;
|
|
954
|
+
if (pathMod2.extname(s)) return false;
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
function quoteForCmdExe(arg) {
|
|
958
|
+
if (arg === "") return '""';
|
|
959
|
+
if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
|
|
960
|
+
return `"${arg.replace(/"/g, '""')}"`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/tools/shell.ts
|
|
964
|
+
function registerShellTools(registry, opts) {
|
|
965
|
+
const rootDir = pathMod3.resolve(opts.rootDir);
|
|
966
|
+
const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
|
|
967
|
+
const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
968
|
+
const jobs = opts.jobs ?? new JobRegistry();
|
|
969
|
+
const getExtraAllowed = typeof opts.extraAllowed === "function" ? opts.extraAllowed : (() => {
|
|
970
|
+
const snapshot2 = opts.extraAllowed ?? [];
|
|
971
|
+
return () => snapshot2;
|
|
972
|
+
})();
|
|
973
|
+
const isAllowAll = typeof opts.allowAll === "function" ? opts.allowAll : () => opts.allowAll === true;
|
|
974
|
+
registry.register({
|
|
975
|
+
name: "run_command",
|
|
976
|
+
description: "Run a shell command in the project root and return its combined stdout+stderr.\n\nConstraints (read these before the first call):\n\u2022 Chain operators `|`, `||`, `&&`, `;` ARE supported \u2014 parsed natively, no shell invoked, so semantics are identical on Windows / macOS / Linux. Each chain segment is allowlist-checked individually: `git status | grep main` runs if both halves are allowed.\n\u2022 File redirects ARE supported: `>` truncate, `>>` append, `<` stdin from file, `2>` / `2>>` stderr to file, `2>&1` merge stderr\u2192stdout, `&>` both to file. Targets resolve relative to the project root. At most one redirect per fd per segment.\n\u2022 Background `&`, heredoc `<<`, command substitution `$(\u2026)`, subshells `(\u2026)`, and process substitution `<(\u2026)` are NOT supported. Wrap a literal `&` arg in quotes; for input use a `<` file or the binary's own --input flag.\n\u2022 Env-var expansion `$VAR` is NOT performed \u2014 `$VAR` is passed as a literal string. Use the binary's own --env flag or substitute the value yourself.\n\u2022 `cd` DOES NOT PERSIST between calls \u2014 each call spawns a fresh process rooted at the project. `cd` also does not persist within parsed chains like `cd dir && command`. Use a command-native cwd flag instead: `npm --prefix <dir> run <script>`, `npm --prefix <dir> exec -- <bin>`, `git -C <dir> ...`, `cargo -C <dir> ...`, `pytest <dir>/tests`.\n\u2022 Glob patterns (`*.ts`) are passed through as literal arguments \u2014 no shell expansion. Use `grep -r`, `rg`, `find -name`, etc.\n\u2022 Avoid commands with unbounded output (`netstat -ano`, `find /`, etc.) \u2014 they waste tokens. Filter at source: `netstat -ano -p TCP`, `find src -name '*.ts'`, `grep -c`, `wc -l`.\n\nCommon read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
|
|
977
|
+
// Plan-mode gate: allow allowlisted commands through (git status,
|
|
978
|
+
// cargo check, ls, grep …) so the model can actually investigate
|
|
979
|
+
// during planning. Anything that would otherwise trigger a
|
|
980
|
+
// confirmation prompt is treated as "not read-only" and bounced.
|
|
981
|
+
readOnlyCheck: (args) => {
|
|
982
|
+
if (isAllowAll()) return true;
|
|
983
|
+
const cmd = typeof args?.command === "string" ? args.command.trim() : "";
|
|
984
|
+
if (!cmd) return false;
|
|
985
|
+
return isCommandAllowed(cmd, getExtraAllowed());
|
|
986
|
+
},
|
|
987
|
+
parameters: {
|
|
988
|
+
type: "object",
|
|
989
|
+
properties: {
|
|
990
|
+
command: {
|
|
991
|
+
type: "string",
|
|
992
|
+
description: 'Full command line. POSIX-ish quoting. Chain operators `|`, `||`, `&&`, `;` and file redirects `>` / `>>` / `<` / `2>` / `2>>` / `2>&1` / `&>` work natively (no shell). Background `&`, heredoc `<<`, env-var expansion `$VAR`, and command substitution `$(\u2026)` are rejected (or passed through as literal in the case of `$VAR`). To pass an operator character as a literal argument (e.g. a regex), wrap it in quotes: `grep "a|b" file.txt`.'
|
|
993
|
+
},
|
|
994
|
+
timeoutSec: {
|
|
995
|
+
type: "integer",
|
|
996
|
+
description: `Override the default ${timeoutSec}s timeout for a single command.`
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
required: ["command"]
|
|
1000
|
+
},
|
|
1001
|
+
fn: async (args, ctx) => {
|
|
1002
|
+
const cmd = args.command.trim();
|
|
1003
|
+
if (!cmd) throw new Error("run_command: empty command");
|
|
1004
|
+
if (!isAllowAll() && !isCommandAllowed(cmd, getExtraAllowed())) {
|
|
1005
|
+
const gate = ctx?.confirmationGate ?? pauseGate;
|
|
1006
|
+
const choice = await gate.ask({ kind: "run_command", payload: { command: cmd } });
|
|
1007
|
+
if (choice.type === "deny") {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
`user denied: ${cmd}${choice.denyContext ? ` \u2014 ${choice.denyContext}` : ""}`
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
if (choice.type === "always_allow") {
|
|
1013
|
+
addProjectShellAllowed(rootDir, choice.prefix);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const effectiveTimeout = Math.max(1, Math.min(600, args.timeoutSec ?? timeoutSec));
|
|
1017
|
+
const result = await runCommand(cmd, {
|
|
1018
|
+
cwd: rootDir,
|
|
1019
|
+
timeoutSec: effectiveTimeout,
|
|
1020
|
+
maxOutputChars,
|
|
1021
|
+
signal: ctx?.signal
|
|
1022
|
+
});
|
|
1023
|
+
return formatCommandResult(cmd, result);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
registry.register({
|
|
1027
|
+
name: "run_background",
|
|
1028
|
+
description: "Spawn a long-running process (dev server, watcher, any command that doesn't naturally exit) and detach. Waits up to `waitSec` seconds for startup (or until the output matches a readiness signal like 'Local:', 'listening on', 'compiled successfully'), then returns the job id + startup preview. The process keeps running; call `job_output` to tail its logs, `stop_job` to kill it, `list_jobs` to see all running jobs.\n\nSame shell constraints as run_command: NO `&&` / `||` / `|` / `;` / `>` / `<` / `2>&1`, `cd` doesn't persist. Dev servers that need a subdirectory: use the tool's own --prefix / --cwd flag. For Vite specifically, `--prefix` on npm only tells npm where package.json is; vite's server root still defaults to process cwd, so pass `vite <project-dir>` or configure via `vite.config.ts` root.\n\nUSE THIS \u2014 not `run_command` \u2014 for: npm/yarn/pnpm run dev, uvicorn / flask run, go run, cargo watch, tsc --watch, webpack serve, anything with 'dev' / 'serve' / 'watch' in the name.",
|
|
1029
|
+
parameters: {
|
|
1030
|
+
type: "object",
|
|
1031
|
+
properties: {
|
|
1032
|
+
command: {
|
|
1033
|
+
type: "string",
|
|
1034
|
+
description: "Full command line. Same quoting rules as run_command (no pipes / redirects / chaining)."
|
|
1035
|
+
},
|
|
1036
|
+
waitSec: {
|
|
1037
|
+
type: "integer",
|
|
1038
|
+
description: "Max seconds to wait for startup before returning. 0..30, default 3. A ready-signal match short-circuits this."
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
required: ["command"]
|
|
1042
|
+
},
|
|
1043
|
+
fn: async (args, ctx) => {
|
|
1044
|
+
const cmd = args.command.trim();
|
|
1045
|
+
if (!cmd) throw new Error("run_background: empty command");
|
|
1046
|
+
if (!isAllowAll() && !isCommandAllowed(cmd, getExtraAllowed())) {
|
|
1047
|
+
const gate = ctx?.confirmationGate ?? pauseGate;
|
|
1048
|
+
const choice = await gate.ask({ kind: "run_background", payload: { command: cmd } });
|
|
1049
|
+
if (choice.type === "deny") {
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
`user denied: ${cmd}${choice.denyContext ? ` \u2014 ${choice.denyContext}` : ""}`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
if (choice.type === "always_allow") {
|
|
1055
|
+
addProjectShellAllowed(rootDir, choice.prefix);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const result = await jobs.start(cmd, {
|
|
1059
|
+
cwd: rootDir,
|
|
1060
|
+
waitSec: args.waitSec,
|
|
1061
|
+
signal: ctx?.signal
|
|
1062
|
+
});
|
|
1063
|
+
return formatJobStart(result);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
registry.register({
|
|
1067
|
+
name: "job_output",
|
|
1068
|
+
description: "Read the latest output of a background job started with `run_background`. By default returns the tail of the buffer (last 80 lines). Pass `since` (the `byteLength` from a previous call) to stream only new content incrementally. Tells you whether the job is still running, so you can stop polling when it's done.",
|
|
1069
|
+
readOnly: true,
|
|
1070
|
+
parallelSafe: true,
|
|
1071
|
+
stormExempt: true,
|
|
1072
|
+
parameters: {
|
|
1073
|
+
type: "object",
|
|
1074
|
+
properties: {
|
|
1075
|
+
jobId: { type: "integer", description: "Job id returned by run_background." },
|
|
1076
|
+
since: {
|
|
1077
|
+
type: "integer",
|
|
1078
|
+
description: "Return only output written past this byte offset (for incremental polling)."
|
|
1079
|
+
},
|
|
1080
|
+
tailLines: {
|
|
1081
|
+
type: "integer",
|
|
1082
|
+
description: "Cap the returned slice to the last N lines. Default 80, 0 = unlimited."
|
|
1083
|
+
}
|
|
1084
|
+
},
|
|
1085
|
+
required: ["jobId"]
|
|
1086
|
+
},
|
|
1087
|
+
fn: async (args) => {
|
|
1088
|
+
const out = jobs.read(args.jobId, {
|
|
1089
|
+
since: args.since,
|
|
1090
|
+
tailLines: args.tailLines ?? 80
|
|
1091
|
+
});
|
|
1092
|
+
if (!out) return `job ${args.jobId}: not found (use list_jobs)`;
|
|
1093
|
+
return formatJobRead(args.jobId, out);
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
registry.register({
|
|
1097
|
+
name: "wait_for_job",
|
|
1098
|
+
description: "Block until a background job exits or produces new output, bounded by `timeoutMs`. Use this instead of polling `job_output` with identical args when you're intentionally waiting for state to change. Returns JSON with `exited`, `exitCode`, and `latestOutput`.",
|
|
1099
|
+
readOnly: true,
|
|
1100
|
+
parameters: {
|
|
1101
|
+
type: "object",
|
|
1102
|
+
properties: {
|
|
1103
|
+
jobId: { type: "integer", description: "Job id returned by run_background." },
|
|
1104
|
+
timeoutMs: {
|
|
1105
|
+
type: "integer",
|
|
1106
|
+
description: "Max time to block before returning if nothing changes. Clamped to 0..30000. Default 5000."
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
required: ["jobId"]
|
|
1110
|
+
},
|
|
1111
|
+
fn: async (args) => {
|
|
1112
|
+
const out = await jobs.waitForJob(args.jobId, { timeoutMs: args.timeoutMs });
|
|
1113
|
+
if (!out) return `job ${args.jobId}: not found (use list_jobs)`;
|
|
1114
|
+
return {
|
|
1115
|
+
jobId: args.jobId,
|
|
1116
|
+
exited: out.exited,
|
|
1117
|
+
exitCode: out.exitCode,
|
|
1118
|
+
latestOutput: out.latestOutput
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
registry.register({
|
|
1123
|
+
name: "stop_job",
|
|
1124
|
+
description: "Stop a background job started with `run_background`. SIGTERM first; SIGKILL after a short grace period if it doesn't exit cleanly. Returns the final output + exit code. Safe to call on an already-exited job.",
|
|
1125
|
+
parameters: {
|
|
1126
|
+
type: "object",
|
|
1127
|
+
properties: {
|
|
1128
|
+
jobId: { type: "integer" }
|
|
1129
|
+
},
|
|
1130
|
+
required: ["jobId"]
|
|
1131
|
+
},
|
|
1132
|
+
fn: async (args) => {
|
|
1133
|
+
const rec = await jobs.stop(args.jobId);
|
|
1134
|
+
if (!rec) return `job ${args.jobId}: not found`;
|
|
1135
|
+
return formatJobStop(rec);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
registry.register({
|
|
1139
|
+
name: "list_jobs",
|
|
1140
|
+
description: "List every background job started this session \u2014 running and exited \u2014 with id, command, pid, status. Use when you've lost track of which job_id corresponds to which process, or to see what's still alive.",
|
|
1141
|
+
readOnly: true,
|
|
1142
|
+
parallelSafe: true,
|
|
1143
|
+
stormExempt: true,
|
|
1144
|
+
parameters: { type: "object", properties: {} },
|
|
1145
|
+
fn: async () => {
|
|
1146
|
+
const all = jobs.list();
|
|
1147
|
+
if (all.length === 0) return "(no background jobs started this session)";
|
|
1148
|
+
return all.map(formatJobRow).join("\n");
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
return registry;
|
|
1152
|
+
}
|
|
1153
|
+
function formatJobStart(r) {
|
|
1154
|
+
const header = r.stillRunning ? `[job ${r.jobId} started \xB7 pid ${r.pid ?? "?"} \xB7 ${r.readyMatched ? "READY signal matched" : "running (no ready signal yet)"}]` : r.exitCode !== null ? `[job ${r.jobId} exited during startup \xB7 exit ${r.exitCode}]` : `[job ${r.jobId} failed to start]`;
|
|
1155
|
+
return r.preview ? `${header}
|
|
1156
|
+
${r.preview}` : header;
|
|
1157
|
+
}
|
|
1158
|
+
function formatJobRead(jobId, r) {
|
|
1159
|
+
const status = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exited ${r.exitCode}` : r.spawnError ? `failed (${r.spawnError})` : "stopped";
|
|
1160
|
+
const header = `[job ${jobId} \xB7 ${status} \xB7 byteLength=${r.byteLength}]
|
|
1161
|
+
$ ${r.command}`;
|
|
1162
|
+
return r.output ? `${header}
|
|
1163
|
+
${r.output}` : header;
|
|
1164
|
+
}
|
|
1165
|
+
function formatJobStop(r) {
|
|
1166
|
+
const running = r.running ? "still running (SIGKILL may be pending)" : `exit ${r.exitCode ?? "?"}`;
|
|
1167
|
+
const tail = tailLines(r.output, 40);
|
|
1168
|
+
const header = `[job ${r.id} stopped \xB7 ${running}]
|
|
1169
|
+
$ ${r.command}`;
|
|
1170
|
+
return tail ? `${header}
|
|
1171
|
+
${tail}` : header;
|
|
1172
|
+
}
|
|
1173
|
+
function formatJobRow(r) {
|
|
1174
|
+
const age = ((Date.now() - r.startedAt) / 1e3).toFixed(1);
|
|
1175
|
+
const state = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exit ${r.exitCode}` : r.spawnError ? "failed" : "stopped";
|
|
1176
|
+
return ` ${String(r.id).padStart(3)} ${state.padEnd(24)} ${age}s ago $ ${r.command}`;
|
|
1177
|
+
}
|
|
1178
|
+
function tailLines(s, n) {
|
|
1179
|
+
if (!s) return "";
|
|
1180
|
+
const lines = s.split("\n");
|
|
1181
|
+
if (lines.length <= n) return s;
|
|
1182
|
+
const dropped = lines.length - n;
|
|
1183
|
+
return [`[\u2026 ${dropped} earlier lines \u2026]`, ...lines.slice(-n)].join("\n");
|
|
1184
|
+
}
|
|
1185
|
+
function formatCommandResult(cmd, r) {
|
|
1186
|
+
const header = r.timedOut ? `$ ${cmd}
|
|
1187
|
+
[killed after timeout]` : `$ ${cmd}
|
|
1188
|
+
[exit ${r.exitCode ?? "?"}]`;
|
|
1189
|
+
return r.output ? `${header}
|
|
1190
|
+
${r.output}` : header;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// src/tools/jobs.ts
|
|
1194
|
+
function killProcessTree2(pid, signal) {
|
|
1195
|
+
if (process.platform === "win32") {
|
|
1196
|
+
const args = ["/pid", String(pid), "/T"];
|
|
1197
|
+
if (signal === "SIGKILL") args.push("/F");
|
|
1198
|
+
try {
|
|
1199
|
+
const killer = spawn3("taskkill", args, {
|
|
1200
|
+
stdio: "ignore",
|
|
1201
|
+
windowsHide: true
|
|
1202
|
+
});
|
|
1203
|
+
killer.on("error", () => {
|
|
1204
|
+
});
|
|
1205
|
+
} catch {
|
|
1206
|
+
}
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
process.kill(-pid, signal);
|
|
1211
|
+
return;
|
|
1212
|
+
} catch {
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
process.kill(pid, signal);
|
|
1216
|
+
} catch {
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
var DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
|
|
1220
|
+
var READY_SIGNALS = [
|
|
1221
|
+
// HTTP server banners
|
|
1222
|
+
/\blistening on\b/i,
|
|
1223
|
+
/\blocal:\s+https?:\/\//i,
|
|
1224
|
+
/\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?\b/i,
|
|
1225
|
+
/\b(?:ready|server started|started server|app listening)\b/i,
|
|
1226
|
+
// Bundlers / compilers
|
|
1227
|
+
/\bcompiled successfully\b/i,
|
|
1228
|
+
/\bbuild complete(?:d)?\b/i,
|
|
1229
|
+
/\bwatching for (?:file )?changes\b/i,
|
|
1230
|
+
/\bready in \d+/i,
|
|
1231
|
+
// Generic
|
|
1232
|
+
/\bstartup (?:complete|finished)\b/i
|
|
1233
|
+
];
|
|
1234
|
+
var JobRegistry = class {
|
|
1235
|
+
jobs = /* @__PURE__ */ new Map();
|
|
1236
|
+
nextId = 1;
|
|
1237
|
+
/** Resolves on (a) ready signal, (b) early exit, or (c) waitSec deadline — child keeps running regardless. */
|
|
1238
|
+
async start(command, opts) {
|
|
1239
|
+
const trimmed = command.trim();
|
|
1240
|
+
if (!trimmed) throw new Error("run_background: empty command");
|
|
1241
|
+
const op = detectShellOperator(trimmed);
|
|
1242
|
+
if (op !== null) {
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
`run_background: shell operator "${op}" is not supported \u2014 spawn one process per background job. Compose via your orchestration, not the shell.`
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
const argv = tokenizeCommand(trimmed);
|
|
1248
|
+
if (argv.length === 0) throw new Error("run_background: empty command");
|
|
1249
|
+
const waitMs = Math.max(0, Math.min(30, opts.waitSec ?? 3)) * 1e3;
|
|
1250
|
+
const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
|
|
1251
|
+
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
|
1252
|
+
const spawnOpts = {
|
|
1253
|
+
cwd: pathMod4.resolve(opts.cwd),
|
|
1254
|
+
shell: false,
|
|
1255
|
+
windowsHide: true,
|
|
1256
|
+
env: process.env,
|
|
1257
|
+
// POSIX: detach so the child becomes its own process-group leader.
|
|
1258
|
+
// Required for `process.kill(-pid, …)` later — without it a group
|
|
1259
|
+
// kill fails and we end up only signaling the wrapper, leaving
|
|
1260
|
+
// grandchildren (node → vite → esbuild …) orphaned.
|
|
1261
|
+
// Windows: detached would spawn a new console window; leave the
|
|
1262
|
+
// default and use taskkill /T for tree termination.
|
|
1263
|
+
detached: process.platform !== "win32",
|
|
1264
|
+
...spawnOverrides
|
|
1265
|
+
};
|
|
1266
|
+
let child;
|
|
1267
|
+
try {
|
|
1268
|
+
child = spawn3(bin, args, spawnOpts);
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
const id2 = this.nextId++;
|
|
1271
|
+
const job2 = {
|
|
1272
|
+
id: id2,
|
|
1273
|
+
command: trimmed,
|
|
1274
|
+
pid: null,
|
|
1275
|
+
startedAt: Date.now(),
|
|
1276
|
+
exitCode: null,
|
|
1277
|
+
output: `[spawn failed] ${err.message}`,
|
|
1278
|
+
totalBytesWritten: 0,
|
|
1279
|
+
running: false,
|
|
1280
|
+
spawnError: err.message,
|
|
1281
|
+
child: null,
|
|
1282
|
+
readyPromise: Promise.resolve(),
|
|
1283
|
+
signalReady: () => {
|
|
1284
|
+
},
|
|
1285
|
+
closedPromise: Promise.resolve(),
|
|
1286
|
+
signalClosed: () => {
|
|
1287
|
+
},
|
|
1288
|
+
outputWaiters: /* @__PURE__ */ new Set()
|
|
1289
|
+
};
|
|
1290
|
+
this.jobs.set(id2, job2);
|
|
1291
|
+
return {
|
|
1292
|
+
jobId: id2,
|
|
1293
|
+
pid: null,
|
|
1294
|
+
stillRunning: false,
|
|
1295
|
+
readyMatched: false,
|
|
1296
|
+
preview: job2.output,
|
|
1297
|
+
exitCode: null
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const id = this.nextId++;
|
|
1301
|
+
let readyResolve = () => {
|
|
1302
|
+
};
|
|
1303
|
+
const readyPromise = new Promise((res) => {
|
|
1304
|
+
readyResolve = res;
|
|
1305
|
+
});
|
|
1306
|
+
let closedResolve = () => {
|
|
1307
|
+
};
|
|
1308
|
+
const closedPromise = new Promise((res) => {
|
|
1309
|
+
closedResolve = res;
|
|
1310
|
+
});
|
|
1311
|
+
const job = {
|
|
1312
|
+
id,
|
|
1313
|
+
command: trimmed,
|
|
1314
|
+
pid: child.pid ?? null,
|
|
1315
|
+
startedAt: Date.now(),
|
|
1316
|
+
exitCode: null,
|
|
1317
|
+
output: "",
|
|
1318
|
+
totalBytesWritten: 0,
|
|
1319
|
+
running: true,
|
|
1320
|
+
child,
|
|
1321
|
+
readyPromise,
|
|
1322
|
+
signalReady: readyResolve,
|
|
1323
|
+
closedPromise,
|
|
1324
|
+
signalClosed: closedResolve,
|
|
1325
|
+
outputWaiters: /* @__PURE__ */ new Set()
|
|
1326
|
+
};
|
|
1327
|
+
this.jobs.set(id, job);
|
|
1328
|
+
let readyMatched = false;
|
|
1329
|
+
let recentForReady = "";
|
|
1330
|
+
const READY_WINDOW = 1024;
|
|
1331
|
+
const onData = (chunk) => {
|
|
1332
|
+
const s = chunk.toString();
|
|
1333
|
+
job.totalBytesWritten += s.length;
|
|
1334
|
+
job.output += s;
|
|
1335
|
+
if (job.output.length > maxBytes) {
|
|
1336
|
+
const overflow = job.output.length - maxBytes;
|
|
1337
|
+
const cut = job.output.indexOf("\n", overflow);
|
|
1338
|
+
const start = cut >= 0 ? cut + 1 : overflow;
|
|
1339
|
+
job.output = `[\u2026 older output dropped \u2026]
|
|
1340
|
+
${job.output.slice(start)}`;
|
|
1341
|
+
}
|
|
1342
|
+
if (!readyMatched) {
|
|
1343
|
+
recentForReady = (recentForReady + s).slice(-READY_WINDOW);
|
|
1344
|
+
for (const re of READY_SIGNALS) {
|
|
1345
|
+
if (re.test(recentForReady)) {
|
|
1346
|
+
readyMatched = true;
|
|
1347
|
+
job.signalReady();
|
|
1348
|
+
break;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (job.outputWaiters.size > 0) {
|
|
1353
|
+
const waiters = [...job.outputWaiters];
|
|
1354
|
+
job.outputWaiters.clear();
|
|
1355
|
+
for (const wake of waiters) wake();
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
child.stdout?.on("data", onData);
|
|
1359
|
+
child.stderr?.on("data", onData);
|
|
1360
|
+
child.on("error", (err) => {
|
|
1361
|
+
job.running = false;
|
|
1362
|
+
job.spawnError = err.message;
|
|
1363
|
+
job.signalReady();
|
|
1364
|
+
job.signalClosed();
|
|
1365
|
+
});
|
|
1366
|
+
child.on("close", (code) => {
|
|
1367
|
+
job.running = false;
|
|
1368
|
+
job.exitCode = code;
|
|
1369
|
+
job.signalReady();
|
|
1370
|
+
job.signalClosed();
|
|
1371
|
+
});
|
|
1372
|
+
const onAbort = () => this.stop(id, { graceMs: 100 });
|
|
1373
|
+
if (opts.signal?.aborted) {
|
|
1374
|
+
onAbort();
|
|
1375
|
+
} else {
|
|
1376
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1377
|
+
}
|
|
1378
|
+
let timer = null;
|
|
1379
|
+
await Promise.race([
|
|
1380
|
+
readyPromise,
|
|
1381
|
+
new Promise((res) => {
|
|
1382
|
+
timer = setTimeout(res, waitMs);
|
|
1383
|
+
})
|
|
1384
|
+
]);
|
|
1385
|
+
if (timer) clearTimeout(timer);
|
|
1386
|
+
return {
|
|
1387
|
+
jobId: id,
|
|
1388
|
+
pid: job.pid,
|
|
1389
|
+
stillRunning: job.running,
|
|
1390
|
+
readyMatched,
|
|
1391
|
+
preview: job.output,
|
|
1392
|
+
exitCode: job.exitCode
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
read(id, opts = {}) {
|
|
1396
|
+
const job = this.jobs.get(id);
|
|
1397
|
+
if (!job) return null;
|
|
1398
|
+
const full = job.output;
|
|
1399
|
+
let slice = full;
|
|
1400
|
+
if (typeof opts.since === "number" && opts.since >= 0 && opts.since < full.length) {
|
|
1401
|
+
slice = full.slice(opts.since);
|
|
1402
|
+
}
|
|
1403
|
+
if (typeof opts.tailLines === "number" && opts.tailLines > 0) {
|
|
1404
|
+
const lines = slice.split("\n");
|
|
1405
|
+
const keep = lines.slice(Math.max(0, lines.length - opts.tailLines));
|
|
1406
|
+
slice = keep.join("\n");
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
output: slice,
|
|
1410
|
+
byteLength: full.length,
|
|
1411
|
+
running: job.running,
|
|
1412
|
+
exitCode: job.exitCode,
|
|
1413
|
+
command: job.command,
|
|
1414
|
+
pid: job.pid,
|
|
1415
|
+
spawnError: job.spawnError
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
async waitForJob(id, opts = {}) {
|
|
1419
|
+
const job = this.jobs.get(id);
|
|
1420
|
+
if (!job) return null;
|
|
1421
|
+
if (!job.running) {
|
|
1422
|
+
return {
|
|
1423
|
+
exited: true,
|
|
1424
|
+
exitCode: job.exitCode,
|
|
1425
|
+
latestOutput: job.output
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
const timeoutMs = Math.max(0, Math.min(3e4, opts.timeoutMs ?? 5e3));
|
|
1429
|
+
const startOutput = job.output;
|
|
1430
|
+
let wakeOutput = null;
|
|
1431
|
+
const outputPromise = new Promise((resolve4) => {
|
|
1432
|
+
wakeOutput = resolve4;
|
|
1433
|
+
job.outputWaiters.add(resolve4);
|
|
1434
|
+
});
|
|
1435
|
+
let timer = null;
|
|
1436
|
+
await Promise.race([
|
|
1437
|
+
job.closedPromise,
|
|
1438
|
+
outputPromise,
|
|
1439
|
+
new Promise((resolve4) => {
|
|
1440
|
+
timer = setTimeout(resolve4, timeoutMs);
|
|
1441
|
+
})
|
|
1442
|
+
]);
|
|
1443
|
+
if (timer) clearTimeout(timer);
|
|
1444
|
+
if (wakeOutput) job.outputWaiters.delete(wakeOutput);
|
|
1445
|
+
return {
|
|
1446
|
+
exited: !job.running,
|
|
1447
|
+
exitCode: job.exitCode,
|
|
1448
|
+
latestOutput: latestOutputSince(startOutput, job.output)
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
/** SIGTERM, wait graceMs, then SIGKILL. Idempotent on already-exited jobs. */
|
|
1452
|
+
async stop(id, opts = {}) {
|
|
1453
|
+
const job = this.jobs.get(id);
|
|
1454
|
+
if (!job) return null;
|
|
1455
|
+
if (!job.running || !job.child) return snapshot(job);
|
|
1456
|
+
const graceMs = Math.max(0, opts.graceMs ?? 2e3);
|
|
1457
|
+
if (job.pid !== null) {
|
|
1458
|
+
killProcessTree2(job.pid, "SIGTERM");
|
|
1459
|
+
} else {
|
|
1460
|
+
try {
|
|
1461
|
+
job.child.kill("SIGTERM");
|
|
1462
|
+
} catch {
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
await Promise.race([job.closedPromise, new Promise((res) => setTimeout(res, graceMs))]);
|
|
1466
|
+
if (job.running) {
|
|
1467
|
+
if (job.pid !== null) {
|
|
1468
|
+
killProcessTree2(job.pid, "SIGKILL");
|
|
1469
|
+
} else {
|
|
1470
|
+
try {
|
|
1471
|
+
job.child.kill("SIGKILL");
|
|
1472
|
+
} catch {
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
await Promise.race([job.closedPromise, new Promise((res) => setTimeout(res, 5e3))]);
|
|
1476
|
+
}
|
|
1477
|
+
return snapshot(job);
|
|
1478
|
+
}
|
|
1479
|
+
list() {
|
|
1480
|
+
return [...this.jobs.values()].map(snapshot);
|
|
1481
|
+
}
|
|
1482
|
+
async shutdown(deadlineMs = 5e3) {
|
|
1483
|
+
const start = Date.now();
|
|
1484
|
+
const runningJobs = [...this.jobs.values()].filter((j) => j.running && j.child);
|
|
1485
|
+
if (runningJobs.length === 0) return;
|
|
1486
|
+
for (const job of runningJobs) {
|
|
1487
|
+
if (job.pid !== null) killProcessTree2(job.pid, "SIGTERM");
|
|
1488
|
+
else
|
|
1489
|
+
try {
|
|
1490
|
+
job.child?.kill("SIGTERM");
|
|
1491
|
+
} catch {
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
const allClose = Promise.all(runningJobs.map((j) => j.readyPromise));
|
|
1495
|
+
const elapsed = () => Date.now() - start;
|
|
1496
|
+
const graceMs = Math.min(1500, Math.max(0, deadlineMs / 2));
|
|
1497
|
+
await Promise.race([allClose, new Promise((res) => setTimeout(res, graceMs))]);
|
|
1498
|
+
for (const job of runningJobs) {
|
|
1499
|
+
if (!job.running) continue;
|
|
1500
|
+
if (job.pid !== null) killProcessTree2(job.pid, "SIGKILL");
|
|
1501
|
+
else
|
|
1502
|
+
try {
|
|
1503
|
+
job.child?.kill("SIGKILL");
|
|
1504
|
+
} catch {
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
const remaining = Math.max(800, deadlineMs - elapsed());
|
|
1508
|
+
await Promise.race([allClose, new Promise((res) => setTimeout(res, remaining))]);
|
|
1509
|
+
}
|
|
1510
|
+
/** Count of still-running jobs — drives the TUI status-bar indicator. */
|
|
1511
|
+
runningCount() {
|
|
1512
|
+
let n = 0;
|
|
1513
|
+
for (const job of this.jobs.values()) if (job.running) n++;
|
|
1514
|
+
return n;
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
function snapshot(job) {
|
|
1518
|
+
return {
|
|
1519
|
+
id: job.id,
|
|
1520
|
+
command: job.command,
|
|
1521
|
+
pid: job.pid,
|
|
1522
|
+
startedAt: job.startedAt,
|
|
1523
|
+
exitCode: job.exitCode,
|
|
1524
|
+
output: job.output,
|
|
1525
|
+
totalBytesWritten: job.totalBytesWritten,
|
|
1526
|
+
running: job.running,
|
|
1527
|
+
spawnError: job.spawnError
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
function latestOutputSince(before, after) {
|
|
1531
|
+
if (!before) return after;
|
|
1532
|
+
if (after.startsWith(before)) return after.slice(before.length);
|
|
1533
|
+
return after;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
export {
|
|
1537
|
+
pauseGate,
|
|
1538
|
+
JobRegistry,
|
|
1539
|
+
BUILTIN_ALLOWLIST,
|
|
1540
|
+
runCommand,
|
|
1541
|
+
registerShellTools,
|
|
1542
|
+
formatCommandResult
|
|
1543
|
+
};
|
|
1544
|
+
//# sourceMappingURL=chunk-W4LDFAZ6.js.map
|