dsh-tabbit 0.2.3 → 0.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +116 -0
  4. package/README.md +55 -81
  5. package/client/client.js +389 -0
  6. package/cordis.patch.yml +76 -5
  7. package/lib/core/index.js +742 -0
  8. package/lib/installer/detect.js +374 -0
  9. package/lib/installer/download.js +247 -0
  10. package/lib/installer/index.js +254 -0
  11. package/lib/mentions/index.js +595 -0
  12. package/lib/permissions/index.js +136 -0
  13. package/lib/runtime/cli.js +229 -0
  14. package/lib/runtime/client.js +431 -0
  15. package/lib/runtime/codec.js +126 -0
  16. package/lib/runtime/endpoint.js +248 -0
  17. package/lib/runtime/errors.js +126 -0
  18. package/lib/runtime/instances.js +287 -0
  19. package/lib/runtime/net.js +143 -0
  20. package/lib/runtime/peer.js +132 -0
  21. package/lib/tool-browser/index.js +425 -0
  22. package/lib/update-check.js +343 -0
  23. package/lib/web-fetch/index.js +219 -0
  24. package/package.json +53 -16
  25. package/skills/tabbit/SKILL.md +66 -0
  26. package/skills/tabbit/references/interaction-helpers.md +150 -0
  27. package/skills/tabbit/references/platform-invocation.md +174 -0
  28. package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
  29. package/skills/tabbit/references/runtime-recovery.md +104 -0
  30. package/README.zh-CN.md +0 -114
  31. package/index.js +0 -352
  32. package/installer.js +0 -568
  33. package/skills/tabbit-browser/SKILL.md +0 -274
  34. package/skills/tabbit-browser/agents/openai.yaml +0 -4
  35. package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
  36. package/skills/tabbit-browser/references/platform-invocation.md +0 -45
  37. package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
  38. package/update-check.js +0 -177
  39. /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
@@ -0,0 +1,742 @@
1
+ /*
2
+ * ============================================================================
3
+ * 文件职责:核心模块——`ctx.tabbit` 服务 + 各种 dsh 宿主集成
4
+ * ============================================================================
5
+ *
6
+ * 这是补丁行 `tabbit-core`(行名 = 裸包名 `dsh-tabbit`)加载的模块,
7
+ * 是其它五个功能模块的共同地基。它做的事:
8
+ * 1. 定义并提供 `ctx.tabbit` 服务(TabbitService 类):settings 读取、
9
+ * TabbitClient 缓存、实例四级解析、会话任务登记、权限授权记忆、清理;
10
+ * 2. 注册 settings 命名空间 `tabbit`(instance/launcherPath/pageAccess/intranetFetch);
11
+ * 3. 注册随包 skill `tabbit`(教模型怎么用工具的文档;与浏览器写入
12
+ * ~/.agents/skills/tabbit 的共享 skill 同名——刻意的,见 SKILL_NAME 注释);
13
+ * 4. 注册 `/tabbit-info` 诊断命令(不叫 /tabbit:userInvocable skill 的
14
+ * `/名字` 调用入口会与之撞名);
15
+ * 5. 往系统提示词里注入一小段"你有浏览器能力"的说明;
16
+ * 6. 清理历史版本安装的"Tabbit 模式" agent preset(迁移,见 removeManagedPreset)。
17
+ *
18
+ * ─── 必备背景 1:dsh 与 Cordis 插件模型 ─────────────────────────────────
19
+ *
20
+ * DeepSeek Harness(dsh)构建在 Cordis 框架上。Cordis 的世界观:
21
+ *
22
+ * - 【插件】= 一个导出 { name, inject, apply(ctx) } 的模块。
23
+ * · name:插件名(日志、调试用);
24
+ * · inject:声明依赖哪些【服务】——只有这些服务全就绪,apply 才被调用;
25
+ * · apply(ctx):插件的全部逻辑入口,ctx 是上下文对象。
26
+ *
27
+ * - 【服务】= 挂在 ctx 上的具名能力。`ctx.provide('tabbit', 实例)` 发布服务;
28
+ * 其它插件在 inject 里写 'tabbit' 后就能用 `ctx.tabbit` 拿到实例。
29
+ * dsh 自身的核心能力也都是服务:ctx.settings(设置)、ctx.tools(工具注册)、
30
+ * ctx.web(web 能力)、ctx.skills、ctx.commands、ctx.jobs(后台任务)、
31
+ * ctx.approval(用户审批)、ctx.webServer(HTTP 路由)等。
32
+ *
33
+ * - 【ctx.inject(名单, 回调)】= "软依赖":不阻塞本插件加载,等名单里的服务
34
+ * 可用时再执行回调(服务卸载时回调注册的东西也随之回收)。本文件用它挂
35
+ * skills/commands/systemPrompt——这些服务不在时核心功能照常工作。
36
+ *
37
+ * - 【ctx.effect(fn, 说明)】= 注册"副作用":fn 返回一个清理函数,插件被
38
+ * 卸载(disposal)时框架自动调用清理函数。这是 Cordis 的资源生命周期
39
+ * 管理方式(类似 React useEffect)。
40
+ *
41
+ * - 【ctx.on(事件名, 处理器)】= 订阅事件总线。本文件订阅 dsh 的
42
+ * `agent/disposed`(一个会话的 agent 被销毁)来触发浏览器任务清理。
43
+ *
44
+ * ─── 必备背景 2:bundle 机制与"为什么行名必须是裸包名" ────────────────
45
+ *
46
+ * dsh 的【bundle】= 一个 npm 包声明 package.json 的 `dsh.bundle.patch` 指向
47
+ * 一份 cordis.patch.yml。dsh 装载 profile 时把补丁叠进插件组合表,按每行的
48
+ * `name`(模块说明符)import 对应模块。行名可以用子路径导出
49
+ * (dsh-tabbit/permissions 等),但【dsh-web 的客户端模块扫描只认裸包名行】:
50
+ * 它靠裸名行找到包的 package.json,读其中 `dsh.client` 字段来发现并服务
51
+ * 前端插件(client/client.js,即 @tab 提及)。所以本 core 模块的行名必须是
52
+ * `dsh-tabbit` 本身,不能写成 `dsh-tabbit/core`。
53
+ */
54
+ import { existsSync } from 'node:fs';
55
+ import { readFile, rm } from 'node:fs/promises';
56
+ import { homedir } from 'node:os';
57
+ import { join } from 'node:path';
58
+ import { fileURLToPath } from 'node:url';
59
+ // schemastery:dsh 全家桶用的运行时 schema 校验库(惯例以 z 引入,用法类似 zod)。
60
+ import z from '@deepseek-ai/schemastery';
61
+ import { TabbitClient } from '../runtime/client.js';
62
+ import { listAllTabs } from '../runtime/endpoint.js';
63
+ import { TabbitCliError } from '../runtime/errors.js';
64
+ import { defaultLauncherPath, listInstances } from '../runtime/instances.js';
65
+ import { prependUpdateNotice } from '../update-check.js';
66
+ /* 上面接口对应的 schemastery 运行时校验模式(dsh settings 服务要求提供,用于校验与默认值)。 */
67
+ export const SETTINGS_SCHEMA = z.object({
68
+ instance: z.string().default(''),
69
+ launcherPath: z.string().default(''),
70
+ pageAccess: z.union([z.const('ask'), z.const('always'), z.const('never')]).default('ask'),
71
+ intranetFetch: z.union([z.const('ask'), z.const('always'), z.const('never')]).default('ask'),
72
+ });
73
+ /*
74
+ * web_fetch 共用任务的固定名字。注意:任务名同时是浏览器里标签组的可见标题
75
+ * (Runtime Service 没有独立标题字段),所以起成给用户看的样子。
76
+ */
77
+ export const FETCH_TASK_NAME = 'DeepSeek Harness · Web Fetch';
78
+ /*
79
+ * ============================================================================
80
+ * TabbitService —— 以 `ctx.tabbit` 名义提供给全家使用的核心服务
81
+ * ============================================================================
82
+ * 持有的全部状态都是【进程内内存】(不落盘):dsh 重启即清零,这正是
83
+ * "每会话询问一次"之类语义想要的。
84
+ */
85
+ export class TabbitService {
86
+ readSettings;
87
+ logger;
88
+ /* 缓存的 TabbitClient(按"实例id+launcher路径"作 key,配置变了就重建)。 */
89
+ cachedClient;
90
+ cachedKey = '';
91
+ /*
92
+ * 会话任务登记表:agentId → 任务名 → 该任务【实际在哪些实例上执行过】。
93
+ *
94
+ * 为什么要记实例集合而不是只记任务名?——修过的一个真实 bug:
95
+ * 会话结束清理时如果用"当下重新解析出来的实例"去 finish,而观看实例
96
+ * (viewer)在会话期间漂移过(用户换了个 Tabbit 窗口看 dsh),finish 就会
97
+ * 打到错误实例上、命中 "Unknown task name" 被静默吞掉——真正持有任务的
98
+ * 实例永远收不到 finish,标签组就一直挂在用户浏览器里。
99
+ * 所以每次求值成功都记下当时真正用的实例,清理时对【每个记过的实例】
100
+ * 分别 finish。
101
+ */
102
+ sessionTaskRegistry = new Map();
103
+ /* agentId → 该会话已定型的默认任务名(首次调用即定型,之后复用)。 */
104
+ defaultTaskNames = new Map();
105
+ /* 已通过"页面访问"授权的 agentId 集合(会话级记忆,成功一次不再问)。 */
106
+ pageAccessGrants = new Set();
107
+ /* 内网访问授权:agentId → 已授权的 origin 集合(会话+origin 级记忆)。 */
108
+ intranetGrants = new Map();
109
+ /* 共享 web_fetch 任务实际用过的实例集合(插件卸载时逐一 finish)。 */
110
+ fetchTaskInstances = new Set();
111
+ /* 最近一次检测到的"正在观看 dsh-web 的 Tabbit 实例"(含时间戳)。 */
112
+ viewerInstance;
113
+ // 构造参数是"读取 settings 的函数"而不是 settings 值本身——每次要用时
114
+ // 现读,天然支持热更新(用户改设置立即生效,无需重启)。
115
+ // logger 可选(apply 里接的是 dsh 的 ctx.logger):会透传给每个 TabbitClient,
116
+ // 让 finish 吞错、清理失败、隔离恢复这些原本静默的路径在 dsh 日志里留痕。
117
+ constructor(readSettings, logger) {
118
+ this.readSettings = readSettings;
119
+ this.logger = logger;
120
+ }
121
+ /* 记录当前观看 dsh web UI 的 Tabbit 实例(mentions 的 /tabbit/instance-hint 路由调用;后写覆盖先写)。 */
122
+ setViewerInstance(id) {
123
+ this.viewerInstance = { id, at: Date.now() };
124
+ }
125
+ viewer() {
126
+ return this.viewerInstance;
127
+ }
128
+ /*
129
+ * 【实例四级解析】——多实例机器上"该在哪个 Tabbit 里执行"的决策,按优先级:
130
+ *
131
+ * 1. settings 显式指定(tabbit.instance)——用户说了算;
132
+ * 2. 正在观看 dsh-web 的 Tabbit 实例(须仍在线)——"在哪看就在哪跑",
133
+ * 由 client 打点 + peer.ts 溯源检测(仅 macOS);
134
+ * 3. 继承的 TABBIT_PLAYWRIGHT_INSTANCE 环境变量——嵌入形态的权威通道
135
+ * (Tabbit 打包并启动自带 dsh 时,把自己的实例 id 注入环境变量);
136
+ * 4. auto:交给注册表自动选择(唯一在线实例就选它;否则由 runtime 客户端
137
+ * 抛出带实例清单的引导错误)。
138
+ */
139
+ resolveExecutionInstance() {
140
+ const settings = this.readSettings();
141
+ if (settings.instance !== '')
142
+ return { id: settings.instance, source: 'settings' };
143
+ const instances = listInstances();
144
+ const viewer = this.viewerInstance;
145
+ if (viewer !== undefined && instances.some((instance) => instance.id === viewer.id && instance.online)) {
146
+ return { id: viewer.id, source: 'dsh-web-viewer' };
147
+ }
148
+ const fromEnv = process.env.TABBIT_PLAYWRIGHT_INSTANCE;
149
+ if (fromEnv !== undefined &&
150
+ fromEnv !== '' &&
151
+ // 正常要求 env 指到的实例真在注册表里。Windows 注册表已能真实解析
152
+ // (%LOCALAPPDATA%\Tabbit\LocalAgent\instances\*.json,见 instances.ts),
153
+ // 但记录由浏览器的 host-integration 安装步骤写入,存在"浏览器装了、
154
+ // 记录还没写"的窗口——注册表为空时放行 env 值兜底:它是嵌入形态的
155
+ // 权威通道,真伪由原生 CLI 自己校验(选不中会报可解码的实例选择错误)。
156
+ (instances.some((instance) => instance.id === fromEnv) || (process.platform === 'win32' && instances.length === 0))) {
157
+ return { id: fromEnv, source: 'environment' };
158
+ }
159
+ return { source: 'auto' };
160
+ }
161
+ currentSettings() {
162
+ return this.readSettings();
163
+ }
164
+ /*
165
+ * 拿一个(缓存的)TabbitClient。key 里包含解析出的实例 id 和 launcher 路径,
166
+ * 任一变化(用户改设置、观看实例漂移)就重建客户端——保证永远用最新解析结果。
167
+ */
168
+ client() {
169
+ const settings = this.readSettings();
170
+ const resolved = this.resolveExecutionInstance();
171
+ const key = `${resolved.id ?? ''} ${settings.launcherPath}`;
172
+ if (!this.cachedClient || this.cachedKey !== key) {
173
+ this.cachedClient = new TabbitClient({
174
+ // 条件展开语法:值存在才把该字段放进对象(避免显式传 undefined)。
175
+ ...(resolved.id !== undefined ? { instanceId: resolved.id } : {}),
176
+ ...(settings.launcherPath ? { launcherPath: settings.launcherPath } : {}),
177
+ ...(this.logger ? { logger: this.logger } : {}),
178
+ });
179
+ this.cachedKey = key;
180
+ }
181
+ return this.cachedClient;
182
+ }
183
+ /*
184
+ * 钉死在指定实例上的客户端,绕过实时解析。
185
+ * 只用于清理路径:任务要在【它实际运行过的实例】上 finish,而不是清理时
186
+ * 恰好解析出来的"当前"实例(那个会漂移,见 sessionTaskRegistry 的注释)。
187
+ * instanceId 为 undefined(登记时实例都解析不出的极端情况)时退回尽力而为
188
+ * 的当前解析。
189
+ */
190
+ clientFor(instanceId) {
191
+ if (instanceId === undefined)
192
+ return this.client();
193
+ const settings = this.readSettings();
194
+ return new TabbitClient({
195
+ instanceId,
196
+ ...(settings.launcherPath ? { launcherPath: settings.launcherPath } : {}),
197
+ ...(this.logger ? { logger: this.logger } : {}),
198
+ });
199
+ }
200
+ /* 生效的 launcher 路径(settings 覆盖 > 默认位置)。 */
201
+ launcherPath() {
202
+ const settings = this.readSettings();
203
+ return settings.launcherPath || defaultLauncherPath();
204
+ }
205
+ instances() {
206
+ return listInstances();
207
+ }
208
+ /*
209
+ * 全 profile 标签页清单(含用户自己开的页面)——【不经模型、不经 CLI 子进程】
210
+ * 的直连读取(runtime/endpoint.ts,稳态 ~1ms),零副作用:不建任务、不开
211
+ * 页面、不出现在 tasks 列表。
212
+ *
213
+ * 实例定位与求值路径刻意不同:求值走 launcher(能自动拉起浏览器),清单是
214
+ * 被动读取——【绝不能因为一次列表查询把浏览器拉起来】(同 mentions 里
215
+ * roster 的既有原则),所以只对"已在线"的实例发起连接:
216
+ * - options.instanceId 显式指定:必须已注册,离线就如实报离线;
217
+ * - 未指定:沿用四级实例解析;解析结果为 auto 时选唯一在线实例,
218
+ * 0 个在线报离线、多个在线报歧义(带清单的引导错误)。
219
+ */
220
+ async listAllTabs(options = {}) {
221
+ const instances = listInstances();
222
+ const wanted = options.instanceId ?? this.resolveExecutionInstance().id;
223
+ let target;
224
+ if (wanted !== undefined) {
225
+ target = instances.find((instance) => instance.id === wanted);
226
+ if (!target) {
227
+ const roster = instances.map((instance) => `${instance.id} (${instance.appName})`).join(', ') || 'none';
228
+ throw new TabbitCliError({
229
+ kind: 'instance-selection',
230
+ code: 'INSTANCE_SELECTION',
231
+ message: `Tabbit instance ${wanted} is not registered. Available instances: ${roster}.`,
232
+ });
233
+ }
234
+ }
235
+ else {
236
+ const online = instances.filter((instance) => instance.online);
237
+ if (online.length === 1) {
238
+ target = online[0];
239
+ }
240
+ else if (online.length === 0) {
241
+ throw new TabbitCliError({
242
+ kind: 'browser-unavailable',
243
+ code: 'ENDPOINT_MISSING',
244
+ message: 'No Tabbit Browser instance is currently running.',
245
+ });
246
+ }
247
+ else {
248
+ const roster = online.map((instance) => `${instance.id} (${instance.appName})`).join(', ');
249
+ throw new TabbitCliError({
250
+ kind: 'instance-selection',
251
+ code: 'INSTANCE_SELECTION',
252
+ message: `Multiple Tabbit Browser instances are online; pick one of: ${roster}.`,
253
+ });
254
+ }
255
+ }
256
+ return await listAllTabs(target.endpointPath, {
257
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
258
+ });
259
+ }
260
+ /*
261
+ * 会话默认任务名——同时也是用户浏览器里那个标签组的【可见标题】
262
+ * (Runtime Service 没有独立标题字段,任务名即标题)。
263
+ *
264
+ * 规则:【首次调用即定型】。之后的调用永远返回同一个名字,并【无视】新传
265
+ * 的 label——因为换名字就等于换任务(浏览器状态、标签组全都是另一套了),
266
+ * 会话中途换名会把之前的浏览器状态"弄丢"。
267
+ *
268
+ * 名字格式:`<label>-dsh-<id4>`,无 label 时是 `dsh-<id4>`。
269
+ * - label:模型经 tabbit_browser 工具的 label 参数传入的人话描述
270
+ * (如 "GitHub trending research"),让用户在浏览器里能看懂这组标签
271
+ * 是干嘛的;
272
+ * - id4:agentId 去掉 "session-" 前缀后的前 4 个 hex 字符,是 dsh 会话 id
273
+ * 的真实片段。它有两个不可去掉的作用:① 把浏览器里可见的标签组关联回
274
+ * 具体 dsh 会话(/tabbit-info、CLI `tasks` 排障时对得上号);② 防止不同会话
275
+ * 撞出同一个任务名而共用一个任务。
276
+ */
277
+ defaultTaskFor(agentId, label) {
278
+ const existing = this.defaultTaskNames.get(agentId);
279
+ if (existing !== undefined)
280
+ return existing;
281
+ const shortId = agentId.replace(/^session-/u, '').slice(0, 4);
282
+ const cleanLabel = sanitizeLabel(label);
283
+ const name = cleanLabel !== '' ? `${cleanLabel}-dsh-${shortId}` : `dsh-${shortId}`;
284
+ this.defaultTaskNames.set(agentId, name);
285
+ return name;
286
+ }
287
+ /* 登记共享 fetch 任务在某实例上被用过(供 releaseAll 精准清理)。 */
288
+ markFetchTaskUsed(instanceId) {
289
+ this.fetchTaskInstances.add(instanceId);
290
+ }
291
+ /* 登记"某会话的某任务在某实例上执行过"(tool-browser 每次求值成功后调用)。 */
292
+ rememberSessionTask(agentId, taskName, instanceId) {
293
+ let tasks = this.sessionTaskRegistry.get(agentId);
294
+ if (!tasks) {
295
+ tasks = new Map();
296
+ this.sessionTaskRegistry.set(agentId, tasks);
297
+ }
298
+ let instances = tasks.get(taskName);
299
+ if (!instances) {
300
+ instances = new Set();
301
+ tasks.set(taskName, instances);
302
+ }
303
+ instances.add(instanceId);
304
+ }
305
+ /* 某会话名下登记过的任务名列表(mentions 的 @tab 候选用它圈定范围)。 */
306
+ sessionTasks(agentId) {
307
+ return [...(this.sessionTaskRegistry.get(agentId)?.keys() ?? [])];
308
+ }
309
+ /*
310
+ * 忘掉一个本会话已不再拥有的任务——模型在会话中途显式 finish 了它
311
+ * (tabbit_browser 的 finish 参数)。两件事:
312
+ * 1. 从清理登记表里删掉(会话结束时不再对它重复 finish);
313
+ * 2. 若它恰是本会话的默认任务:把默认名也删掉,让下一次不带 task 的调用
314
+ * 重新定名(可携带新 label)、开新任务——而不是复用一个已关闭的名字。
315
+ */
316
+ forgetTask(agentId, taskName) {
317
+ this.sessionTaskRegistry.get(agentId)?.delete(taskName);
318
+ if (this.defaultTaskNames.get(agentId) === taskName) {
319
+ this.defaultTaskNames.delete(agentId);
320
+ }
321
+ }
322
+ /* 该会话是否已获得"页面访问"授权(permissions 模块查询)。 */
323
+ hasPageAccessGrant(agentId) {
324
+ return this.pageAccessGrants.has(agentId);
325
+ }
326
+ grantPageAccess(agentId) {
327
+ this.pageAccessGrants.add(agentId);
328
+ }
329
+ /* 会话级内网访问授权查询,按 URL origin(协议+主机+端口)为粒度。 */
330
+ hasIntranetGrant(agentId, origin) {
331
+ return this.intranetGrants.get(agentId)?.has(origin) ?? false;
332
+ }
333
+ grantIntranet(agentId, origin) {
334
+ let origins = this.intranetGrants.get(agentId);
335
+ if (!origins) {
336
+ origins = new Set();
337
+ this.intranetGrants.set(agentId, origins);
338
+ }
339
+ origins.add(origin);
340
+ }
341
+ /*
342
+ * 会话结束清理(`agent/disposed` 事件触发):忘掉该会话的授权与默认任务名,
343
+ * 并把它的每个浏览器任务【在每个实际执行过的实例上】分别 finish。
344
+ * 尽力而为:单个 finish 失败不影响其它任务的清理,但会记一行日志——
345
+ * 清理是无人盯着的静默路径,不留痕就没法排查"标签组没关掉"类问题。
346
+ * (finishTask 内部吞掉的三类"视为已达成"错误在 client 层各自记日志;
347
+ * 这里的 catch 接的是其余真失败,如 151.x 的 INVALID_STATE 竞态。)
348
+ */
349
+ async releaseAgent(agentId) {
350
+ this.pageAccessGrants.delete(agentId);
351
+ this.intranetGrants.delete(agentId);
352
+ this.defaultTaskNames.delete(agentId);
353
+ const tasks = this.sessionTaskRegistry.get(agentId);
354
+ this.sessionTaskRegistry.delete(agentId);
355
+ if (!tasks)
356
+ return;
357
+ await Promise.all([...tasks].flatMap(([taskName, instanceIds]) => [...instanceIds].map((instanceId) => this.clientFor(instanceId)
358
+ .finishTask(taskName)
359
+ .catch((error) => {
360
+ this.logger?.(`session cleanup: finish failed for task "${taskName}" on instance ${instanceId ?? 'unresolved'}: ${String(error?.message ?? error)}`);
361
+ }))));
362
+ }
363
+ /*
364
+ * 全量清理(插件卸载/dsh 退出时经 ctx.effect 触发):结束本插件创建过的
365
+ * 一切任务——所有会话任务 + 共享 fetch 任务,同样按"任务×实例"逐对 finish。
366
+ */
367
+ async releaseAll() {
368
+ const targets = [];
369
+ for (const tasks of this.sessionTaskRegistry.values()) {
370
+ for (const [taskName, instanceIds] of tasks) {
371
+ for (const instanceId of instanceIds)
372
+ targets.push({ taskName, instanceId });
373
+ }
374
+ }
375
+ for (const instanceId of this.fetchTaskInstances)
376
+ targets.push({ taskName: FETCH_TASK_NAME, instanceId });
377
+ this.sessionTaskRegistry.clear();
378
+ this.defaultTaskNames.clear();
379
+ this.pageAccessGrants.clear();
380
+ this.intranetGrants.clear();
381
+ this.fetchTaskInstances.clear();
382
+ await Promise.all(targets.map(({ taskName, instanceId }) => this.clientFor(instanceId)
383
+ .finishTask(taskName)
384
+ .catch((error) => {
385
+ this.logger?.(`disposal cleanup: finish failed for task "${taskName}" on instance ${instanceId ?? 'unresolved'}: ${String(error?.message ?? error)}`);
386
+ })));
387
+ }
388
+ }
389
+ const MAX_LABEL_LENGTH = 48;
390
+ /*
391
+ * 清洗模型给的 label:压缩连续空白为单个空格、去首尾空白、截断到 48 字符。
392
+ * 必须清洗——这个字符串会原样成为用户浏览器里可见的标签组标题。
393
+ */
394
+ function sanitizeLabel(label) {
395
+ if (label === undefined)
396
+ return '';
397
+ return label.replace(/\s+/gu, ' ').trim().slice(0, MAX_LABEL_LENGTH);
398
+ }
399
+ /* ────────────────────────────────────────────────────────────────────────────
400
+ * 随包 skill 注册
401
+ *
402
+ * dsh 的 skill 机制:一份 SKILL.md(教模型做某类事的文档),模型按需加载进
403
+ * 上下文。dsh 的 skills 服务通过【provider】发现 skill:provider 提供
404
+ * list()(列出候选"卡片":名字/描述/rank,【不含正文】)和 get()(按候选取
405
+ * 正文)。分两段是有讲究的:卡片常驻模型上下文,模型靠 description 决定值不
406
+ * 值得花 token 读正文;正文只在真被调用时才落进上下文。
407
+ * 我们注册一个 provider,把包内 skills/tabbit/ 目录端上去。
408
+ *
409
+ * 【元数据只有一个事实源:SKILL.md 的 frontmatter】。这里曾经硬编码过一份
410
+ * SKILL_DESCRIPTION,于是文件顶部 frontmatter 里的 description 成了死字符串
411
+ * ——改它不生效,两处措辞越漂越远。dsh 本来就为此留了位置(SkillCandidate
412
+ * 的 metadata 字段定义原文:"parsed optional metadata object from
413
+ * provider-specific skill frontmatter"),所以改成读文件解析。
414
+ * ──────────────────────────────────────────────────────────────────────────── */
415
+ /*
416
+ * skill 的运行时身份。刻意不从 frontmatter 取:系统提示词段落和 tabbit_browser
417
+ * 的工具 description 里都硬写着这个名字,改 frontmatter 改不动它们——名字归
418
+ * 代码管,frontmatter 里的 name 是给人和其它读 SKILL.md 的工具看的。
419
+ *
420
+ * 【为什么叫 tabbit 而不是 tabbit-browser(2026-08-28 方案二决策)】:
421
+ * 浏览器安装时会把官方 skill 写到共享目录 ~/.agents/skills/tabbit/(随浏览器
422
+ * 更新,内容与 Runtime 同步演进),dsh 的 skill-filesystem provider 原生扫描
423
+ * 该目录(user-agents 根,rank 500)。同名去重规则(近层直接胜出/同层 rank
424
+ * 小者胜)下,本包这份 rank 600 的随包副本便自动成为【兜底】:装了新浏览器
425
+ * 的机器用共享版,没装/老浏览器的机器用包内版。名字必须相同这套优先级才
426
+ * 生效——不同名会变成两个 skill 并列出现。
427
+ */
428
+ const SKILL_NAME = 'tabbit';
429
+ // import.meta.url 是当前模块文件的 file:// URL;new URL(相对路径, 它) 得到
430
+ // 包内其它文件的稳定定位——无论包被装到哪里都正确(比 __dirname 更 ESM)。
431
+ const SKILL_URL = new URL('../../skills/tabbit/SKILL.md', import.meta.url);
432
+ const SKILL_RESOURCE_BASE = {
433
+ kind: 'directory',
434
+ // resourceBase 指向 skill 目录:SKILL.md 里引用的 references/*.md 由此解析。
435
+ path: fileURLToPath(new URL('../../skills/tabbit/', import.meta.url)),
436
+ };
437
+ /* 模型可自主加载、用户也可手动调用。 */
438
+ const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
439
+ /*
440
+ * dsh 约定:随 npm 包一起发布的 skill 用 rank 600(区分于用户自建等来源);
441
+ * 重名时 rank【小】者胜,同 rank 再比 provider 注册顺序。
442
+ * dsh-skill 自己也导出了同值的 BUNDLED_SKILL_RANK,这里仍然本地声明——本文件
443
+ * 对所有 dsh 包都是 `import type {}` 的纯类型引用,换成值导入会让插件在没装
444
+ * 该 peer 包的组合里直接加载失败,为一个常量不值当。
445
+ */
446
+ const BUNDLED_SKILL_RANK = 600;
447
+ const SKILL_PROVIDER_NAME = 'dsh-tabbit-bundled-skill';
448
+ /*
449
+ * 只在 frontmatter 被改坏(缺 description)时兜底:dsh 的 validateCandidate 会
450
+ * 拒收空描述的候选,没有兜底就等于整个 skill 从目录里消失。
451
+ */
452
+ const SKILL_FALLBACK_DESCRIPTION = "Recipes for operating the user's Tabbit Browser through the tabbit_browser tool. Load before non-trivial browser work.";
453
+ /*
454
+ * 拆开 SKILL.md:返回 frontmatter 解析出的字段 + 去掉 frontmatter 的正文
455
+ * (正文才是要进模型上下文的内容)。
456
+ * 只认我们自己写的这一层【扁平 key: value】(值可带引号),认不出的行直接
457
+ * 跳过——不为一个几行的 frontmatter 引入 YAML 依赖,也不假装能解析嵌套结构。
458
+ */
459
+ function parseSkillDocument(source) {
460
+ if (!source.startsWith('---\n'))
461
+ return { fields: {}, body: source };
462
+ const end = source.indexOf('\n---\n', 4);
463
+ if (end === -1)
464
+ return { fields: {}, body: source };
465
+ const fields = {};
466
+ for (const line of source.slice(4, end).split('\n')) {
467
+ const match = /^([A-Za-z][\w-]*):\s*(.*)$/u.exec(line);
468
+ if (match === null)
469
+ continue;
470
+ // 去掉整体包裹的成对引号(YAML 里 description: "..." 很常见)。
471
+ const value = match[2].trim().replace(/^(['"])([\s\S]*)\1$/u, '$2');
472
+ if (value !== '')
473
+ fields[match[1]] = value;
474
+ }
475
+ return { fields, body: source.slice(end + 5) };
476
+ }
477
+ /*
478
+ * 读盘 + 解析,组装 dsh 要的两样东西:候选卡片(list 用)与正文(get 用)。
479
+ * 每次现读:SKILL.md 改了下一次发现就生效,文件只有几 KB,读盘成本可忽略。
480
+ * signal:provider 契约要求发现流程能被调用方取消,直接透给 readFile。
481
+ * 读失败就让它抛——dsh 注册表会 catch 住、打一行
482
+ * `skill provider "…" skipped: …` 警告并跳过本 provider,比静默返回空目录
483
+ * ("skill 莫名其妙不见了")好排查得多。
484
+ */
485
+ async function loadSkillDocument(signal) {
486
+ const source = await readFile(SKILL_URL, { encoding: 'utf8', ...(signal ? { signal } : {}) });
487
+ const { fields, body } = parseSkillDocument(source);
488
+ return {
489
+ candidate: {
490
+ name: SKILL_NAME,
491
+ description: fields.description ?? SKILL_FALLBACK_DESCRIPTION,
492
+ // whenToUse 是 dsh 的可选路由补充字段:frontmatter 写了就带上。
493
+ ...(fields.whenToUse !== undefined ? { whenToUse: fields.whenToUse } : {}),
494
+ invocation: SKILL_INVOCATION,
495
+ provider: SKILL_PROVIDER_NAME,
496
+ source: 'bundled',
497
+ resourceBase: SKILL_RESOURCE_BASE,
498
+ rank: BUNDLED_SKILL_RANK,
499
+ // locator 是 provider 私有句柄(dsh 原样传回 get());path 供宿主展示。
500
+ locator: SKILL_URL,
501
+ path: fileURLToPath(SKILL_URL),
502
+ metadata: fields,
503
+ },
504
+ content: body,
505
+ };
506
+ }
507
+ /*
508
+ * skill provider 本体:list 列卡片,get 按名取正文。
509
+ * get 返回正文前经 prependUpdateNotice 过一道(../update-check.ts):有新版
510
+ * 时在正文顶部插一段更新通知(由模型转告用户);检查失败/无新版/浏览器
511
+ * 托管(预装)形态下原样返回,绝不拖慢或搞坏 skill 加载。
512
+ * export 仅为单元测试(tests/plugin.test.mjs 直接调 list/get)。
513
+ */
514
+ export const skillProvider = {
515
+ name: SKILL_PROVIDER_NAME,
516
+ async list(options = {}) {
517
+ const { candidate } = await loadSkillDocument(options.signal);
518
+ return [candidate];
519
+ },
520
+ async get(selected, options = {}) {
521
+ if (selected.name !== SKILL_NAME)
522
+ return undefined;
523
+ const { candidate, content } = await loadSkillDocument(options.signal);
524
+ return { ...candidate, content: await prependUpdateNotice(content) };
525
+ },
526
+ };
527
+ /*
528
+ * 注入系统提示词的段落:让模型【一开始就知道】自己有真浏览器能力、状态会
529
+ * 跨调用持久、以及安全底线(别拿用户的浏览器干破坏性的事)。
530
+ * 不写这段的话,模型要等到看见工具列表才隐约知道,用法也容易跑偏。
531
+ */
532
+ const PROMPT_SECTION_TEXT = [
533
+ 'Tabbit Browser integration: the `tabbit_browser` tool runs Playwright code inside the',
534
+ "user's real Tabbit Browser profile (shared logged-in sessions), and `web_fetch` retrieves",
535
+ 'pages through that browser. Browser state persists across calls within a session task.',
536
+ 'Load the `tabbit` skill before non-trivial browser work. Treat the browser as the',
537
+ "user's own: no destructive account actions, no visiting sensitive services unasked.",
538
+ ].join(' ');
539
+ /* 历史版本 preset 的所有权标记文件名(目录里有它 = 目录归本插件管)。 */
540
+ const PRESET_MARKER = '.dsh-tabbit-managed';
541
+ /* $DSH_HOME 的解析:环境变量优先,缺省 ~/.dsh(与 dsh 本体一致)。 */
542
+ function dshHome() {
543
+ const fromEnv = process.env.DSH_HOME;
544
+ return fromEnv !== undefined && fromEnv !== '' ? fromEnv : join(homedir(), '.dsh');
545
+ }
546
+ /*
547
+ * 【迁移清理】移除历史版本安装的「Tabbit 模式」agent preset
548
+ * (`$DSH_HOME/.agent-presets/tabbit`)。
549
+ *
550
+ * 这个 preset 曾经存在的唯一理由:老版本 dsh 自带的 preset 把
551
+ * `tool-web.fetch` 写死为 false,而 bundle 补丁层够不到 preset 文件。
552
+ * dsh 0.1.2-alpha.1 起标准 preset 已自带 `fetch: true`,preset 使命终结;
553
+ * 且旧快照里的配置键(如 backgroundMode)在新版 dsh 里已改名,留着反而会
554
+ * 让「Tabbit 模式」会话挂载失败。
555
+ *
556
+ * 所有权标记协议照旧生效:
557
+ * - 有 `.dsh-tabbit-managed` 标记 → 归我们管,整目录删除;
558
+ * - 无标记(用户删标记接管过/自建同名目录)→ 绝不碰。
559
+ */
560
+ async function removeManagedPreset() {
561
+ const targetDir = join(dshHome(), '.agent-presets', 'tabbit');
562
+ if (!existsSync(targetDir) || !existsSync(join(targetDir, PRESET_MARKER)))
563
+ return;
564
+ await rm(targetDir, { recursive: true, force: true });
565
+ }
566
+ /* ────────────────────────────────────────────────────────────────────────────
567
+ * Cordis 插件导出三件套(dsh 加载本模块时读取的约定导出)
568
+ * ──────────────────────────────────────────────────────────────────────────── */
569
+ export const name = 'tabbit-core';
570
+ // 硬依赖 settings 服务:它就绪后 apply 才会被调用。
571
+ export const inject = ['settings'];
572
+ /* 插件入口:dsh 加载 `dsh-tabbit` 行时调用,完成全部注册。 */
573
+ export function apply(ctx) {
574
+ // ① 注册 settings 命名空间 "tabbit"。scope.get() 每次返回当前值(热加载)。
575
+ // as 断言是因为 dsh 的 settings 键名类型是闭集,第三方命名空间挤不进
576
+ // 联合类型,只能绕过编译器(运行时完全合法)。
577
+ const scope = ctx.settings.register('tabbit', SETTINGS_SCHEMA);
578
+ const service = new TabbitService(() => scope.get(),
579
+ // 把 dsh 日志器接给服务与底层客户端:finish 吞错/清理失败/隔离恢复这些
580
+ // 原本静默的路径由此在 dsh 日志里可见(排查实例漂移导致标签组残留的关键痕迹)。
581
+ (message) => ctx.logger.info(`dsh-tabbit: ${message}`));
582
+ // ② 发布 ctx.tabbit 服务——其它五个模块 inject: ['tabbit'] 等的就是这句。
583
+ ctx.provide('tabbit', service);
584
+ // ③ 迁移清理:移除历史版本安装的 Tabbit 模式 preset(异步发起,失败只
585
+ // 告警不阻塞插件加载;无标记的用户自管目录绝不触碰)。
586
+ void removeManagedPreset().catch((error) => {
587
+ ctx.logger.warn(`dsh-tabbit: legacy managed preset cleanup failed: ${String(error?.message ?? error)}`);
588
+ });
589
+ // ④ 注册插件卸载清理:effect 的回调返回"清理函数",插件被卸载(或 dsh
590
+ // 退出)时框架调用它 → 结束我们创建过的所有浏览器任务。
591
+ ctx.effect(() => () => {
592
+ void service.releaseAll();
593
+ }, 'dsh-tabbit: finish browser tasks on disposal');
594
+ // ⑤ 订阅 dsh 的 agent/disposed 事件:一个会话的 agent 销毁时,清理该会话
595
+ // 的任务与授权(标签组随之从用户浏览器里消失)。
596
+ ctx.on('agent/disposed', ({ agent }) => {
597
+ void service.releaseAgent(String(agent.id));
598
+ });
599
+ // ⑥ 软依赖 skills 服务:可用时注册我们的 skill provider。
600
+ // (as never 同样是为了绕过 dsh 对第三方 provider 形状的窄类型。)
601
+ ctx.inject(['skills'], (skillCtx) => {
602
+ skillCtx.skills.registerProvider(() => skillProvider);
603
+ });
604
+ // ⑦ 软依赖 systemPrompt 服务:注入提示词段落(order 决定它在提示词里的位置)。
605
+ ctx.inject(['systemPrompt'], (promptCtx) => {
606
+ promptCtx.systemPrompt.section({
607
+ name: 'tabbit',
608
+ order: 150,
609
+ text: PROMPT_SECTION_TEXT,
610
+ });
611
+ });
612
+ // ⑧ 软依赖 commands 服务:注册 /tabbit-info 诊断命令(用户在 dsh 输入框里
613
+ // 敲)。不叫 /tabbit:skill 已改名 tabbit 且 userInvocable——dsh 里用户
614
+ // 可用 `/名字` 直接调用 skill,命令名与之撞名会互相遮蔽。
615
+ ctx.inject(['commands'], (commandCtx) => {
616
+ commandCtx.commands.register({
617
+ name: 'tabbit-info',
618
+ description: 'Show Tabbit Browser integration status: launcher, instances, tasks, permissions.',
619
+ handler: async ({ agent }) => {
620
+ try {
621
+ const report = await renderStatus(service, readLocalePreference(ctx.settings));
622
+ const newline = report.indexOf('\n');
623
+ const conclusion = newline === -1 ? report : report.slice(0, newline);
624
+ // 先落 tabbit/status 事件再返回:web 客户端把它折成常显的状态卡
625
+ // (见 client/client.js),命令行节点只保留结论摘要。sourceEventSeq
626
+ // 指回这条事件,是 dsh 关联命令生命周期与领域投影的标准字段。
627
+ const event = agent.session.append('tabbit/status', {
628
+ at: Date.now(),
629
+ conclusion,
630
+ report,
631
+ });
632
+ return { kind: 'success', text: conclusion, sourceEventSeq: event.seq };
633
+ }
634
+ catch (error) {
635
+ return { kind: 'error', text: `tabbit status failed: ${String(error?.message ?? error)}` };
636
+ }
637
+ },
638
+ });
639
+ });
640
+ }
641
+ /*
642
+ * /tabbit-info 首行结论的用户语言。locale.preference 由 dsh 的 locale 插件
643
+ * 注册(zh/en,存用户设置文档);未设置时 dsh 客户端跟随浏览器语言并兜底
644
+ * en——服务端看不到浏览器语言,因此同样兜底 en,与 dsh 的 FALLBACK_LOCALE
645
+ * 取向一致(浏览器没点名 shipped 语言时,读中文的可能性最低)。
646
+ */
647
+ function readLocalePreference(settings) {
648
+ const locale = settings.get('locale');
649
+ return locale?.preference === 'zh' ? 'zh' : 'en';
650
+ }
651
+ /*
652
+ * 首行结论:用户在收起的命令行上能扫到的只有这一行,所以按“是否需要
653
+ * 用户采取行动”给结论——未安装提醒装/启动、未运行提醒启动、多实例时
654
+ * 告知会优先用最近使用的实例,正常时给一句可读的状态摘要。明细行保持
655
+ * 英文技术格式(可直接贴进 issue),不复述结论。
656
+ */
657
+ function statusConclusion(service, settings, launcher, locale) {
658
+ const t = (zh, en) => (locale === 'zh' ? zh : en);
659
+ if (!existsSync(launcher)) {
660
+ return t('⚠️ 未找到 Tabbit 浏览器——请先启动(或安装)Tabbit Browser,再运行 /tabbit-info', '⚠️ Tabbit Browser not found — launch (or install) Tabbit Browser first, then rerun /tabbit-info');
661
+ }
662
+ const onlineInstances = service.instances().filter((instance) => instance.online);
663
+ if (onlineInstances.length === 0) {
664
+ return t('⚠️ Tabbit 浏览器已安装但未在运行——请启动 Tabbit Browser 后重试', '⚠️ Tabbit Browser is installed but not running — launch Tabbit Browser and retry');
665
+ }
666
+ if (!settings.instance && onlineInstances.length > 1) {
667
+ // 多实例不再当警告:正常使用中 dsh-web 的观看实例打点(viewer)几乎
668
+ // 总在,解析会落在"当前正在看 dsh 的那个实例"上;需要锁死再设
669
+ // tabbit.instance(明细行里保留了这条提示)。
670
+ return t(`✅ 检测到 ${onlineInstances.length} 个在线 Tabbit 浏览器实例,会优先使用最近使用的实例`, `✅ ${onlineInstances.length} Tabbit Browser instances online — preferring the most recently used one`);
671
+ }
672
+ const resolved = service.resolveExecutionInstance();
673
+ const execution = resolved.id !== undefined
674
+ ? t(`${resolved.id}(来源 ${resolved.source})`, `${resolved.id} (via ${resolved.source})`)
675
+ : t('唯一在线实例(auto)', 'the single online instance (auto)');
676
+ return t(`✅ Tabbit 集成正常——${onlineInstances.length} 个实例在线,执行实例 ${execution}`, `✅ Tabbit integration OK — ${onlineInstances.length} instance(s) online, executing on ${execution}`);
677
+ }
678
+ /*
679
+ * /tabbit-info 命令的输出渲染:首行结论(跟随用户语言),空行,然后是
680
+ * launcher 状态、实例列表(在线/选中标记 + 多实例提示)、生效实例及来源、
681
+ * 观看实例、权限设置、任务占用表。
682
+ */
683
+ async function renderStatus(service, locale) {
684
+ const settings = service.currentSettings();
685
+ const launcher = service.launcherPath();
686
+ const lines = [];
687
+ lines.push(statusConclusion(service, settings, launcher, locale));
688
+ lines.push('');
689
+ const instances = service.instances();
690
+ if (instances.length === 0) {
691
+ lines.push('instances: none registered');
692
+ }
693
+ else {
694
+ lines.push('instances:');
695
+ for (const instance of instances) {
696
+ const marks = [instance.online ? 'online' : 'offline', settings.instance === instance.id ? 'selected' : '']
697
+ .filter(Boolean)
698
+ .join(', ');
699
+ lines.push(` - ${instance.id} ${instance.appName} (${marks})`);
700
+ }
701
+ if (!settings.instance && instances.filter((instance) => instance.online).length > 1) {
702
+ lines.push(' ! multiple instances online — set settings key tabbit.instance to one of the ids above');
703
+ }
704
+ }
705
+ const resolved = service.resolveExecutionInstance();
706
+ if (resolved.id !== undefined) {
707
+ lines.push(`execution instance: ${resolved.id} (via ${resolved.source})`);
708
+ }
709
+ else {
710
+ lines.push('execution instance: auto (single online instance, else guided error)');
711
+ }
712
+ const viewer = service.viewer();
713
+ if (viewer !== undefined) {
714
+ lines.push(`dsh-web viewer: ${viewer.id} (detected ${new Date(viewer.at).toISOString()})`);
715
+ }
716
+ lines.push(`permissions: pageAccess=${settings.pageAccess}, intranetFetch=${settings.intranetFetch}`);
717
+ // 任务列表只在"有在线实例(查询不会有副作用)或全机只装一个实例"时查询:
718
+ // 浏览器全离线时任何 CLI 调用都会把浏览器拉起来——诊断命令不该有这种副作用。
719
+ const online = instances.some((instance) => instance.online);
720
+ if (online || instances.length === 1) {
721
+ try {
722
+ const tasks = await service.client().listTasks();
723
+ if (tasks.length === 0) {
724
+ lines.push('tasks: none');
725
+ }
726
+ else {
727
+ lines.push(`tasks (${tasks.length}/8):`);
728
+ for (const task of tasks) {
729
+ const flags = [task.idle ? 'idle' : 'active', task.quarantined ? 'QUARANTINED' : ''].filter(Boolean).join(', ');
730
+ lines.push(` - ${task.taskName} (${flags})`);
731
+ }
732
+ }
733
+ }
734
+ catch (error) {
735
+ lines.push(`tasks: unavailable (${String(error?.message ?? error)})`);
736
+ }
737
+ }
738
+ else {
739
+ lines.push('tasks: browser offline (skipped to avoid launching it)');
740
+ }
741
+ return lines.join('\n');
742
+ }