@inobit/pi-instabox 0.0.0-stage → 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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1141 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +449 -0
- package/src/tools.ts +322 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 扩展入口(装配层,方案 §4.2/§4.4/§4.5/§4.6、§5.1、T15)。
|
|
3
|
+
*
|
|
4
|
+
* 装配顺序:
|
|
5
|
+
* 0. **加载期第一件事**:`loadConfig()` + `resolvePaths(getAgentDir())`,随即接上文件日志
|
|
6
|
+
* `<agentDir>/logs/pi-instabox/pi-instabox.jsonl`(渠道与级别见 {@link attachLogger})。
|
|
7
|
+
* 接线必须早于探测:否则 `cli.probe`(尤其失败/超时那条)会被丢掉,而它正是事后判断
|
|
8
|
+
* 「扩展为什么自禁用」的第一手证据。
|
|
9
|
+
* 1. 之后**加载期**只做一件事:`probeVersion`(`cli.ts` 默认 3000ms 预算),失败按原因分两种:
|
|
10
|
+
* - **明确没有 CLI**(`reason: "missing"` / 非 0 退出 / 无版本串)→ 只挂一条 `session_start`
|
|
11
|
+
* warning,不注册任何工具/命令/MCP、不写缓存与 helper、不 throw,也不重试。
|
|
12
|
+
* (这条「零注册」是方案 §7 验收 1 的硬要求;§6 的 T18 冒烟行只验证「无工具 / 无 MCP /
|
|
13
|
+
* 一条 warning / 无残留文件」,它不以 `/instabox-status` 为前提。)
|
|
14
|
+
* - **被超时击杀**(`reason: "timeout"`,实测 `instabox version` 803–1178ms,pi 加载期并发下
|
|
15
|
+
* 5 次里 2 次吃满预算)**不等于没有 CLI**:本次仍零注册,但等第一次 `session_start` 用
|
|
16
|
+
* {@link RETRY_PROBE_TIMEOUT_MS}(5000ms)重试一次——成功就当场走完整装配并在本会话补跑
|
|
17
|
+
* 后台任务,仍失败才按「无 CLI」禁用。只重试一次(一次性状态机)。
|
|
18
|
+
* 2. 有 CLI:建 `BoxLister` / `Pool` / `TemplateStore` 三个会话级单例(`cfg` / `paths` 已在第 0 步备好);
|
|
19
|
+
* **模板缓存命中就立刻注册工具**(加载期绝不跑 `ls`、
|
|
20
|
+
* 绝不连远端),未命中等到 `session_start` 的**后台**拉取。整段装配在 `assemble()` 里,
|
|
21
|
+
* 加载期与「超时后重试成功」共用同一份逻辑。日志已在第 0 步接上,因此装配全程的埋点都落盘;
|
|
22
|
+
* 装配失败 / 无 CLI 时**只**写日志,不建 `templates.json`、`bin/` 等运行期目录。
|
|
23
|
+
* 3. 命令 `/instabox-use`(含冷启动唯一重试入口)、`/instabox-status`(诊断)与
|
|
24
|
+
* `/instabox-devtools`(`status` / `on [box]` / `off` / `reset`,动态开关远端 MCP)、目标 entry
|
|
25
|
+
* renderer、两个 CLI flag(`--instabox-devtools` / `--instabox-devtools-box`,必须在工厂最前面登记,
|
|
26
|
+
* 否则 pi 会把它们当成未知选项直接 exit(1))。
|
|
27
|
+
* **DevTools MCP 默认关闭**:只有 `--instabox-devtools`(或 `/instabox-devtools on`)能开启;
|
|
28
|
+
* box 的优先级是**会话绑定 > `--instabox-devtools-box` > `config.devToolsBox`**,会话绑定由
|
|
29
|
+
* `/instabox-devtools on <box>` 写成一条 entry(`reset` 写清除标记,不改开关、不注销)。
|
|
30
|
+
* 加载期读不到命令行 flag 值,所以判定一律在 `session_start` 之后。
|
|
31
|
+
* 4. `session_start`:**同步段只做内存赋值与通知 drain**(`sessionCtx` 赋值、清一次性提示键、
|
|
32
|
+
* drain 队列),随后把后台任务丢到一个宏任务之后再跑。后台先按 flag 处理 DevTools、再拉模板声明
|
|
33
|
+
* 「成功即静默」,来源按
|
|
34
|
+
* 「有缓存 → 会话目标 / `config.defaultBox` / 最近拉取」「无缓存 → 第一台 running,
|
|
35
|
+
* 没有 running 才用列表第一台」选;**连接即唤醒**(后台同步也允许唤醒暂停的箱),
|
|
36
|
+
* 只有**真的同步不了**才提示一次;成功后实时读 codemode 状态,三分支里至多提示一次。
|
|
37
|
+
* 连「后台任务的同步前奏」都不放进事件处理器:`TemplateStore` / `readCache` 是同步磁盘读,
|
|
38
|
+
* 放在 handler 帧里就等于阻塞会话启动(gate 型单测也测不出这一层,见 §4.4)。
|
|
39
|
+
* 内部日志默认静音,只有 `PI_INSTABOX_DEBUG=1` 时才写(见 {@link debug})。
|
|
40
|
+
* 5. `session_tree`:只换掉本次事件的 ctx(不写 prompt、不注入上下文);目标状态本就不缓存,
|
|
41
|
+
* 见下一条。
|
|
42
|
+
* 6. `session_shutdown`:`pool.disposeAll()` 收掉本实例持有的远端连接(否则换会话 / `/reload`
|
|
43
|
+
* 之后会残留 ssh 子进程),并丢掉已失效的 ctx;随后 `setLogger(undefined)` 复位日志,
|
|
44
|
+
* 不留进程内残留(下一个会话重新装配时再建)。
|
|
45
|
+
*
|
|
46
|
+
* 会话状态一律**实时读** `ctx.sessionManager.getBranch()`:解析链第 2 级是「本轮最后一条 user
|
|
47
|
+
* 消息」,缓存它会让这一级永远读到会话开始时的快照而失效;因此每次工具调用都用工具自带的
|
|
48
|
+
* `ExtensionToolContext`(`tools.ts` 新增的可选第二参数)重新取分支。
|
|
49
|
+
*
|
|
50
|
+
* 硬约束(裁决 V2):**从不调用 `pi.setActiveTools`**,不改用户工具选择集;codemode 未激活
|
|
51
|
+
* 时只说清补救命令。所有异常吞掉转 `notify` / 工具 `isError`,绝不 throw 到会话层。
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import type {
|
|
55
|
+
ExtensionAPI,
|
|
56
|
+
ExtensionCommandContext,
|
|
57
|
+
ExtensionContext,
|
|
58
|
+
SessionEntry,
|
|
59
|
+
SessionStartEvent,
|
|
60
|
+
ToolExposure,
|
|
61
|
+
} from "@earendil-works/pi-coding-agent";
|
|
62
|
+
import { type BoxInfo, createBoxLister } from "./boxes.ts";
|
|
63
|
+
import { type ExecFn, type ProbeVersionResult, probeVersion } from "./cli.ts";
|
|
64
|
+
import { getAgentDir, loadConfig, resolvePaths } from "./config.ts";
|
|
65
|
+
import { buildMcpServerConfig, ensureHelperScript } from "./devtools.ts";
|
|
66
|
+
import { createTargetRendererWithHostText } from "./entry-render.ts";
|
|
67
|
+
import { type LogLevel, type Logger, createFileLogger, logEvent, setLogger } from "./log.ts";
|
|
68
|
+
import { type Pool, createPool } from "./pool.ts";
|
|
69
|
+
import type { RemoteClient, RemoteTool } from "./remote.ts";
|
|
70
|
+
import { resolveBox, type ResolveResult } from "./resolve.ts";
|
|
71
|
+
import {
|
|
72
|
+
DEVTOOLS_TARGET_ENTRY,
|
|
73
|
+
TARGET_ENTRY,
|
|
74
|
+
devtoolsTargetEntryData,
|
|
75
|
+
readDevtoolsTarget,
|
|
76
|
+
readTarget,
|
|
77
|
+
targetEntryData,
|
|
78
|
+
} from "./session-target.ts";
|
|
79
|
+
import { createTemplateStore, nodeReadText, readCache } from "./template.ts";
|
|
80
|
+
import { buildToolDefinitions } from "./tools.ts";
|
|
81
|
+
|
|
82
|
+
/** 无 `instabox` CLI 时的唯一提示(每会话一条;方案 §7 验收 1)。 */
|
|
83
|
+
const NO_CLI_MESSAGE = "未找到 instabox CLI,instabox 扩展已禁用";
|
|
84
|
+
|
|
85
|
+
/** `ls` 里唯一算「健康」的状态:后台声明优先挑它(连它不需要唤醒)。 */
|
|
86
|
+
const RUNNING_STATE = "running";
|
|
87
|
+
|
|
88
|
+
/** 没有可用沙箱(`ls` 空或失败):不可恢复,每会话至多一条。 */
|
|
89
|
+
const NO_BOX_MESSAGE =
|
|
90
|
+
"instabox: 没有可用沙箱(instabox ls 返回空或失败),桌面工具未注册;请检查 instabox CLI 与沙箱状态";
|
|
91
|
+
|
|
92
|
+
/** codemode 工具的注册名(只读判定用,绝不用来 `setActiveTools`)。 */
|
|
93
|
+
const CODEMODE_TOOL = "codemode";
|
|
94
|
+
|
|
95
|
+
/** codemode 未激活(条目存在)时的补救提示(方案 §4.2 检测分支②,原文口径)。 */
|
|
96
|
+
const CODEMODE_INACTIVE_MESSAGE =
|
|
97
|
+
'instabox 桌面工具需要 codemode 处于激活状态:pi --tools +codemode,或在 settings 里设 defaultTools: ["+codemode"]';
|
|
98
|
+
|
|
99
|
+
/** codemode 条目根本不存在时的提示(被 `--tools` 白名单 / 替换掉的内置扩展剔除,分支③)。 */
|
|
100
|
+
const CODEMODE_UNAVAILABLE_MESSAGE =
|
|
101
|
+
"本会话没有 codemode 工具,instabox 桌面工具不可调用;请允许 codemode(--tools +codemode 或内置扩展)";
|
|
102
|
+
|
|
103
|
+
/** `/instabox-status` 里 codemode 状态的展示文案。 */
|
|
104
|
+
const CODEMODE_LABELS = {
|
|
105
|
+
active: "active",
|
|
106
|
+
inactive: "registered, not active",
|
|
107
|
+
unavailable: "unavailable (no codemode tool in this session)",
|
|
108
|
+
} as const;
|
|
109
|
+
|
|
110
|
+
/** 开关 flag:**唯一**能开启远端 DevTools MCP 的入口(config 只补默认 box)。 */
|
|
111
|
+
const DEVTOOLS_FLAG = "instabox-devtools";
|
|
112
|
+
|
|
113
|
+
/** box flag:覆盖 `config.devToolsBox` 的默认箱名;空串=没给。 */
|
|
114
|
+
const DEVTOOLS_BOX_FLAG = "instabox-devtools-box";
|
|
115
|
+
|
|
116
|
+
/** 只给了 box flag、没给开关:明说怎么开启,不静默启用(config 里的 box 同理)。 */
|
|
117
|
+
const DEVTOOLS_SWITCH_MISSING_MESSAGE = "instabox: 已指定 box 但未开启 devtools,请加 --instabox-devtools";
|
|
118
|
+
|
|
119
|
+
/** 开关开着但三个来源都没给箱名:不猜,直接不注册。 */
|
|
120
|
+
const DEVTOOLS_NO_BOX_MESSAGE =
|
|
121
|
+
"instabox: 未指定 devtools box(--instabox-devtools-box 或 config.devToolsBox),DevTools MCP 未注册";
|
|
122
|
+
|
|
123
|
+
/** `reset` 的提示:entry 已清,注册与开关都没动,下一次 `on` 才会回退。 */
|
|
124
|
+
const DEVTOOLS_RESET_MESSAGE = "instabox: 已清除会话绑定的 devtools box,下次 on 将回退到 flag/config";
|
|
125
|
+
|
|
126
|
+
/** `/instabox-devtools` 的用法提示(未知子命令时才出现)。 */
|
|
127
|
+
const DEVTOOLS_USAGE = "用法:/instabox-devtools [status|on [box]|off|reset]";
|
|
128
|
+
|
|
129
|
+
/** 扩展内部排查日志前缀(H6:内部日志只作排查,不进验收)。 */
|
|
130
|
+
const LOG_PREFIX = "[pi-instabox]";
|
|
131
|
+
|
|
132
|
+
/** codemode 三态:已激活 / 有条目但未激活 / 本会话没有该条目。 */
|
|
133
|
+
type CodemodeState = keyof typeof CODEMODE_LABELS;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* DevTools box 的三个来源(优先级同序):会话绑定 > `--instabox-devtools-box` > `config.devToolsBox`。
|
|
137
|
+
*/
|
|
138
|
+
type DevtoolsBoxSource = "session" | "flag" | "config";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 一次 DevTools 注册的来源(`devtools.enabled` 与 `status` 回显):
|
|
142
|
+
* 启动路径取实际生效的 box 来源,`/instabox-devtools on` 路径一律 `command`。
|
|
143
|
+
*/
|
|
144
|
+
type DevtoolsRegistrationSource = DevtoolsBoxSource | "command";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 一条没有 UI 出口时排队、等 `session_start` drain 的提示。
|
|
148
|
+
*
|
|
149
|
+
* `key` 存在表示它来自 `notifyOnce`(每会话至多一条):只有 drain 真正发出去之后才置位
|
|
150
|
+
* `notified`,否则「已通知」会提前标记、把提示吞掉(加载期 `warn`、工具执行期的 `skippedConfig`)。
|
|
151
|
+
*/
|
|
152
|
+
interface PendingNotification {
|
|
153
|
+
/** `notifyOnce` 的去重键;加载期固定提示(helper 写盘失败等)没有键 */
|
|
154
|
+
key?: string;
|
|
155
|
+
message: string;
|
|
156
|
+
type: "info" | "warning" | "error";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 日志构造参数(`createFileLogger` 的子集):单测注入替身只需实现这些。
|
|
161
|
+
*/
|
|
162
|
+
export interface InstaboxLoggerOptions {
|
|
163
|
+
/** 日志目录(装配层给 `<agentDir>/logs/pi-instabox`) */
|
|
164
|
+
dir: string;
|
|
165
|
+
/** `PI_INSTABOX_DEBUG=1` → `debug`(含 `debug` 事件),否则 `info`(只记生命周期) */
|
|
166
|
+
level: LogLevel;
|
|
167
|
+
/** 写盘失败回调(默认转 {@link debug},因此只有开 debug 时才见到) */
|
|
168
|
+
onError: (message: string) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 装配注入面:真实调用由 pi 只传 `pi`(默认值即生产行为),单测用它替掉会 spawn 的真远端客户端。
|
|
173
|
+
*/
|
|
174
|
+
export interface InstaboxAssemblyDeps {
|
|
175
|
+
/** 覆盖 `pool` 的远端客户端工厂(默认 `createRemoteClient`,会 spawn `instabox ssh`) */
|
|
176
|
+
clientFactory?: (box: string) => RemoteClient;
|
|
177
|
+
/**
|
|
178
|
+
* 覆盖文件日志构造(默认 `createFileLogger`)。
|
|
179
|
+
*
|
|
180
|
+
* 注入即视为显式测试接缝:`VITEST` 下**只有**注入时才建 logger,否则一律不建、不碰磁盘。
|
|
181
|
+
*/
|
|
182
|
+
loggerFactory?: (options: InstaboxLoggerOptions) => Logger;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 内部排查日志:**默认完全静音**,只有 `PI_INSTABOX_DEBUG=1` 才写一条。
|
|
187
|
+
*
|
|
188
|
+
* 后台同步成功即静默(不发 notice),日志也只在显式开启时出现,免得把会话输出搅乱;
|
|
189
|
+
* 排查时 `PI_INSTABOX_DEBUG=1 pi …` 就能看到来源选择、唤醒、握手失败这些细节。
|
|
190
|
+
*/
|
|
191
|
+
function debug(message: string): void {
|
|
192
|
+
if (process.env.PI_INSTABOX_DEBUG !== "1") return;
|
|
193
|
+
try {
|
|
194
|
+
console.warn(`${LOG_PREFIX} ${message}`);
|
|
195
|
+
} catch {
|
|
196
|
+
// 日志本身失败不能变成用户可见错误
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `unknown` → message 文本(错误文本里不出现 `[object Object]`)。 */
|
|
201
|
+
function messageOf(error: unknown): string {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 分支里**最后一条 user 消息**的纯文本(解析链第 2 级;只取 text 段,图片忽略)。 */
|
|
206
|
+
function lastUserText(entries: readonly SessionEntry[]): string | undefined {
|
|
207
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
208
|
+
const entry = entries[i];
|
|
209
|
+
if (entry === undefined || entry.type !== "message") continue;
|
|
210
|
+
const message = entry.message;
|
|
211
|
+
if (message.role !== "user") continue;
|
|
212
|
+
return textOfContent(message.content);
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** user 消息内容 → 纯文本:`string` 原样,内容数组只拼 `type === "text"` 段。 */
|
|
218
|
+
function textOfContent(content: unknown): string | undefined {
|
|
219
|
+
if (typeof content === "string") return content;
|
|
220
|
+
if (!Array.isArray(content)) return undefined;
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
for (const item of content) {
|
|
223
|
+
if (typeof item !== "object" || item === null) continue;
|
|
224
|
+
const record = item as { type?: unknown; text?: unknown };
|
|
225
|
+
if (record.type === "text" && typeof record.text === "string") parts.push(record.text);
|
|
226
|
+
}
|
|
227
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 候选箱名:去空、去重、保持 `ls` 顺序。 */
|
|
231
|
+
function namesOf(boxes: readonly BoxInfo[]): string[] {
|
|
232
|
+
const seen = new Set<string>();
|
|
233
|
+
const names: string[] = [];
|
|
234
|
+
for (const box of boxes) {
|
|
235
|
+
if (box.name === "" || seen.has(box.name)) continue;
|
|
236
|
+
seen.add(box.name);
|
|
237
|
+
names.push(box.name);
|
|
238
|
+
}
|
|
239
|
+
return names;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 把异步的 `pool.get` 桥成 `template.ts` 要的**同步**客户端工厂。
|
|
244
|
+
*
|
|
245
|
+
* 工厂只记下 box;第一次 RPC 才去池里取连接。`allowWake` 由 `template.ensure()` 透传,
|
|
246
|
+
* 省略时按 `true` 处理:**连接即唤醒**(池自己只在箱不是 running 时才跑 wake/start/resume),
|
|
247
|
+
* 后台同步也遵守同一条规则——否则「声明了工具却因为箱在暂停而永远连不上」会变成静默降级。
|
|
248
|
+
* 取不到就把原因抛给 `template.ensure`,由它自己收成 `{ ok: false, error }` 并保留旧缓存。
|
|
249
|
+
* 连接归 `pool` 所有,这里不 `close()`。
|
|
250
|
+
*/
|
|
251
|
+
function lazyPoolClient(pool: Pool, box: string, options?: { allowWake?: boolean }): RemoteClient {
|
|
252
|
+
let client: RemoteClient | undefined;
|
|
253
|
+
|
|
254
|
+
async function use(): Promise<RemoteClient> {
|
|
255
|
+
if (client !== undefined && !client.isDead()) return client;
|
|
256
|
+
const got = await pool.get(box, { allowWake: options?.allowWake ?? true });
|
|
257
|
+
if (!got.ok) throw new Error(got.error);
|
|
258
|
+
client = got.client;
|
|
259
|
+
// `remote.ts` 的 initialize 幂等(重复调用共享同一次握手),因此这里只是把
|
|
260
|
+
// `initialize` 的结果(含 `serverInfo.version`)取回来,不会再发一轮握手。
|
|
261
|
+
return client;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
initialize: async (signal) => (await use()).initialize(signal),
|
|
266
|
+
listTools: async (signal) => (await use()).listTools(signal),
|
|
267
|
+
callTool: async (name, args, signal) => (await use()).callTool(name, args, signal),
|
|
268
|
+
close: () => {
|
|
269
|
+
// 连接由 pool 持有并负责关停,模板层只借用
|
|
270
|
+
},
|
|
271
|
+
isDead: () => client !== undefined && client.isDead(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 超时后的会话重试预算(毫秒):`instabox version` 冷启动实测 803–1178ms,留足余量。 */
|
|
276
|
+
const RETRY_PROBE_TIMEOUT_MS = 5_000;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 探测失败是否只是「被超时击杀」。
|
|
280
|
+
*
|
|
281
|
+
* 只有 `reason: "timeout"`(宿主 `killed: true`)才算;`missing`(ENOENT / command not found)、
|
|
282
|
+
* `error`(非 0 退出、无版本串)都是真的没有可用 CLI,重试没有意义。
|
|
283
|
+
*/
|
|
284
|
+
function probeTimedOut(probe: ProbeVersionResult): boolean {
|
|
285
|
+
return !probe.ok && probe.reason === "timeout";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 扩展默认导出:加载期探测 → 装配(或按「无 CLI」降级,超时则等 `session_start` 重试一次)。 */
|
|
289
|
+
export default async function instaboxExtension(
|
|
290
|
+
pi: ExtensionAPI,
|
|
291
|
+
deps: InstaboxAssemblyDeps = {},
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
// —— 工厂最前面:登记两个 CLI flag ——
|
|
294
|
+
// pi 只在扩展加载期判定 flag 合法性,未登记的 flag 会让进程直接 exit(1),所以这两条必须
|
|
295
|
+
// 早于配置解析、日志接线与 CLI 探测。这里只登记;加载期 `getFlag()` 读到的仍是默认值
|
|
296
|
+
// (命令行值在扩展加载完成之后才写进 `runtime.flagValues`),真正的判定在 `session_start`
|
|
297
|
+
// 之后(见 `assemble()` 里的 `devToolsSwitchOn`)。
|
|
298
|
+
pi.registerFlag(DEVTOOLS_FLAG, {
|
|
299
|
+
type: "boolean",
|
|
300
|
+
default: false,
|
|
301
|
+
description: "Enable the remote chrome-devtools-mcp server (default: disabled)",
|
|
302
|
+
});
|
|
303
|
+
pi.registerFlag(DEVTOOLS_BOX_FLAG, {
|
|
304
|
+
type: "string",
|
|
305
|
+
default: "",
|
|
306
|
+
description: "Box that hosts chrome-devtools-mcp (default: config.devToolsBox)",
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const exec: ExecFn = (command, args, options) => pi.exec(command, args, options);
|
|
310
|
+
|
|
311
|
+
// —— 加载期第 0 步:配置与路径(探测之前,因此配置只在这里解析一次) ——
|
|
312
|
+
const cfg = loadConfig();
|
|
313
|
+
const paths = resolvePaths(getAgentDir());
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 接上文件日志(`<agentDir>/logs/pi-instabox/pi-instabox.jsonl`):级别由 `PI_INSTABOX_DEBUG` 定。
|
|
317
|
+
*
|
|
318
|
+
* **探测之前**就调(配置与路径解析完之后立刻),这样 `cli.probe`(含失败/超时)与装配失败
|
|
319
|
+
* 都有记录;`log.ts` 的 `createFileLogger` 只在写入时建目录,因此「只写日志、不建运行期目录」
|
|
320
|
+
* 由写入路径本身保证。`VITEST` 下不建(否则单测会写真实磁盘),此时 `logEvent` 就是 no-op;
|
|
321
|
+
* 注入 `loggerFactory` 即视为显式测试接缝,跳过这个短路。
|
|
322
|
+
*/
|
|
323
|
+
function attachLogger(): void {
|
|
324
|
+
if (deps.loggerFactory === undefined && process.env.VITEST) return;
|
|
325
|
+
const factory = deps.loggerFactory ?? createFileLogger;
|
|
326
|
+
setLogger(
|
|
327
|
+
factory({
|
|
328
|
+
dir: paths.logDir,
|
|
329
|
+
level: process.env.PI_INSTABOX_DEBUG === "1" ? "debug" : "info",
|
|
330
|
+
onError: (message) => debug(message),
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
attachLogger();
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 成功路径的完整装配:池 / 模板缓存 / 命令 / renderer / DevTools MCP / 事件。
|
|
339
|
+
*
|
|
340
|
+
* `cfg` / `paths` 来自加载期(第 0 步),日志已在那时接上。加载期探测通过时调一次;
|
|
341
|
+
* 「探测超时 → `session_start` 重试通过」时也调它(同一份逻辑,不复制)。
|
|
342
|
+
* 返回的 `startSession` 供重试路径在当前会话补跑后台任务。
|
|
343
|
+
*/
|
|
344
|
+
function assemble(): { startSession: (ctx: ExtensionContext, source?: SessionStartEvent["reason"]) => void } {
|
|
345
|
+
const boxLister = createBoxLister({ exec });
|
|
346
|
+
const pool = createPool({
|
|
347
|
+
exec,
|
|
348
|
+
listBoxes: boxLister.list,
|
|
349
|
+
timeouts: cfg.timeouts,
|
|
350
|
+
...(deps.clientFactory === undefined ? {} : { clientFactory: deps.clientFactory }),
|
|
351
|
+
});
|
|
352
|
+
const templateStore = createTemplateStore({
|
|
353
|
+
paths,
|
|
354
|
+
clientFactory: (box, options) => lazyPoolClient(pool, box, options),
|
|
355
|
+
warn: (message) => debug(message),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/** 最近一次事件的 ctx(工具执行期没有装配层的 ctx,一次性提示都走这里) */
|
|
359
|
+
let sessionCtx: ExtensionContext | undefined;
|
|
360
|
+
/** 本会话已发出的一次性提示键(`session_start` 清空) */
|
|
361
|
+
const notified = new Set<string>();
|
|
362
|
+
/** 已注册的桌面工具名 → exposure(`/instabox-status` 与「是否有工具」判定都读它) */
|
|
363
|
+
const registeredTools = new Map<string, ToolExposure>();
|
|
364
|
+
/** 当前工具声明来自哪台箱的模板缓存(**不是**解析链结果:实际目标在调用时解析) */
|
|
365
|
+
let declaredFromBox: string | undefined;
|
|
366
|
+
/** 加载期产生、等 `session_start` 才有的 UI 出口 */
|
|
367
|
+
const pendingNotifications: PendingNotification[] = [];
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 实时读当前分支:传进来的 ctx 优先(工具调用用工具自带 ctx,命令用命令 ctx),
|
|
371
|
+
* 否则退回最近一次事件的 ctx。**不缓存**:解析链第 2 级必须看到本轮最新的 user 消息。
|
|
372
|
+
*/
|
|
373
|
+
function branchOf(ctx: ExtensionContext | undefined): SessionEntry[] {
|
|
374
|
+
const source = ctx ?? sessionCtx;
|
|
375
|
+
if (source === undefined) return [];
|
|
376
|
+
try {
|
|
377
|
+
return source.sessionManager.getBranch();
|
|
378
|
+
} catch (error) {
|
|
379
|
+
debug(`instabox: cannot read session branch: ${messageOf(error)}`);
|
|
380
|
+
logEvent("error", { where: "session branch", message: messageOf(error) }, "warn");
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 每会话至多一条(同键只发一次)。
|
|
387
|
+
*
|
|
388
|
+
* 没有 UI 出口时(加载期、或 `session_shutdown` 之后放掉了 ctx)先入队、**不**置位去重键:
|
|
389
|
+
* 键只在真正发出去时({@link flushPendingNotifications} 或这里)才标记,否则那条提示会被
|
|
390
|
+
* 「已通知」静默吞掉。
|
|
391
|
+
*/
|
|
392
|
+
function notifyOnce(key: string, message: string, type: "info" | "warning" = "warning"): void {
|
|
393
|
+
if (notified.has(key)) return;
|
|
394
|
+
const ctx = sessionCtx;
|
|
395
|
+
if (ctx === undefined) {
|
|
396
|
+
if (!pendingNotifications.some((item) => item.key === key)) pendingNotifications.push({ key, message, type });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
notified.add(key);
|
|
400
|
+
ctx.ui.notify(message, type);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** drain 排队的提示:**先发再标记**(去重键已置位的跳过),所以加载期那条不会丢也不会重复。 */
|
|
404
|
+
function flushPendingNotifications(ctx: ExtensionContext): void {
|
|
405
|
+
for (const item of pendingNotifications.splice(0)) {
|
|
406
|
+
if (item.key !== undefined) {
|
|
407
|
+
if (notified.has(item.key)) continue;
|
|
408
|
+
notified.add(item.key);
|
|
409
|
+
}
|
|
410
|
+
ctx.ui.notify(item.message, item.type);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 兜底:任何未预料的异常都只记日志 + 一条 warning,**绝不 throw 到会话层**
|
|
416
|
+
* (命令与事件处理器共用;正常的失败路径各自有更具体的提示)。
|
|
417
|
+
*/
|
|
418
|
+
async function safely(ctx: ExtensionContext, label: string, body: () => Promise<void>): Promise<void> {
|
|
419
|
+
try {
|
|
420
|
+
await body();
|
|
421
|
+
} catch (error) {
|
|
422
|
+
debug(`instabox: ${label} failed: ${messageOf(error)}`);
|
|
423
|
+
logEvent("error", { where: label, message: messageOf(error) }, "warn");
|
|
424
|
+
ctx.ui.notify(`instabox: ${label} 失败:${messageOf(error)}`, "warning");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 解析链(方案 §4.3、T6):`box` 参数 → 本轮最后一条 user 消息 → 会话目标 →
|
|
430
|
+
* `config.defaultBox` → `ls` 第一台。`skippedConfig` 每会话只 warn 一次。
|
|
431
|
+
*
|
|
432
|
+
* `toolCtx` 是 `tools.ts` 透传的本次调用上下文:分支**每次**都重新读,因此第 2 级不会
|
|
433
|
+
* 退化成「会话开始时的快照」。
|
|
434
|
+
*/
|
|
435
|
+
async function resolveTarget(paramBox?: string, toolCtx?: ExtensionContext): Promise<ResolveResult> {
|
|
436
|
+
const branch = branchOf(toolCtx);
|
|
437
|
+
const listed = await boxLister.list();
|
|
438
|
+
if (!listed.ok) {
|
|
439
|
+
const reason = listed.error ?? "unknown error";
|
|
440
|
+
return { ok: false, kind: "no-boxes", message: `cannot list boxes: ${reason}`, candidates: [] };
|
|
441
|
+
}
|
|
442
|
+
const result = resolveBox({
|
|
443
|
+
paramBox,
|
|
444
|
+
lastUserMessage: lastUserText(branch),
|
|
445
|
+
sessionTarget: readTarget(branch),
|
|
446
|
+
configDefaultBox: cfg.defaultBox,
|
|
447
|
+
boxes: listed.boxes,
|
|
448
|
+
});
|
|
449
|
+
if (result.ok && result.skippedConfig === true) {
|
|
450
|
+
notifyOnce(
|
|
451
|
+
"skipped-config",
|
|
452
|
+
`instabox: config.defaultBox "${cfg.defaultBox}" 不是现有箱子,已回落到 ls 第一台(${result.box})`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 注册某箱模板里的工具(同名替换由宿主保证),返回本次声明的条数。 */
|
|
459
|
+
function registerDesktopTools(remoteTools: readonly RemoteTool[], box: string): number {
|
|
460
|
+
const definitions = buildToolDefinitions({
|
|
461
|
+
remoteTools,
|
|
462
|
+
pool,
|
|
463
|
+
resolve: resolveTarget,
|
|
464
|
+
listBoxes: () => boxLister.list(),
|
|
465
|
+
warn: (message) => notifyOnce(`register-warning:${message}`, message),
|
|
466
|
+
});
|
|
467
|
+
for (const definition of definitions) {
|
|
468
|
+
pi.registerTool(definition);
|
|
469
|
+
registeredTools.set(definition.name, definition.exposure ?? "direct");
|
|
470
|
+
}
|
|
471
|
+
declaredFromBox = box;
|
|
472
|
+
return definitions.length;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 缓存里**最近拉取**的那台箱(`fetchedAt` 最新,平局按名字字典序),**不是**解析链结果。
|
|
477
|
+
*
|
|
478
|
+
* 它只回答「加载期/后台用哪份缓存声明工具」,取决于缓存本身而不是当前目标;
|
|
479
|
+
* `/instabox-status` 会把这一点显式标注出来(`tool declarations: …`)。
|
|
480
|
+
*
|
|
481
|
+
* **同步磁盘读**(`readCache`):只能在加载期或 `backgroundSessionStart` 的异步体里调用,
|
|
482
|
+
* 绝不要放进 `session_start` 的同步段。
|
|
483
|
+
*/
|
|
484
|
+
function mostRecentlyFetchedCachedBox(): string | undefined {
|
|
485
|
+
const { boxes } = readCache(nodeReadText, paths.templateCacheFile);
|
|
486
|
+
let best: { box: string; at: number } | undefined;
|
|
487
|
+
for (const [box, entry] of Object.entries(boxes)) {
|
|
488
|
+
if (box === "") continue;
|
|
489
|
+
const parsed = Date.parse(entry.fetchedAt);
|
|
490
|
+
const at = Number.isNaN(parsed) ? 0 : parsed;
|
|
491
|
+
if (best === undefined || at > best.at || (at === best.at && box < best.box)) best = { box, at };
|
|
492
|
+
}
|
|
493
|
+
return best?.box;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 缓存优先的声明来源(**不跑 `ls`**):会话目标 → `config.defaultBox` →
|
|
498
|
+
* 缓存里最近拉取的那台。只有「该箱在 per-box 模板缓存里确实有条目」才算命中。
|
|
499
|
+
*
|
|
500
|
+
* 首访会同步读 `templates.json`(`TemplateStore` 惰性加载),因此同样只允许在加载期或
|
|
501
|
+
* `backgroundSessionStart` 的异步体里调用。
|
|
502
|
+
*/
|
|
503
|
+
function cachedTargetBox(ctx?: ExtensionContext): string | undefined {
|
|
504
|
+
const sessionTarget = readTarget(branchOf(ctx));
|
|
505
|
+
for (const candidate of [sessionTarget, cfg.defaultBox]) {
|
|
506
|
+
if (candidate !== undefined && candidate !== "" && templateStore.get(candidate) !== undefined) {
|
|
507
|
+
return candidate;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return mostRecentlyFetchedCachedBox();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** codemode 三态:实时读 `getActiveTools()` / `getAllTools()`(加载期一律不判定)。 */
|
|
514
|
+
function codemodeState(): CodemodeState {
|
|
515
|
+
if (pi.getActiveTools().includes(CODEMODE_TOOL)) return "active";
|
|
516
|
+
const registered = pi.getAllTools().some((tool) => tool.name === CODEMODE_TOOL);
|
|
517
|
+
return registered ? "inactive" : "unavailable";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* codemode 三态 + 是否有 codemode-exposure 的 MCP server,落一条 `codemode` 埋点。
|
|
522
|
+
* 日志里的 `missing` 对应内部三态的 `unavailable`(本会话根本没有 codemode 条目)。
|
|
523
|
+
*/
|
|
524
|
+
function logCodemode(): void {
|
|
525
|
+
const state = codemodeState();
|
|
526
|
+
logEvent(
|
|
527
|
+
"codemode",
|
|
528
|
+
{ state: state === "unavailable" ? "missing" : state, server: hasCodemodeExposureServer() },
|
|
529
|
+
"debug",
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 是否存在 enabled 且 `codemode` exposure 的 MCP server。
|
|
535
|
+
* `exposure` 缺省即 `codemode`(`mcp-servers.d.ts` 的默认值),所以不能只比字面量。
|
|
536
|
+
*
|
|
537
|
+
* 局限(已确认,非实现选择):`pi.getMcpServers()` 只返回**扩展注册**的 server
|
|
538
|
+
* (`loader.js` 里就是 `runtime.mcpServers.list()`,即 `registerMcpServer()` 的登记簿),
|
|
539
|
+
* 拿不到 `mcp.json` 里的条目,所以这里也判不了它们。`mcp.json` 的 codemode server 连上后
|
|
540
|
+
* pi 会自动激活 codemode,结果体现在 {@link codemodeState} 的 `active` 分支;
|
|
541
|
+
* 只有在自动激活尚未发生(连接中、或 `autoEnableCodemode: false`)时,本扩展才可能多报一条
|
|
542
|
+
* 「需要激活」的提示——每会话至多一条,且给了可执行补救,属可接受的保守提示。
|
|
543
|
+
*/
|
|
544
|
+
function hasCodemodeExposureServer(): boolean {
|
|
545
|
+
const servers = typeof pi.getMcpServers === "function" ? pi.getMcpServers() : [];
|
|
546
|
+
return servers.some(
|
|
547
|
+
(server) => (server.config.exposure ?? "codemode") === "codemode" && server.config.enabled !== false,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 需要给用户的 codemode 提示(无则 `undefined`):
|
|
553
|
+
* 已激活 → 无;有 codemode exposure 的 MCP server → 无(pi 会自动激活,提示只会是噪音);
|
|
554
|
+
* 有条目未激活 → 补救命令;没有条目 → 明说本会话不可用。**绝不激活、绝不改选择集**。
|
|
555
|
+
*/
|
|
556
|
+
function codemodeNotice(): string | undefined {
|
|
557
|
+
const state = codemodeState();
|
|
558
|
+
if (state === "active") return undefined;
|
|
559
|
+
if (hasCodemodeExposureServer()) return undefined;
|
|
560
|
+
return state === "inactive" ? CODEMODE_INACTIVE_MESSAGE : CODEMODE_UNAVAILABLE_MESSAGE;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** `session_start` 的 codemode 判定:至多一次,且只在确实有桌面工具时才有意义。 */
|
|
564
|
+
function notifyCodemodeOnce(): void {
|
|
565
|
+
logCodemode();
|
|
566
|
+
if (registeredTools.size === 0) return;
|
|
567
|
+
const notice = codemodeNotice();
|
|
568
|
+
if (notice !== undefined) notifyOnce("codemode", notice);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 同步失败的可操作提示(每会话至多一条):只给 `/instabox-use <box>` 这条重试入口。 */
|
|
572
|
+
function notifySyncFailure(box: string, reason: string): void {
|
|
573
|
+
debug(`instabox: cannot sync desktop tools for "${box}": ${reason}`);
|
|
574
|
+
notifyOnce(
|
|
575
|
+
"sync-failed",
|
|
576
|
+
`instabox: 未能同步沙箱 "${box}" 的桌面工具(${reason});执行 /instabox-use ${box} 唤醒并重试。`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* `session_start` 的后台声明:**成功一律静默**,只有真的同步不了才提示。
|
|
582
|
+
*
|
|
583
|
+
* 来源(按顺序):
|
|
584
|
+
* 1. 有缓存:会话目标 → `config.defaultBox` → 最近拉取的那台(都要有缓存条目才算);
|
|
585
|
+
* `ensure(box, { allowWake: true })` 只做一次版本校验,失败**静默**沿用 `stale`。
|
|
586
|
+
* 2. 无缓存:`ls --json`(`force: true`,跳过 5s 缓存)
|
|
587
|
+
* - 有 `state === "running"` 的箱 → 取**第一台** running(连上它不需要唤醒);
|
|
588
|
+
* - 全是非 running(列表非空)→ 取**列表第一台**。
|
|
589
|
+
* 两条都传 `allowWake: true`:**连接即唤醒**,后台同步也允许把暂停的箱拉起来
|
|
590
|
+
* (不传 `onProgress`,因此不会往会话里塞进度提示)。
|
|
591
|
+
* 3. `ls` 空或失败 → 一条「没有可用沙箱」warning(不可恢复,每会话一次)。
|
|
592
|
+
* 4. 拉到失败(无缓存、唤醒失败、认证/网络)→ 一条带 `/instabox-use <box>` 的 warning。
|
|
593
|
+
*/
|
|
594
|
+
async function refreshTemplateInBackground(ctx: ExtensionContext): Promise<void> {
|
|
595
|
+
const cachedBox = cachedTargetBox(ctx);
|
|
596
|
+
if (cachedBox !== undefined) {
|
|
597
|
+
// 一次握手做版本校验;失败沿用旧缓存(新鲜度放弃),静默不提示
|
|
598
|
+
const fetched = await templateStore.ensure(cachedBox, { allowWake: true });
|
|
599
|
+
const template = fetched.ok ? fetched.template : fetched.stale;
|
|
600
|
+
if (template === undefined) {
|
|
601
|
+
notifySyncFailure(cachedBox, fetched.ok ? "no cached template" : fetched.error);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (!fetched.ok) debug(`instabox: version check failed for "${cachedBox}", using cached tools: ${fetched.error}`);
|
|
605
|
+
registerDesktopTools(template.tools, cachedBox);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const listed = await boxLister.list({ force: true });
|
|
610
|
+
const names = listed.ok ? namesOf(listed.boxes) : [];
|
|
611
|
+
const running = listed.ok ? listed.boxes.find((box) => box.state === RUNNING_STATE) : undefined;
|
|
612
|
+
const box = running?.name ?? names[0];
|
|
613
|
+
if (box === undefined) {
|
|
614
|
+
debug(
|
|
615
|
+
`instabox: no boxes available (${listed.ok ? "empty list" : (listed.error ?? "unknown error")})`,
|
|
616
|
+
);
|
|
617
|
+
notifyOnce("no-box", NO_BOX_MESSAGE);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// 连接即唤醒:有 running 的优先连它,否则第一台由池拉起(不传 onProgress,不阻塞会话)
|
|
621
|
+
const fetched = await templateStore.ensure(box, { allowWake: true });
|
|
622
|
+
if (!fetched.ok) {
|
|
623
|
+
notifySyncFailure(box, fetched.error);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
registerDesktopTools(fetched.template.tools, box);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* 冷启动的唯一重试入口(方案 §4.5 D5):唤醒 → 拉模板 → 注册。
|
|
631
|
+
* 箱子起不来但已有缓存时仍按缓存声明(新鲜度放弃),并说明原因。
|
|
632
|
+
*/
|
|
633
|
+
async function retryRegistration(box: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
634
|
+
const woken = await pool.get(box, {
|
|
635
|
+
signal: ctx.signal,
|
|
636
|
+
onProgress: (message) => ctx.ui.notify(message, "info"),
|
|
637
|
+
});
|
|
638
|
+
const fetched = woken.ok ? await templateStore.ensure(box) : undefined;
|
|
639
|
+
const template = fetched?.ok === true ? fetched.template : templateStore.get(box);
|
|
640
|
+
const reason = woken.ok ? (fetched?.ok === true ? undefined : fetched?.error) : woken.error;
|
|
641
|
+
if (template === undefined) {
|
|
642
|
+
ctx.ui.notify(
|
|
643
|
+
`instabox: 未能注册桌面工具(${reason ?? "unknown error"});修好箱子后再执行 /instabox-use ${box} 重试。`,
|
|
644
|
+
"warning",
|
|
645
|
+
);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const count = registerDesktopTools(template.tools, box);
|
|
649
|
+
if (reason === undefined) {
|
|
650
|
+
ctx.ui.notify(`instabox: registered ${count} desktop tool(s) for box "${box}"`, "info");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
ctx.ui.notify(`instabox: registered ${count} desktop tool(s) from cache; box "${box}" is not usable yet: ${reason}`, "warning");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// —— 加载期缓存命中:立刻声明工具(不跑 `ls`、不连远端;缓存是唯一来源) ——
|
|
657
|
+
const loadTarget = cachedTargetBox();
|
|
658
|
+
if (loadTarget !== undefined) {
|
|
659
|
+
const cached = templateStore.get(loadTarget);
|
|
660
|
+
if (cached !== undefined) registerDesktopTools(cached.tools, loadTarget);
|
|
661
|
+
}
|
|
662
|
+
logEvent("load.assembled", {
|
|
663
|
+
cacheHit: declaredFromBox !== undefined,
|
|
664
|
+
box: declaredFromBox,
|
|
665
|
+
tools: registeredTools.size,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
/** `/instabox-use`:空参数列目标 + 候选;无效名拒绝;有效名写 entry(冷启动再唤醒 + 注册)。 */
|
|
669
|
+
async function handleUse(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
670
|
+
const current = readTarget(branchOf(ctx));
|
|
671
|
+
const requested = args.trim();
|
|
672
|
+
if (requested === "") {
|
|
673
|
+
const listed = await boxLister.list();
|
|
674
|
+
const candidates = listed.ok ? namesOf(listed.boxes).join(", ") || "(none)" : `ls failed: ${listed.error ?? "unknown error"}`;
|
|
675
|
+
ctx.ui.notify(`instabox target: ${current ?? "not set"}\ncandidates: ${candidates}`, "info");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const listed = await boxLister.list();
|
|
679
|
+
if (!listed.ok) {
|
|
680
|
+
ctx.ui.notify(`instabox: cannot list boxes: ${listed.error ?? "unknown error"}`, "warning");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (!listed.boxes.some((box) => box.name === requested)) {
|
|
684
|
+
const candidates = namesOf(listed.boxes).join(", ") || "(none)";
|
|
685
|
+
ctx.ui.notify(
|
|
686
|
+
`instabox: unknown box "${requested}"; available boxes: ${candidates}; target unchanged (${current ?? "not set"})`,
|
|
687
|
+
"warning",
|
|
688
|
+
);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
pi.appendEntry(TARGET_ENTRY, targetEntryData(requested));
|
|
692
|
+
ctx.ui.notify(`instabox target: ${requested}`, "info");
|
|
693
|
+
// 已有工具时只写 entry(缓存命中即可),冷启动才走唤醒 + 拉取 + 注册
|
|
694
|
+
if (registeredTools.size > 0) return;
|
|
695
|
+
await retryRegistration(requested, ctx);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** `/instabox-status`:一次性打印配置 / 目标 / 工具 / codemode / MCP server。 */
|
|
699
|
+
async function handleStatus(ctx: ExtensionCommandContext): Promise<void> {
|
|
700
|
+
const lines: string[] = [];
|
|
701
|
+
lines.push(
|
|
702
|
+
`config: defaultBox=${cfg.defaultBox || "(unset)"} devToolsBox=${cfg.devToolsBox || "(unset)"} autoInstallDevTools=${String(cfg.autoInstallDevTools)} remotePath=${cfg.remotePath}`,
|
|
703
|
+
);
|
|
704
|
+
lines.push(`session target: ${readTarget(branchOf(ctx)) ?? "(not set)"}`);
|
|
705
|
+
try {
|
|
706
|
+
const resolved = await resolveTarget(undefined, ctx);
|
|
707
|
+
lines.push(
|
|
708
|
+
`resolved target: ${resolved.ok ? `${resolved.box} (via ${resolved.source})` : `${resolved.kind}: ${resolved.message}`}`,
|
|
709
|
+
);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
lines.push(`resolved target: unavailable (${messageOf(error)})`);
|
|
712
|
+
}
|
|
713
|
+
const tools = [...registeredTools.entries()]
|
|
714
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
715
|
+
.map(([name, exposure]) => `${name} (${exposure})`);
|
|
716
|
+
lines.push(`tools: ${tools.join(", ") || "(none)"}`);
|
|
717
|
+
lines.push(
|
|
718
|
+
declaredFromBox === undefined
|
|
719
|
+
? "tool declarations: (none registered yet)"
|
|
720
|
+
: `tool declarations: from the cached template of box "${declaredFromBox}" (the per-call target is resolved at call time)`,
|
|
721
|
+
);
|
|
722
|
+
lines.push(`active tools: ${pi.getActiveTools().join(", ") || "(none)"}`);
|
|
723
|
+
const state = codemodeState();
|
|
724
|
+
const label = state === "active" && hasCodemodeExposureServer() ? "active (auto via MCP)" : CODEMODE_LABELS[state];
|
|
725
|
+
lines.push(`codemode: ${label}`);
|
|
726
|
+
if (typeof pi.getMcpServers === "function") {
|
|
727
|
+
const servers = pi.getMcpServers().map((server) => server.name);
|
|
728
|
+
lines.push(`mcp servers: ${servers.join(", ") || "(none)"}`);
|
|
729
|
+
}
|
|
730
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// —— 命令:设置/查看目标箱 ——
|
|
734
|
+
pi.registerCommand("instabox-use", {
|
|
735
|
+
description: "Set (or show) the instabox target box for this session",
|
|
736
|
+
handler: async (args, ctx) => {
|
|
737
|
+
sessionCtx = ctx;
|
|
738
|
+
await safely(ctx, "/instabox-use", () => handleUse(args, ctx));
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// —— 命令:诊断 ——
|
|
743
|
+
pi.registerCommand("instabox-status", {
|
|
744
|
+
description: "Show instabox config, resolved target, registered tools, codemode state and MCP servers",
|
|
745
|
+
handler: async (_args, ctx) => {
|
|
746
|
+
sessionCtx = ctx;
|
|
747
|
+
await safely(ctx, "/instabox-status", () => handleStatus(ctx));
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// —— 目标 entry 的转录渲染(R5:返回 Component,`Text` 只收到着色字符串) ——
|
|
752
|
+
pi.registerEntryRenderer(TARGET_ENTRY, createTargetRendererWithHostText());
|
|
753
|
+
|
|
754
|
+
// —— DevTools MCP:**默认关闭**,只有 `--instabox-devtools`(或 `/instabox-devtools on`)
|
|
755
|
+
// 能开启;box 的来源优先级是会话绑定 > `--instabox-devtools-box` > `config.devToolsBox` ——
|
|
756
|
+
/** 本会话已注册的 DevTools MCP(`undefined` = 未注册);`source` 只在 `devtools.enabled` 与 `status` 里回显 */
|
|
757
|
+
let registeredDevTools: { box: string; name: string; source: DevtoolsRegistrationSource } | undefined;
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 开关 flag 的当前值(**只在装配完成后读**)。
|
|
761
|
+
*
|
|
762
|
+
* pi 在扩展加载结束之后才把命令行里的 flag 值写进 `runtime.flagValues`
|
|
763
|
+
* (`applyExtensionFlagValues` 在 `resourceLoader.reload()` 之后跑),加载期读到的永远只是
|
|
764
|
+
* 注册时的默认值 `false`。所以判定放在 `session_start` 与命令里,不要挤回加载期。
|
|
765
|
+
*/
|
|
766
|
+
function devToolsSwitchOn(): boolean {
|
|
767
|
+
return pi.getFlag(DEVTOOLS_FLAG) === true;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** `--instabox-devtools-box`:空串=没给(两侧空白裁掉,与 box 名匹配口径一致) */
|
|
771
|
+
function devToolsFlagBox(): string {
|
|
772
|
+
const value = pi.getFlag(DEVTOOLS_BOX_FLAG);
|
|
773
|
+
return typeof value === "string" ? value.trim() : "";
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* box 的解析结果:箱名 + 它从哪个来源来。三个来源都没给(或会话绑定已被 `reset` 清掉)时
|
|
778
|
+
* 返回 `undefined`。
|
|
779
|
+
*
|
|
780
|
+
* 会话绑定必须实时读分支:`on <box>` 写的是 entry,`/tree` 换分支后绑定会跟着变,
|
|
781
|
+
* 缓存它会让「切分支后回退到 flag/config」这条承诺失效。
|
|
782
|
+
*/
|
|
783
|
+
function devToolsBoxChoice(ctx?: ExtensionContext): { box: string; source: DevtoolsBoxSource } | undefined {
|
|
784
|
+
const sessionBox = readDevtoolsTarget(branchOf(ctx));
|
|
785
|
+
if (sessionBox !== undefined) return { box: sessionBox, source: "session" };
|
|
786
|
+
const fromFlag = devToolsFlagBox();
|
|
787
|
+
if (fromFlag !== "") return { box: fromFlag, source: "flag" };
|
|
788
|
+
if (cfg.devToolsBox !== "") return { box: cfg.devToolsBox, source: "config" };
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** `ls` 的候选箱名(失败直说,不假装「没有箱子」) */
|
|
793
|
+
async function devToolsCandidates(): Promise<string> {
|
|
794
|
+
const listed = await boxLister.list();
|
|
795
|
+
return listed.ok
|
|
796
|
+
? namesOf(listed.boxes).join(", ") || "(none)"
|
|
797
|
+
: `ls failed: ${listed.error ?? "unknown error"}`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* 写 helper + 注册(替换)MCP server:启动路径与 `/instabox-devtools on` 共用。
|
|
802
|
+
*
|
|
803
|
+
* `source` 记录本次注册的 box 是从哪来的:启动路径取实际生效的来源(session / flag / config),
|
|
804
|
+
* 命令路径一律 `command`(即使最终 box 由 flag/config 提供)。
|
|
805
|
+
* helper 写盘失败只 warn(远端可能早就装好了 `chrome-devtools-mcp`,不该连带放弃注册);
|
|
806
|
+
* 已注册时先 `unregister` 再注册,换 box 不会留下旧连接。
|
|
807
|
+
* 返回是否注册成功(失败已经提示过,调用方不必再报一次)。
|
|
808
|
+
*/
|
|
809
|
+
function registerDevTools(box: string, source: DevtoolsRegistrationSource): boolean {
|
|
810
|
+
const helper = ensureHelperScript({
|
|
811
|
+
remotePath: cfg.remotePath,
|
|
812
|
+
autoInstall: cfg.autoInstallDevTools,
|
|
813
|
+
paths,
|
|
814
|
+
});
|
|
815
|
+
if (!helper.ok) {
|
|
816
|
+
notifyOnce(
|
|
817
|
+
`devtools-helper:${box}`,
|
|
818
|
+
`instabox: 无法写入 DevTools helper(${helper.error ?? "unknown error"});远端 DevTools MCP 可能起不来。`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const registration = buildMcpServerConfig(cfg, paths, box);
|
|
822
|
+
let ok = true;
|
|
823
|
+
try {
|
|
824
|
+
if (registeredDevTools !== undefined) pi.unregisterMcpServer(registeredDevTools.name);
|
|
825
|
+
pi.registerMcpServer(registration.name, registration.config);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
ok = false;
|
|
828
|
+
notifyOnce(
|
|
829
|
+
`devtools-register:${box}`,
|
|
830
|
+
`instabox: 远端 DevTools MCP 注册失败(${messageOf(error)});桌面工具不受影响。`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
logEvent("devtools.register", { box, ok });
|
|
834
|
+
if (!ok) {
|
|
835
|
+
registeredDevTools = undefined;
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
registeredDevTools = { box, name: registration.name, source };
|
|
839
|
+
logEvent("devtools.enabled", { box, source });
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* `session_start` 后台体里的启动判定:开关关着就什么都不做(只给了 box flag 时提示一次);
|
|
845
|
+
* 开关开着就按「会话绑定 > flag > config」取 box,缺失 / 不在 `ls` 里则 warning + 不注册
|
|
846
|
+
* (**不静默换机**)。会话恢复后曾 `on` 过的 box 因此能继续生效。
|
|
847
|
+
*/
|
|
848
|
+
async function syncDevToolsOnStartup(ctx: ExtensionContext): Promise<void> {
|
|
849
|
+
if (!devToolsSwitchOn()) {
|
|
850
|
+
if (devToolsFlagBox() !== "") notifyOnce("devtools-switch-missing", DEVTOOLS_SWITCH_MISSING_MESSAGE);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
// 本会话已注册(命令开过、或更早一次 session_start):不重复写盘,也不打扰用户
|
|
854
|
+
if (registeredDevTools !== undefined) return;
|
|
855
|
+
const choice = devToolsBoxChoice(ctx);
|
|
856
|
+
if (choice === undefined) {
|
|
857
|
+
notifyOnce("devtools-no-box", DEVTOOLS_NO_BOX_MESSAGE);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const box = choice.box;
|
|
861
|
+
const listed = await boxLister.list();
|
|
862
|
+
if (!listed.ok) {
|
|
863
|
+
notifyOnce(
|
|
864
|
+
"devtools-ls-failed",
|
|
865
|
+
`instabox: 无法校验 devtools box "${box}"(${listed.error ?? "unknown error"});DevTools MCP 未注册`,
|
|
866
|
+
);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (!listed.boxes.some((candidate) => candidate.name === box)) {
|
|
870
|
+
const candidates = namesOf(listed.boxes).join(", ") || "(none)";
|
|
871
|
+
notifyOnce(
|
|
872
|
+
`devtools-unknown-box:${box}`,
|
|
873
|
+
`instabox: devtools box "${box}" 不在 instabox ls 里;DevTools MCP 未注册(候选:${candidates})`,
|
|
874
|
+
);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
registerDevTools(box, choice.source);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* `/instabox-devtools [status|on [box]|off|reset]`:查看 / 开启(替换)/ 关闭远端 DevTools MCP /
|
|
882
|
+
* 清除会话绑定的 box。
|
|
883
|
+
*/
|
|
884
|
+
async function handleDevTools(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
885
|
+
const [subcommand = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
886
|
+
const action = subcommand === "" ? "status" : subcommand.toLowerCase();
|
|
887
|
+
if (action === "status") {
|
|
888
|
+
// 三段分开报:已注册的 server(`reset` 不动它)、会话绑定(`reset` 清的就是它)、
|
|
889
|
+
// 以及下一次 `on` / 启动会用的解析结果 —— 合成一行会让 `reset` 看起来没生效。
|
|
890
|
+
const sessionBox = readDevtoolsTarget(branchOf(ctx));
|
|
891
|
+
const effective = devToolsBoxChoice(ctx);
|
|
892
|
+
ctx.ui.notify(
|
|
893
|
+
[
|
|
894
|
+
`instabox-devtools: ${devToolsSwitchOn() ? "on" : "off"} (--${DEVTOOLS_FLAG})`,
|
|
895
|
+
`registration: ${
|
|
896
|
+
registeredDevTools === undefined
|
|
897
|
+
? "(none)"
|
|
898
|
+
: `${registeredDevTools.box} (via ${registeredDevTools.source})`
|
|
899
|
+
}`,
|
|
900
|
+
`session binding: ${sessionBox ?? "(unset)"}`,
|
|
901
|
+
`effective box: ${effective === undefined ? "(unset)" : `${effective.box} (${effective.source})`}`,
|
|
902
|
+
`candidates: ${await devToolsCandidates()}`,
|
|
903
|
+
].join("\n"),
|
|
904
|
+
"info",
|
|
905
|
+
);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (action === "off") {
|
|
909
|
+
if (registeredDevTools === undefined) {
|
|
910
|
+
ctx.ui.notify("instabox: DevTools MCP 未注册,无操作", "info");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const { box, name } = registeredDevTools;
|
|
914
|
+
pi.unregisterMcpServer(name);
|
|
915
|
+
registeredDevTools = undefined;
|
|
916
|
+
logEvent("devtools.disabled", {});
|
|
917
|
+
ctx.ui.notify(`instabox: DevTools MCP 已关闭(原 box "${box}")`, "info");
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (action === "reset") {
|
|
921
|
+
// 只写一条清除标记:开关与已注册的 server 都不动(`off` 才是注销入口)
|
|
922
|
+
pi.appendEntry(DEVTOOLS_TARGET_ENTRY, devtoolsTargetEntryData(""));
|
|
923
|
+
logEvent("devtools.reset", {});
|
|
924
|
+
ctx.ui.notify(DEVTOOLS_RESET_MESSAGE, "info");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (action === "on") {
|
|
928
|
+
const requested = rest.join(" ").trim();
|
|
929
|
+
// 给了 box:解析目标就是它,并写成会话绑定(下次启动优先用它);
|
|
930
|
+
// 没给:按 会话绑定 → flag → config 解析,三者都没有才报缺失
|
|
931
|
+
const choice = requested !== "" ? { box: requested, source: "command" as const } : devToolsBoxChoice(ctx);
|
|
932
|
+
if (choice === undefined) {
|
|
933
|
+
ctx.ui.notify(DEVTOOLS_NO_BOX_MESSAGE, "warning");
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const box = choice.box;
|
|
937
|
+
const listed = await boxLister.list();
|
|
938
|
+
if (!listed.ok) {
|
|
939
|
+
ctx.ui.notify(`instabox: cannot list boxes: ${listed.error ?? "unknown error"}`, "warning");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (!listed.boxes.some((candidate) => candidate.name === box)) {
|
|
943
|
+
const candidates = namesOf(listed.boxes).join(", ") || "(none)";
|
|
944
|
+
ctx.ui.notify(
|
|
945
|
+
`instabox: unknown box "${box}"; available boxes: ${candidates}; devtools 状态未变`,
|
|
946
|
+
"warning",
|
|
947
|
+
);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (requested !== "") pi.appendEntry(DEVTOOLS_TARGET_ENTRY, devtoolsTargetEntryData(box));
|
|
951
|
+
if (!registerDevTools(box, "command")) return; // 失败原因已提示
|
|
952
|
+
ctx.ui.notify(`instabox: DevTools MCP registered for box "${box}"`, "info");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
ctx.ui.notify(`instabox: 未知参数 "${subcommand}";${DEVTOOLS_USAGE}`, "warning");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// —— 命令:动态开关(flag 之外的运行期入口;`off` 只关本会话的注册,`reset` 只清会话绑定) ——
|
|
959
|
+
pi.registerCommand("instabox-devtools", {
|
|
960
|
+
description: "Show, enable (on [box]), disable (off) or reset the session box of the remote DevTools MCP server",
|
|
961
|
+
handler: async (args, ctx) => {
|
|
962
|
+
sessionCtx = ctx;
|
|
963
|
+
await safely(ctx, "/instabox-devtools", () => handleDevTools(args, ctx));
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* `session_start` 的后台任务:DevTools flag 判定 + 拉模板(连接即唤醒)+ 注册 + codemode 只读判定。
|
|
969
|
+
* 所有 UI 提示都在这里发,异常只 debug(`safely` 再兜一层 warning)。
|
|
970
|
+
*
|
|
971
|
+
* 注意:async 函数体在**第一次 await 之前**是同步执行的,`TemplateStore` / `readCache` 的
|
|
972
|
+
* 同步磁盘读正好落在那里。所以调用方必须先让出一个宏任务({@link startBackgroundSessionStart}),
|
|
973
|
+
* 否则这些读会发生在事件处理器的同步帧里。
|
|
974
|
+
*/
|
|
975
|
+
async function backgroundSessionStart(ctx: ExtensionContext): Promise<void> {
|
|
976
|
+
await safely(ctx, "session_start", async () => {
|
|
977
|
+
await syncDevToolsOnStartup(ctx);
|
|
978
|
+
await refreshTemplateInBackground(ctx);
|
|
979
|
+
notifyCodemodeOnce();
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 把后台任务挪到下一个宏任务:`session_start` 的同步段因此只剩内存赋值与通知 drain。
|
|
985
|
+
* `unref` 让这个定时器不拖住进程退出(与 pi-todo 的清理定时器同款)。
|
|
986
|
+
*/
|
|
987
|
+
function startBackgroundSessionStart(ctx: ExtensionContext): void {
|
|
988
|
+
const timer = setTimeout(() => {
|
|
989
|
+
void backgroundSessionStart(ctx).catch((error: unknown) => {
|
|
990
|
+
debug(`instabox: session_start background task failed: ${messageOf(error)}`);
|
|
991
|
+
logEvent("error", { where: "session_start background", message: messageOf(error) }, "warn");
|
|
992
|
+
});
|
|
993
|
+
}, 0);
|
|
994
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// —— 事件:会话开始 ——
|
|
998
|
+
/**
|
|
999
|
+
* 本会话的启动:同步段只有内存赋值 + 通知 drain,远端与磁盘工作都在宏任务之后。
|
|
1000
|
+
* 正常路径由 `session_start` 调用;「加载期探测超时 → 会话开始后重试成功」的那条路径要在
|
|
1001
|
+
* **当前**会话里补跑一次(那次 `session_start` 已经派发过了),所以这里必须单独成函数。
|
|
1002
|
+
*/
|
|
1003
|
+
let startedCtx: ExtensionContext | undefined;
|
|
1004
|
+
function startSession(ctx: ExtensionContext, source?: SessionStartEvent["reason"]): void {
|
|
1005
|
+
// 同一会话只启动一次:重试路径显式补跑时,若 pi 把刚注册的处理器也算进同一轮派发,不会跑两遍
|
|
1006
|
+
if (startedCtx === ctx) return;
|
|
1007
|
+
startedCtx = ctx;
|
|
1008
|
+
sessionCtx = ctx;
|
|
1009
|
+
notified.clear();
|
|
1010
|
+
flushPendingNotifications(ctx);
|
|
1011
|
+
// `source` = `session_start` 的 reason(startup / reload / new / resume / fork);
|
|
1012
|
+
// `target` 是本会话已有的目标 entry(未设则不记),与解析链结果无关。
|
|
1013
|
+
logEvent("session.start", { target: readTarget(branchOf(ctx)), source, mode: ctx.mode });
|
|
1014
|
+
// 不 await、也不在这里调用 async 体:pi 会 await 事件处理器,等在这里(甚至只是让 async
|
|
1015
|
+
// 函数的同步前奏跑起来)就等于把 ssh/握手/读盘算进会话启动(方案 §4.4)
|
|
1016
|
+
startBackgroundSessionStart(ctx);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
pi.on("session_start", (event, ctx) => startSession(ctx, event.reason));
|
|
1020
|
+
|
|
1021
|
+
// —— 事件:`/tree` 换分支后换掉 ctx(目标状态不缓存,解析链每次调用实时读分支) ——
|
|
1022
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
1023
|
+
sessionCtx = ctx;
|
|
1024
|
+
const target = readTarget(branchOf(ctx));
|
|
1025
|
+
logEvent("session.tree", { target }, "debug");
|
|
1026
|
+
debug(`instabox: session_tree → target=${target ?? "(not set)"}`);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// —— 事件:会话结束(换会话 / `/reload` / 退出)→ 收掉本实例的远端连接,避免残留 ssh ——
|
|
1030
|
+
pi.on("session_shutdown", async () => {
|
|
1031
|
+
const open = pool.size();
|
|
1032
|
+
pool.disposeAll();
|
|
1033
|
+
// 在飞的建连由池自己按代数校验丢弃(`dispose` 会把 clients 与 inflight 两侧都驱逐),
|
|
1034
|
+
// 这里只把已失效的 ctx 放掉:之后的 `notifyOnce` 会走排队,等下一个会话 drain。
|
|
1035
|
+
sessionCtx = undefined;
|
|
1036
|
+
logEvent("session.shutdown", { open });
|
|
1037
|
+
// 本实例已收尾:复位日志单例,避免进程内遗留(下一个会话重新装配时再建)
|
|
1038
|
+
setLogger(undefined);
|
|
1039
|
+
// 只在真的收过连接时记一条(没有连接时 shutdown 不该产生任何日志噪音)
|
|
1040
|
+
if (open > 0) debug(`instabox: session_shutdown → disposed ${open} connection(s)`);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
return { startSession };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* 加载期探测超时的会话开始重试:**超时 ≠ 没有 CLI**。
|
|
1048
|
+
*
|
|
1049
|
+
* `instabox version` 在本机实测 803–1178ms,而加载期预算(`cli.ts` 默认 3000ms,早期 1200ms)
|
|
1050
|
+
* 在冷启动慢时会被击杀;直接自禁用会让相当比例(实测 5 次里 2 次)的冷启动误判「无 CLI」。
|
|
1051
|
+
* 因此在**第一次** `session_start` 里用更长预算({@link RETRY_PROBE_TIMEOUT_MS})再探一次:
|
|
1052
|
+
* 成功 → 立刻装配并在本会话补跑后台任务;仍失败 → 才按「无 CLI」禁用。只重试一次。
|
|
1053
|
+
*/
|
|
1054
|
+
async function retryProbe(ctx: ExtensionContext, source?: SessionStartEvent["reason"]): Promise<void> {
|
|
1055
|
+
const retried = await probeVersion(exec, { timeout: RETRY_PROBE_TIMEOUT_MS });
|
|
1056
|
+
if (!retried.ok) {
|
|
1057
|
+
disable(ctx, NO_CLI_MESSAGE, `CLI retry probe failed: ${retried.error}`);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
// 装配成功之后才算「可用」:`assemble()` 抛错时若已置 `assembled`,后续会话就再也不会
|
|
1061
|
+
// 重试,而且会把「初始化失败」误报成「未找到 instabox CLI」
|
|
1062
|
+
let session: { startSession: (ctx: ExtensionContext, source?: SessionStartEvent["reason"]) => void };
|
|
1063
|
+
try {
|
|
1064
|
+
session = assemble();
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
disable(
|
|
1067
|
+
ctx,
|
|
1068
|
+
`instabox 扩展初始化失败:${messageOf(error)}`,
|
|
1069
|
+
`assembly after a successful retry probe failed: ${messageOf(error)}`,
|
|
1070
|
+
);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
state = "assembled";
|
|
1074
|
+
try {
|
|
1075
|
+
// 本次会话已经派发过 session_start(处理器还没注册)→ 手动补跑后台拉取与 codemode 判定
|
|
1076
|
+
session.startSession(ctx, source);
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
// 装配已完成,只是本次补跑失败:只记日志,不改变状态(下一个会话正常启动)
|
|
1079
|
+
debug(`instabox: startSession after retry failed: ${messageOf(error)}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* 发一条禁用/失败提示,**绝不抛**。
|
|
1085
|
+
*
|
|
1086
|
+
* 这些调用点都在 fire-and-forget 的异步链里(`session_start` 重试、`retryProbe` 的 catch),
|
|
1087
|
+
* 并且可能在 ctx 失效(换会话 / `/reload` 之后的旧 ctx)以后才执行;`ui.notify` 抛一次就会
|
|
1088
|
+
* 变成进程级 unhandled rejection,所以统一从这里出去,异常只记日志。
|
|
1089
|
+
*/
|
|
1090
|
+
function notifyQuietly(ctx: ExtensionContext, message: string, type: "info" | "warning" = "warning"): void {
|
|
1091
|
+
try {
|
|
1092
|
+
ctx.ui.notify(message, type);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
debug(`instabox: ui.notify failed: ${messageOf(error)}`);
|
|
1095
|
+
logEvent("error", { where: "ui.notify", message: messageOf(error) }, "warn");
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/** 重试状态机:`pending` 只出现一次(一次性标记),之后不再探测。 */
|
|
1100
|
+
let state: "pending" | "probing" | "assembled" | "disabled" = "pending";
|
|
1101
|
+
/** 禁用时对用户说的那句话:装配失败要换成准确原因,不能一律说「未找到 CLI」。 */
|
|
1102
|
+
let disabledMessage = NO_CLI_MESSAGE;
|
|
1103
|
+
|
|
1104
|
+
/** 一次性关掉这条路:记下原因 / 状态,只记日志,给一条绝不抛的提示。 */
|
|
1105
|
+
function disable(ctx: ExtensionContext, message: string, detail: string): void {
|
|
1106
|
+
state = "disabled";
|
|
1107
|
+
disabledMessage = message;
|
|
1108
|
+
debug(`instabox: ${detail}`);
|
|
1109
|
+
notifyQuietly(ctx, message);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// —— 加载期:探测 CLI(默认 3000ms);没有就零注册、不建运行期目录(日志已接上,只写日志) ——
|
|
1113
|
+
const probe = await probeVersion(exec);
|
|
1114
|
+
if (probe.ok) {
|
|
1115
|
+
assemble();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (!probeTimedOut(probe)) {
|
|
1119
|
+
debug(`instabox CLI probe failed: ${probe.error}`);
|
|
1120
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1121
|
+
notifyQuietly(ctx, NO_CLI_MESSAGE);
|
|
1122
|
+
});
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
// 只是被超时击杀:本次零注册,等第一次 session_start 用更长预算重试
|
|
1126
|
+
debug(`instabox CLI probe timed out; retrying on session_start with ${RETRY_PROBE_TIMEOUT_MS}ms: ${probe.error}`);
|
|
1127
|
+
pi.on("session_start", (event, ctx) => {
|
|
1128
|
+
if (state === "assembled") return;
|
|
1129
|
+
if (state === "disabled") {
|
|
1130
|
+
notifyQuietly(ctx, disabledMessage);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (state === "probing") return;
|
|
1134
|
+
state = "probing";
|
|
1135
|
+
// 不 await:5000ms 的探测不能算进会话启动(与 `session_start` 其余部分同一口径)
|
|
1136
|
+
void retryProbe(ctx, event.reason).catch((error: unknown) => {
|
|
1137
|
+
disable(ctx, `instabox 扩展初始化失败:${messageOf(error)}`, `retry probe failed: ${messageOf(error)}`);
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
}
|