@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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-engine agent profiles: everything that differs between Claude Code,
|
|
3
|
+
* Codex CLI and OpenCode when running headless inside a ForgeAI Docker
|
|
4
|
+
* session. The rest of the pipeline (connector lifecycle, MCP bridge,
|
|
5
|
+
* StreamBatcher, Socket.io contract, snapshot transport) is engine-agnostic.
|
|
6
|
+
*
|
|
7
|
+
* | Engine | Binary | Config file | Resume |
|
|
8
|
+
* |-------------|------------|------------------------------------------|--------------|
|
|
9
|
+
* | CLAUDE_CODE | claude | /session/workspace/.claude/mcp_config.json (via --mcp-config) | --continue |
|
|
10
|
+
* | CODEX | codex | /home/agent/.codex/config.toml | exec resume --last |
|
|
11
|
+
* | OPENCODE | opencode | /home/agent/.config/opencode/opencode.json | run --continue |
|
|
12
|
+
*/
|
|
13
|
+
import { DEFAULT_AGENT_TYPE, isAgentType } from "@getforgeai/protocol";
|
|
14
|
+
import { CodexJsonlExtractor } from "./codex-jsonl-extractor.js";
|
|
15
|
+
import { OpenCodeJsonlExtractor } from "./opencode-jsonl-extractor.js";
|
|
16
|
+
import { StreamJsonExtractor } from "./stream-json-extractor.js";
|
|
17
|
+
/** Narrow an arbitrary config/dispatch string to a supported AgentType. */
|
|
18
|
+
export function resolveAgentType(value) {
|
|
19
|
+
if (value && isAgentType(value))
|
|
20
|
+
return value;
|
|
21
|
+
return DEFAULT_AGENT_TYPE;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Make sure a cloud-provided prompt still leads the agent to the deposited
|
|
25
|
+
* skill. Cloud task/step commands (e.g. "forge_merge_back") replace the
|
|
26
|
+
* default prompt that pointed at the SKILL.md; engines with native skill
|
|
27
|
+
* discovery find the skill anyway, but Codex would otherwise never learn the
|
|
28
|
+
* skill exists. No-op when the engine discovers skills natively or when no
|
|
29
|
+
* skill was deposited.
|
|
30
|
+
*/
|
|
31
|
+
export function appendSkillPointer(prompt, profile, skillName) {
|
|
32
|
+
if (profile.nativeSkillDiscovery || !skillName)
|
|
33
|
+
return prompt;
|
|
34
|
+
return `${prompt}\n\nThe "${skillName}" skill at .claude/skills/${skillName}/SKILL.md describes the procedure to follow — read it before starting.`;
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Claude Code
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const CLAUDE_MCP_CONFIG_PATH = "/session/workspace/.claude/mcp_config.json";
|
|
40
|
+
const claudeProfile = {
|
|
41
|
+
type: "CLAUDE_CODE",
|
|
42
|
+
binary: "claude",
|
|
43
|
+
configTargetPath: CLAUDE_MCP_CONFIG_PATH,
|
|
44
|
+
buildConfig(mcp) {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
mcpServers: {
|
|
47
|
+
forgeai: {
|
|
48
|
+
command: "node",
|
|
49
|
+
args: ["/session/forge-mcp-relay.mjs"],
|
|
50
|
+
env: {
|
|
51
|
+
FORGEAI_MCP_HOST: mcp.host,
|
|
52
|
+
FORGEAI_MCP_PORT: String(mcp.port),
|
|
53
|
+
FORGEAI_MCP_TOKEN: mcp.token,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}, null, 2);
|
|
58
|
+
},
|
|
59
|
+
buildArgs(prompt) {
|
|
60
|
+
return [
|
|
61
|
+
"--dangerously-skip-permissions",
|
|
62
|
+
"--mcp-config",
|
|
63
|
+
CLAUDE_MCP_CONFIG_PATH,
|
|
64
|
+
"-p",
|
|
65
|
+
prompt,
|
|
66
|
+
"--output-format",
|
|
67
|
+
"stream-json",
|
|
68
|
+
"--verbose",
|
|
69
|
+
"--include-partial-messages",
|
|
70
|
+
];
|
|
71
|
+
},
|
|
72
|
+
buildContinueArgs(prompt) {
|
|
73
|
+
return ["--continue", ...this.buildArgs(prompt)];
|
|
74
|
+
},
|
|
75
|
+
spawnEnv: {},
|
|
76
|
+
nativeSkillDiscovery: true,
|
|
77
|
+
stateDir: "/home/agent/.claude",
|
|
78
|
+
snapshotExcludes: [
|
|
79
|
+
"settings.json",
|
|
80
|
+
// Claude Code writes ".credentials.json" (dot-prefixed) on Linux; keep
|
|
81
|
+
// both spellings excluded — tar --exclude needs an exact name match.
|
|
82
|
+
"credentials.json",
|
|
83
|
+
".credentials.json",
|
|
84
|
+
"statsig",
|
|
85
|
+
"commands",
|
|
86
|
+
"todos",
|
|
87
|
+
],
|
|
88
|
+
authErrorPatterns: {
|
|
89
|
+
stderr: [
|
|
90
|
+
"Not logged in",
|
|
91
|
+
"Invalid or expired token",
|
|
92
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
93
|
+
],
|
|
94
|
+
combined: ['"authentication_error"', "Failed to authenticate"],
|
|
95
|
+
},
|
|
96
|
+
authSetupHint: "forge auth setup-claude",
|
|
97
|
+
};
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Codex CLI
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/** TOML basic strings share JSON's escape syntax for our value charset. */
|
|
102
|
+
function tomlString(value) {
|
|
103
|
+
return JSON.stringify(value);
|
|
104
|
+
}
|
|
105
|
+
const codexProfile = {
|
|
106
|
+
type: "CODEX",
|
|
107
|
+
binary: "codex",
|
|
108
|
+
configTargetPath: "/home/agent/.codex/config.toml",
|
|
109
|
+
buildConfig(mcp) {
|
|
110
|
+
return [
|
|
111
|
+
"# Generated by ForgeAI — do not edit",
|
|
112
|
+
"# The Docker container is the security boundary; Codex's own sandbox is disabled.",
|
|
113
|
+
'approval_policy = "never"',
|
|
114
|
+
'sandbox_mode = "danger-full-access"',
|
|
115
|
+
"",
|
|
116
|
+
"[mcp_servers.forgeai]",
|
|
117
|
+
'command = "node"',
|
|
118
|
+
'args = ["/session/forge-mcp-relay.mjs"]',
|
|
119
|
+
"startup_timeout_sec = 30",
|
|
120
|
+
"",
|
|
121
|
+
"[mcp_servers.forgeai.env]",
|
|
122
|
+
`FORGEAI_MCP_HOST = ${tomlString(mcp.host)}`,
|
|
123
|
+
`FORGEAI_MCP_PORT = ${tomlString(String(mcp.port))}`,
|
|
124
|
+
`FORGEAI_MCP_TOKEN = ${tomlString(mcp.token)}`,
|
|
125
|
+
"",
|
|
126
|
+
].join("\n");
|
|
127
|
+
},
|
|
128
|
+
buildArgs(prompt) {
|
|
129
|
+
return [
|
|
130
|
+
"exec",
|
|
131
|
+
"--json",
|
|
132
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
133
|
+
"--skip-git-repo-check",
|
|
134
|
+
prompt,
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
buildContinueArgs(prompt) {
|
|
138
|
+
return [
|
|
139
|
+
"exec",
|
|
140
|
+
"resume",
|
|
141
|
+
"--last",
|
|
142
|
+
"--json",
|
|
143
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
144
|
+
"--skip-git-repo-check",
|
|
145
|
+
prompt,
|
|
146
|
+
];
|
|
147
|
+
},
|
|
148
|
+
spawnEnv: {},
|
|
149
|
+
nativeSkillDiscovery: false,
|
|
150
|
+
stateDir: "/home/agent/.codex",
|
|
151
|
+
snapshotExcludes: ["auth.json", "config.toml", "log"],
|
|
152
|
+
authErrorPatterns: {
|
|
153
|
+
stderr: ["Not logged in", "codex login", "OPENAI_API_KEY", "CODEX_API_KEY"],
|
|
154
|
+
combined: [
|
|
155
|
+
'"authentication_error"',
|
|
156
|
+
"invalid_api_key",
|
|
157
|
+
"Incorrect API key",
|
|
158
|
+
"401 Unauthorized",
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
authSetupHint: "forge auth setup-codex",
|
|
162
|
+
};
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// OpenCode
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* Permission override merged over any project-level opencode.json, so a
|
|
168
|
+
* repo's own config can never re-introduce interactive "ask" prompts that
|
|
169
|
+
* would hang a headless container.
|
|
170
|
+
*/
|
|
171
|
+
const OPENCODE_PERMISSION_JSON = JSON.stringify({
|
|
172
|
+
"*": "allow",
|
|
173
|
+
external_directory: { "**": "allow" },
|
|
174
|
+
doom_loop: "allow",
|
|
175
|
+
});
|
|
176
|
+
const openCodeProfile = {
|
|
177
|
+
type: "OPENCODE",
|
|
178
|
+
binary: "opencode",
|
|
179
|
+
configTargetPath: "/home/agent/.config/opencode/opencode.json",
|
|
180
|
+
buildConfig(mcp, opts) {
|
|
181
|
+
return JSON.stringify({
|
|
182
|
+
$schema: "https://opencode.ai/config.json",
|
|
183
|
+
...(opts?.model ? { model: opts.model } : {}),
|
|
184
|
+
permission: {
|
|
185
|
+
"*": "allow",
|
|
186
|
+
external_directory: { "**": "allow" },
|
|
187
|
+
doom_loop: "allow",
|
|
188
|
+
},
|
|
189
|
+
mcp: {
|
|
190
|
+
forgeai: {
|
|
191
|
+
type: "local",
|
|
192
|
+
command: ["node", "/session/forge-mcp-relay.mjs"],
|
|
193
|
+
environment: {
|
|
194
|
+
FORGEAI_MCP_HOST: mcp.host,
|
|
195
|
+
FORGEAI_MCP_PORT: String(mcp.port),
|
|
196
|
+
FORGEAI_MCP_TOKEN: mcp.token,
|
|
197
|
+
},
|
|
198
|
+
enabled: true,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
}, null, 2);
|
|
202
|
+
},
|
|
203
|
+
buildArgs(prompt) {
|
|
204
|
+
return ["run", "--format", "json", prompt];
|
|
205
|
+
},
|
|
206
|
+
buildContinueArgs(prompt) {
|
|
207
|
+
return ["run", "--continue", "--format", "json", prompt];
|
|
208
|
+
},
|
|
209
|
+
spawnEnv: { OPENCODE_PERMISSION: OPENCODE_PERMISSION_JSON },
|
|
210
|
+
// OpenCode reads .claude/skills natively (OPENCODE_DISABLE_CLAUDE_CODE_SKILLS opts out)
|
|
211
|
+
nativeSkillDiscovery: true,
|
|
212
|
+
stateDir: "/home/agent/.local/share/opencode",
|
|
213
|
+
snapshotExcludes: ["auth.json", "log", "bin", "cache"],
|
|
214
|
+
authErrorPatterns: {
|
|
215
|
+
stderr: [],
|
|
216
|
+
combined: [
|
|
217
|
+
'"authentication_error"',
|
|
218
|
+
"invalid_api_key",
|
|
219
|
+
"Incorrect API key",
|
|
220
|
+
"AuthenticationError",
|
|
221
|
+
"invalid x-api-key",
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
authSetupHint: "forge auth setup-opencode",
|
|
225
|
+
};
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Registry
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
const PROFILES = {
|
|
230
|
+
CLAUDE_CODE: claudeProfile,
|
|
231
|
+
CODEX: codexProfile,
|
|
232
|
+
OPENCODE: openCodeProfile,
|
|
233
|
+
};
|
|
234
|
+
export function getAgentProfile(agentType) {
|
|
235
|
+
return PROFILES[resolveAgentType(agentType)];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Instantiate the stream extractor matching the agent engine.
|
|
239
|
+
* All three classes share the AgentStreamExtractor surface and an identical
|
|
240
|
+
* constructor signature, so spawn call sites stay engine-agnostic.
|
|
241
|
+
*/
|
|
242
|
+
export function createStreamExtractor(agentType, onText, onQuestion, onToolActivity, onTokenUsage, sessionId) {
|
|
243
|
+
const resolved = resolveAgentType(agentType);
|
|
244
|
+
switch (resolved) {
|
|
245
|
+
case "CODEX":
|
|
246
|
+
return new CodexJsonlExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
|
|
247
|
+
case "OPENCODE":
|
|
248
|
+
return new OpenCodeJsonlExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
|
|
249
|
+
default:
|
|
250
|
+
return new StreamJsonExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=agent-profiles.js.map
|
|
@@ -57,6 +57,8 @@ type ProcessHandlerArgs = {
|
|
|
57
57
|
agentSlots: number;
|
|
58
58
|
/** Session/container ID for log context */
|
|
59
59
|
sessionId?: string;
|
|
60
|
+
/** Agent engine that produced the stream (extractor + snapshot selection) */
|
|
61
|
+
agentType?: string;
|
|
60
62
|
};
|
|
61
63
|
/**
|
|
62
64
|
* Attach exit and error handlers to an agent process.
|
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { TaskResumePayloadSchema, WorkflowStepDispatchPayloadSchema, } from "@getforgeai/protocol";
|
|
4
4
|
import { dockerConnector } from "./connectors/docker-connector.js";
|
|
5
5
|
import { StreamBatcher } from "./stream-batcher.js";
|
|
6
|
-
import {
|
|
6
|
+
import { createStreamExtractor, getAgentProfile } from "./agent-profiles.js";
|
|
7
7
|
import { AgentRegistry } from "./agent-registry.js";
|
|
8
8
|
import { handleResume } from "./resume-handler.js";
|
|
9
9
|
import { pullSkill } from "../lib/skill-manager.js";
|
|
@@ -103,8 +103,8 @@ export function attachProcessHandlers(process, args) {
|
|
|
103
103
|
turnIndex: jsonExtractorTask?.turnIndex ?? 0,
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
-
// Parse
|
|
107
|
-
jsonExtractorTask =
|
|
106
|
+
// Parse the engine's JSON output: extract text deltas + tool activity
|
|
107
|
+
jsonExtractorTask = createStreamExtractor(args.agentType, (textDelta) => {
|
|
108
108
|
stdoutBatcher.write(textDelta);
|
|
109
109
|
}, undefined, // onQuestion — not needed for task agents
|
|
110
110
|
(activity) => {
|
|
@@ -167,7 +167,9 @@ export function attachProcessHandlers(process, args) {
|
|
|
167
167
|
if (session) {
|
|
168
168
|
const { autoSaveCode, createSessionSnapshot } = await import("../vm/session-snapshot.js");
|
|
169
169
|
await autoSaveCode(session.containerId, session.id, session.gitCredentials);
|
|
170
|
-
|
|
170
|
+
// Snapshot the engine the session was provisioned for (recorded
|
|
171
|
+
// on SessionState at spawn) rather than the current config value.
|
|
172
|
+
const snapshot = await createSessionSnapshot(session, getAgentProfile(session.agentType ?? args.agentType));
|
|
171
173
|
if (snapshot) {
|
|
172
174
|
socketClient.socket.emit("workflow:step:snapshot", {
|
|
173
175
|
orgId: savedOrgId,
|
|
@@ -289,18 +291,19 @@ export function attachProcessHandlers(process, args) {
|
|
|
289
291
|
queue.clear();
|
|
290
292
|
vmLog.error(label, "Auth error — queue cleared. Attempting token refresh from Keychain...");
|
|
291
293
|
void (async () => {
|
|
294
|
+
const setupHint = getAgentProfile(args.agentType).authSetupHint;
|
|
292
295
|
try {
|
|
293
296
|
const { getVmManager } = await import("../vm/vm-manager-facade.js");
|
|
294
|
-
const refreshed = await getVmManager().refreshToken();
|
|
297
|
+
const refreshed = await getVmManager().refreshToken(args.agentType);
|
|
295
298
|
if (refreshed) {
|
|
296
|
-
vmLog.cyan(label, "
|
|
299
|
+
vmLog.cyan(label, "Credentials refreshed — next dispatch will use them");
|
|
297
300
|
}
|
|
298
301
|
else {
|
|
299
|
-
vmLog.error(label,
|
|
302
|
+
vmLog.error(label, `No new credentials found. Run: ${setupHint}`);
|
|
300
303
|
}
|
|
301
304
|
}
|
|
302
305
|
catch {
|
|
303
|
-
vmLog.error(label,
|
|
306
|
+
vmLog.error(label, `Credential refresh failed. Run: ${setupHint}`);
|
|
304
307
|
}
|
|
305
308
|
})();
|
|
306
309
|
}
|
|
@@ -354,7 +357,7 @@ export function attachWorkflowProcessHandlers(process, args) {
|
|
|
354
357
|
let chunkIndex = 0;
|
|
355
358
|
let accumulatedContent = "";
|
|
356
359
|
let contentTruncated = false;
|
|
357
|
-
// Parse
|
|
360
|
+
// Parse the engine's JSON output: extract only text deltas
|
|
358
361
|
let jsonExtractor;
|
|
359
362
|
if (childProcess.stdout) {
|
|
360
363
|
const stdoutBatcher = new StreamBatcher((chunk) => {
|
|
@@ -384,10 +387,10 @@ export function attachWorkflowProcessHandlers(process, args) {
|
|
|
384
387
|
}
|
|
385
388
|
}
|
|
386
389
|
});
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
// When
|
|
390
|
-
jsonExtractor =
|
|
390
|
+
// The extractor parses the engine's newline-delimited JSON output and
|
|
391
|
+
// emits only chat text to the batcher (no thinking, tool_use, system events).
|
|
392
|
+
// When a structured question is detected, it is emitted via workflow:question.
|
|
393
|
+
jsonExtractor = createStreamExtractor(args.agentType, (textDelta) => {
|
|
391
394
|
stdoutBatcher.write(textDelta);
|
|
392
395
|
}, (questions) => {
|
|
393
396
|
socketClient.emitWorkflowQuestion({
|
|
@@ -637,6 +640,7 @@ export async function spawnAgent(params, ctx) {
|
|
|
637
640
|
mcpConfigPath: "/workspace/.claude/mcp_config.json",
|
|
638
641
|
skillName: skill?.name ?? skillName,
|
|
639
642
|
prompt: params.taskCommand,
|
|
643
|
+
agentType: ctx.agentType,
|
|
640
644
|
repoUrl: params.repoUrl,
|
|
641
645
|
taskBranch,
|
|
642
646
|
workflowBaseBranch: params.workflowBaseBranch,
|
|
@@ -693,6 +697,7 @@ export async function spawnAgent(params, ctx) {
|
|
|
693
697
|
label: "spawner",
|
|
694
698
|
agentSlots: ctx.agentSlots,
|
|
695
699
|
sessionId: result.vmId,
|
|
700
|
+
agentType: ctx.agentType,
|
|
696
701
|
});
|
|
697
702
|
}
|
|
698
703
|
catch (err) {
|
|
@@ -940,7 +945,9 @@ function setupIdleWindowTimeout(agentId, messageHandler, idleState, params, reg,
|
|
|
940
945
|
if (session) {
|
|
941
946
|
const { autoSaveCode, createSessionSnapshot } = await import("../vm/session-snapshot.js");
|
|
942
947
|
await autoSaveCode(session.containerId, session.id, session.gitCredentials);
|
|
943
|
-
|
|
948
|
+
// Snapshot the engine the session was provisioned for — ctx.agentType
|
|
949
|
+
// may have changed (cloud push / config set) during the idle window.
|
|
950
|
+
const snapshot = await createSessionSnapshot(session, getAgentProfile(session.agentType));
|
|
944
951
|
if (snapshot) {
|
|
945
952
|
ctx.socketClient.socket.emit("workflow:step:snapshot", {
|
|
946
953
|
orgId: params.orgId,
|
|
@@ -1002,6 +1009,7 @@ async function spawnWorkflowStepAgent(params, ctx) {
|
|
|
1002
1009
|
mcpConfigPath: "/workspace/.claude/mcp_config.json",
|
|
1003
1010
|
skillName: skill?.name ?? skillName,
|
|
1004
1011
|
prompt,
|
|
1012
|
+
agentType: ctx.agentType,
|
|
1005
1013
|
repoUrl: params.repoUrl,
|
|
1006
1014
|
taskBranch: params.baseBranch,
|
|
1007
1015
|
workflowBaseBranch: params.sourceBranch,
|
|
@@ -1146,6 +1154,7 @@ async function spawnWorkflowStepAgent(params, ctx) {
|
|
|
1146
1154
|
label: "workflow-dispatch",
|
|
1147
1155
|
agentSlots: ctx.agentSlots,
|
|
1148
1156
|
sessionId: result.vmId,
|
|
1157
|
+
agentType: ctx.agentType,
|
|
1149
1158
|
workflowId: params.workflowId,
|
|
1150
1159
|
stepId: params.stepId,
|
|
1151
1160
|
conversationId: params.conversationId ?? agentId,
|
|
@@ -1268,6 +1277,7 @@ async function resumeWorkflowStepAgent(agentId, userMessage, lastAssistantConten
|
|
|
1268
1277
|
mcpConfigPath: "/workspace/.claude/mcp_config.json",
|
|
1269
1278
|
skillName: DEFAULT_SKILL_NAME,
|
|
1270
1279
|
prompt: userMessage,
|
|
1280
|
+
agentType: ctx.agentType,
|
|
1271
1281
|
repoUrl: originalParams.repoUrl,
|
|
1272
1282
|
taskBranch: originalParams.baseBranch,
|
|
1273
1283
|
workflowBaseBranch: originalParams.sourceBranch,
|
|
@@ -1338,6 +1348,7 @@ async function resumeWorkflowStepAgent(agentId, userMessage, lastAssistantConten
|
|
|
1338
1348
|
label: "workflow-dispatch",
|
|
1339
1349
|
agentSlots: ctx.agentSlots,
|
|
1340
1350
|
sessionId: result.vmId,
|
|
1351
|
+
agentType: ctx.agentType,
|
|
1341
1352
|
workflowId: originalParams.workflowId,
|
|
1342
1353
|
stepId: originalParams.stepId,
|
|
1343
1354
|
conversationId: originalParams.conversationId ?? agentId,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing / usage-limit error detection shared by all agent stream extractors.
|
|
3
|
+
*
|
|
4
|
+
* The patterns are generic English phrasings that cover Anthropic ("credit
|
|
5
|
+
* balance", "limit will reset"), OpenAI ("insufficient_quota", "exceeded your
|
|
6
|
+
* current quota") and OpenRouter-style wording, so the same detector works for
|
|
7
|
+
* Claude Code, Codex and OpenCode output.
|
|
8
|
+
*/
|
|
9
|
+
/** True when any of the given texts matches a billing/quota-exhaustion pattern. */
|
|
10
|
+
export declare function matchesBillingPatterns(texts: Array<string | null | undefined>): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Extract the usage-limit reset timestamp from error text.
|
|
13
|
+
* Handles multiple formats:
|
|
14
|
+
* - "reset at 2026-03-08T15:00:00+00:00" (ISO datetime)
|
|
15
|
+
* - "resets 7pm (Europe/Paris)" (time + timezone)
|
|
16
|
+
* - "resets in 2 hours" (relative duration)
|
|
17
|
+
* Returns an ISO string if a valid future timestamp is found, otherwise null.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractResetTimestampFromTexts(texts: Array<string | null | undefined>): string | null;
|
|
20
|
+
//# sourceMappingURL=billing-detect.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing / usage-limit error detection shared by all agent stream extractors.
|
|
3
|
+
*
|
|
4
|
+
* The patterns are generic English phrasings that cover Anthropic ("credit
|
|
5
|
+
* balance", "limit will reset"), OpenAI ("insufficient_quota", "exceeded your
|
|
6
|
+
* current quota") and OpenRouter-style wording, so the same detector works for
|
|
7
|
+
* Claude Code, Codex and OpenCode output.
|
|
8
|
+
*/
|
|
9
|
+
const BILLING_PATTERNS = [
|
|
10
|
+
/credit.{0,20}balance/i,
|
|
11
|
+
/insufficient.{0,20}(?:credit|fund|balance|quota)/i,
|
|
12
|
+
/exceed.{0,20}(?:quota|budget|limit)/i,
|
|
13
|
+
/usage.{0,20}limit.{0,20}reached/i,
|
|
14
|
+
/limit.{0,20}(?:will )?reset/i,
|
|
15
|
+
/(?:hit|reached).{0,10}(?:your )?limit/i,
|
|
16
|
+
/billing/i,
|
|
17
|
+
/payment.{0,20}(?:required|failed|method)/i,
|
|
18
|
+
/account.{0,20}(?:suspended|deactivated)/i,
|
|
19
|
+
/out of (?:credit|token)/i,
|
|
20
|
+
];
|
|
21
|
+
/** True when any of the given texts matches a billing/quota-exhaustion pattern. */
|
|
22
|
+
export function matchesBillingPatterns(texts) {
|
|
23
|
+
const candidates = texts.filter(Boolean);
|
|
24
|
+
return candidates.some((text) => BILLING_PATTERNS.some((re) => re.test(text)));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Extract the usage-limit reset timestamp from error text.
|
|
28
|
+
* Handles multiple formats:
|
|
29
|
+
* - "reset at 2026-03-08T15:00:00+00:00" (ISO datetime)
|
|
30
|
+
* - "resets 7pm (Europe/Paris)" (time + timezone)
|
|
31
|
+
* - "resets in 2 hours" (relative duration)
|
|
32
|
+
* Returns an ISO string if a valid future timestamp is found, otherwise null.
|
|
33
|
+
*/
|
|
34
|
+
export function extractResetTimestampFromTexts(texts) {
|
|
35
|
+
const candidates = texts.filter(Boolean);
|
|
36
|
+
for (const text of candidates) {
|
|
37
|
+
// Pattern 1: "reset at <ISO datetime>"
|
|
38
|
+
const isoMatch = /reset\s+at\s+(.+?)(?:\.|$)/i.exec(text);
|
|
39
|
+
if (isoMatch) {
|
|
40
|
+
const dateStr = isoMatch[1].trim();
|
|
41
|
+
const parsed = new Date(dateStr);
|
|
42
|
+
if (!Number.isNaN(parsed.getTime()) && parsed.getTime() > Date.now()) {
|
|
43
|
+
return parsed.toISOString();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Pattern 2: "resets 7pm (Europe/Paris)" or "resets 19:00 (Europe/Paris)"
|
|
47
|
+
const tzMatch = /resets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([^)]+)\)/i.exec(text);
|
|
48
|
+
if (tzMatch) {
|
|
49
|
+
const result = resolveTimeInTimezone(tzMatch[1], tzMatch[2], tzMatch[3], tzMatch[4]);
|
|
50
|
+
if (result)
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
// Pattern 3: "resets in 2 hours" or "resets in 30 minutes"
|
|
54
|
+
const relMatch = /resets?\s+in\s+(\d+)\s*(hour|hr|minute|min)s?/i.exec(text);
|
|
55
|
+
if (relMatch) {
|
|
56
|
+
const amount = parseInt(relMatch[1], 10);
|
|
57
|
+
const unit = relMatch[2].toLowerCase();
|
|
58
|
+
const ms = unit.startsWith("hour") || unit === "hr"
|
|
59
|
+
? amount * 3600_000
|
|
60
|
+
: amount * 60_000;
|
|
61
|
+
const future = new Date(Date.now() + ms);
|
|
62
|
+
return future.toISOString();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a time-of-day (e.g. "7pm") in a named timezone to an ISO string.
|
|
69
|
+
* Uses Intl.DateTimeFormat to avoid external dependencies.
|
|
70
|
+
*/
|
|
71
|
+
function resolveTimeInTimezone(hourStr, minuteStr, ampm, timezone) {
|
|
72
|
+
try {
|
|
73
|
+
let hour = parseInt(hourStr, 10);
|
|
74
|
+
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
|
|
75
|
+
if (ampm) {
|
|
76
|
+
const isPm = ampm.toLowerCase() === "pm";
|
|
77
|
+
if (isPm && hour < 12)
|
|
78
|
+
hour += 12;
|
|
79
|
+
if (!isPm && hour === 12)
|
|
80
|
+
hour = 0;
|
|
81
|
+
}
|
|
82
|
+
// Get current date in the target timezone
|
|
83
|
+
const now = new Date();
|
|
84
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
85
|
+
timeZone: timezone,
|
|
86
|
+
year: "numeric",
|
|
87
|
+
month: "2-digit",
|
|
88
|
+
day: "2-digit",
|
|
89
|
+
hour: "2-digit",
|
|
90
|
+
minute: "2-digit",
|
|
91
|
+
hour12: false,
|
|
92
|
+
});
|
|
93
|
+
const parts = formatter.formatToParts(now);
|
|
94
|
+
const get = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
|
|
95
|
+
const tzYear = get("year");
|
|
96
|
+
const tzMonth = get("month");
|
|
97
|
+
const tzDay = get("day");
|
|
98
|
+
const tzHour = get("hour");
|
|
99
|
+
const tzMinute = get("minute");
|
|
100
|
+
// Build target time in the timezone's "local" frame
|
|
101
|
+
// Then compute the UTC offset to convert to absolute time
|
|
102
|
+
const localNowMs = Date.UTC(tzYear, tzMonth - 1, tzDay, tzHour, tzMinute);
|
|
103
|
+
const offsetMs = localNowMs - now.getTime();
|
|
104
|
+
// Target time today in timezone-local frame
|
|
105
|
+
let targetLocalMs = Date.UTC(tzYear, tzMonth - 1, tzDay, hour, minute);
|
|
106
|
+
// If already past, try tomorrow
|
|
107
|
+
if (targetLocalMs - offsetMs <= now.getTime()) {
|
|
108
|
+
targetLocalMs += 86_400_000;
|
|
109
|
+
}
|
|
110
|
+
const result = new Date(targetLocalMs - offsetMs);
|
|
111
|
+
if (result.getTime() > Date.now()) {
|
|
112
|
+
return result.toISOString();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Invalid timezone or parse error — fall through
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=billing-detect.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentStreamExtractor, QuestionData, ToolActivityData, TokenUsageData } from "./stream-json-extractor.js";
|
|
2
|
+
/**
|
|
3
|
+
* Extracts normalized events from Codex CLI's `codex exec --json` output.
|
|
4
|
+
*
|
|
5
|
+
* Codex emits newline-delimited JSON "thread events":
|
|
6
|
+
* {"type":"thread.started","thread_id":"..."}
|
|
7
|
+
* {"type":"turn.started"}
|
|
8
|
+
* {"type":"item.started"|"item.updated"|"item.completed","item":{...}}
|
|
9
|
+
* {"type":"turn.completed","usage":{"input_tokens":N,"cached_input_tokens":N,"output_tokens":N}}
|
|
10
|
+
* {"type":"turn.failed","error":{...}} / {"type":"error","message":"..."}
|
|
11
|
+
*
|
|
12
|
+
* Item types: agent_message (text), reasoning, command_execution,
|
|
13
|
+
* file_change, mcp_tool_call, web_search, todo_list.
|
|
14
|
+
*
|
|
15
|
+
* Exposes the same callback surface as StreamJsonExtractor so the spawn
|
|
16
|
+
* pipeline (StreamBatcher → Socket.io) is unchanged. Codex has no equivalent
|
|
17
|
+
* of Claude's AskUserQuestion tool — structured questions reach the Cloud via
|
|
18
|
+
* the forge_ask_human MCP tool instead, so onQuestion is never called here.
|
|
19
|
+
*/
|
|
20
|
+
export declare class CodexJsonlExtractor implements AgentStreamExtractor {
|
|
21
|
+
private readonly onText;
|
|
22
|
+
private lineBuf;
|
|
23
|
+
/** Emitted text length per agent_message item id (suffix dedup). */
|
|
24
|
+
private readonly emittedTextLengths;
|
|
25
|
+
/** Item ids already reported as tool activity. */
|
|
26
|
+
private readonly emittedActivityIds;
|
|
27
|
+
/** Whether any text was emitted (to separate consecutive messages). */
|
|
28
|
+
private hasEmittedText;
|
|
29
|
+
turnIndex: number;
|
|
30
|
+
lastError: string | null;
|
|
31
|
+
/** Codex thread id from thread.started — usable with `codex exec resume`. */
|
|
32
|
+
threadId: string | null;
|
|
33
|
+
private inputTokens;
|
|
34
|
+
private outputTokens;
|
|
35
|
+
private readonly onToolActivity?;
|
|
36
|
+
private readonly onTokenUsage?;
|
|
37
|
+
private readonly sessionId?;
|
|
38
|
+
constructor(onText: (delta: string) => void, _onQuestion?: (questions: QuestionData[]) => void, onToolActivity?: (activity: ToolActivityData) => void, onTokenUsage?: (usage: TokenUsageData) => void, sessionId?: string);
|
|
39
|
+
write(data: string): void;
|
|
40
|
+
flush(): void;
|
|
41
|
+
private parseLine;
|
|
42
|
+
private handleUsage;
|
|
43
|
+
private captureError;
|
|
44
|
+
private handleItem;
|
|
45
|
+
/** Emit only the not-yet-emitted suffix of an item's text. */
|
|
46
|
+
private emitTextDelta;
|
|
47
|
+
private emitActivityOnce;
|
|
48
|
+
private emitMcpToolCall;
|
|
49
|
+
isBillingError(extraText?: string): boolean;
|
|
50
|
+
extractResetTimestamp(extraText?: string): string | null;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=codex-jsonl-extractor.d.ts.map
|