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/agent-turn.mjs
CHANGED
|
@@ -1,35 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-turn.mjs — runAgentTurn:一个用户回合的驱动器(submit / 队列递归入口)。
|
|
3
|
+
*
|
|
4
|
+
* 2026-08-30 拆分(回回 500 行硬限):回合生命周期(状态复位 → runAgent 循环 →
|
|
5
|
+
* 错误/Continue/中断处理 → finally 收尾 → 队列)留在这里;工具事件 → TUI 状态的
|
|
6
|
+
* 回调装配(onToken/onReasoning/onToolCall/onToolResult/onToolOutput/onTurnEnd 等
|
|
7
|
+
* + flushStream)在 tool-events.mjs buildToolCallbacks;子agent 区块缓冲与完成冻结
|
|
8
|
+
* (routeSub* / finishSubTask / freeze*SubTasks)在 subagent-blocks.mjs;标题生成
|
|
9
|
+
* 在 generate-title.mjs ensureSessionTitle。
|
|
10
|
+
*/
|
|
1
11
|
import { runAgent, ContinueError } from "../agent.mjs"
|
|
2
12
|
import { saveSession } from "../session.mjs"
|
|
3
|
-
import { sliceByWidth } from "./render.mjs"
|
|
4
13
|
import { ansi, C } from "./ansi.mjs"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
14
|
+
import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
|
|
15
|
+
import { freezeAllSubTasks } from "./subagent-blocks.mjs"
|
|
16
|
+
import { ensureSessionTitle } from "../generate-title.mjs"
|
|
7
17
|
|
|
8
18
|
/** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
|
|
9
19
|
* wait at most this long for the in-flight distill before the final session save —
|
|
10
20
|
* never let shutdown hang on the background summary call. */
|
|
11
21
|
const DISTILL_FLUSH_TIMEOUT_MS = 5000
|
|
12
22
|
|
|
13
|
-
/** Tool execution start timestamps (performance.now ms), keyed by tool name. */
|
|
14
|
-
const _toolTicks = Object.create(null)
|
|
15
|
-
|
|
16
|
-
/** Per-tool streaming preview line limits — tools with verbose output get more lines.
|
|
17
|
-
* NOTE: `advisor` is intentionally NOT pruned by the live-line mechanism: its
|
|
18
|
-
* streaming returns early (kind-split into _advisorThink/advisorStreaming) and
|
|
19
|
-
* is rendered full-length in render-conversation. The entry is kept for
|
|
20
|
-
* symmetry with the map's other tools. */
|
|
21
|
-
const LIVE_LINE_LIMITS = {
|
|
22
|
-
bash: 10,
|
|
23
|
-
advisor: 15,
|
|
24
|
-
read: 3,
|
|
25
|
-
grep: 8,
|
|
26
|
-
glob: 8,
|
|
27
|
-
search: 8,
|
|
28
|
-
websearch: 8,
|
|
29
|
-
code_search: 8,
|
|
30
|
-
doc_search: 8,
|
|
31
|
-
}
|
|
32
|
-
|
|
33
23
|
/** Execute one agent conversation turn (triggered by submit or queue).
|
|
34
24
|
* Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
|
|
35
25
|
* ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
|
|
@@ -40,12 +30,6 @@ export async function runAgentTurn(ctx, text) {
|
|
|
40
30
|
// 可注入覆盖(测试用);默认走真实实现
|
|
41
31
|
const runAgentImpl = ctx.runAgent ?? runAgent
|
|
42
32
|
const saveSessionImpl = ctx.saveSession ?? saveSession
|
|
43
|
-
// A new user message starts a new turn: auto-expanded completed replies from the
|
|
44
|
-
// previous turn (kept open so the user could read them) collapse now.
|
|
45
|
-
for (const idx of state._autoExpand ?? []) {
|
|
46
|
-
state.expandedBlocks?.delete(`long-${idx}`)
|
|
47
|
-
}
|
|
48
|
-
state._autoExpand = []
|
|
49
33
|
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
50
34
|
pushLine(text, C.text)
|
|
51
35
|
|
|
@@ -55,333 +39,32 @@ export async function runAgentTurn(ctx, text) {
|
|
|
55
39
|
state.streaming = ""
|
|
56
40
|
state.reasoning = ""
|
|
57
41
|
state._advisorBlocks = []
|
|
58
|
-
state.subTasks
|
|
42
|
+
// NOTE (§7.2 D4): state.subTasks is intentionally NOT reset here — subagent
|
|
43
|
+
// activity blocks persist across turns (the user can still expand a finished
|
|
44
|
+
// child's block from a previous turn). Child tool calls never enter the parent
|
|
45
|
+
// history, so the blocks are the only trace of child activity; memory is
|
|
46
|
+
// bounded by the N2 per-child 500-line ring buffer.
|
|
59
47
|
state.currentTool = null
|
|
60
48
|
state.processingStarted = Date.now()
|
|
61
49
|
state.controller = new AbortController()
|
|
50
|
+
// Turn-start re-sample (2026-08-30): ConPTY may have recovered the true size
|
|
51
|
+
// since the last event hook — a growth is accepted immediately (asymmetric
|
|
52
|
+
// rule), so generation starts full-width instead of waiting for the finally.
|
|
53
|
+
state.dims?.refresh()
|
|
62
54
|
state.interruptPrompt = null
|
|
63
|
-
// Refresh status bar every second during processing
|
|
55
|
+
// Refresh status bar every second during processing; also refresh when any
|
|
56
|
+
// subagent block is still running so its header elapsed ticks (§7.2 D4 —
|
|
57
|
+
// no new timer, the existing ticker carries it). Blocks stay visible after
|
|
58
|
+
// the turn ends, but frozen headers don't need 1s refreshes.
|
|
59
|
+
const subRunning = () => Object.values(state.subTasks ?? {}).some((s) => !s.done)
|
|
64
60
|
const ticker = setInterval(() => {
|
|
65
|
-
if (state.processing) render()
|
|
61
|
+
if (state.processing || subRunning()) render()
|
|
66
62
|
}, 1000)
|
|
67
63
|
render()
|
|
68
64
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// dispatched inside executeToolCalls; onTurnEnd fires after the turn loop
|
|
73
|
-
// resumes). If a future change calls flushStream mid-advisor-execution the
|
|
74
|
-
// in-progress thinking WOULD be lost — keep the ordering, or flush here too.
|
|
75
|
-
const flushStream = () => {
|
|
76
|
-
if (state.reasoning) {
|
|
77
|
-
const idx = state.lines.length
|
|
78
|
-
pushLine(state.reasoning, C.reason)
|
|
79
|
-
// Completed reasoning stays expanded (user is reading it) until the next turn
|
|
80
|
-
state.expandedBlocks ??= new Set()
|
|
81
|
-
state.expandedBlocks.add(`long-${idx}`)
|
|
82
|
-
state._autoExpand ??= []
|
|
83
|
-
state._autoExpand.push(idx)
|
|
84
|
-
state.reasoning = ""
|
|
85
|
-
}
|
|
86
|
-
if (state.streaming) {
|
|
87
|
-
const idx = state.lines.length
|
|
88
|
-
pushLine(state.streaming, C.text)
|
|
89
|
-
// Completed main output stays expanded (user is reading it) until the next turn
|
|
90
|
-
state.expandedBlocks ??= new Set()
|
|
91
|
-
state.expandedBlocks.add(`long-${idx}`)
|
|
92
|
-
state._autoExpand ??= []
|
|
93
|
-
state._autoExpand.push(idx)
|
|
94
|
-
state.streaming = ""
|
|
95
|
-
}
|
|
96
|
-
state._advisorBlocks = []
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const callbacks = {
|
|
100
|
-
onToken: (t) => {
|
|
101
|
-
// Subagent streaming: prefix format role#id/ → extract id, update subTask streaming text
|
|
102
|
-
const subMatch = t.match(/^([\w-]+)#(\d+)\//)
|
|
103
|
-
if (subMatch) {
|
|
104
|
-
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
105
|
-
const payload = t.slice(subMatch[0].length)
|
|
106
|
-
if (!state.subTasks[key]) {
|
|
107
|
-
state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
|
|
108
|
-
}
|
|
109
|
-
// `[model]<name>` metadata token: record the subagent's model (may differ from the
|
|
110
|
-
// parent's) — shown in the subagent header, NOT appended to its content stream.
|
|
111
|
-
// Only treat as metadata when the model isn't set yet (it's always the FIRST token);
|
|
112
|
-
// a child content token that happens to start with "[model]" must not be swallowed.
|
|
113
|
-
if (payload.startsWith("[model]") && state.subTasks[key].model === undefined) {
|
|
114
|
-
state.subTasks[key].model = payload.slice(7)
|
|
115
|
-
scheduleRender()
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
state.subTasks[key].text += payload
|
|
119
|
-
if (state.subTasks[key].text.length > 2000) {
|
|
120
|
-
state.subTasks[key].text = state.subTasks[key].text.slice(-2000)
|
|
121
|
-
}
|
|
122
|
-
scheduleRender()
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
ensureAssistantLabel()
|
|
126
|
-
state.streaming += t
|
|
127
|
-
scheduleRender()
|
|
128
|
-
},
|
|
129
|
-
onReasoning: (t) => {
|
|
130
|
-
// Subagent reasoning tokens also carry role#id/ prefix, go into subTasks panel
|
|
131
|
-
const subMatch = t.match(/^([\w-]+)#(\d+)\//)
|
|
132
|
-
if (subMatch) {
|
|
133
|
-
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
134
|
-
if (!state.subTasks[key]) {
|
|
135
|
-
state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
|
|
136
|
-
}
|
|
137
|
-
scheduleRender()
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
ensureAssistantLabel()
|
|
141
|
-
state.reasoning += t
|
|
142
|
-
scheduleRender()
|
|
143
|
-
},
|
|
144
|
-
onToolCall: (name, args) => {
|
|
145
|
-
// Subagent tool call: prefix role#id/toolName → update subTask current tool
|
|
146
|
-
const subMatch = name.match(/^([\w-]+)#(\d+)\//)
|
|
147
|
-
if (subMatch) {
|
|
148
|
-
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
149
|
-
const toolName = name.slice(subMatch[0].length)
|
|
150
|
-
if (!state.subTasks[key]) {
|
|
151
|
-
state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
|
|
152
|
-
}
|
|
153
|
-
state.subTasks[key].tool = toolName
|
|
154
|
-
state.subTasks[key].toolArgs = args
|
|
155
|
-
state.subTasks[key].text = ""
|
|
156
|
-
scheduleRender()
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
// Redundant with flushStream() below (it clears both buffers) — kept as
|
|
160
|
-
// defense-in-depth so a future flushStream change cannot leak advisor
|
|
161
|
-
// buffers into the next tool's view.
|
|
162
|
-
if (name === "advisor") { state._advisorBlocks = [] }
|
|
163
|
-
flushStream()
|
|
164
|
-
ensureAssistantLabel()
|
|
165
|
-
state.currentTool = name
|
|
166
|
-
// Advisor's effective model (resolved once for the status line + inline title below).
|
|
167
|
-
const advModel = name === "advisor" ? (() => { try { return resolveAdvisorProvider(agent).model } catch { return null } })() : null
|
|
168
|
-
// Update status bar with current tool and key arguments for user visibility
|
|
169
|
-
if (name === "bash" && args.command) {
|
|
170
|
-
const cmd = args.command.replace(/\s+/g, " ").trim()
|
|
171
|
-
state.status = `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + "…" : cmd}`
|
|
172
|
-
} else if ((name === "read" || name === "write" || name === "edit" || name === "grep" || name === "glob") && args.path) {
|
|
173
|
-
state.status = `${name}: ${args.path}`
|
|
174
|
-
} else if (name === "grep" && args.pattern) {
|
|
175
|
-
state.status = `grep: ${args.pattern}`
|
|
176
|
-
} else if (name === "glob" && args.pattern) {
|
|
177
|
-
state.status = `glob: ${args.pattern}`
|
|
178
|
-
} else if (name === "websearch" && args.query) {
|
|
179
|
-
state.status = `search: ${args.query.length > 40 ? args.query.slice(0, 40) + "…" : args.query}`
|
|
180
|
-
} else if (name === "advisor") {
|
|
181
|
-
state.status = `advisor review (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})`
|
|
182
|
-
} else {
|
|
183
|
-
state.status = `tool: ${name}`
|
|
184
|
-
}
|
|
185
|
-
// Advisor: tag the round in the tool title — the model's own "第N轮" narration
|
|
186
|
-
// is unreliable (it glues onto the previous line), so the round belongs here.
|
|
187
|
-
// Also show the advisor's effective model (it may differ from the main agent's).
|
|
188
|
-
const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
|
|
189
|
-
const argSummary = summarize(args)
|
|
190
|
-
// Inline block title — panel tools get both the title AND the
|
|
191
|
-
// streaming output panel, complementary display.
|
|
192
|
-
const color = ({ advisor: C.advisor, bash: C.warn, verify: C.tool }[name] ?? C.text)
|
|
193
|
-
pushLine(`❯ ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, color)
|
|
194
|
-
_toolTicks[name] = performance.now()
|
|
195
|
-
},
|
|
196
|
-
onToolResult: (name, result) => {
|
|
197
|
-
state.currentTool = null
|
|
198
|
-
// Subagent complete: mark earliest running subTask as done
|
|
199
|
-
const isSubagent = name === "subagent"
|
|
200
|
-
if (isSubagent) {
|
|
201
|
-
const running = Object.entries(state.subTasks)
|
|
202
|
-
.filter(([, s]) => !s.done)
|
|
203
|
-
.sort(([, a], [, b]) => a.started - b.started)
|
|
204
|
-
if (running.length > 0) {
|
|
205
|
-
const [key] = running[0]
|
|
206
|
-
state.subTasks[key].done = true
|
|
207
|
-
state.subTasks[key].tool = null
|
|
208
|
-
}
|
|
209
|
-
// Subagent report preview (max 8 lines) displayed directly in conversation
|
|
210
|
-
const lines = result.split("\n")
|
|
211
|
-
const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
|
|
212
|
-
if (preview) pushLine(preview, C.dim)
|
|
213
|
-
if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
|
|
214
|
-
// Clear done entries from panel after 3 seconds
|
|
215
|
-
setTimeout(() => {
|
|
216
|
-
for (const key of Object.keys(state.subTasks)) {
|
|
217
|
-
if (state.subTasks[key].done) delete state.subTasks[key]
|
|
218
|
-
}
|
|
219
|
-
if (state.processing) render()
|
|
220
|
-
}, 3000)
|
|
221
|
-
}
|
|
222
|
-
if (!isSubagent && name !== "advisor") {
|
|
223
|
-
// Remove live streaming lines — done line handles the summary.
|
|
224
|
-
for (let i = state.lines.length - 1; i >= 0; i--) {
|
|
225
|
-
if (state.lines[i]._live === name) state.lines.splice(i, 1)
|
|
226
|
-
}
|
|
227
|
-
const summary = formatToolSummary(name, result)
|
|
228
|
-
if (summary) pushLine(` ${summary}`, C.dim)
|
|
229
|
-
}
|
|
230
|
-
if (name === "advisor") {
|
|
231
|
-
// The review's thinking must survive into the conversation history like
|
|
232
|
-
// the main agent's reasoning (flushStream does for state.reasoning) —
|
|
233
|
-
// discarding it left the thought process visible only mid-review, then
|
|
234
|
-
// gone. Flush BEFORE the done line so the block sits above it.
|
|
235
|
-
// NOTE (rendering): the flushed block has NO "│ " gutter prefix while
|
|
236
|
-
// the live streaming view adds one — same convention as the main
|
|
237
|
-
// agent's reasoning (live gutter, history plain). Intentional.
|
|
238
|
-
const blocks = state._advisorBlocks ?? []
|
|
239
|
-
if (blocks.length > 0) {
|
|
240
|
-
// Flush the ordered blocks in sequence — thinking and tool progress
|
|
241
|
-
// alternate in history exactly as they were emitted. The live
|
|
242
|
-
// "[thinking…]" placeholders are stripped (wait indicators, not
|
|
243
|
-
// review content); literal replaceAll of the shared constant can
|
|
244
|
-
// never drift.
|
|
245
|
-
const text = blocks
|
|
246
|
-
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
|
|
247
|
-
.join("")
|
|
248
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
249
|
-
.trim()
|
|
250
|
-
if (text) {
|
|
251
|
-
const idx = state.lines.length
|
|
252
|
-
pushLine(text, C.reason)
|
|
253
|
-
// Completed review output stays expanded (user is reading it).
|
|
254
|
-
state.expandedBlocks ??= new Set()
|
|
255
|
-
state.expandedBlocks.add("long-" + idx)
|
|
256
|
-
state._autoExpand ??= []
|
|
257
|
-
state._autoExpand.push(idx)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
// Done line for ALL tools (panel area abolished — inline only).
|
|
262
|
-
if (!isSubagent) {
|
|
263
|
-
const elapsed = _toolTicks[name] ? ` (${Math.round(performance.now() - _toolTicks[name])}ms)` : ""
|
|
264
|
-
const summary = formatToolSummary(name, result)
|
|
265
|
-
const tail = summary ? ` → ${sliceByWidth(summary, 60)}` : ""
|
|
266
|
-
pushLine(`❯ ${name} — done${elapsed}${tail}`, C.dim)
|
|
267
|
-
}
|
|
268
|
-
delete _toolTicks[name]
|
|
269
|
-
},
|
|
270
|
-
onToolOutput: (name, chunk) => {
|
|
271
|
-
// All tools use inline conversation blocks — panel area is abolished.
|
|
272
|
-
// Stream up to 5 preview lines; the full result is in the tool message.
|
|
273
|
-
const part = typeof chunk === "string"
|
|
274
|
-
? { kind: "text", text: chunk.trimEnd() }
|
|
275
|
-
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "").trimEnd() }
|
|
276
|
-
if (!part.text) return
|
|
277
|
-
if (name === "advisor") {
|
|
278
|
-
// Accumulate to buffer — formatTables + wrapText in render-conversation
|
|
279
|
-
// handles markdown formatting, same as main agent response.
|
|
280
|
-
// NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
|
|
281
|
-
// emit() wrapper) — a raw string chunk is never think; if that ever
|
|
282
|
-
// changes, plain-string think would land in advisorStreaming.
|
|
283
|
-
// ORDERED block buffer — preserves the interleaved emission order
|
|
284
|
-
// (think → tool → think → … → final). Two separate buffers (_advisorThink
|
|
285
|
-
// vs advisorStreaming) rendered think-block-then-main-block, which
|
|
286
|
-
// regrouped ALL thinking above ALL tool progress — the alternating
|
|
287
|
-
// timeline was destroyed. Consecutive chunks of the same kind merge
|
|
288
|
-
// into one block; kind flips start a new block; render walks the
|
|
289
|
-
// blocks in order with per-kind colors.
|
|
290
|
-
const isString = typeof chunk === "string"
|
|
291
|
-
const raw = isString ? chunk : String(chunk?.text ?? "")
|
|
292
|
-
const kind = isString ? "text" : (chunk?.kind ?? "text")
|
|
293
|
-
const blocks = state._advisorBlocks ??= []
|
|
294
|
-
const last = blocks.at(-1)
|
|
295
|
-
if (last && last.kind === kind) last.text += raw
|
|
296
|
-
else blocks.push({ kind, text: raw })
|
|
297
|
-
scheduleRender()
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
// Rolling output — show latest N lines with fold marker per tool.
|
|
301
|
-
// _live marker per tool enables per-tool pruning without affecting other content.
|
|
302
|
-
const color = ({ think: C.reason, tool: C.tool }[part.kind] ?? C.dim)
|
|
303
|
-
for (const line of part.text.split("\n")) {
|
|
304
|
-
const trimmed = line.trimEnd()
|
|
305
|
-
if (!trimmed) continue
|
|
306
|
-
state.lines.push({ text: `│ ${trimmed}`, color, _live: name })
|
|
307
|
-
}
|
|
308
|
-
// Prune: keep at most N lines + "│ …" fold marker per tool (configurable, tool-specific)
|
|
309
|
-
const configLimit = agent.config?.agent?.streamPreviewLines
|
|
310
|
-
const toolLimit = LIVE_LINE_LIMITS[name]
|
|
311
|
-
const previewLines = configLimit ?? toolLimit ?? 5
|
|
312
|
-
let count = 0
|
|
313
|
-
let hasFold = false
|
|
314
|
-
for (let i = state.lines.length - 1; i >= 0; i--) {
|
|
315
|
-
if (state.lines[i]._live === name) {
|
|
316
|
-
if (++count > previewLines) {
|
|
317
|
-
if (!hasFold) {
|
|
318
|
-
state.lines[i] = { text: "│ …", color: C.dim, _live: name }
|
|
319
|
-
hasFold = true; count = previewLines
|
|
320
|
-
} else {
|
|
321
|
-
state.lines.splice(i, 1)
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
scheduleRender()
|
|
327
|
-
},
|
|
328
|
-
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
329
|
-
onQuestion: (text, options) => askQuestion(text, options),
|
|
330
|
-
onCompress: () => {
|
|
331
|
-
pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
|
|
332
|
-
},
|
|
333
|
-
// Async distillation landed (SEND-STALL-DISTILL §2.3): the machine line was replaced by
|
|
334
|
-
// the compressed version — persist it so the session file ends up compressed. Silent:
|
|
335
|
-
// a save failure must never surface after the turn already returned.
|
|
336
|
-
onDistilled: () => {
|
|
337
|
-
try { saveSessionImpl(agent, state.lines) } catch { /* 静默 */ }
|
|
338
|
-
},
|
|
339
|
-
onUsage: (usage) => {
|
|
340
|
-
state.tokens.prompt += usage.prompt_tokens ?? 0
|
|
341
|
-
state.tokens.completion += usage.completion_tokens ?? 0
|
|
342
|
-
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
343
|
-
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
344
|
-
state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
|
|
345
|
-
},
|
|
346
|
-
// Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
|
|
347
|
-
onWait: ({ phase, seconds }) => {
|
|
348
|
-
if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
|
|
349
|
-
else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
|
|
350
|
-
else state.status = `Rate-limited 429, retry in ${seconds}s`
|
|
351
|
-
render()
|
|
352
|
-
},
|
|
353
|
-
onTaskUpdate: (items) => {
|
|
354
|
-
state.tasks = items
|
|
355
|
-
const done = items.filter((i) => i.status === "done").length
|
|
356
|
-
// Leave trace with current task title: reviewing history shows what was in progress
|
|
357
|
-
const current = items.find((i) => i.status === "in_progress")
|
|
358
|
-
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
359
|
-
render()
|
|
360
|
-
},
|
|
361
|
-
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
362
|
-
onTurnEnd: (() => {
|
|
363
|
-
let n = 0
|
|
364
|
-
return () => {
|
|
365
|
-
// Flush pending reasoning/streaming before the next turn starts.
|
|
366
|
-
// Guard pushbacks (verify/advisor) continue the agent loop without
|
|
367
|
-
// returning to the TUI — without flushing, old thinking bleeds into
|
|
368
|
-
// the next turn and the guard reminder is invisible.
|
|
369
|
-
flushStream()
|
|
370
|
-
// Mirror the last system-reminder from agent.history so guard
|
|
371
|
-
// pushback messages appear in the conversation at the right spot.
|
|
372
|
-
const last = agent.history.at(-1)
|
|
373
|
-
if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
|
|
374
|
-
// Reminders can embed long prior tables — show only the first lines
|
|
375
|
-
// (the full text is in agent.history); 3 lines + ellipsis.
|
|
376
|
-
const lines = last.content.split("\n")
|
|
377
|
-
const shown = lines.length > 3 ? lines.slice(0, 3).join("\n") + "\n…" : last.content
|
|
378
|
-
pushLine(shown, C.warn)
|
|
379
|
-
}
|
|
380
|
-
if (++n % 5 !== 0) return
|
|
381
|
-
try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
382
|
-
}
|
|
383
|
-
})(),
|
|
384
|
-
}
|
|
65
|
+
const { callbacks, flushStream } = buildToolCallbacks({
|
|
66
|
+
agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, summarize, saveSessionImpl,
|
|
67
|
+
})
|
|
385
68
|
|
|
386
69
|
// try/finally: every exit path — including an unexpected throw inside the catch
|
|
387
70
|
// block (e.g. the continue-permission UI) — must stop the ticker and reset state,
|
|
@@ -436,8 +119,19 @@ export async function runAgentTurn(ctx, text) {
|
|
|
436
119
|
} finally {
|
|
437
120
|
clearInterval(ticker)
|
|
438
121
|
state.processing = false
|
|
439
|
-
state.subTasks = {}
|
|
440
122
|
state._advisorBlocks = []
|
|
123
|
+
// Interrupted runs (Ctrl+C abort / error mid-turn): still-running child blocks
|
|
124
|
+
// would linger as pinned ghosts above the input box — freeze them like normal
|
|
125
|
+
// completions so the trace scrolls away with the conversation (2026-08-30).
|
|
126
|
+
freezeAllSubTasks(state)
|
|
127
|
+
// Tool-block carriers get the same sweep (P0-2, 2026-08-30 consult): without
|
|
128
|
+
// an onToolResult their header would say "running" forever; ticks are cleared
|
|
129
|
+
// so no stale start time leaks into the next turn.
|
|
130
|
+
sweepToolBlocks(state)
|
|
131
|
+
// Output just stopped — the deterministic moment ConPTY's buffer info has
|
|
132
|
+
// recovered (stale-small only occurs DURING heavy output). Sample here:
|
|
133
|
+
// a growth is accepted immediately, a shrink still needs double-confirm.
|
|
134
|
+
state.dims?.refresh()
|
|
441
135
|
state.controller = null
|
|
442
136
|
state.status = "Ready"
|
|
443
137
|
// FR1: status bar must recover immediately — the awaits below (title-gen, distill flush,
|
|
@@ -449,20 +143,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
449
143
|
state.tasks = []
|
|
450
144
|
}
|
|
451
145
|
// Auto-generate session title from the first user message (once per session)
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
const { generateTitle } = await import("../generate-title.mjs")
|
|
455
|
-
const firstUser = (agent._fullHistory ?? agent.history).find(
|
|
456
|
-
(m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
|
|
457
|
-
)
|
|
458
|
-
if (firstUser) {
|
|
459
|
-
const title = await generateTitle(firstUser.content, agent.provider)
|
|
460
|
-
if (title) agent.title = title
|
|
461
|
-
}
|
|
462
|
-
} catch {
|
|
463
|
-
// Title generation failure is non-fatal
|
|
464
|
-
}
|
|
465
|
-
}
|
|
146
|
+
await ensureSessionTitle(agent)
|
|
466
147
|
// Exit flush (SEND-STALL-DISTILL §2.5): the round-end distillation runs async — before
|
|
467
148
|
// the final save, give it a bounded window to land the compressed history on disk.
|
|
468
149
|
// The next turn's runAgent would await it anyway; this covers the real exit path
|
package/src/tui/cmd-advisor.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** /advisor command: configure review model/thinking and toggle the review guard.
|
|
2
2
|
* Interactive loop UX — stays in menu after each action, Esc to exit.
|
|
3
3
|
* ctx: { agent, showPicker, pushLine, pushLabel, persistRaw } */
|
|
4
|
+
import { readFileSync } from "node:fs"
|
|
4
5
|
import { ansi, C } from "./ansi.mjs"
|
|
6
|
+
import { activeSlot, slotPath } from "../session.mjs"
|
|
7
|
+
import { writeSessionFile } from "./cmd-eng.mjs"
|
|
5
8
|
|
|
6
9
|
export async function handleAdvisorCommand(ctx) {
|
|
7
10
|
const { agent, showPicker, pushLine, pushLabel } = ctx
|
|
@@ -19,6 +22,21 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
// Guard-only dual write (2026-08-29 — advisor.guard is session-level): the guard goes into
|
|
26
|
+
// the CURRENT session slot first (shared with VS Code), the config.json mirror follows.
|
|
27
|
+
// Other advisor keys (model/thinking/effort/timeout) stay config-scoped — persist() only.
|
|
28
|
+
const persistGuard = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const p = slotPath(agent.cwd, activeSlot(agent.cwd))
|
|
31
|
+
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
32
|
+
if (data && typeof data === "object" && Array.isArray(data.history)) {
|
|
33
|
+
data.advisor = { ...(typeof data.advisor === "object" && data.advisor !== null ? data.advisor : {}), guard: cfg.guard === true }
|
|
34
|
+
writeSessionFile(p, data)
|
|
35
|
+
}
|
|
36
|
+
} catch { /* slot missing/unreadable — config mirror still written */ }
|
|
37
|
+
await persist()
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
// Lazy model cache — fetched once per /advisor session
|
|
23
41
|
let modelCache = null
|
|
24
42
|
|
|
@@ -133,9 +151,9 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
133
151
|
|
|
134
152
|
if (choice.action === "guard") {
|
|
135
153
|
cfg.guard = !(cfg.guard === true)
|
|
136
|
-
await
|
|
154
|
+
await persistGuard().catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
137
155
|
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
138
|
-
pushLine(`Advisor: ${cfg.guard === true ? "on" : "off"}`, C.tool)
|
|
156
|
+
pushLine(`Advisor: ${cfg.guard === true ? "on" : "off"} (session)`, C.tool)
|
|
139
157
|
continue
|
|
140
158
|
}
|
|
141
159
|
|
package/src/tui/cmd-eng.mjs
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
/** /eng command: toggle engineering mode.
|
|
2
2
|
* Requires METHODOLOGY.md in project root. Offers to create one if missing.
|
|
3
3
|
* ctx: { agent, pushLine, pushLabel, persistRaw, showPicker } */
|
|
4
|
-
import { existsSync, copyFileSync } from "node:fs"
|
|
5
|
-
import { join } from "node:path"
|
|
4
|
+
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"
|
|
5
|
+
import { join, dirname } from "node:path"
|
|
6
6
|
import { fileURLToPath } from "node:url"
|
|
7
7
|
import { ansi, C } from "./ansi.mjs"
|
|
8
|
+
import { activeSlot, slotPath } from "../session.mjs"
|
|
8
9
|
|
|
9
10
|
const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
|
|
10
11
|
import { ENG_OFF_REMINDER } from "../agent.mjs"
|
|
11
12
|
|
|
13
|
+
/** Atomic slot write (same shape as session.mjs writeSessionFile — kept local to avoid a
|
|
14
|
+
* private-import; cmd-advisor's guard toggle shares this helper). */
|
|
15
|
+
export function writeSessionFile(p, data) {
|
|
16
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
17
|
+
const tmp = `${p}.tmp`
|
|
18
|
+
writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
19
|
+
try {
|
|
20
|
+
renameSync(tmp, p)
|
|
21
|
+
} catch {
|
|
22
|
+
try { unlinkSync(p) } catch {}
|
|
23
|
+
try { renameSync(tmp, p) } catch { writeFileSync(p, readFileSync(tmp, "utf8"), "utf8") }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
export async function handleEngCommand(ctx) {
|
|
13
28
|
const { agent, pushLine, pushLabel, persistRaw, showPicker } = ctx
|
|
14
29
|
agent.config.agent ??= {}
|
|
@@ -40,13 +55,35 @@ export async function handleEngCommand(ctx) {
|
|
|
40
55
|
agent._pendingReminders = agent._pendingReminders ?? []
|
|
41
56
|
agent._pendingReminders.push(ENG_OFF_REMINDER)
|
|
42
57
|
}
|
|
43
|
-
await
|
|
44
|
-
raw.agent ??= {}
|
|
45
|
-
raw.agent.engineering = agent.config.agent.engineering
|
|
46
|
-
})
|
|
58
|
+
await persistEngineering(ctx, agent)
|
|
47
59
|
pushLabel("❯ Eng", ansi.bold + C.tool)
|
|
48
|
-
pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"}`, C.tool)
|
|
60
|
+
pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"} (session)`, C.tool)
|
|
49
61
|
if (agent.config.agent.engineering) {
|
|
50
62
|
pushLine(` → strictly following ${methodologyPath}`, C.dim)
|
|
51
63
|
}
|
|
52
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Dual persistence (2026-08-29 — engineering is session-level): write the flipped flag into
|
|
68
|
+
* the CURRENT session slot first (slot authority — shared with VS Code, per-session), then
|
|
69
|
+
* the config.json mirror (CLI visibility/compat; no longer the cross-session source of truth).
|
|
70
|
+
* The in-memory agent.config.agent.engineering (already flipped) stays the live authority for
|
|
71
|
+
* this process; saveSession also round-trips it on every turn-end write.
|
|
72
|
+
*/
|
|
73
|
+
async function persistEngineering(ctx, agent) {
|
|
74
|
+
const slot = activeSlot(agent.cwd)
|
|
75
|
+
try {
|
|
76
|
+
const p = slotPath(agent.cwd, slot)
|
|
77
|
+
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
78
|
+
if (data && typeof data === "object" && Array.isArray(data.history)) {
|
|
79
|
+
data.engineering = agent.config.agent.engineering
|
|
80
|
+
writeSessionFile(p, data)
|
|
81
|
+
}
|
|
82
|
+
} catch { /* slot missing/unreadable — config mirror still written */ }
|
|
83
|
+
if (ctx.persistRaw) {
|
|
84
|
+
await ctx.persistRaw((raw) => {
|
|
85
|
+
raw.agent ??= {}
|
|
86
|
+
raw.agent.engineering = agent.config.agent.engineering
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/tui/dims.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dims.mjs — terminal dimension single source (2026-08-30).
|
|
3
|
+
*
|
|
4
|
+
* Windows ConPTY bug (user report: streaming output crammed into a left-hand
|
|
5
|
+
* sliver on a 2K screen, restored to full width after mouse interaction):
|
|
6
|
+
* process.stdout.columns/rows are UNSTABLE — GetConsoleScreenBufferInfo lags
|
|
7
|
+
* behind the real window (ConPTY async update). Reads return falsy at startup
|
|
8
|
+
* (the old ||80 fallback cramped the whole session) and flip between stale
|
|
9
|
+
* and fresh values across calls.
|
|
10
|
+
*
|
|
11
|
+
* Rule: sample-and-hold with ASYMMETRIC acceptance (2026-08-30 consult).
|
|
12
|
+
* Every consumer reads the CACHED dims here, NEVER process.stdout.columns
|
|
13
|
+
* directly. Sampling happens only in event hooks (startup, delayed resample,
|
|
14
|
+
* resize, agent-turn finally, idle watchdog) — never in the render path.
|
|
15
|
+
*
|
|
16
|
+
* Asymmetric acceptance: the failure mode is one-directional — ConPTY reports
|
|
17
|
+
* a value SMALLER than reality (stale small buffer), never larger. So:
|
|
18
|
+
* - a LARGER sample is accepted immediately (real grow / recovery),
|
|
19
|
+
* - a SMALLER sample needs two consecutive confirmations before it is
|
|
20
|
+
* committed (real shrink), which absorbs the stale-shrink race.
|
|
21
|
+
*/
|
|
22
|
+
function defaultSample() {
|
|
23
|
+
return {
|
|
24
|
+
cols: Number(process.stdout.columns),
|
|
25
|
+
rows: Number(process.stdout.rows),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function makeDimsState(initial = {}, sampleFn = defaultSample, onChange = null) {
|
|
30
|
+
let dims = {
|
|
31
|
+
cols: Number(initial.cols) || 80,
|
|
32
|
+
rows: Number(initial.rows) || 24,
|
|
33
|
+
}
|
|
34
|
+
let sawValid = false
|
|
35
|
+
// Pending shrink confirmation: first sighting of a smaller sample parks it
|
|
36
|
+
// here; a second consecutive identical sighting commits it.
|
|
37
|
+
let pendingShrink = null
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** Cached dims (what every render path must use). */
|
|
41
|
+
get: () => dims,
|
|
42
|
+
/** True once a real terminal size has been observed (diagnostics / startup retry). */
|
|
43
|
+
get sawValid() { return sawValid },
|
|
44
|
+
/** Sample (event hooks only). See asymmetric-acceptance note above. */
|
|
45
|
+
refresh: () => {
|
|
46
|
+
const s = sampleFn()
|
|
47
|
+
const c = Number(s.cols)
|
|
48
|
+
const r = Number(s.rows)
|
|
49
|
+
if (!(c >= 40 && r >= 10)) return dims // falsy/stale-unusable → keep last good
|
|
50
|
+
sawValid = true
|
|
51
|
+
if (c > dims.cols || r > dims.rows) {
|
|
52
|
+
// Growth (incl. recovery from a stale-small cache): accept immediately.
|
|
53
|
+
dims = { cols: c, rows: r }
|
|
54
|
+
pendingShrink = null
|
|
55
|
+
onChange?.(dims)
|
|
56
|
+
return dims
|
|
57
|
+
}
|
|
58
|
+
if (c < dims.cols || r < dims.rows) {
|
|
59
|
+
// Shrink: needs two consecutive identical sightings (ConPTY reports a
|
|
60
|
+
// stale-small buffer during output activity; one sighting proves nothing).
|
|
61
|
+
if (pendingShrink && pendingShrink.cols === c && pendingShrink.rows === r) {
|
|
62
|
+
dims = { cols: c, rows: r }
|
|
63
|
+
pendingShrink = null
|
|
64
|
+
onChange?.(dims)
|
|
65
|
+
} else {
|
|
66
|
+
pendingShrink = { cols: c, rows: r }
|
|
67
|
+
}
|
|
68
|
+
return dims
|
|
69
|
+
}
|
|
70
|
+
pendingShrink = null
|
|
71
|
+
return dims
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|