@pixelbyte-software/pixcode 1.33.11 → 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/assets/{index-oLYHJ2X5.js → index-BvClqlMf.js} +1 -1
- package/dist/index.html +1 -1
- 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/package.json +1 -1
- package/scripts/smoke/a2a-roundtrip.mjs +98 -0
- 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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/validator.ts
|
|
2
|
+
// Hand-written validators for incoming A2A payloads.
|
|
3
|
+
// We deliberately avoid adding a new dep (zod, ajv) for the
|
|
4
|
+
// foundation; a follow-on plan can swap to a schema lib if needed.
|
|
5
|
+
//
|
|
6
|
+
// All path strings use JSONPath-style "$" as the document root so
|
|
7
|
+
// callers can map errors to wire-payload locations consistently.
|
|
8
|
+
export class A2AValidationError extends Error {
|
|
9
|
+
path;
|
|
10
|
+
constructor(message, path) {
|
|
11
|
+
super(`${path}: ${message}`);
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.name = 'A2AValidationError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function assertNonEmptyString(value, path) {
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
18
|
+
throw new A2AValidationError('expected non-empty string', path);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function assertPart(value, path) {
|
|
22
|
+
if (!value || typeof value !== 'object') {
|
|
23
|
+
throw new A2AValidationError('expected object', path);
|
|
24
|
+
}
|
|
25
|
+
const part = value;
|
|
26
|
+
if (part.kind === 'text') {
|
|
27
|
+
assertNonEmptyString(part.text, `${path}.text`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (part.kind === 'file') {
|
|
31
|
+
assertNonEmptyString(part.name, `${path}.name`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (part.kind === 'data') {
|
|
35
|
+
const data = part.data;
|
|
36
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
37
|
+
throw new A2AValidationError('data must be a plain object', `${path}.data`);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new A2AValidationError('part.kind must be text|file|data', `${path}.kind`);
|
|
42
|
+
}
|
|
43
|
+
export function assertMessage(value, path = '$') {
|
|
44
|
+
if (!value || typeof value !== 'object') {
|
|
45
|
+
throw new A2AValidationError('expected object', path);
|
|
46
|
+
}
|
|
47
|
+
const m = value;
|
|
48
|
+
assertNonEmptyString(m.messageId, `${path}.messageId`);
|
|
49
|
+
if (m.role !== 'user' && m.role !== 'agent') {
|
|
50
|
+
throw new A2AValidationError('role must be user|agent', `${path}.role`);
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(m.parts) || m.parts.length === 0) {
|
|
53
|
+
throw new A2AValidationError('parts must be non-empty array', `${path}.parts`);
|
|
54
|
+
}
|
|
55
|
+
m.parts.forEach((p, i) => assertPart(p, `${path}.parts[${i}]`));
|
|
56
|
+
}
|
|
57
|
+
export function assertSubmitTaskInput(value) {
|
|
58
|
+
if (!value || typeof value !== 'object') {
|
|
59
|
+
throw new A2AValidationError('expected object', '$');
|
|
60
|
+
}
|
|
61
|
+
const v = value;
|
|
62
|
+
assertMessage(v.message, '$.message');
|
|
63
|
+
assertNonEmptyString(v.adapterId, '$.adapterId');
|
|
64
|
+
}
|
|
65
|
+
export function assertAgentCard(value) {
|
|
66
|
+
if (!value || typeof value !== 'object') {
|
|
67
|
+
throw new A2AValidationError('expected object', '$');
|
|
68
|
+
}
|
|
69
|
+
const card = value;
|
|
70
|
+
assertNonEmptyString(card.name, '$.name');
|
|
71
|
+
assertNonEmptyString(card.description, '$.description');
|
|
72
|
+
assertNonEmptyString(card.url, '$.url');
|
|
73
|
+
assertNonEmptyString(card.version, '$.version');
|
|
74
|
+
if (!Array.isArray(card.capabilities)) {
|
|
75
|
+
throw new A2AValidationError('capabilities must be array', '$.capabilities');
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(card.skills)) {
|
|
78
|
+
throw new A2AValidationError('skills must be array', '$.skills');
|
|
79
|
+
}
|
|
80
|
+
card.skills.forEach((s, i) => {
|
|
81
|
+
assertNonEmptyString(s.id, `$.skills[${i}].id`);
|
|
82
|
+
assertNonEmptyString(s.description, `$.skills[${i}].description`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=validator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validator.js","sourceRoot":"","sources":["../../../../../server/modules/orchestration/a2a/validator.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// server/modules/orchestration/index.ts
|
|
2
|
+
// Public surface for the orchestration module.
|
|
3
|
+
// All cross-module consumers must import from here per
|
|
4
|
+
// eslint.config.js boundaries rules.
|
|
5
|
+
export { createA2ARouter } from './a2a/routes.js';
|
|
6
|
+
export { adapterRegistry } from './a2a/adapter-registry.js';
|
|
7
|
+
export { ClaudeCodeA2AAdapter } from './a2a/adapters/claude-code.adapter.js';
|
|
8
|
+
export { AbstractA2AAdapter } from './a2a/adapters/abstract-a2a.adapter.js';
|
|
9
|
+
export { a2aBus } from './a2a/bus.js';
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../server/modules/orchestration/index.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,+CAA+C;AAC/C,uDAAuD;AACvD,qCAAqC;AAErC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AAK7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,wCAAwC,CAAC;AAC5E,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixelbyte-software/pixcode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.0",
|
|
4
4
|
"description": "Pixcode — a desktop and mobile web UI for Claude Code, Cursor CLI, Codex, Gemini CLI, Qwen Code, and OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist-server/server/index.js",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// scripts/smoke/a2a-roundtrip.mjs
|
|
2
|
+
// End-to-end smoke check for the A2A foundation.
|
|
3
|
+
//
|
|
4
|
+
// Usage: node scripts/smoke/a2a-roundtrip.mjs [baseUrl]
|
|
5
|
+
// Default: http://127.0.0.1:3001
|
|
6
|
+
//
|
|
7
|
+
// Pre-reqs:
|
|
8
|
+
// - pixcode server running (npm run server:dev-watch)
|
|
9
|
+
// - ANTHROPIC_API_KEY (or pixcode auth) configured for Claude Code
|
|
10
|
+
//
|
|
11
|
+
// What it does:
|
|
12
|
+
// 1. GET /a2a/.well-known/agent-card.json - sanity check
|
|
13
|
+
// 2. GET /a2a/agents - confirms claude-code is registered
|
|
14
|
+
// 3. POST /a2a/tasks - submits a tiny task
|
|
15
|
+
// 4. Streams /a2a/tasks/:id/stream - prints events until terminal state
|
|
16
|
+
//
|
|
17
|
+
// Pass/fail:
|
|
18
|
+
// Exits 0 on terminal state "completed". Non-zero otherwise.
|
|
19
|
+
|
|
20
|
+
const baseUrl = process.argv[2] ?? 'http://127.0.0.1:3001';
|
|
21
|
+
|
|
22
|
+
async function jget(path) {
|
|
23
|
+
const r = await fetch(`${baseUrl}${path}`);
|
|
24
|
+
if (!r.ok) throw new Error(`GET ${path} -> ${r.status}`);
|
|
25
|
+
return r.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
console.log('1) /a2a/.well-known/agent-card.json');
|
|
30
|
+
const card = await jget('/a2a/.well-known/agent-card.json');
|
|
31
|
+
console.log(' name=', card.name, 'version=', card.version);
|
|
32
|
+
if (card.name !== 'pixcode') throw new Error('AgentCard.name != "pixcode"');
|
|
33
|
+
|
|
34
|
+
console.log('2) /a2a/agents');
|
|
35
|
+
const agents = await jget('/a2a/agents');
|
|
36
|
+
const ids = agents.agents.map((a) => a.name);
|
|
37
|
+
console.log(' registered:', ids.join(', '));
|
|
38
|
+
if (!ids.includes('pixcode-claude-code')) {
|
|
39
|
+
throw new Error('claude-code adapter not registered');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('3) POST /a2a/tasks');
|
|
43
|
+
const submitRes = await fetch(`${baseUrl}/a2a/tasks`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
adapterId: 'claude-code',
|
|
48
|
+
message: {
|
|
49
|
+
messageId: 'm_smoke_1',
|
|
50
|
+
role: 'user',
|
|
51
|
+
parts: [{ kind: 'text', text: 'Reply with the single word: ok' }],
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!submitRes.ok) throw new Error(`submit -> ${submitRes.status}`);
|
|
56
|
+
const task = await submitRes.json();
|
|
57
|
+
console.log(' task.id=', task.id, 'state=', task.state);
|
|
58
|
+
|
|
59
|
+
console.log('4) GET /a2a/tasks/:id/stream (SSE)');
|
|
60
|
+
const streamRes = await fetch(`${baseUrl}/a2a/tasks/${task.id}/stream`);
|
|
61
|
+
if (!streamRes.ok) throw new Error(`stream -> ${streamRes.status}`);
|
|
62
|
+
|
|
63
|
+
const reader = streamRes.body.getReader();
|
|
64
|
+
const dec = new TextDecoder();
|
|
65
|
+
let buffer = '';
|
|
66
|
+
let terminalState = null;
|
|
67
|
+
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
if (done) break;
|
|
71
|
+
buffer += dec.decode(value, { stream: true });
|
|
72
|
+
|
|
73
|
+
let idx;
|
|
74
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
75
|
+
const frame = buffer.slice(0, idx);
|
|
76
|
+
buffer = buffer.slice(idx + 2);
|
|
77
|
+
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
78
|
+
if (!dataLine) continue;
|
|
79
|
+
const event = JSON.parse(dataLine.slice('data: '.length));
|
|
80
|
+
console.log(' event:', event.kind ?? 'snapshot', '->', event);
|
|
81
|
+
if (event.kind === 'task-state') {
|
|
82
|
+
terminalState = event.state;
|
|
83
|
+
if (['completed', 'canceled', 'failed'].includes(terminalState)) break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (terminalState && ['completed', 'canceled', 'failed'].includes(terminalState)) break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log('terminal state:', terminalState);
|
|
90
|
+
if (terminalState !== 'completed') {
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main().catch((err) => {
|
|
96
|
+
console.error('SMOKE FAILED:', err);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
});
|
package/server/index.js
CHANGED
|
@@ -71,6 +71,11 @@ import qwenRoutes from './routes/qwen.js';
|
|
|
71
71
|
import pluginsRoutes from './routes/plugins.js';
|
|
72
72
|
import messagesRoutes from './routes/messages.js';
|
|
73
73
|
import providerRoutes from './modules/providers/provider.routes.js';
|
|
74
|
+
import {
|
|
75
|
+
createA2ARouter,
|
|
76
|
+
adapterRegistry,
|
|
77
|
+
ClaudeCodeA2AAdapter,
|
|
78
|
+
} from './modules/orchestration/index.js';
|
|
74
79
|
import networkRoutes from './routes/network.js';
|
|
75
80
|
import telegramRoutes from './routes/telegram.js';
|
|
76
81
|
import { restoreBotFromConfig } from './services/telegram/bot.js';
|
|
@@ -376,6 +381,10 @@ app.use('/api/sessions', authenticateToken, messagesRoutes);
|
|
|
376
381
|
// Unified provider MCP routes (protected)
|
|
377
382
|
app.use('/api/providers', authenticateToken, providerRoutes);
|
|
378
383
|
|
|
384
|
+
// A2A protocol router — has its own auth middleware, do NOT wrap with authenticateToken
|
|
385
|
+
adapterRegistry.register(new ClaudeCodeA2AAdapter());
|
|
386
|
+
app.use('/a2a', createA2ARouter());
|
|
387
|
+
|
|
379
388
|
// Network discovery / QR endpoints (protected)
|
|
380
389
|
app.use('/api/network', authenticateToken, networkRoutes);
|
|
381
390
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/adapter-registry.ts
|
|
2
|
+
// In-process registry mapping adapter ids to AbstractA2AAdapter
|
|
3
|
+
// instances. Resolution supports three id forms:
|
|
4
|
+
// - "claude-code" explicit
|
|
5
|
+
// - "skill:<skillId>" first REGISTERED adapter advertising that skill
|
|
6
|
+
// (Map iteration is insertion-ordered per ES spec).
|
|
7
|
+
// - "auto" first registered adapter (placeholder until
|
|
8
|
+
// AI-suggested routing arrives in a later plan)
|
|
9
|
+
|
|
10
|
+
import type { AbstractA2AAdapter } from '@/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js';
|
|
11
|
+
import type { AgentCard } from '@/modules/orchestration/a2a/types.js';
|
|
12
|
+
|
|
13
|
+
class AdapterRegistry {
|
|
14
|
+
// Map iteration order is insertion-ordered (ES spec); auto and skill: resolution depend on this.
|
|
15
|
+
private readonly byId = new Map<string, AbstractA2AAdapter>();
|
|
16
|
+
|
|
17
|
+
register(adapter: AbstractA2AAdapter): void {
|
|
18
|
+
if (this.byId.has(adapter.id)) {
|
|
19
|
+
throw new Error(`A2A adapter already registered: ${adapter.id}`);
|
|
20
|
+
}
|
|
21
|
+
this.byId.set(adapter.id, adapter);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(idOrSelector: string): AbstractA2AAdapter | undefined {
|
|
25
|
+
if (idOrSelector === 'auto') {
|
|
26
|
+
if (this.byId.size > 1) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'A2A adapter selector "auto" is not yet implemented for multi-adapter registries. ' +
|
|
29
|
+
'Pass an explicit adapter id ("claude-code") or a "skill:<id>" selector. ' +
|
|
30
|
+
'AI-suggested routing will replace this stub in a later plan.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const first = this.byId.values().next().value;
|
|
34
|
+
return first ?? undefined;
|
|
35
|
+
}
|
|
36
|
+
if (idOrSelector.startsWith('skill:')) {
|
|
37
|
+
const skill = idOrSelector.slice('skill:'.length);
|
|
38
|
+
for (const adapter of this.byId.values()) {
|
|
39
|
+
if (adapter.agentCard.skills.some((s) => s.id === skill)) {
|
|
40
|
+
return adapter;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return this.byId.get(idOrSelector);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
list(): AbstractA2AAdapter[] {
|
|
49
|
+
return [...this.byId.values()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
agentCards(): AgentCard[] {
|
|
53
|
+
return this.list().map((a) => a.agentCard);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const adapterRegistry = new AdapterRegistry();
|
|
58
|
+
export type { AdapterRegistry };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts
|
|
2
|
+
// Base class every CLI adapter extends. Adapters wrap the
|
|
3
|
+
// existing per-CLI runtime files (claude-sdk.js, openai-codex.js, ...)
|
|
4
|
+
// and translate between A2A messages and the CLI's native I/O.
|
|
5
|
+
|
|
6
|
+
import { a2aBus } from '@/modules/orchestration/a2a/bus.js';
|
|
7
|
+
import type {
|
|
8
|
+
AgentCard,
|
|
9
|
+
Artifact,
|
|
10
|
+
Message,
|
|
11
|
+
Task,
|
|
12
|
+
TaskError,
|
|
13
|
+
TaskState,
|
|
14
|
+
} from '@/modules/orchestration/a2a/types.js';
|
|
15
|
+
|
|
16
|
+
export interface AdapterContext {
|
|
17
|
+
/** Where the adapter executes — for now this is the project cwd; a future
|
|
18
|
+
* plan introduces WorkspaceHandle (worktree / docker). */
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** pixcode permission mode passed through to the underlying CLI. */
|
|
21
|
+
permissionMode?: 'acceptEdits' | 'plan' | 'bypassPermissions' | 'default';
|
|
22
|
+
/** Optional parent task id when this adapter is invoked inside a workflow. */
|
|
23
|
+
parentTaskId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TaskHandle {
|
|
27
|
+
cancel(): Promise<void>;
|
|
28
|
+
finished: Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export abstract class AbstractA2AAdapter {
|
|
32
|
+
abstract readonly id: string;
|
|
33
|
+
abstract readonly agentCard: AgentCard;
|
|
34
|
+
|
|
35
|
+
abstract submitTask(task: Task, ctx: AdapterContext): Promise<TaskHandle>;
|
|
36
|
+
abstract cancelTask(taskId: string): Promise<void>;
|
|
37
|
+
|
|
38
|
+
protected emitState(taskId: string, state: TaskState, error?: TaskError): void {
|
|
39
|
+
a2aBus.publish({ kind: 'task-state', taskId, state, error });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected emitMessage(taskId: string, message: Message): void {
|
|
43
|
+
a2aBus.publish({ kind: 'message', taskId, message });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
protected emitArtifact(taskId: string, artifact: Artifact): void {
|
|
47
|
+
a2aBus.publish({ kind: 'artifact', taskId, artifact });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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
|
+
}
|