dsh-cindy-host 0.1.20
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 +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,2902 @@
|
|
|
1
|
+
# Cindy Phone Link: protocol findings and design
|
|
2
|
+
|
|
3
|
+
The settings card 「Cindy 手机连接」 makes this DSH Host reachable from the Cindy
|
|
4
|
+
mobile client. Before any UI was written, four questions were answered from
|
|
5
|
+
Cindy's own source. They are recorded here because the answer to the first one
|
|
6
|
+
removed a feature that the original request assumed existed.
|
|
7
|
+
|
|
8
|
+
## The four questions
|
|
9
|
+
|
|
10
|
+
### 1. Does the relay return a pairing token or pairing URL?
|
|
11
|
+
|
|
12
|
+
**No.** There is no pairing concept anywhere in the DeviceLink protocol. The
|
|
13
|
+
relay is a dumb router: it authenticates the WebSocket from the bearer token,
|
|
14
|
+
binds the connection to a Cindy account, and fills `Envelope.src` itself. The
|
|
15
|
+
only identity it ever hands back is in `hello-ack`:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
HelloAckPayload = { serverProtocolVersion, deviceId, userId, capabilities? }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Evidence: `Cindy/packages/device-link-protocol/src/protocol.ts:28-121` (the
|
|
22
|
+
`EnvelopeKind` union has no pairing kind) and `:110-121` (the `hello-ack`
|
|
23
|
+
payload above). A repository-wide search for `配对`/`pairing`/`pairingCode`/
|
|
24
|
+
`deviceCode` across `apps/desktop/src`, `apps/mobile/src`, `packages/` and
|
|
25
|
+
`cindy-protocol/` returns only unrelated request/tool-call pairing.
|
|
26
|
+
|
|
27
|
+
### 2. What QR content does the Android app scan?
|
|
28
|
+
|
|
29
|
+
**The regional mobile download page URL** — and that QR is not part of DeviceLink
|
|
30
|
+
at all. Cindy's own "connect your phone" surface renders the website's
|
|
31
|
+
`/download/` path and tells the user to sign in to the same account:
|
|
32
|
+
|
|
33
|
+
- `Cindy/apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx:119-127`
|
|
34
|
+
builds the code from `websiteUrl` (`resolveMobileDownloadUrl`), canonicalizing
|
|
35
|
+
`cindy.com.cn → cindy.cn`.
|
|
36
|
+
- `.../i18n/locales/zh-CN/common.json:7241` — `"scanToOpen": "扫码打开 Cindy App,并登录同一账号"`.
|
|
37
|
+
|
|
38
|
+
So the phone is linked by **account**, not by the code: scan → install → sign in.
|
|
39
|
+
Because the user dropped the QR requirement, this bundle renders no QR at all
|
|
40
|
+
rather than inventing a payload Cindy does not understand.
|
|
41
|
+
|
|
42
|
+
### 3. Which relay frame reports a successful pairing?
|
|
43
|
+
|
|
44
|
+
**There is none.** Association is account-scoped, so there is no handshake to
|
|
45
|
+
observe. What the Host can observe instead:
|
|
46
|
+
|
|
47
|
+
- `presence-changed` carries a `PresenceSnapshot` for every same-account device
|
|
48
|
+
(`Cindy/packages/device-link/src/protocol.ts` and `.../src/client.ts:2202-2212`).
|
|
49
|
+
- `GET /api/device-link/devices` returns `DeviceView[]` for the whole account
|
|
50
|
+
(`Cindy/packages/device-link/src/protocol.ts:203-219`).
|
|
51
|
+
- A phone that actually uses this Host sends `link-open` (dropped for
|
|
52
|
+
listing-only controllers) or `invoke`.
|
|
53
|
+
|
|
54
|
+
This bundle therefore defines **connected** as *a device opened a link to, or
|
|
55
|
+
invoked, this Host* — an observed event, not a guess. Presence alone shows the
|
|
56
|
+
device row but leaves the state at `waiting`, which is why the card can honestly
|
|
57
|
+
say "等待手机连接" while a phone is merely online.
|
|
58
|
+
|
|
59
|
+
Because any same-account Cindy client is a valid controller, `connected` means
|
|
60
|
+
"a Cindy client reached this Host", not "a phone reached this Host". The device
|
|
61
|
+
list always names the platform, so the card distinguishes them rather than
|
|
62
|
+
hiding a linked desktop behind a green dot.
|
|
63
|
+
|
|
64
|
+
To name those rows, the runtime performs one read-only
|
|
65
|
+
`GET /api/device-link/devices` after `hello-ack`, and once more for a controller
|
|
66
|
+
whose platform is still unknown. `presence-changed` only arrives for devices
|
|
67
|
+
that come online while we are connected, so without this read a device that
|
|
68
|
+
linked earlier would show as a bare hex prefix.
|
|
69
|
+
|
|
70
|
+
### 4. Is there a ready-made QR component or pairing API?
|
|
71
|
+
|
|
72
|
+
There is no pairing API. For rendering, Cindy uses the `qrcode` package's
|
|
73
|
+
`toDataURL` (`MobileDownloadDialog.tsx:129-152`). Unused here, since there is no
|
|
74
|
+
QR.
|
|
75
|
+
|
|
76
|
+
## What the relay does require
|
|
77
|
+
|
|
78
|
+
The relay only routes `link-open`/`invoke` to a target that advertised
|
|
79
|
+
`remoteControlEnabled: true` in `hello` (`CONTROL_KINDS` in
|
|
80
|
+
`device-link-protocol/src/protocol.ts:75`). A Host that stays silent about this
|
|
81
|
+
is invisible to the phone. So the runtime's `hello` carries exactly the user's
|
|
82
|
+
own opt-in: `remoteControlEnabled: policy.isEnabled()`.
|
|
83
|
+
|
|
84
|
+
## The DSH data source
|
|
85
|
+
|
|
86
|
+
The bundle originally read DSH through `ctx.apiProxy` (`InProcessApiClient`).
|
|
87
|
+
Current DSH Web does not mount `apiProxy` — `dsh --dump-config` shows only
|
|
88
|
+
`typert`, `typert-gateway`, `api-remotes` and `session-controller` — so
|
|
89
|
+
`ctx.get('apiProxy')` was permanently `undefined`, the projection never started,
|
|
90
|
+
and the phone received an empty session list. The console demo's fixtures hid
|
|
91
|
+
this; the phone did not.
|
|
92
|
+
|
|
93
|
+
The replacement reads the same facts from services the Web profile does mount
|
|
94
|
+
(`src/dsh-session-source.js`):
|
|
95
|
+
|
|
96
|
+
| Need | Host seam | Counterpart |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| session list | `ctx.sessionController.list()` → `SessionSummary[]` | old `client.sessions.list()` |
|
|
99
|
+
| list lifecycle | cordis `api-session/added\|removed\|status\|activity\|error` | old `events.host()` frames |
|
|
100
|
+
| message history | `sessionController.page()` | `local-db:messages:list` |
|
|
101
|
+
| live session stream | `sessionController.follow()` → `AsyncIterable<SessionFollowFrame>` | `maker:event` |
|
|
102
|
+
| queue / projection | `sessionController.control()` → `AsyncIterable<SessionControlFrame>` | `maker:input:projection` |
|
|
103
|
+
| send / interrupt / queue | `sessionController.prompt()/cancel()/updateQueue()` | `maker:send` / `abort` / `maker:input:*` |
|
|
104
|
+
| create / fork / rename | `sessionController.create()/fork()/rename()` | `maker:create-session` 等 |
|
|
105
|
+
| agent roster | `sessionController.modelCatalog()/resolveAgent()` | `maker:list-available-agents` |
|
|
106
|
+
| titles | `ctx.sessionQuery.readTitleSnapshots(ids)` | session row title |
|
|
107
|
+
| goal | `ctx.goals` (`GoalService`) | `maker:goal:*` |
|
|
108
|
+
| approval | `ctx.approval` (`ApprovalService`) | `maker:resolve-interaction` |
|
|
109
|
+
|
|
110
|
+
### Two traps this seam hit
|
|
111
|
+
|
|
112
|
+
Both are the same mistake — reading a sibling plugin's contribution at `apply`
|
|
113
|
+
time instead of waiting for it:
|
|
114
|
+
|
|
115
|
+
1. **Services.** `ctx.get('sessionController')` in `apply` returned `undefined`
|
|
116
|
+
because the supplying plugin had not activated yet. The fix is
|
|
117
|
+
`ctx.inject(['sessionController'], (ctx) => …)`, which also re-runs on
|
|
118
|
+
removal so a reloaded session API cannot leave the projection reading a dead
|
|
119
|
+
service.
|
|
120
|
+
2. **Ordering against an async runtime.** `startHost` awaits a Cindy session
|
|
121
|
+
(keychain + token refresh) before it resolves, while `ctx.inject` fires
|
|
122
|
+
immediately. Attaching the source only through `runtime?.setSource(...)`
|
|
123
|
+
silently dropped it when `runtime` was still `undefined`. The plugin now
|
|
124
|
+
holds the source and re-applies it after the `await`, which covers both
|
|
125
|
+
orders.
|
|
126
|
+
|
|
127
|
+
The status route reports which seam won (`diagnostics.dataSource`) and how much
|
|
128
|
+
has been projected, so a silent `none` can never be mistaken for "no sessions
|
|
129
|
+
yet":
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
GET /api/dsh-cindy-host/status
|
|
133
|
+
→ "diagnostics":{"dataSource":"session-controller","projectionRunning":true,"projectedSessions":0}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## The channel contract
|
|
137
|
+
|
|
138
|
+
`src/cindy-channels.js` is the Host's half of the DeviceLink tunnel: one invoke
|
|
139
|
+
in, one `invoke-result` out. Implemented so far:
|
|
140
|
+
|
|
141
|
+
| Channel | Behaviour |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `device-link:subscribe` / `:unsubscribe` | registers/releases the controller as a push recipient; accepts the `sessions` and `session:<id>` topics |
|
|
144
|
+
| `local-db:sessions:list` | flat `RemoteSession[]` (also the responsiveness probe, so one cheap read) |
|
|
145
|
+
| `local-db:sessions:get` | the same flat row, by top-level `id` |
|
|
146
|
+
| `local-db:sessions:patch-meta` | applies the title via DSH `rename`; pin/archive have no DSH counterpart and are not pretended |
|
|
147
|
+
| `local-db:messages:list` | the transcript, newest first, paged by `before` |
|
|
148
|
+
| `maker:create-session` | DSH session creation; echoes a controller-preallocated id |
|
|
149
|
+
| `maker:send` | a prompt into the session |
|
|
150
|
+
| `maker:list-active` | `[{ sessionId, isTurnRunning }]` |
|
|
151
|
+
| `maker:get-pending-interactions` / `maker:resolve-interaction` | DSH approvals, answered by the watching controller |
|
|
152
|
+
| `maker:input:get-projection` | the folded `control()` stream's queue |
|
|
153
|
+
| `maker:git-safety:get` | the feature is off; the three booleans are read positionally |
|
|
154
|
+
| `maker:goal:get-status` | folded from the `goal` session projection; never resumes the session |
|
|
155
|
+
| `maker:input:enqueue` / `maker:input:steer` | the composer's send paths (`mode: 'queue'` / `'steer'`); enqueue answers the new projection with the accepted item already in it |
|
|
156
|
+
| `fs:stat-path` / `fs:list-dir` / `text-file:read-preview` | absolute-path reads over `ctx.fs`, translated to the controller's vocabulary |
|
|
157
|
+
| `maker:list-available-agents` / `maker:get-capabilities` / `maker:provider:list` | the agent roster — `pi` only |
|
|
158
|
+
| everything else | `CHANNEL_NOT_ALLOWED` |
|
|
159
|
+
|
|
160
|
+
**As of round 10, every channel the controller actually requests is answered.**
|
|
161
|
+
The only refusals left in a live log are `maker:get-capabilities` for
|
|
162
|
+
`claude-code` / `codex`: the controller probes all three harnesses, this Host
|
|
163
|
+
offers one, and `NOT_AVAILABLE` is the honest reply. That is by design — Cindy's
|
|
164
|
+
picker offers codex / claude / pi, and a single-entry picker was accepted.
|
|
165
|
+
|
|
166
|
+
Still unimplemented but *not currently requested*: `maker:goal:get-status`
|
|
167
|
+
(goal), the todo surface, and the `file-browser` family. Goal needs
|
|
168
|
+
`sessionController.resolveAgent`, which **activates** the session's agent — a
|
|
169
|
+
side effect that should not be triggered by a status query — so it needs a
|
|
170
|
+
design decision rather than a quick channel.
|
|
171
|
+
|
|
172
|
+
`CHANNEL_NOT_ALLOWED` is not a failure: it is what a Cindy controlled device
|
|
173
|
+
answers for a channel it does not serve, and the controller degrades on it. A
|
|
174
|
+
channel we accepted and then failed would be worse — the phone would show a
|
|
175
|
+
broken feature instead of an absent one. `NOT_AVAILABLE` is reserved for "this
|
|
176
|
+
DSH profile exposes no session API at all", so "no capability" stays
|
|
177
|
+
distinguishable from "it broke".
|
|
178
|
+
|
|
179
|
+
An invoke with no `id` or no `src` is answered with **nothing**: the relay could
|
|
180
|
+
not route such a reply, and a fabricated frame would be worse than silence.
|
|
181
|
+
|
|
182
|
+
### The wire shape is the FLAT `RemoteSession`, and it cost a day
|
|
183
|
+
|
|
184
|
+
`local-db:sessions:list` returns a flat `RemoteSession[]` — identity and status at
|
|
185
|
+
the top level — and `sessions:get` returns exactly one such row. `title` is part
|
|
186
|
+
of it; nothing else is.
|
|
187
|
+
|
|
188
|
+
`RemoteSessionListItem` (the `{ session: {…}, subtitle, detail, … }` view model)
|
|
189
|
+
is built **inside the controller's own renderers**
|
|
190
|
+
(`maker-shared/sessionList.ts`, `mobileHome.ts`) and must never cross the wire.
|
|
191
|
+
|
|
192
|
+
An earlier revision here emitted that view model, citing a
|
|
193
|
+
`mobileMakerTransport.listSessions()` that **does not exist in the Cindy tree**.
|
|
194
|
+
The controller neither validates nor unwraps: it hands the invoke result straight
|
|
195
|
+
to `remoteSessionStore.setDeviceSessions`, which de-dupes on `session.id`. So a
|
|
196
|
+
wrapped row arrives with `id`, `status` and `deviceLinkDeviceId` all `undefined`
|
|
197
|
+
and is discarded by **three independent filters**, without one error anywhere:
|
|
198
|
+
|
|
199
|
+
1. de-dupe key `session.id` → every row collapses onto a single `undefined` key;
|
|
200
|
+
2. status filter, default `'active'` → `undefined !== 'active'`;
|
|
201
|
+
3. device filter `canonicalDeviceId ?? deviceLinkDeviceId === selectedDeviceId`
|
|
202
|
+
→ both `undefined`.
|
|
203
|
+
|
|
204
|
+
The symptom is exactly "the device is online, but it has no tasks" — invisible
|
|
205
|
+
from the Host, which logs `ok: true, items: 1`.
|
|
206
|
+
|
|
207
|
+
Two more details of the flat row:
|
|
208
|
+
|
|
209
|
+
- **`deviceLinkDeviceId` / `deviceLinkDeviceName` must be top-level.** The
|
|
210
|
+
controller's home aggregates sessions from every linked computer, so a row that
|
|
211
|
+
names no device is dropped once the user selects this Host — the only way to
|
|
212
|
+
see it.
|
|
213
|
+
- **`maker:list-active` has its own shape**, `[{ sessionId, isTurnRunning }]`.
|
|
214
|
+
Answering it with session rows is silently skipped, and surfaces only as a
|
|
215
|
+
running badge that never lights up.
|
|
216
|
+
|
|
217
|
+
### The gate that catches shape errors
|
|
218
|
+
|
|
219
|
+
`tools/probe-list-shape.ts` runs the controller's **own** builders over the row
|
|
220
|
+
this Host serves, with the retired wrapped shape as a control:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
control (wrapped) → home_rows: 0, detail_active_rows: 0
|
|
224
|
+
actual (flat) → home_rows: 1, detail_active_rows: 1
|
|
225
|
+
shape gate passed: the served rows render, the wrapped control does not.
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`G:\Projects\Cindy\node_modules\.bin\tsx.cmd tools\probe-list-shape.ts`
|
|
229
|
+
|
|
230
|
+
Losing any of `id`, `status`, `deviceLinkDeviceId`, `deviceLinkDeviceName` at
|
|
231
|
+
the top level brings the symptom straight back, so the regression test asserts
|
|
232
|
+
those fields rather than a row count.
|
|
233
|
+
|
|
234
|
+
### Two capabilities that arrive late
|
|
235
|
+
|
|
236
|
+
The DSH services this Host reads (`sessionController`, `sessionQuery`) are
|
|
237
|
+
provided by *other* plugins and may activate after this one. `ctx.get` at `apply`
|
|
238
|
+
time yields nothing; the fix is `ctx.inject`. Even then, `startHost` awaits a
|
|
239
|
+
Cindy session (keychain + token refresh) while `ctx.inject` fires immediately —
|
|
240
|
+
so anything captured at construction freezes empty, and a perfectly capable Host
|
|
241
|
+
answers `NOT_AVAILABLE` forever. Both the session source and the write
|
|
242
|
+
capabilities are therefore resolved **per request** (`setSource`,
|
|
243
|
+
`resolveCapabilities`), never captured.
|
|
244
|
+
|
|
245
|
+
`SessionSummary` carries no `createdAt`, but the row requires one. The source
|
|
246
|
+
reads it from the session header **once per session id** (creation time and cwd
|
|
247
|
+
are immutable, so the cache cannot go stale) rather than on every listing —
|
|
248
|
+
`sessions:list` is also the phone's responsiveness probe and must stay cheap.
|
|
249
|
+
|
|
250
|
+
### Finding this class of bug
|
|
251
|
+
|
|
252
|
+
A controller's view is invisible from the Host, so "the phone shows nothing" has
|
|
253
|
+
three very different causes: the channel was never asked for, we refused it, or
|
|
254
|
+
we answered with something unusable. The status route therefore reports the last
|
|
255
|
+
20 invokes it served:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
GET /api/dsh-cindy-host/status → diagnostics.recentInvokes
|
|
259
|
+
[{ channel: "local-db:sessions:list", ok: true, items: 1, bytes: 820, ... },
|
|
260
|
+
{ channel: "maker:get-capabilities", ok: false, code: "CHANNEL_NOT_ALLOWED" }]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Only the channel name, outcome, item count, reply size, and timestamp are kept —
|
|
264
|
+
never a payload. That log is what turned "点不开" from a guess into the
|
|
265
|
+
device-stamp bug above.
|
|
266
|
+
|
|
267
|
+
### The agent kind is `pi`, always
|
|
268
|
+
|
|
269
|
+
Cindy's controller has a harness picker limited to **codex / claude / pi**, and
|
|
270
|
+
`MobileAgentKind` is a closed union (`apps/mobile/src/device-link/mobileMakerTransport.ts`).
|
|
271
|
+
A row whose `agentKind` is outside it has no label, no capability entry, and no
|
|
272
|
+
picker entry on the phone.
|
|
273
|
+
|
|
274
|
+
Whatever internal agent drives a DSH session is therefore erased at this
|
|
275
|
+
boundary: every session is presented as `pi`, and `maker:list-available-agents`
|
|
276
|
+
answers `['pi']` so the roster and the rows agree. `maker:get-capabilities` for
|
|
277
|
+
any other kind is refused with `NOT_AVAILABLE` rather than answered with a
|
|
278
|
+
capability this Host does not have.
|
|
279
|
+
|
|
280
|
+
An early stub in this repository reported `agentKind: 'pi'` and the phone showed
|
|
281
|
+
the task; a later rewrite reported `'dsh'` and the task silently disappeared —
|
|
282
|
+
the symptom was an empty list, with no error anywhere, while the Host logged
|
|
283
|
+
`ok: true, items: 1`.
|
|
284
|
+
|
|
285
|
+
### Verifying a row without a phone
|
|
286
|
+
|
|
287
|
+
`tools/probe-mobile-home.ts` runs the **controller's own** rendering code over the
|
|
288
|
+
exact row this Host serves, using Cindy's `tsx`:
|
|
289
|
+
|
|
290
|
+
```powershell
|
|
291
|
+
G:\Projects\Cindy\node_modules\.bin\tsx.cmd tools\probe-mobile-home.ts
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
It exercises both builders — `buildMobileHomePresentation` (the home, which
|
|
295
|
+
filters by `canonicalDeviceId ?? deviceLinkDeviceId === selectedDeviceId`) and
|
|
296
|
+
`buildRemoteSessionSections` (the device detail page) — and reports how many rows
|
|
297
|
+
survive each. This is what turned "the phone shows nothing" from a guess into a
|
|
298
|
+
specific missing field, and it is much faster than a round trip through a real
|
|
299
|
+
handset.
|
|
300
|
+
|
|
301
|
+
### The live stream needs `session:<id>` subscriptions
|
|
302
|
+
|
|
303
|
+
Two topics exist, and only two are served: `sessions` for list-level changes and
|
|
304
|
+
`session:<id>` for one session's live stream. `acceptTopics` used to accept only
|
|
305
|
+
`sessions`, so the controller's `session:<id>` subscription was **silently
|
|
306
|
+
dropped**: the subscribe call succeeded, the reply looked right, and no stream
|
|
307
|
+
ever arrived.
|
|
308
|
+
|
|
309
|
+
Pushes are routed per topic, not broadcast. `sessionSubscribers` keeps
|
|
310
|
+
`sessionId -> Set<deviceId>`, and a message push reaches exactly the controllers
|
|
311
|
+
watching that session — sending one session's transcript to every subscriber
|
|
312
|
+
would leak it into another session's view.
|
|
313
|
+
|
|
314
|
+
The payload is exactly what the controller's `local-db:messages:created` handler
|
|
315
|
+
reads (`apps/mobile/src/session/remoteSessionStore.ts`):
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
{ sessionId, message } // message is one RemoteMessage row
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
So a live append and a transcript read produce the same rows from the same fold
|
|
322
|
+
(`foldSessionEvent` / `toCindyMessages`), and the subscription set is cleared when
|
|
323
|
+
the switch goes off.
|
|
324
|
+
|
|
325
|
+
### Approvals are an answerer, not a feature
|
|
326
|
+
|
|
327
|
+
DSH raises `approval/request` as a **waterfall** event
|
|
328
|
+
(`@deepseek-ai/dsh-user-approval`), and the chain's terminal answerer is
|
|
329
|
+
fail-closed. That dictates the design:
|
|
330
|
+
|
|
331
|
+
- **No Cindy controller watching that session → `next()`.** The question passes
|
|
332
|
+
down the chain so the local UI, or the fail-closed default, decides. A Host
|
|
333
|
+
that swallowed the question would silently deny work the user could have
|
|
334
|
+
approved at the desk — a worse failure than not answering at all.
|
|
335
|
+
- **A controller watching → register, push, wait.** `host-approvals.js` keeps the
|
|
336
|
+
open questions, `maker:interaction-request` pushes the card, and
|
|
337
|
+
`maker:resolve-interaction` settles it.
|
|
338
|
+
|
|
339
|
+
The vocabularies differ and must be translated, not passed through: the
|
|
340
|
+
controller answers `{ kind: 'permission', behavior: 'allow' | … }`, while DSH
|
|
341
|
+
speaks `'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'` — and
|
|
342
|
+
`'allowed-once'` is the only grant DSH defines, so every non-allow answer becomes
|
|
343
|
+
`'rejected'`. An id the Host does not know, or a decision it cannot map, is
|
|
344
|
+
**refused rather than acknowledged**, so the controller keeps the card open
|
|
345
|
+
instead of believing it was answered.
|
|
346
|
+
|
|
347
|
+
Three lifecycle rules, each with a test: an aborted ask settles `cancelled` and
|
|
348
|
+
discards a late answer; an unanswered question times out rather than hanging
|
|
349
|
+
DSH's turn; and turning the switch off settles every open question, because a
|
|
350
|
+
question asked over a link that is going away can never be answered.
|
|
351
|
+
|
|
352
|
+
`maker:get-pending-interactions` is also requested by the controller on every
|
|
353
|
+
session open, so it answers even with nothing pending — an empty list is the
|
|
354
|
+
honest answer, and refusing it would look like a broken feature.
|
|
355
|
+
|
|
356
|
+
### The input queue is a stream, not a query
|
|
357
|
+
|
|
358
|
+
`maker:input:get-projection` cannot be answered from stored state: DSH publishes
|
|
359
|
+
the queue and job state as an `AsyncIterable` of control frames from
|
|
360
|
+
`sessionController.control()` — a `baseline` followed by per-session `queue` and
|
|
361
|
+
`jobs` replacements. `host-input-queue.js` folds that stream and answers from the
|
|
362
|
+
fold.
|
|
363
|
+
|
|
364
|
+
The controller's reader is defensive — an entry survives only with `clientId`,
|
|
365
|
+
`text`, `persistedContent`, `model`, `workingDir`, `createOpts` and
|
|
366
|
+
`chatMessage.role === 'user'`
|
|
367
|
+
(`apps/mobile/src/session/inputProjection.ts`) — so this Host emits an entry only
|
|
368
|
+
when it can fill all of those, and otherwise emits a shorter queue. A row the
|
|
369
|
+
controller would silently discard is worse than an absent one: it costs a corpus
|
|
370
|
+
read and teaches nothing.
|
|
371
|
+
|
|
372
|
+
The error/recovery fields describe a turn that failed inside the *controller's*
|
|
373
|
+
agent; DSH reports those as session events instead, so they are emitted as empty
|
|
374
|
+
rather than invented.
|
|
375
|
+
|
|
376
|
+
The channel also answers when no queue support is wired at all: the controller
|
|
377
|
+
requests it on every session open, and an empty projection is the honest answer
|
|
378
|
+
where a refusal looks like a broken composer.
|
|
379
|
+
|
|
380
|
+
#### The field was called `workingDir` on one side and `cwd` on the other
|
|
381
|
+
|
|
382
|
+
Every projection this Host built was empty, and the reason was a name:
|
|
383
|
+
|
|
384
|
+
- the cache behind `sessionRowFor` holds **source** rows, whose directory field
|
|
385
|
+
is `cwd` (`dsh-session-source.js`);
|
|
386
|
+
- `cindy-session-row.js` renames it to `workingDir` when building the row the
|
|
387
|
+
controller reads;
|
|
388
|
+
- both queue builders read `session.workingDir` — so on a cached source row they
|
|
389
|
+
always saw `undefined` and returned `null`.
|
|
390
|
+
|
|
391
|
+
The visible damage was not a missing queue row. It was **duplicate prompts**:
|
|
392
|
+
`maker:input:enqueue` answers with the projection that must already contain the
|
|
393
|
+
item just accepted, so with `pendingQueue: []` the phone never settled its
|
|
394
|
+
composer. The draft stayed in the box, the spinner kept turning, and the user's
|
|
395
|
+
next action — 插话, which steers — sent the identical text again. Two byte-equal
|
|
396
|
+
user messages 39 seconds apart is what that looks like in the transcript.
|
|
397
|
+
|
|
398
|
+
It also emptied `maker:input:get-projection` for *real* queued items, so the
|
|
399
|
+
queue panel could never show anything.
|
|
400
|
+
|
|
401
|
+
`workingDirOf(session)` now accepts either name, and two tests pin it: one on the
|
|
402
|
+
builder, and one that wires the **real** `queuedRowFromController` into the
|
|
403
|
+
router over a `{ cwd }` row. The original test used a stub `queuedRow` that
|
|
404
|
+
ignored the session entirely, which is why a wrong name survived a full suite.
|
|
405
|
+
|
|
406
|
+
Measured on the live Host through the self-test route:
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
before: enqueue -> ack pendingQueue=[] (composer never settles)
|
|
410
|
+
after: enqueue -> ack pendingQueue=[selftest-push-1]
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
The trap generalises: **a row that two modules both read is a contract, and the
|
|
414
|
+
rename between the cache and the wire is where it breaks.** A stub at the seam
|
|
415
|
+
hides it perfectly, because the stub is written in the shape the caller expects.
|
|
416
|
+
|
|
417
|
+
### Todos need no channel — but they do need the right tool *name*
|
|
418
|
+
|
|
419
|
+
The controller receives no todo channel. It reads them out of a **message**:
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
message.content.input.todos -> [{ content, status, activeForm? }]
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
and this Host's `tool_use` rows carry `content.input` = the parsed tool
|
|
426
|
+
arguments. DSH's `todo_write` takes exactly `{ todos: [{ content, status }] }`
|
|
427
|
+
(`dsh-tool-todo`).
|
|
428
|
+
|
|
429
|
+
The shape is necessary but **not sufficient**, and believing otherwise cost a
|
|
430
|
+
round trip: the phone selects the card **by name**, not by shape.
|
|
431
|
+
`extractPlanTodos` returns todos for `TodoWrite` (and `update_plan`) and `null`
|
|
432
|
+
for every other name, so a row named `todo_write` renders nothing no matter how
|
|
433
|
+
correct its input is. `cindy-message-row.js` therefore translates the one name it
|
|
434
|
+
has verified — `todo_write → TodoWrite` — and passes the data through untouched.
|
|
435
|
+
|
|
436
|
+
Confirmed against a real session: the transcript's `todo_write` call now leaves
|
|
437
|
+
this Host as
|
|
438
|
+
|
|
439
|
+
```
|
|
440
|
+
toolName=TodoWrite todos=3
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Two lessons, both about verification rather than protocol:
|
|
444
|
+
|
|
445
|
+
- **A probe must enter through the branch that decides.** `tools/probe-todo.ts`
|
|
446
|
+
originally called `extractTodosFromSourceMessage` directly, which skips the
|
|
447
|
+
name gate — so it passed for a whole round while the phone showed nothing. It
|
|
448
|
+
now calls `extractPlanTodos(toolName, input)` and fails on a wrong name.
|
|
449
|
+
- **`extractTodosFromSourceMessage` is not the gate.** It is the *extractor* the
|
|
450
|
+
gate calls. Testing the extractor tests half the contract.
|
|
451
|
+
|
|
452
|
+
The general rule stands: **check whether the controller derives the thing from
|
|
453
|
+
what you already send** before building a channel. But "it derives it" is only
|
|
454
|
+
true once you have run the derivation the way the controller runs it.
|
|
455
|
+
|
|
456
|
+
### A push is only as good as the subscription behind it
|
|
457
|
+
|
|
458
|
+
`pushSessionUpdate` fans out to the controllers holding `session:<id>`, and
|
|
459
|
+
returns silently when nobody holds it. So "the phone never updated" has two
|
|
460
|
+
indistinguishable causes — no subscriber, or a frame the controller dropped.
|
|
461
|
+
|
|
462
|
+
Both halves were verified locally, without a phone, using the self-test route:
|
|
463
|
+
|
|
464
|
+
1. subscribe as a controller, then watch `diagnostics.subscriptions`;
|
|
465
|
+
2. `maker:input:enqueue` and watch `diagnostics.recentPushes`.
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
subscribe session:<id> -> sessions=[{sessionId:…, devices:[selftest]}]
|
|
469
|
+
enqueue -> local-db:messages:created watchers=1 (the prompt)
|
|
470
|
+
local-db:messages:created watchers=1 (the answer)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
That the answer appears as its own push is the point: **a live reply needs the
|
|
474
|
+
`session:<id>` subscription and nothing else.** When a handset shows nothing, read
|
|
475
|
+
`subscriptions.sessions` first — an empty list there is not a push bug, it is a
|
|
476
|
+
missing subscription.
|
|
477
|
+
|
|
478
|
+
`recentPushes` and `subscriptions` exist for exactly this, and neither existed
|
|
479
|
+
while the phone was silently receiving nothing.
|
|
480
|
+
|
|
481
|
+
### Goals are read from a projection, never by waking a session
|
|
482
|
+
|
|
483
|
+
`ctx.goals` is keyed by `Agent`, and the only public way to obtain one session's
|
|
484
|
+
Agent is `sessionController.resolveAgent(sessionId)` — which **resumes** the
|
|
485
|
+
session. The controller asks for goal status on *every* session open, so
|
|
486
|
+
answering it that way would resume a cold session to look at it: a side effect
|
|
487
|
+
nobody asked for.
|
|
488
|
+
|
|
489
|
+
It is also unnecessary. `dsh-goal` registers a session projection under the key
|
|
490
|
+
`goal` (`dsh-goal/lib/index.js`), and projection frames ride the same
|
|
491
|
+
`sessionController.control()` stream this Host already folds for the input queue.
|
|
492
|
+
So the goal arrives as data and a status read costs nothing.
|
|
493
|
+
|
|
494
|
+
The vocabularies line up for free: DSH phases are `active | paused | blocked |
|
|
495
|
+
complete`, a direct subset of the controller's `… | budgetLimited |
|
|
496
|
+
usageLimited` (`maker-shared/deviceLinkContract.ts`), so the mapping is the
|
|
497
|
+
identity. This Host has no goal token budget, so `budgetTokens` /
|
|
498
|
+
`noProgressLimit` / `usageResetAt` are `null` rather than a fabricated zero the
|
|
499
|
+
card would render as real usage.
|
|
500
|
+
|
|
501
|
+
One distinction matters: the controller treats `undefined` as "never fetched" and
|
|
502
|
+
`null` as "fetched, no goal", so an unseen projection stays `undefined` instead
|
|
503
|
+
of being flattened.
|
|
504
|
+
|
|
505
|
+
The **write** commands (`goal:set|pause|resume|clear`) are a separate task: those
|
|
506
|
+
legitimately need `resolveAgent`, because the user is acting on that session — the
|
|
507
|
+
rule is that a *status read* must not wake a session, not that waking is always
|
|
508
|
+
wrong.
|
|
509
|
+
|
|
510
|
+
### Fail-closed is a property, not a sample
|
|
511
|
+
|
|
512
|
+
`test/cindy-channels-fail-closed.test.js` proves the guard against Cindy's own
|
|
513
|
+
`REMOTE_INVOKE_ALLOWLIST` rather than a hand-picked list. The fixture
|
|
514
|
+
(`test/fixtures/cindy-invoke-allowlist.json`) is extracted from
|
|
515
|
+
`allowlist.ts` with the constant references its blocks use resolved, and is
|
|
516
|
+
documented there as a **lower bound** — `REMOTE_RESOURCE_CHANNELS` is a spread
|
|
517
|
+
from another module and `DL_VOICE_CREDENTIAL_SYNC` is not a plain literal, so
|
|
518
|
+
both are absent.
|
|
519
|
+
|
|
520
|
+
The property is two-sided:
|
|
521
|
+
|
|
522
|
+
- **outside** the supported set, every allowlisted channel answers exactly
|
|
523
|
+
`CHANNEL_NOT_ALLOWED` — the one code the controller degrades on;
|
|
524
|
+
- **inside** it, no channel ever answers `CHANNEL_NOT_ALLOWED`, because a
|
|
525
|
+
supported channel that hid itself would make the controller hide a feature that
|
|
526
|
+
is actually there. (`BAD_REQUEST` / `NOT_FOUND` for an empty-args invoke is
|
|
527
|
+
correct — that is a validation answer, not absence.)
|
|
528
|
+
|
|
529
|
+
Writing that test immediately found a hole in its own first rule, not in the
|
|
530
|
+
implementation: the original rule treated a validation error from a supported
|
|
531
|
+
channel as a fail-closed violation. The two-sided form says what was meant.
|
|
532
|
+
|
|
533
|
+
## Design
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
设置 → Cindy 手机连接 (lib/client.js)
|
|
537
|
+
│ settings.section, id = dsh-cindy-host
|
|
538
|
+
│ reads/writes transportEnabled via ctx.settingsScope
|
|
539
|
+
│ polls GET /api/dsh-cindy-host/status
|
|
540
|
+
▼
|
|
541
|
+
src/dsh-plugin.js settings namespace + optional /api route
|
|
542
|
+
▼
|
|
543
|
+
src/host.js runtime: session → relay socket → projection → status
|
|
544
|
+
├── src/host-status.js the five states and the device ledger
|
|
545
|
+
├── src/host-routes.js status / reconnect / login / logout
|
|
546
|
+
├── src/cindy-login-flow.js non-interactive request-code → verify-code
|
|
547
|
+
└── src/authorization-policy.js who may control this Host
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
The page is a `settings.section` contribution rather than a
|
|
551
|
+
`settings.plugin.item` card. It carries a switch, a sign-in flow and a device
|
|
552
|
+
list — a page's worth of state — and the section slot is where the shell's own
|
|
553
|
+
contract puts "a feature owns its own settings page". `slots.inject` again, for
|
|
554
|
+
the same reason as the plugin tab: the shell declares the slot when
|
|
555
|
+
`ui-settings-general` activates, which may be after this bundle.
|
|
556
|
+
|
|
557
|
+
### Host is the only truth
|
|
558
|
+
|
|
559
|
+
The card never decides whether the Host is connected. The switch is a settings
|
|
560
|
+
write; everything drawn on the card comes from the last `status` answer:
|
|
561
|
+
|
|
562
|
+
| State | Means |
|
|
563
|
+
|---|---|
|
|
564
|
+
| `disconnected` | switch off, or no session yet |
|
|
565
|
+
| `authenticating` | reading/refreshing the Cindy session, or dialing the relay |
|
|
566
|
+
| `waiting` | relay online (`hello-ack` received), no phone has reached us |
|
|
567
|
+
| `connected` | a phone opened a link or invoked this Host |
|
|
568
|
+
| `failed` | a concrete failure; `message` names it |
|
|
569
|
+
|
|
570
|
+
### Switch off means torn down
|
|
571
|
+
|
|
572
|
+
Turning the switch off closes the WebSocket, stops the DSH session projection,
|
|
573
|
+
and clears the in-process subscriber set, accepted controllers, and device
|
|
574
|
+
ledger. The projection is not left idling: it exists to feed the phone.
|
|
575
|
+
|
|
576
|
+
### Login lives in the card
|
|
577
|
+
|
|
578
|
+
A host has no TTY, so the CLI's readline fallback could never run there. The
|
|
579
|
+
card drives the same three Cindy auth calls as steps: request code, verify code,
|
|
580
|
+
and (when Cindy answers `select_account`) pick an account. Tokens go to the OS
|
|
581
|
+
credential store through `keytar` and are never sent to the browser — the card
|
|
582
|
+
only ever sees booleans and a masked identifier.
|
|
583
|
+
|
|
584
|
+
### Who may control this Host
|
|
585
|
+
|
|
586
|
+
Admission needs two facts: the switch is on, and the device is not explicitly
|
|
587
|
+
revoked. There is deliberately no per-device enrollment step, because the relay
|
|
588
|
+
only routes frames between devices of one Cindy account — a `link-open` that
|
|
589
|
+
arrives here is already same-account. Revocation stays honored, so an enrollment
|
|
590
|
+
UI can be added later without reworking this decision.
|
|
591
|
+
|
|
592
|
+
## Boundaries
|
|
593
|
+
|
|
594
|
+
- Not changed: the DSH Web shell, the shipped presets, and the user's main
|
|
595
|
+
profile. The smoke test uses a disposable `DSH_HOME`.
|
|
596
|
+
- Out of scope by request: device management, revocation UI, remote-control
|
|
597
|
+
configuration, and any QR surface.
|
|
598
|
+
- The relay's own source (`apps/server`) is not in the Cindy checkout, so relay
|
|
599
|
+
behaviour here is taken from the shared protocol package both sides compile
|
|
600
|
+
against, not from the server implementation.
|
|
601
|
+
- `fs-watch:<workdir>` is a legal topic that this Host deliberately does **not**
|
|
602
|
+
accept: there is no file-watch engine behind it, so acknowledging it would
|
|
603
|
+
promise `maker:file-browser:event` pushes that never come. File browsing works
|
|
604
|
+
on demand (`fs:stat-path` / `fs:list-dir` / `text-file:read-preview`); only the
|
|
605
|
+
live directory refresh is absent.
|
|
606
|
+
|
|
607
|
+
## Verifying by hand
|
|
608
|
+
|
|
609
|
+
```powershell
|
|
610
|
+
$env:DSH_HOME = Join-Path (Get-Location) '.sandbox/dsh-home'
|
|
611
|
+
dsh --profile cindy-smoke --from-default-profile web --dump-config
|
|
612
|
+
dsh plugin --profile cindy-smoke add .
|
|
613
|
+
dsh --profile cindy-smoke --dump-config # Gate 1: composition
|
|
614
|
+
dsh --profile cindy-smoke --no-open --port 3081 # Gate 2: a second UI
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Then, against `http://127.0.0.1:3081`:
|
|
618
|
+
|
|
619
|
+
```
|
|
620
|
+
GET /api/dsh-cindy-host/status → {"ok":true,"installed":true,"status":{…}}
|
|
621
|
+
GET /?token=… → window.__DSH_BOOT__ lists dsh-cindy-host
|
|
622
|
+
GET /plugins/??dsh-cindy-host/client.js&rev=… → the card bundle
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
### The assembled Host, not the unit tests
|
|
626
|
+
|
|
627
|
+
```powershell
|
|
628
|
+
npm run verify # unit tests + channel audit + acceptance, in that order
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Each part answers a different question, and the order is deliberate:
|
|
632
|
+
|
|
633
|
+
| command | the question it answers |
|
|
634
|
+
|---|---|
|
|
635
|
+
| `npm test` | do the mappings, shapes and guards hold in isolation? |
|
|
636
|
+
| `npm run audit:channels` | is every channel the phone names either served, or declined **with a recorded reason**? |
|
|
637
|
+
| `npm run acceptance` | does the assembled Host serve them over the route a phone uses, and does every push it claims to make actually go out? |
|
|
638
|
+
|
|
639
|
+
`npm run acceptance -- --with-prompts` additionally drives real agent turns — a queue that
|
|
640
|
+
is genuinely held, the interaction round-trip, and the send paths — which is what takes
|
|
641
|
+
the report from "34/48 exercised" to **48/48**, at the cost of a few model calls. It is
|
|
642
|
+
off by default for that reason.
|
|
643
|
+
|
|
644
|
+
Both modes end with the two coverage lines, and those are the numbers to read first:
|
|
645
|
+
|
|
646
|
+
```
|
|
647
|
+
channel coverage: 48/48 served channels exercised
|
|
648
|
+
push coverage: 9/9 push channels sent
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
They were verified from a **cold start** — fresh process, zero counters, every session
|
|
652
|
+
cold — because that is where the cold-session defects hid, and because a coverage figure
|
|
653
|
+
read from a long-running instance's accumulated totals proves less than it looks like.
|
|
654
|
+
|
|
655
|
+
Before a handset session it is also worth a glance at `/api/dsh-cindy-host/status`:
|
|
656
|
+
`status.state` (`connected`), `status.host.online`, `status.login.authenticated`, and
|
|
657
|
+
`host.deviceId` — the last one is what the phone links to, so a changed id means the
|
|
658
|
+
phone will want a fresh pairing. `diagnostics.pushTotals` / `invokeTotals` are monotonic,
|
|
659
|
+
so "did the phone ever call X" and "did that frame ever go out" are readable from a
|
|
660
|
+
snapshot rather than reconstructed from a churning ring.
|
|
661
|
+
|
|
662
|
+
`npm run acceptance` exists because **every real defect in this Host was an
|
|
663
|
+
assembly bug** — a field named differently on the two sides of a seam, a
|
|
664
|
+
capability built and never forwarded, a channel implemented but unreachable. Unit
|
|
665
|
+
tests cannot see that class by construction; only a run against the assembled
|
|
666
|
+
Host can. The script drives the same self-test route a handset does and prints one
|
|
667
|
+
line per channel.
|
|
668
|
+
|
|
669
|
+
It is deliberately no substitute for a handset: it cannot see what the controller
|
|
670
|
+
*renders*. What it removes is the other half of the search space — the case where
|
|
671
|
+
the Host itself is the broken side.
|
|
672
|
+
|
|
673
|
+
Two habits it enforces on itself, both learned the hard way:
|
|
674
|
+
|
|
675
|
+
- **No vacuous checks.** The paging check first ran against a freshly created
|
|
676
|
+
session with no transcript, so "paging advances" passed while testing nothing.
|
|
677
|
+
It now finds a session that actually has rows — and has since observed a page
|
|
678
|
+
answering `asked 3, got 5`, the boundary-group rule working on real data.
|
|
679
|
+
- **Report what was refused.** Every run ends with the channels the controller
|
|
680
|
+
asked for that this Host refused, by count. Refusing an unimplemented channel is
|
|
681
|
+
correct behaviour, but *which* channels those are is the evidence needed to
|
|
682
|
+
decide whether one is worth implementing.
|
|
683
|
+
|
|
684
|
+
Prompts are opt-in (`--with-prompts`), because exercising the send paths starts
|
|
685
|
+
real agent turns and costs tokens.
|
|
686
|
+
|
|
687
|
+
## Observed result
|
|
688
|
+
|
|
689
|
+
With the switch turned on in that isolated profile, the Host:
|
|
690
|
+
|
|
691
|
+
1. read the Cindy session the OS credential store already held and refreshed it;
|
|
692
|
+
2. connected to `wss://device-link.cindy.com.cn/api/device-link/ws` and received
|
|
693
|
+
`hello-ack` with a real `deviceId` and `userId`;
|
|
694
|
+
3. resolved real device names and platforms for the account through the device
|
|
695
|
+
directory (`<phone>` / android, `YOP` / win32, …);
|
|
696
|
+
4. reported `connected` once a same-account Cindy client opened a link to it.
|
|
697
|
+
|
|
698
|
+
Turning the switch off returned the status to `disconnected`, cleared the device
|
|
699
|
+
ledger and the host identity, and left the process with **zero** established
|
|
700
|
+
sockets — the relay connection was genuinely closed rather than idled.
|
|
701
|
+
|
|
702
|
+
Note: the OS credential store is shared across DSH profiles (`keytar` service
|
|
703
|
+
`DSH Cindy Host`), so a session created by `npm run login` is visible to the
|
|
704
|
+
smoke profile too. The smoke profile itself only owns its settings document.
|
|
705
|
+
|
|
706
|
+
### Two installations on one machine need two credentials, and the rule is the *home*
|
|
707
|
+
|
|
708
|
+
That sharing became a real defect once this plugin was installed in the real deployment
|
|
709
|
+
alongside the sandbox: the relay's device identity comes from the **login** (the stored
|
|
710
|
+
credential carries the `deviceId` that `hello-ack` echoes), so two installations on one
|
|
711
|
+
credential present the same device — and the relay keeps **one** connection per device
|
|
712
|
+
(`4409: 同 deviceId 的新连接顶掉了本连接`). With the new reconnect ladder on both sides that
|
|
713
|
+
is not a brief overlap but a one-second kicking loop, and the controller's session list is
|
|
714
|
+
what the user sees while it happens.
|
|
715
|
+
|
|
716
|
+
So the entry is scoped by home: the **default** home (`~/.dsh`) keeps `session-v1`, and any
|
|
717
|
+
other `DSH_HOME` gets `session-v1@<digest>`.
|
|
718
|
+
|
|
719
|
+
The first version of that rule was wrong in a way only the real deployment could show: it
|
|
720
|
+
keyed on **`DSH_HOME` being set** rather than on **which home it names**, and this
|
|
721
|
+
deployment sets `DSH_HOME=C:\Users\<user>\.dsh` explicitly — the default home, spelled out.
|
|
722
|
+
The result was a Host that booted with *no* session ("重启 dsh 貌似不会自动连接 cindy",
|
|
723
|
+
with a login form for an account that was already signed in) and `no stored session` when
|
|
724
|
+
read directly. The rule now compares the resolved paths (`~` expanded, trailing separator
|
|
725
|
+
ignored, case-insensitive on Windows), which is also what makes it testable without
|
|
726
|
+
depending on where the test runs.
|
|
727
|
+
|
|
728
|
+
## What the phone found that the tests did not
|
|
729
|
+
|
|
730
|
+
Connecting a real handset surfaced eight defects in one session, none of which
|
|
731
|
+
any unit test could see. They are worth naming together, because the pattern is
|
|
732
|
+
the lesson:
|
|
733
|
+
|
|
734
|
+
| Symptom | Cause |
|
|
735
|
+
|---|---|
|
|
736
|
+
| prompts sent twice | `workingDir` on the wire vs `cwd` in the cache |
|
|
737
|
+
| no todo card | the phone picks that card by tool **name**, `TodoWrite` |
|
|
738
|
+
| no live reply | nobody held the `session:<id>` topic |
|
|
739
|
+
| spinner forever | turn end is signalled only by a session-scoped `maker:event` |
|
|
740
|
+
| queue buttons dead | ten queue channels, none implemented |
|
|
741
|
+
| inputs merged | the prompt identity was the relay id, not the phone's `clientId` |
|
|
742
|
+
| queue buttons NOT_AVAILABLE | the capability was built and never forwarded |
|
|
743
|
+
| cancel/edit NOT_FOUND | the controller's id is not DSH's `MessageId` |
|
|
744
|
+
|
|
745
|
+
Three verification habits would each have caught part of this:
|
|
746
|
+
|
|
747
|
+
- **Probes must enter through the branch that decides.** `probe-todo.ts` called
|
|
748
|
+
the extractor directly and skipped the name gate, so it passed for a round
|
|
749
|
+
while the phone rendered nothing.
|
|
750
|
+
- **Tests must not stub the seam under test.** The enqueue test replaced the real
|
|
751
|
+
row builder, so a wrong field name survived a green suite.
|
|
752
|
+
- **A capability that is built is not a capability that is wired.** There is now
|
|
753
|
+
an invariant test: every key the source seam produces must arrive at the
|
|
754
|
+
channel layer.
|
|
755
|
+
|
|
756
|
+
### Pushed projections are a convenience, not a source of truth
|
|
757
|
+
|
|
758
|
+
The control stream *broadcasts* projection changes, and this Host treated those
|
|
759
|
+
frames as the only source. So a frame that never arrived was indistinguishable
|
|
760
|
+
from "nothing changed" — a goal existed while every status read answered `null`
|
|
761
|
+
for twenty seconds, and a queue fold reported empty while DSH held items.
|
|
762
|
+
|
|
763
|
+
`projectionMode: 'all'` on `sessionQuery.observeSession` computes the registered
|
|
764
|
+
projections on demand, and that is what the status reads use now:
|
|
765
|
+
|
|
766
|
+
```
|
|
767
|
+
before: maker:goal:get-status → null (20s, while a goal existed)
|
|
768
|
+
after: maker:goal:get-status → { status: 'paused', maxTurns: 1, … }
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
`queueItemsFromInbox` is a faithful port of the controller's own
|
|
772
|
+
`queueItemsFromInbox`, so the queue the controller sees is the queue DSH holds.
|
|
773
|
+
The observation is a lease and is always released.
|
|
774
|
+
|
|
775
|
+
The same idea caught a second bug the fold had been hiding: a queued item's
|
|
776
|
+
optimistic row outlived the message it described. The durable user message *is*
|
|
777
|
+
the proof that DSH's inbox dropped the entry, so `promptRpcIdOf(event)` retires
|
|
778
|
+
that row and pushes the corrected projection to `session:<id>`.
|
|
779
|
+
|
|
780
|
+
### Two identities, and both send paths
|
|
781
|
+
|
|
782
|
+
A queued item has two ids, and confusing them fails quietly:
|
|
783
|
+
|
|
784
|
+
- the controller's `clientId`, which DSH persists as the prompt's `rpcId`, and
|
|
785
|
+
which the controller retires its local submission echo on;
|
|
786
|
+
- DSH's durable `MessageId`, which is what `updateQueue` is keyed on.
|
|
787
|
+
|
|
788
|
+
Both send paths must therefore carry the controller's `clientId` as the prompt
|
|
789
|
+
identity — `maker:input:enqueue` **and** `maker:send`. Getting this wrong does not
|
|
790
|
+
produce an error: the draft stays in the composer, so the next thing the user
|
|
791
|
+
types is appended to it and both go out as one message (`也可能叫 progress哈喽`),
|
|
792
|
+
or the message sits "sending" and disappears on the next transcript read.
|
|
793
|
+
|
|
794
|
+
This is the defect that was reported as "第二句发送失败 / host failed to serve this
|
|
795
|
+
channel" and could not be reproduced from the Host in one session — two sends 15 ms and
|
|
796
|
+
4 ms apart came back as two independent turns. It was the **`maker:send`** half (the
|
|
797
|
+
enqueue half had already been fixed), and it is now **confirmed on the handset in the real
|
|
798
|
+
deployment**: the second message appears once, the composer clears, and the reply streams.
|
|
799
|
+
|
|
800
|
+
### Paging never splits a message
|
|
801
|
+
|
|
802
|
+
A message's rows (thought, tool call, answer) share one `createdAt`, and the
|
|
803
|
+
controller's cursor is exactly that timestamp. Cutting a group at the `limit`
|
|
804
|
+
means the next page filters with a strict `< cursor` and drops the rest, so those
|
|
805
|
+
rows become unreachable and the transcript shows a hole the user cannot page
|
|
806
|
+
past. `pageRows` finishes the boundary group instead.
|
|
807
|
+
|
|
808
|
+
### The model catalog has three ways to be silently wrong
|
|
809
|
+
|
|
810
|
+
- the label field is **`displayName`**, not `label`;
|
|
811
|
+
- plan mode is read from **`planMode.supported`**, an object, not a boolean;
|
|
812
|
+
- `MobileModelOption` carries **no provider id**, because the catalog flattens
|
|
813
|
+
models out of their provider groups. So `maker:set-model` usually arrives with a
|
|
814
|
+
model alone, and the Host resolves the provider from its own catalog rather
|
|
815
|
+
than refusing a choice the user already made.
|
|
816
|
+
|
|
817
|
+
### Pushes are verified too, and counted monotonically
|
|
818
|
+
|
|
819
|
+
The reply settles the controller that asked; the **push** is what keeps a second
|
|
820
|
+
screen — and the same screen after a reconnect — correct. `npm run acceptance`
|
|
821
|
+
therefore checks both, and for pushes it compares a **monotonic per-channel
|
|
822
|
+
total** (`diagnostics.pushTotals`) rather than the ring of recent pushes:
|
|
823
|
+
|
|
824
|
+
```
|
|
825
|
+
PASS a goal write is pushed to the session watchers maker:goal:status-changed 5→6
|
|
826
|
+
PASS a queue command is broadcast to the session watchers maker:input:projection 4→5
|
|
827
|
+
PASS a live message is pushed to the session watchers local-db:messages:created 2→4
|
|
828
|
+
PASS a turn state change is pushed maker:event 5→8
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
The ring is only the last twenty entries and churns during a turn, so a count
|
|
832
|
+
taken from it can *fall* between two reads — a push that happened can look like
|
|
833
|
+
one that did not. That is not a hypothetical: it produced a false failure the
|
|
834
|
+
first time this check ran.
|
|
835
|
+
|
|
836
|
+
Two further self-inflicted false failures are worth remembering, because both
|
|
837
|
+
came from the Host being right:
|
|
838
|
+
|
|
839
|
+
- the script reused one `clientId` per run, and DSH dedupes a prompt by its
|
|
840
|
+
`rpcId` — so the second run sent nothing and reported "no message pushed";
|
|
841
|
+
- a queued item can be admitted between two checks, so a late `NOT_FOUND` on an
|
|
842
|
+
edit is DSH's own scheduling, not a defect.
|
|
843
|
+
|
|
844
|
+
## Controls that are owned by a preset, not by the Host
|
|
845
|
+
|
|
846
|
+
Plan mode is the sharpest example yet of a service that exists, works, and is
|
|
847
|
+
still unreachable from the Host context. In this deployment the Web bundle
|
|
848
|
+
**disables** the base `plan-mode` row:
|
|
849
|
+
|
|
850
|
+
```yaml
|
|
851
|
+
# @deepseek-ai/dsh-web-app/cordis.patch.yml
|
|
852
|
+
- id: plan-mode
|
|
853
|
+
disabled: true
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
and each agent preset mounts `@deepseek-ai/dsh-plan-mode` inside the scope it
|
|
857
|
+
owns — `cordis`, `ptc`, and `standard` do, `minimal` does not. So
|
|
858
|
+
`ctx.inject(['planMode'])` never fires on the host plane, `agent.ctx.get('planMode')`
|
|
859
|
+
did not answer either, and the phone's plan switch answered *"this session
|
|
860
|
+
composes no plan mode"* while `maker:list-agent-commands` plainly listed `plan`.
|
|
861
|
+
|
|
862
|
+
What did answer is the thing the preset itself registered: the `/plan` command.
|
|
863
|
+
`dsh-plan-mode` registers it through the command registry of whatever scope it
|
|
864
|
+
was mounted in, and that registry already resolves per agent for the `/` palette
|
|
865
|
+
— so the switch and the palette read the same source:
|
|
866
|
+
|
|
867
|
+
```js
|
|
868
|
+
commands.find(agent, 'plan') // the per-session capability test
|
|
869
|
+
commands.execute(agent, '/plan' | '/plan off', [], signal) // the switch itself
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Notes that matter:
|
|
873
|
+
|
|
874
|
+
- the capability is `{ supported: ... }`, an object, and it is now answered in
|
|
875
|
+
order of evidence: a host-plane service, then a **live** agent whose scope
|
|
876
|
+
carries the switch, then the **default preset's own composition rows**
|
|
877
|
+
(`agentPresets.compositionInventory()` → `{ isDefault, rows[].moduleName }`).
|
|
878
|
+
Without the last step a freshly restarted Host would answer "unsupported" and
|
|
879
|
+
take the control away from every controller until somebody opened a session;
|
|
880
|
+
- a row reported `'conditional'` (an unevaluated `!!js disabled` expression)
|
|
881
|
+
counts as mounted — a refusal to evaluate is not evidence of absence;
|
|
882
|
+
- the command answers with human text ("Plan mode on." vs "applies from the next
|
|
883
|
+
step"), not with DSH's `committed`/`queued` word, so the channel reports
|
|
884
|
+
`{ outcome: 'accepted', message }` instead of inventing an outcome;
|
|
885
|
+
- permission presets are the opposite case and stay simple: `ctx.permissionPresets`
|
|
886
|
+
**is** a host-plane service, so `permissionModes` comes straight from
|
|
887
|
+
`presets.names` — verified live as `read-only` / `workspace-write` /
|
|
888
|
+
`danger-full-access`.
|
|
889
|
+
|
|
890
|
+
## Effort is part of a selection, and a write nobody reports is invisible
|
|
891
|
+
|
|
892
|
+
`maker:set-effort` looks like a knob and is not one: `SessionSelectModelRequest
|
|
893
|
+
extends ModelSelection`, so effort can only be written **with** the model it
|
|
894
|
+
belongs to. Two things followed:
|
|
895
|
+
|
|
896
|
+
- the write re-states the session's own selection (`modelSelection.next ??
|
|
897
|
+
lastUsed`, catalog default only for a session that never chose). Picking the
|
|
898
|
+
catalog default would move the session onto another model as a side effect of
|
|
899
|
+
changing effort — a different action than the user took;
|
|
900
|
+
- the **row** had `effort` hardcoded to `'default'`, so a write that landed was
|
|
901
|
+
invisible: the composer reads the current value from the row, and the live check
|
|
902
|
+
showed `set-effort high → {ok:true}` with the row still answering `default`.
|
|
903
|
+
The row now carries the selection's `reasoningEffort`, and the same live run
|
|
904
|
+
reads `effort=high → low → high` back. DSH also validates the value itself:
|
|
905
|
+
a bogus effort is refused with *"provider … does not support reasoning effort
|
|
906
|
+
…"* and the row keeps its previous value, which is the right shape for an
|
|
907
|
+
invalid input.
|
|
908
|
+
|
|
909
|
+
`maker:set-fast-mode` stays unimplemented on purpose: every model in this Host's
|
|
910
|
+
catalog declares `supportsFastMode: false` and the capability says
|
|
911
|
+
`hasFastMode: false`, so the controller never draws the toggle.
|
|
912
|
+
|
|
913
|
+
### The forwarding bug, for the third time — and the fix that ends it
|
|
914
|
+
|
|
915
|
+
`setEffort` was built in the seam, forwarded through `capabilityProvider`, and
|
|
916
|
+
then left out of the **runtime's** hand-written field list, so the channel
|
|
917
|
+
answered `NOT_AVAILABLE` on a Host advertising four effort levels. The same class
|
|
918
|
+
of bug had already hit `queueControl`. The field list is gone: the runtime now
|
|
919
|
+
forwards the seam output whole (`capabilityProvider({ ...currentSeam, files,
|
|
920
|
+
goalWrite })`) and `capabilityProvider` remains the single place that decides what
|
|
921
|
+
the channel layer may see. Two of the three capabilities that live outside the
|
|
922
|
+
seam — `files` and `goalWrite` — are named explicitly because they come from
|
|
923
|
+
their own injections.
|
|
924
|
+
|
|
925
|
+
## A new conversation's runtime arrived, and this Host dropped it
|
|
926
|
+
|
|
927
|
+
Reported from the handset: 「新对话选择了 gpt 模型,一运行又变成了 deepseek」. The picker is
|
|
928
|
+
not the defect, and neither is DSH: this Host's `maker:create-session` read two fields out of
|
|
929
|
+
the controller's argument and ignored the rest of the runtime it was handed. The model was the
|
|
930
|
+
half the user could see; `permissionMode` and the row's `providerId` were the same defect, and
|
|
931
|
+
the permission half is the one with teeth (the two subsections at the end of this section).
|
|
932
|
+
|
|
933
|
+
A **new** conversation has nowhere else to put that runtime. Measured in the handset's own
|
|
934
|
+
pipeline (`apps/mobile/src/session/newSessionCreation.ts`), the order is create → getSession →
|
|
935
|
+
enqueue, and `setModel` is never called anywhere in it — the transport declares the verb, and
|
|
936
|
+
the only callers in the tree are tests. What the user picked travels in the create args, under
|
|
937
|
+
the same names in both controllers:
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
// apps/mobile/src/session/newSession.ts — buildRemoteCreateSessionOptions
|
|
941
|
+
{ agentKind, workspaceKind, model, permissionMode, fastMode,
|
|
942
|
+
...(effort ? { effort } : {}), ...(providerId ? { providerId } : {}),
|
|
943
|
+
...(workingDir ? { workingDir, extraDirs } : {}) }
|
|
944
|
+
|
|
945
|
+
// apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts
|
|
946
|
+
{ agentKind, id?, workingDir?, workspaceKind, model, effort, permissionMode, fastMode,
|
|
947
|
+
extraDirs?, writableDirs?, providerId? }
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
The consequence is visible one layer down, in DSH's own route resolution
|
|
951
|
+
(`dsh-api-session-controller`, `selectionFor`):
|
|
952
|
+
|
|
953
|
+
```js
|
|
954
|
+
get current() {
|
|
955
|
+
if (picked !== undefined) return picked; // a session-local selection
|
|
956
|
+
const loggedHeader = agent.session.requestHeader();
|
|
957
|
+
if (loggedHeader === undefined) return defaultModel.currentSelection(); // ← the profile default
|
|
958
|
+
…
|
|
959
|
+
}
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
A session created through this channel has `picked === undefined` (no `model/selection` event
|
|
963
|
+
was ever appended) and no request header yet, so its **first prompt runs on the profile's
|
|
964
|
+
`agent-default-model`** — `deepseek-official/deepseek-flash` in the profile this was reported
|
|
965
|
+
from. The handset then reads the authoritative row back: `dsh-session-source.js` folds
|
|
966
|
+
`projections.values.modelSelection.next ?? lastUsed`, the pending selection is empty, so the row
|
|
967
|
+
answers deepseek and replaces the optimistic gpt row the user was looking at. That is the
|
|
968
|
+
「一运行就变成 deepseek」.
|
|
969
|
+
|
|
970
|
+
The fix keeps the contract in one place. The seam's `createSession` now accepts the selection
|
|
971
|
+
and installs it immediately after the create — the only order DSH allows, because a selection
|
|
972
|
+
needs a session to belong to — and the provider-resolution plus write that `maker:set-model`
|
|
973
|
+
already used became `applyModelSelection`, so both paths share one implementation instead of a
|
|
974
|
+
second copy. The channel layer forwards the three fields the controllers actually send, so the
|
|
975
|
+
seam is not a second place that has to guess them.
|
|
976
|
+
|
|
977
|
+
Three decisions are worth stating outright:
|
|
978
|
+
|
|
979
|
+
- **Absent means absent.** A blank `model` is not forwarded and no selection is installed: a
|
|
980
|
+
controller with no opinion must not move the session off the Host's default.
|
|
981
|
+
- **A model that cannot be routed is refused, never swallowed.** Two shapes reach the write and
|
|
982
|
+
both answer `NOT_AVAILABLE` rather than `THREW` — which the controller reads as this Host
|
|
983
|
+
crashing: a model no catalog group serves is refused before the write, and DSH's own
|
|
984
|
+
`session/model-unavailable` (a route it cannot resolve, e.g. a provider that lost its
|
|
985
|
+
credential between the picker read and the create) is translated by the seam. In the second
|
|
986
|
+
case the session does exist by then — but the controller's create is idempotent and probes
|
|
987
|
+
`getSession` before rebuilding, so the honest refusal costs one retry instead of a
|
|
988
|
+
conversation running a model nobody picked. A failure that is **not** a refusal is left alone.
|
|
989
|
+
- **DSH's write has two effects, and this Host inherits both.** `session.selectModel` installs
|
|
990
|
+
the session's coming request **and** updates the profile's `agent-default-model` — what the
|
|
991
|
+
desk's own model picker does. A conversation created from the handset on gpt therefore also
|
|
992
|
+
becomes the default for sessions no picker has touched. That is DSH's semantics, not
|
|
993
|
+
something this Host invented; only a future DSH API that separates "this session" from "the
|
|
994
|
+
default" would change it.
|
|
995
|
+
|
|
996
|
+
Measured through the Host's own self-test route against the sandboxed instance (`.sandbox`, port
|
|
997
|
+
3081, whose plugin entry is a link to this repository):
|
|
998
|
+
|
|
999
|
+
```
|
|
1000
|
+
control (no model asked) -> created, row.model = deepseek-flash (the profile default)
|
|
1001
|
+
picked (deepseek-v4-pro, low) -> created, row.model = deepseek-v4-pro, row.effort = low
|
|
1002
|
+
bogus (gpt-9-nope) -> NOT_AVAILABLE no provider in this Host's catalog serves gpt-9-nope
|
|
1003
|
+
unrouted (openai-codex, unconfigured) -> NOT_AVAILABLE no adapter registered for provider "openai-codex"
|
|
1004
|
+
permissionMode=read-only -> created, reply and row both read-only, row.providerId=deepseek-official
|
|
1005
|
+
permissionMode=auto (unadvertised) -> created, the reply names no preset, the row keeps the profile preset
|
|
1006
|
+
no model and no permission -> created, row.model = the profile default, no providerId on the row
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
The control line is what makes the probe worth running: the path with no pick is untouched. The
|
|
1010
|
+
pick line is the defect's opposite — the row the handset reads back reports the model the user
|
|
1011
|
+
chose. And the third decision above is measurable, not just claimed: read immediately after that
|
|
1012
|
+
probe, the sandbox's own `settings.yaml` said `agent-default-model: provider deepseek-official /
|
|
1013
|
+
model deepseek-v4-pro / reasoningEffort low` — the pick had become the profile default. (The
|
|
1014
|
+
acceptance run that followed set the model back to `deepseek-flash`, which is why the same file
|
|
1015
|
+
reads that way now.) `node tools/acceptance.mjs --base http://127.0.0.1:3081` passes 81/81 on the
|
|
1016
|
+
same instance.
|
|
1017
|
+
|
|
1018
|
+
### The permission preset, where the silent drop was dangerous
|
|
1019
|
+
|
|
1020
|
+
`permissionMode` rides the same create args, and the handset's permission options **are this
|
|
1021
|
+
Host's own advertised list**: `maker:get-capabilities` answers `permissionModes` from DSH's
|
|
1022
|
+
preset table (`read-only` / `workspace-write` / `danger-full-access`) and the mobile picker is
|
|
1023
|
+
rendered straight from it (`buildSessionRuntimeOptions` → `permissionOptions`), with the draft
|
|
1024
|
+
reconciler coercing any value outside the list onto its first entry
|
|
1025
|
+
(`packages/maker-shared/src/agentCapabilities.ts`). So the phone offered three real levels, the
|
|
1026
|
+
draft carried one of them, and the session ran the profile's preset instead: a user who chose
|
|
1027
|
+
read-only received `danger-full-access` while believing the agent was restricted.
|
|
1028
|
+
|
|
1029
|
+
The fix installs the preset right after the create, through the same write the explicit
|
|
1030
|
+
`maker:set-permission-mode` channel uses (`installPermissionMode`), so the two paths cannot
|
|
1031
|
+
drift — but only when the name is one this Host advertises. That "only" is the decision:
|
|
1032
|
+
|
|
1033
|
+
- **No translation between the two vocabularies.** The controller has a vocabulary of its own
|
|
1034
|
+
(`ask`, `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`), which it sends when its
|
|
1035
|
+
capability read failed and it fell back to its legacy list. Mapping `auto` onto
|
|
1036
|
+
`workspace-write`, or `bypassPermissions` onto `danger-full-access`, would be this Host
|
|
1037
|
+
choosing a privilege level for the user out of a word that means something else on each side.
|
|
1038
|
+
- **And no refusal either.** Refusing would leave a phone whose capability read failed unable to
|
|
1039
|
+
start any conversation at all — worse than running the profile's preset, which the
|
|
1040
|
+
authoritative row then reports honestly.
|
|
1041
|
+
- So an unadvertised name is **ignored, logged and left alone**: `create-session: this Host
|
|
1042
|
+
advertises [...] but the controller asked for permission mode "auto"; keeping the profile
|
|
1043
|
+
preset` goes to `ctx.logger` — cordis's built-in logging service, so it lands in the host's own
|
|
1044
|
+
log — and the reply simply never names a preset it did not install.
|
|
1045
|
+
|
|
1046
|
+
### The session row lost the model's source
|
|
1047
|
+
|
|
1048
|
+
`RemoteSession.providerId` is what the controller keeps beside the model, and it derives its
|
|
1049
|
+
new-chat draft runtime from the most recent session (`pickRecentSessionRuntime` →
|
|
1050
|
+
`{ model, providerId }`). These rows never carried it, even though the same `modelSelection`
|
|
1051
|
+
projection that answers `model` and `effort` carries `provider` — so "跟随最近会话" reproduced
|
|
1052
|
+
the model and dropped its source.
|
|
1053
|
+
|
|
1054
|
+
Both folds now read that one projection (`next ?? lastUsed`) and the wire row **omits** the field
|
|
1055
|
+
when the session never chose one: a missing `providerId` is what the controller reads as "follow
|
|
1056
|
+
the Host's default route", which is exactly what such a session runs. Absent, not `null` — the
|
|
1057
|
+
controller distinguishes the two.
|
|
1058
|
+
|
|
1059
|
+
Every shape is pinned by a test: the runtime is forwarded to the seam, a blank picker installs
|
|
1060
|
+
nothing, an unroutable model is refused with the code the controller reads, DSH's own refusal is
|
|
1061
|
+
translated, a genuine failure is not dressed up as one, an advertised permission preset is
|
|
1062
|
+
installed onto the session (and an unadvertised one is logged and left at the profile preset),
|
|
1063
|
+
and the row carries its source when it has one. The assertion that used to say *"the picker is
|
|
1064
|
+
ignored; DSH creates the session"* was this defect written down as a contract; it now asserts the
|
|
1065
|
+
opposite.
|
|
1066
|
+
|
|
1067
|
+
## Record what was asked, not just what was answered
|
|
1068
|
+
|
|
1069
|
+
Two diagnostics earned their place this round, both for the same reason: the
|
|
1070
|
+
reply alone cannot explain a refusal.
|
|
1071
|
+
|
|
1072
|
+
- `maker:get-capabilities` now logs `ask=agentKind=…`. It showed the two linked
|
|
1073
|
+
desktop controllers asking about `codex` and `claude-code` and receiving
|
|
1074
|
+
`NOT_AVAILABLE` while every `pi` request succeeded — i.e. the refusals are the
|
|
1075
|
+
honest answer about harnesses this Host does not offer, not a broken catalog.
|
|
1076
|
+
Without the kind in the log, those four refusals per poll read exactly like a
|
|
1077
|
+
defect.
|
|
1078
|
+
- The send channels now log attachments:
|
|
1079
|
+
`ask=attachments=2 form=mixed (not materialized)`. See below for why.
|
|
1080
|
+
|
|
1081
|
+
## Attachments: what the wire carries, and what this Host can serve
|
|
1082
|
+
|
|
1083
|
+
The phone sends attachments **inside the message**, not over a channel of their
|
|
1084
|
+
own: `maker:send` and `maker:input:enqueue` carry
|
|
1085
|
+
`files?: RemoteSerializedAttachment[]`, and `extractSendText` threw that away
|
|
1086
|
+
without a word. Recorded shapes (`apps/mobile/src/session/types.ts`):
|
|
1087
|
+
|
|
1088
|
+
```ts
|
|
1089
|
+
interface RemoteSerializedAttachment {
|
|
1090
|
+
id, name, path, ext, size, sha256?, category, mimeType,
|
|
1091
|
+
url?, originalName?, base64?, textContent?, truncated?, annotated?
|
|
1092
|
+
}
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
The mobile app's two builders — `buildMobileRemoteFileAttachment(remotePath)` and
|
|
1096
|
+
`buildMobileUploadedAttachment({ossKey, …})` in `apps/mobile/src/session/attachments.ts`
|
|
1097
|
+
— fill neither `base64` nor `textContent`, and a search of the mobile sources
|
|
1098
|
+
found no attachment path that does. So the wire carries two forms, and neither is
|
|
1099
|
+
inline bytes.
|
|
1100
|
+
|
|
1101
|
+
1. **Uploaded** — the bytes are PUT to the account's OSS staging area
|
|
1102
|
+
(`POST /api/device-link/media/presign-put`, `apps/mobile/src/session/mobileAttachmentUpload.ts`)
|
|
1103
|
+
and `path` becomes a transit reference. **Served since this round**: see
|
|
1104
|
+
"Uploaded attachments are fetched with this Host's own account" below. It was dropped
|
|
1105
|
+
before, and that is what "发照片不成功, 一直转圈" was.
|
|
1106
|
+
2. **Host path** — an absolute path on this machine (the file browser's
|
|
1107
|
+
"send to session"). **Served**, see below.
|
|
1108
|
+
|
|
1109
|
+
### Host-path attachments, implemented and measured
|
|
1110
|
+
|
|
1111
|
+
`host-attachments.js` reads the path through the composed filesystem and builds the
|
|
1112
|
+
prompt parts DSH accepts (`PromptContentPart` in dsh-api-session-controller):
|
|
1113
|
+
|
|
1114
|
+
- `{ type: 'image', mediaType, data: <base64> }` for the four media types DSH admits
|
|
1115
|
+
(`png|jpeg|webp|gif`) — the Host promotes the bytes to a durable reference itself,
|
|
1116
|
+
so no upload is needed;
|
|
1117
|
+
- `{ type: 'file', receiptId }` for everything else, where the receipt comes from
|
|
1118
|
+
`ctx.fileUploads.upload(agent, { data, name }, signal)` → `{ receiptId, file }`.
|
|
1119
|
+
|
|
1120
|
+
The extension and the declared mime type each get a say: a declared type outside
|
|
1121
|
+
DSH's closed union is not accepted as an image, and the extension decides when the
|
|
1122
|
+
declaration is unhelpful. Anything unreadable, oversized (20 MB), a directory, or
|
|
1123
|
+
unresolvable is **dropped per attachment with a reason** — the prompt and its text
|
|
1124
|
+
still go, because refusing a message over one attachment would be worse than sending
|
|
1125
|
+
it without.
|
|
1126
|
+
|
|
1127
|
+
THE ECHO MATTERS AS MUCH AS THE SEND. The phone reads an attachment from the
|
|
1128
|
+
message's **content JSON** (`readFileAttachments`), and it retires its optimistic
|
|
1129
|
+
bubble by `clientId` — so an attachment that is not restated on the authoritative row
|
|
1130
|
+
disappears the moment that row lands. `cindy-message-row.js` now carries them as
|
|
1131
|
+
`files: [{ name, size, mimeType? }]` on the message's text row (or on a row of its
|
|
1132
|
+
own when the message was nothing but attachments). An **image is reported as a file
|
|
1133
|
+
entry on purpose**: the phone renders an image from a `url` or inline `base64` it can
|
|
1134
|
+
fetch, and this Host serves neither, so a file chip at least keeps the attachment's
|
|
1135
|
+
existence and name visible instead of silently dropping it.
|
|
1136
|
+
|
|
1137
|
+
Measured live, both paths end to end:
|
|
1138
|
+
|
|
1139
|
+
```
|
|
1140
|
+
ask=attachments=1 form=host-paths
|
|
1141
|
+
[user] files={"name":"package.json","size":1050} ← the bytes this Host actually read
|
|
1142
|
+
[assistant] got it
|
|
1143
|
+
|
|
1144
|
+
[user] files={"name":"probe-image.png","size":75,"mimeType":"image/png"}
|
|
1145
|
+
[assistant] ok ← DSH admitted the image part
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
### Uploaded attachments are fetched with this Host's own account
|
|
1149
|
+
|
|
1150
|
+
The phone never sends image bytes. It uploads them to the account's staging area and
|
|
1151
|
+
puts an opaque **transit reference** in the message, and resolving that is the
|
|
1152
|
+
controlled end's job — `packages/device-link/src/attachmentOssRef.ts` in the Cindy repo
|
|
1153
|
+
is the contract, and the reference implementation of the receiving half is the desktop
|
|
1154
|
+
being controlled (`apps/desktop/src/main/maker-ipc/normalizeAttachments.ts:336`:
|
|
1155
|
+
`parseAttachmentOssRef` → `presign-get` → download → verify → hand the agent a real
|
|
1156
|
+
file, then remove the staging object).
|
|
1157
|
+
|
|
1158
|
+
This Host now does the same, with the credential it was already logged in with — the
|
|
1159
|
+
media API is scoped to the account, which is exactly why the login exists
|
|
1160
|
+
(`src/host-media.js`):
|
|
1161
|
+
|
|
1162
|
+
```
|
|
1163
|
+
POST {apiBase}/media/presign-get { key } → { getUrl }
|
|
1164
|
+
GET getUrl → bytes (never through the relay)
|
|
1165
|
+
DELETE {apiBase}/media { key } → the staging object, after use
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
`{apiBase}` is derived from the relay URL rather than configured separately
|
|
1169
|
+
(`…/api/device-link/ws` → `…/api/device-link`), because a second setting could only
|
|
1170
|
+
ever disagree with the socket that is actually connected. The token is the live one the
|
|
1171
|
+
relay authenticated with, read at fetch time — **not** a fresh `restoreSession()`,
|
|
1172
|
+
which would refresh and rotate the same refresh token concurrently with the running
|
|
1173
|
+
Host (the failure that once cost a real login).
|
|
1174
|
+
|
|
1175
|
+
Three details were measured rather than assumed, and each one had already caused a
|
|
1176
|
+
wrong conclusion:
|
|
1177
|
+
|
|
1178
|
+
- **The phones send the LEGACY scheme.** `apps/mobile/src/session/attachments.ts:189`
|
|
1179
|
+
builds `xdt-oss-attach://m/…`, not `cindy-oss-attach://…`. A classifier that knew only
|
|
1180
|
+
the current scheme reported a live photo upload as `form=host-paths`, which is how the
|
|
1181
|
+
first diagnosis of this bug went wrong. Both schemes are now recognized, and
|
|
1182
|
+
recognition is broader than parsing on purpose: a malformed reference is reported as
|
|
1183
|
+
`malformed-ref` instead of being mistaken for a path.
|
|
1184
|
+
- **A caption-less photo is a message.** The phone sends `text: ''` with the attachment
|
|
1185
|
+
(`inputProjection.ts` → `buildQueuedTextMessage` → `text: trimmed`), and this Host
|
|
1186
|
+
refused it as `carried no text` *before* looking at the attachments — the actual
|
|
1187
|
+
spinner. What is refused now is having **neither** text nor a serveable attachment,
|
|
1188
|
+
which is the only case with genuinely nothing to run.
|
|
1189
|
+
- **`x-oss-object-acl: private` is signed into the PUT URL.** An upload without that
|
|
1190
|
+
header is a 403, not a default-ACL upload.
|
|
1191
|
+
|
|
1192
|
+
The failure vocabulary is closed and small (`no-credential`, `presign-failed`,
|
|
1193
|
+
`download-failed`, `oversize`, `size-mismatch`, `sha256-mismatch`, `aborted`), a declared
|
|
1194
|
+
`size`/`sha256` is verified when present, and an attachment-only prompt whose bytes
|
|
1195
|
+
could not be fetched is **refused with the reason** rather than sent as an empty prompt —
|
|
1196
|
+
sending it would deliver a photo the agent never saw.
|
|
1197
|
+
|
|
1198
|
+
`tools/attachment-probe.mjs` is the only check that covers "the bytes reached the
|
|
1199
|
+
model", because that part cannot be unit-tested: it stages a synthetic image with this
|
|
1200
|
+
Host's own credential, sends the reference the phones build, and asserts the agent
|
|
1201
|
+
describes the picture. Measured, twice (10/10):
|
|
1202
|
+
|
|
1203
|
+
```
|
|
1204
|
+
PASS the account staged the bytes — key=cindy/device-link/…/b869f05e-….png
|
|
1205
|
+
PASS the staging upload landed — PUT 200
|
|
1206
|
+
PASS the reference is one a phone would send — xdt-oss-attach://m/eyJvc… len=335
|
|
1207
|
+
PASS a caption-less photo is accepted — ok
|
|
1208
|
+
PASS the answer names the colour that is in the image — 红色 Image received … 16×16 px, PNG, 79
|
|
1209
|
+
PASS the staging object was released after the prompt landed — gone
|
|
1210
|
+
PASS the probe session is hidden from controllers — status=deleted
|
|
1211
|
+
10/10 checks passed
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
and `npm run acceptance` carries the cheaper half of it on every pass, so the message
|
|
1215
|
+
shape cannot regress silently:
|
|
1216
|
+
|
|
1217
|
+
```
|
|
1218
|
+
PASS a caption-less photo is a message, and its bytes are what can fail
|
|
1219
|
+
— ATTACHMENT_UNAVAILABLE: this Host could not fetch the attachment: presign-get answered 400
|
|
1220
|
+
PASS a prompt with neither text nor attachment is still refused — BAD_REQUEST
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
### The bubble showed 没有可展示的远程路径
|
|
1224
|
+
|
|
1225
|
+
Reported from the handset after the send path worked: 图片发送成功之后,图片信息就从手机端
|
|
1226
|
+
消失了, and the row read 没有可展示的远程路径. That string is the phone's own fallback
|
|
1227
|
+
(`i18n/locales/zh-CN/message.json:261`, `missingRemotePath`), and this is the whole chain:
|
|
1228
|
+
|
|
1229
|
+
```
|
|
1230
|
+
we served: content = { text: '', files: [{ name: '368492.jpg', size: 311456, mimeType: 'image/jpeg' }] }
|
|
1231
|
+
phone: readFileAttachments → path = record.path ?? record.url → undefined
|
|
1232
|
+
buildFilePayload(name, '') → body = missingRemotePath
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
The phone reads the two kinds from **two different fields** (`messageNormalize.ts`):
|
|
1236
|
+
|
|
1237
|
+
- `files[]` → `{ name, path?, url?, mimeType? }`, and a file with neither `path` nor
|
|
1238
|
+
`url` renders as that placeholder;
|
|
1239
|
+
- `images[]` → `{ url?, base64?, mimeType?, originalName? }`, where
|
|
1240
|
+
`uri = url ?? (base64 ? 'data:'+mimeType+';base64,'+base64 : undefined)` and **an entry
|
|
1241
|
+
with neither is dropped outright**.
|
|
1242
|
+
|
|
1243
|
+
So an image must travel in `images[]`. Why it never did: DSH's durable reference for it is
|
|
1244
|
+
content-addressed and states that it is "never a filesystem path or bearer URL"
|
|
1245
|
+
(`dsh-attachment` `ImageAttachmentRef`), so there was nothing to put in `path` — and the
|
|
1246
|
+
media channel was out of scope. The way out is that the phone accepts an inline
|
|
1247
|
+
**`data:image/...`** URI (`isPreviewableUri` counts it), and DSH already **has** the bytes:
|
|
1248
|
+
`ctx.attachments.readImage(ref)` returns them, verified against the stored digest.
|
|
1249
|
+
|
|
1250
|
+
So the fold now hydrates just the page it is about to serve:
|
|
1251
|
+
|
|
1252
|
+
```
|
|
1253
|
+
user row → content.files[{ … imageRef: <the whole durable descriptor> }]
|
|
1254
|
+
→ hydrateImageAttachments(page, { readImage }) ← one read per page image
|
|
1255
|
+
→ content.images[{ base64, mimeType, originalName }] and files[] emptied
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
Two details, both learned the hard way:
|
|
1259
|
+
|
|
1260
|
+
- **The whole descriptor must travel**, not a convenient subset. The store validates the
|
|
1261
|
+
reference it is asked to read, so a handle rebuilt from `{attachmentId, mimeType, bytes}`
|
|
1262
|
+
failed *every* read while still looking correct — measured: `attempted: 1, served: 0`.
|
|
1263
|
+
The counter now says so out loud (`diagnostics.attachmentReads`), because "the photo does
|
|
1264
|
+
not render" otherwise has three indistinguishable causes: no handle on the block, no
|
|
1265
|
+
attachment service on this profile, or a failed read.
|
|
1266
|
+
- **Hydration runs after paging**, so only the rows actually being served pay for reading
|
|
1267
|
+
image bytes; and one unreadable or oversized image (4 MB inline budget) costs that image,
|
|
1268
|
+
never the page — the row keeps an honest file chip rather than a fabricated path.
|
|
1269
|
+
|
|
1270
|
+
Verified against the user's own photo, byte for byte:
|
|
1271
|
+
|
|
1272
|
+
```
|
|
1273
|
+
row: files = null
|
|
1274
|
+
images[0] = { mimeType: 'image/jpeg', originalName: '368492.jpg', base64 len 415276 }
|
|
1275
|
+
decoded: 311456 bytes, FF D8 … FF D9, sha256 = f9307d9e878c7e096dd98c343830926f067ffdde3f8d5b3f24139bd2ca185cbe
|
|
1276
|
+
DSH said: attachmentId = sha256:f9307d9e878c7e096dd98c343830926f067ffdde3f8d5b3f24139bd2ca185cbe
|
|
1277
|
+
reads: attachmentReads { attempted: 1, served: 1, failed: 0 }
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
and `tools/attachment-probe.mjs` (now 13/13) asserts the same shape on a synthetic image on
|
|
1281
|
+
every run, including that the inlined bytes hash back to what was uploaded:
|
|
1282
|
+
|
|
1283
|
+
```
|
|
1284
|
+
PASS the user row carries the image inline instead of a path-less file entry — files=null
|
|
1285
|
+
PASS the inlined bytes are exactly the bytes that were uploaded — 79B sha=f4555c6dc13c…
|
|
1286
|
+
PASS the inlined entry carries what the phone needs to render it — mimeType=image/png originalName=probe-red.png
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
What is still not served is the *other* direction: an image the agent produced, or a
|
|
1290
|
+
thumbnail for a file, still needs `device-link:media:fetch` (or a URL the Host serves that a
|
|
1291
|
+
remote phone could reach — an HTTP URL on this machine is not reachable from the handset).
|
|
1292
|
+
|
|
1293
|
+
A photo the *user* sends is now rendered from its own bytes, and that is **confirmed on the
|
|
1294
|
+
handset in the real deployment** ("我发送的照片我已经可以看到了") — the whole loop, on the
|
|
1295
|
+
profile that actually serves the phone: the phone's upload → this Host fetching it from the
|
|
1296
|
+
staging area with the account credential → DSH admitting it as a durable attachment → the
|
|
1297
|
+
transcript read inlining those bytes → the bubble rendering them.
|
|
1298
|
+
|
|
1299
|
+
## Real scale broke the list: subagents, and a title fold per poll
|
|
1300
|
+
|
|
1301
|
+
Moving this Host from the sandbox profile to the real one — 219 sessions instead of 5 —
|
|
1302
|
+
produced 任务列表一直在转圈 / 电脑暂时没有回应请求. Measured on the live instance through its
|
|
1303
|
+
own channel, before any change:
|
|
1304
|
+
|
|
1305
|
+
```
|
|
1306
|
+
local-db:sessions:list 12 061 ms (219 rows) ← the handset's responsiveness probe
|
|
1307
|
+
local-db:sessions:list 12 723 ms (2nd call)
|
|
1308
|
+
maker:list-active 8 364 ms ← polled constantly, needs one boolean
|
|
1309
|
+
local-db:sessions:get 8 400 ms (one session — it read the whole list to find it)
|
|
1310
|
+
```
|
|
1311
|
+
|
|
1312
|
+
Two independent defects, both ours:
|
|
1313
|
+
|
|
1314
|
+
**1. Subagent runs were listed as tasks.** `~/.dsh/sessions` held 219 logs; nearly all of
|
|
1315
|
+
them were delegated runs, and their headers say so:
|
|
1316
|
+
|
|
1317
|
+
```
|
|
1318
|
+
7543226e-… cwd=G:\Projects\tiny-world agentPreset=code delegationDepth=1 origin=subagent
|
|
1319
|
+
95fb7595-… cwd=G:\Projects\tiny-world agentPreset=code delegationDepth=1 origin=subagent
|
|
1320
|
+
session-b9e98377-… cwd=G:\Projects\tiny-world agentPreset=code delegationDepth=0 origin=(none)
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
DSH's own Web list shows only user-facing sessions — which is why the user had never seen
|
|
1324
|
+
them — while this Host listed every one, as "Untitled DSH task", because the source ignored
|
|
1325
|
+
`origin`/`parentSessionId`. They are filtered now at the **single point both readers share**
|
|
1326
|
+
(`listItems()`), and `api-session/added` is filtered too, so a subagent cannot even push a
|
|
1327
|
+
row into the controller's list and then disappear on its next read.
|
|
1328
|
+
|
|
1329
|
+
**2. Every list read folded every title.** A title is a log-backed fold
|
|
1330
|
+
(`sessionQuery.readTitleSnapshots`, ~50 ms each) and the read folded all of them *inside the
|
|
1331
|
+
request*: 219 × 50 ms ≈ 12 s, in front of a handset that gives up at 15 s. Now:
|
|
1332
|
+
|
|
1333
|
+
- the read folds **one bounded batch** (`titleRefreshBudget = 16`) and answers, so the rows
|
|
1334
|
+
the controller is about to show arrive in one batch's time;
|
|
1335
|
+
- the rest are folded **behind the answer**, one batch at a time, by a single in-flight
|
|
1336
|
+
warm-up — the next poll serves them from cache and folds nothing;
|
|
1337
|
+
- a title this Host changed (a rename) drops its cache entry immediately, and a 60 s TTL
|
|
1338
|
+
heals one changed by DSH's own title regeneration;
|
|
1339
|
+
- `maker:list-active` and `maker:session-in-turn` no longer read rows at all: they ask the
|
|
1340
|
+
source for turn states, which is the same listing minus titles and header reads. They only
|
|
1341
|
+
ever needed one boolean.
|
|
1342
|
+
|
|
1343
|
+
The lesson worth keeping: **the sandbox was five sessions and milliseconds.** A Host that
|
|
1344
|
+
serves a controller has to be measured on the corpus that controller will actually see, and
|
|
1345
|
+
"one fold per row" is invisible until the row count is 219.
|
|
1346
|
+
|
|
1347
|
+
**Until the fix is loaded, the noise can be stopped without a restart.** The subagent half is
|
|
1348
|
+
data, not code: the Host's archive flags (this plugin's own settings section) hide a session
|
|
1349
|
+
from every controller, and a running Host adopts an **external** edit of its settings file —
|
|
1350
|
+
the settings provider reports it as a `provider` change and the runtime re-seeds the flags.
|
|
1351
|
+
Measured on the real deployment: 176 subagent ids written as `{ status: archived }` under
|
|
1352
|
+
`dsh-cindy-host.sessionFlags` turned the controller's list into 43 real tasks immediately,
|
|
1353
|
+
with no restart (backup kept beside the file). The latency half is code, and only a restart
|
|
1354
|
+
loads it.
|
|
1355
|
+
|
|
1356
|
+
## A lost relay connection is retried, not reported dead
|
|
1357
|
+
|
|
1358
|
+
Reported from the handset, twice: 没看到 dsh, then 打开设置,显示断线了. The Host had been
|
|
1359
|
+
connected for hours, lost its relay heartbeat, and — by its own design — stopped:
|
|
1360
|
+
|
|
1361
|
+
```js
|
|
1362
|
+
// before: host.js
|
|
1363
|
+
async function recoverFromDeadSocket() {
|
|
1364
|
+
await disconnect({ keepSwitch: true });
|
|
1365
|
+
status.setState('failed', '与 Cindy relay 的心跳已丢失');
|
|
1366
|
+
}
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
`failed` is where the story ended. The socket was gone, nothing was scheduled, and the
|
|
1370
|
+
phone had no DSH in its device list until a person walked over to the desktop and pressed
|
|
1371
|
+
重新连接. On a *phone-controlled* Host that is a silent outage, and it is exactly what the
|
|
1372
|
+
settings card showed: 断线.
|
|
1373
|
+
|
|
1374
|
+
The reference is the Cindy device-link **client**, which the user pointed at with "我印象中
|
|
1375
|
+
它从来没有掉线过" — and it is right:
|
|
1376
|
+
|
|
1377
|
+
```
|
|
1378
|
+
packages/device-link/src/client.ts
|
|
1379
|
+
reconnectBaseMs: 1_000 reconnectMaxMs: 30_000 reconnectStableResetMs: 10_000
|
|
1380
|
+
// 应用层心跳: online 后每 20s 发 ping; 连续 N 个周期无 pong 视为僵死, 强制重连
|
|
1381
|
+
// 入站业务流量会把这组计数清零
|
|
1382
|
+
type DeviceLinkStatus = 'stopped' | 'connecting' | 'online'
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
Three things follow, and all three are now true here (`src/host-reconnect.js`):
|
|
1386
|
+
|
|
1387
|
+
- **A lost socket is retried forever**, 1s doubling to a 30s cap, with **downward jitter**
|
|
1388
|
+
(0.7×–1.0×) so many hosts that lost the same relay do not come back in lockstep. The
|
|
1389
|
+
ladder resets after 10s of stable connection, so a Host that flapped for a week still
|
|
1390
|
+
retries in a second once the link is healthy.
|
|
1391
|
+
- **There is a state for it.** The card's vocabulary had five states and none meant
|
|
1392
|
+
"retrying", which is *why* the Host had to call itself `failed`. It now has `connecting`
|
|
1393
|
+
(正在重连, warn colour) — the same middle state the client has.
|
|
1394
|
+
- **The socket is not killed by silence.** Any inbound frame clears the missed-pong
|
|
1395
|
+
counter, not just `pong`: the client states this rule for its own watchdog, and a relay
|
|
1396
|
+
that keeps delivering presence and invoke frames while answering pings late was being
|
|
1397
|
+
declared dead mid-conversation. The miss limit is the client's 3.
|
|
1398
|
+
|
|
1399
|
+
Every path that used to give up now retries — a socket `error`, a socket `close`, the
|
|
1400
|
+
heartbeat watchdog, and a socket that cannot even be **created** (booting before the
|
|
1401
|
+
network is up used to leave the Host `failed` until somebody noticed). Turning the phone
|
|
1402
|
+
switch off still cancels a queued retry: an explicit off means off. The card's 重新连接
|
|
1403
|
+
button survives as an escape hatch that skips the wait and resets the ladder.
|
|
1404
|
+
|
|
1405
|
+
`diagnostics.reconnect` reports `{ attempts, pending, lastReason }`, because "the Host
|
|
1406
|
+
vanished" and "the Host came back on its own" are indistinguishable from the handset
|
|
1407
|
+
without it. `test/host.test.js` pins all three behaviours with injected timers: a closed
|
|
1408
|
+
socket re-opens itself (status `connecting`, one retry queued at 700–1000 ms, connection
|
|
1409
|
+
restored with no call from a person), switching off cancels the retry, and a failing
|
|
1410
|
+
`openSocket` is retried rather than reported dead.
|
|
1411
|
+
|
|
1412
|
+
## The file browser's op set, read from the caller
|
|
1413
|
+
|
|
1414
|
+
The reference host serves fourteen `file-browser:remote-op` ops; the **phone asks
|
|
1415
|
+
for nine** (`mobileMakerTransport.ts`): `caps`, `listDir`, `readFile`, `listAllFiles`,
|
|
1416
|
+
`searchCollect`, `thumbnail`, `exportFileStart`, `exportFileStatus`. The write ops it
|
|
1417
|
+
supports (`createFile`, `createFolder`, `writeFile`, `renameEntry`, `deleteEntry`) are
|
|
1418
|
+
**never sent by the phone** — reading the caller first is what stopped a
|
|
1419
|
+
`writeText`-backed implementation of ops nothing would ever call, and `ctx.fs` has no
|
|
1420
|
+
mkdir/delete/rename anyway.
|
|
1421
|
+
|
|
1422
|
+
`searchCollect` is now served: the controller's shape (`{ matches, truncated,
|
|
1423
|
+
totalMatches, totalFiles }`, no `ok`) with its own bounds — 500 matches collected,
|
|
1424
|
+
`totalMatches` still counted past the cap, files skipped under `node_modules`/`.git`
|
|
1425
|
+
and friends. Two things were measured rather than assumed:
|
|
1426
|
+
|
|
1427
|
+
- **`totalMatches` must be the real total.** Stopping the scan at the cap reported
|
|
1428
|
+
"1 match" for a search that found two, because the reference host gets the total
|
|
1429
|
+
from ripgrep while it truncates the list.
|
|
1430
|
+
- **Listing first and resolving each path was the whole cost.** The first
|
|
1431
|
+
implementation read `listAllFiles` (6.1 s on this repository — it walks up to
|
|
1432
|
+
`LIST_ALL_LIMIT` entries, `node_modules` included) and then re-resolved every file.
|
|
1433
|
+
Walking the tree directly, reusing each directory entry's handle and never entering
|
|
1434
|
+
the skipped directories, took the same search from **6280 ms to 379 ms** with
|
|
1435
|
+
identical results (16 matches over 117 files).
|
|
1436
|
+
|
|
1437
|
+
The three ops that need the media pipeline answer in **their own documented shape**
|
|
1438
|
+
instead of `unknown op`, so the controller degrades deliberately:
|
|
1439
|
+
|
|
1440
|
+
```
|
|
1441
|
+
thumbnail → { ok: false, code: 'THUMB_UNSUPPORTED', message: … }
|
|
1442
|
+
exportFileStart → { ok: false, message: 'file export needs the Cindy media pipeline…' }
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
## The alignment is now a check, not a belief
|
|
1446
|
+
|
|
1447
|
+
`npm run audit:channels` reads the channel names out of the phone's own transport
|
|
1448
|
+
(`call('…')` literals), compares them with what this Host serves, and **requires every
|
|
1449
|
+
refusal to be classified**. The current state:
|
|
1450
|
+
|
|
1451
|
+
```
|
|
1452
|
+
channels a controller may name: 192
|
|
1453
|
+
served: 52
|
|
1454
|
+
declined: 140
|
|
1455
|
+
unclassified: 0
|
|
1456
|
+
```
|
|
1457
|
+
|
|
1458
|
+
`served` is the number of channels in `SUPPORTED_CHANNELS`, so it moves whenever one is
|
|
1459
|
+
added; the number that must never move is `unclassified`.
|
|
1460
|
+
|
|
1461
|
+
The 192 is not the phone's vocabulary: it is `device-link`'s own **shared allowlist**
|
|
1462
|
+
(`packages/device-link/src/allowlist.ts`), which is what actually decides whether a
|
|
1463
|
+
frame may cross the wire. Auditing the phone's `call()` sites alone left whole families
|
|
1464
|
+
(`maker:worker:*`, `maker:team:*`, `git-context:*`, `maker:agent:*`, …) unseen — and the
|
|
1465
|
+
desktop app is a controller too, in practice the one driving this Host. Reading the
|
|
1466
|
+
allowlist instead found **67 channels nobody had decided about**; each is now either
|
|
1467
|
+
served or declined with a recorded reason, several of them for a specific reason worth
|
|
1468
|
+
naming:
|
|
1469
|
+
|
|
1470
|
+
| channel | why it is not served |
|
|
1471
|
+
|---|---|
|
|
1472
|
+
| `maker:steer` / `maker:abort-session` | the same effects this Host serves as `maker:input:steer` / `maker:input:stop` — the names the phone calls; nothing calls these |
|
|
1473
|
+
| `maker:generate-title` | the *new-draft* title path; the session title path (`maker:regenerate-title`, what the rename box calls) is served |
|
|
1474
|
+
| `maker:agent:*` | the controlled desktop's CLI agent runtime (install/version/status); DSH runs its agent in-process |
|
|
1475
|
+
| `fs:resolve-path` | the desktop's @-mention path resolution; the phone's @ palette resolves through `maker:scan-at-resources` |
|
|
1476
|
+
| `maker:set-writable-dirs` | the file sandbox's writable roots are DSH-owned policy, not a remote setting |
|
|
1477
|
+
|
|
1478
|
+
One of them was worth **serving** rather than declining: `maker:session-in-turn`. The
|
|
1479
|
+
desktop calls it as a **stall watchdog** — *"when Generating looks stuck and no push has
|
|
1480
|
+
arrived, verify the host is really still running; only `false` is safe to finish on,
|
|
1481
|
+
never kill a slow turn that is genuinely working"* (`isSessionTurnRunningFor`). It
|
|
1482
|
+
answers a bare boolean, and it is read from the **source**, not from this Host's cached
|
|
1483
|
+
turn state: a missed push is precisely what it is probing for, and the cache is fed by
|
|
1484
|
+
those same pushes. Measured: `true` while a turn runs, `false` once it ends, and `false`
|
|
1485
|
+
for an unknown session rather than an error frame.
|
|
1486
|
+
|
|
1487
|
+
### The shortlist that decides what to serve next
|
|
1488
|
+
|
|
1489
|
+
The same audit now counts **call sites** for each declined channel, across both the
|
|
1490
|
+
phone's `call()` sites and the desktop renderer's `t('<channel>')` / `invokeRemote(…)`
|
|
1491
|
+
sites, and prints them ranked:
|
|
1492
|
+
|
|
1493
|
+
```
|
|
1494
|
+
declined, but a controller calls it (ranked — the shortlist for what to serve next):
|
|
1495
|
+
3x local-db:messages:around-client-id — the newer history view; …
|
|
1496
|
+
3x maker:message:delete — the session log is append-only; there is nothing to delete
|
|
1497
|
+
2x local-db:conversations:search — reads this Host does not implement
|
|
1498
|
+
2x maker:compact-session — blocked by the phone's own 15s budget, not by Host work
|
|
1499
|
+
…
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
This exists because the two failure modes are not symmetric. A refusal nobody reaches
|
|
1503
|
+
costs nothing; a refusal a live controller reaches is a feature that visibly does not
|
|
1504
|
+
work — which is exactly how the watchdog was found, by hand, one round earlier. Ranking
|
|
1505
|
+
by call sites turns that into a scan instead of a hunch: everything above is either an
|
|
1506
|
+
out-of-scope family with a recorded reason, or a candidate with the reason written next
|
|
1507
|
+
to it.
|
|
1508
|
+
|
|
1509
|
+
### A refusal is only as good as the client's handling of it
|
|
1510
|
+
|
|
1511
|
+
Declining a channel is half a decision. The other half is what the controller does when
|
|
1512
|
+
it hears no: a designed fallback is a feature degrading deliberately, while a raw error
|
|
1513
|
+
is a blank panel. So where one exists, the reason names it — and it was checked in the
|
|
1514
|
+
client's source rather than assumed:
|
|
1515
|
+
|
|
1516
|
+
| declined | the client's own answer to it |
|
|
1517
|
+
|---|---|
|
|
1518
|
+
| `local-db:messages:view` / `around*` / `history:*` | `isHistoryViewUnavailable()` matches exactly `CHANNEL_NOT_ALLOWED \| UNSUPPORTED_CAPABILITY \| not registered \| No handler`, and the paged transcript this Host serves takes over |
|
|
1519
|
+
| `maker:provider:list` | `useDeviceProviders`: *"仅结构化确认旧端没有 provider:list 时允许 capabilities-only 回退"* — the refusal is what makes the capability-only list legitimate instead of a guess |
|
|
1520
|
+
| `sidebar-settings:get-project-order` | `SyncedProjectOrderSnapshot.available === false` → *"被控端没有这个接口,控制端应回退到自己的混排"*, with `UNAVAILABLE_PROJECT_ORDER_SNAPSHOT` shipped for it |
|
|
1521
|
+
|
|
1522
|
+
This is also why `maker:provider:list` must *refuse* rather than answer an empty success
|
|
1523
|
+
(defect #18): an empty catalog is indistinguishable from "there are no models", so
|
|
1524
|
+
answering it would take the fallback away from the client that has one.
|
|
1525
|
+
|
|
1526
|
+
### The failed-turn affordance, characterised instead of hand-waved
|
|
1527
|
+
|
|
1528
|
+
`maker:input:retry-last-error` / `maker:input:clear-error` /
|
|
1529
|
+
`local-db:messages:dismiss-error` are the retry-and-dismiss buttons on a failed turn, and
|
|
1530
|
+
they were declined for a vague reason ("retry lives in the provider layer"). The audit
|
|
1531
|
+
now carries the real one, because it was read out of the client:
|
|
1532
|
+
|
|
1533
|
+
```ts
|
|
1534
|
+
// CCAgentSessionView.tsx — the banner's trigger
|
|
1535
|
+
// error-tail-banner: 会话尾部停在未忽略的 role='error' 行 → 输入框上方显示…
|
|
1536
|
+
```
|
|
1537
|
+
|
|
1538
|
+
The affordance appears only for a **trailing un-dismissed `role='error'` message row**,
|
|
1539
|
+
and this Host's fold emits no such row. So the buttons cannot appear at all — which is
|
|
1540
|
+
what makes the refusal harmless, and is a much better answer than "retry is elsewhere".
|
|
1541
|
+
Whether it should stay that way is now a decision with a stated shape, recorded as a
|
|
1542
|
+
*candidate* in the audit:
|
|
1543
|
+
|
|
1544
|
+
> would need two halves: map a failed turn to a trailing `role: 'error'` row, and serve
|
|
1545
|
+
> retry as a re-send of the last user prompt (or the shared continue prompt when the
|
|
1546
|
+
> failed turn already produced output)
|
|
1547
|
+
|
|
1548
|
+
The supporting measurement: across every session log in this deployment (879 zstd
|
|
1549
|
+
frames) the `turn/end` reasons that actually occur are `completed`, `interrupted`,
|
|
1550
|
+
`aborted`, and `blocked` — **no failure reason has been produced yet**, so there is
|
|
1551
|
+
nothing to translate today.
|
|
1552
|
+
|
|
1553
|
+
That list — `decided for now, but serveable` — was printing empty until this round: the
|
|
1554
|
+
`candidate:` note a rule records was not copied into the row it was attached to, so the
|
|
1555
|
+
"pick one up next" section could never show anything. It now names three candidates
|
|
1556
|
+
(`maker:compact-session`, and this retry pair).
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
It exists because "the phone shows nothing" and "we chose not to serve that" are
|
|
1560
|
+
indistinguishable from the outside: the first audit found **21 channels nobody had
|
|
1561
|
+
decided about**, hiding inside a long list of deliberate refusals. Each one now has a
|
|
1562
|
+
recorded reason, and the ones that are merely *not done yet* are listed separately:
|
|
1563
|
+
|
|
1564
|
+
| channel | state |
|
|
1565
|
+
|---|---|
|
|
1566
|
+
| `maker:get-context-usage` | **served** — `ctx.tokenMeter.measure(session).totalTokens`, with the window from `ctx.llm.resolveModelInfo(provider, model).context.contextWindow` |
|
|
1567
|
+
| `maker:regenerate-title` | **served** — `ctx.sessionTitle.refresh(session)`, generate-only as the controller's contract says |
|
|
1568
|
+
| `maker:compact-session` | **blocked** — see below |
|
|
1569
|
+
| `fs:mkdir-p` | **blocked, not pending** — `ctx.fs` exposes no create-directory API at all |
|
|
1570
|
+
|
|
1571
|
+
Two of these were only findable by reading DSH's own services rather than assuming:
|
|
1572
|
+
`ctx.sessionTitle.refresh()` is the manual title trigger, and `ctx.tokenMeter` /
|
|
1573
|
+
`ctx.llm` answer the context question. Both follow the same rule as the reads above —
|
|
1574
|
+
**no resume**: a cold session answers the empty value (`{ title: null }`,
|
|
1575
|
+
`null`), which the controller already renders, instead of waking an agent for a
|
|
1576
|
+
decoration. Measured:
|
|
1577
|
+
|
|
1578
|
+
```
|
|
1579
|
+
cold session → { title: null } 131 ms
|
|
1580
|
+
live session → { title: "Acceptance probe one-word reply" } 717 ms
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
The regenerate call is generate-only: the controller persists what it gets through
|
|
1584
|
+
`local-db:sessions:patch-meta`, which is why "nothing to name it with" is a *value*
|
|
1585
|
+
(`{ title: null }`) rather than an error frame.
|
|
1586
|
+
|
|
1587
|
+
### Coverage, not just classification
|
|
1588
|
+
|
|
1589
|
+
Classification answers "do we intend to serve this". It cannot answer "has anything
|
|
1590
|
+
ever actually asked for it", and the two are easy to confuse: `maker:list-agent-skills`
|
|
1591
|
+
was classified *served* while every call to it returned an empty list, and the whole
|
|
1592
|
+
approval path was implemented, wired, and never once reached. So the Host now keeps
|
|
1593
|
+
**monotonic per-channel invoke and refusal totals** (`diagnostics.invokeTotals`,
|
|
1594
|
+
`diagnostics.refusalTotals`), and the acceptance run reports what a run exercises:
|
|
1595
|
+
|
|
1596
|
+
```
|
|
1597
|
+
channel coverage: 34/47 served channels exercised # before the queue phase existed
|
|
1598
|
+
never called (by this run or any controller since the Host started):
|
|
1599
|
+
maker:input:move
|
|
1600
|
+
maker:input:remove
|
|
1601
|
+
maker:send
|
|
1602
|
+
…
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
The ring (`recentInvokes`, forty entries) cannot answer this — a polling controller
|
|
1606
|
+
churns it within seconds, which is the same reason `pushTotals` exists for the push
|
|
1607
|
+
direction. The tally is deliberately reported rather than asserted: some channels are
|
|
1608
|
+
only reachable from a handset tapping a specific control, and failing on those would
|
|
1609
|
+
push toward deleting the report instead of reading it.
|
|
1610
|
+
|
|
1611
|
+
Adding the report immediately paid for itself. It showed the file browser was never
|
|
1612
|
+
exercised by any automated run, and probing it exposed a contract detail that no unit
|
|
1613
|
+
test had covered: its op argument is `workdir`, **not** the `workingDir` every other
|
|
1614
|
+
channel uses. The controller sends it even for the `caps` probe —
|
|
1615
|
+
`caps: (workdir) => call('file-browser:remote-op', [{ op: 'caps', workdir }])` in the
|
|
1616
|
+
mobile transport — so `{ op: 'caps' }` alone answers "invalid remote-op args". The
|
|
1617
|
+
acceptance run now drives one op per family and pins the deliberate refusals:
|
|
1618
|
+
|
|
1619
|
+
```
|
|
1620
|
+
file-browser:remote-op (caps) { ok: true }
|
|
1621
|
+
file-browser:remote-op (listDir) 12 entries
|
|
1622
|
+
file-browser:remote-op (searchCollect) 35 matches
|
|
1623
|
+
file-browser:remote-op (readFile) package.json
|
|
1624
|
+
file-browser:remote-op (thumbnail) { ok: false, code: 'THUMB_UNSUPPORTED' }
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
The same run also covers the composer palettes and all four session controls, restoring
|
|
1628
|
+
what each setter changes — 62 checks, up from 35.
|
|
1629
|
+
|
|
1630
|
+
With `--with-prompts`, the run additionally builds a **real queue** (a slow turn, then two
|
|
1631
|
+
items enqueued while it runs) and drives every queue mutation against it:
|
|
1632
|
+
`set-expanded`, `set-edit-lock`, `set-interaction-lock`, `update-text`, `update-content`,
|
|
1633
|
+
`move`, `remove`, `steer`, `resume` — the channels whose defects a handset found, and
|
|
1634
|
+
which the earlier version of this script could only reach *by luck*, because an idle
|
|
1635
|
+
agent admits a prompt instead of queueing it. It ends with the interaction round-trip:
|
|
1636
|
+
`maker:send` asks a question, the card is read from `maker:get-pending-interactions`, and
|
|
1637
|
+
answering it by question text clears it.
|
|
1638
|
+
|
|
1639
|
+
```
|
|
1640
|
+
channel coverage: 48/48 served channels exercised
|
|
1641
|
+
push coverage: 9/9 push channels sent
|
|
1642
|
+
85/85 checks passed
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
Both directions of the wire now have coverage evidence, and both are the same claim:
|
|
1646
|
+
**every channel this Host serves, and every push it can send, has been exercised end to
|
|
1647
|
+
end by a repeatable run** — not classified as implemented, exercised. The push half is
|
|
1648
|
+
read from `pushTotals` against the same list the audit classifies
|
|
1649
|
+
(`src/host-push-channels.js`), which moved out of the audit script the moment a second
|
|
1650
|
+
caller needed it: two copies of "what this Host sends" would drift, and the drift would
|
|
1651
|
+
be silent — a channel added to the Host and forgotten in one tool just stops being
|
|
1652
|
+
audited.
|
|
1653
|
+
|
|
1654
|
+
48/48 is the claim worth keeping. The queue phase prints the invariant it depends on
|
|
1655
|
+
(`items enqueued during a turn stay pending — acc-slow…, acc-a…, acc-b…`), and if the
|
|
1656
|
+
model declines to ask a question the interaction phase prints a loud `SKIP` rather than
|
|
1657
|
+
passing silently, because a silent skip is exactly how that path stayed broken in the
|
|
1658
|
+
first place.
|
|
1659
|
+
|
|
1660
|
+
### Manual compaction is blocked by the *phone's* budget, not by this Host
|
|
1661
|
+
|
|
1662
|
+
`ctx.compaction.compactNow(agent, signal)` exists, the phone has an entry point
|
|
1663
|
+
(`maker:compact-session`, `{tokensBefore, estimatedTokensAfter, noop}`), and the
|
|
1664
|
+
reference `/compact` command runs the same call — so the Host side is a dozen lines.
|
|
1665
|
+
|
|
1666
|
+
It is not served anyway, because the **phone would abandon it first**:
|
|
1667
|
+
`maker:compact-session` is absent from
|
|
1668
|
+
`MOBILE_INVOKE_TIMEOUT_OVERRIDES_MS` (`packages/device-link/src/invokePolicy.ts`), so
|
|
1669
|
+
it gets the 15s mobile default — and that file's own comment says the default was
|
|
1670
|
+
tightened to 15s precisely so slow channels must claim a longer window explicitly. A
|
|
1671
|
+
compaction is one LLM summarization call over the whole history; the reference
|
|
1672
|
+
command has no such bound. Serving it would produce the worst combination: the phone
|
|
1673
|
+
reports a failure while the Host finishes compacting the history underneath it.
|
|
1674
|
+
|
|
1675
|
+
The honest move is to leave it refused until Cindy gives the channel a real budget —
|
|
1676
|
+
a one-line change there, not work here.
|
|
1677
|
+
|
|
1678
|
+
### The other direction: does the phone give *our* channels enough time?
|
|
1679
|
+
|
|
1680
|
+
The same audit now reads the phone's timeout table and lists served channels whose
|
|
1681
|
+
handlers can outlast the 15s default. Seven appear, all of them channels that resolve
|
|
1682
|
+
(or resume) a session's agent. Measured on this Host, that fear is unfounded:
|
|
1683
|
+
|
|
1684
|
+
```
|
|
1685
|
+
cold session, maker:goal:get-status (a read, no resume) → 114 ms
|
|
1686
|
+
cold session, maker:set-plan-mode (resumes the agent) → 124 ms
|
|
1687
|
+
same channel once warm → 6 ms
|
|
1688
|
+
```
|
|
1689
|
+
|
|
1690
|
+
So the list is a **regression guard**, not a defect list: it will light up if the
|
|
1691
|
+
phone tightens its default further, or if a Host channel starts doing something slow.
|
|
1692
|
+
|
|
1693
|
+
### The push half, checked the same way
|
|
1694
|
+
|
|
1695
|
+
The goal names invoke **and** push channels, so the audit answers the same question
|
|
1696
|
+
in the other direction: every push channel the phone handles must be one this Host
|
|
1697
|
+
sends, or one it does not send *for a reason*. Constants are resolved to their values
|
|
1698
|
+
first (`SESSION_ACTIVITY_CHANNEL` → `local-db:sessions:activity`), because a report
|
|
1699
|
+
that prints `<CONSTANT>` rows and calls itself complete is not checking them:
|
|
1700
|
+
|
|
1701
|
+
```
|
|
1702
|
+
push channels the phone handles: 31
|
|
1703
|
+
this Host sends: 9
|
|
1704
|
+
not sent, with a reason: 22
|
|
1705
|
+
unclassified: 0
|
|
1706
|
+
```
|
|
1707
|
+
|
|
1708
|
+
The nine are `maker:event`, `local-db:messages:created`, `maker:input:projection`,
|
|
1709
|
+
`maker:goal:status-changed`, `maker:interaction-request`, `maker:interaction-dismissed`,
|
|
1710
|
+
`local-db:sessions:created`, `local-db:sessions:patched`, `local-db:sessions:activity` —
|
|
1711
|
+
and they cover every push the phone handles **that has a fact behind it here**. The
|
|
1712
|
+
22 are "no fact to report", which is a stronger claim than "not implemented":
|
|
1713
|
+
|
|
1714
|
+
- the log is append-only, so `local-db:messages:deleted` has nothing to announce;
|
|
1715
|
+
- DSH sessions are never `closed`, which is the only state `maker:status-changed` retires;
|
|
1716
|
+
- the roster is exactly one harness (`pi`) and never changes, so `maker:agents:changed`
|
|
1717
|
+
carries no information — the phone re-reads capabilities when it attaches anyway;
|
|
1718
|
+
- `maker:provider:changed` would announce a provider registry this Host does not have
|
|
1719
|
+
(the same reason `maker:provider:list` is refused);
|
|
1720
|
+
- `maker:event:batch` is an optional transport optimization behind a capability this
|
|
1721
|
+
Host does not advertise, and `maker:session-sync` is a desktop bulk frame — this Host
|
|
1722
|
+
sends per-event pushes plus an authoritative snapshot on attach.
|
|
1723
|
+
|
|
1724
|
+
### The context read is a read
|
|
1725
|
+
|
|
1726
|
+
`maker:get-context-usage` is answered **without resuming anything**: `ctx.sessions`
|
|
1727
|
+
holds live sessions only, and a cold session answers `null`, which the controller
|
|
1728
|
+
renders as "暂无上下文数据". Waking a session to answer a number the user only glances
|
|
1729
|
+
at would be a side effect nobody asked for — the same rule the goal read follows.
|
|
1730
|
+
|
|
1731
|
+
The window is reported only when the provider discloses one (`LlmResolvedModelInfo`
|
|
1732
|
+
carries an optional `context`). The request header's `maxTokens` is deliberately **not**
|
|
1733
|
+
used as a window: that is an output bound, and a percentage computed from it would be
|
|
1734
|
+
a number that means nothing. Measured live:
|
|
1735
|
+
|
|
1736
|
+
```
|
|
1737
|
+
cold session → { ok: true, result: null }
|
|
1738
|
+
warm session → { totalTokens: 51191, maxTokens: 1000000, percent: 5.1191 }
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
Two of the 21 were worth reading twice: `local-db:messages:view`/`around` and
|
|
1742
|
+
`local-db:history:*` are the phone's *newer* history view, and refusing them is what
|
|
1743
|
+
makes it page the transcript this Host does serve — a refusal that improves the
|
|
1744
|
+
result, not a missing feature.
|
|
1745
|
+
|
|
1746
|
+
## What the session controls cost, measured
|
|
1747
|
+
|
|
1748
|
+
The four composer controls the phone draws from `maker:get-capabilities` were exercised
|
|
1749
|
+
through the same channels the controller calls, on one session, reading the row back
|
|
1750
|
+
after each:
|
|
1751
|
+
|
|
1752
|
+
```
|
|
1753
|
+
maker:set-model deepseek-flash → deepseek-v4-pro row.model updated
|
|
1754
|
+
maker:set-effort low / max / off / high row.effort updated each time
|
|
1755
|
+
maker:set-permission-mode workspace-write → read-only row.permissionMode updated
|
|
1756
|
+
maker:set-plan-mode on / off accepted (and idempotent:
|
|
1757
|
+
"Plan mode is already inactive.")
|
|
1758
|
+
```
|
|
1759
|
+
|
|
1760
|
+
and all four are accepted **while a turn is running** — the session row is the authority,
|
|
1761
|
+
so the picker's new value survives the next read.
|
|
1762
|
+
|
|
1763
|
+
The capability payload the pickers are built from, verbatim: four `availableModels` (each
|
|
1764
|
+
with `efforts`, `effortDisplayNames`, `defaultEffort`), top-level `effortLevels`
|
|
1765
|
+
(off/low/high/max), `permissionModes` (read-only/workspace-write/danger-full-access),
|
|
1766
|
+
`planMode: { supported: true }`, `hasFastMode: false`. The audit flags
|
|
1767
|
+
`set-plan-mode`/`set-permission-mode` as "slow channels with no phone-side timeout
|
|
1768
|
+
override" because their code path resolves (and may resume) the session agent; measured
|
|
1769
|
+
on a session untouched since the Host started, that resolve is **135 ms** (29 ms warm) —
|
|
1770
|
+
far inside the phone's 15 s budget, so the flag is a shape warning rather than a latency
|
|
1771
|
+
one.
|
|
1772
|
+
|
|
1773
|
+
One note about reading these back: a row read taken ~700 ms after a setter can still show
|
|
1774
|
+
the previous value. Two seconds is enough. That is a property of the read, not of the
|
|
1775
|
+
write — the same setter immediately before it had already returned `true`.
|
|
1776
|
+
|
|
1777
|
+
## What the controller reads, and not what we assume it reads
|
|
1778
|
+
|
|
1779
|
+
Four real-handset reports, four assumptions that survived unit tests and produced
|
|
1780
|
+
nothing visible on the phone. Every one was settled by reading the controller's own
|
|
1781
|
+
branch, and the push log now carries a `said` digest so the frames themselves can
|
|
1782
|
+
be read back (`diagnostics.recentPushes[].said`).
|
|
1783
|
+
|
|
1784
|
+
### System prompts arrived as ordinary messages
|
|
1785
|
+
|
|
1786
|
+
DSH logs its prompt injections as messages, and **two** shapes matter:
|
|
1787
|
+
|
|
1788
|
+
```jsonc
|
|
1789
|
+
// the system prompt itself
|
|
1790
|
+
{ "type": "system/message", "message": { "role": "system", "source": { "kind": "plugin", "plugin": "@deepseek-ai/dsh-system-prompt" }, ... } }
|
|
1791
|
+
// the runtime-context snapshot, re-injected EVERY turn — as a USER message
|
|
1792
|
+
{ "type": "user/message", "data": { "role": "user", "source": { "kind": "plugin", "plugin": "@deepseek-ai/dsh-system-prompt", "form": "snapshot" }, ... } }
|
|
1793
|
+
```
|
|
1794
|
+
|
|
1795
|
+
The fold decided role with `role === 'user' ? 'user' : 'assistant'`, so the whole
|
|
1796
|
+
system prompt rendered as an assistant reply and the "Current runtime context. This
|
|
1797
|
+
snapshot supersedes…" snapshot rendered as something the user had said, once per
|
|
1798
|
+
turn. `dsh-llm`'s `MessageSourceMap` names the categories (`user`, `plugin`,
|
|
1799
|
+
`model`, `tool`), and `plugin` is what every injection carries — so the filter is on
|
|
1800
|
+
the **source**, not the role, and a `plugin` message renders no rows at all. Verified
|
|
1801
|
+
on the real transcript: 151 rows → 150 (exactly the one injection), zero rows
|
|
1802
|
+
containing either prompt.
|
|
1803
|
+
|
|
1804
|
+
`plugin` also carries a `form`: `notice` is a one-line account DSH itself shows as a
|
|
1805
|
+
collapsed row, so mapping those onto the phone's system card is a deliberate
|
|
1806
|
+
follow-up rather than a guess made here.
|
|
1807
|
+
|
|
1808
|
+
### "Load earlier" re-fetched the same page forever
|
|
1809
|
+
|
|
1810
|
+
`oldestMessageCursor(loaded)` returns the oldest loaded row's **`id`**, and
|
|
1811
|
+
`mergeEarlierMessages` merges only when it can still find that id
|
|
1812
|
+
(`current.find(row => row.id === options.before)`). The Host treated `before` as a
|
|
1813
|
+
`createdAt`, and `'2026-…' < 'session-…'` is true for every row, so each pull
|
|
1814
|
+
returned the newest page again.
|
|
1815
|
+
|
|
1816
|
+
Two more things had to line up before the entry point could even appear:
|
|
1817
|
+
`hasOlderMessagesByServerCount` answers **false** for an unknown total (deliberately,
|
|
1818
|
+
so an entry point that leads nowhere is never shown), and the session row reported
|
|
1819
|
+
`_count: null`. The row now carries its real row count — computed on the
|
|
1820
|
+
**single-session** read, not the list the controller polls, so paging was not fixed
|
|
1821
|
+
by making polling expensive. Live: page 2 from a row id returned three different
|
|
1822
|
+
older rows, zero repeats, `_count.messages=150`.
|
|
1823
|
+
|
|
1824
|
+
### The spinner: three separate lies in one signal
|
|
1825
|
+
|
|
1826
|
+
The phone clears "thinking" only on an explicit terminal event, and the sequence we
|
|
1827
|
+
actually sent was:
|
|
1828
|
+
|
|
1829
|
+
```
|
|
1830
|
+
02:36:41.922 done ← 1 ms after the assistant message
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
and, in a cleaner run, a `done` **31 ms after a live turn began**, then another
|
|
1834
|
+
116 ms *before* the assistant's message was appended. Causes, all measured:
|
|
1835
|
+
|
|
1836
|
+
1. `sessionRowCache` is refreshed by a **list read**, so a `running: true` captured
|
|
1837
|
+
when a turn began outlived the turn that ended it — which silenced both safety
|
|
1838
|
+
nets (`pushTurnIdle` on attach, `reconcileTurnState`). The cache is now fed by
|
|
1839
|
+
every authority: `turn/start`, `turn/end` and the lifecycle events.
|
|
1840
|
+
2. `api-session/status` **flaps**: it reports `running: false` between a prompt being
|
|
1841
|
+
accepted and its turn really starting. Reading that as the end is what sent the
|
|
1842
|
+
31 ms `done`. `turn/start` and `turn/end` are the real boundaries and are now what
|
|
1843
|
+
the plugin announces; `idle` no longer ends a turn in `turnEventFor`.
|
|
1844
|
+
3. `reconcileTurnState` was armed **once per appended event** — a single turn armed
|
|
1845
|
+
dozens of timers and produced **eighteen `done` pushes**, each of which makes the
|
|
1846
|
+
controller finalize its streaming rows again. One pending check per session now,
|
|
1847
|
+
and a real boundary cancels it instead of re-arming it.
|
|
1848
|
+
|
|
1849
|
+
The corrected, measured sequence for one turn:
|
|
1850
|
+
|
|
1851
|
+
```
|
|
1852
|
+
02:43:13.844 done ← attach announcement (session idle)
|
|
1853
|
+
02:43:15.653 status isRunning:true ← turn/start
|
|
1854
|
+
02:43:15.806 local-db:messages:created user "say hi"
|
|
1855
|
+
02:43:16.684 local-db:messages:created assistant "hi"
|
|
1856
|
+
02:43:16.685 done ← turn/end, 1 ms later
|
|
1857
|
+
```
|
|
1858
|
+
|
|
1859
|
+
### A goal that exists, answered as "no goal"
|
|
1860
|
+
|
|
1861
|
+
`applyGoal` in the controller reads the difference exactly: **`undefined`** (a payload
|
|
1862
|
+
with no `result` field) means "unknown, leave the card alone", while an explicit
|
|
1863
|
+
**`null`** means "confirmed no goal" and wipes it. The Host answered `null` whenever
|
|
1864
|
+
the projection read failed or the goal key was not registered — and because a goal
|
|
1865
|
+
that has not changed never pushes again, nothing brought the card back. An
|
|
1866
|
+
unanswerable read now returns no `result` at all.
|
|
1867
|
+
|
|
1868
|
+
Two more goal gaps closed: `GoalPhase` is `active | paused | blocked | complete`, all
|
|
1869
|
+
inside the controller's vocabulary, and the card only refreshes from
|
|
1870
|
+
`maker:goal:status-changed` — so the Host now pushes that when the **projection**
|
|
1871
|
+
changes (`applyControlFrame`), not only when the user's own write changes it.
|
|
1872
|
+
|
|
1873
|
+
## A refresh failure used to sign the user out
|
|
1874
|
+
|
|
1875
|
+
`restoreSession` runs on every Host start, and its failure path was:
|
|
1876
|
+
|
|
1877
|
+
```js
|
|
1878
|
+
const refreshed = await refreshStoredSession(saved);
|
|
1879
|
+
if (!refreshed.ok) { await clearSession(); } // any failure ⇒ delete the credential
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
`refreshStoredSession` collapsed **every** exception — timeout, DNS, 5xx — into
|
|
1883
|
+
"Cindy 登录态已过期". So one network hiccup permanently deleted a working session and
|
|
1884
|
+
forced a fresh login, and a second Host instance on the same credential (its own
|
|
1885
|
+
refresh rotates the refresh token out from under the first) did the same.
|
|
1886
|
+
|
|
1887
|
+
Now the service's **refusal** (400/401/403) is separated from its **unavailability**
|
|
1888
|
+
(network, 5xx, 429): an unreachable service keeps the credential and tries with what
|
|
1889
|
+
is stored, and a refusal is *reported* rather than acted on. `cindyRequest` therefore
|
|
1890
|
+
carries `status` (a refusal) or `transport: true` (never arrived); a stub that hides
|
|
1891
|
+
the status tests neither.
|
|
1892
|
+
|
|
1893
|
+
**Nothing automatic deletes the credential any more.** The card's login form keys off
|
|
1894
|
+
the restore *result* (`resolved.ok`), not off the credential's presence, so a rejected
|
|
1895
|
+
session still shows the form while the credential survives — which is what makes a
|
|
1896
|
+
merely-raced rotation or a brief outage recoverable on the next start instead of
|
|
1897
|
+
demanding a new login. Deletion is an explicit act only: the logout route and
|
|
1898
|
+
`logout.js`, both through `forgetSession`. `test/auth-session.test.js` pins that rule
|
|
1899
|
+
with the credential store stubbed, because it is the one behaviour here that is
|
|
1900
|
+
destructive.
|
|
1901
|
+
|
|
1902
|
+
**Operational note:** run one Host instance per Cindy credential. Two instances
|
|
1903
|
+
sharing one credential race on refresh — the second one's rotation invalidates the
|
|
1904
|
+
first one's token.
|
|
1905
|
+
|
|
1906
|
+
`npm run acceptance` also guards the two fixes above rather than trusting them
|
|
1907
|
+
forever: `paging advances strictly older` now requires the second page to contain
|
|
1908
|
+
**different** rows (re-serving the newest page with older timestamps would still have
|
|
1909
|
+
looked "strictly older"), and `no prompt injection reaches the transcript` scans a
|
|
1910
|
+
real transcript for the system prompt and the runtime-context snapshot.
|
|
1911
|
+
|
|
1912
|
+
## Skills are read for the session's scope, and the scope key is the agent
|
|
1913
|
+
|
|
1914
|
+
`maker:list-agent-skills` answered an empty list **successfully**, on a Host whose
|
|
1915
|
+
skill roots plainly hold skills and whose `/` command list was full. The registry
|
|
1916
|
+
documented the reason in one line: *"Viewing scope (the calling agent); omitted reads
|
|
1917
|
+
the global layer alone."*
|
|
1918
|
+
|
|
1919
|
+
Skills live in a layered registry (`dsh-skill`), and the layer is decided where the
|
|
1920
|
+
provider was **registered**: host rows and repository plugins land in the global
|
|
1921
|
+
layer, while a plugin mounted by an agent preset's standing mount — which is where
|
|
1922
|
+
`skill-filesystem` and `tool-skill` live, per `standard/agent.cordis.yml` — lands in
|
|
1923
|
+
that preset's layer. Reading from the Host's own context therefore asks about the
|
|
1924
|
+
global layer, and the honest answer there is zero.
|
|
1925
|
+
|
|
1926
|
+
So the channel now passes the session through (`maker:list-agent-skills` already
|
|
1927
|
+
carried `sessionId`; the Host was dropping it) and the read is scoped:
|
|
1928
|
+
|
|
1929
|
+
```js
|
|
1930
|
+
const scope = await agentScopeFor(sessionId); // live agent → resume → standing key
|
|
1931
|
+
skills.list({ ...(scope ? { scope } : {}), ...(cwd ? { cwd } : {}) });
|
|
1932
|
+
```
|
|
1933
|
+
|
|
1934
|
+
**The scope key is the agent object itself.** The first attempt used
|
|
1935
|
+
`scopeOf(agent.ctx)`, on the reasonable-sounding theory that an agent's context
|
|
1936
|
+
carries its scope tag — and a temporary probe printed `live=true ... scope=false`:
|
|
1937
|
+
that context carries no tag at all. DSH's own skill tool settles it:
|
|
1938
|
+
|
|
1939
|
+
```js
|
|
1940
|
+
// @deepseek-ai/dsh-tool-skill, in both the catalog and the loader path
|
|
1941
|
+
scope: agent
|
|
1942
|
+
```
|
|
1943
|
+
|
|
1944
|
+
Measured with the fix in place, on this deployment:
|
|
1945
|
+
`live=true scoped=6 global=0`, and with no session id `scoped=0 global=0`. The six are
|
|
1946
|
+
real user skills discovered under the shared agents root; a temporary `SKILL.md` added
|
|
1947
|
+
to `<dshHome>/skills` appeared as a seventh and vanished again when it was deleted.
|
|
1948
|
+
|
|
1949
|
+
### The same menu, three different requests
|
|
1950
|
+
|
|
1951
|
+
"Live agents only, never a resume" was the second thing this read got wrong, and the
|
|
1952
|
+
phone showed it as *commands present, skills absent* — because the command list beside
|
|
1953
|
+
it **does** resolve the agent. Measured on one cold session:
|
|
1954
|
+
|
|
1955
|
+
```
|
|
1956
|
+
maker:list-agent-skills → 0 (live-agent lookup only)
|
|
1957
|
+
maker:list-agent-commands → 6 (resolves the agent; this is what made it live)
|
|
1958
|
+
maker:list-agent-skills → 6 (same session, now live)
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
Every Host restart makes every session cold, so this was not an edge case: it was the
|
|
1962
|
+
normal state of a session the phone had just opened. The scope now follows the same
|
|
1963
|
+
rule as the command list — live agent, then resume — because two different answers for
|
|
1964
|
+
one session is not purity, it is a missing menu.
|
|
1965
|
+
|
|
1966
|
+
A request naming **no** session is the new-task composer, and there the answer is what a
|
|
1967
|
+
new session would offer:
|
|
1968
|
+
|
|
1969
|
+
```js
|
|
1970
|
+
await ctx.agentPresets.standingKeyFor() // "for a host reader with no agent …
|
|
1971
|
+
// starts no agent, no session, and no turn"
|
|
1972
|
+
```
|
|
1973
|
+
|
|
1974
|
+
DSH documents that method for exactly this reader, and it composes the preset's plugins
|
|
1975
|
+
without starting anything. A session that *is* named but cannot be resolved still answers
|
|
1976
|
+
nothing: inventing the default composition's skills for an unknown session would be a
|
|
1977
|
+
claim about a session this Host cannot see.
|
|
1978
|
+
|
|
1979
|
+
Final state of the three paths, measured: cold session `6`, no session `6`, unknown
|
|
1980
|
+
session `0` with `ok: true` (an empty success, which the controller renders as "nothing
|
|
1981
|
+
to offer" rather than as a broken composer).
|
|
1982
|
+
|
|
1983
|
+
Two rules this read keeps:
|
|
1984
|
+
|
|
1985
|
+
- **an unknown session is still an empty success, not a refusal.** A Host composing no
|
|
1986
|
+
skill registry answers the same way;
|
|
1987
|
+
- **nothing here is a decoration that costs a session its state.** The resume only
|
|
1988
|
+
happens for a session the controller explicitly named while composing a message — the
|
|
1989
|
+
same session the command list already resumed for that same tap.
|
|
1990
|
+
|
|
1991
|
+
## A message is rendered for a participant, not for every role
|
|
1992
|
+
|
|
1993
|
+
The runtime-context snapshot and the system prompt were the first two injections to
|
|
1994
|
+
reach the phone as conversation: `role` alone turned them into an assistant reply and a
|
|
1995
|
+
`Current runtime context…` user bubble. The fix at the time was to drop
|
|
1996
|
+
`source.kind === 'plugin'`.
|
|
1997
|
+
|
|
1998
|
+
That was a denylist, and the next injection walked around it. The skills catalog is
|
|
1999
|
+
emitted by `@deepseek-ai/dsh-tool-skill` as a **user message**:
|
|
2000
|
+
|
|
2001
|
+
```js
|
|
2002
|
+
createUserMessage({ content: [...], source: { kind: 'skill-catalog', form: 'catalog', entries } })
|
|
2003
|
+
// …and form: 'catalog', update: true for a replacement
|
|
2004
|
+
```
|
|
2005
|
+
|
|
2006
|
+
so `<system-reminder> … <available_skills> …` arrived as something the user had said —
|
|
2007
|
+
one bubble, every time the catalog changed. DSH had already stated the rule: a
|
|
2008
|
+
`user/message` event carries **three** producers (a human prompt, a plugin notification,
|
|
2009
|
+
and an injected goal round) and "all three project their `content` verbatim; `source`
|
|
2010
|
+
tells them apart".
|
|
2011
|
+
|
|
2012
|
+
The test is therefore inverted — an allowlist of what a participant produced:
|
|
2013
|
+
|
|
2014
|
+
```js
|
|
2015
|
+
const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
|
|
2016
|
+
if (sourceKind !== null && !RENDERABLE_SOURCE_KINDS.has(sourceKind)) return rows;
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
The kinds found across DSH's own packages are exactly `user`, `model`, `tool`, `plugin`,
|
|
2020
|
+
and `skill-catalog`; every future one is producer-supplied context by default, which is
|
|
2021
|
+
the safe direction for a transcript. A source that is absent entirely is still rendered:
|
|
2022
|
+
DSH requires one on every message, so its absence means a shape this Host cannot
|
|
2023
|
+
classify, and hiding a user's own words is the worse failure.
|
|
2024
|
+
|
|
2025
|
+
Verified on a real session (`588c7fe7`, "哈喽问候对话"): the latest page went from
|
|
2026
|
+
three rows — assistant reply, the `available_skills` injection, and `哈喽` — to two,
|
|
2027
|
+
with a scan for `available_skills|system-reminder|Current runtime context` finding
|
|
2028
|
+
nothing.
|
|
2029
|
+
|
|
2030
|
+
## The approval and question cards needed to go *first*, not just be registered
|
|
2031
|
+
|
|
2032
|
+
The Host has listened for `approval/request` and `user-questions/request` since the
|
|
2033
|
+
beginning, and the phone still never saw a single card. The tool call simply hung: the
|
|
2034
|
+
session stayed `running`, `maker:get-pending-interactions` stayed `[]`, and the model
|
|
2035
|
+
was eventually told its own question had been "interrupted after it was recorded, but
|
|
2036
|
+
no result was durably recorded".
|
|
2037
|
+
|
|
2038
|
+
Two independent reasons, both invisible from the Host's own diagnostics:
|
|
2039
|
+
|
|
2040
|
+
**1. The dispatch is agent-scoped.** DSH raises both waterfalls through
|
|
2041
|
+
`scopeTarget(agent, agent)`, and Cordis admits a listener to a filtered dispatch only
|
|
2042
|
+
when it is global, untagged, or tagged with the dispatch key or one of its ancestors:
|
|
2043
|
+
|
|
2044
|
+
```js
|
|
2045
|
+
// cordis EventsService.dispatch
|
|
2046
|
+
filter((hook) => hook.global || !filter || filter.call(thisArg, hook.ctx))
|
|
2047
|
+
```
|
|
2048
|
+
|
|
2049
|
+
A host-plane observer is none of the latter, so the listener was filtered out before it
|
|
2050
|
+
could run. `{ global: true }` — "receive the event regardless of context filter checks".
|
|
2051
|
+
|
|
2052
|
+
**2. The Web bundle parks the chain first.** `dsh-api-remotes` registers its own
|
|
2053
|
+
listener on these same events and forwards them to a browser client:
|
|
2054
|
+
|
|
2055
|
+
```js
|
|
2056
|
+
// @deepseek-ai/dsh-api-remotes
|
|
2057
|
+
return ctx.on(event, function (request, next) {
|
|
2058
|
+
return forwardWaterfall(queue, event, request, { … }, next); // holds a
|
|
2059
|
+
}); // Promise.withResolvers()
|
|
2060
|
+
```
|
|
2061
|
+
|
|
2062
|
+
It does not call `next()` until that remote answers, and a waterfall is a chain: a
|
|
2063
|
+
listener registered **after** it never gets a turn. So the Host's answerer is registered
|
|
2064
|
+
`{ prepend: true, global: true }` — first say when a Cindy controller is watching, and
|
|
2065
|
+
`next()` when none is, which hands the question back to the browser path unchanged.
|
|
2066
|
+
|
|
2067
|
+
Measured on this deployment, before and after, with a fresh session and a subscribed
|
|
2068
|
+
controller:
|
|
2069
|
+
|
|
2070
|
+
```
|
|
2071
|
+
before ask_user_question → session running forever, pending [], no push
|
|
2072
|
+
after ask_user_question → card in 3s: { kind: 'ask_user_question', requestId: 'dsh-question-1', … }
|
|
2073
|
+
maker:resolve-interaction { kind: 'ask_user_question', answers: { 'Which colour?': 'blue' } }
|
|
2074
|
+
→ accepted, pending 0, tool result {"answers":[{"id":"colour","selected":["blue"]}]},
|
|
2075
|
+
agent continues: "You picked **blue**."
|
|
2076
|
+
```
|
|
2077
|
+
|
|
2078
|
+
and the approval seam on the same plumbing, by making the agent escalate out of its
|
|
2079
|
+
sandbox:
|
|
2080
|
+
|
|
2081
|
+
```
|
|
2082
|
+
card in 3s: { kind: 'permission', requestId: 'dsh-approval-2', toolName: 'write',
|
|
2083
|
+
reason: 'escalate sandbox to danger-full-access: …' }
|
|
2084
|
+
deny → accepted, pending 0, tool result isError: 'the user rejected escalating this
|
|
2085
|
+
operation to "danger-full-access"', agent stops, and no file is written outside
|
|
2086
|
+
the workspace
|
|
2087
|
+
```
|
|
2088
|
+
|
|
2089
|
+
`test/dsh-plugin.test.js` pins both options through the plugin's real `apply`, because
|
|
2090
|
+
missing either one is silent: the Host's diagnostics show a healthy session and a
|
|
2091
|
+
waiting tool call, and only the phone knows nothing arrived.
|
|
2092
|
+
|
|
2093
|
+
### …and then the frame itself was the wrong shape
|
|
2094
|
+
|
|
2095
|
+
Even with both answerers registered, the first real handset test produced **no card** —
|
|
2096
|
+
and the Host logs said everything was fine:
|
|
2097
|
+
|
|
2098
|
+
```
|
|
2099
|
+
04:59:29 maker:interaction-request watchers=1
|
|
2100
|
+
said={"kind":"permission","requestId":"dsh-approval-2","sessionId":"a15dacf0…",
|
|
2101
|
+
"toolName":"write","reason":"escalate sandbox to danger-full-access…"}
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
A card **was** created, and it **was** pushed to the one watching controller (the phone).
|
|
2105
|
+
Both clients, though, read the payload as `payload.sessionId` plus a **nested**
|
|
2106
|
+
`payload.request`, and drop anything else in silence:
|
|
2107
|
+
|
|
2108
|
+
```js
|
|
2109
|
+
// apps/mobile/src/session/remoteSessionStore.ts
|
|
2110
|
+
const sessionId = readString(payload, 'sessionId');
|
|
2111
|
+
const request = isRecord(payload.request) ? payload.request : null;
|
|
2112
|
+
if (sessionId && request) this.applyInteractionRequest(sessionId, { request, … });
|
|
2113
|
+
// apps/desktop/src/renderer/lib/makerChatStore.ts
|
|
2114
|
+
const payload = raw as { sessionId?: unknown; request?: { requestId?: unknown; kind?: unknown } };
|
|
2115
|
+
```
|
|
2116
|
+
|
|
2117
|
+
The Host was sending the request **flat**. So the failure was invisible from every
|
|
2118
|
+
direction except the screen: the registry held the card, `maker:get-pending-interactions`
|
|
2119
|
+
returned it, the push went out with a watcher — and nothing rendered.
|
|
2120
|
+
|
|
2121
|
+
What made this hard to see is worth recording: **the list path answers this same nested
|
|
2122
|
+
shape**. `maker:get-pending-interactions` returns `{ request: {…} }` entries, so a test
|
|
2123
|
+
that goes through the list cannot tell a correct push from a broken one. The unit test
|
|
2124
|
+
made the same mistake — it asserted `payload.kind === 'permission'` on a flat payload, so
|
|
2125
|
+
it passed while clients dropped every frame. Both are fixed:
|
|
2126
|
+
|
|
2127
|
+
- the Host pushes `{ sessionId, request }` from both answerers;
|
|
2128
|
+
- `test/host.test.js` asserts the nested shape (`payload.request.kind`, …);
|
|
2129
|
+
- `tools/acceptance.mjs` asserts the **frame**: `recentPushes` for
|
|
2130
|
+
`maker:interaction-request` must have `watchers > 0` and a `said` digest containing the
|
|
2131
|
+
nested `"request"` and the request id.
|
|
2132
|
+
|
|
2133
|
+
Confirmed on a real handset after the fix (`ask_user_question` answered normally from the
|
|
2134
|
+
phone). The lesson generalises past this channel: **a push is only verified when the
|
|
2135
|
+
payload the client's handler reads has been asserted, not when the same data can be
|
|
2136
|
+
fetched another way.**
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
### The card payloads, checked against the controller's own builders
|
|
2140
|
+
|
|
2141
|
+
The request side is this Host's, but the **decision** side is the controller's, and the
|
|
2142
|
+
two vocabularies have to line up or the card is drawn and cannot be answered. From the
|
|
2143
|
+
Cindy sources:
|
|
2144
|
+
|
|
2145
|
+
```ts
|
|
2146
|
+
// packages/maker-shared/src/interaction.ts
|
|
2147
|
+
export function buildAskUserQuestionDecision(answers: Record<string, string>) {
|
|
2148
|
+
return { kind: 'ask_user_question', answers }; // keyed by question TEXT
|
|
2149
|
+
}
|
|
2150
|
+
// permission decisions carry exactly: behavior: 'allow' | 'deny'
|
|
2151
|
+
```
|
|
2152
|
+
|
|
2153
|
+
which matches this Host's `QUESTION_KIND = 'ask_user_question'` plus
|
|
2154
|
+
`questionAnswerOf`'s text-keyed lookup, and `decisionOutcome`'s
|
|
2155
|
+
`'allow' → 'allowed-once'` / everything else → `'rejected'` (fail-closed).
|
|
2156
|
+
|
|
2157
|
+
Two deliberate choices on the request side, both verified in the same pass:
|
|
2158
|
+
|
|
2159
|
+
- the permission card carries **no** `canAlwaysAllow`, so the controller never offers
|
|
2160
|
+
"always allow": DSH's approval vocabulary here is `allowed-once` / `rejected`, and
|
|
2161
|
+
advertising a persistence option this seam cannot honour would silently downgrade the
|
|
2162
|
+
user's intent;
|
|
2163
|
+
- a question answered by **text** is matched back through the questions that were asked,
|
|
2164
|
+
and anything the controller returns that is not one of the offered labels is carried
|
|
2165
|
+
as DSH's `custom` rather than dropped.
|
|
2166
|
+
|
|
2167
|
+
A decision whose kind does not match the pending entry is refused (`{ accepted: false }`)
|
|
2168
|
+
rather than acknowledged — the controller keeps the card open instead of believing it was
|
|
2169
|
+
answered. Measured directly: sending `{ kind: 'question', … }` to a question card yields
|
|
2170
|
+
`accepted: false`, and `{ kind: 'ask_user_question', answers: { 'Which colour?': 'blue' } }`
|
|
2171
|
+
yields `accepted: true`.
|
|
2172
|
+
|
|
2173
|
+
### "Watching" is not "belongs to the phone"
|
|
2174
|
+
|
|
2175
|
+
The answerer originally claimed a card only while a controller was subscribed to that
|
|
2176
|
+
session (`watchersFor(sessionId) > 0`), and passed everything else down the chain so the
|
|
2177
|
+
desk could answer it. That guard is right in spirit and wrong in this case: leaving a
|
|
2178
|
+
session unsubscribes its topic, so an agent that asks something **after the user looks
|
|
2179
|
+
away** had its question handed to the Web bundle's remote forwarder — which parks it
|
|
2180
|
+
waiting for a browser. Measured, on a real session:
|
|
2181
|
+
|
|
2182
|
+
```
|
|
2183
|
+
attach session → unsubscribe → agent asks a question
|
|
2184
|
+
pending while unwatched = 0
|
|
2185
|
+
pending after re-subscribing = 0 ← the card is gone for the phone, permanently
|
|
2186
|
+
session = running (forever; no card anywhere the user is looking)
|
|
2187
|
+
```
|
|
2188
|
+
|
|
2189
|
+
The distinction the fix makes is between *currently watching* and *belongs to the phone*:
|
|
2190
|
+
a session this Host has served to a controller at least once in this process keeps
|
|
2191
|
+
claiming its cards, and the card stays discoverable through
|
|
2192
|
+
`maker:get-pending-interactions` even while nobody is subscribed. A session no controller
|
|
2193
|
+
has ever attached to is still the desk's, unchanged. Re-measured after the fix:
|
|
2194
|
+
|
|
2195
|
+
```
|
|
2196
|
+
attach session → unsubscribe → agent asks a question
|
|
2197
|
+
card in 3s: { kind: 'ask_user_question', requestId: 'dsh-question-1', … }
|
|
2198
|
+
maker:resolve-interaction { kind: 'ask_user_question', answers: { 'Pick one': 'A' } }
|
|
2199
|
+
→ accepted, agent continues: "You picked **A**."
|
|
2200
|
+
```
|
|
2201
|
+
|
|
2202
|
+
The card's own budget came with this: it was the registry default of **two minutes**,
|
|
2203
|
+
which is the wrong shape for the surface being served. The card has no countdown, the
|
|
2204
|
+
user may be away from the phone, and the path this replaces waits indefinitely — so a
|
|
2205
|
+
short Host timeout is strictly worse than the reference. Half an hour, with
|
|
2206
|
+
`maker:input:stop` settling a card immediately as the escape hatch, mirrors the reference
|
|
2207
|
+
while still bounding a forgotten turn.
|
|
2208
|
+
|
|
2209
|
+
`test/host.test.js` pins both halves: an untouched session still defers (`null`), and a
|
|
2210
|
+
session that was attached to and then looked away still lists its card and accepts the
|
|
2211
|
+
answer.
|
|
2212
|
+
|
|
2213
|
+
## The list spinner reads the row, so the row had to be patched
|
|
2214
|
+
|
|
2215
|
+
The report was 思考中 outliving the turn — "agent 已经结束了,但我这边还是显示 思考中",
|
|
2216
|
+
in an ordinary conversation and again when a goal round ended. The Host's own state was
|
|
2217
|
+
settled every time it was inspected: `row.running=false`, `maker:list-active` empty,
|
|
2218
|
+
`maker:session-in-turn` false, queue empty, and a `maker:event {type:'done'}` had gone
|
|
2219
|
+
out with `watchers=1`.
|
|
2220
|
+
|
|
2221
|
+
The reason is that **two spinners read two different things**. A terminal `maker:event`
|
|
2222
|
+
clears the composer and finalizes streaming rows; the **session list**'s running badge
|
|
2223
|
+
reads the cached row it already holds, and that row only changes when something patches
|
|
2224
|
+
it. This Host's list-level patch carried `updatedAt` and nothing else:
|
|
2225
|
+
|
|
2226
|
+
```js
|
|
2227
|
+
// src/session-publisher.js — before
|
|
2228
|
+
else if (item.kind === 'session-status') publish(ws, subscribers,
|
|
2229
|
+
'local-db:sessions:patched', { sessionId: item.sessionId, patch: { updatedAt: item.occurredAt } }, record);
|
|
2230
|
+
```
|
|
2231
|
+
|
|
2232
|
+
so a row whose optimistic start had set `running: true` kept it, and the list went on
|
|
2233
|
+
spinning after the answer had arrived. It is the same class as 缺陷15 and 缺陷19 and a
|
|
2234
|
+
different surface: those were about *whether* a terminal event is sent, this is about
|
|
2235
|
+
which reader is listening.
|
|
2236
|
+
|
|
2237
|
+
The fix is `publishRowTurnState(sessionId, running)`, published at exactly the two
|
|
2238
|
+
moments the `maker:event` frame is (DSH's own `turn/start` / `turn/end`, which the plugin
|
|
2239
|
+
drives) and never from the flapping `idle` phase — a row must not assert a turn state the
|
|
2240
|
+
Host cannot stand behind:
|
|
2241
|
+
|
|
2242
|
+
```
|
|
2243
|
+
local-db:sessions:patched { sessionId, patch: { running, updatedAt } }
|
|
2244
|
+
```
|
|
2245
|
+
|
|
2246
|
+
Delivered **once per controller**, to the union of the `sessions` topic holders and that
|
|
2247
|
+
session's watchers: a phone sitting inside one session holds both topics, and a row patch
|
|
2248
|
+
is a state assertion rather than an event, so the same reader receiving it twice is pure
|
|
2249
|
+
duplicate work. That dedupe is not cosmetic — it is what makes "one patch per turn
|
|
2250
|
+
boundary" checkable from the frame log. The first version published down both paths, and
|
|
2251
|
+
the existing frame-count assertion caught it immediately (`3 !== 1` on attach).
|
|
2252
|
+
|
|
2253
|
+
`test/host.test.js` pins both readers: a controller holding *only* the list topic gets
|
|
2254
|
+
exactly one frame for a finished turn (the case that was broken), and a controller
|
|
2255
|
+
holding both topics gets one `maker:event` plus one row patch, not two row patches.
|
|
2256
|
+
|
|
2257
|
+
## A subscription the relay cannot reach is not a subscription
|
|
2258
|
+
|
|
2259
|
+
> **这条策略后来被推翻了**——见「presence 会说谎,订阅表是我们自己的账本」。这里保留当时的推理与
|
|
2260
|
+
> 实测,因为推翻它的证据正是它自己造成的故障:**当前实现不删任何订阅**。
|
|
2261
|
+
|
|
2262
|
+
The other half of the same report — 「我在这边一直盯着这个屏幕,但是他还是一直在转圈思考中…
|
|
2263
|
+
我必须要返回上一页退出会话重新进才能看到你的回答」 — was measured with the frame log on
|
|
2264
|
+
both sides of the relay, and the Host was the one at fault:
|
|
2265
|
+
|
|
2266
|
+
```
|
|
2267
|
+
09:17:38 presence-changed { deviceId: <phone>, online: false, lastSeenAt: 09:17:42 }
|
|
2268
|
+
09:18:29 last request from the phone
|
|
2269
|
+
09:18–09:2x Host pushes: local-db:messages:created …, maker:event {type:'done'}, row patches
|
|
2270
|
+
→ every frame dropped by a relay with no route to that device
|
|
2271
|
+
```
|
|
2272
|
+
|
|
2273
|
+
The Host held a subscription set it had no way to check, and `online:false` was the only
|
|
2274
|
+
moment the relay ever said so. Three changes follow from that one fact:
|
|
2275
|
+
|
|
2276
|
+
- ~~**`presence-changed{online:false}` drops the device's subscriptions**~~ — 这是当时的做法(`sessions`
|
|
2277
|
+
主题和它的每一个 `session:*` 主题),前提是「推给中继已经不认的路由不算送达」。这个前提在 09:48:04
|
|
2278
|
+
反噬了(见下),所以**现在只把设备记进 `offlineDevices`,订阅一个都不删**(`markDeviceUnreachable`):
|
|
2279
|
+
`pushSessionUpdate` 仍然发给每一个 watcher,可达性只决定这次推送**算不算已送达**(局部变量
|
|
2280
|
+
`reachable` 只进推送计数 `pushLog[].watchers`;终端状态的去重账另走一路,它自己判断
|
|
2281
|
+
`offlineDevices.has(deviceId)`)。
|
|
2282
|
+
- **The sessions it was inside are remembered** (`watchedSessionsByDevice`), so its next
|
|
2283
|
+
`link-open` can be answered with the turn state instead of silence. The announcement is
|
|
2284
|
+
sent only when the cached row positively says the turn is over: a cold row is never read
|
|
2285
|
+
as idle, so a live turn is never cleared by this path.
|
|
2286
|
+
- **The unwatched fallback in `announceTurnIdle` skips unreachable devices.** It exists to
|
|
2287
|
+
repair a controller whose subscription died with the process, so it fans out to
|
|
2288
|
+
controllers with no watchers for the session; a device the relay has declared offline is
|
|
2289
|
+
not told and, crucially, **not counted as told** — the de-duplication entry that keeps one
|
|
2290
|
+
turn boundary to one `done` is a claim that the frame arrived, and the re-link repair
|
|
2291
|
+
depends on that claim being true.
|
|
2292
|
+
|
|
2293
|
+
The de-duplication itself is a `device × session` timestamp with a 30 s window, recorded by
|
|
2294
|
+
every terminal announcement (the session watchers, the unwatched fallback) and consulted by
|
|
2295
|
+
the `link-open` repair. It is needed because the two paths genuinely race: the
|
|
2296
|
+
`device-link:subscribe` that attaches a controller announces the terminal state it finds,
|
|
2297
|
+
and the re-link that follows immediately reaches the same device for the same turn — one
|
|
2298
|
+
`done` too many makes the controller finalize its streaming rows again.
|
|
2299
|
+
|
|
2300
|
+
`test/host.test.js` pins both directions with an injected clock: a device declared offline
|
|
2301
|
+
receives nothing (including a turn that ends while it is away) and is told the truth when
|
|
2302
|
+
it links again after the window; a re-link inside the window repeats nothing.
|
|
2303
|
+
|
|
2304
|
+
### presence 会说谎,设备自己的帧不会
|
|
2305
|
+
|
|
2306
|
+
上面那条「离线就撤销订阅」的策略,实测在 09:48:04 造成了它本来要避免的症状:
|
|
2307
|
+
|
|
2308
|
+
```
|
|
2309
|
+
09:47:04 dsh web 重启,手机重新连上并订阅
|
|
2310
|
+
09:48:04 presence-changed { online: false } ← 但用户正盯着手机看
|
|
2311
|
+
→ dropOfflineSubscriptions 撤掉全部订阅 → 之后不再有任何 push
|
|
2312
|
+
09:51:13 手机自己重新 subscribe(用户退出会话再进来)
|
|
2313
|
+
```
|
|
2314
|
+
|
|
2315
|
+
这三分钟里手机既转圈又「没有收到你的这些操作记录」——和这条策略要修的那个症状一模一样。结论:
|
|
2316
|
+
**presence 是中继的意见,设备发来的帧是事实**。所以现在任何一帧(`invoke` 或 `link-open`)来自
|
|
2317
|
+
被标记离线的设备,就当场撤销判定(`markDeviceReachable`):
|
|
2318
|
+
|
|
2319
|
+
- 它不再被计入「已送达」(从 `offlineDevices` 删掉),而它可能错过的回合状态重新播报
|
|
2320
|
+
(复用 `announceWatchedTurnState`,与 `link-open` 同一条去重窗口);
|
|
2321
|
+
- 它正在看的视图被失效,于是它重读而不是停在旧内容;
|
|
2322
|
+
- **订阅从来没有被删过,所以没有东西需要「恢复」**。撤销是不可逆的,可达性是可逆的——这就是后来
|
|
2323
|
+
把「离线就撤销订阅」整条推翻的地方。
|
|
2324
|
+
|
|
2325
|
+
presence 改口说 `online: true` 时做同样的事。它只是加速器而不是生命线:推送本来就与可达性无关,
|
|
2326
|
+
所以一台再也不开口的设备,仍然会收到中继能路由到它的一切。
|
|
2327
|
+
|
|
2328
|
+
### 一次列表读超时不该变成空列表
|
|
2329
|
+
|
|
2330
|
+
同一段时间还量到 **7 次 `local-db:sessions:list` 以 `TimeoutError` 失败**,集中在重启后的 13 秒内:
|
|
2331
|
+
手机重连 + 两台桌面端在轮询,同时冷读 45 个会话,都压在那个 15s 预算上。控制器把列表失败渲染成
|
|
2332
|
+
**什么都没有**——也就是用户看到的转圈。`dsh-session-source.js` 因此加了两条性质:
|
|
2333
|
+
|
|
2334
|
+
- **单飞**:并发调用共享同一次读,三个控制器在冷启动时问同一个问题,不会把已经赶不上期限的活儿
|
|
2335
|
+
再乘三;
|
|
2336
|
+
- **陈旧的答案好过失败的答案**:真的错过期限时,把刚才读到的那份列表发出去(120s 内),而不是
|
|
2337
|
+
抛错。数据是真实且持久的,有疑问的只是它的年龄,而且下一次轮询就会纠正——它替代的失败模式是
|
|
2338
|
+
空列表。
|
|
2339
|
+
|
|
2340
|
+
陈旧的应答会记数并出现在 `diagnostics.listing`(`staleServes` / `lastStaleReason`),所以「被吸收
|
|
2341
|
+
的降级」不会变成静默。**这个数不该增长**:它在长,说明列表读本身需要优化,而不是说明系统健康。
|
|
2342
|
+
|
|
2343
|
+
## 一次未处理的 rejection 会结束整个 dsh web
|
|
2344
|
+
|
|
2345
|
+
手机报「DSH 掉线了」的那一次,原因不在中继也不在网络。证据链(时间为 UTC+8):
|
|
2346
|
+
|
|
2347
|
+
```
|
|
2348
|
+
17:30:10 用户从手机发来「我已经重启了。」(进程 17:29:04 启动)
|
|
2349
|
+
17:30:12 本轮 turn 113 开始;同一时刻我启动了 tools/acceptance.mjs --with-prompts
|
|
2350
|
+
17:30:23 turn/end {"reason":{"kind":"interrupted"}} ← 回合中途进程消失
|
|
2351
|
+
17:30:24 settings.yaml 与 acceptance-probe 会话日志的最后几笔写入(退出前的落盘)
|
|
2352
|
+
17:32:35 用户手动重启,cordis.yml 重新生成
|
|
2353
|
+
```
|
|
2354
|
+
|
|
2355
|
+
Windows 事件日志里**没有** node.exe 崩溃记录,`~/.dsh` 里也没有崩溃报告,说明不是原生
|
|
2356
|
+
崩溃。原因在 DSH 自己的启动代码里:
|
|
2357
|
+
|
|
2358
|
+
```js
|
|
2359
|
+
// node_modules/@deepseek-ai/dsh-app-boot/lib/index.js — installFailLoud
|
|
2360
|
+
const handler = (err) => {
|
|
2361
|
+
…
|
|
2362
|
+
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
|
2363
|
+
…
|
|
2364
|
+
proc.exit(1)
|
|
2365
|
+
}
|
|
2366
|
+
proc.on('unhandledRejection', handler)
|
|
2367
|
+
```
|
|
2368
|
+
|
|
2369
|
+
即:**进程里任何一处未处理的 promise rejection,都会让 `dsh web` 把堆栈写到 stderr 并
|
|
2370
|
+
`exit(1)`**。`exit(1)` 是正常退出,系统层面因此什么都没有;而本插件住在同一个进程里,中继
|
|
2371
|
+
websocket 随之断开,手机看到的就是「掉线」,直到 2.5 分钟后手动重启才恢复。
|
|
2372
|
+
|
|
2373
|
+
### 抛出点:一个 async 方法被同步 try/catch 包着
|
|
2374
|
+
|
|
2375
|
+
终端里的那一行给出了答案:
|
|
2376
|
+
|
|
2377
|
+
```
|
|
2378
|
+
fatal load failure: RemoteError: queued item is no longer pending
|
|
2379
|
+
at SessionCommandController.updateQueue (…/dsh-api-session-controller/lib/index.js:842:33)
|
|
2380
|
+
at Object.update (…/G:/Projects/DSH-cindy-host/src/dsh-plugin.js:636:36)
|
|
2381
|
+
```
|
|
2382
|
+
|
|
2383
|
+
`src/cindy-channels.js` 里的提交函数是这样写的:
|
|
2384
|
+
|
|
2385
|
+
```js
|
|
2386
|
+
function commitQueueAction(control, sessionId, itemId, action) {
|
|
2387
|
+
try {
|
|
2388
|
+
control.update({ sessionId, itemId, action }); // ← 没有 await
|
|
2389
|
+
return null; // ← 永远返回「没失败」
|
|
2390
|
+
} catch (error) { … }
|
|
2391
|
+
}
|
|
2392
|
+
```
|
|
2393
|
+
|
|
2394
|
+
而 `queueControl.update` 是 `async` 的——它先 `await sessionController.resolveAgent(sessionId)`
|
|
2395
|
+
再提交(这个 `await` 是后来加的,为了让冷会话上的队列项也能改):
|
|
2396
|
+
|
|
2397
|
+
```js
|
|
2398
|
+
update: async ({ sessionId, itemId, action }) => {
|
|
2399
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
2400
|
+
if (resolved?.error !== undefined) throw resolved.error;
|
|
2401
|
+
return sessionController.updateQueue({ sessionId, itemId, action });
|
|
2402
|
+
},
|
|
2403
|
+
```
|
|
2404
|
+
|
|
2405
|
+
`updateQueue` 是同步方法,同步 `throw`——但它在一个 `async` 函数体里,于是变成**被拒绝的
|
|
2406
|
+
promise**。同步的 `try`/`catch` 什么也接不到,那个 promise 也没人接,直接落到 DSH 的
|
|
2407
|
+
fail-loud 处理器上。于是这一个改动同时造成了两个后果:
|
|
2408
|
+
|
|
2409
|
+
1. **整个 dsh web 退出**,手机随之掉线(事故现场);
|
|
2410
|
+
2. **每一次被 DSH 拒绝的队列操作都被当成成功回给控制器**——`null`(「没有失败」)是无条件
|
|
2411
|
+
返回的。手机点了取消,行还在;这正是 `acceptance.mjs` 注释里那句「a cancel that reported
|
|
2412
|
+
NOT_FOUND…a silent success」的反面版本。
|
|
2413
|
+
|
|
2414
|
+
修法是把整条链改成真异步(`await control.update(...)`,`commitQueueAction` /
|
|
2415
|
+
`commitQueueActionAndReconcile` 变 `async`,三处调用点 `await`),并顺手做了通用加固:本插件
|
|
2416
|
+
所有边界都不允许异常逃逸——
|
|
2417
|
+
|
|
2418
|
+
- socket `'message'` 监听器不再裸调 `handleFrame`;
|
|
2419
|
+
- invoke 应答路径(`fitReplyToFrame` + `send`)不再可能在 `.then(onOk)` 里抛;
|
|
2420
|
+
- 心跳、重连、设备目录、活跃会话播报这些浮动 promise 全部挂上 rejection handler;
|
|
2421
|
+
- Cordis 的 `session/event` 监听器整体包 `try`/`catch`——它跑在 DSH 自己的事件分发里,
|
|
2422
|
+
抛出去就没人接:
|
|
2423
|
+
|
|
2424
|
+
```js
|
|
2425
|
+
// @deepseek-ai/cordis/lib/index.js
|
|
2426
|
+
emit(...args) { this.dispatch("emit", args).map((cb) => cb(...args)); }
|
|
2427
|
+
```
|
|
2428
|
+
|
|
2429
|
+
也就是说,插件抛出的异常不是「这个插件坏了」,而是「桌面的 DSH 没了」。
|
|
2430
|
+
|
|
2431
|
+
捕获到的东西统一进有界的 `handlerErrors` 环形缓冲,并通过 `/status` 的
|
|
2432
|
+
`diagnostics.handlerErrors` 暴露(含 `where`、`message`、前 6 行 `stack`)。**这个数组非空
|
|
2433
|
+
意味着刚刚挡住了一次会让整个 dsh web 退出的异常**,而不是「Host 扛住了」。
|
|
2434
|
+
|
|
2435
|
+
测试也补在了正确的位置:`test/queue-commands.test.js` 的 `queueControl.update` 夹具**故意
|
|
2436
|
+
写成 async**,因为真的那个就是 async——同步夹具让这个 bug 一路过关。新增两条断言拒绝路径,
|
|
2437
|
+
去掉修复后它们会以 `unhandledRejection` 失败(已实测),也就是事故本身的形状。
|
|
2438
|
+
|
|
2439
|
+
排查同类问题的最快证据仍然是**启动 `dsh web` 那个终端的 stderr**:那一行 `dsh: fatal load
|
|
2440
|
+
failure:` 后面就是完整的堆栈,它指向真正的抛出点。
|
|
2441
|
+
|
|
2442
|
+
## 删除 / 归档 / 置顶: one channel, and only one of its fields had a counterpart
|
|
2443
|
+
|
|
2444
|
+
The controller's session menu is four actions and **one** write. Desktop
|
|
2445
|
+
(`apps/desktop/src/renderer/lib/sessionService.ts`) and mobile
|
|
2446
|
+
(`apps/mobile/src/session/useSessionListActions.ts`) both send:
|
|
2447
|
+
|
|
2448
|
+
```ts
|
|
2449
|
+
invoke(deviceId, 'local-db:sessions:patch-meta', [sessionId, patch])
|
|
2450
|
+
type SessionMetaPatch = Partial<Pick<RemoteSession, 'status' | 'title' | 'pinnedAt'>>
|
|
2451
|
+
// 归档 `{ status: 'archived', pinnedAt: null }` 删除 `{ status: 'deleted' }`
|
|
2452
|
+
// 置顶 `{ pinnedAt: <ISO> }` 重命名 `{ title }`
|
|
2453
|
+
```
|
|
2454
|
+
|
|
2455
|
+
Only 重命名 has a DSH counterpart (`sessionController.rename`), and it works end to end —
|
|
2456
|
+
renaming the probe and re-reading the list returns the new title from both the reply and
|
|
2457
|
+
the next listing. The other three were answered with the *unchanged row*, which is not a
|
|
2458
|
+
refusal at all: the controller applies only the fields it wrote, taken from this reply,
|
|
2459
|
+
|
|
2460
|
+
```ts
|
|
2461
|
+
// apps/mobile/src/session/useSessionListActions.ts
|
|
2462
|
+
const updated = await sessionMetaWriteQueue.enqueue(…);
|
|
2463
|
+
remoteSessionStore.applySessionPatch(shardId, session.id, pickWriteFields(updated, fields, …));
|
|
2464
|
+
```
|
|
2465
|
+
|
|
2466
|
+
so an unchanged row is an instruction to revert the user's edit. That is what "点了没反应"
|
|
2467
|
+
was.
|
|
2468
|
+
|
|
2469
|
+
DSH sessions are a log-backed store with no archived, deleted or pinned state, so these
|
|
2470
|
+
three are now this Host's own bookkeeping (`src/session-flags.js`), persisted in this
|
|
2471
|
+
plugin's settings section as `sessionFlags` and folded onto the row at every read path
|
|
2472
|
+
(list, get, and the row echoed back to a writer). Three decisions are deliberate:
|
|
2473
|
+
|
|
2474
|
+
- **删除 hides; it does not destroy.** The session leaves what this Host's controllers
|
|
2475
|
+
see; DSH's log and the desk's own DSH UI are untouched, and the archive view restores
|
|
2476
|
+
it. A Host that deleted DSH data on a phone tap would be a far worse failure than a
|
|
2477
|
+
hidden row.
|
|
2478
|
+
- **A hidden session is out of `maker:list-active` too.** The phone does not show its row,
|
|
2479
|
+
so an entry for it would light the running badge from a session nobody can open.
|
|
2480
|
+
- **The other controllers are told.** The write is echoed as a row patch to every device
|
|
2481
|
+
holding that row — the writer's own `sessionPendingWrites.consumeMaskedPush` exists for
|
|
2482
|
+
exactly that echo — instead of leaving them on a stale status until their next read.
|
|
2483
|
+
|
|
2484
|
+
### Why DSH's own session list will never show this
|
|
2485
|
+
|
|
2486
|
+
Reported from the handset, on both actions: "手机端归档了,DSH 没有归档 / 手机端已经删除了,
|
|
2487
|
+
PC 还在". The Host half is complete and was verified on the live process — the phone's writes
|
|
2488
|
+
persist and every controller is served the new status:
|
|
2489
|
+
|
|
2490
|
+
```
|
|
2491
|
+
sessionFlags:
|
|
2492
|
+
e85dce4e-…: { status: archived } ← 哈喽
|
|
2493
|
+
4afc2caf-…: { status: deleted } ← 打招呼问候
|
|
2494
|
+
local-db:sessions:list →
|
|
2495
|
+
id=4afc2caf-… status=[deleted]
|
|
2496
|
+
id=e85dce4e-… status=[archived]
|
|
2497
|
+
```
|
|
2498
|
+
|
|
2499
|
+
What still lists them is **DSH itself**, and it is a boundary rather than a missing wire —
|
|
2500
|
+
DSH has no writable field and no command for this state at all:
|
|
2501
|
+
|
|
2502
|
+
- `SessionSummary` is exactly `sessionId, updatedAt, running, blank, parentSessionId?,
|
|
2503
|
+
origin?, cwd?, projections?` (`dsh-api-session-controller/lib/types/types.d.ts:145`) —
|
|
2504
|
+
no status, no archived, no hidden, and `SessionListRequest` is `{ cursor? }`, so not even
|
|
2505
|
+
a filter to honour;
|
|
2506
|
+
- the controller's whole command surface is `list / create / rename / fork / resolveAgent`
|
|
2507
|
+
— there is no delete, archive or hide;
|
|
2508
|
+
- a full sweep of the installed packages for `'archived'` hits only the **workspace**
|
|
2509
|
+
controller (`archivedSessionIds` in `dsh-api-workspace-controller`) — a different concept
|
|
2510
|
+
that never touches a session row;
|
|
2511
|
+
- the one list-hiding mechanism DSH does have, `blank`, is *derived* from an empty log
|
|
2512
|
+
("lists hide blank sessions"), not a flag anybody can set.
|
|
2513
|
+
|
|
2514
|
+
Worth stating plainly, because it is the difference from a real controlled desktop: there,
|
|
2515
|
+
归档/删除 write **the desk's own database**, so the desk's Cindy UI reflects them by
|
|
2516
|
+
construction — the desk *is* the store. Here the store is DSH's, which has no such field, so
|
|
2517
|
+
the flags are authoritative for every Cindy controller of this Host (phone and any desktop
|
|
2518
|
+
client alike) and invisible to DSH's own UI. Chosen deliberately over the two alternatives
|
|
2519
|
+
that would make it visible: rewriting the real session title (`[已归档] …`, dirty and
|
|
2520
|
+
clobberable by title regeneration), or deleting the session's files outright (DSH exposes no
|
|
2521
|
+
API for it either — filesystem surgery, and the log is gone for good).
|
|
2522
|
+
|
|
2523
|
+
A consequence to know when testing: a **deleted** row is gone from every client view, so the
|
|
2524
|
+
phone offers no way to bring it back; clearing the flag on the Host is the only restore path.
|
|
2525
|
+
|
|
2526
|
+
### Where the durability actually lives: the Host is the authority, the clients are caches
|
|
2527
|
+
|
|
2528
|
+
Asked directly — "DSH has no such field, so how does Cindy know it was archived?" — the answer
|
|
2529
|
+
is that Cindy does not *discover* it. Three layers:
|
|
2530
|
+
|
|
2531
|
+
1. **The controller said so.** 删除/归档 is a write the controller initiates
|
|
2532
|
+
(`local-db:sessions:patch-meta`), applied optimistically on the device before the round trip
|
|
2533
|
+
(`useSessionListActions` → `remoteSessionStore.applySessionPatch`). The phone rendering
|
|
2534
|
+
"已归档" is the phone's own write, not a read.
|
|
2535
|
+
2. **This Host is now the durable authority for it** — the `sessionFlags` section. The status
|
|
2536
|
+
travels back on three paths: the reply row (which decides whether the writer keeps its edit),
|
|
2537
|
+
`local-db:sessions:list` / `get` (any later read, on any device), and the
|
|
2538
|
+
`local-db:sessions:patched` push (the other controllers). A second device can only have
|
|
2539
|
+
learned it from here, and a phone that lost its cache re-learns it on its next read. Before
|
|
2540
|
+
this existed the flag had no home at all outside the one device that made it.
|
|
2541
|
+
3. **Both clients do keep a local copy of the list, status included** — and both deliberately
|
|
2542
|
+
refuse to cache a *deleted* row:
|
|
2543
|
+
- phone: `apps/mobile/src/session/mobileHomeListCache.ts` — AsyncStorage under
|
|
2544
|
+
`xdt.mobileHomeListCache.v2.<userId>`, painted on cold start and reconciled when the fresh
|
|
2545
|
+
list arrives; `coerceCachedSession` accepts only `'active'`/`'archived'` (anything else,
|
|
2546
|
+
`deleted` included, returns null) while `pinnedAt` is kept;
|
|
2547
|
+
- desktop: `apps/desktop/src/main/device-link/mirrorCacheStore.ts` — the list snapshot is
|
|
2548
|
+
written atomically to `session-list.json` beside per-(device, session) message pages, and
|
|
2549
|
+
its `coerceCachedSession` applies the same rule (`status !== 'active' && !== 'archived'`
|
|
2550
|
+
→ null), with `'status'` in the field whitelist.
|
|
2551
|
+
|
|
2552
|
+
So a cached list is a *copy of what this Host answered*, not a second source of truth.
|
|
2553
|
+
|
|
2554
|
+
This also explains why 删除 has no phone-side undo while 归档 does. When the status patch reaches
|
|
2555
|
+
the client store, `remoteSessionStore.applySessionPatch` drops the row from the shard for both
|
|
2556
|
+
statuses, and for `deleted` it additionally clears the message cache and the on-disk history
|
|
2557
|
+
(`clearSessionMessageCache`, `clearHistoryDisk`). An archived session can therefore be re-read
|
|
2558
|
+
and shown in the archive view, while a deleted one has nothing left to render — a client-side
|
|
2559
|
+
choice, not something the Host can serve around.
|
|
2560
|
+
|
|
2561
|
+
### The write that looked right and was not
|
|
2562
|
+
|
|
2563
|
+
The first implementation persisted with `scope.update({ sessionFlags })`, and the whole
|
|
2564
|
+
round trip passed in unit tests. On the live Host it did this:
|
|
2565
|
+
|
|
2566
|
+
```
|
|
2567
|
+
settings loaded: probe status = archived ← the flag did survive the restart, as designed
|
|
2568
|
+
restore ({status: 'active'}): reply status = active
|
|
2569
|
+
pin ({pinnedAt: …}): reply status = archived ← it came back
|
|
2570
|
+
settings file: { status: archived, pinnedAt: '2026-01-01T00:00:00.000Z' }
|
|
2571
|
+
```
|
|
2572
|
+
|
|
2573
|
+
`update` **deep-merges** its patch into the namespace's user section, so writing "no flags
|
|
2574
|
+
for this session" merged into nothing and left the archived flag on disk; the next
|
|
2575
|
+
settings commit re-seeded the store from that value and the session stayed hidden however
|
|
2576
|
+
often the user pressed 恢复. `replace` is the wholesale/removal path but would have to
|
|
2577
|
+
restate the entire section (freezing composition defaults into the user layer), so the
|
|
2578
|
+
write is now a single path-addressed op:
|
|
2579
|
+
|
|
2580
|
+
```js
|
|
2581
|
+
ctx.settings.mutate(settingsNamespace(SETTINGS_NAMESPACE),
|
|
2582
|
+
[{ op: 'set', path: ['sessionFlags'], value: sessionFlags }])
|
|
2583
|
+
```
|
|
2584
|
+
|
|
2585
|
+
`test/dsh-plugin.test.js` drives that writer over a merge-faithful section and asserts the
|
|
2586
|
+
removal lands, so the deep-merge trap cannot come back silently. Re-measured live:
|
|
2587
|
+
`archived → active → pin(active) → unpin → deleted → active`, ending at `sessionFlags: {}`
|
|
2588
|
+
with no residue.
|
|
2589
|
+
|
|
2590
|
+
The same live pass produced the acceptance checks — a reply-row assertion for each action,
|
|
2591
|
+
the pushed frame with `watchers=3`, the list and live-list agreement, and a 恢复 that
|
|
2592
|
+
leaves the probe exactly as it was found:
|
|
2593
|
+
|
|
2594
|
+
```
|
|
2595
|
+
PASS 归档 answers with the row the controller will store — status=archived
|
|
2596
|
+
PASS the archive is pushed to the controllers holding the row — watchers=3 said={"patch":{"status":"archived",…
|
|
2597
|
+
PASS an archived session is served as archived, not as active
|
|
2598
|
+
PASS an archived session cannot light the running badge
|
|
2599
|
+
PASS 删除 answers with the row, and the list agrees — reply=deleted list=deleted
|
|
2600
|
+
PASS 置顶 answers with the pin the controller caches — pinnedAt=2026-01-01T00:00:00.000Z
|
|
2601
|
+
PASS 取消置顶 takes the pin back out of the row — pinnedAt=undefined
|
|
2602
|
+
PASS 恢复 leaves the session exactly as it was found — status=active pinnedAt=undefined
|
|
2603
|
+
channel coverage: 48/48 served channels exercised
|
|
2604
|
+
push coverage: 9/9 push channels sent
|
|
2605
|
+
95/95 checks passed
|
|
2606
|
+
```
|
|
2607
|
+
|
|
2608
|
+
## 收尾:媒体取件补齐 + 历史视图通道
|
|
2609
|
+
|
|
2610
|
+
这一节记录最后补齐的两块能力,以及它们各自解决的手机端现象。
|
|
2611
|
+
|
|
2612
|
+
### 为什么手机打开会话只显示一两条、重进还会丢历史
|
|
2613
|
+
|
|
2614
|
+
两个原因都不在被控端,但只有一个能被被控端修好:
|
|
2615
|
+
|
|
2616
|
+
1. **页是客户端自己定的 20 行。** `apps/mobile/src/session/messagePaging.ts` 的
|
|
2617
|
+
`MESSAGE_FETCH_PAGE_SIZE = 20`,打开会话的请求永远走 `[20,10,5,1]`
|
|
2618
|
+
(`apps/mobile/app/sessions/[sessionId].tsx`)。而这里的一行 = 一个内容块,所以
|
|
2619
|
+
**一个 turn 就能占满一页**:实测「画一只小猫」最新一页 21 行,正好只有最后一个 turn,
|
|
2620
|
+
而整段是 81 行。被控端能做的是把**最新一页**加宽(`NEWEST_WINDOW_ROWS = 200` +
|
|
2621
|
+
256 KiB 文本预算),短会话因此一次到齐;带游标的翻页仍严格按 `limit` 答。
|
|
2622
|
+
2. **客户端的连续性规则。** `setLatestMessageWindow` 在满页时丢弃「无法证明相接」的旧缓存段
|
|
2623
|
+
(`remoteSessionStore.ts` 的 `moreBeyondWindow` 分支),已证连续的段记在
|
|
2624
|
+
`sessionWindowCoverage`,而会话回收会清掉它。于是每次重进都要重新拉。
|
|
2625
|
+
参考被控端靠 `local-db:messages:view` 规避:它返回的是**带自己游标的 work 窗口**,
|
|
2626
|
+
客户端不必再从 20 行页里猜连续性。
|
|
2627
|
+
|
|
2628
|
+
### `local-db:messages:view` / `work-details` / `view-intent`
|
|
2629
|
+
|
|
2630
|
+
契约取自 `@cindy/maker-shared/message-window`、桌面参考
|
|
2631
|
+
`apps/desktop/src/main/localDb/ipc/historyViewReader.ts` 与手机侧 transport:
|
|
2632
|
+
|
|
2633
|
+
```
|
|
2634
|
+
local-db:messages:view (sessionId, { before? }) → { version:1, items, nextCursor, hasMore }
|
|
2635
|
+
local-db:messages:work-details (sessionId, ref, { after? }) → { version:1, messages, nextCursor, hasMore }
|
|
2636
|
+
local-db:messages:view-intent (sessionId, refs) → true
|
|
2637
|
+
```
|
|
2638
|
+
|
|
2639
|
+
- 一页 `items` **时间正序**,`nextCursor` 是该页**最旧**那条 id,回传为 `before` 继续往前翻;
|
|
2640
|
+
`hasMore` 决定「加载更早」入口。页预算 20 项 / 256 KiB(详情页同为 256 KiB)。
|
|
2641
|
+
- 本 Host 的分组单元是**可读的行 + 折叠的活动段**,不是「一个 turn 一项」:
|
|
2642
|
+
`user` 提示与 `assistant` 答复保持为顶层 `messages` 项(人能读的必须可见),
|
|
2643
|
+
两者之间的 thinking / tool_use / tool_result 折叠成 `work` 项,并按
|
|
2644
|
+
`WORK_ITEM_MAX_ROWS = 40` 行切段,让每次展开约等于一页详情。
|
|
2645
|
+
**第一版按 turn 分组是错的,而且错得很显眼**:本会话里那个 329 行 / 138 工具的 turn
|
|
2646
|
+
变成了一个折叠项,整页 20 项全是折叠、**零行可读**——用户看到的就是
|
|
2647
|
+
「进入会话,只显示一号折叠的会话,然后每次展开十来分钟,又要继续展开,完全看不到最近记录」。
|
|
2648
|
+
换成分组后,同一会话读 1357 行得到 128 项(58 条可读 prose + 70 个活动段,
|
|
2649
|
+
最大 40 行 / 平均 18.6 行),最新一页就是最近读到的那些对话。
|
|
2650
|
+
- 超出扫描预算(`HISTORY_SCAN_MAX_ROWS = 20000`)或没有会话 API 时回
|
|
2651
|
+
`UNSUPPORTED_CAPABILITY` / `NOT_AVAILABLE`——客户端把这两种都识别为「这里没有视图」,
|
|
2652
|
+
自动回退到原始 20 行窗口,也就是它今天的行为。
|
|
2653
|
+
- **`link-accept` 必须广告 `history-view-v1`**:手机端
|
|
2654
|
+
`device-link/historyViewCapability.ts` 在读不到该能力时**根本不会调用**这三个通道
|
|
2655
|
+
(`HOST_CAPABILITIES` 在 `src/host-authorization.js`)。服务端实现了通道却没广告能力,
|
|
2656
|
+
等于没实现。
|
|
2657
|
+
|
|
2658
|
+
### `device-link:media:fetch` 的缩图与内联
|
|
2659
|
+
|
|
2660
|
+
对齐参考被控端 `apps/desktop/src/main/device-link/mediaFetch.ts` 的三层答案:
|
|
2661
|
+
|
|
2662
|
+
1. **降采样**(`thumbnail: true`,且是 png/jpeg/webp、输入 ≤ 48 MiB):用 **harness 自带的
|
|
2663
|
+
sharp**(`dsh-attachment-local` 的依赖;本插件从 `process.argv[1]`——即 `dsh web` 入口——
|
|
2664
|
+
解析它,避免自带第二套原生图像栈)+ EXIF 转正 + 最长边 1024 + webp q80 + 5 s 软超时;
|
|
2665
|
+
产物 ≤ 700 KiB 时以 `{ ossKey:'', mimeType:'image/webp', size, inlineBase64 }` 内联回包
|
|
2666
|
+
(省掉 upload → presign → download 整往返)。实测 `kitten.png` 2 454 031 B → 102 350 B / 114 ms。
|
|
2667
|
+
2. **小原图内联**:缩不动(无 codec、gif 这类不该重编码的格式、渲染失败或产物超限)但原图
|
|
2668
|
+
≤ 512 KiB 且是 `image/*` 时,直接内联原图字节——客户端只接受 `image/*` 的内联包,
|
|
2669
|
+
非图片仍走 key。
|
|
2670
|
+
3. **暂存 + 去重缓存**:其余情况上传 OSS 返回 key;同一文件(path|size|mtime 为键)30 分钟内
|
|
2671
|
+
复用同一个对象,`skipCache: true` 时绕过(对象被删而文件没变的情形)。
|
|
2672
|
+
|
|
2673
|
+
三层任一失败都只是「退回下一层」,不会让取件失败:契约一直要求控制器接受 `ossKey` 形式。
|
|
2674
|
+
|
|
2675
|
+
### 帧预算(超帧 = 丢历史)
|
|
2676
|
+
|
|
2677
|
+
relay 对超过 `MAX_FRAME_BYTES`(2 MiB)的帧是**拒收**,被控端抛 `PAYLOAD_TOO_LARGE` 后若不接住,
|
|
2678
|
+
控制端只能干等 30 s 超时——手机端表现为「历史全消失、加载更早也没响应」。
|
|
2679
|
+
`src/host-frame-budget.js` 因此按参考实现做三段降级(内容截断 128 KiB/工具输出 8 KiB →
|
|
2680
|
+
丢内联图片 → 整行占位 → 按消息边界裁行并打 `agentMeta.remoteRowsTrimmed` /
|
|
2681
|
+
`remoteOriginalRowCount`),其余 channel 回结构化 `PAYLOAD_TOO_LARGE`,`send()` 另有超帧硬拒绝
|
|
2682
|
+
并计数(`/status` 的 `diagnostics.frameBudget`)。
|
|
2683
|
+
|
|
2684
|
+
### claw 的计划为什么在手机上变成一张 ToDo 卡
|
|
2685
|
+
|
|
2686
|
+
claw 工作流的任务清单**一直在同步进 DSH**:适配器每次结算 mutation 都会
|
|
2687
|
+
`session.append('todo/write', { todos })`(`@veewo/dsh-claw-kit` 的 `index.js`,注释写的是
|
|
2688
|
+
「Drive the DSH-native todo dock … so the UI shows the plan's step progress bar」),实测本会话日志里
|
|
2689
|
+
有 **159 条** `todo/write` 快照,内容就是计划里的任务与状态。所以桌面端的 todo dock 是有的。
|
|
2690
|
+
|
|
2691
|
+
手机看不到,是因为两侧渲染的**来源不同**:
|
|
2692
|
+
|
|
2693
|
+
- DSH 把 `todo/write` 声明为 **log-only UI state**(「Log-only UI state; never derived
|
|
2694
|
+
history」),桌面端从 `todos` **投影**渲染 dock;
|
|
2695
|
+
- 手机没有投影通道,它的计划卡只从转录里的 **`TodoWrite` 工具行**渲染
|
|
2696
|
+
(`extractTodosFromMessage` / `agentPlanSource`,`@cindy/maker-shared/message-render`)。
|
|
2697
|
+
|
|
2698
|
+
而转录折叠按定义丢掉非 surface 事件,所以 `todo/write` 从来没变成行。
|
|
2699
|
+
|
|
2700
|
+
处置:`src/dsh-message-fold.js` 的 `entryForTodoWrite` 把**每一条非空**的 `todo/write` 快照
|
|
2701
|
+
翻译成一行 `todo_write` 工具调用(时间戳取快照自身,位置就在它发生的地方),名字映射成
|
|
2702
|
+
`TodoWrite` 后即选中手机的计划卡;空快照刻意跳过——那是适配器清空 dock 的方式,
|
|
2703
|
+
「没有卡」不等于「一张空卡」。同一翻译在**实时路径**上也生效,所以计划推进时卡片会随更新。
|
|
2704
|
+
|
|
2705
|
+
实测(`tools/probe-todo-rows.mjs`):本会话 159 条快照 → 156 行手机可见的 `TodoWrite` 行
|
|
2706
|
+
(3 条空快照被跳过),行内就是 `[completed] …` / `[in_progress] …` / `[pending] …` 的真实状态。
|
|
2707
|
+
|
|
2708
|
+
## 待发项的位置是预测,而预测要用“用户敲下的时刻”
|
|
2709
|
+
|
|
2710
|
+
两条真机报告,看起来是两件事,实际是同一个判断:
|
|
2711
|
+
|
|
2712
|
+
> 插入之后顺序会变,它会插入到我前面说的两行话前面。
|
|
2713
|
+
>
|
|
2714
|
+
> 你一直在我的对话之上在工作⋯最好是我发完对话之后无论如何你都把正在工作这个信息调到最后。
|
|
2715
|
+
|
|
2716
|
+
先把责任分清楚:**“插入跑到前面”有一半是真实的投递顺序**。实测那一轮,用户的两条消息在
|
|
2717
|
+
10:45:11 / 10:45:24 排队等下一个 turn,第三条是一次**插话**(steer,它跳过队列),10:46:11
|
|
2718
|
+
就变成 durable——转录里它本来就在前两条之前。Host 加剧了这个现象:待发项被整体追加到页尾,
|
|
2719
|
+
于是用户**更早**敲的两行被排到了**更晚**敲的那行底下。
|
|
2720
|
+
|
|
2721
|
+
处置是给三种形状各一个时刻,再按它插入(`src/cindy-channels.js`):
|
|
2722
|
+
|
|
2723
|
+
```js
|
|
2724
|
+
occurredAtMs(entry) // work 段按 summary.endedAtMs;messages 项按最后一条;裸行按 createdAt
|
|
2725
|
+
mergePendingByTime(items, pending, { newestFirst })
|
|
2726
|
+
```
|
|
2727
|
+
|
|
2728
|
+
`local-db:messages:list` 是新到旧(`newestFirst: true`),`messages:view` 是旧到新——同一个函数
|
|
2729
|
+
两个方向,因为两个页面用的是同一份“用户敲下的顺序”。
|
|
2730
|
+
|
|
2731
|
+
**但钉底只作用于升序的那个方向。** `mergePendingByTime` 里的 `runsNow` 带 `!newestFirst` 前提
|
|
2732
|
+
(`src/cindy-channels.js:142`):`messages:view` 这种旧到新的页面里,运行中的卡片被钉在最后;
|
|
2733
|
+
新到旧的 `local-db:messages:list` 不重排,运行中的卡片本来就在最前——那份列表没有「最后一行」
|
|
2734
|
+
可言,`test/pending-order.test.js:80-84` 明确断言它不会因为这条规则被重排。
|
|
2735
|
+
|
|
2736
|
+
**这是一个预测,而且它不总是对。** 排队项要到下一个 turn 边界才投递,所以它仍可能落到那条
|
|
2737
|
+
超过它的插话之后;行会移动一次,移动的时机是它变成 durable 的那一刻。投递顺序是转录的,
|
|
2738
|
+
而在什么都还没投递之前,用户看的就是**敲下的顺序**。
|
|
2739
|
+
|
|
2740
|
+
正在工作的卡片单独处理:升序页面里最后一项是 `isStreaming === true` 的 `work` 段时,排序在它之后的
|
|
2741
|
+
(包括刚接受、还没落盘的提示)统统挪到它上面——运行中的卡片永远是最后一行。理由是「正在工作」
|
|
2742
|
+
是现在时,它属于用户说过的所有话**之下**,否则刚发出去的那句话会被顶到自己对话的中间。
|
|
2743
|
+
|
|
2744
|
+
`test/pending-order.test.js` 钉住五条,包括「the running work card is always last, under what the
|
|
2745
|
+
user just said」和「an entry with no readable time is treated as oldest, never dropped」——没有可读
|
|
2746
|
+
时间的行按最旧处理,宁可顺序不完美也不能把用户的话弄丢。
|
|
2747
|
+
|
|
2748
|
+
## 被 DSH 拒绝的插话是竞态,重试一次,而不是让用户的话买单
|
|
2749
|
+
|
|
2750
|
+
手机上报的是 `current turn no longer accepts steering`。这是**两级**处理,缺一不可:
|
|
2751
|
+
|
|
2752
|
+
1. **模式跟随会话。** `wantsSteer = channel === 'maker:input:steer' && capabilities.isSessionRunning?.(sessionId) === true`
|
|
2753
|
+
——turn 已经结束的插话本来就该是一条普通提示,而不是一次注定被拒的插话
|
|
2754
|
+
(`test/queue-commands.test.js:153`「an insert that arrives after the turn ended is a normal
|
|
2755
|
+
prompt, not a refusal」)。
|
|
2756
|
+
2. **仍然被拒时重试一次。** 只有拒绝文本与插话相关(`/steer/i`)才重试,且重试走 `queue`,
|
|
2757
|
+
并记住这**不是**一条 steering 行,免得投影谎称有一条。其它任何失败原样传给控制器
|
|
2758
|
+
(`:203`「a failure that is not about steering still travels」)。
|
|
2759
|
+
|
|
2760
|
+
理由是第 1 步先天不够:我们检查会话状态、和 DSH 做决定之间有 gap,这是竞态。它不能由用户的话
|
|
2761
|
+
来承担后果。修复前的代价是:插话在手机上变成一个错误字符串,而用户正看着的那条消息从对话里
|
|
2762
|
+
消失,直到他再发一次。
|
|
2763
|
+
|
|
2764
|
+
### 同一类问题:没人重试的帧,在订阅时补上
|
|
2765
|
+
|
|
2766
|
+
实测 App 从后台回来会做一次 unsubscribe → resubscribe 的抖动(一个会话里 26 次 subscribe /
|
|
2767
|
+
13 次 unsubscribe),而一次发送正好落在抖动里:承载「已接受」和「视图已变」的两帧被中继丢掉,
|
|
2768
|
+
且无人重试——气泡一直转、转录一直陈旧,直到重启 App。
|
|
2769
|
+
|
|
2770
|
+
订阅是客户端**一定在听**的唯一时刻,所以 Host 在那里补它没能送到的东西:权威的 input
|
|
2771
|
+
projection(让转圈的气泡退场)+ 一次视图失效(让它重新读)。两帧都幂等、各一帧代价,
|
|
2772
|
+
并且只在真的重订阅时发生(`src/cindy-channels.js` 的 `device-link:subscribe` 分支)。
|
|
2773
|
+
|
|
2774
|
+
## presence 会说谎,订阅表是我们自己的账本
|
|
2775
|
+
|
|
2776
|
+
`markDeviceUnreachable` 只往 `offlineDevices` 加一个 id,**不删订阅**。第一版删了,理由是
|
|
2777
|
+
「中继到不了的订阅不算订阅」,实测两次都证明这个理由反过来:
|
|
2778
|
+
|
|
2779
|
+
- presence 是**只发 delta 的意见**,它没有义务纠正自己:一个陈旧的 `online:false` 之后不会
|
|
2780
|
+
跟着一条 `online:true`,除非设备真的重连。09:48:04 它把用户正看着的手机报成离线,订阅被删,
|
|
2781
|
+
接下来三分钟就是转圈——正是删订阅想修的那个现象。
|
|
2782
|
+
- 修复路径本身是死代码:`markDeviceReachable` 靠入站帧触发,而客户端一旦认为对端不可用就会
|
|
2783
|
+
**停止发帧**(它撤掉自己的对端恢复,并把该设备从计划里过滤掉)。等于 Host 销毁了自己关于
|
|
2784
|
+
「谁要求被推送」的唯一记录,然后等一台它刚宣布沉默的设备开口。
|
|
2785
|
+
|
|
2786
|
+
所以订阅表是这个进程自己关于**谁要求被推送**的记录,可达性是**另一个事实**,它只决定一次推送
|
|
2787
|
+
算不算「已送达」,从不决定记录是否存在。`push` 是没有 ack 的 routed fire-and-forget:给不可达
|
|
2788
|
+
设备推一帧,代价是中继丢掉一帧;删掉记录,代价是整个会话。
|
|
2789
|
+
|
|
2790
|
+
这次推翻留下一处残骸:`watchedListDevices` 现在**只有写没有读**(它当年是为「把 `sessions`
|
|
2791
|
+
主题还回去」而记的,而现在已经没有东西会把它拿走)。
|
|
2792
|
+
|
|
2793
|
+
### 于是补一个脉冲,触发点是“设备自己开口”
|
|
2794
|
+
|
|
2795
|
+
控制器从后台回来**不会**重新订阅(实测:连续 20 次 invoke 里没有一次 `device-link:subscribe`),
|
|
2796
|
+
但它会一直问(`messages:view` / `view-intent` / `input:enqueue`,每秒级)。所以「这台设备提到了
|
|
2797
|
+
这个会话」是唯一一定会到的恢复触发点:
|
|
2798
|
+
|
|
2799
|
+
```js
|
|
2800
|
+
pulseRepair(sessionId) // REPAIR_PULSE_MS = 30_000,每会话最多一次
|
|
2801
|
+
// 补的正是丢掉的 push 会带的两帧:input projection + 视图失效
|
|
2802
|
+
```
|
|
2803
|
+
|
|
2804
|
+
没人看这个会话时它什么都不做。**它补的是丢掉的 push 会带的那两帧——input projection 与视图失效;
|
|
2805
|
+
turn 状态不在这里**:那是 `markDeviceReachable` 的活,它在**任何**一条入站帧到达时撤销不可达判定
|
|
2806
|
+
并补发该设备在看的每个会话的 turn 状态(且只在设备确实曾被打上离线标记时才有事可做,
|
|
2807
|
+
`src/host.js:700`)。
|
|
2808
|
+
|
|
2809
|
+
### 边界:Host 收到了、手机却没更新(这一条不是我们的)
|
|
2810
|
+
|
|
2811
|
+
鸿蒙上跑安卓版 Cindy 客户端,App 从后台回到前台后,这条 device-link 连接的**读方向**会失效,
|
|
2812
|
+
而写方向仍然正常:客户端发出的请求能到达 Host,Host 的答复与所有推送都到不了客户端。表现是
|
|
2813
|
+
发送后一直转圈、看不到工作进度、顶部没有重连提示、设备仍显示「已连接」,**退出会话再进或重启
|
|
2814
|
+
App 后一切恢复**。
|
|
2815
|
+
|
|
2816
|
+
归属是操作系统的应用恢复行为,所以 Host 不再为它投入:
|
|
2817
|
+
|
|
2818
|
+
- **官方客户端同样复现**(用户实测),排除某个客户端版本的分支问题;
|
|
2819
|
+
- **主机侧一切正常**:手机入站 `input:enqueue ok=True`(并按 `turn/start` 落盘)、`watchers` 里
|
|
2820
|
+
有这台手机、两帧也都按 30 s 规则补推了;
|
|
2821
|
+
- **协议没有投递确认**:push 是 routed fire-and-forget,Host 对「客户端是否收到」零观测,所以
|
|
2822
|
+
这个方向在 Host 侧既看不见也无法补救——任何「我们发出去了」都不能当成「它收到了」。
|
|
2823
|
+
|
|
2824
|
+
Host 侧唯一能看到的签名是**客户端在反复重发同一个读**:
|
|
2825
|
+
|
|
2826
|
+
```
|
|
2827
|
+
12:18:03 / :04 / :08 / :09 messages:view + view-intent
|
|
2828
|
+
12:20:20 / :21 / :28 同上
|
|
2829
|
+
12:29:09 / :10 / :18 同上
|
|
2830
|
+
```
|
|
2831
|
+
|
|
2832
|
+
如果它收到了答复,就不会一秒问两次;这也解释了它为什么自认健康——它只按「发送是否成功」判断。
|
|
2833
|
+
|
|
2834
|
+
**这是真机观测,不是仓库内可复现的因果。** 上表与「官方客户端同样复现」都来自一次真人实测,
|
|
2835
|
+
留存的记录在 `.claw/truth/dsh-cindy-host-mobile-resume-limitation.md`;`src/` 与 `test/` 里没有
|
|
2836
|
+
对应的日志或测试,所以这一节的证据强度与其它节不同——不要把它当成可以从代码验出来的结论。
|
|
2837
|
+
|
|
2838
|
+
现场处理:**等约 1 分钟**(入站有时会自行恢复,durable 行随后送达);仍不出现就**退出会话再进**,
|
|
2839
|
+
强制重建连接。复发时先做三件事,省掉重新取证的时间:
|
|
2840
|
+
|
|
2841
|
+
1. `/status` 的 `subscriptions.sessions` 里手机还在不在(在 → 不是订阅问题);
|
|
2842
|
+
2. `recentInvokes` 里它是不是在**重复发同一个读**(是 → 答复没到它,方向性问题);
|
|
2843
|
+
3. `pushTotals` 是否还在自增(在 → Host 确实在推,问题在通道而不在 Host)。**不要**用
|
|
2844
|
+
`recentPushes[].watchers` 判断这一条:那个数是「算不算已送达」,中继把手机标成离线时它就是 0,
|
|
2845
|
+
而这恰好是这种故障的常态。
|
|
2846
|
+
|
|
2847
|
+
## harness 的后台任务通知不是用户说的话
|
|
2848
|
+
|
|
2849
|
+
DSH 的后台任务通知(`background job … finished` 这类)在手机上渲染成了用户自己发的消息。
|
|
2850
|
+
|
|
2851
|
+
根因和之前两次一样:这些行是**带 `source` 的消息**,而转录折叠只按 role 判断
|
|
2852
|
+
(`role === 'user' ? 'user' : 'assistant'`),于是生产者的上下文被当成了人的话。同一根因先前
|
|
2853
|
+
已经以两种面貌出现过——系统提示被渲染成 assistant 回复、`Current runtime context…` 快照每轮
|
|
2854
|
+
渲染成一条用户气泡。当时的修法是丢弃 `source.kind === 'plugin'`,但那是否决名单,下一种注入
|
|
2855
|
+
就绕过去了:skills 目录用的是 `skill-catalog`。
|
|
2856
|
+
|
|
2857
|
+
判据因此反转成**允许名单**:
|
|
2858
|
+
|
|
2859
|
+
```js
|
|
2860
|
+
const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
|
|
2861
|
+
if (sourceKind !== null && !RENDERABLE_SOURCE_KINDS.has(sourceKind)) return rows;
|
|
2862
|
+
```
|
|
2863
|
+
|
|
2864
|
+
不在名单里的(`plugin`、`skill-catalog`,以及将来任何一种)默认是生产者提供的上下文,不渲染;
|
|
2865
|
+
完全没有 `source` 的仍然渲染——DSH 要求每条消息都带 source,缺失说明这是一个本 Host 认不出的
|
|
2866
|
+
形状,而藏起用户自己的话是更坏的失败。
|
|
2867
|
+
|
|
2868
|
+
被藏掉的行必须在**某处**可观测,否则「Host 悄悄丢了一条消息」和「本来就没有可显示的」从桌面端
|
|
2869
|
+
看是同一个样子。所以判断单独导出(`isHarnessNotice(event)`,`src/dsh-message-fold.js`),计数进
|
|
2870
|
+
`diagnostics.suppressedNotices`。`test/dsh-message-fold.test.js:308` 钉住两侧:
|
|
2871
|
+
「a harness notice is not conversation, and a person still is」。
|
|
2872
|
+
|
|
2873
|
+
**一条约束值得单独写下来:隐藏只发生在投影/渲染层。** 工具本身、通知的产生方式、以及 agent 是否
|
|
2874
|
+
使用后台任务都不受影响——通知是给 agent 和桌面端看的,只是不该冒充用户说过的话。
|
|
2875
|
+
|
|
2876
|
+
## 真机复测清单(累计)
|
|
2877
|
+
|
|
2878
|
+
重启 `dsh web` 之后(新通道与新能力都只在重启后生效):
|
|
2879
|
+
|
|
2880
|
+
1. 打开「画一只小猫」——整段短对话应一次显示,不需要再点「加载更早」。
|
|
2881
|
+
2. 打开一个长会话并往上翻几页,退出再进入——已翻出来的历史应当保留
|
|
2882
|
+
(这条依赖手机端确实走了 `messages:view`:可在 `/status` 的 `invokeTotals` 里看到
|
|
2883
|
+
`local-db:messages:view` 出现过)。
|
|
2884
|
+
3. 发一张照片,或让 agent 画一张图并打开——应显示图片(内联缩图路径),不再出现「取图失败」。
|
|
2885
|
+
4. 「加载更早」应当即时返回(transcript 缓存 + 50 ms 级翻页)。
|
|
2886
|
+
5. **转圈不再需要手动重进**:让 agent 答完一句话,然后息屏/切走让中继把手机标成离线,
|
|
2887
|
+
再回到会话——答案应当直接出现,思考中应当自己结束。可在 `/status` 对照两件事:
|
|
2888
|
+
`subscriptions.sessions` 里**仍然有这台手机**(订阅没被删),以及 `pushTotals` 仍在自增
|
|
2889
|
+
(推送照发)。注意 **`recentPushes[].watchers` 在手机离线期间会降到 0**——那个计数是
|
|
2890
|
+
「算不算已送达」,它变 0 正是这条策略的证据,不代表 Host 没推。
|
|
2891
|
+
6. **插话**(agent 正在回答时发一句):应当作为插话进入当前 turn;如果 DSH 已经过了可插话的
|
|
2892
|
+
点,它应当变成下一条普通消息,而不是报 `current turn no longer accepts steering`,更不应
|
|
2893
|
+
从对话里消失。
|
|
2894
|
+
7. **顺序**:agent 忙时连发两条,再插一句——三句应当按你敲下的顺序显示;正在工作的卡片应当
|
|
2895
|
+
始终在所有用户消息**之下**(最后一行)。
|
|
2896
|
+
8. **后台任务通知**:让 agent 起一个后台任务并等它结束——通知不应当出现在对话里;
|
|
2897
|
+
`/status` 的 `diagnostics.suppressedNotices` 应当随之增加(不是静默消失)。
|
|
2898
|
+
9. **息屏/切走再回来仍收不到**(答案不出现、进度不动、没有重连提示)时,先按上一节的三步
|
|
2899
|
+
取证:`subscriptions.sessions` 有没有这台手机、`recentInvokes` 里它是否在重复发同一个读、
|
|
2900
|
+
`pushTotals` 是否在自增(不要用 `recentPushes[].watchers`,它会被 presence 清零)。三步都指向
|
|
2901
|
+
「通道」而不是「Host」时,这就是那条 OS 侧限制——等约 1 分钟,或退出会话再进。
|
|
2902
|
+
|