@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/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 { /* use default */ }
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; // gateway always supplies one; defensive fallback
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
- console.log(paint('90', ` 🤖 ${friendlyModelName(model)}${verSeg} | 🧠 ${ctxPct}% | 💰 ${costSeg}`));
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: { Authorization: `Bearer ${auth.token}` },
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
- // Everything else — including 4001 (gateway rejected the JWT) — is handled
712
- // by scheduleReconnect, which refreshes the token before reconnecting so an
713
- // expired-token disconnect recovers on its own instead of forcing a manual
714
- // bridge restart.
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; // unknown — don't gate startup on a probe we can't trust
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 { /* already gone — fine */ }
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;
@@ -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); // normalises away `..`
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
  }