@xcanwin/manyoyo 8.6.2 → 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
@@ -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;
@@ -2460,6 +2514,24 @@ function redactConfigObject(value) {
2460
2514
  return result;
2461
2515
  }
2462
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
+
2463
2535
  function buildSafeWebConfigSnapshot(snapshot, ctx) {
2464
2536
  const parsed = snapshot && snapshot.parseError ? {} : toPlainObject(snapshot && snapshot.parsed);
2465
2537
  let raw = '';
@@ -2480,6 +2552,8 @@ function buildSafeWebConfigSnapshot(snapshot, ctx) {
2480
2552
  return {
2481
2553
  path: snapshot && snapshot.path ? snapshot.path : path.resolve(getDefaultWebConfigPath()),
2482
2554
  raw,
2555
+ // 版本号 = 磁盘原文的 sha256(不是脱敏后的文本:只有密钥变了也要能识别),只返回哈希,不泄露内容
2556
+ etag: computeContentEtag(snapshot && typeof snapshot.raw === 'string' ? snapshot.raw : ''),
2483
2557
  parsed: redactConfigObject(parsed),
2484
2558
  defaults: redactConfigObject(buildConfigDefaults(ctx, parsed)),
2485
2559
  parseError: snapshot && snapshot.parseError ? snapshot.parseError : null,
@@ -3121,6 +3195,7 @@ function cloneWebAgentSessionsDeep(sourceHistory) {
3121
3195
  : {};
3122
3196
  Object.keys(agents).forEach(agentId => {
3123
3197
  cloned[agentId] = JSON.parse(JSON.stringify(agents[agentId]));
3198
+ cloned[agentId].epoch = messagesView.createEpoch();
3124
3199
  });
3125
3200
  return cloned;
3126
3201
  }
@@ -3837,8 +3912,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
3837
3912
  const fs = require('fs');
3838
3913
  const path = require('path');
3839
3914
 
3915
+ const crypto = require('crypto');
3916
+
3840
3917
  const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3841
3918
  const maxBytes = ${String(maxBytes)};
3919
+ const editMaxBytes = ${String(WEB_FILE_EDIT_MAX_BYTES)};
3842
3920
  const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
3843
3921
 
3844
3922
  function looksBinary(buffer) {
@@ -3889,11 +3967,18 @@ try {
3889
3967
  truncated: maxBytes > 0 && size > maxBytes
3890
3968
  }));
3891
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
+ }
3892
3976
  process.stdout.write(JSON.stringify({
3893
3977
  path: realPath,
3894
3978
  kind: 'text',
3895
3979
  size,
3896
3980
  truncated: maxBytes > 0 && size > maxBytes,
3981
+ etag,
3897
3982
  content: buffer.toString('utf8')
3898
3983
  }));
3899
3984
  }
@@ -3931,12 +4016,17 @@ try {
3931
4016
  `);
3932
4017
  }
3933
4018
 
3934
- function buildContainerFileWriteCommand(requestedPath, content) {
4019
+ // expectedEtag:读取时拿到的内容 sha256。写入前在容器里重新计算当前内容的哈希,不一致就不写(返回 conflict)。
4020
+ // 检查和写入之间仍有一个极短的窗口(同一个脚本里的两条语句之间),可以接受。
4021
+ function buildContainerFileWriteCommand(requestedPath, content, expectedEtag) {
3935
4022
  return buildWebContainerNodeCommand(`
3936
4023
  // __MANYOYO_FS_WRITE__
3937
4024
  const fs = require('fs');
4025
+ const crypto = require('crypto');
3938
4026
 
3939
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))};
3940
4030
  const nextContent = ${JSON.stringify(String(content == null ? '' : content))};
3941
4031
 
3942
4032
  try {
@@ -3946,13 +4036,26 @@ try {
3946
4036
  throw new Error('目标不是文件: ' + realPath);
3947
4037
  }
3948
4038
 
3949
- fs.writeFileSync(realPath, nextContent, 'utf8');
3950
- const savedStat = fs.statSync(realPath);
3951
- process.stdout.write(JSON.stringify({
3952
- path: realPath,
3953
- saved: true,
3954
- size: savedStat.size
3955
- }));
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
+ }
3956
4059
  } catch (e) {
3957
4060
  process.stdout.write(JSON.stringify({
3958
4061
  error: e && e.message ? e.message : '保存文件失败'
@@ -4243,22 +4346,140 @@ async function readJsonBody(req) {
4243
4346
  }
4244
4347
  }
4245
4348
 
4246
- function sendJson(res, statusCode, payload, extraHeaders = {}) {
4247
- res.writeHead(statusCode, {
4248
- 'Content-Type': 'application/json; charset=utf-8',
4249
- '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',
4250
4426
  ...extraHeaders
4251
- });
4252
- 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 });
4253
4469
  }
4254
4470
 
4255
4471
  function sendNdjson(res, payload) {
4256
4472
  res.write(`${JSON.stringify(payload)}\n`);
4257
4473
  }
4258
4474
 
4475
+ const WEB_STREAM_FLUSH_EVENT_TYPES = new Set(['process.exited', 'process.interrupted', 'agent.turn.failed']);
4476
+
4259
4477
  function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
4260
4478
  return (payload, type, data = {}) => {
4261
- 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
+ });
4262
4483
  sendNdjson(res, { ...payload, controlEvent });
4263
4484
  };
4264
4485
  }
@@ -4347,13 +4568,8 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
4347
4568
  saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
4348
4569
  }
4349
4570
 
4350
- function sendHtml(res, statusCode, html, extraHeaders = {}) {
4351
- res.writeHead(statusCode, {
4352
- 'Content-Type': 'text/html; charset=utf-8',
4353
- 'Cache-Control': 'no-store',
4354
- ...extraHeaders
4355
- });
4356
- res.end(html);
4571
+ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
4572
+ sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
4357
4573
  }
4358
4574
 
4359
4575
  const SETUP_TEST_TIMEOUT_SECONDS = 60;
@@ -4738,11 +4954,54 @@ function buildSessionAudit(ctx, state, sessionRef) {
4738
4954
  };
4739
4955
  }
4740
4956
 
4957
+ // 按文件 mtime + size 缓存 index.html 原文(2MB,之前每次请求都同步读一遍)
4958
+ let indexHtmlCache = { key: '', html: '', variants: new Map() };
4959
+
4741
4960
  function loadIndexHtml() {
4742
- if (!fs.existsSync(INDEX_HTML_PATH)) {
4961
+ let stat = null;
4962
+ try {
4963
+ stat = fs.statSync(INDEX_HTML_PATH);
4964
+ } catch (e) {
4743
4965
  return '<h1>Template Not Found</h1>';
4744
4966
  }
4745
- 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);
4746
5005
  }
4747
5006
 
4748
5007
  function escapeHtmlText(value) {
@@ -4823,7 +5082,31 @@ function sendTerminalEvent(ws, type, payload = {}) {
4823
5082
  ws.send(JSON.stringify({ type, ...payload }));
4824
5083
  }
4825
5084
 
4826
- 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) {
4827
5110
  const terminalBootstrap = [
4828
5111
  'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
4829
5112
  'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
@@ -4836,6 +5119,10 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4836
5119
  'if [ -f /run/manyoyo-sys/env.sh ]; then',
4837
5120
  ' . /run/manyoyo-sys/env.sh',
4838
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',
4839
5126
  'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
4840
5127
  ' COLUMNS="$MANYOYO_TERM_COLS"',
4841
5128
  ' LINES="$MANYOYO_TERM_ROWS"',
@@ -4865,7 +5152,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4865
5152
  `TERM=${termValue}`,
4866
5153
  `COLORTERM=${colorTermValue}`,
4867
5154
  `MANYOYO_TERM_COLS=${String(cols)}`,
4868
- `MANYOYO_TERM_ROWS=${String(rows)}`
5155
+ `MANYOYO_TERM_ROWS=${String(rows)}`,
5156
+ `MANYOYO_TERM_ID=${termId}`
4869
5157
  ],
4870
5158
  command: ['/bin/bash', '-lc', terminalBootstrap]
4871
5159
  }, { stdio: ['pipe', 'pipe', 'pipe'] });
@@ -4873,7 +5161,8 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4873
5161
 
4874
5162
  function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4875
5163
  const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
4876
- const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows);
5164
+ const termId = crypto.randomBytes(8).toString('hex');
5165
+ const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows, termId);
4877
5166
  const session = {
4878
5167
  id: sessionId,
4879
5168
  containerName,
@@ -4916,6 +5205,10 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4916
5205
  }
4917
5206
  session.closing = true;
4918
5207
  clearInterval(pingTimer);
5208
+ if (resizeState.timer) {
5209
+ clearTimeout(resizeState.timer);
5210
+ resizeState.timer = null;
5211
+ }
4919
5212
  state.terminalSessions.delete(sessionId);
4920
5213
  if (ptyProcess && !ptyProcess.killed) {
4921
5214
  ptyProcess.kill('SIGTERM');
@@ -4927,6 +5220,72 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4927
5220
  }
4928
5221
  };
4929
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
+
4930
5289
  ptyProcess.stdout.on('data', chunk => {
4931
5290
  sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
4932
5291
  });
@@ -4973,7 +5332,7 @@ function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
4973
5332
  }
4974
5333
 
4975
5334
  if (payload.type === 'resize') {
4976
- // 当前后端不直接驱动 docker exec 的 TTY 动态 resize,保留事件以便后续扩展。
5335
+ requestTerminalResize(payload.cols, payload.rows);
4977
5336
  return;
4978
5337
  }
4979
5338
 
@@ -5037,7 +5396,7 @@ async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
5037
5396
  });
5038
5397
  return true;
5039
5398
  }
5040
- sendHtml(res, 200, applyServeTitle(loadIndexHtml(), ctx), { 'Referrer-Policy': 'no-referrer' });
5399
+ sendIndexHtml(res, 'login', ctx, { 'Referrer-Policy': 'no-referrer' });
5041
5400
  return true;
5042
5401
  }
5043
5402
 
@@ -5265,8 +5624,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5265
5624
  sendJson(res, 400, { error: '配置内容不能为空' });
5266
5625
  return;
5267
5626
  }
5627
+ const ifMatch = req.headers['if-match'];
5628
+ if (ifMatch === undefined) {
5629
+ sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
5630
+ return;
5631
+ }
5268
5632
 
5633
+ // 从这里到写盘之间不能有 await:读文件、比对版本、写文件在同一个同步段里完成,并发的两个请求不会同时通过检查
5269
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
+ }
5270
5644
  let finalRaw = raw;
5271
5645
  let parsed = null;
5272
5646
  try {
@@ -5283,6 +5657,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5283
5657
  sendJson(res, 200, {
5284
5658
  saved: true,
5285
5659
  path: savePath,
5660
+ etag: computeContentEtag(finalRaw),
5286
5661
  defaults: buildConfigDefaults(ctx, parsed)
5287
5662
  });
5288
5663
  }
@@ -5603,7 +5978,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5603
5978
  })
5604
5979
  .sort(compareWebSessionCreatedDesc);
5605
5980
 
5606
- sendJson(res, 200, { sessions });
5981
+ sendJson(res, 200, { sessions }, {}, { autoEtag: true });
5607
5982
  }
5608
5983
  },
5609
5984
  {
@@ -5743,14 +6118,62 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5743
6118
  if (!sessionRef) {
5744
6119
  return;
5745
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
+ }
5746
6127
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5747
6128
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
5748
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);
5749
6142
  sendJson(res, 200, {
5750
6143
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5751
6144
  containerName: sessionRef.containerName,
5752
6145
  agentId: sessionRef.agentId,
5753
- 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
5754
6177
  });
5755
6178
  }
5756
6179
  },
@@ -5905,12 +6328,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5905
6328
  return;
5906
6329
  }
5907
6330
 
6331
+ const ifMatch = req.headers['if-match'];
6332
+ if (ifMatch === undefined) {
6333
+ sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
6334
+ return;
6335
+ }
6336
+
5908
6337
  await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
5909
6338
  const result = await execJsonCommandInWebContainer(
5910
6339
  ctx,
5911
6340
  sessionRef.containerName,
5912
- buildContainerFileWriteCommand(targetPath, content)
6341
+ buildContainerFileWriteCommand(targetPath, content, normalizeIfMatchHeader(ifMatch))
5913
6342
  );
6343
+ if (result && result.conflict === true) {
6344
+ sendJson(res, 409, { error: result.error || '文件已在容器里被修改', conflict: true });
6345
+ return;
6346
+ }
5914
6347
  if (result && result.error) {
5915
6348
  sendJson(res, 400, { error: result.error });
5916
6349
  return;
@@ -5985,7 +6418,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5985
6418
 
5986
6419
  const containerMap = listWebManyoyoContainers(ctx);
5987
6420
  const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
5988
- sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
6421
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
5989
6422
  }
5990
6423
  },
5991
6424
  {
@@ -6146,9 +6579,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6146
6579
  sendJson(res, 400, { error: 'command 不能为空' });
6147
6580
  return;
6148
6581
  }
6149
-
6150
- await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
6151
- 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
+ }
6152
6599
  const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
6153
6600
  // role 用 'system'、带 mode: 'command',与前端实时发送时的约定对齐(workspace-panel.tsx
6154
6601
  // handleSend 的 command 分支)——命令输出本来就不该走 assistant 的气泡样式和 markdown 渲染,
@@ -6178,11 +6625,20 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6178
6625
  sendJson(res, 400, { error: 'prompt 不能为空' });
6179
6626
  return;
6180
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
+ }
6181
6636
 
6182
6637
  let prepared = null;
6183
6638
  try {
6184
6639
  prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
6185
6640
  } catch (e) {
6641
+ releaseAgentClaim();
6186
6642
  sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
6187
6643
  return;
6188
6644
  }
@@ -6190,8 +6646,10 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6190
6646
  const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
6191
6647
  appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
6192
6648
  mode: 'agent',
6193
- contextMode
6649
+ contextMode,
6650
+ ...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
6194
6651
  });
6652
+ releaseAgentClaim();
6195
6653
  const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
6196
6654
  agentProgram: agentMeta.agentProgram
6197
6655
  });
@@ -6227,6 +6685,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6227
6685
  sendJson(res, 400, { error: 'prompt 不能为空' });
6228
6686
  return;
6229
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
+ }
6230
6695
  if (state.agentRuns.has(sessionRef.containerName)) {
6231
6696
  // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
6232
6697
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
@@ -6237,7 +6702,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6237
6702
 
6238
6703
  const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
6239
6704
  mode: 'agent',
6240
- pending: true
6705
+ pending: true,
6706
+ ...(streamClientId.value ? { clientMessageId: streamClientId.value } : {})
6241
6707
  });
6242
6708
  const traceMessage = appendWebAgentTraceMessage(
6243
6709
  state.webHistoryDir,
@@ -6365,7 +6831,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6365
6831
  content: traceLines.join('\n'),
6366
6832
  traceEvents: traceEvents.slice(),
6367
6833
  pending: true
6368
- });
6834
+ }, { defer: true });
6369
6835
  }
6370
6836
  if (event && event.type === 'content_delta' && typeof event.content === 'string') {
6371
6837
  const now = Date.now();
@@ -6391,7 +6857,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6391
6857
  patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
6392
6858
  content: event.content,
6393
6859
  pending: true
6394
- });
6860
+ }, { defer: true });
6395
6861
  lastPartialPersistAt = now;
6396
6862
  }
6397
6863
  }
@@ -6741,7 +7207,7 @@ async function startWebServer(options) {
6741
7207
 
6742
7208
  // 前端构建产物见 frontend/(npm run build:web),输出到 lib/web/index.html
6743
7209
  if (req.method === 'GET' && pathname === '/') {
6744
- sendHtml(res, 200, renderAppHtml(ctx));
7210
+ sendIndexHtml(res, 'app', ctx);
6745
7211
  return;
6746
7212
  }
6747
7213
 
@@ -6917,6 +7383,7 @@ async function startWebServer(options) {
6917
7383
  port: listenPort,
6918
7384
  close: () => new Promise(resolve => {
6919
7385
  ctx.logger.info('web server closing');
7386
+ flushWebSessionHistories();
6920
7387
  if (state.networkWatch) {
6921
7388
  state.networkWatch.stop();
6922
7389
  state.networkWatch = null;
@@ -6956,5 +7423,14 @@ async function startWebServer(options) {
6956
7423
 
6957
7424
  module.exports = {
6958
7425
  startWebServer,
6959
- buildEnvModelOptions
7426
+ buildEnvModelOptions,
7427
+ // 仅供测试:直接驱动历史读写层
7428
+ __testing: {
7429
+ appendWebSessionMessage,
7430
+ appendWebSessionControlEvent,
7431
+ removeWebSessionMessage,
7432
+ loadWebSessionHistory,
7433
+ saveWebSessionHistory,
7434
+ cloneWebAgentSessionsDeep
7435
+ }
6960
7436
  };