@addozhang/dsh-discord 0.4.0 → 0.5.0-alpha.2
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/README.md +13 -14
- package/README.zh.md +12 -13
- package/cordis.patch.yml +1 -1
- package/lib/client/card-controller.d.ts +1 -1
- package/lib/client/card-form.d.ts +2 -1
- package/lib/client/index.d.ts +2 -2
- package/lib/client/slot-contract.d.ts +7 -0
- package/lib/client/snapshot-store.d.ts +1 -1
- package/lib/compose.d.ts +2 -2
- package/lib/compose.js +1 -1
- package/lib/dsh/host-asks.d.ts +96 -0
- package/lib/dsh/host-asks.js +151 -0
- package/lib/dsh/host-events.d.ts +42 -0
- package/lib/dsh/host-events.js +279 -0
- package/lib/dsh/host-face.d.ts +418 -0
- package/lib/dsh/host-face.js +598 -0
- package/lib/features/adapter-status.d.ts +15 -2
- package/lib/features/adapter-status.js +100 -4
- package/lib/features/approval-store.d.ts +1 -1
- package/lib/features/interaction-router.d.ts +2 -2
- package/lib/features/model-control.d.ts +1 -1
- package/lib/features/project-list.d.ts +1 -1
- package/lib/features/project-list.js +1 -1
- package/lib/features/session-resume.d.ts +1 -1
- package/lib/i18n.d.ts +5 -0
- package/lib/i18n.js +14 -0
- package/lib/index.js +121 -67
- package/lib/settings.js +5 -2
- package/lib/startup.d.ts +1 -1
- package/lib/startup.js +14 -7
- package/lib/stream/live.d.ts +13 -36
- package/lib/stream/live.js +157 -86
- package/lib/stream/tool-view.d.ts +12 -2
- package/lib/stream/tool-view.js +32 -2
- package/package.json +10 -12
- package/lib/dsh/api-proxy-face.d.ts +0 -400
- package/lib/dsh/api-proxy-face.js +0 -515
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed防腐层 over the Host's 0.1.6 controller services — the cordis
|
|
3
|
+
* services `sessionController`, `workspaceController`, and `sessionQuery`
|
|
4
|
+
* that replaced the 0.1.1-rc.2 `apiProxy` gateway. Domain methods speak the
|
|
5
|
+
* typert faces directly: plain request objects in, plain values out, and
|
|
6
|
+
* business rejections carried as thrown `RemoteError`s (`{code, message}`).
|
|
7
|
+
* Two guarantees are preserved from the rc.2 seam:
|
|
8
|
+
*
|
|
9
|
+
* 1. Boundedness — a Host that never answers must not wedge an interaction
|
|
10
|
+
* handler (or a Discord ephemeral) forever; every call races a timeout and
|
|
11
|
+
* resolves to an unobservable outcome instead.
|
|
12
|
+
* 2. Observability — every terminal outcome is reported through the injected
|
|
13
|
+
* log sink, so a silent-void call can never again be misread as a hang.
|
|
14
|
+
*
|
|
15
|
+
* The exported port-level API (function names, outcome unions) is unchanged
|
|
16
|
+
* from the rc.2 face: the Discord features and their tests keep their
|
|
17
|
+
* vocabulary while this module alone absorbs the host-side rebase.
|
|
18
|
+
*/
|
|
19
|
+
import { parseWorkspaceReference } from '../policy/disclosure.js';
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the 0.1.6 controller services off the Cordis context. Throws one
|
|
22
|
+
* actionable TypeError naming every absent service — the composition root
|
|
23
|
+
* treats that as a fail-loud startup boundary, never a silent half-mount.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveHostFace(ctx) {
|
|
26
|
+
const missing = [];
|
|
27
|
+
for (const name of ['sessionController', 'workspaceController', 'sessionQuery']) {
|
|
28
|
+
if (ctx.get(name) === undefined || ctx.get(name) === null)
|
|
29
|
+
missing.push(name);
|
|
30
|
+
}
|
|
31
|
+
if (missing.length > 0) {
|
|
32
|
+
throw new TypeError(`dsh-discord requires the 0.1.6 host controller services; missing ${missing.map(name => `'${name}'`).join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
session: ctx.get('sessionController'),
|
|
36
|
+
workspace: ctx.get('workspaceController'),
|
|
37
|
+
sessionQuery: ctx.get('sessionQuery'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Raised when the Host did not answer within the bounded window. */
|
|
41
|
+
export class RpcTimeoutError extends Error {
|
|
42
|
+
constructor(timeoutMs) {
|
|
43
|
+
super(`dsh host call did not answer within ${String(timeoutMs)}ms`);
|
|
44
|
+
this.name = 'RpcTimeoutError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Race one host promise against a bounded window. */
|
|
48
|
+
export function withRpcTimeout(promise, timeoutMs) {
|
|
49
|
+
let timer;
|
|
50
|
+
const timeout = new Promise((_, reject) => {
|
|
51
|
+
timer = setTimeout(() => { reject(new RpcTimeoutError(timeoutMs)); }, timeoutMs);
|
|
52
|
+
});
|
|
53
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
54
|
+
if (timer !== undefined)
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Default bounded window for unary catalog reads (local, no model work). */
|
|
59
|
+
const CATALOG_TIMEOUT_MS = 5_000;
|
|
60
|
+
/** Default bounded window for prompt admission (the Host may enqueue). */
|
|
61
|
+
const PROMPT_TIMEOUT_MS = 30_000;
|
|
62
|
+
/** Default bounded window for the model directory read. */
|
|
63
|
+
const MODELS_TIMEOUT_MS = 10_000;
|
|
64
|
+
/**
|
|
65
|
+
* A definitive Host business rejection: the 0.1.6 controllers throw
|
|
66
|
+
* `RemoteError` ({code, message}) for every admission refusal. Anything else
|
|
67
|
+
* in a catch is a host fault, not a business verdict.
|
|
68
|
+
*/
|
|
69
|
+
function remoteRejectionOf(error) {
|
|
70
|
+
const code = error?.code;
|
|
71
|
+
return typeof code === 'string' && code !== '' ? { code } : undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Defensive record probe: the wire is untrusted regardless of declared types. */
|
|
74
|
+
function isRecord(value) {
|
|
75
|
+
return typeof value === 'object' && value !== null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Map a Host RemoteError code onto the adapter's stable internal vocabulary:
|
|
79
|
+
* the `session/` namespace prefix is stripped (`session/agent-busy` →
|
|
80
|
+
* `agent-busy`), matching the rc.2 reasons the Discord copy interpolates.
|
|
81
|
+
* Unknown namespaces pass through verbatim.
|
|
82
|
+
*/
|
|
83
|
+
function rejectionReason(error) {
|
|
84
|
+
const rejection = remoteRejectionOf(error);
|
|
85
|
+
if (rejection === undefined)
|
|
86
|
+
return 'host-error';
|
|
87
|
+
return rejection.code.startsWith('session/')
|
|
88
|
+
? rejection.code.slice('session/'.length)
|
|
89
|
+
: rejection.code;
|
|
90
|
+
}
|
|
91
|
+
/** Pull exactly the first frame of one stream (the opening baseline). */
|
|
92
|
+
function firstFrameOf(stream) {
|
|
93
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
94
|
+
return iterator.next().then(result => {
|
|
95
|
+
if (result.done === true)
|
|
96
|
+
throw new Error('workspace stream closed before the baseline frame');
|
|
97
|
+
return result.value;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read the registry baseline as the follow stream's opening frame: one
|
|
102
|
+
* short-lived subscription, aborted the moment the baseline lands. Returns
|
|
103
|
+
* the narrowed baseline record, or undefined when the frame is malformed.
|
|
104
|
+
*/
|
|
105
|
+
async function readWorkspaceBaseline(dsh, timeoutMs) {
|
|
106
|
+
const per = new AbortController();
|
|
107
|
+
let frame;
|
|
108
|
+
try {
|
|
109
|
+
frame = await withRpcTimeout(firstFrameOf(dsh.workspace.follow(per.signal)), timeoutMs);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
per.abort();
|
|
113
|
+
}
|
|
114
|
+
const value = isRecord(frame) && frame['type'] === 'baseline' ? frame['value'] : undefined;
|
|
115
|
+
if (!isRecord(value) || !Array.isArray(value['items']))
|
|
116
|
+
throw new Error('workspace baseline frame is malformed');
|
|
117
|
+
return {
|
|
118
|
+
items: value['items'],
|
|
119
|
+
archivedSessionIds: Array.isArray(value['archivedSessionIds']) ? value['archivedSessionIds'] : [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The bind flow's catalog verifier: resolves an opaque `ws:` reference
|
|
124
|
+
* against the live workspace baseline. A well-formed reference the registry
|
|
125
|
+
* no longer knows — and any malformed one — resolve `stale` (fail-closed, no
|
|
126
|
+
* write can follow); a Host error is `failed`; a timeout is `unknown`.
|
|
127
|
+
*/
|
|
128
|
+
export function createWorkspaceResolver(dsh, options = {}) {
|
|
129
|
+
const port = createWorkspaceCatalogPort(dsh, options);
|
|
130
|
+
return {
|
|
131
|
+
async resolve(reference) {
|
|
132
|
+
const catalog = await port.listWorkspaces();
|
|
133
|
+
if (catalog.outcome !== 'completed') {
|
|
134
|
+
return catalog.outcome === 'unknown' ? { outcome: 'unknown' } : { outcome: 'failed' };
|
|
135
|
+
}
|
|
136
|
+
const id = parseWorkspaceReference(reference);
|
|
137
|
+
const found = id === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: catalog.workspaces.find(workspace => workspace.id === id);
|
|
140
|
+
return found === undefined
|
|
141
|
+
? { outcome: 'stale' }
|
|
142
|
+
: { outcome: 'found', workspace: { id: found.id, title: found.title } };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The `/project list` catalog port over the workspace registry baseline.
|
|
148
|
+
* Outcomes follow the port contract: a definitive Host error is `failed`
|
|
149
|
+
* (sanitized before Discord), while a timeout or unreadable body is
|
|
150
|
+
* `unknown` — delivery was not observed, so no retry is implied.
|
|
151
|
+
*/
|
|
152
|
+
export function createWorkspaceCatalogPort(dsh, options = {}) {
|
|
153
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
154
|
+
const log = options.log;
|
|
155
|
+
return {
|
|
156
|
+
async listWorkspaces() {
|
|
157
|
+
let baseline;
|
|
158
|
+
try {
|
|
159
|
+
baseline = await readWorkspaceBaseline(dsh, timeoutMs);
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
if (cause instanceof RpcTimeoutError) {
|
|
163
|
+
log?.('discord_workspace_list_timeout', { timeoutMs });
|
|
164
|
+
return { outcome: 'unknown' };
|
|
165
|
+
}
|
|
166
|
+
log?.('discord_workspace_list_threw', { cause: String(cause) });
|
|
167
|
+
return { outcome: 'failed' };
|
|
168
|
+
}
|
|
169
|
+
if (!isRecord(baseline) || !Array.isArray(baseline['items'])) {
|
|
170
|
+
log?.('discord_workspace_list_malformed');
|
|
171
|
+
return { outcome: 'failed' };
|
|
172
|
+
}
|
|
173
|
+
const rows = baseline['items'];
|
|
174
|
+
return {
|
|
175
|
+
outcome: 'completed',
|
|
176
|
+
workspaces: rows
|
|
177
|
+
.filter(workspace => isRecord(workspace) && typeof workspace['workspaceId'] === 'string' && workspace['workspaceId'] !== '')
|
|
178
|
+
.map(workspace => {
|
|
179
|
+
const row = workspace;
|
|
180
|
+
return {
|
|
181
|
+
id: row.workspaceId,
|
|
182
|
+
title: typeof row.title === 'string' ? row.title : row.workspaceId,
|
|
183
|
+
// The registered path rides every baseline row (Host
|
|
184
|
+
// WorkspaceView); /session resume scopes candidates by it and
|
|
185
|
+
// /project autocomplete abbreviates it — dropping it here once
|
|
186
|
+
// silently emptied the resume list everywhere (16.46).
|
|
187
|
+
...(typeof row.path === 'string' ? { path: row.path } : {}),
|
|
188
|
+
};
|
|
189
|
+
}),
|
|
190
|
+
// The registry's archived set rides the baseline (session.list rows
|
|
191
|
+
// carry NO archived marker): /session resume subtracts it — resuming
|
|
192
|
+
// an archived session dead-ends in a thread whose turns never run
|
|
193
|
+
// (16.49).
|
|
194
|
+
archivedSessionIds: Array.isArray(baseline['archivedSessionIds'])
|
|
195
|
+
? baseline['archivedSessionIds'].filter((id) => typeof id === 'string')
|
|
196
|
+
: [],
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Read one Workspace's full view (title plus canonical path). The path is
|
|
203
|
+
* for the administrator-only ephemeral info response — the disclosure
|
|
204
|
+
* policy owns whether it ever renders; this face only carries it in memory.
|
|
205
|
+
*/
|
|
206
|
+
export async function readWorkspaceDetail(dsh, reference, options = {}) {
|
|
207
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
208
|
+
const log = options.log;
|
|
209
|
+
let baseline;
|
|
210
|
+
try {
|
|
211
|
+
baseline = await readWorkspaceBaseline(dsh, timeoutMs);
|
|
212
|
+
}
|
|
213
|
+
catch (cause) {
|
|
214
|
+
if (cause instanceof RpcTimeoutError) {
|
|
215
|
+
log?.('discord_workspace_detail_timeout', { timeoutMs });
|
|
216
|
+
return { outcome: 'unknown' };
|
|
217
|
+
}
|
|
218
|
+
log?.('discord_workspace_detail_threw', { cause: String(cause) });
|
|
219
|
+
return { outcome: 'failed' };
|
|
220
|
+
}
|
|
221
|
+
const items = Array.isArray(baseline.items) ? baseline.items : [];
|
|
222
|
+
const id = parseWorkspaceReference(reference) ?? (reference === '' ? undefined : reference);
|
|
223
|
+
const found = items.find(workspace => workspace.workspaceId === id);
|
|
224
|
+
if (found === undefined)
|
|
225
|
+
return { outcome: 'stale' };
|
|
226
|
+
return {
|
|
227
|
+
outcome: 'found',
|
|
228
|
+
workspace: {
|
|
229
|
+
id: found.workspaceId,
|
|
230
|
+
title: found.title,
|
|
231
|
+
path: typeof found.path === 'string' ? found.path : undefined,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Submit one prompt turn through the session controller. A definitive Host
|
|
237
|
+
* rejection (thrown RemoteError) is a rejection carrying the sanitized
|
|
238
|
+
* reason; a timeout or host fault is `unknown` — the turn may or may not
|
|
239
|
+
* have been admitted, so callers must not resubmit. `options.rpcId` pins
|
|
240
|
+
* the adapter-owned stable request id, which the Host records on the
|
|
241
|
+
* durable user message (`source.rpcId`) and de-duplicates on — the 0.1.6
|
|
242
|
+
* admission layer replays `{accepted: true}` for a repeated id, keeping the
|
|
243
|
+
* at-most-once discipline observable. Images (16.50) encode as ordered
|
|
244
|
+
* `image` parts after the text part.
|
|
245
|
+
*/
|
|
246
|
+
export async function promptSession(dsh, request, options = {}) {
|
|
247
|
+
return submitPromptTurn(dsh, { ...request, mode: 'queue' }, options);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Steer the session's active turn: `session.prompt` with `mode: 'steer'`,
|
|
251
|
+
* carrying the same stable request-id discipline as the queue path.
|
|
252
|
+
*/
|
|
253
|
+
export async function steerSession(dsh, request, options = {}) {
|
|
254
|
+
return submitPromptTurn(dsh, { ...request, mode: 'steer' }, options);
|
|
255
|
+
}
|
|
256
|
+
async function submitPromptTurn(dsh, request, options) {
|
|
257
|
+
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
258
|
+
const log = options.log;
|
|
259
|
+
const per = new AbortController();
|
|
260
|
+
try {
|
|
261
|
+
await withRpcTimeout(dsh.session.prompt({
|
|
262
|
+
requestId: options.rpcId ?? crypto.randomUUID(),
|
|
263
|
+
sessionId: request.sessionId,
|
|
264
|
+
mode: request.mode,
|
|
265
|
+
content: [
|
|
266
|
+
{ type: 'text', text: request.prompt },
|
|
267
|
+
...(request.images ?? []).map(image => ({ type: 'image', mediaType: image.mediaType, data: image.base64 })),
|
|
268
|
+
],
|
|
269
|
+
}, per.signal), timeoutMs);
|
|
270
|
+
// The controller schema pins accepted to literal true; admission
|
|
271
|
+
// returning at all is the accepted verdict.
|
|
272
|
+
return { outcome: 'accepted' };
|
|
273
|
+
}
|
|
274
|
+
catch (cause) {
|
|
275
|
+
if (cause instanceof RpcTimeoutError) {
|
|
276
|
+
log?.('discord_prompt_submit_timeout', { timeoutMs, sessionId: request.sessionId });
|
|
277
|
+
return { outcome: 'unknown' };
|
|
278
|
+
}
|
|
279
|
+
const rejection = remoteRejectionOf(cause);
|
|
280
|
+
if (rejection !== undefined) {
|
|
281
|
+
log?.('discord_prompt_submit_rejected', {
|
|
282
|
+
code: rejection.code,
|
|
283
|
+
sessionId: request.sessionId,
|
|
284
|
+
});
|
|
285
|
+
return { outcome: 'rejected', reason: rejectionReason(cause) };
|
|
286
|
+
}
|
|
287
|
+
log?.('discord_prompt_submit_threw', { cause: String(cause), sessionId: request.sessionId });
|
|
288
|
+
return { outcome: 'unknown' };
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
// The host checks the signal only before admission; releasing it after
|
|
292
|
+
// the bounded window keeps the caller's cancellation observable.
|
|
293
|
+
per.abort();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Create one DSH Session against a preallocated id (design.md §10): the 0.1.6
|
|
298
|
+
* controller adopts the same session id idempotently, so an uncertain
|
|
299
|
+
* response never forks a second Session. Same outcome discipline as the
|
|
300
|
+
* prompt path.
|
|
301
|
+
*/
|
|
302
|
+
export async function createSessionViaProxy(dsh, request, options = {}) {
|
|
303
|
+
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
304
|
+
const log = options.log;
|
|
305
|
+
try {
|
|
306
|
+
const value = await withRpcTimeout(dsh.session.create({
|
|
307
|
+
workspaceId: request.workspaceId,
|
|
308
|
+
sessionId: request.sessionId,
|
|
309
|
+
}), timeoutMs);
|
|
310
|
+
if (!isRecord(value) || typeof value['sessionId'] !== 'string') {
|
|
311
|
+
log?.('discord_session_create_malformed', { sessionId: request.sessionId });
|
|
312
|
+
return { outcome: 'unknown' };
|
|
313
|
+
}
|
|
314
|
+
return { outcome: 'completed', sessionId: value['sessionId'] };
|
|
315
|
+
}
|
|
316
|
+
catch (cause) {
|
|
317
|
+
if (cause instanceof RpcTimeoutError) {
|
|
318
|
+
log?.('discord_session_create_timeout', { timeoutMs, sessionId: request.sessionId });
|
|
319
|
+
return { outcome: 'unknown' };
|
|
320
|
+
}
|
|
321
|
+
const rejection = remoteRejectionOf(cause);
|
|
322
|
+
if (rejection !== undefined) {
|
|
323
|
+
log?.('discord_session_create_rejected', { code: rejection.code, sessionId: request.sessionId });
|
|
324
|
+
return { outcome: 'rejected', reason: rejectionReason(cause) };
|
|
325
|
+
}
|
|
326
|
+
log?.('discord_session_create_threw', { cause: String(cause), sessionId: request.sessionId });
|
|
327
|
+
return { outcome: 'unknown' };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** List durable Session ids (`session.list` returns everything in one page). */
|
|
331
|
+
export async function listSessionIds(dsh, options = {}) {
|
|
332
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
333
|
+
const log = options.log;
|
|
334
|
+
let items;
|
|
335
|
+
try {
|
|
336
|
+
const value = await withRpcTimeout(dsh.session.list(undefined), timeoutMs);
|
|
337
|
+
// In-process the controller returns the BARE array; the {items} envelope
|
|
338
|
+
// is the typert wire shape. Accept both.
|
|
339
|
+
items = Array.isArray(value) ? value : (isRecord(value) && Array.isArray(value['items']) ? value['items'] : []);
|
|
340
|
+
}
|
|
341
|
+
catch (cause) {
|
|
342
|
+
if (cause instanceof RpcTimeoutError) {
|
|
343
|
+
log?.('discord_session_list_timeout', { timeoutMs });
|
|
344
|
+
return { outcome: 'unknown' };
|
|
345
|
+
}
|
|
346
|
+
log?.('discord_session_list_threw', { cause: String(cause) });
|
|
347
|
+
return { outcome: 'unknown' };
|
|
348
|
+
}
|
|
349
|
+
const ids = [];
|
|
350
|
+
for (const item of items) {
|
|
351
|
+
const row = item;
|
|
352
|
+
if (typeof row.sessionId === 'string' && row.sessionId !== '')
|
|
353
|
+
ids.push(row.sessionId);
|
|
354
|
+
}
|
|
355
|
+
return { outcome: 'completed', ids };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The rich `session.list` for the /session resume surface: titles ride each
|
|
359
|
+
* row's projection values (absence = the session has no title yet), blank
|
|
360
|
+
* sessions are flagged, and rows arrive updatedAt-descending. Defensive
|
|
361
|
+
* narrowing: the wire is untrusted, extra/missing fields never throw.
|
|
362
|
+
*/
|
|
363
|
+
export async function listSessionSummaries(dsh, options = {}) {
|
|
364
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
365
|
+
const log = options.log;
|
|
366
|
+
let items;
|
|
367
|
+
try {
|
|
368
|
+
const value = await withRpcTimeout(dsh.session.list(undefined), timeoutMs);
|
|
369
|
+
// In-process the controller returns the BARE array; the {items} envelope
|
|
370
|
+
// is the typert wire shape. Accept both.
|
|
371
|
+
items = Array.isArray(value) ? value : (isRecord(value) && Array.isArray(value['items']) ? value['items'] : []);
|
|
372
|
+
}
|
|
373
|
+
catch (cause) {
|
|
374
|
+
if (cause instanceof RpcTimeoutError) {
|
|
375
|
+
log?.('discord_session_summaries_timeout', { timeoutMs });
|
|
376
|
+
return { outcome: 'unknown' };
|
|
377
|
+
}
|
|
378
|
+
log?.('discord_session_summaries_threw', { cause: String(cause) });
|
|
379
|
+
return { outcome: 'unknown' };
|
|
380
|
+
}
|
|
381
|
+
const sessions = [];
|
|
382
|
+
// The wire is untrusted: narrow every row defensively before use.
|
|
383
|
+
for (const item of items) {
|
|
384
|
+
if (typeof item !== 'object' || item === null)
|
|
385
|
+
continue;
|
|
386
|
+
const row = item;
|
|
387
|
+
if (typeof row.sessionId !== 'string' || row.sessionId === '')
|
|
388
|
+
continue;
|
|
389
|
+
const values = row.projections?.values;
|
|
390
|
+
const title = typeof values?.title === 'string' && values.title !== '' ? values.title : undefined;
|
|
391
|
+
sessions.push({
|
|
392
|
+
sessionId: row.sessionId,
|
|
393
|
+
title,
|
|
394
|
+
updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
|
|
395
|
+
running: row.running ?? false,
|
|
396
|
+
blank: row.blank === true,
|
|
397
|
+
cwd: typeof row.cwd === 'string' ? row.cwd : undefined,
|
|
398
|
+
origin: row.origin === 'subagent' ? 'subagent' : undefined,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return { outcome: 'completed', sessions };
|
|
402
|
+
}
|
|
403
|
+
/** Cancel the session's active turn (`session.cancel`); DSH preserves the pending inbox. */
|
|
404
|
+
export async function cancelSessionViaProxy(dsh, request, options = {}) {
|
|
405
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
406
|
+
const log = options.log;
|
|
407
|
+
try {
|
|
408
|
+
await withRpcTimeout(dsh.session.cancel({ sessionId: request.sessionId }), timeoutMs);
|
|
409
|
+
return { outcome: 'accepted' };
|
|
410
|
+
}
|
|
411
|
+
catch (cause) {
|
|
412
|
+
if (cause instanceof RpcTimeoutError) {
|
|
413
|
+
log?.('discord_session_cancel_timeout', { timeoutMs, sessionId: request.sessionId });
|
|
414
|
+
return { outcome: 'unknown' };
|
|
415
|
+
}
|
|
416
|
+
const rejection = remoteRejectionOf(cause);
|
|
417
|
+
if (rejection !== undefined) {
|
|
418
|
+
log?.('discord_session_cancel_rejected', { code: rejection.code, sessionId: request.sessionId });
|
|
419
|
+
return { outcome: 'rejected', reason: rejectionReason(cause) };
|
|
420
|
+
}
|
|
421
|
+
log?.('discord_session_cancel_threw', { cause: String(cause), sessionId: request.sessionId });
|
|
422
|
+
return { outcome: 'unknown' };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** Remove one pending inbox item (`session.updateQueue`, action remove). */
|
|
426
|
+
export async function removeQueueItemViaProxy(dsh, request, options = {}) {
|
|
427
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
428
|
+
const log = options.log;
|
|
429
|
+
try {
|
|
430
|
+
await withRpcTimeout(dsh.session.updateQueue({
|
|
431
|
+
sessionId: request.sessionId,
|
|
432
|
+
itemId: request.itemId,
|
|
433
|
+
action: { kind: 'remove' },
|
|
434
|
+
}), timeoutMs);
|
|
435
|
+
return { outcome: 'accepted' };
|
|
436
|
+
}
|
|
437
|
+
catch (cause) {
|
|
438
|
+
if (cause instanceof RpcTimeoutError) {
|
|
439
|
+
log?.('discord_queue_remove_timeout', { timeoutMs, sessionId: request.sessionId });
|
|
440
|
+
return { outcome: 'unknown' };
|
|
441
|
+
}
|
|
442
|
+
const rejection = remoteRejectionOf(cause);
|
|
443
|
+
if (rejection !== undefined) {
|
|
444
|
+
log?.('discord_queue_remove_rejected', { code: rejection.code, sessionId: request.sessionId });
|
|
445
|
+
return { outcome: 'rejected', reason: rejectionReason(cause) };
|
|
446
|
+
}
|
|
447
|
+
log?.('discord_queue_remove_threw', { cause: String(cause), sessionId: request.sessionId });
|
|
448
|
+
return { outcome: 'unknown' };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Read the session's live selection off the `modelSelection` projection
|
|
453
|
+
* (wired view `{lastUsed, next}`); defensively accept either wired or raw
|
|
454
|
+
* (`pending`) state shapes. Any read failure resolves undefined — the
|
|
455
|
+
* catalog default takes over, never a hard failure.
|
|
456
|
+
*/
|
|
457
|
+
async function readSessionSelection(dsh, sessionId, options) {
|
|
458
|
+
const log = options.log;
|
|
459
|
+
let observation;
|
|
460
|
+
try {
|
|
461
|
+
observation = await withRpcTimeout(dsh.sessionQuery.observeSession(sessionId), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const projections = isRecord(observation) && isRecord(observation['projections'])
|
|
468
|
+
? observation['projections']
|
|
469
|
+
: undefined;
|
|
470
|
+
const values = projections !== undefined && isRecord(projections['values'])
|
|
471
|
+
? projections['values']
|
|
472
|
+
: undefined;
|
|
473
|
+
const selection = (values !== undefined ? values['modelSelection'] : undefined);
|
|
474
|
+
const candidate = selection?.next ?? selection?.pending ?? selection?.lastUsed;
|
|
475
|
+
if (candidate === null || candidate === undefined)
|
|
476
|
+
return undefined;
|
|
477
|
+
const typed = candidate;
|
|
478
|
+
if (typeof typed.provider !== 'string' || typeof typed.model !== 'string')
|
|
479
|
+
return undefined;
|
|
480
|
+
return {
|
|
481
|
+
provider: typed.provider,
|
|
482
|
+
model: typed.model,
|
|
483
|
+
...(typeof typed.reasoningEffort === 'string' ? { reasoningEffort: typed.reasoningEffort } : {}),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
const dispose = observation?.[Symbol.asyncDispose]
|
|
488
|
+
?? observation?.dispose;
|
|
489
|
+
if (typeof dispose === 'function') {
|
|
490
|
+
try {
|
|
491
|
+
void dispose.call(observation);
|
|
492
|
+
}
|
|
493
|
+
catch (cause) {
|
|
494
|
+
log?.('discord_session_selection_dispose_threw', { cause: String(cause) });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The session's detached model directory: the live selection, whether its
|
|
501
|
+
* route still serves, and the per-provider catalog groups the /model
|
|
502
|
+
* cascade browses. Composed from the 0.1.6 global `modelCatalog` plus the
|
|
503
|
+
* session's `modelSelection` projection — the rc.2 per-session
|
|
504
|
+
* `sessions.models` RPC no longer exists.
|
|
505
|
+
*/
|
|
506
|
+
export async function sessionModels(dsh, request, options = {}) {
|
|
507
|
+
const timeoutMs = options.timeoutMs ?? MODELS_TIMEOUT_MS;
|
|
508
|
+
const log = options.log;
|
|
509
|
+
let catalog;
|
|
510
|
+
try {
|
|
511
|
+
catalog = await withRpcTimeout(dsh.session.modelCatalog(), timeoutMs);
|
|
512
|
+
}
|
|
513
|
+
catch (cause) {
|
|
514
|
+
if (cause instanceof RpcTimeoutError) {
|
|
515
|
+
log?.('discord_models_timeout', { sessionId: request.sessionId });
|
|
516
|
+
return { outcome: 'unknown' };
|
|
517
|
+
}
|
|
518
|
+
const rejection = remoteRejectionOf(cause);
|
|
519
|
+
log?.('discord_models_failed', { sessionId: request.sessionId, code: rejection?.code ?? 'malformed' });
|
|
520
|
+
return { outcome: 'failed' };
|
|
521
|
+
}
|
|
522
|
+
if (!isRecord(catalog) || !Array.isArray(catalog['groups'])) {
|
|
523
|
+
log?.('discord_models_malformed', { sessionId: request.sessionId });
|
|
524
|
+
return { outcome: 'failed' };
|
|
525
|
+
}
|
|
526
|
+
const selection = await readSessionSelection(dsh, request.sessionId, options);
|
|
527
|
+
const fallback = isRecord(catalog['default']) ? catalog['default'] : {};
|
|
528
|
+
const current = selection ?? {
|
|
529
|
+
provider: typeof fallback['provider'] === 'string' ? fallback['provider'] : '',
|
|
530
|
+
model: typeof fallback['model'] === 'string' ? fallback['model'] : '',
|
|
531
|
+
...(typeof fallback['reasoningEffort'] === 'string' ? { reasoningEffort: fallback['reasoningEffort'] } : {}),
|
|
532
|
+
};
|
|
533
|
+
const routableProviders = Array.isArray(catalog['routableProviders']) ? catalog['routableProviders'] : [];
|
|
534
|
+
const routable = routableProviders.includes(current.provider);
|
|
535
|
+
return {
|
|
536
|
+
outcome: 'completed',
|
|
537
|
+
models: {
|
|
538
|
+
current,
|
|
539
|
+
routable,
|
|
540
|
+
groups: catalog['groups'],
|
|
541
|
+
failures: Array.isArray(catalog['failures']) ? catalog['failures'] : [],
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Select the complete model selection for one session (session.selectModel):
|
|
547
|
+
* the session switches immediately and the Host records the choice as the
|
|
548
|
+
* default for sessions that have not logged their own — the response only
|
|
549
|
+
* proves the session switch, so callers must not claim the persistence
|
|
550
|
+
* outcome (design.md §7).
|
|
551
|
+
*/
|
|
552
|
+
export async function selectSessionModel(dsh, request, options = {}) {
|
|
553
|
+
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
554
|
+
const log = options.log;
|
|
555
|
+
try {
|
|
556
|
+
const value = await withRpcTimeout(dsh.session.selectModel({
|
|
557
|
+
sessionId: request.sessionId,
|
|
558
|
+
provider: request.provider,
|
|
559
|
+
model: request.model,
|
|
560
|
+
...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
|
|
561
|
+
}), timeoutMs);
|
|
562
|
+
const selected = isRecord(value) ? value['selected'] : undefined;
|
|
563
|
+
if (!isRecord(selected) || typeof selected['provider'] !== 'string' || typeof selected['model'] !== 'string') {
|
|
564
|
+
log?.('discord_model_select_malformed', { sessionId: request.sessionId });
|
|
565
|
+
return { outcome: 'unknown' };
|
|
566
|
+
}
|
|
567
|
+
const chosen = {
|
|
568
|
+
provider: selected['provider'],
|
|
569
|
+
model: selected['model'],
|
|
570
|
+
...(typeof selected['reasoningEffort'] === 'string' ? { reasoningEffort: selected['reasoningEffort'] } : {}),
|
|
571
|
+
};
|
|
572
|
+
return { outcome: 'completed', selected: chosen };
|
|
573
|
+
}
|
|
574
|
+
catch (cause) {
|
|
575
|
+
if (cause instanceof RpcTimeoutError) {
|
|
576
|
+
log?.('discord_model_select_timeout', { sessionId: request.sessionId });
|
|
577
|
+
return { outcome: 'unknown' };
|
|
578
|
+
}
|
|
579
|
+
const rejection = remoteRejectionOf(cause);
|
|
580
|
+
if (rejection !== undefined) {
|
|
581
|
+
log?.('discord_model_select_rejected', { code: rejection.code, sessionId: request.sessionId });
|
|
582
|
+
return { outcome: 'rejected', reason: rejectionReason(cause) };
|
|
583
|
+
}
|
|
584
|
+
log?.('discord_model_select_threw', { cause: String(cause), sessionId: request.sessionId });
|
|
585
|
+
return { outcome: 'unknown' };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The /model surface over the session controller: the composed per-session
|
|
590
|
+
* directory and the guarded selection mutation — the shapes model-control
|
|
591
|
+
* reasons about.
|
|
592
|
+
*/
|
|
593
|
+
export function createModelPort(dsh, options = {}) {
|
|
594
|
+
return {
|
|
595
|
+
models: sessionId => sessionModels(dsh, { sessionId }, options),
|
|
596
|
+
selectModel: request => selectSessionModel(dsh, request, options),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
@@ -106,6 +106,19 @@ export interface ManagementChannelDeps {
|
|
|
106
106
|
export declare function createAdapterManagementHandler(deps: ManagementChannelDeps): (endpoint: string, payload: unknown, signal: {
|
|
107
107
|
aborted: boolean;
|
|
108
108
|
} | undefined) => Promise<RpcAnswer>;
|
|
109
|
-
/**
|
|
110
|
-
export
|
|
109
|
+
/** Narrow composition context: service accessor plus the plugin's effect scope. */
|
|
110
|
+
export interface HostChannelContext {
|
|
111
|
+
get(name: string): unknown;
|
|
112
|
+
effect(execute: () => unknown, label?: string): unknown;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Register the management channel on the webServer through the plugin's own
|
|
116
|
+
* context effect. The 0.1.6 host's `connection.rpc.handle` mounts through
|
|
117
|
+
* the connection service's OWN inject scope — an external plugin's call is
|
|
118
|
+
* rejected ("cannot get property webServer without inject") and a runtime
|
|
119
|
+
* ctx.inject wrapper never fires on an already-started plugin, so the
|
|
120
|
+
* sanctioned shape is: our prefix route, the connection service's fence
|
|
121
|
+
* method, and the channel envelope replicated verbatim.
|
|
122
|
+
*/
|
|
123
|
+
export declare function installAdapterStatusRpc(ctx: HostChannelContext, tracker: AdapterStatusTracker, deps?: ManagementChannelDeps): () => void;
|
|
111
124
|
export {};
|