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.
@@ -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: true,
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)) {