@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.
- package/dist/cli.js +10 -1
- package/dist/commands/auth-setup-agent.d.ts +15 -0
- package/dist/commands/auth-setup-agent.js +98 -0
- package/dist/commands/config-set.js +14 -3
- package/dist/commands/connect.js +23 -7
- package/dist/config/config-manager.d.ts +2 -0
- package/dist/lib/connection-manager.d.ts +11 -0
- package/dist/lib/connection-manager.js +48 -25
- package/dist/lib/daemon-entry.js +23 -6
- package/dist/orchestrator/agent-profiles.d.ts +80 -0
- package/dist/orchestrator/agent-profiles.js +253 -0
- package/dist/orchestrator/agent-spawner.d.ts +2 -0
- package/dist/orchestrator/agent-spawner.js +25 -14
- package/dist/orchestrator/billing-detect.d.ts +20 -0
- package/dist/orchestrator/billing-detect.js +120 -0
- package/dist/orchestrator/codex-jsonl-extractor.d.ts +52 -0
- package/dist/orchestrator/codex-jsonl-extractor.js +210 -0
- package/dist/orchestrator/connectors/connector.d.ts +2 -0
- package/dist/orchestrator/connectors/docker-connector.js +197 -175
- package/dist/orchestrator/opencode-jsonl-extractor.d.ts +47 -0
- package/dist/orchestrator/opencode-jsonl-extractor.js +228 -0
- package/dist/orchestrator/resume-handler.js +2 -0
- package/dist/orchestrator/stream-json-extractor.d.ts +38 -10
- package/dist/orchestrator/stream-json-extractor.js +10 -105
- package/dist/vm/agent-auth-manager.d.ts +32 -0
- package/dist/vm/agent-auth-manager.js +68 -0
- package/dist/vm/docker-process-proxy.d.ts +2 -1
- package/dist/vm/docker-process-proxy.js +17 -10
- package/dist/vm/image-manager.js +1 -1
- package/dist/vm/secure-store.d.ts +24 -0
- package/dist/vm/secure-store.js +110 -0
- package/dist/vm/session-manager.d.ts +33 -5
- package/dist/vm/session-manager.js +129 -19
- package/dist/vm/session-snapshot.d.ts +13 -8
- package/dist/vm/session-snapshot.js +52 -36
- package/package.json +2 -2
- 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
|
|
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
|
-
*
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
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-
|
|
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
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
225
|
-
const proxy = await sm.spawnAgent(params.taskId,
|
|
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:
|
|
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(
|
|
241
|
-
const proxy = await sm.spawnAgent(params.taskId,
|
|
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
|
-
*
|
|
262
|
-
*
|
|
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
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|