tokenmaw 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +30 -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 +352 -21
- package/dist/runtime/agent-store.js +23 -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 +12 -0
- package/dist/ui/fullscreen-tui.js +1195 -120
- 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 +144 -11
- package/docs/architecture-revision.md +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,10 @@ Inside the TUI:
|
|
|
17
17
|
- `/model` selects the default model for the current session.
|
|
18
18
|
- `/agents` shows the effective Agent Specs and their sources.
|
|
19
19
|
- `/sessions` switches sessions; `/new` creates one.
|
|
20
|
+
- `/cd <path>` switches the working directory (relative paths resolve against the current one). The status bar always shows the current directory; `/cd` with no argument or `/pwd` prints it. Switching reloads the project's `.coder/agents` specs and `AGENTS.md`, and tool path authorization follows the new root.
|
|
21
|
+
- `!<command>` runs a shell command directly in the current workspace, outside the agent loop: the prompt turns into `$` and the command text is highlighted while typing, the command and its output stream inline into the conversation transcript (never sent to the model or fed back into context), long-running commands animate their ellipsis, and `Ctrl+C` stops a running command. `/`-commands stay reserved for the built-in slash commands.
|
|
20
22
|
- `Ctrl+K` opens the command palette.
|
|
23
|
+
- `/worktree <name>` creates (or reopens) an isolated git worktree under `.coder/worktrees/<name>` on branch `maw/<name>` and moves this session into it, so parallel sessions stop colliding on one working tree. `/worktree-list` shows status, `/worktree-exit` returns to the main checkout (the worktree stays on disk), `/worktree-remove <name>` drops a clean worktree (the branch is never deleted). A `.worktreeinclude` file copies gitignored files (e.g. `.env`) into new worktrees. Non-interactive equivalent: `maw --worktree [name]`.
|
|
21
24
|
- `Ctrl+B` toggles Agent Activity.
|
|
22
25
|
- `Ctrl+J` or `Alt+Enter` inserts a newline; `Enter` sends. Chinese text wraps by terminal width.
|
|
23
26
|
- `Up` / `Down` browse input history; `Ctrl+U` clears the draft.
|
|
@@ -28,7 +31,11 @@ Inside the TUI:
|
|
|
28
31
|
- `F2` (or `/select`) optionally releases app mouse capture for the terminal's native selection. `F2` again restores app clicks, drag selection and wheel scrolling. `Ctrl+Y` and `PageUp` / `PageDown` also work without the mouse.
|
|
29
32
|
- `Ctrl+X` or `/cancel` stops the current session's agents; send another message to continue.
|
|
30
33
|
- `/compact` summarizes and archives older context of the main agent; an optional argument focuses the digest (e.g. `/compact file changes and pending work`).
|
|
31
|
-
-
|
|
34
|
+
- `/aside <note>` queues a side note without starting a turn; it folds into the next message you send and is announced in the conversation stream.
|
|
35
|
+
- `/btw <question>` opens a side conversation forked from the current session (full context included) and sends your question there. `/back` or `Ctrl+C` returns to the main conversation; side sessions are marked `[side]` in the status bar and the `/sessions` list.
|
|
36
|
+
- `/fork` copies the current conversation into a new saved session. `/sessions` lists both; the original stays untouched.
|
|
37
|
+
- `/goal <text>` sets a standing goal for the session: it is injected into every agent's prompt until cleared, shows in the status bar, and survives across sessions. `/goal clear` removes it.
|
|
38
|
+
- `Ctrl+C` copies a selection; without a selection it arms a quit confirmation — press again within 2 seconds to exit. Inside a side conversation, `Ctrl+C` returns to the parent session instead. Runtime errors appear in the conversation.
|
|
32
39
|
|
|
33
40
|
For a non-interactive run:
|
|
34
41
|
|
|
@@ -49,7 +56,7 @@ Interactive mode requires a terminal. Non-interactive runs report agent failures
|
|
|
49
56
|
|
|
50
57
|
## Architecture
|
|
51
58
|
|
|
52
|
-
The user talks to `main`, whose primary responsibility is responsive conversation and
|
|
59
|
+
The user talks to `main`, whose primary responsibility is responsive conversation and forward progress. Main handles small, local, and well-scoped work directly. It delegates only genuinely multi-file, ambiguous, or independently parallel work; coordinators select specialists only when that extra coordination is useful. Main remains available while background work runs and is resumed by verified agent results. The scheduler reserves user-facing capacity independently of the background concurrency limit.
|
|
53
60
|
|
|
54
61
|
```text
|
|
55
62
|
user ↔ main → coordinator(s) → explorer / implement / review / custom agents
|
|
@@ -102,6 +109,20 @@ Specs can reduce capabilities but cannot bypass global tool policy, path boundar
|
|
|
102
109
|
- New user input interrupts only main's current generation. Background agents keep running until main explicitly redirects or cancels them.
|
|
103
110
|
- Only main output enters the user-visible conversation.
|
|
104
111
|
- Sessions and instances persist under `~/.coder/runtime/` and recover after restart.
|
|
112
|
+
- Each turn records requests, input/output tokens, provider-reported cached input, and first-token latency when the backend supplies them. The status bar shows the session aggregate.
|
|
113
|
+
- `AGENT_MAX_CHILDREN_PER_TURN` limits fan-out (default `3`); `AGENT_MAX_CONCURRENT_TURNS` and `AGENT_MAX_DEPTH` provide additional scheduler guardrails.
|
|
114
|
+
|
|
115
|
+
### Multi-process safety
|
|
116
|
+
|
|
117
|
+
Multiple `maw` processes can run against the same project without silently destroying each other's work:
|
|
118
|
+
|
|
119
|
+
- **Cross-process write locks** — every file write (`edit_file`, `write_file`) and every session is guarded by an O_EXCL lock file under `~/.coder/runtime/locks/`. Lock records carry the holder pid and process start time, so a lock left by a crashed process is detected and taken over automatically, while a live holder is reported by pid (`AGENT_SESSION_LOCK_TIMEOUT_MS` adjusts the session-lock wait, default 5s).
|
|
120
|
+
- **Optimistic conflict detection** — `edit_file` re-verifies inside the lock that the file still matches what the edit was based on; `write_file` refuses to overwrite a file that changed after it was read in this session. Changes from external editors or other tooling surface as explicit errors instead of silent lost updates.
|
|
121
|
+
- **Single writer per session** — opening a session another process is writing succeeds in read-only mode (the status bar shows `[read-only pid <n>]`); submissions, `/goal`, and `/clear` are refused with guidance. `/fork` continues from that point in a writable copy, and once the other writer exits, the session self-heals back to writable on the next message.
|
|
122
|
+
- **Concurrent instance awareness** — live instances register under `~/.coder/runtime/instances/`; the status bar warns when other maw processes share the workspace.
|
|
123
|
+
- **Managed worktrees** — for genuine parallelism, `/worktree <name>` (or `maw --worktree [name]`) isolates each session in its own checkout so conflicts become ordinary merges. Worktrees are `git worktree lock`ed while a session uses them; cleanup removes clean, unlocked ones and never touches dirty checkouts or branches.
|
|
124
|
+
|
|
125
|
+
Provider prompt caching is used when the provider supports it: runtime system prefixes stay stable and live sibling state travels through mailboxes instead of being re-injected into every request. Cross-request response caching is intentionally avoided because coding answers depend on the current workspace and tool results.
|
|
105
126
|
|
|
106
127
|
### Context compaction
|
|
107
128
|
|
package/agents/coordinator.md
CHANGED
|
@@ -7,7 +7,6 @@ agents:
|
|
|
7
7
|
- '*'
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
You coordinate a complex workstream for the parent agent. Understand the goal
|
|
11
|
-
|
|
12
|
-
You do not talk to the user. Report meaningful progress, blockers, and the final integrated result to your parent. Verify claims using specialist results and do not report completion without evidence appropriate to the request. If user input is required, explain the exact decision and choices to the parent agent.
|
|
10
|
+
You coordinate a complex workstream for the parent agent. Understand the goal and choose the smallest workflow that can finish it. Start with one focused explorer or implementer; add another agent only when its work is independent and necessary. As soon as the root cause and edit location are clear, stop exploration and implement. Reuse existing instances instead of spawning duplicates.
|
|
13
11
|
|
|
12
|
+
You do not talk to the user. Report concise progress to your parent: conclusion, evidence, changed paths, and next action. Do not paste large tool outputs. Verify claims with focused checks and do not report completion without evidence. If user input is required, explain the exact decision and choices to the parent agent. Stop or cancel redundant agents once one result resolves the question.
|
package/agents/main.md
CHANGED
|
@@ -10,9 +10,9 @@ agents:
|
|
|
10
10
|
|
|
11
11
|
You are TokenMaw's user-facing main agent. You are the only agent that talks to the user.
|
|
12
12
|
|
|
13
|
-
Your primary responsibility is responsiveness
|
|
13
|
+
Your primary responsibility is responsiveness and forward progress. Answer conversational questions directly. Handle small, local, or well-scoped fixes yourself with the available tools. Delegate only when the task is genuinely multi-file, ambiguous, or benefits from independent parallel work. Never create a coordinator merely to inspect one or two files. When delegating, give one concrete objective, target paths, constraints, and acceptance checks.
|
|
14
14
|
|
|
15
|
-
Delegation is asynchronous: after assigning work,
|
|
15
|
+
Delegation is asynchronous: after assigning work, remain available to the user. Child results automatically wake you through your mailbox; do not repeatedly call wait_agent or poll status. A handoff acknowledgement is not a completion claim. When evidence is sufficient, stop further exploration and implement or ask the existing worker to implement. Never restart an equivalent workstream for a follow-up.
|
|
16
16
|
|
|
17
17
|
Your broad tool access is a fallback capability, not the default workflow. Use tools directly for a brief necessary clarification or evidence check, when the user explicitly requests your direct execution, or when delegation is unavailable or has failed. Keep such work bounded and explain a material fallback.
|
|
18
18
|
|
package/dist/backend.js
CHANGED
|
@@ -89,7 +89,11 @@ async function* ollamaStream(config, systemPrompt, messages, tools, signal) {
|
|
|
89
89
|
yield { content: null, toolCalls: obj.message.tool_calls, done: false };
|
|
90
90
|
}
|
|
91
91
|
if (obj.done) {
|
|
92
|
-
|
|
92
|
+
const stats = obj;
|
|
93
|
+
yield { content: null, done: true, usage: (stats.prompt_eval_count !== undefined || stats.eval_count !== undefined) ? {
|
|
94
|
+
inputTokens: stats.prompt_eval_count,
|
|
95
|
+
outputTokens: stats.eval_count,
|
|
96
|
+
} : undefined };
|
|
93
97
|
return;
|
|
94
98
|
}
|
|
95
99
|
}
|
|
@@ -200,6 +204,7 @@ async function* openaiStream(config, systemPrompt, messages, tools, signal) {
|
|
|
200
204
|
model: config.model,
|
|
201
205
|
stream: true,
|
|
202
206
|
messages: convertToOpenAIMessages(systemPrompt, messages),
|
|
207
|
+
stream_options: { include_usage: true },
|
|
203
208
|
};
|
|
204
209
|
applyOpenAIRequestOptions(body, config);
|
|
205
210
|
const openaiTools = convertToolsToOpenAI(tools);
|
|
@@ -245,6 +250,13 @@ async function* openaiStream(config, systemPrompt, messages, tools, signal) {
|
|
|
245
250
|
continue;
|
|
246
251
|
try {
|
|
247
252
|
const parsed = JSON.parse(trimmed.slice(6));
|
|
253
|
+
if (parsed.usage)
|
|
254
|
+
yield { content: null, done: false, usage: {
|
|
255
|
+
inputTokens: parsed.usage.prompt_tokens,
|
|
256
|
+
outputTokens: parsed.usage.completion_tokens,
|
|
257
|
+
cachedInputTokens: parsed.usage.prompt_tokens_details?.cached_tokens,
|
|
258
|
+
reasoningTokens: parsed.usage.completion_tokens_details?.reasoning_tokens,
|
|
259
|
+
} };
|
|
248
260
|
const choice = parsed.choices?.[0];
|
|
249
261
|
if (!choice)
|
|
250
262
|
continue;
|
|
@@ -495,6 +507,24 @@ async function* anthropicStream(config, systemPrompt, messages, tools, signal) {
|
|
|
495
507
|
yield { content: null, toolCalls: [toolCall], done: false };
|
|
496
508
|
continue;
|
|
497
509
|
}
|
|
510
|
+
if (event === 'message_start' && (parsed.message?.usage?.input_tokens !== undefined)) {
|
|
511
|
+
const usage = parsed.message.usage;
|
|
512
|
+
yield { content: null, done: false, usage: {
|
|
513
|
+
inputTokens: usage.input_tokens,
|
|
514
|
+
cachedInputTokens: usage.cache_read_input_tokens,
|
|
515
|
+
cacheCreationInputTokens: usage.cache_creation_input_tokens,
|
|
516
|
+
} };
|
|
517
|
+
}
|
|
518
|
+
if (event === 'message_delta' && parsed.usage) {
|
|
519
|
+
const usage = parsed.usage;
|
|
520
|
+
if (usage.output_tokens !== undefined || usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined) {
|
|
521
|
+
yield { content: null, done: false, usage: {
|
|
522
|
+
outputTokens: usage.output_tokens,
|
|
523
|
+
cachedInputTokens: usage.cache_read_input_tokens,
|
|
524
|
+
cacheCreationInputTokens: usage.cache_creation_input_tokens,
|
|
525
|
+
} };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
498
528
|
if (event === 'message_stop') {
|
|
499
529
|
yield { content: null, done: true };
|
|
500
530
|
return;
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { resolveModelConfig } from './model-config.js';
|
|
|
6
6
|
import { defaultPolicy } from './policy.js';
|
|
7
7
|
import { AgentRegistry } from './runtime/agent-registry.js';
|
|
8
8
|
import { AgentRuntime } from './runtime/agent-runtime.js';
|
|
9
|
+
import { WorktreeManager } from './runtime/worktree.js';
|
|
10
|
+
import { registerWorkspaceInstance } from './runtime/workspace-instances.js';
|
|
9
11
|
import { runFullscreenTui } from './ui/fullscreen-tui.js';
|
|
10
12
|
import { CODER_VERSION } from './version.js';
|
|
11
13
|
async function main() {
|
|
@@ -13,6 +15,7 @@ async function main() {
|
|
|
13
15
|
program.name('maw').description('Document-driven coding agent runtime').version(CODER_VERSION);
|
|
14
16
|
program.allowExcessArguments(false).showSuggestionAfterError();
|
|
15
17
|
program.option('--model <name>', 'default model name or .agentrc alias');
|
|
18
|
+
program.option('--worktree [name]', 'start inside an isolated managed git worktree (.coder/worktrees/<name>)');
|
|
16
19
|
let config = await loadConfig();
|
|
17
20
|
const selectedFromCli = () => program.opts().model;
|
|
18
21
|
setToolPolicy(defaultPolicy(config.policyLevel ?? 'moderate', process.cwd()));
|
|
@@ -23,6 +26,19 @@ async function main() {
|
|
|
23
26
|
defaultModel: selectedFromCli() ?? config.model,
|
|
24
27
|
resolveModel: (alias) => resolveModelConfig(config, alias).config,
|
|
25
28
|
});
|
|
29
|
+
// Announce this instance so concurrent maw processes in the same workspace
|
|
30
|
+
// can surface a warning (and users can see who else is editing).
|
|
31
|
+
const stopInstanceHeartbeat = await registerWorkspaceInstance(process.cwd());
|
|
32
|
+
// /cd moves the runtime to a new workspace root; the tool policy must follow
|
|
33
|
+
// so read/write authorization keeps covering the new tree.
|
|
34
|
+
runtime.subscribe((event) => {
|
|
35
|
+
if (event.type !== 'workspace_changed')
|
|
36
|
+
return;
|
|
37
|
+
setToolPolicy(defaultPolicy(config.policyLevel ?? 'moderate', event.workspaceRoot));
|
|
38
|
+
void registerWorkspaceInstance(event.workspaceRoot).then((stop) => {
|
|
39
|
+
void stopInstanceHeartbeat().then(stop);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
26
42
|
const configManager = {
|
|
27
43
|
getConfig: () => config,
|
|
28
44
|
saveConfig: async (next) => {
|
|
@@ -73,6 +89,19 @@ async function main() {
|
|
|
73
89
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
74
90
|
throw new Error('Interactive mode requires a terminal. Use maw run --prompt "..." for non-interactive execution.');
|
|
75
91
|
await runtime.whenReady();
|
|
92
|
+
const worktreeArg = program.opts().worktree;
|
|
93
|
+
if (worktreeArg !== undefined) {
|
|
94
|
+
const manager = new WorktreeManager(process.cwd());
|
|
95
|
+
if (!await manager.isGitRepository()) {
|
|
96
|
+
console.error('--worktree requires a git repository');
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const name = typeof worktreeArg === 'string' && worktreeArg.trim() ? worktreeArg.trim() : `session-${Date.now()}`;
|
|
101
|
+
const info = await manager.create(name);
|
|
102
|
+
await runtime.changeWorkspace(info.path);
|
|
103
|
+
process.stdout.write(`worktree ready: ${info.path} (${info.branch})\n`);
|
|
104
|
+
}
|
|
76
105
|
const requested = selectedFromCli();
|
|
77
106
|
const selected = resolveModelConfig(config, requested);
|
|
78
107
|
runtime.setDefaultModel(requested ?? config.model);
|
|
@@ -90,6 +119,7 @@ async function main() {
|
|
|
90
119
|
});
|
|
91
120
|
const shutdown = async () => {
|
|
92
121
|
await runtime.shutdown().catch(() => undefined);
|
|
122
|
+
await stopInstanceHeartbeat().catch(() => undefined);
|
|
93
123
|
process.exit(0);
|
|
94
124
|
};
|
|
95
125
|
process.once('SIGTERM', () => { void shutdown(); });
|
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
|
+
}
|