@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +123 -2
- package/dist/app-runner-host/index.d.mts +61 -0
- package/dist/app-runner-host/index.d.ts +61 -0
- package/dist/app-runner-host/index.mjs +565 -0
- package/dist/index.d.mts +475 -0
- package/dist/index.d.ts +475 -0
- package/dist/index.mjs +357 -0
- package/dist/shared/forge-agent-runtime.B1LZr0Sl.mjs +538 -0
- package/dist/shared/forge-agent-runtime.DlNAoPjs.d.mts +122 -0
- package/dist/shared/forge-agent-runtime.DlNAoPjs.d.ts +122 -0
- package/package.json +55 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { P as ProcessManagerAdapter, A as AgentProcessManager } from './shared/forge-agent-runtime.B1LZr0Sl.mjs';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { mkdir, appendFile, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { SessionDispatcher } from '@skaile/workspaces/sdk/session';
|
|
6
|
+
import 'node:child_process';
|
|
7
|
+
import 'node:fs';
|
|
8
|
+
import 'node:net';
|
|
9
|
+
import 'node:url';
|
|
10
|
+
import 'consola';
|
|
11
|
+
import 'picocolors';
|
|
12
|
+
import '@skaile/workspaces/sdk/client';
|
|
13
|
+
import '@skaile/workspaces/sdk/transport/ws/client';
|
|
14
|
+
import '@skaile/forge-settings/server';
|
|
15
|
+
|
|
16
|
+
const registry = /* @__PURE__ */ new Map();
|
|
17
|
+
registry.set("omp", () => new ProcessManagerAdapter());
|
|
18
|
+
registry.set("claude-sdk", () => new ProcessManagerAdapter());
|
|
19
|
+
function createAdapterForHarness(harness) {
|
|
20
|
+
const factory = registry.get(harness) ?? registry.get("omp");
|
|
21
|
+
return factory();
|
|
22
|
+
}
|
|
23
|
+
function registerHarness(name, factory) {
|
|
24
|
+
registry.set(name, factory);
|
|
25
|
+
}
|
|
26
|
+
function listRegisteredHarnesses() {
|
|
27
|
+
return Array.from(registry.keys());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class EventEmitterSubscriberTransport {
|
|
31
|
+
emitter = new EventEmitter();
|
|
32
|
+
/**
|
|
33
|
+
* Push an event to all listeners registered for this subscriber ID.
|
|
34
|
+
*
|
|
35
|
+
* Part of the `SubscriberTransport` interface — called by `SessionDispatcher`
|
|
36
|
+
* when the agent emits an event.
|
|
37
|
+
*
|
|
38
|
+
* @param subscriberId - ID of the subscriber connection to notify.
|
|
39
|
+
* @param event - Agent event to broadcast.
|
|
40
|
+
*/
|
|
41
|
+
send(subscriberId, event) {
|
|
42
|
+
this.emitter.emit(subscriberId, event);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Remove all listeners for a subscriber ID.
|
|
46
|
+
*
|
|
47
|
+
* Part of the `SubscriberTransport` interface — called by `SessionDispatcher`
|
|
48
|
+
* when a subscriber disconnects or the session ends.
|
|
49
|
+
*
|
|
50
|
+
* @param subscriberId - ID of the subscriber whose listeners should be cleared.
|
|
51
|
+
*/
|
|
52
|
+
remove(subscriberId) {
|
|
53
|
+
this.emitter.removeAllListeners(subscriberId);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Register a per-connection event handler and return an unsubscribe function.
|
|
57
|
+
*
|
|
58
|
+
* Not part of the `SubscriberTransport` interface — used directly by SSE route handlers.
|
|
59
|
+
*
|
|
60
|
+
* @param subscriberId - Unique identifier for this SSE connection.
|
|
61
|
+
* @param handler - Called for each agent event broadcast to this subscriber.
|
|
62
|
+
* @returns Cleanup function — call when the SSE connection closes.
|
|
63
|
+
*/
|
|
64
|
+
listen(subscriberId, handler) {
|
|
65
|
+
this.emitter.on(subscriberId, handler);
|
|
66
|
+
return () => this.emitter.off(subscriberId, handler);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class JsonlMessageStore {
|
|
71
|
+
/**
|
|
72
|
+
* @param dir - Directory where per-session `.jsonl` files are stored.
|
|
73
|
+
* The directory is created on first write if it does not exist.
|
|
74
|
+
*/
|
|
75
|
+
constructor(dir) {
|
|
76
|
+
this.dir = dir;
|
|
77
|
+
}
|
|
78
|
+
/** Tail of this instance's serialised append chain; never rejects. */
|
|
79
|
+
tail = Promise.resolve();
|
|
80
|
+
filePath(sessionId) {
|
|
81
|
+
return join(this.dir, `${sessionId}.jsonl`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Append a message to the session's JSONL file.
|
|
85
|
+
*
|
|
86
|
+
* Creates the file (and parent directory) if absent.
|
|
87
|
+
*
|
|
88
|
+
* Appends are chained on a per-instance tail promise, so overlapping calls are
|
|
89
|
+
* written in the order they were issued rather than racing each other's
|
|
90
|
+
* open/write/close. A failed write is neutralised for the chain but still
|
|
91
|
+
* rejected to its own caller, so one bad append does not poison the ones after it.
|
|
92
|
+
*
|
|
93
|
+
* @param message - Message to persist. `message.sessionId` determines the target file.
|
|
94
|
+
*/
|
|
95
|
+
async append(message) {
|
|
96
|
+
const mine = this.tail.then(async () => {
|
|
97
|
+
await mkdir(this.dir, { recursive: true });
|
|
98
|
+
await appendFile(this.filePath(message.sessionId), `${JSON.stringify(message)}
|
|
99
|
+
`);
|
|
100
|
+
});
|
|
101
|
+
this.tail = mine.catch(() => {
|
|
102
|
+
});
|
|
103
|
+
return mine;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read messages for a session with optional pagination.
|
|
107
|
+
*
|
|
108
|
+
* Reads the full file on every call — acceptable for forge's short-lived sessions.
|
|
109
|
+
*
|
|
110
|
+
* @param sessionId - Session whose messages to retrieve.
|
|
111
|
+
* @param opts.before - Return only messages with `seq` less than this value.
|
|
112
|
+
* @param opts.limit - Return at most this many messages (tail semantics — most recent).
|
|
113
|
+
* @returns Ordered array of messages (oldest first within the result window).
|
|
114
|
+
*/
|
|
115
|
+
async getMessages(sessionId, opts) {
|
|
116
|
+
let content;
|
|
117
|
+
try {
|
|
118
|
+
content = await readFile(this.filePath(sessionId), "utf-8");
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const parsed = [];
|
|
123
|
+
for (const line of content.split("\n")) {
|
|
124
|
+
if (!line.trim()) continue;
|
|
125
|
+
try {
|
|
126
|
+
parsed.push(JSON.parse(line));
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let messages = parsed;
|
|
131
|
+
if (opts?.before !== void 0) {
|
|
132
|
+
messages = messages.filter((m) => m.seq < opts.before);
|
|
133
|
+
}
|
|
134
|
+
if (opts?.limit !== void 0) {
|
|
135
|
+
messages = messages.slice(-opts.limit);
|
|
136
|
+
}
|
|
137
|
+
return messages;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Return the sequence number of the most recent message in the session.
|
|
141
|
+
*
|
|
142
|
+
* Reads the whole JSONL file and takes its last non-empty line — see the
|
|
143
|
+
* class note on why re-reading is acceptable here.
|
|
144
|
+
*
|
|
145
|
+
* @param sessionId - Session to inspect.
|
|
146
|
+
* @returns `seq` of the last appended message, or `0` if the session has no
|
|
147
|
+
* messages (or its last line is unreadable).
|
|
148
|
+
*/
|
|
149
|
+
async getLatestSeq(sessionId) {
|
|
150
|
+
let content;
|
|
151
|
+
try {
|
|
152
|
+
content = await readFile(this.filePath(sessionId), "utf-8");
|
|
153
|
+
} catch {
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
157
|
+
if (lines.length === 0) return 0;
|
|
158
|
+
const lastLine = lines[lines.length - 1];
|
|
159
|
+
try {
|
|
160
|
+
const last = JSON.parse(lastLine ?? "");
|
|
161
|
+
return last.seq ?? 0;
|
|
162
|
+
} catch {
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class BaseAgentManager extends EventEmitter {
|
|
169
|
+
processManager = new AgentProcessManager();
|
|
170
|
+
constructor() {
|
|
171
|
+
super();
|
|
172
|
+
this.setMaxListeners(50);
|
|
173
|
+
}
|
|
174
|
+
// ── Core session creation ─────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* Create a new ManagedSession: spawn subprocess, wire dispatcher, register event handler.
|
|
177
|
+
*
|
|
178
|
+
* This is the canonical "extract" from the `_createSession` methods in each app.
|
|
179
|
+
* The event handler below is the common core — apps extend via `onEvent()`.
|
|
180
|
+
*
|
|
181
|
+
* @param sessionId - The UUID for this session (resolved by the app).
|
|
182
|
+
*/
|
|
183
|
+
async createManagedSession(sessionId) {
|
|
184
|
+
const config = await this.buildAdapterConfig(sessionId);
|
|
185
|
+
const processKey = this.buildProcessKey(sessionId);
|
|
186
|
+
await this.processManager.create(processKey, {
|
|
187
|
+
projectDir: config.projectDir,
|
|
188
|
+
harness: config.harness,
|
|
189
|
+
registry: config.registry,
|
|
190
|
+
providers: config.providers,
|
|
191
|
+
credentialEnv: config.credentialEnv,
|
|
192
|
+
model: config.model,
|
|
193
|
+
env: config.env,
|
|
194
|
+
systemPrompt: config.systemPrompt,
|
|
195
|
+
maxTurns: config.maxTurns,
|
|
196
|
+
agentDir: config.agentDir,
|
|
197
|
+
harnessSessionId: config.harnessSessionId,
|
|
198
|
+
host: config.host,
|
|
199
|
+
wsAuthToken: config.wsAuthToken
|
|
200
|
+
});
|
|
201
|
+
const messageStore = new JsonlMessageStore(this.getMessagesDir(sessionId));
|
|
202
|
+
const subscriberTransport = new EventEmitterSubscriberTransport();
|
|
203
|
+
const client = this.processManager.createAgentClient(processKey);
|
|
204
|
+
const session = {
|
|
205
|
+
sessionId,
|
|
206
|
+
dispatcher: new SessionDispatcher({
|
|
207
|
+
sessionId,
|
|
208
|
+
client,
|
|
209
|
+
store: messageStore,
|
|
210
|
+
transport: subscriberTransport
|
|
211
|
+
}),
|
|
212
|
+
subscriberTransport,
|
|
213
|
+
isStreaming: false,
|
|
214
|
+
error: null
|
|
215
|
+
};
|
|
216
|
+
await session.dispatcher.init();
|
|
217
|
+
const eventHandler = async (event) => {
|
|
218
|
+
if (event.type === "status") {
|
|
219
|
+
const phase = event.phase;
|
|
220
|
+
if (phase === "thinking" || phase === "working") {
|
|
221
|
+
session.isStreaming = true;
|
|
222
|
+
} else if (phase === "idle") {
|
|
223
|
+
session.isStreaming = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (event.type === "finished" || event.type === "turn_end" || event.type === "agent_end") {
|
|
227
|
+
session.isStreaming = false;
|
|
228
|
+
}
|
|
229
|
+
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
230
|
+
session.isStreaming = true;
|
|
231
|
+
}
|
|
232
|
+
if (event.type === "error") {
|
|
233
|
+
session.error = event.error || event.message || String(event);
|
|
234
|
+
session.isStreaming = false;
|
|
235
|
+
}
|
|
236
|
+
const suppress = await this.onEvent(event, session, processKey);
|
|
237
|
+
if (suppress) return;
|
|
238
|
+
this.emit("event", event);
|
|
239
|
+
};
|
|
240
|
+
this.processManager.on(`event:${processKey}`, eventHandler);
|
|
241
|
+
session.removeEventHandler = () => this.processManager.off(`event:${processKey}`, eventHandler);
|
|
242
|
+
this.processManager.on(`error:${processKey}`, (err) => {
|
|
243
|
+
session.error = err.error || String(err);
|
|
244
|
+
session.isStreaming = false;
|
|
245
|
+
this.emit("event", {
|
|
246
|
+
type: "error",
|
|
247
|
+
error: session.error
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
await this.onSessionCreated(session);
|
|
251
|
+
return session;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* App-specific event hook. Called for every agent event after common
|
|
255
|
+
* streaming/error tracking but before SSE fan-out.
|
|
256
|
+
*
|
|
257
|
+
* Return `true` to suppress the default `this.emit('event', event)` call.
|
|
258
|
+
* Default implementation: no-op, returns false.
|
|
259
|
+
*
|
|
260
|
+
* @example Override in forge-workspace to handle `capability_invoked`
|
|
261
|
+
* @example Override in forge-assistant to trigger consolidation on `finished`
|
|
262
|
+
*/
|
|
263
|
+
async onEvent(_event, _session, _processKey) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
// ── Session teardown helpers ──────────────────────────────────────────
|
|
267
|
+
/**
|
|
268
|
+
* Tear down a session: unregister event handler, disconnect dispatcher.
|
|
269
|
+
*/
|
|
270
|
+
teardownSession(session) {
|
|
271
|
+
session.removeEventHandler?.();
|
|
272
|
+
session.dispatcher.disconnect().catch(() => {
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// ── Observable state helpers ──────────────────────────────────────────
|
|
276
|
+
/**
|
|
277
|
+
* Whether the given session is currently streaming.
|
|
278
|
+
* Returns false if the session does not exist.
|
|
279
|
+
* Override in subclasses that cache their session.
|
|
280
|
+
*/
|
|
281
|
+
isSessionStreaming(_sessionId) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Last error for the given session, or null.
|
|
286
|
+
* Override in subclasses that cache their session.
|
|
287
|
+
*/
|
|
288
|
+
getSessionError(_sessionId) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
class FanoutSubscriberTransport {
|
|
294
|
+
emitter = new EventEmitter();
|
|
295
|
+
FANOUT_CHANNEL = "__fanout__";
|
|
296
|
+
constructor(maxListeners = 200) {
|
|
297
|
+
this.emitter.setMaxListeners(maxListeners);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Called by SessionDispatcher. Broadcasts to ALL subscribers (ignores subscriberId).
|
|
301
|
+
*/
|
|
302
|
+
send(_subscriberId, event) {
|
|
303
|
+
this.emitter.emit(this.FANOUT_CHANNEL, event);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Called by SessionDispatcher on disconnect. No-op for fanout — subscribers
|
|
307
|
+
* manage their own cleanup via the unsubscribe function returned by subscribe().
|
|
308
|
+
*/
|
|
309
|
+
remove(_subscriberId) {
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Internal: register a handler for all broadcasted events.
|
|
313
|
+
* Returns an unsubscribe function.
|
|
314
|
+
*/
|
|
315
|
+
_listen(handler) {
|
|
316
|
+
this.emitter.on(this.FANOUT_CHANNEL, handler);
|
|
317
|
+
return () => this.emitter.off(this.FANOUT_CHANNEL, handler);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Current count of listeners on the fanout channel.
|
|
321
|
+
*/
|
|
322
|
+
get listenerCount() {
|
|
323
|
+
return this.emitter.listenerCount(this.FANOUT_CHANNEL);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
class FanoutTransport {
|
|
327
|
+
/**
|
|
328
|
+
* The inner transport passed to SessionDispatcher.
|
|
329
|
+
* SessionDispatcher calls transport.send(subscriberId, event) for each
|
|
330
|
+
* registered subscriber. FanoutTransport overrides send() to broadcast
|
|
331
|
+
* to ALL connected subscribers, ignoring the subscriberId argument.
|
|
332
|
+
*/
|
|
333
|
+
transport;
|
|
334
|
+
constructor() {
|
|
335
|
+
this.transport = new FanoutSubscriberTransport();
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Register a new SSE connection.
|
|
339
|
+
*
|
|
340
|
+
* @param connectionId - Unique identifier for this browser connection (e.g. randomUUID()).
|
|
341
|
+
* Used only for diagnostics — the fanout ignores it for routing.
|
|
342
|
+
* @param handler - Called for each agent event broadcast to all subscribers.
|
|
343
|
+
* @returns Unsubscribe function — call when the SSE connection closes.
|
|
344
|
+
*/
|
|
345
|
+
subscribe(_connectionId, handler) {
|
|
346
|
+
return this.transport._listen(handler);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Returns the current count of active subscribers.
|
|
350
|
+
* Useful for diagnostics and health checks.
|
|
351
|
+
*/
|
|
352
|
+
get subscriberCount() {
|
|
353
|
+
return this.transport.listenerCount;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export { AgentProcessManager, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, listRegisteredHarnesses, registerHarness };
|