@1presence/bridge 0.1.17 → 0.1.19

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
@@ -25,6 +25,12 @@ Conversations are stateful — the bridge maps each 1Presence conversation to a
25
25
 
26
26
  Your OAuth tokens and vault data stay server-side — nothing sensitive is stored locally beyond the auth token.
27
27
 
28
+ ## Model
29
+
30
+ 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.
31
+
32
+ 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.
33
+
28
34
  ## Signing out
29
35
 
30
36
  To sign out and reset the cached credentials:
package/dist/auth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isTokenValid = isTokenValid;
3
4
  exports.ensureFreshToken = ensureFreshToken;
4
5
  exports.getValidAuth = getValidAuth;
5
6
  exports.refreshAuth = refreshAuth;
package/dist/claude.js CHANGED
@@ -3,19 +3,127 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.spawnClaude = spawnClaude;
4
4
  exports.killAll = killAll;
5
5
  const child_process_1 = require("child_process");
6
+ const fs_1 = require("fs");
6
7
  const os_1 = require("os");
7
8
  const path_1 = require("path");
8
9
  const sessions_1 = require("./sessions");
10
+ // ─── Bridge working directory ─────────────────────────────────────────────────
11
+ //
12
+ // Claude Code always loads CLAUDE.md files from cwd upward plus the global
13
+ // ~/.claude/CLAUDE.md. If the bridge is launched from within a development
14
+ // repo (e.g. PresenceAI), it would pick up project CLAUDE.md instructions that
15
+ // direct it to write to local vault paths and call MemPalace — both wrong in
16
+ // bridge/Local Mode. We run Claude in a dedicated temp dir with a LOCAL MODE
17
+ // CLAUDE.md that explicitly overrides those behaviors.
18
+ const BRIDGE_CWD = (0, path_1.join)((0, os_1.tmpdir)(), '1presence-bridge');
19
+ const BRIDGE_CLAUDE_MD = `# Bridge / Local Mode — Overrides
20
+
21
+ You are running as a **1Presence bridge agent** on the user's local machine.
22
+ The rules below **override all other CLAUDE.md instructions**, including the global one.
23
+
24
+ ## Filesystem — DO NOT WRITE LOCALLY
25
+
26
+ You have NO permission to write files to this machine.
27
+ - Do NOT write chat history, vault notes, or memory files to the local filesystem.
28
+ - Do NOT write to any path under /Users/, ~/Library/, ~/.obsidian/, or similar.
29
+ - Attempting to use Write/Edit tools on local paths is forbidden in this mode.
30
+
31
+ ## Vault operations
32
+
33
+ All vault reads and writes go through the \`mcp__1presence__*\` MCP tools.
34
+ Use those tools instead of any local file paths.
35
+
36
+ ## MemPalace / memory
37
+
38
+ MemPalace is hosted in the cloud, not locally. If mempalace tools are available
39
+ via MCP, use them. If a mempalace call fails with "not initialised", skip it
40
+ silently — do NOT attempt to write memory files locally as a fallback.
41
+
42
+ ## Session notes / chat history
43
+
44
+ Do NOT write session notes or chat history to the local filesystem.
45
+ If vault MCP tools are available, update vault files through those.
46
+ Otherwise, skip the write and continue — do not error or warn the user.
47
+
48
+ ## UI tools — call these so the chat UI matches the hosted assistant
49
+
50
+ These tools are wired through MCP and produce dedicated UI events in the user's
51
+ chat interface. Call them by their MCP-prefixed names (\`mcp__1presence__<name>\`).
52
+
53
+ - **set_conversation_title** — Call ONCE on your first reply in a new
54
+ conversation with a 2–4 word topic title (sentence case, no verbs). Call
55
+ again later only if the topic clearly shifts. Without this, the user's
56
+ conversation list shows untitled threads.
57
+
58
+ - **ui_payload** — Emit at most ONE per turn, AFTER your reply text ends.
59
+ Pass \`hints: []\` most turns (aim ~1 hint per 3 early turns, less later)
60
+ and \`suggestions\`: 2–4 follow-on prompts the user can tap. Hints are
61
+ end-user product coaching only — never mention models, AI vendors,
62
+ engineering, or roadmap. Skip entirely on pure tool-ingestion turns.
63
+
64
+ - **plan** — Show a checklist when a task has ≥3 distinct, user-visible
65
+ steps with side effects. Call once at the start with all steps; update
66
+ the FULL list each time a step finishes. Never use for single-tool
67
+ answers, lookups, or conversational replies.
68
+
69
+ - **copy_to_clipboard** — Use whenever the user asks you to copy something,
70
+ or when you produce content (code, a draft, a URL, a list) they will
71
+ clearly want to paste elsewhere. Call once per piece of content; the
72
+ text also streams into the chat.
73
+
74
+ - **request_feature** — Log a feature request when declining a user ask
75
+ because the capability doesn't exist. Not for transient errors. After
76
+ calling, tell the user their request was noted.
77
+
78
+ ## Sending email — ALWAYS use gmail_draft
79
+
80
+ To send email on the user's behalf, ALWAYS call \`gmail_draft\`. This opens
81
+ an inline review panel in the chat UI with Send and Cancel buttons; the
82
+ email is NOT delivered until the user clicks Send themselves.
83
+
84
+ You DO NOT have direct send capability in this mode — \`gmail_send\` is not
85
+ exposed and the user's explicit confirmation in the review panel is the only
86
+ way mail leaves their account. After calling gmail_draft, tell the user the
87
+ draft is ready to review and wait for them to confirm. Never claim the
88
+ email was sent until the user tells you they clicked Send.
89
+
90
+ Use the bare tool name (the part after \`mcp__1presence__\`) when reasoning
91
+ about WHEN to call them — the prefix is just the MCP namespace.
92
+ `;
93
+ // Write the override CLAUDE.md once on module load
94
+ (0, fs_1.mkdirSync)(BRIDGE_CWD, { recursive: true });
95
+ (0, fs_1.writeFileSync)((0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md'), BRIDGE_CLAUDE_MD, 'utf-8');
96
+ const config_1 = require("./config");
97
+ // Track whether we've already announced the model this process — printing it
98
+ // per-spawn is noisy; once on startup is what the user actually wants to see.
99
+ let modelAnnounced = false;
9
100
  // ─── Active processes ─────────────────────────────────────────────────────────
10
101
  const active = new Map();
11
102
  // ─── Spawn ────────────────────────────────────────────────────────────────────
12
103
  function spawnClaude(params) {
13
- const { conversationId, presenceSessionId, text, uid, onEvent, onDone, onError } = params;
104
+ const { conversationId, presenceSessionId, text, uid, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError } = params;
14
105
  const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
15
106
  const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
107
+ // If a prior process is still running for this conversation (user sent a
108
+ // follow-up before the previous turn finished), supersede it. The latest
109
+ // user intent wins; the orphan would otherwise keep streaming events.
110
+ const existing = active.get(conversationId);
111
+ if (existing) {
112
+ process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
113
+ existing.kill('SIGTERM');
114
+ active.delete(conversationId);
115
+ }
16
116
  const claudeSessionId = presenceSessionId ? (0, sessions_1.getClaudeSession)(presenceSessionId) : undefined;
117
+ const ctxParts = [];
118
+ if (vaultFileOpen)
119
+ ctxParts.push(`vault_file_open: ${vaultFileOpen}`);
120
+ if (clientCapabilities?.length)
121
+ ctxParts.push(`client_capabilities: ${clientCapabilities.join(', ')}`);
122
+ if (syncedFolders?.length)
123
+ ctxParts.push(`synced_folders: ${syncedFolders.join(', ')}`);
124
+ const promptText = ctxParts.length > 0 ? `[${ctxParts.join(' | ')}]\n\n${text}` : text;
17
125
  const args = [
18
- '-p', text,
126
+ '-p', promptText,
19
127
  '--output-format', 'stream-json',
20
128
  '--verbose',
21
129
  '--allowedTools', 'mcp__1presence__*',
@@ -23,6 +131,10 @@ function spawnClaude(params) {
23
131
  '--mcp-config', mcpConfigPath,
24
132
  '--strict-mcp-config',
25
133
  ];
134
+ const pinnedModel = (0, config_1.getBridgeModel)();
135
+ if (pinnedModel) {
136
+ args.push('--model', pinnedModel);
137
+ }
26
138
  if (claudeSessionId) {
27
139
  args.push('--resume', claudeSessionId);
28
140
  }
@@ -30,6 +142,7 @@ function spawnClaude(params) {
30
142
  // (OAuth credentials), not an API key that would bill to a separate account.
31
143
  const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
32
144
  const proc = (0, child_process_1.spawn)('claude', args, {
145
+ cwd: BRIDGE_CWD,
33
146
  env: safeEnv,
34
147
  stdio: ['ignore', 'pipe', 'pipe'],
35
148
  });
@@ -38,6 +151,7 @@ function spawnClaude(params) {
38
151
  let messageCount = 0;
39
152
  let costUsd = 0;
40
153
  let usage = null;
154
+ let extractedModel = null;
41
155
  let buffer = '';
42
156
  proc.stdout.on('data', (chunk) => {
43
157
  buffer += chunk.toString('utf-8');
@@ -63,7 +177,20 @@ function spawnClaude(params) {
63
177
  }
64
178
  const keySource = event['apiKeySource'];
65
179
  const model = event['model'];
66
- process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
180
+ if (model)
181
+ extractedModel = model;
182
+ if (!modelAnnounced) {
183
+ // First conversation since bridge started — announce prominently
184
+ // so the user can confirm which model and credential is in use.
185
+ const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
186
+ const pin = (0, config_1.getBridgeModel)() ? ' (pinned via 1presence config)' : '';
187
+ process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
188
+ modelAnnounced = true;
189
+ }
190
+ else {
191
+ // Subsequent conversations — quiet line for power users.
192
+ process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
193
+ }
67
194
  sessionIdExtracted = true;
68
195
  }
69
196
  // Count complete assistant turns + accumulate token usage + log tool calls
@@ -75,6 +202,8 @@ function spawnClaude(params) {
75
202
  usage = {
76
203
  input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
77
204
  output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
205
+ cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
206
+ cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
78
207
  };
79
208
  }
80
209
  const content = msg?.['content'];
@@ -127,19 +256,21 @@ function spawnClaude(params) {
127
256
  catch { /* ignore */ }
128
257
  }
129
258
  if (code !== 0 && code !== null) {
130
- onError(`claude exited with code ${code}`);
259
+ // Pass any partial token usage we observed before the failure so the
260
+ // PWA and the gateway's bridge usage store can still record it.
261
+ onError(`claude exited with code ${code}`, usage, extractedModel);
131
262
  }
132
263
  else {
133
- onDone(messageCount, costUsd, usage);
264
+ onDone(messageCount, costUsd, usage, extractedModel);
134
265
  }
135
266
  });
136
267
  proc.on('error', (err) => {
137
268
  active.delete(conversationId);
138
269
  if (err.code === 'ENOENT') {
139
- onError('claude CLI not found. Please install Claude Code: https://claude.ai/code');
270
+ onError('claude CLI not found. Please install Claude Code: https://claude.ai/code', usage, extractedModel);
140
271
  }
141
272
  else {
142
- onError(err.message);
273
+ onError(err.message, usage, extractedModel);
143
274
  }
144
275
  });
145
276
  }
package/dist/config.js ADDED
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ensureModelChoice = ensureModelChoice;
4
+ exports.getBridgeModel = getBridgeModel;
5
+ const fs_1 = require("fs");
6
+ const os_1 = require("os");
7
+ const path_1 = require("path");
8
+ const readline_1 = require("readline");
9
+ // ─── Storage ──────────────────────────────────────────────────────────────────
10
+ //
11
+ // `~/.1presence/config.json` holds bridge-wide preferences that should persist
12
+ // across runs but aren't sensitive (unlike auth.json). Today: model choice.
13
+ const CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), '.1presence');
14
+ const CONFIG_FILE = (0, path_1.join)(CONFIG_DIR, 'config.json');
15
+ let cached = null;
16
+ function load() {
17
+ if (cached)
18
+ return cached;
19
+ try {
20
+ cached = JSON.parse((0, fs_1.readFileSync)(CONFIG_FILE, 'utf-8'));
21
+ }
22
+ catch {
23
+ cached = {};
24
+ }
25
+ return cached;
26
+ }
27
+ function persist(c) {
28
+ (0, fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
29
+ (0, fs_1.writeFileSync)(CONFIG_FILE, JSON.stringify(c, null, 2), 'utf-8');
30
+ cached = c;
31
+ }
32
+ // ─── Model choice ─────────────────────────────────────────────────────────────
33
+ function promptForModel() {
34
+ return new Promise((resolve) => {
35
+ const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
36
+ process.stdout.write('\nWhich Claude model should the bridge use?\n');
37
+ process.stdout.write(' Leave blank to use your local Claude Code default (recommended).\n');
38
+ process.stdout.write(' Or enter a model id (e.g. claude-sonnet-4-5, claude-opus-4-7, claude-haiku-4-5).\n');
39
+ rl.question(' model: ', (answer) => {
40
+ rl.close();
41
+ const trimmed = answer.trim();
42
+ resolve(trimmed || null);
43
+ });
44
+ });
45
+ }
46
+ /**
47
+ * Ensures the user has picked a model preference. On first interactive run,
48
+ * prompts and persists. Subsequent runs return the saved value without asking.
49
+ * In a non-TTY environment (background process, CI) the prompt is skipped and
50
+ * we record an explicit "no override" so we don't keep trying.
51
+ */
52
+ async function ensureModelChoice() {
53
+ const c = load();
54
+ if ('model' in c)
55
+ return; // already configured (string or explicit null)
56
+ if (!process.stdin.isTTY) {
57
+ persist({ ...c, model: null });
58
+ return;
59
+ }
60
+ const chosen = await promptForModel();
61
+ persist({ ...c, model: chosen });
62
+ if (chosen) {
63
+ console.log(`\nPinned model: ${chosen}`);
64
+ console.log(`(Edit or delete ~/.1presence/config.json to change.)\n`);
65
+ }
66
+ else {
67
+ console.log(`\nUsing your Claude Code default.`);
68
+ console.log(`(Edit ~/.1presence/config.json to pin a model later.)\n`);
69
+ }
70
+ }
71
+ /** Returns the pinned model id, or null to defer to Claude Code's own default. */
72
+ function getBridgeModel() {
73
+ return load().model ?? null;
74
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ const os_1 = require("os");
10
10
  const path_1 = require("path");
11
11
  const auth_1 = require("./auth");
12
12
  const claude_1 = require("./claude");
13
+ const config_1 = require("./config");
13
14
  const update_1 = require("./update");
14
15
  const package_json_1 = require("../package.json");
15
16
  // ─── Config ───────────────────────────────────────────────────────────────────
@@ -20,6 +21,7 @@ const GATEWAY_HTTP = GATEWAY_URL.replace(/^wss?:/, 'https:').replace(/\/$/, '');
20
21
  const PWA_URL = process.env.BRIDGE_PWA_URL ?? GATEWAY_HTTP.replace('://api.', '://');
21
22
  // ─── In-memory state ──────────────────────────────────────────────────────────
22
23
  let currentAuth = null;
24
+ let currentWs = null;
23
25
  // ─── Vault file fetch ─────────────────────────────────────────────────────────
24
26
  async function fetchVaultFile(path, token) {
25
27
  try {
@@ -34,17 +36,41 @@ async function fetchVaultFile(path, token) {
34
36
  return null;
35
37
  }
36
38
  }
39
+ // Pulls the fully-built system prompt from agent-api (via gateway proxy).
40
+ // This matches the hosted runtime exactly: STATIC_SYSTEM_PROMPT + dynamic
41
+ // context (timezone, connector scopes, vault state, personal AGENT.md, etc.).
42
+ // Falls back to null on failure — caller decides whether to use AGENT.md only.
43
+ async function fetchSystemPrompt(token) {
44
+ try {
45
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
46
+ const res = await fetch(`${GATEWAY_HTTP}/system-prompt?timezone=${encodeURIComponent(tz)}`, {
47
+ headers: { Authorization: `Bearer ${token}` },
48
+ });
49
+ if (!res.ok)
50
+ return null;
51
+ const data = await res.json();
52
+ return data.text ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
37
58
  // ─── Setup files ──────────────────────────────────────────────────────────────
38
59
  function tmpFile(name) {
39
60
  return (0, path_1.join)((0, os_1.tmpdir)(), name);
40
61
  }
41
62
  async function writeSetupFiles(auth) {
42
63
  const { uid, token } = auth;
43
- // Fetch user's AGENT.md (fall back to empty if missing claude will still run)
44
- const agentMd = await fetchVaultFile('AGENT.md', token)
45
- ?? await fetchVaultFile('CLAUDE.md', token)
64
+ // Prefer the fully-built hosted system prompt so the bridge runtime behaves
65
+ // identically to the cloud agent (tool-use policy, ui_payload sparsity,
66
+ // plan thresholds, Gmail safety, connector pivots, personal AGENT.md, etc.).
67
+ // If the pod isn't reachable yet, fall back to the user's AGENT.md alone —
68
+ // Claude will still run, just without the platform policy layer.
69
+ const systemPrompt = (await fetchSystemPrompt(token))
70
+ ?? (await fetchVaultFile('AGENT.md', token))
71
+ ?? (await fetchVaultFile('CLAUDE.md', token))
46
72
  ?? '';
47
- (0, fs_1.writeFileSync)(tmpFile(`agent-${uid}.md`), agentMd, 'utf-8');
73
+ (0, fs_1.writeFileSync)(tmpFile(`agent-${uid}.md`), systemPrompt, 'utf-8');
48
74
  // MCP config pointing at gateway's /mcp endpoint (proxied to agent-api)
49
75
  const mcpConfig = {
50
76
  mcpServers: {
@@ -58,7 +84,7 @@ async function writeSetupFiles(auth) {
58
84
  (0, fs_1.writeFileSync)(tmpFile(`mcp-${uid}.json`), JSON.stringify(mcpConfig, null, 2), 'utf-8');
59
85
  }
60
86
  // ─── Handle a single incoming message (token refresh + spawn) ─────────────────
61
- async function handleMessage(conversationId, text, sessionId, ws, auth) {
87
+ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpen, clientCapabilities, syncedFolders) {
62
88
  // Refresh JWT if <10 min remaining before spawning Claude
63
89
  let activeAuth = auth;
64
90
  try {
@@ -70,7 +96,17 @@ async function handleMessage(conversationId, text, sessionId, ws, auth) {
70
96
  }
71
97
  }
72
98
  catch (err) {
73
- console.warn(`[bridge] token refresh failed: ${err.message}`);
99
+ // If the cached token still has time, proceed — refresh was preemptive.
100
+ // If it's already invalid, MCP calls will 401 mid-turn — fail fast instead.
101
+ if (!(0, auth_1.isTokenValid)(auth.token)) {
102
+ const message = 'Authentication expired and refresh failed — please restart the bridge to sign in again.';
103
+ console.error(`[bridge] ${message} (${err.message})`);
104
+ if (currentWs?.readyState === ws_1.default.OPEN) {
105
+ currentWs.send(JSON.stringify({ type: 'error', conversationId, message }));
106
+ }
107
+ return;
108
+ }
109
+ console.warn(`[bridge] token refresh failed (proceeding with current token): ${err.message}`);
74
110
  }
75
111
  let responding = false;
76
112
  (0, claude_1.spawnClaude)({
@@ -78,16 +114,19 @@ async function handleMessage(conversationId, text, sessionId, ws, auth) {
78
114
  presenceSessionId: sessionId,
79
115
  text,
80
116
  uid: activeAuth.uid,
117
+ vaultFileOpen,
118
+ clientCapabilities,
119
+ syncedFolders,
81
120
  onEvent: (event) => {
82
121
  if (!responding && event['type'] === 'assistant') {
83
122
  responding = true;
84
123
  console.log(`[${new Date().toLocaleTimeString()}] ◐ responding…`);
85
124
  }
86
- if (ws.readyState === ws_1.default.OPEN) {
87
- ws.send(JSON.stringify({ type: 'stream', conversationId, event }));
125
+ if (currentWs?.readyState === ws_1.default.OPEN) {
126
+ currentWs.send(JSON.stringify({ type: 'stream', conversationId, event }));
88
127
  }
89
128
  },
90
- onDone: (messageCount, costUsd, usage) => {
129
+ onDone: (messageCount, costUsd, usage, model) => {
91
130
  const parts = [];
92
131
  if (usage)
93
132
  parts.push(`in:${usage.input_tokens} out:${usage.output_tokens}`);
@@ -95,19 +134,44 @@ async function handleMessage(conversationId, text, sessionId, ws, auth) {
95
134
  parts.push(costStr);
96
135
  const suffix = parts.length ? ` ${parts.join(' ')}` : '';
97
136
  console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
98
- if (ws.readyState === ws_1.default.OPEN) {
99
- ws.send(JSON.stringify({ type: 'done', conversationId, messageCount, costUsd }));
137
+ if (currentWs?.readyState === ws_1.default.OPEN) {
138
+ currentWs.send(JSON.stringify({
139
+ type: 'done',
140
+ conversationId,
141
+ messageCount,
142
+ costUsd,
143
+ model,
144
+ usage: usage ? {
145
+ inputTokens: usage.input_tokens,
146
+ outputTokens: usage.output_tokens,
147
+ cacheReadTokens: usage.cache_read_input_tokens,
148
+ cacheCreationTokens: usage.cache_creation_input_tokens,
149
+ } : null,
150
+ }));
100
151
  }
101
152
  },
102
- onError: (message) => {
153
+ onError: (message, usage, model) => {
103
154
  console.error(`[${new Date().toLocaleTimeString()}] ✗ ${message}`);
104
- if (ws.readyState === ws_1.default.OPEN) {
105
- ws.send(JSON.stringify({ type: 'error', conversationId, message }));
155
+ if (currentWs?.readyState === ws_1.default.OPEN) {
156
+ currentWs.send(JSON.stringify({
157
+ type: 'error',
158
+ conversationId,
159
+ message,
160
+ model,
161
+ usage: usage ? {
162
+ inputTokens: usage.input_tokens,
163
+ outputTokens: usage.output_tokens,
164
+ cacheReadTokens: usage.cache_read_input_tokens,
165
+ cacheCreationTokens: usage.cache_creation_input_tokens,
166
+ } : null,
167
+ }));
106
168
  }
107
169
  },
108
170
  });
109
171
  }
110
172
  // ─── WebSocket connection ─────────────────────────────────────────────────────
173
+ // Application-level heartbeat — avoids relying on WebSocket control frames (ping/pong),
174
+ // which some proxies (GKE LB) may not forward reliably.
111
175
  const PING_INTERVAL_MS = 30_000;
112
176
  const PONG_TIMEOUT_MS = 10_000;
113
177
  function connect(auth, retryDelay = 1000) {
@@ -120,7 +184,7 @@ function connect(auth, retryDelay = 1000) {
120
184
  pingTimer = setInterval(() => {
121
185
  if (ws.readyState !== ws_1.default.OPEN)
122
186
  return;
123
- ws.ping();
187
+ ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
124
188
  pongTimer = setTimeout(() => {
125
189
  console.log('[bridge] pong timeout — reconnecting…');
126
190
  ws.terminate();
@@ -137,13 +201,11 @@ function connect(auth, retryDelay = 1000) {
137
201
  pongTimer = null;
138
202
  }
139
203
  }
140
- ws.on('pong', () => {
141
- if (pongTimer) {
142
- clearTimeout(pongTimer);
143
- pongTimer = null;
144
- }
145
- });
146
204
  ws.on('open', () => {
205
+ currentWs = ws;
206
+ // Reset backoff so that a disconnect after a long-stable session
207
+ // reconnects quickly instead of waiting at the 30s cap.
208
+ retryDelay = 1000;
147
209
  console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
148
210
  startPing();
149
211
  });
@@ -155,13 +217,21 @@ function connect(auth, retryDelay = 1000) {
155
217
  catch {
156
218
  return;
157
219
  }
220
+ // Application-level pong — clear the timeout
221
+ if (msg.type === 'pong') {
222
+ if (pongTimer) {
223
+ clearTimeout(pongTimer);
224
+ pongTimer = null;
225
+ }
226
+ return;
227
+ }
158
228
  if (msg.type !== 'message' || !msg.conversationId || !msg.text)
159
229
  return;
160
- const { conversationId, text, sessionId } = msg;
230
+ const { conversationId, text, sessionId, vaultFileOpen, clientCapabilities, syncedFolders } = msg;
161
231
  const ts = new Date().toLocaleTimeString();
162
232
  const preview = text.length > 80 ? text.slice(0, 80) + '…' : text;
163
233
  console.log(`[${ts}] ▶ ${preview}`);
164
- handleMessage(conversationId, text, sessionId ?? null, ws, auth).catch((err) => {
234
+ handleMessage(conversationId, text, sessionId ?? null, auth, vaultFileOpen, clientCapabilities, syncedFolders).catch((err) => {
165
235
  console.error(`[bridge] handleMessage error: ${err.message}`);
166
236
  });
167
237
  });
@@ -200,6 +270,10 @@ async function main() {
200
270
  // Auth
201
271
  const auth = await (0, auth_1.getValidAuth)(GATEWAY_HTTP, PWA_URL);
202
272
  currentAuth = auth;
273
+ // One-time interactive model choice (only prompts on first run; saved to
274
+ // ~/.1presence/config.json). In a non-TTY environment this is a no-op and
275
+ // Claude Code's own default is used.
276
+ await (0, config_1.ensureModelChoice)();
203
277
  // Write system prompt + MCP config
204
278
  process.stdout.write('Setting up…');
205
279
  await writeSetupFiles(auth);
package/dist/sessions.js CHANGED
@@ -8,25 +8,29 @@ const path_1 = require("path");
8
8
  // ─── Storage ──────────────────────────────────────────────────────────────────
9
9
  const CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), '.1presence');
10
10
  const SESSIONS_FILE = (0, path_1.join)(CONFIG_DIR, 'sessions.json');
11
- function load() {
11
+ let cache = null;
12
+ function getMap() {
13
+ if (cache)
14
+ return cache;
12
15
  try {
13
16
  const raw = (0, fs_1.readFileSync)(SESSIONS_FILE, 'utf-8');
14
- return JSON.parse(raw);
17
+ cache = JSON.parse(raw);
15
18
  }
16
19
  catch {
17
- return {};
20
+ cache = {};
18
21
  }
22
+ return cache;
19
23
  }
20
- function save(map) {
24
+ function persist(map) {
21
25
  (0, fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
22
26
  (0, fs_1.writeFileSync)(SESSIONS_FILE, JSON.stringify(map, null, 2), 'utf-8');
23
27
  }
24
28
  // ─── Public API ───────────────────────────────────────────────────────────────
25
29
  function getClaudeSession(presenceSessionId) {
26
- return load()[presenceSessionId];
30
+ return getMap()[presenceSessionId];
27
31
  }
28
32
  function saveClaudeSession(presenceSessionId, claudeSessionId) {
29
- const map = load();
33
+ const map = getMap();
30
34
  map[presenceSessionId] = claudeSessionId;
31
- save(map);
35
+ persist(map);
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "bin": {
6
6
  "1presence-bridge": "dist/index.js"