@xenosystem/agent-interface-runtime 0.1.12

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,877 @@
1
+ /**
2
+ * The Agent host composition โ€” ONE of them, used by every product surface.
3
+ *
4
+ * ADE ยง9 build-order step 3 needs the host to be able to run in a process with
5
+ * no windows. The obvious way to get there is to write a second, smaller
6
+ * composition for the detached entry. That is a fork, and this repo's own
7
+ * invariant (`AGENTS.md`: no surface-specific fork) says why it is the wrong
8
+ * shape: two compositions drift, and the one that drifts is always the one
9
+ * fewer people run.
10
+ *
11
+ * So there is one builder, and the Electron-only capabilities are INJECTED. A
12
+ * surface that has windows passes them; a headless process passes none.
13
+ *
14
+ * ## ๐Ÿ”ด Absent capabilities are declared, never silently dropped
15
+ *
16
+ * โš ๏ธ CORRECTED 2026-08-18. This said the SDK-native lane cannot run without
17
+ * `createSdkRuntimeWindow`, "its tools take a live BrowserWindow". Measured, that
18
+ * is false: the loop takes an `SdkAgentSurface` (send + isDestroyed) and loads in
19
+ * plain Node, and the surface tools act through `SdkAgentSurfaceRuntimeAdapter`,
20
+ * whose unregistered default refuses honestly. A window is now one way to run the
21
+ * lane; the headless runner is the other, and it differs only in where events go
22
+ * and who can answer a prompt.
23
+ *
24
+ * The principle the paragraph was written for is untouched, and still applies to
25
+ * every capability that IS genuinely absent here: the tempting response is to
26
+ * leave the provider out of the catalog entirely.
27
+ *
28
+ * That is wrong, and the reason generalises: **a provider that vanishes looks
29
+ * like a configuration error.** An operator sees a shorter list and goes looking
30
+ * for what they broke. A provider that is PRESENT and reports
31
+ * `runnable: false` with a reason has told them the truth โ€” this build cannot
32
+ * run that lane, and here is why. The descriptor already carries
33
+ * `safeUnavailableReason` for exactly this, so honesty costs a string.
34
+ *
35
+ * ## ยง9 step 3 โ€” ADOPTED
36
+ *
37
+ * Both entries run this. `apps/desktop/src/main/index.ts` calls it with a full
38
+ * `surface`; `detachedHostEntry.ts` calls it with `{}`. The fork it existed to
39
+ * remove is gone, and with it the failure mode that made the case: for weeks
40
+ * each composition was missing services the other had, in both directions โ€”
41
+ * the four ยง5 services reached the detached host and never the app, while
42
+ * tier-1 observation and the ยง5.7 landing queue reached the app and never the
43
+ * detached host. Neither gap is visible from inside one composition, and a test
44
+ * that mounts its own fixture cannot see them at all.
45
+ *
46
+ * ๐Ÿ”ด `createCoordinator()` is DEFERRED, and that is the one thing not to
47
+ * "simplify". Composing is safe in any process; the coordinator opens the
48
+ * durable SQLite state, and two of those open at once is the split-brain the
49
+ * host lease exists to prevent. So the composition is eager โ€” a surface still
50
+ * needs its shell service and registry while another process owns the host โ€”
51
+ * and only the coordinator waits for the election.
52
+ */
53
+ import { XENO_AGENT_HOST_MIN_PROTOCOL_VERSION, XENO_AGENT_HOST_PROTOCOL_VERSION, } from '@xenosystem/agent-interface-contract';
54
+ import { createHash } from 'node:crypto';
55
+ import { join } from 'node:path';
56
+ import { AcpAgentRegistryService, AcpNativeProviderLauncherResolver, AcpAgentTurnExecutionAdapter, AcpInteractivePermissionBridge, AgentHostCoordinator, DirectoryBaselineScanner, DurableAcpPermissionPolicyStore, DurableInterruptedTurnStore, DurablePendingInjectionStore, EngineeringWorkbenchService, LandingService, MountContentReader, ProviderLaneService, RunExecutionEnvironment, RunObservationService, RunIsolationService, RuntimeEventStore, RuntimeEventStoreRepositoryAdapter, SdkNativeAgentTurnExecutionAdapter, SubagentScheduler, setSubagentScheduler, createHostTurnSubagentExecutionAdapter, getSubagentRunService, WorkspaceHostService, WorkspaceShellService, XenoCloudAgentTurnExecutionAdapter, XenoUseRunObserver, createAcpApprovalPortAdapter, createAcpAgentRegistryPort, createAcpNativeTuiLane, createAcpRuntimeProviderDescriptors, createAgentStateRepository, createFixtureAcpAgentConfig, createSdkNativeRuntimeProviderDescriptor, createXenoCloudRuntimeProviderDescriptor, resolveLocalAgentHostPaths, toXenoCloudAgentModelDescriptors, } from '@xenosystem/agent-interface-host/node';
57
+ import { createXenoCloudNodeAgentsClient, discoverXenoCloudNodeRuntime, readXenoCloudCredentials, } from '@xenosystem/agents-client/node';
58
+ /**
59
+ * ๐Ÿ”ด `@xenosystem/agent-interface-electron-host` is imported
60
+ * DYNAMICALLY, everywhere, and only when a surface supplies a window.
61
+ *
62
+ * Its `sdkAgent` module does `import { app, dialog, ipcMain } from 'electron'`
63
+ * at module scope. A static import here puts `electron` in the shared main
64
+ * chunk, so the detached host entry โ€” a plain Node process โ€” dies on
65
+ * `SyntaxError: Named export 'app' not found` before it runs a line.
66
+ *
67
+ * โš ๏ธ Spawning it as `electron --run-as-node` does NOT rescue this: under
68
+ * `ELECTRON_RUN_AS_NODE` the `electron` module resolves to the executable's
69
+ * PATH STRING, not the API, so the named import fails there too.
70
+ *
71
+ * The headless composition test did not catch it, because vite resolves
72
+ * `electron` in-process. Only spawning the BUILT entry did.
73
+ */
74
+ import { createSharedHostServices } from './hostServices.js';
75
+ import { declaredUnavailableLanes, deliverElicitationThrough, sdkUnavailableReasonFor } from './hostLanePolicy.js';
76
+ import { resolveObservationConfig } from './observationConfig.js';
77
+ import { standaloneCloudOptions } from './cloudOptions.js';
78
+ export async function composeAgentHost(options) {
79
+ const env = options.env ?? process.env;
80
+ const fixtureEnabled = env.XENO_AGENT_ENABLE_FIXTURE === '1';
81
+ const paths = resolveLocalAgentHostPaths({
82
+ homeDirectory: options.homeDirectory,
83
+ ...(env.XENO_AGENT_HOST_ROOT ? { hostRootDirectory: env.XENO_AGENT_HOST_ROOT } : {}),
84
+ ...(env.XENO_AGENT_ACP_REGISTRY_ROOT
85
+ ? { acpRegistryRootDirectory: env.XENO_AGENT_ACP_REGISTRY_ROOT }
86
+ : {}),
87
+ });
88
+ const hostRoot = paths.hostRootDirectory;
89
+ const registry = new AcpAgentRegistryService({ rootDir: paths.acpRegistryRootDirectory });
90
+ if (fixtureEnabled) {
91
+ const fixtureMode = env.XENO_AGENT_FIXTURE_MODE || 'normal';
92
+ const fixturePath = env.XENO_AGENT_FIXTURE_PATH
93
+ || join(options.fixtureBaseDirectory, '..', '..', 'tests', 'fixtures', 'acp', 'fixture-acp-agent.mjs');
94
+ const fixture = createFixtureAcpAgentConfig(registry.rootDir, fixtureMode, 'fixture-acp');
95
+ const saved = registry.saveAgent({
96
+ ...fixture,
97
+ args: [fixturePath, `--mode=${fixtureMode}`],
98
+ env: { XENO_ACP_FIXTURE_MODE: fixtureMode, ELECTRON_RUN_AS_NODE: '1' },
99
+ });
100
+ if (!saved.success)
101
+ throw new Error(saved.error || 'Could not register the standalone ACP fixture.');
102
+ }
103
+ const runtimeStore = new RuntimeEventStore(paths.runtimeEventsDirectory);
104
+ const cloudOptions = standaloneCloudOptions({ homeDirectory: options.homeDirectory, hostRoot, env });
105
+ const cloudClient = createXenoCloudNodeAgentsClient(cloudOptions);
106
+ const cloudRunClient = {
107
+ createRun: (input, runOptions) => cloudClient.createRun(input, runOptions),
108
+ attach: (runId, runOptions) => cloudClient.attach(runId, {
109
+ transport: 'sse',
110
+ reconnect: true,
111
+ ...(runOptions?.signal ? { signal: runOptions.signal } : {}),
112
+ }),
113
+ getRun: (runId, runOptions) => cloudClient.getRun(runId, runOptions),
114
+ stopRun: (runId, runOptions) => cloudClient.stopRun(runId, runOptions),
115
+ };
116
+ const acpPermissionPolicyStore = new DurableAcpPermissionPolicyStore({ rootDirectory: hostRoot });
117
+ const interruptedTurnStore = new DurableInterruptedTurnStore({ rootDirectory: hostRoot });
118
+ /**
119
+ * Pinned nodes waiting for a next turn (XENO SPAWN ยง2.2, ยง3).
120
+ *
121
+ * Durable here for the same reason the two stores above it are: this host has a real root, and
122
+ * ยง3 puts a `deferred` row on an append-only record the moment a node is pinned. Held in memory,
123
+ * a restart left that row promising a delivery nothing could still make.
124
+ */
125
+ const pendingInjectionStore = new DurablePendingInjectionStore({ rootDirectory: hostRoot });
126
+ const acpPermissionBridge = new AcpInteractivePermissionBridge({
127
+ messenger: {
128
+ send: (channel, payload) => {
129
+ const send = options.surface.sendElicitation;
130
+ // Throwing is the CORRECT direction here: the bridge treats an
131
+ // unreachable surface as a refusal, and a permission prompt nobody can
132
+ // answer must not resolve as an approval.
133
+ if (!send)
134
+ throw new Error('This Agent host has no surface able to show a permission prompt.');
135
+ send(channel, payload);
136
+ },
137
+ },
138
+ });
139
+ const permissionPolicyService = {
140
+ listRules: () => ({
141
+ success: true,
142
+ rules: acpPermissionPolicyStore.list().map((rule) => ({
143
+ id: rule.id,
144
+ decision: rule.decision,
145
+ ...(rule.kind ? { toolKind: rule.kind } : {}),
146
+ ...(rule.pathPrefix ? { pathPrefix: rule.pathPrefix } : {}),
147
+ ...(rule.createdAt === undefined ? {} : { createdAt: rule.createdAt }),
148
+ })),
149
+ }),
150
+ revokeRule: (input) => ({
151
+ success: true,
152
+ revoked: acpPermissionPolicyStore.remove(input.ruleId),
153
+ }),
154
+ };
155
+ // Loaded only when the SDK lane can actually run. A headless composition has
156
+ // no window, cannot run that lane at all (it is declared unrunnable below),
157
+ // and so has no reason to pay for โ€” or be broken by โ€” this module.
158
+ const sdkInteractionService = options.surface.createSdkRuntimeWindow
159
+ ? (await import('@xenosystem/agent-interface-electron-host'))
160
+ .createSdkNativeInteractionService()
161
+ : undefined;
162
+ const interactionService = {
163
+ answerPermission: (input) => {
164
+ const routed = acpPermissionBridge.answerPermission({
165
+ permissionId: input.permissionId,
166
+ granted: input.granted,
167
+ ...(input.selectedOptionId ? { selectedOptionId: input.selectedOptionId } : {}),
168
+ });
169
+ if (routed.handled)
170
+ return { success: true };
171
+ // ๐Ÿ”ด Not silently "handled". With no SDK lane there is nobody to route
172
+ // this to, and reporting success for an answer nothing received would
173
+ // leave the asker waiting forever on a prompt already marked resolved.
174
+ if (!sdkInteractionService) {
175
+ return { success: false, error: 'This host has no SDK-native lane to answer.' };
176
+ }
177
+ return sdkInteractionService.answerPermission(input);
178
+ },
179
+ answerAskUser: sdkInteractionService
180
+ ? sdkInteractionService.answerAskUser
181
+ : () => ({ success: false, error: 'This host has no SDK-native lane to answer.' }),
182
+ };
183
+ /**
184
+ * The four ยง5 services, from the ONE builder both compositions call.
185
+ *
186
+ * ๐Ÿ”ด They used to be constructed here and NOWHERE else, which meant they
187
+ * reached this composition โ€” the detached host โ€” and not the app the user
188
+ * launches. Closing that by pasting the constructions into `index.ts` would
189
+ * have left two spellings of the same decisions in the two files least likely
190
+ * to be read together, so the decisions moved to `hostServices.ts` instead.
191
+ */
192
+ const { knowledgeService, intakeService, sessionService, coordinationService, coordinationPort, } = createSharedHostServices({
193
+ resolveIntakeRoots: () => workspaceAuthority.grantedDirectories(),
194
+ });
195
+ const engineeringControlService = new EngineeringWorkbenchService({ runtimeEventStore: runtimeStore });
196
+ /**
197
+ * Tier-1 run observation (ADE ยง2.9).
198
+ *
199
+ * ๐Ÿ”ด Built HERE, in the shared composition, and that is a fix rather than a
200
+ * move. It used to live only in `index.ts`, so the DETACHED host โ€” the whole
201
+ * point of ยง9 step 3 โ€” ran with no observation and no landing gate at all:
202
+ * the exact mirror of the four ยง5 services that used to reach only the
203
+ * detached host and never the app. A capability that exists in one
204
+ * composition and not the other is the fork this module exists to remove.
205
+ *
206
+ * ๐Ÿ”’ **Out of process, HTTP only** (locked decision 17). `xeno-use` is
207
+ * AGPL-3.0 and this app is proprietary, so nothing here imports it, links its
208
+ * N-API binding, or takes it as a dependency. The observer speaks its
209
+ * `/v1/use/*` surface and nothing else.
210
+ *
211
+ * ๐Ÿ”ด **Unconfigured means NO observation, not an empty one.** With no
212
+ * endpoint the service is not constructed and no binder is passed, so the host
213
+ * reports review unavailable and `mayLand` refuses for want of a diff โ€” all
214
+ * of which is true and correct. An empty service would instead report every
215
+ * run as a clean diff of changes nobody watched.
216
+ */
217
+ /**
218
+ * Records where EVERY run executes โ€” with or without observation configured.
219
+ *
220
+ * ๐Ÿ”ด Deliberately outside the `if (observationConfig.enabled)` block below.
221
+ * Isolation and observation are different questions, and a run that nothing
222
+ * watches is still a run whose containment the user is entitled to know. Bound
223
+ * here with no sandbox, so it resolves to the local executor and reports
224
+ * `none` โ€” a fact, not an absence. Binding only observed runs would leave
225
+ * every other run reporting `unknown`, which reads as "cannot account for it"
226
+ * when the truth is plainly "nothing confines it".
227
+ */
228
+ const bindRunExecution = (context) => {
229
+ // ๐Ÿ”ด CONFINEMENT REQUIRES A DIRECTORY TO MOUNT. A run with none has nothing
230
+ // to bind a sandbox around, so it is bound unconfined and reports `none` โ€”
231
+ // truthfully. Substituting a directory would mount the wrong tree.
232
+ if (!resolveRunSandbox || !context.primaryDirectory) {
233
+ runExecutionEnvironment.bind({
234
+ runId: context.runId,
235
+ conversationId: context.conversationId,
236
+ });
237
+ return;
238
+ }
239
+ runExecutionEnvironment.bind({
240
+ runId: context.runId,
241
+ conversationId: context.conversationId,
242
+ pendingSandbox: resolveRunSandbox(context.runId, context.primaryDirectory),
243
+ });
244
+ };
245
+ const knownRunIds = new Set();
246
+ let runObservationService;
247
+ let runObserver;
248
+ let observationBegin;
249
+ /**
250
+ * Spawns (or joins) the run's sandbox and describes it for execution.
251
+ *
252
+ * Defined only when a `xeno-use` endpoint is configured โ€” so with no sandbox
253
+ * substrate installed, `bindRunExecution` binds every run unconfined and the
254
+ * app reports `none`, which is the truth and is what ยง5.3 asks it to say.
255
+ */
256
+ let resolveRunSandbox;
257
+ const observationConfig = resolveObservationConfig(env);
258
+ if (observationConfig.reason) {
259
+ // Said once, at startup. Half-configured otherwise fails silently, one 401
260
+ // per run, with nothing pointing at configuration as the cause.
261
+ console.warn(`[xeno-agent-host] run observation is off: ${observationConfig.reason}`);
262
+ }
263
+ if (observationConfig.enabled && observationConfig.baseUrl && observationConfig.token) {
264
+ runObservationService = new RunObservationService();
265
+ const observer = new XenoUseRunObserver({
266
+ baseUrl: observationConfig.baseUrl,
267
+ token: observationConfig.token,
268
+ ...(observationConfig.image ? { image: observationConfig.image } : {}),
269
+ service: runObservationService,
270
+ baselineScanner: new DirectoryBaselineScanner(),
271
+ // Hunk content is read from the HOST side of the mount โ€” a bind mount is
272
+ // the host directory, so this needs no sandbox call and no dependency.
273
+ createContentReader: ({ hostDirectory, containerDirectory }) => new MountContentReader({ mounts: [{ source: hostDirectory, target: containerDirectory }] }),
274
+ onProblem: ({ runId, reason }) => console.warn(`[xeno-agent-host] observation problem${runId ? ` for ${runId}` : ''}: ${reason}`),
275
+ });
276
+ runObserver = observer;
277
+ observationBegin = ({ runId, primaryDirectory }) => {
278
+ // ๐Ÿ”ด Recorded BEFORE the early return below.
279
+ //
280
+ // A run with no directory is never observed, and that is exactly the run
281
+ // the landing gate must not silently ignore. Recording it here is what
282
+ // lets `landingOverlaps` report the combination as incomplete instead of
283
+ // clearing a merge over work nobody watched.
284
+ knownRunIds.add(runId);
285
+ // No directory means nothing to watch. Substituting one would mount the
286
+ // wrong tree into a sandbox and report another project's files as this
287
+ // run's work.
288
+ if (!primaryDirectory)
289
+ return;
290
+ // Not awaited: attaching spawns a container and the turn is the user's
291
+ // actual work. `start` resolves false rather than throwing, and an
292
+ // unobserved run is reported honestly everywhere downstream.
293
+ void observer.start(runId, { hostDirectory: primaryDirectory });
294
+ };
295
+ /**
296
+ * Routes EXECUTION into the sandbox this run is already observed in.
297
+ *
298
+ * ๐Ÿ”ด One container, both roles โ€” which is ADE ยง2.9's architecture ("every
299
+ * run is sandboxed, so the sandbox is an observation plane"), not a
300
+ * shortcut. `isolationIsNotObservation.test.ts` forbids deriving
301
+ * `sandboxed` from an observation binding *while execution still goes to
302
+ * the host*; its own stated exit is "turn execution actually being routed
303
+ * into one", which is this. The binding was never sufficient on its own โ€”
304
+ * it is necessary, and now it is accompanied.
305
+ *
306
+ * The spawn is handed over as a PENDING sandbox rather than awaited here.
307
+ * Awaiting would stall every turn behind a container start; not awaiting at
308
+ * all would let the first command run on the host and still report
309
+ * `sandboxed`. Passing the promise makes the EXECUTOR wait instead, so no
310
+ * command reaches the host and no turn is blocked for a run that never
311
+ * executes anything.
312
+ */
313
+ resolveRunSandbox = async (runId, primaryDirectory) => {
314
+ const started = await observer.start(runId, { hostDirectory: primaryDirectory });
315
+ if (!started)
316
+ return undefined;
317
+ const descriptor = observer.sandboxFor(runId);
318
+ if (!descriptor)
319
+ return undefined;
320
+ return {
321
+ sandboxId: descriptor.sandboxId,
322
+ mount: {
323
+ hostDirectory: descriptor.hostDirectory,
324
+ containerDirectory: descriptor.containerDirectory,
325
+ },
326
+ baseUrl: observationConfig.baseUrl,
327
+ token: observationConfig.token,
328
+ mechanism: 'container (xeno-use)',
329
+ };
330
+ };
331
+ }
332
+ /**
333
+ * The binder the host always gets.
334
+ *
335
+ * ๐Ÿ”ด Unconditional, because isolation must be recorded for every run whether
336
+ * or not observation is configured โ€” and the two used to share one optional
337
+ * hook, so an unobserved run was also an unaccounted-for run. The observation
338
+ * half stays conditional inside it: with no endpoint `runObservationService`
339
+ * is still undefined, so review remains unavailable and `mayLand` still
340
+ * refuses for want of a diff. Nothing about that changed.
341
+ */
342
+ const runObservationBinder = {
343
+ begin: (context) => {
344
+ bindRunExecution({
345
+ runId: context.runId,
346
+ conversationId: context.conversationId,
347
+ ...(context.primaryDirectory === undefined
348
+ ? {}
349
+ : { primaryDirectory: context.primaryDirectory }),
350
+ });
351
+ observationBegin?.(context);
352
+ },
353
+ };
354
+ /**
355
+ * ยง5.7's landing queue โ€” the serialised, gated path to trunk.
356
+ *
357
+ * ๐Ÿ”ด Its overlap picture is read from the OBSERVATION service above. With
358
+ * observation unconfigured there are no observed diffs, so `landingOverlaps()`
359
+ * reports nothing and the gate refuses every landing as `unverified` โ€”
360
+ * correct, and not a degradation to route around: conflict-aware landing
361
+ * without observed change sets would be a verdict about a world nobody
362
+ * looked at.
363
+ *
364
+ * The merge itself is a plain `git merge --no-ff` of the run's branch.
365
+ * `--no-ff` on purpose: a landing is a decision, and a decision should be
366
+ * visible in the history as one commit that can be reverted, rather than
367
+ * dissolved into trunk by a fast-forward.
368
+ */
369
+ engineeringControlService.configureLandingService(new LandingService({
370
+ context: {
371
+ contextFor: () => ({
372
+ overlaps: runObservationService
373
+ // ๐Ÿ”ด The known set is passed, not just the observed one. Without it
374
+ // the gate compares only what it happened to watch and calls the
375
+ // answer complete โ€” which is how a merge lands on top of an
376
+ // unobserved run's work.
377
+ ? runObservationService.landingOverlaps({}, [...knownRunIds])
378
+ // An empty report, which yields `unverified` and refuses. Deliberately
379
+ // NOT a fabricated all-clear: the gate must never clear a landing on
380
+ // facts nobody collected.
381
+ : { runs: [], overlaps: [], complete: false, incompleteRuns: [] },
382
+ verifications: [],
383
+ }),
384
+ },
385
+ checkRunner: {
386
+ run: async ({ rootDirectory, checkId }) => {
387
+ const discovered = engineeringControlService.discoverChecks({ rootDirectory });
388
+ const check = checkId
389
+ ? discovered.checks?.find((candidate) => candidate.id === checkId)
390
+ : discovered.checks?.[0];
391
+ if (!check) {
392
+ return { passed: false, checks: [], problem: 'No check could be discovered for this workspace.' };
393
+ }
394
+ const result = await engineeringControlService.runCheck({ rootDirectory, checkId: check.id });
395
+ // ๐Ÿ”ด `exitCode === 0`, not `result.success`. `success` means the check
396
+ // RAN; a suite that ran perfectly and reported three failures is a
397
+ // successful run of a red check, and treating that as green is the
398
+ // whole failure this gate exists to prevent.
399
+ const passed = result.run?.exitCode === 0;
400
+ return {
401
+ passed,
402
+ checks: [check.id],
403
+ ...(passed
404
+ ? {}
405
+ : {
406
+ problem: result.error
407
+ ?? result.run?.error
408
+ ?? `${check.id} exited ${result.run?.exitCode ?? 'without a status'}`,
409
+ }),
410
+ };
411
+ },
412
+ },
413
+ executor: {
414
+ merge: async ({ rootDirectory, runId, base }) => {
415
+ const branch = await engineeringControlService.gitBranch({
416
+ rootDirectory,
417
+ name: base || 'main',
418
+ create: false,
419
+ });
420
+ if (!branch.success) {
421
+ return { merged: false, error: branch.error || `Could not switch to ${base || 'main'} to land.` };
422
+ }
423
+ return await engineeringControlService.gitMergeForLanding({ rootDirectory, branch: runId });
424
+ },
425
+ },
426
+ }));
427
+ /**
428
+ * Where each run's commands execute, and what containment to report โ€” ADE ยง5.3.
429
+ *
430
+ * ๐Ÿ”ด One object answers both, so the app cannot confine a run while telling
431
+ * the user it does not, or claim containment it does not have. It is
432
+ * constructed unconditionally: with no sandbox bound it hands out the local
433
+ * executor and reports `none`, which is exactly today's truth and is what
434
+ * ยง5.3 asks the product to say out loud.
435
+ *
436
+ * Binding a run to an execution sandbox happens in `bindRunExecution` when
437
+ * a `xeno-use` endpoint is configured. Workspace commands, the agent's
438
+ * Bash tool, and the workspace PTY all resolve through this object. A
439
+ * confined PTY is `/bin/sh -i` with `tty: true` on `CommandStreamPort`.
440
+ */
441
+ const runExecutionEnvironment = new RunExecutionEnvironment();
442
+ let providerLaneServiceForPty;
443
+ const workspaceAuthority = new WorkspaceHostService({
444
+ rootDirectory: paths.workspaceAuthorityRoot,
445
+ managedConversationRoot: paths.managedConversationRoot,
446
+ executionEnvironment: runExecutionEnvironment,
447
+ providerSessionPtyResolver: (profile, cwd, platform) => (providerLaneServiceForPty?.resolveProviderSessionPty(profile, cwd, platform)
448
+ ?? { code: 'NATIVE_TUI_UNSUPPORTED', error: 'Provider lane authority is not ready.' }),
449
+ });
450
+ const workspaceShellService = options.surface.shell
451
+ ? new WorkspaceShellService({ workspaceAuthority, shell: options.surface.shell })
452
+ // Absent rather than a stub. A shell service that accepted "reveal in
453
+ // folder" and did nothing would report success it does not have.
454
+ : undefined;
455
+ /**
456
+ * ๐Ÿ”ด Two ways to run the SDK-native lane now, and a window is only one of them.
457
+ *
458
+ * A composition with a renderer runs it through `createSdkNativeElectronRunner`, exactly as
459
+ * before. A composition without one runs the SAME loop through the headless runner, which
460
+ * routes the two blocking channels to attached clients and REFUSES them when nobody is there.
461
+ * That refusal is what makes the headless lane runnable rather than a trap: an elicitation that
462
+ * is neither answered nor refused wedges the turn forever, because `requestSurfacePermission`
463
+ * has no timeout.
464
+ *
465
+ * So the lane is runnable in both, and `canResolveElicitations` is true in both โ€” for different
466
+ * reasons, which is why the parameter asks about resolving rather than about a window.
467
+ */
468
+ const hasWindow = Boolean(options.surface.createSdkRuntimeWindow);
469
+ const sdkRunnable = true;
470
+ const declaredUnavailable = declaredUnavailableLanes({
471
+ hasWindow,
472
+ canResolveElicitations: true,
473
+ });
474
+ const loadProviders = () => loadAgentHostProviders(registry, cloudOptions, {
475
+ sdkRunnable,
476
+ // The store decides, not a literal โ€” one object, so the declaration cannot drift from it.
477
+ durablePendingInjections: pendingInjectionStore.durable,
478
+ });
479
+ const providers = await loadProviders();
480
+ // Typed as the shared interface, not inferred: inference from the initial two
481
+ // entries produces a union that the SDK adapter is not a member of, and the
482
+ // fix for that must not be a cast โ€” the whole point is that every lane
483
+ // satisfies one contract.
484
+ const turnExecutionAdapters = [
485
+ new AcpAgentTurnExecutionAdapter({
486
+ registryService: registry,
487
+ runtimeStore,
488
+ permissionDelegate: acpPermissionBridge,
489
+ permissionPolicyStore: acpPermissionPolicyStore,
490
+ /**
491
+ * \u{1F534} A PORT, not the service. What an ACP agent may do is exactly these
492
+ * four calls; passing the service itself would put `endRun` and
493
+ * `setInboundPolicy` within reach of the lane, and those are the host's.
494
+ */
495
+ coordinationPort,
496
+ /**
497
+ * \u{1F534} Late-bound on purpose. The adapter is built BEFORE the coordinator
498
+ * that owns the roster โ€” the same ordering `setEventSink` already solves
499
+ * โ€” so this closes over the binding rather than the value. Before the
500
+ * coordinator exists there is no run to report on, so the no-op is
501
+ * correct rather than a swallowed signal.
502
+ */
503
+ onRunActivity: (signal) => { coordinator?.reportRunActivity(signal); },
504
+ ...(fixtureEnabled ? { requestTimeoutMs: 5_000 } : {}),
505
+ }),
506
+ new XenoCloudAgentTurnExecutionAdapter({ client: cloudRunClient, runtimeStore }),
507
+ ];
508
+ if (!options.surface.createSdkRuntimeWindow) {
509
+ // The headless lane. Same loop, same coordination port, same run-activity reporter โ€” the
510
+ // only difference is where its events go and who can answer its prompts.
511
+ const { createSdkNativeHeadlessRunner } = await import('@xenosystem/agent-interface-electron-host/headless');
512
+ turnExecutionAdapters.splice(1, 0, new SdkNativeAgentTurnExecutionAdapter(createSdkNativeHeadlessRunner({
513
+ getAuthToken: () => readXenoCloudCredentials(cloudOptions).apiKey || null,
514
+ getPlatformAuthToken: () => readXenoCloudCredentials(cloudOptions).webToken || null,
515
+ runtimeEventStore: runtimeStore,
516
+ executionEnvironment: runExecutionEnvironment,
517
+ /**
518
+ * ๐Ÿ”ด Delivery is answered from HANDSHAKES, never from subscribers. The RPC server
519
+ * subscribes exactly one listener and fans out to however many sockets it holds, so a
520
+ * listener count is equally consistent with ten attached clients and none. Only a client
521
+ * that completed a handshake AND declared `canPresentElicitations` can actually answer.
522
+ *
523
+ * Late-bound through `coordinator` for the same reason `onRunActivity` is: the adapters
524
+ * are built before the coordinator that owns the client registry exists.
525
+ */
526
+ deliverElicitation: (channel, payload) => deliverElicitationThrough(coordinator, channel, payload),
527
+ }), coordinationPort, (signal) => { coordinator?.reportRunActivity(signal); },
528
+ // The SAME store the windowed lane gets: a pin is made against a CONVERSATION, and which
529
+ // lane happens to execute its next turn is not something an operator chose.
530
+ pendingInjectionStore));
531
+ }
532
+ if (options.surface.createSdkRuntimeWindow) {
533
+ // Registered only where it can actually run. The adapter registry answers
534
+ // `adapter_unavailable` BY NAME for an unregistered lane, so an attempt to
535
+ // use it here fails with the reason rather than with a crash inside a tool
536
+ // reaching for a window that does not exist.
537
+ const { createSdkNativeElectronRunner } = await import('@xenosystem/agent-interface-electron-host');
538
+ const sdkRunner = createSdkNativeElectronRunner({
539
+ getAuthToken: () => readXenoCloudCredentials(cloudOptions).apiKey || null,
540
+ getPlatformAuthToken: () => readXenoCloudCredentials(cloudOptions).webToken || null,
541
+ createRuntimeWindow: options.surface.createSdkRuntimeWindow,
542
+ runtimeEventStore: runtimeStore,
543
+ executionEnvironment: runExecutionEnvironment,
544
+ });
545
+ // The SAME port the ACP lane gets. Two vocabularies, one implementation โ€” a
546
+ // primitive that behaved differently by lane would leave an agent with two
547
+ // answers and no way to tell which to believe.
548
+ turnExecutionAdapters.splice(1, 0, new SdkNativeAgentTurnExecutionAdapter(sdkRunner, coordinationPort,
549
+ // The SAME reporter the ACP lane gets. A run blocked on a human must look
550
+ // the same to a peer whichever runtime is executing it.
551
+ (signal) => { coordinator?.reportRunActivity(signal); }, pendingInjectionStore));
552
+ }
553
+ // Read by `onRunActivity` above, which is created first; see the note there.
554
+ let coordinator;
555
+ /**
556
+ * Subagent execution โ€” ADE ยง5.2, step 6.
557
+ *
558
+ * ๐Ÿ”ด The adapter runs each subagent as a TURN on this host, so a fan-out inherits the same
559
+ * admission budget, deadlines and abandonment as every other surface's work rather than getting
560
+ * a second execution path that would drift from all three.
561
+ *
562
+ * โš ๏ธ Provider selection goes through `fleet.plan`, not a local capability check. Duplicating the
563
+ * matching would give the fleet two answers to "who can run this", and the one nobody is looking
564
+ * at would be the one that drifts.
565
+ */
566
+ // ๐Ÿ”ด Anchored to THIS host's home. The default is the real user's `~/.xeno`, which an
567
+ // isolated host must never write to โ€” and until this line existed, every one did.
568
+ const subagentRunService = getSubagentRunService(join(options.homeDirectory, '.xeno', 'subagent-runs.json'));
569
+ const subagentScheduler = new SubagentScheduler(subagentRunService, runtimeStore);
570
+ // Diagnostics read the REGISTERED scheduler. Without this line they build their own, report its
571
+ // empty status as the system's, and an operator reads "scheduler not running" during an incident
572
+ // where it is running fine.
573
+ setSubagentScheduler(subagentScheduler);
574
+ /**
575
+ * The scheduler is a SURFACE, so it must complete the host handshake before its first turn.
576
+ *
577
+ * ๐Ÿ”ด It never did, and the consequence was invisible: every `turn.start` was refused with
578
+ * "The requesting surface must complete the host handshake before starting a turn.", the adapter
579
+ * read a refusal as an acceptance, and the run sat in `running` with zero runtime events until its
580
+ * lease expired. Measured with scripts/delegation-probe.mjs.
581
+ *
582
+ * โš ๏ธ Cached as a PROMISE so concurrent launches share one handshake instead of racing to
583
+ * register the same surface โ€” and NOT cached on failure, because a handshake that failed while the
584
+ * coordinator was still starting must stay retryable rather than poisoning every later run.
585
+ *
586
+ * โš ๏ธ It lives here rather than in the adapter because the adapter's port is deliberately
587
+ * `turn.start | turn.cancel` only. The composition owns the coordinator and the identity below;
588
+ * the adapter should not need a handshake-capable host to be testable.
589
+ */
590
+ const subagentIdentity = {
591
+ clientId: 'xeno-agent-subagent-scheduler',
592
+ clientVersion: options.hostVersion,
593
+ instanceId: `${options.hostInstanceId}:subagents`,
594
+ surface: 'headless-client',
595
+ };
596
+ /**
597
+ * Host-event subscribers registered BEFORE the coordinator exists.
598
+ *
599
+ * ๐Ÿ”ด `subscribe: (listener) => coordinator?.subscribe(...) ?? (() => {})` ran at adapter
600
+ * CONSTRUCTION, when `coordinator` is still undefined โ€” so it returned the no-op and the subagent
601
+ * adapter never received one host event. It therefore never saw `turn.execution.completed` and
602
+ * never settled a run: a child that finished successfully left its run at `running` until the
603
+ * lease expired, at which point recovery RE-RAN work that had already succeeded.
604
+ *
605
+ * โš ๏ธ The `request` property beside it is late-bound correctly, because it re-reads
606
+ * `coordinator` inside an async function on every call. The same comment covered both and only
607
+ * one of them actually deferred.
608
+ */
609
+ const pendingHostSubscribers = new Map();
610
+ const subscribeToHost = (listener) => {
611
+ if (coordinator)
612
+ return coordinator.subscribe(listener);
613
+ // โš ๏ธ The slot is captured by the returned unsubscribe, so it keeps working after the flush
614
+ // clears the map โ€” the caller cannot tell whether it subscribed before or after the
615
+ // coordinator existed, which is what makes this correct rather than merely working today.
616
+ const slot = {};
617
+ pendingHostSubscribers.set(listener, slot);
618
+ return () => {
619
+ pendingHostSubscribers.delete(listener);
620
+ slot.dispose?.();
621
+ };
622
+ };
623
+ const attachPendingHostSubscribers = () => {
624
+ if (!coordinator)
625
+ return;
626
+ for (const [listener, slot] of pendingHostSubscribers) {
627
+ slot.dispose = coordinator.subscribe(listener);
628
+ }
629
+ pendingHostSubscribers.clear();
630
+ };
631
+ let subagentHandshake;
632
+ const ensureSubagentHandshake = async () => {
633
+ if (subagentHandshake)
634
+ return subagentHandshake;
635
+ subagentHandshake = (async () => {
636
+ try {
637
+ await coordinator?.request('host.handshake', {
638
+ identity: subagentIdentity,
639
+ supportedProtocol: {
640
+ min: XENO_AGENT_HOST_MIN_PROTOCOL_VERSION,
641
+ max: XENO_AGENT_HOST_PROTOCOL_VERSION,
642
+ },
643
+ });
644
+ }
645
+ catch (error) {
646
+ subagentHandshake = undefined;
647
+ throw error;
648
+ }
649
+ })();
650
+ return subagentHandshake;
651
+ };
652
+ const subagentAdapter = createHostTurnSubagentExecutionAdapter({
653
+ // Late-bound for the same reason as `onRunActivity`: adapters are built before the
654
+ // coordinator that executes their turns exists.
655
+ host: {
656
+ request: (async (method, request) => {
657
+ if (!coordinator)
658
+ throw new Error('The Agent host is not ready to execute subagent runs.');
659
+ // Handshake first, every time, idempotently โ€” see ensureSubagentHandshake above.
660
+ if (method !== 'host.handshake')
661
+ await ensureSubagentHandshake();
662
+ return coordinator.request(method, request);
663
+ }),
664
+ subscribe: (listener) => subscribeToHost(listener),
665
+ },
666
+ identity: subagentIdentity,
667
+ selectProvider: async (run) => {
668
+ /**
669
+ * โš ๏ธ No `requiredCapabilities` is passed, and that is deliberate rather than a TODO.
670
+ * `SubagentCapabilityMatch` carries score/reasons/missingCapabilities โ€” it is the RESULT of
671
+ * matching, not the requirement โ€” so there is nothing on a run to forward. Inventing a
672
+ * requirement here would make `fleet.plan` refuse runs for a constraint no one stated.
673
+ * Carrying the requirement onto the run is a contract change, not a line of glue.
674
+ */
675
+ // ๐Ÿ”ด AWAITED. `request` is async, and the previous version cast the Promise to a plain
676
+ // object with `as unknown as` โ€” so `assignments` was always undefined, selection always
677
+ // failed, and every subagent run terminated with "No runtime could be selected for this
678
+ // subagent run." The cast is what made it typecheck.
679
+ const plan = await coordinator?.request('fleet.plan', {
680
+ tasks: [{ taskId: run.id }],
681
+ });
682
+ const providerId = plan?.assignments?.[0]?.providerId;
683
+ if (!providerId)
684
+ return undefined;
685
+ const provider = providers.find((entry) => entry.id === providerId);
686
+ const modelId = provider?.models.find((model) => model.isDefault)?.id ?? provider?.models[0]?.id;
687
+ if (!provider || !modelId)
688
+ return undefined;
689
+ return { providerId, catalogRevision: provider.catalogRevision, modelId };
690
+ },
691
+ });
692
+ subagentScheduler.registerAdapter(subagentAdapter);
693
+ /**
694
+ * The port the host gets: create and list from the run service, cancel from the SCHEDULER โ€”
695
+ * because cancelling a launched run has to reach the adapter that launched it.
696
+ */
697
+ const subagentRuns = {
698
+ createRun: (request) => {
699
+ const result = subagentRunService.createRun(request);
700
+ // ๐Ÿ”ด Drain immediately, or a created run waits for the next sweep โ€” and before this line
701
+ // existed, forever: nothing claimed a queued run at all, so `subagent.run.create` persisted a
702
+ // run that never executed. Proven with scripts/delegation-probe.mjs, which saw `queued` sixty
703
+ // seconds later with zero runtime events.
704
+ //
705
+ // โš ๏ธ Safe to call on every create because the ceiling lives in `claimNextRun`, which counts
706
+ // active runs per slot and refuses beyond `maxConcurrentRuns`. This adds no policy; it only
707
+ // stops the queue from standing still.
708
+ if (result.success)
709
+ subagentScheduler.drain();
710
+ return result;
711
+ },
712
+ listRuns: (request) => subagentRunService.listRuns(request),
713
+ cancelRun: (request) => subagentScheduler.cancelRun(request),
714
+ };
715
+ // The periodic sweep recovers runs whose worker died AND launches anything queued while every
716
+ // slot was full. It was never started, so neither happened.
717
+ subagentScheduler.start();
718
+ let stateRepository;
719
+ const createCoordinator = async () => {
720
+ // Idempotent: the lease is won once, but shutdown and retry paths can race,
721
+ // and opening the durable state twice from one process is no better than
722
+ // opening it from two.
723
+ if (coordinator)
724
+ return coordinator;
725
+ stateRepository = await createAgentStateRepository(paths);
726
+ const runtimeEventRepository = new RuntimeEventStoreRepositoryAdapter(runtimeStore);
727
+ const nativeLauncherResolver = new AcpNativeProviderLauncherResolver(registry);
728
+ const providerLaneService = new ProviderLaneService({
729
+ repository: stateRepository,
730
+ workspaceService: workspaceAuthority,
731
+ hostInstanceId: options.hostInstanceId,
732
+ hostStorageIdentity: createHash('sha256')
733
+ .update(`xeno-agent-state-v2\0${paths.stateDatabaseFile.toLowerCase()}`, 'utf8')
734
+ .digest('hex'),
735
+ getProviders: () => loadProviders(),
736
+ resolveNativeLauncher: (input) => nativeLauncherResolver.resolve(input),
737
+ runtimeEventRepository,
738
+ });
739
+ providerLaneServiceForPty = providerLaneService;
740
+ coordinator = new AgentHostCoordinator({
741
+ hostVersion: options.hostVersion,
742
+ hostInstanceId: options.hostInstanceId,
743
+ processBoundary: 'per-user-service',
744
+ storageSchemaVersion: 2,
745
+ eventSchemaVersion: 1,
746
+ providers,
747
+ providerCatalogLoader: () => loadProviders(),
748
+ runtimeEventRepository,
749
+ /**
750
+ * ADE ยง2.8's `native-tui` lane, finally reachable.
751
+ *
752
+ * Resolved from the ACP registry: a provider offers its own interface iff
753
+ * its agent config carries a `nativeTui` command, which is written only
754
+ * when the vendor's binary was actually FOUND on PATH. So a machine
755
+ * without Claude CLI installed reports `nativeTui: false` for
756
+ * `claude-local` and the mode is refused by name โ€” the honest answer,
757
+ * not a broken session.
758
+ *
759
+ * ๐Ÿ”ด Passing the resolver rather than a snapshot: the registry changes
760
+ * when a user adds an agent, and a value captured here would answer for
761
+ * the providers that existed at boot.
762
+ */
763
+ nativeTuiLane: createAcpNativeTuiLane(registry),
764
+ acpAgentRegistry: createAcpAgentRegistryPort(registry),
765
+ stateRepository,
766
+ workspaceService: workspaceAuthority,
767
+ interactionService,
768
+ permissionPolicyService,
769
+ interruptedTurnStore,
770
+ // Same rule as the interaction service: absent without a window, never a
771
+ // stub that accepts control commands for a lane that cannot run.
772
+ ...(options.surface.createSdkRuntimeWindow
773
+ ? {
774
+ sdkControlService: (await import('@xenosystem/agent-interface-electron-host'))
775
+ .createSdkNativeControlService(),
776
+ }
777
+ : {}),
778
+ engineeringControlService,
779
+ providerLaneService,
780
+ ...(runObservationService ? { runObservationService } : {}),
781
+ runObservationBinder,
782
+ knowledgeService,
783
+ intakeService,
784
+ sessionService,
785
+ coordinationService,
786
+ // ๐Ÿ”ด Supplied with NO source, and that is the point rather than a gap.
787
+ // ยง5.3 requires the user to always know whether a run is contained, so
788
+ // the shipped app must ANSWER โ€” and with no substrate installed the
789
+ // true answer is `none`. This is also the seam an out-of-process
790
+ // `xeno-use` adapter plugs into later: supplying a source here changes
791
+ // every surface at once, and changes nothing else.
792
+ // ๐Ÿ”ด The SAME object that chose the executor. Constructing a bare
793
+ // `RunIsolationService()` here โ€” as this line did โ€” meant the report was
794
+ // computed from nothing while execution was decided elsewhere, which is
795
+ // precisely the drift the environment exists to make unrepresentable.
796
+ runIsolationService: new RunIsolationService({ source: runExecutionEnvironment }),
797
+ subagentRuns,
798
+ acpApprovalService: createAcpApprovalPortAdapter({
799
+ registry,
800
+ resolveConfig: (agentId) => registry.getAgentInternal(agentId),
801
+ }),
802
+ turnExecutionAdapters,
803
+ });
804
+ // Anything that subscribed before this point is attached now. Without this the subagent
805
+ // adapter's terminal-event listener stays in the pending set forever.
806
+ attachPendingHostSubscribers();
807
+ return coordinator;
808
+ };
809
+ let disposed = false;
810
+ const dispose = async () => {
811
+ if (disposed)
812
+ return;
813
+ disposed = true;
814
+ // Observation sandboxes go first: a container that outlives the process
815
+ // that spawned it is a leak nothing later will clean up, and the detached
816
+ // host had no owner for these at all until they moved here.
817
+ await runObserver?.stopAll();
818
+ // `stateRepository` is undefined when this composition never won the lease
819
+ // and so never created a coordinator โ€” the ordinary case for a second app
820
+ // process. `close` is separately OPTIONAL on the port (the JSON
821
+ // implementation holds nothing), so both marks are load-bearing and neither
822
+ // is a defensive reflex.
823
+ await stateRepository?.close?.();
824
+ };
825
+ return {
826
+ createCoordinator,
827
+ paths,
828
+ registry,
829
+ runtimeStore,
830
+ workspaceAuthority,
831
+ workspaceShellService,
832
+ engineeringControlService,
833
+ runObservationService,
834
+ runObserver,
835
+ declaredUnavailable,
836
+ turnExecutionAdapterIds: turnExecutionAdapters.map((adapter) => adapter.id),
837
+ dispose,
838
+ };
839
+ }
840
+ /**
841
+ * The provider catalog.
842
+ *
843
+ * `sdkRunnable` is a parameter rather than being derived here, because whether
844
+ * the lane can run is a fact about the SURFACE, and this function only knows
845
+ * about credentials. Deriving it would make a headless host advertise a lane it
846
+ * cannot execute โ€” the capability-honesty failure (ยง7.1) in its plainest form.
847
+ */
848
+ export async function loadAgentHostProviders(registry, cloudOptions, options) {
849
+ const [acpProviders, cloudDiscovery] = await Promise.all([
850
+ createAcpRuntimeProviderDescriptors(registry),
851
+ discoverXenoCloudNodeRuntime(cloudOptions),
852
+ ]);
853
+ const hostedStatus = cloudDiscovery.hostedStatus;
854
+ const models = toXenoCloudAgentModelDescriptors(cloudDiscovery.modelCatalog.models);
855
+ const sdkUnavailableReason = sdkUnavailableReasonFor({
856
+ sdkRunnable: options.sdkRunnable,
857
+ directCredentialAvailable: cloudDiscovery.directCredentialAvailable,
858
+ modelCatalogError: cloudDiscovery.modelCatalog.error,
859
+ });
860
+ const sdkProvider = createSdkNativeRuntimeProviderDescriptor({
861
+ models,
862
+ runnable: options.sdkRunnable && cloudDiscovery.directCredentialAvailable,
863
+ // So the `context.inject` capability a client reads describes the store this host actually
864
+ // composed, rather than a sentence written when only one store existed.
865
+ durablePendingInjections: options.durablePendingInjections === true,
866
+ ...(sdkUnavailableReason ? { safeUnavailableReason: sdkUnavailableReason } : {}),
867
+ });
868
+ const safeUnavailableReason = hostedStatus.safeUnavailableReason || cloudDiscovery.modelCatalog.error;
869
+ const cloudProvider = createXenoCloudRuntimeProviderDescriptor({
870
+ authenticated: hostedStatus.authenticated && hostedStatus.ok,
871
+ controlCapabilities: hostedStatus.capabilities,
872
+ models,
873
+ ...(safeUnavailableReason ? { safeUnavailableReason } : {}),
874
+ });
875
+ return [sdkProvider, cloudProvider, ...acpProviders];
876
+ }
877
+ //# sourceMappingURL=composeHost.js.map