@armadra/agent 0.7.5 → 0.8.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 (84) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CHANGELOG.zh-CN.md +34 -0
  3. package/dist/agent/session-classifier.d.ts +2 -0
  4. package/dist/agent/session-classifier.js +24 -3
  5. package/dist/agent/session-extensions.d.ts +1 -0
  6. package/dist/agent/session-extensions.js +3 -0
  7. package/dist/agent/session-subagent.js +9 -2
  8. package/dist/agent/subagent-background.d.ts +5 -1
  9. package/dist/agent/subagent-background.js +13 -1
  10. package/dist/agent/subagent-fork.d.ts +10 -5
  11. package/dist/agent/subagent-fork.js +22 -6
  12. package/dist/agent/subagent-registry.d.ts +1 -1
  13. package/dist/agent/subagent-registry.js +1 -1
  14. package/dist/agents/catalog.d.ts +1 -0
  15. package/dist/ai/apis/chatgpt-backend.d.ts +3 -1
  16. package/dist/ai/apis/chatgpt-backend.js +10 -1
  17. package/dist/ai/apis/max-tokens.d.ts +4 -0
  18. package/dist/ai/apis/max-tokens.js +12 -1
  19. package/dist/ai/providers/enrich.d.ts +6 -2
  20. package/dist/ai/providers/enrich.js +13 -2
  21. package/dist/ai/providers/max-tokens-cache.d.ts +22 -0
  22. package/dist/ai/providers/max-tokens-cache.js +95 -0
  23. package/dist/ai/providers/registry.js +3 -3
  24. package/dist/bundle/ama.cjs +1250 -375
  25. package/dist/checkpoints/shadow-git.d.ts +5 -1
  26. package/dist/checkpoints/shadow-git.js +11 -2
  27. package/dist/cli/compose-providers.d.ts +1 -0
  28. package/dist/cli/compose-providers.js +3 -0
  29. package/dist/cli/compose-store.d.ts +6 -2
  30. package/dist/cli/compose-store.js +8 -4
  31. package/dist/cli/compose.js +2 -2
  32. package/dist/config/json-schema.js +1 -0
  33. package/dist/config/key-docs.js +1 -0
  34. package/dist/config/schema-w5.js +2 -0
  35. package/dist/config/settings-registry.js +1 -0
  36. package/dist/config/types-w5.d.ts +5 -0
  37. package/dist/drivers/acp/driver.d.ts +22 -1
  38. package/dist/drivers/acp/driver.js +59 -5
  39. package/dist/drivers/agents.js +3 -0
  40. package/dist/drivers/catalog.d.ts +2 -0
  41. package/dist/drivers/catalog.js +94 -4
  42. package/dist/drivers/native/claude-normalize.d.ts +8 -0
  43. package/dist/drivers/native/claude-normalize.js +27 -0
  44. package/dist/drivers/native/claude-stream.js +16 -1
  45. package/dist/drivers/native/codex-app-server.js +4 -2
  46. package/dist/drivers/native/oneshot.js +12 -2
  47. package/dist/drivers/native/pi-gate.d.ts +19 -0
  48. package/dist/drivers/native/pi-gate.js +39 -0
  49. package/dist/drivers/native/pi-rpc.d.ts +29 -0
  50. package/dist/drivers/native/pi-rpc.js +483 -0
  51. package/dist/drivers/runner.js +1 -0
  52. package/dist/drivers/types.d.ts +1 -1
  53. package/dist/i18n/catalog.d.ts +5 -0
  54. package/dist/i18n/messages/auth.d.ts +2 -0
  55. package/dist/i18n/messages/auth.js +2 -0
  56. package/dist/i18n/messages/config-keys.d.ts +2 -0
  57. package/dist/i18n/messages/config-keys.js +2 -0
  58. package/dist/i18n/messages/config.d.ts +2 -0
  59. package/dist/i18n/messages/drivers.d.ts +4 -0
  60. package/dist/i18n/messages/drivers.js +4 -0
  61. package/dist/i18n/messages/interactive.d.ts +2 -0
  62. package/dist/i18n/messages/interactive.js +2 -0
  63. package/dist/i18n/messages/session.js +2 -2
  64. package/dist/modes/interactive/agent-ui.js +7 -0
  65. package/dist/session/manager.d.ts +3 -1
  66. package/dist/session/manager.js +5 -0
  67. package/dist/session/offload.d.ts +10 -2
  68. package/dist/session/offload.js +27 -4
  69. package/dist/tools/background-jobs.d.ts +4 -4
  70. package/dist/tools/background-jobs.js +4 -4
  71. package/dist/tools/bash.d.ts +2 -1
  72. package/dist/tools/bash.js +4 -3
  73. package/dist/tools/task-ctl.js +2 -1
  74. package/dist/tools/task.d.ts +9 -0
  75. package/dist/tools/task.js +27 -1
  76. package/docs/agents.md +70 -14
  77. package/docs/en/permissions.md +1 -1
  78. package/docs/en/providers.md +11 -10
  79. package/docs/en/sessions.md +2 -1
  80. package/docs/permissions.md +1 -1
  81. package/docs/providers.md +16 -11
  82. package/docs/session-format.md +2 -1
  83. package/docs/sessions.md +2 -1
  84. package/package.json +1 -1
@@ -16,8 +16,10 @@
16
16
  export declare const SHADOW_DIR = "shadow";
17
17
  /** cwd 下(不含忽略的)文件数上限,超出本会话降级为 tools。 */
18
18
  export declare const SHADOW_MAX_FILES = 20000;
19
- /** 单次快照耗时上限(毫秒),超出本会话降级为 tools。 */
19
+ /** 单次快照耗时上限(毫秒),连续 `SHADOW_SLOW_STREAK` 次超出本会话降级为 tools。 */
20
20
  export declare const SHADOW_MAX_SNAPSHOT_MS = 3000;
21
+ /** 连续这么多次快照超时才降级(首次快照不计)。 */
22
+ export declare const SHADOW_SLOW_STREAK = 2;
21
23
  export declare function shadowRoot(dataDir: string): string;
22
24
  /** cwd 对应的影子仓库目录。 */
23
25
  export declare function shadowRepoDir(dataDir: string, cwd: string): string;
@@ -73,6 +75,8 @@ export declare class ShadowRepo {
73
75
  private readonly indexFile;
74
76
  private initialized;
75
77
  private counted;
78
+ /** 连续超时的快照数(首次快照不计),到 `SHADOW_SLOW_STREAK` 才降级。 */
79
+ private slowStreak;
76
80
  constructor(dataDir: string, cwd: string, options?: ShadowGitOptions);
77
81
  private now;
78
82
  private env;
@@ -23,8 +23,10 @@ import { msg } from "../i18n/index.js";
23
23
  export const SHADOW_DIR = "shadow";
24
24
  /** cwd 下(不含忽略的)文件数上限,超出本会话降级为 tools。 */
25
25
  export const SHADOW_MAX_FILES = 20_000;
26
- /** 单次快照耗时上限(毫秒),超出本会话降级为 tools。 */
26
+ /** 单次快照耗时上限(毫秒),连续 `SHADOW_SLOW_STREAK` 次超出本会话降级为 tools。 */
27
27
  export const SHADOW_MAX_SNAPSHOT_MS = 3_000;
28
+ /** 连续这么多次快照超时才降级(首次快照不计)。 */
29
+ export const SHADOW_SLOW_STREAK = 2;
28
30
  /** 单个 git 进程的硬上限,防止卡死。 */
29
31
  const GIT_TIMEOUT_MS = 60_000;
30
32
  /** 本地配置的版本;改了配置项就加一,旧仓库会补写。 */
@@ -85,6 +87,8 @@ export class ShadowRepo {
85
87
  indexFile;
86
88
  initialized;
87
89
  counted = false;
90
+ /** 连续超时的快照数(首次快照不计),到 `SHADOW_SLOW_STREAK` 才降级。 */
91
+ slowStreak = 0;
88
92
  constructor(dataDir, cwd, options = {}) {
89
93
  this.cwd = resolve(cwd);
90
94
  this.dir = shadowRepoDir(dataDir, this.cwd);
@@ -207,6 +211,8 @@ export class ShadowRepo {
207
211
  await this.init();
208
212
  // 计时不含一次性的建仓与本地配置(十来个 git 子进程,Windows 上可达数秒),只量快照本身
209
213
  const started = this.now();
214
+ // 首次快照要把整个工作区读进索引,天然偏慢,不计入超时
215
+ const first = !this.counted;
210
216
  await this.writeExcludes();
211
217
  if (!this.counted) {
212
218
  const out = await this.run([
@@ -233,7 +239,10 @@ export class ShadowRepo {
233
239
  args.push("-p", parent);
234
240
  const commit = await this.text(args);
235
241
  const elapsed = this.now() - started;
236
- if (elapsed > this.maxSnapshotMs) {
242
+ if (first)
243
+ return { commit };
244
+ this.slowStreak = elapsed > this.maxSnapshotMs ? this.slowStreak + 1 : 0;
245
+ if (this.slowStreak >= SHADOW_SLOW_STREAK) {
237
246
  return {
238
247
  commit,
239
248
  degrade: {
@@ -14,6 +14,7 @@
14
14
  * `originator` 头;OAuth 刷新拿到 `auth.chatgpt` 配置与同一份 env。
15
15
  * - 发现缓存(`<dataDir>/models/discovered/<provider>.json`)并入模型表为空的供应商(discovered-cache.ts);
16
16
  * chatgpt 的缓存 flavor 与当前登录不符时视为过期、不并入。
17
+ * - max_tokens 上限缓存(`<dataDir>/models/max-tokens-caps.json`,30 天)载回进程内的表并订阅写回(#152)。
17
18
  */
18
19
  import type { ApiRegistry } from "../ai/apis/api.js";
19
20
  import { ProviderRegistry } from "../ai/providers/registry.js";
@@ -14,10 +14,12 @@
14
14
  * `originator` 头;OAuth 刷新拿到 `auth.chatgpt` 配置与同一份 env。
15
15
  * - 发现缓存(`<dataDir>/models/discovered/<provider>.json`)并入模型表为空的供应商(discovered-cache.ts);
16
16
  * chatgpt 的缓存 flavor 与当前登录不符时视为过期、不并入。
17
+ * - max_tokens 上限缓存(`<dataDir>/models/max-tokens-caps.json`,30 天)载回进程内的表并订阅写回(#152)。
17
18
  */
18
19
  import { loadModelsDevIndex } from "../ai/providers/models-dev-cache.js";
19
20
  import { ProviderRegistry, discoverLocalModels } from "../ai/providers/registry.js";
20
21
  import { mergeDiscoveredModels } from "../ai/providers/discovered-cache.js";
22
+ import { attachMaxTokensCache } from "../ai/providers/max-tokens-cache.js";
21
23
  import { lazyIndex } from "../ai/providers/enrich.js";
22
24
  import { readOAuthEntry } from "../auth/oauth/token-store.js";
23
25
  import { CHATGPT_BASE_URLS, CHATGPT_PROVIDER_ID } from "../auth/chatgpt/presets.js";
@@ -110,6 +112,7 @@ export async function buildProviderRegistry(input, options = {}) {
110
112
  }
111
113
  // 发现缓存(`ama auth login chatgpt` / `ama models discover chatgpt`)并入模型表为空的供应商
112
114
  if (dataDir !== undefined) {
115
+ attachMaxTokensCache(dataDir);
113
116
  const flavor = readOAuthEntry(input.authFile, CHATGPT_PROVIDER_ID)?.flavor;
114
117
  mergeDiscoveredModels(registry, dataDir, lazyIndex(() => loadModelsDevIndex(dataDir)), (id) => (id === CHATGPT_PROVIDER_ID ? flavor : undefined));
115
118
  }
@@ -6,16 +6,19 @@
6
6
  * - `resume{id}` / `fork{id}`:先在本目录、再在全部子目录里按 id 或唯一前缀找文件;找不到 →
7
7
  * `session_not_found`,前缀不唯一 → `invalid_arguments` 并列出候选;
8
8
  * - `session-id{id}`:找到则打开,否则以该 id 新建;
9
- * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁)。
9
+ * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁);源会话不属于任何 AgentSession,
10
+ * 它的告警(图片读不回,#183)显式交给 `context.log`。
10
11
  */
11
12
  import { SessionManager } from "../session/manager.js";
12
13
  import type { SessionListItem } from "../session/types.js";
14
+ import type { LogFn } from "./compose-session.js";
13
15
  import type { RuntimeDeps, SessionRequest } from "./deps.js";
14
16
  /** 按 id / 唯一前缀找会话文件;本 cwd 目录优先。 */
15
17
  export declare function findSessionFile(root: string, id: string, cwd?: string): string | undefined;
16
18
  export declare function openSession(request: SessionRequest, context: {
17
19
  sessionDir: string;
18
20
  cwd: string;
21
+ log?: LogFn | undefined;
19
22
  }): SessionManager;
20
23
  /** 列会话(最新在前);子 Agent 会话缺省跳过,`includeSubagents` 时保留(带 `subagent` 标记)。 */
21
24
  export declare function listSessions(context: {
@@ -23,4 +26,5 @@ export declare function listSessions(context: {
23
26
  cwd?: string;
24
27
  includeSubagents?: boolean;
25
28
  }): SessionListItem[];
26
- export declare function createSessionStore(): RuntimeDeps["sessions"];
29
+ /** `log`:`--fork` 源会话的告警出口(通常是会话的 `options.log`)。 */
30
+ export declare function createSessionStore(log?: LogFn): RuntimeDeps["sessions"];
@@ -6,7 +6,8 @@
6
6
  * - `resume{id}` / `fork{id}`:先在本目录、再在全部子目录里按 id 或唯一前缀找文件;找不到 →
7
7
  * `session_not_found`,前缀不唯一 → `invalid_arguments` 并列出候选;
8
8
  * - `session-id{id}`:找到则打开,否则以该 id 新建;
9
- * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁)。
9
+ * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁);源会话不属于任何 AgentSession,
10
+ * 它的告警(图片读不回,#183)显式交给 `context.log`。
10
11
  */
11
12
  import { existsSync, readdirSync, statSync } from "node:fs";
12
13
  import { join } from "node:path";
@@ -81,7 +82,9 @@ export function openSession(request, context) {
81
82
  : SessionManager.open(file);
82
83
  }
83
84
  case "fork": {
84
- const source = SessionManager.open(requireFile(root, request.id, cwd));
85
+ const source = SessionManager.open(requireFile(root, request.id, cwd), {
86
+ warn: (message) => context.log?.("warn", message),
87
+ });
85
88
  try {
86
89
  const leaf = source.leafId();
87
90
  if (leaf === null)
@@ -104,9 +107,10 @@ export function listSessions(context) {
104
107
  .filter((item) => context.includeSubagents === true || item.subagent !== true)
105
108
  .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
106
109
  }
107
- export function createSessionStore() {
110
+ /** `log`:`--fork` 源会话的告警出口(通常是会话的 `options.log`)。 */
111
+ export function createSessionStore(log) {
108
112
  return {
109
- open: (request, context) => openSession(request, context),
113
+ open: (request, context) => openSession(request, { ...context, log }),
110
114
  list: async (context) => listSessions(context),
111
115
  show: async (id, context) => {
112
116
  const file = requireFile(context.sessionDir, id);
@@ -40,7 +40,7 @@ import { builtinTools } from "../tools/registry.js";
40
40
  import { MEMORY_TOOL, createMemoryTool } from "../memory/tool.js";
41
41
  import { takeCodemodeNotice } from "./codemode-notice.js";
42
42
  import { buildProviderRegistry } from "./compose-providers.js";
43
- import { composeSession, emptyComposeState, } from "./compose-session.js";
43
+ import { composeSession, emptyComposeState, stderrLog, } from "./compose-session.js";
44
44
  import { createSessionStore } from "./compose-store.js";
45
45
  import { defaultSendUser } from "./startup-steps.js";
46
46
  import { imageFitOptionsFor } from "../modes/image-input.js";
@@ -211,7 +211,7 @@ export function createRuntimeDeps(options = {}) {
211
211
  return registry;
212
212
  },
213
213
  },
214
- sessions: createSessionStore(),
214
+ sessions: createSessionStore(options.log ?? stderrLog()),
215
215
  tools: { create: (input) => createTools(input, options, state) },
216
216
  permissions: {
217
217
  create: (input) => new PermissionPipeline({
@@ -161,6 +161,7 @@ function w5Sections() {
161
161
  background: oneOf(SUBAGENT_BACKGROUND_MODES),
162
162
  autoBackgroundAfterMs: num(0),
163
163
  retainSessions: num(0),
164
+ forkMaxContextRatio: num(0.05, 0.95),
164
165
  }),
165
166
  models: object({ aliases: object({ fast: str(), strong: str() }), enabled: strings }),
166
167
  fallbackModel: str(),
@@ -65,6 +65,7 @@ const IMPLICIT_DEFAULTS = {
65
65
  background: "auto",
66
66
  autoBackgroundAfterMs: 0,
67
67
  retainSessions: 4,
68
+ forkMaxContextRatio: 0.5,
68
69
  },
69
70
  reminders: { todo: true, fileChanges: true, contextPressure: true, budget: true },
70
71
  todo: { reminder: 10 },
@@ -90,6 +90,7 @@ export function validateConfigW5(c, config) {
90
90
  "background",
91
91
  "autoBackgroundAfterMs",
92
92
  "retainSessions",
93
+ "forkMaxContextRatio",
93
94
  ], (s, p) => {
94
95
  c.number(s, "maxConcurrent", p, 1, 64);
95
96
  c.number(s, "maxPending", p, 0, 1024);
@@ -97,6 +98,7 @@ export function validateConfigW5(c, config) {
97
98
  c.oneOf(s, "background", p, SUBAGENT_BACKGROUND_MODES);
98
99
  c.number(s, "autoBackgroundAfterMs", p, 0);
99
100
  c.number(s, "retainSessions", p, 0);
101
+ c.number(s, "forkMaxContextRatio", p, 0.05, 0.95);
100
102
  });
101
103
  checkSection(c, config, "models", ["aliases", "enabled"], (s, p) => {
102
104
  c.stringArray(s, "enabled", p);
@@ -101,6 +101,7 @@ const ROWS = [
101
101
  row("subagents.maxConcurrent", "agents", "nextSession", "deny"),
102
102
  row("subagents.maxPending", "agents", "nextSession", "deny"),
103
103
  row("subagents.retainSessions", "agents", "nextSession", "deny"),
104
+ row("subagents.forkMaxContextRatio", "agents", "nextSession", "deny"),
104
105
  // [W7-B2] 改 task 工具描述(缺省后台 / 前台两种文案),故 prefix
105
106
  row("subagents.background", "agents", "restart", "any", { prefix: true }),
106
107
  row("subagents.autoBackgroundAfterMs", "agents", "restart", "any"),
@@ -79,6 +79,11 @@ export interface SubagentsConfig {
79
79
  autoBackgroundAfterMs?: number;
80
80
  /** [M-F] 已结束子会话保留在内存的句柄数(LRU),缺省 4;超出的续聊时从会话文件重开。只认用户级。 */
81
81
  retainSessions?: number;
82
+ /**
83
+ * [#149] fork 回落比例:父上一次请求的输入 token 超过(窗口 − compaction.reserveTokens)× 本值时
84
+ * fork 回落为 fresh;0.05–0.95,缺省 0.5。只认用户级。
85
+ */
86
+ forkMaxContextRatio?: number;
82
87
  }
83
88
  /** `models.enabled` 的一项:`provider/model[@channel]` 或 `provider/*`。 */
84
89
  export declare const MODELS_ENABLED_REF: RegExp;
@@ -19,16 +19,36 @@ import { type DriverDeps } from "../base.js";
19
19
  import type { CatalogCandidate } from "../catalog.js";
20
20
  import { TurnCollector } from "../turn.js";
21
21
  import type { AgentDriver, DriverOpenOptions, DriverProbe, DriverSession } from "../types.js";
22
- import { type AcpAuthMethod, type AcpContentBlock, type AcpSessionConfigOption, type AcpSessionModeState, type AcpSessionUpdate } from "./types.js";
22
+ import { type AcpAuthMethod, type AcpContentBlock, type AcpPromptUsage, type AcpSessionConfigOption, type AcpSessionModeState, type AcpSessionUpdate } from "./types.js";
23
23
  export declare function toAcpContent(blocks: readonly ContentBlock[], images: boolean): AcpContentBlock[];
24
24
  /** session/update → DriverEvent(用户消息回放、命令表等忽略)。 */
25
25
  export declare function pushAcpUpdate(update: AcpSessionUpdate, turn: TurnCollector): void;
26
+ type TurnUsage = {
27
+ input?: number;
28
+ output?: number;
29
+ cacheRead?: number;
30
+ };
31
+ /**
32
+ * `session/prompt` 答复的 usage → 本回合用量(ama 口径:input 不含缓存命中)。
33
+ * - `previous`:Agent 报的是会话累计(如 Copilot)时,上一回合的原始累计值,相减得本回合;
34
+ * - 有 `totalTokens` 且它等于 input + output 时,input 含缓存命中部分,减去 cachedRead。
35
+ */
36
+ export declare function acpTurnUsage(usage: AcpPromptUsage, previous?: AcpPromptUsage): TurnUsage;
26
37
  /** ama 模式 → 该 Agent 的模式 id(显式映射 > 同名)。 */
27
38
  export declare function pickModeId(mode: PermissionMode, candidate: CatalogCandidate, state: AcpSessionModeState | null | undefined): string | undefined;
28
39
  /** `configOptions` 里 category `mode` 的选择项(没有 `modes` 的 Agent 用它表达模式)。 */
29
40
  export declare function modeConfigOption(options: readonly AcpSessionConfigOption[] | null | undefined): AcpSessionConfigOption | undefined;
30
41
  /** 与 {@link pickModeId} 同一映射,在 mode 类配置项的可选值里找(分组的展开)。 */
31
42
  export declare function pickConfigModeValue(mode: PermissionMode, candidate: CatalogCandidate, option: AcpSessionConfigOption | undefined): string | undefined;
43
+ /**
44
+ * `agents.<id>.model` / task 的 `model` → configOptions 里 category `model` 的可选值:先精确匹配值,
45
+ * 再不分大小写匹配值或名称,最后匹配 `provider/model` 的模型部分。没有就返回 undefined(用 Agent 的缺省)。
46
+ */
47
+ export declare function pickModelValue(model: string, options: readonly AcpSessionConfigOption[] | null | undefined): {
48
+ configId: string;
49
+ value: string;
50
+ current: boolean;
51
+ } | undefined;
32
52
  /** 开会话回 -32000 → `agent_auth_required`,列出 `initialize` 给的认证方法。 */
33
53
  export declare function authRequiredError(agentId: string, methods: readonly AcpAuthMethod[], command: readonly string[], cause: unknown): AmaError;
34
54
  export declare class AcpDriver implements AgentDriver {
@@ -40,3 +60,4 @@ export declare class AcpDriver implements AgentDriver {
40
60
  probe(): Promise<DriverProbe>;
41
61
  open(options: DriverOpenOptions): Promise<DriverSession>;
42
62
  }
63
+ export {};
@@ -90,6 +90,34 @@ export function pushAcpUpdate(update, turn) {
90
90
  return;
91
91
  }
92
92
  }
93
+ /**
94
+ * `session/prompt` 答复的 usage → 本回合用量(ama 口径:input 不含缓存命中)。
95
+ * - `previous`:Agent 报的是会话累计(如 Copilot)时,上一回合的原始累计值,相减得本回合;
96
+ * - 有 `totalTokens` 且它等于 input + output 时,input 含缓存命中部分,减去 cachedRead。
97
+ */
98
+ export function acpTurnUsage(usage, previous) {
99
+ const diff = (key) => {
100
+ const now = usage[key];
101
+ if (now === undefined)
102
+ return undefined;
103
+ return previous === undefined ? now : Math.max(0, now - (previous[key] ?? 0));
104
+ };
105
+ const input = diff("inputTokens");
106
+ const output = diff("outputTokens");
107
+ const cacheRead = diff("cachedReadTokens");
108
+ const { totalTokens: total, inputTokens: rawIn = 0, outputTokens: rawOut = 0 } = usage;
109
+ const cached = usage.cachedReadTokens ?? 0;
110
+ const includesCache = total !== undefined &&
111
+ cached > 0 &&
112
+ Math.abs(total - (rawIn + rawOut)) < Math.abs(total - (rawIn + rawOut + cached));
113
+ return {
114
+ ...(input !== undefined
115
+ ? { input: includesCache ? Math.max(0, input - (cacheRead ?? 0)) : input }
116
+ : {}),
117
+ ...(output !== undefined ? { output } : {}),
118
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
119
+ };
120
+ }
93
121
  /** ama 模式在 Agent 那边想要的模式 id,按优先级(显式映射 > 同名)。 */
94
122
  function wantedModeIds(mode, candidate) {
95
123
  const wanted = [candidate.modes?.[mode] ?? mode];
@@ -113,6 +141,23 @@ export function pickConfigModeValue(mode, candidate, option) {
113
141
  const values = option.options.flatMap((o) => "options" in o ? o.options.map((x) => x.value) : [o.value]);
114
142
  return wantedModeIds(mode, candidate).find((id) => values.includes(id));
115
143
  }
144
+ /**
145
+ * `agents.<id>.model` / task 的 `model` → configOptions 里 category `model` 的可选值:先精确匹配值,
146
+ * 再不分大小写匹配值或名称,最后匹配 `provider/model` 的模型部分。没有就返回 undefined(用 Agent 的缺省)。
147
+ */
148
+ export function pickModelValue(model, options) {
149
+ const option = options?.find((o) => o.category === "model" && o.type === "select");
150
+ if (option === undefined)
151
+ return undefined;
152
+ const values = option.options.flatMap((o) => ("options" in o ? o.options : [o]));
153
+ const lower = model.toLowerCase();
154
+ const hit = values.find((v) => v.value === model) ??
155
+ values.find((v) => v.value.toLowerCase() === lower || v.name.toLowerCase() === lower) ??
156
+ values.find((v) => v.value.toLowerCase().split("/").at(-1) === lower);
157
+ return hit === undefined
158
+ ? undefined
159
+ : { configId: option.id, value: hit.value, current: option.currentValue === hit.value };
160
+ }
116
161
  /** 一条认证方法给人看的样子:terminal 型附上要在终端里跑的命令。 */
117
162
  function authMethodText(method, command) {
118
163
  if (method.type !== "terminal")
@@ -181,6 +226,9 @@ class AcpDriverSession {
181
226
  notices = [];
182
227
  /** 本回合已发过 cancel。 */
183
228
  cancelling = false;
229
+ /** Agent 的 usage 是会话累计时,上一回合的原始值({@link acpTurnUsage})。 */
230
+ usageTotals;
231
+ cumulativeUsage = false;
184
232
  constructor(agentId, transport, deps) {
185
233
  this.agentId = agentId;
186
234
  this.transport = transport;
@@ -200,6 +248,7 @@ class AcpDriverSession {
200
248
  onProtocolError: (_line, reason) => this.deps.log?.("debug", `${this.agentId}: ${reason}`),
201
249
  });
202
250
  const signal = options.signal;
251
+ this.cumulativeUsage = candidate.acpUsage === "session";
203
252
  await this.client.initialize(signal);
204
253
  let opened;
205
254
  try {
@@ -228,6 +277,13 @@ class AcpDriverSession {
228
277
  else if (modes != null || modeOption !== undefined) {
229
278
  this.notices.push(msg().drivers.agent.noMatchingMode(this.agentId, options.mode));
230
279
  }
280
+ if (options.model !== undefined) {
281
+ const model = pickModelValue(options.model, opened.configOptions);
282
+ if (model === undefined)
283
+ this.notices.push(msg().drivers.agent.noMatchingModel(this.agentId, options.model));
284
+ else if (!model.current)
285
+ await this.client.setConfigOption(this.id, model.configId, model.value);
286
+ }
231
287
  }
232
288
  /** 续聊优先 resume(不回放),其次 load(回放丢弃),都不行就新开;设好 `this.id`。 */
233
289
  async openSession(options) {
@@ -291,11 +347,9 @@ class AcpDriverSession {
291
347
  });
292
348
  const usage = result.usage;
293
349
  if (usage != null) {
294
- turn.mergeUsage({
295
- ...(usage.inputTokens !== undefined ? { input: usage.inputTokens } : {}),
296
- ...(usage.outputTokens !== undefined ? { output: usage.outputTokens } : {}),
297
- ...(usage.cachedReadTokens !== undefined ? { cacheRead: usage.cachedReadTokens } : {}),
298
- });
350
+ turn.mergeUsage(acpTurnUsage(usage, this.cumulativeUsage ? (this.usageTotals ?? {}) : undefined));
351
+ if (this.cumulativeUsage)
352
+ this.usageTotals = usage;
299
353
  }
300
354
  return turn.result(result.stopReason);
301
355
  }
@@ -17,6 +17,7 @@ import { HostRunnerRegistry } from "./host-runners.js";
17
17
  import { ClaudeStreamDriver } from "./native/claude-stream.js";
18
18
  import { CodexAppServerDriver } from "./native/codex-app-server.js";
19
19
  import { OneshotDriver } from "./native/oneshot.js";
20
+ import { PiRpcDriver } from "./native/pi-rpc.js";
20
21
  import { PidRegistry, pidsFile } from "./pids.js";
21
22
  import { poolFromConfig } from "./pool.js";
22
23
  import { ProgramProbe } from "./probe.js";
@@ -33,6 +34,8 @@ export function createDriver(agentId, candidate, deps) {
33
34
  return new ClaudeStreamDriver(agentId, candidate, deps);
34
35
  case "codex-app-server":
35
36
  return new CodexAppServerDriver(agentId, candidate, deps);
37
+ case "pi-rpc":
38
+ return new PiRpcDriver(agentId, candidate, deps);
36
39
  case "oneshot":
37
40
  return new OneshotDriver(agentId, candidate, deps);
38
41
  }
@@ -24,6 +24,8 @@ export interface CatalogCandidate {
24
24
  modes?: Partial<Record<PermissionMode, string>>;
25
25
  permissions?: DriverCapabilities["permissions"];
26
26
  usage?: DriverCapabilities["usage"];
27
+ /** `session/prompt` 答复的 usage 是会话累计(缺省按本回合)。 */
28
+ acpUsage?: "session";
27
29
  /** 一次性打印模式的方言。 */
28
30
  oneshot?: "claude" | "codex" | "gemini";
29
31
  }
@@ -17,12 +17,55 @@ const ALL_MODES = [
17
17
  "auto",
18
18
  "full-auto",
19
19
  ];
20
+ /**
21
+ * 模式 id 与 ama 不同名的 ACP Agent 的映射(2026-10 实测)。没有映射时 ama 会留在 Agent 自己的当前模式,
22
+ * 而它可能比 ama 宽(如用户把 Claude 的缺省设成 bypassPermissions),所以已知的 Agent 一律写全。
23
+ * 从不映射到放开全部权限的模式(Claude `bypassPermissions`、Codex `agent-full-access`、Copilot autopilot)。
24
+ */
25
+ const CLAUDE_ACP_MODES = {
26
+ plan: "plan",
27
+ default: "default",
28
+ "auto-edit": "acceptEdits",
29
+ auto: "auto",
30
+ "full-auto": "auto",
31
+ };
32
+ /** codex-acp:`read-only` 是只读沙箱、写操作要审批(等同 app-server 的 on-request / read-only)。 */
33
+ const CODEX_ACP_MODES = {
34
+ plan: "read-only",
35
+ default: "read-only",
36
+ "auto-edit": "workspace-write",
37
+ auto: "agent",
38
+ "full-auto": "agent",
39
+ };
40
+ const COPILOT_MODE = "https://agentclientprotocol.com/protocol/session-modes#";
41
+ const COPILOT_MODES = {
42
+ plan: `${COPILOT_MODE}plan`,
43
+ default: `${COPILOT_MODE}agent`,
44
+ "auto-edit": `${COPILOT_MODE}agent`,
45
+ auto: `${COPILOT_MODE}agent`,
46
+ "full-auto": `${COPILOT_MODE}agent`,
47
+ };
48
+ /** Cursor CLI(官方 ACP 文档):`plan` 只读,`agent` 是完整工具权限(需要授权的仍发 request_permission)。 */
49
+ const CURSOR_MODES = {
50
+ plan: "plan",
51
+ default: "agent",
52
+ "auto-edit": "agent",
53
+ auto: "agent",
54
+ "full-auto": "agent",
55
+ };
20
56
  export const DRIVER_CATALOG = [
21
57
  {
22
58
  agentId: "claude",
23
59
  label: "Claude Code",
24
60
  candidates: [
25
- { kind: "acp-adapter", program: "claude-agent-acp", args: [], usage: "usd" },
61
+ {
62
+ kind: "acp-adapter",
63
+ program: "claude-agent-acp",
64
+ args: [],
65
+ verified: ">=0.89.0 <1.0.0",
66
+ usage: "usd",
67
+ modes: CLAUDE_ACP_MODES,
68
+ },
26
69
  {
27
70
  kind: "claude-stream",
28
71
  program: "claude",
@@ -37,7 +80,14 @@ export const DRIVER_CATALOG = [
37
80
  agentId: "codex",
38
81
  label: "Codex",
39
82
  candidates: [
40
- { kind: "acp-adapter", program: "codex-acp", args: [], usage: "tokens" },
83
+ {
84
+ kind: "acp-adapter",
85
+ program: "codex-acp",
86
+ args: [],
87
+ verified: ">=2.2.0 <3.0.0",
88
+ usage: "tokens",
89
+ modes: CODEX_ACP_MODES,
90
+ },
41
91
  {
42
92
  kind: "codex-app-server",
43
93
  program: "codex",
@@ -70,15 +120,44 @@ export const DRIVER_CATALOG = [
70
120
  {
71
121
  agentId: "opencode",
72
122
  label: "OpenCode",
73
- candidates: [{ kind: "acp", program: "opencode", args: ["acp"] }],
123
+ candidates: [{ kind: "acp", program: "opencode", args: ["acp"], verified: ">=1.18.0 <2.0.0" }],
74
124
  },
75
125
  {
76
126
  agentId: "copilot",
77
127
  label: "GitHub Copilot CLI",
78
128
  candidates: [
79
- { kind: "acp", program: "copilot", args: ["--acp", "--stdio"], usage: "requests" },
129
+ {
130
+ kind: "acp",
131
+ program: "copilot",
132
+ args: ["--acp", "--stdio"],
133
+ verified: ">=1.0.95 <2.0.0",
134
+ usage: "requests",
135
+ modes: COPILOT_MODES,
136
+ acpUsage: "session",
137
+ },
80
138
  ],
81
139
  },
140
+ {
141
+ agentId: "pi",
142
+ label: "Pi",
143
+ // 社区 ACP 适配器 pi-acp 不发 request_permission(pi 没有审批通道),会让工具不经人直接执行,不收录
144
+ candidates: [
145
+ {
146
+ kind: "pi-rpc",
147
+ program: "pi",
148
+ args: ["--mode", "rpc"],
149
+ verified: ">=1.1.0 <2.0.0",
150
+ usage: "usd",
151
+ },
152
+ ],
153
+ },
154
+ {
155
+ agentId: "cursor",
156
+ label: "Cursor CLI",
157
+ // 未实测(本机未装):按官方文档 `cursor-agent acp`,模式 agent / plan / ask;`cursor/ask_question`
158
+ // 等阻塞式扩展方法 ama 不实现(回 method not found,不代答)
159
+ candidates: [{ kind: "acp", program: "cursor-agent", args: ["acp"], modes: CURSOR_MODES }],
160
+ },
82
161
  {
83
162
  agentId: "goose",
84
163
  label: "Goose",
@@ -142,6 +221,17 @@ export function candidateCapabilities(c) {
142
221
  usage: "tokens",
143
222
  images: false,
144
223
  };
224
+ case "pi-rpc":
225
+ // 审批经 ama 加载的审批闸扩展交给人(native/pi-gate.ts)
226
+ return {
227
+ resume: "resume",
228
+ list: false,
229
+ permissions: "interactive",
230
+ steer: true,
231
+ modes: ALL_MODES,
232
+ usage: "usd",
233
+ images: true,
234
+ };
145
235
  case "oneshot":
146
236
  return {
147
237
  resume: c.oneshot === "gemini" ? "none" : "resume",
@@ -39,7 +39,15 @@ export interface ClaudeResult {
39
39
  tool_name?: string;
40
40
  }[];
41
41
  errors?: string[];
42
+ /** 进程内累计,按模型分;`contextWindow` 是该模型的上下文窗口(2.1.x 起)。 */
43
+ modelUsage?: Record<string, {
44
+ contextWindow?: number;
45
+ }>;
42
46
  }
47
+ /** 一条主线 assistant 消息的用量 = 这次请求后的上下文占用(输入 + 缓存读写 + 输出)。 */
48
+ export declare function claudeContextTokens(usage: unknown): number | undefined;
49
+ /** result.modelUsage 里某模型(缺省取最大)的上下文窗口。 */
50
+ export declare function claudeContextWindow(result: ClaudeResult, model: string | undefined): number | undefined;
43
51
  /** result.subtype → 回合结束原因;`undefined` 表示执行出错(调用方按错误处理)。 */
44
52
  export declare function claudeStopReason(result: ClaudeResult, interrupted: boolean): "end_turn" | "max_turn_requests" | "cancelled" | undefined;
45
53
  /** 只保留会话范围的权限建议(`localSettings` 等会改用户 / 项目配置文件,不能替人写)。 */
@@ -81,6 +81,33 @@ export function claudePermissionMode(mode, manualName) {
81
81
  }
82
82
  /** 只能由人回答的提问类工具(ama 不代答)。 */
83
83
  export const CLAUDE_QUESTION_TOOLS = new Set(["AskUserQuestion"]);
84
+ /** 一条主线 assistant 消息的用量 = 这次请求后的上下文占用(输入 + 缓存读写 + 输出)。 */
85
+ export function claudeContextTokens(usage) {
86
+ if (typeof usage !== "object" || usage === null)
87
+ return undefined;
88
+ const u = usage;
89
+ let total = 0;
90
+ for (const key of [
91
+ "input_tokens",
92
+ "cache_read_input_tokens",
93
+ "cache_creation_input_tokens",
94
+ "output_tokens",
95
+ ])
96
+ if (typeof u[key] === "number")
97
+ total += u[key];
98
+ return total > 0 ? total : undefined;
99
+ }
100
+ /** result.modelUsage 里某模型(缺省取最大)的上下文窗口。 */
101
+ export function claudeContextWindow(result, model) {
102
+ const entries = result.modelUsage ?? {};
103
+ const hit = model !== undefined ? entries[model]?.contextWindow : undefined;
104
+ if (typeof hit === "number" && hit > 0)
105
+ return hit;
106
+ const windows = Object.values(entries)
107
+ .map((e) => e.contextWindow)
108
+ .filter((w) => typeof w === "number" && w > 0);
109
+ return windows.length > 0 ? Math.max(...windows) : undefined;
110
+ }
84
111
  /** result.subtype → 回合结束原因;`undefined` 表示执行出错(调用方按错误处理)。 */
85
112
  export function claudeStopReason(result, interrupted) {
86
113
  if (interrupted)
@@ -20,7 +20,7 @@ import { createLineReader } from "../../modes/rpc/jsonl.js";
20
20
  import { armCancelWatchdog, probeCandidate, spawnOf } from "../base.js";
21
21
  import { stderrHint } from "../process.js";
22
22
  import { TurnCollector, oneLine } from "../turn.js";
23
- import { CLAUDE_QUESTION_TOOLS, claudePermissionMode, claudeStopReason, claudeTodos, claudeToolKind, claudeToolLocations, claudeToolTitle, sessionScopedSuggestions, } from "./claude-normalize.js";
23
+ import { CLAUDE_QUESTION_TOOLS, claudeContextTokens, claudeContextWindow, claudePermissionMode, claudeStopReason, claudeTodos, claudeToolKind, claudeToolLocations, claudeToolTitle, sessionScopedSuggestions, } from "./claude-normalize.js";
24
24
  import { msg as messages } from "../../i18n/index.js";
25
25
  export function claudeArgs(options, sessionId, manualName = "manual") {
26
26
  const args = [
@@ -116,6 +116,8 @@ class ClaudeStreamSession {
116
116
  interrupted = false;
117
117
  sawPartialText = false;
118
118
  lastTotalCost = 0;
119
+ /** 最近一条主线 assistant 消息的上下文占用与模型(result 里查窗口用)。 */
120
+ context = {};
119
121
  closed = false;
120
122
  exited = false;
121
123
  constructor(agentId, id, transport, deps) {
@@ -218,6 +220,13 @@ class ClaudeStreamSession {
218
220
  ...(u.cache_read_input_tokens !== undefined ? { cacheRead: u.cache_read_input_tokens } : {}),
219
221
  ...(cost !== undefined ? { costUsd: cost } : {}),
220
222
  });
223
+ const window = claudeContextWindow(result, this.context.model);
224
+ if (this.context.tokens !== undefined || window !== undefined)
225
+ turn.push({
226
+ type: "usage",
227
+ ...(this.context.tokens !== undefined ? { contextTokens: this.context.tokens } : {}),
228
+ ...(window !== undefined ? { contextWindow: window } : {}),
229
+ });
221
230
  if (typeof result.result === "string" && result.result !== "")
222
231
  turn.setFinalText(result.result);
223
232
  const denials = result.permission_denials?.length ?? 0;
@@ -337,6 +346,12 @@ class ClaudeStreamSession {
337
346
  return;
338
347
  // 子 Agent(Task)内部的消息不进最终文本
339
348
  const nested = typeof parent === "string" && parent !== "";
349
+ const tokens = nested ? undefined : claudeContextTokens(message["usage"]);
350
+ if (tokens !== undefined)
351
+ this.context = {
352
+ tokens,
353
+ ...(typeof message["model"] === "string" ? { model: message["model"] } : {}),
354
+ };
340
355
  for (const block of message["content"]) {
341
356
  if (block["type"] === "text" &&
342
357
  !nested &&