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.
Files changed (41) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/package.json +4 -3
  3. package/src/acp/bridge.mjs +4 -0
  4. package/src/agent/dispatch.mjs +19 -7
  5. package/src/agent/helpers.mjs +12 -0
  6. package/src/agent/record-results.mjs +130 -0
  7. package/src/agent/setup.mjs +5 -8
  8. package/src/agent/spawn-child.mjs +159 -0
  9. package/src/agent-tools/consult.mjs +95 -73
  10. package/src/agent-tools/escalate.mjs +53 -62
  11. package/src/agent-tools/subagent.mjs +39 -38
  12. package/src/agent.mjs +25 -109
  13. package/src/generate-title.mjs +30 -1
  14. package/src/prompts/advisor-round1.md +5 -6
  15. package/src/prompts/advisor-round2.md +3 -4
  16. package/src/prompts/advisor-round3.md +3 -4
  17. package/src/prompts/eng-coder.md +10 -0
  18. package/src/prompts/engineering.md +81 -14
  19. package/src/prompts/methodology-template.md +8 -3
  20. package/src/prompts/system.md +1 -1
  21. package/src/session.mjs +48 -1
  22. package/src/tools/system.mjs +3 -1
  23. package/src/tui/agent-turn.mjs +44 -363
  24. package/src/tui/cmd-advisor.mjs +20 -2
  25. package/src/tui/cmd-eng.mjs +44 -7
  26. package/src/tui/dims.mjs +74 -0
  27. package/src/tui/fold-block.mjs +208 -0
  28. package/src/tui/index.mjs +53 -16
  29. package/src/tui/key-handler-search.mjs +1 -1
  30. package/src/tui/key-handler.mjs +9 -6
  31. package/src/tui/layout.mjs +21 -20
  32. package/src/tui/mouse.mjs +8 -6
  33. package/src/tui/pickers.mjs +1 -1
  34. package/src/tui/render-conversation.mjs +368 -113
  35. package/src/tui/render-frame.mjs +9 -88
  36. package/src/tui/render-loop.mjs +12 -8
  37. package/src/tui/render.mjs +5 -0
  38. package/src/tui/startup.mjs +67 -13
  39. package/src/tui/subagent-blocks.mjs +326 -0
  40. package/src/tui/tool-args.mjs +67 -0
  41. package/src/tui/tool-events.mjs +461 -0
@@ -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 { formatToolSummary } from "./tool-summaries.mjs"
6
- import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
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 (elapsed timer)
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
- // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
70
- // Timing safety: onToolResult flushes _advisorThink into history and empties
71
- // the buffers BEFORE onTurnEnd can call flushStream (tool result is
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
- if (!agent.title) {
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
@@ -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 persist().catch(err => pushLine(`[error] ${err.message}`, C.error))
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
 
@@ -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 persistRaw((raw) => {
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
+ }
@@ -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
+ }