tokenmaw 0.3.0 → 0.4.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/README.md +22 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +46 -0
- package/dist/infra/tools.js +274 -28
- package/dist/markdown.js +83 -48
- package/dist/responses.js +7 -1
- package/dist/runtime/agent-registry.js +35 -5
- package/dist/runtime/agent-runtime.js +309 -19
- package/dist/runtime/agent-store.js +52 -0
- package/dist/runtime/file-lock.js +256 -0
- package/dist/runtime/locks.js +58 -38
- package/dist/runtime/session-timeline.js +32 -3
- package/dist/runtime/workspace-instances.js +109 -0
- package/dist/runtime/worktree.js +321 -0
- package/dist/ui/bracketed-paste.js +231 -0
- package/dist/ui/commands.js +11 -0
- package/dist/ui/fullscreen-tui.js +1282 -122
- package/dist/ui/markdown.js +19 -9
- package/dist/ui/scrollbar.js +370 -0
- package/dist/ui/syntax.js +3 -5
- package/dist/ui/theme.js +198 -0
- package/dist/ui/tui-design.js +78 -0
- package/dist/ui/welcome.js +555 -11
- package/dist/update-check.js +332 -0
- package/docs/architecture-revision.md +1 -1
- package/package.json +9 -3
package/dist/infra/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile, readdir, mkdir, stat, rename, rm } from 'node:fs/promises';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { exec, execFile } from 'node:child_process';
|
|
3
|
+
import { exec, execFile, spawn } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
6
|
import { ToolRegistry } from '../tools/registry.js';
|
|
@@ -63,6 +63,36 @@ async function writeViaWorkspace(targetPath, content, ctx) {
|
|
|
63
63
|
await atomicWrite(absoluteTarget, content);
|
|
64
64
|
return absoluteTarget;
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Staged, all-or-nothing write: content goes to a staging file next to the
|
|
68
|
+
* target, is verified from disk, and only then is atomically swapped in via
|
|
69
|
+
* rename. The target is never observable in a partially written or unverified
|
|
70
|
+
* state, and any failure (including failed readback verification) leaves the
|
|
71
|
+
* target byte-for-byte unchanged — no post-failure restore pass needed.
|
|
72
|
+
*/
|
|
73
|
+
async function stagedWrite(path, content, ctx, verify) {
|
|
74
|
+
const absoluteTarget = resolveWriteTarget(path, ctx);
|
|
75
|
+
const root = workspaceRoot(ctx);
|
|
76
|
+
const rel = absoluteTarget.startsWith(root)
|
|
77
|
+
? relative(root, absoluteTarget)
|
|
78
|
+
: join('__external__', absoluteTarget.replace(/^([a-zA-Z]:)?[/\\]+/, ''));
|
|
79
|
+
const workspacePath = join(root, '.agent-workspace', rel);
|
|
80
|
+
const staging = `${absoluteTarget}.${process.pid}.${randomUUID()}.staging`;
|
|
81
|
+
await mkdir(dirname(absoluteTarget), { recursive: true });
|
|
82
|
+
try {
|
|
83
|
+
await writeFile(staging, content, 'utf8');
|
|
84
|
+
const written = await readFile(staging, 'utf8');
|
|
85
|
+
await verify(written);
|
|
86
|
+
await mkdir(dirname(workspacePath), { recursive: true });
|
|
87
|
+
await atomicWrite(workspacePath, content);
|
|
88
|
+
await rename(staging, absoluteTarget);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await rm(staging, { force: true }).catch(() => undefined);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
return absoluteTarget;
|
|
95
|
+
}
|
|
66
96
|
async function atomicWrite(path, content) {
|
|
67
97
|
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
68
98
|
await writeFile(temp, content, 'utf8');
|
|
@@ -196,6 +226,54 @@ function closestLineHints(content, search) {
|
|
|
196
226
|
return hints;
|
|
197
227
|
return lineNumbersOf(content, firstLine.slice(0, 24), 3);
|
|
198
228
|
}
|
|
229
|
+
function normalizedForSimilarity(value) {
|
|
230
|
+
return value.replace(/\r\n/g, '\n').replace(/[\t ]+/g, ' ').trim();
|
|
231
|
+
}
|
|
232
|
+
function editDistance(a, b) {
|
|
233
|
+
if (a === b)
|
|
234
|
+
return 0;
|
|
235
|
+
if (!a.length)
|
|
236
|
+
return b.length;
|
|
237
|
+
if (!b.length)
|
|
238
|
+
return a.length;
|
|
239
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
240
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
241
|
+
const current = [i];
|
|
242
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
243
|
+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
244
|
+
}
|
|
245
|
+
previous = current;
|
|
246
|
+
}
|
|
247
|
+
return previous[b.length];
|
|
248
|
+
}
|
|
249
|
+
function closestMatch(content, search) {
|
|
250
|
+
const requested = search.split('\n');
|
|
251
|
+
const lines = content.split('\n');
|
|
252
|
+
const count = requested.length;
|
|
253
|
+
if (!search.trim() || !lines.length)
|
|
254
|
+
return undefined;
|
|
255
|
+
let best;
|
|
256
|
+
for (let i = 0; i <= lines.length - count; i += 1) {
|
|
257
|
+
const matched = lines.slice(i, i + count).join('\n');
|
|
258
|
+
const left = normalizedForSimilarity(search);
|
|
259
|
+
const right = normalizedForSimilarity(matched);
|
|
260
|
+
const max = Math.max(left.length, right.length, 1);
|
|
261
|
+
const similarity = 1 - editDistance(left, right) / max;
|
|
262
|
+
if (!best || similarity > best.similarity)
|
|
263
|
+
best = { line: i + 1, matched, similarity };
|
|
264
|
+
}
|
|
265
|
+
return best;
|
|
266
|
+
}
|
|
267
|
+
function diagnosticDiff(requested, matched) {
|
|
268
|
+
const requestedLines = requested.split('\n');
|
|
269
|
+
const matchedLines = matched.split('\n');
|
|
270
|
+
const lines = ['```diff', '- requested'];
|
|
271
|
+
lines.push(...requestedLines.map((line) => `- ${line}`));
|
|
272
|
+
lines.push('+ matched');
|
|
273
|
+
lines.push(...matchedLines.map((line) => `+ ${line}`));
|
|
274
|
+
lines.push('```');
|
|
275
|
+
return lines.join('\n');
|
|
276
|
+
}
|
|
199
277
|
function stripReadFileLineNumbers(search) {
|
|
200
278
|
const lines = search.split('\n');
|
|
201
279
|
const contentLines = lines.filter((line) => !line.startsWith('... (showing lines '));
|
|
@@ -258,7 +336,17 @@ export function getToolPolicy() {
|
|
|
258
336
|
return clonePolicy(defaultToolPolicy);
|
|
259
337
|
}
|
|
260
338
|
async function withWriteLock(ctx, path, action) {
|
|
261
|
-
|
|
339
|
+
if (!ctx) {
|
|
340
|
+
// Bare toolkit call without a runtime context (direct library use): no
|
|
341
|
+
// lock service exists to consult. Documented as unlocked.
|
|
342
|
+
return action();
|
|
343
|
+
}
|
|
344
|
+
if (!ctx.acquireWriteLock) {
|
|
345
|
+
// Never write unlocked when a runtime context is present: a silent
|
|
346
|
+
// unlocked write would lose updates against concurrent processes.
|
|
347
|
+
throw new Error(`write lock unavailable for ${path}: the runtime context did not provide acquireWriteLock; refusing unlocked write`);
|
|
348
|
+
}
|
|
349
|
+
const release = await ctx.acquireWriteLock(path);
|
|
262
350
|
try {
|
|
263
351
|
return await action();
|
|
264
352
|
}
|
|
@@ -535,8 +623,7 @@ function parseStringArray(value) {
|
|
|
535
623
|
return undefined;
|
|
536
624
|
}
|
|
537
625
|
}
|
|
538
|
-
|
|
539
|
-
const raw = await readFile(filePath, 'utf8');
|
|
626
|
+
function formatLineRange(raw, offset = 1, limit) {
|
|
540
627
|
if (raw === '')
|
|
541
628
|
return '';
|
|
542
629
|
const allLines = raw.split('\n');
|
|
@@ -548,11 +635,17 @@ async function readLineRange(filePath, offset = 1, limit) {
|
|
|
548
635
|
? `${numbered}\n... (showing lines ${startLine}-${endLine} of ${totalLines}; use offset/limit to read more)`
|
|
549
636
|
: numbered;
|
|
550
637
|
}
|
|
638
|
+
async function readLineRange(filePath, offset = 1, limit) {
|
|
639
|
+
return formatLineRange(await readFile(filePath, 'utf8'), offset, limit);
|
|
640
|
+
}
|
|
551
641
|
function boundedOutput(value, maxChars = 4 * 1024 * 1024) {
|
|
552
642
|
if (value.length <= maxChars)
|
|
553
643
|
return value;
|
|
554
644
|
return `${value.slice(0, maxChars)}\n... (output truncated at ${maxChars} characters)`;
|
|
555
645
|
}
|
|
646
|
+
function contentVersion(content) {
|
|
647
|
+
return createHash('sha256').update(content).digest('hex');
|
|
648
|
+
}
|
|
556
649
|
// ── Pure-Node search fallback (used when `rg` is not installed) ──────────────
|
|
557
650
|
// Mirrors the rg invocations in `search_text`/`search_files`: hidden files are
|
|
558
651
|
// included, only `.git` and `node_modules` are skipped.
|
|
@@ -934,7 +1027,10 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
934
1027
|
const offsetArg = typeof args['offset'] === 'number' ? args['offset'] : undefined;
|
|
935
1028
|
const limitArg = typeof args['limit'] === 'number' ? args['limit'] : undefined;
|
|
936
1029
|
try {
|
|
937
|
-
|
|
1030
|
+
const targetPath = resolveToolPath(path, ctx);
|
|
1031
|
+
const content = await readFile(targetPath, 'utf8');
|
|
1032
|
+
ctx?.recordReadVersion?.(targetPath, contentVersion(content));
|
|
1033
|
+
return formatLineRange(content, offsetArg, limitArg);
|
|
938
1034
|
}
|
|
939
1035
|
catch (error) {
|
|
940
1036
|
return `Error reading file: ${String(error)}`;
|
|
@@ -955,7 +1051,10 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
955
1051
|
continue;
|
|
956
1052
|
}
|
|
957
1053
|
try {
|
|
958
|
-
|
|
1054
|
+
const targetPath = resolveToolPath(path, ctx);
|
|
1055
|
+
const content = await readFile(targetPath, 'utf8');
|
|
1056
|
+
ctx?.recordReadVersion?.(targetPath, contentVersion(content));
|
|
1057
|
+
sections.push(`===== ${path} =====\n${formatLineRange(content, 1, maxLines)}`);
|
|
959
1058
|
}
|
|
960
1059
|
catch (error) {
|
|
961
1060
|
sections.push(`===== ${path} =====\nError reading file: ${String(error)}`);
|
|
@@ -1006,6 +1105,16 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
1006
1105
|
catch (error) {
|
|
1007
1106
|
return `Error reading file for edit: ${String(error)}`;
|
|
1008
1107
|
}
|
|
1108
|
+
const currentVersion = contentVersion(src);
|
|
1109
|
+
if (ctx?.requirePriorRead) {
|
|
1110
|
+
const readVersion = ctx.getReadVersion?.(targetPath);
|
|
1111
|
+
if (!readVersion) {
|
|
1112
|
+
return `Error: edit_file requires a prior read_file of ${path} in this session. Read the file, then retry the edit.`;
|
|
1113
|
+
}
|
|
1114
|
+
if (readVersion !== currentVersion) {
|
|
1115
|
+
return `Error: edit_file read lease is stale for ${path}; the file changed after it was read. Read it again before editing.`;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1009
1118
|
let parsed;
|
|
1010
1119
|
try {
|
|
1011
1120
|
if (Array.isArray(editsArg)) {
|
|
@@ -1022,13 +1131,18 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
1022
1131
|
catch (error) {
|
|
1023
1132
|
return `Error parsing edits JSON: ${String(error)}`;
|
|
1024
1133
|
}
|
|
1025
|
-
const noMatchError = (index, search) => {
|
|
1026
|
-
const hints = closestLineHints(
|
|
1134
|
+
const noMatchError = (index, search, haystack = content) => {
|
|
1135
|
+
const hints = closestLineHints(haystack, search);
|
|
1027
1136
|
const hint = hints.length > 0 ? `\nFirst line of the search loosely appears near lines: ${hints.join(', ')}.` : '';
|
|
1028
|
-
|
|
1137
|
+
const closest = closestMatch(haystack, search);
|
|
1138
|
+
const detail = closest
|
|
1139
|
+
? `\nClosest normalized window: lines ${closest.line}-${closest.line + search.split('\n').length - 1} (similarity ${closest.similarity.toFixed(3)}).\n${diagnosticDiff(search, closest.matched)}`
|
|
1140
|
+
: '';
|
|
1141
|
+
return `Error: edit[${index}]: Could not find old text in ${path}. It must match exactly, including whitespace, indentation, and line endings.\nSearch string was:\n${search}${hint}${detail}\nNext action: resubmit the exact matched text.`;
|
|
1029
1142
|
};
|
|
1030
1143
|
let content = src;
|
|
1031
1144
|
const log = [];
|
|
1145
|
+
const diagnostics = [];
|
|
1032
1146
|
const applied = [];
|
|
1033
1147
|
for (let i = 0; i < parsed.length; i += 1) {
|
|
1034
1148
|
const entry = parsed[i];
|
|
@@ -1047,10 +1161,12 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
1047
1161
|
const numberedSearch = stripReadFileLineNumbers(search);
|
|
1048
1162
|
const unescapedSearch = unescapeEscapes(search);
|
|
1049
1163
|
const variants = [search];
|
|
1050
|
-
if (
|
|
1051
|
-
variants.
|
|
1052
|
-
|
|
1053
|
-
variants.
|
|
1164
|
+
if (!replaceAll) {
|
|
1165
|
+
if (numberedSearch !== undefined && numberedSearch !== search && !variants.includes(numberedSearch))
|
|
1166
|
+
variants.push(numberedSearch);
|
|
1167
|
+
if (unescapedSearch !== search && !variants.includes(unescapedSearch))
|
|
1168
|
+
variants.push(unescapedSearch);
|
|
1169
|
+
}
|
|
1054
1170
|
if (replaceAll) {
|
|
1055
1171
|
let usedVariant;
|
|
1056
1172
|
let count = 0;
|
|
@@ -1096,33 +1212,52 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
1096
1212
|
content = content.replace(matched, effectiveReplace);
|
|
1097
1213
|
applied.push({ search: matched, replace: effectiveReplace, made: 1 });
|
|
1098
1214
|
const normalizedLineNumbers = numberedSearch !== undefined && matchedVariant === numberedSearch;
|
|
1099
|
-
log.push(`edit[${i}]: replaced ${matched.length} chars via ${outcome.strategy}${normalizedLineNumbers ? ' (line-number normalized)' : ''}`);
|
|
1215
|
+
log.push(`edit[${i}]: replaced ${matched.length} chars via ${outcome.strategy}${normalizedLineNumbers ? ' (line-number normalized)' : ''}${outcome.strategy === 'exact' ? '' : ' [non-exact; use exact text next time]'}`);
|
|
1216
|
+
if (outcome.strategy !== 'exact' || normalizedLineNumbers) {
|
|
1217
|
+
diagnostics.push(`edit[${i}] matched span vs requested (strategy: ${outcome.strategy}${normalizedLineNumbers ? ', line-number normalized' : ''}):\n${diagnosticDiff(search, matched)}`);
|
|
1218
|
+
}
|
|
1100
1219
|
}
|
|
1101
1220
|
if (content === src) {
|
|
1102
1221
|
return `OK: no changes made to ${path}${log.length > 0 ? ` (${log.join('; ')})` : ''}`;
|
|
1103
1222
|
}
|
|
1223
|
+
// Optimistic conflict check: while holding the write lock, confirm the
|
|
1224
|
+
// file on disk still matches the content these edits were based on.
|
|
1225
|
+
// The cross-process lock excludes other runtime processes, so a
|
|
1226
|
+
// mismatch means an external writer (editor, script) touched the file
|
|
1227
|
+
// between our read and this write — refuse instead of clobbering it.
|
|
1104
1228
|
try {
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1229
|
+
const fresh = await readFile(targetPath, 'utf8');
|
|
1230
|
+
if (contentVersion(fresh) !== currentVersion) {
|
|
1231
|
+
return `Error: ${path} changed underneath this edit (modified by another process after it was read). No changes were applied. Read the file again and retry with fresh content.`;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
return `Error: ${path} could not be re-read before applying edits (it may have been deleted externally). No changes were applied.`;
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
const writtenPath = await stagedWrite(path, content, ctx, (written) => {
|
|
1239
|
+
for (const edit of applied) {
|
|
1240
|
+
if (edit.replace === '') {
|
|
1241
|
+
if (countOccurrences(written, edit.search) !== 0) {
|
|
1242
|
+
throw new Error(`readback verification failed for ${path}: deleted text is still present after write.`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
else if (countOccurrences(written, edit.replace) < edit.made) {
|
|
1246
|
+
throw new Error(`readback verification failed for ${path}: expected at least ${edit.made} occurrence(s) of the replaced text in the written file.`);
|
|
1111
1247
|
}
|
|
1112
1248
|
}
|
|
1113
|
-
|
|
1114
|
-
return `Error: readback verification failed for ${path}: expected at least ${edit.made} occurrence(s) of the replaced text in the written file.`;
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1249
|
+
});
|
|
1117
1250
|
await gitAutoCommit(writtenPath, `edit: ${path} (${applied.length} change${applied.length === 1 ? '' : 's'})`, ctx);
|
|
1118
1251
|
const diff = unifiedDiff(path, src, content);
|
|
1119
|
-
const sha256 = createHash('sha256').update(
|
|
1252
|
+
const sha256 = createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
1253
|
+
ctx?.recordWriteVersion?.(targetPath, contentVersion(content));
|
|
1120
1254
|
const linesBefore = src.split('\n').length;
|
|
1121
|
-
const linesAfter =
|
|
1122
|
-
return [`OK: ${log.join('; ')} (${writtenPath}); ${linesBefore} → ${linesAfter} lines; sha256:${sha256}`, diff].filter(Boolean).join('\n\n');
|
|
1255
|
+
const linesAfter = content.split('\n').length;
|
|
1256
|
+
return [`OK: ${log.join('; ')} (${writtenPath}); ${linesBefore} → ${linesAfter} lines; sha256:${sha256}`, diagnostics.join('\n\n'), diff].filter(Boolean).join('\n\n');
|
|
1123
1257
|
}
|
|
1124
1258
|
catch (error) {
|
|
1125
|
-
|
|
1259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1260
|
+
return `Error writing edited file: ${message} (target left unchanged)`;
|
|
1126
1261
|
}
|
|
1127
1262
|
});
|
|
1128
1263
|
}
|
|
@@ -1170,6 +1305,15 @@ async function executeBuiltinTool(name, args, ctx) {
|
|
|
1170
1305
|
catch {
|
|
1171
1306
|
previous = '';
|
|
1172
1307
|
}
|
|
1308
|
+
// Stale-read check: when this session read the file before and it has
|
|
1309
|
+
// since changed (another process wrote it), a blind overwrite would
|
|
1310
|
+
// silently destroy that work. Force a fresh read + retry instead.
|
|
1311
|
+
if (ctx?.requirePriorRead && existed) {
|
|
1312
|
+
const readVersion = ctx.getReadVersion?.(targetPath);
|
|
1313
|
+
if (readVersion && readVersion !== contentVersion(previous)) {
|
|
1314
|
+
return `Error: ${path} changed after it was read in this session (another process may have written it). Read the file again, then retry write_file.`;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1173
1317
|
const snapshot = existed ? await snapshotBeforeWrite(targetPath, workspaceRoot(ctx)) : { path: null };
|
|
1174
1318
|
const writtenPath = await writeViaWorkspace(path, content, ctx);
|
|
1175
1319
|
await gitAutoCommit(writtenPath, `write: ${path}`, ctx);
|
|
@@ -1298,3 +1442,105 @@ export function listTools(options = {}) {
|
|
|
1298
1442
|
export async function executeTool(name, args, ctx) {
|
|
1299
1443
|
return toolRegistry.execute(name, args, ctx);
|
|
1300
1444
|
}
|
|
1445
|
+
/** Spawn a command without a shell layer around it, wired for incremental
|
|
1446
|
+
* output: stdout/stderr chunks arrive through callbacks as the process runs,
|
|
1447
|
+
* and `signal` (or the runtime timeout) kills the process tree. */
|
|
1448
|
+
function shellSpawn(file, args, options) {
|
|
1449
|
+
return new Promise((resolveExit) => {
|
|
1450
|
+
let settled = false;
|
|
1451
|
+
const settle = (code, signal) => {
|
|
1452
|
+
if (settled)
|
|
1453
|
+
return;
|
|
1454
|
+
settled = true;
|
|
1455
|
+
resolveExit({ code, signal });
|
|
1456
|
+
};
|
|
1457
|
+
let spawnError;
|
|
1458
|
+
const child = spawn(file, args, {
|
|
1459
|
+
cwd: options.cwd,
|
|
1460
|
+
signal: options.signal,
|
|
1461
|
+
windowsVerbatimArguments: options.windowsVerbatimArguments,
|
|
1462
|
+
env: process.env,
|
|
1463
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1464
|
+
});
|
|
1465
|
+
child.stdout?.on('data', (chunk) => options.onStdout(chunk.toString('utf8')));
|
|
1466
|
+
child.stderr?.on('data', (chunk) => options.onStderr(chunk.toString('utf8')));
|
|
1467
|
+
child.on('error', (error) => {
|
|
1468
|
+
spawnError = error;
|
|
1469
|
+
// Spawn failures (missing shell, bad cwd) behave like a 127 exit.
|
|
1470
|
+
settle(error.code === 'ENOENT' ? 127 : 1, undefined);
|
|
1471
|
+
});
|
|
1472
|
+
child.on('close', (code, signal) => {
|
|
1473
|
+
if (spawnError)
|
|
1474
|
+
return;
|
|
1475
|
+
settle(code ?? undefined, signal ?? undefined);
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
/** Run a user-typed `!command` directly in a shell, bypassing the agent loop
|
|
1480
|
+
* and tool policy entirely: the user is the authorizer. Output is streamed to
|
|
1481
|
+
* the TUI via `onChunk` (stdout and stderr interleaved as they arrive), and
|
|
1482
|
+
* `abort` kills the process tree. Like a normal shell, a nonzero exit code is
|
|
1483
|
+
* not an error for the caller — the exit code rides in the result. */
|
|
1484
|
+
export function runShellCommand(command, options) {
|
|
1485
|
+
const timeoutMs = Math.max(100, Math.min(Number(options.timeoutMs ?? 300_000), 600_000));
|
|
1486
|
+
const nl = String.fromCharCode(10);
|
|
1487
|
+
const maxChars = 16 * 1024 * 1024;
|
|
1488
|
+
return new Promise((resolveShell) => {
|
|
1489
|
+
const argv0 = process.platform === 'win32'
|
|
1490
|
+
? { file: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', command], verbatim: true }
|
|
1491
|
+
: { file: '/bin/sh', args: ['-c', command], verbatim: false };
|
|
1492
|
+
const controller = new AbortController();
|
|
1493
|
+
if (options.signal) {
|
|
1494
|
+
if (options.signal.aborted)
|
|
1495
|
+
controller.abort(options.signal.reason);
|
|
1496
|
+
else
|
|
1497
|
+
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
1498
|
+
}
|
|
1499
|
+
let output = '';
|
|
1500
|
+
let truncated = false;
|
|
1501
|
+
let timedOut = false;
|
|
1502
|
+
let settled = false;
|
|
1503
|
+
const push = (text) => {
|
|
1504
|
+
if (!text)
|
|
1505
|
+
return;
|
|
1506
|
+
const room = maxChars - output.length;
|
|
1507
|
+
if (room <= 0) {
|
|
1508
|
+
truncated = true;
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
output += text.length > room ? text.slice(0, room) : text;
|
|
1512
|
+
if (text.length > room)
|
|
1513
|
+
truncated = true;
|
|
1514
|
+
options.onChunk?.(text);
|
|
1515
|
+
};
|
|
1516
|
+
const settle = (exitCode, note) => {
|
|
1517
|
+
if (settled)
|
|
1518
|
+
return;
|
|
1519
|
+
settled = true;
|
|
1520
|
+
clearTimeout(timer);
|
|
1521
|
+
resolveShell({ output: `${output}${truncated ? `${nl}(output truncated)` : ''}${note ?? ''}`, exitCode });
|
|
1522
|
+
};
|
|
1523
|
+
const timer = setTimeout(() => {
|
|
1524
|
+
timedOut = true;
|
|
1525
|
+
controller.abort();
|
|
1526
|
+
}, timeoutMs);
|
|
1527
|
+
timer.unref?.();
|
|
1528
|
+
void shellSpawn(argv0.file, argv0.args, {
|
|
1529
|
+
cwd: options.workspaceRoot,
|
|
1530
|
+
signal: controller.signal,
|
|
1531
|
+
windowsVerbatimArguments: argv0.verbatim,
|
|
1532
|
+
onStdout: push,
|
|
1533
|
+
onStderr: push,
|
|
1534
|
+
}).then(({ code, signal }) => {
|
|
1535
|
+
if (timedOut) {
|
|
1536
|
+
settle(undefined, `${nl}Error: command timed out`);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (controller.signal.aborted || signal) {
|
|
1540
|
+
settle(undefined, `${nl}(stopped)`);
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
settle(code ?? 0);
|
|
1544
|
+
});
|
|
1545
|
+
});
|
|
1546
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -1,21 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* markdown.ts — Lightweight terminal markdown renderer.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Emits Blessed style tags (not raw ANSI) so the TUI renders every highlight
|
|
5
|
+
* with theme-driven colors, and exports the theme shape so unit tests and the
|
|
6
|
+
* TUI share one definition. No external dependencies — pure string transform.
|
|
6
7
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/** Blessed tags quantize hex colors to the terminal palette; chalk's
|
|
9
|
+
* truecolor SGR sequences are dropped by Blessed's attribute parser, which is
|
|
10
|
+
* why highlights previously rendered as plain white. */
|
|
11
|
+
export const DEFAULT_MARKDOWN_THEME = {
|
|
9
12
|
text: '#d7e0ea',
|
|
10
13
|
muted: '#7f92a6',
|
|
11
14
|
accent: '#6fb1d6',
|
|
12
|
-
|
|
15
|
+
heading: '#8ac3e6',
|
|
16
|
+
headingStrong: '#d7e0ea',
|
|
13
17
|
codeBg: '#16212d',
|
|
14
18
|
codeText: '#c7d7e6',
|
|
15
19
|
codeFence: '#5f7388',
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
diffAddBg: '#10281a',
|
|
21
|
+
diffAddText: '#9fd0a6',
|
|
22
|
+
diffDelBg: '#2b1215',
|
|
23
|
+
diffDelText: '#d99f9f',
|
|
18
24
|
};
|
|
25
|
+
let currentTheme = DEFAULT_MARKDOWN_THEME;
|
|
26
|
+
export function setMarkdownTheme(theme) {
|
|
27
|
+
currentTheme = theme;
|
|
28
|
+
}
|
|
29
|
+
export function getMarkdownTheme() {
|
|
30
|
+
return currentTheme;
|
|
31
|
+
}
|
|
32
|
+
/** Blessed reserves braces for style tags; escape literal ones. */
|
|
33
|
+
export function escapeTags(text) {
|
|
34
|
+
return text.replace(/{/g, '{open}').replace(/}/g, '{close}');
|
|
35
|
+
}
|
|
36
|
+
function fg(color, text) {
|
|
37
|
+
return `{${color}-fg}${text}{/${color}-fg}`;
|
|
38
|
+
}
|
|
39
|
+
/** Italic and strikethrough are not Blessed flags; raw SGR is honored by the
|
|
40
|
+
* content parser and excluded from width measurement. */
|
|
41
|
+
function italic(text) {
|
|
42
|
+
return `\x1b[3m${text}\x1b[23m`;
|
|
43
|
+
}
|
|
44
|
+
function strike(text) {
|
|
45
|
+
return `\x1b[9m${text}\x1b[29m`;
|
|
46
|
+
}
|
|
19
47
|
function isDiffLanguage(lang) {
|
|
20
48
|
return lang.toLowerCase() === 'diff' || lang.toLowerCase() === 'patch';
|
|
21
49
|
}
|
|
@@ -31,27 +59,30 @@ export function diffKind(line) {
|
|
|
31
59
|
return 'context';
|
|
32
60
|
}
|
|
33
61
|
export function renderDiffLine(line) {
|
|
62
|
+
const theme = getMarkdownTheme();
|
|
34
63
|
switch (diffKind(line)) {
|
|
35
64
|
case 'add':
|
|
36
|
-
return
|
|
65
|
+
return fg(theme.diffAddText, escapeTags(line));
|
|
37
66
|
case 'del':
|
|
38
|
-
return
|
|
67
|
+
return fg(theme.diffDelText, escapeTags(line));
|
|
39
68
|
case 'hunk':
|
|
40
|
-
return
|
|
69
|
+
return fg(theme.accent, escapeTags(line));
|
|
41
70
|
case 'file':
|
|
42
|
-
return
|
|
71
|
+
return fg(theme.text, escapeTags(line));
|
|
43
72
|
case 'context':
|
|
44
73
|
default:
|
|
45
|
-
return
|
|
74
|
+
return fg(theme.muted, escapeTags(line));
|
|
46
75
|
}
|
|
47
76
|
}
|
|
48
77
|
function renderCodeLine(lang, line) {
|
|
49
|
-
return isDiffLanguage(lang) ? renderDiffLine(line) :
|
|
78
|
+
return isDiffLanguage(lang) ? renderDiffLine(line) : fg(getMarkdownTheme().codeText, escapeTags(line));
|
|
50
79
|
}
|
|
51
80
|
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
52
|
-
|
|
81
|
+
const TAG_PATTERN = /\{[^{}]*\}/g;
|
|
82
|
+
/** Display width ignoring ANSI escapes and style tags, counting East-Asian
|
|
83
|
+
* wide chars as 2 columns. */
|
|
53
84
|
export function displayWidth(text) {
|
|
54
|
-
const clean = text.replace(ANSI_PATTERN, '');
|
|
85
|
+
const clean = text.replace(ANSI_PATTERN, '').replace(TAG_PATTERN, '');
|
|
55
86
|
let width = 0;
|
|
56
87
|
for (const ch of clean) {
|
|
57
88
|
const code = ch.codePointAt(0);
|
|
@@ -135,6 +166,7 @@ function truncateToWidth(text, maxWidth) {
|
|
|
135
166
|
}
|
|
136
167
|
/** Render a parsed table as aligned monospace lines that fit within maxWidth columns. */
|
|
137
168
|
export function renderGfmTable(table, maxWidth) {
|
|
169
|
+
const theme = getMarkdownTheme();
|
|
138
170
|
const columns = table.header.length;
|
|
139
171
|
const gap = ' │ ';
|
|
140
172
|
const gapWidth = displayWidth(gap);
|
|
@@ -153,40 +185,43 @@ export function renderGfmTable(table, maxWidth) {
|
|
|
153
185
|
overflow -= reduce;
|
|
154
186
|
}
|
|
155
187
|
}
|
|
156
|
-
const padCell = (
|
|
157
|
-
const padding = Math.max(0, width - displayWidth(
|
|
188
|
+
const padCell = (plain, width, align) => {
|
|
189
|
+
const padding = Math.max(0, width - displayWidth(plain));
|
|
158
190
|
if (align === 'right')
|
|
159
|
-
return ' '.repeat(padding) +
|
|
191
|
+
return ' '.repeat(padding) + plain;
|
|
160
192
|
if (align === 'center') {
|
|
161
193
|
const left = Math.floor(padding / 2);
|
|
162
|
-
return ' '.repeat(left) +
|
|
194
|
+
return ' '.repeat(left) + plain + ' '.repeat(padding - left);
|
|
163
195
|
}
|
|
164
|
-
return
|
|
196
|
+
return plain + ' '.repeat(padding);
|
|
165
197
|
};
|
|
198
|
+
// Pad the plain cell first, then style — so padding never measures tags.
|
|
166
199
|
const renderRow = (cells, style) => cells
|
|
167
200
|
.map((cell, index) => {
|
|
168
201
|
const width = widths[index];
|
|
169
202
|
const plain = displayWidth(cell) > width ? truncateToWidth(cell, width) : cell;
|
|
170
|
-
return padCell(
|
|
203
|
+
return style(padCell(plain, width, table.aligns[index]));
|
|
171
204
|
})
|
|
172
|
-
.join(
|
|
173
|
-
const header = renderRow(table.header, (
|
|
205
|
+
.join(fg(theme.muted, gap));
|
|
206
|
+
const header = renderRow(table.header, (plain) => fg(theme.accent, `{bold}${inlineMarkdown(plain)}{/bold}`));
|
|
174
207
|
// One uniform border color for every structural character (│, ─, ┼).
|
|
175
|
-
const separator =
|
|
176
|
-
const body = table.rows.map((row) => renderRow(row, (
|
|
177
|
-
return [header,
|
|
208
|
+
const separator = fg(theme.muted, widths.map((width) => '─'.repeat(width)).join('─┼─'));
|
|
209
|
+
const body = table.rows.map((row) => renderRow(row, (plain) => inlineMarkdown(plain)));
|
|
210
|
+
return [header, separator, ...body];
|
|
178
211
|
}
|
|
179
212
|
export function inlineMarkdown(text) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
.replace(
|
|
183
|
-
.replace(
|
|
184
|
-
.replace(
|
|
185
|
-
.replace(
|
|
186
|
-
.replace(
|
|
187
|
-
.replace(
|
|
213
|
+
const theme = getMarkdownTheme();
|
|
214
|
+
return escapeTags(text)
|
|
215
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, (_m, t) => `{bold}${italic(t)}{/bold}`)
|
|
216
|
+
.replace(/\*\*(.+?)\*\*/g, (_m, t) => `{bold}${t}{/bold}`)
|
|
217
|
+
.replace(/__(.+?)__/g, (_m, t) => `{bold}${t}{/bold}`)
|
|
218
|
+
.replace(/\*(.+?)\*/g, (_m, t) => italic(t))
|
|
219
|
+
.replace(/_(.+?)_/g, (_m, t) => italic(t))
|
|
220
|
+
.replace(/`([^`]+)`/g, (_m, t) => `{${theme.codeBg}-bg}{${theme.codeText}-fg} ${t} {/${theme.codeText}-fg}{/${theme.codeBg}-bg}`)
|
|
221
|
+
.replace(/~~(.+?)~~/g, (_m, t) => strike(t));
|
|
188
222
|
}
|
|
189
223
|
export function renderMarkdown(text, cols = 80) {
|
|
224
|
+
const theme = getMarkdownTheme();
|
|
190
225
|
const lines = text.split('\n');
|
|
191
226
|
const out = [];
|
|
192
227
|
let inCodeBlock = false;
|
|
@@ -203,12 +238,12 @@ export function renderMarkdown(text, cols = 80) {
|
|
|
203
238
|
}
|
|
204
239
|
else {
|
|
205
240
|
inCodeBlock = false;
|
|
206
|
-
const langLabel = codeLang ?
|
|
207
|
-
out.push(
|
|
241
|
+
const langLabel = codeLang ? fg(theme.muted, italic(` ${escapeTags(codeLang)}`)) : '';
|
|
242
|
+
out.push(fg(theme.codeFence, '┌' + '─'.repeat(Math.max(2, cols - 2))) + langLabel);
|
|
208
243
|
for (const cl of codeLines) {
|
|
209
|
-
out.push(
|
|
244
|
+
out.push(fg(theme.codeFence, '│ ') + renderCodeLine(codeLang, cl));
|
|
210
245
|
}
|
|
211
|
-
out.push(
|
|
246
|
+
out.push(fg(theme.codeFence, '└' + '─'.repeat(Math.max(2, cols - 2))));
|
|
212
247
|
codeLang = '';
|
|
213
248
|
codeLines = [];
|
|
214
249
|
}
|
|
@@ -222,19 +257,19 @@ export function renderMarkdown(text, cols = 80) {
|
|
|
222
257
|
const h2 = raw.match(/^## (.+)/);
|
|
223
258
|
const h3 = raw.match(/^### (.+)/);
|
|
224
259
|
if (h1) {
|
|
225
|
-
out.push('\n' +
|
|
260
|
+
out.push('\n' + fg(theme.heading, `{bold}${escapeTags(h1[1])}{/bold}`));
|
|
226
261
|
continue;
|
|
227
262
|
}
|
|
228
263
|
if (h2) {
|
|
229
|
-
out.push('\n' +
|
|
264
|
+
out.push('\n' + fg(theme.headingStrong, `{bold}${escapeTags(h2[1])}{/bold}`));
|
|
230
265
|
continue;
|
|
231
266
|
}
|
|
232
267
|
if (h3) {
|
|
233
|
-
out.push(
|
|
268
|
+
out.push(`{bold}${escapeTags(h3[1])}{/bold}`);
|
|
234
269
|
continue;
|
|
235
270
|
}
|
|
236
271
|
if (/^---+$/.test(raw) || /^\*\*\*+$/.test(raw)) {
|
|
237
|
-
out.push(
|
|
272
|
+
out.push(fg(theme.muted, '─'.repeat(cols)));
|
|
238
273
|
continue;
|
|
239
274
|
}
|
|
240
275
|
const tableMatch = matchGfmTable(lines, index);
|
|
@@ -245,30 +280,30 @@ export function renderMarkdown(text, cols = 80) {
|
|
|
245
280
|
}
|
|
246
281
|
const bullet = raw.match(/^(\s*)[*\-+] (.+)/);
|
|
247
282
|
if (bullet) {
|
|
248
|
-
out.push((bullet[1] ?? '') +
|
|
283
|
+
out.push((bullet[1] ?? '') + fg(theme.accent, '•') + ' ' + inlineMarkdown(bullet[2] ?? ''));
|
|
249
284
|
continue;
|
|
250
285
|
}
|
|
251
286
|
const numbered = raw.match(/^(\s*)(\d+)\. (.+)/);
|
|
252
287
|
if (numbered) {
|
|
253
288
|
out.push((numbered[1] ?? '') +
|
|
254
|
-
|
|
289
|
+
fg(theme.accent, escapeTags(numbered[2] + '.')) +
|
|
255
290
|
' ' +
|
|
256
291
|
inlineMarkdown(numbered[3] ?? ''));
|
|
257
292
|
continue;
|
|
258
293
|
}
|
|
259
294
|
const bq = raw.match(/^> (.+)/);
|
|
260
295
|
if (bq) {
|
|
261
|
-
out.push(
|
|
296
|
+
out.push(fg(theme.muted, '│ ') + fg(theme.muted, italic(escapeTags(bq[1]))));
|
|
262
297
|
continue;
|
|
263
298
|
}
|
|
264
299
|
out.push(inlineMarkdown(raw));
|
|
265
300
|
}
|
|
266
301
|
if (inCodeBlock && codeLines.length > 0) {
|
|
267
|
-
out.push(
|
|
302
|
+
out.push(fg(theme.codeFence, '┌─'));
|
|
268
303
|
for (const cl of codeLines) {
|
|
269
|
-
out.push(
|
|
304
|
+
out.push(fg(theme.codeFence, '│ ') + renderCodeLine(codeLang, cl));
|
|
270
305
|
}
|
|
271
|
-
out.push(
|
|
306
|
+
out.push(fg(theme.codeFence, '└─'));
|
|
272
307
|
}
|
|
273
308
|
return out.join('\n');
|
|
274
309
|
}
|