@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/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
- // Running cost across all turns this process has handled, for the cost segment
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 { /* use default */ }
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; // gateway always supplies one; defensive fallback
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
- console.log(`✓ Bridge connected${who}. Local Mode active on all your devices.\n`);
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; // unknown — don't gate startup on a probe we can't trust
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 { /* 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;
@@ -17,9 +13,14 @@ function draw() {
17
13
  export function formatElapsed(seconds) {
18
14
  if (seconds < 60)
19
15
  return `${seconds}s`;
20
- const m = Math.floor(seconds / 60);
21
- const s = seconds % 60;
22
- return `${m}m ${s.toString().padStart(2, '0')}s`;
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
  }