ucode-agent 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +399 -327
  2. package/package.json +6 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/livelog.js +113 -0
  6. package/src/core/loop.js +2105 -1659
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/core/tests.js +86 -0
  10. package/src/tools/blocks.js +117 -0
  11. package/src/tools/browser.js +121 -59
  12. package/src/tools/cache.js +105 -0
  13. package/src/tools/deploy.js +283 -0
  14. package/src/tools/files.js +91 -8
  15. package/src/tools/index.js +634 -495
  16. package/src/tools/rename.js +157 -0
  17. package/src/tools/scaffold.js +85 -6
  18. package/src/tools/shell.js +799 -701
  19. package/src/tools/symbols.js +218 -0
  20. package/src/tools/types.js +179 -0
  21. package/src/ui/activity.js +203 -0
  22. package/src/ui/plain.js +22 -3
  23. package/src/ui/screen.js +65 -19
  24. package/src/ui/theme.js +5 -1
  25. package/templates/blocks/app-shell.tsx +81 -0
  26. package/templates/blocks/data-table.tsx +117 -0
  27. package/templates/blocks/empty-state.tsx +41 -0
  28. package/templates/blocks/page-header.tsx +27 -0
  29. package/templates/blocks/stat-cards.tsx +46 -0
  30. package/templates/next-shadcn/TEMPLATE.md +53 -9
  31. package/templates/next-shadcn/_package-lock.json +1335 -148
  32. package/templates/next-shadcn/components.json +1 -1
  33. package/templates/next-shadcn/next.config.ts +2 -1
  34. package/templates/next-shadcn/package.json +4 -2
  35. package/templates/next-shadcn/presets/citrus.json +77 -0
  36. package/templates/next-shadcn/presets/graphite.json +77 -0
  37. package/templates/next-shadcn/presets/grove.json +77 -0
  38. package/templates/next-shadcn/presets/ocean.json +78 -0
  39. package/templates/next-shadcn/presets/sunset.json +77 -0
  40. package/templates/next-shadcn/presets/violet.json +77 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  45. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  47. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  48. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  49. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  50. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  51. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  52. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  53. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  54. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  55. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  56. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  57. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  58. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  59. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  60. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  61. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  62. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  63. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  64. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  65. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  66. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  67. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  68. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  69. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  70. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  71. package/ucode.js +8 -1
@@ -1,701 +1,799 @@
1
- /**
2
- * shell.js — running commands.
3
- *
4
- * Most of this file is about the ways a spawned command can go wrong in a way
5
- * that hurts the agent rather than the task: one that waits for a keyboard it
6
- * will never get, one that never exits, one that kills the runtime the agent is
7
- * running on, one whose grandchild holds the pipe open so the close event never
8
- * arrives. Each is handled explicitly, because each has exactly one symptom from
9
- * the outside — the agent appears to freeze.
10
- */
11
-
12
- import { spawn } from 'node:child_process';
13
- import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs';
14
- import os from 'node:os';
15
- import path from 'node:path';
16
- import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
18
-
19
- const DEFAULT_TIMEOUT = 120_000;
20
- const MAX_TIMEOUT = 600_000;
21
- const LIVE_LINES = 200;
22
-
23
- /**
24
- * Anything that looks like a dev server. These never finish on their own, so
25
- * waiting for them to is a guaranteed timeout — they are started in the
26
- * background instead, and watched only until they say they are ready.
27
- */
28
- const LOOKS_LIKE_SERVER =
29
- /(\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b|\b(?:vite|next dev|next start|nuxt dev|astro dev|webpack serve|svelte-kit dev|serve)\b|\buvicorn\b|\bgunicorn\b|\bflask run\b|\bdjango[\w-]* runserver\b|\bmanage\.py runserver\b|\brails server\b|\bhttp\.server\b|\bhttp-server\b|\blive-server\b)/i;
30
-
31
- /** A foreground server that was explicitly asked for still gets a short leash. */
32
- const SERVER_TIMEOUT = 30_000;
33
-
34
- /**
35
- * Installs are slow and legitimately so: create-next-app pulls hundreds of
36
- * packages and a cold cargo build compiles the world. Cutting that off at two
37
- * minutes leaves a half-written project on disk, which from the outside looks
38
- * exactly like the agent giving up in the middle. Ten minutes, still bounded.
39
- */
40
- const INSTALL_TIMEOUT = 600_000;
41
- const LOOKS_LIKE_INSTALL =
42
- /(\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|create)\b|\bnpx\s+(?:create-|degit\b|shadcn)|\bpip3?\s+install\b|\bpoetry\s+(?:install|add)\b|\bcargo\s+(?:build|install|fetch)\b|\bgo\s+(?:mod\s+download|get)\b|\bbundle\s+install\b|\bcomposer\s+(?:install|require)\b|\bgit\s+clone\b)/i;
43
-
44
- /**
45
- * Commands that kill a whole class of process rather than one process.
46
- *
47
- * `taskkill /IM node.exe /F` takes down the agent itself — it runs on Node —
48
- * along with the editor, any other servers, and everything else sharing the
49
- * runtime. It is a real failure mode, not a hypothetical one: a dev server
50
- * stops answering, the model tries the right PID, misses, and escalates to
51
- * killing all of Node, at which point it has killed the process it was
52
- * reporting to and hangs until something times out.
53
- *
54
- * Refused with an explanation, so the model can pick the narrow thing instead.
55
- */
56
- const KILLS_EVERYTHING =
57
- /(\btaskkill\b[^|;&]*\/IM\s+(?:node|node\.exe|cmd|cmd\.exe|powershell|powershell\.exe|pwsh|pwsh\.exe)\b|\b(?:killall|pkill)\s+(?:-\w+\s+)*(?:node|nodejs)\b|\bpkill\b[^|;&]*-f\s+(?:node|npm|pnpm)\b|\bStop-Process\b[^|;&]*-Name\s+["']?node)/i;
58
-
59
- // ---------------------------------------------------------------------------
60
- // The environment a command runs in
61
- // ---------------------------------------------------------------------------
62
-
63
- /**
64
- * What every command inherits on top of the user's own environment.
65
- *
66
- * Each line removes a way for a command to be slow or to stall:
67
- *
68
- * CI scaffolders use their defaults instead of asking,
69
- * and test runners run once instead of watching forever
70
- * npm_config_yes npx installs without its "Ok to proceed? (y)"
71
- * fund / audit npm skips two network round trips on every install
72
- * update_notifier and the version check on every invocation
73
- * NEXT_TELEMETRY Next skips its telemetry notice and ping
74
- * NO_COLOR output comes back as text rather than escape codes,
75
- * which the model would otherwise have to read past
76
- */
77
- export function childEnv(base = process.env) {
78
- return {
79
- ...base,
80
- CI: '1',
81
- npm_config_yes: 'true',
82
- npm_config_fund: 'false',
83
- npm_config_audit: 'false',
84
- npm_config_update_notifier: 'false',
85
- // Take a package from the local cache when it is there, instead of asking
86
- // the registry whether a newer copy exists first. Installing the same
87
- // framework for the second app in a day goes from network-bound to disk-bound.
88
- npm_config_prefer_offline: 'true',
89
- NEXT_TELEMETRY_DISABLED: '1',
90
- NO_COLOR: '1',
91
- FORCE_COLOR: '0',
92
- };
93
- }
94
-
95
- /**
96
- * Kill a command and everything it started.
97
- *
98
- * Killing only the shell leaves the dev server it launched still running, and
99
- * on Windows a surviving grandchild that inherited our stdio keeps the pipe
100
- * open — so 'close' never fires and the loop waits forever.
101
- */
102
- export function killTree(pid) {
103
- if (!pid) return;
104
- if (process.platform === 'win32') {
105
- try {
106
- spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
107
- } catch { /* best effort */ }
108
- try { process.kill(pid); } catch { /* already gone */ }
109
- } else {
110
- try { process.kill(-pid, 'SIGKILL'); } catch { /* not a group leader */ }
111
- try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
112
- try { spawn('pkill', ['-P', String(pid), '-9'], { stdio: 'ignore' }); } catch { /* best effort */ }
113
- }
114
- }
115
-
116
- // ---------------------------------------------------------------------------
117
- // Servers
118
- // ---------------------------------------------------------------------------
119
-
120
- const LOG_DIR = path.join(os.tmpdir(), 'ucode-logs');
121
-
122
- /** How long to watch a server for a sign of life before handing back anyway. */
123
- const READY_WAIT = Number(process.env.UCODE_READY_WAIT_MS) || 45_000;
124
-
125
- /** Once a URL has been printed, how long to wait for it to also say "ready". */
126
- const URL_GRACE = 6_000;
127
-
128
- const POLL = 150;
129
-
130
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
131
-
132
- const URL_IN_LOG = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|[\w.-]+\.local)(?::\d{2,5})?(?:\/[^\s'")\]]*)?/i;
133
-
134
- const READY_IN_LOG =
135
- /(\bready in\b|✓\s*ready|\bready\b[^\n]*\d+(?:\.\d+)?\s?m?s\b|compiled successfully|compiled client and server|\blistening (?:on|at)\b|server (?:is )?(?:running|started|listening|ready)|\brunning (?:on|at)\b|started server on|application startup complete|development server is running|serving (?:http|at|on)|available on:)/i;
136
-
137
- /** The URL a person would type: 0.0.0.0 and [::] do not open on Windows. */
138
- function tidyUrl(url) {
139
- return url
140
- .replace(/:\/\/(?:0\.0\.0\.0|\[::1?\])/, '://localhost')
141
- .replace(/\/$/, '');
142
- }
143
-
144
- function readLog(file) {
145
- try {
146
- const size = statSync(file).size;
147
- const text = readFileSync(file, 'utf8');
148
- // Only the recent part matters, and a chatty server can print a lot.
149
- return stripAnsi(size > 65_536 ? text.slice(-65_536) : text);
150
- } catch {
151
- return '';
152
- }
153
- }
154
-
155
- function tail(text, keep = 14) {
156
- const lines = text.trim().split(/\r?\n/).filter(Boolean);
157
- return lines.length <= keep ? lines : [`… ${lines.length - keep} earlier lines`, ...lines.slice(-keep)];
158
- }
159
-
160
- function stopHint(pid) {
161
- return process.platform === 'win32' ? `taskkill /PID ${pid} /T /F` : `kill -- -${pid}`;
162
- }
163
-
164
- /**
165
- * Start something long-running, and come back the moment it is usable.
166
- *
167
- * The old way was to start it and return after a fixed half second, which told
168
- * the model nothing: not whether it had crashed, not which port it chose, not
169
- * whether it was ready. The model then had to probe, usually too early, and a
170
- * slow reasoning model spends most of a minute per probe.
171
- *
172
- * Instead its output goes to a log file — a file, not a pipe, because nobody
173
- * will be reading a pipe once this returns and a full pipe stalls the server —
174
- * and the log is watched until one of three things happens: it prints that it
175
- * is ready, it exits, or the wait runs out. Whichever comes first is reported
176
- * with the URL it is actually listening on.
177
- */
178
- function startServer(command, workdir, { env } = {}) {
179
- return new Promise((resolve, reject) => {
180
- let log;
181
- let fd;
182
- try {
183
- mkdirSync(LOG_DIR, { recursive: true });
184
- log = path.join(LOG_DIR, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}.log`);
185
- fd = openSync(log, 'a');
186
- } catch (err) {
187
- reject(new ToolFailure({
188
- kind: 'log_unwritable',
189
- attempted: `starting "${command}" in the background`,
190
- failed: `Could not create a log file in ${LOG_DIR}: ${err.message}`,
191
- fix: 'Check that the temp directory is writable.',
192
- cause: err,
193
- }));
194
- return;
195
- }
196
-
197
- let child;
198
- try {
199
- child = spawn(command, {
200
- cwd: workdir.abs,
201
- shell: true,
202
- windowsHide: true,
203
- // Not detached on Windows, and this is load-bearing. A detached process
204
- // there has no console, and programs launched under it write nothing
205
- // to a redirected file — measured: every one of node, npm and next
206
- // produced an empty log, so a server's "ready" line never arrived and
207
- // every start waited out the full timer. Attached, the output lands.
208
- // The server itself still outlives ucode: only this shell is tied to
209
- // ucode's job object, and the job lets grandchildren break away.
210
- detached: process.platform !== 'win32',
211
- stdio: ['ignore', fd, fd],
212
- env,
213
- });
214
- } catch (err) {
215
- closeSync(fd);
216
- reject(new ToolFailure({
217
- kind: 'spawn_failed',
218
- attempted: `starting "${command}" in the background`,
219
- failed: `The shell would not start: ${err.message}`,
220
- fix: 'Check the command name and that a shell is on PATH.',
221
- cause: err,
222
- }));
223
- return;
224
- }
225
-
226
- // The child holds its own handle to the log now.
227
- closeSync(fd);
228
- child.unref();
229
-
230
- const started = Date.now();
231
- let exitCode = null;
232
- let spawnError = null;
233
- let urlSeenAt = null;
234
- let done = false;
235
-
236
- child.on('exit', (code) => { exitCode = code ?? -1; });
237
- child.on('error', (err) => { spawnError = err; });
238
-
239
- const finish = (build) => {
240
- if (done) return;
241
- done = true;
242
- clearInterval(timer);
243
- resolve(build());
244
- };
245
-
246
- const describeRun = (lines) => [
247
- ...lines,
248
- `Command: ${command}`,
249
- `Directory: ${workdir.show}`,
250
- `Output log: ${log}`,
251
- ].join('\n');
252
-
253
- const check = () => {
254
- const text = readLog(log);
255
- const seconds = ((Date.now() - started) / 1000).toFixed(1);
256
-
257
- if (spawnError) {
258
- finish(() => {
259
- const out = result(
260
- describeRun([`It could not start: ${spawnError.message}`]),
261
- 'failed to start'
262
- );
263
- out.output = tail(text);
264
- return out;
265
- });
266
- return;
267
- }
268
-
269
- if (exitCode !== null) {
270
- finish(() => {
271
- const out = result(
272
- describeRun([
273
- `It exited with code ${exitCode} after ${seconds}s, before it was ready.`,
274
- '',
275
- text.trim() || '(it printed nothing)',
276
- ]),
277
- `exited ${exitCode} before it was ready`
278
- );
279
- out.exitCode = exitCode;
280
- out.output = tail(text);
281
- return out;
282
- });
283
- return;
284
- }
285
-
286
- const url = URL_IN_LOG.exec(text)?.[0]
287
- ?? (/\bport\s+(\d{2,5})\b/i.exec(text) ? `http://localhost:${/\bport\s+(\d{2,5})\b/i.exec(text)[1]}` : null);
288
- if (url && urlSeenAt === null) urlSeenAt = Date.now();
289
- const ready = READY_IN_LOG.test(text);
290
- const graceOver = urlSeenAt !== null && Date.now() - urlSeenAt >= URL_GRACE;
291
-
292
- if ((ready && url) || graceOver || (ready && Date.now() - started > 1500)) {
293
- finish(() => {
294
- const where = url ? tidyUrl(url) : null;
295
- return result(
296
- describeRun([
297
- `Running in the background as PID ${child.pid}, ready after ${seconds}s.`,
298
- where ? `Open it at ${where}` : 'It did not print a URL; check the log for the port.',
299
- `Stop it with: ${stopHint(child.pid)}`,
300
- '',
301
- 'Do not start it again it is already running.',
302
- ]),
303
- where ? `ready · ${where} · PID ${child.pid}` : `ready · PID ${child.pid}`
304
- );
305
- });
306
- return;
307
- }
308
-
309
- if (Date.now() - started >= READY_WAIT) {
310
- finish(() => {
311
- const out = result(
312
- describeRun([
313
- `Still starting after ${Math.round(READY_WAIT / 1000)}s — running as PID ${child.pid}, ` +
314
- 'but it has not said it is ready yet.',
315
- url ? `It mentioned ${tidyUrl(url)}.` : '',
316
- `Stop it with: ${stopHint(child.pid)}`,
317
- '',
318
- text.trim() ? `Latest output:\n${tail(text).join('\n')}` : '(no output yet)',
319
- ].filter((l) => l !== '')),
320
- `still starting · PID ${child.pid}`
321
- );
322
- out.output = tail(text);
323
- return out;
324
- });
325
- }
326
- };
327
-
328
- const timer = setInterval(check, POLL);
329
- });
330
- }
331
-
332
- // ---------------------------------------------------------------------------
333
- // Installing in the background
334
- // ---------------------------------------------------------------------------
335
-
336
- /**
337
- * Installs already running, by directory.
338
- *
339
- * The moment a package.json with dependencies is written, its install starts
340
- * in the background — while the model is still writing the components. By the
341
- * time it asks to install, build or start the app, the install is usually done
342
- * or nearly so, and whatever wait is left is the remainder rather than the
343
- * whole thing.
344
- */
345
- const installs = new Map();
346
-
347
- /** The package manager a project already uses, going by its lockfile. */
348
- export function packageManagerFor(dir) {
349
- const has = (f) => { try { statSync(path.join(dir, f)); return true; } catch { return false; } };
350
- if (has('pnpm-lock.yaml')) return 'pnpm';
351
- if (has('yarn.lock')) return 'yarn';
352
- if (has('bun.lockb') || has('bun.lock')) return 'bun';
353
- // npm by default, deliberately: pnpm 10+ refuses to run install scripts
354
- // without an interactive approval, which fails the install outright here.
355
- return 'npm';
356
- }
357
-
358
- function runInstall(dir) {
359
- const pm = packageManagerFor(dir);
360
- const command = pm === 'npm' ? 'npm install --no-audit --no-fund' : `${pm} install`;
361
- const started = Date.now();
362
-
363
- const promise = new Promise((resolve) => {
364
- let output = '';
365
- let child;
366
- try {
367
- child = spawn(command, {
368
- cwd: dir, shell: true, windowsHide: true,
369
- stdio: ['ignore', 'pipe', 'pipe'], env: childEnv(),
370
- });
371
- } catch (err) {
372
- resolve({ code: -1, output: err.message, command, seconds: 0 });
373
- return;
374
- }
375
- const take = (chunk) => { if (output.length < MAX_OUTPUT * 2) output += stripAnsi(chunk.toString()); };
376
- child.stdout?.on('data', take);
377
- child.stderr?.on('data', take);
378
- const timer = setTimeout(() => killTree(child.pid), INSTALL_TIMEOUT);
379
- child.on('close', (code) => {
380
- clearTimeout(timer);
381
- resolve({ code, output: output.trim(), command, seconds: Math.round((Date.now() - started) / 1000) });
382
- });
383
- child.on('error', (err) => {
384
- clearTimeout(timer);
385
- resolve({ code: -1, output: err.message, command, seconds: 0 });
386
- });
387
- });
388
-
389
- const entry = { promise, stale: false };
390
- installs.set(dir, entry);
391
- // If package.json changed again while this was running, go once more.
392
- promise.then(() => {
393
- if (installs.get(dir) !== entry) return;
394
- if (entry.stale) runInstall(dir);
395
- else installs.delete(dir);
396
- });
397
- return entry;
398
- }
399
-
400
- /**
401
- * Called whenever a package.json is written. Starts an install if it declares
402
- * dependencies, or marks a running one to go again with the new list.
403
- */
404
- export function packageJsonWritten(file, content) {
405
- let pkg;
406
- try { pkg = JSON.parse(content); } catch { return; }
407
- const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
408
- if (Object.keys(deps).length === 0) return;
409
-
410
- const dir = path.dirname(file);
411
- const running = installs.get(dir);
412
- if (running) running.stale = true;
413
- else runInstall(dir);
414
- }
415
-
416
- /** The install running for this directory or any folder above it, if one is. */
417
- function installFor(dir) {
418
- let at = path.resolve(dir);
419
- for (;;) {
420
- if (installs.has(at)) return { dir: at, entry: installs.get(at) };
421
- const up = path.dirname(at);
422
- if (up === at) return null;
423
- at = up;
424
- }
425
- }
426
-
427
- const PLAIN_INSTALL = /^\s*(?:npm\s+(?:i|install)|pnpm\s+(?:i|install)|yarn(?:\s+install)?|bun\s+(?:i|install))(?:\s+--?[\w-]+(?:=\S+)?)*\s*$/i;
428
-
429
- /**
430
- * Wait for a background install before running something that needs it and
431
- * if the command IS that install, hand back the background one's result
432
- * instead of doing it twice.
433
- */
434
- async function awaitInstall(command, workdir, onOutput) {
435
- // Models often write `cd app && npm run build` instead of passing cwd, so the
436
- // directory a command really runs in is read off the front of it.
437
- const cd = /^\s*cd\s+(?:\/d\s+)?("?)([^"&|;]+?)\1\s*(?:&&|;)\s*/i.exec(command);
438
- const dir = cd ? path.resolve(workdir.abs, cd[2].trim()) : path.resolve(workdir.abs);
439
- const rest = cd ? command.slice(cd[0].length) : command;
440
-
441
- const found = installFor(dir);
442
- if (!found) return null;
443
-
444
- onOutput?.(['waiting for the install that started when package.json was written']);
445
- let done = await found.entry.promise;
446
- // It may have been restarted for a newer package.json; wait for that too.
447
- while (installs.get(found.dir) && installs.get(found.dir) !== found.entry) {
448
- done = await installs.get(found.dir).promise;
449
- }
450
-
451
- if (!PLAIN_INSTALL.test(rest) || path.resolve(found.dir) !== dir) return null;
452
-
453
- const out = result(
454
- `The install already ran in the background as soon as package.json was written ` +
455
- `(\`${done.command}\`, ${done.seconds}s).\n\nexit code: ${done.code}\n\n${done.output || '(no output)'}`,
456
- `already installed in the background · exit ${done.code} · ${done.seconds}s`
457
- );
458
- out.exitCode = done.code;
459
- if (done.code !== 0) out.output = tail(done.output);
460
- return out;
461
- }
462
-
463
- // ---------------------------------------------------------------------------
464
- // run_command
465
- // ---------------------------------------------------------------------------
466
-
467
- export async function runCommand({ command, cwd, timeout_ms, background }, { onOutput } = {}) {
468
- if (typeof command !== 'string' || !command.trim()) {
469
- throw new ToolFailure({
470
- kind: 'bad_args',
471
- attempted: 'running a command',
472
- failed: 'The "command" argument was missing or empty.',
473
- fix: 'Pass the whole command line as one string.',
474
- });
475
- }
476
-
477
- if (KILLS_EVERYTHING.test(command)) {
478
- throw new ToolFailure({
479
- kind: 'suicidal_command',
480
- attempted: `running \`${command.trim().slice(0, 80)}\``,
481
- failed:
482
- 'That kills every process of its kind, which includes the one running this ' +
483
- 'agent the session would end in the middle of the task.',
484
- fix:
485
- 'Kill the single process instead. Start long-running things with background: ' +
486
- 'true, which hands back a PID, then stop that PID by number. For a stuck port, ' +
487
- 'find its owner first: `netstat -ano | findstr :3000` on Windows, ' +
488
- '`lsof -i :3000` elsewhere.',
489
- });
490
- }
491
-
492
- const workdir = cwd
493
- ? resolveIn(cwd, 'run_command', 'cwd')
494
- : { abs: getRoot(), inside: true, show: '.' };
495
- await guard(workdir, `run a command in ${workdir.abs}`);
496
-
497
- const env = childEnv();
498
- const server = LOOKS_LIKE_SERVER.test(command);
499
-
500
- // Anything run where a background install is still going waits for it —
501
- // two installs in one folder corrupt node_modules, and a build before the
502
- // install finishes fails for no reason the model could see.
503
- const alreadyInstalled = await awaitInstall(command, workdir, onOutput);
504
- if (alreadyInstalled) return alreadyInstalled;
505
-
506
- // A dev server is backgrounded whether or not the model remembered to ask.
507
- // Only an explicit `background: false` keeps one in the foreground.
508
- if (background || (server && background !== false)) {
509
- return startServer(command, workdir, { env });
510
- }
511
-
512
- let timeout = Math.min(Math.max(Number(timeout_ms) || DEFAULT_TIMEOUT, 1000), MAX_TIMEOUT);
513
- // An explicit timeout_ms is the caller's decision and is left alone. These
514
- // only adjust the default.
515
- if (!timeout_ms && LOOKS_LIKE_INSTALL.test(command)) timeout = INSTALL_TIMEOUT;
516
- if (!timeout_ms && server) timeout = Math.min(timeout, SERVER_TIMEOUT);
517
-
518
- return new Promise((resolve, reject) => {
519
- let child;
520
- try {
521
- child = spawn(command, {
522
- cwd: workdir.abs,
523
- shell: true,
524
- windowsHide: true,
525
- // No stdin. A command that asks a question gets end-of-input at once
526
- // and either takes its default or fails in a second — instead of
527
- // waiting, on an open pipe nobody writes to, until the timeout.
528
- stdio: ['ignore', 'pipe', 'pipe'],
529
- env,
530
- });
531
- } catch (err) {
532
- reject(new ToolFailure({
533
- kind: 'spawn_failed',
534
- attempted: `running "${command}"`,
535
- failed: `The shell would not start: ${err.message}`,
536
- fix: 'Check the command name and that a shell is on PATH.',
537
- cause: err,
538
- }));
539
- return;
540
- }
541
-
542
- let captured = '';
543
- let timedOut = false;
544
- let done = false;
545
- let timer = null;
546
- let backstop = null;
547
-
548
- // Output is reported while it happens, so a slow build is something you
549
- // watch rather than something you sit through. Whole lines only — a
550
- // partial line waits for its newline — and a \r progress bar collapses to
551
- // its latest state, since a line meant to overwrite itself should not
552
- // scroll past a hundred times.
553
- const live = typeof onOutput === 'function' ? onOutput : null;
554
- let held = '';
555
- let livePrinted = 0;
556
- let liveCapped = false;
557
-
558
- const take = (chunk) => {
559
- const text = stripAnsi(chunk.toString());
560
- // Collect more than will be shown, then trim once at the end.
561
- if (captured.length < MAX_OUTPUT * 4) captured += text;
562
- if (!live || liveCapped) return;
563
-
564
- held += text;
565
- const parts = held.split(/\r?\n/);
566
- held = parts.pop();
567
- if (!parts.length) return;
568
-
569
- const lines = parts.map((l) => l.split('\r').pop());
570
- const room = LIVE_LINES - livePrinted;
571
- if (lines.length > room) {
572
- lines.length = Math.max(0, room);
573
- liveCapped = true;
574
- lines.push('… the rest is in the final output');
575
- }
576
- livePrinted += lines.length;
577
- if (lines.length) live(lines);
578
- };
579
-
580
- child.stdout?.on('data', take);
581
- child.stderr?.on('data', take);
582
-
583
- /**
584
- * The single place this promise resolves. Both the close event and the
585
- * post-kill backstop land here, so nothing can leave it pending: a killed
586
- * shell whose grandchild still holds our stdio never emits 'close', and
587
- * that used to freeze the entire agent.
588
- */
589
- const finish = (code) => {
590
- if (done) return;
591
- done = true;
592
- clearTimeout(timer);
593
- clearTimeout(backstop);
594
- if (live && held.trim() && !liveCapped) live([held.split('\r').pop()]);
595
- held = '';
596
-
597
- const body = captured.trim() || '(no output)';
598
- // What survives on screen: nothing when it worked and the user already
599
- // watched it, the tail when it did not.
600
- const shown = (failed) => (live && !failed && !liveCapped ? [] : tail(body));
601
-
602
- if (timedOut) {
603
- let content = `Timed out after ${Math.round(timeout / 1000)}s and was killed.\n\n${body}`;
604
- if (server || READY_IN_LOG.test(captured)) {
605
- content +=
606
- '\n\nThat looks like a server rather than a command that finishes. Run it ' +
607
- 'again without background: false — ucode starts servers in the background ' +
608
- 'and reports the URL as soon as it is ready.';
609
- } else if (/\?\s*›|\(y\/n\)|\[y\/N\]|press enter|select an option|use arrow keys/i.test(captured)) {
610
- content +=
611
- '\n\nIt looks like it stopped to ask a question. Nothing can answer it — pass ' +
612
- 'the non-interactive flag instead (--yes, -y, --defaults, or the option it asked about).';
613
- }
614
- const out = result(content, `timed out after ${Math.round(timeout / 1000)}s`);
615
- out.output = shown(true);
616
- resolve(out);
617
- return;
618
- }
619
-
620
- const count = captured.trim() ? captured.trim().split(/\r?\n/).length : 0;
621
- const out = result(
622
- `exit code: ${code}\n\n${body}`,
623
- `exit ${code} · ${count} line${count === 1 ? '' : 's'}`
624
- );
625
- out.exitCode = code;
626
- out.output = shown(code !== 0);
627
- resolve(out);
628
- };
629
-
630
- timer = setTimeout(() => {
631
- timedOut = true;
632
- killTree(child.pid);
633
- // The tree kill is best effort; if the pipes stay open, force the
634
- // resolution anyway. The loop has to move on either way.
635
- backstop = setTimeout(() => finish(null), 2500);
636
- }, timeout);
637
-
638
- child.on('error', (err) => {
639
- if (done) return;
640
- done = true;
641
- clearTimeout(timer);
642
- clearTimeout(backstop);
643
- reject(new ToolFailure({
644
- kind: 'spawn_failed',
645
- attempted: `running "${command}"`,
646
- failed: `The command could not run: ${err.message}`,
647
- fix: 'Check the executable name and your PATH.',
648
- cause: err,
649
- }));
650
- });
651
-
652
- child.on('close', (code) => finish(code));
653
- });
654
- }
655
-
656
- /**
657
- * Several commands at once, with a ceiling on how many run together.
658
- *
659
- * Install, build and test are independent often enough to be worth it, and
660
- * three round trips become one.
661
- */
662
- export async function runCommands({ commands, max_parallel = 3 }, opts = {}) {
663
- if (!Array.isArray(commands) || commands.length === 0) {
664
- throw new ToolFailure({
665
- kind: 'bad_args',
666
- attempted: 'running several commands',
667
- failed: 'The "commands" argument must be a non-empty array.',
668
- fix: 'Pass commands as [{ command, cwd?, timeout_ms?, background? }, ...].',
669
- });
670
- }
671
-
672
- const width = Math.min(Math.max(Number(max_parallel) || 3, 1), 10);
673
- const finished = new Array(commands.length);
674
- let next = 0;
675
-
676
- const worker = async () => {
677
- for (;;) {
678
- const i = next++;
679
- if (i >= commands.length) return;
680
- try {
681
- finished[i] = { ok: await runCommand(commands[i] ?? {}, opts) };
682
- } catch (err) {
683
- finished[i] = { err };
684
- }
685
- }
686
- };
687
-
688
- await Promise.all(Array.from({ length: Math.min(width, commands.length) }, worker));
689
-
690
- const blocks = finished.map((r, i) => {
691
- const label = `${i + 1}. ${commands[i]?.command ?? '(missing command)'}`;
692
- if (r.err) return `${label}\nFAILED: ${r.err.failed ?? r.err.message}`;
693
- return `${label}\n${r.ok.content}`;
694
- });
695
-
696
- const summary = finished
697
- .map((r, i) => `${i + 1}. ${r.err ? 'failed' : r.ok.summary}`)
698
- .join(' · ');
699
-
700
- return result(blocks.join('\n\n---\n\n'), `${commands.length} commands: ${summary}`);
701
- }
1
+ /**
2
+ * shell.js — running commands.
3
+ *
4
+ * Most of this file is about the ways a spawned command can go wrong in a way
5
+ * that hurts the agent rather than the task: one that waits for a keyboard it
6
+ * will never get, one that never exits, one that kills the runtime the agent is
7
+ * running on, one whose grandchild holds the pipe open so the close event never
8
+ * arrives. Each is handled explicitly, because each has exactly one symptom from
9
+ * the outside — the agent appears to freeze.
10
+ */
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { ToolFailure } from '../core/failure.js';
17
+ import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
18
+
19
+ const DEFAULT_TIMEOUT = 120_000;
20
+ const MAX_TIMEOUT = 600_000;
21
+ const LIVE_LINES = 200;
22
+
23
+ /**
24
+ * Anything that looks like a dev server. These never finish on their own, so
25
+ * waiting for them to is a guaranteed timeout — they are started in the
26
+ * background instead, and watched only until they say they are ready.
27
+ */
28
+ const LOOKS_LIKE_SERVER =
29
+ /(\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b|\b(?:vite|next dev|next start|nuxt dev|astro dev|webpack serve|svelte-kit dev|serve)\b|\buvicorn\b|\bgunicorn\b|\bflask run\b|\bdjango[\w-]* runserver\b|\bmanage\.py runserver\b|\brails server\b|\bhttp\.server\b|\bhttp-server\b|\blive-server\b)/i;
30
+
31
+ /** A foreground server that was explicitly asked for still gets a short leash. */
32
+ const SERVER_TIMEOUT = 30_000;
33
+
34
+ /**
35
+ * Installs are slow and legitimately so: create-next-app pulls hundreds of
36
+ * packages and a cold cargo build compiles the world. Cutting that off at two
37
+ * minutes leaves a half-written project on disk, which from the outside looks
38
+ * exactly like the agent giving up in the middle. Ten minutes, still bounded.
39
+ */
40
+ const INSTALL_TIMEOUT = 600_000;
41
+ const LOOKS_LIKE_INSTALL =
42
+ /(\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|create)\b|\bnpx\s+(?:create-|degit\b|shadcn)|\bpip3?\s+install\b|\bpoetry\s+(?:install|add)\b|\bcargo\s+(?:build|install|fetch)\b|\bgo\s+(?:mod\s+download|get)\b|\bbundle\s+install\b|\bcomposer\s+(?:install|require)\b|\bgit\s+clone\b)/i;
43
+
44
+ /**
45
+ * Commands that kill a whole class of process rather than one process.
46
+ *
47
+ * `taskkill /IM node.exe /F` takes down the agent itself — it runs on Node —
48
+ * along with the editor, any other servers, and everything else sharing the
49
+ * runtime. It is a real failure mode, not a hypothetical one: a dev server
50
+ * stops answering, the model tries the right PID, misses, and escalates to
51
+ * killing all of Node, at which point it has killed the process it was
52
+ * reporting to and hangs until something times out.
53
+ *
54
+ * Refused with an explanation, so the model can pick the narrow thing instead.
55
+ */
56
+ const KILLS_EVERYTHING =
57
+ /(\btaskkill\b[^|;&]*\/IM\s+(?:node|node\.exe|cmd|cmd\.exe|powershell|powershell\.exe|pwsh|pwsh\.exe)\b|\b(?:killall|pkill)\s+(?:-\w+\s+)*(?:node|nodejs)\b|\bpkill\b[^|;&]*-f\s+(?:node|npm|pnpm)\b|\bStop-Process\b[^|;&]*-Name\s+["']?node)/i;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // The environment a command runs in
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * What every command inherits on top of the user's own environment.
65
+ *
66
+ * Each line removes a way for a command to be slow or to stall:
67
+ *
68
+ * CI scaffolders use their defaults instead of asking,
69
+ * and test runners run once instead of watching forever
70
+ * npm_config_yes npx installs without its "Ok to proceed? (y)"
71
+ * fund / audit npm skips two network round trips on every install
72
+ * update_notifier and the version check on every invocation
73
+ * NEXT_TELEMETRY Next skips its telemetry notice and ping
74
+ * NO_COLOR output comes back as text rather than escape codes,
75
+ * which the model would otherwise have to read past
76
+ */
77
+ export function childEnv(base = process.env) {
78
+ return {
79
+ ...base,
80
+ CI: '1',
81
+ npm_config_yes: 'true',
82
+ npm_config_fund: 'false',
83
+ npm_config_audit: 'false',
84
+ npm_config_update_notifier: 'false',
85
+ // Take a package from the local cache when it is there, instead of asking
86
+ // the registry whether a newer copy exists first. Installing the same
87
+ // framework for the second app in a day goes from network-bound to disk-bound.
88
+ npm_config_prefer_offline: 'true',
89
+ NEXT_TELEMETRY_DISABLED: '1',
90
+ NO_COLOR: '1',
91
+ FORCE_COLOR: '0',
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Kill a command and everything it started.
97
+ *
98
+ * Killing only the shell leaves the dev server it launched still running, and
99
+ * on Windows a surviving grandchild that inherited our stdio keeps the pipe
100
+ * open — so 'close' never fires and the loop waits forever.
101
+ */
102
+ export function killTree(pid) {
103
+ if (!pid) return;
104
+ if (process.platform === 'win32') {
105
+ try {
106
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
107
+ } catch { /* best effort */ }
108
+ try { process.kill(pid); } catch { /* already gone */ }
109
+ } else {
110
+ try { process.kill(-pid, 'SIGKILL'); } catch { /* not a group leader */ }
111
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
112
+ try { spawn('pkill', ['-P', String(pid), '-9'], { stdio: 'ignore' }); } catch { /* best effort */ }
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Servers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ const LOG_DIR = path.join(os.tmpdir(), 'ucode-logs');
121
+
122
+ /** How long to watch a server for a sign of life before handing back anyway. */
123
+ const READY_WAIT = Number(process.env.UCODE_READY_WAIT_MS) || 45_000;
124
+
125
+ /** Once a URL has been printed, how long to wait for it to also say "ready". */
126
+ const URL_GRACE = 6_000;
127
+
128
+ const POLL = 150;
129
+
130
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
131
+
132
+ const URL_IN_LOG = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|[\w.-]+\.local)(?::\d{2,5})?(?:\/[^\s'")\]]*)?/i;
133
+
134
+ const READY_IN_LOG =
135
+ /(\bready in\b|✓\s*ready|\bready\b[^\n]*\d+(?:\.\d+)?\s?m?s\b|compiled successfully|compiled client and server|\blistening (?:on|at)\b|server (?:is )?(?:running|started|listening|ready)|\brunning (?:on|at)\b|started server on|application startup complete|development server is running|serving (?:http|at|on)|available on:)/i;
136
+
137
+ /** The URL a person would type: 0.0.0.0 and [::] do not open on Windows. */
138
+ function tidyUrl(url) {
139
+ return url
140
+ .replace(/:\/\/(?:0\.0\.0\.0|\[::1?\])/, '://localhost')
141
+ .replace(/\/$/, '');
142
+ }
143
+
144
+ function readLog(file) {
145
+ try {
146
+ const size = statSync(file).size;
147
+ const text = readFileSync(file, 'utf8');
148
+ // Only the recent part matters, and a chatty server can print a lot.
149
+ return stripAnsi(size > 65_536 ? text.slice(-65_536) : text);
150
+ } catch {
151
+ return '';
152
+ }
153
+ }
154
+
155
+ function tail(text, keep = 14) {
156
+ const lines = text.trim().split(/\r?\n/).filter(Boolean);
157
+ return lines.length <= keep ? lines : [`… ${lines.length - keep} earlier lines`, ...lines.slice(-keep)];
158
+ }
159
+
160
+ function stopHint(pid) {
161
+ return process.platform === 'win32' ? `taskkill /PID ${pid} /T /F` : `kill -- -${pid}`;
162
+ }
163
+
164
+ /**
165
+ * Start something long-running, and come back the moment it is usable.
166
+ *
167
+ * The old way was to start it and return after a fixed half second, which told
168
+ * the model nothing: not whether it had crashed, not which port it chose, not
169
+ * whether it was ready. The model then had to probe, usually too early, and a
170
+ * slow reasoning model spends most of a minute per probe.
171
+ *
172
+ * Instead its output goes to a log file — a file, not a pipe, because nobody
173
+ * will be reading a pipe once this returns and a full pipe stalls the server —
174
+ * and the log is watched until one of three things happens: it prints that it
175
+ * is ready, it exits, or the wait runs out. Whichever comes first is reported
176
+ * with the URL it is actually listening on.
177
+ */
178
+ /** Dev servers that said they were ready, newest last for opening the app when a turn ends. */
179
+ const readyServers = [];
180
+
181
+ /** Servers that became ready at or after `since` (epoch ms). */
182
+ export function serversReadySince(since = 0) {
183
+ return readyServers.filter((s) => s.at >= since);
184
+ }
185
+
186
+ /** Every server started this session, for reading what they have logged. */
187
+ export function runningServers() {
188
+ return readyServers.slice();
189
+ }
190
+
191
+ function startServer(command, workdir, { env } = {}) {
192
+ return new Promise((resolve, reject) => {
193
+ let log;
194
+ let fd;
195
+ try {
196
+ mkdirSync(LOG_DIR, { recursive: true });
197
+ log = path.join(LOG_DIR, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}.log`);
198
+ fd = openSync(log, 'a');
199
+ } catch (err) {
200
+ reject(new ToolFailure({
201
+ kind: 'log_unwritable',
202
+ attempted: `starting "${command}" in the background`,
203
+ failed: `Could not create a log file in ${LOG_DIR}: ${err.message}`,
204
+ fix: 'Check that the temp directory is writable.',
205
+ cause: err,
206
+ }));
207
+ return;
208
+ }
209
+
210
+ let child;
211
+ try {
212
+ child = spawn(command, {
213
+ cwd: workdir.abs,
214
+ shell: true,
215
+ windowsHide: true,
216
+ // Not detached on Windows, and this is load-bearing. A detached process
217
+ // there has no console, and programs launched under it write nothing
218
+ // to a redirected file — measured: every one of node, npm and next
219
+ // produced an empty log, so a server's "ready" line never arrived and
220
+ // every start waited out the full timer. Attached, the output lands.
221
+ // The server itself still outlives ucode: only this shell is tied to
222
+ // ucode's job object, and the job lets grandchildren break away.
223
+ detached: process.platform !== 'win32',
224
+ stdio: ['ignore', fd, fd],
225
+ env,
226
+ });
227
+ } catch (err) {
228
+ closeSync(fd);
229
+ reject(new ToolFailure({
230
+ kind: 'spawn_failed',
231
+ attempted: `starting "${command}" in the background`,
232
+ failed: `The shell would not start: ${err.message}`,
233
+ fix: 'Check the command name and that a shell is on PATH.',
234
+ cause: err,
235
+ }));
236
+ return;
237
+ }
238
+
239
+ // The child holds its own handle to the log now.
240
+ closeSync(fd);
241
+ child.unref();
242
+
243
+ const started = Date.now();
244
+ let exitCode = null;
245
+ let spawnError = null;
246
+ let urlSeenAt = null;
247
+ let done = false;
248
+
249
+ child.on('exit', (code) => { exitCode = code ?? -1; });
250
+ child.on('error', (err) => { spawnError = err; });
251
+
252
+ const finish = (build) => {
253
+ if (done) return;
254
+ done = true;
255
+ clearInterval(timer);
256
+ resolve(build());
257
+ };
258
+
259
+ const describeRun = (lines) => [
260
+ ...lines,
261
+ `Command: ${command}`,
262
+ `Directory: ${workdir.show}`,
263
+ `Output log: ${log}`,
264
+ ].join('\n');
265
+
266
+ const check = () => {
267
+ const text = readLog(log);
268
+ const seconds = ((Date.now() - started) / 1000).toFixed(1);
269
+
270
+ if (spawnError) {
271
+ finish(() => {
272
+ const out = result(
273
+ describeRun([`It could not start: ${spawnError.message}`]),
274
+ 'failed to start'
275
+ );
276
+ out.output = tail(text);
277
+ return out;
278
+ });
279
+ return;
280
+ }
281
+
282
+ if (exitCode !== null) {
283
+ finish(() => {
284
+ const out = result(
285
+ describeRun([
286
+ `It exited with code ${exitCode} after ${seconds}s, before it was ready.`,
287
+ '',
288
+ text.trim() || '(it printed nothing)',
289
+ ]),
290
+ `exited ${exitCode} before it was ready`
291
+ );
292
+ out.exitCode = exitCode;
293
+ out.output = tail(text);
294
+ return out;
295
+ });
296
+ return;
297
+ }
298
+
299
+ const url = URL_IN_LOG.exec(text)?.[0]
300
+ ?? (/\bport\s+(\d{2,5})\b/i.exec(text) ? `http://localhost:${/\bport\s+(\d{2,5})\b/i.exec(text)[1]}` : null);
301
+ if (url && urlSeenAt === null) urlSeenAt = Date.now();
302
+ const ready = READY_IN_LOG.test(text);
303
+ const graceOver = urlSeenAt !== null && Date.now() - urlSeenAt >= URL_GRACE;
304
+
305
+ if ((ready && url) || graceOver || (ready && Date.now() - started > 1500)) {
306
+ finish(() => {
307
+ const where = url ? tidyUrl(url) : null;
308
+ if (where) readyServers.push({ url: where, pid: child.pid, at: Date.now(), log, command, cwd: workdir });
309
+ return result(
310
+ describeRun([
311
+ `Running in the background as PID ${child.pid}, ready after ${seconds}s.`,
312
+ where ? `Open it at ${where}` : 'It did not print a URL; check the log for the port.',
313
+ `Stop it with: ${stopHint(child.pid)}`,
314
+ '',
315
+ 'Do not start it again it is already running.',
316
+ ]),
317
+ where ? `ready · ${where} · PID ${child.pid}` : `ready · PID ${child.pid}`
318
+ );
319
+ });
320
+ return;
321
+ }
322
+
323
+ if (Date.now() - started >= READY_WAIT) {
324
+ finish(() => {
325
+ const out = result(
326
+ describeRun([
327
+ `Still starting after ${Math.round(READY_WAIT / 1000)}s — running as PID ${child.pid}, ` +
328
+ 'but it has not said it is ready yet.',
329
+ url ? `It mentioned ${tidyUrl(url)}.` : '',
330
+ `Stop it with: ${stopHint(child.pid)}`,
331
+ '',
332
+ text.trim() ? `Latest output:\n${tail(text).join('\n')}` : '(no output yet)',
333
+ ].filter((l) => l !== '')),
334
+ `still starting · PID ${child.pid}`
335
+ );
336
+ out.output = tail(text);
337
+ return out;
338
+ });
339
+ }
340
+ };
341
+
342
+ const timer = setInterval(check, POLL);
343
+ });
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Installing in the background
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /**
351
+ * Installs already running, by directory.
352
+ *
353
+ * The moment a package.json with dependencies is written, its install starts
354
+ * in the background while the model is still writing the components. By the
355
+ * time it asks to install, build or start the app, the install is usually done
356
+ * or nearly so, and whatever wait is left is the remainder rather than the
357
+ * whole thing.
358
+ */
359
+ const installs = new Map();
360
+
361
+ /** The package manager a project already uses, going by its lockfile. */
362
+ export function packageManagerFor(dir) {
363
+ const has = (f) => { try { statSync(path.join(dir, f)); return true; } catch { return false; } };
364
+ if (has('pnpm-lock.yaml')) return 'pnpm';
365
+ if (has('yarn.lock')) return 'yarn';
366
+ if (has('bun.lockb') || has('bun.lock')) return 'bun';
367
+ // npm by default, deliberately: pnpm 10+ refuses to run install scripts
368
+ // without an interactive approval, which fails the install outright here.
369
+ return 'npm';
370
+ }
371
+
372
+ function runInstall(dir) {
373
+ const pm = packageManagerFor(dir);
374
+ const command = pm === 'npm' ? 'npm install --no-audit --no-fund' : `${pm} install`;
375
+ const started = Date.now();
376
+
377
+ const promise = new Promise((resolve) => {
378
+ let output = '';
379
+ let child;
380
+ try {
381
+ child = spawn(command, {
382
+ cwd: dir, shell: true, windowsHide: true,
383
+ stdio: ['ignore', 'pipe', 'pipe'], env: childEnv(),
384
+ });
385
+ } catch (err) {
386
+ resolve({ code: -1, output: err.message, command, seconds: 0 });
387
+ return;
388
+ }
389
+ const take = (chunk) => { if (output.length < MAX_OUTPUT * 2) output += stripAnsi(chunk.toString()); };
390
+ child.stdout?.on('data', take);
391
+ child.stderr?.on('data', take);
392
+ const timer = setTimeout(() => killTree(child.pid), INSTALL_TIMEOUT);
393
+ child.on('close', (code) => {
394
+ clearTimeout(timer);
395
+ resolve({ code, output: output.trim(), command, seconds: Math.round((Date.now() - started) / 1000) });
396
+ });
397
+ child.on('error', (err) => {
398
+ clearTimeout(timer);
399
+ resolve({ code: -1, output: err.message, command, seconds: 0 });
400
+ });
401
+ });
402
+
403
+ const entry = { promise, stale: false };
404
+ installs.set(dir, entry);
405
+ // If package.json changed again while this was running, go once more.
406
+ promise.then(() => {
407
+ if (installs.get(dir) !== entry) return;
408
+ if (entry.stale) runInstall(dir);
409
+ else installs.delete(dir);
410
+ });
411
+ return entry;
412
+ }
413
+
414
+ /**
415
+ * Called whenever a package.json is written. Starts an install if it declares
416
+ * dependencies, or marks a running one to go again with the new list.
417
+ */
418
+ export function packageJsonWritten(file, content) {
419
+ let pkg;
420
+ try { pkg = JSON.parse(content); } catch { return; }
421
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
422
+ if (Object.keys(deps).length === 0) return;
423
+
424
+ const dir = path.dirname(file);
425
+ const running = installs.get(dir);
426
+ if (running) running.stale = true;
427
+ else runInstall(dir);
428
+ }
429
+
430
+ /** The install running in exactly this folder, to follow it to its end. */
431
+ export function installIn(dir) {
432
+ return installs.get(path.resolve(dir))?.promise ?? null;
433
+ }
434
+
435
+ /** The install running for this directory or any folder above it, if one is. */
436
+ function installFor(dir) {
437
+ let at = path.resolve(dir);
438
+ for (;;) {
439
+ if (installs.has(at)) return { dir: at, entry: installs.get(at) };
440
+ const up = path.dirname(at);
441
+ if (up === at) return null;
442
+ at = up;
443
+ }
444
+ }
445
+
446
+ const PLAIN_INSTALL = /^\s*(?:npm\s+(?:i|install)|pnpm\s+(?:i|install)|yarn(?:\s+install)?|bun\s+(?:i|install))(?:\s+--?[\w-]+(?:=\S+)?)*\s*$/i;
447
+
448
+ /**
449
+ * Wait for a background install before running something that needs it — and
450
+ * if the command IS that install, hand back the background one's result
451
+ * instead of doing it twice.
452
+ */
453
+ async function awaitInstall(command, workdir, onOutput) {
454
+ // Models often write `cd app && npm run build` instead of passing cwd, so the
455
+ // directory a command really runs in is read off the front of it.
456
+ const cd = /^\s*cd\s+(?:\/d\s+)?("?)([^"&|;]+?)\1\s*(?:&&|;)\s*/i.exec(command);
457
+ const dir = cd ? path.resolve(workdir.abs, cd[2].trim()) : path.resolve(workdir.abs);
458
+ const rest = cd ? command.slice(cd[0].length) : command;
459
+
460
+ const found = installFor(dir);
461
+ if (!found) return null;
462
+
463
+ onOutput?.(['waiting for the install that started when package.json was written']);
464
+ let done = await found.entry.promise;
465
+ // It may have been restarted for a newer package.json; wait for that too.
466
+ while (installs.get(found.dir) && installs.get(found.dir) !== found.entry) {
467
+ done = await installs.get(found.dir).promise;
468
+ }
469
+
470
+ if (!PLAIN_INSTALL.test(rest) || path.resolve(found.dir) !== dir) return null;
471
+
472
+ const out = result(
473
+ `The install already ran in the background as soon as package.json was written ` +
474
+ `(\`${done.command}\`, ${done.seconds}s).\n\nexit code: ${done.code}\n\n${done.output || '(no output)'}`,
475
+ `already installed in the background · exit ${done.code} · ${done.seconds}s`
476
+ );
477
+ out.exitCode = done.code;
478
+ if (done.code !== 0) out.output = tail(done.output);
479
+ return out;
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // What a failed build is really asking for
484
+ // ---------------------------------------------------------------------------
485
+
486
+ /**
487
+ * Plain next steps for the build failures that send a model down a hole.
488
+ *
489
+ * Measured: a missing shadcn component failed the build, and instead of
490
+ * adding it the model spent a dozen steps listing node_modules, then deleted
491
+ * the app and started over. The error names exactly what is missing; this
492
+ * turns that into the one command that fixes it.
493
+ */
494
+ export function buildHints(output, dir = null) {
495
+ const hints = [];
496
+ const seen = new Set();
497
+ const add = (key, text) => {
498
+ if (seen.has(key)) return;
499
+ seen.add(key);
500
+ hints.push(text);
501
+ };
502
+
503
+ const UI = /(?:Can't resolve|Cannot find module) '@\/components\/ui\/([\w-]+)'/g;
504
+ for (const m of output.matchAll(UI)) {
505
+ add(`ui:${m[1]}`, `The shadcn component "${m[1]}" is not in this project. Add it with ` +
506
+ `\`npx shadcn@latest add ${m[1]} -y\` (cwd: the app folder), then build again. ` +
507
+ 'Do not look inside node_modules.');
508
+ }
509
+
510
+ const PKG = /(?:Can't resolve|Cannot find module) '((?:@[\w.-]+\/)?[\w.-]+)(\/[^']*)?'/g;
511
+ for (const m of output.matchAll(PKG)) {
512
+ const [, pkg, subpath] = m;
513
+ if (pkg.startsWith('.')) continue;
514
+ if (subpath && dir && installed(dir, pkg)) {
515
+ // Installed, but that path inside it does not exist: an import copied
516
+ // from an older version. Installing it again changes nothing.
517
+ add(`sub:${pkg}${subpath}`, `"${pkg}" is installed, but "${pkg}${subpath}" does not exist — ` +
518
+ `that path is from an older version. Import from "${pkg}" itself` +
519
+ (pkg === 'next-themes'
520
+ ? '; for the provider\'s props use `React.ComponentProps<typeof NextThemesProvider>`.'
521
+ : ', or check its package.json "exports" for the right path.') +
522
+ ' Do not reinstall it.');
523
+ continue;
524
+ }
525
+ add(`pkg:${pkg}`, `The package "${pkg}" is not installed. Install it with ` +
526
+ `\`npm install ${pkg}\` (cwd: the app folder), then build again.`);
527
+ }
528
+
529
+ if (/Parsing ecmascript source code failed|Expression expected|Unexpected token/.test(output)) {
530
+ add('syntax', 'A file does not parse. Open the file and line the error names, fix that ' +
531
+ 'syntax, then build again.');
532
+ }
533
+
534
+ return hints;
535
+ }
536
+
537
+ /** Is this package installed for the project at dir (or a folder above it)? */
538
+ function installed(dir, pkg) {
539
+ for (let at = path.resolve(dir); ; at = path.dirname(at)) {
540
+ try {
541
+ statSync(path.join(at, 'node_modules', pkg, 'package.json'));
542
+ return true;
543
+ } catch { /* not here */ }
544
+ if (path.dirname(at) === at) return false;
545
+ }
546
+ }
547
+
548
+ /** The folder a command really runs in: its cwd, moved by a leading `cd x &&`. */
549
+ function effectiveDir(command, workdir) {
550
+ const cd = /^\s*cd\s+(?:\/d\s+)?("?)([^"&|;]+?)\1\s*(?:&&|;)\s*/i.exec(command);
551
+ return cd ? path.resolve(workdir.abs, cd[2].trim()) : path.resolve(workdir.abs);
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // run_command
556
+ // ---------------------------------------------------------------------------
557
+
558
+ export async function runCommand({ command, cwd, timeout_ms, background }, { onOutput } = {}) {
559
+ if (typeof command !== 'string' || !command.trim()) {
560
+ throw new ToolFailure({
561
+ kind: 'bad_args',
562
+ attempted: 'running a command',
563
+ failed: 'The "command" argument was missing or empty.',
564
+ fix: 'Pass the whole command line as one string.',
565
+ });
566
+ }
567
+
568
+ // `sleep 3 && curl localhost:3000` after the server already reported ready
569
+ // is a wait for nothing. Measured on a real build; the sleep is dropped.
570
+ const napping = /^\s*(?:sleep\s+\d+(?:\.\d+)?|timeout\s+\/t\s+\d+(?:\s+\/nobreak)?)\s*(?:&&|;)\s*/i.exec(command);
571
+ if (napping && readyServers.length) command = command.slice(napping[0].length);
572
+
573
+ if (KILLS_EVERYTHING.test(command)) {
574
+ throw new ToolFailure({
575
+ kind: 'suicidal_command',
576
+ attempted: `running \`${command.trim().slice(0, 80)}\``,
577
+ failed:
578
+ 'That kills every process of its kind, which includes the one running this ' +
579
+ 'agent — the session would end in the middle of the task.',
580
+ fix:
581
+ 'Kill the single process instead. Start long-running things with background: ' +
582
+ 'true, which hands back a PID, then stop that PID by number. For a stuck port, ' +
583
+ 'find its owner first: `netstat -ano | findstr :3000` on Windows, ' +
584
+ '`lsof -i :3000` elsewhere.',
585
+ });
586
+ }
587
+
588
+ const workdir = cwd
589
+ ? resolveIn(cwd, 'run_command', 'cwd')
590
+ : { abs: getRoot(), inside: true, show: '.' };
591
+ await guard(workdir, `run a command in ${workdir.abs}`);
592
+
593
+ const env = childEnv();
594
+ const server = LOOKS_LIKE_SERVER.test(command);
595
+
596
+ // Anything run where a background install is still going waits for it —
597
+ // two installs in one folder corrupt node_modules, and a build before the
598
+ // install finishes fails for no reason the model could see.
599
+ const alreadyInstalled = await awaitInstall(command, workdir, onOutput);
600
+ if (alreadyInstalled) return alreadyInstalled;
601
+
602
+ // A dev server is backgrounded whether or not the model remembered to ask.
603
+ // Only an explicit `background: false` keeps one in the foreground.
604
+ if (background || (server && background !== false)) {
605
+ return startServer(command, workdir, { env });
606
+ }
607
+
608
+ let timeout = Math.min(Math.max(Number(timeout_ms) || DEFAULT_TIMEOUT, 1000), MAX_TIMEOUT);
609
+ // An explicit timeout_ms is the caller's decision and is left alone. These
610
+ // only adjust the default.
611
+ if (!timeout_ms && LOOKS_LIKE_INSTALL.test(command)) timeout = INSTALL_TIMEOUT;
612
+ if (!timeout_ms && server) timeout = Math.min(timeout, SERVER_TIMEOUT);
613
+
614
+ return new Promise((resolve, reject) => {
615
+ let child;
616
+ try {
617
+ child = spawn(command, {
618
+ cwd: workdir.abs,
619
+ shell: true,
620
+ windowsHide: true,
621
+ // No stdin. A command that asks a question gets end-of-input at once
622
+ // and either takes its default or fails in a second — instead of
623
+ // waiting, on an open pipe nobody writes to, until the timeout.
624
+ stdio: ['ignore', 'pipe', 'pipe'],
625
+ env,
626
+ });
627
+ } catch (err) {
628
+ reject(new ToolFailure({
629
+ kind: 'spawn_failed',
630
+ attempted: `running "${command}"`,
631
+ failed: `The shell would not start: ${err.message}`,
632
+ fix: 'Check the command name and that a shell is on PATH.',
633
+ cause: err,
634
+ }));
635
+ return;
636
+ }
637
+
638
+ let captured = '';
639
+ let timedOut = false;
640
+ let done = false;
641
+ let timer = null;
642
+ let backstop = null;
643
+
644
+ // Output is reported while it happens, so a slow build is something you
645
+ // watch rather than something you sit through. Whole lines only — a
646
+ // partial line waits for its newline — and a \r progress bar collapses to
647
+ // its latest state, since a line meant to overwrite itself should not
648
+ // scroll past a hundred times.
649
+ const live = typeof onOutput === 'function' ? onOutput : null;
650
+ let held = '';
651
+ let livePrinted = 0;
652
+ let liveCapped = false;
653
+
654
+ const take = (chunk) => {
655
+ const text = stripAnsi(chunk.toString());
656
+ // Collect more than will be shown, then trim once at the end.
657
+ if (captured.length < MAX_OUTPUT * 4) captured += text;
658
+ if (!live || liveCapped) return;
659
+
660
+ held += text;
661
+ const parts = held.split(/\r?\n/);
662
+ held = parts.pop();
663
+ if (!parts.length) return;
664
+
665
+ const lines = parts.map((l) => l.split('\r').pop());
666
+ const room = LIVE_LINES - livePrinted;
667
+ if (lines.length > room) {
668
+ lines.length = Math.max(0, room);
669
+ liveCapped = true;
670
+ lines.push('… the rest is in the final output');
671
+ }
672
+ livePrinted += lines.length;
673
+ if (lines.length) live(lines);
674
+ };
675
+
676
+ child.stdout?.on('data', take);
677
+ child.stderr?.on('data', take);
678
+
679
+ /**
680
+ * The single place this promise resolves. Both the close event and the
681
+ * post-kill backstop land here, so nothing can leave it pending: a killed
682
+ * shell whose grandchild still holds our stdio never emits 'close', and
683
+ * that used to freeze the entire agent.
684
+ */
685
+ const finish = (code) => {
686
+ if (done) return;
687
+ done = true;
688
+ clearTimeout(timer);
689
+ clearTimeout(backstop);
690
+ if (live && held.trim() && !liveCapped) live([held.split('\r').pop()]);
691
+ held = '';
692
+
693
+ const body = captured.trim() || '(no output)';
694
+ // What survives on screen: nothing when it worked and the user already
695
+ // watched it, the tail when it did not.
696
+ const shown = (failed) => (live && !failed && !liveCapped ? [] : tail(body));
697
+
698
+ if (timedOut) {
699
+ let content = `Timed out after ${Math.round(timeout / 1000)}s and was killed.\n\n${body}`;
700
+ if (server || READY_IN_LOG.test(captured)) {
701
+ content +=
702
+ '\n\nThat looks like a server rather than a command that finishes. Run it ' +
703
+ 'again without background: false — ucode starts servers in the background ' +
704
+ 'and reports the URL as soon as it is ready.';
705
+ } else if (/\?\s*›|\(y\/n\)|\[y\/N\]|press enter|select an option|use arrow keys/i.test(captured)) {
706
+ content +=
707
+ '\n\nIt looks like it stopped to ask a question. Nothing can answer it — pass ' +
708
+ 'the non-interactive flag instead (--yes, -y, --defaults, or the option it asked about).';
709
+ }
710
+ const out = result(content, `timed out after ${Math.round(timeout / 1000)}s`);
711
+ out.output = shown(true);
712
+ resolve(out);
713
+ return;
714
+ }
715
+
716
+ const count = captured.trim() ? captured.trim().split(/\r?\n/).length : 0;
717
+ const hints = code !== 0 ? buildHints(captured, effectiveDir(command, workdir)) : [];
718
+ const advice = hints.length ? `\n\nWhat to do:\n${hints.map((h) => `- ${h}`).join('\n')}` : '';
719
+ const out = result(
720
+ `exit code: ${code}\n\n${body}${advice}`,
721
+ `exit ${code} · ${count} line${count === 1 ? '' : 's'}`
722
+ );
723
+ out.exitCode = code;
724
+ out.output = shown(code !== 0);
725
+ resolve(out);
726
+ };
727
+
728
+ timer = setTimeout(() => {
729
+ timedOut = true;
730
+ killTree(child.pid);
731
+ // The tree kill is best effort; if the pipes stay open, force the
732
+ // resolution anyway. The loop has to move on either way.
733
+ backstop = setTimeout(() => finish(null), 2500);
734
+ }, timeout);
735
+
736
+ child.on('error', (err) => {
737
+ if (done) return;
738
+ done = true;
739
+ clearTimeout(timer);
740
+ clearTimeout(backstop);
741
+ reject(new ToolFailure({
742
+ kind: 'spawn_failed',
743
+ attempted: `running "${command}"`,
744
+ failed: `The command could not run: ${err.message}`,
745
+ fix: 'Check the executable name and your PATH.',
746
+ cause: err,
747
+ }));
748
+ });
749
+
750
+ child.on('close', (code) => finish(code));
751
+ });
752
+ }
753
+
754
+ /**
755
+ * Several commands at once, with a ceiling on how many run together.
756
+ *
757
+ * Install, build and test are independent often enough to be worth it, and
758
+ * three round trips become one.
759
+ */
760
+ export async function runCommands({ commands, max_parallel = 3 }, opts = {}) {
761
+ if (!Array.isArray(commands) || commands.length === 0) {
762
+ throw new ToolFailure({
763
+ kind: 'bad_args',
764
+ attempted: 'running several commands',
765
+ failed: 'The "commands" argument must be a non-empty array.',
766
+ fix: 'Pass commands as [{ command, cwd?, timeout_ms?, background? }, ...].',
767
+ });
768
+ }
769
+
770
+ const width = Math.min(Math.max(Number(max_parallel) || 3, 1), 10);
771
+ const finished = new Array(commands.length);
772
+ let next = 0;
773
+
774
+ const worker = async () => {
775
+ for (;;) {
776
+ const i = next++;
777
+ if (i >= commands.length) return;
778
+ try {
779
+ finished[i] = { ok: await runCommand(commands[i] ?? {}, opts) };
780
+ } catch (err) {
781
+ finished[i] = { err };
782
+ }
783
+ }
784
+ };
785
+
786
+ await Promise.all(Array.from({ length: Math.min(width, commands.length) }, worker));
787
+
788
+ const blocks = finished.map((r, i) => {
789
+ const label = `${i + 1}. ${commands[i]?.command ?? '(missing command)'}`;
790
+ if (r.err) return `${label}\nFAILED: ${r.err.failed ?? r.err.message}`;
791
+ return `${label}\n${r.ok.content}`;
792
+ });
793
+
794
+ const summary = finished
795
+ .map((r, i) => `${i + 1}. ${r.err ? 'failed' : r.ok.summary}`)
796
+ .join(' · ');
797
+
798
+ return result(blocks.join('\n\n---\n\n'), `${commands.length} commands: ${summary}`);
799
+ }