@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 +87 -2
- package/dist/index.js +63 -6
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
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
|
-
|
|
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({
|