@shgroup/dsh-serenity-hooks 1.31.4 → 1.31.8
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/README.md +26 -5
- package/dsh.plugin.json +3 -3
- package/lib/client.js +17 -11
- package/lib/host/contract.d.ts +9 -4
- package/lib/host/type-contract.d.ts +44 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +306 -18
- package/lib/opencode-provider.d.ts +134 -0
- package/lib/{skiff-debug-B445WY23.js → skiff-debug-w2c3UqQk.js} +1 -1
- package/package.json +20 -20
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **一句话**:装上这个插件,你在电脑上给 AI 划一块自己的工作区(就是一个普通目录),
|
|
4
4
|
> AI 在里面干活时就有了**记忆、纪律、工具和边界**——中途换模型、重启电脑、第二天再来,都能接着干。
|
|
5
5
|
>
|
|
6
|
-
> 适用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(下称 DSH)0.1.
|
|
6
|
+
> 适用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(下称 DSH)0.1.5-rc.1 及以上。
|
|
7
7
|
> 想了解背后的想法(为什么叫"认知容器"),看 [docs/cognitive-container-theory.md](https://github.com/tellmewhattodo/dsh-serenity-plugin/blob/master/docs/cognitive-container-theory.md);本文只讲**能干什么、怎么用**。
|
|
8
8
|
|
|
9
9
|
**几个词先说明白**(后面都用这几个词,不再解释):
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
## 2. 快速开始(2 分钟)
|
|
34
34
|
|
|
35
|
-
前置:Node ≥ 20(或 bun)、DSH 0.1.
|
|
35
|
+
前置:Node ≥ 20(或 bun)、DSH 0.1.5-rc.1 及以上。
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
# 1. 装插件(自动加入 DSH 的 web profile)
|
|
@@ -111,6 +111,27 @@ dsh plugin --profile web add link:$(pwd)/hooks/dsh-serenity-hooks
|
|
|
111
111
|
|
|
112
112
|
> 默认只监听 127.0.0.1 的入口,要暴露到公网由你自己决定(隧道 / 反代 / 端口映射都行),插件不绑定任何特定做法。
|
|
113
113
|
|
|
114
|
+
### 3.4 省你一步:用 opencode 的模型
|
|
115
|
+
|
|
116
|
+
想在 DSH 里用 opencode 的网关(`opencode.ai/zen`),本来除了配路由还得手抄一串请求头——
|
|
117
|
+
这些头是 opencode 用来做**会话亲和路由**的,少写一个 `x-opencode-session`,`/zen/go` 面就直接 400 拒绝。
|
|
118
|
+
**插件装好就自动配**,这一步你不用管:
|
|
119
|
+
|
|
120
|
+
| 你的情况 | 插件做什么 |
|
|
121
|
+
|---|---|
|
|
122
|
+
| 已经配了 opencode 路由,但缺头 | 只补缺的那几个;**你手写过的值一律不动**(头名不分大小写,`X-Title` 和 `x-title` 一样算数) |
|
|
123
|
+
| 一个 opencode 路由都没有,但环境里有 `OPENCODE_API_KEY` | 自动建 `opencode-go` 路由并带全头(没有 key 的人不受影响,不会平白多出一组模型) |
|
|
124
|
+
|
|
125
|
+
- **只管"会话族"的头**:`x-opencode-session` / `X-Session-ID` / `x-opencode-client` / `x-opencode-project`。
|
|
126
|
+
前两个名字是**同一个值的两个别名**——你写了其中一个,就用**你的值**补上另一个,不会出现两个互相打架的会话 id
|
|
127
|
+
- **不冒充 opencode 官方客户端**:`X-Title` / `HTTP-Referer` 这类"身份头"只在免费档的滥用判别里起作用,
|
|
128
|
+
插件不注入(替你骗额度不是我们该做的事,付费面本来也不需要它们);`User-Agent` 是 DSH 的保留名,想注也注不了
|
|
129
|
+
- **会话标识是固定的**(`dsh-serenity`):DSH 的请求头在路由解析时一次算好,只能给静态值。
|
|
130
|
+
好处是同一台机器的请求稳定落同一个上游(对缓存友好);代价是做不到"按会话分区"
|
|
131
|
+
- **配的是你自己的文件**:写入 DSH 的 settings(`llm-pi-ai.providers.<路由>.headers`),随时可改可删。
|
|
132
|
+
不想要这个自动配置,就把本插件的 `opencodeProvider.autoConfigure` 关掉(关掉后一切回到手抄)
|
|
133
|
+
- **密钥不碰**:插件只补路由和头,`OPENCODE_API_KEY` 放环境变量即可
|
|
134
|
+
|
|
114
135
|
---
|
|
115
136
|
|
|
116
137
|
## 4. 一个工作区长什么样
|
|
@@ -270,7 +291,7 @@ DSH 一个进程可以同时带多个工作区,每个工作区各自对接自
|
|
|
270
291
|
|
|
271
292
|
| 层 | 位置 | 放什么 |
|
|
272
293
|
|---|---|---|
|
|
273
|
-
| DSH 原生设置 | DSH 的 settings.yaml | 简单开关:网关 / 重建 / 会话命名、重建阈值、子角色开关与调试端口、ACP
|
|
294
|
+
| DSH 原生设置 | DSH 的 settings.yaml | 简单开关:网关 / 重建 / 会话命名、重建阈值、子角色开关与调试端口、ACP 与问答页开关、**巡航总开关(默认关)**、**opencode 路由自动配置(默认开)** |
|
|
274
295
|
| 插件全局文件 | `~/.dsh/serenity-hooks.json`(权限 0600) | 网关账号(scrypt + TOTP)、监听地址与端口、工作区白名单、cookie 安全开关、问答页 key |
|
|
275
296
|
| 工作区配置 | `.opencode/serenity.json` | 助手模型白名单、日志阈值、安全模式黑名单、子角色、**巡航(间隔/会话/焦点/窗口)**、**微信(账号/路由/开关)** |
|
|
276
297
|
| 工作区凭据 | `localstore.json` | 密钥与本地偏好;微信机器人 token 也在这一层 |
|
|
@@ -296,7 +317,7 @@ AI 一次能"记住"的内容有上限。满了不用你手动开新会话:
|
|
|
296
317
|
|
|
297
318
|
```bash
|
|
298
319
|
pnpm typecheck # 类型检查(node + 浏览器端两套)
|
|
299
|
-
pnpm test # 全量测试(当前
|
|
320
|
+
pnpm test # 全量测试(当前 78 个文件 / 1169 个用例)
|
|
300
321
|
pnpm build # 打包(lib/index.js + client.js)
|
|
301
322
|
```
|
|
302
323
|
|
|
@@ -360,4 +381,4 @@ pnpm build # 打包(lib/index.js + client.js)
|
|
|
360
381
|
|
|
361
382
|
MIT(见 [LICENSE](https://github.com/tellmewhattodo/dsh-serenity-plugin/blob/master/LICENSE))
|
|
362
383
|
|
|
363
|
-
> **版本**:v1.31.
|
|
384
|
+
> **版本**:v1.31.7 | **前置**:DSH 0.1.5-rc.1+ / Node ≥ 20 或 bun | **测试**:78 个文件 / 1169 个用例
|
package/dsh.plugin.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-serenity-hooks",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.8",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
|
-
"description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/logbook/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/autopilot-trajectory/im-bridge)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/自主巡航。适配 DSH 0.1.
|
|
5
|
+
"description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/logbook/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/autopilot-trajectory/im-bridge)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/自主巡航。适配 DSH 0.1.5-rc.1(deepseek-ai/deepseek-harness)。",
|
|
6
6
|
"engines": {
|
|
7
|
-
"dsh": ">=0.1.
|
|
7
|
+
"dsh": ">=0.1.5-rc.1"
|
|
8
8
|
},
|
|
9
9
|
"contributes": {
|
|
10
10
|
"tools": [
|
package/lib/client.js
CHANGED
|
@@ -419,15 +419,21 @@ window.__ModuleLoader__.load({
|
|
|
419
419
|
}
|
|
420
420
|
/**
|
|
421
421
|
* 取 rail 图片的浏览器 File。
|
|
422
|
-
*
|
|
422
|
+
*
|
|
423
|
+
* v1.31.6 适配 0.1.5-rc.1:`IConversation.draftImages(ids)` **已改名**
|
|
424
|
+
* `resolveDraftAttachments(ids)`(同语义同返回:ordered draft descriptors)。
|
|
425
|
+
* 这是**静默突破**——调用点用结构化断言隔离宿主(零宿主 import 策略),
|
|
426
|
+
* 类型检查看不见旧名消失,只会运行时 `=== undefined` → 静默返回 `[]`(兜底悄悄失效)。
|
|
427
|
+
*
|
|
428
|
+
* conversation 是 root singleton 的公开方法(读 controller 的 draftAttachments Map,
|
|
423
429
|
* 与调用 ctx 的作用域无关)——直接 ctx.get('conversation')。
|
|
424
|
-
* ⚠️ 必须作为方法调用(conversation.
|
|
425
|
-
*
|
|
430
|
+
* ⚠️ 必须作为方法调用(conversation.resolveDraftAttachments(ids)):解构取出再调会丢失 this
|
|
431
|
+
* (内部读 this 的 draft registry → "Cannot read properties of undefined")。
|
|
426
432
|
*/
|
|
427
433
|
async function getDraftFiles(ctx, _sessionId, ids) {
|
|
428
434
|
const conversation = ctx.get?.("conversation");
|
|
429
|
-
if (conversation?.
|
|
430
|
-
return (conversation.
|
|
435
|
+
if (conversation?.resolveDraftAttachments === void 0) return [];
|
|
436
|
+
return (conversation.resolveDraftAttachments(ids) ?? []).map((a) => a.file);
|
|
431
437
|
}
|
|
432
438
|
/**
|
|
433
439
|
* 纯文本重发(绕过图片门禁——不含 image part,模型永不触发 MODEL_DOES_NOT_SUPPORT_IMAGES)。
|
|
@@ -455,22 +461,22 @@ window.__ModuleLoader__.load({
|
|
|
455
461
|
const inputActions = props.inputActions;
|
|
456
462
|
const handlingRef = (0, react.useRef)(false);
|
|
457
463
|
const promptError = session?.promptError?.error;
|
|
458
|
-
const
|
|
464
|
+
const attachmentIds = input?.attachmentIds ?? [];
|
|
459
465
|
(0, react.useEffect)(() => {
|
|
460
466
|
if (handlingRef.current || !isImageFallbackTrigger(promptError?.code, promptError?.details?.reason)) return;
|
|
461
|
-
if (
|
|
467
|
+
if (attachmentIds.length === 0) return;
|
|
462
468
|
handlingRef.current = true;
|
|
463
469
|
(async () => {
|
|
464
470
|
try {
|
|
465
|
-
const files = await getDraftFiles(String(sessionId),
|
|
471
|
+
const files = await getDraftFiles(String(sessionId), attachmentIds);
|
|
466
472
|
if (files.length === 0) throw new Error("no draft image files");
|
|
467
473
|
const saved = [];
|
|
468
474
|
for (const file of files) saved.push(await uploadImage(file, String(sessionId)));
|
|
469
475
|
const note = imageNoteTemplate(saved);
|
|
470
476
|
const draft = input?.draft ?? "";
|
|
471
477
|
const text = draft === "" ? note : `${draft}\n${note}`;
|
|
472
|
-
if (inputActions?.
|
|
473
|
-
for (const id of
|
|
478
|
+
if (inputActions?.removeAttachment !== void 0 && inputActions.setDraft !== void 0 && inputActions.submit !== void 0) {
|
|
479
|
+
for (const id of attachmentIds) inputActions.removeAttachment(id);
|
|
474
480
|
inputActions.setDraft(text);
|
|
475
481
|
inputActions.submit();
|
|
476
482
|
} else await resendText(String(sessionId), text);
|
|
@@ -482,7 +488,7 @@ window.__ModuleLoader__.load({
|
|
|
482
488
|
})();
|
|
483
489
|
}, [
|
|
484
490
|
promptError,
|
|
485
|
-
|
|
491
|
+
attachmentIds,
|
|
486
492
|
sessionId,
|
|
487
493
|
uploadImage,
|
|
488
494
|
getDraftFiles,
|
package/lib/host/contract.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface HostServiceContract {
|
|
|
38
38
|
/** 缺失是否致命 */
|
|
39
39
|
required: boolean;
|
|
40
40
|
}
|
|
41
|
-
/** dsp 依赖的宿主服务与成员(对照
|
|
41
|
+
/** dsp 依赖的宿主服务与成员(对照 `_tmp/host-0.1.5-rc.1/`(`dsh-develop host-fetch` 产物)逐一核对) */
|
|
42
42
|
export declare const HOST_SERVICES: readonly HostServiceContract[];
|
|
43
43
|
export interface HostEventContract {
|
|
44
44
|
name: HostEventName;
|
|
@@ -55,14 +55,14 @@ export interface HostEventContract {
|
|
|
55
55
|
* 在名字写错/宿主改名时**编译失败**(v1.30.7,review F-04/F-05)。
|
|
56
56
|
* 该 import 为 type-only,编译期擦除,不产生运行时依赖。
|
|
57
57
|
*/
|
|
58
|
-
export declare const HOST_EVENT_NAMES: readonly ["agent/session-start", "agent/pre-step", "agent/turn-stopping", "agent/status", "agent/inbox/inserted", "session/event", "session/created", "tools/pre-execute", "tools/post-execute", "system-prompt/assemble"];
|
|
58
|
+
export declare const HOST_EVENT_NAMES: readonly ["agent/session-start", "agent/pre-step", "agent/turn-stopping", "agent/status", "agent/inbox/inserted", "agent/disposed", "session/event", "session/created", "session/disposed", "settings/updated", "tools/pre-execute", "tools/post-execute", "system-prompt/assemble"];
|
|
59
59
|
export type HostEventName = (typeof HOST_EVENT_NAMES)[number];
|
|
60
60
|
/**
|
|
61
61
|
* dsp 订阅的宿主事件清单(与 HOST_EVENT_NAMES 同源;impact/site 供 health 报告与文档)。
|
|
62
62
|
*/
|
|
63
63
|
export declare const HOST_EVENTS: readonly HostEventContract[];
|
|
64
64
|
/** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
|
|
65
|
-
export declare const REQUIRED_HOST_RANGE = "^0.1.
|
|
65
|
+
export declare const REQUIRED_HOST_RANGE = "^0.1.5-rc.1";
|
|
66
66
|
export interface HostContractIssue {
|
|
67
67
|
id: string;
|
|
68
68
|
kind: 'service' | 'member' | 'version';
|
|
@@ -80,8 +80,13 @@ export interface HostContractReport {
|
|
|
80
80
|
/** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
|
|
81
81
|
export declare function compareSemver(a: string, b: string): number | null;
|
|
82
82
|
/**
|
|
83
|
-
* 宿主版本是否落在 dsp
|
|
83
|
+
* 宿主版本是否落在 dsp 被验证的范围(`REQUIRED_HOST_RANGE`)。
|
|
84
84
|
* 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
|
|
85
|
+
*
|
|
86
|
+
* floor/ceiling **从 `REQUIRED_HOST_RANGE` 派生**(v1.31.6,0.1.5-rc.1 适配轮):
|
|
87
|
+
* 此前 floor 是独立硬编码字面量——改 `REQUIRED_HOST_RANGE` 而忘改 floor 会造出
|
|
88
|
+
* "常量说 0.1.5、判定用 0.1.2" 的双真相源。ceiling 无法从 caret 语义机械派生(手写比较器
|
|
89
|
+
* 不实现 semver 的 caret 规则),故保留显式常量并在此声明其含义。
|
|
85
90
|
*/
|
|
86
91
|
export declare function checkHostVersion(version: string | null): {
|
|
87
92
|
ok: boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host/type-contract.ts — 宿主**类型**契约(编译期反证层,v1.31.8,S142 全量 review 产物)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么存在(R↓)
|
|
5
|
+
*
|
|
6
|
+
* dsp 用**结构化断言**隔离宿主(手写 interface + `as` / `as never`,零宿主运行时 import),
|
|
7
|
+
* 换来 peer-only 打包与"不随宿主版本耦合"的灵活度;代价是**类型检查对宿主漂移失明**:
|
|
8
|
+
* 宿主把成员改名、改返回形状,dsp 侧拿不到任何编译期信号,只能靠**人读宿主源码**发现。
|
|
9
|
+
*
|
|
10
|
+
* 历史已三次静默失效:
|
|
11
|
+
* - `Session.events` → `snapshotEvents()`(v1.28.0)
|
|
12
|
+
* - `conversation.draftImages()` → `resolveDraftAttachments()`(v1.31.6)
|
|
13
|
+
* - `sessionPersistence.list()` 返回形状 `SessionHeader[]`(`h.cwd`)→
|
|
14
|
+
* `SessionPersistenceSnapshot[]`(`h.header.cwd`)(v1.31.6)
|
|
15
|
+
* 后两条是升级 0.1.5-rc.1 时**逐文件人读**才挖出来的——不是机制发现的。
|
|
16
|
+
*
|
|
17
|
+
* ## 既有三道防线的盲区(2026-09-10 全量 review 结论)
|
|
18
|
+
*
|
|
19
|
+
* | 防线 | 覆盖 | 盲区 |
|
|
20
|
+
* |------|------|------|
|
|
21
|
+
* | `contract.ts` 的 `satisfies keyof Events` | 事件**名** | payload 形状 |
|
|
22
|
+
* | `probeHostContract`(运行期) | 服务**名** + 成员**存在性** | 成员**签名**、**返回形状** |
|
|
23
|
+
* | `tests/**` | —— | **完全不参与类型检查**(tsconfig `include: ["src"]`)→ 写在测试里的"真实宿主类型断言"从未生效 |
|
|
24
|
+
*
|
|
25
|
+
* ## 本模块补的那一格:用宿主真实类型反证 dsp 的形状假设
|
|
26
|
+
*
|
|
27
|
+
* 机制:
|
|
28
|
+
* ① `import type {} from '<宿主包>'` 把该包的 `declare module '@deepseek-ai/cordis'` 声明增强
|
|
29
|
+
* 拉进 program(**空类型 import,编译期擦除 → 零运行时依赖**,peer-only 打包不受影响;
|
|
30
|
+
* 与 `src/client/index.ts` 引 `dsh-client-ui-session` 同款手法)
|
|
31
|
+
* ② 每条 `Expect<Extends<宿主真相, dsp 假设>>` 是一道**编译期闸门**:宿主改名/改形状 →
|
|
32
|
+
* `dsh-develop typecheck` 当场红,升级当天报警,而不是等线上静默失效
|
|
33
|
+
* ③ 换宿主版本时 `dsh-develop typecheck-host <ver>` 会对**解包的新宿主**跑同一批断言
|
|
34
|
+
*
|
|
35
|
+
* ## 维护纪律(强制)
|
|
36
|
+
*
|
|
37
|
+
* **新增一处对宿主的形状依赖 → 在此加一条对应闸门**(断言旁注明 dsp 消费点与后果)。
|
|
38
|
+
* 断言只表达 dsp **真正依赖**的那部分(不求与宿主全等)→ 宿主新增字段/放宽类型不会误报,
|
|
39
|
+
* 只有"dsp 依赖的东西没了或变了"才红。
|
|
40
|
+
*
|
|
41
|
+
* 注:本文件**没有任何运行时语句**,编译产物只有 `export {}`,不影响包体行为。
|
|
42
|
+
*/
|
|
43
|
+
/** 哨兵:确保本模块不是空文件被 tree-shake 掉(无运行时副作用,值为类型) */
|
|
44
|
+
export type HostTypeContractSatisfied = true;
|
package/lib/index.d.ts
CHANGED
|
@@ -64,6 +64,14 @@ export interface Config {
|
|
|
64
64
|
webFetch?: {
|
|
65
65
|
enabled?: boolean;
|
|
66
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* v1.31.7 opencode 路由自动配置(plugin 全局;S142 用户"省得用户配"):
|
|
69
|
+
* 补 `llm-pi-ai.providers.<opencode 路由>.headers` 的路由头;环境有 OPENCODE_API_KEY 时建路由。
|
|
70
|
+
* 缺省开(`!== false` 判定);关闭后一切回到"用户手抄"。
|
|
71
|
+
*/
|
|
72
|
+
opencodeProvider?: {
|
|
73
|
+
autoConfigure?: boolean;
|
|
74
|
+
};
|
|
67
75
|
}
|
|
68
76
|
export declare const Config: z<Config>;
|
|
69
77
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
2
|
import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$2, f as readHandymanConfig, i as findGitRoot, l as pathInside, m as resolveInside, n as SAFE_MODE_MARKER, o as isSafeModeOn, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-DAsSHsub.js";
|
|
3
3
|
import { a as readSkiffRoles, d as systemPromptSource, i as isSkiffSessionId, l as roleToolWhitelist, o as resolveRoleSystemPrompt } from "./skiff-role-BYvNnwv1.js";
|
|
4
|
-
import { A as skiffRoleFor, C as listActiveHandymen, D as splitModel, E as requireWhitelistedModel, O as writeFailedStatus, S as handymanProgressPaths, T as readProgress, _ as unregisterSkiffSession, a as startSkiffDebugServer, b as HANDYMAN_GUIDE, c as askSkiff, d as ensureWorkspacePromptSection, f as getSkiffAgent, g as skiffTrajectoryEnabled, h as skiffSessionSnapshot, k as writeProgress, l as createSkiffAgent, m as skiffSessionInfo, n as jscSafeJsonText, o as stopSkiffDebugServer, p as skiffMsmGate, r as renderSkiffMarkdown, s as stripThink, t as discoverCccs, u as ensureSkiffSession, v as workspaceTrajectoryLine, w as newStopToken, x as buildRoundPrompt, y as waitAgentIdle } from "./skiff-debug-
|
|
5
|
-
import { c as hostWebServer, i as hostSessions, n as hostInjected, o as hostSubagents, r as hostService, s as hostWeb, t as hostAgents } from "./access-fiehjxV6.js";
|
|
4
|
+
import { A as skiffRoleFor, C as listActiveHandymen, D as splitModel, E as requireWhitelistedModel, O as writeFailedStatus, S as handymanProgressPaths, T as readProgress, _ as unregisterSkiffSession, a as startSkiffDebugServer, b as HANDYMAN_GUIDE, c as askSkiff, d as ensureWorkspacePromptSection, f as getSkiffAgent, g as skiffTrajectoryEnabled, h as skiffSessionSnapshot, k as writeProgress, l as createSkiffAgent, m as skiffSessionInfo, n as jscSafeJsonText, o as stopSkiffDebugServer, p as skiffMsmGate, r as renderSkiffMarkdown, s as stripThink, t as discoverCccs, u as ensureSkiffSession, v as workspaceTrajectoryLine, w as newStopToken, x as buildRoundPrompt, y as waitAgentIdle } from "./skiff-debug-w2c3UqQk.js";
|
|
5
|
+
import { a as hostSettings, c as hostWebServer, i as hostSessions, n as hostInjected, o as hostSubagents, r as hostService, s as hostWeb, t as hostAgents } from "./access-fiehjxV6.js";
|
|
6
6
|
import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-wWk4AIwV.js";
|
|
7
7
|
import { C as useSession, S as summarize, _ as resolveSessionByTitle, a as archiveSessions, b as setActiveSessionInfo, c as createSession, d as getActiveSessionInfo, f as healthCheck, g as readActiveSessionMd, h as qaCheck, i as SESSION_ACTIONS, l as extractSessionMdPathFromText, m as parseSessionContextFromEvents, n as readLastBound, o as clearActiveSessionInfo, p as listSessions, r as DEFAULT_SESSION_SCOPE, s as closeSession, t as appendBound, u as findSession, v as sessionEvents, x as showSession, y as sessionsRoot } from "./session-bound-BpTayaIS.js";
|
|
8
8
|
import { n as registerSettingsSection, t as readSimpleSettings } from "./settings-section-01rTVq6v.js";
|
|
@@ -1091,7 +1091,7 @@ function setSafeMode(root, on) {
|
|
|
1091
1091
|
}
|
|
1092
1092
|
//#endregion
|
|
1093
1093
|
//#region src/host/contract.ts
|
|
1094
|
-
/** dsp 依赖的宿主服务与成员(对照
|
|
1094
|
+
/** dsp 依赖的宿主服务与成员(对照 `_tmp/host-0.1.5-rc.1/`(`dsh-develop host-fetch` 产物)逐一核对) */
|
|
1095
1095
|
const HOST_SERVICES = [
|
|
1096
1096
|
{
|
|
1097
1097
|
id: "tools",
|
|
@@ -1160,11 +1160,21 @@ const HOST_SERVICES = [
|
|
|
1160
1160
|
id: "settings",
|
|
1161
1161
|
name: "settings",
|
|
1162
1162
|
access: "injected",
|
|
1163
|
-
members: [
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1163
|
+
members: [
|
|
1164
|
+
{
|
|
1165
|
+
name: "installSection",
|
|
1166
|
+
kind: "function"
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
name: "get",
|
|
1170
|
+
kind: "function"
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
name: "update",
|
|
1174
|
+
kind: "function"
|
|
1175
|
+
}
|
|
1176
|
+
],
|
|
1177
|
+
impact: "设置面板不安装 → 所有开关静默 no-op;opencode 路由自动配置失效",
|
|
1168
1178
|
required: true
|
|
1169
1179
|
},
|
|
1170
1180
|
{
|
|
@@ -1292,7 +1302,11 @@ const HOST_SERVICES = [
|
|
|
1292
1302
|
}
|
|
1293
1303
|
];
|
|
1294
1304
|
/** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
|
|
1295
|
-
const REQUIRED_HOST_RANGE = "^0.1.
|
|
1305
|
+
const REQUIRED_HOST_RANGE = "^0.1.5-rc.1";
|
|
1306
|
+
/** 范围 floor:剥掉 caret/比较前缀(`^0.1.5-rc.1` → `0.1.5-rc.1`),供 {@link checkHostVersion} 使用 */
|
|
1307
|
+
const REQUIRED_HOST_FLOOR = REQUIRED_HOST_RANGE.replace(/^[\^~>=<\s]+/, "");
|
|
1308
|
+
/** 范围 ceiling:`^0.1.x` 的上界 = `<0.2.0`(显式常量,见 {@link checkHostVersion} 注释) */
|
|
1309
|
+
const REQUIRED_HOST_CEILING = "0.2.0";
|
|
1296
1310
|
/** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
|
|
1297
1311
|
function compareSemver(a, b) {
|
|
1298
1312
|
const parse = (v) => {
|
|
@@ -1317,16 +1331,21 @@ function compareSemver(a, b) {
|
|
|
1317
1331
|
return ra < rb ? -1 : 1;
|
|
1318
1332
|
}
|
|
1319
1333
|
/**
|
|
1320
|
-
* 宿主版本是否落在 dsp
|
|
1334
|
+
* 宿主版本是否落在 dsp 被验证的范围(`REQUIRED_HOST_RANGE`)。
|
|
1321
1335
|
* 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
|
|
1336
|
+
*
|
|
1337
|
+
* floor/ceiling **从 `REQUIRED_HOST_RANGE` 派生**(v1.31.6,0.1.5-rc.1 适配轮):
|
|
1338
|
+
* 此前 floor 是独立硬编码字面量——改 `REQUIRED_HOST_RANGE` 而忘改 floor 会造出
|
|
1339
|
+
* "常量说 0.1.5、判定用 0.1.2" 的双真相源。ceiling 无法从 caret 语义机械派生(手写比较器
|
|
1340
|
+
* 不实现 semver 的 caret 规则),故保留显式常量并在此声明其含义。
|
|
1322
1341
|
*/
|
|
1323
1342
|
function checkHostVersion(version) {
|
|
1324
1343
|
if (version === null) return {
|
|
1325
1344
|
ok: false,
|
|
1326
1345
|
detail: `host version unknown (required ${REQUIRED_HOST_RANGE})`
|
|
1327
1346
|
};
|
|
1328
|
-
const floor = compareSemver(version,
|
|
1329
|
-
const ceiling = compareSemver(version,
|
|
1347
|
+
const floor = compareSemver(version, REQUIRED_HOST_FLOOR);
|
|
1348
|
+
const ceiling = compareSemver(version, REQUIRED_HOST_CEILING);
|
|
1330
1349
|
if (floor === null || ceiling === null) return {
|
|
1331
1350
|
ok: false,
|
|
1332
1351
|
detail: `host version "${version}" not parseable (required ${REQUIRED_HOST_RANGE})`
|
|
@@ -3065,13 +3084,15 @@ function subagentOutputText(output) {
|
|
|
3065
3084
|
* - 模型经 `agentOptions` 注入(spawn 后端能力位 `agentOptions: true`),因此**不依赖**
|
|
3066
3085
|
* 宿主未放开的 `subagent-model-selection` 设置;
|
|
3067
3086
|
* - 不循环、不写进度文件、不做完成码校验——"一次调用一次结果"。
|
|
3087
|
+
*
|
|
3088
|
+
* 前置条件:`opts.parent` 必须是发起 agent(由调用方 `execute` 在解析 CCC 之前校验;
|
|
3089
|
+
* 本函数不再重复检查——单一真相源)。
|
|
3068
3090
|
*/
|
|
3069
3091
|
async function runForegroundJob(ctx, opts) {
|
|
3070
3092
|
const { task, label, model, models, parent, signal } = opts;
|
|
3071
3093
|
requireWhitelistedModel(model, models);
|
|
3072
3094
|
const subagents = hostSubagents(ctx);
|
|
3073
3095
|
if (!subagents?.start) throw new Error("handyman foreground: host subagents service unavailable — neither ctx.subagents nor ctx.get(\"subagents\") yielded a start() function. Check `dashboard health` (host-contract section, service \"subagents\"); mode=\"background\" does not need this service and still works.");
|
|
3074
|
-
if (!parent) throw new Error("handyman foreground: requires a calling agent (exec.agent was undefined)");
|
|
3075
3096
|
const { provider, model: modelName } = splitModel(model);
|
|
3076
3097
|
const run = await subagents.start("spawn", {
|
|
3077
3098
|
...label === void 0 ? {} : { label },
|
|
@@ -3278,6 +3299,7 @@ function createHandymanTool(ctx) {
|
|
|
3278
3299
|
},
|
|
3279
3300
|
async execute(args, exec) {
|
|
3280
3301
|
if (args.guide) return { guide: HANDYMAN_GUIDE };
|
|
3302
|
+
if ((args.mode ?? "foreground") === "foreground" && !exec.agent) throw new Error("handyman foreground: requires a calling agent (exec.agent was undefined)");
|
|
3281
3303
|
const root = findSerenityRoot(agentCwd$6(exec));
|
|
3282
3304
|
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
3283
3305
|
const hc = readHandymanConfig(root, DEFAULT_SERENITY_CONFIG_PATHS);
|
|
@@ -6999,7 +7021,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
6999
7021
|
workspace: url.searchParams.get("workspace") ?? void 0
|
|
7000
7022
|
});
|
|
7001
7023
|
const root = findSerenityRoot(workspace) ?? "";
|
|
7002
|
-
const { discoverCccs } = await import("./skiff-debug-
|
|
7024
|
+
const { discoverCccs } = await import("./skiff-debug-w2c3UqQk.js").then((n) => n.i);
|
|
7003
7025
|
sendJson$2(res, 200, { cccs: await discoverCccs(ctx, root) });
|
|
7004
7026
|
} catch (err) {
|
|
7005
7027
|
sendJson$2(res, 400, { error: err.message ?? String(err) });
|
|
@@ -8628,8 +8650,8 @@ function performRebuild(session, pending, meter) {
|
|
|
8628
8650
|
}, {
|
|
8629
8651
|
surfaceOp: {
|
|
8630
8652
|
op: "replace",
|
|
8631
|
-
|
|
8632
|
-
|
|
8653
|
+
startSeq: nodes[0],
|
|
8654
|
+
endSeq: nodes[nodes.length - 1]
|
|
8633
8655
|
},
|
|
8634
8656
|
sourceEventSeqs: nodes
|
|
8635
8657
|
});
|
|
@@ -10604,7 +10626,7 @@ function matchCcc(input, candidates) {
|
|
|
10604
10626
|
}
|
|
10605
10627
|
/** 组装候选列表(discoverCccs 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
|
|
10606
10628
|
async function collectCandidates(ctx) {
|
|
10607
|
-
const { discoverCccs } = await import("./skiff-debug-
|
|
10629
|
+
const { discoverCccs } = await import("./skiff-debug-w2c3UqQk.js").then((n) => n.i);
|
|
10608
10630
|
const entries = await discoverCccs(ctx, process.cwd());
|
|
10609
10631
|
const seen = /* @__PURE__ */ new Set();
|
|
10610
10632
|
const out = [];
|
|
@@ -11077,6 +11099,270 @@ async function registerWebFetchProvider(ctx) {
|
|
|
11077
11099
|
}
|
|
11078
11100
|
}
|
|
11079
11101
|
//#endregion
|
|
11102
|
+
//#region src/opencode-provider.ts
|
|
11103
|
+
/** `llm-pi-ai` 的 settings 命名空间(宿主 `dsh-llm-pi-ai` 的 `NS`) */
|
|
11104
|
+
const LLM_PI_AI_NAMESPACE = "llm-pi-ai";
|
|
11105
|
+
/**
|
|
11106
|
+
* 本机安装的会话标识(静态;见文件头"已知局限")。
|
|
11107
|
+
*
|
|
11108
|
+
* 为什么用常量而不是随机/uuid:`headers` 只在路由解析时求值一次,随机值等于每次进程启动换一个
|
|
11109
|
+
* 亲和键(缓存永远不命中),反而劣化。常量让"同一安装 → 同一亲和键"成立。
|
|
11110
|
+
*/
|
|
11111
|
+
const OPENCODE_SESSION_ID = "dsh-serenity";
|
|
11112
|
+
/** 视为 opencode 路由的路由名(pi-ai 目录里的两个 id) */
|
|
11113
|
+
const OPENCODE_ROUTE_IDS = ["opencode", "opencode-go"];
|
|
11114
|
+
/** 自动配置时创建的路由名(pi-ai 目录的 Go 面;用户选的是付费 `/zen/go`) */
|
|
11115
|
+
const OPENCODE_ROUTE_TO_CREATE = "opencode-go";
|
|
11116
|
+
/** 触发 L2 建路由的环境变量名(与 pi-ai 目录 provider 的 `envApiKeyAuth` 声明一致) */
|
|
11117
|
+
const OPENCODE_API_KEY_ENV = "OPENCODE_API_KEY";
|
|
11118
|
+
/**
|
|
11119
|
+
* 注入的头("会话族")——每次调用返回新对象(调用方会放进 patch,共享可变对象是坑)。
|
|
11120
|
+
*
|
|
11121
|
+
* 注:同一个值同时给 `x-opencode-session`(pi 与第三方代理用的名字)与 `X-Session-ID`
|
|
11122
|
+
* (OpenCode 自家客户端用的名字)——两处证据各自出现,都发才不会漏。
|
|
11123
|
+
* 头名大小写不敏感是 HTTP 语义,但**名字不同就是两个头**,故都要。
|
|
11124
|
+
*/
|
|
11125
|
+
function opencodeRouteHeaders() {
|
|
11126
|
+
return {
|
|
11127
|
+
"x-opencode-session": OPENCODE_SESSION_ID,
|
|
11128
|
+
"X-Session-ID": OPENCODE_SESSION_ID,
|
|
11129
|
+
"x-opencode-client": "dsh",
|
|
11130
|
+
"x-opencode-project": "global"
|
|
11131
|
+
};
|
|
11132
|
+
}
|
|
11133
|
+
function isPlainObject(value) {
|
|
11134
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11135
|
+
}
|
|
11136
|
+
/** 该路由是否指向 opencode(按路由名,或按 baseURL 主机名——用户可能把路由改名) */
|
|
11137
|
+
function isOpencodeRoute(route, profile) {
|
|
11138
|
+
if (OPENCODE_ROUTE_IDS.includes(route)) return true;
|
|
11139
|
+
const baseURL = profile.baseURL;
|
|
11140
|
+
if (typeof baseURL !== "string" || baseURL === "") return false;
|
|
11141
|
+
try {
|
|
11142
|
+
const host = new URL(baseURL).hostname.toLowerCase();
|
|
11143
|
+
return host === "opencode.ai" || host.endsWith(".opencode.ai");
|
|
11144
|
+
} catch {
|
|
11145
|
+
return false;
|
|
11146
|
+
}
|
|
11147
|
+
}
|
|
11148
|
+
/** HTTP 头名按大小写不敏感查找(HTTP 字段名语义;用户写 `x-title` 与 `X-Title` 等价) */
|
|
11149
|
+
function hasHeaderCaseInsensitive(headers, name) {
|
|
11150
|
+
return headerValue(headers, name) !== void 0;
|
|
11151
|
+
}
|
|
11152
|
+
/** 按大小写不敏感取头值(取不到 → undefined) */
|
|
11153
|
+
function headerValue(headers, name) {
|
|
11154
|
+
const want = name.toLowerCase();
|
|
11155
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === want && typeof v === "string") return v;
|
|
11156
|
+
}
|
|
11157
|
+
/**
|
|
11158
|
+
* 会话标识的两个头名是**同一逻辑值**的两个别名(两处独立证据各用一个:
|
|
11159
|
+
* Go 面 400 点名 `x-opencode-session`,OpenCode 自家客户端用 `X-Session-ID`)。
|
|
11160
|
+
*
|
|
11161
|
+
* 因此判定顺序是"先定值、再补名":用户任写其一 → 用**他的值**补齐另一个;
|
|
11162
|
+
* 两个都没写 → 用本插件常量。**绝不出现两个互相矛盾的 id**(那会让上游按哪个路由都说不清)。
|
|
11163
|
+
*/
|
|
11164
|
+
function sessionIdentityValue(headers) {
|
|
11165
|
+
return headerValue(headers, "x-opencode-session") ?? headerValue(headers, "X-Session-ID") ?? "dsh-serenity";
|
|
11166
|
+
}
|
|
11167
|
+
/**
|
|
11168
|
+
* 计算自动配置计划(**纯函数**:不读文件、不碰 ctx——便于穷举测试)。
|
|
11169
|
+
*
|
|
11170
|
+
* 判据顺序(R↓):
|
|
11171
|
+
* ① 命名空间未注册(`resolved === undefined`)→ idle,调用方负责重试(llm-pi-ai 可能后装载)
|
|
11172
|
+
* ② 逐个已有 opencode 路由:只补缺失的键(用户的显式值一律不动)
|
|
11173
|
+
* ③ 一个 opencode 路由都没有 + 环境有 `OPENCODE_API_KEY` → 建路由(带全头)
|
|
11174
|
+
* ④ 其余 → idle `no-route`(含"没有 key 就不建路由"——不给不用 opencode 的人平白加模型)
|
|
11175
|
+
*/
|
|
11176
|
+
function planOpencodeAutoConfig(input) {
|
|
11177
|
+
const actions = [];
|
|
11178
|
+
if (input.resolved === void 0) return {
|
|
11179
|
+
patch: {},
|
|
11180
|
+
actions: [{
|
|
11181
|
+
kind: "idle",
|
|
11182
|
+
reason: "namespace-unregistered"
|
|
11183
|
+
}]
|
|
11184
|
+
};
|
|
11185
|
+
const providers = isPlainObject(input.resolved) && isPlainObject(input.resolved.providers) ? input.resolved.providers : {};
|
|
11186
|
+
const patchProviders = {};
|
|
11187
|
+
let sawOpencodeRoute = false;
|
|
11188
|
+
for (const [route, rawProfile] of Object.entries(providers)) {
|
|
11189
|
+
const profile = isPlainObject(rawProfile) ? rawProfile : {};
|
|
11190
|
+
if (!isOpencodeRoute(route, profile)) continue;
|
|
11191
|
+
sawOpencodeRoute = true;
|
|
11192
|
+
const existing = isPlainObject(profile.headers) ? profile.headers : {};
|
|
11193
|
+
const sessionId = sessionIdentityValue(existing);
|
|
11194
|
+
const wanted = {
|
|
11195
|
+
...opencodeRouteHeaders(),
|
|
11196
|
+
"x-opencode-session": sessionId,
|
|
11197
|
+
"X-Session-ID": sessionId
|
|
11198
|
+
};
|
|
11199
|
+
const missing = {};
|
|
11200
|
+
for (const [name, value] of Object.entries(wanted)) if (!hasHeaderCaseInsensitive(existing, name)) missing[name] = value;
|
|
11201
|
+
const keys = Object.keys(missing);
|
|
11202
|
+
if (keys.length === 0) {
|
|
11203
|
+
actions.push({
|
|
11204
|
+
kind: "skip-route",
|
|
11205
|
+
route,
|
|
11206
|
+
reason: "headers-complete"
|
|
11207
|
+
});
|
|
11208
|
+
continue;
|
|
11209
|
+
}
|
|
11210
|
+
patchProviders[route] = { headers: missing };
|
|
11211
|
+
actions.push({
|
|
11212
|
+
kind: "fill-headers",
|
|
11213
|
+
route,
|
|
11214
|
+
keys
|
|
11215
|
+
});
|
|
11216
|
+
}
|
|
11217
|
+
if (!sawOpencodeRoute) {
|
|
11218
|
+
const key = input.env[OPENCODE_API_KEY_ENV];
|
|
11219
|
+
if (typeof key !== "string" || key === "") actions.push({
|
|
11220
|
+
kind: "idle",
|
|
11221
|
+
reason: "no-route"
|
|
11222
|
+
});
|
|
11223
|
+
else {
|
|
11224
|
+
patchProviders[OPENCODE_ROUTE_TO_CREATE] = { headers: opencodeRouteHeaders() };
|
|
11225
|
+
actions.push({
|
|
11226
|
+
kind: "create-route",
|
|
11227
|
+
route: OPENCODE_ROUTE_TO_CREATE
|
|
11228
|
+
});
|
|
11229
|
+
}
|
|
11230
|
+
}
|
|
11231
|
+
return {
|
|
11232
|
+
patch: Object.keys(patchProviders).length > 0 ? { providers: patchProviders } : {},
|
|
11233
|
+
actions
|
|
11234
|
+
};
|
|
11235
|
+
}
|
|
11236
|
+
/** 动作 → 单行人读日志(无秘密) */
|
|
11237
|
+
function describeOpencodeAction(action) {
|
|
11238
|
+
switch (action.kind) {
|
|
11239
|
+
case "fill-headers": return `补齐 ${action.route} 的路由头(${action.keys.length} 项:${action.keys.join(", ")})`;
|
|
11240
|
+
case "create-route": return `创建 ${action.route} 路由(检测到 ${OPENCODE_API_KEY_ENV})+ 路由头`;
|
|
11241
|
+
case "skip-route": return `${action.route} 路由头已齐备(跳过)`;
|
|
11242
|
+
case "idle": return `无需动作(${action.reason})`;
|
|
11243
|
+
}
|
|
11244
|
+
}
|
|
11245
|
+
/**
|
|
11246
|
+
* 执行一次自动配置。返回是否**写入了**(调用方据此决定是否停止重试)。
|
|
11247
|
+
*
|
|
11248
|
+
* 失败语义(F-08/F-07 纪律):**任何失败都不抛给宿主**——apply 抛错 = 整个 dsh 启动失败。
|
|
11249
|
+
* 一律 try/catch + 响亮告警(用户能看到"没生效 + 为什么"),最坏情况退回"用户手抄头"。
|
|
11250
|
+
*/
|
|
11251
|
+
async function applyOpencodeAutoConfigOnce(ctx, env) {
|
|
11252
|
+
const settings = hostSettings(ctx);
|
|
11253
|
+
if (settings?.get === void 0 || settings.update === void 0) {
|
|
11254
|
+
console.warn("[serenity-hooks] opencode 路由自动配置跳过:settings 服务不可用(未能读取/写入 llm-pi-ai 命名空间)");
|
|
11255
|
+
return {
|
|
11256
|
+
wrote: false,
|
|
11257
|
+
registered: false
|
|
11258
|
+
};
|
|
11259
|
+
}
|
|
11260
|
+
let resolved;
|
|
11261
|
+
try {
|
|
11262
|
+
resolved = settings.get.call(settings, LLM_PI_AI_NAMESPACE);
|
|
11263
|
+
} catch (error) {
|
|
11264
|
+
console.warn(`[serenity-hooks] opencode 路由自动配置跳过:读取 ${LLM_PI_AI_NAMESPACE} 失败: ${String(error?.message ?? error)}`);
|
|
11265
|
+
return {
|
|
11266
|
+
wrote: false,
|
|
11267
|
+
registered: false
|
|
11268
|
+
};
|
|
11269
|
+
}
|
|
11270
|
+
const plan = planOpencodeAutoConfig({
|
|
11271
|
+
resolved,
|
|
11272
|
+
env: env ?? process.env
|
|
11273
|
+
});
|
|
11274
|
+
if (Object.keys(plan.patch).length === 0) return {
|
|
11275
|
+
wrote: false,
|
|
11276
|
+
registered: resolved !== void 0
|
|
11277
|
+
};
|
|
11278
|
+
try {
|
|
11279
|
+
await settings.update.call(settings, LLM_PI_AI_NAMESPACE, plan.patch);
|
|
11280
|
+
} catch (error) {
|
|
11281
|
+
console.warn(`[serenity-hooks] ✗ opencode 路由自动配置写入被拒: ${String(error?.message ?? error)}(可手动写入 llm-pi-ai.providers.<route>.headers;见本插件 README)`);
|
|
11282
|
+
return {
|
|
11283
|
+
wrote: false,
|
|
11284
|
+
registered: true
|
|
11285
|
+
};
|
|
11286
|
+
}
|
|
11287
|
+
for (const action of plan.actions) {
|
|
11288
|
+
if (action.kind === "idle" || action.kind === "skip-route") continue;
|
|
11289
|
+
console.log(`[serenity-hooks] ✓ opencode 路由:${describeOpencodeAction(action)}`);
|
|
11290
|
+
}
|
|
11291
|
+
return {
|
|
11292
|
+
wrote: true,
|
|
11293
|
+
registered: true
|
|
11294
|
+
};
|
|
11295
|
+
}
|
|
11296
|
+
/**
|
|
11297
|
+
* 命名空间注册重试间隔(毫秒;累计 ~72s)。
|
|
11298
|
+
*
|
|
11299
|
+
* 为什么要重试:`llm-pi-ai` 只在**它自己的** `ctx.inject(['settings'])` 回调里注册命名空间,
|
|
11300
|
+
* 与本插件 apply **无先后保证**(两者都依赖 settings 服务)。首次读取拿到 undefined 是常态而非错误
|
|
11301
|
+
* (与 skiff 的 CCC root 重试同款问题、同款处置:v1.30.13 实证"只在 apply 时试一次 → 永不重试")。
|
|
11302
|
+
*/
|
|
11303
|
+
const NAMESPACE_RETRY_DELAYS_MS = [
|
|
11304
|
+
1e3,
|
|
11305
|
+
3e3,
|
|
11306
|
+
8e3,
|
|
11307
|
+
2e4,
|
|
11308
|
+
4e4
|
|
11309
|
+
];
|
|
11310
|
+
/**
|
|
11311
|
+
* 装配 opencode 路由自动配置。
|
|
11312
|
+
*
|
|
11313
|
+
* 触发面(三层,与 skiff 同款;R↓:单点触发在同装载竞态下必然漏):
|
|
11314
|
+
* ① apply 时立即试一次
|
|
11315
|
+
* ② 命名空间尚未注册 → 退避重试(1s/3s/8s/20s/40s)
|
|
11316
|
+
* ③ `settings/updated` → 再试(用户热改 settings.yaml 加路由时自愈;自身写入触发的复评是幂等空转)
|
|
11317
|
+
*
|
|
11318
|
+
* 不设"一次性完成"闸门:③ 必须能在之后继续生效(用户随时可能新增 opencode 路由)。
|
|
11319
|
+
*/
|
|
11320
|
+
function registerOpencodeAutoConfig(ctx) {
|
|
11321
|
+
let settled = false;
|
|
11322
|
+
let retryTimer = null;
|
|
11323
|
+
let retries = 0;
|
|
11324
|
+
const clearRetry = () => {
|
|
11325
|
+
if (retryTimer !== null) {
|
|
11326
|
+
clearTimeout(retryTimer);
|
|
11327
|
+
retryTimer = null;
|
|
11328
|
+
}
|
|
11329
|
+
};
|
|
11330
|
+
const scheduleRetry = () => {
|
|
11331
|
+
if (retryTimer !== null || settled) return;
|
|
11332
|
+
if (retries >= NAMESPACE_RETRY_DELAYS_MS.length) {
|
|
11333
|
+
console.warn(`[serenity-hooks] opencode 路由自动配置放弃:重试 ${retries} 次仍等不到 ${LLM_PI_AI_NAMESPACE} 命名空间(未使用 pi-ai 适配器时正常;若要手配见 README)`);
|
|
11334
|
+
settled = true;
|
|
11335
|
+
return;
|
|
11336
|
+
}
|
|
11337
|
+
const delay = NAMESPACE_RETRY_DELAYS_MS[retries] ?? 0;
|
|
11338
|
+
retryTimer = setTimeout(() => {
|
|
11339
|
+
retryTimer = null;
|
|
11340
|
+
retries += 1;
|
|
11341
|
+
sync();
|
|
11342
|
+
}, delay);
|
|
11343
|
+
retryTimer.unref?.();
|
|
11344
|
+
};
|
|
11345
|
+
function sync() {
|
|
11346
|
+
applyOpencodeAutoConfigOnce(ctx).then((result) => {
|
|
11347
|
+
if (result.registered) {
|
|
11348
|
+
settled = true;
|
|
11349
|
+
clearRetry();
|
|
11350
|
+
} else if (!settled) scheduleRetry();
|
|
11351
|
+
}).catch((error) => {
|
|
11352
|
+
console.warn(`[serenity-hooks] opencode 路由自动配置异常: ${String(error?.message ?? error)}`);
|
|
11353
|
+
settled = true;
|
|
11354
|
+
clearRetry();
|
|
11355
|
+
});
|
|
11356
|
+
}
|
|
11357
|
+
try {
|
|
11358
|
+
ctx.on?.call(ctx, "settings/updated", (ns) => {
|
|
11359
|
+
if (ns === "llm-pi-ai") sync();
|
|
11360
|
+
});
|
|
11361
|
+
} catch {}
|
|
11362
|
+
sync();
|
|
11363
|
+
registerDisposer(ctx, "opencode provider auto-config retry timer", clearRetry);
|
|
11364
|
+
}
|
|
11365
|
+
//#endregion
|
|
11080
11366
|
//#region src/im-weixin.ts
|
|
11081
11367
|
/**
|
|
11082
11368
|
* im-weixin.ts — 微信通道实现(`im-bridge` 的通道之一,v1.31.0)
|
|
@@ -11356,7 +11642,8 @@ const Config = z.object({
|
|
|
11356
11642
|
enabled: z.boolean().default(false),
|
|
11357
11643
|
httpPort: z.number().min(1024).max(65535).default(3100)
|
|
11358
11644
|
}),
|
|
11359
|
-
webFetch: z.object({ enabled: z.boolean().default(true) })
|
|
11645
|
+
webFetch: z.object({ enabled: z.boolean().default(true) }),
|
|
11646
|
+
opencodeProvider: z.object({ autoConfigure: z.boolean().default(true) })
|
|
11360
11647
|
});
|
|
11361
11648
|
function apply(ctx, config) {
|
|
11362
11649
|
try {
|
|
@@ -11407,6 +11694,7 @@ function apply(ctx, config) {
|
|
|
11407
11694
|
registerWeixinOutputGuard(ctx);
|
|
11408
11695
|
registerLifecycle(ctx);
|
|
11409
11696
|
if (config.webFetch?.enabled !== false) registerWebFetchProvider(ctx);
|
|
11697
|
+
if (config.opencodeProvider?.autoConfigure !== false) registerOpencodeAutoConfig(ctx);
|
|
11410
11698
|
}
|
|
11411
11699
|
/**
|
|
11412
11700
|
* F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-provider.ts — opencode zen / go 路由的自动配置(v1.31.7,S142 用户需求)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么存在(R↓)
|
|
5
|
+
*
|
|
6
|
+
* opencode 的 zen / go 面按**请求头**做会话亲和路由:缺 `x-opencode-session` 时
|
|
7
|
+
* `/zen/go` 直接 400 `Request is missing x-opencode-session and cannot be routed efficiently`。
|
|
8
|
+
* 而 DSH 用的 **pi-ai 库本身不发这些头**(`@earendil-works/pi-ai@0.85.1` 全库 `x-opencode` 零命中;
|
|
9
|
+
* pi 的 CLI 是**应用层**自己在 `packages/coding-agent/src/core/sdk.ts` 加的)。
|
|
10
|
+
* → 用户要在 DSH 里用 opencode,除了配路由还得手抄一串头。本模块把这步自动化。
|
|
11
|
+
*
|
|
12
|
+
* ## 两层(S142 用户 2026-09-10 拍板:两层默认都开)
|
|
13
|
+
*
|
|
14
|
+
* - **L1 补头**:路由已存在(路由名 ∈ opencode/opencode-go,或 baseURL 命中 opencode.ai)且缺头
|
|
15
|
+
* → 只补**缺失的键**。已存在的键(用户手写的)**一律不覆盖**——头名按 HTTP 语义**大小写不敏感**比较。
|
|
16
|
+
* - **L2 建路由**:环境里有 `OPENCODE_API_KEY` 且没有任何 opencode 路由 → 建 `opencode-go` 并带全头。
|
|
17
|
+
* “有 key”就是用户意图的最强证据 → 不需要的人(没 key)零影响(不会平白多出一组模型)。
|
|
18
|
+
*
|
|
19
|
+
* ## 注入通道(唯一可用且是官方的)
|
|
20
|
+
*
|
|
21
|
+
* `ctx.settings.update('llm-pi-ai', patch)` —— `dsh-settings` 的官方写入面:
|
|
22
|
+
* ① 深合并(`mergeLayers` 逐层递归,数组整体替换)→ 不会冲掉用户别的路由
|
|
23
|
+
* ② **先校验后落盘**(pi-ai 注册时给的 `validate`)→ 坏补丁拒绝且不持久化
|
|
24
|
+
* ③ `dsh-settings-file` 的 `writable` 恒 true → 写入用户 settings 文档(0600,用户可读可删)
|
|
25
|
+
*
|
|
26
|
+
* 被否掉的备选:① 本插件 `cordis.patch.yml` 直接改 `llm-pi-ai` 的 config —— 宿主 patch 语义是
|
|
27
|
+
* **整体替换 config(非深合并)**,会冲掉部署方在该条目上配的其它 provider;且无法按条件启用。
|
|
28
|
+
* ② 让用户手抄 —— 本次要消灭的正是这一步。
|
|
29
|
+
*
|
|
30
|
+
* ## 只注入"会话族",**不注入身份族**(诚实边界,用户选了付费 /zen/go)
|
|
31
|
+
*
|
|
32
|
+
* - 注入:`x-opencode-session` / `X-Session-ID`(同一个稳定的本机安装标识)、
|
|
33
|
+
* `x-opencode-client: dsh`、`x-opencode-project: global`
|
|
34
|
+
* (证据:Trae 的 Go 面代理注入的正是这一族,且该项目 README 明列 "dsh 亲测有效")
|
|
35
|
+
* - **不注入** `X-Title: opencode` / `HTTP-Referer: https://opencode.ai/` —— 那是**冒充 OpenCode 客户端**,
|
|
36
|
+
* 只在免费档的**滥用判别**中起作用(hermes #106495 实证)。冒充他人客户端去绕限额不是本插件该做的事。
|
|
37
|
+
* - **不注入** `User-Agent` —— 它是 `@deepseek-ai/dsh-llm` 的 attribution 保留名
|
|
38
|
+
* (`APP_IDENTITY` 无配置缝),宿主层面就不可覆盖。
|
|
39
|
+
* - **不注入** `x-opencode-request` —— 它的语义是"会话内递增"(`msg_1`/`msg_2`…),
|
|
40
|
+
* 本模块只能给**静态**值,写个假的递增 id 比不写更糟。
|
|
41
|
+
*
|
|
42
|
+
* ## 已知局限(必须显式,不粉饰)
|
|
43
|
+
*
|
|
44
|
+
* DSH 的 provider `headers` 在路由解析时**一次性求值**(无 per-request 模板缝;pi-ai 支持
|
|
45
|
+
* `transformHeaders` 但 DSH 适配器未用)→ 会话标识只能是**静态**的。代价 = 跨会话共用同一亲和键:
|
|
46
|
+
* 对提示词缓存是利好(同机器请求恒定落同一上游 provider),对"按会话分区"是语义偏差。
|
|
47
|
+
* pi issue #4847 的报告者也点出该 workaround 的固有缺陷("bound to the installation")。
|
|
48
|
+
*/
|
|
49
|
+
import type { Context } from 'cordis';
|
|
50
|
+
/** `llm-pi-ai` 的 settings 命名空间(宿主 `dsh-llm-pi-ai` 的 `NS`) */
|
|
51
|
+
export declare const LLM_PI_AI_NAMESPACE = "llm-pi-ai";
|
|
52
|
+
/**
|
|
53
|
+
* 本机安装的会话标识(静态;见文件头"已知局限")。
|
|
54
|
+
*
|
|
55
|
+
* 为什么用常量而不是随机/uuid:`headers` 只在路由解析时求值一次,随机值等于每次进程启动换一个
|
|
56
|
+
* 亲和键(缓存永远不命中),反而劣化。常量让"同一安装 → 同一亲和键"成立。
|
|
57
|
+
*/
|
|
58
|
+
export declare const OPENCODE_SESSION_ID = "dsh-serenity";
|
|
59
|
+
/** 视为 opencode 路由的路由名(pi-ai 目录里的两个 id) */
|
|
60
|
+
export declare const OPENCODE_ROUTE_IDS: readonly string[];
|
|
61
|
+
/** 自动配置时创建的路由名(pi-ai 目录的 Go 面;用户选的是付费 `/zen/go`) */
|
|
62
|
+
export declare const OPENCODE_ROUTE_TO_CREATE = "opencode-go";
|
|
63
|
+
/** 触发 L2 建路由的环境变量名(与 pi-ai 目录 provider 的 `envApiKeyAuth` 声明一致) */
|
|
64
|
+
export declare const OPENCODE_API_KEY_ENV = "OPENCODE_API_KEY";
|
|
65
|
+
/**
|
|
66
|
+
* 注入的头("会话族")——每次调用返回新对象(调用方会放进 patch,共享可变对象是坑)。
|
|
67
|
+
*
|
|
68
|
+
* 注:同一个值同时给 `x-opencode-session`(pi 与第三方代理用的名字)与 `X-Session-ID`
|
|
69
|
+
* (OpenCode 自家客户端用的名字)——两处证据各自出现,都发才不会漏。
|
|
70
|
+
* 头名大小写不敏感是 HTTP 语义,但**名字不同就是两个头**,故都要。
|
|
71
|
+
*/
|
|
72
|
+
export declare function opencodeRouteHeaders(): Record<string, string>;
|
|
73
|
+
export interface OpencodeAutoConfigInput {
|
|
74
|
+
/** `llm-pi-ai` 命名空间**解析后**的值(`settings.get` 结果;undefined = 命名空间未注册) */
|
|
75
|
+
resolved: unknown;
|
|
76
|
+
/** 进程环境(注入以便测试) */
|
|
77
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
78
|
+
}
|
|
79
|
+
/** 一条动作记录(日志 + 测试断言共用;不返回"秘密"、只返回做了什么) */
|
|
80
|
+
export type OpencodeAutoConfigAction = {
|
|
81
|
+
kind: 'fill-headers';
|
|
82
|
+
route: string;
|
|
83
|
+
keys: readonly string[];
|
|
84
|
+
} | {
|
|
85
|
+
kind: 'create-route';
|
|
86
|
+
route: string;
|
|
87
|
+
} | {
|
|
88
|
+
kind: 'skip-route';
|
|
89
|
+
route: string;
|
|
90
|
+
reason: 'headers-complete';
|
|
91
|
+
} | {
|
|
92
|
+
kind: 'idle';
|
|
93
|
+
reason: 'namespace-unregistered' | 'no-route';
|
|
94
|
+
};
|
|
95
|
+
export interface OpencodeAutoConfigPlan {
|
|
96
|
+
/** 传给 `settings.update` 的补丁;空对象 = 无事可做(**不写**,避免无谓触发 settings/updated) */
|
|
97
|
+
patch: Record<string, unknown>;
|
|
98
|
+
actions: readonly OpencodeAutoConfigAction[];
|
|
99
|
+
}
|
|
100
|
+
/** 该路由是否指向 opencode(按路由名,或按 baseURL 主机名——用户可能把路由改名) */
|
|
101
|
+
export declare function isOpencodeRoute(route: string, profile: Record<string, unknown>): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* 计算自动配置计划(**纯函数**:不读文件、不碰 ctx——便于穷举测试)。
|
|
104
|
+
*
|
|
105
|
+
* 判据顺序(R↓):
|
|
106
|
+
* ① 命名空间未注册(`resolved === undefined`)→ idle,调用方负责重试(llm-pi-ai 可能后装载)
|
|
107
|
+
* ② 逐个已有 opencode 路由:只补缺失的键(用户的显式值一律不动)
|
|
108
|
+
* ③ 一个 opencode 路由都没有 + 环境有 `OPENCODE_API_KEY` → 建路由(带全头)
|
|
109
|
+
* ④ 其余 → idle `no-route`(含"没有 key 就不建路由"——不给不用 opencode 的人平白加模型)
|
|
110
|
+
*/
|
|
111
|
+
export declare function planOpencodeAutoConfig(input: OpencodeAutoConfigInput): OpencodeAutoConfigPlan;
|
|
112
|
+
/** 动作 → 单行人读日志(无秘密) */
|
|
113
|
+
export declare function describeOpencodeAction(action: OpencodeAutoConfigAction): string;
|
|
114
|
+
/**
|
|
115
|
+
* 执行一次自动配置。返回是否**写入了**(调用方据此决定是否停止重试)。
|
|
116
|
+
*
|
|
117
|
+
* 失败语义(F-08/F-07 纪律):**任何失败都不抛给宿主**——apply 抛错 = 整个 dsh 启动失败。
|
|
118
|
+
* 一律 try/catch + 响亮告警(用户能看到"没生效 + 为什么"),最坏情况退回"用户手抄头"。
|
|
119
|
+
*/
|
|
120
|
+
export declare function applyOpencodeAutoConfigOnce(ctx: Context, env?: Readonly<Record<string, string | undefined>>): Promise<{
|
|
121
|
+
wrote: boolean;
|
|
122
|
+
registered: boolean;
|
|
123
|
+
}>;
|
|
124
|
+
/**
|
|
125
|
+
* 装配 opencode 路由自动配置。
|
|
126
|
+
*
|
|
127
|
+
* 触发面(三层,与 skiff 同款;R↓:单点触发在同装载竞态下必然漏):
|
|
128
|
+
* ① apply 时立即试一次
|
|
129
|
+
* ② 命名空间尚未注册 → 退避重试(1s/3s/8s/20s/40s)
|
|
130
|
+
* ③ `settings/updated` → 再试(用户热改 settings.yaml 加路由时自愈;自身写入触发的复评是幂等空转)
|
|
131
|
+
*
|
|
132
|
+
* 不设"一次性完成"闸门:③ 必须能在之后继续生效(用户随时可能新增 opencode 路由)。
|
|
133
|
+
*/
|
|
134
|
+
export declare function registerOpencodeAutoConfig(ctx: Context): void;
|
|
@@ -2445,7 +2445,7 @@ async function discoverCccs(ctx, defaultRoot) {
|
|
|
2445
2445
|
} catch {}
|
|
2446
2446
|
if (roots.length === 0) try {
|
|
2447
2447
|
const sp = hostService(ctx, "sessionPersistence");
|
|
2448
|
-
for (const h of await sp?.list?.() ?? []) pushRoot(h?.cwd);
|
|
2448
|
+
for (const h of await sp?.list?.() ?? []) pushRoot(h?.header?.cwd);
|
|
2449
2449
|
} catch {}
|
|
2450
2450
|
if (roots.length === 0) try {
|
|
2451
2451
|
const sessions = hostSessions(ctx);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shgroup/dsh-serenity-hooks",
|
|
3
|
-
"version": "1.31.
|
|
4
|
-
"description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/logbook/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/autopilot-trajectory/im-bridge)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/自主巡航。适配 DSH 0.1.
|
|
3
|
+
"version": "1.31.8",
|
|
4
|
+
"description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/logbook/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/autopilot-trajectory/im-bridge)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/自主巡航。适配 DSH 0.1.5-rc.1。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -52,24 +52,24 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
56
|
-
"@deepseek-ai/dsh-agent-loop": "^0.1.
|
|
57
|
-
"@deepseek-ai/dsh-agent-presets": "^0.1.
|
|
58
|
-
"@deepseek-ai/dsh-compaction": "^0.1.
|
|
59
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
60
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
61
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
62
|
-
"@deepseek-ai/dsh-session-title": "^0.1.
|
|
63
|
-
"@deepseek-ai/dsh-shell": "^0.1.
|
|
64
|
-
"@deepseek-ai/dsh-shell-env": "^0.1.
|
|
65
|
-
"@deepseek-ai/dsh-skill": "^0.1.
|
|
66
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.
|
|
67
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
68
|
-
"@deepseek-ai/dsh-web": "^0.1.
|
|
69
|
-
"@deepseek-ai/dsh-web-fetch-http": "^0.1.
|
|
70
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
71
|
-
"cordis": "^4.0.
|
|
72
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
55
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-agent-loop": "^0.1.5-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-compaction": "^0.1.5-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-session": "^0.1.5-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-session-title": "^0.1.5-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-shell": "^0.1.5-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-shell-env": "^0.1.5-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-skill": "^0.1.5-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-web": "^0.1.5-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-web-fetch-http": "^0.1.5-rc.1",
|
|
70
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
71
|
+
"cordis": "^4.0.2",
|
|
72
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
|
75
75
|
"@deepseek-ai/dsh-host-webserver": {
|