@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/bin/manyoyo.js CHANGED
@@ -348,6 +348,7 @@ function installServeProcessDiagnostics(logger) {
348
348
  * @property {string[]} [volumes] - 挂载卷数组
349
349
  * @property {Object.<string, Object>} [plugins] - 可选插件配置映射(如 plugins.playwright)
350
350
  * @property {{title?:string}} [serve] - serve 命令专属配置(如自定义网页标题,留空字符串则显示为空)
351
+ * @property {string} [agentInstructions] - 网页里执行 Agent 时追加给它的说明(接在内置环境说明后面;runs.<name> 可覆盖)
351
352
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
352
353
  * @property {string} [yolo] - YOLO 模式
353
354
  * @property {string} [containerMode] - 容器模式
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // Web 端执行 Agent 时追加给它的环境说明(唯一数据源)。
4
+ // 只写环境事实、用条件句:不鼓励它主动生成或展示图片,也不重复模型本来就知道的事。
5
+ const BUILTIN_AGENT_INSTRUCTIONS = [
6
+ '你的回复会显示在 MANYOYO 网页里(用户可能在手机上看),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
+ function pickUserInstructions(config, runName) {
14
+ const source = config && typeof config === 'object' ? config : {};
15
+ const runs = source.runs && typeof source.runs === 'object' ? source.runs : {};
16
+ const run = runName && Object.prototype.hasOwnProperty.call(runs, runName) ? runs[runName] : null;
17
+ if (run && typeof run === 'object' && typeof run.agentInstructions === 'string') {
18
+ return run.agentInstructions;
19
+ }
20
+ return typeof source.agentInstructions === 'string' ? source.agentInstructions : '';
21
+ }
22
+
23
+ // 去掉控制字符(保留换行、制表符):codex 的 TOML 字符串不接受它们,也没有正当用途;
24
+ // {prompt} 会被命令模板当成占位符替换,拆开写
25
+ function sanitizeUserInstructions(text) {
26
+ return String(text || '')
27
+ .replace(/\r\n?/g, '\n')
28
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
29
+ .replace(/\{prompt\}/g, '{ prompt }')
30
+ .trim()
31
+ .slice(0, USER_INSTRUCTIONS_MAX_CHARS);
32
+ }
33
+
34
+ function buildAgentInstructions(config, runName) {
35
+ const extra = sanitizeUserInstructions(pickUserInstructions(config, runName));
36
+ return extra ? `${BUILTIN_AGENT_INSTRUCTIONS}\n\n${extra}` : BUILTIN_AGENT_INSTRUCTIONS;
37
+ }
38
+
39
+ // TOML 基本字符串:JSON 的转义(\" \\ \n \t \uXXXX)在 TOML 里含义相同;
40
+ // 控制字符已在 sanitize 时去掉,这里只剩 JSON 会原样输出、TOML 也允许的字符
41
+ function toTomlBasicString(text) {
42
+ return JSON.stringify(String(text || ''));
43
+ }
44
+
45
+ module.exports = {
46
+ BUILTIN_AGENT_INSTRUCTIONS,
47
+ buildAgentInstructions,
48
+ sanitizeUserInstructions,
49
+ toTomlBasicString
50
+ };
@@ -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,9 @@ 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
+ module.exports = { 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`):每次执行时现读配置(`runs.<快照里的 run 名>.agentInstructions` > 全局),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
 
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ // “改动”视图:在容器里对工作目录所在的 git 仓库跑只读命令(status / diff),按需执行、不轮询。
4
+ // 仓库内容和 git 配置都由容器内进程控制,按不可信输入处理:关掉会执行外部程序的配置
5
+ //(外部 diff、textconv、fsmonitor),不写 index(GIT_OPTIONAL_LOCKS=0),输出有上限。
6
+
7
+ const CHANGES_MAX_FILES = 500;
8
+ const DIFF_MAX_BYTES = 512 * 1024;
9
+ const GIT_TIMEOUT_SECONDS = 10;
10
+ const STATUS_MARK = '__MANYOYO_GIT_STATUS__';
11
+ const NUMSTAT_MARK = '__MANYOYO_GIT_NUMSTAT__';
12
+ const ROOT_MARK = '__MANYOYO_GIT_ROOT__';
13
+
14
+ function quote(value) {
15
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
16
+ }
17
+
18
+ const GIT = `GIT_OPTIONAL_LOCKS=0 GIT_LITERAL_PATHSPECS=1 timeout ${GIT_TIMEOUT_SECONDS} git -c core.quotepath=off -c core.fsmonitor=false -c diff.external= -c color.ui=false`;
19
+
20
+ // 进到工作目录所在仓库的根目录;不是仓库时只输出一行 NOT_REPO
21
+ function enterRepo(workDir) {
22
+ return [
23
+ `cd -- ${quote(workDir || '/')} 2>/dev/null || { echo NOT_REPO; exit 0; }`,
24
+ `top="$(${GIT} rev-parse --show-toplevel 2>/dev/null)" || { echo NOT_REPO; exit 0; }`,
25
+ 'cd -- "$top" || { echo NOT_REPO; exit 0; }'
26
+ ].join('; ');
27
+ }
28
+
29
+ function buildGitChangesCommand(workDir) {
30
+ return [
31
+ enterRepo(workDir),
32
+ `printf '%s\\n%s\\n' ${ROOT_MARK} "$top"`,
33
+ `printf '%s\\n' ${STATUS_MARK}`,
34
+ `${GIT} status --porcelain=v1 -z --no-renames --untracked-files=all 2>/dev/null | head -c 1048576`,
35
+ `printf '\\n%s\\n' ${NUMSTAT_MARK}`,
36
+ `if ${GIT} rev-parse --verify -q HEAD >/dev/null 2>&1; then ${GIT} diff HEAD --numstat -z --no-renames --no-ext-diff --no-textconv 2>/dev/null | head -c 1048576; fi`
37
+ ].join('; ');
38
+ }
39
+
40
+ function sectionAfter(output, mark, nextMark) {
41
+ const start = output.indexOf(`${mark}\n`);
42
+ if (start < 0) return '';
43
+ const from = start + mark.length + 1;
44
+ const end = nextMark ? output.indexOf(`\n${nextMark}\n`, from) : -1;
45
+ return end < 0 ? output.slice(from) : output.slice(from, end);
46
+ }
47
+
48
+ function parseGitChangesOutput(output) {
49
+ const text = String(output || '');
50
+ if (!text.includes(ROOT_MARK)) {
51
+ return { repo: false, root: '', files: [], truncated: false };
52
+ }
53
+ const root = sectionAfter(text, ROOT_MARK, STATUS_MARK).split('\n')[0].trim();
54
+ const statusPart = sectionAfter(text, STATUS_MARK, NUMSTAT_MARK);
55
+ const numstatPart = sectionAfter(text, NUMSTAT_MARK, '');
56
+ const counts = new Map();
57
+ for (const record of numstatPart.split('\0')) {
58
+ const match = record.replace(/^\n/, '').match(/^(-|\d+)\t(-|\d+)\t(.+)$/s);
59
+ if (match) {
60
+ counts.set(match[3], {
61
+ added: match[1] === '-' ? null : Number(match[1]),
62
+ deleted: match[2] === '-' ? null : Number(match[2])
63
+ });
64
+ }
65
+ }
66
+ const files = [];
67
+ let truncated = false;
68
+ for (const record of statusPart.split('\0')) {
69
+ if (record.length < 4 || record[2] !== ' ') continue;
70
+ const code = record.slice(0, 2);
71
+ const filePath = record.slice(3);
72
+ if (!filePath || filePath.includes('\n')) continue;
73
+ if (files.length >= CHANGES_MAX_FILES) {
74
+ truncated = true;
75
+ break;
76
+ }
77
+ const count = counts.get(filePath) || { added: null, deleted: null };
78
+ files.push({ path: filePath, status: code === '??' ? 'untracked' : describeStatus(code), ...count });
79
+ }
80
+ return { repo: true, root, files, truncated };
81
+ }
82
+
83
+ function describeStatus(code) {
84
+ if (code.includes('D')) return 'deleted';
85
+ if (code.includes('A')) return 'added';
86
+ if (code.includes('U')) return 'conflicted';
87
+ return 'modified';
88
+ }
89
+
90
+ function isAcceptableDiffPath(filePath) {
91
+ return typeof filePath === 'string'
92
+ && filePath.length > 0
93
+ && filePath.length <= 4096
94
+ && !filePath.startsWith('-')
95
+ && !filePath.includes('\0')
96
+ && !filePath.includes('\n');
97
+ }
98
+
99
+ function buildGitDiffCommand(workDir, filePath) {
100
+ return [
101
+ enterRepo(workDir),
102
+ `printf '%s\\n' ${STATUS_MARK}`,
103
+ `if ${GIT} rev-parse --verify -q HEAD >/dev/null 2>&1; then base=HEAD; else base=; fi`,
104
+ `${GIT} diff $base --no-ext-diff --no-textconv --no-color --no-renames -- ${quote(filePath)} 2>/dev/null | head -c ${DIFF_MAX_BYTES + 1}`
105
+ ].join('; ');
106
+ }
107
+
108
+ function parseGitDiffOutput(output) {
109
+ const text = String(output || '');
110
+ if (!text.includes(STATUS_MARK)) {
111
+ return { repo: false, diff: '', truncated: false };
112
+ }
113
+ const body = sectionAfter(text, STATUS_MARK, '');
114
+ const truncated = Buffer.byteLength(body, 'utf8') > DIFF_MAX_BYTES;
115
+ return {
116
+ repo: true,
117
+ diff: truncated ? Buffer.from(body, 'utf8').subarray(0, DIFF_MAX_BYTES).toString('utf8') : body,
118
+ truncated
119
+ };
120
+ }
121
+
122
+ module.exports = {
123
+ CHANGES_MAX_FILES,
124
+ DIFF_MAX_BYTES,
125
+ buildGitChangesCommand,
126
+ parseGitChangesOutput,
127
+ buildGitDiffCommand,
128
+ parseGitDiffOutput,
129
+ isAcceptableDiffPath
130
+ };