@visorcraft/idlehands 1.1.6 → 1.1.8
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 +32 -0
- package/dist/agent/formatting.js +251 -0
- package/dist/agent/formatting.js.map +1 -0
- package/dist/agent/review-artifact.js +147 -0
- package/dist/agent/review-artifact.js.map +1 -0
- package/dist/agent/tool-calls.js +226 -0
- package/dist/agent/tool-calls.js.map +1 -0
- package/dist/agent.js +314 -695
- package/dist/agent.js.map +1 -1
- package/dist/anton/controller.js +1 -1
- package/dist/anton/controller.js.map +1 -1
- package/dist/anton/lock.js +0 -3
- package/dist/anton/lock.js.map +1 -1
- package/dist/anton/parser.js +0 -1
- package/dist/anton/parser.js.map +1 -1
- package/dist/anton/reporter.js +1 -1
- package/dist/anton/reporter.js.map +1 -1
- package/dist/bot/commands.js +3 -2
- package/dist/bot/commands.js.map +1 -1
- package/dist/bot/confirm-telegram.js +2 -1
- package/dist/bot/confirm-telegram.js.map +1 -1
- package/dist/bot/discord-routing.js +179 -0
- package/dist/bot/discord-routing.js.map +1 -0
- package/dist/bot/discord-streaming.js +171 -0
- package/dist/bot/discord-streaming.js.map +1 -0
- package/dist/bot/discord.js +25 -221
- package/dist/bot/discord.js.map +1 -1
- package/dist/bot/format.js +2 -25
- package/dist/bot/format.js.map +1 -1
- package/dist/bot/telegram.js +56 -12
- package/dist/bot/telegram.js.map +1 -1
- package/dist/cli/args.js +4 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/build-repl-context.js.map +1 -1
- package/dist/cli/command-registry.js +2 -1
- package/dist/cli/command-registry.js.map +1 -1
- package/dist/cli/command-utils.js +27 -0
- package/dist/cli/command-utils.js.map +1 -0
- package/dist/cli/commands/anton.js +3 -2
- package/dist/cli/commands/anton.js.map +1 -1
- package/dist/cli/commands/model.js +8 -7
- package/dist/cli/commands/model.js.map +1 -1
- package/dist/cli/commands/project.js +5 -4
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/session.js +118 -8
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/commands/tools.js +4 -3
- package/dist/cli/commands/tools.js.map +1 -1
- package/dist/cli/input.js +2 -1
- package/dist/cli/input.js.map +1 -1
- package/dist/cli/repl-dispatch.js +85 -0
- package/dist/cli/repl-dispatch.js.map +1 -0
- package/dist/cli/runtime-cmds.js +7 -7
- package/dist/cli/runtime-cmds.js.map +1 -1
- package/dist/cli/service.js +0 -14
- package/dist/cli/service.js.map +1 -1
- package/dist/cli/setup.js +25 -5
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/watch.js +2 -1
- package/dist/cli/watch.js.map +1 -1
- package/dist/client.js +51 -4
- package/dist/client.js.map +1 -1
- package/dist/config.js +79 -0
- package/dist/config.js.map +1 -1
- package/dist/context.js +101 -10
- package/dist/context.js.map +1 -1
- package/dist/harnesses.js +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/loader.js +58 -0
- package/dist/hooks/loader.js.map +1 -0
- package/dist/hooks/manager.js +180 -0
- package/dist/hooks/manager.js.map +1 -0
- package/dist/hooks/plugins/example-console.js +24 -0
- package/dist/hooks/plugins/example-console.js.map +1 -0
- package/dist/hooks/scaffold.js +53 -0
- package/dist/hooks/scaffold.js.map +1 -0
- package/dist/hooks/types.js +8 -0
- package/dist/hooks/types.js.map +1 -0
- package/dist/index.js +16 -64
- package/dist/index.js.map +1 -1
- package/dist/progress/agent-hooks.js +37 -0
- package/dist/progress/agent-hooks.js.map +1 -0
- package/dist/progress/ir.js +7 -0
- package/dist/progress/ir.js.map +1 -0
- package/dist/progress/progress-message-renderer.js +63 -0
- package/dist/progress/progress-message-renderer.js.map +1 -0
- package/dist/progress/serialize-discord.js +60 -0
- package/dist/progress/serialize-discord.js.map +1 -0
- package/dist/progress/serialize-telegram.js +55 -0
- package/dist/progress/serialize-telegram.js.map +1 -0
- package/dist/progress/serialize-tui.js +39 -0
- package/dist/progress/serialize-tui.js.map +1 -0
- package/dist/progress/tool-summary.js +58 -0
- package/dist/progress/tool-summary.js.map +1 -0
- package/dist/progress/tool-tail.js +48 -0
- package/dist/progress/tool-tail.js.map +1 -0
- package/dist/progress/turn-progress.js +215 -0
- package/dist/progress/turn-progress.js.map +1 -0
- package/dist/replay.js +2 -5
- package/dist/replay.js.map +1 -1
- package/dist/safety.js +0 -1
- package/dist/safety.js.map +1 -1
- package/dist/spinner.js +8 -0
- package/dist/spinner.js.map +1 -1
- package/dist/tools.js +422 -29
- package/dist/tools.js.map +1 -1
- package/dist/tui/branch-picker.js.map +1 -1
- package/dist/tui/command-handler.js.map +1 -1
- package/dist/tui/controller.js +417 -33
- package/dist/tui/controller.js.map +1 -1
- package/dist/tui/keymap.js +15 -0
- package/dist/tui/keymap.js.map +1 -1
- package/dist/tui/render.js +115 -3
- package/dist/tui/render.js.map +1 -1
- package/dist/tui/state.js +82 -1
- package/dist/tui/state.js.map +1 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -292,14 +292,24 @@ export async function undo_path(ctx, args) {
|
|
|
292
292
|
}
|
|
293
293
|
export async function read_file(ctx, args) {
|
|
294
294
|
const p = resolvePath(ctx, args?.path);
|
|
295
|
-
const offset = args?.offset ? Number(args.offset) : undefined;
|
|
295
|
+
const offset = args?.offset != null ? Number(args.offset) : undefined;
|
|
296
296
|
const rawLimit = args?.limit != null ? Number(args.limit) : undefined;
|
|
297
297
|
const limit = Number.isFinite(rawLimit) && rawLimit > 0
|
|
298
298
|
? Math.max(1, Math.floor(rawLimit))
|
|
299
|
-
:
|
|
299
|
+
: 200;
|
|
300
300
|
const search = typeof args?.search === 'string' ? args.search : undefined;
|
|
301
|
-
const
|
|
302
|
-
const
|
|
301
|
+
const rawContext = args?.context != null ? Number(args.context) : undefined;
|
|
302
|
+
const context = Number.isFinite(rawContext) && rawContext >= 0
|
|
303
|
+
? Math.max(0, Math.min(200, Math.floor(rawContext)))
|
|
304
|
+
: 10;
|
|
305
|
+
const formatRaw = typeof args?.format === 'string' ? args.format.trim().toLowerCase() : 'numbered';
|
|
306
|
+
const format = (formatRaw === 'plain' || formatRaw === 'numbered' || formatRaw === 'sparse')
|
|
307
|
+
? formatRaw
|
|
308
|
+
: 'numbered';
|
|
309
|
+
const rawMaxBytes = args?.max_bytes != null ? Number(args.max_bytes) : undefined;
|
|
310
|
+
const maxBytes = Number.isFinite(rawMaxBytes) && rawMaxBytes > 0
|
|
311
|
+
? Math.min(256 * 1024, Math.max(256, Math.floor(rawMaxBytes)))
|
|
312
|
+
: 20 * 1024;
|
|
303
313
|
if (!p)
|
|
304
314
|
throw new Error('read_file: missing path');
|
|
305
315
|
// Detect directories early with a helpful message instead of cryptic EISDIR
|
|
@@ -315,13 +325,6 @@ export async function read_file(ctx, args) {
|
|
|
315
325
|
const buf = await fs.readFile(p).catch((e) => {
|
|
316
326
|
throw new Error(`read_file: cannot read ${p}: ${e?.message ?? String(e)}`);
|
|
317
327
|
});
|
|
318
|
-
if (buf.length > maxBytes) {
|
|
319
|
-
// Truncate gracefully instead of throwing
|
|
320
|
-
const truncText = buf.subarray(0, maxBytes).toString('utf8');
|
|
321
|
-
const truncLines = truncText.split(/\r?\n/);
|
|
322
|
-
const numbered = truncLines.map((l, i) => `${String(i + 1).padStart(4)}| ${l}`).join('\n');
|
|
323
|
-
return `# ${p} [TRUNCATED: ${buf.length} bytes, showing first ${maxBytes}]\n${numbered}`;
|
|
324
|
-
}
|
|
325
328
|
// Binary detection: NUL byte in first 512 bytes (§8)
|
|
326
329
|
for (let i = 0; i < Math.min(buf.length, 512); i++) {
|
|
327
330
|
if (buf[i] === 0) {
|
|
@@ -332,41 +335,59 @@ export async function read_file(ctx, args) {
|
|
|
332
335
|
const text = buf.toString('utf8');
|
|
333
336
|
const lines = text.split(/\r?\n/);
|
|
334
337
|
let start = 1;
|
|
335
|
-
let end =
|
|
338
|
+
let end = Math.min(lines.length, limit);
|
|
339
|
+
let matchLines = [];
|
|
336
340
|
if (search) {
|
|
337
|
-
|
|
341
|
+
matchLines = [];
|
|
338
342
|
for (let i = 0; i < lines.length; i++) {
|
|
339
343
|
if (lines[i].includes(search))
|
|
340
344
|
matchLines.push(i + 1);
|
|
341
345
|
}
|
|
342
346
|
if (!matchLines.length) {
|
|
343
|
-
return `# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines
|
|
347
|
+
return truncateBytes(`# ${p}\n# search not found: ${JSON.stringify(search)}\n# file has ${lines.length} lines`, maxBytes).text;
|
|
344
348
|
}
|
|
345
349
|
const firstIdx = matchLines[0];
|
|
350
|
+
// Window around the first match, but never return more than `limit` lines.
|
|
346
351
|
start = Math.max(1, firstIdx - context);
|
|
347
352
|
end = Math.min(lines.length, firstIdx + context);
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
out.push(`${String(ln).padStart(6, ' ')}| ${lines[ln - 1] ?? ''}`);
|
|
353
|
+
if (end - start + 1 > limit) {
|
|
354
|
+
const half = Math.floor(limit / 2);
|
|
355
|
+
start = Math.max(1, firstIdx - half);
|
|
356
|
+
end = Math.min(lines.length, start + limit - 1);
|
|
353
357
|
}
|
|
354
|
-
if (end < lines.length)
|
|
355
|
-
out.push(`# ... (${lines.length - end} more lines)`);
|
|
356
|
-
return out.join('\n');
|
|
357
358
|
}
|
|
358
359
|
else if (offset && offset >= 1) {
|
|
359
|
-
start = offset;
|
|
360
|
-
end =
|
|
360
|
+
start = Math.max(1, Math.floor(offset));
|
|
361
|
+
end = Math.min(lines.length, start + limit - 1);
|
|
361
362
|
}
|
|
363
|
+
const matchSet = new Set(matchLines);
|
|
362
364
|
const out = [];
|
|
363
|
-
out.push(`# ${p}`);
|
|
365
|
+
out.push(`# ${p} (lines ${start}-${end} of ${lines.length})`);
|
|
366
|
+
if (search) {
|
|
367
|
+
const shown = matchLines.slice(0, 20);
|
|
368
|
+
out.push(`# matches at lines: ${shown.join(', ')}${matchLines.length > shown.length ? ' …' : ''}`);
|
|
369
|
+
}
|
|
370
|
+
const renderNumbered = (ln, body) => `${ln}| ${body}`;
|
|
364
371
|
for (let ln = start; ln <= end; ln++) {
|
|
365
|
-
|
|
372
|
+
const body = lines[ln - 1] ?? '';
|
|
373
|
+
if (format === 'plain') {
|
|
374
|
+
out.push(body);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (format === 'numbered') {
|
|
378
|
+
out.push(renderNumbered(ln, body));
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
// sparse: number anchor lines + matches; otherwise raw text.
|
|
382
|
+
const isAnchor = ln === start || ln === end || (ln - start) % 10 === 0;
|
|
383
|
+
if (isAnchor || matchSet.has(ln))
|
|
384
|
+
out.push(renderNumbered(ln, body));
|
|
385
|
+
else
|
|
386
|
+
out.push(body);
|
|
366
387
|
}
|
|
367
388
|
if (end < lines.length)
|
|
368
389
|
out.push(`# ... (${lines.length - end} more lines)`);
|
|
369
|
-
return out.join('\n');
|
|
390
|
+
return truncateBytes(out.join('\n'), maxBytes).text;
|
|
370
391
|
}
|
|
371
392
|
export async function read_files(ctx, args) {
|
|
372
393
|
const reqs = Array.isArray(args?.requests) ? args.requests : [];
|
|
@@ -602,6 +623,292 @@ export async function edit_file(ctx, args) {
|
|
|
602
623
|
const cwdWarning = checkCwdWarning('edit_file', p, ctx);
|
|
603
624
|
return `edited ${p} (replace_all=${replaceAll})${replayNote}${cwdWarning}`;
|
|
604
625
|
}
|
|
626
|
+
function normalizePatchPath(p) {
|
|
627
|
+
let s = String(p ?? '').trim();
|
|
628
|
+
if (!s || s === '/dev/null')
|
|
629
|
+
return '';
|
|
630
|
+
// Strip quotes some generators add
|
|
631
|
+
s = s.replace(/^"|"$/g, '');
|
|
632
|
+
// Drop common diff prefixes
|
|
633
|
+
s = s.replace(/^[ab]\//, '').replace(/^\.\/+/, '');
|
|
634
|
+
// Normalize to posix separators for diffs
|
|
635
|
+
s = s.replace(/\\/g, '/');
|
|
636
|
+
const norm = path.posix.normalize(s);
|
|
637
|
+
if (norm.startsWith('../') || norm === '..' || norm.startsWith('/')) {
|
|
638
|
+
throw new Error(`apply_patch: unsafe path in patch: ${JSON.stringify(s)}`);
|
|
639
|
+
}
|
|
640
|
+
return norm;
|
|
641
|
+
}
|
|
642
|
+
function extractTouchedFilesFromPatch(patchText) {
|
|
643
|
+
const paths = [];
|
|
644
|
+
const created = new Set();
|
|
645
|
+
const deleted = new Set();
|
|
646
|
+
let pendingOld = null;
|
|
647
|
+
let pendingNew = null;
|
|
648
|
+
const seen = new Set();
|
|
649
|
+
const lines = String(patchText ?? '').split(/\r?\n/);
|
|
650
|
+
for (const line of lines) {
|
|
651
|
+
// Primary: git-style header
|
|
652
|
+
if (line.startsWith('diff --git ')) {
|
|
653
|
+
const m = /^diff --git\s+a\/(.+?)\s+b\/(.+?)\s*$/.exec(line);
|
|
654
|
+
if (m) {
|
|
655
|
+
const aPath = normalizePatchPath(m[1]);
|
|
656
|
+
const bPath = normalizePatchPath(m[2]);
|
|
657
|
+
const use = bPath || aPath;
|
|
658
|
+
if (use && !seen.has(use)) {
|
|
659
|
+
seen.add(use);
|
|
660
|
+
paths.push(use);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
pendingOld = null;
|
|
664
|
+
pendingNew = null;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// Fallback: unified diff headers
|
|
668
|
+
if (line.startsWith('--- ')) {
|
|
669
|
+
pendingOld = line.slice(4).trim();
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (line.startsWith('+++ ')) {
|
|
673
|
+
pendingNew = line.slice(4).trim();
|
|
674
|
+
const oldP = pendingOld ? pendingOld.replace(/^a\//, '').trim() : '';
|
|
675
|
+
const newP = pendingNew ? pendingNew.replace(/^b\//, '').trim() : '';
|
|
676
|
+
const oldIsDevNull = oldP === '/dev/null';
|
|
677
|
+
const newIsDevNull = newP === '/dev/null';
|
|
678
|
+
if (!newIsDevNull) {
|
|
679
|
+
const rel = normalizePatchPath(newP);
|
|
680
|
+
if (rel && !seen.has(rel)) {
|
|
681
|
+
seen.add(rel);
|
|
682
|
+
paths.push(rel);
|
|
683
|
+
}
|
|
684
|
+
if (oldIsDevNull)
|
|
685
|
+
created.add(rel);
|
|
686
|
+
}
|
|
687
|
+
if (!oldIsDevNull && newIsDevNull) {
|
|
688
|
+
const rel = normalizePatchPath(oldP);
|
|
689
|
+
if (rel && !seen.has(rel)) {
|
|
690
|
+
seen.add(rel);
|
|
691
|
+
paths.push(rel);
|
|
692
|
+
}
|
|
693
|
+
deleted.add(rel);
|
|
694
|
+
}
|
|
695
|
+
pendingOld = null;
|
|
696
|
+
pendingNew = null;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return { paths, created, deleted };
|
|
701
|
+
}
|
|
702
|
+
async function runCommandWithStdin(cmd, cmdArgs, stdinText, cwd, maxOutBytes) {
|
|
703
|
+
return await new Promise((resolve, reject) => {
|
|
704
|
+
const child = spawn(cmd, cmdArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
705
|
+
const outChunks = [];
|
|
706
|
+
const errChunks = [];
|
|
707
|
+
let outSeen = 0;
|
|
708
|
+
let errSeen = 0;
|
|
709
|
+
let outCaptured = 0;
|
|
710
|
+
let errCaptured = 0;
|
|
711
|
+
const pushCapped = (chunks, buf, kind) => {
|
|
712
|
+
const n = buf.length;
|
|
713
|
+
if (kind === 'out')
|
|
714
|
+
outSeen += n;
|
|
715
|
+
else
|
|
716
|
+
errSeen += n;
|
|
717
|
+
const captured = kind === 'out' ? outCaptured : errCaptured;
|
|
718
|
+
const remaining = maxOutBytes - captured;
|
|
719
|
+
if (remaining <= 0)
|
|
720
|
+
return;
|
|
721
|
+
const take = n <= remaining ? buf : buf.subarray(0, remaining);
|
|
722
|
+
chunks.push(Buffer.from(take));
|
|
723
|
+
if (kind === 'out')
|
|
724
|
+
outCaptured += take.length;
|
|
725
|
+
else
|
|
726
|
+
errCaptured += take.length;
|
|
727
|
+
};
|
|
728
|
+
child.stdout.on('data', (d) => pushCapped(outChunks, Buffer.from(d), 'out'));
|
|
729
|
+
child.stderr.on('data', (d) => pushCapped(errChunks, Buffer.from(d), 'err'));
|
|
730
|
+
child.on('error', (e) => reject(new Error(`${cmd}: ${e?.message ?? String(e)}`)));
|
|
731
|
+
child.on('close', (code) => {
|
|
732
|
+
const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
|
|
733
|
+
const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
|
|
734
|
+
const outT = truncateBytes(outRaw, maxOutBytes, outSeen);
|
|
735
|
+
const errT = truncateBytes(errRaw, maxOutBytes, errSeen);
|
|
736
|
+
resolve({ rc: code ?? 0, out: outT.text, err: errT.text });
|
|
737
|
+
});
|
|
738
|
+
child.stdin.write(String(stdinText ?? ''), 'utf8');
|
|
739
|
+
child.stdin.end();
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
export async function edit_range(ctx, args) {
|
|
743
|
+
const p = resolvePath(ctx, args?.path);
|
|
744
|
+
const startLine = Number(args?.start_line);
|
|
745
|
+
const endLine = Number(args?.end_line);
|
|
746
|
+
const rawReplacement = args?.replacement;
|
|
747
|
+
const replacement = typeof rawReplacement === 'string' ? rawReplacement
|
|
748
|
+
: (rawReplacement != null && typeof rawReplacement === 'object' ? JSON.stringify(rawReplacement, null, 2) : undefined);
|
|
749
|
+
if (!p)
|
|
750
|
+
throw new Error('edit_range: missing path');
|
|
751
|
+
if (!Number.isFinite(startLine) || startLine < 1)
|
|
752
|
+
throw new Error('edit_range: missing/invalid start_line');
|
|
753
|
+
if (!Number.isFinite(endLine) || endLine < startLine)
|
|
754
|
+
throw new Error('edit_range: missing/invalid end_line');
|
|
755
|
+
if (replacement == null)
|
|
756
|
+
throw new Error('edit_range: missing replacement (got ' + typeof rawReplacement + ')');
|
|
757
|
+
// Path safety check (Phase 9)
|
|
758
|
+
const pathVerdict = checkPathSafety(p);
|
|
759
|
+
if (pathVerdict.tier === 'forbidden') {
|
|
760
|
+
throw new Error(`edit_range: ${pathVerdict.reason}`);
|
|
761
|
+
}
|
|
762
|
+
if (pathVerdict.tier === 'cautious' && !ctx.noConfirm) {
|
|
763
|
+
if (ctx.confirm) {
|
|
764
|
+
const ok = await ctx.confirm(pathVerdict.prompt || `Edit range in ${p}?`, { tool: 'edit_range', args: { path: p, start_line: startLine, end_line: endLine } });
|
|
765
|
+
if (!ok)
|
|
766
|
+
throw new Error(`edit_range: cancelled by user (${pathVerdict.reason})`);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
throw new Error(`edit_range: blocked (${pathVerdict.reason}) without --no-confirm/--yolo`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (ctx.dryRun)
|
|
773
|
+
return `dry-run: would edit_range ${p} lines ${startLine}-${endLine} (${Buffer.byteLength(replacement, 'utf8')} bytes)`;
|
|
774
|
+
// Phase 9d: snapshot /etc/ files before editing
|
|
775
|
+
if (ctx.mode === 'sys' && ctx.vault) {
|
|
776
|
+
await snapshotBeforeEdit(ctx.vault, p).catch(() => { });
|
|
777
|
+
}
|
|
778
|
+
const beforeText = await fs.readFile(p, 'utf8').catch((e) => {
|
|
779
|
+
throw new Error(`edit_range: cannot read ${p}: ${e?.message ?? String(e)}`);
|
|
780
|
+
});
|
|
781
|
+
const eol = beforeText.includes('\r\n') ? '\r\n' : '\n';
|
|
782
|
+
const lines = beforeText.split(/\r?\n/);
|
|
783
|
+
if (startLine > lines.length) {
|
|
784
|
+
throw new Error(`edit_range: start_line ${startLine} out of range (file has ${lines.length} lines)`);
|
|
785
|
+
}
|
|
786
|
+
if (endLine > lines.length) {
|
|
787
|
+
throw new Error(`edit_range: end_line ${endLine} out of range (file has ${lines.length} lines)`);
|
|
788
|
+
}
|
|
789
|
+
const startIdx = startLine - 1;
|
|
790
|
+
const deleteCount = endLine - startLine + 1;
|
|
791
|
+
// For deletion, allow empty replacement to remove the range without leaving a blank line.
|
|
792
|
+
const replacementLines = replacement === '' ? [] : replacement.split(/\r?\n/);
|
|
793
|
+
lines.splice(startIdx, deleteCount, ...replacementLines);
|
|
794
|
+
const out = lines.join(eol);
|
|
795
|
+
await backupFile(p, ctx);
|
|
796
|
+
await atomicWrite(p, out);
|
|
797
|
+
ctx.onMutation?.(p);
|
|
798
|
+
const replayNote = await checkpointReplay(ctx, {
|
|
799
|
+
op: 'edit_range',
|
|
800
|
+
filePath: p,
|
|
801
|
+
before: Buffer.from(beforeText, 'utf8'),
|
|
802
|
+
after: Buffer.from(out, 'utf8')
|
|
803
|
+
});
|
|
804
|
+
const cwdWarning = checkCwdWarning('edit_range', p, ctx);
|
|
805
|
+
return `edited ${p} lines ${startLine}-${endLine}${replayNote}${cwdWarning}`;
|
|
806
|
+
}
|
|
807
|
+
export async function apply_patch(ctx, args) {
|
|
808
|
+
const rawPatch = args?.patch;
|
|
809
|
+
const patchText = typeof rawPatch === 'string' ? rawPatch
|
|
810
|
+
: (rawPatch != null && typeof rawPatch === 'object' ? JSON.stringify(rawPatch, null, 2) : undefined);
|
|
811
|
+
const rawFiles = Array.isArray(args?.files) ? args.files : [];
|
|
812
|
+
const files = rawFiles
|
|
813
|
+
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
|
814
|
+
.filter(Boolean);
|
|
815
|
+
const stripRaw = Number(args?.strip);
|
|
816
|
+
const strip = Number.isFinite(stripRaw) ? Math.max(0, Math.min(5, Math.floor(stripRaw))) : 0;
|
|
817
|
+
if (!patchText)
|
|
818
|
+
throw new Error('apply_patch: missing patch');
|
|
819
|
+
if (!files.length)
|
|
820
|
+
throw new Error('apply_patch: missing files[]');
|
|
821
|
+
const touched = extractTouchedFilesFromPatch(patchText);
|
|
822
|
+
if (!touched.paths.length) {
|
|
823
|
+
throw new Error('apply_patch: patch contains no recognizable file headers');
|
|
824
|
+
}
|
|
825
|
+
const declared = new Set(files.map(normalizePatchPath));
|
|
826
|
+
const unknown = touched.paths.filter((p) => !declared.has(p));
|
|
827
|
+
if (unknown.length) {
|
|
828
|
+
throw new Error(`apply_patch: patch touches undeclared file(s): ${unknown.join(', ')}`);
|
|
829
|
+
}
|
|
830
|
+
const absPaths = touched.paths.map((rel) => resolvePath(ctx, rel));
|
|
831
|
+
// Path safety check (Phase 9)
|
|
832
|
+
const verdicts = absPaths.map((p) => ({ p, v: checkPathSafety(p) }));
|
|
833
|
+
const forbidden = verdicts.filter(({ v }) => v.tier === 'forbidden');
|
|
834
|
+
if (forbidden.length) {
|
|
835
|
+
throw new Error(`apply_patch: ${forbidden[0].v.reason} (${forbidden[0].p})`);
|
|
836
|
+
}
|
|
837
|
+
const cautious = verdicts.filter(({ v }) => v.tier === 'cautious');
|
|
838
|
+
if (cautious.length && !ctx.noConfirm) {
|
|
839
|
+
if (ctx.confirm) {
|
|
840
|
+
const preview = patchText.length > 4000 ? patchText.slice(0, 4000) + '\n[truncated]' : patchText;
|
|
841
|
+
const ok = await ctx.confirm(`Apply patch touching ${touched.paths.length} file(s)?\n- ${touched.paths.join('\n- ')}\n\nProceed? (y/N) `, { tool: 'apply_patch', args: { files: touched.paths, strip }, diff: preview });
|
|
842
|
+
if (!ok)
|
|
843
|
+
throw new Error('apply_patch: cancelled by user');
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
throw new Error('apply_patch: blocked (cautious paths) without --no-confirm/--yolo');
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const maxToolBytes = ctx.maxExecBytes ?? DEFAULT_MAX_EXEC_BYTES;
|
|
850
|
+
const stripArg = `-p${strip}`;
|
|
851
|
+
// Dry-run: validate the patch applies cleanly, but do not mutate files.
|
|
852
|
+
if (ctx.dryRun) {
|
|
853
|
+
const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
|
|
854
|
+
if (haveGit) {
|
|
855
|
+
const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
|
|
856
|
+
if (chk.rc !== 0)
|
|
857
|
+
throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
|
|
861
|
+
if (chk.rc !== 0)
|
|
862
|
+
throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
|
|
863
|
+
}
|
|
864
|
+
return `dry-run: patch would apply cleanly (${touched.paths.length} files): ${touched.paths.join(', ')}`;
|
|
865
|
+
}
|
|
866
|
+
// Snapshot + backup before applying
|
|
867
|
+
const beforeMap = new Map();
|
|
868
|
+
for (const abs of absPaths) {
|
|
869
|
+
// Phase 9d: snapshot /etc/ files before editing
|
|
870
|
+
if (ctx.mode === 'sys' && ctx.vault) {
|
|
871
|
+
await snapshotBeforeEdit(ctx.vault, abs).catch(() => { });
|
|
872
|
+
}
|
|
873
|
+
const before = await fs.readFile(abs).catch(() => Buffer.from(''));
|
|
874
|
+
beforeMap.set(abs, before);
|
|
875
|
+
await backupFile(abs, ctx);
|
|
876
|
+
}
|
|
877
|
+
// Apply with git apply if available; fallback to patch.
|
|
878
|
+
const haveGit = !spawnSync('git', ['--version'], { stdio: 'ignore' }).error;
|
|
879
|
+
if (haveGit) {
|
|
880
|
+
const chk = await runCommandWithStdin('git', ['apply', stripArg, '--check', '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
|
|
881
|
+
if (chk.rc !== 0)
|
|
882
|
+
throw new Error(`apply_patch: git apply --check failed:\n${chk.err || chk.out}`);
|
|
883
|
+
const app = await runCommandWithStdin('git', ['apply', stripArg, '--whitespace=nowarn'], patchText, ctx.cwd, maxToolBytes);
|
|
884
|
+
if (app.rc !== 0)
|
|
885
|
+
throw new Error(`apply_patch: git apply failed:\n${app.err || app.out}`);
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
const chk = await runCommandWithStdin('patch', [stripArg, '--dry-run', '--batch'], patchText, ctx.cwd, maxToolBytes);
|
|
889
|
+
if (chk.rc !== 0)
|
|
890
|
+
throw new Error(`apply_patch: patch --dry-run failed:\n${chk.err || chk.out}`);
|
|
891
|
+
const app = await runCommandWithStdin('patch', [stripArg, '--batch'], patchText, ctx.cwd, maxToolBytes);
|
|
892
|
+
if (app.rc !== 0)
|
|
893
|
+
throw new Error(`apply_patch: patch failed:\n${app.err || app.out}`);
|
|
894
|
+
}
|
|
895
|
+
// Replay checkpoints + mutation hooks
|
|
896
|
+
let replayNotes = '';
|
|
897
|
+
let cwdWarnings = '';
|
|
898
|
+
for (const abs of absPaths) {
|
|
899
|
+
const after = await fs.readFile(abs).catch(() => Buffer.from(''));
|
|
900
|
+
ctx.onMutation?.(abs);
|
|
901
|
+
const replayNote = await checkpointReplay(ctx, {
|
|
902
|
+
op: 'apply_patch',
|
|
903
|
+
filePath: abs,
|
|
904
|
+
before: beforeMap.get(abs) ?? Buffer.from(''),
|
|
905
|
+
after
|
|
906
|
+
});
|
|
907
|
+
replayNotes += replayNote;
|
|
908
|
+
cwdWarnings += checkCwdWarning('apply_patch', abs, ctx);
|
|
909
|
+
}
|
|
910
|
+
return `applied patch (${touched.paths.length} files): ${touched.paths.join(', ')}${replayNotes}${cwdWarnings}`;
|
|
911
|
+
}
|
|
605
912
|
export async function list_dir(ctx, args) {
|
|
606
913
|
const p = resolvePath(ctx, args?.path ?? '.');
|
|
607
914
|
const recursive = Boolean(args?.recursive);
|
|
@@ -739,6 +1046,84 @@ function hasBackgroundExecIntent(command) {
|
|
|
739
1046
|
// like >&2, <&, and &>.
|
|
740
1047
|
return /(^|[;\s])&(?![&><\d])(?=($|[;\s]))/.test(stripped);
|
|
741
1048
|
}
|
|
1049
|
+
function safeFireAndForget(fn) {
|
|
1050
|
+
if (!fn)
|
|
1051
|
+
return;
|
|
1052
|
+
try {
|
|
1053
|
+
const r = fn();
|
|
1054
|
+
if (r && typeof r.catch === 'function')
|
|
1055
|
+
r.catch(() => { });
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
// best effort only
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function makeExecStreamer(ctx) {
|
|
1062
|
+
const cb = ctx.onToolStream;
|
|
1063
|
+
if (!cb)
|
|
1064
|
+
return null;
|
|
1065
|
+
const id = ctx.toolCallId ?? '';
|
|
1066
|
+
const name = ctx.toolName ?? 'exec';
|
|
1067
|
+
const intervalMs = Math.max(50, Math.floor(ctx.toolStreamIntervalMs ?? 750));
|
|
1068
|
+
const maxChunkChars = Math.max(80, Math.floor(ctx.toolStreamMaxChunkChars ?? 900));
|
|
1069
|
+
const maxBufferChars = Math.max(maxChunkChars, Math.floor(ctx.toolStreamMaxBufferChars ?? 12_000));
|
|
1070
|
+
let outBuf = '';
|
|
1071
|
+
let errBuf = '';
|
|
1072
|
+
let lastEmit = 0;
|
|
1073
|
+
let timer = null;
|
|
1074
|
+
const emit = (stream, chunk) => {
|
|
1075
|
+
const trimmed = chunk.length > maxChunkChars ? chunk.slice(-maxChunkChars) : chunk;
|
|
1076
|
+
const ev = { id, name, stream, chunk: trimmed };
|
|
1077
|
+
safeFireAndForget(() => cb(ev));
|
|
1078
|
+
};
|
|
1079
|
+
const schedule = () => {
|
|
1080
|
+
if (timer)
|
|
1081
|
+
return;
|
|
1082
|
+
const delay = Math.max(0, intervalMs - (Date.now() - lastEmit));
|
|
1083
|
+
timer = setTimeout(() => {
|
|
1084
|
+
timer = null;
|
|
1085
|
+
flush(false);
|
|
1086
|
+
}, delay);
|
|
1087
|
+
};
|
|
1088
|
+
const flush = (force = false) => {
|
|
1089
|
+
const now = Date.now();
|
|
1090
|
+
if (!force && now - lastEmit < intervalMs) {
|
|
1091
|
+
schedule();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
lastEmit = now;
|
|
1095
|
+
if (outBuf) {
|
|
1096
|
+
emit('stdout', outBuf);
|
|
1097
|
+
outBuf = '';
|
|
1098
|
+
}
|
|
1099
|
+
if (errBuf) {
|
|
1100
|
+
emit('stderr', errBuf);
|
|
1101
|
+
errBuf = '';
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
const push = (stream, textRaw) => {
|
|
1105
|
+
const text = stripAnsi(textRaw).replace(/\r/g, '\n');
|
|
1106
|
+
if (!text)
|
|
1107
|
+
return;
|
|
1108
|
+
if (stream === 'stdout')
|
|
1109
|
+
outBuf += text;
|
|
1110
|
+
else
|
|
1111
|
+
errBuf += text;
|
|
1112
|
+
if (outBuf.length > maxBufferChars)
|
|
1113
|
+
outBuf = outBuf.slice(-maxBufferChars);
|
|
1114
|
+
if (errBuf.length > maxBufferChars)
|
|
1115
|
+
errBuf = errBuf.slice(-maxBufferChars);
|
|
1116
|
+
schedule();
|
|
1117
|
+
};
|
|
1118
|
+
const done = () => {
|
|
1119
|
+
if (timer) {
|
|
1120
|
+
clearTimeout(timer);
|
|
1121
|
+
timer = null;
|
|
1122
|
+
}
|
|
1123
|
+
flush(true);
|
|
1124
|
+
};
|
|
1125
|
+
return { push, done };
|
|
1126
|
+
}
|
|
742
1127
|
export async function exec(ctx, args) {
|
|
743
1128
|
const command = typeof args?.command === 'string' ? args.command : undefined;
|
|
744
1129
|
const cwd = args?.cwd ? resolvePath(ctx, args.cwd) : ctx.cwd;
|
|
@@ -900,8 +1285,15 @@ export async function exec(ctx, args) {
|
|
|
900
1285
|
else
|
|
901
1286
|
errCaptured += take.length;
|
|
902
1287
|
};
|
|
903
|
-
|
|
904
|
-
child.
|
|
1288
|
+
const streamer = makeExecStreamer(ctx);
|
|
1289
|
+
child.stdout.on('data', (d) => {
|
|
1290
|
+
pushCapped(outChunks, d, 'out');
|
|
1291
|
+
streamer?.push('stdout', d.toString('utf8'));
|
|
1292
|
+
});
|
|
1293
|
+
child.stderr.on('data', (d) => {
|
|
1294
|
+
pushCapped(errChunks, d, 'err');
|
|
1295
|
+
streamer?.push('stderr', d.toString('utf8'));
|
|
1296
|
+
});
|
|
905
1297
|
const rc = await new Promise((resolve, reject) => {
|
|
906
1298
|
child.on('error', (err) => {
|
|
907
1299
|
clearTimeout(killTimer);
|
|
@@ -912,6 +1304,7 @@ export async function exec(ctx, args) {
|
|
|
912
1304
|
});
|
|
913
1305
|
clearTimeout(killTimer);
|
|
914
1306
|
ctx.signal?.removeEventListener('abort', onAbort);
|
|
1307
|
+
streamer?.done();
|
|
915
1308
|
const outRaw = stripAnsi(Buffer.concat(outChunks).toString('utf8'));
|
|
916
1309
|
const errRaw = stripAnsi(Buffer.concat(errChunks).toString('utf8'));
|
|
917
1310
|
const outLines = collapseStackTraces(dedupeRepeats(outRaw.split(/\r?\n/))).join('\n').trimEnd();
|