@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
@@ -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 { StreamJsonExtractor } from "./stream-json-extractor.js";
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 stream-json output: extract text deltas + tool activity
107
- jsonExtractorTask = new StreamJsonExtractor((textDelta) => {
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
- const snapshot = await createSessionSnapshot(session);
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, "Token refreshed — next dispatch will use the new token");
299
+ vmLog.cyan(label, "Credentials refreshed — next dispatch will use them");
297
300
  }
298
301
  else {
299
- vmLog.error(label, "No new token in Keychain. Run: forge auth setup-claude");
302
+ vmLog.error(label, `No new credentials found. Run: ${setupHint}`);
300
303
  }
301
304
  }
302
305
  catch {
303
- vmLog.error(label, "Token refresh failed. Run: forge auth setup-claude");
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 stream-json output from Claude Code: extract only text deltas
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
- // StreamJsonExtractor parses newline-delimited JSON from --output-format stream-json
388
- // and emits only text_delta content to the batcher (no thinking, tool_use, system events)
389
- // When AskUserQuestion is detected, emit structured questions via workflow:question
390
- jsonExtractor = new StreamJsonExtractor((textDelta) => {
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
- const snapshot = await createSessionSnapshot(session);
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