codemini-cli 0.4.6 → 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) {
@@ -3341,8 +3676,23 @@ async function runProjectRequirementsPipeline({
3341
3676
  name: custom.name,
3342
3677
  summary: error instanceof Error ? error.message : String(error)
3343
3678
  });
3679
+ onAgentEvent({ type: 'skill:end', name: custom.name });
3680
+ }
3681
+ if (manifestPath) {
3682
+ await updateProjectRequirementsManifest(manifestPath, {
3683
+ status: 'failed',
3684
+ failedCount: steps.length,
3685
+ error: error instanceof Error ? error.message : String(error)
3686
+ }).catch(() => {});
3344
3687
  }
3345
- throw error;
3688
+ return {
3689
+ type: 'assistant',
3690
+ text: `Project requirements pipeline failed: ${error instanceof Error ? error.message : String(error)}`,
3691
+ planFile,
3692
+ reportPath,
3693
+ manifestPath,
3694
+ aborted: true
3695
+ };
3346
3696
  }
3347
3697
  if (onAgentEvent) {
3348
3698
  onAgentEvent({
@@ -3496,7 +3846,8 @@ function compactHistoryPreview(value, maxChars = 72) {
3496
3846
  function formatHistoryList({ currentSession, sessions }) {
3497
3847
  const currentMessages = Array.isArray(currentSession?.messages) ? currentSession.messages.length : 0;
3498
3848
  const lines = [
3499
- `Current session ${currentSession.id}`,
3849
+ `Current session ${currentSession.title || currentSession.id}`,
3850
+ `Session id ${currentSession.id}`,
3500
3851
  `Messages ${currentMessages}`,
3501
3852
  '',
3502
3853
  'Recent sessions'
@@ -3505,8 +3856,9 @@ function formatHistoryList({ currentSession, sessions }) {
3505
3856
  for (const [index, session] of sessions.entries()) {
3506
3857
  const count = Number(session.messageCount || 0);
3507
3858
  lines.push(
3508
- `${index + 1}. ${session.id}`,
3509
- ` ${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}` : ''}`,
3510
3862
  ` ${compactHistoryPreview(session.preview)}`,
3511
3863
  ` resume: /history resume ${session.id}`
3512
3864
  );
@@ -3523,6 +3875,9 @@ export async function createChatRuntime({
3523
3875
  systemPrompt,
3524
3876
  requestToolApproval
3525
3877
  }) {
3878
+ if (session && typeof session === 'object' && !session.projectDir) {
3879
+ session.projectDir = process.cwd();
3880
+ }
3526
3881
  let activeRequestToolApproval = typeof requestToolApproval === 'function' ? requestToolApproval : null;
3527
3882
  const startupEvents = [];
3528
3883
  const initialIndex = await initializeProjectIndex(process.cwd()).catch(() => null);
@@ -3558,8 +3913,12 @@ export async function createChatRuntime({
3558
3913
  }
3559
3914
  let currentSession = session;
3560
3915
  let config = initialConfig;
3916
+ model = model || currentSession?.model || resolveDefaultModel(config);
3917
+ if (currentSession && typeof currentSession === 'object') {
3918
+ currentSession.model = model;
3919
+ }
3561
3920
  const baseSystemPrompt = systemPrompt;
3562
- let executionMode = config.execution?.mode || 'auto';
3921
+ let executionMode = config.execution?.mode || 'normal';
3563
3922
  if (hasPendingPlanApproval(currentSession)) {
3564
3923
  executionMode = 'plan';
3565
3924
  }
@@ -3585,6 +3944,7 @@ export async function createChatRuntime({
3585
3944
  let historySessionCache = [
3586
3945
  {
3587
3946
  id: currentSession.id,
3947
+ title: currentSession.title || deriveSessionTitle(currentSession.messages || []),
3588
3948
  messageCount: Array.isArray(currentSession.messages) ? currentSession.messages.length : 0
3589
3949
  }
3590
3950
  ];
@@ -3594,10 +3954,12 @@ export async function createChatRuntime({
3594
3954
  const merged = [
3595
3955
  {
3596
3956
  id: currentSession.id,
3957
+ title: currentSession.title || deriveSessionTitle(currentSession.messages || []),
3597
3958
  messageCount: Array.isArray(currentSession.messages) ? currentSession.messages.length : 0
3598
3959
  },
3599
3960
  ...initialSessions.map((session) => ({
3600
3961
  id: session.id,
3962
+ title: session.title || '',
3601
3963
  messageCount: Number(session.messageCount || 0)
3602
3964
  }))
3603
3965
  ];
@@ -3620,6 +3982,7 @@ export async function createChatRuntime({
3620
3982
  'gateway.base_url',
3621
3983
  'gateway.api_key',
3622
3984
  'model.name',
3985
+ 'model.fast_name',
3623
3986
  'ui.language',
3624
3987
  'ui.reply_language',
3625
3988
  'execution.mode',
@@ -3648,6 +4011,7 @@ export async function createChatRuntime({
3648
4011
  const commandPriorityOrder = [
3649
4012
  '/help',
3650
4013
  '/status',
4014
+ '/model',
3651
4015
  '/config',
3652
4016
  '/memory',
3653
4017
  '/capture',
@@ -3672,6 +4036,7 @@ export async function createChatRuntime({
3672
4036
  { name: 'exit', description: completionCopy.commands.exit },
3673
4037
  { name: 'commands', description: completionCopy.commands.commands },
3674
4038
  { name: 'status', description: completionCopy.commands.status },
4039
+ { name: 'model', description: completionCopy.commands.model },
3675
4040
  { name: 'mode', description: completionCopy.commands.mode },
3676
4041
  { name: 'compact', description: completionCopy.commands.compact },
3677
4042
  { name: 'checkpoint', description: completionCopy.commands.checkpoint },
@@ -3722,6 +4087,7 @@ export async function createChatRuntime({
3722
4087
  const historyTemplates = ['/history list', '/history current', '/history resume <session_id>'];
3723
4088
  const memoryTemplates = ['/memory list <scope>', '/memory search <scope> <query>', '/memory forget <scope> <id>'];
3724
4089
  const modeTemplates = ['/mode normal', '/mode auto', '/mode plan'];
4090
+ const modelTemplates = ['/model current', '/model main', '/model fast', '/model set <name>'];
3725
4091
  const checkpointTemplates = [
3726
4092
  '/checkpoint create <name>',
3727
4093
  '/checkpoint list',
@@ -3740,6 +4106,7 @@ export async function createChatRuntime({
3740
4106
  ...memoryTemplates,
3741
4107
  ...historyTemplates,
3742
4108
  ...modeTemplates,
4109
+ ...modelTemplates,
3743
4110
  ...checkpointTemplates,
3744
4111
  ...specTemplates,
3745
4112
  ...planTemplates,
@@ -3787,6 +4154,7 @@ export async function createChatRuntime({
3787
4154
  'memory',
3788
4155
  'compact',
3789
4156
  'mode',
4157
+ 'model',
3790
4158
  'checkpoint',
3791
4159
  'plan',
3792
4160
  'agents',
@@ -3806,6 +4174,7 @@ export async function createChatRuntime({
3806
4174
  for (const template of memoryTemplates) registerSuggestion(template, completionCopy.generic.memoryCommand);
3807
4175
  for (const template of historyTemplates) registerSuggestion(template, completionCopy.generic.historyCommand);
3808
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);
3809
4178
  for (const template of checkpointTemplates) registerSuggestion(template, completionCopy.generic.checkpointCommand);
3810
4179
  for (const template of specTemplates) registerSuggestion(template, completionCopy.generic.specCommand);
3811
4180
  for (const template of planTemplates) {
@@ -3900,6 +4269,15 @@ export async function createChatRuntime({
3900
4269
  if (commandPart === 'status') {
3901
4270
  return [registerSuggestion('/status', completionCopy.generic.statusCommand)];
3902
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
+ }
3903
4281
  if (commandPart === 'mode') {
3904
4282
  if (tokens.length === 1 || (tokens.length === 2 && !hasTrailingSpace)) {
3905
4283
  const sub = tokens[1] || '';
@@ -3984,7 +4362,7 @@ export async function createChatRuntime({
3984
4362
  .filter((session) => String(session.id || '').startsWith(''))
3985
4363
  .map((session) => ({
3986
4364
  value: `/history resume ${session.id}`,
3987
- display: `/history resume ${session.id} · ${Number(session.messageCount || 0)} msgs`,
4365
+ display: `/history resume ${session.id} · ${session.title || 'untitled'} · ${Number(session.messageCount || 0)} msgs`,
3988
4366
  description: completionCopy.generic.resumeSession
3989
4367
  }));
3990
4368
  if (dynamic.length > 0) return dynamic;
@@ -3999,7 +4377,7 @@ export async function createChatRuntime({
3999
4377
  .filter((session) => String(session.id || '').startsWith(idPrefix))
4000
4378
  .map((session) => ({
4001
4379
  value: `/history resume ${session.id}`,
4002
- display: `/history resume ${session.id} · ${Number(session.messageCount || 0)} msgs`,
4380
+ display: `/history resume ${session.id} · ${session.title || 'untitled'} · ${Number(session.messageCount || 0)} msgs`,
4003
4381
  description: completionCopy.generic.resumeSession
4004
4382
  }));
4005
4383
  if (dynamic.length > 0) return dynamic;
@@ -4038,22 +4416,41 @@ export async function createChatRuntime({
4038
4416
  if (systemText) {
4039
4417
  currentSession.messages.push(stampedMessage('system', systemText));
4040
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';
4041
4424
  await saveSession(currentSession);
4042
4425
  };
4043
4426
 
4044
- const persistAssistantExchange = async (userText, assistantText, { includeUser = true } = {}) => {
4427
+ const persistAssistantExchange = async (userText, assistantText, { includeUser = true, extra = {} } = {}) => {
4045
4428
  if (includeUser && userText) {
4046
4429
  currentSession.messages.push(stampedMessage('user', userText));
4047
4430
  }
4048
4431
  if (assistantText) {
4049
- 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
+ });
4050
4440
  }
4441
+ currentSession.model = model || config.model.name;
4442
+ currentSession.mode = executionMode || config.execution?.mode || 'normal';
4051
4443
  await saveSession(currentSession);
4052
4444
  };
4053
4445
 
4054
4446
  const persistUserExchange = async (userText) => {
4055
4447
  if (!userText) return;
4056
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';
4057
4454
  await saveSession(currentSession);
4058
4455
  };
4059
4456
 
@@ -4187,12 +4584,13 @@ export async function createChatRuntime({
4187
4584
  let activeAbortController = null;
4188
4585
  let activeSubSession = null;
4189
4586
 
4190
- const submit = async (line, onAgentEvent) => {
4587
+ const submit = async (line, onAgentEvent, options = {}) => {
4191
4588
  // 每次提交创建新的 AbortController,替代旧的
4192
4589
  activeAbortController = new AbortController();
4193
4590
  const { signal } = activeAbortController;
4194
4591
  const activeReplySystemPrompt = await buildActiveSystemPrompt();
4195
4592
  const parsedInput = parseInput(line);
4593
+ const readOnlyCodeWiki = options?.readOnlyCodeWiki === true;
4196
4594
  const maybeAutoDreamFromRuntime = async () => {
4197
4595
  const threshold = Number(config?.memory?.auto_dream_threshold ?? 10);
4198
4596
  if (!(threshold > 0)) return null;
@@ -4226,7 +4624,7 @@ export async function createChatRuntime({
4226
4624
  }
4227
4625
  };
4228
4626
  try {
4229
- if (shouldPersistInputHistory(parsedInput)) {
4627
+ if (!readOnlyCodeWiki && shouldPersistInputHistory(parsedInput)) {
4230
4628
  await appendInputHistory(line);
4231
4629
  }
4232
4630
  } catch {
@@ -4235,6 +4633,34 @@ export async function createChatRuntime({
4235
4633
  if (parsedInput.type === 'empty') {
4236
4634
  return { type: 'noop' };
4237
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
+ }
4238
4664
  if (parsedInput.type === 'shell') {
4239
4665
  const shell = await handleShellInput(parsedInput.command, config);
4240
4666
  return { type: 'shell', text: shell.text };
@@ -4244,12 +4670,12 @@ export async function createChatRuntime({
4244
4670
  if (parsedInput.command === 'new') {
4245
4671
  const fresh = await createSession();
4246
4672
  currentSession = fresh;
4247
- executionMode = config.execution?.mode || 'auto';
4673
+ executionMode = config.execution?.mode || 'normal';
4248
4674
  compactState.backupMessages = null;
4249
4675
  setResultDir(path.join(getSessionsDir(), String(fresh.id)));
4250
4676
  historyIdCache = [fresh.id, ...historyIdCache.filter((id) => id !== fresh.id)];
4251
4677
  historySessionCache = [
4252
- { id: fresh.id, messageCount: 0 },
4678
+ { id: fresh.id, title: fresh.title || '', messageCount: 0 },
4253
4679
  ...historySessionCache.filter((s) => s.id !== fresh.id)
4254
4680
  ];
4255
4681
  return {
@@ -4261,7 +4687,7 @@ export async function createChatRuntime({
4261
4687
  if (parsedInput.command === 'help') {
4262
4688
  return {
4263
4689
  type: 'system',
4264
- 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>'
4265
4691
  };
4266
4692
  }
4267
4693
  if (parsedInput.command === 'status') {
@@ -4271,6 +4697,31 @@ export async function createChatRuntime({
4271
4697
  text: `mode=${executionMode} | role=general | model=${model || config.model.name} | max_ctx=${effectiveMaxContextTokens(config)} | session=${currentSession.id} | todos=${todoCount}`
4272
4698
  };
4273
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
+ }
4274
4725
  if (parsedInput.command === 'mode') {
4275
4726
  const next = (parsedInput.args[0] || '').trim().toLowerCase();
4276
4727
  if (!next) {
@@ -4325,9 +4776,13 @@ export async function createChatRuntime({
4325
4776
  });
4326
4777
  activeSubSession = null;
4327
4778
  currentSession.planState = null;
4779
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4328
4780
  await removePlanFileIfPresent(planState);
4329
4781
  executionMode = 'auto';
4330
- 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
+ });
4331
4786
  return { type: 'assistant', text: result.text, aborted: !!result.aborted };
4332
4787
  }
4333
4788
  if (parsedInput.command === 'edit') {
@@ -4376,6 +4831,15 @@ export async function createChatRuntime({
4376
4831
  });
4377
4832
  currentSession.planState = revised;
4378
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
+ }
4379
4843
  const text = `Plan revised.\n${buildPendingPlanApprovalMessage(currentSession.planState)}`;
4380
4844
  await persistLocalExchange(line, text);
4381
4845
  return { type: 'system', text };
@@ -4390,6 +4854,7 @@ export async function createChatRuntime({
4390
4854
  }
4391
4855
  if (hasPendingPlanApproval(currentSession)) {
4392
4856
  currentSession.planState = null;
4857
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4393
4858
  executionMode = 'auto';
4394
4859
  const text = 'Pending plan rejected and cleared.';
4395
4860
  await persistLocalExchange(line, text, { includeUser: false });
@@ -4403,6 +4868,7 @@ export async function createChatRuntime({
4403
4868
  }
4404
4869
  const planState = { ...currentSession.planState };
4405
4870
  currentSession.planState = null;
4871
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4406
4872
  await removePlanFileIfPresent(planState);
4407
4873
  executionMode = 'auto';
4408
4874
  const text = 'Pending plan rejected and cleared.';
@@ -4518,6 +4984,15 @@ export async function createChatRuntime({
4518
4984
  steps: Array.isArray(auto.steps) ? auto.steps : []
4519
4985
  };
4520
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
+ }
4521
4996
  const text = buildAutoPlanSystemSummary(auto);
4522
4997
  await persistLocalExchange(line, text);
4523
4998
  return {
@@ -4543,9 +5018,13 @@ export async function createChatRuntime({
4543
5018
  });
4544
5019
  activeSubSession = null;
4545
5020
  currentSession.planState = null;
5021
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
4546
5022
  await removePlanFileIfPresent(planState);
4547
5023
  executionMode = 'auto';
4548
- 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
+ });
4549
5028
  return { type: 'assistant', text: result.text, aborted: !!result.aborted };
4550
5029
  }
4551
5030
  if (sub === 'stay') {
@@ -4652,6 +5131,7 @@ export async function createChatRuntime({
4652
5131
  historyIdCache = sessions.map((s) => s.id);
4653
5132
  historySessionCache = sessions.map((s) => ({
4654
5133
  id: s.id,
5134
+ title: s.title || '',
4655
5135
  messageCount: Number(s.messageCount || 0)
4656
5136
  }));
4657
5137
  if (sessions.length === 0) return { type: 'system', text: 'No sessions found' };
@@ -4677,7 +5157,7 @@ export async function createChatRuntime({
4677
5157
  }
4678
5158
  if (!historyIdCache.includes(targetId)) historyIdCache.unshift(targetId);
4679
5159
  historySessionCache = [
4680
- { 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 },
4681
5161
  ...historySessionCache.filter((s) => s.id !== targetId)
4682
5162
  ];
4683
5163
  return {
@@ -4998,7 +5478,8 @@ export async function createChatRuntime({
4998
5478
  let result;
4999
5479
  try {
5000
5480
  result = await askModel({
5001
- text: rendered,
5481
+ text: custom.metadata.type === 'skill' ? line : rendered,
5482
+ modelText: custom.metadata.type === 'skill' ? rendered : undefined,
5002
5483
  session: currentSession,
5003
5484
  config,
5004
5485
  model,
@@ -5015,8 +5496,12 @@ export async function createChatRuntime({
5015
5496
  name: custom.name,
5016
5497
  summary: error instanceof Error ? error.message : String(error)
5017
5498
  });
5499
+ onAgentEvent({ type: 'skill:end', name: custom.name });
5018
5500
  }
5019
- throw error;
5501
+ return {
5502
+ type: 'system',
5503
+ text: `Skill "${custom.name}" failed: ${error instanceof Error ? error.message : String(error)}`
5504
+ };
5020
5505
  }
5021
5506
  if (custom.metadata.type === 'skill' && onAgentEvent) {
5022
5507
  onAgentEvent({ type: 'skill:end', name: custom.name });
@@ -5040,8 +5525,12 @@ export async function createChatRuntime({
5040
5525
  });
5041
5526
  activeSubSession = null;
5042
5527
  currentSession.planState = null;
5528
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
5043
5529
  executionMode = 'auto';
5044
- 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
+ });
5045
5534
  return { type: 'assistant', text: result.text, aborted: !!result.aborted };
5046
5535
  }
5047
5536
  if (isStayInPlanText(parsedInput.text)) {
@@ -5051,6 +5540,7 @@ export async function createChatRuntime({
5051
5540
  }
5052
5541
  if (isRejectPlanText(parsedInput.text)) {
5053
5542
  currentSession.planState = null;
5543
+ if (onAgentEvent) onAgentEvent({ type: 'plan:approval_cleared' });
5054
5544
  executionMode = 'auto';
5055
5545
  const text = 'Pending plan rejected and cleared.';
5056
5546
  await persistLocalExchange(line, text);
@@ -5062,6 +5552,43 @@ export async function createChatRuntime({
5062
5552
  };
5063
5553
  }
5064
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
+
5065
5592
  if (compactState.autoEnabled) {
5066
5593
  const currentTokens = estimateMessagesTokens(currentSession.messages);
5067
5594
  const maxTokens = effectiveMaxContextTokens(config);
@@ -5166,6 +5693,18 @@ export async function createChatRuntime({
5166
5693
  consumeStartupEvents: () => startupEvents.splice(0, startupEvents.length),
5167
5694
  getInputHistory: () => loadInputHistory(),
5168
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
+ },
5169
5708
  setRequestToolApproval: (handler) => {
5170
5709
  activeRequestToolApproval = typeof handler === 'function' ? handler : null;
5171
5710
  return true;