@1presence/bridge 0.59.0 → 0.61.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
@@ -41,6 +41,16 @@ import { getBridgeModel } from './config.js';
41
41
  // Track whether we've already announced the model this process — printing it
42
42
  // per-spawn is noisy; once on startup is what the user actually wants to see.
43
43
  let modelAnnounced = false;
44
+ // Claude Code engine version driving the turns, captured from the SDK init
45
+ // event (`claude_code_version`). Constant per process, so cached module-level
46
+ // and read by the after-turn status line — mirrors the `v2.x.x` segment of the
47
+ // local statusline. Null until the first turn's init event arrives.
48
+ let cliVersion = null;
49
+ export function getCliVersion() { return cliVersion; }
50
+ const rateLimitWindows = new Map();
51
+ export function getRateLimitWindow(type) {
52
+ return rateLimitWindows.get(type);
53
+ }
44
54
  // Verbose flag — when set via --verbose, log full tool inputs and outputs
45
55
  // PLUS the entire system prompt. Great for prompt debugging, noisy for
46
56
  // message debugging (the prompt dump buries the conversation).
@@ -185,6 +195,35 @@ function joinErrorDetail(...parts) {
185
195
  // Lines the SDK/CLI writes to its own stderr that look like a failure — surfaced
186
196
  // even outside verbose mode so a turn that dies upstream leaves a trail.
187
197
  const SDK_STDERR_ERROR_RE = /\b(error|exception|fail(?:ed|ure)?|invalid|unauthor|forbidden|refus|denied|40[0-9]|429|5\d\d|overloaded|rate.?limit)\b/i;
198
+ // The remote 1Presence MCP server's key (matches index.ts writeMcpConfig +
199
+ // the `mcp__1presence__*` allowlist). reconnectMcpServer() takes this name.
200
+ const REMOTE_MCP_SERVER_NAME = '1presence';
201
+ // Signature of a dropped remote MCP session. The pod binds each MCP session to a
202
+ // single long-lived SSE `GET /mcp` stream held in its memory; if that stream
203
+ // drops mid-run (transient network blip on a long workflow stage), the pod
204
+ // deletes the session and every later `POST /mcp/message?sessionId=…` returns
205
+ // HTTP 404 "session not found or expired". The SDK does NOT auto-retry an
206
+ // expired MCP session (sdk.d.ts: "Session expiry is not retried automatically;
207
+ // callers can mcp_reconnect and retry") — it surfaces the 404 as a tool_result
208
+ // error to the model, which then dead-loops calling tools against the same dead
209
+ // session for the rest of the turn. Detecting this lets us reconnect the server
210
+ // so the model's next attempt re-handshakes a fresh session and recovers.
211
+ const MCP_SESSION_EXPIRED_RE = /session not found or expired|Error POSTing to endpoint \(HTTP 404\)/i;
212
+ // Pull every text fragment out of a tool_result `content` (string OR the MCP
213
+ // block-array shape `[{type:'text',text:'…'}]`) so we can scan it for the
214
+ // session-expiry signature above.
215
+ function toolResultText(content) {
216
+ if (typeof content === 'string')
217
+ return content;
218
+ if (Array.isArray(content)) {
219
+ return content
220
+ .map((b) => (b && typeof b === 'object' && typeof b.text === 'string'
221
+ ? b.text
222
+ : ''))
223
+ .join(' ');
224
+ }
225
+ return '';
226
+ }
188
227
  /**
189
228
  * Copy for an actionable rate-limit notice. The SDK emits `rate_limit_event`
190
229
  * whenever rate-limit info CHANGES — including the routine `allowed` case on
@@ -384,10 +423,13 @@ export function spawnClaude(params) {
384
423
  const keySource = event['apiKeySource'];
385
424
  const model = event['model'];
386
425
  const sid = event['session_id'];
426
+ const ccv = event['claude_code_version'];
387
427
  if (sid)
388
428
  currentSessionId = sid;
389
429
  if (model)
390
430
  extractedModel = model;
431
+ if (ccv)
432
+ cliVersion = ccv;
391
433
  if (!modelAnnounced) {
392
434
  const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
393
435
  const pin = getBridgeModel() ? ' (selected at startup)' : '';
@@ -636,8 +678,28 @@ export function spawnClaude(params) {
636
678
  ...(pinnedModel ? { model: pinnedModel } : {}),
637
679
  };
638
680
  const promptMessages = buildPromptMessages(history);
681
+ // Throttle remote-MCP reconnects: a single dropped session produces a burst
682
+ // of 404 tool_results (the model keeps trying), so reconnect at most once per
683
+ // window to avoid a reconnect storm. A reconnect is in-flight guard too.
684
+ let lastMcpReconnectAt = 0;
685
+ let mcpReconnecting = false;
686
+ const MCP_RECONNECT_THROTTLE_MS = 10_000;
687
+ const maybeReconnectRemoteMcp = (q) => {
688
+ const now = Date.now();
689
+ if (mcpReconnecting || now - lastMcpReconnectAt < MCP_RECONNECT_THROTTLE_MS)
690
+ return;
691
+ mcpReconnecting = true;
692
+ lastMcpReconnectAt = now;
693
+ process.stderr.write(paint(SECTION_COLORS.result, '[bridge] remote MCP session expired — reconnecting and retrying') + '\n');
694
+ onNotice?.('Reconnecting to 1Presence tools…');
695
+ void q.reconnectMcpServer(REMOTE_MCP_SERVER_NAME)
696
+ .then(() => process.stderr.write(paint(SECTION_COLORS.result, '[bridge] remote MCP reconnected') + '\n'))
697
+ .catch((err) => process.stderr.write(paint(SECTION_COLORS.result, `[bridge] remote MCP reconnect failed: ${err.message}`) + '\n'))
698
+ .finally(() => { mcpReconnecting = false; });
699
+ };
639
700
  try {
640
- for await (const m of query({ prompt: promptStream(promptMessages), options })) {
701
+ const q = query({ prompt: promptStream(promptMessages), options });
702
+ for await (const m of q) {
641
703
  // Skip echoed input replays — they would double-count in the accumulator
642
704
  // and re-stream prior turns to the PWA.
643
705
  if (m.isReplay)
@@ -647,7 +709,7 @@ export function spawnClaude(params) {
647
709
  const subtype = m.subtype;
648
710
  if (subtype === 'init') {
649
711
  const init = m;
650
- const event = { type: 'system', subtype: 'init', model: init.model, apiKeySource: init.apiKeySource };
712
+ const event = { type: 'system', subtype: 'init', model: init.model, apiKeySource: init.apiKeySource, claude_code_version: init.claude_code_version };
651
713
  if (handleEvent(event))
652
714
  onEvent(event);
653
715
  }
@@ -703,6 +765,19 @@ export function spawnClaude(params) {
703
765
  }
704
766
  case 'user': {
705
767
  const um = m;
768
+ // Detect a dropped remote-MCP session in any tool_result and re-handshake
769
+ // so the model's next tool call lands on a live session instead of
770
+ // 404-looping for the rest of the turn (the SDK won't auto-retry it).
771
+ const content = um.message?.['content'];
772
+ if (Array.isArray(content)) {
773
+ for (const block of content) {
774
+ if (block && typeof block === 'object' && block['type'] === 'tool_result'
775
+ && MCP_SESSION_EXPIRED_RE.test(toolResultText(block['content']))) {
776
+ maybeReconnectRemoteMcp(q);
777
+ break;
778
+ }
779
+ }
780
+ }
706
781
  const event = { type: 'user', message: um.message };
707
782
  if (handleEvent(event))
708
783
  onEvent(event);
@@ -739,6 +814,16 @@ export function spawnClaude(params) {
739
814
  // a notice when the user is actually warned or throttled.
740
815
  const info = m.rate_limit_info;
741
816
  const status = info?.status;
817
+ // Retain the latest reading for this window so the after-turn status
818
+ // line can render the full 5h/7d picture even though each event only
819
+ // carries one window. utilization is a 0–100 used-percentage.
820
+ if (info?.rateLimitType && typeof info.utilization === 'number') {
821
+ rateLimitWindows.set(info.rateLimitType, {
822
+ utilization: info.utilization,
823
+ resetsAt: info.resetsAt,
824
+ status: status ?? 'allowed',
825
+ });
826
+ }
742
827
  if (status === 'allowed_warning' || status === 'rejected') {
743
828
  // Admin-only ephemeral notice — jargon is fine in Local Mode.
744
829
  onNotice?.(formatRateLimitNotice(status, info?.resetsAt));
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url';
7
7
  import { createRequire } from 'module';
8
8
  import { query } from '@anthropic-ai/claude-agent-sdk';
9
9
  import { getValidAuth, ensureFreshToken, forceRefreshToken, isTokenValid, AuthCancelledError } from './auth.js';
10
- import { spawnClaude, killAll, cancelConversation, setVerbose, setDebug, paint, SECTION_COLORS } from './claude.js';
10
+ import { spawnClaude, killAll, cancelConversation, setVerbose, setDebug, paint, SECTION_COLORS, getCliVersion, getRateLimitWindow } from './claude.js';
11
11
  import { ensureModelChoice } from './config.js';
12
12
  import { checkAndUpdate } from './update.js';
13
13
  import { makeBridgeAccumulator, postSaveTurn } from './accumulator.js';
@@ -101,6 +101,51 @@ function contextWindowFor(model) {
101
101
  }
102
102
  return DEFAULT_CONTEXT_WINDOW;
103
103
  }
104
+ // ─── Rate-limit window rendering ──────────────────────────────────────────────
105
+ // Mirrors the local Claude Code statusline's ⏱️ line: a 10-cell bar + used % +
106
+ // reset time per subscription window. Data comes from the SDK's rate_limit_event
107
+ // (captured across turns in claude.ts), so no auth/header probe is needed.
108
+ // 7-day usage may arrive as the generic `seven_day` window or a model-specific
109
+ // variant; surface whichever the SDK has reported, preferring the generic one.
110
+ function sevenDayWindow() {
111
+ return getRateLimitWindow('seven_day')
112
+ ?? getRateLimitWindow('seven_day_opus')
113
+ ?? getRateLimitWindow('seven_day_sonnet');
114
+ }
115
+ function makeBar(pct) {
116
+ const width = 10;
117
+ const filled = Math.max(0, Math.min(width, Math.floor((pct * width) / 100)));
118
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
119
+ }
120
+ // Green < 70%, yellow 70–89%, red ≥ 90% — same thresholds as the statusline.
121
+ function rlColor(pct) {
122
+ return pct >= 90 ? '31' : pct >= 70 ? '33' : '32';
123
+ }
124
+ // "12:20a.m." within a day, "Mon 11:00p.m." when the reset is > 24h out.
125
+ function formatReset(resetsAt) {
126
+ if (!resetsAt)
127
+ return '';
128
+ const ms = resetsAt < 1e12 ? resetsAt * 1000 : resetsAt;
129
+ const d = new Date(ms);
130
+ const h24 = d.getHours();
131
+ const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
132
+ const mm = String(d.getMinutes()).padStart(2, '0');
133
+ const time = `${h12}:${mm}${h24 < 12 ? 'a.m.' : 'p.m.'}`;
134
+ if (ms - Date.now() > 86_400_000) {
135
+ const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
136
+ return `${wd} ${time}`;
137
+ }
138
+ return time;
139
+ }
140
+ // One coloured "5h ██░░░░░░░░ 20% resets 12:20a.m." segment; null if unknown.
141
+ function formatWindow(label, w) {
142
+ if (!w)
143
+ return null;
144
+ const pct = Math.round(w.utilization);
145
+ const reset = formatReset(w.resetsAt);
146
+ const seg = `${label} ${makeBar(pct)} ${pct}%${reset ? ` resets ${reset}` : ''}`;
147
+ return paint(rlColor(pct), seg);
148
+ }
104
149
  // ─── System prompt fetch ──────────────────────────────────────────────────────
105
150
  // Pulls the fully-built system prompt from agent-api (via gateway proxy).
106
151
  // This MUST match the hosted runtime exactly — STATIC_SYSTEM_PROMPT + dynamic
@@ -436,14 +481,26 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
436
481
  parts.push(costStr);
437
482
  const suffix = ` ${parts.join(' ')}`;
438
483
  console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
439
- // Status-bar line, mirroring local Claude Code: model · context fill ·
440
- // session cost. Dimmed and indented so it groups under the done line
441
- // without competing with it. The cost segment falls back to "plan usage"
442
- // whenever the running total is 0 (the subscription case).
484
+ // Status-bar line, mirroring the local Claude Code statusline:
485
+ // 🤖 model v<version> | 🧠 context% | 💰 cost
486
+ // ⏱️ 5h <bar> n% resets | 7d <bar> n% resets
487
+ // Dimmed and indented so it groups under the done line without competing
488
+ // with it. The cost segment falls back to "plan usage" whenever the running
489
+ // total is 0 (the subscription case). The ⏱️ window line is only printed
490
+ // once the SDK has reported at least one window this process.
443
491
  sessionCostUsd += costUsd;
444
492
  const ctxPct = Math.max(0, Math.min(100, Math.round((contextTokens / contextWindowFor(model)) * 100)));
445
493
  const costSeg = sessionCostUsd > 0 ? `$${sessionCostUsd.toFixed(2)} session` : 'plan usage';
446
- console.log(paint('90', ` 🤖 ${friendlyModelName(model)} · 🧠 ${ctxPct}% · 💰 ${costSeg}`));
494
+ const ver = getCliVersion();
495
+ const verSeg = ver ? ` v${ver}` : '';
496
+ console.log(paint('90', ` 🤖 ${friendlyModelName(model)}${verSeg} | 🧠 ${ctxPct}% | 💰 ${costSeg}`));
497
+ const windows = [
498
+ formatWindow('5h', getRateLimitWindow('five_hour')),
499
+ formatWindow('7d', sevenDayWindow()),
500
+ ].filter((s) => s !== null);
501
+ if (windows.length > 0) {
502
+ console.log(` ${paint('90', '⏱️')} ${windows.join(paint('90', ' | '))}`);
503
+ }
447
504
  const mapped = toBridgeUsage(usage);
448
505
  if (currentWs?.readyState === WebSocket.OPEN) {
449
506
  currentWs.send(JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "type": "module",
6
6
  "bin": {