ucode-agent 1.57.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.57.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';
@@ -784,8 +785,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
784
785
  ' Next.js, Vite, anything with an npm run dev - start it and leave it up when you',
785
786
  ' finish. ucode opens it in the browser for the user as soon as it is ready, so a',
786
787
  ' build that ends with the server stopped ends with nothing to look at. A one-page',
787
- ' app with no server needs none of this: the file is the app.',
788
- ' 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.',
789
791
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
790
792
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
791
793
  ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
@@ -1184,13 +1186,9 @@ export class Agent {
1184
1186
  const message = skillMessage(skill, { automatic: true, short: true });
1185
1187
  this.loaded.add(skill.name);
1186
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.
1187
1191
  this.push(message);
1188
- this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
1189
- // A clear row under it. This is bookkeeping about the turn, not part of
1190
- // it, and against the first line of thinking the two read as one block —
1191
- // the notice looks like the opening of the answer rather than a note
1192
- // about how the answer is being reached.
1193
- this.ui.blank();
1194
1192
  }
1195
1193
  }
1196
1194
 
@@ -1200,6 +1198,9 @@ export class Agent {
1200
1198
  beginTurn();
1201
1199
  this.lookedThisTurn = false;
1202
1200
  this.lookAgain = null;
1201
+ this.handOverPending = false;
1202
+ this.appName = null;
1203
+ this.appTemplate = null;
1203
1204
  this.reads = new Map();
1204
1205
  this.declines = 0;
1205
1206
  this.apps = [];
@@ -1559,6 +1560,24 @@ export class Agent {
1559
1560
  const badArgs = await this.runCalls(reply.toolCalls);
1560
1561
  if (this.abort.signal.aborted) return;
1561
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
+
1562
1581
  // Malformed arguments go back to the model, but only so many times.
1563
1582
  if (badArgs) {
1564
1583
  argRetries++;
@@ -1798,6 +1817,8 @@ export class Agent {
1798
1817
  if (call.name === 'create_app' && call.args?.folder) {
1799
1818
  const made = path.resolve(this.cwd, String(call.args.folder));
1800
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');
1801
1822
  }
1802
1823
 
1803
1824
  if (call.name === 'look_at_app') {
@@ -2455,6 +2476,72 @@ export class Agent {
2455
2476
  * Never throws. A browser that will not start is a reason to skip the look,
2456
2477
  * never a reason to fail the turn that built the app.
2457
2478
  */
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
+ }
2524
+
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
+
2458
2545
  async lookOnceThisTurn(root, changed) {
2459
2546
  // A look that found problems is taken again after the fix round, even if
2460
2547
  // the fix only touched the page's script. Looking once a turn let a live
@@ -54,6 +54,9 @@ const INSTALL_TIMEOUT = 600_000;
54
54
  const LOOKS_LIKE_INSTALL =
55
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;
56
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
+
57
60
  /**
58
61
  * Any kill by program name rather than by process number.
59
62
  *
@@ -659,6 +662,16 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
659
662
  });
660
663
  }
661
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
+
662
675
  if (KILLS_BY_NAME.test(command)) {
663
676
  const attempted = `running \`${command.trim().slice(0, 80)}\``;
664
677
  try {
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) {