@golba98/codexa 1.0.3 → 1.0.4

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 (85) hide show
  1. package/README.md +17 -18
  2. package/bin/codexa.js +62 -144
  3. package/package.json +4 -3
  4. package/src/app.tsx +533 -275
  5. package/src/commands/handler.ts +2 -2
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/layeredConfig.ts +1 -1
  8. package/src/config/runtimeConfig.ts +1 -1
  9. package/src/config/settings.ts +1 -1
  10. package/src/config/trustStore.ts +1 -1
  11. package/src/core/README.md +52 -0
  12. package/src/core/agent/loop.ts +282 -0
  13. package/src/core/agent/protocol.ts +211 -0
  14. package/src/core/agent/tools.ts +414 -0
  15. package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
  16. package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
  17. package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
  18. package/src/core/debug/modelStateDebug.ts +34 -0
  19. package/src/core/executables/antigravityExecutable.ts +48 -0
  20. package/src/core/executables/executableResolver.ts +5 -1
  21. package/src/core/perf/renderDebug.ts +10 -6
  22. package/src/core/providerLauncher/registry.ts +30 -14
  23. package/src/core/providerLauncher/types.ts +11 -9
  24. package/src/core/providerLauncher/workspaceConfig.ts +41 -26
  25. package/src/core/providerRuntime/anthropic.ts +7 -1
  26. package/src/core/providerRuntime/antigravity.ts +305 -0
  27. package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
  28. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  29. package/src/core/providerRuntime/contextMetadata.ts +12 -24
  30. package/src/core/providerRuntime/local.ts +129 -51
  31. package/src/core/providerRuntime/models.ts +22 -11
  32. package/src/core/providerRuntime/registry.ts +58 -31
  33. package/src/core/providerRuntime/types.ts +19 -14
  34. package/src/core/providers/codexSubprocess.ts +2 -2
  35. package/src/core/providers/types.ts +1 -1
  36. package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
  37. package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
  38. package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
  39. package/src/core/terminal/clearFrameBoundary.ts +814 -0
  40. package/src/core/terminal/frameLock.ts +109 -0
  41. package/src/core/terminal/inkRenderReset.ts +123 -0
  42. package/src/core/terminal/terminalControl.ts +22 -0
  43. package/src/core/terminal/terminalTitle.ts +16 -102
  44. package/src/core/{channel.ts → version/channel.ts} +1 -1
  45. package/src/core/{updateCheck.ts → version/updateCheck.ts} +10 -5
  46. package/src/core/{launchContext.ts → workspace/launchContext.ts} +9 -16
  47. package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
  48. package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
  49. package/src/headless/execRunner.ts +2 -2
  50. package/src/index.tsx +43 -89
  51. package/src/session/appSession.ts +10 -7
  52. package/src/session/chatLifecycle.ts +1 -1
  53. package/src/session/liveRenderScheduler.ts +1 -1
  54. package/src/session/types.ts +1 -1
  55. package/src/ui/AppShell.tsx +672 -706
  56. package/src/ui/AttachmentImportPanel.tsx +2 -2
  57. package/src/ui/BottomComposer.tsx +145 -144
  58. package/src/ui/ModelPickerScreen.tsx +216 -36
  59. package/src/ui/ModelReasoningPicker.tsx +1 -1
  60. package/src/ui/PlanReviewPanel.tsx +1 -1
  61. package/src/ui/ProviderPicker.tsx +735 -321
  62. package/src/ui/RuntimeStatusBar.tsx +108 -0
  63. package/src/ui/SelectionPanel.tsx +4 -0
  64. package/src/ui/SettingsPanel.tsx +1 -1
  65. package/src/ui/TextEntryPanel.tsx +1 -1
  66. package/src/ui/Timeline.tsx +1619 -1470
  67. package/src/ui/TopHeader.tsx +46 -30
  68. package/src/ui/TranscriptShell.tsx +322 -0
  69. package/src/ui/TurnGroup.tsx +2 -2
  70. package/src/ui/UpdateAvailableCard.tsx +3 -3
  71. package/src/ui/UpdatePromptPanel.tsx +16 -22
  72. package/src/ui/layout.ts +298 -24
  73. package/src/ui/layoutListWindow.ts +145 -0
  74. package/src/ui/logoVariants.ts +4 -8
  75. package/src/ui/runtimeDisplay.ts +15 -3
  76. package/src/ui/textLayout.ts +15 -4
  77. package/src/ui/timelineMeasure.ts +194 -122
  78. package/src/core/codex.ts +0 -124
  79. package/src/ui/StaticTranscriptItem.tsx +0 -56
  80. /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
  81. /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
  82. /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
  83. /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
  84. /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
  85. /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
@@ -36,7 +36,7 @@ import {
36
36
  type WorkspaceDisplayMode,
37
37
  } from "../config/settings.js";
38
38
 
39
- import type { WorkspaceCommandContext } from "../core/launchContext.js";
39
+ import type { WorkspaceCommandContext } from "../core/workspace/launchContext.js";
40
40
  import {
41
41
  findModelCapability,
42
42
  formatModelCapabilitiesList,
@@ -120,7 +120,7 @@ export interface CommandContext {
120
120
  modelCapabilities?: CodexModelCapabilities | null;
121
121
  routeStatusMessage?: string;
122
122
  activeRouteProviderLabel?: string;
123
- projectInstructions?: import("../core/projectInstructions.js").ProjectInstructionsLoadResult | null;
123
+ projectInstructions?: import("../core/workspace/projectInstructions.js").ProjectInstructionsLoadResult | null;
124
124
  }
125
125
 
126
126
  // Mirrors AVAILABLE_APPROVAL_POLICIES[].id from runtimeConfig.ts
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/gen-build-info.mjs — do not edit manually.
2
- export const BUILD_COMMIT = "f4940aa066eae20c0b8decb1fb70f02aca0f2dc0" as const;
3
- export const APP_VERSION = "1.0.3" as const;
2
+ export const BUILD_COMMIT = "b066a15b77696d2a0945ea7c6c3d07630584296b" as const;
3
+ export const APP_VERSION = "1.0.4" as const;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { dirname, join, resolve } from "path";
3
- import { normalizeWorkspaceRoot } from "../core/workspaceRoot.js";
3
+ import { normalizeWorkspaceRoot } from "../core/workspace/workspaceRoot.js";
4
4
  import {
5
5
  formatApprovalPolicyLabel,
6
6
  formatNetworkAccessLabel,
@@ -106,7 +106,7 @@ export interface ResolvedRuntimeConfig {
106
106
  export interface RuntimeStatusContext {
107
107
  workspaceRoot: string;
108
108
  tokensUsed?: number | null;
109
- projectInstructions?: import("../core/projectInstructions.js").ProjectInstructionsLoadResult | null;
109
+ projectInstructions?: import("../core/workspace/projectInstructions.js").ProjectInstructionsLoadResult | null;
110
110
  }
111
111
 
112
112
  export interface RuntimeSummary {
@@ -101,7 +101,7 @@ export const TERMINAL_MOUSE_MODES = ["wheel", "selection"] as const;
101
101
 
102
102
  export type TerminalMouseMode = (typeof TERMINAL_MOUSE_MODES)[number];
103
103
 
104
- export const DEFAULT_TERMINAL_MOUSE_MODE: TerminalMouseMode = "selection";
104
+ export const DEFAULT_TERMINAL_MOUSE_MODE: TerminalMouseMode = "wheel";
105
105
 
106
106
  export interface SettingOption<TValue extends string> {
107
107
  value: TValue;
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2
2
  import { dirname } from "path";
3
3
  import { getCodexaTrustStoreFile } from "./settings.js";
4
- import { normalizeWorkspaceRoot } from "../core/workspaceRoot.js";
4
+ import { normalizeWorkspaceRoot } from "../core/workspace/workspaceRoot.js";
5
5
 
6
6
  interface TrustStoreData {
7
7
  trustedProjectRoots: string[];
@@ -0,0 +1,52 @@
1
+ # `src/core`
2
+
3
+ Non-UI runtime logic for Codexa: launching backends, talking to provider CLIs,
4
+ terminal I/O, workspace resolution, and supporting utilities. UI lives in `src/ui`,
5
+ app state in `src/session`, config in `src/config`. The top level of `src/core` is
6
+ folders-only — every file lives in a domain folder so the directory stays scannable.
7
+
8
+ ## Folder map
9
+
10
+ | Folder | Responsibility |
11
+ | --- | --- |
12
+ | `providers/` | **Low-level Codex subprocess I/O.** Spawns the `codex` binary and parses its output (`codexSubprocess`, `codexJsonStream`, `codexTranscript`), plus the backend registry/types and the `openaiNative` stub. |
13
+ | `providerRuntime/` | **Multi-provider runtimes + discovery.** One runtime per provider (`anthropic`, `gemini`, `local`, `antigravity`) behind a shared interface, plus routing (`registry`), model/metadata helpers (`models`, `capabilityProfile`, `contextMetadata`, `reasoning`) and Claude Code discovery. |
14
+ | `providerLauncher/` | **Workspace provider config + CLI launching.** Which provider is active per workspace (`workspaceConfig`), provider UI state (`registry`), and spawning provider CLIs (`launcher`). |
15
+ | `codex/` | Codex CLI launch/prompt assembly: `codexExecArgs`, `codexLaunch`, `codexPrompt`. |
16
+ | `models/` | Codex CLI capability discovery (`codexCapabilities`, `codexModelCapabilities`) and the legacy model-spec service (`modelSpecs`). |
17
+ | `executables/` | Resolve external CLI binaries with PATH/env handling (`executableResolver` + per-CLI resolvers). |
18
+ | `auth/` | Codex auth status probing. |
19
+ | `process/` | Generic process spawning (`CommandRunner`) and executable-path validation. |
20
+ | `terminal/` | Terminal I/O: ANSI sanitize, raw mode / cursor, title sequences, capability detection, and the `/clear` + resize repaint boundary (`clearFrameBoundary`, `inkRenderReset`). |
21
+ | `workspace/` | Workspace resolution and state: `workspaceRoot`, `workspaceGuard`, `workspaceActivity`, `projectInstructions`, `planStorage`, `launchContext`. |
22
+ | `version/` | Build channel / version branding (`channel`) and update checking (`updateCheck`). |
23
+ | `shared/` | Small cross-cutting utilities: `clipboard`, `cleanupFastFail`, `githubDiagnostics`, `attachments`, `hollowResponseFormat`. |
24
+ | `perf/` | Performance + render instrumentation (`profiler`, `renderDebug`). |
25
+ | `debug/` | Dev-only tracing helpers (`inputDebug`). |
26
+
27
+ ## The three provider layers
28
+
29
+ `providers/`, `providerRuntime/`, and `providerLauncher/` have similar names but are
30
+ distinct layers — they are **not** duplicates:
31
+
32
+ ```
33
+ providerLauncher/ which provider is active for this workspace + how to spawn its CLI
34
+
35
+ providerRuntime/ per-provider runtimes (anthropic/gemini/local/antigravity),
36
+ │ routing, model discovery, capability/context metadata
37
+
38
+ providers/ low-level Codex subprocess I/O + output parsing
39
+ ```
40
+
41
+ The default Codex backend flows through `providers/`; the other providers are
42
+ implemented as `providerRuntime/` runtimes.
43
+
44
+ ## Debug instrumentation
45
+
46
+ These are intentional, env-gated diagnostics (not dead code) — keep them named clearly:
47
+
48
+ - `debug/inputDebug.ts` — stdin state tracing (`CODEXA_DEBUG_INPUT=1`).
49
+ - `perf/renderDebug.ts` — Ink render/flicker tracing (`CODEXA_RENDER_DEBUG=1`). Kept in
50
+ `perf/` rather than `debug/` because it is imported widely across the UI.
51
+ - `providerRuntime/claudeCodeDiscoveryDebug.ts` — entry point for the
52
+ `bun run debug:claude-models` script; lives next to the discovery code it exercises.
@@ -0,0 +1,282 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { BackendRunHandlers } from "../providers/types.js";
4
+ import type { ProviderChatRequest } from "../providerRuntime/types.js";
5
+ import { executeAgentTool, type AgentToolResult } from "./tools.js";
6
+ import { parseAgentToolCall, serializeToolResult, type NormalizedAgentToolCall } from "./protocol.js";
7
+
8
+ export interface AgentChatMessage {
9
+ role: "system" | "user" | "assistant" | "tool";
10
+ content: string;
11
+ }
12
+
13
+ export interface AgentChatResponse {
14
+ text: string;
15
+ toolCalls?: readonly NormalizedAgentToolCall[];
16
+ }
17
+
18
+ export interface RunAgentLoopOptions {
19
+ request: ProviderChatRequest;
20
+ handlers: BackendRunHandlers;
21
+ sendMessages: (messages: readonly AgentChatMessage[], turnIndex: number) => Promise<AgentChatResponse>;
22
+ includeSystemPrompt: boolean;
23
+ signal?: AbortSignal;
24
+ maxToolCalls?: number;
25
+ }
26
+
27
+ const DEFAULT_MAX_TOOL_CALLS = 10;
28
+
29
+ function workspaceSummary(workspaceRoot: string): string {
30
+ const lines = [`Workspace root: ${workspaceRoot}`];
31
+ try {
32
+ const entries = readdirSync(workspaceRoot, { withFileTypes: true })
33
+ .filter((entry) => ![".git", "node_modules", "dist", "build", "coverage"].includes(entry.name))
34
+ .map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`)
35
+ .sort()
36
+ .slice(0, 20);
37
+ if (entries.length > 0) lines.push(`Top-level entries: ${entries.join(", ")}`);
38
+ } catch {
39
+ // Tool-based inspection remains available when a shallow summary is unavailable.
40
+ }
41
+
42
+ try {
43
+ const packageJsonPath = path.join(workspaceRoot, "package.json");
44
+ if (existsSync(packageJsonPath)) {
45
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as Record<string, unknown>;
46
+ const name = typeof packageJson.name === "string" ? packageJson.name : null;
47
+ const description = typeof packageJson.description === "string" ? packageJson.description : null;
48
+ if (name) lines.push(`Package: ${name}${description ? ` - ${description}` : ""}`);
49
+ }
50
+ } catch {
51
+ // A malformed package.json should not prevent a local chat request.
52
+ }
53
+
54
+ return lines.join("\n");
55
+ }
56
+
57
+ function localAgentSystemPrompt(request: ProviderChatRequest): string {
58
+ const hasCargoToml = existsSync(path.join(request.workspaceRoot, "Cargo.toml"));
59
+ return [
60
+ `You are an autonomous coding assistant running inside this workspace: ${request.workspaceRoot}`,
61
+ "You must inspect files with tools before claiming you cannot see them.",
62
+ "For broad questions about the repository, use the workspace summary below, then inspect with get_workspace_info or list_files before answering when more detail is needed.",
63
+ "Use tools to create, edit, build, and test when the user asks for workspace changes.",
64
+ "Do not ask vague clarification questions when the user's intent has an obvious safe implementation.",
65
+ hasCargoToml
66
+ ? "Rust workspace note: Cargo.toml exists. Prefer src/main.rs for simple binaries, use cargo check for validation, use cargo run for running, and do not use rustc main.rs unless main.rs is truly at the workspace root."
67
+ : null,
68
+ "Use exactly one tool call at a time in this format:",
69
+ '<tool_call>{"name":"read_file","arguments":{"path":"src/index.tsx"}}</tool_call>',
70
+ "Available tools: list_files, read_file, write_file, apply_patch, run_shell, get_workspace_info.",
71
+ "Summarize changed files and commands run in your final answer.",
72
+ `Workspace summary:\n${workspaceSummary(request.workspaceRoot)}`,
73
+ request.projectInstructions?.content
74
+ ? ["Project instructions:", request.projectInstructions.content].join("\n")
75
+ : null,
76
+ ].filter(Boolean).join("\n\n");
77
+ }
78
+
79
+ function buildInitialMessages(request: ProviderChatRequest, includeSystemPrompt: boolean): AgentChatMessage[] {
80
+ const systemPrompt = localAgentSystemPrompt(request);
81
+ if (includeSystemPrompt) {
82
+ return [
83
+ { role: "system", content: systemPrompt },
84
+ { role: "user", content: request.prompt },
85
+ ];
86
+ }
87
+
88
+ return [
89
+ { role: "user", content: `${systemPrompt}\n\nUser request:\n${request.prompt}` },
90
+ ];
91
+ }
92
+
93
+ function toolActivityCommand(result: Pick<AgentToolResult, "tool" | "path" | "paths" | "command">): string {
94
+ if (result.command) return `${result.tool}: ${result.command}`;
95
+ if (result.path) return `${result.tool}: ${result.path}`;
96
+ if (result.paths && result.paths.length > 0) return `${result.tool}: ${result.paths.join(", ")}`;
97
+ return result.tool;
98
+ }
99
+
100
+ interface ExecutedCommand {
101
+ command: string;
102
+ success: boolean;
103
+ exitCode?: number | null;
104
+ durationMs?: number;
105
+ }
106
+
107
+ interface AgentLoopSummary {
108
+ changedFiles: Set<string>;
109
+ commands: ExecutedCommand[];
110
+ toolResults: AgentToolResult[];
111
+ }
112
+
113
+ function stableJson(value: unknown): string {
114
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
115
+ if (value && typeof value === "object") {
116
+ const record = value as Record<string, unknown>;
117
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
118
+ }
119
+ return JSON.stringify(value);
120
+ }
121
+
122
+ function toolCallSignature(call: Pick<NormalizedAgentToolCall, "name" | "arguments">): string {
123
+ return `${call.name}:${stableJson(call.arguments)}`;
124
+ }
125
+
126
+ function recordToolResult(summary: AgentLoopSummary, result: AgentToolResult): void {
127
+ for (const file of result.paths ?? []) {
128
+ if (file) summary.changedFiles.add(file);
129
+ }
130
+ if (result.path && (result.tool === "write_file" || result.tool === "apply_patch")) {
131
+ summary.changedFiles.add(result.path);
132
+ }
133
+ if (result.command) {
134
+ summary.commands.push({
135
+ command: result.command,
136
+ success: result.success,
137
+ exitCode: result.exitCode,
138
+ durationMs: result.durationMs,
139
+ });
140
+ }
141
+ summary.toolResults.push(result);
142
+ }
143
+
144
+ function commandStatus(command: ExecutedCommand): string {
145
+ const status = command.success ? "succeeded" : "failed";
146
+ const exitCode = command.exitCode === undefined ? "" : `, exit ${command.exitCode ?? "n/a"}`;
147
+ return `- ${command.command}: ${status}${exitCode}`;
148
+ }
149
+
150
+ function nextCommand(request: ProviderChatRequest, summary: AgentLoopSummary): string {
151
+ if (existsSync(path.join(request.workspaceRoot, "Cargo.toml"))) return "cargo run";
152
+ const lastValidation = [...summary.commands].reverse().find((item) =>
153
+ /\b(?:cargo check|cargo run|bun test|npm test|bun run typecheck|tsc)\b/.test(item.command)
154
+ );
155
+ if (lastValidation) return lastValidation.command;
156
+ return "bun test";
157
+ }
158
+
159
+ function synthesizeFinalMessage(request: ProviderChatRequest, summary: AgentLoopSummary, reason: string): string {
160
+ const files = [...summary.changedFiles].sort();
161
+ const commandLines = summary.commands.map(commandStatus);
162
+ return [
163
+ reason,
164
+ "",
165
+ "Files changed:",
166
+ files.length > 0 ? files.map((file) => `- ${file}`).join("\n") : "- None detected",
167
+ "",
168
+ "Commands run:",
169
+ commandLines.length > 0 ? commandLines.join("\n") : "- None",
170
+ "",
171
+ `Next command: ${nextCommand(request, summary)}`,
172
+ ].join("\n").trim();
173
+ }
174
+
175
+ async function requestFinalAnswer(options: RunAgentLoopOptions, messages: AgentChatMessage[], toolCallCount: number, reason: string): Promise<string | null> {
176
+ messages.push({
177
+ role: "user",
178
+ content: [
179
+ reason,
180
+ "Stop calling tools now. Write the final answer using the tool results already provided.",
181
+ "Include files changed, commands run, whether they succeeded, and the next command the user can run.",
182
+ ].join("\n"),
183
+ });
184
+ const response = await options.sendMessages(messages, toolCallCount);
185
+ const parsed = parseAgentToolCall(response.text);
186
+ return parsed.kind === "final" && parsed.text.trim() ? parsed.text.trim() : null;
187
+ }
188
+
189
+ export async function runAgentLoop(options: RunAgentLoopOptions): Promise<string> {
190
+ const maxToolCalls = options.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
191
+ const messages = buildInitialMessages(options.request, options.includeSystemPrompt);
192
+ let toolCallCount = 0;
193
+ let previousToolSignature: string | null = null;
194
+ const summary: AgentLoopSummary = {
195
+ changedFiles: new Set(),
196
+ commands: [],
197
+ toolResults: [],
198
+ };
199
+
200
+ while (true) {
201
+ if (options.signal?.aborted) {
202
+ throw new Error("Local agent run was canceled.");
203
+ }
204
+
205
+ const response = await options.sendMessages(messages, toolCallCount);
206
+ const structuredToolCall = response.toolCalls?.[0] ?? null;
207
+ const parsed = structuredToolCall
208
+ ? { kind: "tool_call" as const, ...structuredToolCall, raw: JSON.stringify(structuredToolCall) }
209
+ : parseAgentToolCall(response.text);
210
+
211
+ if (parsed.kind === "final") {
212
+ return parsed.text.trim();
213
+ }
214
+
215
+ if (toolCallCount >= maxToolCalls) {
216
+ const reason = `Local agent reached ${maxToolCalls} tool calls without a final answer.`;
217
+ const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
218
+ return final ?? synthesizeFinalMessage(options.request, summary, reason);
219
+ }
220
+
221
+ if (parsed.kind === "malformed_tool_call") {
222
+ messages.push({ role: "assistant", content: response.text });
223
+ toolCallCount += 1;
224
+ messages.push({
225
+ role: "user",
226
+ content: serializeToolResult({
227
+ success: false,
228
+ error: `Malformed tool call: ${parsed.error}`,
229
+ raw: parsed.raw,
230
+ }),
231
+ });
232
+ continue;
233
+ }
234
+
235
+ const signature = toolCallSignature(parsed);
236
+ if (signature === previousToolSignature) {
237
+ const reason = `Local agent repeated the same ${parsed.name} tool call with identical arguments.`;
238
+ const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
239
+ return final ?? synthesizeFinalMessage(options.request, summary, reason);
240
+ }
241
+
242
+ messages.push({ role: "assistant", content: response.text || parsed.raw });
243
+ previousToolSignature = signature;
244
+ toolCallCount += 1;
245
+ const activityId = `local-agent-${toolCallCount}-${parsed.name}`;
246
+ const startedAt = Date.now();
247
+ const runningCommand = toolActivityCommand({
248
+ tool: parsed.name,
249
+ path: typeof parsed.arguments.path === "string" ? parsed.arguments.path : undefined,
250
+ command: typeof parsed.arguments.command === "string" ? parsed.arguments.command : undefined,
251
+ });
252
+ options.handlers.onToolActivity?.({
253
+ id: activityId,
254
+ command: runningCommand,
255
+ status: "running",
256
+ startedAt,
257
+ });
258
+
259
+ const result = await executeAgentTool(parsed.name, parsed.arguments, {
260
+ workspaceRoot: options.request.workspaceRoot,
261
+ runtime: options.request.runtime,
262
+ signal: options.signal,
263
+ });
264
+ const completedCommand = toolActivityCommand(result);
265
+ options.handlers.onToolActivity?.({
266
+ id: activityId,
267
+ command: completedCommand,
268
+ status: result.success ? "completed" : "failed",
269
+ startedAt,
270
+ completedAt: Date.now(),
271
+ summary: result.summary ?? result.error ?? null,
272
+ });
273
+ recordToolResult(summary, result);
274
+
275
+ messages.push({
276
+ role: "user",
277
+ content: serializeToolResult(result),
278
+ });
279
+ }
280
+
281
+ throw new Error(`Local agent stopped after ${maxToolCalls} tool calls without a final answer.`);
282
+ }
@@ -0,0 +1,211 @@
1
+ export type AgentToolName =
2
+ | "list_files"
3
+ | "read_file"
4
+ | "write_file"
5
+ | "apply_patch"
6
+ | "run_shell"
7
+ | "get_workspace_info";
8
+
9
+ export interface ParsedAgentToolCall {
10
+ kind: "tool_call";
11
+ name: AgentToolName;
12
+ arguments: Record<string, unknown>;
13
+ raw: string;
14
+ }
15
+
16
+ export interface NormalizedAgentToolCall {
17
+ name: AgentToolName;
18
+ arguments: Record<string, unknown>;
19
+ }
20
+
21
+ export interface MalformedAgentToolCall {
22
+ kind: "malformed_tool_call";
23
+ raw: string;
24
+ error: string;
25
+ }
26
+
27
+ export type AgentToolParseResult =
28
+ | { kind: "final"; text: string }
29
+ | ParsedAgentToolCall
30
+ | MalformedAgentToolCall;
31
+
32
+ const TOOL_CALL_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/i;
33
+ const TOOL_CALL_OPEN_PATTERN = /<tool_call\b[^>]*>/i;
34
+
35
+ const TOOL_NAMES = new Set<AgentToolName>([
36
+ "list_files",
37
+ "read_file",
38
+ "write_file",
39
+ "apply_patch",
40
+ "run_shell",
41
+ "get_workspace_info",
42
+ ]);
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function parseJsonMaybeWithExtraBrace(raw: string): unknown {
49
+ try {
50
+ return JSON.parse(raw) as unknown;
51
+ } catch (firstError) {
52
+ const trimmed = raw.trim();
53
+ if (trimmed.endsWith("}")) {
54
+ try {
55
+ return JSON.parse(trimmed.slice(0, -1)) as unknown;
56
+ } catch {
57
+ // Return the original parse error below.
58
+ }
59
+ }
60
+ throw firstError;
61
+ }
62
+ }
63
+
64
+ function parseArguments(value: unknown): Record<string, unknown> | null {
65
+ if (value === undefined || value === null) return {};
66
+ if (isRecord(value)) return value;
67
+ if (typeof value === "string") {
68
+ try {
69
+ const parsed = JSON.parse(value) as unknown;
70
+ return isRecord(parsed) ? parsed : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ export function normalizeAgentToolCall(value: unknown): NormalizedAgentToolCall | null {
79
+ if (!isRecord(value)) return null;
80
+
81
+ const functionCall = isRecord(value.function) ? value.function : null;
82
+ const rawName = functionCall?.name ?? value.name ?? value.tool;
83
+ if (typeof rawName !== "string" || !TOOL_NAMES.has(rawName as AgentToolName)) {
84
+ return null;
85
+ }
86
+
87
+ const args = parseArguments(functionCall?.arguments ?? value.arguments ?? value.args);
88
+ if (!args) return null;
89
+
90
+ return {
91
+ name: rawName as AgentToolName,
92
+ arguments: args,
93
+ };
94
+ }
95
+
96
+ export function parseOpenAiToolCalls(value: unknown): NormalizedAgentToolCall[] {
97
+ if (!Array.isArray(value)) return [];
98
+ return value
99
+ .map((item) => normalizeAgentToolCall(item))
100
+ .filter((item): item is NormalizedAgentToolCall => Boolean(item));
101
+ }
102
+
103
+ function extractJsonObjectAfterToolCall(text: string): string | null {
104
+ const open = TOOL_CALL_OPEN_PATTERN.exec(text);
105
+ if (!open) return null;
106
+
107
+ const startSearch = open.index + open[0].length;
108
+ const firstBrace = text.indexOf("{", startSearch);
109
+ if (firstBrace < 0) return text.slice(startSearch).trim();
110
+
111
+ let depth = 0;
112
+ let inString = false;
113
+ let escaped = false;
114
+ for (let index = firstBrace; index < text.length; index += 1) {
115
+ const char = text[index];
116
+ if (escaped) {
117
+ escaped = false;
118
+ continue;
119
+ }
120
+ if (char === "\\") {
121
+ escaped = inString;
122
+ continue;
123
+ }
124
+ if (char === "\"") {
125
+ inString = !inString;
126
+ continue;
127
+ }
128
+ if (inString) continue;
129
+ if (char === "{") depth += 1;
130
+ if (char === "}") {
131
+ depth -= 1;
132
+ if (depth === 0) {
133
+ return text.slice(firstBrace, index + 1).trim();
134
+ }
135
+ }
136
+ }
137
+
138
+ return text.slice(firstBrace).replace(/<\/tool_call>.*/is, "").trim();
139
+ }
140
+
141
+ function parseToolCallPayload(raw: string): AgentToolParseResult {
142
+ try {
143
+ const parsed = parseJsonMaybeWithExtraBrace(raw);
144
+ const fromToolCalls = isRecord(parsed) ? parseOpenAiToolCalls(parsed.tool_calls) : [];
145
+ const normalized = fromToolCalls[0] ?? normalizeAgentToolCall(parsed);
146
+ if (!normalized) {
147
+ return { kind: "malformed_tool_call", raw, error: "Tool call JSON did not contain a supported tool call." };
148
+ }
149
+
150
+ return {
151
+ kind: "tool_call",
152
+ ...normalized,
153
+ raw,
154
+ };
155
+ } catch (error) {
156
+ return {
157
+ kind: "malformed_tool_call",
158
+ raw,
159
+ error: error instanceof Error ? error.message : String(error),
160
+ };
161
+ }
162
+ }
163
+
164
+ export function parseAgentToolCall(text: string): AgentToolParseResult {
165
+ const closedMatch = TOOL_CALL_PATTERN.exec(text);
166
+ const raw = closedMatch?.[1]?.trim() ?? extractJsonObjectAfterToolCall(text);
167
+ if (!raw) {
168
+ return TOOL_CALL_OPEN_PATTERN.test(text)
169
+ ? { kind: "malformed_tool_call", raw: "", error: "Tool call block did not contain JSON." }
170
+ : { kind: "final", text };
171
+ }
172
+
173
+ return parseToolCallPayload(raw);
174
+ }
175
+
176
+ function scalar(value: unknown): string {
177
+ if (value === undefined || value === null) return "";
178
+ if (typeof value === "string") return value;
179
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
180
+ return JSON.stringify(value);
181
+ }
182
+
183
+ function blockField(name: string, value: unknown): string[] {
184
+ return [`${name}:`, scalar(value)];
185
+ }
186
+
187
+ export function serializeToolResult(result: unknown): string {
188
+ const record = isRecord(result) ? result : {};
189
+ const tool = typeof record.tool === "string" ? record.tool : "unknown";
190
+ const lines: string[] = [`<tool_result name="${tool}">`];
191
+
192
+ lines.push(`success: ${scalar(record.success)}`);
193
+ if (record.command !== undefined) lines.push(`command: ${scalar(record.command)}`);
194
+ if (record.path !== undefined) lines.push(`path: ${scalar(record.path)}`);
195
+ if (record.paths !== undefined) lines.push(`paths: ${Array.isArray(record.paths) ? record.paths.join(", ") : scalar(record.paths)}`);
196
+ if (record.exitCode !== undefined) lines.push(`exit_code: ${scalar(record.exitCode)}`);
197
+ if (record.durationMs !== undefined) lines.push(`duration_ms: ${scalar(record.durationMs)}`);
198
+ if (record.summary !== undefined) lines.push(...blockField("summary", record.summary));
199
+ if (record.error !== undefined) lines.push(...blockField("error", record.error));
200
+
201
+ if (tool === "run_shell") {
202
+ lines.push(...blockField("stdout", record.stdout ?? record.output ?? ""));
203
+ lines.push(...blockField("stderr", record.stderr ?? ""));
204
+ } else if (record.output !== undefined) {
205
+ lines.push(...blockField("output", record.output));
206
+ }
207
+
208
+ if (record.raw !== undefined) lines.push(...blockField("raw", record.raw));
209
+ lines.push("</tool_result>");
210
+ return lines.join("\n");
211
+ }