@1presence/bridge 0.73.0 → 0.75.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/README.md +1 -1
- package/dist/accumulator.js +0 -17
- package/dist/auth.js +1 -55
- package/dist/claude.js +23 -290
- package/dist/claudeAuth.js +1 -38
- package/dist/config.js +1 -46
- package/dist/index.js +20 -223
- package/dist/outbox.js +1 -16
- package/dist/sessionPath.js +1 -18
- package/dist/timer.js +0 -4
- package/dist/update.js +0 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,14 +14,8 @@ import { checkAndUpdate } from './update.js';
|
|
|
14
14
|
import { makeBridgeAccumulator, postSaveTurn } from './accumulator.js';
|
|
15
15
|
import { writeSpool, deleteSpool, listSpool } from './outbox.js';
|
|
16
16
|
import { startTurnTimer, stopTurnTimer, formatElapsed } from './timer.js';
|
|
17
|
-
// ESM has no __dirname; derive it. JSON version is read via createRequire to
|
|
18
|
-
// avoid version-sensitive import assertions on a published CLI bin.
|
|
19
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
18
|
const { version } = createRequire(import.meta.url)('../package.json');
|
|
21
|
-
// Published tarballs don't ship src/, so this fires only when running the
|
|
22
|
-
// dist build from a live workspace checkout. Catches the trap where editing
|
|
23
|
-
// src/ without re-running tsc leaves you executing stale dist code — banner
|
|
24
|
-
// version matches package.json but behavior doesn't match the source.
|
|
25
19
|
if (__dirname.endsWith('dist')) {
|
|
26
20
|
const srcDir = join(__dirname, '..', 'src');
|
|
27
21
|
if (existsSync(srcDir)) {
|
|
@@ -32,50 +26,18 @@ if (__dirname.endsWith('dist')) {
|
|
|
32
26
|
}
|
|
33
27
|
}
|
|
34
28
|
}
|
|
35
|
-
// ─── CLI args ─────────────────────────────────────────────────────────────────
|
|
36
29
|
const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
37
|
-
// --debug renders a clean per-turn transcript (user prompt, assistant text,
|
|
38
|
-
// tool inputs, tool outputs) — the bridge equivalent of the chat's admin
|
|
39
|
-
// debug view. Unlike --verbose it does NOT dump the system prompt.
|
|
40
30
|
const DEBUG = process.argv.includes('--debug') || process.argv.includes('-d');
|
|
41
|
-
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
42
31
|
const GATEWAY_URL = process.env.BRIDGE_GATEWAY_URL ?? 'https://api.1presence.com';
|
|
43
32
|
const GATEWAY_WS = GATEWAY_URL.replace(/^https?:/, 'wss:').replace(/\/$/, '') + '/bridge';
|
|
44
33
|
const GATEWAY_HTTP = GATEWAY_URL.replace(/^wss?:/, 'https:').replace(/\/$/, '');
|
|
45
|
-
// PWA hosts the /cli-auth sign-in page; strip 'api.' subdomain to derive it from gateway URL
|
|
46
34
|
const PWA_URL = process.env.BRIDGE_PWA_URL ?? GATEWAY_HTTP.replace('://api.', '://');
|
|
47
|
-
// ─── In-memory state ──────────────────────────────────────────────────────────
|
|
48
35
|
let currentAuth = null;
|
|
49
36
|
let currentWs = null;
|
|
50
|
-
// When the socket first dropped, for reporting how long the bridge was offline
|
|
51
|
-
// once it reconnects. Set on the first disconnect of an outage (repeated failed
|
|
52
|
-
// reconnect attempts don't reset it), cleared on a successful open.
|
|
53
37
|
let disconnectedAt = null;
|
|
54
|
-
// Running cost across all turns this process has handled, for the cost segment
|
|
55
|
-
// of the per-turn status line. On a pure subscription the CLI often reports a
|
|
56
|
-
// per-turn cost of 0, in which case this stays at 0 and reads as "plan usage".
|
|
57
38
|
let sessionCostUsd = 0;
|
|
58
|
-
// Consecutive gateway auth rejections (WS close 4001) since the last good
|
|
59
|
-
// connection. Reset on every successful `open`. A 4001 is almost always a
|
|
60
|
-
// Firebase ID token that expired while the bridge sat idle and a deploy/restart
|
|
61
|
-
// dropped the socket — recoverable by minting a fresh token. But if freshly
|
|
62
|
-
// minted tokens *keep* being rejected, the account itself is the problem
|
|
63
|
-
// (disabled/permission revoked): stop the refresh-and-retry loop and ask the
|
|
64
|
-
// user to sign in again rather than spinning forever.
|
|
65
39
|
let authRejections = 0;
|
|
66
40
|
const MAX_AUTH_REJECTIONS = 3;
|
|
67
|
-
// ─── Status line ──────────────────────────────────────────────────────────────
|
|
68
|
-
//
|
|
69
|
-
// A compact line printed after each completed turn echoing the segments local
|
|
70
|
-
// Claude Code shows in its own status bar: model, context fill, and cost. The
|
|
71
|
-
// 5h/7d subscription rate-limit windows it also shows are deliberately absent —
|
|
72
|
-
// those ride in the API's rate-limit response HEADERS, which the bridge (a
|
|
73
|
-
// consumer of the CLI's stream-json stdout only) never sees. Display only.
|
|
74
|
-
// Raw model id (claude-opus-5, claude-opus-4-7, claude-sonnet-4-6-20250101) to
|
|
75
|
-
// friendly "Opus 5" / "Opus 4.7". Regex-based so new dated snapshots format
|
|
76
|
-
// without a table edit; an unrecognised shape falls back to the raw id rather
|
|
77
|
-
// than guessing. The minor version is capped at 2 digits so a dated snapshot on
|
|
78
|
-
// a single-number id (claude-opus-5-20260724) reads "Opus 5", not "Opus 5.2026…".
|
|
79
41
|
function friendlyModelName(model) {
|
|
80
42
|
if (!model)
|
|
81
43
|
return 'unknown';
|
|
@@ -85,21 +47,7 @@ function friendlyModelName(model) {
|
|
|
85
47
|
const family = `${m[1].charAt(0).toUpperCase()}${m[1].slice(1)}`;
|
|
86
48
|
return m[3] ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`;
|
|
87
49
|
}
|
|
88
|
-
// Context window (tokens) per model, for the context-fill estimate. Keyed by a
|
|
89
|
-
// family regex against the raw model id; first match wins, and an unrecognised
|
|
90
|
-
// id falls back to the Claude 4.x baseline rather than guessing high.
|
|
91
|
-
//
|
|
92
|
-
// Every model the bridge can currently run is 200k: Opus/Sonnet/Haiku 4.x are
|
|
93
|
-
// 200k on the standard path, and the 1M-context window is an API beta that the
|
|
94
|
-
// bridge's subscription print mode never opts into — so it does not apply here.
|
|
95
|
-
// When a model ships with a different standard window, add a row above the
|
|
96
|
-
// baseline; that one line keeps the estimate honest without touching anything
|
|
97
|
-
// else. (The percentage is of the raw window — local Claude's own gauge also
|
|
98
|
-
// reserves output headroom, so its reading runs a few points higher near full.)
|
|
99
50
|
const CONTEXT_WINDOWS = [
|
|
100
|
-
// 5-generation Opus/Sonnet + Mythos-class ship with a 1M window as the
|
|
101
|
-
// DEFAULT (not an opt-in beta), so the bridge's print mode gets it too.
|
|
102
|
-
// Haiku is deliberately excluded (4.5 is 200k and falls to the baseline).
|
|
103
51
|
{ match: /claude-(opus|sonnet|fable|mythos)-5/i, tokens: 1_000_000 },
|
|
104
52
|
{ match: /claude-(opus|sonnet|haiku)-4/i, tokens: 200_000 },
|
|
105
53
|
];
|
|
@@ -113,12 +61,6 @@ function contextWindowFor(model) {
|
|
|
113
61
|
}
|
|
114
62
|
return DEFAULT_CONTEXT_WINDOW;
|
|
115
63
|
}
|
|
116
|
-
// ─── Rate-limit window rendering ──────────────────────────────────────────────
|
|
117
|
-
// Mirrors the local Claude Code statusline's ⏱️ line: a 10-cell bar + used % +
|
|
118
|
-
// reset time per subscription window. Data comes from the SDK's rate_limit_event
|
|
119
|
-
// (captured across turns in claude.ts), so no auth/header probe is needed.
|
|
120
|
-
// 7-day usage may arrive as the generic `seven_day` window or a model-specific
|
|
121
|
-
// variant; surface whichever the SDK has reported, preferring the generic one.
|
|
122
64
|
function sevenDayWindow() {
|
|
123
65
|
return getRateLimitWindow('seven_day')
|
|
124
66
|
?? getRateLimitWindow('seven_day_opus')
|
|
@@ -129,11 +71,9 @@ function makeBar(pct) {
|
|
|
129
71
|
const filled = Math.max(0, Math.min(width, Math.floor((pct * width) / 100)));
|
|
130
72
|
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
131
73
|
}
|
|
132
|
-
// Green < 70%, yellow 70–89%, red ≥ 90% — same thresholds as the statusline.
|
|
133
74
|
function rlColor(pct) {
|
|
134
75
|
return pct >= 90 ? '31' : pct >= 70 ? '33' : '32';
|
|
135
76
|
}
|
|
136
|
-
// "12:20a.m." within a day, "Mon 11:00p.m." when the reset is > 24h out.
|
|
137
77
|
function formatReset(resetsAt) {
|
|
138
78
|
if (!resetsAt)
|
|
139
79
|
return '';
|
|
@@ -149,7 +89,6 @@ function formatReset(resetsAt) {
|
|
|
149
89
|
}
|
|
150
90
|
return time;
|
|
151
91
|
}
|
|
152
|
-
// One coloured "5h ██░░░░░░░░ 20% resets 12:20a.m." segment; null if unknown.
|
|
153
92
|
function formatWindow(label, w) {
|
|
154
93
|
if (!w)
|
|
155
94
|
return null;
|
|
@@ -158,19 +97,8 @@ function formatWindow(label, w) {
|
|
|
158
97
|
const seg = `${label} ${makeBar(pct)} ${pct}%${reset ? ` resets ${reset}` : ''}`;
|
|
159
98
|
return paint(rlColor(pct), seg);
|
|
160
99
|
}
|
|
161
|
-
// ─── System prompt fetch ──────────────────────────────────────────────────────
|
|
162
|
-
// Pulls the fully-built system prompt from agent-api (via gateway proxy).
|
|
163
|
-
// This MUST match the hosted runtime exactly — STATIC_SYSTEM_PROMPT + dynamic
|
|
164
|
-
// context (timezone, connector scopes, vault state, personal AGENT.md, skills,
|
|
165
|
-
// onboarding). There is intentionally NO fallback: if this fails the bridge
|
|
166
|
-
// must surface the error, not silently degrade to a different prompt source
|
|
167
|
-
// (which historically caused the "Skills section authoritative" rule to
|
|
168
|
-
// vanish and the agent to vault-hunt for skills).
|
|
169
100
|
async function fetchSystemPrompt(token, agentSlug) {
|
|
170
101
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
171
|
-
// Pass the selected agent slug so agent-api resolves THIS agent (identity,
|
|
172
|
-
// granted connectors, scoped memory) rather than always falling back to the
|
|
173
|
-
// default 1Presence. Without it, every Local Mode turn was the generalist.
|
|
174
102
|
const agentParam = agentSlug ? `&agent=${encodeURIComponent(agentSlug)}` : '';
|
|
175
103
|
const url = `${GATEWAY_HTTP}/system-prompt-for-bridge?timezone=${encodeURIComponent(tz)}${agentParam}`;
|
|
176
104
|
const headers = { Authorization: `Bearer ${token}` };
|
|
@@ -194,7 +122,7 @@ async function fetchSystemPrompt(token, agentSlug) {
|
|
|
194
122
|
const parsed = JSON.parse(body);
|
|
195
123
|
retryable = parsed.error === 'agent_waking' || parsed.error === 'agent_unreachable';
|
|
196
124
|
}
|
|
197
|
-
catch {
|
|
125
|
+
catch { }
|
|
198
126
|
if (retryable && attempt < maxAttempts) {
|
|
199
127
|
const delayMs = Math.min(2_000 * attempt, 10_000);
|
|
200
128
|
console.log(`[bridge] agent pod waking (${res.status}), retrying system prompt in ${delayMs / 1000}s…`);
|
|
@@ -222,15 +150,9 @@ async function fetchSystemPrompt(token, agentSlug) {
|
|
|
222
150
|
}
|
|
223
151
|
return data.text;
|
|
224
152
|
}
|
|
225
|
-
// ─── Setup files ──────────────────────────────────────────────────────────────
|
|
226
153
|
function tmpFile(name) {
|
|
227
154
|
return join(tmpdir(), name);
|
|
228
155
|
}
|
|
229
|
-
// Fetch the system prompt and write it to /tmp/agent-${uid}.md. The hosted
|
|
230
|
-
// runtime rebuilds buildSystemBlocks() per turn (dynamic context: vault state,
|
|
231
|
-
// connector status, palace, onboarding phase, skills) — call this per turn in
|
|
232
|
-
// the bridge too, otherwise newly shipped skills and mid-session vault writes
|
|
233
|
-
// never reach a long-running bridge. Throws on failure; caller must handle.
|
|
234
156
|
async function writeSystemPrompt(auth, agentSlug) {
|
|
235
157
|
const { uid, token } = auth;
|
|
236
158
|
const systemPrompt = await fetchSystemPrompt(token, agentSlug);
|
|
@@ -249,11 +171,6 @@ function writeMcpConfig(auth) {
|
|
|
249
171
|
type: 'sse',
|
|
250
172
|
url: `${GATEWAY_HTTP}/mcp`,
|
|
251
173
|
headers: { Authorization: `Bearer ${token}` },
|
|
252
|
-
// Force every 1Presence tool into the prompt from turn 1. Without this the
|
|
253
|
-
// SDK defers MCP tools behind tool-search, so the model sees tool *names*
|
|
254
|
-
// (from the system prompt) but has nothing callable and confabulates the
|
|
255
|
-
// call + result as text. Also blocks the turn until the MCP server connects
|
|
256
|
-
// (5s cap) — a loud failure beats a silent tool-less run. See vault/Bugs.md.
|
|
257
174
|
alwaysLoad: true,
|
|
258
175
|
},
|
|
259
176
|
},
|
|
@@ -264,24 +181,14 @@ async function writeSetupFiles(auth, agentSlug) {
|
|
|
264
181
|
await writeSystemPrompt(auth, agentSlug);
|
|
265
182
|
writeMcpConfig(auth);
|
|
266
183
|
}
|
|
267
|
-
// The MCP config embeds a Bearer JWT and the system prompt may contain vault
|
|
268
|
-
// state. writeFileSync's mode only takes effect on file creation — chmodSync
|
|
269
|
-
// covers the overwrite case so a legacy 0644 file gets tightened on next run.
|
|
270
184
|
function writeRestricted(path, data) {
|
|
271
185
|
writeFileSync(path, data, { mode: 0o600 });
|
|
272
186
|
chmodSync(path, 0o600);
|
|
273
187
|
}
|
|
274
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
275
188
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
276
189
|
function isUuid(value) {
|
|
277
190
|
return UUID_RE.test(value);
|
|
278
191
|
}
|
|
279
|
-
// ─── Bridge vision reconstruction (no MCP, no chat turn) ──────────────────────
|
|
280
|
-
//
|
|
281
|
-
// A lightweight path for doc reproduction: the gateway sends a signed GCS URL +
|
|
282
|
-
// system prompt. We download the document and call query() with NO MCP tools —
|
|
283
|
-
// just a direct vision call using the user's claude.ai subscription. The whole
|
|
284
|
-
// reply is collected into one string and sent back as doc_recon_response.
|
|
285
192
|
async function handleDocReconRequest(req) {
|
|
286
193
|
const send = (payload) => {
|
|
287
194
|
if (currentWs?.readyState === WebSocket.OPEN)
|
|
@@ -306,8 +213,6 @@ async function handleDocReconRequest(req) {
|
|
|
306
213
|
const sourceBlock = isImage
|
|
307
214
|
? { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64 } }
|
|
308
215
|
: { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: base64 } };
|
|
309
|
-
// Strip API key so this uses the user's claude.ai subscription (same as every
|
|
310
|
-
// bridge turn). Spread the rest of the env through — options.env REPLACES.
|
|
311
216
|
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
312
217
|
if (!safeEnv['MAX_MCP_OUTPUT_TOKENS'])
|
|
313
218
|
safeEnv['MAX_MCP_OUTPUT_TOKENS'] = '200000';
|
|
@@ -326,9 +231,6 @@ async function handleDocReconRequest(req) {
|
|
|
326
231
|
permissionMode: 'default',
|
|
327
232
|
env: safeEnv,
|
|
328
233
|
};
|
|
329
|
-
// Cast through `unknown[]` like buildPromptMessages in claude.ts — the SDK's
|
|
330
|
-
// discriminated content-block union won't accept the inferred-`string` `type`
|
|
331
|
-
// discriminants on these literals, but the shape is correct at runtime.
|
|
332
234
|
const promptMessages = [{
|
|
333
235
|
type: 'user',
|
|
334
236
|
message: { role: 'user', content: [sourceBlock, { type: 'text', text: req.userText }] },
|
|
@@ -359,9 +261,7 @@ async function handleDocReconRequest(req) {
|
|
|
359
261
|
}
|
|
360
262
|
}
|
|
361
263
|
}
|
|
362
|
-
// ─── Handle a single incoming message (token refresh + spawn) ─────────────────
|
|
363
264
|
async function handleMessage(conversationId, text, sessionId, history, auth, vaultFileOpen, clientCapabilities, syncedFolders, agentSlug, model) {
|
|
364
|
-
// Refresh JWT if <10 min remaining before spawning Claude
|
|
365
265
|
let activeAuth = auth;
|
|
366
266
|
try {
|
|
367
267
|
const freshAuth = await ensureFreshToken(auth);
|
|
@@ -372,8 +272,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
372
272
|
}
|
|
373
273
|
}
|
|
374
274
|
catch (err) {
|
|
375
|
-
// If the cached token still has time, proceed — refresh was preemptive.
|
|
376
|
-
// If it's already invalid, MCP calls will 401 mid-turn — fail fast instead.
|
|
377
275
|
if (!isTokenValid(auth.token)) {
|
|
378
276
|
const message = 'Authentication expired and refresh failed — please restart the bridge to sign in again.';
|
|
379
277
|
stopTurnTimer();
|
|
@@ -385,13 +283,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
385
283
|
}
|
|
386
284
|
console.warn(`[bridge] token refresh failed (proceeding with current token): ${err.message}`);
|
|
387
285
|
}
|
|
388
|
-
// Refresh the system prompt on every turn — the hosted runtime rebuilds its
|
|
389
|
-
// dynamic context per turn (vault state, connector status, palace, onboarding
|
|
390
|
-
// phase, newly enabled skills). If this fails we abort the turn rather than
|
|
391
|
-
// silently reuse a stale snapshot — parity with agent-api is the whole point
|
|
392
|
-
// of bridge mode, and stale prompts have caused user-visible regressions
|
|
393
|
-
// (e.g. agent vault-hunting for skills because the Skills authoritative
|
|
394
|
-
// rule was missing from the previous snapshot).
|
|
395
286
|
try {
|
|
396
287
|
await writeSystemPrompt(activeAuth, agentSlug);
|
|
397
288
|
}
|
|
@@ -407,14 +298,7 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
407
298
|
let responding = false;
|
|
408
299
|
const accumulator = makeBridgeAccumulator();
|
|
409
300
|
const startedAt = Date.now();
|
|
410
|
-
const turnSessionId = sessionId ?? conversationId;
|
|
411
|
-
// The CLI's `--session-id` is treated as a "claim this new session ID"
|
|
412
|
-
// operation — passing the same UUID across turns of one chat (which is
|
|
413
|
-
// what the presence sessionId is) makes turn 2 fail with "Session ID X
|
|
414
|
-
// is already in use", even with --no-session-persistence. Use the
|
|
415
|
-
// per-spawn conversationId instead — continuity comes from history
|
|
416
|
-
// replay via --input-format stream-json, not from CLI session tracking.
|
|
417
|
-
// turnSessionId is still kept for spool records / log correlation.
|
|
301
|
+
const turnSessionId = sessionId ?? conversationId;
|
|
418
302
|
const claudePinnedSessionId = isUuid(conversationId) ? conversationId : crypto.randomUUID();
|
|
419
303
|
function buildSpoolRecord(usage, model) {
|
|
420
304
|
const s = accumulator.state();
|
|
@@ -435,21 +319,12 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
435
319
|
};
|
|
436
320
|
}
|
|
437
321
|
async function finalizeAndPost(record) {
|
|
438
|
-
// Spool BEFORE the network call so a crash between here and the POST
|
|
439
|
-
// ack is recoverable by drain-on-startup. The gateway dedupes on
|
|
440
|
-
// conversationId, so a replay is idempotent.
|
|
441
322
|
try {
|
|
442
323
|
writeSpool(record);
|
|
443
324
|
}
|
|
444
325
|
catch (err) {
|
|
445
326
|
console.warn(`[bridge] spool write failed: ${err.message}`);
|
|
446
327
|
}
|
|
447
|
-
// Refresh the token right before the POST — this fires at turn END, when the
|
|
448
|
-
// most time has elapsed. A long turn (started with a token that was fresh
|
|
449
|
-
// then) must not 401 on save-turn with a since-expired token — the failure
|
|
450
|
-
// that left a run stuck at 'running' on 2026-07-07. Best-effort: on refresh
|
|
451
|
-
// failure fall back to the current token; the spool file is retried on the
|
|
452
|
-
// next successful POST or on the next bridge startup.
|
|
453
328
|
try {
|
|
454
329
|
const fresh = await ensureFreshToken(activeAuth);
|
|
455
330
|
if (fresh.token !== activeAuth.token) {
|
|
@@ -465,8 +340,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
465
340
|
deleteSpool(record.conversationId);
|
|
466
341
|
}
|
|
467
342
|
else {
|
|
468
|
-
// Leave the spool file in place — next startup or next successful
|
|
469
|
-
// POST opportunity will retry. Quietly log so users aren't alarmed.
|
|
470
343
|
console.warn(`[bridge] save-turn POST failed (${result.status}): ${result.error ?? 'unknown'} — kept on disk for retry`);
|
|
471
344
|
}
|
|
472
345
|
}
|
|
@@ -491,10 +364,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
491
364
|
}
|
|
492
365
|
},
|
|
493
366
|
onNotice: (message) => {
|
|
494
|
-
// Ephemeral, non-persisted thread notice (admin-only Local Mode). Relayed
|
|
495
|
-
// by the gateway to the PWA SSE stream as a `notice` AgentEvent; it does
|
|
496
|
-
// NOT go through the turn accumulator, so it never lands in history.
|
|
497
|
-
// Log it too: anything the user sees in chat must have a bridge-log trail.
|
|
498
367
|
console.log(`[${new Date().toLocaleTimeString()}] ⚠ notice: ${message}`);
|
|
499
368
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
500
369
|
currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
|
|
@@ -509,19 +378,13 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
509
378
|
parts.push(costStr);
|
|
510
379
|
const suffix = ` ${parts.join(' ')}`;
|
|
511
380
|
console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
|
|
512
|
-
// Status-bar line, mirroring the local Claude Code statusline:
|
|
513
|
-
// 🤖 model v<version> | 🧠 context% | 💰 cost
|
|
514
|
-
// ⏱️ 5h <bar> n% resets … | 7d <bar> n% resets …
|
|
515
|
-
// Dimmed and indented so it groups under the done line without competing
|
|
516
|
-
// with it. The cost segment falls back to "plan usage" whenever the running
|
|
517
|
-
// total is 0 (the subscription case). The ⏱️ window line is only printed
|
|
518
|
-
// once the SDK has reported at least one window this process.
|
|
519
381
|
sessionCostUsd += costUsd;
|
|
520
382
|
const ctxPct = Math.max(0, Math.min(100, Math.round((contextTokens / contextWindowFor(model)) * 100)));
|
|
521
383
|
const costSeg = sessionCostUsd > 0 ? `$${sessionCostUsd.toFixed(2)} session` : 'plan usage';
|
|
522
384
|
const ver = getCliVersion();
|
|
523
385
|
const verSeg = ver ? ` v${ver}` : '';
|
|
524
|
-
|
|
386
|
+
const whoSeg = currentAuth?.email ? ` | 👤 ${currentAuth.email}` : '';
|
|
387
|
+
console.log(paint('90', ` 🤖 ${friendlyModelName(model)}${verSeg} | 🧠 ${ctxPct}% | 💰 ${costSeg}${whoSeg}`));
|
|
525
388
|
const windows = [
|
|
526
389
|
formatWindow('5h', getRateLimitWindow('five_hour')),
|
|
527
390
|
formatWindow('7d', sevenDayWindow()),
|
|
@@ -540,7 +403,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
540
403
|
usage: mapped,
|
|
541
404
|
}));
|
|
542
405
|
}
|
|
543
|
-
// HTTP fallback runs unconditionally — gateway dedupes against WS path.
|
|
544
406
|
void finalizeAndPost(buildSpoolRecord(mapped, model));
|
|
545
407
|
},
|
|
546
408
|
onError: (message, usage, model) => {
|
|
@@ -560,7 +422,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
560
422
|
},
|
|
561
423
|
});
|
|
562
424
|
}
|
|
563
|
-
// ─── Usage shape adapter ──────────────────────────────────────────────────────
|
|
564
425
|
function toBridgeUsage(usage) {
|
|
565
426
|
if (!usage)
|
|
566
427
|
return null;
|
|
@@ -571,12 +432,6 @@ function toBridgeUsage(usage) {
|
|
|
571
432
|
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
572
433
|
};
|
|
573
434
|
}
|
|
574
|
-
// ─── Outbox drain ─────────────────────────────────────────────────────────────
|
|
575
|
-
//
|
|
576
|
-
// On bridge startup and on every successful reconnect, replay any spool
|
|
577
|
-
// records that didn't get a successful POST ack last time. The gateway
|
|
578
|
-
// dedupes on conversationId — if it already saved via the WS path, the
|
|
579
|
-
// reply is finalized=false and we still delete the spool.
|
|
580
435
|
async function drainOutbox(auth) {
|
|
581
436
|
const records = listSpool();
|
|
582
437
|
if (records.length === 0)
|
|
@@ -592,14 +447,15 @@ async function drainOutbox(auth) {
|
|
|
592
447
|
}
|
|
593
448
|
}
|
|
594
449
|
}
|
|
595
|
-
// ─── WebSocket connection ─────────────────────────────────────────────────────
|
|
596
|
-
// Application-level heartbeat — avoids relying on WebSocket control frames (ping/pong),
|
|
597
|
-
// which some proxies (GKE LB) may not forward reliably.
|
|
598
450
|
const PING_INTERVAL_MS = 30_000;
|
|
599
451
|
const PONG_TIMEOUT_MS = 10_000;
|
|
600
452
|
function connect(auth, retryDelay = 1000) {
|
|
601
453
|
const ws = new WebSocket(GATEWAY_WS, {
|
|
602
|
-
headers: {
|
|
454
|
+
headers: {
|
|
455
|
+
Authorization: `Bearer ${auth.token}`,
|
|
456
|
+
'X-Bridge-Capabilities': 'displace-4004',
|
|
457
|
+
'X-Bridge-Version': version,
|
|
458
|
+
},
|
|
603
459
|
});
|
|
604
460
|
let pingTimer = null;
|
|
605
461
|
let pongTimer = null;
|
|
@@ -626,11 +482,7 @@ function connect(auth, retryDelay = 1000) {
|
|
|
626
482
|
}
|
|
627
483
|
ws.on('open', () => {
|
|
628
484
|
currentWs = ws;
|
|
629
|
-
// Reset backoff so that a disconnect after a long-stable session
|
|
630
|
-
// reconnects quickly instead of waiting at the 30s cap.
|
|
631
485
|
retryDelay = 1000;
|
|
632
|
-
// A clean connection means whatever token we hold is accepted — clear the
|
|
633
|
-
// 4001 refresh-retry budget so a future expiry gets the full allowance.
|
|
634
486
|
authRejections = 0;
|
|
635
487
|
const who = auth.email ? ` as ${auth.email}` : '';
|
|
636
488
|
if (disconnectedAt !== null) {
|
|
@@ -642,9 +494,6 @@ function connect(auth, retryDelay = 1000) {
|
|
|
642
494
|
console.log(`✓ Bridge connected${who}. Local Mode active on all your devices.\n`);
|
|
643
495
|
}
|
|
644
496
|
startPing();
|
|
645
|
-
// Drain any save records left behind by an earlier crashed/dropped session.
|
|
646
|
-
// Fire-and-forget — the network is up, the gateway is reachable, and we
|
|
647
|
-
// don't want to block message handling on this.
|
|
648
497
|
if (currentAuth) {
|
|
649
498
|
drainOutbox(currentAuth).catch(err => console.warn(`[bridge] drain failed: ${err.message}`));
|
|
650
499
|
}
|
|
@@ -659,7 +508,6 @@ function connect(auth, retryDelay = 1000) {
|
|
|
659
508
|
console.error(`[bridge] failed to parse ws message as JSON: ${err.message} (raw: ${preview})`);
|
|
660
509
|
return;
|
|
661
510
|
}
|
|
662
|
-
// Application-level pong — clear the timeout
|
|
663
511
|
if (msg.type === 'pong') {
|
|
664
512
|
if (pongTimer) {
|
|
665
513
|
clearTimeout(pongTimer);
|
|
@@ -667,9 +515,6 @@ function connect(auth, retryDelay = 1000) {
|
|
|
667
515
|
}
|
|
668
516
|
return;
|
|
669
517
|
}
|
|
670
|
-
// Stop button: the gateway relays a cancel when the user abandons the turn
|
|
671
|
-
// (PWA→gateway connection dropped). Kill the local Claude Code process for
|
|
672
|
-
// this conversation so it stops generating instead of running to the end.
|
|
673
518
|
if (msg.type === 'cancel' && msg.conversationId) {
|
|
674
519
|
const cancelled = cancelConversation(msg.conversationId);
|
|
675
520
|
if (cancelled)
|
|
@@ -702,49 +547,32 @@ function connect(auth, retryDelay = 1000) {
|
|
|
702
547
|
});
|
|
703
548
|
ws.on('close', (code) => {
|
|
704
549
|
stopPing();
|
|
705
|
-
// Permission genuinely not granted — no token refresh can fix this, so it
|
|
706
|
-
// stays terminal.
|
|
707
550
|
if (code === 4003) {
|
|
708
551
|
console.error('Local Claude Code is not enabled for your account. To request access, email hello@1presence.com.');
|
|
709
552
|
process.exit(1);
|
|
710
553
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
554
|
+
if (code === 4004) {
|
|
555
|
+
if (ws !== currentWs) {
|
|
556
|
+
console.log('[bridge] ignoring a "replaced" close for a socket we already reconnected past.');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
console.log('\nAnother 1Presence bridge connected for this account — this one has exited.');
|
|
560
|
+
console.log('Local Mode runs one bridge per account: the newest connection serves your turns.');
|
|
561
|
+
killAll();
|
|
562
|
+
process.exit(0);
|
|
563
|
+
}
|
|
715
564
|
scheduleReconnect(code, retryDelay);
|
|
716
565
|
});
|
|
717
566
|
ws.on('error', (err) => {
|
|
718
|
-
// close event fires after error — reconnect handled there
|
|
719
567
|
console.error(`[bridge] ws error: ${err.message}`);
|
|
720
568
|
if (VERBOSE && err.stack)
|
|
721
569
|
console.error(err.stack);
|
|
722
570
|
});
|
|
723
571
|
return ws;
|
|
724
572
|
}
|
|
725
|
-
// ─── Reconnect with token refresh ──────────────────────────────────────────────
|
|
726
|
-
//
|
|
727
|
-
// Schedules a reconnect after any non-terminal disconnect. The job this does
|
|
728
|
-
// that the old inline handler didn't: it mints a fresh ID token BEFORE
|
|
729
|
-
// reconnecting. Firebase ID tokens last ~1h, and the only place the bridge
|
|
730
|
-
// previously refreshed was per-turn (handleMessage). So an idle bridge whose
|
|
731
|
-
// socket dropped during a deploy/restart would reconnect carrying an expired
|
|
732
|
-
// token → 401 on the system-prompt fetch and 4001 on the new socket → the old
|
|
733
|
-
// handler then hard-exited with "please restart the bridge". Now it self-heals.
|
|
734
|
-
//
|
|
735
|
-
// • 4001 (gateway rejected the JWT): force a refresh — the gateway's verdict
|
|
736
|
-
// beats our local clock. Bounded by MAX_AUTH_REJECTIONS so a genuinely dead
|
|
737
|
-
// account (revoked permission, no refresh token) still exits instead of
|
|
738
|
-
// looping. Exits only when refresh is impossible or repeatedly rejected.
|
|
739
|
-
// • Any other code (1006 abnormal close, 1001 going-away on pod restart, …):
|
|
740
|
-
// refresh only if near expiry (ensureFreshToken). A transient refresh
|
|
741
|
-
// failure here is non-fatal — reconnect with the current token; if it's
|
|
742
|
-
// truly expired the gateway returns 4001 and the branch above handles it.
|
|
743
573
|
function scheduleReconnect(closeCode, retryDelay) {
|
|
744
574
|
const authFailure = closeCode === 4001;
|
|
745
575
|
const delay = Math.min(retryDelay, 30_000);
|
|
746
|
-
// Stamp the start of the outage — only on the first drop, so the offline
|
|
747
|
-
// duration reported on reconnect spans failed retry cycles too.
|
|
748
576
|
if (disconnectedAt === null)
|
|
749
577
|
disconnectedAt = Date.now();
|
|
750
578
|
const who = currentAuth?.email ? ` for ${currentAuth.email}` : '';
|
|
@@ -771,8 +599,6 @@ function scheduleReconnect(closeCode, retryDelay) {
|
|
|
771
599
|
console.log(`[bridge] token refreshed (attempt ${authRejections}/${MAX_AUTH_REJECTIONS}) — reconnecting with a new token.`);
|
|
772
600
|
}
|
|
773
601
|
catch (err) {
|
|
774
|
-
// Refresh endpoint rejected us — the refresh token is revoked/expired.
|
|
775
|
-
// No silent recovery is possible; the user must sign in again.
|
|
776
602
|
console.error(`[bridge] token refresh failed: ${err.message}`);
|
|
777
603
|
console.error('Please restart the bridge to sign in again.');
|
|
778
604
|
process.exit(1);
|
|
@@ -786,9 +612,6 @@ function scheduleReconnect(closeCode, retryDelay) {
|
|
|
786
612
|
console.warn(`[bridge] token refresh on reconnect failed (proceeding with current token): ${err.message}`);
|
|
787
613
|
}
|
|
788
614
|
}
|
|
789
|
-
// Token is as fresh as we can make it — write setup files and reconnect.
|
|
790
|
-
// If /system-prompt-for-bridge is still 503/down (gateway waking) we
|
|
791
|
-
// reconnect anyway; handleMessage refreshes the prompt on the next turn.
|
|
792
615
|
try {
|
|
793
616
|
await writeSetupFiles(currentAuth);
|
|
794
617
|
}
|
|
@@ -799,19 +622,10 @@ function scheduleReconnect(closeCode, retryDelay) {
|
|
|
799
622
|
connect(currentAuth, nextDelay);
|
|
800
623
|
}, delay);
|
|
801
624
|
}
|
|
802
|
-
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
803
|
-
// Check the LOCAL Claude Code sign-in (the user's own claude.ai subscription
|
|
804
|
-
// OAuth, distinct from the 1Presence gateway login above) before connecting.
|
|
805
|
-
// Local Mode drives Claude Code through the Agent SDK, so if Claude Code isn't
|
|
806
|
-
// signed in every turn fails with the `local_auth` code (and pages ops). Catch
|
|
807
|
-
// it up front: probe, and if signed out, offer to sign in here — polling also
|
|
808
|
-
// picks up a sign-in the user does in another terminal, and the SDK re-reads
|
|
809
|
-
// credentials each turn, so no restart is needed once signed in. Any uncertainty
|
|
810
|
-
// (old CLI without `auth status`, probe error) is treated as "don't block".
|
|
811
625
|
async function ensureClaudeCodeLogin() {
|
|
812
626
|
const status = await probeClaudeAuth();
|
|
813
627
|
if (status === null)
|
|
814
|
-
return;
|
|
628
|
+
return;
|
|
815
629
|
if (status.loggedIn) {
|
|
816
630
|
const who = status.email ? ` as ${status.email}` : '';
|
|
817
631
|
const plan = status.subscriptionType ? ` (${status.subscriptionType})` : '';
|
|
@@ -829,9 +643,6 @@ async function ensureClaudeCodeLogin() {
|
|
|
829
643
|
console.log(' Skipping sign-in. Messages will fail until you run `claude auth login` — sign in any time and resend.\n');
|
|
830
644
|
return;
|
|
831
645
|
}
|
|
832
|
-
// Best-effort: open the sign-in right here. If the CLI is too old for
|
|
833
|
-
// `auth login`, this returns false and the poll still catches a sign-in the
|
|
834
|
-
// user does in another terminal.
|
|
835
646
|
const launched = await launchClaudeLogin();
|
|
836
647
|
if (!launched) {
|
|
837
648
|
console.log(' Couldn’t open sign-in automatically. In another terminal run: claude auth login');
|
|
@@ -860,19 +671,10 @@ async function main() {
|
|
|
860
671
|
}
|
|
861
672
|
if (await checkAndUpdate())
|
|
862
673
|
return;
|
|
863
|
-
// Auth
|
|
864
674
|
const auth = await getValidAuth(GATEWAY_HTTP, PWA_URL);
|
|
865
675
|
currentAuth = auth;
|
|
866
|
-
// One-time interactive model choice (only prompts on first run; saved to
|
|
867
|
-
// ~/.1presence/config.json). In a non-TTY environment this is a no-op and
|
|
868
|
-
// Claude Code's own default is used.
|
|
869
676
|
await ensureModelChoice();
|
|
870
|
-
// Local Claude Code sign-in check (see fn comment). Runs before setup/connect
|
|
871
|
-
// so a signed-out user is guided in up front rather than hitting a failed turn.
|
|
872
677
|
await ensureClaudeCodeLogin();
|
|
873
|
-
// Write system prompt + MCP config. If this fails the bridge is dead in the
|
|
874
|
-
// water — surface the underlying error rather than letting it bubble up as
|
|
875
|
-
// a generic "Fatal:" with no context.
|
|
876
678
|
process.stdout.write('Setting up…');
|
|
877
679
|
try {
|
|
878
680
|
await writeSetupFiles(auth);
|
|
@@ -884,9 +686,7 @@ async function main() {
|
|
|
884
686
|
process.exit(1);
|
|
885
687
|
}
|
|
886
688
|
process.stdout.write(' done.\n');
|
|
887
|
-
// Connect
|
|
888
689
|
connect(auth);
|
|
889
|
-
// Graceful shutdown
|
|
890
690
|
const shutdown = () => {
|
|
891
691
|
console.log('\nShutting down…');
|
|
892
692
|
killAll();
|
|
@@ -894,9 +694,6 @@ async function main() {
|
|
|
894
694
|
};
|
|
895
695
|
process.on('SIGINT', shutdown);
|
|
896
696
|
process.on('SIGTERM', shutdown);
|
|
897
|
-
// Surface anything that would otherwise vanish into the void. Without these,
|
|
898
|
-
// a thrown error inside an async callback (ws handler, child process event,
|
|
899
|
-
// setTimeout) silently kills the bridge with no diagnostic.
|
|
900
697
|
process.on('uncaughtException', (err) => {
|
|
901
698
|
console.error(`[bridge] uncaughtException: ${err.message}`);
|
|
902
699
|
if (err.stack)
|
package/dist/outbox.js
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, readdirSync, readFileSync, unlinkSync } from 'fs';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
// ─── On-disk turn spool ───────────────────────────────────────────────────────
|
|
5
|
-
//
|
|
6
|
-
// Each in-flight bridge turn writes a record to ~/.1presence/outbox/. The file
|
|
7
|
-
// exists from the moment the turn starts until the gateway acks the save-turn
|
|
8
|
-
// POST. If the bridge is killed (Ctrl+C, terminal closed, crash) between
|
|
9
|
-
// Claude finishing and the ack landing, the next startup drains the directory
|
|
10
|
-
// — covering the failure mode the WS+HTTP path alone can't.
|
|
11
|
-
//
|
|
12
|
-
// Records are keyed by conversationId, which the gateway also dedupes on, so
|
|
13
|
-
// a drained replay is idempotent: if it already saved via WS, the POST is a
|
|
14
|
-
// 200 no-op and the spool file is deleted.
|
|
15
|
-
//
|
|
16
|
-
// Payload mode 0600 — the file contains the user's assistant transcript and
|
|
17
|
-
// tool inputs. Tightened on every write to handle legacy world-readable files.
|
|
18
4
|
const OUTBOX_DIR = join(homedir(), '.1presence', 'outbox');
|
|
19
5
|
function ensureDir() {
|
|
20
6
|
mkdirSync(OUTBOX_DIR, { recursive: true });
|
|
@@ -30,7 +16,7 @@ export function deleteSpool(conversationId) {
|
|
|
30
16
|
try {
|
|
31
17
|
unlinkSync(pathFor(conversationId));
|
|
32
18
|
}
|
|
33
|
-
catch {
|
|
19
|
+
catch { }
|
|
34
20
|
}
|
|
35
21
|
export function listSpool() {
|
|
36
22
|
ensureDir();
|
|
@@ -43,7 +29,6 @@ export function listSpool() {
|
|
|
43
29
|
out.push(JSON.parse(raw));
|
|
44
30
|
}
|
|
45
31
|
catch {
|
|
46
|
-
// Malformed file — leave it alone so a human can inspect.
|
|
47
32
|
}
|
|
48
33
|
}
|
|
49
34
|
return out;
|
package/dist/sessionPath.js
CHANGED
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
import { homedir } from 'os';
|
|
2
2
|
import { join, resolve, sep } from 'path';
|
|
3
|
-
// Confinement guard for read_session_file (the in-process `local` MCP server in
|
|
4
|
-
// claude.ts). The Agent SDK occasionally spills a large MCP tool result to a
|
|
5
|
-
// local file under ~/.claude/projects/<cwd-slug>/<session-id>/ and hands the
|
|
6
|
-
// model a stub telling it to read the file. Local Mode disables every built-in
|
|
7
|
-
// tool, so the model has no Read — read_session_file is the recovery path. It
|
|
8
|
-
// MUST stay read-only and confined to the CURRENT session's own folder: file
|
|
9
|
-
// contents read here flow up through the gateway into the model and session
|
|
10
|
-
// history, so an unrestricted reader would leak the operator's machine
|
|
11
|
-
// (~/.ssh, other projects) into the transcript.
|
|
12
|
-
//
|
|
13
|
-
// We deliberately do NOT reconstruct Claude Code's lossy cwd-slug. Instead we
|
|
14
|
-
// require (a) the resolved path to live under ~/.claude/projects/ and (b) the
|
|
15
|
-
// current session UUID to appear as a discrete path segment. The UUID is
|
|
16
|
-
// unguessable, so this pins reads to this session alone while staying robust to
|
|
17
|
-
// changes in how Claude Code encodes the cwd.
|
|
18
|
-
//
|
|
19
|
-
// Returns the resolved absolute path when safe, or null when it must be refused.
|
|
20
3
|
export function resolveSessionFilePath(requestedPath, sessionId, home = homedir()) {
|
|
21
4
|
if (!sessionId || !requestedPath)
|
|
22
5
|
return null;
|
|
23
6
|
const projectsRoot = join(home, '.claude', 'projects');
|
|
24
|
-
const resolved = resolve(requestedPath);
|
|
7
|
+
const resolved = resolve(requestedPath);
|
|
25
8
|
const underProjects = resolved === projectsRoot || resolved.startsWith(projectsRoot + sep);
|
|
26
9
|
if (!underProjects)
|
|
27
10
|
return null;
|
package/dist/timer.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Live elapsed-time indicator for the active turn. Writes `\r\x1b[K⏱ Xs`
|
|
2
|
-
// once per second; wraps console.log/error/warn so that any other output
|
|
3
|
-
// clears the timer line before printing, then redraws the timer on the new
|
|
4
|
-
// bottom line. Idempotent — stop is safe to call multiple times.
|
|
5
1
|
let intervalId = null;
|
|
6
2
|
let startedAt = 0;
|
|
7
3
|
let originalLog = null;
|
package/dist/update.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
|
-
// ESM JSON imports need version-sensitive import assertions; createRequire reads
|
|
4
|
-
// the manifest synchronously on every Node 18+ without that fragility.
|
|
5
3
|
const { version } = createRequire(import.meta.url)('../package.json');
|
|
6
4
|
function isNewer(a, b) {
|
|
7
5
|
const pa = a.split('.').map(Number);
|
|
@@ -34,7 +32,6 @@ export async function checkAndUpdate() {
|
|
|
34
32
|
return true;
|
|
35
33
|
}
|
|
36
34
|
catch {
|
|
37
|
-
// Non-fatal — registry unreachable or fetch unavailable (old Node)
|
|
38
35
|
return false;
|
|
39
36
|
}
|
|
40
37
|
}
|