ucode-agent 1.2.0 → 1.4.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/README.md +73 -17
- package/package.json +3 -2
- package/skills/build-app/SKILL.md +4 -2
- package/skills/ui-ux/SKILL.md +5 -1
- package/src/core/context.js +151 -0
- package/src/core/loop.js +550 -40
- package/src/core/provider.js +58 -15
- package/src/core/updater.js +95 -0
- package/src/tools/browser.js +258 -0
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +472 -394
- package/src/tools/shell.js +149 -1
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +27 -7
- package/src/ui/theme.js +18 -0
package/src/tools/shell.js
CHANGED
|
@@ -82,6 +82,10 @@ export function childEnv(base = process.env) {
|
|
|
82
82
|
npm_config_fund: 'false',
|
|
83
83
|
npm_config_audit: 'false',
|
|
84
84
|
npm_config_update_notifier: 'false',
|
|
85
|
+
// Take a package from the local cache when it is there, instead of asking
|
|
86
|
+
// the registry whether a newer copy exists first. Installing the same
|
|
87
|
+
// framework for the second app in a day goes from network-bound to disk-bound.
|
|
88
|
+
npm_config_prefer_offline: 'true',
|
|
85
89
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
86
90
|
NO_COLOR: '1',
|
|
87
91
|
FORCE_COLOR: '0',
|
|
@@ -196,7 +200,14 @@ function startServer(command, workdir, { env } = {}) {
|
|
|
196
200
|
cwd: workdir.abs,
|
|
197
201
|
shell: true,
|
|
198
202
|
windowsHide: true,
|
|
199
|
-
detached
|
|
203
|
+
// Not detached on Windows, and this is load-bearing. A detached process
|
|
204
|
+
// there has no console, and programs launched under it write nothing
|
|
205
|
+
// to a redirected file — measured: every one of node, npm and next
|
|
206
|
+
// produced an empty log, so a server's "ready" line never arrived and
|
|
207
|
+
// every start waited out the full timer. Attached, the output lands.
|
|
208
|
+
// The server itself still outlives ucode: only this shell is tied to
|
|
209
|
+
// ucode's job object, and the job lets grandchildren break away.
|
|
210
|
+
detached: process.platform !== 'win32',
|
|
200
211
|
stdio: ['ignore', fd, fd],
|
|
201
212
|
env,
|
|
202
213
|
});
|
|
@@ -318,6 +329,137 @@ function startServer(command, workdir, { env } = {}) {
|
|
|
318
329
|
});
|
|
319
330
|
}
|
|
320
331
|
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Installing in the background
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Installs already running, by directory.
|
|
338
|
+
*
|
|
339
|
+
* The moment a package.json with dependencies is written, its install starts
|
|
340
|
+
* in the background — while the model is still writing the components. By the
|
|
341
|
+
* time it asks to install, build or start the app, the install is usually done
|
|
342
|
+
* or nearly so, and whatever wait is left is the remainder rather than the
|
|
343
|
+
* whole thing.
|
|
344
|
+
*/
|
|
345
|
+
const installs = new Map();
|
|
346
|
+
|
|
347
|
+
/** The package manager a project already uses, going by its lockfile. */
|
|
348
|
+
export function packageManagerFor(dir) {
|
|
349
|
+
const has = (f) => { try { statSync(path.join(dir, f)); return true; } catch { return false; } };
|
|
350
|
+
if (has('pnpm-lock.yaml')) return 'pnpm';
|
|
351
|
+
if (has('yarn.lock')) return 'yarn';
|
|
352
|
+
if (has('bun.lockb') || has('bun.lock')) return 'bun';
|
|
353
|
+
// npm by default, deliberately: pnpm 10+ refuses to run install scripts
|
|
354
|
+
// without an interactive approval, which fails the install outright here.
|
|
355
|
+
return 'npm';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function runInstall(dir) {
|
|
359
|
+
const pm = packageManagerFor(dir);
|
|
360
|
+
const command = pm === 'npm' ? 'npm install --no-audit --no-fund' : `${pm} install`;
|
|
361
|
+
const started = Date.now();
|
|
362
|
+
|
|
363
|
+
const promise = new Promise((resolve) => {
|
|
364
|
+
let output = '';
|
|
365
|
+
let child;
|
|
366
|
+
try {
|
|
367
|
+
child = spawn(command, {
|
|
368
|
+
cwd: dir, shell: true, windowsHide: true,
|
|
369
|
+
stdio: ['ignore', 'pipe', 'pipe'], env: childEnv(),
|
|
370
|
+
});
|
|
371
|
+
} catch (err) {
|
|
372
|
+
resolve({ code: -1, output: err.message, command, seconds: 0 });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const take = (chunk) => { if (output.length < MAX_OUTPUT * 2) output += stripAnsi(chunk.toString()); };
|
|
376
|
+
child.stdout?.on('data', take);
|
|
377
|
+
child.stderr?.on('data', take);
|
|
378
|
+
const timer = setTimeout(() => killTree(child.pid), INSTALL_TIMEOUT);
|
|
379
|
+
child.on('close', (code) => {
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
resolve({ code, output: output.trim(), command, seconds: Math.round((Date.now() - started) / 1000) });
|
|
382
|
+
});
|
|
383
|
+
child.on('error', (err) => {
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
resolve({ code: -1, output: err.message, command, seconds: 0 });
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const entry = { promise, stale: false };
|
|
390
|
+
installs.set(dir, entry);
|
|
391
|
+
// If package.json changed again while this was running, go once more.
|
|
392
|
+
promise.then(() => {
|
|
393
|
+
if (installs.get(dir) !== entry) return;
|
|
394
|
+
if (entry.stale) runInstall(dir);
|
|
395
|
+
else installs.delete(dir);
|
|
396
|
+
});
|
|
397
|
+
return entry;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Called whenever a package.json is written. Starts an install if it declares
|
|
402
|
+
* dependencies, or marks a running one to go again with the new list.
|
|
403
|
+
*/
|
|
404
|
+
export function packageJsonWritten(file, content) {
|
|
405
|
+
let pkg;
|
|
406
|
+
try { pkg = JSON.parse(content); } catch { return; }
|
|
407
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
408
|
+
if (Object.keys(deps).length === 0) return;
|
|
409
|
+
|
|
410
|
+
const dir = path.dirname(file);
|
|
411
|
+
const running = installs.get(dir);
|
|
412
|
+
if (running) running.stale = true;
|
|
413
|
+
else runInstall(dir);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The install running for this directory or any folder above it, if one is. */
|
|
417
|
+
function installFor(dir) {
|
|
418
|
+
let at = path.resolve(dir);
|
|
419
|
+
for (;;) {
|
|
420
|
+
if (installs.has(at)) return { dir: at, entry: installs.get(at) };
|
|
421
|
+
const up = path.dirname(at);
|
|
422
|
+
if (up === at) return null;
|
|
423
|
+
at = up;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const PLAIN_INSTALL = /^\s*(?:npm\s+(?:i|install)|pnpm\s+(?:i|install)|yarn(?:\s+install)?|bun\s+(?:i|install))(?:\s+--?[\w-]+(?:=\S+)?)*\s*$/i;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Wait for a background install before running something that needs it — and
|
|
431
|
+
* if the command IS that install, hand back the background one's result
|
|
432
|
+
* instead of doing it twice.
|
|
433
|
+
*/
|
|
434
|
+
async function awaitInstall(command, workdir, onOutput) {
|
|
435
|
+
// Models often write `cd app && npm run build` instead of passing cwd, so the
|
|
436
|
+
// directory a command really runs in is read off the front of it.
|
|
437
|
+
const cd = /^\s*cd\s+(?:\/d\s+)?("?)([^"&|;]+?)\1\s*(?:&&|;)\s*/i.exec(command);
|
|
438
|
+
const dir = cd ? path.resolve(workdir.abs, cd[2].trim()) : path.resolve(workdir.abs);
|
|
439
|
+
const rest = cd ? command.slice(cd[0].length) : command;
|
|
440
|
+
|
|
441
|
+
const found = installFor(dir);
|
|
442
|
+
if (!found) return null;
|
|
443
|
+
|
|
444
|
+
onOutput?.(['waiting for the install that started when package.json was written']);
|
|
445
|
+
let done = await found.entry.promise;
|
|
446
|
+
// It may have been restarted for a newer package.json; wait for that too.
|
|
447
|
+
while (installs.get(found.dir) && installs.get(found.dir) !== found.entry) {
|
|
448
|
+
done = await installs.get(found.dir).promise;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!PLAIN_INSTALL.test(rest) || path.resolve(found.dir) !== dir) return null;
|
|
452
|
+
|
|
453
|
+
const out = result(
|
|
454
|
+
`The install already ran in the background as soon as package.json was written ` +
|
|
455
|
+
`(\`${done.command}\`, ${done.seconds}s).\n\nexit code: ${done.code}\n\n${done.output || '(no output)'}`,
|
|
456
|
+
`already installed in the background · exit ${done.code} · ${done.seconds}s`
|
|
457
|
+
);
|
|
458
|
+
out.exitCode = done.code;
|
|
459
|
+
if (done.code !== 0) out.output = tail(done.output);
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
321
463
|
// ---------------------------------------------------------------------------
|
|
322
464
|
// run_command
|
|
323
465
|
// ---------------------------------------------------------------------------
|
|
@@ -355,6 +497,12 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
|
|
|
355
497
|
const env = childEnv();
|
|
356
498
|
const server = LOOKS_LIKE_SERVER.test(command);
|
|
357
499
|
|
|
500
|
+
// Anything run where a background install is still going waits for it —
|
|
501
|
+
// two installs in one folder corrupt node_modules, and a build before the
|
|
502
|
+
// install finishes fails for no reason the model could see.
|
|
503
|
+
const alreadyInstalled = await awaitInstall(command, workdir, onOutput);
|
|
504
|
+
if (alreadyInstalled) return alreadyInstalled;
|
|
505
|
+
|
|
358
506
|
// A dev server is backgrounded whether or not the model remembered to ask.
|
|
359
507
|
// Only an explicit `background: false` keeps one in the foreground.
|
|
360
508
|
if (background || (server && background !== false)) {
|