ucode-agent 1.56.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA, Cohere and Nex AGI models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -306,6 +306,9 @@ export function lean(messages, keep = 3) {
306
306
  });
307
307
  }
308
308
 
309
+ /** One spelling per file for the read counts: "./a/b.js", "a\b.js" and "a/b.js" are one file. */
310
+ const fileKey = (p) => { const k = path.normalize(String(p)).split(path.sep).join('/'); return process.platform === 'win32' || process.platform === 'darwin' ? k.toLowerCase() : k; };
311
+
309
312
  /** Tools that write whole files, whose sizes are remembered for the turn. */
310
313
  const WHOLE_WRITES = new Set(['write_file', 'batch_write', 'create_app']);
311
314
 
@@ -1196,6 +1199,7 @@ export class Agent {
1196
1199
  // /undo can put the whole turn back.
1197
1200
  beginTurn();
1198
1201
  this.lookedThisTurn = false;
1202
+ this.lookAgain = null;
1199
1203
  this.reads = new Map();
1200
1204
  this.declines = 0;
1201
1205
  this.apps = [];
@@ -1203,6 +1207,11 @@ export class Agent {
1203
1207
  forgetReviews(); // a new request: its apps get a fresh design review
1204
1208
  setRequest(input); // create_app checks this before choosing a starter
1205
1209
  const images = await this.attachImages(input);
1210
+ // The skill goes in ahead of the request, so the request is the last
1211
+ // thing the model reads. After it, a page of house rules was what the
1212
+ // model answered: asked for a dark mode toggle on its second turn, a live
1213
+ // run re-read two files and repeated its first turn's summary instead.
1214
+ this.autoLoad(input);
1206
1215
  this.push(images.length
1207
1216
  ? { role: 'user', content: input, images }
1208
1217
  : { role: 'user', content: input });
@@ -1210,8 +1219,6 @@ export class Agent {
1210
1219
  if (!this.session.title || this.session.title === 'Untitled') {
1211
1220
  this.session.title = titleFrom(input);
1212
1221
  }
1213
-
1214
- this.autoLoad(input);
1215
1222
  // What the model is told about the project, fresh for this turn.
1216
1223
  [this.map, this.memory] = await Promise.all([
1217
1224
  projectMap(this.cwd).catch(() => ''),
@@ -1613,7 +1620,7 @@ export class Agent {
1613
1620
 
1614
1621
  const noted = (call) => {
1615
1622
  if (FILE_WRITES.has(call.name)) {
1616
- for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
1623
+ for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); this.forgetReads(p); }
1617
1624
  }
1618
1625
  if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1619
1626
  };
@@ -1882,7 +1889,7 @@ export class Agent {
1882
1889
  .map((f) => ({ abs: path.resolve(this.cwd, f.path), size: f.content.length, path: f.path }));
1883
1890
  }
1884
1891
 
1885
- async dispatch(call) {
1892
+ async dispatch(call, offered = this.offering) {
1886
1893
  // The model emitted arguments that were not valid JSON. Hand the parser's
1887
1894
  // own complaint straight back so it can correct itself next step.
1888
1895
  if (call.parseError) {
@@ -1899,12 +1906,13 @@ export class Agent {
1899
1906
  // tool that writes, and a new project is not sent the ones that look code
1900
1907
  // up — and a model naming one from memory was, until this check, executing
1901
1908
  // it. A withheld tool has to be refused, not just left out of the menu.
1902
- if (!this.offering?.has(call.name)) {
1909
+ // A worker is checked against its own, narrower list, not the lead's.
1910
+ if (!offered?.has(call.name)) {
1903
1911
  throw new ToolFailure({
1904
1912
  kind: 'no_such_tool',
1905
1913
  attempted: `calling ${call.name}`,
1906
1914
  failed: `${call.name} is not available${this.ui.mode === 'plan' ? ' in plan mode' : ' in this project'}.`,
1907
- fix: `Use one of the tools you were given: ${[...this.offering ?? []].join(', ')}.`,
1915
+ fix: `Use one of the tools you were given: ${[...offered ?? []].join(', ')}.`,
1908
1916
  });
1909
1917
  }
1910
1918
 
@@ -1921,14 +1929,24 @@ export class Agent {
1921
1929
  // usually a fair re-check after an edit. Only the third is a loop. And it
1922
1930
  // is keyed on the file being untouched since — the moment anything writes
1923
1931
  // to it, the count starts again and a real re-read goes through.
1932
+ //
1933
+ // Counted per page, not per file: paging through a long file with offset
1934
+ // is new text every time, and a live run was refused four pages in a row
1935
+ // of a 900-line file it had only ever seen the start of.
1936
+ //
1937
+ // A write starts the count again (forgetReads). It used to be skipped only
1938
+ // while the file sat in sinceCheck, which the checks empty every fix round:
1939
+ // after round one, every read of a file the model had just edited was
1940
+ // refused as "unchanged", and a live build spent sixty steps being told so.
1924
1941
  if (call.name === 'read_file' && call.args?.path) {
1925
- const key = String(call.args.path);
1942
+ const file = String(call.args.path);
1943
+ const key = `${fileKey(file)}#${Number(call.args.offset) || 1}:${Number(call.args.limit) || 0}`;
1926
1944
  const seen = (this.reads ??= new Map()).get(key) ?? 0;
1927
- if (seen >= 2 && !this.sinceCheck?.has(key)) {
1945
+ if (seen >= 2) {
1928
1946
  this.reads.set(key, seen + 1);
1929
1947
  return {
1930
- content: `You have already read ${key} ${seen} times this turn and nothing has written to it since, ` +
1931
- 'so its contents are unchanged and already above. Use what is there. If you need a part ' +
1948
+ content: `You have already read that part of ${file} ${seen} times this turn and nothing has written ` +
1949
+ 'to it since, so it is unchanged and already above. Use what is there. If you need a part ' +
1932
1950
  'you have lost, say which and read the files you still need together in one read_files.',
1933
1951
  summary: 'unchanged since you last read it',
1934
1952
  };
@@ -2031,9 +2049,9 @@ export class Agent {
2031
2049
  }
2032
2050
 
2033
2051
  /** Run a call and settle to { out } or { err } — never throws. */
2034
- execute(call) {
2052
+ execute(call, offered = this.offering) {
2035
2053
  const started = Date.now();
2036
- return this.dispatch(call).then(
2054
+ return this.dispatch(call, offered).then(
2037
2055
  (out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
2038
2056
  (err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
2039
2057
  );
@@ -2048,6 +2066,12 @@ export class Agent {
2048
2066
  return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
2049
2067
  }
2050
2068
 
2069
+ /** A file was written: every page of it read so far is news again. */
2070
+ forgetReads(file) {
2071
+ const prefix = `${fileKey(file)}#`;
2072
+ for (const key of this.reads?.keys() ?? []) if (key.startsWith(prefix)) this.reads.delete(key);
2073
+ }
2074
+
2051
2075
  /** File writes from parallel workers take turns, so two never interleave. */
2052
2076
  fileLock(fn) {
2053
2077
  const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
@@ -2079,7 +2103,7 @@ export class Agent {
2079
2103
  touched: [],
2080
2104
  }))));
2081
2105
 
2082
- for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
2106
+ for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); this.forgetReads(f); }
2083
2107
 
2084
2108
  // A worker that wrote nothing has not done its part, whatever it said.
2085
2109
  // The lead builds those itself rather than leaving holes in the app.
@@ -2111,6 +2135,7 @@ export class Agent {
2111
2135
  { role: 'user', content: String(task.instructions) },
2112
2136
  ];
2113
2137
  const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
2138
+ const offered = new Set(available.map((t) => t.name));
2114
2139
  const wanted = process.env.UCODE_WORKER_MODEL;
2115
2140
  let workerModel = wanted && MODELS[wanted] ? wanted : model();
2116
2141
  const tried = new Set([workerModel]);
@@ -2172,8 +2197,8 @@ export class Agent {
2172
2197
  const exclusive =
2173
2198
  FILE_WRITES.has(call.name) || call.name === 'run_command' || call.name === 'run_commands';
2174
2199
  const { out, err } = exclusive
2175
- ? await this.fileLock(() => this.execute(call))
2176
- : await this.execute(call);
2200
+ ? await this.fileLock(() => this.execute(call, offered))
2201
+ : await this.execute(call, offered);
2177
2202
  if (err) {
2178
2203
  if (!(err instanceof ToolFailure)) throw err;
2179
2204
  this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
@@ -2431,11 +2456,30 @@ export class Agent {
2431
2456
  * never a reason to fail the turn that built the app.
2432
2457
  */
2433
2458
  async lookOnceThisTurn(root, changed) {
2434
- if (this.lookedThisTurn) return null;
2435
-
2436
- const pages = [...changed].filter((f) => PAGE.test(f));
2459
+ // A look that found problems is taken again after the fix round, even if
2460
+ // the fix only touched the page's script. Looking once a turn let a live
2461
+ // build report "Failed to load module script" on its first look, fail to
2462
+ // fix it, and finish as done with a page whose script never ran — nothing
2463
+ // opened the page again. The fix rounds bound how often this repeats.
2464
+ const again = this.lookAgain;
2465
+ if (this.lookedThisTurn && !again) return null;
2466
+
2467
+ let pages = [...changed].filter((f) => PAGE.test(f));
2468
+ // A plain page's script changed and its index.html did not — the usual
2469
+ // shape of a build on the HTML starter, whose page comes from the starter
2470
+ // while the model writes app.js. The page is still the thing to open: a
2471
+ // live build whose "Add" button threw on every click finished as done
2472
+ // because nothing with .html in its name had changed, so nobody looked.
2473
+ if (!pages.length) {
2474
+ for (const f of changed) {
2475
+ const page = path.join(path.dirname(f), 'index.html');
2476
+ if (/\.m?js$/i.test(f) && (await exists(path.resolve(root, page)))) { pages = [page]; break; }
2477
+ }
2478
+ }
2479
+ if (!pages.length && again) pages = [again];
2437
2480
  if (!pages.length) return null;
2438
2481
  this.lookedThisTurn = true;
2482
+ this.lookAgain = null;
2439
2483
 
2440
2484
  try {
2441
2485
  const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
@@ -2448,6 +2492,7 @@ export class Agent {
2448
2492
  const out = await lookAtApp({ url, review: false });
2449
2493
  const found = /^(\d+) problem/.exec(out.summary ?? '');
2450
2494
  this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
2495
+ if (found) this.lookAgain = pages.find((f) => /\.html?$/i.test(f)) ?? pages[0];
2451
2496
  return found ? `I opened the app and looked at it:\n\n${out.content}` : null;
2452
2497
  };
2453
2498
 
@@ -172,7 +172,7 @@ export function autoLoadFor(skills, text) {
172
172
  export function skillMessage(skill, { automatic = false, short = false } = {}) {
173
173
  const digest = short && skill.digest ? skill.digest : null;
174
174
  const why = automatic
175
- ? `The "${skill.name}" skill was loaded automatically because this request is the kind it covers.`
175
+ ? `The "${skill.name}" skill was loaded automatically because the request that follows is the kind it covers.`
176
176
  : `The "${skill.name}" skill was loaded for this task.`;
177
177
  return {
178
178
  role: 'system',
@@ -7,14 +7,14 @@
7
7
  * coding agent can do, because everything after it is built on a lie.
8
8
  */
9
9
 
10
- import { promises as fs } from 'node:fs';
10
+ import { promises as fs, existsSync } from 'node:fs';
11
11
  import { remember } from '../core/undo.js';
12
12
  import path from 'node:path';
13
13
  import { ToolFailure } from '../core/failure.js';
14
14
  import {
15
15
  resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
16
16
  changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
17
- noteFile, writeTracked, assertUnchanged,
17
+ noteFile, writeTracked, assertUnchanged, getRoot,
18
18
  } from './shared.js';
19
19
  import { packageJsonWritten } from './shell.js';
20
20
  import { fuzzyReplace } from './fuzzy.js';
@@ -196,6 +196,44 @@ const brokenNote = (show, problem) => {
196
196
  `a structure you have lost track of.`;
197
197
  };
198
198
 
199
+ /**
200
+ * A module in a page with no build step that imports a stylesheet or an image.
201
+ *
202
+ * A bundler would take it; a browser refuses the whole module ("Failed to
203
+ * load module script ... MIME type text/css"), so none of the app's script
204
+ * runs and every button is dead. The syntax is fine, so nothing else notices
205
+ * until the page is opened — a live build shipped exactly this as done.
206
+ */
207
+ const ASSET_IMPORT = /^[ \t]*import\s+(?:[\w$*{}\s,]+?\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less|svg|png|jpe?g|gif|webp))['"](?!\s*(?:with|assert)\s*\{)/m;
208
+
209
+ export function assetImport(abs, text) {
210
+ if (!/\.m?js$/i.test(abs)) return null;
211
+ // An import quoted inside a block comment is not an import.
212
+ const hit = ASSET_IMPORT.exec(String(text).replace(/\/\*[\s\S]*?\*\//g, ''));
213
+ if (!hit) return null;
214
+ // Anything with a package.json above it, up to the project root, may well be
215
+ // bundled. path.relative, not a string prefix: "ucode2" starts with "ucode",
216
+ // and Windows paths differ in case from one shell to the next.
217
+ const top = getRoot();
218
+ const within = (dir) => {
219
+ const rel = path.relative(top, dir);
220
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
221
+ };
222
+ for (let dir = path.dirname(abs); ; dir = path.dirname(dir)) {
223
+ if (existsSync(path.join(dir, 'package.json'))) return null;
224
+ if (!within(dir) || path.relative(top, dir) === '' || path.dirname(dir) === dir) break;
225
+ }
226
+ return `imports ${hit[1]}, which a page with no build step cannot do: the browser refuses the ` +
227
+ 'whole module, so none of its script runs. Remove that import and load it from the page ' +
228
+ 'instead — <link rel="stylesheet" href="…"> for a stylesheet, an <img> or a plain URL for an image.';
229
+ }
230
+
231
+ /** The note for that, appended to a write's result like a parse problem is. */
232
+ const pageNote = (target, text) => {
233
+ const problem = assetImport(target.abs, text);
234
+ return problem ? `\n\n⚠ ${target.show} ${problem}` : '';
235
+ };
236
+
199
237
  /** A file this short comes back whole after an edit; longer ones show the part around the change. */
200
238
  const SHOW_WHOLE = 250;
201
239
  const AROUND = 15;
@@ -349,6 +387,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
349
387
  lineCount,
350
388
  diff,
351
389
  problem: syntaxProblem(target.abs, content),
390
+ note: pageNote(target, content),
352
391
  line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
353
392
  `(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
354
393
  };
@@ -368,7 +407,7 @@ export async function writeFile({ path: p, content }) {
368
407
 
369
408
  const written = await put(target, content);
370
409
  const out = result(
371
- `${written.line}.${parseNote(target.show, written.problem)}`,
410
+ `${written.line}.${parseNote(target.show, written.problem)}${written.note}`,
372
411
  `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
373
412
  );
374
413
  out.diff = written.diff;
@@ -414,7 +453,7 @@ export async function batchWrite({ files }) {
414
453
  // would bury the reply under three hundred lines of gutter.
415
454
  const written = await put(target, content, { diffMax: 6 });
416
455
  if (!written.existed) created++;
417
- lines.push(written.line + parseNote(target.show, written.problem));
456
+ lines.push(written.line + parseNote(target.show, written.problem) + written.note);
418
457
  if (written.problem) broken++;
419
458
  diff.push(`~${target.show}`, ...written.diff);
420
459
  }
@@ -632,7 +671,7 @@ export async function editFile({ path: p, old_string, new_string, replace_all })
632
671
  const span = toLines(new_string).length;
633
672
  const out = result(
634
673
  `Replaced ${where} (${change}${matched}).` +
635
- parseNote(target.show, syntaxProblem(target.abs, text)) +
674
+ parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
636
675
  nowReads(target.show, text, at, span),
637
676
  `${count > 1 ? `${count} changes from line` : '1 change at line'} ${at} · ${change}` +
638
677
  `${how ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
@@ -705,7 +744,7 @@ export async function multiEdit({ path: p, edits }) {
705
744
 
706
745
  const out = result(
707
746
  `Applied ${edits.length} edits to ${target.show} (${change}).` +
708
- parseNote(target.show, syntaxProblem(target.abs, text)) +
747
+ parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
709
748
  nowReads(target.show, text, 1, toLines(text).length),
710
749
  `${edits.length} edits · ${change}`,
711
750
  MAX_FILE_OUTPUT
@@ -795,7 +834,7 @@ export async function editFiles({ files }) {
795
834
  planned.map((p) => {
796
835
  const problem = syntaxProblem(p.target.abs, p.text);
797
836
  return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
798
- parseNote(p.target.show, problem);
837
+ parseNote(p.target.show, problem) + pageNote(p.target, p.text);
799
838
  }).join('\n'),
800
839
  `${planned.length} files · ${edits} edits`
801
840
  );
@@ -408,12 +408,19 @@ export const WALK_WIDTH = 32;
408
408
  */
409
409
  const known = new Map();
410
410
 
411
+ /**
412
+ * One key per file. Windows and macOS do not care about case, so "App.js" and
413
+ * "app.js" are the same file there — and keyed apart, ucode's own write under
414
+ * one spelling read as somebody else's edit under the other.
415
+ */
416
+ const stampKey = (abs) => (process.platform === 'win32' || process.platform === 'darwin' ? abs.toLowerCase() : abs);
417
+
411
418
  /** Record a file as ucode now knows it. Never throws: a missing stamp only costs the check. */
412
419
  export async function noteFile(abs) {
413
420
  try {
414
- known.set(abs, (await fs.stat(abs)).mtimeMs);
421
+ known.set(stampKey(abs), (await fs.stat(abs)).mtimeMs);
415
422
  } catch {
416
- known.delete(abs);
423
+ known.delete(stampKey(abs));
417
424
  }
418
425
  }
419
426
 
@@ -431,7 +438,7 @@ export async function writeTracked(abs, data, encoding = 'utf8') {
431
438
 
432
439
  /** Forget a file, so the next write to it goes through unchallenged. */
433
440
  export function forgetFile(abs) {
434
- known.delete(abs);
441
+ known.delete(stampKey(abs));
435
442
  }
436
443
 
437
444
  /**
@@ -444,7 +451,7 @@ export function forgetFile(abs) {
444
451
  * clobber.
445
452
  */
446
453
  export async function assertUnchanged(abs, show) {
447
- const seen = known.get(abs);
454
+ const seen = known.get(stampKey(abs));
448
455
  if (seen === undefined) return;
449
456
 
450
457
  let now;
@@ -457,7 +464,7 @@ export async function assertUnchanged(abs, show) {
457
464
  // costs nothing and stops a same-second write reading as someone else's.
458
465
  if (Math.abs(now - seen) < 1) return;
459
466
 
460
- known.delete(abs);
467
+ known.delete(stampKey(abs));
461
468
  throw new ToolFailure({
462
469
  kind: 'changed_on_disk',
463
470
  attempted: `overwriting ${show}`,
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
17
+ import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
18
18
 
19
19
  const DEFAULT_TIMEOUT = 120_000;
20
20
  const MAX_TIMEOUT = 600_000;
@@ -28,6 +28,19 @@ const LIVE_LINES = 200;
28
28
  const LOOKS_LIKE_SERVER =
29
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
30
 
31
+ /**
32
+ * Does some part of this command actually run a server? A segment that only
33
+ * searches for or lists one does not: `ps aux | grep http-server` was
34
+ * backgrounded as a server and reported as having died before it was ready.
35
+ */
36
+ const INSPECTS = /^(?:grep|egrep|findstr|find|ps|echo|cat|type|tasklist|netstat|curl|wget|ls|dir|which|where|head|tail|less|more)\b/i;
37
+ export function startsServer(command) {
38
+ return String(command).replace(/"[^"]*"|'[^']*'/g, '""').split(/&&|\|\||[;|&]/).some((part) => {
39
+ const segment = part.trim();
40
+ return !INSPECTS.test(segment) && LOOKS_LIKE_SERVER.test(segment);
41
+ });
42
+ }
43
+
31
44
  /** A foreground server that was explicitly asked for still gets a short leash. */
32
45
  const SERVER_TIMEOUT = 30_000;
33
46
 
@@ -41,6 +54,17 @@ const INSTALL_TIMEOUT = 600_000;
41
54
  const LOOKS_LIKE_INSTALL =
42
55
  /(\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
56
 
57
+ /**
58
+ * Any kill by program name rather than by process number.
59
+ *
60
+ * `taskkill /F /IM python.exe` in a live build ended six Python processes on
61
+ * the machine, most of them nothing to do with the app. Every process ucode
62
+ * starts comes back with its PID, so the narrow kill is always available;
63
+ * the broad one is the user's call, and is asked about.
64
+ */
65
+ const KILLS_BY_NAME =
66
+ /(\btaskkill\b[^|;&]*\/IM\b|\b(?:killall|pkill)\b|\b(?:Stop-Process|spps)\b[^|;&]*-(?:N(?:a(?:me?)?)?|ProcessName)\b|\bGet-Process\b[^;&]*\|\s*(?:Stop-Process|spps|kill)\b|\bwmic\b[^|;&]*\bprocess\b[^|;&]*\b(?:delete|call\s+terminate)\b|\bkill\b[^;&]*\$\(\s*pgrep\b|\bpgrep\b[^;&]*\|\s*xargs\s+(?:-\S+\s+)*kill\b)/i;
67
+
44
68
  /**
45
69
  * Commands that kill a whole class of process rather than one process.
46
70
  *
@@ -73,6 +97,10 @@ const KILLS_EVERYTHING =
73
97
  * NEXT_TELEMETRY Next skips its telemetry notice and ping
74
98
  * NO_COLOR output comes back as text rather than escape codes,
75
99
  * which the model would otherwise have to read past
100
+ * PYTHONUNBUFFERED Python writes as it goes instead of holding output
101
+ * until a buffer fills: `python -m http.server` said
102
+ * "Serving HTTP" to a log that stayed empty, and each
103
+ * start waited out the full 45 seconds as "still starting"
76
104
  */
77
105
  export function childEnv(base = process.env) {
78
106
  return {
@@ -89,6 +117,7 @@ export function childEnv(base = process.env) {
89
117
  NEXT_TELEMETRY_DISABLED: '1',
90
118
  NO_COLOR: '1',
91
119
  FORCE_COLOR: '0',
120
+ PYTHONUNBUFFERED: '1',
92
121
  };
93
122
  }
94
123
 
@@ -630,13 +659,34 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
630
659
  });
631
660
  }
632
661
 
662
+ if (KILLS_BY_NAME.test(command)) {
663
+ const attempted = `running \`${command.trim().slice(0, 80)}\``;
664
+ try {
665
+ await confirm(
666
+ `stop every matching process: ${command.trim().slice(0, 120)}`,
667
+ 'That kills every process with that name on this computer, not only the ones ucode started.',
668
+ 'command',
669
+ );
670
+ } catch (err) {
671
+ if (err?.kind !== 'cannot_ask') throw err;
672
+ throw new ToolFailure({
673
+ kind: 'broad_kill',
674
+ attempted,
675
+ failed: 'That kills every process with that name on this computer, not only the ones ucode ' +
676
+ 'started, and there is nobody here to approve it.',
677
+ fix: 'Stop the one process by its PID: every server ucode starts reports its PID and the exact ' +
678
+ 'command to stop it.',
679
+ });
680
+ }
681
+ }
682
+
633
683
  const workdir = cwd
634
684
  ? resolveIn(cwd, 'run_command', 'cwd')
635
685
  : { abs: getRoot(), inside: true, show: '.' };
636
686
  await guard(workdir, `run a command in ${workdir.abs}`);
637
687
 
638
688
  const env = childEnv();
639
- const server = LOOKS_LIKE_SERVER.test(command);
689
+ const server = startsServer(command);
640
690
 
641
691
  // Anything run where a background install is still going waits for it —
642
692
  // two installs in one folder corrupt node_modules, and a build before the