thincoder 0.12.49 → 0.12.51
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 +45 -2
- package/package.json +4 -3
- package/src/acp/bridge.mjs +4 -0
- package/src/agent/dispatch.mjs +19 -7
- package/src/agent/helpers.mjs +12 -0
- package/src/agent/record-results.mjs +130 -0
- package/src/agent/setup.mjs +5 -8
- package/src/agent/spawn-child.mjs +159 -0
- package/src/agent-tools/consult.mjs +95 -73
- package/src/agent-tools/escalate.mjs +53 -62
- package/src/agent-tools/subagent.mjs +39 -38
- package/src/agent.mjs +25 -109
- package/src/generate-title.mjs +30 -1
- package/src/prompts/advisor-round1.md +5 -6
- package/src/prompts/advisor-round2.md +3 -4
- package/src/prompts/advisor-round3.md +3 -4
- package/src/prompts/eng-coder.md +10 -0
- package/src/prompts/engineering.md +81 -14
- package/src/prompts/methodology-template.md +8 -3
- package/src/prompts/system.md +1 -1
- package/src/session.mjs +48 -1
- package/src/tools/system.mjs +3 -1
- package/src/tui/agent-turn.mjs +44 -363
- package/src/tui/cmd-advisor.mjs +20 -2
- package/src/tui/cmd-eng.mjs +44 -7
- package/src/tui/dims.mjs +74 -0
- package/src/tui/fold-block.mjs +208 -0
- package/src/tui/index.mjs +53 -16
- package/src/tui/key-handler-search.mjs +1 -1
- package/src/tui/key-handler.mjs +9 -6
- package/src/tui/layout.mjs +21 -20
- package/src/tui/mouse.mjs +8 -6
- package/src/tui/pickers.mjs +1 -1
- package/src/tui/render-conversation.mjs +368 -113
- package/src/tui/render-frame.mjs +9 -88
- package/src/tui/render-loop.mjs +12 -8
- package/src/tui/render.mjs +5 -0
- package/src/tui/startup.mjs +67 -13
- package/src/tui/subagent-blocks.mjs +326 -0
- package/src/tui/tool-args.mjs +67 -0
- package/src/tui/tool-events.mjs +461 -0
package/src/tui/render-frame.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { ansi, C, ESC } from "./ansi.mjs"
|
|
|
10
10
|
import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
|
|
11
11
|
import { sliceByWidth, stringWidth, sanitizeDisplay } from "./render.mjs"
|
|
12
12
|
import { specForModel } from "../config.mjs"
|
|
13
|
-
import { computeLayout
|
|
13
|
+
import { computeLayout } from "./layout.mjs"
|
|
14
14
|
import { basename } from "node:path"
|
|
15
15
|
|
|
16
16
|
export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
|
|
@@ -45,93 +45,16 @@ export function renderHeader(agent, cols) {
|
|
|
45
45
|
return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** Todo/task panel. Returns empty array when no tasks visible.
|
|
48
|
+
/** Todo/task panel. Returns empty array when no tasks visible. The first row is
|
|
49
|
+
* a divider line separating the panel from the conversation above it (user
|
|
50
|
+
* request 2026-08-30). */
|
|
49
51
|
export function renderTodo(visibleTasks, cols) {
|
|
50
|
-
|
|
52
|
+
const divider = `${C.dim}${"─".repeat(Math.max(1, cols - 1))}${ansi.reset}`
|
|
53
|
+
return [divider, ...visibleTasks.map((t) => {
|
|
51
54
|
const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
|
|
52
55
|
const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
|
|
53
56
|
return `${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}`
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Subagent panel. Returns empty when no subagents. */
|
|
58
|
-
export function renderSubagent(allSubs, W) {
|
|
59
|
-
const subs = allSubs
|
|
60
|
-
if (subs.length === 0) return []
|
|
61
|
-
const out = []
|
|
62
|
-
for (const s of subs.slice(0, MAX_SUB_LINES)) {
|
|
63
|
-
const icon = s.done ? "✓" : "…"
|
|
64
|
-
const color = s.done ? C.dim : C.tool
|
|
65
|
-
const label = `[${s.role}${s.model ? " · " + s.model : ""}]`.padEnd(10)
|
|
66
|
-
let content
|
|
67
|
-
if (s.done) {
|
|
68
|
-
const elapsed = Math.floor((Date.now() - s.started) / 1000)
|
|
69
|
-
content = `done ${elapsed}s`
|
|
70
|
-
} else if (s.tool) {
|
|
71
|
-
content = s.tool
|
|
72
|
-
} else if (s.text) {
|
|
73
|
-
const textLines = s.text.split("\n").filter((l) => l.trim())
|
|
74
|
-
content = textLines.length > 0 ? textLines[textLines.length - 1] : "thinking..."
|
|
75
|
-
} else {
|
|
76
|
-
content = "thinking..."
|
|
77
|
-
}
|
|
78
|
-
out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, W - 4 - stringWidth(label)))}${ansi.reset}`)
|
|
79
|
-
}
|
|
80
|
-
if (subs.length > MAX_SUB_LINES) {
|
|
81
|
-
out.push(`${C.dim} ... +${subs.length - MAX_SUB_LINES} more subagents${ansi.reset}`)
|
|
82
|
-
}
|
|
83
|
-
return out
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Per-kind panel colors: reasoning faint, tool progress cyan, main output gray. */
|
|
87
|
-
const PANEL_KIND_COLORS = { think: C.reason, tool: C.tool, text: C.dim }
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Flatten panel parts into display lines, tracking each line's kind.
|
|
91
|
-
* A part not starting mid-line continues the previous line (kind of the line's first fragment wins).
|
|
92
|
-
*/
|
|
93
|
-
function panelLines(p) {
|
|
94
|
-
const lines = []
|
|
95
|
-
for (const part of p.parts ?? []) {
|
|
96
|
-
part.text.split("\n").forEach((seg, j) => {
|
|
97
|
-
if (j === 0 && lines.length > 0) {
|
|
98
|
-
const last = lines[lines.length - 1]
|
|
99
|
-
// Empty trailing line = previous part ended exactly at a line break — the new
|
|
100
|
-
// part owns this line's kind. Otherwise it's a genuine mid-line continuation.
|
|
101
|
-
if (last.text === "") last.kind = part.kind
|
|
102
|
-
last.text += seg
|
|
103
|
-
} else {
|
|
104
|
-
lines.push({ kind: part.kind, text: seg })
|
|
105
|
-
}
|
|
106
|
-
})
|
|
107
|
-
}
|
|
108
|
-
return lines.filter((l) => l.text.trim())
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Tool output panels (streaming output like tail -f). Returns empty when no visible output. */
|
|
112
|
-
export function renderOutput(state, W, panelH) {
|
|
113
|
-
// Same visibility predicate as computeLayout: done panels linger until closeAt
|
|
114
|
-
const active = Object.entries(state.outputPanels ?? {}).filter(([_, p]) => !p.done || (p.closeAt ?? 0) > Date.now())
|
|
115
|
-
if (active.length === 0) return []
|
|
116
|
-
const out = []
|
|
117
|
-
const linesPerPanel = Math.max(1, Math.floor(panelH / active.length))
|
|
118
|
-
for (const [toolName, p] of active) {
|
|
119
|
-
const status = p.done ? `${C.dim}done${ansi.reset}` : `${C.tool}running${ansi.reset}`
|
|
120
|
-
out.push(`${C.text}❯ ${sliceByWidth(sanitizeDisplay(toolName), Math.max(10, W - 25))} — ${status}`)
|
|
121
|
-
const titleRows = 1
|
|
122
|
-
const contentRows = Math.max(0, linesPerPanel - titleRows)
|
|
123
|
-
const lines = contentRows > 0 ? panelLines(p).slice(-contentRows) : []
|
|
124
|
-
for (const l of lines) {
|
|
125
|
-
const color = PANEL_KIND_COLORS[l.kind] ?? C.dim
|
|
126
|
-
out.push(`${color} │ ${sliceByWidth(sanitizeDisplay(l.text), W - 5)}${ansi.reset}`)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
// Safety: never exceed allocated height (could happen with many panels + small terminal)
|
|
130
|
-
if (out.length > panelH) out.length = panelH
|
|
131
|
-
// Fill remaining rows to match panelH exactly. `out.length` is accurate because
|
|
132
|
-
// every pushed entry is a non-empty string (title or content line with prefix).
|
|
133
|
-
for (let i = out.length; i < panelH; i++) out.push("")
|
|
134
|
-
return out
|
|
57
|
+
})]
|
|
135
58
|
}
|
|
136
59
|
|
|
137
60
|
/** Permission preview panel. Returns empty when no permission request. */
|
|
@@ -293,7 +216,7 @@ export function renderRows(state, agent, opts) {
|
|
|
293
216
|
const slashCommands = opts.slashCommands ?? []
|
|
294
217
|
|
|
295
218
|
const layout = computeLayout(state, { cols, rows })
|
|
296
|
-
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks,
|
|
219
|
+
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay } = layout
|
|
297
220
|
|
|
298
221
|
const screen = new Array(rows).fill("")
|
|
299
222
|
const put = (y, lines) => {
|
|
@@ -303,9 +226,7 @@ export function renderRows(state, agent, opts) {
|
|
|
303
226
|
}
|
|
304
227
|
|
|
305
228
|
put(panels.header.y, [renderHeader(agent, cols)])
|
|
306
|
-
put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll))
|
|
307
|
-
if (panels.subagent) put(panels.subagent.y, renderSubagent(allSubs, W))
|
|
308
|
-
if (panels.output) put(panels.output.y, renderOutput(state, W, panels.output.h))
|
|
229
|
+
put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll, rows))
|
|
309
230
|
if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
|
|
310
231
|
if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
|
|
311
232
|
if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
|
package/src/tui/render-loop.mjs
CHANGED
|
@@ -56,19 +56,23 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
|
|
|
56
56
|
|
|
57
57
|
function doRender() {
|
|
58
58
|
try {
|
|
59
|
-
|
|
59
|
+
// Single source (Windows ConPTY instability, 2026-08-30). CACHE ONLY —
|
|
60
|
+
// no per-frame refresh: during heavy streaming output ConPTY reports a
|
|
61
|
+
// STALE buffer size (80) and per-frame sampling let that stale value
|
|
62
|
+
// hijack the cache (streaming content crammed into a left-hand sliver,
|
|
63
|
+
// restored to full width only after flush stopped the output). dims are
|
|
64
|
+
// updated by: startup seed + a delayed re-sample + resize events.
|
|
65
|
+
const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
|
|
60
66
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (p.done && (p.closeAt ?? 0) <= now) delete state.outputPanels[name]
|
|
65
|
-
}
|
|
67
|
+
// NOTE (§7.2 D6): the old state.outputPanels prune is gone — output panels
|
|
68
|
+
// are abolished; subagent blocks live in the conversation and are never
|
|
69
|
+
// auto-pruned (bounded by the N2 per-child line cap instead).
|
|
66
70
|
|
|
67
71
|
const { rows, cursorRow, cursorCol, layout } = renderRows(state, agent,
|
|
68
72
|
{ cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
|
|
69
73
|
|
|
70
74
|
// Clamp scroll (bookkeeping for the status-bar hint; renderConversation clamps internally too)
|
|
71
|
-
state.scroll = Math.min(state.scroll, Math.max(0, countConvLines(state, dims.cols) - layout.panels.conversation.h))
|
|
75
|
+
state.scroll = Math.min(state.scroll, Math.max(0, countConvLines(state, dims.cols, dims.rows) - layout.panels.conversation.h))
|
|
72
76
|
|
|
73
77
|
if (state.ctxCache.len !== agent.history.length) {
|
|
74
78
|
state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
|
|
@@ -107,5 +111,5 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
|
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
return { render, scheduleRender }
|
|
114
|
+
return { render, scheduleRender, get lastRenderAt() { return lastRenderAt } }
|
|
111
115
|
}
|
package/src/tui/render.mjs
CHANGED
|
@@ -209,6 +209,11 @@ const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
|
|
|
209
209
|
export function sanitizeDisplay(s) {
|
|
210
210
|
return s
|
|
211
211
|
.replace(ANSI_SEQUENCE_RE, "")
|
|
212
|
+
// §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
|
|
213
|
+
// strip the sentinel + its RS-wrapped payload (⟦ev⟧turn\x1e…\x1e / bare RS/GS chars).
|
|
214
|
+
.replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
|
|
215
|
+
.replace(/⟦ev⟧[^\x1e\x1d]*/g, "")
|
|
216
|
+
.replace(/[\x1d\x1e]/g, "")
|
|
212
217
|
.replace(/\r\n/g, "\n")
|
|
213
218
|
.replace(/\r/g, "\n")
|
|
214
219
|
.replace(/\t/g, " ")
|
package/src/tui/startup.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { listSlots } from "../session.mjs"
|
|
2
2
|
import { ansi, C } from "./ansi.mjs"
|
|
3
|
+
import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
|
|
4
|
+
import { sliceByWidth } from "./render.mjs"
|
|
5
|
+
import { slimToolResultForDisplay } from "./tool-events.mjs"
|
|
3
6
|
|
|
4
7
|
/** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
|
|
5
8
|
* the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
|
|
@@ -28,9 +31,19 @@ export function historyToLines(history, startIdx, endIdx) {
|
|
|
28
31
|
const m = history[i]
|
|
29
32
|
if (m.role === "user") {
|
|
30
33
|
if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
|
|
34
|
+
// Label only when there is something to show: multimodal user messages
|
|
35
|
+
// (content = [text, image] array, injected after read_image) render NO
|
|
36
|
+
// text on restore — the image is invisible to the terminal and the user
|
|
37
|
+
// just saw it live. A label with no content under it is noise (user
|
|
38
|
+
// report 2026-08-30: stray "❯ You:" after the read_image block).
|
|
39
|
+
const userText = typeof m.content === "string"
|
|
40
|
+
? m.content
|
|
41
|
+
: (Array.isArray(m.content) ? m.content.find((p) => p?.type === "text")?.text ?? "" : "")
|
|
42
|
+
const displayable = typeof m.content === "string" ? !!m.content : !!(userText && userText.trim())
|
|
43
|
+
if (!displayable) continue
|
|
31
44
|
if (lines.length > 0) lines.push({ text: "", color: C.dim })
|
|
32
45
|
lines.push({ text: "❯ You:", color: ansi.bold + C.user })
|
|
33
|
-
if (
|
|
46
|
+
if (userText) lines.push({ text: userText, color: C.text, _kind: "text" })
|
|
34
47
|
inTurn = false
|
|
35
48
|
} else if (m.role === "assistant") {
|
|
36
49
|
if (!inTurn) {
|
|
@@ -39,22 +52,57 @@ export function historyToLines(history, startIdx, endIdx) {
|
|
|
39
52
|
lines.push({ text: "❯ ThinCoder:", color: ansi.bold + C.assistant })
|
|
40
53
|
}
|
|
41
54
|
inTurn = true
|
|
42
|
-
// Reasoning restored as
|
|
43
|
-
//
|
|
55
|
+
// Reasoning restored as ONE C.reason line entry — the exact shape
|
|
56
|
+
// flushStream produces live (single line, full string, no indent), so
|
|
57
|
+
// buildConvLines treats restored thinking IDENTICALLY: >12 wrapped rows
|
|
58
|
+
// fold under the named "▶ thinking" header, short fragments stay visible
|
|
59
|
+
// — same thresholds, same label, both paths. (History: restored thinking
|
|
60
|
+
// used to be split into dim fragments — the consecutive-dim rule's >8
|
|
61
|
+
// threshold never fired on short agentic thinking bursts, so restored
|
|
62
|
+
// sessions showed every fragment unfolded and mislabeled "tool output";
|
|
63
|
+
// user reported thinking "no longer folds" after a restart, 2026-08-30.)
|
|
44
64
|
const reasoning = m.reasoning_content ?? m.reasoning
|
|
45
|
-
if (typeof reasoning === "string" && reasoning.trim()) {
|
|
46
|
-
for (const line of reasoning.split("\n")) lines.push({ text: " " + line, color: C.dim })
|
|
47
|
-
}
|
|
65
|
+
if (typeof reasoning === "string" && reasoning.trim()) lines.push({ text: reasoning, color: C.reason, _kind: "thinking" })
|
|
48
66
|
if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
|
|
49
67
|
for (const tc of m.tool_calls ?? []) {
|
|
50
68
|
const toolResult = history[i + 1]
|
|
51
69
|
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
const tcName = tc.function?.name ?? "?"
|
|
71
|
+
// Tool arguments are part of the visible conversation (2026-08-30 user
|
|
72
|
+
// report: restored sessions showed no args at all — the deprecated
|
|
73
|
+
// display snapshot made historyToLines the ONLY restore path, and it
|
|
74
|
+
// never rendered arguments). Header line = readable key-args summary
|
|
75
|
+
// (describeToolArgs, vscode card-header parity); full pretty JSON rides
|
|
76
|
+
// below as dim lines, auto-folded by the consecutive-dim rule exactly
|
|
77
|
+
// like the restored tool result — same convention, same readability.
|
|
78
|
+
let argsSummary = ""
|
|
79
|
+
let argJson = []
|
|
80
|
+
let rawArgs = null
|
|
81
|
+
try {
|
|
82
|
+
const args = JSON.parse(tc.function?.arguments || "{}")
|
|
83
|
+
argsSummary = describeToolArgs(tcName, args)
|
|
84
|
+
argJson = toolArgsLines(args)
|
|
85
|
+
} catch { rawArgs = String(tc.function?.arguments ?? "").slice(0, 120) /* malformed — raw fallback */ }
|
|
86
|
+
// ONE BLOCK PER TOOL CALL — same carrier the live path emits (user
|
|
87
|
+
// ruling 2026-08-30): header=name+args, body=args JSON + result.
|
|
88
|
+
// Same carrier the live path emits — identical fields, so buildConvLines
|
|
89
|
+
// renders both through one code path (line-level parity by construction).
|
|
90
|
+
lines.push({
|
|
91
|
+
text: "", color: C.tool,
|
|
92
|
+
_kind: "tool",
|
|
93
|
+
_toolBlock: {
|
|
94
|
+
name: tcName,
|
|
95
|
+
roundTag: "",
|
|
96
|
+
argsSummary,
|
|
97
|
+
argsJson: rawArgs ? [rawArgs] : argJson,
|
|
98
|
+
output: [],
|
|
99
|
+
result: hasResult ? slimToolResultForDisplay(String(toolResult.content)) : null,
|
|
100
|
+
summary: null,
|
|
101
|
+
started: 0,
|
|
102
|
+
done: true,
|
|
103
|
+
elapsed: null,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
58
106
|
}
|
|
59
107
|
}
|
|
60
108
|
}
|
|
@@ -72,7 +120,13 @@ export function restoreLines(state, history) {
|
|
|
72
120
|
const total = Array.isArray(history) ? history.length : 0
|
|
73
121
|
if (total === 0) return
|
|
74
122
|
const start = Math.max(0, total - INITIAL_HISTORY_MESSAGES)
|
|
75
|
-
state.
|
|
123
|
+
state._lineIdCounter = state._lineIdCounter ?? 0
|
|
124
|
+
const fresh = historyToLines(history, start, total)
|
|
125
|
+
// Stable per-line ids (P1, 2026-08-30): fold keys for tool blocks derive from
|
|
126
|
+
// _lineId so loadOlder's head-unshift cannot re-bind an expanded block to a
|
|
127
|
+
// different tool (positional tool-{i} keys drift under unshift).
|
|
128
|
+
for (const l of fresh) l._lineId = ++state._lineIdCounter
|
|
129
|
+
state.lines.push(...fresh)
|
|
76
130
|
state._historyLoaded = total - start
|
|
77
131
|
state._historyTotal = total
|
|
78
132
|
state._hasOlder = start > 0
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-blocks.mjs — 子agent 活动区块缓冲(AGENT-LOOP.md §7.2 D4,消费端)。
|
|
3
|
+
*
|
|
4
|
+
* state.subTasks[key] 是完整的活动缓冲:{ key, role, model, started, done, doneAt,
|
|
5
|
+
* blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError,
|
|
6
|
+
* dropped, blockEpoch }。区块是子agent 活动的唯一载体(子工具调用不进父历史),
|
|
7
|
+
* 跨 turn 保留、可重新展开;渲染为会话流内可折叠区块(render-conversation.mjs)。
|
|
8
|
+
*
|
|
9
|
+
* 数据层职责:前缀路由、事件 token 解析(D1/D2)、kind 合并追加、N2 环形上限、
|
|
10
|
+
* N1 渲染节流(渲染调度节流,数据追加永不延迟)、完成冻结(freezeSubTaskLines
|
|
11
|
+
* 家族——_frozenSubTask 载体行,渲染端 render-conversation.mjs 识别)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { C } from "./ansi.mjs"
|
|
15
|
+
|
|
16
|
+
/** `role#id/` prefix router — hyphen included since the eng-coder fix (2026-08-21). */
|
|
17
|
+
export const SUB_PREFIX_RE = /^([\w-]+)#(\d+)\//
|
|
18
|
+
/** ⟦ev⟧ event token parser (D1/D2): `⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`. */
|
|
19
|
+
export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
|
|
20
|
+
/** N2: per-child display-line ring buffer cap — oldest lines drop with a marker. */
|
|
21
|
+
export const SUB_BLOCK_LINE_LIMIT = 500
|
|
22
|
+
/** N1: render-layer throttle for child tool-output appends (generation relays verbatim). */
|
|
23
|
+
export const SUB_RELAY_THROTTLE_MS = 250
|
|
24
|
+
/** Roles a subagent tool child can take (finishSubTask matches the block's role). */
|
|
25
|
+
export const SUBAGENT_ROLES = ["sub", "explore", "plan", "coder", "eng-coder"]
|
|
26
|
+
|
|
27
|
+
let _subRenderLast = 0
|
|
28
|
+
let _subRenderTimer = null
|
|
29
|
+
|
|
30
|
+
/** N1 throttle: leading-edge render + one coalesced trailing flush (final chunk
|
|
31
|
+
* within a window is not lost). Data appends are NEVER throttled — rendering only. */
|
|
32
|
+
export function throttleSubRender(scheduleRender) {
|
|
33
|
+
const now = performance.now()
|
|
34
|
+
const wait = SUB_RELAY_THROTTLE_MS - (now - _subRenderLast)
|
|
35
|
+
if (wait <= 0) {
|
|
36
|
+
_subRenderLast = now
|
|
37
|
+
scheduleRender()
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if (_subRenderTimer) return
|
|
41
|
+
_subRenderTimer = setTimeout(() => {
|
|
42
|
+
_subRenderTimer = null
|
|
43
|
+
_subRenderLast = performance.now()
|
|
44
|
+
scheduleRender()
|
|
45
|
+
}, wait)
|
|
46
|
+
_subRenderTimer.unref?.()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ensureSubTask(state, subMatch) {
|
|
50
|
+
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
51
|
+
// Tombstone guard (2026-08-30 consult residual): a child aborted mid-flight
|
|
52
|
+
// can relay tail tokens AFTER its block was frozen — ensureSubTask must not
|
|
53
|
+
// resurrect it (the recreated running block had no one left to freeze it and
|
|
54
|
+
// sat pinned above the input box until the next turn).
|
|
55
|
+
state._frozenSubKeys ??= new Set()
|
|
56
|
+
if (state._frozenSubKeys.has(key)) return null
|
|
57
|
+
state.subTasks ??= {}
|
|
58
|
+
if (!state.subTasks[key]) {
|
|
59
|
+
state.subTasks[key] = {
|
|
60
|
+
key, role: subMatch[1], model: undefined, started: Date.now(), done: false, doneAt: null,
|
|
61
|
+
blocks: [], currentTool: null, toolArgs: null, turn: 0, maxTurns: 0, approval: null,
|
|
62
|
+
lastError: null, dropped: 0,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return state.subTasks[key]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const countBlockLines = (text) => text.split("\n").length
|
|
69
|
+
|
|
70
|
+
/** Append (kind-merging) with the N2 ring-buffer cap. fresh=true starts a new block
|
|
71
|
+
* even when the previous block has the same kind (used per tool call). */
|
|
72
|
+
export function appendSubBlock(sub, kind, text, { fresh = false } = {}) {
|
|
73
|
+
if (!text) return
|
|
74
|
+
const last = sub.blocks.at(-1)
|
|
75
|
+
if (!fresh && last && last.kind === kind) {
|
|
76
|
+
// Merged append: account only the NET new lines. Counting the full split of
|
|
77
|
+
// every single-line chunk (each ends with \n → 2 segments) would inflate the
|
|
78
|
+
// incremental total far above the block's real line count (P1 修, 2026-08-30).
|
|
79
|
+
const before = countBlockLines(last.text)
|
|
80
|
+
last.text += text
|
|
81
|
+
sub._lineCount = (sub._lineCount ?? 0) + countBlockLines(last.text) - before
|
|
82
|
+
} else {
|
|
83
|
+
sub.blocks.push({ kind, text })
|
|
84
|
+
sub._lineCount = (sub._lineCount ?? 0) + countBlockLines(text)
|
|
85
|
+
}
|
|
86
|
+
sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
|
|
87
|
+
trimSubBlocks(sub)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** N2: keep at most SUB_BLOCK_LINE_LIMIT display lines per child; drop oldest
|
|
91
|
+
* lines and leave one cumulative "…(已省略 N 行)" marker block. Done blocks
|
|
92
|
+
* are bounded by the same cap (called from every append). */
|
|
93
|
+
export function trimSubBlocks(sub) {
|
|
94
|
+
// Incremental line accounting (P1, 2026-08-30): appendSubBlock keeps
|
|
95
|
+
// sub._lineCount; the old implementation recomputed the total with a full
|
|
96
|
+
// blocks.reduce on EVERY append — O(n) per token, O(n²) over a stream.
|
|
97
|
+
sub._lineCount = (sub._lineCount ?? 0)
|
|
98
|
+
let over = sub._lineCount - SUB_BLOCK_LINE_LIMIT
|
|
99
|
+
if (over <= 0) return
|
|
100
|
+
let droppedNow = 0
|
|
101
|
+
while (over > 0 && sub.blocks.length > 0) {
|
|
102
|
+
const first = sub.blocks[0]
|
|
103
|
+
const lines = countBlockLines(first.text)
|
|
104
|
+
const take = Math.min(lines, over)
|
|
105
|
+
if (take >= lines) {
|
|
106
|
+
sub.blocks.shift()
|
|
107
|
+
droppedNow += lines
|
|
108
|
+
} else {
|
|
109
|
+
first.text = first.text.split("\n").slice(take).join("\n")
|
|
110
|
+
droppedNow += take
|
|
111
|
+
}
|
|
112
|
+
over -= take
|
|
113
|
+
}
|
|
114
|
+
sub._lineCount -= droppedNow
|
|
115
|
+
sub.dropped += droppedNow
|
|
116
|
+
const marker = `…(已省略 ${sub.dropped} 行)`
|
|
117
|
+
const first = sub.blocks[0]
|
|
118
|
+
if (first && first.kind === "meta") first.text = marker
|
|
119
|
+
else { sub.blocks.unshift({ kind: "meta", text: marker }); sub._lineCount += 1 }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Mark the earliest running child of the given role(s) as done (✓ header, frozen elapsed). */
|
|
123
|
+
export function finishSubTask(state, roles, lastError = null) {
|
|
124
|
+
state.subTasks ??= {}
|
|
125
|
+
const roleSet = new Set(Array.isArray(roles) ? roles : [roles])
|
|
126
|
+
const running = Object.values(state.subTasks)
|
|
127
|
+
.filter((s) => !s.done && roleSet.has(s.role))
|
|
128
|
+
.sort((a, b) => a.started - b.started)
|
|
129
|
+
if (running.length === 0) return
|
|
130
|
+
const sub = running[0]
|
|
131
|
+
sub.done = true
|
|
132
|
+
sub.doneAt = Date.now()
|
|
133
|
+
sub.currentTool = null
|
|
134
|
+
sub.approval = null
|
|
135
|
+
if (lastError) sub.lastError = lastError
|
|
136
|
+
sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Freeze a finished (or interrupted) child's activity block into state.lines
|
|
140
|
+
* (§7.2 D4 — the block is the child activity's only carrier; moved here from
|
|
141
|
+
* agent-turn.mjs 2026-08-30). Rendering it as a pinned conversation-tail section
|
|
142
|
+
* made every ✓ block a permanent "ghost" stuck above the input box (user report
|
|
143
|
+
* 2026-08-30); frozen into the stream it scrolls away with the conversation AND
|
|
144
|
+
* stays an independent collapsible block — the folded form is a single header
|
|
145
|
+
* summary line, the expanded form re-renders the full activity timeline from the
|
|
146
|
+
* SAME block source. The payload travels as a JSON line flagged with
|
|
147
|
+
* _frozenSubTask; render-conversation.mjs recognizes it and renders the ▶/▼
|
|
148
|
+
* interaction keyed by `sub-${key}` (user ruled 2026-08-30: clickable after
|
|
149
|
+
* freezing — full design interaction, not a dim-lines fallback). subTasks loses
|
|
150
|
+
* the entry on release; memory stays bounded by N2 (ring buffer already applied). */
|
|
151
|
+
export function freezeSubTaskLines(state, sub) {
|
|
152
|
+
if (!sub) return
|
|
153
|
+
state._frozenSubKeys ??= new Set()
|
|
154
|
+
state._frozenSubKeys.add(sub.key)
|
|
155
|
+
if (!sub) return
|
|
156
|
+
sub.done = true
|
|
157
|
+
sub.doneAt = sub.doneAt ?? Date.now()
|
|
158
|
+
state.lines.push({ text: `subagent activity: ${sub.key}`, color: C.dim, _frozenSubTask: sub })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Freeze + release every already-done child block (tool-result sweep:
|
|
162
|
+
* subagent/escalate/consult-check completions each call this after finishSubTask). */
|
|
163
|
+
export function freezeDoneSubTasks(state) {
|
|
164
|
+
for (const key of Object.keys(state.subTasks ?? {})) {
|
|
165
|
+
if (state.subTasks[key].done) {
|
|
166
|
+
freezeSubTaskLines(state, state.subTasks[key])
|
|
167
|
+
delete state.subTasks[key]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Mark ALL running blocks of the given role(s) done — session-level settle.
|
|
173
|
+
* A consult spawns N parallel children (consult#1..#N); the single-shot
|
|
174
|
+
* finishSubTask only ever settled the earliest one, leaving N-1 running
|
|
175
|
+
* ghosts pinned above the input box until turn end (consult residual,
|
|
176
|
+
* 2026-08-30 consult review — 4/4 models converged on this). */
|
|
177
|
+
export function finishSubTasksByRole(state, roles, lastError = null) {
|
|
178
|
+
state.subTasks ??= {}
|
|
179
|
+
const roleSet = new Set(Array.isArray(roles) ? roles : [roles])
|
|
180
|
+
for (const sub of Object.values(state.subTasks)) {
|
|
181
|
+
if (!sub.done && roleSet.has(sub.role)) {
|
|
182
|
+
sub.done = true
|
|
183
|
+
sub.doneAt = Date.now()
|
|
184
|
+
sub.currentTool = null
|
|
185
|
+
sub.approval = null
|
|
186
|
+
if (lastError) sub.lastError = lastError
|
|
187
|
+
sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Precise settle: mark the child of `role` whose [model] token recorded
|
|
193
|
+
* `model` as done. consult_check returns the reply's model — the earliest-
|
|
194
|
+
* running heuristic froze the WRONG block when models settle out of order. */
|
|
195
|
+
export function finishSubTaskByModel(state, role, model, lastError = null) {
|
|
196
|
+
state.subTasks ??= {}
|
|
197
|
+
// Model-string normalization (2026-08-30 follow-up consult): the [model]
|
|
198
|
+
// token carries the BARE model name (resolveChildProvider keeps mname),
|
|
199
|
+
// while consult_check's r.model is consultLabel = "provider:model". Compare
|
|
200
|
+
// tail segments — a full "provider:model" reply matches a bare-name block.
|
|
201
|
+
const want = String(model ?? "").includes(":") ? String(model).split(":").pop() : String(model ?? "")
|
|
202
|
+
for (const sub of Object.values(state.subTasks)) {
|
|
203
|
+
const have = String(sub.model ?? "")
|
|
204
|
+
const haveTail = have.includes(":") ? have.split(":").pop() : have
|
|
205
|
+
if (!sub.done && sub.role === role && haveTail === want) {
|
|
206
|
+
sub.done = true
|
|
207
|
+
sub.doneAt = Date.now()
|
|
208
|
+
sub.currentTool = null
|
|
209
|
+
sub.approval = null
|
|
210
|
+
if (lastError) sub.lastError = lastError
|
|
211
|
+
sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
|
|
212
|
+
return sub
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Turn-end sweep (runAgentTurn finally): freeze ALL remaining blocks — interrupted
|
|
219
|
+
* runs (Ctrl+C abort / error mid-turn) would otherwise linger as pinned ghosts
|
|
220
|
+
* above the input box. Not-done children get done + lastError="interrupted"
|
|
221
|
+
* (skipped when the status already recovered to "Ready"). */
|
|
222
|
+
export function freezeAllSubTasks(state) {
|
|
223
|
+
for (const key of Object.keys(state.subTasks ?? {})) {
|
|
224
|
+
const sub = state.subTasks[key]
|
|
225
|
+
if (!sub.done) {
|
|
226
|
+
sub.done = true
|
|
227
|
+
sub.doneAt = Date.now()
|
|
228
|
+
if (!sub.lastError && state.status !== "Ready") sub.lastError = "interrupted"
|
|
229
|
+
}
|
|
230
|
+
freezeSubTaskLines(state, sub)
|
|
231
|
+
delete state.subTasks[key]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Apply one ⟦ev⟧ event token payload to the child's block header (D1/D2):
|
|
237
|
+
* turn/approval update turn n/max + waiting state. Events NEVER enter blocks
|
|
238
|
+
* or the main stream — header only.
|
|
239
|
+
* @returns {boolean} true = payload was a well-formed event token (consumed)
|
|
240
|
+
*/
|
|
241
|
+
export function applySubEvent(sub, payload) {
|
|
242
|
+
const ev = payload.match(SUB_EVENT_RE)
|
|
243
|
+
if (!ev) return false
|
|
244
|
+
if (ev[1] === "turn") {
|
|
245
|
+
sub.turn = Number(ev[2]) || sub.turn
|
|
246
|
+
sub.maxTurns = Number(ev[3]) || sub.maxTurns
|
|
247
|
+
sub.approval = null
|
|
248
|
+
} else if (ev[1] === "approval") {
|
|
249
|
+
sub.turn = Number(ev[2]) || sub.turn
|
|
250
|
+
sub.maxTurns = Number(ev[3]) || sub.maxTurns
|
|
251
|
+
sub.approval = ev[5] ? ev[5].slice(0, 40) : (ev[4] === "approval" ? "tool" : ev[4])
|
|
252
|
+
}
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Prefix routing (agent-turn callbacks delegate here) ────────────────────
|
|
257
|
+
// Each router returns true when the name/token carried a role#id/ prefix and was
|
|
258
|
+
// consumed into the child's block buffer; false = not a child item (main path).
|
|
259
|
+
|
|
260
|
+
/** Child LLM text / `[model]` metadata / ⟦ev⟧ event token (onToken branch). */
|
|
261
|
+
export function routeSubToken(state, t, scheduleRender) {
|
|
262
|
+
const subMatch = t.match(SUB_PREFIX_RE)
|
|
263
|
+
if (!subMatch) return false
|
|
264
|
+
const sub = ensureSubTask(state, subMatch)
|
|
265
|
+
if (!sub) return true // frozen tombstone — late token from an aborted child: drop
|
|
266
|
+
const payload = t.slice(subMatch[0].length)
|
|
267
|
+
// ⟦ev⟧ event token: turn/approval progress → header ONLY (never blocks,
|
|
268
|
+
// never the main stream — D1).
|
|
269
|
+
if (payload.startsWith("⟦ev⟧")) {
|
|
270
|
+
applySubEvent(sub, payload)
|
|
271
|
+
scheduleRender()
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
// `[model]<name>` metadata token: record the subagent's model (may differ from the
|
|
275
|
+
// parent's) — shown in the block header, NOT appended to its content stream.
|
|
276
|
+
// Only treat as metadata when the model isn't set yet (it's always the FIRST token);
|
|
277
|
+
// a child content token that happens to start with "[model]" must not be swallowed.
|
|
278
|
+
if (payload.startsWith("[model]") && sub.model === undefined) {
|
|
279
|
+
sub.model = payload.slice(7)
|
|
280
|
+
scheduleRender()
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
// Child LLM text → text block (N2 cap inside appendSubBlock).
|
|
284
|
+
appendSubBlock(sub, "text", payload)
|
|
285
|
+
scheduleRender()
|
|
286
|
+
return true
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Child reasoning token → think block (F2: same treatment as main reasoning). */
|
|
290
|
+
export function routeSubReasoning(state, t, scheduleRender) {
|
|
291
|
+
const subMatch = t.match(SUB_PREFIX_RE)
|
|
292
|
+
if (!subMatch) return false
|
|
293
|
+
const sub = ensureSubTask(state, subMatch)
|
|
294
|
+
if (!sub) return true // frozen tombstone — drop late token
|
|
295
|
+
appendSubBlock(sub, "think", t.slice(subMatch[0].length))
|
|
296
|
+
scheduleRender()
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Child tool call → fresh tool block + header currentTool. */
|
|
301
|
+
export function routeSubToolCall(state, name, args, scheduleRender) {
|
|
302
|
+
const subMatch = name.match(SUB_PREFIX_RE)
|
|
303
|
+
if (!subMatch) return false
|
|
304
|
+
const sub = ensureSubTask(state, subMatch)
|
|
305
|
+
if (!sub) return true // frozen tombstone — drop late token
|
|
306
|
+
sub.currentTool = name.slice(subMatch[0].length)
|
|
307
|
+
sub.toolArgs = args
|
|
308
|
+
sub.approval = null
|
|
309
|
+
appendSubBlock(sub, "tool", `❯ ${sub.currentTool}${args?.command ? " — " + String(args.command).replace(/\s+/g, " ").trim().slice(0, 80) : ""}\n`, { fresh: true })
|
|
310
|
+
scheduleRender()
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Child tool output (D1 prefixed-name relay) → append into the CURRENT tool block.
|
|
315
|
+
* Data append immediate; render throttled (N1). */
|
|
316
|
+
export function routeSubToolOutput(state, name, part, scheduleRender) {
|
|
317
|
+
const subMatch = name.match(SUB_PREFIX_RE)
|
|
318
|
+
if (!subMatch) return false
|
|
319
|
+
const sub = ensureSubTask(state, subMatch)
|
|
320
|
+
if (!sub) return true // frozen tombstone — drop late token
|
|
321
|
+
const toolName = name.slice(subMatch[0].length)
|
|
322
|
+
appendSubBlock(sub, "tool", part.text + "\n", { fresh: sub.currentTool !== toolName })
|
|
323
|
+
if (sub.currentTool !== toolName) sub.currentTool = toolName
|
|
324
|
+
throttleSubRender(scheduleRender)
|
|
325
|
+
return true
|
|
326
|
+
}
|