dave-code 1.0.4 → 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
@@ -10,12 +10,14 @@
10
10
 
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
+ import crypto from 'crypto';
13
14
 
14
15
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
15
16
  const packageVersion = pkg.version;
16
- import { exec } from 'child_process';
17
+ import { spawn } from 'child_process';
17
18
  import { getLogoLines } from './logoRenderer.js';
18
- import { hasApiKey, getAIResponse, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
19
+ import { hasApiKey, getAIResponse, streamAIResponse, generateTitle, extractMemoryCandidates, summarizeForCompaction, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
20
+ import { listSessions, saveSession, deleteSession } from './sessionManager.js';
19
21
  import {
20
22
  CONFIG_FILE,
21
23
  getProfiles,
@@ -23,11 +25,10 @@ import {
23
25
  setActiveProfile,
24
26
  updateProfile,
25
27
  openConfigFileInEditor,
26
- getActiveEffort,
27
- setActiveEffort,
28
- EFFORT_PRESETS,
28
+ inferContextWindowTokens,
29
29
  isFirstLaunch,
30
- setInitialized
30
+ setInitialized,
31
+ lastConfigError
31
32
  } from './configManager.js';
32
33
  import { runWelcomeAnimation } from './install.js';
33
34
  import {
@@ -35,10 +36,73 @@ import {
35
36
  secureInputPrompt,
36
37
  chatInputPrompt,
37
38
  waitForEnter,
38
- startSpinner,
39
- selectEffortMenu,
40
- confirmPrompt
39
+ selectWorkModeMenu,
40
+ runThunderActivationAnimation,
41
+ confirmPrompt,
42
+ permissionPrompt,
43
+ textInputPrompt
41
44
  } from './cliMenu.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';
68
+ import { createRuntimeEvents } from './runtimeEvents.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';
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';
96
+ import {
97
+ deletePlan,
98
+ findPlan,
99
+ listPlans,
100
+ planStatusPanel,
101
+ renamePlan,
102
+ updatePlan,
103
+ upsertPlan,
104
+ validatePlanContent
105
+ } from './planManager.js';
42
106
 
43
107
  // Localization Dictionary
44
108
  const locales = {
@@ -71,13 +135,21 @@ const locales = {
71
135
  helpLines: [
72
136
  ' /help - 显示此帮助信息',
73
137
  ' /clear - 清屏并重置主面板',
74
- ' /reset - 重置对话历史',
75
- ' /stats - 显示当前会话的 Token 使用统计',
76
- ' /lang - 切换中英文界面',
138
+ ' /reset - 重置对话历史并新建会话',
139
+ ' /history - 查看并载入历史聊天记录',
140
+ ' /stats - 显示当前会话的 Token 使用统计 (别名: /tokens)',
141
+ ' /lang - 切换中英文界面 (别名: /language)',
77
142
  ' /config - 编辑本地配置文件 (~/.dave-code-config.json)',
78
143
  ' /model - 快捷切换当前激活的服务配置',
79
144
  ' /api - 快捷修改当前配置的 API 密钥',
80
- ' /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 后台项目笔记',
150
+ ' /plan 名称: 需求 - 创建只读实施计划;/plan 管理已有计划',
151
+ ' /code <需求或计划名> - 单次授权编程修改,仍逐项确认',
152
+ ' /undo - 回滚本进程内最近一次文件变更',
81
153
  ' /open - 打开文件或文件夹并在其中工作',
82
154
  ' /exit - 退出 Dave Code 代理'
83
155
  ],
@@ -97,7 +169,7 @@ const locales = {
97
169
  保存并关闭文件后,请在下方按 \x1b[1;32m[回车 (Enter)]\x1b[0m 键重载配置。`,
98
170
  reloadingConfigMsg: '\n\x1b[32m正在重载配置...\x1b[0m\n',
99
171
  openDirSuccess: '已切换工作目录至: ',
100
- openFileSuccess: '已在编辑器中打开文件,并将其内容加载至 AI 上下文。',
172
+ openFileSuccess: '已在编辑器中打开现有文件。Dave 将按需读取最新内容。',
101
173
  openPathError: '路径不存在或无法访问:',
102
174
  openUsagePrompt: '使用方法:/open <文件或文件夹路径>'
103
175
  },
@@ -130,13 +202,21 @@ const locales = {
130
202
  helpLines: [
131
203
  ' /help - Show this help message',
132
204
  ' /clear - Clear screen and refresh home panel',
133
- ' /reset - Reset conversation history',
134
- ' /stats - Show session token usage statistics',
135
- ' /lang - Switch language between Chinese and English',
205
+ ' /reset - Reset conversation history and start a new session',
206
+ ' /history - View and resume past chat sessions',
207
+ ' /stats - Show session token usage statistics (alias: /tokens)',
208
+ ' /lang - Switch language between Chinese and English (alias: /language)',
136
209
  ' /config - Open and edit config file (~/.dave-code-config.json)',
137
210
  ' /model - Interactively switch active service profile',
138
211
  ' /api - Interactively set API key for active profile',
139
- ' /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',
217
+ ' /plan name: request - Create a read-only implementation plan; /plan manages plans',
218
+ ' /code <request or plan> - Authorize one confirmed coding turn',
219
+ ' /undo - Undo the most recent file mutation in this process',
140
220
  ' /open - Open a file or directory to work in',
141
221
  ' /exit - Exit the Dave Code agent'
142
222
  ],
@@ -156,7 +236,7 @@ Please edit and save the file in your editor.
156
236
  Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload configuration.`,
157
237
  reloadingConfigMsg: '\n\x1b[32mReloading configuration...\x1b[0m\n',
158
238
  openDirSuccess: 'Switched working directory to: ',
159
- openFileSuccess: 'Opened file in editor and loaded its content into AI context.',
239
+ openFileSuccess: 'Opened the existing file. Dave will read current content on demand.',
160
240
  openPathError: 'Path does not exist or is not accessible: ',
161
241
  openUsagePrompt: 'Usage: /open <file or folder path>'
162
242
  }
@@ -165,38 +245,69 @@ Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload confi
165
245
  let currentLang = 'cn';
166
246
  let messages = [];
167
247
  let activeOpenFile = null;
248
+ let workspaceRoot = process.cwd();
249
+ let workspaceReady = true;
250
+ let currentSessionId = null;
251
+ let currentSessionTitle = 'New Chat';
252
+ let workMode = 'highway';
253
+ let currentContextPlan = null;
254
+ let currentScanSummary = '';
255
+ let currentReadBrief = '';
256
+
257
+ function initSession() {
258
+ currentSessionId = crypto.randomUUID();
259
+ currentSessionTitle = currentLang === 'cn' ? '新会话' : 'New Chat';
260
+ messages = [];
261
+ workMode = 'highway';
262
+ currentContextPlan = null;
263
+ currentScanSummary = '';
264
+ currentReadBrief = '';
265
+ saveCurrentSession();
266
+ }
168
267
 
169
- function getStringWidth(str) {
170
- const cleanStr = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
171
- let width = 0;
172
- for (let i = 0; i < cleanStr.length; i++) {
173
- const code = cleanStr.charCodeAt(i);
174
- if (
175
- (code >= 0x4e00 && code <= 0x9fff) ||
176
- (code >= 0x3400 && code <= 0x4dbf) ||
177
- (code >= 0x3000 && code <= 0x303f) ||
178
- (code >= 0xff00 && code <= 0xffef)
179
- ) {
180
- width += 2;
181
- } else {
182
- width += 1;
183
- }
184
- }
185
- return width;
268
+ function saveCurrentSession(allowEmpty = false) {
269
+ if (!currentSessionId) return;
270
+ if (!allowEmpty && messages.length === 0) return;
271
+ const sessionData = {
272
+ id: currentSessionId,
273
+ title: currentSessionTitle,
274
+ timestamp: Date.now(),
275
+ messages: messages,
276
+ workspaceRoot: workspaceReady ? workspaceRoot : null,
277
+ activeOpenFile: workspaceReady && activeOpenFile ? activeOpenFile : null,
278
+ workMode,
279
+ contextPlan: currentContextPlan
280
+ };
281
+ saveSession(currentSessionId, sessionData);
186
282
  }
187
283
 
188
- function centerLine(str, width) {
189
- const strWidth = getStringWidth(str);
190
- if (strWidth >= width) return str;
191
- const leftPad = Math.floor((width - strWidth) / 2);
192
- const rightPad = width - strWidth - leftPad;
193
- return ' '.repeat(leftPad) + str + ' '.repeat(rightPad);
284
+ function redrawScreen(messageText = '') {
285
+ clearConsole();
286
+ drawHeader();
287
+ if (messageText) {
288
+ console.log(messageText);
289
+ }
290
+
291
+ const cleanHistory = messages.filter(m => m.role !== 'tool' && !m.toolCall && !m.content.includes('[System Context:') && !m.content.includes('[Tool Response for'));
292
+ if (cleanHistory.length > 0) {
293
+ console.log(currentLang === 'cn' ? '\x1b[90m--- 当前对话内容 (Current Chat) ---\x1b[0m' : '\x1b[90m--- Current Chat Context ---\x1b[0m');
294
+ cleanHistory.forEach(m => {
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}`);
301
+ });
302
+ console.log('\x1b[90m─────────────────────────────────\x1b[0m\n');
303
+ }
194
304
  }
195
305
 
196
- function padLine(str, width) {
197
- const strWidth = getStringWidth(str);
198
- if (strWidth >= width) return str;
199
- return str + ' '.repeat(width - strWidth);
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;
200
311
  }
201
312
 
202
313
  function maskKey(key) {
@@ -209,143 +320,351 @@ function clearConsole() {
209
320
  process.stdout.write('\x1b[2J\x1b[0f');
210
321
  }
211
322
 
212
- async function runConcurrentLimit(items, limit, fn) {
213
- const results = [];
214
- const executing = [];
215
- for (const item of items) {
216
- const p = Promise.resolve().then(() => fn(item));
217
- results.push(p);
218
- if (limit <= items.length) {
219
- const e = p.then(() => executing.splice(executing.indexOf(e), 1));
220
- executing.push(e);
221
- if (executing.length >= limit) {
222
- await Promise.race(executing);
223
- }
224
- }
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 || '')}`);
225
402
  }
226
- return Promise.all(results);
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`);
227
407
  }
228
408
 
229
- async function runFileDigestionWorkflow(filePath) {
230
- const resolvedPath = path.resolve(filePath);
231
- const content = fs.readFileSync(resolvedPath, 'utf8');
232
- const lines = content.split('\n');
233
- const totalLines = lines.length;
234
-
235
- const chunkSize = 2000;
236
- const chunks = [];
237
- for (let i = 0; i < totalLines; i += chunkSize) {
238
- chunks.push(lines.slice(i, i + chunkSize).join('\n'));
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.
435
+ }
239
436
  }
240
-
241
- const totalChunks = chunks.length;
242
- let completedCount = 0;
243
- let failedCount = 0;
244
-
245
- const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
246
- let spinnerIdx = 0;
247
-
248
- function drawProgressBar() {
249
- const percentage = totalChunks > 0 ? Math.round((completedCount / totalChunks) * 100) : 100;
250
- const barLength = 25;
251
- const filledLength = totalChunks > 0 ? Math.round((completedCount / totalChunks) * barLength) : barLength;
252
- const emptyLength = barLength - filledLength;
253
-
254
- const filledBar = '█'.repeat(filledLength);
255
- const emptyBar = '░'.repeat(emptyLength);
256
-
257
- const barColor = completedCount === totalChunks ? '\x1b[32m' : '\x1b[38;2;140;90;240m';
258
- const spinnerFrame = spinnerFrames[spinnerIdx];
259
-
260
- let suffix = '';
261
- if (failedCount > 0) {
262
- suffix = ` \x1b[31m(${failedCount} failed)\x1b[0m`;
437
+ return sections.join('\n\n');
438
+ }
439
+
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 };
263
513
  }
264
-
265
- process.stdout.write(
266
- `\r\x1b[K\x1b[35m[Ultracode Workflow]\x1b[0m Digesting ${path.basename(filePath)}: ` +
267
- `${barColor}[${filledBar}${emptyBar}]\x1b[0m ${percentage}% ` +
268
- `(${completedCount}/${totalChunks} chunks) ${completedCount === totalChunks ? '✔' : '\x1b[36m' + spinnerFrame + '\x1b[0m'}${suffix}`
269
- );
514
+ } finally {
515
+ renderer?.resume();
270
516
  }
271
-
272
- process.stdout.write('\x1b[?25l');
273
- const spinnerInterval = setInterval(() => {
274
- spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
275
- drawProgressBar();
276
- }, 80);
277
-
278
- const tasks = chunks.map((chunkText, idx) => ({ chunkText, idx }));
279
-
280
- const results = await runConcurrentLimit(tasks, 5, async (task) => {
281
- const start = task.idx * chunkSize + 1;
282
- const end = Math.min((task.idx + 1) * chunkSize, totalLines);
283
-
284
- const promptMsg = [
285
- {
286
- role: 'user',
287
- 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}`
288
- }
289
- ];
290
-
291
- try {
292
- const summary = await getAIResponse(promptMsg);
293
- completedCount++;
294
- drawProgressBar();
295
- return { idx: task.idx, start, end, summary };
296
- } catch (err) {
297
- completedCount++;
298
- failedCount++;
299
- drawProgressBar();
300
- return { idx: task.idx, start, end, summary: `[Error reading this segment: ${err.message}]` };
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
+ }))
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
+ }
546
+
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' };
301
570
  }
302
- });
303
-
304
- clearInterval(spinnerInterval);
305
- drawProgressBar(); // final draw to show 100% complete
306
- process.stdout.write('\n\x1b[?25h');
307
-
308
- results.sort((a, b) => a.idx - b.idx);
309
-
310
- let report = `=== ULTRACODE DIGESTION REPORT FOR "${path.basename(filePath)}" ===\n`;
311
- report += `Total Lines: ${totalLines}\n`;
312
- report += `Resolved Path: ${resolvedPath}\n\n`;
313
-
314
- results.forEach(res => {
315
- report += `--- Segment ${res.idx + 1} (Lines ${res.start}-${res.end}) ---\n`;
316
- report += `${res.summary}\n\n`;
317
- });
318
-
319
- report += `=========================================================\n`;
320
- return report;
571
+ if (choice === 3) return { action: 'finish' };
572
+ return { action: 'cancel' };
573
+ } finally {
574
+ renderer?.resume();
575
+ }
321
576
  }
322
577
 
323
578
  function drawHeader() {
324
579
  const logoLines = getLogoLines();
325
580
  const t = locales[currentLang];
581
+ const active = getActiveProfile();
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);
593
+ const leftColWidth = 45;
594
+ const rightColWidth = Math.max(38, boxWidth - leftColWidth - 3);
595
+ const activeFileLabel = workspaceReady && activeOpenFile ? truncateMiddle(sanitizeUntrustedText(path.relative(workspaceRoot, activeOpenFile)), rightColWidth - 13) : (currentLang === 'cn' ? '未选择' : 'None');
596
+ const workspaceLabel = workspaceReady
597
+ ? truncateMiddle(sanitizeUntrustedText(workspaceRoot), rightColWidth - 11)
598
+ : (currentLang === 'cn' ? '未关联,请使用 /open' : 'Not linked; use /open');
599
+ const contextLabel = `${contextStats.modelMessages}/${contextStats.savedMessages} msgs · ~${contextStats.estimatedTokens}/${contextStats.budgetTokens} tokens${contextStats.compacted ? ' · compacted' : ''}`;
600
+
601
+ if (terminalWidth < 100) {
602
+ const contentWidth = Math.max(32, terminalWidth - 2);
603
+ const rule = '─'.repeat(contentWidth);
604
+ const compactStatus = [
605
+ `${currentLang === 'cn' ? '模式' : 'Mode'}: ${workMode === 'thunder' ? '⚡ Thunder' : '➜ Highway'}`,
606
+ `${currentLang === 'cn' ? '模型' : 'Model'}: ${active ? active.model : '(not configured)'}`,
607
+ `${currentLang === 'cn' ? '上下文窗口' : 'Context window'}: ${inferContextWindowTokens(active).toLocaleString()} tokens`,
608
+ `${currentLang === 'cn' ? '目录' : 'Workspace'}: ${workspaceReady ? truncateMiddle(sanitizeUntrustedText(workspaceRoot), contentWidth - 5) : (currentLang === 'cn' ? '未关联,请使用 /open' : 'Not linked; use /open')}`,
609
+ `${currentLang === 'cn' ? '文件' : 'File'}: ${workspaceReady && activeOpenFile ? truncateMiddle(sanitizeUntrustedText(path.relative(workspaceRoot, activeOpenFile)), contentWidth - 5) : (currentLang === 'cn' ? '未选择' : 'None')}`,
610
+ `${currentLang === 'cn' ? '上下文' : 'Context'}: ${contextStats.modelMessages}/${contextStats.savedMessages} · ~${contextStats.estimatedTokens}/${contextStats.budgetTokens}${contextStats.compacted ? (currentLang === 'cn' ? ' · 已压缩' : ' · compacted') : ''}`,
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')}`
616
+ ];
617
+
618
+ console.log(`\x1b[38;2;250;100;30m${rule}\x1b[0m`);
619
+ console.log(centerLine(`\x1b[1mDave Code v${packageVersion}\x1b[0m`, contentWidth));
620
+ for (const logoLine of logoLines) console.log(centerLine(logoLine, contentWidth));
621
+ console.log(`\x1b[38;2;250;100;30m${rule}\x1b[0m`);
622
+ for (const line of compactStatus) console.log(truncateMiddle(line, contentWidth));
623
+ console.log(`\x1b[90m${rule}\x1b[0m\n`);
624
+ return;
625
+ }
326
626
 
327
627
  const rightLines = [
328
- t.tipsTitle,
329
- ...t.tipsLines,
330
- t.newsTitle,
331
- ...t.newsLines,
332
- t.docTitle,
333
- ...t.docLines,
628
+ currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m状态\x1b[0m' : '\x1b[1;38;2;250;100;30mStatus\x1b[0m',
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`] : []),
334
641
  '',
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`] : []),
335
648
  '',
649
+ currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m常用命令\x1b[0m' : '\x1b[1;38;2;250;100;30mCommands\x1b[0m',
650
+ '/mode /plan /code',
651
+ '/agents /open',
652
+ '/help /history /model',
653
+ currentLang === 'cn' ? 'Ctrl+C 或 /exit 退出' : 'Ctrl+C or /exit to quit',
336
654
  '',
337
- ''
655
+ currentLang === 'cn' ? '文件内容会按需读取并压缩上下文。' : 'Files are read on demand and compacted.'
338
656
  ];
339
657
 
340
- const leftColWidth = 45;
341
- const rightColWidth = 47;
342
- const boxWidth = 95;
343
-
344
658
  const boxColor = '\x1b[38;2;250;100;30m';
345
659
  const resetColor = '\x1b[0m';
346
-
347
- const topBorder = boxColor + '┌── ' + resetColor + `\x1b[1mDave Code v${packageVersion}\x1b[0m` + boxColor + ' ' + '─'.repeat(boxWidth - 16 - packageVersion.length) + '┐' + resetColor;
348
- 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}`;
349
668
 
350
669
  console.log(topBorder);
351
670
 
@@ -366,18 +685,16 @@ function drawHeader() {
366
685
  leftContent = centerLine(`\x1b[90m(No Models / 未配置)\x1b[0m`, leftColWidth);
367
686
  }
368
687
  } else if (i === 19) {
369
- const cwd = process.cwd();
370
- const truncatedCwd = cwd.length > leftColWidth - 2 ? '...' + cwd.slice(-(leftColWidth - 5)) : cwd;
371
- leftContent = centerLine(`\x1b[90m${truncatedCwd}\x1b[0m`, leftColWidth);
688
+ leftContent = centerLine(`\x1b[90m${truncateMiddle(process.cwd(), leftColWidth - 2)}\x1b[0m`, leftColWidth);
372
689
  }
373
690
 
374
- const rightContent = padLine(rightLines[i] || '', rightColWidth);
691
+ const rightContent = padLine(truncateMiddle(rightLines[i] || '', rightColWidth), rightColWidth);
375
692
 
376
693
  console.log(
377
- boxColor + '│ ' + resetColor +
378
- leftContent +
379
- boxColor + ' │ ' + resetColor +
380
- rightContent +
694
+ boxColor + '│ ' + resetColor +
695
+ leftContent +
696
+ boxColor + ' │ ' + resetColor +
697
+ rightContent +
381
698
  boxColor + ' │' + resetColor
382
699
  );
383
700
  }
@@ -387,90 +704,39 @@ function drawHeader() {
387
704
 
388
705
  }
389
706
 
390
- class AgentActionTracker {
391
- constructor() {
392
- this.actionsCount = 0;
393
- this.actionTypes = {};
394
- this.lastActionText = '';
395
- this.spinnerIdx = 0;
396
- this.spinnerInterval = null;
397
- this.statusColor = '\x1b[35m';
398
- }
707
+ let promptLoopRunning = false;
399
708
 
400
- start(actionText) {
401
- this.actionsCount++;
402
- this.lastActionText = actionText;
403
-
404
- // Parse action type
405
- let type = 'Action';
406
- if (actionText.includes('Reading file')) type = 'Read';
407
- else if (actionText.includes('Writing file')) type = 'Write';
408
- else if (actionText.includes('Listing directory')) type = 'List';
409
- else if (actionText.includes('Searching files')) type = 'Search';
410
- this.actionTypes[type] = (this.actionTypes[type] || 0) + 1;
411
-
412
- if (!this.spinnerInterval) {
413
- process.stdout.write('\x1b[?25l'); // Hide cursor
414
- const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
415
- this.spinnerInterval = setInterval(() => {
416
- this.spinnerIdx = (this.spinnerIdx + 1) % spinnerFrames.length;
417
- this.draw();
418
- }, 80);
419
- }
420
- this.draw();
421
- }
422
-
423
- draw() {
424
- const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
425
- const spinnerFrame = spinnerFrames[this.spinnerIdx];
426
-
427
- const cleanActionText = this.lastActionText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
428
-
429
- let displayAction = cleanActionText;
430
- if (displayAction.length > 50) {
431
- displayAction = displayAction.slice(0, 47) + '...';
432
- }
433
-
434
- process.stdout.write(
435
- `\r\x1b[K\x1b[35m⚙ [Agent Actions]\x1b[0m ${this.actionsCount} actions | Last: ${displayAction} \x1b[36m${spinnerFrame}\x1b[0m`
436
- );
437
- }
438
-
439
- stop() {
440
- if (this.spinnerInterval) {
441
- clearInterval(this.spinnerInterval);
442
- this.spinnerInterval = null;
443
- }
444
- }
445
-
446
- finish() {
447
- this.stop();
448
- if (this.actionsCount > 0) {
449
- const typesStr = Object.entries(this.actionTypes)
450
- .map(([type, count]) => `${count} ${type.toLowerCase()}${count > 1 ? 's' : ''}`)
451
- .join(', ');
452
- const summaryText = `Completed ${this.actionsCount} actions (${typesStr})`;
453
- process.stdout.write(`\r\x1b[K\x1b[32m✔ [Agent Actions]\x1b[0m ${summaryText}\n`);
709
+ async function promptUser() {
710
+ if (promptLoopRunning) return;
711
+ promptLoopRunning = true;
712
+
713
+ while (true) {
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}`;
722
+ const promptWidth = Math.max(32, process.stdout.columns || 80);
723
+ const padding = ' '.repeat(Math.max(1, promptWidth - getStringWidth(statusText) - 1));
724
+ const colorCode = '\x1b[90m';
725
+ console.log(padding + colorCode + statusText + '\x1b[0m');
726
+ const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
727
+ const placeholder = activeOpenFile
728
+ ? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
729
+ : (currentLang === 'cn' ? '输入问题或命令,例如 /help ...' : 'Enter a prompt or command, e.g. /help ...');
730
+ const input = await chatInputPrompt(placeholder, currentLang);
731
+ try {
732
+ await handleInput(input);
733
+ } catch (error) {
734
+ process.stdout.write('\x1b[?25h');
735
+ console.log(`\n\x1b[31mUnexpected error:\x1b[0m ${error.message}\n`);
454
736
  }
455
- process.stdout.write('\x1b[?25h'); // Show cursor
456
737
  }
457
738
  }
458
739
 
459
- async function promptUser() {
460
- const effort = getActiveEffort();
461
- const effortText = `● ${effort} · /effort`;
462
- const N = effort.length;
463
- const padding = ' '.repeat(Math.max(1, 80 - N));
464
- const colorCode = effort === 'ultracode' ? '\x1b[1;38;2;140;90;240m' : '\x1b[90m';
465
- console.log(padding + colorCode + effortText + '\x1b[0m');
466
- const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
467
- const placeholder = activeOpenFile
468
- ? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
469
- : (currentLang === 'cn' ? 'Try "how do I log an error?" / 输入提示词或命令...' : 'Try "how do I log an error?" / Enter prompt or command...');
470
- const input = await chatInputPrompt(placeholder, currentLang);
471
- handleInput(input);
472
- }
473
-
474
740
  // ── Interactive Config Handlers ──
475
741
 
476
742
  async function triggerProfileSwitch(t) {
@@ -491,13 +757,396 @@ async function triggerProfileSwitch(t) {
491
757
  const chosenProfile = profiles[selectedIdx];
492
758
  setActiveProfile(chosenProfile.model);
493
759
 
494
- clearConsole();
495
- drawHeader();
496
- console.log(`\n\x1b[32mActive model set to: ${chosenProfile.model}\x1b[0m\n`);
760
+ redrawScreen(`\n\x1b[32mActive model set to: ${chosenProfile.model}\x1b[0m\n`);
761
+ }
762
+
763
+ function describeToolRequest(toolName, toolArg, lang) {
764
+ const cn = lang === 'cn';
765
+ const structuredValue = toolArg && typeof toolArg === 'object'
766
+ ? (toolArg.path || toolArg.filePath || toolArg.source || toolArg.query || toolArg.command || '')
767
+ : toolArg;
768
+ const firstLine = String(structuredValue || '').split('\n', 1)[0].trim();
769
+ const target = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
770
+ const labels = {
771
+ LIST_DIR: cn ? `准备查看 ${target || '.'}` : `Preparing to list ${target || '.'}`,
772
+ INSPECT_FILE: cn ? `准备分析 ${target} 的结构` : `Preparing to inspect ${target}`,
773
+ READ_FILE: cn ? `准备读取 ${target}` : `Preparing to read ${target}`,
774
+ READ_NOTEBOOK: cn ? '准备按需读取项目笔记' : 'Preparing to read the project notebook',
775
+ EDIT_FILE: cn ? `准备局部修改 ${target}` : `Preparing to edit ${target}`,
776
+ WRITE_FILE: cn ? `准备修改 ${target}` : `Preparing to change ${target}`,
777
+ SEARCH_GREP: cn ? `准备搜索 “${target}”` : `Preparing to search "${target}"`,
778
+ MAKE_DIR: cn ? `准备创建目录 ${target}` : `Preparing to create directory ${target}`,
779
+ MOVE_PATH: cn ? `准备移动 ${target}` : `Preparing to move ${target}`,
780
+ DELETE_PATH: cn ? `准备删除 ${target}` : `Preparing to delete ${target}`,
781
+ RUN_COMMAND: cn ? `准备运行命令 ${target}` : `Preparing to run ${target}`
782
+ };
783
+ return labels[toolName] || (cn ? `准备执行 ${toolName}` : `Preparing ${toolName}`);
784
+ }
785
+
786
+ function launchEditor(filePath) {
787
+ const command = process.platform === 'win32'
788
+ ? { file: 'explorer.exe', args: [filePath] }
789
+ : process.platform === 'darwin'
790
+ ? { file: 'open', args: [filePath] }
791
+ : { file: 'xdg-open', args: [filePath] };
792
+ const child = spawn(command.file, command.args, { detached: true, stdio: 'ignore', windowsHide: true });
793
+ child.on('error', () => {});
794
+ child.unref();
795
+ }
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
+
850
+ async function handlePlanMenu() {
851
+ while (true) {
852
+ const plans = listPlans(workspaceRoot);
853
+ if (plans.length === 0) {
854
+ console.log(currentLang === 'cn'
855
+ ? '\n\x1b[90m暂无计划。使用 /plan 名称: 需求 创建。\x1b[0m\n'
856
+ : '\n\x1b[90mNo plans. Use /plan name: request to create one.\x1b[0m\n');
857
+ return;
858
+ }
859
+ const selected = await selectMenu(
860
+ currentLang === 'cn' ? '工作区计划' : 'Workspace Plans',
861
+ currentLang === 'cn' ? '选择一个计划进行查看、重命名或删除。' : 'Select a plan to view, rename, or delete.',
862
+ plans.map(plan => ({ name: plan.name, desc: `${plan.status} · ${new Date(plan.updatedAt).toLocaleString()}` })),
863
+ 0
864
+ );
865
+ if (selected === -1) return;
866
+ const plan = plans[selected];
867
+ const action = await selectMenu(
868
+ plan.name,
869
+ currentLang === 'cn' ? `状态: ${plan.status}` : `Status: ${plan.status}`,
870
+ currentLang === 'cn'
871
+ ? [
872
+ { name: '查看计划', desc: '显示完整的做什么与怎么做' },
873
+ { name: '重命名', desc: '修改计划名称' },
874
+ { name: '删除', desc: '永久删除计划' },
875
+ { name: '返回', desc: '返回计划列表' }
876
+ ]
877
+ : [
878
+ { name: 'View Plan', desc: 'Show the complete what-and-how plan' },
879
+ { name: 'Rename', desc: 'Change the plan name' },
880
+ { name: 'Delete', desc: 'Permanently delete the plan' },
881
+ { name: 'Back', desc: 'Return to the plan list' }
882
+ ],
883
+ 0
884
+ );
885
+ if (action === -1 || action === 3) continue;
886
+ if (action === 0) {
887
+ console.log(`\n\x1b[1;36m${sanitizeUntrustedText(plan.name)}\x1b[0m`);
888
+ console.log(`${sanitizeUntrustedText(plan.content)}\n`);
889
+ await waitForEnter(currentLang === 'cn' ? '按 Enter 返回计划列表。' : 'Press Enter to return to plans.');
890
+ } else if (action === 1) {
891
+ const nextName = await textInputPrompt(currentLang === 'cn' ? '新计划名称: ' : 'New plan name: ');
892
+ if (nextName) {
893
+ try {
894
+ renamePlan(workspaceRoot, plan.id, nextName);
895
+ } catch (error) {
896
+ console.log(`\n\x1b[31m${sanitizeUntrustedText(error.message)}\x1b[0m\n`);
897
+ }
898
+ }
899
+ } else if (action === 2) {
900
+ const allowed = await confirmPrompt(currentLang === 'cn'
901
+ ? `删除计划 "${sanitizeUntrustedText(plan.name)}"?(y/n): `
902
+ : `Delete plan "${sanitizeUntrustedText(plan.name)}"? (y/n): `);
903
+ if (allowed) deletePlan(workspaceRoot, plan.id);
904
+ }
905
+ }
906
+ }
907
+
908
+ function sessionMatchesWorkspace(session, candidateRoot) {
909
+ const referencedFiles = new Set();
910
+ for (const message of session.messages || []) {
911
+ if (message.role !== 'assistant') continue;
912
+ const parsed = message.toolCall
913
+ ? { toolName: message.toolCall.name, toolArg: message.toolCall.arguments }
914
+ : parseToolCall(message.content || '');
915
+ if (!parsed || parsed.error || !['READ_FILE', 'EDIT_FILE', 'WRITE_FILE'].includes(parsed.toolName)) continue;
916
+ const rawPath = parsed.toolArg && typeof parsed.toolArg === 'object'
917
+ ? (parsed.toolArg.path || parsed.toolArg.filePath || '')
918
+ : parsed.toolArg;
919
+ let filePath = String(rawPath || '').split('\n', 1)[0].replace(/:(\d+)-(\d+)$/, '').trim();
920
+ if (filePath) referencedFiles.add(filePath);
921
+ if (referencedFiles.size >= 8) break;
922
+ }
923
+
924
+ let matches = 0;
925
+ for (const filePath of referencedFiles) {
926
+ try {
927
+ const resolved = resolveExistingWorkspacePath(filePath, candidateRoot);
928
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) matches++;
929
+ if (matches >= 2) return true;
930
+ } catch (error) {
931
+ // A missing reference simply means this is probably not the old workspace.
932
+ }
933
+ }
934
+ return false;
935
+ }
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');
497
943
  }
498
944
 
499
945
  // ── Main REPL Input Handler ──
500
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
+
501
1150
  async function handleInput(input) {
502
1151
  const trimmed = input.trim();
503
1152
  const t = locales[currentLang];
@@ -525,23 +1174,271 @@ async function handleInput(input) {
525
1174
  }
526
1175
 
527
1176
  if (trimmed === '/reset') {
528
- messages = [];
1177
+ initSession();
529
1178
  resetTokenUsage();
530
1179
  console.log(`\x1b[32m${t.resetMsg}\x1b[0m\n`);
531
1180
  promptUser();
532
1181
  return;
533
1182
  }
534
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
+
1260
+ if (trimmed === '/history') {
1261
+ while (true) {
1262
+ const sessions = listSessions();
1263
+ if (sessions.length === 0) {
1264
+ console.log(currentLang === 'cn' ? '\n\x1b[31m暂无历史聊天记录!\x1b[0m\n' : '\n\x1b[31mNo chat history found!\x1b[0m\n');
1265
+ promptUser();
1266
+ return;
1267
+ }
1268
+
1269
+ const menuOptions = sessions.map(s => {
1270
+ const dateStr = new Date(s.timestamp || Date.now()).toLocaleString(currentLang === 'cn' ? 'zh-CN' : 'en-US', {
1271
+ month: 'short',
1272
+ day: 'numeric',
1273
+ hour: '2-digit',
1274
+ minute: '2-digit'
1275
+ });
1276
+ const cleanMsgs = (s.messages || []).filter(m => m.role !== 'tool' && !m.toolCall && !m.content.includes('[System Context:') && !m.content.includes('[Tool Response for'));
1277
+ const msgCount = cleanMsgs.length;
1278
+ return {
1279
+ name: sanitizeUntrustedText(s.title || (currentLang === 'cn' ? '未命名会话' : 'Untitled Session')),
1280
+ desc: `${dateStr} | ${msgCount} ${currentLang === 'cn' ? '条对话' : 'messages'}`
1281
+ };
1282
+ });
1283
+
1284
+ const titleText = currentLang === 'cn' ? '历史聊天记录 (Chat History)' : 'Chat History';
1285
+ const descText = currentLang === 'cn' ? '选择一个历史聊天记录以继续或删除:' : 'Select a chat history to resume or delete:';
1286
+
1287
+ const currentIdx = sessions.findIndex(s => s.id === currentSessionId);
1288
+ const selectedIdx = await selectMenu(titleText, descText, menuOptions, currentIdx !== -1 ? currentIdx : 0);
1289
+
1290
+ if (selectedIdx === -1) {
1291
+ redrawScreen();
1292
+ promptUser();
1293
+ return;
1294
+ }
1295
+
1296
+ const chosenSession = sessions[selectedIdx];
1297
+
1298
+ const safeChosenTitle = sanitizeUntrustedText(chosenSession.title);
1299
+ const actionTitle = currentLang === 'cn' ? `选择操作: "${safeChosenTitle}"` : `Actions for: "${safeChosenTitle}"`;
1300
+ const actionDesc = currentLang === 'cn' ? '请选择您想要进行的操作:' : 'Please select an action:';
1301
+
1302
+ const actionOptions = currentLang === 'cn' ? [
1303
+ { name: '载入并继续对话 (Resume)', desc: '加载此会话的历史聊天内容并继续' },
1304
+ { name: '删除此会话 (Delete)', desc: '从本地磁盘永久删除此聊天记录' },
1305
+ { name: '取消并返回 (Cancel)', desc: '返回上级历史记录列表' }
1306
+ ] : [
1307
+ { name: 'Resume Conversation', desc: 'Load the messages and continue chatting' },
1308
+ { name: 'Delete Session', desc: 'Permanently remove this chat file from disk' },
1309
+ { name: 'Cancel & Back', desc: 'Go back to the sessions list' }
1310
+ ];
1311
+
1312
+ const actionIdx = await selectMenu(actionTitle, actionDesc, actionOptions, 0);
1313
+
1314
+ if (actionIdx === 0) {
1315
+ currentSessionId = chosenSession.id;
1316
+ currentSessionTitle = chosenSession.title;
1317
+ messages = chosenSession.messages || [];
1318
+ workMode = chosenSession.workMode === 'thunder' ? 'thunder' : 'highway';
1319
+ currentContextPlan = chosenSession.contextPlan || null;
1320
+ currentScanSummary = '';
1321
+
1322
+ const savedWorkspace = chosenSession.workspaceRoot ? path.resolve(chosenSession.workspaceRoot) : null;
1323
+ const savedWorkspaceUsable = savedWorkspace && fs.existsSync(savedWorkspace) && fs.statSync(savedWorkspace).isDirectory()
1324
+ ? savedWorkspace
1325
+ : null;
1326
+ const inferredWorkspace = !savedWorkspaceUsable && sessionMatchesWorkspace(chosenSession, workspaceRoot)
1327
+ ? path.resolve(workspaceRoot)
1328
+ : null;
1329
+ const restorableWorkspace = savedWorkspaceUsable || inferredWorkspace;
1330
+ let workspaceMessage;
1331
+ if (restorableWorkspace && fs.existsSync(restorableWorkspace) && fs.statSync(restorableWorkspace).isDirectory()) {
1332
+ process.chdir(restorableWorkspace);
1333
+ workspaceRoot = restorableWorkspace;
1334
+ workspaceReady = true;
1335
+ activeOpenFile = null;
1336
+ if (chosenSession.activeOpenFile) {
1337
+ try {
1338
+ const restoredFile = resolveWorkspacePath(chosenSession.activeOpenFile, workspaceRoot);
1339
+ if (fs.existsSync(restoredFile) && fs.statSync(restoredFile).isFile()) activeOpenFile = restoredFile;
1340
+ } catch (error) {
1341
+ // The saved active file may have been moved or deleted.
1342
+ }
1343
+ }
1344
+ workspaceMessage = currentLang === 'cn'
1345
+ ? `\x1b[90m工作区: ${workspaceRoot}${inferredWorkspace ? '(已从历史验证)' : ''}\x1b[0m`
1346
+ : `\x1b[90mWorkspace: ${workspaceRoot}${inferredWorkspace ? ' (verified from history)' : ''}\x1b[0m`;
1347
+ saveCurrentSession();
1348
+ } else {
1349
+ workspaceReady = false;
1350
+ activeOpenFile = null;
1351
+ workspaceMessage = currentLang === 'cn'
1352
+ ? `\x1b[33m此旧会话没有可恢复的工作区。请先使用 /open <项目目录>。\x1b[0m`
1353
+ : `\x1b[33mThis older session has no restorable workspace. Use /open <project-directory> first.\x1b[0m`;
1354
+ }
1355
+
1356
+ redrawScreen(currentLang === 'cn'
1357
+ ? `\n\x1b[32m已成功载入会话: "${sanitizeUntrustedText(currentSessionTitle)}"\x1b[0m\n${workspaceMessage}\n`
1358
+ : `\n\x1b[32mSuccessfully loaded session: "${sanitizeUntrustedText(currentSessionTitle)}"\x1b[0m\n${workspaceMessage}\n`
1359
+ );
1360
+
1361
+ const cleanHistory = messages.filter(m => m.role !== 'tool' && !m.toolCall && !m.content.includes('[System Context:') && !m.content.includes('[Tool Response for'));
1362
+ const lastMessages = cleanHistory.slice(-6);
1363
+
1364
+ if (lastMessages.length > 0) {
1365
+ console.log(currentLang === 'cn' ? '\x1b[90m--- 最近聊天内容预览 ---\x1b[0m' : '\x1b[90m--- Recent Chat Preview ---\x1b[0m');
1366
+ lastMessages.forEach(m => {
1367
+ let previewContent = sanitizeUntrustedText(stripReasoningBlocks(m.displayContent || m.content));
1368
+ if (previewContent.length > 150) {
1369
+ previewContent = previewContent.slice(0, 147) + '...';
1370
+ }
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
+ }));
1375
+ });
1376
+ console.log('\x1b[90m────────────────────────\x1b[0m\n');
1377
+ }
1378
+
1379
+ promptUser();
1380
+ return;
1381
+ } else if (actionIdx === 1) {
1382
+ const confirmMsg = currentLang === 'cn'
1383
+ ? `\x1b[31m警告:您确定要删除此会话 "${safeChosenTitle}" 吗?此操作无法撤销! (y/n): \x1b[0m`
1384
+ : `\x1b[31mWarning: Are you sure you want to delete session "${safeChosenTitle}"? This cannot be undone! (y/n): \x1b[0m`;
1385
+
1386
+ const confirmed = await confirmPrompt(confirmMsg);
1387
+ if (confirmed) {
1388
+ deleteSession(chosenSession.id);
1389
+
1390
+ if (chosenSession.id === currentSessionId) {
1391
+ initSession();
1392
+ }
1393
+
1394
+ console.log(currentLang === 'cn' ? '\n\x1b[32m✔ 会话已成功删除!\x1b[0m\n' : '\n\x1b[32m✔ Session deleted successfully!\x1b[0m\n');
1395
+ await new Promise(resolve => setTimeout(resolve, 1000));
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+
535
1401
  if (trimmed === '/stats' || trimmed === '/tokens') {
536
- console.log(`\n\x1b[1;36mToken Usage Statistics / Token 使用统计:\x1b[0m`);
537
- console.log(` \x1b[90mLast Request (单次请求):\x1b[0m`);
538
- console.log(` Input (输入): ${sessionTokenUsage.lastInputTokens.toLocaleString()}`);
539
- console.log(` Output (输出): ${sessionTokenUsage.lastOutputTokens.toLocaleString()}`);
540
- console.log(` Total (总计): ${sessionTokenUsage.lastTotalTokens.toLocaleString()}`);
541
- console.log(` \x1b[90mSession Cumulative (累计会话):\x1b[0m`);
542
- console.log(` Input (输入): ${sessionTokenUsage.inputTokens.toLocaleString()}`);
543
- console.log(` Output (输出): ${sessionTokenUsage.outputTokens.toLocaleString()}`);
544
- 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
+ }
545
1442
  promptUser();
546
1443
  return;
547
1444
  }
@@ -549,9 +1446,7 @@ async function handleInput(input) {
549
1446
  if (trimmed === '/lang' || trimmed === '/language') {
550
1447
  currentLang = currentLang === 'cn' ? 'en' : 'cn';
551
1448
  const nextT = locales[currentLang];
552
- clearConsole();
553
- drawHeader();
554
- console.log(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
1449
+ redrawScreen(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
555
1450
  promptUser();
556
1451
  return;
557
1452
  }
@@ -571,19 +1466,11 @@ async function handleInput(input) {
571
1466
  return;
572
1467
  }
573
1468
 
574
- // Handle /effort (adjust effort level slider)
575
- if (trimmed === '/effort') {
576
- const currentEffort = getActiveEffort();
577
- const selected = await selectEffortMenu(currentEffort, currentLang);
578
- if (selected) {
579
- setActiveEffort(selected);
580
- clearConsole();
581
- drawHeader();
582
- console.log(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
583
- } else {
584
- clearConsole();
585
- drawHeader();
586
- }
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');
587
1474
  promptUser();
588
1475
  return;
589
1476
  }
@@ -610,17 +1497,22 @@ async function handleInput(input) {
610
1497
 
611
1498
  // Pause prompt and wait for Enter key
612
1499
  await waitForEnter(t.openingConfigMsg);
613
-
614
- // Reload config and refresh
615
- console.log(t.reloadingConfigMsg);
616
- clearConsole();
617
- drawHeader();
1500
+
1501
+ // Give the editor a brief moment to flush the saved file, then read it again from disk.
1502
+ await new Promise(resolve => setTimeout(resolve, 150));
1503
+ const reloadedProfile = getActiveProfile();
1504
+ const reloadMessage = reloadedProfile
1505
+ ? (currentLang === 'cn'
1506
+ ? `\n\x1b[32m配置已重载\x1b[0m\n\x1b[90m当前模型: ${reloadedProfile.model}\n配置文件: ${CONFIG_FILE}\x1b[0m\n`
1507
+ : `\n\x1b[32mConfiguration reloaded\x1b[0m\n\x1b[90mActive model: ${reloadedProfile.model}\nConfig file: ${CONFIG_FILE}\x1b[0m\n`)
1508
+ : `\n\x1b[31m${t.noActiveError}\x1b[0m\n\x1b[90m${CONFIG_FILE}\x1b[0m\n`;
1509
+ redrawScreen(reloadMessage);
618
1510
  promptUser();
619
1511
  return;
620
1512
  }
621
1513
 
622
1514
  // Handle /open <path>
623
- if (trimmed.startsWith('/open')) {
1515
+ if (/^\/open(?:\s|$)/.test(trimmed)) {
624
1516
  const args = trimmed.slice(5).trim();
625
1517
  if (!args) {
626
1518
  console.log(`\n\x1b[31m${t.openUsagePrompt}\x1b[0m\n`);
@@ -639,10 +1531,11 @@ async function handleInput(input) {
639
1531
  if (stats && stats.isDirectory()) {
640
1532
  try {
641
1533
  process.chdir(resolvedPath);
1534
+ workspaceRoot = process.cwd();
1535
+ workspaceReady = true;
642
1536
  activeOpenFile = null; // Switch to directory mode clears active file focus
643
- clearConsole();
644
- drawHeader();
645
- console.log(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
1537
+ saveCurrentSession();
1538
+ redrawScreen(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
646
1539
  } catch (err) {
647
1540
  console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
648
1541
  }
@@ -650,75 +1543,36 @@ async function handleInput(input) {
650
1543
  return;
651
1544
  }
652
1545
 
653
- // It is a file or a path to be created as a file
654
- try {
655
- // Create empty file if it doesn't exist
656
- if (!fs.existsSync(resolvedPath)) {
657
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
658
- fs.writeFileSync(resolvedPath, '', 'utf8');
659
- }
660
-
661
- activeOpenFile = resolvedPath; // Track currently open file
662
-
663
- // Read content
664
- const content = fs.readFileSync(resolvedPath, 'utf8');
665
- const lines = content.split('\n');
666
- const activeEffort = getActiveEffort();
667
- const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
668
-
669
- let contextContent = '';
670
- if (activeEffort === 'ultracode') {
671
- if (lines.length > 1000) {
672
- const totalChunks = Math.ceil(lines.length / 2000);
673
- const confirmMsg = `\n\x1b[35m[Ultracode]\x1b[0m File "${path.basename(resolvedPath)}" has ${lines.length} lines. Digesting it will take ${totalChunks} API calls. Proceed? (y/n): `;
674
- const proceed = await confirmPrompt(confirmMsg);
675
-
676
- if (proceed) {
677
- const report = await runFileDigestionWorkflow(resolvedPath);
678
- const relPath = path.relative(process.cwd(), resolvedPath);
679
- contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). The file has been processed using the chunked digestion workflow. Below is the full digestion report. If you need to view raw code or text of specific lines, use the <<READ_FILE: ${relPath}:start-end>> tool (e.g., <<READ_FILE: ${relPath}:1200-1350>>).]\n\n${report}`;
680
- } else {
681
- const relPath = path.relative(process.cwd(), resolvedPath);
682
- contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, digestion was skipped by user. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:1-500>>) to read other segments of the file if needed.]`;
683
- console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
684
- }
685
- } else if (lines.length > preset.maxReadLines) {
686
- const truncatedContent = lines.slice(0, 100).join('\n');
687
- const relPath = path.relative(process.cwd(), resolvedPath);
688
- contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, only the first 100 lines are shown below. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:101-300>>) to read other segments of the file if needed.]\n\n${truncatedContent}`;
689
- console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated first 100 lines for AI context.\x1b[0m`);
690
- } else {
691
- contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
692
- }
1546
+ if (!workspaceReady) {
1547
+ if (stats && stats.isFile()) {
1548
+ workspaceRoot = path.dirname(resolvedPath);
1549
+ process.chdir(workspaceRoot);
1550
+ workspaceReady = true;
693
1551
  } else {
694
- // Non-ultracode modes: Smart Incremental Reading
695
- if (lines.length > preset.maxReadLines) {
696
- const truncatedContent = lines.slice(0, 100).join('\n');
697
- const relPath = path.relative(process.cwd(), resolvedPath);
698
- contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, only the first 100 lines are shown below. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:101-300>>) to read other segments of the file if needed.]\n\n${truncatedContent}`;
699
- } else {
700
- contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
701
- }
1552
+ console.log(currentLang === 'cn'
1553
+ ? '\n\x1b[33m此会话尚未关联工作区,请先使用 /open <项目目录>。\x1b[0m\n'
1554
+ : '\n\x1b[33mThis session is not linked to a workspace. Use /open <project-directory> first.\x1b[0m\n');
1555
+ promptUser();
1556
+ return;
702
1557
  }
703
-
704
- // Load file into messages history as a System context message
705
- messages.push({
706
- role: 'user',
707
- content: contextContent
708
- });
1558
+ }
1559
+
1560
+ if (!stats || !stats.isFile()) {
1561
+ console.log(`\n\x1b[31m${t.openPathError}${sanitizeUntrustedText(resolvedPath)}\x1b[0m\n`);
1562
+ promptUser();
1563
+ return;
1564
+ }
709
1565
 
710
- // Open in default editor
711
- const command = process.platform === 'win32'
712
- ? `start "" "${resolvedPath}"`
713
- : (process.platform === 'darwin' ? `open "${resolvedPath}"` : `xdg-open "${resolvedPath}"`);
1566
+ // Existing files can be focused, but /open never creates workspace content.
1567
+ try {
1568
+ const filePath = resolveWorkspacePath(args, workspaceRoot);
1569
+ activeOpenFile = filePath; // Track currently open file
1570
+ workspaceReady = true;
1571
+ saveCurrentSession();
714
1572
 
715
- exec(command, (err) => {
716
- if (err && process.platform === 'win32') {
717
- exec(`notepad "${resolvedPath}"`);
718
- }
719
- });
1573
+ launchEditor(filePath);
720
1574
 
721
- console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${resolvedPath}\x1b[0m\n`);
1575
+ console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${filePath}\x1b[0m\n`);
722
1576
  } catch (err) {
723
1577
  console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
724
1578
  }
@@ -727,270 +1581,942 @@ async function handleInput(input) {
727
1581
  return;
728
1582
  }
729
1583
 
1584
+ let agentMode = 'chat';
1585
+ let agentPrompt = trimmed;
1586
+ let historyDisplay = trimmed;
1587
+ let executingPlan = null;
1588
+ let pendingPlan = null;
1589
+ let thunderPlanningRequest = null;
1590
+
1591
+ if (/^\/(?:plan|code)(?:\s|$)/.test(trimmed)) {
1592
+ if (!workspaceReady) {
1593
+ console.log(currentLang === 'cn'
1594
+ ? '\n\x1b[33m请先使用 /open <项目目录> 关联工作区。\x1b[0m\n'
1595
+ : '\n\x1b[33mUse /open <project-directory> before planning or coding.\x1b[0m\n');
1596
+ promptUser();
1597
+ return;
1598
+ }
1599
+ const command = parseInputCommand(trimmed, { findPlan: name => findPlan(workspaceRoot, name) });
1600
+ if (command.type === 'error') {
1601
+ console.log(`\n\x1b[31m${sanitizeUntrustedText(command.error)}\x1b[0m\n`);
1602
+ promptUser();
1603
+ return;
1604
+ }
1605
+ if (command.type === 'code.usage') {
1606
+ console.log(currentLang === 'cn'
1607
+ ? '\n用法: /code <需求或计划名>,或 /code --prompt <需求>\n'
1608
+ : '\nUsage: /code <request or plan name>, or /code --prompt <request>\n');
1609
+ promptUser();
1610
+ return;
1611
+ }
1612
+ if (command.type === 'plan.menu') {
1613
+ await handlePlanMenu();
1614
+ redrawScreen();
1615
+ promptUser();
1616
+ return;
1617
+ }
1618
+ if (command.type === 'plan.create') {
1619
+ const existing = findPlan(workspaceRoot, command.name);
1620
+ let replace = false;
1621
+ if (existing) {
1622
+ replace = await confirmPrompt(currentLang === 'cn'
1623
+ ? `计划 "${sanitizeUntrustedText(existing.name)}" 已存在,替换它?(y/n): `
1624
+ : `Plan "${sanitizeUntrustedText(existing.name)}" exists. Replace it? (y/n): `);
1625
+ if (!replace) {
1626
+ promptUser();
1627
+ return;
1628
+ }
1629
+ }
1630
+ if (workMode === 'thunder') {
1631
+ thunderPlanningRequest = { request: command.request, name: command.name, replace };
1632
+ }
1633
+ agentMode = 'plan';
1634
+ pendingPlan = { name: command.name, request: command.request, replace };
1635
+ agentPrompt = `Create the named implementation plan "${command.name}" for this request:\n${command.request}`;
1636
+ } else if (command.type === 'agent' && command.mode === 'code') {
1637
+ agentMode = 'code';
1638
+ executingPlan = command.plan;
1639
+ if (executingPlan?.workflowMode === 'thunder') workMode = 'thunder';
1640
+ agentPrompt = executingPlan
1641
+ ? `Execute the saved plan "${executingPlan.name}". Re-read current files before changing them.\n\n${executingPlan.content}`
1642
+ : command.prompt;
1643
+ }
1644
+ }
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
+
1661
+ if (!workspaceReady) {
1662
+ console.log(currentLang === 'cn'
1663
+ ? '\n\x1b[33m当前历史会话没有关联项目目录。请先输入 /open <项目目录>,再继续修改。\x1b[0m\n'
1664
+ : '\n\x1b[33mThis history session is not linked to a project directory. Run /open <project-directory> before continuing.\x1b[0m\n');
1665
+ promptUser();
1666
+ return;
1667
+ }
1668
+
730
1669
  // Check if active profile and API key are configured
731
1670
  if (!hasApiKey()) {
732
1671
  console.log(`\n${t.noKeyError}\n`);
1672
+ if (lastConfigError) console.log(`\x1b[31m${sanitizeUntrustedText(lastConfigError)}\x1b[0m\n`);
733
1673
  promptUser();
734
1674
  return;
735
1675
  }
736
1676
 
737
- // Push user message to history
738
- messages.push({ role: 'user', content: trimmed });
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
+ }
739
1684
 
740
- const activeEffort = getActiveEffort();
741
- const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
742
- const maxLoops = preset.maxLoops;
1685
+ // Push user message to history
1686
+ currentLang = detectInputLanguage(historyDisplay || agentPrompt, currentLang);
1687
+ messages.push({ role: 'user', content: agentPrompt, displayContent: historyDisplay });
1688
+ saveCurrentSession();
1689
+
1690
+ if (executingPlan) {
1691
+ updatePlan(workspaceRoot, executingPlan.id, { status: 'running', lastRun: { startedAt: Date.now() } });
1692
+ redrawScreen(currentLang === 'cn'
1693
+ ? `\x1b[90m正在执行计划: ${sanitizeUntrustedText(executingPlan.name)}\x1b[0m\n`
1694
+ : `\x1b[90mExecuting plan: ${sanitizeUntrustedText(executingPlan.name)}\x1b[0m\n`);
1695
+ }
743
1696
 
744
- const actionTracker = new AgentActionTracker();
1697
+ const recalledMemories = workspaceReady ? retrieveMemories(workspaceRoot, agentPrompt, 8) : [];
1698
+ const recalledMemoryText = formatMemoriesForPrompt(recalledMemories);
1699
+ const turnTokenStart = {
1700
+ input: sessionTokenUsage.inputTokens,
1701
+ output: sessionTokenUsage.outputTokens
1702
+ };
1703
+
1704
+ const runtime = createRuntimeEvents();
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 });
1730
+ const unsubscribe = runtime.subscribe(event => renderer.handle(event));
1731
+ const abortController = new AbortController();
1732
+ let interrupted = false;
1733
+ const onTurnInterrupt = () => {
1734
+ interrupted = true;
1735
+ abortController.abort();
1736
+ };
1737
+ process.once('SIGINT', onTurnInterrupt);
745
1738
  let loopCount = 0;
746
- let activeReply = '';
1739
+ let turnCompleted = false;
1740
+ let terminalEventSent = false;
1741
+ let compactionShown = false;
1742
+ let compactionSummary = '';
1743
+ let summarizedMessageCount = 0;
1744
+ let turnOutcome = 'stopped';
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
+ }
747
1781
 
748
- while (loopCount < maxLoops) {
749
- // Simulate thinking with animated spinner
750
- const prefixText = currentLang === 'cn' ? '正在思考' : 'Thinking';
751
- const stopSpinner = startSpinner(prefixText);
752
-
753
- try {
754
- activeReply = await getAIResponse(messages, activeOpenFile, preset.maxReadLines);
755
- stopSpinner();
756
- } catch (error) {
757
- stopSpinner();
758
- console.log(`\n\x1b[31mAPI Error:\x1b[0m ${error.message}\n`);
759
- if (loopCount === 0) messages.pop();
760
- break;
761
- }
762
-
763
- // Check if the reply contains any tool tags
764
- const match = activeReply.match(/<<([A-Z_]+):\s*([\s\S]*?)>>/);
765
- if (!match) {
766
- // Final response from Dave
767
- let finalReply = activeReply;
768
- const thinkMatch = finalReply.match(/<think>([\s\S]*?)<\/think>/);
769
- if (thinkMatch) {
770
- const thinkContent = thinkMatch[1].trim();
771
- if (thinkContent) {
772
- console.log(`\n\x1b[90m💭 Dave (Thinking):\n${thinkContent}\x1b[0m`);
773
- }
774
- finalReply = finalReply.replace(/<think>[\s\S]*?<\/think>/, '').trim();
775
- }
776
- if (finalReply === '') {
777
- finalReply = '(Finished reasoning / 已思考完毕但未返回额外文本)';
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] });
778
1788
  }
779
- console.log(`\n\x1b[1;38;2;250;100;30mDave\x1b[0m: ${finalReply}\n`);
780
- messages.push({ role: 'assistant', content: activeReply });
781
- break;
1789
+ yield event;
782
1790
  }
1791
+ }
783
1792
 
784
- // Extract thought text if present
785
- let thoughtText = activeReply.slice(0, match.index).trim();
786
- if (thoughtText) {
787
- const thinkMatch = thoughtText.match(/<think>([\s\S]*?)<\/think>/);
788
- if (thinkMatch) {
789
- const thinkContent = thinkMatch[1].trim();
790
- if (thinkContent) {
791
- console.log(`\n\x1b[90m💭 Dave (Thinking):\n${thinkContent}\x1b[0m`);
1793
+ runtime.emit('turn.started', {
1794
+ label: currentLang === 'cn' ? '准备项目工作流' : 'Preparing project workflow'
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
+ }
1805
+
1806
+ try {
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.
792
1902
  }
793
- thoughtText = thoughtText.replace(/<think>[\s\S]*?<\/think>/, '').trim();
794
1903
  }
795
- if (thoughtText) {
796
- console.log(`\x1b[90m💭 Dave: ${thoughtText}\x1b[0m`);
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]'));
1926
+ if (!compactionShown && contextWasCompacted) {
1927
+ runtime.emit('context.compacted', {
1928
+ omittedMessages: Math.max(1, messages.length - modelMessages.length + 1),
1929
+ keptMessages: modelMessages.length
1930
+ });
1931
+ compactionShown = true;
797
1932
  }
798
- }
799
-
800
- // Save Dave's intermediate tool call message to history
801
- messages.push({ role: 'assistant', content: activeReply });
802
1933
 
803
- const toolName = match[1];
804
- const toolArg = match[2];
1934
+ let activeReply = '';
1935
+ const nativeToolCalls = [];
1936
+ let modelWasTruncated = false;
1937
+ const finishWithoutTools = forceBudgetFinish || forceNoProgressFinish;
1938
+ for await (const modelEvent of trackedModelRunner(modelMessages, {
1939
+ activeOpenFile,
1940
+ signal: abortController.signal,
1941
+ mode: agentMode,
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
+ }) : ''
1967
+ })) {
1968
+ if (modelEvent.type === 'model.delta') {
1969
+ activeReply += modelEvent.data.text || '';
1970
+ } else if (modelEvent.type === 'model.tool_call') {
1971
+ nativeToolCalls.push(modelEvent.data);
1972
+ } else if (modelEvent.type === 'model.completed') {
1973
+ modelWasTruncated = modelEvent.data.truncated === true;
1974
+ runtime.emit(modelEvent.type, modelEvent.data);
1975
+ } else if (modelEvent.type === 'model.started') {
1976
+ runtime.emit('model.started', {
1977
+ ...modelEvent.data,
1978
+ label: loopCount === 0
1979
+ ? (currentLang === 'cn' ? '思考中 · 正在理解问题' : 'Thinking · understanding the request')
1980
+ : (currentLang === 'cn' ? '思考中 · 正在检查工具结果' : 'Thinking · checking tool results')
1981
+ });
1982
+ } else {
1983
+ runtime.emit(modelEvent.type, modelEvent.data);
1984
+ }
1985
+ }
805
1986
 
806
- let toolResult = '';
807
- const statusColor = '\x1b[35m'; // purple for agent action
808
- const resetColor = '\x1b[0m';
809
-
810
- try {
811
- if (toolName === 'LIST_DIR') {
812
- const resolvedPath = path.resolve(toolArg.trim() || '.');
813
- actionTracker.start(`Listing directory: ${path.basename(resolvedPath) || '.'}`);
814
-
815
- const files = fs.readdirSync(resolvedPath);
816
- toolResult = files.map(f => {
817
- const stat = fs.statSync(path.join(resolvedPath, f));
818
- return `${f} (${stat.isDirectory() ? 'Dir' : 'File, ' + stat.size + 'B'})`;
819
- }).join('\n') || '(Empty directory)';
820
- }
821
- else if (toolName === 'READ_FILE') {
822
- let filePath = toolArg.trim();
823
- let startLine = 1;
824
- let endLine = null;
825
-
826
- // Parse range like: path/to/file:20-80
827
- const rangeMatch = filePath.match(/:(\d+)-(\d+)$/);
828
- if (rangeMatch) {
829
- filePath = filePath.slice(0, -rangeMatch[0].length);
830
- startLine = parseInt(rangeMatch[1], 10);
831
- endLine = parseInt(rangeMatch[2], 10);
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
+ });
832
2019
  }
2020
+ }
833
2021
 
834
- const resolvedPath = path.resolve(filePath);
835
- actionTracker.start(`Reading file: ${path.basename(filePath)}${rangeMatch ? ` (Lines ${startLine}-${endLine})` : ''}`);
836
-
837
- const content = fs.readFileSync(resolvedPath, 'utf8');
838
- const lines = content.split('\n');
839
- const activeEffort = getActiveEffort();
840
- const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
841
-
842
- if (rangeMatch) {
843
- const sliced = lines.slice(startLine - 1, endLine);
844
- toolResult = sliced.join('\n');
845
- } else {
846
- if (activeEffort === 'ultracode') {
847
- if (lines.length > 1000) {
848
- actionTracker.stop(); // Stop the dynamic status line before prompt
849
- process.stdout.write('\n'); // Carriage return to next line for clean confirmation
850
- const totalChunks = Math.ceil(lines.length / 2000);
851
- const confirmMsg = `\x1b[35m[Ultracode]\x1b[0m AI requested to read "${path.basename(resolvedPath)}" (${lines.length} lines). Digesting it will take ${totalChunks} API calls. Proceed? (y/n): `;
852
- const proceed = await confirmPrompt(confirmMsg);
853
-
854
- if (proceed) {
855
- const report = await runFileDigestionWorkflow(resolvedPath);
856
- toolResult = `[File is large (${lines.length} lines). Triggered Ultracode Digestion Workflow. Below is the report. Use READ_FILE with ranges for specific parts if needed.]\n\n${report}`;
857
- } else {
858
- const relPath = path.relative(process.cwd(), resolvedPath);
859
- toolResult = `[Warning: File is large (${lines.length} lines) and digestion was skipped by user. You MUST read specific parts using range constraints like <<READ_FILE: ${relPath}:start-end>> (e.g. 1-500) to find the information.]`;
860
- console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
861
- }
862
- } else if (lines.length > preset.maxReadLines) {
863
- const truncated = lines.slice(0, preset.maxReadLines).join('\n');
864
- const relPath = path.relative(process.cwd(), resolvedPath);
865
- toolResult = `[Warning: File is large (${lines.length} lines). Truncated first ${preset.maxReadLines} lines. Use range constraints like <<READ_FILE: ${relPath}:start-end>> to read other sections.]\n\n${truncated}`;
866
- console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated to first ${preset.maxReadLines} lines.\x1b[0m`);
867
- } else {
868
- toolResult = content;
869
- }
870
- } else {
871
- // Non-ultracode modes: Smart Incremental Reading (精读模式)
872
- if (lines.length > preset.maxReadLines) {
873
- const truncated = lines.slice(0, preset.maxReadLines).join('\n');
874
- const relPath = path.relative(process.cwd(), resolvedPath);
875
- toolResult = `[System Context: File is large (${lines.length} lines). Truncated first ${preset.maxReadLines} lines for this read. To read other sections, you MUST use range constraints like <<READ_FILE: ${relPath}:start-end>> (e.g. 200-800) to find the information.]\n\n${truncated}`;
876
- } else {
877
- toolResult = content;
2022
+ if (modelWasTruncated) {
2023
+ runtime.emit('model.retry', {
2024
+ reason: 'output-truncated',
2025
+ label: currentLang === 'cn' ? '模型输出达到上限,正在要求其缩短并继续' : 'Model output was truncated; requesting a concise continuation'
2026
+ });
2027
+ messages.push({
2028
+ role: 'user',
2029
+ content: '[System notice: The previous model response reached its output limit and was not accepted. Continue concisely. If a tool is needed, request exactly one structured tool.]'
2030
+ });
2031
+ saveCurrentSession();
2032
+ loopCount++;
2033
+ continue;
2034
+ }
2035
+
2036
+ const activeProfile = getActiveProfile();
2037
+ let parsedTool = null;
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
+ })) };
2050
+ } else if (nativeToolCalls.length === 1) {
2051
+ const call = nativeToolCalls[0];
2052
+ parsedTool = SUPPORTED_TOOLS.has(call.name)
2053
+ ? { toolName: call.name, toolArg: call.arguments || {}, native: true, id: call.id }
2054
+ : { error: `Unsupported structured tool: ${call.name}` };
2055
+ } else if ((activeProfile?.toolMode || 'native') === 'legacy') {
2056
+ parsedTool = parseToolCall(activeReply);
2057
+ if (parsedTool && !parsedTool.error && typeof parsedTool.toolArg === 'string' && parsedTool.toolArg.trim().startsWith('{')) {
2058
+ try {
2059
+ parsedTool.toolArg = JSON.parse(parsedTool.toolArg);
2060
+ } catch {
2061
+ parsedTool = { error: 'Legacy tool arguments must be valid JSON.' };
2062
+ }
2063
+ }
2064
+ }
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
+ }
2074
+ let finalReply = sanitizeUntrustedText(stripReasoningBlocks(activeReply));
2075
+ if (!finalReply) {
2076
+ finalReply = currentLang === 'cn' ? '模型未返回可显示内容。' : 'The model returned no displayable content.';
2077
+ }
2078
+ if (pendingPlan) {
2079
+ const missingSections = validatePlanContent(finalReply);
2080
+ if (missingSections.length > 0) {
2081
+ planValidationAttempts++;
2082
+ if (planValidationAttempts >= 3) {
2083
+ throw new Error(`Plan validation failed: missing ${missingSections.join(', ')}.`);
878
2084
  }
2085
+ messages.push({ role: 'assistant', content: finalReply });
2086
+ messages.push({ role: 'user', content: `[Plan validation failed. Rewrite the complete plan. Missing: ${missingSections.join(', ')}. Every implementation step must state WHAT changes, HOW it is implemented, affected files/interfaces, and validation.]` });
2087
+ saveCurrentSession();
2088
+ loopCount++;
2089
+ continue;
879
2090
  }
880
2091
  }
881
- }
882
- else if (toolName === 'WRITE_FILE') {
883
- const firstNewline = toolArg.indexOf('\n');
884
- const filePathPart = firstNewline === -1 ? toolArg : toolArg.slice(0, firstNewline);
885
- const fileContentPart = firstNewline === -1 ? '' : toolArg.slice(firstNewline + 1);
886
-
887
- const resolvedPath = path.resolve(filePathPart.trim());
888
- actionTracker.start(`Writing file: ${path.basename(resolvedPath)}`);
889
-
890
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
891
- fs.writeFileSync(resolvedPath, fileContentPart, 'utf8');
892
- toolResult = `Successfully wrote to file: ${resolvedPath}`;
893
- }
894
- else if (toolName === 'SEARCH_GREP') {
895
- const query = toolArg.trim();
896
- actionTracker.start(`Searching files for: "${query}"`);
897
-
898
- const results = [];
899
- let totalMatches = 0;
900
- const maxTotalMatches = 50;
901
-
902
- function searchGrep(dir, q, depth = 0) {
903
- if (depth > 5 || totalMatches >= maxTotalMatches) return;
904
- const files = fs.readdirSync(dir);
905
- for (const f of files) {
906
- if (totalMatches >= maxTotalMatches) break;
907
- if (f === 'node_modules' || f === '.git' || f === '.gemini' || f === 'package-lock.json') continue;
908
- const fullPath = path.join(dir, f);
909
- try {
910
- const stat = fs.statSync(fullPath);
911
- if (stat.isDirectory()) {
912
- searchGrep(fullPath, q, depth + 1);
913
- } else if (stat.isFile()) {
914
- const ext = path.extname(f).toLowerCase();
915
- const textExts = ['.js', '.json', '.txt', '.md', '.html', '.css', '.yml', '.yaml', '.sh', '.bat', '.cmd', '.py', '.cpp', '.h', '.c', '.go', '.rs', '.java', '.ts'];
916
- if (textExts.includes(ext) || stat.size < 100000) {
917
- const content = fs.readFileSync(fullPath, 'utf8');
918
- if (content.includes(q)) {
919
- const lines = content.split('\n');
920
- const fileMatches = [];
921
- for (let i = 0; i < lines.length; i++) {
922
- if (lines[i].includes(q)) {
923
- fileMatches.push({ lineNum: i + 1, text: lines[i].trim() });
924
- totalMatches++;
925
- if (totalMatches >= maxTotalMatches) break;
926
- }
927
- }
928
- if (fileMatches.length > 0) {
929
- results.push({
930
- file: path.relative(process.cwd(), fullPath),
931
- matches: fileMatches
932
- });
933
- }
934
- }
2092
+ runtime.emit('phase.changed', { phase: 'act', label: currentLang === 'cn' ? 'ACT · 整理结果' : 'ACT · organizing result' });
2093
+ runtime.emit('model.delta', { text: finalReply });
2094
+ messages.push({ role: 'assistant', content: finalReply });
2095
+ saveCurrentSession();
2096
+ if (pendingPlan) {
2097
+ upsertPlan(workspaceRoot, {
2098
+ name: pendingPlan.name,
2099
+ request: pendingPlan.request,
2100
+ content: finalReply
2101
+ }, { replace: pendingPlan.replace });
2102
+ }
2103
+ turnCompleted = true;
2104
+
2105
+ if (currentSessionTitle === 'New Chat' || currentSessionTitle === '新会话' || currentSessionTitle === 'New Session') {
2106
+ const titleSessionId = currentSessionId;
2107
+ const titleMessages = prepareModelMessages(messages.map(message => ({ ...message })), currentContextPlan);
2108
+ generateTitle(titleMessages).then(newTitle => {
2109
+ if (currentSessionId === titleSessionId && newTitle && newTitle !== 'New Session') {
2110
+ currentSessionTitle = sanitizeUntrustedText(newTitle).slice(0, 80);
2111
+ saveCurrentSession();
2112
+ }
2113
+ }).catch(() => {});
2114
+ }
2115
+ break;
2116
+ }
2117
+
2118
+ if (parsedTool.error) {
2119
+ const requestedSeveralTools = /multiple tools at once|exactly one .*invocation/i.test(parsedTool.error);
2120
+ runtime.emit('tool.failed', {
2121
+ tool: 'TOOL_PARSE',
2122
+ displaySummary: requestedSeveralTools
2123
+ ? (currentLang === 'cn' ? '模型一次请求了多个工具,已要求逐个调用' : 'The model requested several tools; retrying one at a time')
2124
+ : (currentLang === 'cn' ? '工具格式无法解析' : 'Could not parse tool call'),
2125
+ error: parsedTool.error
2126
+ });
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
+ });
2133
+ saveCurrentSession();
2134
+ loopCount++;
2135
+ continue;
2136
+ }
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`);
935
2181
  }
2182
+ return await (request.allowSession ? permissionPrompt : confirmPrompt)(`\x1b[33m${sanitizeUntrustedText(request.prompt)}\x1b[0m`);
2183
+ } finally {
2184
+ renderer.resume();
936
2185
  }
937
- } catch (e) {
938
- // Ignore unreadable files
939
- }
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
+ });
940
2220
  }
2221
+ messages.push({ role: 'tool', content: toolResult, toolCallId: call.id, toolName: call.toolName });
941
2222
  }
2223
+ saveCurrentSession();
2224
+ if (interrupted) throw new Error('Request cancelled.');
2225
+ loopCount += calls.length;
2226
+ continue;
2227
+ }
942
2228
 
943
- searchGrep(process.cwd(), query);
944
-
945
- if (results.length > 0) {
946
- let outputText = 'Found matches:\n';
947
- results.forEach(res => {
948
- outputText += `- ${res.file}:\n`;
949
- res.matches.forEach(m => {
950
- const displayInfo = m.text.length > 120 ? m.text.slice(0, 117) + '...' : m.text;
951
- outputText += ` Line ${m.lineNum}: ${displayInfo}\n`;
952
- });
2229
+ const toolName = parsedTool.toolName;
2230
+ const toolArg = parsedTool.toolArg;
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.'}]`
953
2253
  });
954
- toolResult = outputText;
955
2254
  } else {
956
- toolResult = `No matches found for "${query}"`;
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
+ });
957
2266
  }
958
- }
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
+ }
2278
+ runtime.emit('tool.requested', {
2279
+ tool: toolName,
2280
+ toolCallId,
2281
+ displaySummary: describeToolRequest(toolName, toolArg, currentLang)
2282
+ });
2283
+
2284
+ messages.push({
2285
+ role: 'assistant',
2286
+ content: '',
2287
+ toolCall: { id: toolCallId, name: toolName, arguments: toolArg }
2288
+ });
2289
+ saveCurrentSession();
2290
+
2291
+ const toolResponse = await executeToolCall({
2292
+ toolName,
2293
+ toolArg,
2294
+ toolCallId,
2295
+ workspaceRoot,
2296
+ emit: runtime.emit,
2297
+ requestPermission: async request => {
2298
+ renderer.pause();
2299
+ try {
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`);
2306
+ } finally {
2307
+ renderer.resume();
2308
+ }
2309
+ },
2310
+ lang: currentLang,
2311
+ mode: agentMode,
2312
+ signal: abortController.signal,
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
2325
+ });
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++;
959
2340
  else {
960
- toolResult = `Unknown tool: ${toolName}`;
2341
+ repeatedToolCount = 1;
2342
+ blockedRepeatedToolSignature = '';
961
2343
  }
962
- } catch (err) {
963
- toolResult = `Error executing tool ${toolName}: ${err.message}`;
964
- console.log(`\n\x1b[31m[Agent Error] ${err.message}\x1b[0m\n`);
2344
+ repeatedToolSignature = signature;
2345
+ repeatedToolResultHash = resultHash;
2346
+ if (toolResponse.cancelled) planActionCancelled = true;
2347
+ messages.push({ role: 'tool', content: String(toolResult || ''), toolCallId, toolName });
2348
+ saveCurrentSession();
2349
+ if (interrupted) throw new Error('Request cancelled.');
2350
+ loopCount++;
965
2351
  }
966
2352
 
967
- // Append tool response to history as a user message
968
- messages.push({
969
- role: 'user',
970
- content: `[Tool Response for ${toolName}:\n${toolResult}]`
971
- });
2353
+ if (turnCompleted) {
2354
+ runtime.emit('phase.changed', { phase: 'verify', label: currentLang === 'cn' ? 'VERIFY · 完成检查' : 'VERIFY · final checks' });
2355
+ runtime.emit('turn.completed', { steps: loopCount });
2356
+ terminalEventSent = true;
2357
+ turnOutcome = planActionCancelled ? 'cancelled' : 'completed';
2358
+ }
2359
+ } catch (error) {
2360
+ if (readPlanCancelled || budgetCancelled || interrupted || abortController.signal.aborted) {
2361
+ runtime.emit('turn.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')
2367
+ });
2368
+ terminalEventSent = true;
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
+ }
2377
+ } else {
2378
+ if (loopCount === 0) {
2379
+ messages.pop();
2380
+ saveCurrentSession();
2381
+ }
2382
+ runtime.emit('turn.failed', {
2383
+ error: currentLang === 'cn' ? `请求失败:${error.message}` : `Request failed: ${error.message}`
2384
+ });
2385
+ terminalEventSent = true;
2386
+ turnOutcome = 'blocked';
2387
+ }
2388
+ } finally {
2389
+ process.removeListener('SIGINT', onTurnInterrupt);
2390
+ if (!terminalEventSent) runtime.emit('turn.cancelled', { reason: currentLang === 'cn' ? '本轮已停止' : 'Turn stopped' });
2391
+ renderer.dispose();
2392
+ unsubscribe();
2393
+ runtime.close();
2394
+ }
972
2395
 
973
- actionTracker.stop(); // Stop the action tracker spinner before we loop back to thinking
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
+ }
974
2476
 
975
- loopCount++;
2477
+ if (executingPlan) {
2478
+ updatePlan(workspaceRoot, executingPlan.id, {
2479
+ status: turnOutcome === 'completed' ? 'completed' : turnOutcome === 'cancelled' ? 'ready' : 'blocked',
2480
+ lastRun: {
2481
+ ...(executingPlan.lastRun || {}),
2482
+ finishedAt: Date.now(),
2483
+ outcome: turnOutcome,
2484
+ reviews: thunderReviews
2485
+ },
2486
+ ...(thunderTeam ? { teamSnapshot: getThunderTeam(workspaceRoot, thunderTeam.id) || thunderTeam } : {})
2487
+ });
976
2488
  }
977
2489
 
978
- actionTracker.finish();
979
- if (loopCount >= maxLoops) {
980
- console.log(`\n\x1b[33m[Warning] Loop limit reached (${maxLoops} steps). Dave stopped to prevent high API token usage. Try raising the effort level using /effort or narrowing down the task.\x1b[0m\n`);
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(() => {});
981
2497
  }
982
2498
 
2499
+ sessionTokenUsage.lastInputTokens = Math.max(0, sessionTokenUsage.inputTokens - turnTokenStart.input);
2500
+ sessionTokenUsage.lastOutputTokens = Math.max(0, sessionTokenUsage.outputTokens - turnTokenStart.output);
2501
+ sessionTokenUsage.lastTotalTokens = sessionTokenUsage.lastInputTokens + sessionTokenUsage.lastOutputTokens;
2502
+ sessionTokenUsage.lastPhaseUsage = {
2503
+ ...phaseUsage,
2504
+ approvedPostScanBudget: Number(currentContextPlan?.turnBudgetTokens) || 0
2505
+ };
2506
+
983
2507
  if (sessionTokenUsage.lastTotalTokens > 0) {
984
- console.log(
985
- `\x1b[90mTokens: ${sessionTokenUsage.lastInputTokens.toLocaleString()} input, ` +
986
- `${sessionTokenUsage.lastOutputTokens.toLocaleString()} output ` +
987
- `(${sessionTokenUsage.lastTotalTokens.toLocaleString()} total) | ` +
988
- `Session Total: ${sessionTokenUsage.inputTokens.toLocaleString()} input, ` +
989
- `${sessionTokenUsage.outputTokens.toLocaleString()} output ` +
990
- `(${sessionTokenUsage.totalTokens.toLocaleString()} total)\x1b[0m\n`
991
- );
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`);
2511
+ }
2512
+
2513
+ if (pendingPlan || executingPlan) {
2514
+ redrawScreen(currentLang === 'cn'
2515
+ ? '\x1b[90m计划状态已更新。\x1b[0m\n'
2516
+ : '\x1b[90mPlan status updated.\x1b[0m\n');
992
2517
  }
993
2518
 
2519
+ await closeWorkspaceShell(workspaceRoot);
994
2520
  promptUser();
995
2521
  }
996
2522
 
@@ -1000,12 +2526,16 @@ async function startApp() {
1000
2526
  clearConsole();
1001
2527
  await runWelcomeAnimation();
1002
2528
  setInitialized();
1003
- // Wait briefly so the user can see the final successful initialization status
1004
- await new Promise(resolve => setTimeout(resolve, 1500));
2529
+ await new Promise(resolve => setTimeout(resolve, 500));
1005
2530
  }
2531
+ initSession();
1006
2532
  clearConsole();
2533
+ // The octopus workspace header remains part of every normal startup.
1007
2534
  drawHeader();
1008
2535
  promptUser();
1009
2536
  }
1010
2537
 
1011
- startApp().catch(console.error);
2538
+ startApp().catch(error => {
2539
+ process.stdout.write('\x1b[?25h');
2540
+ console.error(error);
2541
+ });