@robota-sdk/agent-transport-acp 3.0.0-beta.96 → 3.0.1

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/src/acp-agent.ts CHANGED
@@ -6,13 +6,21 @@ import { isAbsolute, resolve } from 'node:path';
6
6
  import { agent, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
7
7
  import { OWNER_DRIVER_ID } from '@robota-sdk/agent-interface-session';
8
8
 
9
+ import { editorFileAccess } from './acp-editor-files.js';
9
10
  import { promptText } from './acp-prompt-text.js';
11
+ import { availableCommands, replayHistory } from './acp-session-catalog.js';
10
12
  import { runCommandTurn, runModelTurn } from './acp-turn.js';
11
13
  import { createUpdateStream } from './acp-updates.js';
12
14
 
13
15
  import type { IAcpTurnContext } from './acp-turn.js';
14
16
  import type { IAcpTransportOptions, IAcpTransportSession } from './acp-types.js';
15
- import type { AgentApp, PromptResponse } from '@agentclientprotocol/sdk';
17
+ import type {
18
+ AgentApp,
19
+ AgentContext,
20
+ ClientCapabilities,
21
+ McpServer,
22
+ PromptResponse,
23
+ } from '@agentclientprotocol/sdk';
16
24
 
17
25
  /**
18
26
  * JSON-RPC server-error code for a workspace the host has not admitted. Not -32000: ACP assigns
@@ -29,6 +37,15 @@ export interface IAcpAgent {
29
37
  readonly app: AgentApp;
30
38
  /** Cancel the running prompt, if there is one. */
31
39
  cancelActive(): void;
40
+ /** Tell the host the editor's file system is gone, if it was handed one. */
41
+ withdrawEditorFiles(): void;
42
+ }
43
+
44
+ /** What `session/new` and `session/load` both carry that decides whether the editor may have the session. */
45
+ interface ISessionRequest {
46
+ readonly cwd: string;
47
+ readonly mcpServers: readonly McpServer[];
48
+ readonly additionalDirectories?: readonly string[] | null;
32
49
  }
33
50
 
34
51
  function sameDirectory(requested: string, served: string): boolean {
@@ -41,6 +58,10 @@ function sameDirectory(requested: string, served: string): boolean {
41
58
  }
42
59
  }
43
60
 
61
+ function asError(error: unknown): Error {
62
+ return error instanceof Error ? error : new Error(String(error));
63
+ }
64
+
44
65
  export function createAcpAgent(
45
66
  session: IAcpTransportSession,
46
67
  options: IAcpTransportOptions,
@@ -48,63 +69,169 @@ export function createAcpAgent(
48
69
  const driverId = options.driverId ?? OWNER_DRIVER_ID;
49
70
  const report = (error: Error, context: string): void => options.onDeliveryError?.(error, context);
50
71
  let boundSessionId: string | undefined;
51
- /** A `session/new` is waiting for the session to start; a second one must not also claim it. */
72
+ /** A `session/new` or `session/load` is still settling which session this connection serves. */
52
73
  let binding = false;
53
74
  let active: { cancel(): void } | undefined;
54
75
  let toolCalls = 0;
76
+ const usedToolCallIds = new Set<string>();
77
+ const nextToolCallId = (preferredId?: string): string => {
78
+ let id = preferredId;
79
+ while (id === undefined || usedToolCallIds.has(id)) id = `tool-${(toolCalls += 1)}`;
80
+ usedToolCallIds.add(id);
81
+ return id;
82
+ };
83
+ /** The command list the editor last received, so an unchanged list is not sent again. */
84
+ let advertised: string | undefined;
85
+
86
+ /** Refuse what the editor may not ask for; the same rules hold for a new and a loaded session. */
87
+ const admit = (params: ISessionRequest): void => {
88
+ if (!isAbsolute(params.cwd) || !sameDirectory(params.cwd, options.cwd)) {
89
+ throw RequestError.invalidParams(
90
+ undefined,
91
+ `cwd must be the directory this agent was started in: ${options.cwd}`,
92
+ );
93
+ }
94
+ // Servers or roots named by the editor would widen what the session can reach.
95
+ if (params.mcpServers.length > 0) {
96
+ throw RequestError.invalidParams(undefined, 'mcpServers are not accepted by this agent');
97
+ }
98
+ if ((params.additionalDirectories ?? []).length > 0) {
99
+ throw RequestError.invalidParams(
100
+ undefined,
101
+ 'additionalDirectories are not accepted by this agent',
102
+ );
103
+ }
104
+ const admission = options.admitWorkspace();
105
+ if (!admission.admitted) {
106
+ throw new RequestError(WORKSPACE_NOT_ADMITTED, admission.message, {
107
+ reason: 'workspace-not-trusted',
108
+ });
109
+ }
110
+ };
111
+
112
+ /** Wait for the attached session to name itself; a session that failed to start is a fixed error. */
113
+ const sessionId = async (context: string): Promise<string> => {
114
+ try {
115
+ // A session that builds itself in the background cannot name itself until it is ready.
116
+ await session.whenInitialized?.();
117
+ return session.getStatusSnapshot().sessionId;
118
+ } catch (error) {
119
+ report(asError(error), context);
120
+ // The cause can name host paths or settings; the editor only needs to know it failed.
121
+ throw new RequestError(INTERNAL_ERROR, 'The session could not be started');
122
+ }
123
+ };
124
+
125
+ /**
126
+ * Tell the editor which commands it may offer, when they differ from what it last received. The
127
+ * update names the session, so it goes out only once the editor holds that id.
128
+ */
129
+ const advertiseCommands = (client: AgentContext, id: string): void => {
130
+ if (session.listCommands === undefined && session.listSkills === undefined) return;
131
+ let commands: ReturnType<typeof availableCommands>;
132
+ try {
133
+ commands = availableCommands(session);
134
+ } catch (error) {
135
+ report(asError(error), 'available_commands_update');
136
+ return;
137
+ }
138
+ const serialized = JSON.stringify(commands);
139
+ if (serialized === advertised) return;
140
+ advertised = serialized;
141
+ client
142
+ .notify('session/update', {
143
+ sessionId: id,
144
+ update: { sessionUpdate: 'available_commands_update', availableCommands: commands },
145
+ })
146
+ .catch((error: unknown) => report(asError(error), 'available_commands_update'));
147
+ };
148
+ /** After the response that gives the editor the session id has been written. */
149
+ const advertiseAfterReply = (client: AgentContext, id: string): void => {
150
+ setImmediate(() => advertiseCommands(client, id));
151
+ };
152
+
153
+ /** The file system the editor offered in `initialize`. */
154
+ let editorFs: ClientCapabilities['fs'];
155
+ let editorFilesOffered = false;
156
+ /** Hand the host the editor's file system for the bound session, if the editor offered one. */
157
+ const offerEditorFiles = (client: AgentContext): void => {
158
+ const access = editorFileAccess(editorFs, client, () => boundSessionId);
159
+ if (access === undefined) return;
160
+ editorFilesOffered = true;
161
+ options.onEditorFileAccess?.(access);
162
+ };
55
163
 
56
164
  const app = agent({ name: options.agentInfo.name })
57
- .onRequest('initialize', () => ({
58
- // Always the version this agent speaks: the client disconnects if it cannot use it.
59
- protocolVersion: PROTOCOL_VERSION,
60
- agentCapabilities: {
61
- loadSession: false,
62
- promptCapabilities: { image: false, audio: false, embeddedContext: true },
63
- },
64
- agentInfo: { name: options.agentInfo.name, version: options.agentInfo.version },
65
- }))
66
- .onRequest('session/new', async ({ params }) => {
165
+ .onRequest('initialize', ({ params }) => {
166
+ editorFs = params.clientCapabilities?.fs;
167
+ return {
168
+ // Always the version this agent speaks: the client disconnects if it cannot use it.
169
+ protocolVersion: PROTOCOL_VERSION,
170
+ agentCapabilities: {
171
+ loadSession:
172
+ options.loadSession !== undefined && session.getMessagesDisplay !== undefined,
173
+ promptCapabilities: { image: false, audio: false, embeddedContext: true },
174
+ },
175
+ agentInfo: { name: options.agentInfo.name, version: options.agentInfo.version },
176
+ };
177
+ })
178
+ .onRequest('session/new', async ({ params, client }) => {
67
179
  if (boundSessionId !== undefined || binding) {
68
180
  throw RequestError.invalidParams(undefined, 'one session per connection');
69
181
  }
70
- if (!isAbsolute(params.cwd) || !sameDirectory(params.cwd, options.cwd)) {
71
- throw RequestError.invalidParams(
72
- undefined,
73
- `cwd must be the directory this agent was started in: ${options.cwd}`,
74
- );
75
- }
76
- // Servers or roots named by the editor would widen what the session can reach.
77
- if (params.mcpServers.length > 0) {
78
- throw RequestError.invalidParams(undefined, 'mcpServers are not accepted by this agent');
182
+ admit(params);
183
+ binding = true;
184
+ try {
185
+ boundSessionId = await sessionId('session/new');
186
+ } finally {
187
+ binding = false;
79
188
  }
80
- if ((params.additionalDirectories ?? []).length > 0) {
81
- throw RequestError.invalidParams(
82
- undefined,
83
- 'additionalDirectories are not accepted by this agent',
84
- );
189
+ offerEditorFiles(client);
190
+ advertiseAfterReply(client, boundSessionId);
191
+ return { sessionId: boundSessionId };
192
+ })
193
+ .onRequest('session/load', async ({ params, client }) => {
194
+ const load = options.loadSession;
195
+ if (load === undefined || session.getMessagesDisplay === undefined) {
196
+ throw RequestError.methodNotFound('session/load');
85
197
  }
86
- const admission = options.admitWorkspace();
87
- if (!admission.admitted) {
88
- throw new RequestError(WORKSPACE_NOT_ADMITTED, admission.message, {
89
- reason: 'workspace-not-trusted',
90
- });
198
+ if (binding) throw RequestError.invalidParams(undefined, 'a session is already being opened');
199
+ // A load replaces this connection's session, so it may not pull the session out from under a turn.
200
+ if (active !== undefined) {
201
+ throw RequestError.invalidParams(undefined, 'a prompt is running');
91
202
  }
203
+ admit(params);
92
204
  binding = true;
93
205
  try {
94
- // A session that builds itself in the background cannot name itself until it is ready.
95
- await session.whenInitialized?.();
96
- boundSessionId = session.getStatusSnapshot().sessionId;
97
- } catch (error) {
98
- report(error instanceof Error ? error : new Error(String(error)), 'session/new');
99
- // The cause can name host paths or settings; the editor only needs to know it failed.
100
- throw new RequestError(INTERNAL_ERROR, 'The session could not be started');
206
+ let outcome: Awaited<ReturnType<typeof load>>;
207
+ try {
208
+ outcome = await load(params.sessionId);
209
+ } catch (error) {
210
+ report(asError(error), 'session/load');
211
+ throw new RequestError(INTERNAL_ERROR, 'The session could not be loaded');
212
+ }
213
+ if (!outcome.loaded) throw RequestError.invalidParams(undefined, outcome.message);
214
+ boundSessionId = await sessionId('session/load');
215
+ offerEditorFiles(client);
216
+ // The editor sees the conversation it reopened before the load answers.
217
+ const updates = createUpdateStream(client, boundSessionId, report);
218
+ try {
219
+ replayHistory(session.getMessagesDisplay(), updates, nextToolCallId);
220
+ } catch (error) {
221
+ report(asError(error), 'session/load');
222
+ // The session is loaded; only its history could not be shown, and the cause stays local.
223
+ throw new RequestError(INTERNAL_ERROR, 'The session history could not be shown');
224
+ }
225
+ await updates.drained();
101
226
  } finally {
102
227
  binding = false;
103
228
  }
104
- return { sessionId: boundSessionId };
229
+ advertised = undefined;
230
+ advertiseAfterReply(client, boundSessionId);
231
+ return {};
105
232
  })
106
233
  .onRequest('session/prompt', async ({ params, signal, client }): Promise<PromptResponse> => {
107
- if (params.sessionId !== boundSessionId) {
234
+ if (params.sessionId !== boundSessionId || binding) {
108
235
  throw RequestError.invalidParams(undefined, 'unknown session');
109
236
  }
110
237
  if (active !== undefined) {
@@ -132,7 +259,7 @@ export function createAcpAgent(
132
259
  driverId,
133
260
  surface: options.surface ?? 'unknown',
134
261
  cwd: options.cwd,
135
- nextToolCallId: () => `tool-${(toolCalls += 1)}`,
262
+ nextToolCallId,
136
263
  wasCancelled: () => cancelled,
137
264
  report,
138
265
  };
@@ -142,11 +269,22 @@ export function createAcpAgent(
142
269
  : await runModelTurn(turn, text.full);
143
270
  } finally {
144
271
  active = undefined;
272
+ // A command or a model turn can change the catalog (a plugin reload, a preset): the editor
273
+ // gets the new list, and an unchanged one is not sent again.
274
+ advertiseCommands(client, params.sessionId);
145
275
  }
146
276
  })
147
277
  .onNotification('session/cancel', ({ params }) => {
148
278
  if (params.sessionId === boundSessionId) active?.cancel();
149
279
  });
150
280
 
151
- return { app, cancelActive: () => active?.cancel() };
281
+ return {
282
+ app,
283
+ cancelActive: () => active?.cancel(),
284
+ withdrawEditorFiles: () => {
285
+ if (!editorFilesOffered) return;
286
+ editorFilesOffered = false;
287
+ options.onEditorFileAccess?.(undefined);
288
+ },
289
+ };
152
290
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The editor's file system, as the editor offered it in `initialize`: reads and writes the host's
3
+ * file tools may inspect once the session is bound. ACP v1 carries only a pathname, not a stable
4
+ * rooted file capability, so this adapter deliberately cannot claim the authority required to
5
+ * carry contained Read, Write or Edit operations.
6
+ */
7
+
8
+ import { isAbsolute } from 'node:path';
9
+
10
+ import { RequestError } from '@agentclientprotocol/sdk';
11
+
12
+ import type { IAcpEditorFileAccess } from './acp-types.js';
13
+ import type { AgentContext, ClientCapabilities, SendRequestOptions } from '@agentclientprotocol/sdk';
14
+
15
+ function cancellable(signal: AbortSignal | undefined): SendRequestOptions | undefined {
16
+ return signal === undefined ? undefined : { cancellationSignal: signal };
17
+ }
18
+
19
+ /** A JSON-RPC error names its kind in `message` and, from an SDK client, the cause in `data.details`. */
20
+ function editorError(error: unknown): Error {
21
+ if (!(error instanceof RequestError)) return error instanceof Error ? error : new Error(String(error));
22
+ const data: unknown = error.data;
23
+ const details =
24
+ typeof data === 'object' && data !== null && 'details' in data && typeof data.details === 'string'
25
+ ? data.details
26
+ : undefined;
27
+ return new Error(details === undefined ? error.message : `${error.message}: ${details}`);
28
+ }
29
+
30
+ function absolute(path: string): void {
31
+ // ACP names files by absolute path; a relative one would be resolved by the editor, not the tool.
32
+ if (!isAbsolute(path)) throw new Error(`the editor is given absolute paths only: ${path}`);
33
+ }
34
+
35
+ /**
36
+ * The access the editor offered, for the bound session; `undefined` when it offered neither read nor
37
+ * write. `sessionId` is read at each request, so the access follows the session the connection serves.
38
+ */
39
+ export function editorFileAccess(
40
+ fs: ClientCapabilities['fs'],
41
+ client: AgentContext,
42
+ sessionId: () => string | undefined,
43
+ ): IAcpEditorFileAccess | undefined {
44
+ const reads = fs?.readTextFile === true;
45
+ const writes = fs?.writeTextFile === true;
46
+ if (!reads && !writes) return undefined;
47
+ const bound = (): string => {
48
+ const id = sessionId();
49
+ if (id === undefined) throw new Error('the editor has no session to read or write for');
50
+ return id;
51
+ };
52
+ return {
53
+ ...(reads
54
+ ? {
55
+ readTextFile: async (path: string, signal?: AbortSignal): Promise<string> => {
56
+ absolute(path);
57
+ try {
58
+ const reply = await client.request(
59
+ 'fs/read_text_file',
60
+ { sessionId: bound(), path },
61
+ cancellable(signal),
62
+ );
63
+ return reply.content;
64
+ } catch (error) {
65
+ throw editorError(error);
66
+ }
67
+ },
68
+ }
69
+ : {}),
70
+ ...(writes
71
+ ? {
72
+ writeTextFile: async (path: string, content: string, signal?: AbortSignal): Promise<void> => {
73
+ absolute(path);
74
+ try {
75
+ await client.request(
76
+ 'fs/write_text_file',
77
+ { sessionId: bound(), path, content },
78
+ cancellable(signal),
79
+ );
80
+ } catch (error) {
81
+ throw editorError(error);
82
+ }
83
+ },
84
+ }
85
+ : {}),
86
+ };
87
+ }
@@ -0,0 +1,65 @@
1
+ /** What an editor learns about the session besides a turn: the commands it can offer, and the history of a loaded session. */
2
+
3
+ import { acpToolEndStatus, acpToolKind, acpToolStartTitle } from './acp-tool-mapping.js';
4
+
5
+ import type { IAcpUpdateStream } from './acp-updates.js';
6
+ import type { AvailableCommand } from '@agentclientprotocol/sdk';
7
+ import type { IHistoryDisplaySegment, ISessionCommands } from '@robota-sdk/agent-interface-session';
8
+
9
+ /**
10
+ * The slash commands an editor may offer: the runtime's commands, then the user-invocable skills
11
+ * whose names no command already uses. A command a client implements itself (its runner is the
12
+ * client) is not offered, because an editor cannot run it and the runtime would not.
13
+ */
14
+ export function availableCommands(
15
+ session: Partial<Pick<ISessionCommands, 'listCommands' | 'listSkills'>>,
16
+ ): AvailableCommand[] {
17
+ const offered: AvailableCommand[] = [];
18
+ const names = new Set<string>();
19
+ const offer = (name: string, description: string, hint: string | undefined): void => {
20
+ if (names.has(name)) return;
21
+ names.add(name);
22
+ offered.push({ name, description, ...(hint ? { input: { hint } } : {}) });
23
+ };
24
+ for (const command of session.listCommands?.() ?? []) {
25
+ if (command.runner !== 'runtime') continue;
26
+ offer(command.name, command.description, command.argumentHint);
27
+ }
28
+ for (const skill of session.listSkills?.() ?? []) {
29
+ if (skill.userInvocable) offer(skill.name, skill.description, skill.argumentHint);
30
+ }
31
+ return offered;
32
+ }
33
+
34
+ /**
35
+ * A loaded session's history as the editor would have seen it live: what the person typed, what the
36
+ * agent answered, and each finished tool call with how it ended. A call keeps its own id unless that
37
+ * id already named a row of this replay (a provider can reuse ids across turns), because the editor
38
+ * would merge the two rows into one. The connection's allocator also keeps later live calls distinct.
39
+ */
40
+ export function replayHistory(
41
+ segments: readonly IHistoryDisplaySegment[],
42
+ updates: IAcpUpdateStream,
43
+ nextToolCallId: (preferredId?: string) => string,
44
+ ): void {
45
+ for (const segment of segments) {
46
+ if (segment.type === 'text') {
47
+ if (segment.content === '') continue;
48
+ updates.send({
49
+ sessionUpdate: segment.role === 'user' ? 'user_message_chunk' : 'agent_message_chunk',
50
+ content: { type: 'text', text: segment.content },
51
+ });
52
+ continue;
53
+ }
54
+ const { tool } = segment;
55
+ if (tool.internal) continue;
56
+ const toolCallId = nextToolCallId(tool.executionId);
57
+ updates.send({
58
+ sessionUpdate: 'tool_call',
59
+ toolCallId,
60
+ title: acpToolStartTitle(tool),
61
+ kind: acpToolKind(tool.toolName),
62
+ status: acpToolEndStatus(tool),
63
+ });
64
+ }
65
+ }
@@ -20,8 +20,8 @@ export interface IAcpToolCallTracker {
20
20
 
21
21
  export interface IAcpToolCallDeps {
22
22
  readonly updates: IAcpUpdateStream;
23
- /** Ids for tool calls the session gives no id; unique for the life of the session. */
24
- readonly nextToolCallId: () => string;
23
+ /** Allocate a new row id, preserving a preferred execution id only when no earlier row used it. */
24
+ readonly nextToolCallId: (preferredId?: string) => string;
25
25
  /** The served directory, for paths the session reports relative to it. */
26
26
  readonly cwd: string;
27
27
  }
@@ -34,6 +34,7 @@ interface IOpenCall {
34
34
 
35
35
  /** A permission request's row, held until the call it names starts or is refused. */
36
36
  interface IAwaitedCall {
37
+ readonly id: string;
37
38
  readonly rawInput: unknown;
38
39
  /** A background agent's call can run after this prompt, so its row closes with the answer. */
39
40
  readonly background: boolean;
@@ -71,7 +72,7 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
71
72
  // An internal signal tool is not a call the person made or should see.
72
73
  if (tool.internal) return;
73
74
  const asked = take(tool.executionId);
74
- const id = tool.executionId ?? deps.nextToolCallId();
75
+ const id = asked?.id ?? deps.nextToolCallId(tool.executionId);
75
76
  open.push({ id, toolName: tool.toolName, executionId: tool.executionId });
76
77
  const call = {
77
78
  toolCallId: id,
@@ -99,14 +100,15 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
99
100
  return;
100
101
  }
101
102
  // A refused call ends with no start; when its permission was asked, that row is the call.
102
- if (take(tool.executionId) !== undefined) {
103
- updates.send({ sessionUpdate: 'tool_call_update', toolCallId: tool.executionId!, status });
103
+ const asked = take(tool.executionId);
104
+ if (asked !== undefined) {
105
+ updates.send({ sessionUpdate: 'tool_call_update', toolCallId: asked.id, status });
104
106
  return;
105
107
  }
106
108
  // Otherwise give the editor the call, finished, rather than an update for nothing.
107
109
  updates.send({
108
110
  sessionUpdate: 'tool_call',
109
- toolCallId: tool.executionId ?? deps.nextToolCallId(),
111
+ toolCallId: deps.nextToolCallId(tool.executionId),
110
112
  title: acpToolStartTitle(tool),
111
113
  kind: acpToolKind(tool.toolName),
112
114
  status,
@@ -116,14 +118,22 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
116
118
  requested(event) {
117
119
  const { executionId } = event;
118
120
  if (executionId === undefined) {
119
- const call = permissionToolCall(event, `permission-${event.id}`, deps.cwd);
121
+ const call = permissionToolCall(
122
+ event,
123
+ deps.nextToolCallId(`permission-${event.id}`),
124
+ deps.cwd,
125
+ );
120
126
  unnamed.set(event.id, call.toolCallId);
121
127
  return call;
122
128
  }
123
- const call = permissionToolCall(event, executionId, deps.cwd);
124
- // A call that is already running (asked again before its effect) keeps the row it has.
125
- if (!open.some((running) => running.executionId === executionId)) {
129
+ // A repeated request and the call's own start/end keep the row allocated for this call.
130
+ const running = open.find((call) => call.executionId === executionId);
131
+ const waiting = awaited.get(executionId);
132
+ const id = running?.id ?? waiting?.id ?? deps.nextToolCallId(executionId);
133
+ const call = permissionToolCall(event, id, deps.cwd);
134
+ if (running === undefined && waiting === undefined) {
126
135
  awaited.set(executionId, {
136
+ id,
127
137
  rawInput: call.rawInput,
128
138
  background: event.requester !== undefined,
129
139
  });
@@ -147,7 +157,7 @@ export function createToolCallTracker(deps: IAcpToolCallDeps): IAcpToolCallTrack
147
157
 
148
158
  finish() {
149
159
  finished = true;
150
- for (const [id, call] of awaited) close(id, call.background && call.allowed === true);
160
+ for (const call of awaited.values()) close(call.id, call.background && call.allowed === true);
151
161
  for (const row of unnamed.values()) close(row, false);
152
162
  awaited.clear();
153
163
  unnamed.clear();
@@ -40,6 +40,7 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
40
40
  let carrier: IStdioCarrier | null = null;
41
41
  let connection: AgentConnection | null = null;
42
42
  let cancelActive: (() => void) | null = null;
43
+ let withdrawEditorFiles: (() => void) | null = null;
43
44
  let resolveClose: (() => void) | null = null;
44
45
  let rejectClose: ((error: Error) => void) | null = null;
45
46
  let closePromise: Promise<void> = Promise.resolve();
@@ -53,6 +54,8 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
53
54
  };
54
55
  const release = (): void => {
55
56
  try {
57
+ // The host's tools go back to the disk before the connection that carried their access closes.
58
+ withdrawEditorFiles?.();
56
59
  cancelActive?.();
57
60
  connection?.close();
58
61
  } finally {
@@ -60,6 +63,7 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
60
63
  carrier = null;
61
64
  connection = null;
62
65
  cancelActive = null;
66
+ withdrawEditorFiles = null;
63
67
  }
64
68
  };
65
69
  const lifecycleError = (code: ITransportLifecycleError['code']): ITransportLifecycleError =>
@@ -84,6 +88,7 @@ export function createAcpTransport(options: IAcpTransportOptions): IAcpTransport
84
88
  try {
85
89
  const acpAgent = createAcpAgent(session, options);
86
90
  cancelActive = acpAgent.cancelActive;
91
+ withdrawEditorFiles = acpAgent.withdrawEditorFiles;
87
92
  // The SDK destroys what it reads when its connection closes; it reads the bridge, never the
88
93
  // process input, and a closed or failed end ends the transport instead of leaving it idle.
89
94
  const attached = attachStdioCarrier(options.input, options.output, {
package/src/acp-turn.ts CHANGED
@@ -31,8 +31,8 @@ export interface IAcpTurnContext {
31
31
  readonly surface: ISubmitOptions['surface'];
32
32
  /** The served directory; paths the session reports relative to it are made absolute for the editor. */
33
33
  readonly cwd: string;
34
- /** Ids for tool calls the session gives no id; unique for the life of the session. */
35
- readonly nextToolCallId: () => string;
34
+ /** Allocate a new row id, preserving a preferred execution id only when no earlier row used it. */
35
+ readonly nextToolCallId: (preferredId?: string) => string;
36
36
  /** Whether the editor sent `session/cancel` for this prompt. */
37
37
  readonly wasCancelled: () => boolean;
38
38
  readonly report: (error: Error, context: string) => void;
package/src/acp-types.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Public contract of the ACP agent transport: its session role set, host-injected ports and options. */
2
2
 
3
3
  import type {
4
+ ISessionCommands,
5
+ ISessionConversationRead,
4
6
  ISessionEvents,
5
7
  ISessionPromptResolution,
6
8
  ISessionStatusRead,
@@ -14,7 +16,8 @@ import type { Readable, Writable } from 'node:stream';
14
16
 
15
17
  /**
16
18
  * The session roles ACP uses: submit and abort a turn, stream its events, answer its permission
17
- * prompts, and name the session. A full interactive session is assignable; nothing else is required.
19
+ * prompts, and name the session. Optional catalog reads let the editor offer commands; optional
20
+ * history reads enable a host load port to replay a stored session. A full interactive session is assignable.
18
21
  */
19
22
  export interface IAcpTransportSession
20
23
  extends
@@ -22,28 +25,44 @@ export interface IAcpTransportSession
22
25
  ISessionTurnControl,
23
26
  ISessionEvents,
24
27
  ISessionPromptResolution,
25
- ISessionStatusRead {
28
+ ISessionStatusRead,
29
+ Partial<Pick<ISessionCommands, 'listCommands' | 'listSkills'>>,
30
+ Partial<Pick<ISessionConversationRead, 'getMessagesDisplay'>> {
26
31
  /**
27
32
  * Resolves once the session can name itself, and rejects if it failed to start. A session that
28
33
  * builds itself in the background (`InteractiveSession`) has no id before then, so `session/new`
29
- * waits on this. A session that is ready at construction can omit it.
34
+ * and `session/load` wait on this. A session that is ready at construction can omit it.
30
35
  */
31
36
  whenInitialized?(): Promise<void>;
32
37
  }
33
38
 
39
+ /**
40
+ * The editor's pathname file system, as the editor offered it (`clientCapabilities.fs`): a method
41
+ * it did not offer is absent. Paths are absolute. ACP v1 does not bind the path to stable rooted
42
+ * authority, so contained host file tools must not treat this as `IEditorFileAccess.pathAuthority`.
43
+ */
44
+ export interface IAcpEditorFileAccess {
45
+ readTextFile?(path: string, signal?: AbortSignal): Promise<string>;
46
+ writeTextFile?(path: string, content: string, signal?: AbortSignal): Promise<void>;
47
+ }
48
+
34
49
  /** The host's answer to whether the workspace may serve an editor; a refusal carries what to tell the user. */
35
50
  export type TAcpWorkspaceAdmission =
36
51
  { readonly admitted: true } | { readonly admitted: false; readonly message: string };
37
52
 
53
+ /** The host's answer to a `session/load`; a refusal carries what to tell the editor. */
54
+ export type TAcpSessionLoad =
55
+ { readonly loaded: true } | { readonly loaded: false; readonly message: string };
56
+
38
57
  export interface IAcpTransportOptions {
39
58
  /** Process-owned byte streams carrying newline-delimited JSON-RPC: the editor writes `input`, reads `output`. */
40
59
  readonly input: Readable;
41
60
  readonly output: Writable;
42
61
  /** Sent to the editor in the `initialize` reply. */
43
62
  readonly agentInfo: { readonly name: string; readonly version: string };
44
- /** The directory the attached session is rooted at; `session/new` must name this directory. */
63
+ /** The directory the attached session is rooted at; `session/new` and `session/load` must name it. */
45
64
  readonly cwd: string;
46
- /** The host's trust check, asked when the editor creates the session. */
65
+ /** The host's trust check, asked when the editor creates or loads the session. */
47
66
  readonly admitWorkspace: () => TAcpWorkspaceAdmission;
48
67
  /**
49
68
  * Runs a `/command` prompt and returns the text to show. Supplied by the host because command
@@ -52,6 +71,21 @@ export interface IAcpTransportOptions {
52
71
  * cancels the prompt or the connection closes; pass it on so what the command starts stops with it.
53
72
  */
54
73
  readonly executeCommand?: (text: string, signal: AbortSignal) => Promise<string>;
74
+ /**
75
+ * Makes the stored session `sessionId` current, so that the object this transport was attached to
76
+ * answers for it from then on (the host attaches a forwarder, such as a session slot, whose current
77
+ * session the port replaces). It must admit only a session rooted in the served directory, and may
78
+ * refuse with a message for the editor. Supplied by the host because building a session from
79
+ * storage is not this package's; without it or the session's history read, `session/load` is not offered.
80
+ */
81
+ readonly loadSession?: (sessionId: string) => Promise<TAcpSessionLoad>;
82
+ /**
83
+ * Receives the editor's file system after a new or loaded session binds, when the editor offers reads or
84
+ * writes, and `undefined` when the transport stops. The host decides which of its tools use it;
85
+ * they must apply their own path and permission checks first, because the editor checks nothing
86
+ * for the agent. Never called for an editor that offers no file system.
87
+ */
88
+ readonly onEditorFileAccess?: (access: IAcpEditorFileAccess | undefined) => void;
55
89
  /** Attribution recorded on turns and prompt answers submitted through this transport. Default `'owner'`. */
56
90
  readonly driverId?: TDriverId;
57
91
  /** Usage surface recorded on turns. Default `'unknown'` (no editor value exists). */