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.
- package/CHANGELOG.md +57 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +14 -2
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +76 -23
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +48 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/index.mjs +25 -5
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/mouse.mjs +1 -1
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-frame.mjs +18 -10
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/tool-events.mjs +60 -15
package/src/tui/pickers.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
46
|
-
const effort = agent.provider
|
|
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) "
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
package/src/tui/render.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
230
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
231
231
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
232
232
|
.replace(/\n+$/, "")
|
|
233
233
|
}
|
package/src/tui/startup.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
+
}
|
package/src/tui/tool-events.mjs
CHANGED
|
@@ -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
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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:
|