@1presence/bridge 0.39.0 → 0.40.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/dist/claude.js CHANGED
@@ -154,8 +154,15 @@ const RETRY_WALL_CLOCK_CAP_MS = 12_000; // stop retrying past this much elapsed
154
154
  // latest), so upgrading does NOT fix it. We deliberately do not suggest an
155
155
  // upgrade; the automatic retry is the real mitigation and resending sometimes
156
156
  // gets through.
157
- function describeCliFailure(code, apiErrorText) {
157
+ function describeCliFailure(code, apiErrorText, authFailure) {
158
158
  const t = apiErrorText.trim();
159
+ // Auth/credential failure (401/403). Local Mode runs the user's own Claude
160
+ // Code, so naming it (and /login) is intentional and consistent with the
161
+ // "claude CLI not found" message — this is the only place that can tell them
162
+ // how to recover. Takes precedence over the generic branches below.
163
+ if (authFailure) {
164
+ return 'Local Mode could not sign in to Claude Code on this machine. Open a terminal, run `claude` and sign in (or run /login inside Claude Code), then send your message again.';
165
+ }
159
166
  if (/API Error:\s*400/i.test(t) && /(tool use|concurren|parallel)/i.test(t)) {
160
167
  return 'Local Mode hit a known Claude Code error (a print-mode bug that affects every current version). I retried a few times automatically — sending the message again sometimes gets through. See https://github.com/anthropics/claude-code/issues/18131';
161
168
  }
@@ -342,6 +349,11 @@ function spawnClaude(params) {
342
349
  let messageCount = 0;
343
350
  let costUsd = 0;
344
351
  let usage = null;
352
+ // Prompt size of the MOST RECENT assistant call (input + both cache buckets),
353
+ // overwritten on each assistant event so it ends on the turn's final, fullest
354
+ // call. This — not the summed `usage` above — is the current context fill the
355
+ // status line's 🧠 segment reports against the model's window.
356
+ let lastContextTokens = 0;
345
357
  let extractedModel = null;
346
358
  let buffer = '';
347
359
  let killedForViolation = false;
@@ -352,6 +364,10 @@ function spawnClaude(params) {
352
364
  // - producedRealOutput: any real assistant text or tool_use was emitted, so
353
365
  // a later failure must NOT be retried (could double-run a side-effect).
354
366
  let sawApiError = false;
367
+ // - sawAuthFailure: a 401/403 auth/credential failure (the user's local
368
+ // Claude Code is not signed in). Surfaced as an actionable message and
369
+ // never retried (re-spawning won't add credentials).
370
+ let sawAuthFailure = false;
355
371
  let apiErrorText = '';
356
372
  let producedRealOutput = false;
357
373
  proc.stdout.on('data', (chunk) => {
@@ -408,6 +424,10 @@ function spawnClaude(params) {
408
424
  cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
409
425
  cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
410
426
  };
427
+ // Full prompt size of THIS assistant call — non-cached input plus both
428
+ // cache buckets. Overwrite (don't sum): the last write wins, which is
429
+ // the turn's largest/final context.
430
+ lastContextTokens = (u['input_tokens'] ?? 0) + (u['cache_read_input_tokens'] ?? 0) + (u['cache_creation_input_tokens'] ?? 0);
411
431
  }
412
432
  const content = msg?.['content'];
413
433
  if (Array.isArray(content)) {
@@ -463,14 +483,30 @@ function spawnClaude(params) {
463
483
  else if (block['type'] === 'text') {
464
484
  const text = block['text'];
465
485
  if (text) {
466
- if (/^API Error:/i.test(text.trimStart())) {
467
- // The CLI is reporting an underlying API failure as assistant
468
- // text. Capture it for the user-facing message, and suppress
469
- // the whole event so the raw error never reaches the PWA or
470
- // the accumulator (the gateway also blanks it via
486
+ // The CLI reports auth/credential failures (401/403) as a
487
+ // <synthetic> assistant text turn whose wording varies and does
488
+ // NOT reliably start with "API Error:" e.g. "Please run /login
489
+ // · API Error: 401 Invalid authentication credentials" or
490
+ // "Failed to authenticate. API Error: 401 …". Detect by the
491
+ // structured signal (the event's `error: authentication_failed`
492
+ // / `model: <synthetic>`) plus a wording fallback, so it is
493
+ // classified instead of leaking raw into the chat as if the
494
+ // model had said it.
495
+ const isSynthetic = msg?.['model'] === '<synthetic>';
496
+ const isAuthFailure = event['error'] === 'authentication_failed' ||
497
+ (isSynthetic && /(api error:\s*40[13]\b|invalid (api key|authentication)|please run \/login|failed to authenticate|unauthor)/i.test(text));
498
+ if (/^API Error:/i.test(text.trimStart()) || isAuthFailure) {
499
+ // The CLI is reporting an underlying API/auth failure as
500
+ // assistant text. Capture it for the user-facing message, and
501
+ // suppress the whole event so the raw error never reaches the
502
+ // PWA or the accumulator (the gateway also blanks it via
471
503
  // cleanTurnText — this is the upstream defense).
472
504
  sawApiError = true;
473
505
  apiErrorText = text.trim();
506
+ if (isAuthFailure)
507
+ sawAuthFailure = true;
508
+ // Operator log keeps the raw provider line verbatim (with a
509
+ // [bridge] prefix) so the real reason is diagnosable locally.
474
510
  suppressEvent = true;
475
511
  process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge] ${text.replace(/\n+/g, ' ')}`) + '\n');
476
512
  }
@@ -513,11 +549,25 @@ function spawnClaude(params) {
513
549
  }
514
550
  }
515
551
  }
516
- // Extract cost from the final result event
552
+ // Extract cost from the final result event. The CLI also stamps auth/API
553
+ // failures here as `is_error` + `api_error_status` (even though `subtype`
554
+ // stays "success"), so treat it as a robust backstop in case the
555
+ // assistant-text signal above was missed (wording drift across CLI
556
+ // versions). 401/403 → auth failure; other statuses keep the existing
557
+ // 400-retry behaviour (sawApiError only).
517
558
  if (type === 'result') {
518
559
  const c = event['cost_usd'] ?? event['total_cost_usd'];
519
560
  if (typeof c === 'number')
520
561
  costUsd = c;
562
+ if (event['is_error'] === true) {
563
+ sawApiError = true;
564
+ const status = event['api_error_status'];
565
+ if (status === 401 || status === 403)
566
+ sawAuthFailure = true;
567
+ if (!apiErrorText && typeof event['result'] === 'string') {
568
+ apiErrorText = event['result'].trim();
569
+ }
570
+ }
521
571
  }
522
572
  if (!suppressEvent)
523
573
  onEvent(event);
@@ -541,14 +591,20 @@ function spawnClaude(params) {
541
591
  }
542
592
  catch { /* ignore */ }
543
593
  }
544
- if (code !== 0 && code !== null) {
594
+ // An auth failure can land on a "successful" exit (the CLI stamps it on the
595
+ // result event but still exits 0 in some versions), and we've suppressed its
596
+ // text — so without this the turn would finish silently empty. Treat it as a
597
+ // failure regardless of exit code.
598
+ if (sawAuthFailure || (code !== 0 && code !== null)) {
545
599
  // Auto-retry when the CLI failed BEFORE producing any real output — the
546
600
  // signature of the known print-mode 400 regression. A fresh spawn (new
547
601
  // --session-id) often succeeds. We never retry once real text or a tool
548
- // call landed, to avoid double-running a side-effectful tool. Retries use
549
- // escalating backoff and stop past the wall-clock cap (see consts above).
602
+ // call landed, to avoid double-running a side-effectful tool. We also
603
+ // never retry an auth failure re-spawning won't add missing credentials,
604
+ // it just burns the user's plan. Retries use escalating backoff and stop
605
+ // past the wall-clock cap (see consts above).
550
606
  const elapsed = Date.now() - firstAttemptAt;
551
- if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
607
+ if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !sawAuthFailure && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
552
608
  const delay = RETRY_BACKOFF_BASE_MS * (attemptIdx + 1);
553
609
  const nextAttempt = attemptIdx + 2;
554
610
  process.stderr.write(`[bridge] turn failed before output (${apiErrorText.replace(/\n+/g, ' ').slice(0, 120)}) — retrying (${nextAttempt} of ${MAX_TURN_RETRIES + 1}) in ${delay}ms\n`);
@@ -564,10 +620,10 @@ function spawnClaude(params) {
564
620
  // Pass any partial token usage we observed before the failure so the
565
621
  // PWA and the gateway's bridge usage store can still record it. Surface a
566
622
  // classified, user-readable message instead of the opaque exit code.
567
- onError(describeCliFailure(code, apiErrorText), usage, extractedModel);
623
+ onError(describeCliFailure(code, apiErrorText, sawAuthFailure), usage, extractedModel);
568
624
  }
569
625
  else {
570
- onDone(messageCount, costUsd, usage, extractedModel);
626
+ onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
571
627
  }
572
628
  });
573
629
  proc.on('error', (err) => {
package/dist/index.js CHANGED
@@ -45,6 +45,52 @@ const PWA_URL = process.env.BRIDGE_PWA_URL ?? GATEWAY_HTTP.replace('://api.', ':
45
45
  // ─── In-memory state ──────────────────────────────────────────────────────────
46
46
  let currentAuth = null;
47
47
  let currentWs = null;
48
+ // Running cost across all turns this process has handled, for the cost segment
49
+ // of the per-turn status line. On a pure subscription the CLI often reports a
50
+ // per-turn cost of 0, in which case this stays at 0 and reads as "plan usage".
51
+ let sessionCostUsd = 0;
52
+ // ─── Status line ──────────────────────────────────────────────────────────────
53
+ //
54
+ // A compact line printed after each completed turn echoing the segments local
55
+ // Claude Code shows in its own status bar: model, context fill, and cost. The
56
+ // 5h/7d subscription rate-limit windows it also shows are deliberately absent —
57
+ // those ride in the API's rate-limit response HEADERS, which the bridge (a
58
+ // consumer of the CLI's stream-json stdout only) never sees. Display only.
59
+ // Raw model id (claude-opus-4-7, claude-sonnet-4-6-20250101) to friendly "Opus
60
+ // 4.7". Regex-based so new dated snapshots format without a table edit; an
61
+ // unrecognised shape falls back to the raw id rather than guessing.
62
+ function friendlyModelName(model) {
63
+ if (!model)
64
+ return 'unknown';
65
+ const m = /claude-(opus|sonnet|haiku)-(\d+)-(\d+)/i.exec(model);
66
+ if (!m)
67
+ return model;
68
+ return `${m[1].charAt(0).toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`;
69
+ }
70
+ // Context window (tokens) per model, for the context-fill estimate. Keyed by a
71
+ // family regex against the raw model id; first match wins, and an unrecognised
72
+ // id falls back to the Claude 4.x baseline rather than guessing high.
73
+ //
74
+ // Every model the bridge can currently run is 200k: Opus/Sonnet/Haiku 4.x are
75
+ // 200k on the standard path, and the 1M-context window is an API beta that the
76
+ // bridge's subscription print mode never opts into — so it does not apply here.
77
+ // When a model ships with a different standard window, add a row above the
78
+ // baseline; that one line keeps the estimate honest without touching anything
79
+ // else. (The percentage is of the raw window — local Claude's own gauge also
80
+ // reserves output headroom, so its reading runs a few points higher near full.)
81
+ const CONTEXT_WINDOWS = [
82
+ { match: /claude-(opus|sonnet|haiku)-4/i, tokens: 200_000 },
83
+ ];
84
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
85
+ function contextWindowFor(model) {
86
+ if (model) {
87
+ for (const { match, tokens } of CONTEXT_WINDOWS) {
88
+ if (match.test(model))
89
+ return tokens;
90
+ }
91
+ }
92
+ return DEFAULT_CONTEXT_WINDOW;
93
+ }
48
94
  // ─── System prompt fetch ──────────────────────────────────────────────────────
49
95
  // Pulls the fully-built system prompt from agent-api (via gateway proxy).
50
96
  // This MUST match the hosted runtime exactly — STATIC_SYSTEM_PROMPT + dynamic
@@ -279,7 +325,7 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
279
325
  currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
280
326
  }
281
327
  },
282
- onDone: (messageCount, costUsd, usage, model) => {
328
+ onDone: (messageCount, costUsd, usage, model, contextTokens) => {
283
329
  const elapsed = (0, timer_1.stopTurnTimer)();
284
330
  const parts = [(0, timer_1.formatElapsed)(elapsed)];
285
331
  if (usage)
@@ -288,6 +334,14 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
288
334
  parts.push(costStr);
289
335
  const suffix = ` ${parts.join(' ')}`;
290
336
  console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
337
+ // Status-bar line, mirroring local Claude Code: model · context fill ·
338
+ // session cost. Dimmed and indented so it groups under the done line
339
+ // without competing with it. The cost segment falls back to "plan usage"
340
+ // whenever the running total is 0 (the subscription case).
341
+ sessionCostUsd += costUsd;
342
+ const ctxPct = Math.max(0, Math.min(100, Math.round((contextTokens / contextWindowFor(model)) * 100)));
343
+ const costSeg = sessionCostUsd > 0 ? `$${sessionCostUsd.toFixed(2)} session` : 'plan usage';
344
+ console.log((0, claude_1.paint)('90', ` 🤖 ${friendlyModelName(model)} · 🧠 ${ctxPct}% · 💰 ${costSeg}`));
291
345
  const mapped = toBridgeUsage(usage);
292
346
  if (currentWs?.readyState === ws_1.default.OPEN) {
293
347
  currentWs.send(JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "bin": {
6
6
  "1presence-bridge": "dist/index.js"