@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.
- package/dist/api-docs.html +395 -395
- package/dist/assets/{index-B_dU5AHA.js → index-BvClqlMf.js} +134 -134
- package/dist/favicon.svg +8 -8
- package/dist/icons/icon-128x128.svg +9 -9
- package/dist/icons/icon-144x144.svg +9 -9
- package/dist/icons/icon-152x152.svg +9 -9
- package/dist/icons/icon-192x192.svg +9 -9
- package/dist/icons/icon-384x384.svg +9 -9
- package/dist/icons/icon-512x512.svg +9 -9
- package/dist/icons/icon-72x72.svg +9 -9
- package/dist/icons/icon-96x96.svg +9 -9
- package/dist/icons/icon-template.svg +9 -9
- package/dist/index.html +1 -1
- package/dist/logo.svg +12 -12
- package/dist/openapi.yaml +1311 -1311
- package/dist-server/server/index.js +4 -0
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/a2a/adapter-registry.js +47 -0
- package/dist-server/server/modules/orchestration/a2a/adapter-registry.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js +17 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js +233 -0
- package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/agent-card.js +50 -0
- package/dist-server/server/modules/orchestration/a2a/agent-card.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/auth.middleware.js +25 -0
- package/dist-server/server/modules/orchestration/a2a/auth.middleware.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/bus.js +34 -0
- package/dist-server/server/modules/orchestration/a2a/bus.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/routes.js +233 -0
- package/dist-server/server/modules/orchestration/a2a/routes.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/types.js +6 -0
- package/dist-server/server/modules/orchestration/a2a/types.js.map +1 -0
- package/dist-server/server/modules/orchestration/a2a/validator.js +85 -0
- package/dist-server/server/modules/orchestration/a2a/validator.js.map +1 -0
- package/dist-server/server/modules/orchestration/index.js +10 -0
- package/dist-server/server/modules/orchestration/index.js.map +1 -0
- package/dist-server/server/opencode-cli.js +4 -1
- package/dist-server/server/opencode-cli.js.map +1 -1
- package/package.json +178 -178
- package/scripts/smoke/a2a-roundtrip.mjs +98 -0
- package/server/database/db.js +794 -794
- package/server/database/json-store.js +194 -194
- package/server/index.js +9 -0
- package/server/modules/orchestration/a2a/adapter-registry.ts +58 -0
- package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +49 -0
- package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +283 -0
- package/server/modules/orchestration/a2a/agent-card.ts +55 -0
- package/server/modules/orchestration/a2a/auth.middleware.ts +29 -0
- package/server/modules/orchestration/a2a/bus.ts +46 -0
- package/server/modules/orchestration/a2a/routes.ts +264 -0
- package/server/modules/orchestration/a2a/types.ts +111 -0
- package/server/modules/orchestration/a2a/validator.ts +90 -0
- package/server/modules/orchestration/index.ts +26 -0
- package/server/modules/providers/list/opencode/opencode-auth.provider.ts +130 -130
- package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +126 -126
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +232 -232
- package/server/modules/providers/list/opencode/opencode.provider.ts +29 -29
- package/server/modules/providers/list/qwen/qwen-auth.provider.ts +145 -145
- package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -114
- package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
- package/server/modules/providers/list/qwen/qwen.provider.ts +21 -21
- package/server/modules/providers/shared/provider-configs.ts +142 -142
- package/server/opencode-cli.js +4 -1
- package/server/opencode-response-handler.js +107 -107
- package/server/qwen-code-cli.js +395 -395
- package/server/qwen-response-handler.js +73 -73
- package/server/routes/qwen.js +27 -27
- package/server/services/external-access.js +171 -171
- package/server/services/provider-credentials.js +189 -189
- package/server/services/provider-models.js +381 -381
- package/server/services/telegram/telegram-http-client.js +130 -130
- package/server/services/vapid-keys.js +36 -36
- package/server/utils/port-access.js +209 -209
- 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
|
+
}
|