dsh-remote-bridge 2.4.2 → 2.4.3
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.en.md +25 -8
- package/README.md +19 -8
- package/cordis.patch.yml +32 -1
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +163 -1
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +101 -0
- package/lib/index.d.ts +205 -0
- package/lib/index.js +377 -5
- package/package.json +3 -2
package/README.en.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
|
-
<img alt="Version" src="https://img.shields.io/badge/version-v2.4.
|
|
12
|
+
<img alt="Version" src="https://img.shields.io/badge/version-v2.4.3-087f8c">
|
|
13
13
|
<img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
|
|
14
14
|
<img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
|
|
15
15
|
<img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
|
|
@@ -27,6 +27,18 @@ The interface is bilingual and defaults to Chinese. Prebuilt packages run on Win
|
|
|
27
27
|
> with `dsh-api-gateway`, leaving the upstream release unable to start on 0.2.x — it surfaces as
|
|
28
28
|
> "adding an authorized root returns 405". Background, rationale, changes, and test results are in
|
|
29
29
|
> [DSH 0.2.x compatibility notes](./docs/project/DSH-0.2-COMPAT.md).
|
|
30
|
+
>
|
|
31
|
+
> **Adaptation to DSH 0.2.1-rc.2** (rechecked 2026-10-20; see `docs/project/DSH-0.2.1-ADAPTATION.md`):
|
|
32
|
+
> this plugin **deliberately imports no `@deepseek-ai/*` package** — every host service is consumed
|
|
33
|
+
> through a structural type injected by the running Host (see the header comment in
|
|
34
|
+
> `src/host/dsh-0.2-bridge.ts`). Checked one by one against the installed `0.2.1-rc.2` packages:
|
|
35
|
+
> all 10 `inject` service names in `cordis.patch.yml` **still exist with unchanged signatures**, and
|
|
36
|
+
> `engines.dsh` is recognized by the 0.2.1 kernel (`prepareProfileEntries` in
|
|
37
|
+
> `dsh-app-boot/lib/index.js`) — an unmet range only sets that entry to `disabled` and prints to
|
|
38
|
+
> stderr; **the host process does not exit**. So no code change is needed to run on `0.2.1-rc.2`.
|
|
39
|
+
> The only point still needing on-device confirmation is the injection surface of
|
|
40
|
+
> `permissionPresets` (never injectable on 0.2.0 either, where the plugin already falls back to a
|
|
41
|
+
> built-in catalog).
|
|
30
42
|
|
|
31
43
|
## What it looks like
|
|
32
44
|
|
|
@@ -63,7 +75,12 @@ So seeing `dsh-workspace` in a REST address or the state directory is **expected
|
|
|
63
75
|
### Requirements
|
|
64
76
|
|
|
65
77
|
- A working DeepSeek Harness WebUI installation
|
|
66
|
-
- DSH kernel
|
|
78
|
+
- DSH kernel **`>=0.2.0-rc.2 <0.3.0`** (compatible with `0.2.0-rc.2` and every later `0.2.x`, including
|
|
79
|
+
`0.2.1-rc.2`; supported by this branch's compatibility layer — see
|
|
80
|
+
[DSH 0.2.x compatibility notes](./docs/project/DSH-0.2-COMPAT.md)).
|
|
81
|
+
`package.json` declares the same range under `engines.dsh`; the kernel resolves it with semver and
|
|
82
|
+
only **disables** the plugin when unmet (the host process keeps running), with the user able to
|
|
83
|
+
grant an exact-version exemption as the kernel suggests.
|
|
67
84
|
- Node.js `^22.19.0` or `>=24.0.0`
|
|
68
85
|
- Port `3090`, or another available port
|
|
69
86
|
|
|
@@ -77,10 +94,10 @@ dsh plugin --profile web add dsh-remote-bridge
|
|
|
77
94
|
dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
|
|
78
95
|
|
|
79
96
|
# 2. tarball from the GitHub release
|
|
80
|
-
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.4.
|
|
97
|
+
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.4.3/dsh-remote-bridge-v2.4.3.tgz
|
|
81
98
|
|
|
82
99
|
# 3. tarball from the Gitee release (faster from mainland China)
|
|
83
|
-
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.4.
|
|
100
|
+
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.4.3/dsh-remote-bridge-v2.4.3.tgz
|
|
84
101
|
|
|
85
102
|
npx @deepseek-ai/dsh web
|
|
86
103
|
```
|
|
@@ -97,7 +114,7 @@ Restart DSH after installation, then open settings — The section is titled **
|
|
|
97
114
|
To install a downloaded release archive:
|
|
98
115
|
|
|
99
116
|
```sh
|
|
100
|
-
dsh plugin --profile web add ./dsh-remote-bridge-v2.4.
|
|
117
|
+
dsh plugin --profile web add ./dsh-remote-bridge-v2.4.3.tgz
|
|
101
118
|
```
|
|
102
119
|
|
|
103
120
|
## First connection
|
|
@@ -329,13 +346,13 @@ pnpm pack
|
|
|
329
346
|
|
|
330
347
|
`pnpm check` covers type-checking, documentation consistency, and tests. It currently passes **30 test files / 226 cases**, plus **4 browser-layer cases** (Playwright measuring real computed styles).
|
|
331
348
|
|
|
332
|
-
`npm pack` produces roughly a **0.
|
|
349
|
+
`npm pack` produces roughly a **0.55 MB** archive (v2.4.3 measured 572,951 bytes, SHA-256 `38f25c10…299c93`): 2.2.0 dropped the standalone page and its editor dependencies and turned source maps off, down from 1.72 MB; it has since grown slightly with projection passthrough and the permission BFF.
|
|
333
350
|
|
|
334
|
-
v2.4.
|
|
351
|
+
v2.4.3 was verified on DSH **0.2.1-rc.2** on real hardware: all 14/14 host services injected, the three jobs endpoints exercised, and the permission catalog returned the kernel's real three presets. The development baseline is `master@47f943859bef60e4160492346772ded9b24f765a` (kernel 0.2.0-rc.2); see `engines.dsh` above for the supported range. CI covers Windows, Ubuntu, and macOS.
|
|
335
352
|
|
|
336
353
|
## Project
|
|
337
354
|
|
|
338
|
-
- Version: `v2.4.
|
|
355
|
+
- Version: `v2.4.3`
|
|
339
356
|
- Author: upstream [Hakunm](https://github.com/Hakunm); this fork maintained by [westanke](https://github.com/westanke)
|
|
340
357
|
- Repository: [GitHub](https://github.com/westanke/dsh-remote-bridge) · [Gitee](https://gitee.com/westanke/dsh-remote-bridge)
|
|
341
358
|
- npm: [`dsh-remote-bridge`](https://www.npmjs.com/package/dsh-remote-bridge) ([mainland-China mirror](https://registry.npmmirror.com/dsh-remote-bridge))
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
|
-
<img alt="Version" src="https://img.shields.io/badge/version-v2.4.
|
|
12
|
+
<img alt="Version" src="https://img.shields.io/badge/version-v2.4.3-087f8c">
|
|
13
13
|
<img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
|
|
14
14
|
<img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
|
|
15
15
|
<img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
> 0.2.x 用 `dsh-api-gateway` 替换了提供 `apiProxy` 服务的 `dsh-host-apiproxy`,导致上游版本在
|
|
26
26
|
> 0.2.x 上无法启动(表现为「添加根目录返回 405」)。兼容层的完整说明——基于什么、为什么、改了什么、
|
|
27
27
|
> 测了什么——见 [DSH 0.2.x 兼容性适配说明](./docs/project/DSH-0.2-COMPAT.md)。
|
|
28
|
+
>
|
|
29
|
+
> **对 DSH 0.2.1-rc.2 的适配结论**(2026-10-20 复核,依据 `/media/wangke/OFFICE/workspace/dsh-remote-bridge/docs/project/DSH-0.2.1-ADAPTATION.md`):
|
|
30
|
+
> 本插件**刻意不 import 任何 `@deepseek-ai/*` 包**,全部宿主服务经结构类型 + Host 注入消费
|
|
31
|
+
> (见 `src/host/dsh-0.2-bridge.ts` 头注释)。逐一到 `0.2.1-rc.2` 实装包核对:`cordis.patch.yml`
|
|
32
|
+
> 的 10 项 `inject` 服务名**全部仍存在且签名未变**,`engines.dsh` 在 0.2.1 被内核识别
|
|
33
|
+
> (`dsh-app-boot/lib/index.js` 的 `prepareProfileEntries`),不满足时只把该 entry 置
|
|
34
|
+
> `disabled` 并打 stderr,**宿主进程不退出**。故本插件无需改码即可运行于 `0.2.1-rc.2`。
|
|
35
|
+
> 唯一需真机定论的是 `permissionPresets` 的注入面(0.2.0 上即注不进,已降级到内置目录)。
|
|
28
36
|
|
|
29
37
|
## 实际效果
|
|
30
38
|
|
|
@@ -62,7 +70,8 @@
|
|
|
62
70
|
### 环境要求
|
|
63
71
|
|
|
64
72
|
- 已安装并能正常启动的 DeepSeek Harness WebUI
|
|
65
|
-
- DSH 内核 `0.
|
|
73
|
+
- DSH 内核 **`>=0.2.0-rc.2 <0.3.0`**(兼容 `0.2.0-rc.2` 及其后全部 `0.2.x`,含 `0.2.1-rc.2`;由本分支的兼容层支持,见 [DSH 0.2.x 兼容性适配说明](./docs/project/DSH-0.2-COMPAT.md))。
|
|
74
|
+
`package.json` 的 `engines.dsh` 已声明同一范围 —— 内核按 semver 裁决,不满足时该插件被**禁用**(宿主进程不退出),用户可按内核提示授予精确版本豁免。
|
|
66
75
|
- Node.js `^22.19.0` 或 `>=24.0.0`
|
|
67
76
|
- 默认端口 `3090` 可用,或准备一个其他端口
|
|
68
77
|
|
|
@@ -76,10 +85,10 @@ dsh plugin --profile web add dsh-remote-bridge
|
|
|
76
85
|
dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
|
|
77
86
|
|
|
78
87
|
# ② GitHub Release 的 tarball
|
|
79
|
-
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.4.
|
|
88
|
+
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.4.3/dsh-remote-bridge-v2.4.3.tgz
|
|
80
89
|
|
|
81
90
|
# ③ Gitee Release 的 tarball(国内访问更快)
|
|
82
|
-
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.4.
|
|
91
|
+
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.4.3/dsh-remote-bridge-v2.4.3.tgz
|
|
83
92
|
|
|
84
93
|
npx @deepseek-ai/dsh web
|
|
85
94
|
```
|
|
@@ -95,7 +104,7 @@ npx @deepseek-ai/dsh web
|
|
|
95
104
|
从 GitHub Release 下载离线安装包时,可直接安装 tarball:
|
|
96
105
|
|
|
97
106
|
```sh
|
|
98
|
-
dsh plugin --profile web add ./dsh-remote-bridge-v2.4.
|
|
107
|
+
dsh plugin --profile web add ./dsh-remote-bridge-v2.4.3.tgz
|
|
99
108
|
```
|
|
100
109
|
|
|
101
110
|
## 第一次使用
|
|
@@ -323,13 +332,15 @@ pnpm pack
|
|
|
323
332
|
|
|
324
333
|
`pnpm check` 覆盖类型检查、文档一致性与测试;当前 **30 个测试文件 / 226 个用例**,外加 **4 个浏览器层用例**(Playwright 实测量出的计算样式),全部通过。
|
|
325
334
|
|
|
326
|
-
`npm pack` 的产物约 **0.
|
|
335
|
+
`npm pack` 的产物约 **0.55 MB**(v2.4.3 实测 572,951 字节,SHA-256 `38f25c10…299c93`):2.2.0 删除独立页及其编辑器依赖、并关闭 source map 之后从 1.72 MB 降下来,之后随投影透传与权限 BFF 小幅增长。
|
|
327
336
|
|
|
328
|
-
v2.4.
|
|
337
|
+
v2.4.3 在 DSH **0.2.1-rc.2** 上完成真机验证:14/14 宿主服务全部可注入、三个 jobs 端点实测通过、
|
|
338
|
+
权限档位目录返回内核真实三档。开发基线为 `master@47f943859bef60e4160492346772ded9b24f765a`(内核 0.2.0-rc.2),
|
|
339
|
+
兼容范围见上文 `engines.dsh`。CI 覆盖 Windows、Ubuntu 和 macOS。
|
|
329
340
|
|
|
330
341
|
## 项目信息
|
|
331
342
|
|
|
332
|
-
- 当前版本:`v2.4.
|
|
343
|
+
- 当前版本:`v2.4.3`
|
|
333
344
|
- 作者:上游 [Hakunm](https://github.com/Hakunm),本 fork 维护 [westanke](https://github.com/westanke)
|
|
334
345
|
- 仓库:[GitHub](https://github.com/westanke/dsh-remote-bridge) · [Gitee](https://gitee.com/westanke/dsh-remote-bridge)
|
|
335
346
|
- npm:[`dsh-remote-bridge`](https://www.npmjs.com/package/dsh-remote-bridge)([国内镜像](https://registry.npmmirror.com/dsh-remote-bridge))
|
package/cordis.patch.yml
CHANGED
|
@@ -6,7 +6,38 @@
|
|
|
6
6
|
# DSH 0.2.x host services (see src/host/dsh-0.2-bridge.ts). The pre-0.2
|
|
7
7
|
# `apiProxy` facade is rebuilt from these; `@deepseek-ai/dsh-host-apiproxy`
|
|
8
8
|
# stopped shipping at 0.1.1-rc.2 and nothing provides `apiProxy` after it.
|
|
9
|
-
|
|
9
|
+
#
|
|
10
|
+
# ⚠️ 这份列表**必须**与 `src/index.ts` 导出的 `inject` 完全一致。
|
|
11
|
+
# 原因(2026-10-20 实测,代价是三次重启):patch 里的 `inject` **覆盖**
|
|
12
|
+
# 插件自己导出的 `inject`,不是取并集。于是插件声明了、这里漏了的服务
|
|
13
|
+
# 读起来会报 `cannot get property "x" without inject` ——
|
|
14
|
+
# 与「内核压根没有这个服务」的报错**一模一样**,肉眼分不出来。
|
|
15
|
+
#
|
|
16
|
+
# 这一条曾长期掩盖真相:`permissionPresets` 一直以为「web profile 注不进、
|
|
17
|
+
# 只能走内置兜底目录」,实际是这里没声明它。`jobController` 加进来之后
|
|
18
|
+
# 同样注不进,才顺着 `_refresh()` 的 INACTIVE 逻辑反查到这条覆盖规则。
|
|
19
|
+
inject: [
|
|
20
|
+
sessionController,
|
|
21
|
+
workspaceController,
|
|
22
|
+
workspaceRegistry,
|
|
23
|
+
settingsController,
|
|
24
|
+
credentialsController,
|
|
25
|
+
agentPresets,
|
|
26
|
+
# 权限档位目录(`ctx.permissionPresets`):内核把它做成独立的 Typert
|
|
27
|
+
# 远程服务,会话投影那条 wire 视图只给 currentValue、不带候选项。
|
|
28
|
+
permissionPresets,
|
|
29
|
+
llm,
|
|
30
|
+
webServer,
|
|
31
|
+
agents,
|
|
32
|
+
commands,
|
|
33
|
+
# 后台任务(`ctx.jobController`):远程场景下任务跑飞了要能停掉。
|
|
34
|
+
jobController,
|
|
35
|
+
# 文件上传服务(`ctx.fileUploads`):用它而不是内核那条只绑 loopback 的
|
|
36
|
+
# /api/session/uploadFileBinary HTTP 路由。
|
|
37
|
+
fileUploads,
|
|
38
|
+
# 附件存储(`ctx.attachments`):会话历史里的图片以 attachmentId 引用存在。
|
|
39
|
+
attachments,
|
|
40
|
+
]
|
|
10
41
|
config:
|
|
11
42
|
# 状态目录**不改名**:已装的机器上有既有数据库与回收站,换目录等于让用户
|
|
12
43
|
# 的授权根配置和回收站全部消失。改名只动「装的时候叫什么、bundle 叫什么」。
|
package/docs/api/asyncapi.yaml
CHANGED
package/docs/api/openapi.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: DeepSeek Harness Mobile API
|
|
4
|
-
version: 2.4.
|
|
4
|
+
version: 2.4.3
|
|
5
5
|
description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
|
|
6
6
|
servers:
|
|
7
7
|
- url: http://127.0.0.1:3090/api/v1
|
|
@@ -25,6 +25,62 @@ paths:
|
|
|
25
25
|
ok: { type: boolean, const: true }
|
|
26
26
|
version: { type: string, const: v1 }
|
|
27
27
|
pluginVersion: { type: string, const: 2.4.2 }
|
|
28
|
+
/debug/host-services:
|
|
29
|
+
get:
|
|
30
|
+
operationId: hostServices
|
|
31
|
+
summary: Host service injection probe
|
|
32
|
+
description: |
|
|
33
|
+
Reports which DSH host services the plugin actually resolved on the running
|
|
34
|
+
kernel. This plugin deliberately imports no `@deepseek-ai/*` package and
|
|
35
|
+
consumes every host service through a structural type injected by the Host,
|
|
36
|
+
so "is the service there at all" is otherwise unanswerable without guessing.
|
|
37
|
+
|
|
38
|
+
`injected` covers the services named in the plugin's `inject` declaration;
|
|
39
|
+
`observed` covers services confirmed present in the web profile but not yet
|
|
40
|
+
consumed (currently only `workingDirectory`, added by DSH 0.2.1).
|
|
41
|
+
|
|
42
|
+
Requires a device token or loopback admin, like every other authenticated
|
|
43
|
+
endpoint: it exposes host internals.
|
|
44
|
+
responses:
|
|
45
|
+
'200':
|
|
46
|
+
description: Injection probe snapshot taken while the plugin applied.
|
|
47
|
+
content:
|
|
48
|
+
application/json:
|
|
49
|
+
schema:
|
|
50
|
+
type: object
|
|
51
|
+
required: [pluginVersion, injected, observed, summary]
|
|
52
|
+
properties:
|
|
53
|
+
pluginVersion: { type: string }
|
|
54
|
+
injected:
|
|
55
|
+
type: array
|
|
56
|
+
items:
|
|
57
|
+
type: object
|
|
58
|
+
required: [name, available, resolvable]
|
|
59
|
+
properties:
|
|
60
|
+
name: { type: string }
|
|
61
|
+
available: { type: boolean }
|
|
62
|
+
resolvable: { type: boolean }
|
|
63
|
+
error: { type: string }
|
|
64
|
+
observed:
|
|
65
|
+
type: array
|
|
66
|
+
items:
|
|
67
|
+
type: object
|
|
68
|
+
required: [name, available, resolvable]
|
|
69
|
+
properties:
|
|
70
|
+
name: { type: string }
|
|
71
|
+
available: { type: boolean }
|
|
72
|
+
resolvable: { type: boolean }
|
|
73
|
+
error: { type: string }
|
|
74
|
+
summary:
|
|
75
|
+
type: object
|
|
76
|
+
required: [injectedTotal, injectedAvailable, injectedMissing, observedAvailable]
|
|
77
|
+
properties:
|
|
78
|
+
injectedTotal: { type: integer }
|
|
79
|
+
injectedAvailable: { type: integer }
|
|
80
|
+
injectedMissing: { type: array, items: { type: string } }
|
|
81
|
+
observedAvailable: { type: array, items: { type: string } }
|
|
82
|
+
'404':
|
|
83
|
+
description: The probe is not compiled into this build.
|
|
28
84
|
/pairings/exchange:
|
|
29
85
|
post:
|
|
30
86
|
security: []
|
|
@@ -583,6 +639,94 @@ paths:
|
|
|
583
639
|
responses:
|
|
584
640
|
'200': { description: Session archived. }
|
|
585
641
|
'404': { $ref: '#/components/responses/Error' }
|
|
642
|
+
/chat/sessions/{sessionId}/jobs:
|
|
643
|
+
parameters:
|
|
644
|
+
- name: sessionId
|
|
645
|
+
in: path
|
|
646
|
+
required: true
|
|
647
|
+
schema: { type: string }
|
|
648
|
+
get:
|
|
649
|
+
operationId: listSessionJobs
|
|
650
|
+
summary: Background jobs a session can see
|
|
651
|
+
description: |
|
|
652
|
+
The roster this session can observe: its own jobs plus every unowned job.
|
|
653
|
+
The kernel's owner fence is the only access rule, so `sessionId` is
|
|
654
|
+
required and must not be guessed by the client.
|
|
655
|
+
responses:
|
|
656
|
+
'200':
|
|
657
|
+
description: The complete roster (whole-set, not a page).
|
|
658
|
+
content:
|
|
659
|
+
application/json:
|
|
660
|
+
schema:
|
|
661
|
+
type: object
|
|
662
|
+
required: [jobs]
|
|
663
|
+
properties:
|
|
664
|
+
jobs:
|
|
665
|
+
type: array
|
|
666
|
+
items: { $ref: '#/components/schemas/JobView' }
|
|
667
|
+
'404': { $ref: '#/components/responses/Error' }
|
|
668
|
+
/chat/sessions/{sessionId}/jobs/{jobId}/output:
|
|
669
|
+
parameters:
|
|
670
|
+
- name: sessionId
|
|
671
|
+
in: path
|
|
672
|
+
required: true
|
|
673
|
+
schema: { type: string }
|
|
674
|
+
- name: jobId
|
|
675
|
+
in: path
|
|
676
|
+
required: true
|
|
677
|
+
schema: { type: string }
|
|
678
|
+
- name: from
|
|
679
|
+
in: query
|
|
680
|
+
required: false
|
|
681
|
+
description: Absolute byte offset to resume from (a prior response's `next`). Omitted starts at the oldest retained byte.
|
|
682
|
+
schema: { type: integer }
|
|
683
|
+
get:
|
|
684
|
+
operationId: readJobOutput
|
|
685
|
+
summary: Retained output of one background job
|
|
686
|
+
responses:
|
|
687
|
+
'200':
|
|
688
|
+
description: Flattened output plus the offset to resume from.
|
|
689
|
+
content:
|
|
690
|
+
application/json:
|
|
691
|
+
schema:
|
|
692
|
+
type: object
|
|
693
|
+
required: [jobId, text, next, lossy]
|
|
694
|
+
properties:
|
|
695
|
+
jobId: { type: string }
|
|
696
|
+
text: { type: string }
|
|
697
|
+
next: { type: integer }
|
|
698
|
+
lossy: { type: boolean }
|
|
699
|
+
status: { $ref: '#/components/schemas/JobStatus' }
|
|
700
|
+
'404': { $ref: '#/components/responses/Error' }
|
|
701
|
+
/chat/sessions/{sessionId}/jobs/{jobId}/kill:
|
|
702
|
+
parameters:
|
|
703
|
+
- name: sessionId
|
|
704
|
+
in: path
|
|
705
|
+
required: true
|
|
706
|
+
schema: { type: string }
|
|
707
|
+
- name: jobId
|
|
708
|
+
in: path
|
|
709
|
+
required: true
|
|
710
|
+
schema: { type: string }
|
|
711
|
+
post:
|
|
712
|
+
operationId: killSessionJob
|
|
713
|
+
summary: Stop one background job on a human's behalf
|
|
714
|
+
description: |
|
|
715
|
+
Records `cancelled by the user` as the reason. The owning agent still
|
|
716
|
+
receives its completion notice, because a human kill is not one the
|
|
717
|
+
model requested.
|
|
718
|
+
responses:
|
|
719
|
+
'200':
|
|
720
|
+
description: The registry's admission.
|
|
721
|
+
content:
|
|
722
|
+
application/json:
|
|
723
|
+
schema:
|
|
724
|
+
type: object
|
|
725
|
+
required: [jobId, outcome]
|
|
726
|
+
properties:
|
|
727
|
+
jobId: { type: string }
|
|
728
|
+
outcome: { type: string, enum: [requested, already-finished] }
|
|
729
|
+
'404': { $ref: '#/components/responses/Error' }
|
|
586
730
|
/chat/sessions/{sessionId}/messages:
|
|
587
731
|
parameters:
|
|
588
732
|
- name: sessionId
|
|
@@ -1329,3 +1473,21 @@ components:
|
|
|
1329
1473
|
message: { type: string }
|
|
1330
1474
|
requestId: { type: string, format: uuid }
|
|
1331
1475
|
details: { type: object, additionalProperties: true }
|
|
1476
|
+
JobStatus:
|
|
1477
|
+
type: string
|
|
1478
|
+
description: Lifecycle state. `running` and `stopping` are live; the rest are terminal.
|
|
1479
|
+
enum: [running, stopping, completed, killed, failed]
|
|
1480
|
+
JobView:
|
|
1481
|
+
type: object
|
|
1482
|
+
description: Read-only projection of one background job.
|
|
1483
|
+
required: [id, kind, label, status, startedAt]
|
|
1484
|
+
properties:
|
|
1485
|
+
id: { type: string, description: 'Registry-issued id, e.g. `bash-<short-uuid>`.' }
|
|
1486
|
+
kind: { type: string, description: 'Producer kind; `bash` is the only host-registered one today.' }
|
|
1487
|
+
label: { type: string, description: Producer-supplied one-line label. }
|
|
1488
|
+
owner: { type: string, description: Owning session; absent for an unowned job. }
|
|
1489
|
+
status: { $ref: '#/components/schemas/JobStatus' }
|
|
1490
|
+
progress: { type: string, description: 'Live progress line (`3/10`); cleared at settlement.' }
|
|
1491
|
+
detail: { type: string, description: 'Terminal reason (`exit code: 3`); a recorded kill reason is merged in.' }
|
|
1492
|
+
startedAt: { type: integer, format: epoch-ms }
|
|
1493
|
+
finishedAt: { type: integer, format: epoch-ms, description: Absent while live. }
|
|
@@ -514,6 +514,51 @@ class DshWorkspaceClient(
|
|
|
514
514
|
executeUnit("chat/sessions/$sessionId/archive", "POST", null)
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/**
|
|
518
|
+
* 这个会话可见的后台任务清单(它自己的 + 所有无主任务)。
|
|
519
|
+
*
|
|
520
|
+
* 服务端只读内核流的第一帧 —— 内核保证首帧就是全量 truth,
|
|
521
|
+
* 所以这里返回的是完整清单,不是一个分页。
|
|
522
|
+
*/
|
|
523
|
+
fun listSessionJobs(sessionId: String): List<JobView> = executeJson(
|
|
524
|
+
"chat/sessions/$sessionId/jobs",
|
|
525
|
+
"GET",
|
|
526
|
+
null,
|
|
527
|
+
JobListEnvelope.serializer(),
|
|
528
|
+
).jobs
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 读一个后台任务已保留的输出。
|
|
532
|
+
*
|
|
533
|
+
* [from] 是**绝对字节偏移**,传上一次返回的 [JobOutput.next] 即可增量续读;
|
|
534
|
+
* 传 null 从最旧的保留字节开始。
|
|
535
|
+
*
|
|
536
|
+
* [JobOutput.lossy] 为 true 时,请求偏移与返回内容之间有字节被留存策略吃掉 ——
|
|
537
|
+
* 这段输出不是完整的,调用方应当提示用户。
|
|
538
|
+
*/
|
|
539
|
+
fun readJobOutput(sessionId: String, jobId: String, from: Long? = null): JobOutput {
|
|
540
|
+
val query = if (from == null) "" else "?from=$from"
|
|
541
|
+
return executeJson(
|
|
542
|
+
"chat/sessions/$sessionId/jobs/$jobId/output$query",
|
|
543
|
+
"GET",
|
|
544
|
+
null,
|
|
545
|
+
JobOutput.serializer(),
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 终止一个后台任务。
|
|
551
|
+
*
|
|
552
|
+
* 返回 `requested`(已受理)或 `already-finished`(已是终态、无需再杀)——
|
|
553
|
+
* 调用方应据此显示「已终止」或「它已经结束了」,后者不是前者的成功说法。
|
|
554
|
+
*/
|
|
555
|
+
fun killSessionJob(sessionId: String, jobId: String): JobKillResult = executeJson(
|
|
556
|
+
"chat/sessions/$sessionId/jobs/$jobId/kill",
|
|
557
|
+
"POST",
|
|
558
|
+
null,
|
|
559
|
+
JobKillResult.serializer(),
|
|
560
|
+
)
|
|
561
|
+
|
|
517
562
|
fun listPendingApprovals(sessionId: String): List<PendingApproval> = executeJson(
|
|
518
563
|
"chat/sessions/$sessionId/approvals",
|
|
519
564
|
"GET",
|
|
@@ -726,3 +771,59 @@ class EventSubscription internal constructor(
|
|
|
726
771
|
@Serializable private data class SendMessageRequest(val text: String, val mode: String, val clientRequestId: String)
|
|
727
772
|
@Serializable private data class ErrorEnvelope(val error: ApiErrorBody)
|
|
728
773
|
@Serializable private data class ApiErrorBody(val code: String, val message: String, val requestId: String)
|
|
774
|
+
|
|
775
|
+
/** 后台任务的生命周期状态。`running` / `stopping` 存活中,其余为终态。 */
|
|
776
|
+
@Serializable
|
|
777
|
+
enum class JobStatus {
|
|
778
|
+
@SerialName("running") RUNNING,
|
|
779
|
+
@SerialName("stopping") STOPPING,
|
|
780
|
+
@SerialName("completed") COMPLETED,
|
|
781
|
+
@SerialName("killed") KILLED,
|
|
782
|
+
@SerialName("failed") FAILED,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 一个后台任务的只读投影。
|
|
787
|
+
*
|
|
788
|
+
* 服务端逐个字段校验过类型,所以这里除 [status] / [startedAt] / [id] / [kind] / [label]
|
|
789
|
+
* 之外的字段都可空 —— 内核没给就是没有,客户端不该看到一个 `owner: undefined`。
|
|
790
|
+
*/
|
|
791
|
+
@Serializable
|
|
792
|
+
data class JobView(
|
|
793
|
+
val id: String,
|
|
794
|
+
val kind: String,
|
|
795
|
+
val label: String,
|
|
796
|
+
val owner: String? = null,
|
|
797
|
+
val status: JobStatus,
|
|
798
|
+
/** 生产者的实时进度行(`3/10`);终态时被清空。 */
|
|
799
|
+
val progress: String? = null,
|
|
800
|
+
/** 终态原因(`exit code: 3`);记录过的人工取消原因会并进来。 */
|
|
801
|
+
val detail: String? = null,
|
|
802
|
+
/** 注册时刻(epoch ms)。 */
|
|
803
|
+
val startedAt: Long,
|
|
804
|
+
/** 落定时刻(epoch ms);存活中为 null。 */
|
|
805
|
+
val finishedAt: Long? = null,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
@Serializable private data class JobListEnvelope(val jobs: List<JobView>)
|
|
809
|
+
|
|
810
|
+
/** 一个后台任务已保留的输出。 */
|
|
811
|
+
@Serializable
|
|
812
|
+
data class JobOutput(
|
|
813
|
+
val jobId: String,
|
|
814
|
+
val text: String,
|
|
815
|
+
/** 绝对字节偏移;下次增量续读传它。 */
|
|
816
|
+
val next: Long,
|
|
817
|
+
/** true = 请求偏移与返回内容之间有字节被留存策略吃掉,这段输出不完整。 */
|
|
818
|
+
val lossy: Boolean,
|
|
819
|
+
/** 读到终态帧时带上;流被提前切断时为 null。 */
|
|
820
|
+
val status: JobStatus? = null,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
/** 终止结果。 */
|
|
824
|
+
@Serializable
|
|
825
|
+
data class JobKillResult(
|
|
826
|
+
val jobId: String,
|
|
827
|
+
/** `requested` 已受理;`already-finished` 已是终态、无需再杀。 */
|
|
828
|
+
val outcome: String,
|
|
829
|
+
)
|
package/lib/index.d.ts
CHANGED
|
@@ -671,6 +671,57 @@ interface AgentPresetsLike {
|
|
|
671
671
|
interface PermissionPresetsLike {
|
|
672
672
|
catalog(): Promise<unknown>;
|
|
673
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* 内核 `dsh-api-job-controller`(cordis 服务名 `jobController`)。
|
|
676
|
+
*
|
|
677
|
+
* 后台任务(目前只有 bash 一种 producer kind)的只读观察 + 人类取消。
|
|
678
|
+
*
|
|
679
|
+
* **`list` 与 `follow` 是流,不是一次性查询** —— 内核类型上就是
|
|
680
|
+
* `AsyncIterable<JobListFrame>` / `AsyncIterable<JobFollowFrame>`。但 list 的首帧
|
|
681
|
+
* 就是全量 truth(内核注释原文:「Whole-set replacement, so a reconnect's first
|
|
682
|
+
* frame is already the truth」),所以 REST 侧读**第一帧**即可得到完整清单,
|
|
683
|
+
* 不必把 HTTP 变成长连接(手机端耗电、断线重连都更难)。
|
|
684
|
+
*
|
|
685
|
+
* 只有 `kill` 是同步的,且它的返回只有两种:`requested` / `already-finished`
|
|
686
|
+
* —— 没有「不存在」这一档,未知 job 走 `job/not-found` 远程错误。
|
|
687
|
+
*
|
|
688
|
+
* 结构类型照 `dsh-api-job-controller/lib/types/{index,types}.d.ts` 与
|
|
689
|
+
* `dsh-jobs/lib/types/view.d.ts` 抄,只留本插件真用到的那部分。
|
|
690
|
+
*/
|
|
691
|
+
interface JobControllerLike {
|
|
692
|
+
/** 流:首帧全量,随后每次生命周期提交后一帧。 */
|
|
693
|
+
list(request: {
|
|
694
|
+
sessionId: string;
|
|
695
|
+
}, signal: AbortSignal): AsyncIterable<{
|
|
696
|
+
type?: string;
|
|
697
|
+
jobs?: readonly unknown[];
|
|
698
|
+
}>;
|
|
699
|
+
/** 流:opened 锚点 → 若干 output 批次 → 终态 status,之后正常关闭。 */
|
|
700
|
+
follow(request: {
|
|
701
|
+
sessionId?: string;
|
|
702
|
+
jobId: string;
|
|
703
|
+
from?: number;
|
|
704
|
+
}, signal: AbortSignal): AsyncIterable<{
|
|
705
|
+
type?: string;
|
|
706
|
+
job?: unknown;
|
|
707
|
+
from?: number;
|
|
708
|
+
chunks?: readonly unknown[];
|
|
709
|
+
next?: number;
|
|
710
|
+
lossy?: true;
|
|
711
|
+
}>;
|
|
712
|
+
/**
|
|
713
|
+
* 人类取消。返回 `requested`(已受理)或 `already-finished`(已终态,无需再杀)。
|
|
714
|
+
*
|
|
715
|
+
* 注意它**不是** Promise:内核类型上就是同步返回。调用方 `await` 它也合法
|
|
716
|
+
* (await 非 Promise 返回原值),所以这里按内核原样声明成同步。
|
|
717
|
+
*/
|
|
718
|
+
kill(request: {
|
|
719
|
+
sessionId: string;
|
|
720
|
+
jobId: string;
|
|
721
|
+
}): {
|
|
722
|
+
outcome: 'requested' | 'already-finished';
|
|
723
|
+
};
|
|
724
|
+
}
|
|
674
725
|
interface LlmLike {
|
|
675
726
|
/** Live routes, as `{ id, name }` rows. */
|
|
676
727
|
listProviders(): readonly {
|
|
@@ -711,6 +762,16 @@ interface Dsh02HostServices {
|
|
|
711
762
|
credentialsController: CredentialsControllerLike;
|
|
712
763
|
agentPresets: AgentPresetsLike;
|
|
713
764
|
permissionPresets: PermissionPresetsLike;
|
|
765
|
+
/**
|
|
766
|
+
* 后台任务控制器(`ctx.jobController`,`@deepseek-ai/dsh-api-job-controller`)。
|
|
767
|
+
*
|
|
768
|
+
* 为什么要它:远程场景下「任务跑飞了停不掉」是真焦虑 —— 人不在电脑前,
|
|
769
|
+
* 一个卡住的 bash 任务只能干等。手机端需要能看到清单并终止它。
|
|
770
|
+
*
|
|
771
|
+
* 与 `sessionController` 不同,这个是 `TypertRemoteService`(不是 `@Remote`
|
|
772
|
+
* 控制器集合),但结构类型消费方式完全一样:只声明用到的成员,缺了就明确报错。
|
|
773
|
+
*/
|
|
774
|
+
jobController: JobControllerLike;
|
|
714
775
|
llm: LlmLike;
|
|
715
776
|
/**
|
|
716
777
|
* 内核附件存储(`ctx.attachments`);会话历史里的图片要经它取字节。
|
|
@@ -1533,6 +1594,136 @@ declare class WorkspaceEventBus {
|
|
|
1533
1594
|
subscribe(listener: (event: WorkspaceEvent) => void): () => void;
|
|
1534
1595
|
}
|
|
1535
1596
|
//#endregion
|
|
1597
|
+
//#region src/host/host-service-probe.d.ts
|
|
1598
|
+
interface ServiceProbe {
|
|
1599
|
+
name: string;
|
|
1600
|
+
/** 服务是否可解析。`false` = cordis 认为没有插件提供它。 */
|
|
1601
|
+
available: boolean;
|
|
1602
|
+
/** 解析失败时的错误信息(成功时为 undefined)。 */
|
|
1603
|
+
error?: string;
|
|
1604
|
+
/** 解析到的值是否像一个可用的服务对象(非 null/非 undefined)。 */
|
|
1605
|
+
resolvable: boolean;
|
|
1606
|
+
}
|
|
1607
|
+
interface HostServiceReport {
|
|
1608
|
+
/** 探针执行时的内核运行时版本,供比对「哪个版本注入面是这样」。 */
|
|
1609
|
+
pluginVersion: string;
|
|
1610
|
+
injected: ServiceProbe[];
|
|
1611
|
+
observed: ServiceProbe[];
|
|
1612
|
+
/** 注入面摘要:注入了几个、缺几个。缺的那几个就是「功能可能静默不工作」的清单。 */
|
|
1613
|
+
summary: {
|
|
1614
|
+
injectedTotal: number;
|
|
1615
|
+
injectedAvailable: number;
|
|
1616
|
+
injectedMissing: readonly string[];
|
|
1617
|
+
observedAvailable: readonly string[];
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
//#endregion
|
|
1621
|
+
//#region src/host/job-adapter.d.ts
|
|
1622
|
+
/**
|
|
1623
|
+
* 后台任务(background jobs)。
|
|
1624
|
+
*
|
|
1625
|
+
* ## 为什么需要它
|
|
1626
|
+
*
|
|
1627
|
+
* 远程场景下有个真焦虑:用户在手机上让 agent 跑个长任务(bash 构建、批量处理),
|
|
1628
|
+
* 然后切到别的会话或者放下手机 —— **此时没有任何地方能看到「它还在跑吗」,
|
|
1629
|
+
* 更没有地方能「让它停下」**。任务跑飞了只能干等,或者回到电脑前。
|
|
1630
|
+
*
|
|
1631
|
+
* 而内核一直有这套能力:`dsh-api-job-controller` 在 web profile 里挂着,
|
|
1632
|
+
* 提供 roster 流(`list`)、单任务输出观察流(`follow`)与人类取消(`kill`)。
|
|
1633
|
+
* 官方 WebUI 有 Jobs 面板(`client-ui-jobs` + `IconJobs`),本桥此前 26 个能力位里
|
|
1634
|
+
* **一个 job 相关的都没有**。
|
|
1635
|
+
*
|
|
1636
|
+
* ## 为什么 REST 只读第一帧,不做长连接
|
|
1637
|
+
*
|
|
1638
|
+
* 内核的 `list` 是 `AsyncIterable<JobListFrame>`,但内核注释写得很清楚:
|
|
1639
|
+
* 「Whole-set replacement, so a reconnect's first frame is already the truth」——
|
|
1640
|
+
* **首帧就是完整真相**。所以:
|
|
1641
|
+
*
|
|
1642
|
+
* - REST `GET /jobs` 读第一帧就够,返回完整清单;
|
|
1643
|
+
* - 不把 HTTP 变成长连接:手机端长连接耗电、断线重连复杂、而且清单本来就该「拉一次就是真的」;
|
|
1644
|
+
* - 实时推送留给后续(若要,走 WebSocket 事件帧,与 chat.* 同一条通道)。
|
|
1645
|
+
*
|
|
1646
|
+
* ## 为什么端点挂在会话维度下
|
|
1647
|
+
*
|
|
1648
|
+
* 内核的 `list(request)` 与 `kill(request)` **都要 `sessionId`** —— 返回的是
|
|
1649
|
+
* 「该会话自己的任务 + 所有无主任务」,owner fence 是唯一的访问规则。
|
|
1650
|
+
* 所以本模块的每个方法都要调用方给出 `sessionId`,**不能替客户端猜** ——
|
|
1651
|
+
* 猜错会让一个会话看到另一个会话的任务。`kill` 同理:请求里的 session 就是
|
|
1652
|
+
* 那次围栏读写的调用者。
|
|
1653
|
+
*
|
|
1654
|
+
* 所以 REST 也必须是会话维度的:`/chat/sessions/{sessionId}/jobs`。
|
|
1655
|
+
* 做成顶层 `/jobs` 就得让客户端额外猜一个 session,那是把内核的围栏语义
|
|
1656
|
+
* 搬到桥外面,早晚出「一个会话看到另一个会话的任务」这种事。
|
|
1657
|
+
*
|
|
1658
|
+
* 可见性由内核裁决,本模块不二次判断:内核抛 `job/not-found` 就转成 404。
|
|
1659
|
+
*/
|
|
1660
|
+
/** 一个后台任务的对外视图(内核 `JobView` 的结构投影)。 */
|
|
1661
|
+
interface JobView {
|
|
1662
|
+
id: string;
|
|
1663
|
+
kind: string;
|
|
1664
|
+
label: string;
|
|
1665
|
+
/** 属主会话;无主任务没有这个字段(任何调用方都可见)。 */
|
|
1666
|
+
owner?: string;
|
|
1667
|
+
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed';
|
|
1668
|
+
/** 生产者的实时进度行(`3/10`、当前阶段);终态时被清空。 */
|
|
1669
|
+
progress?: string;
|
|
1670
|
+
/** 终态原因(`exit code: 3`);记录过的 kill 原因会并进来。 */
|
|
1671
|
+
detail?: string;
|
|
1672
|
+
/** 注册时刻(epoch ms)。 */
|
|
1673
|
+
startedAt: number;
|
|
1674
|
+
/** 落定时刻(epoch ms);存活中无此字段。 */
|
|
1675
|
+
finishedAt?: number;
|
|
1676
|
+
}
|
|
1677
|
+
/** `GET /jobs` 的响应。 */
|
|
1678
|
+
interface JobListResult {
|
|
1679
|
+
jobs: JobView[];
|
|
1680
|
+
}
|
|
1681
|
+
/** `POST /jobs/{jobId}/kill` 的响应。 */
|
|
1682
|
+
interface JobKillResult {
|
|
1683
|
+
jobId: string;
|
|
1684
|
+
/** `requested` = 已受理;`already-finished` = 已是终态,无需再杀。 */
|
|
1685
|
+
outcome: 'requested' | 'already-finished';
|
|
1686
|
+
}
|
|
1687
|
+
declare class JobAdapter {
|
|
1688
|
+
private readonly host;
|
|
1689
|
+
constructor(host: {
|
|
1690
|
+
jobController: JobControllerLike;
|
|
1691
|
+
});
|
|
1692
|
+
/**
|
|
1693
|
+
* 列出某个会话可见的后台任务。
|
|
1694
|
+
*
|
|
1695
|
+
* 读流的**第一帧**(内核保证它是全量 truth)。若流首帧就不是 `rows`
|
|
1696
|
+
* (内核升级改了帧型),明确报错而不是返回空清单 ——
|
|
1697
|
+
* 空清单会被客户端理解成「没有任务」,那是**假的**。
|
|
1698
|
+
*/
|
|
1699
|
+
list(sessionId: string, signal: AbortSignal): Promise<JobListResult>;
|
|
1700
|
+
/**
|
|
1701
|
+
* 读一个任务已保留的输出(从指定字节偏移继续)。
|
|
1702
|
+
*
|
|
1703
|
+
* 内核的 `follow` 是流:`opened` 锚点 → 若干 `output` 批次 → 终态 `status`。
|
|
1704
|
+
* 这里**只读到第一个终态帧或流结束为止**,把批次摊平成一个文本块 ——
|
|
1705
|
+
* 手机端要看的是「这任务打印了什么」,不是逐帧的字节偏移记账。
|
|
1706
|
+
*
|
|
1707
|
+
* 但 `next` 偏移会一并返回:客户端想增量续读时,拿它当 `from` 再调一次即可,
|
|
1708
|
+
* 不必从头拉。`lossy` 也透出去,让客户端知道「有字节被留存策略吃掉了」,
|
|
1709
|
+
* 而不是把一段对不上的输出当成完整的。
|
|
1710
|
+
*/
|
|
1711
|
+
follow(sessionId: string, jobId: string, from: number | undefined, signal: AbortSignal): Promise<{
|
|
1712
|
+
jobId: string;
|
|
1713
|
+
text: string;
|
|
1714
|
+
next: number;
|
|
1715
|
+
lossy: boolean;
|
|
1716
|
+
status?: JobView['status'];
|
|
1717
|
+
}>;
|
|
1718
|
+
/**
|
|
1719
|
+
* 终止一个后台任务。
|
|
1720
|
+
*
|
|
1721
|
+
* 内核的 `kill` 是**同步**的,且只有 `requested` / `already-finished` 两种返回;
|
|
1722
|
+
* 「这个任务不存在或该会话看不到它」走 `job/not-found` 远程错误,在这里转成 404。
|
|
1723
|
+
*/
|
|
1724
|
+
kill(sessionId: string, jobId: string): JobKillResult;
|
|
1725
|
+
}
|
|
1726
|
+
//#endregion
|
|
1536
1727
|
//#region src/host/cloudflared.d.ts
|
|
1537
1728
|
/**
|
|
1538
1729
|
* Cloudflare 隧道(cloudflared)管理。
|
|
@@ -1725,6 +1916,20 @@ interface RouterOptions {
|
|
|
1725
1916
|
* 生产路径由 `index.ts` 传入与交接实际使用**同一个**目录,避免「界面显示 A、落盘在 B」。
|
|
1726
1917
|
*/
|
|
1727
1918
|
imageFallbackDir?: string;
|
|
1919
|
+
/**
|
|
1920
|
+
* 宿主服务注入面探针报告(见 `host-service-probe.ts`)。
|
|
1921
|
+
*
|
|
1922
|
+
* 可选:不注入则该端点返回 404,测试因此不必每条都补一个字段。
|
|
1923
|
+
* 生产路径由 `index.ts` 在 apply 期间采集后传入 —— 必须是**快照**,
|
|
1924
|
+
* 因为 cordis 的注入属性只在 apply 执行期间可读(见探针文件头注释)。
|
|
1925
|
+
*/
|
|
1926
|
+
hostServiceReport?: HostServiceReport;
|
|
1927
|
+
/**
|
|
1928
|
+
* 后台任务(`/chat/sessions/{sessionId}/jobs`)。
|
|
1929
|
+
*
|
|
1930
|
+
* 可选:不注入则这些端点返回 404,测试不必每条都补一个字段。
|
|
1931
|
+
*/
|
|
1932
|
+
jobs?: JobAdapter;
|
|
1728
1933
|
}
|
|
1729
1934
|
declare class ApiRouter {
|
|
1730
1935
|
private readonly options;
|
package/lib/index.js
CHANGED
|
@@ -1528,12 +1528,36 @@ const FALLBACK_PERMISSION_CATALOG = {
|
|
|
1528
1528
|
defaultPreset: "workspace-write"
|
|
1529
1529
|
};
|
|
1530
1530
|
/**
|
|
1531
|
+
* 从 follow 的 snapshot 帧里取投影基线。
|
|
1532
|
+
*
|
|
1533
|
+
* 0.2.1 起 `SessionFollowFrame` 的 snapshot 分支自带
|
|
1534
|
+
* `projections: SessionProjectionBaseline`,所以正常情况下**不需要**再打一次
|
|
1535
|
+
* `sessionController.projections()`。这个函数就是那个判断:有就用,没有返回 undefined
|
|
1536
|
+
* 让调用方回落。
|
|
1537
|
+
*
|
|
1538
|
+
* 为什么用结构探测而不是类型收窄:本插件刻意不 import `@deepseek-ai/*`,
|
|
1539
|
+
* `snapshot` 在这里是结构类型,看不到内核的判别联合。按形状判断是唯一办法,
|
|
1540
|
+
* 也与本文件其余部分的风格一致。
|
|
1541
|
+
*/
|
|
1542
|
+
function projectionsOf(snapshot) {
|
|
1543
|
+
if (snapshot === null || typeof snapshot !== "object") return void 0;
|
|
1544
|
+
const projections = snapshot.projections;
|
|
1545
|
+
if (projections === null || typeof projections !== "object") return void 0;
|
|
1546
|
+
const values = projections.values;
|
|
1547
|
+
if (values === null || typeof values !== "object") return void 0;
|
|
1548
|
+
const asOfSeq = projections.asOfSeq;
|
|
1549
|
+
return {
|
|
1550
|
+
...typeof asOfSeq === "number" ? { asOfSeq } : {},
|
|
1551
|
+
values
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1531
1555
|
* 读会话的**投影基线**(`permissions` / `modelSelection` / `title` …)。
|
|
1532
1556
|
*
|
|
1533
1557
|
* @patch
|
|
1534
1558
|
* id: history-projections-passthrough
|
|
1535
1559
|
* for: 内核 `sessionController.follow()` 的 snapshot 与 `page()` 都**不返回 projections**
|
|
1536
|
-
*
|
|
1560
|
+
* (0.2.0 的类型上就是 `{ cursor, records, hasMore }`),而客户端要靠
|
|
1537
1561
|
* `projections.values.permissions` 才能显示会话的当前权限档位。
|
|
1538
1562
|
* 内核那边确实注册了这条投影(`dsh-permission-presets` 里
|
|
1539
1563
|
* `ctx.sessionProjections.register({ key: 'permissions', … })`),
|
|
@@ -1542,6 +1566,9 @@ const FALLBACK_PERMISSION_CATALOG = {
|
|
|
1542
1566
|
* 不补的后果是 Android 客户端的权限档位 Pill 与 ⋯ 菜单权限项**从不渲染**
|
|
1543
1567
|
* —— v2.2.44 真机验收实测到了这个,而当时 `PARITY.md` 还把它标成 `done`。
|
|
1544
1568
|
* until: 内核把 projections 并进 follow/page 的返回。
|
|
1569
|
+
* **0.2.1 已达成**:snapshot 分支现在自带 `projections`,history 首屏走
|
|
1570
|
+
* `projectionsOf(snapshot)`,不再打这次 RPC。本函数降级为**老内核回落路径**,
|
|
1571
|
+
* 保留到最低支持版本升过 0.2.1 之后再删。
|
|
1545
1572
|
* test: tests/history-projections.spec.ts
|
|
1546
1573
|
*/
|
|
1547
1574
|
async function readSessionProjections(host, sessionId, signal) {
|
|
@@ -1755,7 +1782,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
1755
1782
|
events: [],
|
|
1756
1783
|
hasMore: false
|
|
1757
1784
|
};
|
|
1758
|
-
const projections = await readSessionProjections(host, sessionId, controller.signal);
|
|
1785
|
+
const projections = projectionsOf(snapshot) ?? await readSessionProjections(host, sessionId, controller.signal);
|
|
1759
1786
|
const withProjections = (payload) => projections === void 0 ? payload : {
|
|
1760
1787
|
...payload,
|
|
1761
1788
|
projections
|
|
@@ -2009,7 +2036,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2009
2036
|
}
|
|
2010
2037
|
//#endregion
|
|
2011
2038
|
//#region src/shared/version.ts
|
|
2012
|
-
const PLUGIN_VERSION = "2.4.
|
|
2039
|
+
const PLUGIN_VERSION = "2.4.3";
|
|
2013
2040
|
//#endregion
|
|
2014
2041
|
//#region src/host/capabilities.ts
|
|
2015
2042
|
/**
|
|
@@ -2087,7 +2114,10 @@ const BRIDGE_CAPABILITIES = [
|
|
|
2087
2114
|
"turn-failure-v1",
|
|
2088
2115
|
"history-byte-budget-v1",
|
|
2089
2116
|
"interaction-replay-v1",
|
|
2090
|
-
"session-snapshot-v1"
|
|
2117
|
+
"session-snapshot-v1",
|
|
2118
|
+
"job-list-v1",
|
|
2119
|
+
"job-output-v1",
|
|
2120
|
+
"job-kill-v1"
|
|
2091
2121
|
];
|
|
2092
2122
|
/**
|
|
2093
2123
|
* `connection.ready` 事件的载荷。
|
|
@@ -4011,6 +4041,267 @@ function trashView(record) {
|
|
|
4011
4041
|
status: record.status
|
|
4012
4042
|
};
|
|
4013
4043
|
}
|
|
4044
|
+
//#endregion
|
|
4045
|
+
//#region src/host/host-service-probe.ts
|
|
4046
|
+
/**
|
|
4047
|
+
* 宿主服务注入面探针。
|
|
4048
|
+
*
|
|
4049
|
+
* 为什么需要它:本插件刻意不 import 任何 `@deepseek-ai/*`(见
|
|
4050
|
+
* docs/project/COMPATIBILITY.md),全部宿主服务按结构类型 + Host 注入消费。
|
|
4051
|
+
* 好处是跨内核版本零漂移,**代价是「服务到底注进来了没有」只能靠猜** ——
|
|
4052
|
+
* cordis 的注入属性是惰性访问器,读一个没人提供的服务会抛
|
|
4053
|
+
* `cannot get property "x" without inject`,而这个错误只在真的去读的那一刻出现。
|
|
4054
|
+
*
|
|
4055
|
+
* 于是历史上反复出现同一类事故:某功能静默不工作,两侧日志都不说一句话,
|
|
4056
|
+
* 排错者先从客户端查起,查到服务端才发现服务压根没注入。
|
|
4057
|
+
* 2.0.0 的 `attachments`、2.0.2 的 `fileUploads` 都是这么炸的(见
|
|
4058
|
+
* dsh-0.2-bridge.ts 里那段长注释)。
|
|
4059
|
+
*
|
|
4060
|
+
* 这个探针把「猜」变成「问」:在 apply 期间(注入属性还可读时)逐个试读一次,
|
|
4061
|
+
* 记录每个服务的**可解析性**,之后任何时候都能回答「0.2.x 上到底有哪些服务可用」。
|
|
4062
|
+
* 它不改变任何行为 —— 纯粹是观测。
|
|
4063
|
+
*
|
|
4064
|
+
* 使用方式:`GET /dsh-workspace-api/api/v1/debug/host-services`
|
|
4065
|
+
* (需要设备 token 或 loopback admin,与其它 REST 端点同一套鉴权)。
|
|
4066
|
+
*/
|
|
4067
|
+
/** 插件 `src/index.ts` 里 inject 声明的全部服务名。 */
|
|
4068
|
+
const INJECTED_SERVICE_NAMES = [
|
|
4069
|
+
"sessionController",
|
|
4070
|
+
"workspaceController",
|
|
4071
|
+
"workspaceRegistry",
|
|
4072
|
+
"settingsController",
|
|
4073
|
+
"credentialsController",
|
|
4074
|
+
"agentPresets",
|
|
4075
|
+
"permissionPresets",
|
|
4076
|
+
"llm",
|
|
4077
|
+
"webServer",
|
|
4078
|
+
"agents",
|
|
4079
|
+
"commands",
|
|
4080
|
+
"jobController",
|
|
4081
|
+
"fileUploads",
|
|
4082
|
+
"attachments"
|
|
4083
|
+
];
|
|
4084
|
+
/** 0.2.1 新增、插件尚未 inject 但已确认存在于 web profile 的服务。 */
|
|
4085
|
+
const OBSERVED_ONLY_SERVICE_NAMES = ["workingDirectory", "workspaceFiles"];
|
|
4086
|
+
/**
|
|
4087
|
+
* 在注入属性**仍可读**的时机(即 `apply` 执行期间)逐个试读服务。
|
|
4088
|
+
*
|
|
4089
|
+
* 为什么必须在 apply 期间读、不能留到 HTTP 回调里:cordis 的注入属性是
|
|
4090
|
+
* 有生命周期的惰性访问器,`apply` 一结束就读不到了(会抛
|
|
4091
|
+
* `cannot get property "attachments" without inject`)。这正是
|
|
4092
|
+
* dsh-0.2-bridge.ts 里 `optionalService` 那段注释记的教训 ——
|
|
4093
|
+
* 2.0.1 把读取留到回调里,结果整个插件激活失败。
|
|
4094
|
+
*
|
|
4095
|
+
* @param host - `ctx` 本身(不是取到的服务对象)。
|
|
4096
|
+
* @returns 每个服务的可解析性报告。
|
|
4097
|
+
*/
|
|
4098
|
+
function probeHostServices(host, pluginVersion) {
|
|
4099
|
+
const probeOne = (name) => {
|
|
4100
|
+
let available = false;
|
|
4101
|
+
let resolvable = false;
|
|
4102
|
+
let error;
|
|
4103
|
+
try {
|
|
4104
|
+
const value = host[name];
|
|
4105
|
+
available = true;
|
|
4106
|
+
resolvable = value !== null && value !== void 0;
|
|
4107
|
+
if (!resolvable) error = "resolved to null/undefined";
|
|
4108
|
+
} catch (caught) {
|
|
4109
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
4110
|
+
}
|
|
4111
|
+
return {
|
|
4112
|
+
name,
|
|
4113
|
+
available,
|
|
4114
|
+
resolvable,
|
|
4115
|
+
...error === void 0 ? {} : { error }
|
|
4116
|
+
};
|
|
4117
|
+
};
|
|
4118
|
+
const injected = INJECTED_SERVICE_NAMES.map(probeOne);
|
|
4119
|
+
const observed = OBSERVED_ONLY_SERVICE_NAMES.map(probeOne);
|
|
4120
|
+
return {
|
|
4121
|
+
pluginVersion,
|
|
4122
|
+
injected,
|
|
4123
|
+
observed,
|
|
4124
|
+
summary: {
|
|
4125
|
+
injectedTotal: injected.length,
|
|
4126
|
+
injectedAvailable: injected.filter((service) => service.available).length,
|
|
4127
|
+
injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
|
|
4128
|
+
observedAvailable: observed.filter((service) => service.available).map((service) => service.name)
|
|
4129
|
+
}
|
|
4130
|
+
};
|
|
4131
|
+
}
|
|
4132
|
+
//#endregion
|
|
4133
|
+
//#region src/host/job-adapter.ts
|
|
4134
|
+
/**
|
|
4135
|
+
* job 适配层的错误。
|
|
4136
|
+
*
|
|
4137
|
+
* **继承 `ApiError` 而不是另起一套**:两者的形状(status + code + message)
|
|
4138
|
+
* 完全一致,继承之后 router 现有的 `asApiError` 直接就能把它转成正确的
|
|
4139
|
+
* HTTP 响应,不必在路由里再写一层转换。这是刻意少造一个概念。
|
|
4140
|
+
*/
|
|
4141
|
+
var JobApiError = class extends ApiError {};
|
|
4142
|
+
/** 把内核 `JobView` 收敛成对外形状。
|
|
4143
|
+
*
|
|
4144
|
+
* 只挑客户端真用得上的字段,并且**逐个校验类型** —— 内核返回的是结构类型,
|
|
4145
|
+
* 一个字段形状变了就该在这里显形,而不是把一个 `undefined` 透给客户端
|
|
4146
|
+
* 让它的严格解码器报 `Field 'x' is required`。
|
|
4147
|
+
*/
|
|
4148
|
+
function toJobView(raw) {
|
|
4149
|
+
if (raw === null || typeof raw !== "object") throw new JobApiError(502, "JOB_SHAPE_INVALID", "The kernel returned a malformed job row.");
|
|
4150
|
+
const job = raw;
|
|
4151
|
+
const id = job["id"];
|
|
4152
|
+
const kind = job["kind"];
|
|
4153
|
+
const label = job["label"];
|
|
4154
|
+
const status = job["status"];
|
|
4155
|
+
const startedAt = job["startedAt"];
|
|
4156
|
+
if (typeof id !== "string" || typeof kind !== "string" || typeof label !== "string") throw new JobApiError(502, "JOB_SHAPE_INVALID", "The kernel returned a job row without id/kind/label.");
|
|
4157
|
+
if (typeof status !== "string" || ![
|
|
4158
|
+
"running",
|
|
4159
|
+
"stopping",
|
|
4160
|
+
"completed",
|
|
4161
|
+
"killed",
|
|
4162
|
+
"failed"
|
|
4163
|
+
].includes(status)) throw new JobApiError(502, "JOB_SHAPE_INVALID", `The kernel returned an unknown job status: ${String(status)}`);
|
|
4164
|
+
if (typeof startedAt !== "number") throw new JobApiError(502, "JOB_SHAPE_INVALID", "The kernel returned a job row without startedAt.");
|
|
4165
|
+
const owner = job["owner"];
|
|
4166
|
+
const progress = job["progress"];
|
|
4167
|
+
const detail = job["detail"];
|
|
4168
|
+
const finishedAt = job["finishedAt"];
|
|
4169
|
+
return {
|
|
4170
|
+
id,
|
|
4171
|
+
kind,
|
|
4172
|
+
label,
|
|
4173
|
+
...typeof owner === "string" ? { owner } : {},
|
|
4174
|
+
status,
|
|
4175
|
+
...typeof progress === "string" ? { progress } : {},
|
|
4176
|
+
...typeof detail === "string" ? { detail } : {},
|
|
4177
|
+
startedAt,
|
|
4178
|
+
...typeof finishedAt === "number" ? { finishedAt } : {}
|
|
4179
|
+
};
|
|
4180
|
+
}
|
|
4181
|
+
var JobAdapter = class {
|
|
4182
|
+
host;
|
|
4183
|
+
constructor(host) {
|
|
4184
|
+
this.host = host;
|
|
4185
|
+
}
|
|
4186
|
+
/**
|
|
4187
|
+
* 列出某个会话可见的后台任务。
|
|
4188
|
+
*
|
|
4189
|
+
* 读流的**第一帧**(内核保证它是全量 truth)。若流首帧就不是 `rows`
|
|
4190
|
+
* (内核升级改了帧型),明确报错而不是返回空清单 ——
|
|
4191
|
+
* 空清单会被客户端理解成「没有任务」,那是**假的**。
|
|
4192
|
+
*/
|
|
4193
|
+
async list(sessionId, signal) {
|
|
4194
|
+
const frame = (await this.host.jobController.list({ sessionId }, signal)[Symbol.asyncIterator]().next()).value;
|
|
4195
|
+
if (frame === void 0 || frame === null) throw new JobApiError(502, "JOB_STREAM_EMPTY", "The job roster stream closed before its first frame.");
|
|
4196
|
+
if (frame.type !== "rows") throw new JobApiError(502, "JOB_FRAME_UNKNOWN", `Unexpected first job frame: ${String(frame.type)}`);
|
|
4197
|
+
const jobs = frame.jobs;
|
|
4198
|
+
if (!Array.isArray(jobs)) throw new JobApiError(502, "JOB_SHAPE_INVALID", "The job roster frame carried no rows array.");
|
|
4199
|
+
return { jobs: jobs.map(toJobView) };
|
|
4200
|
+
}
|
|
4201
|
+
/**
|
|
4202
|
+
* 读一个任务已保留的输出(从指定字节偏移继续)。
|
|
4203
|
+
*
|
|
4204
|
+
* 内核的 `follow` 是流:`opened` 锚点 → 若干 `output` 批次 → 终态 `status`。
|
|
4205
|
+
* 这里**只读到第一个终态帧或流结束为止**,把批次摊平成一个文本块 ——
|
|
4206
|
+
* 手机端要看的是「这任务打印了什么」,不是逐帧的字节偏移记账。
|
|
4207
|
+
*
|
|
4208
|
+
* 但 `next` 偏移会一并返回:客户端想增量续读时,拿它当 `from` 再调一次即可,
|
|
4209
|
+
* 不必从头拉。`lossy` 也透出去,让客户端知道「有字节被留存策略吃掉了」,
|
|
4210
|
+
* 而不是把一段对不上的输出当成完整的。
|
|
4211
|
+
*/
|
|
4212
|
+
async follow(sessionId, jobId, from, signal) {
|
|
4213
|
+
const stream = this.host.jobController.follow({
|
|
4214
|
+
sessionId,
|
|
4215
|
+
jobId,
|
|
4216
|
+
...from === void 0 ? {} : { from }
|
|
4217
|
+
}, signal);
|
|
4218
|
+
const parts = [];
|
|
4219
|
+
let next = from ?? 0;
|
|
4220
|
+
let lossy = false;
|
|
4221
|
+
let status;
|
|
4222
|
+
let sawOpened = false;
|
|
4223
|
+
try {
|
|
4224
|
+
for await (const frame of stream) {
|
|
4225
|
+
if (frame === null || typeof frame !== "object") continue;
|
|
4226
|
+
if (frame.type === "opened") {
|
|
4227
|
+
sawOpened = true;
|
|
4228
|
+
status = toJobView(frame.job).status;
|
|
4229
|
+
next = typeof frame.from === "number" ? frame.from : next;
|
|
4230
|
+
continue;
|
|
4231
|
+
}
|
|
4232
|
+
if (frame.type === "output") {
|
|
4233
|
+
const chunks = frame.chunks;
|
|
4234
|
+
if (Array.isArray(chunks)) {
|
|
4235
|
+
for (const chunk of chunks) if (chunk !== null && typeof chunk === "object" && typeof chunk.text === "string") parts.push(chunk.text);
|
|
4236
|
+
}
|
|
4237
|
+
if (typeof frame.next === "number") next = frame.next;
|
|
4238
|
+
if (frame.lossy === true) lossy = true;
|
|
4239
|
+
continue;
|
|
4240
|
+
}
|
|
4241
|
+
if (frame.type === "status") {
|
|
4242
|
+
status = toJobView(frame.job).status;
|
|
4243
|
+
break;
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
} catch (error) {
|
|
4247
|
+
if (isJobNotFound(error)) throw new JobApiError(404, "JOB_NOT_FOUND", `No observable job ${jobId} for this session.`);
|
|
4248
|
+
throw new JobApiError(502, "JOB_FOLLOW_FAILED", error instanceof Error ? error.message : String(error));
|
|
4249
|
+
}
|
|
4250
|
+
if (!sawOpened) throw new JobApiError(502, "JOB_STREAM_EMPTY", "The job observation stream closed before its anchor.");
|
|
4251
|
+
return {
|
|
4252
|
+
jobId,
|
|
4253
|
+
text: parts.join(""),
|
|
4254
|
+
next,
|
|
4255
|
+
lossy,
|
|
4256
|
+
...status === void 0 ? {} : { status }
|
|
4257
|
+
};
|
|
4258
|
+
}
|
|
4259
|
+
/**
|
|
4260
|
+
* 终止一个后台任务。
|
|
4261
|
+
*
|
|
4262
|
+
* 内核的 `kill` 是**同步**的,且只有 `requested` / `already-finished` 两种返回;
|
|
4263
|
+
* 「这个任务不存在或该会话看不到它」走 `job/not-found` 远程错误,在这里转成 404。
|
|
4264
|
+
*/
|
|
4265
|
+
kill(sessionId, jobId) {
|
|
4266
|
+
let value;
|
|
4267
|
+
try {
|
|
4268
|
+
value = this.host.jobController.kill({
|
|
4269
|
+
sessionId,
|
|
4270
|
+
jobId
|
|
4271
|
+
});
|
|
4272
|
+
} catch (error) {
|
|
4273
|
+
if (isJobNotFound(error)) throw new JobApiError(404, "JOB_NOT_FOUND", `No killable job ${jobId} in this session.`);
|
|
4274
|
+
throw new JobApiError(502, "JOB_KILL_FAILED", error instanceof Error ? error.message : String(error));
|
|
4275
|
+
}
|
|
4276
|
+
return {
|
|
4277
|
+
jobId,
|
|
4278
|
+
outcome: value.outcome
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
4281
|
+
};
|
|
4282
|
+
/** 内核 `job/not-found` 远程错误的识别。
|
|
4283
|
+
*
|
|
4284
|
+
* 内核的「任务不存在」有**两种错误形态**,判别必须都覆盖(2026-10-20 真机实测):
|
|
4285
|
+
*
|
|
4286
|
+
* 1. **`kill`(同步 Remote 调用)**:抛带 `code: 'job/not-found'` 的 Remote 错误
|
|
4287
|
+
* (`RemoteErrorDetailsMap` 里登记的那种)。看 code,不猜文案。
|
|
4288
|
+
* 2. **`follow` / `list`(AsyncIterable 流)**:流里 throw 的是普通
|
|
4289
|
+
* `Error("unknown job <id>")`,**没有 code 元数据** —— 流帧协议里没有
|
|
4290
|
+
* Remote 错误通道。只能认 message 前缀 `unknown job`。
|
|
4291
|
+
*
|
|
4292
|
+
* 这条曾漏过第二形态:output 对不存在的 job 返回 502 JOB_FOLLOW_FAILED
|
|
4293
|
+
* (「内核失败」)而非 404 JOB_NOT_FOUND(「任务不存在」)——
|
|
4294
|
+
* 客户端会把一次查错当成一次内核故障。真机一打就现形。
|
|
4295
|
+
*/
|
|
4296
|
+
function isJobNotFound(error) {
|
|
4297
|
+
if (error === null || typeof error !== "object") return false;
|
|
4298
|
+
const candidate = error;
|
|
4299
|
+
if (candidate.code === "job/not-found") return true;
|
|
4300
|
+
const details = candidate.details;
|
|
4301
|
+
if (details !== null && typeof details === "object" && details.code === "job/not-found") return true;
|
|
4302
|
+
if (typeof candidate.message !== "string") return false;
|
|
4303
|
+
return candidate.message.includes("job/not-found") || candidate.message.startsWith("unknown job");
|
|
4304
|
+
}
|
|
4014
4305
|
function normalizeListenerHost(value) {
|
|
4015
4306
|
if (typeof value !== "string" || value.trim() === "" || isIP(value.trim()) === 0) throw new ApiError(400, "LISTENER_HOST_INVALID", "host must be a numeric IPv4 or IPv6 address.");
|
|
4016
4307
|
return value.trim();
|
|
@@ -4662,6 +4953,32 @@ function stringifyWire(value) {
|
|
|
4662
4953
|
}
|
|
4663
4954
|
//#endregion
|
|
4664
4955
|
//#region src/host/router.ts
|
|
4956
|
+
function matchJobRoute(pathname) {
|
|
4957
|
+
const jobs = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs$/);
|
|
4958
|
+
if (jobs !== null) return {
|
|
4959
|
+
kind: "list",
|
|
4960
|
+
sessionId: decodeURIComponent(jobs[1])
|
|
4961
|
+
};
|
|
4962
|
+
const output = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/output$/);
|
|
4963
|
+
if (output !== null) return {
|
|
4964
|
+
kind: "output",
|
|
4965
|
+
sessionId: decodeURIComponent(output[1]),
|
|
4966
|
+
jobId: decodeURIComponent(output[2])
|
|
4967
|
+
};
|
|
4968
|
+
const kill = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/kill$/);
|
|
4969
|
+
if (kill !== null) return {
|
|
4970
|
+
kind: "kill",
|
|
4971
|
+
sessionId: decodeURIComponent(kill[1]),
|
|
4972
|
+
jobId: decodeURIComponent(kill[2])
|
|
4973
|
+
};
|
|
4974
|
+
const bare = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)$/);
|
|
4975
|
+
if (bare !== null) return {
|
|
4976
|
+
kind: "unknown",
|
|
4977
|
+
sessionId: decodeURIComponent(bare[1]),
|
|
4978
|
+
jobId: decodeURIComponent(bare[2])
|
|
4979
|
+
};
|
|
4980
|
+
return null;
|
|
4981
|
+
}
|
|
4665
4982
|
var ApiRouter = class {
|
|
4666
4983
|
options;
|
|
4667
4984
|
constructor(options) {
|
|
@@ -4706,6 +5023,12 @@ var ApiRouter = class {
|
|
|
4706
5023
|
});
|
|
4707
5024
|
return;
|
|
4708
5025
|
}
|
|
5026
|
+
if (method === "GET" && pathname === "/api/v1/debug/host-services") {
|
|
5027
|
+
const report = this.options.hostServiceReport;
|
|
5028
|
+
if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
|
|
5029
|
+
sendJson(res, 200, report);
|
|
5030
|
+
return;
|
|
5031
|
+
}
|
|
4709
5032
|
if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
|
|
4710
5033
|
this.options.auth.pairingAttempts.consume(requestAddress(req));
|
|
4711
5034
|
const body = await readJson(req, 16384);
|
|
@@ -4990,6 +5313,35 @@ var ApiRouter = class {
|
|
|
4990
5313
|
sendJson(res, 200, { archived: true });
|
|
4991
5314
|
return;
|
|
4992
5315
|
}
|
|
5316
|
+
const jobDispatch = matchJobRoute(pathname);
|
|
5317
|
+
if (jobDispatch !== null) {
|
|
5318
|
+
const jobs = this.options.jobs;
|
|
5319
|
+
if (jobs === void 0) throw new ApiError(404, "NOT_FOUND", "Background jobs are not available in this build.");
|
|
5320
|
+
if (method === "GET" && jobDispatch.kind === "list") {
|
|
5321
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
5322
|
+
sendJson(res, 200, await jobs.list(jobDispatch.sessionId, AbortSignal.timeout(15e3)));
|
|
5323
|
+
return;
|
|
5324
|
+
}
|
|
5325
|
+
if (method === "GET" && jobDispatch.kind === "output") {
|
|
5326
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
5327
|
+
const from = url.searchParams.get("from");
|
|
5328
|
+
const offset = from === null ? void 0 : Number(from);
|
|
5329
|
+
if (offset !== void 0 && !Number.isFinite(offset)) throw new ApiError(400, "QUERY_INVALID", "from must be a finite byte offset.");
|
|
5330
|
+
sendJson(res, 200, await jobs.follow(jobDispatch.sessionId, jobDispatch.jobId, offset, AbortSignal.timeout(3e4)));
|
|
5331
|
+
return;
|
|
5332
|
+
}
|
|
5333
|
+
if (method === "POST" && jobDispatch.kind === "kill") {
|
|
5334
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
5335
|
+
const result = jobs.kill(jobDispatch.sessionId, jobDispatch.jobId);
|
|
5336
|
+
this.audit(principal, "chat.job.kill", void 0, void 0, requestId, {
|
|
5337
|
+
sessionId: jobDispatch.sessionId,
|
|
5338
|
+
jobId: jobDispatch.jobId
|
|
5339
|
+
});
|
|
5340
|
+
sendJson(res, 200, result);
|
|
5341
|
+
return;
|
|
5342
|
+
}
|
|
5343
|
+
throw new ApiError(405, "METHOD_NOT_ALLOWED", "Unsupported method for the jobs resource.");
|
|
5344
|
+
}
|
|
4993
5345
|
const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
|
|
4994
5346
|
if (messagesRoute !== null) {
|
|
4995
5347
|
const sessionId = decodeURIComponent(messagesRoute[1]);
|
|
@@ -6371,6 +6723,7 @@ const inject = [
|
|
|
6371
6723
|
"webServer",
|
|
6372
6724
|
"agents",
|
|
6373
6725
|
"commands",
|
|
6726
|
+
"jobController",
|
|
6374
6727
|
"fileUploads",
|
|
6375
6728
|
"attachments"
|
|
6376
6729
|
];
|
|
@@ -6406,7 +6759,24 @@ async function apply(ctx, config = {}) {
|
|
|
6406
6759
|
commands: ctx.commands
|
|
6407
6760
|
});
|
|
6408
6761
|
const settings = new DshSettingsAdapter(apiProxy);
|
|
6762
|
+
let hostServiceReport;
|
|
6763
|
+
try {
|
|
6764
|
+
hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);
|
|
6765
|
+
const { injectedAvailable, injectedTotal, injectedMissing } = hostServiceReport.summary;
|
|
6766
|
+
const detail = injectedMissing.length === 0 ? "" : `,缺失:${injectedMissing.join(", ")}`;
|
|
6767
|
+
ctx.logger.info(`dsh-remote-bridge: host services ${injectedAvailable}/${injectedTotal} 可注入${detail}`);
|
|
6768
|
+
if (hostServiceReport.summary.observedAvailable.length > 0) ctx.logger.info(`dsh-remote-bridge: 观测到可用但未 inject 的服务:${hostServiceReport.summary.observedAvailable.join(", ")}`);
|
|
6769
|
+
} catch (error) {
|
|
6770
|
+
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: host service probe failed: ${String(error)}`));
|
|
6771
|
+
}
|
|
6409
6772
|
let router;
|
|
6773
|
+
let jobControllerAvailable = false;
|
|
6774
|
+
try {
|
|
6775
|
+
ctx.jobController;
|
|
6776
|
+
jobControllerAvailable = true;
|
|
6777
|
+
} catch {
|
|
6778
|
+
ctx.logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: ctx.jobController unavailable, background-job endpoints disabled"));
|
|
6779
|
+
}
|
|
6410
6780
|
const remote = new RemoteApiServer({
|
|
6411
6781
|
port,
|
|
6412
6782
|
remoteHost,
|
|
@@ -6426,7 +6796,9 @@ async function apply(ctx, config = {}) {
|
|
|
6426
6796
|
tunnels: new CloudflaredTunnelManager(),
|
|
6427
6797
|
cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
|
|
6428
6798
|
maxUploadBytes,
|
|
6429
|
-
imageFallbackDir: imageHandoffDir
|
|
6799
|
+
imageFallbackDir: imageHandoffDir,
|
|
6800
|
+
...hostServiceReport === void 0 ? {} : { hostServiceReport },
|
|
6801
|
+
...jobControllerAvailable ? { jobs: new JobAdapter({ jobController: ctx.jobController }) } : {}
|
|
6430
6802
|
});
|
|
6431
6803
|
try {
|
|
6432
6804
|
await remote.start();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-bridge",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.3",
|
|
4
4
|
"description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.19.0",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"prepack": "npm run check"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
|
-
"node": "^22.19.0 || >=24.0.0"
|
|
64
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
65
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0"
|
|
65
66
|
},
|
|
66
67
|
"keywords": [
|
|
67
68
|
"dsh-plugin",
|