viveworker 0.3.0 → 0.4.1

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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * a2a-handler.mjs — Agent2Agent (A2A) Protocol handler for viveworker bridge.
3
+ *
4
+ * Implements a minimal A2A server (JSON-RPC 2.0) that:
5
+ * - Publishes an Agent Card at /.well-known/agent.json
6
+ * - Accepts tasks via message/send
7
+ * - Returns task status via tasks/get
8
+ * - Cancels tasks via tasks/cancel
9
+ *
10
+ * All tasks require user approval through the PWA before execution.
11
+ */
12
+
13
+ import crypto from "node:crypto";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Agent Card
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export function buildAgentCard(config) {
20
+ const baseUrl = config.a2aPublicUrl || config.publicBaseUrl || `http://localhost:${config.port || 7860}`;
21
+
22
+ // Custom description from A2A_DESCRIPTION env var (set during setup)
23
+ const description = config.a2aDescription ||
24
+ "LAN-connected AI companion that bridges Codex and Claude Desktop. " +
25
+ "Can execute coding tasks, file operations, and code reviews with human approval.";
26
+
27
+ // Custom skills from A2A_SKILLS env var (comma-separated tags → skill objects)
28
+ let skills;
29
+ if (config.a2aSkills) {
30
+ skills = config.a2aSkills.split(",").map((s) => s.trim()).filter(Boolean).map((tag) => ({
31
+ id: tag,
32
+ name: tag,
33
+ description: tag,
34
+ }));
35
+ } else {
36
+ skills = [
37
+ {
38
+ id: "code-task",
39
+ name: "Code Task",
40
+ description: "Execute a coding task via Codex or Claude (with human approval)",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: { instruction: { type: "string" } },
44
+ required: ["instruction"],
45
+ },
46
+ },
47
+ {
48
+ id: "code-review",
49
+ name: "Code Review",
50
+ description: "Review code or a pull request (with human approval)",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ target: { type: "string" },
55
+ context: { type: "string" },
56
+ },
57
+ required: ["target"],
58
+ },
59
+ },
60
+ ];
61
+ }
62
+
63
+ return {
64
+ schemaVersion: "1.0",
65
+ humanReadableId: `viveworker/${config.a2aRelayUserId || "viveworker"}`,
66
+ agentVersion: config.version || "0.3.0",
67
+ name: "viveworker",
68
+ description,
69
+ url: `${baseUrl.replace(/\/$/u, "")}/a2a`,
70
+ provider: { name: "viveworker" },
71
+ capabilities: {
72
+ a2aVersion: "0.2.3",
73
+ streaming: false,
74
+ pushNotifications: false,
75
+ },
76
+ skills,
77
+ authSchemes: [{ scheme: "apiKey", in: "header", name: "X-A2A-Key" }],
78
+ };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Rate limiter (per-IP, in-memory)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const rateBuckets = new Map(); // ip → { count, resetAtMs }
86
+ const RATE_WINDOW_MS = 60_000;
87
+ const RATE_MAX = 10;
88
+
89
+ function checkRateLimit(ip) {
90
+ const now = Date.now();
91
+ let bucket = rateBuckets.get(ip);
92
+ if (!bucket || now >= bucket.resetAtMs) {
93
+ bucket = { count: 0, resetAtMs: now + RATE_WINDOW_MS };
94
+ rateBuckets.set(ip, bucket);
95
+ }
96
+ bucket.count += 1;
97
+ return bucket.count <= RATE_MAX;
98
+ }
99
+
100
+ // Sweep stale buckets every 5 minutes
101
+ setInterval(() => {
102
+ const now = Date.now();
103
+ for (const [ip, bucket] of rateBuckets) {
104
+ if (now >= bucket.resetAtMs) rateBuckets.delete(ip);
105
+ }
106
+ }, 5 * 60_000).unref?.();
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Constants
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const MAX_PENDING_TASKS = 5;
113
+ const MAX_BODY_BYTES = 10_240; // 10 KB
114
+
115
+ // A2A task statuses
116
+ const STATUS = {
117
+ SUBMITTED: "submitted",
118
+ WORKING: "working",
119
+ INPUT_REQUIRED: "input-required",
120
+ COMPLETED: "completed",
121
+ FAILED: "failed",
122
+ CANCELED: "canceled",
123
+ REJECTED: "rejected",
124
+ };
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Helpers
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function taskId() {
131
+ return crypto.randomUUID();
132
+ }
133
+
134
+ function jsonRpcResponse(id, result) {
135
+ return { jsonrpc: "2.0", id: id ?? null, result };
136
+ }
137
+
138
+ function jsonRpcError(id, code, message, data) {
139
+ const err = { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
140
+ if (data !== undefined) err.error.data = data;
141
+ return err;
142
+ }
143
+
144
+ function extractTextFromParts(parts) {
145
+ if (!Array.isArray(parts)) return "";
146
+ return parts
147
+ .filter((p) => p && p.type === "text")
148
+ .map((p) => String(p.text || ""))
149
+ .join("\n")
150
+ .trim();
151
+ }
152
+
153
+ function buildTaskResponse(task) {
154
+ return {
155
+ id: task.id,
156
+ contextId: task.contextId,
157
+ status: {
158
+ state: task.status,
159
+ ...(task.statusMessage ? { message: { role: "agent", parts: [{ type: "text", text: task.statusMessage }] } } : {}),
160
+ },
161
+ ...(task.artifacts.length > 0 ? { artifacts: task.artifacts } : {}),
162
+ history: task.messages,
163
+ };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // A2A request handler (called from bridge)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * @param {object} params
172
+ * @param {object} params.req - Node HTTP IncomingMessage
173
+ * @param {object} params.res - Node HTTP ServerResponse
174
+ * @param {object} params.body - Parsed JSON-RPC request body
175
+ * @param {object} params.config - Bridge config
176
+ * @param {object} params.runtime - Bridge runtime state (Maps)
177
+ * @param {object} params.state - Persistent state
178
+ * @param {Function} params.writeJson - writeJson(res, status, body)
179
+ * @param {Function} params.recordTimelineEntry - recordTimelineEntry({config,runtime,state,entry})
180
+ * @param {Function} params.deliverWebPushItem - deliverWebPushItem({config,state,...})
181
+ * @param {Function} params.saveState - saveState(stateFile, state)
182
+ * @param {Function} params.historyToken - historyToken(stableId) → token string
183
+ * @param {Function} params.cleanText - cleanText(value) → sanitised string
184
+ */
185
+ export async function handleA2ARequest({
186
+ req,
187
+ res,
188
+ body,
189
+ config,
190
+ runtime,
191
+ state,
192
+ writeJson,
193
+ recordTimelineEntry,
194
+ deliverWebPushItem,
195
+ saveState,
196
+ historyToken,
197
+ cleanText,
198
+ }) {
199
+ // --- Auth ---
200
+ const apiKey = req.headers["x-a2a-key"] || "";
201
+ if (!config.a2aApiKey || apiKey !== config.a2aApiKey) {
202
+ return writeJson(res, 401, jsonRpcError(body?.id, -32000, "Unauthorized: invalid or missing X-A2A-Key"));
203
+ }
204
+
205
+ // --- Rate limit ---
206
+ const clientIp = req.socket?.remoteAddress || "unknown";
207
+ if (!checkRateLimit(clientIp)) {
208
+ return writeJson(res, 429, jsonRpcError(body?.id, -32000, "Rate limit exceeded (max 10 req/min)"));
209
+ }
210
+
211
+ // --- JSON-RPC envelope validation ---
212
+ if (!body || body.jsonrpc !== "2.0" || typeof body.method !== "string") {
213
+ return writeJson(res, 400, jsonRpcError(body?.id, -32600, "Invalid JSON-RPC 2.0 request"));
214
+ }
215
+
216
+ const method = body.method;
217
+ const params = body.params || {};
218
+ const rpcId = body.id;
219
+
220
+ switch (method) {
221
+ case "message/send":
222
+ return handleMessageSend({ rpcId, params, req, config, runtime, state, res, writeJson, recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText });
223
+ case "tasks/get":
224
+ return handleTasksGet({ rpcId, params, runtime, res, writeJson });
225
+ case "tasks/cancel":
226
+ return handleTasksCancel({ rpcId, params, runtime, res, writeJson });
227
+ default:
228
+ return writeJson(res, 200, jsonRpcError(rpcId, -32601, `Method not found: ${method}`));
229
+ }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // message/send
234
+ // ---------------------------------------------------------------------------
235
+
236
+ async function handleMessageSend({
237
+ rpcId, params, req, config, runtime, state, res, writeJson,
238
+ recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText,
239
+ }) {
240
+ // Validate message
241
+ const message = params.message;
242
+ if (!message || !Array.isArray(message.parts) || message.parts.length === 0) {
243
+ return writeJson(res, 200, jsonRpcError(rpcId, -32602, "Invalid params: message.parts is required"));
244
+ }
245
+
246
+ const instruction = extractTextFromParts(message.parts);
247
+ if (!instruction) {
248
+ return writeJson(res, 200, jsonRpcError(rpcId, -32602, "Invalid params: no text content in message parts"));
249
+ }
250
+
251
+ // Check pending task limit
252
+ const pendingCount = [...runtime.a2aTasksByToken.values()].filter(
253
+ (t) => t.status === STATUS.SUBMITTED || t.status === STATUS.WORKING || t.status === STATUS.INPUT_REQUIRED
254
+ ).length;
255
+ if (pendingCount >= MAX_PENDING_TASKS) {
256
+ return writeJson(res, 429, jsonRpcError(rpcId, -32000, `Too many pending tasks (max ${MAX_PENDING_TASKS})`));
257
+ }
258
+
259
+ // Create task
260
+ const id = params.taskId || taskId();
261
+ const contextId = params.contextId || taskId();
262
+ const token = historyToken(`a2a_task:${id}`);
263
+ const now = Date.now();
264
+
265
+ const task = {
266
+ id,
267
+ token,
268
+ contextId,
269
+ status: STATUS.SUBMITTED,
270
+ statusMessage: "",
271
+ messages: [message],
272
+ artifacts: [],
273
+ instruction,
274
+ callerInfo: {
275
+ ip: req.socket?.remoteAddress || "",
276
+ userAgent: req.headers["user-agent"] || "",
277
+ },
278
+ createdAtMs: now,
279
+ updatedAtMs: now,
280
+ decisionWaiters: [],
281
+ decision: null,
282
+ };
283
+
284
+ runtime.a2aTasksByToken.set(token, task);
285
+
286
+ // Record timeline entry
287
+ try {
288
+ recordTimelineEntry({
289
+ config,
290
+ runtime,
291
+ state,
292
+ entry: {
293
+ stableId: `a2a_task:${id}`,
294
+ token,
295
+ kind: "a2a_task",
296
+ threadId: "a2a",
297
+ threadLabel: "A2A",
298
+ title: `A2A: ${cleanText(instruction).slice(0, 80)}`,
299
+ summary: cleanText(instruction).slice(0, 160),
300
+ messageText: instruction,
301
+ createdAtMs: now,
302
+ readOnly: false,
303
+ provider: "a2a",
304
+ },
305
+ });
306
+ await saveState(config.stateFile, state);
307
+ } catch (error) {
308
+ console.error(`[a2a-timeline-save] ${error.message}`);
309
+ }
310
+
311
+ // Send web push notification
312
+ try {
313
+ await deliverWebPushItem({
314
+ config,
315
+ state,
316
+ kind: "a2a_task",
317
+ token,
318
+ stableId: `a2a_task:${id}`,
319
+ title: "A2A Task Request",
320
+ body: cleanText(instruction).slice(0, 160),
321
+ buildLocalizedContent: ({ locale }) => {
322
+ const lang = locale?.startsWith("ja") ? "ja" : "en";
323
+ return {
324
+ title: lang === "ja" ? "🤝 外部エージェントからタスク依頼" : "🤝 Incoming A2A task request",
325
+ body: cleanText(instruction).slice(0, 160),
326
+ };
327
+ },
328
+ });
329
+ } catch (error) {
330
+ console.error(`[a2a-push-error] ${error.message}`);
331
+ }
332
+
333
+ // Return task in submitted state (caller will poll tasks/get)
334
+ return writeJson(res, 200, jsonRpcResponse(rpcId, buildTaskResponse(task)));
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // tasks/get
339
+ // ---------------------------------------------------------------------------
340
+
341
+ function handleTasksGet({ rpcId, params, runtime, res, writeJson }) {
342
+ const id = params.taskId || params.id;
343
+ if (!id) {
344
+ return writeJson(res, 200, jsonRpcError(rpcId, -32602, "Invalid params: taskId is required"));
345
+ }
346
+
347
+ // Find task by id
348
+ let task = null;
349
+ for (const t of runtime.a2aTasksByToken.values()) {
350
+ if (t.id === id) { task = t; break; }
351
+ }
352
+
353
+ if (!task) {
354
+ return writeJson(res, 200, jsonRpcError(rpcId, -32001, `Task not found: ${id}`));
355
+ }
356
+
357
+ const result = buildTaskResponse(task);
358
+ if (params.includeHistory === false) {
359
+ delete result.history;
360
+ }
361
+
362
+ return writeJson(res, 200, jsonRpcResponse(rpcId, result));
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // tasks/cancel
367
+ // ---------------------------------------------------------------------------
368
+
369
+ function handleTasksCancel({ rpcId, params, runtime, res, writeJson }) {
370
+ const id = params.taskId || params.id;
371
+ if (!id) {
372
+ return writeJson(res, 200, jsonRpcError(rpcId, -32602, "Invalid params: taskId is required"));
373
+ }
374
+
375
+ let task = null;
376
+ for (const t of runtime.a2aTasksByToken.values()) {
377
+ if (t.id === id) { task = t; break; }
378
+ }
379
+
380
+ if (!task) {
381
+ return writeJson(res, 200, jsonRpcError(rpcId, -32001, `Task not found: ${id}`));
382
+ }
383
+
384
+ // Only active tasks can be canceled
385
+ if ([STATUS.COMPLETED, STATUS.FAILED, STATUS.CANCELED, STATUS.REJECTED].includes(task.status)) {
386
+ return writeJson(res, 200, jsonRpcError(rpcId, -32000, `Task already in terminal state: ${task.status}`));
387
+ }
388
+
389
+ task.status = STATUS.CANCELED;
390
+ task.statusMessage = "Canceled by caller";
391
+ task.updatedAtMs = Date.now();
392
+
393
+ // Notify any decision waiters
394
+ for (const waiter of task.decisionWaiters.splice(0)) {
395
+ try { waiter({ action: "cancel" }); } catch { /* ignore */ }
396
+ }
397
+
398
+ return writeJson(res, 200, jsonRpcResponse(rpcId, { success: true }));
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Decision handler (called from bridge when user approves/denies via PWA)
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /**
406
+ * Process user decision on an A2A task.
407
+ * @param {object} task - A2A task object from runtime.a2aTasksByToken
408
+ * @param {object} decision - { action: "approve"|"deny", instruction?: string }
409
+ */
410
+ export function resolveA2ATaskDecision(task, decision) {
411
+ if (!task) return;
412
+ task.decision = decision;
413
+ task.updatedAtMs = Date.now();
414
+
415
+ if (decision.action === "deny") {
416
+ task.status = STATUS.REJECTED;
417
+ task.statusMessage = "Task rejected by user";
418
+ } else if (decision.action === "approve") {
419
+ task.status = STATUS.WORKING;
420
+ task.statusMessage = "Task approved, executing...";
421
+ if (decision.instruction) {
422
+ task.instruction = decision.instruction;
423
+ }
424
+ }
425
+
426
+ // Notify waiters
427
+ for (const waiter of task.decisionWaiters.splice(0)) {
428
+ try { waiter(decision); } catch { /* ignore */ }
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Mark task as completed with result artifacts.
434
+ */
435
+ export function completeA2ATask(task, resultText) {
436
+ if (!task) return;
437
+ task.status = STATUS.COMPLETED;
438
+ task.statusMessage = "Task completed";
439
+ task.updatedAtMs = Date.now();
440
+ task.artifacts = [
441
+ {
442
+ parts: [{ type: "text", text: resultText }],
443
+ },
444
+ ];
445
+ }
446
+
447
+ /**
448
+ * Mark task as failed.
449
+ */
450
+ export function failA2ATask(task, errorMessage) {
451
+ if (!task) return;
452
+ task.status = STATUS.FAILED;
453
+ task.statusMessage = errorMessage || "Task failed";
454
+ task.updatedAtMs = Date.now();
455
+ }