@deepseek-ai/dsh-cordis-host-runner 0.2.2-alpha.3 → 0.2.2-rc.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/src/index.ts ADDED
@@ -0,0 +1,1290 @@
1
+ /**
2
+ * Dynamic Cordis Plugin service: immutable package definitions, one active run
3
+ * per Plugin, human-approved Client activation, and Host/Client invocation.
4
+ * @module @deepseek-ai/dsh-cordis-host-runner
5
+ */
6
+
7
+ import { Context } from '@deepseek-ai/cordis'
8
+ import type { Fiber } from '@deepseek-ai/cordis'
9
+ import z from '@deepseek-ai/schemastery'
10
+ import type { Agent } from '@deepseek-ai/dsh-agent'
11
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
12
+ declare module '@deepseek-ai/dsh-llm' {
13
+ interface MessageSourceMap {
14
+ 'cordis-host-runner': { kind: 'cordis-host-runner' }
15
+ }
16
+ }
17
+
18
+ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
19
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
20
+ import { isPlugin, normalizeHandler } from './guard.ts'
21
+ import { CordisInspectRegistryService } from './inspect-registry.ts'
22
+ import { missingServices, startHostHalf } from './lifecycle.ts'
23
+ import { DynamicCordisRegistry } from './registry.ts'
24
+ import type {
25
+ DynamicCordisDefineReceipt, DynamicCordisDefineRequest, DynamicCordisDefinition,
26
+ DynamicCordisPackageInspection, DynamicCordisPendingRequest, DynamicCordisPlugin,
27
+ DynamicCordisPluginInspection,
28
+ DynamicCordisReference, DynamicCordisRun,
29
+ } from './registry.ts'
30
+ import { createSandbox, evaluateHostCode, precheckCode } from './sandbox.ts'
31
+ import type {
32
+ ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, CordisErrorDetails,
33
+ CordisDynamicRunMode, CordisInspectProviderManifest, CordisInspectQueryResolution,
34
+ CordisInspectRequestId, CordisInspectResolveAck, DynamicCordisClientSource, DynamicCordisHostHalfResult,
35
+ DynamicCordisInventoryRow, DynamicCordisInvokeResult, DynamicCordisRenderFailure, DynamicCordisResolveAck,
36
+ DynamicCordisRunAttempt, DynamicCordisRunResolution, DynamicCordisRunResponse, DynamicCordisStopResponse,
37
+ DynamicCordisUndefineReceipt, RequestRunOutcome,
38
+ } from './types.ts'
39
+
40
+ export type * from './types.ts'
41
+ export type {
42
+ DynamicCordisDefineReceipt, DynamicCordisDefineRequest, DynamicCordisDefinition, DynamicCordisHandler,
43
+ DynamicCordisPackageInspection, DynamicCordisPlugin, DynamicCordisPluginInspection,
44
+ DynamicCordisReference, DynamicCordisRun,
45
+ } from './registry.ts'
46
+ export { CordisInspectRegistryService } from './inspect-registry.ts'
47
+ export type { HostCordisInspectProviderRegistration } from './inspect-registry.ts'
48
+
49
+ /**
50
+ * Brand a Host-minted Plugin ID.
51
+ * @param id - opaque identifier minted by the Host registry.
52
+ * @returns the branded Plugin identifier.
53
+ */
54
+ function brandCordisDynamicPluginId(id: string): CordisDynamicPluginId {
55
+ return id as CordisDynamicPluginId
56
+ }
57
+
58
+ /**
59
+ * Brand a Host-minted Package ID.
60
+ * @param id - opaque identifier minted by the Host registry.
61
+ * @returns the branded Package identifier.
62
+ */
63
+ function brandCordisDynamicPackageId(id: string): CordisDynamicPackageId {
64
+ return id as CordisDynamicPackageId
65
+ }
66
+
67
+ /**
68
+ * Brand a Host-minted Plugin Run ID.
69
+ * @param id - opaque identifier minted by the Host registry.
70
+ * @returns the branded Plugin Run identifier.
71
+ */
72
+ function brandCordisDynamicPluginRunId(id: string): CordisDynamicPluginRunId {
73
+ return id as CordisDynamicPluginRunId
74
+ }
75
+
76
+ /**
77
+ * Brand a Host-minted approval request ID.
78
+ * @param id - opaque identifier minted by the Host registry.
79
+ * @returns the branded approval request identifier.
80
+ */
81
+ function brandApprovalRequestId(id: string): ApprovalRequestId {
82
+ return id as ApprovalRequestId
83
+ }
84
+
85
+ // The public names are exported separately: a local declaration sharing a name
86
+ // with a type-only import is ambiguous for checkers that bind the import in
87
+ // value position.
88
+ export {
89
+ brandApprovalRequestId as ApprovalRequestId,
90
+ brandCordisDynamicPackageId as CordisDynamicPackageId,
91
+ brandCordisDynamicPluginId as CordisDynamicPluginId,
92
+ brandCordisDynamicPluginRunId as CordisDynamicPluginRunId,
93
+ }
94
+
95
+ declare module '@deepseek-ai/cordis' {
96
+ interface Context {
97
+ /** Process-local dynamic Plugin registry and lifecycle service. */
98
+ dynamicCordisRunner: DynamicCordisRunnerService
99
+ }
100
+ }
101
+
102
+ /** Runner configuration. */
103
+ export interface Config {
104
+ /** Maximum synchronous VM evaluation time in milliseconds. */
105
+ vmTimeoutMs?: number
106
+ /** Maximum wait for a valid Client inspect response in milliseconds. */
107
+ clientInspectTimeoutMs?: number
108
+ }
109
+
110
+ type ResolvedConfig = Required<Config>
111
+
112
+ /** Host-only snapshot consumed by inspect and tool result rendering. */
113
+ export interface DynamicCordisSnapshotRow {
114
+ pluginId: CordisDynamicPluginId
115
+ currentPackageId?: CordisDynamicPackageId
116
+ nextPackageId?: CordisDynamicPackageId
117
+ packages: Array<{
118
+ packageId: CordisDynamicPackageId
119
+ name: string
120
+ purpose: string
121
+ hasHostHalf: boolean
122
+ hasClientHalf: boolean
123
+ }>
124
+ activeRun?: {
125
+ pluginRunId: CordisDynamicPluginRunId
126
+ packageId: CordisDynamicPackageId
127
+ fiber?: Fiber
128
+ handlers: string[]
129
+ renderFailure?: DynamicCordisRenderFailure
130
+ }
131
+ latestRun?: DynamicCordisRunAttempt
132
+ }
133
+
134
+ interface ActivationPlan {
135
+ plugin: DynamicCordisPlugin
136
+ definition: DynamicCordisDefinition
137
+ mode: CordisDynamicRunMode
138
+ }
139
+
140
+ /** Dynamic Plugin registry and Host-half lifecycle. */
141
+ export class DynamicCordisRunnerService extends TypertRemoteService {
142
+ static inject = ['tools']
143
+
144
+ static Config: z<Config> = z.object({
145
+ vmTimeoutMs: z.number().min(1).default(5000),
146
+ clientInspectTimeoutMs: z.number().step(1).min(1).max(2_147_483_647).default(10_000),
147
+ })
148
+
149
+ private readonly rootCtx: Context
150
+ private readonly registry = new DynamicCordisRegistry()
151
+ private readonly inspectRegistry: CordisInspectRegistryService
152
+ private readonly starting = new Map<CordisDynamicPluginId, Promise<DynamicCordisHostHalfResult>>()
153
+ private readonly resolved: ResolvedConfig
154
+ private group: Fiber | undefined
155
+
156
+ /** Create the service under the Host composition. */
157
+ constructor(ctx: Context, config: Config) {
158
+ super(ctx, 'dynamicCordisRunner')
159
+ this.rootCtx = ctx
160
+ this.resolved = config as ResolvedConfig
161
+ this.inspectRegistry = new CordisInspectRegistryService(ctx, this.resolved.clientInspectTimeoutMs)
162
+ }
163
+
164
+ /**
165
+ * Define a new Plugin's first Package or append a Package to an existing Plugin.
166
+ * @param request - Session ownership, Plugin selection, metadata, and source code.
167
+ * @returns Host-minted Plugin and Package identities with declared-half metadata.
168
+ */
169
+ define(request: DynamicCordisDefineRequest): DynamicCordisDefineReceipt {
170
+ const name = request.name.trim()
171
+ const purpose = request.purpose.trim()
172
+ if (name.length === 0) throw new Error('cordis_define needs a non-empty `name`')
173
+ if (purpose.length === 0) throw new Error('cordis_define needs a non-empty `purpose`')
174
+ if (request.code.host === undefined && request.code.client === undefined) {
175
+ throw new Error('cordis_define needs `code.host`, `code.client`, or both')
176
+ }
177
+ if (request.code.host !== undefined) precheckCode(request.code.host, 'code.host')
178
+ if (request.code.client !== undefined) precheckCode(request.code.client, 'code.client')
179
+
180
+ let plugin: DynamicCordisPlugin
181
+ if (request.plugin.kind === 'new') {
182
+ const prefix = request.plugin.idPrefix.trim()
183
+ if (!/^[a-z]{3,6}$/.test(prefix)) {
184
+ throw new Error('cordis_define `plugin.idPrefix` must contain 3–6 lowercase English letters')
185
+ }
186
+ const pluginId = brandCordisDynamicPluginId(this.registry.mintPluginId(prefix))
187
+ plugin = {
188
+ pluginId,
189
+ sessionId: request.sessionId,
190
+ packages: new Map(),
191
+ approvedClientPackages: new Set(),
192
+ clientVersionUpdatesApproved: false,
193
+ }
194
+ this.registry.add(plugin)
195
+ } else {
196
+ const found = this.registry.get(request.plugin.pluginId)
197
+ if (found === undefined || found.sessionId !== request.sessionId) {
198
+ throw new Error(missingPluginMessage(request.plugin.pluginId))
199
+ }
200
+ plugin = found
201
+ }
202
+
203
+ const packageId = brandCordisDynamicPackageId(this.registry.mintPackageId())
204
+ const definition: DynamicCordisDefinition = {
205
+ packageId,
206
+ name,
207
+ purpose,
208
+ ...request.code.host === undefined ? {} : { hostCode: request.code.host },
209
+ ...request.code.client === undefined ? {} : { clientCode: request.code.client },
210
+ }
211
+ plugin.packages.set(packageId, definition)
212
+ return {
213
+ pluginId: plugin.pluginId,
214
+ packageId,
215
+ name,
216
+ purpose,
217
+ hasHostHalf: definition.hostCode !== undefined,
218
+ hasClientHalf: definition.clientCode !== undefined,
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Remove a Plugin, its active run, and all immutable Packages.
224
+ * @param agent - Agent whose Session must own the Plugin.
225
+ * @param pluginId - Stable Plugin identity to remove.
226
+ * @returns Whether removal succeeded and whether it stopped an active run.
227
+ */
228
+ async undefine(agent: Agent, pluginId: CordisDynamicPluginId): Promise<DynamicCordisUndefineReceipt> {
229
+ const plugin = this.owned(agent, pluginId)
230
+ if (plugin === undefined) return { ok: false, reason: 'plugin-missing', message: missingPluginMessage(pluginId) }
231
+ const wasRunning = plugin.run !== undefined
232
+ this.cancelPending(pluginId, `dynamic plugin "${pluginId}" was removed before approval`)
233
+ if (plugin.run !== undefined) await this.retract(plugin)
234
+ this.registry.delete(pluginId)
235
+ return { ok: true, wasRunning }
236
+ }
237
+
238
+ /**
239
+ * Remove a Plugin from the user panel and queue the resulting state change for the model's next step.
240
+ * @param agent - Agent whose Session owns the Plugin and receives the context.
241
+ * @param pluginId - Stable Plugin identity to remove.
242
+ * @returns Whether removal succeeded and whether it stopped an active run.
243
+ */
244
+ @Remote('undefineFromPanel')
245
+ async undefineFromPanel(agent: Agent, pluginId: CordisDynamicPluginId): Promise<DynamicCordisUndefineReceipt> {
246
+ const result = await this.undefine(agent, pluginId)
247
+ if (result.ok) {
248
+ this.injectUserContext(
249
+ agent,
250
+ `The user removed Cordis Plugin ${pluginId} and all of its Packages. The Plugin no longer exists.`,
251
+ )
252
+ }
253
+ return result
254
+ }
255
+
256
+ /**
257
+ * Start or update one Package for a model tool call. An unauthorized Client
258
+ * Package waits for approval; Plugin-wide authorization covers later versions.
259
+ * @param agent - Agent whose Session must own the Plugin.
260
+ * @param pluginId - Stable Plugin identity to activate.
261
+ * @param packageId - Immutable Package version to activate.
262
+ * @param mode - Whether to run the current version or switch versions.
263
+ * @param signal - Tool-call cancellation signal while the activation request is being created.
264
+ * @returns The successful activation identity or an actionable refusal.
265
+ */
266
+ async run(
267
+ agent: Agent,
268
+ pluginId: CordisDynamicPluginId,
269
+ packageId: CordisDynamicPackageId,
270
+ mode: CordisDynamicRunMode,
271
+ signal?: AbortSignal,
272
+ ): Promise<DynamicCordisRunResponse> {
273
+ const plan = this.resolvePlan(agent, pluginId, packageId, mode)
274
+ if (!plan.ok) return plan.response
275
+ if (signal?.aborted === true) {
276
+ return {
277
+ ok: false,
278
+ reason: 'cancelled',
279
+ message: `the run request for dynamic plugin "${pluginId}" was cancelled before activation`,
280
+ }
281
+ }
282
+ if (this.registry.pendingRequestFor(pluginId) !== undefined) {
283
+ return { ok: false, reason: 'transition-in-flight', message: `dynamic plugin "${pluginId}" already has a pending run request` }
284
+ }
285
+ const attempt = this.createAttempt(plan)
286
+ plan.plugin.nextPackageId = packageId
287
+ plan.plugin.latestRun = attempt
288
+ if (plan.definition.clientCode === undefined) {
289
+ const started = await this.activate(plan, undefined, false, attempt)
290
+ if (started.ok) return this.runResponse(plan.plugin, started)
291
+ this.failAttempt(plan.plugin, attempt, 'host-load', started)
292
+ return { ...started, reason: 'host-half-failed' }
293
+ }
294
+
295
+ const requestId = brandApprovalRequestId(this.registry.mintApprovalRequestId())
296
+ const requiresApproval = !plan.plugin.clientVersionUpdatesApproved
297
+ && !plan.plugin.approvedClientPackages.has(packageId)
298
+ attempt.approvalRequestId = requestId
299
+ attempt.requiresApproval = requiresApproval
300
+ attempt.status = requiresApproval ? 'awaiting-approval' : 'starting-host'
301
+ this.registry.armRequest(requestId, {
302
+ agentId: agent.id,
303
+ pluginId,
304
+ packageId,
305
+ pluginRunId: attempt.pluginRunId,
306
+ mode,
307
+ requiresApproval,
308
+ })
309
+ this.ctx.emit('cordis/request-run', {
310
+ requestId,
311
+ agentId: agent.id,
312
+ pluginId,
313
+ packageId,
314
+ mode,
315
+ name: plan.definition.name,
316
+ purpose: plan.definition.purpose,
317
+ requiresApproval,
318
+ })
319
+ return {
320
+ ok: true,
321
+ status: requiresApproval ? 'awaiting-approval' : 'starting',
322
+ pluginId,
323
+ packageId,
324
+ pluginRunId: attempt.pluginRunId,
325
+ mode,
326
+ waitingFor: [],
327
+ ...plan.plugin.currentPackageId === undefined ? {} : { currentPackageId: plan.plugin.currentPackageId },
328
+ nextPackageId: packageId,
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Start Host code for an approved request or a direct panel gesture.
334
+ * @param agent - Agent whose Session must own the Plugin.
335
+ * @param pluginId - Stable Plugin identity to activate.
336
+ * @param packageId - Immutable Package version to activate.
337
+ * @param mode - Whether to run the current version or switch versions.
338
+ * @param requestId - Model-driven request identity, or null for a direct user gesture.
339
+ * @param approveFutureVersions - Whether this approval covers later Packages of the same Plugin.
340
+ * @returns The exact Host activation or a failure message.
341
+ */
342
+ @Remote('runHostHalf')
343
+ async runHostHalf(
344
+ agent: Agent,
345
+ pluginId: CordisDynamicPluginId,
346
+ packageId: CordisDynamicPackageId,
347
+ mode: CordisDynamicRunMode,
348
+ requestId: ApprovalRequestId | null,
349
+ approveFutureVersions: boolean,
350
+ ): Promise<DynamicCordisHostHalfResult> {
351
+ const plan = this.resolvePlan(agent, pluginId, packageId, mode, requestId === null)
352
+ if (!plan.ok) return { ok: false, message: plan.response.message }
353
+ let attempt: DynamicCordisRunAttempt
354
+ if (requestId !== null) {
355
+ const pending = this.registry.peekRequest(requestId)
356
+ if (pending === undefined || pending.pluginId !== pluginId || pending.packageId !== packageId || pending.mode !== mode) {
357
+ return { ok: false, message: `run request "${requestId}" does not authorize ${pluginId}/${packageId}` }
358
+ }
359
+ const latest = plan.plugin.latestRun
360
+ const expectedStatus = pending.requiresApproval ? 'awaiting-approval' : 'starting-host'
361
+ if (latest === undefined || latest.pluginRunId !== pending.pluginRunId
362
+ || (latest.status !== expectedStatus && (!pending.requiresApproval && latest.status !== 'client-pending'))) {
363
+ return { ok: false, message: `run request "${requestId}" no longer identifies the latest run of ${pluginId}` }
364
+ }
365
+ attempt = latest
366
+ if (pending.requiresApproval) {
367
+ plan.plugin.approvedClientPackages.add(packageId)
368
+ if (approveFutureVersions) plan.plugin.clientVersionUpdatesApproved = true
369
+ }
370
+ } else {
371
+ const pending = this.registry.pendingRequestFor(pluginId)
372
+ if (pending !== undefined) return { ok: false, message: `dynamic plugin "${pluginId}" has pending run request ${pending}` }
373
+ const attached = plan.plugin.run?.packageId === packageId
374
+ && plan.plugin.latestRun?.pluginRunId === plan.plugin.run.pluginRunId
375
+ ? plan.plugin.latestRun
376
+ : undefined
377
+ attempt = attached ?? this.createAttempt(plan)
378
+ if (attached === undefined) {
379
+ plan.plugin.nextPackageId = packageId
380
+ plan.plugin.latestRun = attempt
381
+ }
382
+ if (plan.definition.clientCode !== undefined) plan.plugin.approvedClientPackages.add(packageId)
383
+ }
384
+ const attaching = attempt.pluginRunId === plan.plugin.run?.pluginRunId
385
+ if (!attaching) {
386
+ attempt.status = 'starting-host'
387
+ if (attempt.host.status !== 'absent') attempt.host = { status: 'pending', waitingFor: [] }
388
+ }
389
+ const started = await this.activate(plan, requestId ?? undefined, attaching, attempt)
390
+ if (!started.ok) this.failAttempt(plan.plugin, attempt, 'host-load', started)
391
+ return started
392
+ }
393
+
394
+ /**
395
+ * Fetch Client code for the exact active run.
396
+ * @param agent - Agent whose Session must own the Plugin.
397
+ * @param pluginId - Stable Plugin identity to read.
398
+ * @param pluginRunId - Exact active run authorized to receive source.
399
+ * @returns Client source and its Plugin, Package, and run identities.
400
+ */
401
+ @Remote('getClientCode')
402
+ getClientCode(
403
+ agent: Agent,
404
+ pluginId: CordisDynamicPluginId,
405
+ pluginRunId: CordisDynamicPluginRunId,
406
+ ): DynamicCordisClientSource {
407
+ const plugin = this.owned(agent, pluginId)
408
+ if (plugin === undefined) throw new Error(missingPluginMessage(pluginId))
409
+ const run = plugin.run
410
+ if (run === undefined || run.pluginRunId !== pluginRunId) {
411
+ throw new Error(`dynamic plugin "${pluginId}" is not running activation "${pluginRunId}"`)
412
+ }
413
+ const definition = plugin.packages.get(run.packageId)
414
+ if (definition?.clientCode === undefined) throw new Error(`package "${run.packageId}" has no Client half`)
415
+ return {
416
+ code: definition.clientCode,
417
+ name: definition.name,
418
+ pluginId,
419
+ packageId: run.packageId,
420
+ pluginRunId,
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Resolve one model-driven Client activation request.
426
+ * @param requestId - Request identity to settle once.
427
+ * @param resolution - Browser refusal or exact Client activation result.
428
+ * @returns Whether the still-pending request accepted this resolution.
429
+ */
430
+ @Remote('resolveRequestRun')
431
+ async resolveRequestRun(
432
+ requestId: ApprovalRequestId,
433
+ resolution: DynamicCordisRunResolution,
434
+ ): Promise<DynamicCordisResolveAck> {
435
+ const pending = this.registry.peekRequest(requestId)
436
+ if (pending === undefined) return { accepted: false }
437
+ const plugin = this.registry.get(pending.pluginId)
438
+ if (resolution.ok && plugin?.run?.pluginRunId !== resolution.pluginRunId) return { accepted: false }
439
+ if (!resolution.ok && resolution.pluginRunId !== undefined
440
+ && plugin?.run?.pluginRunId !== resolution.pluginRunId) return { accepted: false }
441
+ this.registry.claimRequest(requestId)
442
+ const settled = await this.settleActivation(plugin, resolution, requestId)
443
+ this.announceResolved(requestId, resolution, pending.requiresApproval ? undefined : 'completed')
444
+ this.steerRunOutcome(pending, settled)
445
+ return { accepted: true }
446
+ }
447
+
448
+ /**
449
+ * Settle a direct panel run after this page loaded or failed its Client half.
450
+ * @param agent - Agent whose Session must own the Plugin.
451
+ * @param pluginId - Stable Plugin identity being settled.
452
+ * @param resolution - Exact Client activation result from the acting page.
453
+ * @returns The committed activation or its failure.
454
+ */
455
+ @Remote('settleUserRun')
456
+ async settleUserRun(
457
+ agent: Agent,
458
+ pluginId: CordisDynamicPluginId,
459
+ resolution: DynamicCordisRunResolution,
460
+ ): Promise<DynamicCordisRunResponse> {
461
+ const plugin = this.owned(agent, pluginId)
462
+ if (plugin === undefined) return { ok: false, reason: 'plugin-missing', message: missingPluginMessage(pluginId) }
463
+ const settled = await this.settleActivation(plugin, resolution)
464
+ this.injectUserRunOutcome(agent, pluginId, settled)
465
+ return settled
466
+ }
467
+
468
+ /**
469
+ * Stop the active run while retaining every Package version.
470
+ * @param agent - Agent whose Session must own the Plugin.
471
+ * @param pluginId - Stable Plugin identity to stop.
472
+ * @returns Success or the reason no run was stopped.
473
+ */
474
+ async stop(agent: Agent, pluginId: CordisDynamicPluginId): Promise<DynamicCordisStopResponse> {
475
+ const plugin = this.owned(agent, pluginId)
476
+ if (plugin === undefined) return { ok: false, reason: 'plugin-missing', message: missingPluginMessage(pluginId) }
477
+ const pending = this.registry.pendingRequestFor(pluginId)
478
+ if (plugin.run === undefined && pending === undefined) {
479
+ return { ok: false, reason: 'not-running', message: `dynamic plugin "${pluginId}" is not running` }
480
+ }
481
+ if (pending !== undefined) this.cancelPending(pluginId, `dynamic plugin "${pluginId}" was stopped before approval`)
482
+ if (plugin.run !== undefined) await this.retract(plugin)
483
+ if (plugin.latestRun !== undefined) {
484
+ plugin.latestRun.status = 'stopped'
485
+ if (plugin.latestRun.host.status !== 'absent') plugin.latestRun.host = { status: 'stopped', waitingFor: [] }
486
+ if (plugin.latestRun.client.status !== 'absent') plugin.latestRun.client = { status: 'stopped', waitingFor: [] }
487
+ }
488
+ return { ok: true }
489
+ }
490
+
491
+ /**
492
+ * Stop a Plugin from the user panel and queue the resulting state change for the model's next step.
493
+ * @param agent - Agent whose Session owns the Plugin and receives the context.
494
+ * @param pluginId - Stable Plugin identity to stop.
495
+ * @returns Success or the reason no run was stopped.
496
+ */
497
+ @Remote('stopFromPanel')
498
+ async stopFromPanel(agent: Agent, pluginId: CordisDynamicPluginId): Promise<DynamicCordisStopResponse> {
499
+ const result = await this.stop(agent, pluginId)
500
+ if (!result.ok) return result
501
+ const plugin = this.owned(agent, pluginId)
502
+ this.injectUserContext(
503
+ agent,
504
+ `The user stopped Cordis Plugin ${pluginId}. Its Packages remain defined; currentPackageId is `
505
+ + `${plugin?.currentPackageId ?? 'none'}.`,
506
+ )
507
+ return result
508
+ }
509
+
510
+ /**
511
+ * Replace the Host mirror of the Client inspect provider directory.
512
+ * @param providers - complete Client provider manifest.
513
+ * @returns null after accepting the manifest.
514
+ */
515
+ @Remote('syncInspectManifest')
516
+ syncInspectManifest(providers: readonly CordisInspectProviderManifest[]): null {
517
+ this.inspectRegistry.syncClientManifest(providers)
518
+ return null
519
+ }
520
+
521
+ /**
522
+ * Submit a Client inspect result or failure for a pending query.
523
+ * @param agent - Session that owns the query.
524
+ * @param requestId - exact pending query identity.
525
+ * @param resolution - provider result or structured refusal.
526
+ * @returns acknowledgement with accepted true only for a valid success that settles the query;
527
+ * pending-query failures return { accepted: false } and retain only the first diagnostic.
528
+ */
529
+ @Remote('resolveInspectQuery')
530
+ resolveInspectQuery(
531
+ agent: Agent,
532
+ requestId: CordisInspectRequestId,
533
+ resolution: CordisInspectQueryResolution,
534
+ ): CordisInspectResolveAck {
535
+ return this.inspectRegistry.resolveClientQuery(agent, requestId, resolution)
536
+ }
537
+
538
+ /**
539
+ * Frame-wide inventory, grouped as one row per stable Plugin.
540
+ * @returns Source-free metadata for every process-local Plugin.
541
+ */
542
+ /* jscpd:ignore-start */
543
+ @Remote('inventory')
544
+ inventory(): DynamicCordisInventoryRow[] {
545
+ return this.registry.all().map(plugin => ({
546
+ pluginId: plugin.pluginId,
547
+ agentId: plugin.sessionId,
548
+ packages: [...plugin.packages.values()].map(definition => ({
549
+ packageId: definition.packageId,
550
+ name: definition.name,
551
+ purpose: definition.purpose,
552
+ hasHostHalf: definition.hostCode !== undefined,
553
+ hasClientHalf: definition.clientCode !== undefined,
554
+ })),
555
+ ...plugin.currentPackageId === undefined ? {} : { currentPackageId: plugin.currentPackageId },
556
+ ...plugin.nextPackageId === undefined ? {} : { nextPackageId: plugin.nextPackageId },
557
+ ...plugin.run === undefined ? {} : {
558
+ activeRun: { pluginRunId: plugin.run.pluginRunId, packageId: plugin.run.packageId },
559
+ },
560
+ ...plugin.latestRun === undefined ? {} : { latestRun: cloneAttempt(plugin.latestRun) },
561
+ }))
562
+ }
563
+ /* jscpd:ignore-end */
564
+
565
+ /**
566
+ * Read one Session's Host-rich state for inspection and result rendering.
567
+ * @param agent - Agent whose Session selects visible Plugins.
568
+ * @returns Plugin versions, active runs, Host fibers, and render failures.
569
+ */
570
+ snapshot(agent: Agent): DynamicCordisSnapshotRow[] {
571
+ return this.registry.ofSession(agent.id).map(plugin => ({
572
+ pluginId: plugin.pluginId,
573
+ ...plugin.currentPackageId === undefined ? {} : { currentPackageId: plugin.currentPackageId },
574
+ ...plugin.nextPackageId === undefined ? {} : { nextPackageId: plugin.nextPackageId },
575
+ packages: [...plugin.packages.values()].map(definition => ({
576
+ packageId: definition.packageId,
577
+ name: definition.name,
578
+ purpose: definition.purpose,
579
+ hasHostHalf: definition.hostCode !== undefined,
580
+ hasClientHalf: definition.clientCode !== undefined,
581
+ })),
582
+ ...plugin.run === undefined ? {} : {
583
+ activeRun: {
584
+ pluginRunId: plugin.run.pluginRunId,
585
+ packageId: plugin.run.packageId,
586
+ ...plugin.run.fiber === undefined ? {} : { fiber: plugin.run.fiber },
587
+ handlers: [...plugin.run.handlers.keys()],
588
+ ...plugin.run.renderFailure === undefined ? {} : { renderFailure: plugin.run.renderFailure },
589
+ },
590
+ },
591
+ ...plugin.latestRun === undefined ? {} : { latestRun: cloneAttempt(plugin.latestRun) },
592
+ }))
593
+ }
594
+
595
+ /**
596
+ * Read source-free context for an explicit `@pluginId` user gesture.
597
+ * @param agent - Agent whose Session must own the Plugin.
598
+ * @param pluginId - Stable Plugin identity referenced by the user.
599
+ * @returns The preferred modification base, or undefined when unavailable.
600
+ */
601
+ reference(agent: Agent, pluginId: CordisDynamicPluginId): DynamicCordisReference | undefined {
602
+ const plugin = this.owned(agent, pluginId)
603
+ if (plugin === undefined) return undefined
604
+ const packageId = plugin.nextPackageId
605
+ ?? plugin.currentPackageId
606
+ ?? [...plugin.packages.keys()].at(-1)
607
+ if (packageId === undefined) return undefined
608
+ const definition = plugin.packages.get(packageId)
609
+ if (definition === undefined) return undefined
610
+ return {
611
+ pluginId,
612
+ packageId,
613
+ name: definition.name,
614
+ purpose: definition.purpose,
615
+ ...plugin.currentPackageId === undefined ? {} : { currentPackageId: plugin.currentPackageId },
616
+ ...plugin.nextPackageId === undefined ? {} : { nextPackageId: plugin.nextPackageId },
617
+ ...plugin.run === undefined ? {} : {
618
+ activeRun: { pluginRunId: plugin.run.pluginRunId, packageId: plugin.run.packageId },
619
+ },
620
+ ...plugin.latestRun === undefined ? {} : { latestRun: cloneAttempt(plugin.latestRun) },
621
+ }
622
+ }
623
+
624
+ /**
625
+ * List source-free Plugin summaries owned by one Session.
626
+ * @param agent - Agent whose Session selects visible Plugins.
627
+ * @returns one summary per Plugin in creation order.
628
+ */
629
+ listPlugins(agent: Agent): DynamicCordisPluginInspection[] {
630
+ return this.registry.ofSession(agent.id).map(plugin => this.inspectPlugin(agent, plugin.pluginId))
631
+ }
632
+
633
+ /**
634
+ * Inspect one Plugin without returning Package source.
635
+ * @param agent - Agent whose Session must own the Plugin.
636
+ * @param pluginId - stable Plugin identity.
637
+ * @returns version pointers, latest run, and all Package summaries.
638
+ */
639
+ inspectPlugin(agent: Agent, pluginId: CordisDynamicPluginId): DynamicCordisPluginInspection {
640
+ const plugin = this.owned(agent, pluginId)
641
+ if (plugin === undefined) throw new Error(missingPluginMessage(pluginId))
642
+ const reference = this.reference(agent, pluginId)
643
+ if (reference === undefined) throw new Error(`dynamic plugin "${pluginId}" has no package`)
644
+ return {
645
+ ...reference,
646
+ packages: [...plugin.packages.values()].map(definition => ({
647
+ packageId: definition.packageId,
648
+ name: definition.name,
649
+ purpose: definition.purpose,
650
+ hasHostHalf: definition.hostCode !== undefined,
651
+ hasClientHalf: definition.clientCode !== undefined,
652
+ })),
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Read one exact immutable Package and its Host and Client source.
658
+ * @param agent - Agent whose Session must own the Plugin.
659
+ * @param pluginId - Stable Plugin identity that owns the Package.
660
+ * @param packageId - Exact immutable Package identity to inspect.
661
+ * @returns Package metadata, source, and the Plugin's lifecycle pointers.
662
+ */
663
+ inspectPackage(
664
+ agent: Agent,
665
+ pluginId: CordisDynamicPluginId,
666
+ packageId: CordisDynamicPackageId,
667
+ ): DynamicCordisPackageInspection {
668
+ const plugin = this.owned(agent, pluginId)
669
+ if (plugin === undefined) throw new Error(missingPluginMessage(pluginId))
670
+ const definition = plugin.packages.get(packageId)
671
+ if (definition === undefined) {
672
+ throw new Error(`dynamic package "${packageId}" does not exist on plugin "${pluginId}"`)
673
+ }
674
+ return {
675
+ pluginId,
676
+ packageId,
677
+ name: definition.name,
678
+ purpose: definition.purpose,
679
+ code: {
680
+ ...definition.hostCode === undefined ? {} : { host: definition.hostCode },
681
+ ...definition.clientCode === undefined ? {} : { client: definition.clientCode },
682
+ },
683
+ /* jscpd:ignore-start */
684
+ ...plugin.currentPackageId === undefined ? {} : { currentPackageId: plugin.currentPackageId },
685
+ ...plugin.nextPackageId === undefined ? {} : { nextPackageId: plugin.nextPackageId },
686
+ ...plugin.run === undefined ? {} : {
687
+ activeRun: { pluginRunId: plugin.run.pluginRunId, packageId: plugin.run.packageId },
688
+ },
689
+ ...plugin.latestRun === undefined ? {} : { latestRun: cloneAttempt(plugin.latestRun) },
690
+ /* jscpd:ignore-end */
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Record a post-load render failure for the exact active run.
696
+ * @param agent - Agent whose Session must own the Plugin.
697
+ * @param pluginId - Stable Plugin identity that rendered.
698
+ * @param pluginRunId - Exact active run that produced the failure.
699
+ * @param failure - Slot, message, and entry-retirement result.
700
+ * @returns Null after recording or ignoring a stale report.
701
+ */
702
+ @Remote('reportRenderFailure')
703
+ async reportRenderFailure(
704
+ agent: Agent,
705
+ pluginId: CordisDynamicPluginId,
706
+ pluginRunId: CordisDynamicPluginRunId,
707
+ failure: DynamicCordisRenderFailure,
708
+ ): Promise<null> {
709
+ const plugin = this.owned(agent, pluginId)
710
+ if (plugin?.run?.pluginRunId === pluginRunId) {
711
+ const run = plugin.run
712
+ const definition = plugin.packages.get(plugin.run.packageId)
713
+ const shouldSteer = run.renderFailure === undefined
714
+ run.renderFailure = failure
715
+ const attempt = plugin.latestRun
716
+ if (attempt?.pluginRunId === pluginRunId) {
717
+ attempt.error = this.diagnostic(plugin, attempt, 'client-render', failure)
718
+ attempt.client = { status: 'failed', waitingFor: attempt.client.waitingFor, error: failure.message }
719
+ attempt.status = 'failed'
720
+ }
721
+ if (definition !== undefined && shouldSteer) {
722
+ this.steerRenderFailure(agent, plugin, definition, pluginRunId, failure)
723
+ }
724
+ }
725
+ return await Promise.resolve(null)
726
+ }
727
+
728
+ /**
729
+ * Report a Client guard rejection that happened after the Package completed activation.
730
+ * @param agent - Agent whose Session must own the Plugin.
731
+ * @param pluginId - Stable Plugin identity whose Client code was rejected.
732
+ * @param pluginRunId - Exact active run that produced the rejection.
733
+ * @param failure - Original guard message and stack.
734
+ * @returns Null after reporting or ignoring a stale/startup failure.
735
+ */
736
+ @Remote('reportClientGuardFailure')
737
+ async reportClientGuardFailure(
738
+ agent: Agent,
739
+ pluginId: CordisDynamicPluginId,
740
+ pluginRunId: CordisDynamicPluginRunId,
741
+ failure: CordisErrorDetails,
742
+ ): Promise<null> {
743
+ const plugin = this.owned(agent, pluginId)
744
+ const run = plugin?.run
745
+ if (plugin !== undefined && run?.pluginRunId === pluginRunId) {
746
+ this.steerGuardFailure(plugin, run, 'Client', failure)
747
+ }
748
+ return await Promise.resolve(null)
749
+ }
750
+
751
+ /**
752
+ * Invoke an active Host method while rejecting stale Client runs.
753
+ * @param pluginId - Stable Plugin identity that owns the method.
754
+ * @param pluginRunId - Exact active run authorizing the call.
755
+ * @param method - Registered Host handler name.
756
+ * @param args - JSON argument delivered to the handler.
757
+ * @returns The JSON result or a typed invocation failure.
758
+ */
759
+ @Remote('invoke')
760
+ async invoke(
761
+ pluginId: CordisDynamicPluginId,
762
+ pluginRunId: CordisDynamicPluginRunId,
763
+ method: string,
764
+ args: JsonValue,
765
+ ): Promise<DynamicCordisInvokeResult> {
766
+ const plugin = this.registry.get(pluginId)
767
+ if (plugin === undefined || plugin.run === undefined) {
768
+ return { ok: false, code: 'plugin-not-running', message: `dynamic plugin "${pluginId}" is not running` }
769
+ }
770
+ const run = plugin.run
771
+ if (run.pluginRunId !== pluginRunId) {
772
+ return { ok: false, code: 'stale-run', message: `activation "${pluginRunId}" is no longer active` }
773
+ }
774
+ const handler = run.handlers.get(method)
775
+ if (handler === undefined) {
776
+ return { ok: false, code: 'method-not-found', message: `dynamic plugin "${pluginId}" registered no Host method "${method}"` }
777
+ }
778
+ try {
779
+ return { ok: true, value: await handler(args) as JsonValue }
780
+ } catch (error) {
781
+ const failure = errorDetails(error)
782
+ this.steerHostHandlerFailure(plugin, run, method, failure)
783
+ return { ok: false, code: 'handler-error', ...failure }
784
+ }
785
+ }
786
+
787
+ private resolvePlan(
788
+ agent: Agent,
789
+ pluginId: CordisDynamicPluginId,
790
+ packageId: CordisDynamicPackageId,
791
+ mode: CordisDynamicRunMode,
792
+ allowActiveAttach = false,
793
+ ): { ok: true } & ActivationPlan | { ok: false; response: Extract<DynamicCordisRunResponse, { ok: false }> } {
794
+ const plugin = this.owned(agent, pluginId)
795
+ if (plugin === undefined) return { ok: false, response: { ok: false, reason: 'plugin-missing', message: missingPluginMessage(pluginId) } }
796
+ const definition = plugin.packages.get(packageId)
797
+ if (definition === undefined) {
798
+ return { ok: false, response: { ok: false, reason: 'package-missing', message: `plugin "${pluginId}" has no package "${packageId}"` } }
799
+ }
800
+ const current = plugin.currentPackageId
801
+ if (mode === 'update' && (current === undefined || current === packageId)) {
802
+ return {
803
+ ok: false,
804
+ response: {
805
+ ok: false,
806
+ reason: 'invalid-mode',
807
+ message: current === undefined
808
+ ? `plugin "${pluginId}" has no successful version yet; start "${packageId}" with mode "run"`
809
+ : `package "${packageId}" is already current; use mode "run"`,
810
+ },
811
+ }
812
+ }
813
+ if (mode === 'run' && current !== undefined && current !== packageId) {
814
+ return {
815
+ ok: false,
816
+ response: {
817
+ ok: false,
818
+ reason: 'invalid-mode',
819
+ message: `package "${packageId}" differs from current "${current}"; use mode "update"`,
820
+ },
821
+ }
822
+ }
823
+ if (!allowActiveAttach && this.starting.has(pluginId)) {
824
+ return { ok: false, response: { ok: false, reason: 'transition-in-flight', message: `plugin "${pluginId}" is already starting` } }
825
+ }
826
+ return { ok: true, plugin, definition, mode }
827
+ }
828
+
829
+ private activate(
830
+ plan: ActivationPlan,
831
+ requestId: ApprovalRequestId | undefined,
832
+ allowActiveAttach: boolean,
833
+ attempt: DynamicCordisRunAttempt,
834
+ ): Promise<DynamicCordisHostHalfResult> {
835
+ const inFlight = this.starting.get(plan.plugin.pluginId)
836
+ if (inFlight !== undefined) return inFlight
837
+ const starting = this.startFresh(plan, requestId, allowActiveAttach, attempt)
838
+ this.starting.set(plan.plugin.pluginId, starting)
839
+ return starting.finally(() => { this.starting.delete(plan.plugin.pluginId) })
840
+ }
841
+
842
+ private async startFresh(
843
+ plan: ActivationPlan,
844
+ requestId: ApprovalRequestId | undefined,
845
+ allowActiveAttach: boolean,
846
+ attempt: DynamicCordisRunAttempt,
847
+ ): Promise<DynamicCordisHostHalfResult> {
848
+ const { plugin, definition, mode } = plan
849
+ if (allowActiveAttach
850
+ && plugin.run?.packageId === definition.packageId
851
+ && plugin.run.pluginRunId === attempt.pluginRunId) {
852
+ return {
853
+ ok: true,
854
+ pluginId: plugin.pluginId,
855
+ packageId: definition.packageId,
856
+ pluginRunId: plugin.run.pluginRunId,
857
+ waitingFor: missingFor(this.ctx, plugin.run),
858
+ startedHere: false,
859
+ }
860
+ }
861
+ if (plugin.run !== undefined) await this.retract(plugin)
862
+ if (mode === 'update' || plugin.currentPackageId === undefined) plugin.nextPackageId = definition.packageId
863
+ const run: DynamicCordisRun = {
864
+ pluginRunId: attempt.pluginRunId,
865
+ packageId: definition.packageId,
866
+ handlers: new Map(),
867
+ handlerDisposers: [],
868
+ reportedRuntimeErrors: new Set(),
869
+ ...requestId === undefined ? {} : { startedForRequest: requestId },
870
+ }
871
+ if (definition.hostCode !== undefined) {
872
+ const failure = await this.startHost(plugin, definition.hostCode, run)
873
+ if (failure !== undefined) return { ok: false, ...failure }
874
+ }
875
+ plugin.run = run
876
+ this.ctx.emit('cordis/dynamic-package', {
877
+ pluginId: plugin.pluginId,
878
+ packageId: definition.packageId,
879
+ pluginRunId: run.pluginRunId,
880
+ name: definition.name,
881
+ })
882
+ attempt.host = {
883
+ status: run.fiber === undefined ? 'absent' : missingFor(this.ctx, run).length === 0 ? 'running' : 'waiting',
884
+ waitingFor: missingFor(this.ctx, run),
885
+ }
886
+ if (definition.clientCode === undefined) {
887
+ this.commitActivation(plugin, run)
888
+ } else {
889
+ attempt.status = 'client-pending'
890
+ attempt.client = { status: 'pending', waitingFor: [] }
891
+ }
892
+ return {
893
+ ok: true,
894
+ pluginId: plugin.pluginId,
895
+ packageId: definition.packageId,
896
+ pluginRunId: run.pluginRunId,
897
+ waitingFor: missingFor(this.ctx, run),
898
+ startedHere: true,
899
+ }
900
+ }
901
+
902
+ private async startHost(
903
+ plugin: DynamicCordisPlugin,
904
+ hostCode: string,
905
+ run: DynamicCordisRun,
906
+ ): Promise<CordisErrorDetails | undefined> {
907
+ const handle = (method: unknown, fn: unknown): (() => void) => {
908
+ const normalized = normalizeHandler(method, fn)
909
+ run.handlers.set(normalized.method, normalized.handler)
910
+ const dispose = (): void => {
911
+ if (run.handlers.get(normalized.method) === normalized.handler) run.handlers.delete(normalized.method)
912
+ }
913
+ run.handlerDisposers.push(dispose)
914
+ return dispose
915
+ }
916
+ try {
917
+ const sandbox = createSandbox(plugin.pluginId, { handle })
918
+ const evaluated = await evaluateHostCode(sandbox, hostCode, plugin.pluginId, this.resolved.vmTimeoutMs)
919
+ if (!isPlugin(evaluated)) {
920
+ throw new Error(evaluated === undefined
921
+ ? 'the Host half returned `undefined` — did you forget `return`?'
922
+ : 'the Host half must return a Plugin function or an object with apply(ctx)')
923
+ }
924
+ run.fiber = await startHostHalf(
925
+ this.requireGroup(),
926
+ evaluated,
927
+ (error) => { this.steerGuardFailure(plugin, run, 'Host', errorDetails(error)) },
928
+ )
929
+ return undefined
930
+ } catch (error) {
931
+ for (const dispose of run.handlerDisposers.splice(0)) dispose()
932
+ return errorDetails(error)
933
+ }
934
+ }
935
+
936
+ private async settleActivation(
937
+ plugin: DynamicCordisPlugin | undefined,
938
+ resolution: DynamicCordisRunResolution,
939
+ requestId?: ApprovalRequestId,
940
+ ): Promise<DynamicCordisRunResponse> {
941
+ if (plugin === undefined) return { ok: false, reason: 'plugin-missing', message: 'the dynamic plugin was removed during activation' }
942
+ const attempt = plugin.latestRun
943
+ if (!resolution.ok) {
944
+ if (resolution.reason === 'rejected') {
945
+ if (attempt !== undefined) {
946
+ attempt.status = 'rejected'
947
+ attempt.error = this.diagnostic(plugin, attempt, 'approval', resolution.message ?? 'the run request was declined')
948
+ attempt.client = { status: 'stopped', waitingFor: [] }
949
+ }
950
+ return { ok: false, reason: 'rejected', message: resolution.message ?? 'the run request was declined' }
951
+ }
952
+ const run = plugin.run
953
+ const ownsRun = run !== undefined
954
+ && resolution.pluginRunId === run.pluginRunId
955
+ && (requestId === undefined || run.startedForRequest === requestId)
956
+ && resolution.startedHere !== false
957
+ if (ownsRun) await this.retract(plugin)
958
+ if (attempt !== undefined && (resolution.pluginRunId === undefined || attempt.pluginRunId === resolution.pluginRunId)) {
959
+ this.failAttempt(
960
+ plugin,
961
+ attempt,
962
+ resolution.reason === 'host-half-failed' ? 'host-apply' : 'client-apply',
963
+ {
964
+ message: resolution.message ?? resolution.reason,
965
+ ...resolution.stack === undefined ? {} : { stack: resolution.stack },
966
+ },
967
+ )
968
+ }
969
+ return {
970
+ ok: false,
971
+ reason: resolution.reason,
972
+ message: resolution.message ?? resolution.reason,
973
+ ...resolution.stack === undefined ? {} : { stack: resolution.stack },
974
+ }
975
+ }
976
+ const run = plugin.run
977
+ if (run === undefined || run.pluginRunId !== resolution.pluginRunId) {
978
+ return { ok: false, reason: 'client-half-failed', message: `activation "${resolution.pluginRunId}" is no longer active` }
979
+ }
980
+ if (attempt !== undefined && attempt.pluginRunId === run.pluginRunId) {
981
+ attempt.client = {
982
+ status: resolution.waitingFor === undefined || resolution.waitingFor.length === 0 ? 'running' : 'waiting',
983
+ waitingFor: resolution.waitingFor ?? [],
984
+ }
985
+ }
986
+ this.commitActivation(plugin, run)
987
+ return {
988
+ ...this.runResponse(plugin, {
989
+ ok: true,
990
+ pluginId: plugin.pluginId,
991
+ packageId: run.packageId,
992
+ pluginRunId: run.pluginRunId,
993
+ waitingFor: missingFor(this.ctx, run),
994
+ startedHere: false,
995
+ }),
996
+ ...resolution.waitingFor === undefined ? {} : { clientWaitingFor: resolution.waitingFor },
997
+ }
998
+ }
999
+
1000
+ private commitActivation(plugin: DynamicCordisPlugin, run: DynamicCordisRun): void {
1001
+ plugin.currentPackageId = run.packageId
1002
+ delete plugin.nextPackageId
1003
+ delete run.startedForRequest
1004
+ const attempt = plugin.latestRun
1005
+ if (attempt?.pluginRunId === run.pluginRunId) {
1006
+ attempt.status = attempt.host.status === 'waiting' || attempt.client.status === 'waiting' ? 'waiting' : 'running'
1007
+ delete attempt.approvalRequestId
1008
+ delete attempt.requiresApproval
1009
+ delete attempt.error
1010
+ }
1011
+ }
1012
+
1013
+ private runResponse(
1014
+ plugin: DynamicCordisPlugin,
1015
+ started: Extract<DynamicCordisHostHalfResult, { ok: true }>,
1016
+ ): Extract<DynamicCordisRunResponse, { ok: true }> {
1017
+ return {
1018
+ ok: true,
1019
+ status: 'running',
1020
+ pluginId: plugin.pluginId,
1021
+ packageId: started.packageId,
1022
+ pluginRunId: started.pluginRunId,
1023
+ waitingFor: started.waitingFor,
1024
+ currentPackageId: started.packageId,
1025
+ mode: plugin.latestRun?.pluginRunId === started.pluginRunId ? plugin.latestRun.mode : 'run',
1026
+ }
1027
+ }
1028
+
1029
+ private announceResolved(
1030
+ requestId: ApprovalRequestId,
1031
+ resolution: DynamicCordisRunResolution,
1032
+ override?: RequestRunOutcome,
1033
+ ): void {
1034
+ const outcome = override ?? (resolution.ok ? 'approved' : resolution.reason === 'rejected' ? 'rejected' : 'failed')
1035
+ this.ctx.emit('cordis/request-run-resolved', { requestId, outcome })
1036
+ }
1037
+
1038
+ private steerRunOutcome(
1039
+ pending: DynamicCordisPendingRequest,
1040
+ settled: DynamicCordisRunResponse,
1041
+ ): void {
1042
+ const agents = this.rootCtx.get('agents')
1043
+ const agent = agents?.get(pending.agentId)
1044
+ if (agent === undefined) return
1045
+ const plugin = this.registry.get(pending.pluginId)
1046
+ const identity = `${pending.pluginId}/${pending.packageId} (${pending.pluginRunId})`
1047
+ let text: string
1048
+ if (settled.ok) {
1049
+ text = `Cordis ${pending.mode} ${identity} completed successfully. `
1050
+ + `currentPackageId is ${settled.currentPackageId ?? pending.packageId}. Continue using the running Plugin.`
1051
+ } else if (settled.reason === 'rejected') {
1052
+ text = `The user rejected Cordis ${pending.mode} ${identity}. `
1053
+ + 'Do not request the same activation again unless the user asks.'
1054
+ } else {
1055
+ const returnedStatus = pending.requiresApproval ? 'awaiting-approval' : 'starting'
1056
+ text = `Cordis ${pending.mode} ${identity} failed after the runner returned ${returnedStatus}: `
1057
+ + `${settled.reason}\n${formatErrorDetails(settled)}\n`
1058
+ + `currentPackageId: ${plugin?.currentPackageId ?? 'none'}\n`
1059
+ + `nextPackageId: ${plugin?.nextPackageId ?? pending.packageId}\n`
1060
+ + 'Report the failure to the user; the definition can be managed through the Cordis panel.'
1061
+ }
1062
+ agent.steer(createUserMessage({
1063
+ content: [{ type: 'text', text }],
1064
+ source: { kind: 'cordis-host-runner' },
1065
+ }))
1066
+ }
1067
+
1068
+ private steerRenderFailure(
1069
+ agent: Agent,
1070
+ plugin: DynamicCordisPlugin,
1071
+ definition: DynamicCordisDefinition,
1072
+ pluginRunId: CordisDynamicPluginRunId,
1073
+ failure: DynamicCordisRenderFailure,
1074
+ ): void {
1075
+ agent.steer(createUserMessage({
1076
+ content: [{
1077
+ type: 'text',
1078
+ text: `Cordis Client UI ${plugin.pluginId}/${definition.packageId} (${pluginRunId}) failed while rendering `
1079
+ + `Slot "${failure.slot}" after activation.\n`
1080
+ + `${formatErrorDetails(failure)}\n`
1081
+ + `entryAbdicated: ${failure.abdicated}\n`
1082
+ + 'Report the Client render failure to the user; the definition can be stopped through the Cordis panel.',
1083
+ }],
1084
+ source: { kind: 'cordis-host-runner' },
1085
+ }))
1086
+ }
1087
+
1088
+ private steerHostHandlerFailure(
1089
+ plugin: DynamicCordisPlugin,
1090
+ run: DynamicCordisRun,
1091
+ method: string,
1092
+ failure: CordisErrorDetails,
1093
+ ): void {
1094
+ const reportKey = `Host\u0000handler\u0000${method}\u0000${failure.message}`
1095
+ if (!this.claimRuntimeFailure(plugin, run, reportKey)) return
1096
+ const agents = this.rootCtx.get('agents')
1097
+ const agent = agents?.get(plugin.sessionId)
1098
+ if (agent === undefined) return
1099
+ agent.steer(createUserMessage({
1100
+ content: [{
1101
+ type: 'text',
1102
+ text: `Cordis Host handler ${plugin.pluginId}/${run.packageId} (${run.pluginRunId}) failed when the Client called `
1103
+ + `host.call(${JSON.stringify(method)}).\n`
1104
+ + `${formatErrorDetails(failure)}\n`
1105
+ + 'The Plugin remains running. Report the Host handler failure to the user. If the handler needs a Service, either declare '
1106
+ + 'that Service in the returned Plugin inject list or read it with ctx.get(name) and handle undefined.',
1107
+ }],
1108
+ source: { kind: 'cordis-host-runner' },
1109
+ }))
1110
+ }
1111
+
1112
+ /* jscpd:ignore-start */
1113
+ private steerGuardFailure(
1114
+ plugin: DynamicCordisPlugin,
1115
+ run: DynamicCordisRun,
1116
+ platform: 'Host' | 'Client',
1117
+ failure: CordisErrorDetails,
1118
+ ): void {
1119
+ const reportKey = `${platform}\u0000guard\u0000${failure.message}`
1120
+ if (!this.claimRuntimeFailure(plugin, run, reportKey)) return
1121
+ const agents = this.rootCtx.get('agents')
1122
+ const agent = agents?.get(plugin.sessionId)
1123
+ if (agent === undefined) return
1124
+ agent.steer(createUserMessage({
1125
+ content: [{
1126
+ type: 'text',
1127
+ text: `Cordis ${platform} guard rejected runtime code in ${plugin.pluginId}/${run.packageId} `
1128
+ + `(${run.pluginRunId}) after activation.\n${formatErrorDetails(failure)}\n`
1129
+ + 'The Plugin remains running. Report the guard rejection to the user; it can be stopped through the Cordis panel.',
1130
+ }],
1131
+ source: { kind: 'cordis-host-runner' },
1132
+ }))
1133
+ }
1134
+ /* jscpd:ignore-end */
1135
+
1136
+ private claimRuntimeFailure(plugin: DynamicCordisPlugin, run: DynamicCordisRun, key: string): boolean {
1137
+ const attempt = plugin.latestRun
1138
+ if (plugin.run !== run || attempt?.pluginRunId !== run.pluginRunId
1139
+ || (attempt.status !== 'running' && attempt.status !== 'waiting')) return false
1140
+ if (run.reportedRuntimeErrors.has(key)) return false
1141
+ run.reportedRuntimeErrors.add(key)
1142
+ return true
1143
+ }
1144
+
1145
+ private injectUserRunOutcome(
1146
+ agent: Agent,
1147
+ pluginId: CordisDynamicPluginId,
1148
+ settled: DynamicCordisRunResponse,
1149
+ ): void {
1150
+ const plugin = this.owned(agent, pluginId)
1151
+ let text: string
1152
+ if (settled.ok) {
1153
+ text = `The user manually ran Cordis Plugin ${pluginId}, Package ${settled.packageId}, `
1154
+ + `as ${settled.pluginRunId}. The activation succeeded; currentPackageId is ${settled.currentPackageId}.`
1155
+ } else {
1156
+ const attempt = plugin?.latestRun
1157
+ text = `The user manually ran Cordis Plugin ${pluginId}`
1158
+ + `${attempt === undefined ? '' : `, Package ${attempt.packageId}, as ${attempt.pluginRunId}`}, but it failed: `
1159
+ + `${settled.reason}\n${formatErrorDetails(settled)}\n`
1160
+ + `currentPackageId: ${plugin?.currentPackageId ?? 'none'}\n`
1161
+ + `nextPackageId: ${plugin?.nextPackageId ?? 'none'}`
1162
+ }
1163
+ this.injectUserContext(agent, text)
1164
+ }
1165
+
1166
+ private injectUserContext(agent: Agent, text: string): void {
1167
+ const agents = this.rootCtx.get('agents')
1168
+ if (agents?.get(agent.id) !== agent) return
1169
+ agent.inject(createUserMessage({
1170
+ content: [{ type: 'text', text }],
1171
+ source: { kind: 'cordis-host-runner' },
1172
+ }))
1173
+ }
1174
+
1175
+ private cancelPending(pluginId: CordisDynamicPluginId, message: string): void {
1176
+ const requestId = this.registry.pendingRequestFor(pluginId)
1177
+ if (requestId === undefined) return
1178
+ const pending = this.registry.claimRequest(requestId)
1179
+ if (pending === undefined) return
1180
+ const plugin = this.registry.get(pluginId)
1181
+ if (plugin?.latestRun?.pluginRunId === pending.pluginRunId) {
1182
+ plugin.latestRun.status = 'cancelled'
1183
+ plugin.latestRun.error = this.diagnostic(plugin, plugin.latestRun, 'approval', message)
1184
+ delete plugin.latestRun.approvalRequestId
1185
+ delete plugin.latestRun.requiresApproval
1186
+ }
1187
+ this.announceResolved(requestId, { ok: false, reason: 'rejected' }, 'cancelled')
1188
+ }
1189
+
1190
+ private createAttempt(plan: ActivationPlan): DynamicCordisRunAttempt {
1191
+ return {
1192
+ pluginRunId: brandCordisDynamicPluginRunId(this.registry.mintPluginRunId()),
1193
+ packageId: plan.definition.packageId,
1194
+ mode: plan.mode,
1195
+ status: 'starting-host',
1196
+ host: {
1197
+ status: plan.definition.hostCode === undefined ? 'absent' : 'pending',
1198
+ waitingFor: [],
1199
+ },
1200
+ client: {
1201
+ status: plan.definition.clientCode === undefined ? 'absent' : 'pending',
1202
+ waitingFor: [],
1203
+ },
1204
+ }
1205
+ }
1206
+
1207
+ private failAttempt(
1208
+ plugin: DynamicCordisPlugin,
1209
+ attempt: DynamicCordisRunAttempt,
1210
+ phase: NonNullable<DynamicCordisRunAttempt['error']>['phase'],
1211
+ failure: CordisErrorDetails,
1212
+ ): void {
1213
+ attempt.status = 'failed'
1214
+ attempt.error = this.diagnostic(plugin, attempt, phase, failure)
1215
+ if (phase.startsWith('host')) attempt.host = { status: 'failed', waitingFor: [], error: failure.message }
1216
+ else attempt.client = { status: 'failed', waitingFor: [], error: failure.message }
1217
+ }
1218
+
1219
+ private diagnostic(
1220
+ plugin: DynamicCordisPlugin,
1221
+ attempt: DynamicCordisRunAttempt,
1222
+ phase: NonNullable<DynamicCordisRunAttempt['error']>['phase'],
1223
+ failure: CordisErrorDetails | string,
1224
+ ): NonNullable<DynamicCordisRunAttempt['error']> {
1225
+ const details = typeof failure === 'string' ? { message: failure } : failure
1226
+ return {
1227
+ phase,
1228
+ ...details,
1229
+ pluginId: plugin.pluginId,
1230
+ packageId: attempt.packageId,
1231
+ pluginRunId: attempt.pluginRunId,
1232
+ }
1233
+ }
1234
+
1235
+ private async retract(plugin: DynamicCordisPlugin): Promise<void> {
1236
+ const run = plugin.run
1237
+ if (run === undefined) return
1238
+ delete plugin.run
1239
+ for (const dispose of run.handlerDisposers.splice(0)) dispose()
1240
+ if (run.fiber !== undefined) await run.fiber.dispose()
1241
+ this.ctx.emit('cordis/dynamic-retract', {
1242
+ pluginId: plugin.pluginId,
1243
+ packageId: run.packageId,
1244
+ pluginRunId: run.pluginRunId,
1245
+ })
1246
+ }
1247
+
1248
+ private owned(agent: Agent, pluginId: CordisDynamicPluginId): DynamicCordisPlugin | undefined {
1249
+ const plugin = this.registry.get(pluginId)
1250
+ return plugin?.sessionId === agent.id ? plugin : undefined
1251
+ }
1252
+
1253
+ private requireGroup(): Fiber {
1254
+ this.group ??= this.rootCtx.plugin({ name: 'cordis-dynamic', apply: () => {} })
1255
+ return this.group
1256
+ }
1257
+ }
1258
+
1259
+ function missingFor(ctx: Context, run: DynamicCordisRun): string[] {
1260
+ return run.fiber === undefined ? [] : missingServices(ctx, run.fiber)
1261
+ }
1262
+
1263
+ function missingPluginMessage(id: CordisDynamicPluginId): string {
1264
+ return `no dynamic plugin "${id}" in this process — it may have been removed or lost on DSH restart`
1265
+ }
1266
+
1267
+ function errorDetails(error: unknown): CordisErrorDetails {
1268
+ if (typeof error !== 'object' || error === null) return { message: String(error) }
1269
+ const message = 'message' in error && typeof error.message === 'string'
1270
+ ? error.message
1271
+ : Object.prototype.toString.call(error)
1272
+ const stack = 'stack' in error && typeof error.stack === 'string' ? error.stack : undefined
1273
+ return { message, ...stack === undefined ? {} : { stack } }
1274
+ }
1275
+
1276
+ function formatErrorDetails(failure: CordisErrorDetails): string {
1277
+ return `message: ${failure.message}`
1278
+ + (failure.stack === undefined ? '' : `\nstack:\n${failure.stack}`)
1279
+ }
1280
+
1281
+ function cloneAttempt(attempt: DynamicCordisRunAttempt): DynamicCordisRunAttempt {
1282
+ return {
1283
+ ...attempt,
1284
+ host: { ...attempt.host, waitingFor: [...attempt.host.waitingFor] },
1285
+ client: { ...attempt.client, waitingFor: [...attempt.client.waitingFor] },
1286
+ ...attempt.error === undefined ? {} : { error: { ...attempt.error } },
1287
+ }
1288
+ }
1289
+
1290
+ export default DynamicCordisRunnerService