@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,475 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { AgentClient } from '@skaile/workspaces/sdk/client';
3
+ import { AgentCommand, AgentEvent as AgentEvent$1, MessageV2 } from '@skaile/workspaces/sdk/types';
4
+ import { AIProviderRegistryView } from '@skaile/forge-settings/server';
5
+ import { a as AgentAdapter, b as AgentAdapterConfig, c as AgentCommand$1, A as AgentEvent, d as AgentStatus } from './shared/forge-agent-runtime.DlNAoPjs.mjs';
6
+ import { SubscriberTransport, SessionDispatcher, MessageStore } from '@skaile/workspaces/sdk/session';
7
+
8
+ /**
9
+ * AgentProcessManager — spawns `skaile serve` subprocesses and connects via WebSocket.
10
+ *
11
+ * Replaces direct `createHarness()` calls in forge apps. Each session gets an isolated
12
+ * subprocess running the full agent runtime (harness, connectors, skills) and communicates
13
+ * via the AgentCommand/AgentEvent WebSocket protocol.
14
+ *
15
+ * Uses `WebSocketClientTransport` from `@skaile/agent-transport` for typed protocol
16
+ * handling, reconnection with exponential backoff, and heartbeat keepalive.
17
+ *
18
+ * Usage:
19
+ * const manager = new AgentProcessManager()
20
+ * await manager.create('my-session', { projectDir: '/path', ... })
21
+ * manager.on('event:my-session', (event) => { ... })
22
+ * await manager.prompt('my-session', 'Hello')
23
+ * await manager.shutdown('my-session')
24
+ */
25
+
26
+ interface AgentProcessConfig {
27
+ /** Working directory for the agent (--project-dir) */
28
+ projectDir: string;
29
+ /** Bridge harness type (omp, pi, claude-sdk) */
30
+ harness?: string;
31
+ /**
32
+ * Model selection passed through as `--model`: `"<providerId>/<modelId>"`,
33
+ * where `providerId` is a registry entry id. A bare model id and an `@role`
34
+ * alias also resolve — the runtime does that against its own registry.
35
+ */
36
+ model?: string;
37
+ /**
38
+ * The AI provider registry this session runs against. Every enabled entry's
39
+ * credential is resolved at spawn and delivered through the child
40
+ * environment as `SKAILE_AIP_<ID>_KEY`, which is the *name* the harness
41
+ * adapters write into the config they render. No secret is ever passed as an
42
+ * argument or written to a file by forge.
43
+ */
44
+ registry?: AIProviderRegistryView;
45
+ /**
46
+ * Registry entry ids the session is restricted to, passed as
47
+ * `--providers a,b`. Narrowing `registry` to the same ids is the caller's
48
+ * job — it decides which credentials are delivered; this decides which
49
+ * entries the spawned runtime keeps. Empty or absent: no restriction.
50
+ */
51
+ providers?: string[];
52
+ /**
53
+ * The layered environment `env:` references resolve against and the child
54
+ * env is based on; defaults to `process.env`.
55
+ */
56
+ credentialEnv?: Record<string, string | undefined>;
57
+ /** Environment variables — merged over the base environment and the resolved credentials */
58
+ env?: Record<string, string>;
59
+ /** System prompt — sent via configure message after connect */
60
+ systemPrompt?: string;
61
+ /** Max agentic turns */
62
+ maxTurns?: number;
63
+ /** GitAgent definition directory (--agent-dir) */
64
+ agentDir?: string;
65
+ /** Prompts directory for command injection */
66
+ promptsDir?: string;
67
+ /** SSH key path for git operations */
68
+ sshKeyPath?: string;
69
+ /**
70
+ * OMP harness session ID from a prior run. When set, injected as
71
+ * `SKAILE_RESUME_SESSION_ID` into the subprocess environment so the
72
+ * `skaile serve` subprocess passes it to OMP via `--session <id>`,
73
+ * restoring OMP's native conversation context without history injection.
74
+ */
75
+ harnessSessionId?: string;
76
+ /**
77
+ * Address `skaile serve` binds (`--host`) and this manager dials. Absent: the
78
+ * runtime's default bind (all interfaces) and a client dial on `127.0.0.1`.
79
+ * A host that must keep the child private passes `127.0.0.1`.
80
+ */
81
+ host?: string;
82
+ /**
83
+ * Bearer token the child's WebSocket requires. Delivered to the child as
84
+ * `SKAILE_WS_AUTH_TOKEN` (the runtime removes it from its own environment at
85
+ * startup, so agent processes never see it) and presented by the client
86
+ * transport. Absent: the socket stays unauthenticated, as before.
87
+ */
88
+ wsAuthToken?: string;
89
+ }
90
+ declare class AgentProcessManager extends EventEmitter {
91
+ private processes;
92
+ constructor();
93
+ /**
94
+ * Spawn a `skaile serve` subprocess and connect via WebSocket transport.
95
+ * Resolves when the subprocess is ready and transport is connected.
96
+ */
97
+ create(id: string, config: AgentProcessConfig): Promise<void>;
98
+ /**
99
+ * Send a prompt to the agent subprocess.
100
+ */
101
+ prompt(id: string, message: string): Promise<void>;
102
+ /**
103
+ * Cancel the current agent operation.
104
+ */
105
+ cancel(id: string): Promise<void>;
106
+ /**
107
+ * Gracefully shut down a subprocess.
108
+ */
109
+ shutdown(id: string): Promise<void>;
110
+ /**
111
+ * Check if a subprocess is connected and ready.
112
+ */
113
+ isConnected(id: string): boolean;
114
+ /**
115
+ * Check if a session exists (regardless of connection state).
116
+ */
117
+ has(id: string): boolean;
118
+ /**
119
+ * Send any AgentCommand to the managed process.
120
+ * Used internally by ProcessManagerClientTransport.
121
+ */
122
+ sendCommand(id: string, command: AgentCommand): void;
123
+ /**
124
+ * Create an AgentClient for a managed session.
125
+ * Uses ProcessManagerClientTransport so there is no second subscription
126
+ * on the underlying WebSocket transport. Disposing the client will
127
+ * gracefully shut down the subprocess via manager.shutdown().
128
+ *
129
+ * Throws if the session does not exist. The session must be connected
130
+ * (isConnected() === true) before any commands can be sent.
131
+ */
132
+ createAgentClient(id: string): AgentClient;
133
+ private shutdownSync;
134
+ private waitForReady;
135
+ }
136
+
137
+ /**
138
+ * ProcessManagerAdapter — AgentAdapter implementation backed by AgentProcessManager.
139
+ *
140
+ * Wraps the existing `AgentProcessManager` (spawns `skaile serve` subprocess,
141
+ * connects via WebSocket) so apps can use it through the canonical AgentAdapter
142
+ * interface without coupling to the process manager directly.
143
+ *
144
+ * One instance per active session.
145
+ */
146
+ declare class ProcessManagerAdapter implements AgentAdapter {
147
+ private readonly processManager;
148
+ private readonly emitter;
149
+ private processKey;
150
+ private status;
151
+ constructor();
152
+ connect(config: AgentAdapterConfig): Promise<void>;
153
+ sendCommand(command: AgentCommand$1): Promise<void>;
154
+ listen(): AsyncIterable<AgentEvent>;
155
+ abort(): Promise<void>;
156
+ isConnected(): boolean;
157
+ getStatus(): AgentStatus;
158
+ disconnect(): Promise<void>;
159
+ /**
160
+ * Internal: push an event into the async iterable queue.
161
+ * Exposed with underscore prefix for test injection.
162
+ */
163
+ _pushEvent(event: AgentEvent): void;
164
+ /**
165
+ * Set a custom process key (used by apps that namespace keys as 'userId:slug:mode').
166
+ * Must be called before `connect()`.
167
+ */
168
+ setProcessKey(key: string): void;
169
+ /**
170
+ * Access the underlying AgentProcessManager for advanced use cases
171
+ * (e.g. createAgentClient() needed by SessionDispatcher construction).
172
+ */
173
+ getProcessManager(): AgentProcessManager;
174
+ }
175
+
176
+ type AdapterFactory = () => AgentAdapter;
177
+ /**
178
+ * Create an adapter instance for the given harness name.
179
+ *
180
+ * Falls back to 'omp' if the harness is not registered.
181
+ * The caller must still call `adapter.connect(config)` with the harness name
182
+ * in `config.harness` so the subprocess receives the correct --harness flag.
183
+ */
184
+ declare function createAdapterForHarness(harness: string): AgentAdapter;
185
+ /**
186
+ * Register a custom adapter factory.
187
+ *
188
+ * Use this in tests or for experimental harnesses without touching the built-in list.
189
+ */
190
+ declare function registerHarness(name: string, factory: AdapterFactory): void;
191
+ /**
192
+ * Return all registered harness names.
193
+ */
194
+ declare function listRegisteredHarnesses(): string[];
195
+
196
+ /**
197
+ * EventEmitter-backed `SubscriberTransport` for forge SSE endpoints.
198
+ *
199
+ * One instance is created per session. SSE connections register via the
200
+ * concrete `listen()` method (not part of the `SubscriberTransport` interface)
201
+ * and receive an unsubscribe function for cleanup on connection close.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * const transport = new EventEmitterSubscriberTransport()
206
+ *
207
+ * // In an SSE endpoint handler:
208
+ * const stop = transport.listen('conn-123', (event) => {
209
+ * res.write(`data: ${JSON.stringify(event)}\n\n`)
210
+ * })
211
+ * // On connection close:
212
+ * stop()
213
+ * ```
214
+ */
215
+ declare class EventEmitterSubscriberTransport implements SubscriberTransport {
216
+ private readonly emitter;
217
+ /**
218
+ * Push an event to all listeners registered for this subscriber ID.
219
+ *
220
+ * Part of the `SubscriberTransport` interface — called by `SessionDispatcher`
221
+ * when the agent emits an event.
222
+ *
223
+ * @param subscriberId - ID of the subscriber connection to notify.
224
+ * @param event - Agent event to broadcast.
225
+ */
226
+ send(subscriberId: string, event: AgentEvent$1): void;
227
+ /**
228
+ * Remove all listeners for a subscriber ID.
229
+ *
230
+ * Part of the `SubscriberTransport` interface — called by `SessionDispatcher`
231
+ * when a subscriber disconnects or the session ends.
232
+ *
233
+ * @param subscriberId - ID of the subscriber whose listeners should be cleared.
234
+ */
235
+ remove(subscriberId: string): void;
236
+ /**
237
+ * Register a per-connection event handler and return an unsubscribe function.
238
+ *
239
+ * Not part of the `SubscriberTransport` interface — used directly by SSE route handlers.
240
+ *
241
+ * @param subscriberId - Unique identifier for this SSE connection.
242
+ * @param handler - Called for each agent event broadcast to this subscriber.
243
+ * @returns Cleanup function — call when the SSE connection closes.
244
+ */
245
+ listen(subscriberId: string, handler: (event: AgentEvent$1) => void): () => void;
246
+ }
247
+
248
+ /**
249
+ * The in-memory session record maintained by BaseAgentManager.
250
+ *
251
+ * Mirrors the `AgentSession` / `ManagedSession` interfaces in each app's
252
+ * agent-manager.ts but without app-specific fields (mode, processKey, etc.)
253
+ * Those remain in the app-level subclass.
254
+ */
255
+ interface ManagedSession {
256
+ sessionId: string;
257
+ dispatcher: SessionDispatcher;
258
+ subscriberTransport: EventEmitterSubscriberTransport;
259
+ isStreaming: boolean;
260
+ error: string | null;
261
+ /** Call to unregister the event handler from processManager. */
262
+ removeEventHandler?: () => void;
263
+ }
264
+ /**
265
+ * BaseAgentManager — shared session lifecycle for forge agent-manager singletons.
266
+ *
267
+ * Handles the boilerplate that is identical across forge-workspace,
268
+ * forge-assistant, and forge-project:
269
+ * - AgentProcessManager ownership
270
+ * - JsonlMessageStore + EventEmitterSubscriberTransport + SessionDispatcher creation
271
+ * - Event handler wiring: streaming-state tracking, error tracking, SSE fan-out
272
+ * - Session teardown (removeEventHandler + dispatcher.disconnect)
273
+ *
274
+ * Subclasses implement:
275
+ * - `buildProcessKey(sessionId)` — e.g. 'work', 'assistant:uuid', 'userId:slug:mode'
276
+ * - `buildAdapterConfig(sessionId)` — reads settings, resolves the provider
277
+ * registry, the model selector and the env
278
+ * - `getMessagesDir(sessionId)` — where JSONL files are stored for this session
279
+ * - `onSessionCreated(session)` — app-specific post-creation hook (capabilities,
280
+ * memory context, transformPrompt setup, session timer, etc.)
281
+ */
282
+ declare abstract class BaseAgentManager extends EventEmitter {
283
+ protected readonly processManager: AgentProcessManager;
284
+ constructor();
285
+ /** Derive the AgentProcessManager key for a session. */
286
+ protected abstract buildProcessKey(sessionId: string): string;
287
+ /** Build the config to pass to AgentProcessManager.create(). */
288
+ protected abstract buildAdapterConfig(sessionId: string): Promise<AgentAdapterConfig>;
289
+ /** Return the directory where JSONL message files are stored. */
290
+ protected abstract getMessagesDir(sessionId: string): string;
291
+ /**
292
+ * Hook called after the dispatcher is initialised but before the session
293
+ * is returned. Override for capability registration, transformPrompt setup,
294
+ * memory context injection, idle timer creation, etc.
295
+ */
296
+ protected abstract onSessionCreated(session: ManagedSession): Promise<void>;
297
+ /**
298
+ * Create a new ManagedSession: spawn subprocess, wire dispatcher, register event handler.
299
+ *
300
+ * This is the canonical "extract" from the `_createSession` methods in each app.
301
+ * The event handler below is the common core — apps extend via `onEvent()`.
302
+ *
303
+ * @param sessionId - The UUID for this session (resolved by the app).
304
+ */
305
+ protected createManagedSession(sessionId: string): Promise<ManagedSession>;
306
+ /**
307
+ * App-specific event hook. Called for every agent event after common
308
+ * streaming/error tracking but before SSE fan-out.
309
+ *
310
+ * Return `true` to suppress the default `this.emit('event', event)` call.
311
+ * Default implementation: no-op, returns false.
312
+ *
313
+ * @example Override in forge-workspace to handle `capability_invoked`
314
+ * @example Override in forge-assistant to trigger consolidation on `finished`
315
+ */
316
+ protected onEvent(_event: AgentEvent, _session: ManagedSession, _processKey: string): Promise<boolean>;
317
+ /**
318
+ * Tear down a session: unregister event handler, disconnect dispatcher.
319
+ */
320
+ protected teardownSession(session: ManagedSession): void;
321
+ /**
322
+ * Whether the given session is currently streaming.
323
+ * Returns false if the session does not exist.
324
+ * Override in subclasses that cache their session.
325
+ */
326
+ isSessionStreaming(_sessionId: string): boolean;
327
+ /**
328
+ * Last error for the given session, or null.
329
+ * Override in subclasses that cache their session.
330
+ */
331
+ getSessionError(_sessionId: string): string | null;
332
+ }
333
+
334
+ /**
335
+ * JSONL-backed MessageStore. One file per session at `{dir}/{sessionId}.jsonl`.
336
+ * Each line is a JSON-serialized MessageV2. Append-only; writes are serialised per
337
+ * store instance (see `append()`), and `BaseAgentManager.createManagedSession`
338
+ * constructs one instance per session — so a session's file keeps its call order even
339
+ * when the event dispatcher has several appends in flight at once.
340
+ * Reads the full file on every getMessages()/getLatestSeq() call — acceptable
341
+ * for forge's short-lived single-user sessions.
342
+ *
343
+ * `limit` returns the most recent N messages (tail semantics).
344
+ *
345
+ * Generic over `TMetaExtensions` so platforms can attach typed metadata under
346
+ * `meta` without forking. Forge apps use the bare default (`Record<string, never>`).
347
+ */
348
+ declare class JsonlMessageStore<TMetaExtensions extends object = Record<string, never>> implements MessageStore<MessageV2<TMetaExtensions>> {
349
+ private readonly dir;
350
+ /** Tail of this instance's serialised append chain; never rejects. */
351
+ private tail;
352
+ /**
353
+ * @param dir - Directory where per-session `.jsonl` files are stored.
354
+ * The directory is created on first write if it does not exist.
355
+ */
356
+ constructor(dir: string);
357
+ private filePath;
358
+ /**
359
+ * Append a message to the session's JSONL file.
360
+ *
361
+ * Creates the file (and parent directory) if absent.
362
+ *
363
+ * Appends are chained on a per-instance tail promise, so overlapping calls are
364
+ * written in the order they were issued rather than racing each other's
365
+ * open/write/close. A failed write is neutralised for the chain but still
366
+ * rejected to its own caller, so one bad append does not poison the ones after it.
367
+ *
368
+ * @param message - Message to persist. `message.sessionId` determines the target file.
369
+ */
370
+ append(message: MessageV2<TMetaExtensions>): Promise<void>;
371
+ /**
372
+ * Read messages for a session with optional pagination.
373
+ *
374
+ * Reads the full file on every call — acceptable for forge's short-lived sessions.
375
+ *
376
+ * @param sessionId - Session whose messages to retrieve.
377
+ * @param opts.before - Return only messages with `seq` less than this value.
378
+ * @param opts.limit - Return at most this many messages (tail semantics — most recent).
379
+ * @returns Ordered array of messages (oldest first within the result window).
380
+ */
381
+ getMessages(sessionId: string, opts?: {
382
+ before?: number;
383
+ limit?: number;
384
+ }): Promise<MessageV2<TMetaExtensions>[]>;
385
+ /**
386
+ * Return the sequence number of the most recent message in the session.
387
+ *
388
+ * Reads the whole JSONL file and takes its last non-empty line — see the
389
+ * class note on why re-reading is acceptable here.
390
+ *
391
+ * @param sessionId - Session to inspect.
392
+ * @returns `seq` of the last appended message, or `0` if the session has no
393
+ * messages (or its last line is unreadable).
394
+ */
395
+ getLatestSeq(sessionId: string): Promise<number>;
396
+ }
397
+
398
+ /**
399
+ * Internal class — implements SubscriberTransport, fans out to all subscribers.
400
+ *
401
+ * SessionDispatcher calls `send(subscriberId, event)` for each event, but
402
+ * FanoutSubscriberTransport broadcasts to ALL connected subscribers by
403
+ * ignoring the subscriberId argument and emitting on a shared fanout channel.
404
+ */
405
+ declare class FanoutSubscriberTransport implements SubscriberTransport {
406
+ private readonly emitter;
407
+ private readonly FANOUT_CHANNEL;
408
+ constructor(maxListeners?: number);
409
+ /**
410
+ * Called by SessionDispatcher. Broadcasts to ALL subscribers (ignores subscriberId).
411
+ */
412
+ send(_subscriberId: string, event: AgentEvent$1): void;
413
+ /**
414
+ * Called by SessionDispatcher on disconnect. No-op for fanout — subscribers
415
+ * manage their own cleanup via the unsubscribe function returned by subscribe().
416
+ */
417
+ remove(_subscriberId: string): void;
418
+ /**
419
+ * Internal: register a handler for all broadcasted events.
420
+ * Returns an unsubscribe function.
421
+ */
422
+ _listen(handler: (event: AgentEvent$1) => void): () => void;
423
+ /**
424
+ * Current count of listeners on the fanout channel.
425
+ */
426
+ get listenerCount(): number;
427
+ }
428
+ /**
429
+ * Multi-tab SSE fanout wrapper around EventEmitterSubscriberTransport.
430
+ *
431
+ * When two browser tabs subscribe with different subscriber IDs, each tab would
432
+ * normally only see events directed to it. FanoutTransport lets a single shared
433
+ * event source broadcast to every registered subscriber simultaneously.
434
+ *
435
+ * ## Usage in agent-manager.ts
436
+ *
437
+ * ```ts
438
+ * const fanout = new FanoutTransport()
439
+ *
440
+ * // Pass fanout.transport to SessionDispatcher instead of EventEmitterSubscriberTransport:
441
+ * new SessionDispatcher({ transport: fanout.transport, ... })
442
+ *
443
+ * // In the SSE endpoint (one call per browser connection):
444
+ * const connectionId = randomUUID()
445
+ * const unsub = fanout.subscribe(connectionId, (event) => res.write(...))
446
+ * req.on('close', unsub)
447
+ * ```
448
+ */
449
+ declare class FanoutTransport {
450
+ /**
451
+ * The inner transport passed to SessionDispatcher.
452
+ * SessionDispatcher calls transport.send(subscriberId, event) for each
453
+ * registered subscriber. FanoutTransport overrides send() to broadcast
454
+ * to ALL connected subscribers, ignoring the subscriberId argument.
455
+ */
456
+ readonly transport: FanoutSubscriberTransport;
457
+ constructor();
458
+ /**
459
+ * Register a new SSE connection.
460
+ *
461
+ * @param connectionId - Unique identifier for this browser connection (e.g. randomUUID()).
462
+ * Used only for diagnostics — the fanout ignores it for routing.
463
+ * @param handler - Called for each agent event broadcast to all subscribers.
464
+ * @returns Unsubscribe function — call when the SSE connection closes.
465
+ */
466
+ subscribe(_connectionId: string, handler: (event: AgentEvent$1) => void): () => void;
467
+ /**
468
+ * Returns the current count of active subscribers.
469
+ * Useful for diagnostics and health checks.
470
+ */
471
+ get subscriberCount(): number;
472
+ }
473
+
474
+ export { AgentAdapter, AgentAdapterConfig, AgentCommand$1 as AgentCommand, AgentEvent, AgentProcessManager, AgentStatus, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, listRegisteredHarnesses, registerHarness };
475
+ export type { AgentProcessConfig, ManagedSession };