ucode-agent 1.60.2 → 1.61.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.60.2",
3
+ "version": "1.61.1",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on Google Gemini models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
@@ -12,7 +12,7 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { theme, dim, blue } from '../ui/theme.js';
14
14
  import { VERSION } from './version.js';
15
- import { DEFAULT_MODEL, ENV_FILE, BASE_URL, providerKey } from './provider.js';
15
+ import { ENV_FILE, BASE_URL, providerKey } from './provider.js';
16
16
  import { newer } from './updater.js';
17
17
 
18
18
  const DEADLINE = 6000;
@@ -27,22 +27,11 @@ async function checkKey() {
27
27
  const key = providerKey();
28
28
  if (!key) return { ok: false, name: 'API key', detail: 'not set', fix: `Add GEMINI_API_KEY=... to ${ENV_FILE} (free at aistudio.google.com/apikey)` };
29
29
  try {
30
- const r = await fetch(`${BASE_URL}/chat/completions`, {
31
- method: 'POST',
32
- headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
33
- body: JSON.stringify({ model: DEFAULT_MODEL, messages: [{ role: 'user', content: 'ok' }], max_tokens: 1 }),
34
- signal: timed(),
35
- });
36
- if (r.status === 401 || r.status === 403) return { ok: false, name: 'API key', detail: 'rejected', fix: 'Check the key for typos, or make a new one' };
37
- const body = await r.json().catch(() => ({}));
38
- const meta = body?.error?.metadata?.headers ?? {};
39
- const left = r.headers.get('x-ratelimit-remaining') ?? meta['X-RateLimit-Remaining'];
40
- const reset = Number(r.headers.get('x-ratelimit-reset') ?? meta['X-RateLimit-Reset']);
41
- if (r.status === 429 && /per[- ]day|daily/i.test(`${body?.error?.message} ${body?.error?.metadata?.limit_source}`)) {
42
- const at = Number.isFinite(reset) ? new Date(reset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'tomorrow';
43
- return { ok: false, name: 'Requests today', detail: 'daily free limit used up', fix: `It resets at ${at}; adding credit raises the limit` };
44
- }
45
- return { ok: true, name: 'API key', detail: left != null ? `works · ${left} free requests left today` : 'works' };
30
+ // Listing models checks the key in under a second; a chat request can queue for half a minute.
31
+ const r = await fetch(`${BASE_URL}/models`, { headers: { Authorization: `Bearer ${key}` }, signal: timed() });
32
+ if ([400, 401, 403].includes(r.status)) return { ok: false, name: 'API key', detail: 'rejected', fix: 'Check the key for typos, or make a new one' };
33
+ if (!r.ok) return { ok: false, name: 'API key', detail: `could not check (HTTP ${r.status})`, fix: 'Try again in a minute' };
34
+ return { ok: true, name: 'API key', detail: 'works' };
46
35
  } catch (err) {
47
36
  return { ok: false, name: 'API key', detail: `could not check (${err.name === 'TimeoutError' ? 'no answer' : err.message})`, fix: 'Check your internet connection' };
48
37
  }
package/src/core/loop.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
16
  import { pathToFileURL } from 'node:url';
17
- import { appendFileSync, existsSync } from 'node:fs';
17
+ import { appendFileSync, existsSync, statSync } from 'node:fs';
18
18
  import { readFile, readdir, access, mkdir } from 'node:fs/promises';
19
19
  import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
20
20
  import { LogWatch } from './livelog.js';
@@ -1773,23 +1773,57 @@ export class Agent {
1773
1773
  }
1774
1774
  }
1775
1775
 
1776
- /** /deploy [folder] — the same tool the model calls, run directly. */
1776
+ /**
1777
+ * /deploy puts an app online — only when the user types it.
1778
+ *
1779
+ * /deploy the app made most recently (this session, else the newest folder here)
1780
+ * /deploy <folder> that one
1781
+ * /deploy all every app in this folder, one link each
1782
+ */
1777
1783
  async cmdDeploy(arg) {
1778
- const folder = (arg ?? '').trim() || '.';
1779
- this.ui.toolCall(`Deploying ${folder}`);
1780
- this.ui.startSpinner('getting ready to deploy');
1781
- try {
1782
- const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
1783
- this.ui.stopSpinner();
1784
- this.ui.toolResult(out.summary);
1785
- this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
1786
- } catch (err) {
1787
- this.ui.stopSpinner();
1788
- if (err instanceof ToolFailure) this.ui.error(err);
1789
- else throw err;
1784
+ const wanted = (arg ?? '').trim();
1785
+ const apps = await this.appFolders();
1786
+ let folders;
1787
+ if (wanted === 'all') folders = apps;
1788
+ else if (wanted) folders = [wanted];
1789
+ else {
1790
+ const made = this.apps?.at(-1);
1791
+ folders = [made ? path.relative(this.cwd, made).split(path.sep).join('/') || '.' : (apps[0] ?? '.')];
1792
+ }
1793
+ if (!folders.length) {
1794
+ this.ui.note('no apps here to deploy — build one first, or /deploy <folder>');
1795
+ return;
1796
+ }
1797
+ for (const folder of folders) {
1798
+ this.ui.toolCall(`Deploying ${folder}`);
1799
+ this.ui.startSpinner('getting ready to deploy');
1800
+ try {
1801
+ const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
1802
+ this.ui.stopSpinner();
1803
+ this.ui.toolResult(out.summary);
1804
+ this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
1805
+ } catch (err) {
1806
+ this.ui.stopSpinner();
1807
+ if (err instanceof ToolFailure) this.ui.error(err);
1808
+ else throw err;
1809
+ }
1790
1810
  }
1791
1811
  }
1792
1812
 
1813
+ /** App folders here, newest first: the folder itself if it is one, else its subfolders with a page or package.json. */
1814
+ async appFolders() {
1815
+ if (await exists(path.join(this.cwd, 'index.html'))) return ['.'];
1816
+ const found = [];
1817
+ for (const entry of await readdir(this.cwd, { withFileTypes: true }).catch(() => [])) {
1818
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
1819
+ const dir = path.join(this.cwd, entry.name);
1820
+ if ((await exists(path.join(dir, 'index.html'))) || (await exists(path.join(dir, 'package.json')))) {
1821
+ found.push({ name: entry.name, at: statSync(dir).mtimeMs });
1822
+ }
1823
+ }
1824
+ return found.sort((a, b) => b.at - a.at).map((f) => f.name);
1825
+ }
1826
+
1793
1827
  reportResult(call, out) {
1794
1828
  // A build or type check that just passed already verified everything
1795
1829
  // changed so far; the automatic check at the end would only repeat it.
@@ -1912,6 +1946,26 @@ export class Agent {
1912
1946
  .map((f) => ({ abs: path.resolve(this.cwd, f.path), size: f.content.length, path: f.path }));
1913
1947
  }
1914
1948
 
1949
+ /**
1950
+ * A model that has just made todo-app/ sometimes writes app.js beside it
1951
+ * instead of in it: the page never gets its script, and the hand-over, which
1952
+ * watches the app folder, never fires. A bare name the app already has, and
1953
+ * this folder does not, goes into the app.
1954
+ */
1955
+ intoApp(call) {
1956
+ const app = this.apps?.at(-1);
1957
+ if (!app) return;
1958
+ const fix = (p) => {
1959
+ if (typeof p !== 'string' || path.isAbsolute(p) || existsSync(path.resolve(this.cwd, p))) return p;
1960
+ const inside = path.join(app, p);
1961
+ if (path.relative(app, inside).startsWith('..') || !existsSync(inside)) return p;
1962
+ return path.relative(this.cwd, inside).split(path.sep).join('/');
1963
+ };
1964
+ const a = call.args ?? {};
1965
+ if (call.name === 'write_file') a.path = fix(a.path);
1966
+ else if (call.name === 'batch_write') a.files = normaliseFiles(a.files).map((f) => (f && typeof f === 'object' ? { ...f, path: fix(f.path) } : f));
1967
+ }
1968
+
1915
1969
  async dispatch(call, offered = this.offering) {
1916
1970
  // The model emitted arguments that were not valid JSON. Hand the parser's
1917
1971
  // own complaint straight back so it can correct itself next step.
@@ -2019,6 +2073,8 @@ export class Agent {
2019
2073
  }
2020
2074
  }
2021
2075
 
2076
+ if (call.name === 'write_file' || call.name === 'batch_write') this.intoApp(call);
2077
+
2022
2078
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
2023
2079
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
2024
2080
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
@@ -2525,7 +2581,7 @@ export class Agent {
2525
2581
  }
2526
2582
 
2527
2583
  const content = String(out?.content ?? '');
2528
- if (/Console errors|Failed requests|NOTHING HAPPENS|does not parse/i.test(content)) {
2584
+ if (/Console errors|Failed requests|NOTHING HAPPENS|ADDING DOES NOT WORK|works until you refresh|does not parse/i.test(content)) {
2529
2585
  this.handOverPending = true;
2530
2586
  this.ui.runStat?.('needs a fix');
2531
2587
  return { problems: `I opened the app and looked at it:\n\n${content}` };
@@ -218,7 +218,90 @@ const moved = (a, b) => a.nodes !== b.nodes || a.html !== b.html || a.stored !==
218
218
  * not exercised and not judged. Returning null there is the difference between
219
219
  * a check and a false accusation.
220
220
  */
221
+ /**
222
+ * Add something, the way a person would, and see it land on the page.
223
+ *
224
+ * Typing into "the first field" found a todo app's search box: the search
225
+ * filtered the empty list, the page changed, and an app whose Add did nothing
226
+ * at all was reported as working. So for anything shaped like a list — an
227
+ * input that is not a search, or a New / Add button — the probe text is added
228
+ * and must then appear in the page's visible text. Returns null for pages with
229
+ * nothing to add to, so a calculator or a weather lookup is judged as before.
230
+ */
231
+ const NOT_ADD = /search|filter|find|sort|query/i;
232
+ const LIST_HINT = /task|todo|to-do|item|note|add|new|entry|expense|habit|title|what needs|remind/i;
233
+ const ADD_BUTTON = /^\s*(?:\+|add|new|create|save|submit|post|done)\b|\b(?:add|create|save)\s/i;
234
+
235
+ async function tryAdd(page) {
236
+ const shows = () => page.evaluate((t) => document.body.innerText.includes(t), PROBE_TEXT).catch(() => false);
237
+ const settle = () => page.waitForTimeout(400);
238
+ const hintOf = (el) => el.evaluate((e) => [e.placeholder, e.getAttribute('aria-label'), e.name, e.id]
239
+ .filter(Boolean).join(' ')).catch(() => '');
240
+ const addField = async () => {
241
+ const inputs = page.locator('input[type="text"], input:not([type]), textarea');
242
+ const n = Math.min(await inputs.count().catch(() => 0), 10);
243
+ for (let i = 0; i < n; i++) {
244
+ const el = inputs.nth(i);
245
+ if (!(await el.isVisible().catch(() => false))) continue;
246
+ const hint = await hintOf(el);
247
+ if (!NOT_ADD.test(hint)) return { el, hint };
248
+ }
249
+ return null;
250
+ };
251
+ const addButton = async (words = ADD_BUTTON) => {
252
+ const buttons = page.locator('button:not([disabled]), input[type="submit"], [role="button"]');
253
+ const n = Math.min(await buttons.count().catch(() => 0), 30);
254
+ for (let i = 0; i < n; i++) {
255
+ const b = buttons.nth(i);
256
+ const label = ((await b.innerText().catch(() => '')) || (await b.getAttribute('value').catch(() => '')) ||
257
+ (await b.getAttribute('aria-label').catch(() => '')) || '').trim().replace(/\s+/g, ' ');
258
+ if (words.test(label) && (await b.isVisible().catch(() => false))) return { b, label: label.slice(0, 24) };
259
+ }
260
+ return null;
261
+ };
262
+
263
+ const tried = [];
264
+ let field = await addField();
265
+ let listApp = Boolean(field && LIST_HINT.test(field.hint));
266
+ if (!field) {
267
+ // No field yet: the form may be behind a "New task" / "+" button.
268
+ // Words only: a calculator's "+" opens nothing.
269
+ const opener = await addButton(/\b(?:add|new|create)\b/i);
270
+ if (!opener) return null;
271
+ listApp = true;
272
+ await opener.b.click({ timeout: 2_000 }).catch(() => {});
273
+ await settle();
274
+ tried.push(`clicked "${opener.label}"`);
275
+ field = await addField();
276
+ // No field appeared: that button was not a form opener ("Add one" on a
277
+ // counter), so this is not a list app, and the normal check judges it.
278
+ if (!field) return { tried, added: false, listApp: false };
279
+ }
280
+ const ok = await field.el.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
281
+ if (!ok) return tried.length ? { tried, added: false, listApp } : null;
282
+ await field.el.press('Enter', { timeout: 2_000 }).catch(() => {});
283
+ await settle();
284
+ tried.push(`typed "${PROBE_TEXT}" into ${field.hint ? `"${field.hint.slice(0, 30)}"` : 'the field'} and pressed Enter`);
285
+ if (await shows()) return { tried, added: true, listApp };
286
+
287
+ const button = await addButton();
288
+ if (button) {
289
+ listApp = true;
290
+ await button.b.click({ timeout: 2_000 }).catch(() => {});
291
+ await settle();
292
+ tried.push(`clicked "${button.label}"`);
293
+ if (await shows()) return { tried, added: true, listApp };
294
+ }
295
+ return { tried, added: false, listApp };
296
+ }
297
+
221
298
  async function useTheApp(page) {
299
+ // A list app is judged by whether adding works, not by whether anything moved.
300
+ const add = await tryAdd(page).catch(() => null);
301
+ if (add?.added) return { tried: add.tried, worked: true };
302
+ if (add?.listApp) return { tried: add.tried, worked: false, addFailed: true };
303
+ if (add?.tried.length) await page.reload({ waitUntil: 'load', timeout: 20_000 }).catch(() => {});
304
+
222
305
  const snapshot = () => page.evaluate(() => ({
223
306
  nodes: document.body.querySelectorAll('*').length,
224
307
  text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
@@ -487,7 +570,15 @@ export async function lookAtApp({ url, paths = ['/'], review: withReview = true
487
570
  if (f.smallText) lines.push(`- ${at}: ${f.smallText} text element(s) under 12px.`);
488
571
  }
489
572
 
490
- if (used && !used.worked) {
573
+ if (used?.addFailed) {
574
+ lines.push(
575
+ `- ADDING DOES NOT WORK. I ${used.tried.join(', then ')} — and "${PROBE_TEXT}" never`,
576
+ ' appeared on the page. Adding an item is the whole app. Wire the add input and its',
577
+ ' button to code that pushes the item, saves it and re-renders the list, and make',
578
+ ' sure no error stops that code before it runs.',
579
+ );
580
+ problems++;
581
+ } else if (used && !used.worked) {
491
582
  lines.push(
492
583
  `- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
493
584
  ' gained no elements, changed no text and stored nothing. The markup and the styling',
@@ -231,7 +231,7 @@ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
231
231
  if (staticSite) {
232
232
  const config = path.join(dir, 'vercel.json');
233
233
  if (!existsSync(config)) {
234
- await fs.writeFile(config, JSON.stringify({ buildCommand: null, outputDirectory: '.' }, null, 2) + '\n');
234
+ await fs.writeFile(config, JSON.stringify({ buildCommand: null, outputDirectory: '.' }, null, 2) + '\n');
235
235
  }
236
236
  const sending = (await fs.readdir(dir, { withFileTypes: true }))
237
237
  .filter((e) => e.isFile() && !e.name.startsWith('.') && e.name !== 'vercel.json')
@@ -249,6 +249,12 @@ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
249
249
  });
250
250
  }
251
251
 
252
+ // Vercel turns on "Vercel Authentication" for new projects, which puts every
253
+ // .vercel.app address behind a Vercel login: the link worked for the account
254
+ // owner and asked everyone else to sign in. /deploy is asking for a public
255
+ // site, so the project is made public.
256
+ await api(`/v9/projects/${link.projectId}`, { method: 'PATCH', body: { ssoProtection: null } }).catch(() => {});
257
+
252
258
  say('Uploading and building on Vercel');
253
259
  const started = Date.now();
254
260
  const { code, output } = await new Promise((resolve) => {
@@ -284,9 +290,18 @@ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
284
290
  });
285
291
  }
286
292
 
287
- const live = `https://${link.projectName}.vercel.app`;
288
- const ok = await fetchImpl(live, { signal: AbortSignal.timeout(15_000) }).then((r) => r.status < 400).catch(() => false);
289
- const url = ok ? live : (output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app/g) ?? [live]).at(-1);
293
+ // The address people can open is the project's production alias, which is
294
+ // not always <name>.vercel.app: a taken name gets a suffix ("-eta"). Guessing
295
+ // it handed out a 404, and falling back to the deployment's own URL handed
296
+ // out one that sits behind a Vercel login. Ask Vercel for the real one.
297
+ const project = await api(`/v9/projects/${link.projectId}`).catch(() => ({ json: {} }));
298
+ const aliases = (project.json?.targets?.production?.alias ?? []).slice().sort((a, b) => a.length - b.length);
299
+ const opens = (u) => fetchImpl(u, { redirect: 'manual', signal: AbortSignal.timeout(15_000) })
300
+ .then((r) => r.status < 300).catch(() => false);
301
+ let url = `https://${link.projectName}.vercel.app`;
302
+ for (const alias of aliases) {
303
+ if (await opens(`https://${alias}`)) { url = `https://${alias}`; break; }
304
+ }
290
305
  return result(
291
306
  [
292
307
  `Live at ${url}`,