@skaile/forge-agent-runtime 0.0.0-stage → 1.0.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 +7 -0
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/dist/index.d.mts +593 -0
- package/dist/index.d.ts +593 -0
- package/dist/index.mjs +881 -0
- package/package.json +51 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
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
|
+
import { mkdir, appendFile, readFile } from 'node:fs/promises';
|
|
13
|
+
import { SessionDispatcher } from '@skaile/workspaces/sdk/session';
|
|
14
|
+
|
|
15
|
+
const pc = createColors(true);
|
|
16
|
+
const consolaWithFancy = createConsola;
|
|
17
|
+
const log = consolaWithFancy({
|
|
18
|
+
fancy: false,
|
|
19
|
+
defaults: { tag: pc.bold(pc.cyan("agent-process")) }
|
|
20
|
+
});
|
|
21
|
+
const BASE_PORT = 9100;
|
|
22
|
+
let nextPort = BASE_PORT;
|
|
23
|
+
async function isPortFree(port) {
|
|
24
|
+
return new Promise((resolve2) => {
|
|
25
|
+
const server = net.createServer();
|
|
26
|
+
server.once("error", () => resolve2(false));
|
|
27
|
+
server.once("listening", () => {
|
|
28
|
+
server.close(() => resolve2(true));
|
|
29
|
+
});
|
|
30
|
+
server.listen(port);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function allocatePort() {
|
|
34
|
+
for (let i = 0; i < 100; i++) {
|
|
35
|
+
const port = nextPort++;
|
|
36
|
+
if (nextPort > BASE_PORT + 500) nextPort = BASE_PORT;
|
|
37
|
+
if (await isPortFree(port)) return port;
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Could not find a free port for skaile serve");
|
|
40
|
+
}
|
|
41
|
+
function dialHost(host) {
|
|
42
|
+
if (!host) return "127.0.0.1";
|
|
43
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
44
|
+
}
|
|
45
|
+
function resolveSkaileCmd() {
|
|
46
|
+
try {
|
|
47
|
+
return fileURLToPath(import.meta.resolve("@skaile/workspaces/cli"));
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const entryDir = fileURLToPath(new URL(".", import.meta.url));
|
|
52
|
+
const cliSubpath = join("node_modules", "@skaile", "workspaces", "dist", "cli", "index.js");
|
|
53
|
+
let dir = entryDir;
|
|
54
|
+
for (let i = 0; i < 8; i++) {
|
|
55
|
+
const candidate = join(dir, cliSubpath);
|
|
56
|
+
if (existsSync(candidate)) return candidate;
|
|
57
|
+
const parent = resolve(dir, "..");
|
|
58
|
+
if (parent === dir) break;
|
|
59
|
+
dir = parent;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
return "skaile";
|
|
64
|
+
}
|
|
65
|
+
function resolveBunCmd() {
|
|
66
|
+
const exec = basename(process.execPath).toLowerCase();
|
|
67
|
+
if (exec === "bun" || exec === "bun.exe") return process.execPath;
|
|
68
|
+
return "bun";
|
|
69
|
+
}
|
|
70
|
+
function resolveCliInterpreter(jsEntry) {
|
|
71
|
+
try {
|
|
72
|
+
const firstLine = readFileSync(jsEntry, "utf8").split("\n", 1)[0] ?? "";
|
|
73
|
+
if (/\bbun\b/.test(firstLine)) return resolveBunCmd();
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
return process.execPath;
|
|
77
|
+
}
|
|
78
|
+
class ProcessManagerClientTransport {
|
|
79
|
+
constructor(manager, id) {
|
|
80
|
+
this.manager = manager;
|
|
81
|
+
this.id = id;
|
|
82
|
+
}
|
|
83
|
+
get connected() {
|
|
84
|
+
return this.manager.isConnected(this.id);
|
|
85
|
+
}
|
|
86
|
+
async connect() {
|
|
87
|
+
}
|
|
88
|
+
async disconnect() {
|
|
89
|
+
await this.manager.shutdown(this.id);
|
|
90
|
+
}
|
|
91
|
+
send(command) {
|
|
92
|
+
this.manager.sendCommand(this.id, command);
|
|
93
|
+
}
|
|
94
|
+
onMessage(handler) {
|
|
95
|
+
this.manager.on(`event:${this.id}`, handler);
|
|
96
|
+
return () => this.manager.off(`event:${this.id}`, handler);
|
|
97
|
+
}
|
|
98
|
+
onDisconnect(_handler) {
|
|
99
|
+
return () => {
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
class AgentProcessManager extends EventEmitter {
|
|
104
|
+
processes = /* @__PURE__ */ new Map();
|
|
105
|
+
constructor() {
|
|
106
|
+
super();
|
|
107
|
+
this.setMaxListeners(100);
|
|
108
|
+
const cleanup = () => {
|
|
109
|
+
for (const [id] of this.processes) {
|
|
110
|
+
try {
|
|
111
|
+
this.shutdownSync(id);
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
process.on("exit", cleanup);
|
|
117
|
+
process.on("SIGTERM", cleanup);
|
|
118
|
+
process.on("SIGINT", cleanup);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Spawn a `skaile serve` subprocess and connect via WebSocket transport.
|
|
122
|
+
* Resolves when the subprocess is ready and transport is connected.
|
|
123
|
+
*/
|
|
124
|
+
async create(id, config) {
|
|
125
|
+
log.info(
|
|
126
|
+
`[${id}] Creating session \u2014 harness=${config.harness ?? "omp"} model=${config.model ?? "(registry default)"}`
|
|
127
|
+
);
|
|
128
|
+
log.debug(`[${id}] projectDir=${config.projectDir}`);
|
|
129
|
+
if (this.processes.has(id)) {
|
|
130
|
+
log.info(`[${id}] Shutting down existing process`);
|
|
131
|
+
await this.shutdown(id);
|
|
132
|
+
}
|
|
133
|
+
if (config.systemPrompt) {
|
|
134
|
+
const skaileDir = join(config.projectDir, ".skaile");
|
|
135
|
+
if (!existsSync(skaileDir)) mkdirSync(skaileDir, { recursive: true });
|
|
136
|
+
const systemPromptFile = join(skaileDir, "system-prompt.md");
|
|
137
|
+
writeFileSync(systemPromptFile, config.systemPrompt);
|
|
138
|
+
log.debug(`[${id}] Wrote system prompt (${config.systemPrompt.length} chars)`);
|
|
139
|
+
}
|
|
140
|
+
const credentials = config.registry ? registryCredentialEnv(config.registry, { env: config.credentialEnv }) : { env: {}, deliveries: [] };
|
|
141
|
+
for (const delivery of credentials.deliveries) {
|
|
142
|
+
if (delivery.names.length > 0) {
|
|
143
|
+
log.debug(`[${id}] ${delivery.providerId} credential from ${delivery.source}`);
|
|
144
|
+
} else if (delivery.error) {
|
|
145
|
+
log.warn(`[${id}] ${delivery.providerId} credential not delivered: ${delivery.error}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const env = {
|
|
149
|
+
...config.credentialEnv ?? process.env,
|
|
150
|
+
...credentials.env,
|
|
151
|
+
...config.env ?? {}
|
|
152
|
+
};
|
|
153
|
+
if (config.sshKeyPath) {
|
|
154
|
+
env.GIT_SSH_COMMAND = `ssh -i ${config.sshKeyPath} -o StrictHostKeyChecking=no`;
|
|
155
|
+
}
|
|
156
|
+
if (config.harnessSessionId) {
|
|
157
|
+
env.SKAILE_RESUME_SESSION_ID = config.harnessSessionId;
|
|
158
|
+
log.debug(`[${id}] SKAILE_RESUME_SESSION_ID=${config.harnessSessionId}`);
|
|
159
|
+
}
|
|
160
|
+
if (config.wsAuthToken) env.SKAILE_WS_AUTH_TOKEN = config.wsAuthToken;
|
|
161
|
+
const skaileCmd = resolveSkaileCmd();
|
|
162
|
+
const interpreter = skaileCmd.endsWith(".js") ? resolveCliInterpreter(skaileCmd) : null;
|
|
163
|
+
const MAX_PORT_ATTEMPTS = 3;
|
|
164
|
+
let managed;
|
|
165
|
+
let transport;
|
|
166
|
+
let port = 0;
|
|
167
|
+
for (let attempt = 1; attempt <= MAX_PORT_ATTEMPTS; attempt++) {
|
|
168
|
+
port = await allocatePort();
|
|
169
|
+
log.debug(`[${id}] Allocated port ${port} (attempt ${attempt}/${MAX_PORT_ATTEMPTS})`);
|
|
170
|
+
const args = ["serve", "--project-dir", config.projectDir, "--port", String(port)];
|
|
171
|
+
if (config.host) args.push("--host", config.host);
|
|
172
|
+
if (config.harness) args.push("--harness", config.harness);
|
|
173
|
+
if (config.model) args.push("--model", config.model);
|
|
174
|
+
if (config.providers && config.providers.length > 0)
|
|
175
|
+
args.push("--providers", config.providers.join(","));
|
|
176
|
+
log.debug(
|
|
177
|
+
`[${id}] Spawning: ${interpreter ? `${interpreter} ${skaileCmd}` : skaileCmd} ${args.join(" ")}`
|
|
178
|
+
);
|
|
179
|
+
const child = interpreter ? spawn(interpreter, [skaileCmd, ...args], { env, stdio: ["pipe", "pipe", "pipe"] }) : spawn(skaileCmd, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
180
|
+
transport = new WebSocketClientTransport({
|
|
181
|
+
url: `ws://${dialHost(config.host)}:${port}`,
|
|
182
|
+
...config.wsAuthToken ? { auth: config.wsAuthToken } : {},
|
|
183
|
+
reconnect: { maxAttempts: 3, baseMs: 1e3, maxMs: 1e4 },
|
|
184
|
+
onLog: (line) => log.debug(`[${id}] ${line}`)
|
|
185
|
+
});
|
|
186
|
+
managed = {
|
|
187
|
+
child,
|
|
188
|
+
port,
|
|
189
|
+
transport,
|
|
190
|
+
config,
|
|
191
|
+
status: "starting",
|
|
192
|
+
stderrBuffer: []
|
|
193
|
+
};
|
|
194
|
+
this.processes.set(id, managed);
|
|
195
|
+
child.stdout?.on("data", (chunk) => {
|
|
196
|
+
for (const line of chunk.toString().split("\n")) {
|
|
197
|
+
const trimmed = line.trim();
|
|
198
|
+
if (trimmed && !trimmed.includes("listening on port")) {
|
|
199
|
+
log.info(`[${id}] [stdout] ${trimmed}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
child.stderr?.on("data", (chunk) => {
|
|
204
|
+
const line = chunk.toString().trim();
|
|
205
|
+
if (line) {
|
|
206
|
+
managed.stderrBuffer.push(line);
|
|
207
|
+
if (managed.stderrBuffer.length > 30) managed.stderrBuffer.shift();
|
|
208
|
+
log.warn(`[${id}] [stderr] ${line}`);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
log.debug(`[${id}] Waiting for subprocess ready signal...`);
|
|
212
|
+
try {
|
|
213
|
+
await this.waitForReady(id, managed, 2e4);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const portConflict = managed.stderrBuffer.some((l) => l.includes("EADDRINUSE"));
|
|
216
|
+
try {
|
|
217
|
+
if (managed.child.exitCode === null) managed.child.kill("SIGKILL");
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
this.processes.delete(id);
|
|
221
|
+
if (portConflict && attempt < MAX_PORT_ATTEMPTS) {
|
|
222
|
+
log.warn(`[${id}] Port ${port} already in use \u2014 retrying on a fresh port`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
log.info(`[${id}] Subprocess ready on port ${port}`);
|
|
228
|
+
managed.child.on("exit", (code, signal) => {
|
|
229
|
+
const prev = managed.status;
|
|
230
|
+
managed.status = "stopped";
|
|
231
|
+
if (prev !== "stopped") {
|
|
232
|
+
log.warn(`[${id}] Process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
233
|
+
const stderr = managed.stderrBuffer.slice(-5).join("\n");
|
|
234
|
+
if (stderr) log.debug(`[${id}] Last stderr: ${stderr}`);
|
|
235
|
+
this.emit(`error:${id}`, {
|
|
236
|
+
type: "error",
|
|
237
|
+
error: `Agent process exited (code=${code}, signal=${signal})${stderr ? `: ${stderr}` : ""}`
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
log.debug(`[${id}] Process exited cleanly (code=${code})`);
|
|
241
|
+
}
|
|
242
|
+
this.processes.delete(id);
|
|
243
|
+
});
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
await transport.connect();
|
|
247
|
+
managed.status = "connected";
|
|
248
|
+
log.info(`[${id}] WebSocket connected`);
|
|
249
|
+
managed.unsubMessage = transport.onMessage((event) => {
|
|
250
|
+
if (event.type === "error") {
|
|
251
|
+
log.warn(`[${id}] \u2190 error: ${event.message}`);
|
|
252
|
+
} else {
|
|
253
|
+
log.info(`[${id}] \u2190 ${event.type}`);
|
|
254
|
+
}
|
|
255
|
+
this.emit(`event:${id}`, event);
|
|
256
|
+
});
|
|
257
|
+
managed.unsubDisconnect = transport.onDisconnect(() => {
|
|
258
|
+
if (managed.status === "connected") {
|
|
259
|
+
log.warn(`[${id}] WebSocket disconnected unexpectedly`);
|
|
260
|
+
managed.status = "disconnected";
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Send a prompt to the agent subprocess.
|
|
266
|
+
*/
|
|
267
|
+
async prompt(id, message) {
|
|
268
|
+
log.info(`[${id}] Sending prompt (${message.length} chars)`);
|
|
269
|
+
const managed = this.processes.get(id);
|
|
270
|
+
if (!managed?.transport.connected) {
|
|
271
|
+
throw new Error(`No active connection for session ${id}`);
|
|
272
|
+
}
|
|
273
|
+
managed.transport.send({ type: "prompt", prompt: message });
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Cancel the current agent operation.
|
|
277
|
+
*/
|
|
278
|
+
async cancel(id) {
|
|
279
|
+
log.info(`[${id}] Cancelling`);
|
|
280
|
+
const managed = this.processes.get(id);
|
|
281
|
+
if (managed?.transport.connected) {
|
|
282
|
+
managed.transport.send({ type: "cancel" });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Gracefully shut down a subprocess.
|
|
287
|
+
*/
|
|
288
|
+
async shutdown(id) {
|
|
289
|
+
const managed = this.processes.get(id);
|
|
290
|
+
if (!managed) return;
|
|
291
|
+
log.info(`[${id}] Shutting down (port ${managed.port})`);
|
|
292
|
+
managed.status = "stopped";
|
|
293
|
+
managed.unsubMessage?.();
|
|
294
|
+
managed.unsubDisconnect?.();
|
|
295
|
+
await managed.transport.disconnect();
|
|
296
|
+
if (managed.child.exitCode === null) {
|
|
297
|
+
managed.child.kill("SIGTERM");
|
|
298
|
+
await new Promise((resolve2) => {
|
|
299
|
+
const timeout = setTimeout(() => {
|
|
300
|
+
try {
|
|
301
|
+
managed.child.kill("SIGKILL");
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
resolve2();
|
|
305
|
+
}, 5e3);
|
|
306
|
+
managed.child.on("exit", () => {
|
|
307
|
+
clearTimeout(timeout);
|
|
308
|
+
resolve2();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
this.processes.delete(id);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Check if a subprocess is connected and ready.
|
|
316
|
+
*/
|
|
317
|
+
isConnected(id) {
|
|
318
|
+
const managed = this.processes.get(id);
|
|
319
|
+
return managed?.status === "connected" && managed.transport.connected;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Check if a session exists (regardless of connection state).
|
|
323
|
+
*/
|
|
324
|
+
has(id) {
|
|
325
|
+
return this.processes.has(id);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Send any AgentCommand to the managed process.
|
|
329
|
+
* Used internally by ProcessManagerClientTransport.
|
|
330
|
+
*/
|
|
331
|
+
sendCommand(id, command) {
|
|
332
|
+
const managed = this.processes.get(id);
|
|
333
|
+
if (!managed?.transport.connected) {
|
|
334
|
+
throw new Error(`No active connection for session ${id}`);
|
|
335
|
+
}
|
|
336
|
+
managed.transport.send(command);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Create an AgentClient for a managed session.
|
|
340
|
+
* Uses ProcessManagerClientTransport so there is no second subscription
|
|
341
|
+
* on the underlying WebSocket transport. Disposing the client will
|
|
342
|
+
* gracefully shut down the subprocess via manager.shutdown().
|
|
343
|
+
*
|
|
344
|
+
* Throws if the session does not exist. The session must be connected
|
|
345
|
+
* (isConnected() === true) before any commands can be sent.
|
|
346
|
+
*/
|
|
347
|
+
createAgentClient(id) {
|
|
348
|
+
if (!this.processes.has(id)) {
|
|
349
|
+
throw new Error(`No managed session '${id}'`);
|
|
350
|
+
}
|
|
351
|
+
return new AgentClient(new ProcessManagerClientTransport(this, id));
|
|
352
|
+
}
|
|
353
|
+
// ── Internal ────────────────────────────────────────────────────────
|
|
354
|
+
shutdownSync(id) {
|
|
355
|
+
const managed = this.processes.get(id);
|
|
356
|
+
if (!managed) return;
|
|
357
|
+
managed.status = "stopped";
|
|
358
|
+
managed.unsubMessage?.();
|
|
359
|
+
managed.unsubDisconnect?.();
|
|
360
|
+
try {
|
|
361
|
+
managed.transport.disconnect();
|
|
362
|
+
} catch {
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
managed.child.kill("SIGTERM");
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
this.processes.delete(id);
|
|
369
|
+
}
|
|
370
|
+
waitForReady(id, managed, timeoutMs) {
|
|
371
|
+
return new Promise((resolve2, reject) => {
|
|
372
|
+
const timer = setTimeout(() => {
|
|
373
|
+
reject(
|
|
374
|
+
new Error(
|
|
375
|
+
`skaile serve did not become ready within ${timeoutMs}ms. stderr: ${managed.stderrBuffer.slice(-3).join(" | ")}`
|
|
376
|
+
)
|
|
377
|
+
);
|
|
378
|
+
}, timeoutMs);
|
|
379
|
+
const onData = (chunk) => {
|
|
380
|
+
const text = chunk.toString();
|
|
381
|
+
if (text.includes("listening on port")) {
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
managed.child.stdout?.off("data", onData);
|
|
384
|
+
resolve2();
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
managed.child.stdout?.on("data", onData);
|
|
388
|
+
managed.child.on("exit", (code) => {
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
managed.child.stdout?.off("data", onData);
|
|
391
|
+
reject(
|
|
392
|
+
new Error(
|
|
393
|
+
`skaile serve exited during startup (code=${code}). stderr: ${managed.stderrBuffer.join(" | ")}`
|
|
394
|
+
)
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
class ProcessManagerAdapter {
|
|
402
|
+
processManager = new AgentProcessManager();
|
|
403
|
+
emitter = new EventEmitter();
|
|
404
|
+
processKey = null;
|
|
405
|
+
status = "disconnected";
|
|
406
|
+
constructor() {
|
|
407
|
+
this.emitter.setMaxListeners(20);
|
|
408
|
+
}
|
|
409
|
+
async connect(config) {
|
|
410
|
+
this.processKey = config.projectDir;
|
|
411
|
+
await this.processManager.create(this.processKey, {
|
|
412
|
+
projectDir: config.projectDir,
|
|
413
|
+
harness: config.harness,
|
|
414
|
+
registry: config.registry,
|
|
415
|
+
providers: config.providers,
|
|
416
|
+
credentialEnv: config.credentialEnv,
|
|
417
|
+
model: config.model,
|
|
418
|
+
env: config.env,
|
|
419
|
+
systemPrompt: config.systemPrompt,
|
|
420
|
+
maxTurns: config.maxTurns,
|
|
421
|
+
agentDir: config.agentDir,
|
|
422
|
+
harnessSessionId: config.harnessSessionId,
|
|
423
|
+
host: config.host,
|
|
424
|
+
wsAuthToken: config.wsAuthToken
|
|
425
|
+
});
|
|
426
|
+
this.status = "idle";
|
|
427
|
+
this.processManager.on(`event:${this.processKey}`, (event) => {
|
|
428
|
+
this._pushEvent(event);
|
|
429
|
+
if (event.type === "status") {
|
|
430
|
+
const phase = event.phase;
|
|
431
|
+
if (phase === "thinking" || phase === "working") {
|
|
432
|
+
this.status = "running";
|
|
433
|
+
} else if (phase === "idle") {
|
|
434
|
+
this.status = "idle";
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (event.type === "finished" || event.type === "turn_end" || event.type === "agent_end") {
|
|
438
|
+
this.status = "idle";
|
|
439
|
+
}
|
|
440
|
+
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
441
|
+
this.status = "running";
|
|
442
|
+
}
|
|
443
|
+
if (event.type === "error") {
|
|
444
|
+
this.status = "error";
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
this.processManager.on(`error:${this.processKey}`, (err) => {
|
|
448
|
+
this.status = "error";
|
|
449
|
+
this._pushEvent({
|
|
450
|
+
type: "error",
|
|
451
|
+
error: err.error || String(err)
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
async sendCommand(command) {
|
|
456
|
+
if (!this.processKey || !this.processManager.isConnected(this.processKey)) {
|
|
457
|
+
throw new Error("ProcessManagerAdapter: not connected \u2014 call connect() first");
|
|
458
|
+
}
|
|
459
|
+
this.processManager.sendCommand(this.processKey, command);
|
|
460
|
+
}
|
|
461
|
+
async *listen() {
|
|
462
|
+
const queue = [];
|
|
463
|
+
let resolve = null;
|
|
464
|
+
let done = false;
|
|
465
|
+
const onEvent = (event) => {
|
|
466
|
+
queue.push(event);
|
|
467
|
+
resolve?.();
|
|
468
|
+
resolve = null;
|
|
469
|
+
};
|
|
470
|
+
const onDone = () => {
|
|
471
|
+
done = true;
|
|
472
|
+
resolve?.();
|
|
473
|
+
resolve = null;
|
|
474
|
+
};
|
|
475
|
+
this.emitter.on("_event", onEvent);
|
|
476
|
+
this.emitter.on("_done", onDone);
|
|
477
|
+
try {
|
|
478
|
+
while (true) {
|
|
479
|
+
while (queue.length > 0) {
|
|
480
|
+
yield queue.shift();
|
|
481
|
+
}
|
|
482
|
+
if (done) break;
|
|
483
|
+
await new Promise((res) => {
|
|
484
|
+
resolve = res;
|
|
485
|
+
if (queue.length > 0 || done) {
|
|
486
|
+
res();
|
|
487
|
+
resolve = null;
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
} finally {
|
|
492
|
+
this.emitter.off("_event", onEvent);
|
|
493
|
+
this.emitter.off("_done", onDone);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async abort() {
|
|
497
|
+
if (this.processKey && this.processManager.isConnected(this.processKey)) {
|
|
498
|
+
this.processManager.sendCommand(this.processKey, { type: "cancel" });
|
|
499
|
+
this.status = "idle";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
isConnected() {
|
|
503
|
+
return !!this.processKey && this.processManager.isConnected(this.processKey);
|
|
504
|
+
}
|
|
505
|
+
getStatus() {
|
|
506
|
+
return this.status;
|
|
507
|
+
}
|
|
508
|
+
async disconnect() {
|
|
509
|
+
this.emitter.emit("_done");
|
|
510
|
+
if (this.processKey) {
|
|
511
|
+
await this.processManager.shutdown(this.processKey).catch(() => {
|
|
512
|
+
});
|
|
513
|
+
this.processKey = null;
|
|
514
|
+
}
|
|
515
|
+
this.status = "disconnected";
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Internal: push an event into the async iterable queue.
|
|
519
|
+
* Exposed with underscore prefix for test injection.
|
|
520
|
+
*/
|
|
521
|
+
_pushEvent(event) {
|
|
522
|
+
this.emitter.emit("_event", event);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Set a custom process key (used by apps that namespace keys as 'userId:slug:mode').
|
|
526
|
+
* Must be called before `connect()`.
|
|
527
|
+
*/
|
|
528
|
+
setProcessKey(key) {
|
|
529
|
+
this.processKey = key;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Access the underlying AgentProcessManager for advanced use cases
|
|
533
|
+
* (e.g. createAgentClient() needed by SessionDispatcher construction).
|
|
534
|
+
*/
|
|
535
|
+
getProcessManager() {
|
|
536
|
+
return this.processManager;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const registry = /* @__PURE__ */ new Map();
|
|
541
|
+
registry.set("omp", () => new ProcessManagerAdapter());
|
|
542
|
+
registry.set("claude-sdk", () => new ProcessManagerAdapter());
|
|
543
|
+
function createAdapterForHarness(harness) {
|
|
544
|
+
const factory = registry.get(harness) ?? registry.get("omp");
|
|
545
|
+
return factory();
|
|
546
|
+
}
|
|
547
|
+
function registerHarness(name, factory) {
|
|
548
|
+
registry.set(name, factory);
|
|
549
|
+
}
|
|
550
|
+
function listRegisteredHarnesses() {
|
|
551
|
+
return Array.from(registry.keys());
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
class EventEmitterSubscriberTransport {
|
|
555
|
+
emitter = new EventEmitter();
|
|
556
|
+
/**
|
|
557
|
+
* Push an event to all listeners registered for this subscriber ID.
|
|
558
|
+
*
|
|
559
|
+
* Part of the `SubscriberTransport` interface — called by `SessionDispatcher`
|
|
560
|
+
* when the agent emits an event.
|
|
561
|
+
*
|
|
562
|
+
* @param subscriberId - ID of the subscriber connection to notify.
|
|
563
|
+
* @param event - Agent event to broadcast.
|
|
564
|
+
*/
|
|
565
|
+
send(subscriberId, event) {
|
|
566
|
+
this.emitter.emit(subscriberId, event);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Remove all listeners for a subscriber ID.
|
|
570
|
+
*
|
|
571
|
+
* Part of the `SubscriberTransport` interface — called by `SessionDispatcher`
|
|
572
|
+
* when a subscriber disconnects or the session ends.
|
|
573
|
+
*
|
|
574
|
+
* @param subscriberId - ID of the subscriber whose listeners should be cleared.
|
|
575
|
+
*/
|
|
576
|
+
remove(subscriberId) {
|
|
577
|
+
this.emitter.removeAllListeners(subscriberId);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Register a per-connection event handler and return an unsubscribe function.
|
|
581
|
+
*
|
|
582
|
+
* Not part of the `SubscriberTransport` interface — used directly by SSE route handlers.
|
|
583
|
+
*
|
|
584
|
+
* @param subscriberId - Unique identifier for this SSE connection.
|
|
585
|
+
* @param handler - Called for each agent event broadcast to this subscriber.
|
|
586
|
+
* @returns Cleanup function — call when the SSE connection closes.
|
|
587
|
+
*/
|
|
588
|
+
listen(subscriberId, handler) {
|
|
589
|
+
this.emitter.on(subscriberId, handler);
|
|
590
|
+
return () => this.emitter.off(subscriberId, handler);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
class JsonlMessageStore {
|
|
595
|
+
/**
|
|
596
|
+
* @param dir - Directory where per-session `.jsonl` files are stored.
|
|
597
|
+
* The directory is created on first write if it does not exist.
|
|
598
|
+
*/
|
|
599
|
+
constructor(dir) {
|
|
600
|
+
this.dir = dir;
|
|
601
|
+
}
|
|
602
|
+
/** Tail of this instance's serialised append chain; never rejects. */
|
|
603
|
+
tail = Promise.resolve();
|
|
604
|
+
filePath(sessionId) {
|
|
605
|
+
return join(this.dir, `${sessionId}.jsonl`);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Append a message to the session's JSONL file.
|
|
609
|
+
*
|
|
610
|
+
* Creates the file (and parent directory) if absent.
|
|
611
|
+
*
|
|
612
|
+
* Appends are chained on a per-instance tail promise, so overlapping calls are
|
|
613
|
+
* written in the order they were issued rather than racing each other's
|
|
614
|
+
* open/write/close. A failed write is neutralised for the chain but still
|
|
615
|
+
* rejected to its own caller, so one bad append does not poison the ones after it.
|
|
616
|
+
*
|
|
617
|
+
* @param message - Message to persist. `message.sessionId` determines the target file.
|
|
618
|
+
*/
|
|
619
|
+
async append(message) {
|
|
620
|
+
const mine = this.tail.then(async () => {
|
|
621
|
+
await mkdir(this.dir, { recursive: true });
|
|
622
|
+
await appendFile(this.filePath(message.sessionId), `${JSON.stringify(message)}
|
|
623
|
+
`);
|
|
624
|
+
});
|
|
625
|
+
this.tail = mine.catch(() => {
|
|
626
|
+
});
|
|
627
|
+
return mine;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Read messages for a session with optional pagination.
|
|
631
|
+
*
|
|
632
|
+
* Reads the full file on every call — acceptable for forge's short-lived sessions.
|
|
633
|
+
*
|
|
634
|
+
* @param sessionId - Session whose messages to retrieve.
|
|
635
|
+
* @param opts.before - Return only messages with `seq` less than this value.
|
|
636
|
+
* @param opts.limit - Return at most this many messages (tail semantics — most recent).
|
|
637
|
+
* @returns Ordered array of messages (oldest first within the result window).
|
|
638
|
+
*/
|
|
639
|
+
async getMessages(sessionId, opts) {
|
|
640
|
+
let content;
|
|
641
|
+
try {
|
|
642
|
+
content = await readFile(this.filePath(sessionId), "utf-8");
|
|
643
|
+
} catch {
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
const parsed = [];
|
|
647
|
+
for (const line of content.split("\n")) {
|
|
648
|
+
if (!line.trim()) continue;
|
|
649
|
+
try {
|
|
650
|
+
parsed.push(JSON.parse(line));
|
|
651
|
+
} catch {
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
let messages = parsed;
|
|
655
|
+
if (opts?.before !== void 0) {
|
|
656
|
+
messages = messages.filter((m) => m.seq < opts.before);
|
|
657
|
+
}
|
|
658
|
+
if (opts?.limit !== void 0) {
|
|
659
|
+
messages = messages.slice(-opts.limit);
|
|
660
|
+
}
|
|
661
|
+
return messages;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Return the sequence number of the most recent message in the session.
|
|
665
|
+
*
|
|
666
|
+
* Reads the whole JSONL file and takes its last non-empty line — see the
|
|
667
|
+
* class note on why re-reading is acceptable here.
|
|
668
|
+
*
|
|
669
|
+
* @param sessionId - Session to inspect.
|
|
670
|
+
* @returns `seq` of the last appended message, or `0` if the session has no
|
|
671
|
+
* messages (or its last line is unreadable).
|
|
672
|
+
*/
|
|
673
|
+
async getLatestSeq(sessionId) {
|
|
674
|
+
let content;
|
|
675
|
+
try {
|
|
676
|
+
content = await readFile(this.filePath(sessionId), "utf-8");
|
|
677
|
+
} catch {
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
681
|
+
if (lines.length === 0) return 0;
|
|
682
|
+
const lastLine = lines[lines.length - 1];
|
|
683
|
+
try {
|
|
684
|
+
const last = JSON.parse(lastLine ?? "");
|
|
685
|
+
return last.seq ?? 0;
|
|
686
|
+
} catch {
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
class BaseAgentManager extends EventEmitter {
|
|
693
|
+
processManager = new AgentProcessManager();
|
|
694
|
+
constructor() {
|
|
695
|
+
super();
|
|
696
|
+
this.setMaxListeners(50);
|
|
697
|
+
}
|
|
698
|
+
// ── Core session creation ─────────────────────────────────────────────
|
|
699
|
+
/**
|
|
700
|
+
* Create a new ManagedSession: spawn subprocess, wire dispatcher, register event handler.
|
|
701
|
+
*
|
|
702
|
+
* This is the canonical "extract" from the `_createSession` methods in each app.
|
|
703
|
+
* The event handler below is the common core — apps extend via `onEvent()`.
|
|
704
|
+
*
|
|
705
|
+
* @param sessionId - The UUID for this session (resolved by the app).
|
|
706
|
+
*/
|
|
707
|
+
async createManagedSession(sessionId) {
|
|
708
|
+
const config = await this.buildAdapterConfig(sessionId);
|
|
709
|
+
const processKey = this.buildProcessKey(sessionId);
|
|
710
|
+
await this.processManager.create(processKey, {
|
|
711
|
+
projectDir: config.projectDir,
|
|
712
|
+
harness: config.harness,
|
|
713
|
+
registry: config.registry,
|
|
714
|
+
providers: config.providers,
|
|
715
|
+
credentialEnv: config.credentialEnv,
|
|
716
|
+
model: config.model,
|
|
717
|
+
env: config.env,
|
|
718
|
+
systemPrompt: config.systemPrompt,
|
|
719
|
+
maxTurns: config.maxTurns,
|
|
720
|
+
agentDir: config.agentDir,
|
|
721
|
+
harnessSessionId: config.harnessSessionId,
|
|
722
|
+
host: config.host,
|
|
723
|
+
wsAuthToken: config.wsAuthToken
|
|
724
|
+
});
|
|
725
|
+
const messageStore = new JsonlMessageStore(this.getMessagesDir(sessionId));
|
|
726
|
+
const subscriberTransport = new EventEmitterSubscriberTransport();
|
|
727
|
+
const client = this.processManager.createAgentClient(processKey);
|
|
728
|
+
const session = {
|
|
729
|
+
sessionId,
|
|
730
|
+
dispatcher: new SessionDispatcher({
|
|
731
|
+
sessionId,
|
|
732
|
+
client,
|
|
733
|
+
store: messageStore,
|
|
734
|
+
transport: subscriberTransport
|
|
735
|
+
}),
|
|
736
|
+
subscriberTransport,
|
|
737
|
+
isStreaming: false,
|
|
738
|
+
error: null
|
|
739
|
+
};
|
|
740
|
+
await session.dispatcher.init();
|
|
741
|
+
const eventHandler = async (event) => {
|
|
742
|
+
if (event.type === "status") {
|
|
743
|
+
const phase = event.phase;
|
|
744
|
+
if (phase === "thinking" || phase === "working") {
|
|
745
|
+
session.isStreaming = true;
|
|
746
|
+
} else if (phase === "idle") {
|
|
747
|
+
session.isStreaming = false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (event.type === "finished" || event.type === "turn_end" || event.type === "agent_end") {
|
|
751
|
+
session.isStreaming = false;
|
|
752
|
+
}
|
|
753
|
+
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
754
|
+
session.isStreaming = true;
|
|
755
|
+
}
|
|
756
|
+
if (event.type === "error") {
|
|
757
|
+
session.error = event.error || event.message || String(event);
|
|
758
|
+
session.isStreaming = false;
|
|
759
|
+
}
|
|
760
|
+
const suppress = await this.onEvent(event, session, processKey);
|
|
761
|
+
if (suppress) return;
|
|
762
|
+
this.emit("event", event);
|
|
763
|
+
};
|
|
764
|
+
this.processManager.on(`event:${processKey}`, eventHandler);
|
|
765
|
+
session.removeEventHandler = () => this.processManager.off(`event:${processKey}`, eventHandler);
|
|
766
|
+
this.processManager.on(`error:${processKey}`, (err) => {
|
|
767
|
+
session.error = err.error || String(err);
|
|
768
|
+
session.isStreaming = false;
|
|
769
|
+
this.emit("event", {
|
|
770
|
+
type: "error",
|
|
771
|
+
error: session.error
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
await this.onSessionCreated(session);
|
|
775
|
+
return session;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* App-specific event hook. Called for every agent event after common
|
|
779
|
+
* streaming/error tracking but before SSE fan-out.
|
|
780
|
+
*
|
|
781
|
+
* Return `true` to suppress the default `this.emit('event', event)` call.
|
|
782
|
+
* Default implementation: no-op, returns false.
|
|
783
|
+
*
|
|
784
|
+
* @example Override in forge-workspace to handle `capability_invoked`
|
|
785
|
+
* @example Override in forge-assistant to trigger consolidation on `finished`
|
|
786
|
+
*/
|
|
787
|
+
async onEvent(_event, _session, _processKey) {
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
// ── Session teardown helpers ──────────────────────────────────────────
|
|
791
|
+
/**
|
|
792
|
+
* Tear down a session: unregister event handler, disconnect dispatcher.
|
|
793
|
+
*/
|
|
794
|
+
teardownSession(session) {
|
|
795
|
+
session.removeEventHandler?.();
|
|
796
|
+
session.dispatcher.disconnect().catch(() => {
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
// ── Observable state helpers ──────────────────────────────────────────
|
|
800
|
+
/**
|
|
801
|
+
* Whether the given session is currently streaming.
|
|
802
|
+
* Returns false if the session does not exist.
|
|
803
|
+
* Override in subclasses that cache their session.
|
|
804
|
+
*/
|
|
805
|
+
isSessionStreaming(_sessionId) {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Last error for the given session, or null.
|
|
810
|
+
* Override in subclasses that cache their session.
|
|
811
|
+
*/
|
|
812
|
+
getSessionError(_sessionId) {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
class FanoutSubscriberTransport {
|
|
818
|
+
emitter = new EventEmitter();
|
|
819
|
+
FANOUT_CHANNEL = "__fanout__";
|
|
820
|
+
constructor(maxListeners = 200) {
|
|
821
|
+
this.emitter.setMaxListeners(maxListeners);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Called by SessionDispatcher. Broadcasts to ALL subscribers (ignores subscriberId).
|
|
825
|
+
*/
|
|
826
|
+
send(_subscriberId, event) {
|
|
827
|
+
this.emitter.emit(this.FANOUT_CHANNEL, event);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Called by SessionDispatcher on disconnect. No-op for fanout — subscribers
|
|
831
|
+
* manage their own cleanup via the unsubscribe function returned by subscribe().
|
|
832
|
+
*/
|
|
833
|
+
remove(_subscriberId) {
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Internal: register a handler for all broadcasted events.
|
|
837
|
+
* Returns an unsubscribe function.
|
|
838
|
+
*/
|
|
839
|
+
_listen(handler) {
|
|
840
|
+
this.emitter.on(this.FANOUT_CHANNEL, handler);
|
|
841
|
+
return () => this.emitter.off(this.FANOUT_CHANNEL, handler);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Current count of listeners on the fanout channel.
|
|
845
|
+
*/
|
|
846
|
+
get listenerCount() {
|
|
847
|
+
return this.emitter.listenerCount(this.FANOUT_CHANNEL);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
class FanoutTransport {
|
|
851
|
+
/**
|
|
852
|
+
* The inner transport passed to SessionDispatcher.
|
|
853
|
+
* SessionDispatcher calls transport.send(subscriberId, event) for each
|
|
854
|
+
* registered subscriber. FanoutTransport overrides send() to broadcast
|
|
855
|
+
* to ALL connected subscribers, ignoring the subscriberId argument.
|
|
856
|
+
*/
|
|
857
|
+
transport;
|
|
858
|
+
constructor() {
|
|
859
|
+
this.transport = new FanoutSubscriberTransport();
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Register a new SSE connection.
|
|
863
|
+
*
|
|
864
|
+
* @param connectionId - Unique identifier for this browser connection (e.g. randomUUID()).
|
|
865
|
+
* Used only for diagnostics — the fanout ignores it for routing.
|
|
866
|
+
* @param handler - Called for each agent event broadcast to all subscribers.
|
|
867
|
+
* @returns Unsubscribe function — call when the SSE connection closes.
|
|
868
|
+
*/
|
|
869
|
+
subscribe(_connectionId, handler) {
|
|
870
|
+
return this.transport._listen(handler);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Returns the current count of active subscribers.
|
|
874
|
+
* Useful for diagnostics and health checks.
|
|
875
|
+
*/
|
|
876
|
+
get subscriberCount() {
|
|
877
|
+
return this.transport.listenerCount;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export { AgentProcessManager, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, listRegisteredHarnesses, registerHarness };
|