@xcanwin/manyoyo 5.2.0 → 5.2.8

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/bin/manyoyo.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { execSync, spawnSync } = require('child_process');
3
+ const { spawnSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
@@ -437,20 +437,24 @@ textarea:focus-visible {
437
437
  box-shadow: inset 3px 0 0 var(--accent), 0 0 0 2px rgba(196, 85, 31, 0.14);
438
438
  }
439
439
 
440
- .session-item.history-only {
441
- color: #b3ab9f;
442
- }
443
-
444
- .session-item.history-only .session-name,
445
- .session-item.history-only .session-count,
446
- .session-item.history-only .session-time {
447
- color: #b3ab9f;
440
+ .session-item.status-history:not(.active),
441
+ .session-item.status-stopped:not(.active),
442
+ .session-item.status-unknown:not(.active) {
443
+ border-color: rgba(181, 146, 99, 0.22);
444
+ background: rgba(255, 252, 247, 0.72);
445
+ box-shadow: none;
448
446
  }
449
447
 
450
- .session-item.history-only .session-status {
451
- background: #f7f3ed;
452
- border-color: #e3dbd1;
453
- color: #b3ab9f;
448
+ .session-item.status-history:not(.active) .session-name,
449
+ .session-item.status-history:not(.active) .session-meta,
450
+ .session-item.status-history:not(.active) .session-time,
451
+ .session-item.status-stopped:not(.active) .session-name,
452
+ .session-item.status-stopped:not(.active) .session-meta,
453
+ .session-item.status-stopped:not(.active) .session-time,
454
+ .session-item.status-unknown:not(.active) .session-name,
455
+ .session-item.status-unknown:not(.active) .session-meta,
456
+ .session-item.status-unknown:not(.active) .session-time {
457
+ opacity: 0.25;
454
458
  }
455
459
 
456
460
  .session-name {
@@ -847,6 +851,23 @@ body.terminal-mode .composer {
847
851
  opacity: 0.78;
848
852
  }
849
853
 
854
+ body.agent-mode .msg.origin-command .bubble,
855
+ body.agent-mode .msg.origin-command .msg-meta,
856
+ body.agent-mode .msg.origin-command .msg-exit {
857
+ opacity: 0.25;
858
+ }
859
+
860
+ body.command-mode .msg.origin-agent .bubble,
861
+ body.command-mode .msg.origin-agent .msg-meta,
862
+ body.command-mode .msg.origin-agent .msg-exit {
863
+ opacity: 0.25;
864
+ }
865
+
866
+ body.agent-mode .msg.origin-command .bubble,
867
+ body.command-mode .msg.origin-agent .bubble {
868
+ box-shadow: none;
869
+ }
870
+
850
871
  .bubble pre {
851
872
  margin: 0;
852
873
  white-space: pre-wrap;
@@ -8,6 +8,7 @@
8
8
  />
9
9
  <title>MANYOYO Web</title>
10
10
  <link rel="stylesheet" href="/app/frontend/app.css" />
11
+ <link rel="stylesheet" href="/app/frontend/markdown.css" />
11
12
  <link rel="stylesheet" href="/app/vendor/xterm.css" />
12
13
  </head>
13
14
  <body>
@@ -65,8 +66,8 @@
65
66
  </header>
66
67
  <section class="mode-switch" id="modeSwitch">
67
68
  <div class="mode-switch-left">
68
- <button type="button" id="modeCommandBtn" class="secondary is-active">命令模式</button>
69
- <button type="button" id="modeAgentBtn" class="secondary">AGENT 模式</button>
69
+ <button type="button" id="modeAgentBtn" class="secondary is-active">AGENT 模式</button>
70
+ <button type="button" id="modeCommandBtn" class="secondary">命令模式</button>
70
71
  <button type="button" id="modeTerminalBtn" class="secondary">交互终端</button>
71
72
  </div>
72
73
  <div class="mode-terminal-controls">
@@ -164,6 +165,8 @@
164
165
 
165
166
  <script src="/app/vendor/xterm.js"></script>
166
167
  <script src="/app/vendor/xterm-addon-fit.js"></script>
168
+ <script src="/app/vendor/marked.min.js"></script>
169
+ <script src="/app/frontend/markdown-renderer.js"></script>
167
170
  <script src="/app/frontend/app.js"></script>
168
171
  </body>
169
172
  </html>
@@ -40,7 +40,7 @@
40
40
  active: '',
41
41
  messages: [],
42
42
  messageRenderKeys: [],
43
- mode: 'command',
43
+ mode: 'agent',
44
44
  sending: false,
45
45
  loadingSessions: false,
46
46
  loadingMessages: false,
@@ -155,9 +155,20 @@
155
155
  codex: 'codex exec {prompt}',
156
156
  opencode: 'opencode run {prompt}'
157
157
  };
158
+ const markdownRenderer = window.ManyoyoMarkdown
159
+ && typeof window.ManyoyoMarkdown.shouldRenderMessage === 'function'
160
+ && typeof window.ManyoyoMarkdown.render === 'function'
161
+ ? window.ManyoyoMarkdown
162
+ : null;
163
+
164
+ function appendPlainMessageContent(bubble, content) {
165
+ const pre = document.createElement('pre');
166
+ pre.textContent = content == null ? '' : String(content);
167
+ bubble.appendChild(pre);
168
+ }
158
169
 
159
170
  function roleName(role, message) {
160
- if (role === 'user') return '';
171
+ if (role === 'user') return '';
161
172
  if (role === 'assistant') {
162
173
  if (message && message.mode === 'agent') {
163
174
  return 'AGENT 回复';
@@ -934,7 +945,7 @@
934
945
  removeBtn.disabled = !state.active || busy;
935
946
  removeAllBtn.disabled = !state.active || busy;
936
947
  sendBtn.disabled = !composerMode || !state.active || busy || (agentMode && !agentEnabled);
937
- commandInput.disabled = !composerMode || !state.active || state.sending || (agentMode && !agentEnabled);
948
+ commandInput.disabled = !composerMode || !state.active || (agentMode && !agentEnabled);
938
949
  if (commandInput) {
939
950
  commandInput.placeholder = agentMode
940
951
  ? '输入提示词,例如:请帮我分析当前项目结构并给出重构建议'
@@ -1003,7 +1014,7 @@
1003
1014
 
1004
1015
  async function api(url, options) {
1005
1016
  const requestOptions = Object.assign(
1006
- { headers: { 'Content-Type': 'application/json' } },
1017
+ { headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } },
1007
1018
  options || {}
1008
1019
  );
1009
1020
  const response = await fetch(url, requestOptions);
@@ -1146,6 +1157,10 @@
1146
1157
  row.style.setProperty('--item-index', String(index));
1147
1158
  row.classList.toggle('active', state.active === session.name);
1148
1159
  row.classList.toggle('history-only', status.tone === 'history');
1160
+ row.classList.toggle('status-running', status.tone === 'running');
1161
+ row.classList.toggle('status-stopped', status.tone === 'stopped');
1162
+ row.classList.toggle('status-history', status.tone === 'history');
1163
+ row.classList.toggle('status-unknown', status.tone === 'unknown');
1149
1164
  if (row.__sessionNameNode) {
1150
1165
  row.__sessionNameNode.textContent = session.name;
1151
1166
  }
@@ -1329,16 +1344,33 @@
1329
1344
  return `id:${msg.id}`;
1330
1345
  }
1331
1346
  const role = msg && msg.role ? String(msg.role) : '';
1347
+ const mode = msg && msg.mode ? String(msg.mode) : '';
1332
1348
  const timestamp = msg && msg.timestamp ? String(msg.timestamp) : '';
1333
1349
  const exitCode = msg && typeof msg.exitCode === 'number' ? String(msg.exitCode) : '';
1334
1350
  const pending = msg && msg.pending ? '1' : '0';
1335
1351
  const content = msg && msg.content ? String(msg.content) : '';
1336
- return `idx:${index}|${role}|${timestamp}|${exitCode}|${pending}|${content}`;
1352
+ return `idx:${index}|${role}|${mode}|${timestamp}|${exitCode}|${pending}|${content}`;
1353
+ }
1354
+
1355
+ function resolveMessageOrigin(msg) {
1356
+ const role = msg && msg.role ? String(msg.role) : '';
1357
+ if (!(role === 'user' || role === 'assistant')) {
1358
+ return '';
1359
+ }
1360
+ const mode = msg && msg.mode ? String(msg.mode) : '';
1361
+ if (mode === 'agent') {
1362
+ return 'agent';
1363
+ }
1364
+ return 'command';
1337
1365
  }
1338
1366
 
1339
1367
  function createMessageRow(msg, index) {
1340
1368
  const row = document.createElement('article');
1341
1369
  row.className = 'msg ' + (msg.role || 'system') + (msg.pending ? ' pending' : '');
1370
+ const origin = resolveMessageOrigin(msg);
1371
+ if (origin) {
1372
+ row.classList.add('origin-' + origin);
1373
+ }
1342
1374
  row.style.setProperty('--msg-index', String(index));
1343
1375
 
1344
1376
  const meta = document.createElement('div');
@@ -1354,9 +1386,25 @@
1354
1386
  const bubble = document.createElement('div');
1355
1387
  bubble.className = 'bubble';
1356
1388
 
1357
- const pre = document.createElement('pre');
1358
- pre.textContent = msg.content || '';
1359
- bubble.appendChild(pre);
1389
+ const shouldRenderMarkdown = Boolean(markdownRenderer && markdownRenderer.shouldRenderMessage(msg));
1390
+ if (shouldRenderMarkdown) {
1391
+ const markdownNode = document.createElement('div');
1392
+ markdownNode.className = 'md-content';
1393
+ let renderedMarkdown = '';
1394
+ try {
1395
+ renderedMarkdown = String(markdownRenderer.render(msg.content) || '');
1396
+ } catch (e) {
1397
+ renderedMarkdown = '';
1398
+ }
1399
+ if (renderedMarkdown) {
1400
+ markdownNode.innerHTML = renderedMarkdown;
1401
+ bubble.appendChild(markdownNode);
1402
+ } else {
1403
+ appendPlainMessageContent(bubble, msg.content);
1404
+ }
1405
+ } else {
1406
+ appendPlainMessageContent(bubble, msg.content);
1407
+ }
1360
1408
 
1361
1409
  row.appendChild(meta);
1362
1410
  row.appendChild(bubble);
@@ -1703,6 +1751,7 @@
1703
1751
  })
1704
1752
  });
1705
1753
  closeCreateModal();
1754
+ state.mode = 'agent';
1706
1755
  await loadSessions(data.name);
1707
1756
  if (isMobileLayout()) {
1708
1757
  closeMobileSessionPanel();
@@ -1993,7 +2042,7 @@
1993
2042
  renderSessions();
1994
2043
  renderMessages(state.messages);
1995
2044
  setMobileSessionPanel(false);
1996
- document.body.classList.add('command-mode');
2045
+ document.body.classList.add('agent-mode');
1997
2046
  syncUi();
1998
2047
  loadSessions().catch(function (e) {
1999
2048
  alert(e.message);
@@ -0,0 +1,143 @@
1
+ (function () {
2
+ const MARKDOWN_URL_PROTOCOL_PATTERN = /^(https?:|mailto:|tel:)/i;
3
+ const runtime = {
4
+ configured: false,
5
+ available: false
6
+ };
7
+
8
+ function escapeHtml(value) {
9
+ return String(value == null ? '' : value)
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ }
16
+
17
+ function sanitizeMarkdownUrl(value) {
18
+ const raw = String(value == null ? '' : value).trim();
19
+ if (!raw) {
20
+ return '';
21
+ }
22
+ if (raw[0] === '#') {
23
+ return raw;
24
+ }
25
+ if (raw[0] === '/') {
26
+ return raw.startsWith('//') ? '' : raw;
27
+ }
28
+ if (raw.startsWith('./') || raw.startsWith('../')) {
29
+ return raw;
30
+ }
31
+ if (MARKDOWN_URL_PROTOCOL_PATTERN.test(raw)) {
32
+ return raw;
33
+ }
34
+ return '';
35
+ }
36
+
37
+ function getMarkedApi() {
38
+ const api = window.marked;
39
+ if (!api || typeof api.parse !== 'function') {
40
+ return null;
41
+ }
42
+ return api;
43
+ }
44
+
45
+ function ensureRendererConfigured() {
46
+ if (runtime.configured) {
47
+ return runtime.available;
48
+ }
49
+
50
+ const markedApi = getMarkedApi();
51
+ if (!markedApi || typeof markedApi.Renderer !== 'function' || typeof markedApi.use !== 'function') {
52
+ runtime.configured = true;
53
+ runtime.available = false;
54
+ return false;
55
+ }
56
+
57
+ try {
58
+ const renderer = new markedApi.Renderer();
59
+ renderer.html = function (html) {
60
+ return escapeHtml(html);
61
+ };
62
+ renderer.link = function (href, title, text) {
63
+ const safeHref = sanitizeMarkdownUrl(href);
64
+ if (!safeHref) {
65
+ return escapeHtml(text || '');
66
+ }
67
+ // [P1-02] 移除 marked 已渲染链接文本中的 on* 事件属性,防止内联 HTML 注入 XSS
68
+ const safeText = String(text || '').replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
69
+ let output = '<a href="' + escapeHtml(safeHref) + '" target="_blank" rel="noopener noreferrer"';
70
+ if (title) {
71
+ output += ' title="' + escapeHtml(title) + '"';
72
+ }
73
+ output += '>' + safeText + '</a>';
74
+ return output;
75
+ };
76
+ // [P1-01] 重写 image 渲染器:
77
+ // - 外部 http/https 图片转为可点击链接,避免浏览器自动发起外部请求(追踪像素风险)
78
+ // - 相对路径图片正常渲染为 <img>
79
+ // - 危险协议(javascript:/data: 等)降级为纯文本
80
+ renderer.image = function (href, title, text) {
81
+ const safeHref = sanitizeMarkdownUrl(href);
82
+ if (!safeHref) {
83
+ return escapeHtml(text || '');
84
+ }
85
+ // 外部绝对 URL:转为链接,用户主动决定是否访问
86
+ if (/^https?:/i.test(safeHref)) {
87
+ const safeText = String(text || '').replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '')
88
+ || escapeHtml(safeHref);
89
+ let output = '<a href="' + escapeHtml(safeHref) + '" target="_blank" rel="noopener noreferrer"';
90
+ if (title) {
91
+ output += ' title="' + escapeHtml(title) + '"';
92
+ }
93
+ return output + '>[\uD83D\uDDBC\uFE0F点击查看图片:' + safeText + ']</a>';
94
+ }
95
+ // 相对路径:正常渲染为图片
96
+ let output = '<img src="' + escapeHtml(safeHref) + '" alt="' + escapeHtml(text || '') + '"';
97
+ if (title) {
98
+ output += ' title="' + escapeHtml(title) + '"';
99
+ }
100
+ return output + '>';
101
+ };
102
+
103
+ markedApi.use({
104
+ gfm: true,
105
+ breaks: true,
106
+ renderer
107
+ });
108
+ runtime.available = true;
109
+ } catch (e) {
110
+ runtime.available = false;
111
+ }
112
+ runtime.configured = true;
113
+ return runtime.available;
114
+ }
115
+
116
+ function shouldRenderMessage(msg) {
117
+ return Boolean(msg && msg.mode === 'agent' && msg.role === 'assistant');
118
+ }
119
+
120
+ function render(content) {
121
+ const source = String(content == null ? '' : content);
122
+ if (!source) {
123
+ return '';
124
+ }
125
+ if (!ensureRendererConfigured()) {
126
+ return '';
127
+ }
128
+ const markedApi = getMarkedApi();
129
+ if (!markedApi) {
130
+ return '';
131
+ }
132
+ try {
133
+ return String(markedApi.parse(source) || '');
134
+ } catch (e) {
135
+ return '';
136
+ }
137
+ }
138
+
139
+ window.ManyoyoMarkdown = {
140
+ shouldRenderMessage,
141
+ render
142
+ };
143
+ }());
@@ -0,0 +1,76 @@
1
+ .bubble .md-content {
2
+ color: var(--text);
3
+ font-size: 13px;
4
+ line-height: 1.6;
5
+ word-break: break-word;
6
+ }
7
+
8
+ .bubble .md-content > :first-child {
9
+ margin-top: 0;
10
+ }
11
+
12
+ .bubble .md-content > :last-child {
13
+ margin-bottom: 0;
14
+ }
15
+
16
+ .bubble .md-content p,
17
+ .bubble .md-content ul,
18
+ .bubble .md-content ol,
19
+ .bubble .md-content blockquote,
20
+ .bubble .md-content pre,
21
+ .bubble .md-content table {
22
+ margin: 0.6em 0;
23
+ }
24
+
25
+ .bubble .md-content ul,
26
+ .bubble .md-content ol {
27
+ padding-left: 1.4em;
28
+ }
29
+
30
+ .bubble .md-content code {
31
+ font-family: var(--font-mono);
32
+ font-size: 12px;
33
+ padding: 1px 5px;
34
+ border-radius: 6px;
35
+ background: rgba(194, 149, 79, 0.16);
36
+ }
37
+
38
+ .bubble .md-content pre {
39
+ border-radius: 8px;
40
+ padding: 9px 10px;
41
+ border: 1px solid #e6d2b7;
42
+ background: rgba(255, 244, 224, 0.72);
43
+ overflow-x: auto;
44
+ }
45
+
46
+ .bubble .md-content pre code {
47
+ background: transparent;
48
+ border-radius: 0;
49
+ padding: 0;
50
+ font-size: 12px;
51
+ line-height: 1.5;
52
+ }
53
+
54
+ .bubble .md-content blockquote {
55
+ margin-left: 0;
56
+ padding-left: 10px;
57
+ border-left: 3px solid #dcb788;
58
+ color: #66492d;
59
+ }
60
+
61
+ .bubble .md-content table {
62
+ border-collapse: collapse;
63
+ width: 100%;
64
+ }
65
+
66
+ .bubble .md-content th,
67
+ .bubble .md-content td {
68
+ border: 1px solid #e6d2b7;
69
+ padding: 4px 6px;
70
+ text-align: left;
71
+ }
72
+
73
+ .bubble .md-content a {
74
+ color: #8a4f17;
75
+ text-decoration-color: rgba(138, 79, 23, 0.45);
76
+ }
package/lib/web/server.js CHANGED
@@ -9,7 +9,11 @@ const http = require('http');
9
9
  const WebSocket = require('ws');
10
10
  const JSON5 = require('json5');
11
11
  const { buildContainerRunArgs } = require('../container-run');
12
- const { resolveAgentPromptCommandTemplate } = require('../agent-resume');
12
+ const {
13
+ resolveAgentProgram,
14
+ resolveAgentPromptCommandTemplate,
15
+ buildAgentResumeCommand
16
+ } = require('../agent-resume');
13
17
 
14
18
  const WEB_HISTORY_MAX_MESSAGES = 500;
15
19
  const WEB_OUTPUT_MAX_CHARS = 16000;
@@ -19,6 +23,9 @@ const WEB_TERMINAL_DEFAULT_COLS = 120;
19
23
  const WEB_TERMINAL_DEFAULT_ROWS = 36;
20
24
  const WEB_TERMINAL_MIN_COLS = 40;
21
25
  const WEB_TERMINAL_MIN_ROWS = 12;
26
+ const WEB_AGENT_CONTEXT_MAX_MESSAGES = 24;
27
+ const WEB_AGENT_CONTEXT_MAX_CHARS = 6000;
28
+ const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
22
29
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
23
30
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
24
31
  const FRONTEND_DIR = path.join(__dirname, 'frontend');
@@ -61,6 +68,7 @@ const DEFAULT_WEB_CONFIG_TEMPLATE = `{
61
68
  let XTERM_JS_FILE = null;
62
69
  let XTERM_CSS_FILE = null;
63
70
  let XTERM_ADDON_FIT_JS_FILE = null;
71
+ let MARKED_MIN_JS_FILE = null;
64
72
  try {
65
73
  const xtermPackageDir = path.dirname(require.resolve('@xterm/xterm/package.json'));
66
74
  XTERM_JS_FILE = path.join(xtermPackageDir, 'lib', 'xterm.js');
@@ -75,6 +83,11 @@ try {
75
83
  } catch (e) {
76
84
  XTERM_ADDON_FIT_JS_FILE = null;
77
85
  }
86
+ try {
87
+ MARKED_MIN_JS_FILE = require.resolve('marked/marked.min.js');
88
+ } catch (e) {
89
+ MARKED_MIN_JS_FILE = null;
90
+ }
78
91
 
79
92
  const MIME_TYPES = {
80
93
  '.css': 'text/css; charset=utf-8',
@@ -106,7 +119,12 @@ function loadWebSessionHistory(webHistoryDir, containerName) {
106
119
  containerName,
107
120
  updatedAt: null,
108
121
  messages: [],
109
- agentPromptCommand: ''
122
+ agentPromptCommand: '',
123
+ agentProgram: '',
124
+ resumeSupported: false,
125
+ lastResumeAt: null,
126
+ lastResumeOk: null,
127
+ lastResumeError: ''
110
128
  };
111
129
  }
112
130
 
@@ -118,14 +136,24 @@ function loadWebSessionHistory(webHistoryDir, containerName) {
118
136
  messages: Array.isArray(data.messages) ? data.messages : [],
119
137
  agentPromptCommand: typeof data.agentPromptCommand === 'string'
120
138
  ? data.agentPromptCommand
121
- : ''
139
+ : '',
140
+ agentProgram: typeof data.agentProgram === 'string' ? data.agentProgram : '',
141
+ resumeSupported: data.resumeSupported === true,
142
+ lastResumeAt: typeof data.lastResumeAt === 'string' ? data.lastResumeAt : null,
143
+ lastResumeOk: typeof data.lastResumeOk === 'boolean' ? data.lastResumeOk : null,
144
+ lastResumeError: typeof data.lastResumeError === 'string' ? data.lastResumeError : ''
122
145
  };
123
146
  } catch (e) {
124
147
  return {
125
148
  containerName,
126
149
  updatedAt: null,
127
150
  messages: [],
128
- agentPromptCommand: ''
151
+ agentPromptCommand: '',
152
+ agentProgram: '',
153
+ resumeSupported: false,
154
+ lastResumeAt: null,
155
+ lastResumeOk: null,
156
+ lastResumeError: ''
129
157
  };
130
158
  }
131
159
  }
@@ -174,9 +202,30 @@ function appendWebSessionMessage(webHistoryDir, containerName, role, content, ex
174
202
  function setWebSessionAgentPromptCommand(webHistoryDir, containerName, agentPromptCommand) {
175
203
  const history = loadWebSessionHistory(webHistoryDir, containerName);
176
204
  history.agentPromptCommand = normalizeAgentPromptCommandTemplate(agentPromptCommand, 'agentPromptCommand');
205
+ const agentProgram = resolveAgentProgram(history.agentPromptCommand);
206
+ const resumeCommand = buildAgentResumeCommand(agentProgram);
207
+ history.agentProgram = agentProgram || '';
208
+ history.resumeSupported = Boolean(resumeCommand);
209
+ if (!history.resumeSupported) {
210
+ history.lastResumeAt = null;
211
+ history.lastResumeOk = null;
212
+ history.lastResumeError = '';
213
+ }
177
214
  saveWebSessionHistory(webHistoryDir, containerName, history);
178
215
  }
179
216
 
217
+ function patchWebSessionAgentState(webHistoryDir, containerName, patch) {
218
+ const history = loadWebSessionHistory(webHistoryDir, containerName);
219
+ if (!patch || typeof patch !== 'object') {
220
+ return history;
221
+ }
222
+ Object.keys(patch).forEach(key => {
223
+ history[key] = patch[key];
224
+ });
225
+ saveWebSessionHistory(webHistoryDir, containerName, history);
226
+ return history;
227
+ }
228
+
180
229
  function stripAnsi(text) {
181
230
  if (typeof text !== 'string') return '';
182
231
  return text.replace(/\x1b\[[0-9;]*m/g, '');
@@ -220,6 +269,72 @@ function renderAgentPromptCommand(template, prompt) {
220
269
  return templateText.replace(/\{prompt\}/g, safePrompt);
221
270
  }
222
271
 
272
+ function getAgentRuntimeMeta(history) {
273
+ const sessionHistory = history && typeof history === 'object' ? history : {};
274
+ const template = normalizeAgentPromptCommandTemplate(sessionHistory.agentPromptCommand, 'agentPromptCommand');
275
+ const agentProgram = resolveAgentProgram(template);
276
+ const resumeCommand = buildAgentResumeCommand(agentProgram);
277
+ return {
278
+ agentProgram: agentProgram || '',
279
+ resumeCommand: resumeCommand || '',
280
+ resumeSupported: Boolean(resumeCommand)
281
+ };
282
+ }
283
+
284
+ function hasAgentConversationHistory(history) {
285
+ const messages = history && Array.isArray(history.messages) ? history.messages : [];
286
+ for (const message of messages) {
287
+ if (!message || typeof message !== 'object') continue;
288
+ if (message.mode !== 'agent') continue;
289
+ if (message.role === 'user' || message.role === 'assistant') {
290
+ return true;
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function clipAgentContextMessageText(text) {
297
+ const raw = clipText(stripAnsi(String(text || '')), WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS);
298
+ return raw
299
+ .replace(/\r/g, '')
300
+ .replace(/\n{3,}/g, '\n\n')
301
+ .trim();
302
+ }
303
+
304
+ function buildAgentPromptWithHistory(history, prompt) {
305
+ const sessionHistory = history && Array.isArray(history.messages) ? history.messages : [];
306
+ const relevantMessages = sessionHistory
307
+ .filter(message => message && message.mode === 'agent' && (message.role === 'user' || message.role === 'assistant'))
308
+ .slice(-WEB_AGENT_CONTEXT_MAX_MESSAGES);
309
+ if (!relevantMessages.length) {
310
+ return String(prompt || '');
311
+ }
312
+
313
+ const lines = [];
314
+ for (const message of relevantMessages) {
315
+ const roleName = message.role === 'user' ? '用户' : '助手';
316
+ const content = clipAgentContextMessageText(message.content);
317
+ if (!content) continue;
318
+ lines.push(`${roleName}: ${content}`);
319
+ }
320
+ if (!lines.length) {
321
+ return String(prompt || '');
322
+ }
323
+
324
+ let historyText = lines.join('\n\n');
325
+ if (historyText.length > WEB_AGENT_CONTEXT_MAX_CHARS) {
326
+ historyText = historyText.slice(historyText.length - WEB_AGENT_CONTEXT_MAX_CHARS);
327
+ }
328
+
329
+ return [
330
+ '以下是当前会话最近对话历史(按时间顺序):',
331
+ historyText,
332
+ '---',
333
+ '请基于以上历史回答当前问题。',
334
+ `当前问题: ${String(prompt || '').trim()}`
335
+ ].join('\n');
336
+ }
337
+
223
338
  function secureStringEqual(a, b) {
224
339
  const aStr = String(a || '');
225
340
  const bStr = String(b || '');
@@ -299,11 +414,11 @@ function clearWebAuthSession(state, req) {
299
414
  }
300
415
 
301
416
  function getWebAuthCookie(sessionId) {
302
- return `${WEB_AUTH_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${WEB_AUTH_TTL_SECONDS}`;
417
+ return `${WEB_AUTH_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${WEB_AUTH_TTL_SECONDS}`;
303
418
  }
304
419
 
305
420
  function getWebAuthClearCookie() {
306
- return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
421
+ return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
307
422
  }
308
423
 
309
424
  function getDefaultWebConfigPath() {
@@ -745,6 +860,8 @@ function buildCreateRuntime(ctx, state, payload) {
745
860
  'agentPromptCommand'
746
861
  );
747
862
  const agentPromptCommand = configuredAgentPromptCommand || inferredAgentPromptCommand;
863
+ const agentProgram = resolveAgentProgram(agentPromptCommand);
864
+ const resumeSupported = Boolean(buildAgentResumeCommand(agentProgram));
748
865
 
749
866
  let containerEnvs = Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [];
750
867
  if (hasRequestEnv || hasRequestEnvFile || hasConfigEnv || hasConfigEnvFile) {
@@ -813,6 +930,8 @@ function buildCreateRuntime(ctx, state, payload) {
813
930
  shell: shell || '',
814
931
  shellSuffix: shellSuffix || '',
815
932
  agentEnabled: isAgentPromptCommandEnabled(agentPromptCommand),
933
+ agentProgram: agentProgram || '',
934
+ resumeSupported,
816
935
  yolo: yolo || '',
817
936
  envCount: Math.floor(containerEnvs.length / 2),
818
937
  volumeCount: Math.floor(containerVolumes.length / 2),
@@ -995,6 +1114,7 @@ function getValidSessionName(ctx, res, encodedName) {
995
1114
 
996
1115
  function buildSessionSummary(ctx, state, containerMap, name) {
997
1116
  const history = loadWebSessionHistory(state.webHistoryDir, name);
1117
+ const agentMeta = getAgentRuntimeMeta(history);
998
1118
  const latestMessage = history.messages.length ? history.messages[history.messages.length - 1] : null;
999
1119
  const containerInfo = containerMap[name] || {};
1000
1120
  const updatedAt = history.updatedAt || (latestMessage && latestMessage.timestamp) || null;
@@ -1004,7 +1124,9 @@ function buildSessionSummary(ctx, state, containerMap, name) {
1004
1124
  image: containerInfo.image || '',
1005
1125
  updatedAt,
1006
1126
  messageCount: history.messages.length,
1007
- agentEnabled: isAgentPromptCommandEnabled(history.agentPromptCommand)
1127
+ agentEnabled: isAgentPromptCommandEnabled(history.agentPromptCommand),
1128
+ agentProgram: agentMeta.agentProgram,
1129
+ resumeSupported: agentMeta.resumeSupported
1008
1130
  };
1009
1131
  }
1010
1132
 
@@ -1033,6 +1155,9 @@ function resolveVendorAsset(name) {
1033
1155
  if (name === 'xterm-addon-fit.js') {
1034
1156
  return XTERM_ADDON_FIT_JS_FILE && fs.existsSync(XTERM_ADDON_FIT_JS_FILE) ? XTERM_ADDON_FIT_JS_FILE : null;
1035
1157
  }
1158
+ if (name === 'marked.min.js') {
1159
+ return MARKED_MIN_JS_FILE && fs.existsSync(MARKED_MIN_JS_FILE) ? MARKED_MIN_JS_FILE : null;
1160
+ }
1036
1161
  return null;
1037
1162
  }
1038
1163
 
@@ -1330,6 +1455,14 @@ function sendWebUnauthorized(res, pathname) {
1330
1455
  }
1331
1456
 
1332
1457
  async function handleWebApi(req, res, pathname, ctx, state) {
1458
+ // [P2-03] 对非只读请求校验自定义头,防止 CSRF 攻击
1459
+ // 跨站请求无法设置自定义头(浏览器同源策略),合法前端请求统一携带此头
1460
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1461
+ if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
1462
+ sendJson(res, 403, { error: 'CSRF check failed' });
1463
+ return true;
1464
+ }
1465
+ }
1333
1466
  const routes = [
1334
1467
  {
1335
1468
  method: 'GET',
@@ -1478,18 +1611,59 @@ async function handleWebApi(req, res, pathname, ctx, state) {
1478
1611
  return;
1479
1612
  }
1480
1613
 
1481
- const command = renderAgentPromptCommand(history.agentPromptCommand, prompt);
1482
1614
  await ensureWebContainer(ctx, state, containerName);
1483
- appendWebSessionMessage(state.webHistoryDir, containerName, 'user', prompt, { mode: 'agent' });
1615
+ const agentMeta = getAgentRuntimeMeta(history);
1616
+ const hasPriorConversation = hasAgentConversationHistory(history);
1617
+ let resumeAttempted = false;
1618
+ let resumeSucceeded = false;
1619
+ let resumeError = '';
1620
+ if (hasPriorConversation && agentMeta.resumeSupported && agentMeta.resumeCommand) {
1621
+ resumeAttempted = true;
1622
+ const resumeResult = await execCommandInWebContainer(ctx, containerName, agentMeta.resumeCommand);
1623
+ if (resumeResult.exitCode === 0) {
1624
+ resumeSucceeded = true;
1625
+ } else {
1626
+ resumeError = clipText(String(resumeResult.output || '(无输出)'), 1200);
1627
+ }
1628
+ }
1629
+
1630
+ const effectivePrompt = resumeSucceeded
1631
+ ? prompt
1632
+ : buildAgentPromptWithHistory(history, prompt);
1633
+ const command = renderAgentPromptCommand(history.agentPromptCommand, effectivePrompt);
1634
+ const contextMode = resumeSucceeded ? 'resume' : (hasPriorConversation ? 'history-injected' : 'first-turn');
1635
+ appendWebSessionMessage(state.webHistoryDir, containerName, 'user', prompt, {
1636
+ mode: 'agent',
1637
+ contextMode
1638
+ });
1484
1639
  const result = await execCommandInWebContainer(ctx, containerName, command);
1485
1640
  appendWebSessionMessage(
1486
1641
  state.webHistoryDir,
1487
1642
  containerName,
1488
1643
  'assistant',
1489
1644
  result.output,
1490
- { exitCode: result.exitCode, mode: 'agent' }
1645
+ {
1646
+ exitCode: result.exitCode,
1647
+ mode: 'agent',
1648
+ contextMode,
1649
+ resumeAttempted,
1650
+ resumeSucceeded
1651
+ }
1491
1652
  );
1492
- sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
1653
+ patchWebSessionAgentState(state.webHistoryDir, containerName, {
1654
+ agentProgram: agentMeta.agentProgram,
1655
+ resumeSupported: agentMeta.resumeSupported,
1656
+ lastResumeAt: resumeAttempted ? new Date().toISOString() : history.lastResumeAt || null,
1657
+ lastResumeOk: resumeAttempted ? resumeSucceeded : history.lastResumeOk,
1658
+ lastResumeError: resumeAttempted ? (resumeSucceeded ? '' : resumeError) : history.lastResumeError || ''
1659
+ });
1660
+ sendJson(res, 200, {
1661
+ exitCode: result.exitCode,
1662
+ output: result.output,
1663
+ contextMode,
1664
+ resumeAttempted,
1665
+ resumeSucceeded
1666
+ });
1493
1667
  }
1494
1668
  },
1495
1669
  {
@@ -1629,7 +1803,7 @@ async function startWebServer(options) {
1629
1803
  const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
1630
1804
  if (req.method === 'GET' && appFrontendMatch) {
1631
1805
  const assetName = appFrontendMatch[1];
1632
- if (!(assetName === 'app.css' || assetName === 'app.js')) {
1806
+ if (!(assetName === 'app.css' || assetName === 'app.js' || assetName === 'markdown.css' || assetName === 'markdown-renderer.js')) {
1633
1807
  sendHtml(res, 404, '<h1>404 Not Found</h1>');
1634
1808
  return;
1635
1809
  }
@@ -1640,7 +1814,7 @@ async function startWebServer(options) {
1640
1814
  const appVendorMatch = pathname.match(/^\/app\/vendor\/([A-Za-z0-9._-]+)$/);
1641
1815
  if (req.method === 'GET' && appVendorMatch) {
1642
1816
  const assetName = appVendorMatch[1];
1643
- if (!(assetName === 'xterm.css' || assetName === 'xterm.js' || assetName === 'xterm-addon-fit.js')) {
1817
+ if (!(assetName === 'xterm.css' || assetName === 'xterm.js' || assetName === 'xterm-addon-fit.js' || assetName === 'marked.min.js')) {
1644
1818
  sendHtml(res, 404, '<h1>404 Not Found</h1>');
1645
1819
  return;
1646
1820
  }
@@ -1687,6 +1861,30 @@ async function startWebServer(options) {
1687
1861
  return;
1688
1862
  }
1689
1863
 
1864
+ // [P1-03] Origin 校验,防止跨站 WebSocket 劫持(CSWSH)
1865
+ // 浏览器发起的 WebSocket 请求必须携带 Origin 头,非浏览器客户端(如 curl)不携带则放行
1866
+ const requestOrigin = req.headers.origin;
1867
+ if (requestOrigin) {
1868
+ const allowedOrigins = new Set();
1869
+ if (ctx.serverHost === '0.0.0.0') {
1870
+ // 0.0.0.0 监听时,以请求的 Host 头构造允许来源
1871
+ const hostHeader = req.headers.host || '';
1872
+ if (hostHeader) {
1873
+ allowedOrigins.add(`http://${hostHeader}`);
1874
+ allowedOrigins.add(`https://${hostHeader}`);
1875
+ }
1876
+ } else {
1877
+ allowedOrigins.add(`http://${formatUrlHost(ctx.serverHost)}:${listenPort}`);
1878
+ if (ctx.serverHost === '127.0.0.1') {
1879
+ allowedOrigins.add(`http://localhost:${listenPort}`);
1880
+ }
1881
+ }
1882
+ if (allowedOrigins.size > 0 && !allowedOrigins.has(requestOrigin)) {
1883
+ sendWebSocketUpgradeError(socket, 403, 'Forbidden');
1884
+ return;
1885
+ }
1886
+ }
1887
+
1690
1888
  const authSession = getWebAuthSession(state, req);
1691
1889
  if (!authSession) {
1692
1890
  sendWebSocketUpgradeError(socket, 401, 'UNAUTHORIZED');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "5.2.0",
3
+ "version": "5.2.8",
4
4
  "imageVersion": "1.8.1-common",
5
5
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",
6
6
  "keywords": [
@@ -58,6 +58,7 @@
58
58
  "@xterm/xterm": "^6.0.0",
59
59
  "commander": "^12.0.0",
60
60
  "json5": "^2.2.3",
61
+ "marked": "^12.0.2",
61
62
  "playwright": "1.58.2",
62
63
  "ws": "^8.19.0"
63
64
  },