@xcanwin/manyoyo 8.7.1 → 8.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/web/server.js CHANGED
@@ -15,6 +15,12 @@ const { applyAptMirror } = require('../mirrors');
15
15
  const { BUILTIN_AGENT_INSTRUCTIONS, buildAgentInstructions, composeSystemPrompt, SYSTEM_PROMPT_MAX_CHARS, toTomlBasicString } = require('../agent-instructions');
16
16
  const containerState = require('../container-state');
17
17
  const sessionMeta = require('./session-meta');
18
+ const agentEffort = require('./agent-effort');
19
+ const agentLight = require('./agent-light-call');
20
+ const webRecap = require('./recap');
21
+ const webExtensions = require('./extensions');
22
+ const agentSide = require('./agent-side');
23
+ const contextUsageLib = require('./context-usage');
18
24
  const gitChanges = require('./git-changes');
19
25
  const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
20
26
  const { createNetworkManager, NETWORK_NAME } = require('../container-network');
@@ -233,14 +239,25 @@ function createEmptyWebAgentSession(agentId, agentName) {
233
239
  engineSessionId: '',
234
240
  usageTotal: null,
235
241
  model: '',
242
+ // 推理强度:''=默认(不注入,尊重容器 env / 配置)
243
+ effort: '',
244
+ // claude 最近一轮结束时的上下文占用(见 lib/web/context-usage.js)
245
+ contextUsage: null,
236
246
  queue: [],
237
247
  queuePaused: false,
238
248
  title: '',
249
+ // 自动标题(轻量模型调用生成)与是否已经试过(失败也不重试,避免每次刷新都花钱)
250
+ autoTitle: '',
251
+ autoTitleTried: false,
252
+ // 回顾:{ forMessageId, text, createdAt },对应哪条最终回复
253
+ recap: null,
239
254
  firstPrompt: '',
240
255
  pinned: false,
241
256
  slashCommands: null,
242
257
  // 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
243
- rewindToUuid: ''
258
+ rewindToUuid: '',
259
+ // 分叉出来的 AGENT:下一轮 Claude 命令带 --fork-session(从原会话另开一条,拿到新会话 id 后清掉)
260
+ forkPending: false
244
261
  };
245
262
  }
246
263
 
@@ -293,13 +310,19 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
293
310
  engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
294
311
  usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
295
312
  model: normalizeStoredAgentModelValue(source.model),
313
+ effort: agentEffort.isEffortValue(source.effort) ? source.effort : '',
314
+ contextUsage: contextUsageLib.normalizeStoredContextUsage(source.contextUsage),
296
315
  queue: agentQueue.normalizeStoredQueue(source.queue),
297
316
  queuePaused: source.queuePaused === true,
298
317
  title: sessionMeta.normalizeTitle(source.title),
318
+ autoTitle: sessionMeta.normalizeTitle(source.autoTitle),
319
+ autoTitleTried: source.autoTitleTried === true,
320
+ recap: webRecap.normalizeStoredRecap(source.recap, text => agentLight.cleanLightText(text, agentLight.RECAP_MAX_CHARS)),
299
321
  firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
300
322
  pinned: source.pinned === true,
301
323
  slashCommands: normalizeStoredSlashCommands(source.slashCommands),
302
- rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
324
+ rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : '',
325
+ forkPending: source.forkPending === true
303
326
  };
304
327
  }
305
328
 
@@ -995,6 +1018,11 @@ function renderAgentPromptCommand(template, prompt) {
995
1018
  return templateText.replace(/\{prompt\}/g, () => safePrompt);
996
1019
  }
997
1020
 
1021
+ // 推理强度只接受小写字母 2~16 位(claude: low/medium/high/xhigh/max;codex 因模型而异),其余一律当没选
1022
+ function normalizeEffortValue(value) {
1023
+ return typeof value === 'string' && /^[a-z]{2,16}$/.test(value) ? value : '';
1024
+ }
1025
+
998
1026
  function buildCodexAgentExecCommand(template, prompt, options = {}) {
999
1027
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1000
1028
  const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
@@ -1015,15 +1043,19 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
1015
1043
  // 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
1016
1044
  const images = options && Array.isArray(options.images) ? options.images : [];
1017
1045
  const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
1046
+ const effort = normalizeEffortValue(options && options.effort);
1047
+ const effortFlag = effort && !/model_reasoning_effort/.test(suffix)
1048
+ ? ` -c model_reasoning_effort=${effort}`
1049
+ : '';
1018
1050
  const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
1019
1051
  ? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
1020
1052
  : '';
1021
1053
  if (sessionId) {
1022
1054
  const promptIndex = suffix.indexOf('{prompt}');
1023
1055
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
1024
- codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
1056
+ codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
1025
1057
  } else {
1026
- codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
1058
+ codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${suffix}`;
1027
1059
  }
1028
1060
  }
1029
1061
  // 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
@@ -1050,8 +1082,18 @@ function prependAgentFlags(commandText, matchPattern, flagSpecs) {
1050
1082
  return `${prefix}${suffix}`;
1051
1083
  }
1052
1084
 
1085
+ // 计划模式:-p 下 --permission-mode plan 和免确认参数同时出现时计划模式无效(实测),
1086
+ // 所以在渲染提示词之前从模板里去掉免确认参数和别的 permission-mode,再加上 plan。只处理模板,不碰提示词
1087
+ function applyPlanPermissionMode(templateText) {
1088
+ const stripped = templateText
1089
+ .replace(/(^|\s)--(?:allow-)?dangerously-skip-permissions(?=\s|$)/g, '$1')
1090
+ .replace(/(^|\s)--permission-mode(?:=\S+|\s+\S+)?(?=\s|$)/g, '$1');
1091
+ return stripped.replace(/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*claude)\b/, '$1 --permission-mode plan');
1092
+ }
1093
+
1053
1094
  function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1054
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1095
+ const rawTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1096
+ const templateText = options && options.permissionMode === 'plan' ? applyPlanPermissionMode(rawTemplate) : rawTemplate;
1055
1097
  const flagSpecs = [
1056
1098
  { flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
1057
1099
  { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
@@ -1069,6 +1111,9 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1069
1111
  if (sessionId && resumeAtUuid) {
1070
1112
  flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
1071
1113
  }
1114
+ if (sessionId && options && options.forkSession === true) {
1115
+ flagSpecs.push({ flag: '--fork-session', pattern: /(?:^|\s)--fork-session(?:\s|$)/ });
1116
+ }
1072
1117
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
1073
1118
  if (model) {
1074
1119
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
@@ -1089,11 +1134,15 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1089
1134
  if (forwardSubagentText === true) {
1090
1135
  flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
1091
1136
  }
1092
- const claudeTemplate = prependAgentFlags(
1093
- templateText,
1094
- /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
1095
- flagSpecs
1096
- );
1137
+ const claudePattern = /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/;
1138
+ let claudeTemplate = prependAgentFlags(templateText, claudePattern, flagSpecs);
1139
+ // 推理强度:CLAUDE_CODE_EFFORT_LEVEL 优先于 --effort 和 settings(实测),所以只能用命令前缀的环境变量覆盖容器里
1140
+ // 可能设了的值;模板自己写了就以模板为准
1141
+ const effort = normalizeEffortValue(options && options.effort);
1142
+ const claudePrefix = (templateText.match(claudePattern) || [])[2];
1143
+ if (effort && claudePrefix !== undefined && !/(?:^|\s)CLAUDE_CODE_EFFORT_LEVEL=/.test(claudePrefix)) {
1144
+ claudeTemplate = claudeTemplate.replace(/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b/, (match, envPrefix) => `${envPrefix}CLAUDE_CODE_EFFORT_LEVEL=${effort} claude`);
1145
+ }
1097
1146
  const rendered = claudeTemplate === templateText
1098
1147
  ? renderAgentPromptCommand(templateText, prompt)
1099
1148
  : renderAgentPromptCommand(claudeTemplate, prompt);
@@ -1525,10 +1574,13 @@ function normalizeAgentModelCatalogEntry(item) {
1525
1574
  if (!value) {
1526
1575
  return null;
1527
1576
  }
1577
+ const efforts = agentEffort.extractEfforts(item);
1528
1578
  return {
1529
1579
  value,
1530
1580
  label: pickFirstString(item && item.displayName, value),
1531
- description: pickFirstString(item && item.description)
1581
+ description: pickFirstString(item && item.description),
1582
+ ...(efforts.length ? { efforts } : {}),
1583
+ ...(typeof (item && item.resolvedModel) === 'string' && item.resolvedModel ? { resolvedModel: normalizeStoredAgentModelValue(item.resolvedModel) } : {})
1532
1584
  };
1533
1585
  }
1534
1586
 
@@ -1545,6 +1597,17 @@ function extractClaudeModelsFromControlResponse(payload) {
1545
1597
  return models.map(normalizeAgentModelCatalogEntry).filter(Boolean);
1546
1598
  }
1547
1599
 
1600
+ function extractClaudeCommandsFromControlResponse(payload) {
1601
+ if (!payload || payload.type !== 'control_response') {
1602
+ return undefined;
1603
+ }
1604
+ const response = toPlainObject(payload.response);
1605
+ if (response.request_id !== 'manyoyo-command-catalog') {
1606
+ return undefined;
1607
+ }
1608
+ return sessionMeta.normalizeCommandCatalog(toPlainObject(response.response).commands);
1609
+ }
1610
+
1548
1611
  function extractCodexModelsFromListResponse(payload) {
1549
1612
  if (!payload || payload.id !== 'manyoyo-model-list' || !payload.result) {
1550
1613
  return undefined;
@@ -1590,11 +1653,73 @@ async function fetchAgentModelCatalogCached(ctx, containerName, agentProgram) {
1590
1653
  return pending;
1591
1654
  }
1592
1655
 
1656
+ // 斜杠命令目录(claude 的 initialize 请求,不调用模型):同样按“容器”缓存 10 分钟,拿不到不缓存
1657
+ const commandCatalogCaches = new WeakMap();
1658
+ const COMMAND_CATALOG_FAILURE_TTL_MS = 15 * 1000;
1659
+
1660
+ async function fetchClaudeCommandCatalogCached(ctx, containerName) {
1661
+ let cache = commandCatalogCaches.get(ctx);
1662
+ if (!cache) {
1663
+ cache = new Map();
1664
+ commandCatalogCaches.set(ctx, cache);
1665
+ }
1666
+ const hit = cache.get(containerName);
1667
+ if (hit && (hit.pending || Date.now() - hit.at < (hit.commands.length ? MODEL_CATALOG_TTL_MS : COMMAND_CATALOG_FAILURE_TTL_MS))) {
1668
+ return hit.pending || hit.commands;
1669
+ }
1670
+ const requestLine = JSON.stringify({
1671
+ type: 'control_request',
1672
+ request_id: 'manyoyo-command-catalog',
1673
+ request: { subtype: 'initialize' }
1674
+ });
1675
+ const pending = runAgentProtocolProcess(
1676
+ ctx,
1677
+ containerName,
1678
+ 'claude -p --input-format stream-json --output-format stream-json --verbose',
1679
+ { stdinLines: [requestLine], onLine: extractClaudeCommandsFromControlResponse }
1680
+ ).then(({ result }) => {
1681
+ const commands = Array.isArray(result) ? result : [];
1682
+ // 取不到(容器停了、claude 不在)也记一小会儿:输入框里每敲一个字符都会请求一次,不能每次都进容器等 8 秒
1683
+ cache.set(containerName, { at: Date.now(), commands });
1684
+ return commands;
1685
+ }, () => {
1686
+ cache.set(containerName, { at: Date.now(), commands: [] });
1687
+ return [];
1688
+ });
1689
+ cache.set(containerName, { pending });
1690
+ return pending;
1691
+ }
1692
+
1693
+ // 当前模型能选的推理强度(空数组 = 确实没有,不显示;null = 目录取不到,不知道)。codex 接了第三方服务时目录里全是 OpenAI 的模型,直接当没有
1694
+ async function resolveEffortLevels(ctx, state, sessionRef, currentModel) {
1695
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
1696
+ const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
1697
+ const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
1698
+ history,
1699
+ sessionRef.agentId,
1700
+ containerInfo.defaultCommand
1701
+ ));
1702
+ if (agentProgram !== 'claude' && agentProgram !== 'codex') {
1703
+ return [];
1704
+ }
1705
+ try {
1706
+ if (agentProgram === 'codex') {
1707
+ const env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
1708
+ if (env.OPENAI_BASE_URL) return [];
1709
+ }
1710
+ const catalog = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
1711
+ // 目录取不到(容器没运行、探测超时)= 不知道,和“确实不支持”要分开:不知道时不能改用户已经选好的强度
1712
+ return catalog.length ? agentEffort.effortLevelsFor(agentProgram, catalog, currentModel) : null;
1713
+ } catch (e) {
1714
+ return null;
1715
+ }
1716
+ }
1717
+
1593
1718
  // 以会话实际配置的 env 生成模型选项。defaultModel 是“跟随默认”实际用的模型;
1594
1719
  // Claude 另有按档位别名(opus / sonnet / haiku …)一行一个的选项:value 是别名(--model opus 由 CLI 按 env 解析),
1595
1720
  // label 是该档位对应的真实模型名。不同档位指向同一个模型也各占一行,不合并。
1596
1721
  // 只读 *_MODEL 这类非敏感键,容器可写的 env 只用来展示,不参与任何安全判断
1597
- const CLAUDE_TIER_ORDER = ['opus', 'fable', 'sonnet', 'haiku'];
1722
+ const CLAUDE_TIER_ORDER = ['fable', 'opus', 'sonnet', 'haiku'];
1598
1723
 
1599
1724
  function buildEnvModelOptions(agentProgram, env) {
1600
1725
  const source = env && typeof env === 'object' ? env : {};
@@ -2382,7 +2507,7 @@ function prepareCodexTraceEvent(payload) {
2382
2507
  });
2383
2508
  }
2384
2509
 
2385
- async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
2510
+ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = [], turnOptions = {}) {
2386
2511
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2387
2512
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
2388
2513
  const containerMap = listWebManyoyoContainers(ctx);
@@ -2448,9 +2573,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images =
2448
2573
  const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
2449
2574
  sessionId: sessionIdForRun,
2450
2575
  model: agentSession.model || '',
2576
+ effort: agentSession.effort || '',
2451
2577
  forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
2452
2578
  images: imagesByFlag ? images : [],
2453
2579
  resumeAtUuid: agentSession.rewindToUuid || '',
2580
+ forkSession: agentSession.forkPending === true,
2581
+ permissionMode: turnOptions.permissionMode === 'plan' && agentMeta.agentProgram === 'claude' ? 'plan' : '',
2454
2582
  instructions: buildAgentInstructions(
2455
2583
  configSnapshot.parseError ? {} : configSnapshot.parsed,
2456
2584
  runName,
@@ -2485,20 +2613,36 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2485
2613
  ...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
2486
2614
  ...(turnUsage ? { usage: turnUsage } : {})
2487
2615
  });
2616
+ // 分叉的第一轮:命令里带的是原会话 id,输出里的才是新会话 id
2617
+ const extractedSessionId = extractEngineSessionId(agentMeta.agentProgram, result.stdout);
2618
+ const engineSessionIdNow = agentSession.forkPending === true
2619
+ ? extractedSessionId || meta.engineSessionId
2620
+ : meta.engineSessionId || extractedSessionId;
2621
+ // 分叉出来的 AGENT 第一轮:拿到了新的会话 id 才算分叉成功。没成功(命令失败)就保留 forkPending 和回退位置,
2622
+ // 下一轮仍然是分叉,不能退化成直接续接原会话(那会改写原 AGENT 的会话)
2623
+ // (命令在 init 之后才失败时输出里也有新 id,但新会话可能没落盘完整:这一轮必须成功才算分叉完成)
2624
+ const forkDone = agentSession.forkPending === true && Boolean(engineSessionIdNow) && engineSessionIdNow !== agentSession.engineSessionId
2625
+ && result.exitCode === 0 && result.interrupted !== true;
2626
+ const keepFork = agentSession.forkPending === true && !forkDone;
2488
2627
  const patch = {
2489
- rewindToUuid: '',
2628
+ rewindToUuid: keepFork ? agentSession.rewindToUuid || '' : '',
2629
+ ...(agentSession.forkPending === true && forkDone ? { forkPending: false } : {}),
2490
2630
  lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
2491
2631
  lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
2492
2632
  lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
2493
2633
  };
2494
- const engineSessionId = meta.engineSessionId || extractEngineSessionId(agentMeta.agentProgram, result.stdout);
2495
- if (engineSessionId) {
2496
- patch.engineSessionId = engineSessionId;
2634
+ if (engineSessionIdNow) {
2635
+ patch.engineSessionId = engineSessionIdNow;
2497
2636
  }
2498
2637
  if (turnUsage) {
2499
2638
  patch.usageTotal = accumulateUsageTotal(agentSession.usageTotal, turnUsage);
2500
2639
  }
2501
2640
  if (agentMeta.agentProgram === 'claude') {
2641
+ // 上下文占用:只在轮结束时写一次;这一轮拿不到数据就保留上一次的
2642
+ const contextUsage = contextUsageLib.extractClaudeContextUsage(result.stdoutTail || result.stdout);
2643
+ if (contextUsage) {
2644
+ patch.contextUsage = { ...contextUsage, updatedAt: new Date().toISOString() };
2645
+ }
2502
2646
  const slashCommands = extractClaudeInitSlashCommands(result.stdout);
2503
2647
  if (slashCommands) {
2504
2648
  patch.slashCommands = slashCommands;
@@ -2625,7 +2769,7 @@ async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
2625
2769
  if (!removed.length) {
2626
2770
  return { status: 409, error: '这条提示词已经不在了' };
2627
2771
  }
2628
- patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
2772
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid, contextUsage: null });
2629
2773
  appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
2630
2774
  }
2631
2775
  return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
@@ -2681,6 +2825,296 @@ async function refreshWebAgentTitle(ctx, state, sessionRef, agentProgram) {
2681
2825
  }
2682
2826
  }
2683
2827
 
2828
+ // 轻量模型调用(标题、回顾):不占容器运行锁,同一 AGENT 同一种用途同时最多一个,超时 90 秒,失败返回空串。
2829
+ // 高频路径,失败不写日志
2830
+ const lightCallsInFlight = new WeakMap();
2831
+
2832
+ async function runWebLightCall(ctx, state, sessionRef, agentProgram, kind, rule, input, maxChars) {
2833
+ if (agentProgram !== 'claude' && agentProgram !== 'codex') {
2834
+ return '';
2835
+ }
2836
+ let inFlight = lightCallsInFlight.get(ctx);
2837
+ if (!inFlight) {
2838
+ inFlight = new Set();
2839
+ lightCallsInFlight.set(ctx, inFlight);
2840
+ }
2841
+ const flightKey = `${sessionRef.containerName}\u0000${sessionRef.agentId}\u0000${kind}`;
2842
+ if (inFlight.has(flightKey)) {
2843
+ return '';
2844
+ }
2845
+ inFlight.add(flightKey);
2846
+ try {
2847
+ const agentSession = getWebAgentSession(
2848
+ loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
2849
+ sessionRef.agentId,
2850
+ { includeArchived: true }
2851
+ );
2852
+ let env = {};
2853
+ try {
2854
+ env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
2855
+ } catch (e) { /* 读不到 env 就按没有处理 */ }
2856
+ const model = agentSession ? agentSession.model || '' : '';
2857
+ const run = command => runWebSideCommand(ctx, sessionRef.containerName, command, { timeoutMs: agentLight.LIGHT_CALL_TIMEOUT_MS });
2858
+ let text = '';
2859
+ if (agentProgram === 'claude') {
2860
+ const command = agentLight.buildClaudeLightCommand({ rule, input, model: agentLight.pickClaudeLightModel(env, model) });
2861
+ text = agentLight.parseClaudeLightOutput((await run(command)).stdout);
2862
+ } else {
2863
+ const prompt = agentLight.buildCodexLightPrompt(rule, input);
2864
+ const attempt = async withEffort => {
2865
+ const template = withEffort ? agentLight.CODEX_LIGHT_TEMPLATE : agentLight.CODEX_LIGHT_TEMPLATE.replace(' -c model_reasoning_effort=low', '');
2866
+ const { stdout } = await run(buildCodexAgentExecCommand(template, prompt, { model }));
2867
+ return extractAgentMessageFromStructuredOutput('codex', stdout);
2868
+ };
2869
+ // 有的模型不接受 low:去掉强度再试一次
2870
+ text = await attempt(true) || await attempt(false);
2871
+ }
2872
+ return agentLight.cleanLightText(text, maxChars);
2873
+ } catch (e) {
2874
+ return '';
2875
+ } finally {
2876
+ inFlight.delete(flightKey);
2877
+ }
2878
+ }
2879
+
2880
+ // 侧问:不占容器运行锁,同一 AGENT 同时一个。命令和主任务一样套上 PID 文件包装,
2881
+ // 客户端断开 / 超时时进到容器里终止(只杀宿主机上的 exec 客户端,容器里的进程会继续跑)
2882
+ const sideQuestionsInFlight = new WeakMap();
2883
+
2884
+ function runWebSideCommand(ctx, containerName, command, { onAbortSignal, timeoutMs }) {
2885
+ return new Promise(resolve => {
2886
+ const runState = { runId: agentRunControl.createRunId(), containerName, process: null, closed: null };
2887
+ let stdout = '';
2888
+ let settled = false;
2889
+ let child;
2890
+ try {
2891
+ child = spawnContainerExec(
2892
+ ctx,
2893
+ containerName,
2894
+ { command: ['/bin/bash', '-lc', agentRunControl.wrapAgentCommand(command, runState.runId)] },
2895
+ { stdio: ['ignore', 'pipe', 'ignore'] }
2896
+ );
2897
+ } catch (e) {
2898
+ resolve({ stdout: '', error: '启动失败' });
2899
+ return;
2900
+ }
2901
+ runState.process = child;
2902
+ let markClosed;
2903
+ runState.closed = new Promise(done => { markClosed = done; });
2904
+ let aborted = '';
2905
+ const stop = reason => {
2906
+ if (settled || aborted) return;
2907
+ aborted = reason;
2908
+ terminateWebAgentRun(ctx, runState);
2909
+ };
2910
+ const timer = setTimeout(() => stop('timeout'), timeoutMs);
2911
+ if (typeof onAbortSignal === 'function') onAbortSignal(() => stop('aborted'));
2912
+ child.stdout.setEncoding('utf8');
2913
+ child.stdout.on('data', chunk => { if (stdout.length < 4 * 1024 * 1024) stdout += chunk; });
2914
+ const finish = () => {
2915
+ if (settled) return;
2916
+ settled = true;
2917
+ clearTimeout(timer);
2918
+ markClosed();
2919
+ resolve({ stdout, aborted });
2920
+ };
2921
+ child.once('close', finish);
2922
+ child.once('error', finish);
2923
+ });
2924
+ }
2925
+
2926
+ async function answerWebSideQuestion(ctx, state, sessionRef, agentProgram, agentSession, question, onAbortSignal) {
2927
+ const options = { model: agentSession.model || '', effort: agentSession.effort || '' };
2928
+ let command;
2929
+ if (agentProgram === 'claude') {
2930
+ command = agentSide.buildClaudeSideCommand({ sessionId: agentSession.engineSessionId, ...options, question });
2931
+ } else {
2932
+ command = buildCodexAgentExecCommand(agentSide.buildCodexSideTemplate({ threadId: agentSession.engineSessionId, ...options }), question, {});
2933
+ }
2934
+ const { stdout, aborted } = await runWebSideCommand(ctx, sessionRef.containerName, command, {
2935
+ onAbortSignal,
2936
+ timeoutMs: ctx.sideQuestionTimeoutMs || agentSide.SIDE_TIMEOUT_MS
2937
+ });
2938
+ if (aborted) {
2939
+ return { error: aborted === 'timeout' ? '侧问超时,已停止' : '已取消' };
2940
+ }
2941
+ const answer = agentProgram === 'claude'
2942
+ ? agentLight.parseClaudeLightOutput(stdout)
2943
+ : extractAgentMessageFromStructuredOutput('codex', stdout);
2944
+ return answer && answer.trim() ? { answer: agentSide.clipAnswer(answer) } : { error: 'Agent 没有给出回答' };
2945
+ }
2946
+
2947
+ const EXTENSION_AGENTS = [
2948
+ { program: 'claude', label: 'Claude Code' },
2949
+ { program: 'codex', label: 'Codex CLI' }
2950
+ ];
2951
+
2952
+ async function readWebExtensions(ctx, containerName) {
2953
+ const probe = async command => (await runAgentProtocolProcess(ctx, containerName, command, { timeoutMs: 10000 })).stdout;
2954
+ const present = await Promise.all(EXTENSION_AGENTS.map(async agent => (await probe(`command -v ${agent.program}`)).trim() !== ''));
2955
+ return Promise.all(EXTENSION_AGENTS.filter((agent, index) => present[index]).map(async agent => {
2956
+ if (agent.program === 'claude') {
2957
+ const [catalog, mcpOutput] = await Promise.all([
2958
+ fetchClaudeCommandCatalogCached(ctx, containerName),
2959
+ runAgentProtocolProcess(ctx, containerName, 'claude mcp list', { timeoutMs: 30000 }).then(item => item.stdout)
2960
+ ]);
2961
+ return { ...agent, skills: webExtensions.classifyClaudeSkills(catalog), mcp: webExtensions.parseClaudeMcpList(mcpOutput) };
2962
+ }
2963
+ const [skillsOutput, mcpOutput] = await Promise.all([
2964
+ probe(webExtensions.buildCodexSkillsCommand()),
2965
+ probe('codex mcp list --json')
2966
+ ]);
2967
+ return { ...agent, skills: webExtensions.parseCodexSkillsOutput(skillsOutput), mcp: webExtensions.parseCodexMcpJson(mcpOutput) };
2968
+ }));
2969
+ }
2970
+
2971
+ // 分叉出来的 AGENT 不继承的字段(运行态、统计、队列);其余在下面逐个列出要复制的
2972
+ function cloneMessageForFork(message) {
2973
+ const copy = JSON.parse(JSON.stringify(message));
2974
+ // 幂等 id 和执行中标记只属于原来那一轮
2975
+ delete copy.clientMessageId;
2976
+ delete copy.pending;
2977
+ return copy;
2978
+ }
2979
+
2980
+ async function forkWebAgentSession(ctx, state, sessionRef, messageId) {
2981
+ if (state.agentRuns.has(sessionRef.containerName)) {
2982
+ return { status: 409, error: '任务进行中,暂时不能分叉' };
2983
+ }
2984
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2985
+ const source = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
2986
+ if (!source || !Array.isArray(source.messages) || source.messages.length === 0) {
2987
+ return { status: 404, error: '这个 AGENT 还没有对话,没法分叉' };
2988
+ }
2989
+ let copyUntil = source.messages.length;
2990
+ let prompt = '';
2991
+ let images = [];
2992
+ let target = null;
2993
+ if (messageId) {
2994
+ const index = source.messages.findIndex(item => item && String(item.id || '') === messageId);
2995
+ target = index >= 0 ? source.messages[index] : null;
2996
+ if (!target || target.role !== 'user' || target.mode !== 'agent') {
2997
+ return { status: 404, error: '没有找到这条提示词' };
2998
+ }
2999
+ if (target.pending === true) {
3000
+ return { status: 409, error: '这一轮还在进行中' };
3001
+ }
3002
+ copyUntil = index;
3003
+ prompt = target.content;
3004
+ images = Array.isArray(target.images) ? target.images : [];
3005
+ } else if (source.messages.some(item => item && item.pending === true)) {
3006
+ return { status: 409, error: '任务进行中,暂时不能分叉' };
3007
+ }
3008
+ const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
3009
+ history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
3010
+ )).agentProgram;
3011
+ // 引擎会话:claude 用 --fork-session(带目标那一轮之前的位置);位置找不到或其它 Agent 就退回历史注入(不精确但不丢数据)
3012
+ let engineSessionId = '';
3013
+ let rewindToUuid = '';
3014
+ let conversationMode = 'history';
3015
+ if (program === 'claude' && source.engineSessionId) {
3016
+ try {
3017
+ const command = webRewind.buildClaudeRecordsCommand(source.engineSessionId);
3018
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
3019
+ const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
3020
+ if (!target) {
3021
+ // 从最新处分叉也要固定分叉点:否则原 AGENT 之后又聊了几轮,新 AGENT 第一轮会 fork 到原会话的最新状态
3022
+ const lastAssistant = [...records].reverse().find(item => item.t === 'assistant');
3023
+ if (lastAssistant && webRewind.UUID_PATTERN.test(lastAssistant.u)) {
3024
+ engineSessionId = source.engineSessionId;
3025
+ rewindToUuid = lastAssistant.u;
3026
+ conversationMode = 'precise';
3027
+ }
3028
+ } else {
3029
+ const pick = webRewind.pickClaudeResumeAt(records, { text: target.content, timeMs: Date.parse(target.timestamp) });
3030
+ if (pick.found && pick.uuid) {
3031
+ engineSessionId = source.engineSessionId;
3032
+ rewindToUuid = pick.uuid;
3033
+ conversationMode = 'precise';
3034
+ } else if (pick.found) {
3035
+ // 分叉点在第一轮之前:什么都不需要记得,开新会话
3036
+ conversationMode = 'precise';
3037
+ }
3038
+ }
3039
+ } catch (e) {
3040
+ // 容器起不来 / 读不到记录:退回历史注入(不精确但不丢数据)
3041
+ }
3042
+ }
3043
+ // 上面有 await:重新读一次,期间可能有人开始了新一轮
3044
+ if (state.agentRuns.has(sessionRef.containerName)) {
3045
+ return { status: 409, error: '任务进行中,暂时不能分叉' };
3046
+ }
3047
+ const freshHistory = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
3048
+ const freshSource = getWebAgentSession(freshHistory, sessionRef.agentId, { includeArchived: true });
3049
+ if (!freshSource || freshSource.messages.length < copyUntil) {
3050
+ return { status: 409, error: '对话刚刚发生了变化,请重试' };
3051
+ }
3052
+ const forked = createWebAgentSession(freshHistory);
3053
+ forked.messages = freshSource.messages.slice(0, copyUntil).map(cloneMessageForFork);
3054
+ forked.agentPromptCommand = freshSource.agentPromptCommand || '';
3055
+ forked.model = freshSource.model || '';
3056
+ forked.effort = freshSource.effort || '';
3057
+ forked.firstPrompt = freshSource.firstPrompt || '';
3058
+ forked.slashCommands = freshSource.slashCommands || null;
3059
+ forked.engineSessionId = engineSessionId;
3060
+ forked.rewindToUuid = rewindToUuid;
3061
+ forked.forkPending = Boolean(engineSessionId);
3062
+ // 标题:沿用原来的显示名加“(分叉)”,不再花钱重新生成
3063
+ const baseTitle = freshSource.remark || freshSource.title || freshSource.autoTitle || freshSource.firstPrompt || '';
3064
+ forked.autoTitle = baseTitle ? sessionMeta.normalizeTitle(`${Array.from(baseTitle).slice(0, 24).join('')}(分叉)`) : '';
3065
+ forked.autoTitleTried = true;
3066
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, freshHistory);
3067
+ return {
3068
+ result: {
3069
+ name: buildWebSessionKey(sessionRef.containerName, forked.agentId),
3070
+ agentId: forked.agentId,
3071
+ prompt,
3072
+ images,
3073
+ conversationMode
3074
+ }
3075
+ };
3076
+ }
3077
+
3078
+ // 第一条 Agent 消息发出后与第一轮并行生成标题(像 CLI 那样);写回前重新读一次,AGENT 被删了就放弃
3079
+ async function maybeStartAutoTitle(ctx, state, sessionRef, agentProgram, prompt) {
3080
+ try {
3081
+ await startAutoTitle(ctx, state, sessionRef, agentProgram, prompt);
3082
+ } catch (e) {
3083
+ // 调用方是 void:这里抛出去就是 unhandled rejection,会让 serve 退出。标题是锦上添花,失败静默
3084
+ }
3085
+ }
3086
+
3087
+ async function startAutoTitle(ctx, state, sessionRef, agentProgram, prompt) {
3088
+ if (ctx.autoTitle === false || (agentProgram !== 'claude' && agentProgram !== 'codex')) {
3089
+ return;
3090
+ }
3091
+ const agentSession = getWebAgentSession(
3092
+ loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
3093
+ sessionRef.agentId,
3094
+ { includeArchived: true }
3095
+ );
3096
+ if (!agentSession || agentSession.autoTitleTried || agentSession.autoTitle || agentSession.title || agentSession.remark) {
3097
+ return;
3098
+ }
3099
+ const userMessages = agentSession.messages.filter(item => item && item.role === 'user' && item.mode === 'agent');
3100
+ if (userMessages.length !== 1) {
3101
+ return;
3102
+ }
3103
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { autoTitleTried: true });
3104
+ const title = await runWebLightCall(ctx, state, sessionRef, agentProgram, 'title', agentLight.TITLE_RULE, agentLight.buildTitleInput(prompt), agentLight.TITLE_MAX_CHARS);
3105
+ if (!title) {
3106
+ return;
3107
+ }
3108
+ const fresh = getWebAgentSession(
3109
+ loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
3110
+ sessionRef.agentId,
3111
+ { includeArchived: true }
3112
+ );
3113
+ if (fresh && !fresh.autoTitle) {
3114
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { autoTitle: title });
3115
+ }
3116
+ }
3117
+
2684
3118
  function appendWebAgentTraceMessage(webHistoryDir, sessionRefOrContainerName, content, extra = {}) {
2685
3119
  const text = String(content || '').trim();
2686
3120
  if (!text) {
@@ -4773,6 +5207,17 @@ try {
4773
5207
  `);
4774
5208
  }
4775
5209
 
5210
+ // 开头事件里就带的引擎会话 id:claude 是 system/init 的 session_id,codex 是 thread.started 的 thread_id
5211
+ function extractEarlyEngineSessionId(agentProgram, payload) {
5212
+ if (!payload || typeof payload !== 'object') {
5213
+ return '';
5214
+ }
5215
+ const id = agentProgram === 'claude' && payload.type === 'system' && payload.subtype === 'init'
5216
+ ? payload.session_id
5217
+ : (agentProgram === 'codex' && payload.type === 'thread.started' ? payload.thread_id : '');
5218
+ return typeof id === 'string' && webRewind.UUID_PATTERN.test(id) ? id : '';
5219
+ }
5220
+
4776
5221
  async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
4777
5222
  const opts = options && typeof options === 'object' ? options : {};
4778
5223
  const sessionRef = typeof sessionRefOrContainerName === 'string'
@@ -4848,6 +5293,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4848
5293
  if (agentProgram === 'claude' || agentProgram === 'gemini' || agentProgram === 'codex' || agentProgram === 'opencode') {
4849
5294
  const payload = parseJsonObjectLine(rawLine);
4850
5295
  if (payload) {
5296
+ // claude 开头的 init 事件就带会话 id:第一轮没结束就让侧问等功能拿到它
5297
+ if (typeof opts.onEngineSessionId === 'function') {
5298
+ const earlySessionId = extractEarlyEngineSessionId(agentProgram, payload);
5299
+ if (earlySessionId) {
5300
+ try { opts.onEngineSessionId(earlySessionId); } catch (e) { /* 记不下来不影响本轮 */ }
5301
+ }
5302
+ }
4851
5303
  const traceEvents = prepareStructuredTraceEvents(agentProgram, payload, structuredTraceState);
4852
5304
  traceEvents.forEach(traceEvent => {
4853
5305
  if (!traceEvent || !traceEvent.text) {
@@ -5540,7 +5992,7 @@ async function reconcileOrphanTurns(ctx, state) {
5540
5992
  // 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
5541
5993
  // 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
5542
5994
  // 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
5543
- async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
5995
+ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = [], turnOptions = {}) {
5544
5996
  if (state.agentRuns.has(sessionRef.containerName)) {
5545
5997
  // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
5546
5998
  return { busy: true };
@@ -5554,6 +6006,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5554
6006
  userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
5555
6007
  mode: 'agent',
5556
6008
  pending: true,
6009
+ ...(turnOptions.permissionMode === 'plan' ? { planMode: true } : {}),
5557
6010
  ...(images.length ? { images } : {}),
5558
6011
  ...(clientMessageId ? { clientMessageId: clientMessageId } : {})
5559
6012
  });
@@ -5574,7 +6027,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5574
6027
 
5575
6028
  let prepared = null;
5576
6029
  try {
5577
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
6030
+ prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images, turnOptions);
5578
6031
  } catch (e) {
5579
6032
  releaseWebAgentRun(state, sessionRef.containerName, runState);
5580
6033
  removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
@@ -5583,6 +6036,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5583
6036
  }
5584
6037
 
5585
6038
  const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
6039
+ void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
5586
6040
  const traceLines = ['[执行过程]'];
5587
6041
  const traceEvents = [];
5588
6042
  let streamingReplyMessageId = '';
@@ -5759,6 +6213,10 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5759
6213
  agentProgram: agentMeta.agentProgram,
5760
6214
  // 复用路由入口占好的那把锁,而不是另起一份运行登记
5761
6215
  runState,
6216
+ // 只在这个 AGENT 还没有引擎会话时提前记下(分叉 / 续接的轮次已经有了,不能被改写)
6217
+ onEngineSessionId: (agentMeta.agentProgram === 'claude' || agentMeta.agentProgram === 'codex') && !agentSession.engineSessionId
6218
+ ? sessionId => patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId: sessionId })
6219
+ : undefined,
5762
6220
  onEvent: handleStreamEvent
5763
6221
  });
5764
6222
  } finally {
@@ -6186,6 +6644,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
6186
6644
  agentName: agentSession.agentName,
6187
6645
  agentRemark: agentSession.remark || '',
6188
6646
  agentTitle: agentSession.title || '',
6647
+ autoTitle: agentSession.autoTitle || '',
6648
+ recap: agentSession.recap || null,
6189
6649
  firstPrompt: agentSession.firstPrompt || '',
6190
6650
  pinned: agentSession.pinned === true,
6191
6651
  containerRemark: history.remark || '',
@@ -6198,6 +6658,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
6198
6658
  agentProgram: effectiveAgentProgram || '',
6199
6659
  resumeSupported: effectiveResumeSupported,
6200
6660
  model: agentSession.model || '',
6661
+ effort: agentSession.effort || '',
6662
+ contextUsage: agentSession.contextUsage || null,
6201
6663
  hostPath: applied.hostPath || '',
6202
6664
  containerPath: applied.containerPath || '',
6203
6665
  archived: agentSession.archived === true,
@@ -8226,9 +8688,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8226
8688
  }
8227
8689
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
8228
8690
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
8691
+ const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
8692
+ const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
8693
+ history,
8694
+ sessionRef.agentId,
8695
+ containerInfo.defaultCommand
8696
+ ));
8697
+ // 只有 claude 有可透传的斜杠命令;新 AGENT 没跑过一轮也能靠 initialize 目录列出来
8698
+ const catalog = agentProgram === 'claude'
8699
+ ? await fetchClaudeCommandCatalogCached(ctx, sessionRef.containerName)
8700
+ : [];
8229
8701
  sendJson(res, 200, {
8230
8702
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
8231
- commands: sessionMeta.listUsableSlashCommands(agentSession && agentSession.slashCommands)
8703
+ agentProgram,
8704
+ commands: agentProgram === 'claude'
8705
+ ? sessionMeta.listSlashCommandEntries(agentSession && agentSession.slashCommands, catalog)
8706
+ : []
8232
8707
  });
8233
8708
  }
8234
8709
  },
@@ -8260,7 +8735,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8260
8735
  sendJson(res, 200, {
8261
8736
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
8262
8737
  agentProgram,
8263
- models
8738
+ // 强度列表等内部字段只给服务端用(推理强度选项),不进这个接口的返回
8739
+ models: models.map(({ value, label, description }) => ({ value, label, description }))
8264
8740
  });
8265
8741
  }
8266
8742
  },
@@ -8308,8 +8784,216 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8308
8784
  sendJson(res, 400, { error: e.message || String(e) });
8309
8785
  return;
8310
8786
  }
8311
- patchWebAgentSessionState(state.webHistoryDir, sessionRef, { model });
8312
- sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), model });
8787
+ const patch = { model };
8788
+ // 换模型后,新模型不支持当前强度就回到默认(下次发送不再注入)
8789
+ const before = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
8790
+ if (before && before.effort) {
8791
+ const levels = await resolveEffortLevels(ctx, state, sessionRef, model);
8792
+ if (levels && !levels.includes(before.effort)) patch.effort = '';
8793
+ }
8794
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, patch);
8795
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), model, effort: patch.effort !== undefined ? patch.effort : (before ? before.effort || '' : '') });
8796
+ }
8797
+ },
8798
+ {
8799
+ // 技能与 MCP(只读):容器里装了哪个 Agent 就列哪个。claude 的 skill 来自命令目录,MCP 来自 mcp list(会真实连接,最长 30 秒)
8800
+ method: 'GET',
8801
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/extensions$/),
8802
+ handler: async match => {
8803
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8804
+ if (!sessionRef) {
8805
+ return;
8806
+ }
8807
+ try {
8808
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
8809
+ } catch (e) {
8810
+ sendJson(res, 400, { error: e && e.message ? e.message : '容器没有运行' });
8811
+ return;
8812
+ }
8813
+ const agents = await readWebExtensions(ctx, sessionRef.containerName);
8814
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), agents });
8815
+ }
8816
+ },
8817
+ {
8818
+ // 分叉:同容器新建一个 AGENT,带着目标那一轮之前的对话(不给 messageId 就是全部)。文件不复制(同一个工作目录)
8819
+ method: 'POST',
8820
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/fork$/),
8821
+ handler: async match => {
8822
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8823
+ if (!sessionRef) {
8824
+ return;
8825
+ }
8826
+ const payload = toPlainObject(await readJsonBody(req));
8827
+ const messageId = payload.messageId === undefined || payload.messageId === null ? '' : String(payload.messageId);
8828
+ const outcome = await forkWebAgentSession(ctx, state, sessionRef, messageId);
8829
+ if (outcome.error) {
8830
+ sendJson(res, outcome.status || 400, { error: outcome.error });
8831
+ return;
8832
+ }
8833
+ sendJson(res, 200, outcome.result);
8834
+ }
8835
+ },
8836
+ {
8837
+ // 侧问:带着上下文问一句,不占运行锁(主任务可以同时在跑)、不写历史、不写控制事件
8838
+ method: 'POST',
8839
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/side$/),
8840
+ handler: async match => {
8841
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8842
+ if (!sessionRef) {
8843
+ return;
8844
+ }
8845
+ const payload = toPlainObject(await readJsonBody(req));
8846
+ const question = agentSide.normalizeSideQuestion(payload.question);
8847
+ if (!question) {
8848
+ sendJson(res, 400, { error: '请输入要问的内容(不超过 4000 字)' });
8849
+ return;
8850
+ }
8851
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
8852
+ // 还没发过任何消息的 AGENT 没有历史记录:按空会话处理,不要为此创建
8853
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true }) || createEmptyWebAgentSession();
8854
+ const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
8855
+ const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(history, sessionRef.agentId, containerInfo.defaultCommand));
8856
+ if (agentProgram !== 'claude' && agentProgram !== 'codex') {
8857
+ sendJson(res, 400, { error: '当前 Agent 不支持侧问' });
8858
+ return;
8859
+ }
8860
+ // 刚分叉 / 刚回退、下一轮还没发出去:引擎会话 id 还指着原来的完整会话,带上它会看到分叉点之后 / 已被回退掉的内容
8861
+ if (agentSession.forkPending === true || agentSession.rewindToUuid) {
8862
+ sendJson(res, 409, { error: '刚分叉或回退过,先正常发一条消息再用侧问' });
8863
+ return;
8864
+ }
8865
+ let inFlight = sideQuestionsInFlight.get(ctx);
8866
+ if (!inFlight) {
8867
+ inFlight = new Set();
8868
+ sideQuestionsInFlight.set(ctx, inFlight);
8869
+ }
8870
+ const flightKey = `${sessionRef.containerName}\u0000${sessionRef.agentId}`;
8871
+ if (inFlight.has(flightKey)) {
8872
+ sendJson(res, 409, { error: '上一个侧问还在回答,请稍等' });
8873
+ return;
8874
+ }
8875
+ inFlight.add(flightKey);
8876
+ // 客户端在容器还没就绪时就断开也要算:先登记,拿到终止函数后再补上
8877
+ let abortSide = null;
8878
+ let clientGone = false;
8879
+ res.once('close', () => {
8880
+ if (res.writableEnded) return;
8881
+ clientGone = true;
8882
+ if (abortSide) abortSide();
8883
+ });
8884
+ try {
8885
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
8886
+ if (clientGone) {
8887
+ return;
8888
+ }
8889
+ const outcome = await answerWebSideQuestion(ctx, state, sessionRef, agentProgram, agentSession, question, abort => {
8890
+ abortSide = abort;
8891
+ if (clientGone) abort();
8892
+ });
8893
+ if (outcome.error) {
8894
+ sendJson(res, 502, { error: outcome.error });
8895
+ } else {
8896
+ sendJson(res, 200, { answer: outcome.answer });
8897
+ }
8898
+ } catch (e) {
8899
+ sendJson(res, 500, { error: e && e.message ? e.message : '侧问失败' });
8900
+ } finally {
8901
+ inFlight.delete(flightKey);
8902
+ }
8903
+ }
8904
+ },
8905
+ {
8906
+ // 回顾:隔了很久回来时由前端调用(服务端再校验条件,避免列表接口里悄悄花钱);force 给 /recap 用
8907
+ method: 'POST',
8908
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/recap$/),
8909
+ handler: async match => {
8910
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8911
+ if (!sessionRef) {
8912
+ return;
8913
+ }
8914
+ const payload = toPlainObject(await readJsonBody(req));
8915
+ const readSession = () => getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
8916
+ const agentSession = readSession();
8917
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
8918
+ const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
8919
+ const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(history, sessionRef.agentId, containerInfo.defaultCommand));
8920
+ const respond = () => {
8921
+ const current = readSession();
8922
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), recap: current && current.recap ? current.recap : null });
8923
+ };
8924
+ if (!agentSession || (agentProgram !== 'claude' && agentProgram !== 'codex') || state.agentRuns.has(sessionRef.containerName)) {
8925
+ respond();
8926
+ return;
8927
+ }
8928
+ const target = webRecap.shouldGenerateRecap(agentSession, ctx.nowMs(), payload.force === true);
8929
+ if (!target) {
8930
+ respond();
8931
+ return;
8932
+ }
8933
+ const text = await runWebLightCall(ctx, state, sessionRef, agentProgram, 'recap', agentLight.RECAP_RULE, agentLight.buildRecapInput(target.promptText, target.replyText), agentLight.RECAP_MAX_CHARS);
8934
+ if (text) {
8935
+ // 生成期间可能又发了新一轮:回复已经不是最后一条就丢掉
8936
+ const fresh = readSession();
8937
+ const latest = fresh ? webRecap.findRecapTarget(fresh.messages) : null;
8938
+ if (latest && latest.replyId === target.replyId) {
8939
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { recap: { forMessageId: target.replyId, text, createdAt: new Date().toISOString() } });
8940
+ }
8941
+ }
8942
+ respond();
8943
+ }
8944
+ },
8945
+ {
8946
+ // 推理强度选项:来自 Agent 的模型目录(claude 的 initialize、codex 的 model/list,10 分钟缓存),找不到就是空数组(界面不显示)
8947
+ method: 'GET',
8948
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/effort$/),
8949
+ handler: async match => {
8950
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8951
+ if (!sessionRef) {
8952
+ return;
8953
+ }
8954
+ const agentSession = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
8955
+ const levels = (await resolveEffortLevels(ctx, state, sessionRef, agentSession ? agentSession.model : '')) || [];
8956
+ let envDefault = '';
8957
+ try {
8958
+ const env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
8959
+ envDefault = agentEffort.isEffortValue(env.CLAUDE_CODE_EFFORT_LEVEL) ? env.CLAUDE_CODE_EFFORT_LEVEL : '';
8960
+ } catch (e) { /* 读不到 env 就不提示 */ }
8961
+ sendJson(res, 200, {
8962
+ name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
8963
+ levels,
8964
+ effort: agentSession && levels.includes(agentSession.effort) ? agentSession.effort : '',
8965
+ envDefault
8966
+ });
8967
+ }
8968
+ },
8969
+ {
8970
+ method: 'POST',
8971
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/effort$/),
8972
+ handler: async match => {
8973
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8974
+ if (!sessionRef) {
8975
+ return;
8976
+ }
8977
+ const payload = toPlainObject(await readJsonBody(req));
8978
+ const effort = payload.effort === undefined || payload.effort === null ? '' : payload.effort;
8979
+ if (effort !== '' && !agentEffort.isEffortValue(effort)) {
8980
+ sendJson(res, 400, { error: '推理强度不合法' });
8981
+ return;
8982
+ }
8983
+ if (effort !== '') {
8984
+ const agentSession = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
8985
+ const levels = await resolveEffortLevels(ctx, state, sessionRef, agentSession ? agentSession.model : '');
8986
+ if (levels === null) {
8987
+ sendJson(res, 409, { error: '暂时取不到当前模型支持的推理强度(容器没有运行?),稍后再试' });
8988
+ return;
8989
+ }
8990
+ if (!levels.includes(effort)) {
8991
+ sendJson(res, 400, { error: '当前模型不支持这个推理强度' });
8992
+ return;
8993
+ }
8994
+ }
8995
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { effort });
8996
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), effort });
8313
8997
  }
8314
8998
  },
8315
8999
  {
@@ -8468,6 +9152,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8468
9152
  ...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
8469
9153
  });
8470
9154
  releaseAgentClaim();
9155
+ void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
8471
9156
  const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
8472
9157
  agentProgram: agentMeta.agentProgram
8473
9158
  });
@@ -8518,7 +9203,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8518
9203
  if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
8519
9204
  return;
8520
9205
  }
8521
- const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages);
9206
+ // 计划模式只对 claude 有意义;其它取值一律当没选
9207
+ const turnOptions = payload.permissionMode === 'plan' ? { permissionMode: 'plan' } : {};
9208
+ const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages, turnOptions);
8522
9209
  if (turn.busy) {
8523
9210
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
8524
9211
  } else if (turn.error) {
@@ -8861,6 +9548,8 @@ async function startWebServer(options) {
8861
9548
  dockerExecArgs: options.dockerExecArgs,
8862
9549
  dockerExecAsync: options.dockerExecAsync,
8863
9550
  nowMs: typeof options.nowMs === 'function' ? options.nowMs : Date.now,
9551
+ // 自动标题(第一句话发出后的一次轻量模型调用);测试里按“最后一次 exec 就是 Agent 那一轮”断言时可关掉
9552
+ autoTitle: options.autoTitle !== false,
8864
9553
  retryRuntimeHeal: options.retryRuntimeHeal,
8865
9554
  showImagePullHint: options.showImagePullHint,
8866
9555
  removeContainer: options.removeContainer,
@@ -8977,6 +9666,11 @@ async function startWebServer(options) {
8977
9666
  sendJson(res, 409, { error: e.message, imagePreparing: true });
8978
9667
  return;
8979
9668
  }
9669
+ // 镜像不存在 / 拉不到:附上结构化信息,网页用弹窗告诉用户怎么办
9670
+ if (e && e.imageProblem) {
9671
+ sendJson(res, 409, { error: e.message, imageProblem: e.imageProblem });
9672
+ return;
9673
+ }
8980
9674
  ctx.logger.error('http request error', {
8981
9675
  method: req && req.method ? req.method : '',
8982
9676
  url: redactLoginTokenInUrl(req && req.url ? req.url : ''),
@@ -9179,6 +9873,10 @@ module.exports = {
9179
9873
  __testing: {
9180
9874
  prepareStructuredTraceEvents,
9181
9875
  buildClaudeAgentExecCommand,
9876
+ buildCodexAgentExecCommand,
9877
+ extractEarlyEngineSessionId,
9878
+ finalizeWebAgentExecution,
9879
+ patchWebAgentSessionState,
9182
9880
  buildWebAgentExecCommand,
9183
9881
  buildContainerFileListCommand,
9184
9882
  extractContentDeltaFromPayload,