cli.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (c) 2026 开发者猫叔 (DeveloperCatUncle) · 商业使用需授权:COMMERCIAL-LICENSE.md
"use strict";
/**
* OpenWorkBuddy CLI — 终端里直接跑 agent 任务,与 Web/IM 共用同一套运行时与配置。
*
* 用法:
* openworkbuddy "帮我调研xxx并写成报告" 单发任务,跑完即退出
* openworkbuddy 交互式 REPL(连续对话,保留上下文)
* openworkbuddy -C ~/项目/报表 "把这个目录的表汇总一下" 指定这次在哪个目录干活
* openworkbuddy -f 图.png "这张图里写了什么" 带一个文件/图片一起问(可以写几次)
* cat err.log | openworkbuddy "这个报错什么意思" 管道进来的内容当附加材料
* openworkbuddy -c "接着上面那个继续" 续接最近一次 CLI 会话
* openworkbuddy --json "..." | jq -r 'select(.type=="text").delta' 机器可读事件流
*
* 两条约定,都是为了能塞进管道和脚本:
* 1. **模型的回答走 stdout,进度和日志走 stderr。** 所以 `openworkbuddy "..." > 答案.md` 拿到的是
* 干净的答案,不会混进「第 3 步 思考中…」那些行。
* 2. **退出码说实话**:正常 0,任务出错 1,Ctrl+C 打断 130。以前无论如何都返回 0,
* `openworkbuddy ... && 下一步` 在任务失败时照样往下走。
*
* npm link 后可直接用 `openworkbuddy "任务"`。
*/
// 这趟跑在本机终端、当前系统用户自己的权限下(tools.js memberMustSandbox 据此区分「没账号的服务端请求」)。不进子进程环境
process.env.OWB_ENTRY = "cli";
// Node 太老 / 依赖没装:排在所有 require 最前面,不然用户拿到的是一句 Cannot find module
require("./src/platform/boot-check").enforce({ rootDir: __dirname });
// ---------- 参数解析 ----------
// 解析规则和帮助文本都在 cli-args.js 的那张声明表里,它是纯的:认不出来的选项会
// 原样报回来,由这儿决定怎么说、退出码给几。以前是一串 else if,认不出的词一律
// 当任务文本塞给模型——拼错一个 --quiet,钱照花、进度照打,人还以为自己关掉了。
const cliArgs = require("./src/cli/cli-args");
const parsed = cliArgs.parse(process.argv.slice(2));
const opts = parsed.opts;
if (opts.quiet) opts.verbose = false;
const words = parsed.words;
// ---------- 帮助 / 版本 ----------
// 排在下面那一串 require 前面:看个版本号不该先把 agent、MCP、模型客户端全加载一遍
if (opts.help) { console.log(cliArgs.helpText()); process.exit(0); }
if (opts.version) { console.log(`OpenWorkBuddy ${require("./package.json").version}`); process.exit(0); }
// --json 的 stdout 只许有事件流。依赖里零星的 console.log(MCP 连上了、某处调试行)
// 一律改走 stderr,不然 `| jq` 读到半行中文就整条管道炸了;-q 下这些杂讯也一起闭嘴。
{
const util = require("util");
const toErr = (...a) => {
if (opts.quiet) return;
const text = util.format(...a);
if (!opts.verbose && /^\[(?:MCP|agent)\]/.test(text)) return;
process.stderr.write(text + "\n");
};
console.log = toErr;
console.info = toErr;
}
const fs = require("fs");
const os = require("os");
const path = require("path");
const { dataPath, preferData } = require("./src/platform/paths");
const readline = require("readline");
const { spawnSync } = require("child_process");
const { createLLM } = require("./src/core/model/llm");
const { setWorkspaceDir, getWorkspaceDir } = require("./src/agent/tools");
const { McpManager } = require("./src/agent/mcp");
const { createAgentRuntime } = require("./src/agent/agent");
const lanes = require("./src/core/config/lanes"); // 终端里起的任务归「工程」线;续跑 id 按引擎分开记
const callout = require("./src/util/callout"); // 正文里的提示条:终端没有图标,换成文字标签
const sessSearch = require("./src/core/memory/session-search"); // /resume 的搜索和 --list 的摘要都要它——必须在 listCliSessions 之前
const mdTty = require("./src/cli/md-tty"); // 正文里的 Markdown:终端里渲染出来,别让 **加粗** 糊在脸上
const attach = require("./src/cli/cli-attach"); // 带进来的文件/图片:拖进来的路径、@ 补全、剪贴板
const modes = require("./src/core/config/modes"); // 执行模式的唯一真源;界面和这儿必须是同一份
const projectMemo = require("./src/core/memory/project-memo"); // AGENTS.md / CLAUDE.md:桌面端和这儿读的是同一份
const cliAsk = require("./src/cli/cli-ask"); // agent 问一句时,终端里怎么摆这道选择题
const cliApprove = require("./src/cli/cli-approve"); // 危险操作求批准时,终端里怎么摆那张卡
const security = require("./src/core/safety/security"); // 审批是它发起的;命令行订它的钩子才知道有人正等着点头
const cliLive = require("./src/core/obs/cli-live"); // 把这趟活儿播给网页/手机:看得见、插得上话
const termImage = require("./src/cli/term-image"); // 终端里直接把产出的图画出来 + /open 交给系统程序
const cliSurface = require("./src/cli/cli-surface");
const replKit = require("./src/cli/repl-commands"); // 输入行那几样纯逻辑:多行、搜历史、跑着时那一行的尾巴
const { cols } = require("./src/util/text-width"); // 中文占两列:原地重画那一行要算得出它多宽
const account = require("./src/domains/account/account");
const store = require("./src/platform/store");
// ---------- 输出通道 ----------
// 着色只在「那一头真的是终端」时才加:answer 判 stdout,progress 判 stderr。
// 两个可能一个是 tty 一个被重定向,共用一个 isTTY 会往管道里塞转义序列。
// NO_COLOR 是一条外部约定(no-color.org):设了它,任何程序都不该再往输出里加颜色。
// 谁在用它——盲人用的屏幕阅读器、把终端输出转成纯文本的记录工具、以及一切分不清
// 「\x1b[33m」和正文的下游。之前这儿只看 isTTY:设了 NO_COLOR 的人照样一脸转义序列。
// FORCE_COLOR 是反过来那条(CI 里没有 tty,但日志面板认颜色),顺手一起认了。
const noColorEnv = !!(process.env.NO_COLOR || "");
const forceColor = !!(process.env.FORCE_COLOR || "") && process.env.FORCE_COLOR !== "0";
const ttyErr = !noColorEnv && (!!process.stderr.isTTY || forceColor);
const dim = (s) => (ttyErr ? `\x1b[2m${s}\x1b[0m` : s);
const yellow = (s) => (ttyErr ? `\x1b[33m${s}\x1b[0m` : s);
const red = (s) => (ttyErr ? `\x1b[31m${s}\x1b[0m` : s);
const green = (s) => (ttyErr ? `\x1b[32m${s}\x1b[0m` : s);
/** 改文件那几步的 diff:超过 30 行截掉——终端里要的是一眼看清动了哪儿,全文去看文件 */
function paintDiff(text, max = 30) {
const lines = String(text || "").split("\n");
if (lines.length > 1 && lines[0].startsWith("--- ") && lines[1].startsWith("+++ ")) lines.splice(0, 2); // 文件名上一行已经报过了
const out = lines.slice(0, max).map((l) => (l.startsWith("+") ? green(" " + l) : l.startsWith("-") ? red(" " + l) : dim(" " + l)));
if (lines.length > max) out.push(dim(` … 还有 ${lines.length - max} 行`));
return out.join("\n");
}
const bold = (s) => (ttyErr ? `\x1b[1m${s}\x1b[0m` : s);
/** 进度/诊断:一律 stderr,且 --quiet / --json 下彻底闭嘴 */
// 往终端印过几回。「● 工具」那行跟它的「└ 结果」之间只要插进过别的东西(审批单、提示、正文),结果前就得把调用行再印一遍
let inkSeq = 0;
// workflow 面板开着的时候这两样都闭嘴:过程进会话,屏幕归面板(见下面「workflow 面板」)
const prog = (s) => { if (!opts.quiet && !opts.json && !wfp.st) { tickSettle(); inkSeq++; process.stderr.write(s); } };
/** 模型的回答:stdout,--json 下改走事件流 */
const answer = (s) => { if (!opts.json && !wfp.st) { tickSettle(); inkSeq++; process.stdout.write(s); } };
/** 不走 prog 的那几处(-q / --json 下也得说的话、审批单、提问单):照样先把走字那行定格、照样记一笔 */
const inkRaw = (s) => { tickSettle(); inkSeq++; process.stderr.write(s); };
// ---------- 跑着的时候那一行 ----------
// 「· 第 3 步 思考中… · 12s · 8.4k tokens · Esc 停」:最后印的那行(想、或者一个工具)后面挂一截,每秒原地重画。
// 模型想半分钟、npm test 跑两分钟,屏幕上一动不动,人分不清是在干活还是挂了,也不知道怎么停。
// 原地重画靠的是「光标还在那一行上」:所以只认最后印的那一行(inkSeq 没动过),它自己重画不算印过;
// 别的东西一来(prog / answer / inkRaw)先把这行定格成「· 12s」再让路。整行放不下、窗口改过宽、
// 人在输入行上打着字、审批单摆着,一律不画——宁可不走字,也不能把别的行擦了
// 「思考中」那行打头的 · 会转(✢✳✶✻✽,跟 Claude Code 一样):秒数一秒才跳一下,人盯着一行一秒不动的字,
// 前两秒根本分不出是在想还是卡了。转的只有这一个字符、行长不变,所以不算噪音;工具那行不转,照旧一秒一画
const tick = { render: null, t0: 0, seq: -1, cols: 0, tokens: 0, stop: "Ctrl+C", drawn: false, tailed: false, anim: false, frame: 0, lastSec: -1, ephemeral: false };
/** 输入行上有没有人在打字(交互模式装上)。有的话那行就是他的字,别往上画 */
let typingNow = () => false;
/** 刚印完一行,从这会儿开始计时。render(可用列数, 打头那个字) 给画好的那行,放不下给 null;anim=打头那个字转不转 */
function tickArm(render, fits, anim) {
tick.ephemeral = false;
tick.render = fits ? render : null;
tick.t0 = Date.now();
tick.seq = inkSeq;
tick.cols = process.stderr.columns || 80;
tick.drawn = false;
tick.tailed = false;
tick.anim = !!anim;
tick.lastSec = -1;
}
/** opt.bare:只重画那行本身、不挂尾巴(头两秒转圈用);opt.glyph:打头那个字,不给就是定格的样子 */
function tickPaint(opt) {
if (!tick.render || tick.seq !== inkSeq || (process.stderr.columns || 80) !== tick.cols) return false;
const suffix = opt.bare ? "" : replKit.tickSuffix(opt);
const head = tick.render(termWidth() - cols(suffix) - 1, opt.glyph); // 再留一列:● 在有的终端里占两格
if (!head) return false;
process.stderr.write("\r\x1b[2K" + head + dim(suffix));
return true;
}
function tickDraw() {
if (!tick.render || tick.seq !== inkSeq || keyGrab || typingNow()) return;
const secs = (Date.now() - tick.t0) / 1000;
const sec = Math.floor(secs);
if (!tick.anim && sec === tick.lastSec) return; // 不转的行秒数没跳就不画:定时器一秒跑好几次,白写终端
tick.lastSec = sec;
const glyph = tick.anim ? replKit.spinGlyph(tick.frame++) : undefined;
// 一眨眼就完的步骤不挂尾巴,屏幕上少一半噪音——只转那个字
if (secs < 2) { if (glyph && tickPaint({ bare: true, glyph })) tick.drawn = true; return; }
// 放不下就先丢 token、再丢「Esc 停」,最后只剩秒数
for (const o of [{ secs, tokens: tick.tokens, stop: tick.stop }, { secs, stop: tick.stop }, { secs }]) {
if (tickPaint({ ...o, glyph })) { tick.drawn = true; tick.tailed = true; return; }
}
}
/** 定格:「Esc 停」那截只在跑着的时候有意义,留在翻上去的记录里就是句过期的话;转着的那个字落回 · */
function tickSettle() {
if (tick.ephemeral && tick.render && tick.seq === inkSeq && !typingNow()) {
process.stderr.write("\r\x1b[2K");
tick.render = null;
tick.drawn = false;
tick.ephemeral = false;
return;
}
if (!tick.drawn) return;
tick.drawn = false;
if (!typingNow()) tickPaint(tick.tailed ? { secs: (Date.now() - tick.t0) / 1000 } : { bare: true });
}
/** 人开始在那一行上打字了:定格、这一行不再走字(下一步 / 下一个工具重新起) */
function tickPause() {
tickSettle();
tick.render = null;
}
/** The compact display owns one temporary line; it never rewrites user input. */
function compactProgress(text) {
if (opts.verbose || opts.quiet || opts.json || wfp.st || !process.stderr.isTTY || typingNow() || keyGrab) return;
tickSettle();
const head = toolView.clip(String(text).replace(/[\x00-\x1f\x7f]/g, " "), termWidth());
inkSeq++;
process.stderr.write(dim(head));
tickArm((room, glyph) => toolView.clip((glyph || "·") + " " + head.replace(/^[·●]\s*/, ""), room), true, true);
tick.ephemeral = true;
tick.drawn = true;
}
// ---------- workflow 面板 ----------
// `openworkbuddy workflow` 在终端里跑的时候,屏幕上只有一块面板(画法见 workflow-panel.js):
// 每一步的工具、正文、diff 一概不印——过程全进会话,网页/手机上照样逐条看得见。
// 面板钉在最底下、每秒原地重画:往上挪它占的那几行、清到屏底、再画一遍。
// 两条输出流在面板开着时各包一层:不管谁要印东西(审批单、提问单、报错),先把面板擦掉再印,
// 下一秒面板在它下面重新长出来。漏包一处也不会把面板画进别人的字里——擦的是面板自己记着的那几行。
// 提问/审批摆着的时候(hold > 0)不重画:人正对着单子按键,底下不能有东西跳
const wp = require("./src/cli/workflow-panel");
const wfp = { st: null, cur: -1, widths: [], hold: 0, timer: null, rawErr: null, rawOut: null, bol: true, blink: false };
/** 这一趟 workflow 用不用面板:两条流都得是终端(重定向到文件的人要的是全过程),-q / --json 另有约定 */
const panelWanted = () => !!process.stderr.isTTY && !!process.stdout.isTTY && !opts.quiet && !opts.json;
const cyan = (s) => (ttyErr ? `\x1b[36m${s}\x1b[0m` : s);
/** 转着的那个字:暖色,跟压暗的正文分得开 */
function spinPaint(g) { return ttyErr ? `\x1b[38;5;209m${g}\x1b[39m` : g; }
const PANEL_PAINT = { dim, ok: green, fail: red, run: cyan, title: bold, sel: (s) => bold(cyan(s)) };
const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
/** 擦掉上一次画的面板。窗口改窄过的话,老的一行现在折成了好几行,按现在的宽度算要往上挪几行 */
function panelErase() {
if (!wfp.widths.length) return;
const w = Math.max(1, process.stderr.columns || 80);
const rows = wfp.widths.reduce((n, x) => n + Math.max(1, Math.ceil(x / w)), 0);
wfp.widths = [];
wfp.rawErr(`\x1b[${rows}A\r\x1b[J`);
}
function panelDraw(final) {
if (!wfp.st || (wfp.hold && !final)) return;
wfp.blink = !final && !wfp.blink;
let rows = wp.render(wfp.st, { width: (process.stderr.columns || 80) - 2, paint: (s, k) => (PANEL_PAINT[k] || String)(s), blink: wfp.blink, flat: !!final });
// 比屏幕还高就往上挪不回去了:留标题和最底下那几行(进度条、正在跑的步)
const max = Math.max(4, (process.stderr.rows || 40) - 2);
if (!final && rows.length > max) rows = [rows[0], ...rows.slice(rows.length - (max - 1))];
panelErase();
wfp.rawErr((wfp.bol ? "" : "\n") + rows.join("\n") + "\n");
wfp.bol = true;
wfp.widths = final ? [] : rows.map((r) => cols(stripAnsi(r)));
}
/** 包一层 write:先擦面板,再记下写完光标是不是在行首(面板要从行首画) */
function panelWrap(raw) {
return (chunk, ...rest) => {
panelErase();
const s = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : "";
if (s) wfp.bol = s.endsWith("\n");
return raw(chunk, ...rest);
};
}
function panelStart(state) {
wfp.st = state;
wfp.cur = -1;
wfp.rawErr = process.stderr.write.bind(process.stderr);
wfp.rawOut = process.stdout.write.bind(process.stdout);
process.stderr.write = panelWrap(wfp.rawErr);
process.stdout.write = panelWrap(wfp.rawOut);
panelDraw();
wfp.timer = setInterval(() => panelDraw(), 1000);
if (wfp.timer.unref) wfp.timer.unref();
}
/** 收起:最后画一遍定格的(不闪、不截),两条流还原 */
function panelStop() {
if (!wfp.st) return;
clearInterval(wfp.timer);
panelDraw(true);
process.stderr.write = wfp.rawErr;
process.stdout.write = wfp.rawOut;
wfp.st = null;
}
/** 事件进面板:正文、用量、产出照记(落盘和收尾要用),屏幕上只动那一步的一行 */
function panelFeed(state, ev) {
if (ev.type === "text" && !ev.depth) state.finalParts.push(callout.strip(ev.delta));
else if (ev.type === "usage") state.usage = ev;
else if (ev.type === "files") { state.files = ev.files || state.files; noteChanged(state, ev.changed); }
const s = wfp.st.steps[wfp.cur];
if (s) wp.feed(s, ev, { toolLine: (e) => e.title || toolView.callLine(e, { width: 60 }) });
}
/** 机器可读事件流 */
const emitJson = (o) => { if (opts.json) process.stdout.write(JSON.stringify(o) + "\n"); };
/**
* 正文要不要在终端里渲染成人看的样子。
*
* 只在 stdout 真的是终端时才渲染:`openworkbuddy "…" > 答案.md`、`openworkbuddy … | pbcopy` 要的是原始
* Markdown——那才是能接着加工的东西(跟上面那条「回答走 stdout」是同一个约定)。
* --json 走事件流不归这儿管,--raw 是人明说了别动。
*/
const renderMd = !opts.json && !opts.raw && !!process.stdout.isTTY;
const newMdRenderer = () => (renderMd
? mdTty.createRenderer({ width: process.stdout.columns || 80, color: !noColorEnv })
: null);
// ---------- 参数写错了 ----------
if (parsed.problems.length) {
// 退出码 2 单独留给「参数写错了」:脚本里能跟「任务失败」分开处理,
// 也免得 `openworkbuddy --qiet ... && 下一步` 在打错字的时候照样往下走
process.stderr.write(red(cliArgs.problemText(parsed.problems)));
process.stderr.write(dim("openworkbuddy --help 看全部用法。\n"));
process.exit(2);
}
// 子命令。动词式的写法(openworkbuddy resume / openworkbuddy sessions / openworkbuddy engines)是给人记的,
// 老的 --session / --list / -c 一个都没动,脚本不用改。
let sub = "";
if (cliArgs.SUBS.some((x) => x.name === words[0])) {
sub = words.shift();
// 会话 id 有固定前缀(cli_ 是命令行开的,s_ 是桌面开的),认得出就当 id,认不出就当任务描述
if (sub === "resume" && words[0] && /^(cli_|s_)/.test(words[0])) opts.session = words.shift();
if (sub === "sessions") opts.list = Number(words[0]) > 0 ? Number(words.shift()) : opts.list || 10;
}
let oneShot = words.join(" ").trim();
// ---------- openworkbuddy completion:把 Tab 补全脚本打到 stdout ----------
// 排在读配置前面,跟 doctor 同理:装完就该能生成,不该要求先跑过一次把 config.json 造出来。
// 脚本本身长在 cli-args.js 的同一张表上,改一行选项,三种 shell 的补全同时就有了。
if (sub === "completion") {
const shell = String(words[0] || "").trim() || path.basename(String(process.env.SHELL || "")) || "bash";
const ok = ["bash", "zsh", "fish"];
if (!ok.includes(shell)) {
process.stderr.write(red(`不认识这个 shell:${shell}。可选:${ok.join(" / ")}\n`));
process.exit(2);
}
let engineIds = [];
try { engineIds = require("./src/engines").list().map((b) => b.id); } catch {}
process.stdout.write(cliArgs.completionScript(shell, { sessionsDir: dataPath("data", "sessions"), engines: engineIds }));
// 装法写在 stderr:这样 `openworkbuddy completion zsh > _wb` 拿到的是干净的脚本,说明照样看得见
const how = {
bash: "openworkbuddy completion bash > ~/.openworkbuddy-completion.bash\n然后在 ~/.bashrc 里加一行:source ~/.openworkbuddy-completion.bash",
zsh: "openworkbuddy completion zsh > ~/.zsh/completions/_openworkbuddy\n确认 ~/.zshrc 里有:fpath=(~/.zsh/completions $fpath) 和 autoload -Uz compinit && compinit",
fish: "openworkbuddy completion fish > ~/.config/fish/completions/openworkbuddy.fish\n新开一个窗口就生效",
}[shell];
if (!process.stdout.isTTY) process.stderr.write(dim(how + "\n"));
else process.stderr.write(dim("\n上面这段要存成文件才起作用:\n" + how + "\n"));
process.exit(0);
}
// ---------- 配置与运行时(与 server.js 同源) ----------
const CONFIG_PATH = dataPath("config.json");
// openworkbuddy doctor 是个例外:它就是用来查「为什么什么都没配好」的,在这儿把它拦下等于
// 把唯一一根救命稻草也收走。别的命令照旧当场停——没有配置它们干不了活。
if (!fs.existsSync(CONFIG_PATH) && sub !== "doctor") {
process.stderr.write(red("找不到 config.json,请先运行一次 npm start 生成,或从 config.example.json 复制。\n"));
process.stderr.write(dim("不确定是哪儿不对的话,先跑一句 openworkbuddy doctor。\n"));
process.exit(1);
}
const config = store.readJson(CONFIG_PATH, {});
// 跟网页服务一样先把安全中心的默认策略补齐:config.json 里常常根本没有 security 这一段,
// 不补的话 gateway 是 undefined → 闸门当成关着,删除保护、黑名单、高危拦截全都不生效。
// 只补内存里这份,不回写文件
security.getSecurity(config);
// AI 起的子进程只拿最小环境变量,跟网页服务同一套策略(见 src/platform/child-env.js):
// 属主清单照 config 走;像 Key 的名字只在一个账号时给,读账号库出错按「多个」算
require("./src/platform/child-env").setPolicy(() => {
let solo = false;
try { solo = account.userCount() <= 1; } catch {}
return { allow: security.getSecurity(config).env_passthrough, keys: solo };
});
// 多人共用时碰了文件黑名单直接拦、不出审批卡(同网页服务);读账号库出错按「多个」算
security.setMultiUser(() => {
try { return account.userCount() > 1; } catch { return true; }
});
// 桌面版 / 网页服务多半正开着:它的端口按同一套规则算出来登记进地址闸,AI 的联网工具在命令行里也打不到
require("./src/core/safety/net-guard").registerOwnPort(require("./src/platform/paths").resolvePort(process.env, config), "主服务");
// ---------- --perm:这一趟放多少权 ----------
// 网页那边四档是点得到的(设置里一个下拉),命令行原来只能改 config.json —— 而 config.json 是
// 长期设置:为了让一条 cron 跑全自动,得先把文件改成 full、跑完再改回来,忘了改回来就是
// 明天所有交互式的活儿也不问人了。这正是 --perm 要挡掉的那种事故。
// 所以跟 -C 一个规矩:命令行是「这一次」的意思,**只改内存里这份 config,绝不回写文件**。
// 下面所有人(runtime、审批钩子、/perm)读的都是 config.security.permission_mode 这一个字段,
// 改它一处就全生效,不存在 CLI 一套、内核另一套的分叉。
if (opts.perm) {
security.getSecurity(config).permission_mode = opts.perm;
}
// ---------- --allow:这一趟开跑前就点好头的那几类 ----------
// 跟审批卡上「本会话同类不再问」记进同一张表(只在内存里,退出就没了)。
// 没人坐在终端前的时候,要批的一律当场拒(见 handleApproval)——想让 cron 里的活儿跑 npm test,就在这儿点名
const allowLabels = [];
for (const s of opts.allow || []) {
const r = security.parseAllowRule(s);
if (r.error) { process.stderr.write(red(r.error + "\n")); process.exit(2); }
security.addSessionAllow(r.key);
allowLabels.push(r.label);
}
// ---------- --model:这一次用哪个模型 ----------
// 跟 --perm 一样只改内存。认 models 里的名字,也认型号 id;认不出来就停,绝不退回默认那条去花钱
if (opts.model) {
const want = String(opts.model).trim();
const ms = config.models || [];
const hit = ms.find((m) => m.name === want) || ms.filter((m) => m.model === want)[0];
if (!hit) {
process.stderr.write(red(`配置里没有叫「${want}」的模型。有这些:${ms.map((m) => m.name).join(" / ") || "(一个都没配)"}\n`));
process.exit(2);
}
config.active_model = hit.name;
config.agent = config.agent || {};
const eng = String(config.agent.engine || "builtin").trim() || "builtin";
if (eng !== "builtin") {
// 设置里挑的是本机引擎时,模型根本不起作用——换了就得说出来,账单也跟着从订阅挪回 API
process.stderr.write(dim(`(--model 要走内置 agent,这一次不用「${eng}」引擎)\n`));
config.agent.engine = "builtin";
}
}
/** 当前档位(同一份真源,网页/命令行/审批都读它) */
const permNow = () => security.permissionMode(config.security);
// ---------- openworkbuddy pair:在终端里把手机连上来 ----------
// 位置在 config 读完之后——要拿 config 里的端口去找那台正在跑的服务。
// 为什么非得有服务在跑:配对码只活在服务进程的内存里(落盘的码会在硬盘上留下一把
// 三分钟的钥匙,不值得)。命令行自己是独立进程,它生成的码服务端根本不认识。
// 所以这儿的做法是——命令行本来就能读 users.json,它给自己签一条临时令牌,
// 用这条令牌去请那台服务出码,出完就把这条临时令牌注销掉,不在设备表里留渣。
if (sub === "pair") {
(async () => {
const u = account.defaultUser();
if (!u) {
process.stderr.write(red("本机还没有账号。先打开一次桌面端或网页版注册。\n"));
process.exit(1);
}
const port = require("./src/platform/paths").resolvePort(process.env, config);
const base = `http://127.0.0.1:${port}`;
const A = account._internals;
const token = A.issueToken(u.username, { kind: "session", name: "openworkbuddy pair(临时)" });
const call = (p, init) => fetch(base + p, { ...init, headers: { ...(init || {}).headers, Cookie: `openworkbuddy_token=${token}` } });
const cleanup = () => { try { A.revokeDevice(u.username, A.deviceId(token)); } catch {} };
let d;
try {
d = await call("/api/devices/pair", { method: "POST" }).then((r) => r.json());
} catch {
cleanup();
process.stderr.write(red(`连不上 ${base}。\n`));
process.stderr.write(dim("配对要有一台服务在跑(手机也是连它)。先在另一个窗口 npm start,或者打开桌面端。\n"));
process.exit(1);
}
if (!d || !d.pretty) { cleanup(); process.stderr.write(red("出码失败。\n")); process.exit(1); }
if (opts.json) {
emitJson({ type: "pair", code: d.code, url: d.url, expires_at: d.expires_at });
} else {
if (d.url) {
// 终端里画二维码:手机扫一下就进去了,8 个字符一个都不用敲。
// small:true 用半块字符,一个码占 ~21 行而不是 ~41 行——不然一屏放不下,
// 滚上去只剩半张码,扫不出来
const art = await require("qrcode").toString(d.url, { type: "terminal", small: true, errorCorrectionLevel: "M" }).catch(() => "");
if (art) process.stdout.write("\n" + art);
}
process.stdout.write(`\n ${bold(d.pretty)} ${dim("← 手机上填这串,或者扫上面的码")}\n`);
if (d.url) process.stdout.write(` ${dim(d.url.replace(/\?pair=.*/, ""))}\n`);
prog(dim(`\n等着…(${Math.round((d.expires_at - Date.now()) / 1000)} 秒内有效,Ctrl-C 退出)\n`));
}
// 盯着,连上就报一声。连上之前不退出——不然人刚扫完,终端已经回到提示符,
// 到底成没成全靠猜
process.on("SIGINT", () => { cleanup(); process.stdout.write("\n"); process.exit(130); });
const deadline = d.expires_at;
for (;;) {
if (Date.now() > deadline + 2000) {
cleanup();
prog(yellow("配对码过期了。再跑一次 openworkbuddy pair。\n"));
process.exit(1);
}
await new Promise((r) => setTimeout(r, 1500));
const st = await call("/api/devices/pair/status").then((r) => r.json()).catch(() => null);
if (st && st.claimed) {
cleanup();
if (opts.json) emitJson({ type: "paired", name: st.claimed.name });
else process.stdout.write(green(`\n✓ ${st.claimed.name} 连上了\n`) + dim(" 密码没有离开过这台机器。要断开:设置 → 安全 → 远程访问,把它踢掉。\n"));
process.exit(0);
}
}
})();
return;
}
// ---------- openworkbuddy passwd / openworkbuddy 2fa:忘了密码、丢了手机时的救急口子 ----------
// 位置跟 pair 一样在读完 config 之后,但它们不连服务——直接改 users.json,
// 所以服务开着关着都能用(服务开着时改完记得让本人重新登录:旧令牌已经全作废了)。
// 凭什么让命令行干这件事,见 account.js resetPasswordLocally 头上那段:能读到
// users.json 的人早就拿到了这台机器上的全部东西,再拦一道密码只剩下「忘了密码
// 就彻底进不去」这一个后果。反过来,这两条**绝不能接到 HTTP 上**。
// ---------- openworkbuddy owner:唯一的超级管理员进不去时的救场口 ----------
// 每个组织只有一个超管,而同级动不了同级——他离职、被停用、密码和手机一起丢了的话,
// 界面上就没有出口了。凭据跟上面两条一样:你能读到 users.json,你本来就是机主。
if (sub === "owner") {
const who = String((words.filter((w) => !w.startsWith("-"))[0] || "")).trim();
try {
const rbac = require("./src/domains/account/rbac");
const org = require("./src/domains/account/org");
if (!who) {
// 不给用户名就只是「看看现在是谁」:把一台机器的主子换掉不该是手滑的后果
account.migrateOwners();
const us = account._internals.loadUsers().users || [];
const byOrg = new Map();
for (const u of us) { const o = org.orgIdOf(u); if (!byOrg.has(o)) byOrg.set(o, []); byOrg.get(o).push(u); }
if (!byOrg.size) { process.stderr.write(dim("这台机器上还没有账号。\n")); process.exit(0); }
for (const [id, list] of byOrg) {
const own = list.find((u) => rbac.roleOf(u) === "owner");
const name = org.getOrg(id).name;
process.stdout.write(own
? `${name}:超级管理员是 ${bold(own.username)}${dim("(管理员 " + list.filter((u) => rbac.roleOf(u) === "admin").length + " 人 · 共 " + list.length + " 人)")}\n`
: `${name}:${yellow("还没有超级管理员")}${dim("(共 " + list.length + " 人)")}\n`);
}
process.stderr.write(dim("换人:openworkbuddy owner <用户名> (原来那个会改任管理员)\n"));
process.exit(0);
}
const r = account.setOwnerLocally(who, { actor: "命令行" });
process.stdout.write(green(`\u2713 ${who} 现在是「${r.org_name}」的超级管理员\n`));
if (r.from) process.stdout.write(dim(` 原来的超管 ${r.from} 已改任管理员——这个人还能管人、改设置,只是发不了管理员了。\n`));
if (r.disabled) process.stderr.write(yellow(" 注意:这个账号是「已停用」状态,先到管理后台复职,不然登不上。\n"));
if (r.two_factor) process.stderr.write(dim(" 这个账号开着二次验证,登录还要那串码。\n"));
process.exit(0);
} catch (e) {
process.stderr.write(red((e && e.message) || String(e)) + "\n");
let names = [];
try { names = (account._internals.loadUsers().users || []).map((u) => u.username); } catch {}
if (who && names.length) process.stderr.write(dim("这台机器上的账号:" + names.slice(0, 20).join("、") + (names.length > 20 ? " …" : "") + "\n"));
process.exit(1);
}
}
// ---------- openworkbuddy worktree:分身都在哪儿、哪些还没合回去 ----------
// 分身是撞车时自动开的,用户没亲手建过,所以他也没有"去哪儿找"的直觉。
// 这条就是那个找法:列出来,说清每根分支合回去的命令,顺手把白跑的那些收掉。
if (sub === "worktree") {
const wt = require("./src/agent/worktree");
// 默认位置在应用仓库里时挪到家目录下(worktree.defaultStore,2026-09-29);挪之前开在老位置的一起列
const LEGACY = dataPath("data", "worktrees");
const STORES = [...new Set([wt.defaultStore(LEGACY), LEGACY])];
const want = String((words.filter((w) => !w.startsWith("-"))[0] || "")).trim();
const rows = STORES.flatMap((s) => wt.list(s));
if (want === "清理" || want === "clean") {
// 只收白跑的那些。有改动、有提交的一个不碰——那是还没合回去的活,删了就真没了
const done = STORES.flatMap((s) => wt.sweep(s, { alive: [], days: 0 }));
const left = STORES.flatMap((s) => wt.list(s));
process.stdout.write(done.length ? green(`\u2713 收掉了 ${done.length} 个没产出的分身\n`) : dim("没有可收的分身(有改动的一个都不碰)。\n"));
if (left.length) process.stderr.write(dim(`还剩 ${left.length} 个有改动的,合回去或者自己删:git worktree remove <目录>\n`));
process.exit(0);
}
if (!rows.length) {
process.stdout.write(dim("现在没有分身。\n"));
process.stderr.write(dim("两条任务同时改同一个 git 仓库时,后来那条会自动去自己的 worktree 里改,不跟人抢工作区。\n"));
process.exit(0);
}
for (const r of rows) {
const tag = r.empty ? dim("(白跑,可以收)") : r.dirty ? yellow(`(${r.files} 个文件还没提交)`) : green(`(${r.commits} 笔提交)`);
process.stdout.write(`${bold(r.branch)} ${tag}\n`);
process.stdout.write(dim(` 目录 ${r.dir}\n`));
if (!r.empty && r.repo) process.stdout.write(dim(` 合回来 git -C ${r.repo} merge ${r.branch}\n`));
}
process.stderr.write(dim("收掉白跑的:openworkbuddy worktree 清理\n"));
process.exit(0);
}
if (sub === "passwd" || sub === "2fa") {
const pos = words.filter((w) => !w.startsWith("-")); // [用户名, 新密码?]
const who = String(pos[0] || "").trim();
const off = !!opts.off;
if (!who) {
process.stderr.write(red(`要说改谁:openworkbuddy ${sub} <用户名>${sub === "2fa" ? " --off" : ""}\n`));
// 顺手把这台机器上有哪些账号列出来:忘了密码的人往往连自己的登录名都记不准
let names = [];
try { names = (account._internals.loadUsers().users || []).map((u) => u.username); } catch {}
if (names.length) process.stderr.write(dim("这台机器上的账号:" + names.slice(0, 20).join("、") + (names.length > 20 ? " …" : "") + "\n"));
process.exit(2);
}
try {
if (sub === "2fa") {
if (!off) {
// 不给 --off 就只看状态:直接把人家的二次验证关掉不该是「手滑打错子命令」的后果
const u = (account._internals.loadUsers().users || []).find((x) => x.username === who);
if (!u) throw new Error("没有这个账号:" + who);
const st = account.twoFactorStatus(u);
process.stdout.write(st.on
? `${who} 的二次验证:${green("开着")}${dim("(" + new Date(st.since).toLocaleString() + " 开的,还剩 " + st.recovery_left + " 个恢复码)")}\n`
: `${who} 的二次验证:${dim("没开")}\n`);
if (st.on) process.stderr.write(dim(`手机丢了就跑:openworkbuddy 2fa ${who} --off\n`));
process.exit(0);
}
const had = account.disableTOTP(who, { byAdmin: true, actor: "命令行" });
process.stdout.write(had
? green(`✓ ${who} 的二次验证已关闭\n`) + dim(" 现在只用密码就能登。登进去后到「设置 → 安全」重新绑一次,旧的密钥和恢复码已经作废。\n")
: dim(`${who} 本来就没开二次验证,什么都没改。\n`));
process.exit(0);
}
// openworkbuddy passwd:不给新密码就随机生成一串(长度和复杂度跟着组织策略走)
const r = account.resetPasswordLocally(who, pos[1] || null, { actor: "命令行" });
process.stdout.write(green(`✓ ${who} 的密码已改\n`));
if (r.generated) {
process.stdout.write(`\n ${bold(r.password)} ${dim("← 新密码,这一次之后不会再显示")}\n\n`);
process.stderr.write(dim(" 自己定一个的话:openworkbuddy passwd " + who + " '你的新密码'\n"));
}
process.stderr.write(dim(" 这个账号在别处的登录状态已经全部作废,需要重新登一次。\n"));
if (r.two_factor) process.stderr.write(yellow(" 注意:这个账号还开着二次验证,光有密码登不进去。手机也丢了就跑:openworkbuddy 2fa " + who + " --off\n"));
if (r.disabled) process.stderr.write(yellow(" 注意:这个账号是「已停用」状态,改了密码也登不上。到管理后台复职,或跑 openworkbuddy 里的离职/复职流程。\n"));
process.exit(0);
} catch (e) {
process.stderr.write(red((e && e.message) || String(e)) + "\n");
process.exit(1);
}
}
// ---------- openworkbuddy jev:问一下判断模型 ----------
// 位置在 doctor 前面、createLLM 后面都行,它不碰 agent 那一套。
// 三种用法一条命令收:不给参数是测活,两个参数是是非题,再多几个词就是单选(加 --score 变打分)。
// 为什么不给它做成 /jev 那样的会话内命令:判断不是对话,它没有上下文、不产文字、不记进会话,
// 混进会话里反而要解释「这一条为什么不算一轮」。
if (sub === "jev") {
const so = require("./src/core/judge/systemone");
const jevApi = require("./src/core/judge/jev");
const st = jevApi.status(config);
const say = (s) => process.stdout.write(s);
// 命令行没有登录态,按本机属主过额度闸、记账:组织配的限额在这儿照样生效,不是网页上的专属
const meter = (fn) => require("./src/core/billing/quota").withActor(require("./src/domains/account/admin").ownerActor({ source: "cli", readConfig: () => config }), fn);
(async () => {
if (!st.ready) {
process.stderr.write(red("判断模型还没法用:" + st.why + "\n") + dim(st.how + "\n"));
process.exit(1);
}
const head = () => prog(dim(`走 ${st.label} · ${st.model} · 来源 ${st.from}\n`));
let out, asked = "";
if (!words.length) {
head();
prog(dim("没给材料,那就拿一段固定的客服工单测一下——答得对不对一眼能看出来\n"));
out = await meter(() => jevApi.selftest(config, { metered: true }));
asked = out.state || "";
} else if (words.length === 1) {
process.stderr.write(red("还得说要判断什么。\n") + dim(
' openworkbuddy jev "这段材料" "它急不急" → 是非题,回一个概率\n' +
' openworkbuddy jev "这段材料" "该给谁做" 表格 写作 研究 → 单选题\n' +
' openworkbuddy jev --score "这段材料" "风险多大" 低 中 高 → 打分题(从低到高)\n'));
process.exit(2);
} else {
head();
const [state, ask, ...opt] = words;
asked = state;
const q = !opt.length ? so.noul(ask) : opts.score ? so.score(ask, opt) : so.choice(ask, opt);
out = await meter(() => jevApi.askMetered(config, { state, questions: { 判断: q } }, { meta: "命令行" }));
}
if (!out.ok) {
// 被额度闸挡下的那趟根本没发,别说成「没答上来」——人会去查渠道,而该看的是额度
if (opts.json) say(JSON.stringify({ ok: false, error: out.error, ...(out.quota ? { quota: true } : {}) }) + "\n");
else process.stderr.write(red((out.quota ? "这一趟没发:" : "没答上来:") + out.error + "\n"));
process.exit(1);
}
if (opts.json) {
say(JSON.stringify({ ok: true, model: out.model, ms: out.ms, answers: out.answers, usage: out.usage, cost: so.costOf(out.usage) }) + "\n");
process.exit(0);
}
if (!words.length) prog(dim("材料:" + asked + "\n"));
for (const a of out.answers) {
const g = so.gate(a);
say((g.act ? green("√ ") : yellow("? ")) + so.lineOf(a) + (g.act ? "" : dim(` 不到 ${so.pct(so.SURE_MIN)},别自动照做`)) + "\n");
}
// 截过一定要说:判断是拿前半段做的,人却以为它看了全文,这种错事后最难查
if (out.truncated) process.stderr.write(yellow(`材料太长(${out.state_chars} 字),只喂了前 ${so.MAX_STATE} 字\n`));
prog(dim(`${out.model} · ${out.ms}ms · ${so.costText(out.usage)}\n`));
process.exit(0);
})().catch((e) => { process.stderr.write(red("挂了:" + ((e && e.message) || e) + "\n")); process.exit(1); });
return;
}
// ---------- openworkbuddy doctor:跑不起来时的一次性体检 ----------
// 位置很讲究:必须排在下面 createLLM 前面。模型一个都没配的机器上 createLLM 当场抛
// 「未知 provider: undefined」——而那恰恰是最需要体检的时刻,体检工具自己先死没有道理。
if (sub === "doctor") {
const doctor = require("./src/cli/doctor");
const paint = { ok: green, warn: yellow, bad: red, dim };
(async () => {
const items = await doctor.gather({
paths: require("./src/platform/paths"),
config,
engines: require("./src/engines"),
workspaceDir: cliArgs.startupWorkspace(opts.workspace, process.cwd()),
bootCheck: require("./src/platform/boot-check"),
});
process.stdout.write(doctor.render(items, (t, lv) => (paint[lv] || ((x) => x))(t)));
// 退出码说实话:有要处理的就 1,好写进安装脚本和 CI(openworkbuddy doctor && npm start)
process.exit(doctor.worst(items) >= doctor.LEVELS.bad ? 1 : 0);
})();
return; // CommonJS 的模块体本身就是个函数,这行是合法的「到此为止」,下面那一整套运行时不用再起
}
/** 设置里挑的那个底层引擎。命令行没有登录态,取不到个人偏好,读的就是这份全局配置 */
const cfgEngine = () => String((config.agent || {}).engine || "builtin").trim() || "builtin";
// -C 优先于启动目录:它只覆盖这次调用,不改持久设置
const wantWorkspace = cliArgs.startupWorkspace(opts.workspace, process.cwd());
if (wantWorkspace) {
try { setWorkspaceDir(wantWorkspace); }
catch (e) {
// 显式传了 -C 却用不了,那是命令写错了,得当场停——默默退回默认目录会把文件写到别处
if (opts.workspace) { process.stderr.write(red(`工作目录用不了:${e.message}\n`)); process.exit(1); }
}
}
// -C / /cd 点名进去的那个目录:就地读写,不另套「任务_」那一层(除非在设置里对这个文件夹明说过放法,见 chatDirHere)
let inPlaceRoot = opts.workspace ? getWorkspaceDir() : "";
// 运行时三件套:模型、专家、MCP。与 server.js 读同一批文件,CLI 不另立一套配置
// 活的壳子,不是一次性造好的客户端:/model 换模型时整棵任务树(含委派出去的专家)都得跟着
// 走新的那条,而 agent.js 在建 runtime 的时候就把这个引用拿在手里了。里层随时能换掉,
// 外面那个引用一直有效——否则换完模型还是老的那条在跑,人在终端里看不出来
let llmImpl = createLLM(config);
const llm = {
get provider() { return llmImpl.provider; },
get model() { return llmImpl.model; },
chat: (args) => llmImpl.chat(args),
};
const expertsDoc = store.readJson(preferData("experts.json"), {}) || {};
const experts = expertsDoc.experts || [];
const expertTeams = expertsDoc.teams || [];
const mcpManager = new McpManager();
// ---------- Goal 目标模式(和网页端同一份,见 goal.js) ----------
// 目标验收借判断模型:一堆「达成了没有」的是非题正是它的形状,而且它会说自己有多确定。
// 没配渠道就返回 ok:false,goal.js 自己退回对话模型那条老路——命令行这边不用管
const goalKit = require("./src/core/automation/goal").createGoalEngine({
workspaceDir: getWorkspaceDir,
// 跟网页端同一道额度闸,主体是本机属主(见 openworkbuddy jev 那段)
decide: (args) => require("./src/core/billing/quota").withActor(
require("./src/domains/account/admin").ownerActor({ source: "cli", readConfig: () => config }),
() => require("./src/core/judge/jev").askMetered(config, args, { meta: "目标验收" })),
});
/**
* 拆验收标准、对着标准判分,这两句问谁。
*
* 跟 server.js 那边同一个道理:配了本机引擎(Claude Code / Codex)就借那个 CLI 问——
* 用户切过去图的就是不花 API 的钱,这两步偷偷走 API 的话,没配 Key 的人会发现
* 目标卡永远停在 0/N,而他根本不知道是哪一步没通。
*/
async function goalThink({ system, prompt, timeoutMs }) {
const id = cfgEngine();
const engMod = require("./src/engines");
if (id !== "builtin" && engMod.get(id)) {
// 跟开跑那条过同一道闸(型号要钉、附加参数不许换型号、多人共用要属主打开),不行就报错,不改问 API 模型
const pass = engMod.admit(id, config);
return await engMod.ask({ id, opts: pass.opts, system, prompt, timeoutMs });
}
const r = await llm.chat({ system, history: [{ role: "user", content: prompt }], tools: [], signal: AbortSignal.timeout(timeoutMs) });
return r.text;
}
/** 终端里的目标卡。打勾的用绿√,没打勾的留空框——一眼看出还差哪几项 */
function printGoalCard(goal) {
if (!goal) return;
const p = goalKit.progress(goal);
const head = goal.status === "done" ? green("目标达成") : `目标 ${p.done}/${p.total}` + (goal.round ? ` · 第 ${goal.round} 轮` : "");
prog("\n" + bold(`◆ ${head}`) + dim(` ${goal.text.slice(0, 48)}\n`));
for (const c of goal.criteria) prog(` ${c.done ? green("√") : dim("□")} ${c.done ? dim(c.text) : c.text}\n`);
if (goal.note) prog(yellow(` !${goal.note}\n`));
if (goal.paused) prog(yellow(` 暂停:${goal.paused}\n`));
prog("\n");
}
// ---------- 会话持久化(与 server.js 同一目录同一结构) ----------
const SESS_DIR = dataPath("data", "sessions");
const sessFileOf = (id) => path.join(SESS_DIR, String(id).replace(/[^\w-]/g, "_") + ".json");
/**
* 列最近的会话,新的在前。
*
* 默认把桌面端的会话一起列出来 —— 桌面和命令行写的本来就是同一批文件
* (data/sessions/<id>.json,同一套字段),只列 cli_ 开头那半边,等于人为把
* 「早上在桌面开了个头,下午想在终端接着做」这条路堵死。
* @param {number} n
* @param {boolean} [cliOnly] 只看命令行自己开的(-c 续接时用,免得接到桌面那边正开着的会话)
*/
function listCliSessions(n, cliOnly = false) {
let names = [];
try { names = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith(".json")); } catch { return []; }
if (cliOnly) names = names.filter((f) => f.startsWith("cli_"));
// 先按改动时间排、截到 n 条,再读内容:几百条会话、每条几百 KB 时,全读一遍再截要多等半秒多,
// 而 -c / resume 只要最新那一条
return names
.map((f) => {
const p = path.join(SESS_DIR, f);
let mtime = 0; try { mtime = fs.statSync(p).mtimeMs; } catch {}
return { f, p, mtime };
})
.sort((a, b) => b.mtime - a.mtime)
.slice(0, n)
.map(({ f, p, mtime }) => {
const j = store.readJson(p, {}) || {};
// 轮数按「问了几次」算:transcript 里一问一答是两条,直接数长度会把一次问答报成 2 轮
const turns = (j.transcript || []).filter((t) => t && t.type === "user").length;
const id = f.replace(/\.json$/, "");
// body 只喂给搜索,不上屏:截短一点,选单最多十二条,没必要为搜索读进几万字
return { id, mtime, title: j.title || "", turns, from: id.startsWith("cli_") ? "命令行" : "桌面", engine: j.engine || "", body: sessSearch.digestOf(j, 1200) };
});
}
/** 新会话 id:带到秒 + 三位随机。
* 以前是 `cli_YYYYMMDD`,同一天的每条命令共用一个文件,而 runTask 会把助手回复和工具结果
* 就地追加进 history —— 于是「单发任务」其实拖着当天所有前一条任务的完整上下文,
* 既烧 token 又让模型在别的任务的阴影里答新问题。默认改成一次一个。 */
function newSessionId() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `cli_${stamp}_${Math.random().toString(36).slice(2, 5)}`;
}
// openworkbuddy resume 不给 id = 接最近动过的那个,不管它是在桌面开的还是命令行开的。
// 这是"丝滑切换"的落点:桌面上做到一半,终端里 openworkbuddy resume 就能接着往下走。
if (sub === "resume" && !opts.session) {
const last = listCliSessions(1)[0];
if (!last) { process.stderr.write(red("没有可续接的会话。先跑一次 openworkbuddy \"任务\" 或在桌面端聊一句。\n")); process.exit(1); }
opts.session = last.id;
prog(dim(`(续接 ${last.from}会话 ${last.id}:${last.title || "无标题"})\n`));
}
const lastCli = opts.cont && !opts.session ? listCliSessions(1, true)[0] : null;
if (opts.cont && !opts.session && !lastCli) prog(dim("(没有可续接的命令行会话,开一个新的)\n"));
let sessionId = opts.session || (lastCli && lastCli.id) || newSessionId();
let sessFile = sessFileOf(sessionId);
// 点名要接的会话不存在(多半是少粘了一位):当场停。以前悄悄开一个同名的空会话,钱照花、模型一句前文都看不到,
// 退出码还是 0。REPL 里 /resume 早就这么拒了,两条入口说法得一样
if (opts.session && !fs.existsSync(sessFile)) {
const want = String(opts.session);
const near = listCliSessions(50).filter((r) => r.id.startsWith(want.slice(0, Math.max(4, want.length - 4)))).slice(0, 3);
process.stderr.write(red(`没有这个会话:${want}\n`));
process.stderr.write(dim(near.length
? `是不是这个:\n${near.map((r) => ` ${r.id} ${r.title || "无标题"}`).join("\n")}\n`
: "openworkbuddy sessions 列出最近的会话\n"));
process.exit(2);
}
// 跟网页端是同一批文件,写法也得一样:原子改名 + .bak,坏了先回退别直接覆盖
let sess = store.readJson(sessFile, { history: [], transcript: [], title: "" });
// 手里这份是什么时候从盘上拿的。网页、手机、另一个终端都会写同一个文件:
// 以前一直拿着开跑时读进来的副本,存盘整份盖回去,人家中间写进去的那几轮就被抹掉了
const sessStatOf = (f) => { try { const st = fs.statSync(f); return st.mtimeMs + ":" + st.size; } catch { return ""; } };
let sessStamp = sessStatOf(sessFile);
let sessBase = (sess.transcript || []).length; // 拿到手时对话记录有几条:后面多出来的才是这边自己记的
/** 换成另一份会话(/new、/resume)时一起换掉上面两笔账 */
function adoptSess(next) {
sess = next;
sessStamp = sessStatOf(sessFile);
sessBase = (sess.transcript || []).length;
}
/** 闲着的时候别处写过这条会话:开跑前重读,这一轮的上下文里才有人家那几轮 */
function reloadSessIfChanged() {
const now = sessStatOf(sessFile);
if (!now || now === sessStamp) return false;
const disk = store.readJson(sessFile, null);
if (!disk || typeof disk !== "object") return false;
adoptSess({ history: [], transcript: [], title: "", ...disk });
return true;
}
function saveSess() {
const now = sessStatOf(sessFile);
if (now && now !== sessStamp) {
// 跑着的这一轮当中别处也写了。对话记录只追加,两边各自多出来的接在一起不会错;
// 喂给模型的 history 两边各改各的(压缩会改写前段)拼不回去:按这边的存,那边那份另存一份,不悄悄丢
const disk = store.readJson(sessFile, null);
if (disk && Array.isArray(disk.transcript)) {
sess.transcript = disk.transcript.concat((sess.transcript || []).slice(sessBase));
try {
const dir = path.join(path.dirname(sessFile), ".conflicts"); // 不以 .json 结尾,列会话的地方都不会把它当成一条会话
fs.mkdirSync(dir, { recursive: true });
const keep = path.join(dir, `${path.basename(sessFile, ".json")}-${Date.now()}.json`);
store.writeJsonAtomic(keep, disk);
process.stderr.write(yellow(`\n这条会话在这一轮跑着的时候别处也写过:对话记录两边都留着;模型的上下文按终端这边存,那边那份另存在 ${keep}\n`));
} catch {}
}
}
sess.updated_at = new Date().toISOString();
store.writeJsonAtomic(sessFile, sess);
sessStamp = sessStatOf(sessFile);
sessBase = (sess.transcript || []).length;
}
// ---------- 事件渲染 ----------
const toolView = require("./src/cli/cli-toolview");
const termWidth = () => Math.max(40, (process.stderr.columns || 80) - 2);
/** 工具那几行的颜色:● 和名字亮一点,参数和输出压暗,出错的红 */
const toolPaint = (s, k) => ({ bullet: (y) => (ttyErr ? `\x1b[36m${y}\x1b[39m` : y), name: bold, arg: dim, out: dim, more: dim, err: red }[k] || ((y) => y))(s);
/**
* 走字那行的工具调用,后面挂上长工具报来的进度:「● Render(a.html) · 渲染帧 432/900」。
* 一行必须塞进 room 列(中文按两列算):超一格就折行,原地重画会擦不干净。
* 先让参数那截缩到 30 列(callLine 的下限),还不够才截进度那句;连调用行都放不下给 null,这一趟不走字。
* label 里的控制字符一律换成空格——它是工具随手写的,混进一个 ESC 这一行的宽度就算不准了
*/
function toolTickLine(ev, label, room, paint) {
const fit = (w) => cols(toolView.callLine(ev, { width: w })) <= w;
const t = String(label || "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
let tail = "";
if (t) {
const sep = " · ", max = room - 30;
if (cols(sep + t) <= max) tail = sep + t;
else {
let cut = "";
for (const ch of t) { if (cols(sep + cut + ch + "…") > max) break; cut += ch; }
if (cut) tail = sep + cut + "…";
}
}
if (tail && fit(room - cols(tail))) return toolView.callLine(ev, { width: room - cols(tail), paint }) + (paint ? paint(tail, "more") : tail);
return fit(room) ? toolView.callLine(ev, { width: room, paint }) : null;
}
/**
* 这些事件一来,上一段正文就算说完了:渲染器里压着的半行(还没等到换行的标题、列表、没配平的 **)先吐干净。
* 不吐的话,工具那行先上屏、压着的半句后到,两步的话还会粘成一行;下一步的「## 标题」也会被当成上一段的续行,原样打出 ##。
* status 不在里面:心跳、重试提示会插在一段话中间,在那儿吐等于把没配平的记号原样打出去
*/
const MD_BREAKS = new Set(["step_start", "parallel", "tool_use", "tool_result", "ask_user", "expert_start", "expert_done",
"team_start", "team_done", "limit", "failover", "compact", "trim", "auto_continue", "sleep", "todos", "milestones"]);
const seenNotices = new Set();
function makeEmit(state) {
const flushMd = () => {
const r = state.md ? state.md.end() : "";
// 后面的进度行自带开头的换行;-q 下没有进度行,换行得留着,不然下一段正文接在同一行
if (r) answer(opts.quiet ? r : r.replace(/\n$/, ""));
};
return (ev) => {
// 先播给网页/手机,再管终端怎么显示:这两件事互不相干,哪边坏了都不该拖累另一边
if (state.live) state.live.event(ev);
if (wfp.st) return panelFeed(state, ev);
if (opts.json) {
// step_usage 只是给终端那行走字用的,整趟的用量照旧在收尾那条 usage 里——--json 的事件表不多出一种
if (ev.type === "step_usage") return;
// 事件原样出去,只把 files 这类大字段留给调用方自己挑
emitJson(ev);
if (ev.type === "text" && ev.depth === 0) state.finalParts.push(ev.delta);
if (ev.type === "usage") state.usage = ev;
if (ev.type === "files") { state.files = ev.files || state.files; noteChanged(state, ev.changed); }
return;
}
// Keep diagnostics available on demand while the normal transcript stays short.
if (state.details && state.details.length < 600 && ["tool_use", "tool_result", "status"].includes(ev.type)) {
const detail = {};
for (const key of ["type", "id", "name", "input_preview", "purpose", "title", "preview", "outcome", "isError", "diff", "text", "notice", "depth"])
if (ev[key] !== undefined) detail[key] = typeof ev[key] === "string" ? ev[key].slice(0, key === "diff" ? 16000 : 8000) : ev[key];
const size = Buffer.byteLength(JSON.stringify(detail));
if (state.detailBytes + size <= 1024 * 1024) { state.details.push(detail); state.detailBytes += size; }
}
if (!opts.verbose) {
if (ev.type === "text" && !ev.depth) {
const text = callout.strip(ev.delta);
state.finalParts.push(text);
if (/^\s*>\s*\[!(?:warn|wait|ok|stop)\]/m.test(ev.delta || "")) prog(yellow("\n" + text.trim() + "\n"));
else if (ev.phase !== "commentary") state.pendingText += text;
return;
}
if (ev.type === "step_start") {
if (!ev.depth) compactProgress("· 正在处理…");
return;
}
if (ev.type === "tool_use") {
state.pendingText = ""; // Text followed by a tool call was a progress update.
if (!state.calls) state.calls = new Map();
if (ev.id) state.calls.set(ev.id, ev);
state.toolCount++;
compactProgress(toolView.callLine(ev, { width: termWidth() }));
return;
}
if (ev.type === "tool_result") {
const call = ev.id && state.calls ? state.calls.get(ev.id) : null;
if (ev.id && state.calls) state.calls.delete(ev.id);
if (ev.isError) {
state.failedTools++;
prog("\n" + toolView.callLine(call || ev, { width: termWidth(), paint: toolPaint }) + "\n" +
toolView.resultLines({ ...ev, name: ev.name || (call && call.name) }, { width: termWidth(), paint: toolPaint, max: 2 }).join("\n") + "\n");
}
compactProgress("· 正在处理…");
return;
}
if (ev.type === "status") {
if (ev.model) state.model = ev.model;
if (ev.notice && !seenNotices.has(ev.text)) {
seenNotices.add(ev.text);
prog(yellow(`\n· ${ev.text}\n`));
} else if (!ev.notice && !ev.depth) compactProgress(ev.starting ? "· 正在连接执行引擎…" : "· 正在处理…");
return;
}
if (["parallel", "tool_progress", "expert_start", "expert_done", "team_start", "team_done", "trim", "todos", "milestones"].includes(ev.type)) return;
}
// 结果到了先撤进度:下面一印东西那行就定格,定格的样子该是「● Render(a.html) · 1m20s」,不停在半截的数上
if (ev.type === "tool_result" && state.toolProg && state.toolProg.id === ev.id) state.toolProg = null;
if (MD_BREAKS.has(ev.type)) flushMd();
if (ev.type === "text") {
if (ev.depth > 0) return;
// 这个空行是用来跟上面的进度隔开的;-q / 没进度可打的时候没东西要隔,
// 再吐一个就是往重定向出来的文件里塞前导空行。
if (!state.streamed) { if (!opts.quiet) answer("\n"); state.streamed = true; }
// 提示条是整条一次 emit 的(agent.js 那几处 callout.line),不会被切片切成半个记号
const text = callout.strip(ev.delta);
state.finalParts.push(text); // 记的是**原文**:会话存盘、收尾判断都按原文来,渲染只是给眼睛看的一层
answer(state.md ? state.md.write(text) : text);
} else if (ev.type === "step_start") {
if (ev.depth === 0) {
const head = `· 第 ${ev.step} 步 思考中…`;
prog(dim("\n" + head));
const rest = head.slice(1);
tickArm((room, g) => (cols(head) > room ? null : g ? spinPaint(g) + dim(rest) : dim(head)), cols(head) <= termWidth(), true);
}
state.streamed = false;
} else if (ev.type === "step_usage") {
tick.tokens = (Number(ev.prompt) || 0) + (Number(ev.completion) || 0);
} else if (ev.type === "parallel") {
// 子智能体里的那一批也带上是谁:几个探索并发时,光一句「3 个只读工具并发」分不清是哪个的
const who = ev.depth > 0 && ev.expert ? `${ev.expert} · ` : "";
prog(dim(`\n ▸▸ ${who}${ev.count} ${ev.kind === "gen" ? "条生成任务一起跑" : "个只读工具并发执行"}`));
state.streamed = false;
} else if (ev.type === "tool_use") {
// 「● Shell(npm test)」:跑的是哪条命令、动的是哪个文件,一眼看得见(见 cli-toolview.js)
if (!state.calls) state.calls = new Map();
if (ev.id) state.calls.set(ev.id, ev);
// 问你一句:先不印。真弹了单子,单子上就有这道题,再印一行「● Ask(题目)」加一行「└ 用户的回答」是同一件事说三遍;
// 没弹成(无人值守、这一问被闸拦下)的,等结果回来再补印这一行
if (ev.name === "ask_user" && ev.id) { state.lastTool = null; return; }
prog("\n" + toolView.callLine(ev, { width: termWidth(), paint: toolPaint }));
state.lastTool = { id: ev.id, seq: inkSeq };
state.streamed = false;
// 主线上的工具才走字:专家那几行交错着来,挂在谁后面都说不清
if (!ev.depth) {
const fit = (w) => cols(toolView.callLine(ev, { width: w })) <= w;
// 进度(tool_progress)来了只改 state.toolProg,下一次重画自己带上;认 id,别把上一个工具的进度挂到这一行
state.toolProg = null;
tickArm((room) => toolTickLine(ev, state.toolProg && state.toolProg.id === ev.id ? state.toolProg.label : "", room, toolPaint), fit(termWidth()));
}
} else if (ev.type === "tool_progress") {
// 不另起一行:渲染 900 帧印 900 行就把屏幕冲没了。只挂在还在走字的那行调用后面(主线、最后印的就是它),
// 别的(专家内层、已经被别的输出顶上去的)不说——结果回来照旧有「└」那行
const last = state.lastTool;
if (!ev.depth && last && last.id === ev.id && last.seq === inkSeq) {
const n = (x) => typeof x === "number" && Number.isFinite(x);
const label = ev.label || (n(ev.total) && ev.total > 0 && n(ev.done) ? `${ev.done}/${ev.total}` : n(ev.pct) ? `${Math.round(ev.pct)}%` : "");
if (label) { state.toolProg = { id: ev.id, label }; tick.lastSec = -1; } // lastSec 归零:秒数没跳也得把新进度画上去
}
} else if (ev.type === "ask_user") {
// 单子马上要画了:记下是哪一次调用,它的结果回来时就不再印一遍。几个专家同时问的话按题目认
const q = String(ev.question || "");
for (const [id, c] of state.calls || []) {
if (c.name !== "ask_user" || (state.askCards && state.askCards.has(id))) continue;
let cq = "";
try { cq = String(JSON.parse(c.input_preview || "{}").question || "").trim().slice(0, 500); } catch {}
if (cq && cq !== q) continue;
(state.askCards || (state.askCards = new Set())).add(id);
break;
}
state.streamed = false;
} else if (ev.type === "tool_result") {
if (ev.id && state.askCards && state.askCards.delete(ev.id)) {
// 单子上有题、选了什么 cli-ask 当场回显过、超时由 ask_answer 那条补一句,这儿没有要补的
if (state.calls) state.calls.delete(ev.id);
state.lastTool = null;
state.streamed = false;
return;
}
// └ 只能紧挨着自己那行 ●。并发回来的顺序不一定、中间还可能插进一张审批单——那就把自己那行再印一遍
const call = ev.id && state.calls ? state.calls.get(ev.id) : null;
const last = state.lastTool;
if (ev.id && !(last && last.id === ev.id && last.seq === inkSeq)) prog("\n" + toolView.callLine(call || ev, { width: termWidth(), paint: toolPaint }));
if (ev.id && state.calls) state.calls.delete(ev.id);
const rows = toolView.resultLines({ ...ev, name: ev.name || (call && call.name) }, { width: termWidth(), paint: toolPaint });
if (rows.length) prog("\n" + rows.join("\n"));
state.lastTool = null;
// 改文件那几步把 diff 摆出来:加的绿、删的红。改坏了当场看得见,/rewind 退回去
if (!ev.isError && ev.diff) { prog("\n" + paintDiff(ev.diff)); state.streamed = false; }
} else if (ev.type === "status") {
if (ev.depth === 0 || ev.depth === undefined) { prog(dim(`\n· ${ev.text}`)); state.streamed = false; }
} else if (ev.type === "expert_start") {
prog(yellow(`\n ◆ 委派专家「${ev.expert}」`) + dim(`:${String(ev.task || "").slice(0, 60)}`));
} else if (ev.type === "limit") {
prog(yellow(/^已手动停止/.test(ev.note || "") ? `\n▲ ${ev.note}` : `\n▲ ${ev.note},任务强制收尾`));
} else if (ev.type === "expert_done") {
prog(dim(`\n ◇ 专家「${ev.expert}」交活了`));
state.streamed = false;
} else if (ev.type === "team_start") {
prog(yellow(`\n ◆ 拉了一队人:${ev.team}`) + dim(`(${(ev.members || []).join("、")})`));
state.streamed = false;
} else if (ev.type === "team_done") {
prog(dim(`\n ◇ ${ev.team} 这一队干完了`));
state.streamed = false;
} else if (ev.type === "failover") {
// 这一条必须说:换渠道=这趟活儿后半截是另一个模型答的,账也记到另一个渠道上。
// 不说的话,人只会觉得「怎么后面风格变了」,还以为是自己提示词写崩了
prog(yellow(`\n▲ ${ev.note}`));
state.streamed = false;
} else if (ev.type === "compact") {
// 压缩是背着人做的,但它真的会改变模型手里有什么。一声不吭地把前面几十轮换成一份摘要,
// 然后模型突然「忘了」刚才说好的事——不说清楚的话,这在终端里看起来就像模型坏了
prog(dim(`\n· 上下文压缩:${ev.removed} 条旧对话换成一份摘要(原文存在 data/compact-archive)`));
state.streamed = false;
} else if (ev.type === "trim") {
prog(dim(`\n· 上下文太长,截掉了 ${Math.round((ev.chars || 0) / 1000)}k 字符的旧工具输出`));
state.streamed = false;
} else if (ev.type === "auto_continue") {
prog(dim(`\n· 没干完,自动接着来(第 ${ev.round}/${ev.total} 轮):${String(ev.note || "").slice(0, 60)}`));
state.streamed = false;
} else if (ev.type === "sleep") {
prog(dim(`\n· ${ev.note}`));
state.streamed = false;
} else if (ev.type === "todos") {
// 进度清单每次整张重发:终端里只画最新这张,已完成的变灰,正在做的加粗
const items = ev.items || [];
const done = items.filter((x) => x.status === "done").length;
const rows = items.map((x) => x.status === "done" ? dim(` ✓ ${x.content}`) : x.status === "in_progress" ? bold(` ▶ ${x.content}`) : ` ○ ${x.content}`);
prog(`\n${dim(`· 进度 ${done}/${items.length}`)}\n${rows.join("\n")}`);
state.streamed = false;
} else if (ev.type === "milestones") {
prog(dim(`\n· 进度表 ${ev.file}:${(ev.items || []).length} 项`));
state.streamed = false;
} else if (ev.type === "ask_answer") {
// 问题和选项由 askUser 那边画(它得在人回答**之前**出现);这儿只补一句超时的结局
if (ev.timeout) prog(yellow(`\n· 没等到回答,它按自己的判断接着做了`));
state.streamed = false;
} else if (ev.type === "usage") {
state.usage = ev;
} else if (ev.type === "files") {
state.files = ev.files || state.files;
noteChanged(state, ev.changed);
}
};
}
function printSummary(state) {
if (opts.json) {
emitJson({ type: "done", ok: !state.error, error: state.error || null, session: sessionId,
usage: state.usage || null, credits: state.credits || null,
files: (state.files || []).map((f) => f.name), workspace: getWorkspaceDir() });
return;
}
if (!opts.verbose && state.pendingText) answer(state.md ? state.md.write(state.pendingText) : state.pendingText);
// 收尾补一个换行让文本文件规规矩矩地结束;正文自己已经以换行收尾就别再补一个
if (state.md) {
// 渲染器里可能还压着半行(等一个配对的 ** 没等到就结束了),这一下把它吐干净。
// 它吐出来的行自带换行,所以只有「一个字都没吐过」时才需要补那一个
answer(state.md.end());
if (!state.finalParts.length) answer("\n");
} else if (!/\n$/.test(state.finalParts.join(""))) answer("\n");
if (state.usage && opts.verbose) {
const u = state.usage;
const secs = Math.round((u.elapsed_ms || 0) / 1000);
prog(dim(`\n✧ 共消耗 ${(u.prompt + u.completion).toLocaleString()} tokens(输入 ${u.prompt.toLocaleString()} / 输出 ${u.completion.toLocaleString()})· ${u.calls} 次调用 · ${secs}s · ${u.provider}(${u.model})\n`));
}
if (!opts.verbose && state.toolCount) {
const secs = Math.round(((state.usage && state.usage.elapsed_ms) || 0) / 1000);
prog(dim(`\n${state.failedTools ? "·" : "✓"} ${state.toolCount} 次工具调用${state.failedTools ? ` · ${state.failedTools} 次失败` : ""}${secs ? ` · ${secs}s` : ""} · ${state.interactive ? "/details 看过程" : "--verbose 展开过程"}\n`));
}
if (state.credits && state.credits.spent > 0) {
prog(dim(`✦ 本次扣 ${state.credits.spent} 积分 · 余额 ${state.credits.balance.toLocaleString()}\n`));
}
const made = cliSurface.currentFiles(state.changed, state.files);
if (made.length) {
lastFiles = made.slice(-8);
lastFilesSession = sessionId;
prog(dim("成果:") + lastFiles.map((n, i) => `${i + 1}. ${n}`).join(" ") + dim(" · /open 1 打开") + "\n");
}
}
/**
* 上下文用到哪儿了。
*
* 聊到第几轮该开新会话,这件事以前在终端里完全是黑的:人只能等模型开始「忘事」才发现
* 压缩已经发生过了。数字按跟 agent.js 同一套算——budget 是 max_context_chars,
* 到 compact_threshold_chars(默认六成)就会在下一轮任务前自动压。两边算法必须一致,
* 不然这儿显示 40%、那边已经压过一次,这个读数就是在骗人。
*/
function contextLine() {
const { historyChars, contextBudgetChars } = require("./src/agent/agent");
const ag = config.agent || {};
const budget = contextBudgetChars(llmImpl && llmImpl.contextWindow, ag.max_context_chars);
const threshold = ag.compact_threshold_chars || Math.floor(budget * 0.6);
const used = historyChars(sess.history || []);
const pct = Math.round((used / budget) * 100);
// 进度条只用 ASCII:方块字符在中文终端里按两列画,长度会跟着终端设置变
const w = 20;
const fill = Math.max(0, Math.min(w, Math.round((used / budget) * w)));
const bar = "=".repeat(fill) + "-".repeat(w - fill);
const when = ag.compact === false
? "(自动压缩关着)"
: used > threshold ? "(下一轮开跑前会自动压一次)" : `(到 ${Math.round((threshold / budget) * 100)}% 自动压缩)`;
return `上下文 [${bar}] ${pct}% ${Math.round(used / 1000)}k / ${Math.round(budget / 1000)}k 字符 ${when}`;
}
// ---------- 产出的图:终端里直接看见 ----------
/**
* 记下这一轮真动过的产出。
*
* files 事件带两份名单:`files` 是「工作目录里现在有什么」——包含上个月那次任务留下的
* 一堆东西;`changed` 才是「这一轮写过谁」。出图和「看图:…」只能认后者,
* 认前者的话,一次什么图都没产出的对话,末尾也会冒出一句「看图:某张八月的封面.png」。
*
* 一轮里这个事件会响很多次,每次只报增量,所以得攒起来。
* @param {{changed: string[]}} state
* @param {string[]|undefined} changed
*/
function noteChanged(state, changed) {
for (const n of changed || []) if (!state.changed.includes(n)) state.changed.push(n);
}
/**
* 上一轮报给用户的那份产出清单(最多 8 个)。`/open 3` 数的就是它,
* 跟屏幕上刚打出来的那行严格对齐——另去读一遍目录的话,顺序和内容都可能对不上。
* @type {string[]}
*/
let lastFiles = [];
let lastFilesSession = "";
function recentCliFiles() {
if (sess.cliFilesRoot && sess.cliFilesRoot !== getWorkspaceDir()) return [];
return lastFilesSession === sessionId && lastFiles.length ? lastFiles : Array.isArray(sess.cliFiles) ? sess.cliFiles.filter(n => typeof n === "string") : [];
}
function openCliFile(arg, recent = false, report = true) {
const dir = getWorkspaceDir();
let name = String(arg || "").trim();
const files = recentCliFiles();
if (recent) name = cliSurface.recentTarget(files);
else if (/^\d+$/.test(name)) name = files[Number(name) - 1] || "";
if (!name) {
const message = files.length ? "有多个成果,请用 /open 序号 指定:\n" + files.map((f, i) => ` ${i + 1}. ${f}`).join("\n") : "这条会话还没有可打开的成果;请用 /open 文件路径 指定。";
if (report) prog(yellow(message + "\n"));
return { ok: false, message };
}
try {
const result = cliSurface.openFile(dir, name);
const message = result.ok ? `已交给本机程序打开:${path.relative(fs.realpathSync(dir), result.path)}` : `本机程序未能打开(${result.error}):${result.path}`;
if (report) prog((result.ok ? dim : yellow)(message + "\n"));
return { ...result, message };
} catch (e) {
const message = `打不开 ${name}:${e.message}`;
if (report) prog(yellow(message + "\n"));
return { ok: false, message };
}
}
function naturalOpen(text) {
const request = cliSurface.openRequest(text);
if (!request || opts.json) return null;
reloadSessIfChanged();
const result = openCliFile(request.target === "recent" ? "" : request.target, request.target === "recent", false);
answer(result.message + "\n");
sess.history.push({ role: "user", content: text }, { role: "assistant", text: result.message });
sess.transcript.push({ type: "user", text, at: new Date().toISOString() },
{ type: "assistant", events: [{ type: "text", delta: result.message }], at: new Date().toISOString() });
saveSess();
return result.ok ? "ok" : "error";
}
/** 终端出图能力探一次就够,一场里不会变 */
let imgCapCache = null;
const imgCap = () => (imgCapCache || (imgCapCache = termImage.detect(process.env, !!process.stderr.isTTY)));
/** 那句「你的终端要打开某个开关才能出图」只说一次,每轮都说就成了噪音 */
let capHintShown = false;
/**
* 把这一轮产出的图贴到终端里。
*
* SVG 得先栅格化——终端的图形协议只收位图。转不动(不在桌面版里跑、本机又没装 Chrome)
* 就安静跳过,下面那行「看图:…」兜底,不在这儿报错吓人:用户要的是看图,
* 不是听一段关于渲染器的解释。
*
* @param {Array<{name: string}|string>} files 这一轮产出的(files 事件的 changed),不是整个工作目录
* @returns {Promise<string[]>} 真画出来的文件名
*/
async function drawOutputs(files) {
const cap = imgCap();
const pick = termImage.pickDrawable(files, 3);
// 面板开着的时候不贴:一张图顶掉半屏,面板就被挤没了。整趟跑完统一贴
if (!pick.length || !cap.proto || opts.quiet || opts.json || wfp.st) return [];
const drawn = [];
for (const name of pick) {
let png = null;
try {
const p = path.join(getWorkspaceDir(), name);
if (/\.svg$/i.test(name)) {
const r = await require("./src/domains/media/diagram").svgToPngAnyhow(fs.readFileSync(p, "utf8"));
png = r && r.png;
} else if (/\.png$/i.test(name) || cap.proto === "iterm") {
// iTerm2 那套自己认格式,jpg/gif/webp 原样丢过去就行;
// kitty 的 f=100 只认 PNG,别的位图这儿不画,让「看图:…」那行接手
png = fs.readFileSync(p);
}
} catch { png = null; }
if (!png || !png.length) continue;
// 宽度按终端宽来,留两列边距;终端宽度读不到就按 80 算
const cols = Math.max(20, Math.min(60, ((process.stderr.columns || 80) - 2)));
inkRaw(dim(`▪ ${name}\n`) + termImage.encode(cap.proto, png, { name, cols }));
drawn.push(name);
}
return drawn;
}
/**
* 没画出来的图,告诉人怎么才能看到。
* 交互模式里给 `/open`,一次性模式里给真能粘到 shell 里跑的那条命令——
* 「用系统默认程序打开」这种话等于没说,人还得自己想命令叫什么。
* @param {string[]} made 这一轮真产出的文件(不是整个目录——目录里那些是上个月的东西)
* @param {string[]} drawn drawOutputs 已经画出来的,不用再提
* @param {boolean} interactive
*/
function hintOutputs(made, drawn, interactive) {
if (opts.quiet || opts.json) return;
const rest = termImage.pickDrawable(made, 8).filter((n) => !drawn.includes(n));
if (!rest.length) return;
const cap = imgCap();
if (cap.hint && !capHintShown) { capHintShown = true; prog(dim(` ${cap.hint}\n`)); }
if (interactive) {
prog(dim(` 看图:/open ${rest[0]}`) + dim(rest.length > 1 ? `(或 /open 序号)\n` : "\n"));
return;
}
const { cmd, args } = termImage.openerFor(process.platform, path.join(getWorkspaceDir(), rest[0]));
prog(dim(` 看图:${[cmd, ...args].filter(Boolean).map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}\n`));
}
// ---------- 执行一轮任务(Ctrl+C 停止当前任务而不是直接退出) ----------
/** 任务跑着的时候 = 停它的那个函数,空闲时 = null。交互模式的 Ctrl+C 从这儿调进去 */
let stopCurrent = null;
/**
* 交互模式按 Esc:只停这一趟,按几下都不会变成「直接退出」。
* 不能拿 stopCurrent 顶:那是 Ctrl+C 的把手,第二下就是硬退——Esc Esc 连按一下子就把整个程序关了
*/
let stopSoft = null;
/** 最近一趟的现场(产出、报错)。workflow 一步跑完要从这儿取,runOnce 只回一个 ok/error @type {any} */
let lastRun = null;
/** 终端里打的插话,下一步交给 agent。跟网页/手机上补的那句合并成一份 */
const termInterject = [];
/**
* agent 问一句话时,正等着回答的那个人。
*
* 之前这里是空的——cli.js 不传 askUser,于是 agent.js 认定「当前是无人值守运行,没人在线回答」,
* 让模型自己猜。最近在场的那个人反倒成了唯一问不到的人:网页上点一下就过的岔路
* (报告交 Word 还是 PDF、封面走生图还是排版截图),在终端里全变成模型替你赌一把。
*
* 交互模式下答案从 inbox 那条口子进来(任务跑着的时候敲的字本来就走那儿),
* 单发模式下现开一个 readline。两条路都往这个槽里放同一样东西:一个「收到答案」的函数。
* @type {null | ((text: string|null) => void)}
*/
let pendingAsk = null;
/** 交互模式那个常驻 readline。单发模式下一直是 null——那边现开一个用完就关 @type {import("readline").Interface|null} */
let replRl = null;
/**
* 把提示符重新摆出来:打了一半的字留着,光标放到它们后面。
* 光写 rl.prompt() 的话 readline 把光标挪回行首——任务跑着时敲的字跟着提示符回来,
* 人接着打,新字全插到了前头
*/
const promptKeep = (rl) => { rl.cursor = rl.line.length; rl.prompt(true); };
/** 手机上先答了的时候,把终端这边那个还挂着的提示符撤掉 @type {null | (() => void)} */
let cancelAsk = null;
/** 交互模式装上:攒着没发的那几行(多行输入、粘到一半的)一并扔掉。别处答了题的时候用 */
let composerClear = () => {};
/** 此刻占着终端那个提示符的是哪道题。撤提示符之前得认一下,别把正等着的另一道题连坐撤掉 */
let askOwner = null;
/** 当前这趟活儿的现场:Ctrl+C 信号、交互还是单发。等回答的几处都要用 @type {null|{ctrl: AbortController, onSigint: () => void, interactive: boolean}} */
let askCtx = null;
/** 正在跑的那趟活儿的实时句柄(手机那一屏就是读它写的文件) @type {any} */
let liveNow = null;
/** 有人能回答吗:stdin 得是终端,且不是在给脚本喂 NDJSON。管道进来的内容早读完了,那头没人 */
const somebodyHome = () => !!process.stdin.isTTY && !opts.json;
/** 按键归谁:↑↓ 单子摆着的时候整场归它,REPL 的 readline 一个键都收不到 @type {null | ((ch: string, key: any) => void)} */
let keyGrab = null;
/** 交互模式那个按键拦截点装上了没有(readline 内部 _ttyWrite 拿不到就是没装) */
let replKeyHook = false;
// ---------- 手机/网页上答的那一句 ----------
/**
* 答案到了、但问它的那个人已经不等了 —— 先存着。
*
* 为什么要这个抽屉:读回答用的是游标(读过的不再读),一次读会把文件里所有新行都取走。
* 要是取回来的那条不是当前在等的这道题,直接丢掉就等于**这个答案永远消失**——
* 手机上明明点了,终端里一直干等到超时。宁可存着没人来领,也不能读走了又扔掉。
*/
const remoteInbox = new Map();
const remoteWaiters = new Map(); // id -> 收到答案时叫谁
let remotePoll = null;
function remoteDeliver(a) {
const id = String(a.id);
const fn = remoteWaiters.get(id);
if (fn) { remoteWaiters.delete(id); fn(a); return; }
if (remoteInbox.size > 32) remoteInbox.clear(); // 攒到这个数只可能是陈货,留着也没人来领
remoteInbox.set(id, a);
}
/**
* 等手机上给这条 id 的回答。
* @returns 撤销函数。最后一个等的人走了就把轮询停掉——空转着轮询一个没人等的文件没有意义
*/
function remoteWait(id, fn) {
const key = String(id);
const had = remoteInbox.get(key);
if (had) { remoteInbox.delete(key); fn(had); return () => {}; }
remoteWaiters.set(key, fn);
if (!remotePoll) {
remotePoll = setInterval(() => {
if (!liveNow || !liveNow.live) return;
for (const a of liveNow.answers()) remoteDeliver(a);
}, 600);
if (remotePoll.unref) remotePoll.unref();
}
return () => {
remoteWaiters.delete(key);
if (!remoteWaiters.size && remotePoll) { clearInterval(remotePoll); remotePoll = null; }
};
}
/**
* 终端和手机,谁先答算谁的。
*
* @param {string} id 这道题在实时目录里的编号
* @param {() => Promise<string|null>} readTerminal 终端那条路
* @param {(a: object) => string} mapRemote 手机上那条答案怎么变成终端里会敲的那半截
*/
function raceRemote(id, readTerminal, mapRemote) {
askOwner = String(id);
return new Promise((resolve) => {
let settled = false;
let off = null;
const done = (v) => {
if (settled) return;
settled = true;
if (off) off();
if (askOwner === String(id)) askOwner = null;
resolve(v);
};
off = remoteWait(id, (a) => {
const cancel = cancelAsk; // 先抓住:done 之后这个槽可能已经被下一道题占了
// 终端那个提示符还挂着一道已经有答案的题,不撤掉它会一直等到超时。
// 得先撤再印通知:单子是按「往上退几行」擦的,通知先印出来,擦掉的就是通知和单子下半截,上半截留在屏上。
// 撤掉会让终端那条路还回 null,但那个 then 要等下一拍,下面这个 done 先到,算手机的
if (cancel) { try { cancel(); } catch {} }
prog(yellow(`\n » 手机上答了\n`));
done(mapRemote(a));
});
readTerminal().then(done, () => done(null));
});
}
/**
* 在终端里读一行 —— 提问和审批共用这一条路。
*
* 没人坐在终端前(管道喂进来的、--json 给脚本读的)也照样进来:这儿不接键盘,
* 只负责把超时和 Ctrl+C 变成 null,答案由手机那条路给。以前这种情况直接当「无人值守」,
* 于是 `openworkbuddy < 任务.txt` 挂在后台时,agent 问的每一句都没人能答——手机在手上也没用。
*
* @returns {Promise<string|null>} null = 超时 / Ctrl+C / Ctrl+D / 被手机那边抢答后撤掉
*/
function termReadLine(promptText, timeoutMs) {
const ctx = askCtx;
const sig = ctx ? ctx.ctrl.signal : null;
return new Promise((done) => {
let settled = false;
const finish = (v) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (sig) sig.removeEventListener("abort", onAbort);
pendingAsk = null;
cancelAsk = null;
// 答完把提示符收回去:任务还在跑,这时候留着「答>」会跟着正文一起冲下来
if (ctx && ctx.interactive && replRl) replRl.setPrompt("");
done(v);
};
// 超时和 Ctrl+C 都还回 null:提问那边 agent 会带着「用户没回应」继续跑,
// 审批那边 security 自己按拒绝收场——人走开了不该等于任务作废
const timer = setTimeout(() => finish(null), Math.max(5000, Number(timeoutMs) || 300000));
if (timer.unref) timer.unref();
const onAbort = () => finish(null);
if (sig) sig.addEventListener("abort", onAbort, { once: true });
cancelAsk = () => {
// 别处(手机、网页上的审批卡)已经答了:终端里打了一半的那句是这道题的答案,不是下一条任务。
// 留在 rl.line 里的话,任务一跑完它就跟着提示符回来,人再敲一个字、回车,就当新任务发出去了
if (!settled && ctx && ctx.interactive && replRl && (replRl.line || typingNow())) {
replRl.line = "";
replRl.cursor = 0;
composerClear(); // 攒着的那几行也是这道题的答案,一样不能留到下一条任务里
prog(dim("\n (终端里打了一半的那句没发出去)\n"));
}
finish(null);
};
if (!somebodyHome()) return; // 这头没人,只等手机和超时
pendingAsk = finish;
if (ctx && ctx.interactive && replRl) { replRl.setPrompt(askPrompt(promptText)); promptKeep(replRl); return; }
// 单发模式没有常驻 readline,现开一个。它自己接管 stdin,用完就关
const one = readline.createInterface({ input: process.stdin, output: process.stderr, prompt: askPrompt(promptText) });
one.prompt();
cancelAsk = () => { try { one.close(); } catch {} };
// 顺序不能反:one.close() 会**同步**触发下面那个 close 处理器,
// 先关再 finish 的话,finish(null) 抢在 finish(t) 前面把 settled 占掉——
// 人明明答了,agent 收到的却是「没人回应」。实测就是这样错了一版
one.on("line", (t) => { finish(t); one.close(); });
one.on("close", () => finish(null)); // Ctrl+D:当没回答(已经答过的话 finish 自己会挡掉)
// readline 在 TTY 上会把 Ctrl+C 截成自己的事件,外面那个 process.on("SIGINT") 收不到。
// 不接这一下,等回答的时候按 Ctrl+C 就什么都不会发生
one.on("SIGINT", () => { try { one.close(); } catch {} if (ctx) ctx.onSigint(); });
if (sig) sig.addEventListener("abort", () => { try { one.close(); } catch {} }, { once: true });
});
}
let askSeq = 0;
const newId = (p) => `${p}_${Date.now()}_${++askSeq}`;
/** 能不能摆 ↑↓ 单子:两头都是终端,交互模式还得拦得住按键。不行就退回敲一行 */
function canPickByKey() {
if (!somebodyHome() || !process.stderr.isTTY) return false;
const ctx = askCtx;
if (ctx && ctx.interactive) return !!replRl && replKeyHook;
return typeof process.stdin.setRawMode === "function";
}
/**
* 在终端里摆一张 ↑↓ 挑的单子(画在 stderr),等一个选择。
* p.menu(sel) 给行,p.key(sel, key, ch, 摆出来多久) 说这个键干什么——都是 cli-approve / cli-ask 里的纯函数。
* @returns {Promise<string|null>} 选中第几条("1" 起);null = 超时 / Ctrl+C / Esc 跳过 / 被手机那边抢答后撤掉
*/
function termPick(p, timeoutMs) {
const ctx = askCtx;
const sig = ctx ? ctx.ctrl.signal : null;
return new Promise((done) => {
let settled = false, sel = 0, drawn = 0, unRaw = null;
const t0 = Date.now();
const erase = () => {
if (!drawn) return;
try {
readline.moveCursor(process.stderr, 0, -drawn);
readline.cursorTo(process.stderr, 0);
readline.clearScreenDown(process.stderr);
} catch {}
drawn = 0;
};
const draw = () => {
erase();
const ls = p.menu(sel);
try { process.stderr.write(ls.join("\n") + "\n"); drawn = ls.length; } catch { drawn = 0; }
};
// 终端每送来一块就记下整块。按一个键是一块一个键;输入法上屏、粘贴是一块好几个字,
// readline 会拆成一个个 keypress,光看 keypress 分不出来。得排在 readline 前面收,所以用 prepend
let chunk = "";
const onData = (d) => { chunk = String(d); };
process.stdin.prependListener("data", onData);
const finish = (v) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (sig) sig.removeEventListener("abort", onAbort);
keyGrab = null;
cancelAsk = null;
process.stdin.off("data", onData);
if (unRaw) unRaw();
erase(); // 单子收掉,结果由调用方印一行「✓ 批了这一次」
done(v);
};
const timer = setTimeout(() => finish(null), Math.max(5000, Number(timeoutMs) || 120000));
if (timer.unref) timer.unref();
const onAbort = () => finish(null);
if (sig) sig.addEventListener("abort", onAbort, { once: true });
cancelAsk = () => finish(null);
const onKey = (ch, key) => {
const r = p.key(sel, key || {}, ch, Date.now() - t0);
if (!r) return;
if (r.cancel) { finish(null); if (ctx) ctx.onSigint(); return; } // Ctrl+C 还是「停下这趟」,跟敲一行那条路一样
// 选一条得是单独按下的那一个键。「好的,顺便…」上屏、粘进来一段带 y 的字,都不算点头
if (("pick" in r || r.skip) && chunk && chunk !== ((key && key.sequence) || ch)) return;
if (r.skip) return finish(null); // 提问那张的 Esc:这题不答、交给它自己定,这趟活儿照跑
if ("pick" in r) return finish(String(r.pick + 1));
sel = r.sel;
draw();
};
draw();
if (ctx && ctx.interactive) { keyGrab = onKey; return; }
// 单发模式没有常驻 readline:自己开 raw 模式收键,用完原样还回去
const wasRaw = !!process.stdin.isRaw;
readline.emitKeypressEvents(process.stdin);
try { process.stdin.setRawMode(true); } catch {}
process.stdin.on("keypress", onKey);
process.stdin.resume();
unRaw = () => {
process.stdin.off("keypress", onKey);
try { process.stdin.setRawMode(wasRaw); } catch {}
process.stdin.pause();
};
});
}
/**
* agent 问一句:终端里摆出来,同时推到手机上。
*
* 两头哪头先答都算数。推到手机上这件事不是锦上添花——人起了个长任务就去开会了,
* 中途那道岔路要么等他回来(几十分钟白烧),要么模型替他赌一把。
*/
/** 单子摆着的时候面板不重画:人正对着它按键,底下不能有东西跳 */
async function askUserBoth(ask) {
wfp.hold++;
try { return await askUserBothIn(ask); } finally { wfp.hold--; }
}
async function askUserBothIn(ask) {
const id = newId("ask");
const timeoutMs = Math.max(30000, Number(ask && ask.timeoutMs) || 300000);
const live = liveNow;
if (live) {
live.pend({
id, type: "ask",
question: String((ask && ask.question) || ""),
options: cliAsk.normalize(ask && ask.options),
deadline: Date.now() + timeoutMs,
});
}
const fromPhone = (a) => (a.value == null ? "" : String(a.value));
try {
return await makeAskUser(
(promptText, ms) => raceRemote(id, () => termReadLine(promptText, ms), fromPhone),
// ↑↓ 单子那条路两头各贴个标签:手机上打了个「3」和终端上选了第 3 条,光看字是一样的
canPickByKey() ? (p, ms) => raceRemote(id, () => termPick(p, ms).then((v) => (v == null ? null : { key: v })), (a) => ({ text: fromPhone(a) })) : undefined
)(ask);
} finally {
if (live) live.unpend(id);
}
}
/**
* 一条危险操作求批准:终端里摆出来,同时推到手机上。
*
* 不接这个钩子的话,命令行里的审批长这样:卡住两分钟,然后「用户未批准」——
* 而人从头到尾没被问过。超时对审批来说等于拒绝,所以「没人看见」和「看见了不同意」
* 在日志里长得一模一样,这是最糟的一种沉默。
*/
async function handleApproval(entry) {
wfp.hold++;
try { return await handleApprovalIn(entry); } finally { wfp.hold--; }
}
async function handleApprovalIn(entry) {
const live = liveNow;
// 终端前没人、也没说要等手机(--ask-remote):跟提问同一个规矩,当场拒掉。
// 以前这儿照样摆卡片干等两分钟——cron、管道里跑的活儿每碰一条要批的就白卡 120 秒,最后照样是拒
if (!somebodyHome() && !(opts.askRemote && live && live.live)) {
const r = security.resolveApproval(entry.id, false, "once");
if (!r || !r.ok) return;
const flag = security.allowFlagArg(entry.ruleKey);
const what = [entry.rule, entry.seg].filter(Boolean).join(":");
// 不走 prog:-q、--json 下这句也得有人看见——活儿少干了一步,不能一声不吭
inkRaw(yellow(` ✗ 这一步要人批准,终端前没人,直接拒了(${what.slice(0, 120)})\n`) +
dim(` ${flag ? `预先放行这类:--allow ${flag};` : ""}从手机上批:加 --ask-remote\n`));
return;
}
const deadline = Number(entry.deadline) || Date.now() + 120000;
const timeoutMs = Math.max(5000, deadline - Date.now());
if (live) live.pend(cliApprove.card(entry, deadline));
const fromPhone = (a) => (a.allow ? (a.scope === "session" || a.scope === "always" ? "2" : "1") : "3");
let v = null;
try {
v = await cliApprove.run(entry, {
write: inkRaw,
readLine: (promptText, ms) => raceRemote(entry.id, () => termReadLine(promptText, ms), fromPhone),
pick: canPickByKey() ? (p, ms) => raceRemote(entry.id, () => termPick(p, ms), fromPhone) : undefined,
timeoutMs,
width: (process.stderr.columns || 80) - 2,
paint: (x, k) => ({
warn: (y) => bold(yellow(y)), n: (y) => (ttyErr ? `\x1b[36m${y}\x1b[0m` : y),
label: bold, detail: dim, hint: dim, code: (y) => (ttyErr ? `\x1b[35m${y}\x1b[0m` : y),
add: green, del: red, // 改文件的 diff:加的绿、删的红
}[k] || ((y) => y))(x),
});
} finally {
if (live) live.unpend(entry.id);
}
if (!v) return; // 超时 / Ctrl+C:不去 resolve,让 security 按它自己那套(拒绝)收场
const r = security.resolveApproval(entry.id, v.allow, v.scope);
if (!r || !r.ok) return; // 手机上已经点过了,或者已经超时——这是正常的竞态,不用报错
if (!v.allow) prog(yellow(" ✗ 没批准,它会换个办法或者告诉你卡在哪\n"));
else if (v.scope === "session") prog(green(" ✓ 批了;本次运行期间同类不再问\n"));
else prog(green(" ✓ 批了这一次\n"));
}
/**
* 把一次提问摆到终端上,等一个答案。
*
* 问题走 **stderr**:`openworkbuddy "…" > 报告.md` 的时候答案在文件里,问题得还在人眼前。
* 也不受 --quiet 管——把一道正在等回答的选择题静音,换来的不是清净是卡死。
*
* @param {(prompt: string) => Promise<string|null>} readLine 怎么读这一行(两种模式各给各的)
* @param {Function} [pick] 摆 ↑↓ 单子挑(终端认按键时才有);没有就敲一行
* @returns {(a: {question: string, options: any[], timeoutMs: number}) => Promise<string|null>}
*/
/** 等回答时的提示符。跟平时那个 `openworkbuddy>` 换个颜色和字,一眼看出来现在是它在等你,不是你在等它 */
const askPrompt = (t) => (ttyErr ? `\x1b[33m${t}\x1b[0m` : t);
function makeAskUser(readLine, pick) {
const paint = {
q: (x) => bold(yellow(x)),
n: (x) => (ttyErr ? `\x1b[36m${x}\x1b[0m` : x),
label: bold, detail: dim, hint: dim, warn: yellow,
};
// 「同时摆到手机上」是 askUserBoth 的活儿,不在这儿重做一遍:
// 两处都 pend 的话,同一道题会在手机上并排出现两张卡、各带一个 id,
// 而 agent 只认 askUserBoth 那个 id——点另一张的人会发现点了没反应。
return async (ask) => cliAsk.run(ask, {
write: inkRaw,
readLine: (promptText, deadline) => readLine(promptText, deadline),
pick,
width: (process.stderr.columns || 80) - 2,
rows: process.stderr.rows,
paint: (x, k) => (paint[k] || ((y) => y))(x),
});
}
/**
* 撞车才隔离:别的任务(另一个终端、或者网页那边)此刻正在改同一个 git 仓库,
* 这一趟就去自己的 worktree 里改,改完把分支名和合回去的命令交出来。见 worktree.js 开头。
*
* 为什么包一层而不是写进 runOnce 里:交互模式下 runOnce 是在 REPL 那条异步链上被 await 的,
* 在里头 enterWorkspace 会把工作目录**留给 REPL**——跑完一趟之后 /cwd 显示的还是那个分身目录,
* 用户从此在一个他没听说过的地方干活。withWorkspace 是有边界的,出了这个函数自动还原。
*/
async function runOnce(runtime, text, mode, interactive, shown) {
const openedLocally = naturalOpen(shown || text);
if (openedLocally) return openedLocally;
const wt = require("./src/agent/worktree");
let opened = null;
let STORE = "";
try {
const busy = cliLive.list({ prune: false })
.filter((r) => r.live && r.cwd && r.id !== sessionId)
.map((r) => ({ session: r.id, dir: r.cwd }));
const p = wt.plan(getWorkspaceDir(), { session: sessionId, busy });
if (p.need) {
STORE = wt.defaultStore(dataPath("data", "worktrees")); // 撞上了才问:挪不挪要多问一趟 git
const o = wt.open(STORE, { repo: p.repo, session: sessionId, legacy: [dataPath("data", "worktrees")] });
if (o && o.dir) opened = o;
else if (o && o.error) process.stderr.write(dim("(分身没开成,照旧在原工作区跑:" + o.error + ")\n"));
}
} catch {} // 隔离判断出错绝不能让任务起不来:退回老样子就是这个功能上线前的样子
if (!opened) return runOnceIn(runtime, text, mode, interactive, shown);
process.stderr.write(yellow(wt.hint(opened)) + "\n");
try {
return await require("./src/agent/tools").withWorkspace(opened.dir, () => runOnceIn(runtime, text, mode, interactive, shown));
} finally {
try {
const rel = wt.release(STORE, opened.dir, { title: (shown || text).slice(0, 40) });
process.stderr.write(dim(rel && rel.removed ? "(这趟没留下改动,分身已经收掉)\n" : wt.hint(rel) + "\n"));
} catch {}
}
}
// ---------- 成果按对话分文件夹(口径跟网页端是同一份,见 src/util/task-dirs.js) ----------
// 一条会话一格「任务_月日_标题」:应用自己建的根一律这样,设置里挑的普通文件夹默认也这样。
// 以前命令行一律摊在根上:终端里跑十趟,十份「报告.html」后写的把先写的盖了,网页上「本对话」也一份都认不出来。
// -C / /cd 点名进去的、看着像代码仓库的、分身目录照旧就地读写——那里要改的东西本来就在根上
const taskDirs = require("./src/util/task-dirs");
// 刚往根上放、还没发出去的东西:拷进来的附件、粘贴图、存成文件的长文本。
// 跟网页端先传上来的附件一样,发出去那一刻搬进这条会话的那格
const strayUploads = new Set();
/**
* 这一趟按写代码还是按办公跑。--profile 点名的算数;没点名看目录:代码仓库或有源码的文件夹按写代码。
* 在 ~/Documents 里让它写周报还是办公那一档,成果文件、配图、交付规矩都在
*/
function runProfile() {
if (opts.profile === "code" || opts.profile === "office") return opts.profile;
const root = getWorkspaceDir();
return taskDirs.looksLikeRepo(root) || taskDirs.hasSourceFiles(root) ? "code" : "office";
}
/** 这一趟的成果文件夹(相对工作目录);摊在根上的那种根返回 null,就地读写 */
function chatDirHere(asked) {
const root = getWorkspaceDir();
const anchors = {
workspace: dataPath("workspace"), projects: dataPath("projects"), tenants: require("./src/domains/account/org").tenantsDir(),
homes: require("./src/domains/account/spaces").homes(),
// 文件夹里有源码就地读写。以前只认 package.json、.git 这些:一个放着几个脚本的文件夹会套一层「任务_」,
// read_file 能回落到根上读到文件,run_shell 却跑在那一格里,`node test.js` 直接找不到模块
layouts: config.folder_layouts || {}, inPlace: (!!inPlaceRoot && taskDirs.samePlace(inPlaceRoot, root)) || taskDirs.hasSourceFiles(root),
};
if (!taskDirs.perChatRoot(root, anchors)) return null;
if (!taskDirs.useSessDirAt(sess, root, dataPath("workspace"))) taskDirs.newSessDir(sess, root, dataPath("workspace"), asked);
else try { fs.mkdirSync(path.join(root, sess.dir), { recursive: true }); } catch {}
// 分文件夹以前摊在根上的老产出:整篇重写时写回那份,不在新格里另起第二份(跟网页那头同一套,见 src/util/task-dirs.js flatOutputs)
try {
const tools = require("./src/agent/tools");
tools.ownRootFiles(sessionId, taskDirs.flatOutputs(sess, root, dataPath("workspace"), tools.workspaceKeyOf(root)).map((n) => path.join(root, n)));
} catch {}
return sess.dir;
}
/** 这句话带着的、刚放到根上的附件搬进 dir。名字不用改:模型的相对路径本来就从这格起算 */
function moveStrays(text, dir) {
const root = getWorkspaceDir();
for (const n of [...strayUploads]) {
if (!String(text).includes(n)) continue; // 攒着没发的(/drop 了的也算)留给下一句
strayUploads.delete(n);
if (!dir) continue;
try {
const from = path.join(root, n), to = path.join(root, dir, n);
// 只搬确实是个文件、目标位置还空着的;搬不动就留在根上,读的时候根下兜得到
if (fs.existsSync(from) && fs.statSync(from).isFile() && !fs.existsSync(to)) fs.renameSync(from, to);
} catch {}
}
}
/** 一趟跑完什么都没留下就撤掉那格(会话里也清掉,下一趟按模型起好的标题重新起名),跟网页端回合收尾同一个口子 */
function settleChatDir(dir) {
if (!dir || sess.dir !== dir) return;
const root = getWorkspaceDir();
try { require("./src/platform/deps-guard").dropLoneFence(path.join(root, dir)); } catch {}
if (taskDirs.dropIfEmpty(root, dir)) sess.dir = null;
}
/**
* @param shown 人自己打的那句话。text 前面拼了给模型看的东西(!命令 的输出)时才传,
* 会话标题、网页上那条对话显示的都是它,不是那一大段
* @returns {"ok"|"error"|"aborted"} 给退出码用
*/
async function runOnceIn(runtime, text, mode, interactive, shown) {
try { Object.assign(sess, require("./src/cli/project-owner").cliProject({ explicit: opts.project, cwd: wantWorkspace, projects: config.projects || [], existing: sess })); }
catch (error) { process.stderr.write(red(error.message + "\n")); return "error"; }
if (!shown || shown === text) shown = "";
const asked = shown || text;
// 积分闸门:默认是关的(本地个人用不限额),开了才拦。CLI 消耗记在管理员(首个注册用户)名下
const owner = account.defaultUser();
if (owner && account.creditsEnabled() && owner.credits <= 0) {
process.stderr.write(red(`积分不足(${owner.username} 余额 0):去 Web 端「账号 · 用量」里充值,或者把「积分限额」关掉。\n`));
return "error";
}
if (reloadSessIfChanged()) prog(dim("(这条会话在网页 / 手机 / 另一个终端上有新内容,已经接上)\n"));
// 反过来那头:网页端正跑着这条的话,它跑完存盘会把这边这一轮盖掉。服务端开跑时记在 running.json 里;
// 服务端要是崩了没清,这条记录会过期,所以只提醒不拦
{
const busy = store.readJson(dataPath("data", "running.json"), []);
if (Array.isArray(busy) && busy.includes(sessionId)) {
process.stderr.write(yellow("网页端记着这条会话有任务在跑:两头同时跑,后存完的那头会盖掉另一头这一轮。等那边跑完再发更稳妥\n"));
}
}
sess.history.push({ role: "user", content: text });
const greeting = /^(?:你好|您好|嗨|哈[喽啰]|hello|hi|hey)[!!。.??\s]*$/i.test(asked.trim());
if (!sess.title && !greeting) sess.title = asked.slice(0, 24);
// 在终端里起的活儿归「工程」线。网页/手机上切到那个标签就能看见这条会话——
// 这是两条线里唯一一条服务端替人填的:它确实是从命令行进来的,不是猜的。
sess.lane = "cli";
const hadDir = sess.dir;
const runDir = !sess.dir && greeting ? null : chatDirHere(asked);
moveStrays(text, runDir);
if (opts.verbose && runDir && runDir !== hadDir) prog(dim(`(这条会话的成果放在 ${runDir}/)\n`));
const state = { streamed: false, usage: null, files: null, changed: [], finalParts: [], error: null, md: newMdRenderer(), pendingText: "", details: [], detailBytes: 0, toolCount: 0, failedTools: 0, interactive: !!interactive, session: sessionId };
lastRun = state; // workflow 那头要知道这一步写过什么、错在哪
// 挂到实时目录上:网页端的「工程」标签就是靠它知道这台机器的终端里此刻在干什么
const live = cliLive.announce({
id: sessionId, title: sess.title || asked.slice(0, 60), cwd: getWorkspaceDir(),
mode, user: owner ? owner.username : "",
});
state.live = live;
live.event({ type: "status", text: `终端里起了一趟活儿:${asked.slice(0, 60)}` });
// 心跳:模型想得久的时候一个事件都不出,光靠事件盖时间戳会被判成「这进程死了」
const beatTimer = live.live ? setInterval(() => live.beat(), cliLive.BEAT_MS) : null;
if (beatTimer && beatTimer.unref) beatTimer.unref();
// 那一行的走字:stderr 是终端才有;-q / --json 下没有进度行可挂
tick.tokens = 0;
tick.stop = interactive && replKeyHook ? "Esc" : "Ctrl+C";
// 转圈要一秒八九帧;不转的行在 tickDraw 里按秒数没跳就跳过,不会跟着多写终端
const tickTimer = process.stderr.isTTY && !opts.quiet && !opts.json ? setInterval(tickDraw, replKit.SPIN_MS) : null;
if (tickTimer && tickTimer.unref) tickTimer.unref();
const ctrl = new AbortController();
let aborted = false;
// 硬退出:第二次 Ctrl+C、关终端窗口(SIGHUP)、被 kill(SIGTERM)。进程下一刻就没了,等不到各自的 close 回调。
// 引擎和 run_shell 都在自己的进程组里,不先收掉就成了孤儿,接着改文件、时限也没人管;
// 这一轮也得落盘,不然用户那句话和这一轮说到一半的话全没了。
// 剩下的(后台任务、浏览器、引擎的临时配置目录)挂在 exit 钩子上,process.exit 会走到
let exiting = false;
const hardExit = (code) => {
if (exiting) return;
exiting = true;
try { ctrl.abort(); } catch {} // run_shell / run_node 的进程组当场收(bindStop),引擎也是(jsonl 挂了监听)
try { require("./src/engines/jsonl").killAll("SIGTERM"); } catch {}
try {
const said = state.finalParts.join("");
sess.transcript.push({ type: "user", text, ...(shown ? { shown } : {}), mode, at: new Date().toISOString() });
sess.transcript.push({ type: "assistant", events: [{ type: "text", delta: (said ? said + "\n\n" : "") + "(任务被中断,进程已退出)" }], at: new Date().toISOString() });
saveSess();
} catch {}
try { live.finish({ error: "进程被中断", title: sess.title }); } catch {}
try { mcpManager.stopAll(); } catch {}
process.exit(code);
};
const onHup = () => hardExit(129);
const onTerm = () => hardExit(143);
const onSigint = () => {
if (aborted) {
// 第二次:不等了。收尾还是要做——MCP 那几个子进程是 spawn 出来的,
// 不收就留在系统里,下次启动还会再起一批
inkRaw(yellow("\n(不等了,直接退出)\n"));
hardExit(130);
return;
}
aborted = true;
prog(yellow("\n(收到 Ctrl+C,正在停止任务…再按一次直接退出)\n"));
ctrl.abort();
};
// 单发模式走信号;交互模式下 readline 在终端里把 Ctrl+C 自己截住了,进程根本收不到,
// 所以那边改从 stopCurrent 这个把手调进来——改写前那条路在交互模式下从来没通过
process.on("SIGINT", onSigint);
process.on("SIGHUP", onHup);
process.on("SIGTERM", onTerm);
stopCurrent = onSigint;
stopSoft = () => {
if (aborted) return; // 已经在停了:再按 Esc 什么也不多做
aborted = true;
prog(yellow("\n(按了 Esc,这一趟正在停下…卡住了按 Ctrl+C 强退)\n"));
ctrl.abort();
};
// 等回答的那几处(提问、审批)要用到这一趟的 Ctrl+C 信号和实时句柄
askCtx = { ctrl, onSigint, interactive: !!interactive };
liveNow = live;
// 危险操作求批准时,把卡片同时摆到终端和手机上。不订这个钩子的话,
// 命令行里的审批就是「卡住两分钟然后被拒」,人从头到尾没被问过
const offApproval = security.watchApprovals((ev) => {
if (ev.type === "open") { handleApproval(ev.entry).catch(() => {}); return; }
// 别处点过了 / 超时了:把手机上那张卡撤下来,别留着一个点了没反应的按钮
if (ev.type === "close") {
live.unpend(ev.id);
// 只撤这条自己的提示符:认 id 才行,不然会把同时挂着的另一道题一起撤了
if (askOwner === String(ev.id) && cancelAsk) { try { cancelAsk(); } catch {} }
}
});
let finalText = "";
// Goal 模式:第一次用这句话建目标(拆成验收标准),已有进行中的目标就直接接着冲。
// 目标卡存在会话里,跟网页端是同一份——在手机上起的头,回到终端 `openworkbuddy -s <会话>` 能接着干。
if (modes.isGoalMode(mode)) {
if (!sess.goal || sess.goal.status !== "active") {
prog(dim("拆验收标准…\n"));
try { sess.goal = await goalKit.start(goalThink, text); }
catch (e) { prog(yellow(`拆验收标准失败:${e.message}\n`)); }
}
if (sess.goal && sess.goal.status === "active") sess.goal.paused = ""; // 又开跑了,把「已暂停」摘掉
if (sess.goal) { printGoalCard(sess.goal); live.event({ type: "goal", goal: sess.goal }); }
}
try {
// 外层:目标轮。普通模式只走一轮;goal 模式没达标自动再跑,最多 goalKit.MAX_ROUNDS 轮
let roundStopped = null;
for (let goalRound = 0; ; goalRound++) {
roundStopped = null;
const r = await runtime.runTask({
history: sess.history,
sessionId, // 文件检查点记在这个会话名下,/rewind 才知道哪些是这趟活儿改的
baseDir: runDir || undefined, // 相对路径读写、脚本 cwd、产物落点全在这条会话的那格
// 工作目录(往上到 git 根)的 AGENTS.md / CLAUDE.md 先带上——/init 写的就是它,以前命令行一个字都不读。
// 进行中的目标注进任务上下文:agent 每一轮都对着验收标准干活,不跑偏
projectContext: [projectMemo.memoContext(getWorkspaceDir()), goalKit.contextFor(sess.goal), cliSurface.promptBlock(), opts.appendSystem].filter(Boolean).join("\n\n") || undefined,
maxSteps: opts.maxSteps || undefined, // --max-steps:只管这一次
profile: runProfile(), // 在项目里写代码走精简那一档,见 src/agent/code-profile.js
emit: makeEmit(state),
mode: modes.agentMode(mode), // goal 在外面那层循环里,agent 只认识 ask/plan/craft
user: owner ? owner.username : undefined, // 记忆按人取,命令行走管理员这本账
stopSignal: ctrl.signal,
// 底层 CLI 引擎的线程 id:跟会话存在一起,所以在桌面开的头能在这儿接着跑,反过来也一样
engineSession: lanes.engineSessionFor(sess, cfgEngine()),
// 能问就问。终端前有人当然能问;人起了长任务就去开会了也照样能问——askUserBoth
// 会把这一句同时推到手机上,两头哪头先答都算数。
// ⚠️ 但「播出去了」不等于「有人在看」。live.live 只说明这趟活儿在盘上登记了,
// 没人扫过码、手机根本没打开的时候它一样是 true。管道喂进来的 `openworkbuddy < 任务.txt`、
// 挂在 crontab 里的 --json,那头确实没人:按「有人」算的话,模型每问一句都要
// 干等满 5 分钟超时才肯往下走,一个夜里跑的批处理能就此堵成早上还没跑完。
// 所以默认按 TTY 判;确实打算「人不在电脑前、答案从手机上给」的,显式写 --ask-remote。
askUser: (somebodyHome() || (opts.askRemote && live.live)) ? askUserBoth : undefined,
// 网页/手机上补的那句话,在两步之间读走。终端这边也回显一下——
// 不然坐在电脑前的人只会看见 agent 突然改了主意,不知道是有人从手机上插了一句
getInterject: () => {
const more = live.interjections();
// 坐在电脑前的人也能插话:任务跑着的时候在终端里打的字排在 termInterject 里,
// 跟手机上补的那句走同一个口子
if (termInterject.length) more.push(...termInterject.splice(0));
if (more.length) prog(yellow(`\n » 收到插话:${more.join(" / ").slice(0, 120)}\n`));
return more;
},
});
if (r && r.sessionId) lanes.rememberEngineSession(sess, r.engine || cfgEngine(), r.sessionId);
if (r && r.finalText) finalText = r.finalText;
if (r && r.stopped) roundStopped = r.stopped;
// 没有进行中的目标 / 用户按了 Ctrl+C → 不验收不加轮
if (!sess.goal || sess.goal.status !== "active" || ctrl.signal.aborted) break;
sess.goal.note = "";
sess.goal.paused = "";
prog(dim("\n对着验收标准验收…\n"));
// 证据用「这一轮真写过的文件」,不是整个工作目录:命令行的工作目录常常是个大仓库,
// 整个扫进去等于拿别人早就写好的文件给这一轮打勾
await goalKit.verify(goalThink, sess, finalText, (w) => { sess.goal.note = w; }, state.changed);
sess.goal.round = (sess.goal.round || 0) + 1;
printGoalCard(sess.goal);
live.event({ type: "goal", goal: sess.goal });
saveSess();
if (sess.goal.status === "done") break;
if (!modes.isGoalMode(mode)) break;
// 自动补跑用完了。别悄悄不跑——写清楚为什么停、还差几项,要不要接着烧钱交回给用户
if (goalRound + 1 >= goalKit.MAX_ROUNDS) {
sess.goal.paused = `自动补跑已用满 ${goalKit.MAX_ROUNDS} 轮,还差 ${goalKit.progress(sess.goal).unmet} 项没达成`;
prog(yellow(` 暂停:${sess.goal.paused};想接着冲就再说一句「继续」\n`));
live.event({ type: "goal", goal: sess.goal });
saveSess();
break;
}
// 单次步数上限是 Goal 的分段边界:核对完进度后,只带未达成项进入下一轮。
// 超时、手动停止等其它硬终止继续暂停,避免盲目重跑。
const continueStepLimitedGoal = goalKit.canContinueAfterStepLimit({
goalMode: modes.isGoalMode(mode), active: sess.goal.status === "active",
stopReason: roundStopped, aborted: ctrl.signal.aborted,
});
if (roundStopped && !continueStepLimitedGoal) {
sess.goal.paused = `这轮任务被强制收尾(${roundStopped}),暂停自动补跑`;
prog(yellow(` 暂停:${sess.goal.paused};解决后说一句「继续」接着冲\n`));
live.event({ type: "goal", goal: sess.goal });
saveSess();
break;
}
const fb = goalKit.feedbackFor(sess.goal) + (continueStepLimitedGoal
? `\n\n【目标续跑】上一轮用完了单次步数预算;已核对完成项,只继续补上面列出的未达标项。`
: "");
sess.history.push({ role: "user", content: fb });
prog(dim(`\n » 第 ${sess.goal.round + 1} 轮:只补没达成的那几项\n`));
}
} catch (e) {
state.error = e.message;
// workflow 面板那一行会画 ✗ 和原因,收尾再完整说一遍;这儿再印就是一件事说三遍
if (!wfp.st) inkRaw(red(`\n出错了:${e.message}\n`));
}
// 跟网页对话同一个收尾:这一轮开的标签页、没说 keep 的后台命令、`&` 甩出去的进程组一起收(tools.releaseRun)
try { await require("./src/agent/tools").releaseRun(sessionId); } catch {}
process.removeListener("SIGINT", onSigint);
process.removeListener("SIGHUP", onHup);
process.removeListener("SIGTERM", onTerm);
stopCurrent = null;
stopSoft = null;
offApproval();
askCtx = null;
liveNow = null;
if (beatTimer) clearInterval(beatTimer);
if (tickTimer) clearInterval(tickTimer);
tickSettle();
tick.render = null;
live.finish({ error: state.error, title: sess.title });
settleChatDir(runDir);
// --json 下正文没走 stdout,最终文本从事件里攒回来,落盘的内容两种模式必须一样
if (!finalText && state.finalParts.length) finalText = state.finalParts.join("");
// 落盘:Web 端打开该会话也能回放(最终文本 + 用量)
sess.transcript.push({ type: "user", text, ...(shown ? { shown } : {}), mode, at: new Date().toISOString() });
const events = [];
if (finalText) events.push({ type: "text", delta: finalText });
if (state.usage) events.push(state.usage);
sess.transcript.push({ type: "assistant", events, at: new Date().toISOString() });
sess.cliDetails = state.details;
saveSess();
// 记账:与 Web 端同一本账(data/usage/<年-月>.jsonl,按月分片、一笔一行只追加;见 usage-store.js)
if (owner && state.usage && state.usage.calls > 0) {
const spent = account.chargeRun(owner, { ...state.usage, source: "cli", sessionId });
state.credits = { spent, balance: owner.credits };
}
printSummary(state);
if (cliSurface.currentFiles(state.changed, state.files).length) {
sess.cliFiles = lastFiles;
sess.cliFilesRoot = getWorkspaceDir();
saveSess();
if (!state.error && !aborted && !opts.json && cliSurface.openAfterTask(asked)) openCliFile("", true);
}
// 图在最后:用量、积分、产出清单都打完了再贴,顺序反过来的话图会把那几行顶到屏幕外面
hintOutputs(state.changed, await drawOutputs(state.changed), !!interactive);
return aborted ? "aborted" : state.error ? "error" : "ok";
}
/**
* 管道进来的内容。没接管道(stdin 是终端)就返回空串,绝不阻塞等输入。
* 命令行里已经给了任务时,stdin 多半是调用方顺手开着没关的管道(编排器、ssh、agent 的 Bash 工具默认都这样):
* 一直不来字也一直不关。以前就一直等下去,一个字不打。现在 3 秒没来第一个字就不等了;来了字就照旧读到头
*/
const STDIN_FIRST_BYTE_MS = 3000;
function readStdin(hasTask) {
if (process.stdin.isTTY) return Promise.resolve("");
return new Promise((resolve) => {
let buf = "", got = false, done = false;
const onData = (d) => { got = true; buf += d; };
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
process.stdin.removeListener("data", onData);
resolve(buf);
};
const timer = hasTask ? setTimeout(() => {
if (got) return;
process.stdin.pause(); // 不再读,也不拖着进程不让退
prog(dim("· 标准输入开着但 3 秒没有内容,不等了\n"));
finish();
}, STDIN_FIRST_BYTE_MS) : null;
process.stdin.setEncoding("utf8");
process.stdin.on("data", onData);
process.stdin.on("end", finish);
process.stdin.on("error", finish);
});
}
const STDIN_MAX = 200000; // 再多就不是「材料」是「数据集」了,该让 agent 自己去读文件
// ---------- 带进来的文件 ----------
// (原话里用的是改名前的旧命令名)。
// 终端里把文件带进来有三条路,三条都得认:从访达把文件拖进窗口(粘出来的是反斜杠
// 转义过的路径)、用「拷贝路径」粘进来(空格没转义,只有整行当一条路径才认得出)、
// 或者敲 `@` 让它列工作目录里有什么。再加一个 /paste 直接吃剪贴板里的截图。
// 要紧的是**文件不进对话历史**:跟网页端走同一套约定——先把文件搬进工作目录,正文后面
// 挂一句「(已上传文件:…)」,让模型自己决定是 look_at_image 还是 read_file。图片一旦
// 进了历史,往后每一步都要重发一遍,纯文本模型还会当场 400;而会话是存盘的,那就等于
// 把这个会话永久弄坏了。所以带文件的正确做法只有一种:给个名字,让它自己去看。
let visionWarned = false;
/** 把认出来的文件搬进工作目录,返回给模型看的名字;搬不动的当场说清楚为什么 */
function bringIn(files) {
if (!files || !files.length) return [];
const r = attach.collect(files, { workspaceDir: getWorkspaceDir() });
for (const n of r.copied) strayUploads.add(n); // 本来就在工作目录里的不动,只搬刚拷进来的
for (const s of r.skipped) prog(yellow(` !${s.ref} 没带上:${s.why}\n`));
if (r.names.length && attach.anyImage(r.names) && !visionWarned) {
visionWarned = true;
const v = (config.media || {}).vision || {};
// 不吭声地把图交出去,回头模型说「我看不了图」,人只会以为是自己路径写错了
if (!(String(v.base_url || "").trim() && String(v.model || "").trim())) {
prog(dim(` 没单独配视觉模型,图交给主模型(${llm.model})试着看;它要是纯文本的就看不了,设置 → 模型 → 视觉模型 配一个就行\n`));
}
}
return r.names;
}
/** 一行话里哪几个词是文件。相对路径先按工作目录找,再按人现在所在的目录找 */
function splitFiles(text) {
return attach.parseLine(text, {
home: os.homedir(),
roots: [getWorkspaceDir(), process.cwd()],
exists: (p) => { try { return fs.existsSync(p); } catch { return false; } },
});
}
// ---------- 主流程 ----------
(async () => {
// ---------- openworkbuddy engines:看本机能拿什么当底层,以及一键切过去 ----------
if (sub === "engines") {
const engines = require("./src/engines");
const want = words[0] === "use" ? String(words[1] || "").trim() : "";
if (words[0] === "use") {
if (engines.get(want) === undefined) {
process.stderr.write(red(`没有这个引擎:${want}。可选:${engines.list().map((b) => b.id).join(" / ")}\n`));
process.exit(1);
}
if (want !== "builtin") {
// 切过去之前先确认它真的装了。让用户以为切成功、下一次跑任务才报错,是最难查的那种坑
const found = (await engines.detectAll((config.agent || {}).engine_options || {})).find((e) => e.id === want);
if (!found || !found.installed) {
const b = engines.get(want);
process.stderr.write(red(`${b.label} 没装或跑不起来,没有切换。\n`) + dim(`装法:${b.install}\n`));
process.exit(1);
}
}
// 临存盘前再读一遍。上面那次读到这儿中间隔着一次引擎探测,能跑好几秒——
// 这期间桌面端那个进程很可能刚存过一次配置。拿几秒前的整份内存盖回去,人家刚改的就没了。
const latest = store.readJson(CONFIG_PATH, config) || config;
latest.agent = latest.agent || {};
latest.agent.engine = want;
store.writeJsonAtomic(CONFIG_PATH, latest, { pretty: true });
process.stdout.write(green(`底层引擎已切到「${(engines.get(want) || engines.BUILTIN).label}」\n`));
process.exit(0);
}
const cur = (config.agent || {}).engine || "builtin";
const found = await engines.detectAll((config.agent || {}).engine_options || {});
const rows = [{ ...engines.BUILTIN, installed: true, version: "" }, ...found];
// --json:一行一个 JSON,跟任务的事件流一个格式。表格是给人看的,对齐空格、颜色脚本没法拆
if (opts.json) {
for (const e of rows) process.stdout.write(JSON.stringify({ id: e.id, label: e.label, installed: !!e.installed, version: e.version || "", current: e.id === cur }) + "\n");
process.exit(0);
}
for (const e of rows) {
const mark = e.id === cur ? green(" ●") : " ";
const state = e.id === "builtin" ? "" : e.installed ? green(`已装 ${e.version}`) : yellow("没装");
process.stdout.write(`${mark} ${e.id.padEnd(12)} ${e.label} ${state}\n`);
process.stdout.write(dim(` ${e.note}\n`));
if (!e.installed && e.install) process.stdout.write(dim(` 装法:${e.install}\n`));
}
process.stdout.write(dim("\n切换:openworkbuddy engines use <id>。选了本机 Claude Code / Codex,任务就跑在你已经付过钱的订阅上,不再消耗 API 额度。\n"));
process.exit(0);
}
if (opts.list) {
const rows = listCliSessions(opts.list);
if (opts.json) {
// 挑一条再 resume 的脚本要的是 id,不是去拆那张表
for (const r of rows) {
process.stdout.write(JSON.stringify({ id: r.id, mtime: new Date(r.mtime).toISOString(), title: r.title, turns: r.turns,
from: r.id.startsWith("cli_") ? "cli" : "desktop", engine: r.engine || null }) + "\n");
}
process.exit(0);
}
if (!rows.length) { process.stdout.write("(还没有任何会话)\n"); process.exit(0); }
for (const r of rows) {
// 本地时间。toISOString() 给的是 UTC,跟会话 id 里那串本地时间戳差一个时区,
// 同一个会话在 id 上写着 17:36、在列表里显示 09:36,照时间挑会挑错。
const d = new Date(r.mtime), q = (n) => String(n).padStart(2, "0");
const when = `${d.getFullYear()}-${q(d.getMonth() + 1)}-${q(d.getDate())} ${q(d.getHours())}:${q(d.getMinutes())}`;
process.stdout.write(`${r.id} ${when} ${r.from} ${String(r.turns).padStart(3)} 轮 ${r.title}${r.engine ? dim(" [" + r.engine + "]") : ""}\n`);
}
process.stdout.write(dim(`\n续接:openworkbuddy resume <id> "接着做…";不给 id 就接最近动过的那个(桌面开的也能接)\n`));
process.exit(0);
}
// 带进来的文件。这一步必须排在读管道**前面**:`cat 报错.log | openworkbuddy "这什么意思"` 里
// 提到的路径是材料不是附件,扫一遍会把人家日志里随口提到的文件都搬进工作目录
const namedFiles = [];
for (const ref of opts.files) {
const raw = attach.expandHome(attach.fromFileUrl(ref) || ref, os.homedir());
const tries = path.isAbsolute(raw) ? [raw] : [path.resolve(process.cwd(), raw), path.resolve(getWorkspaceDir(), raw)];
const hit = tries.find((x) => { try { return fs.existsSync(x); } catch { return false; } });
// -f 是人明说的,找不到就当场停。让模型对着一个不存在的文件名瞎猜,钱花了事没办
if (!hit) { process.stderr.write(red(`-f ${ref}:找不到这个文件\n`)); process.exit(2); }
namedFiles.push({ ref, path: hit });
}
// openworkbuddy workflow 流程.json:文件先读、先校验,写错的地方一次列全,一步都不跑。
// 放在 splitFiles 前面:文件名本身不是附件
let flow = null;
// -i 只有流程认:单发任务里写了它,多半是把命令敲错了,悄悄丢掉等于让人以为自己填过了
if (sub !== "workflow" && (opts.inputs || []).length) {
process.stderr.write(red(`-i 只配合 openworkbuddy workflow 用,这里用不上:${opts.inputs.join(" ")}\n`));
process.exit(2);
}
if (sub === "workflow") {
const wf = require("./src/cli/workflow");
const file = oneShot;
if (!file) { process.stderr.write(red("要给一个流程文件:openworkbuddy workflow 流程.json\n")); process.exit(2); }
const full = path.resolve(process.cwd(), file);
let text;
// 配方名当文件名用(openworkbuddy workflow promo-video)。当前目录真有这个文件时跑文件:
// 人明写的路径优先,配方名只是简写。配方模块懒加载,普通流程文件用不着它
let recipes = null, recipe = null;
if (!fs.existsSync(full)) {
try { recipes = require("./src/domains/content/recipes"); } catch { recipes = null; }
recipe = recipes ? recipes.get(file) : null;
if (recipe) text = JSON.stringify(recipes.workflowOf(recipe.id, { config, hasRenderer: false }));
}
if (text === undefined) {
try { text = fs.readFileSync(full, "utf8"); }
catch (e) {
process.stderr.write(red(`读不了 ${file}:${e.code || e.message}\n`));
if (e.code === "ENOENT" && recipes) process.stderr.write(dim(`内置配方可以直接写名字:${recipes.BUILTIN.map((x) => x.id).join(" / ")}\n`));
process.exit(2);
}
}
const what = recipe ? `配方 ${recipe.id}` : file;
const p = wf.parse(text);
if (p.error) { process.stderr.write(red(`${what} 有问题,一步都没跑:\n${p.error}\n`)); process.exit(2); }
// 配方里有的选项在命令行里用不了(截图要桌面版、生视频要先接模型):选中了就直说为什么,
// 不悄悄换成另一个——换了的话跑完一看封面不是自己要的,钱和时间都花了
const blocked = (values) => {
if (!recipe) return [];
const out = [];
for (const f of recipes.formFor(recipe.id, { config, hasRenderer: false }).fields || []) {
const v = values[f.name];
for (const x of Array.isArray(v) ? v : v ? [v] : []) {
const o = (f.options || []).find((q) => String(q.v) === String(x));
if (o && o.disabled) out.push(`${f.label}(${f.name}):「${o.l || x}」用不了,${o.reason || "这台机器上没配好"}`);
}
}
return out;
};
const given = wf.inputArgs(opts.inputs || []);
const iv = wf.resolveInputs(p.inputs, given.given);
const bad = given.errors.concat(iv.errors, blocked(iv.values));
if (bad.length) { process.stderr.write(red(`${what} 的 -i 有问题,一步都没跑:\n${bad.join("\n")}\n`)); process.exit(2); }
if (iv.missing.length) {
const miss = iv.missing.map((n) => p.inputs.find((x) => x.name === n));
const flags = miss.map((x) => `-i ${x.name}=…`).join(" ");
// 没人能答(管道、--json、脚本里跑):直接停,告诉人缺哪几个 -i。别拿空值硬跑——模型会自己编一个产品出来
if (!somebodyHome()) {
process.stderr.write(red(`缺 ${flags}(${miss.map((x) => x.label).join("、")}),一步都没跑\n`));
process.exit(2);
}
for (const inp of miss) {
const choices = inp.options ? `,${inp.type === "multi" ? "可以选几个:" : ""}${inp.options.join(" / ")}` : "";
for (let tries = 1; ; tries++) {
const ans = await termReadLine(`${inp.label}(${inp.name}${choices}):`, 600000);
if (ans === null) { process.stderr.write(red(`\n没填${inp.label},一步都没跑。下次可以直接写 -i ${inp.name}=…\n`)); process.exit(2); }
const one = wf.resolveInputs([inp], { [inp.name]: ans });
const why = one.errors.concat(blocked(one.values));
if (!why.length && !one.missing.length) { iv.values[inp.name] = one.values[inp.name]; break; }
if (tries >= 3) { process.stderr.write(red(`${inp.label}问了三次都没填上,一步都没跑\n`)); process.exit(2); }
prog(yellow(` ${why[0] || `${inp.label}要填,不能空着`}\n`));
}
}
}
flow = p.steps;
flow.title = recipe ? recipe.title : p.name || path.basename(file).replace(/\.json$/i, "");
flow.desc = recipe ? recipe.blurb : p.description || ""; // 配方的 description 开头就是标题,面板上别印两遍
flow.inputs = iv.values;
oneShot = "";
}
const shot = splitFiles(oneShot);
for (const m of shot.missing) prog(yellow(` !${m} 找不到,当普通文字发过去了\n`));
if (shot.files.length) oneShot = shot.text; // 没摘出东西就一个字都不动,双空格之类的原样留着
const wanted = namedFiles.concat(shot.files);
// openworkbuddy review [基准]:diff 在这儿取好,按只看不动跑。放在 splitFiles 之后——
// diff 里满是路径,过一遍那个摘附件的会把半份 diff 当文件摘走
if (sub === "review") {
const rv = require("./src/cli/review");
const r = rv.collect(getWorkspaceDir(), oneShot);
if (r.error) { process.stderr.write(red(r.error + "\n")); process.exit(2); }
if (r.empty) { process.stderr.write(dim(`${r.label}:没有改动,没什么可审的\n`)); process.exit(0); }
prog(dim(`审${r.label}${r.truncated ? `(太长,只放前 ${rv.DIFF_MAX} 字符)` : ""},只审不改…\n`));
oneShot = rv.prompt(r);
opts.mode = "ask";
}
// 管道:有任务描述时当附加材料,没有时管道内容本身就是任务(openworkbuddy < 任务.txt)
const piped = await readStdin(!!String(oneShot || "").trim());
if (piped.trim()) {
const body = piped.length > STDIN_MAX
? piped.slice(0, STDIN_MAX) + `\n…(标准输入共 ${piped.length} 字符,这里只截了前 ${STDIN_MAX} 个)`
: piped;
oneShot = oneShot
? `${oneShot}\n\n---\n以下是从标准输入读到的内容:\n\n${body}`
: body.trim();
}
// 先判「有没有话要问」,再往工作目录里搬东西:搬完才发现没话可问,人补一句重跑,
// 工作目录里就多出一份 报告-2.md——同一个文件躺两遍,之后谁也说不清该看哪一个
if (!oneShot && wanted.length) {
process.stderr.write(red(`带上了 ${wanted.map((f) => path.basename(f.path)).join("、")},可没说要拿它干什么。\n`));
process.stderr.write(dim(`把要问的话也写上:openworkbuddy -f 图.png "这张图里写了什么"\n`));
process.exit(2);
}
if (!oneShot && !flow && !process.stdin.isTTY) { console.log(cliArgs.helpText()); process.exit(1); }
const attachNames = bringIn(wanted);
if (attachNames.length) prog(dim(` 带上了 ${attachNames.join("、")}\n`));
if (opts.mcp && (config.mcp_servers || []).length) {
// 在界面上关掉的那几台,命令行里也别连——同一份 config,两边看见的工具表就该是同一份
mcpManager.setDisabled(config.mcp_disabled || []);
const on = config.mcp_servers.filter((x) => !mcpManager.disabled.has(x.name));
if (opts.verbose) prog(dim(`连接 MCP(${on.length} 个,--no-mcp 可跳过)… `));
else compactProgress("· 正在连接工具…");
await mcpManager.startAll(config.mcp_servers);
prog(dim(opts.verbose ? `连接器 ${mcpManager.clients.size}/${on.length} · ${mcpManager.toolDefs().length} 个工具\n` : `连接器配置 ${on.length} 项 · /mcp 查看可用性\n`));
}
const runtime = createAgentRuntime({ config, llm, mcpManager, experts, expertTeams });
const engineId = (config.agent || {}).engine || "builtin";
const engineBackend = require("./src/engines").get(engineId);
const who = engineBackend ? `底层 ${engineBackend.label}` + green("(不花 API 额度)") : `模型 ${llm.provider}(${llm.model})`;
// 权限档只在「不是默认那档」时印。默认 auto 天天见,印了就是噪音;
// 而 full(命令也不问了)和 plan(一个字都不写)恰恰是那种「以为自己在另一档」会出事的状态,
// 必须让人在第一行就看见——尤其 --perm 是一次性的,退出就没了,更不该只存在于自己的记忆里。
// full 单独多说半句:这一档连「删除保护」一起关掉(rm 类命令在别的档位都要点头,这档不问了),
// 这是 e2e 里钉死的设计,不是漏洞——但一个打了 --perm full 就走开的人,得先在这行里看见它。
const permLine = permNow() === security.DEFAULT_MODE ? ""
: ` · 权限 ${security.PERMISSION_MODES[permNow()].label}${permNow() === "full" ? yellow("(连删除也不问了)") : ""}`;
prog(bold(`OpenWorkBuddy ${require("./package.json").version}`) + dim(` · ${who} · ${modes.modeLabel(opts.mode)}${permLine}\n`));
prog(dim(`目录 ${getWorkspaceDir()}${opts.verbose ? ` · 会话 ${sessionId}` : ""}\n`));
if (allowLabels.length) {
// 放行名单是内置 agent 这一个进程里的;本机引擎(claude -p / codex)按它自己那套权限开关走,不认这张表
prog(dim(`预先放行:${allowLabels.join("、")}${engineBackend ? yellow(`(${engineBackend.label} 不认 --allow,它按自己的权限开关走)`) : ""}\n`));
}
if (flow) {
// 几步共用一个会话;{{名字}} 贴的是那一步落盘的最终回复。管道和 -f 带进来的材料跟着第一步走
// 终端里:一块面板,每步一行(见 workflow-panel.js)。输出重定向了就照老样子把全过程印出来——
// 那是给人事后翻、给别的程序接着加工的,一步一步的来龙去脉才是它要的
const wf = require("./src/cli/workflow");
const panel = panelWanted() ? wp.init({ name: flow.title, description: flow.desc, steps: flow }) : null;
if (panel) {
panel.model = engineBackend ? engineBackend.label : llm.model;
panelStart(panel);
}
const results = {};
const made = [];
let worst = "ok";
let lastSaid = "";
let stuck = null; // 停在哪一步(没成、后面不跑了)
for (let i = 0; i < flow.length; i++) {
const st = flow[i];
const row = panel && panel.steps[i];
if (row) { wfp.cur = i; Object.assign(row, { status: "run", t0: Date.now(), lastAt: Date.now(), activity: "思考中" }); panelDraw(); }
else prog(yellow(`\n── 第 ${i + 1}/${flow.length} 步 · ${st.name} ──\n`));
// 先填 inputs 再贴前面几步的结果:反过来的话,上一步回复里碰巧写着 {{input.x}} 也会被当成空填掉
let text = wf.fill(wf.fillInputs(st.prompt, flow.inputs || {}), results);
if (i === 0) {
if (oneShot) text += `\n\n---\n材料:\n\n${oneShot}`;
text = attach.withNote(text, attachNames);
}
const r = await runOnce(runtime, text, st.mode || opts.mode);
const last = sess.transcript[sess.transcript.length - 1];
const said = last && last.type === "assistant" ? (last.events || []).filter((e) => e.type === "text").map((e) => e.delta).join("") : "";
results[st.name] = said;
if (said.trim()) lastSaid = said;
if (lastRun) for (const n of lastRun.changed) if (!made.includes(n)) made.push(n);
if (row) {
row.t1 = Date.now();
row.status = r === "ok" ? "ok" : "fail";
if (r === "aborted") row.error = "按 Ctrl+C 停了";
else if (r !== "ok") row.error = String((lastRun && lastRun.error) || "没成").split("\n")[0].slice(0, 80);
}
if (r === "aborted") { panelStop(); mcpManager.stopAll(); process.exit(130); }
if (r !== "ok") {
worst = "error";
const rest = flow.length - i - 1;
if (!st.continueOnError || !rest) stuck = { i, name: (panel && row.name) || st.name, error: row ? row.error : "" };
if (!st.continueOnError && rest) {
if (panel) for (let k = i + 1; k < flow.length; k++) panel.steps[k].status = "skip";
else process.stderr.write(red(`第 ${i + 1} 步没成,后面 ${rest} 步不跑了\n`));
break;
}
}
}
if (panel) {
panelStop();
// 面板上只有每步一行;真正要交出去的是最后一步说的那段话。中间几步的原话在会话里。
// 停在半路的不交:前面某一步的话当成整趟的结论摆出来,人会以为做完了
if (stuck) prog(red(`\n✗ 停在第 ${stuck.i + 1} 步「${stuck.name}」${stuck.error ? `:${stuck.error}` : ""}\n`));
else if (lastSaid.trim()) {
const md = newMdRenderer();
const body = callout.strip(lastSaid);
answer("\n" + (md ? md.write(body) + md.end() : body));
if (!/\n$/.test(body) && !md) answer("\n");
}
if (made.length) prog(dim(`\n▪ 这趟写过:${made.slice(0, 8).join("、")}${made.length > 8 ? ` 等 ${made.length} 个` : ""}\n`));
hintOutputs(made, await drawOutputs(made), false);
prog(dim(`${made.length ? "" : "\n"}每步的过程:openworkbuddy resume ${sessionId}\n`));
}
mcpManager.stopAll();
process.exit(worst === "ok" ? 0 : 1);
}
if (oneShot) {
const r = await runOnce(runtime, attach.withNote(oneShot, attachNames), opts.mode);
mcpManager.stopAll();
process.exit(r === "ok" ? 0 : r === "aborted" ? 130 : 1);
}
// ---- REPL ----
// 这一段是重写过的。改写前有四样毛病,在健康机器上一个都不报错,只是悄悄办错事:
// 1. 粘贴多行只进去第一行——readline 一个换行一个 line 事件,rl.question 一次只接一条,
// 剩下的没人接、静默丢掉(实测贴 4 行进去,循环只收到 1 行)。
// 2. Ctrl+C 停不掉任务——终端模式下 readline 自己把 Ctrl+C 截走了,
// runOnce 里那句 process.once("SIGINT") 在交互模式下从来没被调用过。
// 3. 打错的斜杠命令(/exi、/moe)整行当任务发给模型,钱花了事没办。
// 4. Ctrl+D 之后等在 question 上的 Promise 永远不 resolve,MCP 子进程跟着挂死。
// 现在一行输入先过 repl-commands 那张纯表,再由这儿决定怎么说、怎么做。
const repl = require("./src/cli/repl-commands");
const PROMPT = ttyErr ? "\x1b[36mopenworkbuddy>\x1b[0m " : "openworkbuddy> ";
const HIST_FILE = dataPath("data", "cli-history.txt");
const loadHistory = () => {
// 文件里老的在前(跟 bash 一样,人直接 cat 也顺眼),readline 要的是新的在前
try { return repl.sanitizeHistory(fs.readFileSync(HIST_FILE, "utf8").split("\n").reverse()); } catch { return []; }
};
const saveHistory = () => {
// 里面是这个人自己的任务原话,权限跟 config.json 一个待遇(store.SECRET_MODE = 0600)
try {
const list = repl.sanitizeHistory(Array.isArray(rl.history) ? rl.history : []);
if (!list.length) return;
fs.mkdirSync(path.dirname(HIST_FILE), { recursive: true });
fs.writeFileSync(HIST_FILE, list.slice().reverse().join("\n") + "\n", { mode: store.SECRET_MODE });
store.tighten(HIST_FILE, store.SECRET_MODE); // writeFileSync 的 mode 过 umask,老文件也得补收一次
} catch {}
};
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: PROMPT,
completer: completeLine,
history: loadHistory(),
historySize: repl.HISTORY_MAX,
removeHistoryDuplicates: true,
});
replRl = rl; // runOnce 里等回答时要用它读一行,那边在这个闭包外面
// 输入侧:line 事件原样交给 repl.makeInbox——合并粘贴、排队、插话、关掉时叫醒等着的那个人,
// 全在那张纯逻辑里。时钟和定时器能从外面塞进去,所以这套时序在测试里可以手动推、逐帧断言
let quitArmed = 0;
// 带上了、还没跟着问题发出去的文件名。拖一个文件进来先攒着,等人把要问的话打完再一块儿发——
// 拖进来的那一下就发出去,等于让模型自己猜要拿这个文件干嘛
const pending = [];
// !命令 跑完的输出:攒着,跟下一句话一块儿带给模型(/drop 可以不带)。
// shellKid 是正在跑的那条——Ctrl+C 先停它,不能落到「再按一次退出」上
const shellNotes = [];
let shellKid = null;
const inbox = repl.makeInbox({
onInterject: (text) => {
if (!text.trim()) return;
// 它正等着一个答案:这一行是回答,不是插话。不先认这一条的话,
// 人明明回答了,agent 却在那儿干等到超时,然后按「没人回应」自己定
if (pendingAsk) { const f = pendingAsk; pendingAsk = null; f(text); return; }
// 任务跑着的时候敲的字是「插话」,不是下一条任务
termInterject.push(text);
prog(dim(`\n已排入补充要求:${text.replace(/\n/g, " ").slice(0, 60)}\n`));
},
onMerged: (n, blocks) => {
// 粘进来的 N 行,readline 一行一条记进了历史。合成一条之后把多出来的退掉,
// 不然往上翻一次只翻回一行,还把真正有用的历史挤没了
if (n > 1 && blocks.length === 1 && Array.isArray(rl.history)) rl.history.splice(0, n - 1);
saveHistory();
},
});
rl.on("line", (raw) => inbox.line(raw));
rl.on("close", () => { menuClose(); inbox.close(); });
// ---- 打 `/` 就把菜单弹出来 ----
// Tab 补全一直是有的,
// 但一个记不住命令的人第一反应是打个 `/` 等着看有什么,不会去按 Tab。
// 画在输入行**下面**,每次按键擦掉重画。两条守则:
// 1. **相对移动,不算绝对行号。** 先用换行把光标顶下去(顶到屏幕底会自然滚屏),
// 再按同样的行数往回移——滚没滚都不会错位,这是终端里唯一稳的做法。
// 2. **亮着的就是回车会跑的。** 跟 Claude Code 一样第一条默认选中,回车直接跑它,Tab 只补全。
// 哪条默认亮、哪条回车只补全不开跑(要花钱的命令)由 repl.menu 定;@ 补路径不默认选——
// 一句话打到 @某文件 就回车,是想连这句话一起发走,不是想先补全。
// 人一旦按过 ↑↓,只要候选没变就停在他挑的那条;再打一个字候选变了,才回到第一条。
// 任何一步出岔子(终端不认这些指令、Node 换了内部实现)就整场关掉菜单:
// 宁可回到「按 Tab 补全」,也不能把人的输入行搅成一团。
const tw = require("./src/util/text-width"); // 中文占两列,对齐一律走它
const MENU_MAX = 6;
const menuState = { rows: 0, items: [], sel: -1, kind: "", key: "", dead: false };
const menuUsable = () => !menuState.dead && !!process.stdout.isTTY && !!process.stdin.isTTY;
function menuErase() {
if (!menuState.rows) return;
try {
const col = rl.getCursorPos().cols;
readline.moveCursor(process.stdout, 0, 1);
readline.cursorTo(process.stdout, 0);
readline.clearScreenDown(process.stdout);
readline.moveCursor(process.stdout, 0, -1);
readline.cursorTo(process.stdout, col);
} catch { menuState.dead = true; }
menuState.rows = 0;
}
function menuClose() { menuErase(); menuState.items = []; menuState.sel = -1; menuState.kind = ""; menuState.key = ""; }
// 模态选择器:/resume、/model 回车之后进这儿。↑↓ 挑、打字搜、回车定、Esc 走人。
// 跟 / 菜单共用同一个 _ttyWrite 拦截点,也共用 menuState.dead 这个「这台终端不认」的开关:
// 一旦画花过一次,两边一起退回「印一张表、敲序号」——少一半交互也比把人的终端搅烂强。
const picker = { on: false, lines: 0, view: null, key: null };
const pickerUsable = () =>
!menuState.dead && !!ttyWriteOrig && !!process.stdout.isTTY && !!process.stdin.isTTY;
function pickerErase() {
if (!picker.lines) return;
try {
readline.cursorTo(process.stdout, 0);
readline.moveCursor(process.stdout, 0, -picker.lines);
readline.clearScreenDown(process.stdout);
} catch { menuState.dead = true; }
picker.lines = 0;
}
/** 摆出来让人挑一个;挑中了给那一行,Esc / Ctrl+C 给 null。列表空就直接 null,不摆空框 */
function chooseFrom(rows, o) {
const opt = o || {};
const all = Array.isArray(rows) ? rows : [];
if (!all.length) return Promise.resolve(null);
return new Promise((done) => {
let q = typeof opt.q === "string" ? opt.q : ""; // Ctrl+R:输入行上已经打的字直接当搜索词
let sel = Number(opt.sel) || 0; // 默认停在「现在这个」上:回车等于不换,手滑不会换掉
const paint = () => {
pickerErase();
const v = repl.pickerView(all, { q, sel, title: opt.title, verb: opt.verb, hint: opt.hint, max: repl.PICKER_ROWS });
sel = v.sel < 0 ? 0 : v.sel;
picker.view = v;
// 搜索框单独一行,打了字就不压暗——框里那几个字是人自己刚敲的,压暗等于告诉他没生效
const out = ["", dim(v.head), v.typing ? v.search : dim(v.search)];
for (const l of v.lines) out.push(l.on ? `\x1b[36m${l.text}\x1b[39m` : dim(l.text));
out.push(dim(v.foot));
try {
process.stdout.write(out.join("\n") + "\n");
picker.lines = out.length;
} catch { menuState.dead = true; picker.lines = 0; }
};
const finish = (row) => {
picker.on = false; picker.key = null;
pickerErase();
picker.view = null;
done(row || null);
};
picker.key = (ch, k) => {
if (k.ctrl && (k.name === "c" || k.name === "d")) return finish(null);
if (k.name === "escape") return finish(null);
if (k.name === "return" || k.name === "enter") {
const v = picker.view;
return finish(v && v.total ? v.hits[v.sel] : null);
}
if (k.name === "up" || k.name === "down") {
const n = (picker.view && picker.view.total) || 0;
if (!n) return;
sel = k.name === "down" ? (sel + 1) % n : (sel <= 0 ? n - 1 : sel - 1);
return paint();
}
if (k.name === "backspace") { if (q) { q = q.slice(0, -1); sel = 0; paint(); } return; }
if (k.ctrl && k.name === "u") { if (q) { q = ""; sel = 0; paint(); } return; }
if (k.ctrl || k.meta) return; // 别的组合键一律忽略,不要当搜索词吃进去
if (typeof ch === "string" && ch && !/[\x00-\x1f\x7f]/.test(ch)) { q += ch; sel = 0; paint(); }
};
picker.on = true;
paint();
});
}
/** 横着的档位条:←/→ 挪、回车定(给那一档的 id)、Esc / Ctrl+C 给 null。跟 chooseFrom 共用一个按键拦截点 */
function slideFrom(stops, at, o) {
const opt = o || {};
if (!Array.isArray(stops) || !stops.length) return Promise.resolve(null);
return new Promise((done) => {
let i = Math.max(0, Math.min(Number(at) || 0, stops.length - 1));
const paint = () => {
pickerErase();
const v = repl.sliderView(stops, i, { cur: opt.cur, title: opt.title, width: (process.stdout.columns || 80) - 1 });
const track = v.track.map((t) => (t.kind === "on" ? `\x1b[1;36m${t.text}\x1b[22;39m` : dim(t.text))).join("");
const out = ["", dim(v.head), track, ...v.desc.map((l) => dim(l)), dim(v.foot)];
try {
process.stdout.write(out.join("\n") + "\n");
picker.lines = out.length;
} catch { menuState.dead = true; picker.lines = 0; }
};
const finish = (id) => {
picker.on = false; picker.key = null;
pickerErase();
done(id || null);
};
picker.key = (ch, k) => {
if ((k.ctrl && (k.name === "c" || k.name === "d")) || k.name === "escape") return finish(null);
if (k.name === "return" || k.name === "enter") return finish(stops[i].id);
const step = k.name === "left" || k.name === "h" ? -1 : k.name === "right" || k.name === "l" || k.name === "tab" ? 1 : 0;
if (step && i + step >= 0 && i + step < stops.length) { i += step; paint(); }
};
picker.on = true;
paint();
});
}
// `@` 补路径:工作目录里有什么,边打边列。名字里有空格的按 shell 那套转义写回去——
// 这样它跟从访达拖进来的路径长得一模一样,parseLine 两边都认得。
// 目录补完留个 `/` 不留空格:这个词还没打完,菜单接着往下列
const AT_MAX = 40;
function fileMenu(line) {
const s = String(line || "");
const t = attach.atToken(s);
if (!t) return null;
const cut = t.prefix.lastIndexOf("/");
const sub = cut >= 0 ? t.prefix.slice(0, cut + 1) : "";
const base = cut >= 0 ? t.prefix.slice(cut + 1) : t.prefix;
const dir = sub.startsWith("~") || path.isAbsolute(sub)
? attach.expandHome(sub, os.homedir())
: path.join(getWorkspaceDir(), sub);
let ents = [];
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
const low = base.toLowerCase();
const items = [];
for (const e of ents) {
if (e.name.startsWith(".")) continue; // 点开头的是配置和缓存,不是人要带走的东西
if (low && !e.name.toLowerCase().startsWith(low)) continue;
const isDir = e.isDirectory();
items.push({
text: e.name + (isDir ? "/" : ""),
insert: s.slice(0, t.at) + "@" + attach.escPath(sub + e.name) + (isDir ? "/" : " "),
desc: isDir ? "目录" : "",
});
}
items.sort((a, b) => a.text.localeCompare(b.text, "zh"));
return items.length ? { kind: "file", items: items.slice(0, AT_MAX) } : null;
}
// Tab 走的是 readline 自己的补全(菜单没开、或者这台机器上菜单用不了的时候)。
// 两边共用 fileMenu 和 repl.menu,不会出现「菜单里有、Tab 补不出来」
let custom = { list: [], skipped: [] }; // 自定义斜杠命令,下面 reloadCustom 填;补全和菜单要先看得见它
function completeLine(line) {
const f = fileMenu(line);
if (f) return [f.items.map((i) => i.insert), String(line == null ? "" : line)];
return repl.complete(line, { custom: custom.list });
}
function menuDraw() {
if (!menuUsable() || inbox.busy) { menuClose(); return; }
let pos = null;
try { pos = rl.getCursorPos(); } catch { menuState.dead = true; menuClose(); return; }
const hit = require("./src/cli/repl-commands").menu(rl.line || "", { custom: custom.list }) || fileMenu(rl.line || "");
const items = hit ? hit.items.slice(0, MENU_MAX) : [];
// 输入折行了就不画:底下那几行的位置算不准,宁可没菜单也不能画歪
if (!items.length || pos.rows > 0) { menuClose(); return; }
menuErase();
menuState.items = items;
const key = hit.kind + "\n" + items.map((it) => it.text).join("\n");
if (key !== menuState.key) menuState.sel = typeof hit.sel === "number" && !multi.length ? Math.min(hit.sel, items.length - 1) : -1;
menuState.kind = hit.kind;
menuState.key = key;
if (menuState.sel >= items.length) menuState.sel = items.length - 1;
const labelW = items.reduce((w, it) => Math.max(w, tw.cols(it.text)), 0);
const room = Math.max(20, (process.stdout.columns || 80) - 1);
const lines = items.map((it, i) => {
const on = i === menuState.sel;
let body = ` ${on ? "›" : " "} ${tw.padCols(it.text, labelW)}${it.desc ? " " + it.desc : ""}`;
while (tw.cols(body) > room) body = body.slice(0, -1);
return on ? `\x1b[36m${body}\x1b[39m` : `\x1b[2m${body}\x1b[22m`;
});
try {
process.stdout.write("\n" + lines.join("\n"));
readline.moveCursor(process.stdout, 0, -lines.length);
readline.cursorTo(process.stdout, pos.cols);
menuState.rows = lines.length;
} catch { menuState.dead = true; menuState.rows = 0; }
}
/** 说一句话但不弄乱正在打的那一行:把它收起来、说、再原样摆回去 */
function sayAbove(text) {
menuClose();
try {
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
} catch { menuState.dead = true; }
process.stdout.write(text);
try { rl.prompt(true); } catch { menuState.dead = true; }
menuDraw();
}
// Shift+Tab 循环权限档。放权这件事十有八九是「手停在半路上才发现档位不对」,
// 那时候再去敲 /perm ask 已经太慢,得是一个随手就能按到的键。
function cyclePerm() {
// 「全自动」不进这个圈:Shift+Tab 就挨着 Tab,误碰一下就把命令确认也关了,
// 而人正盯着自己那半行字,根本不会去读屏幕上多出来的一行。要到全自动得明着敲 /perm full。
const ids = Object.keys(security.PERMISSION_MODES).filter((id) => id !== "full");
if (!ids.length) return;
const cur = permNow();
const at = ids.indexOf(cur); // 当前是 full 就落到第一档,等于往回收,安全
const next = ids[((at < 0 ? -1 : at) + 1) % ids.length];
security.getSecurity(config).permission_mode = next;
const m = security.PERMISSION_MODES[next];
sayAbove(dim(`权限 → 「${m.label}」(${next}) ${m.desc}(只管这一趟;Shift+Tab 到不了「全自动」,那个得敲 /perm full)\n`));
}
// Esc Esc:把之前问过的话拉回输入行改了重问。
// 这儿只「放回去」,不回卷历史——真删掉已经跑过的那几轮,等于把模型已经做过的事悄悄抹了,
// 而人看不见抹了什么。要重来就开 /new,要接回去就 /resume,两条路都在明面上。
let escArmed = 0;
async function reEditLast() {
// shown 优先:拼了 !命令 输出的那几条,text 是给模型看的全文,放回来的该是人自己打的那句
const msgs = (sess.transcript || [])
.map((t) => (t && t.type === "user" ? String(t.shown || t.text || "").replace(/\s+$/, "") : ""))
.filter((x) => x.trim());
if (!msgs.length) { sayAbove(dim("这个会话还没问过什么,没得改\n")); return; }
if (!pickerUsable()) { sayAbove(dim(`上次问的是:${msgs[msgs.length - 1].replace(/\s+/g, " ").slice(0, 60)}\n`)); return; }
const rows = msgs.slice(-40).reverse().map((full, i) => {
const text = full.replace(/\s+/g, " ").trim();
// 单子上压成一行好认,放回去的是原样——多行的还是原来那几行
return { id: String(i), label: text.length > 56 ? text.slice(0, 56) + "…" : text, meta: "", hay: text, text: full };
});
try {
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
} catch { menuState.dead = true; }
const picked = await chooseFrom(rows, { title: "openworkbuddy> 把哪一句放回去改?", verb: "放回输入行", hint: "打字就筛你问过的话" });
try { rl.prompt(true); } catch { menuState.dead = true; }
if (picked) loadComposer(picked.text);
menuDraw();
}
// ---- 多行输入 ----
// 回车 = 发出去。想先换行:行尾 \ 再回车、Ctrl+J、Option+回车、Shift+回车(终端分得出来时)。
// 粘进来的多行(终端支持括号粘贴时)整块收下、不自己发:看一眼、改两个字再回车——Codex / Claude Code 都是这样。
// 换过去的行留在屏幕上、存在 multi 里,readline 只管最后正在打的那一行:
// 往 readline 那一行里塞 "\n" 的话,Node 25 交出来的是倒过来、拿 \r 连起来的两行
const CONT = ttyErr ? "\x1b[2m ... \x1b[0m" : " ... ";
const ECHO_MAX = 8;
const PASTE_ON = "\x1b[?2004h";
const PASTE_OFF = "\x1b[?2004l";
// 终端把「粘贴结束」那个标记弄丢了(少见,可丢了的话之后打的每个字都被当成粘贴吞掉):停手这么久就当粘完了
const PASTE_IDLE_MS = 1500;
const multi = [];
let composeFrom = PROMPT; // 开始攒行之前的提示符:发出去 / 不要了就换回它(跑着时是空的,等回答时是「答>」)
let pasting = false, pbuf = "", pasteAt = 0, pasteGuard = null;
const lastReturn = { at: 0, ours: false };
typingNow = () => !!rl.line || multi.length > 0 || pasting;
composerClear = () => { multi.length = 0; pasting = false; pbuf = ""; if (rl.getPrompt() === CONT) rl.setPrompt(composeFrom); };
/** 进输入行的那一行:Tab 换成两个空格(readline 算光标不认 Tab),控制字符去掉 */
const inlineSafe = (x) => String(x).replace(/\t/g, " ").replace(/[\x00-\x08\x0a-\x1f\x7f]/g, "");
/** 攒着、印出来的行:Tab 留着(代码的缩进要原样带给模型),别的控制字符去掉 */
const showSafe = (x) => String(x).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
/** 把输入行这一行(换成 shown 的样子)定在屏幕上,光标落到下一行,接着在「 ... 」后面打 */
function holdRow(shown, kept) {
menuClose();
if (!multi.length) composeFrom = rl.getPrompt();
rl.line = shown;
rl.cursor = shown.length;
try { rl.prompt(true); rl.clearLine(); } catch { menuState.dead = true; }
multi.push(kept);
rl.setPrompt(CONT);
}
/** 印几行攒着的(粘进来的中间那些):一次粘两百行不全印,头几行 + 一句还有多少 */
function echoHeld(rows, lead) {
const shown = repl.echoRows(rows.map(showSafe), ECHO_MAX);
if (shown.length) process.stdout.write(shown.map((r, i) => (i === 0 && lead ? lead : CONT) + r).join("\n") + "\n");
}
function endCompose() {
multi.length = 0;
if (rl.getPrompt() === CONT) rl.setPrompt(composeFrom);
}
/** 攒着的几行 + 输入行这一行,整段发出去 */
function submitComposed() {
const text = repl.composeText(multi, rl.line).replace(/\s+$/, "");
menuClose();
try { rl.clearLine(); } catch { menuState.dead = true; }
endCompose();
// readline 自己只记单行;整段压成一行进历史,↑ 翻回来是整段话而不是最后那一行
const h = repl.historyLine(text);
if (h && h.length <= 2000 && Array.isArray(rl.history)) {
const at = rl.history.indexOf(h);
if (at >= 0) rl.history.splice(at, 1);
rl.history.unshift(h);
if (rl.history.length > repl.HISTORY_MAX) rl.history.length = repl.HISTORY_MAX;
}
rl.historyIndex = -1;
inbox.line(text);
}
/** Ctrl+C / 空行上 Ctrl+D:攒着的这段不要了。屏幕上印过的擦不回去,说一句 */
function dropComposed() {
rl.line = "";
rl.cursor = 0;
try { rl.prompt(true); } catch { menuState.dead = true; }
endCompose();
sayAbove(dim("(上面那几行没发,不要了)\n"));
}
/** 在光标这儿断开:前半截定在屏幕上,后半截带到下一行接着打 */
function splitHere() {
const before = rl.line.slice(0, rl.cursor);
const after = rl.line.slice(rl.cursor);
holdRow(before, before);
rl.line = after;
rl.cursor = 0;
try { rl.prompt(true); } catch { menuState.dead = true; }
}
/** 一整块粘完了:一行的插在光标处;多行的前面几行攒着,最后一行留在输入行上,光标停在粘进来的末尾 */
function takePaste(raw) {
pasting = false;
pbuf = "";
if (pasteGuard) { clearTimeout(pasteGuard); pasteGuard = null; }
const t = String(raw || "").replace(/\r\n?/g, "\n").replace(/\n+$/, "");
if (!t) return;
if (!t.includes("\n")) { rl.write(inlineSafe(t)); return; }
const before = rl.line.slice(0, rl.cursor);
const after = rl.line.slice(rl.cursor);
const rows = t.split("\n").map(showSafe);
const tail = rows.pop();
const head = rows.shift();
holdRow(before + inlineSafe(head), before + head);
if (rows.length) { echoHeld(rows); multi.push(...rows); }
rl.line = inlineSafe(tail) + after;
rl.cursor = inlineSafe(tail).length;
try { rl.prompt(true); } catch { menuState.dead = true; }
}
function pasteWatch(ms) {
pasteGuard = setTimeout(() => {
const idle = Date.now() - pasteAt;
if (idle < PASTE_IDLE_MS) { pasteWatch(PASTE_IDLE_MS - idle); return; }
pasteGuard = null;
takePaste(pbuf);
menuDraw();
}, ms);
if (pasteGuard.unref) pasteGuard.unref();
}
/** 一段(可能多行的)字摆回输入框:前面几行攒着,最后一行进输入行,光标在末尾 */
function loadComposer(text) {
const { above, line } = repl.splitComposed(text);
const kept = above.map(showSafe);
if (kept.length) {
holdRow(inlineSafe(kept[0]), kept[0]);
if (kept.length > 1) { echoHeld(kept.slice(1)); multi.push(...kept.slice(1)); }
}
rl.line = inlineSafe(line);
rl.cursor = rl.line.length;
try { rl.prompt(true); } catch { menuState.dead = true; }
}
/** 跑着的时候攒了几行没发,正文把它们冲上去了:活儿跑完在新提示符上重摆一遍 */
function reshowHeld() {
try { readline.cursorTo(process.stdout, 0); readline.clearLine(process.stdout, 0); } catch { menuState.dead = true; }
echoHeld(multi, PROMPT);
}
// Ctrl+R:搜以前问过的话(跨会话存着的那份历史)。挑中了摆回输入框,多行的还是原来那几行
async function searchHistory() {
const full = (sess.transcript || []).filter((t) => t && t.type === "user").map((t) => String(t.shown || t.text || "").replace(/\s+$/, ""));
const rows = repl.historyRows(rl.history, { full });
if (!rows.length) { sayAbove(dim("还没有问过的话可搜\n")); return; }
if (!pickerUsable()) { sayAbove(dim("这台终端摆不了单子;↑↓ 一条条翻也一样\n")); return; }
const typed = rl.line;
rl.line = "";
rl.cursor = 0;
try { rl.prompt(true); readline.cursorTo(process.stdout, 0); readline.clearLine(process.stdout, 0); } catch { menuState.dead = true; }
const picked = await chooseFrom(rows, { title: "openworkbuddy> 搜以前问过的话", verb: "放回输入行", hint: "打几个字就筛", q: typed });
try { rl.prompt(true); } catch { menuState.dead = true; }
if (picked) loadComposer(picked.text);
else if (typed) { rl.line = typed; rl.cursor = typed.length; try { rl.prompt(true); } catch { menuState.dead = true; } }
menuDraw();
}
// Ctrl+G:拿 $VISUAL / $EDITOR 写这段话。长需求、要贴代码、要来回改的时候,终端这一行太窄了。
// 编辑器没正常退出(:cq、被杀、打不开)就不用它的:原来那段原样摆回来,照实说是怎么没的
function openEditor() {
const cur = repl.composeText(multi, rl.line);
const hadRows = multi.length > 0;
menuClose();
rl.line = "";
rl.cursor = 0;
try { rl.prompt(true); } catch { menuState.dead = true; }
endCompose();
const ed = process.env.VISUAL || process.env.EDITOR || (process.platform === "win32" ? "notepad" : "vi");
const hush = () => {}; // 编辑器在前台时 Ctrl+C 归它;万一信号打到这边,别把整个程序带走
let dir = "", got = null, why = "";
try {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "owb-edit-"));
const file = path.join(dir, "prompt.md");
fs.writeFileSync(file, cur, { mode: 0o600 });
const q = process.platform === "win32" ? `"${file}"` : `'${file.replace(/'/g, "'\\''")}'`;
rl.pause();
process.on("SIGINT", hush);
try { process.stdin.setRawMode(false); } catch {}
process.stdout.write(PASTE_OFF);
// 不加 windowsHide:编辑器就是要给人用的;记事本这种窗口程序加了会被藏起来
const r = spawnSync(`${ed} ${q}`, { stdio: "inherit", shell: true });
if (r.error) why = `编辑器没打开(${ed}):${r.error.message}`;
else if (r.signal) why = `编辑器被 ${r.signal} 停了(${ed}),没用它的`;
else if (r.status) why = `编辑器退出码 ${r.status}(${ed}),没用它的`;
else got = fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
} catch (e) {
why = `编辑器没用上:${e.message}`;
} finally {
try { process.stdin.setRawMode(true); } catch {}
if (pasteMode) process.stdout.write(PASTE_ON);
rl.resume();
setImmediate(() => process.removeListener("SIGINT", hush));
if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
}
if (why) sayAbove(yellow(why + "\n"));
else if (hadRows) sayAbove(dim("(上面那段拿去编辑器改了,改好的在下面)\n"));
loadComposer(got === null ? cur : got);
menuDraw();
}
// 按键先过这儿再交给 readline:↑↓ 在菜单开着的时候是「挑哪条」,不是翻历史。
// _ttyWrite 是 readline 的内部,拿不到就降级成「只弹不挑」——菜单照样看得见,
// Tab 走 readline 自己的补全。宁可少一半功能,也不能因为 Node 换了实现就崩在这儿。
const ttyWriteOrig = typeof rl._ttyWrite === "function" ? rl._ttyWrite.bind(rl) : null;
if (ttyWriteOrig) {
replKeyHook = true;
rl._ttyWrite = (ch, key) => {
const k = key || {};
if (keyGrab) { keyGrab(ch, k); return; } // 审批单子摆着:按键全归它
if (picker.on) { picker.key(ch, k); return; } // 选择器开着就整场归它,readline 一个键都收不到
// 跑着的时候一按键就把走字那行定格:人要在这一行上打字了,秒数再跳会把他的字冲掉
if (inbox.busy && tick.drawn) tickPause();
// 括号粘贴:整块先收着,粘完一次摆进输入框——里头的回车不是「发出去」
if (k.name === "paste-start") { pasting = true; pbuf = ""; pasteAt = Date.now(); if (!pasteGuard) pasteWatch(PASTE_IDLE_MS); return; }
if (pasting) {
if (k.name === "paste-end") { takePaste(pbuf); menuDraw(); return; }
if (!(k.ctrl && k.name === "c")) { pbuf += typeof ch === "string" ? ch : ""; pasteAt = Date.now(); return; }
takePaste(""); // 粘到一半按了 Ctrl+C:这块不要了,Ctrl+C 照常往下走
}
if (k.name === "paste-end") return; // 没头的结尾(等不及已经收过了):丢掉
if (k.name === "tab" && k.shift) { cyclePerm(); return; } // 得排在菜单之前,不然被当成补全的 Tab 吃掉
if (k.ctrl && k.name === "r" && !inbox.busy && !multi.length) { void searchHistory(); return; }
if (k.ctrl && k.name === "g" && !inbox.busy) { openEditor(); return; }
if (menuState.items.length && !k.ctrl && !k.meta) {
if (k.name === "up" || k.name === "down") {
const n = menuState.items.length;
menuState.sel = k.name === "down"
? (menuState.sel + 1) % n
: (menuState.sel <= 0 ? n - 1 : menuState.sel - 1);
menuDraw();
return;
}
if (k.name === "escape") { menuClose(); return; }
const enter = k.name === "return" || k.name === "enter";
if (k.name === "tab" || (enter && menuState.sel >= 0)) {
const pick = menuState.items[menuState.sel < 0 ? 0 : menuState.sel];
const go = enter && !!pick.run && !multi.length;
menuClose();
rl.write(null, { ctrl: true, name: "e" });
rl.write(null, { ctrl: true, name: "u" }); // 清掉这行,再把整条命令写回去
rl.write(go ? pick.run : pick.insert);
if (!go) return;
// 回车跑亮着的那条:行已经换成整条命令了,下面照常当一次回车交出去
}
}
const nk = repl.newlineKey(k, Date.now() - lastReturn.at);
// \r\n 那个 \n 尾巴:回车是这边接的(发出去 / 续行)就吞掉,不然交回 readline 让它自己按 crlfDelay 认
if (nk === "crlf-tail") { if (!lastReturn.ours) ttyWriteOrig(ch, key); return; }
if (nk === "newline") { splitHere(); menuDraw(); return; }
if (k.name === "return" && !k.meta) {
lastReturn.at = Date.now();
lastReturn.ours = false;
const cont = repl.continuedLine(rl.line, rl.cursor);
if (cont !== null) { lastReturn.ours = true; holdRow(cont, cont); try { rl.prompt(true); } catch { menuState.dead = true; } return; }
if (multi.length) { lastReturn.ours = true; submitComposed(); return; }
}
// 跑着的时候按 Esc:停这一趟,不退出。在等回答、在打字、攒着几行的时候不抢——那时候的 Esc 不是这个意思
if (k.name === "escape" && inbox.busy && !pendingAsk && !rl.line && !multi.length) { if (stopSoft) stopSoft(); return; }
// Esc Esc 得认 sequence,不能靠掐表:Node 的 keypress 解码器会把连按的两下 ESC
// 合成一个事件(sequence 是两个 \x1b),掐表那套永远等不到第二下。
// 计时那条留着兜底——万一哪天 Node 改了实现,真发两个事件,这边照样认。
if (k.name === "escape" && !rl.line && !multi.length && !inbox.busy) {
const t = Date.now();
if (k.sequence === "\x1b\x1b" || t - escArmed < 900) { escArmed = 0; void reEditLast(); return; }
escArmed = t;
return;
}
if (k.ctrl && k.name === "d" && !rl.line && multi.length) { dropComposed(); return; } // 不然 readline 当成关掉整个程序
if (k.name === "return" || k.name === "enter") menuClose(); // 回车前先擦干净,不然菜单会留在正文里
ttyWriteOrig(ch, key);
menuDraw();
};
}
// 开括号粘贴:终端把粘进来的那块用 ESC[200~ … ESC[201~ 包起来,才分得出「粘的回车」和「按的回车」。
// 拦不住按键(没装上钩子)的时候不开:开了没人拆包,那两个标记会原样进输入行
const pasteMode = !!ttyWriteOrig && !!process.stdin.isTTY && !!process.stdout.isTTY;
if (pasteMode) {
process.stdout.write(PASTE_ON);
process.on("exit", () => { try { process.stdout.write(PASTE_OFF); } catch {} }); // 不关的话回到 shell 里粘贴会多出 200~
}
rl.on("SIGINT", () => {
menuClose();
if (shellKid) { stopShell(); return; } // !命令 跑着:停的是它
if (inbox.busy) { if (stopCurrent) stopCurrent(); return; } // 停这趟活儿,不退出
if (multi.length) { dropComposed(); quitArmed = 0; return; } // 攒着几行没发:扔掉这段,别退出
if (rl.line) { // 打了一半不想要了:清掉这行就行,别退出
rl.write(null, { ctrl: true, name: "e" });
rl.write(null, { ctrl: true, name: "u" });
quitArmed = 0;
return;
}
const now = Date.now();
if (now - quitArmed < 3000) { rl.close(); return; }
quitArmed = now;
process.stdout.write("\n");
prog(dim("再按一次 Ctrl+C 退出,或者敲 /exit\n"));
rl.prompt();
});
const nextInput = () => inbox.next();
// !命令:人自己在工作目录里跑一条 shell,边跑边往屏幕上印,不经过模型、不花钱。
// 跟 run_shell 用同一个 shell(macOS 上 zsh 关掉 nomatch),PATH 也补齐 homebrew 那几个目录。
// stdin 不接:这里不是真终端,vim、交互式 python 这种会一直等输入的东西 Ctrl+C 能停
const runShell = (cmd) => new Promise((resolve) => {
const tools = require("./src/agent/tools");
const { bin, args, opts: shOpts } = tools._internals.pickShell(cmd);
const bufs = [];
let bytes = 0, tailNl = true;
let kid;
try {
kid = require("child_process").spawn(bin, args, {
...shOpts, cwd: getWorkspaceDir(), env: { ...process.env, PATH: tools.shellPath() },
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32", // 自己一个进程组,Ctrl+C 连它起的子进程一块儿收
});
} catch (e) { resolve({ out: "", code: null, error: e.message }); return; }
shellKid = kid;
const take = (w) => (d) => {
w.write(d);
tailNl = d[d.length - 1] === 10;
if (bytes < 2 * 1024 * 1024) { bufs.push(d); bytes += d.length; } // 带给模型的只要头尾几千字,攒这么多足够了
};
kid.stdout.on("data", take(process.stdout));
kid.stderr.on("data", take(process.stderr));
const done = (r) => {
if (shellKid === kid) shellKid = null;
if (!tailNl) process.stdout.write("\n");
resolve({ out: Buffer.concat(bufs).toString("utf8"), ...r });
};
kid.on("error", (e) => done({ code: null, error: e.message }));
kid.on("close", (code, signal) => done({ code, signal }));
});
const stopShell = () => {
const kid = shellKid;
if (!kid) return;
const hit = (sig) => { try { process.kill(-kid.pid, sig); } catch { try { kid.kill(sig); } catch {} } };
hit("SIGINT");
// 有的程序把 SIGINT 吞了(等输入的那种):给两秒,还在就硬收
const t = setTimeout(() => { if (kid.exitCode === null && kid.signalCode === null) hit("SIGKILL"); }, 2000);
if (t.unref) t.unref();
};
// 自己写的斜杠命令:跟着工作目录走(/cd 之后换成那个项目的),读盘很便宜,每条输入前重读一次,
// 改完 .md 不用重开终端
const customCmds = require("./src/cli/custom-commands");
const builtinNames = repl.COMMANDS.flatMap((c) => [c.name, ...(c.aliases || [])]);
const reloadCustom = () => {
try { custom = customCmds.load({ cwd: getWorkspaceDir(), builtins: builtinNames }); } catch { custom = { list: [], skipped: [] }; }
return custom;
};
reloadCustom();
if (custom.list.length) prog(dim(`自定义命令 ${custom.list.length} 条:${custom.list.map((c) => "/" + c.name).join(" ")}\n`));
for (const x of custom.skipped) prog(yellow(` 没接上 ${path.basename(x.file)}:${x.why}\n`));
const runReplCommand = async (v) => {
if (v.name === "help") { prog(repl.helpText({ custom: custom.list })); return; }
if (v.name === "verbose") {
opts.verbose = v.arg ? v.arg === "on" : !opts.verbose;
prog(dim(opts.verbose ? "详细输出已开启;/verbose off 收起。\n" : "简洁输出已开启;/details 可查看上一轮过程。\n"));
return;
}
if (v.name === "details") {
const details = lastRun && lastRun.session === sessionId ? lastRun.details : sess.cliDetails;
if (!Array.isArray(details) || !details.length) { prog(dim("上一轮没有工具调用或诊断。\n")); return; }
const calls = new Map();
for (const ev of details) {
if (ev.type === "tool_use") { if (ev.id) calls.set(ev.id, ev); }
else if (ev.type === "tool_result") {
prog(toolView.callLine(calls.get(ev.id) || ev, { width: termWidth(), paint: toolPaint }) + "\n" + toolView.resultLines(ev, { width: termWidth(), paint: toolPaint, max: 12 }).join("\n") + "\n");
calls.delete(ev.id);
if (ev.diff) prog(paintDiff(ev.diff, 80) + "\n");
} else if (ev.type === "status") prog(dim(`· ${ev.text}\n`));
}
for (const ev of calls.values()) prog(toolView.callLine(ev, { width: termWidth(), paint: toolPaint }) + dim(" · 未收到结果\n"));
prog(dim("过程保留最多 600 条、1 MiB;--json 可记录完整事件流。\n"));
return;
}
if (v.name === "review") {
// diff 在这儿取好塞进去,审查按只看不动跑:审哪一份由人定,不让模型去猜 git 参数
const rv = require("./src/cli/review");
const r = rv.collect(getWorkspaceDir(), v.arg);
if (r.error) { prog(yellow(r.error + "\n")); return; }
if (r.empty) { prog(dim(`${r.label}:没有改动,没什么可审的\n`)); return; }
prog(dim(`审${r.label}${r.truncated ? `(太长,只放前 ${rv.DIFF_MAX} 字符)` : ""},只审不改…\n`));
return { prompt: rv.prompt(r), mode: "ask" };
}
if (v.name === "clear") { process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); return; }
if (v.name === "mode") {
if (!v.arg && pickerUsable()) {
const list = modes.EXEC_MODES;
const picked = await chooseFrom(repl.modePickerRows(list, opts.mode), {
title: "openworkbuddy> 换成哪个模式?", verb: "换", hint: "打字就筛",
sel: Math.max(0, list.findIndex((m) => m.id === opts.mode)),
});
if (!picked || picked.id === opts.mode) { prog(dim(`还是 ${modes.modeLabel(opts.mode)}\n`)); return; }
v = { ...v, arg: picked.id };
}
if (!v.arg) { prog(dim(`当前是 ${modes.modeLabel(opts.mode)};换:/mode ${modes.MODE_ARG}\n`)); return; }
// 这儿原来一个字的校验都没有。`/mode goal` 敲进去照收,状态行接着印「模式 goal」,
// 而底下 `["ask","plan","craft"].includes("goal")` 判 false,安静地按 craft 跑完——
// 用户以为自己开了目标验收,实际上从头到尾没验收过一次。认不出来就当场说,别装作切好了。
if (!modes.isMode(v.arg)) { prog(yellow(modes.modeHint(v.arg) + "\n")); return; }
opts.mode = v.arg;
prog(dim(`已经切到 ${modes.modeLabel(v.arg)}\n`));
return;
}
if (v.name === "perm") {
const cur = permNow();
if (!v.arg && pickerUsable()) {
const ids = Object.keys(security.PERMISSION_MODES);
const got = await slideFrom(ids.map((id) => ({ id, ...security.PERMISSION_MODES[id] })), ids.indexOf(cur), {
cur, title: "openworkbuddy> 这一趟放多少权?往右越放得开",
});
if (!got || got === cur) { prog(dim(`还是「${security.PERMISSION_MODES[cur].label}」\n`)); return; }
v = { ...v, arg: got };
}
if (!v.arg) {
// 不给值就把四档连同「这档到底意味着什么」一起摆出来。只印 id 的话,
// plan / ask / auto / full 四个英文词谁也分不清哪个更放得开,只能去翻文档。
prog(dim(`现在是「${security.PERMISSION_MODES[cur].label}」(${cur})。换:/perm <档位>\n`));
for (const [id, m] of Object.entries(security.PERMISSION_MODES)) {
prog(` ${id === cur ? green("◆") : dim("·")} ${id.padEnd(5)} ${m.label} ${dim(m.desc)}\n`);
}
return;
}
if (!security.PERMISSION_MODES[v.arg]) { prog(yellow(`没有「${v.arg}」这个档位,只能是 ${Object.keys(security.PERMISSION_MODES).join(" / ")}\n`)); return; }
// 只动内存里这份。跟 --perm 同一个道理:交互里临时松一档,不该把 config.json 也改了,
// 否则退出以后所有的活儿都跟着松了,而人早就忘了自己在这儿敲过一句 /perm。
security.getSecurity(config).permission_mode = v.arg;
const m = security.PERMISSION_MODES[v.arg];
prog(dim(`已经切到「${m.label}」——${m.desc}(只管这一趟,没改配置文件)\n`));
return;
}
if (v.name === "new") {
// 换一个新会话文件,而不是把当前这个清空后覆盖回去——刚才那段对话是资料,不该被顺手抹掉
const oldId = sessionId;
sessionId = newSessionId();
sessFile = sessFileOf(sessionId);
adoptSess({ history: [], transcript: [], title: "" });
prog(dim(`开了新会话 ${sessionId}(刚才那段还在:/resume ${oldId})\n`));
return;
}
if (v.name === "init") {
// 已经有的话先说一声再动手:整篇盖掉人手写的项目规范,是这条命令最容易犯的错
const ws = getWorkspaceDir();
const has = ["AGENTS.md", "CLAUDE.md"].filter((n) => {
try { return fs.statSync(path.join(ws, n)).size > 0; } catch { return false; }
}).join(" 和 ");
const t = repl.initTask({ has });
prog(dim(t.note));
return t.prompt; // 交回主循环当一趟活儿跑:权限档、改文件前的确认、/diff 里的记录一个都不少
}
if (v.name === "compact") {
if (!runtime.compactHistory) { prog(yellow("这个引擎不支持手动压缩\n")); return; }
if (reloadSessIfChanged()) prog(dim("(这条会话在别处有新内容,先接上再压)\n"));
const before = require("./src/agent/agent").historyChars(sess.history || []);
const n = (sess.history || []).length;
if (n < 4) { prog(dim("才聊了几句,没什么可压的\n")); return; }
prog(dim("压缩中……(要过一趟模型,十几秒)\n"));
let removed = 0;
try {
await runtime.compactHistory(sess.history, { force: true, emit: (e) => { if (e && e.type === "compact") removed = e.removed; } });
} catch (e) { prog(red(`压缩没成(${e.message}),上下文一点没动\n`)); return; }
prog(dim(repl.compactedText(before, require("./src/agent/agent").historyChars(sess.history || []), removed)));
prog(dim(contextLine() + "\n"));
saveSess();
return;
}
if (v.name === "diff") {
// 「动过」以工具调用为准,不听模型自述:它说改了而没真调 write_file 的情况是存在的
const seen = new Map();
for (const e of sess.history || []) {
if (e.role !== "assistant") continue;
for (const c of e.toolCalls || []) {
if (c.name !== "write_file" && c.name !== "edit_file" && c.name !== "multi_edit") continue;
const p = String(((c.args || c.input || {}).path) || "").trim();
if (p) seen.set(p, true);
}
}
const ws = getWorkspaceDir();
const rows = [...seen.keys()].map((p) => {
const abs = path.isAbsolute(p) ? p : path.join(ws, p);
try {
const st = fs.statSync(abs);
return { path: p, state: "ok", size: repl.sizeText(st.size), when: repl.ago(st.mtimeMs, Date.now()) };
} catch { return { path: p, state: "gone" }; }
});
let git = "", notRepo = false;
const hard = require("./src/util/safe-git").args(ws);
const inRepo = hard ? spawnSync("git", ["-C", ws, ...hard, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8", windowsHide: true }) : { status: 1 };
if (inRepo.status === 0 && String(inRepo.stdout).trim() === "true") {
const d = spawnSync("git", ["-C", ws, ...hard, "diff", "--stat", "--no-ext-diff", "--no-textconv", "HEAD"], { encoding: "utf8", windowsHide: true });
git = d.status === 0 ? String(d.stdout).trim() : "";
if (!git) git = "跟 HEAD 一模一样,没有未提交的改动。";
} else notRepo = true;
prog(repl.changedFilesText(rows, { git, notRepo }));
return;
}
if (v.name === "rewind") {
// 只退这个会话自己留的检查点:别的会话、用户手改的文件一概不碰
const ck = require("./src/agent/checkpoints");
const ws = getWorkspaceDir();
const rows = ck.list(ws, sessionId);
if (!v.arg && rows.length && pickerUsable()) {
const picked = await chooseFrom(repl.checkpointPickerRows(rows, Date.now()), {
title: "openworkbuddy> 退回哪一步之前?那一步和它之后动过的文件一起退", verb: "退回这步之前", hint: "打字就筛文件名",
});
if (!picked) { prog(dim("没退,文件一个没动\n")); return; }
v = { ...v, arg: String(picked.n) };
}
if (!v.arg) { prog(repl.checkpointListText(rows, Date.now())); return; }
const pick = repl.pickCheckpoint(rows, v.arg);
if (!pick) { prog(yellow(`没有第 ${v.arg} 步。/rewind 不带序号先看有哪些\n`)); return; }
const r = ck.rewind(ws, sessionId, pick.id);
prog(r.ok ? repl.rewindResultText(r) : yellow(r.error + "\n"));
return;
}
if (v.name === "mcp") {
const st = mcpManager.status();
const unavailable = cfgEngine() === "builtin" ? new Set() : new Set(require("./src/engines/bridge").forwardable(config.mcp_servers || [], config.mcp_disabled || []).skipped);
const rows = [
...(st.connected || []).map((c) => ({ name: c.name + (c.plugin ? `(${c.plugin})` : ""), ok: !unavailable.has(c.name), tools: c.tools,
...(unavailable.has(c.name) ? { why: "当前本机引擎不支持此连接器传输;切换内置模型可用" } : {}) })),
...(st.failures || []).map((x) => ({ name: x.name + (x.plugin ? `(${x.plugin})` : ""), ok: false, why: x.error || x.raw || "没接上" })),
];
prog(repl.mcpText(rows));
return;
}
if (v.name === "resume") {
// 光有 `openworkbuddy resume` 不够:那条是**开新进程**才用得上的写法。人已经坐在交互模式里,
// 想翻回半小时前那段就得先 /exit 再重开——而一 exit,当前这段的上下文、带着还没发出去的文件、
// 临时调过的 /mode /perm 全跟着没了。所以这儿干的是「原地换一条」,别的什么都不动。
const rows = repl.sessionRows(listCliSessions(repl.RESUME_MAX), { now: Date.now(), currentId: sessionId });
let r = repl.pickSessionRow(rows, v.arg);
// 选单只列最近十二条,更早的照样接得上——只要他手里有 id。/new 和下面那句都会把
// 「刚离开的那条」的 id 打出来,就是留给这一刻用的
const raw = String(v.arg || "");
if (r.kind === "none" && /^(cli_|s_)[\w-]+$/.test(raw) && fs.existsSync(sessFileOf(raw))) {
r = { kind: "ok", row: { id: raw, title: "", turns: 0, from: raw.startsWith("cli_") ? "命令行" : "桌面" } };
}
if (r.kind === "list") {
if (!pickerUsable()) { prog(repl.sessionListText(rows)); return; } // 管道里、或者这台终端画不了
const picked = await chooseFrom(repl.sessionPickerRows(rows), {
title: "openworkbuddy> 接着哪一条往下聊?", verb: "接上",
hint: "打字就筛,标题 / 你说过的话 / 产出文件名都在里头",
});
if (!picked) { prog(dim("没接,还在原来这条\n")); return; }
r = { kind: "ok", row: picked.row };
}
if (r.kind === "none") { prog(yellow(`没认出「${r.arg}」——${r.why}。/resume 不带参数看最近这些\n`)); return; }
if (r.kind === "many") {
prog(yellow(`「${r.arg}」对得上好几条:${r.rows.map((x) => `${x.n} ${x.title || x.id}`).join("、")}。写序号或者写全一点\n`));
return;
}
const row = r.row;
if (row.id === sessionId) { prog(dim("本来就在这条会话里\n")); return; }
// 读不出来就当场停,绝不「接了一个空的」——那等于把人的旧对话悄悄换成一张白纸,
// 而他下一句话是冲着旧对话说的,模型却一无所知
const f = sessFileOf(row.id);
const loaded = store.readJson(f, null);
if (!loaded || typeof loaded !== "object") {
prog(red(`${row.id} 这条读不出来(${f}),没给你接过去,当前这条一点没动\n`));
return;
}
const leaving = sessionId;
sessionId = row.id;
sessFile = f;
adoptSess({ history: [], transcript: [], title: "", ...loaded });
const turns = (sess.transcript || []).filter((t) => t && t.type === "user").length;
prog(dim(`接上了${row.from}会话 ${sessionId}${sess.title ? ":" + sess.title : ""}(${turns} 轮)\n`));
// 接过来的上下文是要花钱的:一条跑过二十轮的会话接过来,下一句话就带着那二十轮一起发出去。
// 不印这行的话,人只会在账单上发现
prog(dim(contextLine() + "\n"));
const last = (sess.transcript || []).filter((t) => t && t.type === "user").pop();
if (last && last.text) prog(dim(`上次问到:${String(last.shown || last.text).replace(/\s+/g, " ").slice(0, 60)}\n`));
if (sess.goal && sess.goal.status === "active") printGoalCard(sess.goal);
// 两边轮流接着聊没事:每一轮开跑前都会重读盘上那份。剩下的风险只有「两头同时在跑」——
// 网页那头已经会拒(终端在跑它回 409),这边开跑时也会提醒;这里先把规矩说在前头
if (row.from === "桌面") prog(yellow("这条是桌面端开的:两边轮流接着聊没问题,别让两头同时跑\n"));
prog(dim(`刚才那条还在:/resume ${leaving}\n`));
return;
}
if (v.name === "session") { prog(dim(`${sessionId}\n${sessFile}\n`)); return; }
if (v.name === "model") {
const engMod = require("./src/engines");
const opt = (config.agent || {}).engine_options || {};
let det = [];
try { det = await engMod.detectAll(opt); } catch (e) { prog(yellow(`本机引擎探测不了(${e.message}),先只列模型\n`)); }
const byId = new Map(det.map((d) => [d.id, d]));
const provs = Array.isArray(config.providers) ? config.providers : [];
const rows = repl.modelRows({
engines: engMod.list().map((b) => ({
id: b.id, label: b.label,
installed: byId.has(b.id) ? byId.get(b.id).installed : false,
install: (byId.get(b.id) || b).install || "",
})),
models: (config.models || []).map((m) => ({
name: m.name, model: m.model,
channelName: (provs.find((p) => p.id === m.channel) || {}).name || "",
})),
engine: cfgEngine(),
activeModel: config.active_model,
});
let r = repl.pickModelRow(rows, v.arg);
if (r.kind === "list") {
if (!pickerUsable()) { prog(repl.modelListText(rows)); return; }
const picked = await chooseFrom(repl.modelPickerRows(rows), {
title: "openworkbuddy> 这趟活儿谁来干?", verb: "换过去",
hint: "打字就筛,型号名和厂商都在里头",
});
if (!picked) { prog(dim("没换,还是刚才那个\n")); return; }
r = { kind: "ok", row: picked.row };
}
if (r.kind === "none") { prog(yellow(`没认出「${r.arg}」——${r.why}。/model 不带参数看选单\n`)); return; }
if (r.kind === "many") {
prog(yellow(`「${r.arg}」对得上好几条:${r.rows.map((x) => `${x.n} ${x.label}`).join("、")}。写序号或者写全一点\n`));
return;
}
const row = r.row;
if (row.current) { prog(dim(`本来用的就是 ${row.label}\n`)); return; }
if (!row.ready) {
// 不许静默降级:选了个没装的引擎,就当场说清楚,绝不偷偷退回内置去花 API 的钱
prog(red(`${row.label} 这台机器上没装,没法切。${row.install ? "装它:" + row.install : ""}\n`));
return;
}
config.agent = config.agent || {};
if (row.kind === "engine") {
config.agent.engine = row.key;
prog(dim(`换成 ${row.label};这趟活儿交给它跑,不花 API 额度\n`));
} else {
const was = cfgEngine();
config.agent.engine = "builtin";
config.active_model = row.key;
llmImpl = createLLM(config); // 壳子不动,里层换掉——已经建好的 runtime 下一轮就用新的
if (was !== "builtin") {
// 顺手改了另一个字段,必须说出来:不说的话,用户以为只换了模型,
// 实际上连「谁来跑」都换了,账单也从订阅挪回了 API
const old = engMod.get(was);
prog(dim(`底层引擎从「${(old && old.label) || was}」扳回内置——不扳的话选模型不起作用\n`));
}
prog(dim(`换成 ${row.label}(${llm.model});只管这一趟,配置文件没动\n`));
}
return;
}
if (v.name === "status") {
const eng = require("./src/engines").get(cfgEngine());
const who = eng ? `底层 ${eng.label}` + green("(不花 API 额度)") : `模型 ${llm.provider}(${llm.model})`;
const turns = (sess.transcript || []).filter((t) => t.type === "user").length;
prog(dim(`模式 ${opts.mode} · ${who}\n工作目录 ${getWorkspaceDir()}\n会话 ${sessionId} · 跑过 ${turns} 轮\n`));
// 每趟活儿开跑前带上的项目规范:写了没生效最难查,这里摆出来是哪几份、各带了多少字
const memos = projectMemo.memoFiles(getWorkspaceDir(), { warn: () => {} });
prog(dim(memos.length
? `项目规范 ${memos.map((m) => m.body === null ? `${m.rel}(超上限没带上)` : `${m.rel}(${m.chars} 字)`).join("、")}\n`
: "项目规范 没有(/init 可以在工作目录生成一份 AGENTS.md)\n"));
let costOf = null;
// 带上 config:本机模型 0 元认的是它挂的渠道地址(pricing.isPrivateBase),不再看型号名
try { const pr = require("./src/core/billing/pricing"); costOf = (u) => pr.costOf(u, { local: !!u.local, config }); } catch {}
prog(dim(repl.sessionUsageText(sess.transcript, costOf) + "\n"));
prog(dim(contextLine() + "\n"));
if (pending.length) prog(dim(`还带着没发出去的文件:${pending.join("、")}\n`));
return;
}
if (v.name === "cd") {
// 底下的 setWorkspaceDir 只收绝对路径,.. 和 ~ 在这儿先翻译好——
// 改写前 /cd .. 和 /cd ~/项目 一律报「工作空间必须是绝对路径」
const target = repl.resolveCd(v.arg, getWorkspaceDir(), os.homedir());
if (!target) { prog(dim(`当前工作目录 ${getWorkspaceDir()}\n`)); return; }
try { setWorkspaceDir(target); inPlaceRoot = getWorkspaceDir(); prog(dim(`工作目录换到 ${getWorkspaceDir()}\n`)); }
catch (e) { prog(red(`换不过去:${e.message}\n`)); }
return;
}
if (v.name === "files") {
try {
const names = fs.readdirSync(getWorkspaceDir()).filter((f) => !f.startsWith("."));
process.stdout.write((names.join("\n") || "(空)") + "\n");
} catch (e) { prog(red(`看不了:${e.message}\n`)); }
return;
}
if (v.name === "open") {
if (v.arg) { openCliFile(v.arg); return; }
const dir = getWorkspaceDir();
let names = [];
try { names = fs.readdirSync(dir).filter((f) => !f.startsWith(".")); } catch {}
const hit = termImage.resolveTarget(v.arg, names, lastFiles);
if (hit.kind === "ambiguous") {
prog(yellow(`${v.arg} 对上了 ${hit.candidates.length} 个:${hit.candidates.slice(0, 5).join("、")};说全一点\n`));
return;
}
if (hit.kind === "outofrange") {
prog(yellow(hit.count ? `刚才那行只列了 ${hit.count} 个;/files 看全部\n` : `还没产出过东西;/files 看工作目录里有什么\n`));
return;
}
if (hit.kind === "missing") { prog(yellow(`工作目录里没有 ${v.arg};/files 看有什么\n`)); return; }
// kind === "dir":不给名字 = 打开工作目录本身,人到访达/资源管理器里自己挑
const target = hit.kind === "file" ? path.join(dir, hit.name) : dir;
const { cmd, args } = termImage.openerFor(process.platform, target);
// 不加 windowsHide:要的就是打开一扇给人看的窗口
const r = spawnSync(cmd, args, { stdio: "ignore" });
// 打不开的原因就两种:没有这个命令(Linux 精简装没 xdg-open),或者系统没给它配默认程序。
// 两种都不该只说一句「失败」——把绝对路径给出去,人至少能自己复制过去打开
if (r.error || r.status) prog(yellow(`打不开(${r.error ? r.error.code || r.error.message : "退出码 " + r.status}):${target}\n`));
else prog(dim(`已交给系统打开:${path.basename(target)}\n`));
return;
}
if (v.name === "paste") {
// 剪贴板里可能躺着三样东西,按「复制的文件 > 截图位图 > 一大段文字」的顺序认。
// 顺序有讲究:在访达里 Cmd+C 一个图片文件,剪贴板里同时有文件引用和这张图的位图,
// 先认文件带进来的是原图,先认位图就成了一张重新编码、名字是时间戳的 PNG
const dir = getWorkspaceDir();
const dest = path.join(dir, attach.freeName(dir, attach.stampName("粘贴图", "png"), new Set(), fs));
let r;
try { r = attach.readClipboard({ dest }); }
catch (e) { prog(red(`读不了剪贴板:${e.message}\n`)); return; }
if (r.kind === "unsupported") { prog(yellow(`${r.why};把文件直接拖进来也一样\n`)); return; }
if (r.kind === "empty") { prog(dim("剪贴板里没有能带进来的东西(复制的文件、截图、或者一大段文字)\n")); return; }
if (r.kind === "text") {
// 短的直接填进输入行让人接着改;几千字塞进一行,光标一动整个屏幕就乱了,所以长的存成文件
if (r.text.length <= attach.BIG_TEXT_CHARS) { loadComposer(r.text.replace(/\r\n?/g, "\n").trim()); return; }
const name = attach.freeName(dir, attach.stampName("粘贴文本", "txt"), new Set(), fs);
try { fs.writeFileSync(path.join(dir, name), r.text); }
catch (e) { prog(red(`存不下来:${e.message}\n`)); return; }
strayUploads.add(name);
if (!pending.includes(name)) pending.push(name);
prog(dim(` ${r.text.length} 字,存成 ${name} 了;接着打你要问的\n`));
return;
}
const got = r.kind === "files" ? r.paths.map((x) => ({ ref: x, path: x })) : [{ ref: r.file, path: r.file }];
// 位图那条本来就写在工作目录里,collect 认得出「已经在里面了」,不会再复制一份
if (r.kind !== "files") strayUploads.add(path.basename(dest));
const names = bringIn(got);
for (const n of names) if (!pending.includes(n)) pending.push(n);
if (names.length) prog(dim(` 带上了 ${names.join("、")};接着打你要问的\n`));
return;
}
if (v.name === "drop") {
if (!pending.length && !shellNotes.length) { prog(dim("本来就没带着什么\n")); return; }
// 只是不往这句话上挂了,文件不删:删掉的可能正是人刚拖进来、还打算用的那份
if (pending.length) prog(dim(`不带了:${pending.join("、")}(文件还在工作目录里,/files 看得到)\n`));
if (shellNotes.length) prog(dim(`刚才那 ${shellNotes.length} 条 !命令 的输出也不带了\n`));
pending.length = 0;
shellNotes.length = 0;
return;
}
};
prog(dim("/help 命令 · /model 换模型 · /open 打开成果 · Esc 停止\n"));
let last = "ok";
rl.prompt();
for (;;) {
const line = await nextInput();
if (line === null) { process.stdout.write("\n"); break; } // Ctrl+D / 关掉了:正常收尾,不挂死
let v = repl.parse(line, { custom: reloadCustom().list });
if (v.kind === "blank") {
if (pending.length) prog(dim(` 还带着 ${pending.join("、")};打一句要问的就一块儿发出去,不要了敲 /drop\n`));
rl.prompt();
continue;
}
if (v.kind === "unknown") { prog(yellow(repl.unknownText(v))); rl.prompt(); continue; }
if (v.kind === "bad-arg") { prog(yellow(repl.badArgText(v))); rl.prompt(); continue; }
if (v.kind === "task" && cliSurface.openRequest(v.text) && !pending.length && !shellNotes.length) {
naturalOpen(v.text);
rl.prompt();
continue;
}
if (v.kind === "shell") {
const r = await runShell(v.cmd);
if (r.error) prog(red(`没跑起来:${r.error}\n`));
else if (r.signal) prog(yellow(`(停了:${r.signal})\n`));
else if (r.code) prog(yellow(`(退出码 ${r.code})\n`));
shellNotes.push(repl.shellNote({ cmd: v.cmd, ...r }));
// 头一条说一次就够:连敲五条 ls,每条后面都跟一句同样的提示就成了噪音
if (shellNotes.length === 1) prog(dim(" 输出会跟着你下一句话带给它;不想带就敲 /drop\n"));
quitArmed = 0;
rl.prompt();
continue;
}
// 命令现场交出来的那趟活儿(/init):不再过 splitFiles——那一步是摘「人拖进来的文件」的,
// 拿它去扫一句现成的话,会把 AGENTS.md 这种词当附件摘走,剩下的句子当场缺一块
let 现成的 = "";
let 这趟模式 = opts.mode;
if (v.kind === "custom") {
const c = custom.list.find((x) => x.name === v.name);
现成的 = customCmds.expand(c.body, v.arg);
prog(dim(`/${c.name} → ${现成的.replace(/\s+/g, " ").slice(0, 60)}${现成的.length > 60 ? "…" : ""}\n`));
v = { kind: "task", text: 现成的 };
}
if (v.kind === "cmd") {
if (v.name === "exit") break;
let 交出来的 = await runReplCommand(v);
// 命令可以连模式一起交出来:/review 必须按只看不动跑,不管当前是哪个模式
if (交出来的 && typeof 交出来的 === "object") { 这趟模式 = 交出来的.mode || opts.mode; 交出来的 = 交出来的.prompt; }
if (typeof 交出来的 !== "string" || !交出来的.trim()) { promptKeep(rl); continue; } // /paste 摆回来的字,光标得在末尾
现成的 = 交出来的;
v = { kind: "task", text: 现成的 };
}
// 这一行里带进来的文件:拖进来的、粘路径进来的、@ 补出来的,都在这儿摘出去,剩下的才是要问的话
const spl = 现成的 ? { files: [], missing: [], text: 现成的 } : splitFiles(v.text);
for (const m of spl.missing) prog(yellow(` !${m} 找不到,当普通文字发过去了\n`));
for (const n of bringIn(spl.files)) if (!pending.includes(n)) pending.push(n);
const body = spl.files.length ? spl.text : v.text;
if (!body) {
// 只把文件拖进来、还没说要干什么:先攒着,等下一句
prog(dim(` 带上了 ${pending.join("、")};接着打你要问的,不要了敲 /drop\n`));
rl.prompt();
continue;
}
inbox.setBusy(true);
menuClose(); // 活儿要开跑了,菜单先收掉——正文一冲下来它就成了屏幕上的残渣
rl.setPrompt(""); // 任务跑着的时候别让提示符插进流式正文里
const 这句 = attach.withNote(body, pending.splice(0));
const notes = shellNotes.splice(0);
// 拼了 !命令 输出的时候,会话里显示的还是人自己打的那句(shown),模型那边收到的是全的
last = await runOnce(runtime, repl.withShellNotes(这句, notes), 这趟模式, true, notes.length ? 这句 : undefined);
// An external engine cannot consume mid-turn input. Deliver any unconsumed
// supplement in a new turn on the same session instead of silently losing it.
while (last === "ok" && termInterject.length) {
const more = termInterject.splice(0).filter(t => t.trim()).join("\n");
if (!more) continue;
prog(dim("继续处理补充要求…\n"));
last = await runOnce(runtime, more, 这趟模式, true);
}
if (last !== "ok" && termInterject.length) {
const more = termInterject.splice(0).filter(t => t.trim()).join(" / ");
if (more) prog(yellow(`补充要求尚未执行,请重新发送:${more}\n`));
}
inbox.setBusy(false);
quitArmed = 0;
// Plan 出完计划:摆「开干 / 接着改」让人挑,别让他自己去想下一步该敲什么
const 下一步 = repl.planNextMode({ mode: 这趟模式, result: last, usable: pickerUsable(), typed: repl.composeText(multi, rl.line) });
if (下一步 === "hint") prog(dim(repl.PLAN_NEXT_HINT));
if (下一步 === "pick") {
const picked = await chooseFrom(repl.planNextRows(), {
title: "openworkbuddy> 计划写好了,接下来?", verb: "定", hint: "Esc 先不选,直接打字说别的",
});
if (picked && picked.id === "go") {
// 切过去就留在 Craft:计划做完多半还有收尾要改,再切回 Plan 是人自己的事
opts.mode = "craft";
prog(dim(`已经切到 ${modes.modeLabel("craft")},照计划开干\n`));
inbox.setBusy(true);
last = await runOnce(runtime, repl.PLAN_GO_TEXT, "craft", true);
inbox.setBusy(false);
quitArmed = 0;
} else if (picked) prog(dim("还在 Plan:下一句说哪儿要改\n"));
}
composeFrom = PROMPT;
if (multi.length) reshowHeld(); // 跑着的时候攒了几行:正文把它们冲远了,摆回提示符上面
rl.setPrompt(multi.length ? CONT : PROMPT);
promptKeep(rl); // 跑着的时候敲了没回车的字还在这一行上,接着打得接在后面
}
saveHistory();
rl.close();
mcpManager.stopAll();
process.exit(last === "ok" ? 0 : last === "aborted" ? 130 : 1);
})();