viveworker 0.4.6 → 0.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Local mobile companion for Codex Desktop and Claude Desktop — approvals, code review, Moltbook drafts, and A2A (Agent-to-Agent) task relay on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -1,20 +1,95 @@
1
1
  /**
2
- * a2a-executor.mjs — Execute approved A2A tasks via Codex CLI.
2
+ * a2a-executor.mjs — Execute approved A2A tasks via Codex or Claude CLI.
3
3
  *
4
4
  * After the user approves an A2A task in the PWA, this module spawns
5
- * `codex exec` to perform the work, captures the output, and updates
6
- * the task status to completed/failed.
5
+ * `codex exec` or `claude -p` to perform the work, captures the output,
6
+ * and updates the task status to completed/failed.
7
7
  */
8
8
 
9
- import { spawn } from "node:child_process";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
10
11
  import path from "node:path";
11
12
  import os from "node:os";
12
13
  import { completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
13
14
 
15
+ const APP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
16
+
17
+ /**
18
+ * Try to find the claude binary by walking well-known locations.
19
+ * Returns the absolute path if found, or null.
20
+ */
21
+ function findClaudeBin() {
22
+ // 1. `which` works if PATH is set (interactive shell, etc.)
23
+ const which = spawnSync("which", ["claude"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
24
+ if (which.status === 0 && which.stdout) return which.stdout.trim();
25
+
26
+ // 2. ~/.claude/local/claude (official standalone install)
27
+ const standalone = path.join(os.homedir(), ".claude", "local", "claude");
28
+ if (existsSync(standalone)) return standalone;
29
+
30
+ // 3. nvm-managed global install — scan version dirs
31
+ const nvmBase = path.join(os.homedir(), ".nvm", "versions", "node");
32
+ if (existsSync(nvmBase)) {
33
+ const ls = spawnSync("ls", [nvmBase], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
34
+ if (ls.status === 0 && ls.stdout) {
35
+ const versions = ls.stdout.trim().split("\n").filter(Boolean).reverse();
36
+ for (const v of versions) {
37
+ const candidate = path.join(nvmBase, v, "bin", "claude");
38
+ if (existsSync(candidate)) return candidate;
39
+ }
40
+ }
41
+ }
42
+
43
+ // 4. Homebrew / common paths
44
+ for (const p of ["/usr/local/bin/claude", "/opt/homebrew/bin/claude"]) {
45
+ if (existsSync(p)) return p;
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Detect which executor CLIs are available on this machine.
53
+ * Returns { codex: boolean, claude: boolean, claudeBin?: string, codexBin?: string }.
54
+ */
55
+ export function detectAvailableExecutors() {
56
+ const codexWhich = spawnSync("which", ["codex"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
57
+ const codexBin = codexWhich.status === 0 && codexWhich.stdout
58
+ ? codexWhich.stdout.trim()
59
+ : existsSync(APP_BUNDLE_PATH) ? APP_BUNDLE_PATH : null;
60
+
61
+ const claudeBin = findClaudeBin();
62
+
63
+ return {
64
+ codex: !!codexBin,
65
+ claude: !!claudeBin,
66
+ codexBin: codexBin || undefined,
67
+ claudeBin: claudeBin || undefined,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Resolve the codex binary path.
73
+ */
74
+ function resolveCodexBin(config) {
75
+ if (config.codexBin) return config.codexBin;
76
+ const which = spawnSync("which", ["codex"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
77
+ if (which.status === 0 && which.stdout) return which.stdout.trim();
78
+ if (existsSync(APP_BUNDLE_PATH)) return APP_BUNDLE_PATH;
79
+ return "codex";
80
+ }
81
+
82
+ /**
83
+ * Resolve the claude binary path.
84
+ */
85
+ function resolveClaudeBin() {
86
+ return findClaudeBin() || "claude";
87
+ }
88
+
14
89
  const EXEC_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
15
90
 
16
91
  /**
17
- * Execute an A2A task by spawning `codex exec`.
92
+ * Execute an A2A task by spawning the chosen executor.
18
93
  *
19
94
  * @param {object} task - A2A task object
20
95
  * @param {object} config - Bridge config
@@ -23,23 +98,32 @@ const EXEC_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
23
98
  * @param {object} helpers
24
99
  * @param {Function} helpers.recordTimelineEntry
25
100
  * @param {Function} helpers.saveState
101
+ * @param {string} [executor] - "codex" or "claude" (auto-detect if omitted)
26
102
  */
27
- export async function executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }) {
103
+ export async function executeA2ATask(task, config, runtime, state, { recordTimelineEntry, saveState }, executor) {
28
104
  const instruction = task.instruction || "";
29
105
  if (!instruction) {
30
106
  failA2ATask(task, "No instruction provided");
31
107
  return;
32
108
  }
33
109
 
34
- console.log(`[a2a-exec] Starting task ${task.id}: ${instruction.slice(0, 80)}`);
110
+ // Resolve executor if not specified.
111
+ if (!executor) {
112
+ const available = detectAvailableExecutors();
113
+ executor = available.codex ? "codex" : available.claude ? "claude" : "codex";
114
+ }
115
+
116
+ console.log(`[a2a-exec] Starting task ${task.id} via ${executor}: ${instruction.slice(0, 80)}`);
35
117
 
36
118
  try {
37
- const result = await runCodexExec(instruction, config);
119
+ const result = executor === "claude"
120
+ ? await runClaudeExec(instruction)
121
+ : await runCodexExec(instruction, config);
38
122
  completeA2ATask(task, result);
39
- console.log(`[a2a-exec] Task ${task.id} completed (${result.length} chars)`);
123
+ console.log(`[a2a-exec] Task ${task.id} completed via ${executor} (${result.length} chars)`);
40
124
  } catch (error) {
41
125
  failA2ATask(task, error.message);
42
- console.error(`[a2a-exec] Task ${task.id} failed: ${error.message}`);
126
+ console.error(`[a2a-exec] Task ${task.id} failed via ${executor}: ${error.message}`);
43
127
  }
44
128
 
45
129
  // Record completion in timeline
@@ -51,13 +135,16 @@ export async function executeA2ATask(task, config, runtime, state, { recordTimel
51
135
  entry: {
52
136
  stableId: `a2a_task_result:${task.id}`,
53
137
  token: task.token,
54
- kind: "a2a_task",
138
+ kind: "a2a_task_result",
55
139
  threadId: "a2a",
56
- threadLabel: "A2A",
140
+ threadLabel: instruction.slice(0, 80),
57
141
  title: task.status === "completed"
58
142
  ? `A2A ✅: ${instruction.slice(0, 60)}`
59
143
  : `A2A ❌: ${instruction.slice(0, 60)}`,
60
- summary: task.statusMessage || "",
144
+ summary: task.status === "completed"
145
+ ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 160)
146
+ : task.statusMessage || "Failed",
147
+ instruction,
61
148
  messageText: task.status === "completed"
62
149
  ? (task.artifacts?.[0]?.parts?.[0]?.text || "").slice(0, 500)
63
150
  : task.statusMessage || "Failed",
@@ -78,23 +165,24 @@ export async function executeA2ATask(task, config, runtime, state, { recordTimel
78
165
  */
79
166
  function runCodexExec(instruction, config) {
80
167
  return new Promise((resolve, reject) => {
81
- const codexBin = config.codexBin || "codex";
82
- const args = ["exec", "--prompt", instruction, "--approval-mode", "full-auto"];
83
- const cwd = config.workspaceRoot || process.cwd();
168
+ const codexBin = resolveCodexBin(config);
169
+ const args = ["exec", "--full-auto", "--skip-git-repo-check", instruction];
170
+ const cwd = config.workspaceRoot || os.tmpdir();
84
171
 
85
172
  const child = spawn(codexBin, args, {
86
173
  cwd,
87
- stdio: ["ignore", "pipe", "pipe"],
174
+ stdio: ["pipe", "pipe", "pipe"],
88
175
  env: { ...process.env, CODEX_HOME: config.codexHome || path.join(os.homedir(), ".codex") },
89
176
  timeout: EXEC_TIMEOUT_MS,
90
177
  });
91
178
 
179
+ child.stdin.end();
180
+
92
181
  let stdout = "";
93
182
  let stderr = "";
94
183
 
95
184
  child.stdout.on("data", (chunk) => {
96
185
  stdout += chunk.toString("utf8");
97
- // Cap output at 100KB
98
186
  if (stdout.length > 100_000) {
99
187
  child.kill("SIGTERM");
100
188
  }
@@ -117,10 +205,60 @@ function runCodexExec(instruction, config) {
117
205
  }
118
206
  });
119
207
 
120
- // Timeout fallback
121
208
  setTimeout(() => {
122
209
  try { child.kill("SIGTERM"); } catch { /* ignore */ }
123
210
  reject(new Error("codex exec timed out (10 minutes)"));
124
211
  }, EXEC_TIMEOUT_MS).unref?.();
125
212
  });
126
213
  }
214
+
215
+ /**
216
+ * Spawn `claude -p` and capture stdout+stderr.
217
+ */
218
+ function runClaudeExec(instruction) {
219
+ return new Promise((resolve, reject) => {
220
+ const claudeBin = resolveClaudeBin();
221
+ const args = ["-p", instruction, "--output-format", "text"];
222
+
223
+ const child = spawn(claudeBin, args, {
224
+ cwd: os.tmpdir(),
225
+ stdio: ["pipe", "pipe", "pipe"],
226
+ env: { ...process.env },
227
+ timeout: EXEC_TIMEOUT_MS,
228
+ });
229
+
230
+ child.stdin.end();
231
+
232
+ let stdout = "";
233
+ let stderr = "";
234
+
235
+ child.stdout.on("data", (chunk) => {
236
+ stdout += chunk.toString("utf8");
237
+ if (stdout.length > 100_000) {
238
+ child.kill("SIGTERM");
239
+ }
240
+ });
241
+ child.stderr.on("data", (chunk) => {
242
+ stderr += chunk.toString("utf8");
243
+ if (stderr.length > 50_000) stderr = stderr.slice(-50_000);
244
+ });
245
+
246
+ child.on("error", (error) => {
247
+ reject(new Error(`Failed to spawn claude: ${error.message}`));
248
+ });
249
+
250
+ child.on("close", (code) => {
251
+ if (code === 0) {
252
+ resolve(stdout.trim() || "(completed with no output)");
253
+ } else {
254
+ const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
255
+ reject(new Error(`claude exec failed: ${errorDetail.slice(0, 500)}`));
256
+ }
257
+ });
258
+
259
+ setTimeout(() => {
260
+ try { child.kill("SIGTERM"); } catch { /* ignore */ }
261
+ reject(new Error("claude exec timed out (10 minutes)"));
262
+ }, EXEC_TIMEOUT_MS).unref?.();
263
+ });
264
+ }
@@ -302,9 +302,10 @@ async function handleMessageSend({
302
302
  token,
303
303
  kind: "a2a_task",
304
304
  threadId: "a2a",
305
- threadLabel: "A2A",
305
+ threadLabel: cleanText(instruction).slice(0, 80),
306
306
  title: `A2A: ${cleanText(instruction).slice(0, 80)}`,
307
307
  summary: cleanText(instruction).slice(0, 160),
308
+ instruction,
308
309
  messageText: instruction,
309
310
  createdAtMs: now,
310
311
  readOnly: false,
@@ -68,6 +68,7 @@ export async function registerWithRelay({ config, buildAgentCard }) {
68
68
  userId,
69
69
  bridgeSecret,
70
70
  a2aApiKey: config.a2aApiKey || "",
71
+ acceptPublicTasks: config.a2aAcceptPublicTasks || false,
71
72
  agentCard,
72
73
  ...(config.a2aRelayRegisterSecret ? { registerSecret: config.a2aRelayRegisterSecret } : {}),
73
74
  };
@@ -166,6 +167,14 @@ export function stopRelayPolling() {
166
167
  console.log("[a2a-relay] Polling stopped");
167
168
  }
168
169
 
170
+ /**
171
+ * Update the acceptPublicTasks flag on the relay via re-registration.
172
+ */
173
+ export async function updatePublicTasksFlag({ config, buildAgentCard, acceptPublicTasks }) {
174
+ config.a2aAcceptPublicTasks = acceptPublicTasks;
175
+ return registerWithRelay({ config, buildAgentCard });
176
+ }
177
+
169
178
  /**
170
179
  * Return current relay connection status for the settings UI.
171
180
  */
@@ -4,12 +4,14 @@
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { spawn } from "node:child_process";
7
8
 
8
9
  export const API_BASE = "https://www.moltbook.com/api/v1";
9
10
 
10
11
  export const DEFAULT_ENV_FILE = path.join(os.homedir(), ".viveworker", "moltbook.env");
11
12
  export const DEFAULT_INBOX_DIR = path.join(os.homedir(), ".viveworker", "moltbook-inbox");
12
13
  export const DEFAULT_SCOUT_STATE_FILE = path.join(os.homedir(), ".viveworker", "moltbook-scout-state.json");
14
+ export const DEFAULT_DRAFTS_DIR = path.join(os.homedir(), ".viveworker", "moltbook-drafts");
13
15
 
14
16
  export async function loadMoltbookEnv(envFile = DEFAULT_ENV_FILE) {
15
17
  const env = {};
@@ -204,3 +206,210 @@ export async function listInboxItems(dir = DEFAULT_INBOX_DIR) {
204
206
  return [];
205
207
  }
206
208
  }
209
+
210
+ // ---------- Draft persistence ----------
211
+ //
212
+ // Pending Moltbook drafts (reply proposals and original-post proposals) are
213
+ // written to disk so they survive bridge restarts. One JSON file per draft,
214
+ // keyed by its bridge-assigned token.
215
+
216
+ export async function ensureDraftsDir(dir = DEFAULT_DRAFTS_DIR) {
217
+ await fs.mkdir(dir, { recursive: true });
218
+ return dir;
219
+ }
220
+
221
+ function draftPathFor(token, dir = DEFAULT_DRAFTS_DIR) {
222
+ // Token may contain colons — encode for safe filenames.
223
+ const safe = encodeURIComponent(token);
224
+ return path.join(dir, `${safe}.json`);
225
+ }
226
+
227
+ export async function writeDraft(draft, dir = DEFAULT_DRAFTS_DIR) {
228
+ await ensureDraftsDir(dir);
229
+ // Exclude runtime-only fields (decisionWaiters is an array of callbacks).
230
+ const { decisionWaiters, ...serializable } = draft;
231
+ await fs.writeFile(draftPathFor(draft.token, dir), JSON.stringify(serializable, null, 2) + "\n", "utf8");
232
+ }
233
+
234
+ export async function readDraft(token, dir = DEFAULT_DRAFTS_DIR) {
235
+ try {
236
+ const raw = await fs.readFile(draftPathFor(token, dir), "utf8");
237
+ return JSON.parse(raw);
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ export async function deleteDraft(token, dir = DEFAULT_DRAFTS_DIR) {
244
+ try {
245
+ await fs.unlink(draftPathFor(token, dir));
246
+ } catch (error) {
247
+ if (error.code !== "ENOENT") throw error;
248
+ }
249
+ }
250
+
251
+ export async function listPendingDrafts(dir = DEFAULT_DRAFTS_DIR) {
252
+ try {
253
+ const files = await fs.readdir(dir);
254
+ const drafts = [];
255
+ for (const file of files) {
256
+ if (!file.endsWith(".json")) continue;
257
+ try {
258
+ const raw = await fs.readFile(path.join(dir, file), "utf8");
259
+ const draft = JSON.parse(raw);
260
+ if (draft && !draft.decision) drafts.push(draft);
261
+ } catch {
262
+ // skip bad file
263
+ }
264
+ }
265
+ return drafts;
266
+ } catch {
267
+ return [];
268
+ }
269
+ }
270
+
271
+ // ---------- Verification puzzle solvers ----------
272
+ //
273
+ // Shared by the CLI (for manual `reply` flow) and the bridge (for fire-and-
274
+ // forget draft posting on approval).
275
+
276
+ // Naive verification-puzzle solver. Handles the obfuscated two-number
277
+ // arithmetic Moltbook currently uses (add / subtract / multiply). Returns
278
+ // `null` if it can't confidently solve — caller falls back to LLM or manual.
279
+ export function solveVerificationPuzzle(challengeText) {
280
+ if (!challengeText) return null;
281
+ const cleaned = String(challengeText)
282
+ .replace(/[^a-zA-Z0-9\s]/g, " ")
283
+ .toLowerCase();
284
+ const numberWords = {
285
+ zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
286
+ ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16,
287
+ seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50,
288
+ sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100,
289
+ };
290
+ const collapseRuns = (w) => w.replace(/([a-z])\1+/g, "$1");
291
+ const rawTokens = cleaned
292
+ .replace(/[^a-z0-9\s]/g, " ")
293
+ .split(/\s+/)
294
+ .filter(Boolean);
295
+ const operationWords = new Set([
296
+ "total", "combined", "force", "velocity", "speed", "gains", "plus", "and",
297
+ "subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower", "slows", "slowed",
298
+ "multiply", "times", "product", "multiplied",
299
+ "divide", "divided", "ratio",
300
+ "how", "much", "what", "exerts", "new",
301
+ ]);
302
+ const isKnown = (w) => numberWords[w] != null || numberWords[collapseRuns(w)] != null || operationWords.has(w) || operationWords.has(collapseRuns(w));
303
+ const merged = [];
304
+ let ti = 0;
305
+ while (ti < rawTokens.length) {
306
+ let best = rawTokens[ti];
307
+ let bestLen = 1;
308
+ let candidate = rawTokens[ti];
309
+ for (let span = 2; span <= Math.min(4, rawTokens.length - ti); span++) {
310
+ candidate += rawTokens[ti + span - 1];
311
+ if (isKnown(candidate) || isKnown(collapseRuns(candidate))) {
312
+ best = candidate;
313
+ bestLen = span;
314
+ }
315
+ }
316
+ merged.push(best);
317
+ ti += bestLen;
318
+ }
319
+ const words = merged.map((w) => {
320
+ if (/^\d+$/.test(w)) return w;
321
+ if (numberWords[w] != null) return w;
322
+ const collapsed = collapseRuns(w);
323
+ if (numberWords[collapsed] != null) return collapsed;
324
+ return collapsed;
325
+ });
326
+ const numbers = [];
327
+ let i = 0;
328
+ while (i < words.length) {
329
+ const w = words[i];
330
+ if (/^\d+$/.test(w)) {
331
+ numbers.push(Number(w));
332
+ i += 1;
333
+ continue;
334
+ }
335
+ if (numberWords[w] != null) {
336
+ let total = numberWords[w];
337
+ i += 1;
338
+ while (i < words.length && numberWords[words[i]] != null) {
339
+ const next = numberWords[words[i]];
340
+ if (next === 100) total *= 100;
341
+ else if (next < 100 && total < 100) total += next;
342
+ else break;
343
+ i += 1;
344
+ }
345
+ numbers.push(total);
346
+ continue;
347
+ }
348
+ i += 1;
349
+ }
350
+ if (numbers.length < 2) return null;
351
+ const a = numbers[0];
352
+ const b = numbers[1];
353
+ const hasWord = (w) => words.includes(w);
354
+ const hasAny = (...ws) => ws.some(hasWord);
355
+ let result;
356
+ if (hasAny("subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower", "slows", "slowed")) {
357
+ result = a - b;
358
+ } else if (hasAny("multiply", "times", "product", "multiplied")) {
359
+ result = a * b;
360
+ } else if (hasAny("divide", "divided", "ratio")) {
361
+ result = b !== 0 ? a / b : a;
362
+ } else {
363
+ result = a + b;
364
+ }
365
+ return result.toFixed(2);
366
+ }
367
+
368
+ // LLM-based verification puzzle solver. Shells out to claude or codex CLI.
369
+ // Returns the answer as "XX.XX" string, or null if unavailable.
370
+ export async function solvePuzzleWithLLM(challengeText) {
371
+ if (!challengeText) return null;
372
+ const prompt =
373
+ `The following text is an obfuscated arithmetic word problem from Moltbook (an AI social network). ` +
374
+ `The text has random capitalization, doubled letters, and stray punctuation — ignore all of that. ` +
375
+ `CRITICAL: ALL symbols (/, *, ^, ~, [, ], etc.) are NOISE, NOT arithmetic operators. ` +
376
+ `The operation is ALWAYS expressed in natural language words only. ` +
377
+ `Extract the numbers (written as words like "thirty five" = 35), determine the operation from WORDS ONLY ` +
378
+ `(addition: "total", "combined", "and", "plus", "gains", "new velocity"; ` +
379
+ `subtraction: "difference", "minus", "less", "loses"; ` +
380
+ `multiplication: "times", "product", "multiplied"; ` +
381
+ `division: "divided by", "ratio", "per"). ` +
382
+ `If no operation word is found, default to addition. ` +
383
+ `Compute the result and output ONLY the number with exactly 2 decimal places (e.g. "58.00"). No other text.\n\n` +
384
+ `Puzzle: ${challengeText}`;
385
+ for (const cmd of ["claude", "codex"]) {
386
+ let bin;
387
+ try {
388
+ bin = await new Promise((resolve) => {
389
+ const p = spawn("command", ["-v", cmd], { shell: "/bin/bash", stdio: ["ignore", "pipe", "ignore"] });
390
+ let out = "";
391
+ p.stdout.on("data", (d) => (out += d.toString()));
392
+ p.on("exit", (code) => resolve(code === 0 ? out.trim() : ""));
393
+ p.on("error", () => resolve(""));
394
+ });
395
+ } catch { bin = ""; }
396
+ if (!bin) continue;
397
+ const args = cmd === "claude" ? ["-p", prompt, "--output-format", "text"] : ["exec", prompt];
398
+ try {
399
+ const result = await new Promise((resolve, reject) => {
400
+ const p = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
401
+ let out = "";
402
+ p.stdout.on("data", (d) => (out += d.toString()));
403
+ p.on("exit", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error(`exit ${code}`))));
404
+ p.on("error", reject);
405
+ });
406
+ const match = result.match(/(\d+\.\d{2})/);
407
+ if (match) return match[1];
408
+ const intMatch = result.match(/^(\d+)$/m);
409
+ if (intMatch) return `${intMatch[1]}.00`;
410
+ } catch {
411
+ // try next
412
+ }
413
+ }
414
+ return null;
415
+ }