ucode-agent 1.9.0 → 1.10.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.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -408,6 +408,11 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
408
408
  '',
409
409
  '## How to work',
410
410
  '',
411
+ 'Say what you are about to do, in one short line, before you do it — "now the',
412
+ 'tests", "wiring this into the loop". A line like that before a group of actions is',
413
+ 'what makes the work readable. Keep it to a sentence: the narration is not the',
414
+ 'answer, and three sentences of intent before every step reads as stalling.',
415
+ '',
411
416
  'Before you guess at an API, ask: type_of gives the exact signature from the',
412
417
  'TypeScript this project has installed, and find_symbol says where something is declared without',
413
418
  'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
@@ -566,6 +571,42 @@ export class Agent {
566
571
  this.working.push(message);
567
572
  }
568
573
 
574
+ /**
575
+ * Answer every tool call a stopped turn never got to.
576
+ *
577
+ * An assistant message ends by asking for tools, and each of those asks
578
+ * needs an answer. Abandon them and the conversation is left mid-sentence,
579
+ * so the next time the model reads it the only sensible thing to do is
580
+ * carry on where it left off — which is exactly what the user pressed stop
581
+ * to prevent. Saying "this did not happen" for each one ends the sentence,
582
+ * and a line from the user ends the task.
583
+ */
584
+ closeInterrupted() {
585
+ const answered = new Set(this.working.filter((m) => m.role === 'tool').map((m) => m.toolCallId));
586
+ const missing = [];
587
+ for (const m of this.working) {
588
+ if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
589
+ for (const call of m.toolCalls) {
590
+ if (!answered.has(call.id)) missing.push(call);
591
+ }
592
+ }
593
+ for (const call of missing) {
594
+ this.push({
595
+ role: 'tool',
596
+ toolCallId: call.id,
597
+ name: call.name,
598
+ content: 'The user stopped the turn before this ran. It did not happen, and it must not be retried.',
599
+ });
600
+ }
601
+ if (missing.length) {
602
+ this.push({
603
+ role: 'user',
604
+ content: 'I stopped that. Drop it and wait for what I ask next — do not pick it back up.',
605
+ });
606
+ }
607
+ return missing.length;
608
+ }
609
+
569
610
  async persist() {
570
611
  try {
571
612
  this.session.model = model();
@@ -607,6 +648,7 @@ export class Agent {
607
648
  if (this.busy && this.abort) {
608
649
  this.abort.abort();
609
650
  this.ui.stopSpinner();
651
+ this.ui.stopTimer?.();
610
652
  this.ui.note('interrupted');
611
653
  }
612
654
  };
@@ -849,9 +891,12 @@ export class Agent {
849
891
  trace({ kind: 'turn', ms: Date.now() - turnStarted });
850
892
  this.stats.workMs += Date.now() - turnStarted;
851
893
  this.stats.turns++;
894
+ if (!finished) this.closeInterrupted();
852
895
  this.busy = false;
853
896
  this.abort = null;
854
897
  this.ui.stopSpinner();
898
+ this.ui.stopTimer?.();
899
+ this.activity = null;
855
900
  this.ui.turnEnd?.({ ok: finished });
856
901
  await this.persist();
857
902
  if (this.full) this.showHeader({ clear: false });
@@ -1,95 +1,152 @@
1
- /**
2
- * updater.js — staying current without anyone running npm by hand.
3
- *
4
- * On every launch ucode asks the registry, in the background, whether a newer
5
- * version exists. If one does, it installs it globally, detached, while you
6
- * work. The version you are running carries on untouched; the next launch is
7
- * the new one. Nothing about starting ucode waits on any of this.
8
- *
9
- * It stays out of the way in three cases: a development checkout (updating
10
- * would overwrite the `npm link` that points at your working copy), when
11
- * UCODE_NO_UPDATE is set, and when another ucode is already updating.
12
- */
13
-
14
- import { spawn } from 'node:child_process';
15
- import { existsSync, promises as fs } from 'node:fs';
16
- import os from 'node:os';
17
- import path from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
- import { VERSION } from './version.js';
20
-
21
- const PACKAGE = 'ucode-agent';
22
- const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
23
- const HOME = path.join(os.homedir(), '.ucode');
24
- const LOCK = path.join(HOME, 'update.lock');
25
- const LOG = path.join(HOME, 'update.log');
26
- const LOCK_TTL = 10 * 60_000;
27
-
28
- /** "1.10.0" > "1.9.3" — numeric, part by part. */
29
- export function newer(a, b) {
30
- const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
31
- const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
32
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
33
- if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
34
- }
35
- return false;
36
- }
37
-
38
- function isDevCheckout() {
39
- return existsSync(path.join(PACKAGE_ROOT, '.git'));
40
- }
41
-
42
- async function latestVersion() {
43
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
44
- signal: AbortSignal.timeout(5_000),
45
- headers: { accept: 'application/json' },
46
- });
47
- if (!res.ok) return null;
48
- return (await res.json())?.version ?? null;
49
- }
50
-
51
- async function takeLock() {
52
- try {
53
- const stat = await fs.stat(LOCK);
54
- if (Date.now() - stat.mtimeMs < LOCK_TTL) return false; // someone else is on it
55
- } catch { /* no lock — good */ }
56
- await fs.mkdir(HOME, { recursive: true });
57
- await fs.writeFile(LOCK, String(process.pid));
58
- return true;
59
- }
60
-
61
- /**
62
- * Check, and install if there is something newer.
63
- *
64
- * @param {object} o
65
- * @param {(v: string) => void} [o.onUpdated] called when the install finishes
66
- */
67
- export async function autoUpdate({ onUpdated } = {}) {
68
- try {
69
- if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
70
- const latest = await latestVersion();
71
- if (!latest || !newer(latest, VERSION)) return;
72
- if (!(await takeLock())) return;
73
-
74
- const log = await fs.open(LOG, 'w');
75
- const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
76
- shell: true,
77
- detached: true,
78
- windowsHide: true,
79
- stdio: ['ignore', log.fd, log.fd],
80
- });
81
- await log.close();
82
- child.unref();
83
-
84
- child.on('exit', async (code) => {
85
- await fs.rm(LOCK, { force: true }).catch(() => {});
86
- if (code === 0) onUpdated?.(latest);
87
- });
88
- child.on('error', async () => {
89
- await fs.rm(LOCK, { force: true }).catch(() => {});
90
- });
91
- } catch {
92
- // An update check must never be the reason ucode misbehaves. Offline,
93
- // registry down, no permission to install globally: all silently skipped.
94
- }
95
- }
1
+ /**
2
+ * updater.js — staying current without anyone running npm by hand.
3
+ *
4
+ * On every launch ucode asks the registry, in the background, whether a newer
5
+ * version exists. If one does, it installs it globally, detached, while you
6
+ * work. The version you are running carries on untouched; the next launch is
7
+ * the new one. Nothing about starting ucode waits on any of this.
8
+ *
9
+ * It stays out of the way in three cases: a development checkout (updating
10
+ * would overwrite the `npm link` that points at your working copy), when
11
+ * UCODE_NO_UPDATE is set, and when another ucode is already updating.
12
+ */
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync, rmSync, promises as fs } from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { VERSION } from './version.js';
20
+
21
+ const PACKAGE = 'ucode-agent';
22
+ const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
23
+ const HOME = path.join(os.homedir(), '.ucode');
24
+ const LOCK = path.join(HOME, 'update.lock');
25
+ const LOG = path.join(HOME, 'update.log');
26
+ const LOCK_TTL = 10 * 60_000;
27
+
28
+ /** "1.10.0" > "1.9.3" — numeric, part by part. */
29
+ export function newer(a, b) {
30
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
31
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
32
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
33
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function isDevCheckout() {
39
+ return existsSync(path.join(PACKAGE_ROOT, '.git'));
40
+ }
41
+
42
+ async function latestVersion() {
43
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
44
+ signal: AbortSignal.timeout(5_000),
45
+ headers: { accept: 'application/json' },
46
+ });
47
+ if (!res.ok) return null;
48
+ return (await res.json())?.version ?? null;
49
+ }
50
+
51
+ /** Is that process still running? A lock held by a dead one is not a lock. */
52
+ function alive(pid) {
53
+ if (!pid) return false;
54
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
55
+ }
56
+
57
+ /**
58
+ * Take the update lock, unless a live process holds it.
59
+ *
60
+ * The lock used to be dropped by the parent watching the installer exit — but
61
+ * the installer is detached and ucode usually leaves first, so the handler
62
+ * never ran and the lock sat there blocking every update until it aged out.
63
+ * Now the holder is checked for a pulse, so a lock left by a process that has
64
+ * gone is simply taken over.
65
+ */
66
+ async function takeLock() {
67
+ try {
68
+ const stat = await fs.stat(LOCK);
69
+ const holder = parseInt(await fs.readFile(LOCK, 'utf8').catch(() => ''), 10);
70
+ if (alive(holder) && Date.now() - stat.mtimeMs < LOCK_TTL) return false;
71
+ } catch { /* no lock — good */ }
72
+ await fs.mkdir(HOME, { recursive: true });
73
+ await fs.writeFile(LOCK, String(process.pid));
74
+ return true;
75
+ }
76
+
77
+ export async function releaseLock() {
78
+ await fs.rm(LOCK, { force: true }).catch(() => {});
79
+ }
80
+
81
+ /**
82
+ * Install the newer version and return true once it is on disk.
83
+ *
84
+ * This one waits. It is used at startup, where waiting a few seconds once per
85
+ * release is the whole point: the alternative is being told an update exists
86
+ * and running the old one anyway.
87
+ */
88
+ export async function installNow(version) {
89
+ if (!(await takeLock())) return false;
90
+ try {
91
+ const done = await new Promise((resolve) => {
92
+ const child = spawn(`npm install -g ${PACKAGE}@${version} --no-audit --no-fund --silent`, {
93
+ shell: true, windowsHide: true, stdio: 'ignore',
94
+ });
95
+ child.on('exit', (code) => resolve(code === 0));
96
+ child.on('error', () => resolve(false));
97
+ setTimeout(() => resolve(false), 90_000);
98
+ });
99
+ return done;
100
+ } finally {
101
+ await releaseLock();
102
+ }
103
+ }
104
+
105
+ /** The newest version on the registry, if it is newer than this one. */
106
+ export async function pendingUpdate() {
107
+ try {
108
+ if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return null;
109
+ const latest = await latestVersion();
110
+ return latest && newer(latest, VERSION) ? latest : null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Check, and install if there is something newer.
118
+ *
119
+ * @param {object} o
120
+ * @param {(v: string) => void} [o.onUpdated] called when the install finishes
121
+ */
122
+ export async function autoUpdate({ onUpdated } = {}) {
123
+ try {
124
+ if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
125
+ const latest = await latestVersion();
126
+ if (!latest || !newer(latest, VERSION)) return;
127
+ if (!(await takeLock())) return;
128
+
129
+ const log = await fs.open(LOG, 'w');
130
+ const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
131
+ shell: true,
132
+ detached: true,
133
+ windowsHide: true,
134
+ stdio: ['ignore', log.fd, log.fd],
135
+ });
136
+ await log.close();
137
+ child.unref();
138
+
139
+ // The lock is cleared by whoever took it. This process may well be gone
140
+ // before npm finishes, so it cannot be left to an exit handler here.
141
+ child.on('exit', async (code) => {
142
+ await releaseLock();
143
+ if (code === 0) onUpdated?.(latest);
144
+ });
145
+ child.on('error', async () => { await releaseLock(); });
146
+ // And if this process leaves first, release it on the way out.
147
+ process.once('exit', () => { try { rmSync(LOCK, { force: true }); } catch {} });
148
+ } catch {
149
+ // An update check must never be the reason ucode misbehaves. Offline,
150
+ // registry down, no permission to install globally: all silently skipped.
151
+ }
152
+ }
package/src/ui/screen.js CHANGED
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
- shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark } from './theme.js';
42
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel } from './theme.js';
43
43
  import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -246,6 +246,7 @@ export class Screen {
246
246
 
247
247
  assistant(text) {
248
248
  if (!text?.trim()) return;
249
+ this.endRun();
249
250
  this.add('');
250
251
  this.add(render(this.md, text));
251
252
  this.add('');
@@ -291,10 +292,31 @@ export class Screen {
291
292
  toolCall(label) {
292
293
  // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
293
294
  // Terminal draws as a white circle on a blue tile.
294
- this.push(`${narrationMark()} ${narration(asLabel(label))}`);
295
+ const kind = groupKind(label);
296
+ const run = this.run;
297
+ // The run's own line is either the last one, or the last but one with its
298
+ // result underneath. Anything further down means something else was said
299
+ // in between, and the run is over.
300
+ const gap = run ? this.lines.length - 1 - run.at : Infinity;
301
+
302
+ // A second step of the same kind rewrites the line the first one wrote,
303
+ // rather than adding another almost-identical one beneath it.
304
+ if (run && run.kind === kind && gap <= 1) {
305
+ if (gap === 1) this.lines.pop(); // its single result line, now counted
306
+ run.count++;
307
+ run.label = label;
308
+ this.lines[run.at] = `${narrationMark()} ${narration(asLabel(groupLabel(label, run.count)))}`;
309
+ this.render();
310
+ } else {
311
+ this.push(`${narrationMark()} ${narration(asLabel(label))}`);
312
+ this.run = { kind, count: 1, at: this.lines.length - 1, label };
313
+ }
295
314
  this.updateSpinner(label);
296
315
  }
297
316
 
317
+ /** Anything that is not another step of the same kind ends the run. */
318
+ endRun() { this.run = null; }
319
+
298
320
  /** The checklist, when the model updates it. One line, wrapped if it must. */
299
321
  plan(items) {
300
322
  const line = planLine(items);
@@ -302,10 +324,15 @@ export class Screen {
302
324
  }
303
325
 
304
326
  toolResult(summary) {
327
+ // Inside a run, the count on the line above already says what happened;
328
+ // a result line per step is the noise this is meant to remove.
329
+ if (this.run && this.run.count > 1) return;
305
330
  this.push(dim(` └ ${summary}`));
306
331
  }
307
332
 
308
333
  toolFailed(summary) {
334
+ // A failure is never folded away.
335
+ this.endRun();
309
336
  this.push(`${dim(' └ ')}${theme.error(summary)}`);
310
337
  }
311
338
 
package/src/ui/theme.js CHANGED
@@ -291,3 +291,40 @@ export const narration = (text) => chalk.dim(text);
291
291
 
292
292
  /** The bullet beside a narration line: present, not loud. */
293
293
  export const narrationMark = () => chalk.dim(deep('●'));
294
+
295
+ /**
296
+ * How a run of the same kind of step reads once it is over.
297
+ *
298
+ * While it happens, "Running npm test" is the useful thing to show. Once
299
+ * three of them have happened, three near-identical lines are just noise
300
+ * between the reader and the answer, so they fold into one: "Ran 3 commands".
301
+ * The present tense belongs to the thing happening now; the past tense to the
302
+ * summary of what did.
303
+ */
304
+ const GROUPS = {
305
+ Running: ['Ran', 'command', 'commands'],
306
+ Reading: ['Read', 'file', 'files'],
307
+ Searching: ['Searched', 'time', 'times'],
308
+ Finding: ['Found', 'pattern', 'patterns'],
309
+ Listing: ['Listed', 'directory', 'directories'],
310
+ Writing: ['Wrote', 'file', 'files'],
311
+ Editing: ['Edited', 'file', 'files'],
312
+ Checking: ['Checked', 'thing', 'things'],
313
+ Looking: ['Looked up', 'name', 'names'],
314
+ Asking: ['Asked about', 'name', 'names'],
315
+ Mapping: ['Mapped', 'folder', 'folders'],
316
+ Adding: ['Added', 'block', 'blocks'],
317
+ Renaming: ['Renamed', 'name', 'names'],
318
+ };
319
+
320
+ /** The first word of a label, which is what decides whether two steps match. */
321
+ export const groupKind = (label) => String(label ?? '').trim().split(/\s+/)[0] ?? '';
322
+
323
+ /** One line standing in for `count` steps that all began with the same word. */
324
+ export function groupLabel(label, count) {
325
+ if (count <= 1) return String(label ?? '');
326
+ const g = GROUPS[groupKind(label)];
327
+ if (!g) return `${label} (+${count - 1} more)`;
328
+ const [past, one, many] = g;
329
+ return `${past} ${count} ${count === 1 ? one : many}`;
330
+ }
package/ucode.js CHANGED
@@ -81,6 +81,31 @@ async function main() {
81
81
  return;
82
82
  }
83
83
 
84
+ // Update before the session starts, not after it ends.
85
+ //
86
+ // Installing in the background and taking effect "next time" means the run
87
+ // you are doing now — the one that might be a demo — is the old one, and you
88
+ // have no way to know. So if there is something newer, it goes on now and
89
+ // this process hands over to it. The wait is a few seconds, once per
90
+ // release; every other launch pays one quick question to the registry.
91
+ if (!args.version && process.argv[2] !== 'login' && process.argv[2] !== 'doctor') {
92
+ const { pendingUpdate, installNow } = await import('./src/core/updater.js');
93
+ const waiting = await pendingUpdate();
94
+ if (waiting) {
95
+ process.stdout.write(` updating to v${waiting}…\n`);
96
+ if (await installNow(waiting)) {
97
+ const { spawnSync } = await import('node:child_process');
98
+ const run = spawnSync(process.execPath, [process.argv[1], ...process.argv.slice(2)], {
99
+ stdio: 'inherit',
100
+ env: { ...process.env, UCODE_NO_UPDATE: '1' }, // the new one must not check again
101
+ });
102
+ process.exit(run.status ?? 0);
103
+ }
104
+ // It did not take. Carry on with the version already here rather than
105
+ // making a failed update the reason ucode will not start.
106
+ }
107
+ }
108
+
84
109
  if (args.model) {
85
110
  try {
86
111
  setModel(args.model);