ucode-agent 1.56.0 → 1.58.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.58.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
@@ -13,6 +13,7 @@
13
13
 
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
+ import { pathToFileURL } from 'node:url';
16
17
  import { appendFileSync, existsSync } from 'node:fs';
17
18
  import { readFile, readdir, access, mkdir } from 'node:fs/promises';
18
19
  import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
@@ -306,6 +307,9 @@ export function lean(messages, keep = 3) {
306
307
  });
307
308
  }
308
309
 
310
+ /** One spelling per file for the read counts: "./a/b.js", "a\b.js" and "a/b.js" are one file. */
311
+ const fileKey = (p) => { const k = path.normalize(String(p)).split(path.sep).join('/'); return process.platform === 'win32' || process.platform === 'darwin' ? k.toLowerCase() : k; };
312
+
309
313
  /** Tools that write whole files, whose sizes are remembered for the turn. */
310
314
  const WHOLE_WRITES = new Set(['write_file', 'batch_write', 'create_app']);
311
315
 
@@ -781,8 +785,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
781
785
  ' Next.js, Vite, anything with an npm run dev - start it and leave it up when you',
782
786
  ' finish. ucode opens it in the browser for the user as soon as it is ready, so a',
783
787
  ' build that ends with the server stopped ends with nothing to look at. A one-page',
784
- ' app with no server needs none of this: the file is the app.',
785
- ' name, handles keys and returns the live link. Build locally first.',
788
+ ' app with no server needs none of this: the file is the app. Write all of it in',
789
+ ' create_app\'s files, then stop: ucode opens the page itself and hands it to the',
790
+ ' user the moment it works. Do not start a server, curl it or re-read it to check.',
786
791
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
787
792
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
788
793
  ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
@@ -1181,13 +1186,9 @@ export class Agent {
1181
1186
  const message = skillMessage(skill, { automatic: true, short: true });
1182
1187
  this.loaded.add(skill.name);
1183
1188
  if (message.short) this.short.add(skill.name);
1189
+ // Nothing on screen: which rules the model was handed is bookkeeping,
1190
+ // and the only text a build should leave is the answer at the end.
1184
1191
  this.push(message);
1185
- this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
1186
- // A clear row under it. This is bookkeeping about the turn, not part of
1187
- // it, and against the first line of thinking the two read as one block —
1188
- // the notice looks like the opening of the answer rather than a note
1189
- // about how the answer is being reached.
1190
- this.ui.blank();
1191
1192
  }
1192
1193
  }
1193
1194
 
@@ -1196,6 +1197,10 @@ export class Agent {
1196
1197
  // /undo can put the whole turn back.
1197
1198
  beginTurn();
1198
1199
  this.lookedThisTurn = false;
1200
+ this.lookAgain = null;
1201
+ this.handOverPending = false;
1202
+ this.appName = null;
1203
+ this.appTemplate = null;
1199
1204
  this.reads = new Map();
1200
1205
  this.declines = 0;
1201
1206
  this.apps = [];
@@ -1203,6 +1208,11 @@ export class Agent {
1203
1208
  forgetReviews(); // a new request: its apps get a fresh design review
1204
1209
  setRequest(input); // create_app checks this before choosing a starter
1205
1210
  const images = await this.attachImages(input);
1211
+ // The skill goes in ahead of the request, so the request is the last
1212
+ // thing the model reads. After it, a page of house rules was what the
1213
+ // model answered: asked for a dark mode toggle on its second turn, a live
1214
+ // run re-read two files and repeated its first turn's summary instead.
1215
+ this.autoLoad(input);
1206
1216
  this.push(images.length
1207
1217
  ? { role: 'user', content: input, images }
1208
1218
  : { role: 'user', content: input });
@@ -1210,8 +1220,6 @@ export class Agent {
1210
1220
  if (!this.session.title || this.session.title === 'Untitled') {
1211
1221
  this.session.title = titleFrom(input);
1212
1222
  }
1213
-
1214
- this.autoLoad(input);
1215
1223
  // What the model is told about the project, fresh for this turn.
1216
1224
  [this.map, this.memory] = await Promise.all([
1217
1225
  projectMap(this.cwd).catch(() => ''),
@@ -1552,6 +1560,24 @@ export class Agent {
1552
1560
  const badArgs = await this.runCalls(reply.toolCalls);
1553
1561
  if (this.abort.signal.aborted) return;
1554
1562
 
1563
+ // The app is written: see whether it works, and if it does, that is the
1564
+ // answer. Waiting for the model to decide it is finished was most of the
1565
+ // time a build took (see handOver).
1566
+ const handed = badArgs ? null : await this.handOver(reply.toolCalls);
1567
+ if (handed?.done) return;
1568
+ if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
1569
+ fixRounds++;
1570
+ this.push({
1571
+ role: 'user',
1572
+ content:
1573
+ `ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
1574
+ 'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
1575
+ `ucode hands it to the user.\n\n${handed.problems}`,
1576
+ });
1577
+ await this.persist();
1578
+ continue;
1579
+ }
1580
+
1555
1581
  // Malformed arguments go back to the model, but only so many times.
1556
1582
  if (badArgs) {
1557
1583
  argRetries++;
@@ -1613,7 +1639,7 @@ export class Agent {
1613
1639
 
1614
1640
  const noted = (call) => {
1615
1641
  if (FILE_WRITES.has(call.name)) {
1616
- for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
1642
+ for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); this.forgetReads(p); }
1617
1643
  }
1618
1644
  if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1619
1645
  };
@@ -1791,6 +1817,8 @@ export class Agent {
1791
1817
  if (call.name === 'create_app' && call.args?.folder) {
1792
1818
  const made = path.resolve(this.cwd, String(call.args.folder));
1793
1819
  if (!(this.apps ??= []).includes(made)) this.apps.push(made);
1820
+ if (call.args.name) this.appName = String(call.args.name);
1821
+ this.appTemplate = String(call.args.template || 'plain-html');
1794
1822
  }
1795
1823
 
1796
1824
  if (call.name === 'look_at_app') {
@@ -1882,7 +1910,7 @@ export class Agent {
1882
1910
  .map((f) => ({ abs: path.resolve(this.cwd, f.path), size: f.content.length, path: f.path }));
1883
1911
  }
1884
1912
 
1885
- async dispatch(call) {
1913
+ async dispatch(call, offered = this.offering) {
1886
1914
  // The model emitted arguments that were not valid JSON. Hand the parser's
1887
1915
  // own complaint straight back so it can correct itself next step.
1888
1916
  if (call.parseError) {
@@ -1899,12 +1927,13 @@ export class Agent {
1899
1927
  // tool that writes, and a new project is not sent the ones that look code
1900
1928
  // up — and a model naming one from memory was, until this check, executing
1901
1929
  // it. A withheld tool has to be refused, not just left out of the menu.
1902
- if (!this.offering?.has(call.name)) {
1930
+ // A worker is checked against its own, narrower list, not the lead's.
1931
+ if (!offered?.has(call.name)) {
1903
1932
  throw new ToolFailure({
1904
1933
  kind: 'no_such_tool',
1905
1934
  attempted: `calling ${call.name}`,
1906
1935
  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(', ')}.`,
1936
+ fix: `Use one of the tools you were given: ${[...offered ?? []].join(', ')}.`,
1908
1937
  });
1909
1938
  }
1910
1939
 
@@ -1921,14 +1950,24 @@ export class Agent {
1921
1950
  // usually a fair re-check after an edit. Only the third is a loop. And it
1922
1951
  // is keyed on the file being untouched since — the moment anything writes
1923
1952
  // to it, the count starts again and a real re-read goes through.
1953
+ //
1954
+ // Counted per page, not per file: paging through a long file with offset
1955
+ // is new text every time, and a live run was refused four pages in a row
1956
+ // of a 900-line file it had only ever seen the start of.
1957
+ //
1958
+ // A write starts the count again (forgetReads). It used to be skipped only
1959
+ // while the file sat in sinceCheck, which the checks empty every fix round:
1960
+ // after round one, every read of a file the model had just edited was
1961
+ // refused as "unchanged", and a live build spent sixty steps being told so.
1924
1962
  if (call.name === 'read_file' && call.args?.path) {
1925
- const key = String(call.args.path);
1963
+ const file = String(call.args.path);
1964
+ const key = `${fileKey(file)}#${Number(call.args.offset) || 1}:${Number(call.args.limit) || 0}`;
1926
1965
  const seen = (this.reads ??= new Map()).get(key) ?? 0;
1927
- if (seen >= 2 && !this.sinceCheck?.has(key)) {
1966
+ if (seen >= 2) {
1928
1967
  this.reads.set(key, seen + 1);
1929
1968
  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 ' +
1969
+ content: `You have already read that part of ${file} ${seen} times this turn and nothing has written ` +
1970
+ 'to it since, so it is unchanged and already above. Use what is there. If you need a part ' +
1932
1971
  'you have lost, say which and read the files you still need together in one read_files.',
1933
1972
  summary: 'unchanged since you last read it',
1934
1973
  };
@@ -2031,9 +2070,9 @@ export class Agent {
2031
2070
  }
2032
2071
 
2033
2072
  /** Run a call and settle to { out } or { err } — never throws. */
2034
- execute(call) {
2073
+ execute(call, offered = this.offering) {
2035
2074
  const started = Date.now();
2036
- return this.dispatch(call).then(
2075
+ return this.dispatch(call, offered).then(
2037
2076
  (out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
2038
2077
  (err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
2039
2078
  );
@@ -2048,6 +2087,12 @@ export class Agent {
2048
2087
  return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
2049
2088
  }
2050
2089
 
2090
+ /** A file was written: every page of it read so far is news again. */
2091
+ forgetReads(file) {
2092
+ const prefix = `${fileKey(file)}#`;
2093
+ for (const key of this.reads?.keys() ?? []) if (key.startsWith(prefix)) this.reads.delete(key);
2094
+ }
2095
+
2051
2096
  /** File writes from parallel workers take turns, so two never interleave. */
2052
2097
  fileLock(fn) {
2053
2098
  const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
@@ -2079,7 +2124,7 @@ export class Agent {
2079
2124
  touched: [],
2080
2125
  }))));
2081
2126
 
2082
- for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
2127
+ for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); this.forgetReads(f); }
2083
2128
 
2084
2129
  // A worker that wrote nothing has not done its part, whatever it said.
2085
2130
  // The lead builds those itself rather than leaving holes in the app.
@@ -2111,6 +2156,7 @@ export class Agent {
2111
2156
  { role: 'user', content: String(task.instructions) },
2112
2157
  ];
2113
2158
  const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
2159
+ const offered = new Set(available.map((t) => t.name));
2114
2160
  const wanted = process.env.UCODE_WORKER_MODEL;
2115
2161
  let workerModel = wanted && MODELS[wanted] ? wanted : model();
2116
2162
  const tried = new Set([workerModel]);
@@ -2172,8 +2218,8 @@ export class Agent {
2172
2218
  const exclusive =
2173
2219
  FILE_WRITES.has(call.name) || call.name === 'run_command' || call.name === 'run_commands';
2174
2220
  const { out, err } = exclusive
2175
- ? await this.fileLock(() => this.execute(call))
2176
- : await this.execute(call);
2221
+ ? await this.fileLock(() => this.execute(call, offered))
2222
+ : await this.execute(call, offered);
2177
2223
  if (err) {
2178
2224
  if (!(err instanceof ToolFailure)) throw err;
2179
2225
  this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
@@ -2430,12 +2476,97 @@ export class Agent {
2430
2476
  * Never throws. A browser that will not start is a reason to skip the look,
2431
2477
  * never a reason to fail the turn that built the app.
2432
2478
  */
2433
- async lookOnceThisTurn(root, changed) {
2434
- if (this.lookedThisTurn) return null;
2479
+ /**
2480
+ * Hand a plain-page app over the moment it works.
2481
+ *
2482
+ * Traced: a tip calculator was on disk and complete 60 seconds in, and the
2483
+ * turn ran to 270 — re-reading the files it had just written, starting
2484
+ * servers to look at a page that needs none, rewriting it to "verify". The
2485
+ * user waited three and a half minutes for an app that was already there.
2486
+ *
2487
+ * So right after a step that writes the app — create_app with its files, a
2488
+ * batch of files, or any write while earlier problems are being fixed —
2489
+ * ucode opens the page itself. No console errors, no failed requests, and
2490
+ * it responds when used: the turn ends there with where to find it. If it
2491
+ * is broken, the problems go straight back, and the next write checks again.
2492
+ * Plain pages only: a framework app is checked when the model says it is
2493
+ * done, since its build and server take their own time.
2494
+ *
2495
+ * Returns { done: true }, { problems }, or null when there was nothing to check.
2496
+ */
2497
+ async handOver(calls) {
2498
+ if (this.ui.mode === 'plan' || !this.apps?.length) return null;
2499
+ const app = this.apps.at(-1);
2500
+ // Plain by the starter it came from, not by what is in the folder: a model
2501
+ // that adds a package.json to a one-page app still made a one-page app, and
2502
+ // judging by the file sent a stopwatch on a 22-step, six-minute detour.
2503
+ if (this.appTemplate !== 'plain-html' || !(await exists(path.join(app, 'index.html')))) return null;
2504
+
2505
+ const inApp = (p) => {
2506
+ const rel = path.relative(app, path.resolve(this.cwd, String(p)));
2507
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
2508
+ };
2509
+ const writes = calls.filter((c) => FILE_WRITES.has(c.name) && pathsOf(c).some(inApp));
2510
+ const whole = writes.some((c) => (c.name === 'create_app' || c.name === 'batch_write') && pathsOf(c).length > 0);
2511
+ if (!writes.length || (!whole && !this.handOverPending)) return null;
2512
+
2513
+ const show = path.relative(this.cwd, app).split(path.sep).join('/') || '.';
2514
+ let out;
2515
+ try {
2516
+ const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
2517
+ this.ui.startSpinner(`opening ${show} to check it works`);
2518
+ out = await withStaticServer(app, (url) => lookAtApp({ url, review: false }));
2519
+ } catch {
2520
+ return null; // no browser: the check at the end of the turn still runs
2521
+ } finally {
2522
+ this.ui.stopSpinner();
2523
+ }
2435
2524
 
2436
- const pages = [...changed].filter((f) => PAGE.test(f));
2525
+ const content = String(out?.content ?? '');
2526
+ if (/Console errors|Failed requests|NOTHING HAPPENS|does not parse/i.test(content)) {
2527
+ this.handOverPending = true;
2528
+ this.ui.runStat?.('needs a fix');
2529
+ return { problems: `I opened the app and looked at it:\n\n${content}` };
2530
+ }
2531
+
2532
+ this.handOverPending = false;
2533
+ // "Tip Calculator is done", with the page as a link the terminal can open.
2534
+ const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
2535
+ const name = String(made ?? path.basename(app)).trim();
2536
+ this.appName = name;
2537
+ const link = pathToFileURL(path.join(app, 'index.html')).href;
2538
+ const text = `${name} is done. Open it here: ${link}\n\n(or open \`${show}/index.html\` in your browser)`;
2539
+ this.push({ role: 'assistant', content: text });
2540
+ this.ui.assistant(text, { closing: true });
2541
+ await this.persist();
2542
+ return { done: true };
2543
+ }
2544
+
2545
+ async lookOnceThisTurn(root, changed) {
2546
+ // A look that found problems is taken again after the fix round, even if
2547
+ // the fix only touched the page's script. Looking once a turn let a live
2548
+ // build report "Failed to load module script" on its first look, fail to
2549
+ // fix it, and finish as done with a page whose script never ran — nothing
2550
+ // opened the page again. The fix rounds bound how often this repeats.
2551
+ const again = this.lookAgain;
2552
+ if (this.lookedThisTurn && !again) return null;
2553
+
2554
+ let pages = [...changed].filter((f) => PAGE.test(f));
2555
+ // A plain page's script changed and its index.html did not — the usual
2556
+ // shape of a build on the HTML starter, whose page comes from the starter
2557
+ // while the model writes app.js. The page is still the thing to open: a
2558
+ // live build whose "Add" button threw on every click finished as done
2559
+ // because nothing with .html in its name had changed, so nobody looked.
2560
+ if (!pages.length) {
2561
+ for (const f of changed) {
2562
+ const page = path.join(path.dirname(f), 'index.html');
2563
+ if (/\.m?js$/i.test(f) && (await exists(path.resolve(root, page)))) { pages = [page]; break; }
2564
+ }
2565
+ }
2566
+ if (!pages.length && again) pages = [again];
2437
2567
  if (!pages.length) return null;
2438
2568
  this.lookedThisTurn = true;
2569
+ this.lookAgain = null;
2439
2570
 
2440
2571
  try {
2441
2572
  const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
@@ -2448,6 +2579,7 @@ export class Agent {
2448
2579
  const out = await lookAtApp({ url, review: false });
2449
2580
  const found = /^(\d+) problem/.exec(out.summary ?? '');
2450
2581
  this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
2582
+ if (found) this.lookAgain = pages.find((f) => /\.html?$/i.test(f)) ?? pages[0];
2451
2583
  return found ? `I opened the app and looked at it:\n\n${out.content}` : null;
2452
2584
  };
2453
2585
 
@@ -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,20 @@ 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
+ /** Opening a file or URL in the desktop browser: start, open, xdg-open, explorer. */
58
+ const OPENS_BROWSER = /^\s*(?:cd\s+[^&;|]+&&\s*)?(?:start(?:\s+"")?|open|xdg-open|explorer(?:\.exe)?|cmd(?:\.exe)?\s+\/c\s+start)\s+\S+\.html?\b|^\s*(?:start|open|xdg-open)\s+https?:\/\//i;
59
+
60
+ /**
61
+ * Any kill by program name rather than by process number.
62
+ *
63
+ * `taskkill /F /IM python.exe` in a live build ended six Python processes on
64
+ * the machine, most of them nothing to do with the app. Every process ucode
65
+ * starts comes back with its PID, so the narrow kill is always available;
66
+ * the broad one is the user's call, and is asked about.
67
+ */
68
+ const KILLS_BY_NAME =
69
+ /(\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;
70
+
44
71
  /**
45
72
  * Commands that kill a whole class of process rather than one process.
46
73
  *
@@ -73,6 +100,10 @@ const KILLS_EVERYTHING =
73
100
  * NEXT_TELEMETRY Next skips its telemetry notice and ping
74
101
  * NO_COLOR output comes back as text rather than escape codes,
75
102
  * which the model would otherwise have to read past
103
+ * PYTHONUNBUFFERED Python writes as it goes instead of holding output
104
+ * until a buffer fills: `python -m http.server` said
105
+ * "Serving HTTP" to a log that stayed empty, and each
106
+ * start waited out the full 45 seconds as "still starting"
76
107
  */
77
108
  export function childEnv(base = process.env) {
78
109
  return {
@@ -89,6 +120,7 @@ export function childEnv(base = process.env) {
89
120
  NEXT_TELEMETRY_DISABLED: '1',
90
121
  NO_COLOR: '1',
91
122
  FORCE_COLOR: '0',
123
+ PYTHONUNBUFFERED: '1',
92
124
  };
93
125
  }
94
126
 
@@ -630,13 +662,44 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
630
662
  });
631
663
  }
632
664
 
665
+ // Opening a page in the user's browser is ucode's job, once the app works.
666
+ // Run by the model, `start index.html` hands the pipe to the browser, which
667
+ // never closes it: a live build sat on it for the full two-minute timeout.
668
+ if (OPENS_BROWSER.test(command)) {
669
+ return result(
670
+ 'Not run: ucode opens the app for the user itself once it works. There is no need to open it.',
671
+ 'not needed — ucode shows the app',
672
+ );
673
+ }
674
+
675
+ if (KILLS_BY_NAME.test(command)) {
676
+ const attempted = `running \`${command.trim().slice(0, 80)}\``;
677
+ try {
678
+ await confirm(
679
+ `stop every matching process: ${command.trim().slice(0, 120)}`,
680
+ 'That kills every process with that name on this computer, not only the ones ucode started.',
681
+ 'command',
682
+ );
683
+ } catch (err) {
684
+ if (err?.kind !== 'cannot_ask') throw err;
685
+ throw new ToolFailure({
686
+ kind: 'broad_kill',
687
+ attempted,
688
+ failed: 'That kills every process with that name on this computer, not only the ones ucode ' +
689
+ 'started, and there is nobody here to approve it.',
690
+ fix: 'Stop the one process by its PID: every server ucode starts reports its PID and the exact ' +
691
+ 'command to stop it.',
692
+ });
693
+ }
694
+ }
695
+
633
696
  const workdir = cwd
634
697
  ? resolveIn(cwd, 'run_command', 'cwd')
635
698
  : { abs: getRoot(), inside: true, show: '.' };
636
699
  await guard(workdir, `run a command in ${workdir.abs}`);
637
700
 
638
701
  const env = childEnv();
639
- const server = LOOKS_LIKE_SERVER.test(command);
702
+ const server = startsServer(command);
640
703
 
641
704
  // Anything run where a background install is still going waits for it —
642
705
  // two installs in one folder corrupt node_modules, and a build before the
package/src/ui/plain.js CHANGED
@@ -200,11 +200,10 @@ export class Plain {
200
200
  if (first.trim()) this.output.write(`\n${blue('›')} ${first}\n`);
201
201
  }
202
202
 
203
+ /** Said beside a tool call, so not the answer: it moves the spinner and is not printed. */
203
204
  narrate(text) {
204
205
  const line = asLabel(text);
205
- if (!line) return;
206
- this.stopSpinner();
207
- this.write(dim(` ⋮ ${line}`));
206
+ if (line) this.updateSpinner(line);
208
207
  }
209
208
 
210
209
  progress(lines) {