@xcanwin/manyoyo 8.6.8 → 8.6.10

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
@@ -26,6 +26,7 @@ const { listServeInstances, formatServeTable } = require('../lib/serve-instances
26
26
  const { pruneDanglingImages: pruneDanglingImagesSafely } = require('../lib/image-prune');
27
27
  const { runUninstall, readPid, defaultIsManyoyoServe, defaultKill } = require('../lib/uninstall');
28
28
  const appUpdate = require('../lib/app-update');
29
+ const codeUpdate = require('../lib/code-update');
29
30
  const { getGithubFetch } = require('../lib/net-proxy');
30
31
  const { createUpdateChecker } = require('../lib/update-check');
31
32
  const { getLoginTokenDir, issueLoginToken } = require('../lib/login-token');
@@ -348,6 +349,7 @@ function installServeProcessDiagnostics(logger) {
348
349
  * @property {string[]} [volumes] - 挂载卷数组
349
350
  * @property {Object.<string, Object>} [plugins] - 可选插件配置映射(如 plugins.playwright)
350
351
  * @property {{title?:string}} [serve] - serve 命令专属配置(如自定义网页标题,留空字符串则显示为空)
352
+ * @property {string} [agentInstructions] - 补充系统提示词(接在默认系统提示词后面,作为网页新建容器的初始内容;runs.<name> 可覆盖)
351
353
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
352
354
  * @property {string} [yolo] - YOLO 模式
353
355
  * @property {string} [containerMode] - 容器模式
@@ -861,7 +863,30 @@ async function updateOfflineInstall(mode, options, globalConfig) {
861
863
  const installed = appUpdate.currentVersion(appRoot) || mode.version;
862
864
  console.log(`${CYAN}🔄 当前版本: ${installed}${NC}`);
863
865
  let result;
864
- if (options.file) {
866
+ if (options.code) {
867
+ const github = getGithubFetch();
868
+ if (github.notice) console.log(`${CYAN}🌐 ${github.notice}${NC}`);
869
+ console.log(`${CYAN}🔄 正在查询分支 ${options.code} 的代码版...${NC}`);
870
+ try {
871
+ // MANYOYO_TEST_CODE_BASE 只给测试用(指向本地下载站替身)
872
+ const outcome = await codeUpdate.installCodeUpdate({
873
+ appRoot, branch: options.code, installed, fetchImpl: github.fetch, targetOs: appUpdate.platformOs(),
874
+ downloadBase: process.env.MANYOYO_TEST_CODE_BASE || undefined, log: line => console.log(` ${line}`)
875
+ });
876
+ const label = `${options.code}@${outcome.info.commit.slice(0, 7)}`;
877
+ if (outcome.status === 'same') {
878
+ console.log(`${GREEN}✅ 已是 ${label},无需更新${NC}`);
879
+ return;
880
+ }
881
+ console.log(`${CYAN} ${label} ${outcome.info.subject}(构建于 ${outcome.info.builtAt || '未知时间'})${NC}`);
882
+ result = outcome;
883
+ } catch (error) {
884
+ if (error instanceof appUpdate.UpdateError && error.code === 'NETWORK') {
885
+ error.message += `\n ${github.proxy && github.proxy.proxyEnv ? `当前代理: ${github.proxy.display},请确认代理可用` : '有代理的话先在终端设置: export https_proxy=http://127.0.0.1:7890'}`;
886
+ }
887
+ throw error;
888
+ }
889
+ } else if (options.file) {
865
890
  const info = appUpdate.parseAppFileName(options.file);
866
891
  if (info && appUpdate.compareVersions(info.version, installed) <= 0) {
867
892
  console.log(`${GREEN}✅ 已是最新版本 ${installed}${NC}`);
@@ -877,7 +902,7 @@ async function updateOfflineInstall(mode, options, globalConfig) {
877
902
  console.log(`${CYAN}🔄 正在查询最新版本...${NC}`);
878
903
  release = await appUpdate.fetchLatestRelease({ fetchImpl: github.fetch });
879
904
  if (appUpdate.compareVersions(release.version, installed) <= 0) {
880
- console.log(`${GREEN}✅ 已是最新版本 ${installed}${NC}`);
905
+ console.log(`${GREEN}✅ ${codeUpdate.latestReleaseNotice(installed, release.version)}${NC}`);
881
906
  return;
882
907
  }
883
908
  console.log(`${CYAN}⬇️ 发现新版本 ${release.version},只下载 manyoyo 本体(数十 MB)${NC}`);
@@ -937,6 +962,14 @@ async function updateOfflineInstall(mode, options, globalConfig) {
937
962
  }
938
963
 
939
964
  async function updateManyoyo(options = {}, globalConfig = {}) {
965
+ if (options.code && (options.file || options.rollback)) {
966
+ console.error(`${RED}❌ --code 不能同时使用 --file 或 --rollback。${NC}`);
967
+ process.exit(1);
968
+ }
969
+ if (options.code && !codeUpdate.isValidBranch(options.code)) {
970
+ console.error(`${RED}❌ 分支名不合法: ${String(options.code).slice(0, 60)}(只能用字母、数字、. _ - /,不能以符号开头,不能含 -- / .. / //)${NC}`);
971
+ process.exit(1);
972
+ }
940
973
  const mode = appUpdate.detectInstallMode({ scriptPath: __filename });
941
974
  if (mode.mode === 'offline') {
942
975
  try {
@@ -954,6 +987,10 @@ async function updateManyoyo(options = {}, globalConfig = {}) {
954
987
  console.error(`${RED}❌ --rollback 只适用于离线包安装;npm 安装的版本请用 npm install -g @xcanwin/manyoyo@<版本> 回退。${NC}`);
955
988
  process.exit(1);
956
989
  }
990
+ if (options.code) {
991
+ console.error(`${RED}❌ --code 只适用于离线包安装;npm 安装请用 npm install -g @xcanwin/manyoyo 升级。${NC}`);
992
+ process.exit(1);
993
+ }
957
994
  if (options.file) {
958
995
  console.error(`${RED}❌ --file 只适用于离线包安装;npm 安装请用 npm install -g @xcanwin/manyoyo 升级。${NC}`);
959
996
  process.exit(1);
@@ -1224,7 +1261,8 @@ https://github.com/xcanwin/manyoyo
1224
1261
  .description('升级到最新版本')
1225
1262
  .option('--rollback', '回到上一版本')
1226
1263
  .option('--file <path>', '安装手动下载的升级包(manyoyo-<版本>-<系统>-<架构>-app.tar.gz,同目录需有 SHA256SUMS)')
1227
- .action(options => selectAction('update', { update: true, rollback: Boolean(options.rollback), file: options.file || '' }));
1264
+ .option('--code [branch]', '安装某个分支的最新代码版(未发布,默认 main)')
1265
+ .action(options => selectAction('update', { update: true, rollback: Boolean(options.rollback), file: options.file || '', code: options.code === undefined ? '' : (options.code === true ? 'main' : options.code) }));
1228
1266
 
1229
1267
  program.command('uninstall')
1230
1268
  .helpGroup('日常:')
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ // Web 端执行 Agent 时追加给它的系统提示词(唯一数据源)。
4
+ // 只写环境事实、用条件句:不鼓励它主动生成或展示图片,也不重复模型本来就知道的事。
5
+ const BUILTIN_AGENT_INSTRUCTIONS = [
6
+ '你的回复会显示在网页里(用户可能在手机上看),Markdown 会被渲染。',
7
+ '- 需要让用户看某张图片时(如截图、生成的图表),用 Markdown 图片语法写容器内的绝对路径:![说明](/路径/图.png),支持 png/jpg/jpeg/gif/webp/bmp/ico。没有必要时不要专门生成或展示图片。',
8
+ '- 提到文件时写容器内的绝对路径,用户可以点开查看。'
9
+ ].join('\n');
10
+
11
+ const USER_INSTRUCTIONS_MAX_CHARS = 4000;
12
+ // 容器的系统提示词 = 默认 + 补充,上限放宽
13
+ const SYSTEM_PROMPT_MAX_CHARS = 8000;
14
+
15
+ function pickUserInstructions(config, runName) {
16
+ const source = config && typeof config === 'object' ? config : {};
17
+ const runs = source.runs && typeof source.runs === 'object' ? source.runs : {};
18
+ const run = runName && Object.prototype.hasOwnProperty.call(runs, runName) ? runs[runName] : null;
19
+ if (run && typeof run === 'object' && typeof run.agentInstructions === 'string') {
20
+ return run.agentInstructions;
21
+ }
22
+ return typeof source.agentInstructions === 'string' ? source.agentInstructions : '';
23
+ }
24
+
25
+ // 去掉控制字符(保留换行、制表符):codex 的 TOML 字符串不接受它们,也没有正当用途;
26
+ // {prompt} 会被命令模板当成占位符替换,拆开写
27
+ function sanitizeUserInstructions(text, max = USER_INSTRUCTIONS_MAX_CHARS) {
28
+ return String(text || '')
29
+ .replace(/\r\n?/g, '\n')
30
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
31
+ .replace(/\{prompt\}/g, '{ prompt }')
32
+ .trim()
33
+ .slice(0, max);
34
+ }
35
+
36
+ // 系统设置里两段各有开关(默认开):默认系统提示词(内置)与补充系统提示词(agentInstructions)
37
+ function pickEnabled(config, key) {
38
+ return !(config && typeof config === 'object' && config[key] === false);
39
+ }
40
+
41
+ // 默认系统提示词 + 补充系统提示词(按开关取用),新建容器时作为初始内容
42
+ function composeSystemPrompt(config, runName) {
43
+ const parts = [];
44
+ if (pickEnabled(config, 'agentInstructionsBuiltinEnabled')) parts.push(BUILTIN_AGENT_INSTRUCTIONS);
45
+ const extra = pickEnabled(config, 'agentInstructionsCustomEnabled')
46
+ ? sanitizeUserInstructions(pickUserInstructions(config, runName))
47
+ : '';
48
+ if (extra) parts.push(extra);
49
+ return parts.length ? `${parts.join('\n\n')}\n` : '';
50
+ }
51
+
52
+ // 某个容器实际使用的系统提示词:容器自己存过就以它为准(enabled=false 即不注入),没存过(旧容器)用当前全局设置
53
+ function buildAgentInstructions(config, runName, containerPrompt) {
54
+ if (containerPrompt && typeof containerPrompt === 'object') {
55
+ return containerPrompt.enabled === false ? '' : sanitizeUserInstructions(containerPrompt.text, SYSTEM_PROMPT_MAX_CHARS);
56
+ }
57
+ return sanitizeUserInstructions(composeSystemPrompt(config, runName), SYSTEM_PROMPT_MAX_CHARS);
58
+ }
59
+
60
+ // TOML 基本字符串:JSON 的转义(\" \\ \n \t \uXXXX)在 TOML 里含义相同;
61
+ // 控制字符已在 sanitize 时去掉,这里只剩 JSON 会原样输出、TOML 也允许的字符
62
+ function toTomlBasicString(text) {
63
+ return JSON.stringify(String(text || ''));
64
+ }
65
+
66
+ module.exports = {
67
+ BUILTIN_AGENT_INSTRUCTIONS,
68
+ buildAgentInstructions,
69
+ composeSystemPrompt,
70
+ sanitizeUserInstructions,
71
+ SYSTEM_PROMPT_MAX_CHARS,
72
+ toTomlBasicString
73
+ };
package/lib/app-update.js CHANGED
@@ -15,6 +15,12 @@ const WEB_BASE = 'https://github.com';
15
15
  // 不依赖 PATH 查找 tar(macOS 与常见 Linux 都在 /usr/bin)
16
16
  const TAR_COMMAND = fs.existsSync('/usr/bin/tar') ? '/usr/bin/tar' : 'tar';
17
17
  const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
18
+ // app/ 下的目录名:正式版 x.y.z,或代码版 x.y.z+<提交前 7 位>(见 lib/code-update.js)
19
+ const APP_VERSION_PATTERN = /^\d+\.\d+\.\d+(\+[0-9a-f]{7})?$/;
20
+
21
+ function isAppVersion(value) {
22
+ return typeof value === 'string' && APP_VERSION_PATTERN.test(value);
23
+ }
18
24
  const FETCH_TIMEOUT_MS = 15000;
19
25
 
20
26
  class UpdateError extends Error {
@@ -71,7 +77,7 @@ function detectInstallMode({ scriptPath, homeDir = os.homedir() }) {
71
77
  const relative = path.relative(appRoot, real);
72
78
  if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
73
79
  const version = relative.split(path.sep)[0];
74
- if (VERSION_PATTERN.test(version)) {
80
+ if (isAppVersion(version)) {
75
81
  return { mode: 'offline', appRoot, version };
76
82
  }
77
83
  }
@@ -190,11 +196,13 @@ function readUpdateRecord(appRoot) {
190
196
  }
191
197
  }
192
198
 
193
- // 只保留当前与上一版本,其它版本目录与残留的临时目录一起清掉
199
+ // 只保留当前、上一版本和最新的正式版(连装几次代码版后仍能回到正式版),其它版本目录与残留的临时目录一起清掉
194
200
  function pruneApps(appRoot, keep) {
195
- for (const name of fs.readdirSync(appRoot)) {
196
- if (keep.includes(name) || name === 'current' || name === '.update.json') continue;
197
- if (VERSION_PATTERN.test(name) || name.startsWith('.tmp-')) fs.rmSync(path.join(appRoot, name), { recursive: true, force: true });
201
+ const names = fs.readdirSync(appRoot);
202
+ const latestRelease = names.filter(name => VERSION_PATTERN.test(name)).sort((a, b) => compareVersions(b, a))[0];
203
+ for (const name of names) {
204
+ if (keep.includes(name) || name === latestRelease || name === 'current' || name === '.update.json') continue;
205
+ if (isAppVersion(name) || name.startsWith('.tmp-')) fs.rmSync(path.join(appRoot, name), { recursive: true, force: true });
198
206
  }
199
207
  }
200
208
 
@@ -306,9 +314,10 @@ function rollbackApp({ appRoot }) {
306
314
  const current = currentVersion(appRoot);
307
315
  const record = readUpdateRecord(appRoot);
308
316
  let previous = record.previous;
309
- if (!previous || !VERSION_PATTERN.test(previous) || !fs.existsSync(path.join(appRoot, previous))) {
310
- const candidates = fs.existsSync(appRoot) ? fs.readdirSync(appRoot).filter(name => VERSION_PATTERN.test(name) && name !== current && fs.existsSync(path.join(appRoot, name, 'manyoyo', 'bin', 'manyoyo.js'))) : [];
311
- candidates.sort((a, b) => compareVersions(b, a));
317
+ if (!previous || !isAppVersion(previous) || !fs.existsSync(path.join(appRoot, previous))) {
318
+ const candidates = fs.existsSync(appRoot) ? fs.readdirSync(appRoot).filter(name => isAppVersion(name) && name !== current && fs.existsSync(path.join(appRoot, name, 'manyoyo', 'bin', 'manyoyo.js'))) : [];
319
+ // 版本相同(正式版与代码版)时正式版排前面
320
+ candidates.sort((a, b) => compareVersions(b, a) || (VERSION_PATTERN.test(b) ? 1 : 0) - (VERSION_PATTERN.test(a) ? 1 : 0));
312
321
  previous = candidates[0];
313
322
  }
314
323
  if (!previous) throw new UpdateError('NO_PREVIOUS', '没有可回滚的上一版本。');
@@ -386,6 +395,8 @@ module.exports = {
386
395
  UpdateError,
387
396
  parseVersion,
388
397
  compareVersions,
398
+ isAppVersion,
399
+ installFromArchive,
389
400
  detectInstallMode,
390
401
  fetchLatestRelease,
391
402
  parseSha256Sums,
@@ -394,6 +405,7 @@ module.exports = {
394
405
  installAppFile,
395
406
  parseAppFileName,
396
407
  networkError,
408
+ request,
397
409
  rollbackApp,
398
410
  currentVersion,
399
411
  describeRuntimeChange,
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ // 代码版升级:`manyoyo update --code [分支]`。
4
+ // 目标机器没有 git / npm,所以构建在 CI 里做(.github/workflows/code-packages.yml、scripts/offline/build-code.js),
5
+ // 这里只下载 CODE.json → 校验 → 下载本机平台的 -app.tar.gz → SHA256 校验 → 复用 installFromArchive 切换 current。
6
+ // 网络与副作用都从参数注入。CODE.json 来自网络,每个字段都严格校验:资产名要“等于”按提交与平台拼出的名字,不是匹配某个正则。
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const appUpdate = require('./app-update');
12
+ const { downloadVerified } = require('./download-verified');
13
+
14
+ const { UpdateError, compareVersions, networkError, request, installFromArchive } = appUpdate;
15
+
16
+ const DOWNLOAD_BASE = 'https://github.com/xcanwin/manyoyo/releases/download/';
17
+ const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,100}$/;
18
+ const IMAGE_VERSION_PATTERN = /^\d+\.\d+\.\d+-[A-Za-z0-9][A-Za-z0-9_.-]*$/;
19
+ const PLATFORMS = ['linux-x64', 'linux-arm64', 'macos-x64', 'macos-arm64'];
20
+ const MAX_CODE_JSON_CHARS = 64 * 1024;
21
+ const SUBJECT_MAX_CHARS = 200;
22
+
23
+ function isValidBranch(branch) {
24
+ return typeof branch === 'string' && BRANCH_PATTERN.test(branch) && !branch.includes('--') && !branch.includes('..') && !branch.includes('//') && !branch.endsWith('/');
25
+ }
26
+
27
+ /** 分支 → 滚动预发布的 tag:code-<分支>,/ 换成 --(分支名不允许含 --,所以映射不会冲突)。CI 脚本与客户端共用。 */
28
+ function codeTagForBranch(branch) {
29
+ if (!isValidBranch(branch)) {
30
+ throw new UpdateError('BAD_BRANCH', `分支名不合法: ${String(branch).slice(0, 60)}(只能用字母、数字、. _ - /,不能以符号开头,不能含 -- / .. / //)`);
31
+ }
32
+ return `code-${branch.replace(/\//g, '--')}`;
33
+ }
34
+
35
+ function codeAssetName(commit, targetOs, targetArch) {
36
+ return `manyoyo-code-${String(commit).slice(0, 7)}-${targetOs}-${targetArch}-app.tar.gz`;
37
+ }
38
+
39
+ function bad(message) {
40
+ return new UpdateError('BAD_CODE_INFO', `代码版信息(CODE.json)不合法:${message},已中止。`);
41
+ }
42
+
43
+ /** 校验 CODE.json;返回规范化后的副本(subject 已清洗)。 */
44
+ function validateCodeInfo(info, { branch }) {
45
+ if (!info || typeof info !== 'object' || Array.isArray(info)) throw bad('不是对象');
46
+ if (info.schemaVersion !== 1) throw bad('schemaVersion 不支持');
47
+ if (info.branch !== branch) throw bad(`分支不符(期望 ${branch})`);
48
+ if (typeof info.commit !== 'string' || !/^[0-9a-f]{40}$/.test(info.commit)) throw bad('commit 格式不对');
49
+ if (typeof info.version !== 'string' || !/^\d+\.\d+\.\d+\+[0-9a-f]{7}$/.test(info.version) || !info.version.endsWith(`+${info.commit.slice(0, 7)}`)) throw bad('version 与 commit 不符');
50
+ if (typeof info.imageVersion !== 'string' || !IMAGE_VERSION_PATTERN.test(info.imageVersion)) throw bad('imageVersion 格式不对');
51
+ if (typeof info.imageReady !== 'boolean') throw bad('imageReady 必须是布尔值');
52
+ if (!info.assets || typeof info.assets !== 'object' || Array.isArray(info.assets)) throw bad('缺少 assets');
53
+ const assets = {};
54
+ for (const [key, asset] of Object.entries(info.assets)) {
55
+ if (!PLATFORMS.includes(key)) throw bad(`未知平台 ${key.slice(0, 40)}`);
56
+ const [targetOs, targetArch] = key.split('-');
57
+ if (!asset || typeof asset !== 'object') throw bad(`${key} 条目不是对象`);
58
+ if (asset.name !== codeAssetName(info.commit, targetOs, targetArch)) throw bad(`${key} 的文件名不符合命名规则`);
59
+ if (typeof asset.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(asset.sha256)) throw bad(`${key} 的 sha256 格式不对`);
60
+ if (!Number.isInteger(asset.size) || asset.size <= 0) throw bad(`${key} 的 size 不对`);
61
+ assets[key] = { name: asset.name, sha256: asset.sha256, size: asset.size };
62
+ }
63
+ // 只用于打印:去掉控制字符并截断
64
+ const subject = String(info.subject || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, SUBJECT_MAX_CHARS);
65
+ return {
66
+ schemaVersion: 1, branch, commit: info.commit, subject, version: info.version,
67
+ imageVersion: info.imageVersion, imageReady: info.imageReady, builtAt: typeof info.builtAt === 'string' ? info.builtAt.slice(0, 40) : '', assets
68
+ };
69
+ }
70
+
71
+ function tagBase(downloadBase, branch) {
72
+ return `${downloadBase}${encodeURIComponent(codeTagForBranch(branch))}/`;
73
+ }
74
+
75
+ // notFoundRetryMs:CI 替换 CODE.json 时(gh 的 --clobber 是先删后传)有一小段时间它不存在,404 先等一下再确认一次
76
+ async function fetchCodeInfo({ branch, fetchImpl = fetch, downloadBase = DOWNLOAD_BASE, notFoundRetryMs = 2000 }) {
77
+ const url = `${tagBase(downloadBase, branch)}CODE.json`;
78
+ let response = await request(fetchImpl, url, { headers: { Accept: 'application/json' } });
79
+ if (response.status === 404) {
80
+ await new Promise(resolve => setTimeout(resolve, notFoundRetryMs));
81
+ response = await request(fetchImpl, url, { headers: { Accept: 'application/json' } });
82
+ }
83
+ if (response.status === 404) {
84
+ throw new UpdateError('NO_CODE_BUILD', `分支 ${branch} 还没有代码版(也可能正在更新)。main 分支在推送后会自动构建(需要几分钟,稍后再试);其他分支需要维护者手动触发构建。`);
85
+ }
86
+ if (!response.ok) throw new UpdateError('HTTP', `查询代码版信息失败(HTTP ${response.status})。\n 地址: ${url}`, { url });
87
+ // 声明的长度就超限的,不读内容
88
+ if (Number(response.headers.get('content-length')) > MAX_CODE_JSON_CHARS) throw bad('内容过大');
89
+ let text;
90
+ try {
91
+ text = await response.text();
92
+ } catch (error) {
93
+ throw networkError(url, error, '读取中断,连不上');
94
+ }
95
+ if (text.length > MAX_CODE_JSON_CHARS) throw bad('内容过大');
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(text);
99
+ } catch (error) {
100
+ throw bad('不是合法的 JSON');
101
+ }
102
+ return validateCodeInfo(parsed, { branch });
103
+ }
104
+
105
+ /**
106
+ * 安装某个分支的代码版。installed 是当前版本号。
107
+ * 返回 { status: 'same', info } 或 { status: 'installed', info, version, previous, manifest }。
108
+ * 校验失败 / 镜像未发布 / 版本更旧都在下载与切换之前中止,current 不变。
109
+ */
110
+ async function installCodeUpdate({ appRoot, branch, installed, fetchImpl = fetch, downloadBase = DOWNLOAD_BASE, notFoundRetryMs, targetArch = appUpdate.arch(), targetOs = appUpdate.platformOs(), run, log = () => {}, tmpRoot = os.tmpdir() }) {
111
+ const info = await fetchCodeInfo({ branch, fetchImpl, downloadBase, notFoundRetryMs });
112
+ if (info.version === installed) return { status: 'same', info };
113
+ if (compareVersions(info.version, installed) < 0) {
114
+ throw new UpdateError('OLDER', `分支 ${branch} 的代码版是 ${info.version},比当前的 ${installed} 旧。${branch} 的最新提交可能还在构建,稍后再试;要换回正式版请用 manyoyo update --rollback 或重新运行安装命令。`);
115
+ }
116
+ if (!info.imageReady) {
117
+ throw new UpdateError('IMAGE_NOT_READY', `这个代码版需要镜像 ${info.imageVersion},但它还没有发布,现在升级会让容器拉不到镜像。请等镜像发布后再试,或换一个分支。`);
118
+ }
119
+ const asset = info.assets[`${targetOs}-${targetArch}`];
120
+ const url = asset ? `${tagBase(downloadBase, branch)}${asset.name}` : '';
121
+ if (!asset) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包。`);
122
+ // 包是按 CODE.json 里的固定命名拼出来的:先确认确实存在(404 就是没有)
123
+ const probe = await request(fetchImpl, url, { method: 'HEAD' });
124
+ if (probe.status === 404) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 的升级包 ${asset.name} 还没有上传完整,请稍后再试。`);
125
+
126
+ const workDir = fs.mkdtempSync(path.join(tmpRoot, 'manyoyo-code-update-'));
127
+ try {
128
+ const archive = path.join(workDir, asset.name);
129
+ try {
130
+ // 服务端声明的大小与 CODE.json 不符就直接中止,不把磁盘写满再校验哈希
131
+ const guarded = async (target, options) => {
132
+ const response = await request(fetchImpl, target, { ...options, noTimeout: true });
133
+ const declared = response.headers.get('content-length');
134
+ if (response.ok && declared !== null && Number(declared) !== asset.size) {
135
+ // status 让 downloadVerified 当作明确的拒绝,不做重试
136
+ throw new UpdateError('CHECKSUM', `代码版升级包大小与 CODE.json 不符(${declared} ≠ ${asset.size}),已中止升级,当前版本不受影响。`, { status: response.status });
137
+ }
138
+ return response;
139
+ };
140
+ await downloadVerified({ url, sha256: asset.sha256, dest: archive, fetchImpl: guarded, log });
141
+ } catch (error) {
142
+ if (/SHA256 校验失败/.test(error.message)) throw new UpdateError('CHECKSUM', '代码版升级包 SHA256 校验失败,已中止升级,当前版本不受影响。');
143
+ if (error instanceof UpdateError) throw error;
144
+ throw networkError(url, error, '下载中断,连不上');
145
+ }
146
+ const result = await installFromArchive({ appRoot, archive, version: info.version, targetArch, targetOs, run });
147
+ return { status: 'installed', info, ...result };
148
+ } finally {
149
+ fs.rmSync(workDir, { recursive: true, force: true });
150
+ }
151
+ }
152
+
153
+ function isCodeVersion(version) {
154
+ return typeof version === 'string' && /^\d+\.\d+\.\d+\+[0-9a-f]{7}$/.test(version);
155
+ }
156
+
157
+ /** 普通 `update` 发现最新正式版不比当前新时说的话:代码版要特别说明怎么换回正式版。 */
158
+ function latestReleaseNotice(installed, latest) {
159
+ if (isCodeVersion(installed)) {
160
+ return `当前是代码版 ${installed},最新正式版 ${latest} 不比它新。要换回正式版,请重新运行安装命令(或 manyoyo update --rollback 回到上一个版本)。`;
161
+ }
162
+ return `已是最新版本 ${installed}`;
163
+ }
164
+
165
+ module.exports = { isCodeVersion, latestReleaseNotice, DOWNLOAD_BASE, isValidBranch, codeTagForBranch, codeAssetName, validateCodeInfo, fetchCodeInfo, installCodeUpdate };
@@ -48,7 +48,8 @@ function paths(homeDir, id) {
48
48
  envFiles: path.join(dir, 'env-files.json'),
49
49
  network: path.join(dir, 'network.json'),
50
50
  netStatus: path.join(dir, 'net-status.json'),
51
- meta: path.join(dir, 'meta.json')
51
+ meta: path.join(dir, 'meta.json'),
52
+ systemPrompt: path.join(dir, 'system-prompt.json')
52
53
  };
53
54
  }
54
55
 
@@ -153,6 +154,7 @@ function writeFileAtomic(filePath, data, mode = 0o600) {
153
154
  * @param {string} [options.id]
154
155
  * @param {string[]} [options.envLines] KEY=VALUE(已校验)
155
156
  * @param {string} [options.autostart] 自启动脚本
157
+ * @param {{enabled: boolean, text: string}} [options.systemPrompt] 系统提示词
156
158
  * @param {object} [options.meta]
157
159
  * @param {boolean} [options.netRequired]
158
160
  * @param {object} [options.network] 已校验的网络策略(见 lib/network-policy.js)
@@ -178,6 +180,7 @@ function createState(options) {
178
180
  if (!fs.existsSync(p.managedEnv)) writeFileAtomic(p.managedEnv, '');
179
181
  setNetRequired(homeDir, id, Boolean(options.netRequired));
180
182
  if (options.network) writeNetworkRaw(homeDir, id, options.network);
183
+ if (options.systemPrompt) writeFileAtomic(p.systemPrompt, `${JSON.stringify({ enabled: options.systemPrompt.enabled !== false, text: String(options.systemPrompt.text || '') })}\n`);
181
184
  writeMeta(homeDir, id, { id, createdAt: new Date().toISOString(), ...(options.meta || {}) });
182
185
  return { id, ...p };
183
186
  }
@@ -398,6 +401,21 @@ function writeNetStatus(homeDir, id, status) {
398
401
  writeFileAtomic(paths(homeDir, id).netStatus, `${JSON.stringify(status)}\n`);
399
402
  }
400
403
 
404
+ // 容器自己的系统提示词 {enabled, text}:创建时定下,之后只能在容器设置里改;没有文件(旧容器)返回 null
405
+ function readSystemPrompt(homeDir, id) {
406
+ try {
407
+ const data = JSON.parse(fs.readFileSync(paths(homeDir, id).systemPrompt, 'utf-8'));
408
+ return { enabled: data.enabled !== false, text: typeof data.text === 'string' ? data.text : '' };
409
+ } catch (e) {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ function writeSystemPrompt(homeDir, id, prompt) {
415
+ assertStateExists(homeDir, id);
416
+ writeFileAtomic(paths(homeDir, id).systemPrompt, `${JSON.stringify({ enabled: prompt.enabled !== false, text: String(prompt.text || '') })}\n`);
417
+ }
418
+
401
419
  function removeState(homeDir, id) {
402
420
  if (!isValidId(id)) return false;
403
421
  fs.rmSync(stateDir(homeDir, id), { recursive: true, force: true });
@@ -456,6 +474,8 @@ module.exports = {
456
474
  readAutostartLogTail,
457
475
  writeManagedEnv,
458
476
  readMeta,
477
+ readSystemPrompt,
478
+ writeSystemPrompt,
459
479
  writeMeta,
460
480
  readNetworkRaw,
461
481
  writeNetworkRaw,
@@ -911,7 +911,9 @@ class PlaywrightPlugin {
911
911
 
912
912
  // 真实探测当前模式(不切换模式、失败也不回退;发布流水线用它:status 在失败时会把模式回退成默认;默认模式会按需重建 current/ 里的配置文件)。
913
913
  // 宿主机上的模式连上浏览器服务开页面;默认模式(容器内浏览器)起一个一次性容器,真的打开页面并读回标题
914
- async probe() {
914
+ // 命令行的 `manyoyo playwright probe`。不能叫 probe:类里同名方法后定义的会覆盖底层的 probe(kind, endpoint),
915
+ // up 里的 probeState 就会调回这里,无限递归(up headed / vnc 永远不返回)
916
+ async probeCommand() {
915
917
  const state = this.readState();
916
918
  if (state.mode !== 'default') {
917
919
  const alive = await this.modeAlive(state);
@@ -984,7 +986,7 @@ class PlaywrightPlugin {
984
986
  return await this.status();
985
987
  }
986
988
  if (action === 'probe') {
987
- return await this.probe();
989
+ return await this.probeCommand();
988
990
  }
989
991
  if (action === 'logs') {
990
992
  return this.logs();
@@ -29,4 +29,13 @@ function buildMirrorsConfigRaw(baseRaw, mirrors) {
29
29
  return upsertValueByPath(baseRaw, ['mirrors'], JSON.stringify(mirrors, null, 4));
30
30
  }
31
31
 
32
- module.exports = { EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw };
32
+ // 网页里执行 Agent 时追加的说明(只写全局配置;runs.<name> 的覆盖在 JSON 里手写)
33
+ function buildAgentInstructionsConfigRaw(baseRaw, text) {
34
+ return upsertValueByPath(baseRaw, ['agentInstructions'], JSON.stringify(String(text || '')));
35
+ }
36
+
37
+ function buildBooleanConfigRaw(baseRaw, key, value) {
38
+ return upsertValueByPath(baseRaw, [key], value ? 'true' : 'false');
39
+ }
40
+
41
+ module.exports = { buildBooleanConfigRaw, EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw };
package/lib/web/AGENTS.md CHANGED
@@ -30,6 +30,10 @@
30
30
  - **终端 WebSocket**:`/api/sessions/:name/terminal/ws`,在 `server.on('upgrade')` 里做 Origin 校验 + 认证 + 容器名校验,再交给 `bindTerminalWebSocket()`。上行 `input` / `resize` / `ping` / `close`,下行 `output` / `status`(`ready` / `closed`)/ `error` / `pong`;实现是 `docker exec` 里用 `script` 或 python `pty` 引导出的伪 TTY。**动态 resize**:bootstrap 的 rc 把 `tty` 输出写进容器里 `/tmp/manyoyo-term-<随机id>.tty`;收到 `resize` 后(整数、10~500 列、4~200 行,否则整条忽略;下限比建连初值 40×12 低,因为手机竖屏不到 40 列;节流,同一时刻只有一个 exec)用固定脚本 `TERMINAL_RESIZE_SCRIPT` 在容器里读该文件,内容必须整行匹配 `/dev/pts/<数字>` 才执行 `stty -F`。**这个 tty 文件容器里的进程可写,只能当不可信输入**(脚本只认普通文件、不跟符号链接、最多读 64 字节),用例见 `test/web-terminal-resize.test.js`。降级成非 TTY 时 resize 自然无效。并发上限 `WEB_TERMINAL_MAX_SESSIONS = 20`,每条连接对应一个 `ptyProcess`,`ws` 关闭即 `SIGTERM`——所以前端任何「卸载终端组件」的改动都等于杀掉用户正在跑的 shell。
31
31
  - 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
32
32
  - 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
33
+ - **系统提示词**(`lib/agent-instructions.js` 唯一数据源,`buildWebAgentExecCommand` 的 `options.instructions`):全局配置(`agentInstructions` + 两个 `agentInstructions{Builtin,Custom}Enabled` 开关,`composeSystemPrompt`)只决定**新建容器时的初始内容**;创建时(请求 `systemPrompt:{enabled,text}`,没给就现算)存进容器状态目录 `system-prompt.json`,之后只能经 `/api/containers/:name/system-prompt` 改,改系统设置不影响已有容器。每次执行时读该容器的文件(`readContainerSystemPrompt`),旧容器没有文件就退回按当前全局配置现算;claude 用 `--append-system-prompt`、codex 用 `-c developer_instructions=<TOML 字符串>`、opencode 把说明写进容器 `/tmp/manyoyo-agent-instructions.md` 再经 `OPENCODE_CONFIG_CONTENT` 合并(1.19.3 实测与用户的 instructions 拼接);gemini 只有 `GEMINI_SYSTEM_MD` 这种整体替换,不注入。用户模板里已有同名参数 / 变量就不注入;用户追加的文本去控制字符、把 `{prompt}` 拆开(否则会被模板替换成提示词)。CLI 的 `manyoyo run` 是交互终端,不注入。
34
+ - **会话标题**:claude 交互模式写 `ai-title`、`/rename` 写 `custom-title`(优先),**`-p` 下实测不生成 `ai-title`**(2.1.290,3 轮)。每轮结束后 `refreshWebAgentTitle` 在后台只读会话 jsonl 尾部 256KB(session id 按 UUID 校验),失败静默不写日志;`firstPrompt` 在第一次追加 Agent 用户消息时记下(历史会被裁剪,不能现算)。这些字段与 `pinned`、`slashCommands` 都要同时加进 `createEmptyWebAgentSession` 和 `normalizeWebAgentSessionRecord`,否则落盘丢失。
35
+ - **斜杠命令候选**(`GET .../slash-commands`):来自 claude 每次运行 init 事件的 `slash_commands` / `skills` / `terminal_slash_commands`,只列 skill 和 `HEADLESS_BUILTIN_COMMANDS`(`-p` 下逐个实测过;`compact` 在 `-p` 下不压缩,别加回去)。
36
+ - **改动视图**(`lib/web/git-changes.js`,`GET .../git/changes`、`.../git/diff?path=`):按需执行、不轮询;仓库和 git 配置由容器控制,命令里关掉外部 diff / textconv / fsmonitor、`GIT_OPTIONAL_LOCKS=0`、输出有上限;diff 的 path 必须出现在服务端重新取的改动列表里,`-` 开头和换行 / NUL 一律拒绝。
33
37
 
34
38
  ## 容器管理接口
35
39
 
@@ -10,6 +10,7 @@ const { envUrlDomains } = require('../network-endpoints');
10
10
  const { egressPaths } = require('../egress-sidecar');
11
11
  const deniedLib = require('../egress-denied');
12
12
  const { isBrowserBackground } = require('../browser-background');
13
+ const { composeSystemPrompt, SYSTEM_PROMPT_MAX_CHARS } = require('../agent-instructions');
13
14
 
14
15
  // 容器管理接口:环境变量、自启动、网络(含端口暴露)、孤儿状态目录。
15
16
  // 全部挂在全局认证网关之后;非只读请求由 handleWebApi 统一校验 X-Requested-With。
@@ -124,6 +125,13 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
124
125
  }
125
126
  }
126
127
 
128
+ function currentSystemPrompt(id) {
129
+ const saved = state_.readSystemPrompt(homeDir, id);
130
+ if (saved) return saved;
131
+ const snapshot = deps.readConfig ? deps.readConfig() : {};
132
+ return { enabled: true, text: composeSystemPrompt(snapshot, '') };
133
+ }
134
+
127
135
  function readPolicy(id) {
128
136
  return normalizePolicy(state_.readNetworkRaw(homeDir, id) || undefined);
129
137
  }
@@ -453,6 +461,31 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
453
461
  });
454
462
  }
455
463
  },
464
+ {
465
+ // 容器自己的系统提示词:创建时定下,之后只在这里改;旧容器没存过时显示按系统设置现算的内容
466
+ method: 'GET',
467
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/system-prompt$/),
468
+ handler: async match => {
469
+ const target = resolveTarget(match[1]);
470
+ if (!target) return;
471
+ sendJson(res, 200, { legacy: false, id: target.id, ...currentSystemPrompt(target.id) });
472
+ }
473
+ },
474
+ {
475
+ method: 'PUT',
476
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/system-prompt$/),
477
+ handler: async match => {
478
+ const target = resolveTarget(match[1], { write: true });
479
+ if (!target) return;
480
+ const body = await readJsonBody(req);
481
+ if (typeof body.text !== 'string' || typeof body.enabled !== 'boolean') {
482
+ sendJson(res, 400, { error: '需要 enabled(布尔)和 text(字符串)' });
483
+ return;
484
+ }
485
+ state_.writeSystemPrompt(homeDir, target.id, { enabled: body.enabled, text: body.text.slice(0, SYSTEM_PROMPT_MAX_CHARS) });
486
+ sendJson(res, 200, { legacy: false, id: target.id, ...currentSystemPrompt(target.id) });
487
+ }
488
+ },
456
489
  {
457
490
  method: 'POST',
458
491
  match: p => p.match(/^\/api\/containers\/([^/]+)\/autostart\/run$/),