ucode-agent 1.54.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.
@@ -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
package/src/ui/plain.js CHANGED
@@ -186,14 +186,20 @@ export class Plain {
186
186
  for (const line of lines) this.output.write(` ${dim(line)}\n`);
187
187
  }
188
188
 
189
- assistant(text, { closing = false } = {}) {
190
- const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
189
+ assistant(text, { closing = false, replay = false } = {}) {
190
+ const body = replay ? String(text) : (closing ? trimAnswer(tidyReply(text)) : tidyReply(text));
191
191
  const out = render(this.md, body);
192
192
  if (!out) return;
193
193
  this.stopSpinner();
194
194
  this.output.write(`\n${out}\n\n`);
195
195
  }
196
196
 
197
+ userMessage(text) {
198
+ this.stopSpinner();
199
+ const first = String(text ?? '').split('\n')[0] ?? '';
200
+ if (first.trim()) this.output.write(`\n${blue('›')} ${first}\n`);
201
+ }
202
+
197
203
  narrate(text) {
198
204
  const line = asLabel(text);
199
205
  if (!line) return;
package/src/ui/screen.js CHANGED
@@ -99,6 +99,34 @@ const NEWLINE = String.fromCharCode(10);
99
99
  const at = (row, col) => `${ESC}[${row};${col}H`;
100
100
  const title = (t) => `${ESC}]0;${t}\x07`;
101
101
 
102
+ /**
103
+ * Every paint is wrapped in these. Autowrap off means a row that is one cell
104
+ * wider than we counted loses its last cell instead of wrapping onto the next
105
+ * row and scrolling the whole frame up — that scroll was the glitch. The
106
+ * synchronized-update pair makes terminals that support it show the frame in
107
+ * one go; the rest ignore it.
108
+ */
109
+ const PAINT_BEGIN = `${ESC}[?2026h${ESC}[?7l`;
110
+ const PAINT_END = `${ESC}[?7h${ESC}[?2026l`;
111
+
112
+ /**
113
+ * Text as it may be drawn: colour codes kept, everything that moves the cursor
114
+ * gone. A carriage return from a CRLF file sent the padding back over the
115
+ * line, a tab took eight cells while it was counted as one, and a clear-screen
116
+ * from a tool's output wiped the frame mid-paint.
117
+ *
118
+ * The SGR colour codes (\x1b[..m) are matched first and kept. An earlier
119
+ * version only excluded them from the CSI branch, so the bare-\x1b fallback
120
+ * stripped the ESC off every colour code and left "[36m" littered across
121
+ * coloured lines — visible only in a real terminal, never in tests, which is
122
+ * why the glitch survived the suite.
123
+ */
124
+ function printable(text) {
125
+ return String(text)
126
+ .replace(/\t/g, ' ')
127
+ .replace(/\x1b\[[0-9;]*m|(\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][0-9A-Za-z]|[\x30-\x7e])?|[\x00-\x09\x0b-\x1a\x1c-\x1f\x7f])/g, (m, bad) => (bad ? '' : m));
128
+ }
129
+
102
130
  /**
103
131
  * Fixed rows below the header: the gap under it, the gap above the input box,
104
132
  * the input box's two borders, the blank row inside it, and the status row.
@@ -181,6 +209,7 @@ export class Screen {
181
209
  this.onModeChange = null;
182
210
  this.spinTimer = null;
183
211
  this.paintedBusy = false; // whose turn the frame on screen was drawn for
212
+ this.paintedLines = null; // transcript length at the last paint; growth since then belongs underneath a scrolled-back view
184
213
  this.activity = null; // the turn in flight: when it began, how many steps
185
214
  this.tick = 0; // animation frames painted, for the spinner
186
215
  this.intro = 0; // when the launch sweep began, 0 once it is over
@@ -292,11 +321,10 @@ export class Screen {
292
321
  */
293
322
  add(text = '') {
294
323
  const width = this.width();
295
- for (const raw of String(text).split('\n')) {
324
+ for (const raw of printable(text).split('\n')) {
296
325
  if (visLen(raw) <= width) this.lines.push(raw);
297
326
  else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
298
327
  }
299
- this.scroll = 0; // new output snaps back to the bottom
300
328
  }
301
329
 
302
330
  push(text = '') {
@@ -329,6 +357,7 @@ export class Screen {
329
357
  clearScreen() {
330
358
  this.lines = [];
331
359
  this.scroll = 0;
360
+ this.paintedLines = null;
332
361
  this.render();
333
362
  }
334
363
 
@@ -339,10 +368,14 @@ export class Screen {
339
368
  * the last thing left on screen, and what the whole session reads like
340
369
  * afterwards, so it is cut to eight lines — see trimAnswer.
341
370
  */
342
- assistant(text, { closing = false } = {}) {
371
+ assistant(text, { closing = false, replay = false, silent = false } = {}) {
343
372
  if (!text?.trim()) return;
373
+ // A replayed answer goes back exactly as it was first shown: no tidying,
374
+ // no trimming, no re-read of the request. Tidy-up exists to keep a live
375
+ // answer short; on a resumed one it rewrites history, which is why a
376
+ // resumed session read like only fragments had survived.
344
377
  const tidy = tidyReply(text, 4, this.lastPrompt);
345
- const body = closing ? trimAnswer(tidy) : tidy;
378
+ const body = replay ? String(text) : (closing ? trimAnswer(tidy) : tidy);
346
379
  if (!body.trim()) return;
347
380
  this.endRun();
348
381
  this.add('');
@@ -355,7 +388,7 @@ export class Screen {
355
388
  this.add(render(this.md, body));
356
389
 
357
390
  this.add('');
358
- this.render();
391
+ if (!silent) this.render();
359
392
  }
360
393
 
361
394
  /**
@@ -368,10 +401,11 @@ export class Screen {
368
401
  * ladder of rules across the page: two horizontal lines per message, each as
369
402
  * loud as the input box, none of them saying anything the rail does not.
370
403
  */
371
- userMessage(text) {
404
+ userMessage(text, { silent = false } = {}) {
372
405
  // Kept so the reply can be checked against it: an answer that opens by
373
406
  // saying the request back is repeating the line directly above it.
374
407
  this.lastPrompt = String(text ?? '');
408
+ this.scroll = 0; // sending something is the one thing that jumps to the bottom
375
409
  const room = Math.max(8, this.width() - 2); // the rail and the space after it
376
410
 
377
411
  const rows = [];
@@ -385,7 +419,7 @@ export class Screen {
385
419
  for (const row of rows) this.add(`${blue(RAIL)} ${chalk.white(row)}`);
386
420
  this.add('');
387
421
  this.add('');
388
- this.render();
422
+ if (!silent) this.render();
389
423
  }
390
424
 
391
425
  /**
@@ -1019,10 +1053,10 @@ export class Screen {
1019
1053
  const width = this.width();
1020
1054
  const liveAt = this.activityRowAt();
1021
1055
  this.output.write(
1022
- HIDE +
1056
+ PAINT_BEGIN + HIDE +
1023
1057
  (liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
1024
1058
  at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
1025
- at(row, col) + SHOW
1059
+ at(row, col) + SHOW + PAINT_END
1026
1060
  );
1027
1061
  }
1028
1062
 
@@ -1149,6 +1183,7 @@ export class Screen {
1149
1183
  }
1150
1184
 
1151
1185
  this.pendingPrompt = 'go ahead? [y/N]';
1186
+ this.scroll = 0; // the question has to be on screen to be answered
1152
1187
  this.render();
1153
1188
 
1154
1189
  return this.nextLine().then((answer) => {
@@ -1273,7 +1308,8 @@ export class Screen {
1273
1308
  const before = this.scroll;
1274
1309
  this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
1275
1310
  if (this.scroll === before && delta > 0) this.flash('already at the top');
1276
- this.render();
1311
+ // One wheel notch arrives as three arrow keys in one chunk; one frame, not three.
1312
+ this.soon();
1277
1313
  }
1278
1314
 
1279
1315
  /**
@@ -1511,6 +1547,20 @@ export class Screen {
1511
1547
  const width = this.width();
1512
1548
  const height = this.viewportHeight();
1513
1549
 
1550
+ // Scrolled back, the view stays on what is being read while output grows
1551
+ // underneath: every line added since the last paint extends the scroll by
1552
+ // the same amount, so the same rows stay on screen. Snapping to the
1553
+ // bottom on every streamed line fought the wheel sixteen times a second.
1554
+ // A streamed reply truncates its tail and rebuilds it on every delta, so
1555
+ // the growth since the last paint is net — but the tail sits below a
1556
+ // scrolled-back viewport, and net growth still moves the scroll by exactly
1557
+ // the amount that keeps the visible rows put.
1558
+ if (this.scroll > 0 && this.paintedLines != null) {
1559
+ const grown = this.lines.length - this.paintedLines;
1560
+ this.scroll = Math.min(Math.max(0, this.scroll + grown), Math.max(0, this.lines.length - height));
1561
+ }
1562
+ this.paintedLines = this.lines.length;
1563
+
1514
1564
  // The live line is the last line of the conversation, not a fixture above
1515
1565
  // the input box. Pinned down there it sat at the bottom of the screen while
1516
1566
  // the message that started it was at the top, with the empty middle of the
@@ -1539,13 +1589,13 @@ export class Screen {
1539
1589
  // The cursor is hidden for the duration of the paint. Without this it is
1540
1590
  // dragged through every line as the frame is written, which shows up as a
1541
1591
  // dot flickering above the input box on every keystroke.
1542
- const out = [HIDE, HOME];
1592
+ const out = [PAINT_BEGIN, HIDE];
1543
1593
  for (let i = 0; i < this.rows; i++) {
1544
- out.push(CLEAR_LINE + padVis(frame[i] ?? '', width) + (i === this.rows - 1 ? '' : '\n'));
1594
+ out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i] ?? '', width));
1545
1595
  }
1546
1596
 
1547
1597
  const [row, col] = this.caret();
1548
- out.push(at(row, col) + SHOW);
1598
+ out.push(at(row, col) + SHOW + PAINT_END);
1549
1599
  this.paintedBusy = this.busy();
1550
1600
  this.output.write(out.join(''));
1551
1601
  }
@@ -1637,12 +1687,12 @@ export class Screen {
1637
1687
  frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1638
1688
  }
1639
1689
 
1640
- const out = [HIDE, HOME];
1690
+ const out = [PAINT_BEGIN, HIDE];
1641
1691
  for (let i = 0; i < this.rows; i++) {
1642
- out.push(CLEAR_LINE + padVis(frame[i], g.cols) + (i === this.rows - 1 ? '' : '\n'));
1692
+ out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i], g.cols));
1643
1693
  }
1644
1694
  const [row, col] = this.caret();
1645
- out.push(at(row, col) + SHOW);
1695
+ out.push(at(row, col) + SHOW + PAINT_END);
1646
1696
  this.paintedBusy = this.busy();
1647
1697
  this.output.write(out.join(''));
1648
1698
  }
package/src/ui/theme.js CHANGED
@@ -174,6 +174,21 @@ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
174
174
  * and a table of every Unicode width would be a megabyte to get the last
175
175
  * fraction of a percent right.
176
176
  */
177
+ /**
178
+ * The emoji among the older symbol blocks — ✅ ❌ ⚡ ⭐ ⌛ and friends. They sit
179
+ * between box-drawing characters that are one cell, so they are listed one by
180
+ * one rather than as a range. Counted as one cell, a model's "✅ Done" line
181
+ * ran a column past the edge, wrapped, and scrolled the whole frame.
182
+ */
183
+ const WIDE_SYMBOLS = new Set([
184
+ 0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x25fd, 0x25fe,
185
+ 0x2614, 0x2615, 0x2648, 0x2649, 0x264a, 0x264b, 0x264c, 0x264d, 0x264e, 0x264f,
186
+ 0x2650, 0x2651, 0x2652, 0x2653, 0x267f, 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd,
187
+ 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26fa,
188
+ 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2753, 0x2754, 0x2755,
189
+ 0x2757, 0x2795, 0x2796, 0x2797, 0x27b0, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55,
190
+ ]);
191
+
177
192
  export function charWidth(code) {
178
193
  // Zero: combining marks, joiners, variation selectors.
179
194
  if ((code >= 0x0300 && code <= 0x036f)
@@ -194,10 +209,15 @@ export function charWidth(code) {
194
209
  || (code >= 0xfe30 && code <= 0xfe6f)
195
210
  || (code >= 0xff00 && code <= 0xff60)
196
211
  || (code >= 0xffe0 && code <= 0xffe6)
212
+ || (code >= 0x1f000 && code <= 0x1f02f)
213
+ || code === 0x1f0cf
214
+ || (code >= 0x1f18e && code <= 0x1f2ff)
197
215
  || (code >= 0x1f300 && code <= 0x1f64f)
198
216
  || (code >= 0x1f680 && code <= 0x1f6ff)
199
- || (code >= 0x1f900 && code <= 0x1f9ff)
200
- || (code >= 0x20000 && code <= 0x3fffd)) return 2;
217
+ || (code >= 0x1f7e0 && code <= 0x1f7eb)
218
+ || (code >= 0x1f900 && code <= 0x1faff)
219
+ || (code >= 0x20000 && code <= 0x3fffd)
220
+ || WIDE_SYMBOLS.has(code)) return 2;
201
221
 
202
222
  return 1;
203
223
  }