atom-agent 1.0.0 → 1.2.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +17 -16
  3. package/dist/App.js +1010 -77
  4. package/dist/adapters.js +108 -8
  5. package/dist/agent/gates.js +14 -1
  6. package/dist/agent/loop-guard.js +182 -0
  7. package/dist/agent/loop.js +781 -329
  8. package/dist/agent/normalize.js +151 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/compact.js +128 -2
  11. package/dist/env-block.js +43 -5
  12. package/dist/scheduler.js +101 -21
  13. package/dist/sessions.js +524 -0
  14. package/dist/system.js +89 -12
  15. package/dist/telemetry-dashboard.js +19 -1
  16. package/dist/telemetry.js +55 -0
  17. package/dist/tools/dir-cache.js +214 -0
  18. package/dist/tools/filesystem.js +43 -3
  19. package/dist/tools/read-cache.js +160 -0
  20. package/dist/tools/registry.js +80 -0
  21. package/dist/tools/ripgrep.js +256 -0
  22. package/dist/tools/search.js +147 -80
  23. package/dist/tools/shared.js +39 -0
  24. package/dist/tools/shell.js +26 -5
  25. package/dist/tools/todo.js +1 -1
  26. package/dist/tools/web.js +6 -6
  27. package/dist/tools.js +3 -0
  28. package/dist/ui/diff-panel.js +55 -0
  29. package/dist/ui/diff-view.js +117 -0
  30. package/dist/ui/diff.js +422 -0
  31. package/dist/ui/highlight.js +120 -0
  32. package/dist/ui/live-host.js +18 -0
  33. package/dist/ui/live-tail.js +9 -3
  34. package/dist/ui/markdown.js +26 -2
  35. package/dist/ui/modals.js +22 -5
  36. package/dist/ui/palette.js +12 -2
  37. package/dist/ui/side-by-side.js +144 -0
  38. package/dist/ui/status-bar.js +20 -4
  39. package/dist/ui/status-host.js +22 -0
  40. package/dist/ui/stream-store.js +48 -0
  41. package/dist/ui/theme.js +6 -0
  42. package/dist/ui/todo-panel.js +10 -2
  43. package/dist/ui/tool-inspector.js +7 -1
  44. package/dist/ui/transcript.js +105 -39
  45. package/dist/zen.js +97 -20
  46. package/package.json +1 -1
@@ -0,0 +1,151 @@
1
+ import { truncateHead } from "../tools/shared.js";
2
+ // Safety net for custom executors (built-in tools already cap: read 64KB
3
+ // head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
4
+ // + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
5
+ // pointers included) pass through byte-identical; only oversized custom
6
+ // results truncate.
7
+ export const TOOL_RESULT_CAP_CHARS = 128 * 1024;
8
+ export function normalizeToolResult(result) {
9
+ let text;
10
+ if (typeof result === "string") {
11
+ text = result;
12
+ }
13
+ else if (result === null || result === undefined) {
14
+ return "Error: tool returned no result";
15
+ }
16
+ else {
17
+ try {
18
+ const json = JSON.stringify(result);
19
+ text = typeof json === "string" ? json : String(result);
20
+ }
21
+ catch {
22
+ try {
23
+ text = String(result);
24
+ }
25
+ catch {
26
+ return "Error: tool returned an unreadable result";
27
+ }
28
+ }
29
+ }
30
+ if (text.length > TOOL_RESULT_CAP_CHARS) {
31
+ const t = truncateHead(text, TOOL_RESULT_CAP_CHARS, `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
32
+ return t.head + t.note;
33
+ }
34
+ return text;
35
+ }
36
+ // Recursively key-sorted JSON for stable signatures. Falls back to a short
37
+ // type tag when unstringifiable (never throws, never aliases objects with
38
+ // strings: prefixes the tag).
39
+ function stableStringify(value) {
40
+ try {
41
+ return JSON.stringify(sortKeys(value)) ?? "undefined";
42
+ }
43
+ catch {
44
+ return `unstringifiable:${typeof value}`;
45
+ }
46
+ }
47
+ function sortKeys(value) {
48
+ if (Array.isArray(value))
49
+ return value.map(sortKeys);
50
+ if (typeof value === "object" && value !== null) {
51
+ const out = {};
52
+ for (const k of Object.keys(value).sort()) {
53
+ out[k] = sortKeys(value[k]);
54
+ }
55
+ return out;
56
+ }
57
+ return value;
58
+ }
59
+ // Stable repetition/cache key: `name` + canonical args. Parsed args come
60
+ // from JSON.parse (insertion-ordered), so sorting closes the alias where
61
+ // `{"a":1,"b":2}` and `{"b":2,"a":1}` would otherwise count as different.
62
+ export function toolSignature(name, parsed) {
63
+ return `${name} ${stableStringify(parsed ?? {})}`;
64
+ }
65
+ // Defensive validation of one assistant message. Never throws: malformed
66
+ // tool_calls entries are dropped with a warning (the caller surfaces them
67
+ // via onWarning so the transcript shows what the model attempted); a fully
68
+ // unusable message becomes empty final text (the loop's turn-end gates then
69
+ // decide, exactly as if the model sent empty content).
70
+ export function normalizeChatResult(raw) {
71
+ const warnings = [];
72
+ if (typeof raw !== "object" || raw === null) {
73
+ return { result: { content: null }, warnings: ["model returned a non-object message"] };
74
+ }
75
+ const m = raw;
76
+ const contentRaw = m["content"];
77
+ const content = typeof contentRaw === "string"
78
+ ? contentRaw
79
+ : contentRaw === null || contentRaw === undefined
80
+ ? null
81
+ : (() => {
82
+ try {
83
+ return JSON.stringify(contentRaw);
84
+ }
85
+ catch {
86
+ return String(contentRaw);
87
+ }
88
+ })();
89
+ const callsRaw = m["calls"] ?? m["tool_calls"];
90
+ if (callsRaw === undefined) {
91
+ const result = { content };
92
+ if (m["usage"] !== undefined)
93
+ result["usage"] = m["usage"];
94
+ if (m["reasoning"] !== undefined)
95
+ result["reasoning"] = m["reasoning"];
96
+ // Length-truncation flag survives normalization (no calls or not — the
97
+ // loop decides; truncated-without-calls behaves as before).
98
+ if (m["truncated"] === true)
99
+ result.truncated = true;
100
+ return { result: result, warnings };
101
+ }
102
+ if (!Array.isArray(callsRaw)) {
103
+ warnings.push("model tool_calls was not an array — ignored");
104
+ return { result: { content, tool_calls: undefined }, warnings };
105
+ }
106
+ const calls = [];
107
+ for (let i = 0; i < callsRaw.length; i++) {
108
+ const entry = callsRaw[i];
109
+ if (typeof entry !== "object" || entry === null) {
110
+ warnings.push(`dropped malformed tool call at index ${i} (not an object)`);
111
+ continue;
112
+ }
113
+ const fn = entry["function"];
114
+ const name = fn?.["name"];
115
+ if (typeof name !== "string" || name.length === 0) {
116
+ const id = typeof entry["id"] === "string" ? entry["id"] : `#${i}`;
117
+ warnings.push(`dropped tool call ${id} with no function name`);
118
+ continue;
119
+ }
120
+ const id = typeof entry["id"] === "string" && entry["id"].length > 0
121
+ ? entry["id"]
122
+ : `call-${i}`;
123
+ const argsRaw = fn?.["arguments"];
124
+ let args;
125
+ if (typeof argsRaw === "string")
126
+ args = argsRaw;
127
+ else if (argsRaw === undefined || argsRaw === null)
128
+ args = "{}";
129
+ else {
130
+ try {
131
+ args = JSON.stringify(argsRaw) ?? "{}";
132
+ }
133
+ catch {
134
+ warnings.push(`dropped tool call ${id} with unstringifiable arguments`);
135
+ continue;
136
+ }
137
+ }
138
+ const call = { id, function: { name, arguments: args } };
139
+ if (typeof entry["type"] === "string")
140
+ call.type = entry["type"];
141
+ calls.push(call);
142
+ }
143
+ const out = { content, tool_calls: calls.length > 0 ? calls : undefined };
144
+ if (m["usage"] !== undefined)
145
+ out["usage"] = m["usage"];
146
+ if (m["reasoning"] !== undefined)
147
+ out["reasoning"] = m["reasoning"];
148
+ if (m["truncated"] === true)
149
+ out.truncated = true;
150
+ return { result: out, warnings };
151
+ }
package/dist/cli.js CHANGED
@@ -60,7 +60,7 @@ Env:
60
60
  OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
61
61
  OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
62
62
  OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
63
- Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /mode | /clear | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
63
+ Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
64
64
  Providers: kilo (default; anonymous free models, key optional)/opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one) + local auto-discovery: ollama (:11434), lmstudio (:1234), llamacpp (:8080) — no keys needed, overrides via ATOM_OLLAMA_URL/ATOM_LMSTUDIO_URL/ATOM_LLAMACPP_URL.
65
65
  Note: reasoning_effort is sent only for opencode-zen supported models.`);
66
66
  process.exit(0);
@@ -86,5 +86,19 @@ const envModel = process.env.OPENCODE_ZEN_MODEL?.trim() || undefined;
86
86
  // --serve parks above (the server holds the event loop), so the TUI must
87
87
  // never start alongside it: serve is a standalone mode like --dashboard.
88
88
  if (!args.includes("--serve")) {
89
- render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true }));
89
+ // Production frame policy (Ink 7.1.1):
90
+ // - incrementalRendering: only changed terminal lines rewrite per frame.
91
+ // Streaming paints touch the live tail + status bar, not the scrollback,
92
+ // so this cuts flicker and stdout bytes on every token flush.
93
+ // Escape hatch: ATOM_INCREMENTAL=0 restores full-frame rendering.
94
+ // - maxFps: 30 keeps keystroke-to-paint latency low; token paints already
95
+ // coalesce to ~15fps via DRAFT_THROTTLE_MS, so Ink never does extra work.
96
+ // - concurrent: enables React concurrent features (useTransition /
97
+ // useDeferredValue) for future deferral of expensive subtrees.
98
+ // Tests are unaffected: they render via ink-testing-library, not here.
99
+ render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true }), {
100
+ incrementalRendering: process.env.ATOM_INCREMENTAL !== "0",
101
+ maxFps: 30,
102
+ concurrent: true,
103
+ });
90
104
  }
package/dist/compact.js CHANGED
@@ -20,6 +20,7 @@ import { chatCompletionForProvider, } from "./zen.js";
20
20
  // ContextManager module; compact.ts imports what its splitter needs and
21
21
  // re-exports the stable surface so existing importers keep working untouched.
22
22
  import { estimateTokensForChars, messageChars } from "./context-manager.js";
23
+ import { truncateHead } from "./tools/shared.js";
23
24
  export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
24
25
  // ---- Constants ----
25
26
  export const COMPACT_KEEP_TOKENS = 8000;
@@ -62,10 +63,12 @@ function turnChars(history, start, end) {
62
63
  export function capToolOutputsInTail(tail) {
63
64
  return tail.map((m) => {
64
65
  if (m.role === "tool" && typeof m.content === "string" && m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
66
+ // Line-aware cap (issue 04): the retained head never ends mid-line;
67
+ // cap value and legacy note prefix are unchanged.
68
+ const t = truncateHead(m.content, COMPACT_TOOL_OUTPUT_CAP, "\n[truncated: tool output exceeded 2000 chars]");
65
69
  return {
66
70
  ...m,
67
- content: m.content.slice(0, COMPACT_TOOL_OUTPUT_CAP) +
68
- "\n[truncated: tool output exceeded 2000 chars]",
71
+ content: t.head + t.note,
69
72
  };
70
73
  }
71
74
  return { ...m };
@@ -233,3 +236,126 @@ export async function requestCompactSummary(req) {
233
236
  }
234
237
  }
235
238
  }
239
+ function pushUniquePath(list, p) {
240
+ if (p.length === 0 || list.includes(p))
241
+ return;
242
+ list.push(p);
243
+ }
244
+ // Collect read/modified paths from committed tool_calls in head (insertion
245
+ // order, unique). Unparseable arguments are skipped — a bad payload must
246
+ // never break compaction. A path both read and written lands in modified
247
+ // only (the write implies the read).
248
+ export function collectTouchedFiles(head) {
249
+ const read = [];
250
+ const modified = [];
251
+ for (const m of head) {
252
+ if (m?.role !== "assistant")
253
+ continue;
254
+ const calls = m.tool_calls;
255
+ if (!Array.isArray(calls))
256
+ continue;
257
+ for (const c of calls) {
258
+ const fn = c?.function;
259
+ if (typeof fn?.name !== "string")
260
+ continue;
261
+ let p = "";
262
+ try {
263
+ const args = typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : null;
264
+ const raw = args?.path;
265
+ if (typeof raw === "string")
266
+ p = raw.trim();
267
+ }
268
+ catch {
269
+ continue; // unparseable args pin nothing
270
+ }
271
+ if (p.length === 0)
272
+ continue;
273
+ if (fn.name === "write" || fn.name === "edit")
274
+ pushUniquePath(modified, p);
275
+ else if (fn.name === "read")
276
+ pushUniquePath(read, p);
277
+ }
278
+ }
279
+ // Modified implies read: keep modified entries out of the read list.
280
+ const modifiedSet = new Set(modified);
281
+ return { read: read.filter((p) => !modifiedSet.has(p)), modified };
282
+ }
283
+ // Canonical on-disk + on-resume format. Empty sections are omitted; both
284
+ // empty renders "" (the caller then appends nothing).
285
+ export function formatTouchedFiles(t) {
286
+ const lines = ["Touched files:"];
287
+ if (t.read.length > 0)
288
+ lines.push(`Read: ${t.read.join(", ")}`);
289
+ if (t.modified.length > 0)
290
+ lines.push(`Modified: ${t.modified.join(", ")}`);
291
+ return lines.length > 1 ? lines.join("\n") : "";
292
+ }
293
+ export function appendTouchedFiles(summaryText, touched) {
294
+ const block = formatTouchedFiles(touched);
295
+ if (!block)
296
+ return summaryText;
297
+ return `${summaryText}\n\n${block}`;
298
+ }
299
+ // ---- Size budget for the summary message ----
300
+ // The summary text itself is never cut — only the file lists shrink to fit,
301
+ // so an over-budget summary degrades gracefully instead of failing
302
+ // compaction. Budget = the summary output cap via the 4ch/token estimator.
303
+ export const COMPACT_SUMMARY_MAX_CHARS = COMPACT_SUMMARY_MAX_TOKENS * COMPACT_CHARS_PER_TOKEN;
304
+ // Shrink the lists until the formatted block fits maxChars. Drops the oldest
305
+ // entry from the longer list (ties: read first — modifications are the
306
+ // higher-signal list). Never throws; an empty result formats to "".
307
+ export function truncateTouchedFiles(touched, maxChars) {
308
+ const read = [...touched.read];
309
+ const modified = [...touched.modified];
310
+ while ((read.length > 0 || modified.length > 0) &&
311
+ formatTouchedFiles({ read, modified }).length > maxChars) {
312
+ if (read.length >= modified.length)
313
+ read.shift();
314
+ else
315
+ modified.shift();
316
+ }
317
+ return { read, modified };
318
+ }
319
+ // Append file lists to the summary within budget: shrink the lists (never
320
+ // the model text) until summary + block fits; when nothing fits, the summary
321
+ // stands alone and compaction still succeeds.
322
+ export function fitSummaryWithFiles(summaryText, touched, maxChars = COMPACT_SUMMARY_MAX_CHARS) {
323
+ const before = touched.read.length + touched.modified.length;
324
+ const block = formatTouchedFiles(touched);
325
+ if (!block)
326
+ return { text: summaryText, truncated: false };
327
+ if (summaryText.length + 2 + block.length <= maxChars) {
328
+ return { text: `${summaryText}\n\n${block}`, truncated: false };
329
+ }
330
+ const room = Math.max(0, maxChars - summaryText.length - 2);
331
+ const shrunk = truncateTouchedFiles(touched, room);
332
+ const shrunkBlock = formatTouchedFiles(shrunk);
333
+ const truncated = shrunk.read.length + shrunk.modified.length < before;
334
+ if (!shrunkBlock)
335
+ return { text: summaryText, truncated };
336
+ return { text: `${summaryText}\n\n${shrunkBlock}`, truncated };
337
+ }
338
+ // ---- Resume surfacing ----
339
+ // Pull the stored block(s) verbatim out of compacted summary messages — the
340
+ // same format as stored, no reformatting. lastIndexOf prefers the appended
341
+ // block (ours is always last; model prose comes first).
342
+ export function extractTouchedFilesSection(text) {
343
+ const idx = text.lastIndexOf("Touched files:");
344
+ if (idx < 0)
345
+ return null;
346
+ const section = text.slice(idx).trimEnd();
347
+ return section.length > 0 ? section : null;
348
+ }
349
+ export function collectStoredTouchedFiles(history) {
350
+ const out = [];
351
+ for (const m of history) {
352
+ if (m?.role !== "user" || typeof m.content !== "string")
353
+ continue;
354
+ if (!m.content.includes("[Compacted context"))
355
+ continue;
356
+ const section = extractTouchedFilesSection(m.content);
357
+ if (section)
358
+ out.push(section);
359
+ }
360
+ return out;
361
+ }
package/dist/env-block.js CHANGED
@@ -6,14 +6,15 @@
6
6
  // content via withEnvBlock), NEVER into user content. history[0] is the only
7
7
  // slot truncateHistory never drops, so the block survives budget trimming.
8
8
  // Caching: the App refreshes history[0] once per turn in submit() (before the
9
- // budget check, so truncation accounts for it) — the loop's up-to-30 POSTs
10
- // reuse the same history[0], so git is shelled at most once per turn.
9
+ // budget check, so truncation accounts for it) — the loop's POSTs reuse the
10
+ // same history[0], so git is shelled at most once per turn.
11
11
  // Failure-silent: missing git / non-repo cwd / timeout → the block shrinks
12
12
  // (cwd + node + time only), never throws, never blocks the turn. No new
13
13
  // dependencies; one cheap `git status` invocation with a short timeout, and
14
14
  // zero shell-outs when `.git` is absent.
15
15
  import { execFileSync } from "node:child_process";
16
16
  import { existsSync } from "node:fs";
17
+ import * as os from "node:os";
17
18
  import * as path from "node:path";
18
19
  // Cap for the block itself (~500 chars per the task). The base system prompt
19
20
  // (SYSTEM_PROMPT + AGENTS.md overlay) is untouched by this cap.
@@ -30,8 +31,9 @@ function shortCwd(cwd) {
30
31
  return `…${cwd.slice(cwd.length - (CWD_DISPLAY_CAP - 1))}`;
31
32
  }
32
33
  // Pure formatter (no I/O): always `cwd + node + time`, plus
33
- // `branch + status` only when git reported them. Capped to
34
- // ENV_BLOCK_CHAR_CAP (cwd is pre-truncated so time/node survive the cap).
34
+ // `branch + status` only when git reported them, plus the operating context
35
+ // (`os + shell + user`) when provided. Capped to ENV_BLOCK_CHAR_CAP (cwd is
36
+ // pre-truncated so time/node survive the cap).
35
37
  export function buildEnvBlock(parts) {
36
38
  const cwd = shortCwd(parts.cwd);
37
39
  const git = parts.branch !== undefined &&
@@ -39,7 +41,10 @@ export function buildEnvBlock(parts) {
39
41
  parts.branch.length > 0
40
42
  ? ` branch=${parts.branch} status=${parts.status ?? "unknown"}`
41
43
  : "";
42
- const block = `[env cwd=${cwd}${git} node=${parts.nodeVersion} time=${parts.timestamp}]`;
44
+ const machine = parts.os !== undefined && parts.os !== null && parts.os.length > 0
45
+ ? ` os=${parts.os} shell=${parts.shell ?? "unknown"} user=${parts.user ?? "unknown"}`
46
+ : "";
47
+ const block = `[env cwd=${cwd}${git}${machine} node=${parts.nodeVersion} time=${parts.timestamp}]`;
43
48
  return block.length > ENV_BLOCK_CHAR_CAP
44
49
  ? `${block.slice(0, ENV_BLOCK_CHAR_CAP - 1)}]`
45
50
  : block;
@@ -117,12 +122,45 @@ export function getEnvBlock(cwd = process.cwd()) {
117
122
  catch {
118
123
  git = null;
119
124
  }
125
+ // Operating context (best-effort, never throws): tells the model which
126
+ // shell runs its commands and who/where it is, so it stops probing with
127
+ // whoami/hostname and guessing `ls` vs `dir` (measured: 2–4 wasted bash
128
+ // calls per task before this existed).
129
+ let osName = "unknown";
130
+ try {
131
+ if (typeof process.platform === "string" && process.platform.length > 0) {
132
+ osName = process.platform;
133
+ }
134
+ }
135
+ catch {
136
+ // keep fallback
137
+ }
138
+ let shell = "unknown";
139
+ try {
140
+ shell = process.platform === "win32" ? "cmd.exe" : "sh";
141
+ }
142
+ catch {
143
+ // keep fallback
144
+ }
145
+ let user = "unknown";
146
+ try {
147
+ const name = os.userInfo?.().username;
148
+ if (typeof name === "string" && name.length > 0) {
149
+ user = name;
150
+ }
151
+ }
152
+ catch {
153
+ // keep fallback (sandboxed runtimes may forbid userInfo)
154
+ }
120
155
  return buildEnvBlock({
121
156
  cwd: dir,
122
157
  branch: git?.branch ?? null,
123
158
  status: git?.status ?? null,
124
159
  nodeVersion,
125
160
  timestamp,
161
+ os: osName,
162
+ shell,
163
+ user,
126
164
  });
127
165
  }
128
166
  catch {
package/dist/scheduler.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // Effect-aware tool scheduler: data-driven parallelism without Promise.all-
2
2
  // ing every tool.
3
3
  //
4
- // The conservative contract (unchanged): batchable reads run concurrently,
5
- // mutations stay serialized, results commit in original call order, cancel
6
- // stops between batches, approval happens per call inside runOneTool. This
7
- // module only PLANS batches; execution (zen.ts runLoopWithChat) is untouched.
4
+ // The contract: batchable calls run concurrently, conflicting calls stay
5
+ // serialized, results commit in original call order, cancel stops between
6
+ // batches, approval happens per call in order before execution. This module
7
+ // only PLANS batches; execution (agent/loop.ts runLoopWithChat) is untouched.
8
8
  //
9
9
  // How it reasons (per tool, from the TOOL_EFFECTS table — the single
10
10
  // coupling point; the algorithm below has no per-tool branches):
@@ -16,23 +16,33 @@
16
16
  // become inline-error singletons downstream, exactly as before).
17
17
  // - interactive (ask_question) or exclusive (shared ambient state the effect
18
18
  // model cannot see: todowrite/todo_update/todo_get) → serial singleton.
19
- // - any filesystem/network WRITE, or any process SPAWN → serial singleton.
20
- // Writes conflict GLOBALLY (not just same-target): a write splits the
21
- // block and read-after-write stays ordered. Target-scoped write batching
22
- // is a deliberate non-goal correctness over theoretical parallelism.
23
- // - reads (filesystem or network) batch with pairwise-disjoint keys, where
24
- // the key is tool + target. Same tool + same target serializes (the old
25
- // overlap rule, kept verbatim: e.g. two reads of one path). Reads never
26
- // conflict across keys an all-read batch cannot race a writer, because
27
- // writers never join batches.
19
+ // - process SPAWN or network WRITE (bash: unbounded footprint) → serial
20
+ // singleton, splitting the block globally.
21
+ // - filesystem writes (write/edit) batch on disjoint canonical file keys
22
+ // (see canonicalFileKey): same-file mutations never share a batch, so they
23
+ // stay strictly ordered in program order; different files run concurrently.
24
+ // An unresolvable target stays serial (never batch what you cannot see).
25
+ // - reads batch with pairwise-disjoint keys, where the key is tool + target.
26
+ // Same tool + same target serializes (the old overlap rule, kept verbatim:
27
+ // e.g. two reads of one path). A path read also conflicts with an open
28
+ // write to the same canonical file (read-after-write stays ordered), and a
29
+ // write conflicts with any open member on its key (write-after-read stays
30
+ // ordered). Directory-scoped scans (grep/glob) vs concurrent writes to
31
+ // unscanned-listed files are out of scope — same exposure as an editor
32
+ // saving mid-scan; only same-canonical-path pairs are ordered.
28
33
  // - deterministic is declared per tool; its current enforcement is the
29
34
  // same-key rule (a re-poll of the same background task, whose output can
30
35
  // grow, never runs concurrently with itself).
31
36
  //
32
37
  // Pure module except the shared arg validators (same imports zen.ts already
33
- // carries — no new coupling class). Covered by tests/scheduler.test.ts; the
34
- // end-to-end ordering/cancel behavior stays pinned by
35
- // tests/parallel-calls.test.ts.
38
+ // carries — no new coupling class) plus best-effort path canonicalization
39
+ // (node:fs/node:path only, so the architecture boundary is unchanged:
40
+ // scheduler still reasons from metadata, never from tool branches).
41
+ // Covered by tests/scheduler.test.ts; the end-to-end ordering/cancel
42
+ // behavior stays pinned by tests/parallel-calls.test.ts and
43
+ // tests/parallel-writes.test.ts.
44
+ import * as fs from "node:fs";
45
+ import * as path from "node:path";
36
46
  import { toolNames, validateToolArgs } from "./tools.js";
37
47
  const str = (v) => (typeof v === "string" ? v : "");
38
48
  const targetOf = (key) => (args) => {
@@ -166,22 +176,65 @@ export const TOOL_EFFECTS = {
166
176
  target: () => null,
167
177
  },
168
178
  };
179
+ // Canonical per-file mutation key: the identity two mutation calls compare
180
+ // before sharing a batch. Lexical resolve against the process cwd (the same
181
+ // base the executors default to), then best-effort symlink resolution, then
182
+ // case-folding on case-insensitive filesystems. Null when the target is
183
+ // missing, empty, or unresolvable — unknown footprints stay serial. Sync and
184
+ // best-effort by design: a handful of calls per tool block, and a miss only
185
+ // costs parallelism, never correctness.
186
+ export function canonicalFileKey(rawPath, cwd = process.cwd()) {
187
+ if (typeof rawPath !== "string" || rawPath.length === 0)
188
+ return null;
189
+ if (rawPath.includes("\0"))
190
+ return null;
191
+ let abs;
192
+ try {
193
+ abs = path.resolve(cwd, rawPath);
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ try {
199
+ abs = fs.realpathSync(abs);
200
+ }
201
+ catch {
202
+ // Fresh write target or unreadable link: the lexical path stands.
203
+ }
204
+ try {
205
+ abs = path.normalize(abs);
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ if (process.platform === "win32" || process.platform === "darwin") {
211
+ abs = abs.toLowerCase();
212
+ }
213
+ return abs;
214
+ }
169
215
  // Partition one assistant message's tool_calls into commit batches,
170
216
  // preserving program order: consecutive batchable calls with pairwise
171
217
  // disjoint keys form one batch; any serial-only call — and any call whose
172
218
  // key already appears in the open batch — closes the batch and runs as a
173
- // strict serial singleton. A later batch never moves ahead of an earlier
174
- // serial call (read-after-write stays ordered), and batches never span the
175
- // block boundary.
219
+ // strict serial singleton. Filesystem writes join a batch only on a
220
+ // disjoint canonical file key (same-file mutations split into sequential
221
+ // batches, never concurrent); a path read conflicting with an open write —
222
+ // or a write conflicting with any open member — on the same canonical key
223
+ // also splits, so per-file program order always holds. A later batch never
224
+ // moves ahead of an earlier serial call, and batches never span the block
225
+ // boundary.
176
226
  export function planBatches(calls) {
177
227
  const batches = [];
178
228
  let open = [];
179
229
  const keys = new Set();
230
+ // Canonical file keys of the open batch ("read" and/or "write" per key).
231
+ const openFiles = new Map();
180
232
  const flush = () => {
181
233
  if (open.length > 0) {
182
234
  batches.push(open);
183
235
  open = [];
184
236
  keys.clear();
237
+ openFiles.clear();
185
238
  }
186
239
  };
187
240
  const singleton = (call, parsed) => {
@@ -212,15 +265,28 @@ export function planBatches(calls) {
212
265
  singleton(call, parsed);
213
266
  continue;
214
267
  }
215
- // Interactive, ambient-state, mutating, or spawning calls serialize.
268
+ // Interactive, ambient-state, spawning, or network-writing calls
269
+ // serialize (bash has no statically visible footprint: global conflict).
216
270
  if (meta.interactive ||
217
271
  meta.exclusive ||
218
- meta.filesystem === "write" ||
219
272
  meta.network === "write" ||
220
273
  meta.process !== "none") {
221
274
  singleton(call, parsed);
222
275
  continue;
223
276
  }
277
+ // Filesystem writes batch on disjoint canonical file keys: the
278
+ // per-file mutation queue. Same-file mutations split into sequential
279
+ // batches (never interleave); disjoint files run concurrently.
280
+ if (meta.filesystem === "write") {
281
+ const fileKey = canonicalFileKey(parsed["path"]);
282
+ if (!fileKey || openFiles.has(fileKey)) {
283
+ singleton(call, parsed);
284
+ continue;
285
+ }
286
+ openFiles.set(fileKey, "write");
287
+ open.push({ call, parsed, parallelKey: `${name} ${fileKey}` });
288
+ continue;
289
+ }
224
290
  // Reads batch on disjoint tool+target keys; empty target = unknown
225
291
  // footprint = serial.
226
292
  let target = null;
@@ -239,6 +305,20 @@ export function planBatches(calls) {
239
305
  singleton(call, parsed);
240
306
  continue;
241
307
  }
308
+ // A path read against an open write to the same canonical file stays
309
+ // ordered (read-after-write): split the batch.
310
+ if (name === "read") {
311
+ const fileKey = canonicalFileKey(target);
312
+ if (fileKey && openFiles.get(fileKey) === "write") {
313
+ singleton(call, parsed);
314
+ continue;
315
+ }
316
+ keys.add(key);
317
+ if (fileKey && !openFiles.has(fileKey))
318
+ openFiles.set(fileKey, "read");
319
+ open.push({ call, parsed, parallelKey: key });
320
+ continue;
321
+ }
242
322
  keys.add(key);
243
323
  open.push({ call, parsed, parallelKey: key });
244
324
  }