ucode-agent 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/loop.js CHANGED
@@ -1,1948 +1,2105 @@
1
- /**
2
- * loop.js — the agent itself.
3
- *
4
- * One turn, end to end: take a line, fold the conversation if it has grown too
5
- * big, ask the model, and if it asked for tools, run them and ask again. Repeat
6
- * until it answers with prose instead of a tool call.
7
- *
8
- * Two histories are kept, deliberately:
9
- * session.messages the complete record, written to disk after every step
10
- * working what is actually sent, which may have its older turns
11
- * folded into a summary once the window gets tight
12
- */
13
-
14
- import path from 'node:path';
15
- import os from 'node:os';
16
- import { appendFileSync } from 'node:fs';
17
- import { readFile, access } from 'node:fs/promises';
18
- import { spawn } from 'node:child_process';
19
-
20
- import {
21
- ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
22
- MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
23
- } from './provider.js';
24
- import {
25
- tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
26
- } from '../tools/index.js';
27
- import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
28
- import { autoUpdate } from './updater.js';
29
- import { closeBrowser, forgetReviews } from '../tools/browser.js';
30
- import {
31
- newSession, save, load, list, remove, removeAll, titleFrom,
32
- } from './history.js';
33
- import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
34
- import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
35
- import { Screen, isLabel } from '../ui/screen.js';
36
- import { Plain } from '../ui/plain.js';
37
- import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
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';
44
-
45
- /**
46
- * Tool calls allowed in one turn.
47
- *
48
- * Scaffolding an app is dozens of writes before anything can even be run, so a
49
- * small ceiling stops a real job halfway through — which from the outside is
50
- * indistinguishable from the agent giving up for no reason. The runaway-loop
51
- * protection this exists for still works at 250; a loop burns through that
52
- * just as visibly, only later.
53
- */
54
- const MAX_STEPS = Number(process.env.UCODE_MAX_STEPS) || 250;
55
- const MAX_ARG_RETRIES = 2;
56
-
57
- /**
58
- * How many times a reply cut off at the output limit is asked to carry on.
59
- * Three covers any answer a terminal should be printing; past that the model
60
- * is rambling and stopping is the kinder outcome.
61
- */
62
- const MAX_CONTINUATIONS = 3;
63
-
64
- /** Read-only tools whose result line adds nothing — the user saw the output. */
65
- const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
66
-
67
- /** Tools that draw their own line, so they get no "● Doing X" line of their own. */
68
- const SILENT = new Set(['update_plan']);
69
-
70
- /** How many rounds of "the type check found errors, fix them" one turn may take. */
71
- const MAX_FIX_ROUNDS = 3;
72
-
73
- /** Files worth checking after they change. */
74
- const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
75
-
76
- /**
77
- * Failures that are the provider's and not the model's: busy, slow, down, or
78
- * unreachable. None of them should end a build — the turn moves to another
79
- * model and carries on from exactly where it was.
80
- */
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;
84
- const MAX_FAILOVERS = 8;
85
- const COOLDOWN = 5 * 60_000;
86
-
87
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
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
-
202
- /** Parallel workers at once, and how many steps each may take. */
203
- const MAX_WORKERS = 3;
204
- const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
205
-
206
- /** Workers build; they do not plan, delegate further, or load skills themselves. */
207
- const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
208
-
209
- const planTool = {
210
- name: 'update_plan',
211
- description:
212
- 'Keep a short checklist the user can see, for work with three or more steps. ' +
213
- 'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
214
- 'on the finished ones. Update it as items finish. Skip it for small tasks.',
215
- parameters: {
216
- type: 'object',
217
- properties: {
218
- items: {
219
- type: 'array',
220
- description: 'The whole plan, in order. At most 6.',
221
- items: {
222
- type: 'object',
223
- properties: {
224
- text: { type: 'string', description: 'A few words: "Build the upload box".' },
225
- done: { type: 'boolean', description: 'True once it is finished.' },
226
- },
227
- required: ['text'],
228
- },
229
- },
230
- },
231
- required: ['items'],
232
- },
233
- };
234
-
235
- const delegateTool = {
236
- name: 'delegate',
237
- description:
238
- 'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
239
- 'same tools as you. Use it when the work splits cleanly into parts that touch ' +
240
- 'different files - e.g. the API route, the upload component and the results view. ' +
241
- 'A worker sees only its instructions, so make them complete: the files it owns, ' +
242
- 'what to build, the exact interfaces (props, types, request and response shapes) ' +
243
- 'it must match, and the design rules. Set up shared files (package.json, design ' +
244
- 'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
245
- 'the files it changed; wire the parts together and check the whole afterwards.',
246
- parameters: {
247
- type: 'object',
248
- properties: {
249
- tasks: {
250
- type: 'array',
251
- description: 'Up to 3 independent pieces of work.',
252
- items: {
253
- type: 'object',
254
- properties: {
255
- name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
256
- instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
257
- },
258
- required: ['name', 'instructions'],
259
- },
260
- },
261
- },
262
- required: ['tasks'],
263
- },
264
- };
265
-
266
- /** The instructions a parallel worker starts with. */
267
- function workerPrompt({ cwd, name, memory, skills, map }) {
268
- return [
269
- `You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
270
- '',
271
- `Working directory: ${cwd}`,
272
- `Platform: ${process.platform}`,
273
- '',
274
- '- Do exactly the task you were given. Touch only the files it names, or new files in',
275
- ' the area it owns - other workers are editing the rest of the project right now.',
276
- '- Read before you edit. read_files for several files, batch_write for several new',
277
- ' files, edit_files for changes across files.',
278
- '- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
279
- ' not install packages unless the task says to - say what you need instead.',
280
- '- Finish what you build: real content, every state handled, no TODOs.',
281
- '- When done, reply with two or three sentences: what you built, in which files, and',
282
- ' anything the lead has to wire up.',
283
- '- The files you own may not exist yet - create them. Do not go looking for them',
284
- ' first. The project map below shows what does exist; read only the files whose',
285
- ' interfaces you must match, then start writing within two or three steps.',
286
- ...(memory ? ['', '## Project memory', '', memory] : []),
287
- ...(map ? ['', '## Project map', '', map] : []),
288
- ...(skills ? ['', '## Instructions in force', '', skills] : []),
289
- ].join('\n');
290
- }
291
-
292
- /** The files a writing tool call touches. */
293
- function pathsOf(call) {
294
- const a = call.args ?? {};
295
- if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
296
- return a.path ? [a.path] : [];
297
- }
298
-
299
- const exists = (p) => access(p).then(() => true, () => false);
300
-
301
- /** Skills reach the model as one extra tool, so bodies load only when wanted. */
302
- const loadSkillTool = {
303
- name: 'load_skill',
304
- description:
305
- 'Load the full instructions for one of the skills listed in your system prompt. ' +
306
- 'Call it the moment a task matches one — before planning, before writing anything ' +
307
- '— then follow what it says.',
308
- parameters: {
309
- type: 'object',
310
- properties: { name: { type: 'string', description: 'The skill name, exactly as listed.' } },
311
- required: ['name'],
312
- },
313
- };
314
-
315
- function systemPrompt({ cwd, skills, mode, check, map, memory }) {
316
- const list = catalogue(skills);
317
-
318
- return [
319
- 'You are ucode, a coding agent working directly in the user\'s terminal.',
320
- '',
321
- `Working directory: ${cwd}`,
322
- `Platform: ${process.platform}`,
323
- ...(memory ? [
324
- '',
325
- '## Project memory',
326
- '',
327
- 'Standing instructions from the user. They outrank your defaults.',
328
- '',
329
- memory,
330
- ] : []),
331
- '',
332
- '## How to work',
333
- '',
334
- ...(mode === 'plan' ? [
335
- 'You are in PLAN MODE. Reading, searching and research are available; every tool',
336
- 'that writes a file or runs a command has been withheld. Investigate, then set out',
337
- 'what you would change: which files, which functions, in what order. Never imply',
338
- 'you have made a change you are not able to make.',
339
- '',
340
- ] : []),
341
- ...(check ? [
342
- `This project checks itself with: ${check}`,
343
- 'After changing code, run that and report what actually happened. "It should work"',
344
- 'is not a result.',
345
- '',
346
- ] : []),
347
- '- Read before you write. Never edit a file you have not read this session.',
348
- '- Prefer edit_file to write_file. Rewrite a whole file only when creating it, or',
349
- ' when the change genuinely touches most of it.',
350
- '- old_string must be copied out of what read_file showed you, character for',
351
- ' character, without the line-number gutter, and must appear exactly once. Add',
352
- ' surrounding lines until it does.',
353
- '- read_file returns up to 600 lines. Read the whole file before editing it rather',
354
- ' than editing from a fragment; pass offset to continue a long one.',
355
- '',
356
- '## Going fast',
357
- '',
358
- 'Every tool call is a round trip to you, and the round trip not the disk, not',
359
- 'the shell is where the time goes. So:',
360
- '',
361
- '- Need more than one file? read_files, all of them in one call. Never read files',
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.',
365
- '- Put independent calls in the same message — several greps, a glob and a read.',
366
- ' Read-only calls in one message run at the same time.',
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',
372
- ' to one file, edit_files for a change that spans several files.',
373
- '- For work with three or more steps, keep a short plan with update_plan - at most',
374
- ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
375
- '- When a build splits into parts that touch different files (the API route, the',
376
- ' upload component, the results view), set up the shared files yourself, then hand',
377
- ' the parts to delegate so they are built in parallel.',
378
- '- A package.json you write starts installing in the background immediately; keep',
379
- ' writing files. Running the install yourself afterwards just waits for that one.',
380
- '- When you finish, ucode type-checks what you changed and hands you the errors, so',
381
- ' there is no need to run tsc yourself.',
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.',
388
- '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
389
- ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
390
- ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
391
- '- Dev servers start in the background by themselves, and the result tells you the',
392
- ' URL once the server says it is ready. Do not start one twice, do not sleep while',
393
- ' waiting for it, and do not curl it before that result comes back.',
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
- '',
406
- '## Safety',
407
- '',
408
- '- run_command runs without asking. That is trust rather than licence: never run',
409
- ' anything destructive the user did not ask for.',
410
- '- Paths are relative to the working directory. Anything outside it needs the user',
411
- ' to approve it first.',
412
- '- Verify. After changing code, run the tests or a quick check with run_command.',
413
- '',
414
- '## Saying what you are doing',
415
- '',
416
- '- Before every tool call, write ONE short line naming the file or command:',
417
- ' "Reading tui.js", "Fixing the spinner in loop.js", "Running npm test".',
418
- '- Present tense, under ten words, and no full stop at the end. It is a label on',
419
- ' work happening right now, not a sentence about work that is finished.',
420
- '- That line and nothing else in the message. No preamble, no plan, no bullets ',
421
- ' the user reads it live while the tool runs.',
422
- '- Say the next one when you take the next step, not all of them up front.',
423
- '',
424
- '## Answering',
425
- '',
426
- '- Be short. Two or three sentences is usually the entire answer. This is a',
427
- ' terminal, not a document.',
428
- '- No preamble, no restating the question, no "I will now...". Just answer.',
429
- '- Do not narrate what the tool output already showed. The user watched the diff',
430
- ' and the command output; cover only what is not obvious from them.',
431
- '- Skip closing summaries of work the user just watched you do but never end',
432
- ' a turn silently. If there is genuinely nothing to add, one short line saying',
433
- ' what changed is the whole answer.',
434
- '- Markdown. Fenced blocks with a language tag get highlighted.',
435
- '- Point at code as path:line so the user can jump straight to it.',
436
- '- Report honestly. If a command failed or you skipped something, say so.',
437
- '- Length tracks the question: a one-line question gets a one-line answer.',
438
- '- Brevity is about your prose and never about your work. What you build is',
439
- ' finished: every control wired, every state handled, no TODO left behind.',
440
- ...(list ? [
441
- '',
442
- '## Skills',
443
- '',
444
- 'These instruction packs are available. When a task matches one, load it with',
445
- 'load_skill as your first step before planning, before writing anything — and',
446
- 'then follow it. A skill already in this conversation outranks your own defaults',
447
- 'and is not advisory.',
448
- '',
449
- list,
450
- ] : []),
451
- '',
452
- '## Project map',
453
- '',
454
- 'Every file in the project at the start of this turn, with the names each code file',
455
- 'exports. Go straight to the files you need instead of searching for them.',
456
- '',
457
- map || '(not available)',
458
- ].join('\n');
459
- }
460
-
461
- export class Agent {
462
- constructor({ cwd, debug = false }) {
463
- this.cwd = cwd;
464
- this.debug = debug;
465
- // A full-screen layout only makes sense on a real terminal. Piped input,
466
- // CI and `echo ... | ucode` get the line-based interface instead.
467
- this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
468
- this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
469
- this.stats = newStats();
470
- this.skills = [];
471
- this.session = newSession(cwd, model());
472
- this.working = [];
473
- this.loaded = new Set();
474
- this.abort = null;
475
- this.busy = false;
476
- this.check = null;
477
- }
478
-
479
- // -- history -------------------------------------------------------------
480
-
481
- push(message) {
482
- this.session.messages.push(message);
483
- this.working.push(message);
484
- }
485
-
486
- async persist() {
487
- try {
488
- this.session.model = model();
489
- await save(this.session);
490
- } catch (err) {
491
- // Losing the save must not lose the turn.
492
- this.ui.error(err, { debug: this.debug });
493
- }
494
- }
495
-
496
- // -- startup -------------------------------------------------------------
497
-
498
- /**
499
- * Everything a turn needs, minus the terminal.
500
- *
501
- * Split out of start() so another front end could prepare an agent and drive
502
- * turn() itself.
503
- */
504
- async bootstrap() {
505
- setRoot(this.cwd);
506
- // Parallel workers can ask at the same moment; the questions queue up and
507
- // are put to the user one at a time, never on top of each other.
508
- let asking = Promise.resolve();
509
- setConfirm((request) => {
510
- const next = asking.then(() => this.ui.confirm(request));
511
- asking = next.catch(() => {});
512
- return next;
513
- });
514
- this.skills = await loadSkills({ cwd: this.cwd });
515
- await this.detectCheck();
516
- }
517
-
518
- async start() {
519
- await this.bootstrap();
520
-
521
- if (this.full) {
522
- await this.ui.start();
523
- this.ui.onInterrupt = () => {
524
- if (this.busy && this.abort) {
525
- this.abort.abort();
526
- this.ui.stopSpinner();
527
- this.ui.note('interrupted');
528
- }
529
- };
530
- this.ui.onModeChange = () => this.showHeader({ clear: false });
531
- }
532
-
533
- for (const problem of this.skills.problems ?? []) {
534
- this.ui.write(theme.warn(` skill not loaded: ${problem}`));
535
- }
536
-
537
- this.showHeader();
538
- this.installSignals();
539
-
540
- // Checked in the background; nothing here waits on it.
541
- autoUpdate({
542
- onUpdated: (version) => {
543
- this.ui.setFacts?.({ update: version });
544
- if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
545
- },
546
- });
547
- await this.repl();
548
- }
549
-
550
- showHeader({ clear = true } = {}) {
551
- if (clear && this.full) this.ui.clearScreen();
552
- const stats = usage(this.working, contextLimit());
553
- this.ui.header({
554
- cwd: this.cwd,
555
- model: modelName(),
556
- used: stats.used,
557
- limit: stats.limit,
558
- title: this.session.title === 'Untitled' ? 'new session' : this.session.title,
559
- });
560
- }
561
-
562
- installSignals() {
563
- const flush = async () => {
564
- await save(this.session).catch(() => {});
565
- process.exit(0);
566
- };
567
- process.on('SIGTERM', flush);
568
-
569
- // The full-screen UI reads keys itself, so it owns ctrl+c and esc.
570
- if (this.full) return;
571
-
572
- this.ui.rl.on('SIGINT', () => {
573
- if (this.busy && this.abort) {
574
- this.abort.abort();
575
- this.ui.stopSpinner();
576
- this.ui.write(dim(' interrupted'));
577
- return;
578
- }
579
- this.ui.write(dim(' (ctrl+d or /exit to quit)'));
580
- this.ui.rl.prompt();
581
- });
582
- }
583
-
584
- /**
585
- * How this project verifies itself, worked out once at startup. Null when
586
- * there is genuinely nothing to run — verification is only insisted on where
587
- * there is something to insist on.
588
- */
589
- async detectCheck() {
590
- const has = (f) => readFile(path.join(this.cwd, f)).then(() => true, () => false);
591
-
592
- if (await has('package.json')) {
593
- try {
594
- const pkg = JSON.parse(await readFile(path.join(this.cwd, 'package.json'), 'utf8'));
595
- if (pkg.scripts?.test && !/no test specified/i.test(pkg.scripts.test)) {
596
- this.check = 'npm test';
597
- return;
598
- }
599
- } catch { /* an unreadable package.json is not worth failing over */ }
600
- }
601
- if (await has('Cargo.toml')) { this.check = 'cargo test'; return; }
602
- if (await has('go.mod')) { this.check = 'go test ./...'; return; }
603
- if (await has('pyproject.toml') || await has('pytest.ini')) { this.check = 'pytest'; return; }
604
- if (await has('Makefile')) { this.check = 'make test'; return; }
605
- this.check = null;
606
- }
607
-
608
- // -- REPL ----------------------------------------------------------------
609
-
610
- async repl() {
611
- let sawInput = false;
612
-
613
- for (;;) {
614
- const line = await this.ui.ask();
615
- if (line === null) {
616
- // End of input before anything was typed. On Windows this is almost
617
- // always npm's PowerShell shim, which runs the CLI as `$input | node`.
618
- // The pipe makes stdin a non-TTY, readline hits EOF at once, and the
619
- // banner flashes up and vanishes — which looks like a crash rather
620
- // than like a program that was never given a keyboard. So say which.
621
- if (!sawInput && !process.stdin.isTTY) this.explainNoKeyboard();
622
- break;
623
- }
624
-
625
- const input = line.trim();
626
- if (input) sawInput = true;
627
- if (!input) continue;
628
-
629
- if (input.startsWith('/')) {
630
- if (await this.command(input) === 'exit') break;
631
- continue;
632
- }
633
-
634
- try {
635
- await this.turn(input);
636
- } catch (err) {
637
- this.ui.error(err, { debug: this.debug });
638
- }
639
- }
640
-
641
- await this.shutdown();
642
- }
643
-
644
- explainNoKeyboard() {
645
- this.ui.blank();
646
- this.ui.write(theme.warn(' ucode could not reach the keyboard, so it stopped.'));
647
- this.ui.blank();
648
- this.ui.write(' That happens when input is piped rather than typed. On Windows it is');
649
- this.ui.write(" usually npm's PowerShell wrapper, which pipes stdin.");
650
- this.ui.blank();
651
- this.ui.write(` ${blue('Any of these work:')}`);
652
- this.ui.write(` ${sky('ucode.cmd')} the cmd shim, which keeps the keyboard`);
653
- this.ui.write(` ${sky('npx ucode-agent')} runs it directly`);
654
- this.ui.write(' or start it from Command Prompt or Windows Terminal');
655
- this.ui.blank();
656
- }
657
-
658
- async shutdown() {
659
- await closeBrowser().catch(() => {});
660
- this.ui.stopSpinner();
661
- if (this.session.messages.length) {
662
- await this.persist();
663
- this.ui.write(dim(`\n saved · ${this.session.title}`));
664
- }
665
- this.ui.close();
666
- }
667
-
668
- // -- one turn ------------------------------------------------------------
669
-
670
- /**
671
- * Pull image paths out of the message and load them, so "what is wrong in
672
- * screenshot.png" works without a separate command for it.
673
- */
674
- async attachImages(input) {
675
- const mentioned = input.match(/[^\s"']+\.(?:png|jpe?g|gif|webp)\b/gi) ?? [];
676
- const images = [];
677
-
678
- for (const name of mentioned) {
679
- const file = path.resolve(this.cwd, name);
680
- try {
681
- const buf = await readFile(file);
682
- if (buf.length > 4 * 1024 * 1024) {
683
- this.ui.note(`${name} is ${(buf.length / 1024 / 1024).toFixed(1)}MB — too big to send, skipped`);
684
- continue;
685
- }
686
- const ext = path.extname(file).toLowerCase().slice(1);
687
- images.push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`);
688
- this.ui.note(`attached ${name}`);
689
- } catch {
690
- // Just a filename mentioned in passing, not a file on disk.
691
- }
692
- }
693
-
694
- return images;
695
- }
696
-
697
- /**
698
- * Skills that this request should arrive with, already loaded.
699
- *
700
- * The load_skill tool asks the model to notice that a task needs a skill,
701
- * and a model in a hurry to be helpful does not always notice. For work
702
- * where the skill *is* the quality bar anything with a user interface in
703
- * it that is not a discovery to make after the app has been built. So the
704
- * request is matched against each skill's trigger words and the body goes in
705
- * before the model takes its first step.
706
- */
707
- autoLoad(input) {
708
- for (const skill of autoLoadFor(this.skills, input)) {
709
- if (this.loaded.has(skill.name)) continue;
710
- this.loaded.add(skill.name);
711
- this.push(skillMessage(skill, { automatic: true }));
712
- this.ui.note(`${skill.name} skill loaded for this`);
713
- }
714
- }
715
-
716
- async turn(input) {
717
- forgetReviews(); // a new request: its apps get a fresh design review
718
- const images = await this.attachImages(input);
719
- this.push(images.length
720
- ? { role: 'user', content: input, images }
721
- : { role: 'user', content: input });
722
-
723
- if (!this.session.title || this.session.title === 'Untitled') {
724
- this.session.title = titleFrom(input);
725
- }
726
-
727
- this.autoLoad(input);
728
- // What the model is told about the project, fresh for this turn.
729
- [this.map, this.memory] = await Promise.all([
730
- projectMap(this.cwd).catch(() => ''),
731
- loadMemory(this.cwd).catch(() => ''),
732
- ]);
733
- await this.persist();
734
-
735
- // A busy model was swapped for a fallback earlier; after a few minutes the
736
- // one the user chose gets another go.
737
- this.preferred ??= model();
738
- if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
739
- setModel(this.preferred);
740
- if (this.full) this.showHeader({ clear: false });
741
- }
742
-
743
- this.busy = true;
744
- this.abort = new AbortController();
745
-
746
- const turnStarted = Date.now();
747
- let finished = false;
748
- this.ui.turnStart?.();
749
- try {
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;
762
- } catch (err) {
763
- if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
764
- else throw err;
765
- } finally {
766
- trace({ kind: 'turn', ms: Date.now() - turnStarted });
767
- this.stats.workMs += Date.now() - turnStarted;
768
- this.stats.turns++;
769
- this.busy = false;
770
- this.abort = null;
771
- this.ui.stopSpinner();
772
- this.ui.turnEnd?.({ ok: finished });
773
- await this.persist();
774
- if (this.full) this.showHeader({ clear: false });
775
- if (finished) this.openWhenReady(turnStarted);
776
- }
777
- }
778
-
779
- /** The tools the model may see, given the mode. */
780
- toolsNow() {
781
- const all = [...tools, loadSkillTool, planTool, delegateTool];
782
- if (this.ui.mode !== 'plan') return all;
783
- return all.filter((t) => !WRITES.has(t.name));
784
- }
785
-
786
- /** Model, tools, model, until it answers with prose. */
787
- async run() {
788
- const available = this.toolsNow();
789
- let argRetries = 0;
790
- let continuations = 0;
791
- let askedToVerify = false;
792
- let askedToSpeak = false;
793
- let fixRounds = 0;
794
- this.failovers = 0;
795
- this.tried = new Set([model()]);
796
-
797
- this.stuck = new StuckWatch();
798
- this.touched = new Set();
799
- this.sinceCheck = new Set();
800
- this.ranSomething = false;
801
-
802
- for (let step = 0; step < MAX_STEPS; step++) {
803
- await this.maybeFold();
804
- this.ui.startSpinner(step === 0 ? 'thinking' : 'working');
805
-
806
- let reply;
807
- let streaming = false;
808
- this.early = new Map();
809
-
810
- try {
811
- const opts = {
812
- signal: this.abort.signal,
813
- onWait: (text) => this.ui.updateSpinner(text),
814
- };
815
- // Only a real terminal has somewhere to stream into.
816
- if (this.full) {
817
- opts.onThinking = () => this.ui.thinkingDelta();
818
- opts.onText = (delta) => {
819
- if (!streaming) {
820
- streaming = true;
821
- this.ui.thinkingEnd();
822
- this.ui.streamBegin();
823
- }
824
- this.ui.streamDelta(delta);
825
- };
826
- // Read-only calls start the moment they are fully written, while the
827
- // rest of the reply is still arriving. Nothing that writes or runs is
828
- // started early: a reply that fails halfway must leave no side effects.
829
- opts.onToolCall = (call) => {
830
- if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
831
- this.early.set(call.id, this.execute(call));
832
- }
833
- };
834
- }
835
-
836
- var asked = Date.now();
837
- reply = await ask(
838
- [
839
- {
840
- role: 'system',
841
- content: systemPrompt({
842
- cwd: this.cwd,
843
- skills: this.skills,
844
- mode: this.ui.mode,
845
- check: this.check,
846
- map: this.map,
847
- memory: this.memory,
848
- }),
849
- },
850
- ...lean(this.working),
851
- ],
852
- available,
853
- opts
854
- );
855
- } catch (err) {
856
- this.ui.thinkingEnd();
857
- if (streaming) this.ui.streamEnd();
858
-
859
- // The model invented a tool and the provider rejected the request
860
- // outright. Tell it what it did and let it try again.
861
- if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
862
- argRetries++;
863
- this.ui.stopSpinner();
864
- this.ui.toolFailed(
865
- `${err.detail?.attemptedName ?? 'invalid tool call'} retrying (${argRetries}/${MAX_ARG_RETRIES})`
866
- );
867
- this.push({
868
- role: 'user',
869
- content:
870
- `Your last tool call was rejected. ${err.failed} The only tools that exist ` +
871
- `are: ${available.map((t) => t.name).join(', ')}. Try again with one of them.`,
872
- });
873
- continue;
874
- }
875
-
876
- // Busy, slow or down: move to the next model and carry on, rather
877
- // than ending a half-built app with an error.
878
- if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
879
- throw err;
880
- }
881
-
882
- // A reply that is nothing but tool calls never starts a text stream, so
883
- // the thinking timer has to be closed out here as well.
884
- this.ui.thinkingEnd();
885
- this.ui.stopSpinner();
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
- });
896
-
897
- // Streamed text is already on screen; turn it into rendered markdown.
898
- // Text that turns out to be narration ahead of a tool call folds into a
899
- // status line instead that is where the live commentary comes from.
900
- const narrating = reply.toolCalls.length > 0;
901
- if (streaming) this.ui.streamEnd({ asNarration: narrating });
902
- else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
903
- else if (reply.text) this.ui.assistant(reply.text);
904
-
905
- if (reply.toolCalls.length === 0) {
906
- // The answer stopped at the provider's output cap rather than at the
907
- // end of a thought, so it is cut mid-word. Ask for the rest instead of
908
- // handing over half an answer with no sign there was more.
909
- if (reply.finishReason === 'length' && reply.text && continuations < MAX_CONTINUATIONS) {
910
- continuations++;
911
- this.push({ role: 'assistant', content: reply.text });
912
- this.push({
913
- role: 'user',
914
- content:
915
- 'Your reply stopped at the output limit, mid-sentence. Carry on from exactly ' +
916
- 'where it broke off. Do not repeat any of it, do not start again, and do not ' +
917
- 'introduce it — just continue.',
918
- });
919
- this.ui.note('hit the output limit — asking for the rest');
920
- continue;
921
- }
922
-
923
- // Type-check what changed and hand back the errors, a few rounds at most.
924
- // A turn that ends on a broken build is the most common way an app
925
- // gets handed over as done when it is not.
926
- if (fixRounds < MAX_FIX_ROUNDS) {
927
- const problems = await this.autoCheck();
928
- if (problems) {
929
- fixRounds++;
930
- if (reply.text) this.push({ role: 'assistant', content: reply.text });
931
- this.push({
932
- role: 'user',
933
- content:
934
- `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
935
- `${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
936
- });
937
- continue;
938
- }
939
- }
940
-
941
- // It changed code and never ran anything. Send it back once.
942
- if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
943
- askedToVerify = true;
944
- if (reply.text) this.push({ role: 'assistant', content: reply.text });
945
- this.push({
946
- role: 'user',
947
- content:
948
- `You changed ${[...this.touched].join(', ')} and did not check it. Run ` +
949
- `\`${this.check}\` with run_command now, then say what actually happened — ` +
950
- 'if it failed, show the output rather than claiming it worked. If that is ' +
951
- 'the wrong way to check this project, run the right one and say which.',
952
- });
953
- this.ui.note('verifying the change');
954
- continue;
955
- }
956
-
957
- /**
958
- * It did the work and then said nothing at all.
959
- *
960
- * Reasoning models do this, and the instruction to skip closing
961
- * summaries makes it more likely. Silence is fine as a style; it is
962
- * not fine as an answer, because the user cannot tell it apart from a
963
- * crash — and if they asked what happened, they asked. One nudge,
964
- * once per turn, and only when there was actually work to report.
965
- */
966
- if (!reply.text?.trim() && this.touched.size + (this.ranSomething ? 1 : 0) > 0 && !askedToSpeak) {
967
- askedToSpeak = true;
968
- this.push({
969
- role: 'user',
970
- content:
971
- 'You finished without saying anything. In one or two sentences: what did ' +
972
- 'you change, and does it work? No preamble, no repeating the diffs.',
973
- });
974
- continue;
975
- }
976
-
977
- this.push({ role: 'assistant', content: reply.text });
978
- if (!reply.text?.trim()) this.ui.note('the model ended the turn without a reply');
979
- return;
980
- }
981
-
982
- this.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
983
- await this.persist();
984
-
985
- const badArgs = await this.runCalls(reply.toolCalls);
986
- if (this.abort.signal.aborted) return;
987
-
988
- // Malformed arguments go back to the model, but only so many times.
989
- if (badArgs) {
990
- argRetries++;
991
- if (argRetries > MAX_ARG_RETRIES) {
992
- throw new Failure({
993
- kind: 'bad_tool_args',
994
- attempted: 'running the tools the model asked for',
995
- failed:
996
- `${modelName()} produced invalid tool arguments ${argRetries} times running ` +
997
- 'and could not correct itself.',
998
- fix:
999
- 'Say what you want more concretely, or /model to another one — North Mini ' +
1000
- 'Code and Nemotron 3.5 Lightning are both steadier with tool arguments.',
1001
- });
1002
- }
1003
- } else {
1004
- argRetries = 0;
1005
- }
1006
-
1007
- await this.persist();
1008
- }
1009
-
1010
- throw new Failure({
1011
- kind: 'step_limit',
1012
- attempted: 'finishing your request',
1013
- failed: `The model was still calling tools after ${MAX_STEPS} steps.`,
1014
- fix:
1015
- 'Nothing is lost — everything so far is on disk. Say "carry on where you left ' +
1016
- 'off" to continue. If it was repeating one step, it is looping: break the task ' +
1017
- 'up, or /new to reset.',
1018
- });
1019
- }
1020
-
1021
- /**
1022
- * Run one round of tool calls.
1023
- *
1024
- * Consecutive read-only calls go out together — four files read at once
1025
- * rather than four round trips while anything that writes or executes runs
1026
- * on its own, in order. Returns whether any call had unusable arguments.
1027
- */
1028
- async runCalls(calls) {
1029
- const groups = [];
1030
- let batch = [];
1031
-
1032
- for (const call of calls) {
1033
- if (PARALLEL_SAFE.has(call.name)) {
1034
- batch.push(call);
1035
- } else {
1036
- if (batch.length) { groups.push(batch); batch = []; }
1037
- groups.push([call]);
1038
- }
1039
- }
1040
- if (batch.length) groups.push(batch);
1041
-
1042
- let badArgs = false;
1043
-
1044
- for (const group of groups) {
1045
- if (this.abort.signal.aborted) return badArgs;
1046
-
1047
- const noted = (call) => {
1048
- if (FILE_WRITES.has(call.name)) {
1049
- for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
1050
- }
1051
- if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1052
- };
1053
-
1054
- if (group.length > 1) {
1055
- for (const call of group) {
1056
- this.ui.toolCall(describe(call.name, call.args));
1057
- noted(call);
1058
- }
1059
- this.ui.startSpinner(`${group.length} lookups at once`);
1060
-
1061
- const settled = await Promise.all(
1062
- group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
1063
- );
1064
-
1065
- this.ui.stopSpinner();
1066
- for (const { call, out, err } of settled) {
1067
- if (err) badArgs = this.reportFailure(call, err) || badArgs;
1068
- else this.reportResult(call, out);
1069
- }
1070
- continue;
1071
- }
1072
-
1073
- for (const call of group) {
1074
- if (this.abort.signal.aborted) return badArgs;
1075
-
1076
- const label = describe(call.name, call.args);
1077
- if (!SILENT.has(call.name)) this.ui.toolCall(label);
1078
- this.ui.startSpinner(label);
1079
- noted(call);
1080
-
1081
- const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
1082
- this.ui.stopSpinner();
1083
- if (err) badArgs = this.reportFailure(call, err) || badArgs;
1084
- else this.reportResult(call, out);
1085
- }
1086
- }
1087
-
1088
- return badArgs;
1089
- }
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
-
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
- }
1193
- if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
1194
- if (out.diff?.length) this.ui.diff(out.diff);
1195
- if (out.output?.length) this.ui.commandOutput(out.output);
1196
- this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1197
- }
1198
-
1199
- /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
1200
- reportFailure(call, err) {
1201
- if (!(err instanceof ToolFailure)) throw err;
1202
-
1203
- this.ui.toolFailed(
1204
- err instanceof Declined ? 'declined' : `${err.kind}: ${err.failed}`
1205
- );
1206
- this.push({
1207
- role: 'tool',
1208
- toolCallId: call.id,
1209
- name: call.name,
1210
- content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
1211
- });
1212
- return err.kind === 'bad_args';
1213
- }
1214
-
1215
- async dispatch(call) {
1216
- // The model emitted arguments that were not valid JSON. Hand the parser's
1217
- // own complaint straight back so it can correct itself next step.
1218
- if (call.parseError) {
1219
- throw new ToolFailure({
1220
- kind: 'bad_args',
1221
- attempted: `calling ${call.name}`,
1222
- failed: `The arguments were not valid JSON: ${call.parseError}`,
1223
- fix: `Call ${call.name} again with the arguments as one well-formed JSON object.`,
1224
- });
1225
- }
1226
-
1227
- if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1228
- if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1229
- if (call.name === 'delegate') return this.delegate(call.args?.tasks);
1230
-
1231
- // Output reaches the screen as the command produces it, so a slow build is
1232
- // something you watch rather than something you sit out in silence.
1233
- return runTool(call.name, call.args ?? {}, {
1234
- onOutput: (lines) => this.ui.progress(lines),
1235
- });
1236
- }
1237
-
1238
- /**
1239
- * Switch to the next model after a provider failure. Returns false once
1240
- * there is nothing sensible left to try. When every model is busy at once,
1241
- * it waits a minute and goes round again rather than giving up.
1242
- */
1243
- async failover(err) {
1244
- if (++this.failovers > MAX_FAILOVERS) return false;
1245
- const from = model();
1246
- let next = fallbackFor(from, this.tried);
1247
-
1248
- if (!next) {
1249
- const until = Date.now() + 60_000;
1250
- this.ui.startSpinner('every model is busy');
1251
- while (Date.now() < until && !this.abort?.signal.aborted) {
1252
- this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
1253
- await wait(1000);
1254
- }
1255
- this.ui.stopSpinner();
1256
- if (this.abort?.signal.aborted) return false;
1257
- this.tried = new Set();
1258
- next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
1259
- }
1260
-
1261
- this.tried.add(next);
1262
- setModel(next);
1263
- this.cooldownUntil = Date.now() + COOLDOWN;
1264
- const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
1265
- this.ui.note(`${modelName(from)} is ${why} — carrying on with ${modelName(next)}`);
1266
- if (this.full) this.showHeader({ clear: false });
1267
- return true;
1268
- }
1269
-
1270
- /** Run a call and settle to { out } or { err } — never throws. */
1271
- execute(call) {
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
- );
1277
- }
1278
-
1279
- updatePlan(items) {
1280
- const list = (Array.isArray(items) ? items : [])
1281
- .filter((i) => i && String(i.text ?? '').trim())
1282
- .slice(0, 6);
1283
- this.ui.plan(list);
1284
- const done = list.filter((i) => i.done).length;
1285
- return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
1286
- }
1287
-
1288
- /** File writes from parallel workers take turns, so two never interleave. */
1289
- fileLock(fn) {
1290
- const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
1291
- this.lockChain = run.catch(() => {});
1292
- return run;
1293
- }
1294
-
1295
- /**
1296
- * Several workers at once, each its own small agent loop with its own
1297
- * conversation, sharing the tools, the project, and whatever skills are
1298
- * already in force. Their lines in the transcript carry their name.
1299
- */
1300
- async delegate(tasks) {
1301
- const list = (Array.isArray(tasks) ? tasks : [])
1302
- .filter((t) => t && String(t.instructions ?? '').trim())
1303
- .slice(0, MAX_WORKERS);
1304
- if (!list.length) {
1305
- throw new ToolFailure({
1306
- kind: 'bad_args',
1307
- attempted: 'starting workers',
1308
- failed: 'No tasks with instructions were given.',
1309
- fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
1310
- });
1311
- }
1312
-
1313
- const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
1314
- name: task.name || `worker ${i + 1}`,
1315
- summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
1316
- touched: [],
1317
- }))));
1318
-
1319
- for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
1320
-
1321
- // A worker that wrote nothing has not done its part, whatever it said.
1322
- // The lead builds those itself rather than leaving holes in the app.
1323
- const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
1324
-
1325
- return {
1326
- content: results
1327
- .map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
1328
- .join('\n\n') +
1329
- (empty.length
1330
- ? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
1331
- 'yourself now, directly - do not delegate them again.'
1332
- : ''),
1333
- summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
1334
- };
1335
- }
1336
-
1337
- async runWorker(task, index) {
1338
- const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
1339
- const touched = new Set();
1340
- const skills = this.skills
1341
- .filter((s) => this.loaded.has(s.name))
1342
- .map((s) => `--- ${s.name} ---\n${s.body}`)
1343
- .join('\n\n');
1344
- const messages = [
1345
- { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
1346
- { role: 'user', content: String(task.instructions) },
1347
- ];
1348
- const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
1349
- const wanted = process.env.UCODE_WORKER_MODEL;
1350
- let workerModel = wanted && MODELS[wanted] ? wanted : model();
1351
- const tried = new Set([workerModel]);
1352
- let failovers = 0;
1353
-
1354
- // Start a moment apart. Three requests in the same instant is exactly what
1355
- // trips a free endpoint's rate limit, and the stagger costs a second or two.
1356
- if (index) await wait(index * 1500);
1357
-
1358
- for (let step = 0; step < WORKER_STEPS; step++) {
1359
- if (this.abort?.signal.aborted) break;
1360
-
1361
- let reply;
1362
- const asked = Date.now();
1363
- try {
1364
- reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1365
- } catch (err) {
1366
- // Same rule as the lead: a busy model is swapped, not a reason to stop.
1367
- if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
1368
- failovers++;
1369
- let next = fallbackFor(workerModel, tried);
1370
- if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
1371
- tried.add(next);
1372
- this.ui.note(`${name}: ${modelName(workerModel)} is busy switching to ${modelName(next)}`);
1373
- workerModel = next;
1374
- step--;
1375
- continue;
1376
- }
1377
- throw err;
1378
- }
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
- });
1385
-
1386
- if (!reply.toolCalls.length) {
1387
- this.ui.toolResult(`${name} finished`);
1388
- return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
1389
- }
1390
-
1391
- messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1392
- for (const call of reply.toolCalls) {
1393
- this.ui.toolCall(`${name} ${describe(call.name, call.args)}`);
1394
- if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
1395
- const { out, err } = FILE_WRITES.has(call.name)
1396
- ? await this.fileLock(() => this.execute(call))
1397
- : await this.execute(call);
1398
- if (err) {
1399
- if (!(err instanceof ToolFailure)) throw err;
1400
- this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
1401
- }
1402
- messages.push({
1403
- role: 'tool', toolCallId: call.id, name: call.name,
1404
- content: err ? err.forModel() : out.content,
1405
- });
1406
- }
1407
- }
1408
-
1409
- return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
1410
- }
1411
-
1412
- /**
1413
- * Check the code files changed since the last check, and return the
1414
- * errors as text for the model or null when everything is clean.
1415
- *
1416
- * TypeScript projects get one `tsc --noEmit` per project that owns a
1417
- * changed file (an app scaffolded into a subfolder is its own project).
1418
- * Plain JavaScript gets a syntax check, Python a compile check. Nothing
1419
- * runs that is not already installed.
1420
- */
1421
- async autoCheck() {
1422
- const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
1423
- this.sinceCheck.clear();
1424
- if (!changed.length) return null;
1425
-
1426
- const root = path.resolve(this.cwd);
1427
- const tsRoots = new Set();
1428
- const singles = [];
1429
-
1430
- for (const rel of changed) {
1431
- const abs = path.resolve(root, rel);
1432
- if (!(await exists(abs))) continue;
1433
- if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
1434
- let dir = path.dirname(abs);
1435
- let owner = null;
1436
- while (dir.startsWith(root)) {
1437
- if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
1438
- const up = path.dirname(dir);
1439
- if (up === dir) break;
1440
- dir = up;
1441
- }
1442
- if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
1443
- else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
1444
- }
1445
-
1446
- const problems = [];
1447
- const check = async (label, command, cwd) => {
1448
- this.ui.toolCall(label);
1449
- this.ui.startSpinner(label);
1450
- const { out, err } = await this.execute({
1451
- id: 'check', name: 'run_command',
1452
- args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
1453
- });
1454
- this.ui.stopSpinner();
1455
- return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
1456
- };
1457
-
1458
- for (const dir of tsRoots) {
1459
- const show = path.relative(root, dir) || '.';
1460
- const out = await check(`Checking types in ${show}`, 'npx --no-install tsc --noEmit --pretty false', dir);
1461
- if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
1462
- const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
1463
- this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
1464
- problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
1465
- }
1466
-
1467
- for (const f of singles) {
1468
- const out = await check(`Checking ${f.rel}`, f.command, root);
1469
- if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
1470
- this.ui.toolFailed('does not compile');
1471
- problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
1472
- }
1473
-
1474
- return problems.length ? problems.join('\n\n') : null;
1475
- }
1476
-
1477
- loadSkill(name) {
1478
- const skill = findSkill(this.skills, name);
1479
- if (!skill) {
1480
- // The available names go in `failed` rather than only in `fix`: the
1481
- // transcript shows the failure line, and a bare "no such skill" leaves
1482
- // the user guessing at what this session actually has.
1483
- const available = this.skills.map((s) => s.name).join(', ') || '(none)';
1484
- throw new ToolFailure({
1485
- kind: 'no_such_skill',
1486
- attempted: `loading the "${name}" skill`,
1487
- failed: `There is no skill called "${name}". This session has: ${available}.`,
1488
- fix: 'Use one of those names, or carry on without one.',
1489
- });
1490
- }
1491
-
1492
- if (this.loaded.has(skill.name)) {
1493
- return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
1494
- }
1495
-
1496
- this.loaded.add(skill.name);
1497
- this.push(skillMessage(skill));
1498
- return {
1499
- content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
1500
- summary: `${skill.name} · ${skill.body.split('\n').length} lines`,
1501
- };
1502
- }
1503
-
1504
- record(u) {
1505
- const total = this.session.usage;
1506
- total.promptTokens += u.promptTokens || 0;
1507
- total.outputTokens += u.outputTokens || 0;
1508
- total.totalTokens += u.totalTokens || 0;
1509
- total.turns += 1;
1510
- }
1511
-
1512
- /** Fold older turns into a summary when the window gets tight. */
1513
- async maybeFold() {
1514
- const limit = contextLimit();
1515
- if (!tooBig(this.working, limit)) return;
1516
-
1517
- this.ui.startSpinner('context is filling up — summarizing earlier turns');
1518
- try {
1519
- const result = await fold(this.working, {
1520
- limit,
1521
- summarize: async (older) => {
1522
- const reply = await ask(
1523
- [
1524
- { role: 'system', content: SUMMARY_PROMPT },
1525
- { role: 'user', content: forSummary(older) },
1526
- ],
1527
- [],
1528
- { signal: this.abort?.signal, temperature: 0 }
1529
- );
1530
- return reply.text;
1531
- },
1532
- });
1533
-
1534
- this.ui.stopSpinner();
1535
- if (result.folded) {
1536
- this.working = result.messages;
1537
- this.ui.note(
1538
- `folded ${result.droppedCount} earlier messages into a summary ` +
1539
- '(the full history is still saved in this session)'
1540
- );
1541
- }
1542
- } catch (err) {
1543
- // If summarizing fails, carry on with the full history and let the API
1544
- // complain — better than silently throwing away the conversation.
1545
- this.ui.stopSpinner();
1546
- this.ui.note(`could not summarize older turns (${err.kind ?? 'error'}); carrying on uncompacted`);
1547
- }
1548
- }
1549
-
1550
- // -- slash commands ------------------------------------------------------
1551
-
1552
- async command(input) {
1553
- const [name, ...rest] = input.split(/\s+/);
1554
- const arg = rest.join(' ').trim();
1555
-
1556
- switch (name) {
1557
- case '/help':
1558
- return this.cmdHelp();
1559
-
1560
- // One thing, one command, however you happen to spell it.
1561
- case '/model':
1562
- case '/models':
1563
- return this.cmdModel(arg);
1564
-
1565
- case '/session':
1566
- case '/sessions':
1567
- case '/resume':
1568
- return this.cmdSessions(arg);
1569
-
1570
- case '/new': return this.cmdNew();
1571
- case '/remember': return this.cmdRemember(arg);
1572
- case '/skills': return this.cmdSkills();
1573
- case '/clear': this.showHeader(); return;
1574
- case '/search': return this.cmdSearch(arg);
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);
1579
- case '/exit':
1580
- case '/quit': return 'exit';
1581
-
1582
- default:
1583
- this.ui.write(theme.warn(` no such command: ${name}`));
1584
- this.ui.note('/help lists them.');
1585
- }
1586
- }
1587
-
1588
- cmdHelp() {
1589
- const rows = [
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'],
1594
- ['/model', 'show the models and switch between them'],
1595
- ['/resume', 'pick up an earlier conversation'],
1596
- ['/new', 'save this one and start fresh'],
1597
- ['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
1598
- ['/skills', 'what ucode knows how to do'],
1599
- ['/search <query>', 'look something up on the web'],
1600
- ['/copy', 'copy the last reply to the clipboard'],
1601
- ['/clear', 'clear the screen, keep the conversation'],
1602
- ['/exit', 'save and quit'],
1603
- ];
1604
-
1605
- this.ui.blank();
1606
- for (const [command, what] of rows) {
1607
- this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
1608
- }
1609
- this.ui.blank();
1610
- this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
1611
- this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
1612
- this.ui.blank();
1613
- }
1614
-
1615
- /** The five models, and this session's spend. */
1616
- async cmdModel(arg) {
1617
- if (arg) {
1618
- try {
1619
- setModel(arg);
1620
- this.preferred = model();
1621
- } catch (err) {
1622
- this.ui.error(err, { debug: this.debug });
1623
- return;
1624
- }
1625
- this.session.model = model();
1626
- this.ui.note(`now using ${modelName()}`);
1627
- this.showHeader({ clear: false });
1628
- return;
1629
- }
1630
-
1631
- const all = modelList();
1632
- const width = Math.max(...all.map((m) => m.name.length));
1633
-
1634
- if (this.ui.pick) {
1635
- const items = all.map((m) => ({
1636
- label:
1637
- `${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1638
- `${m.name.padEnd(width)} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`,
1639
- }));
1640
-
1641
- const chosen = await this.ui.pick(items, {
1642
- active: Math.max(0, all.findIndex((m) => m.active)),
1643
- hint: '↑↓ move · enter to switch · esc to cancel',
1644
- });
1645
- if (chosen === null) return;
1646
-
1647
- setModel(all[chosen].id);
1648
- this.preferred = model();
1649
- this.session.model = model();
1650
- this.ui.note(`now using ${modelName()}`);
1651
- this.showHeader({ clear: false });
1652
- return;
1653
- }
1654
-
1655
- this.ui.blank();
1656
- for (const m of all) {
1657
- this.ui.write(
1658
- ` ${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1659
- `${(m.active ? blue : dim)(m.name.padEnd(width))} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`
1660
- );
1661
- this.ui.write(` ${dim(m.id)}`);
1662
- }
1663
-
1664
- const u = this.session.usage;
1665
- const live = rateLimits();
1666
- this.ui.blank();
1667
- this.ui.write(
1668
- ` ${dim('this session')} ${u.turns} turns · ${formatTokens(u.totalTokens)} tokens ` +
1669
- `(${formatTokens(u.promptTokens)} in, ${formatTokens(u.outputTokens)} out)`
1670
- );
1671
- if (live?.requestsRemaining != null && live?.requestsLimit) {
1672
- this.ui.write(` ${dim('requests')} ${live.requestsRemaining} of ${live.requestsLimit} left`);
1673
- }
1674
- this.ui.blank();
1675
- this.ui.write(dim(' /model <id> switches without the picker.'));
1676
- this.ui.blank();
1677
- }
1678
-
1679
- /**
1680
- * One row per saved conversation.
1681
- *
1682
- * A list of titles and timestamps is not enough to recognise your own work
1683
- * by — half of them start "Fix the". So each row carries what it was about
1684
- * and how far it got, and the ones from this folder are marked, because that
1685
- * is nearly always the one being looked for.
1686
- */
1687
- describeSession(s, width, i) {
1688
- const room = Math.max(24, Math.min(46, width - 34));
1689
- const mark = s.mine ? blue('●') : dim('○');
1690
- const when = relativeTime(s.updatedAt).padEnd(9);
1691
- const turns = `${s.turns} turn${s.turns === 1 ? '' : 's'}`.padEnd(9);
1692
- const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
1693
-
1694
- return {
1695
- // Numbered in the picker, so /session delete 3 has something to point at.
1696
- label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
1697
- sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
1698
- };
1699
- }
1700
-
1701
- async cmdSessions(arg) {
1702
- if (arg === '--clear' || arg === 'clear') {
1703
- const yes = await this.ui.confirm({
1704
- action: 'delete every saved conversation',
1705
- detail: 'This cannot be undone.',
1706
- risk: 'write',
1707
- });
1708
- if (!yes) {
1709
- this.ui.note('cancelled');
1710
- return;
1711
- }
1712
- await removeAll();
1713
- this.ui.note('all sessions deleted');
1714
- return;
1715
- }
1716
-
1717
- // /session delete 3 or /session delete 2,5,7
1718
- const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
1719
- if (del) return this.deleteSessions(del[1]);
1720
-
1721
- const sessions = await list({ cwd: this.cwd });
1722
- if (!sessions.length) {
1723
- this.ui.note('no saved conversations yet');
1724
- return;
1725
- }
1726
-
1727
- for (const bad of sessions.unreadable ?? []) {
1728
- this.ui.write(theme.warn(` could not read session file: ${bad}`));
1729
- }
1730
-
1731
- const shown = sessions.slice(0, 25);
1732
- const width = this.ui.width ? this.ui.width() : 80;
1733
- let index;
1734
-
1735
- if (arg) {
1736
- const n = Number(arg);
1737
- if (!Number.isInteger(n) || n < 1 || n > shown.length) {
1738
- this.ui.write(theme.warn(` "${arg}" is not one of 1-${shown.length}`));
1739
- return;
1740
- }
1741
- index = n - 1;
1742
- } else if (this.ui.pick) {
1743
- // The picker stays open while you delete, so clearing out several old
1744
- // conversations is d d, d d, d d — then Enter on the one you want.
1745
- let active = 0;
1746
- for (;;) {
1747
- const here = shown.filter((s) => s.mine).length;
1748
- const picked = await this.ui.pick(
1749
- shown.map((s, i) => this.describeSession(s, width, i)),
1750
- {
1751
- active,
1752
- deletable: true,
1753
- hint:
1754
- `↑↓ move · enter to continue · d twice to delete · esc to cancel` +
1755
- (here ? ` ${here} from this folder` : ''),
1756
- }
1757
- );
1758
- if (picked === null) return;
1759
- if (typeof picked === 'object' && picked.delete !== undefined) {
1760
- const doomed = shown[picked.delete];
1761
- active = picked.delete;
1762
- if (doomed.id === this.session.id) {
1763
- this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
1764
- continue;
1765
- }
1766
- await remove(doomed.id);
1767
- shown.splice(picked.delete, 1);
1768
- this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
1769
- if (!shown.length) {
1770
- this.ui.note('no saved conversations left');
1771
- return;
1772
- }
1773
- active = Math.min(active, shown.length - 1);
1774
- continue;
1775
- }
1776
- index = picked;
1777
- break;
1778
- }
1779
- } else {
1780
- this.ui.blank();
1781
- this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
1782
- index = await this.ui.choose(
1783
- 'continue which?',
1784
- shown.map((s, i) => this.describeSession(s, width, i).label)
1785
- );
1786
- if (index === null) return;
1787
- }
1788
-
1789
- if (this.session.messages.length) await this.persist();
1790
- if (await this.resume(shown[index].id)) {
1791
- this.showHeader();
1792
- this.replayTail();
1793
- }
1794
- }
1795
-
1796
- /** /session delete 3, or 2,5,7 — numbers as the session list shows them. */
1797
- async deleteSessions(spec) {
1798
- const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
1799
- const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
1800
- const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
1801
- if (!numbers.length || bad.length) {
1802
- this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
1803
- return;
1804
- }
1805
- for (const n of numbers) {
1806
- const s = sessions[n - 1];
1807
- if (s.id === this.session.id) {
1808
- this.ui.note(`skipped ${n} that is the conversation you are in`);
1809
- continue;
1810
- }
1811
- await remove(s.id);
1812
- this.ui.note(`deleted ${n} · ${s.title}`);
1813
- }
1814
- }
1815
-
1816
- async resume(id) {
1817
- try {
1818
- const loaded = await load(id);
1819
- this.session = loaded;
1820
- this.working = [...loaded.messages];
1821
- this.loaded = new Set(loaded.messages.filter((m) => m.skill).map((m) => m.skill));
1822
- if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
1823
- return true;
1824
- } catch (err) {
1825
- this.ui.error(err, { debug: this.debug });
1826
- this.ui.note('Starting a fresh one instead.');
1827
- return false;
1828
- }
1829
- }
1830
-
1831
- /** The last few exchanges, so a resumed conversation has visible context. */
1832
- replayTail(count = 4) {
1833
- const tail = this.session.messages
1834
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
1835
- .slice(-count);
1836
-
1837
- for (const m of tail) {
1838
- if (m.role !== 'user') this.ui.assistant(m.content);
1839
- else if (this.ui.userMessage) this.ui.userMessage(m.content);
1840
- else this.ui.write(`${blue('›')} ${dim(m.content.split('\n')[0])}`);
1841
- }
1842
- if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
1843
- }
1844
-
1845
- /** Add a line to this project's UCODE.md, read at the start of every turn. */
1846
- async cmdRemember(note) {
1847
- if (!note) {
1848
- this.ui.note(`usage: /remember <something ucode should always know here> saved to ${MEMORY_FILE}`);
1849
- return;
1850
- }
1851
- try {
1852
- const file = await remember(this.cwd, note);
1853
- this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
1854
- } catch (err) {
1855
- this.ui.error(new Failure({
1856
- kind: 'memory_unwritable',
1857
- attempted: `saving to ${MEMORY_FILE}`,
1858
- failed: err.message,
1859
- fix: 'Check that this folder is writable.',
1860
- }), { debug: this.debug });
1861
- }
1862
- }
1863
-
1864
- async cmdNew() {
1865
- if (this.session.messages.length) {
1866
- await this.persist();
1867
- this.ui.note(`saved · ${this.session.title}`);
1868
- }
1869
- this.session = newSession(this.cwd, model());
1870
- this.working = [];
1871
- this.loaded = new Set();
1872
- this.showHeader();
1873
- }
1874
-
1875
- async cmdSkills() {
1876
- // Re-read from disk. Skills load once at startup, so one written during
1877
- // this session would otherwise stay invisible — and load_skill would fail
1878
- // on a name the user can see in the folder.
1879
- this.skills = await loadSkills({ cwd: this.cwd });
1880
- for (const problem of this.skills.problems ?? []) {
1881
- this.ui.write(theme.warn(` skill not loaded: ${problem}`));
1882
- }
1883
-
1884
- if (!this.skills.length) {
1885
- this.ui.note('no skills found add a folder with a SKILL.md under .ucode/skills');
1886
- return;
1887
- }
1888
-
1889
- this.ui.blank();
1890
- for (const s of this.skills) {
1891
- const live = this.loaded.has(s.name);
1892
- const auto = s.triggers.length ? dim(' · loads itself') : '';
1893
- this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
1894
- this.ui.write(` ${dim(s.description)}`);
1895
- }
1896
- this.ui.blank();
1897
- this.ui.write(dim(' ● already loaded here · ucode pulls one in when the task matches'));
1898
- this.ui.blank();
1899
- }
1900
-
1901
- async cmdSearch(query) {
1902
- if (!query) {
1903
- this.ui.note('usage: /search <what you want to look up>');
1904
- return;
1905
- }
1906
- await this.turn(
1907
- `Search the web for: ${query}\n\nUse web_search, then summarise what you found and cite the URLs.`
1908
- );
1909
- }
1910
-
1911
- /** Copy the last reply. Every platform ships a clipboard pipe. */
1912
- async cmdCopy() {
1913
- const last = [...this.session.messages]
1914
- .reverse()
1915
- .find((m) => m.role === 'assistant' && m.content?.trim());
1916
-
1917
- if (!last) {
1918
- this.ui.note('nothing to copy yet');
1919
- return;
1920
- }
1921
-
1922
- const tool = process.platform === 'win32' ? 'clip'
1923
- : process.platform === 'darwin' ? 'pbcopy'
1924
- : 'xclip -selection clipboard';
1925
-
1926
- try {
1927
- await new Promise((resolve, reject) => {
1928
- const child = spawn(tool, { shell: true, windowsHide: true });
1929
- child.on('error', reject);
1930
- child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
1931
- child.stdin.end(last.content);
1932
- });
1933
- const lines = last.content.split('\n').length;
1934
- this.ui.note(`copied ${lines} line${lines === 1 ? '' : 's'}`);
1935
- } catch (err) {
1936
- this.ui.error(new Failure({
1937
- kind: 'clipboard_failed',
1938
- attempted: 'copying the last reply',
1939
- failed: `${tool} could not run: ${err.message}`,
1940
- fix: process.platform === 'linux'
1941
- ? 'Install xclip (apt install xclip), or select the text with the mouse.'
1942
- : 'Select the text with the mouse instead.',
1943
- }), { debug: this.debug });
1944
- }
1945
- }
1946
- }
1947
-
1948
- export { DEFAULT_MODEL, PROVIDER };
1
+ /**
2
+ * loop.js — the agent itself.
3
+ *
4
+ * One turn, end to end: take a line, fold the conversation if it has grown too
5
+ * big, ask the model, and if it asked for tools, run them and ask again. Repeat
6
+ * until it answers with prose instead of a tool call.
7
+ *
8
+ * Two histories are kept, deliberately:
9
+ * session.messages the complete record, written to disk after every step
10
+ * working what is actually sent, which may have its older turns
11
+ * folded into a summary once the window gets tight
12
+ */
13
+
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { appendFileSync } from 'node:fs';
17
+ import { readFile, access, mkdir } from 'node:fs/promises';
18
+ import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
19
+ import { LogWatch } from './livelog.js';
20
+ import { runningServers } from '../tools/shell.js';
21
+ import { spawn } from 'node:child_process';
22
+
23
+ import {
24
+ ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
25
+ MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
26
+ } from './provider.js';
27
+ import {
28
+ tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
29
+ } from '../tools/index.js';
30
+ import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
31
+ import { autoUpdate } from './updater.js';
32
+ import { closeBrowser, forgetReviews } from '../tools/browser.js';
33
+ import {
34
+ newSession, save, load, list, remove, removeAll, titleFrom,
35
+ } from './history.js';
36
+ import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
37
+ import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
38
+ import { Screen, isLabel } from '../ui/screen.js';
39
+ import { Plain } from '../ui/plain.js';
40
+ import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
41
+ import { Failure, ToolFailure, Declined } from './failure.js';
42
+ import { StuckWatch, eventFor, describeHit } from './stuck.js';
43
+ import { serversReadySince } from '../tools/shell.js';
44
+ import { formatDuration } from '../ui/activity.js';
45
+ import { runDoctor } from './doctor.js';
46
+ import { deploy } from '../tools/deploy.js';
47
+
48
+ /**
49
+ * Tool calls allowed in one turn.
50
+ *
51
+ * Scaffolding an app is dozens of writes before anything can even be run, so a
52
+ * small ceiling stops a real job halfway through — which from the outside is
53
+ * indistinguishable from the agent giving up for no reason. The runaway-loop
54
+ * protection this exists for still works at 250; a loop burns through that
55
+ * just as visibly, only later.
56
+ */
57
+ const MAX_STEPS = Number(process.env.UCODE_MAX_STEPS) || 250;
58
+ const MAX_ARG_RETRIES = 2;
59
+
60
+ /**
61
+ * How many times a reply cut off at the output limit is asked to carry on.
62
+ * Three covers any answer a terminal should be printing; past that the model
63
+ * is rambling and stopping is the kinder outcome.
64
+ */
65
+ const MAX_CONTINUATIONS = 3;
66
+
67
+ /** Read-only tools whose result line adds nothing the user saw the output. */
68
+ const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
69
+
70
+ /** Tools that draw their own line, so they get no " Doing X" line of their own. */
71
+ const SILENT = new Set(['update_plan']);
72
+
73
+ /** How many rounds of "the type check found errors, fix them" one turn may take. */
74
+ const MAX_FIX_ROUNDS = 3;
75
+
76
+ /** Files worth checking after they change. */
77
+ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
78
+
79
+ /** Where TypeScript keeps what it learned, so the next check is a quick one. */
80
+ export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
81
+
82
+ /** TypeScript before 4.0 rejects --incremental together with --noEmit. */
83
+ const NO_INCREMENTAL = /TS5074|TS6304|'--incremental'/;
84
+
85
+ /**
86
+ * The type check to run. Incremental by default: the first check pays the
87
+ * full cost and writes a build info file, and every one after it reads that
88
+ * and reports in about a second.
89
+ */
90
+ export function typeCheckCommand(incremental = true) {
91
+ const base = 'npx --no-install tsc --noEmit --pretty false';
92
+ return incremental ? `${base} --incremental --tsBuildInfoFile ${TSBUILDINFO}` : base;
93
+ }
94
+
95
+ /**
96
+ * Failures that are the provider's and not the model's: busy, slow, down, or
97
+ * unreachable. None of them should end a build — the turn moves to another
98
+ * model and carries on from exactly where it was.
99
+ */
100
+ const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
101
+ /** Will another model, or a little patience, get past this? Not the daily cap: it covers them all. */
102
+ const passing = (err) => TRANSIENT.has(err.kind) && !err.detail?.daily;
103
+ const MAX_FAILOVERS = 8;
104
+ const COOLDOWN = 5 * 60_000;
105
+
106
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
107
+
108
+ /**
109
+ * UCODE_TRACE=1 writes one JSON line per model call and per tool to
110
+ * ~/.ucode/trace.jsonl (or to the path UCODE_TRACE names): how long it took,
111
+ * tokens in and out, what was called. It is how "it feels slow" becomes a
112
+ * number with a cause attached.
113
+ */
114
+ const TRACE_FILE = process.env.UCODE_TRACE
115
+ ? (process.env.UCODE_TRACE === '1' ? path.join(os.homedir(), '.ucode', 'trace.jsonl') : process.env.UCODE_TRACE)
116
+ : null;
117
+
118
+ /** Tool results worth re-sending in full only while they are recent. */
119
+ const THIN_RESULTS = new Set([
120
+ 'read_file', 'read_files', 'grep', 'glob', 'list_dir', 'run_command', 'run_commands',
121
+ 'look_at_app', 'web_search', 'edit_file', 'multi_edit', 'edit_files',
122
+ ]);
123
+
124
+ /** Replace long strings in old tool arguments with a note of their size. */
125
+ function thinArgs(value) {
126
+ if (typeof value === 'string') {
127
+ return value.length > 400
128
+ ? `[${value.length} characters, already applied — read the file if you need its current text]`
129
+ : value;
130
+ }
131
+ if (Array.isArray(value)) return value.map(thinArgs);
132
+ if (value && typeof value === 'object') {
133
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, thinArgs(v)]));
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /**
139
+ * The conversation as sent, with bulky history thinned.
140
+ *
141
+ * Every file the model writes travels inside its own tool call, so a
142
+ * thirty-file app is re-sent in full on every later step — tens of thousands
143
+ * of tokens the provider has to read before it can answer, growing with
144
+ * each file. Beyond the last few steps that text is replaced by a note of its
145
+ * size; the files are on disk, and the model re-reads one when it needs it.
146
+ * Call ids and results stay paired, and the saved session keeps everything.
147
+ */
148
+ /** Tools whose result is the contents of one named thing, so re-reads repeat. */
149
+ const RE_READ = new Set(['read_file', 'read_files', 'list_dir', 'grep', 'glob']);
150
+
151
+ /** The thing a call was about, when two calls for it return the same text. */
152
+ function subjectOf(call) {
153
+ if (!RE_READ.has(call?.name)) return null;
154
+ const a = call.args ?? {};
155
+ const what = a.path ?? (Array.isArray(a.paths) ? a.paths.join('|') : null) ?? a.pattern;
156
+ if (typeof what !== 'string' || !what) return null;
157
+ // grep and glob also depend on what was asked, not only where.
158
+ const extra = call.name === 'grep' || call.name === 'glob' ? `|${a.pattern ?? ''}|${a.glob ?? ''}` : '';
159
+ return `${call.name}:${what}${extra}`;
160
+ }
161
+
162
+ /**
163
+ * Send each file once.
164
+ *
165
+ * Reading a file four times over a long task puts four copies of it in the
166
+ * conversation, and the first three are worth nothing: the model reads the
167
+ * newest and the older ones only cost tokens and invite it to answer from a
168
+ * stale copy. Every superseded copy becomes a line saying where the current
169
+ * one is. The newest is always kept whole, so nothing the model needs is
170
+ * taken away, and the saved session still holds the lot.
171
+ */
172
+ export function dedupe(messages) {
173
+ const subject = new Map(); // toolCallId -> subject
174
+ for (const m of messages) {
175
+ if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
176
+ for (const c of m.toolCalls) {
177
+ const s = subjectOf(c);
178
+ if (s) subject.set(c.id, s);
179
+ }
180
+ }
181
+ if (!subject.size) return messages;
182
+
183
+ const newest = new Map(); // subject -> index of the last result for it
184
+ messages.forEach((m, i) => {
185
+ if (m.role !== 'tool') return;
186
+ const s = subject.get(m.toolCallId);
187
+ if (s) newest.set(s, i);
188
+ });
189
+
190
+ return messages.map((m, i) => {
191
+ if (m.role !== 'tool') return m;
192
+ const s = subject.get(m.toolCallId);
193
+ if (!s || newest.get(s) === i) return m;
194
+ // Short results are not worth a note in place of themselves.
195
+ if ((m.content?.length ?? 0) < 400) return m;
196
+ const what = s.slice(s.indexOf(':') + 1).split('|')[0];
197
+ return {
198
+ ...m,
199
+ content:
200
+ `[${m.content.length} characters. This was read again later, and the current ` +
201
+ `contents of ${what} are further down this conversation — use those, not this.]`,
202
+ };
203
+ });
204
+ }
205
+
206
+ export function lean(messages, keep = 3) {
207
+ let seen = 0;
208
+ let cut = -1;
209
+ for (let i = messages.length - 1; i >= 0; i--) {
210
+ if (messages[i].role === 'assistant' && messages[i].toolCalls?.length && ++seen === keep) { cut = i; break; }
211
+ }
212
+ if (cut <= 0) return messages;
213
+ return messages.map((m, i) => {
214
+ if (i >= cut) return m;
215
+ if (m.role === 'assistant' && m.toolCalls?.length) {
216
+ return { ...m, toolCalls: m.toolCalls.map((c) => ({ ...c, args: thinArgs(c.args) })) };
217
+ }
218
+ if (m.role === 'tool' && THIN_RESULTS.has(m.name) && (m.content?.length ?? 0) > 1500) {
219
+ return {
220
+ ...m,
221
+ content: `${m.content.slice(0, 300)}
222
+ [${m.content.length} characters from an earlier step, trimmed ` +
223
+ 'to keep the conversation fast — run the tool again if you need this now]',
224
+ };
225
+ }
226
+ return m;
227
+ });
228
+ }
229
+
230
+ function trace(event) {
231
+ if (!TRACE_FILE) return;
232
+ try { appendFileSync(TRACE_FILE, `${JSON.stringify({ at: Date.now(), ...event })}
233
+ `); } catch { /* never fatal */ }
234
+ }
235
+
236
+ /** What /stats reports, counted as the session goes. */
237
+ function newStats() {
238
+ return {
239
+ started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
240
+ tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
241
+ };
242
+ }
243
+
244
+ const BUILD_COMMAND = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b/;
245
+
246
+ function countTool(stats, call, out, err) {
247
+ stats.tools[call.name] = (stats.tools[call.name] ?? 0) + 1;
248
+ if (err) { stats.failed++; return; }
249
+ const a = call.args ?? {};
250
+ if (call.name === 'write_file') stats.written++;
251
+ if (call.name === 'batch_write') stats.written += a.files?.length ?? 0;
252
+ if (call.name === 'edit_file' || call.name === 'multi_edit') stats.edited++;
253
+ if (call.name === 'edit_files') stats.edited += new Set((a.edits ?? []).map((e) => e.path)).size || 1;
254
+ if (call.name === 'run_command') {
255
+ stats.commands++;
256
+ if (BUILD_COMMAND.test(a.command ?? '')) stats.builds++;
257
+ }
258
+ if (call.name === 'run_commands') stats.commands += a.commands?.length ?? 0;
259
+ }
260
+
261
+ /** The /stats box. */
262
+ export function statsLines(st, messages) {
263
+ const n = (v) => Number(v).toLocaleString();
264
+ const top = Object.entries(st.tools).sort((x, y) => y[1] - x[1]).slice(0, 6)
265
+ .map(([k, v]) => `${k} ${v}`).join(' · ') || 'none yet';
266
+ const plural = (v, w) => `${v} ${w}${v === 1 ? '' : 's'}`;
267
+ const rows = [
268
+ ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
269
+ ['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
270
+ ['Tools', top],
271
+ ['Files', `${st.written} written · ${st.edited} edited`],
272
+ ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
273
+ ['Context', `${messages} messages${st.stuck ? ` · ${plural(st.stuck, 'loop')} caught` : ''}`],
274
+ ];
275
+ const w = Math.max(...rows.map(([k]) => k.length));
276
+ return ['', ` ${blue('Stats')}`, ...rows.map(([k, v]) => ` ${dim(k.padEnd(w))} ${v}`), ''];
277
+ }
278
+
279
+ /** Parallel workers at once, and how many steps each may take. */
280
+ const MAX_WORKERS = 3;
281
+ const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
282
+
283
+ /** Workers build; they do not plan, delegate further, or load skills themselves. */
284
+ const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
285
+
286
+ const planTool = {
287
+ name: 'update_plan',
288
+ description:
289
+ 'Keep a short checklist the user can see, for work with three or more steps. ' +
290
+ 'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
291
+ 'on the finished ones. Update it as items finish. Skip it for small tasks.',
292
+ parameters: {
293
+ type: 'object',
294
+ properties: {
295
+ items: {
296
+ type: 'array',
297
+ description: 'The whole plan, in order. At most 6.',
298
+ items: {
299
+ type: 'object',
300
+ properties: {
301
+ text: { type: 'string', description: 'A few words: "Build the upload box".' },
302
+ done: { type: 'boolean', description: 'True once it is finished.' },
303
+ },
304
+ required: ['text'],
305
+ },
306
+ },
307
+ },
308
+ required: ['items'],
309
+ },
310
+ };
311
+
312
+ const delegateTool = {
313
+ name: 'delegate',
314
+ description:
315
+ 'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
316
+ 'same tools as you. Use it when the work splits cleanly into parts that touch ' +
317
+ 'different files - e.g. the API route, the upload component and the results view. ' +
318
+ 'A worker sees only its instructions, so make them complete: the files it owns, ' +
319
+ 'what to build, the exact interfaces (props, types, request and response shapes) ' +
320
+ 'it must match, and the design rules. Set up shared files (package.json, design ' +
321
+ 'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
322
+ 'the files it changed; wire the parts together and check the whole afterwards.',
323
+ parameters: {
324
+ type: 'object',
325
+ properties: {
326
+ tasks: {
327
+ type: 'array',
328
+ description: 'Up to 3 independent pieces of work.',
329
+ items: {
330
+ type: 'object',
331
+ properties: {
332
+ name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
333
+ instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
334
+ },
335
+ required: ['name', 'instructions'],
336
+ },
337
+ },
338
+ },
339
+ required: ['tasks'],
340
+ },
341
+ };
342
+
343
+ /** The instructions a parallel worker starts with. */
344
+ function workerPrompt({ cwd, name, memory, skills, map }) {
345
+ return [
346
+ `You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
347
+ '',
348
+ `Working directory: ${cwd}`,
349
+ `Platform: ${process.platform}`,
350
+ '',
351
+ '- Do exactly the task you were given. Touch only the files it names, or new files in',
352
+ ' the area it owns - other workers are editing the rest of the project right now.',
353
+ '- Read before you edit. read_files for several files, batch_write for several new',
354
+ ' files, edit_files for changes across files.',
355
+ '- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
356
+ ' not install packages unless the task says to - say what you need instead.',
357
+ '- Finish what you build: real content, every state handled, no TODOs.',
358
+ '- When done, reply with two or three sentences: what you built, in which files, and',
359
+ ' anything the lead has to wire up.',
360
+ '- The files you own may not exist yet - create them. Do not go looking for them',
361
+ ' first. The project map below shows what does exist; read only the files whose',
362
+ ' interfaces you must match, then start writing within two or three steps.',
363
+ ...(memory ? ['', '## Project memory', '', memory] : []),
364
+ ...(map ? ['', '## Project map', '', map] : []),
365
+ ...(skills ? ['', '## Instructions in force', '', skills] : []),
366
+ ].join('\n');
367
+ }
368
+
369
+ /** The files a writing tool call touches. */
370
+ function pathsOf(call) {
371
+ const a = call.args ?? {};
372
+ if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
373
+ return a.path ? [a.path] : [];
374
+ }
375
+
376
+ const exists = (p) => access(p).then(() => true, () => false);
377
+
378
+ /** Skills reach the model as one extra tool, so bodies load only when wanted. */
379
+ const loadSkillTool = {
380
+ name: 'load_skill',
381
+ description:
382
+ 'Load the full instructions for one of the skills listed in your system prompt. ' +
383
+ 'Call it the moment a task matches one before planning, before writing anything ' +
384
+ ' then follow what it says.',
385
+ parameters: {
386
+ type: 'object',
387
+ properties: { name: { type: 'string', description: 'The skill name, exactly as listed.' } },
388
+ required: ['name'],
389
+ },
390
+ };
391
+
392
+ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
393
+ const list = catalogue(skills);
394
+
395
+ return [
396
+ 'You are ucode, a coding agent working directly in the user\'s terminal.',
397
+ '',
398
+ `Working directory: ${cwd}`,
399
+ `Platform: ${process.platform}`,
400
+ ...(memory ? [
401
+ '',
402
+ '## Project memory',
403
+ '',
404
+ 'Standing instructions from the user. They outrank your defaults.',
405
+ '',
406
+ memory,
407
+ ] : []),
408
+ '',
409
+ '## How to work',
410
+ '',
411
+ 'Before you guess at an API, ask: type_of gives the exact signature from the',
412
+ 'TypeScript this project has installed, and find_symbol says where something is declared without',
413
+ 'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
414
+ 'find-and-replace that matches too much is the most common broken edit. Reach for',
415
+ 'add_block before writing a table, an empty state or a dashboard by hand.',
416
+ '',
417
+ ...(mode === 'plan' ? [
418
+ 'You are in PLAN MODE. Reading, searching and research are available; every tool',
419
+ 'that writes a file or runs a command has been withheld. Investigate, then set out',
420
+ 'what you would change: which files, which functions, in what order. Never imply',
421
+ 'you have made a change you are not able to make.',
422
+ '',
423
+ ] : []),
424
+ ...(check ? [
425
+ `This project checks itself with: ${check}`,
426
+ 'After changing code, run that and report what actually happened. "It should work"',
427
+ 'is not a result.',
428
+ '',
429
+ ] : []),
430
+ '- Read before you write. Never edit a file you have not read this session.',
431
+ '- Prefer edit_file to write_file. Rewrite a whole file only when creating it, or',
432
+ ' when the change genuinely touches most of it.',
433
+ '- old_string must be copied out of what read_file showed you, character for',
434
+ ' character, without the line-number gutter, and must appear exactly once. Add',
435
+ ' surrounding lines until it does.',
436
+ '- read_file returns up to 600 lines. Read the whole file before editing it rather',
437
+ ' than editing from a fragment; pass offset to continue a long one.',
438
+ '',
439
+ '## Going fast',
440
+ '',
441
+ 'Every tool call is a round trip to you, and the round trip — not the disk, not',
442
+ 'the shell — is where the time goes. So:',
443
+ '',
444
+ '- Need more than one file? read_files, all of them in one call. Never read files',
445
+ ' one at a time when you already know which ones you want.',
446
+ '- An edit result shows the file as it now stands. Do not read a file again after',
447
+ ' editing it - you already have its current text.',
448
+ '- Put independent calls in the same message — several greps, a glob and a read.',
449
+ ' Read-only calls in one message run at the same time.',
450
+ '- New Next.js app? create_app - one step, never create-next-app or shadcn init. It',
451
+ ' copies a starter that already builds and installs it in the background.',
452
+ '- batch_write to lay out several new files at once - but at most about four per',
453
+ ' call: one slip in a huge call throws the whole call away.',
454
+ '- multi_edit for several changes',
455
+ ' to one file, edit_files for a change that spans several files.',
456
+ '- For work with three or more steps, keep a short plan with update_plan - at most',
457
+ ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
458
+ '- When a build splits into parts that touch different files (the API route, the',
459
+ ' upload component, the results view), set up the shared files yourself, then hand',
460
+ ' the parts to delegate so they are built in parallel.',
461
+ '- A package.json you write starts installing in the background immediately; keep',
462
+ ' writing files. Running the install yourself afterwards just waits for that one.',
463
+ '- When you finish, ucode type-checks what you changed and hands you the errors, so',
464
+ ' there is no need to run tsc yourself.',
465
+ '- Once the dev server is ready, run look_at_app on the pages you built. Fix what it',
466
+ ' reports - errors, layout that overflows a phone, the review points worth fixing -',
467
+ ' in one pass, then look once more. A clean second look means it is done: report',
468
+ ' back instead of polishing in circles. Never call an interface finished unlooked at.',
469
+ '- Asked to deploy, publish, host or share the app? Use the deploy tool - it picks the',
470
+ ' name, handles keys and returns the live link. Build locally first.',
471
+ '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
472
+ ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
473
+ ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
474
+ '- Dev servers start in the background by themselves, and the result tells you the',
475
+ ' URL once the server says it is ready. Do not start one twice, do not sleep while',
476
+ ' waiting for it, and do not curl it before that result comes back.',
477
+ '',
478
+ '## When something fails',
479
+ '',
480
+ '- A failed build names the problem. Fix exactly that, then build again. Never go',
481
+ ' exploring inside node_modules: a missing component or package is one install away.',
482
+ '- A build takes most of a minute. Fix every error it lists in one pass - multi_edit,',
483
+ ' edit_files - before building again, never one error per build.',
484
+ '- Never delete an app folder to start over. Fix it where it is - starting again throws',
485
+ ' away the install and everything already written.',
486
+ '- Run an app\'s commands with cwd set to its folder, and keep paths inside those',
487
+ ' commands relative to that folder.',
488
+ '',
489
+ '## Safety',
490
+ '',
491
+ '- run_command runs without asking. That is trust rather than licence: never run',
492
+ ' anything destructive the user did not ask for.',
493
+ '- Paths are relative to the working directory. Anything outside it needs the user',
494
+ ' to approve it first.',
495
+ '- Verify. After changing code, run the tests or a quick check with run_command.',
496
+ '',
497
+ '## Saying what you are doing',
498
+ '',
499
+ '- Before every tool call, write ONE short line naming the file or command:',
500
+ ' "Reading tui.js", "Fixing the spinner in loop.js", "Running npm test".',
501
+ '- Present tense, under ten words, and no full stop at the end. It is a label on',
502
+ ' work happening right now, not a sentence about work that is finished.',
503
+ '- That line and nothing else in the message. No preamble, no plan, no bullets —',
504
+ ' the user reads it live while the tool runs.',
505
+ '- Say the next one when you take the next step, not all of them up front.',
506
+ '',
507
+ '## Answering',
508
+ '',
509
+ '- Be short. Two or three sentences is usually the entire answer. This is a',
510
+ ' terminal, not a document.',
511
+ '- No preamble, no restating the question, no "I will now...". Just answer.',
512
+ '- Do not narrate what the tool output already showed. The user watched the diff',
513
+ ' and the command output; cover only what is not obvious from them.',
514
+ '- Skip closing summaries of work the user just watched you do — but never end',
515
+ ' a turn silently. If there is genuinely nothing to add, one short line saying',
516
+ ' what changed is the whole answer.',
517
+ '- Markdown. Fenced blocks with a language tag get highlighted.',
518
+ '- Point at code as path:line so the user can jump straight to it.',
519
+ '- Report honestly. If a command failed or you skipped something, say so.',
520
+ '- Length tracks the question: a one-line question gets a one-line answer.',
521
+ '- Brevity is about your prose and never about your work. What you build is',
522
+ ' finished: every control wired, every state handled, no TODO left behind.',
523
+ ...(list ? [
524
+ '',
525
+ '## Skills',
526
+ '',
527
+ 'These instruction packs are available. When a task matches one, load it with',
528
+ 'load_skill as your first step — before planning, before writing anything — and',
529
+ 'then follow it. A skill already in this conversation outranks your own defaults',
530
+ 'and is not advisory.',
531
+ '',
532
+ list,
533
+ ] : []),
534
+ '',
535
+ '## Project map',
536
+ '',
537
+ 'Every file in the project at the start of this turn, with the names each code file',
538
+ 'exports. Go straight to the files you need instead of searching for them.',
539
+ '',
540
+ map || '(not available)',
541
+ ].join('\n');
542
+ }
543
+
544
+ export class Agent {
545
+ constructor({ cwd, debug = false }) {
546
+ this.cwd = cwd;
547
+ this.debug = debug;
548
+ // A full-screen layout only makes sense on a real terminal. Piped input,
549
+ // CI and `echo ... | ucode` get the line-based interface instead.
550
+ this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
551
+ this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
552
+ this.stats = newStats();
553
+ this.skills = [];
554
+ this.session = newSession(cwd, model());
555
+ this.working = [];
556
+ this.loaded = new Set();
557
+ this.abort = null;
558
+ this.busy = false;
559
+ this.check = null;
560
+ }
561
+
562
+ // -- history -------------------------------------------------------------
563
+
564
+ push(message) {
565
+ this.session.messages.push(message);
566
+ this.working.push(message);
567
+ }
568
+
569
+ async persist() {
570
+ try {
571
+ this.session.model = model();
572
+ await save(this.session);
573
+ } catch (err) {
574
+ // Losing the save must not lose the turn.
575
+ this.ui.error(err, { debug: this.debug });
576
+ }
577
+ }
578
+
579
+ // -- startup -------------------------------------------------------------
580
+
581
+ /**
582
+ * Everything a turn needs, minus the terminal.
583
+ *
584
+ * Split out of start() so another front end could prepare an agent and drive
585
+ * turn() itself.
586
+ */
587
+ async bootstrap() {
588
+ setRoot(this.cwd);
589
+ // Parallel workers can ask at the same moment; the questions queue up and
590
+ // are put to the user one at a time, never on top of each other.
591
+ let asking = Promise.resolve();
592
+ setConfirm((request) => {
593
+ const next = asking.then(() => this.ui.confirm(request));
594
+ asking = next.catch(() => {});
595
+ return next;
596
+ });
597
+ this.skills = await loadSkills({ cwd: this.cwd });
598
+ await this.detectCheck();
599
+ }
600
+
601
+ async start() {
602
+ await this.bootstrap();
603
+
604
+ if (this.full) {
605
+ await this.ui.start();
606
+ this.ui.onInterrupt = () => {
607
+ if (this.busy && this.abort) {
608
+ this.abort.abort();
609
+ this.ui.stopSpinner();
610
+ this.ui.note('interrupted');
611
+ }
612
+ };
613
+ this.ui.onModeChange = () => this.showHeader({ clear: false });
614
+ }
615
+
616
+ for (const problem of this.skills.problems ?? []) {
617
+ this.ui.write(theme.warn(` skill not loaded: ${problem}`));
618
+ }
619
+
620
+ this.showHeader();
621
+ this.installSignals();
622
+
623
+ // Checked in the background; nothing here waits on it.
624
+ autoUpdate({
625
+ onUpdated: (version) => {
626
+ this.ui.setFacts?.({ update: version });
627
+ if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
628
+ },
629
+ });
630
+ await this.repl();
631
+ }
632
+
633
+ showHeader({ clear = true } = {}) {
634
+ if (clear && this.full) this.ui.clearScreen();
635
+ const stats = usage(this.working, contextLimit());
636
+ this.ui.header({
637
+ cwd: this.cwd,
638
+ model: modelName(),
639
+ used: stats.used,
640
+ limit: stats.limit,
641
+ title: this.session.title === 'Untitled' ? 'new session' : this.session.title,
642
+ });
643
+ }
644
+
645
+ installSignals() {
646
+ const flush = async () => {
647
+ await save(this.session).catch(() => {});
648
+ process.exit(0);
649
+ };
650
+ process.on('SIGTERM', flush);
651
+
652
+ // The full-screen UI reads keys itself, so it owns ctrl+c and esc.
653
+ if (this.full) return;
654
+
655
+ this.ui.rl.on('SIGINT', () => {
656
+ if (this.busy && this.abort) {
657
+ this.abort.abort();
658
+ this.ui.stopSpinner();
659
+ this.ui.write(dim(' interrupted'));
660
+ return;
661
+ }
662
+ this.ui.write(dim(' (ctrl+d or /exit to quit)'));
663
+ this.ui.rl.prompt();
664
+ });
665
+ }
666
+
667
+ /**
668
+ * How this project verifies itself, worked out once at startup. Null when
669
+ * there is genuinely nothing to run — verification is only insisted on where
670
+ * there is something to insist on.
671
+ */
672
+ async detectCheck() {
673
+ const has = (f) => readFile(path.join(this.cwd, f)).then(() => true, () => false);
674
+
675
+ if (await has('package.json')) {
676
+ try {
677
+ const pkg = JSON.parse(await readFile(path.join(this.cwd, 'package.json'), 'utf8'));
678
+ if (pkg.scripts?.test && !/no test specified/i.test(pkg.scripts.test)) {
679
+ this.check = 'npm test';
680
+ return;
681
+ }
682
+ } catch { /* an unreadable package.json is not worth failing over */ }
683
+ }
684
+ if (await has('Cargo.toml')) { this.check = 'cargo test'; return; }
685
+ if (await has('go.mod')) { this.check = 'go test ./...'; return; }
686
+ if (await has('pyproject.toml') || await has('pytest.ini')) { this.check = 'pytest'; return; }
687
+ if (await has('Makefile')) { this.check = 'make test'; return; }
688
+ this.check = null;
689
+ }
690
+
691
+ // -- REPL ----------------------------------------------------------------
692
+
693
+ async repl() {
694
+ let sawInput = false;
695
+
696
+ for (;;) {
697
+ const line = await this.ui.ask();
698
+ if (line === null) {
699
+ // End of input before anything was typed. On Windows this is almost
700
+ // always npm's PowerShell shim, which runs the CLI as `$input | node`.
701
+ // The pipe makes stdin a non-TTY, readline hits EOF at once, and the
702
+ // banner flashes up and vanishes which looks like a crash rather
703
+ // than like a program that was never given a keyboard. So say which.
704
+ if (!sawInput && !process.stdin.isTTY) this.explainNoKeyboard();
705
+ break;
706
+ }
707
+
708
+ const input = line.trim();
709
+ if (input) sawInput = true;
710
+ if (!input) continue;
711
+
712
+ if (input.startsWith('/')) {
713
+ if (await this.command(input) === 'exit') break;
714
+ continue;
715
+ }
716
+
717
+ try {
718
+ await this.turn(input);
719
+ } catch (err) {
720
+ this.ui.error(err, { debug: this.debug });
721
+ }
722
+ }
723
+
724
+ await this.shutdown();
725
+ }
726
+
727
+ explainNoKeyboard() {
728
+ this.ui.blank();
729
+ this.ui.write(theme.warn(' ucode could not reach the keyboard, so it stopped.'));
730
+ this.ui.blank();
731
+ this.ui.write(' That happens when input is piped rather than typed. On Windows it is');
732
+ this.ui.write(" usually npm's PowerShell wrapper, which pipes stdin.");
733
+ this.ui.blank();
734
+ this.ui.write(` ${blue('Any of these work:')}`);
735
+ this.ui.write(` ${sky('ucode.cmd')} the cmd shim, which keeps the keyboard`);
736
+ this.ui.write(` ${sky('npx ucode-agent')} runs it directly`);
737
+ this.ui.write(' or start it from Command Prompt or Windows Terminal');
738
+ this.ui.blank();
739
+ }
740
+
741
+ async shutdown() {
742
+ await closeBrowser().catch(() => {});
743
+ this.ui.stopSpinner();
744
+ if (this.session.messages.length) {
745
+ await this.persist();
746
+ this.ui.write(dim(`\n saved · ${this.session.title}`));
747
+ }
748
+ this.ui.close();
749
+ }
750
+
751
+ // -- one turn ------------------------------------------------------------
752
+
753
+ /**
754
+ * Pull image paths out of the message and load them, so "what is wrong in
755
+ * screenshot.png" works without a separate command for it.
756
+ */
757
+ async attachImages(input) {
758
+ const mentioned = input.match(/[^\s"']+\.(?:png|jpe?g|gif|webp)\b/gi) ?? [];
759
+ const images = [];
760
+
761
+ for (const name of mentioned) {
762
+ const file = path.resolve(this.cwd, name);
763
+ try {
764
+ const buf = await readFile(file);
765
+ if (buf.length > 4 * 1024 * 1024) {
766
+ this.ui.note(`${name} is ${(buf.length / 1024 / 1024).toFixed(1)}MB too big to send, skipped`);
767
+ continue;
768
+ }
769
+ const ext = path.extname(file).toLowerCase().slice(1);
770
+ images.push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`);
771
+ this.ui.note(`attached ${name}`);
772
+ } catch {
773
+ // Just a filename mentioned in passing, not a file on disk.
774
+ }
775
+ }
776
+
777
+ return images;
778
+ }
779
+
780
+ /**
781
+ * Skills that this request should arrive with, already loaded.
782
+ *
783
+ * The load_skill tool asks the model to notice that a task needs a skill,
784
+ * and a model in a hurry to be helpful does not always notice. For work
785
+ * where the skill *is* the quality bar — anything with a user interface in
786
+ * it that is not a discovery to make after the app has been built. So the
787
+ * request is matched against each skill's trigger words and the body goes in
788
+ * before the model takes its first step.
789
+ */
790
+ autoLoad(input) {
791
+ for (const skill of autoLoadFor(this.skills, input)) {
792
+ if (this.loaded.has(skill.name)) continue;
793
+ this.loaded.add(skill.name);
794
+ this.push(skillMessage(skill, { automatic: true }));
795
+ this.ui.note(`${skill.name} skill loaded for this`);
796
+ }
797
+ }
798
+
799
+ async turn(input) {
800
+ forgetReviews(); // a new request: its apps get a fresh design review
801
+ const images = await this.attachImages(input);
802
+ this.push(images.length
803
+ ? { role: 'user', content: input, images }
804
+ : { role: 'user', content: input });
805
+
806
+ if (!this.session.title || this.session.title === 'Untitled') {
807
+ this.session.title = titleFrom(input);
808
+ }
809
+
810
+ this.autoLoad(input);
811
+ // What the model is told about the project, fresh for this turn.
812
+ [this.map, this.memory] = await Promise.all([
813
+ projectMap(this.cwd).catch(() => ''),
814
+ loadMemory(this.cwd).catch(() => ''),
815
+ ]);
816
+ await this.persist();
817
+
818
+ // A busy model was swapped for a fallback earlier; after a few minutes the
819
+ // one the user chose gets another go.
820
+ this.preferred ??= model();
821
+ if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
822
+ setModel(this.preferred);
823
+ if (this.full) this.showHeader({ clear: false });
824
+ }
825
+
826
+ this.busy = true;
827
+ this.abort = new AbortController();
828
+
829
+ const turnStarted = Date.now();
830
+ let finished = false;
831
+ this.ui.turnStart?.();
832
+ try {
833
+ for (;;) {
834
+ try {
835
+ await this.run();
836
+ break;
837
+ } catch (err) {
838
+ // The daily free cap mid-build: wait for the reset and carry on,
839
+ // rather than leaving a half-built app for the user to restart.
840
+ if (err?.detail?.daily && !this.abort.signal.aborted && (await this.waitForReset(err))) continue;
841
+ throw err;
842
+ }
843
+ }
844
+ finished = true;
845
+ } catch (err) {
846
+ if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
847
+ else throw err;
848
+ } finally {
849
+ trace({ kind: 'turn', ms: Date.now() - turnStarted });
850
+ this.stats.workMs += Date.now() - turnStarted;
851
+ this.stats.turns++;
852
+ this.busy = false;
853
+ this.abort = null;
854
+ this.ui.stopSpinner();
855
+ this.ui.turnEnd?.({ ok: finished });
856
+ await this.persist();
857
+ if (this.full) this.showHeader({ clear: false });
858
+ if (finished) this.openWhenReady(turnStarted);
859
+ }
860
+ }
861
+
862
+ /** The tools the model may see, given the mode. */
863
+ toolsNow() {
864
+ const all = [...tools, loadSkillTool, planTool, delegateTool];
865
+ if (this.ui.mode !== 'plan') return all;
866
+ return all.filter((t) => !WRITES.has(t.name));
867
+ }
868
+
869
+ /** Model, tools, model, until it answers with prose. */
870
+ async run() {
871
+ const available = this.toolsNow();
872
+ let argRetries = 0;
873
+ let continuations = 0;
874
+ let askedToVerify = false;
875
+ let askedToSpeak = false;
876
+ let fixRounds = 0;
877
+ this.failovers = 0;
878
+ this.tried = new Set([model()]);
879
+
880
+ this.stuck = new StuckWatch();
881
+ this.touched = new Set();
882
+ this.sinceCheck = new Set();
883
+ this.logWatch = new LogWatch();
884
+ this.ranSomething = false;
885
+
886
+ for (let step = 0; step < MAX_STEPS; step++) {
887
+ await this.maybeFold();
888
+ this.ui.startSpinner(step === 0 ? 'thinking' : 'working');
889
+
890
+ let reply;
891
+ let streaming = false;
892
+ this.early = new Map();
893
+
894
+ try {
895
+ const opts = {
896
+ signal: this.abort.signal,
897
+ onWait: (text) => this.ui.updateSpinner(text),
898
+ };
899
+ // Only a real terminal has somewhere to stream into.
900
+ if (this.full) {
901
+ opts.onThinking = () => this.ui.thinkingDelta();
902
+ opts.onText = (delta) => {
903
+ if (!streaming) {
904
+ streaming = true;
905
+ this.ui.thinkingEnd();
906
+ this.ui.streamBegin();
907
+ }
908
+ this.ui.streamDelta(delta);
909
+ };
910
+ // Read-only calls start the moment they are fully written, while the
911
+ // rest of the reply is still arriving. Nothing that writes or runs is
912
+ // started early: a reply that fails halfway must leave no side effects.
913
+ opts.onToolCall = (call) => {
914
+ if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
915
+ this.early.set(call.id, this.execute(call));
916
+ }
917
+ };
918
+ }
919
+
920
+ var asked = Date.now();
921
+ reply = await ask(
922
+ [
923
+ {
924
+ role: 'system',
925
+ content: systemPrompt({
926
+ cwd: this.cwd,
927
+ skills: this.skills,
928
+ mode: this.ui.mode,
929
+ check: this.check,
930
+ map: this.map,
931
+ memory: this.memory,
932
+ }),
933
+ },
934
+ ...dedupe(lean(this.working)),
935
+ ],
936
+ available,
937
+ opts
938
+ );
939
+ } catch (err) {
940
+ this.ui.thinkingEnd();
941
+ if (streaming) this.ui.streamEnd();
942
+
943
+ // The model invented a tool and the provider rejected the request
944
+ // outright. Tell it what it did and let it try again.
945
+ if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
946
+ argRetries++;
947
+ this.ui.stopSpinner();
948
+ this.ui.toolFailed(
949
+ `${err.detail?.attemptedName ?? 'invalid tool call'} retrying (${argRetries}/${MAX_ARG_RETRIES})`
950
+ );
951
+ this.push({
952
+ role: 'user',
953
+ content:
954
+ `Your last tool call was rejected. ${err.failed} The only tools that exist ` +
955
+ `are: ${available.map((t) => t.name).join(', ')}. Try again with one of them.`,
956
+ });
957
+ continue;
958
+ }
959
+
960
+ // Busy, slow or down: move to the next model and carry on, rather
961
+ // than ending a half-built app with an error.
962
+ if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
963
+ throw err;
964
+ }
965
+
966
+ // A reply that is nothing but tool calls never starts a text stream, so
967
+ // the thinking timer has to be closed out here as well.
968
+ this.ui.thinkingEnd();
969
+ this.ui.stopSpinner();
970
+ this.record(reply.usage);
971
+ this.ui.step?.();
972
+ this.stats.steps++;
973
+ this.stats.tokensIn += reply.usage.promptTokens ?? 0;
974
+ this.stats.tokensOut += reply.usage.outputTokens ?? 0;
975
+ trace({
976
+ kind: 'model', who: 'lead', model: model(), ms: Date.now() - asked,
977
+ in: reply.usage.promptTokens, out: reply.usage.outputTokens,
978
+ calls: reply.toolCalls.map((c) => c.name),
979
+ });
980
+
981
+ // Streamed text is already on screen; turn it into rendered markdown.
982
+ // Text that turns out to be narration ahead of a tool call folds into a
983
+ // status line instead — that is where the live commentary comes from.
984
+ const narrating = reply.toolCalls.length > 0;
985
+ if (streaming) this.ui.streamEnd({ asNarration: narrating });
986
+ else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
987
+ else if (reply.text) this.ui.assistant(reply.text);
988
+
989
+ if (reply.toolCalls.length === 0) {
990
+ // The answer stopped at the provider's output cap rather than at the
991
+ // end of a thought, so it is cut mid-word. Ask for the rest instead of
992
+ // handing over half an answer with no sign there was more.
993
+ if (reply.finishReason === 'length' && reply.text && continuations < MAX_CONTINUATIONS) {
994
+ continuations++;
995
+ this.push({ role: 'assistant', content: reply.text });
996
+ this.push({
997
+ role: 'user',
998
+ content:
999
+ 'Your reply stopped at the output limit, mid-sentence. Carry on from exactly ' +
1000
+ 'where it broke off. Do not repeat any of it, do not start again, and do not ' +
1001
+ 'introduce it — just continue.',
1002
+ });
1003
+ this.ui.note('hit the output limit — asking for the rest');
1004
+ continue;
1005
+ }
1006
+
1007
+ // Type-check what changed and hand back the errors, a few rounds at most.
1008
+ // A turn that ends on a broken build is the most common way an app
1009
+ // gets handed over as done when it is not.
1010
+ if (fixRounds < MAX_FIX_ROUNDS) {
1011
+ const problems = await this.autoCheck();
1012
+ if (problems) {
1013
+ fixRounds++;
1014
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
1015
+ this.push({
1016
+ role: 'user',
1017
+ content:
1018
+ `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
1019
+ `${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
1020
+ });
1021
+ continue;
1022
+ }
1023
+ }
1024
+
1025
+ // It changed code and never ran anything. Send it back once.
1026
+ if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
1027
+ askedToVerify = true;
1028
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
1029
+ this.push({
1030
+ role: 'user',
1031
+ content:
1032
+ `You changed ${[...this.touched].join(', ')} and did not check it. Run ` +
1033
+ `\`${this.check}\` with run_command now, then say what actually happened — ` +
1034
+ 'if it failed, show the output rather than claiming it worked. If that is ' +
1035
+ 'the wrong way to check this project, run the right one and say which.',
1036
+ });
1037
+ this.ui.note('verifying the change');
1038
+ continue;
1039
+ }
1040
+
1041
+ /**
1042
+ * It did the work and then said nothing at all.
1043
+ *
1044
+ * Reasoning models do this, and the instruction to skip closing
1045
+ * summaries makes it more likely. Silence is fine as a style; it is
1046
+ * not fine as an answer, because the user cannot tell it apart from a
1047
+ * crash and if they asked what happened, they asked. One nudge,
1048
+ * once per turn, and only when there was actually work to report.
1049
+ */
1050
+ if (!reply.text?.trim() && this.touched.size + (this.ranSomething ? 1 : 0) > 0 && !askedToSpeak) {
1051
+ askedToSpeak = true;
1052
+ this.push({
1053
+ role: 'user',
1054
+ content:
1055
+ 'You finished without saying anything. In one or two sentences: what did ' +
1056
+ 'you change, and does it work? No preamble, no repeating the diffs.',
1057
+ });
1058
+ continue;
1059
+ }
1060
+
1061
+ this.push({ role: 'assistant', content: reply.text });
1062
+ if (!reply.text?.trim()) this.ui.note('the model ended the turn without a reply');
1063
+ return;
1064
+ }
1065
+
1066
+ this.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1067
+ await this.persist();
1068
+
1069
+ const badArgs = await this.runCalls(reply.toolCalls);
1070
+ if (this.abort.signal.aborted) return;
1071
+
1072
+ // Malformed arguments go back to the model, but only so many times.
1073
+ if (badArgs) {
1074
+ argRetries++;
1075
+ if (argRetries > MAX_ARG_RETRIES) {
1076
+ throw new Failure({
1077
+ kind: 'bad_tool_args',
1078
+ attempted: 'running the tools the model asked for',
1079
+ failed:
1080
+ `${modelName()} produced invalid tool arguments ${argRetries} times running ` +
1081
+ 'and could not correct itself.',
1082
+ fix:
1083
+ 'Say what you want more concretely, or /model to another one — North Mini ' +
1084
+ 'Code and Nemotron 3.5 Lightning are both steadier with tool arguments.',
1085
+ });
1086
+ }
1087
+ } else {
1088
+ argRetries = 0;
1089
+ }
1090
+
1091
+ await this.persist();
1092
+ }
1093
+
1094
+ throw new Failure({
1095
+ kind: 'step_limit',
1096
+ attempted: 'finishing your request',
1097
+ failed: `The model was still calling tools after ${MAX_STEPS} steps.`,
1098
+ fix:
1099
+ 'Nothing is lost — everything so far is on disk. Say "carry on where you left ' +
1100
+ 'off" to continue. If it was repeating one step, it is looping: break the task ' +
1101
+ 'up, or /new to reset.',
1102
+ });
1103
+ }
1104
+
1105
+ /**
1106
+ * Run one round of tool calls.
1107
+ *
1108
+ * Consecutive read-only calls go out together — four files read at once
1109
+ * rather than four round trips — while anything that writes or executes runs
1110
+ * on its own, in order. Returns whether any call had unusable arguments.
1111
+ */
1112
+ async runCalls(calls) {
1113
+ const groups = [];
1114
+ let batch = [];
1115
+
1116
+ for (const call of calls) {
1117
+ if (PARALLEL_SAFE.has(call.name)) {
1118
+ batch.push(call);
1119
+ } else {
1120
+ if (batch.length) { groups.push(batch); batch = []; }
1121
+ groups.push([call]);
1122
+ }
1123
+ }
1124
+ if (batch.length) groups.push(batch);
1125
+
1126
+ let badArgs = false;
1127
+
1128
+ for (const group of groups) {
1129
+ if (this.abort.signal.aborted) return badArgs;
1130
+
1131
+ const noted = (call) => {
1132
+ if (FILE_WRITES.has(call.name)) {
1133
+ for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
1134
+ }
1135
+ if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1136
+ };
1137
+
1138
+ if (group.length > 1) {
1139
+ for (const call of group) {
1140
+ this.ui.toolCall(describe(call.name, call.args));
1141
+ noted(call);
1142
+ }
1143
+ this.ui.startSpinner(`${group.length} lookups at once`);
1144
+
1145
+ const settled = await Promise.all(
1146
+ group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
1147
+ );
1148
+
1149
+ this.ui.stopSpinner();
1150
+ for (const { call, out, err } of settled) {
1151
+ if (err) badArgs = this.reportFailure(call, err) || badArgs;
1152
+ else this.reportResult(call, out);
1153
+ }
1154
+ continue;
1155
+ }
1156
+
1157
+ for (const call of group) {
1158
+ if (this.abort.signal.aborted) return badArgs;
1159
+
1160
+ const label = describe(call.name, call.args);
1161
+ if (!SILENT.has(call.name)) this.ui.toolCall(label);
1162
+ this.ui.startSpinner(label);
1163
+ noted(call);
1164
+
1165
+ const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
1166
+ this.ui.stopSpinner();
1167
+ if (err) badArgs = this.reportFailure(call, err) || badArgs;
1168
+ else this.reportResult(call, out);
1169
+ }
1170
+ }
1171
+
1172
+ return badArgs;
1173
+ }
1174
+
1175
+ /**
1176
+ * Check a finished call against the stuck patterns (see stuck.js) and return
1177
+ * the note to add to its result. A nudge that did not work hands the turn to
1178
+ * another model.
1179
+ */
1180
+ stuckNote(call, outcome) {
1181
+ if (!this.stuck) return '';
1182
+ const verdict = this.stuck.observe(eventFor(call, outcome));
1183
+ if (!verdict) return '';
1184
+ this.stats.stuck++;
1185
+ if (verdict.action === 'switch') {
1186
+ const next = fallbackFor(model(), this.tried ?? new Set([model()]));
1187
+ if (next) {
1188
+ this.tried?.add(next);
1189
+ this.ui.note(`${modelName(model())} kept ${describeHit(verdict.hit)} handing over to ${modelName(next)}`);
1190
+ setModel(next);
1191
+ this.cooldownUntil = Date.now() + COOLDOWN;
1192
+ if (this.full) this.showHeader({ clear: false });
1193
+ }
1194
+ }
1195
+ return verdict.text ? `\n\n${verdict.text}` : '';
1196
+ }
1197
+
1198
+ /**
1199
+ * The daily free cap was hit mid-turn: keep the session, count down to the
1200
+ * reset, and carry on by itself. Esc stops the wait like any other turn.
1201
+ */
1202
+ async waitForReset(err) {
1203
+ const resetAt = err.detail?.resetAt;
1204
+ if (!Number.isFinite(resetAt)) return false;
1205
+ const at = resetAt + 30_000;
1206
+ const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1207
+ this.ui.stopSpinner();
1208
+ this.ui.note(`Daily limit reached — ucode carries on by itself at ${clock}. Esc to stop`);
1209
+ await this.persist();
1210
+ let lastNote = Date.now();
1211
+ while (Date.now() < at) {
1212
+ if (this.abort?.signal.aborted) return false;
1213
+ const left = formatDuration(at - Date.now());
1214
+ if (this.full) this.ui.startSpinner(`daily limit · carrying on at ${clock} (in ${left})`);
1215
+ else if (Date.now() - lastNote > 30 * 60_000) { this.ui.note(`still waiting for the daily limit — ${left} to go`); lastNote = Date.now(); }
1216
+ await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(0, at - Date.now()))));
1217
+ }
1218
+ this.ui.stopSpinner();
1219
+ this.ui.note('Daily limit has reset — carrying on');
1220
+ return true;
1221
+ }
1222
+
1223
+ /** A dev server came up during this turn: open it in the browser, once. */
1224
+ openWhenReady(since) {
1225
+ if (!this.full || process.env.UCODE_OPEN === '0') return;
1226
+ const server = serversReadySince(since).at(-1);
1227
+ if (!server || (this.opened ??= new Set()).has(server.url)) return;
1228
+ this.opened.add(server.url);
1229
+ const [cmd, args] = process.platform === 'win32'
1230
+ ? ['cmd', ['/c', 'start', '', server.url]]
1231
+ : [process.platform === 'darwin' ? 'open' : 'xdg-open', [server.url]];
1232
+ try {
1233
+ spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
1234
+ this.ui.note(`Opened ${server.url} in your browser`);
1235
+ } catch { /* no browser to open — the link is in the answer */ }
1236
+ }
1237
+
1238
+ cmdStats() {
1239
+ for (const line of statsLines(this.stats, this.working?.length ?? 0)) this.ui.write(line);
1240
+ }
1241
+
1242
+ async cmdDoctor() {
1243
+ this.ui.startSpinner('checking your setup');
1244
+ try {
1245
+ const lines = await runDoctor();
1246
+ this.ui.stopSpinner();
1247
+ for (const line of lines) this.ui.write(line);
1248
+ } finally {
1249
+ this.ui.stopSpinner();
1250
+ }
1251
+ }
1252
+
1253
+ /** /deploy [folder] — the same tool the model calls, run directly. */
1254
+ async cmdDeploy(arg) {
1255
+ const folder = (arg ?? '').trim() || '.';
1256
+ this.ui.toolCall(`Deploying ${folder}`);
1257
+ this.ui.startSpinner('getting ready to deploy');
1258
+ try {
1259
+ const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
1260
+ this.ui.stopSpinner();
1261
+ this.ui.toolResult(out.summary);
1262
+ this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
1263
+ } catch (err) {
1264
+ this.ui.stopSpinner();
1265
+ if (err instanceof ToolFailure) this.ui.error(err);
1266
+ else throw err;
1267
+ }
1268
+ }
1269
+
1270
+ reportResult(call, out) {
1271
+ // A build or type check that just passed already verified everything
1272
+ // changed so far; the automatic check at the end would only repeat it.
1273
+ if (call.name === 'run_command' && out.exitCode === 0 &&
1274
+ /(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
1275
+ this.sinceCheck?.clear();
1276
+ }
1277
+ if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
1278
+ if (out.diff?.length) this.ui.diff(out.diff);
1279
+ if (out.output?.length) this.ui.commandOutput(out.output);
1280
+ this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1281
+ }
1282
+
1283
+ /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
1284
+ reportFailure(call, err) {
1285
+ if (!(err instanceof ToolFailure)) throw err;
1286
+
1287
+ this.ui.toolFailed(
1288
+ err instanceof Declined ? 'declined' : `${err.kind}: ${err.failed}`
1289
+ );
1290
+ this.push({
1291
+ role: 'tool',
1292
+ toolCallId: call.id,
1293
+ name: call.name,
1294
+ content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
1295
+ });
1296
+ return err.kind === 'bad_args';
1297
+ }
1298
+
1299
+ async dispatch(call) {
1300
+ // The model emitted arguments that were not valid JSON. Hand the parser's
1301
+ // own complaint straight back so it can correct itself next step.
1302
+ if (call.parseError) {
1303
+ throw new ToolFailure({
1304
+ kind: 'bad_args',
1305
+ attempted: `calling ${call.name}`,
1306
+ failed: `The arguments were not valid JSON: ${call.parseError}`,
1307
+ fix: `Call ${call.name} again with the arguments as one well-formed JSON object.`,
1308
+ });
1309
+ }
1310
+
1311
+ if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1312
+ if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1313
+ if (call.name === 'delegate') return this.delegate(call.args?.tasks);
1314
+
1315
+ // Output reaches the screen as the command produces it, so a slow build is
1316
+ // something you watch rather than something you sit out in silence.
1317
+ return runTool(call.name, call.args ?? {}, {
1318
+ onOutput: (lines) => this.ui.progress(lines),
1319
+ });
1320
+ }
1321
+
1322
+ /**
1323
+ * Switch to the next model after a provider failure. Returns false once
1324
+ * there is nothing sensible left to try. When every model is busy at once,
1325
+ * it waits a minute and goes round again rather than giving up.
1326
+ */
1327
+ async failover(err) {
1328
+ if (++this.failovers > MAX_FAILOVERS) return false;
1329
+ const from = model();
1330
+ let next = fallbackFor(from, this.tried);
1331
+
1332
+ if (!next) {
1333
+ const until = Date.now() + 60_000;
1334
+ this.ui.startSpinner('every model is busy');
1335
+ while (Date.now() < until && !this.abort?.signal.aborted) {
1336
+ this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
1337
+ await wait(1000);
1338
+ }
1339
+ this.ui.stopSpinner();
1340
+ if (this.abort?.signal.aborted) return false;
1341
+ this.tried = new Set();
1342
+ next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
1343
+ }
1344
+
1345
+ this.tried.add(next);
1346
+ setModel(next);
1347
+ this.cooldownUntil = Date.now() + COOLDOWN;
1348
+ const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
1349
+ this.ui.note(`${modelName(from)} is ${why} — carrying on with ${modelName(next)}`);
1350
+ if (this.full) this.showHeader({ clear: false });
1351
+ return true;
1352
+ }
1353
+
1354
+ /** Run a call and settle to { out } or { err } — never throws. */
1355
+ execute(call) {
1356
+ const started = Date.now();
1357
+ return this.dispatch(call).then(
1358
+ (out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
1359
+ (err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
1360
+ );
1361
+ }
1362
+
1363
+ updatePlan(items) {
1364
+ const list = (Array.isArray(items) ? items : [])
1365
+ .filter((i) => i && String(i.text ?? '').trim())
1366
+ .slice(0, 6);
1367
+ this.ui.plan(list);
1368
+ const done = list.filter((i) => i.done).length;
1369
+ return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
1370
+ }
1371
+
1372
+ /** File writes from parallel workers take turns, so two never interleave. */
1373
+ fileLock(fn) {
1374
+ const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
1375
+ this.lockChain = run.catch(() => {});
1376
+ return run;
1377
+ }
1378
+
1379
+ /**
1380
+ * Several workers at once, each its own small agent loop with its own
1381
+ * conversation, sharing the tools, the project, and whatever skills are
1382
+ * already in force. Their lines in the transcript carry their name.
1383
+ */
1384
+ async delegate(tasks) {
1385
+ const list = (Array.isArray(tasks) ? tasks : [])
1386
+ .filter((t) => t && String(t.instructions ?? '').trim())
1387
+ .slice(0, MAX_WORKERS);
1388
+ if (!list.length) {
1389
+ throw new ToolFailure({
1390
+ kind: 'bad_args',
1391
+ attempted: 'starting workers',
1392
+ failed: 'No tasks with instructions were given.',
1393
+ fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
1394
+ });
1395
+ }
1396
+
1397
+ const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
1398
+ name: task.name || `worker ${i + 1}`,
1399
+ summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
1400
+ touched: [],
1401
+ }))));
1402
+
1403
+ for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
1404
+
1405
+ // A worker that wrote nothing has not done its part, whatever it said.
1406
+ // The lead builds those itself rather than leaving holes in the app.
1407
+ const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
1408
+
1409
+ return {
1410
+ content: results
1411
+ .map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
1412
+ .join('\n\n') +
1413
+ (empty.length
1414
+ ? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
1415
+ 'yourself now, directly - do not delegate them again.'
1416
+ : ''),
1417
+ summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
1418
+ };
1419
+ }
1420
+
1421
+ async runWorker(task, index) {
1422
+ const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
1423
+ const touched = new Set();
1424
+ const skills = this.skills
1425
+ .filter((s) => this.loaded.has(s.name))
1426
+ .map((s) => `--- ${s.name} ---\n${s.body}`)
1427
+ .join('\n\n');
1428
+ const messages = [
1429
+ { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
1430
+ { role: 'user', content: String(task.instructions) },
1431
+ ];
1432
+ const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
1433
+ const wanted = process.env.UCODE_WORKER_MODEL;
1434
+ let workerModel = wanted && MODELS[wanted] ? wanted : model();
1435
+ const tried = new Set([workerModel]);
1436
+ let failovers = 0;
1437
+
1438
+ // Start a moment apart. Three requests in the same instant is exactly what
1439
+ // trips a free endpoint's rate limit, and the stagger costs a second or two.
1440
+ if (index) await wait(index * 1500);
1441
+
1442
+ for (let step = 0; step < WORKER_STEPS; step++) {
1443
+ if (this.abort?.signal.aborted) break;
1444
+
1445
+ let reply;
1446
+ const asked = Date.now();
1447
+ try {
1448
+ reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1449
+ } catch (err) {
1450
+ // Same rule as the lead: a busy model is swapped, not a reason to stop.
1451
+ if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
1452
+ failovers++;
1453
+ let next = fallbackFor(workerModel, tried);
1454
+ if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
1455
+ tried.add(next);
1456
+ this.ui.note(`${name}: ${modelName(workerModel)} is busy — switching to ${modelName(next)}`);
1457
+ workerModel = next;
1458
+ step--;
1459
+ continue;
1460
+ }
1461
+ throw err;
1462
+ }
1463
+ this.record(reply.usage);
1464
+ trace({
1465
+ kind: 'model', who: name, model: workerModel, ms: Date.now() - asked,
1466
+ in: reply.usage.promptTokens, out: reply.usage.outputTokens,
1467
+ calls: reply.toolCalls.map((c) => c.name),
1468
+ });
1469
+
1470
+ if (!reply.toolCalls.length) {
1471
+ this.ui.toolResult(`${name} finished`);
1472
+ return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
1473
+ }
1474
+
1475
+ messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1476
+ for (const call of reply.toolCalls) {
1477
+ this.ui.toolCall(`${name} › ${describe(call.name, call.args)}`);
1478
+ if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
1479
+ const { out, err } = FILE_WRITES.has(call.name)
1480
+ ? await this.fileLock(() => this.execute(call))
1481
+ : await this.execute(call);
1482
+ if (err) {
1483
+ if (!(err instanceof ToolFailure)) throw err;
1484
+ this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
1485
+ }
1486
+ messages.push({
1487
+ role: 'tool', toolCallId: call.id, name: call.name,
1488
+ content: err ? err.forModel() : out.content,
1489
+ });
1490
+ }
1491
+ }
1492
+
1493
+ return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
1494
+ }
1495
+
1496
+ /**
1497
+ * Check the code files changed since the last check, and return the
1498
+ * errors as text for the model — or null when everything is clean.
1499
+ *
1500
+ * The check is incremental: TypeScript writes what it learned to a build
1501
+ * info file, so the second check onwards reads that instead of retyping
1502
+ * every dependency — seconds rather than the best part of a minute. The
1503
+ * file sits in node_modules/.cache, which is already ignored by git and is
1504
+ * deliberately left out of the starter package cache.
1505
+ *
1506
+ * TypeScript projects get one `tsc --noEmit` per project that owns a
1507
+ * changed file (an app scaffolded into a subfolder is its own project).
1508
+ * Plain JavaScript gets a syntax check, Python a compile check. Nothing
1509
+ * runs that is not already installed.
1510
+ */
1511
+ async autoCheck() {
1512
+ const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
1513
+ this.sinceCheck.clear();
1514
+ if (!changed.length) return null;
1515
+
1516
+ const root = path.resolve(this.cwd);
1517
+ const tsRoots = new Set();
1518
+ const singles = [];
1519
+
1520
+ for (const rel of changed) {
1521
+ const abs = path.resolve(root, rel);
1522
+ if (!(await exists(abs))) continue;
1523
+ if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
1524
+ let dir = path.dirname(abs);
1525
+ let owner = null;
1526
+ while (dir.startsWith(root)) {
1527
+ if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
1528
+ const up = path.dirname(dir);
1529
+ if (up === dir) break;
1530
+ dir = up;
1531
+ }
1532
+ if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
1533
+ else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
1534
+ }
1535
+
1536
+ const problems = [];
1537
+ const check = async (label, command, cwd) => {
1538
+ this.ui.toolCall(label);
1539
+ this.ui.startSpinner(label);
1540
+ const { out, err } = await this.execute({
1541
+ id: 'check', name: 'run_command',
1542
+ args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
1543
+ });
1544
+ this.ui.stopSpinner();
1545
+ return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
1546
+ };
1547
+
1548
+ for (const dir of tsRoots) {
1549
+ const show = path.relative(root, dir) || '.';
1550
+ await mkdir(path.join(dir, path.dirname(TSBUILDINFO)), { recursive: true }).catch(() => {});
1551
+ let out = await check(`Checking types in ${show}`, typeCheckCommand(true), dir);
1552
+ // Older TypeScript refuses --incremental alongside --noEmit. Say so once
1553
+ // by simply checking again the slow way, rather than failing the edit.
1554
+ if (out.exitCode !== 0 && NO_INCREMENTAL.test(out.content)) {
1555
+ out = await check(`Checking types in ${show}`, typeCheckCommand(false), dir);
1556
+ }
1557
+ if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
1558
+ const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
1559
+ this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
1560
+ problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
1561
+ }
1562
+
1563
+ for (const f of singles) {
1564
+ const out = await check(`Checking ${f.rel}`, f.command, root);
1565
+ if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
1566
+ this.ui.toolFailed('does not compile');
1567
+ problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
1568
+ }
1569
+
1570
+ // Only once it compiles: a failing test on code that does not build tells
1571
+ // the model nothing it does not already know from the errors above.
1572
+ if (!problems.length) {
1573
+ const failed = await this.runRelatedTests(root, changed);
1574
+ if (failed) problems.push(failed);
1575
+ }
1576
+
1577
+ const live = await this.liveErrors();
1578
+ if (live) problems.push(live);
1579
+
1580
+ return problems.length ? problems.join('\n\n') : null;
1581
+ }
1582
+
1583
+ /**
1584
+ * Anything the running app has complained about since the last look. A dev
1585
+ * server knows about a broken import the moment it happens; without this
1586
+ * nobody reads that until a build, or until the user says the page is blank.
1587
+ */
1588
+ async liveErrors() {
1589
+ try {
1590
+ const found = await this.logWatch.since(runningServers());
1591
+ if (found) this.ui.toolFailed('the running app reported an error');
1592
+ return found;
1593
+ } catch {
1594
+ return null; // reading a log must never be what breaks a turn
1595
+ }
1596
+ }
1597
+
1598
+ /**
1599
+ * Run the tests that reach the files just changed, and return their
1600
+ * failures as text or null when they pass, or when this project has no
1601
+ * runner that can be asked which tests matter.
1602
+ */
1603
+ async runRelatedTests(root, changed) {
1604
+ const runner = await testRunnerFor(root);
1605
+ if (!runner) return null;
1606
+
1607
+ const existing = [];
1608
+ for (const rel of changed) if (await exists(path.resolve(root, rel))) existing.push(rel);
1609
+ const command = relatedCommand(runner, existing);
1610
+ if (!command) return null;
1611
+
1612
+ const label = `Running the ${runner} tests that cover this`;
1613
+ this.ui.toolCall(label);
1614
+ this.ui.startSpinner(label);
1615
+ const { out, err } = await this.execute({
1616
+ id: 'tests', name: 'run_command',
1617
+ args: { command, cwd: '.', timeout_ms: 180_000 },
1618
+ });
1619
+ this.ui.stopSpinner();
1620
+
1621
+ if (err) { this.ui.toolResult('tests skipped'); return null; }
1622
+ if (out.exitCode === 0) { this.ui.toolResult('tests pass'); return null; }
1623
+
1624
+ // A runner that is not installed is not a failing test; npx says so.
1625
+ if (/could not determine executable|not found|Cannot find module/i.test(out.content)) {
1626
+ this.ui.toolResult('no test runner installed');
1627
+ return null;
1628
+ }
1629
+
1630
+ this.ui.toolFailed('tests fail');
1631
+ return `The tests covering your change fail (${runner}):\n${summariseFailures(runner, out.content)}`;
1632
+ }
1633
+
1634
+ loadSkill(name) {
1635
+ const skill = findSkill(this.skills, name);
1636
+ if (!skill) {
1637
+ // The available names go in `failed` rather than only in `fix`: the
1638
+ // transcript shows the failure line, and a bare "no such skill" leaves
1639
+ // the user guessing at what this session actually has.
1640
+ const available = this.skills.map((s) => s.name).join(', ') || '(none)';
1641
+ throw new ToolFailure({
1642
+ kind: 'no_such_skill',
1643
+ attempted: `loading the "${name}" skill`,
1644
+ failed: `There is no skill called "${name}". This session has: ${available}.`,
1645
+ fix: 'Use one of those names, or carry on without one.',
1646
+ });
1647
+ }
1648
+
1649
+ if (this.loaded.has(skill.name)) {
1650
+ return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
1651
+ }
1652
+
1653
+ this.loaded.add(skill.name);
1654
+ this.push(skillMessage(skill));
1655
+ return {
1656
+ content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
1657
+ summary: `${skill.name} · ${skill.body.split('\n').length} lines`,
1658
+ };
1659
+ }
1660
+
1661
+ record(u) {
1662
+ const total = this.session.usage;
1663
+ total.promptTokens += u.promptTokens || 0;
1664
+ total.outputTokens += u.outputTokens || 0;
1665
+ total.totalTokens += u.totalTokens || 0;
1666
+ total.turns += 1;
1667
+ }
1668
+
1669
+ /** Fold older turns into a summary when the window gets tight. */
1670
+ async maybeFold() {
1671
+ const limit = contextLimit();
1672
+ if (!tooBig(this.working, limit)) return;
1673
+
1674
+ this.ui.startSpinner('context is filling up — summarizing earlier turns');
1675
+ try {
1676
+ const result = await fold(this.working, {
1677
+ limit,
1678
+ summarize: async (older) => {
1679
+ const reply = await ask(
1680
+ [
1681
+ { role: 'system', content: SUMMARY_PROMPT },
1682
+ { role: 'user', content: forSummary(older) },
1683
+ ],
1684
+ [],
1685
+ { signal: this.abort?.signal, temperature: 0 }
1686
+ );
1687
+ return reply.text;
1688
+ },
1689
+ });
1690
+
1691
+ this.ui.stopSpinner();
1692
+ if (result.folded) {
1693
+ this.working = result.messages;
1694
+ this.ui.note(
1695
+ `folded ${result.droppedCount} earlier messages into a summary ` +
1696
+ '(the full history is still saved in this session)'
1697
+ );
1698
+ }
1699
+ } catch (err) {
1700
+ // If summarizing fails, carry on with the full history and let the API
1701
+ // complain — better than silently throwing away the conversation.
1702
+ this.ui.stopSpinner();
1703
+ this.ui.note(`could not summarize older turns (${err.kind ?? 'error'}); carrying on uncompacted`);
1704
+ }
1705
+ }
1706
+
1707
+ // -- slash commands ------------------------------------------------------
1708
+
1709
+ async command(input) {
1710
+ const [name, ...rest] = input.split(/\s+/);
1711
+ const arg = rest.join(' ').trim();
1712
+
1713
+ switch (name) {
1714
+ case '/help':
1715
+ return this.cmdHelp();
1716
+
1717
+ // One thing, one command, however you happen to spell it.
1718
+ case '/model':
1719
+ case '/models':
1720
+ return this.cmdModel(arg);
1721
+
1722
+ case '/session':
1723
+ case '/sessions':
1724
+ case '/resume':
1725
+ return this.cmdSessions(arg);
1726
+
1727
+ case '/new': return this.cmdNew();
1728
+ case '/remember': return this.cmdRemember(arg);
1729
+ case '/skills': return this.cmdSkills();
1730
+ case '/clear': this.showHeader(); return;
1731
+ case '/search': return this.cmdSearch(arg);
1732
+ case '/copy': return this.cmdCopy();
1733
+ case '/stats': return this.cmdStats();
1734
+ case '/doctor': return this.cmdDoctor();
1735
+ case '/deploy': return this.cmdDeploy(arg);
1736
+ case '/exit':
1737
+ case '/quit': return 'exit';
1738
+
1739
+ default:
1740
+ this.ui.write(theme.warn(` no such command: ${name}`));
1741
+ this.ui.note('/help lists them.');
1742
+ }
1743
+ }
1744
+
1745
+ cmdHelp() {
1746
+ const rows = [
1747
+ ['/help', 'this list'],
1748
+ ['/stats', 'time, steps and tokens this session'],
1749
+ ['/doctor', 'check that everything ucode needs is working'],
1750
+ ['/deploy [folder]', 'put the app online and get its link'],
1751
+ ['/model', 'show the models and switch between them'],
1752
+ ['/resume', 'pick up an earlier conversation'],
1753
+ ['/new', 'save this one and start fresh'],
1754
+ ['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
1755
+ ['/skills', 'what ucode knows how to do'],
1756
+ ['/search <query>', 'look something up on the web'],
1757
+ ['/copy', 'copy the last reply to the clipboard'],
1758
+ ['/clear', 'clear the screen, keep the conversation'],
1759
+ ['/exit', 'save and quit'],
1760
+ ];
1761
+
1762
+ this.ui.blank();
1763
+ for (const [command, what] of rows) {
1764
+ this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
1765
+ }
1766
+ this.ui.blank();
1767
+ this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
1768
+ this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
1769
+ this.ui.blank();
1770
+ }
1771
+
1772
+ /** The five models, and this session's spend. */
1773
+ async cmdModel(arg) {
1774
+ if (arg) {
1775
+ try {
1776
+ setModel(arg);
1777
+ this.preferred = model();
1778
+ } catch (err) {
1779
+ this.ui.error(err, { debug: this.debug });
1780
+ return;
1781
+ }
1782
+ this.session.model = model();
1783
+ this.ui.note(`now using ${modelName()}`);
1784
+ this.showHeader({ clear: false });
1785
+ return;
1786
+ }
1787
+
1788
+ const all = modelList();
1789
+ const width = Math.max(...all.map((m) => m.name.length));
1790
+
1791
+ if (this.ui.pick) {
1792
+ const items = all.map((m) => ({
1793
+ label:
1794
+ `${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1795
+ `${m.name.padEnd(width)} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`,
1796
+ }));
1797
+
1798
+ const chosen = await this.ui.pick(items, {
1799
+ active: Math.max(0, all.findIndex((m) => m.active)),
1800
+ hint: '↑↓ move · enter to switch · esc to cancel',
1801
+ });
1802
+ if (chosen === null) return;
1803
+
1804
+ setModel(all[chosen].id);
1805
+ this.preferred = model();
1806
+ this.session.model = model();
1807
+ this.ui.note(`now using ${modelName()}`);
1808
+ this.showHeader({ clear: false });
1809
+ return;
1810
+ }
1811
+
1812
+ this.ui.blank();
1813
+ for (const m of all) {
1814
+ this.ui.write(
1815
+ ` ${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1816
+ `${(m.active ? blue : dim)(m.name.padEnd(width))} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`
1817
+ );
1818
+ this.ui.write(` ${dim(m.id)}`);
1819
+ }
1820
+
1821
+ const u = this.session.usage;
1822
+ const live = rateLimits();
1823
+ this.ui.blank();
1824
+ this.ui.write(
1825
+ ` ${dim('this session')} ${u.turns} turns · ${formatTokens(u.totalTokens)} tokens ` +
1826
+ `(${formatTokens(u.promptTokens)} in, ${formatTokens(u.outputTokens)} out)`
1827
+ );
1828
+ if (live?.requestsRemaining != null && live?.requestsLimit) {
1829
+ this.ui.write(` ${dim('requests')} ${live.requestsRemaining} of ${live.requestsLimit} left`);
1830
+ }
1831
+ this.ui.blank();
1832
+ this.ui.write(dim(' /model <id> switches without the picker.'));
1833
+ this.ui.blank();
1834
+ }
1835
+
1836
+ /**
1837
+ * One row per saved conversation.
1838
+ *
1839
+ * A list of titles and timestamps is not enough to recognise your own work
1840
+ * by — half of them start "Fix the". So each row carries what it was about
1841
+ * and how far it got, and the ones from this folder are marked, because that
1842
+ * is nearly always the one being looked for.
1843
+ */
1844
+ describeSession(s, width, i) {
1845
+ const room = Math.max(24, Math.min(46, width - 34));
1846
+ const mark = s.mine ? blue('●') : dim('○');
1847
+ const when = relativeTime(s.updatedAt).padEnd(9);
1848
+ const turns = `${s.turns} turn${s.turns === 1 ? '' : 's'}`.padEnd(9);
1849
+ const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
1850
+
1851
+ return {
1852
+ // Numbered in the picker, so /session delete 3 has something to point at.
1853
+ label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
1854
+ sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
1855
+ };
1856
+ }
1857
+
1858
+ async cmdSessions(arg) {
1859
+ if (arg === '--clear' || arg === 'clear') {
1860
+ const yes = await this.ui.confirm({
1861
+ action: 'delete every saved conversation',
1862
+ detail: 'This cannot be undone.',
1863
+ risk: 'write',
1864
+ });
1865
+ if (!yes) {
1866
+ this.ui.note('cancelled');
1867
+ return;
1868
+ }
1869
+ await removeAll();
1870
+ this.ui.note('all sessions deleted');
1871
+ return;
1872
+ }
1873
+
1874
+ // /session delete 3 or /session delete 2,5,7
1875
+ const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
1876
+ if (del) return this.deleteSessions(del[1]);
1877
+
1878
+ const sessions = await list({ cwd: this.cwd });
1879
+ if (!sessions.length) {
1880
+ this.ui.note('no saved conversations yet');
1881
+ return;
1882
+ }
1883
+
1884
+ for (const bad of sessions.unreadable ?? []) {
1885
+ this.ui.write(theme.warn(` could not read session file: ${bad}`));
1886
+ }
1887
+
1888
+ const shown = sessions.slice(0, 25);
1889
+ const width = this.ui.width ? this.ui.width() : 80;
1890
+ let index;
1891
+
1892
+ if (arg) {
1893
+ const n = Number(arg);
1894
+ if (!Number.isInteger(n) || n < 1 || n > shown.length) {
1895
+ this.ui.write(theme.warn(` "${arg}" is not one of 1-${shown.length}`));
1896
+ return;
1897
+ }
1898
+ index = n - 1;
1899
+ } else if (this.ui.pick) {
1900
+ // The picker stays open while you delete, so clearing out several old
1901
+ // conversations is d d, d d, d d — then Enter on the one you want.
1902
+ let active = 0;
1903
+ for (;;) {
1904
+ const here = shown.filter((s) => s.mine).length;
1905
+ const picked = await this.ui.pick(
1906
+ shown.map((s, i) => this.describeSession(s, width, i)),
1907
+ {
1908
+ active,
1909
+ deletable: true,
1910
+ hint:
1911
+ `↑↓ move · enter to continue · d twice to delete · esc to cancel` +
1912
+ (here ? ` — ${here} from this folder` : ''),
1913
+ }
1914
+ );
1915
+ if (picked === null) return;
1916
+ if (typeof picked === 'object' && picked.delete !== undefined) {
1917
+ const doomed = shown[picked.delete];
1918
+ active = picked.delete;
1919
+ if (doomed.id === this.session.id) {
1920
+ this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
1921
+ continue;
1922
+ }
1923
+ await remove(doomed.id);
1924
+ shown.splice(picked.delete, 1);
1925
+ this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
1926
+ if (!shown.length) {
1927
+ this.ui.note('no saved conversations left');
1928
+ return;
1929
+ }
1930
+ active = Math.min(active, shown.length - 1);
1931
+ continue;
1932
+ }
1933
+ index = picked;
1934
+ break;
1935
+ }
1936
+ } else {
1937
+ this.ui.blank();
1938
+ this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
1939
+ index = await this.ui.choose(
1940
+ 'continue which?',
1941
+ shown.map((s, i) => this.describeSession(s, width, i).label)
1942
+ );
1943
+ if (index === null) return;
1944
+ }
1945
+
1946
+ if (this.session.messages.length) await this.persist();
1947
+ if (await this.resume(shown[index].id)) {
1948
+ this.showHeader();
1949
+ this.replayTail();
1950
+ }
1951
+ }
1952
+
1953
+ /** /session delete 3, or 2,5,7 — numbers as the session list shows them. */
1954
+ async deleteSessions(spec) {
1955
+ const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
1956
+ const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
1957
+ const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
1958
+ if (!numbers.length || bad.length) {
1959
+ this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
1960
+ return;
1961
+ }
1962
+ for (const n of numbers) {
1963
+ const s = sessions[n - 1];
1964
+ if (s.id === this.session.id) {
1965
+ this.ui.note(`skipped ${n} — that is the conversation you are in`);
1966
+ continue;
1967
+ }
1968
+ await remove(s.id);
1969
+ this.ui.note(`deleted ${n} · ${s.title}`);
1970
+ }
1971
+ }
1972
+
1973
+ async resume(id) {
1974
+ try {
1975
+ const loaded = await load(id);
1976
+ this.session = loaded;
1977
+ this.working = [...loaded.messages];
1978
+ this.loaded = new Set(loaded.messages.filter((m) => m.skill).map((m) => m.skill));
1979
+ if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
1980
+ return true;
1981
+ } catch (err) {
1982
+ this.ui.error(err, { debug: this.debug });
1983
+ this.ui.note('Starting a fresh one instead.');
1984
+ return false;
1985
+ }
1986
+ }
1987
+
1988
+ /** The last few exchanges, so a resumed conversation has visible context. */
1989
+ replayTail(count = 4) {
1990
+ const tail = this.session.messages
1991
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
1992
+ .slice(-count);
1993
+
1994
+ for (const m of tail) {
1995
+ if (m.role !== 'user') this.ui.assistant(m.content);
1996
+ else if (this.ui.userMessage) this.ui.userMessage(m.content);
1997
+ else this.ui.write(`${blue('›')} ${dim(m.content.split('\n')[0])}`);
1998
+ }
1999
+ if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
2000
+ }
2001
+
2002
+ /** Add a line to this project's UCODE.md, read at the start of every turn. */
2003
+ async cmdRemember(note) {
2004
+ if (!note) {
2005
+ this.ui.note(`usage: /remember <something ucode should always know here> — saved to ${MEMORY_FILE}`);
2006
+ return;
2007
+ }
2008
+ try {
2009
+ const file = await remember(this.cwd, note);
2010
+ this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
2011
+ } catch (err) {
2012
+ this.ui.error(new Failure({
2013
+ kind: 'memory_unwritable',
2014
+ attempted: `saving to ${MEMORY_FILE}`,
2015
+ failed: err.message,
2016
+ fix: 'Check that this folder is writable.',
2017
+ }), { debug: this.debug });
2018
+ }
2019
+ }
2020
+
2021
+ async cmdNew() {
2022
+ if (this.session.messages.length) {
2023
+ await this.persist();
2024
+ this.ui.note(`saved · ${this.session.title}`);
2025
+ }
2026
+ this.session = newSession(this.cwd, model());
2027
+ this.working = [];
2028
+ this.loaded = new Set();
2029
+ this.showHeader();
2030
+ }
2031
+
2032
+ async cmdSkills() {
2033
+ // Re-read from disk. Skills load once at startup, so one written during
2034
+ // this session would otherwise stay invisible — and load_skill would fail
2035
+ // on a name the user can see in the folder.
2036
+ this.skills = await loadSkills({ cwd: this.cwd });
2037
+ for (const problem of this.skills.problems ?? []) {
2038
+ this.ui.write(theme.warn(` skill not loaded: ${problem}`));
2039
+ }
2040
+
2041
+ if (!this.skills.length) {
2042
+ this.ui.note('no skills found — add a folder with a SKILL.md under .ucode/skills');
2043
+ return;
2044
+ }
2045
+
2046
+ this.ui.blank();
2047
+ for (const s of this.skills) {
2048
+ const live = this.loaded.has(s.name);
2049
+ const auto = s.triggers.length ? dim(' · loads itself') : '';
2050
+ this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
2051
+ this.ui.write(` ${dim(s.description)}`);
2052
+ }
2053
+ this.ui.blank();
2054
+ this.ui.write(dim(' ● already loaded here · ucode pulls one in when the task matches'));
2055
+ this.ui.blank();
2056
+ }
2057
+
2058
+ async cmdSearch(query) {
2059
+ if (!query) {
2060
+ this.ui.note('usage: /search <what you want to look up>');
2061
+ return;
2062
+ }
2063
+ await this.turn(
2064
+ `Search the web for: ${query}\n\nUse web_search, then summarise what you found and cite the URLs.`
2065
+ );
2066
+ }
2067
+
2068
+ /** Copy the last reply. Every platform ships a clipboard pipe. */
2069
+ async cmdCopy() {
2070
+ const last = [...this.session.messages]
2071
+ .reverse()
2072
+ .find((m) => m.role === 'assistant' && m.content?.trim());
2073
+
2074
+ if (!last) {
2075
+ this.ui.note('nothing to copy yet');
2076
+ return;
2077
+ }
2078
+
2079
+ const tool = process.platform === 'win32' ? 'clip'
2080
+ : process.platform === 'darwin' ? 'pbcopy'
2081
+ : 'xclip -selection clipboard';
2082
+
2083
+ try {
2084
+ await new Promise((resolve, reject) => {
2085
+ const child = spawn(tool, { shell: true, windowsHide: true });
2086
+ child.on('error', reject);
2087
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
2088
+ child.stdin.end(last.content);
2089
+ });
2090
+ const lines = last.content.split('\n').length;
2091
+ this.ui.note(`copied ${lines} line${lines === 1 ? '' : 's'}`);
2092
+ } catch (err) {
2093
+ this.ui.error(new Failure({
2094
+ kind: 'clipboard_failed',
2095
+ attempted: 'copying the last reply',
2096
+ failed: `${tool} could not run: ${err.message}`,
2097
+ fix: process.platform === 'linux'
2098
+ ? 'Install xclip (apt install xclip), or select the text with the mouse.'
2099
+ : 'Select the text with the mouse instead.',
2100
+ }), { debug: this.debug });
2101
+ }
2102
+ }
2103
+ }
2104
+
2105
+ export { DEFAULT_MODEL, PROVIDER };