@armadra/agent 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/CHANGELOG.zh-CN.md +43 -0
  3. package/README.md +4 -3
  4. package/README.zh-CN.md +1 -1
  5. package/dist/acp.d.ts +2 -2
  6. package/dist/acp.js +2 -2
  7. package/dist/ai/apis/shared.d.ts +7 -1
  8. package/dist/ai/apis/shared.js +10 -0
  9. package/dist/ai/fake/fake-provider.js +5 -1
  10. package/dist/ai/fake/fake-script.d.ts +5 -2
  11. package/dist/ai/fake/fake-script.js +6 -2
  12. package/dist/ai/types.d.ts +2 -1
  13. package/dist/bundle/ama.cjs +48757 -47456
  14. package/dist/cli/args.d.ts +6 -0
  15. package/dist/cli/args.js +46 -0
  16. package/dist/cli/bootstrap.js +13 -2
  17. package/dist/cli/compose-events.d.ts +10 -0
  18. package/dist/cli/compose-events.js +97 -0
  19. package/dist/cli/compose-session.d.ts +25 -4
  20. package/dist/cli/compose-session.js +130 -103
  21. package/dist/cli/compose.js +6 -1
  22. package/dist/cli/runtime.d.ts +3 -1
  23. package/dist/cli/subcommands/auth.d.ts +13 -1
  24. package/dist/cli/subcommands/auth.js +28 -1
  25. package/dist/drivers/acp/client.d.ts +7 -2
  26. package/dist/drivers/acp/client.js +10 -1
  27. package/dist/drivers/acp/driver.d.ts +15 -4
  28. package/dist/drivers/acp/driver.js +110 -33
  29. package/dist/drivers/acp/testing/fake-agent-main.d.ts +1 -1
  30. package/dist/drivers/acp/testing/fake-agent-main.js +9 -2
  31. package/dist/drivers/acp/testing/fake-agent.d.ts +17 -2
  32. package/dist/drivers/acp/testing/fake-agent.js +108 -25
  33. package/dist/drivers/acp/types.d.ts +76 -12
  34. package/dist/drivers/acp/types.js +11 -3
  35. package/dist/drivers/jsonrpc.d.ts +13 -3
  36. package/dist/drivers/jsonrpc.js +40 -7
  37. package/dist/drivers/turn.d.ts +15 -2
  38. package/dist/drivers/turn.js +33 -4
  39. package/dist/i18n/catalog.d.ts +59 -16
  40. package/dist/i18n/catalog.js +4 -1
  41. package/dist/i18n/messages/acp.d.ts +125 -0
  42. package/dist/i18n/messages/acp.js +126 -0
  43. package/dist/i18n/messages/auth.d.ts +2 -0
  44. package/dist/i18n/messages/auth.js +4 -2
  45. package/dist/i18n/messages/cli-args.d.ts +2 -0
  46. package/dist/i18n/messages/cli-args.js +2 -0
  47. package/dist/i18n/messages/cli.d.ts +4 -0
  48. package/dist/i18n/messages/cli.js +2 -0
  49. package/dist/i18n/messages/print.d.ts +3 -33
  50. package/dist/i18n/messages/print.js +3 -33
  51. package/dist/modes/acp/acp-auth-gate.d.ts +40 -0
  52. package/dist/modes/acp/acp-auth-gate.js +201 -0
  53. package/dist/modes/acp/acp-config.d.ts +43 -0
  54. package/dist/modes/acp/acp-config.js +151 -0
  55. package/dist/modes/acp/acp-connection.d.ts +29 -0
  56. package/dist/modes/acp/acp-connection.js +37 -0
  57. package/dist/modes/acp/acp-events.d.ts +59 -23
  58. package/dist/modes/acp/acp-events.js +154 -88
  59. package/dist/modes/acp/acp-mode.d.ts +13 -2
  60. package/dist/modes/acp/acp-mode.js +23 -8
  61. package/dist/modes/acp/acp-server.d.ts +59 -32
  62. package/dist/modes/acp/acp-server.js +322 -128
  63. package/dist/modes/acp/acp-sessions.d.ts +80 -0
  64. package/dist/modes/acp/acp-sessions.js +157 -0
  65. package/dist/modes/acp/acp-tool-text.d.ts +23 -0
  66. package/dist/modes/acp/acp-tool-text.js +83 -0
  67. package/dist/modes/print/json-event.d.ts +2 -1
  68. package/dist/modes/print/json-event.js +6 -1
  69. package/dist/tools/edit.d.ts +6 -2
  70. package/dist/tools/edit.js +19 -2
  71. package/dist/tools/types.d.ts +11 -0
  72. package/dist/tools/types.js +2 -0
  73. package/dist/tools/write.d.ts +1 -0
  74. package/dist/tools/write.js +21 -3
  75. package/docs/acp.md +143 -29
  76. package/docs/agents.md +2 -0
  77. package/docs/codemode.md +1 -1
  78. package/docs/en/acp.md +192 -0
  79. package/package.json +1 -1
@@ -1,36 +1,45 @@
1
1
  /**
2
- * `ama --mode acp`:把 ama 的会话暴露为 ACP Agent(docs/wave5-plan.md §5.6,D14)。[W5-E]
2
+ * `ama --mode acp`:把 ama 的会话暴露为 ACP Agent(docs/wave5-plan.md §5.6,D14;多会话见
3
+ * docs/acp-plan.md D1、D2、D11、D12、§2.1)。[W5-E,ACP-B]
3
4
  *
4
- * 与 `--mode rpc` 同一引擎(Runtime + 组装根的会话切换),一次只有一个活动会话:
5
- * - `session/new` 新开(启动时那个空会话第一次直接认领);`session/load` 切到该会话并回放历史;
6
- * `session/resume` 切换不回放;`session/list` 列本 cwd 的会话;`session/close` 中断并释放;
7
- * 对非活动会话发 `session/prompt` 时(空闲)先切过去。
8
- * - `session/prompt` → `prompt`(文本 + 图片;资源链接以 `@uri` 文本给出,嵌入资源取文本);
9
- * 回合结束按会话状态给 stopReason:中断 → cancelled,输出截断 → max_tokens,出错 → JSON-RPC 错误。
10
- * - `session/cancel` → `abort`;`session/set_mode` → `setPermissionMode`(模式 id = ama 权限模式)。
11
- * - 审批:ama 的审批请求经 `session/request_permission` 交给客户端(允许 / 本会话允许 / 拒绝);
12
- * 客户端回 cancelled、连接断开或回合被中断 → 交给链上下一个回答者(无人 → 拒绝)。
13
- * - 不声明 / 不使用客户端的 `fs`、`terminal` 能力(Armadra Q5);ama 自己读写、自己跑命令。
14
- * - cwd 固定为启动目录(信任与项目配置都按它判定);客户端给别的 cwd → invalid params。
5
+ * 与 `--mode rpc` 同一引擎(Runtime + 组装根)。多会话:
6
+ * - 每个 ACP sessionId 一个常驻会话(会话池,acp-sessions.ts),各有自己的事件映射器与权限模式;
7
+ * 启动时那个空会话由第一个 `session/new` 认领,之后的 new / load / resume 建兄弟会话(不 dispose 旧的)。
8
+ * - 同一时刻只跑一个回合:其它会话的 `session/prompt` 进 FIFO 队列;出队时把该会话切为前台
9
+ * (宿主 / Hook / 工具工厂跟随,清「本会话允许」记忆),并把它自己的权限模式重放到共享管线。
10
+ * - `session/cancel`:在跑 → abort;排队中 → 直接回 cancelled。`session/close`:中断、释放并出池,
11
+ * 之后对该 id 发 prompt 回 -32002。`$/cancel_request` 撤回 prompt 等价 cancel,答复 -32800。
12
+ * - `session/set_mode`:记在会话上;是前台就立即生效,否则出队时生效(仍发 `current_mode_update`)。
13
+ * - `session/list`:按 cwd 过滤(别的目录 → 空列表),每页 50 条,cursor 翻页;标题去掉嵌入资源块。
14
+ * - 回合结束:stopReason(中断 → cancelled、截断 → max_tokens、拒答 → refusal、出错 → JSON-RPC 错误),
15
+ * 并发 `session_info_update`。
16
+ * - 审批经 `session/request_permission` 交给客户端;不声明 / 不使用客户端的 `fs`、`terminal` 能力。
17
+ * - cwd 固定为启动目录;客户端给的 `mcpServers` / `additionalDirectories` 忽略(stderr 一行)。
15
18
  */
16
19
  import { realpathSync } from "node:fs";
17
20
  import { resolve } from "node:path";
18
- import { currentSession, switchSession } from "../../cli/compose-session.js";
21
+ import { stopReasonOf } from "../../ai/apis/shared.js";
22
+ import { createSessionAlongside, currentSession, disposeSessionAlongside, setForegroundSession, } from "../../cli/compose-session.js";
19
23
  import { listSessions } from "../../cli/compose-store.js";
20
24
  import { AgentSessionImpl } from "../../agent/session.js";
21
- import { JsonRpcPeer, RpcError } from "../../drivers/jsonrpc.js";
25
+ import { RpcError } from "../../drivers/jsonrpc.js";
22
26
  import { ACP_METHODS, ACP_PROTOCOL_VERSION, RPC_ERRORS, } from "../../drivers/acp/types.js";
23
27
  import { isPermissionMode } from "../../permissions/modes.js";
24
28
  import { AMA_VERSION } from "../../version.js";
25
29
  import { errorText } from "../shared.js";
26
30
  import { AcpEventMapper, permissionModes, toolKind, toolLocations, toolTitle, } from "./acp-events.js";
27
31
  import { msg } from "../../i18n/index.js";
32
+ import { CONFIG_IDS, applyConfigOption, availableCommands, buildConfigOptions, prepareConfigOptions, } from "./acp-config.js";
33
+ import { clientCapabilitiesOf } from "./acp-connection.js";
34
+ import { PromptQueue, cancelledResult, pageSessions, sessionTitle, } from "./acp-sessions.js";
28
35
  export const ACP_AGENT_CAPABILITIES = {
29
36
  loadSession: true,
30
37
  promptCapabilities: { image: true, audio: false, embeddedContext: true },
31
38
  mcpCapabilities: { http: false, sse: false },
32
39
  sessionCapabilities: { list: {}, resume: {}, close: {} },
33
40
  };
41
+ /** ama 生成的会话 id(`randomUUID()`)。 */
42
+ const SESSION_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
34
43
  /** 解析符号链接后的绝对路径(macOS 的 /var → /private/var 等);不存在时退回 resolve。 */
35
44
  function canonical(path) {
36
45
  try {
@@ -43,13 +52,13 @@ function canonical(path) {
43
52
  function str(params, key) {
44
53
  const value = params[key];
45
54
  if (typeof value !== "string" || value === "")
46
- throw new RpcError(RPC_ERRORS.invalidParams, msg().print.acp.missingParam(key));
55
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.core.missingParam(key));
47
56
  return value;
48
57
  }
49
58
  /** ACP 提示 → ama 的文本与图片。 */
50
59
  export function promptOf(blocks) {
51
60
  if (!Array.isArray(blocks))
52
- throw new RpcError(RPC_ERRORS.invalidParams, msg().print.acp.promptNotArray);
61
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.core.promptNotArray);
53
62
  const parts = [];
54
63
  const images = [];
55
64
  for (const block of blocks) {
@@ -64,61 +73,118 @@ export function promptOf(blocks) {
64
73
  }
65
74
  return { text: parts.join("\n"), images };
66
75
  }
76
+ function countOf(value) {
77
+ return Array.isArray(value) ? value.length : 0;
78
+ }
67
79
  export class AcpServer {
68
80
  runtime;
81
+ connection;
69
82
  log;
70
83
  peer;
71
- activeId;
72
- /** 启动时的空会话还没被 session/new 认领。 */
73
- freshUnclaimed = true;
74
- mapper;
75
- unsubscribe = () => undefined;
76
- cancelRequested = false;
77
84
  cwd;
78
- constructor(runtime, streams, log = () => undefined) {
85
+ /** ACP sessionId → 常驻会话。 */
86
+ pool = new Map();
87
+ /** 还没交给客户端的会话(启动时那个;关掉最后一个前台会话后补的):`session/new` 认领。 */
88
+ standby;
89
+ /** 新会话的初始权限模式(启动参数 / 配置给的)。 */
90
+ initialMode;
91
+ queue = new PromptQueue((job) => this.runPrompt(job));
92
+ /**
93
+ * 连接由调用方建(`createAcpConnection`),其 handlers 转到 {@link handle} 与
94
+ * {@link handleNotification};认证门在 bootstrap 成功后以同一条连接构造服务端。
95
+ */
96
+ constructor(runtime, connection, log = () => undefined) {
79
97
  this.runtime = runtime;
98
+ this.connection = connection;
80
99
  this.log = log;
81
100
  this.cwd = canonical(runtime.paths.cwd);
82
- this.mapper = this.makeMapper();
83
- this.peer = new JsonRpcPeer({
84
- input: streams.input,
85
- output: streams.output,
86
- onRequest: (method, params) => this.handle(method, (params ?? {})),
87
- onNotification: (method, params) => {
88
- if (method === ACP_METHODS.sessionCancel)
89
- void this.cancel((params ?? {}));
90
- },
91
- onProtocolError: (_line, reason) => this.log(msg().print.acp.unparsable(reason)),
92
- });
93
- this.subscribe();
101
+ this.peer = connection.peer;
102
+ const startup = currentSession(runtime);
103
+ if (startup instanceof AgentSessionImpl)
104
+ this.standby = startup;
105
+ this.initialMode = startup.state.permissionMode;
94
106
  }
107
+ /** 前台会话(宿主 / Hook / 工具工厂当前操作的那个)。 */
95
108
  session() {
96
109
  return currentSession(this.runtime);
97
110
  }
111
+ /** 会话池里的全部会话(含未认领的待命会话);stdin 关闭时逐个收尾。 */
112
+ sessions() {
113
+ const all = [...this.pool.values()].map((entry) => entry.session);
114
+ if (this.standby !== undefined)
115
+ all.push(this.standby);
116
+ return all;
117
+ }
98
118
  /** 审批交给客户端:ama 的 UI broker。 */
99
119
  broker = {
100
120
  ask: (request, signal) => this.askClient(request, signal),
101
121
  };
102
- makeMapper() {
103
- return new AcpEventMapper(this.cwd, (update) => this.emit(update), () => this.session());
122
+ extras(session) {
123
+ return {
124
+ configOptions: this.configOptions(session),
125
+ commands: availableCommands(this.runtime.resources),
126
+ };
104
127
  }
105
- emit(update) {
106
- if (this.activeId === undefined)
107
- return;
108
- void this.peer.notify(ACP_METHODS.sessionUpdate, { sessionId: this.activeId, update });
128
+ configOptions(session) {
129
+ const entry = [...this.pool.values()].find((e) => e.session === session);
130
+ return buildConfigOptions(session, this.runtime.providers, process.env, {
131
+ enabled: this.runtime.config.models?.enabled,
132
+ mode: entry?.mode,
133
+ });
109
134
  }
110
- subscribe() {
111
- this.unsubscribe();
112
- const mapper = this.mapper;
113
- this.unsubscribe = this.session().subscribe((event) => mapper.onEvent(event));
135
+ /** 把会话放进池:自己的映射器(`session/update` 带它自己的 id)与订阅。 */
136
+ adopt(session, mode) {
137
+ const id = session.state.sessionId;
138
+ const mapper = new AcpEventMapper(this.cwd, (update) => void this.peer.notify(ACP_METHODS.sessionUpdate, { sessionId: id, update }), () => session, () => this.extras(session));
139
+ const unsubscribe = session.subscribe((event) => {
140
+ // 会话自己换了模式(plan 流程等)也记下,出队重放时不退回旧模式
141
+ if (event.type === "permission_mode_changed")
142
+ entry.mode = event.mode;
143
+ mapper.onEvent(event);
144
+ });
145
+ const entry = { id, session, mapper, unsubscribe, mode };
146
+ this.pool.set(id, entry);
147
+ return entry;
114
148
  }
115
- dispose() {
116
- this.unsubscribe();
149
+ /**
150
+ * 释放全部会话(stdin 关闭后由模式调用):排队的回 cancelled,等在跑的结束,兄弟会话依次
151
+ * dispose(跑 SessionEnd Hook);前台会话留给 `Runtime.dispose`。
152
+ */
153
+ async dispose(abort = false) {
154
+ this.queue.cancelQueued();
155
+ if (abort)
156
+ await this.session()
157
+ .abort()
158
+ .catch(() => undefined);
159
+ await this.queue.settled();
160
+ for (const session of this.sessions())
161
+ await session.waitForIdle().catch(() => undefined);
162
+ const foreground = this.session();
163
+ for (const entry of this.pool.values())
164
+ entry.unsubscribe();
165
+ for (const session of this.sessions()) {
166
+ if (session === foreground || !(session instanceof AgentSessionImpl))
167
+ continue;
168
+ if (!(foreground instanceof AgentSessionImpl))
169
+ continue;
170
+ await disposeSessionAlongside(this.runtime, session, foreground).catch(() => undefined);
171
+ }
172
+ this.pool.clear();
173
+ this.standby = undefined;
117
174
  }
118
- async handle(method, params) {
175
+ /** 客户端通知(`session/cancel`)。 */
176
+ handleNotification(method, params) {
177
+ if (method === ACP_METHODS.sessionCancel)
178
+ void this.cancel((params ?? {}));
179
+ }
180
+ /** 客户端请求;认证门交接时也由它处理当次请求。 */
181
+ async handle(method, params, ctx) {
119
182
  switch (method) {
120
183
  case ACP_METHODS.initialize:
121
184
  return this.initialize(params);
185
+ case ACP_METHODS.authenticate:
186
+ // 与认证门同一答复:ama 只有 terminal 型方法,按规范不经 authenticate(交接前后一致)
187
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.auth.notAgentMethod);
122
188
  case ACP_METHODS.sessionNew:
123
189
  return this.newSession(params);
124
190
  case ACP_METHODS.sessionLoad:
@@ -126,20 +192,23 @@ export class AcpServer {
126
192
  case ACP_METHODS.sessionResume:
127
193
  return this.openSession(params, false);
128
194
  case ACP_METHODS.sessionList:
129
- return this.list();
195
+ return this.list(params);
130
196
  case ACP_METHODS.sessionClose:
131
197
  return this.close(params);
132
198
  case ACP_METHODS.sessionPrompt:
133
- return this.prompt(params);
199
+ return this.prompt(params, ctx?.signal);
134
200
  case ACP_METHODS.sessionSetMode:
135
201
  return this.setMode(params);
202
+ case ACP_METHODS.sessionSetConfigOption:
203
+ return this.setConfigOption(params);
136
204
  default:
137
205
  throw new RpcError(RPC_ERRORS.methodNotFound, `method not found: ${method}`);
138
206
  }
139
207
  }
140
208
  initialize(params) {
141
209
  if (typeof params["protocolVersion"] !== "number")
142
- throw new RpcError(RPC_ERRORS.invalidParams, msg().print.acp.missingProtocolVersion);
210
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.core.missingProtocolVersion);
211
+ this.connection.markInitialized(clientCapabilitiesOf(params));
143
212
  return {
144
213
  protocolVersion: ACP_PROTOCOL_VERSION,
145
214
  agentCapabilities: ACP_AGENT_CAPABILITIES,
@@ -147,97 +216,164 @@ export class AcpServer {
147
216
  agentInfo: { name: "ama", title: "ama", version: AMA_VERSION },
148
217
  };
149
218
  }
150
- checkCwd(params) {
219
+ /** 开会话请求的公共检查:cwd 固定;`mcpServers` / `additionalDirectories` 忽略并在 stderr 说一行。 */
220
+ checkOpen(params) {
151
221
  const cwd = params["cwd"];
152
- if (cwd === undefined)
153
- return;
154
- if (typeof cwd !== "string" || canonical(cwd) !== this.cwd)
155
- throw new RpcError(RPC_ERRORS.invalidParams, msg().print.acp.fixedCwd(this.cwd, String(cwd)));
222
+ if (cwd !== undefined && (typeof cwd !== "string" || canonical(cwd) !== this.cwd))
223
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.core.fixedCwd(this.cwd, String(cwd)));
224
+ const mcp = countOf(params["mcpServers"]);
225
+ if (mcp > 0)
226
+ this.log(msg().acp.session.ignoredMcp(mcp));
227
+ const dirs = countOf(params["additionalDirectories"]);
228
+ if (dirs > 0)
229
+ this.log(msg().acp.session.ignoredDirs(dirs));
156
230
  }
157
- modes() {
158
- return permissionModes(this.session().state.permissionMode);
159
- }
160
- async switchTo(next, reason) {
161
- const session = await next;
162
- this.mapper = this.makeMapper();
163
- this.subscribe();
164
- if (session instanceof AgentSessionImpl)
165
- session.announceStart(reason);
231
+ /** new / load / resume 的答复:模式与配置项;答复发出后再公布命令表与配置项。 */
232
+ opened(entry) {
233
+ const configOptions = this.configOptions(entry.session);
234
+ setImmediate(() => entry.mapper.announce());
235
+ return {
236
+ modes: permissionModes(entry.mode),
237
+ ...(configOptions.length > 0 ? { configOptions } : {}),
238
+ };
166
239
  }
167
240
  async newSession(params) {
168
- this.checkCwd(params);
169
- const current = this.session();
170
- if (this.freshUnclaimed && current.messages.length === 0) {
171
- this.freshUnclaimed = false;
241
+ this.checkOpen(params);
242
+ await prepareConfigOptions(this.runtime.providers);
243
+ let session;
244
+ if (this.standby !== undefined && this.standby.messages.length === 0) {
245
+ session = this.standby;
246
+ this.standby = undefined;
172
247
  }
173
248
  else {
174
- this.ensureIdle();
175
- await this.switchTo(switchSession(this.runtime, { kind: "new" }), "new");
249
+ session = await createSessionAlongside(this.runtime, { kind: "new" });
176
250
  }
177
- this.activeId = this.session().state.sessionId;
178
- return { sessionId: this.activeId, modes: this.modes() };
251
+ const entry = this.adopt(session, this.initialMode);
252
+ // 兄弟会话自己的 session_start 在订阅之后发(认领的启动会话早已发过)
253
+ if (session !== currentSession(this.runtime))
254
+ session.announceStart("new");
255
+ return { sessionId: entry.id, ...this.opened(entry) };
179
256
  }
180
257
  async openSession(params, replay) {
181
258
  const id = str(params, "sessionId");
182
- this.checkCwd(params);
183
- if (this.session().state.sessionId !== id) {
184
- this.ensureIdle();
185
- try {
186
- await this.switchTo(switchSession(this.runtime, { kind: "resume", id }), "resume");
259
+ this.checkOpen(params);
260
+ await prepareConfigOptions(this.runtime.providers);
261
+ let entry = this.pool.get(id);
262
+ if (entry === undefined) {
263
+ if (this.standby !== undefined && this.standby.state.sessionId === id) {
264
+ entry = this.adopt(this.standby, this.initialMode);
265
+ this.standby = undefined;
187
266
  }
188
- catch (error) {
189
- throw new RpcError(RPC_ERRORS.resourceNotFound, msg().print.acp.sessionNotFound(id, errorText(error)));
267
+ else {
268
+ let session;
269
+ try {
270
+ // 本服务端发出的 id 都是 UUID;只有这种形状才在文件不存在时按原 id 新建(空会话不落盘,
271
+ // 进程重启后客户端——如 Zed 的 Reload Agent——会带着它回来),其它一律 -32002
272
+ session = await createSessionAlongside(this.runtime, { kind: "resume", id }, { createIfMissing: SESSION_ID_SHAPE.test(id) });
273
+ }
274
+ catch (error) {
275
+ throw new RpcError(RPC_ERRORS.resourceNotFound, msg().acp.core.sessionNotFound(id, errorText(error)));
276
+ }
277
+ entry = this.pool.get(id) ?? this.adopt(session, this.initialMode);
278
+ session.announceStart("resume");
190
279
  }
191
280
  }
192
- this.freshUnclaimed = false;
193
- this.activeId = id;
194
281
  if (replay) {
195
- this.mapper.replay(this.session().messages);
282
+ entry.mapper.replay(entry.session.messages);
196
283
  await this.peer.flush();
197
284
  }
198
- return { modes: this.modes() };
285
+ return this.opened(entry);
199
286
  }
200
- list() {
287
+ list(params) {
288
+ const cwd = params["cwd"];
289
+ if (cwd !== undefined &&
290
+ cwd !== null &&
291
+ (typeof cwd !== "string" || canonical(cwd) !== this.cwd))
292
+ return { sessions: [] };
201
293
  // 会话目录按 ama 启动时的 cwd 字串分组(不是 realpath)
202
294
  const items = listSessions({
203
295
  sessionDir: this.runtime.paths.sessionDir,
204
296
  cwd: this.runtime.paths.cwd,
205
297
  });
206
- return {
207
- sessions: items.map((item) => ({
208
- sessionId: item.id,
209
- cwd: item.cwd,
210
- title: item.name ?? item.firstPrompt ?? null,
211
- updatedAt: item.modifiedAt,
212
- })),
213
- };
298
+ return pageSessions(items, params["cursor"]);
299
+ }
300
+ entryOf(params) {
301
+ const id = str(params, "sessionId");
302
+ const entry = this.pool.get(id);
303
+ if (entry === undefined)
304
+ throw new RpcError(RPC_ERRORS.resourceNotFound, msg().acp.session.notOpen(id));
305
+ return entry;
214
306
  }
215
307
  async close(params) {
216
308
  const id = str(params, "sessionId");
217
- if (id === this.activeId) {
218
- if (this.session().state.isStreaming)
219
- await this.session().abort();
220
- this.activeId = undefined;
309
+ const entry = this.pool.get(id);
310
+ if (entry === undefined)
311
+ return {};
312
+ this.pool.delete(id);
313
+ this.queue.cancelQueued(id);
314
+ const running = this.queue.running;
315
+ if (running?.sessionId === id) {
316
+ running.cancelRequested = true;
317
+ await entry.session.abort().catch(() => undefined);
318
+ await this.queue.settled(id);
221
319
  }
320
+ entry.unsubscribe();
321
+ let fallback = this.session();
322
+ if (fallback === entry.session) {
323
+ const next = [...this.pool.values()][0]?.session ?? this.standby;
324
+ if (next !== undefined)
325
+ fallback = next;
326
+ else {
327
+ // 关掉的是唯一的会话:补一个待命会话当前台,下一个 session/new 认领它
328
+ this.standby = await createSessionAlongside(this.runtime, { kind: "new" });
329
+ fallback = this.standby;
330
+ }
331
+ }
332
+ if (fallback instanceof AgentSessionImpl)
333
+ await disposeSessionAlongside(this.runtime, entry.session, fallback);
222
334
  return {};
223
335
  }
224
- ensureIdle() {
225
- if (this.session().state.isStreaming)
226
- throw new RpcError(RPC_ERRORS.invalidRequest, msg().print.acp.busy);
227
- }
228
- async prompt(params) {
229
- const id = str(params, "sessionId");
230
- if (id !== this.activeId)
231
- await this.openSession({ sessionId: id }, false);
336
+ async prompt(params, signal) {
337
+ const entry = this.entryOf(params);
232
338
  const { text, images } = promptOf(params["prompt"]);
233
- const session = this.session();
339
+ const result = new Promise((resolve, reject) => {
340
+ const job = {
341
+ sessionId: entry.id,
342
+ text,
343
+ images,
344
+ cancelRequested: false,
345
+ resolve,
346
+ reject,
347
+ };
348
+ // 客户端以 $/cancel_request 撤回:等价 session/cancel,答复由对等端改成 -32800。
349
+ // 连接关闭(stdin 结束)也会 abort 这个 signal——那时不算撤回,已开始的运行照常跑完。
350
+ signal?.addEventListener("abort", () => {
351
+ if (this.peer.isOpen)
352
+ this.cancelJob(job);
353
+ }, { once: true });
354
+ this.queue.push(job);
355
+ });
356
+ const answer = await result;
357
+ if (signal?.aborted === true && this.peer.isOpen)
358
+ throw new RpcError(RPC_ERRORS.requestCancelled, "session/prompt: request cancelled");
359
+ return answer;
360
+ }
361
+ /** 出队:切前台、重放权限模式、跑回合、算 stopReason,再发 `session_info_update`。 */
362
+ async runPrompt(job) {
363
+ const entry = this.pool.get(job.sessionId);
364
+ if (entry === undefined || job.cancelRequested)
365
+ return cancelledResult();
366
+ const { session } = entry;
367
+ setForegroundSession(this.runtime, session);
368
+ if (this.runtime.permission.mode !== entry.mode)
369
+ session.setPermissionMode(entry.mode);
234
370
  const before = session.getStats().tokens;
235
- this.cancelRequested = false;
371
+ let failure;
236
372
  try {
237
- await session.prompt(text, images.length > 0 ? { images } : {});
373
+ await session.prompt(job.text, job.images.length > 0 ? { images: job.images } : {});
238
374
  }
239
375
  catch (error) {
240
- throw new RpcError(RPC_ERRORS.internalError, errorText(error));
376
+ failure = error;
241
377
  }
242
378
  await this.peer.flush();
243
379
  const after = session.getStats().tokens;
@@ -248,54 +384,112 @@ export class AcpServer {
248
384
  cachedWriteTokens: after.cacheWrite - before.cacheWrite,
249
385
  totalTokens: after.total - before.total,
250
386
  };
387
+ entry.mapper.emitSessionInfo(sessionTitle(session.state.sessionName, session.messages), new Date().toISOString());
388
+ if (job.cancelRequested)
389
+ return { stopReason: "cancelled", usage };
390
+ if (failure !== undefined)
391
+ throw new RpcError(RPC_ERRORS.internalError, errorText(failure));
251
392
  const last = [...session.messages].reverse().find((m) => "role" in m && m.role === "assistant");
252
- const reason = last !== undefined && "stopReason" in last ? last.stopReason : "stop";
253
- if (this.cancelRequested || reason === "aborted")
393
+ const reason = last !== undefined && "stopReason" in last ? stopReasonOf(last) : "stop";
394
+ if (reason === "aborted")
254
395
  return { stopReason: "cancelled", usage };
396
+ if (reason === "refusal")
397
+ return { stopReason: "refusal", usage };
255
398
  if (reason === "error") {
256
399
  const message = last !== undefined && "errorMessage" in last ? last.errorMessage : undefined;
257
- throw new RpcError(RPC_ERRORS.internalError, message ?? msg().print.acp.modelFailed);
400
+ throw new RpcError(RPC_ERRORS.internalError, message ?? msg().acp.core.modelFailed);
258
401
  }
259
402
  return { stopReason: reason === "length" ? "max_tokens" : "end_turn", usage };
260
403
  }
404
+ /** 中断一个提示:在跑 → abort;排队中 → 出队后立即回 cancelled(`runPrompt` 见标记直接返回)。 */
405
+ cancelJob(job) {
406
+ job.cancelRequested = true;
407
+ if (this.queue.running === job) {
408
+ const entry = this.pool.get(job.sessionId);
409
+ void entry?.session.abort().catch(() => undefined);
410
+ }
411
+ }
261
412
  async cancel(params) {
262
- if (params["sessionId"] !== this.activeId)
413
+ const id = params["sessionId"];
414
+ if (typeof id !== "string")
263
415
  return;
264
- this.cancelRequested = true;
265
- await this.session()
266
- .abort()
267
- .catch(() => undefined);
416
+ this.queue.cancelQueued(id);
417
+ const running = this.queue.running;
418
+ if (running?.sessionId === id)
419
+ this.cancelJob(running);
268
420
  }
269
421
  setMode(params) {
270
- str(params, "sessionId");
422
+ const entry = this.entryOf(params);
271
423
  const mode = params["modeId"];
272
424
  if (!isPermissionMode(mode))
273
- throw new RpcError(RPC_ERRORS.invalidParams, msg().print.acp.unknownMode(String(mode)));
274
- this.session().setPermissionMode(mode);
425
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.core.unknownMode(String(mode)));
426
+ this.applyMode(entry, mode);
275
427
  return {};
276
428
  }
429
+ /** `session/set_mode` 与 `mode` 配置项共用:按会话记,前台立即生效,否则出队时生效。 */
430
+ applyMode(entry, mode) {
431
+ entry.mode = mode;
432
+ if (entry.session === this.session() && this.runtime.permission.mode !== mode) {
433
+ entry.session.setPermissionMode(mode); // 映射器发 current_mode_update + config_option_update
434
+ return;
435
+ }
436
+ const notify = (update) => void this.peer.notify(ACP_METHODS.sessionUpdate, { sessionId: entry.id, update });
437
+ notify({ sessionUpdate: "current_mode_update", currentModeId: mode });
438
+ notify({
439
+ sessionUpdate: "config_option_update",
440
+ configOptions: this.configOptions(entry.session),
441
+ });
442
+ }
443
+ async setConfigOption(params) {
444
+ const entry = this.entryOf(params);
445
+ const configId = str(params, "configId");
446
+ if (configId === CONFIG_IDS.mode) {
447
+ const mode = params["value"];
448
+ if (!isPermissionMode(mode))
449
+ throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.config.invalidValue(configId, String(mode)));
450
+ this.applyMode(entry, mode);
451
+ }
452
+ else
453
+ await applyConfigOption(entry.session, params);
454
+ return { configOptions: this.configOptions(entry.session) };
455
+ }
277
456
  async askClient(request, signal) {
278
- const sessionId = this.activeId;
279
- if (sessionId === undefined || !this.peer.isOpen)
457
+ // 审批来自在跑的那个回合(它就是前台);不在回合里时按前台会话
458
+ const running = this.queue.running;
459
+ const foreground = this.session();
460
+ const entry = running !== undefined
461
+ ? this.pool.get(running.sessionId)
462
+ : [...this.pool.values()].find((e) => e.session === foreground);
463
+ if (entry === undefined || !this.peer.isOpen)
280
464
  return undefined;
281
- const toolCallId = this.mapper.toolCallIdFor(request.toolName, request.input) ?? request.requestId;
465
+ // 本会话(含 codemode 内层)的调用 id 就是已发 tool_call 的 id;子 Agent(depth > 0)的 id 属于
466
+ // 子会话、客户端没见过,退回按工具名 + 参数匹配
467
+ const context = request.context;
468
+ const own = (context?.depth ?? 0) === 0 && context?.taskId === undefined;
469
+ const toolCallId = (own && context?.toolCallId !== undefined
470
+ ? entry.mapper.wireId(context.toolCallId)
471
+ : undefined) ??
472
+ entry.mapper.toolCallIdFor(request.toolName, request.input) ??
473
+ request.requestId;
474
+ // 映射器记得的标题优先(codemode 内层带前缀,客户端不会被改成无前缀的标题)
475
+ const title = entry.mapper.titleFor(toolCallId) ?? toolTitle(request.toolName, request.input);
282
476
  const locations = toolLocations(request.input, this.cwd);
283
477
  let answer;
284
478
  try {
285
479
  answer = await this.peer.request(ACP_METHODS.requestPermission, {
286
- sessionId,
480
+ sessionId: entry.id,
287
481
  toolCall: {
288
482
  toolCallId,
289
- title: toolTitle(request.toolName, request.input),
483
+ title,
290
484
  kind: toolKind(request.toolName),
291
485
  status: "pending",
292
486
  rawInput: request.input,
293
487
  ...(locations !== undefined ? { locations } : {}),
294
488
  },
295
489
  options: [
296
- { optionId: "allow_once", name: msg().print.acp.allowOnce, kind: "allow_once" },
297
- { optionId: "allow_always", name: msg().print.acp.allowAlways, kind: "allow_always" },
298
- { optionId: "reject_once", name: msg().print.acp.rejectOnce, kind: "reject_once" },
490
+ { optionId: "allow_once", name: msg().acp.core.allowOnce, kind: "allow_once" },
491
+ { optionId: "allow_always", name: msg().acp.core.allowAlways, kind: "allow_always" },
492
+ { optionId: "reject_once", name: msg().acp.core.rejectOnce, kind: "reject_once" },
299
493
  ],
300
494
  }, signal);
301
495
  }