ucode-agent 1.5.0 → 1.6.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.
Files changed (58) hide show
  1. package/README.md +53 -1
  2. package/package.json +3 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/loop.js +301 -12
  6. package/src/core/provider.js +93 -10
  7. package/src/core/stuck.js +269 -0
  8. package/src/tools/browser.js +121 -59
  9. package/src/tools/deploy.js +283 -0
  10. package/src/tools/files.js +91 -8
  11. package/src/tools/index.js +44 -10
  12. package/src/tools/scaffold.js +61 -1
  13. package/src/tools/shell.js +89 -1
  14. package/src/ui/activity.js +203 -0
  15. package/src/ui/plain.js +22 -3
  16. package/src/ui/screen.js +65 -19
  17. package/templates/next-shadcn/TEMPLATE.md +53 -9
  18. package/templates/next-shadcn/_package-lock.json +1335 -148
  19. package/templates/next-shadcn/components.json +1 -1
  20. package/templates/next-shadcn/next.config.ts +2 -1
  21. package/templates/next-shadcn/package.json +4 -2
  22. package/templates/next-shadcn/presets/citrus.json +77 -0
  23. package/templates/next-shadcn/presets/graphite.json +77 -0
  24. package/templates/next-shadcn/presets/grove.json +77 -0
  25. package/templates/next-shadcn/presets/ocean.json +78 -0
  26. package/templates/next-shadcn/presets/sunset.json +77 -0
  27. package/templates/next-shadcn/presets/violet.json +77 -0
  28. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  29. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  30. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  31. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  32. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  33. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  34. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  35. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  36. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  37. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  38. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  39. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  40. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  41. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  42. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  43. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  44. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  45. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  46. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  47. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  48. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  49. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  50. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  51. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  52. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  53. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  54. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  55. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  56. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  57. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  58. package/ucode.js +8 -1
package/src/core/loop.js CHANGED
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { appendFileSync } from 'node:fs';
15
17
  import { readFile, access } from 'node:fs/promises';
16
18
  import { spawn } from 'node:child_process';
17
19
 
@@ -24,7 +26,7 @@ import {
24
26
  } from '../tools/index.js';
25
27
  import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
26
28
  import { autoUpdate } from './updater.js';
27
- import { closeBrowser } from '../tools/browser.js';
29
+ import { closeBrowser, forgetReviews } from '../tools/browser.js';
28
30
  import {
29
31
  newSession, save, load, list, remove, removeAll, titleFrom,
30
32
  } from './history.js';
@@ -34,6 +36,11 @@ import { Screen, isLabel } from '../ui/screen.js';
34
36
  import { Plain } from '../ui/plain.js';
35
37
  import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
36
38
  import { Failure, ToolFailure, Declined } from './failure.js';
39
+ import { StuckWatch, eventFor, describeHit } from './stuck.js';
40
+ import { serversReadySince } from '../tools/shell.js';
41
+ import { formatDuration } from '../ui/activity.js';
42
+ import { runDoctor } from './doctor.js';
43
+ import { deploy } from '../tools/deploy.js';
37
44
 
38
45
  /**
39
46
  * Tool calls allowed in one turn.
@@ -72,11 +79,126 @@ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
72
79
  * model and carries on from exactly where it was.
73
80
  */
74
81
  const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
82
+ /** Will another model, or a little patience, get past this? Not the daily cap: it covers them all. */
83
+ const passing = (err) => TRANSIENT.has(err.kind) && !err.detail?.daily;
75
84
  const MAX_FAILOVERS = 8;
76
85
  const COOLDOWN = 5 * 60_000;
77
86
 
78
87
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
79
88
 
89
+ /**
90
+ * UCODE_TRACE=1 writes one JSON line per model call and per tool to
91
+ * ~/.ucode/trace.jsonl (or to the path UCODE_TRACE names): how long it took,
92
+ * tokens in and out, what was called. It is how "it feels slow" becomes a
93
+ * number with a cause attached.
94
+ */
95
+ const TRACE_FILE = process.env.UCODE_TRACE
96
+ ? (process.env.UCODE_TRACE === '1' ? path.join(os.homedir(), '.ucode', 'trace.jsonl') : process.env.UCODE_TRACE)
97
+ : null;
98
+
99
+ /** Tool results worth re-sending in full only while they are recent. */
100
+ const THIN_RESULTS = new Set([
101
+ 'read_file', 'read_files', 'grep', 'glob', 'list_dir', 'run_command', 'run_commands',
102
+ 'look_at_app', 'web_search', 'edit_file', 'multi_edit', 'edit_files',
103
+ ]);
104
+
105
+ /** Replace long strings in old tool arguments with a note of their size. */
106
+ function thinArgs(value) {
107
+ if (typeof value === 'string') {
108
+ return value.length > 400
109
+ ? `[${value.length} characters, already applied — read the file if you need its current text]`
110
+ : value;
111
+ }
112
+ if (Array.isArray(value)) return value.map(thinArgs);
113
+ if (value && typeof value === 'object') {
114
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, thinArgs(v)]));
115
+ }
116
+ return value;
117
+ }
118
+
119
+ /**
120
+ * The conversation as sent, with bulky history thinned.
121
+ *
122
+ * Every file the model writes travels inside its own tool call, so a
123
+ * thirty-file app is re-sent in full on every later step — tens of thousands
124
+ * of tokens the provider has to read before it can answer, growing with
125
+ * each file. Beyond the last few steps that text is replaced by a note of its
126
+ * size; the files are on disk, and the model re-reads one when it needs it.
127
+ * Call ids and results stay paired, and the saved session keeps everything.
128
+ */
129
+ export function lean(messages, keep = 3) {
130
+ let seen = 0;
131
+ let cut = -1;
132
+ for (let i = messages.length - 1; i >= 0; i--) {
133
+ if (messages[i].role === 'assistant' && messages[i].toolCalls?.length && ++seen === keep) { cut = i; break; }
134
+ }
135
+ if (cut <= 0) return messages;
136
+ return messages.map((m, i) => {
137
+ if (i >= cut) return m;
138
+ if (m.role === 'assistant' && m.toolCalls?.length) {
139
+ return { ...m, toolCalls: m.toolCalls.map((c) => ({ ...c, args: thinArgs(c.args) })) };
140
+ }
141
+ if (m.role === 'tool' && THIN_RESULTS.has(m.name) && (m.content?.length ?? 0) > 1500) {
142
+ return {
143
+ ...m,
144
+ content: `${m.content.slice(0, 300)}
145
+ … [${m.content.length} characters from an earlier step, trimmed ` +
146
+ 'to keep the conversation fast — run the tool again if you need this now]',
147
+ };
148
+ }
149
+ return m;
150
+ });
151
+ }
152
+
153
+ function trace(event) {
154
+ if (!TRACE_FILE) return;
155
+ try { appendFileSync(TRACE_FILE, `${JSON.stringify({ at: Date.now(), ...event })}
156
+ `); } catch { /* never fatal */ }
157
+ }
158
+
159
+ /** What /stats reports, counted as the session goes. */
160
+ function newStats() {
161
+ return {
162
+ started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
163
+ tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
164
+ };
165
+ }
166
+
167
+ const BUILD_COMMAND = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b/;
168
+
169
+ function countTool(stats, call, out, err) {
170
+ stats.tools[call.name] = (stats.tools[call.name] ?? 0) + 1;
171
+ if (err) { stats.failed++; return; }
172
+ const a = call.args ?? {};
173
+ if (call.name === 'write_file') stats.written++;
174
+ if (call.name === 'batch_write') stats.written += a.files?.length ?? 0;
175
+ if (call.name === 'edit_file' || call.name === 'multi_edit') stats.edited++;
176
+ if (call.name === 'edit_files') stats.edited += new Set((a.edits ?? []).map((e) => e.path)).size || 1;
177
+ if (call.name === 'run_command') {
178
+ stats.commands++;
179
+ if (BUILD_COMMAND.test(a.command ?? '')) stats.builds++;
180
+ }
181
+ if (call.name === 'run_commands') stats.commands += a.commands?.length ?? 0;
182
+ }
183
+
184
+ /** The /stats box. */
185
+ export function statsLines(st, messages) {
186
+ const n = (v) => Number(v).toLocaleString();
187
+ const top = Object.entries(st.tools).sort((x, y) => y[1] - x[1]).slice(0, 6)
188
+ .map(([k, v]) => `${k} ${v}`).join(' · ') || 'none yet';
189
+ const plural = (v, w) => `${v} ${w}${v === 1 ? '' : 's'}`;
190
+ const rows = [
191
+ ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
192
+ ['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
193
+ ['Tools', top],
194
+ ['Files', `${st.written} written · ${st.edited} edited`],
195
+ ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
196
+ ['Context', `${messages} messages${st.stuck ? ` · ${plural(st.stuck, 'loop')} caught` : ''}`],
197
+ ];
198
+ const w = Math.max(...rows.map(([k]) => k.length));
199
+ return ['', ` ${blue('Stats')}`, ...rows.map(([k, v]) => ` ${dim(k.padEnd(w))} ${v}`), ''];
200
+ }
201
+
80
202
  /** Parallel workers at once, and how many steps each may take. */
81
203
  const MAX_WORKERS = 3;
82
204
  const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
@@ -238,11 +360,15 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
238
360
  '',
239
361
  '- Need more than one file? read_files, all of them in one call. Never read files',
240
362
  ' one at a time when you already know which ones you want.',
363
+ '- An edit result shows the file as it now stands. Do not read a file again after',
364
+ ' editing it - you already have its current text.',
241
365
  '- Put independent calls in the same message — several greps, a glob and a read.',
242
366
  ' Read-only calls in one message run at the same time.',
243
367
  '- New Next.js app? create_app - one step, never create-next-app or shadcn init. It',
244
368
  ' copies a starter that already builds and installs it in the background.',
245
- '- batch_write to lay out several new files at once, multi_edit for several changes',
369
+ '- batch_write to lay out several new files at once - but at most about four per',
370
+ ' call: one slip in a huge call throws the whole call away.',
371
+ '- multi_edit for several changes',
246
372
  ' to one file, edit_files for a change that spans several files.',
247
373
  '- For work with three or more steps, keep a short plan with update_plan - at most',
248
374
  ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
@@ -253,9 +379,12 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
253
379
  ' writing files. Running the install yourself afterwards just waits for that one.',
254
380
  '- When you finish, ucode type-checks what you changed and hands you the errors, so',
255
381
  ' there is no need to run tsc yourself.',
256
- '- Once the dev server is ready, run look_at_app on the pages you built and fix what',
257
- ' it reports - errors, layout that overflows a phone, and the visual review - then',
258
- ' look again. Do not call an interface finished before it has been looked at.',
382
+ '- Once the dev server is ready, run look_at_app on the pages you built. Fix what it',
383
+ ' reports - errors, layout that overflows a phone, the review points worth fixing -',
384
+ ' in one pass, then look once more. A clean second look means it is done: report',
385
+ ' back instead of polishing in circles. Never call an interface finished unlooked at.',
386
+ '- Asked to deploy, publish, host or share the app? Use the deploy tool - it picks the',
387
+ ' name, handles keys and returns the live link. Build locally first.',
259
388
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
260
389
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
261
390
  ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
@@ -263,6 +392,17 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
263
392
  ' URL once the server says it is ready. Do not start one twice, do not sleep while',
264
393
  ' waiting for it, and do not curl it before that result comes back.',
265
394
  '',
395
+ '## When something fails',
396
+ '',
397
+ '- A failed build names the problem. Fix exactly that, then build again. Never go',
398
+ ' exploring inside node_modules: a missing component or package is one install away.',
399
+ '- A build takes most of a minute. Fix every error it lists in one pass - multi_edit,',
400
+ ' edit_files - before building again, never one error per build.',
401
+ '- Never delete an app folder to start over. Fix it where it is - starting again throws',
402
+ ' away the install and everything already written.',
403
+ '- Run an app\'s commands with cwd set to its folder, and keep paths inside those',
404
+ ' commands relative to that folder.',
405
+ '',
266
406
  '## Safety',
267
407
  '',
268
408
  '- run_command runs without asking. That is trust rather than licence: never run',
@@ -326,6 +466,7 @@ export class Agent {
326
466
  // CI and `echo ... | ucode` get the line-based interface instead.
327
467
  this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
328
468
  this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
469
+ this.stats = newStats();
329
470
  this.skills = [];
330
471
  this.session = newSession(cwd, model());
331
472
  this.working = [];
@@ -573,6 +714,7 @@ export class Agent {
573
714
  }
574
715
 
575
716
  async turn(input) {
717
+ forgetReviews(); // a new request: its apps get a fresh design review
576
718
  const images = await this.attachImages(input);
577
719
  this.push(images.length
578
720
  ? { role: 'user', content: input, images }
@@ -601,17 +743,36 @@ export class Agent {
601
743
  this.busy = true;
602
744
  this.abort = new AbortController();
603
745
 
746
+ const turnStarted = Date.now();
747
+ let finished = false;
748
+ this.ui.turnStart?.();
604
749
  try {
605
- await this.run();
750
+ for (;;) {
751
+ try {
752
+ await this.run();
753
+ break;
754
+ } catch (err) {
755
+ // The daily free cap mid-build: wait for the reset and carry on,
756
+ // rather than leaving a half-built app for the user to restart.
757
+ if (err?.detail?.daily && !this.abort.signal.aborted && (await this.waitForReset(err))) continue;
758
+ throw err;
759
+ }
760
+ }
761
+ finished = true;
606
762
  } catch (err) {
607
763
  if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
608
764
  else throw err;
609
765
  } finally {
766
+ trace({ kind: 'turn', ms: Date.now() - turnStarted });
767
+ this.stats.workMs += Date.now() - turnStarted;
768
+ this.stats.turns++;
610
769
  this.busy = false;
611
770
  this.abort = null;
612
771
  this.ui.stopSpinner();
772
+ this.ui.turnEnd?.({ ok: finished });
613
773
  await this.persist();
614
774
  if (this.full) this.showHeader({ clear: false });
775
+ if (finished) this.openWhenReady(turnStarted);
615
776
  }
616
777
  }
617
778
 
@@ -633,6 +794,7 @@ export class Agent {
633
794
  this.failovers = 0;
634
795
  this.tried = new Set([model()]);
635
796
 
797
+ this.stuck = new StuckWatch();
636
798
  this.touched = new Set();
637
799
  this.sinceCheck = new Set();
638
800
  this.ranSomething = false;
@@ -671,6 +833,7 @@ export class Agent {
671
833
  };
672
834
  }
673
835
 
836
+ var asked = Date.now();
674
837
  reply = await ask(
675
838
  [
676
839
  {
@@ -684,7 +847,7 @@ export class Agent {
684
847
  memory: this.memory,
685
848
  }),
686
849
  },
687
- ...this.working,
850
+ ...lean(this.working),
688
851
  ],
689
852
  available,
690
853
  opts
@@ -712,7 +875,7 @@ export class Agent {
712
875
 
713
876
  // Busy, slow or down: move to the next model and carry on, rather
714
877
  // than ending a half-built app with an error.
715
- if (TRANSIENT.has(err.kind) && !this.abort.signal.aborted && (await this.failover(err))) continue;
878
+ if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
716
879
  throw err;
717
880
  }
718
881
 
@@ -721,6 +884,15 @@ export class Agent {
721
884
  this.ui.thinkingEnd();
722
885
  this.ui.stopSpinner();
723
886
  this.record(reply.usage);
887
+ this.ui.step?.();
888
+ this.stats.steps++;
889
+ this.stats.tokensIn += reply.usage.promptTokens ?? 0;
890
+ this.stats.tokensOut += reply.usage.outputTokens ?? 0;
891
+ trace({
892
+ kind: 'model', who: 'lead', model: model(), ms: Date.now() - asked,
893
+ in: reply.usage.promptTokens, out: reply.usage.outputTokens,
894
+ calls: reply.toolCalls.map((c) => c.name),
895
+ });
724
896
 
725
897
  // Streamed text is already on screen; turn it into rendered markdown.
726
898
  // Text that turns out to be narration ahead of a tool call folds into a
@@ -916,11 +1088,112 @@ export class Agent {
916
1088
  return badArgs;
917
1089
  }
918
1090
 
1091
+ /**
1092
+ * Check a finished call against the stuck patterns (see stuck.js) and return
1093
+ * the note to add to its result. A nudge that did not work hands the turn to
1094
+ * another model.
1095
+ */
1096
+ stuckNote(call, outcome) {
1097
+ if (!this.stuck) return '';
1098
+ const verdict = this.stuck.observe(eventFor(call, outcome));
1099
+ if (!verdict) return '';
1100
+ this.stats.stuck++;
1101
+ if (verdict.action === 'switch') {
1102
+ const next = fallbackFor(model(), this.tried ?? new Set([model()]));
1103
+ if (next) {
1104
+ this.tried?.add(next);
1105
+ this.ui.note(`${modelName(model())} kept ${describeHit(verdict.hit)} — handing over to ${modelName(next)}`);
1106
+ setModel(next);
1107
+ this.cooldownUntil = Date.now() + COOLDOWN;
1108
+ if (this.full) this.showHeader({ clear: false });
1109
+ }
1110
+ }
1111
+ return verdict.text ? `\n\n${verdict.text}` : '';
1112
+ }
1113
+
1114
+ /**
1115
+ * The daily free cap was hit mid-turn: keep the session, count down to the
1116
+ * reset, and carry on by itself. Esc stops the wait like any other turn.
1117
+ */
1118
+ async waitForReset(err) {
1119
+ const resetAt = err.detail?.resetAt;
1120
+ if (!Number.isFinite(resetAt)) return false;
1121
+ const at = resetAt + 30_000;
1122
+ const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1123
+ this.ui.stopSpinner();
1124
+ this.ui.note(`Daily limit reached — ucode carries on by itself at ${clock}. Esc to stop`);
1125
+ await this.persist();
1126
+ let lastNote = Date.now();
1127
+ while (Date.now() < at) {
1128
+ if (this.abort?.signal.aborted) return false;
1129
+ const left = formatDuration(at - Date.now());
1130
+ if (this.full) this.ui.startSpinner(`daily limit · carrying on at ${clock} (in ${left})`);
1131
+ else if (Date.now() - lastNote > 30 * 60_000) { this.ui.note(`still waiting for the daily limit — ${left} to go`); lastNote = Date.now(); }
1132
+ await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(0, at - Date.now()))));
1133
+ }
1134
+ this.ui.stopSpinner();
1135
+ this.ui.note('Daily limit has reset — carrying on');
1136
+ return true;
1137
+ }
1138
+
1139
+ /** A dev server came up during this turn: open it in the browser, once. */
1140
+ openWhenReady(since) {
1141
+ if (!this.full || process.env.UCODE_OPEN === '0') return;
1142
+ const server = serversReadySince(since).at(-1);
1143
+ if (!server || (this.opened ??= new Set()).has(server.url)) return;
1144
+ this.opened.add(server.url);
1145
+ const [cmd, args] = process.platform === 'win32'
1146
+ ? ['cmd', ['/c', 'start', '', server.url]]
1147
+ : [process.platform === 'darwin' ? 'open' : 'xdg-open', [server.url]];
1148
+ try {
1149
+ spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
1150
+ this.ui.note(`Opened ${server.url} in your browser`);
1151
+ } catch { /* no browser to open — the link is in the answer */ }
1152
+ }
1153
+
1154
+ cmdStats() {
1155
+ for (const line of statsLines(this.stats, this.working?.length ?? 0)) this.ui.write(line);
1156
+ }
1157
+
1158
+ async cmdDoctor() {
1159
+ this.ui.startSpinner('checking your setup');
1160
+ try {
1161
+ const lines = await runDoctor();
1162
+ this.ui.stopSpinner();
1163
+ for (const line of lines) this.ui.write(line);
1164
+ } finally {
1165
+ this.ui.stopSpinner();
1166
+ }
1167
+ }
1168
+
1169
+ /** /deploy [folder] — the same tool the model calls, run directly. */
1170
+ async cmdDeploy(arg) {
1171
+ const folder = (arg ?? '').trim() || '.';
1172
+ this.ui.toolCall(`Deploying ${folder}`);
1173
+ this.ui.startSpinner('getting ready to deploy');
1174
+ try {
1175
+ const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
1176
+ this.ui.stopSpinner();
1177
+ this.ui.toolResult(out.summary);
1178
+ this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
1179
+ } catch (err) {
1180
+ this.ui.stopSpinner();
1181
+ if (err instanceof ToolFailure) this.ui.error(err);
1182
+ else throw err;
1183
+ }
1184
+ }
1185
+
919
1186
  reportResult(call, out) {
1187
+ // A build or type check that just passed already verified everything
1188
+ // changed so far; the automatic check at the end would only repeat it.
1189
+ if (call.name === 'run_command' && out.exitCode === 0 &&
1190
+ /(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
1191
+ this.sinceCheck?.clear();
1192
+ }
920
1193
  if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
921
1194
  if (out.diff?.length) this.ui.diff(out.diff);
922
1195
  if (out.output?.length) this.ui.commandOutput(out.output);
923
- this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content });
1196
+ this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
924
1197
  }
925
1198
 
926
1199
  /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
@@ -934,7 +1207,7 @@ export class Agent {
934
1207
  role: 'tool',
935
1208
  toolCallId: call.id,
936
1209
  name: call.name,
937
- content: err.forModel(),
1210
+ content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
938
1211
  });
939
1212
  return err.kind === 'bad_args';
940
1213
  }
@@ -996,7 +1269,11 @@ export class Agent {
996
1269
 
997
1270
  /** Run a call and settle to { out } or { err } — never throws. */
998
1271
  execute(call) {
999
- return this.dispatch(call).then((out) => ({ out }), (err) => ({ err }));
1272
+ const started = Date.now();
1273
+ return this.dispatch(call).then(
1274
+ (out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
1275
+ (err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
1276
+ );
1000
1277
  }
1001
1278
 
1002
1279
  updatePlan(items) {
@@ -1082,11 +1359,12 @@ export class Agent {
1082
1359
  if (this.abort?.signal.aborted) break;
1083
1360
 
1084
1361
  let reply;
1362
+ const asked = Date.now();
1085
1363
  try {
1086
1364
  reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1087
1365
  } catch (err) {
1088
1366
  // Same rule as the lead: a busy model is swapped, not a reason to stop.
1089
- if (TRANSIENT.has(err.kind) && failovers < 6 && !this.abort?.signal.aborted) {
1367
+ if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
1090
1368
  failovers++;
1091
1369
  let next = fallbackFor(workerModel, tried);
1092
1370
  if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
@@ -1099,6 +1377,11 @@ export class Agent {
1099
1377
  throw err;
1100
1378
  }
1101
1379
  this.record(reply.usage);
1380
+ trace({
1381
+ kind: 'model', who: name, model: workerModel, ms: Date.now() - asked,
1382
+ in: reply.usage.promptTokens, out: reply.usage.outputTokens,
1383
+ calls: reply.toolCalls.map((c) => c.name),
1384
+ });
1102
1385
 
1103
1386
  if (!reply.toolCalls.length) {
1104
1387
  this.ui.toolResult(`${name} finished`);
@@ -1290,6 +1573,9 @@ export class Agent {
1290
1573
  case '/clear': this.showHeader(); return;
1291
1574
  case '/search': return this.cmdSearch(arg);
1292
1575
  case '/copy': return this.cmdCopy();
1576
+ case '/stats': return this.cmdStats();
1577
+ case '/doctor': return this.cmdDoctor();
1578
+ case '/deploy': return this.cmdDeploy(arg);
1293
1579
  case '/exit':
1294
1580
  case '/quit': return 'exit';
1295
1581
 
@@ -1302,6 +1588,9 @@ export class Agent {
1302
1588
  cmdHelp() {
1303
1589
  const rows = [
1304
1590
  ['/help', 'this list'],
1591
+ ['/stats', 'time, steps and tokens this session'],
1592
+ ['/doctor', 'check that everything ucode needs is working'],
1593
+ ['/deploy [folder]', 'put the app online and get its link'],
1305
1594
  ['/model', 'show the models and switch between them'],
1306
1595
  ['/resume', 'pick up an earlier conversation'],
1307
1596
  ['/new', 'save this one and start fresh'],
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import dotenv from 'dotenv';
24
24
  import OpenAI from 'openai';
25
+ import { jsonrepair } from 'jsonrepair';
25
26
  import { Failure } from './failure.js';
26
27
 
27
28
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -395,7 +396,14 @@ export function explain(err, id) {
395
396
  (Number.isFinite(asNumber) && asNumber > 0 ? asNumber : null) ??
396
397
  seconds(/try again in ([\dhms.]+)/i.exec(detail)?.[1]) ??
397
398
  null;
398
- const daily = /per day|RPD|TPD|tokens per day/i.test(detail);
399
+ // The daily cap reads "free-models-per-day-high-balance", with hyphens, and
400
+ // names its source in the metadata. It is one cap across every free model,
401
+ // so it is reported at once rather than waited on model after model.
402
+ const meta = body?.error?.metadata ?? {};
403
+ const daily = /per[- ]day|RPD|TPD|daily/i.test(`${detail} ${meta.limit_source ?? ''}`);
404
+ const resetMs = Number(meta.headers?.['X-RateLimit-Reset'] ?? err?.headers?.get?.('x-ratelimit-reset'));
405
+ const resetAt = daily && Number.isFinite(resetMs) && resetMs > Date.now() ? new Date(resetMs) : null;
406
+ const cap = Number(meta.headers?.['X-RateLimit-Limit']) || null;
399
407
  const wait = Number.isFinite(retryAfter) && retryAfter
400
408
  ? (retryAfter >= 60 ? `${Math.ceil(retryAfter / 60)} min` : `${Math.ceil(retryAfter)}s`)
401
409
  : null;
@@ -404,14 +412,14 @@ export function explain(err, id) {
404
412
  kind: 'rate_limit',
405
413
  attempted,
406
414
  failed: daily
407
- ? `The free daily request cap for ${modelName(id)} is used up.`
415
+ ? `This key's free daily limit${cap ? ` of ${cap} requests` : ''} is used up. It covers every free model, so switching will not help.`
408
416
  : `Too many requests for ${modelName(id)} just now${wait ? ` — clear in ${wait}` : ''}.`,
409
417
  fix: daily
410
- ? 'Free caps reset each day. /model switches to another one, or add credit to ' +
411
- 'your account to lift the ceiling.'
418
+ ? `It resets ${resetAt ? `at ${resetAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'once a day'}. ` +
419
+ 'Adding credit to your account raises the limit.'
412
420
  : 'ucode waits these out on its own. Free endpoints are shared, so it usually ' +
413
421
  'clears in seconds; /model moves to a quieter one.',
414
- detail: { retryAfter, daily },
422
+ detail: { retryAfter, daily, resetAt: resetAt?.getTime() ?? null },
415
423
  cause: err,
416
424
  });
417
425
  }
@@ -554,7 +562,7 @@ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
554
562
  * @param {Array} messages neutral messages
555
563
  * @param {Array} [tools] neutral tool definitions
556
564
  * @param {object} [opts] { model, temperature, signal, maxOutputTokens,
557
- * onText, onThinking, onWait }
565
+ * reasoning, attempts, onText, onThinking, onWait }
558
566
  */
559
567
  export async function ask(messages, tools = [], opts = {}) {
560
568
  const id = opts.model || current;
@@ -578,8 +586,11 @@ export async function ask(messages, tools = [], opts = {}) {
578
586
  }
579
587
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
580
588
  if (opts.maxOutputTokens) request.max_tokens = opts.maxOutputTokens;
589
+ if (opts.reasoning) request.reasoning = opts.reasoning;
581
590
 
582
- const attempts = 4;
591
+ // A side call (the design review) passes fewer: it is better skipped than
592
+ // waited on through a string of rate-limit pauses.
593
+ const attempts = opts.attempts ?? 4;
583
594
  let problem;
584
595
 
585
596
  // Text already on screen cannot be unprinted, so a stream is only safe to
@@ -710,7 +721,7 @@ async function streamed(request, opts, id) {
710
721
 
711
722
  const toolCalls = [];
712
723
  for (const [index, slot] of partial) {
713
- toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
724
+ toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args, cutOff: finishReason === 'length' }));
714
725
  }
715
726
 
716
727
  return {
@@ -727,6 +738,56 @@ async function streamed(request, opts, id) {
727
738
  };
728
739
  }
729
740
 
741
+ /**
742
+ * Recover the files from a file-write call whose JSON will not parse.
743
+ *
744
+ * The usual cause is a double quote inside the code that the model forgot to
745
+ * escape — `className="flex"` — which ends the JSON string early. No general
746
+ * repair can know which quote was meant, but a file write has a fixed shape:
747
+ * "path", then "content", then either the next file or the end. Splitting on
748
+ * that shape and escaping the stray quotes gets every file back.
749
+ */
750
+ export function salvageWrites(text) {
751
+ const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
752
+ if (!heads.length) return null;
753
+
754
+ const files = [];
755
+ for (let i = 0; i < heads.length; i++) {
756
+ const from = heads[i].index + heads[i][0].length;
757
+ const to = i + 1 < heads.length ? heads[i + 1].index : text.length;
758
+ // The string ends at the last quote that is followed by nothing but JSON
759
+ // punctuation — `"}, {`, or `"}}, {` when the model added a brace, or `"}]}`.
760
+ const body = text.slice(from, to).replace(/"[\s,{}[\]]*$/, '');
761
+ const content = unescapeLoose(body);
762
+ const pathValue = unescapeLoose(heads[i][1]);
763
+ if (!pathValue || !content) return null; // not the shape we thought — leave it an honest error
764
+ files.push({ path: pathValue, content });
765
+ }
766
+ return files.length ? files : null;
767
+ }
768
+
769
+ /**
770
+ * Decode a JSON string body the forgiving way: the standard escapes are
771
+ * honoured, and everything JSON would reject — a raw line break, a tab, a stray
772
+ * quote, an escape JSON does not know — is kept as the character it plainly is.
773
+ */
774
+ function unescapeLoose(s) {
775
+ const simple = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
776
+ let out = '';
777
+ for (let i = 0; i < s.length; i++) {
778
+ const c = s[i];
779
+ if (c !== '\\' || i === s.length - 1) { out += c; continue; }
780
+ const next = s[++i];
781
+ if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i + 1, i + 5))) {
782
+ out += String.fromCharCode(parseInt(s.slice(i + 1, i + 5), 16));
783
+ i += 4;
784
+ } else {
785
+ out += simple[next] ?? next;
786
+ }
787
+ }
788
+ return out;
789
+ }
790
+
730
791
  /**
731
792
  * Parse one tool call's arguments.
732
793
  *
@@ -734,7 +795,7 @@ async function streamed(request, opts, id) {
734
795
  * back to the model, which usually fixes its own JSON on the next step —
735
796
  * cheaper than failing the whole turn over a stray comma.
736
797
  */
737
- function readCall({ id, name, raw }) {
798
+ export function readCall({ id, name, raw, cutOff = false }) {
738
799
  const call = { id, name, args: {} };
739
800
  const text = String(raw ?? '').trim();
740
801
  if (!text) return call;
@@ -743,6 +804,28 @@ function readCall({ id, name, raw }) {
743
804
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) call.args = parsed;
744
805
  else call.parseError = `arguments must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`;
745
806
  } catch (err) {
807
+ // A missing comma or a stray control character in a 3,000-token
808
+ // batch_write used to throw the whole step away — half a minute of output
809
+ // discarded over one character. Repair the usual slips instead. Never when
810
+ // the reply was cut off at the output limit, though: repairing that would
811
+ // close the string and quietly write half a file.
812
+ if (!cutOff) {
813
+ try {
814
+ const fixed = JSON.parse(jsonrepair(text));
815
+ if (fixed && typeof fixed === 'object' && !Array.isArray(fixed)) {
816
+ call.args = fixed;
817
+ call.repaired = true;
818
+ return call;
819
+ }
820
+ } catch { /* beyond general repair — try the file-write shape next */ }
821
+
822
+ const salvaged = (name === 'write_file' || name === 'batch_write') ? salvageWrites(text) : null;
823
+ if (salvaged) {
824
+ call.args = name === 'write_file' ? salvaged[0] : { files: salvaged };
825
+ call.repaired = true;
826
+ return call;
827
+ }
828
+ }
746
829
  call.parseError = `${err.message} — the raw arguments were: ${text.slice(0, 300)}`;
747
830
  }
748
831
  return call;
@@ -753,7 +836,7 @@ function normalize(data, id) {
753
836
  const message = choice?.message ?? {};
754
837
 
755
838
  const toolCalls = (message.tool_calls ?? []).map((c) =>
756
- readCall({ id: c.id, name: c.function?.name, raw: c.function?.arguments })
839
+ readCall({ id: c.id, name: c.function?.name, raw: c.function?.arguments, cutOff: choice?.finish_reason === 'length' })
757
840
  );
758
841
 
759
842
  const u = data?.usage ?? {};