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