@armadra/agent 0.7.0 → 0.7.2

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 (103) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/CHANGELOG.zh-CN.md +41 -0
  3. package/dist/agent/session-cache.d.ts +4 -2
  4. package/dist/agent/session-cache.js +5 -3
  5. package/dist/agent/session-compaction.d.ts +13 -1
  6. package/dist/agent/session-compaction.js +36 -0
  7. package/dist/agent/session-core.d.ts +2 -0
  8. package/dist/agent/session-state.d.ts +9 -3
  9. package/dist/agent/session-state.js +8 -6
  10. package/dist/agent/session.js +3 -3
  11. package/dist/agent/types-w5.d.ts +3 -0
  12. package/dist/agent/types.d.ts +18 -0
  13. package/dist/agents/task-record.d.ts +4 -0
  14. package/dist/agents/task-record.js +18 -0
  15. package/dist/ai/context.d.ts +7 -2
  16. package/dist/ai/context.js +45 -7
  17. package/dist/ai/providers/catalog-data.js +1 -1
  18. package/dist/auth/oauth/token-store.d.ts +2 -0
  19. package/dist/auth/oauth/token-store.js +7 -2
  20. package/dist/bundle/ama.cjs +1684 -490
  21. package/dist/checkpoints/shadow-git.js +2 -1
  22. package/dist/compaction/breakdown.d.ts +51 -0
  23. package/dist/compaction/breakdown.js +159 -0
  24. package/dist/compaction/estimate.d.ts +6 -0
  25. package/dist/compaction/estimate.js +1 -1
  26. package/dist/compaction/prefix-estimate.d.ts +23 -0
  27. package/dist/compaction/prefix-estimate.js +34 -0
  28. package/dist/config/auth-file.js +10 -2
  29. package/dist/config/fs-retry.d.ts +18 -0
  30. package/dist/config/fs-retry.js +33 -0
  31. package/dist/config/json-schema.js +1 -0
  32. package/dist/config/key-docs.js +1 -0
  33. package/dist/config/schema.js +2 -0
  34. package/dist/config/settings-registry.js +1 -0
  35. package/dist/config/types.d.ts +2 -0
  36. package/dist/drivers/native/codex-app-server.js +6 -0
  37. package/dist/drivers/runner.js +17 -0
  38. package/dist/drivers/store.d.ts +3 -0
  39. package/dist/drivers/store.js +4 -0
  40. package/dist/i18n/catalog.d.ts +53 -0
  41. package/dist/i18n/messages/agents.d.ts +3 -0
  42. package/dist/i18n/messages/agents.js +3 -0
  43. package/dist/i18n/messages/config-keys.d.ts +2 -0
  44. package/dist/i18n/messages/config-keys.js +2 -0
  45. package/dist/i18n/messages/config.d.ts +2 -0
  46. package/dist/i18n/messages/interactive.d.ts +6 -0
  47. package/dist/i18n/messages/interactive.js +6 -0
  48. package/dist/i18n/messages/panels.d.ts +3 -0
  49. package/dist/i18n/messages/panels.js +5 -2
  50. package/dist/i18n/messages/report-context.d.ts +94 -0
  51. package/dist/i18n/messages/report-context.js +95 -0
  52. package/dist/i18n/messages/report.d.ts +96 -0
  53. package/dist/i18n/messages/report.js +13 -2
  54. package/dist/i18n/messages/settings.d.ts +2 -0
  55. package/dist/i18n/messages/settings.js +2 -0
  56. package/dist/i18n/messages/subcommands.js +2 -2
  57. package/dist/modes/acp/acp-events.d.ts +17 -4
  58. package/dist/modes/acp/acp-events.js +32 -6
  59. package/dist/modes/commands-core.js +4 -0
  60. package/dist/modes/context-report.d.ts +35 -0
  61. package/dist/modes/context-report.js +172 -0
  62. package/dist/modes/interactive/agent-bar.d.ts +2 -0
  63. package/dist/modes/interactive/agent-bar.js +6 -0
  64. package/dist/modes/interactive/commands.d.ts +2 -1
  65. package/dist/modes/interactive/commands.js +10 -1
  66. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  67. package/dist/modes/interactive/interactive-mode.js +6 -1
  68. package/dist/modes/interactive/panels.d.ts +2 -1
  69. package/dist/modes/interactive/panels.js +13 -5
  70. package/dist/modes/interactive/program-status-ui.d.ts +72 -0
  71. package/dist/modes/interactive/program-status-ui.js +229 -0
  72. package/dist/modes/interactive/session-events.d.ts +4 -0
  73. package/dist/modes/interactive/session-events.js +24 -2
  74. package/dist/modes/interactive/status-bar.d.ts +21 -3
  75. package/dist/modes/interactive/status-bar.js +64 -25
  76. package/dist/modes/interactive/status-ctx.d.ts +55 -0
  77. package/dist/modes/interactive/status-ctx.js +125 -0
  78. package/dist/modes/interactive/status-line.d.ts +2 -1
  79. package/dist/modes/interactive/status-line.js +3 -2
  80. package/dist/modes/interactive/tasks-report.js +4 -0
  81. package/dist/modes/print/print-mode.js +12 -0
  82. package/dist/modes/session-report.d.ts +10 -2
  83. package/dist/modes/session-report.js +26 -2
  84. package/dist/sdk.d.ts +1 -1
  85. package/dist/tools/types.d.ts +6 -0
  86. package/dist/tui/components/meter.d.ts +2 -0
  87. package/dist/tui/components/meter.js +1 -1
  88. package/dist/tui/program-status.d.ts +90 -0
  89. package/dist/tui/program-status.js +259 -0
  90. package/docs/acp.md +4 -4
  91. package/docs/agents.md +1 -1
  92. package/docs/en/acp.md +4 -4
  93. package/docs/en/providers.md +5 -3
  94. package/docs/en/rpc.md +3 -0
  95. package/docs/en/sessions.md +24 -1
  96. package/docs/en/tui.md +79 -22
  97. package/docs/providers.md +8 -5
  98. package/docs/rpc.md +1 -0
  99. package/docs/session-format.md +3 -3
  100. package/docs/sessions.md +23 -1
  101. package/docs/tui-design.md +2 -2
  102. package/docs/tui.md +79 -22
  103. package/package.json +1 -1
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 终端程序状态协议 OSC 7501(Program Status Protocol rev 0.3,2026-10-07):
3
+ * https://www.superlogical.com/rex/docs/build/program-status
4
+ *
5
+ * - 编码(纯函数):`ESC ] 7501 ; key=value:… ESC \`。state 必填;id 每段 `[A-Za-z0-9_.+-]{1,32}`、
6
+ * ≤ 8 层、≤ 128 字节;kind 只随 blocked、progress(0–100 整数)只随 working / blocked;app 同 id 段;
7
+ * title / msg 去控制字符、压成一行后按 UTF-8 字节截断(192 / 2048,不切坏字符),再 base64。
8
+ * 字段不合法就丢掉该字段(state / id 不合法整条不发),不发终端会整条丢弃的坏报告;整条 ≤ 4096 字节。
9
+ * - 检测:发 `OSC 7501 ; ?` 紧跟 `CSI c`(DA1);先收到 `?` 回复 = 支持,DA1 回复先到或超时 = 不支持。
10
+ * 两种回复都在输入解析里吞掉(`handleInput`),不会当成按键进输入框;迟到的回复同样吞掉。
11
+ * - tmux:缺省丢弃未知 OSC,`on` 时用 DCS passthrough(`ESC P tmux; … ESC \`,内容里 ESC 翻倍)包裹,
12
+ * 需要 `set -g allow-passthrough on`;tmux 不把外层终端对透传查询的回复转给窗格,所以 tmux 里 `auto`
13
+ * 不检测、等同 `off`。
14
+ * - 发射器:每个 id 记最后一次写出的报告,同值不重写;同一 state 下只换 msg / title 的更新按节流合并。
15
+ */
16
+ export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\";
17
+ export const DA1_QUERY = "\x1b[c";
18
+ export const MAX_SEQUENCE_BYTES = 4096;
19
+ export const MAX_TITLE_BYTES = 192;
20
+ export const MAX_MSG_BYTES = 2048;
21
+ const MAX_ID_BYTES = 128;
22
+ const MAX_ID_DEPTH = 8;
23
+ const SEGMENT = /^[A-Za-z0-9_.+-]{1,32}$/;
24
+ const STATES = ["idle", "working", "done", "blocked", "error", "clear"];
25
+ const KINDS = ["permission", "question", "auth"];
26
+ const ELLIPSIS = "…";
27
+ /** 控制字符(C0、DEL、C1)换成空格,空白压成一个,去首尾。 */
28
+ export function sanitizeLine(text) {
29
+ return text
30
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, " ")
31
+ .replace(/\s+/g, " ")
32
+ .trim();
33
+ }
34
+ /** 按 UTF-8 字节截断到 maxBytes(按码点切,不出半个字符);截断时末尾带 `…`。 */
35
+ export function truncateUtf8(text, maxBytes) {
36
+ if (Buffer.byteLength(text, "utf8") <= maxBytes)
37
+ return text;
38
+ const budget = maxBytes - Buffer.byteLength(ELLIPSIS, "utf8");
39
+ let used = 0;
40
+ let out = "";
41
+ for (const ch of text) {
42
+ const n = Buffer.byteLength(ch, "utf8");
43
+ if (used + n > budget)
44
+ break;
45
+ used += n;
46
+ out += ch;
47
+ }
48
+ return out + ELLIPSIS;
49
+ }
50
+ /** id 是否合法(空串不合法;根记录不带 id)。 */
51
+ export function validId(id) {
52
+ if (Buffer.byteLength(id, "utf8") > MAX_ID_BYTES)
53
+ return false;
54
+ const segments = id.split("/");
55
+ return segments.length <= MAX_ID_DEPTH && segments.every((s) => SEGMENT.test(s));
56
+ }
57
+ /** 任意字符串 → 合法的单段 id(非法字符换 `_`,截到 n 个字符);空串给 `x`。 */
58
+ export function idSegment(raw, n = 12) {
59
+ const s = raw.replace(/[^A-Za-z0-9_.+-]/g, "_").slice(0, n);
60
+ return s === "" ? "x" : s;
61
+ }
62
+ function encodeText(text, maxBytes) {
63
+ if (text === undefined)
64
+ return undefined;
65
+ const line = sanitizeLine(text);
66
+ if (line === "")
67
+ return undefined;
68
+ return Buffer.from(truncateUtf8(line, maxBytes), "utf8").toString("base64");
69
+ }
70
+ /** 报告 → pairs(不含 OSC 包裹);state / id 不合法返回 undefined。 */
71
+ export function encodePairs(report) {
72
+ if (!STATES.includes(report.state))
73
+ return undefined;
74
+ if (report.id !== undefined && !validId(report.id))
75
+ return undefined;
76
+ const pairs = [`state=${report.state}`];
77
+ if (report.id !== undefined)
78
+ pairs.push(`id=${report.id}`);
79
+ if (report.state === "blocked" && report.kind !== undefined && KINDS.includes(report.kind))
80
+ pairs.push(`kind=${report.kind}`);
81
+ const p = report.progress;
82
+ if ((report.state === "working" || report.state === "blocked") &&
83
+ p !== undefined &&
84
+ Number.isInteger(p) &&
85
+ p >= 0 &&
86
+ p <= 100)
87
+ pairs.push(`progress=${p}`);
88
+ if (report.app !== undefined && SEGMENT.test(report.app))
89
+ pairs.push(`app=${report.app}`);
90
+ if (report.state !== "clear") {
91
+ const title = encodeText(report.title, MAX_TITLE_BYTES);
92
+ if (title !== undefined)
93
+ pairs.push(`title=${title}`);
94
+ const msg = encodeText(report.msg, MAX_MSG_BYTES);
95
+ if (msg !== undefined)
96
+ pairs.push(`msg=${msg}`);
97
+ }
98
+ // 各字段已有上限,整条最多约 3.2 KB;仍超限就依次丢 msg、title
99
+ while (osc(pairs.join(":")).length > MAX_SEQUENCE_BYTES) {
100
+ const last = pairs.at(-1);
101
+ if (!last.startsWith("msg=") && !last.startsWith("title="))
102
+ return undefined;
103
+ pairs.pop();
104
+ }
105
+ return pairs.join(":");
106
+ }
107
+ function osc(body) {
108
+ return `\x1b]7501;${body}\x1b\\`;
109
+ }
110
+ /** 完整的 OSC 7501 序列;报告不合法返回 undefined(不发坏报告)。 */
111
+ export function encodeProgramStatus(report) {
112
+ const pairs = encodePairs(report);
113
+ return pairs === undefined ? undefined : osc(pairs);
114
+ }
115
+ /** tmux DCS passthrough:`ESC P tmux; <内容,ESC 翻倍> ESC \`。 */
116
+ export function tmuxPassthrough(sequence) {
117
+ return `\x1bPtmux;${sequence.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
118
+ }
119
+ /** 终端对 `OSC 7501 ; ?` 的回复(`?` 之后的内容忽略)。 */
120
+ export function isProgramStatusReply(sequence) {
121
+ return /^\x1b\]7501;\?/.test(sequence);
122
+ }
123
+ /** DA1 回复 `CSI ? … c`。 */
124
+ export function isDa1Reply(sequence) {
125
+ return /^\x1b\[\?[\d;]*c$/.test(sequence);
126
+ }
127
+ /** 发射器:检测、同值去重、节流;只在 `supported` 后写。 */
128
+ export class ProgramStatusEmitter {
129
+ options;
130
+ support = "unknown";
131
+ tmux;
132
+ desired = new Map();
133
+ sent = new Map();
134
+ timers = new Map();
135
+ probeTimer;
136
+ now;
137
+ constructor(options) {
138
+ this.options = options;
139
+ this.tmux = Boolean(options.env?.["TMUX"]);
140
+ this.now = options.now ?? Date.now;
141
+ }
142
+ /** 当前是否会写出报告。 */
143
+ get supported() {
144
+ return this.support === "yes";
145
+ }
146
+ /** 按模式开始:on 直接启用,auto 发检测查询(tmux 里不检测,等同 off),off 什么都不做。 */
147
+ start() {
148
+ if (this.support !== "unknown")
149
+ return;
150
+ const mode = this.options.mode;
151
+ if (mode === "off" || (mode === "auto" && this.tmux)) {
152
+ this.support = "no";
153
+ return;
154
+ }
155
+ if (mode === "on")
156
+ return this.enable();
157
+ this.support = "probing";
158
+ this.options.write(PROGRAM_STATUS_QUERY + DA1_QUERY);
159
+ this.probeTimer = setTimeout(() => this.settleProbe(false), this.options.timeoutMs ?? 300);
160
+ this.probeTimer.unref?.();
161
+ }
162
+ /** 输入解析里调用:吞掉检测回复(返回 true),其余返回 false。 */
163
+ handleInput(sequence) {
164
+ if (isProgramStatusReply(sequence)) {
165
+ if (this.support === "probing")
166
+ this.settleProbe(true);
167
+ return true;
168
+ }
169
+ if (isDa1Reply(sequence)) {
170
+ if (this.support === "probing")
171
+ this.settleProbe(false);
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+ /** 设定某个 id(缺省根记录)的状态;`clear` 同时忘掉该 id 及其下层的记录。 */
177
+ report(report) {
178
+ const key = report.id ?? "";
179
+ const full = this.options.app !== undefined ? { ...report, app: this.options.app } : report;
180
+ if (report.state === "clear") {
181
+ for (const id of [...this.desired.keys()])
182
+ if (key === "" || id === key || id.startsWith(`${key}/`))
183
+ this.forget(id);
184
+ }
185
+ this.desired.set(key, full);
186
+ if (this.support === "yes")
187
+ this.flush(key);
188
+ if (report.state === "clear")
189
+ this.desired.delete(key);
190
+ }
191
+ /** 停止计时器(不再写任何东西)。 */
192
+ dispose() {
193
+ if (this.probeTimer !== undefined)
194
+ clearTimeout(this.probeTimer);
195
+ this.probeTimer = undefined;
196
+ for (const timer of this.timers.values())
197
+ clearTimeout(timer);
198
+ this.timers.clear();
199
+ if (this.support === "probing")
200
+ this.support = "no";
201
+ }
202
+ settleProbe(ok) {
203
+ if (this.probeTimer !== undefined)
204
+ clearTimeout(this.probeTimer);
205
+ this.probeTimer = undefined;
206
+ if (ok)
207
+ this.enable();
208
+ else
209
+ this.support = "no";
210
+ }
211
+ enable() {
212
+ this.support = "yes";
213
+ for (const key of this.desired.keys())
214
+ this.flush(key);
215
+ }
216
+ forget(id) {
217
+ this.desired.delete(id);
218
+ this.sent.delete(id);
219
+ const timer = this.timers.get(id);
220
+ if (timer !== undefined)
221
+ clearTimeout(timer);
222
+ this.timers.delete(id);
223
+ }
224
+ flush(key) {
225
+ const report = this.desired.get(key);
226
+ if (report === undefined)
227
+ return;
228
+ const body = encodePairs(report);
229
+ if (body === undefined)
230
+ return;
231
+ const last = this.sent.get(key);
232
+ if (last?.body === body)
233
+ return;
234
+ const throttle = this.options.throttleMs ?? 500;
235
+ const head = `${report.state}:${report.kind ?? ""}`;
236
+ const wait = last !== undefined && last.head === head ? last.at + throttle - this.now() : 0;
237
+ if (wait > 0 && report.state !== "clear") {
238
+ if (!this.timers.has(key)) {
239
+ const timer = setTimeout(() => {
240
+ this.timers.delete(key);
241
+ this.flush(key);
242
+ }, wait);
243
+ timer.unref?.();
244
+ this.timers.set(key, timer);
245
+ }
246
+ return;
247
+ }
248
+ const timer = this.timers.get(key);
249
+ if (timer !== undefined)
250
+ clearTimeout(timer);
251
+ this.timers.delete(key);
252
+ const sequence = osc(body);
253
+ this.options.write(this.tmux ? tmuxPassthrough(sequence) : sequence);
254
+ if (report.state === "clear")
255
+ this.sent.delete(key);
256
+ else
257
+ this.sent.set(key, { body, head, at: this.now() });
258
+ }
259
+ }
package/docs/acp.md CHANGED
@@ -60,13 +60,13 @@ ama --mode acp # 与 -p 互斥;其余参数(--model、-
60
60
  | 工具开始 | `tool_call_update`(`in_progress`) |
61
61
  | 工具结束(含内层) | `tool_call_update`(`completed` / `failed`);`content` 为 `[diff?, 文本]`:edit / write 带 `diff`(`path`、`oldText`、`newText`,新文件 `oldText: null`),文本只带前 4 KB;`locations[].line` 是首个改动行;不发 `rawOutput` |
62
62
  | `todo` 更新 | `plan` |
63
- | 每轮结束 | `usage_update`(上下文已用、窗口、会话累计美元) |
63
+ | 每条助手消息结束、每轮结束 | `usage_update`(上下文已用、窗口、会话累计美元);与上次同值不重发 |
64
64
  | 权限模式变化 | `current_mode_update` |
65
- | 模型 / 思考级别变化 | `config_option_update`(全部配置项) |
66
- | 会话开出(new / load / resume)之后 | `available_commands_update` 与 `config_option_update` |
65
+ | 模型 / 思考级别变化 | `config_option_update`(全部配置项);换模型时另发一条 `usage_update`(窗口可能变了) |
66
+ | 会话开出(new / load / resume)之后 | `available_commands_update`、`config_option_update` 与 `usage_update`(load 在回放之后),客户端不用等第一轮结束 |
67
67
  | 回合结束之后 | `session_info_update`(`updatedAt`;标题与上次不同才带 `title`) |
68
68
 
69
- diff 的改前 / 改后全文只随实时事件走,不写进会话文件:任一侧超过 256 KiB 不带 diff,`session/load` 回放的工具结果只有前 4 KB 文本。模式列表的 `name` 是显示名(如 `Manual`、`Accept edits`),`description` 随界面语言。`session/prompt` 的结果带本回合 token 用量 `usage`(`inputTokens`、`outputTokens`、`cachedReadTokens`、`cachedWriteTokens`、`totalTokens`);该字段在 schema 1.24.1 里仍是 UNSTABLE(只在不稳定 schema 中),客户端可以忽略,以 `usage_update` 为准。
69
+ diff 的改前 / 改后全文只随实时事件走,不写进会话文件:任一侧超过 256 KiB 不带 diff,`session/load` 回放的工具结果只有前 4 KB 文本。模式列表的 `name` 是显示名(如 `Manual`、`Accept edits`),`description` 随界面语言。`session/prompt` 的结果带本回合 token 用量 `usage`(`inputTokens`、`outputTokens`、`cachedReadTokens`、`cachedWriteTokens`、`totalTokens`);该字段在 schema 1.24.1 里仍是 UNSTABLE(只在不稳定 schema 中),客户端可以忽略,以 `usage_update` 为准。模型窗口未知时(如没有 `contextWindow` 的自定义模型)不发 `usage_update`:schema 里 `size` 必填,ama 不编造窗口。
70
70
 
71
71
  `toolCallId` 在会话内唯一:上游供应商给的 id 若在后续回合重复(个别兼容接口的兜底 id、测试用假模型),线上 id 加 `#2`、`#3`… 区分,之后的状态更新、审批与回放都按这个映射(客户端按 id 合并条目,不区分会把不同调用合成一条)。
72
72
 
package/docs/agents.md CHANGED
@@ -256,7 +256,7 @@ ACP Agent 不给 `modes`、改用 category `mode` 的配置项表达模式时,
256
256
  - **信任**:只在 ama 已信任的目录里起外部 Agent(`claude -p` 会跳过目录信任对话框并执行项目 hooks 与配置);未信任时 `task` 报错并提示 `ama trust`。
257
257
  - **并发**:外部 Agent 总共 `agents.maxConcurrent`(缺省 3),每个 Agent 另有上限(`agents.<id>.maxConcurrent`,claude 缺省 2);超出排队,排队可被中断。与 ama 子会话的并发分开计。
258
258
  - **预算**:`task` 的 `budgetUsd`(Claude 透传 `--max-budget-usd`,其余按用量累计、超限中断);`agents.sessionBudgetUsd` 是本会话外部 Agent 的美元总额,用尽后不再启动。Codex 订阅 token、Copilot premium request 按各自单位记,不换算美元;Claude 的 `total_cost_usd` 是它自己的估算。
259
- - **记账**:每回合写 `custom{ama.agent-usage}`,会话建立写 `custom{ama.agent-session}`(外部 CLI 自己的会话 id,用于续聊);`/session` 与 `get_session_stats` 的 `external` 段按 Agent 汇总。ama 会话只存最终文本、工具摘要、用量与引用,外部 Agent 的原始事件只在内存里显示,不落盘。
259
+ - **记账**:每回合写 `custom{ama.agent-usage}`,会话建立写 `custom{ama.agent-session}`(外部 CLI 自己的会话 id,用于续聊);`/session` 与 `get_session_stats` 的 `external` 段按 Agent 汇总。外部 Agent 报告了上下文占用与窗口(ACP 的 `usage_update`、Codex app-server 的 `tokenUsage`)时,只记这两个数字:任务记录与 `ama.agent-usage` 带 `contextTokens / contextWindow`,`external.byAgent` 取最近一次,`/tasks` 与 Agent 栏的任务行显示 `ctx 34%`(低于 10% 保留一位小数);回合中途变化也会即时更新。ama 会话只存最终文本、工具摘要、用量与引用,外部 Agent 的原始事件只在内存里显示,不落盘。
260
260
  - **看门狗**:单回合缺省 30 分钟;中断后 15 秒内没有回合结束就关 stdin 再结束进程树;空闲 10 分钟关进程,能续接的下次续聊时以 resume 重开。外部 Agent 进程登记在 `<数据目录>/drivers/pids.json`,ama 异常退出留下的孤儿在下次使用时清理(核对命令行,pid 被复用的不杀)。
261
261
 
262
262
  ### 嵌入宿主
package/docs/en/acp.md CHANGED
@@ -60,13 +60,13 @@ One `ama --mode acp` process can keep several sessions open (Zed's threads share
60
60
  | Tool starts | `tool_call_update` (`in_progress`) |
61
61
  | Tool ends (inner calls included) | `tool_call_update` (`completed` / `failed`); `content` is `[diff?, text]`: edit / write carry a `diff` (`path`, `oldText`, `newText`; `oldText: null` for a new file), the text is limited to the first 4 KB; `locations[].line` is the first changed line; no `rawOutput` |
62
62
  | `todo` update | `plan` |
63
- | End of each turn | `usage_update` (context used, window size, session cost in USD) |
63
+ | End of each assistant message and each turn | `usage_update` (context used, window size, session cost in USD); not resent when unchanged |
64
64
  | Permission mode change | `current_mode_update` |
65
- | Model / thinking level change | `config_option_update` (all options) |
66
- | After a session opens (new / load / resume) | `available_commands_update` and `config_option_update` |
65
+ | Model / thinking level change | `config_option_update` (all options); a model change also sends a `usage_update` (the window may differ) |
66
+ | After a session opens (new / load / resume) | `available_commands_update`, `config_option_update` and `usage_update` (for load, after the replay), so clients need not wait for the first turn |
67
67
  | After a turn | `session_info_update` (`updatedAt`; `title` only when it differs from the last one) |
68
68
 
69
- The full before / after text of a diff only travels with live events and is never written to the session file: above 256 KiB on either side there is no diff, and tool results replayed by `session/load` carry only their first 4 KB of text. The `name` of each mode is its display name (such as `Manual` or `Accept edits`); the `description` follows the UI language. The `session/prompt` result carries the turn's token usage `usage` (`inputTokens`, `outputTokens`, `cachedReadTokens`, `cachedWriteTokens`, `totalTokens`); in schema 1.24.1 this field is still UNSTABLE (only in the unstable schema), so clients may ignore it and rely on `usage_update`.
69
+ The full before / after text of a diff only travels with live events and is never written to the session file: above 256 KiB on either side there is no diff, and tool results replayed by `session/load` carry only their first 4 KB of text. The `name` of each mode is its display name (such as `Manual` or `Accept edits`); the `description` follows the UI language. The `session/prompt` result carries the turn's token usage `usage` (`inputTokens`, `outputTokens`, `cachedReadTokens`, `cachedWriteTokens`, `totalTokens`); in schema 1.24.1 this field is still UNSTABLE (only in the unstable schema), so clients may ignore it and rely on `usage_update`. When the model's context window is unknown (such as a custom model without `contextWindow`), no `usage_update` is sent: `size` is required by the schema and ama does not make up a window.
70
70
 
71
71
  `toolCallId` is unique within a session: when the upstream provider reuses an id in a later turn (fallback ids of a few compatible APIs, the fake test model), the wire id gets `#2`, `#3`, … and later status updates, approvals and replay follow that mapping (clients merge entries by id, so without this different calls would collapse into one).
72
72
 
@@ -400,7 +400,7 @@ Inference order: conservative defaults ← inference table (provider id, then ba
400
400
  | `requiresReasoningContentOnAssistantMessages` | Earlier assistant messages of reasoning models carry `reasoning_content` (DeepSeek) |
401
401
  | `requiresToolResultName` | Tool result messages carry `name` (Mistral) |
402
402
  | `requiresAssistantAfterToolResult` | Insert an assistant message when a user message directly follows a tool result |
403
- | `supportsMidConvoSystemMessages` | Later system prompt patches are inserted back as system messages at their position |
403
+ | `supportsMidConvoSystemMessages` | Later prompt patches go back as system messages in place; off (default): trailing context, see "Caching" |
404
404
  | `cacheControlFormat` | `anthropic`: put `cache_control` on the system prompt, the last tool and the last user/tool message |
405
405
  | `supportsStrictTools` | Send `strict: true` for strictly compatible tool schemas |
406
406
  | `supportsStore` | Send `store: false` |
@@ -439,6 +439,8 @@ Most usage in long tasks is cache reads: once the prefix changes, every later re
439
439
 
440
440
  Prefix stability is guaranteed by assembly: the system prompt sections have a fixed order without timestamps, tools are sorted by name, and mid-session changes are only appended at the end as system patches ([session-format.md](../session-format.md), Chinese).
441
441
 
442
+ Section patches after the conversation has started (AGENTS.md, Skills or SessionStart hook output changed on resume, host instructions refreshed, the memory section re-rendered after compaction) **do not rewrite the leading system prompt**: protocols without `supportsMidConvoSystemMessages` render them as a user message wrapped in `<system-reminder>` at the patch's position, so the previous request stays a byte-for-byte prefix. The only exception is a patch that removes tools: the tool list itself changed and the prefix is lost anyway, so all patches are folded back into the head as before. Measured on DeepSeek, a mid-conversation system message is accepted and the prefix cache holds, but the model still answers from the leading one, so DeepSeek keeps the switch off ([cache-midconvo-2026-10-09](../benchmarks/cache-midconvo-2026-10-09.md), Chinese). At the next compaction these patches are folded into the head together with the system checkpoint.
443
+
442
444
  ### Request fields
443
445
 
444
446
  | Protocol | Field | Condition |
@@ -476,7 +478,7 @@ If any cache field appears in the raw usage (even 0) → `Usage.cacheReported =
476
478
 
477
479
  ### Model catalog `promptCache`
478
480
 
479
- Only publicly documented values are written (seconds / tokens): all Anthropic models `short 300 / long 3600`, with `minTokens` 512–4096 by model; all OpenAI models `short 300 / long 86400 / minTokens 1024`; all Kimi models `short 300`. DeepSeek, Zhipu, Qwen, Groq, xAI, Mistral, OpenRouter and Google promise no TTL, so it stays empty (no warming; attribution assumes 10 minutes for implicit caching). You can fill it in yourself in `models[]` / `modelOverrides[]`.
481
+ Only publicly documented values are written (seconds / tokens): all Anthropic models `short 300 / long 3600`, with `minTokens` 512–4096 by model; all OpenAI models `short 300 / long 86400 / minTokens 1024`; all Kimi models `short 300`; all DeepSeek models `short 3600 / minTokens 2048` (the vendor only says unused cache is cleared after "a few hours to a few days", so the conservative 1 hour is used; cache reads were measured in 2048-token blocks). Zhipu, Qwen, Groq, xAI, Mistral, OpenRouter and Google promise no TTL, so it stays empty: no warming and no early pruning on a guessed lifetime; only miss attribution assumes 10 minutes for implicit caching. You can fill it in yourself in `models[]` / `modelOverrides[]`; DeepSeek models on relays do not inherit the official catalog values, fill them in the same way if needed.
480
482
 
481
483
  ### Session layer: fingerprints, misses and three states
482
484
 
@@ -541,7 +543,7 @@ ama models cache-probe <provider/id> [--tokens 2048] [--gap-ms 3000] [--json] [-
541
543
 
542
544
  It sends a deterministic fixed prefix (about `--tokens` tokens) + `Reply with: ok`, `maxTokens: 16`, twice `--gap-ms` apart, and decides:
543
545
 
544
- - `reported`: the second cacheRead ≥ 50% of the prefix; when the catalog has no `promptCache`, it suggests filling in `promptCache.short` to enable warming;
546
+ - `reported`: the second cacheRead ≥ 50% of the prefix; when the catalog has no `promptCache`, it explains that the lifetime is unknown (no warming, no early pruning) and to fill it in only with a lifetime the upstream documents, without suggesting a guessed value — a value that is too short fires warming and early pruning while the cache is still valid;
545
547
  - `silent`: reads and writes were 0 both times; it suggests setting `compat.cacheReporting: "silent"`. When the response has cache fields that stay 0, it also hints at a possible write delay (kimi-k2.5 on the same relay read 0 twice 3 seconds apart, but the full prefix on the second request 8 seconds apart), so retry with a larger `--gap-ms`;
546
548
  - `inconclusive`: a small read or only writes, most likely a cache granularity or TTL issue.
547
549
 
package/docs/en/rpc.md CHANGED
@@ -285,6 +285,7 @@ The `data` of `get_session_stats` is `SessionStats`:
285
285
  | `tokens` | `{ input, output, cacheRead, cacheWrite, total }`, including warming requests |
286
286
  | `cost` | USD; absent when any message lacks a cost (the interface shows `$?`) |
287
287
  | `contextTokens` / `contextWindow` / `contextPercent` | Current context estimate, window and usage (0–100); absent when the model has no window |
288
+ | `context` | Estimate source and auto-compaction thresholds (fields: sessions.md) |
288
289
  | `cacheHitRate` | The legacy hit rate: cacheRead / (input + cacheRead + cacheWrite), every request in the denominator |
289
290
  | `cache` | `SessionCacheStats` (example below), present only when the session-layer cache controller is wired |
290
291
 
@@ -334,6 +335,8 @@ The `data` of `get_session_stats` is `SessionStats`:
334
335
 
335
336
  `cache_miss` gives `detail` only for `prefix_changed` (`system` / `tools`). The values and meanings of `reason` for `cache_warm{stopped}` are in [tui.md](tui.md) "Cache and context". The result object of `ama -p --output-format json` also has a `cache` field of the same shape.
336
337
 
338
+ It also carries `context: { tokens, window, percent }` (context used, window size and usage 0–100, defined like `contextTokens` / `contextWindow` / `contextPercent` in the table above); an unknown item is omitted (for a model without a window only `tokens` is present), and when all are unknown there is no `context`.
339
+
337
340
  ## Approvals
338
341
 
339
342
  1. When a tool call needs confirmation, the server sends `permission_request`. `preview` (optional) is the pre-execution preview: `{ kind: "bash" | "write" | "edit" | "other", lines: string[], severity: "info" | "warn" | "danger", affected?: { path, exists, bytes?, files? }[] }`; `lines` are already laid out without colors and can be shown as is. The preview is read-only and bounded, and absent when it cannot be computed.
@@ -61,6 +61,29 @@ those notification turns are written to the same session file.
61
61
  - A per-file summary is cached in `<data dir>/stats-index.json`, invalidated by file mtime and size; a time-zone change invalidates the whole index; a full scan also drops files that no longer exist. `--no-cache` neither reads nor writes it.
62
62
  - Measured (local machine, `src/session/stats-perf.test.ts`): 1000 sessions, 67 MB (12 turns, 24 tool calls and 2 KB tool results each), about 160 ms cold and about 15 ms with the index.
63
63
 
64
+ ## Context usage of the current session (`getStats()` / `get_session_stats`)
65
+
66
+ `ama stats` reports historical billed usage; how much context the running session has left is in the context fields of
67
+ `SessionStats` (SDK `session.getStats()`, passed through unchanged by RPC `get_session_stats`; the status bar and `/session` read
68
+ the same fields):
69
+
70
+ | Field | Meaning |
71
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
72
+ | `contextTokens` | Estimated tokens of context the next request will carry (see `context.source` for how) |
73
+ | `contextWindow` | The current model's window; **absent** when the model has no window information (fix it with `ama models discover` or by setting `contextWindow` for the model in config) |
74
+ | `contextPercent` | `contextTokens / contextWindow`, 0–100 with one decimal, capped at 100; **absent** without a window (the UI shows `ctx ?`) |
75
+ | `context.source` | `usage`: usage of the last valid assistant message + an estimate of the messages after it; `estimate`: no trustworthy usage, the whole context is estimated; `prefix`: the baseline before the first request |
76
+ | `context.usageTokens` | The part taken from usage; 0 for `estimate` / `prefix` |
77
+ | `context.trailingTokens` | The estimated part (messages after the usage, or the whole estimate / baseline) |
78
+ | `context.autoCompactAt` | Threshold of automatic summarization (tier two) = window − `compaction.reserveTokens`; **absent** when auto-compaction is off, tripped after repeated failures, or there is no window |
79
+ | `context.pruneAt` | Threshold of tool-result pruning (tier one) = 0.7 × (window − `reserveTokens`); absent under the same conditions |
80
+
81
+ - **Estimation**: CJK ideographs / kana / hangul count 1 token each, other characters / 4, images 1,600 each; usage uses `totalTokens` (or input + output + cache reads / writes, output included).
82
+ - **Before the first request** (`prefix`): the system prompt and tool table are written to the session only at the first request, so until then the estimate is taken from what is about to be sent (section texts + tool declaration JSON); a new session is not at 0 the moment it opens. This is for stats only: nothing is written to the session and the request body does not change.
83
+ - **After compaction / context edits** (`estimate`): the last usage no longer describes the current context, so the whole context is estimated until the next request brings new usage; the UI puts `≈` in front of the number.
84
+ - **An absent field means "unknown"**, not 0: `contextWindow`, `contextPercent`, `context.autoCompactAt` and `context.pruneAt` are left out of the JSON when absent. Older versions or custom session implementations may not have `context`; reading `contextTokens` is enough there.
85
+ - `tokens` (`{ input, output, cacheRead, cacheWrite, total }`) is the **session total** of billed usage, including requests that never enter the context (compaction summaries, cache warming); do not read it as context usage.
86
+
64
87
  ## Search: `ama sessions search`
65
88
 
66
89
  ```
@@ -178,7 +201,7 @@ Rolling back code (`/rewind`, design in [rewind-plan.md](../rewind-plan.md), Chi
178
201
  - **Snapshots**: at the start of a new turn, `git add -A` + `write-tree` + `commit-tree` (parent: the previous shadow commit), with the commit id recorded as the checkpoint's `shadowCommit`. The shadow repository uses a fixed identity and empty global / system config (your signing, hooks, filters and templates are not read), `core.autocrlf=false` with line-ending conversion off, so raw disk bytes are stored; `gc.auto=0`.
179
202
  - **Ignores**: `.gitignore` in the working directory applies; when the working directory is inside a git repository, paths your repository ignores (parent `.gitignore` files, `info/exclude`, the global ignore file) stay out of the shadow repository too; `.git` is always excluded.
180
203
  - **Restore**: the current working directory is written as a tree and compared with the target commit, so only differing files are processed; conflict and safety checks are the same as in `tools` (symbolic links, hard links, non-regular files and directories on the path replaced by links are skipped). The "known" current content = what is in the latest shadow snapshot, what ama last wrote or what the latest checkpoint recorded; anything else counts as a change outside the turn and is skipped by default. Ignored files are left alone; files changed by edit / write but not in the shadow repository (outside the working directory, ignored) restore from the `tools` records. When the target checkpoint has no shadow commit (after a downgrade, or the shadow repository was deleted) the whole restore follows `tools`.
181
- - **Guards**: in these cases the session downgrades to `tools` with a one-time notice: `git` is not on PATH; the working directory (ignored files excluded) has more than 20 000 files (checked before the first snapshot); a single snapshot takes longer than 3 seconds (that commit is kept). It is not enabled when the working directory is the home directory or the file-system root.
204
+ - **Guards**: in these cases the session downgrades to `tools` with a one-time notice: `git` is not on PATH; the working directory (ignored files excluded) has more than 20 000 files (checked before the first snapshot); a single snapshot takes longer than 3 seconds (that commit is kept; creating the shadow repository the first time does not count). It is not enabled when the working directory is the home directory or the file-system root.
182
205
  - **Limitations**:
183
206
  - bash changes in the latest turn enter a snapshot only when the next turn starts; rolling back before that, they cannot be told apart from manual changes and count as conflicts (overwriting is an option).
184
207
  - git only records the executable bit: restore only adjusts the executable bit and keeps other permission bits; symbolic links and submodules are not restored.