@skaile/forge-agent-runtime 1.0.0 → 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/dist/index.mjs CHANGED
@@ -1,541 +1,17 @@
1
- import { spawn } from 'node:child_process';
1
+ import { P as ProcessManagerAdapter, A as AgentProcessManager } from './shared/forge-agent-runtime.B1LZr0Sl.mjs';
2
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
3
  import { mkdir, appendFile, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
13
5
  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
- }
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';
539
15
 
540
16
  const registry = /* @__PURE__ */ new Map();
541
17
  registry.set("omp", () => new ProcessManagerAdapter());