@lazyingart/agintiflow 0.8.10 → 0.8.12

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
@@ -28,7 +28,7 @@ It is designed for workflows where an AI agent should act, but every tool, log,
28
28
  | Core loop | Plan -> use tools -> log events -> finish or resume |
29
29
  | Browser control | Playwright, lazy browser startup, domain allowlists |
30
30
  | Model layer | Smart routing over DeepSeek fast/pro presets with manual OpenAI-compatible fallback |
31
- | Local tools | Guarded workspace file tools, optional shell commands, Docker sandbox support, and advisory agent wrappers |
31
+ | Local tools | Guarded workspace file tools, Codex-style patching, optional shell commands, Docker sandbox support, and advisory agent wrappers |
32
32
  | Memory | Session state, persisted web settings, chat continuation |
33
33
  | Operator UX | Multilingual web UI with provider selection, run output, and conversation history |
34
34
 
@@ -65,6 +65,8 @@ aginti chat
65
65
 
66
66
  Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
67
67
 
68
+ For code edits, AgInTiFlow routes patch/refactor/database-style tasks to DeepSeek v4 pro by default and exposes `apply_patch` as a deterministic workspace tool. It supports exact replacements, Codex-style patch envelopes, and unified diffs, with preflight checks, path guardrails, hashes, and compact per-file diffs. See [docs/patch-tools.md](docs/patch-tools.md).
69
+
68
70
  Launch the local web UI from an installed package:
69
71
 
70
72
  ```bash
@@ -0,0 +1,17 @@
1
+ # Patch Tools
2
+
3
+ AgInTiFlow exposes a deterministic `apply_patch` workspace tool for coding-agent edits. It is designed for DeepSeek v4 pro and other routed models to make auditable code changes without relying on free-form shell redirection.
4
+
5
+ ## Supported Patch Modes
6
+
7
+ - Exact replacement: pass `path`, `search`, `replace`, and optionally `expectedReplacements` or `baseHash`.
8
+ - Codex-style patch envelope: pass `patch` with `*** Begin Patch`, `*** Update File`, `*** Add File`, `*** Delete File`, and `*** End Patch`.
9
+ - Unified diff: pass `patch` with standard `--- a/file`, `+++ b/file`, and `@@` hunks.
10
+
11
+ All paths must stay inside the configured workspace. Secret-like paths, `.git`, `node_modules` writes, binary files, and huge files are blocked. Multi-file patches are preflighted before writing, and each changed file records before/after hashes plus a compact diff in the session events.
12
+
13
+ ## Agent Workflow
14
+
15
+ For large codebases, the model should first use `list_files`, `search_files`, and `read_file` to identify the relevant files. It should then call `apply_patch`, run safe tests or linters when available, and summarize changed files and residual risk.
16
+
17
+ Smart routing treats patch/refactor/edit/database tasks as complex work, so DeepSeek v4 pro is selected by default unless the user explicitly chooses another route.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
@@ -5,7 +5,9 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { repairModelMessageHistory, runAgent } from "../src/agent-runner.js";
7
7
  import { resolveRuntimeConfig } from "../src/config.js";
8
+ import { selectModelRoute } from "../src/model-routing.js";
8
9
  import { SessionStore } from "../src/session-store.js";
10
+ import { executeWorkspaceTool } from "../src/workspace-tools.js";
9
11
 
10
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
13
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-coding-tools-"));
@@ -78,6 +80,12 @@ try {
78
80
  !staleDeepSeekState.messages.some((message) => message.role === "tool" && message.tool_call_id === "stale-call"),
79
81
  "repaired DeepSeek history retained an orphan stale tool message"
80
82
  );
83
+ const patchRoute = selectModelRoute({
84
+ routingMode: "smart",
85
+ provider: "deepseek",
86
+ goal: "patch this large codebase and migrate the database tests",
87
+ });
88
+ assert(/pro/i.test(patchRoute.model), "patch/refactor task did not route to DeepSeek pro");
81
89
 
82
90
  const writeRun = await runMock("Create notes/hello.md with a short coding smoke message.", "coding-write");
83
91
  const written = await fs.readFile(path.join(workspace, "notes/hello.md"), "utf8");
@@ -106,6 +114,78 @@ try {
106
114
  assert(patched === "new\n", "mock patch did not update expected file");
107
115
  assert(patchRun.events.some((event) => event.type === "file.changed"), "patch run did not persist file.changed event");
108
116
 
117
+ await fs.writeFile(path.join(workspace, "patch-target.txt"), "old\n", "utf8");
118
+ const multiPatchRun = await runMock("Apply multi-file Codex patch to replace old and add a note.", "coding-patch-multi");
119
+ const multiPatched = await fs.readFile(path.join(workspace, "patch-target.txt"), "utf8");
120
+ const patchNote = await fs.readFile(path.join(workspace, "notes/patch-note.md"), "utf8");
121
+ assert(multiPatched === "new\n", "mock multi-file patch did not update expected file");
122
+ assert(patchNote.includes("multi-file patch smoke"), "mock multi-file patch did not add expected file");
123
+ assert(
124
+ multiPatchRun.events.filter((event) => event.type === "file.changed").length >= 2,
125
+ "multi-file patch did not persist per-file change events"
126
+ );
127
+
128
+ await fs.writeFile(path.join(workspace, "unified-target.txt"), "alpha\nold\nomega\n", "utf8");
129
+ const unified = await executeWorkspaceTool(
130
+ "apply_patch",
131
+ {
132
+ patch: [
133
+ "--- a/unified-target.txt",
134
+ "+++ b/unified-target.txt",
135
+ "@@ -1,3 +1,3 @@",
136
+ " alpha",
137
+ "-old",
138
+ "+new",
139
+ " omega",
140
+ ].join("\n"),
141
+ },
142
+ {
143
+ commandCwd: workspace,
144
+ allowFileTools: true,
145
+ }
146
+ );
147
+ const unifiedText = await fs.readFile(path.join(workspace, "unified-target.txt"), "utf8");
148
+ assert(unified.ok && unifiedText === "alpha\nnew\nomega\n", "unified apply_patch did not update expected file");
149
+
150
+ const blockedPatch = await executeWorkspaceTool(
151
+ "apply_patch",
152
+ {
153
+ patch: ["*** Begin Patch", "*** Add File: .env", "+TOKEN=blocked", "*** End Patch"].join("\n"),
154
+ },
155
+ {
156
+ commandCwd: workspace,
157
+ allowFileTools: true,
158
+ }
159
+ );
160
+ assert(blockedPatch.blocked, "patch document to sensitive path was not blocked by guardrail");
161
+
162
+ await fs.writeFile(path.join(workspace, "move-source.txt"), "source\n", "utf8");
163
+ await fs.writeFile(path.join(workspace, "move-target.txt"), "target\n", "utf8");
164
+ const moveOverResult = await executeWorkspaceTool(
165
+ "apply_patch",
166
+ {
167
+ patch: [
168
+ "*** Begin Patch",
169
+ "*** Update File: move-source.txt",
170
+ "*** Move to: move-target.txt",
171
+ "@@",
172
+ "-source",
173
+ "+moved",
174
+ "*** End Patch",
175
+ ].join("\n"),
176
+ },
177
+ {
178
+ commandCwd: workspace,
179
+ allowFileTools: true,
180
+ }
181
+ )
182
+ .then(() => "")
183
+ .catch((error) => String(error?.message || error));
184
+ assert(
185
+ /move over an existing file/.test(moveOverResult),
186
+ "patch move over an existing file was not rejected"
187
+ );
188
+
109
189
  const envRun = await runMock("Create file: .env with blocked content.", "coding-block-env");
110
190
  await fs
111
191
  .access(path.join(workspace, ".env"))
@@ -135,11 +215,16 @@ try {
135
215
  workspace,
136
216
  checks: [
137
217
  "deepseek_history_repair",
218
+ "deepseek_pro_patch_route",
138
219
  "write_file",
139
220
  "duplicate_write_failed",
140
221
  "resume_session_write",
141
222
  "virtual_workspace_path",
142
223
  "apply_patch",
224
+ "multi_file_patch",
225
+ "unified_patch",
226
+ "patch_guardrail",
227
+ "patch_move_no_overwrite",
143
228
  "block_env",
144
229
  "block_outside",
145
230
  ],
@@ -271,7 +271,7 @@ function createInitialState(config, sessionId) {
271
271
  : "A host shell command tool is available under the configured trust policy."
272
272
  : "No shell command tool is available.",
273
273
  config.allowFileTools
274
- ? `Workspace file tools are available in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
274
+ ? `Workspace file tools are available in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. apply_patch supports exact single-file replacements plus Codex-style/unified multi-file patches; prefer it for source edits after reading/searching the relevant context. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
275
275
  : "No workspace file tools are available.",
276
276
  config.allowWrapperTools
277
277
  ? `External coding-agent wrappers are available as advisory tools only. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Wrapper status: ${wrapperStatusText()}.`
@@ -279,7 +279,7 @@ function createInitialState(config, sessionId) {
279
279
  `Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
280
280
  "A frontend canvas/artifacts tunnel exists. Use send_to_canvas when important markdown, diffs, screenshots, images, or workspace files should be highlighted in the UI. It is optional and ordinary final text can still go directly to finish.",
281
281
  "For visual-output requests such as draw, plot, graph, chart, diagram, figure, image, or visualization, proactively publish a canvas artifact even when the user does not mention canvas. If workspace file tools are enabled, prefer creating a small SVG or markdown artifact and call send_to_canvas with selected=true.",
282
- "Work like a practical coding agent: inspect when useful, edit with file tools, run safe checks when they add confidence, and keep outputs inside the workspace.",
282
+ "Work like a practical coding agent: inspect when useful, patch code with apply_patch, run safe checks when they add confidence, iterate on failures, and keep outputs inside the workspace.",
283
283
  "For large projects, decompose into useful files and milestones, implement a coherent minimal version first, then iterate with checks rather than only describing what you would do.",
284
284
  "For website/app/code/LaTeX/Python/C/shell tasks, create or edit real workspace files, run available build/compile/test commands, and surface artifacts through the canvas when useful.",
285
285
  "For research or web-search tasks, use browser tools or safe shell network tools when the current policy allows; cite or save useful sources in workspace notes when the task needs traceability.",
@@ -304,7 +304,7 @@ function createInitialState(config, sessionId) {
304
304
  : `Shell working directory: ${config.commandCwd}`
305
305
  : "",
306
306
  config.allowFileTools
307
- ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. Local preview tools available: open_workspace_file and preview_workspace.`
307
+ ? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. Use apply_patch for code edits; it accepts exact replacements or Codex-style/unified multi-file patches. Local preview tools available: open_workspace_file and preview_workspace.`
308
308
  : "",
309
309
  config.allowWrapperTools
310
310
  ? `Agent wrappers: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
@@ -499,6 +499,7 @@ function sanitizeToolArgs(toolName, args) {
499
499
  if (toolName === "apply_patch") {
500
500
  return {
501
501
  ...safeArgs,
502
+ patch: typeof args.patch === "string" ? `[${Buffer.byteLength(args.patch, "utf8")} bytes sha256=${hashForLog(args.patch)}]` : safeArgs.patch,
502
503
  search: typeof args.search === "string" ? redactSensitiveText(args.search).slice(0, 160) : safeArgs.search,
503
504
  replace: typeof args.replace === "string" ? redactSensitiveText(args.replace).slice(0, 160) : safeArgs.replace,
504
505
  };
@@ -610,7 +611,7 @@ async function captureSyntheticSnapshot(store, step, config) {
610
611
  : `Shell tool available in: ${config.commandCwd}`
611
612
  : "Shell tool disabled.",
612
613
  config.allowFileTools
613
- ? `Workspace file tools available in: ${config.commandCwd}. Use workspace-relative paths.`
614
+ ? `Workspace file tools available in: ${config.commandCwd}. Use workspace-relative paths. Use apply_patch for code edits; it supports exact single-file replacement and multi-file Codex-style/unified patches.`
614
615
  : "Workspace file tools disabled.",
615
616
  config.allowWrapperTools
616
617
  ? `Agent wrappers available: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
@@ -819,9 +820,10 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
819
820
 
820
821
  await store.appendEvent("tool.completed", eventResult);
821
822
  observers.event("tool.completed", eventResult);
822
- if (result.change) {
823
+ const changes = Array.isArray(result.changes) && result.changes.length ? result.changes : result.change ? [result.change] : [];
824
+ for (const item of changes) {
823
825
  const change = {
824
- ...result.change,
826
+ ...item,
825
827
  toolName: toolCall.function.name,
826
828
  commandCwd: config.commandCwd,
827
829
  };
@@ -60,7 +60,7 @@ function label(name, bgCode) {
60
60
  }
61
61
 
62
62
  function userPrompt() {
63
- return `\n${label("user>", ansi.userBg)} `;
63
+ return `\n${label("user>", ansi.userBg)} ${color("|", ansi.userBg)} `;
64
64
  }
65
65
 
66
66
  function commandCompleter(line = "") {
@@ -70,6 +70,21 @@ function commandCompleter(line = "") {
70
70
  return [hits.length > 0 ? hits : SLASH_COMMANDS, trimmed];
71
71
  }
72
72
 
73
+ function stripAnsi(value) {
74
+ return String(value || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
75
+ }
76
+
77
+ function promptGutter() {
78
+ const visible = stripAnsi(userPrompt()).replace(/^\n/, "").length;
79
+ return " ".repeat(Math.max(visible - 2, 0)) + `${color("|", ansi.userBg)} `;
80
+ }
81
+
82
+ function commandSuggestions(line = "") {
83
+ const trimmed = String(line || "");
84
+ if (!trimmed.startsWith("/") || /\s/.test(trimmed)) return [];
85
+ return SLASH_COMMANDS.filter((command) => command.startsWith(trimmed)).slice(0, 8);
86
+ }
87
+
73
88
  function stripMarkdown(text) {
74
89
  const lines = String(text || "").split(/\r?\n/);
75
90
  let inFence = false;
@@ -79,16 +94,26 @@ function stripMarkdown(text) {
79
94
  let line = rawLine;
80
95
  if (/^\s*```/.test(line)) {
81
96
  inFence = !inFence;
82
- if (inFence) rendered.push(color("code", ansi.dim));
97
+ if (inFence) {
98
+ const language = line.replace(/^\s*```/, "").trim();
99
+ rendered.push(color(language ? `code ${language}` : "code", ansi.dim));
100
+ }
83
101
  continue;
84
102
  }
85
103
 
86
104
  if (!inFence) {
87
105
  if (/^\s*[-*_]{3,}\s*$/.test(line)) {
88
- rendered.push("");
106
+ rendered.push(color("-".repeat(42), ansi.dim));
107
+ continue;
108
+ }
109
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
110
+ if (heading) {
111
+ rendered.push(color(heading[2].replace(/\s+#*$/, ""), ansi.bold, ansi.cyan));
112
+ continue;
113
+ }
114
+ if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)) {
89
115
  continue;
90
116
  }
91
- line = line.replace(/^\s{0,3}#{1,6}\s+/, "");
92
117
  line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
93
118
  line = line.replace(/\*\*([^*]+)\*\*/g, (_, value) => color(value, ansi.bold));
94
119
  line = line.replace(/__([^_]+)__/g, (_, value) => color(value, ansi.bold));
@@ -96,7 +121,9 @@ function stripMarkdown(text) {
96
121
  line = line.replace(/(^|[^\w])_([^_\n]+)_/g, "$1$2");
97
122
  line = line.replace(/`([^`]+)`/g, (_, value) => color(value, ansi.yellow));
98
123
  line = line.replace(/^(\s*)[-*+]\s+/, "$1- ");
99
- line = line.replace(/^\s*>\s?/, " ");
124
+ line = line.replace(/^\s*>\s?(.+)$/, (_, value) => color(`| ${value}`, ansi.dim));
125
+ } else {
126
+ line = color(` ${line}`, ansi.yellow);
100
127
  }
101
128
 
102
129
  rendered.push(line);
@@ -105,10 +132,15 @@ function stripMarkdown(text) {
105
132
  return rendered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
106
133
  }
107
134
 
108
- function printWrapped(prefix, text) {
135
+ function rolePrefix(name, bgCode) {
136
+ return `${label(name, bgCode)} ${color("|", bgCode)} `;
137
+ }
138
+
139
+ function printWrapped(prefix, text, { stripCode = "" } = {}) {
109
140
  const rendered = stripMarkdown(text);
110
141
  const lines = rendered.split("\n");
111
- const gutter = " ".repeat(useColor ? 9 : prefix.length);
142
+ const visible = stripAnsi(prefix).length;
143
+ const gutter = `${" ".repeat(Math.max(visible - 2, 0))}${stripCode ? color("|", stripCode) : "|"} `;
112
144
  console.log(`${prefix}${lines[0] || ""}`);
113
145
  for (const line of lines.slice(1)) {
114
146
  console.log(`${gutter}${line}`);
@@ -116,7 +148,7 @@ function printWrapped(prefix, text) {
116
148
  }
117
149
 
118
150
  function printAgentMessage(text) {
119
- printWrapped(`${label("aginti>", ansi.agentBg)} `, text);
151
+ printWrapped(rolePrefix("aginti>", ansi.agentBg), text, { stripCode: ansi.agentBg });
120
152
  }
121
153
 
122
154
  function printSystemLine(text) {
@@ -197,6 +229,113 @@ function printHelp() {
197
229
  );
198
230
  }
199
231
 
232
+ function renderPromptBuffer(buffer, previousLineCount = 0) {
233
+ for (let index = 0; index < previousLineCount; index += 1) {
234
+ output.write(`\r${ansi.clearLine}`);
235
+ if (index < previousLineCount - 1) output.write("\x1b[1A");
236
+ }
237
+
238
+ const lines = String(buffer || "").split("\n");
239
+ const suggestions = commandSuggestions(lines[0] || "");
240
+ const rendered = [];
241
+ rendered.push(`${userPrompt().replace(/^\n/, "")}${lines[0] || ""}`);
242
+ for (const line of lines.slice(1)) {
243
+ rendered.push(`${promptGutter()}${line}`);
244
+ }
245
+ if (suggestions.length > 0) {
246
+ rendered.push(`${promptGutter()}${color(`suggest: ${suggestions.join(" ")}`, ansi.dim)}`);
247
+ }
248
+ output.write(rendered.join("\n"));
249
+ return rendered.length;
250
+ }
251
+
252
+ function createAbortError(message = "Aborted with Ctrl+C") {
253
+ const error = new Error(message);
254
+ error.code = "ABORT_ERR";
255
+ error.name = "AbortError";
256
+ return error;
257
+ }
258
+
259
+ function readTtyPrompt() {
260
+ return new Promise((resolve, reject) => {
261
+ emitKeypressEvents(input);
262
+ const wasRaw = Boolean(input.isRaw);
263
+ let buffer = "";
264
+ let renderedLines = 0;
265
+
266
+ const cleanup = () => {
267
+ input.off("keypress", handler);
268
+ if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
269
+ input.pause();
270
+ output.write(ansi.cursorShow);
271
+ };
272
+
273
+ const redraw = () => {
274
+ renderedLines = renderPromptBuffer(buffer, renderedLines);
275
+ };
276
+
277
+ const submit = () => {
278
+ cleanup();
279
+ output.write("\n");
280
+ resolve(buffer);
281
+ };
282
+
283
+ const handler = (str = "", key = {}) => {
284
+ if (key.ctrl && key.name === "c") {
285
+ cleanup();
286
+ output.write("\n");
287
+ reject(createAbortError());
288
+ return;
289
+ }
290
+ if ((key.ctrl && key.name === "j") || (key.sequence === "\n" && key.name !== "return" && key.name !== "enter")) {
291
+ buffer += "\n";
292
+ redraw();
293
+ return;
294
+ }
295
+ if (key.name === "return" || key.name === "enter" || key.sequence === "\r" || str === "\r") {
296
+ submit();
297
+ return;
298
+ }
299
+ if (key.name === "backspace") {
300
+ buffer = buffer.slice(0, -1);
301
+ redraw();
302
+ return;
303
+ }
304
+ if (key.name === "tab") {
305
+ const suggestions = commandSuggestions(buffer.split("\n")[0] || "");
306
+ if (suggestions.length === 1) {
307
+ buffer = suggestions[0];
308
+ }
309
+ redraw();
310
+ return;
311
+ }
312
+ if (key.name === "escape") {
313
+ buffer = "";
314
+ redraw();
315
+ return;
316
+ }
317
+ if (key.ctrl || key.meta) return;
318
+ if (str && !key.sequence?.startsWith("\x1b")) {
319
+ buffer += str;
320
+ redraw();
321
+ }
322
+ };
323
+
324
+ input.resume();
325
+ input.setRawMode(true);
326
+ output.write(ansi.cursorHide);
327
+ input.on("keypress", handler);
328
+ redraw();
329
+ });
330
+ }
331
+
332
+ async function readPromptAnswer(rl) {
333
+ if (input.isTTY && output.isTTY && typeof input.setRawMode === "function") {
334
+ return readTtyPrompt();
335
+ }
336
+ return rl.question(userPrompt());
337
+ }
338
+
200
339
  function printStatus(state) {
201
340
  printSystemLine(`project=${process.cwd()}`);
202
341
  printSystemLine(`cwd=${state.commandCwd || process.cwd()}`);
@@ -237,6 +376,7 @@ function attachRunInterrupts(controller) {
237
376
 
238
377
  emitKeypressEvents(input);
239
378
  const wasRaw = Boolean(input.isRaw);
379
+ input.resume();
240
380
  input.setRawMode(true);
241
381
  const handler = (_str, key = {}) => {
242
382
  const isEscape = key.name === "escape";
@@ -564,12 +704,15 @@ async function runPrompt(prompt, state, packageDir) {
564
704
 
565
705
  export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
566
706
  const state = createState(args);
567
- const rl = readline.createInterface({
568
- input,
569
- output,
570
- terminal: Boolean(input.isTTY && output.isTTY),
571
- completer: commandCompleter,
572
- });
707
+ const rl =
708
+ input.isTTY && output.isTTY
709
+ ? null
710
+ : readline.createInterface({
711
+ input,
712
+ output,
713
+ terminal: false,
714
+ completer: commandCompleter,
715
+ });
573
716
 
574
717
  await renderLaunchHeader(packageVersion);
575
718
  printSystemLine(`Project: ${process.cwd()}`);
@@ -581,7 +724,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
581
724
  while (true) {
582
725
  let answer = "";
583
726
  try {
584
- answer = await rl.question(userPrompt());
727
+ answer = await readPromptAnswer(rl);
585
728
  } catch (error) {
586
729
  if (error?.code === "ERR_USE_AFTER_CLOSE") break;
587
730
  if (isAbortError(error)) {
@@ -609,6 +752,6 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
609
752
  }
610
753
  }
611
754
  } finally {
612
- rl.close();
755
+ rl?.close();
613
756
  }
614
757
  }
@@ -81,6 +81,21 @@ function mockWorkspaceToolForGoal(goal = "") {
81
81
  const text = String(goal).toLowerCase();
82
82
  const targetPath = mockPathForGoal(goal);
83
83
  if (/patch|replace|edit/.test(text)) {
84
+ if (/multi|codex|unified|several|multiple/i.test(text)) {
85
+ return mockToolCall("apply_patch", {
86
+ patch: [
87
+ "*** Begin Patch",
88
+ "*** Update File: patch-target.txt",
89
+ "@@",
90
+ "-old",
91
+ "+new",
92
+ "*** Add File: notes/patch-note.md",
93
+ "+Created by AgInTiFlow mock mode.",
94
+ "+Goal: multi-file patch smoke.",
95
+ "*** End Patch",
96
+ ].join("\n"),
97
+ });
98
+ }
84
99
  return mockToolCall("apply_patch", {
85
100
  path: targetPath,
86
101
  search: "old",
@@ -166,7 +181,7 @@ export async function createPlan(client, config, state) {
166
181
  ? `Shell tool is enabled in ${config.commandCwd}. In Docker, this path is mounted as /workspace with persistent /aginti-env and /aginti-cache mounts. Use relative paths or /workspace paths, not absolute host temp paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}. For npm/pip/conda/venv setup, explain the need and wait for approval unless policy is allow.`
167
182
  : "",
168
183
  config.allowFileTools
169
- ? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
184
+ ? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. apply_patch supports exact single-file replacements and Codex-style/unified multi-file patches; prefer it for edits after reading relevant context. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
170
185
  : "",
171
186
  config.allowWrapperTools
172
187
  ? `Agent wrappers are enabled. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Status: ${wrapperStatusText()}.`
@@ -456,16 +471,21 @@ export async function requestNextStep(client, config, messages) {
456
471
  function: {
457
472
  name: "apply_patch",
458
473
  description:
459
- "Apply a deterministic workspace-local search/replace patch to one small UTF-8 file. Provide exact search and replacement text. The runtime records before/after hashes and a compact diff.",
474
+ "Apply deterministic workspace-local code edits. Either provide path/search/replace for one exact replacement, or provide patch with a Codex-style patch envelope (*** Begin Patch / *** Update File / *** Add File / *** Delete File / *** End Patch) or unified diff. Use this after reading/searching relevant files. It supports multi-file add/update/delete patches, blocks secrets/.git/node_modules/outside-workspace paths, preflights hunks before writing, and records before/after hashes plus compact diffs.",
460
475
  parameters: {
461
476
  type: "object",
462
477
  properties: {
478
+ patch: {
479
+ type: "string",
480
+ description:
481
+ "Optional multi-file patch document. Supports Codex-style patch envelope or unified diff. Use workspace-relative paths only.",
482
+ },
463
483
  path: { type: "string", description: "Workspace-relative file path." },
464
- search: { type: "string" },
465
- replace: { type: "string" },
484
+ search: { type: "string", description: "Exact text to replace when using single-file patch mode." },
485
+ replace: { type: "string", description: "Replacement text when using single-file patch mode." },
466
486
  expectedReplacements: { type: "integer" },
487
+ baseHash: { type: "string", description: "Optional sha256 from read_file; rejects if file changed before patching." },
467
488
  },
468
- required: ["path", "search", "replace"],
469
489
  additionalProperties: false,
470
490
  },
471
491
  },
@@ -538,7 +558,13 @@ export async function requestNextStep(client, config, messages) {
538
558
  toolPayload.error,
539
559
  toolPayload.reason,
540
560
  toolPayload.path ? `Path: ${toolPayload.path}` : "",
541
- toolPayload.change?.diff ? `Diff:\n${toolPayload.change.diff}` : "",
561
+ Array.isArray(toolPayload.changes)
562
+ ? toolPayload.changes
563
+ .map((change) => [change.path ? `Path: ${change.path}` : "", change.diff ? `Diff:\n${change.diff}` : ""].filter(Boolean).join("\n"))
564
+ .filter(Boolean)
565
+ .join("\n\n")
566
+ : "",
567
+ !Array.isArray(toolPayload.changes) && toolPayload.change?.diff ? `Diff:\n${toolPayload.change.diff}` : "",
542
568
  ]
543
569
  .filter(Boolean)
544
570
  .join("\n");
@@ -5,6 +5,10 @@ const COMPLEXITY_KEYWORDS = [
5
5
  "failing",
6
6
  "test",
7
7
  "implement",
8
+ "patch",
9
+ "apply_patch",
10
+ "edit",
11
+ "large codebase",
8
12
  "design",
9
13
  "review",
10
14
  "migrate",
@@ -8,6 +8,7 @@ export const WORKSPACE_WRITE_TOOL_NAMES = ["write_file", "apply_patch"];
8
8
 
9
9
  const MAX_READ_BYTES = 220_000;
10
10
  const MAX_WRITE_BYTES = 220_000;
11
+ const MAX_PATCH_BYTES = 260_000;
11
12
  const MAX_LIST_ENTRIES = 360;
12
13
  const MAX_SEARCH_RESULTS = 80;
13
14
  const DEFAULT_MAX_DEPTH = 4;
@@ -52,6 +53,23 @@ function compactPreview(value, limit = 180) {
52
53
  return text.length <= limit ? text : `${text.slice(0, limit)}...`;
53
54
  }
54
55
 
56
+ function countOccurrences(text, search) {
57
+ if (!search) return 0;
58
+ let count = 0;
59
+ let index = 0;
60
+ while ((index = text.indexOf(search, index)) !== -1) {
61
+ count += 1;
62
+ index += search.length;
63
+ }
64
+ return count;
65
+ }
66
+
67
+ function replaceOnce(text, search, replace) {
68
+ const index = text.indexOf(search);
69
+ if (index === -1) return text;
70
+ return `${text.slice(0, index)}${replace}${text.slice(index + search.length)}`;
71
+ }
72
+
55
73
  function sanitizePathInput(inputPath) {
56
74
  const value = String(inputPath || ".").trim();
57
75
  if (!value) return ".";
@@ -130,6 +148,16 @@ export function checkWorkspaceToolUse(toolName, args, config) {
130
148
  }
131
149
 
132
150
  try {
151
+ if (toolName === "apply_patch" && typeof args.patch === "string" && args.patch.trim()) {
152
+ for (const operation of parsePatchDocument(args.patch)) {
153
+ for (const candidate of [operation.path, operation.newPath].filter(Boolean)) {
154
+ const target = resolveWorkspacePath(config, candidate);
155
+ const policy = pathPolicy(toolName, target.relativePath);
156
+ if (!policy.allowed) return policy;
157
+ }
158
+ }
159
+ return { allowed: true };
160
+ }
133
161
  const target = resolveWorkspacePath(config, args.path || ".");
134
162
  return pathPolicy(toolName, target.relativePath);
135
163
  } catch (error) {
@@ -150,6 +178,7 @@ export function summarizeWorkspaceTools(config) {
150
178
  limits: {
151
179
  maxReadBytes: MAX_READ_BYTES,
152
180
  maxWriteBytes: MAX_WRITE_BYTES,
181
+ maxPatchBytes: MAX_PATCH_BYTES,
153
182
  maxListEntries: MAX_LIST_ENTRIES,
154
183
  maxSearchResults: MAX_SEARCH_RESULTS,
155
184
  },
@@ -333,7 +362,8 @@ async function writeChange(target, nextContent, action, details = {}) {
333
362
  beforeText = before.toString("utf8");
334
363
  beforeHash = hashBuffer(before);
335
364
  beforeBytes = before.length;
336
- } catch {
365
+ } catch (error) {
366
+ if (error?.code !== "ENOENT") throw error;
337
367
  // Missing files are valid for create/overwrite actions.
338
368
  }
339
369
 
@@ -357,6 +387,27 @@ async function writeChange(target, nextContent, action, details = {}) {
357
387
  };
358
388
  }
359
389
 
390
+ async function deleteChange(target, action = "delete_file", details = {}) {
391
+ const before = await fs.readFile(target.absolutePath);
392
+ if (before.length > MAX_READ_BYTES) throw new Error(`Existing file is too large to delete safely: ${target.relativePath}`);
393
+ if (before.includes(0)) throw new Error(`Binary files cannot be deleted through this tool: ${target.relativePath}`);
394
+ const beforeText = before.toString("utf8");
395
+ const beforeHash = hashBuffer(before);
396
+ await fs.unlink(target.absolutePath);
397
+ return {
398
+ ok: true,
399
+ action,
400
+ path: target.relativePath,
401
+ beforeHash,
402
+ afterHash: null,
403
+ beforeBytes: before.length,
404
+ afterBytes: 0,
405
+ deleted: true,
406
+ diff: compactDiff(target.relativePath, beforeText, ""),
407
+ ...details,
408
+ };
409
+ }
410
+
360
411
  async function writeFile(config, args) {
361
412
  const target = resolveWorkspacePath(config, args.path);
362
413
  const mode = args.mode === "overwrite" ? "overwrite" : "create";
@@ -380,8 +431,15 @@ async function writeFile(config, args) {
380
431
  }
381
432
 
382
433
  async function applyPatch(config, args) {
434
+ if (typeof args.patch === "string" && args.patch.trim()) {
435
+ return applyPatchDocument(config, args);
436
+ }
437
+
383
438
  const target = resolveWorkspacePath(config, args.path);
384
- const { content: beforeText } = await readTextFile(target);
439
+ const { content: beforeText, hash } = await readTextFile(target);
440
+ if (args.baseHash && args.baseHash !== hash) {
441
+ throw new Error(`Base hash mismatch for ${target.relativePath}; read the file again before patching.`);
442
+ }
385
443
  const search = String(args.search || "");
386
444
  const replace = String(args.replace ?? "");
387
445
  if (!search) throw new Error("Patch search text is required.");
@@ -409,6 +467,247 @@ async function applyPatch(config, args) {
409
467
  };
410
468
  }
411
469
 
470
+ function ensurePatchSize(patch) {
471
+ const bytes = Buffer.byteLength(String(patch || ""), "utf8");
472
+ if (bytes > MAX_PATCH_BYTES) throw new Error(`Patch is too large for safe workspace tools (${bytes} bytes).`);
473
+ }
474
+
475
+ function cleanPatchPath(rawPath) {
476
+ let value = String(rawPath || "").trim();
477
+ value = value.replace(/^"|"$/g, "");
478
+ if (!value || value === "/dev/null") return "";
479
+ value = value.replace(/^\.[/\\]/, "");
480
+ value = value.replace(/^[ab]\//, "");
481
+ return value;
482
+ }
483
+
484
+ function flushPatchHunk(hunks, oldLines, newLines) {
485
+ if (!oldLines.length && !newLines.length) return;
486
+ hunks.push({
487
+ search: oldLines.join("\n"),
488
+ replace: newLines.join("\n"),
489
+ });
490
+ oldLines.length = 0;
491
+ newLines.length = 0;
492
+ }
493
+
494
+ function parsePrefixedHunks(lines) {
495
+ const hunks = [];
496
+ const oldLines = [];
497
+ const newLines = [];
498
+
499
+ for (const line of lines) {
500
+ if (line.startsWith("@@")) {
501
+ flushPatchHunk(hunks, oldLines, newLines);
502
+ continue;
503
+ }
504
+ if (line.startsWith("\\ No newline")) continue;
505
+ const marker = line[0];
506
+ const content = line.slice(1);
507
+ if (marker === " ") {
508
+ oldLines.push(content);
509
+ newLines.push(content);
510
+ } else if (marker === "-") {
511
+ oldLines.push(content);
512
+ } else if (marker === "+") {
513
+ newLines.push(content);
514
+ }
515
+ }
516
+
517
+ flushPatchHunk(hunks, oldLines, newLines);
518
+ return hunks.filter((hunk) => hunk.search !== hunk.replace);
519
+ }
520
+
521
+ function parseCodexPatchDocument(patch) {
522
+ const lines = String(patch || "").replace(/\r\n?/g, "\n").split("\n");
523
+ const operations = [];
524
+ let index = 0;
525
+
526
+ while (index < lines.length) {
527
+ const line = lines[index];
528
+ const add = line.match(/^\*\*\* Add File:\s+(.+)$/);
529
+ const update = line.match(/^\*\*\* Update File:\s+(.+)$/);
530
+ const remove = line.match(/^\*\*\* Delete File:\s+(.+)$/);
531
+
532
+ if (add) {
533
+ const filePath = cleanPatchPath(add[1]);
534
+ const contentLines = [];
535
+ index += 1;
536
+ while (index < lines.length && !lines[index].startsWith("*** ")) {
537
+ contentLines.push(lines[index].startsWith("+") ? lines[index].slice(1) : lines[index]);
538
+ index += 1;
539
+ }
540
+ operations.push({ type: "add", path: filePath, content: contentLines.join("\n") });
541
+ continue;
542
+ }
543
+
544
+ if (remove) {
545
+ operations.push({ type: "delete", path: cleanPatchPath(remove[1]) });
546
+ index += 1;
547
+ continue;
548
+ }
549
+
550
+ if (update) {
551
+ const filePath = cleanPatchPath(update[1]);
552
+ const hunkLines = [];
553
+ let moveTo = "";
554
+ index += 1;
555
+ while (index < lines.length) {
556
+ const move = lines[index].match(/^\*\*\* Move to:\s+(.+)$/);
557
+ if (move) {
558
+ moveTo = cleanPatchPath(move[1]);
559
+ index += 1;
560
+ continue;
561
+ }
562
+ if (lines[index].startsWith("*** ")) break;
563
+ hunkLines.push(lines[index]);
564
+ index += 1;
565
+ }
566
+ operations.push({ type: "update", path: filePath, newPath: moveTo, hunks: parsePrefixedHunks(hunkLines) });
567
+ continue;
568
+ }
569
+
570
+ index += 1;
571
+ }
572
+
573
+ return operations;
574
+ }
575
+
576
+ function parseUnifiedPatchDocument(patch) {
577
+ const lines = String(patch || "").replace(/\r\n?/g, "\n").split("\n");
578
+ const operations = [];
579
+ let index = 0;
580
+
581
+ while (index < lines.length) {
582
+ const oldHeader = lines[index]?.match(/^---\s+(.+)$/);
583
+ const newHeader = lines[index + 1]?.match(/^\+\+\+\s+(.+)$/);
584
+ if (!oldHeader || !newHeader) {
585
+ index += 1;
586
+ continue;
587
+ }
588
+
589
+ const oldPath = cleanPatchPath(oldHeader[1].split(/\s+/)[0]);
590
+ const newPath = cleanPatchPath(newHeader[1].split(/\s+/)[0]);
591
+ const hunkLines = [];
592
+ index += 2;
593
+ while (index < lines.length && !/^---\s+/.test(lines[index])) {
594
+ hunkLines.push(lines[index]);
595
+ index += 1;
596
+ }
597
+
598
+ if (!oldPath && newPath) {
599
+ const content = hunkLines
600
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
601
+ .map((line) => line.slice(1))
602
+ .join("\n");
603
+ operations.push({ type: "add", path: newPath, content });
604
+ } else if (oldPath && !newPath) {
605
+ operations.push({ type: "delete", path: oldPath });
606
+ } else {
607
+ operations.push({ type: "update", path: oldPath, newPath: newPath && newPath !== oldPath ? newPath : "", hunks: parsePrefixedHunks(hunkLines) });
608
+ }
609
+ }
610
+
611
+ return operations;
612
+ }
613
+
614
+ function parsePatchDocument(patch) {
615
+ const text = String(patch || "").trim();
616
+ if (!text) throw new Error("Patch content is required.");
617
+ ensurePatchSize(text);
618
+ const operations = text.includes("*** Begin Patch") ? parseCodexPatchDocument(text) : parseUnifiedPatchDocument(text);
619
+ if (!operations.length) throw new Error("Patch did not contain any supported file operations.");
620
+ return operations;
621
+ }
622
+
623
+ async function applyPatchDocument(config, args) {
624
+ const operations = parsePatchDocument(args.patch);
625
+ const planned = [];
626
+ const seenPaths = new Set();
627
+
628
+ for (const operation of operations) {
629
+ if (!operation.path) throw new Error("Patch operation is missing a file path.");
630
+ if (seenPaths.has(operation.path)) throw new Error(`Patch contains duplicate file operations for ${operation.path}.`);
631
+ seenPaths.add(operation.path);
632
+
633
+ const target = resolveWorkspacePath(config, operation.path);
634
+ const policy = pathPolicy("apply_patch", target.relativePath);
635
+ if (!policy.allowed) throw new Error(policy.reason);
636
+ const newTarget = operation.newPath ? resolveWorkspacePath(config, operation.newPath) : null;
637
+ if (newTarget) {
638
+ const newPolicy = pathPolicy("apply_patch", newTarget.relativePath);
639
+ if (!newPolicy.allowed) throw new Error(newPolicy.reason);
640
+ const newTargetExists = await fs
641
+ .stat(newTarget.absolutePath)
642
+ .then(() => true)
643
+ .catch((error) => {
644
+ if (error?.code === "ENOENT") return false;
645
+ throw error;
646
+ });
647
+ if (newTargetExists) throw new Error(`Patch cannot move over an existing file: ${newTarget.relativePath}`);
648
+ }
649
+
650
+ if (operation.type === "add") {
651
+ const exists = await fs
652
+ .stat(target.absolutePath)
653
+ .then(() => true)
654
+ .catch((error) => {
655
+ if (error?.code === "ENOENT") return false;
656
+ throw error;
657
+ });
658
+ if (exists) throw new Error(`Patch cannot add an existing file: ${target.relativePath}`);
659
+ planned.push({ operation, target, afterText: String(operation.content ?? "") });
660
+ continue;
661
+ }
662
+
663
+ if (operation.type === "delete") {
664
+ await readTextFile(target);
665
+ planned.push({ operation, target, delete: true });
666
+ continue;
667
+ }
668
+
669
+ const { content: beforeText } = await readTextFile(target);
670
+ let afterText = beforeText;
671
+ for (const hunk of operation.hunks || []) {
672
+ if (!hunk.search) throw new Error(`Patch hunk for ${target.relativePath} has no removable/context lines.`);
673
+ const matches = countOccurrences(afterText, hunk.search);
674
+ if (matches !== 1) {
675
+ throw new Error(`Patch hunk for ${target.relativePath} expected exactly 1 match, found ${matches}. Add more context or read the file again.`);
676
+ }
677
+ afterText = replaceOnce(afterText, hunk.search, hunk.replace);
678
+ }
679
+ if (afterText === beforeText && !operation.newPath) throw new Error(`Patch made no changes to ${target.relativePath}.`);
680
+ planned.push({ operation, target, afterText });
681
+ }
682
+
683
+ const changes = [];
684
+ for (const item of planned) {
685
+ const { operation, target } = item;
686
+ if (item.delete) {
687
+ changes.push(await deleteChange(target, "apply_patch_delete", { patchFormat: "multi-file" }));
688
+ continue;
689
+ }
690
+ if (operation.newPath) {
691
+ const newTarget = resolveWorkspacePath(config, operation.newPath);
692
+ const policy = pathPolicy("apply_patch", newTarget.relativePath);
693
+ if (!policy.allowed) throw new Error(policy.reason);
694
+ changes.push(await writeChange(newTarget, item.afterText, "apply_patch_move", { fromPath: target.relativePath, patchFormat: "multi-file" }));
695
+ await fs.unlink(target.absolutePath);
696
+ continue;
697
+ }
698
+ changes.push(await writeChange(target, item.afterText, operation.type === "add" ? "apply_patch_add" : "apply_patch_update", { patchFormat: "multi-file" }));
699
+ }
700
+
701
+ return {
702
+ ok: true,
703
+ toolName: "apply_patch",
704
+ path: changes.length === 1 ? changes[0].path : "",
705
+ changes,
706
+ change: changes[0],
707
+ summary: `${changes.length} file change(s) applied`,
708
+ };
709
+ }
710
+
412
711
  export async function executeWorkspaceTool(toolName, args, config) {
413
712
  const guard = checkWorkspaceToolUse(toolName, args, config);
414
713
  if (!guard.allowed) {