viveworker 0.7.0-beta.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.7.0-beta.3",
3
+ "version": "0.7.0",
4
4
  "description": "Open mobile control surface for Codex, Claude, Thread Sharing, File Share, Moltbook, and A2A tasks on your trusted LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -680,7 +680,7 @@ function withNotificationIcon(kind, title) {
680
680
 
681
681
  function normalizeTimelineOutcome(value) {
682
682
  const normalized = cleanText(value || "").toLowerCase();
683
- return ["pending", "approved", "rejected", "implemented", "dismissed", "submitted"].includes(normalized)
683
+ return ["pending", "approved", "rejected", "failed", "implemented", "dismissed", "submitted"].includes(normalized)
684
684
  ? normalized
685
685
  : "";
686
686
  }
@@ -2993,6 +2993,70 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2993
2993
  return deduped;
2994
2994
  }
2995
2995
 
2996
+ // Cheap structural diff used by record* helpers to decide whether a rebuilt
2997
+ // timeline / history / code-events array actually changed in a way worth
2998
+ // persisting. The previous implementation `JSON.stringify(a) !== JSON.stringify(b)`
2999
+ // allocated several megabytes of intermediate strings on every call when the
3000
+ // projection included `diffText` (5+ KB per file_event × ~250 entries × 2
3001
+ // sides), which dominated scavenger GC time in busy sessions and showed up as
3002
+ // a 25% CPU sink in `JsonStringifier::Serialize_*` under V8 sampling. Walking
3003
+ // the arrays in lockstep and bailing on the first mismatched primitive lets
3004
+ // the common "fresh entry prepended" case exit on the very first stableId
3005
+ // compare with zero allocations.
3006
+ //
3007
+ // Field semantics:
3008
+ // - String / number / boolean fields use value-equality via `===` (works
3009
+ // across object reconstruction by `normalizeTimelineEntry`).
3010
+ // - `Array` fields (e.g. `previousFileRefs`) shallow-compare lengths and the
3011
+ // `path` of each element rather than serializing.
3012
+ // - `null` / `undefined` are treated as equal to each other but distinct from
3013
+ // any defined value.
3014
+ function timelineProjectionChanged(nextItems, previousItems, fieldKeys) {
3015
+ const a = Array.isArray(nextItems) ? nextItems : [];
3016
+ const b = Array.isArray(previousItems) ? previousItems : [];
3017
+ if (a.length !== b.length) {
3018
+ return true;
3019
+ }
3020
+ for (let i = 0; i < a.length; i++) {
3021
+ const ai = a[i];
3022
+ const bi = b[i];
3023
+ if (ai === bi) continue;
3024
+ if (!ai || !bi) return true;
3025
+ for (let k = 0; k < fieldKeys.length; k++) {
3026
+ const key = fieldKeys[k];
3027
+ const av = ai[key];
3028
+ const bv = bi[key];
3029
+ if (av === bv) continue;
3030
+ if (av == null && bv == null) continue;
3031
+ if (Array.isArray(av) && Array.isArray(bv)) {
3032
+ if (av.length !== bv.length) return true;
3033
+ let arrayDiffers = false;
3034
+ for (let j = 0; j < av.length; j++) {
3035
+ const aj = av[j];
3036
+ const bj = bv[j];
3037
+ if (aj === bj) continue;
3038
+ if (!aj || !bj) {
3039
+ arrayDiffers = true;
3040
+ break;
3041
+ }
3042
+ // Most array-of-object fields here are fileRef-shaped: identity is
3043
+ // captured by `path`. Falling back to JSON for unrecognised shapes
3044
+ // is unnecessary — a path mismatch (or a null path either side) is
3045
+ // sufficient to mark the projection changed.
3046
+ if (aj.path !== bj.path) {
3047
+ arrayDiffers = true;
3048
+ break;
3049
+ }
3050
+ }
3051
+ if (arrayDiffers) return true;
3052
+ continue;
3053
+ }
3054
+ return true;
3055
+ }
3056
+ }
3057
+ return false;
3058
+ }
3059
+
2996
3060
  function isCodeEventEntry(raw) {
2997
3061
  if (!isPlainObject(raw)) {
2998
3062
  return false;
@@ -3158,35 +3222,21 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
3158
3222
  [normalized, ...runtime.recentTimelineEntries.filter((item) => item.stableId !== normalized.stableId)],
3159
3223
  config.maxTimelineEntries
3160
3224
  );
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
- );
3225
+ // Note: diffText is intentionally omitted from the projection — the
3226
+ // line-count fields (`diffAddedLines` / `diffRemovedLines`) are a sufficient
3227
+ // proxy for "diff content changed" and avoid materialising a megabyte-class
3228
+ // intermediate string on every record call.
3229
+ const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
3230
+ "stableId",
3231
+ "title",
3232
+ "createdAtMs",
3233
+ "diffAvailable",
3234
+ "diffSource",
3235
+ "diffAddedLines",
3236
+ "diffRemovedLines",
3237
+ "previousFileRefs",
3238
+ "cwd",
3239
+ ]);
3190
3240
  runtime.recentTimelineEntries = nextItems;
3191
3241
  state.recentTimelineEntries = nextItems;
3192
3242
  return changed;
@@ -3205,35 +3255,19 @@ function recordCodeEvent({ config, runtime, state, entry }) {
3205
3255
  [normalized, ...runtime.recentCodeEvents.filter((item) => item.stableId !== normalized.stableId)],
3206
3256
  config.maxCodeEvents
3207
3257
  );
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
- );
3258
+ // See `recordTimelineEntry` for the rationale on dropping `diffText` from
3259
+ // the projection — same megabyte-stringify hot path; same line-count proxy.
3260
+ const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3261
+ "stableId",
3262
+ "title",
3263
+ "createdAtMs",
3264
+ "diffAvailable",
3265
+ "diffSource",
3266
+ "diffAddedLines",
3267
+ "diffRemovedLines",
3268
+ "previousFileRefs",
3269
+ "cwd",
3270
+ ]);
3237
3271
  runtime.recentCodeEvents = nextItems;
3238
3272
  state.recentCodeEvents = nextItems;
3239
3273
  if (changed) {
@@ -3257,37 +3291,19 @@ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
3257
3291
  ],
3258
3292
  config.maxCodeEvents
3259
3293
  );
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
- );
3294
+ // See `recordTimelineEntry` for the rationale on dropping `diffText`.
3295
+ const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3296
+ "stableId",
3297
+ "title",
3298
+ "createdAtMs",
3299
+ "threadLabel",
3300
+ "diffAvailable",
3301
+ "diffSource",
3302
+ "diffAddedLines",
3303
+ "diffRemovedLines",
3304
+ "previousFileRefs",
3305
+ "cwd",
3306
+ ]);
3291
3307
  runtime.recentCodeEvents = nextItems;
3292
3308
  state.recentCodeEvents = nextItems;
3293
3309
  if (changed) {
@@ -3353,9 +3369,11 @@ function recordHistoryItem({ config, runtime, state, item }) {
3353
3369
  [normalized, ...runtime.recentHistoryItems.filter((entry) => entry.stableId !== normalized.stableId)],
3354
3370
  config.maxHistoryItems
3355
3371
  );
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]));
3372
+ const changed = timelineProjectionChanged(nextItems, runtime.recentHistoryItems, [
3373
+ "stableId",
3374
+ "title",
3375
+ "createdAtMs",
3376
+ ]);
3359
3377
  runtime.recentHistoryItems = nextItems;
3360
3378
  state.recentHistoryItems = nextItems;
3361
3379
  return changed;
@@ -3784,7 +3802,7 @@ async function scanOnce({ config, runtime, state }) {
3784
3802
  if (config.webUiEnabled) {
3785
3803
  let claudeTranscriptChanged = false;
3786
3804
  if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
3787
- runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
3805
+ runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir, config.claudeTranscriptMaxAgeMs);
3788
3806
  runtime.lastClaudeScanAt = now;
3789
3807
  }
3790
3808
  let claudeSessionTitlesChanged = false;
@@ -4122,7 +4140,16 @@ async function refreshClaudeSessionTitles(runtime) {
4122
4140
  return changed;
4123
4141
  }
4124
4142
 
4125
- async function listClaudeTranscriptFiles(claudeProjectsDir) {
4143
+ async function listClaudeTranscriptFiles(claudeProjectsDir, maxAgeMs = 0) {
4144
+ // ~/.claude/projects can accumulate thousands of .jsonl files (one per
4145
+ // session, never garbage-collected by Claude Code itself). Returning all of
4146
+ // them means processClaudeTranscriptFile fs.stat()s each every poll
4147
+ // iteration — for a long-time user that's 3000+ stats every 2.5 s, all but
4148
+ // a handful of which are guaranteed-cold. Filter by mtime here so the
4149
+ // per-iteration working set tracks "active sessions", not "total sessions
4150
+ // ever". maxAgeMs=0 disables filtering for callers who explicitly want
4151
+ // everything (none in the bridge today).
4152
+ const cutoffMs = maxAgeMs > 0 ? Date.now() - maxAgeMs : 0;
4126
4153
  try {
4127
4154
  const result = [];
4128
4155
  const entries = await fs.readdir(claudeProjectsDir, { withFileTypes: true });
@@ -4133,7 +4160,17 @@ async function listClaudeTranscriptFiles(claudeProjectsDir) {
4133
4160
  const files = await fs.readdir(projectPath, { withFileTypes: true });
4134
4161
  for (const file of files) {
4135
4162
  if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
4136
- result.push(path.join(projectPath, file.name));
4163
+ const fullPath = path.join(projectPath, file.name);
4164
+ if (cutoffMs > 0) {
4165
+ try {
4166
+ const stat = await fs.stat(fullPath);
4167
+ if (stat.mtimeMs < cutoffMs) continue;
4168
+ } catch {
4169
+ // skip unreadable file (vanished or permissioned out)
4170
+ continue;
4171
+ }
4172
+ }
4173
+ result.push(fullPath);
4137
4174
  }
4138
4175
  } catch {
4139
4176
  // skip unreadable project dirs
@@ -11294,6 +11331,7 @@ function buildPendingApprovalDetail(runtime, state, approval, locale) {
11294
11331
  : approvalKind === "hazbase_wallet_payment"
11295
11332
  ? [
11296
11333
  { label: t(locale, "server.action.payWithWallet"), tone: "primary", url: `/api/payments/x402/hazbase-wallet/${encodeURIComponent(approval.token)}/pay`, body: { hazbaseReauth: true } },
11334
+ { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
11297
11335
  ]
11298
11336
  : [
11299
11337
  { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
@@ -13480,9 +13518,93 @@ function buildWebAppHtml({ pairToken }) {
13480
13518
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
13481
13519
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
13482
13520
  <link rel="stylesheet" href="/app.css">
13521
+ <style>
13522
+ .boot-splash {
13523
+ position: fixed;
13524
+ inset: 0;
13525
+ z-index: 9999;
13526
+ display: grid;
13527
+ place-items: center;
13528
+ padding: max(1.2rem, env(safe-area-inset-top)) 1rem max(1.2rem, env(safe-area-inset-bottom));
13529
+ color: #f5fbff;
13530
+ background:
13531
+ radial-gradient(circle at 50% 18%, rgba(47, 143, 103, 0.22), transparent 30%),
13532
+ radial-gradient(circle at 78% 78%, rgba(79, 131, 216, 0.14), transparent 28%),
13533
+ linear-gradient(180deg, #081015 0%, #091015 100%);
13534
+ transition: opacity 220ms ease, visibility 220ms ease;
13535
+ }
13536
+ .boot-splash__card {
13537
+ width: min(20rem, 82vw);
13538
+ display: grid;
13539
+ justify-items: center;
13540
+ gap: 0.9rem;
13541
+ text-align: center;
13542
+ }
13543
+ .boot-splash__logo {
13544
+ width: clamp(5.4rem, 28vw, 7rem);
13545
+ height: clamp(5.4rem, 28vw, 7rem);
13546
+ border-radius: 28%;
13547
+ background:
13548
+ radial-gradient(circle at 76% 24%, rgba(125, 211, 252, 0.22), transparent 30%),
13549
+ linear-gradient(180deg, rgba(23, 52, 72, 0.96), rgba(9, 17, 23, 0.96));
13550
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.32);
13551
+ }
13552
+ .boot-splash__title {
13553
+ margin: 0.25rem 0 0;
13554
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13555
+ font-size: clamp(1.65rem, 8vw, 2.4rem);
13556
+ line-height: 1;
13557
+ letter-spacing: -0.04em;
13558
+ }
13559
+ .boot-splash__status {
13560
+ margin: 0;
13561
+ color: rgba(205, 220, 231, 0.72);
13562
+ font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Text", "Helvetica Neue", sans-serif;
13563
+ font-size: 0.9rem;
13564
+ letter-spacing: 0.02em;
13565
+ }
13566
+ .boot-splash__dots {
13567
+ display: inline-grid;
13568
+ grid-auto-flow: column;
13569
+ gap: 0.34rem;
13570
+ margin-top: 0.15rem;
13571
+ }
13572
+ .boot-splash__dots span {
13573
+ width: 0.42rem;
13574
+ height: 0.42rem;
13575
+ border-radius: 999px;
13576
+ background: rgba(142, 215, 255, 0.88);
13577
+ animation: viveworker-boot-dot 980ms ease-in-out infinite;
13578
+ }
13579
+ .boot-splash__dots span:nth-child(2) { animation-delay: 140ms; }
13580
+ .boot-splash__dots span:nth-child(3) { animation-delay: 280ms; }
13581
+ .viveworker-ready .boot-splash {
13582
+ opacity: 0;
13583
+ visibility: hidden;
13584
+ }
13585
+ @keyframes viveworker-boot-dot {
13586
+ 0%, 80%, 100% { transform: translateY(0); opacity: 0.42; }
13587
+ 40% { transform: translateY(-0.28rem); opacity: 1; }
13588
+ }
13589
+ @media (prefers-reduced-motion: reduce) {
13590
+ .boot-splash,
13591
+ .boot-splash__dots span {
13592
+ transition: none;
13593
+ animation: none;
13594
+ }
13595
+ }
13596
+ </style>
13483
13597
  <title>viveworker</title>
13484
13598
  </head>
13485
13599
  <body>
13600
+ <div id="boot-splash" class="boot-splash" role="status" aria-live="polite" aria-label="viveworker is starting">
13601
+ <div class="boot-splash__card">
13602
+ <img class="boot-splash__logo" src="/icons/viveworker-v-pulse.svg" alt="" width="112" height="112" decoding="async">
13603
+ <h1 class="boot-splash__title">viveworker</h1>
13604
+ <p class="boot-splash__status">Starting</p>
13605
+ <span class="boot-splash__dots" aria-hidden="true"><span></span><span></span><span></span></span>
13606
+ </div>
13607
+ </div>
13486
13608
  <div id="app"></div>
13487
13609
  <script type="module" src="/app.js"></script>
13488
13610
  </body>
@@ -15028,9 +15150,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15028
15150
  const result = await completeHazbaseWalletPaymentApproval({ config, runtime, state, approval });
15029
15151
  return writeJson(res, 200, result);
15030
15152
  } catch (error) {
15031
- approval.resolving = false;
15153
+ const failure = await finalizeHazbaseWalletPaymentFailure({ config, runtime, state, approval, error });
15032
15154
  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" });
15155
+ return writeJson(res, Number(error?.statusCode) || 500, {
15156
+ error: failure.error,
15157
+ approvalFinalized: true,
15158
+ retryable: true,
15159
+ });
15034
15160
  }
15035
15161
  }
15036
15162
 
@@ -15922,7 +16048,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15922
16048
  if (approval.resolved || approval.resolving) {
15923
16049
  return writeJson(res, 409, { error: "approval-already-handled" });
15924
16050
  }
15925
- if (approval.kind === "hazbase_wallet_payment") {
16051
+ if (approval.kind === "hazbase_wallet_payment" && decision !== "decline") {
15926
16052
  console.warn(`[hazbase-wallet-payment-generic-decision-blocked] ${approval.requestKey} | ${decision}`);
15927
16053
  return writeJson(res, 409, { error: "hazbase-wallet-payment-requires-wallet-pay-route" });
15928
16054
  }
@@ -15936,6 +16062,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15936
16062
  return writeJson(res, 200, { ok: true, decision });
15937
16063
  } catch (error) {
15938
16064
  approval.resolving = false;
16065
+ if (res.headersSent || res.writableEnded) {
16066
+ console.error(`[approval-decision-post-write-error] ${approval.requestKey} | ${error.message}`);
16067
+ return;
16068
+ }
15939
16069
  return writeJson(res, 500, { error: error.message });
15940
16070
  }
15941
16071
  }
@@ -16520,7 +16650,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16520
16650
  if (approval.resolved || approval.resolving) {
16521
16651
  return writeApprovalHandled(res, req, approval.title, 409, config.defaultLocale);
16522
16652
  }
16523
- if (approval.kind === "hazbase_wallet_payment") {
16653
+ if (approval.kind === "hazbase_wallet_payment" && decision !== "decline") {
16524
16654
  console.warn(`[hazbase-wallet-payment-native-decision-blocked] ${approval.requestKey} | ${decision}`);
16525
16655
  if (requestWantsHtml(req)) {
16526
16656
  return writeHtml(
@@ -16559,6 +16689,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16559
16689
  });
16560
16690
  } catch (error) {
16561
16691
  approval.resolving = false;
16692
+ if (res.headersSent || res.writableEnded) {
16693
+ console.error(`[native-approval-decision-post-write-error] ${approval.requestKey} | ${error.message}`);
16694
+ return;
16695
+ }
16562
16696
  if (requestWantsHtml(req)) {
16563
16697
  return writeHtml(
16564
16698
  res,
@@ -16573,6 +16707,9 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16573
16707
  return writeJson(res, 500, { error: error.message });
16574
16708
  }
16575
16709
  } catch (error) {
16710
+ if (res.headersSent || res.writableEnded) {
16711
+ return;
16712
+ }
16576
16713
  if (requestWantsHtml(req)) {
16577
16714
  return writeHtml(
16578
16715
  res,
@@ -17969,6 +18106,12 @@ function buildConfig(cli) {
17969
18106
  maxCodeEvents: numberEnv("MAX_CODE_EVENTS", 1000),
17970
18107
  maxTimelineThreads: numberEnv("MAX_TIMELINE_THREADS", 20),
17971
18108
  maxReadBytes: numberEnv("MAX_READ_BYTES", 2 * 1024 * 1024),
18109
+ // Cap how far back the bridge looks for Claude transcripts. Default 7 days
18110
+ // covers a long pause without dragging the long tail of dead sessions
18111
+ // (which can easily reach thousands of files) into the per-iteration
18112
+ // stat loop. Set to 0 to disable filtering and scan everything (the
18113
+ // legacy behavior).
18114
+ claudeTranscriptMaxAgeMs: numberEnv("CLAUDE_TRANSCRIPT_MAX_AGE_DAYS", 7) * 24 * 60 * 60 * 1000,
17972
18115
  maxMessageChars: numberEnv("MAX_MESSAGE_CHARS", 320),
17973
18116
  maxCommandChars: numberEnv("MAX_COMMAND_CHARS", 220),
17974
18117
  maxJustificationChars: numberEnv("MAX_JUSTIFICATION_CHARS", 220),
@@ -18012,18 +18155,31 @@ function buildConfig(cli) {
18012
18155
  }
18013
18156
 
18014
18157
 
18158
+ function inferHazbaseDeviceBindingIdFromAccounts(accounts) {
18159
+ const list = Array.isArray(accounts) ? accounts : [];
18160
+ for (const entry of list) {
18161
+ const deviceBindingId = cleanText(
18162
+ entry?.primaryDeviceBindingId || entry?.deviceBindingId || entry?.ownerDeviceBindingId || ""
18163
+ );
18164
+ if (deviceBindingId) return deviceBindingId;
18165
+ }
18166
+ return "";
18167
+ }
18168
+
18015
18169
  function normalizeHazbaseState(raw) {
18016
18170
  const value = raw && typeof raw === "object" ? raw : {};
18171
+ const accounts = Array.isArray(value.accounts) ? value.accounts : [];
18172
+ const deviceBindingId = cleanText(value.deviceBindingId ?? "") || inferHazbaseDeviceBindingIdFromAccounts(accounts);
18017
18173
  return {
18018
18174
  email: cleanText(value.email ?? ""),
18019
18175
  accessToken: cleanText(value.accessToken ?? ""),
18020
18176
  sessionId: cleanText(value.sessionId ?? ""),
18021
18177
  userId: cleanText(value.userId ?? ""),
18022
- deviceBindingId: cleanText(value.deviceBindingId ?? ""),
18178
+ deviceBindingId,
18023
18179
  credentialId: cleanText(value.credentialId ?? ""),
18024
18180
  highTrustToken: cleanText(value.highTrustToken ?? ""),
18025
18181
  highTrustExpiresAt: cleanText(value.highTrustExpiresAt ?? ""),
18026
- accounts: Array.isArray(value.accounts) ? value.accounts : [],
18182
+ accounts,
18027
18183
  sessionInvalid: value.sessionInvalid === true,
18028
18184
  sessionInvalidReason: cleanText(value.sessionInvalidReason ?? ""),
18029
18185
  sessionInvalidAt: cleanText(value.sessionInvalidAt ?? ""),
@@ -18032,19 +18188,30 @@ function normalizeHazbaseState(raw) {
18032
18188
 
18033
18189
  function isHazbaseInvalidAppSessionError(error) {
18034
18190
  const details = error?.details && typeof error.details === "object" ? error.details : {};
18035
- const statusCode = Number(error?.statusCode || error?.status || details.statusCode || 0);
18036
- const text = [
18191
+ const rawText = [
18037
18192
  error?.code,
18038
18193
  error?.message,
18039
18194
  details.errorCode,
18040
18195
  details.code,
18041
18196
  details.error,
18042
18197
  details.message,
18198
+ typeof error?.details === "string" ? error.details : "",
18199
+ ]
18200
+ .map((entry) => cleanText(entry || ""))
18201
+ .filter(Boolean)
18202
+ .join(" ");
18203
+ const statusCode = Number(error?.statusCode || error?.status || details.statusCode || 0) ||
18204
+ (/statusCode["']?\s*[:=]\s*401/u.test(rawText) || /\b401\b/u.test(rawText) ? 401 : 0);
18205
+ const text = [
18206
+ rawText,
18043
18207
  ]
18044
18208
  .map((entry) => cleanText(entry || "").toLowerCase())
18045
18209
  .filter(Boolean)
18046
18210
  .join(" ");
18047
- return statusCode === 401 && text.includes("invalid app session");
18211
+ return statusCode === 401 && (
18212
+ text.includes("invalid app session") ||
18213
+ text.includes("invalid_app_session")
18214
+ );
18048
18215
  }
18049
18216
 
18050
18217
  function markHazbaseSessionInvalid(state, reason = "invalid_app_session") {
@@ -18274,6 +18441,51 @@ function resolveHazbaseAccountForChain(accounts, chainId) {
18274
18441
  }
18275
18442
 
18276
18443
 
18444
+ async function finalizeHazbaseWalletPaymentFailure({ config, runtime, state, approval, error }) {
18445
+ const errorCode = cleanText(error?.code || error?.message || "hazbase-wallet-payment-failed") || "hazbase-wallet-payment-failed";
18446
+ if (approval.resolved) {
18447
+ return { paid: false, decision: "failed", error: errorCode };
18448
+ }
18449
+
18450
+ const payload = {
18451
+ paid: false,
18452
+ decision: "failed",
18453
+ error: errorCode,
18454
+ paymentRequestId: approval.paymentRequestId || approval.requestId || "",
18455
+ };
18456
+
18457
+ approval.resolved = true;
18458
+ approval.resolving = false;
18459
+ runtime.nativeApprovalsByToken.delete(approval.token);
18460
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
18461
+ approval.resolveClaudeWaiter?.(payload);
18462
+
18463
+ const statusMessage = t(config.defaultLocale, "server.message.paymentFailed", { reason: errorCode });
18464
+ const stateChanged = recordActionHistoryItem({
18465
+ config,
18466
+ runtime,
18467
+ state,
18468
+ kind: "approval",
18469
+ stableId: `approval:${approval.requestKey}:${Date.now()}`,
18470
+ token: approval.token,
18471
+ title: approval.title,
18472
+ threadLabel: approval.threadLabel || "",
18473
+ messageText: `${statusMessage}\n\n${approval.messageText}`,
18474
+ summary: statusMessage,
18475
+ fileRefs: [],
18476
+ diffText: "",
18477
+ diffSource: "",
18478
+ diffAvailable: false,
18479
+ diffAddedLines: 0,
18480
+ diffRemovedLines: 0,
18481
+ outcome: "failed",
18482
+ provider: approval.provider || "viveworker",
18483
+ });
18484
+ if (stateChanged) await saveState(config.stateFile, state);
18485
+ console.log(`[hazbase-wallet-payment-failed] ${approval.requestKey} | ${errorCode}`);
18486
+ return payload;
18487
+ }
18488
+
18277
18489
  async function completeHazbaseWalletPaymentApproval({ config, runtime, state, approval }) {
18278
18490
  const hazbase = normalizeHazbaseState(state.hazbase);
18279
18491
  if (!hazbase.accessToken) throw codedError("hazbase-auth-required", 401);
@@ -19183,8 +19395,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
19183
19395
  );
19184
19396
 
19185
19397
  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]))
19398
+ timelineProjectionChanged(nextHistoryItems, runtime.recentHistoryItems, [
19399
+ "stableId",
19400
+ "title",
19401
+ "threadLabel",
19402
+ ])
19188
19403
  ) {
19189
19404
  runtime.recentHistoryItems = nextHistoryItems;
19190
19405
  state.recentHistoryItems = nextHistoryItems;
@@ -19225,8 +19440,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
19225
19440
  );
19226
19441
 
19227
19442
  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]))
19443
+ timelineProjectionChanged(nextTimelineEntries, runtime.recentTimelineEntries, [
19444
+ "stableId",
19445
+ "title",
19446
+ "threadLabel",
19447
+ ])
19230
19448
  ) {
19231
19449
  runtime.recentTimelineEntries = nextTimelineEntries;
19232
19450
  state.recentTimelineEntries = nextTimelineEntries;
@@ -19261,8 +19479,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
19261
19479
  );
19262
19480
 
19263
19481
  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]))
19482
+ timelineProjectionChanged(nextCodeEvents, runtime.recentCodeEvents, [
19483
+ "stableId",
19484
+ "title",
19485
+ "threadLabel",
19486
+ ])
19266
19487
  ) {
19267
19488
  runtime.recentCodeEvents = nextCodeEvents;
19268
19489
  state.recentCodeEvents = nextCodeEvents;
@@ -19697,6 +19918,37 @@ async function main() {
19697
19918
  process.on("SIGINT", handleSignal);
19698
19919
  process.on("SIGTERM", handleSignal);
19699
19920
 
19921
+ // Belt-and-suspenders: a stray ERR_HTTP_HEADERS_SENT inside an async
19922
+ // request handler used to escape as an UnhandledPromiseRejection and crash
19923
+ // the process under launchd, triggering a restart loop that then re-ran all
19924
+ // the startup backfills (state.json parse, rollout/transcript replay) and
19925
+ // burned CPU + memory for ~30s per cycle. The targeted catch-handler guards
19926
+ // around handleNativeApprovalDecision cover the known sites; this top-level
19927
+ // filter swallows the same class of error from any new site we haven't
19928
+ // audited yet so the process keeps serving instead of dying.
19929
+ const isBenignHttpError = (err) =>
19930
+ err && typeof err === "object" && (
19931
+ err.code === "ERR_HTTP_HEADERS_SENT" ||
19932
+ err.code === "ERR_STREAM_WRITE_AFTER_END" ||
19933
+ err.code === "ERR_STREAM_DESTROYED" ||
19934
+ err.code === "ECONNRESET" ||
19935
+ err.code === "EPIPE"
19936
+ );
19937
+ process.on("uncaughtException", (err) => {
19938
+ if (isBenignHttpError(err)) {
19939
+ console.warn(`[uncaught-http] ${err.code} ${err.message}`);
19940
+ return;
19941
+ }
19942
+ console.error(`[uncaught] ${err?.stack || err}`);
19943
+ });
19944
+ process.on("unhandledRejection", (reason) => {
19945
+ if (isBenignHttpError(reason)) {
19946
+ console.warn(`[unhandled-http] ${reason.code} ${reason.message}`);
19947
+ return;
19948
+ }
19949
+ console.error(`[unhandled] ${reason?.stack || reason}`);
19950
+ });
19951
+
19700
19952
  try {
19701
19953
  if (config.webPushEnabled) {
19702
19954
  webPush.setVapidDetails(