@getforgeai/cli 0.1.0-beta.2 → 0.1.0-beta.4

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 (37) 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/orchestrator/agent-profiles.d.ts +80 -0
  11. package/dist/orchestrator/agent-profiles.js +253 -0
  12. package/dist/orchestrator/agent-spawner.d.ts +2 -0
  13. package/dist/orchestrator/agent-spawner.js +25 -14
  14. package/dist/orchestrator/billing-detect.d.ts +20 -0
  15. package/dist/orchestrator/billing-detect.js +120 -0
  16. package/dist/orchestrator/codex-jsonl-extractor.d.ts +52 -0
  17. package/dist/orchestrator/codex-jsonl-extractor.js +210 -0
  18. package/dist/orchestrator/connectors/connector.d.ts +2 -0
  19. package/dist/orchestrator/connectors/docker-connector.js +197 -175
  20. package/dist/orchestrator/opencode-jsonl-extractor.d.ts +47 -0
  21. package/dist/orchestrator/opencode-jsonl-extractor.js +228 -0
  22. package/dist/orchestrator/resume-handler.js +2 -0
  23. package/dist/orchestrator/stream-json-extractor.d.ts +38 -10
  24. package/dist/orchestrator/stream-json-extractor.js +10 -105
  25. package/dist/vm/agent-auth-manager.d.ts +32 -0
  26. package/dist/vm/agent-auth-manager.js +68 -0
  27. package/dist/vm/docker-process-proxy.d.ts +2 -1
  28. package/dist/vm/docker-process-proxy.js +17 -10
  29. package/dist/vm/image-manager.js +1 -1
  30. package/dist/vm/secure-store.d.ts +24 -0
  31. package/dist/vm/secure-store.js +110 -0
  32. package/dist/vm/session-manager.d.ts +33 -5
  33. package/dist/vm/session-manager.js +129 -19
  34. package/dist/vm/session-snapshot.d.ts +13 -8
  35. package/dist/vm/session-snapshot.js +52 -36
  36. package/package.json +2 -2
  37. 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 { appendSkillPointer, 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,97 @@ 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. Cloud-provided prompts get a pointer to the deposited
172
+ // skill when the engine has no native skill discovery (Codex).
173
+ const prompt = params.prompt
174
+ ? appendSkillPointer(params.prompt, profile, params.skill?.name)
175
+ : buildDefaultPrompt(params.skillName);
176
+ const agentArgs = profile.buildArgs(prompt);
177
+ const env = {};
178
+ vmLog.magenta("docker-connector", `Spawning ${profile.type} agent in container ${sessionId}`, sessionId);
179
+ const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, env, profile.type);
180
+ // 9. 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);
188
+ });
188
189
  });
189
- });
190
+ }
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
+ });
199
+ }
200
+ return {
201
+ pid: proxy.pid,
202
+ process: proxy,
203
+ vmId: sessionId,
204
+ };
190
205
  }
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
- });
206
+ catch (err) {
207
+ stopMcpBridge(sessionId).catch(() => { });
208
+ await sm.destroySession(params.taskId).catch(() => { });
209
+ throw err;
199
210
  }
200
- return {
201
- pid: proxy.pid,
202
- process: proxy,
203
- vmId: sessionId,
204
- };
205
211
  },
206
212
  async stop(entry) {
207
213
  await stopAgentProcess(entry);
@@ -214,15 +220,23 @@ export const dockerConnector = {
214
220
  const sm = getVmManager();
215
221
  const session = sm.getSessionByTaskId(params.taskId);
216
222
  if (session && session.status === "RUNNING") {
217
- vmLog.cyan("docker-connector", `Resuming in existing container ${session.id}`, session.id);
223
+ // Continue with the engine the container was provisioned for — its
224
+ // config, credentials and on-disk session belong to that engine, even
225
+ // if the CLI's configured engine changed since the session started.
226
+ const profile = getAgentProfile(session.agentType ?? params.agentType);
227
+ if (params.agentType && profile.type !== params.agentType) {
228
+ vmLog.warn("docker-connector", `Configured engine is ${params.agentType} but session ${session.id} was provisioned for ${profile.type} — continuing with ${profile.type}`, session.id);
229
+ }
230
+ vmLog.cyan("docker-connector", `Resuming ${profile.type} in existing container ${session.id}`, session.id);
218
231
  // Restart MCP bridge (stopped on previous agent exit)
219
232
  await startSessionMcpBridge(session, params);
220
- // Use --continue for workflow resume in existing container (full context)
233
+ // Continue the on-disk session for workflow resume in the existing
234
+ // container (full context)
221
235
  if (params.workflowContext) {
222
236
  const continuePrompt = params.prompt ??
223
237
  "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);
238
+ const agentArgs = profile.buildContinueArgs(continuePrompt);
239
+ const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
226
240
  // Keep container alive for next idle window cycle
227
241
  proxy.once("exit", () => {
228
242
  stopMcpBridge(session.id).catch(() => { });
@@ -234,11 +248,11 @@ export const dockerConnector = {
234
248
  vmId: session.id,
235
249
  };
236
250
  }
237
- // Board task resume: use --continue with user's answer (container alive from ask-human)
251
+ // Board task resume: continue with user's answer (container alive from ask-human)
238
252
  const resumePrompt = ctx?.response ?? "Continue your work.";
239
253
  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);
254
+ const agentArgs = profile.buildContinueArgs(resumePrompt);
255
+ const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
242
256
  return {
243
257
  pid: proxy.pid,
244
258
  process: proxy,
@@ -258,80 +272,88 @@ export const dockerConnector = {
258
272
  },
259
273
  /**
260
274
  * 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.
275
+ * Restores the engine's state dir into a fresh container, then launches the
276
+ * engine's continue command for full context restoration.
263
277
  */
264
278
  async resumeWithSnapshot(params) {
265
279
  const sm = getVmManager();
280
+ const profile = getAgentProfile(params.agentType);
266
281
  // 1. Ensure Docker is ready + image available
267
282
  await sm.ensureVmRunning();
268
283
  // 2. Create session (container + host dirs)
269
284
  const sessionId = await sm.createSession(params.taskId);
270
285
  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
- });
286
+ // From here on, a failure must not leak the freshly created container.
287
+ try {
288
+ // 3. Clone repo (picks up auto-saved code from git push)
289
+ await sm.cloneRepo(sessionId, params.repoUrl, params.taskBranch, params.workflowBaseBranch);
290
+ // 4. Prepare workspace files
291
+ const skillFiles = [];
292
+ if (params.skill) {
293
+ for (const file of params.skill.files) {
294
+ skillFiles.push({
295
+ relativePath: `${params.skill.name}/${file.path}`,
296
+ content: file.content,
297
+ });
298
+ }
281
299
  }
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) {
300
+ sm.prepareWorkspace(sessionId, {
301
+ contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
302
+ mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
303
+ skillFiles,
304
+ });
297
305
  await sm.exec(params.taskId, "bash", [
298
306
  "-c",
299
- [
300
- "mkdir -p /session/workspace/.claude/skills",
301
- "cp -r /session/skills/. /session/workspace/.claude/skills/",
302
- ].join(" && "),
307
+ buildConfigInstallScript(profile),
303
308
  ]);
309
+ if (skillFiles.length > 0) {
310
+ await sm.exec(params.taskId, "bash", [
311
+ "-c",
312
+ [
313
+ "mkdir -p /session/workspace/.claude/skills",
314
+ "cp -r /session/skills/. /session/workspace/.claude/skills/",
315
+ ].join(" && "),
316
+ ]);
317
+ }
318
+ // 5. Install agent credentials inside the fresh container
319
+ await assertAgentBinaryAvailable(sm, params.taskId, profile);
320
+ await sm.materializeAgentAuth(params.taskId, profile.type);
321
+ // 6. Restore the session snapshot (already written to sessionDir by caller)
322
+ const { restoreSessionSnapshot } = await import("../../vm/session-snapshot.js");
323
+ const snapshotRestored = await restoreSessionSnapshot(session, params.sessionSnapshotBase64, profile);
324
+ // 7. Start MCP bridge
325
+ await startSessionMcpBridge(session, params);
326
+ // 8. Spawn with the continue command if snapshot restored, otherwise fresh prompt
327
+ let proxy;
328
+ if (snapshotRestored) {
329
+ vmLog.cyan("docker-connector", `Spawning ${profile.type} continue (snapshot restored) in ${sessionId}`, sessionId);
330
+ const continuePrompt = params.prompt ??
331
+ "Continue your work from where you left off. Do not repeat completed work.";
332
+ proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildContinueArgs(continuePrompt), {}, profile.type);
333
+ }
334
+ else {
335
+ vmLog.warn("docker-connector", `Snapshot restore failed — falling back to prompt in ${sessionId}`, sessionId);
336
+ const prompt = params.prompt
337
+ ? appendSkillPointer(params.prompt, profile, params.skill?.name)
338
+ : buildDefaultPrompt(params.skillName);
339
+ proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildArgs(prompt), {}, profile.type);
340
+ }
341
+ // Workflow: keep container alive for idle window
342
+ proxy.once("exit", () => {
343
+ stopMcpBridge(sessionId).catch(() => { });
344
+ vmLog.dim("docker-connector", `Snapshot-resumed workflow agent exited — container ${sessionId} kept alive`, sessionId);
345
+ });
346
+ return {
347
+ pid: proxy.pid,
348
+ process: proxy,
349
+ vmId: sessionId,
350
+ };
304
351
  }
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", () => {
352
+ catch (err) {
327
353
  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
- };
354
+ await sm.destroySession(params.taskId).catch(() => { });
355
+ throw err;
356
+ }
335
357
  },
336
358
  };
337
359
  //# 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