@1presence/bridge 0.38.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/auth.js +13 -7
- package/dist/claude.js +69 -13
- package/dist/index.js +91 -13
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -86,26 +86,32 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
|
|
|
86
86
|
res.setHeader('Access-Control-Allow-Origin', pwaOrigin);
|
|
87
87
|
}
|
|
88
88
|
res.setHeader('Vary', 'Origin');
|
|
89
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
89
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
90
90
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
91
91
|
if (req.method === 'OPTIONS') {
|
|
92
92
|
res.writeHead(204);
|
|
93
93
|
res.end();
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
|
-
if (req.method !== 'POST') {
|
|
97
|
-
res.writeHead(405);
|
|
98
|
-
res.end();
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
96
|
const reqUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
102
97
|
const path = reqUrl.pathname;
|
|
103
|
-
// Every
|
|
98
|
+
// Every request must carry the launch-specific nonce.
|
|
104
99
|
if (!checkNonce(reqUrl.searchParams.get('nonce'))) {
|
|
105
100
|
res.writeHead(403);
|
|
106
101
|
res.end();
|
|
107
102
|
return;
|
|
108
103
|
}
|
|
104
|
+
// Lets the PWA verify the bridge is still listening before POSTing the token.
|
|
105
|
+
if (req.method === 'GET' && (path === '/' || path === '')) {
|
|
106
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
107
|
+
res.end('1Presence bridge waiting for sign-in');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (req.method !== 'POST') {
|
|
111
|
+
res.writeHead(405);
|
|
112
|
+
res.end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
109
115
|
// Status beacon from the PWA — used so we exit early when the user closes
|
|
110
116
|
// the auth tab before signing in (sendBeacon path).
|
|
111
117
|
if (path === '/status') {
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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.
|
|
549
|
-
//
|
|
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
|
|
@@ -60,19 +106,43 @@ async function fetchSystemPrompt(token, agentSlug) {
|
|
|
60
106
|
// default 1Presence. Without it, every Local Mode turn was the generalist.
|
|
61
107
|
const agentParam = agentSlug ? `&agent=${encodeURIComponent(agentSlug)}` : '';
|
|
62
108
|
const url = `${GATEWAY_HTTP}/system-prompt-for-bridge?timezone=${encodeURIComponent(tz)}${agentParam}`;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
110
|
+
const maxAttempts = 8;
|
|
111
|
+
let res = null;
|
|
112
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
113
|
+
try {
|
|
114
|
+
res = await fetch(url, { headers });
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (attempt === maxAttempts) {
|
|
118
|
+
throw new Error(`fetch failed for ${url}: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (res.status === 503 || res.status === 502) {
|
|
124
|
+
const body = await res.text().catch(() => '');
|
|
125
|
+
let retryable = true;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(body);
|
|
128
|
+
retryable = parsed.error === 'agent_waking' || parsed.error === 'agent_unreachable';
|
|
129
|
+
}
|
|
130
|
+
catch { /* use default */ }
|
|
131
|
+
if (retryable && attempt < maxAttempts) {
|
|
132
|
+
const delayMs = Math.min(2_000 * attempt, 10_000);
|
|
133
|
+
console.log(`[bridge] agent pod waking (${res.status}), retrying system prompt in ${delayMs / 1000}s…`);
|
|
134
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const body = await res.text().catch(() => '<unreadable body>');
|
|
140
|
+
throw new Error(`/system-prompt-for-bridge returned ${res.status} ${res.statusText}: ${body.slice(0, 500)}`);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
75
143
|
}
|
|
144
|
+
if (!res)
|
|
145
|
+
throw new Error(`/system-prompt-for-bridge failed after ${maxAttempts} attempts`);
|
|
76
146
|
let data;
|
|
77
147
|
try {
|
|
78
148
|
data = await res.json();
|
|
@@ -255,7 +325,7 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
255
325
|
currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
|
|
256
326
|
}
|
|
257
327
|
},
|
|
258
|
-
onDone: (messageCount, costUsd, usage, model) => {
|
|
328
|
+
onDone: (messageCount, costUsd, usage, model, contextTokens) => {
|
|
259
329
|
const elapsed = (0, timer_1.stopTurnTimer)();
|
|
260
330
|
const parts = [(0, timer_1.formatElapsed)(elapsed)];
|
|
261
331
|
if (usage)
|
|
@@ -264,6 +334,14 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
264
334
|
parts.push(costStr);
|
|
265
335
|
const suffix = ` ${parts.join(' ')}`;
|
|
266
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}`));
|
|
267
345
|
const mapped = toBridgeUsage(usage);
|
|
268
346
|
if (currentWs?.readyState === ws_1.default.OPEN) {
|
|
269
347
|
currentWs.send(JSON.stringify({
|