@xcanwin/manyoyo 8.7.0 → 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');
@@ -27,6 +33,8 @@ const { createDeniedNotifier } = require('./denied-notify');
27
33
  const { domainRuleAllows } = require('../network-policy');
28
34
  const { createPortForwarder } = require('../port-forward');
29
35
  const { createContainerManageRoutes, pendingRisks, STALE_PAGE_MESSAGE } = require('./container-manage');
36
+ const { createModelServiceRoutes } = require('./model-services');
37
+ const { thirdPartyCodexModels } = require('../provider-presets');
30
38
  const { resolveManageOptions } = require('../container-manage-options');
31
39
  const { defaultPolicy, usesProxy } = require('../network-policy');
32
40
  const { normalizeMirrors } = require('../runtime-normalizers');
@@ -38,7 +46,7 @@ const { resolveQuickChatPath, expandTemplate } = require('../name-template');
38
46
  const { AGENT_ENV_SCHEMA } = require('../init-config');
39
47
  const { ensureImagePresent, pullImageProcess } = require('../image-pull');
40
48
  const { waitForImport } = require('../offline-import');
41
- const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
49
+ const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw, buildSetupSkippedConfigRaw } = require('../setup-config');
42
50
  const { createImageAgentProbe } = require('../image-agents');
43
51
  const { createAuthStore } = require('./auth-store');
44
52
  const webUploads = require('./uploads');
@@ -231,14 +239,25 @@ function createEmptyWebAgentSession(agentId, agentName) {
231
239
  engineSessionId: '',
232
240
  usageTotal: null,
233
241
  model: '',
242
+ // 推理强度:''=默认(不注入,尊重容器 env / 配置)
243
+ effort: '',
244
+ // claude 最近一轮结束时的上下文占用(见 lib/web/context-usage.js)
245
+ contextUsage: null,
234
246
  queue: [],
235
247
  queuePaused: false,
236
248
  title: '',
249
+ // 自动标题(轻量模型调用生成)与是否已经试过(失败也不重试,避免每次刷新都花钱)
250
+ autoTitle: '',
251
+ autoTitleTried: false,
252
+ // 回顾:{ forMessageId, text, createdAt },对应哪条最终回复
253
+ recap: null,
237
254
  firstPrompt: '',
238
255
  pinned: false,
239
256
  slashCommands: null,
240
257
  // 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
241
- rewindToUuid: ''
258
+ rewindToUuid: '',
259
+ // 分叉出来的 AGENT:下一轮 Claude 命令带 --fork-session(从原会话另开一条,拿到新会话 id 后清掉)
260
+ forkPending: false
242
261
  };
243
262
  }
244
263
 
@@ -291,13 +310,19 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
291
310
  engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
292
311
  usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
293
312
  model: normalizeStoredAgentModelValue(source.model),
313
+ effort: agentEffort.isEffortValue(source.effort) ? source.effort : '',
314
+ contextUsage: contextUsageLib.normalizeStoredContextUsage(source.contextUsage),
294
315
  queue: agentQueue.normalizeStoredQueue(source.queue),
295
316
  queuePaused: source.queuePaused === true,
296
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)),
297
321
  firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
298
322
  pinned: source.pinned === true,
299
323
  slashCommands: normalizeStoredSlashCommands(source.slashCommands),
300
- 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
301
326
  };
302
327
  }
303
328
 
@@ -587,6 +612,26 @@ function createWebSessionMessageId() {
587
612
  return `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
588
613
  }
589
614
 
615
+ // 容器状态类的系统消息(已创建并启动 / 已启动 / 首次命令已执行)只在会话已经有对话时才写:
616
+ // 新会话一建好就写一条,聊天页就不是空的,输入框不再居中;对话中途重建或启动容器时才值得告诉用户
617
+ function appendWebContainerNotice(webHistoryDir, sessionRefOrContainerName, content) {
618
+ const sessionRef = typeof sessionRefOrContainerName === 'string'
619
+ ? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
620
+ : sessionRefOrContainerName;
621
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
622
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
623
+ if (agentSession.messages.length) {
624
+ appendWebSessionMessage(webHistoryDir, sessionRef, 'system', content);
625
+ return;
626
+ }
627
+ // 不写消息,但新建的会话照样要落盘(列表里是真的 AGENT,不是现算出来的)
628
+ const timestamp = new Date().toISOString();
629
+ if (!agentSession.createdAt) agentSession.createdAt = timestamp;
630
+ agentSession.updatedAt = timestamp;
631
+ history.updatedAt = timestamp;
632
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
633
+ }
634
+
590
635
  function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role, content, extra = {}) {
591
636
  const sessionRef = typeof sessionRefOrContainerName === 'string'
592
637
  ? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
@@ -760,33 +805,6 @@ function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
760
805
  return removed;
761
806
  }
762
807
 
763
- // 从页面隐藏 / 取消隐藏某一组(用户消息 + 它的执行过程 + 回复):只改展示,agent 仍记得
764
- function setWebSessionTurnHidden(webHistoryDir, sessionRef, userMessageId, hidden) {
765
- const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
766
- const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
767
- if (!agentSession) {
768
- return 0;
769
- }
770
- const start = agentSession.messages.findIndex(item => item && String(item.id || '') === String(userMessageId) && item.role === 'user');
771
- if (start === -1) {
772
- return 0;
773
- }
774
- let end = start + 1;
775
- while (end < agentSession.messages.length && agentSession.messages[end].role !== 'user') {
776
- end += 1;
777
- }
778
- for (let i = start; i < end; i += 1) {
779
- if (hidden) agentSession.messages[i].hidden = true;
780
- else delete agentSession.messages[i].hidden;
781
- }
782
- agentSession.epoch = messagesView.createEpoch();
783
- const timestamp = new Date().toISOString();
784
- agentSession.updatedAt = timestamp;
785
- history.updatedAt = timestamp;
786
- saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
787
- return end - start;
788
- }
789
-
790
808
  function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
791
809
  const history = loadWebSessionHistory(webHistoryDir, containerName);
792
810
  history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
@@ -1000,6 +1018,11 @@ function renderAgentPromptCommand(template, prompt) {
1000
1018
  return templateText.replace(/\{prompt\}/g, () => safePrompt);
1001
1019
  }
1002
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
+
1003
1026
  function buildCodexAgentExecCommand(template, prompt, options = {}) {
1004
1027
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1005
1028
  const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
@@ -1020,21 +1043,24 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
1020
1043
  // 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
1021
1044
  const images = options && Array.isArray(options.images) ? options.images : [];
1022
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
+ : '';
1023
1050
  const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
1024
1051
  ? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
1025
1052
  : '';
1026
1053
  if (sessionId) {
1027
1054
  const promptIndex = suffix.indexOf('{prompt}');
1028
1055
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
1029
- codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
1056
+ codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
1030
1057
  } else {
1031
- codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
1058
+ codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${suffix}`;
1032
1059
  }
1033
1060
  }
1034
1061
  // 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
1035
- return withCodexBaseUrl(codexTemplate === templateText
1036
- ? renderAgentPromptCommand(templateText, prompt)
1037
- : renderAgentPromptCommand(codexTemplate, prompt));
1062
+ // 先给模板加占位再渲染提示词:用户的提示词里写了 model_provider / --model 之类的字也不会让注入失效
1063
+ return renderAgentPromptCommand(withCodexBaseUrl(codexTemplate), prompt);
1038
1064
  }
1039
1065
 
1040
1066
  function prependAgentFlags(commandText, matchPattern, flagSpecs) {
@@ -1056,8 +1082,18 @@ function prependAgentFlags(commandText, matchPattern, flagSpecs) {
1056
1082
  return `${prefix}${suffix}`;
1057
1083
  }
1058
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
+
1059
1094
  function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1060
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1095
+ const rawTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1096
+ const templateText = options && options.permissionMode === 'plan' ? applyPlanPermissionMode(rawTemplate) : rawTemplate;
1061
1097
  const flagSpecs = [
1062
1098
  { flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
1063
1099
  { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
@@ -1075,6 +1111,9 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1075
1111
  if (sessionId && resumeAtUuid) {
1076
1112
  flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
1077
1113
  }
1114
+ if (sessionId && options && options.forkSession === true) {
1115
+ flagSpecs.push({ flag: '--fork-session', pattern: /(?:^|\s)--fork-session(?:\s|$)/ });
1116
+ }
1078
1117
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
1079
1118
  if (model) {
1080
1119
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
@@ -1095,11 +1134,15 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1095
1134
  if (forwardSubagentText === true) {
1096
1135
  flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
1097
1136
  }
1098
- const claudeTemplate = prependAgentFlags(
1099
- templateText,
1100
- /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
1101
- flagSpecs
1102
- );
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
+ }
1103
1146
  const rendered = claudeTemplate === templateText
1104
1147
  ? renderAgentPromptCommand(templateText, prompt)
1105
1148
  : renderAgentPromptCommand(claudeTemplate, prompt);
@@ -1531,10 +1574,13 @@ function normalizeAgentModelCatalogEntry(item) {
1531
1574
  if (!value) {
1532
1575
  return null;
1533
1576
  }
1577
+ const efforts = agentEffort.extractEfforts(item);
1534
1578
  return {
1535
1579
  value,
1536
1580
  label: pickFirstString(item && item.displayName, value),
1537
- 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) } : {})
1538
1584
  };
1539
1585
  }
1540
1586
 
@@ -1551,6 +1597,17 @@ function extractClaudeModelsFromControlResponse(payload) {
1551
1597
  return models.map(normalizeAgentModelCatalogEntry).filter(Boolean);
1552
1598
  }
1553
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
+
1554
1611
  function extractCodexModelsFromListResponse(payload) {
1555
1612
  if (!payload || payload.id !== 'manyoyo-model-list' || !payload.result) {
1556
1613
  return undefined;
@@ -1596,11 +1653,73 @@ async function fetchAgentModelCatalogCached(ctx, containerName, agentProgram) {
1596
1653
  return pending;
1597
1654
  }
1598
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
+
1599
1718
  // 以会话实际配置的 env 生成模型选项。defaultModel 是“跟随默认”实际用的模型;
1600
1719
  // Claude 另有按档位别名(opus / sonnet / haiku …)一行一个的选项:value 是别名(--model opus 由 CLI 按 env 解析),
1601
1720
  // label 是该档位对应的真实模型名。不同档位指向同一个模型也各占一行,不合并。
1602
1721
  // 只读 *_MODEL 这类非敏感键,容器可写的 env 只用来展示,不参与任何安全判断
1603
- const CLAUDE_TIER_ORDER = ['opus', 'fable', 'sonnet', 'haiku'];
1722
+ const CLAUDE_TIER_ORDER = ['fable', 'opus', 'sonnet', 'haiku'];
1604
1723
 
1605
1724
  function buildEnvModelOptions(agentProgram, env) {
1606
1725
  const source = env && typeof env === 'object' ? env : {};
@@ -2388,7 +2507,7 @@ function prepareCodexTraceEvent(payload) {
2388
2507
  });
2389
2508
  }
2390
2509
 
2391
- async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
2510
+ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = [], turnOptions = {}) {
2392
2511
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2393
2512
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
2394
2513
  const containerMap = listWebManyoyoContainers(ctx);
@@ -2454,9 +2573,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images =
2454
2573
  const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
2455
2574
  sessionId: sessionIdForRun,
2456
2575
  model: agentSession.model || '',
2576
+ effort: agentSession.effort || '',
2457
2577
  forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
2458
2578
  images: imagesByFlag ? images : [],
2459
2579
  resumeAtUuid: agentSession.rewindToUuid || '',
2580
+ forkSession: agentSession.forkPending === true,
2581
+ permissionMode: turnOptions.permissionMode === 'plan' && agentMeta.agentProgram === 'claude' ? 'plan' : '',
2460
2582
  instructions: buildAgentInstructions(
2461
2583
  configSnapshot.parseError ? {} : configSnapshot.parsed,
2462
2584
  runName,
@@ -2491,20 +2613,36 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2491
2613
  ...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
2492
2614
  ...(turnUsage ? { usage: turnUsage } : {})
2493
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;
2494
2627
  const patch = {
2495
- rewindToUuid: '',
2628
+ rewindToUuid: keepFork ? agentSession.rewindToUuid || '' : '',
2629
+ ...(agentSession.forkPending === true && forkDone ? { forkPending: false } : {}),
2496
2630
  lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
2497
2631
  lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
2498
2632
  lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
2499
2633
  };
2500
- const engineSessionId = meta.engineSessionId || extractEngineSessionId(agentMeta.agentProgram, result.stdout);
2501
- if (engineSessionId) {
2502
- patch.engineSessionId = engineSessionId;
2634
+ if (engineSessionIdNow) {
2635
+ patch.engineSessionId = engineSessionIdNow;
2503
2636
  }
2504
2637
  if (turnUsage) {
2505
2638
  patch.usageTotal = accumulateUsageTotal(agentSession.usageTotal, turnUsage);
2506
2639
  }
2507
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
+ }
2508
2646
  const slashCommands = extractClaudeInitSlashCommands(result.stdout);
2509
2647
  if (slashCommands) {
2510
2648
  patch.slashCommands = slashCommands;
@@ -2631,7 +2769,7 @@ async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
2631
2769
  if (!removed.length) {
2632
2770
  return { status: 409, error: '这条提示词已经不在了' };
2633
2771
  }
2634
- patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
2772
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid, contextUsage: null });
2635
2773
  appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
2636
2774
  }
2637
2775
  return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
@@ -2687,6 +2825,296 @@ async function refreshWebAgentTitle(ctx, state, sessionRef, agentProgram) {
2687
2825
  }
2688
2826
  }
2689
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
+
2690
3118
  function appendWebAgentTraceMessage(webHistoryDir, sessionRefOrContainerName, content, extra = {}) {
2691
3119
  const text = String(content || '').trim();
2692
3120
  if (!text) {
@@ -4232,20 +4660,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
4232
4660
  mirrors,
4233
4661
  warn: message => console.warn(message)
4234
4662
  });
4235
- appendWebSessionMessage(
4236
- state.webHistoryDir,
4237
- messageSessionRef || runtime.containerName,
4238
- 'system',
4239
- `容器 ${runtime.containerName} 已创建并启动。`
4240
- );
4663
+ appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已创建并启动。`);
4241
4664
  if (runtime.first) {
4242
4665
  const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
4243
- appendWebSessionMessage(
4244
- state.webHistoryDir,
4245
- messageSessionRef || runtime.containerName,
4246
- 'system',
4247
- firstResult.exitCode === 0 ? '首次命令已执行' : formatWebFirstFailure(firstResult, runtime.first)
4248
- );
4666
+ if (firstResult.exitCode === 0) {
4667
+ appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, '首次命令已执行');
4668
+ } else {
4669
+ // 失败一律写:用户必须看到
4670
+ appendWebSessionMessage(state.webHistoryDir, messageSessionRef || runtime.containerName, 'system', formatWebFirstFailure(firstResult, runtime.first));
4671
+ }
4249
4672
  }
4250
4673
  return;
4251
4674
  }
@@ -4253,12 +4676,7 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
4253
4676
  const status = ctx.getContainerStatus(runtime.containerName);
4254
4677
  if (status !== 'running') {
4255
4678
  ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
4256
- appendWebSessionMessage(
4257
- state.webHistoryDir,
4258
- messageSessionRef || runtime.containerName,
4259
- 'system',
4260
- `容器 ${runtime.containerName} 已启动。`
4261
- );
4679
+ appendWebContainerNotice(state.webHistoryDir, messageSessionRef || runtime.containerName, `容器 ${runtime.containerName} 已启动。`);
4262
4680
  }
4263
4681
  await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
4264
4682
  }
@@ -4789,6 +5207,17 @@ try {
4789
5207
  `);
4790
5208
  }
4791
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
+
4792
5221
  async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
4793
5222
  const opts = options && typeof options === 'object' ? options : {};
4794
5223
  const sessionRef = typeof sessionRefOrContainerName === 'string'
@@ -4864,6 +5293,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4864
5293
  if (agentProgram === 'claude' || agentProgram === 'gemini' || agentProgram === 'codex' || agentProgram === 'opencode') {
4865
5294
  const payload = parseJsonObjectLine(rawLine);
4866
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
+ }
4867
5303
  const traceEvents = prepareStructuredTraceEvents(agentProgram, payload, structuredTraceState);
4868
5304
  traceEvents.forEach(traceEvent => {
4869
5305
  if (!traceEvent || !traceEvent.text) {
@@ -5556,7 +5992,7 @@ async function reconcileOrphanTurns(ctx, state) {
5556
5992
  // 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
5557
5993
  // 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
5558
5994
  // 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
5559
- async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
5995
+ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = [], turnOptions = {}) {
5560
5996
  if (state.agentRuns.has(sessionRef.containerName)) {
5561
5997
  // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
5562
5998
  return { busy: true };
@@ -5570,6 +6006,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5570
6006
  userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
5571
6007
  mode: 'agent',
5572
6008
  pending: true,
6009
+ ...(turnOptions.permissionMode === 'plan' ? { planMode: true } : {}),
5573
6010
  ...(images.length ? { images } : {}),
5574
6011
  ...(clientMessageId ? { clientMessageId: clientMessageId } : {})
5575
6012
  });
@@ -5590,7 +6027,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5590
6027
 
5591
6028
  let prepared = null;
5592
6029
  try {
5593
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
6030
+ prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images, turnOptions);
5594
6031
  } catch (e) {
5595
6032
  releaseWebAgentRun(state, sessionRef.containerName, runState);
5596
6033
  removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
@@ -5599,6 +6036,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5599
6036
  }
5600
6037
 
5601
6038
  const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
6039
+ void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
5602
6040
  const traceLines = ['[执行过程]'];
5603
6041
  const traceEvents = [];
5604
6042
  let streamingReplyMessageId = '';
@@ -5775,6 +6213,10 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5775
6213
  agentProgram: agentMeta.agentProgram,
5776
6214
  // 复用路由入口占好的那把锁,而不是另起一份运行登记
5777
6215
  runState,
6216
+ // 只在这个 AGENT 还没有引擎会话时提前记下(分叉 / 续接的轮次已经有了,不能被改写)
6217
+ onEngineSessionId: (agentMeta.agentProgram === 'claude' || agentMeta.agentProgram === 'codex') && !agentSession.engineSessionId
6218
+ ? sessionId => patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId: sessionId })
6219
+ : undefined,
5778
6220
  onEvent: handleStreamEvent
5779
6221
  });
5780
6222
  } finally {
@@ -5893,7 +6335,10 @@ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
5893
6335
  }
5894
6336
 
5895
6337
  const SETUP_TEST_TIMEOUT_SECONDS = 60;
5896
- const SETUP_TEST_PROMPT = 'Reply with the single word OK.';
6338
+ // 要求真的调一次工具:只回一句 OK 发现不了“能连通但调不了工具”(智谱 + Claude Code 工具搜索就是这样)。
6339
+ // 命令文本里没有 42,算出来的结果只会来自真的执行
6340
+ const SETUP_TEST_PROMPT = 'Run the shell command `echo mp-$((6*7))` and reply with its exact output only.';
6341
+ const SETUP_TEST_EXPECT_OUTPUT = 'mp-42';
5897
6342
 
5898
6343
  function getSetupWorkpath(state) {
5899
6344
  return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
@@ -5980,7 +6425,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
5980
6425
  if (!template) {
5981
6426
  return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
5982
6427
  }
5983
- const agentCommand = withCodexBaseUrl(renderAgentPromptCommand(template, SETUP_TEST_PROMPT));
6428
+ const agentCommand = renderAgentPromptCommand(withCodexBaseUrl(template), SETUP_TEST_PROMPT);
5984
6429
  const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
5985
6430
 
5986
6431
  const workpath = getSetupWorkpath(state);
@@ -6018,7 +6463,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
6018
6463
  })
6019
6464
  ]);
6020
6465
  clearTimeout(timer);
6021
- return classifyConnectionResult(result, secrets);
6466
+ return classifyConnectionResult(result, secrets, { expectOutput: SETUP_TEST_EXPECT_OUTPUT });
6022
6467
  } catch (e) {
6023
6468
  return classifyConnectionResult({ exitCode: 1, output: e && e.message ? e.message : '' }, secrets);
6024
6469
  } finally {
@@ -6076,8 +6521,10 @@ async function buildSetupStatus(ctx, state) {
6076
6521
  const snapshot = readWebConfigSnapshot(state.webConfigPath);
6077
6522
  const config = snapshot.parseError ? {} : snapshot.parsed;
6078
6523
  const configuredAgents = getConfiguredAgents(config);
6524
+ const setupSkipped = Boolean(config.serve && typeof config.serve === 'object' && config.serve.setupSkipped === true);
6079
6525
  return {
6080
- needsSetup: configuredAgents.length === 0,
6526
+ needsSetup: configuredAgents.length === 0 && !setupSkipped,
6527
+ setupSkipped,
6081
6528
  configuredAgents,
6082
6529
  serverUser: ctx.authUser,
6083
6530
  // 自动生成(用户看不到)的密码不算“已设置”
@@ -6108,12 +6555,13 @@ function resolveSetupRunInput(ctx, state, payload) {
6108
6555
  if (!hostPath || !path.isAbsolute(hostPath)) {
6109
6556
  throw new Error('hostPath 必须是绝对路径');
6110
6557
  }
6111
- if (hostPath === getSetupWorkpath(state)) fs.mkdirSync(hostPath, { recursive: true });
6112
6558
  ctx.validateHostPath(hostPath);
6113
6559
  }
6560
+ // 选的就是默认工作目录根:不写进运行配置(新建容器各自建 <根>/<时间> 子目录,见 buildAgentRunProfile)
6561
+ if (hostPath === getSetupWorkpath(state)) hostPath = '';
6562
+ fs.mkdirSync(getSetupWorkpath(state), { recursive: true });
6114
6563
  const profile = buildAgentRunProfile(agent, body.env === undefined ? {} : body.env, {
6115
6564
  existingRun,
6116
- defaultHostPath: getSetupWorkpath(state),
6117
6565
  hostPath
6118
6566
  });
6119
6567
  return { agent, snapshot, profile };
@@ -6196,6 +6644,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
6196
6644
  agentName: agentSession.agentName,
6197
6645
  agentRemark: agentSession.remark || '',
6198
6646
  agentTitle: agentSession.title || '',
6647
+ autoTitle: agentSession.autoTitle || '',
6648
+ recap: agentSession.recap || null,
6199
6649
  firstPrompt: agentSession.firstPrompt || '',
6200
6650
  pinned: agentSession.pinned === true,
6201
6651
  containerRemark: history.remark || '',
@@ -6208,6 +6658,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
6208
6658
  agentProgram: effectiveAgentProgram || '',
6209
6659
  resumeSupported: effectiveResumeSupported,
6210
6660
  model: agentSession.model || '',
6661
+ effort: agentSession.effort || '',
6662
+ contextUsage: agentSession.contextUsage || null,
6211
6663
  hostPath: applied.hostPath || '',
6212
6664
  containerPath: applied.containerPath || '',
6213
6665
  archived: agentSession.archived === true,
@@ -7169,6 +7621,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7169
7621
  sendJson(res, 200, { saved: true, agent: input.agent, run: sanitizeSensitiveData(input.profile) });
7170
7622
  }
7171
7623
  },
7624
+ {
7625
+ method: 'POST',
7626
+ match: currentPath => currentPath === '/api/setup/skip' ? [] : null,
7627
+ handler: async () => {
7628
+ await readJsonBody(req);
7629
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
7630
+ if (snapshot.parseError) {
7631
+ sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
7632
+ return;
7633
+ }
7634
+ let nextRaw;
7635
+ try {
7636
+ nextRaw = buildSetupSkippedConfigRaw(baseRawOf(snapshot));
7637
+ parseAndValidateConfigRaw(nextRaw);
7638
+ } catch (e) {
7639
+ sendJson(res, 400, { error: '配置写入失败', detail: e.message || '' });
7640
+ return;
7641
+ }
7642
+ writeConfigFileSecure(path.resolve(state.webConfigPath), nextRaw);
7643
+ sendJson(res, 200, { saved: true });
7644
+ }
7645
+ },
7172
7646
  {
7173
7647
  method: 'POST',
7174
7648
  match: currentPath => currentPath === '/api/setup/password' ? [] : null,
@@ -8158,27 +8632,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8158
8632
  sendJson(res, 200, outcome);
8159
8633
  }
8160
8634
  },
8161
- {
8162
- method: 'POST',
8163
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/hide$/),
8164
- handler: async match => {
8165
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
8166
- if (!sessionRef) {
8167
- return;
8168
- }
8169
- const payload = await readJsonBody(req);
8170
- if (state.agentRuns.has(sessionRef.containerName)) {
8171
- sendJson(res, 409, { error: '任务进行中,暂时不能隐藏' });
8172
- return;
8173
- }
8174
- const changed = setWebSessionTurnHidden(state.webHistoryDir, sessionRef, String(payload.messageId || ''), payload.hidden !== false);
8175
- if (!changed) {
8176
- sendJson(res, 404, { error: '没有找到这一组' });
8177
- return;
8178
- }
8179
- sendJson(res, 200, { ok: true, count: changed });
8180
- }
8181
- },
8182
8635
  {
8183
8636
  // “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
8184
8637
  method: 'GET',
@@ -8235,9 +8688,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8235
8688
  }
8236
8689
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
8237
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
+ : [];
8238
8701
  sendJson(res, 200, {
8239
8702
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
8240
- commands: sessionMeta.listUsableSlashCommands(agentSession && agentSession.slashCommands)
8703
+ agentProgram,
8704
+ commands: agentProgram === 'claude'
8705
+ ? sessionMeta.listSlashCommandEntries(agentSession && agentSession.slashCommands, catalog)
8706
+ : []
8241
8707
  });
8242
8708
  }
8243
8709
  },
@@ -8258,11 +8724,19 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8258
8724
  containerInfo.defaultCommand
8259
8725
  );
8260
8726
  const agentProgram = resolveAgentProgram(effectiveTemplate);
8261
- const models = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
8727
+ let models = null;
8728
+ if (agentProgram === 'codex') {
8729
+ // 接了第三方时目录里全是 OpenAI 的模型名,改用这家的预设模型
8730
+ try {
8731
+ models = thirdPartyCodexModels(readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName));
8732
+ } catch (e) { /* 读不到 env 就走原来的目录探测 */ }
8733
+ }
8734
+ if (!models) models = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
8262
8735
  sendJson(res, 200, {
8263
8736
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
8264
8737
  agentProgram,
8265
- models
8738
+ // 强度列表等内部字段只给服务端用(推理强度选项),不进这个接口的返回
8739
+ models: models.map(({ value, label, description }) => ({ value, label, description }))
8266
8740
  });
8267
8741
  }
8268
8742
  },
@@ -8310,8 +8784,216 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8310
8784
  sendJson(res, 400, { error: e.message || String(e) });
8311
8785
  return;
8312
8786
  }
8313
- patchWebAgentSessionState(state.webHistoryDir, sessionRef, { model });
8314
- 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 });
8315
8997
  }
8316
8998
  },
8317
8999
  {
@@ -8470,6 +9152,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8470
9152
  ...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
8471
9153
  });
8472
9154
  releaseAgentClaim();
9155
+ void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
8473
9156
  const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
8474
9157
  agentProgram: agentMeta.agentProgram
8475
9158
  });
@@ -8520,7 +9203,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8520
9203
  if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
8521
9204
  return;
8522
9205
  }
8523
- 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);
8524
9209
  if (turn.busy) {
8525
9210
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
8526
9211
  } else if (turn.error) {
@@ -8755,6 +9440,28 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8755
9440
  }
8756
9441
  ];
8757
9442
 
9443
+ routes.push(...createModelServiceRoutes({
9444
+ ctx,
9445
+ state,
9446
+ req,
9447
+ res,
9448
+ deps: {
9449
+ sendJson,
9450
+ readJsonBody,
9451
+ decodeSessionName,
9452
+ readSnapshot: () => readWebConfigSnapshot(state.webConfigPath),
9453
+ writeRaw: raw => writeConfigFileSecure(path.resolve(state.webConfigPath), raw),
9454
+ baseRawOf,
9455
+ validateRaw: parseAndValidateConfigRaw,
9456
+ runConnectionTest: (agent, env) => runSetupConnectionTest(ctx, state, agent, env),
9457
+ defaultHostPath: () => getSetupWorkpath(state),
9458
+ listManaged: async () => {
9459
+ const manager = getWebNetworkManager(ctx, state);
9460
+ return typeof manager.listManaged === 'function' ? manager.listManaged() : [];
9461
+ }
9462
+ }
9463
+ }));
9464
+
8758
9465
  routes.push(...createContainerManageRoutes({
8759
9466
  ctx,
8760
9467
  state,
@@ -8841,6 +9548,8 @@ async function startWebServer(options) {
8841
9548
  dockerExecArgs: options.dockerExecArgs,
8842
9549
  dockerExecAsync: options.dockerExecAsync,
8843
9550
  nowMs: typeof options.nowMs === 'function' ? options.nowMs : Date.now,
9551
+ // 自动标题(第一句话发出后的一次轻量模型调用);测试里按“最后一次 exec 就是 Agent 那一轮”断言时可关掉
9552
+ autoTitle: options.autoTitle !== false,
8844
9553
  retryRuntimeHeal: options.retryRuntimeHeal,
8845
9554
  showImagePullHint: options.showImagePullHint,
8846
9555
  removeContainer: options.removeContainer,
@@ -8957,6 +9666,11 @@ async function startWebServer(options) {
8957
9666
  sendJson(res, 409, { error: e.message, imagePreparing: true });
8958
9667
  return;
8959
9668
  }
9669
+ // 镜像不存在 / 拉不到:附上结构化信息,网页用弹窗告诉用户怎么办
9670
+ if (e && e.imageProblem) {
9671
+ sendJson(res, 409, { error: e.message, imageProblem: e.imageProblem });
9672
+ return;
9673
+ }
8960
9674
  ctx.logger.error('http request error', {
8961
9675
  method: req && req.method ? req.method : '',
8962
9676
  url: redactLoginTokenInUrl(req && req.url ? req.url : ''),
@@ -9159,6 +9873,10 @@ module.exports = {
9159
9873
  __testing: {
9160
9874
  prepareStructuredTraceEvents,
9161
9875
  buildClaudeAgentExecCommand,
9876
+ buildCodexAgentExecCommand,
9877
+ extractEarlyEngineSessionId,
9878
+ finalizeWebAgentExecution,
9879
+ patchWebAgentSessionState,
9162
9880
  buildWebAgentExecCommand,
9163
9881
  buildContainerFileListCommand,
9164
9882
  extractContentDeltaFromPayload,