@xcanwin/manyoyo 8.6.8 → 8.6.9

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
@@ -12,7 +12,10 @@ const JSON5 = require('json5');
12
12
  const { buildContainerRunArgs, hasNetworkArg } = require('../container-run');
13
13
  const { buildContainerIntegration, mergeIntegration } = require('../plugin');
14
14
  const { applyAptMirror } = require('../mirrors');
15
+ const { BUILTIN_AGENT_INSTRUCTIONS, buildAgentInstructions, toTomlBasicString } = require('../agent-instructions');
15
16
  const containerState = require('../container-state');
17
+ const sessionMeta = require('./session-meta');
18
+ const gitChanges = require('./git-changes');
16
19
  const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
17
20
  const { createNetworkManager, NETWORK_NAME } = require('../container-network');
18
21
  const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../network-policy');
@@ -34,7 +37,7 @@ const { resolveRuntimeConfig } = require('../runtime-resolver');
34
37
  const { AGENT_ENV_SCHEMA } = require('../init-config');
35
38
  const { ensureImagePresent, pullImageProcess } = require('../image-pull');
36
39
  const { waitForImport } = require('../offline-import');
37
- const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw } = require('../setup-config');
40
+ const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw } = require('../setup-config');
38
41
  const { listSetupAgents, getConfiguredAgents, buildAgentRunProfile, classifyConnectionResult, redactSecrets, validateSetupPassword, listMirrorPresets } = require('../setup');
39
42
  const { applyDoctorFixes } = require('../doctor');
40
43
  const { writeConfigFileSecure } = require('../secure-file');
@@ -96,6 +99,7 @@ const NATIVE_SESSION_RESUME_PROGRAMS = new Set(['claude', 'codex', 'opencode']);
96
99
  const WEB_FILE_PREVIEW_MAX_BYTES = 512 * 1024;
97
100
  const WEB_FILE_EDIT_MAX_BYTES = 2 * 1024 * 1024;
98
101
  const WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER = 4;
102
+ const WEB_FS_LIST_MAX_LIMIT = 200;
99
103
  const IMAGE_EXTENSION_CONTENT_TYPES = {
100
104
  '.png': 'image/png',
101
105
  '.jpg': 'image/jpeg',
@@ -221,7 +225,11 @@ function createEmptyWebAgentSession(agentId, agentName) {
221
225
  usageTotal: null,
222
226
  model: '',
223
227
  queue: [],
224
- queuePaused: false
228
+ queuePaused: false,
229
+ title: '',
230
+ firstPrompt: '',
231
+ pinned: false,
232
+ slashCommands: null
225
233
  };
226
234
  }
227
235
 
@@ -275,7 +283,23 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
275
283
  usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
276
284
  model: normalizeStoredAgentModelValue(source.model),
277
285
  queue: agentQueue.normalizeStoredQueue(source.queue),
278
- queuePaused: source.queuePaused === true
286
+ queuePaused: source.queuePaused === true,
287
+ title: sessionMeta.normalizeTitle(source.title),
288
+ firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
289
+ pinned: source.pinned === true,
290
+ slashCommands: normalizeStoredSlashCommands(source.slashCommands)
291
+ };
292
+ }
293
+
294
+ // claude 每次运行开头的 init 事件里带的命令列表:all 是全部,skills 是其中的 skill,terminal 是只在终端里可用的
295
+ function normalizeStoredSlashCommands(value) {
296
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
297
+ return null;
298
+ }
299
+ return {
300
+ all: sessionMeta.normalizeSlashCommands(value.all),
301
+ skills: sessionMeta.normalizeSlashCommands(value.skills),
302
+ terminal: sessionMeta.normalizeSlashCommands(value.terminal)
279
303
  };
280
304
  }
281
305
 
@@ -570,6 +594,10 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
570
594
  if (!agentSession.createdAt) {
571
595
  agentSession.createdAt = timestamp;
572
596
  }
597
+ // 首条 Agent 对话消息只记一次,作为没有标题时的会话名(消息会被裁剪,不能现算)
598
+ if (role === 'user' && extra.mode !== 'command' && !agentSession.firstPrompt) {
599
+ agentSession.firstPrompt = sessionMeta.normalizeFirstPrompt(content);
600
+ }
573
601
  agentSession.messages.push(message);
574
602
 
575
603
  if (agentSession.messages.length > WEB_HISTORY_MAX_MESSAGES) {
@@ -911,7 +939,8 @@ function quoteBashSingleValue(value) {
911
939
  function renderAgentPromptCommand(template, prompt) {
912
940
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
913
941
  const safePrompt = quoteBashSingleValue(prompt);
914
- return templateText.replace(/\{prompt\}/g, safePrompt);
942
+ // 用函数返回替换值:字符串形式会把提示词里的 $' $` $& 当成替换模式
943
+ return templateText.replace(/\{prompt\}/g, () => safePrompt);
915
944
  }
916
945
 
917
946
  function buildCodexAgentExecCommand(template, prompt, options = {}) {
@@ -929,12 +958,17 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
929
958
  const injectedFlags = hasJson ? '' : ' --json';
930
959
  const hasModel = /(?:^|\s)(?:-m|--model)(?:\s|$)/.test(suffix);
931
960
  const modelFlag = model && !hasModel ? ` --model ${quoteBashSingleValue(model)}` : '';
961
+ // 环境说明:模板里已经写了 developer_instructions 就以用户的为准
962
+ const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
963
+ const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
964
+ ? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
965
+ : '';
932
966
  if (sessionId) {
933
967
  const promptIndex = suffix.indexOf('{prompt}');
934
968
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
935
- codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${resumeSuffix}`;
969
+ codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${resumeSuffix}`;
936
970
  } else {
937
- codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${suffix}`;
971
+ codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${suffix}`;
938
972
  }
939
973
  }
940
974
  return codexTemplate === templateText
@@ -948,10 +982,12 @@ function prependAgentFlags(commandText, matchPattern, flagSpecs) {
948
982
  return String(commandText || '');
949
983
  }
950
984
  const prefix = matched[1] || '';
951
- let suffix = matched[matched.length - 1] || '';
985
+ // “模板里是否已经写了这个参数”只看原模板:插进去的参数值(比如环境说明正文里的 “-r”)不能让后面的参数被跳过
986
+ const original = matched[matched.length - 1] || '';
987
+ let suffix = original;
952
988
  for (let i = flagSpecs.length - 1; i >= 0; i -= 1) {
953
989
  const spec = flagSpecs[i];
954
- if (!spec || !spec.flag || !(spec.pattern instanceof RegExp) || spec.pattern.test(suffix)) {
990
+ if (!spec || !spec.flag || !(spec.pattern instanceof RegExp) || spec.pattern.test(original)) {
955
991
  continue;
956
992
  }
957
993
  suffix = ` ${spec.flag}${suffix}`;
@@ -978,6 +1014,14 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
978
1014
  if (model) {
979
1015
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
980
1016
  }
1017
+ // 环境说明:追加在内置系统提示之后;模板里已经写了 --append-system-prompt(-file) 就以用户的为准
1018
+ const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
1019
+ if (instructions) {
1020
+ flagSpecs.push({
1021
+ flag: `--append-system-prompt ${quoteBashSingleValue(instructions)}`,
1022
+ pattern: /(?:^|\s)--append-system-prompt(?:-file)?(?:\s|$)/
1023
+ });
1024
+ }
981
1025
  // 子 Agent 说的话和思考默认不转发(v2.1.211+ 才有 --forward-subagent-text,旧版本不认这个参数会直接报错)。
982
1026
  // 'auto':在容器里随命令探测(claude --help 约 50ms),支持就展开成这个参数、不支持展开成空;true:直接加。
983
1027
  // 'auto' 的参数放在整条命令末尾,不改变前面已有参数的相对顺序
@@ -1035,14 +1079,20 @@ function buildOpenCodeAgentExecCommand(template, prompt, options = {}) {
1035
1079
  if (model) {
1036
1080
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
1037
1081
  }
1038
- const opencodeTemplate = prependAgentFlags(
1039
- templateText,
1040
- /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)opencode\b.*?\s+run\b)(.*)$/,
1041
- flagSpecs
1042
- );
1043
- return opencodeTemplate === templateText
1082
+ const opencodePattern = /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)opencode\b.*?\s+run\b)(.*)$/;
1083
+ const opencodeTemplate = prependAgentFlags(templateText, opencodePattern, flagSpecs);
1084
+ const rendered = opencodeTemplate === templateText
1044
1085
  ? renderAgentPromptCommand(templateText, prompt)
1045
1086
  : renderAgentPromptCommand(opencodeTemplate, prompt);
1087
+ // 环境说明:opencode 的 instructions 只认文件路径。OPENCODE_CONFIG_CONTENT 会与用户自己的配置合并
1088
+ //(instructions 数组拼接,实测 1.19.3),用户已经设置了这个变量或在模板里写了它时不动
1089
+ const instructions = options && typeof options.instructions === 'string' ? options.instructions : '';
1090
+ if (!instructions || !opencodePattern.test(templateText) || /OPENCODE_CONFIG_CONTENT/.test(templateText)) {
1091
+ return rendered;
1092
+ }
1093
+ const file = '/tmp/manyoyo-agent-instructions.md';
1094
+ const content = quoteBashSingleValue(JSON.stringify({ instructions: [file] }));
1095
+ return `if [ -z "\${OPENCODE_CONFIG_CONTENT:-}" ]; then printf '%s' ${quoteBashSingleValue(instructions)} > ${file} && export OPENCODE_CONFIG_CONTENT=${content}; fi; ${rendered}`;
1046
1096
  }
1047
1097
 
1048
1098
  function buildWebAgentExecCommand(template, prompt, agentProgram, options = {}) {
@@ -1214,6 +1264,23 @@ function extractClaudeSessionId(text) {
1214
1264
  return '';
1215
1265
  }
1216
1266
 
1267
+ function extractClaudeInitSlashCommands(text) {
1268
+ for (const rawLine of String(text || '').split('\n')) {
1269
+ const payload = parseJsonObjectLine(rawLine);
1270
+ if (payload && payload.type === 'system' && payload.subtype === 'init') {
1271
+ if (!Array.isArray(payload.slash_commands)) {
1272
+ return null;
1273
+ }
1274
+ return normalizeStoredSlashCommands({
1275
+ all: payload.slash_commands,
1276
+ skills: payload.skills,
1277
+ terminal: payload.terminal_slash_commands
1278
+ });
1279
+ }
1280
+ }
1281
+ return null;
1282
+ }
1283
+
1217
1284
  function extractOpenCodeSessionId(text) {
1218
1285
  for (const rawLine of String(text || '').split('\n')) {
1219
1286
  const payload = parseJsonObjectLine(rawLine);
@@ -2313,10 +2380,14 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2313
2380
  const effectivePrompt = resumeSucceeded
2314
2381
  ? prompt
2315
2382
  : buildAgentPromptWithHistory(agentSession, prompt);
2383
+ // 环境说明每次执行时现读配置:改了设置不用重建容器;运行配置名记在创建时的快照里(旧会话没有,用全局配置)
2384
+ const configSnapshot = readWebConfigSnapshot(state.webConfigPath);
2385
+ const runName = history.runtimeSnapshot && typeof history.runtimeSnapshot.run === 'string' ? history.runtimeSnapshot.run : '';
2316
2386
  const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
2317
2387
  sessionId: sessionIdForRun,
2318
2388
  model: agentSession.model || '',
2319
- forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false
2389
+ forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
2390
+ instructions: buildAgentInstructions(configSnapshot.parseError ? {} : configSnapshot.parsed, runName)
2320
2391
  });
2321
2392
  const contextMode = resumeSucceeded ? 'resume' : (hasPriorConversation ? 'history-injected' : 'first-turn');
2322
2393
 
@@ -2357,10 +2428,66 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
2357
2428
  if (turnUsage) {
2358
2429
  patch.usageTotal = accumulateUsageTotal(agentSession.usageTotal, turnUsage);
2359
2430
  }
2431
+ if (agentMeta.agentProgram === 'claude') {
2432
+ const slashCommands = extractClaudeInitSlashCommands(result.stdout);
2433
+ if (slashCommands) {
2434
+ patch.slashCommands = slashCommands;
2435
+ }
2436
+ }
2360
2437
  patchWebAgentSessionState(state.webHistoryDir, sessionRef, patch);
2361
2438
  return replyMessage;
2362
2439
  }
2363
2440
 
2441
+ function resolveWebSessionWorkDir(state, sessionRef) {
2442
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2443
+ const applied = history.applied && typeof history.applied === 'object' ? history.applied : {};
2444
+ return typeof applied.containerPath === 'string' && applied.containerPath ? applied.containerPath : '/';
2445
+ }
2446
+
2447
+ async function readWebGitChanges(ctx, state, sessionRef) {
2448
+ const result = await execCommandInWebContainer(
2449
+ ctx,
2450
+ sessionRef.containerName,
2451
+ gitChanges.buildGitChangesCommand(resolveWebSessionWorkDir(state, sessionRef)),
2452
+ { withEnv: false }
2453
+ );
2454
+ return gitChanges.parseGitChangesOutput(result.stdout);
2455
+ }
2456
+
2457
+ // 每轮结束后在后台读一次 Agent 自己生成的标题(目前只有 claude 会写 ai-title,交互模式实测;
2458
+ // 其他 Agent 只用首条消息)。不阻塞本轮结果;读不到或失败都静默——每轮都会触发,属于高频路径,不写日志
2459
+ async function refreshWebAgentTitle(ctx, state, sessionRef, agentProgram) {
2460
+ if (agentProgram !== 'claude') {
2461
+ return '';
2462
+ }
2463
+ try {
2464
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2465
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
2466
+ const command = sessionMeta.buildClaudeTitleReadCommand(agentSession && agentSession.engineSessionId);
2467
+ if (!command) {
2468
+ return '';
2469
+ }
2470
+ const sessionId = agentSession.engineSessionId;
2471
+ const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
2472
+ const title = sessionMeta.parseClaudeTitleOutput(result.stdout);
2473
+ // 读标题期间 AGENT 可能被删除或换了引擎会话:写回前重新读一次,不在了就放弃(不能把它重新建出来)
2474
+ const fresh = getWebAgentSession(
2475
+ loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
2476
+ sessionRef.agentId,
2477
+ { includeArchived: true }
2478
+ );
2479
+ if (!fresh || fresh.engineSessionId !== sessionId) {
2480
+ return '';
2481
+ }
2482
+ if (title && title !== fresh.title) {
2483
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { title });
2484
+ }
2485
+ return title;
2486
+ } catch (e) {
2487
+ return '';
2488
+ }
2489
+ }
2490
+
2364
2491
  function appendWebAgentTraceMessage(webHistoryDir, sessionRefOrContainerName, content, extra = {}) {
2365
2492
  const text = String(content || '').trim();
2366
2493
  if (!text) {
@@ -2841,6 +2968,9 @@ function validateWebConfigShape(configObject) {
2841
2968
  if (hasOwn(config, 'agentPromptCommand')) {
2842
2969
  normalizeAgentPromptCommandTemplate(config.agentPromptCommand, 'agentPromptCommand');
2843
2970
  }
2971
+ if (hasOwn(config, 'agentInstructions') && typeof config.agentInstructions !== 'string') {
2972
+ throw new Error('agentInstructions 必须是字符串');
2973
+ }
2844
2974
  if (hasOwn(config, 'runs')) {
2845
2975
  const runs = config.runs;
2846
2976
  if (runs !== undefined && (typeof runs !== 'object' || runs === null || Array.isArray(runs))) {
@@ -2851,6 +2981,9 @@ function validateWebConfigShape(configObject) {
2851
2981
  if (hasOwn(normalizedRun, 'agentPromptCommand')) {
2852
2982
  normalizeAgentPromptCommandTemplate(normalizedRun.agentPromptCommand, `runs.${runName}.agentPromptCommand`);
2853
2983
  }
2984
+ if (hasOwn(normalizedRun, 'agentInstructions') && typeof normalizedRun.agentInstructions !== 'string') {
2985
+ throw new Error(`runs.${runName}.agentInstructions 必须是字符串`);
2986
+ }
2854
2987
  });
2855
2988
  }
2856
2989
  }
@@ -3185,6 +3318,7 @@ function buildCreateRuntime(ctx, state, payload) {
3185
3318
  // 仅供服务端内部复用(如"创建相同配置容器"/"复制容器"),绝不能透传给任何对外响应字段,
3186
3319
  // 因为 env/volumes/ports 可能包含密钥原始值(对外响应不暴露它们)。
3187
3320
  runtimeSnapshot: {
3321
+ run: runName || '',
3188
3322
  hostPath,
3189
3323
  containerPath,
3190
3324
  imageName,
@@ -3936,13 +4070,19 @@ async function execJsonCommandInWebContainer(ctx, containerName, command) {
3936
4070
  }
3937
4071
  }
3938
4072
 
3939
- function buildContainerFileListCommand(requestedPath) {
4073
+ // options.limit / options.prefix 供输入框 @ 补全用:先按名字前缀过滤、按“目录在前 + 名字”排序后只取前 limit 项,
4074
+ // 只对这些项做 lstat(大目录里全量 lstat 才是慢的部分)
4075
+ function buildContainerFileListCommand(requestedPath, options = {}) {
4076
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 0;
4077
+ const prefix = typeof options.prefix === 'string' ? options.prefix.toLowerCase() : '';
3940
4078
  return buildWebContainerNodeCommand(`
3941
4079
  // __MANYOYO_FS_LIST__
3942
4080
  const fs = require('fs');
3943
4081
  const path = require('path');
3944
4082
 
3945
4083
  const requestedPath = ${JSON.stringify(String(requestedPath || '/'))};
4084
+ const limit = ${JSON.stringify(limit)};
4085
+ const prefix = ${JSON.stringify(prefix)};
3946
4086
 
3947
4087
  try {
3948
4088
  const realPath = fs.realpathSync(requestedPath);
@@ -3953,7 +4093,20 @@ try {
3953
4093
 
3954
4094
  const root = path.parse(realPath).root;
3955
4095
  const parentPath = realPath === root ? '' : path.dirname(realPath);
3956
- const entries = fs.readdirSync(realPath, { withFileTypes: true })
4096
+ let dirents = fs.readdirSync(realPath, { withFileTypes: true });
4097
+ if (prefix) {
4098
+ dirents = dirents.filter(entry => entry.name.toLowerCase().startsWith(prefix));
4099
+ }
4100
+ let truncated = false;
4101
+ if (limit > 0 && dirents.length > limit) {
4102
+ dirents.sort((a, b) => {
4103
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
4104
+ return a.name.localeCompare(b.name, 'zh-CN');
4105
+ });
4106
+ dirents = dirents.slice(0, limit);
4107
+ truncated = true;
4108
+ }
4109
+ const entries = dirents
3957
4110
  .map(entry => {
3958
4111
  const fullPath = path.join(realPath, entry.name);
3959
4112
  let itemStat = null;
@@ -4002,7 +4155,8 @@ try {
4002
4155
  process.stdout.write(JSON.stringify({
4003
4156
  path: realPath,
4004
4157
  parentPath,
4005
- entries
4158
+ entries,
4159
+ ...(truncated ? { truncated: true } : {})
4006
4160
  }));
4007
4161
  } catch (e) {
4008
4162
  process.stdout.write(JSON.stringify({
@@ -5127,6 +5281,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
5127
5281
  resumeError,
5128
5282
  engineSessionId
5129
5283
  }, result);
5284
+ void refreshWebAgentTitle(ctx, state, sessionRef, agentMeta.agentProgram);
5130
5285
  emitStreamEvent({
5131
5286
  type: 'result',
5132
5287
  exitCode: result.exitCode,
@@ -5451,6 +5606,9 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
5451
5606
  agentId,
5452
5607
  agentName: agentSession.agentName,
5453
5608
  agentRemark: agentSession.remark || '',
5609
+ agentTitle: agentSession.title || '',
5610
+ firstPrompt: agentSession.firstPrompt || '',
5611
+ pinned: agentSession.pinned === true,
5454
5612
  containerRemark: history.remark || '',
5455
5613
  status: containerInfo.status || 'history',
5456
5614
  image: containerInfo.image || '',
@@ -6223,6 +6381,51 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6223
6381
  });
6224
6382
  }
6225
6383
  },
6384
+ {
6385
+ // 系统设置“Agent 环境说明”:内置说明(只读)+ 全局配置里用户追加的说明
6386
+ method: 'GET',
6387
+ match: currentPath => currentPath === '/api/agent-instructions' ? [] : null,
6388
+ handler: async () => {
6389
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
6390
+ const config = snapshot.parseError ? {} : snapshot.parsed;
6391
+ sendJson(res, 200, {
6392
+ builtin: BUILTIN_AGENT_INSTRUCTIONS,
6393
+ custom: typeof config.agentInstructions === 'string' ? config.agentInstructions : ''
6394
+ });
6395
+ }
6396
+ },
6397
+ {
6398
+ method: 'POST',
6399
+ match: currentPath => currentPath === '/api/agent-instructions' ? [] : null,
6400
+ handler: async () => {
6401
+ const payload = toPlainObject(await readJsonBody(req));
6402
+ if (typeof payload.custom !== 'string') {
6403
+ sendJson(res, 400, { error: 'custom 必须是字符串' });
6404
+ return;
6405
+ }
6406
+ const custom = payload.custom.slice(0, 4000);
6407
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
6408
+ if (snapshot.parseError) {
6409
+ sendJson(res, 400, { error: '当前配置解析失败,无法继续', detail: snapshot.parseError });
6410
+ return;
6411
+ }
6412
+ // 没写过、也没填内容时不动配置文件
6413
+ if (!custom.trim() && !hasOwn(snapshot.parsed, 'agentInstructions')) {
6414
+ sendJson(res, 200, { saved: false, custom });
6415
+ return;
6416
+ }
6417
+ let nextRaw;
6418
+ try {
6419
+ nextRaw = buildAgentInstructionsConfigRaw(baseRawOf(snapshot), custom);
6420
+ parseAndValidateConfigRaw(nextRaw);
6421
+ } catch (e) {
6422
+ sendJson(res, 400, { error: '配置写入失败' });
6423
+ return;
6424
+ }
6425
+ writeConfigFileSecure(path.resolve(state.webConfigPath), nextRaw);
6426
+ sendJson(res, 200, { saved: true, custom });
6427
+ }
6428
+ },
6226
6429
  {
6227
6430
  method: 'GET',
6228
6431
  match: currentPath => currentPath === '/api/config' ? [] : null,
@@ -6727,6 +6930,26 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6727
6930
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), remark });
6728
6931
  }
6729
6932
  },
6933
+ {
6934
+ method: 'POST',
6935
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-pin$/),
6936
+ handler: async match => {
6937
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
6938
+ if (!sessionRef) {
6939
+ return;
6940
+ }
6941
+ const payload = await readJsonBody(req);
6942
+ const pinned = payload.pinned === true;
6943
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
6944
+ // 默认 AGENT 在还没有任何消息时也会出现在列表里(历史里还没有它的记录),允许置顶
6945
+ if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID && !getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })) {
6946
+ sendJson(res, 404, { error: 'AGENT 不存在' });
6947
+ return;
6948
+ }
6949
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { pinned });
6950
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), pinned });
6951
+ }
6952
+ },
6730
6953
  {
6731
6954
  method: 'GET',
6732
6955
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
@@ -6806,12 +7029,17 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6806
7029
  }
6807
7030
  const requestUrl = new URL(req.url || '/api/sessions/x/fs/list', 'http://localhost');
6808
7031
  const targetPath = String(requestUrl.searchParams.get('path') || '/').trim() || '/';
7032
+ const limitParam = Number.parseInt(requestUrl.searchParams.get('limit') || '', 10);
7033
+ const listOptions = {
7034
+ limit: Number.isInteger(limitParam) && limitParam > 0 ? Math.min(limitParam, WEB_FS_LIST_MAX_LIMIT) : 0,
7035
+ prefix: String(requestUrl.searchParams.get('prefix') || '').slice(0, 255)
7036
+ };
6809
7037
 
6810
7038
  await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
6811
7039
  const payload = await execJsonCommandInWebContainer(
6812
7040
  ctx,
6813
7041
  sessionRef.containerName,
6814
- buildContainerFileListCommand(targetPath)
7042
+ buildContainerFileListCommand(targetPath, listOptions)
6815
7043
  );
6816
7044
  if (payload && payload.error) {
6817
7045
  sendJson(res, 400, { error: payload.error });
@@ -7040,6 +7268,68 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7040
7268
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail }, {}, { autoEtag: true });
7041
7269
  }
7042
7270
  },
7271
+ {
7272
+ // “改动”视图:工作目录所在 git 仓库的改动列表(按需请求,前端不轮询)
7273
+ method: 'GET',
7274
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/changes$/),
7275
+ handler: async match => {
7276
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7277
+ if (!sessionRef) {
7278
+ return;
7279
+ }
7280
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
7281
+ const changes = await readWebGitChanges(ctx, state, sessionRef);
7282
+ sendJson(res, 200, changes);
7283
+ }
7284
+ },
7285
+ {
7286
+ method: 'GET',
7287
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/git\/diff$/),
7288
+ handler: async match => {
7289
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7290
+ if (!sessionRef) {
7291
+ return;
7292
+ }
7293
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
7294
+ const filePath = String(requestUrl.searchParams.get('path') || '');
7295
+ if (!gitChanges.isAcceptableDiffPath(filePath)) {
7296
+ sendJson(res, 400, { error: '文件路径不合法' });
7297
+ return;
7298
+ }
7299
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
7300
+ // 只给改动列表里出现过的文件看 diff:路径由服务端重新取一次核对,不信任请求
7301
+ const changes = await readWebGitChanges(ctx, state, sessionRef);
7302
+ const entry = changes.files.find(file => file.path === filePath);
7303
+ if (!entry || entry.status === 'untracked') {
7304
+ sendJson(res, 404, { error: '该文件不在改动列表里' });
7305
+ return;
7306
+ }
7307
+ const result = await execCommandInWebContainer(
7308
+ ctx,
7309
+ sessionRef.containerName,
7310
+ gitChanges.buildGitDiffCommand(resolveWebSessionWorkDir(state, sessionRef), filePath),
7311
+ { withEnv: false }
7312
+ );
7313
+ sendJson(res, 200, { path: filePath, ...gitChanges.parseGitDiffOutput(result.stdout) });
7314
+ }
7315
+ },
7316
+ {
7317
+ // 输入框里打 / 时的候选:只列实测能在 Web 端执行的(见 lib/web/session-meta.js)
7318
+ method: 'GET',
7319
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/slash-commands$/),
7320
+ handler: async match => {
7321
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7322
+ if (!sessionRef) {
7323
+ return;
7324
+ }
7325
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7326
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
7327
+ sendJson(res, 200, {
7328
+ name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
7329
+ commands: sessionMeta.listUsableSlashCommands(agentSession && agentSession.slashCommands)
7330
+ });
7331
+ }
7332
+ },
7043
7333
  {
7044
7334
  method: 'GET',
7045
7335
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/models$/),
@@ -7279,6 +7569,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7279
7569
  resumeError,
7280
7570
  engineSessionId
7281
7571
  }, result);
7572
+ void refreshWebAgentTitle(ctx, state, sessionRef, agentMeta.agentProgram);
7282
7573
  sendJson(res, 200, {
7283
7574
  exitCode: result.exitCode,
7284
7575
  output: result.output,
@@ -7883,6 +8174,8 @@ module.exports = {
7883
8174
  __testing: {
7884
8175
  prepareStructuredTraceEvents,
7885
8176
  buildClaudeAgentExecCommand,
8177
+ buildWebAgentExecCommand,
8178
+ buildContainerFileListCommand,
7886
8179
  extractContentDeltaFromPayload,
7887
8180
  extractAgentMessageFromStructuredOutput,
7888
8181
  appendWebSessionMessage,
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ // 会话的展示信息(标题、首条消息、斜杠命令列表):来自容器里 Agent 自己写的文件或输出,
4
+ // 都按不可信文本处理——只保留可打印字符、限制长度,前端一律当纯文本显示。
5
+
6
+ const TITLE_MAX_CHARS = 60;
7
+ const FIRST_PROMPT_MAX_CHARS = 80;
8
+ // 只读会话文件尾部:标题记录在 Claude 每轮结束时追加,读尾部就够了,文件再大也不慢
9
+ const TITLE_TAIL_BYTES = 256 * 1024;
10
+ const SLASH_COMMAND_PATTERN = /^[A-Za-z0-9:_.-]{1,64}$/;
11
+ const SLASH_COMMANDS_MAX = 200;
12
+ const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
13
+
14
+ function toDisplayText(value, maxChars) {
15
+ if (typeof value !== 'string') {
16
+ return '';
17
+ }
18
+ const text = value
19
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, ' ')
20
+ .replace(/\s+/g, ' ')
21
+ .trim();
22
+ return Array.from(text).slice(0, maxChars).join('');
23
+ }
24
+
25
+ function normalizeTitle(value) {
26
+ return toDisplayText(value, TITLE_MAX_CHARS);
27
+ }
28
+
29
+ function normalizeFirstPrompt(value) {
30
+ return toDisplayText(value, FIRST_PROMPT_MAX_CHARS);
31
+ }
32
+
33
+ function normalizeSlashCommands(value) {
34
+ if (!Array.isArray(value)) {
35
+ return [];
36
+ }
37
+ const seen = new Set();
38
+ const result = [];
39
+ for (const item of value) {
40
+ const name = typeof item === 'string' ? item.replace(/^\//, '') : '';
41
+ if (!SLASH_COMMAND_PATTERN.test(name) || seen.has(name)) {
42
+ continue;
43
+ }
44
+ seen.add(name);
45
+ result.push(name);
46
+ if (result.length >= SLASH_COMMANDS_MAX) {
47
+ break;
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function isClaudeSessionId(value) {
54
+ return typeof value === 'string' && CLAUDE_SESSION_ID_PATTERN.test(value);
55
+ }
56
+
57
+ // 在容器里执行的只读命令:找到这个会话的 jsonl,只读尾部,只留标题类记录。
58
+ // ai-title 是 claude 交互模式自动生成的(-p 下实测不生成);custom-title 是 /rename 写的,优先
59
+ // session id 已按 UUID 格式校验,拼进命令前不需要再转义
60
+ function buildClaudeTitleReadCommand(sessionId) {
61
+ if (!isClaudeSessionId(sessionId)) {
62
+ return '';
63
+ }
64
+ return [
65
+ `for f in "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/${sessionId}.jsonl; do`,
66
+ `[ -f "$f" ] && tail -c ${TITLE_TAIL_BYTES} "$f" | grep -E '"type":"(ai-title|custom-title)"' | tail -n 20;`,
67
+ 'break; done'
68
+ ].join(' ');
69
+ }
70
+
71
+ function parseClaudeTitleOutput(output) {
72
+ const lines = String(output || '').split('\n').map(line => line.trim()).filter(Boolean);
73
+ let custom = '';
74
+ let generated = '';
75
+ for (const line of lines) {
76
+ try {
77
+ const record = JSON.parse(line);
78
+ if (record && record.type === 'custom-title') {
79
+ custom = normalizeTitle(record.customTitle) || custom;
80
+ } else if (record && record.type === 'ai-title') {
81
+ generated = normalizeTitle(record.aiTitle) || generated;
82
+ }
83
+ } catch (e) {
84
+ // 尾部第一行可能被截断,跳过
85
+ }
86
+ }
87
+ return custom || generated;
88
+ }
89
+
90
+ // 在 claude -p(Web 端的执行方式)下实测有用的内置命令(2026-10-19,claude 2.1.290):
91
+ // context / usage / recap / mcp / list-agents / rename 都返回了正常结果。不列的:compact(-p 下没有压缩,
92
+ // 输出和 usage 一样)、agents(已移除)、insights(报告写在容器里的 file:// 地址,网页打不开)、
93
+ // model / effort / goal(只影响这一次进程,网页里换模型用模型菜单)、clear / init 等改状态的没测,宁缺毋滥。
94
+ // skill 类命令会展开成提示词,实测可用
95
+ const HEADLESS_BUILTIN_COMMANDS = new Set(['context', 'usage', 'recap', 'mcp', 'list-agents', 'rename']);
96
+
97
+ function listUsableSlashCommands(stored) {
98
+ if (!stored || !Array.isArray(stored.all)) {
99
+ return [];
100
+ }
101
+ const skills = new Set(stored.skills || []);
102
+ const terminal = new Set(stored.terminal || []);
103
+ return stored.all.filter(name => !terminal.has(name) && (skills.has(name) || HEADLESS_BUILTIN_COMMANDS.has(name)));
104
+ }
105
+
106
+ module.exports = {
107
+ HEADLESS_BUILTIN_COMMANDS,
108
+ listUsableSlashCommands,
109
+ TITLE_MAX_CHARS,
110
+ FIRST_PROMPT_MAX_CHARS,
111
+ normalizeTitle,
112
+ normalizeFirstPrompt,
113
+ normalizeSlashCommands,
114
+ isClaudeSessionId,
115
+ buildClaudeTitleReadCommand,
116
+ parseClaudeTitleOutput
117
+ };
@@ -65,6 +65,8 @@
65
65
  "shell": "",
66
66
  "shellSuffix": "",
67
67
  "agentPromptCommand": "",
68
+ // 网页里执行 Agent 时追加的说明(接在内置环境说明后面),例如 "回答用中文"
69
+ "agentInstructions": "",
68
70
  "yolo": "",
69
71
  "quiet": ["tip", "cmd"],
70
72