viveworker 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,6 +64,12 @@ const runtime = {
64
64
  threadOwnerClientIds: new Map(),
65
65
  nativeApprovalsByToken: new Map(),
66
66
  nativeApprovalsByRequestKey: new Map(),
67
+ claudeApprovalWaiters: new Map(),
68
+ claudeKnownFiles: [],
69
+ lastClaudeScanAt: 0,
70
+ claudeFileStates: new Map(),
71
+ claudeSessionTitles: new Map(),
72
+ lastClaudeSessionTitleScanAt: 0,
67
73
  fileApprovalDeltasById: new Map(),
68
74
  planRequestsByToken: new Map(),
69
75
  planRequestsByRequestKey: new Map(),
@@ -82,6 +88,7 @@ const runtime = {
82
88
  stopping: false,
83
89
  };
84
90
  const state = await loadState(config.stateFile);
91
+ await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
85
92
  const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
86
93
  const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
87
94
  const restoredPendingUserInputStateChanged = restorePendingUserInputRequests({ config, runtime, state });
@@ -204,6 +211,7 @@ function buildSessionLocalePayload(config, state, deviceId) {
204
211
  defaultLocale: defaultLocale(config),
205
212
  deviceDetectedLocale: resolved.detectedLocale || null,
206
213
  deviceOverrideLocale: resolved.overrideLocale || null,
214
+ claudeAwayMode: state?.claudeAwayMode === true,
207
215
  };
208
216
  }
209
217
 
@@ -311,18 +319,19 @@ function fileEventTitle(locale, fileEventType) {
311
319
  }
312
320
  }
313
321
 
314
- function fileEventDetailCopy(locale, fileEventType) {
322
+ function fileEventDetailCopy(locale, fileEventType, provider) {
323
+ const vars = { provider: providerDisplayName(locale, provider) };
315
324
  switch (normalizeTimelineFileEventType(fileEventType)) {
316
325
  case "read":
317
- return t(locale, "detail.fileEvent.read");
326
+ return t(locale, "detail.fileEvent.read", vars);
318
327
  case "write":
319
- return t(locale, "detail.fileEvent.write");
328
+ return t(locale, "detail.fileEvent.write", vars);
320
329
  case "create":
321
- return t(locale, "detail.fileEvent.create");
330
+ return t(locale, "detail.fileEvent.create", vars);
322
331
  case "delete":
323
- return t(locale, "detail.fileEvent.delete");
332
+ return t(locale, "detail.fileEvent.delete", vars);
324
333
  case "rename":
325
- return t(locale, "detail.fileEvent.rename");
334
+ return t(locale, "detail.fileEvent.rename", vars);
326
335
  default:
327
336
  return t(locale, "detail.detailUnavailable");
328
337
  }
@@ -1978,6 +1987,15 @@ function handleSignal() {
1978
1987
  runtime.stopping = true;
1979
1988
  }
1980
1989
 
1990
+ function normalizeProvider(value) {
1991
+ const normalized = String(value || "").toLowerCase();
1992
+ return normalized === "claude" ? "claude" : "codex";
1993
+ }
1994
+
1995
+ function providerDisplayName(locale, provider) {
1996
+ return t(locale, normalizeProvider(provider) === "claude" ? "common.claude" : "common.codex");
1997
+ }
1998
+
1981
1999
  function normalizeHistoryItems(rawItems, maxItems) {
1982
2000
  if (!Array.isArray(rawItems)) {
1983
2001
  return [];
@@ -2044,6 +2062,7 @@ function normalizeHistoryItem(raw) {
2044
2062
  readOnly: raw.readOnly !== false,
2045
2063
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
2046
2064
  tone: cleanText(raw.tone ?? "") || "secondary",
2065
+ provider: normalizeProvider(raw.provider),
2047
2066
  };
2048
2067
  }
2049
2068
 
@@ -2196,6 +2215,7 @@ function normalizeTimelineEntry(raw) {
2196
2215
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
2197
2216
  tone: cleanText(raw.tone ?? "") || "secondary",
2198
2217
  cwd: resolvePath(cleanText(raw.cwd || "")),
2218
+ provider: normalizeProvider(raw.provider),
2199
2219
  };
2200
2220
  }
2201
2221
 
@@ -2421,6 +2441,7 @@ function recordActionHistoryItem({
2421
2441
  diffAddedLines = 0,
2422
2442
  diffRemovedLines = 0,
2423
2443
  outcome = "",
2444
+ provider = "codex",
2424
2445
  }) {
2425
2446
  const item = {
2426
2447
  stableId,
@@ -2438,6 +2459,7 @@ function recordActionHistoryItem({
2438
2459
  diffAddedLines,
2439
2460
  diffRemovedLines,
2440
2461
  outcome,
2462
+ provider: normalizeProvider(provider),
2441
2463
  createdAtMs: Date.now(),
2442
2464
  readOnly: true,
2443
2465
  primaryLabel: "詳細",
@@ -2723,6 +2745,21 @@ async function scanOnce({ config, runtime, state }) {
2723
2745
  dirty = dirty || changed;
2724
2746
  }
2725
2747
 
2748
+ if (config.webUiEnabled) {
2749
+ if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
2750
+ runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
2751
+ runtime.lastClaudeScanAt = now;
2752
+ }
2753
+ if (now - runtime.lastClaudeSessionTitleScanAt >= config.directoryScanIntervalMs) {
2754
+ await refreshClaudeSessionTitles(runtime);
2755
+ runtime.lastClaudeSessionTitleScanAt = now;
2756
+ }
2757
+ for (const filePath of runtime.claudeKnownFiles) {
2758
+ const changed = await processClaudeTranscriptFile({ filePath, config, runtime, state, now });
2759
+ dirty = dirty || changed;
2760
+ }
2761
+ }
2762
+
2726
2763
  if (config.webUiEnabled) {
2727
2764
  const sqliteTimelineChanged = await processSqliteTimelineLog({
2728
2765
  config,
@@ -2945,6 +2982,230 @@ function fileEventCallIdFromStableId(stableId) {
2945
2982
  return match ? cleanText(match[1]) : "";
2946
2983
  }
2947
2984
 
2985
+ async function refreshClaudeSessionTitles(runtime) {
2986
+ // Read ~/Library/Application Support/Claude/claude-code-sessions/*/*/local_*.json
2987
+ // Each file maps cliSessionId → title (auto-generated by Claude Desktop).
2988
+ const baseDir = path.join(
2989
+ os.homedir(),
2990
+ "Library",
2991
+ "Application Support",
2992
+ "Claude",
2993
+ "claude-code-sessions"
2994
+ );
2995
+ try {
2996
+ const topEntries = await fs.readdir(baseDir, { withFileTypes: true });
2997
+ for (const top of topEntries) {
2998
+ if (!top.isDirectory()) continue;
2999
+ const topPath = path.join(baseDir, top.name);
3000
+ let midEntries;
3001
+ try {
3002
+ midEntries = await fs.readdir(topPath, { withFileTypes: true });
3003
+ } catch {
3004
+ continue;
3005
+ }
3006
+ for (const mid of midEntries) {
3007
+ if (!mid.isDirectory()) continue;
3008
+ const midPath = path.join(topPath, mid.name);
3009
+ let files;
3010
+ try {
3011
+ files = await fs.readdir(midPath, { withFileTypes: true });
3012
+ } catch {
3013
+ continue;
3014
+ }
3015
+ for (const file of files) {
3016
+ if (!file.isFile() || !file.name.startsWith("local_") || !file.name.endsWith(".json")) continue;
3017
+ try {
3018
+ const raw = await fs.readFile(path.join(midPath, file.name), "utf8");
3019
+ const data = JSON.parse(raw);
3020
+ const cliSessionId = cleanText(data?.cliSessionId || "");
3021
+ const title = cleanText(data?.title || "");
3022
+ if (cliSessionId && title) {
3023
+ runtime.claudeSessionTitles.set(cliSessionId, title);
3024
+ }
3025
+ } catch {
3026
+ // skip unreadable/invalid
3027
+ }
3028
+ }
3029
+ }
3030
+ }
3031
+ } catch {
3032
+ // base dir missing — Claude Desktop not installed
3033
+ }
3034
+ }
3035
+
3036
+ async function listClaudeTranscriptFiles(claudeProjectsDir) {
3037
+ try {
3038
+ const result = [];
3039
+ const entries = await fs.readdir(claudeProjectsDir, { withFileTypes: true });
3040
+ for (const entry of entries) {
3041
+ if (!entry.isDirectory()) continue;
3042
+ const projectPath = path.join(claudeProjectsDir, entry.name);
3043
+ try {
3044
+ const files = await fs.readdir(projectPath, { withFileTypes: true });
3045
+ for (const file of files) {
3046
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
3047
+ result.push(path.join(projectPath, file.name));
3048
+ }
3049
+ } catch {
3050
+ // skip unreadable project dirs
3051
+ }
3052
+ }
3053
+ return result;
3054
+ } catch {
3055
+ return [];
3056
+ }
3057
+ }
3058
+
3059
+ async function processClaudeTranscriptFile({ filePath, config, runtime, state, now }) {
3060
+ let fileState = runtime.claudeFileStates.get(filePath);
3061
+ if (!fileState) {
3062
+ // Derive a default threadLabel from the project directory path
3063
+ // e.g. ~/.claude/projects/-Users-y-hoshino-Work-kanade/session.jsonl → "kanade"
3064
+ const projectDirName = path.basename(path.dirname(filePath));
3065
+ const decodedProjectDir = projectDirName.replace(/^-/u, "").replace(/-/gu, "/");
3066
+ const defaultThreadLabel = path.basename(decodedProjectDir) || "";
3067
+
3068
+ // On startup, use the same "recent only" strategy as processRolloutFile to avoid
3069
+ // re-processing old entries that would be immediately discarded by the 250-entry limit.
3070
+ let initialOffset = 0;
3071
+ let startupCutoffMs = 0;
3072
+ try {
3073
+ const stat = await fs.stat(filePath);
3074
+ const shouldReplayRecent = stat.mtimeMs >= now - config.replaySeconds * 1000;
3075
+ if (shouldReplayRecent) {
3076
+ // Recently active transcript: start from near the end and apply a time cutoff
3077
+ initialOffset = Math.max(0, stat.size - config.maxReadBytes);
3078
+ startupCutoffMs = now - config.replaySeconds * 1000;
3079
+ } else {
3080
+ // Inactive transcript: skip to end, only process future appends
3081
+ initialOffset = stat.size;
3082
+ }
3083
+ } catch {
3084
+ // stat failed — start from beginning with no cutoff
3085
+ }
3086
+
3087
+ fileState = { offset: initialOffset, threadId: "", threadLabel: defaultThreadLabel, cwd: "", startupCutoffMs, remainder: "" };
3088
+ runtime.claudeFileStates.set(filePath, fileState);
3089
+ }
3090
+
3091
+ let fileContent;
3092
+ let newOffset;
3093
+ try {
3094
+ const stat = await fs.stat(filePath);
3095
+ if (stat.size <= fileState.offset) return false;
3096
+ if (stat.size - fileState.offset > config.maxReadBytes) {
3097
+ fileState.offset = Math.max(0, stat.size - config.maxReadBytes);
3098
+ fileState.remainder = "";
3099
+ }
3100
+ const readLen = stat.size - fileState.offset;
3101
+ const buf = Buffer.alloc(readLen);
3102
+ const fh = await fs.open(filePath, "r");
3103
+ try {
3104
+ const { bytesRead } = await fh.read(buf, 0, readLen, fileState.offset);
3105
+ fileContent = buf.slice(0, bytesRead).toString("utf8");
3106
+ } finally {
3107
+ await fh.close();
3108
+ }
3109
+ newOffset = stat.size;
3110
+ } catch {
3111
+ return false;
3112
+ }
3113
+
3114
+ const merged = (fileState.remainder || "") + fileContent;
3115
+ const lines = merged.split("\n");
3116
+ // Last element is the in-progress partial line (or "" if file ends with \n).
3117
+ // Stash it as the next remainder so a future scan can complete it.
3118
+ fileState.remainder = lines.pop() ?? "";
3119
+ fileState.offset = newOffset;
3120
+ let dirty = false;
3121
+
3122
+ for (const line of lines) {
3123
+ const trimmed = line.trim();
3124
+ if (!trimmed) continue;
3125
+
3126
+ let record;
3127
+ try {
3128
+ record = JSON.parse(trimmed);
3129
+ } catch {
3130
+ continue;
3131
+ }
3132
+
3133
+ // Apply startup cutoff: skip entries older than replaySeconds on first scan
3134
+ if (fileState.startupCutoffMs && record.timestamp) {
3135
+ const recordMs = Date.parse(record.timestamp);
3136
+ if (recordMs && recordMs < fileState.startupCutoffMs) {
3137
+ // Still update threadId/cwd state so later entries inherit correct context
3138
+ if (record.sessionId) fileState.threadId = record.sessionId;
3139
+ if (record.cwd) {
3140
+ fileState.cwd = record.cwd;
3141
+ fileState.threadLabel = path.basename(record.cwd);
3142
+ }
3143
+ continue;
3144
+ }
3145
+ }
3146
+
3147
+ if (record.sessionId) fileState.threadId = record.sessionId;
3148
+ if (record.cwd) {
3149
+ fileState.cwd = record.cwd;
3150
+ fileState.threadLabel = path.basename(record.cwd);
3151
+ }
3152
+
3153
+ // Only process Claude Desktop sessions
3154
+ if (record.entrypoint !== "claude-desktop") continue;
3155
+
3156
+ const type = record.type;
3157
+ if (type !== "user" && type !== "assistant") continue;
3158
+ // Skip Claude Code's meta records (e.g. Stop hook feedback injected as a
3159
+ // synthetic user message). They are not real user input.
3160
+ if (record.isMeta === true) continue;
3161
+
3162
+ const msg = record.message || {};
3163
+ const content = msg.content;
3164
+ const uuid = cleanText(record.uuid || "");
3165
+ const createdAtMs = record.timestamp ? Date.parse(record.timestamp) : now;
3166
+ const threadId = cleanText(fileState.threadId || record.sessionId || "");
3167
+ // Prefer Claude Desktop's auto-generated session title (e.g. "Sync Codex app from Mac to iPhone")
3168
+ // when available; fall back to the cwd-derived basename (e.g. "viveworker").
3169
+ const claudeTitle = threadId ? runtime.claudeSessionTitles.get(threadId) || "" : "";
3170
+ const threadLabel = claudeTitle || fileState.threadLabel || "";
3171
+
3172
+ let text = "";
3173
+ if (typeof content === "string") {
3174
+ text = content;
3175
+ } else if (Array.isArray(content)) {
3176
+ for (const block of content) {
3177
+ if (block?.type === "text" && block.text) text += block.text;
3178
+ }
3179
+ }
3180
+ text = cleanText(text);
3181
+ if (!text) continue;
3182
+
3183
+ const kind = type === "user" ? "user_message" : "assistant_final";
3184
+ const stableId = `${kind}:${threadId}:${uuid}`;
3185
+ const entry = normalizeTimelineEntry({
3186
+ stableId,
3187
+ token: historyToken(stableId),
3188
+ kind,
3189
+ threadId,
3190
+ threadLabel,
3191
+ title: threadLabel || "Claude",
3192
+ summary: truncate(singleLine(text), 180),
3193
+ messageText: text,
3194
+ createdAtMs,
3195
+ readOnly: true,
3196
+ cwd: fileState.cwd,
3197
+ provider: "claude",
3198
+ });
3199
+ if (entry) {
3200
+ dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
3201
+ }
3202
+ }
3203
+
3204
+ // Clear startup cutoff after first scan so subsequent incremental reads are unfiltered
3205
+ fileState.startupCutoffMs = 0;
3206
+ return dirty;
3207
+ }
3208
+
2948
3209
  async function processSqliteCompletionLog({ config, runtime, state, now }) {
2949
3210
  const logsDbFile = cleanText(runtime.logsDbFile || config.codexLogsDbFile || "");
2950
3211
  if (!logsDbFile) {
@@ -3702,8 +3963,13 @@ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
3702
3963
  config.timelineAttachmentsDir,
3703
3964
  `${Date.now()}-${crypto.randomUUID()}${extension}`
3704
3965
  );
3705
- await fs.copyFile(normalizedSourcePath, destinationPath);
3706
- return destinationPath;
3966
+ try {
3967
+ await fs.copyFile(normalizedSourcePath, destinationPath);
3968
+ return destinationPath;
3969
+ } catch (error) {
3970
+ console.warn(`[timeline-image-copy-skipped] ${error?.message || error}`);
3971
+ return "";
3972
+ }
3707
3973
  }
3708
3974
 
3709
3975
  async function normalizePersistedTimelineImagePaths({ config, state, imagePaths = [] }) {
@@ -3746,7 +4012,7 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
3746
4012
 
3747
4013
  let persistentPath = existingSourcePath;
3748
4014
  if (!existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
3749
- persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath);
4015
+ persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath) || existingSourcePath;
3750
4016
  }
3751
4017
 
3752
4018
  aliases[normalizedPath] = persistentPath;
@@ -4287,7 +4553,7 @@ function buildRolloutEvent({ record, filePath, fileState, sessionIndex, config,
4287
4553
  const message = formatMessage(
4288
4554
  [
4289
4555
  t(config.defaultLocale, "server.message.approveOnMac"),
4290
- justification || t(config.defaultLocale, "server.message.approvalNeededInCodex"),
4556
+ justification || t(config.defaultLocale, "server.message.approvalNeededInCodex", { provider: providerDisplayName(config.defaultLocale, "codex") }),
4291
4557
  cmd ? t(config.defaultLocale, "server.message.commandPrefix", { command: cmd }) : null,
4292
4558
  approval.extraCount > 0 ? t(config.defaultLocale, "server.message.extraApprovals", { count: approval.extraCount }) : null,
4293
4559
  ],
@@ -4804,6 +5070,7 @@ async function createNativeApproval({ config, runtime, conversationId, request,
4804
5070
  createdAtMs: now,
4805
5071
  resolved: false,
4806
5072
  resolving: false,
5073
+ provider: "codex",
4807
5074
  };
4808
5075
  }
4809
5076
 
@@ -6979,7 +7246,7 @@ function getNativeThreadLabel({ runtime, conversationId, cwd }) {
6979
7246
  if (cwd) {
6980
7247
  return truncate(cleanText(path.basename(cwd)), 90) || shortId(normalizedConversationId);
6981
7248
  }
6982
- return shortId(normalizedConversationId) || "Codex task";
7249
+ return shortId(normalizedConversationId) || t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, "codex") });
6983
7250
  }
6984
7251
 
6985
7252
  function threadStateArchiveStatus(threadState) {
@@ -8667,6 +8934,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8667
8934
  summary: formatNotificationBody(approval.messageText, 100) || approval.messageText,
8668
8935
  primaryLabel: t(locale, "server.action.review"),
8669
8936
  createdAtMs: Number(approval.createdAtMs) || now,
8937
+ provider: normalizeProvider(approval.provider),
8670
8938
  });
8671
8939
  }
8672
8940
 
@@ -8686,6 +8954,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8686
8954
  summary: formatNotificationBody(planRequest.messageText, 100) || planRequest.messageText,
8687
8955
  primaryLabel: t(locale, "server.action.review"),
8688
8956
  createdAtMs: Number(planRequest.createdAtMs) || now,
8957
+ provider: "codex",
8689
8958
  });
8690
8959
  }
8691
8960
 
@@ -8709,6 +8978,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8709
8978
  summary: userInputRequest.notificationText || formatNotificationBody(userInputRequest.messageText, 100),
8710
8979
  primaryLabel: t(locale, userInputRequest.supported ? "server.action.select" : "server.action.detail"),
8711
8980
  createdAtMs: Number(userInputRequest.createdAtMs) || now,
8981
+ provider: "codex",
8712
8982
  });
8713
8983
  }
8714
8984
 
@@ -8732,6 +9002,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
8732
9002
  fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
8733
9003
  primaryLabel: t(locale, "server.action.detail"),
8734
9004
  createdAtMs: item.createdAtMs,
9005
+ provider: normalizeProvider(item.provider),
8735
9006
  }));
8736
9007
  }
8737
9008
 
@@ -8923,6 +9194,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8923
9194
  messageText: approval.messageText,
8924
9195
  outcome: "pending",
8925
9196
  createdAtMs: Number(approval.createdAtMs) || now,
9197
+ provider: normalizeProvider(approval.provider),
8926
9198
  })
8927
9199
  );
8928
9200
  }
@@ -8946,6 +9218,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8946
9218
  messageText: planRequest.messageText,
8947
9219
  outcome: "pending",
8948
9220
  createdAtMs: Number(planRequest.createdAtMs) || now,
9221
+ provider: "codex",
8949
9222
  })
8950
9223
  );
8951
9224
  }
@@ -8973,6 +9246,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8973
9246
  messageText: userInputRequest.messageText,
8974
9247
  outcome: "pending",
8975
9248
  createdAtMs: Number(userInputRequest.createdAtMs) || now,
9249
+ provider: "codex",
8976
9250
  })
8977
9251
  );
8978
9252
  }
@@ -8993,6 +9267,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8993
9267
  messageText: historyItem.messageText,
8994
9268
  outcome: historyItem.outcome,
8995
9269
  createdAtMs: historyItem.createdAtMs,
9270
+ provider: normalizeProvider(historyItem.provider),
8996
9271
  })
8997
9272
  );
8998
9273
  }
@@ -9031,7 +9306,7 @@ function buildTimelineThreads(entries, config) {
9031
9306
  }
9032
9307
  const preferredLabel =
9033
9308
  sanitizeTimelineThreadFilterLabel(entry.threadLabel || "", threadId) ||
9034
- t(DEFAULT_LOCALE, "server.fallback.codexTask");
9309
+ t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, entry?.provider) });
9035
9310
  const existing = byThread.get(threadId);
9036
9311
  if (!existing) {
9037
9312
  byThread.set(threadId, {
@@ -9080,6 +9355,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
9080
9355
  diffRemovedLines: Math.max(0, Number(entry.diffRemovedLines) || 0),
9081
9356
  outcome: entry.outcome || "",
9082
9357
  createdAtMs: entry.createdAtMs,
9358
+ provider: normalizeProvider(entry.provider),
9083
9359
  }));
9084
9360
 
9085
9361
  return {
@@ -9090,9 +9366,11 @@ function buildTimelineResponse(runtime, state, config, locale) {
9090
9366
 
9091
9367
  function buildPendingApprovalDetail(runtime, approval, locale) {
9092
9368
  const previousContext = buildPreviousApprovalContext(runtime, approval);
9093
- return {
9369
+ const approvalKind = cleanText(approval.kind || "");
9370
+ const detail = {
9094
9371
  kind: "approval",
9095
- approvalKind: cleanText(approval.kind || ""),
9372
+ approvalKind,
9373
+ provider: normalizeProvider(approval.provider),
9096
9374
  token: approval.token,
9097
9375
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
9098
9376
  threadLabel: approval.threadLabel || "",
@@ -9105,12 +9383,28 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
9105
9383
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
9106
9384
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
9107
9385
  previousContext,
9108
- readOnly: false,
9109
- actions: [
9110
- { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
9111
- { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
9112
- ],
9386
+ readOnly: approval.readOnly === true,
9387
+ actions: approval.readOnly === true
9388
+ ? []
9389
+ : [
9390
+ { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
9391
+ { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
9392
+ ],
9113
9393
  };
9394
+ if (approvalKind === "plan") {
9395
+ const planText = String(approval.planText || "");
9396
+ detail.planText = planText;
9397
+ detail.planHtml = planText
9398
+ ? renderMessageHtml(planText, `<p>${escapeHtml(t(locale, "detail.planReady"))}</p>`)
9399
+ : "";
9400
+ }
9401
+ if (approvalKind === "question") {
9402
+ detail.questions = Array.isArray(approval.questions) ? approval.questions : [];
9403
+ detail.answerUrl = `/api/items/question/${encodeURIComponent(approval.token)}/answer`;
9404
+ // Question answers are submitted via answerUrl; remove approve/reject actions.
9405
+ detail.actions = [];
9406
+ }
9407
+ return detail;
9114
9408
  }
9115
9409
 
9116
9410
  function buildPreviousApprovalContext(runtime, approval) {
@@ -9393,7 +9687,7 @@ function buildTimelineFileEventDetail(entry, locale) {
9393
9687
  threadLabel: entry.threadLabel || "",
9394
9688
  fileEventType,
9395
9689
  createdAtMs: Number(entry.createdAtMs) || 0,
9396
- messageHtml: renderMessageHtml(fileEventDetailCopy(locale, fileEventType), `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
9690
+ messageHtml: renderMessageHtml(fileEventDetailCopy(locale, fileEventType, entry?.provider), `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
9397
9691
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
9398
9692
  previousFileRefs: normalizeTimelineFileRefs(entry.previousFileRefs ?? []),
9399
9693
  diffAvailable: Boolean(entry.diffAvailable),
@@ -9522,7 +9816,7 @@ async function submitGenericUserInputDecision({ config, runtime, state, userInpu
9522
9816
  title: userInputRequest.title,
9523
9817
  messageText: userInputRequest.testRequest
9524
9818
  ? `${t(config.defaultLocale, "server.message.choiceSubmittedTest")}\n\n${buildChoiceHistoryText(userInputRequest, submittedAnswers)}`
9525
- : `${t(config.defaultLocale, "server.message.choiceSubmitted")}\n\n${buildChoiceHistoryText(userInputRequest, submittedAnswers)}`,
9819
+ : `${t(config.defaultLocale, "server.message.choiceSubmitted", { provider: providerDisplayName(config.defaultLocale, "codex") })}\n\n${buildChoiceHistoryText(userInputRequest, submittedAnswers)}`,
9526
9820
  summary: userInputRequest.testRequest
9527
9821
  ? t(config.defaultLocale, "server.message.choiceSummaryReceivedTest")
9528
9822
  : t(config.defaultLocale, "server.message.choiceSummarySubmitted"),
@@ -9960,8 +10254,98 @@ async function handlePlanDecision({ config, runtime, state, planRequest, decisio
9960
10254
  console.log(`[plan-decision] ${planRequest.requestKey} | ${decision} | ${decisionTransport}`);
9961
10255
  }
9962
10256
 
10257
+ function claudeToolFingerprint(toolName, toolInput) {
10258
+ if (!toolInput || typeof toolInput !== "object") return "";
10259
+ if (toolName === "Bash") {
10260
+ return String(toolInput.command || "").slice(0, 500);
10261
+ }
10262
+ if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
10263
+ return String(toolInput.file_path || toolInput.path || "");
10264
+ }
10265
+ if (toolName === "AskUserQuestion") {
10266
+ // PostToolUse may include the user's `answers` in toolInput, which would
10267
+ // change a naive JSON.stringify fingerprint. Hash only the question texts.
10268
+ try {
10269
+ const qs = Array.isArray(toolInput.questions) ? toolInput.questions : [];
10270
+ return JSON.stringify(qs.map((q) => String(q?.question || q?.header || ""))).slice(0, 500);
10271
+ } catch {
10272
+ return "";
10273
+ }
10274
+ }
10275
+ if (toolName === "ExitPlanMode") {
10276
+ return String(toolInput.plan || "").slice(0, 500);
10277
+ }
10278
+ try {
10279
+ return JSON.stringify(toolInput).slice(0, 500);
10280
+ } catch {
10281
+ return "";
10282
+ }
10283
+ }
10284
+
10285
+ function formatClaudeQuestionAnswers(questions, answers) {
10286
+ if (!Array.isArray(questions) || !Array.isArray(answers)) return "";
10287
+ const lines = [];
10288
+ for (let i = 0; i < questions.length; i++) {
10289
+ const q = questions[i] || {};
10290
+ const ans = answers[i] || {};
10291
+ const questionText = String(q.question || q.header || "").trim();
10292
+ if (!questionText) continue;
10293
+ const options = Array.isArray(q.options) ? q.options : [];
10294
+ const optionIndices = Array.isArray(ans.optionIndices) ? ans.optionIndices : [];
10295
+ const labels = optionIndices
10296
+ .map((idx) => options[idx]?.label)
10297
+ .filter((label) => typeof label === "string" && label.length > 0);
10298
+ const note = typeof ans.note === "string" ? ans.note.trim() : "";
10299
+ let answerLine = "";
10300
+ if (labels.length > 0) {
10301
+ answerLine = labels.join(", ");
10302
+ }
10303
+ if (note) {
10304
+ answerLine = answerLine ? `${answerLine} (note: ${note})` : note;
10305
+ }
10306
+ if (!answerLine) {
10307
+ answerLine = "(no answer)";
10308
+ }
10309
+ lines.push(`Q${i + 1}: ${questionText}`);
10310
+ lines.push(`A: ${answerLine}`);
10311
+ }
10312
+ return lines.join("\n");
10313
+ }
10314
+
10315
+ function findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint) {
10316
+ let match = null;
10317
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
10318
+ if (approval.resolved) continue;
10319
+ if (approval.conversationId !== threadId) continue;
10320
+ if (approval.claudeToolName && toolName && approval.claudeToolName !== toolName) continue;
10321
+ if (approval.claudeToolFingerprint && fingerprint && approval.claudeToolFingerprint !== fingerprint) continue;
10322
+ if (!match || approval.createdAtMs > match.createdAtMs) match = approval;
10323
+ }
10324
+ return match;
10325
+ }
10326
+
9963
10327
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
9964
- await runtime.ipcClient?.sendApprovalDecision(approval, decision);
10328
+ if (approval.resolveClaudeWaiter) {
10329
+ if (approval.provider === "claude" && approval.kind === "plan") {
10330
+ // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
10331
+ // (Claude still shows the native PC plan dialog). Instead, deny the tool
10332
+ // call with a reason that tells Claude the user already decided on mobile.
10333
+ approval.resolveClaudeWaiter({
10334
+ permissionDecision: "deny",
10335
+ permissionDecisionReason:
10336
+ decision === "accept"
10337
+ ? "User approved the plan via the viveworker mobile app. Proceed with implementing the plan exactly as proposed and do not call ExitPlanMode again."
10338
+ : "User rejected the plan via the viveworker mobile app. Revise the plan based on the conversation context before calling ExitPlanMode again.",
10339
+ });
10340
+ } else {
10341
+ approval.resolveClaudeWaiter(decision);
10342
+ }
10343
+ } else if (approval.provider === "claude") {
10344
+ // notifyOnly Claude approvals have no long-poll waiter and no ipcClient
10345
+ // back-channel — the desktop user already answered. Nothing to send.
10346
+ } else {
10347
+ await runtime.ipcClient?.sendApprovalDecision(approval, decision);
10348
+ }
9965
10349
  approval.resolved = true;
9966
10350
  approval.resolving = false;
9967
10351
  runtime.nativeApprovalsByToken.delete(approval.token);
@@ -9975,8 +10359,8 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
9975
10359
  token: approval.token,
9976
10360
  title: approval.title,
9977
10361
  threadLabel: approval.threadLabel || "",
9978
- messageText: `${approvalDecisionMessage(decision, config.defaultLocale)}\n\n${approval.messageText}`,
9979
- summary: approvalDecisionMessage(decision, config.defaultLocale),
10362
+ messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
10363
+ summary: approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
9980
10364
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
9981
10365
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
9982
10366
  diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
@@ -9984,6 +10368,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
9984
10368
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
9985
10369
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
9986
10370
  outcome: decision === "accept" ? "approved" : "rejected",
10371
+ provider: approval.provider || "codex",
9987
10372
  });
9988
10373
  if (stateChanged) {
9989
10374
  await saveState(config.stateFile, state);
@@ -10394,6 +10779,26 @@ function createNativeApprovalServer({ config, runtime, state }) {
10394
10779
  });
10395
10780
  }
10396
10781
 
10782
+ if (url.pathname === "/api/settings/claude-away-mode" && req.method === "POST") {
10783
+ const session = requireMutatingApiSession(req, res, config, state);
10784
+ if (!session) {
10785
+ return;
10786
+ }
10787
+ let payload;
10788
+ try {
10789
+ payload = await parseJsonBody(req);
10790
+ } catch {
10791
+ return writeJson(res, 400, { error: "invalid-json-body" });
10792
+ }
10793
+ const enabled = payload?.enabled === true;
10794
+ if (state.claudeAwayMode !== enabled) {
10795
+ state.claudeAwayMode = enabled;
10796
+ await saveState(config.stateFile, state);
10797
+ }
10798
+ await syncClaudeAwayModeSentinel(config, enabled);
10799
+ return writeJson(res, 200, { ok: true, enabled });
10800
+ }
10801
+
10397
10802
  if (url.pathname === "/api/push/status" && req.method === "GET") {
10398
10803
  const session = requireApiSession(req, res, config, state);
10399
10804
  if (!session) {
@@ -10515,6 +10920,221 @@ function createNativeApprovalServer({ config, runtime, state }) {
10515
10920
  });
10516
10921
  }
10517
10922
 
10923
+ if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
10924
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
10925
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
10926
+ return writeJson(res, 401, { error: "unauthorized" });
10927
+ }
10928
+
10929
+ let body;
10930
+ try {
10931
+ body = await parseJsonBody(req);
10932
+ } catch {
10933
+ return writeJson(res, 400, { error: "invalid-json-body" });
10934
+ }
10935
+
10936
+ const eventType = String(body.eventType || body.hookEventName || "");
10937
+
10938
+ if (eventType === "Stop" || eventType === "SessionEnd") {
10939
+ // Agent turn ended — any still-pending native approvals for this
10940
+ // session were not resolved by PostToolUse, meaning the user
10941
+ // denied them on PC. Resolve as deny so iPhone clears them.
10942
+ const threadId = String(body.threadId || body.sessionId || "");
10943
+ console.log(`[claude-stop] threadId=${threadId} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
10944
+ if (threadId) {
10945
+ const pending = [];
10946
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
10947
+ if (!approval.resolved && approval.conversationId === threadId) {
10948
+ pending.push(approval);
10949
+ }
10950
+ }
10951
+ console.log(`[claude-stop] matched=${pending.length}`);
10952
+ for (const approval of pending) {
10953
+ try {
10954
+ await handleNativeApprovalDecision({ config, runtime, state, approval, decision: "reject" });
10955
+ } catch (error) {
10956
+ console.error(`[claude-stop-resolve-error] ${approval.requestKey} | ${error.message}`);
10957
+ }
10958
+ }
10959
+ }
10960
+ return writeJson(res, 200, {});
10961
+ }
10962
+
10963
+ if (eventType === "file_event") {
10964
+ const threadId = String(body.threadId || body.sessionId || "");
10965
+ const filePath = String(body.filePath || "");
10966
+ const fileEventType = String(body.fileEventType || "");
10967
+ if (!threadId || !filePath || !fileEventType) {
10968
+ return writeJson(res, 200, {});
10969
+ }
10970
+ const eventCwd = String(body.cwd || "");
10971
+ const createdAtMs = Number(body.createdAtMs) || Date.now();
10972
+ const threadLabel =
10973
+ runtime.claudeSessionTitles.get(threadId) ||
10974
+ getNativeThreadLabel({ runtime, conversationId: threadId, cwd: eventCwd }) ||
10975
+ (eventCwd ? path.basename(eventCwd) : "") ||
10976
+ threadId.slice(0, 40);
10977
+ const stableId = `file_event:${fileEventType}:${threadId}:${historyToken(`${threadId}:${createdAtMs}:${filePath}`)}`;
10978
+ const token = historyToken(`file_event:${fileEventType}:${threadId}:${createdAtMs}:${filePath}`);
10979
+ const entry = {
10980
+ stableId,
10981
+ token,
10982
+ kind: "file_event",
10983
+ fileEventType,
10984
+ threadId,
10985
+ threadLabel,
10986
+ title: fileEventTitle(DEFAULT_LOCALE, fileEventType),
10987
+ summary: "",
10988
+ fileRefs: [filePath],
10989
+ diffText: String(body.diffText || ""),
10990
+ diffSource: "claude-hook",
10991
+ diffAvailable: body.diffAvailable === true || Boolean(body.diffText),
10992
+ diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
10993
+ diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
10994
+ createdAtMs,
10995
+ cwd: eventCwd,
10996
+ readOnly: true,
10997
+ provider: "claude",
10998
+ };
10999
+ const changed = recordTimelineEntry({ config, runtime, state, entry });
11000
+ if (changed) {
11001
+ try {
11002
+ await saveState(config.stateFile, state);
11003
+ } catch (error) {
11004
+ console.error(`[claude-file-event-save] ${error.message}`);
11005
+ }
11006
+ }
11007
+ return writeJson(res, 200, { ok: true });
11008
+ }
11009
+
11010
+ if (eventType === "PostToolUse" || eventType === "PostToolUseFailure") {
11011
+ // The tool actually ran on PC — resolve any pending native approval
11012
+ // for this session/tool as accepted (so iPhone moves it to completed).
11013
+ const threadId = String(body.threadId || body.sessionId || "");
11014
+ const toolName = String(body.toolName || "");
11015
+ const fingerprint = claudeToolFingerprint(toolName, body.toolInput);
11016
+ console.log(`[claude-post-tool] threadId=${threadId} tool=${toolName} fp=${fingerprint.slice(0, 80)} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
11017
+ const match = findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint);
11018
+ console.log(`[claude-post-tool] match=${match ? match.requestKey : "none"}`);
11019
+ if (match) {
11020
+ try {
11021
+ await handleNativeApprovalDecision({ config, runtime, state, approval: match, decision: "accept" });
11022
+ } catch (error) {
11023
+ console.error(`[claude-post-tool-resolve-error] ${match.requestKey} | ${error.message}`);
11024
+ }
11025
+ }
11026
+ return writeJson(res, 200, {});
11027
+ }
11028
+
11029
+ if (eventType !== "approval_request") {
11030
+ // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
11031
+ return writeJson(res, 200, {});
11032
+ }
11033
+
11034
+ // Approval request — register in shared map so UI can display it,
11035
+ // then long-poll until user decides (or 10 min timeout)
11036
+ const token = crypto.randomBytes(18).toString("hex");
11037
+ const threadId = String(body.threadId || "");
11038
+ const requestId = String(body.requestId || crypto.randomUUID());
11039
+ const requestKey = `${threadId}:${requestId}`;
11040
+
11041
+ const approval = {
11042
+ token,
11043
+ requestKey,
11044
+ conversationId: threadId,
11045
+ requestId,
11046
+ ownerClientId: null,
11047
+ kind: String(body.approvalKind || "command"),
11048
+ threadLabel:
11049
+ runtime.claudeSessionTitles.get(threadId) ||
11050
+ (body.cwd ? path.basename(String(body.cwd)) : "") ||
11051
+ String(body.threadId || "").slice(0, 40),
11052
+ title: config.approvalTitle,
11053
+ messageText: String(body.messageText || ""),
11054
+ fileRefs: Array.isArray(body.fileRefs) ? body.fileRefs : [],
11055
+ previousFileRefs: [],
11056
+ diffText: String(body.diffText || ""),
11057
+ diffAvailable: Boolean(body.diffAvailable),
11058
+ diffSource: String(body.diffSource || "claude_permission_request"),
11059
+ diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
11060
+ diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
11061
+ createdAtMs: Number(body.createdAtMs) || Date.now(),
11062
+ resolved: false,
11063
+ resolving: false,
11064
+ resolveClaudeWaiter: null,
11065
+ claudeToolName: String(body.toolName || ""),
11066
+ claudeToolFingerprint: claudeToolFingerprint(String(body.toolName || ""), body.toolInput),
11067
+ provider: "claude",
11068
+ planText: String(body.planText || ""),
11069
+ questions: Array.isArray(body.questions) ? body.questions : [],
11070
+ notifyOnly: body.notifyOnly === true,
11071
+ readOnly: body.notifyOnly === true,
11072
+ };
11073
+
11074
+ runtime.nativeApprovalsByToken.set(token, approval);
11075
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
11076
+
11077
+ deliverWebPushItem({
11078
+ config,
11079
+ state,
11080
+ kind: "approval",
11081
+ token: approval.token,
11082
+ stableId: pendingApprovalStableId(approval),
11083
+ title: approval.title,
11084
+ body: approval.messageText,
11085
+ buildLocalizedContent: ({ locale }) => ({
11086
+ title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
11087
+ body: approval.messageText,
11088
+ }),
11089
+ }).catch(() => {});
11090
+
11091
+ // Notify-only path: phone gets a read-only entry + push, but the
11092
+ // hook does not block on a decision. The PostToolUse handler will
11093
+ // clean it up once the desktop user finishes the tool call.
11094
+ if (approval.notifyOnly) {
11095
+ return writeJson(res, 200, { ok: true, notifyOnly: true });
11096
+ }
11097
+
11098
+ const CLAUDE_APPROVAL_WAIT_MS = 600_000; // 10 min
11099
+
11100
+ const decisionPromise = new Promise((resolve) => {
11101
+ approval.resolveClaudeWaiter = resolve;
11102
+ });
11103
+
11104
+ const timeoutPromise = new Promise((resolve) => {
11105
+ setTimeout(() => resolve("deny"), CLAUDE_APPROVAL_WAIT_MS);
11106
+ });
11107
+
11108
+ const decision = await Promise.race([decisionPromise, timeoutPromise]);
11109
+
11110
+ // Clean up if timed out (waiter was never called)
11111
+ if (!approval.resolved) {
11112
+ approval.resolved = true;
11113
+ approval.resolving = false;
11114
+ runtime.nativeApprovalsByToken.delete(token);
11115
+ runtime.nativeApprovalsByRequestKey.delete(requestKey);
11116
+ }
11117
+
11118
+ // The waiter may resolve with either a string ("accept" / "reject" /
11119
+ // "deny") or an object { permissionDecision, permissionDecisionReason }.
11120
+ // The latter is used by the AskUserQuestion answer flow so the user's
11121
+ // chosen text is fed back to Claude.
11122
+ if (decision && typeof decision === "object") {
11123
+ const responseBody = {
11124
+ permissionDecision:
11125
+ decision.permissionDecision === "allow" ? "allow" : "deny",
11126
+ };
11127
+ if (typeof decision.permissionDecisionReason === "string" && decision.permissionDecisionReason) {
11128
+ responseBody.permissionDecisionReason = decision.permissionDecisionReason;
11129
+ }
11130
+ return writeJson(res, 200, responseBody);
11131
+ }
11132
+
11133
+ return writeJson(res, 200, {
11134
+ permissionDecision: decision === "accept" ? "allow" : "deny",
11135
+ });
11136
+ }
11137
+
10518
11138
  if (url.pathname === "/api/inbox" && req.method === "GET") {
10519
11139
  const session = requireApiSession(req, res, config, state);
10520
11140
  if (!session) {
@@ -10658,6 +11278,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
10658
11278
  approval.resolving = true;
10659
11279
  try {
10660
11280
  await handleNativeApprovalDecision({ config, runtime, state, approval, decision });
11281
+ if (approval.provider === "claude") {
11282
+ activateClaudeDesktopIfMac(req);
11283
+ }
10661
11284
  return writeJson(res, 200, { ok: true, decision });
10662
11285
  } catch (error) {
10663
11286
  approval.resolving = false;
@@ -10665,6 +11288,85 @@ function createNativeApprovalServer({ config, runtime, state }) {
10665
11288
  }
10666
11289
  }
10667
11290
 
11291
+ const apiQuestionAnswerMatch = url.pathname.match(/^\/api\/items\/question\/([^/]+)\/answer$/u);
11292
+ if (apiQuestionAnswerMatch && req.method === "POST") {
11293
+ const session = requireMutatingApiSession(req, res, config, state);
11294
+ if (!session) {
11295
+ return;
11296
+ }
11297
+
11298
+ const token = decodeURIComponent(apiQuestionAnswerMatch[1]);
11299
+ const approval = runtime.nativeApprovalsByToken.get(token);
11300
+ if (!approval) {
11301
+ return writeJson(res, 404, { error: "question-not-found" });
11302
+ }
11303
+ if (approval.kind !== "question") {
11304
+ return writeJson(res, 409, { error: "not-a-question" });
11305
+ }
11306
+ if (approval.resolved || approval.resolving) {
11307
+ return writeJson(res, 409, { error: "question-already-handled" });
11308
+ }
11309
+
11310
+ let payload;
11311
+ try {
11312
+ payload = await parseJsonBody(req);
11313
+ } catch {
11314
+ return writeJson(res, 400, { error: "invalid-json-body" });
11315
+ }
11316
+
11317
+ const answers = Array.isArray(payload?.answers) ? payload.answers : [];
11318
+ const reasonText = formatClaudeQuestionAnswers(approval.questions, answers);
11319
+ if (!reasonText) {
11320
+ return writeJson(res, 400, { error: "no-answers" });
11321
+ }
11322
+
11323
+ approval.resolving = true;
11324
+ try {
11325
+ if (approval.resolveClaudeWaiter) {
11326
+ approval.resolveClaudeWaiter({
11327
+ permissionDecision: "deny",
11328
+ permissionDecisionReason: reasonText,
11329
+ });
11330
+ }
11331
+ approval.resolved = true;
11332
+ approval.resolving = false;
11333
+ runtime.nativeApprovalsByToken.delete(approval.token);
11334
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
11335
+
11336
+ const stateChanged = recordActionHistoryItem({
11337
+ config,
11338
+ runtime,
11339
+ state,
11340
+ kind: "approval",
11341
+ stableId: `approval:${approval.requestKey}:${Date.now()}`,
11342
+ token: approval.token,
11343
+ title: approval.title,
11344
+ threadLabel: approval.threadLabel || "",
11345
+ messageText: reasonText,
11346
+ summary: t(config.defaultLocale, "server.message.questionAnswered", {
11347
+ provider: providerDisplayName(config.defaultLocale, approval.provider),
11348
+ }),
11349
+ fileRefs: [],
11350
+ diffText: "",
11351
+ diffSource: "",
11352
+ diffAvailable: false,
11353
+ diffAddedLines: 0,
11354
+ diffRemovedLines: 0,
11355
+ outcome: "answered",
11356
+ provider: approval.provider || "claude",
11357
+ });
11358
+ if (stateChanged) {
11359
+ await saveState(config.stateFile, state);
11360
+ }
11361
+ console.log(`[claude-question-answer] ${approval.requestKey}`);
11362
+ activateClaudeDesktopIfMac(req);
11363
+ return writeJson(res, 200, { ok: true });
11364
+ } catch (error) {
11365
+ approval.resolving = false;
11366
+ return writeJson(res, 500, { error: error.message });
11367
+ }
11368
+ }
11369
+
10668
11370
  const apiPlanDecisionMatch = url.pathname.match(/^\/api\/items\/plan\/([^/]+)\/(implement|decline)$/u);
10669
11371
  if (apiPlanDecisionMatch && req.method === "POST") {
10670
11372
  const session = requireMutatingApiSession(req, res, config, state);
@@ -10942,7 +11644,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
10942
11644
  title: userInputRequest.title,
10943
11645
  body: userInputRequest.testRequest
10944
11646
  ? `${t(config.defaultLocale, "server.message.choiceSubmittedTest")}\n\n${formatSubmittedTestAnswers(userInputRequest, userInputRequest.draftAnswers)}`
10945
- : t(config.defaultLocale, "server.message.choiceSubmitted"),
11647
+ : t(config.defaultLocale, "server.message.choiceSubmitted", { provider: providerDisplayName(config.defaultLocale, "codex") }),
10946
11648
  tone: "ok",
10947
11649
  })
10948
11650
  );
@@ -11455,6 +12157,28 @@ function writeJson(res, statusCode, body) {
11455
12157
  res.end(`${JSON.stringify(body)}\n`);
11456
12158
  }
11457
12159
 
12160
+ // Return focus to the Claude Desktop app when a Mac client just submitted a
12161
+ // plan / question / approval decision, so the user can resume in Claude
12162
+ // without manually switching apps. iOS / Android paired devices are skipped
12163
+ // (UA match). If Claude is not running, we do nothing — the AppleScript
12164
+ // `exists (process "Claude")` guard prevents an unwanted launch.
12165
+ function activateClaudeDesktopIfMac(req) {
12166
+ try {
12167
+ const ua = String(req?.headers?.["user-agent"] || "");
12168
+ if (/iPhone|iPad|iPod|Android/iu.test(ua)) return;
12169
+ if (!/Macintosh|Mac OS X/iu.test(ua)) return;
12170
+ const script = `tell application "System Events"
12171
+ if exists (processes where name is "Claude") then
12172
+ tell application "Claude" to activate
12173
+ end if
12174
+ end tell`;
12175
+ const child = spawn("osascript", ["-e", script], { stdio: "ignore" });
12176
+ child.unref();
12177
+ } catch {
12178
+ // Best effort.
12179
+ }
12180
+ }
12181
+
11458
12182
  function writeHtml(res, statusCode, html) {
11459
12183
  res.statusCode = statusCode;
11460
12184
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -11477,7 +12201,7 @@ function stopHttpServer(server) {
11477
12201
  });
11478
12202
  }
11479
12203
 
11480
- function renderApprovalPage({ eyebrow, title, messageText, token, basePath, actions, locale = DEFAULT_LOCALE }) {
12204
+ function renderApprovalPage({ eyebrow, title, messageText, token, basePath, actions, locale = DEFAULT_LOCALE, provider = "codex" }) {
11481
12205
  const messageHtml = renderMessageHtml(messageText, `<p>${escapeHtml(t(locale, "detail.approvalRequested"))}</p>`);
11482
12206
  const actionsHtml = actions
11483
12207
  .map(
@@ -11582,7 +12306,7 @@ function renderApprovalPage({ eyebrow, title, messageText, token, basePath, acti
11582
12306
  <div class="message">
11583
12307
  ${messageHtml}
11584
12308
  </div>
11585
- <p class="hint">${escapeHtml(t(locale, "summary.approval"))}</p>
12309
+ <p class="hint">${escapeHtml(t(locale, "summary.approval", { provider: providerDisplayName(locale, provider) }))}</p>
11586
12310
  <div class="actions">
11587
12311
  ${actionsHtml}
11588
12312
  <a class="button link" href="#" onclick="history.back(); return false;">${escapeHtml(t(locale, "common.back"))}</a>
@@ -11965,7 +12689,7 @@ function renderMessagePage({ eyebrow, title, messageText, locale = DEFAULT_LOCAL
11965
12689
  </html>`;
11966
12690
  }
11967
12691
 
11968
- function renderStatusPage({ title, body, tone }) {
12692
+ function renderStatusPage({ title, body, tone, provider = "codex", locale = DEFAULT_LOCALE }) {
11969
12693
  return `<!doctype html>
11970
12694
  <html lang="en">
11971
12695
  <head>
@@ -12028,7 +12752,7 @@ function renderStatusPage({ title, body, tone }) {
12028
12752
  </head>
12029
12753
  <body>
12030
12754
  <section class="card">
12031
- <span class="chip">Codex</span>
12755
+ <span class="chip">${escapeHtml(providerDisplayName(locale, provider))}</span>
12032
12756
  <h1>${escapeHtml(title)}</h1>
12033
12757
  <p>${escapeHtml(body)}</p>
12034
12758
  <p class="muted">このページは閉じて大丈夫です。</p>
@@ -12115,16 +12839,18 @@ function markdownMessageStyles() {
12115
12839
  }`;
12116
12840
  }
12117
12841
 
12118
- function approvalDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE) {
12842
+ function approvalDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE, provider = "codex") {
12843
+ const vars = { provider: providerDisplayName(locale, provider) };
12119
12844
  if (decision === "accept") {
12120
- return t(locale, "server.message.approvalAccepted");
12845
+ return t(locale, "server.message.approvalAccepted", vars);
12121
12846
  }
12122
- return t(locale, "server.message.approvalRejected");
12847
+ return t(locale, "server.message.approvalRejected", vars);
12123
12848
  }
12124
12849
 
12125
- function planDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE) {
12850
+ function planDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE, provider = "codex") {
12851
+ const vars = { provider: providerDisplayName(locale, provider) };
12126
12852
  if (decision === "implement") {
12127
- return t(locale, "server.message.planImplemented");
12853
+ return t(locale, "server.message.planImplemented", vars);
12128
12854
  }
12129
12855
  return t(locale, "server.message.planDismissed");
12130
12856
  }
@@ -12140,14 +12866,15 @@ function decisionLabel(decision, locale = config?.defaultLocale || DEFAULT_LOCAL
12140
12866
  return decision === "accept" ? t(locale, "server.action.approve") : t(locale, "server.action.reject");
12141
12867
  }
12142
12868
 
12143
- function approvalDecisionConfirm(decision, locale = config?.defaultLocale || DEFAULT_LOCALE) {
12869
+ function approvalDecisionConfirm(decision, locale = config?.defaultLocale || DEFAULT_LOCALE, provider = "codex") {
12870
+ const vars = { provider: providerDisplayName(locale, provider) };
12144
12871
  if (decision === "implement") {
12145
12872
  return t(locale, "server.confirm.planImplement");
12146
12873
  }
12147
12874
  if (decision === "accept") {
12148
- return t(locale, "server.confirm.approve");
12875
+ return t(locale, "server.confirm.approve", vars);
12149
12876
  }
12150
- return t(locale, "server.confirm.reject");
12877
+ return t(locale, "server.confirm.reject", vars);
12151
12878
  }
12152
12879
 
12153
12880
  function resolvePlanDecisionAnswer(planRequest, decision) {
@@ -12406,6 +13133,7 @@ function buildConfig(cli) {
12406
13133
  pairingToken: process.env.PAIRING_TOKEN || "",
12407
13134
  pairingExpiresAtMs: numberEnv("PAIRING_EXPIRES_AT_MS", 0),
12408
13135
  sessionSecret: process.env.SESSION_SECRET || "",
13136
+ claudeProjectsDir: resolvePath(process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), ".claude", "projects")),
12409
13137
  };
12410
13138
  }
12411
13139
 
@@ -12588,6 +13316,7 @@ async function loadState(stateFile) {
12588
13316
  historyFileSourceFile: cleanText(parsed.historyFileSourceFile ?? ""),
12589
13317
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
12590
13318
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
13319
+ claudeAwayMode: parsed.claudeAwayMode === true,
12591
13320
  };
12592
13321
  } catch {
12593
13322
  return {
@@ -12614,10 +13343,30 @@ async function loadState(stateFile) {
12614
13343
  historyFileSourceFile: "",
12615
13344
  pairingConsumedAt: 0,
12616
13345
  pairingConsumedCredential: "",
13346
+ claudeAwayMode: false,
12617
13347
  };
12618
13348
  }
12619
13349
  }
12620
13350
 
13351
+ function claudeAwayModeSentinelPath(config) {
13352
+ const dir = config?.configDir || path.join(os.homedir(), ".viveworker");
13353
+ return path.join(dir, "claude-away-mode");
13354
+ }
13355
+
13356
+ async function syncClaudeAwayModeSentinel(config, enabled) {
13357
+ const sentinel = claudeAwayModeSentinelPath(config);
13358
+ try {
13359
+ if (enabled) {
13360
+ await fs.mkdir(path.dirname(sentinel), { recursive: true });
13361
+ await fs.writeFile(sentinel, "on\n", "utf8");
13362
+ } else {
13363
+ await fs.unlink(sentinel).catch(() => {});
13364
+ }
13365
+ } catch (error) {
13366
+ console.error(`[claude-away-mode-sentinel-error] ${error.message}`);
13367
+ }
13368
+ }
13369
+
12621
13370
  async function saveState(stateFile, state) {
12622
13371
  const output = JSON.stringify(state, null, 2);
12623
13372
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
@@ -13016,7 +13765,7 @@ function getThreadName(sessionIndex, rolloutThreadLabels, threadId, cwd, filePat
13016
13765
  }
13017
13766
 
13018
13767
  function describeContext({ threadName }) {
13019
- const threadLabel = truncate(cleanText(threadName || ""), 90) || "Codex task";
13768
+ const threadLabel = truncate(cleanText(threadName || ""), 90) || t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, "codex") });
13020
13769
  return { threadLabel };
13021
13770
  }
13022
13771
 
@@ -13099,10 +13848,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
13099
13848
  if (!threadId) {
13100
13849
  return entry;
13101
13850
  }
13102
- const nativeThreadLabel = getNativeThreadLabel({
13851
+ const claudeTitle = runtime.claudeSessionTitles.get(threadId) || "";
13852
+ const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
13103
13853
  runtime,
13104
13854
  conversationId: threadId,
13105
- cwd: "",
13855
+ cwd: cleanText(entry.cwd || ""),
13106
13856
  });
13107
13857
  const threadLabel = preferTitleOnlyJsonThreadLabel(nativeThreadLabel, threadId, entry.messageText, entry.summary);
13108
13858
  const title = threadLabel || kindTitle(config.defaultLocale, entry.kind);
@@ -13134,10 +13884,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
13134
13884
  if (!threadId) {
13135
13885
  return entry;
13136
13886
  }
13137
- const nativeThreadLabel = getNativeThreadLabel({
13887
+ const claudeTitle = runtime.claudeSessionTitles.get(threadId) || "";
13888
+ const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
13138
13889
  runtime,
13139
13890
  conversationId: threadId,
13140
- cwd: "",
13891
+ cwd: cleanText(entry.cwd || ""),
13141
13892
  });
13142
13893
  const threadLabel = preferTitleOnlyJsonThreadLabel(nativeThreadLabel, threadId, entry.messageText, entry.summary);
13143
13894
  const title = threadLabel || kindTitle(config.defaultLocale, entry.kind);
@@ -13545,7 +14296,7 @@ async function main() {
13545
14296
 
13546
14297
  console.log(
13547
14298
  [
13548
- "Codex ntfy bridge",
14299
+ "viveworker bridge",
13549
14300
  `dryRun=${config.dryRun}`,
13550
14301
  `once=${config.once}`,
13551
14302
  `codexHome=${config.codexHome}`,