viveworker 0.1.10 → 0.2.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,839 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * viveworker Claude Desktop / Claude Code hook handler.
5
+ *
6
+ * Invoked by the Claude Code harness for hook events configured in
7
+ * ~/.claude/settings.json. Relays events to the viveworker bridge server
8
+ * and waits for approval decisions on PermissionRequest hooks.
9
+ *
10
+ * Usage:
11
+ * node viveworker-claude-hook.mjs --env-file ~/.viveworker/config.env
12
+ */
13
+
14
+ import { execFile } from "node:child_process";
15
+ import { promises as fs } from "node:fs";
16
+ import http from "node:http";
17
+ import https from "node:https";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import { createInterface } from "node:readline";
22
+ import { promisify } from "node:util";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Bootstrap: parse args, load env file
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let envFilePath = "";
31
+ for (let i = 2; i < process.argv.length - 1; i++) {
32
+ if (process.argv[i] === "--env-file") {
33
+ envFilePath = process.argv[i + 1];
34
+ break;
35
+ }
36
+ }
37
+
38
+ if (envFilePath) {
39
+ try {
40
+ const raw = await fs.readFile(resolvePath(envFilePath), "utf8");
41
+ for (const line of raw.split("\n")) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed || trimmed.startsWith("#")) continue;
44
+ const eqIdx = trimmed.indexOf("=");
45
+ if (eqIdx === -1) continue;
46
+ const key = trimmed.slice(0, eqIdx).trim();
47
+ const val = trimmed.slice(eqIdx + 1).trim();
48
+ if (key && !(key in process.env)) {
49
+ process.env[key] = val;
50
+ }
51
+ }
52
+ } catch {
53
+ // Missing env file is non-fatal
54
+ }
55
+ }
56
+
57
+ const BASE_URL = stripTrailingSlash(process.env.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "");
58
+ const HOOK_SECRET = process.env.SESSION_SECRET || "";
59
+ const STATE_DIR = resolvePath(
60
+ process.env.CLAUDE_HOOK_STATE_DIR || path.join(os.homedir(), ".viveworker", "claude-hooks")
61
+ );
62
+
63
+ // Exit silently if not configured for Claude provider
64
+ if (!BASE_URL || !HOOK_SECRET) {
65
+ process.exit(0);
66
+ }
67
+
68
+ // Timeouts
69
+ const APPROVAL_TIMEOUT_MS = 660_000; // 11 min — matches hook timeout (900s) minus buffer
70
+ const DEFAULT_EVENT_TIMEOUT_MS = 12_000;
71
+ // Throttle for auto-opening the PC browser from handlePreToolInteractive so
72
+ // back-to-back plan/question intercepts do not spam new browser invocations.
73
+ const BROWSER_OPEN_THROTTLE_MS = 10_000;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Read hook event from stdin
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const stdinText = await readStdin();
80
+ let event;
81
+ try {
82
+ event = JSON.parse(stdinText);
83
+ } catch {
84
+ process.exit(0);
85
+ }
86
+
87
+ const hookEventName = String(event.hook_event_name || "");
88
+ const sessionId = String(event.session_id || "");
89
+ const toolName = String(event.tool_name || "");
90
+ const toolInput = isPlainObject(event.tool_input) ? event.tool_input : {};
91
+ const cwd = String(event.cwd || process.cwd());
92
+
93
+ await fs.mkdir(STATE_DIR, { recursive: true });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Route hook event
97
+ // ---------------------------------------------------------------------------
98
+
99
+ switch (hookEventName) {
100
+ case "PermissionRequest":
101
+ await handlePermissionRequest();
102
+ break;
103
+ case "PreToolUse":
104
+ await handlePreToolUse();
105
+ break;
106
+ case "PostToolUse":
107
+ case "PostToolUseFailure":
108
+ await handlePostToolUse();
109
+ break;
110
+ default:
111
+ // Notification, Stop, UserPromptSubmit, SessionEnd
112
+ await postEvent(hookEventName, { sessionId, toolName, cwd }, DEFAULT_EVENT_TIMEOUT_MS);
113
+ break;
114
+ }
115
+
116
+ process.exit(0);
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Hook handlers
120
+ // ---------------------------------------------------------------------------
121
+
122
+ async function handlePermissionRequest() {
123
+ const requestId = String(event.request_id || generateId());
124
+ let approvalKind = "command";
125
+ let messageText = `Tool approval needed: ${toolName}`;
126
+ let fileRefs = [];
127
+ let diffText = "";
128
+ let diffAddedLines = 0;
129
+ let diffRemovedLines = 0;
130
+
131
+ if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
132
+ approvalKind = "file";
133
+ const targetFile = String(toolInput.file_path || toolInput.path || "");
134
+ fileRefs = targetFile ? [targetFile] : [];
135
+ messageText = "File changes need approval.";
136
+
137
+ try {
138
+ const diff = await computeProspectiveDiff(toolName, toolInput, cwd);
139
+ diffText = diff.diffText;
140
+ diffAddedLines = diff.addedLines;
141
+ diffRemovedLines = diff.removedLines;
142
+ } catch {
143
+ // Proceed without diff if computation fails
144
+ }
145
+ } else if (toolName === "Bash") {
146
+ approvalKind = "command";
147
+ const cmd = String(toolInput.command || "");
148
+ messageText = `Command approval needed.\n\`\`\`\n${cmd.slice(0, 500)}\n\`\`\``;
149
+ } else if (toolName === "ExitPlanMode") {
150
+ approvalKind = "plan";
151
+ messageText = "Plan approval needed.";
152
+ } else if (toolName === "AskUserQuestion") {
153
+ approvalKind = "question";
154
+ messageText = "Question from Claude.";
155
+ } else {
156
+ messageText = `Tool approval needed: ${toolName}`;
157
+ }
158
+
159
+ const body = {
160
+ eventType: "approval_request",
161
+ threadId: sessionId,
162
+ requestId,
163
+ approvalKind,
164
+ toolName,
165
+ toolInput,
166
+ cwd,
167
+ createdAtMs: Date.now(),
168
+ fileRefs,
169
+ diffText,
170
+ diffAvailable: Boolean(diffText),
171
+ diffSource: "claude_permission_request",
172
+ diffAddedLines,
173
+ diffRemovedLines,
174
+ messageText,
175
+ };
176
+
177
+ if (approvalKind === "plan") {
178
+ body.planText = String(toolInput.plan || "");
179
+ } else if (approvalKind === "question") {
180
+ body.questions = Array.isArray(toolInput.questions) ? toolInput.questions : [];
181
+ }
182
+
183
+ const result = await postEvent("PermissionRequest", body, APPROVAL_TIMEOUT_MS);
184
+ const behavior = String(result?.permissionDecision || "deny") === "allow" ? "allow" : "deny";
185
+ const reason = typeof result?.permissionDecisionReason === "string" ? result.permissionDecisionReason : "";
186
+
187
+ const decision = { behavior };
188
+ if (reason) decision.message = reason;
189
+
190
+ const output = {
191
+ hookSpecificOutput: {
192
+ hookEventName: "PermissionRequest",
193
+ decision,
194
+ },
195
+ };
196
+ process.stdout.write(JSON.stringify(output) + "\n");
197
+ }
198
+
199
+ async function handlePreToolUse() {
200
+ // ExitPlanMode and AskUserQuestion are auto-allowed tools that do not
201
+ // trigger PermissionRequest hooks. Intercept them here instead, and
202
+ // forward to the bridge so the user can respond from their phone.
203
+ if (toolName === "ExitPlanMode" || toolName === "AskUserQuestion") {
204
+ await handlePreToolInteractive();
205
+ return;
206
+ }
207
+
208
+ if (toolName !== "Write" && toolName !== "Edit" && toolName !== "MultiEdit") {
209
+ return;
210
+ }
211
+ const filePath = String(toolInput.file_path || toolInput.path || "");
212
+ if (!filePath) return;
213
+
214
+ const resolvedFilePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
215
+ const snapshotDir = path.join(STATE_DIR, "snapshots", sanitizeForPath(sessionId));
216
+ await fs.mkdir(snapshotDir, { recursive: true });
217
+
218
+ try {
219
+ const content = await fs.readFile(resolvedFilePath, "utf8");
220
+ const snapshotFile = path.join(snapshotDir, encodeFilePathForSnapshot(resolvedFilePath));
221
+ await fs.writeFile(snapshotFile, content, "utf8");
222
+ } catch {
223
+ // New file or unreadable — snapshot not needed
224
+ }
225
+ }
226
+
227
+ async function handlePreToolInteractive() {
228
+ // Away mode gate: when sentinel file is absent, do not intercept and let
229
+ // Claude Code show its native PC UI as usual.
230
+ const sentinelDir = envFilePath
231
+ ? path.dirname(resolvePath(envFilePath))
232
+ : path.join(os.homedir(), ".viveworker");
233
+ const sentinelPath = path.join(sentinelDir, "claude-away-mode");
234
+ let awayOn = false;
235
+ try {
236
+ const st = await fs.stat(sentinelPath);
237
+ awayOn = st.isFile();
238
+ } catch {
239
+ awayOn = false;
240
+ }
241
+ const requestId = String(event.request_id || generateId());
242
+ let approvalKind = "command";
243
+ let messageText = `Tool approval needed: ${toolName}`;
244
+ if (toolName === "ExitPlanMode") {
245
+ approvalKind = "plan";
246
+ messageText = "Plan approval needed.";
247
+ } else if (toolName === "AskUserQuestion") {
248
+ approvalKind = "question";
249
+ messageText = "Question from Claude.";
250
+ }
251
+
252
+ const body = {
253
+ eventType: "approval_request",
254
+ threadId: sessionId,
255
+ requestId,
256
+ approvalKind,
257
+ toolName,
258
+ toolInput,
259
+ cwd,
260
+ createdAtMs: Date.now(),
261
+ fileRefs: [],
262
+ diffText: "",
263
+ diffAvailable: false,
264
+ diffSource: "claude_pre_tool_use",
265
+ diffAddedLines: 0,
266
+ diffRemovedLines: 0,
267
+ messageText,
268
+ };
269
+ if (approvalKind === "plan") {
270
+ body.planText = String(toolInput.plan || "");
271
+ } else if (approvalKind === "question") {
272
+ body.questions = Array.isArray(toolInput.questions) ? toolInput.questions : [];
273
+ }
274
+
275
+ // Away mode OFF: send a notify-only event so the phone shows a read-only
276
+ // alert, then return immediately so Claude Code's native PC UI runs.
277
+ if (!awayOn) {
278
+ body.notifyOnly = true;
279
+ await postEvent("PreToolUse", body, DEFAULT_EVENT_TIMEOUT_MS);
280
+ return;
281
+ }
282
+
283
+ // Away mode ON: Claude Desktop has no IPC path to inject plan/question
284
+ // decisions (unlike approvals), so the hook long-poll below is the only way
285
+ // to resolve the prompt, and the Claude Desktop native dialog is suppressed
286
+ // for the duration of the intercept. Auto-open the PC default browser to
287
+ // the viveworker Web UI so the user at their desk can answer alongside the
288
+ // paired phone. The bridge's resolved/resolving guard ensures first-answer
289
+ // wins when both respond.
290
+ await openBrowserWithThrottle();
291
+
292
+ const result = await postEvent("PreToolUse", body, APPROVAL_TIMEOUT_MS);
293
+ const permissionDecision = String(result?.permissionDecision || "deny") === "allow" ? "allow" : "deny";
294
+ const permissionDecisionReason = typeof result?.permissionDecisionReason === "string" ? result.permissionDecisionReason : "";
295
+
296
+ const hookSpecificOutput = {
297
+ hookEventName: "PreToolUse",
298
+ permissionDecision,
299
+ };
300
+ if (permissionDecisionReason) {
301
+ hookSpecificOutput.permissionDecisionReason = permissionDecisionReason;
302
+ }
303
+ process.stdout.write(JSON.stringify({ hookSpecificOutput }) + "\n");
304
+ }
305
+
306
+ async function handlePostToolUse() {
307
+ // Emit file_event entries for Read / Write / Edit / MultiEdit so the
308
+ // viveworker timeline mirrors Codex's file event feed. This runs before
309
+ // snapshot cleanup so the PreToolUse snapshot is still available for diff.
310
+ const filePath = String(toolInput.file_path || toolInput.path || "");
311
+ if (filePath && (toolName === "Read" || toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit")) {
312
+ const resolvedFilePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
313
+ try {
314
+ if (toolName === "Read") {
315
+ await postEvent(
316
+ "file_event",
317
+ {
318
+ eventType: "file_event",
319
+ fileEventType: "read",
320
+ filePath: resolvedFilePath,
321
+ threadId: sessionId,
322
+ sessionId,
323
+ cwd,
324
+ createdAtMs: Date.now(),
325
+ diffText: "",
326
+ diffAvailable: false,
327
+ diffAddedLines: 0,
328
+ diffRemovedLines: 0,
329
+ },
330
+ DEFAULT_EVENT_TIMEOUT_MS
331
+ );
332
+ } else {
333
+ const snapshotDir = path.join(STATE_DIR, "snapshots", sanitizeForPath(sessionId));
334
+ const snapshotFile = path.join(snapshotDir, encodeFilePathForSnapshot(resolvedFilePath));
335
+ let oldContent = "";
336
+ let hadSnapshot = false;
337
+ try {
338
+ oldContent = await fs.readFile(snapshotFile, "utf8");
339
+ hadSnapshot = true;
340
+ } catch {
341
+ // No snapshot → treat as create
342
+ }
343
+ let newContent = "";
344
+ try {
345
+ newContent = await fs.readFile(resolvedFilePath, "utf8");
346
+ } catch {
347
+ // File may have been deleted; leave empty
348
+ }
349
+ const diff = await computeUnifiedDiff(oldContent, newContent, filePath);
350
+ const fileEventType = hadSnapshot ? "write" : "create";
351
+ await postEvent(
352
+ "file_event",
353
+ {
354
+ eventType: "file_event",
355
+ fileEventType,
356
+ filePath: resolvedFilePath,
357
+ threadId: sessionId,
358
+ sessionId,
359
+ cwd,
360
+ createdAtMs: Date.now(),
361
+ diffText: diff.diffText,
362
+ diffAvailable: Boolean(diff.diffText),
363
+ diffAddedLines: diff.addedLines,
364
+ diffRemovedLines: diff.removedLines,
365
+ },
366
+ DEFAULT_EVENT_TIMEOUT_MS
367
+ );
368
+ }
369
+ } catch {
370
+ // file_event is best-effort
371
+ }
372
+ }
373
+
374
+ // Snapshot cleanup (file edits only)
375
+ if (filePath) {
376
+ const resolvedFilePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
377
+ const snapshotDir = path.join(STATE_DIR, "snapshots", sanitizeForPath(sessionId));
378
+ const snapshotFile = path.join(snapshotDir, encodeFilePathForSnapshot(resolvedFilePath));
379
+ try {
380
+ await fs.unlink(snapshotFile);
381
+ } catch {
382
+ // Snapshot may not exist
383
+ }
384
+ }
385
+
386
+ // Notify bridge so any approval that was accepted directly on PC
387
+ // (bypassing the iPhone) gets cleared from the pending list and recorded
388
+ // as completed.
389
+ await postEvent(
390
+ hookEventName,
391
+ { threadId: sessionId, sessionId, toolName, toolInput, cwd },
392
+ DEFAULT_EVENT_TIMEOUT_MS
393
+ );
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Diff computation
398
+ // ---------------------------------------------------------------------------
399
+
400
+ async function computeProspectiveDiff(tName, tInput, workingDir) {
401
+ const filePath = String(tInput.file_path || tInput.path || "");
402
+ if (!filePath) return { diffText: "", addedLines: 0, removedLines: 0 };
403
+
404
+ const resolvedFilePath = path.isAbsolute(filePath) ? filePath : path.join(workingDir, filePath);
405
+
406
+ let oldContent = "";
407
+ try {
408
+ oldContent = await fs.readFile(resolvedFilePath, "utf8");
409
+ } catch {
410
+ // New file
411
+ }
412
+
413
+ let newContent = oldContent;
414
+ if (tName === "Write") {
415
+ newContent = String(tInput.content || "");
416
+ } else if (tName === "Edit") {
417
+ newContent = applyEdit(oldContent, tInput);
418
+ } else if (tName === "MultiEdit") {
419
+ newContent = applyMultiEdit(oldContent, Array.isArray(tInput.edits) ? tInput.edits : []);
420
+ }
421
+
422
+ if (oldContent === newContent) {
423
+ return { diffText: "", addedLines: 0, removedLines: 0 };
424
+ }
425
+
426
+ const tmpDir = path.join(STATE_DIR, "tmp");
427
+ await fs.mkdir(tmpDir, { recursive: true });
428
+ const rand = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
429
+ const tmpOld = path.join(tmpDir, `old_${rand}`);
430
+ const tmpNew = path.join(tmpDir, `new_${rand}`);
431
+
432
+ try {
433
+ await fs.writeFile(tmpOld, oldContent, "utf8");
434
+ await fs.writeFile(tmpNew, newContent, "utf8");
435
+
436
+ let diffText = "";
437
+ try {
438
+ const { stdout } = await execFileAsync("diff", ["-u", tmpOld, tmpNew]);
439
+ diffText = stdout;
440
+ } catch (err) {
441
+ // diff exits with code 1 when there are differences — that's normal
442
+ diffText = String(err.stdout || "");
443
+ }
444
+ // Replace temp file paths with the actual file path in the diff header
445
+ if (diffText) {
446
+ const tmpOldEscaped = tmpOld.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
447
+ const tmpNewEscaped = tmpNew.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
448
+ diffText = diffText
449
+ .replace(new RegExp(tmpOldEscaped, "gu"), `a/${filePath}`)
450
+ .replace(new RegExp(tmpNewEscaped, "gu"), `b/${filePath}`);
451
+ }
452
+
453
+ let addedLines = 0;
454
+ let removedLines = 0;
455
+ for (const line of diffText.split("\n")) {
456
+ if (line.startsWith("+") && !line.startsWith("+++")) addedLines++;
457
+ else if (line.startsWith("-") && !line.startsWith("---")) removedLines++;
458
+ }
459
+
460
+ return { diffText, addedLines, removedLines };
461
+ } finally {
462
+ await fs.unlink(tmpOld).catch(() => {});
463
+ await fs.unlink(tmpNew).catch(() => {});
464
+ }
465
+ }
466
+
467
+ async function computeUnifiedDiff(oldContent, newContent, displayPath) {
468
+ if (oldContent === newContent) {
469
+ return { diffText: "", addedLines: 0, removedLines: 0 };
470
+ }
471
+ const tmpDir = path.join(STATE_DIR, "tmp");
472
+ await fs.mkdir(tmpDir, { recursive: true });
473
+ const rand = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
474
+ const tmpOld = path.join(tmpDir, `old_${rand}`);
475
+ const tmpNew = path.join(tmpDir, `new_${rand}`);
476
+ try {
477
+ await fs.writeFile(tmpOld, oldContent, "utf8");
478
+ await fs.writeFile(tmpNew, newContent, "utf8");
479
+ let diffText = "";
480
+ try {
481
+ const { stdout } = await execFileAsync("diff", ["-u", tmpOld, tmpNew]);
482
+ diffText = stdout;
483
+ } catch (err) {
484
+ diffText = String(err.stdout || "");
485
+ }
486
+ if (diffText && displayPath) {
487
+ const tmpOldEscaped = tmpOld.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
488
+ const tmpNewEscaped = tmpNew.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
489
+ diffText = diffText
490
+ .replace(new RegExp(tmpOldEscaped, "gu"), `a/${displayPath}`)
491
+ .replace(new RegExp(tmpNewEscaped, "gu"), `b/${displayPath}`);
492
+ }
493
+ let addedLines = 0;
494
+ let removedLines = 0;
495
+ for (const line of diffText.split("\n")) {
496
+ if (line.startsWith("+") && !line.startsWith("+++")) addedLines++;
497
+ else if (line.startsWith("-") && !line.startsWith("---")) removedLines++;
498
+ }
499
+ return { diffText, addedLines, removedLines };
500
+ } finally {
501
+ await fs.unlink(tmpOld).catch(() => {});
502
+ await fs.unlink(tmpNew).catch(() => {});
503
+ }
504
+ }
505
+
506
+ function applyEdit(content, edit) {
507
+ const oldStr = String(edit.old_string || "");
508
+ const newStr = String(edit.new_string || "");
509
+ if (edit.replace_all === true) {
510
+ return content.split(oldStr).join(newStr);
511
+ }
512
+ const idx = content.indexOf(oldStr);
513
+ if (idx === -1) return content;
514
+ return content.slice(0, idx) + newStr + content.slice(idx + oldStr.length);
515
+ }
516
+
517
+ function applyMultiEdit(content, edits) {
518
+ let result = content;
519
+ for (const edit of edits) {
520
+ result = applyEdit(result, edit);
521
+ }
522
+ return result;
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // HTTP helper — posts to viveworker bridge
527
+ // ---------------------------------------------------------------------------
528
+
529
+ async function postEvent(eventName, body, timeoutMs = DEFAULT_EVENT_TIMEOUT_MS) {
530
+ const url = `${BASE_URL}/api/providers/claude/events`;
531
+ const payload = JSON.stringify({ ...body, hookEventName: eventName });
532
+
533
+ return new Promise((resolve) => {
534
+ let resolved = false;
535
+ const done = (value) => {
536
+ if (!resolved) {
537
+ resolved = true;
538
+ resolve(value);
539
+ }
540
+ };
541
+
542
+ const timer = setTimeout(() => done({}), timeoutMs);
543
+
544
+ let parsedUrl;
545
+ try {
546
+ parsedUrl = new URL(url);
547
+ } catch {
548
+ clearTimeout(timer);
549
+ done({});
550
+ return;
551
+ }
552
+
553
+ const isHttps = parsedUrl.protocol === "https:";
554
+ const port = parsedUrl.port
555
+ ? Number(parsedUrl.port)
556
+ : isHttps ? 443 : 80;
557
+
558
+ const options = {
559
+ hostname: parsedUrl.hostname,
560
+ port,
561
+ path: parsedUrl.pathname + parsedUrl.search,
562
+ method: "POST",
563
+ headers: {
564
+ "Content-Type": "application/json",
565
+ "Content-Length": Buffer.byteLength(payload),
566
+ "x-viveworker-hook-secret": HOOK_SECRET,
567
+ },
568
+ // Allow self-signed certs for loopback/LAN connections
569
+ rejectUnauthorized: false,
570
+ };
571
+
572
+ const protocol = isHttps ? https : http;
573
+ const req = protocol.request(options, (res) => {
574
+ let data = "";
575
+ res.on("data", (chunk) => { data += chunk; });
576
+ res.on("end", () => {
577
+ clearTimeout(timer);
578
+ try {
579
+ done(data ? JSON.parse(data) : {});
580
+ } catch {
581
+ done({});
582
+ }
583
+ });
584
+ });
585
+
586
+ req.on("error", () => {
587
+ clearTimeout(timer);
588
+ done({});
589
+ });
590
+
591
+ req.write(payload);
592
+ req.end();
593
+ });
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Utilities
598
+ // ---------------------------------------------------------------------------
599
+
600
+ async function readStdin() {
601
+ return new Promise((resolve) => {
602
+ if (process.stdin.isTTY) {
603
+ resolve("");
604
+ return;
605
+ }
606
+ const rl = createInterface({ input: process.stdin });
607
+ const lines = [];
608
+ rl.on("line", (line) => lines.push(line));
609
+ rl.on("close", () => resolve(lines.join("\n")));
610
+ });
611
+ }
612
+
613
+ function resolvePath(p) {
614
+ if (!p) return p;
615
+ if (p === "~") return os.homedir();
616
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
617
+ return p;
618
+ }
619
+
620
+ function stripTrailingSlash(s) {
621
+ return s.replace(/\/+$/u, "");
622
+ }
623
+
624
+ function generateId() {
625
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
626
+ }
627
+
628
+ function sanitizeForPath(s) {
629
+ return s.replace(/[^a-zA-Z0-9_-]/gu, "_").slice(0, 64);
630
+ }
631
+
632
+ function encodeFilePathForSnapshot(filePath) {
633
+ return filePath.replace(/[/\\]/gu, "_") + ".snap";
634
+ }
635
+
636
+ function isPlainObject(v) {
637
+ return v !== null && typeof v === "object" && !Array.isArray(v);
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Browser auto-open helper (away-mode intercept → viveworker Web UI)
642
+ // ---------------------------------------------------------------------------
643
+
644
+ async function openBrowserWithThrottle() {
645
+ if (!BASE_URL) return;
646
+ const throttleFile = path.join(STATE_DIR, "last-browser-open-ms");
647
+ const now = Date.now();
648
+ try {
649
+ const raw = await fs.readFile(throttleFile, "utf8");
650
+ const last = Number(raw.trim());
651
+ if (Number.isFinite(last) && now - last < BROWSER_OPEN_THROTTLE_MS) {
652
+ return;
653
+ }
654
+ } catch {
655
+ // No throttle file yet, or unreadable — proceed.
656
+ }
657
+ try {
658
+ await fs.writeFile(throttleFile, String(now), "utf8");
659
+ } catch {
660
+ // Failing to record the timestamp just means we might open twice; fine.
661
+ }
662
+ try {
663
+ // Target URL carries a `focusPending=claude` hint plus a fresh timestamp
664
+ // so the web app knows to auto-open the newest pending Claude plan /
665
+ // question on boot. The timestamp also forces a reload when we navigate
666
+ // an already-open tab, so the popup re-runs its focus logic.
667
+ const prefix = `${BASE_URL}/app`;
668
+ const targetUrl = `${prefix}?focusPending=claude&ts=${Date.now()}`;
669
+ // Try to focus (and navigate) an existing viveworker tab in any running
670
+ // browser first; only open a new popup window if none exists. Handled via
671
+ // AppleScript so the user does not accumulate duplicate tabs/windows.
672
+ //
673
+ // New windows are launched as a Chromium `--app` popup (chromeless,
674
+ // mobile-sized, top-right) if any Chromium-family browser is installed;
675
+ // otherwise we fall back to a plain `open <url>` in the default browser.
676
+ //
677
+ // Fire-and-forget: the `osascript` child exits in well under a second,
678
+ // and we unref() so node never waits on it. Any failure (no running
679
+ // browser matching, permission denied, non-macOS) is swallowed.
680
+ const chromium = await pickChromiumBrowser();
681
+ const script = buildFocusOrOpenAppleScript(targetUrl, prefix, chromium);
682
+ const child = execFile("osascript", ["-e", script], () => {});
683
+ child.unref();
684
+ } catch {
685
+ // execFile throwing synchronously is rare; swallow.
686
+ }
687
+ }
688
+
689
+ async function pickChromiumBrowser() {
690
+ const candidates = [
691
+ { path: "/Applications/Brave Browser.app", name: "Brave Browser" },
692
+ { path: "/Applications/Arc.app", name: "Arc" },
693
+ { path: "/Applications/Google Chrome.app", name: "Google Chrome" },
694
+ { path: "/Applications/Microsoft Edge.app", name: "Microsoft Edge" },
695
+ { path: "/Applications/Vivaldi.app", name: "Vivaldi" },
696
+ ];
697
+ // Build installed-set first.
698
+ const installed = [];
699
+ for (const c of candidates) {
700
+ try {
701
+ await fs.stat(c.path);
702
+ installed.push(c);
703
+ } catch {
704
+ // Not installed.
705
+ }
706
+ }
707
+ if (installed.length === 0) return null;
708
+ // Prefer whichever Chromium browser is currently running — that is where
709
+ // the user's existing viveworker pairing session lives, so the `--app`
710
+ // popup inherits the same cookies instead of asking for a pairing code.
711
+ for (const c of installed) {
712
+ if (await isProcessRunningByName(c.name)) {
713
+ return c;
714
+ }
715
+ }
716
+ // None running: fall back to the first installed candidate.
717
+ return installed[0];
718
+ }
719
+
720
+ function isProcessRunningByName(processName) {
721
+ return new Promise((resolve) => {
722
+ // `pgrep -xq` matches an exact process name. macOS reports the displayed
723
+ // app name (e.g. "Brave Browser") as the process name for GUI apps.
724
+ execFile("pgrep", ["-xq", processName], (err) => {
725
+ resolve(!err);
726
+ });
727
+ });
728
+ }
729
+
730
+ function buildFocusOrOpenAppleScript(targetUrl, prefix, chromium) {
731
+ // Escape for embedding inside an AppleScript string literal.
732
+ const escUrl = targetUrl.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
733
+ const escPrefix = prefix.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
734
+ const escChromiumName = chromium
735
+ ? chromium.name.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')
736
+ : "";
737
+
738
+ // Popup geometry: iPhone 14-ish footprint pinned to the primary display's
739
+ // top-right corner (with a small margin + allowance for the menu bar).
740
+ const POPUP_W = 390;
741
+ const POPUP_H = 800;
742
+ const POPUP_RIGHT_MARGIN = 20;
743
+ const POPUP_TOP_MARGIN = 40;
744
+
745
+ // Launch phase: if a Chromium-family browser is available, open the Web UI
746
+ // as a chromeless `--app` popup window and then force the bounds explicitly
747
+ // via AppleScript (Chromium sometimes ignores `--window-size`/`position`
748
+ // when it restores state from its local profile).
749
+ const launchPhase = chromium
750
+ ? `
751
+ set screenWidth to 1920
752
+ try
753
+ tell application "Finder"
754
+ set desktopBounds to bounds of window of desktop
755
+ end tell
756
+ set screenWidth to (item 3 of desktopBounds) as integer
757
+ end try
758
+ set popupW to ${POPUP_W}
759
+ set popupH to ${POPUP_H}
760
+ set popupX to (screenWidth - popupW - ${POPUP_RIGHT_MARGIN})
761
+ set popupY to ${POPUP_TOP_MARGIN}
762
+ set cmd to "open -na " & quoted form of "${escChromiumName}" & " --args --app=" & quoted form of targetURL & " --window-size=" & popupW & "," & popupH & " --window-position=" & popupX & "," & popupY
763
+ do shell script cmd
764
+ delay 0.5
765
+ try
766
+ using terms from application "Google Chrome"
767
+ tell application "${escChromiumName}"
768
+ repeat with w in windows
769
+ try
770
+ if (URL of active tab of w as string) starts with prefix then
771
+ set bounds of w to {popupX, popupY, popupX + popupW, popupY + popupH}
772
+ activate
773
+ exit repeat
774
+ end if
775
+ end try
776
+ end repeat
777
+ end tell
778
+ end using terms from
779
+ end try
780
+ return "launched"
781
+ `
782
+ : `
783
+ do shell script "open " & quoted form of targetURL
784
+ return "opened"
785
+ `;
786
+
787
+ return `
788
+ set targetURL to "${escUrl}"
789
+ set prefix to "${escPrefix}"
790
+
791
+ set chromiumBrowsers to {"Google Chrome", "Arc", "Brave Browser", "Microsoft Edge", "Vivaldi"}
792
+
793
+ repeat with appName in chromiumBrowsers
794
+ try
795
+ tell application "System Events"
796
+ if not (exists (processes where name is (appName as string))) then error "not running"
797
+ end tell
798
+ using terms from application "Google Chrome"
799
+ tell application (appName as string)
800
+ repeat with w in windows
801
+ set tIdx to 0
802
+ repeat with t in tabs of w
803
+ set tIdx to tIdx + 1
804
+ if (URL of t as string) starts with prefix then
805
+ -- Do NOT rewrite the tab URL here. The running web app will
806
+ -- auto-navigate to the newest unresolved Claude pending via
807
+ -- its polling loop, but only when the user is not currently
808
+ -- mid-answer on another pending item.
809
+ set active tab index of w to tIdx
810
+ set index of w to 1
811
+ activate
812
+ return "focused"
813
+ end if
814
+ end repeat
815
+ end repeat
816
+ end tell
817
+ end using terms from
818
+ end try
819
+ end repeat
820
+
821
+ try
822
+ tell application "System Events"
823
+ if not (exists (processes where name is "Safari")) then error "not running"
824
+ end tell
825
+ tell application "Safari"
826
+ repeat with w in windows
827
+ repeat with t in tabs of w
828
+ if (URL of t as string) starts with prefix then
829
+ set current tab of w to t
830
+ set index of w to 1
831
+ activate
832
+ return "focused"
833
+ end if
834
+ end repeat
835
+ end repeat
836
+ end tell
837
+ end try
838
+ ${launchPhase}`;
839
+ }