@24klynx/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/break-cache-B716oddK.mjs +71 -0
- package/dist/break-cache-B716oddK.mjs.map +1 -0
- package/dist/bughunter-DeAizlBM.mjs +32 -0
- package/dist/bughunter-DeAizlBM.mjs.map +1 -0
- package/dist/clear-C1dFE5aD.mjs +24 -0
- package/dist/clear-C1dFE5aD.mjs.map +1 -0
- package/dist/config-D-xVXTXi.mjs +2 -0
- package/dist/config-Des0z-k9.mjs +147 -0
- package/dist/config-Des0z-k9.mjs.map +1 -0
- package/dist/context-BmZ8VEan.mjs +128 -0
- package/dist/context-BmZ8VEan.mjs.map +1 -0
- package/dist/context-viz-2ZZaTL2C.mjs +61 -0
- package/dist/context-viz-2ZZaTL2C.mjs.map +1 -0
- package/dist/env-CeeZcoDI.mjs +55 -0
- package/dist/env-CeeZcoDI.mjs.map +1 -0
- package/dist/git-branch-Dn1CP6An.mjs +96 -0
- package/dist/git-branch-Dn1CP6An.mjs.map +1 -0
- package/dist/headless-launcher-I8NWyD6k.mjs +171 -0
- package/dist/headless-launcher-I8NWyD6k.mjs.map +1 -0
- package/dist/index.d.mts +970 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3243 -0
- package/dist/index.mjs.map +1 -0
- package/dist/memory-gnURjOnQ.mjs +199 -0
- package/dist/memory-gnURjOnQ.mjs.map +1 -0
- package/dist/privacy-B6Rm1Xck.mjs +114 -0
- package/dist/privacy-B6Rm1Xck.mjs.map +1 -0
- package/dist/process-lifecycle-Dg6n2QS-.mjs +784 -0
- package/dist/process-lifecycle-Dg6n2QS-.mjs.map +1 -0
- package/dist/sandbox-toggle-9akjTw3h.mjs +64 -0
- package/dist/sandbox-toggle-9akjTw3h.mjs.map +1 -0
- package/dist/stats-DjKezhTJ.mjs +73 -0
- package/dist/stats-DjKezhTJ.mjs.map +1 -0
- package/dist/status-B3Tw-Ef4.mjs +92 -0
- package/dist/status-B3Tw-Ef4.mjs.map +1 -0
- package/dist/upgrade-CREWRNeC.mjs +72 -0
- package/dist/upgrade-CREWRNeC.mjs.map +1 -0
- package/package.json +39 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3243 @@
|
|
|
1
|
+
import { a as endSync, c as withSync, d as runPhase2WithContext, f as bootstrap, i as beginSync, l as runPhase1, n as onCleanup, o as enterFullscreen, r as uninstallProcessLifecycle, s as exitFullscreen, t as installProcessLifecycle, u as runPhase2 } from "./process-lifecycle-Dg6n2QS-.mjs";
|
|
2
|
+
import { a as saveConfig, i as resolveProvider, n as loadConfig, r as resolveConfigEnv, t as handleConfigCommand } from "./config-Des0z-k9.mjs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { asSessionId, migrate, openDatabase, resolvePaths } from "@lynx/core";
|
|
5
|
+
import { createSessionManager, getLastSession } from "@lynx/session";
|
|
6
|
+
import { assembleSystemPrompt } from "@lynx/agent";
|
|
7
|
+
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { cpus, homedir, platform, release } from "node:os";
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { request } from "node:https";
|
|
14
|
+
import { Worker } from "node:worker_threads";
|
|
15
|
+
import { writeHeapSnapshot } from "node:v8";
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
//#region \0rolldown/runtime.js
|
|
18
|
+
var __defProp = Object.defineProperty;
|
|
19
|
+
var __exportAll = (all, no_symbols) => {
|
|
20
|
+
let target = {};
|
|
21
|
+
for (var name in all) __defProp(target, name, {
|
|
22
|
+
get: all[name],
|
|
23
|
+
enumerable: true
|
|
24
|
+
});
|
|
25
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
26
|
+
return target;
|
|
27
|
+
};
|
|
28
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/args.ts
|
|
31
|
+
/**
|
|
32
|
+
* CLI argument parsing — commander setup with all sub‑commands.
|
|
33
|
+
*
|
|
34
|
+
* Each command defers its handler to a lazily‑loaded module
|
|
35
|
+
* so startup remains fast (Phase 1 ≤ 1.5s).
|
|
36
|
+
*/
|
|
37
|
+
var args_exports = /* @__PURE__ */ __exportAll({
|
|
38
|
+
createProgram: () => createProgram,
|
|
39
|
+
runCli: () => runCli
|
|
40
|
+
});
|
|
41
|
+
/** Create the full CLI program with all sub‑commands registered. */
|
|
42
|
+
function createProgram() {
|
|
43
|
+
const program = new Command();
|
|
44
|
+
program.name("lynx").description("AI CLI — 多渠道 Agent 网关").version("0.0.0");
|
|
45
|
+
program.command("doctor").description("运行系统健康检查").action(async () => {
|
|
46
|
+
const { runDoctor } = await Promise.resolve().then(() => runner_exports$1);
|
|
47
|
+
await runDoctor();
|
|
48
|
+
});
|
|
49
|
+
program.command("crestodian").description("非交互式 LLM 查询").requiredOption("--message <text>", "要发送的消息").option("--model <model>", "要使用的模型").action(async (opts) => {
|
|
50
|
+
const { runCrestodian } = await Promise.resolve().then(() => runner_exports);
|
|
51
|
+
await runCrestodian(opts.message, opts.model);
|
|
52
|
+
});
|
|
53
|
+
program.command("sessions").description("列出、恢复或派生会话").option("--action <action>", "list | resume <id> | fork <id> | delete <id>", "list").option("--id <id>", "Session ID for resume/fork/delete").option("--label <label>", "New label for fork").action(async (opts) => {
|
|
54
|
+
const { handleSessionCommand } = await Promise.resolve().then(() => session_exports);
|
|
55
|
+
await handleSessionCommand(opts);
|
|
56
|
+
});
|
|
57
|
+
program.command("config").description("显示或设置配置项").option("--show", "Show current configuration").option("--set <key>", "Set a configuration key").option("--value <value>", "Value for --set").option("--path", "Show config file path").action(async (opts) => {
|
|
58
|
+
const { handleConfigCommand } = await import("./config-D-xVXTXi.mjs");
|
|
59
|
+
await handleConfigCommand(opts);
|
|
60
|
+
});
|
|
61
|
+
program.command("start").description("以无头后台模式启动(无 TUI)").option("--headless", "以无头模式运行(守护进程模式必需)").option("--feishu-app-id <id>", "飞书 App ID(覆盖 FEISHU_APP_ID 环境变量)").option("--feishu-app-secret <secret>", "飞书 App Secret(覆盖 FEISHU_APP_SECRET 环境变量)").option("--model <model>", "要使用的模型").option("--verbose", "启用详细日志").action(async (opts) => {
|
|
62
|
+
if (!opts.headless) {
|
|
63
|
+
process.stderr.write("错误:'lynx start' 需要 --headless 参数。\n");
|
|
64
|
+
process.stderr.write("用法:lynx start --headless [--verbose]\n");
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const { startHeadless } = await import("./headless-launcher-I8NWyD6k.mjs");
|
|
69
|
+
const feishu = opts.feishuAppId && opts.feishuAppSecret ? {
|
|
70
|
+
appId: opts.feishuAppId,
|
|
71
|
+
appSecret: opts.feishuAppSecret
|
|
72
|
+
} : void 0;
|
|
73
|
+
await startHeadless({
|
|
74
|
+
model: opts.model,
|
|
75
|
+
verbose: opts.verbose,
|
|
76
|
+
feishu
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
program.command("plugin").description("管理插件(列出、启用、禁用、安装、移除)").option("--list", "List installed plugins").option("--enable <name>", "Enable a plugin").option("--disable <name>", "Disable a plugin").option("--install <path>", "Install a plugin from a local path").option("--remove <name>", "Remove an installed plugin").action(async (opts) => {
|
|
80
|
+
const { handlePluginCommand } = await Promise.resolve().then(() => plugin_exports);
|
|
81
|
+
await handlePluginCommand(opts);
|
|
82
|
+
});
|
|
83
|
+
program.command("share").description("将会话导出为可分享的 Markdown 文件").option("--session-id <id>", "要导出的会话 ID(默认最近一次会话)").option("--output <path>", "输出文件路径").action(async (opts) => {
|
|
84
|
+
const { handleShareCommand } = await Promise.resolve().then(() => share_exports);
|
|
85
|
+
const result = await handleShareCommand(opts);
|
|
86
|
+
process.stdout.write(`${result.output}\n`);
|
|
87
|
+
});
|
|
88
|
+
program.command("export").description("以多种格式导出会话(markdown / json / html)").option("--session-id <id>", "要导出的会话 ID(默认最近一次会话)").option("--format <fmt>", "输出格式:markdown、json 或 html", "markdown").option("--output <path>", "输出文件路径").action(async (opts) => {
|
|
89
|
+
const { handleExportCommand } = await Promise.resolve().then(() => export_exports);
|
|
90
|
+
const result = await handleExportCommand(opts);
|
|
91
|
+
process.stdout.write(`${result.output}\n`);
|
|
92
|
+
});
|
|
93
|
+
program.command("tag").description("为会话添加、移除或列出标签").option("--session-id <id>", "目标会话 ID(默认最近一次会话)").option("--tag <tag>", "标签内容").option("--action <action>", "操作:add、remove 或 list", "list").action(async (opts) => {
|
|
94
|
+
const { handleTagCommand } = await Promise.resolve().then(() => tag_exports);
|
|
95
|
+
const result = await handleTagCommand(opts);
|
|
96
|
+
process.stdout.write(`${result.output}\n`);
|
|
97
|
+
});
|
|
98
|
+
program.command("copy").description("将最后一条助手回复复制到系统剪贴板").option("--session-id <id>", "目标会话 ID(默认最近一次会话)").option("--index <n>", "从末尾倒数第几条消息(默认 0 = 最后一条)", "0").action(async (opts) => {
|
|
99
|
+
const { handleCopyCommand } = await Promise.resolve().then(() => copy_exports);
|
|
100
|
+
const result = await handleCopyCommand({
|
|
101
|
+
sessionId: opts.sessionId,
|
|
102
|
+
index: opts.index ? Number(opts.index) : 0
|
|
103
|
+
});
|
|
104
|
+
process.stdout.write(`${result.output}\n`);
|
|
105
|
+
});
|
|
106
|
+
program.command("clear").description("清空当前会话消息,开始全新对话").option("--session-id <id>", "目标会话 ID(默认当前会话)").action(async (opts) => {
|
|
107
|
+
const { handleClearCommand } = await import("./clear-C1dFE5aD.mjs");
|
|
108
|
+
const result = handleClearCommand({ sessionId: opts.sessionId });
|
|
109
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
110
|
+
});
|
|
111
|
+
program.command("env").description("显示 Lynx 环境变量和运行环境信息").action(async () => {
|
|
112
|
+
const { handleEnvCommand } = await import("./env-CeeZcoDI.mjs");
|
|
113
|
+
const result = handleEnvCommand();
|
|
114
|
+
process.stdout.write(`${result.output}\n`);
|
|
115
|
+
});
|
|
116
|
+
program.command("context").description("显示 IDE 连接状态和活跃文件信息").action(async () => {
|
|
117
|
+
const { handleContextCommand } = await import("./context-BmZ8VEan.mjs");
|
|
118
|
+
const result = handleContextCommand();
|
|
119
|
+
process.stdout.write(`${result.output}\n`);
|
|
120
|
+
});
|
|
121
|
+
program.command("context-viz").description("可视化当前上下文窗口的 token 使用情况").action(async () => {
|
|
122
|
+
const { handleContextVizCommand } = await import("./context-viz-2ZZaTL2C.mjs");
|
|
123
|
+
const result = handleContextVizCommand();
|
|
124
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
125
|
+
});
|
|
126
|
+
program.command("stats").description("会话统计信息(token 消耗、工具调用频率等)").option("--session-id <id>", "目标会话 ID(默认当前会话)").option("--period <period>", "统计时间范围:today、week 或 all", "all").action(async (opts) => {
|
|
127
|
+
const { handleStatsCommand } = await import("./stats-DjKezhTJ.mjs");
|
|
128
|
+
const result = handleStatsCommand({
|
|
129
|
+
sessionId: opts.sessionId,
|
|
130
|
+
period: opts.period
|
|
131
|
+
});
|
|
132
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
133
|
+
});
|
|
134
|
+
program.command("status").description("系统健康仪表盘(MCP、Channel、Agent、磁盘等)").action(async () => {
|
|
135
|
+
const { handleStatusCommand } = await import("./status-B3Tw-Ef4.mjs");
|
|
136
|
+
const result = handleStatusCommand();
|
|
137
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
138
|
+
});
|
|
139
|
+
program.command("bughunter").description("只读诊断模式,系统排查问题并给出修复建议").option("--issue <text>", "具体的问题描述或错误消息").action(async (opts) => {
|
|
140
|
+
const { handleBughunterCommand } = await import("./bughunter-DeAizlBM.mjs");
|
|
141
|
+
const result = handleBughunterCommand({ issue: opts.issue });
|
|
142
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
143
|
+
});
|
|
144
|
+
program.command("upgrade").description("自助升级 Lynx 到最新版本").option("--check", "仅检查依赖更新,不执行升级").option("--version <ver>", "指定目标版本(暂未实现)").action(async (opts) => {
|
|
145
|
+
const { handleUpgradeCommand } = await import("./upgrade-CREWRNeC.mjs");
|
|
146
|
+
const result = handleUpgradeCommand({
|
|
147
|
+
check: opts.check,
|
|
148
|
+
version: opts.version
|
|
149
|
+
});
|
|
150
|
+
process.stdout.write(`${result.output}\n`);
|
|
151
|
+
});
|
|
152
|
+
program.command("sandbox-toggle").description("切换 Bash 沙箱模式开关(启用 / 禁用)").option("--enable", "强制启用沙箱").option("--disable", "强制禁用沙箱").action(async (opts) => {
|
|
153
|
+
const { handleSandboxToggleCommand } = await import("./sandbox-toggle-9akjTw3h.mjs");
|
|
154
|
+
const result = handleSandboxToggleCommand({
|
|
155
|
+
enable: opts.enable,
|
|
156
|
+
disable: opts.disable
|
|
157
|
+
});
|
|
158
|
+
process.stdout.write(`${result.output}\n`);
|
|
159
|
+
});
|
|
160
|
+
program.command("privacy").description("隐私设置面板(允许列表、禁止列表、分析共享)").option("--show", "显示当前隐私设置").option("--allow <domain>", "将域名添加到允许列表").option("--deny <domain>", "将域名添加到禁止列表").action(async (opts) => {
|
|
161
|
+
const { handlePrivacyCommand } = await import("./privacy-B6Rm1Xck.mjs");
|
|
162
|
+
const result = handlePrivacyCommand({
|
|
163
|
+
show: opts.show,
|
|
164
|
+
allow: opts.allow,
|
|
165
|
+
deny: opts.deny
|
|
166
|
+
});
|
|
167
|
+
process.stdout.write(`${result.output}\n`);
|
|
168
|
+
});
|
|
169
|
+
program.command("branch").description("管理 Git 分支:列出、创建、切换、删除").option("--action <action>", "操作类型:list、create、switch 或 delete", "list").option("--name <name>", "分支名称(create / switch / delete 时必填)").option("--base <base>", "创建分支时的基准分支(仅 create 时有效)").action(async (opts) => {
|
|
170
|
+
const { handleBranchCommand } = await import("./git-branch-Dn1CP6An.mjs");
|
|
171
|
+
const result = handleBranchCommand({
|
|
172
|
+
action: opts.action,
|
|
173
|
+
name: opts.name,
|
|
174
|
+
base: opts.base
|
|
175
|
+
});
|
|
176
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
177
|
+
});
|
|
178
|
+
program.command("memory").description("记忆管理 — 列出、搜索、添加、删除、去重、导出记忆").option("--action <action>", "操作:list(默认)、search、add、delete、compact、export", "list").option("--query <query>", "搜索查询词或条目名称").option("--name <name>", "条目名称(用于 add/delete)").action(async (opts) => {
|
|
179
|
+
const { handleMemoryCommand } = await import("./memory-gnURjOnQ.mjs");
|
|
180
|
+
const result = handleMemoryCommand({
|
|
181
|
+
action: opts.action,
|
|
182
|
+
query: opts.query,
|
|
183
|
+
name: opts.name
|
|
184
|
+
});
|
|
185
|
+
process.stdout.write(`${result.instruction}\n`);
|
|
186
|
+
});
|
|
187
|
+
program.command("break-cache").description("清除指定缓存(prompt / transcript / tool / all)").option("--cache <type>", "缓存类型:prompt、transcript、tool 或 all", "all").action(async (opts) => {
|
|
188
|
+
const { handleBreakCacheCommand } = await import("./break-cache-B716oddK.mjs");
|
|
189
|
+
const result = await handleBreakCacheCommand({ cache: opts.cache });
|
|
190
|
+
process.stdout.write(`${result.output}\n`);
|
|
191
|
+
});
|
|
192
|
+
return program;
|
|
193
|
+
}
|
|
194
|
+
/** Parse argv and run the matching command. */
|
|
195
|
+
async function runCli(argv = process.argv) {
|
|
196
|
+
await createProgram().parseAsync(argv);
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/catalog.ts
|
|
200
|
+
const READONLY = {
|
|
201
|
+
permissionMode: "default",
|
|
202
|
+
allowWrites: false
|
|
203
|
+
};
|
|
204
|
+
const FULL_ACCESS = {
|
|
205
|
+
permissionMode: "auto",
|
|
206
|
+
allowWrites: true
|
|
207
|
+
};
|
|
208
|
+
/** The master list of registered commands. */
|
|
209
|
+
const CATALOG = [
|
|
210
|
+
{
|
|
211
|
+
name: "doctor",
|
|
212
|
+
description: "Run system health checks",
|
|
213
|
+
policy: READONLY,
|
|
214
|
+
handlerPath: "./doctor/runner.js"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "crestodian",
|
|
218
|
+
description: "Non‑interactive LLM query",
|
|
219
|
+
policy: {
|
|
220
|
+
...READONLY,
|
|
221
|
+
permissionMode: "headless"
|
|
222
|
+
},
|
|
223
|
+
handlerPath: "./crestodian/runner.js"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "sessions",
|
|
227
|
+
description: "List, resume, or fork sessions",
|
|
228
|
+
policy: {
|
|
229
|
+
...FULL_ACCESS,
|
|
230
|
+
allowedTools: []
|
|
231
|
+
},
|
|
232
|
+
handlerPath: "./commands/session.js"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "config",
|
|
236
|
+
description: "Show or set configuration values",
|
|
237
|
+
policy: {
|
|
238
|
+
...READONLY,
|
|
239
|
+
allowWrites: true
|
|
240
|
+
},
|
|
241
|
+
handlerPath: "./commands/config.js"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "plugin",
|
|
245
|
+
description: "Manage installed plugins (list, install, remove, enable, disable)",
|
|
246
|
+
policy: {
|
|
247
|
+
...READONLY,
|
|
248
|
+
allowWrites: true
|
|
249
|
+
},
|
|
250
|
+
handlerPath: "./commands/plugin.js"
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "sed",
|
|
254
|
+
description: "在文件上进行正则查找替换",
|
|
255
|
+
policy: {
|
|
256
|
+
permissionMode: "default",
|
|
257
|
+
allowedTools: ["read_file", "write_file"],
|
|
258
|
+
allowWrites: true
|
|
259
|
+
},
|
|
260
|
+
handlerPath: "./commands/sed-edit.js"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "commit",
|
|
264
|
+
description: "创建 Git 提交并推送(约定式提交)",
|
|
265
|
+
policy: {
|
|
266
|
+
permissionMode: "default",
|
|
267
|
+
allowedTools: ["bash"],
|
|
268
|
+
allowWrites: false
|
|
269
|
+
},
|
|
270
|
+
handlerPath: "./commands/git-commit.js"
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "branch",
|
|
274
|
+
description: "管理 Git 分支:列出、创建、切换、删除",
|
|
275
|
+
policy: {
|
|
276
|
+
permissionMode: "default",
|
|
277
|
+
allowedTools: ["bash"],
|
|
278
|
+
allowWrites: false
|
|
279
|
+
},
|
|
280
|
+
handlerPath: "./commands/git-branch.js"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "review",
|
|
284
|
+
description: "审查 GitHub Pull Request 变更",
|
|
285
|
+
policy: {
|
|
286
|
+
permissionMode: "default",
|
|
287
|
+
allowedTools: ["bash"],
|
|
288
|
+
allowWrites: false
|
|
289
|
+
},
|
|
290
|
+
handlerPath: "./commands/git-review.js"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "pr-comments",
|
|
294
|
+
description: "处理 PR 审查评论(修改代码或回复确认)",
|
|
295
|
+
policy: {
|
|
296
|
+
permissionMode: "default",
|
|
297
|
+
allowedTools: ["bash"],
|
|
298
|
+
allowWrites: false
|
|
299
|
+
},
|
|
300
|
+
handlerPath: "./commands/git-pr-comments.js"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "issue",
|
|
304
|
+
description: "管理 GitHub Issue(创建、列出、查看)",
|
|
305
|
+
policy: {
|
|
306
|
+
permissionMode: "default",
|
|
307
|
+
allowedTools: ["bash"],
|
|
308
|
+
allowWrites: false
|
|
309
|
+
},
|
|
310
|
+
handlerPath: "./commands/git-issue.js"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "autofix-pr",
|
|
314
|
+
description: "自动修复 PR 问题(开发中)",
|
|
315
|
+
policy: {
|
|
316
|
+
permissionMode: "default",
|
|
317
|
+
allowedTools: ["bash"],
|
|
318
|
+
allowWrites: false
|
|
319
|
+
},
|
|
320
|
+
handlerPath: "./commands/git-autofix.js"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "diff",
|
|
324
|
+
description: "展示 Git 工作区变更(未暂存和已暂存)",
|
|
325
|
+
policy: {
|
|
326
|
+
permissionMode: "default",
|
|
327
|
+
allowedTools: ["bash"],
|
|
328
|
+
allowWrites: false
|
|
329
|
+
},
|
|
330
|
+
handlerPath: "./commands/git-diff.js"
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "ant-trace",
|
|
334
|
+
description: "工具调用链追踪可视化",
|
|
335
|
+
policy: READONLY,
|
|
336
|
+
handlerPath: "./commands/ant-trace.js"
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: "debug-tool-call",
|
|
340
|
+
description: "单步工具调用调试器",
|
|
341
|
+
policy: READONLY,
|
|
342
|
+
handlerPath: "./commands/debug-tool-call.js"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
name: "tasks",
|
|
346
|
+
description: "后台任务监视与控制(列出、取消、查看详情)",
|
|
347
|
+
policy: READONLY,
|
|
348
|
+
handlerPath: "./commands/tasks.js"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: "heapdump",
|
|
352
|
+
description: "生成 V8 堆快照用于内存泄漏诊断",
|
|
353
|
+
policy: READONLY,
|
|
354
|
+
handlerPath: "./commands/heapdump.js"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: "memory",
|
|
358
|
+
description: "记忆管理 — 列出、搜索、添加、删除、去重、导出记忆",
|
|
359
|
+
policy: {
|
|
360
|
+
permissionMode: "default",
|
|
361
|
+
allowedTools: ["memory_write"],
|
|
362
|
+
allowWrites: true
|
|
363
|
+
},
|
|
364
|
+
handlerPath: "./commands/memory.js"
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "break-cache",
|
|
368
|
+
description: "清除指定缓存(prompt / transcript / tool / all)",
|
|
369
|
+
policy: READONLY,
|
|
370
|
+
handlerPath: "./commands/break-cache.js"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "perf-issue",
|
|
374
|
+
description: "性能问题诊断与健康检查",
|
|
375
|
+
policy: READONLY,
|
|
376
|
+
handlerPath: "./commands/perf-issue.js"
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: "share",
|
|
380
|
+
description: "将会话导出为可分享的 Markdown 文件",
|
|
381
|
+
policy: {
|
|
382
|
+
...READONLY,
|
|
383
|
+
allowWrites: true
|
|
384
|
+
},
|
|
385
|
+
handlerPath: "./commands/share.js"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "export",
|
|
389
|
+
description: "以多种格式导出会话(markdown / json / html)",
|
|
390
|
+
policy: {
|
|
391
|
+
...READONLY,
|
|
392
|
+
allowWrites: true
|
|
393
|
+
},
|
|
394
|
+
handlerPath: "./commands/export.js"
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: "tag",
|
|
398
|
+
description: "为会话添加、移除或列出标签",
|
|
399
|
+
policy: {
|
|
400
|
+
...READONLY,
|
|
401
|
+
allowWrites: true
|
|
402
|
+
},
|
|
403
|
+
handlerPath: "./commands/tag.js"
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: "copy",
|
|
407
|
+
description: "将最后一条助手回复复制到系统剪贴板",
|
|
408
|
+
policy: READONLY,
|
|
409
|
+
handlerPath: "./commands/copy.js"
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "clear",
|
|
413
|
+
description: "清空当前会话消息,开始全新对话",
|
|
414
|
+
policy: {
|
|
415
|
+
permissionMode: "default",
|
|
416
|
+
allowedTools: [],
|
|
417
|
+
allowWrites: false
|
|
418
|
+
},
|
|
419
|
+
handlerPath: "./commands/clear.js"
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "env",
|
|
423
|
+
description: "显示 Lynx 环境变量和运行环境信息",
|
|
424
|
+
policy: READONLY,
|
|
425
|
+
handlerPath: "./commands/env.js"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "context",
|
|
429
|
+
description: "显示 IDE 连接状态和活跃文件信息",
|
|
430
|
+
policy: READONLY,
|
|
431
|
+
handlerPath: "./commands/context.js"
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
name: "context-viz",
|
|
435
|
+
description: "可视化当前上下文窗口的 token 使用情况",
|
|
436
|
+
policy: READONLY,
|
|
437
|
+
handlerPath: "./commands/context-viz.js"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
name: "stats",
|
|
441
|
+
description: "会话统计信息(token 消耗、工具调用频率等)",
|
|
442
|
+
policy: {
|
|
443
|
+
permissionMode: "default",
|
|
444
|
+
allowedTools: [],
|
|
445
|
+
allowWrites: false
|
|
446
|
+
},
|
|
447
|
+
handlerPath: "./commands/stats.js"
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "status",
|
|
451
|
+
description: "系统健康仪表盘(MCP、Channel、Agent、磁盘等)",
|
|
452
|
+
policy: {
|
|
453
|
+
permissionMode: "default",
|
|
454
|
+
allowedTools: [],
|
|
455
|
+
allowWrites: false
|
|
456
|
+
},
|
|
457
|
+
handlerPath: "./commands/status.js"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "bughunter",
|
|
461
|
+
description: "只读诊断模式,系统排查问题并给出修复建议",
|
|
462
|
+
policy: READONLY,
|
|
463
|
+
handlerPath: "./commands/bughunter.js"
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "upgrade",
|
|
467
|
+
description: "自助升级 Lynx 到最新版本",
|
|
468
|
+
policy: {
|
|
469
|
+
permissionMode: "default",
|
|
470
|
+
allowedTools: ["bash"],
|
|
471
|
+
allowWrites: false
|
|
472
|
+
},
|
|
473
|
+
handlerPath: "./commands/upgrade.js"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
name: "sandbox-toggle",
|
|
477
|
+
description: "切换 Bash 沙箱模式开关(启用 / 禁用)",
|
|
478
|
+
policy: {
|
|
479
|
+
...READONLY,
|
|
480
|
+
allowWrites: true
|
|
481
|
+
},
|
|
482
|
+
handlerPath: "./commands/sandbox-toggle.js"
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
name: "privacy-settings",
|
|
486
|
+
description: "隐私设置面板(允许列表、禁止列表、分析共享)",
|
|
487
|
+
policy: {
|
|
488
|
+
...READONLY,
|
|
489
|
+
allowWrites: true
|
|
490
|
+
},
|
|
491
|
+
handlerPath: "./commands/privacy.js"
|
|
492
|
+
}
|
|
493
|
+
];
|
|
494
|
+
/** Look up a command by name. */
|
|
495
|
+
function findCommand(name) {
|
|
496
|
+
return CATALOG.find((c) => c.name === name);
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/debug.ts
|
|
500
|
+
/**
|
|
501
|
+
* Debug logging infrastructure — writes structured JSONL logs
|
|
502
|
+
* to disk for post‑mortem analysis.
|
|
503
|
+
*
|
|
504
|
+
* Uses pino for formatting and rotation. Production logs are
|
|
505
|
+
* JSONL with daily rotation; development uses pino‑pretty.
|
|
506
|
+
*/
|
|
507
|
+
const LEVEL_ORDER = {
|
|
508
|
+
trace: 10,
|
|
509
|
+
debug: 20,
|
|
510
|
+
info: 30,
|
|
511
|
+
warn: 40,
|
|
512
|
+
error: 50
|
|
513
|
+
};
|
|
514
|
+
function formatLine(level, msg, data) {
|
|
515
|
+
const entry = {
|
|
516
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
517
|
+
level,
|
|
518
|
+
msg,
|
|
519
|
+
...data !== void 0 ? { data } : {}
|
|
520
|
+
};
|
|
521
|
+
return JSON.stringify(entry);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Create a file‑based debug logger.
|
|
525
|
+
*
|
|
526
|
+
* @param logDir Directory for log files (defaults to ~/.lynx/logs).
|
|
527
|
+
* @param minLevel Minimum level to emit.
|
|
528
|
+
*/
|
|
529
|
+
function createDebugLogger(logDir, minLevel = "info") {
|
|
530
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
531
|
+
const stream = createWriteStream(join(logDir, `lynx-debug.jsonl`), { flags: "a" });
|
|
532
|
+
const minRank = LEVEL_ORDER[minLevel];
|
|
533
|
+
function log(level, msg, data) {
|
|
534
|
+
if (LEVEL_ORDER[level] < minRank) return;
|
|
535
|
+
stream.write(formatLine(level, msg, data) + "\n");
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
trace(msg, data) {
|
|
539
|
+
log("trace", msg, data);
|
|
540
|
+
},
|
|
541
|
+
debug(msg, data) {
|
|
542
|
+
log("debug", msg, data);
|
|
543
|
+
},
|
|
544
|
+
info(msg, data) {
|
|
545
|
+
log("info", msg, data);
|
|
546
|
+
},
|
|
547
|
+
warn(msg, data) {
|
|
548
|
+
log("warn", msg, data);
|
|
549
|
+
},
|
|
550
|
+
error(msg, data) {
|
|
551
|
+
log("error", msg, data);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/doctor/runner.ts
|
|
557
|
+
/**
|
|
558
|
+
* Doctor — system health checks.
|
|
559
|
+
*
|
|
560
|
+
* Runs a series of diagnostic checks and prints a report.
|
|
561
|
+
* Each check returns pass/fail with an optional detail message.
|
|
562
|
+
*/
|
|
563
|
+
var runner_exports$1 = /* @__PURE__ */ __exportAll({
|
|
564
|
+
printReport: () => printReport,
|
|
565
|
+
runChecks: () => runChecks,
|
|
566
|
+
runDoctor: () => runDoctor
|
|
567
|
+
});
|
|
568
|
+
function checkNodeVersion() {
|
|
569
|
+
const version = process.version;
|
|
570
|
+
const passed = parseInt(version.slice(1).split(".")[0], 10) >= 22;
|
|
571
|
+
return {
|
|
572
|
+
name: "Node.js ≥ 22",
|
|
573
|
+
passed,
|
|
574
|
+
detail: passed ? version : `${version} (need ≥ 22.19.0)`
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function checkPlatform() {
|
|
578
|
+
return {
|
|
579
|
+
name: "Platform support",
|
|
580
|
+
passed: true,
|
|
581
|
+
detail: `${platform()} ${release()} (${cpus().length} CPUs)`
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function checkHomeDir() {
|
|
585
|
+
const paths = resolvePaths();
|
|
586
|
+
let passed = existsSync(paths.home);
|
|
587
|
+
if (!passed) try {
|
|
588
|
+
mkdirSync(paths.home, { recursive: true });
|
|
589
|
+
passed = true;
|
|
590
|
+
} catch {}
|
|
591
|
+
return {
|
|
592
|
+
name: "Home directory exists",
|
|
593
|
+
passed,
|
|
594
|
+
detail: paths.home
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function checkConfigDir() {
|
|
598
|
+
const configDir = dirname(resolvePaths().configFile);
|
|
599
|
+
let passed = existsSync(configDir);
|
|
600
|
+
if (!passed) try {
|
|
601
|
+
mkdirSync(configDir, { recursive: true });
|
|
602
|
+
passed = true;
|
|
603
|
+
} catch {}
|
|
604
|
+
return {
|
|
605
|
+
name: "Config directory exists",
|
|
606
|
+
passed,
|
|
607
|
+
detail: configDir
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function checkDataDir() {
|
|
611
|
+
const dataDir = dirname(resolvePaths().stateDb);
|
|
612
|
+
try {
|
|
613
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
|
614
|
+
accessSync(dataDir, constants.W_OK);
|
|
615
|
+
return {
|
|
616
|
+
name: "Data directory writable",
|
|
617
|
+
passed: true,
|
|
618
|
+
detail: dataDir
|
|
619
|
+
};
|
|
620
|
+
} catch {
|
|
621
|
+
return {
|
|
622
|
+
name: "Data directory writable",
|
|
623
|
+
passed: false,
|
|
624
|
+
detail: `Cannot write to ${dataDir}`
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function checkDatabase() {
|
|
629
|
+
try {
|
|
630
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
631
|
+
migrate(db);
|
|
632
|
+
const row = db.prepare("SELECT sqlite_version() as v").get();
|
|
633
|
+
db.close();
|
|
634
|
+
return {
|
|
635
|
+
name: "SQLite database",
|
|
636
|
+
passed: true,
|
|
637
|
+
detail: `v${row?.v}`
|
|
638
|
+
};
|
|
639
|
+
} catch (err) {
|
|
640
|
+
return {
|
|
641
|
+
name: "SQLite database",
|
|
642
|
+
passed: false,
|
|
643
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function checkSessionsTable() {
|
|
648
|
+
try {
|
|
649
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
650
|
+
migrate(db);
|
|
651
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM sessions").get();
|
|
652
|
+
db.close();
|
|
653
|
+
return {
|
|
654
|
+
name: "Sessions table",
|
|
655
|
+
passed: true,
|
|
656
|
+
detail: `${count?.c ?? 0} sessions`
|
|
657
|
+
};
|
|
658
|
+
} catch (err) {
|
|
659
|
+
return {
|
|
660
|
+
name: "Sessions table",
|
|
661
|
+
passed: false,
|
|
662
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** All registered checks, in display order. */
|
|
667
|
+
const CHECKS = [
|
|
668
|
+
checkNodeVersion,
|
|
669
|
+
checkPlatform,
|
|
670
|
+
checkHomeDir,
|
|
671
|
+
checkConfigDir,
|
|
672
|
+
checkDataDir,
|
|
673
|
+
checkDatabase,
|
|
674
|
+
checkSessionsTable
|
|
675
|
+
];
|
|
676
|
+
/** Run all health checks and return the report. */
|
|
677
|
+
function runChecks() {
|
|
678
|
+
const checks = [];
|
|
679
|
+
for (const fn of CHECKS) checks.push(fn());
|
|
680
|
+
return {
|
|
681
|
+
checks,
|
|
682
|
+
allPassed: checks.every((c) => c.passed)
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** Print the doctor report to stdout. */
|
|
686
|
+
function printReport(report) {
|
|
687
|
+
for (const check of report.checks) {
|
|
688
|
+
const icon = check.passed ? "✓" : "✗";
|
|
689
|
+
process.stdout.write(`${icon} ${check.name}`);
|
|
690
|
+
if (check.detail) process.stdout.write(` (${check.detail})`);
|
|
691
|
+
process.stdout.write("\n");
|
|
692
|
+
}
|
|
693
|
+
process.stdout.write("\n");
|
|
694
|
+
process.stdout.write(report.allPassed ? "All checks passed.\n" : "Some checks failed.\n");
|
|
695
|
+
}
|
|
696
|
+
/** Entry point for the `lynx doctor` command. */
|
|
697
|
+
async function runDoctor() {
|
|
698
|
+
const report = runChecks();
|
|
699
|
+
printReport(report);
|
|
700
|
+
process.exitCode = report.allPassed ? 0 : 1;
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/crestodian/runner.ts
|
|
704
|
+
var runner_exports = /* @__PURE__ */ __exportAll({ runCrestodian: () => runCrestodian });
|
|
705
|
+
/**
|
|
706
|
+
* Run a non‑interactive query against the LLM.
|
|
707
|
+
*
|
|
708
|
+
* Reads API keys from environment variables (DEEPSEEK_API_KEY
|
|
709
|
+
* or OPENAI_API_KEY) and streams the response to stdout.
|
|
710
|
+
*/
|
|
711
|
+
async function runCrestodian(message, _model) {
|
|
712
|
+
if (!message) {
|
|
713
|
+
process.stderr.write("Error: --message is required\n");
|
|
714
|
+
process.exitCode = 1;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const config = loadConfig();
|
|
718
|
+
resolveConfigEnv(config);
|
|
719
|
+
let provider;
|
|
720
|
+
try {
|
|
721
|
+
provider = resolveProvider(config);
|
|
722
|
+
} catch (err) {
|
|
723
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
724
|
+
process.exitCode = 1;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const systemPrompt = assembleSystemPrompt({
|
|
728
|
+
model: config.model,
|
|
729
|
+
systemPrompt: "You are Lynx, an AI assistant. Be helpful and concise.",
|
|
730
|
+
maxTokens: 8192,
|
|
731
|
+
maxCompactionFailures: 3,
|
|
732
|
+
budget: {
|
|
733
|
+
maxTokens: Infinity,
|
|
734
|
+
maxUsd: Infinity,
|
|
735
|
+
maxTurns: Infinity
|
|
736
|
+
},
|
|
737
|
+
provider
|
|
738
|
+
}, []);
|
|
739
|
+
const messages = [{
|
|
740
|
+
role: "user",
|
|
741
|
+
content: [{
|
|
742
|
+
type: "text",
|
|
743
|
+
text: message
|
|
744
|
+
}]
|
|
745
|
+
}];
|
|
746
|
+
const controller = new AbortController();
|
|
747
|
+
try {
|
|
748
|
+
const stream = provider.stream("deepseek-v4-pro", messages, systemPrompt, [], controller.signal);
|
|
749
|
+
for await (const event of stream) if (event.type === "text_delta") process.stdout.write(event.text);
|
|
750
|
+
process.stdout.write("\n");
|
|
751
|
+
} catch (err) {
|
|
752
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
753
|
+
process.exitCode = 1;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region src/commands/session.ts
|
|
758
|
+
/**
|
|
759
|
+
* Session command — list, resume, fork, and delete sessions
|
|
760
|
+
* from the command line.
|
|
761
|
+
*
|
|
762
|
+
* Uses SessionManager for persistence and SessionPicker for
|
|
763
|
+
* interactive selection when no ID is provided.
|
|
764
|
+
*/
|
|
765
|
+
var session_exports = /* @__PURE__ */ __exportAll({ handleSessionCommand: () => handleSessionCommand });
|
|
766
|
+
/** Print text content blocks from messages to stdout. */
|
|
767
|
+
function printSessionText(messages) {
|
|
768
|
+
for (const msg of messages) for (const block of msg.content) if (block.type === "text" && block.text) process.stdout.write(block.text + "\n");
|
|
769
|
+
}
|
|
770
|
+
/** Handle the `lynx sessions` command. */
|
|
771
|
+
async function handleSessionCommand(opts) {
|
|
772
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
773
|
+
const mgr = createSessionManager(db);
|
|
774
|
+
try {
|
|
775
|
+
switch (opts.action) {
|
|
776
|
+
case "list": {
|
|
777
|
+
const sessions = mgr.list();
|
|
778
|
+
if (sessions.length === 0) process.stdout.write("No sessions found.\n");
|
|
779
|
+
else for (const s of sessions) {
|
|
780
|
+
const label = s.label ?? "(untitled)";
|
|
781
|
+
const msgCount = s.messages.length;
|
|
782
|
+
const updated = new Date(s.updatedAt).toISOString();
|
|
783
|
+
process.stdout.write(`${s.id} ${label} ${msgCount} msgs ${updated}\n`);
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
case "resume": {
|
|
788
|
+
if (!opts.id) {
|
|
789
|
+
process.stderr.write("Error: --id is required for resume\n");
|
|
790
|
+
process.exitCode = 1;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const session = mgr.load(asSessionId(opts.id));
|
|
794
|
+
if (!session) {
|
|
795
|
+
process.stderr.write(`Session not found: ${opts.id}\n`);
|
|
796
|
+
process.exitCode = 1;
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
printSessionText(session.messages);
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
case "fork": {
|
|
803
|
+
if (!opts.id) {
|
|
804
|
+
process.stderr.write("Error: --id is required for fork\n");
|
|
805
|
+
process.exitCode = 1;
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const forked = mgr.fork(asSessionId(opts.id), opts.label);
|
|
809
|
+
process.stdout.write(`Forked: ${forked.id}\n`);
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
case "delete":
|
|
813
|
+
if (!opts.id) {
|
|
814
|
+
process.stderr.write("Error: --id is required for delete\n");
|
|
815
|
+
process.exitCode = 1;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
mgr.delete(asSessionId(opts.id));
|
|
819
|
+
process.stdout.write(`Deleted: ${opts.id}\n`);
|
|
820
|
+
break;
|
|
821
|
+
default:
|
|
822
|
+
process.stderr.write(`Unknown action: ${opts.action}\n`);
|
|
823
|
+
process.exitCode = 1;
|
|
824
|
+
}
|
|
825
|
+
} finally {
|
|
826
|
+
mgr.destroy();
|
|
827
|
+
db.close();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/entry.ts
|
|
832
|
+
/**
|
|
833
|
+
* Unified entry point — the first code that runs after `lynx.mjs`.
|
|
834
|
+
*
|
|
835
|
+
* Responsibilities:
|
|
836
|
+
* 1. Version check (bail early if Node.js < 22.19).
|
|
837
|
+
* 2. Parse CLI args and route to the correct handler.
|
|
838
|
+
* 3. For interactive mode: bootstrap services + launch TUI.
|
|
839
|
+
* 4. For sub‑commands: lazily import the handler.
|
|
840
|
+
*
|
|
841
|
+
* This file intentionally avoids importing heavy modules
|
|
842
|
+
* (Ink, provider implementations) at the top level so that
|
|
843
|
+
* `lynx --version` returns in < 100 ms.
|
|
844
|
+
*/
|
|
845
|
+
function guardNodeVersion() {
|
|
846
|
+
const v = process.version.slice(1).split(".").map(Number);
|
|
847
|
+
const major = v[0];
|
|
848
|
+
const minor = v[1] ?? 0;
|
|
849
|
+
if (major < 22 || major === 22 && minor < 19) {
|
|
850
|
+
process.stderr.write(`Lynx requires Node.js ≥ 22.19.0 (found ${process.version})\n`);
|
|
851
|
+
process.exit(1);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/** Main entry point. Invoked from `lynx.mjs`. */
|
|
855
|
+
async function main(argv = process.argv) {
|
|
856
|
+
guardNodeVersion();
|
|
857
|
+
const userArgs = argv.slice(2);
|
|
858
|
+
const cliOnlyFlags = new Set([
|
|
859
|
+
"--help",
|
|
860
|
+
"-h",
|
|
861
|
+
"--version",
|
|
862
|
+
"-V"
|
|
863
|
+
]);
|
|
864
|
+
const hasCliFlag = userArgs.some((a) => cliOnlyFlags.has(a));
|
|
865
|
+
if (!userArgs.some((a) => !a.startsWith("-")) && !hasCliFlag) {
|
|
866
|
+
const { launchTui } = await Promise.resolve().then(() => tui_launcher_exports);
|
|
867
|
+
await launchTui();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const { runCli } = await Promise.resolve().then(() => args_exports);
|
|
871
|
+
await runCli(argv);
|
|
872
|
+
}
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/memory-monitor.ts
|
|
875
|
+
/**
|
|
876
|
+
* MemoryMonitor — proactive heap sampling with three‑level alerting.
|
|
877
|
+
*
|
|
878
|
+
* Samples `process.memoryUsage().heapUsed` on a configurable interval
|
|
879
|
+
* (default 30s) and fires alerts at user‑defined thresholds. Each alert
|
|
880
|
+
* level triggers a distinct action: warn → GC hint, danger → compaction,
|
|
881
|
+
* fatal → cache flush.
|
|
882
|
+
*
|
|
883
|
+
* Design (§5.9j):
|
|
884
|
+
* - 30s heap sampling interval
|
|
885
|
+
* - 3‑level alert:
|
|
886
|
+
* 700 MB → warn: trigger GC + auto compact context
|
|
887
|
+
* 900 MB → danger: force snip compaction
|
|
888
|
+
* 950 MB → fatal: release all caches + notify user
|
|
889
|
+
* - Uses global.gc() when available (requires --expose-gc)
|
|
890
|
+
* - Emits structured events for integration with status bar
|
|
891
|
+
*/
|
|
892
|
+
const MB = 1024 * 1024;
|
|
893
|
+
const DEFAULT_INTERVAL_MS = 3e4;
|
|
894
|
+
const DEFAULT_WARN_MB = 700;
|
|
895
|
+
const DEFAULT_DANGER_MB = 900;
|
|
896
|
+
const DEFAULT_FATAL_MB = 950;
|
|
897
|
+
/**
|
|
898
|
+
* Proactive heap monitor with three‑level alerting.
|
|
899
|
+
*
|
|
900
|
+
* Usage:
|
|
901
|
+
* ```ts
|
|
902
|
+
* const monitor = new MemoryMonitor({ intervalMs: 30_000 });
|
|
903
|
+
* monitor.on("alert", (alert) => {
|
|
904
|
+
* if (alert.level === "fatal") console.error("OOM imminent!");
|
|
905
|
+
* });
|
|
906
|
+
* monitor.start();
|
|
907
|
+
* ```
|
|
908
|
+
*/
|
|
909
|
+
var MemoryMonitor = class extends EventEmitter {
|
|
910
|
+
config;
|
|
911
|
+
timer = null;
|
|
912
|
+
running = false;
|
|
913
|
+
/** Track the last alert level to avoid repeated warnings. */
|
|
914
|
+
lastAlertLevel = null;
|
|
915
|
+
constructor(config = {}) {
|
|
916
|
+
super();
|
|
917
|
+
this.config = {
|
|
918
|
+
intervalMs: config.intervalMs ?? DEFAULT_INTERVAL_MS,
|
|
919
|
+
warnThreshold: config.warnThreshold ?? DEFAULT_WARN_MB * MB,
|
|
920
|
+
dangerThreshold: config.dangerThreshold ?? DEFAULT_DANGER_MB * MB,
|
|
921
|
+
fatalThreshold: config.fatalThreshold ?? DEFAULT_FATAL_MB * MB
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
/** Emitted when a memory alert is triggered. */
|
|
925
|
+
onAlert(listener) {
|
|
926
|
+
return super.on("alert", listener);
|
|
927
|
+
}
|
|
928
|
+
/** Emitted on each sample (for dashboards). */
|
|
929
|
+
onSample(listener) {
|
|
930
|
+
return super.on("sample", listener);
|
|
931
|
+
}
|
|
932
|
+
/** Emitted when compaction is requested. */
|
|
933
|
+
onCompact(listener) {
|
|
934
|
+
return super.on("compact", listener);
|
|
935
|
+
}
|
|
936
|
+
/** Emitted when caches should be flushed. */
|
|
937
|
+
onFlushCaches(listener) {
|
|
938
|
+
return super.on("flush_caches", listener);
|
|
939
|
+
}
|
|
940
|
+
/** Start periodic heap sampling. No‑op if already running. */
|
|
941
|
+
start() {
|
|
942
|
+
if (this.running) return;
|
|
943
|
+
this.running = true;
|
|
944
|
+
this.sample();
|
|
945
|
+
this.timer = setInterval(() => {
|
|
946
|
+
this.sample();
|
|
947
|
+
}, this.config.intervalMs);
|
|
948
|
+
this.timer.unref();
|
|
949
|
+
}
|
|
950
|
+
/** Stop periodic sampling. */
|
|
951
|
+
stop() {
|
|
952
|
+
this.running = false;
|
|
953
|
+
if (this.timer) {
|
|
954
|
+
clearInterval(this.timer);
|
|
955
|
+
this.timer = null;
|
|
956
|
+
}
|
|
957
|
+
this.lastAlertLevel = null;
|
|
958
|
+
}
|
|
959
|
+
/** Take a manual snapshot and return it. */
|
|
960
|
+
snapshot() {
|
|
961
|
+
const mem = process.memoryUsage();
|
|
962
|
+
return {
|
|
963
|
+
heapUsed: mem.heapUsed,
|
|
964
|
+
heapTotal: mem.heapTotal,
|
|
965
|
+
external: mem.external,
|
|
966
|
+
rss: mem.rss,
|
|
967
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
/** Sample heap usage and fire alerts if thresholds are breached. */
|
|
971
|
+
sample() {
|
|
972
|
+
const snap = this.snapshot();
|
|
973
|
+
this.emit("sample", snap);
|
|
974
|
+
const { heapUsed } = snap;
|
|
975
|
+
const { warnThreshold, dangerThreshold, fatalThreshold } = this.config;
|
|
976
|
+
let level = null;
|
|
977
|
+
let threshold = 0;
|
|
978
|
+
if (heapUsed >= fatalThreshold) {
|
|
979
|
+
level = "fatal";
|
|
980
|
+
threshold = fatalThreshold;
|
|
981
|
+
} else if (heapUsed >= dangerThreshold) {
|
|
982
|
+
level = "danger";
|
|
983
|
+
threshold = dangerThreshold;
|
|
984
|
+
} else if (heapUsed >= warnThreshold) {
|
|
985
|
+
level = "warn";
|
|
986
|
+
threshold = warnThreshold;
|
|
987
|
+
}
|
|
988
|
+
if (level) {
|
|
989
|
+
if (level === this.lastAlertLevel) return;
|
|
990
|
+
this.lastAlertLevel = level;
|
|
991
|
+
const alert = {
|
|
992
|
+
level,
|
|
993
|
+
heapUsed,
|
|
994
|
+
percentage: Math.round(heapUsed / threshold * 100),
|
|
995
|
+
threshold,
|
|
996
|
+
timestamp: snap.timestamp
|
|
997
|
+
};
|
|
998
|
+
this.emit("alert", alert);
|
|
999
|
+
this.actOnAlert(alert);
|
|
1000
|
+
} else if (this.lastAlertLevel) this.lastAlertLevel = null;
|
|
1001
|
+
}
|
|
1002
|
+
/** Execute the appropriate action for each alert level. */
|
|
1003
|
+
actOnAlert(alert) {
|
|
1004
|
+
switch (alert.level) {
|
|
1005
|
+
case "warn":
|
|
1006
|
+
this.triggerGc();
|
|
1007
|
+
break;
|
|
1008
|
+
case "danger":
|
|
1009
|
+
this.triggerGc();
|
|
1010
|
+
this.emit("compact", {
|
|
1011
|
+
reason: "memory_pressure",
|
|
1012
|
+
heapUsed: alert.heapUsed
|
|
1013
|
+
});
|
|
1014
|
+
break;
|
|
1015
|
+
case "fatal":
|
|
1016
|
+
this.triggerGc();
|
|
1017
|
+
setImmediate(() => this.triggerGc());
|
|
1018
|
+
this.emit("flush_caches", {
|
|
1019
|
+
reason: "oom_imminent",
|
|
1020
|
+
heapUsed: alert.heapUsed
|
|
1021
|
+
});
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/** Trigger V8 garbage collection if --expose-gc is enabled. */
|
|
1026
|
+
triggerGc() {
|
|
1027
|
+
if (typeof global.gc === "function") try {
|
|
1028
|
+
global.gc();
|
|
1029
|
+
} catch {}
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/update-check.ts
|
|
1034
|
+
/**
|
|
1035
|
+
* UpdateChecker — asynchronous, non‑blocking version check.
|
|
1036
|
+
*
|
|
1037
|
+
* Queries npm and GitHub for the latest Lynx release and compares
|
|
1038
|
+
* against the running version. Designed to never block startup:
|
|
1039
|
+
* the check runs in the background and fires a callback when
|
|
1040
|
+
* complete.
|
|
1041
|
+
*
|
|
1042
|
+
* Design (§5.9k):
|
|
1043
|
+
* - Async, non‑blocking — fire and forget
|
|
1044
|
+
* - O_EXCL lock — only one check runs at a time per process
|
|
1045
|
+
* - Dual channel: npm registry + GitHub releases
|
|
1046
|
+
* - GitHub as fallback when npm is slow or unreachable
|
|
1047
|
+
* - Result cached for the session lifetime
|
|
1048
|
+
* - Respects NO_UPDATE_CHECK env var
|
|
1049
|
+
*/
|
|
1050
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
1051
|
+
const DEFAULT_CACHE_TTL_MS = 36e5;
|
|
1052
|
+
let lastResult = null;
|
|
1053
|
+
let checkInProgress = null;
|
|
1054
|
+
/**
|
|
1055
|
+
* Check for updates asynchronously.
|
|
1056
|
+
*
|
|
1057
|
+
* Only one check runs at a time — concurrent calls return the
|
|
1058
|
+
* same Promise. Results are cached for the configured TTL.
|
|
1059
|
+
*
|
|
1060
|
+
* Callers should never await this at startup — fire‑and‑forget
|
|
1061
|
+
* and read the result from the cache on next call.
|
|
1062
|
+
*/
|
|
1063
|
+
function checkForUpdates(config) {
|
|
1064
|
+
if (lastResult) {
|
|
1065
|
+
if (Date.now() - new Date(lastResult.checkedAt).getTime() < (config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS)) return Promise.resolve({
|
|
1066
|
+
...lastResult,
|
|
1067
|
+
source: "cache"
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
if (checkInProgress) return checkInProgress;
|
|
1071
|
+
if (process.env.NO_UPDATE_CHECK) {
|
|
1072
|
+
const result = {
|
|
1073
|
+
current: config.currentVersion,
|
|
1074
|
+
latest: null,
|
|
1075
|
+
hasUpdate: false,
|
|
1076
|
+
releaseUrl: null,
|
|
1077
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1078
|
+
source: "error",
|
|
1079
|
+
error: "Update check disabled via NO_UPDATE_CHECK"
|
|
1080
|
+
};
|
|
1081
|
+
return Promise.resolve(result);
|
|
1082
|
+
}
|
|
1083
|
+
checkInProgress = runUpdateCheck(config).then((result) => {
|
|
1084
|
+
lastResult = result;
|
|
1085
|
+
return result;
|
|
1086
|
+
}).finally(() => {
|
|
1087
|
+
checkInProgress = null;
|
|
1088
|
+
});
|
|
1089
|
+
return checkInProgress;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Synchronously return the last cached update result.
|
|
1093
|
+
* Returns null if no check has been performed yet.
|
|
1094
|
+
*/
|
|
1095
|
+
function getLastUpdateResult() {
|
|
1096
|
+
return lastResult;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Clear the cached update result (for testing).
|
|
1100
|
+
*/
|
|
1101
|
+
function clearUpdateCache() {
|
|
1102
|
+
lastResult = null;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Run the update check against npm, falling back to GitHub.
|
|
1106
|
+
*/
|
|
1107
|
+
async function runUpdateCheck(config) {
|
|
1108
|
+
const pkg = config.packageName ?? "lynx";
|
|
1109
|
+
const repo = config.githubRepo ?? "loongcrown/lynx";
|
|
1110
|
+
const timeout = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1111
|
+
try {
|
|
1112
|
+
const npmResult = await checkNpm(pkg, timeout);
|
|
1113
|
+
return buildResult(config.currentVersion, npmResult, "npm");
|
|
1114
|
+
} catch {}
|
|
1115
|
+
try {
|
|
1116
|
+
const ghResult = await checkGitHub(repo, timeout);
|
|
1117
|
+
return buildResult(config.currentVersion, ghResult, "github");
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1120
|
+
return {
|
|
1121
|
+
current: config.currentVersion,
|
|
1122
|
+
latest: null,
|
|
1123
|
+
hasUpdate: false,
|
|
1124
|
+
releaseUrl: null,
|
|
1125
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1126
|
+
source: "error",
|
|
1127
|
+
error: message
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Check the npm registry for the latest version.
|
|
1133
|
+
*/
|
|
1134
|
+
function checkNpm(packageName, timeoutMs) {
|
|
1135
|
+
return new Promise((resolve, reject) => {
|
|
1136
|
+
const req = request(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
|
|
1137
|
+
method: "GET",
|
|
1138
|
+
timeout: timeoutMs,
|
|
1139
|
+
headers: { Accept: "application/json" }
|
|
1140
|
+
}, (res) => {
|
|
1141
|
+
let body = "";
|
|
1142
|
+
res.on("data", (chunk) => {
|
|
1143
|
+
body += chunk.toString();
|
|
1144
|
+
});
|
|
1145
|
+
res.on("end", () => {
|
|
1146
|
+
try {
|
|
1147
|
+
const version = JSON.parse(body).version;
|
|
1148
|
+
if (!version || typeof version !== "string") {
|
|
1149
|
+
reject(/* @__PURE__ */ new Error("Invalid npm response: missing version field"));
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
resolve(version);
|
|
1153
|
+
} catch (err) {
|
|
1154
|
+
reject(err instanceof Error ? err : /* @__PURE__ */ new Error("Failed to parse npm response"));
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
req.on("error", reject);
|
|
1159
|
+
req.on("timeout", () => {
|
|
1160
|
+
req.destroy();
|
|
1161
|
+
reject(/* @__PURE__ */ new Error("npm request timed out"));
|
|
1162
|
+
});
|
|
1163
|
+
req.end();
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Check GitHub releases for the latest version tag.
|
|
1168
|
+
*/
|
|
1169
|
+
function checkGitHub(repo, timeoutMs) {
|
|
1170
|
+
return new Promise((resolve, reject) => {
|
|
1171
|
+
const req = request(`https://api.github.com/repos/${repo}/releases/latest`, {
|
|
1172
|
+
method: "GET",
|
|
1173
|
+
timeout: timeoutMs,
|
|
1174
|
+
headers: {
|
|
1175
|
+
Accept: "application/vnd.github+json",
|
|
1176
|
+
"User-Agent": "Lynx-Update-Checker/1.0"
|
|
1177
|
+
}
|
|
1178
|
+
}, (res) => {
|
|
1179
|
+
let body = "";
|
|
1180
|
+
res.on("data", (chunk) => {
|
|
1181
|
+
body += chunk.toString();
|
|
1182
|
+
});
|
|
1183
|
+
res.on("end", () => {
|
|
1184
|
+
try {
|
|
1185
|
+
const tag = JSON.parse(body).tag_name;
|
|
1186
|
+
if (!tag || typeof tag !== "string") {
|
|
1187
|
+
reject(/* @__PURE__ */ new Error("Invalid GitHub response: missing tag_name field"));
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
resolve(tag.startsWith("v") ? tag.slice(1) : tag);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
reject(err instanceof Error ? err : /* @__PURE__ */ new Error("Failed to parse GitHub response"));
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
req.on("error", reject);
|
|
1197
|
+
req.on("timeout", () => {
|
|
1198
|
+
req.destroy();
|
|
1199
|
+
reject(/* @__PURE__ */ new Error("GitHub request timed out"));
|
|
1200
|
+
});
|
|
1201
|
+
req.end();
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Compare two semver strings and build the result.
|
|
1206
|
+
*/
|
|
1207
|
+
function buildResult(current, latest, source) {
|
|
1208
|
+
return {
|
|
1209
|
+
current,
|
|
1210
|
+
latest,
|
|
1211
|
+
hasUpdate: compareVersions(latest, current) > 0,
|
|
1212
|
+
releaseUrl: source === "github" ? `https://github.com/loongcrown/lynx/releases/latest` : `https://www.npmjs.com/package/lynx/v/${latest}`,
|
|
1213
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1214
|
+
source
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Simple semver comparison (supports major.minor.patch only).
|
|
1219
|
+
*
|
|
1220
|
+
* Returns positive if a > b, negative if a < b, 0 if equal.
|
|
1221
|
+
*/
|
|
1222
|
+
function compareVersions(a, b) {
|
|
1223
|
+
const aParts = a.split(".").map(Number);
|
|
1224
|
+
const bParts = b.split(".").map(Number);
|
|
1225
|
+
const len = Math.max(aParts.length, bParts.length);
|
|
1226
|
+
for (let i = 0; i < len; i++) {
|
|
1227
|
+
const aVal = aParts[i] ?? 0;
|
|
1228
|
+
const bVal = bParts[i] ?? 0;
|
|
1229
|
+
if (aVal > bVal) return 1;
|
|
1230
|
+
if (aVal < bVal) return -1;
|
|
1231
|
+
}
|
|
1232
|
+
return 0;
|
|
1233
|
+
}
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region src/snapshot-store.ts
|
|
1236
|
+
/**
|
|
1237
|
+
* SnapshotStore — lightweight file-based snapshot persistence.
|
|
1238
|
+
*
|
|
1239
|
+
* Snapshots are serialised session checkpoints stored as JSON
|
|
1240
|
+
* files in the snapshots directory. Each snapshot captures the
|
|
1241
|
+
* session label, message list, and workspace at a point in time.
|
|
1242
|
+
*
|
|
1243
|
+
* Used by the SnapshotBrowser (/snapshots) to list and restore
|
|
1244
|
+
* previous session states.
|
|
1245
|
+
*/
|
|
1246
|
+
/**
|
|
1247
|
+
* Create a snapshot store backed by a directory on disk.
|
|
1248
|
+
*
|
|
1249
|
+
* The directory is created on first write if it doesn't exist.
|
|
1250
|
+
* Snapshots are stored as individual JSON files named `<id>.json`.
|
|
1251
|
+
*/
|
|
1252
|
+
function createSnapshotStore(snapshotsDir) {
|
|
1253
|
+
return {
|
|
1254
|
+
/**
|
|
1255
|
+
* Capture a new snapshot of a session's current state.
|
|
1256
|
+
*
|
|
1257
|
+
* Returns the StoredSnapshot that was written to disk.
|
|
1258
|
+
*/
|
|
1259
|
+
capture(session) {
|
|
1260
|
+
const id = crypto.randomUUID();
|
|
1261
|
+
const snapshot = {
|
|
1262
|
+
id,
|
|
1263
|
+
label: session.label ?? "untitled",
|
|
1264
|
+
timestamp: Date.now(),
|
|
1265
|
+
workspace: session.workspace ?? "",
|
|
1266
|
+
messages: [...session.messages]
|
|
1267
|
+
};
|
|
1268
|
+
mkdirSync(snapshotsDir, { recursive: true });
|
|
1269
|
+
writeFileSync(join(snapshotsDir, `${id}.json`), JSON.stringify(snapshot, null, 2), "utf-8");
|
|
1270
|
+
return snapshot;
|
|
1271
|
+
},
|
|
1272
|
+
/**
|
|
1273
|
+
* List all stored snapshots, sorted newest-first.
|
|
1274
|
+
*
|
|
1275
|
+
* Returns only metadata (no message bodies) for efficient listing.
|
|
1276
|
+
*/
|
|
1277
|
+
list() {
|
|
1278
|
+
let entries;
|
|
1279
|
+
try {
|
|
1280
|
+
entries = readdirSync(snapshotsDir);
|
|
1281
|
+
} catch {
|
|
1282
|
+
return [];
|
|
1283
|
+
}
|
|
1284
|
+
const snapshots = [];
|
|
1285
|
+
for (const entry of entries) {
|
|
1286
|
+
if (!entry.endsWith(".json")) continue;
|
|
1287
|
+
entry.replace(/\.json$/, "");
|
|
1288
|
+
const filePath = join(snapshotsDir, entry);
|
|
1289
|
+
try {
|
|
1290
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
1291
|
+
const snap = JSON.parse(raw);
|
|
1292
|
+
snapshots.push({
|
|
1293
|
+
id: snap.id,
|
|
1294
|
+
timestamp: snap.timestamp,
|
|
1295
|
+
label: snap.label
|
|
1296
|
+
});
|
|
1297
|
+
} catch {}
|
|
1298
|
+
}
|
|
1299
|
+
snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
|
1300
|
+
return snapshots;
|
|
1301
|
+
},
|
|
1302
|
+
/**
|
|
1303
|
+
* Load a full snapshot by ID (includes message bodies).
|
|
1304
|
+
*
|
|
1305
|
+
* Returns null if the snapshot doesn't exist or is corrupted.
|
|
1306
|
+
*/
|
|
1307
|
+
load(id) {
|
|
1308
|
+
const filePath = join(snapshotsDir, `${id}.json`);
|
|
1309
|
+
try {
|
|
1310
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
1311
|
+
return JSON.parse(raw);
|
|
1312
|
+
} catch {
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
/**
|
|
1317
|
+
* Delete a snapshot by ID.
|
|
1318
|
+
*
|
|
1319
|
+
* No-op if the snapshot doesn't exist.
|
|
1320
|
+
*/
|
|
1321
|
+
delete(id) {
|
|
1322
|
+
try {
|
|
1323
|
+
unlinkSync(join(snapshotsDir, `${id}.json`));
|
|
1324
|
+
} catch {}
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
//#endregion
|
|
1329
|
+
//#region src/tui-launcher.ts
|
|
1330
|
+
/**
|
|
1331
|
+
* TUI launcher — boots the application and renders the Ink UI.
|
|
1332
|
+
*
|
|
1333
|
+
* This module is lazily loaded only when the user enters
|
|
1334
|
+
* interactive mode (no sub‑command given).
|
|
1335
|
+
*/
|
|
1336
|
+
var tui_launcher_exports = /* @__PURE__ */ __exportAll({ launchTui: () => launchTui });
|
|
1337
|
+
/** Approximate USD per 1M input tokens. */
|
|
1338
|
+
const INPUT_PRICE_PER_1M = {
|
|
1339
|
+
"deepseek-chat": .27,
|
|
1340
|
+
"deepseek-reasoner": .55,
|
|
1341
|
+
"gpt-4o": 2.5,
|
|
1342
|
+
"gpt-4o-mini": .15,
|
|
1343
|
+
"o3-mini": 1.1,
|
|
1344
|
+
"claude-sonnet-4-20250514": 3,
|
|
1345
|
+
"claude-haiku-3-5": .8,
|
|
1346
|
+
"claude-opus-4-20250514": 15
|
|
1347
|
+
};
|
|
1348
|
+
/** Approximate USD per 1M output tokens. */
|
|
1349
|
+
const OUTPUT_PRICE_PER_1M = {
|
|
1350
|
+
"deepseek-chat": 1.1,
|
|
1351
|
+
"deepseek-reasoner": 2.19,
|
|
1352
|
+
"gpt-4o": 10,
|
|
1353
|
+
"gpt-4o-mini": .6,
|
|
1354
|
+
"o3-mini": 4.4,
|
|
1355
|
+
"claude-sonnet-4-20250514": 15,
|
|
1356
|
+
"claude-haiku-3-5": 4,
|
|
1357
|
+
"claude-opus-4-20250514": 75
|
|
1358
|
+
};
|
|
1359
|
+
/**
|
|
1360
|
+
* Estimate the USD cost for a given token count.
|
|
1361
|
+
*
|
|
1362
|
+
* Uses approximate 70/30 input/output split and model‑specific pricing.
|
|
1363
|
+
* Falls back to $1.0/$4.0 per million for unknown models.
|
|
1364
|
+
*/
|
|
1365
|
+
function estimateCost(model, totalTokens) {
|
|
1366
|
+
if (totalTokens <= 0) return 0;
|
|
1367
|
+
const inputPrice = INPUT_PRICE_PER_1M[model] ?? 1;
|
|
1368
|
+
const outputPrice = OUTPUT_PRICE_PER_1M[model] ?? 4;
|
|
1369
|
+
const inputTokens = Math.floor(totalTokens * .7);
|
|
1370
|
+
const outputTokens = Math.floor(totalTokens * .3);
|
|
1371
|
+
return inputTokens / 1e6 * inputPrice + outputTokens / 1e6 * outputPrice;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Launch the interactive TUI.
|
|
1375
|
+
*
|
|
1376
|
+
* Phase 1: bootstrap services (DB, sessions, tools, agent engine).
|
|
1377
|
+
* Phase 2: render the Ink TUI.
|
|
1378
|
+
*/
|
|
1379
|
+
async function launchTui() {
|
|
1380
|
+
const paths = resolvePaths();
|
|
1381
|
+
const config = loadConfig();
|
|
1382
|
+
resolveConfigEnv(config);
|
|
1383
|
+
const provider = resolveProvider(config);
|
|
1384
|
+
const builtinSkillsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "lynx-agent", "skills");
|
|
1385
|
+
const ctx = bootstrap({
|
|
1386
|
+
homeDir: paths.home,
|
|
1387
|
+
provider,
|
|
1388
|
+
model: process.env.LYNX_MODEL ?? config.model,
|
|
1389
|
+
skillsDir: builtinSkillsDir,
|
|
1390
|
+
workspace: process.cwd()
|
|
1391
|
+
});
|
|
1392
|
+
const sessions = ctx.sessionMgr.list();
|
|
1393
|
+
const activeSession = sessions.length > 0 ? sessions[0] : ctx.sessionMgr.create("default", process.cwd());
|
|
1394
|
+
let abortLayer = 0;
|
|
1395
|
+
let currentAbortController = null;
|
|
1396
|
+
let isStreaming = false;
|
|
1397
|
+
const resetAbortLayer = () => {
|
|
1398
|
+
abortLayer = 0;
|
|
1399
|
+
};
|
|
1400
|
+
const getAbortLayer = () => abortLayer;
|
|
1401
|
+
const incrementAbortLayer = () => {
|
|
1402
|
+
abortLayer++;
|
|
1403
|
+
return abortLayer;
|
|
1404
|
+
};
|
|
1405
|
+
installProcessLifecycle({
|
|
1406
|
+
ctx,
|
|
1407
|
+
onAbortLl: () => {
|
|
1408
|
+
ctx.engine.abort();
|
|
1409
|
+
},
|
|
1410
|
+
onAbortTool: () => {
|
|
1411
|
+
ctx.engine.abort();
|
|
1412
|
+
},
|
|
1413
|
+
getAbortLayer,
|
|
1414
|
+
incrementAbortLayer,
|
|
1415
|
+
resetAbortLayer,
|
|
1416
|
+
isStreaming: () => isStreaming
|
|
1417
|
+
});
|
|
1418
|
+
const providerModels = ctx.provider.listModels().map((m) => ({
|
|
1419
|
+
id: m.id,
|
|
1420
|
+
label: m.label,
|
|
1421
|
+
contextWindow: m.contextWindow,
|
|
1422
|
+
maxOutput: m.maxOutput,
|
|
1423
|
+
inputPrice: INPUT_PRICE_PER_1M[m.id],
|
|
1424
|
+
outputPrice: OUTPUT_PRICE_PER_1M[m.id]
|
|
1425
|
+
}));
|
|
1426
|
+
const callbacks = {
|
|
1427
|
+
async *onInput(message) {
|
|
1428
|
+
resetAbortLayer();
|
|
1429
|
+
isStreaming = true;
|
|
1430
|
+
const userMsg = {
|
|
1431
|
+
id: crypto.randomUUID(),
|
|
1432
|
+
role: "user",
|
|
1433
|
+
content: [{
|
|
1434
|
+
type: "text",
|
|
1435
|
+
text: message
|
|
1436
|
+
}],
|
|
1437
|
+
timestamp: Date.now(),
|
|
1438
|
+
turnIndex: 0
|
|
1439
|
+
};
|
|
1440
|
+
currentAbortController = new AbortController();
|
|
1441
|
+
const signal = currentAbortController.signal;
|
|
1442
|
+
let turnCompleted = false;
|
|
1443
|
+
let reasoningStart = null;
|
|
1444
|
+
let reasoningText = "";
|
|
1445
|
+
/** End reasoning tracking, yielding a status event with duration and text if reasoning was active. */
|
|
1446
|
+
const endReasoning = () => {
|
|
1447
|
+
if (reasoningStart === null) return null;
|
|
1448
|
+
const durationMs = Date.now() - reasoningStart;
|
|
1449
|
+
const text = reasoningText;
|
|
1450
|
+
reasoningStart = null;
|
|
1451
|
+
reasoningText = "";
|
|
1452
|
+
return {
|
|
1453
|
+
type: "reasoning_status",
|
|
1454
|
+
status: "ended",
|
|
1455
|
+
durationMs,
|
|
1456
|
+
text
|
|
1457
|
+
};
|
|
1458
|
+
};
|
|
1459
|
+
try {
|
|
1460
|
+
for await (const event of ctx.engine.submit(activeSession, userMsg, signal)) switch (event.type) {
|
|
1461
|
+
case "text_delta": {
|
|
1462
|
+
const rEnd = endReasoning();
|
|
1463
|
+
if (rEnd) yield rEnd;
|
|
1464
|
+
yield {
|
|
1465
|
+
type: "text_delta",
|
|
1466
|
+
text: event.text
|
|
1467
|
+
};
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
case "reasoning_delta":
|
|
1471
|
+
if (reasoningStart === null) {
|
|
1472
|
+
reasoningStart = Date.now();
|
|
1473
|
+
yield {
|
|
1474
|
+
type: "reasoning_status",
|
|
1475
|
+
status: "started"
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
reasoningText += event.text;
|
|
1479
|
+
break;
|
|
1480
|
+
case "tool_use_start": {
|
|
1481
|
+
const rEnd = endReasoning();
|
|
1482
|
+
if (rEnd) yield rEnd;
|
|
1483
|
+
yield {
|
|
1484
|
+
type: "tool_use",
|
|
1485
|
+
name: event.name,
|
|
1486
|
+
callId: event.callId
|
|
1487
|
+
};
|
|
1488
|
+
break;
|
|
1489
|
+
}
|
|
1490
|
+
case "tool_result":
|
|
1491
|
+
yield {
|
|
1492
|
+
type: "tool_result",
|
|
1493
|
+
callId: event.toolUseId,
|
|
1494
|
+
content: event.content
|
|
1495
|
+
};
|
|
1496
|
+
break;
|
|
1497
|
+
case "tool_recap":
|
|
1498
|
+
yield {
|
|
1499
|
+
type: "tool_recap",
|
|
1500
|
+
summary: event.summary,
|
|
1501
|
+
toolCount: event.toolCount,
|
|
1502
|
+
durationMs: event.durationMs
|
|
1503
|
+
};
|
|
1504
|
+
break;
|
|
1505
|
+
case "error":
|
|
1506
|
+
yield {
|
|
1507
|
+
type: "error",
|
|
1508
|
+
message: event.message
|
|
1509
|
+
};
|
|
1510
|
+
break;
|
|
1511
|
+
case "done": {
|
|
1512
|
+
const rEnd = endReasoning();
|
|
1513
|
+
if (rEnd) yield rEnd;
|
|
1514
|
+
turnCompleted = true;
|
|
1515
|
+
const totalTokens = event.totalTokens ?? 0;
|
|
1516
|
+
yield {
|
|
1517
|
+
type: "done",
|
|
1518
|
+
totalTokens,
|
|
1519
|
+
costUsd: estimateCost(ctx.agentConfig.model, totalTokens)
|
|
1520
|
+
};
|
|
1521
|
+
break;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (turnCompleted && activeSession.messages.length > 0) try {
|
|
1525
|
+
snapshotStore.capture(activeSession);
|
|
1526
|
+
} catch {}
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
yield {
|
|
1529
|
+
type: "error",
|
|
1530
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1531
|
+
};
|
|
1532
|
+
} finally {
|
|
1533
|
+
isStreaming = false;
|
|
1534
|
+
currentAbortController = null;
|
|
1535
|
+
}
|
|
1536
|
+
},
|
|
1537
|
+
onSessionPick(sessionId) {
|
|
1538
|
+
const session = ctx.sessionMgr.load(asSessionId(sessionId));
|
|
1539
|
+
if (session) process.stdout.write(`Switched to session: ${session.label ?? sessionId}\n`);
|
|
1540
|
+
},
|
|
1541
|
+
onPermissionReply(requestId, approved) {
|
|
1542
|
+
ctx.permissionBridge.handleReply(requestId, approved);
|
|
1543
|
+
},
|
|
1544
|
+
onAbort() {
|
|
1545
|
+
ctx.engine.abort();
|
|
1546
|
+
},
|
|
1547
|
+
onModelPick(modelId) {
|
|
1548
|
+
ctx.agentConfig.model = modelId;
|
|
1549
|
+
const cfg = loadConfig();
|
|
1550
|
+
cfg.model = modelId;
|
|
1551
|
+
saveConfig(cfg);
|
|
1552
|
+
},
|
|
1553
|
+
onConfigChange(key, value) {
|
|
1554
|
+
const cfg = loadConfig();
|
|
1555
|
+
if (key === "model" && typeof value === "string") cfg.model = value;
|
|
1556
|
+
else if (key === "theme" && typeof value === "string") cfg.theme = value;
|
|
1557
|
+
saveConfig(cfg);
|
|
1558
|
+
},
|
|
1559
|
+
onThemeChange(themeName) {
|
|
1560
|
+
const cfg = loadConfig();
|
|
1561
|
+
cfg.theme = themeName;
|
|
1562
|
+
saveConfig(cfg);
|
|
1563
|
+
},
|
|
1564
|
+
onSessionCreate(label) {
|
|
1565
|
+
ctx.sessionMgr.create(label, workspace);
|
|
1566
|
+
},
|
|
1567
|
+
onSessionFork(newLabel) {
|
|
1568
|
+
ctx.sessionMgr.fork(activeSession.id, newLabel);
|
|
1569
|
+
},
|
|
1570
|
+
onSessionRename(newLabel) {
|
|
1571
|
+
ctx.sessionMgr.rename(activeSession.id, newLabel);
|
|
1572
|
+
},
|
|
1573
|
+
onSessionDelete(sessionId) {
|
|
1574
|
+
ctx.sessionMgr.delete(asSessionId(sessionId));
|
|
1575
|
+
},
|
|
1576
|
+
onSessionResume(sessionId) {
|
|
1577
|
+
const resumed = ctx.sessionMgr.load(asSessionId(sessionId));
|
|
1578
|
+
if (resumed) process.stdout.write(`Resumed session: ${resumed.label}\n`);
|
|
1579
|
+
},
|
|
1580
|
+
onSessionCompact() {
|
|
1581
|
+
ctx.engine.compact(activeSession);
|
|
1582
|
+
},
|
|
1583
|
+
async onRequestDiff() {
|
|
1584
|
+
try {
|
|
1585
|
+
const { execSync } = await import("node:child_process");
|
|
1586
|
+
const output = execSync("git diff --name-status", {
|
|
1587
|
+
cwd: workspace,
|
|
1588
|
+
encoding: "utf-8",
|
|
1589
|
+
timeout: 5e3
|
|
1590
|
+
});
|
|
1591
|
+
const files = [];
|
|
1592
|
+
const lines = output.trim().split("\n").filter(Boolean);
|
|
1593
|
+
for (const line of lines) {
|
|
1594
|
+
const parts = line.split(" ");
|
|
1595
|
+
const status = (parts[0] ?? "?").charAt(0);
|
|
1596
|
+
const path = parts.length > 1 ? parts.slice(1).join(" ") : line;
|
|
1597
|
+
let diff = "";
|
|
1598
|
+
try {
|
|
1599
|
+
diff = execSync(`git diff -- "${path}"`, {
|
|
1600
|
+
cwd: workspace,
|
|
1601
|
+
encoding: "utf-8",
|
|
1602
|
+
timeout: 3e3
|
|
1603
|
+
});
|
|
1604
|
+
} catch {
|
|
1605
|
+
diff = "(diff unavailable)";
|
|
1606
|
+
}
|
|
1607
|
+
files.push({
|
|
1608
|
+
path,
|
|
1609
|
+
status,
|
|
1610
|
+
diff
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
return files;
|
|
1614
|
+
} catch {
|
|
1615
|
+
return [];
|
|
1616
|
+
}
|
|
1617
|
+
},
|
|
1618
|
+
async onRequestSnapshots() {
|
|
1619
|
+
return snapshotStore.list();
|
|
1620
|
+
},
|
|
1621
|
+
async onRequestTasks() {
|
|
1622
|
+
return [...phase2Tasks];
|
|
1623
|
+
},
|
|
1624
|
+
async onOpenExternalEditor(currentText) {
|
|
1625
|
+
const editor = process.env.EDITOR ?? process.env.VISUAL ?? "notepad";
|
|
1626
|
+
const { writeFileSync, readFileSync, unlinkSync } = await import("node:fs");
|
|
1627
|
+
const { tmpdir } = await import("node:os");
|
|
1628
|
+
const { join } = await import("node:path");
|
|
1629
|
+
const { randomUUID } = await import("node:crypto");
|
|
1630
|
+
const { execSync } = await import("node:child_process");
|
|
1631
|
+
const tmpPath = join(tmpdir(), `lynx-editor-${randomUUID()}.md`);
|
|
1632
|
+
try {
|
|
1633
|
+
writeFileSync(tmpPath, currentText || "# Lynx Editor\n\n", "utf-8");
|
|
1634
|
+
execSync(`${editor} "${tmpPath}"`, {
|
|
1635
|
+
stdio: "inherit",
|
|
1636
|
+
timeout: 3e5
|
|
1637
|
+
});
|
|
1638
|
+
const edited = readFileSync(tmpPath, "utf-8");
|
|
1639
|
+
return edited === "# Lynx Editor\n\n" && currentText === "" ? null : edited;
|
|
1640
|
+
} catch {
|
|
1641
|
+
return null;
|
|
1642
|
+
} finally {
|
|
1643
|
+
try {
|
|
1644
|
+
unlinkSync(tmpPath);
|
|
1645
|
+
} catch {}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
const phase2Tasks = [];
|
|
1650
|
+
const snapshotStore = createSnapshotStore(paths.snapshotsDir);
|
|
1651
|
+
const workspace = process.cwd();
|
|
1652
|
+
const userHome = homedir();
|
|
1653
|
+
/** Build MCP connection info from the connection manager. */
|
|
1654
|
+
function buildMcpConnections() {
|
|
1655
|
+
return ctx.mcpManager.listConnections().map((c) => ({
|
|
1656
|
+
name: c.serverName,
|
|
1657
|
+
status: c.status,
|
|
1658
|
+
toolCount: c.tools.length,
|
|
1659
|
+
detail: c.errorMessage ?? c.status,
|
|
1660
|
+
transport: c.transport,
|
|
1661
|
+
authStatus: c.authStatus,
|
|
1662
|
+
resourceCount: c.resourceCount,
|
|
1663
|
+
tools: c.toolsMeta,
|
|
1664
|
+
resources: c.resources
|
|
1665
|
+
}));
|
|
1666
|
+
}
|
|
1667
|
+
/** Build plugin info from manifest registry and loader state. */
|
|
1668
|
+
function buildPlugins() {
|
|
1669
|
+
const loaded = new Set(ctx.pluginRegistry.loader.listLoaded());
|
|
1670
|
+
return ctx.pluginRegistry.manifestRegistry.listAll().map((e) => ({
|
|
1671
|
+
name: e.manifest.name,
|
|
1672
|
+
version: e.manifest.version,
|
|
1673
|
+
description: e.manifest.description ?? "",
|
|
1674
|
+
loaded: loaded.has(e.manifest.name)
|
|
1675
|
+
}));
|
|
1676
|
+
}
|
|
1677
|
+
/** Build skill info from the skill registry, annotating source origin. */
|
|
1678
|
+
function buildSkills() {
|
|
1679
|
+
return ctx.skillRegistry.list().map((s) => {
|
|
1680
|
+
let source = "builtin";
|
|
1681
|
+
if (s.path.startsWith(userHome)) source = "user";
|
|
1682
|
+
else if (s.path.startsWith(workspace)) source = "project";
|
|
1683
|
+
return {
|
|
1684
|
+
name: s.name,
|
|
1685
|
+
description: s.description,
|
|
1686
|
+
source
|
|
1687
|
+
};
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
/** Build context summary for /context panel. */
|
|
1691
|
+
function buildContextInfo() {
|
|
1692
|
+
return {
|
|
1693
|
+
memoryFacts: ctx.memoryFacts.length,
|
|
1694
|
+
rules: ctx.rules.length,
|
|
1695
|
+
skills: ctx.skills.length,
|
|
1696
|
+
model: ctx.agentConfig.model,
|
|
1697
|
+
workspace,
|
|
1698
|
+
sessionLabel: activeSession.label
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
/** Build usage statistics for /usage panel. */
|
|
1702
|
+
function buildUsageInfo() {
|
|
1703
|
+
const model = ctx.agentConfig.model;
|
|
1704
|
+
const inputPrice = INPUT_PRICE_PER_1M[model];
|
|
1705
|
+
const outputPrice = OUTPUT_PRICE_PER_1M[model];
|
|
1706
|
+
const pricing = inputPrice !== void 0 && outputPrice !== void 0 ? `${model}: $${inputPrice}/M input · $${outputPrice}/M output` : `${model}: pricing N/A`;
|
|
1707
|
+
return {
|
|
1708
|
+
tokensUsed: 0,
|
|
1709
|
+
costUsd: 0,
|
|
1710
|
+
budgetMaxTokens: ctx.agentConfig.budget.maxTokens,
|
|
1711
|
+
budgetMaxUsd: ctx.agentConfig.budget.maxUsd,
|
|
1712
|
+
turns: 0,
|
|
1713
|
+
modelPricing: pricing
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
let memoryMonitor = null;
|
|
1717
|
+
try {
|
|
1718
|
+
enterFullscreen();
|
|
1719
|
+
const React = await import("react");
|
|
1720
|
+
const { render } = await import("ink");
|
|
1721
|
+
const { App } = await import("@lynx/tui");
|
|
1722
|
+
const { unmount, waitUntilExit } = render(React.createElement(App, {
|
|
1723
|
+
callbacks,
|
|
1724
|
+
session: activeSession,
|
|
1725
|
+
sessions,
|
|
1726
|
+
models: providerModels,
|
|
1727
|
+
currentModel: ctx.agentConfig.model,
|
|
1728
|
+
columns: process.stdout.columns ?? 80,
|
|
1729
|
+
rows: process.stdout.rows ?? 24,
|
|
1730
|
+
showWelcome: true,
|
|
1731
|
+
onPermissionReady: (handler) => {
|
|
1732
|
+
ctx.permissionBridge.setTuiHandler(handler);
|
|
1733
|
+
},
|
|
1734
|
+
mcpConnections: buildMcpConnections(),
|
|
1735
|
+
plugins: buildPlugins(),
|
|
1736
|
+
skills: buildSkills(),
|
|
1737
|
+
contextInfo: buildContextInfo(),
|
|
1738
|
+
usageInfo: buildUsageInfo()
|
|
1739
|
+
}));
|
|
1740
|
+
runPhase2WithContext(ctx, paths).then((results) => {
|
|
1741
|
+
const statusMap = {
|
|
1742
|
+
true: "done",
|
|
1743
|
+
false: "failed"
|
|
1744
|
+
};
|
|
1745
|
+
for (const r of results) phase2Tasks.push({
|
|
1746
|
+
id: `phase2-${r.name}`,
|
|
1747
|
+
name: r.name,
|
|
1748
|
+
status: statusMap[String(r.ok)] ?? "failed",
|
|
1749
|
+
error: r.error
|
|
1750
|
+
});
|
|
1751
|
+
}).catch(() => {});
|
|
1752
|
+
memoryMonitor = new MemoryMonitor();
|
|
1753
|
+
memoryMonitor.onAlert((alert) => {
|
|
1754
|
+
process.stderr.write(`[lynx] Memory ${alert.level}: ${Math.round(alert.heapUsed / 1024 / 1024)}MB (${alert.percentage}% of threshold)\n`);
|
|
1755
|
+
});
|
|
1756
|
+
memoryMonitor.start();
|
|
1757
|
+
checkForUpdates({ currentVersion: "0.1.0" }).then((result) => {
|
|
1758
|
+
if (result.hasUpdate) process.stderr.write(`[lynx] Update available: ${result.current} → ${result.latest} (${result.releaseUrl})\n`);
|
|
1759
|
+
}).catch(() => {});
|
|
1760
|
+
await waitUntilExit();
|
|
1761
|
+
memoryMonitor.stop();
|
|
1762
|
+
exitFullscreen();
|
|
1763
|
+
unmount();
|
|
1764
|
+
ctx.destroy();
|
|
1765
|
+
} catch (err) {
|
|
1766
|
+
process.stderr.write(`Failed to start TUI: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1767
|
+
try {
|
|
1768
|
+
memoryMonitor?.stop();
|
|
1769
|
+
} catch {}
|
|
1770
|
+
try {
|
|
1771
|
+
exitFullscreen();
|
|
1772
|
+
} catch {}
|
|
1773
|
+
try {
|
|
1774
|
+
ctx.destroy();
|
|
1775
|
+
} catch {}
|
|
1776
|
+
process.exitCode = 1;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
//#endregion
|
|
1780
|
+
//#region src/worker-pool.ts
|
|
1781
|
+
/**
|
|
1782
|
+
* WorkerPool — bounded thread pool for CPU‑bound tasks.
|
|
1783
|
+
*
|
|
1784
|
+
* Manages a fixed number of worker threads (2‑8) with a FIFO task
|
|
1785
|
+
* queue. All workers share the same worker script; tasks differ
|
|
1786
|
+
* only by their postMessage payload.
|
|
1787
|
+
*
|
|
1788
|
+
* Design:
|
|
1789
|
+
* - Fixed pool size, configurable at creation time
|
|
1790
|
+
* - FIFO queue — tasks are executed in submission order
|
|
1791
|
+
* - Auto‑restart — crashed workers replaced up to maxRetries times
|
|
1792
|
+
* - Idle shrink — workers idle for > 30s are terminated (min 2 kept)
|
|
1793
|
+
* - Graceful shutdown — drain remaining tasks before exit
|
|
1794
|
+
*/
|
|
1795
|
+
const MIN_POOL_SIZE = 2;
|
|
1796
|
+
const MAX_POOL_SIZE = 8;
|
|
1797
|
+
const DEFAULT_SIZE = 2;
|
|
1798
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
1799
|
+
const DEFAULT_IDLE_MS = 3e4;
|
|
1800
|
+
/**
|
|
1801
|
+
* Bounded worker thread pool.
|
|
1802
|
+
*
|
|
1803
|
+
* Usage:
|
|
1804
|
+
* ```ts
|
|
1805
|
+
* const pool = new WorkerPool({ workerScript: "./heavy-task.js", size: 4 });
|
|
1806
|
+
* const result = await pool.enqueue({ input: 42 });
|
|
1807
|
+
* await pool.shutdown();
|
|
1808
|
+
* ```
|
|
1809
|
+
*/
|
|
1810
|
+
var WorkerPool = class {
|
|
1811
|
+
config;
|
|
1812
|
+
workerScript;
|
|
1813
|
+
queue = [];
|
|
1814
|
+
workers = /* @__PURE__ */ new Map();
|
|
1815
|
+
runningTasks = /* @__PURE__ */ new Map();
|
|
1816
|
+
nextWorkerId = 0;
|
|
1817
|
+
destroyed = false;
|
|
1818
|
+
totalRestarts = 0;
|
|
1819
|
+
constructor(config) {
|
|
1820
|
+
const size = Math.min(MAX_POOL_SIZE, Math.max(MIN_POOL_SIZE, config.size ?? DEFAULT_SIZE));
|
|
1821
|
+
this.config = {
|
|
1822
|
+
size,
|
|
1823
|
+
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
1824
|
+
idleTimeoutMs: config.idleTimeoutMs ?? DEFAULT_IDLE_MS
|
|
1825
|
+
};
|
|
1826
|
+
this.workerScript = config.workerScript;
|
|
1827
|
+
for (let i = 0; i < size; i++) this.spawnWorker();
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Enqueue a task for execution.
|
|
1831
|
+
*
|
|
1832
|
+
* Returns a Promise that resolves with the worker's result
|
|
1833
|
+
* or rejects if the pool is destroyed.
|
|
1834
|
+
*/
|
|
1835
|
+
enqueue(data) {
|
|
1836
|
+
if (this.destroyed) return Promise.reject(/* @__PURE__ */ new Error("WorkerPool is destroyed"));
|
|
1837
|
+
return new Promise((resolve, reject) => {
|
|
1838
|
+
const task = {
|
|
1839
|
+
id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1840
|
+
workerData: data,
|
|
1841
|
+
resolve,
|
|
1842
|
+
reject,
|
|
1843
|
+
retries: 0
|
|
1844
|
+
};
|
|
1845
|
+
this.queue.push(task);
|
|
1846
|
+
this.drain();
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
/** Current pool status for monitoring. */
|
|
1850
|
+
status() {
|
|
1851
|
+
let active = 0;
|
|
1852
|
+
let idle = 0;
|
|
1853
|
+
for (const w of this.workers.values()) if (w.busy) active++;
|
|
1854
|
+
else idle++;
|
|
1855
|
+
return {
|
|
1856
|
+
size: this.workers.size,
|
|
1857
|
+
active,
|
|
1858
|
+
idle,
|
|
1859
|
+
queued: this.queue.length,
|
|
1860
|
+
totalRestarts: this.totalRestarts
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Gracefully shut down the pool.
|
|
1865
|
+
*
|
|
1866
|
+
* Waits for active tasks to finish, then terminates all workers.
|
|
1867
|
+
* Queued tasks that haven't started are rejected.
|
|
1868
|
+
*/
|
|
1869
|
+
async shutdown() {
|
|
1870
|
+
this.destroyed = true;
|
|
1871
|
+
for (const task of this.queue) task.reject(/* @__PURE__ */ new Error("WorkerPool shut down"));
|
|
1872
|
+
this.queue.length = 0;
|
|
1873
|
+
for (const entry of this.workers.values()) {
|
|
1874
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1875
|
+
await entry.worker.terminate();
|
|
1876
|
+
}
|
|
1877
|
+
this.workers.clear();
|
|
1878
|
+
}
|
|
1879
|
+
/** Spawn a new worker thread. */
|
|
1880
|
+
spawnWorker() {
|
|
1881
|
+
const id = this.nextWorkerId++;
|
|
1882
|
+
let worker;
|
|
1883
|
+
try {
|
|
1884
|
+
worker = new Worker(this.workerScript, { workerData: { poolId: id } });
|
|
1885
|
+
} catch {
|
|
1886
|
+
return -1;
|
|
1887
|
+
}
|
|
1888
|
+
worker.on("error", (_err) => {
|
|
1889
|
+
this.handleWorkerCrash(id);
|
|
1890
|
+
});
|
|
1891
|
+
worker.on("messageerror", () => {
|
|
1892
|
+
this.handleWorkerCrash(id);
|
|
1893
|
+
});
|
|
1894
|
+
worker.on("exit", (code) => {
|
|
1895
|
+
if (code !== 0) this.handleWorkerCrash(id);
|
|
1896
|
+
});
|
|
1897
|
+
const idleTimer = this.startIdleTimer(id);
|
|
1898
|
+
this.workers.set(id, {
|
|
1899
|
+
worker,
|
|
1900
|
+
busy: false,
|
|
1901
|
+
restarts: 0,
|
|
1902
|
+
idleTimer
|
|
1903
|
+
});
|
|
1904
|
+
return id;
|
|
1905
|
+
}
|
|
1906
|
+
/** Replace a crashed worker and re-queue or reject its active task. */
|
|
1907
|
+
handleWorkerCrash(id) {
|
|
1908
|
+
const entry = this.workers.get(id);
|
|
1909
|
+
if (!entry) return;
|
|
1910
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1911
|
+
entry.restarts++;
|
|
1912
|
+
this.totalRestarts++;
|
|
1913
|
+
entry.worker.terminate().catch(() => {});
|
|
1914
|
+
this.workers.delete(id);
|
|
1915
|
+
const activeTask = this.runningTasks.get(id);
|
|
1916
|
+
if (activeTask) {
|
|
1917
|
+
this.runningTasks.delete(id);
|
|
1918
|
+
activeTask.retries++;
|
|
1919
|
+
if (activeTask.retries > this.config.maxRetries) activeTask.reject(/* @__PURE__ */ new Error(`Task failed after ${activeTask.retries} worker crashes`));
|
|
1920
|
+
else this.queue.unshift(activeTask);
|
|
1921
|
+
}
|
|
1922
|
+
if (!this.destroyed && this.workers.size < this.config.size) this.spawnWorker();
|
|
1923
|
+
this.drain();
|
|
1924
|
+
}
|
|
1925
|
+
/** Try to dequeue and execute pending tasks. */
|
|
1926
|
+
drain() {
|
|
1927
|
+
if (this.destroyed) return;
|
|
1928
|
+
for (const [id, entry] of this.workers) if (!entry.busy && this.queue.length > 0) {
|
|
1929
|
+
const task = this.queue.shift();
|
|
1930
|
+
if (!task) return;
|
|
1931
|
+
entry.busy = true;
|
|
1932
|
+
if (entry.idleTimer) {
|
|
1933
|
+
clearTimeout(entry.idleTimer);
|
|
1934
|
+
entry.idleTimer = null;
|
|
1935
|
+
}
|
|
1936
|
+
this.runTask(id, entry.worker, task);
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
if (this.queue.length === 0) this.maybeShrink();
|
|
1940
|
+
}
|
|
1941
|
+
/** Execute a single task on a worker. */
|
|
1942
|
+
runTask(workerId, worker, task) {
|
|
1943
|
+
this.runningTasks.set(workerId, task);
|
|
1944
|
+
const onMessage = (result) => {
|
|
1945
|
+
cleanup();
|
|
1946
|
+
task.resolve(result);
|
|
1947
|
+
const entry = this.workers.get(workerId);
|
|
1948
|
+
if (entry) {
|
|
1949
|
+
entry.busy = false;
|
|
1950
|
+
entry.idleTimer = this.startIdleTimer(workerId);
|
|
1951
|
+
}
|
|
1952
|
+
this.drain();
|
|
1953
|
+
};
|
|
1954
|
+
const onError = (err) => {
|
|
1955
|
+
cleanup();
|
|
1956
|
+
task.reject(err);
|
|
1957
|
+
const entry = this.workers.get(workerId);
|
|
1958
|
+
if (entry) {
|
|
1959
|
+
entry.busy = false;
|
|
1960
|
+
entry.idleTimer = this.startIdleTimer(workerId);
|
|
1961
|
+
}
|
|
1962
|
+
this.drain();
|
|
1963
|
+
};
|
|
1964
|
+
const cleanup = () => {
|
|
1965
|
+
this.runningTasks.delete(workerId);
|
|
1966
|
+
worker.removeListener("message", onMessage);
|
|
1967
|
+
worker.removeListener("error", onError);
|
|
1968
|
+
};
|
|
1969
|
+
worker.once("message", onMessage);
|
|
1970
|
+
worker.once("error", onError);
|
|
1971
|
+
worker.postMessage({
|
|
1972
|
+
taskId: task.id,
|
|
1973
|
+
data: task.workerData
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
/** Start or reset the idle timer for a worker. */
|
|
1977
|
+
startIdleTimer(workerId) {
|
|
1978
|
+
return setTimeout(() => {
|
|
1979
|
+
this.shrinkWorker(workerId);
|
|
1980
|
+
}, this.config.idleTimeoutMs);
|
|
1981
|
+
}
|
|
1982
|
+
/** Attempt to shrink idle workers if above minimum. */
|
|
1983
|
+
maybeShrink() {
|
|
1984
|
+
const idleIds = [];
|
|
1985
|
+
for (const [id, entry] of this.workers) if (!entry.busy) idleIds.push(id);
|
|
1986
|
+
while (idleIds.length > MIN_POOL_SIZE && this.workers.size > MIN_POOL_SIZE) {
|
|
1987
|
+
const id = idleIds.pop();
|
|
1988
|
+
this.shrinkWorker(id);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
/** Terminate a specific idle worker. */
|
|
1992
|
+
shrinkWorker(workerId) {
|
|
1993
|
+
const entry = this.workers.get(workerId);
|
|
1994
|
+
if (!entry || entry.busy) return;
|
|
1995
|
+
if (this.workers.size <= MIN_POOL_SIZE) return;
|
|
1996
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
1997
|
+
entry.worker.terminate().catch(() => {});
|
|
1998
|
+
this.workers.delete(workerId);
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
//#endregion
|
|
2002
|
+
//#region src/commands/plugin.ts
|
|
2003
|
+
/**
|
|
2004
|
+
* Plugin command — manage installed plugins.
|
|
2005
|
+
*
|
|
2006
|
+
* Sub‑commands:
|
|
2007
|
+
* list — list installed plugins with status
|
|
2008
|
+
* enable <name> — enable a disabled plugin
|
|
2009
|
+
* disable <name> — disable a plugin (without uninstalling)
|
|
2010
|
+
* install <path> — install a plugin from a local path
|
|
2011
|
+
* remove <name> — uninstall a plugin
|
|
2012
|
+
*/
|
|
2013
|
+
var plugin_exports = /* @__PURE__ */ __exportAll({ handlePluginCommand: () => handlePluginCommand });
|
|
2014
|
+
/** Path to the installed‑plugins registry JSON file. */
|
|
2015
|
+
function registryPath() {
|
|
2016
|
+
return join(resolvePaths().home, "plugins.json");
|
|
2017
|
+
}
|
|
2018
|
+
/** Path to the Lynx extensions directory. */
|
|
2019
|
+
function extensionsDir() {
|
|
2020
|
+
return join(resolvePaths().home, "extensions");
|
|
2021
|
+
}
|
|
2022
|
+
function loadRegistry() {
|
|
2023
|
+
const path = registryPath();
|
|
2024
|
+
if (!existsSync(path)) return {};
|
|
2025
|
+
try {
|
|
2026
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
2027
|
+
} catch {
|
|
2028
|
+
const backupPath = path + ".bak";
|
|
2029
|
+
renameSync(path, backupPath);
|
|
2030
|
+
process.stderr.write(`[lynx] 警告:插件注册表已损坏,已备份到 ${backupPath}。正在使用默认配置。\n`);
|
|
2031
|
+
return {};
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
function saveRegistry(reg) {
|
|
2035
|
+
const path = registryPath();
|
|
2036
|
+
const dir = dirname(path);
|
|
2037
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2038
|
+
const tmp = path + ".tmp";
|
|
2039
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", "utf-8");
|
|
2040
|
+
renameSync(tmp, path);
|
|
2041
|
+
}
|
|
2042
|
+
function readManifest(pluginDir) {
|
|
2043
|
+
const manifestPath = join(pluginDir, "manifest.json");
|
|
2044
|
+
if (!existsSync(manifestPath)) return null;
|
|
2045
|
+
try {
|
|
2046
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
2047
|
+
} catch {
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
/** Scan the extensions directory for installed plugins. */
|
|
2052
|
+
function scanInstalled() {
|
|
2053
|
+
const dir = extensionsDir();
|
|
2054
|
+
const result = /* @__PURE__ */ new Map();
|
|
2055
|
+
if (!existsSync(dir)) return result;
|
|
2056
|
+
let entries;
|
|
2057
|
+
try {
|
|
2058
|
+
entries = readdirSync(dir);
|
|
2059
|
+
} catch {
|
|
2060
|
+
return result;
|
|
2061
|
+
}
|
|
2062
|
+
for (const entry of entries) {
|
|
2063
|
+
const fullPath = join(dir, entry);
|
|
2064
|
+
try {
|
|
2065
|
+
if (!__require("node:fs").statSync(fullPath).isDirectory()) continue;
|
|
2066
|
+
} catch {
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
const manifest = readManifest(fullPath);
|
|
2070
|
+
if (manifest) result.set(manifest.name, fullPath);
|
|
2071
|
+
}
|
|
2072
|
+
return result;
|
|
2073
|
+
}
|
|
2074
|
+
/** Handle the `lynx plugin` command. */
|
|
2075
|
+
async function handlePluginCommand(opts) {
|
|
2076
|
+
if (opts.list) {
|
|
2077
|
+
await listPlugins();
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
if (opts.enable) {
|
|
2081
|
+
await setPluginEnabled(opts.enable, true);
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (opts.disable) {
|
|
2085
|
+
await setPluginEnabled(opts.disable, false);
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
if (opts.install) {
|
|
2089
|
+
await installPlugin(opts.install);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
if (opts.remove) {
|
|
2093
|
+
await removePlugin(opts.remove);
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
await listPlugins();
|
|
2097
|
+
}
|
|
2098
|
+
async function listPlugins() {
|
|
2099
|
+
const installed = scanInstalled();
|
|
2100
|
+
const registry = loadRegistry();
|
|
2101
|
+
if (installed.size === 0) {
|
|
2102
|
+
process.stdout.write("No plugins installed.\n");
|
|
2103
|
+
process.stdout.write(`Extensions directory: ${extensionsDir()}\n`);
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
const lines = [];
|
|
2107
|
+
for (const [name, dirPath] of installed) {
|
|
2108
|
+
const manifest = readManifest(dirPath);
|
|
2109
|
+
const enabled = registry[name]?.enabled ?? true;
|
|
2110
|
+
const version = manifest?.version ?? "unknown";
|
|
2111
|
+
const type = manifest?.type ?? "unknown";
|
|
2112
|
+
const desc = manifest?.description ?? "";
|
|
2113
|
+
const status = enabled ? "enabled" : "disabled";
|
|
2114
|
+
lines.push(`${status === "disabled" ? "○" : "✓"} ${name}@${version} (${type}) ${status} — ${desc}`);
|
|
2115
|
+
lines.push(` path: ${dirPath}`);
|
|
2116
|
+
}
|
|
2117
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
2118
|
+
}
|
|
2119
|
+
async function setPluginEnabled(name, enabled) {
|
|
2120
|
+
const registry = loadRegistry();
|
|
2121
|
+
const installed = scanInstalled();
|
|
2122
|
+
if (!installed.has(name)) {
|
|
2123
|
+
process.stderr.write(`Plugin "${name}" is not installed.\n`);
|
|
2124
|
+
process.exitCode = 1;
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
registry[name] = {
|
|
2128
|
+
name,
|
|
2129
|
+
version: readManifest(installed.get(name))?.version ?? "unknown",
|
|
2130
|
+
description: readManifest(installed.get(name))?.description ?? "",
|
|
2131
|
+
type: readManifest(installed.get(name))?.type ?? "unknown",
|
|
2132
|
+
enabled,
|
|
2133
|
+
path: installed.get(name)
|
|
2134
|
+
};
|
|
2135
|
+
saveRegistry(registry);
|
|
2136
|
+
const action = enabled ? "enabled" : "disabled";
|
|
2137
|
+
process.stdout.write(`Plugin "${name}" ${action}.\n`);
|
|
2138
|
+
}
|
|
2139
|
+
async function installPlugin(sourcePath) {
|
|
2140
|
+
const manifest = readManifest(sourcePath);
|
|
2141
|
+
if (!manifest) {
|
|
2142
|
+
process.stderr.write(`No valid manifest.json found at "${sourcePath}".\n`);
|
|
2143
|
+
process.exitCode = 1;
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
const targetDir = join(extensionsDir(), manifest.name);
|
|
2147
|
+
if (existsSync(targetDir)) {
|
|
2148
|
+
process.stderr.write(`Plugin "${manifest.name}" is already installed at "${targetDir}".\nUse "lynx plugin remove ${manifest.name}" first to reinstall.\n`);
|
|
2149
|
+
process.exitCode = 1;
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
const { cpSync } = await import("node:fs");
|
|
2153
|
+
try {
|
|
2154
|
+
mkdirSync(dirname(targetDir), { recursive: true });
|
|
2155
|
+
cpSync(sourcePath, targetDir, { recursive: true });
|
|
2156
|
+
} catch (err) {
|
|
2157
|
+
process.stderr.write(`Failed to copy plugin: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2158
|
+
process.exitCode = 1;
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
const registry = loadRegistry();
|
|
2162
|
+
registry[manifest.name] = {
|
|
2163
|
+
name: manifest.name,
|
|
2164
|
+
version: manifest.version,
|
|
2165
|
+
description: manifest.description ?? "",
|
|
2166
|
+
type: manifest.type,
|
|
2167
|
+
enabled: true,
|
|
2168
|
+
path: targetDir
|
|
2169
|
+
};
|
|
2170
|
+
saveRegistry(registry);
|
|
2171
|
+
process.stdout.write(`Plugin "${manifest.name}@${manifest.version}" installed to "${targetDir}".\n`);
|
|
2172
|
+
}
|
|
2173
|
+
async function removePlugin(name) {
|
|
2174
|
+
const registry = loadRegistry();
|
|
2175
|
+
const dirPath = scanInstalled().get(name);
|
|
2176
|
+
if (!dirPath) {
|
|
2177
|
+
process.stderr.write(`Plugin "${name}" is not installed.\n`);
|
|
2178
|
+
process.exitCode = 1;
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const { rmSync } = await import("node:fs");
|
|
2182
|
+
try {
|
|
2183
|
+
rmSync(dirPath, {
|
|
2184
|
+
recursive: true,
|
|
2185
|
+
force: true
|
|
2186
|
+
});
|
|
2187
|
+
} catch (err) {
|
|
2188
|
+
process.stderr.write(`Failed to remove plugin directory: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2189
|
+
process.exitCode = 1;
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
delete registry[name];
|
|
2193
|
+
saveRegistry(registry);
|
|
2194
|
+
process.stdout.write(`Plugin "${name}" removed.\n`);
|
|
2195
|
+
}
|
|
2196
|
+
//#endregion
|
|
2197
|
+
//#region src/commands/git-commit.ts
|
|
2198
|
+
/**
|
|
2199
|
+
* 处理 /commit 命令,返回一段中文指令引导模型完成提交。
|
|
2200
|
+
*/
|
|
2201
|
+
function handleCommitCommand(args) {
|
|
2202
|
+
const { message, all } = args;
|
|
2203
|
+
const steps = [
|
|
2204
|
+
"# Git 提交任务",
|
|
2205
|
+
"",
|
|
2206
|
+
"请按以下步骤完成提交:",
|
|
2207
|
+
"",
|
|
2208
|
+
"1. 先运行 `git status` 查看当前工作区状态",
|
|
2209
|
+
"2. 运行 `git diff --stat` 查看变更文件摘要",
|
|
2210
|
+
"3. 运行 `git diff` 查看具体变更内容,根据变更内容生成有意义的提交消息"
|
|
2211
|
+
];
|
|
2212
|
+
if (all) steps.push("4. 运行 `git add -A` 暂存所有变更(包括新增、修改和删除)");
|
|
2213
|
+
else steps.push("4. 运行 `git add <files>` 暂存需要提交的文件(请根据 diff 内容选择合适的文件)");
|
|
2214
|
+
if (message && message.trim().length > 0) steps.push(`5. 运行 \`git commit -m "${message}"\` 创建提交`);
|
|
2215
|
+
else steps.push("5. 运行 `git commit -m \"<生成的提交消息>\"` 创建提交(使用约定式提交格式:feat/fix/refactor/chore + 中文描述)");
|
|
2216
|
+
steps.push("6. 运行 `git push` 推送到远程仓库", "", "注意事项:", "- 提交消息使用约定式提交格式:类型用英文(feat/fix/refactor/chore/docs/test),描述用中文", "- 每次提交后立即执行 git push", "- 如果推送失败(如远程有更新),先执行 git pull --rebase 再推送", "- 不要提交 .env 文件或包含密钥的配置文件");
|
|
2217
|
+
return { instruction: steps.join("\n") };
|
|
2218
|
+
}
|
|
2219
|
+
//#endregion
|
|
2220
|
+
//#region src/commands/git-review.ts
|
|
2221
|
+
/**
|
|
2222
|
+
* 处理 /review 命令,返回一段中文指令引导模型完成代码审查。
|
|
2223
|
+
*/
|
|
2224
|
+
function handleReviewCommand(args) {
|
|
2225
|
+
const { number, base } = args;
|
|
2226
|
+
const steps = [
|
|
2227
|
+
"# GitHub PR 审查任务",
|
|
2228
|
+
"",
|
|
2229
|
+
"请按以下步骤完成 PR 审查:",
|
|
2230
|
+
""
|
|
2231
|
+
];
|
|
2232
|
+
if (number !== void 0) steps.push(`1. 运行 \`gh pr view ${number} --json number,title,author,body,state,additions,deletions,files\` 获取 PR #${number} 的详细信息`, `2. 运行 \`gh pr diff ${number}\` 获取 PR #${number} 的完整变更内容`);
|
|
2233
|
+
else {
|
|
2234
|
+
const baseFilter = base ? ` --base ${base}` : "";
|
|
2235
|
+
steps.push(`1. 运行 \`gh pr list --state open --limit 10${baseFilter}\` 列出待审查的 PR`, "2. 选择一个需要审查的 PR,运行 `gh pr diff <number>` 获取完整变更内容");
|
|
2236
|
+
}
|
|
2237
|
+
steps.push("", "3. 系统化分析变更内容(按以下维度):", "", " a) 正确性:逻辑错误、边界条件处理、空值/null 检查、竞态条件", " b) 安全性:输入校验、注入风险、密钥泄露、权限检查", " c) 代码风格:命名规范、函数长度、嵌套层级、重复代码", " d) 测试:测试覆盖率、边界情况覆盖、错误路径测试", " e) 架构:模块耦合度、接口设计、依赖方向是否正确", " f) 性能:不必要的循环、大对象分配、阻塞 IO", "", "4. 输出结构化审查报告:", "", " - 总体评价(1-2 句话总结)", " - 严重问题(必须修复才能合并)", " - 建议改进(推荐但不阻塞合并)", " - 亮点(做得好的地方)", "", "注意:审查要具体,引用具体文件和行号。", "如果 PR 描述中有验收标准,逐条检查是否满足。");
|
|
2238
|
+
return { instruction: steps.join("\n") };
|
|
2239
|
+
}
|
|
2240
|
+
//#endregion
|
|
2241
|
+
//#region src/commands/git-pr-comments.ts
|
|
2242
|
+
/**
|
|
2243
|
+
* 处理 /pr-comments 命令,返回一段中文指令引导模型处理 PR 评论。
|
|
2244
|
+
*/
|
|
2245
|
+
function handlePrCommentsCommand(args) {
|
|
2246
|
+
const { number } = args;
|
|
2247
|
+
const steps = [
|
|
2248
|
+
"# PR 评论处理任务",
|
|
2249
|
+
"",
|
|
2250
|
+
"请按以下步骤处理 PR 审查评论:",
|
|
2251
|
+
""
|
|
2252
|
+
];
|
|
2253
|
+
if (number !== void 0) steps.push(`1. 运行 \`gh pr view ${number} --comments\` 获取 PR #${number} 的所有评论`, `2. 运行 \`gh pr view ${number} --json reviewRequests,reviews\` 查看审查状态`);
|
|
2254
|
+
else steps.push("1. 运行 `gh pr list --state open --limit 5` 找到当前活跃的 PR", "2. 对目标 PR 运行 `gh pr view <number> --comments` 获取所有评论");
|
|
2255
|
+
steps.push("", "3. 逐条处理评论:", "", " 对于未解决的评论(需要代码修改):", " - 理解评论指出的问题", " - 使用 read_file 读取相关文件,确认上下文", " - 使用 edit_file 或 write_file 进行针对性修改", " - 修改后使用 gh api 回复评论说明修改内容", "", " 对于已解决的评论(无需修改):", " - 添加简短回复确认处理完毕(如 \"已修复,感谢指正\")", "", "4. 所有评论处理完后,运行 `gh pr view <number> --comments` 确认没有遗漏", "", "注意:", "- 一次修改尽量覆盖多条相关评论,减少提交次数", "- 如有不理解的评论,标记出来请求澄清", "- 修改完成后简要总结所有变更");
|
|
2256
|
+
return { instruction: steps.join("\n") };
|
|
2257
|
+
}
|
|
2258
|
+
//#endregion
|
|
2259
|
+
//#region src/commands/git-issue.ts
|
|
2260
|
+
/**
|
|
2261
|
+
* 处理 /issue 命令,返回一段中文指令引导模型完成 Issue 操作。
|
|
2262
|
+
*/
|
|
2263
|
+
function handleIssueCommand(args) {
|
|
2264
|
+
const { action = "list", title, number } = args;
|
|
2265
|
+
const steps = [];
|
|
2266
|
+
switch (action) {
|
|
2267
|
+
case "create":
|
|
2268
|
+
steps.push("# 创建 GitHub Issue", "", "请按以下步骤创建新 Issue:", "");
|
|
2269
|
+
if (title && title.trim().length > 0) steps.push("1. 基于以下标题编写详细的 Issue 正文(包括问题描述、复现步骤、预期行为、环境信息)", ` 标题:${title}`);
|
|
2270
|
+
else steps.push("1. 首先了解需要创建什么 Issue——分析当前上下文,确定 Issue 的主题和内容", "2. 编写 Issue 正文(包括问题描述、复现步骤、预期行为、环境信息)");
|
|
2271
|
+
steps.push("", `3. 运行 \`gh issue create --title "<标题>" --body "<正文>"\` 创建 Issue`, "", "注意:", "- 如果是 Bug 报告,需要包含:环境、复现步骤、实际行为、预期行为", "- 如果是功能请求,需要包含:动机、方案描述、替代方案", "- 描述要清晰具体,让维护者无需追问即可理解");
|
|
2272
|
+
break;
|
|
2273
|
+
case "view":
|
|
2274
|
+
if (number !== void 0) steps.push("# 查看 GitHub Issue", "", `1. 运行 \`gh issue view ${number}\` 查看 Issue #${number} 详情`, `2. 运行 \`gh issue view ${number} --comments\` 查看所有评论`, "3. 总结 Issue 关键信息:标题、状态、标签、提出者、讨论要点");
|
|
2275
|
+
else steps.push("# 查看 GitHub Issue", "", "1. 运行 `gh issue list --state open --limit 10` 列出活跃 Issue", "2. 选择一个 Issue,运行 `gh issue view <number>` 查看详情", "3. 总结 Issue 关键信息");
|
|
2276
|
+
break;
|
|
2277
|
+
default:
|
|
2278
|
+
steps.push("# 列出 GitHub Issue", "", "1. 运行 `gh issue list --state open --limit 20` 列出活跃 Issue", "2. 以结构化表格展示:编号、标题、标签、状态、负责人、更新时间", "3. 可选:添加 --label <name> 按标签过滤,或 --assignee <user> 按负责人过滤");
|
|
2279
|
+
break;
|
|
2280
|
+
}
|
|
2281
|
+
return { instruction: steps.join("\n") };
|
|
2282
|
+
}
|
|
2283
|
+
//#endregion
|
|
2284
|
+
//#region src/commands/git-autofix.ts
|
|
2285
|
+
/**
|
|
2286
|
+
* 处理 /autofix-pr 命令。
|
|
2287
|
+
*
|
|
2288
|
+
* 当前为占位实现,返回开发中提示信息。
|
|
2289
|
+
* 后续将实现完整的自动修复管线。
|
|
2290
|
+
*/
|
|
2291
|
+
function handleAutoFixCommand(_args) {
|
|
2292
|
+
return { instruction: "自动修复功能正在开发中,暂不可用。请关注后续版本更新。" };
|
|
2293
|
+
}
|
|
2294
|
+
//#endregion
|
|
2295
|
+
//#region src/commands/git-diff.ts
|
|
2296
|
+
/**
|
|
2297
|
+
* 处理 /diff 命令,返回一段中文指令引导模型展示代码变更。
|
|
2298
|
+
*/
|
|
2299
|
+
function handleDiffCommand(args) {
|
|
2300
|
+
const { staged, files } = args;
|
|
2301
|
+
const steps = [
|
|
2302
|
+
"# Git 变更查看任务",
|
|
2303
|
+
"",
|
|
2304
|
+
"请按以下步骤展示工作区变更:",
|
|
2305
|
+
""
|
|
2306
|
+
];
|
|
2307
|
+
if (staged) {
|
|
2308
|
+
steps.push("1. 运行 `git diff --staged --stat` 查看已暂存变更的文件摘要");
|
|
2309
|
+
if (files && files.length > 0) {
|
|
2310
|
+
const fileList = files.map((f) => `"${f}"`).join(" ");
|
|
2311
|
+
steps.push(`2. 运行 \`git diff --staged -- ${fileList}\` 查看指定文件的已暂存变更详情`);
|
|
2312
|
+
} else steps.push("2. 运行 `git diff --staged` 查看所有已暂存变更详情");
|
|
2313
|
+
} else {
|
|
2314
|
+
steps.push("1. 运行 `git diff --stat` 查看未暂存变更的文件摘要");
|
|
2315
|
+
steps.push("2. 运行 `git status --short` 查看工作区状态概览");
|
|
2316
|
+
if (files && files.length > 0) {
|
|
2317
|
+
const fileList = files.map((f) => `"${f}"`).join(" ");
|
|
2318
|
+
steps.push(`3. 运行 \`git diff -- ${fileList}\` 查看指定文件的变更详情`);
|
|
2319
|
+
} else steps.push("3. 运行 `git diff` 查看所有未暂存变更详情");
|
|
2320
|
+
steps.push("4. 运行 `git diff --staged --stat` 同时查看已暂存变更(如有)");
|
|
2321
|
+
}
|
|
2322
|
+
steps.push("", "5. 以结构化格式展示变更:", "", " - 变更文件列表(按状态分类:新增 A、修改 M、删除 D、重命名 R)", " - 每个文件的核心变更摘要(增减行数、主要修改内容)", " - 高亮关键变更(如公共 API 签名修改、配置变更、新增依赖)", " - 如有安全问题提醒(如密钥硬编码、不安全依赖版本)", "", "注意:", "- 如果 diff 输出过长,优先展示关键文件的变更", "- 对于二进制文件变更,说明文件名和大小变化即可", "- 如果没有任何变更,明确告知用户工作区干净");
|
|
2323
|
+
return { instruction: steps.join("\n") };
|
|
2324
|
+
}
|
|
2325
|
+
//#endregion
|
|
2326
|
+
//#region src/commands/ant-trace.ts
|
|
2327
|
+
/**
|
|
2328
|
+
* 处理 /ant-trace 命令。
|
|
2329
|
+
*
|
|
2330
|
+
* 返回一条 model instruction,驱动模型检查会话历史中的
|
|
2331
|
+
* 工具调用链并生成结构化诊断输出。
|
|
2332
|
+
*/
|
|
2333
|
+
function handleAntTraceCommand(opts) {
|
|
2334
|
+
const scope = opts.sessionId ? `会话 ${opts.sessionId}` : "当前会话";
|
|
2335
|
+
return { instruction: `请对${scope}中的工具调用链进行全面追踪分析,输出结构化调用树。
|
|
2336
|
+
|
|
2337
|
+
### 分析步骤
|
|
2338
|
+
|
|
2339
|
+
1. **扫描消息**:遍历会话中所有消息,找出 tool_use 和 tool_result 块。
|
|
2340
|
+
2. **构建调用树**:根据 tool_use 的父子关系(通过 callId / parentCallId 关联)构建树状结构。
|
|
2341
|
+
3. **计算耗时**:为每个工具调用计算从 tool_use 到对应 tool_result 的时间差。
|
|
2342
|
+
4. **标记异常**:对以下情况高亮标注:
|
|
2343
|
+
- 非零 exitCode 的调用
|
|
2344
|
+
- 返回 error 的调用
|
|
2345
|
+
- 耗时超过 10 秒的调用(慢调用)
|
|
2346
|
+
- 被 abort 中断的调用
|
|
2347
|
+
5. **输出格式**:以缩进文本树形式呈现,包含:
|
|
2348
|
+
- 工具名称
|
|
2349
|
+
- 调用耗时(ms)
|
|
2350
|
+
- 输入摘要(截断至 80 字符)
|
|
2351
|
+
- 输出摘要(截断至 80 字符)
|
|
2352
|
+
- 状态标记:✓ 成功 / ✗ 失败 / ⚠ 慢调用 / ⊗ 已中断
|
|
2353
|
+
|
|
2354
|
+
### 输出示例
|
|
2355
|
+
|
|
2356
|
+
\`\`\`
|
|
2357
|
+
ant-trace — ${scope} 工具调用链
|
|
2358
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2359
|
+
|
|
2360
|
+
└─ todo_write (12ms) ✓
|
|
2361
|
+
├─ 输入: {"todos": [...]}
|
|
2362
|
+
└─ 输出: todos updated
|
|
2363
|
+
|
|
2364
|
+
└─ read_file (45ms) ✓
|
|
2365
|
+
├─ 输入: {"file_path": "..."}
|
|
2366
|
+
└─ 输出: 156 lines read
|
|
2367
|
+
|
|
2368
|
+
└─ bash:npm test (3420ms) ⚠ 慢调用
|
|
2369
|
+
├─ 输入: {"command": "npm test"}
|
|
2370
|
+
└─ 输出: Tests: 12 passed, 1 failed
|
|
2371
|
+
└─ grep (180ms) ✗ 失败
|
|
2372
|
+
├─ 输入: {"pattern": "error"}
|
|
2373
|
+
└─ 错误: exit code 1
|
|
2374
|
+
|
|
2375
|
+
═══════════════════════════════
|
|
2376
|
+
总计:15 次工具调用,13 成功,1 失败,1 慢调用
|
|
2377
|
+
总耗时:3,847ms
|
|
2378
|
+
\`\`\`
|
|
2379
|
+
|
|
2380
|
+
请先通过搜索会话消息构建调用树,再输出分析结果。` };
|
|
2381
|
+
}
|
|
2382
|
+
//#endregion
|
|
2383
|
+
//#region src/commands/debug-tool-call.ts
|
|
2384
|
+
/**
|
|
2385
|
+
* 处理 /debug-tool-call 命令。
|
|
2386
|
+
*
|
|
2387
|
+
* 返回一条 model instruction,驱动模型检查会话历史中的
|
|
2388
|
+
* 工具调用并输出调试信息。
|
|
2389
|
+
*/
|
|
2390
|
+
function handleDebugToolCall(opts) {
|
|
2391
|
+
if (opts.tool) {
|
|
2392
|
+
const toolName = opts.tool;
|
|
2393
|
+
return { instruction: `请调试工具 "${toolName}" 在当前会话中最后一次调用的完整详情。
|
|
2394
|
+
|
|
2395
|
+
### 分析要求
|
|
2396
|
+
|
|
2397
|
+
1. **定位调用**:在会话消息中搜索最后一个 tool_use 名称为 "${toolName}" 的调用。
|
|
2398
|
+
2. **展示完整输入**:格式化输出完整的 input JSON(不做截断)。
|
|
2399
|
+
3. **展示完整输出**:格式化输出 tool_result 的完整内容。
|
|
2400
|
+
4. **错误诊断**:如果调用返回错误或非零退出码,分析可能原因:
|
|
2401
|
+
- 输入参数是否有问题?
|
|
2402
|
+
- 环境(路径、权限、网络)是否有问题?
|
|
2403
|
+
- 是否有并发竞争?
|
|
2404
|
+
5. **耗时细分**:如果可用,将耗时分解为:
|
|
2405
|
+
- 网络往返时间(如有)
|
|
2406
|
+
- 进程启动时间(如适用)
|
|
2407
|
+
- 实际处理时间
|
|
2408
|
+
- 总耗时
|
|
2409
|
+
|
|
2410
|
+
### 输出格式
|
|
2411
|
+
|
|
2412
|
+
\`\`\`
|
|
2413
|
+
debug-tool-call: ${toolName}
|
|
2414
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2415
|
+
状态:✓ 成功 / ✗ 失败
|
|
2416
|
+
|
|
2417
|
+
📥 完整输入:
|
|
2418
|
+
{完整 JSON}
|
|
2419
|
+
|
|
2420
|
+
📤 完整输出:
|
|
2421
|
+
{完整输出内容}
|
|
2422
|
+
|
|
2423
|
+
⏱ 耗时细分:
|
|
2424
|
+
总计:235ms
|
|
2425
|
+
└─ 网络:12ms
|
|
2426
|
+
└─ 处理:210ms
|
|
2427
|
+
└─ 其他:13ms
|
|
2428
|
+
|
|
2429
|
+
🔍 诊断结论:
|
|
2430
|
+
{分析结论或"无异常"}
|
|
2431
|
+
\`\`\`
|
|
2432
|
+
|
|
2433
|
+
如果找不到该工具的调用记录,请明确报告。` };
|
|
2434
|
+
}
|
|
2435
|
+
if (opts.step) {
|
|
2436
|
+
const stepIdx = opts.step;
|
|
2437
|
+
return { instruction: `请展示会话中步骤 ${stepIdx} 处的所有工具调用详情。
|
|
2438
|
+
|
|
2439
|
+
### 步骤定义
|
|
2440
|
+
|
|
2441
|
+
步骤按时间顺序编号(从 1 开始),每个步骤包含该轮对话中调用的所有工具。
|
|
2442
|
+
步骤 ${stepIdx} 可能包含多个并行或串行的工具调用。
|
|
2443
|
+
|
|
2444
|
+
### 分析要求
|
|
2445
|
+
|
|
2446
|
+
1. **列出所有调用**:该步骤中所有的 tool_use 名称和 callId。
|
|
2447
|
+
2. **逐一展示详情**:
|
|
2448
|
+
- 输入参数(完整 JSON)
|
|
2449
|
+
- 输出内容(完整文本)
|
|
2450
|
+
- 执行耗时(ms)
|
|
2451
|
+
- 成功/失败状态
|
|
2452
|
+
3. **步骤总结**:
|
|
2453
|
+
- 调用数量
|
|
2454
|
+
- 成功/失败统计
|
|
2455
|
+
- 总耗时
|
|
2456
|
+
|
|
2457
|
+
### 输出格式
|
|
2458
|
+
|
|
2459
|
+
\`\`\`
|
|
2460
|
+
debug-tool-call: 步骤 ${stepIdx}
|
|
2461
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2462
|
+
本步骤共 N 次工具调用(M 成功 / K 失败)
|
|
2463
|
+
|
|
2464
|
+
── 调用 1:<工具名> ──────────────────
|
|
2465
|
+
状态:✓
|
|
2466
|
+
输入:{...}
|
|
2467
|
+
输出:{...}
|
|
2468
|
+
耗时:120ms
|
|
2469
|
+
|
|
2470
|
+
── 调用 2:<工具名> ──────────────────
|
|
2471
|
+
状态:✗ 失败
|
|
2472
|
+
输入:{...}
|
|
2473
|
+
错误:...
|
|
2474
|
+
耗时:45ms
|
|
2475
|
+
\`\`\`
|
|
2476
|
+
|
|
2477
|
+
如果步骤 ${stepIdx} 不存在,请报告可用步骤范围。` };
|
|
2478
|
+
}
|
|
2479
|
+
return { instruction: `请列出当前会话中最近的工具调用概览(最多 20 条)。
|
|
2480
|
+
|
|
2481
|
+
### 输出格式
|
|
2482
|
+
|
|
2483
|
+
以编号列表展示,每条包含:
|
|
2484
|
+
- 步骤编号(可用作 --step 参数)
|
|
2485
|
+
- 工具名称
|
|
2486
|
+
- 调用耗时(ms)
|
|
2487
|
+
- 状态图标(✓ / ✗ / ⚠)
|
|
2488
|
+
- 输入摘要(截断至 60 字符)
|
|
2489
|
+
|
|
2490
|
+
\`\`\`
|
|
2491
|
+
debug-tool-call: 最近调用概览
|
|
2492
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2493
|
+
|
|
2494
|
+
#1 步骤 1 todo_write 12ms ✓ {"todos": [...]}
|
|
2495
|
+
#2 步骤 1 read_file 45ms ✓ {"file_path": "src/..."}
|
|
2496
|
+
#3 步骤 2 bash 3420ms ⚠ {"command": "npm t..."}
|
|
2497
|
+
#4 步骤 2 grep 180ms ✗ {"pattern": "err..."}
|
|
2498
|
+
#5 步骤 3 write_file 23ms ✓ {"file_path": "src/..."}
|
|
2499
|
+
|
|
2500
|
+
────────────────────────────────
|
|
2501
|
+
共 15 次调用。使用 --step <N> 查看某步骤详情,--tool <name> 查看特定工具详情。
|
|
2502
|
+
\`\`\`
|
|
2503
|
+
|
|
2504
|
+
请根据会话消息生成上述列表。` };
|
|
2505
|
+
}
|
|
2506
|
+
//#endregion
|
|
2507
|
+
//#region src/commands/tasks.ts
|
|
2508
|
+
/**
|
|
2509
|
+
* 处理 /tasks 命令。
|
|
2510
|
+
*
|
|
2511
|
+
* 根据 action 返回不同的 model instruction,驱动模型
|
|
2512
|
+
* 执行相应的任务管理操作。
|
|
2513
|
+
*/
|
|
2514
|
+
function handleTasksCommand(opts) {
|
|
2515
|
+
switch (opts.action ?? "list") {
|
|
2516
|
+
case "cancel":
|
|
2517
|
+
if (!opts.taskId) return { instruction: "请提示用户:取消任务需要提供 --taskId 参数。例如:/tasks --action cancel --taskId <id>" };
|
|
2518
|
+
return { instruction: `请取消任务 "${opts.taskId}"。
|
|
2519
|
+
|
|
2520
|
+
### 操作步骤
|
|
2521
|
+
|
|
2522
|
+
1. 使用 task 工具的 cancel 操作取消该任务。
|
|
2523
|
+
2. 如果任务不存在或已经结束,报告给用户。
|
|
2524
|
+
3. 如果取消成功,确认并展示任务在取消前的状态。
|
|
2525
|
+
|
|
2526
|
+
### 输出格式
|
|
2527
|
+
|
|
2528
|
+
\`\`\`
|
|
2529
|
+
取消任务:${opts.taskId}
|
|
2530
|
+
━━━━━━━━━━━━━━━━━━━━
|
|
2531
|
+
状态:已取消
|
|
2532
|
+
任务名称:<name>
|
|
2533
|
+
运行时长:<duration>
|
|
2534
|
+
\`\`\`` };
|
|
2535
|
+
case "detail":
|
|
2536
|
+
if (!opts.taskId) return { instruction: "请提示用户:查看任务详情需要提供 --taskId 参数。例如:/tasks --action detail --taskId <id>" };
|
|
2537
|
+
return { instruction: `请展示任务 "${opts.taskId}" 的完整详情。
|
|
2538
|
+
|
|
2539
|
+
### 内容要求
|
|
2540
|
+
|
|
2541
|
+
1. **基本信息**:任务 ID、名称、状态、创建时间、运行时长
|
|
2542
|
+
2. **输入参数**:任务启动时的完整输入
|
|
2543
|
+
3. **输出流**:任务执行期间产生的所有输出(stdout + stderr)
|
|
2544
|
+
4. **错误信息**:如果任务失败,展示完整错误堆栈
|
|
2545
|
+
5. **子任务**:如果该任务包含子任务,列出子任务树
|
|
2546
|
+
|
|
2547
|
+
### 输出格式
|
|
2548
|
+
|
|
2549
|
+
\`\`\`
|
|
2550
|
+
任务详情:${opts.taskId}
|
|
2551
|
+
━━━━━━━━━━━━━━━━━━━━━━
|
|
2552
|
+
|
|
2553
|
+
📋 基本信息
|
|
2554
|
+
名称:<任务名称>
|
|
2555
|
+
状态:运行中 / 完成 / 失败 / 排队中
|
|
2556
|
+
创建:<ISO 时间>
|
|
2557
|
+
运行时长:<duration>
|
|
2558
|
+
|
|
2559
|
+
📥 输入参数
|
|
2560
|
+
{完整 JSON}
|
|
2561
|
+
|
|
2562
|
+
📤 输出流
|
|
2563
|
+
────────────────────────
|
|
2564
|
+
{完整 stdout 输出}
|
|
2565
|
+
────────────────────────
|
|
2566
|
+
{如 stderr 有内容则展示}
|
|
2567
|
+
|
|
2568
|
+
❌ 错误信息
|
|
2569
|
+
{如有}
|
|
2570
|
+
\`\`\`` };
|
|
2571
|
+
default: return { instruction: `请列出当前所有后台任务的状态概览。
|
|
2572
|
+
|
|
2573
|
+
### 内容要求
|
|
2574
|
+
|
|
2575
|
+
展示以下信息(每项任务一行):
|
|
2576
|
+
- **状态图标**:
|
|
2577
|
+
- ⟳ 运行中(青色高亮)
|
|
2578
|
+
- ○ 排队中(灰色)
|
|
2579
|
+
- ✓ 完成(绿色)
|
|
2580
|
+
- ✗ 失败(红色)
|
|
2581
|
+
- **进度条**:运行中的任务展示完成百分比(如 "[████████░░] 80%")
|
|
2582
|
+
- **任务名称**
|
|
2583
|
+
- **运行时长**(运行中的任务)
|
|
2584
|
+
- **错误摘要**(失败的任务,截断至一行)
|
|
2585
|
+
- **取消按钮**:对运行中的任务提示可用 "/tasks cancel --taskId <id>" 取消
|
|
2586
|
+
|
|
2587
|
+
### 输出格式
|
|
2588
|
+
|
|
2589
|
+
\`\`\`
|
|
2590
|
+
任务面板
|
|
2591
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2592
|
+
|
|
2593
|
+
⟳ phase2-scanExtensions [████████░░] 80% 已运行 3s
|
|
2594
|
+
✓ phase2-preloadSkills 完成 (1.2s)
|
|
2595
|
+
✗ phase2-connectMcpServers 失败 — 连接超时
|
|
2596
|
+
○ phase2-loadMemory 排队中
|
|
2597
|
+
✓ phase2-initChannels 完成 (0.8s)
|
|
2598
|
+
|
|
2599
|
+
────────────────────────────────────────────
|
|
2600
|
+
共 5 个任务:1 运行中,1 排队,2 完成,1 失败
|
|
2601
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2602
|
+
|
|
2603
|
+
📌 提示:
|
|
2604
|
+
/tasks --action detail --taskId <id> 查看任务完整输出
|
|
2605
|
+
/tasks --action cancel --taskId <id> 取消运行中的任务
|
|
2606
|
+
\`\`\`
|
|
2607
|
+
|
|
2608
|
+
请调用 task 工具的 list 操作获取任务列表,然后按上述格式输出。` };
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
//#endregion
|
|
2612
|
+
//#region src/commands/heapdump.ts
|
|
2613
|
+
/**
|
|
2614
|
+
* /heapdump — V8 堆快照,用于内存泄漏诊断。
|
|
2615
|
+
*
|
|
2616
|
+
* 调用 Node.js 内置的 writeHeapSnapshot() 生成 .heapsnapshot 文件,
|
|
2617
|
+
* 保存到 ~/.lynx/heapdumps/ 目录下。可在 Chrome DevTools 的
|
|
2618
|
+
* Memory 面板中加载分析。
|
|
2619
|
+
*
|
|
2620
|
+
* 用法:/heapdump
|
|
2621
|
+
*/
|
|
2622
|
+
/** 堆快照输出目录 */
|
|
2623
|
+
const HEAPDUMP_DIR = join(homedir(), ".lynx", "heapdumps");
|
|
2624
|
+
/**
|
|
2625
|
+
* 处理 /heapdump 命令。
|
|
2626
|
+
*
|
|
2627
|
+
* 生成立即堆快照并返回文件路径。这是 local 类型命令——
|
|
2628
|
+
* 不经过模型,直接在 CLI 进程中执行。
|
|
2629
|
+
*/
|
|
2630
|
+
async function handleHeapdumpCommand() {
|
|
2631
|
+
if (!existsSync(HEAPDUMP_DIR)) try {
|
|
2632
|
+
mkdirSync(HEAPDUMP_DIR, { recursive: true });
|
|
2633
|
+
} catch (err) {
|
|
2634
|
+
return { output: `错误:无法创建堆快照目录 ${HEAPDUMP_DIR}:${err instanceof Error ? err.message : String(err)}` };
|
|
2635
|
+
}
|
|
2636
|
+
const filepath = join(HEAPDUMP_DIR, `heap-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.heapsnapshot`);
|
|
2637
|
+
try {
|
|
2638
|
+
const filename = writeHeapSnapshot(filepath);
|
|
2639
|
+
return { output: `堆快照已保存到:${filename}\n\n共 ${getDumpCount()} 个快照文件。\n分析步骤:\n 1. 打开 Chrome DevTools\n 2. 切换到 Memory 面板\n 3. 点击 "Load" 加载 "${filename}"\n 4. 按 Retained Size 排序查找内存泄漏` };
|
|
2640
|
+
} catch (err) {
|
|
2641
|
+
return { output: `错误:堆快照生成失败:${err instanceof Error ? err.message : String(err)}` };
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* 获取 dump 目录下的快照文件数量(用于提示用户定期清理)。
|
|
2646
|
+
*/
|
|
2647
|
+
function getDumpCount() {
|
|
2648
|
+
try {
|
|
2649
|
+
const { readdirSync } = __require("node:fs");
|
|
2650
|
+
return readdirSync(HEAPDUMP_DIR).filter((f) => f.endsWith(".heapsnapshot")).length;
|
|
2651
|
+
} catch {
|
|
2652
|
+
return -1;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
//#endregion
|
|
2656
|
+
//#region src/commands/perf-issue.ts
|
|
2657
|
+
const HEALTH_CHECK_INSTRUCTION = `请对当前会话进行全面的性能健康检查,输出诊断报告。
|
|
2658
|
+
|
|
2659
|
+
### 检查项目
|
|
2660
|
+
|
|
2661
|
+
1. **慢调用筛查**
|
|
2662
|
+
- 扫描会话中所有 tool_use/tool_result 对
|
|
2663
|
+
- 计算每次工具调用的耗时
|
|
2664
|
+
- 标记耗时 > 5 秒的"慢调用"和 > 30 秒的"极慢调用"
|
|
2665
|
+
- 分析慢调用的公共模式(相同的工具?相似的输入?特定时间段?)
|
|
2666
|
+
|
|
2667
|
+
2. **日志分析**
|
|
2668
|
+
- 检查 ~/.lynx/logs/ 目录下的最新日志文件
|
|
2669
|
+
- 查找错误、警告和超时记录
|
|
2670
|
+
- 查找重复出现的模式(如每 30 秒的重试风暴)
|
|
2671
|
+
- 提取关键性能指标(如 MCP 连接延迟、模型响应延迟)
|
|
2672
|
+
|
|
2673
|
+
3. **内存使用模式**
|
|
2674
|
+
- 查看最近的内存监控数据(如有)
|
|
2675
|
+
- 检查是否存在持续增长的内存趋势(可能泄漏)
|
|
2676
|
+
- 注意 heapUsed 在短时间内陡增的模式
|
|
2677
|
+
|
|
2678
|
+
4. **会话效率**
|
|
2679
|
+
- 统计工具调用次数和总耗时
|
|
2680
|
+
- 计算"有效工具调用比"(有产出的调用 / 总调用)
|
|
2681
|
+
- 识别冗余调用(重复读取同一文件、重复搜索相同模式)
|
|
2682
|
+
|
|
2683
|
+
5. **优化建议**
|
|
2684
|
+
- 针对发现的问题给出具体优化建议
|
|
2685
|
+
- 按优先级排序:高(立即修复)、中(下次迭代)、低(长期优化)
|
|
2686
|
+
|
|
2687
|
+
### 输出格式
|
|
2688
|
+
|
|
2689
|
+
\`\`\`
|
|
2690
|
+
性能健康检查报告
|
|
2691
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2692
|
+
|
|
2693
|
+
📊 会话概况
|
|
2694
|
+
会话时长:<duration>
|
|
2695
|
+
工具调用:N 次
|
|
2696
|
+
总耗时:<X>ms
|
|
2697
|
+
平均耗时:<Y>ms/调用
|
|
2698
|
+
|
|
2699
|
+
🐢 慢调用(共 N 个)
|
|
2700
|
+
#1 todo_write 5.2s ⚠ 超过阈值
|
|
2701
|
+
#2 bash 32.1s 🔴 极慢
|
|
2702
|
+
|
|
2703
|
+
📋 日志摘要
|
|
2704
|
+
[WARN] MCP 连接超时 — 出现 3 次
|
|
2705
|
+
[ERROR] 文件未找到 — 出现 1 次
|
|
2706
|
+
|
|
2707
|
+
🧠 内存
|
|
2708
|
+
当前堆使用:<X>MB
|
|
2709
|
+
趋势:稳定 / 上升(12% 增长)
|
|
2710
|
+
风险等级:低 / 中 / 高
|
|
2711
|
+
|
|
2712
|
+
📈 效率分析
|
|
2713
|
+
有效调用率:85%(17/20)
|
|
2714
|
+
冗余调用:2 次(重复读取同一文件)
|
|
2715
|
+
|
|
2716
|
+
💡 优化建议
|
|
2717
|
+
🔴 [高] 合并对同一文件的多次读取
|
|
2718
|
+
🟡 [中] 为频繁搜索添加缓存
|
|
2719
|
+
🟢 [低] 考虑减少 todo_write 的调用频率
|
|
2720
|
+
\`\`\``;
|
|
2721
|
+
/**
|
|
2722
|
+
* 处理 /perf-issue 命令。
|
|
2723
|
+
*
|
|
2724
|
+
* 返回 model instruction,驱动模型执行性能诊断分析。
|
|
2725
|
+
*/
|
|
2726
|
+
function handlePerfIssueCommand(opts) {
|
|
2727
|
+
if (opts.issue) return { instruction: `请诊断以下性能问题:"${opts.issue}"
|
|
2728
|
+
|
|
2729
|
+
### 诊断步骤
|
|
2730
|
+
|
|
2731
|
+
1. **复现分析**:根据问题描述,推测最可能的性能瓶颈点。
|
|
2732
|
+
2. **日志排查**:检查 ~/.lynx/logs/ 目录下相关日志,搜索与问题相关的错误或警告。
|
|
2733
|
+
3. **调用链分析**:如果在会话历史中,查找与问题描述相关的工具调用,分析其耗时特征。
|
|
2734
|
+
4. **系统资源**:检查当前进程的 CPU 和内存使用情况。
|
|
2735
|
+
5. **环境因素**:考虑网络延迟、磁盘 I/O、第三方服务响应时间等外部因素。
|
|
2736
|
+
6. **根因分析**:给出最可能的根因(不超过 3 个)。
|
|
2737
|
+
7. **解决方案**:针对每个根因给出具体的修复步骤。
|
|
2738
|
+
|
|
2739
|
+
### 输出格式
|
|
2740
|
+
|
|
2741
|
+
\`\`\`
|
|
2742
|
+
性能诊断:${opts.issue}
|
|
2743
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2744
|
+
|
|
2745
|
+
🔍 问题复现
|
|
2746
|
+
{分析}
|
|
2747
|
+
|
|
2748
|
+
📊 数据支撑
|
|
2749
|
+
{日志片段、耗时数据、调用链}
|
|
2750
|
+
|
|
2751
|
+
🎯 根因分析
|
|
2752
|
+
1. <最可能的根因>
|
|
2753
|
+
2. <次要可能性>
|
|
2754
|
+
3. <边缘情况>
|
|
2755
|
+
|
|
2756
|
+
🛠 解决方案
|
|
2757
|
+
方案 1:<具体步骤>
|
|
2758
|
+
方案 2:<具体步骤>
|
|
2759
|
+
方案 3:<具体步骤>
|
|
2760
|
+
|
|
2761
|
+
📌 验证方法
|
|
2762
|
+
{如何确认问题已解决}
|
|
2763
|
+
\`\`\`
|
|
2764
|
+
|
|
2765
|
+
请按上述步骤执行诊断,优先检查日志和当前会话数据。` };
|
|
2766
|
+
return { instruction: HEALTH_CHECK_INSTRUCTION };
|
|
2767
|
+
}
|
|
2768
|
+
//#endregion
|
|
2769
|
+
//#region src/commands/share.ts
|
|
2770
|
+
/**
|
|
2771
|
+
* /share — 将会话导出为可分享的 Markdown 文件。
|
|
2772
|
+
*
|
|
2773
|
+
* 从 ~/.lynx/sessions/{sessionId}.json 读取消息,
|
|
2774
|
+
* 格式化为干净的 Markdown 后写入 ~/.lynx/exports/ 目录。
|
|
2775
|
+
*/
|
|
2776
|
+
var share_exports = /* @__PURE__ */ __exportAll({ handleShareCommand: () => handleShareCommand });
|
|
2777
|
+
/** 从 JSON 文件加载会话消息。 */
|
|
2778
|
+
function loadSessionMessages$2(sessionId) {
|
|
2779
|
+
const messagesPath = join(homedir(), ".lynx", "sessions", `${sessionId}.json`);
|
|
2780
|
+
if (!existsSync(messagesPath)) throw new Error(`会话消息文件未找到:${messagesPath}`);
|
|
2781
|
+
const raw = readFileSync(messagesPath, "utf-8");
|
|
2782
|
+
return JSON.parse(raw).messages ?? [];
|
|
2783
|
+
}
|
|
2784
|
+
/** 从数据库获取最近一次会话的 ID。 */
|
|
2785
|
+
function getMostRecentSessionId$3() {
|
|
2786
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
2787
|
+
try {
|
|
2788
|
+
const session = getLastSession(db);
|
|
2789
|
+
if (!session) throw new Error("未找到任何会话记录。");
|
|
2790
|
+
return session.id;
|
|
2791
|
+
} finally {
|
|
2792
|
+
db.close();
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
/** 截断过长文本,保留开头和结尾的提示。 */
|
|
2796
|
+
function truncate$1(text, maxLen) {
|
|
2797
|
+
if (text.length <= maxLen) return text;
|
|
2798
|
+
const half = Math.floor(maxLen / 2);
|
|
2799
|
+
return text.slice(0, half) + "\n...(内容已截断)...\n" + text.slice(-half);
|
|
2800
|
+
}
|
|
2801
|
+
/** 将内容块转为适合嵌入 Markdown 的文本。 */
|
|
2802
|
+
function blockToMarkdown$1(block) {
|
|
2803
|
+
switch (block.type) {
|
|
2804
|
+
case "text": return block.text;
|
|
2805
|
+
case "tool_use": return `_**调用工具:** ${block.name}_\n\`\`\`json\n${JSON.stringify(block.input, null, 2)}\n\`\`\``;
|
|
2806
|
+
case "tool_result":
|
|
2807
|
+
if (block.isError) return `<details><summary>工具执行出错</summary>\n\n\`\`\`\n${block.content}\n\`\`\`\n</details>`;
|
|
2808
|
+
return `<details><summary>工具执行结果</summary>\n\n\`\`\`\n${truncate$1(block.content, 3e3)}\n\`\`\`\n</details>`;
|
|
2809
|
+
case "reasoning": return `<details><summary>思考过程</summary>\n\n${block.text}\n</details>`;
|
|
2810
|
+
default: return "";
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
/** 将会话消息列表格式化为 Markdown。 */
|
|
2814
|
+
function formatSessionAsMarkdown$1(messages, sessionLabel) {
|
|
2815
|
+
const lines = [];
|
|
2816
|
+
lines.push(`# ${sessionLabel}`);
|
|
2817
|
+
lines.push("");
|
|
2818
|
+
lines.push(`> 导出时间:${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2819
|
+
lines.push(`> 消息总数:${messages.length}`);
|
|
2820
|
+
lines.push("");
|
|
2821
|
+
lines.push("---");
|
|
2822
|
+
lines.push("");
|
|
2823
|
+
for (const msg of messages) {
|
|
2824
|
+
if (msg.role === "system") continue;
|
|
2825
|
+
const roleLabel = msg.role === "user" ? "用户" : "Lynx";
|
|
2826
|
+
const emoji = msg.role === "user" ? "🧑" : "🤖";
|
|
2827
|
+
lines.push(`### ${emoji} ${roleLabel}`);
|
|
2828
|
+
lines.push("");
|
|
2829
|
+
for (const block of msg.content) {
|
|
2830
|
+
const md = blockToMarkdown$1(block);
|
|
2831
|
+
if (md) lines.push(md);
|
|
2832
|
+
}
|
|
2833
|
+
lines.push("");
|
|
2834
|
+
lines.push("---");
|
|
2835
|
+
lines.push("");
|
|
2836
|
+
}
|
|
2837
|
+
return lines.join("\n");
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* 处理 /share 命令。
|
|
2841
|
+
*
|
|
2842
|
+
* 将会话消息导出为 Markdown 文件,方便分享。
|
|
2843
|
+
* 默认导出最近一次会话,可通过 --session-id 指定。
|
|
2844
|
+
*/
|
|
2845
|
+
async function handleShareCommand(opts) {
|
|
2846
|
+
const sessionId = opts.sessionId ?? getMostRecentSessionId$3();
|
|
2847
|
+
const messages = loadSessionMessages$2(sessionId);
|
|
2848
|
+
if (messages.length === 0) return { output: `会话 ${sessionId} 中没有消息。` };
|
|
2849
|
+
const markdown = formatSessionAsMarkdown$1(messages, `会话 ${sessionId}`);
|
|
2850
|
+
const exportsDir = join(homedir(), ".lynx", "exports");
|
|
2851
|
+
if (!existsSync(exportsDir)) mkdirSync(exportsDir, {
|
|
2852
|
+
mode: 448,
|
|
2853
|
+
recursive: true
|
|
2854
|
+
});
|
|
2855
|
+
const outputPath = opts.output ?? join(exportsDir, `session-${sessionId}.md`);
|
|
2856
|
+
const outputDir = dirname(outputPath);
|
|
2857
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, {
|
|
2858
|
+
mode: 448,
|
|
2859
|
+
recursive: true
|
|
2860
|
+
});
|
|
2861
|
+
writeFileSync(outputPath, markdown, "utf-8");
|
|
2862
|
+
return { output: `会话已导出到:${outputPath}` };
|
|
2863
|
+
}
|
|
2864
|
+
//#endregion
|
|
2865
|
+
//#region src/commands/export.ts
|
|
2866
|
+
/**
|
|
2867
|
+
* /export — 以多种格式导出会话。
|
|
2868
|
+
*
|
|
2869
|
+
* 支持三种输出格式:
|
|
2870
|
+
* - markdown:与 /share 相同,格式化为可读的 Markdown
|
|
2871
|
+
* - json:导出原始消息数组(格式化 JSON)
|
|
2872
|
+
* - html:将 Markdown 包裹在基础 HTML 模板中
|
|
2873
|
+
*/
|
|
2874
|
+
var export_exports = /* @__PURE__ */ __exportAll({ handleExportCommand: () => handleExportCommand });
|
|
2875
|
+
/** 从 JSON 文件加载会话消息。 */
|
|
2876
|
+
function loadSessionMessages$1(sessionId) {
|
|
2877
|
+
const messagesPath = join(homedir(), ".lynx", "sessions", `${sessionId}.json`);
|
|
2878
|
+
if (!existsSync(messagesPath)) throw new Error(`会话消息文件未找到:${messagesPath}`);
|
|
2879
|
+
const raw = readFileSync(messagesPath, "utf-8");
|
|
2880
|
+
return JSON.parse(raw).messages ?? [];
|
|
2881
|
+
}
|
|
2882
|
+
/** 从数据库获取最近一次会话的 ID。 */
|
|
2883
|
+
function getMostRecentSessionId$2() {
|
|
2884
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
2885
|
+
try {
|
|
2886
|
+
const session = getLastSession(db);
|
|
2887
|
+
if (!session) throw new Error("未找到任何会话记录。");
|
|
2888
|
+
return session.id;
|
|
2889
|
+
} finally {
|
|
2890
|
+
db.close();
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
/** 截断过长文本。 */
|
|
2894
|
+
function truncate(text, maxLen) {
|
|
2895
|
+
if (text.length <= maxLen) return text;
|
|
2896
|
+
const half = Math.floor(maxLen / 2);
|
|
2897
|
+
return text.slice(0, half) + "\n...(内容已截断)...\n" + text.slice(-half);
|
|
2898
|
+
}
|
|
2899
|
+
/** 将内容块转为适合嵌入 Markdown 的文本。 */
|
|
2900
|
+
function blockToMarkdown(block) {
|
|
2901
|
+
switch (block.type) {
|
|
2902
|
+
case "text": return block.text;
|
|
2903
|
+
case "tool_use": return `_**调用工具:** ${block.name}_\n\`\`\`json\n${JSON.stringify(block.input, null, 2)}\n\`\`\``;
|
|
2904
|
+
case "tool_result":
|
|
2905
|
+
if (block.isError) return `<details><summary>工具执行出错</summary>\n\n\`\`\`\n${block.content}\n\`\`\`\n</details>`;
|
|
2906
|
+
return `<details><summary>工具执行结果</summary>\n\n\`\`\`\n${truncate(block.content, 3e3)}\n\`\`\`\n</details>`;
|
|
2907
|
+
case "reasoning": return `<details><summary>思考过程</summary>\n\n${block.text}\n</details>`;
|
|
2908
|
+
default: return "";
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
/** 将会话消息列表格式化为 Markdown。 */
|
|
2912
|
+
function formatSessionAsMarkdown(messages, sessionId) {
|
|
2913
|
+
const lines = [];
|
|
2914
|
+
lines.push(`# 会话 ${sessionId}`);
|
|
2915
|
+
lines.push("");
|
|
2916
|
+
lines.push(`> 导出时间:${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2917
|
+
lines.push(`> 消息总数:${messages.length}`);
|
|
2918
|
+
lines.push("");
|
|
2919
|
+
lines.push("---");
|
|
2920
|
+
lines.push("");
|
|
2921
|
+
for (const msg of messages) {
|
|
2922
|
+
if (msg.role === "system") continue;
|
|
2923
|
+
const roleLabel = msg.role === "user" ? "用户" : "Lynx";
|
|
2924
|
+
const emoji = msg.role === "user" ? "🧑" : "🤖";
|
|
2925
|
+
lines.push(`### ${emoji} ${roleLabel}`);
|
|
2926
|
+
lines.push("");
|
|
2927
|
+
for (const block of msg.content) {
|
|
2928
|
+
const md = blockToMarkdown(block);
|
|
2929
|
+
if (md) lines.push(md);
|
|
2930
|
+
}
|
|
2931
|
+
lines.push("");
|
|
2932
|
+
lines.push("---");
|
|
2933
|
+
lines.push("");
|
|
2934
|
+
}
|
|
2935
|
+
return lines.join("\n");
|
|
2936
|
+
}
|
|
2937
|
+
/** 将 Markdown 包裹在 HTML 模板中。 */
|
|
2938
|
+
function wrapMarkdownInHtml(markdown, sessionId) {
|
|
2939
|
+
return `<!DOCTYPE html>
|
|
2940
|
+
<html lang="zh-CN">
|
|
2941
|
+
<head>
|
|
2942
|
+
<meta charset="UTF-8">
|
|
2943
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2944
|
+
<title>Lynx 会话导出 — ${sessionId}</title>
|
|
2945
|
+
<style>
|
|
2946
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2947
|
+
body {
|
|
2948
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
2949
|
+
max-width: 900px;
|
|
2950
|
+
margin: 0 auto;
|
|
2951
|
+
padding: 2rem;
|
|
2952
|
+
line-height: 1.7;
|
|
2953
|
+
color: #1a1a1a;
|
|
2954
|
+
background: #fafafa;
|
|
2955
|
+
}
|
|
2956
|
+
h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #111; }
|
|
2957
|
+
blockquote { border-left: 4px solid #ddd; padding-left: 1rem; color: #666; margin: 1rem 0; }
|
|
2958
|
+
hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
|
|
2959
|
+
h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #333; }
|
|
2960
|
+
pre {
|
|
2961
|
+
background: #f0f0f0;
|
|
2962
|
+
border-radius: 6px;
|
|
2963
|
+
padding: 1rem;
|
|
2964
|
+
overflow-x: auto;
|
|
2965
|
+
font-size: 0.9rem;
|
|
2966
|
+
}
|
|
2967
|
+
code { font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
|
|
2968
|
+
details {
|
|
2969
|
+
background: #f5f5f5;
|
|
2970
|
+
border-radius: 6px;
|
|
2971
|
+
padding: 0.75rem 1rem;
|
|
2972
|
+
margin: 0.5rem 0;
|
|
2973
|
+
}
|
|
2974
|
+
details summary { cursor: pointer; font-weight: 600; color: #555; }
|
|
2975
|
+
details pre { margin-top: 0.5rem; }
|
|
2976
|
+
.meta { color: #888; font-size: 0.9rem; }
|
|
2977
|
+
</style>
|
|
2978
|
+
</head>
|
|
2979
|
+
<body>
|
|
2980
|
+
${markdown}
|
|
2981
|
+
</body>
|
|
2982
|
+
</html>`;
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* 处理 /export 命令。
|
|
2986
|
+
*
|
|
2987
|
+
* 以指定格式导出会话,支持 markdown / json / html。
|
|
2988
|
+
* 默认导出最近一次会话为 markdown 格式。
|
|
2989
|
+
*/
|
|
2990
|
+
async function handleExportCommand(opts) {
|
|
2991
|
+
const sessionId = opts.sessionId ?? getMostRecentSessionId$2();
|
|
2992
|
+
const messages = loadSessionMessages$1(sessionId);
|
|
2993
|
+
if (messages.length === 0) return { output: `会话 ${sessionId} 中没有消息。` };
|
|
2994
|
+
const format = opts.format ?? "markdown";
|
|
2995
|
+
const exportsDir = join(homedir(), ".lynx", "exports");
|
|
2996
|
+
if (!existsSync(exportsDir)) mkdirSync(exportsDir, {
|
|
2997
|
+
mode: 448,
|
|
2998
|
+
recursive: true
|
|
2999
|
+
});
|
|
3000
|
+
const defaultOutput = join(exportsDir, `session-${sessionId}.${format === "html" ? "html" : format === "json" ? "json" : "md"}`);
|
|
3001
|
+
const outputPath = opts.output ?? defaultOutput;
|
|
3002
|
+
const outputDir = dirname(outputPath);
|
|
3003
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, {
|
|
3004
|
+
mode: 448,
|
|
3005
|
+
recursive: true
|
|
3006
|
+
});
|
|
3007
|
+
let content;
|
|
3008
|
+
switch (format) {
|
|
3009
|
+
case "markdown":
|
|
3010
|
+
content = formatSessionAsMarkdown(messages, sessionId);
|
|
3011
|
+
break;
|
|
3012
|
+
case "json":
|
|
3013
|
+
content = JSON.stringify(messages, null, 2);
|
|
3014
|
+
break;
|
|
3015
|
+
case "html":
|
|
3016
|
+
content = wrapMarkdownInHtml(formatSessionAsMarkdown(messages, sessionId), sessionId);
|
|
3017
|
+
break;
|
|
3018
|
+
default: return { output: `不支持的导出格式:${format}。请使用 markdown、json 或 html。` };
|
|
3019
|
+
}
|
|
3020
|
+
writeFileSync(outputPath, content, "utf-8");
|
|
3021
|
+
return { output: `会话已导出到:${outputPath}(格式:${format})` };
|
|
3022
|
+
}
|
|
3023
|
+
//#endregion
|
|
3024
|
+
//#region src/commands/tag.ts
|
|
3025
|
+
/**
|
|
3026
|
+
* /tag — 为会话打标签,用于分类管理。
|
|
3027
|
+
*
|
|
3028
|
+
* 标签数据存储在 ~/.lynx/session-tags.json 中。
|
|
3029
|
+
* 支持三种操作:
|
|
3030
|
+
* - add:为指定会话添加标签
|
|
3031
|
+
* - remove:移除指定标签
|
|
3032
|
+
* - list:列出所有带标签的会话,或指定会话的所有标签
|
|
3033
|
+
*/
|
|
3034
|
+
var tag_exports = /* @__PURE__ */ __exportAll({ handleTagCommand: () => handleTagCommand });
|
|
3035
|
+
/** 获取标签文件路径。 */
|
|
3036
|
+
function tagsFilePath() {
|
|
3037
|
+
return join(homedir(), ".lynx", "session-tags.json");
|
|
3038
|
+
}
|
|
3039
|
+
/** 加载所有标签数据。 */
|
|
3040
|
+
function loadTags() {
|
|
3041
|
+
const path = tagsFilePath();
|
|
3042
|
+
if (!existsSync(path)) return {};
|
|
3043
|
+
try {
|
|
3044
|
+
const raw = readFileSync(path, "utf-8");
|
|
3045
|
+
return JSON.parse(raw);
|
|
3046
|
+
} catch {
|
|
3047
|
+
return {};
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
/** 保存标签数据。 */
|
|
3051
|
+
function saveTags(tags) {
|
|
3052
|
+
const path = tagsFilePath();
|
|
3053
|
+
const dir = dirname(path);
|
|
3054
|
+
if (!existsSync(dir)) mkdirSync(dir, {
|
|
3055
|
+
mode: 448,
|
|
3056
|
+
recursive: true
|
|
3057
|
+
});
|
|
3058
|
+
writeFileSync(path, JSON.stringify(tags, null, 2), "utf-8");
|
|
3059
|
+
}
|
|
3060
|
+
/** 从数据库获取最近一次会话的 ID。 */
|
|
3061
|
+
function getMostRecentSessionId$1() {
|
|
3062
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
3063
|
+
try {
|
|
3064
|
+
const session = getLastSession(db);
|
|
3065
|
+
if (!session) throw new Error("未找到任何会话记录。");
|
|
3066
|
+
return session.id;
|
|
3067
|
+
} finally {
|
|
3068
|
+
db.close();
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* 从数据库获取会话的 label(用于 list 展示)。
|
|
3073
|
+
* 如果数据库不可用或会话不存在,返回 sessionId。
|
|
3074
|
+
*/
|
|
3075
|
+
function getSessionLabel(sessionId) {
|
|
3076
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
3077
|
+
try {
|
|
3078
|
+
return db.prepare("SELECT label FROM sessions WHERE id = ?").get(sessionId)?.label ?? sessionId;
|
|
3079
|
+
} catch {
|
|
3080
|
+
return sessionId;
|
|
3081
|
+
} finally {
|
|
3082
|
+
db.close();
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* 处理 /tag 命令。
|
|
3087
|
+
*
|
|
3088
|
+
* 为会话添加、移除或列出标签。
|
|
3089
|
+
* 默认操作为 list;add/remove 需要提供 --tag 参数。
|
|
3090
|
+
*/
|
|
3091
|
+
async function handleTagCommand(opts) {
|
|
3092
|
+
const action = opts.action ?? "list";
|
|
3093
|
+
const tags = loadTags();
|
|
3094
|
+
switch (action) {
|
|
3095
|
+
case "add": {
|
|
3096
|
+
if (!opts.tag || opts.tag.trim().length === 0) return { output: "错误:请使用 --tag 指定要添加的标签。" };
|
|
3097
|
+
const sessionId = opts.sessionId ?? getMostRecentSessionId$1();
|
|
3098
|
+
const normalizedTag = opts.tag.trim();
|
|
3099
|
+
if (!tags[sessionId]) tags[sessionId] = [];
|
|
3100
|
+
if (tags[sessionId].includes(normalizedTag)) return { output: `会话 ${sessionId} 已有标签 "${normalizedTag}"。` };
|
|
3101
|
+
tags[sessionId].push(normalizedTag);
|
|
3102
|
+
saveTags(tags);
|
|
3103
|
+
return { output: `已为会话 ${sessionId} 添加标签 "${normalizedTag}"。` };
|
|
3104
|
+
}
|
|
3105
|
+
case "remove": {
|
|
3106
|
+
if (!opts.tag || opts.tag.trim().length === 0) return { output: "错误:请使用 --tag 指定要移除的标签。" };
|
|
3107
|
+
const sessionId = opts.sessionId ?? getMostRecentSessionId$1();
|
|
3108
|
+
const normalizedTag = opts.tag.trim();
|
|
3109
|
+
if (!tags[sessionId] || !tags[sessionId].includes(normalizedTag)) return { output: `会话 ${sessionId} 没有标签 "${normalizedTag}"。` };
|
|
3110
|
+
tags[sessionId] = tags[sessionId].filter((t) => t !== normalizedTag);
|
|
3111
|
+
if (tags[sessionId].length === 0) delete tags[sessionId];
|
|
3112
|
+
saveTags(tags);
|
|
3113
|
+
return { output: `已从会话 ${sessionId} 移除标签 "${normalizedTag}"。` };
|
|
3114
|
+
}
|
|
3115
|
+
case "list": {
|
|
3116
|
+
if (opts.sessionId) {
|
|
3117
|
+
const sessionTags = tags[opts.sessionId];
|
|
3118
|
+
if (!sessionTags || sessionTags.length === 0) return { output: `会话 ${opts.sessionId} 没有标签。` };
|
|
3119
|
+
const label = getSessionLabel(opts.sessionId);
|
|
3120
|
+
return { output: `会话 ${opts.sessionId}(${label})的标签:\n${sessionTags.map((t) => ` - ${t}`).join("\n")}` };
|
|
3121
|
+
}
|
|
3122
|
+
const entries = Object.entries(tags);
|
|
3123
|
+
if (entries.length === 0) return { output: "没有任何已打标签的会话。" };
|
|
3124
|
+
const lines = [];
|
|
3125
|
+
for (const [sid, sessionTags] of entries) {
|
|
3126
|
+
if (sessionTags.length === 0) continue;
|
|
3127
|
+
const label = getSessionLabel(sid);
|
|
3128
|
+
lines.push(`${sid}(${label}):${sessionTags.join(", ")}`);
|
|
3129
|
+
}
|
|
3130
|
+
if (lines.length === 0) return { output: "没有任何已打标签的会话。" };
|
|
3131
|
+
return { output: lines.join("\n") };
|
|
3132
|
+
}
|
|
3133
|
+
default: return { output: `未知操作:${action}。请使用 add、remove 或 list。` };
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
//#endregion
|
|
3137
|
+
//#region src/commands/copy.ts
|
|
3138
|
+
/**
|
|
3139
|
+
* /copy — 将最后一条助手回复复制到剪贴板。
|
|
3140
|
+
*
|
|
3141
|
+
* 从会话中提取指定索引(从末尾倒数)的助手消息文本,
|
|
3142
|
+
* 通过平台对应的剪贴板命令写入系统剪贴板。
|
|
3143
|
+
*
|
|
3144
|
+
* 支持平台:Windows(PowerShell)、macOS(pbcopy)、
|
|
3145
|
+
* Linux(xclip 或 wl-copy)。
|
|
3146
|
+
*/
|
|
3147
|
+
var copy_exports = /* @__PURE__ */ __exportAll({ handleCopyCommand: () => handleCopyCommand });
|
|
3148
|
+
/** 从 JSON 文件加载会话消息。 */
|
|
3149
|
+
function loadSessionMessages(sessionId) {
|
|
3150
|
+
const messagesPath = join(homedir(), ".lynx", "sessions", `${sessionId}.json`);
|
|
3151
|
+
if (!existsSync(messagesPath)) throw new Error(`会话消息文件未找到:${messagesPath}`);
|
|
3152
|
+
const raw = readFileSync(messagesPath, "utf-8");
|
|
3153
|
+
return JSON.parse(raw).messages ?? [];
|
|
3154
|
+
}
|
|
3155
|
+
/** 从数据库获取最近一次会话的 ID。 */
|
|
3156
|
+
function getMostRecentSessionId() {
|
|
3157
|
+
const db = openDatabase({ dbPath: resolvePaths().stateDb });
|
|
3158
|
+
try {
|
|
3159
|
+
const session = getLastSession(db);
|
|
3160
|
+
if (!session) throw new Error("未找到任何会话记录。");
|
|
3161
|
+
return session.id;
|
|
3162
|
+
} finally {
|
|
3163
|
+
db.close();
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* 从消息中提取纯文本内容(仅 text 类型的内容块)。
|
|
3168
|
+
* 跳过 tool_use、tool_result 和 reasoning 块。
|
|
3169
|
+
*/
|
|
3170
|
+
function extractTextContent(msg) {
|
|
3171
|
+
const parts = [];
|
|
3172
|
+
for (const block of msg.content) if (block.type === "text" && block.text) parts.push(block.text);
|
|
3173
|
+
return parts.join("\n\n");
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* 检测当前平台并返回对应的剪贴板命令。
|
|
3177
|
+
* 返回 [命令, args...] 或 null(不支持的平台)。
|
|
3178
|
+
*/
|
|
3179
|
+
function getClipboardCommand() {
|
|
3180
|
+
const platform = process.platform;
|
|
3181
|
+
if (platform === "win32") return ["powershell", ["-Command", "Set-Clipboard -Value $input"]];
|
|
3182
|
+
if (platform === "darwin") return ["pbcopy", []];
|
|
3183
|
+
if (platform === "linux") try {
|
|
3184
|
+
execSync("which wl-copy 2>/dev/null || command -v wl-copy 2>/dev/null", { stdio: "ignore" });
|
|
3185
|
+
return ["wl-copy", []];
|
|
3186
|
+
} catch {
|
|
3187
|
+
try {
|
|
3188
|
+
execSync("which xclip 2>/dev/null || command -v xclip 2>/dev/null", { stdio: "ignore" });
|
|
3189
|
+
return ["xclip", ["-selection", "clipboard"]];
|
|
3190
|
+
} catch {
|
|
3191
|
+
return null;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
return null;
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* 将文本写入系统剪贴板。
|
|
3198
|
+
* 使用管道将内容传入剪贴板命令的 stdin。
|
|
3199
|
+
*/
|
|
3200
|
+
function copyToClipboard(text) {
|
|
3201
|
+
const cmd = getClipboardCommand();
|
|
3202
|
+
if (!cmd) return false;
|
|
3203
|
+
const [command, args] = cmd;
|
|
3204
|
+
try {
|
|
3205
|
+
if (process.platform === "win32") execSync(`powershell -Command "$input = [System.IO.StreamReader]::new([System.Console]::OpenStandardInput()).ReadToEnd(); Set-Clipboard -Value $input"`, {
|
|
3206
|
+
input: text,
|
|
3207
|
+
stdio: "pipe",
|
|
3208
|
+
timeout: 1e4
|
|
3209
|
+
});
|
|
3210
|
+
else if (execSync(`${command} ${args.join(" ")}`, {
|
|
3211
|
+
input: text,
|
|
3212
|
+
stdio: "pipe",
|
|
3213
|
+
timeout: 1e4
|
|
3214
|
+
}).length > 0) {}
|
|
3215
|
+
return true;
|
|
3216
|
+
} catch {
|
|
3217
|
+
return false;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* 处理 /copy 命令。
|
|
3222
|
+
*
|
|
3223
|
+
* 将指定索引(从末尾倒数)的助手消息文本复制到系统剪贴板。
|
|
3224
|
+
* 默认复制最后一条助手回复。
|
|
3225
|
+
*/
|
|
3226
|
+
async function handleCopyCommand(opts) {
|
|
3227
|
+
const sessionId = opts.sessionId ?? getMostRecentSessionId();
|
|
3228
|
+
const messages = loadSessionMessages(sessionId);
|
|
3229
|
+
if (messages.length === 0) return { output: `会话 ${sessionId} 中没有消息。` };
|
|
3230
|
+
const assistantMessages = messages.filter((m) => m.role === "assistant").toReversed();
|
|
3231
|
+
if (assistantMessages.length === 0) return { output: `会话 ${sessionId} 中没有助手回复。` };
|
|
3232
|
+
const index = opts.index ?? 0;
|
|
3233
|
+
if (index < 0 || index >= assistantMessages.length) return { output: `索引 ${index} 超出范围。会话共有 ${assistantMessages.length} 条助手回复(有效索引:0 到 ${assistantMessages.length - 1})。` };
|
|
3234
|
+
const targetMessage = assistantMessages[index];
|
|
3235
|
+
const text = extractTextContent(targetMessage);
|
|
3236
|
+
if (!text || text.trim().length === 0) return { output: `助手回复 #${index} 不包含文本内容(可能只有工具调用或思考过程)。` };
|
|
3237
|
+
if (!copyToClipboard(text)) return { output: "无法复制到剪贴板。请确认已安装剪贴板工具:\n Windows:PowerShell 5.0+\n macOS:系统自带 pbcopy\n Linux:请安装 xclip 或 wl-clipboard" };
|
|
3238
|
+
return { output: `已复制助手回复 #${index} 到剪贴板。\n预览:${text.length > 80 ? text.slice(0, 80) + "..." : text}` };
|
|
3239
|
+
}
|
|
3240
|
+
//#endregion
|
|
3241
|
+
export { CATALOG, MemoryMonitor, WorkerPool, beginSync, bootstrap, checkForUpdates, clearUpdateCache, createDebugLogger, createProgram, endSync, enterFullscreen, exitFullscreen, findCommand, getLastUpdateResult, handleAntTraceCommand, handleAutoFixCommand, handleCommitCommand, handleConfigCommand, handleCopyCommand, handleDebugToolCall, handleDiffCommand, handleExportCommand, handleHeapdumpCommand, handleIssueCommand, handlePerfIssueCommand, handlePluginCommand, handlePrCommentsCommand, handleReviewCommand, handleSessionCommand, handleShareCommand, handleTagCommand, handleTasksCommand, installProcessLifecycle, launchTui, main, onCleanup, printReport, runChecks, runCli, runCrestodian, runDoctor, runPhase1, runPhase2, uninstallProcessLifecycle, withSync };
|
|
3242
|
+
|
|
3243
|
+
//# sourceMappingURL=index.mjs.map
|