@skaile/forge-agent-runtime 0.0.0-stage → 1.1.0

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,538 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
4
+ import net from 'node:net';
5
+ import { join, resolve, basename } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createConsola } from 'consola';
8
+ import { createColors } from 'picocolors';
9
+ import { AgentClient } from '@skaile/workspaces/sdk/client';
10
+ import { WebSocketClientTransport } from '@skaile/workspaces/sdk/transport/ws/client';
11
+ import { registryCredentialEnv } from '@skaile/forge-settings/server';
12
+
13
+ const pc = createColors(true);
14
+ const consolaWithFancy = createConsola;
15
+ const log = consolaWithFancy({
16
+ fancy: false,
17
+ defaults: { tag: pc.bold(pc.cyan("agent-process")) }
18
+ });
19
+ const BASE_PORT = 9100;
20
+ let nextPort = BASE_PORT;
21
+ async function isPortFree(port) {
22
+ return new Promise((resolve2) => {
23
+ const server = net.createServer();
24
+ server.once("error", () => resolve2(false));
25
+ server.once("listening", () => {
26
+ server.close(() => resolve2(true));
27
+ });
28
+ server.listen(port);
29
+ });
30
+ }
31
+ async function allocatePort() {
32
+ for (let i = 0; i < 100; i++) {
33
+ const port = nextPort++;
34
+ if (nextPort > BASE_PORT + 500) nextPort = BASE_PORT;
35
+ if (await isPortFree(port)) return port;
36
+ }
37
+ throw new Error("Could not find a free port for skaile serve");
38
+ }
39
+ function dialHost(host) {
40
+ if (!host) return "127.0.0.1";
41
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
42
+ }
43
+ function resolveSkaileCmd() {
44
+ try {
45
+ return fileURLToPath(import.meta.resolve("@skaile/workspaces/cli"));
46
+ } catch {
47
+ }
48
+ try {
49
+ const entryDir = fileURLToPath(new URL(".", import.meta.url));
50
+ const cliSubpath = join("node_modules", "@skaile", "workspaces", "dist", "cli", "index.js");
51
+ let dir = entryDir;
52
+ for (let i = 0; i < 8; i++) {
53
+ const candidate = join(dir, cliSubpath);
54
+ if (existsSync(candidate)) return candidate;
55
+ const parent = resolve(dir, "..");
56
+ if (parent === dir) break;
57
+ dir = parent;
58
+ }
59
+ } catch {
60
+ }
61
+ return "skaile";
62
+ }
63
+ function resolveBunCmd() {
64
+ const exec = basename(process.execPath).toLowerCase();
65
+ if (exec === "bun" || exec === "bun.exe") return process.execPath;
66
+ return "bun";
67
+ }
68
+ function resolveCliInterpreter(jsEntry) {
69
+ try {
70
+ const firstLine = readFileSync(jsEntry, "utf8").split("\n", 1)[0] ?? "";
71
+ if (/\bbun\b/.test(firstLine)) return resolveBunCmd();
72
+ } catch {
73
+ }
74
+ return process.execPath;
75
+ }
76
+ class ProcessManagerClientTransport {
77
+ constructor(manager, id) {
78
+ this.manager = manager;
79
+ this.id = id;
80
+ }
81
+ get connected() {
82
+ return this.manager.isConnected(this.id);
83
+ }
84
+ async connect() {
85
+ }
86
+ async disconnect() {
87
+ await this.manager.shutdown(this.id);
88
+ }
89
+ send(command) {
90
+ this.manager.sendCommand(this.id, command);
91
+ }
92
+ onMessage(handler) {
93
+ this.manager.on(`event:${this.id}`, handler);
94
+ return () => this.manager.off(`event:${this.id}`, handler);
95
+ }
96
+ onDisconnect(_handler) {
97
+ return () => {
98
+ };
99
+ }
100
+ }
101
+ class AgentProcessManager extends EventEmitter {
102
+ processes = /* @__PURE__ */ new Map();
103
+ constructor() {
104
+ super();
105
+ this.setMaxListeners(100);
106
+ const cleanup = () => {
107
+ for (const [id] of this.processes) {
108
+ try {
109
+ this.shutdownSync(id);
110
+ } catch {
111
+ }
112
+ }
113
+ };
114
+ process.on("exit", cleanup);
115
+ process.on("SIGTERM", cleanup);
116
+ process.on("SIGINT", cleanup);
117
+ }
118
+ /**
119
+ * Spawn a `skaile serve` subprocess and connect via WebSocket transport.
120
+ * Resolves when the subprocess is ready and transport is connected.
121
+ */
122
+ async create(id, config) {
123
+ log.info(
124
+ `[${id}] Creating session \u2014 harness=${config.harness ?? "omp"} model=${config.model ?? "(registry default)"}`
125
+ );
126
+ log.debug(`[${id}] projectDir=${config.projectDir}`);
127
+ if (this.processes.has(id)) {
128
+ log.info(`[${id}] Shutting down existing process`);
129
+ await this.shutdown(id);
130
+ }
131
+ if (config.systemPrompt) {
132
+ const skaileDir = join(config.projectDir, ".skaile");
133
+ if (!existsSync(skaileDir)) mkdirSync(skaileDir, { recursive: true });
134
+ const systemPromptFile = join(skaileDir, "system-prompt.md");
135
+ writeFileSync(systemPromptFile, config.systemPrompt);
136
+ log.debug(`[${id}] Wrote system prompt (${config.systemPrompt.length} chars)`);
137
+ }
138
+ const credentials = config.registry ? registryCredentialEnv(config.registry, { env: config.credentialEnv }) : { env: {}, deliveries: [] };
139
+ for (const delivery of credentials.deliveries) {
140
+ if (delivery.names.length > 0) {
141
+ log.debug(`[${id}] ${delivery.providerId} credential from ${delivery.source}`);
142
+ } else if (delivery.error) {
143
+ log.warn(`[${id}] ${delivery.providerId} credential not delivered: ${delivery.error}`);
144
+ }
145
+ }
146
+ const env = {
147
+ ...config.credentialEnv ?? process.env,
148
+ ...credentials.env,
149
+ ...config.env ?? {}
150
+ };
151
+ if (config.sshKeyPath) {
152
+ env.GIT_SSH_COMMAND = `ssh -i ${config.sshKeyPath} -o StrictHostKeyChecking=no`;
153
+ }
154
+ if (config.harnessSessionId) {
155
+ env.SKAILE_RESUME_SESSION_ID = config.harnessSessionId;
156
+ log.debug(`[${id}] SKAILE_RESUME_SESSION_ID=${config.harnessSessionId}`);
157
+ }
158
+ if (config.wsAuthToken) env.SKAILE_WS_AUTH_TOKEN = config.wsAuthToken;
159
+ const skaileCmd = resolveSkaileCmd();
160
+ const interpreter = skaileCmd.endsWith(".js") ? resolveCliInterpreter(skaileCmd) : null;
161
+ const MAX_PORT_ATTEMPTS = 3;
162
+ let managed;
163
+ let transport;
164
+ let port = 0;
165
+ for (let attempt = 1; attempt <= MAX_PORT_ATTEMPTS; attempt++) {
166
+ port = await allocatePort();
167
+ log.debug(`[${id}] Allocated port ${port} (attempt ${attempt}/${MAX_PORT_ATTEMPTS})`);
168
+ const args = ["serve", "--project-dir", config.projectDir, "--port", String(port)];
169
+ if (config.host) args.push("--host", config.host);
170
+ if (config.harness) args.push("--harness", config.harness);
171
+ if (config.model) args.push("--model", config.model);
172
+ if (config.providers && config.providers.length > 0)
173
+ args.push("--providers", config.providers.join(","));
174
+ log.debug(
175
+ `[${id}] Spawning: ${interpreter ? `${interpreter} ${skaileCmd}` : skaileCmd} ${args.join(" ")}`
176
+ );
177
+ const child = interpreter ? spawn(interpreter, [skaileCmd, ...args], { env, stdio: ["pipe", "pipe", "pipe"] }) : spawn(skaileCmd, args, { env, stdio: ["pipe", "pipe", "pipe"] });
178
+ transport = new WebSocketClientTransport({
179
+ url: `ws://${dialHost(config.host)}:${port}`,
180
+ ...config.wsAuthToken ? { auth: config.wsAuthToken } : {},
181
+ reconnect: { maxAttempts: 3, baseMs: 1e3, maxMs: 1e4 },
182
+ onLog: (line) => log.debug(`[${id}] ${line}`)
183
+ });
184
+ managed = {
185
+ child,
186
+ port,
187
+ transport,
188
+ config,
189
+ status: "starting",
190
+ stderrBuffer: []
191
+ };
192
+ this.processes.set(id, managed);
193
+ child.stdout?.on("data", (chunk) => {
194
+ for (const line of chunk.toString().split("\n")) {
195
+ const trimmed = line.trim();
196
+ if (trimmed && !trimmed.includes("listening on port")) {
197
+ log.info(`[${id}] [stdout] ${trimmed}`);
198
+ }
199
+ }
200
+ });
201
+ child.stderr?.on("data", (chunk) => {
202
+ const line = chunk.toString().trim();
203
+ if (line) {
204
+ managed.stderrBuffer.push(line);
205
+ if (managed.stderrBuffer.length > 30) managed.stderrBuffer.shift();
206
+ log.warn(`[${id}] [stderr] ${line}`);
207
+ }
208
+ });
209
+ log.debug(`[${id}] Waiting for subprocess ready signal...`);
210
+ try {
211
+ await this.waitForReady(id, managed, 2e4);
212
+ } catch (err) {
213
+ const portConflict = managed.stderrBuffer.some((l) => l.includes("EADDRINUSE"));
214
+ try {
215
+ if (managed.child.exitCode === null) managed.child.kill("SIGKILL");
216
+ } catch {
217
+ }
218
+ this.processes.delete(id);
219
+ if (portConflict && attempt < MAX_PORT_ATTEMPTS) {
220
+ log.warn(`[${id}] Port ${port} already in use \u2014 retrying on a fresh port`);
221
+ continue;
222
+ }
223
+ throw err;
224
+ }
225
+ log.info(`[${id}] Subprocess ready on port ${port}`);
226
+ managed.child.on("exit", (code, signal) => {
227
+ const prev = managed.status;
228
+ managed.status = "stopped";
229
+ if (prev !== "stopped") {
230
+ log.warn(`[${id}] Process exited unexpectedly (code=${code}, signal=${signal})`);
231
+ const stderr = managed.stderrBuffer.slice(-5).join("\n");
232
+ if (stderr) log.debug(`[${id}] Last stderr: ${stderr}`);
233
+ this.emit(`error:${id}`, {
234
+ type: "error",
235
+ error: `Agent process exited (code=${code}, signal=${signal})${stderr ? `: ${stderr}` : ""}`
236
+ });
237
+ } else {
238
+ log.debug(`[${id}] Process exited cleanly (code=${code})`);
239
+ }
240
+ this.processes.delete(id);
241
+ });
242
+ break;
243
+ }
244
+ await transport.connect();
245
+ managed.status = "connected";
246
+ log.info(`[${id}] WebSocket connected`);
247
+ managed.unsubMessage = transport.onMessage((event) => {
248
+ if (event.type === "error") {
249
+ log.warn(`[${id}] \u2190 error: ${event.message}`);
250
+ } else {
251
+ log.info(`[${id}] \u2190 ${event.type}`);
252
+ }
253
+ this.emit(`event:${id}`, event);
254
+ });
255
+ managed.unsubDisconnect = transport.onDisconnect(() => {
256
+ if (managed.status === "connected") {
257
+ log.warn(`[${id}] WebSocket disconnected unexpectedly`);
258
+ managed.status = "disconnected";
259
+ }
260
+ });
261
+ }
262
+ /**
263
+ * Send a prompt to the agent subprocess.
264
+ */
265
+ async prompt(id, message) {
266
+ log.info(`[${id}] Sending prompt (${message.length} chars)`);
267
+ const managed = this.processes.get(id);
268
+ if (!managed?.transport.connected) {
269
+ throw new Error(`No active connection for session ${id}`);
270
+ }
271
+ managed.transport.send({ type: "prompt", prompt: message });
272
+ }
273
+ /**
274
+ * Cancel the current agent operation.
275
+ */
276
+ async cancel(id) {
277
+ log.info(`[${id}] Cancelling`);
278
+ const managed = this.processes.get(id);
279
+ if (managed?.transport.connected) {
280
+ managed.transport.send({ type: "cancel" });
281
+ }
282
+ }
283
+ /**
284
+ * Gracefully shut down a subprocess.
285
+ */
286
+ async shutdown(id) {
287
+ const managed = this.processes.get(id);
288
+ if (!managed) return;
289
+ log.info(`[${id}] Shutting down (port ${managed.port})`);
290
+ managed.status = "stopped";
291
+ managed.unsubMessage?.();
292
+ managed.unsubDisconnect?.();
293
+ await managed.transport.disconnect();
294
+ if (managed.child.exitCode === null) {
295
+ managed.child.kill("SIGTERM");
296
+ await new Promise((resolve2) => {
297
+ const timeout = setTimeout(() => {
298
+ try {
299
+ managed.child.kill("SIGKILL");
300
+ } catch {
301
+ }
302
+ resolve2();
303
+ }, 5e3);
304
+ managed.child.on("exit", () => {
305
+ clearTimeout(timeout);
306
+ resolve2();
307
+ });
308
+ });
309
+ }
310
+ this.processes.delete(id);
311
+ }
312
+ /**
313
+ * Check if a subprocess is connected and ready.
314
+ */
315
+ isConnected(id) {
316
+ const managed = this.processes.get(id);
317
+ return managed?.status === "connected" && managed.transport.connected;
318
+ }
319
+ /**
320
+ * Check if a session exists (regardless of connection state).
321
+ */
322
+ has(id) {
323
+ return this.processes.has(id);
324
+ }
325
+ /**
326
+ * Send any AgentCommand to the managed process.
327
+ * Used internally by ProcessManagerClientTransport.
328
+ */
329
+ sendCommand(id, command) {
330
+ const managed = this.processes.get(id);
331
+ if (!managed?.transport.connected) {
332
+ throw new Error(`No active connection for session ${id}`);
333
+ }
334
+ managed.transport.send(command);
335
+ }
336
+ /**
337
+ * Create an AgentClient for a managed session.
338
+ * Uses ProcessManagerClientTransport so there is no second subscription
339
+ * on the underlying WebSocket transport. Disposing the client will
340
+ * gracefully shut down the subprocess via manager.shutdown().
341
+ *
342
+ * Throws if the session does not exist. The session must be connected
343
+ * (isConnected() === true) before any commands can be sent.
344
+ */
345
+ createAgentClient(id) {
346
+ if (!this.processes.has(id)) {
347
+ throw new Error(`No managed session '${id}'`);
348
+ }
349
+ return new AgentClient(new ProcessManagerClientTransport(this, id));
350
+ }
351
+ // ── Internal ────────────────────────────────────────────────────────
352
+ shutdownSync(id) {
353
+ const managed = this.processes.get(id);
354
+ if (!managed) return;
355
+ managed.status = "stopped";
356
+ managed.unsubMessage?.();
357
+ managed.unsubDisconnect?.();
358
+ try {
359
+ managed.transport.disconnect();
360
+ } catch {
361
+ }
362
+ try {
363
+ managed.child.kill("SIGTERM");
364
+ } catch {
365
+ }
366
+ this.processes.delete(id);
367
+ }
368
+ waitForReady(id, managed, timeoutMs) {
369
+ return new Promise((resolve2, reject) => {
370
+ const timer = setTimeout(() => {
371
+ reject(
372
+ new Error(
373
+ `skaile serve did not become ready within ${timeoutMs}ms. stderr: ${managed.stderrBuffer.slice(-3).join(" | ")}`
374
+ )
375
+ );
376
+ }, timeoutMs);
377
+ const onData = (chunk) => {
378
+ const text = chunk.toString();
379
+ if (text.includes("listening on port")) {
380
+ clearTimeout(timer);
381
+ managed.child.stdout?.off("data", onData);
382
+ resolve2();
383
+ }
384
+ };
385
+ managed.child.stdout?.on("data", onData);
386
+ managed.child.on("exit", (code) => {
387
+ clearTimeout(timer);
388
+ managed.child.stdout?.off("data", onData);
389
+ reject(
390
+ new Error(
391
+ `skaile serve exited during startup (code=${code}). stderr: ${managed.stderrBuffer.join(" | ")}`
392
+ )
393
+ );
394
+ });
395
+ });
396
+ }
397
+ }
398
+
399
+ class ProcessManagerAdapter {
400
+ processManager = new AgentProcessManager();
401
+ emitter = new EventEmitter();
402
+ processKey = null;
403
+ status = "disconnected";
404
+ constructor() {
405
+ this.emitter.setMaxListeners(20);
406
+ }
407
+ async connect(config) {
408
+ this.processKey = config.projectDir;
409
+ await this.processManager.create(this.processKey, {
410
+ projectDir: config.projectDir,
411
+ harness: config.harness,
412
+ registry: config.registry,
413
+ providers: config.providers,
414
+ credentialEnv: config.credentialEnv,
415
+ model: config.model,
416
+ env: config.env,
417
+ systemPrompt: config.systemPrompt,
418
+ maxTurns: config.maxTurns,
419
+ agentDir: config.agentDir,
420
+ harnessSessionId: config.harnessSessionId,
421
+ host: config.host,
422
+ wsAuthToken: config.wsAuthToken
423
+ });
424
+ this.status = "idle";
425
+ this.processManager.on(`event:${this.processKey}`, (event) => {
426
+ this._pushEvent(event);
427
+ if (event.type === "status") {
428
+ const phase = event.phase;
429
+ if (phase === "thinking" || phase === "working") {
430
+ this.status = "running";
431
+ } else if (phase === "idle") {
432
+ this.status = "idle";
433
+ }
434
+ }
435
+ if (event.type === "finished" || event.type === "turn_end" || event.type === "agent_end") {
436
+ this.status = "idle";
437
+ }
438
+ if (event.type === "message_start" && event.message?.role === "assistant") {
439
+ this.status = "running";
440
+ }
441
+ if (event.type === "error") {
442
+ this.status = "error";
443
+ }
444
+ });
445
+ this.processManager.on(`error:${this.processKey}`, (err) => {
446
+ this.status = "error";
447
+ this._pushEvent({
448
+ type: "error",
449
+ error: err.error || String(err)
450
+ });
451
+ });
452
+ }
453
+ async sendCommand(command) {
454
+ if (!this.processKey || !this.processManager.isConnected(this.processKey)) {
455
+ throw new Error("ProcessManagerAdapter: not connected \u2014 call connect() first");
456
+ }
457
+ this.processManager.sendCommand(this.processKey, command);
458
+ }
459
+ async *listen() {
460
+ const queue = [];
461
+ let resolve = null;
462
+ let done = false;
463
+ const onEvent = (event) => {
464
+ queue.push(event);
465
+ resolve?.();
466
+ resolve = null;
467
+ };
468
+ const onDone = () => {
469
+ done = true;
470
+ resolve?.();
471
+ resolve = null;
472
+ };
473
+ this.emitter.on("_event", onEvent);
474
+ this.emitter.on("_done", onDone);
475
+ try {
476
+ while (true) {
477
+ while (queue.length > 0) {
478
+ yield queue.shift();
479
+ }
480
+ if (done) break;
481
+ await new Promise((res) => {
482
+ resolve = res;
483
+ if (queue.length > 0 || done) {
484
+ res();
485
+ resolve = null;
486
+ }
487
+ });
488
+ }
489
+ } finally {
490
+ this.emitter.off("_event", onEvent);
491
+ this.emitter.off("_done", onDone);
492
+ }
493
+ }
494
+ async abort() {
495
+ if (this.processKey && this.processManager.isConnected(this.processKey)) {
496
+ this.processManager.sendCommand(this.processKey, { type: "cancel" });
497
+ this.status = "idle";
498
+ }
499
+ }
500
+ isConnected() {
501
+ return !!this.processKey && this.processManager.isConnected(this.processKey);
502
+ }
503
+ getStatus() {
504
+ return this.status;
505
+ }
506
+ async disconnect() {
507
+ this.emitter.emit("_done");
508
+ if (this.processKey) {
509
+ await this.processManager.shutdown(this.processKey).catch(() => {
510
+ });
511
+ this.processKey = null;
512
+ }
513
+ this.status = "disconnected";
514
+ }
515
+ /**
516
+ * Internal: push an event into the async iterable queue.
517
+ * Exposed with underscore prefix for test injection.
518
+ */
519
+ _pushEvent(event) {
520
+ this.emitter.emit("_event", event);
521
+ }
522
+ /**
523
+ * Set a custom process key (used by apps that namespace keys as 'userId:slug:mode').
524
+ * Must be called before `connect()`.
525
+ */
526
+ setProcessKey(key) {
527
+ this.processKey = key;
528
+ }
529
+ /**
530
+ * Access the underlying AgentProcessManager for advanced use cases
531
+ * (e.g. createAgentClient() needed by SessionDispatcher construction).
532
+ */
533
+ getProcessManager() {
534
+ return this.processManager;
535
+ }
536
+ }
537
+
538
+ export { AgentProcessManager as A, ProcessManagerAdapter as P };
@@ -0,0 +1,122 @@
1
+ import { AIProviderRegistryView } from '@skaile/forge-settings/server';
2
+
3
+ /**
4
+ * AgentAdapter — canonical interface for forge agent harnesses.
5
+ *
6
+ * Every harness (omp, claude-sdk, codex, …) runs behind an adapter that
7
+ * implements this interface. Apps depend only on this contract, not the
8
+ * concrete adapter type.
9
+ *
10
+ * Lifecycle:
11
+ * 1. Call `connect(config)` — spawns the subprocess or initialises the SDK client.
12
+ * 2. Call `sendCommand(cmd)` to dispatch prompts and control commands.
13
+ * 3. Call `listen()` to consume the async event stream.
14
+ * 4. Call `abort()` to cancel an in-flight turn.
15
+ * 5. Call `disconnect()` to tear down cleanly.
16
+ *
17
+ * Adapters are STATEFUL: one instance per active agent session.
18
+ */
19
+
20
+ /** Input to the agent: a natural-language prompt or a control message. */
21
+ interface AgentCommand {
22
+ type: string;
23
+ [key: string]: unknown;
24
+ }
25
+ /** Output from the agent: any typed event from the skaile protocol. */
26
+ interface AgentEvent {
27
+ type: string;
28
+ [key: string]: unknown;
29
+ }
30
+ /** Observable lifecycle state of an adapter instance. */
31
+ type AgentStatus = "idle" | "running" | "error" | "disconnected";
32
+ /**
33
+ * Configuration passed to `AgentAdapter.connect()`.
34
+ *
35
+ * All fields mirror `AgentProcessConfig` from `agent-process.ts`
36
+ * so the ProcessManagerAdapter can pass them through unchanged.
37
+ */
38
+ interface AgentAdapterConfig {
39
+ /** Working directory for the agent (--project-dir) */
40
+ projectDir: string;
41
+ /** Bridge harness type (omp, claude-sdk). Defaults to 'omp'. */
42
+ harness?: string;
43
+ /**
44
+ * The AI provider registry the session runs against. The adapter resolves
45
+ * each enabled entry's credential reference and delivers it through the
46
+ * child environment.
47
+ */
48
+ registry?: AIProviderRegistryView;
49
+ /**
50
+ * Registry entry ids the session is restricted to (`--providers a,b`).
51
+ * Absent or empty: the session sees the whole registry. `registry` should
52
+ * already be narrowed to the same ids so only their credentials are delivered.
53
+ */
54
+ providers?: string[];
55
+ /**
56
+ * The layered environment `env:` references resolve against and the child
57
+ * env is based on; defaults to `process.env`.
58
+ */
59
+ credentialEnv?: Record<string, string | undefined>;
60
+ /** Model selection as `"<providerId>/<modelId>"`, a bare model id, or `@role` */
61
+ model?: string;
62
+ /** Environment variables injected into the subprocess */
63
+ env?: Record<string, string>;
64
+ /** System prompt written to .skaile/system-prompt.md before spawn */
65
+ systemPrompt?: string;
66
+ /** Max agentic turns */
67
+ maxTurns?: number;
68
+ /** GitAgent definition directory */
69
+ agentDir?: string;
70
+ /** OMP harness session ID for native resume */
71
+ harnessSessionId?: string;
72
+ /** Address the subprocess binds and the client dials. Absent: runtime default bind, dial `127.0.0.1`. */
73
+ host?: string;
74
+ /** Bearer token the subprocess WebSocket requires and the client presents. Absent: no authentication. */
75
+ wsAuthToken?: string;
76
+ }
77
+ /**
78
+ * Canonical interface for all forge agent harnesses.
79
+ *
80
+ * Implementations wrap the `AgentProcessManager` pattern used by the existing
81
+ * forge apps, exposing a minimal async API that higher-level managers consume.
82
+ */
83
+ interface AgentAdapter {
84
+ /**
85
+ * Connect to the agent harness and prepare it for commands.
86
+ * Must be called once before `sendCommand` or `listen`.
87
+ */
88
+ connect(config: AgentAdapterConfig): Promise<void>;
89
+ /**
90
+ * Send a command to the connected agent.
91
+ * Throws if `connect()` has not been called or the session has disconnected.
92
+ */
93
+ sendCommand(command: AgentCommand): Promise<void>;
94
+ /**
95
+ * Return an async iterable of agent events.
96
+ * The iterable completes when the adapter disconnects.
97
+ *
98
+ * Callers can use `for await (const event of adapter.listen()) { ... }` or
99
+ * subscribe via an EventEmitter bridge (see BaseAgentManager).
100
+ */
101
+ listen(): AsyncIterable<AgentEvent>;
102
+ /**
103
+ * Cancel the current in-flight agent turn.
104
+ * No-op if the agent is idle.
105
+ */
106
+ abort(): Promise<void>;
107
+ /**
108
+ * Whether the adapter is connected and the agent is ready to receive commands.
109
+ */
110
+ isConnected(): boolean;
111
+ /**
112
+ * Observable lifecycle state.
113
+ */
114
+ getStatus(): AgentStatus;
115
+ /**
116
+ * Tear down the adapter: disconnect, stop subprocess if applicable, release resources.
117
+ * Safe to call multiple times.
118
+ */
119
+ disconnect(): Promise<void>;
120
+ }
121
+
122
+ export type { AgentEvent as A, AgentAdapter as a, AgentAdapterConfig as b, AgentCommand as c, AgentStatus as d };