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 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
- - `Ctrl+C` exits. Runtime errors appear in the conversation.
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 coordination. Execution tasks, including saving an HTML page, are delegated to coordinators by default; coordinators select specialists. Main yields after handing off work and is automatically resumed by agent results. Its broad tool access remains available for bounded checks and fallback, subject to the configured workspace policy. The scheduler reserves user-facing capacity independently of the background concurrency limit.
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
 
@@ -7,7 +7,6 @@ agents:
7
7
  - '*'
8
8
  ---
9
9
 
10
- You coordinate a complex workstream for the parent agent. Understand the goal, inspect the available agent catalog, and decide your own workflow. Delegate concrete work to the smallest useful set of specialist agents. Use parallel agents only when their work is independent. Send corrections to an existing instance instead of spawning duplicates.
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 to the user, not doing all the work yourself. Answer conversational questions directly. For requests involving implementation, file creation, investigation, research, or verification, briefly acknowledge the concrete task and delegate execution to a coordinator by default, including small tasks such as saving an HTML page. Give it the user's objective, target paths, constraints, and acceptance checks. Do not perform a long sequence of execution tools before delegating.
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, finish your current response so you 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 the user sends a follow-up, respond promptly and forward relevant changes to the existing coordinator without restarting unrelated background 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
- yield { content: null, done: true };
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(); });
@@ -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
- const release = await ctx?.acquireWriteLock?.(path);
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
- async function readLineRange(filePath, offset = 1, limit) {
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
- return await readLineRange(resolveToolPath(path, ctx), offsetArg, limitArg);
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
- sections.push(`===== ${path} =====\n${await readLineRange(resolveToolPath(path, ctx), 1, maxLines)}`);
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(src, search);
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
- 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}`;
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 (numberedSearch !== undefined && numberedSearch !== search && !variants.includes(numberedSearch))
1051
- variants.push(numberedSearch);
1052
- if (unescapedSearch !== search && !variants.includes(unescapedSearch))
1053
- variants.push(unescapedSearch);
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 writtenPath = await writeViaWorkspace(path, content, ctx);
1106
- const written = await readFile(writtenPath, 'utf8');
1107
- for (const edit of applied) {
1108
- if (edit.replace === '') {
1109
- if (countOccurrences(written, edit.search) !== 0) {
1110
- return `Error: readback verification failed for ${path}: deleted text is still present after write.`;
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
- else if (countOccurrences(written, edit.replace) < edit.made) {
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(written).digest('hex').slice(0, 12);
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 = written.split('\n').length;
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
- return `Error writing edited file: ${String(error)}`;
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
+ }