@xcanwin/manyoyo 8.6.0 → 8.6.3

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
@@ -13,7 +13,7 @@ const { buildContainerRunArgs, hasNetworkArg } = require('../container-run');
13
13
  const { buildContainerIntegration, mergeIntegration } = require('../plugin');
14
14
  const { applyAptMirror } = require('../mirrors');
15
15
  const containerState = require('../container-state');
16
- const { buildExecArgs, resolveContainerId } = require('../container-exec');
16
+ const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
17
17
  const { createNetworkManager, NETWORK_NAME } = require('../container-network');
18
18
  const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../network-policy');
19
19
  const { withEnvEndpoints } = require('../network-endpoints');
@@ -23,7 +23,7 @@ const { isBrowserBackground } = require('../browser-background');
23
23
  const { createDeniedNotifier } = require('./denied-notify');
24
24
  const { domainRuleAllows } = require('../network-policy');
25
25
  const { createPortForwarder } = require('../port-forward');
26
- const { createContainerManageRoutes, pendingRisks } = require('./container-manage');
26
+ const { createContainerManageRoutes, pendingRisks, STALE_PAGE_MESSAGE } = require('./container-manage');
27
27
  const { resolveManageOptions } = require('../container-manage-options');
28
28
  const { defaultPolicy, usesProxy } = require('../network-policy');
29
29
  const { normalizeMirrors } = require('../runtime-normalizers');
@@ -56,6 +56,9 @@ const {
56
56
  projectSessionEvents
57
57
  } = require('../core/events');
58
58
  const { FileEventStore } = require('../core/event-store');
59
+ const historyStore = require('./history-store');
60
+ const messagesView = require('./messages-view');
61
+ const zlib = require('zlib');
59
62
  const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path');
60
63
  const { isValidLogDateTag, readServeLogEntries } = require('../serve-log-reader');
61
64
 
@@ -68,6 +71,14 @@ const WEB_TERMINAL_DEFAULT_COLS = 120;
68
71
  const WEB_TERMINAL_DEFAULT_ROWS = 36;
69
72
  const WEB_TERMINAL_MIN_COLS = 40;
70
73
  const WEB_TERMINAL_MIN_ROWS = 12;
74
+ // resize 的下限比建连时的 40×12 低:手机竖屏的真实列数可以小于 40,夹到 40 容器就会比网页宽、行尾被切
75
+ const WEB_TERMINAL_RESIZE_MIN_COLS = 10;
76
+ const WEB_TERMINAL_RESIZE_MIN_ROWS = 4;
77
+ const WEB_TERMINAL_MAX_COLS = 500;
78
+ const WEB_TERMINAL_MAX_ROWS = 200;
79
+ // resize 节流:窗口拖动时前端虽有防抖,服务端仍保证两次容器内 stty 之间至少隔这么久
80
+ const WEB_TERMINAL_RESIZE_MIN_INTERVAL_MS = 150;
81
+ const WEB_TERMINAL_ID_PATTERN = /^[0-9a-f]{16}$/;
71
82
  // 终端 WebSocket 空闲心跳:纯交互式终端可能长时间没有任何数据,
72
83
  // 反向代理 / 移动网络的空闲超时(常见 60s)会悄悄掐断连接,这里定期发 ping
73
84
  // 帧制造流量。判死放宽到连续 3 次没回 pong(约 90s),手机切后台导致连接被
@@ -197,6 +208,8 @@ function createEmptyWebAgentSession(agentId, agentName) {
197
208
  createdAt: null,
198
209
  updatedAt: null,
199
210
  messages: [],
211
+ // 稳定前缀被改动时(删除非 pending 消息、克隆)必须换新值,增量同步据此让前端整页重置
212
+ epoch: messagesView.createEpoch(),
200
213
  events: [],
201
214
  lastResumeAt: null,
202
215
  lastResumeOk: null,
@@ -237,6 +250,9 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
237
250
  createdAt: typeof source.createdAt === 'string' ? source.createdAt : null,
238
251
  updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : null,
239
252
  messages: Array.isArray(source.messages) ? source.messages : [],
253
+ epoch: typeof source.epoch === 'string' && /^[0-9a-f]{8,32}$/.test(source.epoch)
254
+ ? source.epoch
255
+ : messagesView.createEpoch(),
240
256
  events: Array.isArray(source.events)
241
257
  ? source.events.filter(event => {
242
258
  try {
@@ -342,40 +358,40 @@ function normalizeWebHistoryRecord(containerName, rawData) {
342
358
  function loadWebSessionHistory(webHistoryDir, containerName) {
343
359
  ensureWebHistoryDir(webHistoryDir);
344
360
  const filePath = getWebHistoryFile(webHistoryDir, containerName);
345
- if (!fs.existsSync(filePath)) {
346
- return normalizeWebHistoryRecord(containerName, {});
347
- }
361
+ const cached = historyStore.read(filePath, raw => normalizeWebHistoryRecord(containerName, raw));
362
+ return cached ? cached.value : normalizeWebHistoryRecord(containerName, {});
363
+ }
348
364
 
349
- try {
350
- return normalizeWebHistoryRecord(containerName, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
351
- } catch (e) {
352
- return normalizeWebHistoryRecord(containerName, {});
353
- }
365
+ // 同一历史的内存版本号,/messages 的 ETag 用它(不用 mtime:同一毫秒内的两次写入会撞)
366
+ function getWebSessionHistoryVersion(webHistoryDir, containerName) {
367
+ return historyStore.getVersion(getWebHistoryFile(webHistoryDir, containerName));
354
368
  }
355
369
 
356
- function saveWebSessionHistory(webHistoryDir, containerName, history) {
357
- ensureWebHistoryDir(webHistoryDir);
358
- const filePath = getWebHistoryFile(webHistoryDir, containerName);
359
- const normalized = normalizeWebHistoryRecord(containerName, history);
360
- const runtimeMeta = getAgentRuntimeMeta(normalized.agentPromptCommand || '');
361
- const defaultAgent = getWebAgentSession(normalized, WEB_DEFAULT_AGENT_ID);
362
- const legacyCompatible = {
363
- ...normalized,
370
+ function serializeWebHistory(history) {
371
+ const runtimeMeta = getAgentRuntimeMeta(history.agentPromptCommand || '');
372
+ return JSON.stringify({
373
+ ...history,
364
374
  agentProgram: runtimeMeta.agentProgram || '',
365
375
  resumeSupported: runtimeMeta.resumeSupported === true
366
- };
367
- if (defaultAgent) {
368
- legacyCompatible.messages = Array.isArray(defaultAgent.messages) ? defaultAgent.messages : [];
369
- legacyCompatible.lastResumeAt = defaultAgent.lastResumeAt || null;
370
- legacyCompatible.lastResumeOk = typeof defaultAgent.lastResumeOk === 'boolean' ? defaultAgent.lastResumeOk : null;
371
- legacyCompatible.lastResumeError = defaultAgent.lastResumeError || '';
372
- }
373
- fs.writeFileSync(filePath, JSON.stringify(legacyCompatible, null, 4));
376
+ });
377
+ }
378
+
379
+ // defer: 只改内存缓存,最迟 1 秒后落盘(流式过程中的 trace 快照用);不传则立即原子写
380
+ function saveWebSessionHistory(webHistoryDir, containerName, history, options = {}) {
381
+ ensureWebHistoryDir(webHistoryDir);
382
+ const filePath = getWebHistoryFile(webHistoryDir, containerName);
383
+ const value = options.defer === true ? history : normalizeWebHistoryRecord(containerName, history);
384
+ historyStore.write(filePath, value, serializeWebHistory, { defer: options.defer === true });
385
+ }
386
+
387
+ function flushWebSessionHistories() {
388
+ historyStore.flushAll();
374
389
  }
375
390
 
376
391
  function removeWebSessionHistory(webHistoryDir, containerName) {
377
392
  ensureWebHistoryDir(webHistoryDir);
378
393
  const filePath = getWebHistoryFile(webHistoryDir, containerName);
394
+ historyStore.remove(filePath);
379
395
  if (fs.existsSync(filePath)) {
380
396
  fs.unlinkSync(filePath);
381
397
  }
@@ -399,7 +415,9 @@ function removeContainerIdempotent(ctx, containerName) {
399
415
  function removeAllAgentHistoryArtifacts(webHistoryDir, containerName, agentIds) {
400
416
  const eventStore = new FileEventStore(webHistoryDir);
401
417
  (Array.isArray(agentIds) ? agentIds : []).forEach(agentId => {
402
- eventStore.remove(buildWebSessionKey(containerName, agentId));
418
+ const aggregateId = buildWebSessionKey(containerName, agentId);
419
+ controlEventSeqCache.delete(`${path.resolve(webHistoryDir)}|${aggregateId}`);
420
+ eventStore.remove(aggregateId);
403
421
  });
404
422
  }
405
423
 
@@ -557,23 +575,56 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
557
575
  return message;
558
576
  }
559
577
 
560
- function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}) {
578
+ // 事件日志(FileEventStore)每条事件立即追加,而历史快照里的 events 是合并写盘的(强杀后可能落后)。
579
+ // seq 必须以日志为准:进程内记住每个 aggregate 最后追加的 seq,第一次用时从日志读一次。
580
+ const controlEventSeqCache = new Map();
581
+
582
+ function resolveNextControlSeq(eventStore, cacheKey, aggregateId, snapshotEvents, refresh) {
583
+ let last = refresh ? undefined : controlEventSeqCache.get(cacheKey);
584
+ if (last === undefined) {
585
+ const logged = eventStore.read(aggregateId);
586
+ if (logged.length) {
587
+ last = logged[logged.length - 1].seq;
588
+ } else {
589
+ // 日志为空:只有旧版本留下的历史快照里有事件,沿用它的 seq
590
+ const lastSnapshot = snapshotEvents.length ? snapshotEvents[snapshotEvents.length - 1] : null;
591
+ last = lastSnapshot ? lastSnapshot.seq : 0;
592
+ }
593
+ }
594
+ return last + 1;
595
+ }
596
+
597
+ function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}, options = {}) {
561
598
  const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
562
599
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
563
600
  const events = Array.isArray(agentSession.events) ? agentSession.events : [];
564
- const lastEvent = events.length ? events[events.length - 1] : null;
565
- const event = createControlEvent({
566
- type,
567
- aggregateId: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
568
- seq: lastEvent ? lastEvent.seq + 1 : 1,
569
- data
570
- });
601
+ const aggregateId = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
602
+ const cacheKey = `${path.resolve(webHistoryDir)}|${aggregateId}`;
571
603
  const eventStore = new FileEventStore(webHistoryDir);
572
- eventStore.append(event);
604
+ let event = null;
605
+ for (let attempt = 0; attempt < 2 && !event; attempt += 1) {
606
+ const candidate = createControlEvent({
607
+ type,
608
+ aggregateId,
609
+ seq: resolveNextControlSeq(eventStore, cacheKey, aggregateId, events, attempt > 0),
610
+ data
611
+ });
612
+ try {
613
+ eventStore.append(candidate);
614
+ event = candidate;
615
+ } catch (error) {
616
+ // 缓存的 seq 与日志对不上(日志被别处改过):丢掉缓存按日志重算一次
617
+ controlEventSeqCache.delete(cacheKey);
618
+ if (attempt > 0) {
619
+ throw error;
620
+ }
621
+ }
622
+ }
623
+ controlEventSeqCache.set(cacheKey, event.seq);
573
624
  agentSession.events = [...events, event].slice(-WEB_HISTORY_MAX_MESSAGES);
574
625
  agentSession.updatedAt = event.timestamp;
575
626
  history.updatedAt = event.timestamp;
576
- saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
627
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history, { defer: options.defer === true });
577
628
  return event;
578
629
  }
579
630
 
@@ -591,7 +642,7 @@ function loadWebSessionControlEvents(webHistoryDir, sessionRef, fallbackEvents =
591
642
  return Array.isArray(fallbackEvents) ? fallbackEvents : [];
592
643
  }
593
644
 
594
- function patchWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messageId, patch) {
645
+ function patchWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messageId, patch, options = {}) {
595
646
  const sessionRef = typeof sessionRefOrContainerName === 'string'
596
647
  ? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
597
648
  : sessionRefOrContainerName;
@@ -615,7 +666,7 @@ function patchWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messag
615
666
  const timestamp = new Date().toISOString();
616
667
  agentSession.updatedAt = timestamp;
617
668
  history.updatedAt = timestamp;
618
- saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
669
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history, { defer: options.defer === true });
619
670
  return message;
620
671
  }
621
672
 
@@ -634,6 +685,9 @@ function removeWebSessionMessage(webHistoryDir, sessionRefOrContainerName, messa
634
685
  return null;
635
686
  }
636
687
  const removed = agentSession.messages.splice(index, 1)[0] || null;
688
+ if (removed && removed.pending !== true) {
689
+ agentSession.epoch = messagesView.createEpoch();
690
+ }
637
691
  const timestamp = new Date().toISOString();
638
692
  agentSession.updatedAt = timestamp;
639
693
  history.updatedAt = timestamp;
@@ -829,7 +883,7 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
829
883
  const hasJson = /(?:^|\s)--json(?:\s|$)/.test(suffix);
830
884
  const injectedFlags = hasJson ? '' : ' --json';
831
885
  const hasModel = /(?:^|\s)(?:-m|--model)(?:\s|$)/.test(suffix);
832
- const modelFlag = model && !hasModel ? ` --model ${model}` : '';
886
+ const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
833
887
  if (sessionId) {
834
888
  const promptIndex = suffix.indexOf('{prompt}');
835
889
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
@@ -877,7 +931,7 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
877
931
  }
878
932
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
879
933
  if (model) {
880
- flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
934
+ flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
881
935
  }
882
936
  const claudeTemplate = prependAgentFlags(
883
937
  templateText,
@@ -896,7 +950,7 @@ function buildGeminiAgentExecCommand(template, prompt, options = {}) {
896
950
  ];
897
951
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
898
952
  if (model) {
899
- flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
953
+ flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
900
954
  }
901
955
  const geminiTemplate = prependAgentFlags(
902
956
  templateText,
@@ -922,7 +976,7 @@ function buildOpenCodeAgentExecCommand(template, prompt, options = {}) {
922
976
  }
923
977
  const model = options && typeof options.model === 'string' ? options.model.trim() : '';
924
978
  if (model) {
925
- flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
979
+ flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
926
980
  }
927
981
  const opencodeTemplate = prependAgentFlags(
928
982
  templateText,
@@ -1319,6 +1373,61 @@ function extractOpenCodeModelsFromOutput(text) {
1319
1373
  .map(value => ({ value, label: value, description: '' }));
1320
1374
  }
1321
1375
 
1376
+ // 模型目录要在容器里起一个 Agent 进程才能问到,很慢:按“容器 + Agent 程序”缓存,
1377
+ // 并合并同一时刻的重复请求。缓存挂在 ctx 上(随 serve 实例走),不放模块级,避免测试之间串味
1378
+ const MODEL_CATALOG_TTL_MS = 10 * 60 * 1000;
1379
+ const modelCatalogCaches = new WeakMap();
1380
+
1381
+ async function fetchAgentModelCatalogCached(ctx, containerName, agentProgram) {
1382
+ let cache = modelCatalogCaches.get(ctx);
1383
+ if (!cache) {
1384
+ cache = new Map();
1385
+ modelCatalogCaches.set(ctx, cache);
1386
+ }
1387
+ const key = `${containerName}\u0000${agentProgram}`;
1388
+ const hit = cache.get(key);
1389
+ if (hit && (hit.pending || Date.now() - hit.at < MODEL_CATALOG_TTL_MS)) {
1390
+ return hit.pending || hit.models;
1391
+ }
1392
+ const pending = fetchAgentModelCatalog(ctx, containerName, agentProgram).then(models => {
1393
+ // 探测失败(超时 / 容器没起来)返回空数组,不缓存,下次还能重试
1394
+ if (models.length) cache.set(key, { at: Date.now(), models });
1395
+ else cache.delete(key);
1396
+ return models;
1397
+ }, error => {
1398
+ cache.delete(key);
1399
+ throw error;
1400
+ });
1401
+ cache.set(key, { pending });
1402
+ return pending;
1403
+ }
1404
+
1405
+ // 以会话实际配置的 env 生成模型选项。defaultModel 是“跟随默认”实际用的模型;
1406
+ // Claude 另有按档位别名(opus / sonnet / haiku …)一行一个的选项:value 是别名(--model opus 由 CLI 按 env 解析),
1407
+ // label 是该档位对应的真实模型名。不同档位指向同一个模型也各占一行,不合并。
1408
+ // 只读 *_MODEL 这类非敏感键,容器可写的 env 只用来展示,不参与任何安全判断
1409
+ const CLAUDE_TIER_ORDER = ['opus', 'fable', 'sonnet', 'haiku'];
1410
+
1411
+ function buildEnvModelOptions(agentProgram, env) {
1412
+ const source = env && typeof env === 'object' ? env : {};
1413
+ const defaultKey = { claude: 'ANTHROPIC_MODEL', codex: 'OPENAI_MODEL', opencode: 'OPENAI_MODEL', gemini: 'GEMINI_MODEL' }[agentProgram];
1414
+ if (!defaultKey) return { defaultModel: '', options: [] };
1415
+ const defaultModel = normalizeStoredAgentModelValue(source[defaultKey]);
1416
+ if (agentProgram !== 'claude') return { defaultModel, options: [] };
1417
+ const tiers = [];
1418
+ Object.keys(source).forEach(key => {
1419
+ const matched = key.match(/^ANTHROPIC_DEFAULT_([A-Z0-9]+)_MODEL$/);
1420
+ const model = matched ? normalizeStoredAgentModelValue(source[key]) : '';
1421
+ if (model) tiers.push({ alias: matched[1].toLowerCase(), model });
1422
+ });
1423
+ const rank = alias => (CLAUDE_TIER_ORDER.includes(alias) ? CLAUDE_TIER_ORDER.indexOf(alias) : CLAUDE_TIER_ORDER.length);
1424
+ tiers.sort((x, y) => rank(x.alias) - rank(y.alias) || x.alias.localeCompare(y.alias));
1425
+ return {
1426
+ defaultModel,
1427
+ options: tiers.map(tier => ({ value: tier.alias, label: tier.model, description: tier.alias }))
1428
+ };
1429
+ }
1430
+
1322
1431
  async function fetchAgentModelCatalog(ctx, containerName, agentProgram) {
1323
1432
  if (agentProgram === 'claude') {
1324
1433
  const requestLine = JSON.stringify({
@@ -2405,6 +2514,24 @@ function redactConfigObject(value) {
2405
2514
  return result;
2406
2515
  }
2407
2516
 
2517
+ function computeContentEtag(text) {
2518
+ return crypto.createHash('sha256').update(String(text == null ? '' : text)).digest('hex');
2519
+ }
2520
+
2521
+ // 规范化 If-Match:去掉引号和 W/ 前缀、统一小写;* 表示“只要存在就匹配”(原样保留)
2522
+ function normalizeIfMatchHeader(value) {
2523
+ const text = String(value).trim();
2524
+ if (text === '*') {
2525
+ return '*';
2526
+ }
2527
+ return text.replace(/^W\//, '').replace(/"/g, '').trim().toLowerCase();
2528
+ }
2529
+
2530
+ function ifMatchSatisfied(headerValue, currentEtag) {
2531
+ const normalized = normalizeIfMatchHeader(headerValue);
2532
+ return normalized === '*' || normalized === String(currentEtag).toLowerCase();
2533
+ }
2534
+
2408
2535
  function buildSafeWebConfigSnapshot(snapshot, ctx) {
2409
2536
  const parsed = snapshot && snapshot.parseError ? {} : toPlainObject(snapshot && snapshot.parsed);
2410
2537
  let raw = '';
@@ -2425,6 +2552,8 @@ function buildSafeWebConfigSnapshot(snapshot, ctx) {
2425
2552
  return {
2426
2553
  path: snapshot && snapshot.path ? snapshot.path : path.resolve(getDefaultWebConfigPath()),
2427
2554
  raw,
2555
+ // 版本号 = 磁盘原文的 sha256(不是脱敏后的文本:只有密钥变了也要能识别),只返回哈希,不泄露内容
2556
+ etag: computeContentEtag(snapshot && typeof snapshot.raw === 'string' ? snapshot.raw : ''),
2428
2557
  parsed: redactConfigObject(parsed),
2429
2558
  defaults: redactConfigObject(buildConfigDefaults(ctx, parsed)),
2430
2559
  parseError: snapshot && snapshot.parseError ? snapshot.parseError : null,
@@ -3066,6 +3195,7 @@ function cloneWebAgentSessionsDeep(sourceHistory) {
3066
3195
  : {};
3067
3196
  Object.keys(agents).forEach(agentId => {
3068
3197
  cloned[agentId] = JSON.parse(JSON.stringify(agents[agentId]));
3198
+ cloned[agentId].epoch = messagesView.createEpoch();
3069
3199
  });
3070
3200
  return cloned;
3071
3201
  }
@@ -3782,8 +3912,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
3782
3912
  const fs = require('fs');
3783
3913
  const path = require('path');
3784
3914
 
3915
+ const crypto = require('crypto');
3916
+
3785
3917
  const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3786
3918
  const maxBytes = ${String(maxBytes)};
3919
+ const editMaxBytes = ${String(WEB_FILE_EDIT_MAX_BYTES)};
3787
3920
  const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
3788
3921
 
3789
3922
  function looksBinary(buffer) {
@@ -3834,11 +3967,18 @@ try {
3834
3967
  truncated: maxBytes > 0 && size > maxBytes
3835
3968
  }));
3836
3969
  } else {
3970
+ // 版本号:整个文件内容的 sha256(保存时带回来,容器里的文件被改过就能发现);只给可编辑的小文件算
3971
+ let etag = '';
3972
+ if (size < editMaxBytes) {
3973
+ const whole = readBytes === size ? buffer : fs.readFileSync(realPath);
3974
+ etag = crypto.createHash('sha256').update(whole).digest('hex');
3975
+ }
3837
3976
  process.stdout.write(JSON.stringify({
3838
3977
  path: realPath,
3839
3978
  kind: 'text',
3840
3979
  size,
3841
3980
  truncated: maxBytes > 0 && size > maxBytes,
3981
+ etag,
3842
3982
  content: buffer.toString('utf8')
3843
3983
  }));
3844
3984
  }
@@ -3876,12 +4016,17 @@ try {
3876
4016
  `);
3877
4017
  }
3878
4018
 
3879
- function buildContainerFileWriteCommand(requestedPath, content) {
4019
+ // expectedEtag:读取时拿到的内容 sha256。写入前在容器里重新计算当前内容的哈希,不一致就不写(返回 conflict)。
4020
+ // 检查和写入之间仍有一个极短的窗口(同一个脚本里的两条语句之间),可以接受。
4021
+ function buildContainerFileWriteCommand(requestedPath, content, expectedEtag) {
3880
4022
  return buildWebContainerNodeCommand(`
3881
4023
  // __MANYOYO_FS_WRITE__
3882
4024
  const fs = require('fs');
4025
+ const crypto = require('crypto');
3883
4026
 
3884
4027
  const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
4028
+ const editMaxBytes = ${String(WEB_FILE_EDIT_MAX_BYTES)};
4029
+ const expectedEtag = ${JSON.stringify(String(expectedEtag == null ? '' : expectedEtag))};
3885
4030
  const nextContent = ${JSON.stringify(String(content == null ? '' : content))};
3886
4031
 
3887
4032
  try {
@@ -3891,13 +4036,26 @@ try {
3891
4036
  throw new Error('目标不是文件: ' + realPath);
3892
4037
  }
3893
4038
 
3894
- fs.writeFileSync(realPath, nextContent, 'utf8');
3895
- const savedStat = fs.statSync(realPath);
3896
- process.stdout.write(JSON.stringify({
3897
- path: realPath,
3898
- saved: true,
3899
- size: savedStat.size
3900
- }));
4039
+ // 太大的文件不整个读进内存比对:前端本来就不允许编辑,直接给明确的提示(而不是让用户看到“冲突”)
4040
+ if (stat.size >= editMaxBytes) {
4041
+ throw new Error('文件过大,当前仅支持编辑小于 2MB 的文本文件');
4042
+ }
4043
+ const currentEtag = crypto.createHash('sha256').update(fs.readFileSync(realPath)).digest('hex');
4044
+ if (expectedEtag !== '*' && currentEtag !== expectedEtag) {
4045
+ process.stdout.write(JSON.stringify({
4046
+ conflict: true,
4047
+ error: '文件已在容器里被修改'
4048
+ }));
4049
+ } else {
4050
+ fs.writeFileSync(realPath, nextContent, 'utf8');
4051
+ const savedStat = fs.statSync(realPath);
4052
+ process.stdout.write(JSON.stringify({
4053
+ path: realPath,
4054
+ saved: true,
4055
+ size: savedStat.size,
4056
+ etag: crypto.createHash('sha256').update(Buffer.from(nextContent, 'utf8')).digest('hex')
4057
+ }));
4058
+ }
3901
4059
  } catch (e) {
3902
4060
  process.stdout.write(JSON.stringify({
3903
4061
  error: e && e.message ? e.message : '保存文件失败'
@@ -4188,22 +4346,140 @@ async function readJsonBody(req) {
4188
4346
  }
4189
4347
  }
4190
4348
 
4191
- function sendJson(res, statusCode, payload, extraHeaders = {}) {
4192
- res.writeHead(statusCode, {
4193
- 'Content-Type': 'application/json; charset=utf-8',
4194
- 'Cache-Control': 'no-store',
4349
+ // 前端发送前为提示词生成的 id:服务端原样存进 user 消息,前端据此判断“这条是不是已经发出去了”
4350
+ function readClientMessageId(res, payload) {
4351
+ const raw = payload && payload.clientMessageId;
4352
+ if (raw === undefined || raw === null || raw === '') {
4353
+ return { value: '' };
4354
+ }
4355
+ if (typeof raw !== 'string' || !messagesView.CLIENT_MESSAGE_ID_PATTERN.test(raw)) {
4356
+ sendJson(res, 400, { error: 'clientMessageId 格式不正确' });
4357
+ return null;
4358
+ }
4359
+ return { value: raw };
4360
+ }
4361
+
4362
+ // 同一个 clientMessageId 已经出现在该 agent 的消息里:这条提示词服务端已经收到过,不能再执行一遍
4363
+ // (前端在网络中断后重发同一条时,靠它兜住“服务端其实已经收到”的情况)
4364
+ function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
4365
+ if (!clientMessageId) {
4366
+ return false;
4367
+ }
4368
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
4369
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4370
+ const duplicated = Boolean(agentSession) && agentSession.messages.some(
4371
+ message => message && message.role === 'user' && message.clientMessageId === clientMessageId
4372
+ );
4373
+ if (!duplicated) {
4374
+ return false;
4375
+ }
4376
+ sendJson(res, 409, { error: '这条消息已经发出过', duplicate: true });
4377
+ return true;
4378
+ }
4379
+
4380
+ // 检查重复到 user 消息落盘之间可能有 await(容器启动、网络规则就绪):并发的同一条请求也要挡住
4381
+ const inflightClientMessages = new Set();
4382
+
4383
+ // 返回 release():消息落盘(或失败)后调用;已是重复则回 409 并返回 null
4384
+ function claimClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
4385
+ if (!clientMessageId) {
4386
+ return () => {};
4387
+ }
4388
+ if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId)) {
4389
+ return null;
4390
+ }
4391
+ const key = `${path.resolve(webHistoryDir)}|${buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)}|${clientMessageId}`;
4392
+ if (inflightClientMessages.has(key)) {
4393
+ sendJson(res, 409, { error: '这条消息已经发出过', duplicate: true });
4394
+ return null;
4395
+ }
4396
+ inflightClientMessages.add(key);
4397
+ return () => inflightClientMessages.delete(key);
4398
+ }
4399
+
4400
+ const COMPRESS_MIN_BYTES = 1024;
4401
+
4402
+ function acceptsGzip(req) {
4403
+ return Boolean(req && req.headers && /\bgzip\b/i.test(String(req.headers['accept-encoding'] || '')));
4404
+ }
4405
+
4406
+ function etagMatches(req, etag) {
4407
+ const header = req && req.headers ? String(req.headers['if-none-match'] || '') : '';
4408
+ if (!header) {
4409
+ return false;
4410
+ }
4411
+ if (header.trim() === '*') {
4412
+ return true;
4413
+ }
4414
+ const strip = value => value.trim().replace(/^W\//, '');
4415
+ return header.split(',').some(candidate => strip(candidate) === strip(etag));
4416
+ }
4417
+
4418
+ // 统一的响应出口:可选 ETag(命中 If-None-Match 回 304)+ >1KB 的内容异步 gzip。
4419
+ // 只用于一次性 JSON/HTML;NDJSON 流和 WebSocket 不走这里,压缩会让它们被缓冲、失去实时性。
4420
+ function sendBody(res, statusCode, contentType, body, extraHeaders = {}, options = {}) {
4421
+ const req = res.req;
4422
+ const headers = {
4423
+ 'Content-Type': contentType,
4424
+ 'Cache-Control': options.etag ? 'no-cache' : 'no-store',
4425
+ Vary: 'Accept-Encoding',
4195
4426
  ...extraHeaders
4196
- });
4197
- res.end(JSON.stringify(payload));
4427
+ };
4428
+ if (options.etag) {
4429
+ headers.ETag = options.etag;
4430
+ if (statusCode === 200 && etagMatches(req, options.etag)) {
4431
+ delete headers['Content-Type'];
4432
+ res.writeHead(304, headers);
4433
+ res.end();
4434
+ return;
4435
+ }
4436
+ }
4437
+ const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf-8');
4438
+ const finish = (payload, encoding) => {
4439
+ if (res.destroyed || res.writableEnded) {
4440
+ return;
4441
+ }
4442
+ if (encoding) {
4443
+ headers['Content-Encoding'] = encoding;
4444
+ }
4445
+ headers['Content-Length'] = payload.length;
4446
+ res.writeHead(statusCode, headers);
4447
+ res.end(payload);
4448
+ };
4449
+ if (acceptsGzip(req) && buffer.length > COMPRESS_MIN_BYTES) {
4450
+ const compressed = options.gzipBuffer
4451
+ ? Promise.resolve(options.gzipBuffer)
4452
+ : new Promise((resolve, reject) => {
4453
+ zlib.gzip(buffer, { level: 6 }, (error, result) => (error ? reject(error) : resolve(result)));
4454
+ });
4455
+ compressed.then(result => finish(result, 'gzip'), () => finish(buffer, ''));
4456
+ return;
4457
+ }
4458
+ finish(buffer, '');
4459
+ }
4460
+
4461
+ // options.etag 传入时按它做条件请求;options.autoEtag 则用响应体的 sha1(响应体小、生成成本低的接口用)
4462
+ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
4463
+ const text = JSON.stringify(payload);
4464
+ const etag = options.etag
4465
+ || (options.autoEtag === true && statusCode === 200
4466
+ ? `W/"${crypto.createHash('sha1').update(text).digest('hex')}"`
4467
+ : '');
4468
+ sendBody(res, statusCode, 'application/json; charset=utf-8', text, extraHeaders, { etag });
4198
4469
  }
4199
4470
 
4200
4471
  function sendNdjson(res, payload) {
4201
4472
  res.write(`${JSON.stringify(payload)}\n`);
4202
4473
  }
4203
4474
 
4475
+ const WEB_STREAM_FLUSH_EVENT_TYPES = new Set(['process.exited', 'process.interrupted', 'agent.turn.failed']);
4476
+
4204
4477
  function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
4205
4478
  return (payload, type, data = {}) => {
4206
- const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data);
4479
+ // 流式过程中的事件只改内存(最迟 1 秒落盘);结束类事件立即写盘,顺带写出之前攒着的内容
4480
+ const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data, {
4481
+ defer: !WEB_STREAM_FLUSH_EVENT_TYPES.has(type)
4482
+ });
4207
4483
  sendNdjson(res, { ...payload, controlEvent });
4208
4484
  };
4209
4485
  }
@@ -4292,13 +4568,8 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
4292
4568
  saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
4293
4569
  }
4294
4570
 
4295
- function sendHtml(res, statusCode, html, extraHeaders = {}) {
4296
- res.writeHead(statusCode, {
4297
- 'Content-Type': 'text/html; charset=utf-8',
4298
- 'Cache-Control': 'no-store',
4299
- ...extraHeaders
4300
- });
4301
- res.end(html);
4571
+ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
4572
+ sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
4302
4573
  }
4303
4574
 
4304
4575
  const SETUP_TEST_TIMEOUT_SECONDS = 60;
@@ -4683,11 +4954,54 @@ function buildSessionAudit(ctx, state, sessionRef) {
4683
4954
  };
4684
4955
  }
4685
4956
 
4957
+ // 按文件 mtime + size 缓存 index.html 原文(2MB,之前每次请求都同步读一遍)
4958
+ let indexHtmlCache = { key: '', html: '', variants: new Map() };
4959
+
4686
4960
  function loadIndexHtml() {
4687
- if (!fs.existsSync(INDEX_HTML_PATH)) {
4961
+ let stat = null;
4962
+ try {
4963
+ stat = fs.statSync(INDEX_HTML_PATH);
4964
+ } catch (e) {
4688
4965
  return '<h1>Template Not Found</h1>';
4689
4966
  }
4690
- return fs.readFileSync(INDEX_HTML_PATH, 'utf-8');
4967
+ const key = `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
4968
+ if (indexHtmlCache.key !== key) {
4969
+ indexHtmlCache = { key, html: fs.readFileSync(INDEX_HTML_PATH, 'utf-8'), variants: new Map() };
4970
+ }
4971
+ return indexHtmlCache.html;
4972
+ }
4973
+
4974
+ // 渲染结果(含 serveTitle 处理)+ ETag + gzip 都缓存,文件没变就不重算
4975
+ function getIndexHtmlVariant(kind, ctx) {
4976
+ const html = loadIndexHtml();
4977
+ const variantKey = `${kind}:${typeof ctx.serveTitle === 'string' ? ctx.serveTitle : ''}`;
4978
+ let variant = indexHtmlCache.variants.get(variantKey);
4979
+ if (!variant) {
4980
+ const rendered = kind === 'app' ? renderAppHtml(ctx) : applyServeTitle(html, ctx);
4981
+ variant = {
4982
+ html: rendered,
4983
+ etag: `W/"${crypto.createHash('sha1').update(rendered).digest('hex')}"`,
4984
+ gzipBuffer: null
4985
+ };
4986
+ indexHtmlCache.variants.set(variantKey, variant);
4987
+ }
4988
+ return variant;
4989
+ }
4990
+
4991
+ function sendIndexHtml(res, kind, ctx, extraHeaders = {}) {
4992
+ const variant = getIndexHtmlVariant(kind, ctx);
4993
+ const options = { etag: variant.etag };
4994
+ if (variant.gzipBuffer) {
4995
+ options.gzipBuffer = variant.gzipBuffer;
4996
+ } else if (acceptsGzip(res.req)) {
4997
+ // 第一次压缩的结果留着给后面的请求
4998
+ zlib.gzip(Buffer.from(variant.html, 'utf-8'), { level: 6 }, (error, result) => {
4999
+ if (!error) {
5000
+ variant.gzipBuffer = result;
5001
+ }
5002
+ });
5003
+ }
5004
+ sendHtml(res, 200, variant.html, extraHeaders, options);
4691
5005
  }
4692
5006
 
4693
5007
  function escapeHtmlText(value) {
@@ -4768,7 +5082,31 @@ function sendTerminalEvent(ws, type, payload = {}) {
4768
5082
  ws.send(JSON.stringify({ type, ...payload }));
4769
5083
  }
4770
5084
 
4771
- function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
5085
+ // resize 消息的数值必须是整数且在范围内,否则整条忽略(不夹到边界,避免把异常值悄悄变成合法值)
5086
+ function parseTerminalResize(cols, rows) {
5087
+ if (!Number.isInteger(cols) || !Number.isInteger(rows)) {
5088
+ return null;
5089
+ }
5090
+ if (cols < WEB_TERMINAL_RESIZE_MIN_COLS || cols > WEB_TERMINAL_MAX_COLS
5091
+ || rows < WEB_TERMINAL_RESIZE_MIN_ROWS || rows > WEB_TERMINAL_MAX_ROWS) {
5092
+ return null;
5093
+ }
5094
+ return { cols, rows };
5095
+ }
5096
+
5097
+ // 容器里跑的固定脚本,参数只有三个位置参数(终端 id、列、行),不经过 shell 拼接。
5098
+ // tty 文件在容器里,容器内的进程可以改写它,所以内容只当不可信输入:
5099
+ // 必须是普通文件(不跟符号链接、不碰 FIFO),读最多 64 字节,且整行匹配 /dev/pts/<数字> 才会执行 stty。
5100
+ const TERMINAL_RESIZE_SCRIPT = [
5101
+ 'f="/tmp/manyoyo-term-$1.tty"',
5102
+ '[ -f "$f" ] && [ ! -L "$f" ] || exit 3',
5103
+ 't="$(head -c 64 "$f" 2>/dev/null | head -n 1)"',
5104
+ 'printf \'%s\\n\' "$t" | grep -Eq \'^/dev/pts/[0-9]+$\' || exit 4',
5105
+ 'case "$2$3" in *[!0-9]*) exit 5;; esac',
5106
+ 'exec stty -F "$t" cols "$2" rows "$3"'
5107
+ ].join('\n');
5108
+
5109
+ function spawnWebTerminalProcess(ctx, containerName, cols, rows, termId) {
4772
5110
  const terminalBootstrap = [
4773
5111
  'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
4774
5112
  'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
@@ -4781,6 +5119,10 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4781
5119
  'if [ -f /run/manyoyo-sys/env.sh ]; then',
4782
5120
  ' . /run/manyoyo-sys/env.sh',
4783
5121
  'fi',
5122
+ 'if [ -n "${MANYOYO_TERM_ID:-}" ]; then',
5123
+ ' tty > "/tmp/manyoyo-term-$MANYOYO_TERM_ID.tty" 2>/dev/null || true',
5124
+ ' trap \'rm -f "/tmp/manyoyo-term-$MANYOYO_TERM_ID.tty"\' EXIT',
5125
+ 'fi',
4784
5126
  'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
4785
5127
  ' COLUMNS="$MANYOYO_TERM_COLS"',
4786
5128
  ' LINES="$MANYOYO_TERM_ROWS"',
@@ -4810,7 +5152,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4810
5152
  `TERM=${termValue}`,
4811
5153
  `COLORTERM=${colorTermValue}`,
4812
5154
  `MANYOYO_TERM_COLS=${String(cols)}`,
4813
- `MANYOYO_TERM_ROWS=${String(rows)}`
5155
+ `MANYOYO_TERM_ROWS=${String(rows)}`,
5156
+ `MANYOYO_TERM_ID=${termId}`
4814
5157
  ],
4815
5158
  command: ['/bin/bash', '-lc', terminalBootstrap]
4816
5159
  }, { stdio: ['pipe', 'pipe', 'pipe'] });
@@ -4818,7 +5161,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4818
5161
 
4819
5162
  function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4820
5163
  const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
4821
- const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows);
5164
+ const termId = crypto.randomBytes(8).toString('hex');
5165
+ const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows, termId);
4822
5166
  const session = {
4823
5167
  id: sessionId,
4824
5168
  containerName,
@@ -4861,6 +5205,10 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4861
5205
  }
4862
5206
  session.closing = true;
4863
5207
  clearInterval(pingTimer);
5208
+ if (resizeState.timer) {
5209
+ clearTimeout(resizeState.timer);
5210
+ resizeState.timer = null;
5211
+ }
4864
5212
  state.terminalSessions.delete(sessionId);
4865
5213
  if (ptyProcess && !ptyProcess.killed) {
4866
5214
  ptyProcess.kill('SIGTERM');
@@ -4872,6 +5220,72 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4872
5220
  }
4873
5221
  };
4874
5222
 
5223
+ // 把前端新的窗口大小同步给容器里的伪终端,内核会自动给前台进程发 SIGWINCH。
5224
+ // 数值严格校验;节流:同一时刻只有一个 exec 在跑,期间的新尺寸只保留最后一个
5225
+ const resizeState = { running: false, pending: null, last: null, lastAt: 0, timer: null, retries: 0 };
5226
+ const runTerminalResize = () => {
5227
+ if (resizeState.running || !resizeState.pending || session.closing) {
5228
+ return;
5229
+ }
5230
+ const wait = resizeState.lastAt + WEB_TERMINAL_RESIZE_MIN_INTERVAL_MS - Date.now();
5231
+ if (wait > 0) {
5232
+ if (!resizeState.timer) {
5233
+ resizeState.timer = setTimeout(() => {
5234
+ resizeState.timer = null;
5235
+ runTerminalResize();
5236
+ }, wait);
5237
+ }
5238
+ return;
5239
+ }
5240
+ const size = resizeState.pending;
5241
+ resizeState.pending = null;
5242
+ resizeState.running = true;
5243
+ resizeState.lastAt = Date.now();
5244
+ let child = null;
5245
+ let killTimer = null;
5246
+ const done = code => {
5247
+ if (!resizeState.running) {
5248
+ return;
5249
+ }
5250
+ clearTimeout(killTimer);
5251
+ resizeState.running = false;
5252
+ resizeState.lastAt = Date.now();
5253
+ if (code !== 0 && !resizeState.pending && !session.closing && resizeState.retries < 3) {
5254
+ // 失败(比如 tty 文件还没写好):同尺寸的上报会被去重丢掉,所以这里自己重试几次
5255
+ resizeState.retries += 1;
5256
+ resizeState.pending = size;
5257
+ } else if (code === 0) {
5258
+ resizeState.retries = 0;
5259
+ }
5260
+ runTerminalResize();
5261
+ };
5262
+ try {
5263
+ child = spawnContainerExec(ctx, containerName, {
5264
+ command: ['/bin/bash', '-c', TERMINAL_RESIZE_SCRIPT, 'manyoyo-resize', termId, String(size.cols), String(size.rows)]
5265
+ }, { stdio: 'ignore' });
5266
+ } catch (e) {
5267
+ done(1);
5268
+ return;
5269
+ }
5270
+ killTimer = setTimeout(() => {
5271
+ try { child.kill('SIGKILL'); } catch (e) { /* 已经退出 */ }
5272
+ }, 5000);
5273
+ child.once('close', code => done(code));
5274
+ child.once('error', () => done(1));
5275
+ };
5276
+ const requestTerminalResize = (rawCols, rawRows) => {
5277
+ const size = parseTerminalResize(rawCols, rawRows);
5278
+ if (!size) {
5279
+ return;
5280
+ }
5281
+ if (resizeState.last && resizeState.last.cols === size.cols && resizeState.last.rows === size.rows) {
5282
+ return;
5283
+ }
5284
+ resizeState.last = size;
5285
+ resizeState.pending = size;
5286
+ runTerminalResize();
5287
+ };
5288
+
4875
5289
  ptyProcess.stdout.on('data', chunk => {
4876
5290
  sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
4877
5291
  });
@@ -4918,7 +5332,7 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4918
5332
  }
4919
5333
 
4920
5334
  if (payload.type === 'resize') {
4921
- // 当前后端不直接驱动 docker exec 的 TTY 动态 resize,保留事件以便后续扩展。
5335
+ requestTerminalResize(payload.cols, payload.rows);
4922
5336
  return;
4923
5337
  }
4924
5338
 
@@ -4982,7 +5396,7 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
4982
5396
  });
4983
5397
  return true;
4984
5398
  }
4985
- sendHtml(res, 200, applyServeTitle(loadIndexHtml(), ctx), { 'Referrer-Policy': 'no-referrer' });
5399
+ sendIndexHtml(res, 'login', ctx, { 'Referrer-Policy': 'no-referrer' });
4986
5400
  return true;
4987
5401
  }
4988
5402
 
@@ -5210,8 +5624,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5210
5624
  sendJson(res, 400, { error: '配置内容不能为空' });
5211
5625
  return;
5212
5626
  }
5627
+ const ifMatch = req.headers['if-match'];
5628
+ if (ifMatch === undefined) {
5629
+ sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
5630
+ return;
5631
+ }
5213
5632
 
5633
+ // 从这里到写盘之间不能有 await:读文件、比对版本、写文件在同一个同步段里完成,并发的两个请求不会同时通过检查
5214
5634
  const currentSnapshot = readWebConfigSnapshot(state.webConfigPath);
5635
+ const currentEtag = computeContentEtag(currentSnapshot.raw);
5636
+ if (!ifMatchSatisfied(ifMatch, currentEtag)) {
5637
+ sendJson(res, 409, {
5638
+ error: '配置文件已被其他地方修改',
5639
+ conflict: true,
5640
+ ...buildSafeWebConfigSnapshot(currentSnapshot, ctx)
5641
+ });
5642
+ return;
5643
+ }
5215
5644
  let finalRaw = raw;
5216
5645
  let parsed = null;
5217
5646
  try {
@@ -5228,6 +5657,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5228
5657
  sendJson(res, 200, {
5229
5658
  saved: true,
5230
5659
  path: savePath,
5660
+ etag: computeContentEtag(finalRaw),
5231
5661
  defaults: buildConfigDefaults(ctx, parsed)
5232
5662
  });
5233
5663
  }
@@ -5548,7 +5978,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5548
5978
  })
5549
5979
  .sort(compareWebSessionCreatedDesc);
5550
5980
 
5551
- sendJson(res, 200, { sessions });
5981
+ sendJson(res, 200, { sessions }, {}, { autoEtag: true });
5552
5982
  }
5553
5983
  },
5554
5984
  {
@@ -5688,14 +6118,62 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5688
6118
  if (!sessionRef) {
5689
6119
  return;
5690
6120
  }
6121
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
6122
+ const query = messagesView.parseMessagesQuery(requestUrl.searchParams);
6123
+ if (query.error) {
6124
+ sendJson(res, 400, { error: query.error });
6125
+ return;
6126
+ }
5691
6127
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5692
6128
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
5693
6129
  || createEmptyWebAgentSession(sessionRef.agentId);
6130
+ // 版本号来自历史缓存的进程内计数:不用算响应体就能判断 304,也不会像 mtime 那样在同一毫秒内撞车
6131
+ const version = getWebSessionHistoryVersion(state.webHistoryDir, sessionRef.containerName);
6132
+ const queryDigest = crypto.createHash('sha1')
6133
+ .update(`${sessionRef.agentId}|${requestUrl.search}`)
6134
+ .digest('hex')
6135
+ .slice(0, 12);
6136
+ const etag = `W/"${historyStore.bootId}.${version}.${queryDigest}"`;
6137
+ if (etagMatches(req, etag)) {
6138
+ sendJson(res, 200, {}, {}, { etag });
6139
+ return;
6140
+ }
6141
+ const view = messagesView.buildMessagesResponse(agentSession.messages, agentSession.epoch, query);
5694
6142
  sendJson(res, 200, {
5695
6143
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5696
6144
  containerName: sessionRef.containerName,
5697
6145
  agentId: sessionRef.agentId,
5698
- messages: agentSession.messages
6146
+ ...view
6147
+ }, {}, { etag });
6148
+ }
6149
+ },
6150
+ {
6151
+ method: 'GET',
6152
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages\/([^/]+)\/trace$/),
6153
+ handler: async match => {
6154
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
6155
+ if (!sessionRef) {
6156
+ return;
6157
+ }
6158
+ const messageId = match[2];
6159
+ if (!messagesView.MESSAGE_ID_PATTERN.test(messageId)) {
6160
+ sendJson(res, 400, { error: '消息 id 格式不正确' });
6161
+ return;
6162
+ }
6163
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
6164
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
6165
+ const message = agentSession
6166
+ ? agentSession.messages.find(item => item && item.id === messageId && item.streamTrace === true)
6167
+ : null;
6168
+ if (!message) {
6169
+ sendJson(res, 404, { error: '没有找到这条执行过程' });
6170
+ return;
6171
+ }
6172
+ sendJson(res, 200, {
6173
+ id: message.id,
6174
+ content: message.content,
6175
+ traceEvents: Array.isArray(message.traceEvents) ? message.traceEvents : [],
6176
+ pending: message.pending === true
5699
6177
  });
5700
6178
  }
5701
6179
  },
@@ -5850,12 +6328,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5850
6328
  return;
5851
6329
  }
5852
6330
 
6331
+ const ifMatch = req.headers['if-match'];
6332
+ if (ifMatch === undefined) {
6333
+ sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
6334
+ return;
6335
+ }
6336
+
5853
6337
  await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
5854
6338
  const result = await execJsonCommandInWebContainer(
5855
6339
  ctx,
5856
6340
  sessionRef.containerName,
5857
- buildContainerFileWriteCommand(targetPath, content)
6341
+ buildContainerFileWriteCommand(targetPath, content, normalizeIfMatchHeader(ifMatch))
5858
6342
  );
6343
+ if (result && result.conflict === true) {
6344
+ sendJson(res, 409, { error: result.error || '文件已在容器里被修改', conflict: true });
6345
+ return;
6346
+ }
5859
6347
  if (result && result.error) {
5860
6348
  sendJson(res, 400, { error: result.error });
5861
6349
  return;
@@ -5930,7 +6418,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5930
6418
 
5931
6419
  const containerMap = listWebManyoyoContainers(ctx);
5932
6420
  const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
5933
- sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
6421
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
5934
6422
  }
5935
6423
  },
5936
6424
  {
@@ -5950,7 +6438,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5950
6438
  containerInfo.defaultCommand
5951
6439
  );
5952
6440
  const agentProgram = resolveAgentProgram(effectiveTemplate);
5953
- const models = await fetchAgentModelCatalog(ctx, sessionRef.containerName, agentProgram);
6441
+ const models = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
5954
6442
  sendJson(res, 200, {
5955
6443
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5956
6444
  agentProgram,
@@ -5958,6 +6446,34 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5958
6446
  });
5959
6447
  }
5960
6448
  },
6449
+ {
6450
+ // 毫秒级:只读宿主机上的 env 文件,不进容器;慢的 CLI 目录探测走上面的 /models
6451
+ method: 'GET',
6452
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/model-options$/),
6453
+ handler: async match => {
6454
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
6455
+ if (!sessionRef) {
6456
+ return;
6457
+ }
6458
+ const containerMap = listWebManyoyoContainers(ctx);
6459
+ const containerInfo = containerMap[sessionRef.containerName] || {};
6460
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
6461
+ const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
6462
+ history,
6463
+ sessionRef.agentId,
6464
+ containerInfo.defaultCommand
6465
+ ));
6466
+ let env = {};
6467
+ try {
6468
+ env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
6469
+ } catch (e) { /* 读不到 env 就只给空选项 */ }
6470
+ sendJson(res, 200, {
6471
+ name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
6472
+ agentProgram,
6473
+ ...buildEnvModelOptions(agentProgram, env)
6474
+ });
6475
+ }
6476
+ },
5961
6477
  {
5962
6478
  method: 'POST',
5963
6479
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/model$/),
@@ -6063,9 +6579,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6063
6579
  sendJson(res, 400, { error: 'command 不能为空' });
6064
6580
  return;
6065
6581
  }
6066
-
6067
- await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
6068
- appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', command, { mode: 'command' });
6582
+ const commandClientId = readClientMessageId(res, payload);
6583
+ if (!commandClientId) {
6584
+ return;
6585
+ }
6586
+ const releaseCommandClaim = claimClientMessage(res, state.webHistoryDir, sessionRef, commandClientId.value);
6587
+ if (!releaseCommandClaim) {
6588
+ return;
6589
+ }
6590
+ try {
6591
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
6592
+ appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', command, {
6593
+ mode: 'command',
6594
+ ...(commandClientId.value ? { clientMessageId: commandClientId.value } : {})
6595
+ });
6596
+ } finally {
6597
+ releaseCommandClaim();
6598
+ }
6069
6599
  const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
6070
6600
  // role 用 'system'、带 mode: 'command',与前端实时发送时的约定对齐(workspace-panel.tsx
6071
6601
  // handleSend 的 command 分支)——命令输出本来就不该走 assistant 的气泡样式和 markdown 渲染,
@@ -6095,11 +6625,20 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6095
6625
  sendJson(res, 400, { error: 'prompt 不能为空' });
6096
6626
  return;
6097
6627
  }
6628
+ const agentClientId = readClientMessageId(res, payload);
6629
+ if (!agentClientId) {
6630
+ return;
6631
+ }
6632
+ const releaseAgentClaim = claimClientMessage(res, state.webHistoryDir, sessionRef, agentClientId.value);
6633
+ if (!releaseAgentClaim) {
6634
+ return;
6635
+ }
6098
6636
 
6099
6637
  let prepared = null;
6100
6638
  try {
6101
6639
  prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
6102
6640
  } catch (e) {
6641
+ releaseAgentClaim();
6103
6642
  sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
6104
6643
  return;
6105
6644
  }
@@ -6107,8 +6646,10 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6107
6646
  const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
6108
6647
  appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
6109
6648
  mode: 'agent',
6110
- contextMode
6649
+ contextMode,
6650
+ ...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
6111
6651
  });
6652
+ releaseAgentClaim();
6112
6653
  const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
6113
6654
  agentProgram: agentMeta.agentProgram
6114
6655
  });
@@ -6144,6 +6685,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6144
6685
  sendJson(res, 400, { error: 'prompt 不能为空' });
6145
6686
  return;
6146
6687
  }
6688
+ const streamClientId = readClientMessageId(res, payload);
6689
+ if (!streamClientId) {
6690
+ return;
6691
+ }
6692
+ if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value)) {
6693
+ return;
6694
+ }
6147
6695
  if (state.agentRuns.has(sessionRef.containerName)) {
6148
6696
  // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
6149
6697
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
@@ -6154,7 +6702,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6154
6702
 
6155
6703
  const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
6156
6704
  mode: 'agent',
6157
- pending: true
6705
+ pending: true,
6706
+ ...(streamClientId.value ? { clientMessageId: streamClientId.value } : {})
6158
6707
  });
6159
6708
  const traceMessage = appendWebAgentTraceMessage(
6160
6709
  state.webHistoryDir,
@@ -6282,7 +6831,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6282
6831
  content: traceLines.join('\n'),
6283
6832
  traceEvents: traceEvents.slice(),
6284
6833
  pending: true
6285
- });
6834
+ }, { defer: true });
6286
6835
  }
6287
6836
  if (event && event.type === 'content_delta' && typeof event.content === 'string') {
6288
6837
  const now = Date.now();
@@ -6308,7 +6857,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6308
6857
  patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
6309
6858
  content: event.content,
6310
6859
  pending: true
6311
- });
6860
+ }, { defer: true });
6312
6861
  lastPartialPersistAt = now;
6313
6862
  }
6314
6863
  }
@@ -6658,7 +7207,7 @@ async function startWebServer(options) {
6658
7207
 
6659
7208
  // 前端构建产物见 frontend/(npm run build:web),输出到 lib/web/index.html
6660
7209
  if (req.method === 'GET' && pathname === '/') {
6661
- sendHtml(res, 200, renderAppHtml(ctx));
7210
+ sendIndexHtml(res, 'app', ctx);
6662
7211
  return;
6663
7212
  }
6664
7213
 
@@ -6834,6 +7383,7 @@ async function startWebServer(options) {
6834
7383
  port: listenPort,
6835
7384
  close: () => new Promise(resolve => {
6836
7385
  ctx.logger.info('web server closing');
7386
+ flushWebSessionHistories();
6837
7387
  if (state.networkWatch) {
6838
7388
  state.networkWatch.stop();
6839
7389
  state.networkWatch = null;
@@ -6872,5 +7422,15 @@ async function startWebServer(options) {
6872
7422
  }
6873
7423
 
6874
7424
  module.exports = {
6875
- startWebServer
7425
+ startWebServer,
7426
+ buildEnvModelOptions,
7427
+ // 仅供测试:直接驱动历史读写层
7428
+ __testing: {
7429
+ appendWebSessionMessage,
7430
+ appendWebSessionControlEvent,
7431
+ removeWebSessionMessage,
7432
+ loadWebSessionHistory,
7433
+ saveWebSessionHistory,
7434
+ cloneWebAgentSessionsDeep
7435
+ }
6876
7436
  };