dsh-cindy-host 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cindy invoke channel router.
|
|
3
|
+
*
|
|
4
|
+
* This is the Host's half of the DeviceLink tunnel: the phone sends
|
|
5
|
+
* `invoke { channel, args }` and gets one `invoke-result` back. The channel set
|
|
6
|
+
* is a contract — `Cindy/packages/device-link/src/allowlist.ts` — and this Host
|
|
7
|
+
* implements a deliberate subset of it (file-browser, the agent information
|
|
8
|
+
* flow, approvals, todo, goal).
|
|
9
|
+
*
|
|
10
|
+
* Everything outside that subset answers `CHANNEL_NOT_ALLOWED`, which is exactly
|
|
11
|
+
* what a Cindy controlled device answers for a channel it does not serve. That
|
|
12
|
+
* is a feature of the protocol: the controller treats it as "this device has no
|
|
13
|
+
* such capability" and degrades, rather than waiting for a reply that never
|
|
14
|
+
* comes. A channel we silently accepted and then failed would be worse — the
|
|
15
|
+
* phone would show a broken feature instead of an absent one.
|
|
16
|
+
*/
|
|
17
|
+
import { agentKindForModel, toCindyActiveSessions, toCindySessionList, toCindySessionListRow } from './cindy-session-row.js';
|
|
18
|
+
import { toAgentCapabilities, toCindyPermissionMode, toDshPermissionMode, toNewMakerDefaults, toPermissionOptions, toProviderList } from './host-models.js';
|
|
19
|
+
import { queueItemsFromInbox } from './host-input-queue.js';
|
|
20
|
+
import { toAgentSkills, toAgentCommands, toAtResources } from './host-palette.js';
|
|
21
|
+
import { toGoalStatusPayload } from './host-goals.js';
|
|
22
|
+
import { hostAttachmentsOf } from './host-attachments.js';
|
|
23
|
+
|
|
24
|
+
/** The phone's own unsubscribe channel, paired with subscribe. */
|
|
25
|
+
const SUBSCRIBE = 'device-link:subscribe';
|
|
26
|
+
const UNSUBSCRIBE = 'device-link:unsubscribe';
|
|
27
|
+
|
|
28
|
+
/** Topic carrying list-level changes; one of only two this Host serves. */
|
|
29
|
+
export const TOPIC_SESSIONS = 'sessions';
|
|
30
|
+
/** Topic carrying one session's live stream. */
|
|
31
|
+
export const TOPIC_SESSION_PREFIX = 'session:';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The controller's goal write commands.
|
|
35
|
+
*
|
|
36
|
+
* The status read is deliberately not here: it is answered from the projection
|
|
37
|
+
* stream and must never resume a session.
|
|
38
|
+
*/
|
|
39
|
+
export const GOAL_WRITE_CHANNELS = new Set([
|
|
40
|
+
'maker:goal:set',
|
|
41
|
+
'maker:goal:update',
|
|
42
|
+
'maker:goal:pause',
|
|
43
|
+
'maker:goal:resume',
|
|
44
|
+
'maker:goal:clear',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The controller's pending-queue commands.
|
|
49
|
+
*
|
|
50
|
+
* These are what the queue panel's buttons send — 插话 (promote), 取消 (remove),
|
|
51
|
+
* 编辑 (update), 停止 (stop). Without them the controller shows
|
|
52
|
+
* `CHANNEL_NOT_ALLOWED` and the user cannot clear a stuck queue, which blocks
|
|
53
|
+
* sending anything at all.
|
|
54
|
+
*/
|
|
55
|
+
export const QUEUE_COMMAND_CHANNELS = new Set([
|
|
56
|
+
'maker:input:update-text',
|
|
57
|
+
'maker:input:update-content',
|
|
58
|
+
'maker:input:remove',
|
|
59
|
+
'maker:input:stop',
|
|
60
|
+
'maker:input:clear-session',
|
|
61
|
+
'maker:input:set-expanded',
|
|
62
|
+
'maker:input:set-edit-lock',
|
|
63
|
+
'maker:input:set-interaction-lock',
|
|
64
|
+
'maker:input:resume',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** The controller's current `InputProjection` for one session. */
|
|
68
|
+
function projectionNow(capabilities, sessionId) {
|
|
69
|
+
const project = capabilities.inputProjection;
|
|
70
|
+
return typeof project === 'function'
|
|
71
|
+
? project(sessionId, null)
|
|
72
|
+
: { sessionId, pendingQueue: [], steeringQueueClientIds: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Answer a queue command, and tell the session's other watchers.
|
|
77
|
+
*
|
|
78
|
+
* The reply settles the controller that asked. A second screen watching the same
|
|
79
|
+
* session has no reason to ask again, so without this push it keeps rendering a
|
|
80
|
+
* row the user just deleted — the same shape of staleness the queue fold already
|
|
81
|
+
* caused once.
|
|
82
|
+
* @param request - the invoke being answered.
|
|
83
|
+
* @param capabilities - the per-request capabilities.
|
|
84
|
+
* @param sessionId - the session whose queue changed.
|
|
85
|
+
* @returns the reply frame.
|
|
86
|
+
*/
|
|
87
|
+
function answerQueueCommand(request, capabilities, sessionId) {
|
|
88
|
+
const push = capabilities?.pushInputProjection;
|
|
89
|
+
if (typeof push === 'function') push(sessionId);
|
|
90
|
+
return invokeResult(request, projectionNow(capabilities, sessionId));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Commit one queue mutation to DSH.
|
|
95
|
+
*
|
|
96
|
+
* `control.update` is **async** — it resolves the session's agent before asking DSH to change
|
|
97
|
+
* the inbox — so it reports a refusal by *rejecting*, not by throwing. A `try`/`catch` around
|
|
98
|
+
* a call that is not awaited therefore catches nothing and the rejection floats: measured, it
|
|
99
|
+
* reached DSH's fail-loud unhandled-rejection handler and ended the whole `dsh web` process
|
|
100
|
+
* (`fatal load failure: RemoteError: queued item is no longer pending`, thrown from
|
|
101
|
+
* `SessionCommandController.updateQueue`) while an acceptance probe edited a queue whose item
|
|
102
|
+
* DSH had already admitted. The same bug also reported every refused mutation as a success,
|
|
103
|
+
* because `null` (the "no failure" answer) was returned unconditionally.
|
|
104
|
+
*
|
|
105
|
+
* @returns null on success, or `{ code, message }` to report.
|
|
106
|
+
*/
|
|
107
|
+
async function commitQueueAction(control, sessionId, itemId, action) {
|
|
108
|
+
try {
|
|
109
|
+
await control.update({ sessionId, itemId, action });
|
|
110
|
+
return null;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// DSH's own code travels: `session/steer-unavailable` and
|
|
113
|
+
// `session/queue-item-not-found` describe the refusal precisely, and a
|
|
114
|
+
// generic THREW would hide the difference between "there is no such item"
|
|
115
|
+
// and "there is no turn to steer into".
|
|
116
|
+
const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'THREW';
|
|
117
|
+
return { code, message: String(error?.message ?? error) };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Commit one queue mutation and keep the controller's view honest either way.
|
|
123
|
+
*
|
|
124
|
+
* On **failure** the projection is still pushed. The controller promotes the row
|
|
125
|
+
* optimistically before asking, so a refusal it only learns about from the error
|
|
126
|
+
* leaves it showing a queued item as if it had been taken — and, for a steer,
|
|
127
|
+
* spinning on a turn that never started. The push is what tells it otherwise.
|
|
128
|
+
* @returns null on success, or `{ code, message }` to report.
|
|
129
|
+
*/
|
|
130
|
+
async function commitQueueActionAndReconcile(capabilities, sessionId, itemId, action) {
|
|
131
|
+
const failure = await commitQueueAction(capabilities.queueControl, sessionId, itemId, action);
|
|
132
|
+
if (failure !== null) {
|
|
133
|
+
const push = capabilities?.pushInputProjection;
|
|
134
|
+
if (typeof push === 'function') push(sessionId);
|
|
135
|
+
}
|
|
136
|
+
return failure;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** How long to wait for DSH's `MessageId` before answering NOT_FOUND. */
|
|
140
|
+
const QUEUE_ITEM_ID_WAIT_MS = 1500;
|
|
141
|
+
const QUEUE_ITEM_ID_POLL_MS = 50;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The durable id behind the controller's item id, waiting briefly if needed.
|
|
145
|
+
*
|
|
146
|
+
* `maker:input:enqueue` is answered from a row this Host synthesises, because
|
|
147
|
+
* DSH's own `queue` frame trails the commit. For that moment the item is
|
|
148
|
+
* addressable by the controller — it can see the row and press 取消 on it — but
|
|
149
|
+
* its durable `MessageId` is not yet known, and `updateQueue` is keyed on that id.
|
|
150
|
+
* Answering NOT_FOUND straight away made 取消 and 编辑 fail on a message the user
|
|
151
|
+
* had just sent, which reads as "the button is broken" rather than "try again in
|
|
152
|
+
* a moment". The frame is only milliseconds behind, so this waits for it.
|
|
153
|
+
*
|
|
154
|
+
* The wait is bounded and every iteration re-reads the fold, so a genuinely
|
|
155
|
+
* absent item still fails fast enough to keep the button responsive.
|
|
156
|
+
*
|
|
157
|
+
* @param capabilities - the per-request capabilities.
|
|
158
|
+
* @param sessionId - the session owning the item.
|
|
159
|
+
* @param controllerId - the `clientId` the controller addressed it by.
|
|
160
|
+
* @param waitMs - the bound, injectable so tests do not sleep.
|
|
161
|
+
* @returns the DSH `MessageId`, or null if it never appeared.
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveDshItemId(capabilities, sessionId, controllerId, waitMs = QUEUE_ITEM_ID_WAIT_MS) {
|
|
164
|
+
const mirror = capabilities?.queueMirror;
|
|
165
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
166
|
+
for (;;) {
|
|
167
|
+
const folded = typeof mirror?.dshItemId === 'function' ? mirror.dshItemId(sessionId, controllerId) : null;
|
|
168
|
+
if (folded !== null && folded !== undefined) return folded;
|
|
169
|
+
// The fold learns items only from pushed `queue` frames, and those are not
|
|
170
|
+
// guaranteed to arrive — so a fold-only answer reported NOT_FOUND for an item
|
|
171
|
+
// the Host itself had just accepted. The session's inbox is the authority.
|
|
172
|
+
const read = capabilities?.readSessionState;
|
|
173
|
+
if (typeof read === 'function') {
|
|
174
|
+
let state = null;
|
|
175
|
+
try {
|
|
176
|
+
state = await read(sessionId);
|
|
177
|
+
} catch {
|
|
178
|
+
state = null;
|
|
179
|
+
}
|
|
180
|
+
if (state !== null && state !== undefined) {
|
|
181
|
+
const wanted = String(controllerId);
|
|
182
|
+
const found = queueItemsFromInbox(state.inbox)
|
|
183
|
+
.find((entry) => entry.rpcId === wanted || entry.id === wanted);
|
|
184
|
+
if (found !== undefined) return found.id;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (Date.now() >= deadline) return null;
|
|
188
|
+
await new Promise((resolve) => { setTimeout(resolve, QUEUE_ITEM_ID_POLL_MS); });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Split the topics a controller asked for into the ones this Host serves.
|
|
194
|
+
*
|
|
195
|
+
* `session:<id>` used to be dropped here, which silently denied every live
|
|
196
|
+
* per-session push: the subscription succeeded, the reply looked right, and no
|
|
197
|
+
* stream ever arrived.
|
|
198
|
+
* @param value - the raw `topics` argument.
|
|
199
|
+
* @returns the accepted topics.
|
|
200
|
+
*/
|
|
201
|
+
export function acceptTopics(value) {
|
|
202
|
+
if (!Array.isArray(value)) return [];
|
|
203
|
+
return value.filter((topic) => typeof topic === 'string'
|
|
204
|
+
&& (topic === TOPIC_SESSIONS || (topic.startsWith(TOPIC_SESSION_PREFIX) && topic.length > TOPIC_SESSION_PREFIX.length)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Channels this round implements; everything else is refused by omission. */
|
|
208
|
+
export const SUPPORTED_CHANNELS = Object.freeze([
|
|
209
|
+
SUBSCRIBE,
|
|
210
|
+
UNSUBSCRIBE,
|
|
211
|
+
'local-db:sessions:list',
|
|
212
|
+
'local-db:sessions:get',
|
|
213
|
+
'local-db:messages:list',
|
|
214
|
+
'local-db:sessions:patch-meta',
|
|
215
|
+
'maker:create-session',
|
|
216
|
+
'maker:send',
|
|
217
|
+
'maker:input:enqueue',
|
|
218
|
+
'maker:input:steer',
|
|
219
|
+
'maker:list-active',
|
|
220
|
+
'maker:session-in-turn',
|
|
221
|
+
'maker:git-safety:get',
|
|
222
|
+
'maker:goal:get-status',
|
|
223
|
+
...GOAL_WRITE_CHANNELS,
|
|
224
|
+
...QUEUE_COMMAND_CHANNELS,
|
|
225
|
+
'maker:input:move',
|
|
226
|
+
'maker:set-model',
|
|
227
|
+
'maker:set-effort',
|
|
228
|
+
'fs:list-dir',
|
|
229
|
+
'fs:stat-path',
|
|
230
|
+
'text-file:read-preview',
|
|
231
|
+
'maker:get-pending-interactions',
|
|
232
|
+
'maker:regenerate-title',
|
|
233
|
+
'maker:get-context-usage',
|
|
234
|
+
'maker:resolve-interaction',
|
|
235
|
+
'maker:input:get-projection',
|
|
236
|
+
'maker:list-available-agents',
|
|
237
|
+
'maker:provider:list',
|
|
238
|
+
'maker:get-capabilities',
|
|
239
|
+
'maker:get-new-maker-defaults',
|
|
240
|
+
'maker:list-agent-skills',
|
|
241
|
+
'maker:list-agent-commands',
|
|
242
|
+
'maker:set-plan-mode',
|
|
243
|
+
'file-browser:remote-op',
|
|
244
|
+
'device-link:media:fetch',
|
|
245
|
+
'device-link:file-peer',
|
|
246
|
+
// The work-grouped history window. Gated on the controller's side by the
|
|
247
|
+
// `history-view-v1` capability this Host advertises in `link-accept`
|
|
248
|
+
// (`host-authorization.js`), so serving them is only half of turning them on.
|
|
249
|
+
'local-db:messages:view',
|
|
250
|
+
'local-db:messages:work-details',
|
|
251
|
+
'local-db:messages:view-intent',
|
|
252
|
+
'maker:set-permission-mode',
|
|
253
|
+
'maker:list-desktop-commands',
|
|
254
|
+
'maker:scan-at-resources',
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
/** Three Cindy wire aliases for the same DSH harness and capabilities. */
|
|
258
|
+
export const DSH_AGENT_KINDS = Object.freeze(['claude-code', 'codex', 'pi']);
|
|
259
|
+
|
|
260
|
+
/** Legacy native protocol alias; display fallback for unknown routes is Claude. */
|
|
261
|
+
export const DSH_AGENT_KIND = 'pi';
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What this Host can do for `pi`.
|
|
265
|
+
*
|
|
266
|
+
* The phone asks for capabilities once per agent kind whenever it opens a
|
|
267
|
+
* device, and a refused answer leaves it without the shape it validates
|
|
268
|
+
* against. The lists are empty because this Host exposes no model catalog yet —
|
|
269
|
+
* that is a missing feature, not a broken one, and it must not cost the session
|
|
270
|
+
* list.
|
|
271
|
+
*/
|
|
272
|
+
export const DSH_AGENT_CAPABILITIES = Object.freeze({
|
|
273
|
+
availableModels: [],
|
|
274
|
+
effortLevels: [],
|
|
275
|
+
permissionModes: [],
|
|
276
|
+
hasFastMode: false,
|
|
277
|
+
planModeSupported: false,
|
|
278
|
+
supportsSessionAgentSwitch: false,
|
|
279
|
+
supportsModelWindowSwitchGuard: false,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/** A relay result frame. `dst` is the requesting device, as the relay filled it. */
|
|
283
|
+
export function invokeResult(request, result) {
|
|
284
|
+
return { v: 1, kind: 'invoke-result', id: request.id, dst: request.src, payload: { ok: true, result } };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A refusal the controller knows how to read. */
|
|
288
|
+
export function invokeError(request, code = 'CHANNEL_NOT_ALLOWED', message = 'Channel is not available on DSH Host') {
|
|
289
|
+
return { v: 1, kind: 'invoke-result', id: request.id, dst: request.src, payload: { ok: false, error: { code, message } } };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The refusal codes a seam may name for an ordinary refusal.
|
|
294
|
+
*
|
|
295
|
+
* A seam knows *why* something it was asked for cannot be done — a model that offers
|
|
296
|
+
* no such effort, a path outside the workspace — and the controller reads those codes
|
|
297
|
+
* to choose its fallback. Without this, every one of them arrived as `THREW`, which
|
|
298
|
+
* the controller (rightly) treats as the Host crashing: the acceptance run reported
|
|
299
|
+
* `maker:set-effort restores effort — THREW: provider "openai-codex" model
|
|
300
|
+
* "gpt-5.6-sol" does not support reasoning effort "default"` for a request the Host
|
|
301
|
+
* had *correctly* declined.
|
|
302
|
+
*/
|
|
303
|
+
export const REFUSAL_CODES = new Set(['BAD_REQUEST', 'NOT_AVAILABLE', 'NOT_FOUND', 'FORBIDDEN', 'OVERSIZE', 'CHANNEL_NOT_ALLOWED', 'PAYLOAD_TOO_LARGE', 'INTERNAL']);
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* An error that carries the refusal the controller should read.
|
|
307
|
+
* @param code - one of {@link REFUSAL_CODES}.
|
|
308
|
+
* @param message - what the controller shows or logs.
|
|
309
|
+
* @returns the error to throw from a seam.
|
|
310
|
+
*/
|
|
311
|
+
export function refusalError(code, message) {
|
|
312
|
+
const error = new Error(message);
|
|
313
|
+
error.refusalCode = REFUSAL_CODES.has(code) ? code : 'INTERNAL';
|
|
314
|
+
return error;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The code to answer an error with: the refusal it names, or `THREW` for a genuine
|
|
319
|
+
* failure, which is the difference the controller's error handling keys on.
|
|
320
|
+
* @param error - whatever a seam threw.
|
|
321
|
+
* @returns a refusal code.
|
|
322
|
+
*/
|
|
323
|
+
export function refusalCodeOf(error) {
|
|
324
|
+
return typeof error?.refusalCode === 'string' ? error.refusalCode : 'THREW';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Pull the prompt text out of a send argument.
|
|
329
|
+
*
|
|
330
|
+
* The two send paths carry different shapes:
|
|
331
|
+
* - `maker:send` sends a bare string or `{ type: 'user', content }`, where
|
|
332
|
+
* `content` is the controller's persisted message — an object carrying
|
|
333
|
+
* `text`, or a plain string on older paths;
|
|
334
|
+
* - `maker:input:enqueue` / `:steer` send the controller's own
|
|
335
|
+
* `QueuedRemoteMessage`, whose `text` is top level.
|
|
336
|
+
*
|
|
337
|
+
* Anything else yields nothing, and the caller refuses rather than sending an
|
|
338
|
+
* empty prompt.
|
|
339
|
+
* @param message - the raw argument.
|
|
340
|
+
* @returns the text to send, or null when there is none.
|
|
341
|
+
*/
|
|
342
|
+
export function extractSendText(message) {
|
|
343
|
+
if (typeof message === 'string') return message.trim() === '' ? null : message;
|
|
344
|
+
if (message === null || typeof message !== 'object') return null;
|
|
345
|
+
// The queued-message shape, which is the composer's normal path.
|
|
346
|
+
if (typeof message.text === 'string') return message.text.trim() === '' ? null : message.text;
|
|
347
|
+
const content = message.content;
|
|
348
|
+
if (typeof content === 'string') return content.trim() === '' ? null : content;
|
|
349
|
+
if (content !== null && typeof content === 'object' && typeof content.text === 'string') {
|
|
350
|
+
return content.text.trim() === '' ? null : content.text;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The attachments this Host can actually serve, out of the ones the controller sent.
|
|
357
|
+
*
|
|
358
|
+
* Both forms are serveable: an absolute path on this machine, and an upload transit
|
|
359
|
+
* reference (`cindy-oss-attach://…`, or the legacy `xdt-oss-attach://…` the phones
|
|
360
|
+
* build) whose bytes this Host fetches with its own account credential. A reference is
|
|
361
|
+
* fetched, never handed to a filesystem as if it were a path; anything else with a
|
|
362
|
+
* scheme is left behind, and the send paths log what they carried.
|
|
363
|
+
* @param message - the controller's send argument.
|
|
364
|
+
* @returns the serveable attachments.
|
|
365
|
+
*/
|
|
366
|
+
export function extractSendAttachments(message) {
|
|
367
|
+
return hostAttachmentsOf(message);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Build the router.
|
|
373
|
+
* @param options - the DSH read capabilities, the per-connection subscriber set,
|
|
374
|
+
* and a reader for this Host's own relay identity.
|
|
375
|
+
* @returns a function turning one invoke request into one result frame.
|
|
376
|
+
*/
|
|
377
|
+
export function createChannelRouter({
|
|
378
|
+
listSessions,
|
|
379
|
+
getSession,
|
|
380
|
+
listSessionStates,
|
|
381
|
+
readMessages,
|
|
382
|
+
createSession,
|
|
383
|
+
sendMessage,
|
|
384
|
+
resolveCapabilities,
|
|
385
|
+
subscribers,
|
|
386
|
+
onSubscribe,
|
|
387
|
+
onUnsubscribe,
|
|
388
|
+
now = () => new Date(),
|
|
389
|
+
getDevice,
|
|
390
|
+
}) {
|
|
391
|
+
/**
|
|
392
|
+
* Turn states for the two polled channels, without paying for a row fold.
|
|
393
|
+
*
|
|
394
|
+
* A router built without the cheap read (tests, and any caller that only has the full
|
|
395
|
+
* listing) falls back to it rather than failing: the difference is cost, not answer.
|
|
396
|
+
*/
|
|
397
|
+
const listStates = typeof listSessionStates === 'function' ? listSessionStates : listSessions;
|
|
398
|
+
/**
|
|
399
|
+
* Resolve the Host's write/read capabilities *per request*.
|
|
400
|
+
*
|
|
401
|
+
* They arrive from another plugin and can land after this router is built, so
|
|
402
|
+
* capturing them at construction would freeze an empty set — which shows up as
|
|
403
|
+
* `NOT_AVAILABLE` on a Host that is in fact perfectly able to serve the call.
|
|
404
|
+
*/
|
|
405
|
+
const capabilitiesNow = typeof resolveCapabilities === 'function'
|
|
406
|
+
? resolveCapabilities
|
|
407
|
+
: () => ({ readMessages, createSession, sendMessage });
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The model every session falls back to, as of the last capability read.
|
|
411
|
+
*
|
|
412
|
+
* The controller asks for capabilities when it opens a device and again when a
|
|
413
|
+
* provider revision changes, so this is warm by the time it lists sessions —
|
|
414
|
+
* without the list call itself paying for a catalog read.
|
|
415
|
+
*/
|
|
416
|
+
let catalogDefaultModel = null;
|
|
417
|
+
let agentCatalog = null;
|
|
418
|
+
|
|
419
|
+
/** This Host's relay identity, as the phone's device filter needs it. */
|
|
420
|
+
function deviceIdentity() {
|
|
421
|
+
if (typeof getDevice !== 'function') return undefined;
|
|
422
|
+
try {
|
|
423
|
+
return getDevice();
|
|
424
|
+
} catch {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Read the session rows the phone renders.
|
|
431
|
+
*
|
|
432
|
+
* `local-db:sessions:list` is also the phone's device-responsiveness probe, so
|
|
433
|
+
* it has to stay a single cheap read — which is why the row is folded from the
|
|
434
|
+
* source's one listing rather than from a per-session fetch.
|
|
435
|
+
*
|
|
436
|
+
* **And it may not block on that read at all.** Measured on a live Host (2026-09-18,
|
|
437
|
+
* 19:08–19:15): eighteen `invoke:local-db:sessions:list` calls aborted with
|
|
438
|
+
* `TimeoutError` in seven minutes while a build saturated the machine — the abort is at
|
|
439
|
+
* the **invoke** level (list read + title folding + row building together overran the
|
|
440
|
+
* deadline), so the source's own stale-serve guard never saw it, and the phone read a
|
|
441
|
+
* failed list as "the device is offline". The list a controller renders is satisfied by a
|
|
442
|
+
* few seconds' staleness, so the rows are cached and the read happens behind the answer:
|
|
443
|
+
* a warm cache answers instantly, and a refresh that fails leaves the previous page
|
|
444
|
+
* standing rather than turning a slow machine into a disconnected phone.
|
|
445
|
+
*/
|
|
446
|
+
let rowsCache = null;
|
|
447
|
+
let rowsRefresh = null;
|
|
448
|
+
let rowsGeneration = 0;
|
|
449
|
+
/** How long a served page stays fresh enough to answer with before a refresh is kicked. */
|
|
450
|
+
const SESSION_ROWS_TTL_MS = 5_000;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Void the cached page because this Host just changed a row.
|
|
454
|
+
*
|
|
455
|
+
* Called from every write path that changes what a list read must say — the reply a
|
|
456
|
+
* controller applies is folded from the same rows, so a stale page there is not a small
|
|
457
|
+
* lag but an instruction to revert the user's edit.
|
|
458
|
+
*/
|
|
459
|
+
function invalidateSessionRows() {
|
|
460
|
+
rowsGeneration += 1;
|
|
461
|
+
rowsCache = null;
|
|
462
|
+
rowsRefresh = null; // A pre-write read cannot fulfill a post-write request.
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Read + fold once, sharing one flight between concurrent callers. */
|
|
466
|
+
function refreshSessionRows() {
|
|
467
|
+
if (rowsRefresh !== null) return rowsRefresh;
|
|
468
|
+
const generation = rowsGeneration;
|
|
469
|
+
const attempt = (async () => {
|
|
470
|
+
const items = await listSessions();
|
|
471
|
+
const rows = toCindySessionList(items, { now, device: deviceIdentity(), defaultModel: catalogDefaultModel, catalog: agentCatalog });
|
|
472
|
+
if (generation === rowsGeneration) rowsCache = { rows, at: now() };
|
|
473
|
+
return rows;
|
|
474
|
+
})();
|
|
475
|
+
rowsRefresh = attempt;
|
|
476
|
+
const settle = () => { if (rowsRefresh === attempt) rowsRefresh = null; };
|
|
477
|
+
attempt.then(settle, settle);
|
|
478
|
+
return attempt;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function sessionRows() {
|
|
482
|
+
const cached = rowsCache;
|
|
483
|
+
if (cached !== null) {
|
|
484
|
+
if (now() - cached.at > SESSION_ROWS_TTL_MS) {
|
|
485
|
+
// Stale-while-revalidate: the controller gets a real page now and a fresher one on
|
|
486
|
+
// its next poll. A failed refresh is deliberately silent — the page above is durable.
|
|
487
|
+
refreshSessionRows().catch(() => {});
|
|
488
|
+
}
|
|
489
|
+
return cached.rows;
|
|
490
|
+
}
|
|
491
|
+
// Cold start: the only case where the probe must wait for a read.
|
|
492
|
+
return refreshSessionRows();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Mirror Cindy's native sessions:list paging contract over the cached full projection. */
|
|
496
|
+
function sessionListPage(rows, args) {
|
|
497
|
+
const rawLimit = typeof args[0] === 'number' ? args[0] : Number(args[0]);
|
|
498
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0
|
|
499
|
+
? Math.min(Math.max(Math.floor(rawLimit), 1), 1_000)
|
|
500
|
+
: 20;
|
|
501
|
+
const requestedStatus = args[1];
|
|
502
|
+
const matching = rows.filter((row) => requestedStatus === 'active' || requestedStatus === 'archived'
|
|
503
|
+
? row.status === requestedStatus
|
|
504
|
+
: row.status !== 'deleted');
|
|
505
|
+
const recent = matching.slice(0, limit);
|
|
506
|
+
const options = args[2] !== null && typeof args[2] === 'object' ? args[2] : {};
|
|
507
|
+
if (options.includePinned !== true) return recent;
|
|
508
|
+
const seen = new Set(recent.map((row) => row.id));
|
|
509
|
+
return [...recent, ...matching.filter((row) => row.pinnedAt != null && !seen.has(row.id))];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function sessionRow(sessionId) {
|
|
513
|
+
if (typeof getSession === 'function') {
|
|
514
|
+
const item = await getSession(sessionId);
|
|
515
|
+
if (item == null || String(item.id) !== sessionId) return undefined;
|
|
516
|
+
return toCindySessionListRow(item, { now, device: deviceIdentity(), defaultModel: catalogDefaultModel, catalog: agentCatalog });
|
|
517
|
+
}
|
|
518
|
+
let row = (await sessionRows()).find((candidate) => candidate.id === sessionId);
|
|
519
|
+
if (row === undefined) {
|
|
520
|
+
invalidateSessionRows();
|
|
521
|
+
row = (await sessionRows()).find((candidate) => candidate.id === sessionId);
|
|
522
|
+
}
|
|
523
|
+
return row;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return async function handleInvoke(request) {
|
|
527
|
+
// A reply is addressed by `id` + `src`; without both there is nothing the
|
|
528
|
+
// relay could route, so this answers nothing rather than fabricating a frame.
|
|
529
|
+
if (request === null || typeof request !== 'object') return null;
|
|
530
|
+
if (typeof request.id !== 'string' || typeof request.src !== 'string') return null;
|
|
531
|
+
|
|
532
|
+
const channel = request?.payload?.channel;
|
|
533
|
+
const args = Array.isArray(request?.payload?.args) ? request.payload.args : [];
|
|
534
|
+
|
|
535
|
+
if (channel === SUBSCRIBE) {
|
|
536
|
+
const topics = acceptTopics(args?.[0]?.topics);
|
|
537
|
+
// A controller that subscribes is a controller that wants pushes; the
|
|
538
|
+
// subscription set is per-connection and cleared with the socket.
|
|
539
|
+
if (request.src && typeof onSubscribe === 'function') onSubscribe(request.src, topics);
|
|
540
|
+
else if (request.src) subscribers.add(request.src);
|
|
541
|
+
// A controller that has just attached knows nothing about the turn state,
|
|
542
|
+
// and its spinner clears only on an explicit terminal event — a session
|
|
543
|
+
// merely absent from `maker:list-active` is deliberately not read as idle.
|
|
544
|
+
// So the state is announced rather than left to be inferred from silence:
|
|
545
|
+
// without this, a refused steer or a failed prompt left the phone spinning
|
|
546
|
+
// with nothing on the Host to correct it.
|
|
547
|
+
//
|
|
548
|
+
// The answer is read from the **source**, not from the runtime's row cache:
|
|
549
|
+
// that cache is refreshed by list reads, so a controller attaching
|
|
550
|
+
// mid-session can be looking at a `running: true` captured before the turn
|
|
551
|
+
// that ended it. Announce only what the source says now, and fall back to
|
|
552
|
+
// the cache only when the source cannot be read at all.
|
|
553
|
+
const subscribing = capabilitiesNow();
|
|
554
|
+
const sessionIds = topics.filter((topic) => topic.startsWith(TOPIC_SESSION_PREFIX))
|
|
555
|
+
.map((topic) => topic.slice(TOPIC_SESSION_PREFIX.length));
|
|
556
|
+
// The creation pipeline awaits the sessions-topic subscription BEFORE
|
|
557
|
+
// sending create. Reading the corpus for that acknowledgement made cold
|
|
558
|
+
// listing timeouts look like failed creates that never reached this Host.
|
|
559
|
+
if (sessionIds.length > 0 && typeof subscribing.isSessionRunning === 'function'
|
|
560
|
+
&& typeof subscribing.pushTurnIdle === 'function'
|
|
561
|
+
&& typeof subscribing.pushTurnRunning === 'function') {
|
|
562
|
+
let live = null;
|
|
563
|
+
if (typeof getSession !== 'function') {
|
|
564
|
+
try {
|
|
565
|
+
const rows = await listStates();
|
|
566
|
+
live = Array.isArray(rows) ? rows : null;
|
|
567
|
+
} catch {
|
|
568
|
+
// Legacy sources without exact reads keep the runtime fallback.
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
for (const sessionId of sessionIds) {
|
|
572
|
+
let row = live === null ? null : live.find((candidate) => String(candidate?.id) === sessionId) ?? null;
|
|
573
|
+
if (typeof getSession === 'function') {
|
|
574
|
+
try { row = await getSession(sessionId) ?? null; } catch { row = null; }
|
|
575
|
+
}
|
|
576
|
+
const running = row === null ? subscribing.isSessionRunning(sessionId) === true : row.running === true;
|
|
577
|
+
if (running) subscribing.pushTurnRunning(sessionId);
|
|
578
|
+
else subscribing.pushTurnIdle(sessionId);
|
|
579
|
+
// …and the two things a push that never arrived would have carried.
|
|
580
|
+
//
|
|
581
|
+
// Measured: an app returning from the background does an unsubscribe → resubscribe
|
|
582
|
+
// flicker (26 subscribes / 13 unsubscribes in one session), a send landed inside it, and
|
|
583
|
+
// the frames carrying "accepted" and "the view changed" were discarded by the relay with
|
|
584
|
+
// nobody to retry them — the bubble spun forever and the transcript stayed stale until
|
|
585
|
+
// the app was restarted. A subscription is the one moment the client is guaranteed to be
|
|
586
|
+
// listening, so it is where the Host repairs what it could not deliver: the authoritative
|
|
587
|
+
// input projection (which retires a spinning bubble) and a view invalidation (which makes
|
|
588
|
+
// it re-read). Both are idempotent and cost one frame each.
|
|
589
|
+
if (typeof subscribing.pushInputProjection === 'function') subscribing.pushInputProjection(sessionId);
|
|
590
|
+
if (typeof subscribing.invalidateHistoryView === 'function') subscribing.invalidateHistoryView(sessionId);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return invokeResult(request, { subscribed: topics });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (channel === UNSUBSCRIBE) {
|
|
597
|
+
const topics = acceptTopics(args?.[0]?.topics);
|
|
598
|
+
if (request.src && typeof onUnsubscribe === 'function') onUnsubscribe(request.src, topics);
|
|
599
|
+
else if (request.src) subscribers.delete(request.src);
|
|
600
|
+
return invokeResult(request, { unsubscribed: topics });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (channel === 'local-db:sessions:list') {
|
|
604
|
+
return invokeResult(request, sessionListPage(await sessionRows(), args));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (channel === 'local-db:sessions:get') {
|
|
608
|
+
const sessionId = String(args[0] ?? '');
|
|
609
|
+
// A known blank session must be readable before its first enqueue. The
|
|
610
|
+
// discovery list intentionally hides drafts and cannot prove absence.
|
|
611
|
+
const row = await sessionRow(sessionId);
|
|
612
|
+
// Cindy answers an unknown id with an error the controller already handles.
|
|
613
|
+
if (row === undefined) return invokeError(request, 'NOT_FOUND', `No DSH session ${sessionId}`);
|
|
614
|
+
// The row's message total is what lets the controller light its "load
|
|
615
|
+
// earlier" entry point at all: `hasOlderMessagesByServerCount` answers
|
|
616
|
+
// false for an unknown total, deliberately, so that an entry point which
|
|
617
|
+
// leads nowhere is never shown. Answered only here, not in the list: the
|
|
618
|
+
// controller polls the list, and folding every transcript per poll would
|
|
619
|
+
// trade a paging bug for a latency one.
|
|
620
|
+
const count = capabilitiesNow().countMessages;
|
|
621
|
+
if (typeof count === 'function') {
|
|
622
|
+
try {
|
|
623
|
+
const total = await count(sessionId);
|
|
624
|
+
if (Number.isFinite(total)) row._count = { messages: total };
|
|
625
|
+
} catch {
|
|
626
|
+
// An unreadable transcript leaves `_count` as the honest null it was.
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return invokeResult(request, row);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (channel === 'local-db:messages:list') {
|
|
633
|
+
const sessionId = String(args[0] ?? '');
|
|
634
|
+
const read = capabilitiesNow().readMessages;
|
|
635
|
+
if (typeof read !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot read message history yet');
|
|
636
|
+
const options = args[1] !== null && typeof args[1] === 'object' ? args[1] : {};
|
|
637
|
+
const rows = await read(sessionId, options);
|
|
638
|
+
// A user row in history is delivery evidence to Cindy, even if it carries
|
|
639
|
+
// an unknown pendingDelivery field. Keep unconsumed prompts exclusively in
|
|
640
|
+
// InputProjection, or a list read falsely settles the phone's queue.
|
|
641
|
+
return invokeResult(request, rows);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (channel === 'maker:list-active') {
|
|
645
|
+
// Its own shape: `[{ sessionId, isTurnRunning }]`. Answering with session
|
|
646
|
+
// rows makes the controller skip every entry, which surfaces only as a
|
|
647
|
+
// running badge that never lights up.
|
|
648
|
+
//
|
|
649
|
+
// An archived or deleted session is left out: the controller does not show its
|
|
650
|
+
// row, so an entry for it would light the device's running badge from a session
|
|
651
|
+
// the user cannot open.
|
|
652
|
+
//
|
|
653
|
+
// The **cheap** read on purpose. This is the most frequently polled channel on
|
|
654
|
+
// this Host and it needs one boolean per session, so it must not pay for titles:
|
|
655
|
+
// measured 8.4s through the full row fold on a 219-session profile, against a
|
|
656
|
+
// handset that gives up at 15s.
|
|
657
|
+
const rows = await listStates();
|
|
658
|
+
const hidden = capabilitiesNow().sessionHidden;
|
|
659
|
+
const visible = typeof hidden === 'function' ? rows.filter((row) => hidden(row.id) !== true) : rows;
|
|
660
|
+
return invokeResult(request, toCindyActiveSessions(visible));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (channel === 'maker:session-in-turn') {
|
|
664
|
+
// The controller's **stall watchdog** — "controlling side uses this when a
|
|
665
|
+
// Generating state seems stuck and no push has arrived: verify the host is
|
|
666
|
+
// really still running; only `false` is safe to finish on, never kill a slow
|
|
667
|
+
// turn that is genuinely working" (`isSessionTurnRunningFor` in the desktop
|
|
668
|
+
// renderer). It answers a bare boolean.
|
|
669
|
+
//
|
|
670
|
+
// Read from the **source**, not from the runtime's cached row: a missed push is
|
|
671
|
+
// exactly the condition being probed, and the cache is fed by those same
|
|
672
|
+
// pushes. This is the read the subscribe path already trusts, and the cache is
|
|
673
|
+
// only the fallback for when the source cannot be read at all.
|
|
674
|
+
const sessionId = String(args[0] ?? '');
|
|
675
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:session-in-turn needs a session id');
|
|
676
|
+
let running = null;
|
|
677
|
+
try {
|
|
678
|
+
const rows = await listStates(sessionId);
|
|
679
|
+
const row = Array.isArray(rows) ? rows.find((candidate) => String(candidate?.id) === sessionId) ?? null : null;
|
|
680
|
+
running = row === null ? null : row.running === true;
|
|
681
|
+
} catch {
|
|
682
|
+
running = null;
|
|
683
|
+
}
|
|
684
|
+
if (running === null) running = capabilitiesNow().isSessionRunning?.(sessionId) === true;
|
|
685
|
+
return invokeResult(request, running === true);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Three controller aliases share one actual DSH harness. The selected kind
|
|
689
|
+
// is presentation-only; model routing always follows the DSH selection.
|
|
690
|
+
if (channel === 'maker:list-available-agents') {
|
|
691
|
+
return invokeResult(request, [...DSH_AGENT_KINDS]);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (channel === 'maker:get-new-maker-defaults') {
|
|
695
|
+
const requested = typeof args[0] === 'string' ? args[0] : null;
|
|
696
|
+
if (requested !== null && !DSH_AGENT_KINDS.includes(requested)) {
|
|
697
|
+
return invokeError(request, 'NOT_AVAILABLE', `DSH Host does not offer the ${requested} alias`);
|
|
698
|
+
}
|
|
699
|
+
const readCatalog = capabilitiesNow().modelCatalog;
|
|
700
|
+
if (typeof readCatalog !== 'function') return invokeResult(request, {});
|
|
701
|
+
try {
|
|
702
|
+
return invokeResult(request, toNewMakerDefaults(await readCatalog()));
|
|
703
|
+
} catch {
|
|
704
|
+
// Native Cindy also answers an empty object while its defaults cache is unavailable.
|
|
705
|
+
return invokeResult(request, {});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (channel === 'maker:provider:list') {
|
|
710
|
+
const readCatalog = capabilitiesNow().modelCatalog;
|
|
711
|
+
if (typeof readCatalog !== 'function') return invokeResult(request, { providers: [], providerOrder: [] });
|
|
712
|
+
try {
|
|
713
|
+
const catalog = await readCatalog();
|
|
714
|
+
agentCatalog = catalog;
|
|
715
|
+
const defaultModel = catalog?.default?.model;
|
|
716
|
+
catalogDefaultModel = typeof defaultModel === 'string' && defaultModel !== '' ? defaultModel : null;
|
|
717
|
+
invalidateSessionRows();
|
|
718
|
+
return invokeResult(request, toProviderList(catalog, DSH_AGENT_KINDS));
|
|
719
|
+
} catch {
|
|
720
|
+
return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host could not read its model catalog');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (channel === 'maker:get-capabilities') {
|
|
725
|
+
const requested = typeof args[0] === 'string' ? args[0] : null;
|
|
726
|
+
if (requested !== null && !DSH_AGENT_KINDS.includes(requested)) {
|
|
727
|
+
return invokeError(request, 'NOT_AVAILABLE', `DSH Host does not offer the ${requested} alias`);
|
|
728
|
+
}
|
|
729
|
+
// The catalog is a live read, not a constant: it is what the picker lists,
|
|
730
|
+
// and answering from a frozen copy would keep offering models that this
|
|
731
|
+
// Host can no longer route to.
|
|
732
|
+
const readCatalog = capabilitiesNow().modelCatalog;
|
|
733
|
+
if (typeof readCatalog !== 'function') return invokeResult(request, DSH_AGENT_CAPABILITIES);
|
|
734
|
+
let catalog;
|
|
735
|
+
try {
|
|
736
|
+
catalog = await readCatalog();
|
|
737
|
+
} catch (error) {
|
|
738
|
+
// A failed catalog read costs the picker, never the session list.
|
|
739
|
+
agentCatalog = null;
|
|
740
|
+
catalogDefaultModel = null;
|
|
741
|
+
invalidateSessionRows();
|
|
742
|
+
return invokeResult(request, DSH_AGENT_CAPABILITIES);
|
|
743
|
+
}
|
|
744
|
+
agentCatalog = catalog;
|
|
745
|
+
const defaultModel = catalog?.default?.model;
|
|
746
|
+
catalogDefaultModel = typeof defaultModel === 'string' && defaultModel !== '' ? defaultModel : null;
|
|
747
|
+
invalidateSessionRows();
|
|
748
|
+
const capabilities = toAgentCapabilities(catalog);
|
|
749
|
+
// These two fields are what make the controls appear at all: the controller
|
|
750
|
+
// draws the permission picker only when presets are advertised, and the
|
|
751
|
+
// plan-mode switch only when support is — and DSH documents a missing
|
|
752
|
+
// `permissions` projection as "clients hide the control".
|
|
753
|
+
const controls = capabilitiesNow().sessionControls;
|
|
754
|
+
if (typeof controls?.permissionNames === 'function') {
|
|
755
|
+
try {
|
|
756
|
+
capabilities.permissionModes = toPermissionOptions(controls.permissionNames());
|
|
757
|
+
} catch {
|
|
758
|
+
capabilities.permissionModes = [];
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (typeof controls?.planModeSupported === 'function') {
|
|
762
|
+
try {
|
|
763
|
+
const supported = await controls.planModeSupported();
|
|
764
|
+
capabilities.planMode = { supported: supported === true };
|
|
765
|
+
} catch {
|
|
766
|
+
capabilities.planMode = { supported: false };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return invokeResult(request, capabilities);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (channel === 'maker:set-plan-mode') {
|
|
773
|
+
const controls = capabilitiesNow().sessionControls;
|
|
774
|
+
if (typeof controls?.setPlanMode !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no plan mode');
|
|
775
|
+
const sessionId = String(args[0] ?? '');
|
|
776
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:set-plan-mode needs a session id');
|
|
777
|
+
try {
|
|
778
|
+
const outcome = await controls.setPlanMode({ sessionId, enabled: args[1] === true });
|
|
779
|
+
// `queued` means the switch lands at the next step rather than now. The
|
|
780
|
+
// controller treats a resolved call as applied, so the outcome travels
|
|
781
|
+
// as the answer instead of being flattened into a bare success. A seam
|
|
782
|
+
// that can only report "the switch was accepted" says exactly that.
|
|
783
|
+
if (outcome !== null && typeof outcome === 'object') {
|
|
784
|
+
return invokeResult(request, {
|
|
785
|
+
outcome: typeof outcome.outcome === 'string' ? outcome.outcome : 'accepted',
|
|
786
|
+
...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return invokeResult(request, { outcome: typeof outcome === 'string' ? outcome : 'committed' });
|
|
790
|
+
} catch (error) {
|
|
791
|
+
return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (channel === 'maker:set-permission-mode') {
|
|
796
|
+
const controls = capabilitiesNow().sessionControls;
|
|
797
|
+
if (typeof controls?.setPermissionMode !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no permission presets');
|
|
798
|
+
const sessionId = String(args[0] ?? '');
|
|
799
|
+
const mode = typeof args[1] === 'string' ? toDshPermissionMode(args[1]) : null;
|
|
800
|
+
if (sessionId === '' || mode === null) return invokeError(request, 'BAD_REQUEST', 'maker:set-permission-mode needs a supported Cindy mode');
|
|
801
|
+
try {
|
|
802
|
+
await controls.setPermissionMode({ sessionId, mode });
|
|
803
|
+
// The preset writes the session's knobs, so the row the controller holds
|
|
804
|
+
// is now stale; a fresh read is pushed for the same reason a queue
|
|
805
|
+
// command pushes one.
|
|
806
|
+
const push = capabilitiesNow().pushInputProjection;
|
|
807
|
+
if (typeof push === 'function') push(sessionId);
|
|
808
|
+
return invokeResult(request, true);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (channel === 'maker:set-model') {
|
|
815
|
+
const select = capabilitiesNow().selectModel;
|
|
816
|
+
if (typeof select !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the model');
|
|
817
|
+
const sessionId = String(args[0] ?? '');
|
|
818
|
+
const model = typeof args[1] === 'string' ? args[1] : '';
|
|
819
|
+
if (sessionId === '' || model === '') return invokeError(request, 'BAD_REQUEST', 'maker:set-model needs a session and a model');
|
|
820
|
+
// The controller sends `[sessionId, model, providerId?]`, and a richer
|
|
821
|
+
// `[…, providerId, null, selection]` form when it has a full selection.
|
|
822
|
+
const selection = args[4] !== null && typeof args[4] === 'object' ? args[4] : null;
|
|
823
|
+
const provider = typeof args[2] === 'string' && args[2] !== ''
|
|
824
|
+
? args[2]
|
|
825
|
+
: (typeof selection?.provider === 'string' && selection.provider !== '' ? selection.provider : null);
|
|
826
|
+
const effort = typeof selection?.reasoningEffort === 'string' && selection.reasoningEffort !== ''
|
|
827
|
+
? selection.reasoningEffort
|
|
828
|
+
: (typeof args[3] === 'string' && args[3] !== '' ? args[3] : undefined);
|
|
829
|
+
// A provider is passed through when the controller has one, but it is not
|
|
830
|
+
// required: `MobileModelOption` carries no provider id, so the Host
|
|
831
|
+
// resolves it from its own catalog rather than refusing the choice.
|
|
832
|
+
try {
|
|
833
|
+
const selected = await select({
|
|
834
|
+
sessionId,
|
|
835
|
+
provider: provider ?? undefined,
|
|
836
|
+
model,
|
|
837
|
+
reasoningEffort: effort,
|
|
838
|
+
});
|
|
839
|
+
// The controller applies this to the row it is showing. A second screen
|
|
840
|
+
// watching the same session learns the change from its next list read:
|
|
841
|
+
// the row-level channel that would push it belongs to the `sessions`
|
|
842
|
+
// topic, which a controller viewing one session does not hold.
|
|
843
|
+
return invokeResult(request, {
|
|
844
|
+
selected: selected?.selected ?? { provider, model, ...(effort === undefined ? {} : { reasoningEffort: effort }) },
|
|
845
|
+
});
|
|
846
|
+
} catch (error) {
|
|
847
|
+
return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// The composer's `/` and `@` palettes.
|
|
852
|
+
//
|
|
853
|
+
// Refusing these is what the user sees as "channel is not available on DSH
|
|
854
|
+
// Host" while typing — the palette is part of the agent's own information
|
|
855
|
+
// flow, not an extra, so it answers even when a source is missing: an empty
|
|
856
|
+
// palette reads as "nothing to offer", which is true, while a refusal reads
|
|
857
|
+
// as a broken composer.
|
|
858
|
+
if (channel === 'maker:set-effort') {
|
|
859
|
+
// Effort is advertised in the capabilities (`effortLevels`, read from the
|
|
860
|
+
// model catalog), so the controller draws the picker and will call this.
|
|
861
|
+
// A Host that lists effort levels but cannot apply one is the same class of
|
|
862
|
+
// lie as a model catalog that cannot route.
|
|
863
|
+
const setEffort = capabilitiesNow().setEffort;
|
|
864
|
+
if (typeof setEffort !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the reasoning effort');
|
|
865
|
+
const sessionId = String(args[0] ?? '');
|
|
866
|
+
const effort = typeof args[1] === 'string' ? args[1] : '';
|
|
867
|
+
if (sessionId === '' || effort === '') return invokeError(request, 'BAD_REQUEST', 'maker:set-effort needs a session and an effort');
|
|
868
|
+
try {
|
|
869
|
+
await setEffort({ sessionId, effort });
|
|
870
|
+
// The row the controller holds is now stale, exactly as after a model or
|
|
871
|
+
// permission change.
|
|
872
|
+
const push = capabilitiesNow().pushInputProjection;
|
|
873
|
+
if (typeof push === 'function') push(sessionId);
|
|
874
|
+
return invokeResult(request, true);
|
|
875
|
+
} catch (error) {
|
|
876
|
+
return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (channel === 'maker:list-agent-skills') {
|
|
881
|
+
const palette = capabilitiesNow().palette;
|
|
882
|
+
if (typeof palette?.skills !== 'function') return invokeResult(request, { success: true, skills: [] });
|
|
883
|
+
const cwd = typeof args[1]?.workingDir === 'string' && args[1].workingDir !== '' ? args[1].workingDir : undefined;
|
|
884
|
+
// The session is what names the viewing scope: a preset's skills are
|
|
885
|
+
// registered into its own layer, so without it the registry answers for
|
|
886
|
+
// the global layer alone and this menu is empty on a profile that has
|
|
887
|
+
// skills. The controller sends the same field for its command list.
|
|
888
|
+
const sessionId = typeof args[1]?.sessionId === 'string' ? args[1].sessionId : '';
|
|
889
|
+
let summaries = [];
|
|
890
|
+
try {
|
|
891
|
+
summaries = await palette.skills({ cwd, sessionId });
|
|
892
|
+
} catch {
|
|
893
|
+
summaries = [];
|
|
894
|
+
}
|
|
895
|
+
return invokeResult(request, toAgentSkills(summaries));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (channel === 'maker:list-agent-commands') {
|
|
899
|
+
const palette = capabilitiesNow().palette;
|
|
900
|
+
if (typeof palette?.commands !== 'function') return invokeResult(request, { success: true, commands: [] });
|
|
901
|
+
const sessionId = typeof args[1]?.sessionId === 'string' ? args[1].sessionId : '';
|
|
902
|
+
let descriptors = [];
|
|
903
|
+
try {
|
|
904
|
+
descriptors = await palette.commands({ sessionId });
|
|
905
|
+
} catch {
|
|
906
|
+
descriptors = [];
|
|
907
|
+
}
|
|
908
|
+
return invokeResult(request, toAgentCommands(descriptors, 'agent-builtin'));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (channel === 'maker:list-desktop-commands') {
|
|
912
|
+
// Commands only the controlling desktop can run. This Host owns none, and
|
|
913
|
+
// an empty success is the right answer here — unlike the provider catalog,
|
|
914
|
+
// nothing downstream branches on this channel being *unsupported*.
|
|
915
|
+
return invokeResult(request, { success: true, commands: [] });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (channel === 'maker:scan-at-resources') {
|
|
919
|
+
const palette = capabilitiesNow().palette;
|
|
920
|
+
const workingDir = typeof args[1]?.workingDir === 'string' ? args[1].workingDir : '';
|
|
921
|
+
if (typeof palette?.resources !== 'function' || workingDir === '') {
|
|
922
|
+
return invokeResult(request, { success: true, items: [] });
|
|
923
|
+
}
|
|
924
|
+
let entries = [];
|
|
925
|
+
try {
|
|
926
|
+
entries = await palette.resources({
|
|
927
|
+
workingDir,
|
|
928
|
+
query: typeof args[1]?.query === 'string' ? args[1].query : '',
|
|
929
|
+
cap: Number.isFinite(args[1]?.cap) ? args[1].cap : undefined,
|
|
930
|
+
});
|
|
931
|
+
} catch {
|
|
932
|
+
entries = [];
|
|
933
|
+
}
|
|
934
|
+
return invokeResult(request, toAtResources(entries, {
|
|
935
|
+
workingDir,
|
|
936
|
+
cap: Number.isFinite(args[1]?.cap) ? args[1].cap : undefined,
|
|
937
|
+
query: typeof args[1]?.query === 'string' ? args[1].query : '',
|
|
938
|
+
}));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (channel === 'maker:create-session') {
|
|
942
|
+
const create = capabilitiesNow().createSession;
|
|
943
|
+
if (typeof create !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot create sessions yet');
|
|
944
|
+
const options = args[0] !== null && typeof args[0] === 'object' ? args[0] : {};
|
|
945
|
+
// The runtime the controller picked for this conversation arrives **here and
|
|
946
|
+
// nowhere else**. A new chat on the handset has no session to call
|
|
947
|
+
// `maker:set-model` on yet — its whole pipeline is create, getSession, enqueue,
|
|
948
|
+
// with no model write in between (measured in
|
|
949
|
+
// `apps/mobile/src/session/newSessionCreation.ts`, which never calls `setModel`).
|
|
950
|
+
// A Host that reads only `id`/`workingDir` therefore leaves the new session with
|
|
951
|
+
// no session-local selection at all, and DSH's first prompt falls back to the
|
|
952
|
+
// profile's `agent-default-model` — reported as
|
|
953
|
+
// 「新对话选了 gpt,一运行又变成 deepseek」.
|
|
954
|
+
//
|
|
955
|
+
// The names are the controller's own: the handset
|
|
956
|
+
// (`buildRemoteCreateSessionOptions`) and the desktop (`buildDeviceLinkCreateArgs`)
|
|
957
|
+
// both send `model`, an optional `providerId` (absent/blank = follow this Host's
|
|
958
|
+
// default route) and `effort`.
|
|
959
|
+
//
|
|
960
|
+
// `permissionMode` is the same class of field and the same class of loss, with a
|
|
961
|
+
// worse failure: the handset's permission options **are** this Host's own
|
|
962
|
+
// advertised Cindy `permissionModes` (ask / acceptEdits / bypassPermissions),
|
|
963
|
+
// mapped exactly onto DSH read-only / workspace-write / danger-full-access presets.
|
|
964
|
+
// A user who picks ask and gets the profile default of danger-full-access believes the agent is restricted while it
|
|
965
|
+
// holds full access. Whatever reaches the seam here is whatever the controller
|
|
966
|
+
// chose; the seam decides whether this Host can actually install it.
|
|
967
|
+
const model = typeof options.model === 'string' ? options.model.trim() : '';
|
|
968
|
+
const provider = typeof options.providerId === 'string' ? options.providerId.trim() : '';
|
|
969
|
+
const effort = typeof options.effort === 'string' ? options.effort.trim() : '';
|
|
970
|
+
const permission = typeof options.permissionMode === 'string' ? toDshPermissionMode(options.permissionMode) : null;
|
|
971
|
+
try {
|
|
972
|
+
// The controller may pre-allocate the session id so its optimistic row and
|
|
973
|
+
// route use the final id from the start; DSH's create is idempotent on a
|
|
974
|
+
// supplied id, so passing it through is safe and avoids a rekey.
|
|
975
|
+
const created = await create({
|
|
976
|
+
sessionId: typeof options.id === 'string' && options.id !== '' ? options.id : undefined,
|
|
977
|
+
cwd: typeof options.workingDir === 'string' && options.workingDir !== '' ? options.workingDir : undefined,
|
|
978
|
+
...(model === '' ? {} : { model }),
|
|
979
|
+
...(provider === '' ? {} : { provider }),
|
|
980
|
+
...(effort === '' ? {} : { reasoningEffort: effort }),
|
|
981
|
+
...(permission === null ? {} : { permissionMode: permission }),
|
|
982
|
+
});
|
|
983
|
+
// What the session is actually on, never what was asked for: DSH resolves and
|
|
984
|
+
// normalizes the selection, so echoing the request would claim a route the
|
|
985
|
+
// session may not be running. Absent means the Host named no selection, which
|
|
986
|
+
// is the only honest answer when the controller named none either.
|
|
987
|
+
const applied = created?.selection ?? null;
|
|
988
|
+
// A new session must be in the next list read, not five seconds later.
|
|
989
|
+
invalidateSessionRows();
|
|
990
|
+
return invokeResult(request, {
|
|
991
|
+
sessionId: String(created?.sessionId ?? options.id ?? ''),
|
|
992
|
+
// The requested platform is ignored. Echo only the applied DSH route
|
|
993
|
+
// (or its known default), not the controller's draft kind.
|
|
994
|
+
agentKind: agentKindForModel({ model: applied?.model, provider: applied?.provider, catalog: agentCatalog }),
|
|
995
|
+
workDir: options.workingDir ?? null,
|
|
996
|
+
usedProjectContext: false,
|
|
997
|
+
...(applied === null ? {} : {
|
|
998
|
+
model: applied.model,
|
|
999
|
+
providerId: applied.provider,
|
|
1000
|
+
...(typeof applied.reasoningEffort === 'string' && applied.reasoningEffort !== ''
|
|
1001
|
+
? { effort: applied.reasoningEffort }
|
|
1002
|
+
: {}),
|
|
1003
|
+
}),
|
|
1004
|
+
// Present only when the preset was really installed. A mode this Host does
|
|
1005
|
+
// not advertise keeps the profile's preset, and saying nothing about it is
|
|
1006
|
+
// the honest answer — the authoritative session row reports what is in force,
|
|
1007
|
+
// and the seam logs the request it could not honor.
|
|
1008
|
+
...(typeof created?.permissionMode === 'string' && created.permissionMode !== ''
|
|
1009
|
+
? { permissionMode: toCindyPermissionMode(created.permissionMode) }
|
|
1010
|
+
: {}),
|
|
1011
|
+
});
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
// A session that was created but whose model cannot be routed must not be
|
|
1014
|
+
// reported as a clean success: its row would then read deepseek while the user
|
|
1015
|
+
// is looking at the model they picked — the silent lie this path exists to
|
|
1016
|
+
// avoid. The refusal travels as a code the controller already understands, and
|
|
1017
|
+
// its idempotent retry adopts the existing session instead of creating another.
|
|
1018
|
+
return invokeError(request, refusalCodeOf(error), String(error?.message ?? error));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (channel === 'local-db:sessions:patch-meta') {
|
|
1023
|
+
const sessionId = String(args[0] ?? '');
|
|
1024
|
+
const patch = args[1] !== null && typeof args[1] === 'object' ? args[1] : {};
|
|
1025
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'local-db:sessions:patch-meta needs a session id');
|
|
1026
|
+
|
|
1027
|
+
// The controller's whole 删除 / 归档 / 置顶 / 重命名 menu arrives here, and it
|
|
1028
|
+
// reads the **returned row** as the truth: `useSessionListActions` applies only
|
|
1029
|
+
// the fields it wrote, taken from this reply. Answering with an unchanged row is
|
|
1030
|
+
// therefore not a refusal — it is an instruction to revert the user's edit,
|
|
1031
|
+
// which is how these three actions used to look like dead buttons.
|
|
1032
|
+
//
|
|
1033
|
+
// Only the title has a DSH counterpart; the other three are this Host's own
|
|
1034
|
+
// bookkeeping (`session-flags.js`), so the reply is built from the source row
|
|
1035
|
+
// with the flags folded back on — the same fold every read path uses.
|
|
1036
|
+
const capabilities = capabilitiesNow();
|
|
1037
|
+
const rename = capabilities.renameSession;
|
|
1038
|
+
let wrote = false;
|
|
1039
|
+
if (typeof rename === 'function' && typeof patch.title === 'string' && patch.title.trim() !== '') {
|
|
1040
|
+
await rename({ sessionId, title: patch.title.trim() });
|
|
1041
|
+
wrote = true;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const writesMeta = patch.status !== undefined || patch.pinnedAt !== undefined;
|
|
1045
|
+
if (writesMeta) {
|
|
1046
|
+
const applyFlags = capabilities.applySessionFlags;
|
|
1047
|
+
if (typeof applyFlags !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot archive, delete or pin sessions');
|
|
1048
|
+
const applied = await applyFlags(sessionId, { status: patch.status, pinnedAt: patch.pinnedAt });
|
|
1049
|
+
wrote = true;
|
|
1050
|
+
// Echo only what was written, with this Host's effective value: that is the
|
|
1051
|
+
// same patch the controller applied optimistically, so other devices converge
|
|
1052
|
+
// on it too.
|
|
1053
|
+
const echoed = {};
|
|
1054
|
+
if (patch.status !== undefined) echoed.status = applied.status;
|
|
1055
|
+
if (patch.pinnedAt !== undefined) echoed.pinnedAt = applied.pinnedAt;
|
|
1056
|
+
if (typeof capabilities.publishSessionMeta === 'function') capabilities.publishSessionMeta(sessionId, echoed);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// The write just changed what a list read must say, so the cached page is void: without
|
|
1060
|
+
// this the reply below would echo the pre-write row, and the controller reads an
|
|
1061
|
+
// unchanged row as "revert my edit" (that is how these buttons looked dead once already).
|
|
1062
|
+
if (wrote) invalidateSessionRows();
|
|
1063
|
+
const row = await sessionRow(sessionId);
|
|
1064
|
+
return row === undefined ? invokeError(request, 'NOT_FOUND', `No DSH session ${sessionId}`) : invokeResult(request, row);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// The composer's send paths. `maker:send` is the direct one; an idle turn
|
|
1068
|
+
// uses `maker:input:enqueue` and a running turn uses `maker:input:steer`,
|
|
1069
|
+
// and BOTH must answer or the composer spins in one of the two states.
|
|
1070
|
+
if (channel === 'maker:input:enqueue' || channel === 'maker:input:steer') {
|
|
1071
|
+
const capabilities = capabilitiesNow();
|
|
1072
|
+
const sessionId = String(args[0] ?? '');
|
|
1073
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
|
|
1074
|
+
|
|
1075
|
+
// A `steer` naming an item DSH already holds is a **promotion** of that
|
|
1076
|
+
// queued item, not a new message: the controller sends the queued row back
|
|
1077
|
+
// with `removeFromQueue`. Re-sending its text would duplicate it, which is
|
|
1078
|
+
// exactly what a queued item's 插话 button must not do.
|
|
1079
|
+
const namedId = typeof args[1]?.clientId === 'string' && args[1].clientId !== '' ? args[1].clientId : null;
|
|
1080
|
+
if (channel === 'maker:input:steer' && namedId !== null) {
|
|
1081
|
+
// Attach the session first: the inbox belongs to the live agent, so
|
|
1082
|
+
// without this the lookup reports the item missing on a cold session and
|
|
1083
|
+
// the steer degrades into a second send.
|
|
1084
|
+
if (typeof capabilities.ensureAgent === 'function') await capabilities.ensureAgent(sessionId);
|
|
1085
|
+
// One shot, no wait: steering a brand-new message is the common case and
|
|
1086
|
+
// must not pay for a queue lookup. Only an item the Host can already
|
|
1087
|
+
// address is treated as a promotion.
|
|
1088
|
+
const dshId = await resolveDshItemId(capabilities, sessionId, namedId, 0);
|
|
1089
|
+
if (dshId !== null) {
|
|
1090
|
+
const control = capabilities.queueControl;
|
|
1091
|
+
if (typeof control !== 'object' || control === null) {
|
|
1092
|
+
return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the pending queue');
|
|
1093
|
+
}
|
|
1094
|
+
const failure = await commitQueueActionAndReconcile(capabilities, sessionId, dshId, { kind: 'steer' });
|
|
1095
|
+
if (failure !== null) return invokeError(request, failure.code, failure.message);
|
|
1096
|
+
// `mirror` only re-places an item the fold already holds, and this one may not be
|
|
1097
|
+
// there: the authoritative read that follows an enqueue can race DSH's splice and
|
|
1098
|
+
// come back empty. That is exactly how a promoted 插话 left the projection saying
|
|
1099
|
+
// nothing — measured as a 42-second gap (08:54:40 promoted → 08:55:22 durable)
|
|
1100
|
+
// with the phone's bubble abandoned in between. The upsert makes the steering id
|
|
1101
|
+
// true from this moment; the durable row's `retireQueuedItem` clears it again.
|
|
1102
|
+
//
|
|
1103
|
+
// The record has to carry the message **content**, because that is what the
|
|
1104
|
+
// controller renders: the first version restated the id with `content: []`, and the
|
|
1105
|
+
// bubble appeared with no text in it (「插入之后里面的文字被清空了」) while the same
|
|
1106
|
+
// empty item produced an empty transcript row. The fold's own record is the
|
|
1107
|
+
// authority when it has one; otherwise the text the controller sent with the row.
|
|
1108
|
+
const mirror = capabilities.queueMirror;
|
|
1109
|
+
const known = typeof mirror?.itemFor === 'function' ? mirror.itemFor(sessionId, namedId) : null;
|
|
1110
|
+
const knownContent = Array.isArray(known?.message?.content) && known.message.content.length > 0
|
|
1111
|
+
? known.message.content
|
|
1112
|
+
: null;
|
|
1113
|
+
const carried = extractSendText(args[1]);
|
|
1114
|
+
const content = knownContent
|
|
1115
|
+
?? (typeof carried === 'string' && carried !== '' ? [{ type: 'text', text: carried }] : []);
|
|
1116
|
+
// Keyed on DSH's own id, not the controller's: the fold is keyed that way, and an
|
|
1117
|
+
// upsert under the `clientId` left the original queued row in the fold beside the new
|
|
1118
|
+
// steering one — a duplicate the projection then reported as both `queued` and
|
|
1119
|
+
// `steering`. The controller's id travels as `rpcId`, which is what the projection
|
|
1120
|
+
// names the row by.
|
|
1121
|
+
const itemId = known?.id !== undefined && known?.id !== null ? String(known.id) : String(dshId);
|
|
1122
|
+
if (typeof mirror?.markSteering === 'function') {
|
|
1123
|
+
mirror.markSteering(sessionId, {
|
|
1124
|
+
...(known ?? {}),
|
|
1125
|
+
id: itemId,
|
|
1126
|
+
rpcId: namedId,
|
|
1127
|
+
message: { ...(known?.message ?? {}), id: itemId, content },
|
|
1128
|
+
});
|
|
1129
|
+
} else {
|
|
1130
|
+
mirror?.mirror(sessionId, namedId, { kind: 'steer' });
|
|
1131
|
+
}
|
|
1132
|
+
// The promotion moved the row from "queued" to "steering": tell the controller
|
|
1133
|
+
// that, or the row it is showing disappears in the hand-off.
|
|
1134
|
+
capabilities.pushInputProjection?.(sessionId);
|
|
1135
|
+
// The controller answers a steer with a boolean and then refetches.
|
|
1136
|
+
return invokeResult(request, true);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const text = extractSendText(args[1]);
|
|
1141
|
+
const attachments = extractSendAttachments(args[1]);
|
|
1142
|
+
// A prompt with neither text nor a serveable attachment is nothing to run, and
|
|
1143
|
+
// accepting it would show the user a delivered message DSH has no counterpart
|
|
1144
|
+
// for. **Attachment-only is legitimate** — the phone's composer sends a photo
|
|
1145
|
+
// with `text: ''` — so the refusal is about having nothing at all, not about
|
|
1146
|
+
// having no words.
|
|
1147
|
+
if (text === null && attachments.length === 0) {
|
|
1148
|
+
return invokeError(request, 'BAD_REQUEST', `${channel} carried no text and no attachment this Host can serve`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// A steer needs a **live turn**, and even then DSH can refuse it: "current turn no longer
|
|
1152
|
+
// accepts steering" is what the phone showed when the user's tap landed as the turn moved
|
|
1153
|
+
// past its steerable point. Checking the session state answers the first half (a turn that
|
|
1154
|
+
// never started), and it cannot answer the second: the gap between our check and DSH's
|
|
1155
|
+
// decision is a race, and **the user's words must not be the thing that loses it**.
|
|
1156
|
+
//
|
|
1157
|
+
// So a refused steer is retried once as a normal prompt: the same intent one step later,
|
|
1158
|
+
// which starts the turn it could not join. Measured before this: the insert produced an
|
|
1159
|
+
// error string on the handset and the message the user was looking at was lost from the
|
|
1160
|
+
// conversation until they sent it again.
|
|
1161
|
+
const wantsSteer = channel === 'maker:input:steer' && capabilities.isSessionRunning?.(sessionId) === true;
|
|
1162
|
+
if (typeof capabilities.sendMessage !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot send prompts yet');
|
|
1163
|
+
// The controller's own `clientId` is the prompt identity, and DSH persists
|
|
1164
|
+
// it as the queued item's `rpcId` — the field the controller retires its
|
|
1165
|
+
// local echo on, and the field DSH dedupes a retried prompt against.
|
|
1166
|
+
// Sending the relay's envelope id instead left the draft in the composer
|
|
1167
|
+
// (so the next thing the user typed was appended to it) and made a retry
|
|
1168
|
+
// indistinguishable from a new message.
|
|
1169
|
+
const send = (mode) => capabilities.sendMessage({
|
|
1170
|
+
sessionId,
|
|
1171
|
+
text: text ?? '',
|
|
1172
|
+
requestId: namedId ?? request.id,
|
|
1173
|
+
mode,
|
|
1174
|
+
// A message with no serveable attachment keeps the exact shape it always
|
|
1175
|
+
// had: an empty list is not information, and every seam test pins it.
|
|
1176
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1177
|
+
});
|
|
1178
|
+
let steer = wantsSteer;
|
|
1179
|
+
let sent;
|
|
1180
|
+
try {
|
|
1181
|
+
sent = await send(wantsSteer ? 'steer' : 'queue');
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
const message = String(error?.message ?? error);
|
|
1184
|
+
if (!wantsSteer || !/steer/i.test(message)) throw error;
|
|
1185
|
+
// The turn would not take it. Say it as a prompt instead, and remember that this was not a
|
|
1186
|
+
// steering row so the projection does not claim one.
|
|
1187
|
+
steer = false;
|
|
1188
|
+
sent = await send('queue');
|
|
1189
|
+
}
|
|
1190
|
+
// An attachment-only prompt whose bytes could not be fetched is refused with
|
|
1191
|
+
// the reason: sending it would deliver a photo the agent never saw.
|
|
1192
|
+
if (sent !== undefined && sent !== null && sent.ok === false) {
|
|
1193
|
+
return invokeError(request, sent.code ?? 'ATTACHMENT_UNAVAILABLE', sent.message ?? 'the attachment could not be fetched');
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (steer) {
|
|
1197
|
+
// A **new** steering prompt (not a promotion) has to be made visible before it is
|
|
1198
|
+
// durable.
|
|
1199
|
+
//
|
|
1200
|
+
// DSH splices it into the running turn's next step a moment after `prompt()`
|
|
1201
|
+
// returns, so a projection read taken right now may legitimately say nothing about
|
|
1202
|
+
// it. The controller's bubble would then have nothing to hold on to and vanish —
|
|
1203
|
+
// 手机上「转圈转圈然后就消失了」 while the message did reach the desktop. Recording
|
|
1204
|
+
// it as a steering item keeps `steeringQueueClientIds` truthful from this moment,
|
|
1205
|
+
// and `retireQueuedItem` clears it the instant the durable row arrives.
|
|
1206
|
+
const controllerId = namedId ?? request.id;
|
|
1207
|
+
if (typeof capabilities.queueMirror?.markSteering === 'function') {
|
|
1208
|
+
capabilities.queueMirror.markSteering(sessionId, {
|
|
1209
|
+
id: controllerId,
|
|
1210
|
+
rpcId: controllerId,
|
|
1211
|
+
message: { id: controllerId, content: text === null || text === '' ? [] : [{ type: 'text', text }] },
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
capabilities.pushInputProjection?.(sessionId);
|
|
1215
|
+
return invokeResult(request, true);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Answer with the queue DSH actually holds.
|
|
1219
|
+
//
|
|
1220
|
+
// This Host used to synthesise a `pendingQueue` row for every accepted
|
|
1221
|
+
// prompt. When the session was idle DSH admitted the prompt immediately and
|
|
1222
|
+
// never put it in the inbox, so that row described a queue entry that did
|
|
1223
|
+
// not exist — and nothing would ever clear it, because no queue frame is
|
|
1224
|
+
// emitted for an item that was never queued. The phone showed 队列中 for a
|
|
1225
|
+
// message the agent had already answered.
|
|
1226
|
+
//
|
|
1227
|
+
// The authoritative read is the honest answer: a row appears only if DSH is
|
|
1228
|
+
// really holding one. A prompt that was admitted instead shows up as a
|
|
1229
|
+
// durable message, which is what the controller renders anyway.
|
|
1230
|
+
if (typeof capabilities.readSessionState === 'function' && typeof capabilities.projectionFromItems === 'function') {
|
|
1231
|
+
const state = await capabilities.readSessionState(sessionId);
|
|
1232
|
+
if (state !== null && state !== undefined) {
|
|
1233
|
+
const items = queueItemsFromInbox(state.inbox);
|
|
1234
|
+
// Teach the fold the durable ids, so a later cancel or edit resolves
|
|
1235
|
+
// without waiting for a queue frame that may never come.
|
|
1236
|
+
capabilities.queueMirror?.adopt?.(sessionId, items);
|
|
1237
|
+
const held = namedId !== null
|
|
1238
|
+
&& items.some((entry) => entry.rpcId === namedId || entry.id === namedId);
|
|
1239
|
+
// A prompt accepted **while a turn is running** belongs in the queue,
|
|
1240
|
+
// and DSH's durable splice can land a moment after `prompt` returns —
|
|
1241
|
+
// so that one row is shown from the acceptance itself. A prompt
|
|
1242
|
+
// accepted by an idle session is admitted immediately, and showing it
|
|
1243
|
+
// as queued is what left the phone spinning on a 队列中 row for a
|
|
1244
|
+
// message the agent had already answered.
|
|
1245
|
+
const pending = !held && namedId !== null && capabilities.isSessionRunning?.(sessionId) === true
|
|
1246
|
+
? (capabilities.queuedRow?.({ clientId: namedId, text, sessionId }) ?? null)
|
|
1247
|
+
: null;
|
|
1248
|
+
// A synthesised row has to exist in the fold too, not just in this answer: the
|
|
1249
|
+
// controller will keep showing it, and the next thing that happens to it (插话 →
|
|
1250
|
+
// promotion, 编辑, 删除) resolves against the fold by that id.
|
|
1251
|
+
if (pending !== null && typeof capabilities.queueMirror?.markQueued === 'function') {
|
|
1252
|
+
capabilities.queueMirror.markQueued(sessionId, {
|
|
1253
|
+
id: namedId,
|
|
1254
|
+
rpcId: namedId,
|
|
1255
|
+
message: { id: namedId, content: text === null || text === '' ? [] : [{ type: 'text', text }] },
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
return invokeResult(request, capabilities.projectionFromItems(sessionId, items, pending));
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const project = capabilities.inputProjection;
|
|
1263
|
+
const pending = namedId === null ? null : capabilities.queuedRow?.({ clientId: namedId, text, sessionId });
|
|
1264
|
+
const projection = typeof project === 'function'
|
|
1265
|
+
? project(sessionId, pending ?? null)
|
|
1266
|
+
: { sessionId, pendingQueue: pending === null ? [] : [pending], steeringQueueClientIds: [] };
|
|
1267
|
+
return invokeResult(request, projection);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Queue commands. The queue lives in DSH's durable inbox, so every mutation
|
|
1271
|
+
// goes to `sessionController.updateQueue` (or `cancel`) and is mirrored into
|
|
1272
|
+
// this Host's fold: DSH's own queue frame trails the commit, and answering a
|
|
1273
|
+
// removal with the pre-removal queue would put the row straight back.
|
|
1274
|
+
if (QUEUE_COMMAND_CHANNELS.has(channel)) {
|
|
1275
|
+
const capabilities = capabilitiesNow();
|
|
1276
|
+
const sessionId = String(args[0]?.sessionId ?? args[0] ?? '');
|
|
1277
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
|
|
1278
|
+
const mirror = capabilities.queueMirror;
|
|
1279
|
+
const control = capabilities.queueControl;
|
|
1280
|
+
const clientId = typeof args[1] === 'string' ? args[1] : (typeof args[1]?.clientId === 'string' ? args[1].clientId : null);
|
|
1281
|
+
|
|
1282
|
+
// Controller-owned queue UI state. The controller sets it here and reads it
|
|
1283
|
+
// back out of the projection, so it must be recorded rather than ignored.
|
|
1284
|
+
if (channel === 'maker:input:set-expanded') {
|
|
1285
|
+
mirror?.setExpanded(sessionId, args[1] === true);
|
|
1286
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1287
|
+
}
|
|
1288
|
+
if (channel === 'maker:input:set-edit-lock') {
|
|
1289
|
+
mirror?.setEditLock(sessionId, clientId, args[2] === true);
|
|
1290
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1291
|
+
}
|
|
1292
|
+
if (channel === 'maker:input:set-interaction-lock') {
|
|
1293
|
+
mirror?.setInteractionLock(sessionId, clientId, args[2] === true);
|
|
1294
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1295
|
+
}
|
|
1296
|
+
// `resume` reopens a paused queue. DSH's inbox is never paused — this Host
|
|
1297
|
+
// reports `queuePaused: false` always — so there is nothing to resume and
|
|
1298
|
+
// the current projection is the whole truth.
|
|
1299
|
+
if (channel === 'maker:input:resume') {
|
|
1300
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (typeof control !== 'object' || control === null) {
|
|
1304
|
+
return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot change the pending queue');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (channel === 'maker:input:stop') {
|
|
1308
|
+
// DSH cancels the active turn without dropping the pending inbox, which
|
|
1309
|
+
// is exactly what the controller asks for: it only ever sends
|
|
1310
|
+
// `keepQueue: true` (see `stopOptionsForProjection`).
|
|
1311
|
+
try {
|
|
1312
|
+
control.cancel({ sessionId });
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
return invokeError(request, 'THREW', String(error?.message ?? error));
|
|
1315
|
+
}
|
|
1316
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (channel === 'maker:input:clear-session') {
|
|
1320
|
+
// Emptying the queue means removing DSH's items one by one: the inbox is
|
|
1321
|
+
// the only owner, and `updateQueue` is the only supported mutation.
|
|
1322
|
+
//
|
|
1323
|
+
// An item DSH has already admitted is **not a failure here**, and two measured
|
|
1324
|
+
// behaviours depended on saying so. `updateQueue` answers such an item with
|
|
1325
|
+
// `session/queue-item-not-found` ("queued item is no longer pending"), which is the
|
|
1326
|
+
// outcome this channel exists to produce — the queue is what it should be. Returning
|
|
1327
|
+
// it as an error also aborted the loop, so everything after the first already-admitted
|
|
1328
|
+
// item stayed queued while the controller was told the clear had failed. Both showed
|
|
1329
|
+
// up as the acceptance suite's only two failures once refusals stopped being swallowed.
|
|
1330
|
+
// A genuine refusal is still reported, after the rest has been attempted.
|
|
1331
|
+
const ids = mirror?.itemIds(sessionId) ?? [];
|
|
1332
|
+
let failure = null;
|
|
1333
|
+
for (const itemId of ids) {
|
|
1334
|
+
const outcome = await commitQueueActionAndReconcile(capabilities, sessionId, itemId, { kind: 'remove' });
|
|
1335
|
+
if (outcome === null) continue;
|
|
1336
|
+
if (outcome.code === 'session/queue-item-not-found') continue;
|
|
1337
|
+
if (failure === null) failure = outcome;
|
|
1338
|
+
}
|
|
1339
|
+
// Only claim the queue is empty when it is: a real failure leaves the fold alone, so
|
|
1340
|
+
// the rows DSH still holds stay visible instead of vanishing on a false promise.
|
|
1341
|
+
if (failure === null) mirror?.clearSession(sessionId);
|
|
1342
|
+
if (failure !== null) return invokeError(request, failure.code, failure.message);
|
|
1343
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
let action;
|
|
1347
|
+
if (channel === 'maker:input:remove') {
|
|
1348
|
+
action = { kind: 'remove' };
|
|
1349
|
+
} else if (channel === 'maker:input:update-text' || channel === 'maker:input:update-content') {
|
|
1350
|
+
// `update-text` passes the new text directly; `update-content` passes a
|
|
1351
|
+
// whole queued row (text plus attachments). Only text is durable here.
|
|
1352
|
+
const text = channel === 'maker:input:update-text' ? args[2] : extractSendText(args[2]);
|
|
1353
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
1354
|
+
return invokeError(request, 'BAD_REQUEST', `${channel} carried no text`);
|
|
1355
|
+
}
|
|
1356
|
+
action = { kind: 'edit', content: [{ type: 'text', text }] };
|
|
1357
|
+
} else {
|
|
1358
|
+
return invokeError(request, 'BAD_REQUEST', `${channel} is not a queue command`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (clientId === null || clientId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs an item id`);
|
|
1362
|
+
// `updateQueue` is keyed on DSH's durable `MessageId`, not the `clientId`
|
|
1363
|
+
// the controller minted, so the id is translated — and the session is
|
|
1364
|
+
// attached first, because the inbox only exists on a live agent: without
|
|
1365
|
+
// that, a queued item on a cold session is invisible and the mutation is
|
|
1366
|
+
// refused as if the item had vanished.
|
|
1367
|
+
if (typeof capabilities.ensureAgent === 'function') await capabilities.ensureAgent(sessionId);
|
|
1368
|
+
const dshId = await resolveDshItemId(capabilities, sessionId, clientId);
|
|
1369
|
+
if (dshId === null) return invokeError(request, 'NOT_FOUND', `No queued item ${clientId}`);
|
|
1370
|
+
const failure = await commitQueueActionAndReconcile(capabilities, sessionId, dshId, action);
|
|
1371
|
+
if (failure !== null) {
|
|
1372
|
+
// A phone can delete a row just after the desktop admitted or removed it.
|
|
1373
|
+
// DSH then says the item is no longer pending; for *remove* only, that is
|
|
1374
|
+
// already the requested outcome. Verify against the durable inbox before
|
|
1375
|
+
// retiring the stale phone row, rather than turning it into a sync error.
|
|
1376
|
+
// Edit and steer must still report the refusal: they did not take effect.
|
|
1377
|
+
if (channel === 'maker:input:remove' && failure.code === 'session/queue-item-not-found'
|
|
1378
|
+
&& typeof capabilities.readSessionState === 'function' && typeof mirror?.adopt === 'function') {
|
|
1379
|
+
let state = null;
|
|
1380
|
+
try { state = await capabilities.readSessionState(sessionId); } catch { /* Keep the original refusal. */ }
|
|
1381
|
+
if (state?.inbox !== null && typeof state?.inbox === 'object') {
|
|
1382
|
+
const items = queueItemsFromInbox(state.inbox);
|
|
1383
|
+
if (!items.some((item) => item.id === String(dshId) || item.rpcId === clientId)) {
|
|
1384
|
+
mirror.adopt(sessionId, items);
|
|
1385
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return invokeError(request, failure.code, failure.message);
|
|
1390
|
+
}
|
|
1391
|
+
mirror?.mirror(sessionId, clientId, action);
|
|
1392
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (channel === 'maker:input:move') {
|
|
1396
|
+
const capabilities = capabilitiesNow();
|
|
1397
|
+
const sessionId = String(args[0] ?? '');
|
|
1398
|
+
const clientId = typeof args[1] === 'string' ? args[1] : null;
|
|
1399
|
+
if (sessionId === '' || clientId === null) return invokeError(request, 'BAD_REQUEST', 'maker:input:move needs a session and an item id');
|
|
1400
|
+
// Reordering is presentation: DSH's inbox keeps its own order and a
|
|
1401
|
+
// reordered row would snap back on the next frame, so this Host records
|
|
1402
|
+
// the new order locally and says nothing it cannot keep.
|
|
1403
|
+
capabilities.queueMirror?.moveItem(sessionId, clientId, args[2]);
|
|
1404
|
+
return answerQueueCommand(request, capabilities, sessionId);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (channel === 'maker:send') {
|
|
1408
|
+
const send = capabilitiesNow().sendMessage;
|
|
1409
|
+
if (typeof send !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot send prompts yet');
|
|
1410
|
+
const sessionId = String(args[0] ?? '');
|
|
1411
|
+
const text = extractSendText(args[1]);
|
|
1412
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:send needs a session id');
|
|
1413
|
+
const sendAttachments = extractSendAttachments(args[1]);
|
|
1414
|
+
// Refusing beats sending an empty prompt: the controller would show the
|
|
1415
|
+
// message as sent while DSH had nothing to run. A photo with no caption is
|
|
1416
|
+
// still something to run, so only "nothing at all" is refused.
|
|
1417
|
+
if (text === null && sendAttachments.length === 0) {
|
|
1418
|
+
return invokeError(request, 'BAD_REQUEST', 'maker:send carried no text and no attachment this Host can serve');
|
|
1419
|
+
}
|
|
1420
|
+
// The controller's own `clientId` is the prompt identity on this path too.
|
|
1421
|
+
// DSH persists it as the message's `rpcId`, which is what the controller
|
|
1422
|
+
// retires its local submission echo on — send the relay's envelope id and
|
|
1423
|
+
// the echo never clears, so the message sits "sending" and then vanishes.
|
|
1424
|
+
const clientId = typeof args[1]?.clientId === 'string' && args[1].clientId !== ''
|
|
1425
|
+
? args[1].clientId
|
|
1426
|
+
: (typeof args[2]?.clientId === 'string' && args[2].clientId !== '' ? args[2].clientId : null);
|
|
1427
|
+
const sent = await send({
|
|
1428
|
+
sessionId,
|
|
1429
|
+
text: text ?? '',
|
|
1430
|
+
requestId: clientId ?? request.id,
|
|
1431
|
+
...(sendAttachments.length > 0 ? { attachments: sendAttachments } : {}),
|
|
1432
|
+
});
|
|
1433
|
+
if (sent !== undefined && sent !== null && sent.ok === false) {
|
|
1434
|
+
return invokeError(request, sent.code ?? 'ATTACHMENT_UNAVAILABLE', sent.message ?? 'the attachment could not be fetched');
|
|
1435
|
+
}
|
|
1436
|
+
return invokeResult(request, { accepted: true });
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Approvals. The questions are DSH's own pending `approval/request`s, routed
|
|
1440
|
+
// here only while a controller is watching the asking session.
|
|
1441
|
+
if (channel === 'maker:get-context-usage') {
|
|
1442
|
+
// How full the session's context is. The controller reads the answer
|
|
1443
|
+
// leniently (`totalTokens`/`contextTokens`, `maxTokens`/`contextWindow`,
|
|
1444
|
+
// `percent`) and renders "暂无上下文数据" for anything unusable — so an unknown
|
|
1445
|
+
// measurement is reported as `null` rather than as a zero, which would read
|
|
1446
|
+
// as "empty context".
|
|
1447
|
+
const usage = capabilitiesNow().contextUsage;
|
|
1448
|
+
if (typeof usage !== 'function') return invokeResult(request, null);
|
|
1449
|
+
const sessionId = String(args[0] ?? '');
|
|
1450
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:get-context-usage needs a session id');
|
|
1451
|
+
try {
|
|
1452
|
+
return invokeResult(request, (await usage({ sessionId })) ?? null);
|
|
1453
|
+
} catch {
|
|
1454
|
+
// A measurement that fails costs the panel, never the session.
|
|
1455
|
+
return invokeResult(request, null);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (channel === 'maker:regenerate-title') {
|
|
1460
|
+
// The controller sends `[{ sessionId }]` here — an object, unlike the bare
|
|
1461
|
+
// session-id string every other channel uses — and it reads the answer as
|
|
1462
|
+
// `{ title: string | null }`: this call only produces a title, and the
|
|
1463
|
+
// controller persists it through `local-db:sessions:patch-meta`. So "no title"
|
|
1464
|
+
// is a value, not an error frame.
|
|
1465
|
+
const sessionId = typeof args[0]?.sessionId === 'string' ? args[0].sessionId : '';
|
|
1466
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:regenerate-title needs a session id');
|
|
1467
|
+
const regenerate = capabilitiesNow().regenerateTitle;
|
|
1468
|
+
if (typeof regenerate !== 'function') return invokeResult(request, { title: null });
|
|
1469
|
+
try {
|
|
1470
|
+
return invokeResult(request, (await regenerate({ sessionId })) ?? { title: null });
|
|
1471
|
+
} catch {
|
|
1472
|
+
// A failed generation is the controller's documented "no title" outcome.
|
|
1473
|
+
return invokeResult(request, { title: null });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (channel === 'maker:get-pending-interactions') {
|
|
1478
|
+
const list = capabilitiesNow().listPendingInteractions;
|
|
1479
|
+
if (typeof list !== 'function') return invokeResult(request, []);
|
|
1480
|
+
const sessionId = typeof args[0] === 'string' && args[0] !== '' ? args[0] : undefined;
|
|
1481
|
+
return invokeResult(request, list(sessionId));
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
if (channel === 'maker:resolve-interaction') {
|
|
1485
|
+
const resolve = capabilitiesNow().resolveInteraction;
|
|
1486
|
+
if (typeof resolve !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot answer interactions');
|
|
1487
|
+
const requestId = typeof args[0] === 'string' ? args[0] : '';
|
|
1488
|
+
const decision = args[1] !== null && typeof args[1] === 'object' ? args[1] : {};
|
|
1489
|
+
if (requestId === '') return invokeError(request, 'BAD_REQUEST', 'maker:resolve-interaction needs a request id');
|
|
1490
|
+
// An unknown id or a decision this Host cannot map is refused rather than
|
|
1491
|
+
// acknowledged: the controller keeps the card open instead of believing it
|
|
1492
|
+
// was answered.
|
|
1493
|
+
return invokeResult(request, resolve(requestId, decision));
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (channel === 'maker:goal:get-status') {
|
|
1497
|
+
// Answered from the session projection stream, so a status read never
|
|
1498
|
+
// resumes a cold session to look at it.
|
|
1499
|
+
const sessionId = String(args[0] ?? '');
|
|
1500
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', 'maker:goal:get-status needs a session id');
|
|
1501
|
+
const capabilities = capabilitiesNow();
|
|
1502
|
+
// Read the projection itself before trusting the stream: the pushed frames
|
|
1503
|
+
// are not guaranteed to arrive, and a goal that exists while every status
|
|
1504
|
+
// read answers `null` is a card that never appears.
|
|
1505
|
+
if (typeof capabilities.readSessionState === 'function') {
|
|
1506
|
+
const state = await capabilities.readSessionState(sessionId);
|
|
1507
|
+
// A registered key answers authoritatively, `null` value included.
|
|
1508
|
+
if (state !== null && state !== undefined && state.hasGoalKey === true) {
|
|
1509
|
+
return invokeResult(request, toGoalStatusPayload(sessionId, state.goal ?? null) ?? null);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const goalStatus = capabilities.goalStatus;
|
|
1513
|
+
const folded = typeof goalStatus === 'function' ? goalStatus(sessionId) : undefined;
|
|
1514
|
+
if (folded !== undefined) return invokeResult(request, folded ?? null);
|
|
1515
|
+
// Nothing could answer, and the controller reads that difference exactly:
|
|
1516
|
+
// `undefined` (a payload with no `result` field) means "unknown, leave the
|
|
1517
|
+
// card alone", while an explicit `null` means "confirmed no goal" and wipes
|
|
1518
|
+
// it. Answering `null` here is how a live goal disappeared from the phone:
|
|
1519
|
+
// the projection read times out or the key is not registered, and because a
|
|
1520
|
+
// goal that has not changed never pushes again, nothing brought it back.
|
|
1521
|
+
return invokeResult(request, undefined);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Goal writes. Unlike the status read above these cannot be answered from
|
|
1525
|
+
// the projection: DSH applies them as compare-and-set mutations on a live
|
|
1526
|
+
// `Agent`, so each one resolves the session. That is acceptable for an
|
|
1527
|
+
// action the user just took and is why the read path stays separate.
|
|
1528
|
+
if (GOAL_WRITE_CHANNELS.has(channel)) {
|
|
1529
|
+
const capabilities = capabilitiesNow();
|
|
1530
|
+
const write = capabilities.goalWrite;
|
|
1531
|
+
if (typeof write !== 'object' || write === null) {
|
|
1532
|
+
return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no goal service');
|
|
1533
|
+
}
|
|
1534
|
+
const sessionId = String(args[0]?.sessionId ?? args[0] ?? '');
|
|
1535
|
+
if (sessionId === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a session id`);
|
|
1536
|
+
|
|
1537
|
+
let outcome;
|
|
1538
|
+
if (channel === 'maker:goal:set') {
|
|
1539
|
+
outcome = await write.set({ sessionId, objective: args[0]?.objective, limits: args[0]?.limits });
|
|
1540
|
+
} else if (channel === 'maker:goal:update') {
|
|
1541
|
+
outcome = await write.update({ sessionId, patch: args[0]?.patch });
|
|
1542
|
+
} else {
|
|
1543
|
+
// `pause` / `resume` / `clear` take the bare session id.
|
|
1544
|
+
outcome = await write[channel.slice('maker:goal:'.length)](sessionId);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
if (outcome?.ok !== true) {
|
|
1548
|
+
// The service's own code is passed through: `GOAL_ALREADY_EXISTS` and
|
|
1549
|
+
// `GOAL_INVALID_TRANSITION` say exactly what went wrong, and the
|
|
1550
|
+
// controller shows the message it is given.
|
|
1551
|
+
return invokeError(request, outcome?.code ?? 'THREW', outcome?.message ?? 'the goal write failed');
|
|
1552
|
+
}
|
|
1553
|
+
// The controller refetches status after a write, but a second screen
|
|
1554
|
+
// watching this session has no reason to — this is its signal.
|
|
1555
|
+
const push = capabilities.pushGoalStatus;
|
|
1556
|
+
if (typeof push === 'function') push(sessionId, outcome.status ?? null);
|
|
1557
|
+
return invokeResult(request, outcome.status ?? null);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
if (channel === 'maker:git-safety:get') {
|
|
1561
|
+
// Git auto-snapshot is a Cindy desktop feature that guards its Rewind
|
|
1562
|
+
// entry. This Host has no such feature, so the honest answer is "off" —
|
|
1563
|
+
// refusing instead would look like a broken setting rather than an absent
|
|
1564
|
+
// one, and the controller reads these three booleans positionally.
|
|
1565
|
+
return invokeResult(request, { autoSnapshotEnabled: false, isCustomized: false, defaultAutoSnapshotEnabled: false });
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (channel === 'maker:input:get-projection') {
|
|
1569
|
+
const capabilities = capabilitiesNow();
|
|
1570
|
+
const sessionId = String(args[0] ?? '');
|
|
1571
|
+
// Read what DSH actually holds before falling back to the fold: a fold that
|
|
1572
|
+
// never heard a `queue` frame reports an empty queue, which is how the
|
|
1573
|
+
// controller came to keep a 队列中 row for a message the agent had answered.
|
|
1574
|
+
if (sessionId !== '' && typeof capabilities.readSessionState === 'function' && typeof capabilities.projectionFromItems === 'function') {
|
|
1575
|
+
const state = await capabilities.readSessionState(sessionId);
|
|
1576
|
+
if (state !== null && state !== undefined) {
|
|
1577
|
+
return invokeResult(request, capabilities.projectionFromItems(sessionId, queueItemsFromInbox(state.inbox)));
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
const project = capabilities.inputProjection;
|
|
1581
|
+
// The controller requests this on every session open, so it must answer
|
|
1582
|
+
// even when the queue is empty: an empty projection is the honest answer,
|
|
1583
|
+
// and refusing it would look like a broken composer.
|
|
1584
|
+
if (typeof project !== 'function' || sessionId === '') {
|
|
1585
|
+
return invokeResult(request, { sessionId, pendingQueue: [], steeringQueueClientIds: [] });
|
|
1586
|
+
}
|
|
1587
|
+
return invokeResult(request, project(sessionId));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// The aggregated remote file browser. One channel, dispatched by `op`; the
|
|
1591
|
+
// ops this Host does not serve answer `{ok:false, message}` the way the
|
|
1592
|
+
// reference host answers an op it does not know, rather than looking empty.
|
|
1593
|
+
if (channel === 'file-browser:remote-op') {
|
|
1594
|
+
const capabilities = capabilitiesNow();
|
|
1595
|
+
const op = args[0]?.op;
|
|
1596
|
+
// Whole-file access is Host-owned; never reinterpret nested SSH as local storage.
|
|
1597
|
+
if (op === 'caps' && typeof capabilities.fileUrl === 'function') {
|
|
1598
|
+
return invokeResult(request, { ok: true, fileRead: true });
|
|
1599
|
+
}
|
|
1600
|
+
if (op === 'fileUrl') {
|
|
1601
|
+
if (typeof capabilities.fileUrl !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This Host has no whole-file reader');
|
|
1602
|
+
return invokeResult(request, await capabilities.fileUrl({ workdir: args[0]?.workdir, relPath: args[0]?.relPath }));
|
|
1603
|
+
}
|
|
1604
|
+
// The two export ops are the controller's only way to *see* a picture that lives on
|
|
1605
|
+
// this machine — a file the agent drew, or any image in the file browser. The
|
|
1606
|
+
// reference controlled end answers `exportFileStart` immediately with a transfer id
|
|
1607
|
+
// and uploads to the shared staging area in the background, then `exportFileStatus`
|
|
1608
|
+
// reports `{state, key}`; the controller presign-gets the key itself. They are
|
|
1609
|
+
// Host-owned rather than browser-owned because the upload needs this runtime's
|
|
1610
|
+
// account session — the same reason attachment fetching lives there.
|
|
1611
|
+
if (op === 'exportFileStart' || op === 'exportFileStatus') {
|
|
1612
|
+
const start = capabilities.exportFileStart;
|
|
1613
|
+
const status = capabilities.exportFileStatus;
|
|
1614
|
+
if (op === 'exportFileStart') {
|
|
1615
|
+
if (typeof start !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot export files yet');
|
|
1616
|
+
const answered = await start({ workdir: args[0]?.workdir, relPath: args[0]?.relPath,
|
|
1617
|
+
...(Object.hasOwn(args[0] ?? {}, 'maxBytes') ? { maxBytes: args[0].maxBytes } : {})
|
|
1618
|
+
}, { src: request.src, id: request.id });
|
|
1619
|
+
return answered?.ok === true ? invokeResult(request, answered) : invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'file export failed');
|
|
1620
|
+
}
|
|
1621
|
+
if (typeof status !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot export files yet');
|
|
1622
|
+
const answered = status({ transferId: args[0]?.transferId }, { src: request.src, id: request.id });
|
|
1623
|
+
return answered?.ok === true ? invokeResult(request, answered) : invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'unknown transfer');
|
|
1624
|
+
}
|
|
1625
|
+
const makeBrowser = capabilities.fileBrowser;
|
|
1626
|
+
const browser = typeof makeBrowser === 'function' ? makeBrowser() : undefined;
|
|
1627
|
+
if (browser === undefined || browser === null) {
|
|
1628
|
+
return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host composes no filesystem');
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
return invokeResult(request, await browser(args[0]));
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
return invokeError(request, 'THREW', String(error?.message ?? error));
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// The work-grouped history window. The controller only asks for these after seeing
|
|
1638
|
+
// `history-view-v1` in this Host's `link-accept`, and it falls back to the raw
|
|
1639
|
+
// `local-db:messages:list` window on any error it recognises as "no view here"
|
|
1640
|
+
// (`isHistoryViewUnavailable`), which is what `NOT_AVAILABLE` and
|
|
1641
|
+
// `UNSUPPORTED_CAPABILITY` both are.
|
|
1642
|
+
if (channel === 'local-db:messages:view' || channel === 'local-db:messages:work-details' || channel === 'local-db:messages:view-intent') {
|
|
1643
|
+
const view = capabilitiesNow().historyView;
|
|
1644
|
+
if (view === undefined || view === null) return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host projects no session history view');
|
|
1645
|
+
const sessionId = typeof args[0] === 'string' ? args[0] : '';
|
|
1646
|
+
const answered = channel === 'local-db:messages:view'
|
|
1647
|
+
? await view.page(sessionId, args[1]?.before)
|
|
1648
|
+
: channel === 'local-db:messages:work-details'
|
|
1649
|
+
? await view.details(sessionId, args[1], args[2]?.after)
|
|
1650
|
+
: await view.intent(sessionId, args[1]);
|
|
1651
|
+
// Pending bodies belong in InputProjection, never synthetic user history.
|
|
1652
|
+
return answered?.ok === true
|
|
1653
|
+
? invokeResult(request, answered.result)
|
|
1654
|
+
: invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'history view failed');
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// A controller asking this machine for one of its files: an image the agent produced,
|
|
1658
|
+
// referenced by path in the transcript. The answer is a staged `ossKey` the controller
|
|
1659
|
+
// presign-gets itself, exactly as this Host does for a photo the phone uploaded.
|
|
1660
|
+
if (channel === 'device-link:file-peer') {
|
|
1661
|
+
const filePeer = capabilitiesNow().filePeer;
|
|
1662
|
+
if (typeof filePeer !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This Host has no file-peer transport');
|
|
1663
|
+
try {
|
|
1664
|
+
// Identity comes from the relay envelope, never from controller-supplied args.
|
|
1665
|
+
return invokeResult(request, await filePeer(request.src, args[0]));
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
const code = typeof error?.message === 'string' && /^(FILE_PEER_[A-Z_]+|INVALID_FILE_PEER_REQUEST|BAD_REQUEST|FORBIDDEN|NOT_FOUND|NOT_AVAILABLE)$/.test(error.message) ? error.message : 'FILE_PEER_FAILED';
|
|
1668
|
+
return invokeError(request, code, code);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (channel === 'device-link:media:fetch') {
|
|
1673
|
+
const fetchMedia = capabilitiesNow().fetchLocalMedia;
|
|
1674
|
+
if (typeof fetchMedia !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot serve local media');
|
|
1675
|
+
// `skipCache` is the controller saying "the object this key named is gone, stage it
|
|
1676
|
+
// again": it must bypass the staging cache rather than be dropped on the floor, or a
|
|
1677
|
+
// deleted object would stay unmaterializable for the cache's whole lifetime.
|
|
1678
|
+
const answered = await fetchMedia({
|
|
1679
|
+
url: args[0]?.url,
|
|
1680
|
+
thumbnail: args[0]?.thumbnail === true,
|
|
1681
|
+
skipCache: args[0]?.skipCache === true,
|
|
1682
|
+
...(args[0]?.prepareOnly === true ? { prepareOnly: true } : {}),
|
|
1683
|
+
});
|
|
1684
|
+
return answered?.ok === true
|
|
1685
|
+
? invokeResult(request, answered.result)
|
|
1686
|
+
: invokeError(request, answered?.code ?? 'INTERNAL', answered?.message ?? 'media fetch failed');
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// File reads. Both families the controller uses take an absolute path and
|
|
1690
|
+
// answer in its own vocabulary; the translation lives in `host-files.js`.
|
|
1691
|
+
if (channel === 'fs:stat-path' || channel === 'fs:list-dir' || channel === 'text-file:read-preview') {
|
|
1692
|
+
const files = capabilitiesNow().files;
|
|
1693
|
+
if (typeof files !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot read files yet');
|
|
1694
|
+
const reader = files();
|
|
1695
|
+
const path = channel === 'text-file:read-preview'
|
|
1696
|
+
? (args[0]?.filePath ?? args[0]?.path)
|
|
1697
|
+
: args[0]?.path;
|
|
1698
|
+
if (typeof path !== 'string' || path.trim() === '') return invokeError(request, 'BAD_REQUEST', `${channel} needs a path`);
|
|
1699
|
+
if (channel === 'fs:stat-path') return invokeResult(request, await reader.statPath(path));
|
|
1700
|
+
if (channel === 'fs:list-dir') return invokeResult(request, await reader.listDir(path));
|
|
1701
|
+
return invokeResult(request, await reader.readTextPreview(path));
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Fail closed and say so. The controller degrades on CHANNEL_NOT_ALLOWED.
|
|
1705
|
+
return invokeError(request);
|
|
1706
|
+
};
|
|
1707
|
+
}
|