@agentex/agent 0.0.23 → 0.0.26

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 (177) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/dist/derived.d.ts +5 -3
  5. package/dist/derived.d.ts.map +1 -1
  6. package/dist/derived.js +11 -7
  7. package/dist/derived.js.map +1 -1
  8. package/dist/index.d.ts +7 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/acp/index.d.ts +1 -1
  13. package/dist/providers/acp/index.d.ts.map +1 -1
  14. package/dist/providers/acp/index.js +5 -97
  15. package/dist/providers/acp/index.js.map +1 -1
  16. package/dist/providers/acp/session.d.ts +8 -1
  17. package/dist/providers/acp/session.d.ts.map +1 -1
  18. package/dist/providers/acp/session.js +94 -0
  19. package/dist/providers/acp/session.js.map +1 -1
  20. package/dist/providers/claude/attach.d.ts +8 -0
  21. package/dist/providers/claude/attach.d.ts.map +1 -0
  22. package/dist/providers/claude/attach.js +113 -0
  23. package/dist/providers/claude/attach.js.map +1 -0
  24. package/dist/providers/claude/execute.d.ts.map +1 -1
  25. package/dist/providers/claude/execute.js +17 -2
  26. package/dist/providers/claude/execute.js.map +1 -1
  27. package/dist/providers/claude/goal-capability.d.ts +15 -0
  28. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  29. package/dist/providers/claude/goal-capability.js +20 -0
  30. package/dist/providers/claude/goal-capability.js.map +1 -0
  31. package/dist/providers/claude/index.d.ts.map +1 -1
  32. package/dist/providers/claude/index.js +8 -4
  33. package/dist/providers/claude/index.js.map +1 -1
  34. package/dist/providers/claude/session.d.ts +11 -9
  35. package/dist/providers/claude/session.d.ts.map +1 -1
  36. package/dist/providers/claude/session.js +36 -14
  37. package/dist/providers/claude/session.js.map +1 -1
  38. package/dist/providers/codex/attach.d.ts +9 -0
  39. package/dist/providers/codex/attach.d.ts.map +1 -0
  40. package/dist/providers/codex/attach.js +93 -0
  41. package/dist/providers/codex/attach.js.map +1 -0
  42. package/dist/providers/codex/execute.d.ts.map +1 -1
  43. package/dist/providers/codex/execute.js +17 -3
  44. package/dist/providers/codex/execute.js.map +1 -1
  45. package/dist/providers/codex/goal-capability.d.ts +13 -0
  46. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  47. package/dist/providers/codex/goal-capability.js +18 -0
  48. package/dist/providers/codex/goal-capability.js.map +1 -0
  49. package/dist/providers/codex/index.d.ts +1 -0
  50. package/dist/providers/codex/index.d.ts.map +1 -1
  51. package/dist/providers/codex/index.js +9 -6
  52. package/dist/providers/codex/index.js.map +1 -1
  53. package/dist/providers/codex/session.d.ts +11 -7
  54. package/dist/providers/codex/session.d.ts.map +1 -1
  55. package/dist/providers/codex/session.js +37 -12
  56. package/dist/providers/codex/session.js.map +1 -1
  57. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  58. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  59. package/dist/providers/codex/transcript-normalize.js +191 -0
  60. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  61. package/dist/providers/cursor/index.d.ts.map +1 -1
  62. package/dist/providers/cursor/index.js +2 -2
  63. package/dist/providers/cursor/index.js.map +1 -1
  64. package/dist/providers/openclaw/index.d.ts.map +1 -1
  65. package/dist/providers/openclaw/index.js +2 -2
  66. package/dist/providers/openclaw/index.js.map +1 -1
  67. package/dist/providers/opencode/index.d.ts.map +1 -1
  68. package/dist/providers/opencode/index.js +3 -5
  69. package/dist/providers/opencode/index.js.map +1 -1
  70. package/dist/providers/pi/index.d.ts.map +1 -1
  71. package/dist/providers/pi/index.js +3 -5
  72. package/dist/providers/pi/index.js.map +1 -1
  73. package/dist/providers/process/index.d.ts.map +1 -1
  74. package/dist/providers/process/index.js +2 -2
  75. package/dist/providers/process/index.js.map +1 -1
  76. package/dist/registry.d.ts +0 -1
  77. package/dist/registry.d.ts.map +1 -1
  78. package/dist/registry.js +0 -4
  79. package/dist/registry.js.map +1 -1
  80. package/dist/sessions/index.d.ts +3 -0
  81. package/dist/sessions/index.d.ts.map +1 -0
  82. package/dist/sessions/index.js +2 -0
  83. package/dist/sessions/index.js.map +1 -0
  84. package/dist/sessions/record.d.ts +43 -0
  85. package/dist/sessions/record.d.ts.map +1 -0
  86. package/dist/sessions/record.js +85 -0
  87. package/dist/sessions/record.js.map +1 -0
  88. package/dist/types.d.ts +176 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/dist/utils/endpoint.d.ts +38 -0
  92. package/dist/utils/endpoint.d.ts.map +1 -0
  93. package/dist/utils/endpoint.js +151 -0
  94. package/dist/utils/endpoint.js.map +1 -0
  95. package/dist/utils/env.d.ts.map +1 -1
  96. package/dist/utils/env.js +5 -1
  97. package/dist/utils/env.js.map +1 -1
  98. package/dist/utils/uuid.d.ts +7 -1
  99. package/dist/utils/uuid.d.ts.map +1 -1
  100. package/dist/utils/uuid.js +21 -1
  101. package/dist/utils/uuid.js.map +1 -1
  102. package/package.json +64 -7
  103. package/src/derived.ts +311 -0
  104. package/src/goals/controller.ts +442 -0
  105. package/src/goals/index.ts +21 -0
  106. package/src/goals/normalize.ts +173 -0
  107. package/src/goals/sentinel.ts +90 -0
  108. package/src/index.ts +270 -0
  109. package/src/providers/_shared/http-agent.ts +304 -0
  110. package/src/providers/acp/index.ts +103 -0
  111. package/src/providers/acp/parse.ts +131 -0
  112. package/src/providers/acp/session.ts +744 -0
  113. package/src/providers/claude/attach.ts +147 -0
  114. package/src/providers/claude/codec.ts +43 -0
  115. package/src/providers/claude/execute.ts +300 -0
  116. package/src/providers/claude/goal-capability.ts +21 -0
  117. package/src/providers/claude/index.ts +72 -0
  118. package/src/providers/claude/mcp.ts +82 -0
  119. package/src/providers/claude/parse.ts +824 -0
  120. package/src/providers/claude/session.ts +1192 -0
  121. package/src/providers/claude/transcript.ts +555 -0
  122. package/src/providers/codex/attach.ts +123 -0
  123. package/src/providers/codex/codec.ts +50 -0
  124. package/src/providers/codex/execute.ts +337 -0
  125. package/src/providers/codex/goal-capability.ts +19 -0
  126. package/src/providers/codex/index.ts +57 -0
  127. package/src/providers/codex/modes.ts +159 -0
  128. package/src/providers/codex/parse.ts +691 -0
  129. package/src/providers/codex/plan-mode.ts +49 -0
  130. package/src/providers/codex/session.ts +1287 -0
  131. package/src/providers/codex/transcript-normalize.ts +197 -0
  132. package/src/providers/codex/transcript.ts +487 -0
  133. package/src/providers/codex/usage-scanner.ts +178 -0
  134. package/src/providers/copilot/index.ts +19 -0
  135. package/src/providers/cursor/codec.ts +44 -0
  136. package/src/providers/cursor/execute.ts +271 -0
  137. package/src/providers/cursor/index.ts +25 -0
  138. package/src/providers/cursor/parse.ts +288 -0
  139. package/src/providers/gemini/index.ts +21 -0
  140. package/src/providers/openclaw/codec.ts +40 -0
  141. package/src/providers/openclaw/execute.ts +19 -0
  142. package/src/providers/openclaw/index.ts +29 -0
  143. package/src/providers/opencode/codec.ts +50 -0
  144. package/src/providers/opencode/event-parse.ts +141 -0
  145. package/src/providers/opencode/execute.ts +251 -0
  146. package/src/providers/opencode/http-session.ts +427 -0
  147. package/src/providers/opencode/index.ts +30 -0
  148. package/src/providers/opencode/parse.ts +203 -0
  149. package/src/providers/opencode/server.ts +0 -0
  150. package/src/providers/pi/codec.ts +44 -0
  151. package/src/providers/pi/execute.ts +297 -0
  152. package/src/providers/pi/index.ts +30 -0
  153. package/src/providers/pi/parse.ts +231 -0
  154. package/src/providers/pi/session.ts +381 -0
  155. package/src/providers/process/execute.ts +148 -0
  156. package/src/providers/process/index.ts +52 -0
  157. package/src/registry.ts +40 -0
  158. package/src/sessions/index.ts +8 -0
  159. package/src/sessions/record.ts +108 -0
  160. package/src/types.ts +1638 -0
  161. package/src/utils/ask-user-question.ts +57 -0
  162. package/src/utils/auth.ts +661 -0
  163. package/src/utils/binary.ts +179 -0
  164. package/src/utils/endpoint.ts +172 -0
  165. package/src/utils/env.ts +63 -0
  166. package/src/utils/execute-all.ts +68 -0
  167. package/src/utils/exit-plan-mode.ts +40 -0
  168. package/src/utils/instructions.ts +427 -0
  169. package/src/utils/process.ts +223 -0
  170. package/src/utils/runtime-config.ts +100 -0
  171. package/src/utils/runtime-homes.ts +49 -0
  172. package/src/utils/skill-commands.ts +493 -0
  173. package/src/utils/skills.ts +500 -0
  174. package/src/utils/template.ts +16 -0
  175. package/src/utils/tool-names.ts +51 -0
  176. package/src/utils/uuid.ts +21 -0
  177. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,744 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
+ import { Readable, Writable } from "node:stream";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import type {
6
+ AgentSession,
7
+ AgentMode,
8
+ CancelResult,
9
+ ClearGoalResult,
10
+ ExecutionContext,
11
+ ExecutionResult,
12
+ ExecutionStatus,
13
+ GoalOptions,
14
+ GoalState,
15
+ StopTaskResult,
16
+ SendHandle,
17
+ SendOptions,
18
+ SessionContext,
19
+ SessionState,
20
+ SetGoalResult,
21
+ StreamEvent,
22
+ TurnResult,
23
+ } from "../../types.js";
24
+ import { GoalController, EMULATED_GOAL_CAPABILITY } from "../../goals/index.js";
25
+ import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
26
+ import { uuidv7 } from "../../utils/uuid.js";
27
+ import { extractContentText, mapAcpStopReason, mapAcpUpdate } from "./parse.js";
28
+
29
+ // Type-only SDK imports — erased at runtime so the SDK is only loaded when a
30
+ // session is actually created (via the dynamic import in `connect`).
31
+ import type {
32
+ Client,
33
+ RequestPermissionRequest,
34
+ RequestPermissionResponse,
35
+ SessionNotification,
36
+ } from "@agentclientprotocol/sdk";
37
+
38
+ /**
39
+ * Optional hooks to absorb a specific ACP agent's quirks without forking the
40
+ * base — Paseo's pattern (e.g. Copilot hides a deprecated "autopilot" mode and
41
+ * exposes a synthetic "allow-all"). Keep the base generic; put weirdness here.
42
+ */
43
+ export interface AcpTransformers {
44
+ /** Rewrite the discovered mode list (filter, rename, add synthetic modes). */
45
+ modes?: (modes: AgentMode[]) => AgentMode[];
46
+ /** Map a requested mode id to the protocol mode id before `setSessionMode`. */
47
+ modeId?: (modeId: string) => string;
48
+ }
49
+
50
+ /** What `acpProvider` hands a session: how to spawn + drive the agent. */
51
+ export interface AcpSessionDeps {
52
+ /** Provider id (used for event `providerType`). */
53
+ provider: string;
54
+ /** Command to spawn: [binary, ...args]. e.g. ["gemini", "--acp"]. */
55
+ command: string[];
56
+ /** Environment overlay. */
57
+ env?: Record<string, string>;
58
+ /** Default mode id applied on session creation. */
59
+ modeId?: string;
60
+ /** Per-agent quirk transformers. */
61
+ transformers?: AcpTransformers;
62
+ }
63
+
64
+ /** The slice of the ACP `ClientSideConnection` surface this session uses. */
65
+ interface AcpConnection {
66
+ initialize(params: unknown): Promise<{
67
+ protocolVersion?: number;
68
+ agentCapabilities?: unknown;
69
+ authMethods?: unknown;
70
+ }>;
71
+ newSession(params: unknown): Promise<{ sessionId: string; modes?: unknown; models?: unknown }>;
72
+ loadSession(params: unknown): Promise<{ modes?: unknown; models?: unknown }>;
73
+ prompt(params: unknown): Promise<{ stopReason?: string }>;
74
+ cancel(params: unknown): Promise<void>;
75
+ setSessionMode?(params: unknown): Promise<unknown>;
76
+ }
77
+
78
+ /** Reject if `p` doesn't settle within `ms` — bounds the handshake so a hung
79
+ * agent binary can't hang connect()/listModes() indefinitely. */
80
+ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
81
+ return Promise.race([
82
+ p,
83
+ new Promise<never>((_, reject) =>
84
+ setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),
85
+ ),
86
+ ]);
87
+ }
88
+
89
+ /** Resume session id from sessionParams (sessionId / session_id), or null. */
90
+ function readAcpResumeId(sp: Record<string, unknown> | null | undefined): string | null {
91
+ if (!sp) return null;
92
+ const id = sp["sessionId"] ?? sp["session_id"];
93
+ return typeof id === "string" && id.length > 0 ? id : null;
94
+ }
95
+
96
+ const ACP_HANDSHAKE_TIMEOUT_MS = 30_000;
97
+
98
+ interface AcpSdk {
99
+ ndJsonStream(
100
+ output: WritableStream<Uint8Array>,
101
+ input: ReadableStream<Uint8Array>,
102
+ ): unknown;
103
+ ClientSideConnection: new (toClient: (agent: unknown) => Client, stream: unknown) => AcpConnection;
104
+ PROTOCOL_VERSION: number;
105
+ }
106
+
107
+ /** Spawn the agent, run the ACP handshake, and return a connected session. */
108
+ export async function createAcpSession(
109
+ deps: AcpSessionDeps,
110
+ ctx: SessionContext,
111
+ ): Promise<AgentSession> {
112
+ const session = new AcpSession(deps, ctx);
113
+ await session.connect();
114
+ return session;
115
+ }
116
+
117
+ class AcpSession implements AgentSession {
118
+ private _state: SessionState = "idle";
119
+ private _sessionId: string | null = null;
120
+ private proc: ChildProcess | null = null;
121
+ private connection: AcpConnection | null = null;
122
+
123
+ /** A turn is in flight (ACP runs one prompt at a time). */
124
+ private _turnActive = false;
125
+ /** Accumulated assistant text for the current turn → TurnResult.summary. */
126
+ private _turnText = "";
127
+ private _inFlight: Promise<TurnResult> | null = null;
128
+ private _draining = false;
129
+ /** toolCallId → tool name, so tool_call_update can report a name. Cleared per turn. */
130
+ private readonly _toolNames = new Map<string, string>();
131
+
132
+ /** Goal engine. ACP agents expose no native goal surface — always emulated. */
133
+ private readonly _goals: GoalController;
134
+
135
+ constructor(
136
+ private readonly deps: AcpSessionDeps,
137
+ private readonly ctx: SessionContext,
138
+ ) {
139
+ this._goals = new GoalController({
140
+ providerType: this.deps.provider,
141
+ capability: EMULATED_GOAL_CAPABILITY,
142
+ getSessionId: () => this._sessionId,
143
+ send: (m) => this.send(m),
144
+ dispatch: (event: StreamEvent) => {
145
+ if (this.ctx.onEvent) void Promise.resolve(this.ctx.onEvent(event)).catch(() => {});
146
+ },
147
+ });
148
+ }
149
+
150
+ get sessionId(): string | null {
151
+ return this._sessionId;
152
+ }
153
+ get state(): SessionState {
154
+ return this._state;
155
+ }
156
+
157
+ // -------------------------------------------------------------------------
158
+ // Connect — spawn + initialize + newSession
159
+ // -------------------------------------------------------------------------
160
+
161
+ async connect(): Promise<void> {
162
+ const acp = (await import("@agentclientprotocol/sdk")) as unknown as AcpSdk;
163
+
164
+ const binary = this.ctx.config?.command ?? this.deps.command[0]!;
165
+ const args = this.deps.command.slice(1);
166
+ const env = buildEnv({ ...this.deps.env, ...this.ctx.env });
167
+ ensurePathInEnv(env);
168
+
169
+ const proc = spawn(binary, args, {
170
+ cwd: this.ctx.cwd ?? process.cwd(),
171
+ env,
172
+ stdio: ["pipe", "pipe", "pipe"],
173
+ });
174
+ this.proc = proc;
175
+
176
+ proc.stderr?.setEncoding("utf-8");
177
+ proc.stderr?.on("data", (chunk: string) => {
178
+ if (this.ctx.onOutput) {
179
+ try {
180
+ void this.ctx.onOutput("stderr", chunk);
181
+ } catch {
182
+ /* swallow */
183
+ }
184
+ }
185
+ });
186
+ proc.on("exit", () => {
187
+ if (this._state !== "closed") this._state = "closed";
188
+ });
189
+ // Catch spawn failures (ENOENT, EACCES) — an unhandled 'error' event would
190
+ // otherwise crash the host process.
191
+ proc.on("error", () => {
192
+ if (this._state !== "closed") this._state = "closed";
193
+ });
194
+
195
+ if (!proc.stdin || !proc.stdout) throw new Error("Failed to open stdio on ACP agent process");
196
+
197
+ // fs callbacks do unconfined disk I/O on agent-supplied paths — equivalent to
198
+ // the agent's own filesystem access (it's a local subprocess with the user's
199
+ // privileges), so this isn't a sandbox escape, just coordination.
200
+ try {
201
+ const writable = Writable.toWeb(proc.stdin) as unknown as WritableStream<Uint8Array>;
202
+ const readable = Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>;
203
+ const stream = acp.ndJsonStream(writable, readable);
204
+
205
+ const client: Client = {
206
+ requestPermission: (params) => this.handlePermission(params),
207
+ sessionUpdate: (params) => this.handleSessionUpdate(params),
208
+ readTextFile: async (params) => {
209
+ const content = await readFile((params as { path: string }).path, "utf-8");
210
+ return { content };
211
+ },
212
+ writeTextFile: async (params) => {
213
+ await writeFile(
214
+ (params as { path: string; content: string }).path,
215
+ (params as { content: string }).content,
216
+ "utf-8",
217
+ );
218
+ return {};
219
+ },
220
+ };
221
+
222
+ this.connection = new acp.ClientSideConnection(() => client, stream);
223
+
224
+ const initResult = await withTimeout(
225
+ this.connection.initialize({
226
+ protocolVersion: acp.PROTOCOL_VERSION,
227
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
228
+ }),
229
+ ACP_HANDSHAKE_TIMEOUT_MS,
230
+ "acp initialize",
231
+ );
232
+ const agentCaps = (initResult as { agentCapabilities?: { loadSession?: boolean } }).agentCapabilities;
233
+ const cwd = this.ctx.cwd ?? process.cwd();
234
+
235
+ // Resume an existing session when the caller supplied one AND the agent
236
+ // advertises `loadSession`; otherwise start fresh. (ACP `session/load`
237
+ // replays history via notifications, which our turn-isolation gate drops —
238
+ // we want continuity, not a re-emit.) When resume isn't possible the new
239
+ // session id supersedes the caller's, so saved params naturally roll over.
240
+ const resumeId = readAcpResumeId(this.ctx.sessionParams);
241
+ let loaded = false;
242
+ if (resumeId && agentCaps?.loadSession && this.connection.loadSession) {
243
+ try {
244
+ await withTimeout(
245
+ this.connection.loadSession({ sessionId: resumeId, cwd, mcpServers: [] }),
246
+ ACP_HANDSHAKE_TIMEOUT_MS,
247
+ "acp loadSession",
248
+ );
249
+ this._sessionId = resumeId;
250
+ loaded = true;
251
+ } catch (err) {
252
+ if (this.ctx.onOutput) {
253
+ const reason = err instanceof Error ? err.message : String(err);
254
+ try {
255
+ void this.ctx.onOutput("stderr", `agentex: acp session/load failed for ${resumeId}, starting fresh: ${reason}\n`);
256
+ } catch { /* swallow */ }
257
+ }
258
+ }
259
+ }
260
+ if (!loaded) {
261
+ const res = await withTimeout(
262
+ this.connection.newSession({ cwd, mcpServers: [] }),
263
+ ACP_HANDSHAKE_TIMEOUT_MS,
264
+ "acp newSession",
265
+ );
266
+ this._sessionId = typeof res.sessionId === "string" ? res.sessionId : null;
267
+ }
268
+
269
+ const requested = this.ctx.config?.modeId ?? this.deps.modeId;
270
+ if (requested && this._sessionId && this.connection.setSessionMode) {
271
+ const modeId = this.deps.transformers?.modeId
272
+ ? this.deps.transformers.modeId(requested)
273
+ : requested;
274
+ try {
275
+ await this.connection.setSessionMode({ sessionId: this._sessionId, modeId });
276
+ } catch {
277
+ /* mode application is best-effort */
278
+ }
279
+ }
280
+ } catch (err) {
281
+ // Spawn/handshake failed — tear down so we don't leak the child process.
282
+ this._state = "closed";
283
+ try {
284
+ proc.kill();
285
+ } catch {
286
+ /* ignore */
287
+ }
288
+ throw err;
289
+ }
290
+
291
+ // Wire AbortSignal → close.
292
+ if (this.ctx.signal) {
293
+ if (this.ctx.signal.aborted) void this.close();
294
+ else this.ctx.signal.addEventListener("abort", () => void this.close(), { once: true });
295
+ }
296
+ }
297
+
298
+ // -------------------------------------------------------------------------
299
+ // Client callbacks (agent → us)
300
+ // -------------------------------------------------------------------------
301
+
302
+ private async handleSessionUpdate(params: SessionNotification): Promise<void> {
303
+ const update = (params as { update?: unknown }).update as Record<string, unknown> | undefined;
304
+ if (!update) return;
305
+ // Drop stragglers from a finished/aborted turn (ACP events aren't turn-tagged,
306
+ // so a late update would otherwise be attributed to the next turn).
307
+ if (!this._turnActive) return;
308
+
309
+ const kind = update["sessionUpdate"];
310
+ if (kind === "agent_message_chunk") {
311
+ const text = extractContentText(update["content"]);
312
+ if (text) this._turnText += text;
313
+ }
314
+ // Cache tool names from the initial tool_call so tool_call_update (which often
315
+ // omits `title`) can still report a toolName.
316
+ if (kind === "tool_call") {
317
+ const id = update["toolCallId"];
318
+ const title = update["title"];
319
+ if (typeof id === "string" && typeof title === "string" && title) {
320
+ this._toolNames.set(id, title);
321
+ }
322
+ }
323
+
324
+ const event = mapAcpUpdate(update, {
325
+ provider: this.deps.provider,
326
+ sessionId: this._sessionId,
327
+ timestamp: new Date().toISOString(),
328
+ });
329
+ if (event) {
330
+ if (event.type === "tool_result" && !event.toolName && event.toolCallId) {
331
+ const cached = this._toolNames.get(event.toolCallId);
332
+ if (cached) event.toolName = cached;
333
+ }
334
+ if (this.ctx.onEvent) {
335
+ try {
336
+ await this.ctx.onEvent(event);
337
+ } catch {
338
+ /* a throwing handler must not break the stream */
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ private async handlePermission(
345
+ params: RequestPermissionRequest,
346
+ ): Promise<RequestPermissionResponse> {
347
+ const toolCall = ((params as { toolCall?: unknown }).toolCall ?? {}) as Record<string, unknown>;
348
+ const options = (((params as { options?: unknown }).options ?? []) as Array<Record<string, unknown>>);
349
+
350
+ const select = (allow: boolean): RequestPermissionResponse => {
351
+ const want = allow ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
352
+ const opt = options.find((o) => want.includes(String(o["kind"]))) ?? options[0];
353
+ if (!opt) return { outcome: { outcome: "cancelled" } } as RequestPermissionResponse;
354
+ return {
355
+ outcome: { outcome: "selected", optionId: String(opt["optionId"]) },
356
+ } as RequestPermissionResponse;
357
+ };
358
+
359
+ this._state = "waiting_for_approval";
360
+ const restore = (): void => {
361
+ if (this._state === "waiting_for_approval") this._state = this._turnActive ? "thinking" : "idle";
362
+ };
363
+
364
+ if (!this.ctx.onUserInputRequest) {
365
+ const r = select(true);
366
+ restore();
367
+ return r;
368
+ }
369
+
370
+ try {
371
+ const result = await this.ctx.onUserInputRequest({
372
+ toolName: String(toolCall["title"] ?? toolCall["kind"] ?? "tool"),
373
+ input: (toolCall["rawInput"] as Record<string, unknown>) ?? toolCall,
374
+ toolUseId: String(toolCall["toolCallId"] ?? ""),
375
+ ...(typeof toolCall["title"] === "string" ? { title: toolCall["title"] as string } : {}),
376
+ });
377
+ const r = select(result.allow);
378
+ restore();
379
+ return r;
380
+ } catch {
381
+ const r = select(false);
382
+ restore();
383
+ return r;
384
+ }
385
+ }
386
+
387
+ // -------------------------------------------------------------------------
388
+ // Public API
389
+ // -------------------------------------------------------------------------
390
+
391
+ async send(message: string, options?: SendOptions): Promise<SendHandle> {
392
+ if (this._state === "closed") throw new Error("Session is closed");
393
+ if (this._draining) throw new Error("Session is draining — no new sends accepted");
394
+ if (this._turnActive) {
395
+ throw new Error("ACP session is busy — a turn is already in progress (concurrentSend not supported)");
396
+ }
397
+
398
+ const uuid = uuidv7();
399
+ this._turnActive = true;
400
+ this._turnText = "";
401
+ this._toolNames.clear();
402
+ this._state = "thinking";
403
+
404
+ const result = this.runPrompt(message, options);
405
+ this._inFlight = result;
406
+ // Advance any emulated goal loop once the turn settles (runPrompt calls
407
+ // finishTurn before resolving, so the session is idle by then).
408
+ void result.then((r) => this._goals.onTurnSettled(r)).catch(() => {});
409
+ return { uuid, result };
410
+ }
411
+
412
+ private finishTurn(): void {
413
+ this._turnActive = false;
414
+ this._inFlight = null;
415
+ if (this._state !== "closed") this._state = "idle";
416
+ }
417
+
418
+ private async runPrompt(message: string, options?: SendOptions): Promise<TurnResult> {
419
+ const sessionId = this._sessionId;
420
+ if (!this.connection || !sessionId) {
421
+ this.finishTurn();
422
+ return {
423
+ summary: null,
424
+ costUsd: null,
425
+ status: "failed",
426
+ errorCode: "not_initialized",
427
+ errorMessage: "ACP session is not initialized",
428
+ };
429
+ }
430
+
431
+ const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
432
+ let timer: ReturnType<typeof setTimeout> | null = null;
433
+
434
+ const promptP = this.connection
435
+ .prompt({ sessionId, prompt: [{ type: "text", text: message }] })
436
+ .then((res) => ({ ok: true as const, res }))
437
+ .catch((err: unknown) => ({
438
+ ok: false as const,
439
+ error: err instanceof Error ? err.message : String(err),
440
+ }));
441
+
442
+ const guards: Promise<"timeout" | "aborted">[] = [];
443
+ if (timeoutSec && timeoutSec > 0) {
444
+ guards.push(
445
+ new Promise((resolve) => {
446
+ timer = setTimeout(() => resolve("timeout"), timeoutSec * 1000);
447
+ }),
448
+ );
449
+ }
450
+ if (options?.signal) {
451
+ if (options.signal.aborted) guards.push(Promise.resolve("aborted"));
452
+ else
453
+ guards.push(
454
+ new Promise((resolve) =>
455
+ options.signal!.addEventListener("abort", () => resolve("aborted"), { once: true }),
456
+ ),
457
+ );
458
+ }
459
+
460
+ const outcome = await Promise.race([promptP.then(() => "prompt" as const), ...guards]);
461
+ if (timer) clearTimeout(timer);
462
+
463
+ if (outcome === "timeout" || outcome === "aborted") {
464
+ try {
465
+ await this.interrupt();
466
+ } catch {
467
+ /* best effort */
468
+ }
469
+ // Drain the cancelled turn before finishing: ACP guarantees the agent
470
+ // flushes any pending session/update notifications and then resolves the
471
+ // prompt as cancelled. Awaiting it (bounded) keeps those stragglers inside
472
+ // THIS turn's window so they can't bleed into the next turn's text/events.
473
+ const summaryBeforeDrain = this._turnText;
474
+ await Promise.race([
475
+ promptP,
476
+ new Promise<void>((resolve) => setTimeout(resolve, 2000)),
477
+ ]);
478
+ this.finishTurn();
479
+ return {
480
+ summary: (this._turnText || summaryBeforeDrain) || null,
481
+ costUsd: null,
482
+ status: outcome,
483
+ errorCode: outcome,
484
+ errorMessage: outcome === "timeout" ? "Turn exceeded its timeout" : "Turn aborted",
485
+ };
486
+ }
487
+
488
+ const settled = await promptP;
489
+ this.finishTurn();
490
+ if (!settled.ok) {
491
+ return {
492
+ summary: this._turnText || null,
493
+ costUsd: null,
494
+ status: "failed",
495
+ errorCode: "prompt_error",
496
+ errorMessage: settled.error,
497
+ };
498
+ }
499
+ return {
500
+ summary: this._turnText || null,
501
+ costUsd: null,
502
+ status: mapAcpStopReason(settled.res?.stopReason),
503
+ errorCode: null,
504
+ errorMessage: null,
505
+ };
506
+ }
507
+
508
+ async cancel(_uuid: string): Promise<CancelResult> {
509
+ // ACP has no per-queued-message cancel — only whole-turn cancel via interrupt().
510
+ return { cancelled: false };
511
+ }
512
+
513
+ setGoal(objective: string, options?: GoalOptions): Promise<SetGoalResult> {
514
+ return this._goals.setGoal(objective, options);
515
+ }
516
+
517
+ clearGoal(options?: { reason?: "cleared" | "blocked" }): Promise<ClearGoalResult> {
518
+ return this._goals.clearGoal(options);
519
+ }
520
+
521
+ getGoal(): GoalState | null {
522
+ return this._goals.getGoal();
523
+ }
524
+
525
+ async stopTask(_taskId: string): Promise<StopTaskResult> {
526
+ // ACP has no per-task stop control; capabilities.stopTask is false.
527
+ return { stopped: false };
528
+ }
529
+
530
+ async interrupt(): Promise<void> {
531
+ if (this.connection && this._sessionId) {
532
+ try {
533
+ await this.connection.cancel({ sessionId: this._sessionId });
534
+ } catch {
535
+ /* best effort */
536
+ }
537
+ }
538
+ }
539
+
540
+ async drain(): Promise<void> {
541
+ this._draining = true;
542
+ const inFlight = this._inFlight;
543
+ if (inFlight) {
544
+ try {
545
+ await inFlight;
546
+ } catch {
547
+ /* ignore */
548
+ }
549
+ }
550
+ await this.close();
551
+ }
552
+
553
+ async close(): Promise<void> {
554
+ this._state = "closed";
555
+ const proc = this.proc;
556
+ this.proc = null;
557
+ if (proc && !proc.killed) {
558
+ try {
559
+ proc.kill();
560
+ } catch {
561
+ /* ignore */
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Discover an ACP agent's available modes by spawning it, running the
569
+ * handshake, creating a throwaway session, reading the advertised modes, and
570
+ * closing. Returns [] on any failure (modes are advisory).
571
+ */
572
+ export async function listAcpModes(deps: AcpSessionDeps, ctx?: SessionContext): Promise<AgentMode[]> {
573
+ let proc: ChildProcess | null = null;
574
+ try {
575
+ const acp = (await import("@agentclientprotocol/sdk")) as unknown as AcpSdk;
576
+ const binary = ctx?.config?.command ?? deps.command[0]!;
577
+ const args = deps.command.slice(1);
578
+ const env = buildEnv({ ...deps.env, ...ctx?.env });
579
+ ensurePathInEnv(env);
580
+ proc = spawn(binary, args, {
581
+ cwd: ctx?.cwd ?? process.cwd(),
582
+ env,
583
+ stdio: ["pipe", "pipe", "pipe"],
584
+ });
585
+ // Swallow spawn errors so they don't surface as an unhandled 'error' crash.
586
+ proc.on("error", () => {});
587
+ if (!proc.stdin || !proc.stdout) return [];
588
+ const writable = Writable.toWeb(proc.stdin) as unknown as WritableStream<Uint8Array>;
589
+ const readable = Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>;
590
+ const stream = acp.ndJsonStream(writable, readable);
591
+ const client: Client = {
592
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }) as RequestPermissionResponse,
593
+ sessionUpdate: async () => {},
594
+ };
595
+ const connection = new acp.ClientSideConnection(() => client, stream);
596
+ await withTimeout(
597
+ connection.initialize({
598
+ protocolVersion: acp.PROTOCOL_VERSION,
599
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
600
+ }),
601
+ ACP_HANDSHAKE_TIMEOUT_MS,
602
+ "acp initialize (listModes)",
603
+ );
604
+ const res = await withTimeout(
605
+ connection.newSession({ cwd: ctx?.cwd ?? process.cwd(), mcpServers: [] }),
606
+ ACP_HANDSHAKE_TIMEOUT_MS,
607
+ "acp newSession (listModes)",
608
+ );
609
+ const modes = parseAcpModes(res.modes);
610
+ return deps.transformers?.modes ? deps.transformers.modes(modes) : modes;
611
+ } catch {
612
+ return [];
613
+ } finally {
614
+ // Always tear down the throwaway process — initialize/newSession can throw
615
+ // (agent doesn't speak ACP, handshake mismatch) and would otherwise leak it.
616
+ if (proc && !proc.killed) {
617
+ try {
618
+ proc.kill();
619
+ } catch {
620
+ /* ignore */
621
+ }
622
+ }
623
+ }
624
+ }
625
+
626
+ /** Parse an ACP `SessionModeState` (`{ currentModeId, availableModes }`) into AgentMode[]. */
627
+ export function parseAcpModes(modeState: unknown): AgentMode[] {
628
+ if (!modeState || typeof modeState !== "object") return [];
629
+ const available = (modeState as Record<string, unknown>)["availableModes"];
630
+ if (!Array.isArray(available)) return [];
631
+ const out: AgentMode[] = [];
632
+ for (const m of available) {
633
+ if (!m || typeof m !== "object") continue;
634
+ const r = m as Record<string, unknown>;
635
+ const id = typeof r["id"] === "string" ? r["id"] : "";
636
+ if (!id) continue;
637
+ out.push({
638
+ id,
639
+ name: typeof r["name"] === "string" ? r["name"] : id,
640
+ ...(typeof r["description"] === "string" ? { description: r["description"] } : {}),
641
+ });
642
+ }
643
+ return out;
644
+ }
645
+
646
+ /** Map a session `TurnResult.status` to the one-shot `ExecutionStatus`. */
647
+ function mapTurnStatus(status: TurnResult["status"]): ExecutionStatus {
648
+ switch (status) {
649
+ case "completed":
650
+ case "max_turns":
651
+ case "max_budget":
652
+ return "completed";
653
+ case "failed":
654
+ return "failed";
655
+ case "aborted":
656
+ return "aborted";
657
+ case "timeout":
658
+ return "timeout";
659
+ default:
660
+ return "completed";
661
+ }
662
+ }
663
+
664
+ /**
665
+ * One-shot ACP execute: open a session, send the prompt, await the turn, close.
666
+ * Lives here (not `index.ts`) so `index.ts` stays a light leaf — this and the
667
+ * session machinery load only when an ACP provider is actually invoked
668
+ * (spec §5.1).
669
+ */
670
+ export async function runAcpExecute(
671
+ deps: AcpSessionDeps,
672
+ ctx: ExecutionContext,
673
+ ): Promise<ExecutionResult> {
674
+ const runId = ctx.runId ?? uuidv7();
675
+ const startedAt = new Date().toISOString();
676
+ const startMs = Date.now();
677
+ const model = ctx.model ?? ctx.config?.model ?? null;
678
+
679
+ const sessionCtx: SessionContext = {
680
+ ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}),
681
+ ...(ctx.env !== undefined ? { env: ctx.env } : {}),
682
+ ...(ctx.config !== undefined ? { config: ctx.config } : {}),
683
+ ...(ctx.onEvent ? { onEvent: ctx.onEvent } : {}),
684
+ ...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}),
685
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
686
+ };
687
+
688
+ const base = {
689
+ runId,
690
+ startedAt,
691
+ signal: null as string | null,
692
+ model,
693
+ summary: null as string | null,
694
+ sessionParams: null as Record<string, unknown> | null,
695
+ sessionDisplayId: null as string | null,
696
+ clearSession: false,
697
+ billingType: null,
698
+ };
699
+
700
+ let session: AgentSession | null = null;
701
+ try {
702
+ session = await createAcpSession(deps, sessionCtx);
703
+ const handle = await session.send(ctx.prompt, {
704
+ ...(ctx.config?.timeoutSec ? { timeoutSec: ctx.config.timeoutSec } : {}),
705
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
706
+ });
707
+ const turn = await handle.result;
708
+ const sessionId = session.sessionId;
709
+ await session.close();
710
+ return {
711
+ ...base,
712
+ completedAt: new Date().toISOString(),
713
+ durationMs: Date.now() - startMs,
714
+ exitCode: turn.status === "completed" ? 0 : 1,
715
+ status: mapTurnStatus(turn.status),
716
+ errorMessage: turn.errorMessage,
717
+ errorCode: turn.errorCode,
718
+ summary: turn.summary,
719
+ ...(turn.usage ? { usage: turn.usage as ExecutionResult["usage"] } : {}),
720
+ costUsd: turn.costUsd,
721
+ sessionParams: sessionId ? { sessionId } : null,
722
+ sessionDisplayId: sessionId,
723
+ };
724
+ } catch (err) {
725
+ if (session) {
726
+ try {
727
+ await session.close();
728
+ } catch {
729
+ /* ignore */
730
+ }
731
+ }
732
+ return {
733
+ ...base,
734
+ completedAt: new Date().toISOString(),
735
+ durationMs: Date.now() - startMs,
736
+ exitCode: 1,
737
+ signal: null,
738
+ status: "failed",
739
+ errorMessage: err instanceof Error ? err.message : String(err),
740
+ errorCode: "acp_error",
741
+ costUsd: null,
742
+ };
743
+ }
744
+ }