codemini-cli 0.4.7 → 0.4.8

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.
@@ -12,7 +12,7 @@ import {
12
12
  } from './provider/index.js';
13
13
  import { isDangerousCommand, runShellCommand } from './shell.js';
14
14
  import { getBuiltinTools } from './tools.js';
15
- import { createSession, listSessions, loadSession, pruneSessions, saveSession } from './session-store.js';
15
+ import { createSession, deriveSessionTitle, listSessions, loadSession, pruneSessions, saveSession } from './session-store.js';
16
16
  import { getConfigValue, loadConfig, resetConfig, setConfigValue } from './config-store.js';
17
17
  import { evaluateCommandPolicy } from './command-policy.js';
18
18
  import { appendInputHistory, loadInputHistory } from './input-history-store.js';
@@ -55,7 +55,7 @@ function toOpenAIMessages(sessionMessages) {
55
55
  }
56
56
  mapped.push({
57
57
  role: msg.role,
58
- content: msg.content,
58
+ content: typeof msg.model_content === 'string' && msg.model_content ? msg.model_content : msg.content,
59
59
  ...(typeof msg.reasoning_content === 'string' && msg.reasoning_content ? { reasoning_content: msg.reasoning_content } : {}),
60
60
  ...(Array.isArray(msg.reasoning_details) && msg.reasoning_details.length > 0 ? { reasoning_details: msg.reasoning_details } : {}),
61
61
  ...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {})
@@ -102,6 +102,7 @@ function getCompletionCopy(language = 'zh') {
102
102
  'gateway.timeout_ms': '网关超时时间(毫秒)',
103
103
  'gateway.max_retries': '网关重试次数',
104
104
  'model.name': '当前模型名称',
105
+ 'model.fast_name': '快速模型名称',
105
106
  'model.max_context_tokens': '模型上下文 token 上限',
106
107
  'ui.language': '界面语言',
107
108
  'ui.reply_language': '回复语言',
@@ -156,6 +157,7 @@ function getCompletionCopy(language = 'zh') {
156
157
  exit: '退出聊天',
157
158
  commands: '列出 slash/自定义命令',
158
159
  status: '查看运行状态(mode/model/session)',
160
+ model: '查看或切换模型',
159
161
  mode: '设置执行模式:normal|auto|plan',
160
162
  compact: '压缩消息上下文',
161
163
  checkpoint: '创建/查看/加载检查点',
@@ -193,6 +195,7 @@ function getCompletionCopy(language = 'zh') {
193
195
  retryCommand: '重试上一条用户请求',
194
196
  stopCommand: '中止当前回答',
195
197
  statusCommand: '查看运行状态',
198
+ modelCommand: '查看或切换模型',
196
199
  resumeSession: '恢复一个已保存的会话'
197
200
  }
198
201
  },
@@ -204,6 +207,7 @@ function getCompletionCopy(language = 'zh') {
204
207
  'gateway.timeout_ms': 'gateway timeout in milliseconds',
205
208
  'gateway.max_retries': 'gateway retry count',
206
209
  'model.name': 'active model name',
210
+ 'model.fast_name': 'fast model name',
207
211
  'model.max_context_tokens': 'model context token limit',
208
212
  'ui.language': 'UI language',
209
213
  'ui.reply_language': 'reply language',
@@ -258,6 +262,7 @@ function getCompletionCopy(language = 'zh') {
258
262
  exit: 'exit chat',
259
263
  commands: 'list slash/custom commands',
260
264
  status: 'show runtime status (mode/model/session)',
265
+ model: 'show or switch model',
261
266
  mode: 'set execution mode: normal|auto|plan',
262
267
  compact: 'compress message context',
263
268
  checkpoint: 'create/list/load conversation checkpoints',
@@ -295,6 +300,7 @@ function getCompletionCopy(language = 'zh') {
295
300
  retryCommand: 'retry the last user request',
296
301
  stopCommand: 'stop the current response',
297
302
  statusCommand: 'show runtime status',
303
+ modelCommand: 'show or switch model',
298
304
  resumeSession: 'resume a saved session'
299
305
  }
300
306
  }
@@ -309,6 +315,7 @@ function describeConfigKey(key, mode = 'set', language = 'zh') {
309
315
  }
310
316
 
311
317
  const SUB_AGENT_ROLES = ['planner', 'advisor', 'coder', 'reviewer', 'tester', 'summarizer'];
318
+ const CODEWIKI_READ_ONLY_TOOLS = ['read', 'grep', 'list', 'glob', 'query_project_index', 'read_plan'];
312
319
  export const ROLE_TOOL_POLICY = {
313
320
  planner: ['read', 'grep', 'list', 'query_project_index', 'tool_search', 'glob', 'ast_query', 'read_ast_node', 'web_fetch', 'web_search', 'read_plan', 'update_plan'],
314
321
  advisor: ['read', 'grep', 'list', 'query_project_index', 'tool_search', 'read_plan'],
@@ -2112,13 +2119,21 @@ function buildRuntimeStateSnapshot({ currentSession, config, model, executionMod
2112
2119
  const currentContextTokens = parentTokens + subTokens;
2113
2120
  const maxContextTokens = effectiveMaxContextTokens(config);
2114
2121
  const contextUsagePct = maxContextTokens > 0 ? Math.min(100, Math.max(0, (currentContextTokens / maxContextTokens) * 100)) : 0;
2122
+ const planState = currentSession?.planState;
2115
2123
  const snapshot = {
2116
2124
  sessionId: currentSession?.id || '',
2117
- mode: executionMode || config.execution?.mode || 'auto',
2125
+ sessionTitle: currentSession?.title || '',
2126
+ messageCount: Array.isArray(currentSession?.messages) ? currentSession.messages.length : 0,
2127
+ mode: executionMode || config.execution?.mode || 'normal',
2118
2128
  sdkProvider: config.sdk?.provider || 'openai-compatible',
2119
2129
  agentRole: 'general',
2120
2130
  model: model || config.model?.name || '',
2121
- maxContextTokens
2131
+ mainModel: config.model?.name || '',
2132
+ fastModel: config.model?.fast_name || config.model?.name || '',
2133
+ maxContextTokens,
2134
+ pendingPlanApproval: planState?.status === 'pending_approval'
2135
+ ? { goal: planState.goal, summary: planState.finalSummary || planState.summary, filePath: planState.filePath, steps: planState.steps || [] }
2136
+ : null
2122
2137
  };
2123
2138
  Object.defineProperties(snapshot, {
2124
2139
  currentContextTokens: {
@@ -2131,11 +2146,6 @@ function buildRuntimeStateSnapshot({ currentSession, config, model, executionMod
2131
2146
  enumerable: false,
2132
2147
  writable: false
2133
2148
  },
2134
- pendingPlanApproval: {
2135
- value: currentSession?.planState?.status === 'pending_approval',
2136
- enumerable: false,
2137
- writable: false
2138
- },
2139
2149
  pendingReflectSkill: {
2140
2150
  value: currentSession?.planState?.status === 'pending_reflect_skill',
2141
2151
  enumerable: false,
@@ -2145,6 +2155,70 @@ function buildRuntimeStateSnapshot({ currentSession, config, model, executionMod
2145
2155
  return snapshot;
2146
2156
  }
2147
2157
 
2158
+ function resolveDefaultModel(config) {
2159
+ return String(config?.model?.name || '').trim();
2160
+ }
2161
+
2162
+ function resolveFastModel(config) {
2163
+ return String(config?.model?.fast_name || config?.model?.lite_name || config?.model?.name || '').trim();
2164
+ }
2165
+
2166
+ function normalizeGeneratedSessionTitle(value, fallback = '') {
2167
+ const cleaned = String(value || '')
2168
+ .replace(/^[\s"'`#::「『【\[]+|[\s"'`。.!??!」』】\]]+$/g, '')
2169
+ .replace(/\s+/g, ' ')
2170
+ .trim();
2171
+ const title = cleaned || fallback || '';
2172
+ if (!title) return '';
2173
+ return title.length > 48 ? `${title.slice(0, 45).trimEnd()}...` : title;
2174
+ }
2175
+
2176
+ function shouldReplaceSessionTitle(title) {
2177
+ const value = String(title || '').trim();
2178
+ return !value || value === '新会话' || value === 'New session';
2179
+ }
2180
+
2181
+ async function generateSessionTitle({ userText, assistantText = '', config, signal }) {
2182
+ const fallback = normalizeGeneratedSessionTitle(deriveSessionTitle([{ role: 'user', content: userText }]));
2183
+ const latestConfig = await loadConfig().catch(() => config);
2184
+ const effectiveConfig = latestConfig || config;
2185
+ const fastModel = resolveFastModel(effectiveConfig);
2186
+ if (!fastModel) return fallback;
2187
+ const titleInput = [
2188
+ `User:\n${String(userText || '').slice(0, 1200)}`,
2189
+ assistantText ? `Assistant:\n${String(assistantText || '').slice(0, 1600)}` : ''
2190
+ ].filter(Boolean).join('\n\n');
2191
+ try {
2192
+ const result = await createChatCompletion({
2193
+ sdkProvider: effectiveConfig.sdk?.provider,
2194
+ baseUrl: effectiveConfig.gateway.base_url,
2195
+ apiKey: effectiveConfig.gateway.api_key,
2196
+ model: fastModel,
2197
+ messages: [
2198
+ {
2199
+ role: 'system',
2200
+ content: [
2201
+ 'Generate a concise chat session title.',
2202
+ 'Base it on the completed user-assistant exchange, not only the user prompt.',
2203
+ 'Return only the title text.',
2204
+ 'Use the same language as the user when possible.',
2205
+ 'No quotes, no markdown, no punctuation at the ends.',
2206
+ 'Maximum 18 Chinese characters or 8 English words.'
2207
+ ].join(' ')
2208
+ },
2209
+ { role: 'user', content: titleInput }
2210
+ ],
2211
+ tools: [],
2212
+ timeoutMs: Math.min(Number(effectiveConfig.gateway?.timeout_ms || 30000), 30000),
2213
+ maxRetries: 0,
2214
+ signal
2215
+ });
2216
+ return normalizeGeneratedSessionTitle(result?.text, fallback) || fallback;
2217
+ } catch {
2218
+ return fallback;
2219
+ }
2220
+ }
2221
+
2148
2222
  function estimatePromptTokensForRequest(sessionMessages, userText = '') {
2149
2223
  const tokenMsgs = [
2150
2224
  ...(Array.isArray(sessionMessages) ? sessionMessages : []),
@@ -2330,6 +2404,7 @@ async function expandFileMentions(rawText, workspaceRoot = process.cwd()) {
2330
2404
 
2331
2405
  async function askModel({
2332
2406
  text,
2407
+ modelText,
2333
2408
  session,
2334
2409
  config,
2335
2410
  model,
@@ -2344,13 +2419,14 @@ async function askModel({
2344
2419
  maxSteps: maxStepsOverride,
2345
2420
  skipAnalysisNudge = false
2346
2421
  }) {
2422
+ const modelInputText = typeof modelText === 'string' && modelText ? modelText : text;
2347
2423
  const maxContextTokens = effectiveMaxContextTokens(config);
2348
2424
  const triggerPct = Number(config.context?.preflight_trigger_pct || 92);
2349
2425
  const hardPct = Number(config.context?.hard_limit_pct || 98);
2350
- const preflightTokens = estimatePromptTokensForRequest(session.messages, text);
2426
+ const preflightTokens = estimatePromptTokensForRequest(session.messages, modelInputText);
2351
2427
  const preflightPct = (preflightTokens / maxContextTokens) * 100;
2352
2428
 
2353
- if (preflightPct >= triggerPct) {
2429
+ if (persistSession && preflightPct >= triggerPct) {
2354
2430
  const auto = compactMessagesLocally(session.messages, {
2355
2431
  mode: preflightPct >= hardPct ? 'aggressive' : 'conservative'
2356
2432
  });
@@ -2414,11 +2490,19 @@ async function askModel({
2414
2490
  }
2415
2491
 
2416
2492
  if (text) {
2417
- session.messages.push(stampedMessage('user', text));
2493
+ const shouldGenerateTitle = !session.messages.some((msg) => msg?.role === 'user');
2494
+ const modelExtra =
2495
+ typeof modelText === 'string' && modelText && modelText !== text ? { model_content: modelText } : {};
2496
+ session.messages.push(stampedMessage('user', text, modelExtra));
2497
+ if (!shouldGenerateTitle) {
2498
+ session.title = deriveSessionTitle(session.messages);
2499
+ }
2500
+ session.model = model || config.model.name;
2501
+ session.mode = executionMode || config.execution?.mode || 'normal';
2418
2502
  if (persistSession) await saveSession(session);
2419
2503
  }
2420
2504
 
2421
- const projectContextSnippet = await buildProjectContextSnippet(process.cwd(), text).catch(() => '');
2505
+ const projectContextSnippet = await buildProjectContextSnippet(process.cwd(), modelInputText).catch(() => '');
2422
2506
  const projectContextGuidance =
2423
2507
  'Use this project context as lightweight guidance and verify important details with fresh reads when needed.';
2424
2508
  const effectiveSystemPrompt = projectContextSnippet
@@ -2478,6 +2562,22 @@ async function askModel({
2478
2562
  }
2479
2563
 
2480
2564
  let activeAssistantIndex = -1;
2565
+ const pendingToolMeta = new Map();
2566
+ const attachToolMetaToSessionCall = (toolId, meta = {}) => {
2567
+ if (!toolId) return false;
2568
+ for (let i = session.messages.length - 1; i >= 0; i -= 1) {
2569
+ const msg = session.messages[i];
2570
+ if (msg?.role !== 'assistant' || !Array.isArray(msg.tool_calls)) continue;
2571
+ const call = msg.tool_calls.find((tc) => String(tc?.id || '') === String(toolId));
2572
+ if (!call) continue;
2573
+ if (Number.isFinite(Number(meta.durationMs))) call.durationMs = Number(meta.durationMs);
2574
+ if (typeof meta.summary === 'string' && meta.summary.trim()) call.summary = meta.summary.trim();
2575
+ if (typeof meta.status === 'string' && meta.status.trim()) call.status = meta.status.trim();
2576
+ msg.at = new Date().toISOString();
2577
+ return true;
2578
+ }
2579
+ return false;
2580
+ };
2481
2581
  const wrappedAgentEvent = (event) => {
2482
2582
  // Always accumulate messages in session (for token tracking), only save when persisting
2483
2583
  if (event?.type === 'assistant:start') {
@@ -2508,19 +2608,42 @@ async function askModel({
2508
2608
  if (persistSession) scheduleSessionSave();
2509
2609
  }
2510
2610
  activeAssistantIndex = -1;
2611
+ } else if (event?.type === 'tool:end' || event?.type === 'tool:error' || event?.type === 'tool:blocked') {
2612
+ const toolId = String(event.id || '');
2613
+ if (toolId) {
2614
+ const meta = {
2615
+ durationMs: Number.isFinite(Number(event.durationMs)) ? Number(event.durationMs) : undefined,
2616
+ summary: typeof event.summary === 'string' ? event.summary : '',
2617
+ status:
2618
+ event.type === 'tool:error'
2619
+ ? 'error'
2620
+ : event.type === 'tool:blocked'
2621
+ ? 'blocked'
2622
+ : 'done'
2623
+ };
2624
+ pendingToolMeta.set(toolId, meta);
2625
+ if (attachToolMetaToSessionCall(toolId, meta) && persistSession) scheduleSessionSave();
2626
+ }
2511
2627
  } else if (event?.type === 'tool:result') {
2628
+ const toolId = String(event.id || '');
2629
+ const meta = pendingToolMeta.get(toolId) || {};
2512
2630
  session.messages.push(
2513
2631
  stampedMessage('tool', event.content || '', {
2514
- tool_call_id: event.id || ''
2632
+ tool_call_id: toolId,
2633
+ ...(Number.isFinite(Number(meta.durationMs)) ? { tool_duration_ms: Number(meta.durationMs) } : {}),
2634
+ ...(meta.summary ? { tool_summary: meta.summary } : {}),
2635
+ ...(meta.status ? { tool_status: meta.status } : {})
2515
2636
  })
2516
2637
  );
2638
+ pendingToolMeta.delete(toolId);
2517
2639
  if (persistSession) scheduleSessionSave();
2518
2640
  }
2519
2641
 
2520
2642
  if (onAgentEvent) onAgentEvent(event);
2521
2643
  };
2522
2644
 
2523
- const loopUserPrompt = persistSession ? '' : text;
2645
+ const loopUserPrompt = persistSession ? '' : modelInputText;
2646
+ const expectedModelText = typeof modelText === 'string' && modelText && modelText !== text ? modelText : '';
2524
2647
  const loopResult = await runAgentLoop({
2525
2648
  systemPrompt: effectiveSystemPrompt,
2526
2649
  userPrompt: loopUserPrompt,
@@ -2530,7 +2653,7 @@ async function askModel({
2530
2653
  toolHandlers: filteredHandlers,
2531
2654
  initialMessages: toOpenAIMessages(session.messages),
2532
2655
  onEvent: wrappedAgentEvent,
2533
- executionMode: executionMode || config.execution?.mode || 'auto',
2656
+ executionMode: executionMode || config.execution?.mode || 'normal',
2534
2657
  alwaysAllowTools:
2535
2658
  alwaysAllowTools || config.execution?.always_allow_tools || ['run', 'read', 'write'],
2536
2659
  toolResultMaxChars: config.context?.tool_result_max_chars || 12000,
@@ -2581,6 +2704,26 @@ async function askModel({
2581
2704
  session.messages = loopResult.messages
2582
2705
  .filter((m) => m.role !== 'system')
2583
2706
  .map((m) => ({ ...m, at: new Date().toISOString() }));
2707
+ if (expectedModelText) {
2708
+ for (let i = session.messages.length - 1; i >= 0; i -= 1) {
2709
+ const message = session.messages[i];
2710
+ if (message?.role === 'user' && message.content === expectedModelText) {
2711
+ message.content = text;
2712
+ message.model_content = expectedModelText;
2713
+ break;
2714
+ }
2715
+ }
2716
+ }
2717
+ if (shouldReplaceSessionTitle(session.title)) {
2718
+ session.title = await generateSessionTitle({
2719
+ userText: text,
2720
+ assistantText: loopResult.text || '',
2721
+ config,
2722
+ signal
2723
+ });
2724
+ }
2725
+ session.model = model || config.model.name;
2726
+ session.mode = executionMode || config.execution?.mode || 'normal';
2584
2727
  await flushScheduledSave();
2585
2728
  await saveSession(session);
2586
2729
  try {
@@ -2684,7 +2827,60 @@ async function runSubAgentTask({
2684
2827
  blockedCount,
2685
2828
  toolErrorCount,
2686
2829
  hasErrorLine,
2687
- artifactPaths: artifactPaths.slice(0, SUB_AGENT_HANDOFF_MAX_ITEMS)
2830
+ artifactPaths: artifactPaths.slice(0, SUB_AGENT_HANDOFF_MAX_ITEMS),
2831
+ messages: Array.isArray(subSession.messages) ? structuredClone(subSession.messages) : []
2832
+ };
2833
+ }
2834
+
2835
+ function buildPlanStepTranscript({ stepRecord, stepIndex, totalSteps, messages }) {
2836
+ const toolCardsById = new Map();
2837
+ const toolCards = [];
2838
+ const source = Array.isArray(messages) ? messages : [];
2839
+
2840
+ for (const msg of source) {
2841
+ if (msg?.role === 'assistant' && Array.isArray(msg.tool_calls)) {
2842
+ for (const tc of msg.tool_calls) {
2843
+ const id = String(tc?.id || `tool-${toolCards.length + 1}`);
2844
+ if (toolCardsById.has(id)) continue;
2845
+ const card = {
2846
+ id,
2847
+ name: tc?.function?.name || tc?.name || 'tool',
2848
+ arguments: tc?.function?.arguments || tc?.arguments || {},
2849
+ status: tc?.status || 'done',
2850
+ durationMs: Number.isFinite(Number(tc?.durationMs)) ? Number(tc.durationMs) : null,
2851
+ summary: tc?.summary || '',
2852
+ result: ''
2853
+ };
2854
+ toolCardsById.set(id, card);
2855
+ toolCards.push(card);
2856
+ }
2857
+ } else if (msg?.role === 'tool') {
2858
+ const id = String(msg.tool_call_id || '');
2859
+ const card = toolCardsById.get(id);
2860
+ if (!card) continue;
2861
+ card.result = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || '');
2862
+ if (typeof msg.tool_summary === 'string' && msg.tool_summary.trim()) card.summary = msg.tool_summary.trim();
2863
+ if (Number.isFinite(Number(msg.tool_duration_ms))) card.durationMs = Number(msg.tool_duration_ms);
2864
+ if (typeof msg.tool_status === 'string' && msg.tool_status.trim()) card.status = msg.tool_status.trim();
2865
+ }
2866
+ }
2867
+
2868
+ const segments = [];
2869
+ if (toolCards.length > 0) {
2870
+ segments.push({ type: 'tools', cards: toolCards });
2871
+ }
2872
+ if (stepRecord.role === 'summarizer' && stepRecord.output) {
2873
+ segments.push({ type: 'text', text: stepRecord.output, isStreaming: false });
2874
+ }
2875
+
2876
+ return {
2877
+ step: stepIndex + 1,
2878
+ total: totalSteps,
2879
+ role: stepRecord.role || 'general',
2880
+ title: stepRecord.title || '',
2881
+ status: stepRecord.failed ? 'failed' : 'done',
2882
+ summary: stepRecord.failed ? stepRecord.failureReason : trimInline(stepRecord.output || '', 160),
2883
+ segments
2688
2884
  };
2689
2885
  }
2690
2886
 
@@ -2712,8 +2908,11 @@ async function executePlanWithSubAgents({
2712
2908
  return { text: '(no steps to execute)', aborted: false };
2713
2909
  }
2714
2910
 
2911
+ emitPlanEvent({ type: 'assistant:start' });
2912
+
2715
2913
  const priorSteps = [];
2716
2914
  const results = [];
2915
+ const transcript = [];
2717
2916
 
2718
2917
  // Emit structured plan steps so TUI can show all steps with real role/title
2719
2918
  emitPlanEvent({
@@ -2725,6 +2924,15 @@ async function executePlanWithSubAgents({
2725
2924
  const step = steps[i];
2726
2925
  if (signal?.aborted) break;
2727
2926
 
2927
+ emitPlanEvent({
2928
+ type: 'plan:step_start',
2929
+ planFile: planFilePath,
2930
+ step: i + 1,
2931
+ total: steps.length,
2932
+ role: step.role,
2933
+ title: step.title
2934
+ });
2935
+
2728
2936
  emitPlanEvent({
2729
2937
  type: 'plan:progress',
2730
2938
  planFile: planFilePath,
@@ -2772,6 +2980,7 @@ async function executePlanWithSubAgents({
2772
2980
  toolErrorCount: output.toolErrorCount || 0,
2773
2981
  hasErrorLine: output.hasErrorLine || false,
2774
2982
  artifactPaths: output.artifactPaths || [],
2983
+ messages: output.messages || [],
2775
2984
  failed:
2776
2985
  output.hasErrorLine ||
2777
2986
  stepOutputHasFailureSignals(step.role, output.text || ''),
@@ -2785,6 +2994,12 @@ async function executePlanWithSubAgents({
2785
2994
  }
2786
2995
  priorSteps.push(stepRecord);
2787
2996
  results.push(stepRecord);
2997
+ transcript.push(buildPlanStepTranscript({
2998
+ stepRecord,
2999
+ stepIndex: i,
3000
+ totalSteps: steps.length,
3001
+ messages: output.messages || []
3002
+ }));
2788
3003
 
2789
3004
  // Write step result to plan file for subsequent steps to read
2790
3005
  if (planFilePath) {
@@ -2809,6 +3024,17 @@ async function executePlanWithSubAgents({
2809
3024
  summary: stepRecord.failed ? stepRecord.failureReason : trimInline(stepRecord.output, 160)
2810
3025
  });
2811
3026
 
3027
+ emitPlanEvent({
3028
+ type: 'plan:step_done',
3029
+ planFile: planFilePath,
3030
+ step: i + 1,
3031
+ total: steps.length,
3032
+ role: step.role,
3033
+ title: step.title,
3034
+ status: stepRecord.failed ? 'failed' : 'done',
3035
+ summary: stepRecord.failed ? stepRecord.failureReason : trimInline(stepRecord.output, 160)
3036
+ });
3037
+
2812
3038
  if (stepRecord.failed && i < steps.length - 1) {
2813
3039
  const summarizerIndex = steps.findIndex((candidate, index) => index > i && candidate.role === 'summarizer');
2814
3040
  if (summarizerIndex > i) {
@@ -2848,7 +3074,11 @@ async function executePlanWithSubAgents({
2848
3074
  return {
2849
3075
  text: summaryLines.join('\n'),
2850
3076
  aborted: !!signal?.aborted,
2851
- results
3077
+ results,
3078
+ transcript,
3079
+ sessionText:
3080
+ [...results].reverse().find((r) => r.role === 'summarizer')?.output ||
3081
+ summaryLines.join('\n')
2852
3082
  };
2853
3083
  }
2854
3084
 
@@ -3007,10 +3237,29 @@ function renderAutoPlanMarkdown({
3007
3237
  }
3008
3238
 
3009
3239
  function parseProjectRequirementsOptions(args = []) {
3010
- const raw = args.join(' ').trim();
3240
+ let depth = 'standard';
3241
+ const focusArgs = [];
3242
+ for (const arg of Array.isArray(args) ? args : []) {
3243
+ const value = String(arg || '').trim();
3244
+ const normalized = value.toLowerCase();
3245
+ if (['--fast', '--quick', '--lite', '--light', '--快速'].includes(normalized)) {
3246
+ depth = 'fast';
3247
+ continue;
3248
+ }
3249
+ if (['--standard', '--balanced', '--默认', '--标准'].includes(normalized)) {
3250
+ depth = 'standard';
3251
+ continue;
3252
+ }
3253
+ if (['--deep', '--full', '--thorough', '--深度'].includes(normalized)) {
3254
+ depth = 'deep';
3255
+ continue;
3256
+ }
3257
+ focusArgs.push(arg);
3258
+ }
3259
+ const raw = focusArgs.join(' ').trim();
3011
3260
  const normalized = raw.toLowerCase();
3012
3261
  const hasIgnoreIntent = /(忽略|跳过|不生成|不要|无需|排除|exclude|skip|omit|without|no\s+)/i.test(raw);
3013
- if (!hasIgnoreIntent) return { raw, ignoredSections: [] };
3262
+ if (!hasIgnoreIntent) return { raw, focusArgs, depth, ignoredSections: [] };
3014
3263
 
3015
3264
  const ignored = [];
3016
3265
  for (const section of PROJECT_REQUIREMENTS_SECTION_MARKERS) {
@@ -3023,7 +3272,7 @@ function parseProjectRequirementsOptions(args = []) {
3023
3272
  });
3024
3273
  if (matched) ignored.push(section);
3025
3274
  }
3026
- return { raw, ignoredSections: ignored };
3275
+ return { raw, focusArgs, depth, ignoredSections: ignored };
3027
3276
  }
3028
3277
 
3029
3278
  function renderProjectRequirementsSectionContract(ignoredSections = []) {
@@ -3041,7 +3290,7 @@ function renderProjectRequirementsSectionContract(ignoredSections = []) {
3041
3290
 
3042
3291
  function buildProjectRequirementsSteps(renderedSkillPrompt, args = []) {
3043
3292
  const options = parseProjectRequirementsOptions(args);
3044
- const userArgs = args.join(' ').trim();
3293
+ const userArgs = options.raw;
3045
3294
  const requestedFocus = userArgs ? `User request/focus: ${userArgs}` : 'User request/focus: full workspace requirements report.';
3046
3295
  const reportDate = formatLocalDate();
3047
3296
  const reportPath = `docs/requirements/${reportDate}-project-requirements.html`;
@@ -3060,6 +3309,120 @@ function buildProjectRequirementsSteps(renderedSkillPrompt, args = []) {
3060
3309
  'Do not invent dates; use the report paths above.'
3061
3310
  ].join('\n');
3062
3311
 
3312
+ const writeReportStep = {
3313
+ title: '🎨 Write banking-style requirements HTML report',
3314
+ role: 'coder',
3315
+ task: [
3316
+ 'Create the final project requirements report from the accumulated plan context.',
3317
+ reportContract,
3318
+ 'Follow the project-requirements skill instructions below exactly, including chunked HTML writing for medium/large reports.',
3319
+ 'Use the blue/white/gray banking-style shell and produce polished inline HTML/CSS/SVG diagrams. Keep the report professional, light, and conservative.',
3320
+ 'Organize the main requirements section primarily by API/interface business requirement cards.',
3321
+ 'The final HTML must be self-contained and directly openable from disk.',
3322
+ 'Write the primary report to the exact primary report path above. Create the companion Markdown only if useful.',
3323
+ 'Skill instructions:',
3324
+ renderedSkillPrompt
3325
+ ].join('\n\n')
3326
+ };
3327
+ const reviewStep = {
3328
+ title: '🔎 Review API coverage and traceability',
3329
+ role: 'reviewer',
3330
+ task: [
3331
+ 'Review the generated requirements report against the project-requirements contract and accumulated evidence.',
3332
+ reportContract,
3333
+ 'Check that major APIs/interfaces are represented, business requirements are decomposed per API, evidence paths are present, inferred/unknown content is labeled, diagrams are visible as inline HTML/CSS/SVG without external rendering libraries, and the report path matches the required local date.',
3334
+ 'Check that the visual style is light blue/white/gray and suitable for banking/financial review.',
3335
+ 'Report concrete gaps and risks only. Do not rewrite the whole report.'
3336
+ ].join('\n')
3337
+ };
3338
+ const summaryStep = {
3339
+ title: '🧾 Summarize final report and unresolved questions',
3340
+ role: 'summarizer',
3341
+ task: [
3342
+ 'Synthesize the project requirements pipeline results into a concise final status for the user.',
3343
+ reportContract,
3344
+ 'Mention the generated report path, API/interface coverage, strongest business requirement findings, unresolved questions, what was not verified, and the best next action.',
3345
+ 'Do not re-analyze the codebase unless the accumulated evidence is clearly insufficient.'
3346
+ ].join('\n')
3347
+ };
3348
+
3349
+ if (options.depth === 'fast') {
3350
+ return [
3351
+ {
3352
+ title: '⚡ Map evidence and interfaces',
3353
+ role: 'planner',
3354
+ task: [
3355
+ 'Quickly map project evidence and build a practical API/interface inventory before report writing.',
3356
+ reportContract,
3357
+ 'Inspect top-level docs, manifests, route/command entry points, obvious handlers, schemas, tests, and project index results when available.',
3358
+ 'Produce a concise evidence map and major interface inventory with evidence paths, prioritizing broad coverage over exhaustive edge cases.',
3359
+ 'Do not write the final report.'
3360
+ ].join('\n')
3361
+ },
3362
+ {
3363
+ title: '🧩 Synthesize requirements, flows, and risks',
3364
+ role: 'advisor',
3365
+ task: [
3366
+ 'Synthesize requirement-ready findings from the evidence map and interface inventory.',
3367
+ reportContract,
3368
+ 'For major interfaces capture capability, actor, trigger, inputs, outputs, business rules, validation/permission notes, data reads/writes, core flow dependencies, risks, acceptance criteria, and open questions.',
3369
+ 'Keep findings compact and API-centered. Preserve evidence paths and EXTRACTED/INFERRED/UNKNOWN labels. Do not write the final report.'
3370
+ ].join('\n')
3371
+ },
3372
+ writeReportStep,
3373
+ {
3374
+ title: '🔎 Review and summarize coverage',
3375
+ role: 'reviewer',
3376
+ task: [
3377
+ 'Review the generated report and produce the final user-facing status in one pass.',
3378
+ reportContract,
3379
+ 'Check major interface coverage, evidence paths, EXTRACTED/INFERRED/UNKNOWN labels, visible diagrams, and report path.',
3380
+ 'Do not rewrite the whole report. Report concrete gaps, unresolved questions, and the single best next action.'
3381
+ ].join('\n')
3382
+ }
3383
+ ];
3384
+ }
3385
+
3386
+ if (options.depth === 'standard') {
3387
+ return [
3388
+ {
3389
+ title: '🧭 Map entry points and evidence sources',
3390
+ role: 'planner',
3391
+ task: [
3392
+ 'Map project entry points and evidence sources before any report writing.',
3393
+ reportContract,
3394
+ 'Inspect top-level docs, package manifests, route/command entry points, tests, obvious interface files, and project index results when useful.',
3395
+ 'Produce a concise evidence map grouped by docs, routes/commands, handlers, schemas, tests, configuration, storage, and operations.',
3396
+ 'Include evidence paths and open questions. Do not write the final report.'
3397
+ ].join('\n')
3398
+ },
3399
+ {
3400
+ title: '📚 Build API and interface inventory',
3401
+ role: 'planner',
3402
+ task: [
3403
+ 'Build the canonical API/interface inventory using the evidence map.',
3404
+ reportContract,
3405
+ 'Enumerate every major HTTP endpoint, CLI command, tool call, MCP/RPC handler, queue/scheduled job, exported SDK function, and user-facing workflow entry point.',
3406
+ 'For each item include type, route/command/function, owner module, evidence path, likely actor, and whether it is EXTRACTED, INFERRED, or UNKNOWN.',
3407
+ 'Do not write the final report.'
3408
+ ].join('\n')
3409
+ },
3410
+ {
3411
+ title: '🧩 Decompose requirements, flows, data, and risks',
3412
+ role: 'advisor',
3413
+ task: [
3414
+ 'Decompose requirement-ready findings for each major API/interface from the inventory.',
3415
+ reportContract,
3416
+ 'For each interface capture business capability, actor, user goal, trigger, inputs, outputs, preconditions, main/alternate flows, business rules, validation and permission checks, sensitive data, data reads/writes, side effects, acceptance criteria, and open questions.',
3417
+ 'Keep findings API-centered rather than module-centered. Preserve evidence paths and EXTRACTED/INFERRED/UNKNOWN labels. Do not write the final report.'
3418
+ ].join('\n')
3419
+ },
3420
+ writeReportStep,
3421
+ reviewStep,
3422
+ summaryStep
3423
+ ];
3424
+ }
3425
+
3063
3426
  return [
3064
3427
  {
3065
3428
  title: '🧭 Map entry points and evidence sources',
@@ -3123,42 +3486,9 @@ function buildProjectRequirementsSteps(renderedSkillPrompt, args = []) {
3123
3486
  'Favor clear business process decomposition over broad architecture prose. Do not write the final report.'
3124
3487
  ].join('\n')
3125
3488
  },
3126
- {
3127
- title: '🎨 Write banking-style requirements HTML report',
3128
- role: 'coder',
3129
- task: [
3130
- 'Create the final project requirements report from the accumulated plan context.',
3131
- reportContract,
3132
- 'Follow the project-requirements skill instructions below exactly, including chunked HTML writing for medium/large reports.',
3133
- 'Use the blue/white/gray banking-style shell and produce polished inline HTML/CSS/SVG diagrams. Keep the report professional, light, and conservative.',
3134
- 'Organize the main requirements section primarily by API/interface business requirement cards.',
3135
- 'The final HTML must be self-contained and directly openable from disk.',
3136
- 'Write the primary report to the exact primary report path above. Create the companion Markdown only if useful.',
3137
- 'Skill instructions:',
3138
- renderedSkillPrompt
3139
- ].join('\n\n')
3140
- },
3141
- {
3142
- title: '🔎 Review API coverage and traceability',
3143
- role: 'reviewer',
3144
- task: [
3145
- 'Review the generated requirements report against the project-requirements contract and accumulated evidence.',
3146
- reportContract,
3147
- 'Check that major APIs/interfaces are represented, business requirements are decomposed per API, evidence paths are present, inferred/unknown content is labeled, diagrams are visible as inline HTML/CSS/SVG without external rendering libraries, and the report path matches the required local date.',
3148
- 'Check that the visual style is light blue/white/gray and suitable for banking/financial review.',
3149
- 'Report concrete gaps and risks only. Do not rewrite the whole report.'
3150
- ].join('\n')
3151
- },
3152
- {
3153
- title: '🧾 Summarize final report and unresolved questions',
3154
- role: 'summarizer',
3155
- task: [
3156
- 'Synthesize the project requirements pipeline results into a concise final status for the user.',
3157
- reportContract,
3158
- 'Mention the generated report path, API/interface coverage, strongest business requirement findings, unresolved questions, what was not verified, and the best next action.',
3159
- 'Do not re-analyze the codebase unless the accumulated evidence is clearly insufficient.'
3160
- ].join('\n')
3161
- }
3489
+ writeReportStep,
3490
+ reviewStep,
3491
+ summaryStep
3162
3492
  ];
3163
3493
  }
3164
3494
 
@@ -3200,7 +3530,8 @@ async function createProjectRequirementsShell({
3200
3530
  manifestPath,
3201
3531
  planFile,
3202
3532
  goal,
3203
- steps
3533
+ steps,
3534
+ depth = 'standard'
3204
3535
  }) {
3205
3536
  const workspaceRoot = process.cwd();
3206
3537
  const absoluteReportPath = path.resolve(workspaceRoot, reportPath);
@@ -3231,6 +3562,7 @@ async function createProjectRequirementsShell({
3231
3562
  ];
3232
3563
  const manifest = {
3233
3564
  status: 'running',
3565
+ depth,
3234
3566
  goal,
3235
3567
  html: reportPath,
3236
3568
  markdown: companionPath,
@@ -3278,7 +3610,8 @@ async function runProjectRequirementsPipeline({
3278
3610
  onSubSessionActive
3279
3611
  }) {
3280
3612
  const renderedSkillPrompt = await expandFileMentions(renderCommandPrompt(custom, parsedInput.args), process.cwd());
3281
- const userFocus = parsedInput.args.join(' ').trim();
3613
+ const options = parseProjectRequirementsOptions(parsedInput.args);
3614
+ const userFocus = options.raw;
3282
3615
  const goal = userFocus ? `project requirements report: ${userFocus}` : 'project requirements report';
3283
3616
  const reportDate = formatLocalDate();
3284
3617
  const reportPath = `docs/requirements/${reportDate}-project-requirements.html`;
@@ -3298,15 +3631,17 @@ async function runProjectRequirementsPipeline({
3298
3631
  manifestPath,
3299
3632
  planFile,
3300
3633
  goal,
3301
- steps
3634
+ steps,
3635
+ depth: options.depth
3302
3636
  });
3303
3637
  const planState = {
3304
3638
  status: 'approved',
3305
3639
  source: 'project-requirements',
3640
+ depth: options.depth,
3306
3641
  goal,
3307
3642
  filePath: planFile,
3308
- summary: 'Dedicated sub-agent pipeline for project requirements report generation.',
3309
- finalSummary: 'Executing project requirements pipeline.',
3643
+ summary: `Dedicated ${options.depth} sub-agent pipeline for project requirements report generation.`,
3644
+ finalSummary: `Executing ${options.depth} project requirements pipeline.`,
3310
3645
  steps
3311
3646
  };
3312
3647
  if (onAgentEvent) {
@@ -3511,7 +3846,8 @@ function compactHistoryPreview(value, maxChars = 72) {
3511
3846
  function formatHistoryList({ currentSession, sessions }) {
3512
3847
  const currentMessages = Array.isArray(currentSession?.messages) ? currentSession.messages.length : 0;
3513
3848
  const lines = [
3514
- `Current session ${currentSession.id}`,
3849
+ `Current session ${currentSession.title || currentSession.id}`,
3850
+ `Session id ${currentSession.id}`,
3515
3851
  `Messages ${currentMessages}`,
3516
3852
  '',
3517
3853
  'Recent sessions'
@@ -3520,8 +3856,9 @@ function formatHistoryList({ currentSession, sessions }) {
3520
3856
  for (const [index, session] of sessions.entries()) {
3521
3857
  const count = Number(session.messageCount || 0);
3522
3858
  lines.push(
3523
- `${index + 1}. ${session.id}`,
3524
- ` ${count} ${count === 1 ? 'msg' : 'msgs'} | ${formatHistoryTimestamp(session.updatedAt)}`,
3859
+ `${index + 1}. ${session.title || session.id}`,
3860
+ ` id=${session.id}`,
3861
+ ` ${count} ${count === 1 ? 'msg' : 'msgs'} | ${formatHistoryTimestamp(session.updatedAt)}${session.model ? ` | ${session.model}` : ''}`,
3525
3862
  ` ${compactHistoryPreview(session.preview)}`,
3526
3863
  ` resume: /history resume ${session.id}`
3527
3864
  );
@@ -3538,6 +3875,9 @@ export async function createChatRuntime({
3538
3875
  systemPrompt,
3539
3876
  requestToolApproval
3540
3877
  }) {
3878
+ if (session && typeof session === 'object' && !session.projectDir) {
3879
+ session.projectDir = process.cwd();
3880
+ }
3541
3881
  let activeRequestToolApproval = typeof requestToolApproval === 'function' ? requestToolApproval : null;
3542
3882
  const startupEvents = [];
3543
3883
  const initialIndex = await initializeProjectIndex(process.cwd()).catch(() => null);
@@ -3573,8 +3913,12 @@ export async function createChatRuntime({
3573
3913
  }
3574
3914
  let currentSession = session;
3575
3915
  let config = initialConfig;
3916
+ model = model || currentSession?.model || resolveDefaultModel(config);
3917
+ if (currentSession && typeof currentSession === 'object') {
3918
+ currentSession.model = model;
3919
+ }
3576
3920
  const baseSystemPrompt = systemPrompt;
3577
- let executionMode = config.execution?.mode || 'auto';
3921
+ let executionMode = config.execution?.mode || 'normal';
3578
3922
  if (hasPendingPlanApproval(currentSession)) {
3579
3923
  executionMode = 'plan';
3580
3924
  }
@@ -3600,6 +3944,7 @@ export async function createChatRuntime({
3600
3944
  let historySessionCache = [
3601
3945
  {
3602
3946
  id: currentSession.id,
3947
+ title: currentSession.title || deriveSessionTitle(currentSession.messages || []),
3603
3948
  messageCount: Array.isArray(currentSession.messages) ? currentSession.messages.length : 0
3604
3949
  }
3605
3950
  ];
@@ -3609,10 +3954,12 @@ export async function createChatRuntime({
3609
3954
  const merged = [
3610
3955
  {
3611
3956
  id: currentSession.id,
3957
+ title: currentSession.title || deriveSessionTitle(currentSession.messages || []),
3612
3958
  messageCount: Array.isArray(currentSession.messages) ? currentSession.messages.length : 0
3613
3959
  },
3614
3960
  ...initialSessions.map((session) => ({
3615
3961
  id: session.id,
3962
+ title: session.title || '',
3616
3963
  messageCount: Number(session.messageCount || 0)
3617
3964
  }))
3618
3965
  ];
@@ -3635,6 +3982,7 @@ export async function createChatRuntime({
3635
3982
  'gateway.base_url',
3636
3983
  'gateway.api_key',
3637
3984
  'model.name',
3985
+ 'model.fast_name',
3638
3986
  'ui.language',
3639
3987
  'ui.reply_language',
3640
3988
  'execution.mode',
@@ -3663,6 +4011,7 @@ export async function createChatRuntime({
3663
4011
  const commandPriorityOrder = [
3664
4012
  '/help',
3665
4013
  '/status',
4014
+ '/model',
3666
4015
  '/config',
3667
4016
  '/memory',
3668
4017
  '/capture',
@@ -3687,6 +4036,7 @@ export async function createChatRuntime({
3687
4036
  { name: 'exit', description: completionCopy.commands.exit },
3688
4037
  { name: 'commands', description: completionCopy.commands.commands },
3689
4038
  { name: 'status', description: completionCopy.commands.status },
4039
+ { name: 'model', description: completionCopy.commands.model },
3690
4040
  { name: 'mode', description: completionCopy.commands.mode },
3691
4041
  { name: 'compact', description: completionCopy.commands.compact },
3692
4042
  { name: 'checkpoint', description: completionCopy.commands.checkpoint },
@@ -3737,6 +4087,7 @@ export async function createChatRuntime({
3737
4087
  const historyTemplates = ['/history list', '/history current', '/history resume <session_id>'];
3738
4088
  const memoryTemplates = ['/memory list <scope>', '/memory search <scope> <query>', '/memory forget <scope> <id>'];
3739
4089
  const modeTemplates = ['/mode normal', '/mode auto', '/mode plan'];
4090
+ const modelTemplates = ['/model current', '/model main', '/model fast', '/model set <name>'];
3740
4091
  const checkpointTemplates = [
3741
4092
  '/checkpoint create <name>',
3742
4093
  '/checkpoint list',
@@ -3755,6 +4106,7 @@ export async function createChatRuntime({
3755
4106
  ...memoryTemplates,
3756
4107
  ...historyTemplates,
3757
4108
  ...modeTemplates,
4109
+ ...modelTemplates,
3758
4110
  ...checkpointTemplates,
3759
4111
  ...specTemplates,
3760
4112
  ...planTemplates,
@@ -3802,6 +4154,7 @@ export async function createChatRuntime({
3802
4154
  'memory',
3803
4155
  'compact',
3804
4156
  'mode',
4157
+ 'model',
3805
4158
  'checkpoint',
3806
4159
  'plan',
3807
4160
  'agents',
@@ -3821,6 +4174,7 @@ export async function createChatRuntime({
3821
4174
  for (const template of memoryTemplates) registerSuggestion(template, completionCopy.generic.memoryCommand);
3822
4175
  for (const template of historyTemplates) registerSuggestion(template, completionCopy.generic.historyCommand);
3823
4176
  for (const template of modeTemplates) registerSuggestion(template, completionCopy.generic.modeCommand);
4177
+ for (const template of modelTemplates) registerSuggestion(template, completionCopy.generic.modelCommand || completionCopy.commands.model);
3824
4178
  for (const template of checkpointTemplates) registerSuggestion(template, completionCopy.generic.checkpointCommand);
3825
4179
  for (const template of specTemplates) registerSuggestion(template, completionCopy.generic.specCommand);
3826
4180
  for (const template of planTemplates) {
@@ -3915,6 +4269,15 @@ export async function createChatRuntime({
3915
4269
  if (commandPart === 'status') {
3916
4270
  return [registerSuggestion('/status', completionCopy.generic.statusCommand)];
3917
4271
  }
4272
+ if (commandPart === 'model') {
4273
+ if (tokens.length === 1 || (tokens.length === 2 && !hasTrailingSpace)) {
4274
+ const sub = tokens[1] || '';
4275
+ return ['current', 'main', 'fast', 'set']
4276
+ .filter((m) => m.startsWith(sub))
4277
+ .map((m) => registerSuggestion(`/model ${m}${m === 'set' ? ' ' : ''}`, completionCopy.generic.modelCommand));
4278
+ }
4279
+ return materializeSuggestions(modelTemplates);
4280
+ }
3918
4281
  if (commandPart === 'mode') {
3919
4282
  if (tokens.length === 1 || (tokens.length === 2 && !hasTrailingSpace)) {
3920
4283
  const sub = tokens[1] || '';
@@ -3999,7 +4362,7 @@ export async function createChatRuntime({
3999
4362
  .filter((session) => String(session.id || '').startsWith(''))
4000
4363
  .map((session) => ({
4001
4364
  value: `/history resume ${session.id}`,
4002
- display: `/history resume ${session.id} · ${Number(session.messageCount || 0)} msgs`,
4365
+ display: `/history resume ${session.id} · ${session.title || 'untitled'} · ${Number(session.messageCount || 0)} msgs`,
4003
4366
  description: completionCopy.generic.resumeSession
4004
4367
  }));
4005
4368
  if (dynamic.length > 0) return dynamic;
@@ -4014,7 +4377,7 @@ export async function createChatRuntime({
4014
4377
  .filter((session) => String(session.id || '').startsWith(idPrefix))
4015
4378
  .map((session) => ({
4016
4379
  value: `/history resume ${session.id}`,
4017
- display: `/history resume ${session.id} · ${Number(session.messageCount || 0)} msgs`,
4380
+ display: `/history resume ${session.id} · ${session.title || 'untitled'} · ${Number(session.messageCount || 0)} msgs`,
4018
4381
  description: completionCopy.generic.resumeSession
4019
4382
  }));
4020
4383
  if (dynamic.length > 0) return dynamic;
@@ -4053,22 +4416,41 @@ export async function createChatRuntime({
4053
4416
  if (systemText) {
4054
4417
  currentSession.messages.push(stampedMessage('system', systemText));
4055
4418
  }
4419
+ if (shouldReplaceSessionTitle(currentSession.title)) {
4420
+ currentSession.title = deriveSessionTitle(currentSession.messages);
4421
+ }
4422
+ currentSession.model = model || config.model.name;
4423
+ currentSession.mode = executionMode || config.execution?.mode || 'normal';
4056
4424
  await saveSession(currentSession);
4057
4425
  };
4058
4426
 
4059
- const persistAssistantExchange = async (userText, assistantText, { includeUser = true } = {}) => {
4427
+ const persistAssistantExchange = async (userText, assistantText, { includeUser = true, extra = {} } = {}) => {
4060
4428
  if (includeUser && userText) {
4061
4429
  currentSession.messages.push(stampedMessage('user', userText));
4062
4430
  }
4063
4431
  if (assistantText) {
4064
- currentSession.messages.push(stampedMessage('assistant', assistantText));
4432
+ currentSession.messages.push(stampedMessage('assistant', assistantText, extra));
4433
+ }
4434
+ if (shouldReplaceSessionTitle(currentSession.title)) {
4435
+ currentSession.title = await generateSessionTitle({
4436
+ userText,
4437
+ assistantText,
4438
+ config
4439
+ });
4065
4440
  }
4441
+ currentSession.model = model || config.model.name;
4442
+ currentSession.mode = executionMode || config.execution?.mode || 'normal';
4066
4443
  await saveSession(currentSession);
4067
4444
  };
4068
4445
 
4069
4446
  const persistUserExchange = async (userText) => {
4070
4447
  if (!userText) return;
4071
4448
  currentSession.messages.push(stampedMessage('user', userText));
4449
+ if (shouldReplaceSessionTitle(currentSession.title)) {
4450
+ currentSession.title = deriveSessionTitle(currentSession.messages);
4451
+ }
4452
+ currentSession.model = model || config.model.name;
4453
+ currentSession.mode = executionMode || config.execution?.mode || 'normal';
4072
4454
  await saveSession(currentSession);
4073
4455
  };
4074
4456
 
@@ -4202,12 +4584,13 @@ export async function createChatRuntime({
4202
4584
  let activeAbortController = null;
4203
4585
  let activeSubSession = null;
4204
4586
 
4205
- const submit = async (line, onAgentEvent) => {
4587
+ const submit = async (line, onAgentEvent, options = {}) => {
4206
4588
  // 每次提交创建新的 AbortController,替代旧的
4207
4589
  activeAbortController = new AbortController();
4208
4590
  const { signal } = activeAbortController;
4209
4591
  const activeReplySystemPrompt = await buildActiveSystemPrompt();
4210
4592
  const parsedInput = parseInput(line);
4593
+ const readOnlyCodeWiki = options?.readOnlyCodeWiki === true;
4211
4594
  const maybeAutoDreamFromRuntime = async () => {
4212
4595
  const threshold = Number(config?.memory?.auto_dream_threshold ?? 10);
4213
4596
  if (!(threshold > 0)) return null;
@@ -4241,7 +4624,7 @@ export async function createChatRuntime({
4241
4624
  }
4242
4625
  };
4243
4626
  try {
4244
- if (shouldPersistInputHistory(parsedInput)) {
4627
+ if (!readOnlyCodeWiki && shouldPersistInputHistory(parsedInput)) {
4245
4628
  await appendInputHistory(line);
4246
4629
  }
4247
4630
  } catch {
@@ -4250,6 +4633,34 @@ export async function createChatRuntime({
4250
4633
  if (parsedInput.type === 'empty') {
4251
4634
  return { type: 'noop' };
4252
4635
  }
4636
+ if (readOnlyCodeWiki) {
4637
+ const expandedText = await expandFileMentions(line, process.cwd());
4638
+ const readOnlySystemPrompt = [
4639
+ activeReplySystemPrompt,
4640
+ 'CodeWiki repository Q&A mode:',
4641
+ '- Answer questions about the current repository and generated CodeWiki/project-requirements report.',
4642
+ '- This is read-only. Do not modify files, update plans, run shell commands, delete anything, write memories, or ask to perform side effects.',
4643
+ '- Use only read-only project inspection tools when evidence is needed.',
4644
+ '- Be concise and cite relevant files or report sections when useful.'
4645
+ ].join('\n\n');
4646
+ const transientSession = structuredClone(currentSession);
4647
+ const result = await askModel({
4648
+ text: expandedText,
4649
+ session: transientSession,
4650
+ config,
4651
+ model,
4652
+ systemPrompt: readOnlySystemPrompt,
4653
+ onAgentEvent,
4654
+ requestToolApproval: activeRequestToolApproval,
4655
+ executionMode: 'auto',
4656
+ alwaysAllowTools: CODEWIKI_READ_ONLY_TOOLS,
4657
+ allowedTools: CODEWIKI_READ_ONLY_TOOLS,
4658
+ persistSession: false,
4659
+ maxSteps: 32,
4660
+ signal
4661
+ });
4662
+ return { type: 'assistant', text: result.text, aborted: !!result.aborted };
4663
+ }
4253
4664
  if (parsedInput.type === 'shell') {
4254
4665
  const shell = await handleShellInput(parsedInput.command, config);
4255
4666
  return { type: 'shell', text: shell.text };
@@ -4259,12 +4670,12 @@ export async function createChatRuntime({
4259
4670
  if (parsedInput.command === 'new') {
4260
4671
  const fresh = await createSession();
4261
4672
  currentSession = fresh;
4262
- executionMode = config.execution?.mode || 'auto';
4673
+ executionMode = config.execution?.mode || 'normal';
4263
4674
  compactState.backupMessages = null;
4264
4675
  setResultDir(path.join(getSessionsDir(), String(fresh.id)));
4265
4676
  historyIdCache = [fresh.id, ...historyIdCache.filter((id) => id !== fresh.id)];
4266
4677
  historySessionCache = [
4267
- { id: fresh.id, messageCount: 0 },
4678
+ { id: fresh.id, title: fresh.title || '', messageCount: 0 },
4268
4679
  ...historySessionCache.filter((s) => s.id !== fresh.id)
4269
4680
  ];
4270
4681
  return {
@@ -4276,7 +4687,7 @@ export async function createChatRuntime({
4276
4687
  if (parsedInput.command === 'help') {
4277
4688
  return {
4278
4689
  type: 'system',
4279
- text: 'Commands: /help /exit /new /stop /commands /status /mode /compact /checkpoint /spec /plan /yes /no /edit /reject /agents /config /memory /capture /inbox /dream /reflect /history /debug /retry /<custom> !<shell>'
4690
+ text: 'Commands: /help /exit /new /stop /commands /status /model /mode /compact /checkpoint /spec /plan /yes /no /edit /reject /agents /config /memory /capture /inbox /dream /reflect /history /debug /retry /<custom> !<shell>'
4280
4691
  };
4281
4692
  }
4282
4693
  if (parsedInput.command === 'status') {
@@ -4286,6 +4697,31 @@ export async function createChatRuntime({
4286
4697
  text: `mode=${executionMode} | role=general | model=${model || config.model.name} | max_ctx=${effectiveMaxContextTokens(config)} | session=${currentSession.id} | todos=${todoCount}`
4287
4698
  };
4288
4699
  }
4700
+ if (parsedInput.command === 'model') {
4701
+ const sub = String(parsedInput.args[0] || 'current').trim().toLowerCase();
4702
+ const mainModel = resolveDefaultModel(config);
4703
+ const fastModel = resolveFastModel(config);
4704
+ if (sub === 'current' || sub === 'status') {
4705
+ return {
4706
+ type: 'system',
4707
+ text: `Current model: ${model || mainModel}\nDefault model: ${mainModel}\nFast model: ${fastModel}${config.model?.fast_name ? '' : ' (fallback to default; set /config set model.fast_name <name>)'}`
4708
+ };
4709
+ }
4710
+ if (sub === 'main' || sub === 'default') {
4711
+ model = mainModel;
4712
+ } else if (sub === 'fast') {
4713
+ model = fastModel;
4714
+ } else if (sub === 'set') {
4715
+ const next = parsedInput.args.slice(1).join(' ').trim();
4716
+ if (!next) return { type: 'system', text: 'Usage: /model set <name>' };
4717
+ model = next;
4718
+ } else {
4719
+ return { type: 'system', text: 'Usage: /model current | /model main | /model fast | /model set <name>' };
4720
+ }
4721
+ currentSession.model = model;
4722
+ await saveSession(currentSession);
4723
+ return { type: 'system', text: `Model switched to: ${model}` };
4724
+ }
4289
4725
  if (parsedInput.command === 'mode') {
4290
4726
  const next = (parsedInput.args[0] || '').trim().toLowerCase();
4291
4727
  if (!next) {
@@ -4340,9 +4776,13 @@ export async function createChatRuntime({
4340
4776
  });
4341
4777
  activeSubSession = null;
4342
4778
  currentSession.planState = null;
4779
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4343
4780
  await removePlanFileIfPresent(planState);
4344
4781
  executionMode = 'auto';
4345
- await persistAssistantExchange(line, result.text || '', { includeUser: false });
4782
+ await persistAssistantExchange(line, result.sessionText || result.text || '', {
4783
+ includeUser: false,
4784
+ extra: Array.isArray(result.transcript) ? { plan_transcript: result.transcript } : {}
4785
+ });
4346
4786
  return { type: 'assistant', text: result.text, aborted: !!result.aborted };
4347
4787
  }
4348
4788
  if (parsedInput.command === 'edit') {
@@ -4391,6 +4831,15 @@ export async function createChatRuntime({
4391
4831
  });
4392
4832
  currentSession.planState = revised;
4393
4833
  executionMode = 'plan';
4834
+ if (onAgentEvent) {
4835
+ onAgentEvent({
4836
+ type: 'plan:pending_approval',
4837
+ goal: currentSession.planState.goal,
4838
+ summary: currentSession.planState.finalSummary || currentSession.planState.summary,
4839
+ filePath: currentSession.planState.filePath,
4840
+ steps: currentSession.planState.steps
4841
+ });
4842
+ }
4394
4843
  const text = `Plan revised.\n${buildPendingPlanApprovalMessage(currentSession.planState)}`;
4395
4844
  await persistLocalExchange(line, text);
4396
4845
  return { type: 'system', text };
@@ -4405,6 +4854,7 @@ export async function createChatRuntime({
4405
4854
  }
4406
4855
  if (hasPendingPlanApproval(currentSession)) {
4407
4856
  currentSession.planState = null;
4857
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4408
4858
  executionMode = 'auto';
4409
4859
  const text = 'Pending plan rejected and cleared.';
4410
4860
  await persistLocalExchange(line, text, { includeUser: false });
@@ -4418,6 +4868,7 @@ export async function createChatRuntime({
4418
4868
  }
4419
4869
  const planState = { ...currentSession.planState };
4420
4870
  currentSession.planState = null;
4871
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4421
4872
  await removePlanFileIfPresent(planState);
4422
4873
  executionMode = 'auto';
4423
4874
  const text = 'Pending plan rejected and cleared.';
@@ -4533,6 +4984,15 @@ export async function createChatRuntime({
4533
4984
  steps: Array.isArray(auto.steps) ? auto.steps : []
4534
4985
  };
4535
4986
  executionMode = 'plan';
4987
+ if (onAgentEvent) {
4988
+ onAgentEvent({
4989
+ type: 'plan:pending_approval',
4990
+ goal: currentSession.planState.goal,
4991
+ summary: currentSession.planState.finalSummary || currentSession.planState.summary,
4992
+ filePath: currentSession.planState.filePath,
4993
+ steps: currentSession.planState.steps
4994
+ });
4995
+ }
4536
4996
  const text = buildAutoPlanSystemSummary(auto);
4537
4997
  await persistLocalExchange(line, text);
4538
4998
  return {
@@ -4558,9 +5018,13 @@ export async function createChatRuntime({
4558
5018
  });
4559
5019
  activeSubSession = null;
4560
5020
  currentSession.planState = null;
5021
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4561
5022
  await removePlanFileIfPresent(planState);
4562
5023
  executionMode = 'auto';
4563
- await persistAssistantExchange(line, result.text || '', { includeUser: false });
5024
+ await persistAssistantExchange(line, result.sessionText || result.text || '', {
5025
+ includeUser: false,
5026
+ extra: Array.isArray(result.transcript) ? { plan_transcript: result.transcript } : {}
5027
+ });
4564
5028
  return { type: 'assistant', text: result.text, aborted: !!result.aborted };
4565
5029
  }
4566
5030
  if (sub === 'stay') {
@@ -4667,6 +5131,7 @@ export async function createChatRuntime({
4667
5131
  historyIdCache = sessions.map((s) => s.id);
4668
5132
  historySessionCache = sessions.map((s) => ({
4669
5133
  id: s.id,
5134
+ title: s.title || '',
4670
5135
  messageCount: Number(s.messageCount || 0)
4671
5136
  }));
4672
5137
  if (sessions.length === 0) return { type: 'system', text: 'No sessions found' };
@@ -4692,7 +5157,7 @@ export async function createChatRuntime({
4692
5157
  }
4693
5158
  if (!historyIdCache.includes(targetId)) historyIdCache.unshift(targetId);
4694
5159
  historySessionCache = [
4695
- { id: targetId, messageCount: Array.isArray(loaded.messages) ? loaded.messages.length : 0 },
5160
+ { id: targetId, title: loaded.title || deriveSessionTitle(loaded.messages || []), messageCount: Array.isArray(loaded.messages) ? loaded.messages.length : 0 },
4696
5161
  ...historySessionCache.filter((s) => s.id !== targetId)
4697
5162
  ];
4698
5163
  return {
@@ -5013,7 +5478,8 @@ export async function createChatRuntime({
5013
5478
  let result;
5014
5479
  try {
5015
5480
  result = await askModel({
5016
- text: rendered,
5481
+ text: custom.metadata.type === 'skill' ? line : rendered,
5482
+ modelText: custom.metadata.type === 'skill' ? rendered : undefined,
5017
5483
  session: currentSession,
5018
5484
  config,
5019
5485
  model,
@@ -5059,8 +5525,12 @@ export async function createChatRuntime({
5059
5525
  });
5060
5526
  activeSubSession = null;
5061
5527
  currentSession.planState = null;
5528
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
5062
5529
  executionMode = 'auto';
5063
- await persistAssistantExchange(line, result.text || '', { includeUser: false });
5530
+ await persistAssistantExchange(line, result.sessionText || result.text || '', {
5531
+ includeUser: false,
5532
+ extra: Array.isArray(result.transcript) ? { plan_transcript: result.transcript } : {}
5533
+ });
5064
5534
  return { type: 'assistant', text: result.text, aborted: !!result.aborted };
5065
5535
  }
5066
5536
  if (isStayInPlanText(parsedInput.text)) {
@@ -5070,6 +5540,7 @@ export async function createChatRuntime({
5070
5540
  }
5071
5541
  if (isRejectPlanText(parsedInput.text)) {
5072
5542
  currentSession.planState = null;
5543
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
5073
5544
  executionMode = 'auto';
5074
5545
  const text = 'Pending plan rejected and cleared.';
5075
5546
  await persistLocalExchange(line, text);
@@ -5081,6 +5552,43 @@ export async function createChatRuntime({
5081
5552
  };
5082
5553
  }
5083
5554
 
5555
+ // Plan mode with no pending plan → auto-generate structured plan
5556
+ if (executionMode === 'plan') {
5557
+ const expandedPlanText = await expandFileMentions(parsedInput.text, process.cwd());
5558
+ await maybeAutoDreamFromRuntime();
5559
+ const auto = await buildAutoPlanAndRun({
5560
+ goal: expandedPlanText,
5561
+ session: currentSession,
5562
+ config,
5563
+ model,
5564
+ systemPrompt: activeReplySystemPrompt,
5565
+ onAgentEvent,
5566
+ sessionId: currentSession.id,
5567
+ taskClass: classifyPlanTaskClass(expandedPlanText)
5568
+ });
5569
+ currentSession.planState = {
5570
+ status: 'pending_approval',
5571
+ source: 'auto',
5572
+ goal: expandedPlanText,
5573
+ filePath: auto.filePath,
5574
+ summary: auto.summary || '',
5575
+ finalSummary: auto.finalSummary || auto.summary || '',
5576
+ steps: Array.isArray(auto.steps) ? auto.steps : []
5577
+ };
5578
+ if (onAgentEvent) {
5579
+ onAgentEvent({
5580
+ type: 'plan:pending_approval',
5581
+ goal: currentSession.planState.goal,
5582
+ summary: currentSession.planState.finalSummary || currentSession.planState.summary,
5583
+ filePath: currentSession.planState.filePath,
5584
+ steps: currentSession.planState.steps
5585
+ });
5586
+ }
5587
+ const text = buildAutoPlanSystemSummary(auto);
5588
+ await persistLocalExchange(line, text);
5589
+ return { type: 'system', text };
5590
+ }
5591
+
5084
5592
  if (compactState.autoEnabled) {
5085
5593
  const currentTokens = estimateMessagesTokens(currentSession.messages);
5086
5594
  const maxTokens = effectiveMaxContextTokens(config);
@@ -5185,6 +5693,18 @@ export async function createChatRuntime({
5185
5693
  consumeStartupEvents: () => startupEvents.splice(0, startupEvents.length),
5186
5694
  getInputHistory: () => loadInputHistory(),
5187
5695
  getCurrentSessionId: () => currentSession.id,
5696
+ getSessionMessages: () => currentSession.messages || [],
5697
+ reloadConfig: async () => {
5698
+ config = await loadConfig();
5699
+ return config;
5700
+ },
5701
+ setExecutionMode: async (next) => {
5702
+ if (!['normal', 'auto', 'plan'].includes(next)) return false;
5703
+ executionMode = next;
5704
+ await setConfigValue('execution.mode', next);
5705
+ config = await loadConfig();
5706
+ return true;
5707
+ },
5188
5708
  setRequestToolApproval: (handler) => {
5189
5709
  activeRequestToolApproval = typeof handler === 'function' ? handler : null;
5190
5710
  return true;