@1presence/bridge 0.1.18 → 0.2.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
@@ -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,27 +3,151 @@ 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, vaultFileOpen, 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;
17
- const promptText = vaultFileOpen ? `[vault_file_open: ${vaultFileOpen}]\n\n${text}` : text;
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;
125
+ // Lockdown rationale:
126
+ // - `--tools ""` disables ALL built-in tools (Bash/Read/Write/Edit/Glob/Grep/
127
+ // WebFetch/etc.). MCP tools are not "built-in" so the 1Presence MCP surface
128
+ // remains available.
129
+ // - `--setting-sources ""` prevents claude CLI from loading the user's
130
+ // ~/.claude/settings.json (and project/.local equivalents). Without this,
131
+ // permissive `permissions.allow` rules in the user's personal Claude Code
132
+ // config would silently re-enable Bash/Edit/Write etc. inside the bridge.
133
+ // - `--strict-mcp-config` keeps the MCP surface to exactly what we wire in
134
+ // via --mcp-config. Together these guarantee the bridge can only call
135
+ // `mcp__1presence__*` — no filesystem, no shell, no arbitrary network.
18
136
  const args = [
19
137
  '-p', promptText,
20
138
  '--output-format', 'stream-json',
21
139
  '--verbose',
140
+ '--tools', '',
141
+ '--setting-sources', '',
22
142
  '--allowedTools', 'mcp__1presence__*',
23
143
  '--system-prompt', systemPromptPath,
24
144
  '--mcp-config', mcpConfigPath,
25
145
  '--strict-mcp-config',
26
146
  ];
147
+ const pinnedModel = (0, config_1.getBridgeModel)();
148
+ if (pinnedModel) {
149
+ args.push('--model', pinnedModel);
150
+ }
27
151
  if (claudeSessionId) {
28
152
  args.push('--resume', claudeSessionId);
29
153
  }
@@ -31,6 +155,7 @@ function spawnClaude(params) {
31
155
  // (OAuth credentials), not an API key that would bill to a separate account.
32
156
  const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
33
157
  const proc = (0, child_process_1.spawn)('claude', args, {
158
+ cwd: BRIDGE_CWD,
34
159
  env: safeEnv,
35
160
  stdio: ['ignore', 'pipe', 'pipe'],
36
161
  });
@@ -39,6 +164,7 @@ function spawnClaude(params) {
39
164
  let messageCount = 0;
40
165
  let costUsd = 0;
41
166
  let usage = null;
167
+ let extractedModel = null;
42
168
  let buffer = '';
43
169
  proc.stdout.on('data', (chunk) => {
44
170
  buffer += chunk.toString('utf-8');
@@ -64,7 +190,20 @@ function spawnClaude(params) {
64
190
  }
65
191
  const keySource = event['apiKeySource'];
66
192
  const model = event['model'];
67
- process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
193
+ if (model)
194
+ extractedModel = model;
195
+ if (!modelAnnounced) {
196
+ // First conversation since bridge started — announce prominently
197
+ // so the user can confirm which model and credential is in use.
198
+ const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
199
+ const pin = (0, config_1.getBridgeModel)() ? ' (pinned via 1presence config)' : '';
200
+ process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
201
+ modelAnnounced = true;
202
+ }
203
+ else {
204
+ // Subsequent conversations — quiet line for power users.
205
+ process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
206
+ }
68
207
  sessionIdExtracted = true;
69
208
  }
70
209
  // Count complete assistant turns + accumulate token usage + log tool calls
@@ -76,6 +215,8 @@ function spawnClaude(params) {
76
215
  usage = {
77
216
  input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
78
217
  output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
218
+ cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
219
+ cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
79
220
  };
80
221
  }
81
222
  const content = msg?.['content'];
@@ -128,19 +269,21 @@ function spawnClaude(params) {
128
269
  catch { /* ignore */ }
129
270
  }
130
271
  if (code !== 0 && code !== null) {
131
- onError(`claude exited with code ${code}`);
272
+ // Pass any partial token usage we observed before the failure so the
273
+ // PWA and the gateway's bridge usage store can still record it.
274
+ onError(`claude exited with code ${code}`, usage, extractedModel);
132
275
  }
133
276
  else {
134
- onDone(messageCount, costUsd, usage);
277
+ onDone(messageCount, costUsd, usage, extractedModel);
135
278
  }
136
279
  });
137
280
  proc.on('error', (err) => {
138
281
  active.delete(conversationId);
139
282
  if (err.code === 'ENOENT') {
140
- onError('claude CLI not found. Please install Claude Code: https://claude.ai/code');
283
+ onError('claude CLI not found. Please install Claude Code: https://claude.ai/code', usage, extractedModel);
141
284
  }
142
285
  else {
143
- onError(err.message);
286
+ onError(err.message, usage, extractedModel);
144
287
  }
145
288
  });
146
289
  }
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 ───────────────────────────────────────────────────────────────────
@@ -35,17 +36,41 @@ async function fetchVaultFile(path, token) {
35
36
  return null;
36
37
  }
37
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
+ }
38
58
  // ─── Setup files ──────────────────────────────────────────────────────────────
39
59
  function tmpFile(name) {
40
60
  return (0, path_1.join)((0, os_1.tmpdir)(), name);
41
61
  }
42
62
  async function writeSetupFiles(auth) {
43
63
  const { uid, token } = auth;
44
- // Fetch user's AGENT.md (fall back to empty if missing claude will still run)
45
- const agentMd = await fetchVaultFile('AGENT.md', token)
46
- ?? 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))
47
72
  ?? '';
48
- (0, fs_1.writeFileSync)(tmpFile(`agent-${uid}.md`), agentMd, 'utf-8');
73
+ (0, fs_1.writeFileSync)(tmpFile(`agent-${uid}.md`), systemPrompt, 'utf-8');
49
74
  // MCP config pointing at gateway's /mcp endpoint (proxied to agent-api)
50
75
  const mcpConfig = {
51
76
  mcpServers: {
@@ -59,7 +84,7 @@ async function writeSetupFiles(auth) {
59
84
  (0, fs_1.writeFileSync)(tmpFile(`mcp-${uid}.json`), JSON.stringify(mcpConfig, null, 2), 'utf-8');
60
85
  }
61
86
  // ─── Handle a single incoming message (token refresh + spawn) ─────────────────
62
- async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpen) {
87
+ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpen, clientCapabilities, syncedFolders) {
63
88
  // Refresh JWT if <10 min remaining before spawning Claude
64
89
  let activeAuth = auth;
65
90
  try {
@@ -71,7 +96,17 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
71
96
  }
72
97
  }
73
98
  catch (err) {
74
- 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}`);
75
110
  }
76
111
  let responding = false;
77
112
  (0, claude_1.spawnClaude)({
@@ -80,6 +115,8 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
80
115
  text,
81
116
  uid: activeAuth.uid,
82
117
  vaultFileOpen,
118
+ clientCapabilities,
119
+ syncedFolders,
83
120
  onEvent: (event) => {
84
121
  if (!responding && event['type'] === 'assistant') {
85
122
  responding = true;
@@ -89,7 +126,7 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
89
126
  currentWs.send(JSON.stringify({ type: 'stream', conversationId, event }));
90
127
  }
91
128
  },
92
- onDone: (messageCount, costUsd, usage) => {
129
+ onDone: (messageCount, costUsd, usage, model) => {
93
130
  const parts = [];
94
131
  if (usage)
95
132
  parts.push(`in:${usage.input_tokens} out:${usage.output_tokens}`);
@@ -98,18 +135,43 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
98
135
  const suffix = parts.length ? ` ${parts.join(' ')}` : '';
99
136
  console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
100
137
  if (currentWs?.readyState === ws_1.default.OPEN) {
101
- currentWs.send(JSON.stringify({ type: 'done', conversationId, messageCount, costUsd }));
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
+ }));
102
151
  }
103
152
  },
104
- onError: (message) => {
153
+ onError: (message, usage, model) => {
105
154
  console.error(`[${new Date().toLocaleTimeString()}] ✗ ${message}`);
106
155
  if (currentWs?.readyState === ws_1.default.OPEN) {
107
- currentWs.send(JSON.stringify({ type: 'error', conversationId, message }));
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
+ }));
108
168
  }
109
169
  },
110
170
  });
111
171
  }
112
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.
113
175
  const PING_INTERVAL_MS = 30_000;
114
176
  const PONG_TIMEOUT_MS = 10_000;
115
177
  function connect(auth, retryDelay = 1000) {
@@ -122,7 +184,7 @@ function connect(auth, retryDelay = 1000) {
122
184
  pingTimer = setInterval(() => {
123
185
  if (ws.readyState !== ws_1.default.OPEN)
124
186
  return;
125
- ws.ping();
187
+ ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
126
188
  pongTimer = setTimeout(() => {
127
189
  console.log('[bridge] pong timeout — reconnecting…');
128
190
  ws.terminate();
@@ -139,14 +201,11 @@ function connect(auth, retryDelay = 1000) {
139
201
  pongTimer = null;
140
202
  }
141
203
  }
142
- ws.on('pong', () => {
143
- if (pongTimer) {
144
- clearTimeout(pongTimer);
145
- pongTimer = null;
146
- }
147
- });
148
204
  ws.on('open', () => {
149
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;
150
209
  console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
151
210
  startPing();
152
211
  });
@@ -158,13 +217,21 @@ function connect(auth, retryDelay = 1000) {
158
217
  catch {
159
218
  return;
160
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
+ }
161
228
  if (msg.type !== 'message' || !msg.conversationId || !msg.text)
162
229
  return;
163
- const { conversationId, text, sessionId, vaultFileOpen } = msg;
230
+ const { conversationId, text, sessionId, vaultFileOpen, clientCapabilities, syncedFolders } = msg;
164
231
  const ts = new Date().toLocaleTimeString();
165
232
  const preview = text.length > 80 ? text.slice(0, 80) + '…' : text;
166
233
  console.log(`[${ts}] ▶ ${preview}`);
167
- handleMessage(conversationId, text, sessionId ?? null, auth, vaultFileOpen).catch((err) => {
234
+ handleMessage(conversationId, text, sessionId ?? null, auth, vaultFileOpen, clientCapabilities, syncedFolders).catch((err) => {
168
235
  console.error(`[bridge] handleMessage error: ${err.message}`);
169
236
  });
170
237
  });
@@ -203,6 +270,10 @@ async function main() {
203
270
  // Auth
204
271
  const auth = await (0, auth_1.getValidAuth)(GATEWAY_HTTP, PWA_URL);
205
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)();
206
277
  // Write system prompt + MCP config
207
278
  process.stdout.write('Setting up…');
208
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.18",
3
+ "version": "0.2.0",
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"