@1presence/bridge 0.60.0 → 0.62.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/claude.js CHANGED
@@ -195,6 +195,35 @@ function joinErrorDetail(...parts) {
195
195
  // Lines the SDK/CLI writes to its own stderr that look like a failure — surfaced
196
196
  // even outside verbose mode so a turn that dies upstream leaves a trail.
197
197
  const SDK_STDERR_ERROR_RE = /\b(error|exception|fail(?:ed|ure)?|invalid|unauthor|forbidden|refus|denied|40[0-9]|429|5\d\d|overloaded|rate.?limit)\b/i;
198
+ // The remote 1Presence MCP server's key (matches index.ts writeMcpConfig +
199
+ // the `mcp__1presence__*` allowlist). reconnectMcpServer() takes this name.
200
+ const REMOTE_MCP_SERVER_NAME = '1presence';
201
+ // Signature of a dropped remote MCP session. The pod binds each MCP session to a
202
+ // single long-lived SSE `GET /mcp` stream held in its memory; if that stream
203
+ // drops mid-run (transient network blip on a long workflow stage), the pod
204
+ // deletes the session and every later `POST /mcp/message?sessionId=…` returns
205
+ // HTTP 404 "session not found or expired". The SDK does NOT auto-retry an
206
+ // expired MCP session (sdk.d.ts: "Session expiry is not retried automatically;
207
+ // callers can mcp_reconnect and retry") — it surfaces the 404 as a tool_result
208
+ // error to the model, which then dead-loops calling tools against the same dead
209
+ // session for the rest of the turn. Detecting this lets us reconnect the server
210
+ // so the model's next attempt re-handshakes a fresh session and recovers.
211
+ const MCP_SESSION_EXPIRED_RE = /session not found or expired|Error POSTing to endpoint \(HTTP 404\)/i;
212
+ // Pull every text fragment out of a tool_result `content` (string OR the MCP
213
+ // block-array shape `[{type:'text',text:'…'}]`) so we can scan it for the
214
+ // session-expiry signature above.
215
+ function toolResultText(content) {
216
+ if (typeof content === 'string')
217
+ return content;
218
+ if (Array.isArray(content)) {
219
+ return content
220
+ .map((b) => (b && typeof b === 'object' && typeof b.text === 'string'
221
+ ? b.text
222
+ : ''))
223
+ .join(' ');
224
+ }
225
+ return '';
226
+ }
198
227
  /**
199
228
  * Copy for an actionable rate-limit notice. The SDK emits `rate_limit_event`
200
229
  * whenever rate-limit info CHANGES — including the routine `allowed` case on
@@ -649,8 +678,28 @@ export function spawnClaude(params) {
649
678
  ...(pinnedModel ? { model: pinnedModel } : {}),
650
679
  };
651
680
  const promptMessages = buildPromptMessages(history);
681
+ // Throttle remote-MCP reconnects: a single dropped session produces a burst
682
+ // of 404 tool_results (the model keeps trying), so reconnect at most once per
683
+ // window to avoid a reconnect storm. A reconnect is in-flight guard too.
684
+ let lastMcpReconnectAt = 0;
685
+ let mcpReconnecting = false;
686
+ const MCP_RECONNECT_THROTTLE_MS = 10_000;
687
+ const maybeReconnectRemoteMcp = (q) => {
688
+ const now = Date.now();
689
+ if (mcpReconnecting || now - lastMcpReconnectAt < MCP_RECONNECT_THROTTLE_MS)
690
+ return;
691
+ mcpReconnecting = true;
692
+ lastMcpReconnectAt = now;
693
+ process.stderr.write(paint(SECTION_COLORS.result, '[bridge] remote MCP session expired — reconnecting and retrying') + '\n');
694
+ onNotice?.('Reconnecting to 1Presence tools…');
695
+ void q.reconnectMcpServer(REMOTE_MCP_SERVER_NAME)
696
+ .then(() => process.stderr.write(paint(SECTION_COLORS.result, '[bridge] remote MCP reconnected') + '\n'))
697
+ .catch((err) => process.stderr.write(paint(SECTION_COLORS.result, `[bridge] remote MCP reconnect failed: ${err.message}`) + '\n'))
698
+ .finally(() => { mcpReconnecting = false; });
699
+ };
652
700
  try {
653
- for await (const m of query({ prompt: promptStream(promptMessages), options })) {
701
+ const q = query({ prompt: promptStream(promptMessages), options });
702
+ for await (const m of q) {
654
703
  // Skip echoed input replays — they would double-count in the accumulator
655
704
  // and re-stream prior turns to the PWA.
656
705
  if (m.isReplay)
@@ -716,6 +765,19 @@ export function spawnClaude(params) {
716
765
  }
717
766
  case 'user': {
718
767
  const um = m;
768
+ // Detect a dropped remote-MCP session in any tool_result and re-handshake
769
+ // so the model's next tool call lands on a live session instead of
770
+ // 404-looping for the rest of the turn (the SDK won't auto-retry it).
771
+ const content = um.message?.['content'];
772
+ if (Array.isArray(content)) {
773
+ for (const block of content) {
774
+ if (block && typeof block === 'object' && block['type'] === 'tool_result'
775
+ && MCP_SESSION_EXPIRED_RE.test(toolResultText(block['content']))) {
776
+ maybeReconnectRemoteMcp(q);
777
+ break;
778
+ }
779
+ }
780
+ }
719
781
  const event = { type: 'user', message: um.message };
720
782
  if (handleEvent(event))
721
783
  onEvent(event);
@@ -0,0 +1,130 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ /**
4
+ * Parse the stdout of `claude auth status --json`. Extracts the first JSON object
5
+ * (defensive against any banner/log noise around it). Returns null when there is
6
+ * no parseable object or no boolean `loggedIn` field — i.e. an output shape we
7
+ * don't recognise, which the caller treats as "unknown, don't block".
8
+ * Exported for unit testing.
9
+ */
10
+ export function parseAuthStatus(stdout) {
11
+ const start = stdout.indexOf('{');
12
+ const end = stdout.lastIndexOf('}');
13
+ if (start === -1 || end === -1 || end < start)
14
+ return null;
15
+ try {
16
+ const j = JSON.parse(stdout.slice(start, end + 1));
17
+ if (typeof j['loggedIn'] !== 'boolean')
18
+ return null;
19
+ return {
20
+ loggedIn: j['loggedIn'],
21
+ email: typeof j['email'] === 'string' ? j['email'] : undefined,
22
+ authMethod: typeof j['authMethod'] === 'string' ? j['authMethod'] : undefined,
23
+ subscriptionType: typeof j['subscriptionType'] === 'string' ? j['subscriptionType'] : undefined,
24
+ orgName: typeof j['orgName'] === 'string' ? j['orgName'] : undefined,
25
+ };
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * Ask the local `claude` CLI whether it's signed in via `claude auth status
33
+ * --json`. Zero-cost — no model turn runs. Resolves the parsed status, or null
34
+ * when we can't determine it (CLI not installed, too old for `auth status`,
35
+ * errored, or timed out). null means "unknown": a probe we can't trust must
36
+ * never gate startup — the first turn's error path still guides the user if auth
37
+ * is genuinely broken.
38
+ */
39
+ export function probeClaudeAuth(timeoutMs = 8000) {
40
+ return new Promise((resolve) => {
41
+ let settled = false;
42
+ const finish = (v) => { if (!settled) {
43
+ settled = true;
44
+ resolve(v);
45
+ } };
46
+ let proc;
47
+ try {
48
+ proc = spawn('claude', ['auth', 'status', '--json'], { stdio: ['ignore', 'pipe', 'ignore'] });
49
+ }
50
+ catch {
51
+ finish(null);
52
+ return;
53
+ }
54
+ const timer = setTimeout(() => { try {
55
+ proc.kill('SIGKILL');
56
+ }
57
+ catch { /* already gone */ } finish(null); }, timeoutMs);
58
+ let out = '';
59
+ proc.stdout?.on('data', (c) => { out += c.toString('utf-8'); });
60
+ proc.on('error', () => { clearTimeout(timer); finish(null); });
61
+ proc.on('close', () => { clearTimeout(timer); finish(parseAuthStatus(out)); });
62
+ });
63
+ }
64
+ /**
65
+ * Launch the local Claude Code subscription sign-in (`claude auth login
66
+ * --claudeai`) with inherited stdio, so the user completes the browser / paste
67
+ * flow right here. Resolves true when the child exits (any exit code — the caller
68
+ * re-probes to learn the outcome), or false if it can't be spawned at all (CLI
69
+ * missing / too old for `auth login`), in which case the poll below still carries
70
+ * a sign-in the user does in another terminal.
71
+ */
72
+ export function launchClaudeLogin() {
73
+ return new Promise((resolve) => {
74
+ let proc;
75
+ try {
76
+ proc = spawn('claude', ['auth', 'login', '--claudeai'], { stdio: 'inherit' });
77
+ }
78
+ catch {
79
+ resolve(false);
80
+ return;
81
+ }
82
+ proc.on('error', () => resolve(false));
83
+ proc.on('close', () => resolve(true));
84
+ });
85
+ }
86
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
87
+ /**
88
+ * Poll `claude auth status` until it reports signed-in, or the timeout elapses.
89
+ * This is also what catches a sign-in the user completes in ANOTHER terminal
90
+ * window: on macOS the credential lives in the Keychain, which no process can
91
+ * watch, so polling is the only cross-platform detector. The Agent SDK re-reads
92
+ * credentials on every query(), so once this returns true the very next turn
93
+ * works with no bridge restart. `onWaiting` fires once, the first time we find
94
+ * the user still isn't signed in (so the caller can print a waiting line without
95
+ * spamming it). Returns true if signed in, false on timeout.
96
+ */
97
+ export async function waitForClaudeLogin(opts = {}) {
98
+ const intervalMs = opts.intervalMs ?? 3000;
99
+ const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000;
100
+ const deadline = Date.now() + timeoutMs;
101
+ let announced = false;
102
+ for (;;) {
103
+ const status = await probeClaudeAuth();
104
+ if (status?.loggedIn)
105
+ return true;
106
+ if (Date.now() >= deadline)
107
+ return false;
108
+ if (!announced) {
109
+ opts.onWaiting?.();
110
+ announced = true;
111
+ }
112
+ await sleep(intervalMs);
113
+ }
114
+ }
115
+ /**
116
+ * Minimal Y/n prompt. Returns the boolean answer; empty input returns `def`.
117
+ * Caller is responsible for only invoking this when stdin is a TTY.
118
+ */
119
+ export function promptYesNo(question, def) {
120
+ return new Promise((resolve) => {
121
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
122
+ rl.question(question, (answer) => {
123
+ rl.close();
124
+ const a = answer.trim().toLowerCase();
125
+ if (a === '')
126
+ return resolve(def);
127
+ resolve(a === 'y' || a === 'yes');
128
+ });
129
+ });
130
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
9
9
  import { getValidAuth, ensureFreshToken, forceRefreshToken, isTokenValid, AuthCancelledError } from './auth.js';
10
10
  import { spawnClaude, killAll, cancelConversation, setVerbose, setDebug, paint, SECTION_COLORS, getCliVersion, getRateLimitWindow } from './claude.js';
11
11
  import { ensureModelChoice } from './config.js';
12
+ import { probeClaudeAuth, launchClaudeLogin, waitForClaudeLogin, promptYesNo } from './claudeAuth.js';
12
13
  import { checkAndUpdate } from './update.js';
13
14
  import { makeBridgeAccumulator, postSaveTurn } from './accumulator.js';
14
15
  import { writeSpool, deleteSpool, listSpool } from './outbox.js';
@@ -759,6 +760,54 @@ function scheduleReconnect(closeCode, retryDelay) {
759
760
  }, delay);
760
761
  }
761
762
  // ─── Main ─────────────────────────────────────────────────────────────────────
763
+ // Check the LOCAL Claude Code sign-in (the user's own claude.ai subscription
764
+ // OAuth, distinct from the 1Presence gateway login above) before connecting.
765
+ // Local Mode drives Claude Code through the Agent SDK, so if Claude Code isn't
766
+ // signed in every turn fails with the `local_auth` code (and pages ops). Catch
767
+ // it up front: probe, and if signed out, offer to sign in here — polling also
768
+ // picks up a sign-in the user does in another terminal, and the SDK re-reads
769
+ // credentials each turn, so no restart is needed once signed in. Any uncertainty
770
+ // (old CLI without `auth status`, probe error) is treated as "don't block".
771
+ async function ensureClaudeCodeLogin() {
772
+ const status = await probeClaudeAuth();
773
+ if (status === null)
774
+ return; // unknown — don't gate startup on a probe we can't trust
775
+ if (status.loggedIn) {
776
+ const who = status.email ? ` as ${status.email}` : '';
777
+ const plan = status.subscriptionType ? ` (${status.subscriptionType})` : '';
778
+ console.log(paint(SECTION_COLORS.assistant, `✓ Claude Code signed in${who}${plan}`));
779
+ return;
780
+ }
781
+ console.log(paint(SECTION_COLORS.result, '\n⚠ Your local Claude Code isn’t signed in.'));
782
+ console.log(' Local Mode runs on your own Claude subscription — Claude Code has to be signed in on this machine, or every message will fail.');
783
+ if (!process.stdin.isTTY) {
784
+ console.log(' Not an interactive terminal, so I can’t prompt. Sign in with `claude auth login`, then start the bridge again.\n');
785
+ return;
786
+ }
787
+ const yes = await promptYesNo(' Log in to Claude now? (Y/n) ', true);
788
+ if (!yes) {
789
+ console.log(' Skipping sign-in. Messages will fail until you run `claude auth login` — sign in any time and resend.\n');
790
+ return;
791
+ }
792
+ // Best-effort: open the sign-in right here. If the CLI is too old for
793
+ // `auth login`, this returns false and the poll still catches a sign-in the
794
+ // user does in another terminal.
795
+ const launched = await launchClaudeLogin();
796
+ if (!launched) {
797
+ console.log(' Couldn’t open sign-in automatically. In another terminal run: claude auth login');
798
+ }
799
+ const ok = await waitForClaudeLogin({
800
+ onWaiting: () => console.log(paint('90', ' Waiting for sign-in… finish it in the browser (or run `claude auth login` in any terminal). Ctrl+C to abort.')),
801
+ });
802
+ if (ok) {
803
+ const s = await probeClaudeAuth();
804
+ const who = s?.email ? ` as ${s.email}` : '';
805
+ console.log(paint(SECTION_COLORS.assistant, `✓ Signed in${who} — continuing.\n`));
806
+ }
807
+ else {
808
+ console.log(paint(SECTION_COLORS.result, ' Still not signed in — starting anyway. Sign in any time and resend your message.\n'));
809
+ }
810
+ }
762
811
  async function main() {
763
812
  console.log(`1Presence Bridge v${version}\n`);
764
813
  if (VERBOSE) {
@@ -778,6 +827,9 @@ async function main() {
778
827
  // ~/.1presence/config.json). In a non-TTY environment this is a no-op and
779
828
  // Claude Code's own default is used.
780
829
  await ensureModelChoice();
830
+ // Local Claude Code sign-in check (see fn comment). Runs before setup/connect
831
+ // so a signed-out user is guided in up front rather than hitting a failed turn.
832
+ await ensureClaudeCodeLogin();
781
833
  // Write system prompt + MCP config. If this fails the bridge is dead in the
782
834
  // water — surface the underlying error rather than letting it bubble up as
783
835
  // a generic "Fatal:" with no context.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.60.0",
3
+ "version": "0.62.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": {