ucode-agent 1.62.8 → 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
@@ -28,11 +28,12 @@ import { spawn } from 'node:child_process';
28
28
 
29
29
  import {
30
30
  ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
31
- estimateConversation, MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor, backupFor,
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,12 +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';
58
+ import { startRecording, isSilent, cleanTranscript, MAX_RECORD_MS } from './voice.js';
57
59
  import { runDoctor } from './doctor.js';
58
60
  import { JS_LOGIC } from './jslogic.js';
59
61
  import { deploy } from '../tools/deploy.js';
60
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';
61
70
 
62
71
  /**
63
72
  * Tool calls allowed in one turn.
@@ -123,7 +132,10 @@ const LOOKUP_NUDGE = 5;
123
132
  /** A file with a page in it — something a browser can be pointed at. */
124
133
  const PAGE = /\.(?:html?|tsx|jsx)$/i;
125
134
 
126
- 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';
127
139
 
128
140
  /** Where TypeScript keeps what it learned, so the next check is a quick one. */
129
141
  export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
@@ -337,7 +349,7 @@ function trace(event) {
337
349
  function newStats() {
338
350
  return {
339
351
  started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
340
- 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,
341
353
  };
342
354
  }
343
355
 
@@ -367,6 +379,7 @@ export function statsLines(st, messages) {
367
379
  const rows = [
368
380
  ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
369
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`]] : []),
370
383
  ['Tools', top],
371
384
  ['Files', `${st.written} written · ${st.edited} edited`],
372
385
  ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
@@ -575,7 +588,7 @@ const loadSkillTool = {
575
588
  },
576
589
  };
577
590
 
578
- function systemPrompt({ cwd, skills, mode, check, map, memory }) {
591
+ function systemPrompt({ cwd, skills, mode, check, map, memory, lessons }) {
579
592
  const list = catalogue(skills);
580
593
 
581
594
  return [
@@ -591,6 +604,14 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
591
604
  '',
592
605
  memory,
593
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
+ ] : []),
594
615
  '',
595
616
  '## How to work',
596
617
  '',
@@ -598,6 +619,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
598
619
  'A greeting, a question, a single word, or anything unclear gets a short plain',
599
620
  'answer - or one question back about what they would like - never an app. "What',
600
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.',
601
624
  '',
602
625
  'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
603
626
  'what you built as someone using it for the first time: is the button inside its',
@@ -865,6 +888,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
865
888
  '- Point at code as path:line so the user can jump straight to it.',
866
889
  '- Report honestly. If a command failed or you skipped something, say so.',
867
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.',
868
893
  '- Brevity is about your prose and never about your work. What you build is',
869
894
  ' finished: every control wired, every state handled, no TODO left behind.',
870
895
  ...(list ? [
@@ -1011,6 +1036,82 @@ export class Agent {
1011
1036
  });
1012
1037
  this.skills = await loadSkills({ cwd: this.cwd });
1013
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
+ ];
1014
1115
  }
1015
1116
 
1016
1117
  async start() {
@@ -1028,6 +1129,7 @@ export class Agent {
1028
1129
  };
1029
1130
  this.ui.onModeChange = () => this.showHeader({ clear: false });
1030
1131
  this.ui.onAttach = () => { this.attachFile().catch(() => {}); };
1132
+ this.ui.onMic = (action) => { this.mic(action).catch((err) => this.ui.error(err, { debug: this.debug })); };
1031
1133
  }
1032
1134
 
1033
1135
  for (const problem of this.skills.problems ?? []) {
@@ -1036,6 +1138,7 @@ export class Agent {
1036
1138
 
1037
1139
  this.showHeader();
1038
1140
  this.installSignals();
1141
+ this.startMcp();
1039
1142
 
1040
1143
  // Checked in the background; nothing here waits on it.
1041
1144
  autoUpdate({
@@ -1166,6 +1269,7 @@ export class Agent {
1166
1269
 
1167
1270
  async shutdown() {
1168
1271
  await closeBrowser().catch(() => {});
1272
+ this.mcp?.close();
1169
1273
  this.ui.stopSpinner();
1170
1274
  if (this.session.messages.length) {
1171
1275
  await this.persist();
@@ -1245,6 +1349,20 @@ export class Agent {
1245
1349
  this.declines = 0;
1246
1350
  this.apps = [];
1247
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(() => {});
1248
1366
  forgetReviews(); // a new request: its apps get a fresh design review
1249
1367
  setRequest(input); // create_app checks this before choosing a starter
1250
1368
  const images = await this.attachImages(input);
@@ -1266,14 +1384,15 @@ export class Agent {
1266
1384
  this.session.title = titleFrom(input);
1267
1385
  }
1268
1386
  // What the model is told about the project, fresh for this turn.
1269
- [this.map, this.memory] = await Promise.all([
1387
+ [this.map, this.memory, this.lessons] = await Promise.all([
1270
1388
  projectMap(this.cwd).catch(() => ''),
1271
1389
  loadMemory(this.cwd).catch(() => ''),
1390
+ lessonsText().catch(() => ''),
1272
1391
  ]);
1273
1392
  // Nothing to look up in an empty folder, so those tools do not go out with
1274
1393
  // the request. Decided per turn: the moment there is code, they are back.
1275
1394
  this.fresh = !hasCode(this.map);
1276
- request.content = withScope(input) + added.text;
1395
+ request.content = withScope(input, { hasCode: !this.fresh }) + added.text;
1277
1396
  this.wantsWeb = WANTS_WEB.test(input);
1278
1397
  await this.persist();
1279
1398
 
@@ -1313,6 +1432,10 @@ export class Agent {
1313
1432
  trace({ kind: 'turn', ms: Date.now() - turnStarted });
1314
1433
  this.stats.workMs += Date.now() - turnStarted;
1315
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);
1316
1439
  if (!finished) this.closeInterrupted();
1317
1440
  const ok = finished && !this.endedSilently;
1318
1441
  this.busy = false;
@@ -1344,10 +1467,59 @@ export class Agent {
1344
1467
  const live = this.fresh
1345
1468
  ? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
1346
1469
  : all;
1347
- 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() ?? [])];
1348
1472
  return live.filter((t) => !WRITES.has(t.name));
1349
1473
  }
1350
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
+
1351
1523
  /** Model, tools, model, until it answers with prose. */
1352
1524
  async run() {
1353
1525
  const available = this.toolsNow();
@@ -1379,6 +1551,7 @@ export class Agent {
1379
1551
  const opts = {
1380
1552
  signal: this.abort.signal,
1381
1553
  onWait: (text) => this.ui.updateSpinner(text),
1554
+ effort: this.effortFor(step, fixRounds),
1382
1555
  };
1383
1556
  // Only a real terminal has somewhere to stream into.
1384
1557
  if (this.full) {
@@ -1419,10 +1592,11 @@ export class Agent {
1419
1592
  content: systemPrompt({
1420
1593
  cwd: this.cwd,
1421
1594
  skills: this.skills,
1422
- mode: this.ui.mode,
1595
+ mode: this.readOnly ? 'plan' : this.ui.mode,
1423
1596
  check: this.check,
1424
1597
  map: this.map,
1425
1598
  memory: this.memory,
1599
+ lessons: this.lessons,
1426
1600
  }),
1427
1601
  },
1428
1602
  ...dedupe(lean(this.working)),
@@ -1530,6 +1704,7 @@ export class Agent {
1530
1704
  if (problems) {
1531
1705
  fixRounds++;
1532
1706
  this.fixing = true;
1707
+ const extra = await this.beforeFixRound(problems, fixRounds);
1533
1708
  if (reply.text) this.push({ role: 'assistant', content: reply.text });
1534
1709
  this.push({
1535
1710
  role: 'user',
@@ -1537,12 +1712,29 @@ export class Agent {
1537
1712
  `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
1538
1713
  `${MAX_FIX_ROUNDS}). Fix all of them with the smallest edits that do it, then ` +
1539
1714
  'finish. Change nothing else - no restyling, no rewrites of code that works.' +
1540
- `\n\n${problems}`,
1715
+ `\n\n${problems}${extra}`,
1541
1716
  });
1542
1717
  continue;
1543
1718
  }
1544
1719
  }
1545
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
+
1546
1738
  // It changed code and never ran anything. Send it back once — but
1547
1739
  // only if this project's check would actually exercise what changed.
1548
1740
  // A page and a stylesheet in a repo that happens to have `npm test`
@@ -1616,12 +1808,13 @@ export class Agent {
1616
1808
  if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
1617
1809
  fixRounds++;
1618
1810
  this.fixing = true;
1811
+ const extra = await this.beforeFixRound(handed.problems, fixRounds);
1619
1812
  this.push({
1620
1813
  role: 'user',
1621
1814
  content:
1622
1815
  `ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
1623
1816
  'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
1624
- `ucode hands it to the user.\n\n${handed.problems}`,
1817
+ `ucode hands it to the user.\n\n${handed.problems}${extra}`,
1625
1818
  });
1626
1819
  await this.persist();
1627
1820
  continue;
@@ -1827,6 +2020,59 @@ export class Agent {
1827
2020
  }
1828
2021
  }
1829
2022
 
2023
+ /**
2024
+ * The mic button (ctrl+t, a click, or /mic). 'toggle' starts listening, or
2025
+ * stops and puts the words in the box; 'send' stops and sends them; 'cancel'
2026
+ * throws the recording away.
2027
+ */
2028
+ async mic(action = 'toggle') {
2029
+ const state = this.ui.listening;
2030
+ if (state === 'starting' || state === 'writing') return;
2031
+
2032
+ if (state !== 'listening') {
2033
+ if (action !== 'toggle') return;
2034
+ this.ui.setListening('starting');
2035
+ try {
2036
+ this.recording = await startRecording();
2037
+ } catch (err) {
2038
+ this.ui.setListening(null);
2039
+ this.ui.note(`mic: ${err.message}`);
2040
+ return;
2041
+ }
2042
+ this.ui.setListening('listening');
2043
+ this.micTimer = setTimeout(() => this.ui.onMic?.('toggle'), MAX_RECORD_MS);
2044
+ return;
2045
+ }
2046
+
2047
+ clearTimeout(this.micTimer);
2048
+ // Taken and the state moved on before any await, so a second press
2049
+ // while this one finishes finds nothing to stop.
2050
+ const recording = this.recording;
2051
+ this.recording = null;
2052
+ if (!recording) return;
2053
+ if (action === 'cancel') {
2054
+ this.ui.setListening(null);
2055
+ this.ui.flash('stopped listening');
2056
+ await recording.cancel();
2057
+ return;
2058
+ }
2059
+
2060
+ this.ui.setListening('writing');
2061
+ let text = '';
2062
+ try {
2063
+ const wav = await recording.stop();
2064
+ if (!wav || isSilent(wav)) {
2065
+ this.ui.note('mic: heard nothing — check the microphone is on, then speak a little louder');
2066
+ return;
2067
+ }
2068
+ text = cleanTranscript(await transcribe(wav));
2069
+ if (!text) this.ui.note('mic: could not make out any words — try again, closer to the microphone');
2070
+ } finally {
2071
+ this.ui.setListening(null);
2072
+ }
2073
+ if (text) this.ui.insertText(text, { send: action === 'send' });
2074
+ }
2075
+
1830
2076
  /** Show a URL or a page in the project in the desktop browser (see opener.js). UCODE_OPEN=0 turns it off. */
1831
2077
  openInBrowser(target) {
1832
2078
  if (!this.full || process.env.UCODE_OPEN === '0') return false;
@@ -2201,12 +2447,85 @@ export class Agent {
2201
2447
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
2202
2448
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
2203
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);
2204
2458
 
2205
2459
  // Output reaches the screen as the command produces it, so a slow build is
2206
2460
  // something you watch rather than something you sit out in silence.
2207
- return runTool(call.name, call.args ?? {}, {
2461
+ const out = await runTool(call.name, call.args ?? {}, {
2208
2462
  onOutput: (lines) => this.ui.progress(lines),
2209
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
+ }
2210
2529
  }
2211
2530
 
2212
2531
  /**
@@ -2265,6 +2584,7 @@ export class Agent {
2265
2584
  const list = (Array.isArray(items) ? items : [])
2266
2585
  .filter((i) => i && String(i.text ?? '').trim())
2267
2586
  .slice(0, 6);
2587
+ this.planItems = list;
2268
2588
  this.ui.plan(list);
2269
2589
  const done = list.filter((i) => i.done).length;
2270
2590
  return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
@@ -2338,7 +2658,7 @@ export class Agent {
2338
2658
  { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
2339
2659
  { role: 'user', content: String(task.instructions) },
2340
2660
  ];
2341
- 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));
2342
2662
  const offered = new Set(available.map((t) => t.name));
2343
2663
  const wanted = process.env.UCODE_WORKER_MODEL;
2344
2664
  let workerModel = wanted && MODELS[wanted] ? wanted : model();
@@ -2361,7 +2681,7 @@ export class Agent {
2361
2681
  // again costs far less than carrying the whole history.
2362
2682
  thinWorker(messages, contextLimit(workerModel) * 0.6);
2363
2683
  try {
2364
- 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' });
2365
2685
  } catch (err) {
2366
2686
  // Same rule as the lead: a busy model is swapped, not a reason to stop.
2367
2687
  if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
@@ -2609,6 +2929,13 @@ export class Agent {
2609
2929
  if (failed) problems.push(failed);
2610
2930
  }
2611
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
+
2612
2939
  const live = await this.liveErrors();
2613
2940
  if (live) problems.push(live);
2614
2941
 
@@ -2714,6 +3041,15 @@ export class Agent {
2714
3041
  return { problems: `I opened the app and looked at it:\n\n${content}` };
2715
3042
  }
2716
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
+
2717
3053
  this.handOverPending = false;
2718
3054
  // "Tip Calculator is done", with the page as a link the terminal can open.
2719
3055
  const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
@@ -2732,6 +3068,14 @@ export class Agent {
2732
3068
  return { done: true };
2733
3069
  }
2734
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
+
2735
3079
  async lookOnceThisTurn(root, changed) {
2736
3080
  // A look that found problems is taken again after the fix round, even if
2737
3081
  // the fix only touched the page's script. Looking once a turn let a live
@@ -2957,19 +3301,146 @@ export class Agent {
2957
3301
  case '/search': return this.cmdSearch(arg);
2958
3302
  case '/copy': return this.cmdCopy();
2959
3303
  case '/stats': return this.cmdStats();
2960
- case '/undo': return this.cmdUndo();
3304
+ case '/undo': return this.cmdUndo(arg);
2961
3305
  case '/doctor': return this.cmdDoctor();
2962
3306
  case '/look': return this.cmdLook(arg);
2963
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);
3314
+ case '/mic':
3315
+ if (this.ui.onMic) return this.mic('toggle');
3316
+ return this.ui.note('the mic works in the full-screen view — start ucode in a terminal window');
2964
3317
  case '/exit':
2965
3318
  case '/quit': return 'exit';
2966
3319
 
2967
- 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));
2968
3325
  this.ui.write(theme.warn(` no such command: ${name}`));
2969
3326
  this.ui.note('/help lists them.');
3327
+ }
2970
3328
  }
2971
3329
  }
2972
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
+
2973
3444
  /**
2974
3445
  * Look at the running app, because the user asked to.
2975
3446
  *
@@ -3008,7 +3479,21 @@ ${out.content}` });
3008
3479
  * one that just happened — and it says how many files it touched rather than
3009
3480
  * listing them, the way everything else here reports work.
3010
3481
  */
3011
- 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
+ }
3012
3497
  const count = changedCount();
3013
3498
  if (!count) {
3014
3499
  this.ui.note('nothing to undo — the last turn changed no files.');
@@ -3026,11 +3511,18 @@ ${out.content}` });
3026
3511
  cmdHelp() {
3027
3512
  const rows = [
3028
3513
  ['/help', 'this list'],
3029
- ['/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'],
3030
3521
  ['/stats', 'time, steps and tokens this session'],
3031
3522
  ['/doctor', 'check that everything ucode needs is working'],
3032
3523
  ['/look [url]', 'open the running app and report what is on the page'],
3033
3524
  ['/deploy [folder]', 'put the app online and get its link'],
3525
+ ['/mic', 'say what you want instead of typing it (or ctrl+t)'],
3034
3526
  ['/model', 'show the models and switch between them'],
3035
3527
  ['/resume', 'pick up an earlier conversation'],
3036
3528
  ['/new', 'save this one and start fresh'],
@@ -3042,6 +3534,8 @@ ${out.content}` });
3042
3534
  ['/exit', 'save and quit'],
3043
3535
  ];
3044
3536
 
3537
+ for (const c of this.commands?.values() ?? []) rows.push([`/${c.name}`, `yours: ${c.description}`]);
3538
+
3045
3539
  this.ui.blank();
3046
3540
  for (const [command, what] of rows) {
3047
3541
  this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
@@ -3049,6 +3543,7 @@ ${out.content}` });
3049
3543
  this.ui.blank();
3050
3544
  this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
3051
3545
  this.ui.write(dim(' ctrl+b swaps plan and build · + file (or ctrl+o) adds a picture or file · esc stops a running turn · ctrl+d quits'));
3546
+ this.ui.write(dim(' ctrl+t (or the mic button) listens: speak, then enter to send, ctrl+t to check it first, esc to cancel'));
3052
3547
  this.ui.blank();
3053
3548
  }
3054
3549