viveworker 0.7.0-beta.3 → 0.8.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.
Files changed (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1696 -223
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +727 -9
  21. package/web/app.js +1810 -232
  22. package/web/i18n.js +207 -17
  23. package/web/index.html +115 -1
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
@@ -21,6 +21,12 @@ import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
21
21
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
22
22
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
23
23
  import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems } from "./moltbook-api.mjs";
24
+ import { startRemotePairingRelay, DEFAULT_RELAY_URL } from "./lib/remote-pairing/orchestrator.mjs";
25
+ import { restartRemotePairingRelay, persistRemotePairingEnv, getRemotePairingStatus } from "./lib/remote-pairing/control.mjs";
26
+ import { appendRemotePairingAuditEvent, readRemotePairingAuditEvents } from "./lib/remote-pairing/audit.mjs";
27
+ import { loadPairings, removePairingPersisted, addPairingPersisted, rotateRelayTokenPersisted, buildPairing, REMOTE_PAIRINGS_FILE } from "./lib/remote-pairing/pairings.mjs";
28
+ import { ensureIdentityKeypair } from "./lib/remote-pairing/keys.mjs";
29
+ import { fingerprintIdentity, bytesToHex } from "./lib/remote-pairing/keys-core.mjs";
24
30
 
25
31
  const __filename = fileURLToPath(import.meta.url);
26
32
  const __dirname = path.dirname(__filename);
@@ -42,6 +48,8 @@ const listSupportedPayments = typeof hazbaseAuth.listSupportedPayments === "func
42
48
  ? hazbaseAuth.listSupportedPayments.bind(hazbaseAuth)
43
49
  : async () => ({ networks: [], defaultNetwork: "base-sepolia" });
44
50
  const appPackageVersion = readPackageVersion();
51
+ const WEB_APP_BUILD_ID = "20260428-remote-token-refresh";
52
+ const WEB_APP_SCRIPT_URL = `/app.js?v=${WEB_APP_BUILD_ID}`;
45
53
  const sessionCookieName = "viveworker_session";
46
54
  const deviceCookieName = "viveworker_device";
47
55
  const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
@@ -52,6 +60,7 @@ const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
52
60
  const MAX_PAIRED_DEVICES = 200;
53
61
  const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
54
62
  const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
63
+ const REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS = 30 * 24 * 60 * 60 * 1000;
55
64
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
56
65
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
57
66
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
@@ -209,6 +218,7 @@ const cli = parseCliArgs(process.argv.slice(2));
209
218
  const envFile = resolveEnvFile(cli.envFile);
210
219
  loadEnvFile(envFile);
211
220
  loadEnvFile(path.join(os.homedir(), ".viveworker", "a2a.env"));
221
+ loadEnvFile(path.join(os.homedir(), ".viveworker", "remote-pairing.env"));
212
222
  await maybeRotateStartupPairingEnv(envFile);
213
223
 
214
224
  const config = buildConfig(cli);
@@ -261,6 +271,7 @@ const runtime = {
261
271
  recentCodeEvents: [],
262
272
  pairingAttemptsByRemoteAddress: new Map(),
263
273
  ipcClient: null,
274
+ remotePairingHandle: null,
264
275
  stopping: false,
265
276
  // In-memory cache for the `/api/share/status` endpoint. A 30s TTL avoids
266
277
  // hammering the share worker on every settings-page render. Purely runtime —
@@ -475,6 +486,11 @@ function buildSessionLocalePayload(config, state, deviceId) {
475
486
  a2aShareEnabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
476
487
  a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
477
488
  a2aExecutorPreference: state.a2aExecutorPreference || "ask",
489
+ // Remote pairing is always "available" — the row appears in Settings →
490
+ // Integrations regardless of whether the relay is currently enabled, so
491
+ // users can find the toggle. The actual enabled/connected state is read
492
+ // from /api/remote-pairing/status.
493
+ remotePairingAvailable: true,
478
494
  };
479
495
  }
480
496
 
@@ -680,7 +696,7 @@ function withNotificationIcon(kind, title) {
680
696
 
681
697
  function normalizeTimelineOutcome(value) {
682
698
  const normalized = cleanText(value || "").toLowerCase();
683
- return ["pending", "approved", "rejected", "implemented", "dismissed", "submitted"].includes(normalized)
699
+ return ["pending", "approved", "rejected", "failed", "implemented", "dismissed", "submitted"].includes(normalized)
684
700
  ? normalized
685
701
  : "";
686
702
  }
@@ -808,6 +824,21 @@ function normalizeTimelineFileRefs(rawFileRefs) {
808
824
  return deduped;
809
825
  }
810
826
 
827
+ function filterTimelineFileRefsForImagePaths(fileRefs, imagePaths) {
828
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs);
829
+ const normalizedImages = normalizeTimelineImagePaths(imagePaths);
830
+ if (normalizedRefs.length === 0 || normalizedImages.length === 0) {
831
+ return normalizedRefs;
832
+ }
833
+
834
+ const imageRefKeys = new Set();
835
+ for (const imagePath of normalizedImages) {
836
+ imageRefKeys.add(imagePath);
837
+ imageRefKeys.add(path.basename(imagePath));
838
+ }
839
+ return normalizedRefs.filter((fileRef) => !imageRefKeys.has(fileRef) && !imageRefKeys.has(path.basename(fileRef)));
840
+ }
841
+
811
842
  function cleanTimelineFileRef(value) {
812
843
  let normalized = cleanText(value || "");
813
844
  if (!normalized) {
@@ -2874,7 +2905,8 @@ function normalizeHistoryItem(raw) {
2874
2905
  const title =
2875
2906
  (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2876
2907
  (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2877
- const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
2908
+ const rawMessageText = raw.messageText ?? "";
2909
+ const messageText = normalizeTimelineMessageText(rawMessageText);
2878
2910
  const summary = normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "";
2879
2911
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
2880
2912
  if (!stableId || !historyKinds.has(kind) || !title) {
@@ -2882,6 +2914,14 @@ function normalizeHistoryItem(raw) {
2882
2914
  }
2883
2915
 
2884
2916
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2917
+ const imagePaths = normalizeTimelineImagePaths([
2918
+ ...(Array.isArray(raw.imagePaths ?? raw.localImagePaths) ? (raw.imagePaths ?? raw.localImagePaths) : []),
2919
+ ...extractTimelineImagePaths(rawMessageText),
2920
+ ]);
2921
+ const fileRefs = filterTimelineFileRefsForImagePaths(
2922
+ normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
2923
+ imagePaths
2924
+ );
2885
2925
 
2886
2926
  const normalized = {
2887
2927
  stableId,
@@ -2892,8 +2932,8 @@ function normalizeHistoryItem(raw) {
2892
2932
  threadLabel,
2893
2933
  summary,
2894
2934
  messageText,
2895
- imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
2896
- fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
2935
+ imagePaths,
2936
+ fileRefs,
2897
2937
  diffText: normalizeTimelineDiffText(raw.diffText ?? ""),
2898
2938
  diffSource: normalizeTimelineDiffSource(raw.diffSource ?? ""),
2899
2939
  diffAvailable: raw.diffAvailable === true || Boolean(raw.diffText),
@@ -2993,6 +3033,70 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2993
3033
  return deduped;
2994
3034
  }
2995
3035
 
3036
+ // Cheap structural diff used by record* helpers to decide whether a rebuilt
3037
+ // timeline / history / code-events array actually changed in a way worth
3038
+ // persisting. The previous implementation `JSON.stringify(a) !== JSON.stringify(b)`
3039
+ // allocated several megabytes of intermediate strings on every call when the
3040
+ // projection included `diffText` (5+ KB per file_event × ~250 entries × 2
3041
+ // sides), which dominated scavenger GC time in busy sessions and showed up as
3042
+ // a 25% CPU sink in `JsonStringifier::Serialize_*` under V8 sampling. Walking
3043
+ // the arrays in lockstep and bailing on the first mismatched primitive lets
3044
+ // the common "fresh entry prepended" case exit on the very first stableId
3045
+ // compare with zero allocations.
3046
+ //
3047
+ // Field semantics:
3048
+ // - String / number / boolean fields use value-equality via `===` (works
3049
+ // across object reconstruction by `normalizeTimelineEntry`).
3050
+ // - `Array` fields (e.g. `previousFileRefs`) shallow-compare lengths and the
3051
+ // `path` of each element rather than serializing.
3052
+ // - `null` / `undefined` are treated as equal to each other but distinct from
3053
+ // any defined value.
3054
+ function timelineProjectionChanged(nextItems, previousItems, fieldKeys) {
3055
+ const a = Array.isArray(nextItems) ? nextItems : [];
3056
+ const b = Array.isArray(previousItems) ? previousItems : [];
3057
+ if (a.length !== b.length) {
3058
+ return true;
3059
+ }
3060
+ for (let i = 0; i < a.length; i++) {
3061
+ const ai = a[i];
3062
+ const bi = b[i];
3063
+ if (ai === bi) continue;
3064
+ if (!ai || !bi) return true;
3065
+ for (let k = 0; k < fieldKeys.length; k++) {
3066
+ const key = fieldKeys[k];
3067
+ const av = ai[key];
3068
+ const bv = bi[key];
3069
+ if (av === bv) continue;
3070
+ if (av == null && bv == null) continue;
3071
+ if (Array.isArray(av) && Array.isArray(bv)) {
3072
+ if (av.length !== bv.length) return true;
3073
+ let arrayDiffers = false;
3074
+ for (let j = 0; j < av.length; j++) {
3075
+ const aj = av[j];
3076
+ const bj = bv[j];
3077
+ if (aj === bj) continue;
3078
+ if (!aj || !bj) {
3079
+ arrayDiffers = true;
3080
+ break;
3081
+ }
3082
+ // Most array-of-object fields here are fileRef-shaped: identity is
3083
+ // captured by `path`. Falling back to JSON for unrecognised shapes
3084
+ // is unnecessary — a path mismatch (or a null path either side) is
3085
+ // sufficient to mark the projection changed.
3086
+ if (aj.path !== bj.path) {
3087
+ arrayDiffers = true;
3088
+ break;
3089
+ }
3090
+ }
3091
+ if (arrayDiffers) return true;
3092
+ continue;
3093
+ }
3094
+ return true;
3095
+ }
3096
+ }
3097
+ return false;
3098
+ }
3099
+
2996
3100
  function isCodeEventEntry(raw) {
2997
3101
  if (!isPlainObject(raw)) {
2998
3102
  return false;
@@ -3063,7 +3167,8 @@ function normalizeTimelineEntry(raw) {
3063
3167
  }
3064
3168
 
3065
3169
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
3066
- const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
3170
+ const rawMessageText = raw.messageText ?? "";
3171
+ const messageText = normalizeTimelineMessageText(rawMessageText);
3067
3172
  const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3068
3173
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
3069
3174
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
@@ -3098,6 +3203,14 @@ function normalizeTimelineEntry(raw) {
3098
3203
  threadLabel ||
3099
3204
  kindTitle(DEFAULT_LOCALE, kind);
3100
3205
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
3206
+ const imagePaths = normalizeTimelineImagePaths([
3207
+ ...(Array.isArray(raw.imagePaths ?? raw.localImagePaths) ? (raw.imagePaths ?? raw.localImagePaths) : []),
3208
+ ...extractTimelineImagePaths(rawMessageText),
3209
+ ]);
3210
+ const fileRefs = filterTimelineFileRefsForImagePaths(
3211
+ normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
3212
+ imagePaths
3213
+ );
3101
3214
 
3102
3215
  const normalized = {
3103
3216
  stableId,
@@ -3110,8 +3223,8 @@ function normalizeTimelineEntry(raw) {
3110
3223
  messageText,
3111
3224
  fileEventType,
3112
3225
  previousFileRefs: normalizeTimelineFileRefs(raw.previousFileRefs ?? []),
3113
- imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
3114
- fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
3226
+ imagePaths,
3227
+ fileRefs,
3115
3228
  diffText,
3116
3229
  diffSource,
3117
3230
  diffAvailable: raw.diffAvailable === true || Boolean(diffText),
@@ -3158,35 +3271,21 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
3158
3271
  [normalized, ...runtime.recentTimelineEntries.filter((item) => item.stableId !== normalized.stableId)],
3159
3272
  config.maxTimelineEntries
3160
3273
  );
3161
- const changed =
3162
- JSON.stringify(
3163
- nextItems.map((item) => [
3164
- item.stableId,
3165
- item.title,
3166
- item.createdAtMs,
3167
- item.diffAvailable,
3168
- item.diffSource,
3169
- item.diffAddedLines,
3170
- item.diffRemovedLines,
3171
- item.diffText,
3172
- item.previousFileRefs,
3173
- item.cwd,
3174
- ])
3175
- ) !==
3176
- JSON.stringify(
3177
- runtime.recentTimelineEntries.map((item) => [
3178
- item.stableId,
3179
- item.title,
3180
- item.createdAtMs,
3181
- item.diffAvailable,
3182
- item.diffSource,
3183
- item.diffAddedLines,
3184
- item.diffRemovedLines,
3185
- item.diffText,
3186
- item.previousFileRefs,
3187
- item.cwd,
3188
- ])
3189
- );
3274
+ // Note: diffText is intentionally omitted from the projection — the
3275
+ // line-count fields (`diffAddedLines` / `diffRemovedLines`) are a sufficient
3276
+ // proxy for "diff content changed" and avoid materialising a megabyte-class
3277
+ // intermediate string on every record call.
3278
+ const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
3279
+ "stableId",
3280
+ "title",
3281
+ "createdAtMs",
3282
+ "diffAvailable",
3283
+ "diffSource",
3284
+ "diffAddedLines",
3285
+ "diffRemovedLines",
3286
+ "previousFileRefs",
3287
+ "cwd",
3288
+ ]);
3190
3289
  runtime.recentTimelineEntries = nextItems;
3191
3290
  state.recentTimelineEntries = nextItems;
3192
3291
  return changed;
@@ -3205,35 +3304,19 @@ function recordCodeEvent({ config, runtime, state, entry }) {
3205
3304
  [normalized, ...runtime.recentCodeEvents.filter((item) => item.stableId !== normalized.stableId)],
3206
3305
  config.maxCodeEvents
3207
3306
  );
3208
- const changed =
3209
- JSON.stringify(
3210
- nextItems.map((item) => [
3211
- item.stableId,
3212
- item.title,
3213
- item.createdAtMs,
3214
- item.diffAvailable,
3215
- item.diffSource,
3216
- item.diffAddedLines,
3217
- item.diffRemovedLines,
3218
- item.diffText,
3219
- item.previousFileRefs,
3220
- item.cwd,
3221
- ])
3222
- ) !==
3223
- JSON.stringify(
3224
- runtime.recentCodeEvents.map((item) => [
3225
- item.stableId,
3226
- item.title,
3227
- item.createdAtMs,
3228
- item.diffAvailable,
3229
- item.diffSource,
3230
- item.diffAddedLines,
3231
- item.diffRemovedLines,
3232
- item.diffText,
3233
- item.previousFileRefs,
3234
- item.cwd,
3235
- ])
3236
- );
3307
+ // See `recordTimelineEntry` for the rationale on dropping `diffText` from
3308
+ // the projection — same megabyte-stringify hot path; same line-count proxy.
3309
+ const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3310
+ "stableId",
3311
+ "title",
3312
+ "createdAtMs",
3313
+ "diffAvailable",
3314
+ "diffSource",
3315
+ "diffAddedLines",
3316
+ "diffRemovedLines",
3317
+ "previousFileRefs",
3318
+ "cwd",
3319
+ ]);
3237
3320
  runtime.recentCodeEvents = nextItems;
3238
3321
  state.recentCodeEvents = nextItems;
3239
3322
  if (changed) {
@@ -3257,37 +3340,19 @@ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
3257
3340
  ],
3258
3341
  config.maxCodeEvents
3259
3342
  );
3260
- const changed =
3261
- JSON.stringify(
3262
- nextItems.map((item) => [
3263
- item.stableId,
3264
- item.title,
3265
- item.createdAtMs,
3266
- item.threadLabel,
3267
- item.diffAvailable,
3268
- item.diffSource,
3269
- item.diffAddedLines,
3270
- item.diffRemovedLines,
3271
- item.diffText,
3272
- item.previousFileRefs,
3273
- item.cwd,
3274
- ])
3275
- ) !==
3276
- JSON.stringify(
3277
- runtime.recentCodeEvents.map((item) => [
3278
- item.stableId,
3279
- item.title,
3280
- item.createdAtMs,
3281
- item.threadLabel,
3282
- item.diffAvailable,
3283
- item.diffSource,
3284
- item.diffAddedLines,
3285
- item.diffRemovedLines,
3286
- item.diffText,
3287
- item.previousFileRefs,
3288
- item.cwd,
3289
- ])
3290
- );
3343
+ // See `recordTimelineEntry` for the rationale on dropping `diffText`.
3344
+ const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3345
+ "stableId",
3346
+ "title",
3347
+ "createdAtMs",
3348
+ "threadLabel",
3349
+ "diffAvailable",
3350
+ "diffSource",
3351
+ "diffAddedLines",
3352
+ "diffRemovedLines",
3353
+ "previousFileRefs",
3354
+ "cwd",
3355
+ ]);
3291
3356
  runtime.recentCodeEvents = nextItems;
3292
3357
  state.recentCodeEvents = nextItems;
3293
3358
  if (changed) {
@@ -3353,9 +3418,11 @@ function recordHistoryItem({ config, runtime, state, item }) {
3353
3418
  [normalized, ...runtime.recentHistoryItems.filter((entry) => entry.stableId !== normalized.stableId)],
3354
3419
  config.maxHistoryItems
3355
3420
  );
3356
- const changed =
3357
- JSON.stringify(nextItems.map((entry) => [entry.stableId, entry.title, entry.createdAtMs])) !==
3358
- JSON.stringify(runtime.recentHistoryItems.map((entry) => [entry.stableId, entry.title, entry.createdAtMs]));
3421
+ const changed = timelineProjectionChanged(nextItems, runtime.recentHistoryItems, [
3422
+ "stableId",
3423
+ "title",
3424
+ "createdAtMs",
3425
+ ]);
3359
3426
  runtime.recentHistoryItems = nextItems;
3360
3427
  state.recentHistoryItems = nextItems;
3361
3428
  return changed;
@@ -3784,7 +3851,7 @@ async function scanOnce({ config, runtime, state }) {
3784
3851
  if (config.webUiEnabled) {
3785
3852
  let claudeTranscriptChanged = false;
3786
3853
  if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
3787
- runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
3854
+ runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir, config.claudeTranscriptMaxAgeMs);
3788
3855
  runtime.lastClaudeScanAt = now;
3789
3856
  }
3790
3857
  let claudeSessionTitlesChanged = false;
@@ -4122,7 +4189,16 @@ async function refreshClaudeSessionTitles(runtime) {
4122
4189
  return changed;
4123
4190
  }
4124
4191
 
4125
- async function listClaudeTranscriptFiles(claudeProjectsDir) {
4192
+ async function listClaudeTranscriptFiles(claudeProjectsDir, maxAgeMs = 0) {
4193
+ // ~/.claude/projects can accumulate thousands of .jsonl files (one per
4194
+ // session, never garbage-collected by Claude Code itself). Returning all of
4195
+ // them means processClaudeTranscriptFile fs.stat()s each every poll
4196
+ // iteration — for a long-time user that's 3000+ stats every 2.5 s, all but
4197
+ // a handful of which are guaranteed-cold. Filter by mtime here so the
4198
+ // per-iteration working set tracks "active sessions", not "total sessions
4199
+ // ever". maxAgeMs=0 disables filtering for callers who explicitly want
4200
+ // everything (none in the bridge today).
4201
+ const cutoffMs = maxAgeMs > 0 ? Date.now() - maxAgeMs : 0;
4126
4202
  try {
4127
4203
  const result = [];
4128
4204
  const entries = await fs.readdir(claudeProjectsDir, { withFileTypes: true });
@@ -4133,7 +4209,17 @@ async function listClaudeTranscriptFiles(claudeProjectsDir) {
4133
4209
  const files = await fs.readdir(projectPath, { withFileTypes: true });
4134
4210
  for (const file of files) {
4135
4211
  if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
4136
- result.push(path.join(projectPath, file.name));
4212
+ const fullPath = path.join(projectPath, file.name);
4213
+ if (cutoffMs > 0) {
4214
+ try {
4215
+ const stat = await fs.stat(fullPath);
4216
+ if (stat.mtimeMs < cutoffMs) continue;
4217
+ } catch {
4218
+ // skip unreadable file (vanished or permissioned out)
4219
+ continue;
4220
+ }
4221
+ }
4222
+ result.push(fullPath);
4137
4223
  }
4138
4224
  } catch {
4139
4225
  // skip unreadable project dirs
@@ -5144,8 +5230,8 @@ async function readRecentRolloutUserMessagesWithImages({ filePath, maxBytes }) {
5144
5230
  async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5145
5231
  const conditions = [
5146
5232
  `id > ${Math.max(0, Number(cursorId) || 0)}`,
5147
- `target = 'codex_api::endpoint::responses_websocket'`,
5148
- `feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%'`,
5233
+ `target IN ('codex_api::endpoint::responses_websocket', 'codex_api::sse::responses')`,
5234
+ `(feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%' OR feedback_log_body LIKE '%SSE event: {"type":"response.output_item.done"%')`,
5149
5235
  `feedback_log_body LIKE '%"type":"message"%'`,
5150
5236
  `feedback_log_body LIKE '%"role":"assistant"%'`,
5151
5237
  ];
@@ -5155,28 +5241,50 @@ async function querySqliteTimelineRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5155
5241
  }
5156
5242
 
5157
5243
  const sql = `
5158
- SELECT id, ts, thread_id, feedback_log_body
5244
+ SELECT
5245
+ logs.id,
5246
+ logs.ts,
5247
+ logs.thread_id,
5248
+ logs.feedback_log_body,
5249
+ (
5250
+ SELECT ctx.feedback_log_body
5251
+ FROM logs AS ctx
5252
+ WHERE ctx.id < logs.id
5253
+ AND ctx.id >= logs.id - 5
5254
+ AND ctx.target = 'codex_otel.log_only'
5255
+ AND ctx.feedback_log_body LIKE '%conversation.id=%'
5256
+ ORDER BY ctx.id DESC
5257
+ LIMIT 1
5258
+ ) AS context_log_body
5159
5259
  FROM logs
5160
5260
  WHERE ${conditions.join("\n AND ")}
5161
- ORDER BY id ASC
5261
+ ORDER BY logs.id ASC
5162
5262
  LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
5163
5263
  `;
5164
5264
 
5165
5265
  return runSqliteJsonQuery(logsDbFile, sql);
5166
5266
  }
5167
5267
 
5168
- function buildSqliteTimelineEntry({ row, config, runtime }) {
5169
- const body = String(row?.feedback_log_body ?? "");
5170
- const marker = "websocket event: ";
5171
- const markerIndex = body.indexOf(marker);
5172
- if (markerIndex === -1) {
5173
- return null;
5268
+ function parseResponsesEventPayload(body) {
5269
+ const raw = String(body || "");
5270
+ for (const marker of ["websocket event: ", "SSE event: "]) {
5271
+ const markerIndex = raw.indexOf(marker);
5272
+ if (markerIndex === -1) {
5273
+ continue;
5274
+ }
5275
+ try {
5276
+ return JSON.parse(raw.slice(markerIndex + marker.length));
5277
+ } catch {
5278
+ return null;
5279
+ }
5174
5280
  }
5281
+ return null;
5282
+ }
5175
5283
 
5176
- let payload;
5177
- try {
5178
- payload = JSON.parse(body.slice(markerIndex + marker.length));
5179
- } catch {
5284
+ function buildSqliteTimelineEntry({ row, config, runtime }) {
5285
+ const body = String(row?.feedback_log_body ?? "");
5286
+ const payload = parseResponsesEventPayload(body);
5287
+ if (!payload) {
5180
5288
  return null;
5181
5289
  }
5182
5290
 
@@ -5200,7 +5308,12 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
5200
5308
  return null;
5201
5309
  }
5202
5310
 
5203
- const threadId = cleanText(row.thread_id || extractThreadIdFromLogBody(body));
5311
+ const contextLogBody = String(row?.context_log_body ?? "");
5312
+ const threadId = cleanText(
5313
+ row.thread_id ||
5314
+ extractThreadIdFromLogBody(body) ||
5315
+ extractThreadIdFromLogBody(contextLogBody)
5316
+ );
5204
5317
  if (!threadId) {
5205
5318
  return null;
5206
5319
  }
@@ -5328,6 +5441,25 @@ async function findReplyUploadFallback(config, sourcePath, usedPaths = new Set()
5328
5441
  return candidates[0]?.filePath || "";
5329
5442
  }
5330
5443
 
5444
+ function isFileAccessDeniedError(error) {
5445
+ const code = cleanText(error?.code || "");
5446
+ return code === "EACCES" || code === "EPERM";
5447
+ }
5448
+
5449
+ async function copyFileWithSystemCp(sourcePath, destinationPath) {
5450
+ return new Promise((resolve, reject) => {
5451
+ const child = spawn("/bin/cp", ["-p", sourcePath, destinationPath], { stdio: "ignore" });
5452
+ child.once("error", reject);
5453
+ child.once("exit", (code, signal) => {
5454
+ if (code === 0) {
5455
+ resolve(true);
5456
+ return;
5457
+ }
5458
+ reject(new Error(`cp exited with ${signal || code}`));
5459
+ });
5460
+ });
5461
+ }
5462
+
5331
5463
  async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
5332
5464
  const normalizedSourcePath = resolvePath(cleanText(sourcePath || ""));
5333
5465
  if (!normalizedSourcePath) {
@@ -5344,6 +5476,16 @@ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
5344
5476
  await fs.copyFile(normalizedSourcePath, destinationPath);
5345
5477
  return destinationPath;
5346
5478
  } catch (error) {
5479
+ if (isFileAccessDeniedError(error)) {
5480
+ try {
5481
+ await copyFileWithSystemCp(normalizedSourcePath, destinationPath);
5482
+ return destinationPath;
5483
+ } catch (fallbackError) {
5484
+ await fs.rm(destinationPath, { force: true }).catch(() => {});
5485
+ console.warn(`[timeline-image-copy-skipped] ${error?.message || error}; cp fallback failed: ${fallbackError?.message || fallbackError}`);
5486
+ return "";
5487
+ }
5488
+ }
5347
5489
  console.warn(`[timeline-image-copy-skipped] ${error?.message || error}`);
5348
5490
  return "";
5349
5491
  }
@@ -5356,8 +5498,12 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5356
5498
  }
5357
5499
 
5358
5500
  const aliases = isPlainObject(state.timelineImagePathAliases) ? state.timelineImagePathAliases : (state.timelineImagePathAliases = {});
5501
+ const copyFailures = isPlainObject(state.timelineImagePathCopyFailures)
5502
+ ? state.timelineImagePathCopyFailures
5503
+ : (state.timelineImagePathCopyFailures = {});
5359
5504
  const usedFallbacks = new Set();
5360
5505
  const nextPaths = [];
5506
+ const copyRetryMs = 10 * 60 * 1000;
5361
5507
 
5362
5508
  for (const rawPath of normalizedImagePaths) {
5363
5509
  const normalizedPath = cleanText(rawPath || "");
@@ -5367,24 +5513,38 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5367
5513
 
5368
5514
  const aliasedPath = cleanText(aliases[normalizedPath] || "");
5369
5515
  if (aliasedPath) {
5370
- try {
5371
- await fs.access(aliasedPath);
5372
- nextPaths.push(aliasedPath);
5373
- continue;
5374
- } catch {
5375
- // Fall through and repair below.
5516
+ if (aliasedPath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
5517
+ try {
5518
+ await fs.access(aliasedPath);
5519
+ nextPaths.push(aliasedPath);
5520
+ continue;
5521
+ } catch {
5522
+ // Fall through and repair below.
5523
+ }
5376
5524
  }
5377
5525
  }
5378
5526
 
5527
+ const lastCopyFailureMs = Math.max(0, Number(copyFailures[normalizedPath]) || 0);
5528
+ if (
5529
+ lastCopyFailureMs > 0 &&
5530
+ Date.now() - lastCopyFailureMs < copyRetryMs &&
5531
+ !normalizedPath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)
5532
+ ) {
5533
+ nextPaths.push(normalizedPath);
5534
+ continue;
5535
+ }
5536
+
5379
5537
  let existingSourcePath = normalizedPath;
5380
5538
  try {
5381
5539
  await fs.access(existingSourcePath);
5382
- } catch {
5383
- existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
5384
- if (!existingSourcePath) {
5385
- continue;
5540
+ } catch (error) {
5541
+ if (!isFileAccessDeniedError(error)) {
5542
+ existingSourcePath = await findReplyUploadFallback(config, normalizedPath, usedFallbacks);
5543
+ if (!existingSourcePath) {
5544
+ continue;
5545
+ }
5546
+ usedFallbacks.add(existingSourcePath);
5386
5547
  }
5387
- usedFallbacks.add(existingSourcePath);
5388
5548
  }
5389
5549
 
5390
5550
  let persistentPath = existingSourcePath;
@@ -5392,6 +5552,11 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
5392
5552
  persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath) || existingSourcePath;
5393
5553
  }
5394
5554
 
5555
+ if (persistentPath === existingSourcePath && !existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
5556
+ copyFailures[normalizedPath] = Date.now();
5557
+ } else {
5558
+ delete copyFailures[normalizedPath];
5559
+ }
5395
5560
  aliases[normalizedPath] = persistentPath;
5396
5561
  nextPaths.push(persistentPath);
5397
5562
  }
@@ -5738,8 +5903,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
5738
5903
  async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 }) {
5739
5904
  const conditions = [
5740
5905
  `id > ${Math.max(0, Number(cursorId) || 0)}`,
5741
- `target = 'codex_api::endpoint::responses_websocket'`,
5742
- `feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%'`,
5906
+ `target IN ('codex_api::endpoint::responses_websocket', 'codex_api::sse::responses')`,
5907
+ `(feedback_log_body LIKE '%websocket event: {"type":"response.output_item.done"%' OR feedback_log_body LIKE '%SSE event: {"type":"response.output_item.done"%')`,
5743
5908
  `feedback_log_body LIKE '%"type":"message"%'`,
5744
5909
  `feedback_log_body LIKE '%"role":"assistant"%'`,
5745
5910
  `feedback_log_body LIKE '%"phase":"final_answer"%'`,
@@ -5750,10 +5915,24 @@ async function querySqliteCompletionRows({ logsDbFile, cursorId, minTsSec = 0 })
5750
5915
  }
5751
5916
 
5752
5917
  const sql = `
5753
- SELECT id, ts, thread_id, feedback_log_body
5918
+ SELECT
5919
+ logs.id,
5920
+ logs.ts,
5921
+ logs.thread_id,
5922
+ logs.feedback_log_body,
5923
+ (
5924
+ SELECT ctx.feedback_log_body
5925
+ FROM logs AS ctx
5926
+ WHERE ctx.id < logs.id
5927
+ AND ctx.id >= logs.id - 5
5928
+ AND ctx.target = 'codex_otel.log_only'
5929
+ AND ctx.feedback_log_body LIKE '%conversation.id=%'
5930
+ ORDER BY ctx.id DESC
5931
+ LIMIT 1
5932
+ ) AS context_log_body
5754
5933
  FROM logs
5755
5934
  WHERE ${conditions.join("\n AND ")}
5756
- ORDER BY id ASC
5935
+ ORDER BY logs.id ASC
5757
5936
  LIMIT ${SQLITE_COMPLETION_BATCH_SIZE}
5758
5937
  `;
5759
5938
 
@@ -5799,16 +5978,8 @@ async function runSqliteJsonQuery(dbFile, sql) {
5799
5978
 
5800
5979
  function buildSqliteCompletionEvent({ row, config, runtime }) {
5801
5980
  const body = String(row?.feedback_log_body ?? "");
5802
- const marker = "websocket event: ";
5803
- const markerIndex = body.indexOf(marker);
5804
- if (markerIndex === -1) {
5805
- return null;
5806
- }
5807
-
5808
- let payload;
5809
- try {
5810
- payload = JSON.parse(body.slice(markerIndex + marker.length));
5811
- } catch {
5981
+ const payload = parseResponsesEventPayload(body);
5982
+ if (!payload) {
5812
5983
  return null;
5813
5984
  }
5814
5985
 
@@ -5821,7 +5992,12 @@ function buildSqliteCompletionEvent({ row, config, runtime }) {
5821
5992
  return null;
5822
5993
  }
5823
5994
 
5824
- const threadId = cleanText(row.thread_id || extractThreadIdFromLogBody(body));
5995
+ const contextLogBody = String(row?.context_log_body ?? "");
5996
+ const threadId = cleanText(
5997
+ row.thread_id ||
5998
+ extractThreadIdFromLogBody(body) ||
5999
+ extractThreadIdFromLogBody(contextLogBody)
6000
+ );
5825
6001
  const turnId = cleanText(extractTurnIdFromLogBody(body) || item.id || row.id);
5826
6002
  if (!threadId || !turnId) {
5827
6003
  return null;
@@ -8214,6 +8390,12 @@ function normalizeIpcErrorMessage(errorValue) {
8214
8390
  return cleanText(String(errorValue ?? "")) || "ipc-request-failed";
8215
8391
  }
8216
8392
 
8393
+ function isStartTurnAckTimeout(errorValue) {
8394
+ const message = normalizeIpcErrorMessage(errorValue);
8395
+ return message === "thread-follower-start-turn-timeout" ||
8396
+ message === "turn/start-timeout";
8397
+ }
8398
+
8217
8399
  function buildDefaultCollaborationMode(threadState) {
8218
8400
  // Fallback turns must leave Plan mode unless the caller explicitly opts in.
8219
8401
  return buildRequestedCollaborationMode(threadState, "default");
@@ -8905,6 +9087,14 @@ function deriveClaudeSdkCliThreadLabel(messageText, conversationId = "") {
8905
9087
  if (/^You are scoring Moltbook posts? for an AI agent\b/iu.test(single)) {
8906
9088
  return "Moltbook scoring";
8907
9089
  }
9090
+ if (/^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9091
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single)) {
9092
+ return "Moltbook reply drafting";
9093
+ }
9094
+ if (/^You are composing an original post on Moltbook\b/iu.test(single) ||
9095
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)) {
9096
+ return "Moltbook post drafting";
9097
+ }
8908
9098
  if (/^Codex from another agent:/iu.test(single)) {
8909
9099
  return truncate(cleanText(single.replace(/^Codex from another agent:\s*/iu, "")), 90) || "Cross-agent task";
8910
9100
  }
@@ -8925,6 +9115,46 @@ function isHiddenClaudeInternalScoringText(text) {
8925
9115
  return /^You are scoring Moltbook posts? for an AI agent\b/iu.test(single);
8926
9116
  }
8927
9117
 
9118
+ function isMoltbookHarnessThreadLabel(text) {
9119
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9120
+ if (!single) {
9121
+ return false;
9122
+ }
9123
+ return (
9124
+ single === "Moltbook reply drafting" ||
9125
+ single === "Moltbook post drafting" ||
9126
+ /^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9127
+ /^You are composing an original post on Moltbook\b/iu.test(single) ||
9128
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single) ||
9129
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)
9130
+ );
9131
+ }
9132
+
9133
+ function isHiddenMoltbookHarnessPromptText(text) {
9134
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9135
+ if (!single) {
9136
+ return false;
9137
+ }
9138
+ return (
9139
+ /^You are drafting a reply on behalf of the agent described below\b/iu.test(single) ||
9140
+ /^You are composing an original post on Moltbook\b/iu.test(single) ||
9141
+ /\bYou are replying to the following post on Moltbook\b/iu.test(single) ||
9142
+ /\bAvailable submolts:\s*general,\s*builds,\s*tooling,\s*agents,\s*infrastructure\b.*\bSUBMOLT:\s*.*\bTITLE:\s*.*\bINTENT:\s*/iu.test(single)
9143
+ );
9144
+ }
9145
+
9146
+ function isHiddenMoltbookHarnessOutputText(text) {
9147
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
9148
+ if (!single) {
9149
+ return false;
9150
+ }
9151
+ return (
9152
+ /^INTENT:\s+.+\s+---\s+.+/isu.test(single) ||
9153
+ /^SUBMOLT:\s+.+\s+TITLE:\s+.+\s+INTENT:\s+.+\s+---\s+.+/isu.test(single) ||
9154
+ single === "NO_MATCH"
9155
+ );
9156
+ }
9157
+
8928
9158
  function isHiddenCodexApprovalAssessmentText(text) {
8929
9159
  const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
8930
9160
  if (!single) {
@@ -8934,7 +9164,7 @@ function isHiddenCodexApprovalAssessmentText(text) {
8934
9164
  }
8935
9165
 
8936
9166
  function shouldHideInternalTimelineItem(item) {
8937
- return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item);
9167
+ return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item) || shouldHideMoltbookHarnessItem(item);
8938
9168
  }
8939
9169
 
8940
9170
  function shouldHideClaudeInternalItem(item) {
@@ -8956,6 +9186,29 @@ function shouldHideClaudeInternalItem(item) {
8956
9186
  );
8957
9187
  }
8958
9188
 
9189
+ function shouldHideMoltbookHarnessItem(item) {
9190
+ if (!isPlainObject(item)) {
9191
+ return false;
9192
+ }
9193
+ const provider = normalizeProvider(item.provider);
9194
+ if (provider !== "claude" && provider !== "codex") {
9195
+ return false;
9196
+ }
9197
+
9198
+ return (
9199
+ isMoltbookHarnessThreadLabel(item.threadLabel) ||
9200
+ isMoltbookHarnessThreadLabel(item.title) ||
9201
+ isHiddenMoltbookHarnessPromptText(item.messageText) ||
9202
+ isHiddenMoltbookHarnessPromptText(item.summary) ||
9203
+ isHiddenMoltbookHarnessPromptText(item.detailText) ||
9204
+ isHiddenMoltbookHarnessPromptText(item.message) ||
9205
+ isHiddenMoltbookHarnessOutputText(item.messageText) ||
9206
+ isHiddenMoltbookHarnessOutputText(item.summary) ||
9207
+ isHiddenMoltbookHarnessOutputText(item.detailText) ||
9208
+ isHiddenMoltbookHarnessOutputText(item.message)
9209
+ );
9210
+ }
9211
+
8959
9212
  function shouldSuppressInternalScannedEvent(event) {
8960
9213
  if (!isPlainObject(event)) {
8961
9214
  return false;
@@ -9774,6 +10027,16 @@ function base64UrlDecode(value) {
9774
10027
  return Buffer.from(String(value), "base64url").toString("utf8");
9775
10028
  }
9776
10029
 
10030
+ function constantTimeStringEqual(a, b) {
10031
+ // Pad-then-compare so the timing leak is bounded by the longer string's
10032
+ // length rather than the matching prefix. Caller still gets a clean
10033
+ // boolean — no behavioural difference from `===`.
10034
+ const left = Buffer.from(String(a ?? ""), "utf8");
10035
+ const right = Buffer.from(String(b ?? ""), "utf8");
10036
+ if (left.length !== right.length) return false;
10037
+ return crypto.timingSafeEqual(left, right);
10038
+ }
10039
+
9777
10040
  function signSessionPayload(payload, secret) {
9778
10041
  const encoded = base64UrlEncode(JSON.stringify(payload));
9779
10042
  const signature = crypto.createHmac("sha256", secret).update(encoded).digest("base64url");
@@ -10100,6 +10363,35 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
10100
10363
  };
10101
10364
  }
10102
10365
 
10366
+ function inferLegacyRelayDeviceId(state, config, pairing) {
10367
+ const active = activeTrustedDevices(state, config);
10368
+ if (active.length === 1) {
10369
+ return active[0].deviceId;
10370
+ }
10371
+
10372
+ const addedAtMs = Number(pairing?.addedAtMs) || 0;
10373
+ if (addedAtMs <= 0) {
10374
+ return "";
10375
+ }
10376
+ const nearby = active.filter(({ record }) => {
10377
+ const pairedAtMs = Number(record?.pairedAtMs) || 0;
10378
+ return pairedAtMs > 0 && Math.abs(pairedAtMs - addedAtMs) <= 10 * 60 * 1000;
10379
+ });
10380
+ return nearby.length === 1 ? nearby[0].deviceId : "";
10381
+ }
10382
+
10383
+ function resolveRelaySessionDeviceId(state, config, pairing, fallbackDeviceId) {
10384
+ const explicit = cleanText(pairing?.deviceId || "");
10385
+ if (explicit) {
10386
+ return explicit;
10387
+ }
10388
+ const inferred = inferLegacyRelayDeviceId(state, config, pairing);
10389
+ if (inferred) {
10390
+ return inferred;
10391
+ }
10392
+ return cleanText(pairing?.phoneFingerprint || "") || cleanText(fallbackDeviceId || "") || null;
10393
+ }
10394
+
10103
10395
  // Shared between `/api/session` and `/api/bootstrap` so both return an
10104
10396
  // identical session shape.
10105
10397
  function buildSessionPayload({ config, state, session }) {
@@ -10110,6 +10402,7 @@ function buildSessionPayload({ config, state, session }) {
10110
10402
  webPushEnabled: config.webPushEnabled,
10111
10403
  httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
10112
10404
  appVersion: appPackageVersion,
10405
+ webAppBuildId: WEB_APP_BUILD_ID,
10113
10406
  deviceId: session.deviceId || null,
10114
10407
  temporaryPairing: session.temporaryPairing === true,
10115
10408
  ...buildSessionLocalePayload(config, state, session.deviceId),
@@ -10143,6 +10436,38 @@ function readSession(req, config, state) {
10143
10436
  };
10144
10437
  }
10145
10438
 
10439
+ // Relay-dispatched requests authenticate via the Noise channel
10440
+ // binding + pairing identity, set on the synthetic IncomingMessage by
10441
+ // scripts/lib/remote-pairing/http-dispatch.mjs. They have no cookies
10442
+ // (HttpOnly session cookie can't be forwarded by the PWA's JS layer,
10443
+ // and the RPC frame doesn't carry browser cookies), so the cookie
10444
+ // path below would always 401 them.
10445
+ //
10446
+ // Trust model: only this process's relay orchestrator can attach this
10447
+ // marker — it's set on a synthetic req object that never leaves the
10448
+ // bridge. An attacker would need code-exec inside the bridge to forge
10449
+ // it, which is past the threat model anyway. The orchestrator only
10450
+ // dispatches requests for pairings whose Noise IK handshake succeeded
10451
+ // against the bridge's static key, so reaching this branch implies
10452
+ // the phone proved possession of `pairing.phonePub`.
10453
+ const relay = req.viveworker;
10454
+ if (relay?.fromRelay && relay.pairing?.pairingId) {
10455
+ const relayDeviceId = resolveRelaySessionDeviceId(state, config, relay.pairing, deviceId);
10456
+ return {
10457
+ authenticated: true,
10458
+ sessionId: `relay:${relay.pairing.pairingId}`,
10459
+ pairedAtMs: Number(relay.pairing.addedAtMs) || Date.now(),
10460
+ // Relay sessions live only as long as the underlying Noise channel
10461
+ // — there's no cookie expiry to track. 0 = "no fixed expiry, the
10462
+ // transport layer manages liveness".
10463
+ expiresAtMs: 0,
10464
+ deviceId: relayDeviceId,
10465
+ fromRelay: true,
10466
+ pairingId: relay.pairing.pairingId,
10467
+ relayPhoneFingerprint: relay.pairing.phoneFingerprint || "",
10468
+ };
10469
+ }
10470
+
10146
10471
  const token = parseCookies(req)[sessionCookieName];
10147
10472
  const payload = token ? verifySessionToken(token, config.sessionSecret) : null;
10148
10473
  if (!payload) {
@@ -10440,8 +10765,15 @@ function validatePairingPayload(payload, config, state) {
10440
10765
 
10441
10766
  const code = cleanText(payload?.code ?? "").toUpperCase();
10442
10767
  const token = cleanText(payload?.token ?? "");
10443
- const matchesCode = code && cleanText(config.pairingCode).toUpperCase() === code;
10444
- const matchesToken = token && cleanText(config.pairingToken) === token;
10768
+ // Constant-time compare both sides even with the 8/15min lockout the
10769
+ // pairing token is long enough that a measurable timing prefix-attack
10770
+ // could meaningfully reduce the brute-force search space.
10771
+ const matchesCode =
10772
+ !!code &&
10773
+ constantTimeStringEqual(cleanText(config.pairingCode).toUpperCase(), code);
10774
+ const matchesToken =
10775
+ !!token &&
10776
+ constantTimeStringEqual(cleanText(config.pairingToken), token);
10445
10777
  if (matchesToken) {
10446
10778
  return { ok: true, credential: `token:${token}` };
10447
10779
  }
@@ -10601,6 +10933,16 @@ function requestOrigin(req) {
10601
10933
  }
10602
10934
 
10603
10935
  function requireTrustedMutationOrigin(req, res, config) {
10936
+ // Relay-dispatched requests have no Origin / Referer (the PWA's RPC
10937
+ // frame doesn't synthesize one) and a non-loopback synthetic
10938
+ // remoteAddress, so they would always 403 here. They're authenticated
10939
+ // by the Noise channel binding instead — no browser-side CSRF surface
10940
+ // exists because an attacker can't establish a Noise channel without
10941
+ // the pairing's static key. See readSession() for the trust-model
10942
+ // notes.
10943
+ if (req.viveworker?.fromRelay) {
10944
+ return true;
10945
+ }
10604
10946
  const origin = requestOrigin(req);
10605
10947
  if (!origin) {
10606
10948
  if (isLoopbackRequest(req)) {
@@ -11248,7 +11590,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
11248
11590
  threadLabel: entry.threadLabel,
11249
11591
  summary: entry.summary,
11250
11592
  fileEventType: normalizeTimelineFileEventType(entry.fileEventType ?? ""),
11251
- imageUrls: buildTimelineEntryImageUrls(entry),
11593
+ imageUrls: buildTimelineEntryImageUrls(entry, config),
11252
11594
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
11253
11595
  diffAvailable: Boolean(entry.diffAvailable),
11254
11596
  diffAddedLines: Math.max(0, Number(entry.diffAddedLines) || 0),
@@ -11294,6 +11636,7 @@ function buildPendingApprovalDetail(runtime, state, approval, locale) {
11294
11636
  : approvalKind === "hazbase_wallet_payment"
11295
11637
  ? [
11296
11638
  { label: t(locale, "server.action.payWithWallet"), tone: "primary", url: `/api/payments/x402/hazbase-wallet/${encodeURIComponent(approval.token)}/pay`, body: { hazbaseReauth: true } },
11639
+ { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
11297
11640
  ]
11298
11641
  : [
11299
11642
  { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
@@ -11756,7 +12099,35 @@ function buildHistoryDetail(item, locale, runtime = null) {
11756
12099
  };
11757
12100
  }
11758
12101
 
11759
- function buildTimelineEntryImageUrls(entry) {
12102
+ function signTimelineEntryImageUrl(config, token, index, filePath) {
12103
+ const secret = cleanText(config?.sessionSecret || "");
12104
+ const normalizedToken = cleanText(token || "");
12105
+ const normalizedPath = filePath ? path.resolve(String(filePath)) : "";
12106
+ const normalizedIndex = Math.max(0, Number(index) || 0);
12107
+ if (!secret || !normalizedToken || !normalizedPath) {
12108
+ return "";
12109
+ }
12110
+ return crypto
12111
+ .createHmac("sha256", secret)
12112
+ .update(`${normalizedToken}\n${normalizedIndex}\n${normalizedPath}`)
12113
+ .digest("base64url");
12114
+ }
12115
+
12116
+ function isValidTimelineEntryImageSignature(config, url, token, index, filePath) {
12117
+ const provided = cleanText(url?.searchParams?.get("imageToken") || "");
12118
+ if (!provided) {
12119
+ return false;
12120
+ }
12121
+ const expected = signTimelineEntryImageUrl(config, token, index, filePath);
12122
+ if (!expected) {
12123
+ return false;
12124
+ }
12125
+ const left = Buffer.from(provided, "utf8");
12126
+ const right = Buffer.from(expected, "utf8");
12127
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
12128
+ }
12129
+
12130
+ function buildTimelineEntryImageUrls(entry, config = null) {
11760
12131
  const imagePaths = normalizeTimelineImagePaths(entry?.imagePaths ?? []);
11761
12132
  if (imagePaths.length === 0) {
11762
12133
  return [];
@@ -11765,10 +12136,14 @@ function buildTimelineEntryImageUrls(entry) {
11765
12136
  if (!token) {
11766
12137
  return [];
11767
12138
  }
11768
- return imagePaths.map((_, index) => `/api/timeline/${encodeURIComponent(token)}/images/${index}`);
12139
+ return imagePaths.map((filePath, index) => {
12140
+ const imageToken = signTimelineEntryImageUrl(config, token, index, filePath);
12141
+ const suffix = imageToken ? `?imageToken=${encodeURIComponent(imageToken)}` : "";
12142
+ return `/api/timeline/${encodeURIComponent(token)}/images/${index}${suffix}`;
12143
+ });
11769
12144
  }
11770
12145
 
11771
- function buildTimelineMessageDetail(entry, locale, runtime = null) {
12146
+ function buildTimelineMessageDetail(entry, locale, runtime = null, config = null) {
11772
12147
  return {
11773
12148
  kind: entry.kind,
11774
12149
  token: entry.token,
@@ -11777,7 +12152,7 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
11777
12152
  threadLabel: entry.threadLabel || "",
11778
12153
  createdAtMs: Number(entry.createdAtMs) || 0,
11779
12154
  messageHtml: renderMessageHtml(entry.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
11780
- imageUrls: buildTimelineEntryImageUrls(entry),
12155
+ imageUrls: buildTimelineEntryImageUrls(entry, config),
11781
12156
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
11782
12157
  previousContext: buildInterruptedTimelineContext(runtime, entry, locale),
11783
12158
  interruptNotice: interruptedDetailNotice(entry.messageText, locale),
@@ -12269,6 +12644,18 @@ async function handleCompletionReply({
12269
12644
  );
12270
12645
  let lastError = null;
12271
12646
  const ownerClientId = runtime.threadOwnerClientIds.get(conversationId) ?? null;
12647
+ const finalizeReplyAccepted = async () => {
12648
+ if (timelineImageAliases.length > 0) {
12649
+ const aliases = isPlainObject(state.timelineImagePathAliases)
12650
+ ? state.timelineImagePathAliases
12651
+ : (state.timelineImagePathAliases = {});
12652
+ for (const [sourcePath, persistentPath] of timelineImageAliases) {
12653
+ aliases[sourcePath] = persistentPath;
12654
+ }
12655
+ await saveState(config.stateFile, state);
12656
+ }
12657
+ scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
12658
+ };
12272
12659
 
12273
12660
  for (const candidate of turnCandidates) {
12274
12661
  try {
@@ -12291,18 +12678,19 @@ async function handleCompletionReply({
12291
12678
  console.log(
12292
12679
  `[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
12293
12680
  );
12294
- if (timelineImageAliases.length > 0) {
12295
- const aliases = isPlainObject(state.timelineImagePathAliases)
12296
- ? state.timelineImagePathAliases
12297
- : (state.timelineImagePathAliases = {});
12298
- for (const [sourcePath, persistentPath] of timelineImageAliases) {
12299
- aliases[sourcePath] = persistentPath;
12300
- }
12301
- await saveState(config.stateFile, state);
12302
- }
12303
- scheduleBestEffortFileCleanup(stagedWorkspaceImagePaths);
12681
+ await finalizeReplyAccepted();
12304
12682
  return;
12305
12683
  } catch (error) {
12684
+ if (isStartTurnAckTimeout(error)) {
12685
+ // Codex can create the turn but fail to send the bridge an ACK before
12686
+ // its internal follower timeout fires. Retrying risks duplicate user
12687
+ // turns, so treat this as accepted and let the timeline catch up.
12688
+ console.log(
12689
+ `[completion-reply] accepted candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
12690
+ );
12691
+ await finalizeReplyAccepted();
12692
+ return;
12693
+ }
12306
12694
  lastError = error;
12307
12695
  console.log(
12308
12696
  `[completion-reply] failed candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} error=${normalizeIpcErrorMessage(error)} raw=${inspect(error?.ipcError ?? error, { depth: 6, breakLength: 160 })}`
@@ -13082,7 +13470,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13082
13470
  if (timelineMessageKinds.has(kind)) {
13083
13471
  const entry = timelineEntryByToken(runtime, token, kind);
13084
13472
  if (!entry) return null;
13085
- const detail = buildTimelineMessageDetail(entry, locale, runtime);
13473
+ const detail = buildTimelineMessageDetail(entry, locale, runtime, config);
13086
13474
  // Add reply support for Codex assistant_final entries only (replaces completion reply).
13087
13475
  // Check directly against timeline entries (not history items) to avoid
13088
13476
  // maxHistoryItems eviction issues.
@@ -13283,8 +13671,11 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13283
13671
  return historyItem ? buildHistoryDetail(historyItem, locale, runtime) : null;
13284
13672
  }
13285
13673
 
13286
- function resolveTimelineEntryImagePath(runtime, token, index) {
13287
- const entry = timelineEntryByToken(runtime, token);
13674
+ function resolveTimelineEntryImagePath(runtime, state, token, index) {
13675
+ const normalizedToken = cleanText(token || "");
13676
+ const entry = timelineEntryByToken(runtime, normalizedToken)
13677
+ || (state?.recentTimelineEntries ?? []).find((candidate) => cleanText(candidate?.token || "") === normalizedToken)
13678
+ || null;
13288
13679
  if (!entry) {
13289
13680
  return "";
13290
13681
  }
@@ -13480,11 +13871,125 @@ function buildWebAppHtml({ pairToken }) {
13480
13871
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
13481
13872
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
13482
13873
  <link rel="stylesheet" href="/app.css">
13874
+ <style>
13875
+ .boot-splash {
13876
+ position: fixed;
13877
+ inset: 0;
13878
+ z-index: 9999;
13879
+ display: grid;
13880
+ place-items: center;
13881
+ padding: max(1.2rem, env(safe-area-inset-top)) 1rem max(1.2rem, env(safe-area-inset-bottom));
13882
+ color: #f5fbff;
13883
+ background:
13884
+ radial-gradient(circle at 50% 18%, rgba(47, 143, 103, 0.22), transparent 30%),
13885
+ radial-gradient(circle at 78% 78%, rgba(79, 131, 216, 0.14), transparent 28%),
13886
+ linear-gradient(180deg, #081015 0%, #091015 100%);
13887
+ transition: opacity 220ms ease, visibility 220ms ease;
13888
+ }
13889
+ .boot-splash__card {
13890
+ width: min(20rem, 82vw);
13891
+ display: grid;
13892
+ justify-items: center;
13893
+ gap: 0.9rem;
13894
+ text-align: center;
13895
+ }
13896
+ .boot-splash__logo {
13897
+ width: clamp(5.4rem, 28vw, 7rem);
13898
+ height: clamp(5.4rem, 28vw, 7rem);
13899
+ border-radius: 28%;
13900
+ background:
13901
+ radial-gradient(circle at 76% 24%, rgba(125, 211, 252, 0.22), transparent 30%),
13902
+ linear-gradient(180deg, rgba(23, 52, 72, 0.96), rgba(9, 17, 23, 0.96));
13903
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.32);
13904
+ }
13905
+ .boot-splash__title {
13906
+ margin: 0.25rem 0 0;
13907
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13908
+ font-size: clamp(1.65rem, 8vw, 2.4rem);
13909
+ line-height: 1;
13910
+ letter-spacing: -0.04em;
13911
+ }
13912
+ .boot-splash__status {
13913
+ margin: 0;
13914
+ color: rgba(205, 220, 231, 0.72);
13915
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13916
+ font-size: 0.9rem;
13917
+ letter-spacing: 0.02em;
13918
+ min-height: 1.25em;
13919
+ transition: opacity 160ms ease, transform 160ms ease;
13920
+ }
13921
+ .boot-splash__hint {
13922
+ max-width: 15rem;
13923
+ margin: -0.35rem 0 0;
13924
+ color: rgba(178, 196, 210, 0.58);
13925
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13926
+ font-size: 0.78rem;
13927
+ line-height: 1.45;
13928
+ opacity: 0;
13929
+ transform: translateY(-0.2rem);
13930
+ transition: opacity 220ms ease, transform 220ms ease;
13931
+ }
13932
+ .boot-splash__hint.is-visible {
13933
+ opacity: 1;
13934
+ transform: translateY(0);
13935
+ }
13936
+ .boot-splash__dots {
13937
+ display: inline-grid;
13938
+ grid-auto-flow: column;
13939
+ gap: 0.34rem;
13940
+ margin-top: 0.15rem;
13941
+ }
13942
+ .boot-splash__dots span {
13943
+ width: 0.42rem;
13944
+ height: 0.42rem;
13945
+ border-radius: 999px;
13946
+ background: rgba(142, 215, 255, 0.88);
13947
+ animation: viveworker-boot-dot 980ms ease-in-out infinite;
13948
+ }
13949
+ .boot-splash__dots span:nth-child(2) { animation-delay: 140ms; }
13950
+ .boot-splash__dots span:nth-child(3) { animation-delay: 280ms; }
13951
+ .viveworker-ready .boot-splash {
13952
+ opacity: 0;
13953
+ visibility: hidden;
13954
+ }
13955
+ @keyframes viveworker-boot-dot {
13956
+ 0%, 80%, 100% { transform: translateY(0); opacity: 0.42; }
13957
+ 40% { transform: translateY(-0.28rem); opacity: 1; }
13958
+ }
13959
+ @media (prefers-reduced-motion: reduce) {
13960
+ .boot-splash,
13961
+ .boot-splash__status,
13962
+ .boot-splash__hint,
13963
+ .boot-splash__dots span {
13964
+ transition: none;
13965
+ animation: none;
13966
+ }
13967
+ }
13968
+ </style>
13483
13969
  <title>viveworker</title>
13484
13970
  </head>
13485
13971
  <body>
13972
+ <div id="boot-splash" class="boot-splash" role="status" aria-live="polite" aria-label="viveworker is starting">
13973
+ <div class="boot-splash__card">
13974
+ <img class="boot-splash__logo" src="/icons/viveworker-v-pulse.svg" alt="" width="112" height="112" decoding="async">
13975
+ <h1 class="boot-splash__title">viveworker</h1>
13976
+ <p id="boot-splash-status" class="boot-splash__status">Checking your trusted Wi-Fi...</p>
13977
+ <p id="boot-splash-hint" class="boot-splash__hint" hidden>The first remote connection can take tens of seconds.</p>
13978
+ <span class="boot-splash__dots" aria-hidden="true"><span></span><span></span><span></span></span>
13979
+ </div>
13980
+ </div>
13486
13981
  <div id="app"></div>
13487
- <script type="module" src="/app.js"></script>
13982
+ <script>
13983
+ (() => {
13984
+ const isJa = (navigator.language || "").toLowerCase().startsWith("ja");
13985
+ const message = isJa ? "同じWi-Fi内のPCを確認中..." : "Checking your trusted Wi-Fi...";
13986
+ const status = document.getElementById("boot-splash-status");
13987
+ const splash = document.getElementById("boot-splash");
13988
+ if (status) status.textContent = message;
13989
+ if (splash) splash.setAttribute("aria-label", \`viveworker \${message}\`);
13990
+ })();
13991
+ </script>
13992
+ <script type="module" src="${WEB_APP_SCRIPT_URL}"></script>
13488
13993
  </body>
13489
13994
  </html>`;
13490
13995
  }
@@ -13493,10 +13998,132 @@ function resolvePagePairingToken({ req, config, state, requestedToken }) {
13493
13998
  return resolveManifestPairingToken({ config, state, requestedToken });
13494
13999
  }
13495
14000
 
14001
+ function normalizeBootTraceEvent(event) {
14002
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
14003
+ return null;
14004
+ }
14005
+ const normalized = {
14006
+ tMs: Math.max(0, Math.min(600_000, Math.round(Number(event.tMs) || 0))),
14007
+ type: cleanText(event.type || "").slice(0, 80),
14008
+ };
14009
+ if (!normalized.type) {
14010
+ return null;
14011
+ }
14012
+ const phase = cleanText(event.phase || "").slice(0, 80);
14013
+ const stateValue = cleanText(event.state || "").slice(0, 80);
14014
+ const previousState = cleanText(event.previousState || "").slice(0, 80);
14015
+ const reason = cleanText(event.reason || "").slice(0, 120);
14016
+ const urlPath = cleanText(event.url || "").split("?")[0].slice(0, 120);
14017
+ if (phase) normalized.phase = phase;
14018
+ if (stateValue) normalized.state = stateValue;
14019
+ if (previousState) normalized.previousState = previousState;
14020
+ if (reason) normalized.reason = reason;
14021
+ if (urlPath) normalized.url = urlPath;
14022
+ if (event.sticky === true || event.sticky === false) normalized.sticky = event.sticky;
14023
+ if (Number.isFinite(Number(event.code))) normalized.code = Math.round(Number(event.code));
14024
+ if (event.resumed === true || event.resumed === false) normalized.resumed = event.resumed;
14025
+ return normalized;
14026
+ }
14027
+
14028
+ function formatBootTraceEvent(event) {
14029
+ const parts = [`${event.tMs}ms`, event.type];
14030
+ if (event.phase) parts.push(event.phase);
14031
+ if (event.state) {
14032
+ parts.push(event.previousState ? `${event.previousState}->${event.state}` : event.state);
14033
+ }
14034
+ if (event.url) parts.push(event.url);
14035
+ if (event.reason) parts.push(`reason=${event.reason}`);
14036
+ if (event.sticky === true) parts.push("sticky=1");
14037
+ if (event.code) parts.push(`code=${event.code}`);
14038
+ if (event.resumed === true || event.resumed === false) parts.push(`resumed=${event.resumed ? 1 : 0}`);
14039
+ return parts.join(":");
14040
+ }
14041
+
14042
+ function logRemotePairingBootTrace(body, session, req) {
14043
+ const traceId = cleanText(body?.traceId || "").slice(0, 80) || "unknown";
14044
+ const reason = cleanText(body?.reason || "").slice(0, 80) || "unknown";
14045
+ const appBuildId = cleanText(body?.appBuildId || "").slice(0, 80) || "unknown";
14046
+ const totalMs = Math.max(0, Math.min(600_000, Math.round(Number(body?.totalMs) || 0)));
14047
+ const remoteRouteSeen = body?.remoteRouteSeen === true;
14048
+ const eventList = Array.isArray(body?.events) ? body.events : [];
14049
+ const events = eventList
14050
+ .slice(-90)
14051
+ .map(normalizeBootTraceEvent)
14052
+ .filter(Boolean);
14053
+ const phases = events
14054
+ .filter((event) => event.type === "route" && event.phase)
14055
+ .map((event) => `${event.tMs}:${event.phase}`)
14056
+ .join(" > ");
14057
+ const transport = events
14058
+ .filter((event) => event.phase === "remote-transport-state" || event.phase === "remote-transport-error")
14059
+ .map((event) => {
14060
+ if (event.phase === "remote-transport-error") return `${event.tMs}:error(${event.reason || "unknown"})`;
14061
+ return `${event.tMs}:${event.previousState || "?"}->${event.state || "?"}${event.reason ? `(${event.reason})` : ""}`;
14062
+ })
14063
+ .join(" > ");
14064
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
14065
+ const ua = requestUserAgent(req).slice(0, 90);
14066
+
14067
+ console.log(
14068
+ `[remote-pairing-boot] trace=${traceId} device=${deviceId} reason=${reason} ` +
14069
+ `total=${totalMs}ms remote=${remoteRouteSeen ? 1 : 0} build=${appBuildId} ` +
14070
+ `events=${events.length} ua=${JSON.stringify(ua)}`
14071
+ );
14072
+ if (phases) {
14073
+ console.log(`[remote-pairing-boot-phases] trace=${traceId} ${phases}`);
14074
+ }
14075
+ if (transport) {
14076
+ console.log(`[remote-pairing-boot-transport] trace=${traceId} ${transport}`);
14077
+ }
14078
+ if (totalMs >= 5_000 || remoteRouteSeen) {
14079
+ console.log(`[remote-pairing-boot-detail] trace=${traceId} ${events.map(formatBootTraceEvent).join(" | ")}`);
14080
+ }
14081
+ }
14082
+
14083
+ function recordRemotePairingAudit(event) {
14084
+ appendRemotePairingAuditEvent({
14085
+ atMs: Date.now(),
14086
+ ...event,
14087
+ }).catch((err) => {
14088
+ console.warn(`[remote-pairing-audit] write failed: ${err?.message}`);
14089
+ });
14090
+ }
14091
+
14092
+ function remotePairingAuditFieldsForPairing(pairing) {
14093
+ if (!pairing) return {};
14094
+ return {
14095
+ pairingId: redactShortId(pairing.pairingId),
14096
+ phoneFingerprint: pairing.phoneFingerprint || "",
14097
+ label: pairing.label || "",
14098
+ deviceId: pairing.deviceId || "",
14099
+ };
14100
+ }
14101
+
14102
+ function redactShortId(value) {
14103
+ const text = cleanText(value || "").slice(0, 80);
14104
+ return text ? `${text.slice(0, 6)}…` : "";
14105
+ }
14106
+
14107
+ function relayHostForAudit(value) {
14108
+ try {
14109
+ return new URL(value || DEFAULT_RELAY_URL).host;
14110
+ } catch {
14111
+ return "";
14112
+ }
14113
+ }
14114
+
14115
+ function shouldAutoRotateRemotePairingToken(pairing, now = Date.now()) {
14116
+ if (!pairing) return false;
14117
+ const updatedAt = Number(pairing.relayTokenUpdatedAtMs) || 0;
14118
+ if (!updatedAt) return true;
14119
+ return now - updatedAt >= REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS;
14120
+ }
14121
+
13496
14122
  function createNativeApprovalServer({ config, runtime, state }) {
13497
14123
  const requestHandler = async (req, res) => {
13498
14124
  try {
13499
14125
  const url = new URL(req.url, config.nativeApprovalPublicBaseUrl);
14126
+ refreshPairingConfigFromEnvFile(config);
13500
14127
 
13501
14128
  if (url.pathname === "/health") {
13502
14129
  return writeJson(res, 200, { ok: true });
@@ -13549,7 +14176,16 @@ function createNativeApprovalServer({ config, runtime, state }) {
13549
14176
  url.pathname === "/i18n.js" ||
13550
14177
  url.pathname === "/hazbase-passkey.js" ||
13551
14178
  url.pathname === "/sw.js" ||
13552
- url.pathname.startsWith("/icons/")
14179
+ url.pathname.startsWith("/icons/") ||
14180
+ // Remote-pairing PWA modules + their generated crypto bundle.
14181
+ // The bundle is auto-built by scripts/build-remote-pairing-bundle.mjs
14182
+ // and the per-module wrappers under web/remote-pairing/ import from it.
14183
+ // resolveWebAsset() still enforces the webRoot containment check, so
14184
+ // a wide-prefix allowlist here is safe against path traversal.
14185
+ url.pathname.startsWith("/remote-pairing/") ||
14186
+ url.pathname === "/remote-pairing.bundle.js" ||
14187
+ url.pathname === "/remote-pairing.bundle.js.map" ||
14188
+ url.pathname === "/remote-pairing.bundle.js.LEGAL.txt"
13553
14189
  ) {
13554
14190
  const served = await serveWebAsset(res, url.pathname, req);
13555
14191
  if (served) {
@@ -13725,9 +14361,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
13725
14361
  }
13726
14362
  const sessionPayload = buildSessionPayload({ config, state, session });
13727
14363
  if (!session.authenticated) {
13728
- return writeJson(res, 200, { session: sessionPayload });
14364
+ return writeJson(res, 200, { appBuildId: WEB_APP_BUILD_ID, session: sessionPayload });
13729
14365
  }
13730
14366
  return writeJson(res, 200, {
14367
+ appBuildId: WEB_APP_BUILD_ID,
13731
14368
  session: sessionPayload,
13732
14369
  inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
13733
14370
  timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
@@ -14454,17 +15091,523 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
14454
15091
  return writeJson(res, 200, { ok: true, acceptPublicTasks: accept });
14455
15092
  }
14456
15093
 
14457
- // ─── Thread sharing ──────────────────────────────────────────────
15094
+ // ─── Remote pairing (off-LAN PWA relay) ──────────────────────────
14458
15095
 
14459
- function buildThreadShareContent(shareType, body) {
14460
- if (shareType === "plan_review") {
14461
- const sections = [];
14462
- if (body.context) sections.push(`## Context\n${body.context}`);
14463
- if (body.plan) sections.push(`## Plan\n${body.plan}`);
14464
- if (Array.isArray(body.files) && body.files.length) {
14465
- sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
14466
- }
14467
- sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
15096
+ // GET /api/remote-pairing/status — settings UI bootstrap.
15097
+ //
15098
+ // Returns the current orchestrator state plus the (read-only) list of
15099
+ // pairings on disk. Sessions only exist when `enabled: true`; when the
15100
+ // relay is off we still surface the pairings so the user can revoke
15101
+ // before re-enabling.
15102
+ if (url.pathname === "/api/remote-pairing/status" && req.method === "GET") {
15103
+ const session = requireApiSession(req, res, config, state);
15104
+ if (!session) return;
15105
+ const status = getRemotePairingStatus(runtime);
15106
+ let pairings = [];
15107
+ let auditEvents = [];
15108
+ try {
15109
+ const list = await loadPairings();
15110
+ pairings = list.map((p) => ({
15111
+ pairingId: p.pairingId,
15112
+ label: p.label || "",
15113
+ phonePub: p.phonePub,
15114
+ phoneFingerprint: p.phoneFingerprint,
15115
+ deviceId: p.deviceId || null,
15116
+ addedAtMs: p.addedAtMs ?? null,
15117
+ relayTokenUpdatedAtMs: p.relayTokenUpdatedAtMs ?? null,
15118
+ lastSeenAtMs: p.lastSeenAtMs ?? null,
15119
+ }));
15120
+ } catch (err) {
15121
+ console.warn(`[remote-pairing] status: failed to load pairings: ${err?.message}`);
15122
+ }
15123
+ try {
15124
+ auditEvents = await readRemotePairingAuditEvents({ limit: 12 });
15125
+ } catch (err) {
15126
+ console.warn(`[remote-pairing] status: failed to load audit log: ${err?.message}`);
15127
+ }
15128
+ return writeJson(res, 200, {
15129
+ enabled: status.enabled,
15130
+ relayUrl: status.relayUrl,
15131
+ configuredRelayUrl: config.remotePairingRelayUrl || "",
15132
+ identityFingerprint: status.identityFingerprint,
15133
+ identityPubHex: status.identityPubHex,
15134
+ sessions: status.sessions,
15135
+ pairings,
15136
+ auditEvents,
15137
+ pairingsFile: REMOTE_PAIRINGS_FILE,
15138
+ });
15139
+ }
15140
+
15141
+ // POST /api/remote-pairing/boot-trace — PWA cold-start timing.
15142
+ //
15143
+ // This is intentionally diagnostics-only: it does not mutate state and
15144
+ // only logs a sanitized, capped phase timeline so off-LAN boot latency
15145
+ // can be debugged without attaching Safari Web Inspector to the phone.
15146
+ if (url.pathname === "/api/remote-pairing/boot-trace" && req.method === "POST") {
15147
+ const session = requireApiSession(req, res, config, state);
15148
+ if (!session) return;
15149
+ let body;
15150
+ try {
15151
+ body = await parseJsonBody(req);
15152
+ } catch (err) {
15153
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15154
+ }
15155
+ try {
15156
+ logRemotePairingBootTrace(body, session, req);
15157
+ } catch (err) {
15158
+ console.warn(`[remote-pairing-boot] failed to log trace: ${err?.message}`);
15159
+ }
15160
+ return writeJson(res, 200, { ok: true });
15161
+ }
15162
+
15163
+ // POST /api/remote-pairing/toggle — flip on/off without restart.
15164
+ //
15165
+ // Body: { enabled: boolean }
15166
+ // Persists to ~/.viveworker/remote-pairing.env (so the next bridge
15167
+ // launch starts in the new state) and hot-restarts the orchestrator.
15168
+ // The same approval server's request handler is reused, so cookies /
15169
+ // session auth behave identically over LAN HTTP and the relay.
15170
+ if (url.pathname === "/api/remote-pairing/toggle" && req.method === "POST") {
15171
+ const session = requireMutatingApiSession(req, res, config, state);
15172
+ if (!session) return;
15173
+ let body;
15174
+ try {
15175
+ body = await parseJsonBody(req);
15176
+ } catch (err) {
15177
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15178
+ }
15179
+ if (typeof body?.enabled !== "boolean") {
15180
+ return writeJson(res, 400, { error: "enabled-required", message: "body.enabled must be a boolean" });
15181
+ }
15182
+ if (typeof requestHandler !== "function") {
15183
+ return writeJson(res, 503, { error: "request-handler-unavailable" });
15184
+ }
15185
+ const next = body.enabled;
15186
+ config.remotePairingEnabled = next;
15187
+ recordRemotePairingAudit({
15188
+ type: next ? "relay_enabled" : "relay_disabled",
15189
+ outcome: "info",
15190
+ deviceId: session.deviceId || "",
15191
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15192
+ });
15193
+ try {
15194
+ await persistRemotePairingEnv({ enabled: next });
15195
+ } catch (err) {
15196
+ // Persistence failure shouldn't roll back the in-memory config —
15197
+ // the user can retry from the UI and the runtime is still in the
15198
+ // intended state. Surface the error clearly.
15199
+ console.error(`[remote-pairing] env persist failed: ${err?.message}`);
15200
+ }
15201
+ try {
15202
+ await restartRemotePairingRelay({
15203
+ runtime,
15204
+ config,
15205
+ requestListener: requestHandler,
15206
+ logger: console,
15207
+ auditEventSink: recordRemotePairingAudit,
15208
+ });
15209
+ } catch (err) {
15210
+ console.error(`[remote-pairing] hot-restart failed: ${err?.message}`);
15211
+ return writeJson(res, 500, { error: "restart-failed", message: err.message });
15212
+ }
15213
+ const status = getRemotePairingStatus(runtime);
15214
+ return writeJson(res, 200, {
15215
+ ok: true,
15216
+ enabled: status.enabled,
15217
+ relayUrl: status.relayUrl,
15218
+ identityFingerprint: status.identityFingerprint,
15219
+ sessions: status.sessions,
15220
+ });
15221
+ }
15222
+
15223
+ // POST /api/remote-pairing/relay-url — change the relay endpoint.
15224
+ //
15225
+ // Body: { relayUrl: string | null }
15226
+ // - empty string / null → falls back to DEFAULT_RELAY_URL
15227
+ // - non-empty string → must be wss://, except ws:// loopback for local dev
15228
+ // Persists + hot-restarts (only restarts if currently enabled, since
15229
+ // a dormant handle has nothing to reconnect).
15230
+ if (url.pathname === "/api/remote-pairing/relay-url" && req.method === "POST") {
15231
+ const session = requireMutatingApiSession(req, res, config, state);
15232
+ if (!session) return;
15233
+ let body;
15234
+ try {
15235
+ body = await parseJsonBody(req);
15236
+ } catch (err) {
15237
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15238
+ }
15239
+ const raw = body?.relayUrl;
15240
+ const relayUrlResult = normalizeRemotePairingRelayUrl(raw ?? "");
15241
+ if (!relayUrlResult.ok) {
15242
+ return writeJson(res, 400, {
15243
+ error: "invalid-relay-url",
15244
+ message: relayUrlResult.message,
15245
+ });
15246
+ }
15247
+ const normalized = relayUrlResult.value;
15248
+ config.remotePairingRelayUrl = normalized;
15249
+ recordRemotePairingAudit({
15250
+ type: "relay_url_changed",
15251
+ outcome: "info",
15252
+ deviceId: session.deviceId || "",
15253
+ relayHost: relayHostForAudit(normalized || DEFAULT_RELAY_URL),
15254
+ });
15255
+ try {
15256
+ await persistRemotePairingEnv({ relayUrl: normalized || null });
15257
+ } catch (err) {
15258
+ console.error(`[remote-pairing] env persist failed: ${err?.message}`);
15259
+ }
15260
+ if (config.remotePairingEnabled && typeof requestHandler === "function") {
15261
+ try {
15262
+ await restartRemotePairingRelay({
15263
+ runtime,
15264
+ config,
15265
+ requestListener: requestHandler,
15266
+ logger: console,
15267
+ auditEventSink: recordRemotePairingAudit,
15268
+ });
15269
+ } catch (err) {
15270
+ console.error(`[remote-pairing] hot-restart failed: ${err?.message}`);
15271
+ return writeJson(res, 500, { error: "restart-failed", message: err.message });
15272
+ }
15273
+ }
15274
+ const status = getRemotePairingStatus(runtime);
15275
+ return writeJson(res, 200, {
15276
+ ok: true,
15277
+ enabled: status.enabled,
15278
+ relayUrl: status.relayUrl,
15279
+ configuredRelayUrl: config.remotePairingRelayUrl || "",
15280
+ });
15281
+ }
15282
+
15283
+ // POST /api/remote-pairing/revoke — drop a pairing by phonePub.
15284
+ //
15285
+ // Body: { phonePub: string } (64-char hex, the phone's static X25519 pub)
15286
+ // Removes the entry from `~/.viveworker/remote-pairings.json` and
15287
+ // calls reloadNow() so the orchestrator tears the live session down.
15288
+ // No-op if the pub isn't on file (idempotent).
15289
+ if (url.pathname === "/api/remote-pairing/revoke" && req.method === "POST") {
15290
+ const session = requireMutatingApiSession(req, res, config, state);
15291
+ if (!session) return;
15292
+ let body;
15293
+ try {
15294
+ body = await parseJsonBody(req);
15295
+ } catch (err) {
15296
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15297
+ }
15298
+ const phonePub = typeof body?.phonePub === "string" ? body.phonePub.trim().toLowerCase() : "";
15299
+ if (!/^[0-9a-f]{64}$/u.test(phonePub)) {
15300
+ return writeJson(res, 400, {
15301
+ error: "invalid-phone-pub",
15302
+ message: "phonePub must be a 64-char hex string",
15303
+ });
15304
+ }
15305
+ let beforeLen = 0;
15306
+ let afterLen = 0;
15307
+ let removedPairing = null;
15308
+ try {
15309
+ const before = await loadPairings();
15310
+ beforeLen = before.length;
15311
+ removedPairing = before.find((p) => p.phonePub === phonePub) || null;
15312
+ const after = await removePairingPersisted(phonePub);
15313
+ afterLen = after.length;
15314
+ } catch (err) {
15315
+ return writeJson(res, 500, { error: "revoke-failed", message: err.message });
15316
+ }
15317
+ const removed = beforeLen !== afterLen;
15318
+ if (removed && runtime.remotePairingHandle) {
15319
+ try {
15320
+ await runtime.remotePairingHandle.reloadNow();
15321
+ } catch (err) {
15322
+ console.warn(`[remote-pairing] reloadNow after revoke failed: ${err?.message}`);
15323
+ }
15324
+ }
15325
+ if (removed) {
15326
+ recordRemotePairingAudit({
15327
+ type: "pairing_revoked",
15328
+ outcome: "info",
15329
+ deviceId: session.deviceId || "",
15330
+ ...remotePairingAuditFieldsForPairing(removedPairing),
15331
+ });
15332
+ }
15333
+ return writeJson(res, 200, { ok: true, removed, remaining: afterLen });
15334
+ }
15335
+
15336
+ // POST /api/remote-pairing/rotate-token — rotate the relay capability
15337
+ // for the current LAN-reachable paired phone. This endpoint is also
15338
+ // denied inside the relay dispatcher, so an off-LAN phone cannot rotate
15339
+ // itself into a split-brain state where the bridge updated but the PWA
15340
+ // failed to persist the new token.
15341
+ if (url.pathname === "/api/remote-pairing/rotate-token" && req.method === "POST") {
15342
+ const session = requireMutatingApiSession(req, res, config, state);
15343
+ if (!session) return;
15344
+ let body;
15345
+ try {
15346
+ body = await parseJsonBody(req);
15347
+ } catch (err) {
15348
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15349
+ }
15350
+ const phonePub = typeof body?.phonePub === "string" ? body.phonePub.trim().toLowerCase() : "";
15351
+ if (!/^[0-9a-f]{64}$/u.test(phonePub)) {
15352
+ return writeJson(res, 400, {
15353
+ error: "invalid-phone-pub",
15354
+ message: "phonePub must be a 64-char hex string",
15355
+ });
15356
+ }
15357
+
15358
+ let current = [];
15359
+ try {
15360
+ current = await loadPairings();
15361
+ } catch (err) {
15362
+ return writeJson(res, 500, { error: "load-pairings-failed", message: err.message });
15363
+ }
15364
+ const existing = current.find((p) => p.phonePub === phonePub) || null;
15365
+ if (!existing) {
15366
+ return writeJson(res, 404, { error: "pairing-not-found" });
15367
+ }
15368
+ if (existing.deviceId && session.deviceId && existing.deviceId !== session.deviceId) {
15369
+ recordRemotePairingAudit({
15370
+ type: "token_rotate_denied",
15371
+ outcome: "failure",
15372
+ deviceId: session.deviceId || "",
15373
+ reason: "device mismatch",
15374
+ ...remotePairingAuditFieldsForPairing(existing),
15375
+ });
15376
+ return writeJson(res, 403, { error: "device-mismatch" });
15377
+ }
15378
+
15379
+ let rotated;
15380
+ try {
15381
+ ({ rotated } = await rotateRelayTokenPersisted(phonePub));
15382
+ } catch (err) {
15383
+ recordRemotePairingAudit({
15384
+ type: "token_rotate_failed",
15385
+ outcome: "failure",
15386
+ deviceId: session.deviceId || "",
15387
+ reason: err.message,
15388
+ ...remotePairingAuditFieldsForPairing(existing),
15389
+ });
15390
+ return writeJson(res, 500, { error: "rotate-token-failed", message: err.message });
15391
+ }
15392
+ if (!rotated) {
15393
+ return writeJson(res, 404, { error: "pairing-not-found" });
15394
+ }
15395
+
15396
+ if (runtime.remotePairingHandle) {
15397
+ try {
15398
+ await runtime.remotePairingHandle.reloadNow();
15399
+ } catch (err) {
15400
+ console.warn(`[remote-pairing] reloadNow after token rotation failed: ${err?.message}`);
15401
+ }
15402
+ }
15403
+
15404
+ let bridgeKeypair;
15405
+ try {
15406
+ bridgeKeypair = await ensureIdentityKeypair();
15407
+ } catch (err) {
15408
+ return writeJson(res, 500, {
15409
+ error: "bridge-keypair-failed",
15410
+ message: err.message,
15411
+ });
15412
+ }
15413
+ const relayUrl = (config.remotePairingRelayUrl || "").trim() || DEFAULT_RELAY_URL;
15414
+ recordRemotePairingAudit({
15415
+ type: "token_rotated",
15416
+ outcome: "success",
15417
+ deviceId: session.deviceId || "",
15418
+ relayHost: relayHostForAudit(relayUrl),
15419
+ ...remotePairingAuditFieldsForPairing(rotated),
15420
+ });
15421
+
15422
+ return writeJson(res, 200, {
15423
+ ok: true,
15424
+ pairingId: rotated.pairingId,
15425
+ relayToken: rotated.relayToken,
15426
+ phonePub: rotated.phonePub,
15427
+ phoneFingerprint: rotated.phoneFingerprint,
15428
+ deviceId: rotated.deviceId || null,
15429
+ bridgePubHex: bytesToHex(bridgeKeypair.pub),
15430
+ bridgeFingerprint: fingerprintIdentity(bridgeKeypair.pub),
15431
+ relayUrl,
15432
+ label: rotated.label,
15433
+ addedAtMs: rotated.addedAtMs,
15434
+ relayTokenUpdatedAtMs: rotated.relayTokenUpdatedAtMs ?? null,
15435
+ });
15436
+ }
15437
+
15438
+ // POST /api/remote-pairing/lan-enroll — finalize a LAN pairing by
15439
+ // exchanging X25519 static pubkeys so the same phone can later reach
15440
+ // the bridge via the relay (when off-LAN).
15441
+ //
15442
+ // Body: { phonePubHex: string (64-hex), label?: string (≤200 chars) }
15443
+ // Returns: { ok, pairingId, relayToken, phonePub, phoneFingerprint, bridgePubHex,
15444
+ // bridgeFingerprint, relayUrl, label, addedAtMs }
15445
+ //
15446
+ // Side effects:
15447
+ // - Adds (or refreshes) the phone in ~/.viveworker/remote-pairings.json.
15448
+ // Idempotent on phonePub: re-pairing the same device keeps the
15449
+ // existing pairingId so the relay slot doesn't churn (any in-flight
15450
+ // remote session for that phone stays valid).
15451
+ // - Triggers runtime.remotePairingHandle.reloadNow() so the
15452
+ // orchestrator spawns a session for the new phone immediately
15453
+ // when the relay is currently on. Safe no-op when the relay is off
15454
+ // — the next toggle-on will pick up the new entry from disk.
15455
+ // - Calls ensureIdentityKeypair(), which generates the bridge's
15456
+ // static keypair on first run. After this endpoint succeeds the
15457
+ // bridge always has a stable identity.
15458
+ //
15459
+ // Auth: requires an authenticated session. The natural caller is the
15460
+ // LAN-paired phone right after /api/session/pair succeeds — the
15461
+ // session cookie set by that endpoint is what gates this one.
15462
+ if (url.pathname === "/api/remote-pairing/lan-enroll" && req.method === "POST") {
15463
+ const session = requireMutatingApiSession(req, res, config, state);
15464
+ if (!session) return;
15465
+ let body;
15466
+ try {
15467
+ body = await parseJsonBody(req);
15468
+ } catch (err) {
15469
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
15470
+ }
15471
+ const phonePubHex = typeof body?.phonePubHex === "string"
15472
+ ? body.phonePubHex.trim().toLowerCase()
15473
+ : "";
15474
+ if (!/^[0-9a-f]{64}$/u.test(phonePubHex)) {
15475
+ return writeJson(res, 400, {
15476
+ error: "invalid-phone-pub",
15477
+ message: "phonePubHex must be a 64-char hex string",
15478
+ });
15479
+ }
15480
+ // Cap label at 200 chars — the persisted JSON has no hard limit but
15481
+ // long strings make the settings UI ugly and there's no legitimate
15482
+ // reason for a device label to exceed this.
15483
+ const label = typeof body?.label === "string"
15484
+ ? body.label.trim().slice(0, 200)
15485
+ : "";
15486
+
15487
+ // Idempotent re-enroll: if the same phone re-pairs (e.g. user
15488
+ // cleared site data on the phone, regenerated the keypair, then
15489
+ // happened to land on the same hex — vanishingly unlikely; far more
15490
+ // commonly: same keypair, fresh label), keep the original pairingId.
15491
+ let existingPairings = [];
15492
+ try {
15493
+ existingPairings = await loadPairings();
15494
+ } catch (err) {
15495
+ return writeJson(res, 500, {
15496
+ error: "load-pairings-failed",
15497
+ message: err.message,
15498
+ });
15499
+ }
15500
+ const existing = existingPairings.find((p) => p.phonePub === phonePubHex);
15501
+ const pairingId = existing?.pairingId || crypto.randomUUID();
15502
+ const autoRotateToken = shouldAutoRotateRemotePairingToken(existing);
15503
+ const relayToken = autoRotateToken ? undefined : existing?.relayToken || undefined;
15504
+ const relayTokenUpdatedAtMs = autoRotateToken ? Date.now() : existing?.relayTokenUpdatedAtMs;
15505
+
15506
+ // Bridge identity — generates on first call. We can't lean on
15507
+ // runtime.remotePairingHandle here because the relay may be OFF
15508
+ // (in which case the handle is dormant and identityKeypair is
15509
+ // null), but enrollment still has to expose a stable bridge pub
15510
+ // so the phone can store it.
15511
+ let bridgeKeypair;
15512
+ try {
15513
+ bridgeKeypair = await ensureIdentityKeypair();
15514
+ } catch (err) {
15515
+ return writeJson(res, 500, {
15516
+ error: "bridge-keypair-failed",
15517
+ message: err.message,
15518
+ });
15519
+ }
15520
+
15521
+ let entry;
15522
+ try {
15523
+ entry = buildPairing({
15524
+ pairingId,
15525
+ relayToken,
15526
+ relayTokenUpdatedAtMs,
15527
+ phonePub: phonePubHex,
15528
+ label,
15529
+ deviceId: session.deviceId,
15530
+ });
15531
+ } catch (err) {
15532
+ return writeJson(res, 400, {
15533
+ error: "build-pairing-failed",
15534
+ message: err.message,
15535
+ });
15536
+ }
15537
+
15538
+ try {
15539
+ await addPairingPersisted(entry);
15540
+ } catch (err) {
15541
+ return writeJson(res, 500, {
15542
+ error: "save-pairings-failed",
15543
+ message: err.message,
15544
+ });
15545
+ }
15546
+
15547
+ // Kick the orchestrator if it's running so the new pairing's
15548
+ // session spawns without waiting for the fs.watch debounce. When
15549
+ // the relay is off this is a no-op (dormant handle's reloadNow is
15550
+ // safe to call).
15551
+ if (runtime.remotePairingHandle) {
15552
+ try {
15553
+ await runtime.remotePairingHandle.reloadNow();
15554
+ } catch (err) {
15555
+ console.warn(`[remote-pairing] reloadNow after lan-enroll failed: ${err?.message}`);
15556
+ }
15557
+ }
15558
+ recordRemotePairingAudit({
15559
+ type: existing ? "pairing_refreshed" : "pairing_enrolled",
15560
+ outcome: "success",
15561
+ deviceId: session.deviceId || "",
15562
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15563
+ ...remotePairingAuditFieldsForPairing(entry),
15564
+ });
15565
+ if (existing && autoRotateToken) {
15566
+ recordRemotePairingAudit({
15567
+ type: "token_auto_rotated",
15568
+ outcome: "success",
15569
+ deviceId: session.deviceId || "",
15570
+ relayHost: relayHostForAudit(config.remotePairingRelayUrl || DEFAULT_RELAY_URL),
15571
+ reason: existing.relayTokenUpdatedAtMs ? "scheduled LAN refresh" : "legacy token metadata",
15572
+ ...remotePairingAuditFieldsForPairing(entry),
15573
+ });
15574
+ }
15575
+
15576
+ const bridgePubHex = bytesToHex(bridgeKeypair.pub);
15577
+ const bridgeFingerprint = fingerprintIdentity(bridgeKeypair.pub);
15578
+ // Relay URL: explicit config value > orchestrator default. We
15579
+ // surface the resolved URL (not the configured one) so the phone
15580
+ // doesn't have to know about the fallback chain.
15581
+ const relayUrl = (config.remotePairingRelayUrl || "").trim() || DEFAULT_RELAY_URL;
15582
+
15583
+ return writeJson(res, 200, {
15584
+ ok: true,
15585
+ pairingId: entry.pairingId,
15586
+ relayToken: entry.relayToken,
15587
+ phonePub: entry.phonePub,
15588
+ phoneFingerprint: entry.phoneFingerprint,
15589
+ deviceId: entry.deviceId || null,
15590
+ bridgePubHex,
15591
+ bridgeFingerprint,
15592
+ relayUrl,
15593
+ label: entry.label,
15594
+ addedAtMs: entry.addedAtMs,
15595
+ relayTokenUpdatedAtMs: entry.relayTokenUpdatedAtMs ?? null,
15596
+ relayTokenRotated: existing ? autoRotateToken : false,
15597
+ });
15598
+ }
15599
+
15600
+ // ─── Thread sharing ──────────────────────────────────────────────
15601
+
15602
+ function buildThreadShareContent(shareType, body) {
15603
+ if (shareType === "plan_review") {
15604
+ const sections = [];
15605
+ if (body.context) sections.push(`## Context\n${body.context}`);
15606
+ if (body.plan) sections.push(`## Plan\n${body.plan}`);
15607
+ if (Array.isArray(body.files) && body.files.length) {
15608
+ sections.push(`## Relevant files\n${body.files.map((f) => `- ${f}`).join("\n")}`);
15609
+ }
15610
+ sections.push(`## Instruction\n${body.instruction || "共有されたプランの内容を把握し、実現可能性や改善点を検討してください。"}`);
14468
15611
  return sections.join("\n\n");
14469
15612
  }
14470
15613
  if (shareType === "handoff") {
@@ -15028,9 +16171,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15028
16171
  const result = await completeHazbaseWalletPaymentApproval({ config, runtime, state, approval });
15029
16172
  return writeJson(res, 200, result);
15030
16173
  } catch (error) {
15031
- approval.resolving = false;
16174
+ const failure = await finalizeHazbaseWalletPaymentFailure({ config, runtime, state, approval, error });
15032
16175
  console.error(`[hazbase-wallet-payment-error] ${approval.requestKey} | ${error?.stack || error?.message || error}`);
15033
- return writeJson(res, Number(error?.statusCode) || 500, { error: error?.code || error?.message || "hazbase-wallet-payment-failed" });
16176
+ return writeJson(res, Number(error?.statusCode) || 500, {
16177
+ error: failure.error,
16178
+ approvalFinalized: true,
16179
+ retryable: true,
16180
+ });
15034
16181
  }
15035
16182
  }
15036
16183
 
@@ -15799,18 +16946,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15799
16946
 
15800
16947
  const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
15801
16948
  if (apiTimelineImageMatch && req.method === "GET") {
15802
- const session = requireApiSession(req, res, config, state);
15803
- if (!session) {
15804
- return;
15805
- }
15806
16949
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
15807
16950
  const index = Number(apiTimelineImageMatch[2]) || 0;
15808
- const filePath = resolveTimelineEntryImagePath(runtime, token, index);
16951
+ const filePath = resolveTimelineEntryImagePath(runtime, state, token, index);
15809
16952
  if (!filePath) {
15810
16953
  res.statusCode = 404;
15811
16954
  res.end("not-found");
15812
16955
  return;
15813
16956
  }
16957
+ if (!isValidTimelineEntryImageSignature(config, url, token, index, filePath)) {
16958
+ const session = requireApiSession(req, res, config, state);
16959
+ if (!session) {
16960
+ return;
16961
+ }
16962
+ }
15814
16963
  try {
15815
16964
  const body = await fs.readFile(filePath);
15816
16965
  res.statusCode = 200;
@@ -15922,7 +17071,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15922
17071
  if (approval.resolved || approval.resolving) {
15923
17072
  return writeJson(res, 409, { error: "approval-already-handled" });
15924
17073
  }
15925
- if (approval.kind === "hazbase_wallet_payment") {
17074
+ if (approval.kind === "hazbase_wallet_payment" && decision !== "decline") {
15926
17075
  console.warn(`[hazbase-wallet-payment-generic-decision-blocked] ${approval.requestKey} | ${decision}`);
15927
17076
  return writeJson(res, 409, { error: "hazbase-wallet-payment-requires-wallet-pay-route" });
15928
17077
  }
@@ -15936,6 +17085,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15936
17085
  return writeJson(res, 200, { ok: true, decision });
15937
17086
  } catch (error) {
15938
17087
  approval.resolving = false;
17088
+ if (res.headersSent || res.writableEnded) {
17089
+ console.error(`[approval-decision-post-write-error] ${approval.requestKey} | ${error.message}`);
17090
+ return;
17091
+ }
15939
17092
  return writeJson(res, 500, { error: error.message });
15940
17093
  }
15941
17094
  }
@@ -16520,7 +17673,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16520
17673
  if (approval.resolved || approval.resolving) {
16521
17674
  return writeApprovalHandled(res, req, approval.title, 409, config.defaultLocale);
16522
17675
  }
16523
- if (approval.kind === "hazbase_wallet_payment") {
17676
+ if (approval.kind === "hazbase_wallet_payment" && decision !== "decline") {
16524
17677
  console.warn(`[hazbase-wallet-payment-native-decision-blocked] ${approval.requestKey} | ${decision}`);
16525
17678
  if (requestWantsHtml(req)) {
16526
17679
  return writeHtml(
@@ -16559,6 +17712,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16559
17712
  });
16560
17713
  } catch (error) {
16561
17714
  approval.resolving = false;
17715
+ if (res.headersSent || res.writableEnded) {
17716
+ console.error(`[native-approval-decision-post-write-error] ${approval.requestKey} | ${error.message}`);
17717
+ return;
17718
+ }
16562
17719
  if (requestWantsHtml(req)) {
16563
17720
  return writeHtml(
16564
17721
  res,
@@ -16573,6 +17730,9 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16573
17730
  return writeJson(res, 500, { error: error.message });
16574
17731
  }
16575
17732
  } catch (error) {
17733
+ if (res.headersSent || res.writableEnded) {
17734
+ return;
17735
+ }
16576
17736
  if (requestWantsHtml(req)) {
16577
17737
  return writeHtml(
16578
17738
  res,
@@ -16588,14 +17748,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16588
17748
  }
16589
17749
  };
16590
17750
 
17751
+ let server;
16591
17752
  if (config.webPushEnabled) {
16592
- return createHttpsServer({
17753
+ server = createHttpsServer({
16593
17754
  cert: readFileSync(config.tlsCertFile, "utf8"),
16594
17755
  key: readFileSync(config.tlsKeyFile, "utf8"),
16595
17756
  }, requestHandler);
17757
+ } else {
17758
+ server = createHttpServer(requestHandler);
16596
17759
  }
16597
-
16598
- return createHttpServer(requestHandler);
17760
+ // Expose the handler on the server so the remote-pairing relay client can
17761
+ // reuse it: RPC frames arriving over the encrypted relay channel get
17762
+ // wrapped in synthetic req/res objects and dispatched through the SAME
17763
+ // listener LAN HTTP requests use, so authn/authz/cookies/CSRF behave
17764
+ // identically off-LAN.
17765
+ server.requestHandler = requestHandler;
17766
+ return server;
16599
17767
  }
16600
17768
 
16601
17769
  function requestWantsHtml(req) {
@@ -17899,6 +19067,7 @@ function buildConfig(cli) {
17899
19067
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
17900
19068
  const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
17901
19069
  return {
19070
+ envFile: resolveEnvFile(cli.envFile),
17902
19071
  dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
17903
19072
  once: cli.once,
17904
19073
  codexHome,
@@ -17913,6 +19082,12 @@ function buildConfig(cli) {
17913
19082
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
17914
19083
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
17915
19084
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
19085
+ // Remote-pairing relay (PWA off-LAN). Off by default — bridges that
19086
+ // never leave the trusted LAN don't need to open an outbound connection
19087
+ // to the relay. Toggle with REMOTE_PAIRING_ENABLED=true in
19088
+ // ~/.viveworker/remote-pairing.env (or any source loadEnvFile reads).
19089
+ remotePairingEnabled: boolEnv("REMOTE_PAIRING_ENABLED", false),
19090
+ remotePairingRelayUrl: remotePairingRelayUrlFromEnv(),
17916
19091
  // share.viveworker.com — HTML hosting backed by the same A2A credentials.
17917
19092
  // Override for staging / self-hosted deployments via VIVEWORKER_SHARE_URL.
17918
19093
  a2aShareUrl: stripTrailingSlash(
@@ -17969,6 +19144,12 @@ function buildConfig(cli) {
17969
19144
  maxCodeEvents: numberEnv("MAX_CODE_EVENTS", 1000),
17970
19145
  maxTimelineThreads: numberEnv("MAX_TIMELINE_THREADS", 20),
17971
19146
  maxReadBytes: numberEnv("MAX_READ_BYTES", 2 * 1024 * 1024),
19147
+ // Cap how far back the bridge looks for Claude transcripts. Default 7 days
19148
+ // covers a long pause without dragging the long tail of dead sessions
19149
+ // (which can easily reach thousands of files) into the per-iteration
19150
+ // stat loop. Set to 0 to disable filtering and scan everything (the
19151
+ // legacy behavior).
19152
+ claudeTranscriptMaxAgeMs: numberEnv("CLAUDE_TRANSCRIPT_MAX_AGE_DAYS", 7) * 24 * 60 * 60 * 1000,
17972
19153
  maxMessageChars: numberEnv("MAX_MESSAGE_CHARS", 320),
17973
19154
  maxCommandChars: numberEnv("MAX_COMMAND_CHARS", 220),
17974
19155
  maxJustificationChars: numberEnv("MAX_JUSTIFICATION_CHARS", 220),
@@ -18012,18 +19193,31 @@ function buildConfig(cli) {
18012
19193
  }
18013
19194
 
18014
19195
 
19196
+ function inferHazbaseDeviceBindingIdFromAccounts(accounts) {
19197
+ const list = Array.isArray(accounts) ? accounts : [];
19198
+ for (const entry of list) {
19199
+ const deviceBindingId = cleanText(
19200
+ entry?.primaryDeviceBindingId || entry?.deviceBindingId || entry?.ownerDeviceBindingId || ""
19201
+ );
19202
+ if (deviceBindingId) return deviceBindingId;
19203
+ }
19204
+ return "";
19205
+ }
19206
+
18015
19207
  function normalizeHazbaseState(raw) {
18016
19208
  const value = raw && typeof raw === "object" ? raw : {};
19209
+ const accounts = Array.isArray(value.accounts) ? value.accounts : [];
19210
+ const deviceBindingId = cleanText(value.deviceBindingId ?? "") || inferHazbaseDeviceBindingIdFromAccounts(accounts);
18017
19211
  return {
18018
19212
  email: cleanText(value.email ?? ""),
18019
19213
  accessToken: cleanText(value.accessToken ?? ""),
18020
19214
  sessionId: cleanText(value.sessionId ?? ""),
18021
19215
  userId: cleanText(value.userId ?? ""),
18022
- deviceBindingId: cleanText(value.deviceBindingId ?? ""),
19216
+ deviceBindingId,
18023
19217
  credentialId: cleanText(value.credentialId ?? ""),
18024
19218
  highTrustToken: cleanText(value.highTrustToken ?? ""),
18025
19219
  highTrustExpiresAt: cleanText(value.highTrustExpiresAt ?? ""),
18026
- accounts: Array.isArray(value.accounts) ? value.accounts : [],
19220
+ accounts,
18027
19221
  sessionInvalid: value.sessionInvalid === true,
18028
19222
  sessionInvalidReason: cleanText(value.sessionInvalidReason ?? ""),
18029
19223
  sessionInvalidAt: cleanText(value.sessionInvalidAt ?? ""),
@@ -18032,19 +19226,30 @@ function normalizeHazbaseState(raw) {
18032
19226
 
18033
19227
  function isHazbaseInvalidAppSessionError(error) {
18034
19228
  const details = error?.details && typeof error.details === "object" ? error.details : {};
18035
- const statusCode = Number(error?.statusCode || error?.status || details.statusCode || 0);
18036
- const text = [
19229
+ const rawText = [
18037
19230
  error?.code,
18038
19231
  error?.message,
18039
19232
  details.errorCode,
18040
19233
  details.code,
18041
19234
  details.error,
18042
19235
  details.message,
19236
+ typeof error?.details === "string" ? error.details : "",
19237
+ ]
19238
+ .map((entry) => cleanText(entry || ""))
19239
+ .filter(Boolean)
19240
+ .join(" ");
19241
+ const statusCode = Number(error?.statusCode || error?.status || details.statusCode || 0) ||
19242
+ (/statusCode["']?\s*[:=]\s*401/u.test(rawText) || /\b401\b/u.test(rawText) ? 401 : 0);
19243
+ const text = [
19244
+ rawText,
18043
19245
  ]
18044
19246
  .map((entry) => cleanText(entry || "").toLowerCase())
18045
19247
  .filter(Boolean)
18046
19248
  .join(" ");
18047
- return statusCode === 401 && text.includes("invalid app session");
19249
+ return statusCode === 401 && (
19250
+ text.includes("invalid app session") ||
19251
+ text.includes("invalid_app_session")
19252
+ );
18048
19253
  }
18049
19254
 
18050
19255
  function markHazbaseSessionInvalid(state, reason = "invalid_app_session") {
@@ -18274,6 +19479,51 @@ function resolveHazbaseAccountForChain(accounts, chainId) {
18274
19479
  }
18275
19480
 
18276
19481
 
19482
+ async function finalizeHazbaseWalletPaymentFailure({ config, runtime, state, approval, error }) {
19483
+ const errorCode = cleanText(error?.code || error?.message || "hazbase-wallet-payment-failed") || "hazbase-wallet-payment-failed";
19484
+ if (approval.resolved) {
19485
+ return { paid: false, decision: "failed", error: errorCode };
19486
+ }
19487
+
19488
+ const payload = {
19489
+ paid: false,
19490
+ decision: "failed",
19491
+ error: errorCode,
19492
+ paymentRequestId: approval.paymentRequestId || approval.requestId || "",
19493
+ };
19494
+
19495
+ approval.resolved = true;
19496
+ approval.resolving = false;
19497
+ runtime.nativeApprovalsByToken.delete(approval.token);
19498
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
19499
+ approval.resolveClaudeWaiter?.(payload);
19500
+
19501
+ const statusMessage = t(config.defaultLocale, "server.message.paymentFailed", { reason: errorCode });
19502
+ const stateChanged = recordActionHistoryItem({
19503
+ config,
19504
+ runtime,
19505
+ state,
19506
+ kind: "approval",
19507
+ stableId: `approval:${approval.requestKey}:${Date.now()}`,
19508
+ token: approval.token,
19509
+ title: approval.title,
19510
+ threadLabel: approval.threadLabel || "",
19511
+ messageText: `${statusMessage}\n\n${approval.messageText}`,
19512
+ summary: statusMessage,
19513
+ fileRefs: [],
19514
+ diffText: "",
19515
+ diffSource: "",
19516
+ diffAvailable: false,
19517
+ diffAddedLines: 0,
19518
+ diffRemovedLines: 0,
19519
+ outcome: "failed",
19520
+ provider: approval.provider || "viveworker",
19521
+ });
19522
+ if (stateChanged) await saveState(config.stateFile, state);
19523
+ console.log(`[hazbase-wallet-payment-failed] ${approval.requestKey} | ${errorCode}`);
19524
+ return payload;
19525
+ }
19526
+
18277
19527
  async function completeHazbaseWalletPaymentApproval({ config, runtime, state, approval }) {
18278
19528
  const hazbase = normalizeHazbaseState(state.hazbase);
18279
19529
  if (!hazbase.accessToken) throw codedError("hazbase-auth-required", 401);
@@ -18476,32 +19726,70 @@ function resolveEnvFile(explicitPath) {
18476
19726
  return path.join(workspaceRoot, "viveworker.env");
18477
19727
  }
18478
19728
 
18479
- function loadEnvFile(filePath) {
18480
- try {
18481
- const text = readFileSync(filePath, "utf8");
18482
- for (const rawLine of text.split(/\r?\n/u)) {
18483
- const line = rawLine.trim();
18484
- if (!line || line.startsWith("#")) {
18485
- continue;
18486
- }
19729
+ function parseEnvText(text) {
19730
+ const output = {};
19731
+ for (const rawLine of String(text || "").split(/\r?\n/u)) {
19732
+ const line = rawLine.trim();
19733
+ if (!line || line.startsWith("#")) {
19734
+ continue;
19735
+ }
18487
19736
 
18488
- const separator = line.indexOf("=");
18489
- if (separator === -1) {
18490
- continue;
18491
- }
19737
+ const separator = line.indexOf("=");
19738
+ if (separator === -1) {
19739
+ continue;
19740
+ }
18492
19741
 
18493
- const key = line.slice(0, separator).trim();
18494
- let value = line.slice(separator + 1).trim();
18495
- if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
18496
- value = value.slice(1, -1);
18497
- }
18498
- process.env[key] = value;
19742
+ const key = line.slice(0, separator).trim();
19743
+ let value = line.slice(separator + 1).trim();
19744
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
19745
+ value = value.slice(1, -1);
18499
19746
  }
19747
+ output[key] = value;
19748
+ }
19749
+ return output;
19750
+ }
19751
+
19752
+ function loadEnvFile(filePath) {
19753
+ try {
19754
+ Object.assign(process.env, parseEnvText(readFileSync(filePath, "utf8")));
18500
19755
  } catch {
18501
19756
  // Optional env file.
18502
19757
  }
18503
19758
  }
18504
19759
 
19760
+ function refreshPairingConfigFromEnvFile(config) {
19761
+ if (!config?.envFile) {
19762
+ return false;
19763
+ }
19764
+
19765
+ let entries;
19766
+ try {
19767
+ entries = parseEnvText(readFileSync(config.envFile, "utf8"));
19768
+ } catch {
19769
+ return false;
19770
+ }
19771
+
19772
+ const nextCode = entries.PAIRING_CODE ?? "";
19773
+ const nextToken = entries.PAIRING_TOKEN ?? "";
19774
+ const nextExpiresAtMs = Number(entries.PAIRING_EXPIRES_AT_MS) || 0;
19775
+ if (
19776
+ config.pairingCode === nextCode &&
19777
+ config.pairingToken === nextToken &&
19778
+ Number(config.pairingExpiresAtMs) === nextExpiresAtMs
19779
+ ) {
19780
+ return false;
19781
+ }
19782
+
19783
+ config.pairingCode = nextCode;
19784
+ config.pairingToken = nextToken;
19785
+ config.pairingExpiresAtMs = nextExpiresAtMs;
19786
+ process.env.PAIRING_CODE = nextCode;
19787
+ process.env.PAIRING_TOKEN = nextToken;
19788
+ process.env.PAIRING_EXPIRES_AT_MS = String(nextExpiresAtMs);
19789
+ console.log(`[pairing] refreshed credentials from ${config.envFile}`);
19790
+ return true;
19791
+ }
19792
+
18505
19793
  async function maybeRotateStartupPairingEnv(envFile) {
18506
19794
  if (!truthy(process.env.AUTH_REQUIRED)) {
18507
19795
  return;
@@ -19183,8 +20471,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
19183
20471
  );
19184
20472
 
19185
20473
  if (
19186
- JSON.stringify(nextHistoryItems.map((item) => [item.stableId, item.title, item.threadLabel])) !==
19187
- JSON.stringify(runtime.recentHistoryItems.map((item) => [item.stableId, item.title, item.threadLabel]))
20474
+ timelineProjectionChanged(nextHistoryItems, runtime.recentHistoryItems, [
20475
+ "stableId",
20476
+ "title",
20477
+ "threadLabel",
20478
+ ])
19188
20479
  ) {
19189
20480
  runtime.recentHistoryItems = nextHistoryItems;
19190
20481
  state.recentHistoryItems = nextHistoryItems;
@@ -19225,8 +20516,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
19225
20516
  );
19226
20517
 
19227
20518
  if (
19228
- JSON.stringify(nextTimelineEntries.map((entry) => [entry.stableId, entry.title, entry.threadLabel])) !==
19229
- JSON.stringify(runtime.recentTimelineEntries.map((entry) => [entry.stableId, entry.title, entry.threadLabel]))
20519
+ timelineProjectionChanged(nextTimelineEntries, runtime.recentTimelineEntries, [
20520
+ "stableId",
20521
+ "title",
20522
+ "threadLabel",
20523
+ ])
19230
20524
  ) {
19231
20525
  runtime.recentTimelineEntries = nextTimelineEntries;
19232
20526
  state.recentTimelineEntries = nextTimelineEntries;
@@ -19261,8 +20555,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
19261
20555
  );
19262
20556
 
19263
20557
  if (
19264
- JSON.stringify(nextCodeEvents.map((entry) => [entry.stableId, entry.title, entry.threadLabel])) !==
19265
- JSON.stringify(runtime.recentCodeEvents.map((entry) => [entry.stableId, entry.title, entry.threadLabel]))
20558
+ timelineProjectionChanged(nextCodeEvents, runtime.recentCodeEvents, [
20559
+ "stableId",
20560
+ "title",
20561
+ "threadLabel",
20562
+ ])
19266
20563
  ) {
19267
20564
  runtime.recentCodeEvents = nextCodeEvents;
19268
20565
  state.recentCodeEvents = nextCodeEvents;
@@ -19348,21 +20645,68 @@ function isInlineImagePlaceholderText(value) {
19348
20645
  return cleanText(stripInlineImagePlaceholderMarkup(value)) === "" && /<\/?image\b/iu.test(String(value || ""));
19349
20646
  }
19350
20647
 
20648
+ function stripCodexUserAttachmentEnvelope(value) {
20649
+ const source = String(value || "");
20650
+ if (!/Files mentioned by the user:/iu.test(source) || !/My request for Codex:/iu.test(source)) {
20651
+ return source;
20652
+ }
20653
+
20654
+ const marker = source.match(/\bMy request for Codex:\s*/iu);
20655
+ if (!marker || marker.index == null) {
20656
+ return source;
20657
+ }
20658
+ return source.slice(marker.index + marker[0].length).trim();
20659
+ }
20660
+
19351
20661
  function normalizeTimelineMessageText(value, locale = DEFAULT_LOCALE) {
19352
- return normalizeLongText(replaceTurnAbortedMarkup(stripInlineImagePlaceholderMarkup(value), locale));
20662
+ return normalizeLongText(
20663
+ replaceTurnAbortedMarkup(
20664
+ stripCodexUserAttachmentEnvelope(stripInlineImagePlaceholderMarkup(value)),
20665
+ locale
20666
+ )
20667
+ );
20668
+ }
20669
+
20670
+ function extractTimelineImagePaths(value) {
20671
+ const source = String(value || "");
20672
+ if (!/Files mentioned by the user:/iu.test(source)) {
20673
+ return [];
20674
+ }
20675
+
20676
+ const paths = [];
20677
+ for (const match of source.matchAll(/(\/Users\/[^\n\r]+?\.(?:png|jpe?g|webp|gif|heic|heif))(?:\s|$)/giu)) {
20678
+ const filePath = cleanText(match[1] || "");
20679
+ if (filePath) {
20680
+ paths.push(filePath);
20681
+ }
20682
+ }
20683
+ return normalizeTimelineImagePaths(paths);
19353
20684
  }
19354
20685
 
19355
20686
  function normalizeTimelineImagePaths(value) {
19356
20687
  if (!Array.isArray(value)) {
19357
20688
  return [];
19358
20689
  }
19359
- return value
19360
- .map((entry) => cleanText(entry || ""))
19361
- .filter(Boolean);
20690
+ const output = [];
20691
+ const seen = new Set();
20692
+ for (const entry of value) {
20693
+ const normalized = cleanText(entry || "");
20694
+ if (!normalized || seen.has(normalized)) {
20695
+ continue;
20696
+ }
20697
+ seen.add(normalized);
20698
+ output.push(normalized);
20699
+ }
20700
+ return output;
19362
20701
  }
19363
20702
 
19364
20703
  function normalizeNotificationText(value, locale = DEFAULT_LOCALE) {
19365
- return normalizeLongText(replaceTurnAbortedMarkup(stripNotificationMarkup(value), locale))
20704
+ return normalizeLongText(
20705
+ replaceTurnAbortedMarkup(
20706
+ stripCodexUserAttachmentEnvelope(stripNotificationMarkup(value)),
20707
+ locale
20708
+ )
20709
+ )
19366
20710
  .replace(/\n{2,}/gu, "\n")
19367
20711
  .trim();
19368
20712
  }
@@ -19423,6 +20767,65 @@ function cleanText(value) {
19423
20767
  return String(value || "").replace(/\s+/gu, " ").trim();
19424
20768
  }
19425
20769
 
20770
+ function normalizeRemotePairingRelayUrl(value) {
20771
+ const trimmed = cleanText(value);
20772
+ if (!trimmed) {
20773
+ return { ok: true, value: "" };
20774
+ }
20775
+
20776
+ let parsed;
20777
+ try {
20778
+ parsed = new URL(trimmed);
20779
+ } catch {
20780
+ return {
20781
+ ok: false,
20782
+ message: "relayUrl must be a valid URL",
20783
+ };
20784
+ }
20785
+
20786
+ if (parsed.username || parsed.password) {
20787
+ return {
20788
+ ok: false,
20789
+ message: "relayUrl must not include credentials",
20790
+ };
20791
+ }
20792
+
20793
+ if (parsed.protocol === "wss:") {
20794
+ return { ok: true, value: trimmed };
20795
+ }
20796
+
20797
+ if (parsed.protocol === "ws:" && isLoopbackRelayHost(parsed.hostname)) {
20798
+ return { ok: true, value: trimmed };
20799
+ }
20800
+
20801
+ return {
20802
+ ok: false,
20803
+ message: "relayUrl must use wss://; ws:// is allowed only for localhost or loopback development",
20804
+ };
20805
+ }
20806
+
20807
+ function isLoopbackRelayHost(hostname) {
20808
+ const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/gu, "");
20809
+ return (
20810
+ host === "localhost" ||
20811
+ host.endsWith(".localhost") ||
20812
+ host === "::1" ||
20813
+ /^127(?:\.\d{1,3}){3}$/u.test(host)
20814
+ );
20815
+ }
20816
+
20817
+ function remotePairingRelayUrlFromEnv() {
20818
+ const raw = process.env.REMOTE_PAIRING_RELAY_URL || "";
20819
+ const result = normalizeRemotePairingRelayUrl(raw);
20820
+ if (result.ok) {
20821
+ return result.value;
20822
+ }
20823
+ if (cleanText(raw)) {
20824
+ console.warn(`[remote-pairing] ignoring REMOTE_PAIRING_RELAY_URL: ${result.message}`);
20825
+ }
20826
+ return "";
20827
+ }
20828
+
19426
20829
  function extractTitleOnlyJsonTitle(value) {
19427
20830
  const rawText = String(value ?? "").trim();
19428
20831
  if (!rawText) {
@@ -19697,6 +21100,37 @@ async function main() {
19697
21100
  process.on("SIGINT", handleSignal);
19698
21101
  process.on("SIGTERM", handleSignal);
19699
21102
 
21103
+ // Belt-and-suspenders: a stray ERR_HTTP_HEADERS_SENT inside an async
21104
+ // request handler used to escape as an UnhandledPromiseRejection and crash
21105
+ // the process under launchd, triggering a restart loop that then re-ran all
21106
+ // the startup backfills (state.json parse, rollout/transcript replay) and
21107
+ // burned CPU + memory for ~30s per cycle. The targeted catch-handler guards
21108
+ // around handleNativeApprovalDecision cover the known sites; this top-level
21109
+ // filter swallows the same class of error from any new site we haven't
21110
+ // audited yet so the process keeps serving instead of dying.
21111
+ const isBenignHttpError = (err) =>
21112
+ err && typeof err === "object" && (
21113
+ err.code === "ERR_HTTP_HEADERS_SENT" ||
21114
+ err.code === "ERR_STREAM_WRITE_AFTER_END" ||
21115
+ err.code === "ERR_STREAM_DESTROYED" ||
21116
+ err.code === "ECONNRESET" ||
21117
+ err.code === "EPIPE"
21118
+ );
21119
+ process.on("uncaughtException", (err) => {
21120
+ if (isBenignHttpError(err)) {
21121
+ console.warn(`[uncaught-http] ${err.code} ${err.message}`);
21122
+ return;
21123
+ }
21124
+ console.error(`[uncaught] ${err?.stack || err}`);
21125
+ });
21126
+ process.on("unhandledRejection", (reason) => {
21127
+ if (isBenignHttpError(reason)) {
21128
+ console.warn(`[unhandled-http] ${reason.code} ${reason.message}`);
21129
+ return;
21130
+ }
21131
+ console.error(`[unhandled] ${reason?.stack || reason}`);
21132
+ });
21133
+
19700
21134
  try {
19701
21135
  if (config.webPushEnabled) {
19702
21136
  webPush.setVapidDetails(
@@ -19816,6 +21250,38 @@ async function main() {
19816
21250
  }
19817
21251
  }
19818
21252
 
21253
+ // --- Remote-pairing relay (PWA off-LAN) ---
21254
+ // Optional. When enabled, the bridge keeps an outbound WebSocket open per
21255
+ // paired phone (`~/.viveworker/remote-pairings.json`) to the remote-pairing
21256
+ // relay (Cloudflare Worker + Durable Object). RPC frames arriving over
21257
+ // those channels are dispatched through the same `requestHandler` LAN HTTP
21258
+ // uses, so cookies / auth / CSRF behave identically off-LAN.
21259
+ //
21260
+ // If disabled (default), the orchestrator returns a dormant handle and
21261
+ // does no I/O. Pairings file changes are auto-reloaded via fs.watch — no
21262
+ // outer loop needed here.
21263
+ if (approvalServer && approvalServer.requestHandler) {
21264
+ try {
21265
+ runtime.remotePairingHandle = await startRemotePairingRelay({
21266
+ enabled: config.remotePairingEnabled,
21267
+ relayUrl: config.remotePairingRelayUrl || undefined,
21268
+ requestListener: approvalServer.requestHandler,
21269
+ logger: console,
21270
+ auditEventSink: recordRemotePairingAudit,
21271
+ });
21272
+ const status = runtime.remotePairingHandle.getStatus();
21273
+ if (status.enabled) {
21274
+ console.log(
21275
+ `[remote-pairing] connected relay=${status.relayUrl} ` +
21276
+ `identity=${status.identityFingerprint} ` +
21277
+ `paired=${status.sessions.length}`
21278
+ );
21279
+ }
21280
+ } catch (err) {
21281
+ console.error(`[remote-pairing] startup failed: ${err?.message}`);
21282
+ }
21283
+ }
21284
+
19819
21285
  let lastA2aEnvCheckAt = 0;
19820
21286
  const A2A_ENV_CHECK_INTERVAL_MS = 30_000; // check every 30 seconds
19821
21287
 
@@ -19902,6 +21368,13 @@ async function main() {
19902
21368
 
19903
21369
  stopRelayPolling();
19904
21370
 
21371
+ if (runtime.remotePairingHandle) {
21372
+ try {
21373
+ runtime.remotePairingHandle.close();
21374
+ } catch {}
21375
+ runtime.remotePairingHandle = null;
21376
+ }
21377
+
19905
21378
  if (runtime.ipcClient) {
19906
21379
  runtime.ipcClient.stop();
19907
21380
  }