@illuminis/comprism 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/out/agent/command.js +4 -4
  2. package/out/agent/session.d.ts +6 -1
  3. package/out/agent/session.js +17 -8
  4. package/out/commands/agents.js +3 -3
  5. package/out/commands/ask.js +8 -8
  6. package/out/commands/codemap.d.ts +1 -1
  7. package/out/commands/codemap.js +3 -3
  8. package/out/commands/commands-thin.js +4 -4
  9. package/out/commands/config.js +1 -1
  10. package/out/commands/cost.js +4 -4
  11. package/out/commands/hooks.js +1 -1
  12. package/out/commands/install.d.ts +1 -1
  13. package/out/commands/install.js +3 -2
  14. package/out/commands/instructions.js +1 -1
  15. package/out/commands/integrations.js +4 -4
  16. package/out/commands/keys.js +6 -6
  17. package/out/commands/login.js +37 -21
  18. package/out/commands/permissions.js +2 -2
  19. package/out/commands/plugins.js +3 -3
  20. package/out/commands/privacy.js +2 -2
  21. package/out/commands/repl.js +107 -83
  22. package/out/commands/review.js +6 -6
  23. package/out/commands/settings.js +30 -123
  24. package/out/commands/skills.js +2 -2
  25. package/out/commands/unattended.js +6 -6
  26. package/out/commands/update.js +1 -1
  27. package/out/commands/welcome.js +20 -29
  28. package/out/commands/worktrees.d.ts +1 -1
  29. package/out/graph/sync.d.ts +1 -1
  30. package/out/graph/sync.js +6 -6
  31. package/out/lib/attach.js +4 -3
  32. package/out/lib/commandlist.d.ts +1 -1
  33. package/out/lib/commandlist.js +2 -2
  34. package/out/lib/config.d.ts +3 -2
  35. package/out/lib/config.js +7 -8
  36. package/out/lib/connection.js +1 -1
  37. package/out/lib/gateway.d.ts +5 -5
  38. package/out/lib/machine.js +1 -1
  39. package/out/lib/project-ops.d.ts +1 -1
  40. package/out/lib/project-ops.js +4 -4
  41. package/out/lib/queue.js +1 -1
  42. package/out/lib/readiness.d.ts +3 -30
  43. package/out/lib/readiness.js +13 -82
  44. package/out/lib/servicecommand.d.ts +14 -0
  45. package/out/lib/servicecommand.js +74 -0
  46. package/out/lib/sessions.js +3 -3
  47. package/out/lib/ui.d.ts +9 -6
  48. package/out/lib/ui.js +20 -26
  49. package/out/lib/voice.js +1 -1
  50. package/out/lib/words.d.ts +20 -0
  51. package/out/lib/words.js +27 -0
  52. package/out/machine/codemap/build.d.ts +45 -0
  53. package/out/machine/codemap/build.js +91 -0
  54. package/out/machine/codemap/facts.d.ts +47 -0
  55. package/out/machine/codemap/facts.js +12 -0
  56. package/out/machine/codemap/files.d.ts +45 -0
  57. package/out/machine/codemap/files.js +207 -0
  58. package/out/machine/codemap/read-locales.d.ts +29 -0
  59. package/out/machine/codemap/read-locales.js +246 -0
  60. package/out/machine/codemap/read-python.d.ts +11 -0
  61. package/out/machine/codemap/read-python.js +116 -0
  62. package/out/machine/codemap/read-typescript.d.ts +16 -0
  63. package/out/machine/codemap/read-typescript.js +292 -0
  64. package/out/machine/executor/browser.d.ts +14 -0
  65. package/out/machine/executor/browser.js +270 -0
  66. package/out/machine/executor/diagnostics.d.ts +2 -0
  67. package/out/machine/executor/diagnostics.js +181 -0
  68. package/out/machine/executor/documents.d.ts +40 -0
  69. package/out/machine/executor/documents.js +170 -0
  70. package/out/machine/executor/files.d.ts +2 -0
  71. package/out/machine/executor/files.js +590 -0
  72. package/out/machine/executor/git.d.ts +48 -0
  73. package/out/machine/executor/git.js +145 -0
  74. package/out/machine/executor/hooks.d.ts +51 -0
  75. package/out/machine/executor/hooks.js +154 -0
  76. package/out/machine/executor/index.d.ts +45 -0
  77. package/out/machine/executor/index.js +367 -0
  78. package/out/machine/executor/notebook.d.ts +2 -0
  79. package/out/machine/executor/notebook.js +147 -0
  80. package/out/machine/executor/paths.d.ts +20 -0
  81. package/out/machine/executor/paths.js +154 -0
  82. package/out/machine/executor/sandbox.d.ts +40 -0
  83. package/out/machine/executor/sandbox.js +299 -0
  84. package/out/machine/executor/shell.d.ts +86 -0
  85. package/out/machine/executor/shell.js +582 -0
  86. package/out/machine/executor/toolservers.d.ts +20 -0
  87. package/out/machine/executor/toolservers.js +189 -0
  88. package/out/machine/executor/worktree.d.ts +9 -0
  89. package/out/machine/executor/worktree.js +119 -0
  90. package/out/machine/folder/project.d.ts +28 -0
  91. package/out/machine/folder/project.js +114 -0
  92. package/out/machine/runtime/home.d.ts +2 -0
  93. package/out/machine/runtime/home.js +48 -0
  94. package/out/machine/runtime/needs.d.ts +30 -0
  95. package/out/machine/runtime/needs.js +89 -0
  96. package/out/machine/runtime/self.d.ts +23 -0
  97. package/out/machine/runtime/self.js +124 -0
  98. package/out/providers/index.js +2 -1
  99. package/out/thin.js +11 -10
  100. package/package.json +4 -4
@@ -0,0 +1,582 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_TIMEOUT_MS = void 0;
37
+ exports.safeEnvironment = safeEnvironment;
38
+ exports.keptOutput = keptOutput;
39
+ exports.shellFor = shellFor;
40
+ exports.runCommand = runCommand;
41
+ exports.runProgram = runProgram;
42
+ exports.stopForeground = stopForeground;
43
+ exports.startBackground = startBackground;
44
+ exports.readOutput = readOutput;
45
+ exports.stopProcess = stopProcess;
46
+ exports.listProcesses = listProcesses;
47
+ exports.stopEverything = stopEverything;
48
+ exports.guessTestCommand = guessTestCommand;
49
+ exports.runTests = runTests;
50
+ /**
51
+ * Running things on a real machine.
52
+ *
53
+ * Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
54
+ * items D17 (run a command), D18 (run the tests), D19 to D21 (long running
55
+ * processes), D22 (install a dependency) and F12 (a restricted environment).
56
+ *
57
+ * ## Why the tests are their own action
58
+ *
59
+ * `run_tests` could be a shell command and deliberately is not. Its result is
60
+ * the grading signal the whole product is built on: it is how a job knows it is
61
+ * finished, and how the routing engine eventually learns whether a cheaper
62
+ * model actually finished the work. A result buried inside the output of a
63
+ * generic command is a result nobody can count.
64
+ *
65
+ * ## Why the environment is trimmed
66
+ *
67
+ * A command inherits the environment of whatever started it, and on a
68
+ * developer's machine that includes cloud credentials, deployment tokens and
69
+ * signing keys. None of that is needed to run a test suite, and all of it is
70
+ * available to anything the command chooses to run. The agent is not the risk
71
+ * here; a package in the project's own dependency tree is.
72
+ */
73
+ const child_process_1 = require("child_process");
74
+ const fs = __importStar(require("fs"));
75
+ const path = __importStar(require("path"));
76
+ const paths_1 = require("./paths");
77
+ /** What a command may print back into the conversation. Beyond this it is cut,
78
+ * and the cut is stated: a model that believes it saw the whole output, and
79
+ * did not, draws a confident conclusion from half of it. */
80
+ const MAX_OUTPUT_CHARS = 30_000;
81
+ /** Environment variables never passed to a command.
82
+ *
83
+ * Matched by pattern rather than listed by name, because the list is
84
+ * unknowable: every cloud provider, CI system and package registry invents its
85
+ * own, and a list of the ones we happened to think of is a list that is out of
86
+ * date the day it is written. */
87
+ const SECRET_ENV = /(^|_)(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS|AUTH|PRIVATE|SESSION|COOKIE)(_|$)/i;
88
+ /** Kept regardless, because a command that cannot find its own tools is a
89
+ * command that fails for a reason nobody can diagnose. */
90
+ const ALWAYS_KEEP = new Set([
91
+ "PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "TERM", "TMPDIR", "TZ",
92
+ "NODE_ENV", "PWD", "PYTHONPATH", "VIRTUAL_ENV", "NVM_DIR", "JAVA_HOME",
93
+ "GOPATH", "GOROOT", "CARGO_HOME", "RUSTUP_HOME",
94
+ ]);
95
+ function safeEnvironment(from = process.env) {
96
+ const out = {};
97
+ for (const [k, v] of Object.entries(from)) {
98
+ if (ALWAYS_KEEP.has(k) || !SECRET_ENV.test(k))
99
+ out[k] = v;
100
+ }
101
+ // Told, not hidden. A command that behaves differently under the agent should
102
+ // be able to say so, and a person debugging one should be able to see why.
103
+ out.ILLUMINIS_AGENT = "1";
104
+ return out;
105
+ }
106
+ /** Long output keeps its beginning and its end, where a build's first error
107
+ * and a test run's summary are, and says how much of the middle was cut
108
+ * (manual 5.12). */
109
+ function clip(text) {
110
+ if (text.length <= MAX_OUTPUT_CHARS)
111
+ return text;
112
+ const cut = text.length - MAX_OUTPUT_CHARS;
113
+ const head = text.slice(0, MAX_OUTPUT_CHARS / 2);
114
+ const tail = text.slice(text.length - MAX_OUTPUT_CHARS / 2);
115
+ return (`${head}\n\n[... ${cut.toLocaleString()} characters cut from the middle. The first and ` +
116
+ `last parts are shown. Narrow the command, or write the output to a file and read part of it.]\n\n${tail}`);
117
+ }
118
+ /** The default time limit on a command (manual 5.12). */
119
+ exports.DEFAULT_TIMEOUT_MS = 120_000;
120
+ /** How long a command may sit silent after printing what looks like a question
121
+ * before it is taken to be waiting for someone to type (manual 5.12). */
122
+ const WAITING_MS = 8_000;
123
+ const ASK_WORDS = /(password|passphrase|y\/n|yes\/no|continue|proceed|press (enter|any key)|enter |select|choose|overwrite|confirm)/i;
124
+ /** A last line that reads as a question to whoever is at the keyboard. */
125
+ function asksForInput(line) {
126
+ const last = line.trim();
127
+ if (!last)
128
+ return false;
129
+ if (/\?$/.test(last))
130
+ return true;
131
+ return /[:>\])]$/.test(last) && ASK_WORDS.test(last);
132
+ }
133
+ /** Every command's whole output, by its number, for `output N` in a session.
134
+ * Kept in memory for this process only and never sent anywhere. */
135
+ const outputs = new Map();
136
+ let outputCounter = 0;
137
+ const MAX_KEPT = 50;
138
+ function keptOutput(n) {
139
+ return outputs.get(n);
140
+ }
141
+ function keep(command, text) {
142
+ outputCounter += 1;
143
+ outputs.set(outputCounter, { command, text });
144
+ if (outputs.size > MAX_KEPT)
145
+ outputs.delete(Math.min(...outputs.keys()));
146
+ return outputCounter;
147
+ }
148
+ function lineCount(text) {
149
+ if (!text)
150
+ return 0;
151
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
152
+ }
153
+ /** Run a command and wait for it.
154
+ *
155
+ * Never throws for an ordinary failure. A command that exits non-zero is a
156
+ * RESULT: it is usually the most useful thing that can happen, because it is
157
+ * how the agent learns what is wrong.
158
+ */
159
+ /**
160
+ * The shell a command runs in: the system shell on macOS and Linux, and
161
+ * PowerShell on Windows (manual 1.2), which is what a Windows developer types
162
+ * into and what the agent is told it is writing for.
163
+ */
164
+ function shellFor(command) {
165
+ if (process.platform === "win32" && process.env.COMPRISM_WINDOWS_SHELL !== "cmd") {
166
+ // PowerShell 7 where it is installed, because it takes `a && b` as people
167
+ // write it; Windows PowerShell otherwise, which every Windows machine has.
168
+ return [windowsPowerShell(), ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
169
+ "-Command", command], false];
170
+ }
171
+ return [command, [], true];
172
+ }
173
+ let pwshFound;
174
+ function windowsPowerShell() {
175
+ if (pwshFound === undefined) {
176
+ pwshFound = null;
177
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
178
+ const candidate = path.join(dir, "pwsh.exe");
179
+ if (dir && fs.existsSync(candidate)) {
180
+ pwshFound = candidate;
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ return pwshFound ?? "powershell.exe";
186
+ }
187
+ function runCommand(command, opts) {
188
+ const cwd = opts.cwd ? (0, paths_1.resolveInside)(opts.root, opts.cwd, true) : fs.realpathSync(opts.root);
189
+ if (opts.sandbox)
190
+ return sandboxed(command, cwd, opts);
191
+ return collect({ ...opts, label: opts.label ?? command }, () => {
192
+ // Through a shell on purpose: pipes, redirection and `&&` are how people
193
+ // actually write commands, and an agent told it cannot use them writes three
194
+ // commands where a person would write one.
195
+ //
196
+ // Running a command IS this function. It is the agent's shell tool, and a
197
+ // shell tool that cannot reach a shell is not a tool. What stands between a
198
+ // command and the machine is the approval list every command is matched
199
+ // against before it reaches here, plus the working directory being resolved
200
+ // inside the workspace root. Removing the shell would not add safety; it
201
+ // would move the same commands somewhere with fewer checks in front of them.
202
+ // The marker has to be the LAST line before the call, not the first line of
203
+ // the explanation: semgrep only reads the line immediately above.
204
+ const [file, argv, useShell] = shellFor(command);
205
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- runs the command the person approved, which is what this tool is for
206
+ const child = (0, child_process_1.spawn)(file, argv, {
207
+ cwd, shell: useShell,
208
+ env: { ...safeEnvironment(), ...(opts.extraEnv ?? {}) },
209
+ stdio: [opts.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
210
+ // Its own process group, so stopping it stops what the shell started as
211
+ // well (manual 6.7). Not on Windows, where it would open a new window.
212
+ detached: process.platform !== "win32",
213
+ });
214
+ if (opts.input !== undefined && child.stdin) {
215
+ // A command that never reads its input must not fail on it.
216
+ child.stdin.on("error", () => undefined);
217
+ child.stdin.end(opts.input);
218
+ }
219
+ return child;
220
+ });
221
+ }
222
+ /** The same command inside the sandbox, and anything it blocked said as
223
+ * blocked by the sandbox (manual 4.12). */
224
+ async function sandboxed(command, cwd, opts) {
225
+ const box = opts.sandbox;
226
+ box.takeBlocked();
227
+ const w = box.wrap(command, cwd);
228
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- the approved command, wrapped by the sandbox
229
+ const result = await collect({ ...opts, label: opts.label ?? command }, () => (0, child_process_1.spawn)(w.file, w.args, {
230
+ cwd, env: { ...safeEnvironment(), ...(opts.extraEnv ?? {}), ...w.env },
231
+ stdio: ["ignore", "pipe", "pipe"],
232
+ detached: process.platform !== "win32",
233
+ }));
234
+ const why = box.explain(result.content, box.takeBlocked());
235
+ return why ? { ...result, isError: true, summary: why, content: `${why}\n\n${result.content}` } : result;
236
+ }
237
+ /** Run one program with its arguments as a list, and no shell in between.
238
+ *
239
+ * For commands this tool builds itself, such as git. A shell would have to be
240
+ * told how to quote each argument, and the quoting differs by shell: the POSIX
241
+ * single quotes that kept a commit message whole on macOS and Linux reached
242
+ * git on Windows as literal characters, so a commit read `'fix` and a branch
243
+ * name no longer matched. With no shell there is nothing to quote, on any
244
+ * system, and a model written message cannot become a second command. */
245
+ function runProgram(file, args, opts) {
246
+ const cwd = opts.cwd ? (0, paths_1.resolveInside)(opts.root, opts.cwd, true) : fs.realpathSync(opts.root);
247
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- a fixed program with its arguments as a list, no shell
248
+ return collect(opts, () => (0, child_process_1.spawn)(file, args, {
249
+ cwd, env: { ...safeEnvironment(), ...(opts.extraEnv ?? {}) },
250
+ stdio: ["ignore", "pipe", "pipe"],
251
+ }));
252
+ }
253
+ /** Commands running in the foreground right now, so Ctrl+C can stop them
254
+ * (manual 6.7). Each is marked stopped before it is killed, so its result
255
+ * says so rather than reading as an ordinary exit. */
256
+ const foreground = new Map();
257
+ /** Stop every foreground command and everything it started. */
258
+ function stopForeground() {
259
+ for (const [child, mark] of foreground) {
260
+ mark.stopped = true;
261
+ killTree(child);
262
+ }
263
+ }
264
+ function killTree(child) {
265
+ if (process.platform === "win32" && child.pid) {
266
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
267
+ (0, child_process_1.spawn)("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
268
+ return;
269
+ }
270
+ try {
271
+ process.kill(-child.pid, "SIGKILL");
272
+ }
273
+ catch {
274
+ child.kill("SIGKILL");
275
+ }
276
+ }
277
+ function collect(opts, start) {
278
+ const timeoutMs = opts.timeoutMs ?? exports.DEFAULT_TIMEOUT_MS;
279
+ const began = Date.now();
280
+ return new Promise((resolve) => {
281
+ let out = "";
282
+ let done = false;
283
+ const child = start();
284
+ const mark = { stopped: false };
285
+ foreground.set(child, mark);
286
+ let lastOutput = Date.now();
287
+ const collect = (chunk) => {
288
+ const text = chunk.toString();
289
+ out += text;
290
+ lastOutput = Date.now();
291
+ opts.onOutput?.(text);
292
+ };
293
+ child.stdout?.on("data", collect);
294
+ child.stderr?.on("data", collect);
295
+ const finish = (r) => {
296
+ if (done)
297
+ return;
298
+ done = true;
299
+ clearTimeout(timer);
300
+ clearInterval(watch);
301
+ foreground.delete(child);
302
+ const id = keep(opts.label ?? "", out);
303
+ resolve({ ...r, facts: { ...(r.facts ?? {}), output_id: id, lines: lineCount(out),
304
+ seconds: Math.round((Date.now() - began) / 100) / 10 } });
305
+ };
306
+ const timer = setTimeout(() => {
307
+ // The whole process group, not just the shell. Killing the shell leaves
308
+ // whatever it started running, which is how a stuck job leaves a server
309
+ // holding a port after everybody has gone home. On Windows the tree is
310
+ // stopped with taskkill, since there are no process groups.
311
+ killTree(child);
312
+ const secs = Math.round(timeoutMs / 1000);
313
+ finish({
314
+ isError: true,
315
+ content: clip(out) +
316
+ `\n\n[Timed out: stopped after ${secs}s, the time limit. If this command ` +
317
+ "waits for input it will never finish; run it in a way that does not. " +
318
+ "A longer limit is --timeout on the job.]",
319
+ summary: `timed out after ${secs}s`,
320
+ facts: { timed_out: 1, limit_s: secs },
321
+ });
322
+ }, timeoutMs);
323
+ // A command waiting for somebody to type never finishes: stdin is closed,
324
+ // but a program that reads the terminal directly (a password prompt) sits
325
+ // there until the limit. Silence after a question is taken as that.
326
+ const watch = setInterval(() => {
327
+ if (done || Date.now() - lastOutput < WAITING_MS)
328
+ return;
329
+ const last = out.trimEnd().split("\n").pop() ?? "";
330
+ if (!asksForInput(last))
331
+ return;
332
+ killTree(child);
333
+ finish({
334
+ isError: true,
335
+ content: clip(out) +
336
+ "\n\n[Stopped: it was waiting for keyboard input, and nobody can type into a " +
337
+ "command the agent runs. Pass the answer as an option, or run it yourself.]",
338
+ summary: "stopped: waiting for keyboard input",
339
+ facts: { waiting_input: 1 },
340
+ });
341
+ }, 1_000);
342
+ child.on("error", (err) => {
343
+ finish({ isError: true, content: `Could not run it: ${err.message}` });
344
+ });
345
+ child.on("close", (code) => {
346
+ const body = clip(out).trim() || "(no output)";
347
+ if (mark.stopped) {
348
+ finish({
349
+ isError: true,
350
+ content: `${body}\n\n[Stopped by the person. The output above is everything it printed before it was stopped.]`,
351
+ summary: "stopped",
352
+ facts: { stopped: 1 },
353
+ });
354
+ return;
355
+ }
356
+ finish({
357
+ // Non-zero is not an error in the sense that matters here. It is
358
+ // information, and it is usually the most useful information available.
359
+ isError: false,
360
+ content: `exit ${code}\n\n${body}`,
361
+ summary: code === 0 ? "exit 0" : `exit ${code}`,
362
+ facts: { exit: code ?? -1 },
363
+ });
364
+ });
365
+ });
366
+ }
367
+ const running = new Map();
368
+ let counter = 0;
369
+ /** A port a server announces in its output: "port 8765", "localhost:5173". */
370
+ const PORT = /(?:\bport\s+|localhost:|127\.0\.0\.1:|0\.0\.0\.0:|\[::\]:|\/\/[\w.-]+:)(\d{2,5})\b/i;
371
+ function startBackground(command, opts) {
372
+ const cwd = opts.cwd ? (0, paths_1.resolveInside)(opts.root, opts.cwd, true) : fs.realpathSync(opts.root);
373
+ const id = `p${++counter}`;
374
+ // The background half of the same shell tool, with the same approval list and
375
+ // the same workspace root in front of it. See `runCommand` above. Its own
376
+ // process group, so stopping it stops everything it started (manual 5.13).
377
+ const [file, argv, useShell] = shellFor(command);
378
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- runs the command the person approved, which is what this tool is for
379
+ const child = (0, child_process_1.spawn)(file, argv, {
380
+ // Unbuffered, so a Python server's "port 8765" line arrives when it is
381
+ // printed rather than when a buffer fills (read_output, processes).
382
+ cwd, shell: useShell, env: { ...safeEnvironment(), PYTHONUNBUFFERED: "1" },
383
+ stdio: ["ignore", "pipe", "pipe"],
384
+ detached: process.platform !== "win32",
385
+ });
386
+ const entry = { child, buffer: "", read: 0, command, started: Date.now() };
387
+ const collect = (chunk) => {
388
+ const text = chunk.toString();
389
+ entry.buffer += text;
390
+ if (!entry.port) {
391
+ const m = PORT.exec(text);
392
+ if (m)
393
+ entry.port = Number(m[1]);
394
+ }
395
+ // Bounded. A watcher left running for an hour would otherwise hold its whole
396
+ // output in memory, and nobody is going to read the first hour of it.
397
+ if (entry.buffer.length > MAX_OUTPUT_CHARS * 4) {
398
+ entry.buffer = entry.buffer.slice(-MAX_OUTPUT_CHARS * 2);
399
+ entry.read = 0;
400
+ }
401
+ };
402
+ child.stdout?.on("data", collect);
403
+ child.stderr?.on("data", collect);
404
+ running.set(id, entry);
405
+ return {
406
+ content: `Started as ${id}: ${command}\nUse read_output with ${id} to see what it prints, and stop_process to end it.`,
407
+ summary: `started ${id}`,
408
+ facts: { process: counter },
409
+ };
410
+ }
411
+ function alive(entry) {
412
+ return entry.child.exitCode === null && entry.child.signalCode === null;
413
+ }
414
+ function readOutput(id) {
415
+ const entry = running.get(id);
416
+ if (!entry)
417
+ return { isError: true, content: `No process called ${id}. Only processes this session started can be read.` };
418
+ const fresh = entry.buffer.slice(entry.read);
419
+ entry.read = entry.buffer.length;
420
+ const up = alive(entry);
421
+ return {
422
+ content: (fresh.trim() || "(nothing new)") +
423
+ `\n\n[${id} is ${up ? "still running" : `finished, exit ${entry.child.exitCode}`}.]`,
424
+ summary: up ? `${id} running` : `${id} exited`,
425
+ };
426
+ }
427
+ /** Stop one process this tool started, and everything it started, by the
428
+ * number it was given. Never by name or by port (manual 5.13). */
429
+ function stopProcess(id) {
430
+ const entry = running.get(id);
431
+ if (!entry) {
432
+ return { isError: true, content: `No process called ${id}. The agent can stop only processes it started, by their number.` };
433
+ }
434
+ const wasUp = alive(entry);
435
+ if (wasUp) {
436
+ if (process.platform === "win32" && entry.child.pid) {
437
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
438
+ (0, child_process_1.spawn)("taskkill", ["/pid", String(entry.child.pid), "/T", "/F"], { stdio: "ignore" });
439
+ }
440
+ else {
441
+ try {
442
+ process.kill(-entry.child.pid, "SIGTERM");
443
+ }
444
+ catch {
445
+ entry.child.kill("SIGTERM");
446
+ }
447
+ // Whatever ignores the polite request is stopped a moment later.
448
+ const pid = entry.child.pid;
449
+ setTimeout(() => { try {
450
+ process.kill(-pid, "SIGKILL");
451
+ }
452
+ catch { /* already gone */ } }, 1500).unref();
453
+ }
454
+ }
455
+ running.delete(id);
456
+ return {
457
+ content: wasUp ? `Stopped ${id} (${entry.command}) and everything it started.` : `${id} had already finished.`,
458
+ summary: `stopped ${id}`,
459
+ };
460
+ }
461
+ /** What this session started and is still tracking, for `processes`. */
462
+ function listProcesses() {
463
+ return [...running.entries()].map(([id, e]) => ({
464
+ id, command: e.command, running: alive(e), seconds: Math.round((Date.now() - e.started) / 1000),
465
+ ...(e.port ? { port: e.port } : {}),
466
+ }));
467
+ }
468
+ /** Everything still running, stopped, and what was stopped. Called when a
469
+ * session ends, so a job that started a dev server does not leave it holding
470
+ * a port after the person has closed the terminal (manual 5.13). */
471
+ function stopEverything() {
472
+ const stopped = [];
473
+ for (const [id, e] of Array.from(running.entries())) {
474
+ if (alive(e))
475
+ stopped.push(`${id} ${e.command}`);
476
+ stopProcess(id);
477
+ }
478
+ return stopped;
479
+ }
480
+ // ── the tests ───────────────────────────────────────────────────────────────
481
+ /** How this project runs its tests, worked out from what is in it.
482
+ *
483
+ * Ordered by how specific the evidence is. A `test` script in package.json is
484
+ * a statement by the project's own authors; the presence of a pytest.ini is an
485
+ * inference. Guessing wrong is cheap here because the result says what it ran.
486
+ */
487
+ /** Whether this folder holds Python tests, by their conventional names.
488
+ *
489
+ * `test_x.py` and `x_test.py` are the two names pytest collects by default, so
490
+ * a folder containing either is a folder pytest can run. Deliberately shallow:
491
+ * one directory read, no walk, because this is a guess made before every test
492
+ * run and a recursive scan of somebody's repository is not.
493
+ */
494
+ function looksLikePython(root) {
495
+ try {
496
+ return fs.readdirSync(root).some((name) => /^test_.*\.py$/.test(name) || /.*_test\.py$/.test(name));
497
+ }
498
+ catch {
499
+ // An unreadable project directory is a different problem, and it will be
500
+ // reported by whatever tries to read a file next.
501
+ return false;
502
+ }
503
+ }
504
+ /** The Python this machine runs: `python3` where `python` is not installed,
505
+ * which is every recent Mac and most Linux. Guessing `python` there ran
506
+ * nothing and cost the agent a step (exit 127). */
507
+ function python() {
508
+ const dirs = (process.env.PATH ?? "").split(path.delimiter);
509
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
510
+ const has = (name) => dirs.some((d) => exts.some((e) => {
511
+ try {
512
+ return fs.statSync(path.join(d, name + e)).isFile();
513
+ }
514
+ catch {
515
+ return false;
516
+ }
517
+ }));
518
+ return has("python") || !has("python3") ? "python" : "python3";
519
+ }
520
+ function guessTestCommand(root) {
521
+ const has = (f) => fs.existsSync(path.join(root, f));
522
+ const pkgPath = path.join(root, "package.json");
523
+ if (has("package.json")) {
524
+ try {
525
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
526
+ if (pkg.scripts?.test) {
527
+ return has("pnpm-lock.yaml") ? "pnpm test"
528
+ : has("yarn.lock") ? "yarn test" : "npm test";
529
+ }
530
+ }
531
+ catch { /* a malformed package.json is not our problem to report here */ }
532
+ }
533
+ if (has("pytest.ini") || has("pyproject.toml") || has("tests") || has("setup.cfg")) {
534
+ return `${python()} -m pytest -q`;
535
+ }
536
+ // A project identified by its TEST FILES rather than by a configuration file.
537
+ // A plain folder of `test_*.py` with no `pyproject.toml` is an entirely normal
538
+ // Python project and was not recognized, so `run_tests` refused and the model
539
+ // had to work out the command itself. It did, and it cost a step and an
540
+ // approval on a job that was otherwise clean. Found by watching a real run in
541
+ // the desktop app.
542
+ //
543
+ // One shallow directory read, and only when nothing above matched.
544
+ if (looksLikePython(root))
545
+ return `${python()} -m pytest -q`;
546
+ if (has("Cargo.toml"))
547
+ return "cargo test";
548
+ if (has("go.mod"))
549
+ return "go test ./...";
550
+ if (has("Gemfile"))
551
+ return "bundle exec rspec";
552
+ if (has("pom.xml"))
553
+ return "mvn -q test";
554
+ if (has("build.gradle") || has("build.gradle.kts"))
555
+ return "gradle test";
556
+ return null;
557
+ }
558
+ async function runTests(opts) {
559
+ const root = fs.realpathSync(opts.root);
560
+ let command = opts.command?.trim() || guessTestCommand(root) || "";
561
+ if (!command) {
562
+ return {
563
+ isError: true,
564
+ content: "I could not work out how this project runs its tests. Tell me the " +
565
+ "command and I will use it from now on.",
566
+ };
567
+ }
568
+ if (opts.path)
569
+ command = `${command} ${(0, paths_1.resolveInside)(root, opts.path, false)}`;
570
+ const result = await runCommand(command, opts);
571
+ const passed = /^exit 0\b/.test(result.content);
572
+ return {
573
+ ...result,
574
+ passed,
575
+ command,
576
+ // The verdict first, in words, before the output. This result is read by a
577
+ // model deciding whether it has finished, and burying "exit 1" under two
578
+ // hundred lines of test output is how a job declares success on a red suite.
579
+ content: `${passed ? "TESTS PASSED" : "TESTS FAILED"} (${command})\n\n${result.content}`,
580
+ summary: `${passed ? "tests passed" : "tests failed"}: ${command}`,
581
+ };
582
+ }
@@ -0,0 +1,20 @@
1
+ /** The tool servers one job started, by name. */
2
+ export declare class ToolServers {
3
+ private readonly cwd;
4
+ private running;
5
+ constructor(cwd: string);
6
+ private server;
7
+ /** The server's tools, as it described them, as JSON text. */
8
+ list(name: string, command: string): Promise<{
9
+ isError: boolean;
10
+ content: string;
11
+ }>;
12
+ /** One call, and the text the server returned. */
13
+ call(name: string, command: string, tool: string, args: Record<string, unknown>): Promise<{
14
+ isError: boolean;
15
+ content: string;
16
+ }>;
17
+ /** Every server this job started, stopped. Called when the job ends. */
18
+ stopAll(): void;
19
+ get count(): number;
20
+ }