@xcanwin/manyoyo 8.6.10 → 8.6.12

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
@@ -19,7 +19,7 @@ const gitChanges = require('./git-changes');
19
19
  const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
20
20
  const { createNetworkManager, NETWORK_NAME } = require('../container-network');
21
21
  const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../network-policy');
22
- const { withEnvEndpoints } = require('../network-endpoints');
22
+ const { withEnvEndpoints, modelDomains, agentModelDomains } = require('../network-endpoints');
23
23
  const { egressPaths, isSidecarName } = require('../egress-sidecar');
24
24
  const { read: readDenied } = require('../egress-denied');
25
25
  const { isBrowserBackground } = require('../browser-background');
@@ -38,6 +38,11 @@ const { AGENT_ENV_SCHEMA } = require('../init-config');
38
38
  const { ensureImagePresent, pullImageProcess } = require('../image-pull');
39
39
  const { waitForImport } = require('../offline-import');
40
40
  const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
41
+ const { createImageAgentProbe } = require('../image-agents');
42
+ const { createAuthStore } = require('./auth-store');
43
+ const webUploads = require('./uploads');
44
+ const webSnapshots = require('./snapshots');
45
+ const webRewind = require('./rewind');
41
46
  const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
42
47
  const { applyDoctorFixes } = require('../doctor');
43
48
  const { writeConfigFileSecure } = require('../secure-file');
@@ -52,7 +57,7 @@ const {
52
57
  resolveAgentPromptCommandTemplate,
53
58
  buildAgentResumeCommand
54
59
  } = require('../agent-resume');
55
- const { resolveYoloCommand: resolveYoloCommandFromAdapters } = require('../agent-adapters');
60
+ const { resolveYoloCommand: resolveYoloCommandFromAdapters, withCodexBaseUrl } = require('../agent-adapters');
56
61
  const {
57
62
  createControlEvent,
58
63
  validateControlEvent,
@@ -229,7 +234,9 @@ function createEmptyWebAgentSession(agentId, agentName) {
229
234
  title: '',
230
235
  firstPrompt: '',
231
236
  pinned: false,
232
- slashCommands: null
237
+ slashCommands: null,
238
+ // 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
239
+ rewindToUuid: ''
233
240
  };
234
241
  }
235
242
 
@@ -287,7 +294,8 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
287
294
  title: sessionMeta.normalizeTitle(source.title),
288
295
  firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
289
296
  pinned: source.pinned === true,
290
- slashCommands: normalizeStoredSlashCommands(source.slashCommands)
297
+ slashCommands: normalizeStoredSlashCommands(source.slashCommands),
298
+ rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
291
299
  };
292
300
  }
293
301
 
@@ -730,6 +738,53 @@ function removeWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messa
730
738
  return removed;
731
739
  }
732
740
 
741
+ // 删掉某条消息及之后的所有消息(回退对话)。改动的是稳定前缀,必须换新 epoch,增量同步据此让前端整页重置
742
+ function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
743
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
744
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
745
+ if (!agentSession) {
746
+ return [];
747
+ }
748
+ const index = agentSession.messages.findIndex(item => item && String(item.id || '') === String(messageId));
749
+ if (index === -1) {
750
+ return [];
751
+ }
752
+ const removed = agentSession.messages.splice(index);
753
+ agentSession.epoch = messagesView.createEpoch();
754
+ const timestamp = new Date().toISOString();
755
+ agentSession.updatedAt = timestamp;
756
+ history.updatedAt = timestamp;
757
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
758
+ return removed;
759
+ }
760
+
761
+ // 从页面隐藏 / 取消隐藏某一组(用户消息 + 它的执行过程 + 回复):只改展示,agent 仍记得
762
+ function setWebSessionTurnHidden(webHistoryDir, sessionRef, userMessageId, hidden) {
763
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
764
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
765
+ if (!agentSession) {
766
+ return 0;
767
+ }
768
+ const start = agentSession.messages.findIndex(item => item && String(item.id || '') === String(userMessageId) && item.role === 'user');
769
+ if (start === -1) {
770
+ return 0;
771
+ }
772
+ let end = start + 1;
773
+ while (end < agentSession.messages.length && agentSession.messages[end].role !== 'user') {
774
+ end += 1;
775
+ }
776
+ for (let i = start; i < end; i += 1) {
777
+ if (hidden) agentSession.messages[i].hidden = true;
778
+ else delete agentSession.messages[i].hidden;
779
+ }
780
+ agentSession.epoch = messagesView.createEpoch();
781
+ const timestamp = new Date().toISOString();
782
+ agentSession.updatedAt = timestamp;
783
+ history.updatedAt = timestamp;
784
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
785
+ return end - start;
786
+ }
787
+
733
788
  function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
734
789
  const history = loadWebSessionHistory(webHistoryDir, containerName);
735
790
  history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
@@ -960,20 +1015,24 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
960
1015
  const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
961
1016
  // 环境说明:模板里已经写了 developer_instructions 就以用户的为准
962
1017
  const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
1018
+ // 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
1019
+ const images = options && Array.isArray(options.images) ? options.images : [];
1020
+ const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
963
1021
  const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
964
1022
  ? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
965
1023
  : '';
966
1024
  if (sessionId) {
967
1025
  const promptIndex = suffix.indexOf('{prompt}');
968
1026
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
969
- codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${resumeSuffix}`;
1027
+ codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
970
1028
  } else {
971
- codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${suffix}`;
1029
+ codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
972
1030
  }
973
1031
  }
974
- return codexTemplate === templateText
1032
+ // 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
1033
+ return withCodexBaseUrl(codexTemplate === templateText
975
1034
  ? renderAgentPromptCommand(templateText, prompt)
976
- : renderAgentPromptCommand(codexTemplate, prompt);
1035
+ : renderAgentPromptCommand(codexTemplate, prompt));
977
1036
  }
978
1037
 
979
1038
  function prependAgentFlags(commandText, matchPattern, flagSpecs) {
@@ -1010,6 +1069,10 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1010
1069
  if (sessionId) {
1011
1070
  flagSpecs.push({ flag: `-r ${sessionId}`, pattern: /(?:^|\s)-r(?:\s|$)/ });
1012
1071
  }
1072
+ const resumeAtUuid = options && typeof options.resumeAtUuid === 'string' && webRewind.UUID_PATTERN.test(options.resumeAtUuid) ? options.resumeAtUuid : '';
1073
+ if (sessionId && resumeAtUuid) {
1074
+ flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
1075
+ }
1013
1076
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
1014
1077
  if (model) {
1015
1078
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
@@ -2323,7 +2386,7 @@ function prepareCodexTraceEvent(payload) {
2323
2386
  });
2324
2387
  }
2325
2388
 
2326
- async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2389
+ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
2327
2390
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2328
2391
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
2329
2392
  const containerMap = listWebManyoyoContainers(ctx);
@@ -2377,9 +2440,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2377
2440
  }
2378
2441
  }
2379
2442
 
2443
+ // Codex 用 --image 传图;其余 Agent 在提示词末尾写路径,让它们用读文件的工具去看
2444
+ const imagesByFlag = agentMeta.agentProgram === 'codex';
2445
+ const promptWithImages = imagesByFlag ? prompt : webUploads.appendImageNote(prompt, images);
2380
2446
  const effectivePrompt = resumeSucceeded
2381
- ? prompt
2382
- : buildAgentPromptWithHistory(agentSession, prompt);
2447
+ ? promptWithImages
2448
+ : buildAgentPromptWithHistory(agentSession, promptWithImages);
2383
2449
  // 环境说明每次执行时现读配置:改了设置不用重建容器;运行配置名记在创建时的快照里(旧会话没有,用全局配置)
2384
2450
  const configSnapshot = readWebConfigSnapshot(state.webConfigPath);
2385
2451
  const runName = history.runtimeSnapshot && typeof history.runtimeSnapshot.run === 'string' ? history.runtimeSnapshot.run : '';
@@ -2387,6 +2453,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2387
2453
  sessionId: sessionIdForRun,
2388
2454
  model: agentSession.model || '',
2389
2455
  forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
2456
+ images: imagesByFlag ? images : [],
2457
+ resumeAtUuid: agentSession.rewindToUuid || '',
2390
2458
  instructions: buildAgentInstructions(
2391
2459
  configSnapshot.parseError ? {} : configSnapshot.parsed,
2392
2460
  runName,
@@ -2418,9 +2486,11 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2418
2486
  resumeAttempted: meta.resumeAttempted,
2419
2487
  resumeSucceeded: meta.resumeSucceeded,
2420
2488
  interrupted: result.interrupted === true,
2489
+ ...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
2421
2490
  ...(turnUsage ? { usage: turnUsage } : {})
2422
2491
  });
2423
2492
  const patch = {
2493
+ rewindToUuid: '',
2424
2494
  lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
2425
2495
  lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
2426
2496
  lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
@@ -2442,6 +2512,129 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2442
2512
  return replyMessage;
2443
2513
  }
2444
2514
 
2515
+ // 每轮前后各拍一次工作目录快照(影子 git,见 lib/web/snapshots.js);失败 / 太大不影响这一轮,只是这一轮不能回退文件
2516
+ // 在容器里跑一段 bash 脚本并取 stdout(脚本里的路径 / 提交号都已转义或校验)。
2517
+ // 用 `bash -c` 而不是 execCommandInWebContainer 的 `/bin/bash -lc`:不走登录 shell,更快;也让测试里按 /bin/bash 识别 Agent 运行的假 docker 不会把它误当成一轮对话
2518
+ function runWebContainerScript(ctx, containerName, script, timeoutMs = 60000) {
2519
+ return new Promise(resolve => {
2520
+ let child;
2521
+ try {
2522
+ child = spawnContainerExec(ctx, containerName, { withEnv: false, command: ['bash', '-c', script] }, { stdio: ['ignore', 'pipe', 'ignore'] });
2523
+ } catch (e) {
2524
+ resolve('');
2525
+ return;
2526
+ }
2527
+ let out = '';
2528
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
2529
+ child.stdout.setEncoding('utf8');
2530
+ child.stdout.on('data', chunk => { if (out.length < 4 * 1024 * 1024) out += chunk; });
2531
+ child.once('close', () => { clearTimeout(timer); resolve(out); });
2532
+ child.once('error', () => { clearTimeout(timer); resolve(out); });
2533
+ });
2534
+ }
2535
+
2536
+ async function takeWebSnapshot(ctx, state, sessionRef, label) {
2537
+ const workDir = resolveWebSessionWorkDir(state, sessionRef);
2538
+ if (!workDir || workDir === '/') {
2539
+ return { skipped: 'unavailable' };
2540
+ }
2541
+ try {
2542
+ return webSnapshots.parseSnapshotOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildSnapshotCommand(workDir, label), 15000));
2543
+ } catch (e) {
2544
+ return { skipped: 'unavailable' };
2545
+ }
2546
+ }
2547
+
2548
+ // 回退的目标必须是一条已经结束的 Agent 用户消息,且这个容器里没有任务在跑
2549
+ function findWebRewindTarget(state, sessionRef, messageId) {
2550
+ if (state.agentRuns.has(sessionRef.containerName)) {
2551
+ return { status: 409, error: '任务进行中,暂时不能回退' };
2552
+ }
2553
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2554
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
2555
+ const id = String(messageId || '');
2556
+ const message = agentSession && agentSession.messages.find(item => item && String(item.id || '') === id);
2557
+ if (!message || message.role !== 'user' || message.mode === 'command') {
2558
+ return { status: 404, error: '没有找到这条提示词' };
2559
+ }
2560
+ if (message.pending === true) {
2561
+ return { status: 409, error: '这一轮还在进行中' };
2562
+ }
2563
+ return { history, agentSession, message };
2564
+ }
2565
+
2566
+ async function rewindWebSession(ctx, state, sessionRef, messageId, scope) {
2567
+ const target = findWebRewindTarget(state, sessionRef, messageId);
2568
+ if (target.error) {
2569
+ return target;
2570
+ }
2571
+ // 回退期间占住这个容器的运行锁:恢复脚本最长跑 2 分钟,这段时间里不能让新的一轮开跑(文件会被还原、新消息会被删)
2572
+ const runState = reserveWebAgentRun(state, sessionRef);
2573
+ try {
2574
+ return await rewindWebSessionLocked(ctx, state, sessionRef, scope, target);
2575
+ } finally {
2576
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
2577
+ }
2578
+ }
2579
+
2580
+ async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
2581
+ const { history, agentSession, message } = target;
2582
+ let filesRestored = false;
2583
+ if (scope !== 'conversation') {
2584
+ if (!webSnapshots.isSnapId(message.snapBefore)) {
2585
+ return { status: 409, error: '这一轮没有文件快照,不能回退文件' };
2586
+ }
2587
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
2588
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestoreCommand(resolveWebSessionWorkDir(state, sessionRef), message.snapBefore), 120000);
2589
+ const restored = webSnapshots.parseRestoreOutput(out);
2590
+ if (!restored.ok) {
2591
+ // 先要给“现在”拍一张快照(让回退本身能撤销);有超过 20MB 的大文件或快照仓库超过 1GB 时拍不了,宁可不回退
2592
+ if (restored.reason === 'too-large') {
2593
+ return { status: 409, error: '工作目录里有超过 20MB 的大文件(或快照已超过 1GB),没法先给当前状态留快照,所以不能回退文件;只回退对话没有这个限制' };
2594
+ }
2595
+ return { status: 500, error: '恢复文件失败,工作目录没有改动' };
2596
+ }
2597
+ filesRestored = true;
2598
+ }
2599
+ let conversationMode = '';
2600
+ if (scope !== 'files') {
2601
+ const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
2602
+ history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
2603
+ )).agentProgram;
2604
+ let engineSessionId = agentSession.engineSessionId || '';
2605
+ let rewindToUuid = '';
2606
+ if (!engineSessionId) {
2607
+ conversationMode = 'precise';
2608
+ } else if (program === 'claude') {
2609
+ const command = webRewind.buildClaudeRecordsCommand(engineSessionId);
2610
+ const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
2611
+ const pick = webRewind.pickClaudeResumeAt(records, { text: message.content, timeMs: Date.parse(message.timestamp) });
2612
+ if (pick.found && pick.uuid) {
2613
+ rewindToUuid = pick.uuid;
2614
+ conversationMode = 'precise';
2615
+ } else if (pick.found) {
2616
+ // 回退到第一轮:Claude 那边什么都不需要记得,开新会话
2617
+ engineSessionId = '';
2618
+ conversationMode = 'precise';
2619
+ } else {
2620
+ engineSessionId = '';
2621
+ conversationMode = 'history';
2622
+ }
2623
+ } else {
2624
+ // 其它 Agent 没有精确回退:丢掉引擎会话,下一轮用剩下的聊天记录注入(不丢数据,只丢工具调用等细节)
2625
+ engineSessionId = '';
2626
+ conversationMode = 'history';
2627
+ }
2628
+ const removed = removeWebSessionMessagesFrom(state.webHistoryDir, sessionRef, message.id);
2629
+ if (!removed.length) {
2630
+ return { status: 409, error: '这条提示词已经不在了' };
2631
+ }
2632
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
2633
+ appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
2634
+ }
2635
+ return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
2636
+ }
2637
+
2445
2638
  function resolveWebSessionWorkDir(state, sessionRef) {
2446
2639
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2447
2640
  const applied = history.applied && typeof history.applied === 'object' ? history.applied : {};
@@ -2537,48 +2730,21 @@ function parseCookies(req) {
2537
2730
  return cookies;
2538
2731
  }
2539
2732
 
2540
- function pruneExpiredWebAuthSessions(state) {
2541
- const now = Date.now();
2542
- for (const [sessionId, session] of state.authSessions.entries()) {
2543
- if (!session || session.expiresAt <= now) {
2544
- state.authSessions.delete(sessionId);
2545
- }
2546
- }
2547
- }
2548
-
2549
2733
  function createWebAuthSession(state, username) {
2550
- pruneExpiredWebAuthSessions(state);
2551
- const sessionId = crypto.randomBytes(24).toString('hex');
2552
- state.authSessions.set(sessionId, {
2553
- username,
2554
- expiresAt: Date.now() + WEB_AUTH_TTL_SECONDS * 1000
2555
- });
2556
- return sessionId;
2734
+ return state.authStore.create(username);
2557
2735
  }
2558
2736
 
2559
2737
  function getWebAuthSession(state, req) {
2560
- pruneExpiredWebAuthSessions(state);
2561
- const cookies = parseCookies(req);
2562
- const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
2738
+ const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
2563
2739
  if (!sessionId) return null;
2564
-
2565
- const session = state.authSessions.get(sessionId);
2566
- if (!session) return null;
2567
- if (session.expiresAt <= Date.now()) {
2568
- state.authSessions.delete(sessionId);
2569
- return null;
2570
- }
2571
-
2572
- // Sliding session expiration
2573
- session.expiresAt = Date.now() + WEB_AUTH_TTL_SECONDS * 1000;
2574
- return { sessionId, username: session.username };
2740
+ const session = state.authStore.touch(sessionId);
2741
+ return session ? { sessionId, username: session.username } : null;
2575
2742
  }
2576
2743
 
2577
2744
  function clearWebAuthSession(state, req) {
2578
- const cookies = parseCookies(req);
2579
- const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
2745
+ const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
2580
2746
  if (sessionId) {
2581
- state.authSessions.delete(sessionId);
2747
+ state.authStore.remove(sessionId);
2582
2748
  }
2583
2749
  }
2584
2750
 
@@ -3132,6 +3298,33 @@ function buildStaticContainerRuntime(ctx, containerName) {
3132
3298
  };
3133
3299
  }
3134
3300
 
3301
+ function normalizeFirstConfig(value, sourceLabel) {
3302
+ if (value === undefined || value === null) return {};
3303
+ if (typeof value !== 'object' || Array.isArray(value)) {
3304
+ throw new Error(`${sourceLabel} 的 first 必须是对象(map),例如 {"shell":"init.sh"}`);
3305
+ }
3306
+ return value;
3307
+ }
3308
+
3309
+ // first.envFile 里已存在的文件内容 + first.env,后者优先(与 CLI 一致);返回 KEY=VALUE 行
3310
+ function buildFirstEnvLines(firstResolved) {
3311
+ const lines = [];
3312
+ (firstResolved.envFile || []).forEach(file => {
3313
+ const resolvedPath = expandHomeAliasPath(file);
3314
+ if (!path.isAbsolute(resolvedPath)) {
3315
+ throw new Error(`环境变量文件仅支持绝对路径: ${file}`);
3316
+ }
3317
+ if (!fs.existsSync(resolvedPath)) return;
3318
+ containerState.parseEnvFileContent(fs.readFileSync(resolvedPath, 'utf-8')).entries
3319
+ .forEach(entry => lines.push(`${entry.key}=${entry.value}`));
3320
+ });
3321
+ Object.entries(firstResolved.env || {}).forEach(([key, value]) => {
3322
+ const parsed = parseEnvEntry(`${key}=${value}`);
3323
+ lines.push(`${parsed.key}=${parsed.value}`);
3324
+ });
3325
+ return lines;
3326
+ }
3327
+
3135
3328
  function buildCreateRuntime(ctx, state, payload) {
3136
3329
  const body = toPlainObject(payload);
3137
3330
  const requestOptions = toPlainObject(body.createOptions);
@@ -3157,6 +3350,13 @@ function buildCreateRuntime(ctx, state, payload) {
3157
3350
  const requestVolumeList = hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : [];
3158
3351
  const requestPortList = hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : [];
3159
3352
 
3353
+ // first(首次命令):请求里带了就只用它(复制容器时来自快照,已合并过),否则全局 + runs.<name>
3354
+ const hasRequestFirst = hasOwn(requestOptions, 'first');
3355
+ const globalFirstConfig = hasRequestFirst ? {} : normalizeFirstConfig(config.first, '全局配置');
3356
+ const runFirstConfig = hasRequestFirst
3357
+ ? normalizeFirstConfig(requestOptions.first, 'createOptions')
3358
+ : normalizeFirstConfig(runConfig.first, '运行配置');
3359
+
3160
3360
  const resolvedBase = resolveRuntimeConfig({
3161
3361
  cliOptions: {
3162
3362
  hostPath: requestOptions.hostPath,
@@ -3171,8 +3371,8 @@ function buildCreateRuntime(ctx, state, payload) {
3171
3371
  },
3172
3372
  globalConfig: config,
3173
3373
  runConfig,
3174
- globalFirstConfig: {},
3175
- runFirstConfig: {},
3374
+ globalFirstConfig,
3375
+ runFirstConfig,
3176
3376
  defaults: {
3177
3377
  hostPath: ctx.hostPath,
3178
3378
  containerName: `my-${ctx.formatDate()}`,
@@ -3312,6 +3512,19 @@ function buildCreateRuntime(ctx, state, payload) {
3312
3512
  });
3313
3513
  }
3314
3514
 
3515
+ const firstResolved = resolvedBase.first;
3516
+ const firstCommand = String(firstResolved.exec.shell || '').trim()
3517
+ ? `${firstResolved.exec.prefix || ''}${firstResolved.exec.shell}${firstResolved.exec.suffix || ''}`
3518
+ : '';
3519
+ // 写进快照的 first 已经是合并后的结果,复制容器时当作请求里的 first 使用
3520
+ const firstSnapshot = {
3521
+ shellPrefix: pickFirstString(runFirstConfig.shellPrefix, globalFirstConfig.shellPrefix),
3522
+ shell: pickFirstString(runFirstConfig.shell, globalFirstConfig.shell),
3523
+ shellSuffix: pickFirstString(runFirstConfig.shellSuffix, globalFirstConfig.shellSuffix),
3524
+ env: firstResolved.env || {},
3525
+ envFile: firstResolved.envFile || []
3526
+ };
3527
+
3315
3528
  return {
3316
3529
  containerName,
3317
3530
  hostPath,
@@ -3322,6 +3535,7 @@ function buildCreateRuntime(ctx, state, payload) {
3322
3535
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
3323
3536
  containerEnvs,
3324
3537
  envFiles,
3538
+ first: firstCommand ? { command: firstCommand, envLines: buildFirstEnvLines(firstResolved) } : null,
3325
3539
  containerVolumes,
3326
3540
  containerPorts,
3327
3541
  autostart: manage.autostart,
@@ -3349,6 +3563,7 @@ function buildCreateRuntime(ctx, state, payload) {
3349
3563
  ports: resolvedBase.ports || [],
3350
3564
  autostart: manage.autostart,
3351
3565
  systemPrompt,
3566
+ first: firstSnapshot,
3352
3567
  // 复制容器时端口暴露(宿主机端口会冲突)与 peers(按 id 引用别的容器)不带过去
3353
3568
  network: { ...manage.network, expose: [], peers: { inbound: [] } }
3354
3569
  },
@@ -3434,6 +3649,7 @@ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
3434
3649
  ports: runtimeSnapshot.ports || [],
3435
3650
  autostart: runtimeSnapshot.autostart || '',
3436
3651
  systemPrompt: runtimeSnapshot.systemPrompt || undefined,
3652
+ first: runtimeSnapshot.first || undefined,
3437
3653
  network: runtimeSnapshot.network || undefined,
3438
3654
  // 快照来自服务端、原容器创建时已确认过,复制不必再确认
3439
3655
  confirmRisk: runtimeSnapshot.network ? true : undefined
@@ -3636,6 +3852,22 @@ async function isWebImagePresent(ctx, imageRef) {
3636
3852
  }
3637
3853
  }
3638
3854
 
3855
+ // `images --format` 的输出 → [{ name, tag, id, created, size }]:去掉悬空镜像,只留版本号合规(x.y.z-后缀)的,同名同版本去重
3856
+ function parseWebImageList(output) {
3857
+ const seen = new Set();
3858
+ const images = [];
3859
+ for (const line of String(output || '').split('\n')) {
3860
+ const [name, tag, id, created, size] = line.split('\t').map(part => (part || '').trim());
3861
+ if (!name || !tag || name === '<none>' || tag === '<none>') continue;
3862
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/:-]*$/.test(name) || !/^\d+\.\d+\.\d+-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tag)) continue;
3863
+ const key = `${name}:${tag}`;
3864
+ if (seen.has(key)) continue;
3865
+ seen.add(key);
3866
+ images.push({ name, tag, id: id || '', created: created || '', size: size || '' });
3867
+ }
3868
+ return images;
3869
+ }
3870
+
3639
3871
  function createImagePreparingError(message) {
3640
3872
  const error = new Error(message);
3641
3873
  error.statusCode = 409;
@@ -3729,7 +3961,7 @@ async function cleanupStaleSetupTestContainers(ctx) {
3729
3961
  }
3730
3962
 
3731
3963
  // Agent 一轮对话里,容器被过滤代理拦下的新域名(浏览器后台请求不算):只在 allowlist 容器上轮询
3732
- function createWebDeniedNotifier(ctx, sessionRef, emit) {
3964
+ function createWebDeniedNotifier(ctx, sessionRef, emit, modelOptions = {}) {
3733
3965
  try {
3734
3966
  const homeDir = ctx.homeDir || os.homedir();
3735
3967
  const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
@@ -3737,10 +3969,20 @@ function createWebDeniedNotifier(ctx, sessionRef, emit) {
3737
3969
  const deniedDir = egressPaths(homeDir).denied;
3738
3970
  const policyOf = () => normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined);
3739
3971
  if (!usesProxy(policyOf())) return null;
3972
+ // 模型域名:env 里的 URL 域名 + 该 Agent 没配 BASE_URL 时的官方域名;每次现读 env(用户可能刚改过)
3973
+ const isModelHost = host => {
3974
+ const envLines = [
3975
+ ...containerState.readEnv(homeDir, id).entries.map(entry => `${entry.key}=${entry.value}`),
3976
+ ...containerState.envFileEntries(homeDir, id).map(entry => `${entry.key}=${entry.value}`)
3977
+ ];
3978
+ return agentModelDomains(envLines, modelOptions.agentProgram).includes(String(host).toLowerCase());
3979
+ };
3740
3980
  return createDeniedNotifier({
3741
3981
  read: () => readDenied(deniedDir, id),
3742
3982
  isIgnored: host => isBrowserBackground(host) || domainRuleAllows(policyOf(), host),
3743
- emit
3983
+ emit,
3984
+ isModelHost,
3985
+ onModelDenied: modelOptions.onModelDenied
3744
3986
  });
3745
3987
  } catch (e) {
3746
3988
  return null;
@@ -3967,6 +4209,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3967
4209
  'system',
3968
4210
  `容器 ${runtime.containerName} 已创建并启动。`
3969
4211
  );
4212
+ if (runtime.first) {
4213
+ const firstResult = await runWebFirstCommand(ctx, runtime.containerName, runtime.first);
4214
+ appendWebSessionMessage(
4215
+ state.webHistoryDir,
4216
+ messageSessionRef || runtime.containerName,
4217
+ 'system',
4218
+ firstResult.exitCode === 0 ? '首次命令已执行' : formatWebFirstFailure(firstResult, runtime.first)
4219
+ );
4220
+ }
3970
4221
  return;
3971
4222
  }
3972
4223
 
@@ -3983,6 +4234,58 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3983
4234
  await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
3984
4235
  }
3985
4236
 
4237
+ const WEB_FIRST_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
4238
+ const WEB_FIRST_COMMAND_OUTPUT_CHARS = 500;
4239
+
4240
+ // 新建容器后执行一次 first(与 CLI 的 executeFirstCommand 同语义,经 buildExecArgs 带上 first.env);
4241
+ // 失败只返回结果,不删容器、不抛错
4242
+ function runWebFirstCommand(ctx, containerName, first) {
4243
+ return new Promise(resolve => {
4244
+ const tail = createTailBuffer(WEB_FIRST_COMMAND_OUTPUT_CHARS * 4);
4245
+ let settled = false;
4246
+ let timer = null;
4247
+ const finish = result => {
4248
+ if (settled) return;
4249
+ settled = true;
4250
+ if (timer) clearTimeout(timer);
4251
+ resolve({ ...result, output: tail.text() });
4252
+ };
4253
+ let child;
4254
+ try {
4255
+ child = spawnContainerExec(
4256
+ ctx,
4257
+ containerName,
4258
+ { extraEnv: first.envLines, command: ['/bin/bash', '-c', first.command] },
4259
+ { stdio: ['ignore', 'pipe', 'pipe'] }
4260
+ );
4261
+ } catch (e) {
4262
+ tail.push(e && e.message ? e.message : '');
4263
+ finish({ exitCode: 1 });
4264
+ return;
4265
+ }
4266
+ timer = setTimeout(() => {
4267
+ tail.push('\n[等了 10 分钟仍未结束,已停止等待(容器里的命令可能还在运行)]');
4268
+ child.kill('SIGKILL');
4269
+ finish({ exitCode: 124 });
4270
+ }, ctx.firstCommandTimeoutMs || WEB_FIRST_COMMAND_TIMEOUT_MS);
4271
+ for (const stream of [child.stdout, child.stderr]) {
4272
+ stream.setEncoding('utf8');
4273
+ stream.on('data', chunk => tail.push(chunk));
4274
+ }
4275
+ child.on('error', error => {
4276
+ tail.push(error && error.message ? error.message : '');
4277
+ finish({ exitCode: 1 });
4278
+ });
4279
+ child.on('close', (code, signal) => finish({ exitCode: typeof code === 'number' ? code : (signal ? 1 : 0) }));
4280
+ });
4281
+ }
4282
+
4283
+ function formatWebFirstFailure(result, first) {
4284
+ const secrets = first.envLines.map(line => line.slice(line.indexOf('=') + 1));
4285
+ const text = sanitizeServeLogText(redactSecrets(result.output, secrets)).trim().slice(-WEB_FIRST_COMMAND_OUTPUT_CHARS);
4286
+ return `首次命令失败(退出码 ${result.exitCode}):${text}`;
4287
+ }
4288
+
3986
4289
  async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
3987
4290
  const opts = options && typeof options === 'object' ? options : {};
3988
4291
  const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
@@ -4204,6 +4507,25 @@ try {
4204
4507
  `);
4205
4508
  }
4206
4509
 
4510
+ // 只判断路径是否存在(点击聊天里的路径时用);不存在不是错误,返回 exists:false
4511
+ function buildContainerPathExistsCommand(requestedPath) {
4512
+ return buildWebContainerNodeCommand(`
4513
+ // __MANYOYO_FS_EXISTS__
4514
+ const fs = require('fs');
4515
+
4516
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
4517
+
4518
+ let result = { exists: false };
4519
+ try {
4520
+ const stat = fs.statSync(requestedPath);
4521
+ result = { exists: true, type: stat.isDirectory() ? 'dir' : (stat.isFile() ? 'file' : 'other') };
4522
+ } catch (e) {
4523
+ result = { exists: false };
4524
+ }
4525
+ process.stdout.write(JSON.stringify(result));
4526
+ `);
4527
+ }
4528
+
4207
4529
  function buildContainerFileReadCommand(requestedPath, options = {}) {
4208
4530
  const opts = options && typeof options === 'object' ? options : {};
4209
4531
  const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes > 0
@@ -4930,7 +5252,7 @@ function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
4930
5252
  }
4931
5253
  ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
4932
5254
  };
4933
- runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
5255
+ runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse(), Array.isArray(item.images) ? item.images : [])
4934
5256
  .then(turn => {
4935
5257
  if (turn && turn.busy) putBack('busy');
4936
5258
  else if (turn && turn.error) putBack(turn.error);
@@ -4960,6 +5282,8 @@ function listLiveRunsInContainer(ctx, containerName) {
4960
5282
  // 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
4961
5283
  // 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
4962
5284
  async function resumeQueuedAgentTurns(ctx, state) {
5285
+ // 先等启动对账:否则会在还挂着旧 pending 的会话上再开新一轮
5286
+ try { await state.orphanReconcileReady; } catch (e) { /* 对账失败不影响排队恢复 */ }
4963
5287
  let containers = {};
4964
5288
  try {
4965
5289
  containers = listWebManyoyoContainers(ctx);
@@ -5073,10 +5397,135 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
5073
5397
  saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
5074
5398
  }
5075
5399
 
5400
+ // ---- serve 启动对账 ----
5401
+ // serve 重启会丢掉内存里的运行登记,历史里留着 pending=true 的消息,前端据此永远显示“进行中”。
5402
+ // 启动时记下当时就 pending 的那批消息 id(之后新发起的轮次不碰),再按容器里是否还有活着的运行分两种处理:
5403
+ // 没有 → 立即标成已中断;还有(docker 下容器里的 agent 会比 serve 活得久)→ 每 5 秒查一次,跑完再标。
5404
+ const WEB_ORPHAN_POLL_MS = 5000;
5405
+ const ORPHAN_NOTE_INTERRUPTED = {
5406
+ trace: '服务重启,本轮已中断',
5407
+ reply: '服务重启时这一轮被中断了。可以发送“继续”让它接着做。'
5408
+ };
5409
+ const ORPHAN_NOTE_FINISHED = {
5410
+ trace: '服务重启,本轮在后台跑完,结果未接收',
5411
+ reply: '服务重启后这一轮在后台跑完了,但结果没能接收;可以发送“继续”让它总结。'
5412
+ };
5413
+
5414
+ function collectStartupPending(ctx, state) {
5415
+ const entries = [];
5416
+ let names = [];
5417
+ try {
5418
+ names = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
5419
+ } catch (e) {
5420
+ ctx.logger.warn('orphan reconcile: list history failed', { message: e && e.message });
5421
+ return entries;
5422
+ }
5423
+ for (const containerName of names) {
5424
+ try {
5425
+ const history = loadWebSessionHistory(state.webHistoryDir, containerName);
5426
+ for (const [agentId, agentSession] of Object.entries(history.agents || {})) {
5427
+ const ids = (agentSession.messages || []).filter(message => message && message.pending === true).map(message => message.id);
5428
+ if (ids.length) entries.push({ containerName, agentId, ids: new Set(ids) });
5429
+ }
5430
+ } catch (e) {
5431
+ ctx.logger.warn('orphan reconcile: skip unreadable history', { container: containerName, message: e && e.message });
5432
+ }
5433
+ }
5434
+ return entries;
5435
+ }
5436
+
5437
+ // 只改启动时记下的那些消息;这一轮的执行过程末尾补一行,最终回复(没有流式半截回复就新建一条)写一句说明
5438
+ function finalizeOrphanEntry(ctx, state, entry, note) {
5439
+ const sessionRef = { containerName: entry.containerName, agentId: entry.agentId };
5440
+ const history = loadWebSessionHistory(state.webHistoryDir, entry.containerName);
5441
+ const agentSession = getWebAgentSession(history, entry.agentId, { includeArchived: true });
5442
+ if (!agentSession || !Array.isArray(agentSession.messages)) return;
5443
+ const running = state.agentRuns.get(entry.containerName);
5444
+ const newerRunInSession = Boolean(running && running.sessionRef && running.sessionRef.agentId === entry.agentId);
5445
+ let touched = false;
5446
+ let replied = false;
5447
+ for (const message of agentSession.messages) {
5448
+ if (!message || message.pending !== true || !entry.ids.has(message.id)) continue;
5449
+ message.pending = false;
5450
+ message.interrupted = true;
5451
+ touched = true;
5452
+ if (message.streamTrace) {
5453
+ message.content = `${message.content || ''}\n[任务] ${note.trace}`;
5454
+ } else if (message.streamingReply) {
5455
+ message.streamingReply = false;
5456
+ message.content = message.content ? `${message.content}\n\n${note.reply}` : note.reply;
5457
+ replied = true;
5458
+ }
5459
+ }
5460
+ if (!touched) return;
5461
+ const timestamp = new Date().toISOString();
5462
+ agentSession.updatedAt = timestamp;
5463
+ history.updatedAt = timestamp;
5464
+ saveWebSessionHistory(state.webHistoryDir, entry.containerName, history);
5465
+ // 这个会话已经有新的一轮在跑:不在末尾插入说明,免得夹进新一轮的消息中间
5466
+ if (!replied && !newerRunInSession) {
5467
+ appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', note.reply, { mode: 'agent', interrupted: true });
5468
+ }
5469
+ appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'process.interrupted', { reason: 'serve-restart' });
5470
+ }
5471
+
5472
+ function isWebContainerRunning(ctx, containerName) {
5473
+ try {
5474
+ return Boolean(ctx.containerExists(containerName)) && ctx.getContainerStatus(containerName) === 'running';
5475
+ } catch (e) {
5476
+ return false;
5477
+ }
5478
+ }
5479
+
5480
+ async function waitForRuntimeReady(ctx) {
5481
+ const deadline = Date.now() + 10 * 60 * 1000;
5482
+ while (ctx.runtimeState && ctx.runtimeState.status === 'starting' && Date.now() < deadline) {
5483
+ await new Promise(resolve => setTimeout(resolve, 1000));
5484
+ }
5485
+ }
5486
+
5487
+ async function reconcileOrphanTurns(ctx, state) {
5488
+ const entries = collectStartupPending(ctx, state);
5489
+ if (!entries.length) return;
5490
+ await waitForRuntimeReady(ctx);
5491
+ const byContainer = new Map();
5492
+ for (const entry of entries) {
5493
+ if (!byContainer.has(entry.containerName)) byContainer.set(entry.containerName, []);
5494
+ byContainer.get(entry.containerName).push(entry);
5495
+ }
5496
+ for (const [containerName, list] of byContainer) {
5497
+ const live = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
5498
+ if (!live.length) {
5499
+ for (const entry of list) {
5500
+ try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_INTERRUPTED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
5501
+ }
5502
+ continue;
5503
+ }
5504
+ let checking = false;
5505
+ const timer = setInterval(async () => {
5506
+ if (checking) return;
5507
+ checking = true;
5508
+ try {
5509
+ const stillLive = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
5510
+ if (stillLive.length) return;
5511
+ clearInterval(timer);
5512
+ state.orphanTimers.delete(timer);
5513
+ for (const entry of list) {
5514
+ try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_FINISHED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
5515
+ }
5516
+ } finally {
5517
+ checking = false;
5518
+ }
5519
+ }, ctx.orphanPollMs || WEB_ORPHAN_POLL_MS);
5520
+ if (typeof timer.unref === 'function') timer.unref();
5521
+ state.orphanTimers.add(timer);
5522
+ }
5523
+ }
5524
+
5076
5525
  // 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
5077
5526
  // 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
5078
5527
  // 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
5079
- async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
5528
+ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
5080
5529
  if (state.agentRuns.has(sessionRef.containerName)) {
5081
5530
  // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
5082
5531
  return { busy: true };
@@ -5090,6 +5539,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5090
5539
  userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
5091
5540
  mode: 'agent',
5092
5541
  pending: true,
5542
+ ...(images.length ? { images } : {}),
5093
5543
  ...(clientMessageId ? { clientMessageId: clientMessageId } : {})
5094
5544
  });
5095
5545
  traceMessage = appendWebAgentTraceMessage(
@@ -5109,7 +5559,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5109
5559
 
5110
5560
  let prepared = null;
5111
5561
  try {
5112
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
5562
+ prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
5113
5563
  } catch (e) {
5114
5564
  releaseWebAgentRun(state, sessionRef.containerName, runState);
5115
5565
  removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
@@ -5271,9 +5721,23 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5271
5721
 
5272
5722
  };
5273
5723
  // 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
5274
- const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
5724
+ // 被拦的是 Agent 自己的模型域名:继续等只会白等到超时,立即中止本轮(不暂停排队,不是用户点的停止)
5725
+ const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }), {
5726
+ agentProgram: agentMeta.agentProgram,
5727
+ onModelDenied: host => {
5728
+ if (runState.modelDenied) return;
5729
+ runState.modelDenied = host;
5730
+ handleStreamEvent({ type: 'network_denied', host, model: true });
5731
+ runState.stopping = true;
5732
+ if (runState.process) terminateWebAgentRun(ctx, runState);
5733
+ }
5734
+ });
5735
+ let snapBefore = { skipped: 'unavailable' };
5275
5736
  try {
5276
5737
  if (deniedNotifier) deniedNotifier.start();
5738
+ snapBefore = await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} before`);
5739
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id,
5740
+ snapBefore.sha ? { snapBefore: snapBefore.sha } : { snapSkipped: snapBefore.skipped });
5277
5741
  let result;
5278
5742
  try {
5279
5743
  result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
@@ -5291,14 +5755,21 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5291
5755
  exitCode: result.exitCode,
5292
5756
  interrupted: result.interrupted === true
5293
5757
  });
5294
- traceLines.push(result.interrupted === true
5295
- ? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
5296
- : '[任务] 已完成');
5758
+ const modelDeniedHost = runState.modelDenied || '';
5759
+ traceLines.push(modelDeniedHost
5760
+ ? '[任务] 已中止:模型地址被网络策略拦截'
5761
+ : (result.interrupted === true
5762
+ ? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
5763
+ : '[任务] 已完成'));
5764
+ const snapAfter = snapBefore.sha
5765
+ ? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
5766
+ : { skipped: snapBefore.skipped };
5297
5767
  patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5298
5768
  pending: false,
5299
5769
  contextMode,
5300
5770
  resumeAttempted,
5301
- resumeSucceeded
5771
+ resumeSucceeded,
5772
+ ...(snapAfter.sha ? { snapAfter: snapAfter.sha } : {})
5302
5773
  });
5303
5774
  patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5304
5775
  content: traceLines.join('\n'),
@@ -5312,26 +5783,30 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5312
5783
  if (streamingReplyMessageId) {
5313
5784
  removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
5314
5785
  }
5786
+ const finalResult = modelDeniedHost
5787
+ ? { ...result, interrupted: true, output: `已中止:模型地址 ${modelDeniedHost} 被网络策略拦截。`, networkDenied: { host: modelDeniedHost, model: true } }
5788
+ : result;
5315
5789
  const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
5316
5790
  contextMode,
5317
5791
  resumeAttempted,
5318
5792
  resumeSucceeded,
5319
5793
  resumeError,
5320
5794
  engineSessionId
5321
- }, result);
5795
+ }, finalResult);
5322
5796
  void refreshWebAgentTitle(ctx, state, sessionRef, agentMeta.agentProgram);
5323
5797
  emitStreamEvent({
5324
5798
  type: 'result',
5325
- exitCode: result.exitCode,
5326
- output: result.output,
5799
+ exitCode: finalResult.exitCode,
5800
+ output: finalResult.output,
5801
+ ...(finalResult.networkDenied ? { networkDenied: finalResult.networkDenied } : {}),
5327
5802
  // 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
5328
5803
  // 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
5329
5804
  replyMessageId: replyMessage && replyMessage.id,
5330
5805
  contextMode,
5331
5806
  resumeAttempted,
5332
5807
  resumeSucceeded,
5333
- interrupted: result.interrupted === true
5334
- }, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
5808
+ interrupted: finalResult.interrupted === true
5809
+ }, finalResult.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
5335
5810
  } catch (e) {
5336
5811
  ctx.logger.error('agent turn failed', {
5337
5812
  session: turnSessionKey,
@@ -5339,11 +5814,15 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5339
5814
  message: e && e.message ? e.message : 'Agent 执行失败'
5340
5815
  });
5341
5816
  traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
5817
+ const failedSnapAfter = snapBefore.sha
5818
+ ? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
5819
+ : { skipped: snapBefore.skipped };
5342
5820
  patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5343
5821
  pending: false,
5344
5822
  contextMode,
5345
5823
  resumeAttempted,
5346
- resumeSucceeded
5824
+ resumeSucceeded,
5825
+ ...(failedSnapAfter.sha ? { snapAfter: failedSnapAfter.sha } : {})
5347
5826
  });
5348
5827
  patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5349
5828
  content: traceLines.join('\n'),
@@ -5365,7 +5844,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5365
5844
  clearInterval(heartbeatTimer);
5366
5845
  clearTimeout(runState.stepTimer);
5367
5846
  // 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
5368
- const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
5847
+ const hasNext = runState.userStopped !== true && !runState.modelDenied && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
5369
5848
  if (hasNext && !res.writableEnded && !res.destroyed) {
5370
5849
  // 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
5371
5850
  sendNdjson(res, { type: 'queue_next' });
@@ -5416,6 +5895,15 @@ async function inspectSetupImage(ctx, state) {
5416
5895
  }
5417
5896
  }
5418
5897
 
5898
+ // 镜像里装了哪些 Agent CLI;镜像没就绪或探测失败返回 null(未知,向导照常可选)
5899
+ async function probeSetupImageAgents(ctx, state) {
5900
+ if (!ctx.dockerExecAsync) return null;
5901
+ const image = await inspectSetupImage(ctx, state);
5902
+ if (image.status !== 'ready') return null;
5903
+ if (!state.imageAgentProbe) state.imageAgentProbe = createImageAgentProbe(ctx.dockerExecAsync);
5904
+ return state.imageAgentProbe(image.name);
5905
+ }
5906
+
5419
5907
  // 在一次性容器里用所选 Agent 发一次最小请求;容器无论成败都会删除。
5420
5908
  async function runSetupConnectionTest(ctx, state, agent, envMap) {
5421
5909
  const secrets = Object.values(envMap);
@@ -5429,7 +5917,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
5429
5917
  if (!template) {
5430
5918
  return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
5431
5919
  }
5432
- const agentCommand = renderAgentPromptCommand(template, SETUP_TEST_PROMPT);
5920
+ const agentCommand = withCodexBaseUrl(renderAgentPromptCommand(template, SETUP_TEST_PROMPT));
5433
5921
  const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
5434
5922
 
5435
5923
  const workpath = getSetupWorkpath(state);
@@ -6541,11 +7029,35 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6541
7029
  sendJson(res, 200, await buildSetupStatus(ctx, state));
6542
7030
  }
6543
7031
  },
7032
+ {
7033
+ // 本机已有的镜像(系统设置「基础」里选镜像名 / 版本用):只列版本号符合 x.y.z-后缀 的,按镜像名分组
7034
+ method: 'GET',
7035
+ match: currentPath => currentPath === '/api/images' ? [] : null,
7036
+ handler: async () => {
7037
+ const defaults = { imageName: ctx.imageName || '', imageVersion: ctx.imageVersion || '' };
7038
+ if (!ctx.dockerExecAsync || !ctx.runtimeState || ctx.runtimeState.status !== 'ready') {
7039
+ sendJson(res, 200, { defaults, images: [] });
7040
+ return;
7041
+ }
7042
+ let output = '';
7043
+ try {
7044
+ output = String(await ctx.dockerExecAsync(['images', '-a', '--format', '{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}'], { timeout: 15000 }) || '');
7045
+ } catch (e) {
7046
+ output = '';
7047
+ }
7048
+ sendJson(res, 200, { defaults, images: parseWebImageList(output) });
7049
+ }
7050
+ },
6544
7051
  {
6545
7052
  method: 'GET',
6546
7053
  match: currentPath => currentPath === '/api/setup/agents' ? [] : null,
6547
7054
  handler: async () => {
6548
- sendJson(res, 200, { agents: listSetupAgents(), mirrorPresets: listMirrorPresets() });
7055
+ const installed = await probeSetupImageAgents(ctx, state);
7056
+ const agents = listSetupAgents().map(agent => ({
7057
+ ...agent,
7058
+ available: installed ? installed.includes(agent.id) : null
7059
+ }));
7060
+ sendJson(res, 200, { agents, mirrorPresets: listMirrorPresets() });
6549
7061
  }
6550
7062
  },
6551
7063
  {
@@ -7100,6 +7612,69 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7100
7612
  sendJson(res, 200, payload);
7101
7613
  }
7102
7614
  },
7615
+ {
7616
+ // 聊天里的图片:原始请求体,类型只收 png/jpeg/gif/webp,单张 10MB
7617
+ method: 'POST',
7618
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/uploads$/),
7619
+ handler: async match => {
7620
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7621
+ if (!sessionRef) {
7622
+ return;
7623
+ }
7624
+ const ext = webUploads.extForContentType(req.headers['content-type']);
7625
+ if (!ext) {
7626
+ sendJson(res, 415, { error: '只支持 png、jpeg、gif、webp 图片' });
7627
+ return;
7628
+ }
7629
+ const declared = Number(req.headers['content-length']);
7630
+ if (Number.isFinite(declared) && declared > webUploads.MAX_IMAGE_BYTES) {
7631
+ sendJson(res, 413, { error: '图片不能超过 10MB' });
7632
+ return;
7633
+ }
7634
+ if (!ctx.containerExists(sessionRef.containerName)) {
7635
+ sendJson(res, 404, { error: `容器不存在: ${sessionRef.containerName}` });
7636
+ return;
7637
+ }
7638
+ const homeDir = ctx.homeDir || os.homedir();
7639
+ const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
7640
+ if (!id || !containerState.stateExists(homeDir, id)) {
7641
+ sendJson(res, 409, { error: '这个容器太旧,不支持发图片,重建后可用' });
7642
+ return;
7643
+ }
7644
+ const saved = await webUploads.saveUpload(req, path.join(containerState.paths(homeDir, id).box, 'uploads'), ext);
7645
+ if (!saved.ok) {
7646
+ sendJson(res, saved.status, { error: saved.error });
7647
+ return;
7648
+ }
7649
+ sendJson(res, 200, { path: `${webUploads.CONTAINER_UPLOAD_DIR}/${saved.name}` });
7650
+ }
7651
+ },
7652
+ {
7653
+ method: 'GET',
7654
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/stat$/),
7655
+ handler: async match => {
7656
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7657
+ if (!sessionRef) {
7658
+ return;
7659
+ }
7660
+ const requestUrl = new URL(req.url || '/api/sessions/x/fs/stat', 'http://localhost');
7661
+ const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
7662
+ // 点一下文字不该拉起容器:没在运行就当作不存在
7663
+ if (!targetPath || !ctx.containerExists(sessionRef.containerName) || ctx.getContainerStatus(sessionRef.containerName) !== 'running') {
7664
+ sendJson(res, 200, { exists: false });
7665
+ return;
7666
+ }
7667
+ let payload = null;
7668
+ try {
7669
+ payload = await execJsonCommandInWebContainer(ctx, sessionRef.containerName, buildContainerPathExistsCommand(targetPath));
7670
+ } catch (e) {
7671
+ payload = null;
7672
+ }
7673
+ sendJson(res, 200, payload && payload.exists === true
7674
+ ? { exists: true, type: payload.type }
7675
+ : { exists: false });
7676
+ }
7677
+ },
7103
7678
  {
7104
7679
  method: 'GET',
7105
7680
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/read$/),
@@ -7320,6 +7895,130 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7320
7895
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
7321
7896
  }
7322
7897
  },
7898
+ {
7899
+ // 按轮看改动:比较某一轮前后两个快照(提交号必须是 40 位小写十六进制)
7900
+ method: 'GET',
7901
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/changes$/),
7902
+ handler: async match => {
7903
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7904
+ if (!sessionRef) {
7905
+ return;
7906
+ }
7907
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
7908
+ const from = String(requestUrl.searchParams.get('from') || '');
7909
+ const to = String(requestUrl.searchParams.get('to') || '');
7910
+ if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
7911
+ sendJson(res, 400, { error: '快照号不合法' });
7912
+ return;
7913
+ }
7914
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
7915
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(resolveWebSessionWorkDir(state, sessionRef), from, to), 30000);
7916
+ sendJson(res, 200, webSnapshots.parseRoundChangesOutput(out));
7917
+ }
7918
+ },
7919
+ {
7920
+ method: 'GET',
7921
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/diff$/),
7922
+ handler: async match => {
7923
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7924
+ if (!sessionRef) {
7925
+ return;
7926
+ }
7927
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
7928
+ const from = String(requestUrl.searchParams.get('from') || '');
7929
+ const to = String(requestUrl.searchParams.get('to') || '');
7930
+ const filePath = String(requestUrl.searchParams.get('path') || '');
7931
+ if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
7932
+ sendJson(res, 400, { error: '快照号不合法' });
7933
+ return;
7934
+ }
7935
+ if (!gitChanges.isAcceptableDiffPath(filePath)) {
7936
+ sendJson(res, 400, { error: '文件路径不合法' });
7937
+ return;
7938
+ }
7939
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
7940
+ const workDir = resolveWebSessionWorkDir(state, sessionRef);
7941
+ // 只给这两个快照之间出现过的文件看 diff:路径由服务端重新取一次核对,不信任请求
7942
+ const listed = webSnapshots.parseRoundChangesOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(workDir, from, to), 30000));
7943
+ if (!listed.files.some(file => file.path === filePath)) {
7944
+ sendJson(res, 404, { error: '该文件不在这一轮的改动里' });
7945
+ return;
7946
+ }
7947
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundDiffCommand(workDir, from, to, filePath), 30000);
7948
+ sendJson(res, 200, { path: filePath, ...webSnapshots.parseRoundDiffOutput(out) });
7949
+ }
7950
+ },
7951
+ {
7952
+ // 回退前预览:会改 / 删 / 恢复哪些文件(会先拍一张“现在”的快照,不改任何文件)
7953
+ method: 'POST',
7954
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind\/preview$/),
7955
+ handler: async match => {
7956
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7957
+ if (!sessionRef) {
7958
+ return;
7959
+ }
7960
+ const payload = await readJsonBody(req);
7961
+ const target = findWebRewindTarget(state, sessionRef, payload.messageId);
7962
+ if (target.error) {
7963
+ sendJson(res, target.status, { error: target.error });
7964
+ return;
7965
+ }
7966
+ const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
7967
+ target.history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
7968
+ )).agentProgram;
7969
+ const result = { agentProgram: program, precise: program === 'claude', files: null };
7970
+ if (webSnapshots.isSnapId(target.message.snapBefore)) {
7971
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestorePreviewCommand(resolveWebSessionWorkDir(state, sessionRef), target.message.snapBefore), 30000);
7972
+ const preview = webSnapshots.parseRestorePreviewOutput(out);
7973
+ result.files = preview.ok ? preview : { ok: false, reason: preview.reason };
7974
+ }
7975
+ sendJson(res, 200, result);
7976
+ }
7977
+ },
7978
+ {
7979
+ // 回退:scope = both(对话和文件)/ conversation(只对话)/ files(只文件)
7980
+ method: 'POST',
7981
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind$/),
7982
+ handler: async match => {
7983
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7984
+ if (!sessionRef) {
7985
+ return;
7986
+ }
7987
+ const payload = await readJsonBody(req);
7988
+ const scope = String(payload.scope || '');
7989
+ if (!['both', 'conversation', 'files'].includes(scope)) {
7990
+ sendJson(res, 400, { error: 'scope 只能是 both、conversation 或 files' });
7991
+ return;
7992
+ }
7993
+ const outcome = await rewindWebSession(ctx, state, sessionRef, payload.messageId, scope);
7994
+ if (outcome.error) {
7995
+ sendJson(res, outcome.status, { error: outcome.error });
7996
+ return;
7997
+ }
7998
+ sendJson(res, 200, outcome);
7999
+ }
8000
+ },
8001
+ {
8002
+ method: 'POST',
8003
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/hide$/),
8004
+ handler: async match => {
8005
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8006
+ if (!sessionRef) {
8007
+ return;
8008
+ }
8009
+ const payload = await readJsonBody(req);
8010
+ if (state.agentRuns.has(sessionRef.containerName)) {
8011
+ sendJson(res, 409, { error: '任务进行中,暂时不能隐藏' });
8012
+ return;
8013
+ }
8014
+ const changed = setWebSessionTurnHidden(state.webHistoryDir, sessionRef, String(payload.messageId || ''), payload.hidden !== false);
8015
+ if (!changed) {
8016
+ sendJson(res, 404, { error: '没有找到这一组' });
8017
+ return;
8018
+ }
8019
+ sendJson(res, 200, { ok: true, count: changed });
8020
+ }
8021
+ },
7323
8022
  {
7324
8023
  // “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
7325
8024
  method: 'GET',
@@ -7642,6 +8341,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7642
8341
  }
7643
8342
 
7644
8343
  const payload = await readJsonBody(req);
8344
+ let streamImages;
8345
+ try {
8346
+ streamImages = webUploads.parseImages(payload.images);
8347
+ } catch (e) {
8348
+ sendJson(res, 400, { error: e.message });
8349
+ return;
8350
+ }
7645
8351
  const prompt = (payload.prompt || '').trim();
7646
8352
  if (!prompt) {
7647
8353
  sendJson(res, 400, { error: 'prompt 不能为空' });
@@ -7654,7 +8360,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7654
8360
  if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
7655
8361
  return;
7656
8362
  }
7657
- const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
8363
+ const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages);
7658
8364
  if (turn.busy) {
7659
8365
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
7660
8366
  } else if (turn.error) {
@@ -7671,6 +8377,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7671
8377
  return;
7672
8378
  }
7673
8379
  const payload = await readJsonBody(req);
8380
+ let queueImages;
8381
+ try {
8382
+ queueImages = webUploads.parseImages(payload.images);
8383
+ } catch (e) {
8384
+ sendJson(res, 400, { error: e.message });
8385
+ return;
8386
+ }
7674
8387
  const text = (payload.prompt || '').trim();
7675
8388
  if (!text) {
7676
8389
  sendJson(res, 400, { error: 'prompt 不能为空' });
@@ -7696,7 +8409,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7696
8409
  // 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
7697
8410
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7698
8411
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
7699
- const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
8412
+ const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when, images: queueImages });
7700
8413
  if (!added.ok) {
7701
8414
  sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
7702
8415
  return;
@@ -7975,6 +8688,7 @@ async function startWebServer(options) {
7975
8688
  ? options.agentRawOutputMaxChars
7976
8689
  : AGENT_RAW_OUTPUT_MAX_CHARS,
7977
8690
  // 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
8691
+ orphanPollMs: Number.isFinite(options.orphanPollMs) && options.orphanPollMs > 0 ? options.orphanPollMs : WEB_ORPHAN_POLL_MS,
7978
8692
  agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
7979
8693
  ? options.agentStepFallbackMs
7980
8694
  : agentQueue.STEP_FALLBACK_MS,
@@ -7993,7 +8707,15 @@ async function startWebServer(options) {
7993
8707
  const state = {
7994
8708
  webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
7995
8709
  webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
7996
- authSessions: new Map(),
8710
+ orphanTimers: new Set(),
8711
+ authStore: createAuthStore({
8712
+ file: options.authSessionsFile || '',
8713
+ user: ctx.authUser,
8714
+ pass: ctx.authPass,
8715
+ ttlMs: WEB_AUTH_TTL_SECONDS * 1000,
8716
+ now: Date.now,
8717
+ warn: message => ctx.logger.warn(message)
8718
+ }),
7997
8719
  terminalSessions: new Map(),
7998
8720
  agentRuns: new Map(),
7999
8721
  // imageRef -> { status: 'pulling'|'failed', message, promise }
@@ -8206,6 +8928,7 @@ async function startWebServer(options) {
8206
8928
  });
8207
8929
 
8208
8930
  startStaleSetupCleanup(ctx);
8931
+ state.orphanReconcileReady = reconcileOrphanTurns(ctx, state).catch(e => ctx.logger.warn('orphan reconcile failed', { message: e && e.message }));
8209
8932
  startContainerNetworkWatch(ctx, state);
8210
8933
  if (ctx.autoPullImage) {
8211
8934
  startWebImagePrepare(ctx, state);
@@ -8219,6 +8942,9 @@ async function startWebServer(options) {
8219
8942
  close: () => new Promise(resolve => {
8220
8943
  ctx.logger.info('web server closing');
8221
8944
  flushWebSessionHistories();
8945
+ state.authStore.close();
8946
+ state.orphanTimers.forEach(timer => clearInterval(timer));
8947
+ state.orphanTimers.clear();
8222
8948
  if (state.networkWatch) {
8223
8949
  state.networkWatch.stop();
8224
8950
  state.networkWatch = null;