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,1932 @@
|
|
|
1
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { DEFAULT_HOST_SETTINGS, SETTINGS_NAMESPACE, validateHostSettings } from './host-settings.js';
|
|
4
|
+
import { DshHostSource } from './dsh-host-source.js';
|
|
5
|
+
import { createSessionControllerSource } from './dsh-session-source.js';
|
|
6
|
+
import { readActiveSessionSummaries } from './dsh-active-sessions.js';
|
|
7
|
+
import { createMessageCounter, createMessageReader, foldSessionEvent, isHarnessNotice, promptRpcIdOf } from './dsh-message-fold.js';
|
|
8
|
+
import { createHistoryViewController } from './host-history-view.js';
|
|
9
|
+
import { createFileReader } from './host-files.js';
|
|
10
|
+
import { createFileBrowser } from './host-file-browser.js';
|
|
11
|
+
import { createAttachmentMaterializer } from './host-attachments.js';
|
|
12
|
+
import { createGoalWriter } from './host-goals.js';
|
|
13
|
+
import { startHost } from './host.js';
|
|
14
|
+
import { refusalError } from './cindy-channels.js';
|
|
15
|
+
import { hydrateImageAttachments } from './cindy-message-row.js';
|
|
16
|
+
import { API_PREFIX, createHostRoutes } from './host-routes.js';
|
|
17
|
+
|
|
18
|
+
export const name = 'dsh-cindy-host';
|
|
19
|
+
export const inject = ['settings'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Brand a string as a settings namespace.
|
|
23
|
+
*
|
|
24
|
+
* This used to be `import { settingsNamespace } from '@deepseek-ai/dsh-settings'`, and that import
|
|
25
|
+
* is why the plugin could not be installed on a current DSH: the helper exists in
|
|
26
|
+
* `0.1.1-rc.2` but **is not exported by `0.1.5-rc.2`** (whose surface is `SettingsProvider`,
|
|
27
|
+
* `SettingsConflictError`, `redactSecrets`, `default`). Keeping it meant declaring an old DSH
|
|
28
|
+
* generation as a dependency, and installing that generation into a profile is what broke the
|
|
29
|
+
* host's own composition (`dsh-client-file-upload` / `dsh-session` mismatch → profile will not
|
|
30
|
+
* boot). See `doc/publishing.md` §5.6.
|
|
31
|
+
*
|
|
32
|
+
* The helper is a brand, not behaviour — validate, return the string — so it lives here now and
|
|
33
|
+
* costs nothing: the namespace is the value, the pattern is the contract.
|
|
34
|
+
*/
|
|
35
|
+
const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
36
|
+
function settingsNamespace(value) {
|
|
37
|
+
if (!SETTINGS_NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
const ControllerSchema = Schema.object({ state: Schema.union(['authorized', 'revoked']), displayName: Schema.string(), grantedAt: Schema.string(), revokedAt: Schema.string(), grantRevision: Schema.natural() });
|
|
41
|
+
// The archive/delete/pin flags a controller writes: this Host's own answer to three
|
|
42
|
+
// actions DSH has no concept of. `pinnedAt: ''` is a remembered unpin (settings
|
|
43
|
+
// reject `null`, and an absent field cannot be told apart from "never pinned").
|
|
44
|
+
const SessionFlagSchema = Schema.object({ status: Schema.union(['active', 'archived', 'deleted']), pinnedAt: Schema.string() });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* This bundle's settings schema.
|
|
48
|
+
*
|
|
49
|
+
* Each field restates the matching `DEFAULT_HOST_SETTINGS` entry, so a profile that has never
|
|
50
|
+
* written a value still resolves a complete object rather than an empty one.
|
|
51
|
+
*/
|
|
52
|
+
const HostSettingsSchema = Schema.object({
|
|
53
|
+
transportEnabled: Schema.boolean().default(DEFAULT_HOST_SETTINGS.transportEnabled),
|
|
54
|
+
remoteControlEnabled: Schema.boolean().default(DEFAULT_HOST_SETTINGS.remoteControlEnabled),
|
|
55
|
+
controllers: Schema.dict(ControllerSchema).default({}),
|
|
56
|
+
deviceId: Schema.string().default(DEFAULT_HOST_SETTINGS.deviceId),
|
|
57
|
+
sessionFlags: Schema.dict(SessionFlagSchema).default({}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The schema DSH projects as this entry's settings surface.
|
|
62
|
+
*
|
|
63
|
+
* DSH 0.2 reads a plugin **entry's own `Config`** (`SettingsForms.describe()`, keyed by profile
|
|
64
|
+
* entry id) and accepts a write only to a field beneath a **volatile** node, so the root is
|
|
65
|
+
* marked volatile when the running schemastery supports it: one volatile root is the exact
|
|
66
|
+
* equivalent of the namespace-wide registration that 0.2 removed (see
|
|
67
|
+
* {@link createSettingsScope}).
|
|
68
|
+
*
|
|
69
|
+
* `volatile()` is **feature-detected on purpose**: schemastery `3.18.2` — the copy DSH
|
|
70
|
+
* `0.1.5-rc.2` resolves, and still inside this bundle's declared `^3.18.1` peer range — has no
|
|
71
|
+
* such method, so calling it unconditionally would throw while this module loads and take the
|
|
72
|
+
* whole profile down. Without it the plain schema still serves the 0.1.x `settings.register`
|
|
73
|
+
* path, which does not need the marker.
|
|
74
|
+
*/
|
|
75
|
+
const HostSchema = typeof HostSettingsSchema.volatile === 'function' ? HostSettingsSchema.volatile() : HostSettingsSchema;
|
|
76
|
+
|
|
77
|
+
/** The plugin entry's settings surface; see {@link createSettingsScope}. */
|
|
78
|
+
export const Config = HostSchema;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Services that can supply the DSH session read source, in preference order.
|
|
82
|
+
* `sessionController` is what current DSH Web mounts; `apiProxy` was removed
|
|
83
|
+
* from it, so that branch is last and optional.
|
|
84
|
+
*/
|
|
85
|
+
const SOURCE_SERVICES = ['sessionController', 'apiProxy'];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The plan-mode module an agent preset mounts.
|
|
89
|
+
*
|
|
90
|
+
* This deployment does **not** compose plan mode on the host plane: the Web
|
|
91
|
+
* bundle disables the base `plan-mode` row and each preset that wants it mounts
|
|
92
|
+
* this module inside its own agent context. So the presence of the service is a
|
|
93
|
+
* per-preset fact, and the only honest places to read it are the agent's own
|
|
94
|
+
* context and the preset composition.
|
|
95
|
+
*/
|
|
96
|
+
const PLAN_MODE_MODULE = '@deepseek-ai/dsh-plan-mode';
|
|
97
|
+
|
|
98
|
+
/** How long a preset-composition read is reused, in milliseconds. */
|
|
99
|
+
const PRESET_INVENTORY_TTL_MS = 30_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the DSH read source once its supplying service exists.
|
|
103
|
+
*
|
|
104
|
+
* Both candidates are provided by *other* plugins, so `apply` cannot read them
|
|
105
|
+
* synchronously: doing that is what left the original bundle with a permanently
|
|
106
|
+
* undefined source and an empty session list. `ctx.inject` waits for the service
|
|
107
|
+
* and re-runs on removal, which is the only correct way to consume it.
|
|
108
|
+
* @param ctx - the plugin context that owns the service.
|
|
109
|
+
* @param serviceName - which candidate became available.
|
|
110
|
+
* @returns the source and the label naming its seam.
|
|
111
|
+
*/
|
|
112
|
+
export function buildDshSource(ctx, serviceName, options = {}) {
|
|
113
|
+
if (serviceName === 'sessionController') {
|
|
114
|
+
const sessionController = ctx.get('sessionController');
|
|
115
|
+
const sessionQuery = ctx.get('sessionQuery');
|
|
116
|
+
/**
|
|
117
|
+
* The Workspace that owns a new session's directory, or undefined.
|
|
118
|
+
*
|
|
119
|
+
* Read per call and never captured: the registry is another plugin's service and may
|
|
120
|
+
* activate after this seam is built (the same reason every other seam here resolves
|
|
121
|
+
* late). `create` is DSH's own "create or reuse" for a directory, so a known folder
|
|
122
|
+
* reuses its record and a genuinely new one is registered — which is what makes the
|
|
123
|
+
* session appear where the desk expects it.
|
|
124
|
+
*/
|
|
125
|
+
async function workspaceForNewSession(cwd) {
|
|
126
|
+
if (cwd === undefined) return undefined;
|
|
127
|
+
const registry = ctx.get('workspaceRegistry');
|
|
128
|
+
if (registry === undefined || registry === null || typeof registry.create !== 'function') return undefined;
|
|
129
|
+
try {
|
|
130
|
+
const workspace = await registry.create(cwd);
|
|
131
|
+
return workspace !== null && workspace !== undefined && typeof workspace.id === 'string' && workspace.id !== ''
|
|
132
|
+
? workspace
|
|
133
|
+
: undefined;
|
|
134
|
+
} catch {
|
|
135
|
+
// A path that cannot back a Workspace — relative, missing, a file — keeps the
|
|
136
|
+
// session creatable; it simply will not be grouped on the desk.
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The optional session-control services, brought up explicitly.
|
|
142
|
+
*
|
|
143
|
+
* Cordis activates a service when something *injects* it; a plain `get` on a
|
|
144
|
+
* registered-but-idle service answers `undefined`. Reading them that way is
|
|
145
|
+
* how plan mode looked uncomposed on a profile that plainly composes it — the
|
|
146
|
+
* same trap the original `apiProxy` seam fell into. Neither is required, so
|
|
147
|
+
* each keeps its own availability instead of failing the whole Host.
|
|
148
|
+
*/
|
|
149
|
+
const controls = { planMode: undefined, permissionPresets: undefined, fileUploads: undefined, tokenMeter: undefined, llm: undefined, sessionTitle: undefined, skills: undefined };
|
|
150
|
+
ctx.inject(['planMode'], (planCtx) => {
|
|
151
|
+
controls.planMode = planCtx.get('planMode');
|
|
152
|
+
return () => { controls.planMode = undefined; };
|
|
153
|
+
});
|
|
154
|
+
ctx.inject(['permissionPresets'], (presetCtx) => {
|
|
155
|
+
controls.permissionPresets = presetCtx.get('permissionPresets');
|
|
156
|
+
return () => { controls.permissionPresets = undefined; };
|
|
157
|
+
});
|
|
158
|
+
// File attachments ride a receipt minted by the Session upload owner; without
|
|
159
|
+
// this service a file can still be read, but it cannot become a prompt part.
|
|
160
|
+
ctx.inject(['fileUploads'], (uploadCtx) => {
|
|
161
|
+
controls.fileUploads = uploadCtx.get('fileUploads');
|
|
162
|
+
return () => { controls.fileUploads = undefined; };
|
|
163
|
+
});
|
|
164
|
+
// The context meter and the model route: one measures the live session, the
|
|
165
|
+
// other discloses the routed model's context window. Both are optional, and
|
|
166
|
+
// each keeps its own absence.
|
|
167
|
+
ctx.inject(['tokenMeter'], (meterCtx) => {
|
|
168
|
+
controls.tokenMeter = meterCtx.get('tokenMeter');
|
|
169
|
+
return () => { controls.tokenMeter = undefined; };
|
|
170
|
+
});
|
|
171
|
+
ctx.inject(['llm'], (llmCtx) => {
|
|
172
|
+
controls.llm = llmCtx.get('llm');
|
|
173
|
+
return () => { controls.llm = undefined; };
|
|
174
|
+
});
|
|
175
|
+
// The title service owns the log-backed title fold and its provider, so the
|
|
176
|
+
// controller's "regenerate title" action goes through it rather than through a
|
|
177
|
+
// second title path of our own.
|
|
178
|
+
ctx.inject(['sessionTitle'], (titleCtx) => {
|
|
179
|
+
controls.sessionTitle = titleCtx.get('sessionTitle');
|
|
180
|
+
return () => { controls.sessionTitle = undefined; };
|
|
181
|
+
});
|
|
182
|
+
// The skill registry. Its answer depends on the *viewing scope*, so merely
|
|
183
|
+
// holding the service is not enough — see `liveAgentFor` below.
|
|
184
|
+
ctx.inject(['skills'], (skillCtx) => {
|
|
185
|
+
controls.skills = skillCtx.get('skills');
|
|
186
|
+
return () => { controls.skills = undefined; };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The viewing scope for one session's registry reads — skills, and the shape
|
|
191
|
+
* the command list beside them already uses.
|
|
192
|
+
*
|
|
193
|
+
* Skills live in layered registries: a preset's standing mount registers into
|
|
194
|
+
* that preset's layer, and a read answers for the viewing scope it is asked
|
|
195
|
+
* about. Reading from this Host's own context therefore names the global layer
|
|
196
|
+
* alone, which is how this menu came to be empty on a profile whose skills are
|
|
197
|
+
* all registered by a preset.
|
|
198
|
+
*
|
|
199
|
+
* The scope key is the **agent object itself**, not a tag read off its context:
|
|
200
|
+
* DSH's own skill tool calls `ctx.skills.list({ scope: agent, cwd })`, and the
|
|
201
|
+
* agent is the scope-carrier key its dispatcher couples the subject to. Asking
|
|
202
|
+
* `scopeOf(agent.ctx)` answers undefined — that context carries no tag — which
|
|
203
|
+
* is exactly how an empty menu survived the first fix attempt.
|
|
204
|
+
*
|
|
205
|
+
* A **live** agent is preferred, and a cold session is then resumed — the same
|
|
206
|
+
* thing the command list next to it does, for the same user action. Measured on
|
|
207
|
+
* this deployment: a cold session answered `skills: 0` and `commands: 6`
|
|
208
|
+
* (resolving the agent is what makes the difference), and the same session
|
|
209
|
+
* answered `skills: 6` once it was live. Both lists describe one session; two
|
|
210
|
+
* different answers there is not purity, it is a missing menu the user sees.
|
|
211
|
+
*
|
|
212
|
+
* A request naming **no** session is the new-task composer, and there the honest
|
|
213
|
+
* answer is what a new session would offer: `agentPresets.standingKeyFor()`,
|
|
214
|
+
* which DSH documents as "the standing scope key of one preset, for a host
|
|
215
|
+
* reader with no agent … ensuring the mount composes plugins but starts no
|
|
216
|
+
* agent, no session, and no turn". A session that *is* named but cannot be
|
|
217
|
+
* resolved answers nothing: inventing the default composition's skills for an
|
|
218
|
+
* unknown session would be a claim about a session this Host cannot see.
|
|
219
|
+
* @param sessionId - the session whose agent to use as a viewing scope, if any.
|
|
220
|
+
* @returns the viewing scope key, or undefined when none can be named.
|
|
221
|
+
*/
|
|
222
|
+
async function agentScopeFor(sessionId) {
|
|
223
|
+
const named = typeof sessionId === 'string' && sessionId !== '';
|
|
224
|
+
const live = liveAgentFor(sessionId);
|
|
225
|
+
if (live !== undefined) return live;
|
|
226
|
+
if (named) {
|
|
227
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
228
|
+
return resolved?.error === undefined ? resolved.agent : undefined;
|
|
229
|
+
}
|
|
230
|
+
const presets = ctx.get('agentPresets');
|
|
231
|
+
if (typeof presets?.standingKeyFor !== 'function') return undefined;
|
|
232
|
+
try {
|
|
233
|
+
return await presets.standingKeyFor();
|
|
234
|
+
} catch {
|
|
235
|
+
// An unusable default composition is not a reason to fail a menu; the
|
|
236
|
+
// caller then reads the global layer.
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The live agent for one session, when this Host already has one.
|
|
243
|
+
*
|
|
244
|
+
* `ctx.agents.get(id)` answers for live agents only, which is the cheap first
|
|
245
|
+
* probe: it asks nothing of the session store and resumes nothing.
|
|
246
|
+
* @param sessionId - the session to look up.
|
|
247
|
+
* @returns the live agent, or undefined.
|
|
248
|
+
*/
|
|
249
|
+
function liveAgentFor(sessionId) {
|
|
250
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
251
|
+
const agents = ctx.get('agents');
|
|
252
|
+
if (typeof agents?.get !== 'function') return undefined;
|
|
253
|
+
try {
|
|
254
|
+
return agents.get(sessionId) ?? undefined;
|
|
255
|
+
} catch {
|
|
256
|
+
// A registry mid-teardown answers nothing; the caller then reads the
|
|
257
|
+
// global layer rather than failing the palette.
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The plan-mode controller that belongs to one agent, when one is reachable.
|
|
264
|
+
*
|
|
265
|
+
* `ctx.planMode` is unreachable from this context by composition, not by
|
|
266
|
+
* accident: the Web bundle disables the base row and each preset composes
|
|
267
|
+
* the controller inside its own agent scope. Best effort only — the command
|
|
268
|
+
* route below is what actually reaches a preset-owned switch.
|
|
269
|
+
* @param agent - the controller-resolved agent, or undefined.
|
|
270
|
+
* @returns the plan-mode controller for that agent, or undefined.
|
|
271
|
+
*/
|
|
272
|
+
function planModeFor(agent) {
|
|
273
|
+
const scoped = typeof agent?.ctx?.get === 'function' ? agent.ctx.get('planMode') : undefined;
|
|
274
|
+
return scoped === undefined ? controls.planMode : scoped;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The plan-mode controller mounted inside one agent's own scope.
|
|
279
|
+
* @param agent - the controller-resolved agent.
|
|
280
|
+
* @returns the scoped controller, or undefined when the preset mounts none.
|
|
281
|
+
*/
|
|
282
|
+
function planModeInAgentScope(agent) {
|
|
283
|
+
return typeof agent?.ctx?.get === 'function' ? agent.ctx.get('planMode') : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether the agent's own scope registers the `/plan` command.
|
|
288
|
+
*
|
|
289
|
+
* This is the switch that actually exists in this deployment: `dsh-plan-mode`
|
|
290
|
+
* registers `/plan` through the command registry of whatever scope it was
|
|
291
|
+
* mounted in, and the same command list the phone's `/` palette already reads
|
|
292
|
+
* answers for one exact agent. A preset that composes no plan mode registers
|
|
293
|
+
* no such command, so this is the honest per-session test.
|
|
294
|
+
* @param agent - the agent to test.
|
|
295
|
+
* @returns true when `/plan` resolves for that agent.
|
|
296
|
+
*/
|
|
297
|
+
function planCommandAvailable(agent) {
|
|
298
|
+
const commands = ctx.get('commands');
|
|
299
|
+
if (typeof commands?.find !== 'function') return false;
|
|
300
|
+
try {
|
|
301
|
+
return commands.find(agent, 'plan') !== undefined;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Preset-composition reads are reused; the answer changes only on an edit. */
|
|
308
|
+
let presetInventory = { at: 0, value: null };
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Whether the preset a session naming no preset would compose mounts plan
|
|
312
|
+
* mode.
|
|
313
|
+
*
|
|
314
|
+
* Used only when nothing live can answer — a freshly restarted Host has no
|
|
315
|
+
* agent to inspect, and answering "unsupported" there would take the
|
|
316
|
+
* control away from every controller until somebody opened a session. A row
|
|
317
|
+
* reported `'conditional'` (an unevaluated `!!js disabled` expression)
|
|
318
|
+
* counts as mounted: the refusal to evaluate is not evidence of absence.
|
|
319
|
+
* @returns true when the default composition names the plan-mode module.
|
|
320
|
+
*/
|
|
321
|
+
async function presetComposesPlanMode() {
|
|
322
|
+
const presets = ctx.get('agentPresets');
|
|
323
|
+
if (typeof presets?.compositionInventory !== 'function') return false;
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
if (presetInventory.value !== null && now - presetInventory.at < PRESET_INVENTORY_TTL_MS) return presetInventory.value;
|
|
326
|
+
let answer = false;
|
|
327
|
+
try {
|
|
328
|
+
const inventory = await presets.compositionInventory();
|
|
329
|
+
const rows = Array.isArray(inventory) ? inventory : [];
|
|
330
|
+
const target = rows.find((preset) => preset?.isDefault === true) ?? null;
|
|
331
|
+
if (Array.isArray(target?.rows)) {
|
|
332
|
+
answer = target.rows.some((row) => row?.moduleName === PLAN_MODE_MODULE && row.enabled !== false);
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// A composition this Host cannot read is not evidence that it mounts
|
|
336
|
+
// plan mode, so the answer stays false and the control stays hidden.
|
|
337
|
+
answer = false;
|
|
338
|
+
}
|
|
339
|
+
presetInventory = { at: now, value: answer };
|
|
340
|
+
return answer;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Read one session's authoritative projection state.
|
|
345
|
+
*
|
|
346
|
+
* The control stream *pushes* projection changes, but a push that never
|
|
347
|
+
* arrives is indistinguishable from "nothing changed": that is how a queue
|
|
348
|
+
* came to hold a phantom row for a message the agent had already answered.
|
|
349
|
+
* `observeSession` computes the registered projections on demand, so this is
|
|
350
|
+
* the read that can be trusted. The lease is always released.
|
|
351
|
+
* @param sessionId - the session to observe.
|
|
352
|
+
* @returns the projection values this Host consumes, or null when unreadable.
|
|
353
|
+
*/
|
|
354
|
+
async function readSessionStateNow(sessionId) {
|
|
355
|
+
if (sessionQuery === undefined || typeof sessionQuery.observeSession !== 'function') return null;
|
|
356
|
+
let observation;
|
|
357
|
+
try {
|
|
358
|
+
observation = await sessionQuery.observeSession(sessionId, { projectionMode: 'all' });
|
|
359
|
+
const values = observation?.projections?.values;
|
|
360
|
+
if (values === null || typeof values !== 'object') return null;
|
|
361
|
+
return {
|
|
362
|
+
inbox: Object.prototype.hasOwnProperty.call(values, 'inbox') ? values.inbox : undefined,
|
|
363
|
+
goal: Object.prototype.hasOwnProperty.call(values, 'goal') ? values.goal : undefined,
|
|
364
|
+
hasGoalKey: Object.prototype.hasOwnProperty.call(values, 'goal'),
|
|
365
|
+
// What the session is running on. Effort is part of a model selection
|
|
366
|
+
// in DSH, so changing effort means re-stating the model — and this is
|
|
367
|
+
// where that model comes from.
|
|
368
|
+
modelSelection: Object.prototype.hasOwnProperty.call(values, 'modelSelection') ? values.modelSelection : undefined,
|
|
369
|
+
};
|
|
370
|
+
} catch {
|
|
371
|
+
// A projection read that fails costs a fresher answer, never the call.
|
|
372
|
+
return null;
|
|
373
|
+
} finally {
|
|
374
|
+
try {
|
|
375
|
+
observation?.[Symbol.dispose]?.();
|
|
376
|
+
} catch {
|
|
377
|
+
// Releasing a lease must not mask the answer.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The model one session is running on, for a write that must name it.
|
|
384
|
+
*
|
|
385
|
+
* A selection the user made is the authority; `next` is the one the coming
|
|
386
|
+
* request will use, and `lastUsed` is the fallback for a session whose
|
|
387
|
+
* pending selection was already consumed. The catalog default is the last
|
|
388
|
+
* resort, for a session that has never chosen.
|
|
389
|
+
* @param sessionId - the session to read.
|
|
390
|
+
* @returns `{ provider, model }`, or null when nothing can name it.
|
|
391
|
+
*/
|
|
392
|
+
async function currentSelection(sessionId) {
|
|
393
|
+
const state = await readSessionStateNow(sessionId);
|
|
394
|
+
const selection = state?.modelSelection?.next ?? state?.modelSelection?.lastUsed ?? null;
|
|
395
|
+
if (typeof selection?.provider === 'string' && selection.provider !== ''
|
|
396
|
+
&& typeof selection?.model === 'string' && selection.model !== '') {
|
|
397
|
+
return {
|
|
398
|
+
provider: selection.provider,
|
|
399
|
+
model: selection.model,
|
|
400
|
+
// The effort the session already runs on belongs here: changing effort means
|
|
401
|
+
// re-stating the whole selection, and "set it to what it already is" has to
|
|
402
|
+
// be recognisable as a no-op (see `setEffort`).
|
|
403
|
+
...(typeof selection.reasoningEffort === 'string' && selection.reasoningEffort !== ''
|
|
404
|
+
? { reasoningEffort: selection.reasoningEffort }
|
|
405
|
+
: {}),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const catalog = await sessionController.modelCatalog();
|
|
410
|
+
const fallback = catalog?.default;
|
|
411
|
+
if (typeof fallback?.provider === 'string' && fallback.provider !== ''
|
|
412
|
+
&& typeof fallback?.model === 'string' && fallback.model !== '') {
|
|
413
|
+
return { provider: fallback.provider, model: fallback.model };
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// A failed catalog read leaves the caller to refuse rather than guess.
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The reasoning efforts the catalog declares for one model route.
|
|
423
|
+
*
|
|
424
|
+
* `null` means "this Host cannot say" — a catalog that never loaded, or a route it
|
|
425
|
+
* does not list — and the caller then lets DSH answer rather than inventing a
|
|
426
|
+
* refusal for a model it has not read about.
|
|
427
|
+
* @param selection - `{ provider, model }`.
|
|
428
|
+
* @returns the effort ids, or null when unknown.
|
|
429
|
+
*/
|
|
430
|
+
async function supportedEfforts(selection) {
|
|
431
|
+
try {
|
|
432
|
+
const catalog = await sessionController.modelCatalog();
|
|
433
|
+
const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
|
|
434
|
+
const group = groups.find((entry) => entry?.id === selection.provider);
|
|
435
|
+
const model = (Array.isArray(group?.models) ? group.models : []).find((entry) => entry?.id === selection.model);
|
|
436
|
+
const efforts = Array.isArray(model?.reasoning?.efforts) ? model.reasoning.efforts : [];
|
|
437
|
+
const ids = efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '');
|
|
438
|
+
return ids.length > 0 ? ids : null;
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Install one session-local model selection, resolving the provider when the
|
|
446
|
+
* caller has none.
|
|
447
|
+
*
|
|
448
|
+
* The controller's model option carries no provider id — the catalog flattens
|
|
449
|
+
* models out of their provider groups — so a choice usually arrives with the
|
|
450
|
+
* model alone. This Host's own catalog is the authority on which provider
|
|
451
|
+
* serves it, and guessing would risk switching to a different model than the
|
|
452
|
+
* user picked.
|
|
453
|
+
*
|
|
454
|
+
* DSH's `session.selectModel` is the only write that installs a selection, and
|
|
455
|
+
* it has two effects by design: the session's coming request uses it **and** the
|
|
456
|
+
* profile's `agent-default-model` is updated — exactly what the desk's own model
|
|
457
|
+
* picker does. That is why a conversation created from the handset with gpt also
|
|
458
|
+
* becomes the default for sessions no picker has touched yet.
|
|
459
|
+
* @param request - the session, its requested provider/model, and optional effort.
|
|
460
|
+
* @returns DSH's normalized `{ selected }` answer.
|
|
461
|
+
*/
|
|
462
|
+
async function applyModelSelection({ sessionId, provider, model, reasoningEffort }) {
|
|
463
|
+
let resolved = provider;
|
|
464
|
+
if (typeof resolved !== 'string' || resolved === '') {
|
|
465
|
+
const catalog = await sessionController.modelCatalog();
|
|
466
|
+
const groups = Array.isArray(catalog?.groups) ? catalog.groups : [];
|
|
467
|
+
resolved = groups.find((group) => (Array.isArray(group?.models) ? group.models : [])
|
|
468
|
+
.some((entry) => entry?.id === model))?.id;
|
|
469
|
+
}
|
|
470
|
+
if (typeof resolved !== 'string' || resolved === '') {
|
|
471
|
+
throw refusalError('NOT_AVAILABLE', "no provider in this Host's catalog serves " + model);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
return await sessionController.selectModel({
|
|
475
|
+
sessionId,
|
|
476
|
+
provider: resolved,
|
|
477
|
+
model,
|
|
478
|
+
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
479
|
+
});
|
|
480
|
+
} catch (error) {
|
|
481
|
+
// DSH refuses a route it cannot resolve with `session/model-unavailable`.
|
|
482
|
+
// Passed through raw that reaches the controller as `THREW`, which it reads
|
|
483
|
+
// as this Host crashing rather than as "that model is not usable here" — the
|
|
484
|
+
// same misreading the effort refusal was fixed for. Only that refusal is
|
|
485
|
+
// translated; anything else is a genuine failure and travels unchanged.
|
|
486
|
+
if (error?.code === 'session/model-unavailable') {
|
|
487
|
+
throw refusalError('NOT_AVAILABLE', String(error?.message ?? error));
|
|
488
|
+
}
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The permission preset names this Host advertises, in the preset table's order. */
|
|
494
|
+
function advertisedPermissionNames() {
|
|
495
|
+
const presets = controls.permissionPresets;
|
|
496
|
+
return Array.isArray(presets?.names) ? [...presets.names] : [];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Write one permission preset onto one session.
|
|
501
|
+
*
|
|
502
|
+
* The preset is written onto the **session**; the service then drives the
|
|
503
|
+
* sandbox-mode and approval-policy knobs that execution reads. Shared by the
|
|
504
|
+
* explicit `maker:set-permission-mode` channel and the create path, so both
|
|
505
|
+
* install a preset the same way.
|
|
506
|
+
* @param request - the session to write and the preset name to install.
|
|
507
|
+
*/
|
|
508
|
+
async function installPermissionMode({ sessionId, mode }) {
|
|
509
|
+
const presets = controls.permissionPresets;
|
|
510
|
+
if (typeof presets?.set !== 'function') throw new Error('permission presets are not composed on this Host');
|
|
511
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
512
|
+
if (resolved?.error !== undefined) throw resolved.error;
|
|
513
|
+
presets.set(resolved.agent.session, mode);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Install the permission preset a **new** conversation asked for, when this Host
|
|
518
|
+
* advertises it.
|
|
519
|
+
*
|
|
520
|
+
* The controller's permission vocabulary is not this Host's. `maker:get-capabilities`
|
|
521
|
+
* answers `permissionModes` from DSH's own preset table, and the handset coerces its
|
|
522
|
+
* draft into that list (`reconcileRuntimeDraftWithCapabilities`), so an advertised
|
|
523
|
+
* name is the ordinary case and it is installed verbatim. That matters more here than
|
|
524
|
+
* anywhere else on this seam: a user who picks `read-only` and silently receives the
|
|
525
|
+
* profile default of `danger-full-access` believes the agent is restricted while it
|
|
526
|
+
* holds full access.
|
|
527
|
+
*
|
|
528
|
+
* An unadvertised name — the handset's legacy fallback list (`ask`, `acceptEdits`,
|
|
529
|
+
* `plan`, `bypassPermissions`) when its capability read failed — is deliberately
|
|
530
|
+
* **not** translated and deliberately **not** refused. Translating would guess a
|
|
531
|
+
* privilege level on the user's behalf, and refusing would leave a phone whose
|
|
532
|
+
* capability read failed unable to start any conversation at all. It stays at the
|
|
533
|
+
* profile's preset, is logged, and the session row reports what is in force.
|
|
534
|
+
* @param request - the session and the preset name the controller asked for.
|
|
535
|
+
* @returns the installed name, or null when this Host does not advertise it.
|
|
536
|
+
*/
|
|
537
|
+
async function applyPermissionMode({ sessionId, mode }) {
|
|
538
|
+
const advertised = advertisedPermissionNames();
|
|
539
|
+
if (!advertised.includes(mode)) {
|
|
540
|
+
// Loud rather than silent: the log is where an absorbed, unhonoured request is
|
|
541
|
+
// visible, and the advertised list is what makes it actionable.
|
|
542
|
+
ctx.logger?.warn?.(`create-session: this Host advertises ${JSON.stringify(advertised)} but the controller asked for permission mode "${mode}"; keeping the profile preset`);
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
await installPermissionMode({ sessionId, mode });
|
|
546
|
+
return mode;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The routed model's context window, when the provider discloses one.
|
|
551
|
+
*
|
|
552
|
+
* `LlmResolvedModelInfo.context` is optional — a provider that lists an id and
|
|
553
|
+
* nothing else leaves it absent. An absent window is reported as null rather
|
|
554
|
+
* than guessed from the request's `maxTokens` (that is an **output** bound, and
|
|
555
|
+
* using it as a context window would show the user a percentage that means
|
|
556
|
+
* nothing).
|
|
557
|
+
* @param sessionId - the session whose selection names the route.
|
|
558
|
+
* @returns the window in tokens, or null when unknown.
|
|
559
|
+
*/
|
|
560
|
+
async function contextWindowFor(sessionId) {
|
|
561
|
+
const llm = controls.llm;
|
|
562
|
+
if (typeof llm?.resolveModelInfo !== 'function') return null;
|
|
563
|
+
const selection = await currentSelection(sessionId);
|
|
564
|
+
if (selection === null) return null;
|
|
565
|
+
try {
|
|
566
|
+
const resolved = await llm.resolveModelInfo(selection.provider, selection.model);
|
|
567
|
+
const window = resolved?.context?.contextWindow;
|
|
568
|
+
return Number.isFinite(window) && window > 0 ? window : null;
|
|
569
|
+
} catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Message history comes from the raw log, not the projection: the projection
|
|
574
|
+
// carries list rows, and `sessionController.page()` would need a `throughSeq`
|
|
575
|
+
// the Host has no cheap way to learn.
|
|
576
|
+
//
|
|
577
|
+
// An image the phone sent comes back as a durable DSH attachment, and DSH's own
|
|
578
|
+
// reference for it is content-addressed — deliberately neither a path nor a URL
|
|
579
|
+
// ("never a filesystem path or bearer URL"), so there is nothing to hand the
|
|
580
|
+
// controller for rendering. `ctx.attachments.readImage` is the way back to the
|
|
581
|
+
// bytes, and inlining them is what makes the photo appear in the bubble instead of
|
|
582
|
+
// the 没有可展示的远程路径 placeholder. Read per request, so a profile whose
|
|
583
|
+
// attachment service activates later is picked up without a rebuild; absent, the
|
|
584
|
+
// rows simply keep their file chips.
|
|
585
|
+
//
|
|
586
|
+
// Counted, because "the photo does not render" has three indistinguishable causes
|
|
587
|
+
// from the outside — no handle on the block, no attachment service on this profile,
|
|
588
|
+
// or a read that failed — and the counter is what separates them.
|
|
589
|
+
const attachmentReads = { attempted: 0, served: 0, failed: 0 };
|
|
590
|
+
const readImageAttachment = async (handle) => {
|
|
591
|
+
const store = ctx.get('attachments');
|
|
592
|
+
if (store === undefined || typeof store.readImage !== 'function') {
|
|
593
|
+
attachmentReads.failed += 1;
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
attachmentReads.attempted += 1;
|
|
597
|
+
try {
|
|
598
|
+
// The descriptor is passed through **whole**: it is the value DSH stored, and
|
|
599
|
+
// the store validates what it is asked to read.
|
|
600
|
+
const stored = await store.readImage(handle, AbortSignal.timeout(5_000));
|
|
601
|
+
const data = stored?.data;
|
|
602
|
+
if (data === undefined || data === null) {
|
|
603
|
+
attachmentReads.failed += 1;
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
attachmentReads.served += 1;
|
|
607
|
+
return {
|
|
608
|
+
base64: Buffer.from(data).toString('base64'),
|
|
609
|
+
bytes: Number.isFinite(stored?.ref?.bytes) ? stored.ref.bytes : Buffer.from(data).length,
|
|
610
|
+
mediaType: typeof stored?.ref?.mediaType === 'string' ? stored.ref.mediaType : undefined,
|
|
611
|
+
};
|
|
612
|
+
} catch {
|
|
613
|
+
// One unreadable image costs that image, never the page.
|
|
614
|
+
attachmentReads.failed += 1;
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
const readMessages = sessionQuery === undefined
|
|
619
|
+
? undefined
|
|
620
|
+
: createMessageReader({ readSessionLog: (sessionId) => sessionQuery.readSession(sessionId), readImageAttachment });
|
|
621
|
+
/**
|
|
622
|
+
* Turn image handles into bytes for **whatever is about to reach the phone**.
|
|
623
|
+
*
|
|
624
|
+
* One hydrator, three call sites (`messages:list` pages, history-view pages, and the live
|
|
625
|
+
* `messages:created` push), because "the user's photo does not render" has already happened
|
|
626
|
+
* once by hydration living on a single path: it was wired into `messages:list`, the
|
|
627
|
+
* controller moved onto the history view, and every photo went back to a path-less chip.
|
|
628
|
+
* A row is only renderable after this runs, so every path that hands a row to a controller
|
|
629
|
+
* has to run it.
|
|
630
|
+
*/
|
|
631
|
+
const hydrateRows = (rows) => hydrateImageAttachments(rows, { readImage: readImageAttachment });
|
|
632
|
+
/**
|
|
633
|
+
* How many rows one session's transcript holds.
|
|
634
|
+
*
|
|
635
|
+
* The controller only lights its "load earlier" entry point when it knows the
|
|
636
|
+
* total and the total exceeds what it has loaded
|
|
637
|
+
* (`hasOlderMessagesByServerCount`: an unknown total is deliberately treated
|
|
638
|
+
* as "no"), and it reads that total from the session row's `_count.messages`.
|
|
639
|
+
* This Host used to answer `_count: null`, so the entry point never lit up.
|
|
640
|
+
* The count is computed on the single-session read rather than the list, which
|
|
641
|
+
* the controller polls: folding every transcript on every poll would trade a
|
|
642
|
+
* paging bug for a latency one.
|
|
643
|
+
*/
|
|
644
|
+
const countMessages = sessionQuery === undefined || readMessages === undefined
|
|
645
|
+
? undefined
|
|
646
|
+
: createMessageCounter({ reader: readMessages });
|
|
647
|
+
/**
|
|
648
|
+
* The work-grouped history window the controller prefers once it has seen
|
|
649
|
+
* `history-view-v1`.
|
|
650
|
+
*
|
|
651
|
+
* Grouping runs over the same cached transcript the raw window pages
|
|
652
|
+
* (`readMessages.all`), so the two views can never disagree about what the session
|
|
653
|
+
* contains — and neither of them re-reads the log to answer.
|
|
654
|
+
*/
|
|
655
|
+
const historyView = readMessages === undefined
|
|
656
|
+
? undefined
|
|
657
|
+
: createHistoryViewController({
|
|
658
|
+
rows: (sessionId) => readMessages.all(sessionId),
|
|
659
|
+
// The turn state has to be **wired** here; the controller's own default is `() => false`.
|
|
660
|
+
// That default is why a running turn was never marked `isStreaming`, which cost two things
|
|
661
|
+
// at once and looked like a client problem both times: the phone drew no live card, and
|
|
662
|
+
// the "running card is last" rule had nothing to detect, so the user's own messages stayed
|
|
663
|
+
// below their own conversation.
|
|
664
|
+
sessionRunning: (sessionId) => options.isSessionRunning?.(sessionId) === true,
|
|
665
|
+
// …and so does this: the view is what the controller actually reads, so without it a
|
|
666
|
+
// photo arrives as a file entry whose `imageRef` means nothing to the phone.
|
|
667
|
+
hydrate: hydrateRows,
|
|
668
|
+
});
|
|
669
|
+
// Bound once, not inline: the rename path has to be able to invalidate this source's
|
|
670
|
+
// cached title, and a property of the object literal below is not in scope here.
|
|
671
|
+
const sessionSource = createSessionControllerSource({
|
|
672
|
+
sessionController,
|
|
673
|
+
readActiveSessions: () => readActiveSessionSummaries(ctx),
|
|
674
|
+
subscribe: (name, listener) => ctx.on(name, listener),
|
|
675
|
+
readTitles: sessionQuery === undefined ? undefined : (ids) => readTitles(sessionQuery, ids),
|
|
676
|
+
readSessionMeta: sessionQuery === undefined ? undefined : (ids) => readSessionMeta(sessionQuery, ids),
|
|
677
|
+
readActivityEvidence: typeof readMessages?.activity === 'function' ? (sessionId) => readMessages.activity(sessionId) : undefined,
|
|
678
|
+
// ID lookup is not sidebar discovery. New drafts exist before they have a
|
|
679
|
+
// user turn and must be readable without waiting for the list cache.
|
|
680
|
+
readSessionSummary: typeof sessionQuery?.observeSession !== 'function' ? undefined : async (sessionId) => {
|
|
681
|
+
let observation;
|
|
682
|
+
try {
|
|
683
|
+
observation = await sessionQuery.observeSession(sessionId, { projectionMode: 'all', signal: AbortSignal.timeout(15_000) });
|
|
684
|
+
const header = observation.header;
|
|
685
|
+
if (header.id !== sessionId || header.cwd === undefined) return undefined;
|
|
686
|
+
const metadata = observation.projections?.values?.sessionListMetadata;
|
|
687
|
+
return {
|
|
688
|
+
sessionId: header.id,
|
|
689
|
+
createdAt: header.createdAt,
|
|
690
|
+
updatedAt: Math.max(header.createdAt, metadata?.lastPromptAt ?? 0),
|
|
691
|
+
cwd: header.cwd,
|
|
692
|
+
origin: header.origin,
|
|
693
|
+
parentSessionId: header.parentSession,
|
|
694
|
+
blank: metadata?.blank === true,
|
|
695
|
+
running: options.isSessionRunning?.(sessionId) === true,
|
|
696
|
+
projections: observation.projections,
|
|
697
|
+
};
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return undefined;
|
|
700
|
+
throw error; // An I/O or replay failure is not evidence of absence.
|
|
701
|
+
} finally {
|
|
702
|
+
observation?.[Symbol.dispose]?.();
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
return {
|
|
707
|
+
kind: 'session-controller',
|
|
708
|
+
readMessages,
|
|
709
|
+
countMessages,
|
|
710
|
+
historyView,
|
|
711
|
+
// Reported through the status route: "the photo does not render" has three
|
|
712
|
+
// indistinguishable causes from the outside, and this separates them.
|
|
713
|
+
attachmentReads,
|
|
714
|
+
// Internal plumbing rather than a controller capability: the live push path has to inline a
|
|
715
|
+
// message's images before the row leaves, exactly like the two read paths do. It rides the
|
|
716
|
+
// seam so there is **one** hydrator (`hydrateRows`) instead of one per caller — a second
|
|
717
|
+
// copy is how the history view ended up serving un-hydrated rows.
|
|
718
|
+
hydrateImages: hydrateRows,
|
|
719
|
+
/**
|
|
720
|
+
* Create one session **the way the desk's own UI creates it**.
|
|
721
|
+
*
|
|
722
|
+
* `session.create` takes `workspaceId` **or** `cwd`, never both, and only the
|
|
723
|
+
* workspace form calls `workspace.attachSession()` — the registration that DSH's
|
|
724
|
+
* own session list groups by (the `sessionIds` array in
|
|
725
|
+
* `.dsh/storages/workspace.json`). Creating with a bare `cwd` therefore produces a
|
|
726
|
+
* session that exists, runs, streams and answers, and is **invisible in DSH
|
|
727
|
+
* itself**: reported from the handset as 手机上新建一个对话,dsh 上没看到, and
|
|
728
|
+
* measured — the phone's session was absent from that workspace's `sessionIds`
|
|
729
|
+
* while a Web-created session sat in it.
|
|
730
|
+
*
|
|
731
|
+
* So the requested directory is resolved to its workspace first (reusing the
|
|
732
|
+
* existing record for that canonical path, creating one only when the directory is
|
|
733
|
+
* genuinely new to DSH). A `workspaceRegistry` that cannot place the path — not
|
|
734
|
+
* mounted, relative, missing, a file — falls back to the bare `cwd`, which keeps
|
|
735
|
+
* the old behaviour: the session is created, it just is not grouped on the desk.
|
|
736
|
+
*
|
|
737
|
+
* It also installs the model the controller picked, because a new conversation
|
|
738
|
+
* has no other way to say it: the handset's pipeline is create, getSession,
|
|
739
|
+
* enqueue, and it never calls `setModel` (`newSessionCreation.ts`), so the
|
|
740
|
+
* runtime it chose reaches this Host **only** in these options. Creating first
|
|
741
|
+
* and selecting second is the only order DSH allows — a selection needs a
|
|
742
|
+
* session — and it is the difference between the user's pick and the profile
|
|
743
|
+
* default: without it the first prompt runs on `agent-default-model` and the
|
|
744
|
+
* handset reports 「新对话选了 gpt,一运行又变成 deepseek」.
|
|
745
|
+
*
|
|
746
|
+
* The permission preset rides the same options for the same reason, and it is the
|
|
747
|
+
* one where a wrong answer is dangerous rather than merely annoying: a session that
|
|
748
|
+
* keeps `danger-full-access` after the user asked for `read-only` reads as a
|
|
749
|
+
* restriction the agent does not have. Only a name this Host advertises is
|
|
750
|
+
* installed — see `applyPermissionMode` — and the reply names it only when it was
|
|
751
|
+
* really installed.
|
|
752
|
+
*
|
|
753
|
+
* A model this Host cannot route **throws** rather than being swallowed. The
|
|
754
|
+
* session exists either way, and the controller's create retry is idempotent
|
|
755
|
+
* and probes for the session before rebuilding, so the honest refusal costs one
|
|
756
|
+
* retry while a silent wrong model costs a conversation.
|
|
757
|
+
*/
|
|
758
|
+
createSession: async (options) => {
|
|
759
|
+
const cwd = typeof options?.cwd === 'string' && options.cwd !== '' ? options.cwd : undefined;
|
|
760
|
+
const workspace = await workspaceForNewSession(cwd);
|
|
761
|
+
const created = await sessionController.create({
|
|
762
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
763
|
+
...(workspace === undefined ? (cwd === undefined ? {} : { cwd }) : { workspaceId: workspace.id }),
|
|
764
|
+
});
|
|
765
|
+
sessionSource.invalidateListing();
|
|
766
|
+
const installed = {};
|
|
767
|
+
if (typeof options?.model === 'string' && options.model !== '') {
|
|
768
|
+
const applied = await applyModelSelection({
|
|
769
|
+
sessionId: created.sessionId,
|
|
770
|
+
provider: options.provider,
|
|
771
|
+
model: options.model,
|
|
772
|
+
reasoningEffort: options.reasoningEffort,
|
|
773
|
+
});
|
|
774
|
+
installed.selection = applied?.selected ?? null;
|
|
775
|
+
}
|
|
776
|
+
const mode = typeof options?.permissionMode === 'string' ? options.permissionMode.trim() : '';
|
|
777
|
+
if (mode !== '') {
|
|
778
|
+
const permissionMode = await applyPermissionMode({ sessionId: created.sessionId, mode });
|
|
779
|
+
if (permissionMode !== null) installed.permissionMode = permissionMode;
|
|
780
|
+
}
|
|
781
|
+
return Object.keys(installed).length === 0 ? created : { ...created, ...installed };
|
|
782
|
+
},
|
|
783
|
+
/**
|
|
784
|
+
* Send one prompt, carrying whatever attachments this Host can serve.
|
|
785
|
+
*
|
|
786
|
+
* The controller's attachments arrive inside the message. An absolute host path
|
|
787
|
+
* is read through the composed filesystem, and an upload transit reference is
|
|
788
|
+
* fetched from the account's media staging area (`resolveAttachmentRef`, which
|
|
789
|
+
* this runtime supplies because the account session is its own). Both become a
|
|
790
|
+
* durable DSH attachment — an image part, or a file receipt.
|
|
791
|
+
*
|
|
792
|
+
* Text-bearing prompts are never refused for an attachment that could not be
|
|
793
|
+
* carried: the text still goes, and the answer says what was left out. A prompt
|
|
794
|
+
* that is **only** an attachment is the one case that must fail loudly, because
|
|
795
|
+
* there is nothing left to send — accepting it would show the user a delivered
|
|
796
|
+
* photo that the agent never saw.
|
|
797
|
+
*/
|
|
798
|
+
sendMessage: async ({ sessionId, text, requestId, mode, attachments, resolveAttachmentRef, releaseAttachmentRef }) => {
|
|
799
|
+
const incoming = Array.isArray(attachments) ? attachments : [];
|
|
800
|
+
const body = typeof text === 'string' ? text : '';
|
|
801
|
+
let parts = [];
|
|
802
|
+
let dropped = [];
|
|
803
|
+
let consumed = [];
|
|
804
|
+
if (incoming.length > 0) {
|
|
805
|
+
const fileSystem = ctx.get('fs');
|
|
806
|
+
const materializer = createAttachmentMaterializer({
|
|
807
|
+
fileSystem,
|
|
808
|
+
fileUploads: controls.fileUploads,
|
|
809
|
+
resolveAttachmentRef,
|
|
810
|
+
});
|
|
811
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
812
|
+
if (resolved?.error !== undefined) throw resolved.error;
|
|
813
|
+
({ parts, dropped, consumed } = await materializer.materialize({
|
|
814
|
+
agent: resolved.agent,
|
|
815
|
+
attachments: incoming,
|
|
816
|
+
signal: AbortSignal.timeout(30_000),
|
|
817
|
+
}));
|
|
818
|
+
}
|
|
819
|
+
if (parts.length === 0 && incoming.length > 0 && body.trim() === '') {
|
|
820
|
+
return {
|
|
821
|
+
ok: false,
|
|
822
|
+
code: 'ATTACHMENT_UNAVAILABLE',
|
|
823
|
+
message: `this Host could not fetch the attachment: ${dropped.map((entry) => entry.reason).join(', ') || 'unknown reason'}`,
|
|
824
|
+
attachmentsDropped: dropped,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const content = [
|
|
828
|
+
...(body !== '' ? [{ type: 'text', text: body }] : []),
|
|
829
|
+
...parts,
|
|
830
|
+
];
|
|
831
|
+
const result = await sessionController.prompt({
|
|
832
|
+
requestId,
|
|
833
|
+
sessionId,
|
|
834
|
+
// An idle turn queues; a running turn steers. Both are the same DSH
|
|
835
|
+
// primitive, which is why one capability serves both channels.
|
|
836
|
+
mode: mode === 'steer' ? 'steer' : 'queue',
|
|
837
|
+
content,
|
|
838
|
+
// `signal` is REQUIRED here, not optional: the service calls
|
|
839
|
+
// `signal.throwIfAborted()` on entry, so omitting it throws a TypeError
|
|
840
|
+
// that the controller never sees — it just keeps spinning. The timeout is
|
|
841
|
+
// the honest bound: acceptance is fast, and a prompt still unaccepted
|
|
842
|
+
// after 30s is a failure, not a slow success.
|
|
843
|
+
}, AbortSignal.timeout(30_000));
|
|
844
|
+
// The staged object was a **transit** copy, and its bytes are now a durable
|
|
845
|
+
// DSH attachment. Releasing it after the prompt has landed keeps the staging
|
|
846
|
+
// area from accumulating an orphan per photo; a failure here is never worth
|
|
847
|
+
// failing a prompt that already succeeded, so it is best-effort by contract.
|
|
848
|
+
if (typeof releaseAttachmentRef === 'function') {
|
|
849
|
+
for (const ossKey of consumed) void releaseAttachmentRef(ossKey).catch(() => undefined);
|
|
850
|
+
}
|
|
851
|
+
return { ok: true, result, attachmentsServed: parts.length, attachmentsDropped: dropped };
|
|
852
|
+
},
|
|
853
|
+
/**
|
|
854
|
+
* Rename, and drop the cached title the same moment.
|
|
855
|
+
*
|
|
856
|
+
* The row this answers with is built from the list read, which serves titles from a
|
|
857
|
+
* cache (`dsh-session-source.js`); without this the controller would read back the
|
|
858
|
+
* **old** title for up to the cache's TTL and look like the rename failed. A rename
|
|
859
|
+
* is the only title change this Host causes itself, so it is the only one that needs
|
|
860
|
+
* telling.
|
|
861
|
+
*/
|
|
862
|
+
renameSession: async ({ sessionId, title }) => {
|
|
863
|
+
const result = await sessionController.rename({ sessionId, title });
|
|
864
|
+
sessionSource.invalidateTitle?.(sessionId);
|
|
865
|
+
return result;
|
|
866
|
+
},
|
|
867
|
+
/**
|
|
868
|
+
* The controller's queue commands.
|
|
869
|
+
*
|
|
870
|
+
* DSH keeps the pending queue in the session's durable inbox, so a queued
|
|
871
|
+
* message is not something this Host can edit on its own: `updateQueue` is
|
|
872
|
+
* the only supported mutation, and it is compare-and-set on the item id
|
|
873
|
+
* the controller already holds.
|
|
874
|
+
*/
|
|
875
|
+
queueControl: {
|
|
876
|
+
/**
|
|
877
|
+
* Commit one pending-queue mutation.
|
|
878
|
+
*
|
|
879
|
+
* `updateQueue` mutates the **live agent's** inbox and refuses outright
|
|
880
|
+
* when there is none — with the message "queued item is no longer
|
|
881
|
+
* pending", which reads as a race and is really "this session is not
|
|
882
|
+
* attached". So the agent is resolved first, exactly as the goal writes
|
|
883
|
+
* do; without it every cancel and edit failed on a session nobody had
|
|
884
|
+
* opened, even though the item was plainly still queued.
|
|
885
|
+
*/
|
|
886
|
+
update: async ({ sessionId, itemId, action }) => {
|
|
887
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
888
|
+
if (resolved?.error !== undefined) throw resolved.error;
|
|
889
|
+
return sessionController.updateQueue({ sessionId, itemId, action });
|
|
890
|
+
},
|
|
891
|
+
// Cancels the active turn **without** dropping the pending inbox, which
|
|
892
|
+
// is exactly what the controller asks for when it stops with a queue.
|
|
893
|
+
cancel: ({ sessionId }) => sessionController.cancel({ sessionId }),
|
|
894
|
+
},
|
|
895
|
+
/**
|
|
896
|
+
* How full one session's context is.
|
|
897
|
+
*
|
|
898
|
+
* A **read**, so it never resumes a session to answer: `ctx.sessions` holds
|
|
899
|
+
* live sessions only, and a cold session answers `null` (the controller
|
|
900
|
+
* renders that as "暂无上下文数据") rather than paying a resume for a number
|
|
901
|
+
* the user only glances at. That is the same rule the goal read follows.
|
|
902
|
+
*
|
|
903
|
+
* The total comes from `ctx.tokenMeter.measure(session)`; the window comes
|
|
904
|
+
* from the routed model, and is omitted when the provider does not disclose
|
|
905
|
+
* one — the controller then shows the raw count instead of a percentage it
|
|
906
|
+
* would have to invent.
|
|
907
|
+
* @param request - the session to measure.
|
|
908
|
+
* @returns `{ totalTokens, maxTokens?, percent? }`, or null when unknown.
|
|
909
|
+
*/
|
|
910
|
+
contextUsage: async ({ sessionId }) => {
|
|
911
|
+
const meter = controls.tokenMeter;
|
|
912
|
+
if (typeof meter?.measure !== 'function') return null;
|
|
913
|
+
const sessions = ctx.get('sessions');
|
|
914
|
+
const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
915
|
+
if (session === undefined) return null;
|
|
916
|
+
let measurement;
|
|
917
|
+
try {
|
|
918
|
+
measurement = meter.measure(session);
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
const totalTokens = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
|
|
923
|
+
if (totalTokens === null) return null;
|
|
924
|
+
const window = await contextWindowFor(sessionId);
|
|
925
|
+
if (window === null || window <= 0) return { totalTokens };
|
|
926
|
+
return { totalTokens, maxTokens: window, percent: Math.min(100, Math.max(0, (totalTokens / window) * 100)) };
|
|
927
|
+
},
|
|
928
|
+
/**
|
|
929
|
+
* Regenerate one session's title.
|
|
930
|
+
*
|
|
931
|
+
* The controller's contract is generate-only — it persists whatever it gets
|
|
932
|
+
* through `local-db:sessions:patch-meta` — and it renders `{ title: null }` as
|
|
933
|
+
* "nothing to name it with". So a service that is absent, a session that is
|
|
934
|
+
* cold, and a provider that fails all answer the same way, and none of them is
|
|
935
|
+
* an error frame.
|
|
936
|
+
*
|
|
937
|
+
* A cold session is deliberately **not** resumed: this is a decoration on a
|
|
938
|
+
* session the user has open, and waking an agent to name it would be a side
|
|
939
|
+
* effect nobody asked for (the rule the goal and context reads follow too).
|
|
940
|
+
* @param request - the session whose title should be regenerated.
|
|
941
|
+
* @returns `{ title }`, null when there is none.
|
|
942
|
+
*/
|
|
943
|
+
regenerateTitle: async ({ sessionId }) => {
|
|
944
|
+
const titles = controls.sessionTitle;
|
|
945
|
+
if (typeof titles?.refresh !== 'function') return { title: null };
|
|
946
|
+
const sessions = ctx.get('sessions');
|
|
947
|
+
const session = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
|
|
948
|
+
if (session === undefined) return { title: null };
|
|
949
|
+
const snapshot = await titles.refresh(session, AbortSignal.timeout(25_000));
|
|
950
|
+
const title = typeof snapshot?.title === 'string' && snapshot.title !== '' ? snapshot.title : null;
|
|
951
|
+
return { title };
|
|
952
|
+
},
|
|
953
|
+
/**
|
|
954
|
+
* The model catalog the controller's picker reads.
|
|
955
|
+
*
|
|
956
|
+
* A capability read is the only way it learns this Host's models, and it
|
|
957
|
+
* asks once per device: an empty catalog does not look like a missing
|
|
958
|
+
* feature, it looks like a Host with nothing to offer.
|
|
959
|
+
*/
|
|
960
|
+
modelCatalog: () => sessionController.modelCatalog(),
|
|
961
|
+
/**
|
|
962
|
+
* Make sure a session has a live agent, and say whether it does.
|
|
963
|
+
*
|
|
964
|
+
* The inbox — the pending queue — is a property of the **live agent**. A
|
|
965
|
+
* cold session's projection snapshot carries no inbox at all, so a queue
|
|
966
|
+
* that is plainly durable reads as empty until something attaches the
|
|
967
|
+
* session, and a mutation is refused with "queued item is no longer
|
|
968
|
+
* pending". Reading or changing the queue therefore begins by attaching.
|
|
969
|
+
*/
|
|
970
|
+
ensureAgent: async (sessionId) => {
|
|
971
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
972
|
+
return resolved?.error === undefined;
|
|
973
|
+
},
|
|
974
|
+
/**
|
|
975
|
+
* Read one session's authoritative projection state.
|
|
976
|
+
*
|
|
977
|
+
* The control stream *pushes* projection changes, but a push that never
|
|
978
|
+
* arrives is indistinguishable from "nothing changed": that is how a queue
|
|
979
|
+
* came to hold a phantom row for a message the agent had already answered.
|
|
980
|
+
* `observeSession` computes the registered projections on demand, so this is
|
|
981
|
+
* the read that can be trusted. The lease is always released.
|
|
982
|
+
*/
|
|
983
|
+
readSessionState: readSessionStateNow,
|
|
984
|
+
/** Apply the controller's model choice to one session. */
|
|
985
|
+
selectModel: async ({ sessionId, provider, model, reasoningEffort }) =>
|
|
986
|
+
applyModelSelection({ sessionId, provider, model, reasoningEffort }),
|
|
987
|
+
/**
|
|
988
|
+
* Change one session's reasoning effort.
|
|
989
|
+
*
|
|
990
|
+
* Effort is part of a **model selection** in DSH
|
|
991
|
+
* (`SessionSelectModelRequest extends ModelSelection`), so the write has to
|
|
992
|
+
* name the model it applies to. The session's own selection is the
|
|
993
|
+
* authority — re-stating it is not a model switch, and picking the catalog
|
|
994
|
+
* default here would silently move a session onto another model as a side
|
|
995
|
+
* effect of changing effort.
|
|
996
|
+
*/
|
|
997
|
+
setEffort: async ({ sessionId, effort }) => {
|
|
998
|
+
const selection = await currentSelection(sessionId);
|
|
999
|
+
if (selection === null) throw new Error('this Host cannot name the model this session runs on');
|
|
1000
|
+
// Re-stating the effort a session already runs on is a no-op, not a write.
|
|
1001
|
+
//
|
|
1002
|
+
// This is not just an optimisation: DSH validates the effort against the model
|
|
1003
|
+
// *route*, and a route that lists no `default` rejects the word outright
|
|
1004
|
+
// ("provider \"openai-codex\" model \"gpt-5.6-sol\" does not support reasoning
|
|
1005
|
+
// effort \"default\"") while the session is demonstrably running on it. The
|
|
1006
|
+
// controller restores the value it read from the row, so that combination is
|
|
1007
|
+
// the *ordinary* path — and it used to surface as the Host crashing.
|
|
1008
|
+
if (selection.reasoningEffort === effort) return selection;
|
|
1009
|
+
const supported = await supportedEfforts(selection);
|
|
1010
|
+
if (supported !== null && !supported.includes(effort)) {
|
|
1011
|
+
throw refusalError('NOT_AVAILABLE', `model ${selection.model} offers no reasoning effort "${effort}"`);
|
|
1012
|
+
}
|
|
1013
|
+
return sessionController.selectModel({ sessionId, ...selection, reasoningEffort: effort });
|
|
1014
|
+
},
|
|
1015
|
+
/**
|
|
1016
|
+
* The composer's `/` and `@` palettes.
|
|
1017
|
+
*
|
|
1018
|
+
* All three sources are other plugins' services and may be absent, so each
|
|
1019
|
+
* answers with an empty envelope rather than refusing: an empty palette is
|
|
1020
|
+
* honest ("nothing to offer"), while a refusal here reads to the controller
|
|
1021
|
+
* as a broken composer.
|
|
1022
|
+
*/
|
|
1023
|
+
palette: {
|
|
1024
|
+
/**
|
|
1025
|
+
* The skills the human-facing `/` menu can offer.
|
|
1026
|
+
*
|
|
1027
|
+
* **The scope is the whole point.** `ctx.skills.list()` reads the *global*
|
|
1028
|
+
* layer when no scope is given, and an agent preset's skills are registered
|
|
1029
|
+
* into that preset's own layer — so listing from the host context answered
|
|
1030
|
+
* "no skills" on a profile that plainly has them (the phone's `/` menu was
|
|
1031
|
+
* empty while its command list was not).
|
|
1032
|
+
*
|
|
1033
|
+
* The scope comes from the session's own agent, which is the only way a
|
|
1034
|
+
* preset's layer is visible at all — see `agentScopeFor`.
|
|
1035
|
+
*/
|
|
1036
|
+
skills: async ({ cwd, sessionId }) => {
|
|
1037
|
+
const skills = controls.skills;
|
|
1038
|
+
if (skills === undefined || typeof skills.list !== 'function') return [];
|
|
1039
|
+
const scope = await agentScopeFor(sessionId);
|
|
1040
|
+
return skills.list({
|
|
1041
|
+
...(scope === undefined ? {} : { scope }),
|
|
1042
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
commands: async ({ sessionId }) => {
|
|
1046
|
+
const commands = ctx.get('commands');
|
|
1047
|
+
if (commands === undefined || typeof commands.list !== 'function') return [];
|
|
1048
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
1049
|
+
// A cold session still has a command catalog; the agent is only what
|
|
1050
|
+
// DSH keys it by, so an unresolvable session yields no commands rather
|
|
1051
|
+
// than an error the palette would show as broken.
|
|
1052
|
+
return resolved?.error === undefined ? commands.list(resolved.agent) : [];
|
|
1053
|
+
},
|
|
1054
|
+
resources: async ({ workingDir, query, cap }) => {
|
|
1055
|
+
const fileSystem = ctx.get('fs');
|
|
1056
|
+
if (fileSystem === undefined || typeof fileSystem.listDir !== 'function') return [];
|
|
1057
|
+
const resolved = typeof fileSystem.resolve === 'function' ? await fileSystem.resolve(workingDir) : workingDir;
|
|
1058
|
+
const listed = await fileSystem.listDir(resolved);
|
|
1059
|
+
return (Array.isArray(listed) ? listed : []).map((entry) => ({
|
|
1060
|
+
name: String(entry?.name ?? ''),
|
|
1061
|
+
kind: entry?.type === 'directory' || entry?.kind === 'dir' ? 'dir' : 'file',
|
|
1062
|
+
})).filter((entry) => entry.name !== '');
|
|
1063
|
+
},
|
|
1064
|
+
},
|
|
1065
|
+
/**
|
|
1066
|
+
* The session controls the composer offers: plan mode and the permission
|
|
1067
|
+
* preset.
|
|
1068
|
+
*
|
|
1069
|
+
* Both are other plugins' services and may be absent, and their absence is
|
|
1070
|
+
* meaningful: DSH documents that a missing `permissions` projection means
|
|
1071
|
+
* "clients hide the control". So the capability list and the channels are
|
|
1072
|
+
* built from what is composed, never from what this Host wishes were.
|
|
1073
|
+
*/
|
|
1074
|
+
sessionControls: {
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether this Host can switch plan mode for a session.
|
|
1077
|
+
*
|
|
1078
|
+
* Plan mode is preset-owned here, so the question is about the
|
|
1079
|
+
* composition a session gets, and it is answered from evidence in order
|
|
1080
|
+
* of strength: a host-plane service if one exists, a live agent whose
|
|
1081
|
+
* context carries it, then the default preset's composition rows. Only
|
|
1082
|
+
* when none of those can be read does this answer false — and false
|
|
1083
|
+
* hides the control, which is the same silence DSH itself uses for a
|
|
1084
|
+
* missing `permissions` projection.
|
|
1085
|
+
* @returns true when a plan-mode switch exists to be driven.
|
|
1086
|
+
*/
|
|
1087
|
+
async planModeSupported() {
|
|
1088
|
+
if (typeof controls.planMode?.set === 'function') return true;
|
|
1089
|
+
const agents = ctx.get('agents');
|
|
1090
|
+
if (typeof agents?.list === 'function') {
|
|
1091
|
+
try {
|
|
1092
|
+
if (agents.list().some((agent) => planModeFor(agent) !== undefined || planCommandAvailable(agent))) return true;
|
|
1093
|
+
} catch {
|
|
1094
|
+
// A registry mid-teardown answers nothing; fall through to the
|
|
1095
|
+
// composition read rather than treating it as "unsupported".
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return presetComposesPlanMode();
|
|
1099
|
+
},
|
|
1100
|
+
/**
|
|
1101
|
+
* Switch plan mode for one session.
|
|
1102
|
+
*
|
|
1103
|
+
* The command registry is the real switch here. Plan mode is composed by
|
|
1104
|
+
* the agent's preset inside its own scope, so `ctx.planMode` is not
|
|
1105
|
+
* reachable from the Host; the `/plan` command that preset registered is,
|
|
1106
|
+
* and it is the exact path the Web composer takes. Its own answer travels
|
|
1107
|
+
* back to the controller rather than being flattened into a success, so a
|
|
1108
|
+
* switch that only lands at the next step is not reported as already on.
|
|
1109
|
+
* @param request - the session to switch and the requested state.
|
|
1110
|
+
* @returns the outcome word, or the command's own result.
|
|
1111
|
+
*/
|
|
1112
|
+
async setPlanMode({ sessionId, enabled }) {
|
|
1113
|
+
const resolved = await sessionController.resolveAgent(sessionId);
|
|
1114
|
+
if (resolved?.error !== undefined) throw resolved.error;
|
|
1115
|
+
const agent = resolved.agent;
|
|
1116
|
+
const scoped = planModeInAgentScope(agent);
|
|
1117
|
+
if (typeof scoped?.set === 'function') return scoped.set(agent, enabled === true);
|
|
1118
|
+
if (typeof controls.planMode?.set === 'function') return controls.planMode.set(agent, enabled === true);
|
|
1119
|
+
|
|
1120
|
+
const commands = ctx.get('commands');
|
|
1121
|
+
// A preset that composes no plan mode registers no `/plan`, so the
|
|
1122
|
+
// refusal is per session and about composition, not about this Host.
|
|
1123
|
+
if (typeof commands?.execute !== 'function' || !planCommandAvailable(agent)) {
|
|
1124
|
+
throw new Error('this session composes no plan mode');
|
|
1125
|
+
}
|
|
1126
|
+
const execution = await commands.execute(
|
|
1127
|
+
agent,
|
|
1128
|
+
enabled === true ? '/plan' : '/plan off',
|
|
1129
|
+
[],
|
|
1130
|
+
// The registry's contract takes a caller-owned signal; an unarmed
|
|
1131
|
+
// switch is instant, so a still-unsettled one after 30s is a failure.
|
|
1132
|
+
AbortSignal.timeout(30_000),
|
|
1133
|
+
);
|
|
1134
|
+
if (execution === undefined) throw new Error('the plan command did not resolve');
|
|
1135
|
+
const result = execution?.result;
|
|
1136
|
+
if (result?.kind === 'error') throw new Error(String(result.text ?? 'plan mode switch failed'));
|
|
1137
|
+
return {
|
|
1138
|
+
// The command answers with human text ("Plan mode on." vs "applies
|
|
1139
|
+
// from the next step"), not with DSH's outcome word, so claiming
|
|
1140
|
+
// `committed` here would be a guess. What is known is that the
|
|
1141
|
+
// switch was accepted, and the text says when it lands.
|
|
1142
|
+
outcome: 'accepted',
|
|
1143
|
+
message: typeof result?.text === 'string' ? result.text : undefined,
|
|
1144
|
+
};
|
|
1145
|
+
},
|
|
1146
|
+
/** The advertised preset names, in the preset table's order. */
|
|
1147
|
+
permissionNames: advertisedPermissionNames,
|
|
1148
|
+
setPermissionMode: installPermissionMode,
|
|
1149
|
+
},
|
|
1150
|
+
/**
|
|
1151
|
+
* The aggregated remote file browser the controller's file screen uses.
|
|
1152
|
+
*
|
|
1153
|
+
* A factory rather than an instance: the filesystem service can be replaced
|
|
1154
|
+
* when its plugin reloads, and the reader has to come from whatever is
|
|
1155
|
+
* composed at the moment of the call — the same lesson as every other seam
|
|
1156
|
+
* in this Host.
|
|
1157
|
+
*/
|
|
1158
|
+
fileBrowser: () => {
|
|
1159
|
+
const fileSystem = ctx.get('fs');
|
|
1160
|
+
return fileSystem === undefined ? undefined : createFileBrowser({ fileSystem });
|
|
1161
|
+
},
|
|
1162
|
+
source: sessionSource,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const apiProxy = ctx.get('apiProxy');
|
|
1167
|
+
if (apiProxy === undefined) {
|
|
1168
|
+
return { kind: 'api-proxy', readMessages: undefined, createSession: undefined, sendMessage: undefined, source: undefined };
|
|
1169
|
+
}
|
|
1170
|
+
// The legacy adapter is loaded **lazily, and its absence is not fatal**.
|
|
1171
|
+
//
|
|
1172
|
+
// Measured on a clean install (a tarball into a fresh DSH_HOME): a static import here dragged
|
|
1173
|
+
// `@deepseek-ai/dsh-host-apiproxy` in as a hard requirement, its transitive
|
|
1174
|
+
// `@deepseek-ai/dsh-agent-presets` resolved to a copy that does not export `InvalidPresetIdError`,
|
|
1175
|
+
// and **the plugin failed to load at all** — the whole profile would not boot, on a dependency
|
|
1176
|
+
// the current DSH never even uses (it mounts `sessionController`, not `apiProxy`). An optional
|
|
1177
|
+
// capability must not be able to break mounting, so it is required at the moment it is needed
|
|
1178
|
+
// and a failure degrades to "this seam serves nothing" instead of "the tree does not load".
|
|
1179
|
+
try {
|
|
1180
|
+
const require = createRequire(import.meta.url);
|
|
1181
|
+
const { InProcessApiClient, toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy');
|
|
1182
|
+
return { kind: 'api-proxy', readMessages: undefined, createSession: undefined, sendMessage: undefined, source: new DshHostSource(new InProcessApiClient(toFetchHandler(apiProxy))) };
|
|
1183
|
+
} catch {
|
|
1184
|
+
return { kind: 'api-proxy', readMessages: undefined, createSession: undefined, sendMessage: undefined, source: undefined };
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* How long this Host may run with a clean boundary record before that is itself suspicious.
|
|
1190
|
+
*
|
|
1191
|
+
* A boundary that never fires and a boundary that was never installed look identical from the
|
|
1192
|
+
* outside, and the second one is how a whole `dsh web` process dies. So `handlerErrors: []` is
|
|
1193
|
+
* not automatically good news: past this uptime, an empty record means "nothing has exercised
|
|
1194
|
+
* the boundaries yet", which is a fact worth reading rather than a green light.
|
|
1195
|
+
*/
|
|
1196
|
+
export const BOUNDARY_SILENCE_MS = 10 * 60_000;
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* The boundary verdict, as one word plus the numbers behind it.
|
|
1200
|
+
* @param options - contained errors, uptime, and the silence threshold.
|
|
1201
|
+
* @returns `{ verdict, contained, uptimeMs }` where verdict is `recovered` | `silent` | `starting`.
|
|
1202
|
+
*/
|
|
1203
|
+
export function boundaryVerdict({ contained = [], uptimeMs = 0, silenceMs = BOUNDARY_SILENCE_MS } = {}) {
|
|
1204
|
+
const count = Array.isArray(contained) ? contained.length : 0;
|
|
1205
|
+
if (count > 0) return { verdict: 'recovered', contained: count, uptimeMs };
|
|
1206
|
+
return { verdict: uptimeMs >= silenceMs ? 'silent' : 'starting', contained: 0, uptimeMs };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The diagnostics block the status route serves.
|
|
1211
|
+
*
|
|
1212
|
+
* Extracted from `apply` with explicit inputs for one measured reason: the first version read
|
|
1213
|
+
* `sessionSource` from a scope it does not exist in, which threw a `ReferenceError`, and the
|
|
1214
|
+
* status route answers a throwing producer by **omitting `diagnostics` entirely** (its own
|
|
1215
|
+
* test pins that, because the settings page must still load). So a single stale reference
|
|
1216
|
+
* silently removed every counter this Host's bug hunts depend on — no error, no gap in the
|
|
1217
|
+
* JSON, just a shorter body. Here the inputs cannot be guessed wrong, a test calls it, and
|
|
1218
|
+
* each volatile field is read through {@link field} so one broken producer degrades that field
|
|
1219
|
+
* instead of the block.
|
|
1220
|
+
*
|
|
1221
|
+
* @param options - the runtime, the seam kind, the current source seam, and the listing reader.
|
|
1222
|
+
* @returns the diagnostics object, with every key present.
|
|
1223
|
+
*/
|
|
1224
|
+
export function buildDiagnostics({ runtime, sourceKind, seam, listingDiagnostics, boundaries }) {
|
|
1225
|
+
const field = (read, fallback) => {
|
|
1226
|
+
try {
|
|
1227
|
+
return read();
|
|
1228
|
+
} catch {
|
|
1229
|
+
return fallback;
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
return {
|
|
1233
|
+
dataSource: sourceKind,
|
|
1234
|
+
transfers: field(() => runtime?.getTransferDiagnostics?.() ?? null, null),
|
|
1235
|
+
projectionRunning: runtime?.projectionRunning === true,
|
|
1236
|
+
projectedSessions: field(() => (runtime ? runtime.model.list().length : 0), 0),
|
|
1237
|
+
// What a controller actually asked for and what it got back, with the
|
|
1238
|
+
// asking device. Without attribution, polling from another linked
|
|
1239
|
+
// computer reads as if it came from the phone the user is holding.
|
|
1240
|
+
recentInvokes: field(() => (runtime ? runtime.getInvokeLog().slice(-20) : []), []),
|
|
1241
|
+
// Refusals outlive the success ring: a controller polling a transcript
|
|
1242
|
+
// evicts everything else within seconds, and the one refused channel is
|
|
1243
|
+
// the only entry that explains "host failed to serve this channel".
|
|
1244
|
+
recentRefusals: field(() => (runtime ? runtime.getRefusalLog().slice(-30) : []), []),
|
|
1245
|
+
// What went out, and to how many controllers. A push to zero watchers and
|
|
1246
|
+
// a push the controller dropped look identical from the desk, so the
|
|
1247
|
+
// destination count is what makes "the phone never updated" attributable.
|
|
1248
|
+
recentPushes: field(() => (runtime ? runtime.getPushLog().slice(-20) : []), []),
|
|
1249
|
+
// Monotonic per-channel totals. The ring churns during a turn, so a count
|
|
1250
|
+
// taken from it is not evidence that a push happened.
|
|
1251
|
+
pushTotals: field(() => (runtime ? runtime.getPushTotals() : {}), {}),
|
|
1252
|
+
// The same for the request direction: which channels have ever been asked
|
|
1253
|
+
// for, and which of those answers were refusals. `recentInvokes` is a
|
|
1254
|
+
// forty-entry ring and a polling controller evicts it within seconds, so
|
|
1255
|
+
// without this "did the phone ever call X" is unanswerable.
|
|
1256
|
+
invokeTotals: field(() => (runtime ? runtime.getInvokeTotals() : {}), {}),
|
|
1257
|
+
refusalTotals: field(() => (runtime ? runtime.getRefusalTotals() : {}), {}),
|
|
1258
|
+
// The phone's own reading of itself, with arrival times. A controller has no log of its
|
|
1259
|
+
// own: it reports by invoking an unknown channel whose name carries the reading, so every
|
|
1260
|
+
// entry here is a refusal **by design**. Kept apart from the rings above because ordinary
|
|
1261
|
+
// polling evicts them within seconds, and "the app was stuck for 96s starting at 10:37:06"
|
|
1262
|
+
// is exactly the finding that must survive until someone looks.
|
|
1263
|
+
phoneDiagnostics: field(() => (runtime ? runtime.getPhoneDiagnostics() : []), []),
|
|
1264
|
+
// How the transcript's image inlining is actually doing: `attempted` counts the
|
|
1265
|
+
// image handles the reader was handed, `served` the ones whose bytes came back.
|
|
1266
|
+
// A photo that renders as a file chip is one of {no handle on the block, no
|
|
1267
|
+
// attachment service on this profile, a failed read} and only these numbers tell
|
|
1268
|
+
// them apart. `served: 0, attempted: 0` with a photo in the transcript means the
|
|
1269
|
+
// handle never reached the reader at all.
|
|
1270
|
+
attachmentReads: seam?.attachmentReads ?? { attempted: 0, served: 0, failed: 0 },
|
|
1271
|
+
// How the automatic reconnection is doing. "The Host vanished from the phone" and
|
|
1272
|
+
// "the Host was briefly offline and came back on its own" look identical from the
|
|
1273
|
+
// handset, and only this tells them apart.
|
|
1274
|
+
reconnect: field(() => (runtime ? runtime.getReconnectState() : { attempts: 0, pending: false, lastReason: null }), null),
|
|
1275
|
+
// What had to be given up to fit one device-link frame. The relay drops an
|
|
1276
|
+
// oversized frame outright, so a degraded page and a page that never arrived
|
|
1277
|
+
// are indistinguishable from the handset — this is where they differ.
|
|
1278
|
+
frameBudget: field(
|
|
1279
|
+
() => (runtime && typeof runtime.getFrameBudget === 'function'
|
|
1280
|
+
? runtime.getFrameBudget()
|
|
1281
|
+
: { limitBytes: null, refusals: 0, recentRefusals: [], degradations: [] }),
|
|
1282
|
+
null,
|
|
1283
|
+
),
|
|
1284
|
+
// Errors this Host caught at its own boundaries. Should stay empty: each entry is an
|
|
1285
|
+
// exception that would otherwise have left the whole `dsh web` process through DSH's
|
|
1286
|
+
// fail-loud unhandled-rejection handler.
|
|
1287
|
+
handlerErrors: field(() => (runtime && typeof runtime.getHandlerErrors === 'function' ? runtime.getHandlerErrors() : []), []),
|
|
1288
|
+
/**
|
|
1289
|
+
* The boundary record, with a verdict rather than just a list.
|
|
1290
|
+
*
|
|
1291
|
+
* `recovered` means the plugin caught something that would otherwise have ended the whole
|
|
1292
|
+
* `dsh web` process — good news, and worth reading. `silent` means the process has been up
|
|
1293
|
+
* past {@link BOUNDARY_SILENCE_MS} without a single boundary firing, which is **not**
|
|
1294
|
+
* automatically healthy: a boundary that is never exercised and one that was never
|
|
1295
|
+
* installed are indistinguishable from here, and `installed` is what tells them apart.
|
|
1296
|
+
*/
|
|
1297
|
+
boundaries: field(() => {
|
|
1298
|
+
const contained = runtime && typeof runtime.getHandlerErrors === 'function' ? runtime.getHandlerErrors() : [];
|
|
1299
|
+
const uptimeMs = runtime && typeof runtime.getUptimeMs === 'function' ? runtime.getUptimeMs() : 0;
|
|
1300
|
+
return {
|
|
1301
|
+
...boundaryVerdict({ contained, uptimeMs }),
|
|
1302
|
+
installed: Array.isArray(boundaries?.installed) ? [...boundaries.installed] : [],
|
|
1303
|
+
};
|
|
1304
|
+
}, { verdict: 'unknown', contained: 0, uptimeMs: 0, installed: [] }),
|
|
1305
|
+
// How the session listing is doing. `staleServes > 0` means a controller was answered
|
|
1306
|
+
// from the previous read because the live one missed its deadline — an absorbed
|
|
1307
|
+
// degradation, and the difference between a rendered list and the reported spinner.
|
|
1308
|
+
listing: field(() => (typeof listingDiagnostics === 'function' ? listingDiagnostics() : null), null),
|
|
1309
|
+
// Rows this Host deliberately did not show the phone. Hidden is not dropped: the count is how
|
|
1310
|
+
// a projection decision stays distinguishable from a lost message.
|
|
1311
|
+
suppressedNotices: field(() => (runtime && typeof runtime.getSuppressedNotices === 'function' ? runtime.getSuppressedNotices() : 0), 0),
|
|
1312
|
+
// The topics controllers currently hold. An empty set means the phone
|
|
1313
|
+
// never subscribed — the single most likely reason a live reply or a todo
|
|
1314
|
+
// card never reaches it.
|
|
1315
|
+
subscriptions: field(() => (runtime ? runtime.getSubscriptions() : { devices: [], sessions: [] }), { devices: [], sessions: [] }),
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* The capability provider the channel layer reads.
|
|
1321
|
+
*
|
|
1322
|
+
* Written as explicit fields rather than a spread of the seam's output so that
|
|
1323
|
+
* `kind`/`source` stay out of the channel layer — but that means a capability
|
|
1324
|
+
* the seam produces can be built and never forwarded, which is exactly what
|
|
1325
|
+
* happened to `queueControl`: every queue button answered NOT_AVAILABLE while
|
|
1326
|
+
* the implementation sat right there. `dsh-plugin.test.js` now asserts that
|
|
1327
|
+
* every capability the seam builds arrives here.
|
|
1328
|
+
* @param sources - the capabilities one source seam produced.
|
|
1329
|
+
* @returns the provider `startHost` resolves per request.
|
|
1330
|
+
*/
|
|
1331
|
+
export function capabilityProvider(sources) {
|
|
1332
|
+
return {
|
|
1333
|
+
readMessages: sources.readMessages,
|
|
1334
|
+
countMessages: sources.countMessages,
|
|
1335
|
+
// The work-grouped history window the controller reaches for once it has seen
|
|
1336
|
+
// `history-view-v1`; without this forwarding the three channels would answer
|
|
1337
|
+
// NOT_AVAILABLE while the controller sat right here (the `queueControl` failure).
|
|
1338
|
+
historyView: sources.historyView,
|
|
1339
|
+
attachmentReads: sources.attachmentReads,
|
|
1340
|
+
// Internal (the live push path), but it crosses the same seam, so it is forwarded here: the
|
|
1341
|
+
// invariant test in `test/dsh-plugin.test.js` requires every seam key to be reachable, and a
|
|
1342
|
+
// key that exists but is reachable from nowhere is exactly the bug class this repo keeps
|
|
1343
|
+
// closing.
|
|
1344
|
+
hydrateImages: sources.hydrateImages,
|
|
1345
|
+
createSession: sources.createSession,
|
|
1346
|
+
sendMessage: sources.sendMessage,
|
|
1347
|
+
renameSession: sources.renameSession,
|
|
1348
|
+
files: sources.files,
|
|
1349
|
+
goalWrite: sources.goalWrite,
|
|
1350
|
+
queueControl: sources.queueControl,
|
|
1351
|
+
modelCatalog: sources.modelCatalog,
|
|
1352
|
+
regenerateTitle: sources.regenerateTitle,
|
|
1353
|
+
contextUsage: sources.contextUsage,
|
|
1354
|
+
selectModel: sources.selectModel,
|
|
1355
|
+
setEffort: sources.setEffort,
|
|
1356
|
+
readSessionState: sources.readSessionState,
|
|
1357
|
+
ensureAgent: sources.ensureAgent,
|
|
1358
|
+
palette: sources.palette,
|
|
1359
|
+
sessionControls: sources.sessionControls,
|
|
1360
|
+
fileBrowser: sources.fileBrowser,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Build the controller's goal writer from the two contexts that supply it.
|
|
1366
|
+
*
|
|
1367
|
+
* Both services come from other plugins and activate independently, so this
|
|
1368
|
+
* reads them off the injected contexts rather than closing over anything: the
|
|
1369
|
+
* first version referenced a `buildDshSource` local, which threw
|
|
1370
|
+
* `sessionController is not defined` at call time — a failure no unit test of
|
|
1371
|
+
* the writer could see, because it only exists once the wiring runs.
|
|
1372
|
+
* @param sourceCtx - the context that carries `sessionController`.
|
|
1373
|
+
* @param goalCtx - the context that carries `goals`.
|
|
1374
|
+
* @returns the writer the goal channels call.
|
|
1375
|
+
*/
|
|
1376
|
+
export function buildGoalWrite(sourceCtx, goalCtx) {
|
|
1377
|
+
const controller = sourceCtx.get('sessionController');
|
|
1378
|
+
return createGoalWriter({
|
|
1379
|
+
goals: goalCtx.get('goals'),
|
|
1380
|
+
resolveAgent: typeof controller?.resolveAgent === 'function'
|
|
1381
|
+
? (sessionId) => controller.resolveAgent(sessionId)
|
|
1382
|
+
: undefined,
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Read creation times and working directories from the session headers.
|
|
1388
|
+
*
|
|
1389
|
+
* `SessionSummary` carries neither `createdAt` nor a durable cwd, but the phone's
|
|
1390
|
+
* session row requires a creation time.
|
|
1391
|
+
*/
|
|
1392
|
+
export async function readSessionMeta(sessionQuery, sessionIds) {
|
|
1393
|
+
const records = await sessionQuery.listSessions();
|
|
1394
|
+
const wanted = new Set(sessionIds);
|
|
1395
|
+
const meta = new Map();
|
|
1396
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
1397
|
+
const header = record?.header;
|
|
1398
|
+
if (header === undefined || !wanted.has(header.id)) continue;
|
|
1399
|
+
meta.set(header.id, {
|
|
1400
|
+
createdAt: Number.isFinite(header.createdAt) ? new Date(header.createdAt).toISOString() : undefined,
|
|
1401
|
+
cwd: typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : null,
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
return meta;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Fold a batch title read into a `Map<sessionId, title>`.
|
|
1409
|
+
*
|
|
1410
|
+
* The nesting is the trap: `readTitleSnapshots` answers
|
|
1411
|
+
* `{ sessionId, status, value }`, where `value` is a `SessionTitleObservation`
|
|
1412
|
+
* whose `title` is a `SessionTitleSnapshot` — so the text is at
|
|
1413
|
+
* `value.title.title`, two levels below a field that already reads like a title.
|
|
1414
|
+
* Reading `value.title` as a string silently yields an object, which fails a
|
|
1415
|
+
* `typeof === 'string'` guard and leaves every session unnamed.
|
|
1416
|
+
*
|
|
1417
|
+
* A rejected entry is one session's failure, not the batch's: titles are row
|
|
1418
|
+
* decoration, so the rest still come through.
|
|
1419
|
+
*/
|
|
1420
|
+
export function foldTitleSnapshots(results) {
|
|
1421
|
+
const titles = new Map();
|
|
1422
|
+
const rows = Array.isArray(results) ? results : [];
|
|
1423
|
+
for (const row of rows) {
|
|
1424
|
+
if (row === null || typeof row !== 'object' || row.status !== 'fulfilled') continue;
|
|
1425
|
+
const snapshot = row.value?.title;
|
|
1426
|
+
const text = typeof snapshot?.title === 'string' ? snapshot.title : null;
|
|
1427
|
+
if (typeof row.sessionId === 'string' && text !== null && text.trim() !== '') {
|
|
1428
|
+
titles.set(row.sessionId, text.trim());
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return titles;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function readTitles(sessionQuery, sessionIds) {
|
|
1435
|
+
return foldTitleSnapshots(await sessionQuery.readTitleSnapshots(sessionIds));
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* The settings write for the archive/delete/pin flags.
|
|
1440
|
+
*
|
|
1441
|
+
* **`mutate` with one `set` op, never `update`.** `update` deep-merges its patch into
|
|
1442
|
+
* the namespace's user section, so it cannot express a removal: un-archiving a session
|
|
1443
|
+
* writes "no flags for this session", which merges into nothing and leaves the archived
|
|
1444
|
+
* flag still on disk — where the next settings commit re-seeds it and the session
|
|
1445
|
+
* silently stays hidden. Measured on the live Host: archived → restored → *still*
|
|
1446
|
+
* `archived`, with the stale flag reloaded from the file. A path-addressed `set`
|
|
1447
|
+
* replaces the field exactly, which is what a snapshot write means.
|
|
1448
|
+
*
|
|
1449
|
+
* @param settings - the `settings` service.
|
|
1450
|
+
* @param ns - this bundle's namespace, as registered.
|
|
1451
|
+
* @returns the writer the runtime persists through.
|
|
1452
|
+
*/
|
|
1453
|
+
export function sessionFlagsWriter(settings, ns) {
|
|
1454
|
+
return (sessionFlags) => settings.mutate(ns, [{ op: 'set', path: ['sessionFlags'], value: sessionFlags }]);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The settings seam, for both DSH generations this bundle supports.
|
|
1459
|
+
*
|
|
1460
|
+
* **0.1.x**: `settings.register(ns, schema, options)` publishes a plugin's settings and returns
|
|
1461
|
+
* a live scope (`get`/`watch`/`update`/`replace`). The registration is *also* what makes
|
|
1462
|
+
* `update(ns, …)` and `mutate(ns, …)` accept this namespace — their write path looks the
|
|
1463
|
+
* namespace up in the provider's registration table.
|
|
1464
|
+
*
|
|
1465
|
+
* **0.2 removed `register`.** The host now reads the plugin **entry's own `Config`** schema
|
|
1466
|
+
* (`SettingsForms.describe()`, keyed by **profile entry id**), resolves it, and hands the value
|
|
1467
|
+
* to `apply(ctx, config)`; `update`/`mutate` address that same entry id. This bundle's row is
|
|
1468
|
+
* `- id: dsh-cindy-host` (see `cordis.patch.yml`), so `SETTINGS_NAMESPACE` names the same
|
|
1469
|
+
* surface under both models — only the way the plugin obtains it differs. Hence one seam with
|
|
1470
|
+
* two branches instead of two code paths through the rest of the plugin.
|
|
1471
|
+
*
|
|
1472
|
+
* The returned object always has the 0.1.x scope shape, so every consumer below is
|
|
1473
|
+
* generation-agnostic.
|
|
1474
|
+
*
|
|
1475
|
+
* @param ctx - the plugin context that owns the `settings` service.
|
|
1476
|
+
* @param entryId - the namespace (0.1.x) / profile entry id (0.2) this bundle writes.
|
|
1477
|
+
* @param projected - the host-resolved `Config` value; only 0.2 supplies it.
|
|
1478
|
+
* @returns the settings scope described above.
|
|
1479
|
+
*/
|
|
1480
|
+
export function createSettingsScope(ctx, entryId, projected) {
|
|
1481
|
+
const { settings } = ctx;
|
|
1482
|
+
if (typeof settings.register === 'function') {
|
|
1483
|
+
return settings.register(entryId, HostSchema, { base: DEFAULT_HOST_SETTINGS, applies: 'live', validate: validateHostSettings });
|
|
1484
|
+
}
|
|
1485
|
+
if (projected === undefined) throw new Error('dsh-cindy-host: the host resolved no Config for this entry, so there is nowhere to read settings from');
|
|
1486
|
+
// Volatile Config is updated WITHOUT remounting the plugin. Keep a detached
|
|
1487
|
+
// snapshot and re-read the settings owner's resolved value after its event;
|
|
1488
|
+
// copying apply(config) once left the relay disabled after the UI enabled it.
|
|
1489
|
+
const snapshot = (value) => {
|
|
1490
|
+
const next = JSON.parse(JSON.stringify({ ...DEFAULT_HOST_SETTINGS, ...value }));
|
|
1491
|
+
validateHostSettings(next);
|
|
1492
|
+
return next;
|
|
1493
|
+
};
|
|
1494
|
+
let current = snapshot(projected);
|
|
1495
|
+
const watchers = new Set();
|
|
1496
|
+
const report = (error) => ctx.logger?.warn?.(`dsh-cindy-host settings: a watcher failed and was contained: ${String(error)}`);
|
|
1497
|
+
const publish = (next) => {
|
|
1498
|
+
if (JSON.stringify(current) === JSON.stringify(next)) return;
|
|
1499
|
+
current = next;
|
|
1500
|
+
for (const callback of [...watchers]) {
|
|
1501
|
+
try {
|
|
1502
|
+
// Runtime updates are asynchronous. A synchronous try/catch alone does
|
|
1503
|
+
// not contain a rejection from starting or stopping the relay.
|
|
1504
|
+
Promise.resolve(callback(next)).catch(report);
|
|
1505
|
+
} catch (error) {
|
|
1506
|
+
report(error);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
let refreshing = false;
|
|
1511
|
+
const refresh = () => {
|
|
1512
|
+
if (refreshing) return; // describe may itself emit document-updated.
|
|
1513
|
+
refreshing = true;
|
|
1514
|
+
try {
|
|
1515
|
+
const descriptor = settings.describe().find((row) => row.ns === entryId);
|
|
1516
|
+
if (descriptor !== undefined) publish(snapshot(descriptor.value));
|
|
1517
|
+
} finally {
|
|
1518
|
+
refreshing = false;
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
return {
|
|
1523
|
+
get: () => current,
|
|
1524
|
+
refresh,
|
|
1525
|
+
watch: (callback) => {
|
|
1526
|
+
watchers.add(callback);
|
|
1527
|
+
return () => watchers.delete(callback);
|
|
1528
|
+
},
|
|
1529
|
+
update: async (patch) => {
|
|
1530
|
+
await settings.update(entryId, patch);
|
|
1531
|
+
refresh(); // Use resolved values, not an invented shallow merge.
|
|
1532
|
+
},
|
|
1533
|
+
replace: async (section) => {
|
|
1534
|
+
await settings.replace(entryId, section);
|
|
1535
|
+
refresh();
|
|
1536
|
+
},
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Present one interaction to Cindy and the downstream DSH UI, settling on the first answer.
|
|
1543
|
+
* The downstream branch receives a private abort signal so the losing local card is dismissed;
|
|
1544
|
+
* without that cancellation contract a Promise.race would leak a parked waterfall listener.
|
|
1545
|
+
*/
|
|
1546
|
+
export async function arbitrateInteraction({ request, next, begin, settleLocal }) {
|
|
1547
|
+
const handle = await begin();
|
|
1548
|
+
if (handle === null) return next();
|
|
1549
|
+
|
|
1550
|
+
const downstreamAbort = new AbortController();
|
|
1551
|
+
const upstreamSignal = request?.signal;
|
|
1552
|
+
const downstreamSignal = upstreamSignal === undefined
|
|
1553
|
+
? downstreamAbort.signal
|
|
1554
|
+
: AbortSignal.any([upstreamSignal, downstreamAbort.signal]);
|
|
1555
|
+
let cancellable = false;
|
|
1556
|
+
try {
|
|
1557
|
+
request.signal = downstreamSignal;
|
|
1558
|
+
cancellable = request.signal === downstreamSignal;
|
|
1559
|
+
} catch {
|
|
1560
|
+
try {
|
|
1561
|
+
Object.defineProperty(request, 'signal', { value: downstreamSignal, configurable: true, writable: true });
|
|
1562
|
+
cancellable = request.signal === downstreamSignal;
|
|
1563
|
+
} catch {
|
|
1564
|
+
cancellable = false;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
// Never start a branch we cannot dismiss; keep the existing Cindy answer path instead.
|
|
1568
|
+
if (!cancellable) return handle.answered;
|
|
1569
|
+
|
|
1570
|
+
const controller = Promise.resolve(handle.answered).then((value) => ({ kind: 'controller', value }));
|
|
1571
|
+
const local = Promise.resolve().then(next).then(
|
|
1572
|
+
(value) => ({ kind: 'local', value }),
|
|
1573
|
+
(error) => ({ kind: 'local-error', error }),
|
|
1574
|
+
);
|
|
1575
|
+
const first = await Promise.race([controller, local]);
|
|
1576
|
+
if (first.kind === 'local-error') return (await controller).value;
|
|
1577
|
+
if (first.kind === 'local') {
|
|
1578
|
+
settleLocal(handle.requestId, first.value);
|
|
1579
|
+
return first.value;
|
|
1580
|
+
}
|
|
1581
|
+
downstreamAbort.abort(new Error('interaction settled by Cindy'));
|
|
1582
|
+
void local.then(() => undefined, () => undefined);
|
|
1583
|
+
return first.value;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
export function apply(ctx, config) {
|
|
1587
|
+
const scope = createSettingsScope(ctx, settingsNamespace(SETTINGS_NAMESPACE), config);
|
|
1588
|
+
let runtime;
|
|
1589
|
+
// The source can arrive before OR after the runtime: `startHost` awaits a
|
|
1590
|
+
// Cindy session before resolving, while `ctx.inject` fires the moment the DSH
|
|
1591
|
+
// service activates. Holding it here and re-applying after the await covers
|
|
1592
|
+
// both orders — dropping it when `runtime` was still undefined is what left
|
|
1593
|
+
// the projection permanently stopped in the first wiring attempt.
|
|
1594
|
+
let currentSource;
|
|
1595
|
+
/**
|
|
1596
|
+
* The whole seam output, forwarded as one object.
|
|
1597
|
+
*
|
|
1598
|
+
* This used to be a hand-maintained field list, and it was the bug twice:
|
|
1599
|
+
* `queueControl` was built and left out (every queue button answered
|
|
1600
|
+
* NOT_AVAILABLE), and then `setEffort` was built, forwarded through
|
|
1601
|
+
* `capabilityProvider`, and left out here — so the phone's effort picker
|
|
1602
|
+
* answered NOT_AVAILABLE on a Host that advertises four effort levels.
|
|
1603
|
+
* `capabilityProvider` is the single place that decides what the channel layer
|
|
1604
|
+
* may see, so the seam output travels whole.
|
|
1605
|
+
*/
|
|
1606
|
+
let currentSeam = {};
|
|
1607
|
+
/**
|
|
1608
|
+
* Every listener this plugin hands to the host, and the entry point that wraps it.
|
|
1609
|
+
*
|
|
1610
|
+
* Cordis's `emit` dispatch has no `try`/`catch`, and its `waterfall` hands a listener's
|
|
1611
|
+
* returned promise straight back to DSH — so one throw or rejection from any of these is not
|
|
1612
|
+
* a degraded plugin, it is a dead `dsh web`: DSH's boot registers an unhandled-rejection
|
|
1613
|
+
* handler that writes `fatal load failure` to stderr and calls `process.exit(1)`. Measured
|
|
1614
|
+
* once already (`RemoteError: queued item is no longer pending` took the whole desktop down).
|
|
1615
|
+
*
|
|
1616
|
+
* So each registration goes through {@link guarded}, which names it and contains whatever it
|
|
1617
|
+
* throws. `test/plugin-boundaries.test.js` scans this file and fails when a `ctx.on(`
|
|
1618
|
+
* registration does not go through it — an unwrapped boundary has to be a test failure rather
|
|
1619
|
+
* than something a reviewer has to notice.
|
|
1620
|
+
*/
|
|
1621
|
+
const guardedListeners = [];
|
|
1622
|
+
|
|
1623
|
+
/** Record one contained boundary failure, before or after the runtime exists. */
|
|
1624
|
+
const earlyBoundaryErrors = [];
|
|
1625
|
+
function noteBoundaryError(where, error) {
|
|
1626
|
+
if (runtime != null && typeof runtime.noteHandlerError === 'function') {
|
|
1627
|
+
runtime.noteHandlerError(where, error);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
earlyBoundaryErrors.push({
|
|
1631
|
+
at: new Date().toISOString(),
|
|
1632
|
+
where,
|
|
1633
|
+
message: (error instanceof Error ? error.message : String(error)).slice(0, 400),
|
|
1634
|
+
});
|
|
1635
|
+
if (earlyBoundaryErrors.length > 20) earlyBoundaryErrors.shift();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Wrap one host-facing listener in the plugin's single guarded entry point.
|
|
1640
|
+
* @param label - what this listener is, for the diagnostics record.
|
|
1641
|
+
* @param listener - the listener as written.
|
|
1642
|
+
* @returns a listener that cannot let its failure escape.
|
|
1643
|
+
*/
|
|
1644
|
+
function guarded(label, listener) {
|
|
1645
|
+
guardedListeners.push(label);
|
|
1646
|
+
return (...args) => {
|
|
1647
|
+
try {
|
|
1648
|
+
const result = listener(...args);
|
|
1649
|
+
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
|
|
1650
|
+
// A waterfall listener's rejection is the dangerous shape: DSH awaits what it
|
|
1651
|
+
// returns, so a rejection here arrives as an unhandled one at the process level.
|
|
1652
|
+
return result.catch((error) => {
|
|
1653
|
+
noteBoundaryError(label, error);
|
|
1654
|
+
return undefined;
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
return result;
|
|
1658
|
+
} catch (error) {
|
|
1659
|
+
noteBoundaryError(label, error);
|
|
1660
|
+
return undefined;
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** The boundary record for diagnostics: what is installed, and what it has contained. */
|
|
1666
|
+
function boundaryState() {
|
|
1667
|
+
const contained = runtime != null && typeof runtime.getHandlerErrors === 'function'
|
|
1668
|
+
? runtime.getHandlerErrors()
|
|
1669
|
+
: earlyBoundaryErrors;
|
|
1670
|
+
return { installed: [...guardedListeners], contained: contained.slice() };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* The current source's listing diagnostics, or a null answer.
|
|
1675
|
+
*
|
|
1676
|
+
* Held here rather than read from `currentSeam`: the first version reached for the
|
|
1677
|
+
* session source by name from inside `getDiagnostics`, where it is out of scope, and the
|
|
1678
|
+
* resulting `ReferenceError` made the status route drop **its whole diagnostics block**
|
|
1679
|
+
* (that route answers a throwing producer by omitting the field, so the settings page still
|
|
1680
|
+
* loads). One stale reference, and every counter a bug hunt needs was gone with no error to
|
|
1681
|
+
* show for it. The seam output also travels whole into the channel layer, which is asserted
|
|
1682
|
+
* by an invariant test — a diagnostics accessor is not a capability and does not belong there.
|
|
1683
|
+
*/
|
|
1684
|
+
let currentListingDiagnostics = () => null;
|
|
1685
|
+
/** The controller's file reads, once a filesystem service is composed. */
|
|
1686
|
+
let currentFiles;
|
|
1687
|
+
/** The controller's goal writes, once a goal service is composed. */
|
|
1688
|
+
let currentGoalWrite;
|
|
1689
|
+
// Which seam supplied the source, for the status route's diagnostics. Without
|
|
1690
|
+
// it a silent `none` looks identical to "this profile has no sessions yet".
|
|
1691
|
+
let sourceKind = 'none';
|
|
1692
|
+
|
|
1693
|
+
if (typeof scope.refresh === 'function') {
|
|
1694
|
+
ctx.on('settings/document-updated', guarded('settings-document-updated', (namespace) => {
|
|
1695
|
+
if (namespace === SETTINGS_NAMESPACE) scope.refresh();
|
|
1696
|
+
}));
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
ctx.effect(async () => {
|
|
1700
|
+
runtime = await startHost(undefined, scope.get(), {
|
|
1701
|
+
// A provider, not a snapshot: these callbacks come from the injected
|
|
1702
|
+
// session service, which activates after `startHost` already awaited a
|
|
1703
|
+
// Cindy session. Capturing them here would freeze an empty set and make a
|
|
1704
|
+
// capable Host answer NOT_AVAILABLE forever.
|
|
1705
|
+
resolveCapabilities: () => capabilityProvider({
|
|
1706
|
+
...currentSeam,
|
|
1707
|
+
// The two capabilities that come from *other* injections rather than
|
|
1708
|
+
// from the seam: the filesystem, and the goal service's writer.
|
|
1709
|
+
files: currentFiles,
|
|
1710
|
+
goalWrite: currentGoalWrite,
|
|
1711
|
+
}),
|
|
1712
|
+
// The controller's archive/delete/pin writes go straight to this plugin's
|
|
1713
|
+
// settings section, so the whole flag store reloads with the process.
|
|
1714
|
+
persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
|
|
1715
|
+
});
|
|
1716
|
+
// Settings can change while startHost awaits authentication. Reconcile the
|
|
1717
|
+
// latest snapshot after the runtime exists instead of dropping that update.
|
|
1718
|
+
await runtime.updateSettings(scope.get());
|
|
1719
|
+
if (currentSource !== undefined) await runtime.setSource(currentSource);
|
|
1720
|
+
return () => runtime.stop();
|
|
1721
|
+
}, 'dsh-cindy-host runtime');
|
|
1722
|
+
ctx.effect(() => scope.watch((settings) => runtime?.updateSettings(settings)), 'dsh-cindy-host settings');
|
|
1723
|
+
|
|
1724
|
+
// Attach the DSH read source whenever a supplier activates, and detach it if
|
|
1725
|
+
// that supplier goes away — a reload of the session API must not leave the
|
|
1726
|
+
// projection reading a dead service.
|
|
1727
|
+
for (const serviceName of SOURCE_SERVICES) {
|
|
1728
|
+
ctx.inject([serviceName], (sourceCtx) => {
|
|
1729
|
+
const built = buildDshSource(sourceCtx, serviceName, {
|
|
1730
|
+
// Read lazily: the runtime resolves after this seam is built, and the state changes on
|
|
1731
|
+
// every turn boundary.
|
|
1732
|
+
isSessionRunning: (sessionId) => runtime?.isSessionRunning?.(sessionId) === true,
|
|
1733
|
+
});
|
|
1734
|
+
// The filesystem is a separate service; the controller's file reads are
|
|
1735
|
+
// only wired when this profile composes one.
|
|
1736
|
+
const fileSystem = sourceCtx.get('fs');
|
|
1737
|
+
if (fileSystem !== undefined) {
|
|
1738
|
+
const reader = createFileReader({ fileSystem });
|
|
1739
|
+
currentFiles = () => reader;
|
|
1740
|
+
}
|
|
1741
|
+
// Goal writes need a live `Agent`, which only this controller can hand out.
|
|
1742
|
+
// `goals` is a separate plugin that may activate later, so it is injected
|
|
1743
|
+
// rather than read here: a missing goal service must leave the channels
|
|
1744
|
+
// answering NOT_AVAILABLE, not make this Host look like it has no source.
|
|
1745
|
+
sourceCtx.inject(['goals'], (goalCtx) => {
|
|
1746
|
+
currentGoalWrite = buildGoalWrite(sourceCtx, goalCtx);
|
|
1747
|
+
return () => {
|
|
1748
|
+
currentGoalWrite = undefined;
|
|
1749
|
+
};
|
|
1750
|
+
});
|
|
1751
|
+
currentSource = built.source;
|
|
1752
|
+
// The seam output travels whole; see `currentSeam`.
|
|
1753
|
+
currentSeam = built;
|
|
1754
|
+
sourceKind = built.kind;
|
|
1755
|
+
// Bound next to the source it describes, because this is the only scope that has it.
|
|
1756
|
+
currentListingDiagnostics = () => (typeof built.source?.listDiagnostics === 'function' ? built.source.listDiagnostics() : null);
|
|
1757
|
+
// The runtime may not have resolved yet; it applies the pending source
|
|
1758
|
+
// after its own await, and picks up the readers at construction.
|
|
1759
|
+
void runtime?.setSource(currentSource);
|
|
1760
|
+
// The input queue and jobs arrive as an AsyncIterable, not a query:
|
|
1761
|
+
// consume it for as long as the service is composed and fold each frame
|
|
1762
|
+
// into the runtime, which is what `maker:input:get-projection` reads.
|
|
1763
|
+
const controllerAbort = new AbortController();
|
|
1764
|
+
void (async () => {
|
|
1765
|
+
try {
|
|
1766
|
+
for await (const frame of sessionController.control(controllerAbort.signal)) {
|
|
1767
|
+
runtime?.applyControlFrame(frame);
|
|
1768
|
+
}
|
|
1769
|
+
} catch {
|
|
1770
|
+
// A broken control stream costs the queue panel, never the session.
|
|
1771
|
+
}
|
|
1772
|
+
})();
|
|
1773
|
+
sourceCtx.effect(() => () => controllerAbort.abort(), `dsh-cindy-host: ${built.kind} control stream`);
|
|
1774
|
+
sourceCtx.effect(() => () => {
|
|
1775
|
+
if (sourceKind === built.kind) sourceKind = 'none';
|
|
1776
|
+
currentSource = undefined;
|
|
1777
|
+
currentSeam = {};
|
|
1778
|
+
currentListingDiagnostics = () => null;
|
|
1779
|
+
currentFiles = undefined;
|
|
1780
|
+
void runtime?.setSource(undefined);
|
|
1781
|
+
}, `dsh-cindy-host: ${built.kind} source`);
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// The live session stream. `session/event` fires for every appended event, so
|
|
1786
|
+
// it is the push path's source: fold the event into the controller's message
|
|
1787
|
+
// rows and hand them to whoever is watching that session.
|
|
1788
|
+
ctx.effect(() => ctx.on('session/event', guarded('session-event', async (session, event) => {
|
|
1789
|
+
// This handler runs inside Cordis's own dispatch, which has no `try`/`catch`: a throw
|
|
1790
|
+
// from here does not fail this plugin, it fails the process — DSH's boot installs an
|
|
1791
|
+
// unhandled-rejection handler that writes `fatal load failure` and calls `exit(1)`. The
|
|
1792
|
+
// work below reads DSH's session events and folds them, so it is exactly where an
|
|
1793
|
+
// unexpected event shape surfaces; it must never be able to take the desktop down.
|
|
1794
|
+
await handleSessionEvent(session, event);
|
|
1795
|
+
})), 'dsh-cindy-host: live session stream');
|
|
1796
|
+
|
|
1797
|
+
/** Fold one DSH session event into the push path and the transcript cache. */
|
|
1798
|
+
async function handleSessionEvent(session, event) {
|
|
1799
|
+
if (!runtime || typeof runtime.pushSessionMessage !== 'function') return;
|
|
1800
|
+
const sessionId = session?.header?.id;
|
|
1801
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
1802
|
+
// DSH's own turn boundaries are the authority for the phone's spinner.
|
|
1803
|
+
//
|
|
1804
|
+
// The projection's running flag is *not*: `api-session/status` reports
|
|
1805
|
+
// `running: false` for a moment between a prompt being accepted and its turn
|
|
1806
|
+
// really starting, and announcing that as the end produced a `done` **31 ms
|
|
1807
|
+
// after a live turn began** — the controller finalized its streaming rows and
|
|
1808
|
+
// stopped its timer while the agent was still generating. `turn/start` and
|
|
1809
|
+
// `turn/end` are the boundaries that mean what the phone reads into them.
|
|
1810
|
+
if (event?.type === 'turn/start' && typeof runtime.pushTurnRunning === 'function') {
|
|
1811
|
+
runtime.pushTurnRunning(sessionId);
|
|
1812
|
+
} else if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') {
|
|
1813
|
+
runtime.pushTurnIdle(sessionId);
|
|
1814
|
+
}
|
|
1815
|
+
// A real boundary needs no fallback, and arming one here is not harmless: this
|
|
1816
|
+
// handler runs *after* the boundary was announced, so it re-armed the very
|
|
1817
|
+
// timer the boundary had just cancelled and sent a duplicate `done` 1.5s later.
|
|
1818
|
+
const boundaryArrived = event?.type === 'turn/start' || event?.type === 'turn/end';
|
|
1819
|
+
// The prompt is now a durable message, so DSH's inbox no longer holds it and
|
|
1820
|
+
// the controller's optimistic queue row has to go — otherwise it shows
|
|
1821
|
+
// "队列中" for a message the agent already answered.
|
|
1822
|
+
const rpcId = promptRpcIdOf(event);
|
|
1823
|
+
if (rpcId !== null && typeof runtime.retireQueuedItem === 'function') runtime.retireQueuedItem(sessionId, rpcId);
|
|
1824
|
+
// The transcript cache is kept current for **every** session, watched or not.
|
|
1825
|
+
//
|
|
1826
|
+
// It is what makes paging cheap (`createMessageReader`): one full read seeds it,
|
|
1827
|
+
// and each appended event extends it, so a "load earlier" page costs O(page)
|
|
1828
|
+
// instead of re-reading and re-folding a whole session log — measured at 216 ms
|
|
1829
|
+
// warm and 2.3 s during a live turn on the 17 MB conversation the user reported
|
|
1830
|
+
// as 加载更早消息…每次只加载一点点. Skipping unwatched sessions would be wrong: the
|
|
1831
|
+
// cache's whole value is that it never needs re-reading, and the controller
|
|
1832
|
+
// reaches for a session's history through a fresh subscription, not through a
|
|
1833
|
+
// stream this process was already following.
|
|
1834
|
+
if (currentSeam !== undefined && currentSeam !== null && typeof currentSeam.readMessages === 'function'
|
|
1835
|
+
&& typeof currentSeam.readMessages.noteEvent === 'function') {
|
|
1836
|
+
currentSeam.readMessages.noteEvent(sessionId, event);
|
|
1837
|
+
currentSource?.invalidateActivityEvidence?.(sessionId);
|
|
1838
|
+
}
|
|
1839
|
+
// A harness notice is hidden from the phone (the fold returns no rows for it), and the count is
|
|
1840
|
+
// what keeps that hiding honest: "there was nothing to show" and "the Host dropped a message"
|
|
1841
|
+
// must not look alike from the desk.
|
|
1842
|
+
if (isHarnessNotice(event) && typeof runtime.noteSuppressedNotice === 'function') runtime.noteSuppressedNotice();
|
|
1843
|
+
// Nothing to do unless a controller is watching; the fold is not free.
|
|
1844
|
+
if (runtime.watchersFor(sessionId) === 0) return;
|
|
1845
|
+
// Awaited, because `pushSessionMessage` inlines the row's images first: a photo has to be
|
|
1846
|
+
// renderable in the frame that carries it, not one read later.
|
|
1847
|
+
for (const row of foldSessionEvent(event, { sessionId })) await runtime.pushSessionMessage(sessionId, row);
|
|
1848
|
+
// A durable message is a second witness that something happened, so it also
|
|
1849
|
+
// schedules the one check that makes sure the spinner cannot outlive the
|
|
1850
|
+
// turn it was watching — unless this event *was* the boundary.
|
|
1851
|
+
if (!boundaryArrived && typeof runtime.reconcileTurnState === 'function') runtime.reconcileTurnState(sessionId);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// DSH raises approvals as a waterfall and the chain's terminal answerer is
|
|
1855
|
+
// fail-closed. This answerer only serves questions for sessions a Cindy
|
|
1856
|
+
// controller is watching; in every other case it calls `next()` so the local
|
|
1857
|
+
// UI — or the fail-closed default — decides. Swallowing a question the user
|
|
1858
|
+
// could have answered at the desk would be worse than not answering at all.
|
|
1859
|
+
ctx.effect(() => ctx.on('approval/request', guarded('approval-request', async (req, next) => {
|
|
1860
|
+
if (!runtime || typeof runtime.beginApproval !== 'function') return next();
|
|
1861
|
+
const sessionId = req?.agent?.session?.header?.id;
|
|
1862
|
+
return arbitrateInteraction({
|
|
1863
|
+
request: req,
|
|
1864
|
+
next,
|
|
1865
|
+
begin: () => runtime.beginApproval({
|
|
1866
|
+
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
1867
|
+
toolName: typeof req?.toolName === 'string' ? req.toolName : '',
|
|
1868
|
+
reason: req?.reason,
|
|
1869
|
+
callId: req?.callId,
|
|
1870
|
+
signal: req?.signal,
|
|
1871
|
+
}),
|
|
1872
|
+
settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
|
|
1873
|
+
});
|
|
1874
|
+
// `prepend` and `global` are both REQUIRED, and this is the difference between
|
|
1875
|
+
// a phone that can answer a card and one that never sees one.
|
|
1876
|
+
//
|
|
1877
|
+
// `global`: DSH dispatches these waterfalls at the **agent's** scope
|
|
1878
|
+
// (`scopeTarget(agent, agent)`), and Cordis admits a listener to a filtered
|
|
1879
|
+
// dispatch only when it is global, untagged, or tagged with the dispatch key or
|
|
1880
|
+
// an ancestor (`EventsService.dispatch` → `hook.global || !filter || …`).
|
|
1881
|
+
//
|
|
1882
|
+
// `prepend`: the Web bundle composes `dsh-api-remotes`, which registers its own
|
|
1883
|
+
// listener on this same event and **parks the chain** while it waits for a
|
|
1884
|
+
// browser client to answer (`forwardWaterfall` holds a `Promise.withResolvers()`
|
|
1885
|
+
// and never calls `next()` until that remote answers). Registered after it, this
|
|
1886
|
+
// answerer never got a turn: the tool call simply hung, the session stayed
|
|
1887
|
+
// `running`, and the phone showed nothing. Going first means a Cindy controller
|
|
1888
|
+
// answers when one is watching; when none is, `next()` hands the question back
|
|
1889
|
+
// to the browser path unchanged.
|
|
1890
|
+
}), { prepend: true, global: true }), 'dsh-cindy-host: approval answerer');
|
|
1891
|
+
|
|
1892
|
+
// The `ask_user` tool blocks on a different waterfall from approvals, and the
|
|
1893
|
+
// controller renders it as its own card. Leaving it unanswered does not degrade
|
|
1894
|
+
// the conversation — it stops it: the tool call waits for a human who never saw
|
|
1895
|
+
// the question. Same rule as approvals: only claim questions for a session a
|
|
1896
|
+
// controller is watching, and pass the rest down the chain.
|
|
1897
|
+
ctx.effect(() => ctx.on('user-questions/request', guarded('user-questions-request', async (request, next) => {
|
|
1898
|
+
if (!runtime || typeof runtime.beginUserQuestion !== 'function') return next();
|
|
1899
|
+
const sessionId = request?.agent?.session?.header?.id;
|
|
1900
|
+
return arbitrateInteraction({
|
|
1901
|
+
request,
|
|
1902
|
+
next,
|
|
1903
|
+
begin: () => runtime.beginUserQuestion({
|
|
1904
|
+
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
1905
|
+
questions: request?.questions,
|
|
1906
|
+
signal: request?.signal,
|
|
1907
|
+
}),
|
|
1908
|
+
settleLocal: (requestId, outcome) => runtime.settleInteractionOutcome(requestId, outcome),
|
|
1909
|
+
});
|
|
1910
|
+
// Same `prepend` + `global` requirement as the approval answerer above, and the
|
|
1911
|
+
// same reason: this waterfall is agent-scoped and the Web bundle's remote
|
|
1912
|
+
// forwarding listener parks ahead of a late registration.
|
|
1913
|
+
}), { prepend: true, global: true }), 'dsh-cindy-host: user-question answerer');
|
|
1914
|
+
|
|
1915
|
+
// The settings page reaches the Host through this route. `webServer` is absent
|
|
1916
|
+
// in headless compositions, so the row is injected optionally: the runtime and
|
|
1917
|
+
// its settings still work, only the browser surface is missing.
|
|
1918
|
+
const routes = createHostRoutes({
|
|
1919
|
+
getRuntime: () => runtime,
|
|
1920
|
+
getDiagnostics: () => buildDiagnostics({ runtime, sourceKind, seam: currentSeam, listingDiagnostics: currentListingDiagnostics, boundaries: boundaryState() }),
|
|
1921
|
+
// The Host's own relay identity outlives the credential: a phone links to the
|
|
1922
|
+
// *device id*, so it is remembered here and reused when the credential is gone.
|
|
1923
|
+
getSettings: () => scope.get(),
|
|
1924
|
+
rememberDeviceId: (deviceId) => scope.update({ deviceId }),
|
|
1925
|
+
});
|
|
1926
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
1927
|
+
webCtx.effect(
|
|
1928
|
+
() => webCtx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler: routes.handle }),
|
|
1929
|
+
'dsh-cindy-host: settings-page API',
|
|
1930
|
+
);
|
|
1931
|
+
});
|
|
1932
|
+
}
|