@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
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { n as loadConfig } from "./config-Des0z-k9.mjs";
|
|
2
|
+
import { migrate, openDatabase, resolvePaths } from "@lynx/core";
|
|
3
|
+
import { createSessionManager } from "@lynx/session";
|
|
4
|
+
import { createMcpAuthHandler, createMemoryWriteHandler, createSendMessageHandler, createToolRegistry, injectTaskManager, mcpAuthDescriptor, memoryWriteDescriptor, registerBuiltinTools, sendMessageDescriptor } from "@lynx/tools";
|
|
5
|
+
import { PredictiveLoader, createHookRegistry, createManifestRegistry, createPluginLoader } from "@lynx/plugins";
|
|
6
|
+
import { createMcpManager, createMemoryManager, createQueryEngine, createSkillRegistry, createSkillToolHandler, createTaskManager, getBaseSystemPrompt } from "@lynx/agent";
|
|
7
|
+
import { createDenialTracker, createRuleEngine, createRulesLoader, loadFromDisk } from "@lynx/permissions";
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { createChannelRegistry, createFeishuAdapter } from "@lynx/channels";
|
|
13
|
+
//#region src/mcp-loader.ts
|
|
14
|
+
/**
|
|
15
|
+
* MCP configuration loader — reads MCP server configurations
|
|
16
|
+
* from Lynx settings files (3‑layer merge: global → project → local).
|
|
17
|
+
*
|
|
18
|
+
* Settings layers (later overrides earlier for same‑named servers):
|
|
19
|
+
* 1. ~/.lynx/settings.json — global
|
|
20
|
+
* 2. .lynx/settings.json — project (committed)
|
|
21
|
+
* 3. .lynx/settings.local.json — project local (gitignored)
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Try to read and parse a JSON file. Returns undefined if the file
|
|
25
|
+
* does not exist or is malformed.
|
|
26
|
+
*/
|
|
27
|
+
function tryReadJson(filePath) {
|
|
28
|
+
if (!existsSync(filePath)) return void 0;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Find the project root by walking up from cwd looking for `.lynx/`.
|
|
37
|
+
* Returns undefined if no project root is found.
|
|
38
|
+
*/
|
|
39
|
+
function findProjectRoot(startDir) {
|
|
40
|
+
let dir = startDir;
|
|
41
|
+
for (let i = 0; i < 50; i++) {
|
|
42
|
+
if (existsSync(join(dir, ".lynx"))) return dir;
|
|
43
|
+
const parent = join(dir, "..");
|
|
44
|
+
if (parent === dir) return void 0;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Load MCP server configurations from all settings layers.
|
|
50
|
+
*
|
|
51
|
+
* Merge order: global → project → project‑local.
|
|
52
|
+
* Same‑named servers in later layers override earlier ones entirely.
|
|
53
|
+
*/
|
|
54
|
+
function loadMcpConfigs(workspaceDir) {
|
|
55
|
+
const globalSettings = tryReadJson(resolvePaths().settingsFile);
|
|
56
|
+
const projectRoot = workspaceDir ? findProjectRoot(workspaceDir) : findProjectRoot(process.cwd());
|
|
57
|
+
const projectSettings = projectRoot ? tryReadJson(join(projectRoot, ".lynx", "settings.json")) : void 0;
|
|
58
|
+
const localSettings = projectRoot ? tryReadJson(join(projectRoot, ".lynx", "settings.local.json")) : void 0;
|
|
59
|
+
const merged = /* @__PURE__ */ new Map();
|
|
60
|
+
function addServers(servers, source) {
|
|
61
|
+
if (!servers) return;
|
|
62
|
+
for (const server of servers) merged.set(server.name, {
|
|
63
|
+
config: server,
|
|
64
|
+
source
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
addServers(globalSettings?.mcpServers, "global");
|
|
68
|
+
addServers(projectSettings?.mcpServers, "project");
|
|
69
|
+
addServers(localSettings?.mcpServers, "project-local");
|
|
70
|
+
return {
|
|
71
|
+
servers: Array.from(merged.values()).map((e) => e.config),
|
|
72
|
+
entries: merged
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Convenience: load and validate MCP configs for bootstrap.
|
|
77
|
+
*
|
|
78
|
+
* Returns an array of valid McpServerConfig objects, filtering out
|
|
79
|
+
* entries that have neither a `command` nor a `url`.
|
|
80
|
+
*/
|
|
81
|
+
function loadAndValidateMcpConfigs(workspaceDir) {
|
|
82
|
+
return loadMcpConfigs(workspaceDir).servers.filter((s) => {
|
|
83
|
+
const isValid = !!(s.command || s.url);
|
|
84
|
+
if (!isValid) {}
|
|
85
|
+
return isValid;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/bootstrap.ts
|
|
90
|
+
/** Timeout for auto‑denying a permission request when the user doesn't respond. */
|
|
91
|
+
const PERMISSION_TIMEOUT_MS = 6e4;
|
|
92
|
+
/**
|
|
93
|
+
* Create a permission bridge that mediates between the agent engine
|
|
94
|
+
* and the TUI's permission dialog.
|
|
95
|
+
*
|
|
96
|
+
* The bridge uses a Promise Map pattern:
|
|
97
|
+
* 1. Agent calls `requestPermission()` → Promise created + stored
|
|
98
|
+
* 2. TUI handler fires → shows permission dialog
|
|
99
|
+
* 3. User responds → `handleReply()` resolves the Promise
|
|
100
|
+
* 4. Agent resumes (or skips the tool)
|
|
101
|
+
*
|
|
102
|
+
* Safety levels "Safe" and "WorkspaceSafe" are auto‑allowed without
|
|
103
|
+
* showing the dialog.
|
|
104
|
+
*/
|
|
105
|
+
function createPermissionBridge() {
|
|
106
|
+
const pending = /* @__PURE__ */ new Map();
|
|
107
|
+
let tuiHandler = null;
|
|
108
|
+
return {
|
|
109
|
+
async requestPermission(toolName, safety, description) {
|
|
110
|
+
if (safety === "Safe" || safety === "WorkspaceSafe") return true;
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const requestId = randomUUID();
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
pending.delete(requestId);
|
|
115
|
+
resolve(false);
|
|
116
|
+
}, PERMISSION_TIMEOUT_MS);
|
|
117
|
+
pending.set(requestId, {
|
|
118
|
+
resolve,
|
|
119
|
+
timer
|
|
120
|
+
});
|
|
121
|
+
if (tuiHandler) tuiHandler({
|
|
122
|
+
requestId,
|
|
123
|
+
toolName,
|
|
124
|
+
description,
|
|
125
|
+
safety
|
|
126
|
+
});
|
|
127
|
+
else {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
pending.delete(requestId);
|
|
130
|
+
resolve(false);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
handleReply(requestId, approved) {
|
|
135
|
+
const entry = pending.get(requestId);
|
|
136
|
+
if (entry) {
|
|
137
|
+
clearTimeout(entry.timer);
|
|
138
|
+
pending.delete(requestId);
|
|
139
|
+
entry.resolve(approved);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
setTuiHandler(handler) {
|
|
143
|
+
tuiHandler = handler;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Load all .md files from a directory as an array of file contents.
|
|
149
|
+
* Silently returns [] if the directory doesn't exist or is unreadable.
|
|
150
|
+
*/
|
|
151
|
+
function loadTextFilesFromDir(dir) {
|
|
152
|
+
const facts = [];
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = readdirSync(dir);
|
|
156
|
+
} catch {
|
|
157
|
+
return facts;
|
|
158
|
+
}
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (!entry.endsWith(".md")) continue;
|
|
161
|
+
const fullPath = join(dir, entry);
|
|
162
|
+
try {
|
|
163
|
+
if (!statSync(fullPath).isFile()) continue;
|
|
164
|
+
facts.push(readFileSync(fullPath, "utf-8"));
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
return facts;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Bootstrap the entire Lynx application.
|
|
171
|
+
*
|
|
172
|
+
* This is the ONLY place where cross‑package wiring happens.
|
|
173
|
+
* Every other module only talks to its immediate dependencies
|
|
174
|
+
* through explicit factory‑injected interfaces.
|
|
175
|
+
*/
|
|
176
|
+
function bootstrap(config) {
|
|
177
|
+
const dbPath = join(config.homeDir, "state.db");
|
|
178
|
+
const workspace = config.workspace ?? process.cwd();
|
|
179
|
+
const db = openDatabase({ dbPath });
|
|
180
|
+
migrate(db);
|
|
181
|
+
const sessionMgr = createSessionManager(db);
|
|
182
|
+
const toolRegistry = createToolRegistry();
|
|
183
|
+
const ruleEngine = createRuleEngine();
|
|
184
|
+
const rulesLoader = createRulesLoader(ruleEngine);
|
|
185
|
+
registerBuiltinTools(toolRegistry);
|
|
186
|
+
const skillRegistry = createSkillRegistry(config.skillsDir ?? join(config.homeDir, "..", "packages", "lynx-agent", "skills"));
|
|
187
|
+
const userSkillsDir = join(homedir(), ".lynx", "skills");
|
|
188
|
+
skillRegistry.addDirectory(userSkillsDir);
|
|
189
|
+
const projectSkillsDir = join(workspace, ".claude", "skills");
|
|
190
|
+
skillRegistry.addDirectory(projectSkillsDir);
|
|
191
|
+
const skills = skillRegistry.list();
|
|
192
|
+
toolRegistry.register({
|
|
193
|
+
name: "Skill",
|
|
194
|
+
description: "加载技能完整指令。支持 load(加载技能内容)、list(列出所有技能)、search(搜索技能)、reload(重新加载技能目录)四种操作。",
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
action: {
|
|
199
|
+
type: "string",
|
|
200
|
+
enum: [
|
|
201
|
+
"load",
|
|
202
|
+
"list",
|
|
203
|
+
"search",
|
|
204
|
+
"reload"
|
|
205
|
+
],
|
|
206
|
+
description: "操作类型:load 加载技能内容、list 列出所有技能、search 搜索技能、reload 重新加载"
|
|
207
|
+
},
|
|
208
|
+
skill: {
|
|
209
|
+
type: "string",
|
|
210
|
+
description: "要加载的技能名称(load 操作时使用)"
|
|
211
|
+
},
|
|
212
|
+
args: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "传递给技能的可选参数"
|
|
215
|
+
},
|
|
216
|
+
query: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "搜索关键词(search 操作时使用)"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
kind: "ReadOnly",
|
|
223
|
+
safety: "Safe",
|
|
224
|
+
availability: { type: "always" },
|
|
225
|
+
executor: "Skill",
|
|
226
|
+
owner: "core"
|
|
227
|
+
}, createSkillToolHandler(skillRegistry));
|
|
228
|
+
const mcpManager = createMcpManager();
|
|
229
|
+
const settingsMcpServers = loadAndValidateMcpConfigs();
|
|
230
|
+
const programmaticServers = config.mcpServers ?? [];
|
|
231
|
+
const settingsOnly = settingsMcpServers.filter((s) => !programmaticServers.some((p) => p.name === s.name));
|
|
232
|
+
const allMcpServers = [...programmaticServers, ...settingsOnly];
|
|
233
|
+
for (const serverConfig of allMcpServers) mcpManager.connect(serverConfig).catch(() => {});
|
|
234
|
+
const channelRegistry = createChannelRegistry();
|
|
235
|
+
if (config.feishu?.appId && config.feishu?.appSecret) {
|
|
236
|
+
const feishuAdapter = createFeishuAdapter(config.feishu);
|
|
237
|
+
channelRegistry.register(feishuAdapter);
|
|
238
|
+
}
|
|
239
|
+
const toolHandlers = /* @__PURE__ */ new Map();
|
|
240
|
+
const allTools = toolRegistry.listAll();
|
|
241
|
+
for (const desc of allTools) try {
|
|
242
|
+
const handler = toolRegistry.resolveExecutor(desc);
|
|
243
|
+
toolHandlers.set(desc.name, handler);
|
|
244
|
+
} catch {}
|
|
245
|
+
const mcpDispatcher = { async handle(invocation, _signal) {
|
|
246
|
+
const result = await mcpManager.callTool(invocation.toolName, invocation.payload);
|
|
247
|
+
return {
|
|
248
|
+
content: result.content,
|
|
249
|
+
success: !result.isError
|
|
250
|
+
};
|
|
251
|
+
} };
|
|
252
|
+
for (const mcpTool of mcpManager.getAllTools()) {
|
|
253
|
+
if (toolHandlers.has(mcpTool.name)) {
|
|
254
|
+
process.stderr.write(`[lynx] 警告:MCP 工具 "${mcpTool.name}" 与现有工具冲突 — 已跳过\n`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
toolHandlers.set(mcpTool.name, mcpDispatcher);
|
|
258
|
+
}
|
|
259
|
+
new Set(mcpManager.getAllTools().map((t) => t.name));
|
|
260
|
+
const allToolsWithMcp = [...allTools, ...mcpManager.getAllTools().filter((t) => !allTools.some((b) => b.name === t.name))];
|
|
261
|
+
const manifestRegistry = createManifestRegistry();
|
|
262
|
+
const hookRegistry = createHookRegistry();
|
|
263
|
+
const pluginRegistry = {
|
|
264
|
+
manifestRegistry,
|
|
265
|
+
loader: createPluginLoader(),
|
|
266
|
+
hooks: hookRegistry
|
|
267
|
+
};
|
|
268
|
+
const predictiveLoader = new PredictiveLoader(async (pluginId) => {
|
|
269
|
+
return import(pluginId);
|
|
270
|
+
});
|
|
271
|
+
loadFromDisk(rulesLoader, workspace);
|
|
272
|
+
const denialTracker = createDenialTracker();
|
|
273
|
+
const permissionBridge = createPermissionBridge();
|
|
274
|
+
const userMemoryDir = join(homedir(), ".lynx", "memory");
|
|
275
|
+
const projectMemoryDir = join(workspace, ".claude", "memory");
|
|
276
|
+
const memoryManager = createMemoryManager(userMemoryDir);
|
|
277
|
+
const memoryFacts = [...memoryManager.list().map((e) => memoryManager.get(e.name)?.content).filter(Boolean), ...loadTextFilesFromDir(projectMemoryDir)];
|
|
278
|
+
const memoryWriteHandler = createMemoryWriteHandler(memoryManager);
|
|
279
|
+
toolRegistry.register(memoryWriteDescriptor, memoryWriteHandler);
|
|
280
|
+
toolHandlers.set(memoryWriteDescriptor.name, memoryWriteHandler);
|
|
281
|
+
allTools.push(memoryWriteDescriptor);
|
|
282
|
+
const mcpAuthHandler = createMcpAuthHandler({ reconnect: async (serverName) => {
|
|
283
|
+
return { status: (await mcpManager.reconnect(serverName)).status };
|
|
284
|
+
} });
|
|
285
|
+
toolRegistry.register(mcpAuthDescriptor, mcpAuthHandler);
|
|
286
|
+
toolHandlers.set(mcpAuthDescriptor.name, mcpAuthHandler);
|
|
287
|
+
allTools.push(mcpAuthDescriptor);
|
|
288
|
+
const sendMessageHandler = createSendMessageHandler({ async send(channel, content, recipients) {
|
|
289
|
+
const adapter = channelRegistry.get(channel);
|
|
290
|
+
if (!adapter) throw new Error(`未注册的消息通道:${channel}`);
|
|
291
|
+
const message = {
|
|
292
|
+
id: randomUUID(),
|
|
293
|
+
role: "assistant",
|
|
294
|
+
content: [{
|
|
295
|
+
type: "text",
|
|
296
|
+
text: content
|
|
297
|
+
}],
|
|
298
|
+
timestamp: Date.now(),
|
|
299
|
+
turnIndex: 0
|
|
300
|
+
};
|
|
301
|
+
return { messageId: await adapter.sendMessage(message) };
|
|
302
|
+
} });
|
|
303
|
+
toolRegistry.register(sendMessageDescriptor, sendMessageHandler);
|
|
304
|
+
toolHandlers.set(sendMessageDescriptor.name, sendMessageHandler);
|
|
305
|
+
allTools.push(sendMessageDescriptor);
|
|
306
|
+
const globalLynxMd = join(homedir(), ".lynx", "LYNX.md");
|
|
307
|
+
const globalInstructions = [];
|
|
308
|
+
try {
|
|
309
|
+
if (existsSync(globalLynxMd) && statSync(globalLynxMd).isFile()) globalInstructions.push(`# LYNX.md — 全局用户指令\n${readFileSync(globalLynxMd, "utf-8")}`);
|
|
310
|
+
} catch {}
|
|
311
|
+
const userRulesDir = join(homedir(), ".lynx", "rules");
|
|
312
|
+
const projectRulesDir = join(workspace, ".claude", "rules");
|
|
313
|
+
const rules = [
|
|
314
|
+
...globalInstructions,
|
|
315
|
+
...loadTextFilesFromDir(userRulesDir),
|
|
316
|
+
...loadTextFilesFromDir(projectRulesDir)
|
|
317
|
+
];
|
|
318
|
+
const agentConfig = {
|
|
319
|
+
provider: config.provider,
|
|
320
|
+
model: config.model,
|
|
321
|
+
systemPrompt: getBaseSystemPrompt(),
|
|
322
|
+
maxTokens: 8192,
|
|
323
|
+
maxCompactionFailures: 3,
|
|
324
|
+
budget: {
|
|
325
|
+
maxTokens: 2e5,
|
|
326
|
+
maxUsd: 10,
|
|
327
|
+
maxTurns: 100
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
const engine = createQueryEngine({
|
|
331
|
+
config: agentConfig,
|
|
332
|
+
provider: config.provider,
|
|
333
|
+
toolHandlers,
|
|
334
|
+
allTools: allToolsWithMcp,
|
|
335
|
+
skills,
|
|
336
|
+
memoryFacts,
|
|
337
|
+
rules,
|
|
338
|
+
checkPermission: async (toolName, safety, description) => {
|
|
339
|
+
if (denialTracker.isTripped()) return false;
|
|
340
|
+
const safetyLevel = safety;
|
|
341
|
+
const approved = await permissionBridge.requestPermission(toolName, safetyLevel, description);
|
|
342
|
+
if (safetyLevel === "RequiresApproval" || safetyLevel === "Dangerous") if (approved) denialTracker.recordApproval();
|
|
343
|
+
else denialTracker.recordDenial();
|
|
344
|
+
return approved;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
const taskManager = createTaskManager();
|
|
348
|
+
injectTaskManager(taskManager);
|
|
349
|
+
return {
|
|
350
|
+
db,
|
|
351
|
+
sessionMgr,
|
|
352
|
+
toolRegistry,
|
|
353
|
+
pluginRegistry,
|
|
354
|
+
ruleEngine,
|
|
355
|
+
engine,
|
|
356
|
+
provider: config.provider,
|
|
357
|
+
agentConfig,
|
|
358
|
+
permissionBridge,
|
|
359
|
+
skillRegistry,
|
|
360
|
+
skills,
|
|
361
|
+
mcpManager,
|
|
362
|
+
predictiveLoader,
|
|
363
|
+
channelRegistry,
|
|
364
|
+
memoryManager,
|
|
365
|
+
memoryFacts,
|
|
366
|
+
rules,
|
|
367
|
+
destroy() {
|
|
368
|
+
taskManager.destroy();
|
|
369
|
+
channelRegistry.destroyAll();
|
|
370
|
+
mcpManager.destroy();
|
|
371
|
+
engine.destroy();
|
|
372
|
+
sessionMgr.destroy();
|
|
373
|
+
db.close();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/startup.ts
|
|
379
|
+
/**
|
|
380
|
+
* Run Phase 1 — blocking startup that must finish before
|
|
381
|
+
* the user sees anything useful.
|
|
382
|
+
*
|
|
383
|
+
* Returns a database handle the rest of the app can use.
|
|
384
|
+
*/
|
|
385
|
+
function runPhase1() {
|
|
386
|
+
const started = Date.now();
|
|
387
|
+
const paths = resolvePaths();
|
|
388
|
+
const db = openDatabase({ dbPath: paths.stateDb });
|
|
389
|
+
migrate(db);
|
|
390
|
+
return {
|
|
391
|
+
paths,
|
|
392
|
+
db,
|
|
393
|
+
elapsedMs: Date.now() - started
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Run Phase 2 — background work that executes after the TUI is rendered.
|
|
398
|
+
*
|
|
399
|
+
* Tasks run sequentially with a setImmediate gap between each so the
|
|
400
|
+
* event loop stays responsive. Each task is independently try‑catched
|
|
401
|
+
* so a single failure doesn't block the rest.
|
|
402
|
+
*
|
|
403
|
+
* Returns the elapsed time in ms.
|
|
404
|
+
*/
|
|
405
|
+
async function runPhase2(result) {
|
|
406
|
+
return runPhase2Tasks(null, result.paths);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Run Phase 2 with the full AppContext (preferred path).
|
|
410
|
+
*
|
|
411
|
+
* When called from the TUI launcher, the AppContext provides access
|
|
412
|
+
* to all initialized services (plugin registry, MCP manager, etc.).
|
|
413
|
+
*
|
|
414
|
+
* Tasks:
|
|
415
|
+
* 1. scanExtensions — discover plugins in the extensions directory
|
|
416
|
+
* 2. preloadSkills — index skills for autocomplete
|
|
417
|
+
* 3. connectMcpServers — establish MCP connections
|
|
418
|
+
* 4. loadMemory — load memory files into the agent context
|
|
419
|
+
*/
|
|
420
|
+
async function runPhase2WithContext(ctx, paths) {
|
|
421
|
+
return runPhase2Tasks(ctx, paths);
|
|
422
|
+
}
|
|
423
|
+
function createSilentLogger() {
|
|
424
|
+
return {
|
|
425
|
+
info: () => {},
|
|
426
|
+
warn: (msg) => process.stderr.write(`[phase2] ${msg}\n`)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async function runPhase2Tasks(ctx, paths) {
|
|
430
|
+
const logger = createSilentLogger();
|
|
431
|
+
const results = [];
|
|
432
|
+
const tasks = [
|
|
433
|
+
async () => {
|
|
434
|
+
await scanExtensions(ctx, paths, logger);
|
|
435
|
+
},
|
|
436
|
+
async () => {
|
|
437
|
+
await preloadSkills(ctx, paths, logger);
|
|
438
|
+
},
|
|
439
|
+
async () => {
|
|
440
|
+
await connectMcpServers(ctx, logger);
|
|
441
|
+
},
|
|
442
|
+
async () => {
|
|
443
|
+
await loadMemory(ctx, paths, logger);
|
|
444
|
+
},
|
|
445
|
+
async () => {
|
|
446
|
+
await initChannels(ctx, logger);
|
|
447
|
+
}
|
|
448
|
+
];
|
|
449
|
+
for (const task of tasks) {
|
|
450
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
451
|
+
const started = Date.now();
|
|
452
|
+
const name = task.name || "unknown";
|
|
453
|
+
try {
|
|
454
|
+
await task();
|
|
455
|
+
results.push({
|
|
456
|
+
name,
|
|
457
|
+
ok: true,
|
|
458
|
+
elapsedMs: Date.now() - started
|
|
459
|
+
});
|
|
460
|
+
} catch (err) {
|
|
461
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
462
|
+
logger.warn(`${name} failed: ${message}`);
|
|
463
|
+
results.push({
|
|
464
|
+
name,
|
|
465
|
+
ok: false,
|
|
466
|
+
error: message,
|
|
467
|
+
elapsedMs: Date.now() - started
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (results.some((r) => !r.ok)) {
|
|
472
|
+
const failures = results.filter((r) => !r.ok).map((r) => r.name).join(", ");
|
|
473
|
+
logger.warn(`Phase 2 completed with failures: ${failures}`);
|
|
474
|
+
} else logger.info(`Phase 2 completed: ${results.length} tasks OK`);
|
|
475
|
+
return results;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Scan the extensions directory for plugins, register manifests,
|
|
479
|
+
* and execute plugin code via the PluginLoader.
|
|
480
|
+
*
|
|
481
|
+
* Looks in:
|
|
482
|
+
* 1. <lynx-home>/extensions/ (user extensions)
|
|
483
|
+
* 2. <project>/.lynx/extensions/ (project extensions)
|
|
484
|
+
* 3. <lynx-install>/extensions/ (built‑in extensions)
|
|
485
|
+
*/
|
|
486
|
+
async function scanExtensions(ctx, paths, logger) {
|
|
487
|
+
if (!ctx) return;
|
|
488
|
+
const { readdirSync, statSync } = await import("node:fs");
|
|
489
|
+
const { join } = await import("node:path");
|
|
490
|
+
const scanDirs = [join(paths.home, "extensions"), join(process.cwd(), ".lynx", "extensions")];
|
|
491
|
+
for (const dir of scanDirs) {
|
|
492
|
+
let entries;
|
|
493
|
+
try {
|
|
494
|
+
entries = readdirSync(dir);
|
|
495
|
+
} catch {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
for (const entry of entries) {
|
|
499
|
+
const fullPath = join(dir, entry);
|
|
500
|
+
try {
|
|
501
|
+
if (!statSync(fullPath).isDirectory()) continue;
|
|
502
|
+
} catch {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const manifestPath = join(fullPath, "manifest.json");
|
|
506
|
+
try {
|
|
507
|
+
statSync(manifestPath);
|
|
508
|
+
} catch {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
const discovered = ctx.pluginRegistry.manifestRegistry.scan([fullPath]);
|
|
513
|
+
for (const manifestEntry of discovered) {
|
|
514
|
+
const pluginCtx = {
|
|
515
|
+
pluginId: manifestEntry.manifest.name,
|
|
516
|
+
logger: {
|
|
517
|
+
info: (msg) => logger.info(`[plugin:${manifestEntry.manifest.name}] ${msg}`),
|
|
518
|
+
warn: (msg) => logger.warn(`[plugin:${manifestEntry.manifest.name}] ${msg}`),
|
|
519
|
+
error: (msg) => logger.warn(`[plugin:${manifestEntry.manifest.name}] ${msg}`)
|
|
520
|
+
},
|
|
521
|
+
config: {},
|
|
522
|
+
storage: createPluginStorage()
|
|
523
|
+
};
|
|
524
|
+
ctx.pluginRegistry.loader.load(manifestEntry, pluginCtx).catch((err) => {
|
|
525
|
+
logger.warn(`Plugin "${manifestEntry.manifest.name}" failed to load: ${String(err)}`);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
} catch {}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Create a simple in‑memory KV store for plugin storage.
|
|
534
|
+
*
|
|
535
|
+
* In Phase 5 this will be backed by the SQLite database
|
|
536
|
+
* for persistence across sessions.
|
|
537
|
+
*/
|
|
538
|
+
function createPluginStorage() {
|
|
539
|
+
const store = /* @__PURE__ */ new Map();
|
|
540
|
+
return {
|
|
541
|
+
get(key) {
|
|
542
|
+
return store.get(key);
|
|
543
|
+
},
|
|
544
|
+
set(key, value) {
|
|
545
|
+
store.set(key, value);
|
|
546
|
+
},
|
|
547
|
+
delete(key) {
|
|
548
|
+
store.delete(key);
|
|
549
|
+
},
|
|
550
|
+
clear() {
|
|
551
|
+
store.clear();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Preload skill definitions for autocomplete and quick access.
|
|
557
|
+
*
|
|
558
|
+
* Skills are already loaded during bootstrap (Phase 1). This Phase 2 task
|
|
559
|
+
* enqueues plugins for predictive warming through the PredictiveLoader,
|
|
560
|
+
* reducing first‑use latency for plugins that are likely to be needed.
|
|
561
|
+
*/
|
|
562
|
+
async function preloadSkills(ctx, _paths, _logger) {
|
|
563
|
+
if (!ctx) return;
|
|
564
|
+
const loader = ctx.predictiveLoader;
|
|
565
|
+
loader.onInputPrefix("/");
|
|
566
|
+
loader.onInputPrefix("@");
|
|
567
|
+
await loader.processQueue();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Connect to configured MCP servers.
|
|
571
|
+
*
|
|
572
|
+
* MCP connections are established during bootstrap (Phase 1) via
|
|
573
|
+
* McpManager.connect() for all configured servers. Connection
|
|
574
|
+
* failures are tracked via McpConnection.status.
|
|
575
|
+
*
|
|
576
|
+
* This Phase 2 task exists as a hook point for re‑connection or
|
|
577
|
+
* health check logic in future phases.
|
|
578
|
+
*/
|
|
579
|
+
async function connectMcpServers(_ctx, _logger) {}
|
|
580
|
+
/**
|
|
581
|
+
* Load memory files into the agent's context.
|
|
582
|
+
*
|
|
583
|
+
* Memory is loaded during bootstrap (Phase 1) from:
|
|
584
|
+
* 1. ~/.lynx/memory/
|
|
585
|
+
* 2. <workspace>/.claude/memory/
|
|
586
|
+
*
|
|
587
|
+
* Content flows through AppContext.memoryFacts → EngineDeps →
|
|
588
|
+
* LoopDeps → assembleSystemPrompt ("Memory" section).
|
|
589
|
+
*
|
|
590
|
+
* This Phase 2 task exists as a hook point for runtime memory
|
|
591
|
+
* reload (e.g. after file changes).
|
|
592
|
+
*/
|
|
593
|
+
async function loadMemory(_ctx, paths, _logger) {}
|
|
594
|
+
/**
|
|
595
|
+
* Initialize registered channel adapters (飞书 etc.).
|
|
596
|
+
*
|
|
597
|
+
* Channels are registered during bootstrap (Phase 1) but their
|
|
598
|
+
* init() (auth, WS connect) is deferred to Phase 2 so the TUI
|
|
599
|
+
* is already visible before network I/O starts.
|
|
600
|
+
*/
|
|
601
|
+
async function initChannels(ctx, logger) {
|
|
602
|
+
if (!ctx) return;
|
|
603
|
+
const channelIds = ctx.channelRegistry.list();
|
|
604
|
+
if (channelIds.length === 0) {
|
|
605
|
+
logger.info("No channel adapters registered — skipping channel init");
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
logger.info(`Initializing channels: ${channelIds.join(", ")}`);
|
|
609
|
+
await ctx.channelRegistry.initAll();
|
|
610
|
+
logger.info(`Channels initialized: ${channelIds.join(", ")}`);
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/terminal-mode.ts
|
|
614
|
+
/**
|
|
615
|
+
* Terminal mode management — alt buffer, mouse tracking, DEC sync.
|
|
616
|
+
*
|
|
617
|
+
* Provides functions to enter/exit the alternate screen buffer,
|
|
618
|
+
* enable/disable mouse tracking, and wrap output with DEC
|
|
619
|
+
* Synchronized Update markers for flicker‑free rendering.
|
|
620
|
+
*
|
|
621
|
+
* All escape sequences are no‑ops on unsupported terminals
|
|
622
|
+
* (graceful degradation).
|
|
623
|
+
*/
|
|
624
|
+
/** Enter alternate screen buffer. */
|
|
625
|
+
const ALT_ENTER = "\x1B[?1049h";
|
|
626
|
+
/** Exit alternate screen buffer. */
|
|
627
|
+
const ALT_EXIT = "\x1B[?1049l";
|
|
628
|
+
/** Begin DEC Synchronized Update — subsequent output is buffered. */
|
|
629
|
+
const BSU = "\x1B[?2026h";
|
|
630
|
+
/** End DEC Synchronized Update — flush buffered output atomically. */
|
|
631
|
+
const ESU = "\x1B[?2026l";
|
|
632
|
+
/** Enter fullscreen mode: alt buffer only (no mouse tracking). */
|
|
633
|
+
function enterFullscreen() {
|
|
634
|
+
process.stdout.write(ALT_ENTER);
|
|
635
|
+
}
|
|
636
|
+
/** Exit fullscreen mode: restore main buffer. */
|
|
637
|
+
function exitFullscreen() {
|
|
638
|
+
process.stdout.write(ALT_EXIT);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Wrap a synchronous callback with DEC Synchronized Update markers.
|
|
642
|
+
*
|
|
643
|
+
* All output written to stdout during the callback is buffered by the
|
|
644
|
+
* terminal and rendered atomically, eliminating flicker during re‑renders.
|
|
645
|
+
*
|
|
646
|
+
* Does NOT nest — calling beginSync inside an active sync region
|
|
647
|
+
* is a no‑op (tracked via module‑level flag).
|
|
648
|
+
*/
|
|
649
|
+
let syncActive = false;
|
|
650
|
+
function beginSync() {
|
|
651
|
+
if (syncActive) return;
|
|
652
|
+
syncActive = true;
|
|
653
|
+
process.stdout.write(BSU);
|
|
654
|
+
}
|
|
655
|
+
function endSync() {
|
|
656
|
+
if (!syncActive) return;
|
|
657
|
+
syncActive = false;
|
|
658
|
+
process.stdout.write(ESU);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Execute a callback within a DEC synchronized update region.
|
|
662
|
+
* Exceptions propagate; sync is always ended.
|
|
663
|
+
*/
|
|
664
|
+
function withSync(fn) {
|
|
665
|
+
beginSync();
|
|
666
|
+
try {
|
|
667
|
+
return fn();
|
|
668
|
+
} finally {
|
|
669
|
+
endSync();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/process-lifecycle.ts
|
|
674
|
+
let hardExitTimer = null;
|
|
675
|
+
let installed = false;
|
|
676
|
+
/** Maximum time (ms) allowed for graceful shutdown before force exit. */
|
|
677
|
+
const FORCE_EXIT_MS = 2e3;
|
|
678
|
+
/**
|
|
679
|
+
* Install process lifecycle handlers.
|
|
680
|
+
*
|
|
681
|
+
* Only call once. Subsequent calls are no‑ops.
|
|
682
|
+
* Handles SIGINT (3‑layer abort), SIGTERM (graceful shutdown),
|
|
683
|
+
* SIGHUP (graceful shutdown), and terminal loss.
|
|
684
|
+
*/
|
|
685
|
+
function installProcessLifecycle(config) {
|
|
686
|
+
if (installed) return;
|
|
687
|
+
installed = true;
|
|
688
|
+
const { ctx, onAbortLl, onAbortTool, incrementAbortLayer, isStreaming } = config;
|
|
689
|
+
const onSigint = () => {
|
|
690
|
+
const layer = incrementAbortLayer();
|
|
691
|
+
if (layer >= 3) {
|
|
692
|
+
exitFullscreen();
|
|
693
|
+
ctx.destroy();
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
if (!isStreaming()) {
|
|
697
|
+
exitFullscreen();
|
|
698
|
+
ctx.destroy();
|
|
699
|
+
process.exit(0);
|
|
700
|
+
}
|
|
701
|
+
if (layer === 1) onAbortLl();
|
|
702
|
+
else if (layer === 2) onAbortTool();
|
|
703
|
+
};
|
|
704
|
+
process.on("SIGINT", onSigint);
|
|
705
|
+
const gracefulShutdown = () => {
|
|
706
|
+
startForceExitTimer();
|
|
707
|
+
try {
|
|
708
|
+
exitFullscreen();
|
|
709
|
+
} catch {}
|
|
710
|
+
try {
|
|
711
|
+
ctx.destroy();
|
|
712
|
+
} catch {}
|
|
713
|
+
process.exit(0);
|
|
714
|
+
};
|
|
715
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
716
|
+
process.on("SIGHUP", () => {
|
|
717
|
+
try {
|
|
718
|
+
const newCfg = loadConfig();
|
|
719
|
+
if (typeof newCfg.model === "string" && newCfg.model !== ctx.agentConfig.model) {
|
|
720
|
+
ctx.agentConfig.model = newCfg.model;
|
|
721
|
+
process.stderr.write(`[lynx] SIGHUP:模型已切换 → ${newCfg.model}\n`);
|
|
722
|
+
}
|
|
723
|
+
if (typeof newCfg.theme === "string") process.stderr.write(`[lynx] SIGHUP:主题已切换 → ${newCfg.theme}\n`);
|
|
724
|
+
process.stderr.write("[lynx] SIGHUP:配置已重新加载\n");
|
|
725
|
+
} catch (reloadErr) {
|
|
726
|
+
process.stderr.write(`[lynx] SIGHUP 重新加载失败:${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)} — 回退到关闭流程\n`);
|
|
727
|
+
gracefulShutdown();
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
const onTerminalLost = () => {
|
|
731
|
+
startForceExitTimer();
|
|
732
|
+
try {
|
|
733
|
+
ctx.destroy();
|
|
734
|
+
} catch {}
|
|
735
|
+
process.exit(0);
|
|
736
|
+
};
|
|
737
|
+
process.stdin.on("end", onTerminalLost);
|
|
738
|
+
process.stdin.on("close", onTerminalLost);
|
|
739
|
+
process.stdout.on("close", onTerminalLost);
|
|
740
|
+
process.on("unhandledRejection", (reason) => {
|
|
741
|
+
process.stderr.write(`[lynx] 未处理的 Promise 拒绝:${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}\n`);
|
|
742
|
+
startForceExitTimer();
|
|
743
|
+
exitFullscreen();
|
|
744
|
+
ctx.destroy();
|
|
745
|
+
process.exit(1);
|
|
746
|
+
});
|
|
747
|
+
process.on("uncaughtException", (err) => {
|
|
748
|
+
process.stderr.write(`[lynx] 未捕获的异常:${err.stack ?? err.message}\n`);
|
|
749
|
+
startForceExitTimer();
|
|
750
|
+
exitFullscreen();
|
|
751
|
+
ctx.destroy();
|
|
752
|
+
process.exit(1);
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Register a cleanup handler that runs during graceful shutdown.
|
|
757
|
+
* Replaces any previously registered handler.
|
|
758
|
+
*/
|
|
759
|
+
function onCleanup(handler) {}
|
|
760
|
+
/**
|
|
761
|
+
* Remove all process lifecycle handlers (for testing).
|
|
762
|
+
*/
|
|
763
|
+
function uninstallProcessLifecycle() {
|
|
764
|
+
installed = false;
|
|
765
|
+
process.removeAllListeners("SIGINT");
|
|
766
|
+
process.removeAllListeners("SIGTERM");
|
|
767
|
+
process.removeAllListeners("SIGHUP");
|
|
768
|
+
if (hardExitTimer) {
|
|
769
|
+
clearTimeout(hardExitTimer);
|
|
770
|
+
hardExitTimer = null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function startForceExitTimer() {
|
|
774
|
+
if (hardExitTimer) return;
|
|
775
|
+
hardExitTimer = setTimeout(() => {
|
|
776
|
+
process.stderr.write("[lynx] 超时后强制退出\n");
|
|
777
|
+
process.exit(1);
|
|
778
|
+
}, FORCE_EXIT_MS);
|
|
779
|
+
hardExitTimer.unref();
|
|
780
|
+
}
|
|
781
|
+
//#endregion
|
|
782
|
+
export { endSync as a, withSync as c, runPhase2WithContext as d, bootstrap as f, beginSync as i, runPhase1 as l, onCleanup as n, enterFullscreen as o, uninstallProcessLifecycle as r, exitFullscreen as s, installProcessLifecycle as t, runPhase2 as u };
|
|
783
|
+
|
|
784
|
+
//# sourceMappingURL=process-lifecycle-Dg6n2QS-.mjs.map
|