@axiom-lattice/cli-a2a 0.1.5 → 0.1.7

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 +27 -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
@@ -2,20 +2,23 @@
2
2
  * Codex A2A Executor
3
3
  *
4
4
  * Bridges the local Codex CLI to the A2A protocol. Spawns Codex as a
5
- * child process for each request and captures stdout as the response.
5
+ * child process for each request. Supports multi-turn conversation via
6
+ * contextId → sessionId binding through SessionBindingStore.
6
7
  */
7
8
 
8
9
  import type { A2AExecutor } from '../index.js';
9
10
  import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
10
- import type { Message as A2AMessage } from '@a2a-js/sdk';
11
11
 
12
12
  import type { AgentConfig } from '../../config/types.js';
13
+ import type { SessionBindingStore } from '../../session-store.js';
14
+ import { generateContextId } from '../../session-store.js';
13
15
  import { logger } from '../../logger.js';
14
16
  import { CodexClient } from './client.js';
15
17
  import {
16
18
  publishTask,
17
19
  publishStatus,
18
20
  publishFinalArtifact,
21
+ extractText,
19
22
  } from '../events.js';
20
23
 
21
24
  const log = logger.child('codex:executor');
@@ -26,9 +29,11 @@ export class CodexExecutor implements A2AExecutor {
26
29
  private config: Required<AgentConfig>;
27
30
  private client: CodexClient | null = null;
28
31
  private initialized = false;
32
+ private sessionStore: SessionBindingStore;
29
33
 
30
- constructor(config: Required<AgentConfig>) {
34
+ constructor(config: Required<AgentConfig>, sessionStore: SessionBindingStore) {
31
35
  this.config = config;
36
+ this.sessionStore = sessionStore;
32
37
  }
33
38
 
34
39
  async initialize(): Promise<void> {
@@ -54,37 +59,75 @@ export class CodexExecutor implements A2AExecutor {
54
59
  }
55
60
 
56
61
  async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
57
- const { taskId, contextId, userMessage, task } = ctx;
62
+ const { taskId, userMessage } = ctx;
58
63
  await this.initialize();
59
64
 
60
- try {
61
- // 1. Register task
62
- if (!task) {
63
- publishTask(bus, taskId, contextId);
64
- publishStatus(bus, taskId, contextId, 'submitted');
65
- }
65
+ const reuseByContext = this.config.session?.reuseByContext ?? true;
66
66
 
67
- // 2. Working
68
- publishStatus(bus, taskId, contextId, 'working', 'Processing request...');
67
+ // Resolve contextId: use provided one or generate new
68
+ let contextId = ctx.contextId;
69
+ if (!contextId) {
70
+ contextId = generateContextId();
71
+ log.info('Generated new contextId', { contextId, taskId });
72
+ }
69
73
 
70
- // 3. Extract prompt text
71
- const promptText = this.extractText(userMessage);
74
+ // Look up existing session binding
75
+ const binding = reuseByContext
76
+ ? this.sessionStore.get(contextId, 'codex')
77
+ : null;
72
78
 
73
- log.info('Sending prompt to Codex', { taskId, len: promptText.length });
79
+ const sessionId = binding?.sessionId;
74
80
 
75
- // 4. Execute
76
- const response = await this.client!.execute(promptText);
81
+ try {
82
+ // Register task (always include contextId so client can track)
83
+ const existingTask = ctx.task;
84
+ if (!existingTask) {
85
+ publishTask(bus, taskId, contextId);
86
+ publishStatus(bus, taskId, contextId, 'submitted');
87
+ }
77
88
 
78
- // 5. Finalize
79
- const finalText = response || 'No response from Codex.';
80
- publishFinalArtifact(bus, taskId, contextId, finalText);
89
+ publishStatus(bus, taskId, contextId, 'working');
90
+
91
+ const promptText = extractText(userMessage);
92
+
93
+ // Extract per-request workingDirectory from A2A message metadata
94
+ const userMsg = userMessage as unknown as Record<string, unknown>;
95
+ const workingDirectory = userMsg.metadata &&
96
+ typeof userMsg.metadata === 'object'
97
+ ? (userMsg.metadata as Record<string, unknown>).workingDirectory as string | undefined
98
+ : undefined;
99
+
100
+ log.info('Sending prompt to Codex', {
101
+ taskId,
102
+ contextId,
103
+ sessionId: sessionId || '(new)',
104
+ len: promptText.length,
105
+ workingDirectory: workingDirectory || '(default)',
106
+ });
107
+
108
+ const result = await this.client!.execute(promptText, sessionId, workingDirectory);
109
+
110
+ // Save or update session binding
111
+ this.sessionStore.set({
112
+ provider: 'codex',
113
+ contextId,
114
+ sessionId: result.sessionId,
115
+ activeTaskId: undefined,
116
+ activeTaskState: undefined,
117
+ createdAt: binding?.createdAt ?? new Date().toISOString(),
118
+ updatedAt: new Date().toISOString(),
119
+ expiresAt: new Date(Date.now() + (this.config.session?.ttl ?? 24 * 60 * 60 * 1000)).toISOString(),
120
+ });
121
+
122
+ // Finalize
123
+ publishFinalArtifact(bus, taskId, contextId, result.text || 'No response from Codex.');
81
124
  publishStatus(bus, taskId, contextId, 'completed', undefined, true);
82
125
  bus.finished();
83
- log.info('Task completed', { taskId, len: finalText.length });
126
+ log.info('Task completed', { taskId, contextId, sessionId: result.sessionId });
84
127
 
85
128
  } catch (error) {
86
129
  const msg = (error as Error).message ?? String(error);
87
- log.error('Execution failed', { taskId, error: msg });
130
+ log.error('Execution failed', { taskId, contextId, error: msg });
88
131
  publishStatus(bus, taskId, contextId, 'failed', `Error: ${msg}`, true);
89
132
  bus.finished();
90
133
  }
@@ -96,23 +139,13 @@ export class CodexExecutor implements A2AExecutor {
96
139
  publishStatus(bus, taskId, '', 'canceled', 'Codex task cancelled', true);
97
140
  bus.finished();
98
141
  }
99
-
100
- // ── Helpers ─────────────────────────────────────────────────────────────
101
-
102
- private extractText(message: A2AMessage): string {
103
- return message.parts
104
- .filter((p) => {
105
- const part = p as unknown as Record<string, unknown>;
106
- const text = part.text as string | undefined;
107
- return text !== undefined && text !== null;
108
- })
109
- .map((p) => (p as unknown as { text: string }).text)
110
- .join('\n');
111
- }
112
142
  }
113
143
 
114
144
  // ─── Factory ────────────────────────────────────────────────────────────────
115
145
 
116
- export function createCodexExecutor(config: Required<AgentConfig>): A2AExecutor {
117
- return new CodexExecutor(config);
146
+ export function createCodexExecutor(
147
+ config: Required<AgentConfig>,
148
+ sessionStore: SessionBindingStore,
149
+ ): A2AExecutor {
150
+ return new CodexExecutor(config, sessionStore);
118
151
  }
@@ -6,10 +6,24 @@
6
6
  */
7
7
 
8
8
  import { TaskState } from '@a2a-js/sdk';
9
- import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from '@a2a-js/sdk';
9
+ import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, Message as A2AMessage } from '@a2a-js/sdk';
10
10
  import type { ExecutionEventBus } from '@a2a-js/sdk/server';
11
11
  import { v4 as uuidv4 } from 'uuid';
12
12
 
13
+ /**
14
+ * Extract plain text content from an A2A message's parts.
15
+ */
16
+ export function extractText(message: A2AMessage): string {
17
+ return message.parts
18
+ .filter((p) => {
19
+ const part = p as unknown as Record<string, unknown>;
20
+ const text = part.text as string | undefined;
21
+ return text !== undefined && text !== null;
22
+ })
23
+ .map((p) => (p as unknown as { text: string }).text)
24
+ .join('\n');
25
+ }
26
+
13
27
  /**
14
28
  * Register a task with the execution event bus before publishing any events.
15
29
  */
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import type { AgentExecutor } from '@a2a-js/sdk/server';
10
+ import type { SessionBindingStore } from '../session-store.js';
11
+ import type { AgentConfig, ProviderType } from '../config/types.js';
10
12
 
11
13
  /**
12
14
  * Extended executor interface with lifecycle methods.
@@ -24,12 +26,14 @@ export type { AgentExecutor, RequestContext, ExecutionEventBus } from '@a2a-js/s
24
26
  export { TaskState } from '@a2a-js/sdk';
25
27
  export { v4 as uuidv4 } from 'uuid';
26
28
 
27
- import type { AgentConfig, ProviderType } from '../config/types.js';
28
-
29
29
  /**
30
- * Factory function type: given resolved config, produce an executor.
30
+ * Factory function type: given resolved config and a session store,
31
+ * produce an executor.
31
32
  */
32
- export type ExecutorFactory = (config: Required<AgentConfig>) => A2AExecutor;
33
+ export type ExecutorFactory = (
34
+ config: Required<AgentConfig>,
35
+ sessionStore: SessionBindingStore,
36
+ ) => A2AExecutor;
33
37
 
34
38
  /**
35
39
  * Map provider type to executor factory.
@@ -44,11 +48,14 @@ export function getExecutor(provider: ProviderType): ExecutorFactory | undefined
44
48
  return registry.get(provider);
45
49
  }
46
50
 
47
- export function createExecutor(config: Required<AgentConfig>): A2AExecutor {
51
+ export function createExecutor(
52
+ config: Required<AgentConfig>,
53
+ sessionStore: SessionBindingStore,
54
+ ): A2AExecutor {
48
55
  const factory = registry.get(config.provider);
49
56
  if (!factory) {
50
57
  const available = Array.from(registry.keys()).join(', ');
51
58
  throw new Error(`Unknown provider: ${config.provider}. Available: ${available}`);
52
59
  }
53
- return factory(config);
60
+ return factory(config, sessionStore);
54
61
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * OpenCode CLI client wrapper.
3
3
  *
4
- * Spawns the OpenCode CLI as a child process. Uses `opencode run` with
5
- * optional `--attach` to connect to an existing server. Captures stdout
6
- * as the response. Same pattern as Codex and Claude Code clients.
4
+ * Spawns the OpenCode CLI as a child process. For the first turn, uses
5
+ * `--format json` to discover the session ID, then extracts the response
6
+ * text. For subsequent turns, uses `--session <id>` to continue the
7
+ * conversation with plain text output.
7
8
  */
8
9
 
9
10
  import { spawn } from 'node:child_process';
@@ -28,6 +29,11 @@ export interface OpenCodeClientConfig {
28
29
  timeout?: number;
29
30
  }
30
31
 
32
+ export interface OpenCodeExecuteResult {
33
+ text: string;
34
+ sessionId: string;
35
+ }
36
+
31
37
  // ─── Client ─────────────────────────────────────────────────────────────────
32
38
 
33
39
  export class OpenCodeClient {
@@ -35,7 +41,6 @@ export class OpenCodeClient {
35
41
 
36
42
  constructor(private config: OpenCodeClientConfig) {}
37
43
 
38
- /** Abort the currently running child process (if any). */
39
44
  abort(): void {
40
45
  if (this.currentChild) {
41
46
  this.currentChild.kill('SIGTERM');
@@ -49,18 +54,38 @@ export class OpenCodeClient {
49
54
  }
50
55
 
51
56
  /**
52
- * Execute a prompt via the OpenCode CLI and return the response text.
57
+ * Execute a prompt via the OpenCode CLI.
58
+ *
59
+ * @param prompt - The user prompt text.
60
+ * @param sessionId - If provided, continues the session with `--session`.
61
+ * Otherwise creates a new session using `--format json` to capture the
62
+ * session ID.
63
+ * @param workingDirectory - Optional per-request working directory override.
64
+ * Falls back to config.workdir if not provided.
53
65
  */
54
- async execute(prompt: string): Promise<string> {
66
+ async execute(prompt: string, sessionId?: string, workingDirectory?: string): Promise<OpenCodeExecuteResult> {
67
+ if (sessionId) {
68
+ const text = await this.runResume(prompt, sessionId, workingDirectory);
69
+ return { text, sessionId };
70
+ }
71
+ return this.runNew(prompt, workingDirectory);
72
+ }
73
+
74
+ private resolveWorkdir(override?: string): string {
75
+ return override || this.config.workdir || process.cwd();
76
+ }
77
+
78
+ // ── New session (first turn) ──────────────────────────────────────────
79
+
80
+ private async runNew(prompt: string, workingDirectory?: string): Promise<OpenCodeExecuteResult> {
55
81
  const cliPath = this.config.cliPath || 'opencode';
56
82
  const timeout = this.config.timeout ?? 600_000;
57
- const MAX_PROMPT = 100_000; // safe below typical ARG_MAX
83
+ const MAX_PROMPT = 100_000;
58
84
 
59
85
  if (prompt.length > MAX_PROMPT) {
60
- throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}). Split into smaller messages or use a provider with HTTP API transport.`);
86
+ throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}).`);
61
87
  }
62
88
 
63
- // Only pass essential env vars
64
89
  const env: Record<string, string> = {
65
90
  PATH: process.env.PATH ?? '',
66
91
  HOME: process.env.HOME ?? '',
@@ -68,13 +93,110 @@ export class OpenCodeClient {
68
93
  for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
69
94
  if (process.env[key]) env[key] = process.env[key];
70
95
  }
71
- // Forward auth env vars if present
72
96
  for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME']) {
73
97
  if (process.env[key]) env[key] = process.env[key];
74
98
  }
75
99
 
76
- const args: string[] = ['run', prompt];
100
+ const args: string[] = ['run', '--format', 'json'];
101
+ if (this.config.model) {
102
+ args.push('--model', this.config.model);
103
+ }
104
+ if (this.config.agent) {
105
+ args.push('--agent', this.config.agent);
106
+ }
107
+ if (this.config.attachUrl) {
108
+ args.push('--attach', this.config.attachUrl);
109
+ }
110
+ args.push('--dangerously-skip-permissions', prompt);
111
+
112
+ const workdir = this.resolveWorkdir(workingDirectory);
113
+ log.info('Spawning opencode (new session, json)', { cliPath, workdir });
114
+
115
+ return new Promise<OpenCodeExecuteResult>((resolve, reject) => {
116
+ const child = spawn(cliPath, args, {
117
+ cwd: workdir,
118
+ env,
119
+ stdio: ['ignore', 'pipe', 'pipe'],
120
+ });
121
+
122
+ this.currentChild = child;
123
+
124
+ let stdout = '';
125
+ let stderr = '';
126
+ const MAX_OUTPUT = 10_000_000;
127
+
128
+ const timer = setTimeout(() => {
129
+ child.kill('SIGTERM');
130
+ const forceTimer = setTimeout(() => {
131
+ if (child.exitCode === null) child.kill('SIGKILL');
132
+ }, 5000);
133
+ child.on('close', () => clearTimeout(forceTimer));
134
+ reject(new Error(`OpenCode execution timed out after ${timeout}ms`));
135
+ }, timeout);
136
+
137
+ child.stdout?.on('data', (chunk: Buffer) => {
138
+ stdout += chunk.toString();
139
+ if (stdout.length > MAX_OUTPUT) {
140
+ child.kill('SIGTERM');
141
+ setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); }, 5000);
142
+ }
143
+ });
77
144
 
145
+ child.stderr?.on('data', (chunk: Buffer) => {
146
+ stderr += chunk.toString();
147
+ log.debug('OpenCode stderr', { text: chunk.toString().trim() });
148
+ });
149
+
150
+ child.on('close', (code) => {
151
+ clearTimeout(timer);
152
+ this.currentChild = null;
153
+
154
+ if (code !== 0) {
155
+ const errMsg = stderr.trim() || stdout.trim() || `OpenCode exited with code ${code}`;
156
+ log.warn('OpenCode non-zero exit', { code, stderr: errMsg });
157
+ reject(new Error(errMsg));
158
+ return;
159
+ }
160
+
161
+ try {
162
+ const { text, sessionId } = this.parseJsonEvents(stdout);
163
+ resolve({ text, sessionId });
164
+ } catch (err) {
165
+ reject(new Error(`Failed to parse OpenCode JSON output: ${(err as Error).message}`));
166
+ }
167
+ });
168
+
169
+ child.on('error', (err) => {
170
+ clearTimeout(timer);
171
+ log.error('OpenCode spawn failed', { error: err.message });
172
+ reject(new Error(`Failed to start opencode: ${err.message}. Is it installed?`));
173
+ });
174
+ });
175
+ }
176
+
177
+ // ── Resume session ────────────────────────────────────────────────────
178
+
179
+ private async runResume(prompt: string, sessionId: string, workingDirectory?: string): Promise<string> {
180
+ const cliPath = this.config.cliPath || 'opencode';
181
+ const timeout = this.config.timeout ?? 600_000;
182
+ const MAX_PROMPT = 100_000;
183
+
184
+ if (prompt.length > MAX_PROMPT) {
185
+ throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}).`);
186
+ }
187
+
188
+ const env: Record<string, string> = {
189
+ PATH: process.env.PATH ?? '',
190
+ HOME: process.env.HOME ?? '',
191
+ };
192
+ for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
193
+ if (process.env[key]) env[key] = process.env[key];
194
+ }
195
+ for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME']) {
196
+ if (process.env[key]) env[key] = process.env[key];
197
+ }
198
+
199
+ const args: string[] = ['run', '--session', sessionId];
78
200
  if (this.config.model) {
79
201
  args.push('--model', this.config.model);
80
202
  }
@@ -84,16 +206,14 @@ export class OpenCodeClient {
84
206
  if (this.config.attachUrl) {
85
207
  args.push('--attach', this.config.attachUrl);
86
208
  }
87
- // Default format captures plain text output
88
- args.push('--format', 'default');
89
- // Never go interactive
90
- args.push('--dangerously-skip-permissions');
209
+ args.push('--format', 'default', '--dangerously-skip-permissions', prompt);
91
210
 
92
- log.info('Spawning opencode', { cliPath, workdir: this.config.workdir, model: this.config.model });
211
+ const workdir = this.resolveWorkdir(workingDirectory);
212
+ log.info('Spawning opencode (resume)', { sessionId, workdir });
93
213
 
94
214
  return new Promise<string>((resolve, reject) => {
95
215
  const child = spawn(cliPath, args, {
96
- cwd: this.config.workdir || process.cwd(),
216
+ cwd: workdir,
97
217
  env,
98
218
  stdio: ['ignore', 'pipe', 'pipe'],
99
219
  });
@@ -102,7 +222,7 @@ export class OpenCodeClient {
102
222
 
103
223
  let stdout = '';
104
224
  let stderr = '';
105
- const MAX_OUTPUT = 10_000_000; // 10MB
225
+ const MAX_OUTPUT = 10_000_000;
106
226
 
107
227
  const timer = setTimeout(() => {
108
228
  child.kill('SIGTERM');
@@ -146,4 +266,58 @@ export class OpenCodeClient {
146
266
  });
147
267
  });
148
268
  }
269
+
270
+ // ── Parser ────────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Parse newline-delimited JSON events from OpenCode's `--format json` output.
274
+ * Extracts the sessionID from the first event that contains it, and
275
+ * accumulates text content from relevant events.
276
+ */
277
+ private parseJsonEvents(stdout: string): { text: string; sessionId: string } {
278
+ const lines = stdout.split('\n').filter((l) => l.trim());
279
+ let sessionId = '';
280
+ const textParts: string[] = [];
281
+
282
+ for (const line of lines) {
283
+ let event: Record<string, unknown>;
284
+ try {
285
+ event = JSON.parse(line);
286
+ } catch {
287
+ continue;
288
+ }
289
+
290
+ // Capture sessionID from any event that has it
291
+ if (!sessionId && event.sessionID) {
292
+ sessionId = event.sessionID as string;
293
+ }
294
+
295
+ // Skip error events — they don't contain user-facing text
296
+ if (event.type === 'error') continue;
297
+
298
+ // Extract text from events that have content
299
+ // Common OpenCode event shapes: { type, text }, { type, content }, { message }
300
+ const text = event.text as string | undefined
301
+ || event.content as string | undefined
302
+ || event.message as string | undefined;
303
+
304
+ if (text) {
305
+ textParts.push(text);
306
+ }
307
+ }
308
+
309
+ if (!sessionId) {
310
+ // Fallback: if no sessionID in JSON, the output may be plain text from
311
+ // a non-JSON mode invocation. Return the raw text.
312
+ return {
313
+ text: textParts.join('\n').trim() || stdout.trim(),
314
+ sessionId: '',
315
+ };
316
+ }
317
+
318
+ return {
319
+ text: textParts.join('\n').trim() || 'No response from OpenCode.',
320
+ sessionId,
321
+ };
322
+ }
149
323
  }
@@ -2,21 +2,23 @@
2
2
  * OpenCode A2A Executor
3
3
  *
4
4
  * Bridges the local OpenCode CLI to the A2A protocol. Spawns `opencode run`
5
- * as a child process for each request and captures stdout as the response.
6
- * Same spawn pattern as Codex and Claude Code executors.
5
+ * as a child process for each request. Supports multi-turn conversation
6
+ * 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 { OpenCodeClient } 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('opencode:executor');
@@ -27,9 +29,11 @@ export class OpenCodeExecutor implements A2AExecutor {
27
29
  private config: Required<AgentConfig>;
28
30
  private client: OpenCodeClient | 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> {
@@ -56,31 +60,70 @@ export class OpenCodeExecutor implements A2AExecutor {
56
60
  }
57
61
 
58
62
  async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
59
- const { taskId, contextId, userMessage, task } = ctx;
63
+ const { taskId, userMessage } = ctx;
60
64
  await this.initialize();
61
65
 
66
+ const reuseByContext = this.config.session?.reuseByContext ?? true;
67
+
68
+ let contextId = ctx.contextId;
69
+ if (!contextId) {
70
+ contextId = generateContextId();
71
+ log.info('Generated new contextId', { contextId, taskId });
72
+ }
73
+
74
+ const binding = reuseByContext
75
+ ? this.sessionStore.get(contextId, 'opencode')
76
+ : null;
77
+
78
+ const sessionId = binding?.sessionId;
79
+
62
80
  try {
63
- if (!task) {
81
+ const existingTask = ctx.task;
82
+ if (!existingTask) {
64
83
  publishTask(bus, taskId, contextId);
65
84
  publishStatus(bus, taskId, contextId, 'submitted');
66
85
  }
67
86
 
68
- publishStatus(bus, taskId, contextId, 'working', 'Processing request...');
69
-
70
- const promptText = this.extractText(userMessage);
71
- log.info('Sending prompt to OpenCode', { taskId, len: promptText.length });
72
-
73
- const response = await this.client!.execute(promptText);
74
-
75
- const finalText = response || 'No response from OpenCode.';
76
- publishFinalArtifact(bus, taskId, contextId, finalText);
87
+ publishStatus(bus, taskId, contextId, 'working');
88
+
89
+ const promptText = extractText(userMessage);
90
+
91
+ // Extract per-request workingDirectory from A2A message metadata
92
+ const userMsg = userMessage as unknown as Record<string, unknown>;
93
+ const workingDirectory = userMsg.metadata &&
94
+ typeof userMsg.metadata === 'object'
95
+ ? (userMsg.metadata as Record<string, unknown>).workingDirectory as string | undefined
96
+ : undefined;
97
+
98
+ log.info('Sending prompt to OpenCode', {
99
+ taskId,
100
+ contextId,
101
+ sessionId: sessionId || '(new)',
102
+ len: promptText.length,
103
+ workingDirectory: workingDirectory || '(default)',
104
+ });
105
+
106
+ const result = await this.client!.execute(promptText, sessionId, workingDirectory);
107
+
108
+ this.sessionStore.set({
109
+ provider: 'opencode',
110
+ contextId,
111
+ sessionId: result.sessionId,
112
+ activeTaskId: undefined,
113
+ activeTaskState: undefined,
114
+ createdAt: binding?.createdAt ?? new Date().toISOString(),
115
+ updatedAt: new Date().toISOString(),
116
+ expiresAt: new Date(Date.now() + (this.config.session?.ttl ?? 24 * 60 * 60 * 1000)).toISOString(),
117
+ });
118
+
119
+ publishFinalArtifact(bus, taskId, contextId, result.text || 'No response from OpenCode.');
77
120
  publishStatus(bus, taskId, contextId, 'completed', undefined, true);
78
121
  bus.finished();
79
- log.info('Task completed', { taskId, len: finalText.length });
122
+ log.info('Task completed', { taskId, contextId, sessionId: result.sessionId });
80
123
 
81
124
  } catch (error) {
82
125
  const msg = (error as Error).message ?? String(error);
83
- log.error('Execution failed', { taskId, error: msg });
126
+ log.error('Execution failed', { taskId, contextId, error: msg });
84
127
  publishStatus(bus, taskId, contextId, 'failed', `Error: ${msg}`, true);
85
128
  bus.finished();
86
129
  }
@@ -92,21 +135,13 @@ export class OpenCodeExecutor implements A2AExecutor {
92
135
  publishStatus(bus, taskId, '', 'canceled', 'OpenCode task cancelled', true);
93
136
  bus.finished();
94
137
  }
95
-
96
- private extractText(message: A2AMessage): string {
97
- return message.parts
98
- .filter((p) => {
99
- const part = p as unknown as Record<string, unknown>;
100
- const text = part.text as string | undefined;
101
- return text !== undefined && text !== null;
102
- })
103
- .map((p) => (p as unknown as { text: string }).text)
104
- .join('\n');
105
- }
106
138
  }
107
139
 
108
140
  // ─── Factory ────────────────────────────────────────────────────────────────
109
141
 
110
- export function createOpenCodeExecutor(config: Required<AgentConfig>): A2AExecutor {
111
- return new OpenCodeExecutor(config);
142
+ export function createOpenCodeExecutor(
143
+ config: Required<AgentConfig>,
144
+ sessionStore: SessionBindingStore,
145
+ ): A2AExecutor {
146
+ return new OpenCodeExecutor(config, sessionStore);
112
147
  }
package/src/index.ts CHANGED
@@ -52,3 +52,11 @@ export type { AgentExecutor, RequestContext, ExecutionEventBus } from './executo
52
52
 
53
53
  // Logger
54
54
  export { logger, LogLevel } from './logger.js';
55
+
56
+ // Session Store
57
+ export {
58
+ SessionBindingStore,
59
+ generateContextId,
60
+ resolveSessionStorePath,
61
+ } from './session-store.js';
62
+ export type { SessionBinding } from './session-store.js';