@getforgeai/cli 0.1.0-beta.1 → 0.1.0-beta.3

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.
Files changed (39) hide show
  1. package/dist/cli.js +10 -1
  2. package/dist/commands/auth-setup-agent.d.ts +15 -0
  3. package/dist/commands/auth-setup-agent.js +98 -0
  4. package/dist/commands/config-set.js +14 -3
  5. package/dist/commands/connect.js +23 -7
  6. package/dist/config/config-manager.d.ts +2 -0
  7. package/dist/lib/connection-manager.d.ts +11 -0
  8. package/dist/lib/connection-manager.js +48 -25
  9. package/dist/lib/daemon-entry.js +23 -6
  10. package/dist/mcp/mcp-probe.d.ts +20 -0
  11. package/dist/mcp/mcp-probe.js +24 -4
  12. package/dist/orchestrator/agent-profiles.d.ts +65 -0
  13. package/dist/orchestrator/agent-profiles.js +236 -0
  14. package/dist/orchestrator/agent-spawner.d.ts +2 -0
  15. package/dist/orchestrator/agent-spawner.js +25 -14
  16. package/dist/orchestrator/billing-detect.d.ts +20 -0
  17. package/dist/orchestrator/billing-detect.js +120 -0
  18. package/dist/orchestrator/codex-jsonl-extractor.d.ts +52 -0
  19. package/dist/orchestrator/codex-jsonl-extractor.js +210 -0
  20. package/dist/orchestrator/connectors/connector.d.ts +2 -0
  21. package/dist/orchestrator/connectors/docker-connector.js +192 -175
  22. package/dist/orchestrator/opencode-jsonl-extractor.d.ts +47 -0
  23. package/dist/orchestrator/opencode-jsonl-extractor.js +228 -0
  24. package/dist/orchestrator/resume-handler.js +2 -0
  25. package/dist/orchestrator/stream-json-extractor.d.ts +38 -10
  26. package/dist/orchestrator/stream-json-extractor.js +10 -105
  27. package/dist/vm/agent-auth-manager.d.ts +32 -0
  28. package/dist/vm/agent-auth-manager.js +68 -0
  29. package/dist/vm/docker-process-proxy.d.ts +2 -1
  30. package/dist/vm/docker-process-proxy.js +17 -10
  31. package/dist/vm/image-manager.js +1 -1
  32. package/dist/vm/secure-store.d.ts +24 -0
  33. package/dist/vm/secure-store.js +110 -0
  34. package/dist/vm/session-manager.d.ts +33 -5
  35. package/dist/vm/session-manager.js +129 -19
  36. package/dist/vm/session-snapshot.d.ts +13 -8
  37. package/dist/vm/session-snapshot.js +52 -36
  38. package/package.json +2 -2
  39. package/rootfs/Dockerfile +5 -2
@@ -15,6 +15,8 @@ import { startMcpBridge, stopMcpBridge } from "../../vm/mcp-bridge.js";
15
15
  import { getDockerBridgeGatewayIp } from "../../vm/docker-executor.js";
16
16
  import { activeConnections } from "../../lib/connection-manager.js";
17
17
  import { vmLog } from "../../vm/log.js";
18
+ import { getConfig } from "../../config/config-manager.js";
19
+ import { getAgentProfile } from "../agent-profiles.js";
18
20
  // ---------------------------------------------------------------------------
19
21
  // Helpers
20
22
  // ---------------------------------------------------------------------------
@@ -31,26 +33,44 @@ async function resolveMcpRelayHost() {
31
33
  return "host.docker.internal";
32
34
  }
33
35
  /**
34
- * Build MCP config JSON for the Docker container.
36
+ * Build the agent config file (MCP wiring + permissions) for the container.
35
37
  * The relay connects to the host MCP server via host.docker.internal.
36
- * Runs the relay staged in the session dir (always matches this CLI build)
38
+ * It runs the relay staged in the session dir (always matches this CLI build)
37
39
  * and authenticates to the bridge with the per-session secret.
40
+ * Format and install path depend on the agent engine (see agent-profiles.ts).
38
41
  */
39
- function buildMcpConfigJson(sessionIndex, mcpSecret, mcpHost) {
40
- const mcpPort = 20000 + sessionIndex;
41
- return JSON.stringify({
42
- mcpServers: {
43
- forgeai: {
44
- command: "node",
45
- args: ["/session/forge-mcp-relay.mjs"],
46
- env: {
47
- FORGEAI_MCP_HOST: mcpHost,
48
- FORGEAI_MCP_PORT: String(mcpPort),
49
- FORGEAI_MCP_TOKEN: mcpSecret,
50
- },
51
- },
52
- },
53
- }, null, 2);
42
+ function buildAgentConfig(profile, sessionIndex, mcpSecret, mcpHost) {
43
+ return profile.buildConfig({ host: mcpHost, port: 20000 + sessionIndex, token: mcpSecret }, { model: getConfig().opencodeModel });
44
+ }
45
+ /**
46
+ * Bash script installing the staged config + context files into the container.
47
+ * The config is staged on the host volume as /session/mcp_config.json
48
+ * regardless of its actual format (JSON or TOML).
49
+ */
50
+ function buildConfigInstallScript(profile) {
51
+ const configDir = profile.configTargetPath.slice(0, profile.configTargetPath.lastIndexOf("/"));
52
+ return [
53
+ `mkdir -p /session/workspace/.forgeai ${configDir}`,
54
+ "cp /session/context.json /session/workspace/.forgeai/context.json",
55
+ `cp /session/mcp_config.json ${profile.configTargetPath}`,
56
+ ].join(" && ");
57
+ }
58
+ /**
59
+ * Fail fast with an actionable message when the engine's binary is missing
60
+ * from the agent image (images built before Codex/OpenCode support).
61
+ */
62
+ async function assertAgentBinaryAvailable(sm, taskId, profile) {
63
+ if (profile.type === "CLAUDE_CODE")
64
+ return;
65
+ const result = await sm.exec(taskId, "bash", [
66
+ "-c",
67
+ `command -v ${profile.binary}`,
68
+ ]);
69
+ if (result.exitCode !== 0) {
70
+ throw new Error(`Agent binary "${profile.binary}" not found in the agent image. ` +
71
+ `Update it with: docker pull getforgeai/agent:latest ` +
72
+ `(or remove the local image and run forge vm init to rebuild)`);
73
+ }
54
74
  }
55
75
  function buildDefaultPrompt(skillName) {
56
76
  return `You are a ForgeAI agent. Read and follow the ${skillName} skill in .claude/skills/${skillName}/SKILL.md to implement your assigned task.`;
@@ -69,35 +89,6 @@ function buildResumePrompt(basePrompt, ctx) {
69
89
  }
70
90
  return parts.join("\n");
71
91
  }
72
- /** Standard Claude CLI args for agent execution */
73
- function buildAgentArgs(mcpConfigPath, prompt) {
74
- return [
75
- "--dangerously-skip-permissions",
76
- "--mcp-config",
77
- mcpConfigPath,
78
- "-p",
79
- prompt,
80
- "--output-format",
81
- "stream-json",
82
- "--verbose",
83
- "--include-partial-messages",
84
- ];
85
- }
86
- /** Claude CLI args for --continue resume (requires -p for print/stream mode) */
87
- function buildContinueArgs(mcpConfigPath, prompt) {
88
- return [
89
- "--continue",
90
- "--dangerously-skip-permissions",
91
- "--mcp-config",
92
- mcpConfigPath,
93
- "-p",
94
- prompt,
95
- "--output-format",
96
- "stream-json",
97
- "--verbose",
98
- "--include-partial-messages",
99
- ];
100
- }
101
92
  /**
102
93
  * Start the MCP bridge for a session.
103
94
  * Returns the MCP port used, or null if no active connection available.
@@ -126,82 +117,94 @@ async function startSessionMcpBridge(session, params) {
126
117
  // Connector
127
118
  // ---------------------------------------------------------------------------
128
119
  export const dockerConnector = {
129
- name: "docker-claude-code",
120
+ name: "docker-agent",
130
121
  async start(params) {
131
122
  const sm = getVmManager();
123
+ const profile = getAgentProfile(params.agentType);
132
124
  // 1. Ensure Docker is ready + image available
133
125
  await sm.ensureVmRunning();
134
126
  // 2. Create session (container + host dirs)
135
127
  const sessionId = await sm.createSession(params.taskId);
136
128
  const session = sm.getSessionByTaskId(params.taskId);
137
- // 3. Clone repo inside the container
138
- await sm.cloneRepo(sessionId, params.repoUrl, params.taskBranch, params.workflowBaseBranch);
139
- // 4. Prepare workspace files on the host (volume-mounted at /session)
140
- const skillFiles = [];
141
- if (params.skill) {
142
- for (const file of params.skill.files) {
143
- skillFiles.push({
144
- relativePath: `${params.skill.name}/${file.path}`,
145
- content: file.content,
146
- });
129
+ // From here on, a failure must not leak the freshly created container
130
+ // there is no orphan sweep, only destroySession removes containers.
131
+ try {
132
+ // 3. Clone repo inside the container
133
+ await sm.cloneRepo(sessionId, params.repoUrl, params.taskBranch, params.workflowBaseBranch);
134
+ // 4. Prepare workspace files on the host (volume-mounted at /session)
135
+ const skillFiles = [];
136
+ if (params.skill) {
137
+ for (const file of params.skill.files) {
138
+ skillFiles.push({
139
+ relativePath: `${params.skill.name}/${file.path}`,
140
+ content: file.content,
141
+ });
142
+ }
147
143
  }
148
- }
149
- sm.prepareWorkspace(sessionId, {
150
- contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
151
- mcpConfigJson: buildMcpConfigJson(session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
152
- skillFiles,
153
- });
154
- // 5. Copy workspace files from volume mount into /workspace inside container
155
- await sm.exec(params.taskId, "bash", [
156
- "-c",
157
- [
158
- "mkdir -p /session/workspace/.forgeai /session/workspace/.claude",
159
- "cp /session/context.json /session/workspace/.forgeai/context.json",
160
- "cp /session/mcp_config.json /session/workspace/.claude/mcp_config.json",
161
- ].join(" && "),
162
- ]);
163
- if (skillFiles.length > 0) {
144
+ sm.prepareWorkspace(sessionId, {
145
+ contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
146
+ mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
147
+ skillFiles,
148
+ });
149
+ // 5. Copy workspace files from volume mount into place inside container
164
150
  await sm.exec(params.taskId, "bash", [
165
151
  "-c",
166
- [
167
- "mkdir -p /session/workspace/.claude/skills",
168
- "cp -r /session/skills/. /session/workspace/.claude/skills/",
169
- ].join(" && "),
152
+ buildConfigInstallScript(profile),
170
153
  ]);
171
- }
172
- // 6. Start MCP bridge (TCP server on host for forge-mcp-relay in container)
173
- await startSessionMcpBridge(session, params);
174
- // 7. Spawn agent
175
- const prompt = params.prompt ?? buildDefaultPrompt(params.skillName);
176
- const agentArgs = buildAgentArgs("/session/workspace/.claude/mcp_config.json", prompt);
177
- const env = {};
178
- vmLog.magenta("docker-connector", `Spawning agent in container ${sessionId}`, sessionId);
179
- const proxy = await sm.spawnAgent(params.taskId, "claude", agentArgs, env);
180
- // 8. Container lifecycle on agent exit — depends on workflow context
181
- if (!params.workflowContext) {
182
- // Board task: destroy container + MCP bridge immediately on exit
183
- proxy.once("exit", () => {
184
- stopMcpBridge(sessionId).catch(() => { });
185
- sm.destroySession(params.taskId).catch((err) => {
186
- const message = err instanceof Error ? err.message : String(err);
187
- vmLog.warn("docker-connector", `Cleanup failed for ${sessionId}: ${message}`, sessionId);
154
+ if (skillFiles.length > 0) {
155
+ // Skills always live in .claude/skills: Claude Code and OpenCode read
156
+ // that directory natively, Codex is pointed at it via the prompt.
157
+ await sm.exec(params.taskId, "bash", [
158
+ "-c",
159
+ [
160
+ "mkdir -p /session/workspace/.claude/skills",
161
+ "cp -r /session/skills/. /session/workspace/.claude/skills/",
162
+ ].join(" && "),
163
+ ]);
164
+ }
165
+ // 6. Install agent credentials inside the container when the engine
166
+ // reads them from disk (Codex auth.json, OpenCode auth.json)
167
+ await assertAgentBinaryAvailable(sm, params.taskId, profile);
168
+ await sm.materializeAgentAuth(params.taskId, profile.type);
169
+ // 7. Start MCP bridge (TCP server on host for forge-mcp-relay in container)
170
+ await startSessionMcpBridge(session, params);
171
+ // 8. Spawn agent
172
+ const prompt = params.prompt ?? buildDefaultPrompt(params.skillName);
173
+ const agentArgs = profile.buildArgs(prompt);
174
+ const env = {};
175
+ vmLog.magenta("docker-connector", `Spawning ${profile.type} agent in container ${sessionId}`, sessionId);
176
+ const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, env, profile.type);
177
+ // 9. Container lifecycle on agent exit — depends on workflow context
178
+ if (!params.workflowContext) {
179
+ // Board task: destroy container + MCP bridge immediately on exit
180
+ proxy.once("exit", () => {
181
+ stopMcpBridge(sessionId).catch(() => { });
182
+ sm.destroySession(params.taskId).catch((err) => {
183
+ const message = err instanceof Error ? err.message : String(err);
184
+ vmLog.warn("docker-connector", `Cleanup failed for ${sessionId}: ${message}`, sessionId);
185
+ });
188
186
  });
189
- });
187
+ }
188
+ else {
189
+ // Workflow step: keep container alive for idle window.
190
+ // Only stop MCP bridge — container stays for --continue resume.
191
+ // Destruction is handled by idle timeout / dismiss / error in agent-spawner.
192
+ proxy.once("exit", () => {
193
+ stopMcpBridge(sessionId).catch(() => { });
194
+ vmLog.dim("docker-connector", `Workflow agent exited — container ${sessionId} kept alive for idle window`, sessionId);
195
+ });
196
+ }
197
+ return {
198
+ pid: proxy.pid,
199
+ process: proxy,
200
+ vmId: sessionId,
201
+ };
190
202
  }
191
- else {
192
- // Workflow step: keep container alive for idle window.
193
- // Only stop MCP bridge — container stays for --continue resume.
194
- // Destruction is handled by idle timeout / dismiss / error in agent-spawner.
195
- proxy.once("exit", () => {
196
- stopMcpBridge(sessionId).catch(() => { });
197
- vmLog.dim("docker-connector", `Workflow agent exited — container ${sessionId} kept alive for idle window`, sessionId);
198
- });
203
+ catch (err) {
204
+ stopMcpBridge(sessionId).catch(() => { });
205
+ await sm.destroySession(params.taskId).catch(() => { });
206
+ throw err;
199
207
  }
200
- return {
201
- pid: proxy.pid,
202
- process: proxy,
203
- vmId: sessionId,
204
- };
205
208
  },
206
209
  async stop(entry) {
207
210
  await stopAgentProcess(entry);
@@ -214,15 +217,23 @@ export const dockerConnector = {
214
217
  const sm = getVmManager();
215
218
  const session = sm.getSessionByTaskId(params.taskId);
216
219
  if (session && session.status === "RUNNING") {
217
- vmLog.cyan("docker-connector", `Resuming in existing container ${session.id}`, session.id);
220
+ // Continue with the engine the container was provisioned for — its
221
+ // config, credentials and on-disk session belong to that engine, even
222
+ // if the CLI's configured engine changed since the session started.
223
+ const profile = getAgentProfile(session.agentType ?? params.agentType);
224
+ if (params.agentType && profile.type !== params.agentType) {
225
+ vmLog.warn("docker-connector", `Configured engine is ${params.agentType} but session ${session.id} was provisioned for ${profile.type} — continuing with ${profile.type}`, session.id);
226
+ }
227
+ vmLog.cyan("docker-connector", `Resuming ${profile.type} in existing container ${session.id}`, session.id);
218
228
  // Restart MCP bridge (stopped on previous agent exit)
219
229
  await startSessionMcpBridge(session, params);
220
- // Use --continue for workflow resume in existing container (full context)
230
+ // Continue the on-disk session for workflow resume in the existing
231
+ // container (full context)
221
232
  if (params.workflowContext) {
222
233
  const continuePrompt = params.prompt ??
223
234
  "Continue your work from where you left off. Do not repeat completed work.";
224
- const agentArgs = buildContinueArgs("/session/workspace/.claude/mcp_config.json", continuePrompt);
225
- const proxy = await sm.spawnAgent(params.taskId, "claude", agentArgs);
235
+ const agentArgs = profile.buildContinueArgs(continuePrompt);
236
+ const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
226
237
  // Keep container alive for next idle window cycle
227
238
  proxy.once("exit", () => {
228
239
  stopMcpBridge(session.id).catch(() => { });
@@ -234,11 +245,11 @@ export const dockerConnector = {
234
245
  vmId: session.id,
235
246
  };
236
247
  }
237
- // Board task resume: use --continue with user's answer (container alive from ask-human)
248
+ // Board task resume: continue with user's answer (container alive from ask-human)
238
249
  const resumePrompt = ctx?.response ?? "Continue your work.";
239
250
  await startSessionMcpBridge(session, params);
240
- const agentArgs = buildContinueArgs("/session/workspace/.claude/mcp_config.json", resumePrompt);
241
- const proxy = await sm.spawnAgent(params.taskId, "claude", agentArgs);
251
+ const agentArgs = profile.buildContinueArgs(resumePrompt);
252
+ const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
242
253
  return {
243
254
  pid: proxy.pid,
244
255
  process: proxy,
@@ -258,80 +269,86 @@ export const dockerConnector = {
258
269
  },
259
270
  /**
260
271
  * Resume a workflow step using a restored session snapshot.
261
- * The snapshot has already been restored into the container by the caller.
262
- * Uses `claude --continue` for full context restoration.
272
+ * Restores the engine's state dir into a fresh container, then launches the
273
+ * engine's continue command for full context restoration.
263
274
  */
264
275
  async resumeWithSnapshot(params) {
265
276
  const sm = getVmManager();
277
+ const profile = getAgentProfile(params.agentType);
266
278
  // 1. Ensure Docker is ready + image available
267
279
  await sm.ensureVmRunning();
268
280
  // 2. Create session (container + host dirs)
269
281
  const sessionId = await sm.createSession(params.taskId);
270
282
  const session = sm.getSessionByTaskId(params.taskId);
271
- // 3. Clone repo (picks up auto-saved code from git push)
272
- await sm.cloneRepo(sessionId, params.repoUrl, params.taskBranch, params.workflowBaseBranch);
273
- // 4. Prepare workspace files
274
- const skillFiles = [];
275
- if (params.skill) {
276
- for (const file of params.skill.files) {
277
- skillFiles.push({
278
- relativePath: `${params.skill.name}/${file.path}`,
279
- content: file.content,
280
- });
283
+ // From here on, a failure must not leak the freshly created container.
284
+ try {
285
+ // 3. Clone repo (picks up auto-saved code from git push)
286
+ await sm.cloneRepo(sessionId, params.repoUrl, params.taskBranch, params.workflowBaseBranch);
287
+ // 4. Prepare workspace files
288
+ const skillFiles = [];
289
+ if (params.skill) {
290
+ for (const file of params.skill.files) {
291
+ skillFiles.push({
292
+ relativePath: `${params.skill.name}/${file.path}`,
293
+ content: file.content,
294
+ });
295
+ }
281
296
  }
282
- }
283
- sm.prepareWorkspace(sessionId, {
284
- contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
285
- mcpConfigJson: buildMcpConfigJson(session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
286
- skillFiles,
287
- });
288
- await sm.exec(params.taskId, "bash", [
289
- "-c",
290
- [
291
- "mkdir -p /session/workspace/.forgeai /session/workspace/.claude",
292
- "cp /session/context.json /session/workspace/.forgeai/context.json",
293
- "cp /session/mcp_config.json /session/workspace/.claude/mcp_config.json",
294
- ].join(" && "),
295
- ]);
296
- if (skillFiles.length > 0) {
297
+ sm.prepareWorkspace(sessionId, {
298
+ contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
299
+ mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
300
+ skillFiles,
301
+ });
297
302
  await sm.exec(params.taskId, "bash", [
298
303
  "-c",
299
- [
300
- "mkdir -p /session/workspace/.claude/skills",
301
- "cp -r /session/skills/. /session/workspace/.claude/skills/",
302
- ].join(" && "),
304
+ buildConfigInstallScript(profile),
303
305
  ]);
306
+ if (skillFiles.length > 0) {
307
+ await sm.exec(params.taskId, "bash", [
308
+ "-c",
309
+ [
310
+ "mkdir -p /session/workspace/.claude/skills",
311
+ "cp -r /session/skills/. /session/workspace/.claude/skills/",
312
+ ].join(" && "),
313
+ ]);
314
+ }
315
+ // 5. Install agent credentials inside the fresh container
316
+ await assertAgentBinaryAvailable(sm, params.taskId, profile);
317
+ await sm.materializeAgentAuth(params.taskId, profile.type);
318
+ // 6. Restore the session snapshot (already written to sessionDir by caller)
319
+ const { restoreSessionSnapshot } = await import("../../vm/session-snapshot.js");
320
+ const snapshotRestored = await restoreSessionSnapshot(session, params.sessionSnapshotBase64, profile);
321
+ // 7. Start MCP bridge
322
+ await startSessionMcpBridge(session, params);
323
+ // 8. Spawn with the continue command if snapshot restored, otherwise fresh prompt
324
+ let proxy;
325
+ if (snapshotRestored) {
326
+ vmLog.cyan("docker-connector", `Spawning ${profile.type} continue (snapshot restored) in ${sessionId}`, sessionId);
327
+ const continuePrompt = params.prompt ??
328
+ "Continue your work from where you left off. Do not repeat completed work.";
329
+ proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildContinueArgs(continuePrompt), {}, profile.type);
330
+ }
331
+ else {
332
+ vmLog.warn("docker-connector", `Snapshot restore failed — falling back to prompt in ${sessionId}`, sessionId);
333
+ const prompt = params.prompt ?? buildDefaultPrompt(params.skillName);
334
+ proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildArgs(prompt), {}, profile.type);
335
+ }
336
+ // Workflow: keep container alive for idle window
337
+ proxy.once("exit", () => {
338
+ stopMcpBridge(sessionId).catch(() => { });
339
+ vmLog.dim("docker-connector", `Snapshot-resumed workflow agent exited — container ${sessionId} kept alive`, sessionId);
340
+ });
341
+ return {
342
+ pid: proxy.pid,
343
+ process: proxy,
344
+ vmId: sessionId,
345
+ };
304
346
  }
305
- // 5. Restore the session snapshot (already written to sessionDir by caller)
306
- const { restoreSessionSnapshot } = await import("../../vm/session-snapshot.js");
307
- const snapshotRestored = await restoreSessionSnapshot(session, params.sessionSnapshotBase64);
308
- // 6. Start MCP bridge
309
- await startSessionMcpBridge(session, params);
310
- // 7. Spawn with --continue if snapshot restored, otherwise -p prompt
311
- let proxy;
312
- if (snapshotRestored) {
313
- vmLog.cyan("docker-connector", `Spawning with --continue (snapshot restored) in ${sessionId}`, sessionId);
314
- const continuePrompt = params.prompt ??
315
- "Continue your work from where you left off. Do not repeat completed work.";
316
- const agentArgs = buildContinueArgs("/session/workspace/.claude/mcp_config.json", continuePrompt);
317
- proxy = await sm.spawnAgent(params.taskId, "claude", agentArgs);
318
- }
319
- else {
320
- vmLog.warn("docker-connector", `Snapshot restore failed — falling back to prompt in ${sessionId}`, sessionId);
321
- const prompt = params.prompt ?? buildDefaultPrompt(params.skillName);
322
- const agentArgs = buildAgentArgs("/session/workspace/.claude/mcp_config.json", prompt);
323
- proxy = await sm.spawnAgent(params.taskId, "claude", agentArgs);
324
- }
325
- // Workflow: keep container alive for idle window
326
- proxy.once("exit", () => {
347
+ catch (err) {
327
348
  stopMcpBridge(sessionId).catch(() => { });
328
- vmLog.dim("docker-connector", `Snapshot-resumed workflow agent exited — container ${sessionId} kept alive`, sessionId);
329
- });
330
- return {
331
- pid: proxy.pid,
332
- process: proxy,
333
- vmId: sessionId,
334
- };
349
+ await sm.destroySession(params.taskId).catch(() => { });
350
+ throw err;
351
+ }
335
352
  },
336
353
  };
337
354
  //# sourceMappingURL=docker-connector.js.map
@@ -0,0 +1,47 @@
1
+ import type { AgentStreamExtractor, QuestionData, ToolActivityData, TokenUsageData } from "./stream-json-extractor.js";
2
+ /**
3
+ * Extracts normalized events from OpenCode's `opencode run --format json`
4
+ * output.
5
+ *
6
+ * OpenCode emits newline-delimited JSON events, each carrying a `sessionID`
7
+ * and a `part` object:
8
+ * {"type":"step_start","part":{...}}
9
+ * {"type":"text","part":{"id":"prt_...","type":"text","text":"..."}}
10
+ * {"type":"tool_use","part":{"callID":"...","tool":"bash","state":{"status":"completed","input":{...}}}}
11
+ * {"type":"step_finish","part":{"tokens":{"input":N,"output":N,"reasoning":N,"cache":{"read":N,"write":N}}}}
12
+ * {"type":"error",...}
13
+ *
14
+ * Exposes the same callback surface as StreamJsonExtractor so the spawn
15
+ * pipeline (StreamBatcher → Socket.io) is unchanged. OpenCode has no
16
+ * equivalent of Claude's AskUserQuestion tool — structured questions reach
17
+ * the Cloud via the forge_ask_human MCP tool instead.
18
+ */
19
+ export declare class OpenCodeJsonlExtractor implements AgentStreamExtractor {
20
+ private readonly onText;
21
+ private lineBuf;
22
+ /** Emitted text length per text part id (suffix dedup across re-emits). */
23
+ private readonly emittedTextLengths;
24
+ /** Tool call ids already reported as activity. */
25
+ private readonly emittedCallIds;
26
+ private hasEmittedText;
27
+ turnIndex: number;
28
+ lastError: string | null;
29
+ /** OpenCode session id (ses_...) — usable with `opencode run --session`. */
30
+ openCodeSessionId: string | null;
31
+ private inputTokens;
32
+ private outputTokens;
33
+ private readonly onToolActivity?;
34
+ private readonly onTokenUsage?;
35
+ private readonly sessionId?;
36
+ constructor(onText: (delta: string) => void, _onQuestion?: (questions: QuestionData[]) => void, onToolActivity?: (activity: ToolActivityData) => void, onTokenUsage?: (usage: TokenUsageData) => void, sessionId?: string);
37
+ write(data: string): void;
38
+ flush(): void;
39
+ private parseLine;
40
+ private handleText;
41
+ private handleToolUse;
42
+ private handleTokens;
43
+ private captureError;
44
+ isBillingError(extraText?: string): boolean;
45
+ extractResetTimestamp(extraText?: string): string | null;
46
+ }
47
+ //# sourceMappingURL=opencode-jsonl-extractor.d.ts.map