@pixelbyte-software/pixcode 1.33.10 → 1.34.0

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 (75) hide show
  1. package/dist/api-docs.html +395 -395
  2. package/dist/assets/{index-B_dU5AHA.js → index-BvClqlMf.js} +134 -134
  3. package/dist/favicon.svg +8 -8
  4. package/dist/icons/icon-128x128.svg +9 -9
  5. package/dist/icons/icon-144x144.svg +9 -9
  6. package/dist/icons/icon-152x152.svg +9 -9
  7. package/dist/icons/icon-192x192.svg +9 -9
  8. package/dist/icons/icon-384x384.svg +9 -9
  9. package/dist/icons/icon-512x512.svg +9 -9
  10. package/dist/icons/icon-72x72.svg +9 -9
  11. package/dist/icons/icon-96x96.svg +9 -9
  12. package/dist/icons/icon-template.svg +9 -9
  13. package/dist/index.html +1 -1
  14. package/dist/logo.svg +12 -12
  15. package/dist/openapi.yaml +1311 -1311
  16. package/dist-server/server/index.js +4 -0
  17. package/dist-server/server/index.js.map +1 -1
  18. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js +47 -0
  19. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js.map +1 -0
  20. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js +17 -0
  21. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js.map +1 -0
  22. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js +233 -0
  23. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js.map +1 -0
  24. package/dist-server/server/modules/orchestration/a2a/agent-card.js +50 -0
  25. package/dist-server/server/modules/orchestration/a2a/agent-card.js.map +1 -0
  26. package/dist-server/server/modules/orchestration/a2a/auth.middleware.js +25 -0
  27. package/dist-server/server/modules/orchestration/a2a/auth.middleware.js.map +1 -0
  28. package/dist-server/server/modules/orchestration/a2a/bus.js +34 -0
  29. package/dist-server/server/modules/orchestration/a2a/bus.js.map +1 -0
  30. package/dist-server/server/modules/orchestration/a2a/routes.js +233 -0
  31. package/dist-server/server/modules/orchestration/a2a/routes.js.map +1 -0
  32. package/dist-server/server/modules/orchestration/a2a/types.js +6 -0
  33. package/dist-server/server/modules/orchestration/a2a/types.js.map +1 -0
  34. package/dist-server/server/modules/orchestration/a2a/validator.js +85 -0
  35. package/dist-server/server/modules/orchestration/a2a/validator.js.map +1 -0
  36. package/dist-server/server/modules/orchestration/index.js +10 -0
  37. package/dist-server/server/modules/orchestration/index.js.map +1 -0
  38. package/dist-server/server/opencode-cli.js +4 -1
  39. package/dist-server/server/opencode-cli.js.map +1 -1
  40. package/package.json +178 -178
  41. package/scripts/smoke/a2a-roundtrip.mjs +98 -0
  42. package/server/database/db.js +794 -794
  43. package/server/database/json-store.js +194 -194
  44. package/server/index.js +9 -0
  45. package/server/modules/orchestration/a2a/adapter-registry.ts +58 -0
  46. package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +49 -0
  47. package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +283 -0
  48. package/server/modules/orchestration/a2a/agent-card.ts +55 -0
  49. package/server/modules/orchestration/a2a/auth.middleware.ts +29 -0
  50. package/server/modules/orchestration/a2a/bus.ts +46 -0
  51. package/server/modules/orchestration/a2a/routes.ts +264 -0
  52. package/server/modules/orchestration/a2a/types.ts +111 -0
  53. package/server/modules/orchestration/a2a/validator.ts +90 -0
  54. package/server/modules/orchestration/index.ts +26 -0
  55. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +130 -130
  56. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +126 -126
  57. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +232 -232
  58. package/server/modules/providers/list/opencode/opencode.provider.ts +29 -29
  59. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +145 -145
  60. package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -114
  61. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
  62. package/server/modules/providers/list/qwen/qwen.provider.ts +21 -21
  63. package/server/modules/providers/shared/provider-configs.ts +142 -142
  64. package/server/opencode-cli.js +4 -1
  65. package/server/opencode-response-handler.js +107 -107
  66. package/server/qwen-code-cli.js +395 -395
  67. package/server/qwen-response-handler.js +73 -73
  68. package/server/routes/qwen.js +27 -27
  69. package/server/services/external-access.js +171 -171
  70. package/server/services/provider-credentials.js +189 -189
  71. package/server/services/provider-models.js +381 -381
  72. package/server/services/telegram/telegram-http-client.js +130 -130
  73. package/server/services/vapid-keys.js +36 -36
  74. package/server/utils/port-access.js +209 -209
  75. package/scripts/rest-sweep.mjs +0 -93
@@ -0,0 +1,283 @@
1
+ // server/modules/orchestration/a2a/adapters/claude-code.adapter.ts
2
+ // Wraps the existing server/claude-sdk.js queryClaudeSDK() function.
3
+ // claude-sdk.js was designed to stream SDK messages over a WebSocket
4
+ // connection, so we feed it a "fake WS" that captures send() calls and
5
+ // emits A2A bus events instead.
6
+ //
7
+ // IMPORTANT: claude-sdk.js calls ws.send(<NormalizedMessage object>) — it
8
+ // does NOT JSON.stringify before send. Our shim therefore receives objects
9
+ // (not strings) and dispatches on `frame.kind` (not `frame.type`). See
10
+ // server/shared/types.ts for the MessageKind enum.
11
+
12
+ import crypto from 'node:crypto';
13
+
14
+ // eslint-disable-next-line boundaries/no-unknown -- claude-sdk.js is a top-level CLI runtime not yet classified by eslint.config.js; cleanup deferred (cascades into a server/services classification gap).
15
+ import { abortClaudeSDKSession, queryClaudeSDK } from '@/claude-sdk.js';
16
+ import { AbstractA2AAdapter } from '@/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js';
17
+ import type {
18
+ AdapterContext,
19
+ TaskHandle,
20
+ } from '@/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js';
21
+ import type { AgentCard, Part, Task } from '@/modules/orchestration/a2a/types.js';
22
+
23
+ interface FakeWS {
24
+ send(data: unknown): void;
25
+ readyState: number;
26
+ }
27
+
28
+ // WebSocket.OPEN per the ws library — claude-sdk.js gates send() on readyState === 1.
29
+ const WS_OPEN = 1;
30
+
31
+ function joinPartsToPrompt(parts: Part[]): string {
32
+ return parts
33
+ .map((p) => {
34
+ if (p.kind === 'text') return p.text;
35
+ if (p.kind === 'data') return JSON.stringify(p.data);
36
+ // file parts: include name + uri/inline marker
37
+ return `[file:${p.name}${p.uri ? ` uri=${p.uri}` : ''}]`;
38
+ })
39
+ .join('\n');
40
+ }
41
+
42
+ function newId(prefix: string): string {
43
+ return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
44
+ }
45
+
46
+ export class ClaudeCodeA2AAdapter extends AbstractA2AAdapter {
47
+ readonly id = 'claude-code';
48
+
49
+ readonly agentCard: AgentCard = {
50
+ name: 'pixcode-claude-code',
51
+ description: 'Anthropic Claude Code, accessed via Pixcode',
52
+ url: '/a2a/agents/claude-code',
53
+ version: '1.0.0',
54
+ capabilities: ['streaming', 'fileEdit', 'commandExec', 'mcp'],
55
+ skills: [
56
+ {
57
+ id: 'architectural-review',
58
+ description: 'Review code architecture and propose structural changes',
59
+ },
60
+ {
61
+ id: 'typescript-edit',
62
+ description: 'Edit TypeScript files with type-aware reasoning',
63
+ },
64
+ {
65
+ id: 'multi-file-refactor',
66
+ description: 'Coordinated edits across many files',
67
+ },
68
+ {
69
+ id: 'test-run',
70
+ description: 'Run test suites and react to results',
71
+ },
72
+ ],
73
+ authentication: { type: 'bearer' },
74
+ };
75
+
76
+ private readonly active = new Map<string, { sessionId: string | null }>();
77
+
78
+ async submitTask(task: Task, ctx: AdapterContext): Promise<TaskHandle> {
79
+ // Foundation: only the last user message is fed in. Multi-turn resumption
80
+ // (input-required tasks, workflow chaining) needs to pass options.sessionId
81
+ // and append history; deferred to a follow-on plan.
82
+ const promptText = joinPartsToPrompt(
83
+ task.history[task.history.length - 1]?.parts ?? [],
84
+ );
85
+ const session = { sessionId: null as string | null };
86
+ this.active.set(task.id, session);
87
+
88
+ this.emitState(task.id, 'working');
89
+
90
+ const fakeWS: FakeWS = {
91
+ readyState: WS_OPEN,
92
+ send: (data) => this.handleSdkFrame(task.id, data, session),
93
+ };
94
+
95
+ const finished = (async () => {
96
+ try {
97
+ await queryClaudeSDK(
98
+ promptText,
99
+ {
100
+ cwd: ctx.cwd,
101
+ permissionMode: ctx.permissionMode ?? 'default',
102
+ },
103
+ fakeWS,
104
+ );
105
+ // If cancelTask removed us from `active` first, suppress the spurious
106
+ // 'completed' that would otherwise race the 'canceled' state.
107
+ if (this.active.has(task.id)) {
108
+ this.emitState(task.id, 'completed');
109
+ }
110
+ } catch (err) {
111
+ if (this.active.has(task.id)) {
112
+ this.emitState(task.id, 'failed', {
113
+ code: 'ADAPTER_RUNTIME_ERROR',
114
+ message: err instanceof Error ? err.message : String(err),
115
+ });
116
+ }
117
+ } finally {
118
+ this.active.delete(task.id);
119
+ }
120
+ })();
121
+
122
+ return {
123
+ cancel: () => this.cancelTask(task.id),
124
+ finished,
125
+ };
126
+ }
127
+
128
+ async cancelTask(taskId: string): Promise<void> {
129
+ const session = this.active.get(taskId);
130
+ if (!session) {
131
+ this.emitState(taskId, 'canceled');
132
+ return;
133
+ }
134
+ // Delete BEFORE awaiting so submitTask's IIFE guard (this.active.has)
135
+ // suppresses the spurious 'completed' state when queryClaudeSDK's
136
+ // for-await loop unwinds from the abort.
137
+ this.active.delete(taskId);
138
+ if (session.sessionId) {
139
+ try {
140
+ await abortClaudeSDKSession(session.sessionId);
141
+ } catch {
142
+ // swallow — adapter has already cleaned its own state
143
+ }
144
+ }
145
+ this.emitState(taskId, 'canceled');
146
+ }
147
+
148
+ /**
149
+ * claude-sdk.js calls `ws.send(<NormalizedMessage>)` with a JS OBJECT
150
+ * (not a JSON string). We translate each frame into A2A bus events.
151
+ * See server/shared/types.ts for the MessageKind union.
152
+ */
153
+ private handleSdkFrame(
154
+ taskId: string,
155
+ frame: unknown,
156
+ session: { sessionId: string | null },
157
+ ): void {
158
+ if (!frame || typeof frame !== 'object') return;
159
+ const f = frame as {
160
+ kind?: string;
161
+ sessionId?: unknown;
162
+ newSessionId?: unknown;
163
+ text?: unknown;
164
+ content?: unknown;
165
+ toolName?: unknown;
166
+ toolInput?: unknown;
167
+ toolResult?: unknown;
168
+ };
169
+
170
+ // session_created carries the new session id in `newSessionId`. Capture
171
+ // it here so cancelTask can call abortClaudeSDKSession with the right id.
172
+ if (
173
+ f.kind === 'session_created' &&
174
+ typeof f.newSessionId === 'string' &&
175
+ !session.sessionId
176
+ ) {
177
+ session.sessionId = f.newSessionId;
178
+ }
179
+
180
+ switch (f.kind) {
181
+ case 'session_created':
182
+ case 'status':
183
+ case 'stream_delta':
184
+ case 'stream_end':
185
+ // session_created and status are not user-facing.
186
+ // stream_delta and stream_end CARRY user-visible delta text but are
187
+ // not currently emitted by claude-sdk.js (it doesn't pass
188
+ // includePartialMessages: true to query()). If that flag flips on
189
+ // upstream, these cases must be re-routed to emit text Messages.
190
+ return;
191
+
192
+ case 'text':
193
+ case 'thinking': {
194
+ const text =
195
+ typeof f.text === 'string'
196
+ ? f.text
197
+ : typeof f.content === 'string'
198
+ ? f.content
199
+ : null;
200
+ if (text) {
201
+ this.emitMessage(taskId, {
202
+ messageId: newId('msg'),
203
+ role: 'agent',
204
+ parts: [{ kind: 'text', text }],
205
+ taskId,
206
+ });
207
+ }
208
+ return;
209
+ }
210
+
211
+ case 'tool_use': {
212
+ this.emitArtifact(taskId, {
213
+ artifactId: newId('art'),
214
+ type: 'command-output',
215
+ parts: [
216
+ {
217
+ kind: 'data',
218
+ data: { toolName: f.toolName, toolInput: f.toolInput },
219
+ },
220
+ ],
221
+ metadata: { source: 'claude-tool-use' },
222
+ });
223
+ return;
224
+ }
225
+
226
+ case 'tool_result': {
227
+ this.emitArtifact(taskId, {
228
+ artifactId: newId('art'),
229
+ type: 'command-output',
230
+ parts: [{ kind: 'data', data: { toolResult: f.toolResult } }],
231
+ metadata: { source: 'claude-tool-result' },
232
+ });
233
+ return;
234
+ }
235
+
236
+ case 'permission_request':
237
+ case 'permission_cancelled':
238
+ case 'interactive_prompt':
239
+ case 'task_notification':
240
+ // Informational — surface as data artifact for visibility.
241
+ this.emitArtifact(taskId, {
242
+ artifactId: newId('art'),
243
+ type: 'data',
244
+ parts: [{ kind: 'data', data: f as Record<string, unknown> }],
245
+ metadata: { source: `claude-${f.kind}` },
246
+ });
247
+ return;
248
+
249
+ case 'error': {
250
+ // claude-sdk.js catches internally and emits an error frame without
251
+ // rethrowing, so the IIFE await would resolve cleanly. Force the
252
+ // failed state here and remove from active so the IIFE's
253
+ // 'completed' emit is suppressed by its active.has() guard.
254
+ const message =
255
+ typeof f.content === 'string'
256
+ ? f.content
257
+ : typeof f.text === 'string'
258
+ ? f.text
259
+ : 'Claude Code reported an error';
260
+ this.emitState(taskId, 'failed', {
261
+ code: 'CLAUDE_RUNTIME_ERROR',
262
+ message,
263
+ details: f as Record<string, unknown>,
264
+ });
265
+ this.active.delete(taskId);
266
+ return;
267
+ }
268
+
269
+ case 'complete':
270
+ // Lifecycle redundant with the IIFE's 'completed' emit; suppress to
271
+ // avoid double-signaling. The IIFE owns terminal state transitions.
272
+ return;
273
+
274
+ default:
275
+ // Unknown kind — surface for visibility
276
+ this.emitArtifact(taskId, {
277
+ artifactId: newId('art'),
278
+ type: 'data',
279
+ parts: [{ kind: 'data', data: f as Record<string, unknown> }],
280
+ });
281
+ }
282
+ }
283
+ }
@@ -0,0 +1,55 @@
1
+ // server/modules/orchestration/a2a/agent-card.ts
2
+ // Pixcode advertises itself as one A2A agent at /a2a/.well-known/agent-card.json.
3
+ // Per-CLI adapters publish their own cards under /a2a/agents/:id/agent-card.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { adapterRegistry } from '@/modules/orchestration/a2a/adapter-registry.js';
10
+ import type { AgentCard } from '@/modules/orchestration/a2a/types.js';
11
+
12
+ // Resolve <repo-root>/package.json from this file's location.
13
+ // Source layout: server/modules/orchestration/a2a/agent-card.ts (4 levels deep from repo root)
14
+ // Built layout: dist-server/server/modules/orchestration/a2a/agent-card.js (5 levels deep)
15
+ // Walk up until a package.json containing "name":"@pixelbyte-software/pixcode" is found.
16
+ function readPixcodeVersion(): string {
17
+ let dir = dirname(fileURLToPath(import.meta.url));
18
+ for (let i = 0; i < 8; i++) {
19
+ try {
20
+ const candidate = resolve(dir, 'package.json');
21
+ const raw = readFileSync(candidate, 'utf8');
22
+ const pkg = JSON.parse(raw) as { name?: string; version?: string };
23
+ if (pkg.name === '@pixelbyte-software/pixcode' && typeof pkg.version === 'string') {
24
+ return pkg.version;
25
+ }
26
+ } catch {
27
+ // not here, walk up
28
+ }
29
+ const parent = dirname(dir);
30
+ if (parent === dir) break;
31
+ dir = parent;
32
+ }
33
+ return '0.0.0-dev';
34
+ }
35
+
36
+ const VERSION: string = readPixcodeVersion();
37
+
38
+ export function buildPixcodeAgentCard(baseUrl: string): AgentCard {
39
+ const skills = adapterRegistry
40
+ .agentCards()
41
+ .flatMap((card) => card.skills)
42
+ .filter((skill, idx, arr) => arr.findIndex((s) => s.id === skill.id) === idx);
43
+
44
+ return {
45
+ name: 'pixcode',
46
+ description:
47
+ 'Pixcode multi-CLI orchestration platform. Routes A2A tasks to ' +
48
+ 'Claude Code, Codex, Cursor, Gemini, Qwen, or OpenCode adapters.',
49
+ url: `${baseUrl.replace(/\/$/, '')}/a2a`,
50
+ version: VERSION,
51
+ capabilities: ['streaming', 'taskRouting'],
52
+ skills,
53
+ authentication: { type: 'bearer' },
54
+ };
55
+ }
@@ -0,0 +1,29 @@
1
+ // server/modules/orchestration/a2a/auth.middleware.ts
2
+ // Localhost callers bypass auth; everyone else needs a Bearer JWT
3
+ // validated by pixcode's existing auth stack.
4
+
5
+ import type { NextFunction, Request, Response } from 'express';
6
+
7
+ // @ts-ignore — plain-JS module without type declarations
8
+ // eslint-disable-next-line boundaries/no-unknown -- server/middleware/auth.js is a top-level auth runtime not yet classified by eslint.config.js; cleanup deferred.
9
+ import { authenticateToken } from '@/middleware/auth.js';
10
+
11
+ const LOCAL_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']);
12
+
13
+ function isLocalRequest(req: Request): boolean {
14
+ const remote = req.socket.remoteAddress ?? '';
15
+ if (LOCAL_HOSTS.has(remote)) return true;
16
+ // Trust the X-Forwarded-For header only when the inbound socket is local
17
+ // (i.e. the reverse proxy itself is on the same host).
18
+ return false;
19
+ }
20
+
21
+ export function a2aAuth(req: Request, res: Response, next: NextFunction): void {
22
+ if (isLocalRequest(req)) {
23
+ next();
24
+ return;
25
+ }
26
+ // Delegate to existing pixcode JWT middleware. authenticateToken
27
+ // populates req.user on success and 401s on failure.
28
+ authenticateToken(req, res, next);
29
+ }
@@ -0,0 +1,46 @@
1
+ // server/modules/orchestration/a2a/bus.ts
2
+ // In-process pub/sub on top of Node's EventEmitter.
3
+ // Subscribers receive every event for a given taskId; an
4
+ // "all" subscriber receives every event regardless of task.
5
+ // Note: the literal taskId "__all__" is reserved for the broadcast
6
+ // channel; callers must not use it as a real taskId.
7
+
8
+ import { EventEmitter } from 'node:events';
9
+
10
+ import type { BusEvent } from '@/modules/orchestration/a2a/types.js';
11
+
12
+ type Listener = (event: BusEvent) => void;
13
+
14
+ const ALL = '__all__';
15
+
16
+ class A2ABus {
17
+ private readonly emitter = new EventEmitter();
18
+
19
+ constructor() {
20
+ this.emitter.setMaxListeners(0); // SSE clients can be numerous
21
+ }
22
+
23
+ /** Synchronous: listeners run before publish() returns. */
24
+ publish(event: BusEvent): void {
25
+ this.emitter.emit(event.taskId, event);
26
+ this.emitter.emit(ALL, event);
27
+ }
28
+
29
+ /** Subscribe to events for a specific taskId. Returns an unsubscribe
30
+ * function — caller MUST invoke it to release the listener; the bus
31
+ * retains a strong reference until then. */
32
+ subscribe(taskId: string, listener: Listener): () => void {
33
+ this.emitter.on(taskId, listener);
34
+ return () => this.emitter.off(taskId, listener);
35
+ }
36
+
37
+ /** Subscribe to ALL events. Returns an unsubscribe function with the
38
+ * same release semantics as subscribe(). */
39
+ subscribeAll(listener: Listener): () => void {
40
+ this.emitter.on(ALL, listener);
41
+ return () => this.emitter.off(ALL, listener);
42
+ }
43
+ }
44
+
45
+ export const a2aBus = new A2ABus();
46
+ export type { A2ABus };
@@ -0,0 +1,264 @@
1
+ // server/modules/orchestration/a2a/routes.ts
2
+ // HTTP surface for A2A v0.2. Mounted at /a2a in server/index.js.
3
+
4
+ import crypto from 'node:crypto';
5
+
6
+ import type { Request, Response, Router } from 'express';
7
+ import express from 'express';
8
+
9
+ import { adapterRegistry } from '@/modules/orchestration/a2a/adapter-registry.js';
10
+ import { buildPixcodeAgentCard } from '@/modules/orchestration/a2a/agent-card.js';
11
+ import { a2aAuth } from '@/modules/orchestration/a2a/auth.middleware.js';
12
+ import { a2aBus } from '@/modules/orchestration/a2a/bus.js';
13
+ import type {
14
+ BusEvent,
15
+ Message,
16
+ Task,
17
+ TaskState,
18
+ } from '@/modules/orchestration/a2a/types.js';
19
+ import {
20
+ A2AValidationError,
21
+ assertMessage,
22
+ assertSubmitTaskInput,
23
+ } from '@/modules/orchestration/a2a/validator.js';
24
+
25
+ // In-memory task store. Persistence is out of scope for the foundation;
26
+ // a follow-on plan adds SQLite-backed storage.
27
+ const tasks = new Map<string, Task>();
28
+ // Per-task bus unsubscribe handles; called on terminal state.
29
+ const taskUnsubs = new Map<string, () => void>();
30
+ // Eviction timeouts (terminal tasks live for 1 hour before being purged).
31
+ const taskEvictions = new Map<string, NodeJS.Timeout>();
32
+ const TERMINAL_TASK_TTL_MS = 60 * 60 * 1000; // 1 hour
33
+ const MAX_TASKS = 1000;
34
+
35
+ function newId(prefix: string): string {
36
+ return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
37
+ }
38
+
39
+ function getBaseUrl(req: Request): string {
40
+ // TODO: this trusts X-Forwarded-Proto/Host without checking app's
41
+ // trust-proxy setting. Same posture as auth.middleware.ts; revisit
42
+ // when project-wide trust-proxy decision lands.
43
+ const proto = req.header('x-forwarded-proto') ?? req.protocol;
44
+ const host = req.header('x-forwarded-host') ?? req.get('host');
45
+ return `${proto}://${host}`;
46
+ }
47
+
48
+ function attachBusToTask(task: Task): void {
49
+ const unsubscribe = a2aBus.subscribe(task.id, (event: BusEvent) => {
50
+ if (event.kind === 'task-state') {
51
+ task.state = event.state;
52
+ if (event.error) task.error = event.error;
53
+ task.updatedAt = Date.now();
54
+ if (event.state === 'completed' || event.state === 'canceled' || event.state === 'failed') {
55
+ // Release the listener; schedule eviction.
56
+ const unsub = taskUnsubs.get(task.id);
57
+ if (unsub) {
58
+ unsub();
59
+ taskUnsubs.delete(task.id);
60
+ }
61
+ const existingTimeout = taskEvictions.get(task.id);
62
+ if (existingTimeout) clearTimeout(existingTimeout);
63
+ taskEvictions.set(
64
+ task.id,
65
+ setTimeout(() => {
66
+ tasks.delete(task.id);
67
+ taskEvictions.delete(task.id);
68
+ }, TERMINAL_TASK_TTL_MS),
69
+ );
70
+ }
71
+ } else if (event.kind === 'message') {
72
+ task.history.push(event.message);
73
+ task.updatedAt = Date.now();
74
+ } else if (event.kind === 'artifact') {
75
+ task.artifacts.push(event.artifact);
76
+ task.updatedAt = Date.now();
77
+ }
78
+ });
79
+ taskUnsubs.set(task.id, unsubscribe);
80
+ }
81
+
82
+ export function createA2ARouter(): Router {
83
+ const router: Router = express.Router();
84
+
85
+ router.use(express.json({ limit: '5mb' }));
86
+ router.use(a2aAuth);
87
+
88
+ // Discovery
89
+ router.get('/.well-known/agent-card.json', (req, res) => {
90
+ res.json(buildPixcodeAgentCard(getBaseUrl(req)));
91
+ });
92
+
93
+ router.get('/agents', (_req, res) => {
94
+ res.json({ agents: adapterRegistry.agentCards() });
95
+ });
96
+
97
+ router.get('/agents/:id/agent-card', (req, res) => {
98
+ const adapter = adapterRegistry.get(req.params.id);
99
+ if (!adapter) {
100
+ res.status(404).json({ error: { code: 'AGENT_NOT_FOUND', message: req.params.id } });
101
+ return;
102
+ }
103
+ res.json(adapter.agentCard);
104
+ });
105
+
106
+ // Task lifecycle
107
+ router.post('/tasks', async (req: Request, res: Response) => {
108
+ try {
109
+ assertSubmitTaskInput(req.body);
110
+ } catch (err) {
111
+ const e = err as A2AValidationError;
112
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: e.message, path: e.path } });
113
+ return;
114
+ }
115
+
116
+ const adapter = adapterRegistry.get(req.body.adapterId);
117
+ if (!adapter) {
118
+ res.status(404).json({
119
+ error: { code: 'ADAPTER_NOT_FOUND', message: req.body.adapterId },
120
+ });
121
+ return;
122
+ }
123
+
124
+ // Enforce MAX_TASKS cap. Evict the oldest terminal task first; if all
125
+ // active, fail closed with 503.
126
+ if (tasks.size >= MAX_TASKS) {
127
+ let evicted = false;
128
+ for (const [tid, t] of tasks) {
129
+ if (t.state === 'completed' || t.state === 'canceled' || t.state === 'failed') {
130
+ const timeout = taskEvictions.get(tid);
131
+ if (timeout) clearTimeout(timeout);
132
+ taskEvictions.delete(tid);
133
+ const unsub = taskUnsubs.get(tid);
134
+ if (unsub) {
135
+ unsub();
136
+ taskUnsubs.delete(tid);
137
+ }
138
+ tasks.delete(tid);
139
+ evicted = true;
140
+ break;
141
+ }
142
+ }
143
+ if (!evicted) {
144
+ res.status(503).json({
145
+ error: { code: 'TASK_LIMIT', message: `task store at capacity (${MAX_TASKS})` },
146
+ });
147
+ return;
148
+ }
149
+ }
150
+
151
+ const userMessage: Message = req.body.message;
152
+ const task: Task = {
153
+ id: newId('task'),
154
+ contextId: req.body.contextId,
155
+ state: 'submitted',
156
+ history: [userMessage],
157
+ artifacts: [],
158
+ metadata: req.body.metadata,
159
+ createdAt: Date.now(),
160
+ updatedAt: Date.now(),
161
+ };
162
+ tasks.set(task.id, task);
163
+ // Persist adapterId in metadata so cancel can resolve the owning adapter
164
+ // even when the original request body is no longer available.
165
+ task.metadata = { ...task.metadata, adapterId: req.body.adapterId };
166
+ attachBusToTask(task);
167
+
168
+ try {
169
+ await adapter.submitTask(task, { cwd: process.cwd() });
170
+ } catch (err) {
171
+ // Publish to bus so SSE subscribers and the attachBusToTask listener
172
+ // both see the failure transition. The listener mutates the stored
173
+ // task in place, so the 202 body still reflects the failed state.
174
+ a2aBus.publish({
175
+ kind: 'task-state',
176
+ taskId: task.id,
177
+ state: 'failed',
178
+ error: {
179
+ code: 'ADAPTER_SUBMIT_FAILED',
180
+ message: err instanceof Error ? err.message : String(err),
181
+ },
182
+ });
183
+ }
184
+
185
+ res.status(202).json(task);
186
+ });
187
+
188
+ router.get('/tasks/:id', (req, res) => {
189
+ const task = tasks.get(req.params.id);
190
+ if (!task) {
191
+ res.status(404).json({ error: { code: 'TASK_NOT_FOUND', message: req.params.id } });
192
+ return;
193
+ }
194
+ res.json(task);
195
+ });
196
+
197
+ router.get('/tasks/:id/stream', (req, res) => {
198
+ const task = tasks.get(req.params.id);
199
+ if (!task) {
200
+ res.status(404).json({ error: { code: 'TASK_NOT_FOUND', message: req.params.id } });
201
+ return;
202
+ }
203
+ res.setHeader('Content-Type', 'text/event-stream');
204
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
205
+ res.setHeader('Connection', 'keep-alive');
206
+ res.flushHeaders();
207
+
208
+ // Replay current state once so late subscribers see history.
209
+ const initial = { kind: 'task-snapshot' as const, task };
210
+ res.write(`event: snapshot\ndata: ${JSON.stringify(initial)}\n\n`);
211
+
212
+ const TERMINAL: TaskState[] = ['completed', 'canceled', 'failed'];
213
+ const unsubscribe = a2aBus.subscribe(task.id, (event) => {
214
+ res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
215
+ if (event.kind === 'task-state' && TERMINAL.includes(event.state)) {
216
+ res.end();
217
+ }
218
+ });
219
+
220
+ req.on('close', () => {
221
+ unsubscribe();
222
+ });
223
+ });
224
+
225
+ router.post('/tasks/:id/cancel', async (req, res) => {
226
+ const task = tasks.get(req.params.id);
227
+ if (!task) {
228
+ res.status(404).json({ error: { code: 'TASK_NOT_FOUND', message: req.params.id } });
229
+ return;
230
+ }
231
+ // Look up the adapter that owns this task. We stored adapterId in metadata.
232
+ const adapterId = req.body?.adapterId ?? task.metadata?.adapterId;
233
+ const adapter = typeof adapterId === 'string' ? adapterRegistry.get(adapterId) : undefined;
234
+ if (!adapter) {
235
+ res.status(400).json({
236
+ error: {
237
+ code: 'ADAPTER_REQUIRED',
238
+ message: 'Provide adapterId to cancel a task whose adapter is unknown',
239
+ },
240
+ });
241
+ return;
242
+ }
243
+ await adapter.cancelTask(task.id);
244
+ res.json(tasks.get(task.id));
245
+ });
246
+
247
+ router.post('/messages', (req, res) => {
248
+ try {
249
+ assertMessage(req.body);
250
+ } catch (err) {
251
+ const e = err as A2AValidationError;
252
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: e.message, path: e.path } });
253
+ return;
254
+ }
255
+ a2aBus.publish({
256
+ kind: 'message',
257
+ taskId: req.body.taskId ?? 'broadcast',
258
+ message: req.body,
259
+ });
260
+ res.status(202).json({ accepted: true });
261
+ });
262
+
263
+ return router;
264
+ }