ucode-agent 1.63.0 → 1.64.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
@@ -29,10 +29,11 @@ import { spawn } from 'node:child_process';
29
29
  import {
30
30
  ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
31
31
  estimateConversation, MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor, backupFor, transcribe,
32
+ requestCount, customProvider,
32
33
  } from './provider.js';
33
34
  import {
34
35
  tools, runTool, describe, setRoot, setConfirm, setRequest,
35
- PARALLEL_SAFE, WRITES, FILE_WRITES,
36
+ PARALLEL_SAFE, WRITES, FILE_WRITES, MUTATING,
36
37
  } from '../tools/index.js';
37
38
  import { projectMap, loadMemory, hasCode, remember, MEMORY_FILE } from './context.js';
38
39
  import { autoUpdate } from './updater.js';
@@ -52,13 +53,20 @@ import { serversReadySince } from '../tools/shell.js';
52
53
  import { formatDuration } from '../ui/activity.js';
53
54
  import { MAX_FILE_OUTPUT } from '../tools/shared.js';
54
55
  import { openInBrowser } from './opener.js';
55
- import { withScope, isNoise } from './scope.js';
56
+ import { withScope, isNoise, asksToBuild, asksToChange } from './scope.js';
56
57
  import { chooseFile, projectFiles, loadAttachments } from './attach.js';
57
58
  import { startRecording, isSilent, cleanTranscript, MAX_RECORD_MS } from './voice.js';
58
59
  import { runDoctor } from './doctor.js';
59
60
  import { JS_LOGIC } from './jslogic.js';
60
61
  import { deploy } from '../tools/deploy.js';
61
62
  import { normaliseFiles } from '../tools/scaffold.js';
63
+ import { setPolicy, allowedNow, confirm } from '../tools/shared.js';
64
+ import { genericLook, genericMessage } from './genericcheck.js';
65
+ import { noteMistakes, lessonsText } from './lessons.js';
66
+ import { Snapshots } from './snapshot.js';
67
+ import { loadSettings, addAllow, isTrusted, trust, runHook } from './settings.js';
68
+ import { McpHub, readServers, USER_MCP, projectMcpFile } from './mcp.js';
69
+ import { loadCommands, expandCommand } from './commands.js';
62
70
 
63
71
  /**
64
72
  * Tool calls allowed in one turn.
@@ -124,7 +132,10 @@ const LOOKUP_NUDGE = 5;
124
132
  /** A file with a page in it — something a browser can be pointed at. */
125
133
  const PAGE = /\.(?:html?|tsx|jsx)$/i;
126
134
 
127
- const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?)$/i;
135
+ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?|go|rs)$/i;
136
+
137
+ /** The model that is asked, with the user's say-so, when Flash-Lite cannot fix something. */
138
+ const STRONGER = 'gemini-3.5-flash';
128
139
 
129
140
  /** Where TypeScript keeps what it learned, so the next check is a quick one. */
130
141
  export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
@@ -338,7 +349,7 @@ function trace(event) {
338
349
  function newStats() {
339
350
  return {
340
351
  started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
341
- tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
352
+ tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0, requests: 0,
342
353
  };
343
354
  }
344
355
 
@@ -368,6 +379,7 @@ export function statsLines(st, messages) {
368
379
  const rows = [
369
380
  ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
370
381
  ['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
382
+ ...(st.requests ? [['Requests', `${n(st.requests)} sent — what the free daily limit counts`]] : []),
371
383
  ['Tools', top],
372
384
  ['Files', `${st.written} written · ${st.edited} edited`],
373
385
  ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
@@ -576,7 +588,7 @@ const loadSkillTool = {
576
588
  },
577
589
  };
578
590
 
579
- function systemPrompt({ cwd, skills, mode, check, map, memory }) {
591
+ function systemPrompt({ cwd, skills, mode, check, map, memory, lessons }) {
580
592
  const list = catalogue(skills);
581
593
 
582
594
  return [
@@ -592,6 +604,14 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
592
604
  '',
593
605
  memory,
594
606
  ] : []),
607
+ ...(lessons ? [
608
+ '',
609
+ '## Mistakes ucode has caught in earlier builds',
610
+ '',
611
+ 'These keep coming up. Avoid them from the start - each one caught later costs a fix round.',
612
+ '',
613
+ lessons,
614
+ ] : []),
595
615
  '',
596
616
  '## How to work',
597
617
  '',
@@ -599,6 +619,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
599
619
  'A greeting, a question, a single word, or anything unclear gets a short plain',
600
620
  'answer - or one question back about what they would like - never an app. "What',
601
621
  'can you do?" is answered in words; "make a quiz app" is built.',
622
+ 'A question about this project or its code is answered from the files, never from a guess:',
623
+ 'read the README and the files involved first (one read_files), then answer.',
602
624
  '',
603
625
  'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
604
626
  'what you built as someone using it for the first time: is the button inside its',
@@ -866,6 +888,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
866
888
  '- Point at code as path:line so the user can jump straight to it.',
867
889
  '- Report honestly. If a command failed or you skipped something, say so.',
868
890
  '- Length tracks the question: a one-line question gets a one-line answer.',
891
+ '- Never call your work sleek, seamless, modern, intuitive, robust or stunning. Those',
892
+ ' words are said about every app; say what this one does instead.',
869
893
  '- Brevity is about your prose and never about your work. What you build is',
870
894
  ' finished: every control wired, every state handled, no TODO left behind.',
871
895
  ...(list ? [
@@ -1012,6 +1036,82 @@ export class Agent {
1012
1036
  });
1013
1037
  this.skills = await loadSkills({ cwd: this.cwd });
1014
1038
  await this.detectCheck();
1039
+ await this.loadSettings();
1040
+ this.snaps = new Snapshots(this.cwd);
1041
+ this.undoStack = [];
1042
+ this.commands = await loadCommands(this.cwd);
1043
+ }
1044
+
1045
+ /** settings.json, both of them: what runs without asking, and the hooks. */
1046
+ async loadSettings() {
1047
+ this.settings = await loadSettings(this.cwd);
1048
+ setPolicy({
1049
+ askCommands: this.settings.commands === 'ask',
1050
+ allow: this.settings.allow,
1051
+ onAlways: (entry) => addAllow(this.cwd, entry),
1052
+ });
1053
+ }
1054
+
1055
+ /**
1056
+ * MCP servers: yours start at once, in the background, so the first prompt
1057
+ * never waits on one; this project's wait until you have approved them, the
1058
+ * first time a turn runs (see trustProject). Never throws.
1059
+ */
1060
+ startMcp({ project = false } = {}) {
1061
+ const run = async () => {
1062
+ const specs = project
1063
+ ? await readServers(projectMcpFile(this.cwd))
1064
+ : await readServers(USER_MCP);
1065
+ const fresh = specs.filter((s) => !this.mcp?.servers.has(s.name));
1066
+ if (!fresh.length) return;
1067
+ this.mcp ??= new McpHub();
1068
+ const status = await this.mcp.start(fresh, { cwd: this.cwd });
1069
+ for (const s of status.filter((x) => fresh.some((f) => f.name === x.name))) {
1070
+ this.ui.note(s.ok
1071
+ ? `MCP: ${s.name} connected · ${s.tools} tool${s.tools === 1 ? '' : 's'}`
1072
+ : `MCP: ${s.name} did not start — ${s.error}`);
1073
+ }
1074
+ };
1075
+ const started = run().catch((err) => this.ui.note(`MCP: ${err.message}`));
1076
+ this.mcpStarting = Promise.all([this.mcpStarting, started]);
1077
+ return started;
1078
+ }
1079
+
1080
+ /**
1081
+ * A project's own hooks and MCP servers run commands on this computer, so
1082
+ * they wait for a yes - once, and again whenever they change.
1083
+ */
1084
+ async trustProject() {
1085
+ if (this.trustChecked) return;
1086
+ this.trustChecked = true;
1087
+ const hooks = this.settings?.projectHooks ?? { afterEdit: [], beforeCommand: [] };
1088
+ const servers = await readServers(projectMcpFile(this.cwd));
1089
+ const wanted = { hooks, servers };
1090
+ const asks = [
1091
+ ...hooks.afterEdit.map((h) => `after each edit: ${h}`),
1092
+ ...hooks.beforeCommand.map((h) => `before each command: ${h}`),
1093
+ ...servers.map((s) => `MCP server ${s.name}: ${s.url ?? [s.command, ...(s.args ?? [])].join(' ')}`),
1094
+ ];
1095
+ if (!asks.length) return;
1096
+ let yes = await isTrusted(this.cwd, 'project', wanted);
1097
+ if (!yes) {
1098
+ yes = await Promise.resolve(this.ui.confirm({
1099
+ action: 'this project wants to run its own commands',
1100
+ detail: `${asks.join('\n')}\nFrom .ucode/ in this folder. Only say yes if you trust where it came from.`,
1101
+ risk: 'trust',
1102
+ })).catch(() => false);
1103
+ if (yes) await trust(this.cwd, 'project', wanted).catch(() => {});
1104
+ }
1105
+ this.projectTrusted = Boolean(yes);
1106
+ if (yes && servers.length) this.startMcp({ project: true });
1107
+ }
1108
+
1109
+ /** The hooks that may run: yours, and the project's once approved. */
1110
+ hooks(kind) {
1111
+ return [
1112
+ ...(this.settings?.hooks?.[kind] ?? []),
1113
+ ...(this.projectTrusted ? this.settings?.projectHooks?.[kind] ?? [] : []),
1114
+ ];
1015
1115
  }
1016
1116
 
1017
1117
  async start() {
@@ -1038,6 +1138,7 @@ export class Agent {
1038
1138
 
1039
1139
  this.showHeader();
1040
1140
  this.installSignals();
1141
+ this.startMcp();
1041
1142
 
1042
1143
  // Checked in the background; nothing here waits on it.
1043
1144
  autoUpdate({
@@ -1168,6 +1269,7 @@ export class Agent {
1168
1269
 
1169
1270
  async shutdown() {
1170
1271
  await closeBrowser().catch(() => {});
1272
+ this.mcp?.close();
1171
1273
  this.ui.stopSpinner();
1172
1274
  if (this.session.messages.length) {
1173
1275
  await this.persist();
@@ -1247,6 +1349,20 @@ export class Agent {
1247
1349
  this.declines = 0;
1248
1350
  this.apps = [];
1249
1351
  this.wrote = new Map();
1352
+ this.genericChecked = false;
1353
+ this.escalated = false;
1354
+ this.lastProblems = null;
1355
+ this.planItems = [];
1356
+ this.askedAboutPlan = false;
1357
+ this.mutated = false;
1358
+ this.building = asksToBuild(input);
1359
+ this.changing = asksToChange(input);
1360
+ const sentBefore = requestCount();
1361
+ // The whole project as it is now, for /undo — taken while the model
1362
+ // thinks, so nothing waits on it; the first write waits for it (dispatch).
1363
+ this.snapshotting = (this.snaps?.take(`before: ${titleFrom(input)}`) ?? Promise.resolve(null))
1364
+ .then((id) => (id ? { id, input } : null), () => null);
1365
+ if (this.snaps) await this.trustProject().catch(() => {});
1250
1366
  forgetReviews(); // a new request: its apps get a fresh design review
1251
1367
  setRequest(input); // create_app checks this before choosing a starter
1252
1368
  const images = await this.attachImages(input);
@@ -1268,14 +1384,15 @@ export class Agent {
1268
1384
  this.session.title = titleFrom(input);
1269
1385
  }
1270
1386
  // What the model is told about the project, fresh for this turn.
1271
- [this.map, this.memory] = await Promise.all([
1387
+ [this.map, this.memory, this.lessons] = await Promise.all([
1272
1388
  projectMap(this.cwd).catch(() => ''),
1273
1389
  loadMemory(this.cwd).catch(() => ''),
1390
+ lessonsText().catch(() => ''),
1274
1391
  ]);
1275
1392
  // Nothing to look up in an empty folder, so those tools do not go out with
1276
1393
  // the request. Decided per turn: the moment there is code, they are back.
1277
1394
  this.fresh = !hasCode(this.map);
1278
- request.content = withScope(input) + added.text;
1395
+ request.content = withScope(input, { hasCode: !this.fresh }) + added.text;
1279
1396
  this.wantsWeb = WANTS_WEB.test(input);
1280
1397
  await this.persist();
1281
1398
 
@@ -1315,6 +1432,10 @@ export class Agent {
1315
1432
  trace({ kind: 'turn', ms: Date.now() - turnStarted });
1316
1433
  this.stats.workMs += Date.now() - turnStarted;
1317
1434
  this.stats.turns++;
1435
+ this.stats.requests += requestCount() - sentBefore;
1436
+ // A turn that changed nothing has nothing for /undo to take back.
1437
+ const before = await this.snapshotting;
1438
+ if (before && this.mutated) this.undoStack.push(before);
1318
1439
  if (!finished) this.closeInterrupted();
1319
1440
  const ok = finished && !this.endedSilently;
1320
1441
  this.busy = false;
@@ -1346,10 +1467,59 @@ export class Agent {
1346
1467
  const live = this.fresh
1347
1468
  ? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
1348
1469
  : all;
1349
- if (this.ui.mode !== 'plan') return live;
1470
+ // MCP tools can do anything their server does, so a read-only turn gets none.
1471
+ if (this.ui.mode !== 'plan' && !this.readOnly) return [...live, ...(this.mcp?.tools() ?? [])];
1350
1472
  return live.filter((t) => !WRITES.has(t.name));
1351
1473
  }
1352
1474
 
1475
+ /**
1476
+ * How hard the model thinks on this step (see provider.js for the numbers).
1477
+ * The first step of a build or a change is where the plan and the design
1478
+ * get decided, so it gets more; a fix that has already failed once gets the
1479
+ * most; everything else gets the cheap level that is still real thinking.
1480
+ */
1481
+ effortFor(step, fixRounds) {
1482
+ if (this.fixing && fixRounds >= 2) return 'high';
1483
+ if (step === 0 && (this.building || this.changing)) return 'medium';
1484
+ return 'low';
1485
+ }
1486
+
1487
+ /**
1488
+ * Everything that goes with sending problems back to the model: counting
1489
+ * them for next time, noticing when the last fix did not work, and — once,
1490
+ * with the user's say-so — moving a stuck Flash-Lite to the stronger model.
1491
+ * Returns text to add to the fix-round message.
1492
+ */
1493
+ async beforeFixRound(problems, round) {
1494
+ noteMistakes(problems).catch(() => {});
1495
+
1496
+ const keys = new Set(String(problems).split('\n').map((l) => l.trim()).filter((l) => l.length > 12));
1497
+ const repeated = this.lastProblems && [...keys].some((k) => this.lastProblems.has(k));
1498
+ this.lastProblems = keys;
1499
+ let extra = repeated
1500
+ ? '\n\nThe same problem came back after your last fix, so that fix did not work. Do not repeat ' +
1501
+ 'it: read the failing code again and take a different approach.'
1502
+ : '';
1503
+
1504
+ // Only when a fix has already failed: a new problem is not being stuck.
1505
+ if (repeated && round >= 2 && !this.escalated && /flash-lite/.test(model()) && MODELS[STRONGER] && !customProvider()) {
1506
+ this.escalated = true;
1507
+ const yes = await Promise.resolve(this.ui.confirm({
1508
+ action: `${modelName()} is stuck on this. Use ${modelName(STRONGER)} for the rest of this job?`,
1509
+ detail: `${modelName(STRONGER)} is smarter, but has only about 20 free requests a day.`,
1510
+ risk: 'model',
1511
+ })).catch(() => false);
1512
+ if (yes) {
1513
+ setModel(STRONGER);
1514
+ this.cooldownUntil = 0; // the next turn goes back to the model you chose
1515
+ this.ui.note(`switched to ${modelName(STRONGER)} for this job`);
1516
+ if (this.full) this.showHeader({ clear: false });
1517
+ extra += '\n\nA stronger model has taken over for this fix.';
1518
+ }
1519
+ }
1520
+ return extra;
1521
+ }
1522
+
1353
1523
  /** Model, tools, model, until it answers with prose. */
1354
1524
  async run() {
1355
1525
  const available = this.toolsNow();
@@ -1381,6 +1551,7 @@ export class Agent {
1381
1551
  const opts = {
1382
1552
  signal: this.abort.signal,
1383
1553
  onWait: (text) => this.ui.updateSpinner(text),
1554
+ effort: this.effortFor(step, fixRounds),
1384
1555
  };
1385
1556
  // Only a real terminal has somewhere to stream into.
1386
1557
  if (this.full) {
@@ -1421,10 +1592,11 @@ export class Agent {
1421
1592
  content: systemPrompt({
1422
1593
  cwd: this.cwd,
1423
1594
  skills: this.skills,
1424
- mode: this.ui.mode,
1595
+ mode: this.readOnly ? 'plan' : this.ui.mode,
1425
1596
  check: this.check,
1426
1597
  map: this.map,
1427
1598
  memory: this.memory,
1599
+ lessons: this.lessons,
1428
1600
  }),
1429
1601
  },
1430
1602
  ...dedupe(lean(this.working)),
@@ -1532,6 +1704,7 @@ export class Agent {
1532
1704
  if (problems) {
1533
1705
  fixRounds++;
1534
1706
  this.fixing = true;
1707
+ const extra = await this.beforeFixRound(problems, fixRounds);
1535
1708
  if (reply.text) this.push({ role: 'assistant', content: reply.text });
1536
1709
  this.push({
1537
1710
  role: 'user',
@@ -1539,12 +1712,29 @@ export class Agent {
1539
1712
  `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
1540
1713
  `${MAX_FIX_ROUNDS}). Fix all of them with the smallest edits that do it, then ` +
1541
1714
  'finish. Change nothing else - no restyling, no rewrites of code that works.' +
1542
- `\n\n${problems}`,
1715
+ `\n\n${problems}${extra}`,
1543
1716
  });
1544
1717
  continue;
1545
1718
  }
1546
1719
  }
1547
1720
 
1721
+ // The plan it wrote, checked against what it ticked. Items left open
1722
+ // at the end are the commonest way part of a request goes missing.
1723
+ // Once a turn, and only when the turn actually changed something.
1724
+ const open = (this.planItems ?? []).filter((i) => !i.done).map((i) => String(i.text).trim());
1725
+ if (open.length && this.touched.size && !this.askedAboutPlan) {
1726
+ this.askedAboutPlan = true;
1727
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
1728
+ this.push({
1729
+ role: 'user',
1730
+ content:
1731
+ `Your plan still has ${open.length === 1 ? 'this item' : 'these items'} open: ${open.join('; ')}. ` +
1732
+ 'Finish them now. If they are already done, tick them with update_plan and finish; if one ' +
1733
+ 'cannot be done, say which and why.',
1734
+ });
1735
+ continue;
1736
+ }
1737
+
1548
1738
  // It changed code and never ran anything. Send it back once — but
1549
1739
  // only if this project's check would actually exercise what changed.
1550
1740
  // A page and a stylesheet in a repo that happens to have `npm test`
@@ -1618,12 +1808,13 @@ export class Agent {
1618
1808
  if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
1619
1809
  fixRounds++;
1620
1810
  this.fixing = true;
1811
+ const extra = await this.beforeFixRound(handed.problems, fixRounds);
1621
1812
  this.push({
1622
1813
  role: 'user',
1623
1814
  content:
1624
1815
  `ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
1625
1816
  'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
1626
- `ucode hands it to the user.\n\n${handed.problems}`,
1817
+ `ucode hands it to the user.\n\n${handed.problems}${extra}`,
1627
1818
  });
1628
1819
  await this.persist();
1629
1820
  continue;
@@ -2256,12 +2447,85 @@ export class Agent {
2256
2447
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
2257
2448
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
2258
2449
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
2450
+ if (this.mcp?.has(call.name)) return this.callMcp(call);
2451
+
2452
+ // The snapshot /undo goes back to has to be on disk before anything changes.
2453
+ if (MUTATING.has(call.name)) {
2454
+ this.mutated = true;
2455
+ await this.snapshotting;
2456
+ }
2457
+ if (call.name === 'run_command' || call.name === 'run_commands') await this.beforeCommand(call);
2259
2458
 
2260
2459
  // Output reaches the screen as the command produces it, so a slow build is
2261
2460
  // something you watch rather than something you sit out in silence.
2262
- return runTool(call.name, call.args ?? {}, {
2461
+ const out = await runTool(call.name, call.args ?? {}, {
2263
2462
  onOutput: (lines) => this.ui.progress(lines),
2264
2463
  });
2464
+ if (FILE_WRITES.has(call.name)) await this.afterEdit(call, out);
2465
+ return out;
2466
+ }
2467
+
2468
+ /** A tool from an MCP server: asked about first, unless always allowed. */
2469
+ async callMcp(call) {
2470
+ const subject = `mcp:${call.name}`;
2471
+ if (!allowedNow(subject)) {
2472
+ await confirm(`use ${call.name}`, clip(JSON.stringify(call.args ?? {}), 300), 'mcp', subject);
2473
+ }
2474
+ let answer;
2475
+ try {
2476
+ answer = await this.mcp.call(call.name, call.args ?? {});
2477
+ } catch (err) {
2478
+ throw new ToolFailure({
2479
+ kind: 'mcp_failed',
2480
+ attempted: `calling ${call.name}`,
2481
+ failed: err.message,
2482
+ fix: 'Try once more if it looks temporary; otherwise carry on without it and say so.',
2483
+ });
2484
+ }
2485
+ if (answer.isError) {
2486
+ throw new ToolFailure({
2487
+ kind: 'tool_error',
2488
+ attempted: `calling ${call.name}`,
2489
+ failed: clip(answer.text, 2000),
2490
+ fix: 'Correct the arguments and call it again, or carry on without it.',
2491
+ });
2492
+ }
2493
+ return { content: answer.text, summary: `${this.mcp.serverOf(call.name)} answered` };
2494
+ }
2495
+
2496
+ /** beforeCommand hooks: any that exits non-zero stops the command. */
2497
+ async beforeCommand(call) {
2498
+ const hooks = this.hooks('beforeCommand');
2499
+ if (!hooks.length) return;
2500
+ const commands = call.name === 'run_commands'
2501
+ ? (Array.isArray(call.args?.commands) ? call.args.commands : []).map((c) => c?.command)
2502
+ : [call.args?.command];
2503
+ for (const command of commands.filter(Boolean)) {
2504
+ for (const hook of hooks) {
2505
+ const { code, output } = await runHook(hook, { cwd: this.cwd, env: { UCODE_COMMAND: String(command) } });
2506
+ if (code !== 0) {
2507
+ throw new ToolFailure({
2508
+ kind: 'blocked_by_hook',
2509
+ attempted: `running ${clip(String(command), 80)}`,
2510
+ failed: `A beforeCommand hook stopped it (${hook}): ${clip(output || `exit ${code}`, 500)}`,
2511
+ fix: 'Do it another way, or ask the user to change the hook in .ucode/settings.json.',
2512
+ });
2513
+ }
2514
+ }
2515
+ }
2516
+ }
2517
+
2518
+ /** afterEdit hooks (a formatter, say) on the files a write just changed. */
2519
+ async afterEdit(call, out) {
2520
+ const hooks = this.hooks('afterEdit');
2521
+ const files = pathsOf(call);
2522
+ if (!hooks.length || !files.length) return;
2523
+ for (const hook of hooks) {
2524
+ const { code, output } = await runHook(hook, { cwd: this.cwd, files });
2525
+ if (code !== 0 && out && typeof out.content === 'string') {
2526
+ out.content += `\n\n(The afterEdit hook \`${hook}\` failed: ${clip(output || `exit ${code}`, 400)})`;
2527
+ }
2528
+ }
2265
2529
  }
2266
2530
 
2267
2531
  /**
@@ -2320,6 +2584,7 @@ export class Agent {
2320
2584
  const list = (Array.isArray(items) ? items : [])
2321
2585
  .filter((i) => i && String(i.text ?? '').trim())
2322
2586
  .slice(0, 6);
2587
+ this.planItems = list;
2323
2588
  this.ui.plan(list);
2324
2589
  const done = list.filter((i) => i.done).length;
2325
2590
  return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
@@ -2393,7 +2658,7 @@ export class Agent {
2393
2658
  { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
2394
2659
  { role: 'user', content: String(task.instructions) },
2395
2660
  ];
2396
- const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
2661
+ const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name) && !this.mcp?.has(t.name));
2397
2662
  const offered = new Set(available.map((t) => t.name));
2398
2663
  const wanted = process.env.UCODE_WORKER_MODEL;
2399
2664
  let workerModel = wanted && MODELS[wanted] ? wanted : model();
@@ -2416,7 +2681,7 @@ export class Agent {
2416
2681
  // again costs far less than carrying the whole history.
2417
2682
  thinWorker(messages, contextLimit(workerModel) * 0.6);
2418
2683
  try {
2419
- reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
2684
+ reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel, effort: 'low' });
2420
2685
  } catch (err) {
2421
2686
  // Same rule as the lead: a busy model is swapped, not a reason to stop.
2422
2687
  if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
@@ -2664,6 +2929,13 @@ export class Agent {
2664
2929
  if (failed) problems.push(failed);
2665
2930
  }
2666
2931
 
2932
+ // A Next.js app is not handed over by handOver, so its look is checked here.
2933
+ const app = this.apps?.at(-1);
2934
+ if (app && this.appTemplate === 'next-shadcn') {
2935
+ const generic = await this.genericOnce(app);
2936
+ if (generic) problems.push(generic);
2937
+ }
2938
+
2667
2939
  const live = await this.liveErrors();
2668
2940
  if (live) problems.push(live);
2669
2941
 
@@ -2769,6 +3041,15 @@ export class Agent {
2769
3041
  return { problems: `I opened the app and looked at it:\n\n${content}` };
2770
3042
  }
2771
3043
 
3044
+ // It works; now whether it looks like every other generated app. Once a
3045
+ // turn, milliseconds of reading files, and a fix round only on a hit.
3046
+ const generic = await this.genericOnce(app);
3047
+ if (generic) {
3048
+ this.handOverPending = true;
3049
+ this.ui.runStat?.('looks generic');
3050
+ return { problems: generic };
3051
+ }
3052
+
2772
3053
  this.handOverPending = false;
2773
3054
  // "Tip Calculator is done", with the page as a link the terminal can open.
2774
3055
  const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
@@ -2787,6 +3068,14 @@ export class Agent {
2787
3068
  return { done: true };
2788
3069
  }
2789
3070
 
3071
+ /** The generated-look check (genericcheck.js) on the app just built, once a turn. */
3072
+ async genericOnce(app) {
3073
+ if (this.genericChecked || process.env.UCODE_DESIGN_CHECK === '0') return null;
3074
+ this.genericChecked = true;
3075
+ const found = await genericLook(app, this.appTemplate ?? 'plain-html').catch(() => []);
3076
+ return found.length ? genericMessage(found) : null;
3077
+ }
3078
+
2790
3079
  async lookOnceThisTurn(root, changed) {
2791
3080
  // A look that found problems is taken again after the fix round, even if
2792
3081
  // the fix only touched the page's script. Looking once a turn let a live
@@ -3012,22 +3301,146 @@ export class Agent {
3012
3301
  case '/search': return this.cmdSearch(arg);
3013
3302
  case '/copy': return this.cmdCopy();
3014
3303
  case '/stats': return this.cmdStats();
3015
- case '/undo': return this.cmdUndo();
3304
+ case '/undo': return this.cmdUndo(arg);
3016
3305
  case '/doctor': return this.cmdDoctor();
3017
3306
  case '/look': return this.cmdLook(arg);
3018
3307
  case '/deploy': return this.cmdDeploy(arg);
3308
+ case '/init': return this.cmdInit();
3309
+ case '/diff': return this.cmdDiff();
3310
+ case '/commit': return this.cmdCommit(arg);
3311
+ case '/review': return this.cmdReview();
3312
+ case '/mcp': return this.cmdMcp();
3313
+ case '/permissions': return this.cmdPermissions(arg);
3019
3314
  case '/mic':
3020
3315
  if (this.ui.onMic) return this.mic('toggle');
3021
3316
  return this.ui.note('the mic works in the full-screen view — start ucode in a terminal window');
3022
3317
  case '/exit':
3023
3318
  case '/quit': return 'exit';
3024
3319
 
3025
- default:
3320
+ default: {
3321
+ // One of the user's own, from .ucode/commands/<name>.md.
3322
+ this.commands = await loadCommands(this.cwd);
3323
+ const own = this.commands.get(name.slice(1).toLowerCase());
3324
+ if (own) return this.turn(expandCommand(own.body, arg));
3026
3325
  this.ui.write(theme.warn(` no such command: ${name}`));
3027
3326
  this.ui.note('/help lists them.');
3327
+ }
3028
3328
  }
3029
3329
  }
3030
3330
 
3331
+ /** Run git in the project; resolves to { code, out }. */
3332
+ git(args) {
3333
+ return new Promise((resolve) => {
3334
+ let out = '';
3335
+ const child = spawn('git', args, { cwd: this.cwd, windowsHide: true });
3336
+ child.stdout.on('data', (d) => { out += d; });
3337
+ child.stderr.on('data', (d) => { out += d; });
3338
+ child.on('error', (err) => resolve({ code: -1, out: err.message }));
3339
+ child.on('close', (code) => resolve({ code, out: out.trim() }));
3340
+ });
3341
+ }
3342
+
3343
+ /** /init: read the project and write what ucode should know into UCODE.md. */
3344
+ cmdInit() {
3345
+ return this.turn(
3346
+ `Read this project - its package files, README, config and the main source folders - and write ${MEMORY_FILE} ` +
3347
+ 'in the project root for a coding agent that has never seen it: what it is, the stack, the commands to ' +
3348
+ 'install, run, test and build, the folder layout, and the conventions the code follows. Under 60 lines, ' +
3349
+ `facts only. If ${MEMORY_FILE} exists, update it and keep anything the user wrote. Do not change any other file.`,
3350
+ );
3351
+ }
3352
+
3353
+ /** /diff: what changed — since the session started, or uncommitted in git. */
3354
+ async cmdDiff() {
3355
+ const first = this.undoStack?.[0]?.id;
3356
+ if (first && (await this.snaps?.ready())) {
3357
+ const changes = await this.snaps.changedSince(first);
3358
+ if (!changes.length) { this.ui.note('nothing has changed this session'); return; }
3359
+ const word = { A: 'added', M: 'changed', D: 'deleted' };
3360
+ this.ui.blank();
3361
+ for (const c of changes) this.ui.write(` ${dim((word[c.status] ?? c.status).padEnd(8))} ${c.file}`);
3362
+ this.ui.blank();
3363
+ return;
3364
+ }
3365
+ const { code, out } = await this.git(['status', '--short']);
3366
+ if (code !== 0) { this.ui.note('nothing to compare yet — this folder is not a git repository and no turn has changed it'); return; }
3367
+ this.ui.write(out ? out.split('\n').map((l) => ` ${l}`).join('\n') : dim(' no uncommitted changes'));
3368
+ }
3369
+
3370
+ /** /commit [message]: commit everything, with a message written from the diff when none is given. */
3371
+ async cmdCommit(message) {
3372
+ const status = await this.git(['status', '--porcelain']);
3373
+ if (status.code !== 0) { this.ui.note('this folder is not a git repository'); return; }
3374
+ if (!status.out) { this.ui.note('nothing to commit'); return; }
3375
+ let text = message;
3376
+ if (!text) {
3377
+ this.ui.startSpinner('writing a commit message');
3378
+ const diff = (await this.git(['diff', 'HEAD'])).out || status.out;
3379
+ try {
3380
+ const reply = await ask([
3381
+ { role: 'system', content: 'Write a git commit message for this diff: a subject line under 70 characters in the imperative, then a blank line and up to three short lines on why. Reply with the message only.' },
3382
+ { role: 'user', content: diff.slice(0, 30_000) },
3383
+ ], [], { effort: 'low' });
3384
+ text = reply.text.replace(/^```\w*\n?|```$/g, '').trim();
3385
+ } finally {
3386
+ this.ui.stopSpinner();
3387
+ }
3388
+ }
3389
+ if (!text) { this.ui.note('no message — /commit <message> to give one'); return; }
3390
+ this.ui.write(`\n${text.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
3391
+ const yes = await this.ui.confirm({ action: 'commit everything with this message', detail: `${status.out.split('\n').length} files`, risk: 'command' });
3392
+ if (!yes) { this.ui.note('not committed'); return; }
3393
+ await this.git(['add', '-A']);
3394
+ const done = await this.git(['commit', '-m', text]);
3395
+ this.ui.write(done.code === 0 ? ` ${theme.ok('✓')} committed` : theme.error(` ${done.out.split('\n')[0]}`));
3396
+ }
3397
+
3398
+ /** /review: read the uncommitted changes for bugs, without changing anything. */
3399
+ async cmdReview() {
3400
+ const diff = await this.git(['diff', 'HEAD']);
3401
+ if (diff.code !== 0 || !diff.out) { this.ui.note('no uncommitted changes to review (this needs git)'); return; }
3402
+ this.readOnly = true;
3403
+ try {
3404
+ await this.turn(
3405
+ 'Review these uncommitted changes for bugs: wrong logic, missed edge cases, broken error handling, ' +
3406
+ 'security holes. Read the surrounding code where you need it. List each real problem as file:line, ' +
3407
+ `what goes wrong and the fix - most serious first. If there are none, say so in one line.\n\n\`\`\`diff\n${diff.out.slice(0, 40_000)}\n\`\`\``,
3408
+ );
3409
+ } finally {
3410
+ this.readOnly = false;
3411
+ }
3412
+ }
3413
+
3414
+ /** /mcp: which servers are connected, and how to add one. */
3415
+ async cmdMcp() {
3416
+ await this.mcpStarting;
3417
+ const status = this.mcp?.status() ?? [];
3418
+ this.ui.blank();
3419
+ if (!status.length) this.ui.write(dim(' no MCP servers yet'));
3420
+ for (const s of status) {
3421
+ this.ui.write(` ${s.ok ? theme.ok('●') : theme.error('●')} ${blue(s.name)} ${dim(s.ok ? `${s.tools} tools` : s.error)}`);
3422
+ }
3423
+ this.ui.blank();
3424
+ this.ui.write(dim(' add one: ucode mcp add <name> <command> [args...] or ucode mcp add <name> --url <url>'));
3425
+ this.ui.write(dim(` or edit ${USER_MCP} (every project) or .ucode/mcp.json (this one)`));
3426
+ this.ui.blank();
3427
+ }
3428
+
3429
+ /** /permissions [ask|auto]: whether commands are asked about, and what is always allowed. */
3430
+ async cmdPermissions(arg) {
3431
+ const want = arg.trim().toLowerCase();
3432
+ if (want === 'ask' || want === 'auto') {
3433
+ const { projectSettingsFile, readJson, writeJson } = await import('./settings.js');
3434
+ const file = projectSettingsFile(this.cwd);
3435
+ await writeJson(file, { ...(await readJson(file)), commands: want });
3436
+ await this.loadSettings();
3437
+ }
3438
+ this.ui.blank();
3439
+ this.ui.write(` commands ${blue(this.settings.commands === 'ask' ? 'ask first' : 'run without asking')} ${dim('/permissions ask · /permissions auto')}`);
3440
+ this.ui.write(` allowed ${this.settings.allow.length ? this.settings.allow.join(' · ') : dim('nothing yet — answer "a" at a prompt to add')}`);
3441
+ this.ui.blank();
3442
+ }
3443
+
3031
3444
  /**
3032
3445
  * Look at the running app, because the user asked to.
3033
3446
  *
@@ -3066,7 +3479,21 @@ ${out.content}` });
3066
3479
  * one that just happened — and it says how many files it touched rather than
3067
3480
  * listing them, the way everything else here reports work.
3068
3481
  */
3069
- async cmdUndo() {
3482
+ async cmdUndo(arg = '') {
3483
+ // Snapshots first: they cover what commands changed, and go back further.
3484
+ const n = Math.max(1, Number.parseInt(arg, 10) || 1);
3485
+ if (this.undoStack?.length) {
3486
+ const take = Math.min(n, this.undoStack.length);
3487
+ const target = this.undoStack[this.undoStack.length - take];
3488
+ const { restored, removed, failed } = await this.snaps.restore(target.id);
3489
+ this.undoStack.splice(this.undoStack.length - take, take);
3490
+ const parts = [];
3491
+ if (restored.length) parts.push(`${restored.length} file${restored.length === 1 ? '' : 's'} put back`);
3492
+ if (removed.length) parts.push(`${removed.length} removed`);
3493
+ this.ui.write(` ${theme.ok('✓')} ${parts.join(', ') || 'nothing to do'} — back to before "${clip(target.input, 50)}"`);
3494
+ for (const f of failed) this.ui.write(theme.error(` could not undo ${f}`));
3495
+ return;
3496
+ }
3070
3497
  const count = changedCount();
3071
3498
  if (!count) {
3072
3499
  this.ui.note('nothing to undo — the last turn changed no files.');
@@ -3084,7 +3511,13 @@ ${out.content}` });
3084
3511
  cmdHelp() {
3085
3512
  const rows = [
3086
3513
  ['/help', 'this list'],
3087
- ['/undo', 'put back every file the last turn changed'],
3514
+ ['/undo [n]', 'put the project back as it was before the last turn (or n turns)'],
3515
+ ['/diff', 'what has changed this session'],
3516
+ ['/commit [msg]', 'commit the changes, with a message written for you'],
3517
+ ['/review', 'check the uncommitted changes for bugs, changing nothing'],
3518
+ ['/init', `read the project and write ${MEMORY_FILE} for it`],
3519
+ ['/mcp', 'connected MCP servers and their tools'],
3520
+ ['/permissions', 'ask before commands, or run them; what is always allowed'],
3088
3521
  ['/stats', 'time, steps and tokens this session'],
3089
3522
  ['/doctor', 'check that everything ucode needs is working'],
3090
3523
  ['/look [url]', 'open the running app and report what is on the page'],
@@ -3101,6 +3534,8 @@ ${out.content}` });
3101
3534
  ['/exit', 'save and quit'],
3102
3535
  ];
3103
3536
 
3537
+ for (const c of this.commands?.values() ?? []) rows.push([`/${c.name}`, `yours: ${c.description}`]);
3538
+
3104
3539
  this.ui.blank();
3105
3540
  for (const [command, what] of rows) {
3106
3541
  this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);