dsh-cindy-host 0.1.23 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -496
- package/README.md +14 -164
- package/lib/client.js +101 -7
- package/package.json +2 -3
- package/src/dsh-plugin.js +1 -0
- package/src/host-authorization.js +3 -1
- package/src/host-device-delete.js +50 -0
- package/src/host-device-name.js +55 -0
- package/src/host-input-queue.js +33 -8
- package/src/host-routes.js +38 -16
- package/src/host-settings.js +17 -1
- package/src/host-status.js +3 -1
- package/src/host-version.js +7 -0
- package/src/host.js +62 -8
- package/doc/architecture.md +0 -34
- package/doc/cindy-android-verify.md +0 -362
- package/doc/cindy-mobile-freeze-handoff.md +0 -124
- package/doc/cindy-phone-link.md +0 -2902
- package/doc/desktop-runtime-fix-20260930.md +0 -69
- package/doc/development-plan.md +0 -19
- package/doc/file-peer-apply-recovery-20260921.md +0 -35
- package/doc/file-peer-installed-hotfix-20260921.md +0 -53
- package/doc/file-peer-minimal-apply-20260921.md +0 -78
- package/doc/file-peer-original-contract-alignment-20260921.md +0 -119
- package/doc/file-peer-phone-export-observation-20260921.md +0 -75
- package/doc/file-peer-product-contract-audit-20260921.md +0 -106
- package/doc/file-peer-root-cause-20260921.md +0 -53
- package/doc/file-transfer.md +0 -50
- package/doc/host-performance-20260930.md +0 -91
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +0 -87
- package/doc/new-session-sync-fix-20260930.md +0 -59
- package/doc/patches/cindy-peer-silence-probe.patch +0 -1112
- package/doc/projection-read-probe.md +0 -77
- package/doc/publishing.md +0 -259
- package/doc/releasing.md +0 -149
- package/doc/research.md +0 -24
- package/doc/session-discovery-2026-09-20.md +0 -67
package/README.md
CHANGED
|
@@ -1,178 +1,28 @@
|
|
|
1
1
|
# DSH Cindy Host
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A Host-side adapter connecting Cindy desktop and mobile clients to DeepSeek Harness.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
# 从 npm 安装当前发布版本
|
|
11
|
-
bunx @deepseek-ai/dsh plugin --profile web add dsh-cindy-host
|
|
12
|
-
|
|
13
|
-
# pnpm 10 需要显式批准 keytar 的构建脚本
|
|
14
|
-
bunx @deepseek-ai/dsh plugin --profile web approve-builds
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
在批准列表中选择 `keytar`,然后**重启 DSH Web**。重启后可在 **设置 → Plugins** 找到「Cindy 手机连接」:使用手机号和验证码登录,再开启手机连接。
|
|
18
|
-
|
|
19
|
-
有关 `keytar` 的编译环境、失败降级与替代配置,请见[安装与原生模块说明](#安装与原生模块说明)。
|
|
20
|
-
|
|
21
|
-
## Console demo scope
|
|
22
|
-
|
|
23
|
-
- Conversation list: opaque session ID, title, phase, update time.
|
|
24
|
-
- Activity feed: added, removed, and status summaries only.
|
|
25
|
-
- Safe recovery: stream failure emits a generic stale marker and refreshes the list baseline.
|
|
26
|
-
|
|
27
|
-
The projection deliberately excludes message bodies, prompt input, tool calls/results, approval/question payloads, file paths/content, credentials, and command sending. The Cindy transport consumes only what the projection already produced.
|
|
28
|
-
|
|
29
|
-
## Run
|
|
30
|
-
|
|
31
|
-
Requires Node 22 or newer. There are no package dependencies for the fixture demo.
|
|
32
|
-
|
|
33
|
-
```sh
|
|
34
|
-
npm test
|
|
35
|
-
npm run demo
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Cindy login MVP
|
|
39
|
-
|
|
40
|
-
The Host can request a verification code and exchange it for a Cindy session. It never prints tokens; the host runtime stores a session in the operating-system credential store for refresh. It defaults to the China mainland Cindy auth endpoint (`https://auth.cindy.com.cn`):
|
|
7
|
+
Requires Node.js 22 or newer. Use the DSH plugin manager, or:
|
|
41
8
|
|
|
42
9
|
```sh
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
`CINDY_AUTH_BASE_URL` remains an optional override for an explicit non-mainland environment.
|
|
47
|
-
|
|
48
|
-
The command displays only the newly generated Host device handle on success, and persists the session so the settings card picks up the same login.
|
|
49
|
-
|
|
50
|
-
## Cindy phone link (Settings → Cindy 手机连接)
|
|
51
|
-
|
|
52
|
-
The bundle adds its own page, **设置 → Cindy 手机连接**, registered into the
|
|
53
|
-
shell's `settings.section` slot — a feature owning its settings page is the
|
|
54
|
-
documented seat for this, so no shell change was needed. Turning **连接手机** on
|
|
55
|
-
signs this Host in to Cindy, joins the DeviceLink relay, and makes the Host show
|
|
56
|
-
up in the Cindy mobile client's device list; turning it off closes the socket,
|
|
57
|
-
stops the projection, and forgets every device and subscriber.
|
|
58
|
-
|
|
59
|
-
The page shows the five states the Host can be in (未连接 / 登录中 / 等待手机连接 /
|
|
60
|
-
已连接 / 连接失败), collects the Cindy verification-code login when no session
|
|
61
|
-
exists yet, and lists the connected devices with their name and `deviceId`.
|
|
62
|
-
|
|
63
|
-
There is **no QR code**: Cindy has no pairing protocol, and its own "connect your
|
|
64
|
-
phone" QR is just the regional app-download page. The four protocol questions and
|
|
65
|
-
their source evidence are recorded in
|
|
66
|
-
[Cindy phone link](doc/cindy-phone-link.md), together with the design.
|
|
67
|
-
|
|
68
|
-
## 手机大文件下载(0.1.10 起)
|
|
69
|
-
|
|
70
|
-
原 Export 入口按 inline → peer → OSS 回退。**0.1.11 起 OSS 兜底也支持最多2 GiB(含边界)**,流式上传,不再受旧512 MiB上限限制;约1.3 GB真实文件已经OSS下载到手机。实际手机P2P连接问题仍待定位,本次不宣称直连已修复。需更新/重启Host后生效,不含手机上传或断点续传。验证于DSH 0.1.5-rc.2 / Node 24.12.0;Node要求≥22,keytar为原生依赖。详见[文件传输说明](doc/file-transfer.md)。
|
|
71
|
-
|
|
72
|
-
## 手机任务图标(0.1.12 起)
|
|
73
|
-
|
|
74
|
-
DSH Host 仍只有一种执行 harness;手机任务行把 Cindy 的三种 `agentKind` 用作模型图标别名:DeepSeek 来源任意模型显示 Pi,GPT 模型显示 Codex,其他来源已知模型显示 Claude;**0.1.13 起**无法确认来源的图标改为 Claude,Pi 只保留给确认是 DeepSeek 的来源(0.1.12 的未知来源仍显示 Pi)。手机新建时选择的 agent 平台不会更换 DSH harness,实际模型和来源仍以 DSH 会话选择为准;手机的 agent 筛选、标签和最近会话默认项也会跟随图标 kind。手机端无需更新,但 Host 安装新版后必须重启才生效。
|
|
75
|
-
|
|
76
|
-
## Safe isolated DSH smoke test
|
|
77
|
-
|
|
78
|
-
Do **not** add this package to the DSH profile you use every day. Use a disposable `DSH_HOME`; it gives the smoke profile its own bundles, settings, and credentials. These PowerShell commands run from this repository:
|
|
79
|
-
|
|
80
|
-
```powershell
|
|
81
|
-
# Every following dsh command in this terminal uses only this disposable home.
|
|
82
|
-
$env:DSH_HOME = Join-Path (Get-Location) '.sandbox/dsh-home'
|
|
83
|
-
|
|
84
|
-
# Create an isolated Web profile without starting a server, then add this local bundle.
|
|
85
|
-
dsh --profile cindy-smoke --from-default-profile web --dump-config
|
|
86
|
-
dsh plugin --profile cindy-smoke add .
|
|
87
|
-
|
|
88
|
-
# Gate 1: parse and compose the profile only. Do not continue if this fails.
|
|
89
|
-
dsh --profile cindy-smoke --dump-config
|
|
90
|
-
|
|
91
|
-
# Gate 2: start a separate UI without opening a browser or sharing port 3080.
|
|
92
|
-
# transportEnabled is false by default, so this cannot log in or connect to Cindy.
|
|
93
|
-
dsh --profile cindy-smoke --no-open --port 3081
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Run `npm test` before Gate 1. A clean Gate 2 must show a running DSH host on port 3081 and no Cindy authentication prompt. Stop it with `Ctrl+C`; removing `.sandbox/dsh-home` removes the whole test profile and its credentials. Do not enable `transportEnabled` until the mount, RPC, and lifecycle smoke checks are clean.
|
|
97
|
-
|
|
98
|
-
**Never start port 3081 with `--profile web` and the normal `DSH_HOME`.** A second process then shares the production Host's DeviceLink identity; the relay may route a phone's `maker:input:enqueue` to the wrong process, where resuming a session owned by the 3080 process fails with `SessionAlreadyOwnedError` and appears as `[INTERNAL] DSH Host failed to serve this channel`. It can also make the phone's queue/sync status oscillate. Use only the isolated `cindy-smoke` profile above, and check both `/api/dsh-cindy-host/status` endpoints for duplicate `status.host.deviceId` before testing phone sends.
|
|
99
|
-
|
|
100
|
-
With Gate 2 running, two Host surfaces answer over loopback:
|
|
101
|
-
|
|
10
|
+
dsh plugin --profile desktop add dsh-cindy-host@0.1.26
|
|
102
11
|
```
|
|
103
|
-
GET /api/dsh-cindy-host/status # the card's only source of truth
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
The card itself is served as the `dsh-cindy-host` entry of
|
|
107
|
-
`window.__DSH_BOOT__`; `npm test` renders it with React so its markup and state
|
|
108
|
-
rules are covered without a browser.
|
|
109
|
-
|
|
110
|
-
## DSH API compatibility boundary
|
|
111
|
-
|
|
112
|
-
`DshHostSource` has contract tests for the older public `InProcessApiClient(toFetchHandler(ctx.apiProxy))` adapter. Current DSH Web (`0.1.5-rc.2`) does not mount `apiProxy`; it exposes Typert remotes instead. Therefore this bundle mounts safely without a live DSH source when `apiProxy` is absent, and the isolated smoke test validates composition/lifecycle only.
|
|
113
|
-
|
|
114
|
-
**DSH's own packages are `peerDependencies`, never `dependencies`** — the host supplies them, and a plugin that installs its own copy drags a whole DSH generation into the profile and stops the profile from booting. That is what `0.1.1` did; see [`doc/publishing.md`](doc/publishing.md) §5.4 and §5.6 for the measured failure and the rule.
|
|
115
12
|
|
|
116
|
-
|
|
13
|
+
Reload the Host plugin (or restart DSH) after installation, then refresh Settings → Cindy 手机连接. Keep your existing Cindy sign-in and device identity. Do not run two Host processes with the same device identity.
|
|
117
14
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
### 为什么要批准 `keytar`?
|
|
121
|
-
|
|
122
|
-
`keytar` 将 Cindy 登录会话保存在系统凭据库(Windows 凭据管理器、macOS Keychain 或 Linux Secret Service),因此需要下载预编译二进制;没有适配的预编译包时,会回退到本机编译。对 pnpm 10 而言,这一步需要显式批准构建脚本。
|
|
123
|
-
|
|
124
|
-
- Windows 通常需要 **VS Build Tools**(含 C++ 工作负载);macOS 需要 Xcode Command Line Tools;Linux 需要 `libsecret-1-dev` 等构建依赖。
|
|
125
|
-
- 如果未批准或编译失败,插件仍会安装并挂载;仅 Cindy 登录和凭据存储不可用,并会给出明确错误。
|
|
126
|
-
- 不要使用 `0.1.1` 或更早版本的 tarball;它们会向 profile 装入不兼容的一代 DSH 依赖。请始终从 npm 安装当前版本。
|
|
127
|
-
|
|
128
|
-
发布、干净 profile 验证和替代的 `onlyBuiltDependencies` 配置见 [`doc/publishing.md`](doc/publishing.md) §5.2。
|
|
129
|
-
|
|
130
|
-
## 发布指南
|
|
131
|
-
|
|
132
|
-
- [doc/publishing.md](doc/publishing.md) —— **插件发布**:把本插件做成别人/别的 profile 能装上的 npm 包(bundle 三要素、三种消费方式、包内容白名单、干净 profile 验证、撤版)。
|
|
133
|
-
- [doc/releasing.md](doc/releasing.md) —— **改动上线**:门禁 → claw 留证 → 脱敏 → 部署(重启才是生效点) → 回滚。
|
|
134
|
-
|
|
135
|
-
## 仓库治理
|
|
136
|
-
|
|
137
|
-
分支 `main`,远端 `origin` = <https://github.com/chanyuenpang/dsh-cindy-host>(公开)。`v0.1.1`
|
|
138
|
-
已打 tag、推远端,并以 GitHub Release 形式附 tarball 分发(**不进 npm registry**,见
|
|
139
|
-
[`doc/publishing.md`](doc/publishing.md) §5.5)。
|
|
140
|
-
|
|
141
|
-
**入库的内容**:`src/`(插件与运行时)、`lib/`(设置页客户端入口)、`test/`(单测)、
|
|
142
|
-
`tools/`(验收脚本与活体探针)、`doc/`、`package.json` / `package-lock.json`、
|
|
143
|
-
`cordis.patch.yml`,以及 claw 的**项目记忆** `.claw/{tasks,adr,truth}/` 与 `.claw/project.json`。
|
|
144
|
-
|
|
145
|
-
**不入库的内容**(见 `.gitignore`):
|
|
146
|
-
|
|
147
|
-
- `node_modules/`;
|
|
148
|
-
- `.sandbox/` —— 沙箱用的 DSH home,带它自己的凭据与会话存储。
|
|
149
|
-
注意:`.sandbox/dsh-home/profiles/<profile>/node_modules/dsh-cindy-host` 是指向本仓库的
|
|
150
|
-
**junction**(不是副本),所以任何**跟随链接递归**的工具(`git status --ignored`、部分索引器)
|
|
151
|
-
会绕圈并报 `Filename too long`。普通 `git add/status` 因为整目录被忽略不会进去;不要据此
|
|
152
|
-
「清理」出一个递归副本,磁盘上并没有重复的仓库。
|
|
153
|
-
- `.claw/runtime/`、`.claw/logs/`、`.claw/memory.sqlite*` —— claw 守护进程的运行时状态;
|
|
154
|
-
- 仓库根目录的一次性探测残留(`.claw-*.txt|log|mjs|cjs|report`)与 agent 生成的图片。
|
|
155
|
-
|
|
156
|
-
**验证入口**:
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
npm test # 单测(463 项)
|
|
160
|
-
npm run audit:channels # 通道判定表(served 52 / declined 140 / unclassified 0)
|
|
161
|
-
node tools/acceptance.mjs --base http://127.0.0.1:3080 --with-prompts # 端到端(105 项)
|
|
162
|
-
npm run verify # 上面三者的串行组合
|
|
163
|
-
```
|
|
15
|
+
DSH provides its own session/settings services. DSH 0.1 and 0.2 paths are retained; this release is verified with the DSH 0.2.1-rc.3 desktop runtime. Optional keytar supports OS-backed credentials; without its native binary, sign-in reports an unavailable credential store instead of preventing mounting.
|
|
164
16
|
|
|
165
|
-
|
|
166
|
-
它们只读本地 DSH 状态,用来把「手机上看到什么」量化成数字。
|
|
17
|
+
## 0.1.26
|
|
167
18
|
|
|
168
|
-
|
|
19
|
+
- Edit the Host display name in Settings → Cindy 手机连接 → 主机名称. Names are saved locally, support Unicode and are limited to 64 UTF-16 code units. Online names synchronize without forcing a relay reconnect. Local save and cloud sync are reported separately. Device ID and OS computer name do not change.
|
|
20
|
+
- Queue UI content is plain text as expected by current Cindy rendering.
|
|
21
|
+
- Recent durable-consumption evidence prevents late acknowledgments or stale snapshots from restoring already-consumed messages to pending/steering. This bounded record is per runtime, not a persistent message store.
|
|
22
|
+
- Host status reports pluginVersion separately from its Cindy compatibility identity.
|
|
169
23
|
|
|
170
|
-
-
|
|
171
|
-
- [Architecture](doc/architecture.md)
|
|
172
|
-
- [Development plan](doc/development-plan.md)
|
|
173
|
-
- [Cindy phone link](doc/cindy-phone-link.md)
|
|
174
|
-
- [Projection read probe](doc/projection-read-probe.md)
|
|
24
|
+
These changes are Host-only; no modified Cindy client is required. Advanced optional features remain unavailable rather than returning fabricated success. Automated checks are not a claim that every new-client feature is supported or every live incident is resolved.
|
|
175
25
|
|
|
176
|
-
##
|
|
26
|
+
## Rollback
|
|
177
27
|
|
|
178
|
-
|
|
28
|
+
Reinstall dsh-cindy-host@0.1.25 and reload the Host plugin. Preserve profile settings, device ID and the OS credential store.
|
package/lib/client.js
CHANGED
|
@@ -64,11 +64,22 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
signIn: "登录",
|
|
65
65
|
signingIn: "登录中…",
|
|
66
66
|
logout: "退出登录",
|
|
67
|
-
devices: "
|
|
67
|
+
devices: "账号设备",
|
|
68
68
|
deviceOffline: "离线",
|
|
69
69
|
deviceOnline: "在线",
|
|
70
70
|
unknownPlatform: "未知平台",
|
|
71
71
|
hostDevice: "本机设备",
|
|
72
|
+
hostName: "主机名称",
|
|
73
|
+
hostNameHint: "用于 Cindy 设备列表,最多 64 个字符,不影响系统计算机名或设备连接。",
|
|
74
|
+
saveName: "保存名称",
|
|
75
|
+
savingName: "保存中…",
|
|
76
|
+
nameSaved: "主机名称已保存到本机。",
|
|
77
|
+
nameSaveFailed: "保存失败,请重试",
|
|
78
|
+
nameInvalid: "请输入 1–64 个字符,不能包含换行或控制字符",
|
|
79
|
+
nameSyncPending: "连接 Cindy 后会同步名称。",
|
|
80
|
+
nameSyncing: "正在同步到 Cindy…",
|
|
81
|
+
nameSynced: "名称已同步到 Cindy。",
|
|
82
|
+
nameSyncFailed: "本机名称已保存,但 Cindy 同步失败;请检查网络或登录状态,重新开关连接后重试。",
|
|
72
83
|
noDevices: "还没有手机连上这台 Host。",
|
|
73
84
|
unavailable: "Cindy Host 设置命名空间不可用,请确认插件已在本机加载。",
|
|
74
85
|
statusUnavailable: "读取 Host 状态失败,正在重试…",
|
|
@@ -101,11 +112,22 @@ window.__ModuleLoader__.load({
|
|
|
101
112
|
signIn: "Sign in",
|
|
102
113
|
signingIn: "Signing in…",
|
|
103
114
|
logout: "Sign out",
|
|
104
|
-
devices: "
|
|
115
|
+
devices: "Account devices",
|
|
105
116
|
deviceOffline: "offline",
|
|
106
117
|
deviceOnline: "online",
|
|
107
118
|
unknownPlatform: "unknown platform",
|
|
108
119
|
hostDevice: "This Host",
|
|
120
|
+
hostName: "Host name",
|
|
121
|
+
hostNameHint: "Shown in Cindy. Up to 64 characters; does not change your computer name or device identity.",
|
|
122
|
+
saveName: "Save name",
|
|
123
|
+
savingName: "Saving…",
|
|
124
|
+
nameSaved: "Host name saved locally.",
|
|
125
|
+
nameSaveFailed: "Could not save. Please try again.",
|
|
126
|
+
nameInvalid: "Enter 1–64 characters without line breaks or control characters.",
|
|
127
|
+
nameSyncPending: "The name will sync when connected to Cindy.",
|
|
128
|
+
nameSyncing: "Syncing to Cindy…",
|
|
129
|
+
nameSynced: "Name synced to Cindy.",
|
|
130
|
+
nameSyncFailed: "Saved locally, but Cindy sync failed. Check your network or sign-in, then turn the connection off and on to retry.",
|
|
109
131
|
noDevices: "No phone has reached this Host yet.",
|
|
110
132
|
unavailable: "The Cindy Host settings namespace is unavailable on this deployment.",
|
|
111
133
|
statusUnavailable: "Reading Host status failed; retrying…",
|
|
@@ -180,6 +202,7 @@ window.__ModuleLoader__.load({
|
|
|
180
202
|
devices: status && Array.isArray(status.devices) ? status.devices : [],
|
|
181
203
|
login,
|
|
182
204
|
host: (status && status.host) || null,
|
|
205
|
+
deviceName: typeof value.deviceName === "string" && value.deviceName.trim() ? value.deviceName.trim() : "DSH Host",
|
|
183
206
|
showLogin: login.required === true,
|
|
184
207
|
showReconnect: (status ? status.state : "disconnected") === "failed" && login.required !== true,
|
|
185
208
|
};
|
|
@@ -234,6 +257,15 @@ window.__ModuleLoader__.load({
|
|
|
234
257
|
return value.length > 4 ? "***" + value.slice(-4) : "***";
|
|
235
258
|
}
|
|
236
259
|
|
|
260
|
+
function validHostName(value) {
|
|
261
|
+
return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function writeHostName(scope, value) {
|
|
265
|
+
if (!validHostName(value)) throw new Error(TEXT.nameInvalid);
|
|
266
|
+
if (await scope.set("deviceName", value.trim()) === false) throw new Error(TEXT.nameSaveFailed);
|
|
267
|
+
}
|
|
268
|
+
|
|
237
269
|
/** POST JSON to the Host API and always resolve to a parsed body. */
|
|
238
270
|
async function postJson(path, body) {
|
|
239
271
|
const response = await fetch(API + path, {
|
|
@@ -450,8 +482,55 @@ window.__ModuleLoader__.load({
|
|
|
450
482
|
);
|
|
451
483
|
}
|
|
452
484
|
|
|
485
|
+
/** Keep an unsaved draft independent of status polling and settings snapshots. */
|
|
486
|
+
function HostNameForm({ scope, value, writable, busy = false, sync }) {
|
|
487
|
+
const [draft, setDraft] = react.useState(null);
|
|
488
|
+
const [saving, setSaving] = react.useState(false);
|
|
489
|
+
const [error, setError] = react.useState(null);
|
|
490
|
+
const [saved, setSaved] = react.useState(false);
|
|
491
|
+
const savingRef = react.useRef(false);
|
|
492
|
+
const text = draft === null ? value : draft;
|
|
493
|
+
const valid = validHostName(text);
|
|
494
|
+
const disabled = !writable || busy || saving;
|
|
495
|
+
const save = async (event) => {
|
|
496
|
+
event.preventDefault();
|
|
497
|
+
if (disabled || savingRef.current || !valid || text.trim() === value) return;
|
|
498
|
+
savingRef.current = true;
|
|
499
|
+
setSaving(true);
|
|
500
|
+
setError(null);
|
|
501
|
+
setSaved(false);
|
|
502
|
+
try {
|
|
503
|
+
await writeHostName(scope, text);
|
|
504
|
+
setDraft(null);
|
|
505
|
+
setSaved(true);
|
|
506
|
+
} catch { setError(TEXT.nameSaveFailed); }
|
|
507
|
+
finally { savingRef.current = false; setSaving(false); }
|
|
508
|
+
};
|
|
509
|
+
const syncText = sync?.name === value ? {
|
|
510
|
+
pending: TEXT.nameSyncPending, syncing: TEXT.nameSyncing,
|
|
511
|
+
synced: TEXT.nameSynced, failed: TEXT.nameSyncFailed,
|
|
512
|
+
}[sync.state] : null;
|
|
513
|
+
return h("form", { style: styles.form, onSubmit: save },
|
|
514
|
+
h("label", { style: styles.field },
|
|
515
|
+
h("span", { style: styles.label }, TEXT.hostName),
|
|
516
|
+
h("input", { style: Object.assign({}, styles.input, { minWidth: 0 }),
|
|
517
|
+
value: text, maxLength: 64, disabled,
|
|
518
|
+
onChange: (event) => { setDraft(event.target.value); setSaved(false); setError(null); },
|
|
519
|
+
}),
|
|
520
|
+
),
|
|
521
|
+
h("span", { style: styles.hint }, TEXT.hostNameHint),
|
|
522
|
+
draft !== null && !valid ? h("p", { style: styles.error, role: "alert" }, TEXT.nameInvalid) : null,
|
|
523
|
+
h("button", { type: "submit", style: Object.assign({}, styles.buttonGhost, { alignSelf: "flex-start" }),
|
|
524
|
+
disabled: disabled || !valid || text.trim() === value }, saving ? TEXT.savingName : TEXT.saveName),
|
|
525
|
+
error ? h("p", { style: styles.error, role: "alert" }, error) : null,
|
|
526
|
+
saved ? h("p", { style: styles.ok, role: "status" }, TEXT.nameSaved) : null,
|
|
527
|
+
syncText ? h("p", { style: sync.state === "failed" ? styles.error : styles.hint, role: "status" }, syncText) : null,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
453
531
|
/** One device row: a name the user recognizes plus the id they can quote. */
|
|
454
|
-
function DeviceRow({ device }) {
|
|
532
|
+
function DeviceRow({ device, onDelete, busy = false }) {
|
|
533
|
+
const [confirming, setConfirming] = react.useState(false);
|
|
455
534
|
const online = device.online === true;
|
|
456
535
|
return h(
|
|
457
536
|
"div",
|
|
@@ -460,8 +539,15 @@ window.__ModuleLoader__.load({
|
|
|
460
539
|
h(
|
|
461
540
|
"span",
|
|
462
541
|
{ style: styles.deviceMeta },
|
|
463
|
-
(online ? TEXT.deviceOnline : TEXT.deviceOffline) + " · " + platformText(device.platform) + " · " + device.deviceId,
|
|
542
|
+
(online ? TEXT.deviceOnline : TEXT.deviceOffline) + " · " + platformText(device.platform) + " · " + device.deviceId + (device.lastSeenAt ? " · " + device.lastSeenAt : ""),
|
|
464
543
|
),
|
|
544
|
+
onDelete ? h("div", null,
|
|
545
|
+
confirming && device.online === false ? h("div", null,
|
|
546
|
+
h("span", { style: styles.hint }, "从账号中删除此设备?这会移除该设备记录,并非仅隐藏。"),
|
|
547
|
+
h("button", { type: "button", disabled: busy, onClick: () => onDelete(device.deviceId) }, "确认删除"),
|
|
548
|
+
h("button", { type: "button", disabled: busy, onClick: () => setConfirming(false) }, "取消"),
|
|
549
|
+
) : h("button", { type: "button", disabled: busy || device.online !== false, title: online ? "在线设备不能删除,请先断开" : "删除离线设备", onClick: () => setConfirming(true) }, "删除设备"),
|
|
550
|
+
) : null,
|
|
465
551
|
);
|
|
466
552
|
}
|
|
467
553
|
|
|
@@ -533,6 +619,12 @@ window.__ModuleLoader__.load({
|
|
|
533
619
|
}
|
|
534
620
|
}, [refresh]);
|
|
535
621
|
|
|
622
|
+
const onDeleteDevice = (deviceId) => guard(async () => {
|
|
623
|
+
const result = await postJson("/devices/delete", { deviceId });
|
|
624
|
+
if (!result.ok) { setError(result.message || "删除失败,请刷新重试"); return; }
|
|
625
|
+
if (result.status) setStatus(result.status);
|
|
626
|
+
});
|
|
627
|
+
|
|
536
628
|
const onSendCode = ({ kind, identifier }) =>
|
|
537
629
|
guard(async () => {
|
|
538
630
|
setNotice(null);
|
|
@@ -622,6 +714,8 @@ window.__ModuleLoader__.load({
|
|
|
622
714
|
view.message ? h("span", { style: styles.hint }, "· " + view.message) : null,
|
|
623
715
|
),
|
|
624
716
|
|
|
717
|
+
h(HostNameForm, { scope, value: view.deviceName, writable: view.writable, busy, sync: status && status.nameSync }),
|
|
718
|
+
|
|
625
719
|
view.login.authenticated === true
|
|
626
720
|
? h(
|
|
627
721
|
"div",
|
|
@@ -647,12 +741,12 @@ window.__ModuleLoader__.load({
|
|
|
647
741
|
"div",
|
|
648
742
|
{ style: styles.deviceList },
|
|
649
743
|
h("span", { style: styles.label }, TEXT.devices),
|
|
650
|
-
view.devices.length > 0 ? view.devices.map((device) => h(DeviceRow, { key: device.deviceId, device })) : h("span", { style: styles.hint }, TEXT.noDevices),
|
|
744
|
+
view.devices.length > 0 ? view.devices.map((device) => h(DeviceRow, { key: device.deviceId, device, busy, onDelete: view.login.authenticated && device.deviceId !== view.host?.deviceId ? onDeleteDevice : null })) : h("span", { style: styles.hint }, TEXT.noDevices),
|
|
651
745
|
view.host && view.host.deviceId
|
|
652
746
|
? h(
|
|
653
747
|
"span",
|
|
654
748
|
{ style: styles.deviceMeta },
|
|
655
|
-
TEXT.hostDevice + ": " + (view.host.deviceName || "DSH Host") + " · " + view.host.deviceId,
|
|
749
|
+
TEXT.hostDevice + ": " + (view.host.deviceName || "DSH Host") + " · " + view.host.deviceId + (view.host.appVersion ? " · Cindy " + view.host.appVersion : ""),
|
|
656
750
|
)
|
|
657
751
|
: null,
|
|
658
752
|
),
|
|
@@ -757,7 +851,7 @@ window.__ModuleLoader__.load({
|
|
|
757
851
|
// Test seam: the presentation helpers and sub-components, so the markup
|
|
758
852
|
// rules can be rendered and asserted without a browser. Not part of the
|
|
759
853
|
// plugin contract — the runner reads only `apply` and `inject`.
|
|
760
|
-
exports.__internals = { TEXT, NAMESPACE, API, SECTION_ORDER, resolveCardView, bindSettingsPage, stateText, stateColor, platformText, writeSwitch, readStatus, maskIdentifier, Switch, LoginForm, DeviceRow, createCindySettingsPage };
|
|
854
|
+
exports.__internals = { TEXT, NAMESPACE, API, SECTION_ORDER, resolveCardView, bindSettingsPage, stateText, stateColor, platformText, writeSwitch, validHostName, writeHostName, HostNameForm, readStatus, maskIdentifier, Switch, LoginForm, DeviceRow, createCindySettingsPage };
|
|
761
855
|
return module.exports;
|
|
762
856
|
},
|
|
763
857
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cindy-host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,8 +25,7 @@
|
|
|
25
25
|
"cordis.patch.yml",
|
|
26
26
|
"README.md",
|
|
27
27
|
"CHANGELOG.md",
|
|
28
|
-
"LICENSE"
|
|
29
|
-
"doc"
|
|
28
|
+
"LICENSE"
|
|
30
29
|
],
|
|
31
30
|
"type": "module",
|
|
32
31
|
"main": "./src/dsh-plugin.js",
|
package/src/dsh-plugin.js
CHANGED
|
@@ -55,6 +55,7 @@ const HostSettingsSchema = Schema.object({
|
|
|
55
55
|
remoteControlEnabled: Schema.boolean().default(DEFAULT_HOST_SETTINGS.remoteControlEnabled),
|
|
56
56
|
controllers: Schema.dict(ControllerSchema).default({}),
|
|
57
57
|
deviceId: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceId),
|
|
58
|
+
deviceName: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceName),
|
|
58
59
|
sessionFlags: Schema.dict(SessionFlagSchema).default({}),
|
|
59
60
|
});
|
|
60
61
|
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* which is exactly the trap this file now documents.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { HOST_APP_VERSION } from './host-version.js';
|
|
18
|
+
|
|
17
19
|
const CHANNELS = ['device-link:subscribe', 'local-db:sessions:list'];
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -43,7 +45,7 @@ export function acceptLink(frame, acceptedControllers, capabilities = HOST_CAPAB
|
|
|
43
45
|
id: frame.id,
|
|
44
46
|
dst: frame.src,
|
|
45
47
|
payload: {
|
|
46
|
-
appVersion:
|
|
48
|
+
appVersion: HOST_APP_VERSION,
|
|
47
49
|
allowlistHash: computeAllowlistHash(),
|
|
48
50
|
...(capabilities.length === 0 ? {} : { capabilities: [...capabilities] }),
|
|
49
51
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Delete only a known offline peer; credential renewal belongs to the Host owner. */
|
|
2
|
+
export async function deleteAccountDevice({ deviceId, session, snapshot, url, fetchImpl, refreshSession, getSnapshot = () => snapshot }) {
|
|
3
|
+
const fail = (statusCode, message) => ({ ok: false, statusCode, message });
|
|
4
|
+
const validate = () => {
|
|
5
|
+
if (typeof deviceId !== 'string' || !deviceId.trim()) return fail(400, '设备 ID 无效');
|
|
6
|
+
if (!session?.accessToken) return fail(401, '现有登录态不可用,请检查凭据存储');
|
|
7
|
+
const current = getSnapshot();
|
|
8
|
+
if (deviceId === session.deviceId || deviceId === current.host?.deviceId) return fail(409, '不能删除当前主机');
|
|
9
|
+
const device = current.devices.find(row => row.deviceId === deviceId);
|
|
10
|
+
if (!device) return fail(404, '设备不在当前列表中,请刷新');
|
|
11
|
+
if (device.online !== false) return fail(409, '在线设备不能删除,请先将该设备断开');
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
const invalid = validate();
|
|
15
|
+
if (invalid) return invalid;
|
|
16
|
+
try {
|
|
17
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
18
|
+
const response = await fetchImpl(url + '/' + encodeURIComponent(deviceId), {
|
|
19
|
+
method: 'DELETE', headers: { Authorization: 'Bearer ' + session.accessToken, accept: 'application/json', 'Content-Type': 'application/json' },
|
|
20
|
+
signal: AbortSignal.timeout(15000),
|
|
21
|
+
});
|
|
22
|
+
const body = await response.json().catch(() => null);
|
|
23
|
+
if (response.status === 401 && attempt === 0 && refreshSession) {
|
|
24
|
+
const renewed = await refreshSession(session);
|
|
25
|
+
if (renewed) {
|
|
26
|
+
session = renewed;
|
|
27
|
+
const invalid = validate();
|
|
28
|
+
if (invalid) return invalid;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const messages = {
|
|
34
|
+
401: '登录态已过期,未删除设备,也未清除凭据',
|
|
35
|
+
403: '服务拒绝删除:当前账号无权删除此设备',
|
|
36
|
+
404: '服务未找到该设备,请刷新设备列表',
|
|
37
|
+
405: '当前服务不支持设备删除接口',
|
|
38
|
+
409: '服务拒绝删除:设备状态冲突,请刷新设备列表',
|
|
39
|
+
429: '删除请求过于频繁,请稍后重试',
|
|
40
|
+
};
|
|
41
|
+
const code = response.status >= 400 && response.status <= 599 ? response.status : 502;
|
|
42
|
+
return fail(code, messages[code] ?? ('设备删除服务请求失败(HTTP ' + code + '),请稍后重试'));
|
|
43
|
+
}
|
|
44
|
+
if (body?.deleted !== true || body.deviceId !== deviceId) return fail(502, '服务未确认删除,请刷新设备列表');
|
|
45
|
+
return { ok: true, deviceId, deleted: true };
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return fail(502, '无法确认删除结果,请检查网络并刷新列表');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Serialize account display-name writes; no token refresh or relay reconnect. */
|
|
2
|
+
export function createDeviceNameSync({ url, fetchImpl = fetch }) {
|
|
3
|
+
let desired = null;
|
|
4
|
+
let flight = null;
|
|
5
|
+
let controller = null;
|
|
6
|
+
let revision = 0;
|
|
7
|
+
let state = { state: 'idle', name: null };
|
|
8
|
+
|
|
9
|
+
function update(input) {
|
|
10
|
+
desired = input;
|
|
11
|
+
revision++;
|
|
12
|
+
state = { state: input?.name ? 'pending' : 'idle', name: input?.name ?? null };
|
|
13
|
+
if (!flight) drain();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function drain() {
|
|
17
|
+
if (!desired?.name || !desired.session?.accessToken || !desired.deviceId) return;
|
|
18
|
+
flight = (async () => {
|
|
19
|
+
while (desired?.name && desired.session?.accessToken && desired.deviceId) {
|
|
20
|
+
const input = desired;
|
|
21
|
+
const version = revision;
|
|
22
|
+
controller = new AbortController();
|
|
23
|
+
state = { state: 'syncing', name: input.name };
|
|
24
|
+
let ok = false;
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetchImpl(url + '/' + encodeURIComponent(input.deviceId), {
|
|
27
|
+
method: 'PATCH',
|
|
28
|
+
headers: { Authorization: 'Bearer ' + input.session.accessToken, 'content-type': 'application/json', accept: 'application/json' },
|
|
29
|
+
body: JSON.stringify({ name: input.name }),
|
|
30
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]),
|
|
31
|
+
});
|
|
32
|
+
const body = await response.json();
|
|
33
|
+
ok = response.ok && body?.deviceId === input.deviceId && body.name === input.name;
|
|
34
|
+
} catch { /* A local save is not proof of a remote rename. */ }
|
|
35
|
+
if (version !== revision) continue;
|
|
36
|
+
state = { state: ok ? 'synced' : 'failed', name: input.name };
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
})().finally(() => {
|
|
40
|
+
flight = null;
|
|
41
|
+
controller = null;
|
|
42
|
+
// An update can arrive after the last await but before this finalizer.
|
|
43
|
+
if (state.state === 'pending' && desired?.session?.accessToken) drain();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reset(name = null) {
|
|
48
|
+
revision++;
|
|
49
|
+
desired = name ? { name } : null;
|
|
50
|
+
controller?.abort();
|
|
51
|
+
state = { state: name ? 'pending' : 'idle', name };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { update, reset, snapshot: () => ({ ...state }) };
|
|
55
|
+
}
|
package/src/host-input-queue.js
CHANGED
|
@@ -21,6 +21,9 @@ const DSH_MODEL = 'pi';
|
|
|
21
21
|
const DSH_EFFORT = 'default';
|
|
22
22
|
const DSH_PERMISSION_MODE = 'default';
|
|
23
23
|
|
|
24
|
+
/** Bound recent durable-consumption evidence; this is not a second queue store. */
|
|
25
|
+
export const RETIRED_INPUT_LIMIT = 2048;
|
|
26
|
+
|
|
24
27
|
/**
|
|
25
28
|
* The session's working directory, from whichever row shape was handed in.
|
|
26
29
|
*
|
|
@@ -93,8 +96,10 @@ export function toQueuedRemoteMessage(item, session) {
|
|
|
93
96
|
permissionMode: DSH_PERMISSION_MODE,
|
|
94
97
|
workingDir,
|
|
95
98
|
createOpts,
|
|
96
|
-
//
|
|
97
|
-
|
|
99
|
+
// This is a rendered ChatMessage, not the agent's raw SDK content blocks.
|
|
100
|
+
// Desktop promotes it directly into the transcript as the queue drains;
|
|
101
|
+
// its quote/navigation parsers require a string. Keep rich persistence above.
|
|
102
|
+
chatMessage: { role: 'user', clientId, content: text },
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -132,7 +137,7 @@ export function queuedRowFromController({ clientId, text, session }) {
|
|
|
132
137
|
permissionMode: DSH_PERMISSION_MODE,
|
|
133
138
|
workingDir,
|
|
134
139
|
createOpts,
|
|
135
|
-
chatMessage: { role: 'user', clientId, content:
|
|
140
|
+
chatMessage: { role: 'user', clientId, content: value },
|
|
136
141
|
};
|
|
137
142
|
}
|
|
138
143
|
|
|
@@ -145,6 +150,22 @@ export function createInputQueueTracker() {
|
|
|
145
150
|
const queues = new Map();
|
|
146
151
|
/** sessionId -> SessionJob[] */
|
|
147
152
|
const jobs = new Map();
|
|
153
|
+
// Durable consumption can beat prompt acceptance/ACK and stale control reads.
|
|
154
|
+
// Keep proof long enough that those late observations cannot revive a spinner.
|
|
155
|
+
const retired = new Map();
|
|
156
|
+
const retirementKey = (sessionId, clientId) => JSON.stringify([sessionId, clientId]);
|
|
157
|
+
const isRetired = (sessionId, clientId) => retired.has(retirementKey(sessionId, clientId));
|
|
158
|
+
const itemRetired = (sessionId, item) => isRetired(sessionId,
|
|
159
|
+
typeof item?.rpcId === 'string' && item.rpcId !== '' ? item.rpcId : String(item?.id ?? ''));
|
|
160
|
+
|
|
161
|
+
function retire(sessionId, clientId) {
|
|
162
|
+
if (typeof sessionId !== 'string' || !sessionId || typeof clientId !== 'string' || !clientId) return;
|
|
163
|
+
const key = retirementKey(sessionId, clientId);
|
|
164
|
+
retired.delete(key);
|
|
165
|
+
retired.set(key, true);
|
|
166
|
+
while (retired.size > RETIRED_INPUT_LIMIT) retired.delete(retired.keys().next().value);
|
|
167
|
+
mirror(sessionId, clientId, { kind: 'remove' });
|
|
168
|
+
}
|
|
148
169
|
/**
|
|
149
170
|
* sessionId -> controller-owned queue UI state.
|
|
150
171
|
*
|
|
@@ -230,7 +251,7 @@ export function createInputQueueTracker() {
|
|
|
230
251
|
function markItem(sessionId, item, placement) {
|
|
231
252
|
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
232
253
|
const id = item?.id === undefined || item?.id === null ? null : String(item.id);
|
|
233
|
-
if (id === null) return;
|
|
254
|
+
if (id === null || itemRetired(sessionId, item)) return;
|
|
234
255
|
const items = queues.get(sessionId) ?? [];
|
|
235
256
|
const others = items.filter((entry) => String(entry?.id) !== id);
|
|
236
257
|
queues.set(sessionId, [...others, { ...item, id, placement }]);
|
|
@@ -274,7 +295,8 @@ export function createInputQueueTracker() {
|
|
|
274
295
|
|
|
275
296
|
/** Rebuild one session's queue state. */
|
|
276
297
|
function setItems(sessionId, items) {
|
|
277
|
-
|
|
298
|
+
const pending = Array.isArray(items) ? items.filter((item) => !itemRetired(sessionId, item)) : [];
|
|
299
|
+
if (pending.length > 0) queues.set(sessionId, pending);
|
|
278
300
|
else queues.delete(sessionId);
|
|
279
301
|
}
|
|
280
302
|
|
|
@@ -291,7 +313,7 @@ export function createInputQueueTracker() {
|
|
|
291
313
|
queues.clear();
|
|
292
314
|
jobs.clear();
|
|
293
315
|
for (const [sessionId, items] of Object.entries(frame.value?.queues ?? {})) {
|
|
294
|
-
|
|
316
|
+
setItems(sessionId, items);
|
|
295
317
|
}
|
|
296
318
|
for (const [sessionId, entries] of Object.entries(frame.value?.jobs ?? {})) {
|
|
297
319
|
if (Array.isArray(entries)) jobs.set(sessionId, entries);
|
|
@@ -340,7 +362,7 @@ export function createInputQueueTracker() {
|
|
|
340
362
|
const seen = new Set();
|
|
341
363
|
for (const item of source) {
|
|
342
364
|
const row = toQueuedRemoteMessage(item, session);
|
|
343
|
-
if (row === null) continue;
|
|
365
|
+
if (row === null || isRetired(sessionId, row.clientId)) continue;
|
|
344
366
|
seen.add(row.clientId);
|
|
345
367
|
// DSH next-step (steering) is still an inbox wait, not a consumed message.
|
|
346
368
|
// Cindy supports the same id in both fields: the body survives re-entry
|
|
@@ -348,7 +370,7 @@ export function createInputQueueTracker() {
|
|
|
348
370
|
if (item?.placement === 'queued' || item?.placement === 'steering') pendingQueue.push(row);
|
|
349
371
|
if (item?.placement === 'steering') steeringQueueClientIds.push(row.clientId);
|
|
350
372
|
}
|
|
351
|
-
if (pending !== null && !seen.has(pending.clientId)) pendingQueue.push(pending);
|
|
373
|
+
if (pending !== null && !seen.has(pending.clientId) && !isRetired(sessionId, pending.clientId)) pendingQueue.push(pending);
|
|
352
374
|
|
|
353
375
|
const state = uiFor(sessionId);
|
|
354
376
|
return {
|
|
@@ -375,6 +397,7 @@ export function createInputQueueTracker() {
|
|
|
375
397
|
queues.clear();
|
|
376
398
|
jobs.clear();
|
|
377
399
|
ui.clear();
|
|
400
|
+
retired.clear();
|
|
378
401
|
}
|
|
379
402
|
|
|
380
403
|
return {
|
|
@@ -383,6 +406,8 @@ export function createInputQueueTracker() {
|
|
|
383
406
|
jobsFor,
|
|
384
407
|
projectionFor,
|
|
385
408
|
clear,
|
|
409
|
+
/** Only a durable message event is proof of consumption; cancellation is not. */
|
|
410
|
+
retire,
|
|
386
411
|
/** Whether DSH is holding this item; decides steer-promotion vs a new send. */
|
|
387
412
|
hasItem: (sessionId, itemId) => itemOf(sessionId, itemId) !== undefined,
|
|
388
413
|
/**
|