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.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. 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
+