@1presence/bridge 0.45.0 → 0.50.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 CHANGED
@@ -32,7 +32,7 @@ Your OAuth tokens and vault data stay server-side — nothing sensitive is store
32
32
 
33
33
  ## Model
34
34
 
35
- On first run the bridge asks which Claude model you want it to use. Leave the prompt blank to defer to your local Claude Code default (recommended — typically Sonnet on a Pro subscription), or type a specific model id to pin it (e.g. `claude-opus-4-7`, `claude-sonnet-4-5`, `claude-haiku-4-5`). The choice is saved to `~/.1presence/config.json` and reused on every subsequent run.
35
+ On first run the bridge asks which Claude model you want it to use. Pick "Use Claude Code default" to defer to your local Claude Code default, or choose a specific model id to pin it (e.g. `claude-opus-4-8[1m]`, `claude-opus-4-8`, `claude-sonnet-4-6`, `claude-haiku-4-5`). The default selection (auto-chosen if the prompt times out) is `claude-opus-4-8[1m]` — the latest Opus with a 1M-token context window. The choice is saved to `~/.1presence/config.json` and reused on every subsequent run.
36
36
 
37
37
  The first reply each session prints the model and credential source so you can confirm what's running. To change your pick later, edit or delete `~/.1presence/config.json` — the bridge will prompt again on the next start.
38
38
 
package/dist/auth.js CHANGED
@@ -190,6 +190,21 @@ export async function ensureFreshToken(auth) {
190
190
  const newToken = await refreshIdToken(auth.refreshToken);
191
191
  return { ...auth, token: newToken };
192
192
  }
193
+ /**
194
+ * Mints a fresh ID token unconditionally, ignoring local expiry. Used on the
195
+ * reconnect path after the gateway has *rejected* our token (WS close 4001):
196
+ * the gateway's verdict beats our own clock, so we always refresh rather than
197
+ * trusting a token that still looks valid locally (e.g. clock skew). Returns
198
+ * the refreshed auth, or null when there is no refresh token to mint from —
199
+ * the caller must then fall back to an interactive re-sign-in. Throws only if
200
+ * the refresh endpoint itself rejects (refresh token revoked/expired).
201
+ */
202
+ export async function forceRefreshToken(auth) {
203
+ if (!auth.refreshToken)
204
+ return null;
205
+ const newToken = await refreshIdToken(auth.refreshToken);
206
+ return { ...auth, token: newToken };
207
+ }
193
208
  // ─── Public API ───────────────────────────────────────────────────────────────
194
209
  // No cache — every bridge launch goes through the browser flow. This means
195
210
  // permission revocations take effect on the next restart, and the PWA's
package/dist/claude.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync } from 'fs';
2
2
  import { tmpdir } from 'os';
3
3
  import { join } from 'path';
4
- import { query } from '@anthropic-ai/claude-agent-sdk';
4
+ import { z } from 'zod';
5
+ import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
6
+ import { resolveSessionFilePath } from './sessionPath.js';
5
7
  // ─── Engine ────────────────────────────────────────────────────────────────────
6
8
  //
7
9
  // The bridge drives the local Claude Code install through the Claude Agent SDK's
@@ -161,6 +163,27 @@ function describeCliFailure(apiErrorText, authFailure) {
161
163
  }
162
164
  return 'Local Mode stopped unexpectedly. Please try again.';
163
165
  }
166
+ /**
167
+ * Copy for an actionable rate-limit notice. The SDK emits `rate_limit_event`
168
+ * whenever rate-limit info CHANGES — including the routine `allowed` case on
169
+ * (nearly) every turn — so the caller must drop `allowed` and only invoke this
170
+ * for `allowed_warning` / `rejected`. `resetsAt` is a Unix timestamp; the SDK
171
+ * uses seconds, but we accept ms too in case that changes upstream.
172
+ */
173
+ function formatRateLimitNotice(status, resetsAt) {
174
+ const when = typeof resetsAt === 'number' && resetsAt > 0
175
+ ? new Date(resetsAt < 1e12 ? resetsAt * 1000 : resetsAt)
176
+ .toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
177
+ : null;
178
+ if (status === 'rejected') {
179
+ return when
180
+ ? `Pausing for an upstream rate limit — resumes around ${when}.`
181
+ : 'Pausing briefly for an upstream rate limit, then continuing…';
182
+ }
183
+ return when
184
+ ? `Approaching your usage limit — window resets around ${when}.`
185
+ : 'Approaching your usage limit for this period.';
186
+ }
164
187
  // ─── Prompt construction ─────────────────────────────────────────────────────────
165
188
  //
166
189
  // The gateway pushes the FULL conversation (sanitised via @presence/shared
@@ -274,6 +297,11 @@ export function spawnClaude(params) {
274
297
  // line's 🧠 segment reports against the model's window.
275
298
  let lastContextTokens = 0;
276
299
  let extractedModel = null;
300
+ // Captured from the SDK's system/init event; gates read_session_file so it can
301
+ // only reach files under THIS session's folder. Read by the in-process tool
302
+ // handler, which fires later (when the model invokes it), by which time init
303
+ // has arrived.
304
+ let currentSessionId = null;
277
305
  let killedForViolation = false;
278
306
  let sawApiError = false;
279
307
  let sawAuthFailure = false;
@@ -284,22 +312,28 @@ export function spawnClaude(params) {
284
312
  // runs before any execution) for anything that slips past. Our MCP tools are
285
313
  // auto-approved via allowedTools, so this callback only ever fires to deny.
286
314
  const canUseTool = async (toolName, input) => {
287
- if (toolName.startsWith('mcp__1presence__'))
315
+ // mcp__local__ is the in-process server (read_session_file) — local-only,
316
+ // mechanical, no network/pod hop. Everything else must be a 1Presence tool.
317
+ if (toolName.startsWith('mcp__1presence__') || toolName.startsWith('mcp__local__')) {
288
318
  return { behavior: 'allow', updatedInput: input };
319
+ }
289
320
  return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
290
321
  };
291
322
  // Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
292
323
  // credentials in the Keychain), not an API key that would bill a separate
293
324
  // account. Options.env REPLACES the subprocess env, so spread the rest through.
294
325
  const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
295
- // Raise Claude Code's MCP tool-result token cap (default 25,000). Over the cap,
296
- // the CLI saves the result to a tool-results/*.txt file and returns a stub that
297
- // says "read it with Read" but Local Mode disables every built-in tool
298
- // (`tools: []` below), so the model has no Read tool and stalls. Hosted mode has
299
- // no such cap (results go straight to the Anthropic SDK), so a large skill body
300
- // or vault read breaks Local Mode only a parity bug. Lifting the cap lets
301
- // legitimate 1Presence MCP results pass inline, matching hosted. An operator
302
- // override (env already set) wins. Bounded by the model's context window anyway.
326
+ // When an MCP tool result is "large", Claude Code spills the full result to a
327
+ // tool-results/*.json file under the session folder and hands the model a
328
+ // `<persisted-output>` stub telling it to read that file. Local Mode disables
329
+ // every built-in tool (`tools: []` below), so there is no Read historically
330
+ // the model reached for vault_read (which reads the cloud vault, not local
331
+ // disk) and stalled. The fix is the in-process read_session_file tool (see the
332
+ // `local` MCP server below), which reads the spilled file directly. We also
333
+ // best-effort raise MAX_MCP_OUTPUT_TOKENS so smaller large-ish results pass
334
+ // inline and never spill in the first place — but treat it as advisory: the
335
+ // env var is not honoured by every SDK version (it was a no-op on 0.3.x, which
336
+ // is what made read_session_file necessary). Operator override wins.
303
337
  if (!safeEnv['MAX_MCP_OUTPUT_TOKENS']) {
304
338
  safeEnv['MAX_MCP_OUTPUT_TOKENS'] = '200000';
305
339
  }
@@ -313,6 +347,9 @@ export function spawnClaude(params) {
313
347
  if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
314
348
  const keySource = event['apiKeySource'];
315
349
  const model = event['model'];
350
+ const sid = event['session_id'];
351
+ if (sid)
352
+ currentSessionId = sid;
316
353
  if (model)
317
354
  extractedModel = model;
318
355
  if (!modelAnnounced) {
@@ -367,8 +404,9 @@ export function spawnClaude(params) {
367
404
  // make a non-1Presence tool unreachable. If one appears anyway, kill
368
405
  // the turn so any side effect in flight is the only damage done.
369
406
  const isMcp1presence = toolName.startsWith('mcp__1presence__');
407
+ const isMcpLocal = toolName.startsWith('mcp__local__');
370
408
  const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
371
- if (!isMcp1presence && !isBareName) {
409
+ if (!isMcp1presence && !isMcpLocal && !isBareName) {
372
410
  killedForViolation = true;
373
411
  const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
374
412
  process.stderr.write(`[bridge] FATAL ${violation} — aborting\n`);
@@ -481,12 +519,48 @@ export function spawnClaude(params) {
481
519
  onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
482
520
  return;
483
521
  }
522
+ // In-process MCP server for purely-LOCAL operations that cannot run in the
523
+ // pod (the file lives on this machine, not the agent pod). Currently one
524
+ // tool: read_session_file, the recovery path for the SDK's large-output
525
+ // spill (see resolveSessionFilePath above). It is the ONLY way to read a
526
+ // file on the bridge host — built-ins stay disabled (`tools: []`). The tool
527
+ // body is mechanical (read a confined file); no product logic or secrets, so
528
+ // it is safe in this public package. alwaysLoad keeps it in the turn-1
529
+ // prompt instead of behind tool-search, so the model can use it the moment
530
+ // it is handed a persisted-output stub.
531
+ {
532
+ const localServer = createSdkMcpServer({
533
+ name: 'local',
534
+ version: '1.0.0',
535
+ tools: [
536
+ tool('read_session_file', 'Read a file from THIS Local Mode session\'s own working folder — e.g. a large tool result the runtime saved to a tool-results/*.json file and asked you to read. Read-only and confined to the current session directory. Use this (never vault_read, which reads your cloud vault) for any absolute local path the runtime hands you.', { path: z.string().describe('Absolute path to the file, exactly as the runtime gave it to you.') }, async (args) => {
537
+ const safe = resolveSessionFilePath(args.path, currentSessionId);
538
+ if (!safe) {
539
+ return {
540
+ content: [{ type: 'text', text: `Refused: "${args.path}" is outside this session's directory. Only files under the current session folder can be read.` }],
541
+ isError: true,
542
+ };
543
+ }
544
+ try {
545
+ return { content: [{ type: 'text', text: readFileSync(safe, 'utf-8') }] };
546
+ }
547
+ catch (err) {
548
+ return {
549
+ content: [{ type: 'text', text: `Could not read ${safe}: ${err.message}` }],
550
+ isError: true,
551
+ };
552
+ }
553
+ }, { alwaysLoad: true }),
554
+ ],
555
+ });
556
+ mcpServers = { ...mcpServers, local: localServer };
557
+ }
484
558
  const options = {
485
559
  systemPrompt, // custom string → replaces the default Claude Code prompt
486
560
  mcpServers: mcpServers,
487
561
  strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
488
562
  settingSources: [], // no user/project settings or memory
489
- allowedTools: ['mcp__1presence__*'], // auto-approve our MCP surface (no prompt)
563
+ allowedTools: ['mcp__1presence__*', 'mcp__local__read_session_file'], // auto-approve our MCP surface (no prompt)
490
564
  canUseTool, // hard deny anything else
491
565
  tools: [], // disable ALL built-in tools; MCP tools come via mcpServers and survive.
492
566
  // (The old `extraArgs: { tools: '' }` passed a malformed --tools "" that
@@ -568,9 +642,16 @@ export function spawnClaude(params) {
568
642
  break;
569
643
  }
570
644
  case 'rate_limit_event': {
571
- // SDK surfaces upstream rate-limit pauses; it retries internally.
572
- // Admin-only ephemeral notice jargon is fine in Local Mode.
573
- onNotice?.('Claude Code is pausing briefly for an upstream rate limit, then continuing…');
645
+ // The SDK emits this whenever rate-limit info CHANGES including the
646
+ // routine `allowed` case on (nearly) every turn, which previously
647
+ // spammed a phantom "pausing" notice (see vault/Bugs.md). Only surface
648
+ // a notice when the user is actually warned or throttled.
649
+ const info = m.rate_limit_info;
650
+ const status = info?.status;
651
+ if (status === 'allowed_warning' || status === 'rejected') {
652
+ // Admin-only ephemeral notice — jargon is fine in Local Mode.
653
+ onNotice?.(formatRateLimitNotice(status, info?.resetsAt));
654
+ }
574
655
  break;
575
656
  }
576
657
  // Everything else (partial messages, status, hooks, notifications,
package/dist/config.js CHANGED
@@ -78,10 +78,11 @@ function detectClaudeDefaultModel() {
78
78
  // `--model` flag passed to the subprocess).
79
79
  const MODEL_OPTIONS = [
80
80
  { num: 1, model: null, label: 'Use Claude Code default' },
81
- { num: 2, model: 'claude-opus-4-8', label: 'claude-opus-4-8' },
82
- { num: 3, model: 'claude-opus-4-7', label: 'claude-opus-4-7' },
83
- { num: 4, model: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6' },
84
- { num: 5, model: 'claude-haiku-4-5', label: 'claude-haiku-4-5' },
81
+ { num: 2, model: 'claude-opus-4-8[1m]', label: 'claude-opus-4-8[1m] (1M context)' },
82
+ { num: 3, model: 'claude-opus-4-8', label: 'claude-opus-4-8' },
83
+ { num: 4, model: 'claude-opus-4-7', label: 'claude-opus-4-7' },
84
+ { num: 5, model: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6' },
85
+ { num: 6, model: 'claude-haiku-4-5', label: 'claude-haiku-4-5' },
85
86
  ];
86
87
  const PROMPT_TIMEOUT_MS = 10_000;
87
88
  const DEFAULT_OPTION_NUM = 2;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { tmpdir } from 'os';
5
5
  import { join, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { createRequire } from 'module';
8
- import { getValidAuth, ensureFreshToken, isTokenValid, AuthCancelledError } from './auth.js';
8
+ import { getValidAuth, ensureFreshToken, forceRefreshToken, isTokenValid, AuthCancelledError } from './auth.js';
9
9
  import { spawnClaude, killAll, cancelConversation, setVerbose, setDebug, paint, SECTION_COLORS } from './claude.js';
10
10
  import { ensureModelChoice } from './config.js';
11
11
  import { checkAndUpdate } from './update.js';
@@ -49,6 +49,15 @@ let currentWs = null;
49
49
  // of the per-turn status line. On a pure subscription the CLI often reports a
50
50
  // per-turn cost of 0, in which case this stays at 0 and reads as "plan usage".
51
51
  let sessionCostUsd = 0;
52
+ // Consecutive gateway auth rejections (WS close 4001) since the last good
53
+ // connection. Reset on every successful `open`. A 4001 is almost always a
54
+ // Firebase ID token that expired while the bridge sat idle and a deploy/restart
55
+ // dropped the socket — recoverable by minting a fresh token. But if freshly
56
+ // minted tokens *keep* being rejected, the account itself is the problem
57
+ // (disabled/permission revoked): stop the refresh-and-retry loop and ask the
58
+ // user to sign in again rather than spinning forever.
59
+ let authRejections = 0;
60
+ const MAX_AUTH_REJECTIONS = 3;
52
61
  // ─── Status line ──────────────────────────────────────────────────────────────
53
62
  //
54
63
  // A compact line printed after each completed turn echoing the segments local
@@ -327,6 +336,8 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
327
336
  // Ephemeral, non-persisted thread notice (admin-only Local Mode). Relayed
328
337
  // by the gateway to the PWA SSE stream as a `notice` AgentEvent; it does
329
338
  // NOT go through the turn accumulator, so it never lands in history.
339
+ // Log it too: anything the user sees in chat must have a bridge-log trail.
340
+ console.log(`[${new Date().toLocaleTimeString()}] ⚠ notice: ${message}`);
330
341
  if (currentWs?.readyState === WebSocket.OPEN) {
331
342
  currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
332
343
  }
@@ -448,6 +459,9 @@ function connect(auth, retryDelay = 1000) {
448
459
  // Reset backoff so that a disconnect after a long-stable session
449
460
  // reconnects quickly instead of waiting at the 30s cap.
450
461
  retryDelay = 1000;
462
+ // A clean connection means whatever token we hold is accepted — clear the
463
+ // 4001 refresh-retry budget so a future expiry gets the full allowance.
464
+ authRejections = 0;
451
465
  console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
452
466
  startPing();
453
467
  // Drain any save records left behind by an earlier crashed/dropped session.
@@ -498,32 +512,17 @@ function connect(auth, retryDelay = 1000) {
498
512
  });
499
513
  ws.on('close', (code) => {
500
514
  stopPing();
501
- if (code === 4001) {
502
- console.error('Authentication failed. Please restart the bridge to sign in again.');
503
- process.exit(1);
504
- }
515
+ // Permission genuinely not granted — no token refresh can fix this, so it
516
+ // stays terminal.
505
517
  if (code === 4003) {
506
518
  console.error('Local Claude Code is not enabled for your account. To request access, email hello@1presence.com.');
507
519
  process.exit(1);
508
520
  }
509
- const delay = Math.min(retryDelay, 30_000);
510
- console.log(`Bridge disconnected (${code}). Reconnecting in ${delay / 1000}s…`);
511
- setTimeout(async () => {
512
- try {
513
- // Refresh setup files on reconnect in case token was refreshed.
514
- // If /system-prompt-for-bridge is down this throws — log and retry; the bridge
515
- // is useless without a current prompt so don't paper over it.
516
- if (currentAuth)
517
- await writeSetupFiles(currentAuth);
518
- connect(currentAuth, Math.min(retryDelay * 2, 30_000));
519
- }
520
- catch (err) {
521
- console.error(`[bridge] reconnect setup failed: ${err.message}`);
522
- console.error('[bridge] will retry connection anyway — system prompt may be stale until next refresh');
523
- if (currentAuth)
524
- connect(currentAuth, Math.min(retryDelay * 2, 30_000));
525
- }
526
- }, delay);
521
+ // Everything else — including 4001 (gateway rejected the JWT) — is handled
522
+ // by scheduleReconnect, which refreshes the token before reconnecting so an
523
+ // expired-token disconnect recovers on its own instead of forcing a manual
524
+ // bridge restart.
525
+ scheduleReconnect(code, retryDelay);
527
526
  });
528
527
  ws.on('error', (err) => {
529
528
  // close event fires after error — reconnect handled there
@@ -533,6 +532,78 @@ function connect(auth, retryDelay = 1000) {
533
532
  });
534
533
  return ws;
535
534
  }
535
+ // ─── Reconnect with token refresh ──────────────────────────────────────────────
536
+ //
537
+ // Schedules a reconnect after any non-terminal disconnect. The job this does
538
+ // that the old inline handler didn't: it mints a fresh ID token BEFORE
539
+ // reconnecting. Firebase ID tokens last ~1h, and the only place the bridge
540
+ // previously refreshed was per-turn (handleMessage). So an idle bridge whose
541
+ // socket dropped during a deploy/restart would reconnect carrying an expired
542
+ // token → 401 on the system-prompt fetch and 4001 on the new socket → the old
543
+ // handler then hard-exited with "please restart the bridge". Now it self-heals.
544
+ //
545
+ // • 4001 (gateway rejected the JWT): force a refresh — the gateway's verdict
546
+ // beats our local clock. Bounded by MAX_AUTH_REJECTIONS so a genuinely dead
547
+ // account (revoked permission, no refresh token) still exits instead of
548
+ // looping. Exits only when refresh is impossible or repeatedly rejected.
549
+ // • Any other code (1006 abnormal close, 1001 going-away on pod restart, …):
550
+ // refresh only if near expiry (ensureFreshToken). A transient refresh
551
+ // failure here is non-fatal — reconnect with the current token; if it's
552
+ // truly expired the gateway returns 4001 and the branch above handles it.
553
+ function scheduleReconnect(closeCode, retryDelay) {
554
+ const authFailure = closeCode === 4001;
555
+ const delay = Math.min(retryDelay, 30_000);
556
+ console.log(authFailure
557
+ ? `Authentication expired (${closeCode}). Refreshing token and reconnecting in ${delay / 1000}s…`
558
+ : `Bridge disconnected (${closeCode}). Reconnecting in ${delay / 1000}s…`);
559
+ setTimeout(async () => {
560
+ if (!currentAuth)
561
+ return;
562
+ const nextDelay = Math.min(retryDelay * 2, 30_000);
563
+ if (authFailure) {
564
+ authRejections++;
565
+ if (authRejections > MAX_AUTH_REJECTIONS) {
566
+ console.error('Authentication keeps being rejected even after refreshing — please restart the bridge to sign in again.');
567
+ process.exit(1);
568
+ }
569
+ try {
570
+ const refreshed = await forceRefreshToken(currentAuth);
571
+ if (!refreshed) {
572
+ console.error('Authentication failed and no refresh token is available — please restart the bridge to sign in again.');
573
+ process.exit(1);
574
+ }
575
+ currentAuth = refreshed;
576
+ console.log(`[bridge] token refreshed (attempt ${authRejections}/${MAX_AUTH_REJECTIONS}) — reconnecting with a new token.`);
577
+ }
578
+ catch (err) {
579
+ // Refresh endpoint rejected us — the refresh token is revoked/expired.
580
+ // No silent recovery is possible; the user must sign in again.
581
+ console.error(`[bridge] token refresh failed: ${err.message}`);
582
+ console.error('Please restart the bridge to sign in again.');
583
+ process.exit(1);
584
+ }
585
+ }
586
+ else {
587
+ try {
588
+ currentAuth = await ensureFreshToken(currentAuth);
589
+ }
590
+ catch (err) {
591
+ console.warn(`[bridge] token refresh on reconnect failed (proceeding with current token): ${err.message}`);
592
+ }
593
+ }
594
+ // Token is as fresh as we can make it — write setup files and reconnect.
595
+ // If /system-prompt-for-bridge is still 503/down (gateway waking) we
596
+ // reconnect anyway; handleMessage refreshes the prompt on the next turn.
597
+ try {
598
+ await writeSetupFiles(currentAuth);
599
+ }
600
+ catch (err) {
601
+ console.error(`[bridge] reconnect setup failed: ${err.message}`);
602
+ console.error('[bridge] will retry connection anyway — system prompt may be stale until next refresh');
603
+ }
604
+ connect(currentAuth, nextDelay);
605
+ }, delay);
606
+ }
536
607
  // ─── Main ─────────────────────────────────────────────────────────────────────
537
608
  async function main() {
538
609
  console.log(`1Presence Bridge v${version}\n`);
@@ -0,0 +1,31 @@
1
+ import { homedir } from 'os';
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
+ export function resolveSessionFilePath(requestedPath, sessionId, home = homedir()) {
21
+ if (!sessionId || !requestedPath)
22
+ return null;
23
+ const projectsRoot = join(home, '.claude', 'projects');
24
+ const resolved = resolve(requestedPath); // normalises away `..`
25
+ const underProjects = resolved === projectsRoot || resolved.startsWith(projectsRoot + sep);
26
+ if (!underProjects)
27
+ return null;
28
+ if (!resolved.split(sep).includes(sessionId))
29
+ return null;
30
+ return resolved;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.45.0",
3
+ "version": "0.50.0",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,12 +20,13 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@anthropic-ai/claude-agent-sdk": "^0.3.153",
23
- "ws": "^8.20.0"
23
+ "ws": "^8.20.0",
24
+ "zod": "^4.0.0"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/node": "^20.0.0",
27
28
  "@types/ws": "^8.18.1",
28
29
  "tsx": "^4.22.3",
29
- "typescript": "^5.5.0"
30
+ "typescript": "^6.0.3"
30
31
  }
31
32
  }