@1presence/bridge 0.72.0 → 0.74.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 +17 -289
- package/dist/claudeAuth.js +1 -38
- package/dist/config.js +1 -46
- package/dist/index.js +17 -219
- package/dist/outbox.js +1 -16
- package/dist/sessionPath.js +1 -18
- package/dist/timer.js +8 -7
- 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,46 +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
|
-
|
|
51
|
-
// of the per-turn status line. On a pure subscription the CLI often reports a
|
|
52
|
-
// per-turn cost of 0, in which case this stays at 0 and reads as "plan usage".
|
|
37
|
+
let disconnectedAt = null;
|
|
53
38
|
let sessionCostUsd = 0;
|
|
54
|
-
// Consecutive gateway auth rejections (WS close 4001) since the last good
|
|
55
|
-
// connection. Reset on every successful `open`. A 4001 is almost always a
|
|
56
|
-
// Firebase ID token that expired while the bridge sat idle and a deploy/restart
|
|
57
|
-
// dropped the socket — recoverable by minting a fresh token. But if freshly
|
|
58
|
-
// minted tokens *keep* being rejected, the account itself is the problem
|
|
59
|
-
// (disabled/permission revoked): stop the refresh-and-retry loop and ask the
|
|
60
|
-
// user to sign in again rather than spinning forever.
|
|
61
39
|
let authRejections = 0;
|
|
62
40
|
const MAX_AUTH_REJECTIONS = 3;
|
|
63
|
-
// ─── Status line ──────────────────────────────────────────────────────────────
|
|
64
|
-
//
|
|
65
|
-
// A compact line printed after each completed turn echoing the segments local
|
|
66
|
-
// Claude Code shows in its own status bar: model, context fill, and cost. The
|
|
67
|
-
// 5h/7d subscription rate-limit windows it also shows are deliberately absent —
|
|
68
|
-
// those ride in the API's rate-limit response HEADERS, which the bridge (a
|
|
69
|
-
// consumer of the CLI's stream-json stdout only) never sees. Display only.
|
|
70
|
-
// Raw model id (claude-opus-5, claude-opus-4-7, claude-sonnet-4-6-20250101) to
|
|
71
|
-
// friendly "Opus 5" / "Opus 4.7". Regex-based so new dated snapshots format
|
|
72
|
-
// without a table edit; an unrecognised shape falls back to the raw id rather
|
|
73
|
-
// than guessing. The minor version is capped at 2 digits so a dated snapshot on
|
|
74
|
-
// a single-number id (claude-opus-5-20260724) reads "Opus 5", not "Opus 5.2026…".
|
|
75
41
|
function friendlyModelName(model) {
|
|
76
42
|
if (!model)
|
|
77
43
|
return 'unknown';
|
|
@@ -81,21 +47,7 @@ function friendlyModelName(model) {
|
|
|
81
47
|
const family = `${m[1].charAt(0).toUpperCase()}${m[1].slice(1)}`;
|
|
82
48
|
return m[3] ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`;
|
|
83
49
|
}
|
|
84
|
-
// Context window (tokens) per model, for the context-fill estimate. Keyed by a
|
|
85
|
-
// family regex against the raw model id; first match wins, and an unrecognised
|
|
86
|
-
// id falls back to the Claude 4.x baseline rather than guessing high.
|
|
87
|
-
//
|
|
88
|
-
// Every model the bridge can currently run is 200k: Opus/Sonnet/Haiku 4.x are
|
|
89
|
-
// 200k on the standard path, and the 1M-context window is an API beta that the
|
|
90
|
-
// bridge's subscription print mode never opts into — so it does not apply here.
|
|
91
|
-
// When a model ships with a different standard window, add a row above the
|
|
92
|
-
// baseline; that one line keeps the estimate honest without touching anything
|
|
93
|
-
// else. (The percentage is of the raw window — local Claude's own gauge also
|
|
94
|
-
// reserves output headroom, so its reading runs a few points higher near full.)
|
|
95
50
|
const CONTEXT_WINDOWS = [
|
|
96
|
-
// 5-generation Opus/Sonnet + Mythos-class ship with a 1M window as the
|
|
97
|
-
// DEFAULT (not an opt-in beta), so the bridge's print mode gets it too.
|
|
98
|
-
// Haiku is deliberately excluded (4.5 is 200k and falls to the baseline).
|
|
99
51
|
{ match: /claude-(opus|sonnet|fable|mythos)-5/i, tokens: 1_000_000 },
|
|
100
52
|
{ match: /claude-(opus|sonnet|haiku)-4/i, tokens: 200_000 },
|
|
101
53
|
];
|
|
@@ -109,12 +61,6 @@ function contextWindowFor(model) {
|
|
|
109
61
|
}
|
|
110
62
|
return DEFAULT_CONTEXT_WINDOW;
|
|
111
63
|
}
|
|
112
|
-
// ─── Rate-limit window rendering ──────────────────────────────────────────────
|
|
113
|
-
// Mirrors the local Claude Code statusline's ⏱️ line: a 10-cell bar + used % +
|
|
114
|
-
// reset time per subscription window. Data comes from the SDK's rate_limit_event
|
|
115
|
-
// (captured across turns in claude.ts), so no auth/header probe is needed.
|
|
116
|
-
// 7-day usage may arrive as the generic `seven_day` window or a model-specific
|
|
117
|
-
// variant; surface whichever the SDK has reported, preferring the generic one.
|
|
118
64
|
function sevenDayWindow() {
|
|
119
65
|
return getRateLimitWindow('seven_day')
|
|
120
66
|
?? getRateLimitWindow('seven_day_opus')
|
|
@@ -125,11 +71,9 @@ function makeBar(pct) {
|
|
|
125
71
|
const filled = Math.max(0, Math.min(width, Math.floor((pct * width) / 100)));
|
|
126
72
|
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
127
73
|
}
|
|
128
|
-
// Green < 70%, yellow 70–89%, red ≥ 90% — same thresholds as the statusline.
|
|
129
74
|
function rlColor(pct) {
|
|
130
75
|
return pct >= 90 ? '31' : pct >= 70 ? '33' : '32';
|
|
131
76
|
}
|
|
132
|
-
// "12:20a.m." within a day, "Mon 11:00p.m." when the reset is > 24h out.
|
|
133
77
|
function formatReset(resetsAt) {
|
|
134
78
|
if (!resetsAt)
|
|
135
79
|
return '';
|
|
@@ -145,7 +89,6 @@ function formatReset(resetsAt) {
|
|
|
145
89
|
}
|
|
146
90
|
return time;
|
|
147
91
|
}
|
|
148
|
-
// One coloured "5h ██░░░░░░░░ 20% resets 12:20a.m." segment; null if unknown.
|
|
149
92
|
function formatWindow(label, w) {
|
|
150
93
|
if (!w)
|
|
151
94
|
return null;
|
|
@@ -154,19 +97,8 @@ function formatWindow(label, w) {
|
|
|
154
97
|
const seg = `${label} ${makeBar(pct)} ${pct}%${reset ? ` resets ${reset}` : ''}`;
|
|
155
98
|
return paint(rlColor(pct), seg);
|
|
156
99
|
}
|
|
157
|
-
// ─── System prompt fetch ──────────────────────────────────────────────────────
|
|
158
|
-
// Pulls the fully-built system prompt from agent-api (via gateway proxy).
|
|
159
|
-
// This MUST match the hosted runtime exactly — STATIC_SYSTEM_PROMPT + dynamic
|
|
160
|
-
// context (timezone, connector scopes, vault state, personal AGENT.md, skills,
|
|
161
|
-
// onboarding). There is intentionally NO fallback: if this fails the bridge
|
|
162
|
-
// must surface the error, not silently degrade to a different prompt source
|
|
163
|
-
// (which historically caused the "Skills section authoritative" rule to
|
|
164
|
-
// vanish and the agent to vault-hunt for skills).
|
|
165
100
|
async function fetchSystemPrompt(token, agentSlug) {
|
|
166
101
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
167
|
-
// Pass the selected agent slug so agent-api resolves THIS agent (identity,
|
|
168
|
-
// granted connectors, scoped memory) rather than always falling back to the
|
|
169
|
-
// default 1Presence. Without it, every Local Mode turn was the generalist.
|
|
170
102
|
const agentParam = agentSlug ? `&agent=${encodeURIComponent(agentSlug)}` : '';
|
|
171
103
|
const url = `${GATEWAY_HTTP}/system-prompt-for-bridge?timezone=${encodeURIComponent(tz)}${agentParam}`;
|
|
172
104
|
const headers = { Authorization: `Bearer ${token}` };
|
|
@@ -190,7 +122,7 @@ async function fetchSystemPrompt(token, agentSlug) {
|
|
|
190
122
|
const parsed = JSON.parse(body);
|
|
191
123
|
retryable = parsed.error === 'agent_waking' || parsed.error === 'agent_unreachable';
|
|
192
124
|
}
|
|
193
|
-
catch {
|
|
125
|
+
catch { }
|
|
194
126
|
if (retryable && attempt < maxAttempts) {
|
|
195
127
|
const delayMs = Math.min(2_000 * attempt, 10_000);
|
|
196
128
|
console.log(`[bridge] agent pod waking (${res.status}), retrying system prompt in ${delayMs / 1000}s…`);
|
|
@@ -218,15 +150,9 @@ async function fetchSystemPrompt(token, agentSlug) {
|
|
|
218
150
|
}
|
|
219
151
|
return data.text;
|
|
220
152
|
}
|
|
221
|
-
// ─── Setup files ──────────────────────────────────────────────────────────────
|
|
222
153
|
function tmpFile(name) {
|
|
223
154
|
return join(tmpdir(), name);
|
|
224
155
|
}
|
|
225
|
-
// Fetch the system prompt and write it to /tmp/agent-${uid}.md. The hosted
|
|
226
|
-
// runtime rebuilds buildSystemBlocks() per turn (dynamic context: vault state,
|
|
227
|
-
// connector status, palace, onboarding phase, skills) — call this per turn in
|
|
228
|
-
// the bridge too, otherwise newly shipped skills and mid-session vault writes
|
|
229
|
-
// never reach a long-running bridge. Throws on failure; caller must handle.
|
|
230
156
|
async function writeSystemPrompt(auth, agentSlug) {
|
|
231
157
|
const { uid, token } = auth;
|
|
232
158
|
const systemPrompt = await fetchSystemPrompt(token, agentSlug);
|
|
@@ -245,11 +171,6 @@ function writeMcpConfig(auth) {
|
|
|
245
171
|
type: 'sse',
|
|
246
172
|
url: `${GATEWAY_HTTP}/mcp`,
|
|
247
173
|
headers: { Authorization: `Bearer ${token}` },
|
|
248
|
-
// Force every 1Presence tool into the prompt from turn 1. Without this the
|
|
249
|
-
// SDK defers MCP tools behind tool-search, so the model sees tool *names*
|
|
250
|
-
// (from the system prompt) but has nothing callable and confabulates the
|
|
251
|
-
// call + result as text. Also blocks the turn until the MCP server connects
|
|
252
|
-
// (5s cap) — a loud failure beats a silent tool-less run. See vault/Bugs.md.
|
|
253
174
|
alwaysLoad: true,
|
|
254
175
|
},
|
|
255
176
|
},
|
|
@@ -260,24 +181,14 @@ async function writeSetupFiles(auth, agentSlug) {
|
|
|
260
181
|
await writeSystemPrompt(auth, agentSlug);
|
|
261
182
|
writeMcpConfig(auth);
|
|
262
183
|
}
|
|
263
|
-
// The MCP config embeds a Bearer JWT and the system prompt may contain vault
|
|
264
|
-
// state. writeFileSync's mode only takes effect on file creation — chmodSync
|
|
265
|
-
// covers the overwrite case so a legacy 0644 file gets tightened on next run.
|
|
266
184
|
function writeRestricted(path, data) {
|
|
267
185
|
writeFileSync(path, data, { mode: 0o600 });
|
|
268
186
|
chmodSync(path, 0o600);
|
|
269
187
|
}
|
|
270
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
271
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;
|
|
272
189
|
function isUuid(value) {
|
|
273
190
|
return UUID_RE.test(value);
|
|
274
191
|
}
|
|
275
|
-
// ─── Bridge vision reconstruction (no MCP, no chat turn) ──────────────────────
|
|
276
|
-
//
|
|
277
|
-
// A lightweight path for doc reproduction: the gateway sends a signed GCS URL +
|
|
278
|
-
// system prompt. We download the document and call query() with NO MCP tools —
|
|
279
|
-
// just a direct vision call using the user's claude.ai subscription. The whole
|
|
280
|
-
// reply is collected into one string and sent back as doc_recon_response.
|
|
281
192
|
async function handleDocReconRequest(req) {
|
|
282
193
|
const send = (payload) => {
|
|
283
194
|
if (currentWs?.readyState === WebSocket.OPEN)
|
|
@@ -302,8 +213,6 @@ async function handleDocReconRequest(req) {
|
|
|
302
213
|
const sourceBlock = isImage
|
|
303
214
|
? { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64 } }
|
|
304
215
|
: { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: base64 } };
|
|
305
|
-
// Strip API key so this uses the user's claude.ai subscription (same as every
|
|
306
|
-
// bridge turn). Spread the rest of the env through — options.env REPLACES.
|
|
307
216
|
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
308
217
|
if (!safeEnv['MAX_MCP_OUTPUT_TOKENS'])
|
|
309
218
|
safeEnv['MAX_MCP_OUTPUT_TOKENS'] = '200000';
|
|
@@ -322,9 +231,6 @@ async function handleDocReconRequest(req) {
|
|
|
322
231
|
permissionMode: 'default',
|
|
323
232
|
env: safeEnv,
|
|
324
233
|
};
|
|
325
|
-
// Cast through `unknown[]` like buildPromptMessages in claude.ts — the SDK's
|
|
326
|
-
// discriminated content-block union won't accept the inferred-`string` `type`
|
|
327
|
-
// discriminants on these literals, but the shape is correct at runtime.
|
|
328
234
|
const promptMessages = [{
|
|
329
235
|
type: 'user',
|
|
330
236
|
message: { role: 'user', content: [sourceBlock, { type: 'text', text: req.userText }] },
|
|
@@ -355,9 +261,7 @@ async function handleDocReconRequest(req) {
|
|
|
355
261
|
}
|
|
356
262
|
}
|
|
357
263
|
}
|
|
358
|
-
// ─── Handle a single incoming message (token refresh + spawn) ─────────────────
|
|
359
264
|
async function handleMessage(conversationId, text, sessionId, history, auth, vaultFileOpen, clientCapabilities, syncedFolders, agentSlug, model) {
|
|
360
|
-
// Refresh JWT if <10 min remaining before spawning Claude
|
|
361
265
|
let activeAuth = auth;
|
|
362
266
|
try {
|
|
363
267
|
const freshAuth = await ensureFreshToken(auth);
|
|
@@ -368,8 +272,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
368
272
|
}
|
|
369
273
|
}
|
|
370
274
|
catch (err) {
|
|
371
|
-
// If the cached token still has time, proceed — refresh was preemptive.
|
|
372
|
-
// If it's already invalid, MCP calls will 401 mid-turn — fail fast instead.
|
|
373
275
|
if (!isTokenValid(auth.token)) {
|
|
374
276
|
const message = 'Authentication expired and refresh failed — please restart the bridge to sign in again.';
|
|
375
277
|
stopTurnTimer();
|
|
@@ -381,13 +283,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
381
283
|
}
|
|
382
284
|
console.warn(`[bridge] token refresh failed (proceeding with current token): ${err.message}`);
|
|
383
285
|
}
|
|
384
|
-
// Refresh the system prompt on every turn — the hosted runtime rebuilds its
|
|
385
|
-
// dynamic context per turn (vault state, connector status, palace, onboarding
|
|
386
|
-
// phase, newly enabled skills). If this fails we abort the turn rather than
|
|
387
|
-
// silently reuse a stale snapshot — parity with agent-api is the whole point
|
|
388
|
-
// of bridge mode, and stale prompts have caused user-visible regressions
|
|
389
|
-
// (e.g. agent vault-hunting for skills because the Skills authoritative
|
|
390
|
-
// rule was missing from the previous snapshot).
|
|
391
286
|
try {
|
|
392
287
|
await writeSystemPrompt(activeAuth, agentSlug);
|
|
393
288
|
}
|
|
@@ -403,14 +298,7 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
403
298
|
let responding = false;
|
|
404
299
|
const accumulator = makeBridgeAccumulator();
|
|
405
300
|
const startedAt = Date.now();
|
|
406
|
-
const turnSessionId = sessionId ?? conversationId;
|
|
407
|
-
// The CLI's `--session-id` is treated as a "claim this new session ID"
|
|
408
|
-
// operation — passing the same UUID across turns of one chat (which is
|
|
409
|
-
// what the presence sessionId is) makes turn 2 fail with "Session ID X
|
|
410
|
-
// is already in use", even with --no-session-persistence. Use the
|
|
411
|
-
// per-spawn conversationId instead — continuity comes from history
|
|
412
|
-
// replay via --input-format stream-json, not from CLI session tracking.
|
|
413
|
-
// turnSessionId is still kept for spool records / log correlation.
|
|
301
|
+
const turnSessionId = sessionId ?? conversationId;
|
|
414
302
|
const claudePinnedSessionId = isUuid(conversationId) ? conversationId : crypto.randomUUID();
|
|
415
303
|
function buildSpoolRecord(usage, model) {
|
|
416
304
|
const s = accumulator.state();
|
|
@@ -431,21 +319,12 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
431
319
|
};
|
|
432
320
|
}
|
|
433
321
|
async function finalizeAndPost(record) {
|
|
434
|
-
// Spool BEFORE the network call so a crash between here and the POST
|
|
435
|
-
// ack is recoverable by drain-on-startup. The gateway dedupes on
|
|
436
|
-
// conversationId, so a replay is idempotent.
|
|
437
322
|
try {
|
|
438
323
|
writeSpool(record);
|
|
439
324
|
}
|
|
440
325
|
catch (err) {
|
|
441
326
|
console.warn(`[bridge] spool write failed: ${err.message}`);
|
|
442
327
|
}
|
|
443
|
-
// Refresh the token right before the POST — this fires at turn END, when the
|
|
444
|
-
// most time has elapsed. A long turn (started with a token that was fresh
|
|
445
|
-
// then) must not 401 on save-turn with a since-expired token — the failure
|
|
446
|
-
// that left a run stuck at 'running' on 2026-07-07. Best-effort: on refresh
|
|
447
|
-
// failure fall back to the current token; the spool file is retried on the
|
|
448
|
-
// next successful POST or on the next bridge startup.
|
|
449
328
|
try {
|
|
450
329
|
const fresh = await ensureFreshToken(activeAuth);
|
|
451
330
|
if (fresh.token !== activeAuth.token) {
|
|
@@ -461,8 +340,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
461
340
|
deleteSpool(record.conversationId);
|
|
462
341
|
}
|
|
463
342
|
else {
|
|
464
|
-
// Leave the spool file in place — next startup or next successful
|
|
465
|
-
// POST opportunity will retry. Quietly log so users aren't alarmed.
|
|
466
343
|
console.warn(`[bridge] save-turn POST failed (${result.status}): ${result.error ?? 'unknown'} — kept on disk for retry`);
|
|
467
344
|
}
|
|
468
345
|
}
|
|
@@ -487,10 +364,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
487
364
|
}
|
|
488
365
|
},
|
|
489
366
|
onNotice: (message) => {
|
|
490
|
-
// Ephemeral, non-persisted thread notice (admin-only Local Mode). Relayed
|
|
491
|
-
// by the gateway to the PWA SSE stream as a `notice` AgentEvent; it does
|
|
492
|
-
// NOT go through the turn accumulator, so it never lands in history.
|
|
493
|
-
// Log it too: anything the user sees in chat must have a bridge-log trail.
|
|
494
367
|
console.log(`[${new Date().toLocaleTimeString()}] ⚠ notice: ${message}`);
|
|
495
368
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
496
369
|
currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
|
|
@@ -505,13 +378,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
505
378
|
parts.push(costStr);
|
|
506
379
|
const suffix = ` ${parts.join(' ')}`;
|
|
507
380
|
console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
|
|
508
|
-
// Status-bar line, mirroring the local Claude Code statusline:
|
|
509
|
-
// 🤖 model v<version> | 🧠 context% | 💰 cost
|
|
510
|
-
// ⏱️ 5h <bar> n% resets … | 7d <bar> n% resets …
|
|
511
|
-
// Dimmed and indented so it groups under the done line without competing
|
|
512
|
-
// with it. The cost segment falls back to "plan usage" whenever the running
|
|
513
|
-
// total is 0 (the subscription case). The ⏱️ window line is only printed
|
|
514
|
-
// once the SDK has reported at least one window this process.
|
|
515
381
|
sessionCostUsd += costUsd;
|
|
516
382
|
const ctxPct = Math.max(0, Math.min(100, Math.round((contextTokens / contextWindowFor(model)) * 100)));
|
|
517
383
|
const costSeg = sessionCostUsd > 0 ? `$${sessionCostUsd.toFixed(2)} session` : 'plan usage';
|
|
@@ -536,7 +402,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
536
402
|
usage: mapped,
|
|
537
403
|
}));
|
|
538
404
|
}
|
|
539
|
-
// HTTP fallback runs unconditionally — gateway dedupes against WS path.
|
|
540
405
|
void finalizeAndPost(buildSpoolRecord(mapped, model));
|
|
541
406
|
},
|
|
542
407
|
onError: (message, usage, model) => {
|
|
@@ -556,7 +421,6 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
556
421
|
},
|
|
557
422
|
});
|
|
558
423
|
}
|
|
559
|
-
// ─── Usage shape adapter ──────────────────────────────────────────────────────
|
|
560
424
|
function toBridgeUsage(usage) {
|
|
561
425
|
if (!usage)
|
|
562
426
|
return null;
|
|
@@ -567,12 +431,6 @@ function toBridgeUsage(usage) {
|
|
|
567
431
|
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
568
432
|
};
|
|
569
433
|
}
|
|
570
|
-
// ─── Outbox drain ─────────────────────────────────────────────────────────────
|
|
571
|
-
//
|
|
572
|
-
// On bridge startup and on every successful reconnect, replay any spool
|
|
573
|
-
// records that didn't get a successful POST ack last time. The gateway
|
|
574
|
-
// dedupes on conversationId — if it already saved via the WS path, the
|
|
575
|
-
// reply is finalized=false and we still delete the spool.
|
|
576
434
|
async function drainOutbox(auth) {
|
|
577
435
|
const records = listSpool();
|
|
578
436
|
if (records.length === 0)
|
|
@@ -588,9 +446,6 @@ async function drainOutbox(auth) {
|
|
|
588
446
|
}
|
|
589
447
|
}
|
|
590
448
|
}
|
|
591
|
-
// ─── WebSocket connection ─────────────────────────────────────────────────────
|
|
592
|
-
// Application-level heartbeat — avoids relying on WebSocket control frames (ping/pong),
|
|
593
|
-
// which some proxies (GKE LB) may not forward reliably.
|
|
594
449
|
const PING_INTERVAL_MS = 30_000;
|
|
595
450
|
const PONG_TIMEOUT_MS = 10_000;
|
|
596
451
|
function connect(auth, retryDelay = 1000) {
|
|
@@ -622,18 +477,18 @@ function connect(auth, retryDelay = 1000) {
|
|
|
622
477
|
}
|
|
623
478
|
ws.on('open', () => {
|
|
624
479
|
currentWs = ws;
|
|
625
|
-
// Reset backoff so that a disconnect after a long-stable session
|
|
626
|
-
// reconnects quickly instead of waiting at the 30s cap.
|
|
627
480
|
retryDelay = 1000;
|
|
628
|
-
// A clean connection means whatever token we hold is accepted — clear the
|
|
629
|
-
// 4001 refresh-retry budget so a future expiry gets the full allowance.
|
|
630
481
|
authRejections = 0;
|
|
631
482
|
const who = auth.email ? ` as ${auth.email}` : '';
|
|
632
|
-
|
|
483
|
+
if (disconnectedAt !== null) {
|
|
484
|
+
const downFor = formatElapsed(Math.round((Date.now() - disconnectedAt) / 1000));
|
|
485
|
+
disconnectedAt = null;
|
|
486
|
+
console.log(`✓ Bridge reconnected${who} after ${downFor} offline. Local Mode active on all your devices.\n`);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
console.log(`✓ Bridge connected${who}. Local Mode active on all your devices.\n`);
|
|
490
|
+
}
|
|
633
491
|
startPing();
|
|
634
|
-
// Drain any save records left behind by an earlier crashed/dropped session.
|
|
635
|
-
// Fire-and-forget — the network is up, the gateway is reachable, and we
|
|
636
|
-
// don't want to block message handling on this.
|
|
637
492
|
if (currentAuth) {
|
|
638
493
|
drainOutbox(currentAuth).catch(err => console.warn(`[bridge] drain failed: ${err.message}`));
|
|
639
494
|
}
|
|
@@ -648,7 +503,6 @@ function connect(auth, retryDelay = 1000) {
|
|
|
648
503
|
console.error(`[bridge] failed to parse ws message as JSON: ${err.message} (raw: ${preview})`);
|
|
649
504
|
return;
|
|
650
505
|
}
|
|
651
|
-
// Application-level pong — clear the timeout
|
|
652
506
|
if (msg.type === 'pong') {
|
|
653
507
|
if (pongTimer) {
|
|
654
508
|
clearTimeout(pongTimer);
|
|
@@ -656,9 +510,6 @@ function connect(auth, retryDelay = 1000) {
|
|
|
656
510
|
}
|
|
657
511
|
return;
|
|
658
512
|
}
|
|
659
|
-
// Stop button: the gateway relays a cancel when the user abandons the turn
|
|
660
|
-
// (PWA→gateway connection dropped). Kill the local Claude Code process for
|
|
661
|
-
// this conversation so it stops generating instead of running to the end.
|
|
662
513
|
if (msg.type === 'cancel' && msg.conversationId) {
|
|
663
514
|
const cancelled = cancelConversation(msg.conversationId);
|
|
664
515
|
if (cancelled)
|
|
@@ -691,50 +542,28 @@ function connect(auth, retryDelay = 1000) {
|
|
|
691
542
|
});
|
|
692
543
|
ws.on('close', (code) => {
|
|
693
544
|
stopPing();
|
|
694
|
-
// Permission genuinely not granted — no token refresh can fix this, so it
|
|
695
|
-
// stays terminal.
|
|
696
545
|
if (code === 4003) {
|
|
697
546
|
console.error('Local Claude Code is not enabled for your account. To request access, email hello@1presence.com.');
|
|
698
547
|
process.exit(1);
|
|
699
548
|
}
|
|
700
|
-
// Everything else — including 4001 (gateway rejected the JWT) — is handled
|
|
701
|
-
// by scheduleReconnect, which refreshes the token before reconnecting so an
|
|
702
|
-
// expired-token disconnect recovers on its own instead of forcing a manual
|
|
703
|
-
// bridge restart.
|
|
704
549
|
scheduleReconnect(code, retryDelay);
|
|
705
550
|
});
|
|
706
551
|
ws.on('error', (err) => {
|
|
707
|
-
// close event fires after error — reconnect handled there
|
|
708
552
|
console.error(`[bridge] ws error: ${err.message}`);
|
|
709
553
|
if (VERBOSE && err.stack)
|
|
710
554
|
console.error(err.stack);
|
|
711
555
|
});
|
|
712
556
|
return ws;
|
|
713
557
|
}
|
|
714
|
-
// ─── Reconnect with token refresh ──────────────────────────────────────────────
|
|
715
|
-
//
|
|
716
|
-
// Schedules a reconnect after any non-terminal disconnect. The job this does
|
|
717
|
-
// that the old inline handler didn't: it mints a fresh ID token BEFORE
|
|
718
|
-
// reconnecting. Firebase ID tokens last ~1h, and the only place the bridge
|
|
719
|
-
// previously refreshed was per-turn (handleMessage). So an idle bridge whose
|
|
720
|
-
// socket dropped during a deploy/restart would reconnect carrying an expired
|
|
721
|
-
// token → 401 on the system-prompt fetch and 4001 on the new socket → the old
|
|
722
|
-
// handler then hard-exited with "please restart the bridge". Now it self-heals.
|
|
723
|
-
//
|
|
724
|
-
// • 4001 (gateway rejected the JWT): force a refresh — the gateway's verdict
|
|
725
|
-
// beats our local clock. Bounded by MAX_AUTH_REJECTIONS so a genuinely dead
|
|
726
|
-
// account (revoked permission, no refresh token) still exits instead of
|
|
727
|
-
// looping. Exits only when refresh is impossible or repeatedly rejected.
|
|
728
|
-
// • Any other code (1006 abnormal close, 1001 going-away on pod restart, …):
|
|
729
|
-
// refresh only if near expiry (ensureFreshToken). A transient refresh
|
|
730
|
-
// failure here is non-fatal — reconnect with the current token; if it's
|
|
731
|
-
// truly expired the gateway returns 4001 and the branch above handles it.
|
|
732
558
|
function scheduleReconnect(closeCode, retryDelay) {
|
|
733
559
|
const authFailure = closeCode === 4001;
|
|
734
560
|
const delay = Math.min(retryDelay, 30_000);
|
|
561
|
+
if (disconnectedAt === null)
|
|
562
|
+
disconnectedAt = Date.now();
|
|
563
|
+
const who = currentAuth?.email ? ` for ${currentAuth.email}` : '';
|
|
735
564
|
console.log(authFailure
|
|
736
|
-
? `Authentication expired (${closeCode}). Refreshing token and reconnecting in ${delay / 1000}s…`
|
|
737
|
-
: `Bridge disconnected (${closeCode}). Reconnecting in ${delay / 1000}s…`);
|
|
565
|
+
? `Authentication expired (${closeCode})${who}. Refreshing token and reconnecting in ${delay / 1000}s…`
|
|
566
|
+
: `Bridge disconnected (${closeCode})${who}. Reconnecting in ${delay / 1000}s…`);
|
|
738
567
|
setTimeout(async () => {
|
|
739
568
|
if (!currentAuth)
|
|
740
569
|
return;
|
|
@@ -755,8 +584,6 @@ function scheduleReconnect(closeCode, retryDelay) {
|
|
|
755
584
|
console.log(`[bridge] token refreshed (attempt ${authRejections}/${MAX_AUTH_REJECTIONS}) — reconnecting with a new token.`);
|
|
756
585
|
}
|
|
757
586
|
catch (err) {
|
|
758
|
-
// Refresh endpoint rejected us — the refresh token is revoked/expired.
|
|
759
|
-
// No silent recovery is possible; the user must sign in again.
|
|
760
587
|
console.error(`[bridge] token refresh failed: ${err.message}`);
|
|
761
588
|
console.error('Please restart the bridge to sign in again.');
|
|
762
589
|
process.exit(1);
|
|
@@ -770,9 +597,6 @@ function scheduleReconnect(closeCode, retryDelay) {
|
|
|
770
597
|
console.warn(`[bridge] token refresh on reconnect failed (proceeding with current token): ${err.message}`);
|
|
771
598
|
}
|
|
772
599
|
}
|
|
773
|
-
// Token is as fresh as we can make it — write setup files and reconnect.
|
|
774
|
-
// If /system-prompt-for-bridge is still 503/down (gateway waking) we
|
|
775
|
-
// reconnect anyway; handleMessage refreshes the prompt on the next turn.
|
|
776
600
|
try {
|
|
777
601
|
await writeSetupFiles(currentAuth);
|
|
778
602
|
}
|
|
@@ -783,19 +607,10 @@ function scheduleReconnect(closeCode, retryDelay) {
|
|
|
783
607
|
connect(currentAuth, nextDelay);
|
|
784
608
|
}, delay);
|
|
785
609
|
}
|
|
786
|
-
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
787
|
-
// Check the LOCAL Claude Code sign-in (the user's own claude.ai subscription
|
|
788
|
-
// OAuth, distinct from the 1Presence gateway login above) before connecting.
|
|
789
|
-
// Local Mode drives Claude Code through the Agent SDK, so if Claude Code isn't
|
|
790
|
-
// signed in every turn fails with the `local_auth` code (and pages ops). Catch
|
|
791
|
-
// it up front: probe, and if signed out, offer to sign in here — polling also
|
|
792
|
-
// picks up a sign-in the user does in another terminal, and the SDK re-reads
|
|
793
|
-
// credentials each turn, so no restart is needed once signed in. Any uncertainty
|
|
794
|
-
// (old CLI without `auth status`, probe error) is treated as "don't block".
|
|
795
610
|
async function ensureClaudeCodeLogin() {
|
|
796
611
|
const status = await probeClaudeAuth();
|
|
797
612
|
if (status === null)
|
|
798
|
-
return;
|
|
613
|
+
return;
|
|
799
614
|
if (status.loggedIn) {
|
|
800
615
|
const who = status.email ? ` as ${status.email}` : '';
|
|
801
616
|
const plan = status.subscriptionType ? ` (${status.subscriptionType})` : '';
|
|
@@ -813,9 +628,6 @@ async function ensureClaudeCodeLogin() {
|
|
|
813
628
|
console.log(' Skipping sign-in. Messages will fail until you run `claude auth login` — sign in any time and resend.\n');
|
|
814
629
|
return;
|
|
815
630
|
}
|
|
816
|
-
// Best-effort: open the sign-in right here. If the CLI is too old for
|
|
817
|
-
// `auth login`, this returns false and the poll still catches a sign-in the
|
|
818
|
-
// user does in another terminal.
|
|
819
631
|
const launched = await launchClaudeLogin();
|
|
820
632
|
if (!launched) {
|
|
821
633
|
console.log(' Couldn’t open sign-in automatically. In another terminal run: claude auth login');
|
|
@@ -844,19 +656,10 @@ async function main() {
|
|
|
844
656
|
}
|
|
845
657
|
if (await checkAndUpdate())
|
|
846
658
|
return;
|
|
847
|
-
// Auth
|
|
848
659
|
const auth = await getValidAuth(GATEWAY_HTTP, PWA_URL);
|
|
849
660
|
currentAuth = auth;
|
|
850
|
-
// One-time interactive model choice (only prompts on first run; saved to
|
|
851
|
-
// ~/.1presence/config.json). In a non-TTY environment this is a no-op and
|
|
852
|
-
// Claude Code's own default is used.
|
|
853
661
|
await ensureModelChoice();
|
|
854
|
-
// Local Claude Code sign-in check (see fn comment). Runs before setup/connect
|
|
855
|
-
// so a signed-out user is guided in up front rather than hitting a failed turn.
|
|
856
662
|
await ensureClaudeCodeLogin();
|
|
857
|
-
// Write system prompt + MCP config. If this fails the bridge is dead in the
|
|
858
|
-
// water — surface the underlying error rather than letting it bubble up as
|
|
859
|
-
// a generic "Fatal:" with no context.
|
|
860
663
|
process.stdout.write('Setting up…');
|
|
861
664
|
try {
|
|
862
665
|
await writeSetupFiles(auth);
|
|
@@ -868,9 +671,7 @@ async function main() {
|
|
|
868
671
|
process.exit(1);
|
|
869
672
|
}
|
|
870
673
|
process.stdout.write(' done.\n');
|
|
871
|
-
// Connect
|
|
872
674
|
connect(auth);
|
|
873
|
-
// Graceful shutdown
|
|
874
675
|
const shutdown = () => {
|
|
875
676
|
console.log('\nShutting down…');
|
|
876
677
|
killAll();
|
|
@@ -878,9 +679,6 @@ async function main() {
|
|
|
878
679
|
};
|
|
879
680
|
process.on('SIGINT', shutdown);
|
|
880
681
|
process.on('SIGTERM', shutdown);
|
|
881
|
-
// Surface anything that would otherwise vanish into the void. Without these,
|
|
882
|
-
// a thrown error inside an async callback (ws handler, child process event,
|
|
883
|
-
// setTimeout) silently kills the bridge with no diagnostic.
|
|
884
682
|
process.on('uncaughtException', (err) => {
|
|
885
683
|
console.error(`[bridge] uncaughtException: ${err.message}`);
|
|
886
684
|
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;
|
|
@@ -17,9 +13,14 @@ function draw() {
|
|
|
17
13
|
export function formatElapsed(seconds) {
|
|
18
14
|
if (seconds < 60)
|
|
19
15
|
return `${seconds}s`;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
if (seconds < 3600) {
|
|
17
|
+
const m = Math.floor(seconds / 60);
|
|
18
|
+
const s = seconds % 60;
|
|
19
|
+
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
|
20
|
+
}
|
|
21
|
+
const h = Math.floor(seconds / 3600);
|
|
22
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
23
|
+
return `${h}h ${m.toString().padStart(2, '0')}m`;
|
|
23
24
|
}
|
|
24
25
|
export function startTurnTimer() {
|
|
25
26
|
if (intervalId !== 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
|
}
|