dave-code 1.1.0 → 1.2.0

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/bin/index.js CHANGED
@@ -16,7 +16,7 @@ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.ur
16
16
  const packageVersion = pkg.version;
17
17
  import { spawn } from 'child_process';
18
18
  import { getLogoLines } from './logoRenderer.js';
19
- import { hasApiKey, getAIResponse, streamAIResponse, generateTitle, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
19
+ import { hasApiKey, getAIResponse, streamAIResponse, generateTitle, extractMemoryCandidates, summarizeForCompaction, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
20
20
  import { listSessions, saveSession, deleteSession } from './sessionManager.js';
21
21
  import {
22
22
  CONFIG_FILE,
@@ -25,9 +25,7 @@ import {
25
25
  setActiveProfile,
26
26
  updateProfile,
27
27
  openConfigFileInEditor,
28
- getActiveEffort,
29
- setActiveEffort,
30
- EFFORT_PRESETS,
28
+ inferContextWindowTokens,
31
29
  isFirstLaunch,
32
30
  setInitialized,
33
31
  lastConfigError
@@ -38,20 +36,68 @@ import {
38
36
  secureInputPrompt,
39
37
  chatInputPrompt,
40
38
  waitForEnter,
41
- selectEffortMenu,
39
+ selectWorkModeMenu,
40
+ runThunderActivationAnimation,
42
41
  confirmPrompt,
42
+ permissionPrompt,
43
43
  textInputPrompt
44
44
  } from './cliMenu.js';
45
- import { prepareModelMessages, stripReasoningBlocks, getContextStats } from './contextManager.js';
46
- import { parseToolCall, executeToolCall, resolveExistingWorkspacePath, resolveWorkspacePath, SUPPORTED_TOOLS } from './toolRuntime.js';
45
+ import { estimateTokens, prepareModelMessages, shouldCompact, stripReasoningBlocks, getContextStats } from './contextManager.js';
46
+ import { buildScanSnapshot, planHighwayNotebookBuild, runAdaptiveScan } from './scanManager.js';
47
+ import {
48
+ clearProjectNotebook,
49
+ ensureHighwayProjectNotebook,
50
+ getProjectNotebookStatus,
51
+ loadProjectNotebook,
52
+ markProjectNotebookStale,
53
+ readProjectNotebook
54
+ } from './projectNotebookManager.js';
55
+ import {
56
+ parseToolCall,
57
+ executeToolCall,
58
+ createToolCallSignature,
59
+ resolveBudgetFinishResponse,
60
+ resolveRepeatedToolRequest,
61
+ resolveExistingWorkspacePath,
62
+ resolveWorkspacePath,
63
+ undoLastMutation,
64
+ closeWorkspaceShell,
65
+ SUPPORTED_TOOLS,
66
+ MUTATING_TOOLS
67
+ } from './toolRuntime.js';
47
68
  import { createRuntimeEvents } from './runtimeEvents.js';
48
- import { createTerminalRenderer, sanitizeUntrustedText } from './terminalRenderer.js';
69
+ import { createTerminalRenderer, formatConversationMessage, sanitizeUntrustedText } from './terminalRenderer.js';
70
+ import { displayWidth, displayWidth as getStringWidth, padCenter as centerLine, padEnd as padLine, truncateMiddle } from './textWidth.js';
71
+ import { createThunderRenderer } from './thunderRenderer.js';
49
72
  import { parseInputCommand } from './commandRouter.js';
73
+ import {
74
+ formatThunderResourceProposal,
75
+ runThunderPlanning,
76
+ runThunderPostReview,
77
+ suggestThunderPlanName
78
+ } from './thunderOrchestrator.js';
79
+ import {
80
+ addThunderMessage,
81
+ findThunderTeamForPlan,
82
+ getThunderTeam,
83
+ thunderSummary,
84
+ updateThunderTeam
85
+ } from './thunderManager.js';
86
+ import { buildThunderSystemPrompt } from './thunderPrompts.js';
87
+ import {
88
+ clearMemories,
89
+ deleteMemory,
90
+ formatMemoriesForPrompt,
91
+ memorySummary,
92
+ mergeMemoryCandidates,
93
+ retrieveMemories,
94
+ setMemoryEnabled
95
+ } from './memoryManager.js';
50
96
  import {
51
97
  deletePlan,
52
98
  findPlan,
53
99
  listPlans,
54
- planStatusLines,
100
+ planStatusPanel,
55
101
  renamePlan,
56
102
  updatePlan,
57
103
  upsertPlan,
@@ -96,9 +142,14 @@ const locales = {
96
142
  ' /config - 编辑本地配置文件 (~/.dave-code-config.json)',
97
143
  ' /model - 快捷切换当前激活的服务配置',
98
144
  ' /api - 快捷修改当前配置的 API 密钥',
99
- ' /effort - 调整 AI 思考与阅读的努力程度 (low/medium/high/xhigh/max/ultracode)',
145
+ ' /mode highway|thunder - 切换单 Agent 或办公室团队工作流',
146
+ ' /thunder <需求> - 切换到 Thunder 并开始团队规划',
147
+ ' /agents - 查看当前工作区的 Thunder 团队',
148
+ ' /memory - 查看、开关或删除当前工作区的跨聊天记忆',
149
+ ' /note status|refresh|rebuild|clear - 管理 Highway 后台项目笔记',
100
150
  ' /plan 名称: 需求 - 创建只读实施计划;/plan 管理已有计划',
101
151
  ' /code <需求或计划名> - 单次授权编程修改,仍逐项确认',
152
+ ' /undo - 回滚本进程内最近一次文件变更',
102
153
  ' /open - 打开文件或文件夹并在其中工作',
103
154
  ' /exit - 退出 Dave Code 代理'
104
155
  ],
@@ -158,9 +209,14 @@ const locales = {
158
209
  ' /config - Open and edit config file (~/.dave-code-config.json)',
159
210
  ' /model - Interactively switch active service profile',
160
211
  ' /api - Interactively set API key for active profile',
161
- ' /effort - Adjust AI thinking/reading effort level (low/medium/high/xhigh/max/ultracode)',
212
+ ' /mode highway|thunder - Switch between single-agent and office-team workflow',
213
+ ' /thunder <request> - Switch to Thunder and start team planning',
214
+ ' /agents - View Thunder teams for this workspace',
215
+ ' /memory - View, toggle, or delete cross-chat workspace memories',
216
+ ' /note status|refresh|rebuild|clear - Manage the private Highway project notebook',
162
217
  ' /plan name: request - Create a read-only implementation plan; /plan manages plans',
163
218
  ' /code <request or plan> - Authorize one confirmed coding turn',
219
+ ' /undo - Undo the most recent file mutation in this process',
164
220
  ' /open - Open a file or directory to work in',
165
221
  ' /exit - Exit the Dave Code agent'
166
222
  ],
@@ -193,24 +249,34 @@ let workspaceRoot = process.cwd();
193
249
  let workspaceReady = true;
194
250
  let currentSessionId = null;
195
251
  let currentSessionTitle = 'New Chat';
252
+ let workMode = 'highway';
253
+ let currentContextPlan = null;
254
+ let currentScanSummary = '';
255
+ let currentReadBrief = '';
196
256
 
197
257
  function initSession() {
198
258
  currentSessionId = crypto.randomUUID();
199
259
  currentSessionTitle = currentLang === 'cn' ? '新会话' : 'New Chat';
200
260
  messages = [];
261
+ workMode = 'highway';
262
+ currentContextPlan = null;
263
+ currentScanSummary = '';
264
+ currentReadBrief = '';
201
265
  saveCurrentSession();
202
266
  }
203
267
 
204
- function saveCurrentSession() {
268
+ function saveCurrentSession(allowEmpty = false) {
205
269
  if (!currentSessionId) return;
206
- if (messages.length === 0) return; // Prevent saving empty sessions
270
+ if (!allowEmpty && messages.length === 0) return;
207
271
  const sessionData = {
208
272
  id: currentSessionId,
209
273
  title: currentSessionTitle,
210
274
  timestamp: Date.now(),
211
275
  messages: messages,
212
276
  workspaceRoot: workspaceReady ? workspaceRoot : null,
213
- activeOpenFile: workspaceReady && activeOpenFile ? activeOpenFile : null
277
+ activeOpenFile: workspaceReady && activeOpenFile ? activeOpenFile : null,
278
+ workMode,
279
+ contextPlan: currentContextPlan
214
280
  };
215
281
  saveSession(currentSessionId, sessionData);
216
282
  }
@@ -226,61 +292,22 @@ function redrawScreen(messageText = '') {
226
292
  if (cleanHistory.length > 0) {
227
293
  console.log(currentLang === 'cn' ? '\x1b[90m--- 当前对话内容 (Current Chat) ---\x1b[0m' : '\x1b[90m--- Current Chat Context ---\x1b[0m');
228
294
  cleanHistory.forEach(m => {
229
- let displayContent = sanitizeUntrustedText(stripReasoningBlocks(m.displayContent || m.content));
230
-
231
- if (displayContent) {
232
- if (m.role === 'user') {
233
- console.log(`\x1b[36mUser\x1b[0m: ${displayContent}`);
234
- } else {
235
- console.log(`\x1b[1;38;2;250;100;30mDave\x1b[0m: ${displayContent}`);
236
- }
237
- }
295
+ const displayContent = stripReasoningBlocks(m.displayContent || m.content);
296
+ const rendered = formatConversationMessage({ ...m, displayContent }, {
297
+ color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
298
+ width: Math.max(24, Math.min(process.stdout.columns || 96, 120))
299
+ });
300
+ if (rendered) console.log(`\n${rendered}`);
238
301
  });
239
302
  console.log('\x1b[90m─────────────────────────────────\x1b[0m\n');
240
303
  }
241
304
  }
242
305
 
243
- function getStringWidth(str) {
244
- const cleanStr = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
245
- let width = 0;
246
- for (let i = 0; i < cleanStr.length; i++) {
247
- const code = cleanStr.charCodeAt(i);
248
- if (
249
- (code >= 0x4e00 && code <= 0x9fff) ||
250
- (code >= 0x3400 && code <= 0x4dbf) ||
251
- (code >= 0x3000 && code <= 0x303f) ||
252
- (code >= 0xff00 && code <= 0xffef)
253
- ) {
254
- width += 2;
255
- } else {
256
- width += 1;
257
- }
258
- }
259
- return width;
260
- }
261
-
262
- function centerLine(str, width) {
263
- const strWidth = getStringWidth(str);
264
- if (strWidth >= width) return str;
265
- const leftPad = Math.floor((width - strWidth) / 2);
266
- const rightPad = width - strWidth - leftPad;
267
- return ' '.repeat(leftPad) + str + ' '.repeat(rightPad);
268
- }
269
-
270
- function padLine(str, width) {
271
- const strWidth = getStringWidth(str);
272
- if (strWidth >= width) return str;
273
- return str + ' '.repeat(width - strWidth);
274
- }
275
-
276
- function truncateMiddle(str, width) {
277
- const text = String(str || '');
278
- if (getStringWidth(text) <= width) return text;
279
- if (width <= 6) return text.slice(0, width);
280
- const keep = width - 3;
281
- const left = Math.ceil(keep / 2);
282
- const right = Math.floor(keep / 2);
283
- return text.slice(0, left) + '...' + text.slice(-right);
306
+ function detectInputLanguage(text, fallback = currentLang) {
307
+ const value = String(text || '');
308
+ if (/[\u3400-\u9fff]/.test(value)) return 'cn';
309
+ if (/[A-Za-z]{2,}/.test(value)) return 'en';
310
+ return fallback;
284
311
  }
285
312
 
286
313
  function maskKey(key) {
@@ -293,125 +320,299 @@ function clearConsole() {
293
320
  process.stdout.write('\x1b[2J\x1b[0f');
294
321
  }
295
322
 
296
- async function runConcurrentLimit(items, limit, fn) {
297
- const results = [];
298
- const executing = [];
299
- for (const item of items) {
300
- const p = Promise.resolve().then(() => fn(item));
301
- results.push(p);
302
- if (limit <= items.length) {
303
- const e = p.then(() => executing.splice(executing.indexOf(e), 1));
304
- executing.push(e);
305
- if (executing.length >= limit) {
306
- await Promise.race(executing);
307
- }
323
+ function formatPlanPanelItem(plan, width, compact = false) {
324
+ const meta = {
325
+ ready: { icon: '○', color: '\x1b[36m', cn: '待执行', en: 'READY' },
326
+ running: { icon: '▶', color: '\x1b[33m', cn: '执行中', en: 'RUNNING' },
327
+ completed: { icon: '✓', color: '\x1b[32m', cn: '已完成', en: 'DONE' },
328
+ blocked: { icon: '!', color: '\x1b[31m', cn: '受阻', en: 'BLOCKED' }
329
+ }[plan.status] || { icon: '○', color: '\x1b[36m', cn: '待执行', en: 'READY' };
330
+ const name = sanitizeUntrustedText(plan.name);
331
+ if (compact) return `${meta.icon} ${name}`;
332
+ const label = currentLang === 'cn' ? meta.cn : meta.en;
333
+ const available = Math.max(6, width - getStringWidth(meta.icon) - getStringWidth(label) - 3);
334
+ const shownName = truncateMiddle(name, available);
335
+ const padding = ' '.repeat(Math.max(1, width - getStringWidth(meta.icon) - getStringWidth(shownName) - getStringWidth(label) - 2));
336
+ return `${meta.color}${meta.icon}\x1b[0m ${shownName}${padding}\x1b[90m${label}\x1b[0m`;
337
+ }
338
+
339
+ function notebookStatusLabel(status, lang = currentLang) {
340
+ const cn = lang === 'cn';
341
+ const labels = {
342
+ missing: cn ? '未建立' : 'missing', building: cn ? '建库中' : 'building',
343
+ partial: cn ? '待续建' : 'partial', ready: cn ? '就绪' : 'ready',
344
+ drift: cn ? '有漂移' : 'drift', stale: cn ? '待修复' : 'stale', failed: cn ? '失败' : 'failed'
345
+ };
346
+ return labels[status] || status;
347
+ }
348
+
349
+ function currentNotebookStatus() {
350
+ if (!workspaceReady || workMode !== 'highway') return null;
351
+ return getProjectNotebookStatus(workspaceRoot);
352
+ }
353
+
354
+ function formatNotebookAge(updatedAt) {
355
+ if (!updatedAt) return currentLang === 'cn' ? '从未' : 'never';
356
+ const seconds = Math.max(0, Math.floor((Date.now() - updatedAt) / 1000));
357
+ if (seconds < 60) return currentLang === 'cn' ? '刚刚' : 'just now';
358
+ const minutes = Math.floor(seconds / 60);
359
+ if (minutes < 60) return currentLang === 'cn' ? `${minutes} 分钟前` : `${minutes}m ago`;
360
+ const hours = Math.floor(minutes / 60);
361
+ if (hours < 48) return currentLang === 'cn' ? `${hours} 小时前` : `${hours}h ago`;
362
+ const days = Math.floor(hours / 24);
363
+ return currentLang === 'cn' ? `${days} 天前` : `${days}d ago`;
364
+ }
365
+
366
+ async function confirmLargeNotebookBuild(info, renderer = null) {
367
+ renderer?.pause();
368
+ try {
369
+ const sizeMb = (info.bytes / 1024 / 1024).toFixed(1);
370
+ const prompt = currentLang === 'cn'
371
+ ? `首次项目读取将处理 ${info.files} 个有效文本文件(${sizeMb} MB,约 ${info.estimatedBatches} 个读取批次),并在后台缓存项目认知。是否继续?`
372
+ : `The first project read will process ${info.files} effective text files (${sizeMb} MB, about ${info.estimatedBatches} read batches) and cache project knowledge in the background. Continue?`;
373
+ return await confirmPrompt(`\x1b[33m${prompt}\x1b[0m`);
374
+ } finally { renderer?.resume(); }
375
+ }
376
+
377
+ function cloneReadPlan(plan) {
378
+ return JSON.parse(JSON.stringify(plan));
379
+ }
380
+
381
+ function planTokenTotal(plan) {
382
+ return (plan.files || []).reduce((sum, file) => sum + (Number(file.estimatedTokens) || 0), 0)
383
+ + (Number(plan.notebook?.notebookBudgetTokens) || 0);
384
+ }
385
+
386
+ function printReadPlan(plan, scanUsage = {}) {
387
+ const cn = currentLang === 'cn';
388
+ console.log(cn ? '\n\x1b[1;36mHighway 阅读计划\x1b[0m' : '\n\x1b[1;36mHighway read plan\x1b[0m');
389
+ console.log(cn
390
+ ? ` Scan:${Number(scanUsage.inputTokens || 0).toLocaleString()} 输入 / ${Number(scanUsage.outputTokens || 0).toLocaleString()} 输出`
391
+ : ` Scan: ${Number(scanUsage.inputTokens || 0).toLocaleString()} in / ${Number(scanUsage.outputTokens || 0).toLocaleString()} out`);
392
+ console.log(cn
393
+ ? ` 整轮软预算:${Number(plan.turnBudgetTokens || 0).toLocaleString()} Token · 单次上下文:${Number(plan.contextBudgetTokens || plan.budgetTokens || 0).toLocaleString()}`
394
+ : ` Post-Scan soft budget: ${Number(plan.turnBudgetTokens || 0).toLocaleString()} tokens · per-call context: ${Number(plan.contextBudgetTokens || plan.budgetTokens || 0).toLocaleString()}`);
395
+ console.log(cn
396
+ ? ` 笔记:${plan.notebook?.action || 'reuse'} · ${Number(plan.notebook?.notebookBudgetTokens || 0).toLocaleString()} Token`
397
+ : ` Notebook: ${plan.notebook?.action || 'reuse'} · ${Number(plan.notebook?.notebookBudgetTokens || 0).toLocaleString()} tokens`);
398
+ const files = plan.files || [];
399
+ if (!files.length) console.log(cn ? ' 文件:仅按需读取笔记' : ' Files: notebook only unless evidence is needed');
400
+ for (const [index, file] of files.slice(0, 20).entries()) {
401
+ console.log(` ${index + 1}. [${file.strategy}] ${sanitizeUntrustedText(file.path)}${file.required ? ' *' : ''} · ${Number(file.estimatedTokens || 0).toLocaleString()} · ${sanitizeUntrustedText(file.reason || '')}`);
402
+ }
403
+ if (files.length > 20) console.log(cn ? ` …另有 ${files.length - 20} 个文件` : ` ...and ${files.length - 20} more files`);
404
+ console.log(cn
405
+ ? ` 计划证据估算:约 ${planTokenTotal(plan).toLocaleString()} Token\n`
406
+ : ` Planned evidence estimate: about ${planTokenTotal(plan).toLocaleString()} tokens\n`);
407
+ }
408
+
409
+ function readPlanMenuDescription(plan, scanUsage = {}) {
410
+ const lines = [
411
+ currentLang === 'cn'
412
+ ? `Scan ${Number(scanUsage.inputTokens || 0).toLocaleString()} 输入 / ${Number(scanUsage.outputTokens || 0).toLocaleString()} 输出`
413
+ : `Scan ${Number(scanUsage.inputTokens || 0).toLocaleString()} in / ${Number(scanUsage.outputTokens || 0).toLocaleString()} out`,
414
+ currentLang === 'cn'
415
+ ? `整轮 ${Number(plan.turnBudgetTokens || 0).toLocaleString()} · 单次 ${Number(plan.contextBudgetTokens || plan.budgetTokens || 0).toLocaleString()} · 笔记 ${plan.notebook?.action || 'reuse'} ${Number(plan.notebook?.notebookBudgetTokens || 0).toLocaleString()}`
416
+ : `Turn ${Number(plan.turnBudgetTokens || 0).toLocaleString()} · per-call ${Number(plan.contextBudgetTokens || plan.budgetTokens || 0).toLocaleString()} · notebook ${plan.notebook?.action || 'reuse'} ${Number(plan.notebook?.notebookBudgetTokens || 0).toLocaleString()}`,
417
+ ...(plan.files || []).slice(0, 12).map((file, index) =>
418
+ `${index + 1}. [${file.strategy}] ${file.path}${file.required ? ' *' : ''} · ${Number(file.estimatedTokens || 0).toLocaleString()} · ${file.reason || ''}`),
419
+ ...((plan.files || []).length > 12
420
+ ? [currentLang === 'cn' ? `…另有 ${plan.files.length - 12} 个文件` : `...and ${plan.files.length - 12} more files`]
421
+ : [])
422
+ ];
423
+ return lines.join('\n');
424
+ }
425
+
426
+ function loadProjectInstructions(root) {
427
+ const sections = [];
428
+ for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
429
+ const target = path.join(root, name);
430
+ try {
431
+ if (!fs.statSync(target).isFile()) continue;
432
+ sections.push(`## ${name}\n${fs.readFileSync(target, 'utf8').slice(0, 8192)}`);
433
+ } catch {
434
+ // Missing or unreadable convention files are ignored.
308
435
  }
309
436
  }
310
- return Promise.all(results);
437
+ return sections.join('\n\n');
311
438
  }
312
439
 
313
- async function runFileDigestionWorkflow(filePath, runtime = {}) {
314
- const resolvedPath = path.resolve(filePath);
315
- const content = fs.readFileSync(resolvedPath, 'utf8');
316
- const lines = content.split('\n');
317
- const totalLines = lines.length;
318
-
319
- const chunkSize = 2000;
320
- const chunks = [];
321
- for (let i = 0; i < totalLines; i += chunkSize) {
322
- chunks.push(lines.slice(i, i + chunkSize).join('\n'));
440
+ async function reviewHighwayReadPlan(plan, snapshot, scanUsage, renderer) {
441
+ if (process.env.DAVE_CODE_REVIEW_PLAN !== '1') return { approved: true, plan: cloneReadPlan(plan) };
442
+ const original = cloneReadPlan(plan);
443
+ let current = cloneReadPlan(plan);
444
+ const recommended = Number(original.turnBudgetTokens) || Number(original.budgetTokens) * 3 || 24000;
445
+ const known = new Map((snapshot.files || []).map(file => [file.path.toLowerCase(), file]));
446
+ renderer?.pause();
447
+ try {
448
+ while (true) {
449
+ const selected = await selectMenu(
450
+ currentLang === 'cn' ? '确认 Highway 阅读计划' : 'Confirm Highway read plan',
451
+ readPlanMenuDescription(current, scanUsage),
452
+ [
453
+ { name: currentLang === 'cn' ? '按当前计划开始' : 'Start with this plan', desc: currentLang === 'cn' ? '进入正式 READ' : 'Enter formal READ' },
454
+ { name: currentLang === 'cn' ? '经济预算(70%)' : 'Economy budget (70%)', desc: Math.round(recommended * 0.7).toLocaleString() },
455
+ { name: currentLang === 'cn' ? '推荐预算(100%)' : 'Recommended budget (100%)', desc: recommended.toLocaleString() },
456
+ { name: currentLang === 'cn' ? '充分预算(150%)' : 'Thorough budget (150%)', desc: Math.round(recommended * 1.5).toLocaleString() },
457
+ { name: currentLang === 'cn' ? '自定义预算' : 'Custom budget', desc: currentLang === 'cn' ? '输入累计 Token' : 'Enter cumulative tokens' },
458
+ { name: currentLang === 'cn' ? '修改文件策略' : 'Edit file strategy', desc: 'outline / targeted / full' },
459
+ { name: currentLang === 'cn' ? '增加文件' : 'Add file', desc: currentLang === 'cn' ? '添加快照中的路径' : 'Add a path from the snapshot' },
460
+ { name: currentLang === 'cn' ? '排除文件' : 'Exclude file', desc: currentLang === 'cn' ? '从本轮计划移除' : 'Remove from this turn' },
461
+ { name: currentLang === 'cn' ? '恢复 Scan 推荐' : 'Restore Scan plan', desc: currentLang === 'cn' ? '撤销所有调整' : 'Undo all adjustments' },
462
+ { name: currentLang === 'cn' ? '取消本轮' : 'Cancel turn', desc: currentLang === 'cn' ? '不进入 READ' : 'Do not enter READ' }
463
+ ]
464
+ );
465
+ if (selected === 0) return { approved: true, plan: current };
466
+ if (selected === 1) current.turnBudgetTokens = Math.max(current.contextBudgetTokens || current.budgetTokens, Math.round(recommended * 0.7));
467
+ else if (selected === 2) current.turnBudgetTokens = Math.max(current.contextBudgetTokens || current.budgetTokens, Math.round(recommended));
468
+ else if (selected === 3) current.turnBudgetTokens = Math.max(current.contextBudgetTokens || current.budgetTokens, Math.round(recommended * 1.5));
469
+ else if (selected === 4) {
470
+ const value = Number(await textInputPrompt(currentLang === 'cn' ? '整轮累计预算 Token:' : 'Post-Scan cumulative token budget: '));
471
+ if (Number.isFinite(value) && value >= (current.contextBudgetTokens || current.budgetTokens)) current.turnBudgetTokens = Math.round(value);
472
+ } else if (selected === 5 && current.files.length) {
473
+ const index = await selectMenu(
474
+ currentLang === 'cn' ? '选择文件' : 'Select file',
475
+ currentLang === 'cn' ? '选择后切换读取策略' : 'Choose a new read strategy',
476
+ current.files.map(file => ({ name: file.path, desc: `${file.strategy}${file.required ? ' · required' : ''}` }))
477
+ );
478
+ if (index >= 0) {
479
+ const strategy = await selectMenu('Read strategy', current.files[index].path, [
480
+ { name: 'outline', desc: currentLang === 'cn' ? '仅结构大纲' : 'Structure only' },
481
+ { name: 'targeted', desc: currentLang === 'cn' ? '按需局部读取' : 'Targeted ranges' },
482
+ { name: 'full', desc: currentLang === 'cn' ? '完整读取' : 'Full file' }
483
+ ]);
484
+ if (strategy >= 0) current.files[index].strategy = ['outline', 'targeted', 'full'][strategy];
485
+ }
486
+ } else if (selected === 6) {
487
+ const candidate = (await textInputPrompt(currentLang === 'cn' ? '工作区相对路径:' : 'Workspace-relative path: ')).replace(/\\/g, '/');
488
+ const file = known.get(candidate.toLowerCase());
489
+ if (file && !current.files.some(item => item.path.toLowerCase() === candidate.toLowerCase())) {
490
+ current.files.push({
491
+ path: file.path,
492
+ reason: currentLang === 'cn' ? '用户添加' : 'Added by user',
493
+ strategy: 'targeted',
494
+ required: true,
495
+ estimatedTokens: Math.max(100, Math.ceil(Math.min(file.size, 24000) / 4))
496
+ });
497
+ }
498
+ } else if (selected === 7 && current.files.length) {
499
+ const index = await selectMenu(
500
+ currentLang === 'cn' ? '排除文件' : 'Exclude file',
501
+ currentLang === 'cn' ? '选择要从计划中移除的文件' : 'Choose a file to remove from this plan',
502
+ current.files.map(file => ({ name: file.path, desc: file.required ? 'required' : file.strategy }))
503
+ );
504
+ if (index >= 0) {
505
+ const target = current.files[index];
506
+ const allowed = !target.required || await confirmPrompt(currentLang === 'cn'
507
+ ? `这是 Scan 标记的必需文件,仍要排除 ${target.path}?(y/n): `
508
+ : `Scan marked this file required. Exclude ${target.path} anyway? (y/n): `);
509
+ if (allowed) current.files.splice(index, 1);
510
+ }
511
+ } else if (selected === 8) current = cloneReadPlan(original);
512
+ else if (selected === 9 || selected < 0) return { approved: false, plan: current };
513
+ }
514
+ } finally {
515
+ renderer?.resume();
323
516
  }
324
-
325
- const totalChunks = chunks.length;
326
- let completedCount = 0;
327
- let failedCount = 0;
328
-
329
- const emitProgress = () => {
330
- const percent = totalChunks > 0 ? Math.round((completedCount / totalChunks) * 100) : 100;
331
- runtime.emit?.('tool.progress', {
332
- tool: runtime.toolName || 'READ_FILE',
333
- toolCallId: runtime.toolCallId || '',
334
- target: path.basename(filePath),
335
- current: completedCount,
336
- total: totalChunks,
337
- percent,
338
- failedCount,
339
- label: runtime.lang === 'cn'
340
- ? `正在消化 ${path.basename(filePath)} · ${completedCount}/${totalChunks} 分片`
341
- : `Digesting ${path.basename(filePath)} · ${completedCount}/${totalChunks} chunks`
342
- });
517
+ }
518
+
519
+ function buildReadBrief(plan, notebook) {
520
+ const brief = {
521
+ budget: {
522
+ perCall: plan.contextBudgetTokens || plan.budgetTokens,
523
+ postScan: plan.turnBudgetTokens
524
+ },
525
+ notebook: {
526
+ action: plan.notebook?.action,
527
+ status: notebook?.status || plan.notebook?.status || 'missing',
528
+ maturity: notebook?.maturity || plan.notebook?.maturity || 'baseline',
529
+ sections: Object.keys(notebook?.sections || {}).slice(0, 12)
530
+ },
531
+ files: (plan.files || []).slice(0, 40).map(file => ({
532
+ path: file.path, strategy: file.strategy, required: file.required, reason: file.reason
533
+ }))
343
534
  };
535
+ const text = JSON.stringify(brief);
536
+ if (estimateTokens(text) <= 1200) return text;
537
+ let low = 0;
538
+ let high = text.length;
539
+ while (low < high) {
540
+ const middle = Math.ceil((low + high) / 2);
541
+ if (estimateTokens(text.slice(0, middle)) <= 1200) low = middle;
542
+ else high = middle - 1;
543
+ }
544
+ return `${text.slice(0, low)}\n[Read brief capped at 1200 estimated tokens]`;
545
+ }
344
546
 
345
- emitProgress();
346
-
347
- const tasks = chunks.map((chunkText, idx) => ({ chunkText, idx }));
348
-
349
- const results = await runConcurrentLimit(tasks, 5, async (task) => {
350
- const start = task.idx * chunkSize + 1;
351
- const end = Math.min((task.idx + 1) * chunkSize, totalLines);
352
-
353
- const promptMsg = [
354
- {
355
- role: 'user',
356
- content: `Analyze the following segment (Lines ${start}-${end}) of the file "${path.basename(filePath)}" and write a detailed, dense summary of its contents (key functions/classes/logic if code, or major events/plot/characters if text). Keep the summary highly informative and structured:\n\n${task.chunkText}`
357
- }
358
- ];
359
-
360
- try {
361
- const summary = await getAIResponse(promptMsg);
362
- completedCount++;
363
- emitProgress();
364
- return { idx: task.idx, start, end, summary };
365
- } catch (err) {
366
- completedCount++;
367
- failedCount++;
368
- emitProgress();
369
- return { idx: task.idx, start, end, summary: `[Error reading this segment: ${err.message}]` };
547
+ async function resolveTurnBudgetPressure(plan, usedTokens, renderer) {
548
+ renderer?.pause();
549
+ try {
550
+ const choice = await selectMenu(
551
+ currentLang === 'cn' ? 'Highway Token 预算接近上限' : 'Highway token budget is nearly exhausted',
552
+ currentLang === 'cn'
553
+ ? `已使用 ${usedTokens.toLocaleString()} / ${plan.turnBudgetTokens.toLocaleString()} Token`
554
+ : `Used ${usedTokens.toLocaleString()} / ${plan.turnBudgetTokens.toLocaleString()} tokens`,
555
+ [
556
+ { name: currentLang === 'cn' ? '增加 25%' : 'Add 25%', desc: currentLang === 'cn' ? '继续当前工作流' : 'Continue the workflow' },
557
+ { name: currentLang === 'cn' ? '增加 50%' : 'Add 50%', desc: currentLang === 'cn' ? '为复杂任务增加余量' : 'Add room for complex work' },
558
+ { name: currentLang === 'cn' ? '自定义追加' : 'Custom extension', desc: currentLang === 'cn' ? '输入新增 Token' : 'Enter additional tokens' },
559
+ { name: currentLang === 'cn' ? '基于现有证据收尾' : 'Finish with current evidence', desc: currentLang === 'cn' ? '停止调用工具并简洁回答' : 'Stop tools and answer concisely' },
560
+ { name: currentLang === 'cn' ? '取消本轮' : 'Cancel turn', desc: currentLang === 'cn' ? '保留已经落盘的修改' : 'Keep any already-applied changes' }
561
+ ]
562
+ );
563
+ if (choice === 0) return { action: 'extend', tokens: Math.max(1000, Math.round(plan.turnBudgetTokens * 0.25)) };
564
+ if (choice === 1) return { action: 'extend', tokens: Math.max(1000, Math.round(plan.turnBudgetTokens * 0.5)) };
565
+ if (choice === 2) {
566
+ const tokens = Number(await textInputPrompt(currentLang === 'cn' ? '追加 Token:' : 'Additional tokens: '));
567
+ return Number.isFinite(tokens) && tokens > 0
568
+ ? { action: 'extend', tokens: Math.round(tokens) }
569
+ : { action: 'finish' };
370
570
  }
371
- });
372
-
373
- results.sort((a, b) => a.idx - b.idx);
374
-
375
- let report = `=== ULTRACODE DIGESTION REPORT FOR "${path.basename(filePath)}" ===\n`;
376
- report += `Total Lines: ${totalLines}\n`;
377
- report += `Resolved Path: ${resolvedPath}\n\n`;
378
-
379
- results.forEach(res => {
380
- report += `--- Segment ${res.idx + 1} (Lines ${res.start}-${res.end}) ---\n`;
381
- report += `${res.summary}\n\n`;
382
- });
383
-
384
- report += `=========================================================\n`;
385
- return report;
571
+ if (choice === 3) return { action: 'finish' };
572
+ return { action: 'cancel' };
573
+ } finally {
574
+ renderer?.resume();
575
+ }
386
576
  }
387
577
 
388
578
  function drawHeader() {
389
579
  const logoLines = getLogoLines();
390
580
  const t = locales[currentLang];
391
581
  const active = getActiveProfile();
392
- const effort = getActiveEffort();
393
- const contextStats = getContextStats(messages, effort);
394
- const plans = workspaceReady ? planStatusLines(workspaceRoot, process.stdout.columns && process.stdout.columns < 100 ? 2 : 4) : [];
582
+ const contextStats = getContextStats(messages, currentContextPlan);
583
+ const memory = workspaceReady ? memorySummary(workspaceRoot) : { enabled: true, total: 0 };
584
+ const note = currentNotebookStatus();
585
+ const planLimit = process.stdout.columns && process.stdout.columns < 100 ? 2 : 4;
586
+ const planPanel = workspaceReady
587
+ ? planStatusPanel(workspaceRoot, planLimit)
588
+ : { items: [], total: 0, hidden: 0, counts: { active: 0, completed: 0 } };
589
+ const terminalWidth = Math.max(36, process.stdout.columns || 100);
590
+ // The two-column header scales with the terminal instead of assuming 95
591
+ // columns, so 101-119 column windows no longer leave a ragged right edge.
592
+ const boxWidth = Math.min(terminalWidth - 2, 118);
395
593
  const leftColWidth = 45;
396
- const rightColWidth = 47;
397
- const boxWidth = 95;
594
+ const rightColWidth = Math.max(38, boxWidth - leftColWidth - 3);
398
595
  const activeFileLabel = workspaceReady && activeOpenFile ? truncateMiddle(sanitizeUntrustedText(path.relative(workspaceRoot, activeOpenFile)), rightColWidth - 13) : (currentLang === 'cn' ? '未选择' : 'None');
399
596
  const workspaceLabel = workspaceReady
400
597
  ? truncateMiddle(sanitizeUntrustedText(workspaceRoot), rightColWidth - 11)
401
598
  : (currentLang === 'cn' ? '未关联,请使用 /open' : 'Not linked; use /open');
402
599
  const contextLabel = `${contextStats.modelMessages}/${contextStats.savedMessages} msgs · ~${contextStats.estimatedTokens}/${contextStats.budgetTokens} tokens${contextStats.compacted ? ' · compacted' : ''}`;
403
- const terminalWidth = Math.max(36, process.stdout.columns || 100);
404
600
 
405
601
  if (terminalWidth < 100) {
406
602
  const contentWidth = Math.max(32, terminalWidth - 2);
407
603
  const rule = '─'.repeat(contentWidth);
408
604
  const compactStatus = [
605
+ `${currentLang === 'cn' ? '模式' : 'Mode'}: ${workMode === 'thunder' ? '⚡ Thunder' : '➜ Highway'}`,
409
606
  `${currentLang === 'cn' ? '模型' : 'Model'}: ${active ? active.model : '(not configured)'}`,
410
- `Effort: ${effort}`,
607
+ `${currentLang === 'cn' ? '上下文窗口' : 'Context window'}: ${inferContextWindowTokens(active).toLocaleString()} tokens`,
411
608
  `${currentLang === 'cn' ? '目录' : 'Workspace'}: ${workspaceReady ? truncateMiddle(sanitizeUntrustedText(workspaceRoot), contentWidth - 5) : (currentLang === 'cn' ? '未关联,请使用 /open' : 'Not linked; use /open')}`,
412
609
  `${currentLang === 'cn' ? '文件' : 'File'}: ${workspaceReady && activeOpenFile ? truncateMiddle(sanitizeUntrustedText(path.relative(workspaceRoot, activeOpenFile)), contentWidth - 5) : (currentLang === 'cn' ? '未选择' : 'None')}`,
413
610
  `${currentLang === 'cn' ? '上下文' : 'Context'}: ${contextStats.modelMessages}/${contextStats.savedMessages} · ~${contextStats.estimatedTokens}/${contextStats.budgetTokens}${contextStats.compacted ? (currentLang === 'cn' ? ' · 已压缩' : ' · compacted') : ''}`,
414
- `${currentLang === 'cn' ? '计划' : 'Plans'}: ${plans.length ? plans.join(' | ') : (currentLang === 'cn' ? '' : 'None')}`
611
+ `${currentLang === 'cn' ? '记忆' : 'Memory'}: ${memory.total} ${currentLang === 'cn' ? '条' : 'saved'} · ${memory.enabled ? (currentLang === 'cn' ? '已开启' : 'on') : (currentLang === 'cn' ? '已关闭' : 'off')}`,
612
+ ...(note ? [`${currentLang === 'cn' ? '项目笔记' : 'Note'}: ${notebookStatusLabel(note.status)} · ${note.completed}/${note.files}${note.drift ? ` · ${note.drift} ${currentLang === 'cn' ? '处漂移' : 'drift'}` : ''}`] : []),
613
+ `${currentLang === 'cn' ? '计划' : 'Plans'}: ${planPanel.items.length
614
+ ? `${planPanel.items.map(plan => formatPlanPanelItem(plan, contentWidth, true)).join(' | ')}${planPanel.hidden ? ` | +${planPanel.hidden}` : ''}`
615
+ : (currentLang === 'cn' ? '无' : 'None')}`
415
616
  ];
416
617
 
417
618
  console.log(`\x1b[38;2;250;100;30m${rule}\x1b[0m`);
@@ -425,17 +626,29 @@ function drawHeader() {
425
626
 
426
627
  const rightLines = [
427
628
  currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m状态\x1b[0m' : '\x1b[1;38;2;250;100;30mStatus\x1b[0m',
428
- `Model: ${active ? active.model : '(not configured)'}`,
429
- `Effort: ${effort}`,
430
- `Workspace: ${workspaceLabel}`,
431
- `Active file: ${activeFileLabel}`,
432
- `Context: ${contextLabel}`,
629
+ `${currentLang === 'cn' ? '模式' : 'Mode'}: ${workMode === 'thunder' ? '\x1b[1;33m⚡ Thunder\x1b[0m' : '\x1b[36m➜ Highway\x1b[0m'}`,
630
+ `${currentLang === 'cn' ? '模型' : 'Model'}: ${active ? active.model : (currentLang === 'cn' ? '未配置' : 'not configured')}`,
631
+ `${currentLang === 'cn' ? '阶段' : 'Stage'}: ${currentContextPlan ? 'SCAN ✓ · READ' : (currentLang === 'cn' ? '等待任务' : 'Idle')}`,
632
+ `${currentLang === 'cn' ? '工作区' : 'Workspace'}: ${workspaceLabel}`,
633
+ `${currentLang === 'cn' ? '当前文件' : 'Active file'}: ${activeFileLabel}`,
634
+ `${currentLang === 'cn' ? '上下文' : 'Context'}: ${contextLabel}`,
635
+ currentLang === 'cn'
636
+ ? `记忆: ${memory.total} 条 · ${memory.enabled ? '已开启' : '已关闭'} · /memory`
637
+ : `Memory: ${memory.total} saved · ${memory.enabled ? 'on' : 'off'} · /memory`,
638
+ ...(note ? [currentLang === 'cn'
639
+ ? `笔记: ${notebookStatusLabel(note.status)} · ${note.completed}/${note.files} 文件 · /note`
640
+ : `Note: ${notebookStatusLabel(note.status)} · ${note.completed}/${note.files} files · /note`] : []),
433
641
  '',
434
- currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m计划\x1b[0m' : '\x1b[1;38;2;250;100;30mPlans\x1b[0m',
435
- ...(plans.length ? plans : [currentLang === 'cn' ? '暂无计划' : 'No plans']),
642
+ `${currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m计划\x1b[0m' : '\x1b[1;38;2;250;100;30mPlans\x1b[0m'} ` +
643
+ `\x1b[90m${planPanel.counts.active} ${currentLang === 'cn' ? '未完成' : 'active'} · ${planPanel.counts.completed} ${currentLang === 'cn' ? '已完成' : 'done'}\x1b[0m`,
644
+ ...(planPanel.items.length
645
+ ? planPanel.items.map(plan => formatPlanPanelItem(plan, rightColWidth))
646
+ : [currentLang === 'cn' ? '\x1b[90m暂无计划 · 使用 /plan 创建\x1b[0m' : '\x1b[90mNo plans · create one with /plan\x1b[0m']),
647
+ ...(planPanel.hidden > 0 ? [`\x1b[90m↳ +${planPanel.hidden} more · /plan\x1b[0m`] : []),
436
648
  '',
437
649
  currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m常用命令\x1b[0m' : '\x1b[1;38;2;250;100;30mCommands\x1b[0m',
438
- '/plan /code /open',
650
+ '/mode /plan /code',
651
+ '/agents /open',
439
652
  '/help /history /model',
440
653
  currentLang === 'cn' ? 'Ctrl+C 或 /exit 退出' : 'Ctrl+C or /exit to quit',
441
654
  '',
@@ -444,9 +657,14 @@ function drawHeader() {
444
657
 
445
658
  const boxColor = '\x1b[38;2;250;100;30m';
446
659
  const resetColor = '\x1b[0m';
447
-
448
- const topBorder = boxColor + '┌── ' + resetColor + `\x1b[1mDave Code v${packageVersion}\x1b[0m` + boxColor + ' ' + '─'.repeat(boxWidth - 16 - packageVersion.length) + '┐' + resetColor;
449
- const bottomBorder = boxColor + '└' + '─'.repeat(boxWidth) + '┘' + resetColor;
660
+
661
+ // Every row is "│ " + left + " " + right + " │", so the border must match
662
+ // that exact inner width or the box corners drift as the terminal resizes.
663
+ const innerWidth = leftColWidth + rightColWidth + 3;
664
+ const titleText = `Dave Code v${packageVersion}`;
665
+ const topFill = Math.max(1, innerWidth - displayWidth(titleText) - 4);
666
+ const topBorder = `${boxColor}┌── ${resetColor}\x1b[1m${titleText}\x1b[0m${boxColor} ${'─'.repeat(topFill)}┐${resetColor}`;
667
+ const bottomBorder = `${boxColor}└${'─'.repeat(innerWidth + 2)}┘${resetColor}`;
450
668
 
451
669
  console.log(topBorder);
452
670
 
@@ -467,18 +685,16 @@ function drawHeader() {
467
685
  leftContent = centerLine(`\x1b[90m(No Models / 未配置)\x1b[0m`, leftColWidth);
468
686
  }
469
687
  } else if (i === 19) {
470
- const cwd = process.cwd();
471
- const truncatedCwd = cwd.length > leftColWidth - 2 ? '...' + cwd.slice(-(leftColWidth - 5)) : cwd;
472
- leftContent = centerLine(`\x1b[90m${truncatedCwd}\x1b[0m`, leftColWidth);
688
+ leftContent = centerLine(`\x1b[90m${truncateMiddle(process.cwd(), leftColWidth - 2)}\x1b[0m`, leftColWidth);
473
689
  }
474
690
 
475
691
  const rightContent = padLine(truncateMiddle(rightLines[i] || '', rightColWidth), rightColWidth);
476
692
 
477
693
  console.log(
478
- boxColor + '│ ' + resetColor +
479
- leftContent +
480
- boxColor + ' │ ' + resetColor +
481
- rightContent +
694
+ boxColor + '│ ' + resetColor +
695
+ leftContent +
696
+ boxColor + ' │ ' + resetColor +
697
+ rightContent +
482
698
  boxColor + ' │' + resetColor
483
699
  );
484
700
  }
@@ -495,12 +711,18 @@ async function promptUser() {
495
711
  promptLoopRunning = true;
496
712
 
497
713
  while (true) {
498
- const effort = getActiveEffort();
499
- const effortText = `● ${effort} · /effort`;
714
+ const note = currentNotebookStatus();
715
+ const noteText = note
716
+ ? ` · ${currentLang === 'cn' ? '笔记' : 'Note'} ${notebookStatusLabel(note.status)}`
717
+ : '';
718
+ const statusText = currentContextPlan
719
+ ? `${workMode === 'thunder' ? '⚡ Thunder' : '➜ Highway'} · ${currentLang === 'cn' ? '上下文' : 'Context'} ~${getContextStats(messages, currentContextPlan).estimatedTokens}/${currentContextPlan.budgetTokens}`
720
+ + noteText
721
+ : `${workMode === 'thunder' ? '⚡ Thunder' : '➜ Highway'} · ${currentLang === 'cn' ? '扫描就绪' : 'Scan ready'}${noteText}`;
500
722
  const promptWidth = Math.max(32, process.stdout.columns || 80);
501
- const padding = ' '.repeat(Math.max(1, promptWidth - getStringWidth(effortText) - 1));
502
- const colorCode = effort === 'ultracode' ? '\x1b[33m' : '\x1b[90m';
503
- console.log(padding + colorCode + effortText + '\x1b[0m');
723
+ const padding = ' '.repeat(Math.max(1, promptWidth - getStringWidth(statusText) - 1));
724
+ const colorCode = '\x1b[90m';
725
+ console.log(padding + colorCode + statusText + '\x1b[0m');
504
726
  const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
505
727
  const placeholder = activeOpenFile
506
728
  ? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
@@ -547,7 +769,9 @@ function describeToolRequest(toolName, toolArg, lang) {
547
769
  const target = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
548
770
  const labels = {
549
771
  LIST_DIR: cn ? `准备查看 ${target || '.'}` : `Preparing to list ${target || '.'}`,
772
+ INSPECT_FILE: cn ? `准备分析 ${target} 的结构` : `Preparing to inspect ${target}`,
550
773
  READ_FILE: cn ? `准备读取 ${target}` : `Preparing to read ${target}`,
774
+ READ_NOTEBOOK: cn ? '准备按需读取项目笔记' : 'Preparing to read the project notebook',
551
775
  EDIT_FILE: cn ? `准备局部修改 ${target}` : `Preparing to edit ${target}`,
552
776
  WRITE_FILE: cn ? `准备修改 ${target}` : `Preparing to change ${target}`,
553
777
  SEARCH_GREP: cn ? `准备搜索 “${target}”` : `Preparing to search "${target}"`,
@@ -570,6 +794,59 @@ function launchEditor(filePath) {
570
794
  child.unref();
571
795
  }
572
796
 
797
+ async function handleMemoryMenu() {
798
+ while (true) {
799
+ const summary = memorySummary(workspaceRoot);
800
+ const options = [
801
+ {
802
+ name: currentLang === 'cn'
803
+ ? `自动记忆:${summary.enabled ? '开启' : '关闭'}`
804
+ : `Auto memory: ${summary.enabled ? 'on' : 'off'}`,
805
+ desc: currentLang === 'cn' ? '切换跨聊天记忆提取' : 'Toggle cross-chat memory extraction'
806
+ },
807
+ ...summary.memories.map(memory => ({
808
+ name: `[${memory.category}] ${memory.text}`,
809
+ desc: new Date(memory.updatedAt).toLocaleString(currentLang === 'cn' ? 'zh-CN' : 'en-US')
810
+ })),
811
+ ...(summary.memories.length ? [{
812
+ name: currentLang === 'cn' ? '清空全部记忆' : 'Clear all memories',
813
+ desc: currentLang === 'cn' ? '永久删除当前工作区的自动记忆' : 'Permanently delete workspace memories'
814
+ }] : [])
815
+ ];
816
+ const selected = await selectMenu(
817
+ currentLang === 'cn' ? '工作区记忆' : 'Workspace Memory',
818
+ currentLang === 'cn'
819
+ ? `${summary.total} 条记忆 · 仅保存可跨聊天复用的偏好、约定和项目事实`
820
+ : `${summary.total} memories · durable preferences, conventions, and project facts only`,
821
+ options,
822
+ 0
823
+ );
824
+ if (selected === -1) return;
825
+ if (selected === 0) {
826
+ setMemoryEnabled(workspaceRoot, !summary.enabled);
827
+ continue;
828
+ }
829
+ const memoryIndex = selected - 1;
830
+ if (memoryIndex < summary.memories.length) {
831
+ const memory = summary.memories[memoryIndex];
832
+ const action = await selectMenu(
833
+ currentLang === 'cn' ? '记忆详情' : 'Memory detail',
834
+ memory.text,
835
+ currentLang === 'cn'
836
+ ? [{ name: '删除', desc: '永久删除这条记忆' }, { name: '返回', desc: '保留记忆' }]
837
+ : [{ name: 'Delete', desc: 'Permanently delete this memory' }, { name: 'Back', desc: 'Keep this memory' }],
838
+ 1
839
+ );
840
+ if (action === 0) deleteMemory(workspaceRoot, memory.id);
841
+ continue;
842
+ }
843
+ const confirmed = await confirmPrompt(currentLang === 'cn'
844
+ ? '确认清空当前工作区的全部记忆?(y/n): '
845
+ : 'Clear every memory for this workspace? (y/n): ');
846
+ if (confirmed) clearMemories(workspaceRoot);
847
+ }
848
+ }
849
+
573
850
  async function handlePlanMenu() {
574
851
  while (true) {
575
852
  const plans = listPlans(workspaceRoot);
@@ -657,8 +934,219 @@ function sessionMatchesWorkspace(session, candidateRoot) {
657
934
  return false;
658
935
  }
659
936
 
937
+ function recentConversationForScan(history, maxMessages = 8) {
938
+ return history
939
+ .filter(message => ['user', 'assistant'].includes(message.role) && !message.toolCall)
940
+ .slice(-maxMessages)
941
+ .map(message => `[${message.role}] ${sanitizeUntrustedText(stripReasoningBlocks(message.displayContent || message.content || '')).slice(0, 1200)}`)
942
+ .join('\n');
943
+ }
944
+
660
945
  // ── Main REPL Input Handler ──
661
946
 
947
+ async function chooseWorkMode() {
948
+ return selectWorkModeMenu(workMode, currentLang);
949
+ }
950
+
951
+ async function showThunderTeams() {
952
+ if (!workspaceReady) {
953
+ console.log(currentLang === 'cn' ? '\n请先使用 /open 关联工作区。\n' : '\nUse /open to attach a workspace first.\n');
954
+ return;
955
+ }
956
+ const summary = thunderSummary(workspaceRoot);
957
+ if (!summary.latest) {
958
+ console.log(currentLang === 'cn' ? '\n暂无活跃 Thunder 团队。\n' : '\nNo active Thunder team.\n');
959
+ return;
960
+ }
961
+ const team = summary.latest;
962
+ console.log(`\n\x1b[1;33m⚡ Thunder · ${sanitizeUntrustedText(team.planName)}\x1b[0m`);
963
+ console.log(currentLang === 'cn'
964
+ ? `阶段 ${team.phase} · ${team.members.length} 人 · ${team.resourceProposal.tier} · 并发 ${team.resourceProposal.concurrency}`
965
+ : `Phase ${team.phase} · ${team.members.length} members · ${team.resourceProposal.tier} · concurrency ${team.resourceProposal.concurrency}`);
966
+ for (const member of team.members) {
967
+ const used = (member.contextUsage?.usedTokens || 0).toLocaleString();
968
+ const budget = (member.contextUsage?.budgetTokens || currentContextPlan?.budgetTokens || 0).toLocaleString();
969
+ console.log(` ${member.status === 'done' ? '✓' : member.status === 'blocked' ? '!' : '○'} ${member.name} · ${member.phase || 'reading'} · ${used}/${budget} · ${sanitizeUntrustedText(member.latestReport || member.currentTask || '')}`);
970
+ }
971
+ console.log('');
972
+ }
973
+
974
+ async function runThunderPlanningFlow({ request, requestedName = null, replace = false }) {
975
+ const planName = requestedName || suggestThunderPlanName(request, currentLang);
976
+ const finalName = !requestedName && findPlan(workspaceRoot, planName) ? `${planName} ${new Date().toLocaleTimeString(currentLang === 'cn' ? 'zh-CN' : 'en-US', { hour: '2-digit', minute: '2-digit' })}` : planName;
977
+ const recalled = retrieveMemories(workspaceRoot, request, 8);
978
+ const memoryText = formatMemoriesForPrompt(recalled);
979
+ const runtime = createRuntimeEvents();
980
+ let renderer = createTerminalRenderer({ stdout: process.stdout, lang: currentLang });
981
+ const unsubscribe = runtime.subscribe(event => renderer?.handle(event));
982
+ const abortController = new AbortController();
983
+ const onInterrupt = () => abortController.abort();
984
+ process.once('SIGINT', onInterrupt);
985
+ messages.push({ role: 'user', content: request, displayContent: request });
986
+ saveCurrentSession();
987
+ try {
988
+ runtime.emit('turn.started', { label: currentLang === 'cn' ? '准备 Thunder 工作流' : 'Preparing Thunder workflow' });
989
+ runtime.emit('phase.changed', { phase: 'scan', label: currentLang === 'cn' ? 'SCAN · 扫描项目' : 'SCAN · scanning project' });
990
+ const scan = await runAdaptiveScan({
991
+ workspaceRoot, request, memories: memoryText, conversationContext: recentConversationForScan(messages),
992
+ profile: getActiveProfile(), emit: runtime.emit, signal: abortController.signal, lang: currentLang
993
+ });
994
+ currentContextPlan = scan.contextPlan;
995
+ currentScanSummary = scan.summary;
996
+ saveCurrentSession();
997
+ renderer.dispose();
998
+ renderer = null;
999
+ const result = await runThunderPlanning({
1000
+ workspaceRoot, request, planName: finalName, lang: currentLang, memories: memoryText,
1001
+ contextPlan: scan.contextPlan, scanSummary: scan.summary, scanSnapshot: scan.snapshot,
1002
+ emit: runtime.emit,
1003
+ requestPermission: async request => {
1004
+ renderer?.pause();
1005
+ try {
1006
+ if (request.preview) {
1007
+ const lines = sanitizeUntrustedText(request.preview).split('\n');
1008
+ console.log(lines.slice(0, 30).join('\n'));
1009
+ if (lines.length > 30) console.log(currentLang === 'cn' ? `… 已折叠 ${lines.length - 30} 行预览` : `… ${lines.length - 30} preview lines collapsed`);
1010
+ }
1011
+ return await (request.allowSession ? permissionPrompt : confirmPrompt)(`\x1b[33m${sanitizeUntrustedText(request.prompt)}\x1b[0m`);
1012
+ }
1013
+ finally { renderer?.resume(); }
1014
+ },
1015
+ approveResources: async (proposal, team) => {
1016
+ renderer?.dispose();
1017
+ renderer = createThunderRenderer({
1018
+ stdout: process.stdout, lang: currentLang, team,
1019
+ onMessageRequested: async member => {
1020
+ renderer?.pause();
1021
+ try {
1022
+ const message = await textInputPrompt(currentLang === 'cn'
1023
+ ? `给 ${member.name} 的消息(由 PM 记录和路由): `
1024
+ : `Message for ${member.name} (recorded and routed by PM): `);
1025
+ if (message?.trim()) {
1026
+ addThunderMessage(workspaceRoot, team.id, {
1027
+ from: 'user', to: member.id, type: 'question', summary: message.trim(), refs: [], requiresResponse: true
1028
+ });
1029
+ runtime.emit('message.sent', { teamId: team.id, message: { from: 'user', to: member.id, type: 'question', summary: message.trim() } });
1030
+ }
1031
+ } finally {
1032
+ renderer?.resume();
1033
+ }
1034
+ }
1035
+ });
1036
+ renderer.pause();
1037
+ console.log(`\n\x1b[1;33m${sanitizeUntrustedText(formatThunderResourceProposal(proposal, currentLang))}\x1b[0m\n`);
1038
+ const approved = await confirmPrompt(currentLang === 'cn'
1039
+ ? '批准此资源方案并开始只读团队规划?(y/n): '
1040
+ : 'Approve this resource proposal and start read-only team planning? (y/n): ');
1041
+ if (approved) renderer.resume();
1042
+ return approved;
1043
+ },
1044
+ approvePerformance: async () => confirmPrompt(currentLang === 'cn'
1045
+ ? 'PM 判断这是高难度任务。是否批准高性能档(最多 9 个 Agent、6 个并发请求)?(y/n): '
1046
+ : 'The PM classified this as high difficulty. Approve performance tier (up to 9 agents and 6 concurrent requests)? (y/n): '),
1047
+ signal: abortController.signal
1048
+ });
1049
+ if (result.cancelled) {
1050
+ messages.push({ role: 'assistant', content: currentLang === 'cn' ? 'Thunder 资源方案未获批准,未启动规划。' : 'The Thunder resource proposal was not approved; planning did not start.' });
1051
+ saveCurrentSession();
1052
+ return null;
1053
+ }
1054
+ const plan = upsertPlan(workspaceRoot, {
1055
+ name: finalName, request, content: result.planContent, workflowMode: 'thunder', teamId: result.team.id,
1056
+ resourceProposal: result.team.resourceProposal, teamSnapshot: result.team,
1057
+ taskGraph: result.team.tasks, decisions: result.team.decisions
1058
+ }, { replace });
1059
+ updateThunderTeam(workspaceRoot, result.team.id, { planId: plan.id, planName: plan.name, phase: 'awaiting_code' });
1060
+ messages.push({ role: 'assistant', content: result.planContent });
1061
+ saveCurrentSession();
1062
+ renderer?.dispose();
1063
+ renderer = null;
1064
+ redrawScreen(currentLang === 'cn'
1065
+ ? `\n\x1b[32mThunder 团队计划“${sanitizeUntrustedText(plan.name)}”已保存。输入 /code ${sanitizeUntrustedText(plan.name)} 开始执行。\x1b[0m\n`
1066
+ : `\n\x1b[32mThunder team plan “${sanitizeUntrustedText(plan.name)}” was saved. Run /code ${sanitizeUntrustedText(plan.name)} to execute.\x1b[0m\n`);
1067
+ return plan;
1068
+ } catch (error) {
1069
+ renderer?.dispose();
1070
+ renderer = null;
1071
+ if (abortController.signal.aborted) console.log(currentLang === 'cn' ? '\nThunder 规划已取消。\n' : '\nThunder planning cancelled.\n');
1072
+ else console.log(currentLang === 'cn' ? `\nThunder 规划失败:${sanitizeUntrustedText(error.message)}\n` : `\nThunder planning failed: ${sanitizeUntrustedText(error.message)}\n`);
1073
+ return null;
1074
+ } finally {
1075
+ process.removeListener('SIGINT', onInterrupt);
1076
+ renderer?.dispose();
1077
+ unsubscribe();
1078
+ runtime.close();
1079
+ }
1080
+ }
1081
+
1082
+ async function handleNoteCommand(action) {
1083
+ if (workMode !== 'highway') {
1084
+ console.log(currentLang === 'cn'
1085
+ ? '\n\x1b[33m项目笔记暂仅支持 Highway;Thunder 工作流保持不变。\x1b[0m\n'
1086
+ : '\n\x1b[33mProject notebooks currently support Highway only; Thunder is unchanged.\x1b[0m\n');
1087
+ return;
1088
+ }
1089
+ if (!workspaceReady) {
1090
+ console.log(currentLang === 'cn' ? '\n请先使用 /open 关联工作区。\n' : '\nUse /open to attach a workspace first.\n');
1091
+ return;
1092
+ }
1093
+ if (action === 'status') {
1094
+ const note = getProjectNotebookStatus(workspaceRoot);
1095
+ if (!note.exists) {
1096
+ console.log(currentLang === 'cn'
1097
+ ? '\n\x1b[90m项目笔记尚未建立;下一次 Highway 项目任务会自动建库。\x1b[0m\n'
1098
+ : '\n\x1b[90mNo project notebook exists; the next Highway project task will build it.\x1b[0m\n');
1099
+ return;
1100
+ }
1101
+ const coverage = note.files ? Math.round(note.completed / note.files * 100) : 0;
1102
+ console.log(currentLang === 'cn'
1103
+ ? `\n\x1b[1;36m项目笔记\x1b[0m\n 状态:${notebookStatusLabel(note.status)}\n 覆盖:${note.completed}/${note.files} 文件(${coverage}%)\n 漂移:${note.drift} 个路径\n 更新:${formatNotebookAge(note.updatedAt)}\n`
1104
+ : `\n\x1b[1;36mProject notebook\x1b[0m\n Status: ${notebookStatusLabel(note.status)}\n Coverage: ${note.completed}/${note.files} files (${coverage}%)\n Drift: ${note.drift} paths\n Updated: ${formatNotebookAge(note.updatedAt)}\n`);
1105
+ return;
1106
+ }
1107
+ if (action === 'clear') {
1108
+ const confirmed = await confirmPrompt(currentLang === 'cn'
1109
+ ? '\x1b[31m确认删除此工作区的后台项目笔记?下一次 Highway Scan 将重新建库。\x1b[0m'
1110
+ : '\x1b[31mDelete this workspace notebook? The next Highway Scan will rebuild it.\x1b[0m');
1111
+ if (confirmed) clearProjectNotebook(workspaceRoot);
1112
+ console.log(confirmed
1113
+ ? (currentLang === 'cn' ? '\n\x1b[32m项目笔记已删除。\x1b[0m\n' : '\n\x1b[32mProject notebook deleted.\x1b[0m\n')
1114
+ : (currentLang === 'cn' ? '\n已取消。\n' : '\nCancelled.\n'));
1115
+ return;
1116
+ }
1117
+ if (!hasApiKey()) {
1118
+ console.log(`\n${locales[currentLang].noKeyError}\n`);
1119
+ return;
1120
+ }
1121
+ const runtime = createRuntimeEvents();
1122
+ const renderer = createTerminalRenderer({ stdout: process.stdout, lang: currentLang });
1123
+ const unsubscribe = runtime.subscribe(event => renderer.handle(event));
1124
+ const abortController = new AbortController();
1125
+ const onInterrupt = () => abortController.abort();
1126
+ process.once('SIGINT', onInterrupt);
1127
+ try {
1128
+ runtime.emit('turn.started', { label: currentLang === 'cn' ? '维护后台项目笔记' : 'Maintaining private project notebook' });
1129
+ const snapshot = await buildScanSnapshot(workspaceRoot, '', { emit: runtime.emit, includeOutlines: false, signal: abortController.signal });
1130
+ const existingNote = getProjectNotebookStatus(workspaceRoot);
1131
+ const buildPlan = action === 'rebuild' || !existingNote.exists
1132
+ ? await planHighwayNotebookBuild({
1133
+ snapshot, request: '', emit: runtime.emit, signal: abortController.signal
1134
+ })
1135
+ : null;
1136
+ await ensureHighwayProjectNotebook({
1137
+ workspaceRoot, snapshot, request: '', lang: currentLang, profile: getActiveProfile(), emit: runtime.emit, signal: abortController.signal,
1138
+ mode: action, reason: `manual-${action}`, modelRunner: streamAIResponse,
1139
+ confirmLarge: info => confirmLargeNotebookBuild(info, renderer), buildPlan
1140
+ });
1141
+ runtime.emit('turn.completed', { steps: 1 });
1142
+ } catch (error) {
1143
+ runtime.emit('turn.failed', { error: sanitizeUntrustedText(error.message) });
1144
+ } finally {
1145
+ process.removeListener('SIGINT', onInterrupt);
1146
+ renderer.dispose(); unsubscribe(); runtime.close();
1147
+ }
1148
+ }
1149
+
662
1150
  async function handleInput(input) {
663
1151
  const trimmed = input.trim();
664
1152
  const t = locales[currentLang];
@@ -693,6 +1181,82 @@ async function handleInput(input) {
693
1181
  return;
694
1182
  }
695
1183
 
1184
+ if (trimmed === '/undo') {
1185
+ if (!workspaceReady) console.log(currentLang === 'cn' ? '\n请先使用 /open 关联工作区。\n' : '\nUse /open to attach a workspace first.\n');
1186
+ else {
1187
+ try {
1188
+ const undone = await undoLastMutation(workspaceRoot);
1189
+ console.log(`\n${undone.ok ? '\x1b[32m' : '\x1b[33m'}${sanitizeUntrustedText(undone.message)}\x1b[0m\n`);
1190
+ } catch (error) {
1191
+ console.log(`\n\x1b[31m${sanitizeUntrustedText(error.message)}\x1b[0m\n`);
1192
+ }
1193
+ }
1194
+ promptUser();
1195
+ return;
1196
+ }
1197
+
1198
+ if (/^\/mode(?:\s|$)/.test(trimmed)) {
1199
+ const command = parseInputCommand(trimmed);
1200
+ if (command.type === 'error') {
1201
+ console.log(`\n\x1b[31m${sanitizeUntrustedText(command.error)}\x1b[0m\n`);
1202
+ } else {
1203
+ const selected = command.type === 'mode.menu' ? await chooseWorkMode() : command.mode;
1204
+ if (selected) {
1205
+ const enteringThunder = selected === 'thunder' && workMode !== 'thunder';
1206
+ workMode = selected;
1207
+ saveCurrentSession(true);
1208
+ if (enteringThunder) await runThunderActivationAnimation({ currentLang });
1209
+ redrawScreen(currentLang === 'cn'
1210
+ ? `\n\x1b[32m工作模式已切换为 ${selected === 'thunder' ? '⚡ Thunder' : '➜ Highway'}。\x1b[0m\n`
1211
+ : `\n\x1b[32mWork mode switched to ${selected === 'thunder' ? '⚡ Thunder' : '➜ Highway'}.\x1b[0m\n`);
1212
+ }
1213
+ }
1214
+ promptUser();
1215
+ return;
1216
+ }
1217
+
1218
+ if (/^\/thunder(?:\s|$)/.test(trimmed)) {
1219
+ const command = parseInputCommand(trimmed);
1220
+ if (command.type === 'thunder.usage') {
1221
+ console.log(currentLang === 'cn' ? '\n用法:/thunder <需求>\n' : '\nUsage: /thunder <request>\n');
1222
+ promptUser();
1223
+ return;
1224
+ }
1225
+ const enteringThunder = workMode !== 'thunder';
1226
+ workMode = 'thunder';
1227
+ saveCurrentSession(true);
1228
+ if (enteringThunder) await runThunderActivationAnimation({ currentLang });
1229
+ await handleInput(command.prompt);
1230
+ return;
1231
+ }
1232
+
1233
+ if (trimmed === '/agents') {
1234
+ await showThunderTeams();
1235
+ promptUser();
1236
+ return;
1237
+ }
1238
+
1239
+ if (/^\/note(?:\s|$)/.test(trimmed)) {
1240
+ const command = parseInputCommand(trimmed);
1241
+ if (command.type === 'error') console.log(currentLang === 'cn'
1242
+ ? '\n用法:/note status|refresh|rebuild|clear\n'
1243
+ : '\nUsage: /note status|refresh|rebuild|clear\n');
1244
+ else await handleNoteCommand(command.action);
1245
+ promptUser();
1246
+ return;
1247
+ }
1248
+
1249
+ if (trimmed === '/memory') {
1250
+ if (!workspaceReady) {
1251
+ console.log(currentLang === 'cn' ? '\n请先使用 /open 关联工作区。\n' : '\nUse /open to attach a workspace first.\n');
1252
+ } else {
1253
+ await handleMemoryMenu();
1254
+ redrawScreen();
1255
+ }
1256
+ promptUser();
1257
+ return;
1258
+ }
1259
+
696
1260
  if (trimmed === '/history') {
697
1261
  while (true) {
698
1262
  const sessions = listSessions();
@@ -751,6 +1315,9 @@ async function handleInput(input) {
751
1315
  currentSessionId = chosenSession.id;
752
1316
  currentSessionTitle = chosenSession.title;
753
1317
  messages = chosenSession.messages || [];
1318
+ workMode = chosenSession.workMode === 'thunder' ? 'thunder' : 'highway';
1319
+ currentContextPlan = chosenSession.contextPlan || null;
1320
+ currentScanSummary = '';
754
1321
 
755
1322
  const savedWorkspace = chosenSession.workspaceRoot ? path.resolve(chosenSession.workspaceRoot) : null;
756
1323
  const savedWorkspaceUsable = savedWorkspace && fs.existsSync(savedWorkspace) && fs.statSync(savedWorkspace).isDirectory()
@@ -801,11 +1368,10 @@ async function handleInput(input) {
801
1368
  if (previewContent.length > 150) {
802
1369
  previewContent = previewContent.slice(0, 147) + '...';
803
1370
  }
804
- if (m.role === 'user') {
805
- console.log(`\x1b[36mUser\x1b[0m: ${previewContent}`);
806
- } else {
807
- console.log(`\x1b[1;38;2;250;100;30mDave\x1b[0m: ${previewContent}`);
808
- }
1371
+ console.log(formatConversationMessage({ ...m, displayContent: previewContent }, {
1372
+ color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
1373
+ width: Math.max(24, Math.min(process.stdout.columns || 96, 100))
1374
+ }));
809
1375
  });
810
1376
  console.log('\x1b[90m────────────────────────\x1b[0m\n');
811
1377
  }
@@ -833,15 +1399,46 @@ async function handleInput(input) {
833
1399
  }
834
1400
 
835
1401
  if (trimmed === '/stats' || trimmed === '/tokens') {
836
- console.log(`\n\x1b[1;36mToken Usage Statistics / Token 使用统计:\x1b[0m`);
837
- console.log(` \x1b[90mLast Request (单次请求):\x1b[0m`);
838
- console.log(` Input (输入): ${sessionTokenUsage.lastInputTokens.toLocaleString()}`);
839
- console.log(` Output (输出): ${sessionTokenUsage.lastOutputTokens.toLocaleString()}`);
840
- console.log(` Total (总计): ${sessionTokenUsage.lastTotalTokens.toLocaleString()}`);
841
- console.log(` \x1b[90mSession Cumulative (累计会话):\x1b[0m`);
842
- console.log(` Input (输入): ${sessionTokenUsage.inputTokens.toLocaleString()}`);
843
- console.log(` Output (输出): ${sessionTokenUsage.outputTokens.toLocaleString()}`);
844
- console.log(` Total (总计): ${sessionTokenUsage.totalTokens.toLocaleString()}\n`);
1402
+ if (currentLang === 'cn') {
1403
+ console.log(`\n\x1b[1;36mToken 使用统计\x1b[0m`);
1404
+ console.log(` \x1b[90m本次请求\x1b[0m`);
1405
+ console.log(` 输入:${sessionTokenUsage.lastInputTokens.toLocaleString()}`);
1406
+ console.log(` 输出:${sessionTokenUsage.lastOutputTokens.toLocaleString()}`);
1407
+ console.log(` 总计:${sessionTokenUsage.lastTotalTokens.toLocaleString()}`);
1408
+ console.log(` \x1b[90m当前会话累计\x1b[0m`);
1409
+ console.log(` 输入:${sessionTokenUsage.inputTokens.toLocaleString()}`);
1410
+ console.log(` 输出:${sessionTokenUsage.outputTokens.toLocaleString()}`);
1411
+ console.log(` 总计:${sessionTokenUsage.totalTokens.toLocaleString()}\n`);
1412
+ console.log(` \x1b[90m后台记忆提取:${sessionTokenUsage.memoryTotalTokens.toLocaleString()} Token\x1b[0m\n`);
1413
+ console.log(` \x1b[90m上下文压缩:${sessionTokenUsage.compactionTotalTokens.toLocaleString()} Token · 缓存读取:${sessionTokenUsage.cacheReadInputTokens.toLocaleString()} · 缓存创建:${sessionTokenUsage.cacheCreationInputTokens.toLocaleString()}\x1b[0m\n`);
1414
+ } else {
1415
+ console.log(`\n\x1b[1;36mToken usage\x1b[0m`);
1416
+ console.log(` \x1b[90mLast request\x1b[0m`);
1417
+ console.log(` Input: ${sessionTokenUsage.lastInputTokens.toLocaleString()}`);
1418
+ console.log(` Output: ${sessionTokenUsage.lastOutputTokens.toLocaleString()}`);
1419
+ console.log(` Total: ${sessionTokenUsage.lastTotalTokens.toLocaleString()}`);
1420
+ console.log(` \x1b[90mCurrent session\x1b[0m`);
1421
+ console.log(` Input: ${sessionTokenUsage.inputTokens.toLocaleString()}`);
1422
+ console.log(` Output: ${sessionTokenUsage.outputTokens.toLocaleString()}`);
1423
+ console.log(` Total: ${sessionTokenUsage.totalTokens.toLocaleString()}\n`);
1424
+ console.log(` \x1b[90mBackground memory extraction: ${sessionTokenUsage.memoryTotalTokens.toLocaleString()} tokens\x1b[0m\n`);
1425
+ console.log(` \x1b[90mContext compaction: ${sessionTokenUsage.compactionTotalTokens.toLocaleString()} tokens · cache read: ${sessionTokenUsage.cacheReadInputTokens.toLocaleString()} · cache creation: ${sessionTokenUsage.cacheCreationInputTokens.toLocaleString()}\x1b[0m\n`);
1426
+ }
1427
+ const phases = sessionTokenUsage.lastPhaseUsage;
1428
+ if (phases) {
1429
+ const label = currentLang === 'cn' ? '阶段明细' : 'Phase breakdown';
1430
+ console.log(` \x1b[90m${label}\x1b[0m`);
1431
+ for (const [name, usage] of [
1432
+ ['Scan', phases.scan],
1433
+ ['Notebook', phases.notebook],
1434
+ ['Post-Scan', phases.postScan]
1435
+ ]) {
1436
+ console.log(` ${name}: ${Number(usage?.inputTokens || 0).toLocaleString()} in / ${Number(usage?.outputTokens || 0).toLocaleString()} out / ${Number(usage?.totalTokens || 0).toLocaleString()} total`);
1437
+ }
1438
+ if (phases.approvedPostScanBudget) {
1439
+ console.log(` ${currentLang === 'cn' ? '批准的整轮预算' : 'Approved post-Scan budget'}: ${Number(phases.approvedPostScanBudget).toLocaleString()}\n`);
1440
+ }
1441
+ }
845
1442
  promptUser();
846
1443
  return;
847
1444
  }
@@ -869,16 +1466,11 @@ async function handleInput(input) {
869
1466
  return;
870
1467
  }
871
1468
 
872
- // Handle /effort (adjust effort level slider)
873
- if (trimmed === '/effort') {
874
- const currentEffort = getActiveEffort();
875
- const selected = await selectEffortMenu(currentEffort, currentLang);
876
- if (selected) {
877
- setActiveEffort(selected);
878
- redrawScreen(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
879
- } else {
880
- redrawScreen();
881
- }
1469
+ // Compatibility notice for the removed effort workflow.
1470
+ if (trimmed === '/effort' || trimmed.startsWith('/effort ')) {
1471
+ console.log(currentLang === 'cn'
1472
+ ? '\n\x1b[36m/effort 已移除:上下文预算现在由每轮自适应 Scan 根据项目和需求决定。\x1b[0m\n'
1473
+ : '\n\x1b[36m/effort was removed. Adaptive Scan now chooses the context budget for each project task.\x1b[0m\n');
882
1474
  promptUser();
883
1475
  return;
884
1476
  }
@@ -994,6 +1586,7 @@ async function handleInput(input) {
994
1586
  let historyDisplay = trimmed;
995
1587
  let executingPlan = null;
996
1588
  let pendingPlan = null;
1589
+ let thunderPlanningRequest = null;
997
1590
 
998
1591
  if (/^\/(?:plan|code)(?:\s|$)/.test(trimmed)) {
999
1592
  if (!workspaceReady) {
@@ -1034,18 +1627,37 @@ async function handleInput(input) {
1034
1627
  return;
1035
1628
  }
1036
1629
  }
1630
+ if (workMode === 'thunder') {
1631
+ thunderPlanningRequest = { request: command.request, name: command.name, replace };
1632
+ }
1037
1633
  agentMode = 'plan';
1038
1634
  pendingPlan = { name: command.name, request: command.request, replace };
1039
1635
  agentPrompt = `Create the named implementation plan "${command.name}" for this request:\n${command.request}`;
1040
1636
  } else if (command.type === 'agent' && command.mode === 'code') {
1041
1637
  agentMode = 'code';
1042
1638
  executingPlan = command.plan;
1639
+ if (executingPlan?.workflowMode === 'thunder') workMode = 'thunder';
1043
1640
  agentPrompt = executingPlan
1044
1641
  ? `Execute the saved plan "${executingPlan.name}". Re-read current files before changing them.\n\n${executingPlan.content}`
1045
1642
  : command.prompt;
1046
1643
  }
1047
1644
  }
1048
1645
 
1646
+ if (agentMode === 'chat' && /^(?:你好|您好|嗨|哈喽|hello|hi|hey)[!!。,.\s]*$/i.test(agentPrompt)) {
1647
+ currentLang = detectInputLanguage(agentPrompt, currentLang);
1648
+ const reply = currentLang === 'cn'
1649
+ ? '你好,我在。你可以直接告诉我想了解、规划或修改项目中的什么。'
1650
+ : 'Hello, I’m here. Tell me what you want to understand, plan, or change in the project.';
1651
+ messages.push({ role: 'user', content: agentPrompt, displayContent: historyDisplay });
1652
+ messages.push({ role: 'assistant', content: reply });
1653
+ saveCurrentSession();
1654
+ console.log(formatConversationMessage({ role: 'assistant', content: reply }, {
1655
+ color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR), width: Math.max(24, process.stdout.columns || 80)
1656
+ }));
1657
+ promptUser();
1658
+ return;
1659
+ }
1660
+
1049
1661
  if (!workspaceReady) {
1050
1662
  console.log(currentLang === 'cn'
1051
1663
  ? '\n\x1b[33m当前历史会话没有关联项目目录。请先输入 /open <项目目录>,再继续修改。\x1b[0m\n'
@@ -1062,7 +1674,16 @@ async function handleInput(input) {
1062
1674
  return;
1063
1675
  }
1064
1676
 
1677
+ if (workMode === 'thunder' && (thunderPlanningRequest || agentMode === 'chat')) {
1678
+ const target = thunderPlanningRequest || { request: agentPrompt, name: null, replace: false };
1679
+ currentLang = detectInputLanguage(target.request, currentLang);
1680
+ await runThunderPlanningFlow({ request: target.request, requestedName: target.name, replace: target.replace });
1681
+ promptUser();
1682
+ return;
1683
+ }
1684
+
1065
1685
  // Push user message to history
1686
+ currentLang = detectInputLanguage(historyDisplay || agentPrompt, currentLang);
1066
1687
  messages.push({ role: 'user', content: agentPrompt, displayContent: historyDisplay });
1067
1688
  saveCurrentSession();
1068
1689
 
@@ -1073,16 +1694,39 @@ async function handleInput(input) {
1073
1694
  : `\x1b[90mExecuting plan: ${sanitizeUntrustedText(executingPlan.name)}\x1b[0m\n`);
1074
1695
  }
1075
1696
 
1076
- const activeEffort = getActiveEffort();
1077
- const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
1078
- const maxLoops = preset.maxLoops;
1697
+ const recalledMemories = workspaceReady ? retrieveMemories(workspaceRoot, agentPrompt, 8) : [];
1698
+ const recalledMemoryText = formatMemoriesForPrompt(recalledMemories);
1079
1699
  const turnTokenStart = {
1080
1700
  input: sessionTokenUsage.inputTokens,
1081
1701
  output: sessionTokenUsage.outputTokens
1082
1702
  };
1083
1703
 
1084
1704
  const runtime = createRuntimeEvents();
1085
- const renderer = createTerminalRenderer({ stdout: process.stdout, lang: currentLang });
1705
+ const thunderTeam = executingPlan?.workflowMode === 'thunder'
1706
+ ? (findThunderTeamForPlan(workspaceRoot, executingPlan.id) || (executingPlan.teamId ? getThunderTeam(workspaceRoot, executingPlan.teamId) : null))
1707
+ : null;
1708
+ if (thunderTeam) updateThunderTeam(workspaceRoot, thunderTeam.id, { phase: 'executing' });
1709
+ const pendingThunderDirectives = [];
1710
+ let renderer;
1711
+ renderer = thunderTeam
1712
+ ? createThunderRenderer({
1713
+ stdout: process.stdout, lang: currentLang, team: { ...thunderTeam, phase: 'executing' },
1714
+ onMessageRequested: async member => {
1715
+ renderer?.pause();
1716
+ try {
1717
+ const message = await textInputPrompt(currentLang === 'cn'
1718
+ ? `给 ${member.name} 的消息(由 PM 记录,将在下一协调边界生效): `
1719
+ : `Message for ${member.name} (recorded by PM and applied at the next coordination boundary): `);
1720
+ if (message?.trim()) {
1721
+ addThunderMessage(workspaceRoot, thunderTeam.id, {
1722
+ from: 'user', to: member.id, type: 'question', summary: message.trim(), refs: [], requiresResponse: true
1723
+ });
1724
+ pendingThunderDirectives.push(`[PM-routed user message for ${member.name}] ${message.trim()}`);
1725
+ }
1726
+ } finally { renderer?.resume(); }
1727
+ }
1728
+ })
1729
+ : createTerminalRenderer({ stdout: process.stdout, lang: currentLang });
1086
1730
  const unsubscribe = runtime.subscribe(event => renderer.handle(event));
1087
1731
  const abortController = new AbortController();
1088
1732
  let interrupted = false;
@@ -1095,17 +1739,190 @@ async function handleInput(input) {
1095
1739
  let turnCompleted = false;
1096
1740
  let terminalEventSent = false;
1097
1741
  let compactionShown = false;
1742
+ let compactionSummary = '';
1743
+ let summarizedMessageCount = 0;
1098
1744
  let turnOutcome = 'stopped';
1099
1745
  let planActionCancelled = false;
1746
+ const readTracker = { files: new Map() };
1747
+ const configuredPermission = getActiveProfile()?.permissionPolicy || {};
1748
+ const permissionPolicy = {
1749
+ mode: ['ask', 'acceptEdits', 'allowlist'].includes(configuredPermission.mode) ? configuredPermission.mode : 'ask',
1750
+ rules: Array.isArray(configuredPermission.rules) ? configuredPermission.rules : [],
1751
+ allowedTools: new Set()
1752
+ };
1753
+ let repeatedToolSignature = '';
1754
+ let repeatedToolResultHash = '';
1755
+ let repeatedToolCount = 0;
1756
+ let blockedRepeatedToolSignature = '';
1757
+ const batchToolHistory = new Map();
1758
+ const blockedBatchSignatures = new Set();
1759
+ let planValidationAttempts = 0;
1760
+ let workspaceMayHaveChanged = false;
1761
+ const mutationJournal = [];
1762
+ const phaseUsage = {
1763
+ scan: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
1764
+ notebook: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
1765
+ postScan: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
1766
+ };
1767
+ let readPlanCancelled = false;
1768
+ let budgetCancelled = false;
1769
+ let forceBudgetFinish = false;
1770
+ let forceNoProgressFinish = false;
1771
+ let toolFreeFinishRepairAttempts = 0;
1772
+ let lastBudgetPromptAt = -1;
1773
+
1774
+ function addPhaseUsage(bucket, data = {}) {
1775
+ const target = phaseUsage[bucket];
1776
+ if (!target) return;
1777
+ target.inputTokens += Number(data.inputTokens) || 0;
1778
+ target.outputTokens += Number(data.outputTokens) || 0;
1779
+ target.totalTokens = target.inputTokens + target.outputTokens;
1780
+ }
1781
+
1782
+ async function* trackedModelRunner(modelMessages, options = {}) {
1783
+ for await (const event of streamAIResponse(modelMessages, options)) {
1784
+ if (event.type === 'model.completed') {
1785
+ const bucket = options.usagePhase === 'notebook' ? 'notebook' : 'postScan';
1786
+ addPhaseUsage(bucket, event.data);
1787
+ runtime.emit('usage.phase', { phase: bucket, ...event.data, cumulative: phaseUsage[bucket] });
1788
+ }
1789
+ yield event;
1790
+ }
1791
+ }
1100
1792
 
1101
1793
  runtime.emit('turn.started', {
1102
- label: currentLang === 'cn' ? '思考中 · 正在理解问题' : 'Thinking · understanding the request'
1794
+ label: currentLang === 'cn' ? '准备项目工作流' : 'Preparing project workflow'
1103
1795
  });
1796
+ if (thunderTeam) {
1797
+ runtime.emit('team.started', { team: { ...thunderTeam, phase: 'executing' } });
1798
+ runtime.emit('team.phase', { teamId: thunderTeam.id, phase: 'executing' });
1799
+ const lead = thunderTeam.members.find(member => member.role === 'techLead');
1800
+ if (lead) runtime.emit('member.updated', { teamId: thunderTeam.id, member: { ...lead, status: 'working', currentTask: currentLang === 'cn' ? '执行已批准计划并管理单写入队列' : 'Execute approved plan through the single write queue' } });
1801
+ }
1802
+ if (recalledMemories.length > 0) {
1803
+ runtime.emit('memory.recalled', { count: recalledMemories.length });
1804
+ }
1104
1805
 
1105
1806
  try {
1106
- while (loopCount < maxLoops) {
1107
- const modelMessages = prepareModelMessages(messages, activeEffort);
1108
- const contextWasCompacted = modelMessages.some(message => String(message.content || '').startsWith('[Conversation context compacted:'));
1807
+ runtime.emit('phase.changed', { phase: 'scan', label: currentLang === 'cn' ? 'SCAN · 扫描项目' : 'SCAN · scanning project' });
1808
+ const scan = await runAdaptiveScan({
1809
+ workspaceRoot,
1810
+ request: agentPrompt,
1811
+ memories: recalledMemoryText,
1812
+ conversationContext: recentConversationForScan(messages),
1813
+ profile: getActiveProfile(),
1814
+ lang: currentLang,
1815
+ emit: runtime.emit,
1816
+ signal: abortController.signal,
1817
+ notebookPolicy: workMode === 'highway' ? 'highway' : 'metadata',
1818
+ confirmLargeNotebook: info => confirmLargeNotebookBuild(info, renderer)
1819
+ });
1820
+ currentContextPlan = scan.contextPlan;
1821
+ phaseUsage.scan = { ...phaseUsage.scan, ...(scan.scanUsage || {}) };
1822
+ currentScanSummary = workMode === 'thunder' ? scan.summary : '';
1823
+ if (workMode === 'highway') {
1824
+ runtime.emit('read.plan.presented', {
1825
+ contextPlan: currentContextPlan,
1826
+ scanUsage: scan.scanUsage || {}
1827
+ });
1828
+ const reviewed = await reviewHighwayReadPlan(currentContextPlan, scan.snapshot, scan.scanUsage, renderer);
1829
+ runtime.emit('read.plan.resolved', {
1830
+ approved: reviewed.approved,
1831
+ contextPlan: reviewed.plan
1832
+ });
1833
+ if (!reviewed.approved) {
1834
+ readPlanCancelled = true;
1835
+ throw new Error('READ_PLAN_CANCELLED');
1836
+ }
1837
+ currentContextPlan = reviewed.plan;
1838
+ currentContextPlan.budgetTokens = currentContextPlan.contextBudgetTokens || currentContextPlan.budgetTokens;
1839
+ runtime.emit('phase.changed', {
1840
+ phase: 'read',
1841
+ label: currentLang === 'cn' ? 'READ · 制作并读取项目笔记' : 'READ · preparing project notebook'
1842
+ });
1843
+ const groups = ['full', 'targeted', 'outline'].map((strategy, index) => ({
1844
+ id: `approved-${strategy}`,
1845
+ strategy: strategy === 'targeted' ? 'read' : strategy,
1846
+ paths: (currentContextPlan.files || []).filter(file => file.strategy === strategy).map(file => file.path),
1847
+ reason: 'User-approved Highway read plan.',
1848
+ priority: 100 - index * 20
1849
+ })).filter(group => group.paths.length);
1850
+ const notebookResult = await ensureHighwayProjectNotebook({
1851
+ workspaceRoot,
1852
+ snapshot: scan.snapshot,
1853
+ request: agentPrompt,
1854
+ lang: currentLang,
1855
+ profile: getActiveProfile(),
1856
+ emit: runtime.emit,
1857
+ signal: abortController.signal,
1858
+ confirmLarge: info => confirmLargeNotebookBuild(info, renderer),
1859
+ mode: currentContextPlan.notebook?.action === 'build' && scan.notebook ? 'rebuild'
1860
+ : currentContextPlan.notebook?.action === 'update' ? 'refresh' : 'auto',
1861
+ reason: 'read',
1862
+ notebookBudgetTokens: currentContextPlan.notebook?.notebookBudgetTokens || 2000,
1863
+ notebookSections: currentContextPlan.notebook?.sections || [],
1864
+ buildPlan: {
1865
+ rationale: currentContextPlan.rationale,
1866
+ defaultStrategy: 'outline',
1867
+ groups,
1868
+ validation: {
1869
+ requiredCoveragePercent: 100,
1870
+ requireEntries: true,
1871
+ requireConfigs: true,
1872
+ requireDependencies: true
1873
+ }
1874
+ },
1875
+ modelRunner: trackedModelRunner
1876
+ });
1877
+ currentReadBrief = buildReadBrief(currentContextPlan, notebookResult?.notebook || scan.notebook);
1878
+ } else {
1879
+ currentReadBrief = buildReadBrief(currentContextPlan, null);
1880
+ }
1881
+ if (thunderTeam) updateThunderTeam(workspaceRoot, thunderTeam.id, { scanSnapshot: scan.snapshot, contextPlan: scan.contextPlan });
1882
+ saveCurrentSession();
1883
+ runtime.emit('phase.changed', { phase: 'read', label: currentLang === 'cn' ? 'READ · 正式读取' : 'READ · formal reading' });
1884
+
1885
+ while (!abortController.signal.aborted) {
1886
+ if (loopCount >= 100) {
1887
+ forceNoProgressFinish = true;
1888
+ messages.push({ role: 'user', content: '[Safety limit: 100 agent steps reached. Tools are now disabled. Finish with the best verified result and list any remaining work.]' });
1889
+ }
1890
+ while (pendingThunderDirectives.length) {
1891
+ messages.push({ role: 'user', content: pendingThunderDirectives.shift() });
1892
+ }
1893
+ const activeContextProfile = getActiveProfile();
1894
+ const compacting = shouldCompact(messages, activeContextProfile || currentContextPlan);
1895
+ const archivedCount = Math.max(0, messages.length - 20);
1896
+ if (compacting && (!compactionSummary || archivedCount >= summarizedMessageCount + 10)) {
1897
+ try {
1898
+ compactionSummary = await summarizeForCompaction(messages.slice(0, archivedCount), activeContextProfile, { signal: abortController.signal });
1899
+ summarizedMessageCount = archivedCount;
1900
+ } catch {
1901
+ // prepareModelMessages supplies a deterministic structured fallback.
1902
+ }
1903
+ }
1904
+ const contextOptions = { profile: activeContextProfile, summary: compactionSummary };
1905
+ const modelMessages = prepareModelMessages(messages, currentContextPlan, contextOptions);
1906
+ const contextStats = getContextStats(messages, currentContextPlan, contextOptions);
1907
+ runtime.emit('context.usage', {
1908
+ usedTokens: contextStats.estimatedTokens,
1909
+ rawTokens: contextStats.rawEstimatedTokens,
1910
+ budgetTokens: contextStats.budgetTokens,
1911
+ compacted: contextStats.compacted
1912
+ });
1913
+ const postScanUsed = phaseUsage.notebook.totalTokens + phaseUsage.postScan.totalTokens;
1914
+ const turnBudget = Number(currentContextPlan.turnBudgetTokens) || Number(currentContextPlan.budgetTokens) || 24000;
1915
+ const predictedNextTokens = Math.max(500, Math.min(
1916
+ contextStats.estimatedTokens + (Number(getActiveProfile()?.maxOutputTokens) || 4096),
1917
+ Number(currentContextPlan.contextBudgetTokens || currentContextPlan.budgetTokens) || 24000
1918
+ ));
1919
+ if (!forceBudgetFinish && !forceNoProgressFinish
1920
+ && (postScanUsed >= Math.floor(turnBudget * 0.9) || postScanUsed + predictedNextTokens > turnBudget)
1921
+ && lastBudgetPromptAt < 0) {
1922
+ runtime.emit('budget.warning', { usedTokens: postScanUsed, budgetTokens: turnBudget });
1923
+ lastBudgetPromptAt = postScanUsed;
1924
+ }
1925
+ const contextWasCompacted = modelMessages.some(message => String(message.content || '').startsWith('[Context summary]'));
1109
1926
  if (!compactionShown && contextWasCompacted) {
1110
1927
  runtime.emit('context.compacted', {
1111
1928
  omittedMessages: Math.max(1, messages.length - modelMessages.length + 1),
@@ -1117,12 +1934,36 @@ async function handleInput(input) {
1117
1934
  let activeReply = '';
1118
1935
  const nativeToolCalls = [];
1119
1936
  let modelWasTruncated = false;
1120
- for await (const modelEvent of streamAIResponse(modelMessages, {
1937
+ const finishWithoutTools = forceBudgetFinish || forceNoProgressFinish;
1938
+ for await (const modelEvent of trackedModelRunner(modelMessages, {
1121
1939
  activeOpenFile,
1122
- maxReadLines: preset.maxReadLines,
1123
1940
  signal: abortController.signal,
1124
1941
  mode: agentMode,
1125
- workspaceRoot
1942
+ usagePhase: 'postScan',
1943
+ disableTools: finishWithoutTools,
1944
+ disableToolsReason: forceNoProgressFinish ? 'no-progress' : 'budget',
1945
+ maxOutputTokens: finishWithoutTools
1946
+ ? Math.min(1200, Number(getActiveProfile()?.maxOutputTokens) || 1200)
1947
+ : undefined,
1948
+ notebookEnabled: workMode === 'highway',
1949
+ workspaceRoot,
1950
+ lang: currentLang,
1951
+ projectInstructions: loadProjectInstructions(workspaceRoot),
1952
+ workspaceMemories: recalledMemoryText,
1953
+ contextPlan: currentContextPlan,
1954
+ ...(workMode === 'highway'
1955
+ ? { readBrief: currentReadBrief }
1956
+ : { scanSummary: currentScanSummary }),
1957
+ thunderPrompt: thunderTeam ? buildThunderSystemPrompt('techLead', {
1958
+ language: currentLang,
1959
+ capability: 'code',
1960
+ userRequest: executingPlan.request,
1961
+ approvedPlan: executingPlan.content,
1962
+ assignedTask: currentLang === 'cn' ? '按批准计划执行;统一管理所有文件写入并在完成前验证。' : 'Execute the approved plan, own every workspace write, and validate before completion.',
1963
+ decisions: executingPlan.decisions || [],
1964
+ relevantMemory: recalledMemoryText,
1965
+ resourceLimits: `Tier ${thunderTeam.resourceProposal.tier}; concurrency ${thunderTeam.resourceProposal.concurrency}; single workspace write queue.`
1966
+ }) : ''
1126
1967
  })) {
1127
1968
  if (modelEvent.type === 'model.delta') {
1128
1969
  activeReply += modelEvent.data.text || '';
@@ -1143,6 +1984,41 @@ async function handleInput(input) {
1143
1984
  }
1144
1985
  }
1145
1986
 
1987
+ if (finishWithoutTools) {
1988
+ const finishResolution = resolveBudgetFinishResponse({
1989
+ reply: activeReply,
1990
+ nativeToolCalls,
1991
+ repairAttempts: toolFreeFinishRepairAttempts,
1992
+ lang: currentLang,
1993
+ reason: forceNoProgressFinish ? 'no-progress' : 'budget'
1994
+ });
1995
+ if (finishResolution.action === 'retry') {
1996
+ toolFreeFinishRepairAttempts++;
1997
+ runtime.emit('model.retry', {
1998
+ reason: 'tool-free-finish-protocol',
1999
+ label: currentLang === 'cn'
2000
+ ? '收尾响应错误地请求了工具,正在重新生成纯文本结论'
2001
+ : 'The finish response requested a tool; regenerating a plain-text conclusion'
2002
+ });
2003
+ messages.push({
2004
+ role: 'user',
2005
+ content: '[System notice: The previous finish response was rejected because it attempted a tool call. Tools are unavailable. Do not continue reading and do not output any protocol or markup. Answer now in plain language using only evidence already present in the conversation. Clearly state any limits caused by missing evidence.]'
2006
+ });
2007
+ saveCurrentSession();
2008
+ loopCount++;
2009
+ continue;
2010
+ }
2011
+ if (finishResolution.action === 'fallback') {
2012
+ activeReply = finishResolution.reply;
2013
+ nativeToolCalls.length = 0;
2014
+ modelWasTruncated = false;
2015
+ runtime.emit('model.protocol_blocked', {
2016
+ reason: 'tool-free-finish-protocol',
2017
+ attempts: toolFreeFinishRepairAttempts + 1
2018
+ });
2019
+ }
2020
+ }
2021
+
1146
2022
  if (modelWasTruncated) {
1147
2023
  runtime.emit('model.retry', {
1148
2024
  reason: 'output-truncated',
@@ -1159,8 +2035,18 @@ async function handleInput(input) {
1159
2035
 
1160
2036
  const activeProfile = getActiveProfile();
1161
2037
  let parsedTool = null;
1162
- if (nativeToolCalls.length > 1) {
1163
- parsedTool = { error: 'The model requested multiple tools at once. Request exactly one tool per step.' };
2038
+ if (finishWithoutTools) {
2039
+ parsedTool = null;
2040
+ } else if (nativeToolCalls.length > 1) {
2041
+ const unsupported = nativeToolCalls.find(call => !SUPPORTED_TOOLS.has(call.name));
2042
+ parsedTool = unsupported
2043
+ ? { error: `Unsupported structured tool: ${unsupported.name}` }
2044
+ : { batch: nativeToolCalls.map((call, index) => ({
2045
+ toolName: call.name,
2046
+ toolArg: call.arguments || {},
2047
+ native: true,
2048
+ id: call.id || `${runtime.turnId}-tool-${loopCount + 1}-${index + 1}`
2049
+ })) };
1164
2050
  } else if (nativeToolCalls.length === 1) {
1165
2051
  const call = nativeToolCalls[0];
1166
2052
  parsedTool = SUPPORTED_TOOLS.has(call.name)
@@ -1177,6 +2063,14 @@ async function handleInput(input) {
1177
2063
  }
1178
2064
  }
1179
2065
  if (!parsedTool) {
2066
+ if (pendingThunderDirectives.length > 0) {
2067
+ const interruptedReply = sanitizeUntrustedText(stripReasoningBlocks(activeReply));
2068
+ if (interruptedReply) messages.push({ role: 'assistant', content: interruptedReply });
2069
+ while (pendingThunderDirectives.length) messages.push({ role: 'user', content: pendingThunderDirectives.shift() });
2070
+ saveCurrentSession();
2071
+ loopCount++;
2072
+ continue;
2073
+ }
1180
2074
  let finalReply = sanitizeUntrustedText(stripReasoningBlocks(activeReply));
1181
2075
  if (!finalReply) {
1182
2076
  finalReply = currentLang === 'cn' ? '模型未返回可显示内容。' : 'The model returned no displayable content.';
@@ -1184,7 +2078,8 @@ async function handleInput(input) {
1184
2078
  if (pendingPlan) {
1185
2079
  const missingSections = validatePlanContent(finalReply);
1186
2080
  if (missingSections.length > 0) {
1187
- if (loopCount + 1 >= maxLoops) {
2081
+ planValidationAttempts++;
2082
+ if (planValidationAttempts >= 3) {
1188
2083
  throw new Error(`Plan validation failed: missing ${missingSections.join(', ')}.`);
1189
2084
  }
1190
2085
  messages.push({ role: 'assistant', content: finalReply });
@@ -1194,6 +2089,7 @@ async function handleInput(input) {
1194
2089
  continue;
1195
2090
  }
1196
2091
  }
2092
+ runtime.emit('phase.changed', { phase: 'act', label: currentLang === 'cn' ? 'ACT · 整理结果' : 'ACT · organizing result' });
1197
2093
  runtime.emit('model.delta', { text: finalReply });
1198
2094
  messages.push({ role: 'assistant', content: finalReply });
1199
2095
  saveCurrentSession();
@@ -1208,7 +2104,7 @@ async function handleInput(input) {
1208
2104
 
1209
2105
  if (currentSessionTitle === 'New Chat' || currentSessionTitle === '新会话' || currentSessionTitle === 'New Session') {
1210
2106
  const titleSessionId = currentSessionId;
1211
- const titleMessages = prepareModelMessages(messages.map(message => ({ ...message })), activeEffort);
2107
+ const titleMessages = prepareModelMessages(messages.map(message => ({ ...message })), currentContextPlan);
1212
2108
  generateTitle(titleMessages).then(newTitle => {
1213
2109
  if (currentSessionId === titleSessionId && newTitle && newTitle !== 'New Session') {
1214
2110
  currentSessionTitle = sanitizeUntrustedText(newTitle).slice(0, 80);
@@ -1220,20 +2116,165 @@ async function handleInput(input) {
1220
2116
  }
1221
2117
 
1222
2118
  if (parsedTool.error) {
2119
+ const requestedSeveralTools = /multiple tools at once|exactly one .*invocation/i.test(parsedTool.error);
1223
2120
  runtime.emit('tool.failed', {
1224
2121
  tool: 'TOOL_PARSE',
1225
- displaySummary: currentLang === 'cn' ? '工具格式无法解析' : 'Could not parse tool call',
2122
+ displaySummary: requestedSeveralTools
2123
+ ? (currentLang === 'cn' ? '模型一次请求了多个工具,已要求逐个调用' : 'The model requested several tools; retrying one at a time')
2124
+ : (currentLang === 'cn' ? '工具格式无法解析' : 'Could not parse tool call'),
1226
2125
  error: parsedTool.error
1227
2126
  });
1228
- messages.push({ role: 'user', content: `[Tool Response for TOOL_PARSE:\n${parsedTool.error}]` });
2127
+ messages.push({
2128
+ role: 'user',
2129
+ content: requestedSeveralTools
2130
+ ? '[Tool protocol correction: You requested multiple tools in one response. Request exactly one tool next. Choose the single call most likely to add new evidence; do not repeat a call that already succeeded.]'
2131
+ : `[Tool Response for TOOL_PARSE:\n${parsedTool.error}]`
2132
+ });
1229
2133
  saveCurrentSession();
1230
2134
  loopCount++;
1231
2135
  continue;
1232
2136
  }
1233
2137
 
2138
+ if (parsedTool.batch) {
2139
+ const calls = parsedTool.batch;
2140
+ const executable = [];
2141
+ const completed = [];
2142
+ for (const call of calls) {
2143
+ const signature = createToolCallSignature(call.toolName, call.toolArg);
2144
+ const history = batchToolHistory.get(signature);
2145
+ if (history?.count >= 2) {
2146
+ if (blockedBatchSignatures.has(signature)) forceNoProgressFinish = true;
2147
+ blockedBatchSignatures.add(signature);
2148
+ completed.push({ call, response: { ok: false }, result: `No-progress guard: duplicate ${call.toolName} was not executed; use the cached earlier result or request different evidence.` });
2149
+ } else executable.push(call);
2150
+ }
2151
+ const readOnly = executable.filter(call => !MUTATING_TOOLS.has(call.toolName));
2152
+ const mutations = executable.filter(call => MUTATING_TOOLS.has(call.toolName));
2153
+ for (const call of calls) {
2154
+ runtime.emit('tool.requested', {
2155
+ tool: call.toolName,
2156
+ toolCallId: call.id,
2157
+ displaySummary: describeToolRequest(call.toolName, call.toolArg, currentLang)
2158
+ });
2159
+ }
2160
+ messages.push({
2161
+ role: 'assistant',
2162
+ content: '',
2163
+ toolCalls: calls.map(call => ({ id: call.id, name: call.toolName, arguments: call.toolArg }))
2164
+ });
2165
+ saveCurrentSession();
2166
+
2167
+ const runBatchCall = async call => {
2168
+ const response = await executeToolCall({
2169
+ toolName: call.toolName,
2170
+ toolArg: call.toolArg,
2171
+ toolCallId: call.id,
2172
+ workspaceRoot,
2173
+ emit: runtime.emit,
2174
+ requestPermission: async request => {
2175
+ renderer.pause();
2176
+ try {
2177
+ if (thunderTeam && request.preview) {
2178
+ const lines = sanitizeUntrustedText(request.preview).split('\n');
2179
+ console.log(lines.slice(0, 30).join('\n'));
2180
+ if (lines.length > 30) console.log(currentLang === 'cn' ? `… 已折叠 ${lines.length - 30} 行预览` : `… ${lines.length - 30} preview lines collapsed`);
2181
+ }
2182
+ return await (request.allowSession ? permissionPrompt : confirmPrompt)(`\x1b[33m${sanitizeUntrustedText(request.prompt)}\x1b[0m`);
2183
+ } finally {
2184
+ renderer.resume();
2185
+ }
2186
+ },
2187
+ lang: currentLang,
2188
+ mode: agentMode,
2189
+ signal: abortController.signal,
2190
+ readTracker,
2191
+ permissionPolicy,
2192
+ contextPlan: currentContextPlan,
2193
+ remainingBudgetTokens: Math.max(100, (Number(currentContextPlan.turnBudgetTokens) || 24000) - phaseUsage.notebook.totalTokens - phaseUsage.postScan.totalTokens),
2194
+ readNotebook: workMode === 'highway' ? options => readProjectNotebook(workspaceRoot, options) : async () => null
2195
+ });
2196
+ return { call, response, result: response.modelResult || response.result };
2197
+ };
2198
+
2199
+ completed.push(...await Promise.all(readOnly.map(runBatchCall)));
2200
+ for (const call of mutations) completed.push(await runBatchCall(call));
2201
+ const byId = new Map(completed.map(item => [item.call.id, item]));
2202
+ for (const call of calls) {
2203
+ const item = byId.get(call.id);
2204
+ const toolResult = String(item?.result || 'Tool returned no result.');
2205
+ const signature = createToolCallSignature(call.toolName, call.toolArg);
2206
+ if (!toolResult.startsWith('No-progress guard:')) {
2207
+ const resultHash = crypto.createHash('sha256').update(toolResult).digest('hex');
2208
+ const previous = batchToolHistory.get(signature);
2209
+ batchToolHistory.set(signature, { hash: resultHash, count: previous?.hash === resultHash ? previous.count + 1 : 1 });
2210
+ }
2211
+ if (item?.response?.cancelled) planActionCancelled = true;
2212
+ if (agentMode === 'code' && MUTATING_TOOLS.has(call.toolName) && !item?.response?.cancelled && (item?.response?.ok || call.toolName === 'RUN_COMMAND')) {
2213
+ workspaceMayHaveChanged = true;
2214
+ mutationJournal.push({
2215
+ tool: call.toolName,
2216
+ path: call.toolArg?.path || call.toolArg?.filePath || call.toolArg?.source || '',
2217
+ destination: call.toolArg?.destination || call.toolArg?.to || '',
2218
+ at: Date.now()
2219
+ });
2220
+ }
2221
+ messages.push({ role: 'tool', content: toolResult, toolCallId: call.id, toolName: call.toolName });
2222
+ }
2223
+ saveCurrentSession();
2224
+ if (interrupted) throw new Error('Request cancelled.');
2225
+ loopCount += calls.length;
2226
+ continue;
2227
+ }
2228
+
1234
2229
  const toolName = parsedTool.toolName;
1235
2230
  const toolArg = parsedTool.toolArg;
1236
2231
  const toolCallId = parsedTool.id || `${runtime.turnId}-tool-${loopCount + 1}`;
2232
+ const requestedToolSignature = createToolCallSignature(toolName, toolArg);
2233
+ const repeatedRequest = resolveRepeatedToolRequest({
2234
+ signature: requestedToolSignature,
2235
+ previousSignature: repeatedToolSignature,
2236
+ identicalResultCount: repeatedToolCount,
2237
+ blockedSignature: blockedRepeatedToolSignature
2238
+ });
2239
+ if (repeatedRequest.action !== 'allow') {
2240
+ blockedRepeatedToolSignature = repeatedRequest.blockedSignature;
2241
+ if (repeatedRequest.action === 'redirect') {
2242
+ runtime.emit('model.retry', {
2243
+ reason: 'repeated-tool-call',
2244
+ label: currentLang === 'cn'
2245
+ ? `已阻止重复调用 ${toolName},正在要求改用新的证据路径`
2246
+ : `Blocked repeated ${toolName}; requesting a new evidence path`
2247
+ });
2248
+ messages.push({
2249
+ role: 'user',
2250
+ content: `[No-progress guard: The exact ${toolName} call was not executed because it already returned identical evidence twice. Do not request it again. ${toolName === 'INSPECT_FILE'
2251
+ ? 'Use READ_FILE with a task-relevant line range from the existing outline, inspect a different file, or answer from current evidence.'
2252
+ : 'Change the arguments, choose a different tool, or answer from current evidence.'}]`
2253
+ });
2254
+ } else {
2255
+ forceNoProgressFinish = true;
2256
+ runtime.emit('model.retry', {
2257
+ reason: 'repeated-tool-call-finish',
2258
+ label: currentLang === 'cn'
2259
+ ? '模型仍在重复同一调用,将基于现有证据收尾'
2260
+ : 'The model repeated the same call again; finishing from current evidence'
2261
+ });
2262
+ messages.push({
2263
+ role: 'user',
2264
+ content: '[No-progress guard: You repeated the blocked tool call again. Tools are now disabled for this turn. Give the best concise final answer using only evidence already returned, and identify anything not verified.]'
2265
+ });
2266
+ }
2267
+ saveCurrentSession();
2268
+ loopCount++;
2269
+ continue;
2270
+ }
2271
+ if (toolName === 'RUN_COMMAND') {
2272
+ runtime.emit('phase.changed', { phase: 'verify', label: currentLang === 'cn' ? 'VERIFY · 运行验证' : 'VERIFY · running validation' });
2273
+ } else if (MUTATING_TOOLS.has(toolName)) {
2274
+ runtime.emit('phase.changed', { phase: 'act', label: currentLang === 'cn' ? 'ACT · 准备更改' : 'ACT · preparing change' });
2275
+ } else {
2276
+ runtime.emit('phase.changed', { phase: 'read', label: currentLang === 'cn' ? 'READ · 正式读取' : 'READ · formal reading' });
2277
+ }
1237
2278
  runtime.emit('tool.requested', {
1238
2279
  tool: toolName,
1239
2280
  toolCallId,
@@ -1253,10 +2294,15 @@ async function handleInput(input) {
1253
2294
  toolCallId,
1254
2295
  workspaceRoot,
1255
2296
  emit: runtime.emit,
1256
- requestPermission: async ({ prompt }) => {
2297
+ requestPermission: async request => {
1257
2298
  renderer.pause();
1258
2299
  try {
1259
- return await confirmPrompt(`\x1b[33m${sanitizeUntrustedText(prompt)}\x1b[0m`);
2300
+ if (thunderTeam && request.preview) {
2301
+ const lines = sanitizeUntrustedText(request.preview).split('\n');
2302
+ console.log(lines.slice(0, 30).join('\n'));
2303
+ if (lines.length > 30) console.log(currentLang === 'cn' ? `… 已折叠 ${lines.length - 30} 行预览` : `… ${lines.length - 30} preview lines collapsed`);
2304
+ }
2305
+ return await (request.allowSession ? permissionPrompt : confirmPrompt)(`\x1b[33m${sanitizeUntrustedText(request.prompt)}\x1b[0m`);
1260
2306
  } finally {
1261
2307
  renderer.resume();
1262
2308
  }
@@ -1264,11 +2310,39 @@ async function handleInput(input) {
1264
2310
  lang: currentLang,
1265
2311
  mode: agentMode,
1266
2312
  signal: abortController.signal,
1267
- getActiveEffort,
1268
- effortPresets: EFFORT_PRESETS,
1269
- runFileDigestionWorkflow
2313
+ readTracker,
2314
+ permissionPolicy,
2315
+ contextPlan: currentContextPlan,
2316
+ remainingBudgetTokens: Math.max(
2317
+ 100,
2318
+ (Number(currentContextPlan.turnBudgetTokens) || 24000)
2319
+ - phaseUsage.notebook.totalTokens
2320
+ - phaseUsage.postScan.totalTokens
2321
+ ),
2322
+ readNotebook: workMode === 'highway'
2323
+ ? options => readProjectNotebook(workspaceRoot, options)
2324
+ : async () => null
1270
2325
  });
1271
2326
  const toolResult = toolResponse.modelResult || toolResponse.result;
2327
+ if (agentMode === 'code' && MUTATING_TOOLS.has(toolName) && !toolResponse.cancelled
2328
+ && (toolResponse.ok || toolName === 'RUN_COMMAND')) {
2329
+ workspaceMayHaveChanged = true;
2330
+ mutationJournal.push({
2331
+ tool: toolName,
2332
+ path: toolArg?.path || toolArg?.filePath || toolArg?.source || '',
2333
+ destination: toolArg?.destination || toolArg?.to || '',
2334
+ at: Date.now()
2335
+ });
2336
+ }
2337
+ const signature = requestedToolSignature;
2338
+ const resultHash = crypto.createHash('sha256').update(String(toolResult || '')).digest('hex');
2339
+ if (signature === repeatedToolSignature && resultHash === repeatedToolResultHash) repeatedToolCount++;
2340
+ else {
2341
+ repeatedToolCount = 1;
2342
+ blockedRepeatedToolSignature = '';
2343
+ }
2344
+ repeatedToolSignature = signature;
2345
+ repeatedToolResultHash = resultHash;
1272
2346
  if (toolResponse.cancelled) planActionCancelled = true;
1273
2347
  messages.push({ role: 'tool', content: String(toolResult || ''), toolCallId, toolName });
1274
2348
  saveCurrentSession();
@@ -1277,25 +2351,29 @@ async function handleInput(input) {
1277
2351
  }
1278
2352
 
1279
2353
  if (turnCompleted) {
2354
+ runtime.emit('phase.changed', { phase: 'verify', label: currentLang === 'cn' ? 'VERIFY · 完成检查' : 'VERIFY · final checks' });
1280
2355
  runtime.emit('turn.completed', { steps: loopCount });
1281
2356
  terminalEventSent = true;
1282
2357
  turnOutcome = planActionCancelled ? 'cancelled' : 'completed';
1283
- } else if (loopCount >= maxLoops) {
1284
- runtime.emit('turn.failed', {
1285
- error: currentLang === 'cn'
1286
- ? `已达到 ${maxLoops} 步上限,请提高 effort 或缩小任务范围。`
1287
- : `Reached the ${maxLoops}-step limit. Raise effort or narrow the task.`
1288
- });
1289
- terminalEventSent = true;
1290
- turnOutcome = 'blocked';
1291
2358
  }
1292
2359
  } catch (error) {
1293
- if (interrupted || abortController.signal.aborted) {
2360
+ if (readPlanCancelled || budgetCancelled || interrupted || abortController.signal.aborted) {
1294
2361
  runtime.emit('turn.cancelled', {
1295
- reason: currentLang === 'cn' ? '已取消当前操作' : 'Current operation cancelled'
2362
+ reason: readPlanCancelled
2363
+ ? (currentLang === 'cn' ? '阅读计划已取消,未进入正式读取' : 'Read plan cancelled before formal reading')
2364
+ : budgetCancelled
2365
+ ? (currentLang === 'cn' ? '已按 Token 预算选择取消本轮' : 'Turn cancelled at the token budget boundary')
2366
+ : (currentLang === 'cn' ? '已取消当前操作' : 'Current operation cancelled')
1296
2367
  });
1297
2368
  terminalEventSent = true;
1298
2369
  turnOutcome = 'cancelled';
2370
+ if (readPlanCancelled) {
2371
+ messages.push({
2372
+ role: 'assistant',
2373
+ content: currentLang === 'cn' ? '阅读计划已取消,未进入正式读取。' : 'The read plan was cancelled before formal reading.'
2374
+ });
2375
+ saveCurrentSession();
2376
+ }
1299
2377
  } else {
1300
2378
  if (loopCount === 0) {
1301
2379
  messages.pop();
@@ -1315,30 +2393,121 @@ async function handleInput(input) {
1315
2393
  runtime.close();
1316
2394
  }
1317
2395
 
2396
+ // Code changes are already authorized and durable at this point. Notebook
2397
+ // maintenance is best-effort and must run even after a later failure/cancel.
2398
+ if (workMode === 'highway' && agentMode === 'code' && workspaceMayHaveChanged
2399
+ && getProjectNotebookStatus(workspaceRoot).exists) {
2400
+ const noteRuntime = createRuntimeEvents();
2401
+ const noteRenderer = createTerminalRenderer({ stdout: process.stdout, lang: currentLang });
2402
+ const stopNoteRender = noteRuntime.subscribe(event => noteRenderer.handle(event));
2403
+ const noteAbortController = new AbortController();
2404
+ const onNoteInterrupt = () => noteAbortController.abort();
2405
+ process.once('SIGINT', onNoteInterrupt);
2406
+ try {
2407
+ noteRuntime.emit('turn.started', { label: currentLang === 'cn' ? '同步后台项目笔记' : 'Synchronizing project notebook' });
2408
+ const snapshot = await buildScanSnapshot(workspaceRoot, '', { emit: () => {}, includeOutlines: false, signal: noteAbortController.signal });
2409
+ await ensureHighwayProjectNotebook({
2410
+ workspaceRoot, snapshot, request: agentPrompt, lang: currentLang, profile: getActiveProfile(),
2411
+ emit: noteRuntime.emit, mode: 'refresh', reason: 'dave-code', modelRunner: trackedModelRunner,
2412
+ notebookBudgetTokens: Math.max(400, Math.min(
2413
+ 2000,
2414
+ (Number(currentContextPlan?.turnBudgetTokens) || 24000)
2415
+ - phaseUsage.notebook.totalTokens
2416
+ - phaseUsage.postScan.totalTokens
2417
+ )),
2418
+ mutationJournal, signal: noteAbortController.signal
2419
+ });
2420
+ noteRuntime.emit('turn.completed', { steps: 1 });
2421
+ } catch (error) {
2422
+ markProjectNotebookStale(workspaceRoot, `Dave code update failed: ${error.message}`);
2423
+ noteRuntime.emit('note.failed', { error: sanitizeUntrustedText(error.message), status: 'stale' });
2424
+ noteRuntime.emit('turn.failed', { error: currentLang === 'cn' ? '代码已保存,但项目笔记更新失败;下次 Scan 会优先修复。' : 'Code was saved, but notebook update failed; the next Scan will repair it.' });
2425
+ } finally {
2426
+ process.removeListener('SIGINT', onNoteInterrupt);
2427
+ noteRenderer.dispose(); stopNoteRender(); noteRuntime.close();
2428
+ }
2429
+ }
2430
+
2431
+ let thunderReviews = [];
2432
+ if (thunderTeam) {
2433
+ if (turnOutcome === 'completed') {
2434
+ const reviewRuntime = createRuntimeEvents();
2435
+ const reviewRenderer = createThunderRenderer({ stdout: process.stdout, lang: currentLang, team: { ...thunderTeam, phase: 'reviewing' } });
2436
+ const stopReviewRender = reviewRuntime.subscribe(event => reviewRenderer.handle(event));
2437
+ try {
2438
+ const executionSummary = messages.slice(-12).map(message => {
2439
+ if (message.role === 'tool') return `[${message.toolName || 'tool'}] ${String(message.content || '').slice(0, 1200)}`;
2440
+ return `[${message.role}] ${String(message.content || '').slice(0, 1600)}`;
2441
+ }).join('\n\n');
2442
+ const reviewResult = await runThunderPostReview({
2443
+ workspaceRoot, team: getThunderTeam(workspaceRoot, thunderTeam.id) || thunderTeam,
2444
+ planContent: executingPlan.content, executionSummary, lang: currentLang,
2445
+ memories: recalledMemoryText, emit: reviewRuntime.emit,
2446
+ contextPlan: currentContextPlan, scanSummary: currentScanSummary,
2447
+ requestPermission: async request => {
2448
+ reviewRenderer.pause();
2449
+ try {
2450
+ if (request.preview) console.log(sanitizeUntrustedText(request.preview).split('\n').slice(0, 30).join('\n'));
2451
+ return await (request.allowSession ? permissionPrompt : confirmPrompt)(`\x1b[33m${sanitizeUntrustedText(request.prompt)}\x1b[0m`);
2452
+ }
2453
+ finally { reviewRenderer.resume(); }
2454
+ }
2455
+ });
2456
+ thunderReviews = reviewResult.reports;
2457
+ if (reviewResult.blocked) turnOutcome = 'blocked';
2458
+ } catch (error) {
2459
+ thunderReviews = [{ role: 'review', report: error.message }];
2460
+ updateThunderTeam(workspaceRoot, thunderTeam.id, { phase: 'blocked' });
2461
+ turnOutcome = 'blocked';
2462
+ } finally {
2463
+ reviewRenderer.dispose();
2464
+ stopReviewRender();
2465
+ reviewRuntime.close();
2466
+ }
2467
+ if (thunderReviews.length) {
2468
+ console.log(currentLang === 'cn' ? '\n\x1b[1;33mThunder 最终复核\x1b[0m' : '\n\x1b[1;33mThunder final review\x1b[0m');
2469
+ for (const review of thunderReviews) console.log(` ${review.role}: ${sanitizeUntrustedText(review.report).replace(/\s+/g, ' ').slice(0, 300)}`);
2470
+ console.log('');
2471
+ }
2472
+ } else {
2473
+ updateThunderTeam(workspaceRoot, thunderTeam.id, { phase: turnOutcome === 'cancelled' ? 'cancelled' : 'blocked' });
2474
+ }
2475
+ }
2476
+
1318
2477
  if (executingPlan) {
1319
2478
  updatePlan(workspaceRoot, executingPlan.id, {
1320
2479
  status: turnOutcome === 'completed' ? 'completed' : turnOutcome === 'cancelled' ? 'ready' : 'blocked',
1321
2480
  lastRun: {
1322
2481
  ...(executingPlan.lastRun || {}),
1323
2482
  finishedAt: Date.now(),
1324
- outcome: turnOutcome
1325
- }
2483
+ outcome: turnOutcome,
2484
+ reviews: thunderReviews
2485
+ },
2486
+ ...(thunderTeam ? { teamSnapshot: getThunderTeam(workspaceRoot, thunderTeam.id) || thunderTeam } : {})
1326
2487
  });
1327
2488
  }
1328
2489
 
2490
+ if (turnOutcome === 'completed' && workspaceReady && memorySummary(workspaceRoot).enabled) {
2491
+ const memoryWorkspace = workspaceRoot;
2492
+ const memorySessionId = currentSessionId;
2493
+ const memoryMessages = messages.map(message => ({ ...message }));
2494
+ extractMemoryCandidates(memoryMessages)
2495
+ .then(candidates => mergeMemoryCandidates(memoryWorkspace, candidates, memorySessionId))
2496
+ .catch(() => {});
2497
+ }
2498
+
1329
2499
  sessionTokenUsage.lastInputTokens = Math.max(0, sessionTokenUsage.inputTokens - turnTokenStart.input);
1330
2500
  sessionTokenUsage.lastOutputTokens = Math.max(0, sessionTokenUsage.outputTokens - turnTokenStart.output);
1331
2501
  sessionTokenUsage.lastTotalTokens = sessionTokenUsage.lastInputTokens + sessionTokenUsage.lastOutputTokens;
2502
+ sessionTokenUsage.lastPhaseUsage = {
2503
+ ...phaseUsage,
2504
+ approvedPostScanBudget: Number(currentContextPlan?.turnBudgetTokens) || 0
2505
+ };
1332
2506
 
1333
2507
  if (sessionTokenUsage.lastTotalTokens > 0) {
1334
- console.log(
1335
- `\x1b[90mTokens: ${sessionTokenUsage.lastInputTokens.toLocaleString()} input, ` +
1336
- `${sessionTokenUsage.lastOutputTokens.toLocaleString()} output ` +
1337
- `(${sessionTokenUsage.lastTotalTokens.toLocaleString()} total) | ` +
1338
- `Session Total: ${sessionTokenUsage.inputTokens.toLocaleString()} input, ` +
1339
- `${sessionTokenUsage.outputTokens.toLocaleString()} output ` +
1340
- `(${sessionTokenUsage.totalTokens.toLocaleString()} total)\x1b[0m\n`
1341
- );
2508
+ console.log(currentLang === 'cn'
2509
+ ? `\x1b[90m本次:${sessionTokenUsage.lastInputTokens.toLocaleString()} 输入 · ${sessionTokenUsage.lastOutputTokens.toLocaleString()} 输出 · ${sessionTokenUsage.lastTotalTokens.toLocaleString()} 总计 │ 会话:${sessionTokenUsage.totalTokens.toLocaleString()} 总计\x1b[0m\n`
2510
+ : `\x1b[90mTurn: ${sessionTokenUsage.lastInputTokens.toLocaleString()} in · ${sessionTokenUsage.lastOutputTokens.toLocaleString()} out · ${sessionTokenUsage.lastTotalTokens.toLocaleString()} total │ Session: ${sessionTokenUsage.totalTokens.toLocaleString()} total\x1b[0m\n`);
1342
2511
  }
1343
2512
 
1344
2513
  if (pendingPlan || executingPlan) {
@@ -1347,6 +2516,7 @@ async function handleInput(input) {
1347
2516
  : '\x1b[90mPlan status updated.\x1b[0m\n');
1348
2517
  }
1349
2518
 
2519
+ await closeWorkspaceShell(workspaceRoot);
1350
2520
  promptUser();
1351
2521
  }
1352
2522