@armadra/agent 0.7.4 → 0.7.6

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 (81) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CHANGELOG.zh-CN.md +32 -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/anthropic-request.js +6 -1
  16. package/dist/ai/apis/cache-params.js +2 -1
  17. package/dist/ai/apis/google-request.js +2 -1
  18. package/dist/ai/apis/max-tokens.d.ts +4 -0
  19. package/dist/ai/apis/max-tokens.js +12 -1
  20. package/dist/ai/apis/openai-request.js +3 -1
  21. package/dist/ai/apis/openai-responses-request.js +2 -1
  22. package/dist/ai/json-body.d.ts +33 -1
  23. package/dist/ai/json-body.js +96 -18
  24. package/dist/ai/providers/enrich.d.ts +6 -2
  25. package/dist/ai/providers/enrich.js +13 -2
  26. package/dist/ai/providers/max-tokens-cache.d.ts +22 -0
  27. package/dist/ai/providers/max-tokens-cache.js +95 -0
  28. package/dist/ai/providers/registry.js +3 -3
  29. package/dist/bundle/ama.cjs +982 -442
  30. package/dist/checkpoints/shadow-git.d.ts +5 -1
  31. package/dist/checkpoints/shadow-git.js +11 -2
  32. package/dist/cli/bootstrap.js +13 -5
  33. package/dist/cli/compose-providers.d.ts +1 -0
  34. package/dist/cli/compose-providers.js +3 -0
  35. package/dist/cli/compose-store.d.ts +6 -2
  36. package/dist/cli/compose-store.js +8 -4
  37. package/dist/cli/compose.js +2 -2
  38. package/dist/cli/deps.d.ts +1 -1
  39. package/dist/config/json-schema.js +1 -0
  40. package/dist/config/key-docs.js +1 -0
  41. package/dist/config/schema-w5.js +2 -0
  42. package/dist/config/settings-registry.js +1 -0
  43. package/dist/config/types-w5.d.ts +5 -0
  44. package/dist/i18n/catalog.d.ts +2 -0
  45. package/dist/i18n/messages/config-keys.d.ts +2 -0
  46. package/dist/i18n/messages/config-keys.js +2 -0
  47. package/dist/i18n/messages/config.d.ts +2 -0
  48. package/dist/i18n/messages/interactive.d.ts +2 -0
  49. package/dist/i18n/messages/interactive.js +2 -0
  50. package/dist/i18n/messages/session.js +2 -2
  51. package/dist/modes/acp/acp-server.js +17 -15
  52. package/dist/modes/acp/acp-sessions.d.ts +14 -1
  53. package/dist/modes/acp/acp-sessions.js +44 -1
  54. package/dist/modes/interactive/agent-ui.js +7 -0
  55. package/dist/session/manager.d.ts +18 -0
  56. package/dist/session/manager.js +75 -14
  57. package/dist/session/offload.d.ts +59 -0
  58. package/dist/session/offload.js +218 -0
  59. package/dist/session/store.d.ts +12 -4
  60. package/dist/session/store.js +18 -8
  61. package/dist/tools/background-jobs.d.ts +4 -4
  62. package/dist/tools/background-jobs.js +4 -4
  63. package/dist/tools/bash.d.ts +2 -1
  64. package/dist/tools/bash.js +4 -3
  65. package/dist/tools/read-lines.d.ts +39 -0
  66. package/dist/tools/read-lines.js +163 -86
  67. package/dist/tools/read.js +3 -3
  68. package/dist/tools/task-ctl.js +2 -1
  69. package/dist/tools/task.d.ts +9 -0
  70. package/dist/tools/task.js +27 -1
  71. package/docs/acp.md +6 -3
  72. package/docs/agents.md +14 -3
  73. package/docs/en/acp.md +2 -2
  74. package/docs/en/permissions.md +1 -1
  75. package/docs/en/providers.md +4 -4
  76. package/docs/en/sessions.md +2 -1
  77. package/docs/permissions.md +1 -1
  78. package/docs/providers.md +9 -5
  79. package/docs/session-format.md +11 -0
  80. package/docs/sessions.md +2 -1
  81. 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: {
@@ -76,7 +76,8 @@ export async function bootstrap(argsIn, deps, io) {
76
76
  request = { kind: "resume", id };
77
77
  }
78
78
  const sessionRequest = request;
79
- const sessionManager = await step(ExitCode.Session, m().steps.session, () => deps.sessions.open(sessionRequest, { sessionDir: paths.sessionDir, cwd: paths.cwd }));
79
+ // [#165] 会话替换后跟随当前会话(闭包只经它取,启动会话的 manager 不被常驻)
80
+ let sessionManager = await step(ExitCode.Session, m().steps.session, () => deps.sessions.open(sessionRequest, { sessionDir: paths.sessionDir, cwd: paths.cwd }));
80
81
  let sessionCwd = sessionManager.cwd;
81
82
  if (!existsSync(sessionCwd)) {
82
83
  const replacement = interactive ? await deps.ui?.askCwd?.(sessionCwd) : undefined;
@@ -281,6 +282,8 @@ export async function bootstrap(argsIn, deps, io) {
281
282
  uiBroker: () => uiBroker,
282
283
  onSessionReplaced: (next) => {
283
284
  session = next;
285
+ sessionManager = next.manager ?? sessionManager;
286
+ assembly.sessionManager = sessionManager;
284
287
  },
285
288
  sessionStartContext: () => sessionStartContext,
286
289
  unattended,
@@ -302,7 +305,7 @@ export async function bootstrap(argsIn, deps, io) {
302
305
  if (Object.keys(overrides).length > 0)
303
306
  assembly.overrides = overrides;
304
307
  session = await step(ExitCode.RuntimeError, m().steps.assembly, () => deps.session.create(assembly));
305
- const active = session;
308
+ const active = session; // 只同步使用:闭包捕获它会让启动会话常驻(#165)
306
309
  let disposed;
307
310
  shutdown = (reason) => {
308
311
  disposed ??= (async () => {
@@ -310,7 +313,7 @@ export async function bootstrap(argsIn, deps, io) {
310
313
  await hooks.run("SessionEnd", { reason }).catch(() => undefined);
311
314
  await disposeHost(host, (e) => warn(m().hostDisposeFailed(String(e))));
312
315
  // 会话被替换过时 dispose 当前那个(旧会话由替换方负责)
313
- await (session ?? active).dispose();
316
+ await session?.dispose();
314
317
  })();
315
318
  return disposed;
316
319
  };
@@ -339,8 +342,13 @@ export async function bootstrap(argsIn, deps, io) {
339
342
  providers,
340
343
  model,
341
344
  thinkingLevel,
342
- sessionManager,
343
- session: active,
345
+ // [#165] 跟随当前会话(ACP 关掉启动会话后它可被回收)
346
+ get sessionManager() {
347
+ return sessionManager;
348
+ },
349
+ get session() {
350
+ return session;
351
+ },
344
352
  hooks,
345
353
  host,
346
354
  permission,
@@ -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({
@@ -107,7 +107,7 @@ export interface SessionAssembly {
107
107
  /**
108
108
  * 模式层把当前会话换成另一个(`/new`、`/resume`、`/fork`、RPC `switch_session`)后调用:
109
109
  * bootstrap 让 HostApi.session.*、宿主 sendUser、Hook 公共字段与退出时的 dispose 跟随 `next`。
110
- * 旧会话由调用方自行 dispose;`Runtime.session` 仍指初始会话。
110
+ * 旧会话由调用方自行 dispose;`Runtime.session` / `sessionManager` 也随之指向 `next`(#165)。
111
111
  */
112
112
  onSessionReplaced(next: AgentSession): void;
113
113
  /** SessionStart Hook 的 additionalContext(系统提示 hooks 节);首次装配系统提示时读取。 */
@@ -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;
@@ -534,6 +534,7 @@ declare const en: {
534
534
  backgroundStopped: (jobId: string, command: string) => string;
535
535
  compactionFailed: (error: string) => string;
536
536
  compactionCancelled: string;
537
+ imageReadBackFailed: (id: string) => string;
537
538
  };
538
539
  statusLine: {
539
540
  usage: string;
@@ -1446,6 +1447,7 @@ declare const en: {
1446
1447
  "subagents.maxPending": string;
1447
1448
  "subagents.retainSessions": string;
1448
1449
  "subagents.defaultModel": string;
1450
+ "subagents.forkMaxContextRatio": string;
1449
1451
  "subagents.background": string;
1450
1452
  "subagents.autoBackgroundAfterMs": string;
1451
1453
  models: string;
@@ -115,6 +115,7 @@ export declare const en: {
115
115
  "subagents.maxPending": string;
116
116
  "subagents.retainSessions": string;
117
117
  "subagents.defaultModel": string;
118
+ "subagents.forkMaxContextRatio": string;
118
119
  "subagents.background": string;
119
120
  "subagents.autoBackgroundAfterMs": string;
120
121
  models: string;
@@ -269,6 +270,7 @@ export declare const zh: {
269
270
  "subagents.maxPending": string;
270
271
  "subagents.retainSessions": string;
271
272
  "subagents.defaultModel": string;
273
+ "subagents.forkMaxContextRatio": string;
272
274
  "subagents.background": string;
273
275
  "subagents.autoBackgroundAfterMs": string;
274
276
  models: string;
@@ -164,6 +164,7 @@ const keysEn = {
164
164
  "subagents.maxPending": "Queue limit; task fails beyond it",
165
165
  "subagents.retainSessions": "Finished sub-sessions kept in memory (least recently used first out); others reopen from their session file when continued",
166
166
  "subagents.defaultModel": "Default sub-session model provider/model[@channel]",
167
+ "subagents.forkMaxContextRatio": "Fork sub-agents fall back to fresh when the parent's last request used more than this share of (window − compaction.reserveTokens); 0.05–0.95, default 0.5",
167
168
  "subagents.background": "Whether task runs in the background by default: auto = background in the TUI / RPC / ACP and foreground with -p; always / never are fixed. The call argument and the agent type's background: override it; user, project and host level",
168
169
  "subagents.autoBackgroundAfterMs": "Move a foreground task to the background after it has run this many milliseconds; 0 disables; user and project level",
169
170
  models: "Model aliases and the model picker list; user level only (effective from wave 5 W5-G)",
@@ -292,6 +293,7 @@ const keysZh = {
292
293
  "subagents.maxPending": "排队上限,超出时 task 报错",
293
294
  "subagents.retainSessions": "内存中保留的已结束子会话数(最久未用的先释放),其余续聊时从会话文件重开",
294
295
  "subagents.defaultModel": "子会话缺省模型 provider/model[@channel]",
296
+ "subagents.forkMaxContextRatio": "fork 子 Agent 的回落比例:父上一次请求超过(窗口 − compaction.reserveTokens)的这一比例时改为 fresh;0.05–0.95,缺省 0.5",
295
297
  "subagents.background": "task 缺省是否后台:auto = TUI / RPC / ACP 下后台、-p 下前台;always / never 固定。调用参数与类型定义的 background: 优先;用户、项目、宿主级都认",
296
298
  "subagents.autoBackgroundAfterMs": "前台任务运行超过该毫秒数自动转后台;0 关闭;用户、项目级都认",
297
299
  models: "模型别名与模型选择器清单;只认用户级(第五波 W5-G 起生效)",
@@ -115,6 +115,7 @@ export declare const en: {
115
115
  "subagents.maxPending": string;
116
116
  "subagents.retainSessions": string;
117
117
  "subagents.defaultModel": string;
118
+ "subagents.forkMaxContextRatio": string;
118
119
  "subagents.background": string;
119
120
  "subagents.autoBackgroundAfterMs": string;
120
121
  models: string;
@@ -383,6 +384,7 @@ export declare const zh: {
383
384
  "subagents.maxPending": string;
384
385
  "subagents.retainSessions": string;
385
386
  "subagents.defaultModel": string;
387
+ "subagents.forkMaxContextRatio": string;
386
388
  "subagents.background": string;
387
389
  "subagents.autoBackgroundAfterMs": string;
388
390
  models: string;
@@ -85,6 +85,7 @@ export declare const en: {
85
85
  backgroundStopped: (jobId: string, command: string) => string;
86
86
  compactionFailed: (error: string) => string;
87
87
  compactionCancelled: string;
88
+ imageReadBackFailed: (id: string) => string;
88
89
  };
89
90
  statusLine: {
90
91
  usage: string;
@@ -335,6 +336,7 @@ export declare const zh: {
335
336
  backgroundStopped: (jobId: string, command: string) => string;
336
337
  compactionFailed: (error: string) => string;
337
338
  compactionCancelled: string;
339
+ imageReadBackFailed: (id: string) => string;
338
340
  };
339
341
  statusLine: {
340
342
  usage: string;
@@ -99,6 +99,7 @@ export const en = {
99
99
  backgroundStopped: (jobId, command) => `Background command ${jobId} stopped: ${command}`,
100
100
  compactionFailed: (error) => `Compaction failed: ${error}`,
101
101
  compactionCancelled: "Compaction cancelled",
102
+ imageReadBackFailed: (id) => `Session image for entry ${id} could not be read back (file changed); it stays empty`,
102
103
  },
103
104
  statusLine: {
104
105
  usage: "Usage: /statusline [full|compact]",
@@ -234,6 +235,7 @@ export const zh = {
234
235
  backgroundStopped: (jobId, command) => `后台命令 ${jobId} 已停止:${command}`,
235
236
  compactionFailed: (error) => `压缩失败:${error}`,
236
237
  compactionCancelled: "压缩已取消",
238
+ imageReadBackFailed: (id) => `会话文件已改变,条目 ${id} 的图片读不回,保留为空`,
237
239
  },
238
240
  statusLine: {
239
241
  usage: "用法:/statusline [full|compact]",
@@ -88,7 +88,7 @@ export const en = {
88
88
  cwdIsHome: "the working directory is the home directory",
89
89
  gitNotFound: "git not found",
90
90
  tooManyFiles: (files, max) => `the working directory has ${plural(files, "file")} (limit ${max})`,
91
- tooSlow: (seconds, max) => `the snapshot took ${seconds} s (limit ${max} s)`,
91
+ tooSlow: (seconds, max) => `snapshots took longer than ${max} s twice in a row (latest ${seconds} s)`,
92
92
  gitFailed: (command, code, detail) => `git ${command} failed (${code})${detail === "" ? "" : `: ${detail}`}`,
93
93
  noShadowCommit: "the checkpoint has no shadow commit",
94
94
  shadowCommitMissing: (commit) => `shadow commit ${commit} does not exist`,
@@ -211,7 +211,7 @@ export const zh = {
211
211
  cwdIsHome: "工作目录是家目录",
212
212
  gitNotFound: "找不到 git",
213
213
  tooManyFiles: (files, max) => `工作目录有 ${files} 个文件(上限 ${max})`,
214
- tooSlow: (seconds, max) => `快照用了 ${seconds} 秒(上限 ${max} 秒)`,
214
+ tooSlow: (seconds, max) => `连续两次快照超过 ${max} 秒(最近一次 ${seconds} 秒)`,
215
215
  gitFailed: (command, code, detail) => `git ${command} 失败(${code})${detail === "" ? "" : `:${detail}`}`,
216
216
  noShadowCommit: "检查点没有影子提交",
217
217
  shadowCommitMissing: (commit) => `影子提交 ${commit} 不存在`,
@@ -19,9 +19,10 @@
19
19
  import { realpathSync } from "node:fs";
20
20
  import { resolve } from "node:path";
21
21
  import { stopReasonOf } from "../../ai/apis/shared.js";
22
- import { createSessionAlongside, currentSession, disposeSessionAlongside, setForegroundSession, } from "../../cli/compose-session.js";
22
+ import { createSessionAlongside, currentSession, setForegroundSession, } from "../../cli/compose-session.js";
23
23
  import { listSessions } from "../../cli/compose-store.js";
24
24
  import { AgentSessionImpl } from "../../agent/session.js";
25
+ import { taskControl } from "../../agents/task-control.js";
25
26
  import { RpcError } from "../../drivers/jsonrpc.js";
26
27
  import { ACP_METHODS, ACP_PROTOCOL_VERSION, RPC_ERRORS, } from "../../drivers/acp/types.js";
27
28
  import { isPermissionMode } from "../../permissions/modes.js";
@@ -31,7 +32,7 @@ import { AcpEventMapper, permissionModes, toolKind, toolLocations, toolTitle, }
31
32
  import { msg } from "../../i18n/index.js";
32
33
  import { CONFIG_IDS, applyConfigOption, availableCommands, buildConfigOptions, prepareConfigOptions, } from "./acp-config.js";
33
34
  import { clientCapabilitiesOf } from "./acp-connection.js";
34
- import { PromptQueue, cancelledResult, pageSessions, promptWhenIdle, sessionTitle, } from "./acp-sessions.js";
35
+ import { PromptQueue, cancelledResult, disposeQuiet, pageSessions, promptWhenIdle, quiesce, sessionTitle, } from "./acp-sessions.js";
35
36
  export const ACP_AGENT_CAPABILITIES = {
36
37
  loadSession: true,
37
38
  promptCapabilities: { image: true, audio: false, embeddedContext: true },
@@ -157,8 +158,9 @@ export class AcpServer {
157
158
  .abort()
158
159
  .catch(() => undefined);
159
160
  await this.queue.settled();
160
- for (const session of this.sessions())
161
- await session.waitForIdle().catch(() => undefined);
161
+ for (const s of this.sessions())
162
+ if (s instanceof AgentSessionImpl)
163
+ await quiesce(s, taskControl(s.state.sessionId));
162
164
  const foreground = this.session();
163
165
  for (const entry of this.pool.values())
164
166
  entry.unsubscribe();
@@ -167,7 +169,7 @@ export class AcpServer {
167
169
  continue;
168
170
  if (!(foreground instanceof AgentSessionImpl))
169
171
  continue;
170
- await disposeSessionAlongside(this.runtime, session, foreground).catch(() => undefined);
172
+ await disposeQuiet(this.runtime, session, foreground).catch(() => undefined);
171
173
  }
172
174
  this.pool.clear();
173
175
  this.standby = undefined;
@@ -311,12 +313,11 @@ export class AcpServer {
311
313
  return {};
312
314
  this.pool.delete(id);
313
315
  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);
319
- }
316
+ // [#167] 通知回合不经队列:不论谁在跑都先安静下来(停后台任务、abort 到空闲)
317
+ if (this.queue.running?.sessionId === id)
318
+ this.queue.running.cancelRequested = true;
319
+ await quiesce(entry.session, taskControl(id));
320
+ await this.queue.settled(id);
320
321
  entry.unsubscribe();
321
322
  let fallback = this.session();
322
323
  if (fallback === entry.session) {
@@ -330,7 +331,7 @@ export class AcpServer {
330
331
  }
331
332
  }
332
333
  if (fallback instanceof AgentSessionImpl)
333
- await disposeSessionAlongside(this.runtime, entry.session, fallback);
334
+ await disposeQuiet(this.runtime, entry.session, fallback, taskControl(id));
334
335
  return {};
335
336
  }
336
337
  async prompt(params, signal) {
@@ -377,16 +378,17 @@ export class AcpServer {
377
378
  setForegroundSession(this.runtime, session);
378
379
  if (this.runtime.permission.mode !== entry.mode)
379
380
  session.setPermissionMode(entry.mode);
380
- const before = session.getStats().tokens;
381
+ let before = session.getStats().tokens;
381
382
  let failure;
382
383
  try {
383
- await promptWhenIdle(session, job);
384
+ // [#166] 起点取在真正发起时:等待期间结束的通知回合不计入本次 usage
385
+ await promptWhenIdle(session, job, () => void (before = session.getStats().tokens));
384
386
  }
385
387
  catch (error) {
386
388
  failure = error;
387
389
  }
388
- await this.peer.flush();
389
390
  const after = session.getStats().tokens;
391
+ await this.peer.flush();
390
392
  const usage = {
391
393
  inputTokens: after.input - before.input,
392
394
  outputTokens: after.output - before.output,
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import type { ImageBlock } from "../../ai/types.js";
10
10
  import type { AgentSessionImpl } from "../../agent/session.js";
11
+ import type { TaskControl } from "../../agents/task-control.js";
12
+ import type { Runtime } from "../../cli/runtime.js";
11
13
  import type { AgentMessage, SessionListItem } from "../../session/types.js";
12
14
  import { type AcpPromptResult, type AcpListSessionsResult } from "../../drivers/acp/types.js";
13
15
  import type { PermissionMode } from "../../permissions/types.js";
@@ -86,4 +88,15 @@ export declare function pageSessions(items: readonly SessionListItem[], cursor:
86
88
  * 不用 `streamingBehavior: "followUp"` 入队:`abort()` 不清队列,等待期间的 `session/cancel`
87
89
  * 会让这条提示在之后的周期里冒出来。`job.cancelRequested`(cancel / close / 撤回)→ 不再发,直接返回。
88
90
  */
89
- export declare function promptWhenIdle(session: AgentSessionImpl, job: PromptJob): Promise<void>;
91
+ export declare function promptWhenIdle(session: AgentSessionImpl, job: PromptJob, onStart?: () => void): Promise<void>;
92
+ /**
93
+ * 关会话前把它安静下来(Issue #167):先停在跑的后台任务(被停止的任务不发完成通知),再反复 abort,
94
+ * 直到隔一个宏任务仍然空闲——已完成任务排在投递链上的通知会在上一周期结算后的微任务里开新周期,
95
+ * 一次 abort 不够。超过 `rounds` 轮仍不空闲返回 false。被打断的通知回合与用户 cancel 同样留下中断标记。
96
+ */
97
+ export declare function quiesce(session: AgentSessionImpl, control: TaskControl | undefined, rounds?: number): Promise<boolean>;
98
+ /**
99
+ * `disposeSessionAlongside` 的安静版:撞上 busy(SessionEnd Hook 期间又开了通知回合)→ 再 quiesce 重试;
100
+ * dispose 之后若还有周期(与 dispose 同一时刻开出来的)再 abort 一次。
101
+ */
102
+ export declare function disposeQuiet(runtime: Runtime, session: AgentSessionImpl, fallback: AgentSessionImpl, control?: TaskControl, retries?: number): Promise<void>;
@@ -6,6 +6,7 @@
6
6
  * - 同一时刻只跑一个回合,其它会话的 `session/prompt` 进 {@link PromptQueue}(FIFO),不再回 busy;
7
7
  * - `session/list`:按 `updatedAt` 降序、`sessionId` 次序分页,cursor 是 `updatedAt|sessionId` 的 base64。
8
8
  */
9
+ import { disposeSessionAlongside } from "../../cli/compose-session.js";
9
10
  import { RPC_ERRORS, } from "../../drivers/acp/types.js";
10
11
  import { RpcError } from "../../drivers/jsonrpc.js";
11
12
  import { AmaError } from "../../errors.js";
@@ -164,12 +165,14 @@ export function pageSessions(items, cursor, pageSize = LIST_PAGE_SIZE) {
164
165
  * 不用 `streamingBehavior: "followUp"` 入队:`abort()` 不清队列,等待期间的 `session/cancel`
165
166
  * 会让这条提示在之后的周期里冒出来。`job.cancelRequested`(cancel / close / 撤回)→ 不再发,直接返回。
166
167
  */
167
- export async function promptWhenIdle(session, job) {
168
+ export async function promptWhenIdle(session, job, onStart) {
168
169
  for (;;) {
169
170
  // 等周期而不是看 `state.isStreaming`:busy 以周期为准,周期的收尾阶段 isStreaming 已是 false
170
171
  await session.waitForIdle();
171
172
  if (job.cancelRequested)
172
173
  return;
174
+ // [#166] 每次真正发起前回调(busy 重试时再调,最后一次为准):usage 的起点不含之前的通知回合
175
+ onStart?.();
173
176
  try {
174
177
  await session.prompt(job.text, job.images.length > 0 ? { images: job.images } : {});
175
178
  return;
@@ -180,3 +183,43 @@ export async function promptWhenIdle(session, job) {
180
183
  }
181
184
  }
182
185
  }
186
+ const BUSY = Symbol("busy");
187
+ /** 当前没有周期(含收尾阶段):`waitForIdle()` 已结算即空闲。 */
188
+ async function idleNow(session) {
189
+ return (await Promise.race([session.waitForIdle(), Promise.resolve(BUSY)])) !== BUSY;
190
+ }
191
+ /**
192
+ * 关会话前把它安静下来(Issue #167):先停在跑的后台任务(被停止的任务不发完成通知),再反复 abort,
193
+ * 直到隔一个宏任务仍然空闲——已完成任务排在投递链上的通知会在上一周期结算后的微任务里开新周期,
194
+ * 一次 abort 不够。超过 `rounds` 轮仍不空闲返回 false。被打断的通知回合与用户 cancel 同样留下中断标记。
195
+ */
196
+ export async function quiesce(session, control, rounds = 50) {
197
+ for (const task of control?.list() ?? [])
198
+ if (task.status === "running")
199
+ await control?.stop(task.taskId).catch(() => undefined);
200
+ for (let i = 0; i < rounds; i++) {
201
+ await session.abort();
202
+ await new Promise((resolve) => setImmediate(resolve));
203
+ if (await idleNow(session))
204
+ return true;
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * `disposeSessionAlongside` 的安静版:撞上 busy(SessionEnd Hook 期间又开了通知回合)→ 再 quiesce 重试;
210
+ * dispose 之后若还有周期(与 dispose 同一时刻开出来的)再 abort 一次。
211
+ */
212
+ export async function disposeQuiet(runtime, session, fallback, control, retries = 3) {
213
+ for (let i = 0;; i++) {
214
+ try {
215
+ await disposeSessionAlongside(runtime, session, fallback);
216
+ break;
217
+ }
218
+ catch (error) {
219
+ if (i >= retries || !(error instanceof AmaError && error.code === "busy"))
220
+ throw error;
221
+ await quiesce(session, control);
222
+ }
223
+ }
224
+ await session.abort();
225
+ }
@@ -45,6 +45,8 @@ export function agentCommandHooks(get) {
45
45
  };
46
46
  }
47
47
  const TASK_NOTICE = /^\[task (t\d+)\] ([\s\S]*)$/;
48
+ /** #183:会话图片读不回(`ImageOffload` 的告警)转通知区。 */
49
+ const SESSION_NOTICE = /^cannot read session entry (\S+) back from /;
48
50
  export class AgentUi {
49
51
  deps;
50
52
  plan;
@@ -164,6 +166,11 @@ export class AgentUi {
164
166
  this.deps.notice(level === "warn" ? "warn" : "info", `[${agent} · ${taskId}] ${match[2]}`);
165
167
  return;
166
168
  }
169
+ const image = level === "warn" ? SESSION_NOTICE.exec(message) : null;
170
+ if (image !== null) {
171
+ this.deps.notice("warn", msg().interactive.events.imageReadBackFailed(image[1]));
172
+ return;
173
+ }
167
174
  original?.call(holder, level, message);
168
175
  };
169
176
  holder.log = routed;