@xcanwin/manyoyo 8.6.11 → 8.7.0

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');
@@ -34,10 +34,17 @@ const { estimateContainerCapacity } = require('../capacity');
34
34
  const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
35
35
  const { findValueRangeByPath, applyTextReplacements, upsertValueByPath } = require('../json5-text-edit');
36
36
  const { resolveRuntimeConfig } = require('../runtime-resolver');
37
+ const { resolveQuickChatPath, expandTemplate } = require('../name-template');
37
38
  const { AGENT_ENV_SCHEMA } = require('../init-config');
38
39
  const { ensureImagePresent, pullImageProcess } = require('../image-pull');
39
40
  const { waitForImport } = require('../offline-import');
40
41
  const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw, buildBooleanConfigRaw } = require('../setup-config');
42
+ const { createImageAgentProbe } = require('../image-agents');
43
+ const { createAuthStore } = require('./auth-store');
44
+ const webUploads = require('./uploads');
45
+ const webStorage = require('./storage');
46
+ const webSnapshots = require('./snapshots');
47
+ const webRewind = require('./rewind');
41
48
  const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
42
49
  const { applyDoctorFixes } = require('../doctor');
43
50
  const { writeConfigFileSecure } = require('../secure-file');
@@ -52,7 +59,7 @@ const {
52
59
  resolveAgentPromptCommandTemplate,
53
60
  buildAgentResumeCommand
54
61
  } = require('../agent-resume');
55
- const { resolveYoloCommand: resolveYoloCommandFromAdapters } = require('../agent-adapters');
62
+ const { resolveYoloCommand: resolveYoloCommandFromAdapters, withCodexBaseUrl } = require('../agent-adapters');
56
63
  const {
57
64
  createControlEvent,
58
65
  validateControlEvent,
@@ -229,7 +236,9 @@ function createEmptyWebAgentSession(agentId, agentName) {
229
236
  title: '',
230
237
  firstPrompt: '',
231
238
  pinned: false,
232
- slashCommands: null
239
+ slashCommands: null,
240
+ // 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
241
+ rewindToUuid: ''
233
242
  };
234
243
  }
235
244
 
@@ -287,7 +296,8 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
287
296
  title: sessionMeta.normalizeTitle(source.title),
288
297
  firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
289
298
  pinned: source.pinned === true,
290
- slashCommands: normalizeStoredSlashCommands(source.slashCommands)
299
+ slashCommands: normalizeStoredSlashCommands(source.slashCommands),
300
+ rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
291
301
  };
292
302
  }
293
303
 
@@ -730,6 +740,53 @@ function removeWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messa
730
740
  return removed;
731
741
  }
732
742
 
743
+ // 删掉某条消息及之后的所有消息(回退对话)。改动的是稳定前缀,必须换新 epoch,增量同步据此让前端整页重置
744
+ function removeWebSessionMessagesFrom(webHistoryDir, sessionRef, messageId) {
745
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
746
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
747
+ if (!agentSession) {
748
+ return [];
749
+ }
750
+ const index = agentSession.messages.findIndex(item => item && String(item.id || '') === String(messageId));
751
+ if (index === -1) {
752
+ return [];
753
+ }
754
+ const removed = agentSession.messages.splice(index);
755
+ agentSession.epoch = messagesView.createEpoch();
756
+ const timestamp = new Date().toISOString();
757
+ agentSession.updatedAt = timestamp;
758
+ history.updatedAt = timestamp;
759
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
760
+ return removed;
761
+ }
762
+
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
+
733
790
  function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
734
791
  const history = loadWebSessionHistory(webHistoryDir, containerName);
735
792
  history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
@@ -960,20 +1017,24 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
960
1017
  const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
961
1018
  // 环境说明:模板里已经写了 developer_instructions 就以用户的为准
962
1019
  const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
1020
+ // 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
1021
+ const images = options && Array.isArray(options.images) ? options.images : [];
1022
+ const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
963
1023
  const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
964
1024
  ? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
965
1025
  : '';
966
1026
  if (sessionId) {
967
1027
  const promptIndex = suffix.indexOf('{prompt}');
968
1028
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
969
- codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${resumeSuffix}`;
1029
+ codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
970
1030
  } else {
971
- codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${suffix}`;
1031
+ codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
972
1032
  }
973
1033
  }
974
- return codexTemplate === templateText
1034
+ // 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
1035
+ return withCodexBaseUrl(codexTemplate === templateText
975
1036
  ? renderAgentPromptCommand(templateText, prompt)
976
- : renderAgentPromptCommand(codexTemplate, prompt);
1037
+ : renderAgentPromptCommand(codexTemplate, prompt));
977
1038
  }
978
1039
 
979
1040
  function prependAgentFlags(commandText, matchPattern, flagSpecs) {
@@ -1010,6 +1071,10 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
1010
1071
  if (sessionId) {
1011
1072
  flagSpecs.push({ flag: `-r ${sessionId}`, pattern: /(?:^|\s)-r(?:\s|$)/ });
1012
1073
  }
1074
+ const resumeAtUuid = options && typeof options.resumeAtUuid === 'string' && webRewind.UUID_PATTERN.test(options.resumeAtUuid) ? options.resumeAtUuid : '';
1075
+ if (sessionId && resumeAtUuid) {
1076
+ flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
1077
+ }
1013
1078
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
1014
1079
  if (model) {
1015
1080
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
@@ -2323,7 +2388,7 @@ function prepareCodexTraceEvent(payload) {
2323
2388
  });
2324
2389
  }
2325
2390
 
2326
- async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2391
+ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
2327
2392
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2328
2393
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
2329
2394
  const containerMap = listWebManyoyoContainers(ctx);
@@ -2377,9 +2442,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2377
2442
  }
2378
2443
  }
2379
2444
 
2445
+ // Codex 用 --image 传图;其余 Agent 在提示词末尾写路径,让它们用读文件的工具去看
2446
+ const imagesByFlag = agentMeta.agentProgram === 'codex';
2447
+ const promptWithImages = imagesByFlag ? prompt : webUploads.appendImageNote(prompt, images);
2380
2448
  const effectivePrompt = resumeSucceeded
2381
- ? prompt
2382
- : buildAgentPromptWithHistory(agentSession, prompt);
2449
+ ? promptWithImages
2450
+ : buildAgentPromptWithHistory(agentSession, promptWithImages);
2383
2451
  // 环境说明每次执行时现读配置:改了设置不用重建容器;运行配置名记在创建时的快照里(旧会话没有,用全局配置)
2384
2452
  const configSnapshot = readWebConfigSnapshot(state.webConfigPath);
2385
2453
  const runName = history.runtimeSnapshot && typeof history.runtimeSnapshot.run === 'string' ? history.runtimeSnapshot.run : '';
@@ -2387,6 +2455,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2387
2455
  sessionId: sessionIdForRun,
2388
2456
  model: agentSession.model || '',
2389
2457
  forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
2458
+ images: imagesByFlag ? images : [],
2459
+ resumeAtUuid: agentSession.rewindToUuid || '',
2390
2460
  instructions: buildAgentInstructions(
2391
2461
  configSnapshot.parseError ? {} : configSnapshot.parsed,
2392
2462
  runName,
@@ -2418,9 +2488,11 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2418
2488
  resumeAttempted: meta.resumeAttempted,
2419
2489
  resumeSucceeded: meta.resumeSucceeded,
2420
2490
  interrupted: result.interrupted === true,
2491
+ ...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
2421
2492
  ...(turnUsage ? { usage: turnUsage } : {})
2422
2493
  });
2423
2494
  const patch = {
2495
+ rewindToUuid: '',
2424
2496
  lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
2425
2497
  lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
2426
2498
  lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
@@ -2442,6 +2514,129 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2442
2514
  return replyMessage;
2443
2515
  }
2444
2516
 
2517
+ // 每轮前后各拍一次工作目录快照(影子 git,见 lib/web/snapshots.js);失败 / 太大不影响这一轮,只是这一轮不能回退文件
2518
+ // 在容器里跑一段 bash 脚本并取 stdout(脚本里的路径 / 提交号都已转义或校验)。
2519
+ // 用 `bash -c` 而不是 execCommandInWebContainer 的 `/bin/bash -lc`:不走登录 shell,更快;也让测试里按 /bin/bash 识别 Agent 运行的假 docker 不会把它误当成一轮对话
2520
+ function runWebContainerScript(ctx, containerName, script, timeoutMs = 60000) {
2521
+ return new Promise(resolve => {
2522
+ let child;
2523
+ try {
2524
+ child = spawnContainerExec(ctx, containerName, { withEnv: false, command: ['bash', '-c', script] }, { stdio: ['ignore', 'pipe', 'ignore'] });
2525
+ } catch (e) {
2526
+ resolve('');
2527
+ return;
2528
+ }
2529
+ let out = '';
2530
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
2531
+ child.stdout.setEncoding('utf8');
2532
+ child.stdout.on('data', chunk => { if (out.length < 4 * 1024 * 1024) out += chunk; });
2533
+ child.once('close', () => { clearTimeout(timer); resolve(out); });
2534
+ child.once('error', () => { clearTimeout(timer); resolve(out); });
2535
+ });
2536
+ }
2537
+
2538
+ async function takeWebSnapshot(ctx, state, sessionRef, label) {
2539
+ const workDir = resolveWebSessionWorkDir(state, sessionRef);
2540
+ if (!workDir || workDir === '/') {
2541
+ return { skipped: 'unavailable' };
2542
+ }
2543
+ try {
2544
+ return webSnapshots.parseSnapshotOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildSnapshotCommand(workDir, label), 15000));
2545
+ } catch (e) {
2546
+ return { skipped: 'unavailable' };
2547
+ }
2548
+ }
2549
+
2550
+ // 回退的目标必须是一条已经结束的 Agent 用户消息,且这个容器里没有任务在跑
2551
+ function findWebRewindTarget(state, sessionRef, messageId) {
2552
+ if (state.agentRuns.has(sessionRef.containerName)) {
2553
+ return { status: 409, error: '任务进行中,暂时不能回退' };
2554
+ }
2555
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2556
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
2557
+ const id = String(messageId || '');
2558
+ const message = agentSession && agentSession.messages.find(item => item && String(item.id || '') === id);
2559
+ if (!message || message.role !== 'user' || message.mode === 'command') {
2560
+ return { status: 404, error: '没有找到这条提示词' };
2561
+ }
2562
+ if (message.pending === true) {
2563
+ return { status: 409, error: '这一轮还在进行中' };
2564
+ }
2565
+ return { history, agentSession, message };
2566
+ }
2567
+
2568
+ async function rewindWebSession(ctx, state, sessionRef, messageId, scope) {
2569
+ const target = findWebRewindTarget(state, sessionRef, messageId);
2570
+ if (target.error) {
2571
+ return target;
2572
+ }
2573
+ // 回退期间占住这个容器的运行锁:恢复脚本最长跑 2 分钟,这段时间里不能让新的一轮开跑(文件会被还原、新消息会被删)
2574
+ const runState = reserveWebAgentRun(state, sessionRef);
2575
+ try {
2576
+ return await rewindWebSessionLocked(ctx, state, sessionRef, scope, target);
2577
+ } finally {
2578
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
2579
+ }
2580
+ }
2581
+
2582
+ async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
2583
+ const { history, agentSession, message } = target;
2584
+ let filesRestored = false;
2585
+ if (scope !== 'conversation') {
2586
+ if (!webSnapshots.isSnapId(message.snapBefore)) {
2587
+ return { status: 409, error: '这一轮没有文件快照,不能回退文件' };
2588
+ }
2589
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
2590
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestoreCommand(resolveWebSessionWorkDir(state, sessionRef), message.snapBefore), 120000);
2591
+ const restored = webSnapshots.parseRestoreOutput(out);
2592
+ if (!restored.ok) {
2593
+ // 先要给“现在”拍一张快照(让回退本身能撤销);有超过 20MB 的大文件或快照仓库超过 1GB 时拍不了,宁可不回退
2594
+ if (restored.reason === 'too-large') {
2595
+ return { status: 409, error: '工作目录里有超过 20MB 的大文件(或快照已超过 1GB),没法先给当前状态留快照,所以不能回退文件;只回退对话没有这个限制' };
2596
+ }
2597
+ return { status: 500, error: '恢复文件失败,工作目录没有改动' };
2598
+ }
2599
+ filesRestored = true;
2600
+ }
2601
+ let conversationMode = '';
2602
+ if (scope !== 'files') {
2603
+ const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
2604
+ history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
2605
+ )).agentProgram;
2606
+ let engineSessionId = agentSession.engineSessionId || '';
2607
+ let rewindToUuid = '';
2608
+ if (!engineSessionId) {
2609
+ conversationMode = 'precise';
2610
+ } else if (program === 'claude') {
2611
+ const command = webRewind.buildClaudeRecordsCommand(engineSessionId);
2612
+ const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
2613
+ const pick = webRewind.pickClaudeResumeAt(records, { text: message.content, timeMs: Date.parse(message.timestamp) });
2614
+ if (pick.found && pick.uuid) {
2615
+ rewindToUuid = pick.uuid;
2616
+ conversationMode = 'precise';
2617
+ } else if (pick.found) {
2618
+ // 回退到第一轮:Claude 那边什么都不需要记得,开新会话
2619
+ engineSessionId = '';
2620
+ conversationMode = 'precise';
2621
+ } else {
2622
+ engineSessionId = '';
2623
+ conversationMode = 'history';
2624
+ }
2625
+ } else {
2626
+ // 其它 Agent 没有精确回退:丢掉引擎会话,下一轮用剩下的聊天记录注入(不丢数据,只丢工具调用等细节)
2627
+ engineSessionId = '';
2628
+ conversationMode = 'history';
2629
+ }
2630
+ const removed = removeWebSessionMessagesFrom(state.webHistoryDir, sessionRef, message.id);
2631
+ if (!removed.length) {
2632
+ return { status: 409, error: '这条提示词已经不在了' };
2633
+ }
2634
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
2635
+ appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
2636
+ }
2637
+ return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
2638
+ }
2639
+
2445
2640
  function resolveWebSessionWorkDir(state, sessionRef) {
2446
2641
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2447
2642
  const applied = history.applied && typeof history.applied === 'object' ? history.applied : {};
@@ -2537,48 +2732,21 @@ function parseCookies(req) {
2537
2732
  return cookies;
2538
2733
  }
2539
2734
 
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
2735
  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;
2736
+ return state.authStore.create(username);
2557
2737
  }
2558
2738
 
2559
2739
  function getWebAuthSession(state, req) {
2560
- pruneExpiredWebAuthSessions(state);
2561
- const cookies = parseCookies(req);
2562
- const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
2740
+ const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
2563
2741
  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 };
2742
+ const session = state.authStore.touch(sessionId);
2743
+ return session ? { sessionId, username: session.username } : null;
2575
2744
  }
2576
2745
 
2577
2746
  function clearWebAuthSession(state, req) {
2578
- const cookies = parseCookies(req);
2579
- const sessionId = cookies[WEB_AUTH_COOKIE_NAME];
2747
+ const sessionId = parseCookies(req)[WEB_AUTH_COOKIE_NAME];
2580
2748
  if (sessionId) {
2581
- state.authSessions.delete(sessionId);
2749
+ state.authStore.remove(sessionId);
2582
2750
  }
2583
2751
  }
2584
2752
 
@@ -2812,8 +2980,7 @@ function resolveNowTemplate(value, formatDate) {
2812
2980
  if (typeof value !== 'string') {
2813
2981
  return value;
2814
2982
  }
2815
- const nowText = typeof formatDate === 'function' ? formatDate() : '';
2816
- return value.replace(/\{now\}|\$\{now\}/g, nowText);
2983
+ return expandTemplate(value, { now: typeof formatDate === 'function' ? formatDate() : '' }).value;
2817
2984
  }
2818
2985
 
2819
2986
  function validateContainerNameStrict(containerName) {
@@ -3070,7 +3237,7 @@ function buildConfigDefaults(ctx, config) {
3070
3237
  const parsed = toPlainObject(config);
3071
3238
  const defaults = {
3072
3239
  containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
3073
- hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath, ctx.hostPath)),
3240
+ hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath)),
3074
3241
  containerPath: pickFirstString(parsed.containerPath),
3075
3242
  imageName: pickFirstString(parsed.imageName, ctx.imageName),
3076
3243
  imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
@@ -3118,7 +3285,8 @@ function buildConfigDefaults(ctx, config) {
3118
3285
  function buildStaticContainerRuntime(ctx, containerName) {
3119
3286
  return {
3120
3287
  containerName,
3121
- hostPath: ctx.hostPath,
3288
+ // 只给已存在的容器用;不带 serve 的启动目录,万一容器在这之间被删也不会按它重建
3289
+ hostPath: '',
3122
3290
  containerPath: ctx.containerPath,
3123
3291
  imageName: ctx.imageName,
3124
3292
  imageVersion: ctx.imageVersion,
@@ -3132,6 +3300,33 @@ function buildStaticContainerRuntime(ctx, containerName) {
3132
3300
  };
3133
3301
  }
3134
3302
 
3303
+ function normalizeFirstConfig(value, sourceLabel) {
3304
+ if (value === undefined || value === null) return {};
3305
+ if (typeof value !== 'object' || Array.isArray(value)) {
3306
+ throw new Error(`${sourceLabel} 的 first 必须是对象(map),例如 {"shell":"init.sh"}`);
3307
+ }
3308
+ return value;
3309
+ }
3310
+
3311
+ // first.envFile 里已存在的文件内容 + first.env,后者优先(与 CLI 一致);返回 KEY=VALUE 行
3312
+ function buildFirstEnvLines(firstResolved) {
3313
+ const lines = [];
3314
+ (firstResolved.envFile || []).forEach(file => {
3315
+ const resolvedPath = expandHomeAliasPath(file);
3316
+ if (!path.isAbsolute(resolvedPath)) {
3317
+ throw new Error(`环境变量文件仅支持绝对路径: ${file}`);
3318
+ }
3319
+ if (!fs.existsSync(resolvedPath)) return;
3320
+ containerState.parseEnvFileContent(fs.readFileSync(resolvedPath, 'utf-8')).entries
3321
+ .forEach(entry => lines.push(`${entry.key}=${entry.value}`));
3322
+ });
3323
+ Object.entries(firstResolved.env || {}).forEach(([key, value]) => {
3324
+ const parsed = parseEnvEntry(`${key}=${value}`);
3325
+ lines.push(`${parsed.key}=${parsed.value}`);
3326
+ });
3327
+ return lines;
3328
+ }
3329
+
3135
3330
  function buildCreateRuntime(ctx, state, payload) {
3136
3331
  const body = toPlainObject(payload);
3137
3332
  const requestOptions = toPlainObject(body.createOptions);
@@ -3157,6 +3352,13 @@ function buildCreateRuntime(ctx, state, payload) {
3157
3352
  const requestVolumeList = hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : [];
3158
3353
  const requestPortList = hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : [];
3159
3354
 
3355
+ // first(首次命令):请求里带了就只用它(复制容器时来自快照,已合并过),否则全局 + runs.<name>
3356
+ const hasRequestFirst = hasOwn(requestOptions, 'first');
3357
+ const globalFirstConfig = hasRequestFirst ? {} : normalizeFirstConfig(config.first, '全局配置');
3358
+ const runFirstConfig = hasRequestFirst
3359
+ ? normalizeFirstConfig(requestOptions.first, 'createOptions')
3360
+ : normalizeFirstConfig(runConfig.first, '运行配置');
3361
+
3160
3362
  const resolvedBase = resolveRuntimeConfig({
3161
3363
  cliOptions: {
3162
3364
  hostPath: requestOptions.hostPath,
@@ -3171,10 +3373,11 @@ function buildCreateRuntime(ctx, state, payload) {
3171
3373
  },
3172
3374
  globalConfig: config,
3173
3375
  runConfig,
3174
- globalFirstConfig: {},
3175
- runFirstConfig: {},
3376
+ globalFirstConfig,
3377
+ runFirstConfig,
3176
3378
  defaults: {
3177
- hostPath: ctx.hostPath,
3379
+ // 不用 ctx.hostPath(serve 的启动目录):与前端新建容器对话框一致,按模板在真正创建时才建目录
3380
+ hostPath: DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
3178
3381
  containerName: `my-${ctx.formatDate()}`,
3179
3382
  // 不用 ctx.containerPath:那是 serve 进程的启动目录(CLI 的 cwd 语义),与用户要挂载的目录无关;
3180
3383
  // 没配置也没传时落到下面的 `|| hostPath`,容器内路径与宿主机路径一致
@@ -3188,6 +3391,11 @@ function buildCreateRuntime(ctx, state, payload) {
3188
3391
  isServerStopMode: false,
3189
3392
  pickConfigValue: pickFirstString,
3190
3393
  resolveContainerNameTemplate: value => resolveNowTemplate(value, ctx.formatDate),
3394
+ // {now} / {quickChatPath} 一次解析只取一个时间:容器名和目录用同一个时间戳
3395
+ templateVars: {
3396
+ now: typeof ctx.formatDate === 'function' ? ctx.formatDate() : undefined,
3397
+ quickChatPath: resolveQuickChatPath(config, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) })
3398
+ },
3191
3399
  normalizeCommandSuffix: value => {
3192
3400
  const text = String(value || '').trim();
3193
3401
  return text ? ` ${text}` : '';
@@ -3213,6 +3421,10 @@ function buildCreateRuntime(ctx, state, payload) {
3213
3421
  validateContainerNameStrict(containerName);
3214
3422
 
3215
3423
  const hostPath = resolvedBase.hostPath;
3424
+ // 按模板({now} / {quickChatPath})生成的目录:此刻才建
3425
+ if (resolvedBase.hostPathTemplated && !fs.existsSync(hostPath)) {
3426
+ fs.mkdirSync(hostPath, { recursive: true });
3427
+ }
3216
3428
  if (typeof ctx.validateHostPath === 'function') {
3217
3429
  ctx.validateHostPath(hostPath);
3218
3430
  } else {
@@ -3312,6 +3524,19 @@ function buildCreateRuntime(ctx, state, payload) {
3312
3524
  });
3313
3525
  }
3314
3526
 
3527
+ const firstResolved = resolvedBase.first;
3528
+ const firstCommand = String(firstResolved.exec.shell || '').trim()
3529
+ ? `${firstResolved.exec.prefix || ''}${firstResolved.exec.shell}${firstResolved.exec.suffix || ''}`
3530
+ : '';
3531
+ // 写进快照的 first 已经是合并后的结果,复制容器时当作请求里的 first 使用
3532
+ const firstSnapshot = {
3533
+ shellPrefix: pickFirstString(runFirstConfig.shellPrefix, globalFirstConfig.shellPrefix),
3534
+ shell: pickFirstString(runFirstConfig.shell, globalFirstConfig.shell),
3535
+ shellSuffix: pickFirstString(runFirstConfig.shellSuffix, globalFirstConfig.shellSuffix),
3536
+ env: firstResolved.env || {},
3537
+ envFile: firstResolved.envFile || []
3538
+ };
3539
+
3315
3540
  return {
3316
3541
  containerName,
3317
3542
  hostPath,
@@ -3322,6 +3547,7 @@ function buildCreateRuntime(ctx, state, payload) {
3322
3547
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
3323
3548
  containerEnvs,
3324
3549
  envFiles,
3550
+ first: firstCommand ? { command: firstCommand, envLines: buildFirstEnvLines(firstResolved) } : null,
3325
3551
  containerVolumes,
3326
3552
  containerPorts,
3327
3553
  autostart: manage.autostart,
@@ -3349,6 +3575,7 @@ function buildCreateRuntime(ctx, state, payload) {
3349
3575
  ports: resolvedBase.ports || [],
3350
3576
  autostart: manage.autostart,
3351
3577
  systemPrompt,
3578
+ first: firstSnapshot,
3352
3579
  // 复制容器时端口暴露(宿主机端口会冲突)与 peers(按 id 引用别的容器)不带过去
3353
3580
  network: { ...manage.network, expose: [], peers: { inbound: [] } }
3354
3581
  },
@@ -3434,6 +3661,7 @@ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
3434
3661
  ports: runtimeSnapshot.ports || [],
3435
3662
  autostart: runtimeSnapshot.autostart || '',
3436
3663
  systemPrompt: runtimeSnapshot.systemPrompt || undefined,
3664
+ first: runtimeSnapshot.first || undefined,
3437
3665
  network: runtimeSnapshot.network || undefined,
3438
3666
  // 快照来自服务端、原容器创建时已确认过,复制不必再确认
3439
3667
  confirmRisk: runtimeSnapshot.network ? true : undefined
@@ -3636,6 +3864,22 @@ async function isWebImagePresent(ctx, imageRef) {
3636
3864
  }
3637
3865
  }
3638
3866
 
3867
+ // `images --format` 的输出 → [{ name, tag, id, created, size }]:去掉悬空镜像,只留版本号合规(x.y.z-后缀)的,同名同版本去重
3868
+ function parseWebImageList(output) {
3869
+ const seen = new Set();
3870
+ const images = [];
3871
+ for (const line of String(output || '').split('\n')) {
3872
+ const [name, tag, id, created, size] = line.split('\t').map(part => (part || '').trim());
3873
+ if (!name || !tag || name === '<none>' || tag === '<none>') continue;
3874
+ 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;
3875
+ const key = `${name}:${tag}`;
3876
+ if (seen.has(key)) continue;
3877
+ seen.add(key);
3878
+ images.push({ name, tag, id: id || '', created: created || '', size: size || '' });
3879
+ }
3880
+ return images;
3881
+ }
3882
+
3639
3883
  function createImagePreparingError(message) {
3640
3884
  const error = new Error(message);
3641
3885
  error.statusCode = 409;
@@ -3729,7 +3973,7 @@ async function cleanupStaleSetupTestContainers(ctx) {
3729
3973
  }
3730
3974
 
3731
3975
  // Agent 一轮对话里,容器被过滤代理拦下的新域名(浏览器后台请求不算):只在 allowlist 容器上轮询
3732
- function createWebDeniedNotifier(ctx, sessionRef, emit) {
3976
+ function createWebDeniedNotifier(ctx, sessionRef, emit, modelOptions = {}) {
3733
3977
  try {
3734
3978
  const homeDir = ctx.homeDir || os.homedir();
3735
3979
  const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
@@ -3737,10 +3981,20 @@ function createWebDeniedNotifier(ctx, sessionRef, emit) {
3737
3981
  const deniedDir = egressPaths(homeDir).denied;
3738
3982
  const policyOf = () => normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined);
3739
3983
  if (!usesProxy(policyOf())) return null;
3984
+ // 模型域名:env 里的 URL 域名 + 该 Agent 没配 BASE_URL 时的官方域名;每次现读 env(用户可能刚改过)
3985
+ const isModelHost = host => {
3986
+ const envLines = [
3987
+ ...containerState.readEnv(homeDir, id).entries.map(entry => `${entry.key}=${entry.value}`),
3988
+ ...containerState.envFileEntries(homeDir, id).map(entry => `${entry.key}=${entry.value}`)
3989
+ ];
3990
+ return agentModelDomains(envLines, modelOptions.agentProgram).includes(String(host).toLowerCase());
3991
+ };
3740
3992
  return createDeniedNotifier({
3741
3993
  read: () => readDenied(deniedDir, id),
3742
3994
  isIgnored: host => isBrowserBackground(host) || domainRuleAllows(policyOf(), host),
3743
- emit
3995
+ emit,
3996
+ isModelHost,
3997
+ onModelDenied: modelOptions.onModelDenied
3744
3998
  });
3745
3999
  } catch (e) {
3746
4000
  return null;
@@ -3889,9 +4143,26 @@ function getWebNetworkManager(ctx, state) {
3889
4143
 
3890
4144
  // 进入容器前一律检查网络规则(宿主机侧的“本次启动已下发”状态):文件浏览也会 exec /bin/bash -lc,
3891
4145
  // 里面的 .bashrc 等是 Agent 能改的代码,不能在规则缺失的窗口里跑
4146
+ // 只给名字时:容器还在就不需要运行配置;容器已被删掉,且历史里有创建参数,就按它重建(走 buildCreateRuntime 的路径校验)。
4147
+ // 不再无条件退回 serve 进程的启动目录:那会让重建的容器挂上 $HOME,并且没有原来的 env 和网络设置;
4148
+ // 没有历史也没有容器时直接报错,不再猜参数
4149
+ function resolveRuntimeForExistingName(ctx, state, containerName) {
4150
+ if (ctx.containerExists(containerName)) {
4151
+ return buildStaticContainerRuntime(ctx, containerName);
4152
+ }
4153
+ const history = loadWebSessionHistory(state.webHistoryDir, containerName);
4154
+ const snapshot = history.runtimeSnapshot && typeof history.runtimeSnapshot === 'object' ? history.runtimeSnapshot : null;
4155
+ if (!snapshot || !snapshot.hostPath) {
4156
+ throw new Error('这个会话没有建容器的参数,请新建容器');
4157
+ }
4158
+ const createOptions = buildCloneCreateOptionsFromSnapshot(snapshot, history);
4159
+ createOptions.containerName = containerName;
4160
+ return buildCreateRuntime(ctx, state, { createOptions });
4161
+ }
4162
+
3892
4163
  async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
3893
4164
  const runtime = typeof containerInput === 'string'
3894
- ? buildStaticContainerRuntime(ctx, containerInput)
4165
+ ? resolveRuntimeForExistingName(ctx, state, containerInput)
3895
4166
  : containerInput;
3896
4167
 
3897
4168
  if (!runtime || !runtime.containerName) {
@@ -3967,6 +4238,15 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3967
4238
  'system',
3968
4239
  `容器 ${runtime.containerName} 已创建并启动。`
3969
4240
  );
4241
+ if (runtime.first) {
4242
+ 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
+ );
4249
+ }
3970
4250
  return;
3971
4251
  }
3972
4252
 
@@ -3983,6 +4263,60 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3983
4263
  await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
3984
4264
  }
3985
4265
 
4266
+ const DEFAULT_NEW_CONTAINER_NAME_TEMPLATE = 'my-easy-{now}';
4267
+ const DEFAULT_NEW_CONTAINER_PATH_TEMPLATE = '{quickChatPath}/{now}';
4268
+ const WEB_FIRST_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
4269
+ const WEB_FIRST_COMMAND_OUTPUT_CHARS = 500;
4270
+
4271
+ // 新建容器后执行一次 first(与 CLI 的 executeFirstCommand 同语义,经 buildExecArgs 带上 first.env);
4272
+ // 失败只返回结果,不删容器、不抛错
4273
+ function runWebFirstCommand(ctx, containerName, first) {
4274
+ return new Promise(resolve => {
4275
+ const tail = createTailBuffer(WEB_FIRST_COMMAND_OUTPUT_CHARS * 4);
4276
+ let settled = false;
4277
+ let timer = null;
4278
+ const finish = result => {
4279
+ if (settled) return;
4280
+ settled = true;
4281
+ if (timer) clearTimeout(timer);
4282
+ resolve({ ...result, output: tail.text() });
4283
+ };
4284
+ let child;
4285
+ try {
4286
+ child = spawnContainerExec(
4287
+ ctx,
4288
+ containerName,
4289
+ { extraEnv: first.envLines, command: ['/bin/bash', '-c', first.command] },
4290
+ { stdio: ['ignore', 'pipe', 'pipe'] }
4291
+ );
4292
+ } catch (e) {
4293
+ tail.push(e && e.message ? e.message : '');
4294
+ finish({ exitCode: 1 });
4295
+ return;
4296
+ }
4297
+ timer = setTimeout(() => {
4298
+ tail.push('\n[等了 10 分钟仍未结束,已停止等待(容器里的命令可能还在运行)]');
4299
+ child.kill('SIGKILL');
4300
+ finish({ exitCode: 124 });
4301
+ }, ctx.firstCommandTimeoutMs || WEB_FIRST_COMMAND_TIMEOUT_MS);
4302
+ for (const stream of [child.stdout, child.stderr]) {
4303
+ stream.setEncoding('utf8');
4304
+ stream.on('data', chunk => tail.push(chunk));
4305
+ }
4306
+ child.on('error', error => {
4307
+ tail.push(error && error.message ? error.message : '');
4308
+ finish({ exitCode: 1 });
4309
+ });
4310
+ child.on('close', (code, signal) => finish({ exitCode: typeof code === 'number' ? code : (signal ? 1 : 0) }));
4311
+ });
4312
+ }
4313
+
4314
+ function formatWebFirstFailure(result, first) {
4315
+ const secrets = first.envLines.map(line => line.slice(line.indexOf('=') + 1));
4316
+ const text = sanitizeServeLogText(redactSecrets(result.output, secrets)).trim().slice(-WEB_FIRST_COMMAND_OUTPUT_CHARS);
4317
+ return `首次命令失败(退出码 ${result.exitCode}):${text}`;
4318
+ }
4319
+
3986
4320
  async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
3987
4321
  const opts = options && typeof options === 'object' ? options : {};
3988
4322
  const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
@@ -4204,6 +4538,25 @@ try {
4204
4538
  `);
4205
4539
  }
4206
4540
 
4541
+ // 只判断路径是否存在(点击聊天里的路径时用);不存在不是错误,返回 exists:false
4542
+ function buildContainerPathExistsCommand(requestedPath) {
4543
+ return buildWebContainerNodeCommand(`
4544
+ // __MANYOYO_FS_EXISTS__
4545
+ const fs = require('fs');
4546
+
4547
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
4548
+
4549
+ let result = { exists: false };
4550
+ try {
4551
+ const stat = fs.statSync(requestedPath);
4552
+ result = { exists: true, type: stat.isDirectory() ? 'dir' : (stat.isFile() ? 'file' : 'other') };
4553
+ } catch (e) {
4554
+ result = { exists: false };
4555
+ }
4556
+ process.stdout.write(JSON.stringify(result));
4557
+ `);
4558
+ }
4559
+
4207
4560
  function buildContainerFileReadCommand(requestedPath, options = {}) {
4208
4561
  const opts = options && typeof options === 'object' ? options : {};
4209
4562
  const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes > 0
@@ -4930,7 +5283,7 @@ function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
4930
5283
  }
4931
5284
  ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
4932
5285
  };
4933
- runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
5286
+ runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse(), Array.isArray(item.images) ? item.images : [])
4934
5287
  .then(turn => {
4935
5288
  if (turn && turn.busy) putBack('busy');
4936
5289
  else if (turn && turn.error) putBack(turn.error);
@@ -4960,6 +5313,8 @@ function listLiveRunsInContainer(ctx, containerName) {
4960
5313
  // 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
4961
5314
  // 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
4962
5315
  async function resumeQueuedAgentTurns(ctx, state) {
5316
+ // 先等启动对账:否则会在还挂着旧 pending 的会话上再开新一轮
5317
+ try { await state.orphanReconcileReady; } catch (e) { /* 对账失败不影响排队恢复 */ }
4963
5318
  let containers = {};
4964
5319
  try {
4965
5320
  containers = listWebManyoyoContainers(ctx);
@@ -5073,10 +5428,135 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
5073
5428
  saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
5074
5429
  }
5075
5430
 
5431
+ // ---- serve 启动对账 ----
5432
+ // serve 重启会丢掉内存里的运行登记,历史里留着 pending=true 的消息,前端据此永远显示“进行中”。
5433
+ // 启动时记下当时就 pending 的那批消息 id(之后新发起的轮次不碰),再按容器里是否还有活着的运行分两种处理:
5434
+ // 没有 → 立即标成已中断;还有(docker 下容器里的 agent 会比 serve 活得久)→ 每 5 秒查一次,跑完再标。
5435
+ const WEB_ORPHAN_POLL_MS = 5000;
5436
+ const ORPHAN_NOTE_INTERRUPTED = {
5437
+ trace: '服务重启,本轮已中断',
5438
+ reply: '服务重启时这一轮被中断了。可以发送“继续”让它接着做。'
5439
+ };
5440
+ const ORPHAN_NOTE_FINISHED = {
5441
+ trace: '服务重启,本轮在后台跑完,结果未接收',
5442
+ reply: '服务重启后这一轮在后台跑完了,但结果没能接收;可以发送“继续”让它总结。'
5443
+ };
5444
+
5445
+ function collectStartupPending(ctx, state) {
5446
+ const entries = [];
5447
+ let names = [];
5448
+ try {
5449
+ names = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
5450
+ } catch (e) {
5451
+ ctx.logger.warn('orphan reconcile: list history failed', { message: e && e.message });
5452
+ return entries;
5453
+ }
5454
+ for (const containerName of names) {
5455
+ try {
5456
+ const history = loadWebSessionHistory(state.webHistoryDir, containerName);
5457
+ for (const [agentId, agentSession] of Object.entries(history.agents || {})) {
5458
+ const ids = (agentSession.messages || []).filter(message => message && message.pending === true).map(message => message.id);
5459
+ if (ids.length) entries.push({ containerName, agentId, ids: new Set(ids) });
5460
+ }
5461
+ } catch (e) {
5462
+ ctx.logger.warn('orphan reconcile: skip unreadable history', { container: containerName, message: e && e.message });
5463
+ }
5464
+ }
5465
+ return entries;
5466
+ }
5467
+
5468
+ // 只改启动时记下的那些消息;这一轮的执行过程末尾补一行,最终回复(没有流式半截回复就新建一条)写一句说明
5469
+ function finalizeOrphanEntry(ctx, state, entry, note) {
5470
+ const sessionRef = { containerName: entry.containerName, agentId: entry.agentId };
5471
+ const history = loadWebSessionHistory(state.webHistoryDir, entry.containerName);
5472
+ const agentSession = getWebAgentSession(history, entry.agentId, { includeArchived: true });
5473
+ if (!agentSession || !Array.isArray(agentSession.messages)) return;
5474
+ const running = state.agentRuns.get(entry.containerName);
5475
+ const newerRunInSession = Boolean(running && running.sessionRef && running.sessionRef.agentId === entry.agentId);
5476
+ let touched = false;
5477
+ let replied = false;
5478
+ for (const message of agentSession.messages) {
5479
+ if (!message || message.pending !== true || !entry.ids.has(message.id)) continue;
5480
+ message.pending = false;
5481
+ message.interrupted = true;
5482
+ touched = true;
5483
+ if (message.streamTrace) {
5484
+ message.content = `${message.content || ''}\n[任务] ${note.trace}`;
5485
+ } else if (message.streamingReply) {
5486
+ message.streamingReply = false;
5487
+ message.content = message.content ? `${message.content}\n\n${note.reply}` : note.reply;
5488
+ replied = true;
5489
+ }
5490
+ }
5491
+ if (!touched) return;
5492
+ const timestamp = new Date().toISOString();
5493
+ agentSession.updatedAt = timestamp;
5494
+ history.updatedAt = timestamp;
5495
+ saveWebSessionHistory(state.webHistoryDir, entry.containerName, history);
5496
+ // 这个会话已经有新的一轮在跑:不在末尾插入说明,免得夹进新一轮的消息中间
5497
+ if (!replied && !newerRunInSession) {
5498
+ appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', note.reply, { mode: 'agent', interrupted: true });
5499
+ }
5500
+ appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'process.interrupted', { reason: 'serve-restart' });
5501
+ }
5502
+
5503
+ function isWebContainerRunning(ctx, containerName) {
5504
+ try {
5505
+ return Boolean(ctx.containerExists(containerName)) && ctx.getContainerStatus(containerName) === 'running';
5506
+ } catch (e) {
5507
+ return false;
5508
+ }
5509
+ }
5510
+
5511
+ async function waitForRuntimeReady(ctx) {
5512
+ const deadline = Date.now() + 10 * 60 * 1000;
5513
+ while (ctx.runtimeState && ctx.runtimeState.status === 'starting' && Date.now() < deadline) {
5514
+ await new Promise(resolve => setTimeout(resolve, 1000));
5515
+ }
5516
+ }
5517
+
5518
+ async function reconcileOrphanTurns(ctx, state) {
5519
+ const entries = collectStartupPending(ctx, state);
5520
+ if (!entries.length) return;
5521
+ await waitForRuntimeReady(ctx);
5522
+ const byContainer = new Map();
5523
+ for (const entry of entries) {
5524
+ if (!byContainer.has(entry.containerName)) byContainer.set(entry.containerName, []);
5525
+ byContainer.get(entry.containerName).push(entry);
5526
+ }
5527
+ for (const [containerName, list] of byContainer) {
5528
+ const live = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
5529
+ if (!live.length) {
5530
+ for (const entry of list) {
5531
+ try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_INTERRUPTED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
5532
+ }
5533
+ continue;
5534
+ }
5535
+ let checking = false;
5536
+ const timer = setInterval(async () => {
5537
+ if (checking) return;
5538
+ checking = true;
5539
+ try {
5540
+ const stillLive = isWebContainerRunning(ctx, containerName) ? await listLiveRunsInContainer(ctx, containerName) : [];
5541
+ if (stillLive.length) return;
5542
+ clearInterval(timer);
5543
+ state.orphanTimers.delete(timer);
5544
+ for (const entry of list) {
5545
+ try { finalizeOrphanEntry(ctx, state, entry, ORPHAN_NOTE_FINISHED); } catch (e) { ctx.logger.warn('orphan reconcile failed', { container: containerName, message: e && e.message }); }
5546
+ }
5547
+ } finally {
5548
+ checking = false;
5549
+ }
5550
+ }, ctx.orphanPollMs || WEB_ORPHAN_POLL_MS);
5551
+ if (typeof timer.unref === 'function') timer.unref();
5552
+ state.orphanTimers.add(timer);
5553
+ }
5554
+ }
5555
+
5076
5556
  // 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
5077
5557
  // 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
5078
5558
  // 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
5079
- async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
5559
+ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
5080
5560
  if (state.agentRuns.has(sessionRef.containerName)) {
5081
5561
  // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
5082
5562
  return { busy: true };
@@ -5090,6 +5570,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5090
5570
  userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
5091
5571
  mode: 'agent',
5092
5572
  pending: true,
5573
+ ...(images.length ? { images } : {}),
5093
5574
  ...(clientMessageId ? { clientMessageId: clientMessageId } : {})
5094
5575
  });
5095
5576
  traceMessage = appendWebAgentTraceMessage(
@@ -5109,7 +5590,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5109
5590
 
5110
5591
  let prepared = null;
5111
5592
  try {
5112
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
5593
+ prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
5113
5594
  } catch (e) {
5114
5595
  releaseWebAgentRun(state, sessionRef.containerName, runState);
5115
5596
  removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
@@ -5271,9 +5752,23 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5271
5752
 
5272
5753
  };
5273
5754
  // 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
5274
- const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
5755
+ // 被拦的是 Agent 自己的模型域名:继续等只会白等到超时,立即中止本轮(不暂停排队,不是用户点的停止)
5756
+ const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }), {
5757
+ agentProgram: agentMeta.agentProgram,
5758
+ onModelDenied: host => {
5759
+ if (runState.modelDenied) return;
5760
+ runState.modelDenied = host;
5761
+ handleStreamEvent({ type: 'network_denied', host, model: true });
5762
+ runState.stopping = true;
5763
+ if (runState.process) terminateWebAgentRun(ctx, runState);
5764
+ }
5765
+ });
5766
+ let snapBefore = { skipped: 'unavailable' };
5275
5767
  try {
5276
5768
  if (deniedNotifier) deniedNotifier.start();
5769
+ snapBefore = await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} before`);
5770
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id,
5771
+ snapBefore.sha ? { snapBefore: snapBefore.sha } : { snapSkipped: snapBefore.skipped });
5277
5772
  let result;
5278
5773
  try {
5279
5774
  result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
@@ -5291,14 +5786,21 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5291
5786
  exitCode: result.exitCode,
5292
5787
  interrupted: result.interrupted === true
5293
5788
  });
5294
- traceLines.push(result.interrupted === true
5295
- ? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
5296
- : '[任务] 已完成');
5789
+ const modelDeniedHost = runState.modelDenied || '';
5790
+ traceLines.push(modelDeniedHost
5791
+ ? '[任务] 已中止:模型地址被网络策略拦截'
5792
+ : (result.interrupted === true
5793
+ ? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
5794
+ : '[任务] 已完成'));
5795
+ const snapAfter = snapBefore.sha
5796
+ ? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
5797
+ : { skipped: snapBefore.skipped };
5297
5798
  patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5298
5799
  pending: false,
5299
5800
  contextMode,
5300
5801
  resumeAttempted,
5301
- resumeSucceeded
5802
+ resumeSucceeded,
5803
+ ...(snapAfter.sha ? { snapAfter: snapAfter.sha } : {})
5302
5804
  });
5303
5805
  patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5304
5806
  content: traceLines.join('\n'),
@@ -5312,26 +5814,30 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5312
5814
  if (streamingReplyMessageId) {
5313
5815
  removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
5314
5816
  }
5817
+ const finalResult = modelDeniedHost
5818
+ ? { ...result, interrupted: true, output: `已中止:模型地址 ${modelDeniedHost} 被网络策略拦截。`, networkDenied: { host: modelDeniedHost, model: true } }
5819
+ : result;
5315
5820
  const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
5316
5821
  contextMode,
5317
5822
  resumeAttempted,
5318
5823
  resumeSucceeded,
5319
5824
  resumeError,
5320
5825
  engineSessionId
5321
- }, result);
5826
+ }, finalResult);
5322
5827
  void refreshWebAgentTitle(ctx, state, sessionRef, agentMeta.agentProgram);
5323
5828
  emitStreamEvent({
5324
5829
  type: 'result',
5325
- exitCode: result.exitCode,
5326
- output: result.output,
5830
+ exitCode: finalResult.exitCode,
5831
+ output: finalResult.output,
5832
+ ...(finalResult.networkDenied ? { networkDenied: finalResult.networkDenied } : {}),
5327
5833
  // 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
5328
5834
  // 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
5329
5835
  replyMessageId: replyMessage && replyMessage.id,
5330
5836
  contextMode,
5331
5837
  resumeAttempted,
5332
5838
  resumeSucceeded,
5333
- interrupted: result.interrupted === true
5334
- }, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
5839
+ interrupted: finalResult.interrupted === true
5840
+ }, finalResult.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
5335
5841
  } catch (e) {
5336
5842
  ctx.logger.error('agent turn failed', {
5337
5843
  session: turnSessionKey,
@@ -5339,11 +5845,15 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5339
5845
  message: e && e.message ? e.message : 'Agent 执行失败'
5340
5846
  });
5341
5847
  traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
5848
+ const failedSnapAfter = snapBefore.sha
5849
+ ? await takeWebSnapshot(ctx, state, sessionRef, `web ${userMessage && userMessage.id} after`)
5850
+ : { skipped: snapBefore.skipped };
5342
5851
  patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5343
5852
  pending: false,
5344
5853
  contextMode,
5345
5854
  resumeAttempted,
5346
- resumeSucceeded
5855
+ resumeSucceeded,
5856
+ ...(failedSnapAfter.sha ? { snapAfter: failedSnapAfter.sha } : {})
5347
5857
  });
5348
5858
  patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5349
5859
  content: traceLines.join('\n'),
@@ -5365,7 +5875,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5365
5875
  clearInterval(heartbeatTimer);
5366
5876
  clearTimeout(runState.stepTimer);
5367
5877
  // 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
5368
- const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
5878
+ const hasNext = runState.userStopped !== true && !runState.modelDenied && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
5369
5879
  if (hasNext && !res.writableEnded && !res.destroyed) {
5370
5880
  // 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
5371
5881
  sendNdjson(res, { type: 'queue_next' });
@@ -5389,6 +5899,38 @@ function getSetupWorkpath(state) {
5389
5899
  return path.join(path.dirname(path.resolve(state.webConfigPath)), 'work');
5390
5900
  }
5391
5901
 
5902
+ // 工作目录根:默认的 ~/.manyoyo/work;配置里的快捷对话目录(serve.quickChat.path)另算,storage 里会先校验再用
5903
+ function getStorageRootsConfig(ctx, state) {
5904
+ const extraWorkRoots = [];
5905
+ const configHostPaths = [];
5906
+ try {
5907
+ const parsed = readWebConfigSnapshot(state.webConfigPath).parsed;
5908
+ // 配置里写死的挂载目录(全局与每个 runs.<名>):删除时算在用;含 {模板} 的每次不同,不算
5909
+ const fixedHostPaths = [parsed && parsed.hostPath, ...Object.values((parsed && parsed.runs) || {}).map(run => run && run.hostPath)];
5910
+ for (const value of fixedHostPaths) {
5911
+ if (typeof value === 'string' && value.trim() && !value.includes('{')) configHostPaths.push(expandHomeAliasPath(value.trim()));
5912
+ }
5913
+ const quickChatPath = parsed && parsed.serve && parsed.serve.quickChat && parsed.serve.quickChat.path;
5914
+ if (typeof quickChatPath === 'string' && quickChatPath.trim()) extraWorkRoots.push(expandHomeAliasPath(quickChatPath.trim()));
5915
+ } catch (e) { /* 配置读不出来就只认默认目录 */ }
5916
+ return {
5917
+ workRoots: [getSetupWorkpath(state)],
5918
+ extraWorkRoots,
5919
+ configHostPaths,
5920
+ homeDir: ctx.homeDir || os.homedir(),
5921
+ dockerExecAsync: ctx.dockerExecAsync || (async (args) => ctx.dockerExecArgs(args))
5922
+ };
5923
+ }
5924
+
5925
+ async function loadStorageListing(ctx, state) {
5926
+ const listing = await webStorage.buildStorageListing({
5927
+ ...getStorageRootsConfig(ctx, state),
5928
+ historyDir: state.webHistoryDir
5929
+ });
5930
+ state.storageKnownPaths = new Set(listing.rows.map(row => row.path));
5931
+ return listing;
5932
+ }
5933
+
5392
5934
  async function inspectSetupImage(ctx, state) {
5393
5935
  const image = `${ctx.imageName}:${ctx.imageVersion}`;
5394
5936
  const pulling = state && state.imagePulls.get(image);
@@ -5416,6 +5958,15 @@ async function inspectSetupImage(ctx, state) {
5416
5958
  }
5417
5959
  }
5418
5960
 
5961
+ // 镜像里装了哪些 Agent CLI;镜像没就绪或探测失败返回 null(未知,向导照常可选)
5962
+ async function probeSetupImageAgents(ctx, state) {
5963
+ if (!ctx.dockerExecAsync) return null;
5964
+ const image = await inspectSetupImage(ctx, state);
5965
+ if (image.status !== 'ready') return null;
5966
+ if (!state.imageAgentProbe) state.imageAgentProbe = createImageAgentProbe(ctx.dockerExecAsync);
5967
+ return state.imageAgentProbe(image.name);
5968
+ }
5969
+
5419
5970
  // 在一次性容器里用所选 Agent 发一次最小请求;容器无论成败都会删除。
5420
5971
  async function runSetupConnectionTest(ctx, state, agent, envMap) {
5421
5972
  const secrets = Object.values(envMap);
@@ -5429,7 +5980,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
5429
5980
  if (!template) {
5430
5981
  return { category: 'other', message: `${spec.label} 暂不支持连接测试`, detail: '' };
5431
5982
  }
5432
- const agentCommand = renderAgentPromptCommand(template, SETUP_TEST_PROMPT);
5983
+ const agentCommand = withCodexBaseUrl(renderAgentPromptCommand(template, SETUP_TEST_PROMPT));
5433
5984
  const command = `timeout ${SETUP_TEST_TIMEOUT_SECONDS} /bin/bash -lc ${quoteBashSingleValue(agentCommand)}`;
5434
5985
 
5435
5986
  const workpath = getSetupWorkpath(state);
@@ -5687,7 +6238,7 @@ function buildSessionFallbackApplied(ctx, state, name, history, summary) {
5687
6238
 
5688
6239
  return {
5689
6240
  containerName: name,
5690
- hostPath: defaults.hostPath || ctx.hostPath || '',
6241
+ hostPath: defaults.hostPath || '',
5691
6242
  containerPath: defaults.containerPath || ctx.containerPath || '',
5692
6243
  imageName: defaults.imageName || ctx.imageName || '',
5693
6244
  imageVersion: defaults.imageVersion || ctx.imageVersion || '',
@@ -6419,6 +6970,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6419
6970
  });
6420
6971
  }
6421
6972
  },
6973
+ {
6974
+ // 新建容器对话框的默认值:配置里写了就用配置的,没写就用默认模板
6975
+ // (容器名 my-easy-{now},宿主机目录与容器内目录 {quickChatPath}/{now}),由前端展开;目录在点“创建”时才建
6976
+ method: 'GET',
6977
+ match: currentPath => currentPath === '/api/system/new-container-defaults' ? [] : null,
6978
+ handler: async () => {
6979
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
6980
+ const config = snapshot.parseError ? {} : toPlainObject(snapshot.parsed);
6981
+ const hostPathConfigured = Boolean(pickFirstString(config.hostPath));
6982
+ sendJson(res, 200, {
6983
+ containerName: pickFirstString(config.containerName) || DEFAULT_NEW_CONTAINER_NAME_TEMPLATE,
6984
+ hostPath: pickFirstString(config.hostPath) || DEFAULT_NEW_CONTAINER_PATH_TEMPLATE,
6985
+ containerPath: pickFirstString(config.containerPath) || (hostPathConfigured ? '' : DEFAULT_NEW_CONTAINER_PATH_TEMPLATE),
6986
+ quickChatPath: resolveQuickChatPath(config, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) })
6987
+ });
6988
+ }
6989
+ },
6422
6990
  {
6423
6991
  // 系统设置“Agent 环境说明”:内置说明(只读)+ 全局配置里用户追加的说明
6424
6992
  method: 'GET',
@@ -6541,11 +7109,35 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6541
7109
  sendJson(res, 200, await buildSetupStatus(ctx, state));
6542
7110
  }
6543
7111
  },
7112
+ {
7113
+ // 本机已有的镜像(系统设置「基础」里选镜像名 / 版本用):只列版本号符合 x.y.z-后缀 的,按镜像名分组
7114
+ method: 'GET',
7115
+ match: currentPath => currentPath === '/api/images' ? [] : null,
7116
+ handler: async () => {
7117
+ const defaults = { imageName: ctx.imageName || '', imageVersion: ctx.imageVersion || '' };
7118
+ if (!ctx.dockerExecAsync || !ctx.runtimeState || ctx.runtimeState.status !== 'ready') {
7119
+ sendJson(res, 200, { defaults, images: [] });
7120
+ return;
7121
+ }
7122
+ let output = '';
7123
+ try {
7124
+ output = String(await ctx.dockerExecAsync(['images', '-a', '--format', '{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}'], { timeout: 15000 }) || '');
7125
+ } catch (e) {
7126
+ output = '';
7127
+ }
7128
+ sendJson(res, 200, { defaults, images: parseWebImageList(output) });
7129
+ }
7130
+ },
6544
7131
  {
6545
7132
  method: 'GET',
6546
7133
  match: currentPath => currentPath === '/api/setup/agents' ? [] : null,
6547
7134
  handler: async () => {
6548
- sendJson(res, 200, { agents: listSetupAgents(), mirrorPresets: listMirrorPresets() });
7135
+ const installed = await probeSetupImageAgents(ctx, state);
7136
+ const agents = listSetupAgents().map(agent => ({
7137
+ ...agent,
7138
+ available: installed ? installed.includes(agent.id) : null
7139
+ }));
7140
+ sendJson(res, 200, { agents, mirrorPresets: listMirrorPresets() });
6549
7141
  }
6550
7142
  },
6551
7143
  {
@@ -7100,6 +7692,149 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7100
7692
  sendJson(res, 200, payload);
7101
7693
  }
7102
7694
  },
7695
+ {
7696
+ // 磁盘占用:宿主机工作目录与已删除容器的历史(体积另走 /size,大目录不拖慢列表)
7697
+ method: 'GET',
7698
+ match: currentPath => currentPath === '/api/storage' ? [] : null,
7699
+ handler: async () => {
7700
+ let listing;
7701
+ try {
7702
+ listing = await loadStorageListing(ctx, state);
7703
+ } catch (e) {
7704
+ sendJson(res, 503, { error: '容器运行时不可用,暂时无法判断目录是否还在使用' });
7705
+ return;
7706
+ }
7707
+ sendJson(res, 200, listing);
7708
+ }
7709
+ },
7710
+ {
7711
+ method: 'GET',
7712
+ match: currentPath => currentPath === '/api/storage/size' ? [] : null,
7713
+ handler: async () => {
7714
+ const target = String(new URL(req.url || '/', 'http://localhost').searchParams.get('path') || '');
7715
+ if (!state.storageKnownPaths || !state.storageKnownPaths.has(target)) {
7716
+ sendJson(res, 404, { error: '没有这个目录,请刷新列表' });
7717
+ return;
7718
+ }
7719
+ sendJson(res, 200, await webStorage.directorySize(target));
7720
+ }
7721
+ },
7722
+ {
7723
+ method: 'GET',
7724
+ match: currentPath => currentPath === '/api/storage/files' ? [] : null,
7725
+ handler: async () => {
7726
+ const params = new URL(req.url || '/', 'http://localhost').searchParams;
7727
+ const target = String(params.get('path') || '');
7728
+ if (!state.storageKnownPaths || !state.storageKnownPaths.has(target)) {
7729
+ sendJson(res, 404, { error: '没有这个目录,请刷新列表' });
7730
+ return;
7731
+ }
7732
+ const listed = await webStorage.listDirectory(target, params.get('sub') || '');
7733
+ sendJson(res, listed.ok ? 200 : listed.status, listed.ok ? listed : { error: listed.error });
7734
+ }
7735
+ },
7736
+ {
7737
+ method: 'DELETE',
7738
+ match: currentPath => currentPath === '/api/storage/dirs' ? [] : null,
7739
+ handler: async () => {
7740
+ const body = (await readJsonBody(req)) || {};
7741
+ let listing;
7742
+ try {
7743
+ listing = await loadStorageListing(ctx, state);
7744
+ } catch (e) {
7745
+ sendJson(res, 503, { error: '容器运行时不可用,不能确认目录没人用,已拒绝删除' });
7746
+ return;
7747
+ }
7748
+ const removed = await webStorage.removeManagedDirectory(listing, String(body.path || ''), { emptyOnly: body.emptyOnly === true });
7749
+ sendJson(res, removed.ok ? 200 : removed.status, removed.ok ? { removed: true } : { error: removed.error });
7750
+ }
7751
+ },
7752
+ {
7753
+ method: 'DELETE',
7754
+ match: currentPath => currentPath === '/api/storage/histories' ? [] : null,
7755
+ handler: async () => {
7756
+ const body = (await readJsonBody(req)) || {};
7757
+ const name = String(body.name || '');
7758
+ if (!ctx.isValidContainerName(name)) {
7759
+ sendJson(res, 400, { error: '名称不合法' });
7760
+ return;
7761
+ }
7762
+ let listing;
7763
+ try {
7764
+ listing = await loadStorageListing(ctx, state);
7765
+ } catch (e) {
7766
+ sendJson(res, 503, { error: '容器运行时不可用,不能确认容器已删除,已拒绝清理' });
7767
+ return;
7768
+ }
7769
+ const removed = await webStorage.removeDeletedHistory(
7770
+ state.webHistoryDir, listing, name, historyName => removeWebSessionHistory(state.webHistoryDir, historyName)
7771
+ );
7772
+ sendJson(res, removed.ok ? 200 : removed.status, removed.ok ? { removed: true } : { error: removed.error });
7773
+ }
7774
+ },
7775
+ {
7776
+ // 聊天里的图片:原始请求体,类型只收 png/jpeg/gif/webp,单张 10MB
7777
+ method: 'POST',
7778
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/uploads$/),
7779
+ handler: async match => {
7780
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7781
+ if (!sessionRef) {
7782
+ return;
7783
+ }
7784
+ const ext = webUploads.extForContentType(req.headers['content-type']);
7785
+ if (!ext) {
7786
+ sendJson(res, 415, { error: '只支持 png、jpeg、gif、webp 图片' });
7787
+ return;
7788
+ }
7789
+ const declared = Number(req.headers['content-length']);
7790
+ if (Number.isFinite(declared) && declared > webUploads.MAX_IMAGE_BYTES) {
7791
+ sendJson(res, 413, { error: '图片不能超过 10MB' });
7792
+ return;
7793
+ }
7794
+ if (!ctx.containerExists(sessionRef.containerName)) {
7795
+ sendJson(res, 404, { error: `容器不存在: ${sessionRef.containerName}` });
7796
+ return;
7797
+ }
7798
+ const homeDir = ctx.homeDir || os.homedir();
7799
+ const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
7800
+ if (!id || !containerState.stateExists(homeDir, id)) {
7801
+ sendJson(res, 409, { error: '这个容器太旧,不支持发图片,重建后可用' });
7802
+ return;
7803
+ }
7804
+ const saved = await webUploads.saveUpload(req, path.join(containerState.paths(homeDir, id).box, 'uploads'), ext);
7805
+ if (!saved.ok) {
7806
+ sendJson(res, saved.status, { error: saved.error });
7807
+ return;
7808
+ }
7809
+ sendJson(res, 200, { path: `${webUploads.CONTAINER_UPLOAD_DIR}/${saved.name}` });
7810
+ }
7811
+ },
7812
+ {
7813
+ method: 'GET',
7814
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/stat$/),
7815
+ handler: async match => {
7816
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7817
+ if (!sessionRef) {
7818
+ return;
7819
+ }
7820
+ const requestUrl = new URL(req.url || '/api/sessions/x/fs/stat', 'http://localhost');
7821
+ const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
7822
+ // 点一下文字不该拉起容器:没在运行就当作不存在
7823
+ if (!targetPath || !ctx.containerExists(sessionRef.containerName) || ctx.getContainerStatus(sessionRef.containerName) !== 'running') {
7824
+ sendJson(res, 200, { exists: false });
7825
+ return;
7826
+ }
7827
+ let payload = null;
7828
+ try {
7829
+ payload = await execJsonCommandInWebContainer(ctx, sessionRef.containerName, buildContainerPathExistsCommand(targetPath));
7830
+ } catch (e) {
7831
+ payload = null;
7832
+ }
7833
+ sendJson(res, 200, payload && payload.exists === true
7834
+ ? { exists: true, type: payload.type }
7835
+ : { exists: false });
7836
+ }
7837
+ },
7103
7838
  {
7104
7839
  method: 'GET',
7105
7840
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/read$/),
@@ -7320,6 +8055,130 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7320
8055
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
7321
8056
  }
7322
8057
  },
8058
+ {
8059
+ // 按轮看改动:比较某一轮前后两个快照(提交号必须是 40 位小写十六进制)
8060
+ method: 'GET',
8061
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/changes$/),
8062
+ handler: async match => {
8063
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8064
+ if (!sessionRef) {
8065
+ return;
8066
+ }
8067
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
8068
+ const from = String(requestUrl.searchParams.get('from') || '');
8069
+ const to = String(requestUrl.searchParams.get('to') || '');
8070
+ if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
8071
+ sendJson(res, 400, { error: '快照号不合法' });
8072
+ return;
8073
+ }
8074
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
8075
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(resolveWebSessionWorkDir(state, sessionRef), from, to), 30000);
8076
+ sendJson(res, 200, webSnapshots.parseRoundChangesOutput(out));
8077
+ }
8078
+ },
8079
+ {
8080
+ method: 'GET',
8081
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/rounds\/diff$/),
8082
+ handler: async match => {
8083
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8084
+ if (!sessionRef) {
8085
+ return;
8086
+ }
8087
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
8088
+ const from = String(requestUrl.searchParams.get('from') || '');
8089
+ const to = String(requestUrl.searchParams.get('to') || '');
8090
+ const filePath = String(requestUrl.searchParams.get('path') || '');
8091
+ if (!webSnapshots.isSnapId(from) || !webSnapshots.isSnapId(to)) {
8092
+ sendJson(res, 400, { error: '快照号不合法' });
8093
+ return;
8094
+ }
8095
+ if (!gitChanges.isAcceptableDiffPath(filePath)) {
8096
+ sendJson(res, 400, { error: '文件路径不合法' });
8097
+ return;
8098
+ }
8099
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
8100
+ const workDir = resolveWebSessionWorkDir(state, sessionRef);
8101
+ // 只给这两个快照之间出现过的文件看 diff:路径由服务端重新取一次核对,不信任请求
8102
+ const listed = webSnapshots.parseRoundChangesOutput(await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundChangesCommand(workDir, from, to), 30000));
8103
+ if (!listed.files.some(file => file.path === filePath)) {
8104
+ sendJson(res, 404, { error: '该文件不在这一轮的改动里' });
8105
+ return;
8106
+ }
8107
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRoundDiffCommand(workDir, from, to, filePath), 30000);
8108
+ sendJson(res, 200, { path: filePath, ...webSnapshots.parseRoundDiffOutput(out) });
8109
+ }
8110
+ },
8111
+ {
8112
+ // 回退前预览:会改 / 删 / 恢复哪些文件(会先拍一张“现在”的快照,不改任何文件)
8113
+ method: 'POST',
8114
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind\/preview$/),
8115
+ handler: async match => {
8116
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8117
+ if (!sessionRef) {
8118
+ return;
8119
+ }
8120
+ const payload = await readJsonBody(req);
8121
+ const target = findWebRewindTarget(state, sessionRef, payload.messageId);
8122
+ if (target.error) {
8123
+ sendJson(res, target.status, { error: target.error });
8124
+ return;
8125
+ }
8126
+ const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
8127
+ target.history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
8128
+ )).agentProgram;
8129
+ const result = { agentProgram: program, precise: program === 'claude', files: null };
8130
+ if (webSnapshots.isSnapId(target.message.snapBefore)) {
8131
+ const out = await runWebContainerScript(ctx, sessionRef.containerName, webSnapshots.buildRestorePreviewCommand(resolveWebSessionWorkDir(state, sessionRef), target.message.snapBefore), 30000);
8132
+ const preview = webSnapshots.parseRestorePreviewOutput(out);
8133
+ result.files = preview.ok ? preview : { ok: false, reason: preview.reason };
8134
+ }
8135
+ sendJson(res, 200, result);
8136
+ }
8137
+ },
8138
+ {
8139
+ // 回退:scope = both(对话和文件)/ conversation(只对话)/ files(只文件)
8140
+ method: 'POST',
8141
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/rewind$/),
8142
+ handler: async match => {
8143
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8144
+ if (!sessionRef) {
8145
+ return;
8146
+ }
8147
+ const payload = await readJsonBody(req);
8148
+ const scope = String(payload.scope || '');
8149
+ if (!['both', 'conversation', 'files'].includes(scope)) {
8150
+ sendJson(res, 400, { error: 'scope 只能是 both、conversation 或 files' });
8151
+ return;
8152
+ }
8153
+ const outcome = await rewindWebSession(ctx, state, sessionRef, payload.messageId, scope);
8154
+ if (outcome.error) {
8155
+ sendJson(res, outcome.status, { error: outcome.error });
8156
+ return;
8157
+ }
8158
+ sendJson(res, 200, outcome);
8159
+ }
8160
+ },
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
+ },
7323
8182
  {
7324
8183
  // “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
7325
8184
  method: 'GET',
@@ -7642,6 +8501,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7642
8501
  }
7643
8502
 
7644
8503
  const payload = await readJsonBody(req);
8504
+ let streamImages;
8505
+ try {
8506
+ streamImages = webUploads.parseImages(payload.images);
8507
+ } catch (e) {
8508
+ sendJson(res, 400, { error: e.message });
8509
+ return;
8510
+ }
7645
8511
  const prompt = (payload.prompt || '').trim();
7646
8512
  if (!prompt) {
7647
8513
  sendJson(res, 400, { error: 'prompt 不能为空' });
@@ -7654,7 +8520,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7654
8520
  if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
7655
8521
  return;
7656
8522
  }
7657
- const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
8523
+ const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages);
7658
8524
  if (turn.busy) {
7659
8525
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
7660
8526
  } else if (turn.error) {
@@ -7671,6 +8537,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7671
8537
  return;
7672
8538
  }
7673
8539
  const payload = await readJsonBody(req);
8540
+ let queueImages;
8541
+ try {
8542
+ queueImages = webUploads.parseImages(payload.images);
8543
+ } catch (e) {
8544
+ sendJson(res, 400, { error: e.message });
8545
+ return;
8546
+ }
7674
8547
  const text = (payload.prompt || '').trim();
7675
8548
  if (!text) {
7676
8549
  sendJson(res, 400, { error: 'prompt 不能为空' });
@@ -7696,7 +8569,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7696
8569
  // 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
7697
8570
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7698
8571
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
7699
- const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
8572
+ const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when, images: queueImages });
7700
8573
  if (!added.ok) {
7701
8574
  sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
7702
8575
  return;
@@ -7820,7 +8693,16 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7820
8693
 
7821
8694
  const payload = await readJsonBody(req);
7822
8695
  const removeHistory = payload.removeHistory === true;
8696
+ // 先记下工作目录:历史删掉后就查不到了
8697
+ const removedHistoryInfo = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
8698
+ const workDirHint = (removedHistoryInfo.applied && removedHistoryInfo.applied.hostPath) ||
8699
+ (removedHistoryInfo.runtimeSnapshot && removedHistoryInfo.runtimeSnapshot.hostPath) || '';
7823
8700
  const { removedContainer } = removeContainerIdempotent(ctx, sessionRef.containerName);
8701
+ let removedWorkDir = false;
8702
+ if (removeHistory) {
8703
+ // 连同历史一起删时,顺手清掉空的工作目录(只 rmdir,非空删不动)
8704
+ removedWorkDir = await webStorage.removeEmptyWorkDir({ hostPath: workDirHint, ...getStorageRootsConfig(ctx, state) }).catch(() => false);
8705
+ }
7824
8706
 
7825
8707
  if (removeHistory) {
7826
8708
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
@@ -7833,6 +8715,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7833
8715
  sendJson(res, 200, {
7834
8716
  removed: true,
7835
8717
  removedHistory: removeHistory,
8718
+ removedWorkDir,
7836
8719
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
7837
8720
  });
7838
8721
  }
@@ -7975,6 +8858,7 @@ async function startWebServer(options) {
7975
8858
  ? options.agentRawOutputMaxChars
7976
8859
  : AGENT_RAW_OUTPUT_MAX_CHARS,
7977
8860
  // 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
8861
+ orphanPollMs: Number.isFinite(options.orphanPollMs) && options.orphanPollMs > 0 ? options.orphanPollMs : WEB_ORPHAN_POLL_MS,
7978
8862
  agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
7979
8863
  ? options.agentStepFallbackMs
7980
8864
  : agentQueue.STEP_FALLBACK_MS,
@@ -7993,7 +8877,15 @@ async function startWebServer(options) {
7993
8877
  const state = {
7994
8878
  webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
7995
8879
  webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
7996
- authSessions: new Map(),
8880
+ orphanTimers: new Set(),
8881
+ authStore: createAuthStore({
8882
+ file: options.authSessionsFile || '',
8883
+ user: ctx.authUser,
8884
+ pass: ctx.authPass,
8885
+ ttlMs: WEB_AUTH_TTL_SECONDS * 1000,
8886
+ now: Date.now,
8887
+ warn: message => ctx.logger.warn(message)
8888
+ }),
7997
8889
  terminalSessions: new Map(),
7998
8890
  agentRuns: new Map(),
7999
8891
  // imageRef -> { status: 'pulling'|'failed', message, promise }
@@ -8206,6 +9098,7 @@ async function startWebServer(options) {
8206
9098
  });
8207
9099
 
8208
9100
  startStaleSetupCleanup(ctx);
9101
+ state.orphanReconcileReady = reconcileOrphanTurns(ctx, state).catch(e => ctx.logger.warn('orphan reconcile failed', { message: e && e.message }));
8209
9102
  startContainerNetworkWatch(ctx, state);
8210
9103
  if (ctx.autoPullImage) {
8211
9104
  startWebImagePrepare(ctx, state);
@@ -8219,6 +9112,9 @@ async function startWebServer(options) {
8219
9112
  close: () => new Promise(resolve => {
8220
9113
  ctx.logger.info('web server closing');
8221
9114
  flushWebSessionHistories();
9115
+ state.authStore.close();
9116
+ state.orphanTimers.forEach(timer => clearInterval(timer));
9117
+ state.orphanTimers.clear();
8222
9118
  if (state.networkWatch) {
8223
9119
  state.networkWatch.stop();
8224
9120
  state.networkWatch = null;