@standardagents/code-plugin-sdk 0.0.0-stub.0 → 1.0.0-alpha.1

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.
@@ -0,0 +1,354 @@
1
+ import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
2
+ import { RpcPeer, authorize, realClock } from './protocol.mjs'
3
+
4
+ const always = Object.freeze({ kind: 'always' })
5
+ const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
6
+ const entityKinds = ['account', 'machine', 'project', 'pane', 'section']
7
+ function boundedText(value, label, limit = 512) {
8
+ ensure(typeof value === 'string' && value.trim().length > 0 && value.length <= limit &&
9
+ !/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
10
+ return value
11
+ }
12
+ function validateEntity(entity) {
13
+ ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
14
+ boundedText(entity.id, 'entity id', 128)
15
+ if (entity.machineId !== undefined) boundedText(entity.machineId, 'machine id', 128)
16
+ if (entity.generation !== undefined) {
17
+ ensure(typeof entity.generation === 'string' && /^(0|[1-9]\d{0,19})$/.test(entity.generation) &&
18
+ BigInt(entity.generation) <= 18446744073709551615n, 'invalid_payload', 'Invalid entity generation')
19
+ }
20
+ return { kind: entity.kind, id: entity.id,
21
+ ...(entity.machineId !== undefined ? { machineId: entity.machineId } : {}),
22
+ ...(entity.generation !== undefined ? { generation: entity.generation } : {}) }
23
+ }
24
+ function validateChord(chord) {
25
+ boundedText(chord, 'key chord', 128)
26
+ const presses = chord.split(' ')
27
+ const namedKeys = ['space', 'enter', 'esc', 'tab', 'backspace', 'left', 'right', 'up', 'down', 'pageup', 'pagedown', 'home', 'end']
28
+ ensure(presses.length <= 2 && presses.every(press => {
29
+ const parts = press.split('+')
30
+ const modifiers = parts.filter(part => ['shift', 'alt', 'ctrl'].includes(part))
31
+ const keys = parts.filter(part => !['shift', 'alt', 'ctrl'].includes(part))
32
+ return new Set(modifiers).size === modifiers.length && keys.length === 1 &&
33
+ (namedKeys.includes(keys[0]) || Array.from(keys[0]).length === 1)
34
+ }), 'invalid_payload', 'Invalid native key chord')
35
+ return chord
36
+ }
37
+ function conditionKey(condition) { return JSON.stringify([condition.kind, condition.contributionId ?? null, condition.entity ?? null]) }
38
+ function validateCondition(condition) {
39
+ ensure(condition && ['always', 'section-visible', 'slot-visible', 'panel-open'].includes(condition.kind) &&
40
+ (condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
41
+ return structuredClone(condition)
42
+ }
43
+ function validateContent(content) {
44
+ jsonBytes(content)
45
+ ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
46
+ if (content.kind === 'canvas') {
47
+ const { columns, rows, shade = 0 } = content.canvas ?? {}
48
+ ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
49
+ Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
50
+ Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
51
+ }
52
+ }
53
+
54
+ export function createRuntime({ manifest: input, producer, send, clock = realClock, onError = () => {}, instanceId = '0' }) {
55
+ const manifest = validateManifest(input)
56
+ ensure(producer.pluginId === manifest.id, 'identity_mismatch', 'Manifest and producer ids differ')
57
+ const lifetime = new AbortController()
58
+ const subscriptions = new Map()
59
+ const schedules = new Set()
60
+ const cleanups = new Set()
61
+ const publishers = new Map()
62
+ let visibility = new Set()
63
+ let counter = 0
64
+ let sequence = 0n
65
+ let disposal
66
+ ensure(identifier(instanceId), 'invalid_payload', 'Invalid runtime instance id')
67
+ const peer = new RpcPeer({ producer, send, clock, onError, idPrefix: 'sdk', onRequest: async (operation, options) => {
68
+ if (operation.op === 'runtime.visibility') {
69
+ ensure(Array.isArray(operation.args.conditions) && operation.args.conditions.length <= LIMITS.contributions,
70
+ 'invalid_payload', 'Invalid visibility snapshot')
71
+ visibility = new Set(operation.args.conditions.map(condition => conditionKey(validateCondition(condition))))
72
+ for (const schedule of schedules) schedule.visibilityChanged(visibility)
73
+ return null
74
+ }
75
+ ensure(operation.op === 'runtime.invoke', 'unsupported_operation', 'Unsupported worker operation')
76
+ const subscription = subscriptions.get(operation.args.subscriptionId)
77
+ ensure(subscription, 'subscription_missing', 'Subscription was disposed')
78
+ if (subscription.condition.kind !== 'always' && !visibility.has(conditionKey(subscription.condition))) return null
79
+ const result = await subscription.handler(operation.args.event, { signal: options.signal })
80
+ if (subscription.kind === 'hook') {
81
+ ensure(result && ['proceed', 'cancel'].includes(result.decision), 'invalid_payload', 'Hook must return proceed or cancel')
82
+ }
83
+ return result ?? null
84
+ } })
85
+ function active() { ensure(!lifetime.signal.aborted, 'disposed', 'Plugin runtime is disposed') }
86
+ function request(op, args, options) {
87
+ try { active(); authorize({ op, args }, manifest.capabilities) }
88
+ catch (error) { onError(error); return Promise.reject(error) }
89
+ return peer.request({ op, args }, options)
90
+ }
91
+ function publish(kind, id, entity) {
92
+ active()
93
+ ensure(manifest.capabilities.includes('surfaces'), 'capability_denied', 'Publishing requires surfaces capability')
94
+ const declaration = manifest.contributions.find(item => item.id === id)
95
+ ensure(declaration && (!kind || declaration.kind === kind) && ANCHORS.includes(declaration.anchor),
96
+ 'contribution_missing', 'Surface must match its static contribution declaration')
97
+ const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity: structuredClone(entity) } : {}) }
98
+ const identity = JSON.stringify(key)
99
+ ensure(!publishers.has(identity), 'duplicate_contribution', 'Contribution publisher exists')
100
+ ensure(publishers.size < LIMITS.contributions, 'queue_full', 'Too many contribution publishers')
101
+ let disposed = false
102
+ const replace = content => {
103
+ active()
104
+ ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
105
+ if (content !== null) validateContent(content)
106
+ peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
107
+ }
108
+ const publisher = { replace, clear: () => replace(null), dispose() {
109
+ if (disposed) return
110
+ try { if (!lifetime.signal.aborted) replace(null) }
111
+ finally { disposed = true; publishers.delete(identity) }
112
+ } }
113
+ publishers.set(identity, publisher)
114
+ return { publisher, key }
115
+ }
116
+ function subscribe(kind, name, handler, condition = always, registration) {
117
+ active()
118
+ ensure(typeof handler === 'function', 'invalid_payload', 'Subscription requires a handler')
119
+ ensure(subscriptions.size < LIMITS.subscriptions, 'queue_full', 'Too many subscriptions')
120
+ condition = validateCondition(condition)
121
+ const id = `subscription-${instanceId}-${++counter}`
122
+ const args = { id, kind, name, condition, ...(registration ? { registration } : {}) }
123
+ authorize({ op: 'subscription.add', args }, manifest.capabilities)
124
+ const entry = { kind, handler, condition, registration }
125
+ subscriptions.set(id, entry)
126
+ let disposed = false
127
+ let updating = false
128
+ const remove = () => { if (!lifetime.signal.aborted) request('subscription.remove', { id }).catch(onError) }
129
+ const ready = request('subscription.add', args)
130
+ ready.then(() => { if (disposed) remove() }, error => {
131
+ disposed = true
132
+ subscriptions.delete(id)
133
+ remove()
134
+ onError(error)
135
+ })
136
+ const subscription = { ready, dispose() {
137
+ if (disposed) return
138
+ disposed = true
139
+ subscriptions.delete(id)
140
+ remove()
141
+ } }
142
+ if (registration?.kind === 'key') subscription.update = async (chord, options) => {
143
+ active()
144
+ ensure(!disposed, 'disposed', 'Key registration is disposed')
145
+ validateChord(chord)
146
+ ensure(!updating, 'request_in_flight', 'A key registration update is pending')
147
+ updating = true
148
+ try {
149
+ await ready
150
+ active()
151
+ ensure(!disposed, 'disposed', 'Key registration is disposed')
152
+ const next = { ...entry.registration, chord }
153
+ await request('subscription.add', { ...args, registration: next }, options)
154
+ if (!disposed) entry.registration = next
155
+ return null
156
+ } finally {
157
+ updating = false
158
+ if (disposed) remove()
159
+ }
160
+ }
161
+ return subscription
162
+ }
163
+ function prepareRegistration(kind, id, options, handler) {
164
+ active()
165
+ if (typeof options === 'function' && handler === undefined) { handler = options; options = {} }
166
+ ensure(identifier(id) && object(options) && typeof handler === 'function', 'invalid_payload', 'Invalid contribution registration')
167
+ jsonBytes(options, LIMITS.manifestBytes)
168
+ const declaration = manifest.contributions.find(item => item.id === id && item.kind === kind)
169
+ ensure(declaration, 'contribution_missing', 'Registration must match its static contribution declaration')
170
+ const actionId = options.actionId ?? declaration.actionId ?? id
171
+ ensure(identifier(actionId), 'invalid_payload', 'Invalid registration action id')
172
+ const registration = { contributionId: id, kind, anchor: declaration.anchor, actionId,
173
+ title: boundedText(options.title ?? declaration.title ?? id, 'registration title'),
174
+ ...(options.entity !== undefined ? { entity: validateEntity(options.entity) } : {}) }
175
+ if (kind === 'menu') {
176
+ registration.position = options.position ?? declaration.position ?? 'bottom'
177
+ ensure(menuPositions.includes(registration.position), 'invalid_payload', 'Invalid menu position')
178
+ } else if (kind === 'command') {
179
+ const group = options.group ?? declaration.group
180
+ if (group !== undefined) registration.group = boundedText(group, 'command group')
181
+ } else if (kind === 'key') {
182
+ registration.chord = validateChord(options.chord ?? declaration.chord)
183
+ } else if (kind === 'link') {
184
+ registration.pattern = boundedText(options.pattern ?? declaration.pattern, 'link pattern')
185
+ }
186
+ const condition = validateCondition(options.condition ?? always)
187
+ if (condition.entity !== undefined) condition.entity = validateEntity(condition.entity)
188
+ jsonBytes({ registration, condition }, LIMITS.manifestBytes)
189
+ const invoke = async (event, context) => {
190
+ ensure(object(event) && event.actionId === actionId, 'invalid_payload', 'Invalid registered action selection')
191
+ if (event.entity !== undefined) validateEntity(event.entity)
192
+ if (kind === 'link') boundedText(event.url, 'link URL', 8192)
193
+ const result = await handler(event, context)
194
+ if (kind === 'link') ensure(typeof result === 'boolean', 'invalid_payload', 'Link handlers must return a boolean')
195
+ return result
196
+ }
197
+ return { registration, handler: invoke, condition }
198
+ }
199
+ const registrationKey = registration => JSON.stringify([registration.contributionId, registration.entity ?? null])
200
+ function registerPrepared({ registration, handler, condition }) {
201
+ const identity = registrationKey(registration)
202
+ ensure(![...subscriptions.values()].some(entry => entry.registration && registrationKey(entry.registration) === identity),
203
+ 'duplicate_contribution', 'Contribution registration exists')
204
+ return subscribe('action', registration.actionId, handler, condition, registration)
205
+ }
206
+ function commandGroup(group, commands) {
207
+ active()
208
+ boundedText(group, 'command group')
209
+ ensure(Array.isArray(commands) && commands.length > 0 && commands.length <= LIMITS.contributions &&
210
+ subscriptions.size + commands.length <= LIMITS.subscriptions, 'queue_full', 'Invalid or excessive group commands')
211
+ const prepared = commands.map(command => {
212
+ ensure(object(command), 'invalid_payload', 'Invalid group command')
213
+ const { id, handler, ...options } = command
214
+ return prepareRegistration('command', id, { ...options, group }, handler)
215
+ })
216
+ const identities = new Set([...subscriptions.values()].filter(entry => entry.registration).map(entry => registrationKey(entry.registration)))
217
+ for (const { registration } of prepared) {
218
+ const identity = registrationKey(registration)
219
+ ensure(!identities.has(identity), 'duplicate_contribution', 'Contribution registration exists')
220
+ identities.add(identity)
221
+ }
222
+ const members = []
223
+ const dispose = () => { for (const member of members) member.dispose() }
224
+ try { for (const item of prepared) members.push(registerPrepared(item)) }
225
+ catch (error) { dispose(); throw error }
226
+ const ready = Promise.all(members.map(member => member.ready)).then(() => null)
227
+ ready.catch(dispose)
228
+ return { ready, dispose }
229
+ }
230
+ function schedule(intervalMs, handler, { condition = always, immediate = false } = {}) {
231
+ active()
232
+ ensure(Number.isFinite(intervalMs) && intervalMs >= 1000 / 30 && intervalMs <= 2147483647,
233
+ 'invalid_payload', 'Schedule interval must be between 1000/30 and 2147483647 ms')
234
+ ensure(typeof handler === 'function' && schedules.size < LIMITS.schedules, 'queue_full', 'Invalid or excessive schedules')
235
+ condition = validateCondition(condition)
236
+ const interest = condition.kind === 'always' ? null : subscribe('visibility', condition.contributionId, () => {}, condition)
237
+ const conditionIdentity = conditionKey(condition)
238
+ const controller = new AbortController()
239
+ let activeRun = null
240
+ let timer
241
+ const beginRun = () => {
242
+ const runController = new AbortController()
243
+ const cancelFromSchedule = () => runController.abort()
244
+ const cancelFromLifetime = () => runController.abort()
245
+ controller.signal.addEventListener('abort', cancelFromSchedule, { once: true })
246
+ lifetime.signal.addEventListener('abort', cancelFromLifetime, { once: true })
247
+ const run = {
248
+ controller: runController,
249
+ finish() {
250
+ controller.signal.removeEventListener('abort', cancelFromSchedule)
251
+ lifetime.signal.removeEventListener('abort', cancelFromLifetime)
252
+ if (activeRun?.controller === runController) activeRun = null
253
+ },
254
+ }
255
+ activeRun = run
256
+ return run
257
+ }
258
+ const tick = async () => {
259
+ if (controller.signal.aborted || lifetime.signal.aborted) return
260
+ const visible = condition.kind === 'always' || visibility.has(conditionIdentity)
261
+ if (!visible) {
262
+ timer = clock.setTimeout(tick, intervalMs)
263
+ return
264
+ }
265
+ const run = beginRun()
266
+ try {
267
+ await handler({ signal: run.controller.signal })
268
+ } catch (error) {
269
+ if (!run.controller.signal.aborted || condition.kind === 'always') onError(error)
270
+ } finally {
271
+ run.finish()
272
+ }
273
+ // Each schedule has at most one invocation in flight.
274
+ if (!controller.signal.aborted && !lifetime.signal.aborted) timer = clock.setTimeout(tick, intervalMs)
275
+ }
276
+ const disposable = {
277
+ visibilityChanged(nextVisibility) {
278
+ if (condition.kind !== 'always' && !nextVisibility.has(conditionIdentity)) activeRun?.controller.abort()
279
+ },
280
+ dispose() {
281
+ controller.abort()
282
+ activeRun?.controller.abort()
283
+ clock.clearTimeout(timer)
284
+ interest?.dispose()
285
+ schedules.delete(disposable)
286
+ },
287
+ }
288
+ schedules.add(disposable)
289
+ timer = clock.setTimeout(tick, immediate ? 0 : intervalMs)
290
+ return disposable
291
+ }
292
+ const method = op => (args, options) => request(op, args, options)
293
+ const context = Object.freeze({
294
+ manifest, producer: peer.producer, signal: lifetime.signal, request,
295
+ ...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
296
+ (id, entity) => publish(kind, id, entity).publisher])),
297
+ canvas(id, spec, entity) {
298
+ const { publisher, key } = publish(null, id, entity)
299
+ try { publisher.replace({ kind: 'canvas', canvas: spec }) }
300
+ catch (error) { publisher.dispose(); throw error }
301
+ return { ...publisher, write: (ansi, options) => request('canvas.write', { key, ansi }, options),
302
+ focus: (capture, options) => request('canvas.focus', { key, capture }, options) }
303
+ },
304
+ ...Object.fromEntries(['Event', 'Hook', 'Action', 'Input', 'Select', 'Resize', 'Activate', 'Deactivate'].map(name =>
305
+ [`on${name}`, (event, handler, condition) => subscribe(name.toLowerCase(), event, handler, condition)])),
306
+ ...Object.fromEntries(['menu', 'command', 'key', 'link'].map(kind => [kind,
307
+ (id, options, handler) => registerPrepared(prepareRegistration(kind, id, options, handler))])),
308
+ commandGroup,
309
+ schedule,
310
+ onDispose(cleanup) {
311
+ active()
312
+ ensure(typeof cleanup === 'function' && cleanups.size < LIMITS.subscriptions, 'queue_full', 'Invalid or excessive cleanup callbacks')
313
+ cleanups.add(cleanup)
314
+ return { dispose: () => { cleanups.delete(cleanup) } }
315
+ },
316
+ panes: Object.freeze(Object.fromEntries(['create', 'close', 'restart', 'input', 'focus', 'wait'].map(name => [name, method(`pane.${name}`)]))),
317
+ projects: Object.freeze({ create: method('project.create'), remove: method('project.remove') }),
318
+ notifications: Object.freeze({ show: method('notification.show') }),
319
+ url: Object.freeze({ open: (url, options) => request('url.open', { url }, options) }),
320
+ fetch: method('fetch'),
321
+ secrets: Object.freeze({ get: (name, options) => request('secret.get', { name }, options) }),
322
+ config: Object.freeze({ get: options => request('config.get', {}, options) }),
323
+ state: Object.freeze({ get: (key, options) => request('state.get', { key }, options),
324
+ set: (key, value, options) => request('state.set', { key, value }, options) }),
325
+ context: Object.freeze({ get: options => request('context.get', {}, options) }),
326
+ popover: Object.freeze({ open: method('popover.open') }),
327
+ health: Object.freeze({ set: method('health.set') }),
328
+ webhook: Object.freeze({ ack: (deliveryId, options) => request('webhook.ack', { deliveryId }, options) }),
329
+ })
330
+ return {
331
+ context, receive: frame => peer.receive(frame),
332
+ async activate(definition) {
333
+ active()
334
+ ensure(definition?.id === manifest.id && typeof definition.activate === 'function', 'identity_mismatch', 'Runtime plugin must match static manifest id')
335
+ const cleanup = await definition.activate(context)
336
+ if (typeof cleanup === 'function') {
337
+ if (lifetime.signal.aborted) await cleanup()
338
+ else context.onDispose(cleanup)
339
+ }
340
+ },
341
+ dispose() {
342
+ if (disposal) return disposal
343
+ // Remove owned surfaces before the lifetime signal prevents new work.
344
+ for (const publisher of publishers.values()) { try { publisher.dispose() } catch (error) { onError(error) } }
345
+ lifetime.abort()
346
+ for (const disposable of schedules) disposable.dispose()
347
+ peer.dispose()
348
+ subscriptions.clear()
349
+ disposal = Promise.allSettled([...cleanups].reverse().map(cleanup => Promise.resolve().then(cleanup)))
350
+ .then(results => { for (const result of results) if (result.status === 'rejected') onError(result.reason); cleanups.clear() })
351
+ return disposal
352
+ },
353
+ }
354
+ }
@@ -0,0 +1,24 @@
1
+ import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
2
+ import type { Envelope } from './internal.js';
3
+ export interface Harness {
4
+ context: PluginContext;
5
+ trace: unknown[];
6
+ surfaces: Map<string, Envelope>;
7
+ subscriptions: Map<string, Json>;
8
+ activate(definition: PluginDefinition): Promise<void>;
9
+ flush(): Promise<void>;
10
+ advance(milliseconds: number): Promise<void>;
11
+ emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
12
+ visibility(conditions: Condition[]): Promise<Json>;
13
+ receive(frame: Envelope): void;
14
+ drainTrace(): unknown[];
15
+ readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
16
+ dispose(): Promise<void>;
17
+ }
18
+ export function createHarness(options: {
19
+ manifest: ManifestInput;
20
+ machineId?: string;
21
+ epoch?: string;
22
+ now?: number;
23
+ handlers?: { [K in keyof OperationMap]?: (args: OperationMap[K]['input'], context: { signal: AbortSignal; timeoutMs: number }) => OperationMap[K]['output'] | Promise<OperationMap[K]['output']> };
24
+ }): Harness;
@@ -0,0 +1,104 @@
1
+ import { createRuntime } from './runtime.mjs'
2
+ import { RpcPeer } from './protocol.mjs'
3
+ import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
4
+
5
+ /** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
6
+ export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
7
+ const manifest = validateManifest(input)
8
+ const producer = { pluginId: manifest.id, machineId, epoch }
9
+ const trace = []
10
+ const surfaces = new Map()
11
+ const subscriptions = new Map()
12
+ const localState = new Map()
13
+ const timers = new Map()
14
+ let timerId = 0
15
+ let disposed = false
16
+ const clock = {
17
+ now: () => now,
18
+ setTimeout(callback, delay) { const id = ++timerId; timers.set(id, { due: now + delay, callback }); return id },
19
+ clearTimeout(id) { timers.delete(id) },
20
+ }
21
+ const record = event => {
22
+ ensure(trace.length < 10000, 'queue_full', 'Harness trace reached 10000 entries; drain it before continuing')
23
+ trace.push(event)
24
+ }
25
+ let runtime
26
+ const host = new RpcPeer({ producer, clock,
27
+ send(frame) { record({ type: 'host', frame: structuredClone(frame) }); runtime.receive(frame) },
28
+ onSurface(frame) {
29
+ const key = JSON.stringify(frame.key)
30
+ frame.content === null ? surfaces.delete(key) : surfaces.set(key, structuredClone(frame))
31
+ },
32
+ onRequest(operation, options) {
33
+ record({ type: 'command', operation: structuredClone(operation) })
34
+ if (handlers[operation.op]) return handlers[operation.op](operation.args, options)
35
+ const args = operation.args
36
+ switch (operation.op) {
37
+ case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
38
+ case 'subscription.remove': subscriptions.delete(args.id); return null
39
+ case 'state.get': return structuredClone(localState.get(args.key) ?? null)
40
+ case 'state.set':
41
+ ensure(localState.has(args.key) || localState.size < LIMITS.contributions, 'queue_full', 'Harness state is full')
42
+ localState.set(args.key, structuredClone(args.value)); return null
43
+ case 'config.get': return {}
44
+ case 'context.get': return { machineId }
45
+ case 'health.set': return null
46
+ default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
47
+ }
48
+ },
49
+ onError: error => record({ type: 'error', code: error.code, message: error.message }),
50
+ })
51
+ runtime = createRuntime({ manifest, producer, clock,
52
+ send(frame) { record({ type: 'plugin', frame: structuredClone(frame) }); host.receive(frame) },
53
+ onError: error => record({ type: 'error', code: error.code, message: error.message }),
54
+ })
55
+ async function flush() { for (let turn = 0; turn < 64; turn++) await Promise.resolve() }
56
+ return {
57
+ context: runtime.context, trace, surfaces, subscriptions,
58
+ activate: definition => runtime.activate(definition),
59
+ flush,
60
+ async advance(milliseconds) {
61
+ ensure(Number.isFinite(milliseconds) && milliseconds >= 0, 'invalid_payload', 'Advance requires a nonnegative duration')
62
+ const end = now + milliseconds
63
+ await flush()
64
+ let ticks = 0
65
+ while (true) {
66
+ const next = [...timers].filter(([, timer]) => timer.due <= end)
67
+ .sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0]
68
+ if (!next) break
69
+ ensure(++ticks <= 10000, 'queue_full', 'Advance exceeds the harness timer budget')
70
+ now = next[1].due
71
+ timers.delete(next[0])
72
+ const result = next[1].callback()
73
+ Promise.resolve(result).catch(error => record({ type: 'error', code: error.code, message: error.message }))
74
+ await flush()
75
+ }
76
+ now = end
77
+ await flush()
78
+ },
79
+ async emit(kind, name, event, options) {
80
+ await flush()
81
+ const results = []
82
+ for (const [subscriptionId, subscription] of subscriptions) {
83
+ if (subscription.kind === kind && subscription.name === name) {
84
+ results.push(await host.request({ op: 'runtime.invoke', args: { subscriptionId, event } }, options))
85
+ }
86
+ }
87
+ return results
88
+ },
89
+ visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
90
+ receive: frame => runtime.receive(frame),
91
+ drainTrace() { return trace.splice(0) },
92
+ get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
93
+ async dispose() {
94
+ if (disposed) return
95
+ disposed = true
96
+ await runtime.dispose()
97
+ host.dispose()
98
+ subscriptions.clear()
99
+ surfaces.clear()
100
+ timers.clear()
101
+ record({ type: 'disposed' })
102
+ },
103
+ }
104
+ }