@axiom-lattice/cli-a2a 0.1.5 → 0.1.6

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 (65) hide show
  1. package/.turbo/turbo-build.log +34 -32
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +85 -2
  4. package/__tests__/opencode-executor.test.ts +631 -31
  5. package/__tests__/session-store-path.test.ts +40 -0
  6. package/dist/{chunk-NQIDRU47.mjs → chunk-54SKDK3D.mjs} +78 -32
  7. package/dist/chunk-54SKDK3D.mjs.map +1 -0
  8. package/dist/chunk-BSNQOP2W.mjs +350 -0
  9. package/dist/chunk-BSNQOP2W.mjs.map +1 -0
  10. package/dist/chunk-DRFFQJII.mjs +195 -0
  11. package/dist/chunk-DRFFQJII.mjs.map +1 -0
  12. package/dist/chunk-URJDRXJW.mjs +378 -0
  13. package/dist/chunk-URJDRXJW.mjs.map +1 -0
  14. package/dist/{chunk-G7AGL2QA.mjs → chunk-WZB7FBSX.mjs} +23 -5
  15. package/dist/chunk-WZB7FBSX.mjs.map +1 -0
  16. package/dist/{chunk-LXL47XMZ.mjs → chunk-YGUPSPZO.mjs} +9 -1
  17. package/dist/chunk-YGUPSPZO.mjs.map +1 -0
  18. package/dist/{chunk-35NFMGMS.mjs → chunk-ZP3JNJQJ.mjs} +3 -3
  19. package/dist/chunk-ZP3JNJQJ.mjs.map +1 -0
  20. package/dist/cli.js +769 -156
  21. package/dist/cli.js.map +1 -1
  22. package/dist/cli.mjs +7 -6
  23. package/dist/cli.mjs.map +1 -1
  24. package/dist/executor-LLE5VEFJ.mjs +12 -0
  25. package/dist/executor-LO7P3KUW.mjs +12 -0
  26. package/dist/{executor-RVBGAWUF.mjs → executor-OOLGGXHS.mjs} +4 -3
  27. package/dist/{executors-QIIKBUMJ.mjs → executors-AQHOPGSR.mjs} +2 -2
  28. package/dist/index.d.mts +92 -21
  29. package/dist/index.d.ts +92 -21
  30. package/dist/index.js +731 -123
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +15 -7
  33. package/package.json +10 -3
  34. package/runtime/cli-path.cjs +3 -0
  35. package/runtime/cli-path.d.ts +1 -0
  36. package/runtime/cli-path.mjs +5 -0
  37. package/src/config/defaults.ts +3 -1
  38. package/src/config/types.ts +8 -3
  39. package/src/executors/claude/client.ts +44 -14
  40. package/src/executors/claude/executor.ts +65 -30
  41. package/src/executors/codex/client.ts +239 -16
  42. package/src/executors/codex/executor.ts +70 -37
  43. package/src/executors/events.ts +15 -1
  44. package/src/executors/index.ts +13 -6
  45. package/src/executors/opencode/client.ts +192 -18
  46. package/src/executors/opencode/executor.ts +65 -30
  47. package/src/index.ts +8 -0
  48. package/src/local-state.ts +53 -0
  49. package/src/server/agent-card.ts +1 -0
  50. package/src/server/index.ts +22 -7
  51. package/src/session-store.ts +262 -0
  52. package/dist/chunk-35NFMGMS.mjs.map +0 -1
  53. package/dist/chunk-G7AGL2QA.mjs.map +0 -1
  54. package/dist/chunk-LXL47XMZ.mjs.map +0 -1
  55. package/dist/chunk-NQIDRU47.mjs.map +0 -1
  56. package/dist/chunk-VSZ3DACI.mjs +0 -179
  57. package/dist/chunk-VSZ3DACI.mjs.map +0 -1
  58. package/dist/chunk-WNCDOYZS.mjs +0 -187
  59. package/dist/chunk-WNCDOYZS.mjs.map +0 -1
  60. package/dist/executor-OWPVDUXH.mjs +0 -11
  61. package/dist/executor-XWHWUVQ3.mjs +0 -11
  62. /package/dist/{executor-OWPVDUXH.mjs.map → executor-LLE5VEFJ.mjs.map} +0 -0
  63. /package/dist/{executor-RVBGAWUF.mjs.map → executor-LO7P3KUW.mjs.map} +0 -0
  64. /package/dist/{executor-XWHWUVQ3.mjs.map → executor-OOLGGXHS.mjs.map} +0 -0
  65. /package/dist/{executors-QIIKBUMJ.mjs.map → executors-AQHOPGSR.mjs.map} +0 -0
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * Spawns the Anthropic Claude Code CLI as a child process in non-interactive
5
5
  * print mode (`-p`). Sends prompts, captures stdout as the response.
6
- * Supports configurable CLI path, working directory, model, and timeout.
6
+ * Supports session persistence: first call uses `--session-id <uuid>` to
7
+ * create a named session; subsequent calls use `--resume <uuid>` to continue.
7
8
  */
8
9
 
9
10
  import { spawn } from 'node:child_process';
11
+ import { v4 as uuidv4 } from 'uuid';
10
12
  import { logger } from '../../logger.js';
11
13
 
12
14
  const log = logger.child('claude:client');
@@ -26,6 +28,11 @@ export interface ClaudeClientConfig {
26
28
  timeout?: number;
27
29
  }
28
30
 
31
+ export interface ClaudeExecuteResult {
32
+ text: string;
33
+ sessionId: string;
34
+ }
35
+
29
36
  // ─── Client ─────────────────────────────────────────────────────────────────
30
37
 
31
38
  export class ClaudeClient {
@@ -46,15 +53,36 @@ export class ClaudeClient {
46
53
  }
47
54
 
48
55
  /**
49
- * Execute a prompt via the Claude Code CLI and return the response text.
56
+ * Execute a prompt via the Claude Code CLI.
57
+ *
58
+ * @param prompt - The user prompt text.
59
+ * @param sessionId - If provided, resumes the session with `--resume`.
60
+ * Otherwise creates a new session with `--session-id <new-uuid>`.
61
+ * @param workingDirectory - Optional per-request working directory override.
62
+ * @returns The response text and the session ID (new or existing).
50
63
  */
51
- async execute(prompt: string): Promise<string> {
64
+ async execute(prompt: string, sessionId?: string, workingDirectory?: string): Promise<ClaudeExecuteResult> {
65
+ if (sessionId) {
66
+ const text = await this.run(['-p', '--resume', sessionId, prompt], workingDirectory);
67
+ return { text, sessionId };
68
+ }
69
+
70
+ const newSessionId = uuidv4();
71
+ const text = await this.run(['-p', '--session-id', newSessionId, prompt], workingDirectory);
72
+ return { text, sessionId: newSessionId };
73
+ }
74
+
75
+ // ── Internal ──────────────────────────────────────────────────────────
76
+
77
+ private async run(args: string[], workingDirectory?: string): Promise<string> {
52
78
  const cliPath = this.config.cliPath || 'claude';
53
79
  const timeout = this.config.timeout ?? 300_000;
54
80
  const MAX_PROMPT = 100_000;
55
81
 
56
- if (prompt.length > MAX_PROMPT) {
57
- throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}). Split into smaller messages.`);
82
+ // Validate prompt size
83
+ const promptArg = args[args.length - 1];
84
+ if (promptArg && promptArg.length > MAX_PROMPT) {
85
+ throw new Error(`Prompt too large (${promptArg.length} chars, max ${MAX_PROMPT}). Split into smaller messages.`);
58
86
  }
59
87
 
60
88
  // Only pass relevant env vars to child process
@@ -62,26 +90,29 @@ export class ClaudeClient {
62
90
  PATH: process.env.PATH ?? '',
63
91
  HOME: process.env.HOME ?? '',
64
92
  };
65
- // Forward common proxy/no-proxy settings
66
93
  for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
67
94
  if (process.env[key]) env[key] = process.env[key];
68
95
  }
69
-
70
96
  if (this.config.apiKey) {
71
97
  env['ANTHROPIC_API_KEY'] = this.config.apiKey;
72
98
  }
73
99
 
74
- const args: string[] = ['-p'];
100
+ // Build full args: base args + model + prompt
101
+ const fullArgs: string[] = [];
102
+ for (const arg of args.slice(0, -1)) {
103
+ fullArgs.push(arg);
104
+ }
75
105
  if (this.config.model) {
76
- args.push('--model', this.config.model);
106
+ fullArgs.push('--model', this.config.model);
77
107
  }
78
- args.push(prompt);
108
+ fullArgs.push(promptArg);
79
109
 
80
- log.info('Spawning claude', { cliPath, workdir: this.config.workdir, model: this.config.model });
110
+ const workdir = workingDirectory || this.config.workdir || process.cwd();
111
+ log.info('Spawning claude', { cliPath, workdir, model: this.config.model });
81
112
 
82
113
  return new Promise<string>((resolve, reject) => {
83
- const child = spawn(cliPath, args, {
84
- cwd: this.config.workdir || process.cwd(),
114
+ const child = spawn(cliPath, fullArgs, {
115
+ cwd: workdir,
85
116
  env,
86
117
  stdio: ['ignore', 'pipe', 'pipe'],
87
118
  });
@@ -94,7 +125,6 @@ export class ClaudeClient {
94
125
 
95
126
  const timer = setTimeout(() => {
96
127
  child.kill('SIGTERM');
97
- // Force kill after 5s grace period
98
128
  const forceTimer = setTimeout(() => {
99
129
  if (child.exitCode === null) child.kill('SIGKILL');
100
130
  }, 5000);
@@ -2,21 +2,23 @@
2
2
  * Claude Code A2A Executor
3
3
  *
4
4
  * Bridges the local Claude Code CLI to the A2A protocol. Spawns Claude Code
5
- * in non-interactive print mode (`-p`) for each request and captures stdout
6
- * as the response.
5
+ * in non-interactive print mode (`-p`) for each request. Supports multi-turn
6
+ * conversation via contextId → sessionId binding through SessionBindingStore.
7
7
  */
8
8
 
9
9
  import type { A2AExecutor } from '../index.js';
10
10
  import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
11
- import type { Message as A2AMessage } from '@a2a-js/sdk';
12
11
 
13
12
  import type { AgentConfig } from '../../config/types.js';
13
+ import type { SessionBindingStore } from '../../session-store.js';
14
+ import { generateContextId } from '../../session-store.js';
14
15
  import { logger } from '../../logger.js';
15
16
  import { ClaudeClient } from './client.js';
16
17
  import {
17
18
  publishTask,
18
19
  publishStatus,
19
20
  publishFinalArtifact,
21
+ extractText,
20
22
  } from '../events.js';
21
23
 
22
24
  const log = logger.child('claude:executor');
@@ -27,9 +29,11 @@ export class ClaudeExecutor implements A2AExecutor {
27
29
  private config: Required<AgentConfig>;
28
30
  private client: ClaudeClient | null = null;
29
31
  private initialized = false;
32
+ private sessionStore: SessionBindingStore;
30
33
 
31
- constructor(config: Required<AgentConfig>) {
34
+ constructor(config: Required<AgentConfig>, sessionStore: SessionBindingStore) {
32
35
  this.config = config;
36
+ this.sessionStore = sessionStore;
33
37
  }
34
38
 
35
39
  async initialize(): Promise<void> {
@@ -55,31 +59,70 @@ export class ClaudeExecutor implements A2AExecutor {
55
59
  }
56
60
 
57
61
  async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
58
- const { taskId, contextId, userMessage, task } = ctx;
62
+ const { taskId, userMessage } = ctx;
59
63
  await this.initialize();
60
64
 
65
+ const reuseByContext = this.config.session?.reuseByContext ?? true;
66
+
67
+ let contextId = ctx.contextId;
68
+ if (!contextId) {
69
+ contextId = generateContextId();
70
+ log.info('Generated new contextId', { contextId, taskId });
71
+ }
72
+
73
+ const binding = reuseByContext
74
+ ? this.sessionStore.get(contextId, 'claude')
75
+ : null;
76
+
77
+ const sessionId = binding?.sessionId;
78
+
61
79
  try {
62
- if (!task) {
80
+ const existingTask = ctx.task;
81
+ if (!existingTask) {
63
82
  publishTask(bus, taskId, contextId);
64
83
  publishStatus(bus, taskId, contextId, 'submitted');
65
84
  }
66
85
 
67
- publishStatus(bus, taskId, contextId, 'working', 'Processing request...');
68
-
69
- const promptText = this.extractText(userMessage);
70
- log.info('Sending prompt to Claude Code', { taskId, len: promptText.length });
71
-
72
- const response = await this.client!.execute(promptText);
73
-
74
- const finalText = response || 'No response from Claude Code.';
75
- publishFinalArtifact(bus, taskId, contextId, finalText);
86
+ publishStatus(bus, taskId, contextId, 'working');
87
+
88
+ const promptText = extractText(userMessage);
89
+
90
+ // Extract per-request workingDirectory from A2A message metadata
91
+ const userMsg = userMessage as unknown as Record<string, unknown>;
92
+ const workingDirectory = userMsg.metadata &&
93
+ typeof userMsg.metadata === 'object'
94
+ ? (userMsg.metadata as Record<string, unknown>).workingDirectory as string | undefined
95
+ : undefined;
96
+
97
+ log.info('Sending prompt to Claude Code', {
98
+ taskId,
99
+ contextId,
100
+ sessionId: sessionId || '(new)',
101
+ len: promptText.length,
102
+ workingDirectory: workingDirectory || '(default)',
103
+ });
104
+
105
+ const result = await this.client!.execute(promptText, sessionId, workingDirectory);
106
+
107
+ this.sessionStore.set({
108
+ provider: 'claude',
109
+ contextId,
110
+ sessionId: result.sessionId,
111
+ activeTaskId: undefined,
112
+ activeTaskState: undefined,
113
+ createdAt: binding?.createdAt ?? new Date().toISOString(),
114
+ updatedAt: new Date().toISOString(),
115
+ expiresAt: new Date(Date.now() + (this.config.session?.ttl ?? 24 * 60 * 60 * 1000)).toISOString(),
116
+ });
117
+
118
+ publishFinalArtifact(bus, taskId, contextId, result.text || 'No response from Claude Code.');
76
119
  publishStatus(bus, taskId, contextId, 'completed', undefined, true);
77
120
  bus.finished();
78
- log.info('Task completed', { taskId, len: finalText.length });
121
+ log.info('Task completed', { taskId, contextId, sessionId: result.sessionId });
79
122
 
80
123
  } catch (error) {
81
124
  const msg = (error as Error).message ?? String(error);
82
- log.error('Execution failed', { taskId, error: msg });
125
+ log.error('Execution failed', { taskId, contextId, error: msg });
83
126
  publishStatus(bus, taskId, contextId, 'failed', `Error: ${msg}`, true);
84
127
  bus.finished();
85
128
  }
@@ -91,21 +134,13 @@ export class ClaudeExecutor implements A2AExecutor {
91
134
  publishStatus(bus, taskId, '', 'canceled', 'Claude Code task cancelled', true);
92
135
  bus.finished();
93
136
  }
94
-
95
- private extractText(message: A2AMessage): string {
96
- return message.parts
97
- .filter((p) => {
98
- const part = p as unknown as Record<string, unknown>;
99
- const text = part.text as string | undefined;
100
- return text !== undefined && text !== null;
101
- })
102
- .map((p) => (p as unknown as { text: string }).text)
103
- .join('\n');
104
- }
105
137
  }
106
138
 
107
139
  // ─── Factory ────────────────────────────────────────────────────────────────
108
140
 
109
- export function createClaudeExecutor(config: Required<AgentConfig>): A2AExecutor {
110
- return new ClaudeExecutor(config);
141
+ export function createClaudeExecutor(
142
+ config: Required<AgentConfig>,
143
+ sessionStore: SessionBindingStore,
144
+ ): A2AExecutor {
145
+ return new ClaudeExecutor(config, sessionStore);
111
146
  }
@@ -2,8 +2,9 @@
2
2
  * Codex CLI client wrapper.
3
3
  *
4
4
  * Spawns the OpenAI Codex CLI as a child process, sends prompts via stdin,
5
- * captures stdout as the response. Supports configurable CLI path, working
6
- * directory, model selection, and timeout.
5
+ * captures stdout as the response. Supports session persistence: on first
6
+ * call the thread_id is extracted from JSONL output, and subsequent calls
7
+ * use `codex exec resume <sessionId>` for multi-turn continuation.
7
8
  */
8
9
 
9
10
  import { spawn } from 'node:child_process';
@@ -26,6 +27,11 @@ export interface CodexClientConfig {
26
27
  timeout?: number;
27
28
  }
28
29
 
30
+ export interface CodexExecuteResult {
31
+ text: string;
32
+ sessionId: string;
33
+ }
34
+
29
35
  // ─── Client ─────────────────────────────────────────────────────────────────
30
36
 
31
37
  export class CodexClient {
@@ -46,9 +52,27 @@ export class CodexClient {
46
52
  }
47
53
 
48
54
  /**
49
- * Execute a prompt via the Codex CLI and return the response text.
55
+ * Execute a prompt via the Codex CLI.
56
+ *
57
+ * @param prompt - The user prompt text.
58
+ * @param sessionId - If provided, resumes an existing session.
59
+ * Otherwise creates a new session and extracts the thread_id from JSONL.
60
+ * @param workingDirectory - Optional per-request working directory override.
50
61
  */
51
- async execute(prompt: string): Promise<string> {
62
+ async execute(prompt: string, sessionId?: string, workingDirectory?: string): Promise<CodexExecuteResult> {
63
+ if (sessionId) {
64
+ return this.executeResume(prompt, sessionId, workingDirectory);
65
+ }
66
+ return this.executeNew(prompt, workingDirectory);
67
+ }
68
+
69
+ private resolveWorkdir(override?: string): string {
70
+ return override || this.config.workdir || process.cwd();
71
+ }
72
+
73
+ // ── New session (first turn) ──────────────────────────────────────────
74
+
75
+ private async executeNew(prompt: string, workingDirectory?: string): Promise<CodexExecuteResult> {
52
76
  const cliPath = this.config.cliPath || 'codex';
53
77
  const timeout = this.config.timeout ?? 300_000;
54
78
  const MAX_PROMPT = 100_000;
@@ -57,32 +81,130 @@ export class CodexClient {
57
81
  throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}). Split into smaller messages.`);
58
82
  }
59
83
 
60
- // Only pass relevant env vars to child process
61
84
  const env: Record<string, string> = {
62
85
  PATH: process.env.PATH ?? '',
63
86
  HOME: process.env.HOME ?? '',
64
87
  };
65
- // Forward common proxy/no-proxy settings
66
88
  for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
67
89
  if (process.env[key]) env[key] = process.env[key];
68
90
  }
91
+ if (this.config.apiKey) {
92
+ env['OPENAI_API_KEY'] = this.config.apiKey;
93
+ }
94
+
95
+ const args: string[] = ['exec', '--json'];
96
+ if (this.config.model) {
97
+ args.push('--model', this.config.model);
98
+ }
99
+ args.push(
100
+ '--dangerously-bypass-approvals-and-sandbox',
101
+ '--skip-git-repo-check',
102
+ prompt,
103
+ );
104
+
105
+ const workdir = this.resolveWorkdir(workingDirectory);
106
+ log.info('Spawning codex (new session)', { cliPath, workdir, model: this.config.model });
107
+
108
+ return new Promise<CodexExecuteResult>((resolve, reject) => {
109
+ const child = spawn(cliPath, args, {
110
+ cwd: workdir,
111
+ env,
112
+ stdio: ['ignore', 'pipe', 'pipe'],
113
+ });
114
+
115
+ this.currentChild = child;
116
+
117
+ let stdout = '';
118
+ let stderr = '';
119
+ const MAX_OUTPUT = 10_000_000;
120
+
121
+ const timer = setTimeout(() => {
122
+ child.kill('SIGTERM');
123
+ const forceTimer = setTimeout(() => {
124
+ if (child.exitCode === null) child.kill('SIGKILL');
125
+ }, 5000);
126
+ child.on('close', () => clearTimeout(forceTimer));
127
+ reject(new Error(`Codex execution timed out after ${timeout}ms`));
128
+ }, timeout);
129
+
130
+ child.stdout?.on('data', (chunk: Buffer) => {
131
+ stdout += chunk.toString();
132
+ if (stdout.length > MAX_OUTPUT) {
133
+ child.kill('SIGTERM');
134
+ setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); }, 5000);
135
+ }
136
+ });
137
+
138
+ child.stderr?.on('data', (chunk: Buffer) => {
139
+ stderr += chunk.toString();
140
+ log.debug('Codex stderr', { text: chunk.toString().trim() });
141
+ });
142
+
143
+ child.on('close', (code) => {
144
+ clearTimeout(timer);
145
+ this.currentChild = null;
146
+
147
+ if (code !== 0) {
148
+ const errMsg = stderr.trim() || stdout.trim() || `Codex exited with code ${code}`;
149
+ log.warn('Codex non-zero exit', { code, stderr: errMsg });
150
+ reject(new Error(errMsg));
151
+ return;
152
+ }
153
+
154
+ try {
155
+ const { text, sessionId } = this.parseJsonl(stdout);
156
+ resolve({ text, sessionId });
157
+ } catch (err) {
158
+ reject(new Error(`Failed to parse Codex JSONL output: ${(err as Error).message}`));
159
+ }
160
+ });
161
+
162
+ child.on('error', (err) => {
163
+ clearTimeout(timer);
164
+ log.error('Codex spawn failed', { error: err.message });
165
+ reject(new Error(`Failed to start codex: ${err.message}. Is it installed? (npm i -g @openai/codex)`));
166
+ });
167
+ });
168
+ }
169
+
170
+ // ── Resume session (subsequent turns) ─────────────────────────────────
171
+
172
+ private async executeResume(prompt: string, sessionId: string, workingDirectory?: string): Promise<CodexExecuteResult> {
173
+ const cliPath = this.config.cliPath || 'codex';
174
+ const timeout = this.config.timeout ?? 300_000;
175
+ const MAX_PROMPT = 100_000;
176
+
177
+ if (prompt.length > MAX_PROMPT) {
178
+ throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}). Split into smaller messages.`);
179
+ }
69
180
 
181
+ const env: Record<string, string> = {
182
+ PATH: process.env.PATH ?? '',
183
+ HOME: process.env.HOME ?? '',
184
+ };
185
+ for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
186
+ if (process.env[key]) env[key] = process.env[key];
187
+ }
70
188
  if (this.config.apiKey) {
71
189
  env['OPENAI_API_KEY'] = this.config.apiKey;
72
190
  }
73
191
 
74
- const args: string[] = [];
75
- // Some codex versions support --model flag
192
+ const args: string[] = ['exec', 'resume', sessionId];
76
193
  if (this.config.model) {
77
194
  args.push('--model', this.config.model);
78
195
  }
79
- args.push(prompt);
196
+ args.push(
197
+ '--dangerously-bypass-approvals-and-sandbox',
198
+ '--skip-git-repo-check',
199
+ prompt,
200
+ );
80
201
 
81
- log.info('Spawning codex', { cliPath, workdir: this.config.workdir, model: this.config.model });
202
+ const workdir = this.resolveWorkdir(workingDirectory);
203
+ log.info('Spawning codex (resume)', { sessionId, workdir });
82
204
 
83
- return new Promise<string>((resolve, reject) => {
205
+ return new Promise<CodexExecuteResult>((resolve, reject) => {
84
206
  const child = spawn(cliPath, args, {
85
- cwd: this.config.workdir || process.cwd(),
207
+ cwd: workdir,
86
208
  env,
87
209
  stdio: ['ignore', 'pipe', 'pipe'],
88
210
  });
@@ -95,7 +217,6 @@ export class CodexClient {
95
217
 
96
218
  const timer = setTimeout(() => {
97
219
  child.kill('SIGTERM');
98
- // Force kill after 5s grace period
99
220
  const forceTimer = setTimeout(() => {
100
221
  if (child.exitCode === null) child.kill('SIGKILL');
101
222
  }, 5000);
@@ -120,13 +241,27 @@ export class CodexClient {
120
241
  clearTimeout(timer);
121
242
  this.currentChild = null;
122
243
 
123
- if (code === 0) {
124
- resolve(stdout.trim());
125
- } else {
244
+ if (code !== 0) {
126
245
  const errMsg = stderr.trim() || stdout.trim() || `Codex exited with code ${code}`;
127
246
  log.warn('Codex non-zero exit', { code, stderr: errMsg });
128
247
  reject(new Error(errMsg));
248
+ return;
129
249
  }
250
+
251
+ // Resume output: the CLI prints session metadata header + agent response.
252
+ // Strip the header lines (first 2+ lines before the actual output).
253
+ // The header looks like:
254
+ // OpenAI Codex v0.142.0
255
+ // --------
256
+ // workdir: ...
257
+ // ...
258
+ // --------
259
+ // user
260
+ // ...
261
+ // codex
262
+ // <actual response>
263
+ const text = this.extractResumeText(stdout);
264
+ resolve({ text, sessionId });
130
265
  });
131
266
 
132
267
  child.on('error', (err) => {
@@ -136,4 +271,92 @@ export class CodexClient {
136
271
  });
137
272
  });
138
273
  }
274
+
275
+ // ── Parsers ───────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * Parse JSONL output to extract session thread_id and agent response text.
279
+ */
280
+ private parseJsonl(stdout: string): { text: string; sessionId: string } {
281
+ const lines = stdout.split('\n').filter((l) => l.trim());
282
+ let sessionId = '';
283
+ const textParts: string[] = [];
284
+
285
+ for (const line of lines) {
286
+ let event: Record<string, unknown>;
287
+ try {
288
+ event = JSON.parse(line);
289
+ } catch {
290
+ continue;
291
+ }
292
+
293
+ // Extract thread_id from thread.started event
294
+ if (event.type === 'thread.started' && event.thread_id) {
295
+ sessionId = event.thread_id as string;
296
+ }
297
+
298
+ // Extract text from agent messages in item.completed events
299
+ if (event.type === 'item.completed') {
300
+ const item = event.item as Record<string, unknown> | undefined;
301
+ if (item && item.type === 'agent_message' && item.text) {
302
+ textParts.push(item.text as string);
303
+ }
304
+ }
305
+ }
306
+
307
+ if (!sessionId) {
308
+ throw new Error('No thread_id found in Codex JSONL output');
309
+ }
310
+
311
+ return {
312
+ text: textParts.join('\n').trim() || 'No response from Codex.',
313
+ sessionId,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Extract the agent response text from the resume mode output.
319
+ *
320
+ * The resume output includes a CLI header followed by the conversation.
321
+ * We extract everything after the last "codex" marker line as the
322
+ * latest agent response.
323
+ */
324
+ private extractResumeText(stdout: string): string {
325
+ const lines = stdout.split('\n');
326
+
327
+ // Find the separator: the last "codex" line marks the start of the response
328
+ let responseStart = -1;
329
+ for (let i = 0; i < lines.length; i++) {
330
+ if (lines[i].trim() === 'codex') {
331
+ responseStart = i;
332
+ }
333
+ }
334
+
335
+ if (responseStart >= 0 && responseStart + 1 < lines.length) {
336
+ return lines.slice(responseStart + 1).join('\n').trim();
337
+ }
338
+
339
+ // Fallback: strip known header lines
340
+ let skipHeader = true;
341
+ const resultLines: string[] = [];
342
+ for (const line of lines) {
343
+ const trimmed = line.trim();
344
+ if (skipHeader) {
345
+ if (trimmed === '--------' || trimmed === 'codex') {
346
+ skipHeader = false;
347
+ }
348
+ continue;
349
+ }
350
+ if (trimmed === 'user' || trimmed === 'codex') {
351
+ continue;
352
+ }
353
+ if (trimmed.startsWith('tokens used')) {
354
+ if (resultLines.length > 0) resultLines.pop();
355
+ break;
356
+ }
357
+ resultLines.push(line);
358
+ }
359
+
360
+ return resultLines.join('\n').trim() || stdout.trim();
361
+ }
139
362
  }