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.
- package/README.md +27 -8
- package/package.json +7 -2
- package/scripts/viveworker-bridge.mjs +1146 -151
- package/scripts/viveworker-claude-hook.mjs +839 -0
- package/scripts/viveworker.mjs +71 -0
- package/web/app.css +171 -0
- package/web/app.js +523 -59
- package/web/i18n.js +115 -71
|
@@ -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
|
+
}
|