ucode-agent 1.4.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 (76) hide show
  1. package/README.md +64 -4
  2. package/package.json +5 -2
  3. package/skills/build-app/SKILL.md +19 -1
  4. package/skills/ui-ux/SKILL.md +2 -2
  5. package/src/core/doctor.js +122 -0
  6. package/src/core/loop.js +303 -12
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/tools/browser.js +121 -59
  10. package/src/tools/deploy.js +283 -0
  11. package/src/tools/files.js +91 -8
  12. package/src/tools/index.js +65 -8
  13. package/src/tools/scaffold.js +190 -0
  14. package/src/tools/shell.js +89 -1
  15. package/src/ui/activity.js +203 -0
  16. package/src/ui/plain.js +22 -3
  17. package/src/ui/screen.js +65 -19
  18. package/templates/next-shadcn/AGENTS.md +9 -0
  19. package/templates/next-shadcn/README.md +9 -0
  20. package/templates/next-shadcn/TEMPLATE.md +83 -0
  21. package/templates/next-shadcn/_gitignore +41 -0
  22. package/templates/next-shadcn/_package-lock.json +11465 -0
  23. package/templates/next-shadcn/components.json +25 -0
  24. package/templates/next-shadcn/eslint.config.mjs +18 -0
  25. package/templates/next-shadcn/next-env.d.ts +5 -0
  26. package/templates/next-shadcn/next.config.ts +8 -0
  27. package/templates/next-shadcn/package.json +39 -0
  28. package/templates/next-shadcn/postcss.config.mjs +7 -0
  29. package/templates/next-shadcn/presets/citrus.json +77 -0
  30. package/templates/next-shadcn/presets/graphite.json +77 -0
  31. package/templates/next-shadcn/presets/grove.json +77 -0
  32. package/templates/next-shadcn/presets/ocean.json +78 -0
  33. package/templates/next-shadcn/presets/sunset.json +77 -0
  34. package/templates/next-shadcn/presets/violet.json +77 -0
  35. package/templates/next-shadcn/src/app/favicon.ico +0 -0
  36. package/templates/next-shadcn/src/app/globals.css +155 -0
  37. package/templates/next-shadcn/src/app/layout.tsx +42 -0
  38. package/templates/next-shadcn/src/app/page.tsx +14 -0
  39. package/templates/next-shadcn/src/components/theme-provider.tsx +7 -0
  40. package/templates/next-shadcn/src/components/theme-toggle.tsx +27 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +199 -0
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +111 -0
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +48 -0
  45. package/templates/next-shadcn/src/components/ui/button.tsx +66 -0
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +222 -0
  47. package/templates/next-shadcn/src/components/ui/card.tsx +102 -0
  48. package/templates/next-shadcn/src/components/ui/checkbox.tsx +32 -0
  49. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  50. package/templates/next-shadcn/src/components/ui/command.tsx +195 -0
  51. package/templates/next-shadcn/src/components/ui/dialog.tsx +168 -0
  52. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +268 -0
  53. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  54. package/templates/next-shadcn/src/components/ui/input-group.tsx +156 -0
  55. package/templates/next-shadcn/src/components/ui/input.tsx +18 -0
  56. package/templates/next-shadcn/src/components/ui/label.tsx +23 -0
  57. package/templates/next-shadcn/src/components/ui/popover.tsx +88 -0
  58. package/templates/next-shadcn/src/components/ui/progress.tsx +30 -0
  59. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  60. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +54 -0
  61. package/templates/next-shadcn/src/components/ui/select.tsx +191 -0
  62. package/templates/next-shadcn/src/components/ui/separator.tsx +27 -0
  63. package/templates/next-shadcn/src/components/ui/sheet.tsx +147 -0
  64. package/templates/next-shadcn/src/components/ui/skeleton.tsx +13 -0
  65. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  66. package/templates/next-shadcn/src/components/ui/sonner.tsx +49 -0
  67. package/templates/next-shadcn/src/components/ui/switch.tsx +32 -0
  68. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  69. package/templates/next-shadcn/src/components/ui/tabs.tsx +89 -0
  70. package/templates/next-shadcn/src/components/ui/textarea.tsx +17 -0
  71. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  72. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  73. package/templates/next-shadcn/src/components/ui/tooltip.tsx +56 -0
  74. package/templates/next-shadcn/src/lib/utils.ts +6 -0
  75. package/templates/next-shadcn/tsconfig.json +34 -0
  76. 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,9 +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
- '- batch_write to lay out several new files at once, multi_edit for several changes',
367
+ '- New Next.js app? create_app - one step, never create-next-app or shadcn init. It',
368
+ ' copies a starter that already builds and installs it in the background.',
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',
244
372
  ' to one file, edit_files for a change that spans several files.',
245
373
  '- For work with three or more steps, keep a short plan with update_plan - at most',
246
374
  ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
@@ -251,9 +379,12 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
251
379
  ' writing files. Running the install yourself afterwards just waits for that one.',
252
380
  '- When you finish, ucode type-checks what you changed and hands you the errors, so',
253
381
  ' there is no need to run tsc yourself.',
254
- '- Once the dev server is ready, run look_at_app on the pages you built and fix what',
255
- ' it reports - errors, layout that overflows a phone, and the visual review - then',
256
- ' 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.',
257
388
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
258
389
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
259
390
  ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
@@ -261,6 +392,17 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
261
392
  ' URL once the server says it is ready. Do not start one twice, do not sleep while',
262
393
  ' waiting for it, and do not curl it before that result comes back.',
263
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
+ '',
264
406
  '## Safety',
265
407
  '',
266
408
  '- run_command runs without asking. That is trust rather than licence: never run',
@@ -324,6 +466,7 @@ export class Agent {
324
466
  // CI and `echo ... | ucode` get the line-based interface instead.
325
467
  this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
326
468
  this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
469
+ this.stats = newStats();
327
470
  this.skills = [];
328
471
  this.session = newSession(cwd, model());
329
472
  this.working = [];
@@ -571,6 +714,7 @@ export class Agent {
571
714
  }
572
715
 
573
716
  async turn(input) {
717
+ forgetReviews(); // a new request: its apps get a fresh design review
574
718
  const images = await this.attachImages(input);
575
719
  this.push(images.length
576
720
  ? { role: 'user', content: input, images }
@@ -599,17 +743,36 @@ export class Agent {
599
743
  this.busy = true;
600
744
  this.abort = new AbortController();
601
745
 
746
+ const turnStarted = Date.now();
747
+ let finished = false;
748
+ this.ui.turnStart?.();
602
749
  try {
603
- 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;
604
762
  } catch (err) {
605
763
  if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
606
764
  else throw err;
607
765
  } finally {
766
+ trace({ kind: 'turn', ms: Date.now() - turnStarted });
767
+ this.stats.workMs += Date.now() - turnStarted;
768
+ this.stats.turns++;
608
769
  this.busy = false;
609
770
  this.abort = null;
610
771
  this.ui.stopSpinner();
772
+ this.ui.turnEnd?.({ ok: finished });
611
773
  await this.persist();
612
774
  if (this.full) this.showHeader({ clear: false });
775
+ if (finished) this.openWhenReady(turnStarted);
613
776
  }
614
777
  }
615
778
 
@@ -631,6 +794,7 @@ export class Agent {
631
794
  this.failovers = 0;
632
795
  this.tried = new Set([model()]);
633
796
 
797
+ this.stuck = new StuckWatch();
634
798
  this.touched = new Set();
635
799
  this.sinceCheck = new Set();
636
800
  this.ranSomething = false;
@@ -669,6 +833,7 @@ export class Agent {
669
833
  };
670
834
  }
671
835
 
836
+ var asked = Date.now();
672
837
  reply = await ask(
673
838
  [
674
839
  {
@@ -682,7 +847,7 @@ export class Agent {
682
847
  memory: this.memory,
683
848
  }),
684
849
  },
685
- ...this.working,
850
+ ...lean(this.working),
686
851
  ],
687
852
  available,
688
853
  opts
@@ -710,7 +875,7 @@ export class Agent {
710
875
 
711
876
  // Busy, slow or down: move to the next model and carry on, rather
712
877
  // than ending a half-built app with an error.
713
- 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;
714
879
  throw err;
715
880
  }
716
881
 
@@ -719,6 +884,15 @@ export class Agent {
719
884
  this.ui.thinkingEnd();
720
885
  this.ui.stopSpinner();
721
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
+ });
722
896
 
723
897
  // Streamed text is already on screen; turn it into rendered markdown.
724
898
  // Text that turns out to be narration ahead of a tool call folds into a
@@ -914,11 +1088,112 @@ export class Agent {
914
1088
  return badArgs;
915
1089
  }
916
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
+
917
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
+ }
918
1193
  if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
919
1194
  if (out.diff?.length) this.ui.diff(out.diff);
920
1195
  if (out.output?.length) this.ui.commandOutput(out.output);
921
- 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 }) });
922
1197
  }
923
1198
 
924
1199
  /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
@@ -932,7 +1207,7 @@ export class Agent {
932
1207
  role: 'tool',
933
1208
  toolCallId: call.id,
934
1209
  name: call.name,
935
- content: err.forModel(),
1210
+ content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
936
1211
  });
937
1212
  return err.kind === 'bad_args';
938
1213
  }
@@ -994,7 +1269,11 @@ export class Agent {
994
1269
 
995
1270
  /** Run a call and settle to { out } or { err } — never throws. */
996
1271
  execute(call) {
997
- 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
+ );
998
1277
  }
999
1278
 
1000
1279
  updatePlan(items) {
@@ -1080,11 +1359,12 @@ export class Agent {
1080
1359
  if (this.abort?.signal.aborted) break;
1081
1360
 
1082
1361
  let reply;
1362
+ const asked = Date.now();
1083
1363
  try {
1084
1364
  reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1085
1365
  } catch (err) {
1086
1366
  // Same rule as the lead: a busy model is swapped, not a reason to stop.
1087
- if (TRANSIENT.has(err.kind) && failovers < 6 && !this.abort?.signal.aborted) {
1367
+ if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
1088
1368
  failovers++;
1089
1369
  let next = fallbackFor(workerModel, tried);
1090
1370
  if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
@@ -1097,6 +1377,11 @@ export class Agent {
1097
1377
  throw err;
1098
1378
  }
1099
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
+ });
1100
1385
 
1101
1386
  if (!reply.toolCalls.length) {
1102
1387
  this.ui.toolResult(`${name} finished`);
@@ -1288,6 +1573,9 @@ export class Agent {
1288
1573
  case '/clear': this.showHeader(); return;
1289
1574
  case '/search': return this.cmdSearch(arg);
1290
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);
1291
1579
  case '/exit':
1292
1580
  case '/quit': return 'exit';
1293
1581
 
@@ -1300,6 +1588,9 @@ export class Agent {
1300
1588
  cmdHelp() {
1301
1589
  const rows = [
1302
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'],
1303
1594
  ['/model', 'show the models and switch between them'],
1304
1595
  ['/resume', 'pick up an earlier conversation'],
1305
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 ?? {};