ucode-agent 1.56.0 → 1.57.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/package.json +1 -1
- package/src/core/loop.js +63 -18
- package/src/core/skills.js +1 -1
- package/src/tools/files.js +46 -7
- package/src/tools/shared.js +12 -5
- package/src/tools/shell.js +52 -2
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -306,6 +306,9 @@ export function lean(messages, keep = 3) {
|
|
|
306
306
|
});
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/** One spelling per file for the read counts: "./a/b.js", "a\b.js" and "a/b.js" are one file. */
|
|
310
|
+
const fileKey = (p) => { const k = path.normalize(String(p)).split(path.sep).join('/'); return process.platform === 'win32' || process.platform === 'darwin' ? k.toLowerCase() : k; };
|
|
311
|
+
|
|
309
312
|
/** Tools that write whole files, whose sizes are remembered for the turn. */
|
|
310
313
|
const WHOLE_WRITES = new Set(['write_file', 'batch_write', 'create_app']);
|
|
311
314
|
|
|
@@ -1196,6 +1199,7 @@ export class Agent {
|
|
|
1196
1199
|
// /undo can put the whole turn back.
|
|
1197
1200
|
beginTurn();
|
|
1198
1201
|
this.lookedThisTurn = false;
|
|
1202
|
+
this.lookAgain = null;
|
|
1199
1203
|
this.reads = new Map();
|
|
1200
1204
|
this.declines = 0;
|
|
1201
1205
|
this.apps = [];
|
|
@@ -1203,6 +1207,11 @@ export class Agent {
|
|
|
1203
1207
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1204
1208
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1205
1209
|
const images = await this.attachImages(input);
|
|
1210
|
+
// The skill goes in ahead of the request, so the request is the last
|
|
1211
|
+
// thing the model reads. After it, a page of house rules was what the
|
|
1212
|
+
// model answered: asked for a dark mode toggle on its second turn, a live
|
|
1213
|
+
// run re-read two files and repeated its first turn's summary instead.
|
|
1214
|
+
this.autoLoad(input);
|
|
1206
1215
|
this.push(images.length
|
|
1207
1216
|
? { role: 'user', content: input, images }
|
|
1208
1217
|
: { role: 'user', content: input });
|
|
@@ -1210,8 +1219,6 @@ export class Agent {
|
|
|
1210
1219
|
if (!this.session.title || this.session.title === 'Untitled') {
|
|
1211
1220
|
this.session.title = titleFrom(input);
|
|
1212
1221
|
}
|
|
1213
|
-
|
|
1214
|
-
this.autoLoad(input);
|
|
1215
1222
|
// What the model is told about the project, fresh for this turn.
|
|
1216
1223
|
[this.map, this.memory] = await Promise.all([
|
|
1217
1224
|
projectMap(this.cwd).catch(() => ''),
|
|
@@ -1613,7 +1620,7 @@ export class Agent {
|
|
|
1613
1620
|
|
|
1614
1621
|
const noted = (call) => {
|
|
1615
1622
|
if (FILE_WRITES.has(call.name)) {
|
|
1616
|
-
for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
|
|
1623
|
+
for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); this.forgetReads(p); }
|
|
1617
1624
|
}
|
|
1618
1625
|
if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
|
|
1619
1626
|
};
|
|
@@ -1882,7 +1889,7 @@ export class Agent {
|
|
|
1882
1889
|
.map((f) => ({ abs: path.resolve(this.cwd, f.path), size: f.content.length, path: f.path }));
|
|
1883
1890
|
}
|
|
1884
1891
|
|
|
1885
|
-
async dispatch(call) {
|
|
1892
|
+
async dispatch(call, offered = this.offering) {
|
|
1886
1893
|
// The model emitted arguments that were not valid JSON. Hand the parser's
|
|
1887
1894
|
// own complaint straight back so it can correct itself next step.
|
|
1888
1895
|
if (call.parseError) {
|
|
@@ -1899,12 +1906,13 @@ export class Agent {
|
|
|
1899
1906
|
// tool that writes, and a new project is not sent the ones that look code
|
|
1900
1907
|
// up — and a model naming one from memory was, until this check, executing
|
|
1901
1908
|
// it. A withheld tool has to be refused, not just left out of the menu.
|
|
1902
|
-
|
|
1909
|
+
// A worker is checked against its own, narrower list, not the lead's.
|
|
1910
|
+
if (!offered?.has(call.name)) {
|
|
1903
1911
|
throw new ToolFailure({
|
|
1904
1912
|
kind: 'no_such_tool',
|
|
1905
1913
|
attempted: `calling ${call.name}`,
|
|
1906
1914
|
failed: `${call.name} is not available${this.ui.mode === 'plan' ? ' in plan mode' : ' in this project'}.`,
|
|
1907
|
-
fix: `Use one of the tools you were given: ${[...
|
|
1915
|
+
fix: `Use one of the tools you were given: ${[...offered ?? []].join(', ')}.`,
|
|
1908
1916
|
});
|
|
1909
1917
|
}
|
|
1910
1918
|
|
|
@@ -1921,14 +1929,24 @@ export class Agent {
|
|
|
1921
1929
|
// usually a fair re-check after an edit. Only the third is a loop. And it
|
|
1922
1930
|
// is keyed on the file being untouched since — the moment anything writes
|
|
1923
1931
|
// to it, the count starts again and a real re-read goes through.
|
|
1932
|
+
//
|
|
1933
|
+
// Counted per page, not per file: paging through a long file with offset
|
|
1934
|
+
// is new text every time, and a live run was refused four pages in a row
|
|
1935
|
+
// of a 900-line file it had only ever seen the start of.
|
|
1936
|
+
//
|
|
1937
|
+
// A write starts the count again (forgetReads). It used to be skipped only
|
|
1938
|
+
// while the file sat in sinceCheck, which the checks empty every fix round:
|
|
1939
|
+
// after round one, every read of a file the model had just edited was
|
|
1940
|
+
// refused as "unchanged", and a live build spent sixty steps being told so.
|
|
1924
1941
|
if (call.name === 'read_file' && call.args?.path) {
|
|
1925
|
-
const
|
|
1942
|
+
const file = String(call.args.path);
|
|
1943
|
+
const key = `${fileKey(file)}#${Number(call.args.offset) || 1}:${Number(call.args.limit) || 0}`;
|
|
1926
1944
|
const seen = (this.reads ??= new Map()).get(key) ?? 0;
|
|
1927
|
-
if (seen >= 2
|
|
1945
|
+
if (seen >= 2) {
|
|
1928
1946
|
this.reads.set(key, seen + 1);
|
|
1929
1947
|
return {
|
|
1930
|
-
content: `You have already read ${
|
|
1931
|
-
'so
|
|
1948
|
+
content: `You have already read that part of ${file} ${seen} times this turn and nothing has written ` +
|
|
1949
|
+
'to it since, so it is unchanged and already above. Use what is there. If you need a part ' +
|
|
1932
1950
|
'you have lost, say which and read the files you still need together in one read_files.',
|
|
1933
1951
|
summary: 'unchanged since you last read it',
|
|
1934
1952
|
};
|
|
@@ -2031,9 +2049,9 @@ export class Agent {
|
|
|
2031
2049
|
}
|
|
2032
2050
|
|
|
2033
2051
|
/** Run a call and settle to { out } or { err } — never throws. */
|
|
2034
|
-
execute(call) {
|
|
2052
|
+
execute(call, offered = this.offering) {
|
|
2035
2053
|
const started = Date.now();
|
|
2036
|
-
return this.dispatch(call).then(
|
|
2054
|
+
return this.dispatch(call, offered).then(
|
|
2037
2055
|
(out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
|
|
2038
2056
|
(err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
|
|
2039
2057
|
);
|
|
@@ -2048,6 +2066,12 @@ export class Agent {
|
|
|
2048
2066
|
return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
|
|
2049
2067
|
}
|
|
2050
2068
|
|
|
2069
|
+
/** A file was written: every page of it read so far is news again. */
|
|
2070
|
+
forgetReads(file) {
|
|
2071
|
+
const prefix = `${fileKey(file)}#`;
|
|
2072
|
+
for (const key of this.reads?.keys() ?? []) if (key.startsWith(prefix)) this.reads.delete(key);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2051
2075
|
/** File writes from parallel workers take turns, so two never interleave. */
|
|
2052
2076
|
fileLock(fn) {
|
|
2053
2077
|
const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
|
|
@@ -2079,7 +2103,7 @@ export class Agent {
|
|
|
2079
2103
|
touched: [],
|
|
2080
2104
|
}))));
|
|
2081
2105
|
|
|
2082
|
-
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
|
|
2106
|
+
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); this.forgetReads(f); }
|
|
2083
2107
|
|
|
2084
2108
|
// A worker that wrote nothing has not done its part, whatever it said.
|
|
2085
2109
|
// The lead builds those itself rather than leaving holes in the app.
|
|
@@ -2111,6 +2135,7 @@ export class Agent {
|
|
|
2111
2135
|
{ role: 'user', content: String(task.instructions) },
|
|
2112
2136
|
];
|
|
2113
2137
|
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
|
|
2138
|
+
const offered = new Set(available.map((t) => t.name));
|
|
2114
2139
|
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
2115
2140
|
let workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
2116
2141
|
const tried = new Set([workerModel]);
|
|
@@ -2172,8 +2197,8 @@ export class Agent {
|
|
|
2172
2197
|
const exclusive =
|
|
2173
2198
|
FILE_WRITES.has(call.name) || call.name === 'run_command' || call.name === 'run_commands';
|
|
2174
2199
|
const { out, err } = exclusive
|
|
2175
|
-
? await this.fileLock(() => this.execute(call))
|
|
2176
|
-
: await this.execute(call);
|
|
2200
|
+
? await this.fileLock(() => this.execute(call, offered))
|
|
2201
|
+
: await this.execute(call, offered);
|
|
2177
2202
|
if (err) {
|
|
2178
2203
|
if (!(err instanceof ToolFailure)) throw err;
|
|
2179
2204
|
this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
|
|
@@ -2431,11 +2456,30 @@ export class Agent {
|
|
|
2431
2456
|
* never a reason to fail the turn that built the app.
|
|
2432
2457
|
*/
|
|
2433
2458
|
async lookOnceThisTurn(root, changed) {
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2459
|
+
// A look that found problems is taken again after the fix round, even if
|
|
2460
|
+
// the fix only touched the page's script. Looking once a turn let a live
|
|
2461
|
+
// build report "Failed to load module script" on its first look, fail to
|
|
2462
|
+
// fix it, and finish as done with a page whose script never ran — nothing
|
|
2463
|
+
// opened the page again. The fix rounds bound how often this repeats.
|
|
2464
|
+
const again = this.lookAgain;
|
|
2465
|
+
if (this.lookedThisTurn && !again) return null;
|
|
2466
|
+
|
|
2467
|
+
let pages = [...changed].filter((f) => PAGE.test(f));
|
|
2468
|
+
// A plain page's script changed and its index.html did not — the usual
|
|
2469
|
+
// shape of a build on the HTML starter, whose page comes from the starter
|
|
2470
|
+
// while the model writes app.js. The page is still the thing to open: a
|
|
2471
|
+
// live build whose "Add" button threw on every click finished as done
|
|
2472
|
+
// because nothing with .html in its name had changed, so nobody looked.
|
|
2473
|
+
if (!pages.length) {
|
|
2474
|
+
for (const f of changed) {
|
|
2475
|
+
const page = path.join(path.dirname(f), 'index.html');
|
|
2476
|
+
if (/\.m?js$/i.test(f) && (await exists(path.resolve(root, page)))) { pages = [page]; break; }
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (!pages.length && again) pages = [again];
|
|
2437
2480
|
if (!pages.length) return null;
|
|
2438
2481
|
this.lookedThisTurn = true;
|
|
2482
|
+
this.lookAgain = null;
|
|
2439
2483
|
|
|
2440
2484
|
try {
|
|
2441
2485
|
const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
|
|
@@ -2448,6 +2492,7 @@ export class Agent {
|
|
|
2448
2492
|
const out = await lookAtApp({ url, review: false });
|
|
2449
2493
|
const found = /^(\d+) problem/.exec(out.summary ?? '');
|
|
2450
2494
|
this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
|
|
2495
|
+
if (found) this.lookAgain = pages.find((f) => /\.html?$/i.test(f)) ?? pages[0];
|
|
2451
2496
|
return found ? `I opened the app and looked at it:\n\n${out.content}` : null;
|
|
2452
2497
|
};
|
|
2453
2498
|
|
package/src/core/skills.js
CHANGED
|
@@ -172,7 +172,7 @@ export function autoLoadFor(skills, text) {
|
|
|
172
172
|
export function skillMessage(skill, { automatic = false, short = false } = {}) {
|
|
173
173
|
const digest = short && skill.digest ? skill.digest : null;
|
|
174
174
|
const why = automatic
|
|
175
|
-
? `The "${skill.name}" skill was loaded automatically because
|
|
175
|
+
? `The "${skill.name}" skill was loaded automatically because the request that follows is the kind it covers.`
|
|
176
176
|
: `The "${skill.name}" skill was loaded for this task.`;
|
|
177
177
|
return {
|
|
178
178
|
role: 'system',
|
package/src/tools/files.js
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* coding agent can do, because everything after it is built on a lie.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
11
11
|
import { remember } from '../core/undo.js';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { ToolFailure } from '../core/failure.js';
|
|
14
14
|
import {
|
|
15
15
|
resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
|
|
16
16
|
changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
|
|
17
|
-
noteFile, writeTracked, assertUnchanged,
|
|
17
|
+
noteFile, writeTracked, assertUnchanged, getRoot,
|
|
18
18
|
} from './shared.js';
|
|
19
19
|
import { packageJsonWritten } from './shell.js';
|
|
20
20
|
import { fuzzyReplace } from './fuzzy.js';
|
|
@@ -196,6 +196,44 @@ const brokenNote = (show, problem) => {
|
|
|
196
196
|
`a structure you have lost track of.`;
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A module in a page with no build step that imports a stylesheet or an image.
|
|
201
|
+
*
|
|
202
|
+
* A bundler would take it; a browser refuses the whole module ("Failed to
|
|
203
|
+
* load module script ... MIME type text/css"), so none of the app's script
|
|
204
|
+
* runs and every button is dead. The syntax is fine, so nothing else notices
|
|
205
|
+
* until the page is opened — a live build shipped exactly this as done.
|
|
206
|
+
*/
|
|
207
|
+
const ASSET_IMPORT = /^[ \t]*import\s+(?:[\w$*{}\s,]+?\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less|svg|png|jpe?g|gif|webp))['"](?!\s*(?:with|assert)\s*\{)/m;
|
|
208
|
+
|
|
209
|
+
export function assetImport(abs, text) {
|
|
210
|
+
if (!/\.m?js$/i.test(abs)) return null;
|
|
211
|
+
// An import quoted inside a block comment is not an import.
|
|
212
|
+
const hit = ASSET_IMPORT.exec(String(text).replace(/\/\*[\s\S]*?\*\//g, ''));
|
|
213
|
+
if (!hit) return null;
|
|
214
|
+
// Anything with a package.json above it, up to the project root, may well be
|
|
215
|
+
// bundled. path.relative, not a string prefix: "ucode2" starts with "ucode",
|
|
216
|
+
// and Windows paths differ in case from one shell to the next.
|
|
217
|
+
const top = getRoot();
|
|
218
|
+
const within = (dir) => {
|
|
219
|
+
const rel = path.relative(top, dir);
|
|
220
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
221
|
+
};
|
|
222
|
+
for (let dir = path.dirname(abs); ; dir = path.dirname(dir)) {
|
|
223
|
+
if (existsSync(path.join(dir, 'package.json'))) return null;
|
|
224
|
+
if (!within(dir) || path.relative(top, dir) === '' || path.dirname(dir) === dir) break;
|
|
225
|
+
}
|
|
226
|
+
return `imports ${hit[1]}, which a page with no build step cannot do: the browser refuses the ` +
|
|
227
|
+
'whole module, so none of its script runs. Remove that import and load it from the page ' +
|
|
228
|
+
'instead — <link rel="stylesheet" href="…"> for a stylesheet, an <img> or a plain URL for an image.';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The note for that, appended to a write's result like a parse problem is. */
|
|
232
|
+
const pageNote = (target, text) => {
|
|
233
|
+
const problem = assetImport(target.abs, text);
|
|
234
|
+
return problem ? `\n\n⚠ ${target.show} ${problem}` : '';
|
|
235
|
+
};
|
|
236
|
+
|
|
199
237
|
/** A file this short comes back whole after an edit; longer ones show the part around the change. */
|
|
200
238
|
const SHOW_WHOLE = 250;
|
|
201
239
|
const AROUND = 15;
|
|
@@ -349,6 +387,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
|
|
|
349
387
|
lineCount,
|
|
350
388
|
diff,
|
|
351
389
|
problem: syntaxProblem(target.abs, content),
|
|
390
|
+
note: pageNote(target, content),
|
|
352
391
|
line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
|
|
353
392
|
`(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
|
|
354
393
|
};
|
|
@@ -368,7 +407,7 @@ export async function writeFile({ path: p, content }) {
|
|
|
368
407
|
|
|
369
408
|
const written = await put(target, content);
|
|
370
409
|
const out = result(
|
|
371
|
-
`${written.line}.${parseNote(target.show, written.problem)}`,
|
|
410
|
+
`${written.line}.${parseNote(target.show, written.problem)}${written.note}`,
|
|
372
411
|
`${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
|
|
373
412
|
);
|
|
374
413
|
out.diff = written.diff;
|
|
@@ -414,7 +453,7 @@ export async function batchWrite({ files }) {
|
|
|
414
453
|
// would bury the reply under three hundred lines of gutter.
|
|
415
454
|
const written = await put(target, content, { diffMax: 6 });
|
|
416
455
|
if (!written.existed) created++;
|
|
417
|
-
lines.push(written.line + parseNote(target.show, written.problem));
|
|
456
|
+
lines.push(written.line + parseNote(target.show, written.problem) + written.note);
|
|
418
457
|
if (written.problem) broken++;
|
|
419
458
|
diff.push(`~${target.show}`, ...written.diff);
|
|
420
459
|
}
|
|
@@ -632,7 +671,7 @@ export async function editFile({ path: p, old_string, new_string, replace_all })
|
|
|
632
671
|
const span = toLines(new_string).length;
|
|
633
672
|
const out = result(
|
|
634
673
|
`Replaced ${where} (${change}${matched}).` +
|
|
635
|
-
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
674
|
+
parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
|
|
636
675
|
nowReads(target.show, text, at, span),
|
|
637
676
|
`${count > 1 ? `${count} changes from line` : '1 change at line'} ${at} · ${change}` +
|
|
638
677
|
`${how ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
|
|
@@ -705,7 +744,7 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
705
744
|
|
|
706
745
|
const out = result(
|
|
707
746
|
`Applied ${edits.length} edits to ${target.show} (${change}).` +
|
|
708
|
-
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
747
|
+
parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
|
|
709
748
|
nowReads(target.show, text, 1, toLines(text).length),
|
|
710
749
|
`${edits.length} edits · ${change}`,
|
|
711
750
|
MAX_FILE_OUTPUT
|
|
@@ -795,7 +834,7 @@ export async function editFiles({ files }) {
|
|
|
795
834
|
planned.map((p) => {
|
|
796
835
|
const problem = syntaxProblem(p.target.abs, p.text);
|
|
797
836
|
return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
|
|
798
|
-
parseNote(p.target.show, problem);
|
|
837
|
+
parseNote(p.target.show, problem) + pageNote(p.target, p.text);
|
|
799
838
|
}).join('\n'),
|
|
800
839
|
`${planned.length} files · ${edits} edits`
|
|
801
840
|
);
|
package/src/tools/shared.js
CHANGED
|
@@ -408,12 +408,19 @@ export const WALK_WIDTH = 32;
|
|
|
408
408
|
*/
|
|
409
409
|
const known = new Map();
|
|
410
410
|
|
|
411
|
+
/**
|
|
412
|
+
* One key per file. Windows and macOS do not care about case, so "App.js" and
|
|
413
|
+
* "app.js" are the same file there — and keyed apart, ucode's own write under
|
|
414
|
+
* one spelling read as somebody else's edit under the other.
|
|
415
|
+
*/
|
|
416
|
+
const stampKey = (abs) => (process.platform === 'win32' || process.platform === 'darwin' ? abs.toLowerCase() : abs);
|
|
417
|
+
|
|
411
418
|
/** Record a file as ucode now knows it. Never throws: a missing stamp only costs the check. */
|
|
412
419
|
export async function noteFile(abs) {
|
|
413
420
|
try {
|
|
414
|
-
known.set(abs, (await fs.stat(abs)).mtimeMs);
|
|
421
|
+
known.set(stampKey(abs), (await fs.stat(abs)).mtimeMs);
|
|
415
422
|
} catch {
|
|
416
|
-
known.delete(abs);
|
|
423
|
+
known.delete(stampKey(abs));
|
|
417
424
|
}
|
|
418
425
|
}
|
|
419
426
|
|
|
@@ -431,7 +438,7 @@ export async function writeTracked(abs, data, encoding = 'utf8') {
|
|
|
431
438
|
|
|
432
439
|
/** Forget a file, so the next write to it goes through unchallenged. */
|
|
433
440
|
export function forgetFile(abs) {
|
|
434
|
-
known.delete(abs);
|
|
441
|
+
known.delete(stampKey(abs));
|
|
435
442
|
}
|
|
436
443
|
|
|
437
444
|
/**
|
|
@@ -444,7 +451,7 @@ export function forgetFile(abs) {
|
|
|
444
451
|
* clobber.
|
|
445
452
|
*/
|
|
446
453
|
export async function assertUnchanged(abs, show) {
|
|
447
|
-
const seen = known.get(abs);
|
|
454
|
+
const seen = known.get(stampKey(abs));
|
|
448
455
|
if (seen === undefined) return;
|
|
449
456
|
|
|
450
457
|
let now;
|
|
@@ -457,7 +464,7 @@ export async function assertUnchanged(abs, show) {
|
|
|
457
464
|
// costs nothing and stops a same-second write reading as someone else's.
|
|
458
465
|
if (Math.abs(now - seen) < 1) return;
|
|
459
466
|
|
|
460
|
-
known.delete(abs);
|
|
467
|
+
known.delete(stampKey(abs));
|
|
461
468
|
throw new ToolFailure({
|
|
462
469
|
kind: 'changed_on_disk',
|
|
463
470
|
attempted: `overwriting ${show}`,
|
package/src/tools/shell.js
CHANGED
|
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { ToolFailure } from '../core/failure.js';
|
|
17
|
-
import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
17
|
+
import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TIMEOUT = 120_000;
|
|
20
20
|
const MAX_TIMEOUT = 600_000;
|
|
@@ -28,6 +28,19 @@ const LIVE_LINES = 200;
|
|
|
28
28
|
const LOOKS_LIKE_SERVER =
|
|
29
29
|
/(\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b|\b(?:vite|next dev|next start|nuxt dev|astro dev|webpack serve|svelte-kit dev|serve)\b|\buvicorn\b|\bgunicorn\b|\bflask run\b|\bdjango[\w-]* runserver\b|\bmanage\.py runserver\b|\brails server\b|\bhttp\.server\b|\bhttp-server\b|\blive-server\b)/i;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Does some part of this command actually run a server? A segment that only
|
|
33
|
+
* searches for or lists one does not: `ps aux | grep http-server` was
|
|
34
|
+
* backgrounded as a server and reported as having died before it was ready.
|
|
35
|
+
*/
|
|
36
|
+
const INSPECTS = /^(?:grep|egrep|findstr|find|ps|echo|cat|type|tasklist|netstat|curl|wget|ls|dir|which|where|head|tail|less|more)\b/i;
|
|
37
|
+
export function startsServer(command) {
|
|
38
|
+
return String(command).replace(/"[^"]*"|'[^']*'/g, '""').split(/&&|\|\||[;|&]/).some((part) => {
|
|
39
|
+
const segment = part.trim();
|
|
40
|
+
return !INSPECTS.test(segment) && LOOKS_LIKE_SERVER.test(segment);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/** A foreground server that was explicitly asked for still gets a short leash. */
|
|
32
45
|
const SERVER_TIMEOUT = 30_000;
|
|
33
46
|
|
|
@@ -41,6 +54,17 @@ const INSTALL_TIMEOUT = 600_000;
|
|
|
41
54
|
const LOOKS_LIKE_INSTALL =
|
|
42
55
|
/(\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|create)\b|\bnpx\s+(?:create-|degit\b|shadcn)|\bpip3?\s+install\b|\bpoetry\s+(?:install|add)\b|\bcargo\s+(?:build|install|fetch)\b|\bgo\s+(?:mod\s+download|get)\b|\bbundle\s+install\b|\bcomposer\s+(?:install|require)\b|\bgit\s+clone\b)/i;
|
|
43
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Any kill by program name rather than by process number.
|
|
59
|
+
*
|
|
60
|
+
* `taskkill /F /IM python.exe` in a live build ended six Python processes on
|
|
61
|
+
* the machine, most of them nothing to do with the app. Every process ucode
|
|
62
|
+
* starts comes back with its PID, so the narrow kill is always available;
|
|
63
|
+
* the broad one is the user's call, and is asked about.
|
|
64
|
+
*/
|
|
65
|
+
const KILLS_BY_NAME =
|
|
66
|
+
/(\btaskkill\b[^|;&]*\/IM\b|\b(?:killall|pkill)\b|\b(?:Stop-Process|spps)\b[^|;&]*-(?:N(?:a(?:me?)?)?|ProcessName)\b|\bGet-Process\b[^;&]*\|\s*(?:Stop-Process|spps|kill)\b|\bwmic\b[^|;&]*\bprocess\b[^|;&]*\b(?:delete|call\s+terminate)\b|\bkill\b[^;&]*\$\(\s*pgrep\b|\bpgrep\b[^;&]*\|\s*xargs\s+(?:-\S+\s+)*kill\b)/i;
|
|
67
|
+
|
|
44
68
|
/**
|
|
45
69
|
* Commands that kill a whole class of process rather than one process.
|
|
46
70
|
*
|
|
@@ -73,6 +97,10 @@ const KILLS_EVERYTHING =
|
|
|
73
97
|
* NEXT_TELEMETRY Next skips its telemetry notice and ping
|
|
74
98
|
* NO_COLOR output comes back as text rather than escape codes,
|
|
75
99
|
* which the model would otherwise have to read past
|
|
100
|
+
* PYTHONUNBUFFERED Python writes as it goes instead of holding output
|
|
101
|
+
* until a buffer fills: `python -m http.server` said
|
|
102
|
+
* "Serving HTTP" to a log that stayed empty, and each
|
|
103
|
+
* start waited out the full 45 seconds as "still starting"
|
|
76
104
|
*/
|
|
77
105
|
export function childEnv(base = process.env) {
|
|
78
106
|
return {
|
|
@@ -89,6 +117,7 @@ export function childEnv(base = process.env) {
|
|
|
89
117
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
90
118
|
NO_COLOR: '1',
|
|
91
119
|
FORCE_COLOR: '0',
|
|
120
|
+
PYTHONUNBUFFERED: '1',
|
|
92
121
|
};
|
|
93
122
|
}
|
|
94
123
|
|
|
@@ -630,13 +659,34 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
|
|
|
630
659
|
});
|
|
631
660
|
}
|
|
632
661
|
|
|
662
|
+
if (KILLS_BY_NAME.test(command)) {
|
|
663
|
+
const attempted = `running \`${command.trim().slice(0, 80)}\``;
|
|
664
|
+
try {
|
|
665
|
+
await confirm(
|
|
666
|
+
`stop every matching process: ${command.trim().slice(0, 120)}`,
|
|
667
|
+
'That kills every process with that name on this computer, not only the ones ucode started.',
|
|
668
|
+
'command',
|
|
669
|
+
);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (err?.kind !== 'cannot_ask') throw err;
|
|
672
|
+
throw new ToolFailure({
|
|
673
|
+
kind: 'broad_kill',
|
|
674
|
+
attempted,
|
|
675
|
+
failed: 'That kills every process with that name on this computer, not only the ones ucode ' +
|
|
676
|
+
'started, and there is nobody here to approve it.',
|
|
677
|
+
fix: 'Stop the one process by its PID: every server ucode starts reports its PID and the exact ' +
|
|
678
|
+
'command to stop it.',
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
633
683
|
const workdir = cwd
|
|
634
684
|
? resolveIn(cwd, 'run_command', 'cwd')
|
|
635
685
|
: { abs: getRoot(), inside: true, show: '.' };
|
|
636
686
|
await guard(workdir, `run a command in ${workdir.abs}`);
|
|
637
687
|
|
|
638
688
|
const env = childEnv();
|
|
639
|
-
const server =
|
|
689
|
+
const server = startsServer(command);
|
|
640
690
|
|
|
641
691
|
// Anything run where a background install is still going waits for it —
|
|
642
692
|
// two installs in one folder corrupt node_modules, and a build before the
|