thincoder 0.12.54 → 0.12.58

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 (67) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/advisor/messages.mjs +4 -2
  6. package/src/advisor/run.mjs +2 -2
  7. package/src/agent/dispatch.mjs +66 -26
  8. package/src/agent/helpers.mjs +13 -2
  9. package/src/agent/setup.mjs +14 -2
  10. package/src/agent/spawn-child.mjs +3 -1
  11. package/src/agent-tools/advisor.mjs +19 -9
  12. package/src/agent-tools/eng.mjs +2 -0
  13. package/src/agent-tools/subagent-check.mjs +107 -0
  14. package/src/agent-tools/subagent.mjs +205 -42
  15. package/src/agent.mjs +68 -3
  16. package/src/cli/make-agent.mjs +25 -0
  17. package/src/cli/memory-command.mjs +28 -7
  18. package/src/config.mjs +120 -8
  19. package/src/context.mjs +28 -7
  20. package/src/escape.mjs +76 -23
  21. package/src/mcp/transport-http.mjs +13 -1
  22. package/src/mcp.mjs +52 -7
  23. package/src/memory/core.mjs +78 -10
  24. package/src/memory/docs.mjs +33 -7
  25. package/src/memory.mjs +1 -1
  26. package/src/model-specs.mjs +23 -0
  27. package/src/prompts/discipline.md +15 -1
  28. package/src/prompts/engineering.md +62 -5
  29. package/src/prompts/main.md +1 -0
  30. package/src/prompts/system.md +2 -1
  31. package/src/provider/anthropic.mjs +7 -5
  32. package/src/provider/core.mjs +48 -26
  33. package/src/provider/google.mjs +57 -24
  34. package/src/provider/normalize.mjs +1 -1
  35. package/src/provider/rate.mjs +0 -2
  36. package/src/provider/responses.mjs +8 -13
  37. package/src/provider/sse.mjs +20 -0
  38. package/src/session.mjs +15 -0
  39. package/src/tools/apply_patch.md +2 -0
  40. package/src/tools/bash.md +2 -2
  41. package/src/tools/edit-batch.mjs +104 -0
  42. package/src/tools/edit.md +3 -0
  43. package/src/tools/execute.md +4 -4
  44. package/src/tools/execute.mjs +14 -22
  45. package/src/tools/file.mjs +17 -55
  46. package/src/tools/file_ops.md +1 -1
  47. package/src/tools/git.md +1 -1
  48. package/src/tools/git.mjs +8 -16
  49. package/src/tools/lint.md +1 -1
  50. package/src/tools/linter.mjs +9 -37
  51. package/src/tools/patch.mjs +1 -1
  52. package/src/tools/shared.mjs +7 -20
  53. package/src/tui/agent-turn.mjs +3 -3
  54. package/src/tui/clipboard.mjs +2 -2
  55. package/src/tui/cmd-eng.mjs +1 -0
  56. package/src/tui/cmd-mcp-form.mjs +197 -0
  57. package/src/tui/cmd-mcp.mjs +255 -114
  58. package/src/tui/index.mjs +25 -5
  59. package/src/tui/interaction.mjs +28 -1
  60. package/src/tui/key-handler.mjs +14 -2
  61. package/src/tui/mouse.mjs +1 -1
  62. package/src/tui/pickers.mjs +62 -4
  63. package/src/tui/render-frame.mjs +18 -10
  64. package/src/tui/render.mjs +4 -4
  65. package/src/tui/startup.mjs +4 -2
  66. package/src/tui/subagent-blocks.mjs +119 -4
  67. package/src/tui/tool-events.mjs +60 -15
@@ -1,5 +1,5 @@
1
1
  import { sliceByWidth } from "./render.mjs"
2
- import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
2
+ import { PROVIDER_PRESETS as PRESETS, providerSpec } from "../config.mjs"
3
3
  import { computeLayout } from "./layout.mjs"
4
4
 
5
5
  /** Generic list picker + model/provider management.
@@ -121,6 +121,14 @@ export function createPickers(ctx) {
121
121
  return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
122
122
  }
123
123
 
124
+ /** 模型信息显示的 context 窗口(PROVIDER.md §15 D-C5):K 单位形态跟随覆盖值
125
+ * (config K 是二进制 K:128 → 128×1024 = 131072 → 显示 "128K");≥1M tokens 用 M 形态
126
+ * (1_000_000 → "1M"),与 spec 值的大窗口惯例一致。 */
127
+ function fmtContextK(tokens) {
128
+ if (tokens >= 1_000_000) return `${Math.round(tokens / 1_048_576)}M`
129
+ return `${Math.round(tokens / 1024)}K`
130
+ }
131
+
124
132
  /** Get API key for a provider (config.json only — env vars are not a key source) */
125
133
  function getApiKey(providerName, providerConfig) {
126
134
  return providerConfig.apiKey
@@ -148,6 +156,8 @@ export function createPickers(ctx) {
148
156
  await removeProviderFlow()
149
157
  } else if (e.action === "key") {
150
158
  await setKeyFlow()
159
+ } else if (e.action === "context") {
160
+ await setContextFlow()
151
161
  }
152
162
  }
153
163
  }
@@ -188,9 +198,11 @@ export function createPickers(ctx) {
188
198
  const marker = active ? "●" : ""
189
199
  const note = active ? " ← current" : ""
190
200
  const keyStatus = p.apiKey ? "" : " (no key)"
201
+ // context 窗口显示(PROVIDER.md §15 D-C5/T-C6):跟随 providers[].context 覆盖
202
+ const ctxTag = ` (ctx ${fmtContextK(providerSpec(p).context)})`
191
203
  entries.push({
192
204
  type: "item",
193
- text: `${p.name.padEnd(12)} ${currentModel}${note}`,
205
+ text: `${p.name.padEnd(12)} ${currentModel}${ctxTag}${note}`,
194
206
  action: "open-models",
195
207
  provider: p.name,
196
208
  marker,
@@ -201,6 +213,7 @@ export function createPickers(ctx) {
201
213
  entries.push({ type: "item", text: "Add provider…", action: "add" })
202
214
  if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
203
215
  entries.push({ type: "item", text: "Set / change API key…", action: "key" })
216
+ entries.push({ type: "item", text: "Set context window (K units)…", action: "context" })
204
217
  return entries
205
218
  }
206
219
 
@@ -294,7 +307,8 @@ export function createPickers(ctx) {
294
307
  agent.provider = { ...target }
295
308
  if (agent.config?.agent?.compactThresholdAuto) {
296
309
  const { resolveCompactThreshold } = await import("../config.mjs")
297
- agent.config.agent.compactThreshold = resolveCompactThreshold(null, item.model).value
310
+ // provider 对象(非 model 字符串)——阈值跟随 providers[].context 覆盖(PROVIDER.md §15 T-C2)
311
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, target).value
298
312
  }
299
313
  await persistRaw((raw) => {
300
314
  // 落盘前剥离运行时注入的 proxyUri(由 loadConfig + injectProxy 在加载时重建)
@@ -382,6 +396,50 @@ export function createPickers(ctx) {
382
396
  await persistRaw((raw) => { raw.providers = agent.providers })
383
397
  }
384
398
 
399
+ /** /model provider 管理:context 窗口字段(K 单位,PROVIDER.md §15 D-C4/D-C5)——
400
+ * picker 选 provider + 表单输入(复用 syncProviderField 的落盘模式:改 agent.providers 目标项
401
+ * → persistRaw 全量写盘);空输入清空(回 spec 值);非法输入报错不落盘(D-C1 语义同 loadConfig)。 */
402
+ async function setContextFlow() {
403
+ const se = await showPicker("Set Context Window", [
404
+ { type: "header", text: "Select provider" },
405
+ ...agent.providers.map((p) => ({
406
+ type: "item",
407
+ text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
408
+ name: p.name,
409
+ })),
410
+ ])
411
+ if (!se?.name) return // Esc
412
+ const target = agent.providers.find((p) => p.name === se.name)
413
+ if (!target) return
414
+ const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
415
+ const val = (await askQuestion(
416
+ `Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
417
+ ))?.trim() ?? ""
418
+ if (val === "") {
419
+ delete target.context
420
+ } else {
421
+ const n = Number(val)
422
+ if (!Number.isInteger(n) || n <= 0) {
423
+ pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
424
+ return
425
+ }
426
+ target.context = n
427
+ }
428
+ if (se.name === agent.activeProvider) {
429
+ // 运行时 provider 同步(同 setProviderKey 先例:只补 context,不重建对象以免丢 activeModel 覆盖)
430
+ if (target.context === undefined) delete agent.provider.context
431
+ else agent.provider.context = target.context
432
+ if (agent.config?.agent?.compactThresholdAuto) {
433
+ const { resolveCompactThreshold } = await import("../config.mjs")
434
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
435
+ }
436
+ }
437
+ await persistRaw((raw) => { raw.providers = agent.providers })
438
+ pushLine(target.context !== undefined
439
+ ? `ctx = ${target.context}K (${target.context * 1024} tokens)`
440
+ : `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
441
+ }
442
+
385
443
 
386
444
  /** Slot-bound model picker: two-level provider → model selection that RETURNS
387
445
  * { provider, model } instead of writing main-session state — used by /submodel
@@ -411,5 +469,5 @@ export function createPickers(ctx) {
411
469
  }
412
470
  }
413
471
 
414
- return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot }
472
+ return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot }
415
473
  }
@@ -9,7 +9,7 @@
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
11
  import { sliceByWidth, stringWidth } from "./render.mjs"
12
- import { specForModel } from "../config.mjs"
12
+ import { providerSpec } from "../config.mjs"
13
13
  import { computeLayout, subagentVisibleLines } from "./layout.mjs"
14
14
  import { basename } from "node:path"
15
15
  import { readFileSync } from "node:fs"
@@ -39,11 +39,15 @@ const SLASH_HINTS = {
39
39
 
40
40
  /** Header panel (always 1 line). */
41
41
  export function renderHeader(agent, cols) {
42
- const model = agent.provider.model
43
- const spec = specForModel(model)
42
+ // 2026-09-02 Q1(SESSION.md §8):provider 可为 null(无效 provider 被清空后用户 Esc 取消重选)
43
+ // —— 可选链守卫,头部显示 "no provider" 占位
44
+ const model = agent.provider?.model ?? "no provider"
45
+ // providerSpec(PROVIDER.md §15):模型信息显示跟随 provider 级 context 覆盖(D-C5);
46
+ // provider 为 null 时返回默认 spec 且不产生 "no provider" 查表警告(specForModel("") 空串不告警)
47
+ const spec = providerSpec(agent.provider)
44
48
  const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
45
- const t = agent.provider.thinking
46
- const effort = agent.provider.reasoningEffort
49
+ const t = agent.provider?.thinking
50
+ const effort = agent.provider?.reasoningEffort
47
51
  const thinkBadge = t?.type === "disabled" ? "│ think: off"
48
52
  : effort ? `│ think: ${effort}`
49
53
  : t?.type === thinkOnValue ? "│ think: on" : ""
@@ -288,7 +292,9 @@ function inputBoxStyle(state) {
288
292
  borderColor = C.tool; title = " Question "
289
293
  } else if (state.permission) {
290
294
  borderColor = C.warn
291
- title = state.permission.name === "continue" ? " Continue? (y/n) " : ` Allow ${state.permission.name}? (y/n/a) `
295
+ title = state.permission.name === "continue" ? " Continue? (y/n) "
296
+ : state.permission.batch ? ` Allow ${state.permission.name}? (a/o/n) `
297
+ : ` Allow ${state.permission.name}? (y/n/a) `
292
298
  } else if (state.picker) {
293
299
  title = " Select "
294
300
  } else if (state.wizard) {
@@ -312,9 +318,9 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
312
318
  : " Type answer then Enter │ Esc: cancel"
313
319
  }
314
320
  if (state.permission) {
315
- return state.permission.name === "continue"
316
- ? " y: continue │ n: stop"
317
- : " y: approve │ n: deny │ a: approve all (AUTO)"
321
+ if (state.permission.name === "continue") return " y: continue │ n: stop"
322
+ if (state.permission.batch) return " a: approve all o: one by one │ n: deny"
323
+ return " y: approve │ n: deny │ a: approve all (AUTO)"
318
324
  }
319
325
  if (state.picker) return " type: filter │ ↑↓/PgUp/PgDn: select │ Enter: confirm │ Esc: cancel"
320
326
  if (state.wizard) {
@@ -346,7 +352,9 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
346
352
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
347
353
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
348
354
  const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
349
- const modelContext = specForModel(agent.provider.model).context
355
+ // 2026-09-02 Q1(SESSION.md §8):provider 可为 null —— providerSpec(null) 保守 128K 不抛错
356
+ // 2026-09-02 §15:context 窗口跟随 providers[].context 覆盖(T-C6——百分比基于覆盖后的窗口)
357
+ const modelContext = providerSpec(agent.provider).context
350
358
  const ctxPct = Math.round((state.ctxCache.tokens / modelContext) * 100)
351
359
  const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
352
360
  const ctxHint = ctxPct > 0
@@ -203,7 +203,7 @@ export function layoutInput(chars, cursor, width) {
203
203
  * Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
204
204
  * are also cleaned during replay.
205
205
  */
206
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
206
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
207
207
  const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
208
208
  // Global variant for replace()/split(); the non-global one keeps match.index for slicing
209
209
  const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
@@ -212,7 +212,7 @@ export function sanitizeDisplay(s) {
212
212
  .replace(ANSI_SEQUENCE_RE, "")
213
213
  // §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
214
214
  // strip the sentinel + its RS-wrapped payload (⟦ev⟧turn\x1e…\x1e / bare RS/GS chars).
215
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
215
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
216
216
  .replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
217
217
  // GitHub-#4-class pitfall (2026-08-31): the residue strip used to be
218
218
  // /⟦ev⟧[^\x1e\x1d]*/ — "swallow to end of line/string", which ATE REAL
@@ -222,12 +222,12 @@ export function sanitizeDisplay(s) {
222
222
  // tokens start with a phase word (turn/approval); a bare sentinel with no
223
223
  // letters attached is not a live token. Strip only sentinel+letters.
224
224
  .replace(/⟦ev⟧[A-Za-z]*/g, "")
225
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
225
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
226
226
  .replace(/[\x1d\x1e]/g, "")
227
227
  .replace(/\r\n/g, "\n")
228
228
  .replace(/\r/g, "\n")
229
229
  .replace(/\t/g, " ")
230
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
230
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
231
231
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
232
232
  .replace(/\n+$/, "")
233
233
  }
@@ -140,12 +140,14 @@ export function showStartup(ctx) {
140
140
  const { agent, state, opts, pushLine, pushLabel, render, startWizard } = ctx
141
141
 
142
142
  // Startup screen
143
- if (!agent.provider.apiKey) {
143
+ // 2026-09-02 Q1(SESSION.md §8 D-S2):provider 可为 null(无效 provider 被清空后用户 Esc 取消重选)
144
+ // —— 可选链守卫;`!apiKey` 触发既有 wizard(其 provider 菜单列出已存在 providers,可选中恢复,F3)
145
+ if (!agent.provider?.apiKey) {
144
146
  pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
145
147
  pushLine("No API key configured yet — entering initial setup (Esc to skip anytime)", C.text)
146
148
  startWizard()
147
149
  } else {
148
- pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
150
+ pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider?.model ?? "(none)"}`, C.dim)
149
151
  }
150
152
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
151
153
 
@@ -16,9 +16,12 @@ import { describeToolArgs } from "./tool-args.mjs"
16
16
 
17
17
  /** `role#id/` prefix router — hyphen included since the eng-coder fix (2026-08-21). */
18
18
  export const SUB_PREFIX_RE = /^([\w-]+)#(\d+)\//
19
- /** ⟦ev⟧ event token parser (D1/D2): `⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`. */
20
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
21
- export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
19
+ /** ⟦ev⟧ event token parser (D1/D2): `⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`.
20
+ * phase "done" (§15 D-A3): async child finished — settle-time emits (each entry,
21
+ * it so the child's block freezes (the spawn tool result is a status JSON and
22
+ * must not freeze a still-running block). */
23
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
24
+ export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval|done)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
22
25
  /** N2: per-child display-line ring buffer cap — oldest lines drop with a marker. */
23
26
  export const SUB_BLOCK_LINE_LIMIT = 500
24
27
  /** N1: render-layer throttle for child tool-output appends (generation relays verbatim). */
@@ -267,8 +270,21 @@ export function routeSubToken(state, t, scheduleRender) {
267
270
  if (!sub) return true // frozen tombstone — late token from an aborted child: drop
268
271
  const payload = t.slice(subMatch[0].length)
269
272
  // ⟦ev⟧ event token: turn/approval progress → header ONLY (never blocks,
270
- // never the main stream — D1).
273
+ // never the main stream — D1). phase "done" (§15 D-A3): the async child
274
+ // finished — freeze its block (it stayed live through the background run).
271
275
  if (payload.startsWith("⟦ev⟧")) {
276
+ const ev = payload.match(SUB_EVENT_RE)
277
+ if (ev?.[1] === "done") {
278
+ sub.done = true
279
+ sub.doneAt = Date.now()
280
+ sub.currentTool = null
281
+ sub.approval = null
282
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
283
+ freezeSubTaskLines(state, sub)
284
+ delete state.subTasks[sub.key]
285
+ scheduleRender()
286
+ return true
287
+ }
272
288
  applySubEvent(sub, payload)
273
289
  scheduleRender()
274
290
  return true
@@ -329,3 +345,102 @@ export function routeSubToolOutput(state, name, part, scheduleRender) {
329
345
  throttleSubRender(scheduleRender)
330
346
  return true
331
347
  }
348
+
349
+ // ─── Compression panel (CONTEXT-COMPACTION.md §7 D-C2) ─────────────────────
350
+ // The compression session reuses the subagent block machinery — user ruling
351
+ // "压缩会话像子agent 面板那样显示". While the summary call is in flight the panel
352
+ // lives in state.subTasks (role "compress") and renders in the running subagent
353
+ // panel (subagent-panel.mjs) — the existing 1s turn ticker (agent-turn.mjs
354
+ // subRunning) makes the elapsed header tick; on completion/fallback it freezes
355
+ // into the stream as a collapsible block via freezeSubTaskLines, exactly like a
356
+ // finished child. The panel carries STATUS ONLY (D-C2): the summary body is a
357
+ // machine artifact and never enters the blocks.
358
+
359
+ let _compressSeq = 0
360
+
361
+ /** Live compression panel (role "compress", not done) or null. */
362
+ function liveCompressPanel(state) {
363
+ return Object.values(state.subTasks ?? {}).find((s) => s.role === "compress" && !s.done) ?? null
364
+ }
365
+
366
+ /**
367
+ * Open (or reset — retry) the compression panel. Fired by onCompressStart BEFORE
368
+ * the summary LLM call. info.messages = number of history messages being
369
+ * summarized ("summarizing N messages" stage label). Each attempt restarts the
370
+ * elapsed ticker (panel.started); the previous attempt's failure line stays in
371
+ * the block timeline so the retry history is visible.
372
+ */
373
+ export function ensureCompressPanel(state, info = {}) {
374
+ state.subTasks ??= {}
375
+ let panel = liveCompressPanel(state)
376
+ if (!panel) {
377
+ panel = {
378
+ key: `compress#${++_compressSeq}`,
379
+ role: "compress",
380
+ model: undefined,
381
+ started: Date.now(), done: false, doneAt: null,
382
+ blocks: [], currentTool: null, toolArgs: null,
383
+ turn: 0, maxTurns: 0, approval: null,
384
+ lastError: null, dropped: 0,
385
+ }
386
+ state.subTasks[panel.key] = panel
387
+ }
388
+ // Per-attempt reset (D-C2 state machine): retries return to "Compressing…",
389
+ // the elapsed ticker restarts from this attempt's start.
390
+ panel.done = false
391
+ panel.doneAt = null
392
+ panel.lastError = null
393
+ panel.started = Date.now()
394
+ panel.currentTool = "compressing context…"
395
+ const messages = Number.isInteger(info.messages) && info.messages >= 0 ? info.messages : "?"
396
+ appendSubBlock(panel, "status", "Compressing context…\n", { fresh: true })
397
+ appendSubBlock(panel, "meta", `summarizing ${messages} messages\n`, { fresh: true })
398
+ return panel
399
+ }
400
+
401
+ /** Failure state (onCompressFail): error text ONLY — no degradation note. The
402
+ * fallback note is bound to 3 CONSECUTIVE failures (markCompressFallback) and
403
+ * must not appear on a single failure (D-C2 state machine). */
404
+ export function markCompressFailed(state, error) {
405
+ const panel = liveCompressPanel(state)
406
+ if (!panel) return
407
+ const text = error?.message ? String(error.message) : String(error ?? "unknown error")
408
+ panel.lastError = text
409
+ appendSubBlock(panel, "err", `Compression failed: ${text}\n`, { fresh: true })
410
+ }
411
+
412
+ /** Freeze the compression panel into the stream (collapsible, subagent-style —
413
+ * same carrier/fold-key as a finished child) and release the live entry. */
414
+ function freezeCompressPanel(state, panel) {
415
+ freezeSubTaskLines(state, panel)
416
+ delete state.subTasks[panel.key]
417
+ }
418
+
419
+ /** Completion state (onCompress, LLM summary): "Compressed: N tokens freed →
420
+ * summary (Xs)" — N = pre-compression estimate − post-compression estimate,
421
+ * Xs = elapsed seconds. Block frozen + collapsible (T2). */
422
+ export function markCompressDone(state, info = {}) {
423
+ const panel = liveCompressPanel(state)
424
+ if (!panel) return
425
+ const tokensFreed = Number.isFinite(info.tokensFreed) ? Math.max(0, Math.round(info.tokensFreed)) : 0
426
+ const seconds = Number.isFinite(info.elapsedMs) ? Math.max(0, Math.round(info.elapsedMs / 1000)) : 0
427
+ panel.done = true
428
+ panel.doneAt = Date.now()
429
+ panel.currentTool = null
430
+ appendSubBlock(panel, "status", `Compressed: ${tokensFreed} tokens freed → summary (${seconds}s)\n`, { fresh: true })
431
+ freezeCompressPanel(state, panel)
432
+ }
433
+
434
+ /** Fallback state (onCompress with mode:"fallback"): the 3-consecutive-failures
435
+ * degradation note "Compression failed — fallback: truncated to N messages"
436
+ * (N = tail messages retained). Frozen + collapsible (T3b). */
437
+ export function markCompressFallback(state, info = {}) {
438
+ const panel = liveCompressPanel(state)
439
+ if (!panel) return
440
+ const tailMessages = Number.isFinite(info.tailMessages) ? Math.round(info.tailMessages) : "?"
441
+ panel.done = true
442
+ panel.doneAt = Date.now()
443
+ panel.currentTool = null
444
+ appendSubBlock(panel, "err", `Compression failed — fallback: truncated to ${tailMessages} messages\n`, { fresh: true })
445
+ freezeCompressPanel(state, panel)
446
+ }
@@ -19,6 +19,7 @@ import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor
19
19
  import {
20
20
  SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
21
21
  routeSubToolOutput, finishSubTask, finishSubTasksByRole, finishSubTaskByModel, freezeDoneSubTasks,
22
+ ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
22
23
  } from "./subagent-blocks.mjs"
23
24
  import { TURN_CAP_MARK } from "../agent/spawn-child.mjs"
24
25
 
@@ -106,6 +107,21 @@ function settleToolBlock(state, name, toolId, summary) {
106
107
  }
107
108
  }
108
109
 
110
+ /** Async spawn detection (§15 D-A1): the subagent tool's async:true result is a
111
+ * status JSON ({id, role, status: running|queued}), NOT a report — the child
112
+ * keeps running, so its activity block must not be frozen at spawn time (it
113
+ * freezes via the ⟦ev⟧done event emitted at settle time — §15 D-A3). A real blocking
114
+ * report that happens to parse as this shape is a freak accident; the only
115
+ * consequence would be a late block freeze at turn end (cosmetic). */
116
+ function isAsyncSpawnResult(result) {
117
+ try {
118
+ const o = JSON.parse(result)
119
+ return Boolean(o && typeof o === "object" && typeof o.id !== "undefined" && (o.status === "running" || o.status === "queued"))
120
+ } catch {
121
+ return false
122
+ }
123
+ }
124
+
109
125
 
110
126
 
111
127
  /** Find the live tool-block carrier for a tool event: exact id match when the
@@ -130,8 +146,7 @@ function findToolBlock(state, name, toolId) {
130
146
  /** Build the agent callbacks + the shared flushStream for one turn.
131
147
  * askPermission, askQuestion, saveSessionImpl } */
132
148
  export function buildToolCallbacks(deps) {
133
- const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl } = deps
134
-
149
+ const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl } = deps
135
150
  // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
136
151
  // Timing safety: onToolResult flushes _advisorThink into history and empties
137
152
  // the buffers BEFORE onTurnEnd can call flushStream (tool result is
@@ -251,17 +266,23 @@ export function buildToolCallbacks(deps) {
251
266
  // carrier) and the turn sweep would mislabel it "(interrupted)" — every
252
267
  // successful subagent call showed that banner (consult P1, 2026-08-30).
253
268
  settleToolBlock(state, name, toolId, "completed")
254
- finishSubTask(state, SUBAGENT_ROLES, result.includes(TURN_CAP_MARK) ? "turn cap reached work may be partial" : null)
255
- // Freeze the finished blocks into the conversation stream (user report
256
- // 2026-08-30): a pinned tail section left every block stuck above the
257
- // input box forever. As lines they scroll away, stay expandable via the
258
- // dim auto-fold, and subTasks releases the entry.
259
- freezeDoneSubTasks(state)
260
- // Subagent report preview (max 8 lines) displayed directly in conversation
261
- const lines = result.split("\n")
262
- const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
263
- if (preview) pushLine(preview, C.dim)
264
- if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
269
+ // Async spawn (§15 D-A1): the result is a status JSON, not a report the
270
+ // child KEEPS running; skip the freeze (it would tombstone a live block
271
+ // and drop its relay stream). The block freezes on the ⟦ev⟧done event
272
+ // emitted at turn-end collection.
273
+ if (!isAsyncSpawnResult(result)) {
274
+ finishSubTask(state, SUBAGENT_ROLES, result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
275
+ // Freeze the finished blocks into the conversation stream (user report
276
+ // 2026-08-30): a pinned tail section left every ✓ block stuck above the
277
+ // input box forever. As lines they scroll away, stay expandable via the
278
+ // dim auto-fold, and subTasks releases the entry.
279
+ freezeDoneSubTasks(state)
280
+ // Subagent report preview (max 8 lines) displayed directly in conversation
281
+ const lines = result.split("\n")
282
+ const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
283
+ if (preview) pushLine(preview, C.dim)
284
+ if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
285
+ }
265
286
  } else if (name === "escalate") {
266
287
  // 飞刀 post-op report landed → freeze its block into the conversation too.
267
288
  settleToolBlock(state, name, toolId, "completed")
@@ -398,9 +419,33 @@ export function buildToolCallbacks(deps) {
398
419
  scheduleRender()
399
420
  },
400
421
  onPermissionRequest: (name, args) => askPermission(name, args),
422
+ // Merged batch ask (§16 D-B1): one confirmation for N non-readonly tools in
423
+ // the same response — "approve all / one by one / deny" (key-handler resolves
424
+ // the verdict string; approveAll is batch-scope only, never the AUTO flag).
425
+ onBatchPermissionRequest: (req) => askBatchPermission(req),
401
426
  onQuestion: (text, options) => askQuestion(text, options),
402
- onCompress: () => {
403
- pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
427
+ // Compression lifecycle (CONTEXT-COMPACTION.md §7 D-C2): the compression session renders
428
+ // as a subagent-style panel block start running panel ("Compressing context…" +
429
+ // "summarizing N messages" + elapsed ticker), fail → error text ONLY (no degradation note —
430
+ // that belongs to the 3-consecutive-failures fallback), success → frozen "Compressed: N
431
+ // tokens freed → summary (Xs)" / fallback → "truncated to N messages". The summary BODY
432
+ // never enters the panel or the stream (the summary call is silent). Replaces the old
433
+ // one-line "[context] Context too long..." warn (user ruling: panel, not a status line).
434
+ onCompressStart: (info) => {
435
+ ensureCompressPanel(state, info)
436
+ scheduleRender()
437
+ },
438
+ onCompressFail: (error) => {
439
+ // Q3 (CONTEXT-COMPACTION §7 F3): failure is no longer silent — visible on the panel AND
440
+ // logged to stderr so the error is traceable (400/timeout/network).
441
+ console.error("[context] compression failed:", error?.message ?? error)
442
+ markCompressFailed(state, error)
443
+ scheduleRender()
444
+ },
445
+ onCompress: (info) => {
446
+ if (info?.mode === "fallback") markCompressFallback(state, info)
447
+ else markCompressDone(state, info)
448
+ scheduleRender()
404
449
  },
405
450
  // Async distillation landed (SEND-STALL-DISTILL §2.3): the machine line was replaced by
406
451
  // the compressed version — persist it so the session file ends up compressed. Silent: