@armadra/agent 0.11.0 → 0.12.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.
@@ -14,14 +14,33 @@
14
14
  * - `elicitation/create` 交给 `onElicitation`;给了它 `initialize` 才声明 `clientCapabilities.elicitation`,
15
15
  * 没给时线路与旧版相同(不声明,Agent 发来的请求回 method not found)。`cancel(sessionId)`、连接关闭时
16
16
  * 挂起的 elicitation 一律回 `{ action: "cancel" }`;ama 从不替人填表。
17
+ * - `elicitation/complete`(Agent → 客户端通知,URL 型 elicitation 在带外完成)交给 `onElicitationComplete`;给了它
18
+ * `initialize` 声明 `elicitation: { form: {}, url: {} }`(只给 `onElicitation` 时仍是 `{}`,与旧版相同)。同一
19
+ * `elicitationId` 若还挂着(处理器没答),它的 `signal` 随之 abort、答 `{ action: "cancel" }`,宿主据此收掉卡片。
20
+ * 表单字段含字符串多选(`type: "array"`)与带标题的 `oneOf` / `anyOf`,答复 `content` 里多选是字符串数组;
21
+ * {@link multiSelectOptions} / {@link singleSelectOptions} 把两种写法收成统一的 `{ const, title }` 列表。
22
+ * - 会话管理:`listSessions(cwd?, cursor?, signal?)` 发 `session/list`(要 Agent 声明 `sessionCapabilities.list`,见
23
+ * `supportsList`),`listAllSessions` 跟 `nextCursor` 翻页取全;`closeSession(sessionId, signal?)` 发 `session/close`
24
+ * (`supportsClose`),按规范等同先取消:该会话挂起的权限请求答 `cancelled`、elicitation 答 `cancel`,再发请求。
25
+ * 没声明能力的 Agent 会答 -32601,开会话遇 -32000 同样原样抛出({@link isAuthRequiredError})。
17
26
  * - `setConfigOption(sessionId, configId, value)` 发 `session/set_config_option`;开会话答的 `configOptions`
18
27
  * 原样交回。`initialize` 声明 `clientCapabilities.session.configOptions: {}`(只认 select,不声明 boolean);
19
- * 不声明 `auth.terminal`(ama 作客户端没有可借给 Agent 的交互终端)。[ACP-D]
28
+ * `auth.terminal` 缺省不声明(ama 作客户端没有可借给 Agent 的交互终端),见下 `authTerminal`。[ACP-D]
29
+ * - 认证:`authenticate(methodId)` 发 `authenticate`(agent 型方法)、`logout()` 发 `logout`(Agent 声明
30
+ * `auth.logout` 才可用,见 `supportsLogout`);terminal 型方法规范禁止经 `authenticate`,已知是 terminal 型时本地
31
+ * 拒绝(-32602),宿主自己在终端里跑 `args` / `env`。构造参数 `authTerminal: true` 才在 `initialize` 声明
32
+ * `clientCapabilities.auth.terminal`(宿主有交互终端可借;Agent 据此才给 terminal 型方法),缺省线路与旧版相同。
33
+ * 开会话遇 -32000(auth_required)用 {@link isAuthRequiredError} 判断,`authMethods` 保留 id / name / description / type
34
+ * / args / env 原样。
20
35
  * - 线路两侧开 `$/cancel_request`:本端请求的 `signal` abort 时通知 Agent;Agent 撤回挂起的
21
36
  * `session/request_permission` / `elicitation/create` 时处理器的 `signal` abort,回 cancelled / cancel。[ACP-D]
22
37
  */
23
38
  import { JsonRpcPeer, RpcError } from "../jsonrpc.js";
24
- import { ACP_METHODS, ACP_PROTOCOL_VERSION, RPC_ERRORS, } from "./types.js";
39
+ import { ACP_METHODS, ACP_PROTOCOL_VERSION, RPC_ERRORS, isTerminalAuthMethod, } from "./types.js";
40
+ /** 错误是 ACP 的 auth_required(-32000):开会话或发提示前需要先认证 / 登录。 */
41
+ export function isAuthRequiredError(error) {
42
+ return error instanceof RpcError && error.code === RPC_ERRORS.authRequired;
43
+ }
25
44
  /** 开会话参数里的 `mcpServers`:拷一份,缺省空数组。 */
26
45
  function mcpServersOf(options) {
27
46
  return [...(options?.mcpServers ?? [])];
@@ -52,14 +71,25 @@ export class AcpClient {
52
71
  * - `mcpServers`:开会话时可经 {@link AcpSessionOptions} 传 MCP 服务器;
53
72
  * - `elicitation`:构造参数 `onElicitation` 接 `elicitation/create`;
54
73
  * - `configOptions`:`setConfigOption` 与开会话答的 `configOptions`;
55
- * - `terminal`:构造参数 `onTerminal` 接 `terminal/*`。
74
+ * - `terminal`:构造参数 `onTerminal` 接 `terminal/*`;
75
+ * - `auth`:`authenticate` / `logout`、构造参数 `authTerminal`、`isAuthRequiredError`;
76
+ * - `elicitationComplete`:构造参数 `onElicitationComplete` 接 `elicitation/complete`,表单字段含多选与 `oneOf`。
56
77
  */
57
- static features = { mcpServers: true, elicitation: true, configOptions: true, terminal: true };
78
+ static features = {
79
+ mcpServers: true,
80
+ elicitation: true,
81
+ configOptions: true,
82
+ terminal: true,
83
+ auth: true,
84
+ elicitationComplete: true,
85
+ };
58
86
  peer;
59
87
  /** sessionId → 挂起权限请求的取消器。 */
60
88
  pendingPermissions = new Map();
61
89
  /** sessionId(没给就是空串)→ 挂起 elicitation 的取消器。 */
62
90
  pendingElicitations = new Map();
91
+ /** elicitationId → 挂起 elicitation 的取消器(`elicitation/complete` 用)。 */
92
+ elicitationsById = new Map();
63
93
  initResult;
64
94
  constructor(options) {
65
95
  this.options = options;
@@ -71,6 +101,8 @@ export class AcpClient {
71
101
  onNotification: (method, params) => {
72
102
  if (method === ACP_METHODS.sessionUpdate)
73
103
  options.onUpdate?.(params);
104
+ else if (method === ACP_METHODS.elicitationComplete)
105
+ this.onElicitationComplete(params);
74
106
  },
75
107
  onClose: () => {
76
108
  for (const set of this.pendingPermissions.values())
@@ -109,7 +141,12 @@ export class AcpClient {
109
141
  fs: { readTextFile: false, writeTextFile: false },
110
142
  terminal: this.options.onTerminal !== undefined,
111
143
  session: { configOptions: {} },
112
- ...(this.options.onElicitation !== undefined ? { elicitation: {} } : {}),
144
+ ...(this.options.authTerminal === true ? { auth: { terminal: true } } : {}),
145
+ ...(this.options.onElicitation !== undefined || this.options.onElicitationComplete
146
+ ? {
147
+ elicitation: this.options.onElicitationComplete !== undefined ? { form: {}, url: {} } : {},
148
+ }
149
+ : {}),
113
150
  },
114
151
  ...(this.options.clientInfo !== undefined ? { clientInfo: this.options.clientInfo } : {}),
115
152
  }, signal);
@@ -130,9 +167,27 @@ export class AcpClient {
130
167
  supportsClose() {
131
168
  return this.agentCapabilities.sessionCapabilities?.close != null;
132
169
  }
170
+ /** Agent 声明了 `auth.logout`(存在即支持)。 */
171
+ supportsLogout() {
172
+ return this.agentCapabilities.auth?.logout != null;
173
+ }
133
174
  supportsImages() {
134
175
  return this.agentCapabilities.promptCapabilities?.image === true;
135
176
  }
177
+ /**
178
+ * 用 agent 型认证方法登录(`methodId` 取自 `authMethods`)。terminal 型方法不经 `authenticate`:已知是
179
+ * terminal 型时抛 -32602,由宿主在终端里跑该方法的 `args` / `env`。失败的错误码原样抛出。
180
+ */
181
+ async authenticate(methodId, signal) {
182
+ const known = this.authMethods.find((method) => method.id === methodId);
183
+ if (known !== undefined && isTerminalAuthMethod(known))
184
+ throw new RpcError(RPC_ERRORS.invalidParams, `auth method ${methodId} is a terminal method: run it in a terminal, not through authenticate`);
185
+ return this.peer.request(ACP_METHODS.authenticate, { methodId }, signal);
186
+ }
187
+ /** 登出当前账号;Agent 没声明 `auth.logout`(见 `supportsLogout`)时会答 method not found。 */
188
+ logout(signal) {
189
+ return this.peer.request(ACP_METHODS.logout, {}, signal);
190
+ }
136
191
  newSession(cwd, signal, options) {
137
192
  return this.peer.request(ACP_METHODS.sessionNew, { cwd, mcpServers: mcpServersOf(options) }, signal);
138
193
  }
@@ -144,14 +199,33 @@ export class AcpClient {
144
199
  loadSession(sessionId, cwd, signal, options) {
145
200
  return this.peer.request(ACP_METHODS.sessionLoad, { sessionId, cwd, mcpServers: mcpServersOf(options) }, signal);
146
201
  }
147
- listSessions(cwd, cursor) {
202
+ /** 一页会话;`cwd` 过滤(绝对路径),`cursor` 取自上一页的 `nextCursor`。 */
203
+ listSessions(cwd, cursor, signal) {
148
204
  return this.peer.request(ACP_METHODS.sessionList, {
149
205
  ...(cwd !== undefined ? { cwd } : {}),
150
206
  ...(cursor !== undefined ? { cursor } : {}),
151
- });
207
+ }, signal);
208
+ }
209
+ /** 跟 `nextCursor` 翻页取全(`maxPages` 防对端游标不终止,缺省 100 页)。 */
210
+ async listAllSessions(cwd, signal, maxPages = 100) {
211
+ const sessions = [];
212
+ let cursor;
213
+ for (let page = 0; page < maxPages; page++) {
214
+ const result = await this.listSessions(cwd, cursor, signal);
215
+ sessions.push(...(result.sessions ?? []));
216
+ if (typeof result.nextCursor !== "string" || result.nextCursor === "")
217
+ break;
218
+ cursor = result.nextCursor;
219
+ }
220
+ return sessions;
152
221
  }
153
- closeSession(sessionId) {
154
- return this.peer.request(ACP_METHODS.sessionClose, { sessionId });
222
+ /** 关闭会话:规范要求 Agent 先取消进行中的工作,所以本端先把该会话挂起的请求收掉。 */
223
+ closeSession(sessionId, signal) {
224
+ for (const controller of this.pendingPermissions.get(sessionId) ?? [])
225
+ controller.abort();
226
+ for (const controller of this.pendingElicitations.get(sessionId) ?? [])
227
+ controller.abort();
228
+ return this.peer.request(ACP_METHODS.sessionClose, { sessionId }, signal);
155
229
  }
156
230
  prompt(sessionId, prompt, signal) {
157
231
  return this.peer.request(ACP_METHODS.sessionPrompt, { sessionId, prompt }, signal);
@@ -225,6 +299,9 @@ export class AcpClient {
225
299
  const set = this.pendingElicitations.get(key) ?? new Set();
226
300
  set.add(controller);
227
301
  this.pendingElicitations.set(key, set);
302
+ const id = typeof request.elicitationId === "string" ? request.elicitationId : undefined;
303
+ if (id !== undefined)
304
+ this.elicitationsById.set(id, controller);
228
305
  const signal = AbortSignal.any([controller.signal, connection]);
229
306
  try {
230
307
  const answer = await Promise.race([
@@ -243,6 +320,16 @@ export class AcpClient {
243
320
  }
244
321
  finally {
245
322
  set.delete(controller);
323
+ if (id !== undefined && this.elicitationsById.get(id) === controller)
324
+ this.elicitationsById.delete(id);
246
325
  }
247
326
  }
327
+ /** 通知没有答复:参数不对就忽略;还挂着的同 id elicitation 先收掉,再交给宿主。 */
328
+ onElicitationComplete(params) {
329
+ const id = params?.elicitationId;
330
+ if (typeof id !== "string")
331
+ return;
332
+ this.elicitationsById.get(id)?.abort();
333
+ this.options.onElicitationComplete?.(params);
334
+ }
248
335
  }
@@ -14,8 +14,10 @@ export declare const ACP_PROTOCOL_VERSION: 1;
14
14
  /** 方法名(客户端 → Agent 的请求 / 通知,Agent → 客户端的请求 / 通知)。 */
15
15
  export declare const ACP_METHODS: {
16
16
  readonly initialize: "initialize";
17
- /** ama 不实现(只给 terminal 型认证方法,规范要求这类方法不经 authenticate)。 */
17
+ /** 客户端 → Agent:agent 型认证方法经它完成;terminal 型不经它(规范)。 */
18
18
  readonly authenticate: "authenticate";
19
+ /** 客户端 → Agent:登出当前账号(Agent 声明 `agentCapabilities.auth.logout` 才可用)。 */
20
+ readonly logout: "logout";
19
21
  readonly sessionNew: "session/new";
20
22
  readonly sessionLoad: "session/load";
21
23
  readonly sessionResume: "session/resume";
@@ -28,6 +30,8 @@ export declare const ACP_METHODS: {
28
30
  readonly sessionUpdate: "session/update";
29
31
  readonly requestPermission: "session/request_permission";
30
32
  readonly elicitationCreate: "elicitation/create";
33
+ /** Agent → 客户端通知:URL 型 elicitation 已在带外完成。 */
34
+ readonly elicitationComplete: "elicitation/complete";
31
35
  /** 协议级取消(双向通知,`requestId` 指对端发来的、尚未答复的请求)。 */
32
36
  readonly cancelRequest: "$/cancel_request";
33
37
  };
@@ -93,6 +97,7 @@ export interface AcpClientCapabilities {
93
97
  } | null;
94
98
  } | null;
95
99
  /** `terminal: true`:客户端能替 Agent 起终端跑 terminal 型认证方法。 */
100
+ /** 能在交互终端里复现 Agent 的启动命令;声明后 Agent 才会给 terminal 型认证方法。 */
96
101
  auth?: {
97
102
  terminal?: boolean;
98
103
  };
@@ -144,6 +149,19 @@ export type AcpAuthMethod = {
144
149
  export interface AcpAuthenticateParams {
145
150
  methodId: string;
146
151
  }
152
+ /** `authenticate` / `logout` 的答复:规范里只有 `_meta`。 */
153
+ export type AcpAuthenticateResult = {
154
+ _meta?: Record<string, unknown> | null;
155
+ };
156
+ export type AcpLogoutResult = {
157
+ _meta?: Record<string, unknown> | null;
158
+ };
159
+ /** terminal 型认证方法(客户端在终端里跑 `<agent 命令> <args…>`,带 `env`)。 */
160
+ export type AcpTerminalAuthMethod = Extract<AcpAuthMethod, {
161
+ type: "terminal";
162
+ }>;
163
+ /** 是 terminal 型认证方法:这类方法不经 `authenticate`。 */
164
+ export declare function isTerminalAuthMethod(method: AcpAuthMethod): method is AcpTerminalAuthMethod;
147
165
  export interface AcpInitializeResult {
148
166
  protocolVersion: number;
149
167
  agentCapabilities?: AcpAgentCapabilities;
@@ -234,14 +252,23 @@ export interface AcpSetConfigOptionParams {
234
252
  export interface AcpSetConfigOptionResult {
235
253
  configOptions?: AcpSessionConfigOption[] | null;
236
254
  }
237
- /** 表单里的一个字段:扁平的原始类型,规范只允许这几种。 */
255
+ /** 带标题的枚举项(`oneOf` / `anyOf` 里的一项):`const` 是提交的值,`title` 是显示名。 */
256
+ export interface AcpEnumOption {
257
+ const: string;
258
+ title: string;
259
+ }
260
+ /** 表单里的一个字段:扁平的原始类型或字符串多选,规范只允许这几种;未知 `type` 保留原样、不当已知控件渲染。 */
238
261
  export type AcpElicitationField = {
239
262
  type: "string";
240
263
  title?: string;
241
264
  description?: string;
265
+ /** 单选:只有取值。 */
242
266
  enum?: string[];
243
267
  enumNames?: string[];
268
+ /** 单选:带标题的取值(`const` / `title`)。 */
269
+ oneOf?: AcpEnumOption[];
244
270
  format?: string;
271
+ pattern?: string;
245
272
  minLength?: number;
246
273
  maxLength?: number;
247
274
  default?: string;
@@ -257,7 +284,27 @@ export type AcpElicitationField = {
257
284
  title?: string;
258
285
  description?: string;
259
286
  default?: boolean;
287
+ } | {
288
+ /** 字符串多选:提交值是 `string[]`。 */
289
+ type: "array";
290
+ title?: string;
291
+ description?: string;
292
+ minItems?: number;
293
+ maxItems?: number;
294
+ /** `{ type: "string", enum }`(只有取值)或 `{ anyOf: [{ const, title }] }`(带标题)。 */
295
+ items: {
296
+ type?: "string";
297
+ enum?: string[];
298
+ anyOf?: AcpEnumOption[];
299
+ };
300
+ default?: string[];
260
301
  };
302
+ /** 表单字段的提交值:多选是字符串数组。 */
303
+ export type AcpElicitationValue = string | number | boolean | string[];
304
+ /** 多选字段可选的项:带标题的 `anyOf` 或只有取值的 `enum`(标题取值本身);不是多选字段返回 undefined。 */
305
+ export declare function multiSelectOptions(field: AcpElicitationField): AcpEnumOption[] | undefined;
306
+ /** 单选字符串字段可选的项:`oneOf`(带标题)优先,其次 `enum`(可配 `enumNames`);没有枚举返回 undefined。 */
307
+ export declare function singleSelectOptions(field: AcpElicitationField): AcpEnumOption[] | undefined;
261
308
  export interface AcpElicitationSchema {
262
309
  type: "object";
263
310
  properties: Record<string, AcpElicitationField>;
@@ -266,6 +313,12 @@ export interface AcpElicitationSchema {
266
313
  /** Agent 发来的 `elicitation/create` 参数;未知字段保留。 */
267
314
  export interface AcpElicitationParams {
268
315
  sessionId?: string;
316
+ /** 关联的工具调用(会话范围的 elicitation 可带)。 */
317
+ toolCallId?: string | null;
318
+ /** 不在会话里时关联的请求 id(请求范围)。 */
319
+ requestId?: string | number;
320
+ /** URL 型 elicitation 的 id;之后 Agent 以 `elicitation/complete` 通知它在带外完成。 */
321
+ elicitationId?: string;
269
322
  message: string;
270
323
  /** `form`(缺省)或 `url`。 */
271
324
  mode?: "form" | "url" | (string & {});
@@ -273,11 +326,16 @@ export interface AcpElicitationParams {
273
326
  url?: string;
274
327
  [key: string]: unknown;
275
328
  }
329
+ /** `elicitation/complete` 通知的参数:URL 型 elicitation 已完成。 */
330
+ export interface AcpCompleteElicitationParams {
331
+ elicitationId: string;
332
+ _meta?: Record<string, unknown> | null;
333
+ }
276
334
  export type AcpElicitationAction = "accept" | "decline" | "cancel";
277
335
  export interface AcpElicitationResult {
278
336
  action: AcpElicitationAction;
279
337
  /** 只随 `accept`。 */
280
- content?: Record<string, string | number | boolean>;
338
+ content?: Record<string, AcpElicitationValue>;
281
339
  }
282
340
  export type AcpResumeSessionParams = AcpLoadSessionParams;
283
341
  export type AcpResumeSessionResult = AcpLoadSessionResult;
@@ -288,8 +346,12 @@ export interface AcpListSessionsParams {
288
346
  export interface AcpSessionInfo {
289
347
  sessionId: string;
290
348
  cwd: string;
349
+ /** Agent 报告的额外工作区根(绝对路径);缺省与空数组等价。 */
350
+ additionalDirectories?: string[];
291
351
  title?: string | null;
352
+ /** ISO 8601 的最近活动时间。 */
292
353
  updatedAt?: string | null;
354
+ _meta?: Record<string, unknown> | null;
293
355
  }
294
356
  export interface AcpListSessionsResult {
295
357
  sessions: AcpSessionInfo[];
@@ -298,6 +360,10 @@ export interface AcpListSessionsResult {
298
360
  export interface AcpCloseSessionParams {
299
361
  sessionId: string;
300
362
  }
363
+ /** `session/close` 的答复:规范里只有 `_meta`。 */
364
+ export type AcpCloseSessionResult = {
365
+ _meta?: Record<string, unknown> | null;
366
+ };
301
367
  export interface AcpSetModeParams {
302
368
  sessionId: string;
303
369
  modeId: string;
@@ -13,8 +13,10 @@ export const ACP_PROTOCOL_VERSION = 1;
13
13
  /** 方法名(客户端 → Agent 的请求 / 通知,Agent → 客户端的请求 / 通知)。 */
14
14
  export const ACP_METHODS = {
15
15
  initialize: "initialize",
16
- /** ama 不实现(只给 terminal 型认证方法,规范要求这类方法不经 authenticate)。 */
16
+ /** 客户端 → Agent:agent 型认证方法经它完成;terminal 型不经它(规范)。 */
17
17
  authenticate: "authenticate",
18
+ /** 客户端 → Agent:登出当前账号(Agent 声明 `agentCapabilities.auth.logout` 才可用)。 */
19
+ logout: "logout",
18
20
  sessionNew: "session/new",
19
21
  sessionLoad: "session/load",
20
22
  sessionResume: "session/resume",
@@ -27,6 +29,8 @@ export const ACP_METHODS = {
27
29
  sessionUpdate: "session/update",
28
30
  requestPermission: "session/request_permission",
29
31
  elicitationCreate: "elicitation/create",
32
+ /** Agent → 客户端通知:URL 型 elicitation 已在带外完成。 */
33
+ elicitationComplete: "elicitation/complete",
30
34
  /** 协议级取消(双向通知,`requestId` 指对端发来的、尚未答复的请求)。 */
31
35
  cancelRequest: "$/cancel_request",
32
36
  };
@@ -42,5 +46,30 @@ export const RPC_ERRORS = {
42
46
  /** 请求被 `$/cancel_request` 撤回。 */
43
47
  requestCancelled: -32800,
44
48
  };
49
+ /** 是 terminal 型认证方法:这类方法不经 `authenticate`。 */
50
+ export function isTerminalAuthMethod(method) {
51
+ return method.type === "terminal";
52
+ }
53
+ /** 多选字段可选的项:带标题的 `anyOf` 或只有取值的 `enum`(标题取值本身);不是多选字段返回 undefined。 */
54
+ export function multiSelectOptions(field) {
55
+ if (field.type !== "array")
56
+ return undefined;
57
+ const items = field.items;
58
+ if (Array.isArray(items?.anyOf))
59
+ return items.anyOf.map((o) => ({ const: o.const, title: o.title }));
60
+ if (Array.isArray(items?.enum))
61
+ return items.enum.map((value) => ({ const: value, title: value }));
62
+ return undefined;
63
+ }
64
+ /** 单选字符串字段可选的项:`oneOf`(带标题)优先,其次 `enum`(可配 `enumNames`);没有枚举返回 undefined。 */
65
+ export function singleSelectOptions(field) {
66
+ if (field.type !== "string")
67
+ return undefined;
68
+ if (Array.isArray(field.oneOf))
69
+ return field.oneOf.map((o) => ({ const: o.const, title: o.title }));
70
+ if (Array.isArray(field.enum))
71
+ return field.enum.map((value, i) => ({ const: value, title: field.enumNames?.[i] ?? value }));
72
+ return undefined;
73
+ }
45
74
  /** ama 自己的 `_meta` 命名空间(客户端不得假设其含义)。 */
46
75
  export const ACP_META_KEY = "ama";
@@ -32,6 +32,11 @@ import { StatusArea } from "./status-area.js";
32
32
  import type { StatusBar } from "./status-bar.js";
33
33
  import { type ProgramStatusOptions } from "./program-status-ui.js";
34
34
  import { ToolTracker } from "./tool-view.js";
35
+ /** 底部提示行的清除计时器(测试注入手动时钟,帧黄金不再依赖墙钟)。 */
36
+ export interface HintTimers {
37
+ set(fn: () => void, ms: number): unknown;
38
+ clear(handle: unknown): void;
39
+ }
35
40
  export interface InteractiveModeOptions {
36
41
  /** 缺省 `ProcessTerminal`(测试注入 MemoryTerminal)。 */
37
42
  terminal?: Terminal;
@@ -49,6 +54,8 @@ export interface InteractiveModeOptions {
49
54
  onReady?(handle: InteractiveHandle): void;
50
55
  /** 剪贴板读取的注入(测试;缺省调系统命令)。 */
51
56
  clipboard?: ClipboardDeps;
57
+ /** 提示行清除计时器的注入(测试;缺省真实 `setTimeout`)。 */
58
+ hintTimers?: HintTimers;
52
59
  /** 终端程序状态 OSC 7501(缺省只在真实终端启用;测试注入终端时给了才启用)。 */
53
60
  programStatus?: ProgramStatusOptions;
54
61
  }
@@ -56,6 +56,14 @@ import { loadKeys, processTerminal } from "./terminal-setup.js";
56
56
  import { ToolTracker } from "./tool-view.js";
57
57
  import { backgroundTasks, blockingTasks, keyLabel, runningBackground } from "./task-background.js";
58
58
  const HINT_MS = 2500;
59
+ const realHintTimers = {
60
+ set: (fn, ms) => {
61
+ const handle = setTimeout(fn, ms);
62
+ handle.unref?.();
63
+ return handle;
64
+ },
65
+ clear: (handle) => clearTimeout(handle),
66
+ };
59
67
  export function runInteractiveMode(runtime, context, options = {}) {
60
68
  const terminal = options.terminal ?? processTerminal();
61
69
  const ui = runtime.config.ui ?? {};
@@ -142,6 +150,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
142
150
  tui.addChild(area.quota);
143
151
  // ---- 小工具 ---------------------------------------------------------------
144
152
  let finished = false;
153
+ const hintTimers = options.hintTimers ?? realHintTimers;
145
154
  let hintTimer;
146
155
  let resolveExit = () => undefined;
147
156
  const render = () => tui.requestRender();
@@ -152,14 +161,13 @@ export function runInteractiveMode(runtime, context, options = {}) {
152
161
  const showHint = (text, ms = HINT_MS) => {
153
162
  hint.setText(text === "" ? "" : theme.fg("dim", text));
154
163
  if (hintTimer !== undefined)
155
- clearTimeout(hintTimer);
164
+ hintTimers.clear(hintTimer);
156
165
  hintTimer = undefined;
157
166
  if (text !== "") {
158
- hintTimer = setTimeout(() => {
167
+ hintTimer = hintTimers.set(() => {
159
168
  hint.setText("");
160
169
  render();
161
170
  }, ms);
162
- hintTimer.unref?.();
163
171
  }
164
172
  render();
165
173
  };
@@ -423,7 +431,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
423
431
  finished = true;
424
432
  logoAnimation?.finish();
425
433
  if (hintTimer !== undefined)
426
- clearTimeout(hintTimer);
434
+ hintTimers.clear(hintTimer);
427
435
  loader.stop();
428
436
  area.dispose();
429
437
  osc7501.exit();
@@ -8,6 +8,9 @@
8
8
  * - 写入目标:重定向(`>`、`>>`、`&>`)、tee、cp / mv / mkdir / touch / ln / chmod 等的目标在项目外或受保护;
9
9
  * - 参数里出现机密路径(`cat .env`、`< ~/.ssh/id_rsa`、`--env-file=.env`)。
10
10
  *
11
+ * `env` / `printenv` 不在安全名单里:子进程继承整个进程环境(供应商 API key 等),打印出来就会进
12
+ * tool_result 与会话文件。段里只剩环境赋值或包装命令(`env`、`env FOO=1`)同样不放行。
13
+ *
11
14
  * 静态判定(全部满足即放行):每一段都命中安全名单({@link AUTO_SAFE_COMMANDS} + 用户追加),整条命令
12
15
  * 没有命令替换、参数没有变量展开 `$X`、没有可能匹配点文件的通配、没有超深嵌套。
13
16
  *
@@ -8,6 +8,9 @@
8
8
  * - 写入目标:重定向(`>`、`>>`、`&>`)、tee、cp / mv / mkdir / touch / ln / chmod 等的目标在项目外或受保护;
9
9
  * - 参数里出现机密路径(`cat .env`、`< ~/.ssh/id_rsa`、`--env-file=.env`)。
10
10
  *
11
+ * `env` / `printenv` 不在安全名单里:子进程继承整个进程环境(供应商 API key 等),打印出来就会进
12
+ * tool_result 与会话文件。段里只剩环境赋值或包装命令(`env`、`env FOO=1`)同样不放行。
13
+ *
11
14
  * 静态判定(全部满足即放行):每一段都命中安全名单({@link AUTO_SAFE_COMMANDS} + 用户追加),整条命令
12
15
  * 没有命令替换、参数没有变量展开 `$X`、没有可能匹配点文件的通配、没有超深嵌套。
13
16
  *
@@ -38,7 +41,6 @@ export const AUTO_SAFE_COMMANDS = [
38
41
  "echo",
39
42
  "printf",
40
43
  "which",
41
- "printenv",
42
44
  "true",
43
45
  "false",
44
46
  "cut",
@@ -451,6 +453,7 @@ function cdTarget(argv, cwd) {
451
453
  function parseSegment(segment) {
452
454
  const redirects = extractRedirects(segment);
453
455
  return {
456
+ first: shellWords(redirects.rest.replace(/^[({\s]+/, ""))[0],
454
457
  argv: commandWords(redirects.rest),
455
458
  withSudo: commandWords(redirects.rest, true),
456
459
  redirects,
@@ -478,8 +481,12 @@ function ruleAsk(info, options) {
478
481
  }
479
482
  function safeSegment(info, extra) {
480
483
  const argv = info.withSudo;
481
- if (argv.length === 0)
482
- return undefined;
484
+ // 只剩环境赋值或包装命令:`env` / `env FOO=1` 会打印整个环境(含密钥),不放行。
485
+ if (argv.length === 0) {
486
+ return info.first === undefined
487
+ ? "empty command"
488
+ : `${base(info.first)} is not in the auto safe list`;
489
+ }
483
490
  for (const word of argv) {
484
491
  if (word.includes("$"))
485
492
  return `variable expansion in ${word}`;
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 判定 = auto 的静态判定(`analyzeBashForAuto`:分词、嵌套展开、规则层的网络 / 删除 / 写入目标 /
5
5
  * 机密路径、命令替换、变量展开、点文件通配)**全部通过**,再加四条更窄的限制:
6
- * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器、`printenv`
7
- * 这类会把环境变量里的密钥打进上下文的命令);
6
+ * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器;`env` /
7
+ * `printenv` 这类会把环境变量里的密钥打进上下文的命令两边都没有);
8
8
  * - 没有输出重定向(`/dev/null` 等设备除外);
9
9
  * - 没有嵌套 shell(`sh -c`、`eval`、`xargs`、`find -exec`)与进程替换;
10
10
  * - 段首没有环境赋值或包装命令(`GIT_EXTERNAL_DIFF=… git diff`、`env …`、`nohup …`)。
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 判定 = auto 的静态判定(`analyzeBashForAuto`:分词、嵌套展开、规则层的网络 / 删除 / 写入目标 /
5
5
  * 机密路径、命令替换、变量展开、点文件通配)**全部通过**,再加四条更窄的限制:
6
- * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器、`printenv`
7
- * 这类会把环境变量里的密钥打进上下文的命令);
6
+ * - 每段命令都命中 {@link READONLY_BASH}(比 auto 安全名单窄:去掉测试 / 构建运行器;`env` /
7
+ * `printenv` 这类会把环境变量里的密钥打进上下文的命令两边都没有);
8
8
  * - 没有输出重定向(`/dev/null` 等设备除外);
9
9
  * - 没有嵌套 shell(`sh -c`、`eval`、`xargs`、`find -exec`)与进程替换;
10
10
  * - 段首没有环境赋值或包装命令(`GIT_EXTERNAL_DIFF=… git diff`、`env …`、`nohup …`)。
@@ -23,6 +23,8 @@
23
23
  * `codemode.mode` 显式配置覆盖(缺省配置不写这个键,以后调整映射能惠及老用户)。
24
24
  * - `coordinator` 预设即使显式 `on`,脚本里能调用的工具也只限活动集(read + 宿主工具),见
25
25
  * `codemodeCallableFilter`:协调者「不写文件、不跑 bash」的约定不能经 codemode 绕过。
26
+ * - (#242)`coordinator` 预设不阻塞:`task` 缺省后台(无视 `subagents.background`,调用参数 `background`
27
+ * 与类型定义的 `background:` 仍优先)、`task_ctl wait` 的超时封顶 5 秒;其它预设不变。
26
28
  * - codemode 工具由 B10 经组装根的 `toolFactories` 注册(生效模式为 off 时不注册);不可用
27
29
  * (例如 `codemode.requireStrict` 而运行时 Node 不隔离网络)时 codemode-only 预设**回退到 default 并
28
30
  * warning**(不报错:零配置用户不应因此起不来),显式 `on` 同样忽略;跟随预设得到的 `on` 静默回退。
@@ -56,6 +58,11 @@ export declare function effectiveCodemodeMode(config: Pick<AmaConfig, "tools" |
56
58
  * 不限(`undefined`;`on` 模式下脚本还能调没直接暴露的 ls / task / todo 等)。
57
59
  */
58
60
  export declare function codemodeCallableFilter(config: Pick<AmaConfig, "tools">): "active" | undefined;
61
+ /**
62
+ * [#242] 是否 `coordinator` 预设:协调者只分派、不阻塞——`task` 缺省后台、`task_ctl wait` 超时封顶
63
+ * (组装根据此绑定,见 cli/compose-agents.ts)。其它预设不受影响。
64
+ */
65
+ export declare function isCoordinatorPreset(config: Pick<AmaConfig, "tools">): boolean;
59
66
  /**
60
67
  * `tools.default` 微调:不带前缀的名字整组替换 `base`,再依次应用 `+name` / `-name`。
61
68
  * 返回去重后的名字(保持首次出现顺序)。
@@ -23,6 +23,8 @@
23
23
  * `codemode.mode` 显式配置覆盖(缺省配置不写这个键,以后调整映射能惠及老用户)。
24
24
  * - `coordinator` 预设即使显式 `on`,脚本里能调用的工具也只限活动集(read + 宿主工具),见
25
25
  * `codemodeCallableFilter`:协调者「不写文件、不跑 bash」的约定不能经 codemode 绕过。
26
+ * - (#242)`coordinator` 预设不阻塞:`task` 缺省后台(无视 `subagents.background`,调用参数 `background`
27
+ * 与类型定义的 `background:` 仍优先)、`task_ctl wait` 的超时封顶 5 秒;其它预设不变。
26
28
  * - codemode 工具由 B10 经组装根的 `toolFactories` 注册(生效模式为 off 时不注册);不可用
27
29
  * (例如 `codemode.requireStrict` 而运行时 Node 不隔离网络)时 codemode-only 预设**回退到 default 并
28
30
  * warning**(不报错:零配置用户不应因此起不来),显式 `on` 同样忽略;跟随预设得到的 `on` 静默回退。
@@ -72,7 +74,14 @@ export function effectiveCodemodeMode(config, strict) {
72
74
  * 不限(`undefined`;`on` 模式下脚本还能调没直接暴露的 ls / task / todo 等)。
73
75
  */
74
76
  export function codemodeCallableFilter(config) {
75
- return canonicalPreset(config.tools?.preset) === "coordinator" ? "active" : undefined;
77
+ return isCoordinatorPreset(config) ? "active" : undefined;
78
+ }
79
+ /**
80
+ * [#242] 是否 `coordinator` 预设:协调者只分派、不阻塞——`task` 缺省后台、`task_ctl wait` 超时封顶
81
+ * (组装根据此绑定,见 cli/compose-agents.ts)。其它预设不受影响。
82
+ */
83
+ export function isCoordinatorPreset(config) {
84
+ return canonicalPreset(config.tools?.preset) === "coordinator";
76
85
  }
77
86
  /**
78
87
  * `tools.default` 微调:不带前缀的名字整组替换 `base`,再依次应用 `+name` / `-name`。
@@ -7,6 +7,8 @@
7
7
  * 与父一致,运行时拒绝;
8
8
  * - `wait` / `output` 是轮询类调用(`annotations.pollable`,重复调用检测豁免);
9
9
  * - `send` = 续聊放后台:等价于 `task{taskId, prompt, background:true}`,完成后同样收到通知。
10
+ * - [#242] `coordinator` 预设下 `wait` 的超时封顶 `COORDINATOR_WAIT_MAX_MS`(协调者不阻塞,见
11
+ * `bindTaskCtlWaitCap`);其它预设不变。
10
12
  * - [W7-B1] `wait` 被转后台(`background()`,TUI `Ctrl+B` / RPC `background_task`)打断时立即返回固定
11
13
  * 文案,要求模型不要再等。
12
14
  */
@@ -14,6 +16,13 @@ import type { ToolDefinition } from "./types.js";
14
16
  export declare const TASK_CTL_TOOL = "task_ctl";
15
17
  export declare const DEFAULT_WAIT_MS = 30000;
16
18
  export declare const MAX_WAIT_MS = 600000;
19
+ /** [#242] `coordinator` 预设下 `wait` 的超时上限(毫秒)。 */
20
+ export declare const COORDINATOR_WAIT_MAX_MS = 5000;
21
+ /**
22
+ * [#242] 组装根把 `wait` 的超时上限绑到 `task_ctl` 工具(`coordinator` 预设);没绑定按 `MAX_WAIT_MS`。
23
+ * 工具描述与参数 schema 不变(其它预设逐字节不变);被封顶时在超时回复里说明。
24
+ */
25
+ export declare function bindTaskCtlWaitCap(tool: ToolDefinition | undefined, capMs: number): void;
17
26
  export interface TaskCtlInput {
18
27
  action: "list" | "wait" | "stop" | "output" | "send";
19
28
  taskId?: string;