@1presence/bridge 0.14.0 → 0.16.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
@@ -227,7 +227,7 @@ function spawnClaude(params) {
227
227
  // First conversation since bridge started — announce prominently
228
228
  // so the user can confirm which model and credential is in use.
229
229
  const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
230
- const pin = (0, config_1.getBridgeModel)() ? ' (pinned via 1presence config)' : '';
230
+ const pin = (0, config_1.getBridgeModel)() ? ' (selected at startup)' : '';
231
231
  process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
232
232
  modelAnnounced = true;
233
233
  }
package/dist/config.js CHANGED
@@ -2,35 +2,16 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ensureModelChoice = ensureModelChoice;
4
4
  exports.getBridgeModel = getBridgeModel;
5
- const fs_1 = require("fs");
6
- const os_1 = require("os");
7
- const path_1 = require("path");
8
5
  const readline_1 = require("readline");
9
6
  const child_process_1 = require("child_process");
10
- // ─── Storage ──────────────────────────────────────────────────────────────────
7
+ // ─── In-memory model choice ───────────────────────────────────────────────────
11
8
  //
12
- // `~/.1presence/config.json` holds bridge-wide preferences that should persist
13
- // across runs but aren't sensitive (unlike auth.json). Today: model choice.
14
- const CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), '.1presence');
15
- const CONFIG_FILE = (0, path_1.join)(CONFIG_DIR, 'config.json');
16
- let cached = null;
17
- function load() {
18
- if (cached)
19
- return cached;
20
- try {
21
- cached = JSON.parse((0, fs_1.readFileSync)(CONFIG_FILE, 'utf-8'));
22
- }
23
- catch {
24
- cached = {};
25
- }
26
- return cached;
27
- }
28
- function persist(c) {
29
- (0, fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
30
- (0, fs_1.writeFileSync)(CONFIG_FILE, JSON.stringify(c, null, 2), 'utf-8');
31
- cached = c;
32
- }
33
- // ─── Model choice ─────────────────────────────────────────────────────────────
9
+ // The bridge prompts for a model on every interactive startup. The choice is
10
+ // kept in memory for the life of the process — nothing is written to disk.
11
+ // In a non-TTY environment the prompt is skipped and Claude Code's own default
12
+ // is used.
13
+ let selectedModel = null;
14
+ // ─── Default-model probe ──────────────────────────────────────────────────────
34
15
  /**
35
16
  * Asks the local `claude` CLI which model it would pick by default, by reading
36
17
  * the `model` field of the `system/init` stream-json event and killing the
@@ -106,27 +87,18 @@ const MODEL_OPTIONS = [
106
87
  { num: 4, model: 'claude-haiku-4-5', label: 'claude-haiku-4-5' },
107
88
  ];
108
89
  const PROMPT_TIMEOUT_MS = 10_000;
109
- function defaultOptionNum(previous) {
110
- if (typeof previous === 'string') {
111
- const match = MODEL_OPTIONS.find((o) => o.model === previous);
112
- if (match)
113
- return match.num;
114
- }
115
- // Either never asked (undefined) or explicit no-override (null) → option 1.
116
- return 1;
117
- }
118
- function promptForModel(defaultModel, previousChoice) {
90
+ const DEFAULT_OPTION_NUM = 1;
91
+ function promptForModel(defaultModel) {
119
92
  return new Promise((resolve) => {
120
93
  const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
121
- const defaultNum = defaultOptionNum(previousChoice);
122
94
  process.stdout.write('\nWhich Claude model should the bridge use?\n');
123
95
  for (const opt of MODEL_OPTIONS) {
124
- const isDefault = opt.num === defaultNum;
96
+ const isDefault = opt.num === DEFAULT_OPTION_NUM;
125
97
  const marker = isDefault ? '*' : ' ';
126
98
  const suffix = opt.num === 1 && defaultModel ? ` (${defaultModel})` : '';
127
99
  process.stdout.write(` ${marker} ${opt.num}) ${opt.label}${suffix}\n`);
128
100
  }
129
- process.stdout.write(` (* = current; auto-selected in ${PROMPT_TIMEOUT_MS / 1000}s if nothing pressed)\n`);
101
+ process.stdout.write(` (* = default; auto-selected in ${PROMPT_TIMEOUT_MS / 1000}s if nothing pressed)\n`);
130
102
  let settled = false;
131
103
  const finish = (model) => {
132
104
  if (settled)
@@ -137,14 +109,14 @@ function promptForModel(defaultModel, previousChoice) {
137
109
  resolve(model);
138
110
  };
139
111
  const timer = setTimeout(() => {
140
- const def = MODEL_OPTIONS.find((o) => o.num === defaultNum);
141
- process.stdout.write(`\n(timed out — using option ${defaultNum})\n`);
112
+ const def = MODEL_OPTIONS.find((o) => o.num === DEFAULT_OPTION_NUM);
113
+ process.stdout.write(`\n(timed out — using option ${DEFAULT_OPTION_NUM})\n`);
142
114
  finish(def.model);
143
115
  }, PROMPT_TIMEOUT_MS);
144
116
  rl.question(' choice: ', (answer) => {
145
117
  const trimmed = answer.trim();
146
118
  if (!trimmed) {
147
- const def = MODEL_OPTIONS.find((o) => o.num === defaultNum);
119
+ const def = MODEL_OPTIONS.find((o) => o.num === DEFAULT_OPTION_NUM);
148
120
  finish(def.model);
149
121
  return;
150
122
  }
@@ -160,32 +132,25 @@ function promptForModel(defaultModel, previousChoice) {
160
132
  });
161
133
  }
162
134
  /**
163
- * Asks the user which model to pin every time the bridge starts interactively.
164
- * The current pick (or option 1 on first run) is the timeout default — wait
165
- * 10 seconds and the previous choice carries over. In a non-TTY environment
166
- * the prompt is skipped: an existing pin is kept, and a first non-TTY start
167
- * records "no override" so we don't keep trying.
135
+ * Asks the user which model to use for this bridge session. The choice lives
136
+ * in memory only every startup re-prompts. In a non-TTY environment the
137
+ * prompt is skipped and Claude Code's own default is used.
168
138
  */
169
139
  async function ensureModelChoice() {
170
- const c = load();
171
140
  if (!process.stdin.isTTY) {
172
- if (!('model' in c))
173
- persist({ ...c, model: null });
141
+ selectedModel = null;
174
142
  return;
175
143
  }
176
144
  const defaultModel = await detectClaudeDefaultModel();
177
- const chosen = await promptForModel(defaultModel, c.model);
178
- persist({ ...c, model: chosen });
179
- if (chosen) {
180
- console.log(`\nPinned model: ${chosen}`);
181
- console.log(`(Edit or delete ~/.1presence/config.json to change.)\n`);
145
+ selectedModel = await promptForModel(defaultModel);
146
+ if (selectedModel) {
147
+ console.log(`\nUsing model: ${selectedModel} (this session only)\n`);
182
148
  }
183
149
  else {
184
- console.log(`\nUsing your Claude Code default${defaultModel ? ` (${defaultModel})` : ''}.`);
185
- console.log(`(Edit ~/.1presence/config.json to pin a model later.)\n`);
150
+ console.log(`\nUsing your Claude Code default${defaultModel ? ` (${defaultModel})` : ''}.\n`);
186
151
  }
187
152
  }
188
- /** Returns the pinned model id, or null to defer to Claude Code's own default. */
153
+ /** Returns the model id chosen for this session, or null to defer to Claude Code's own default. */
189
154
  function getBridgeModel() {
190
- return load().model ?? null;
155
+ return selectedModel;
191
156
  }
package/dist/index.js CHANGED
@@ -38,61 +38,63 @@ const PWA_URL = process.env.BRIDGE_PWA_URL ?? GATEWAY_HTTP.replace('://api.', ':
38
38
  // ─── In-memory state ──────────────────────────────────────────────────────────
39
39
  let currentAuth = null;
40
40
  let currentWs = null;
41
- // ─── Vault file fetch ─────────────────────────────────────────────────────────
42
- async function fetchVaultFile(path, token) {
41
+ // ─── System prompt fetch ──────────────────────────────────────────────────────
42
+ // Pulls the fully-built system prompt from agent-api (via gateway proxy).
43
+ // This MUST match the hosted runtime exactly — STATIC_SYSTEM_PROMPT + dynamic
44
+ // context (timezone, connector scopes, vault state, personal AGENT.md, skills,
45
+ // onboarding). There is intentionally NO fallback: if this fails the bridge
46
+ // must surface the error, not silently degrade to a different prompt source
47
+ // (which historically caused the "Skills section authoritative" rule to
48
+ // vanish and the agent to vault-hunt for skills).
49
+ async function fetchSystemPrompt(token) {
50
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
51
+ const url = `${GATEWAY_HTTP}/system-prompt?timezone=${encodeURIComponent(tz)}`;
52
+ let res;
43
53
  try {
44
- const res = await fetch(`${GATEWAY_HTTP}/vault/file?path=${encodeURIComponent(path)}`, {
54
+ res = await fetch(url, {
45
55
  headers: { Authorization: `Bearer ${token}` },
46
56
  });
47
- if (!res.ok)
48
- return null;
49
- return res.text();
50
57
  }
51
- catch {
52
- return null;
58
+ catch (err) {
59
+ throw new Error(`fetch failed for ${url}: ${err.message}`);
53
60
  }
54
- }
55
- // Pulls the fully-built system prompt from agent-api (via gateway proxy).
56
- // This matches the hosted runtime exactly: STATIC_SYSTEM_PROMPT + dynamic
57
- // context (timezone, connector scopes, vault state, personal AGENT.md, etc.).
58
- // Falls back to null on failure — caller decides whether to use AGENT.md only.
59
- async function fetchSystemPrompt(token) {
61
+ if (!res.ok) {
62
+ const body = await res.text().catch(() => '<unreadable body>');
63
+ throw new Error(`/system-prompt returned ${res.status} ${res.statusText}: ${body.slice(0, 500)}`);
64
+ }
65
+ let data;
60
66
  try {
61
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
62
- const res = await fetch(`${GATEWAY_HTTP}/system-prompt?timezone=${encodeURIComponent(tz)}`, {
63
- headers: { Authorization: `Bearer ${token}` },
64
- });
65
- if (!res.ok)
66
- return null;
67
- const data = await res.json();
68
- return data.text ?? null;
67
+ data = await res.json();
68
+ }
69
+ catch (err) {
70
+ throw new Error(`/system-prompt returned non-JSON: ${err.message}`);
69
71
  }
70
- catch {
71
- return null;
72
+ if (!data.text) {
73
+ throw new Error(`/system-prompt returned no "text" field (got keys: ${Object.keys(data).join(', ') || '<none>'})`);
72
74
  }
75
+ return data.text;
73
76
  }
74
77
  // ─── Setup files ──────────────────────────────────────────────────────────────
75
78
  function tmpFile(name) {
76
79
  return (0, path_1.join)((0, os_1.tmpdir)(), name);
77
80
  }
78
- async function writeSetupFiles(auth) {
81
+ // Fetch the system prompt and write it to /tmp/agent-${uid}.md. The hosted
82
+ // runtime rebuilds buildSystemBlocks() per turn (dynamic context: vault state,
83
+ // connector status, palace, onboarding phase, skills) — call this per turn in
84
+ // the bridge too, otherwise newly shipped skills and mid-session vault writes
85
+ // never reach a long-running bridge. Throws on failure; caller must handle.
86
+ async function writeSystemPrompt(auth) {
79
87
  const { uid, token } = auth;
80
- // Prefer the fully-built hosted system prompt so the bridge runtime behaves
81
- // identically to the cloud agent (tool-use policy, ui_payload sparsity,
82
- // plan thresholds, Gmail safety, connector pivots, personal AGENT.md, etc.).
83
- // If the pod isn't reachable yet, fall back to the user's AGENT.md alone —
84
- // Claude will still run, just without the platform policy layer.
85
- const systemPrompt = (await fetchSystemPrompt(token))
86
- ?? (await fetchVaultFile('AGENT.md', token))
87
- ?? (await fetchVaultFile('CLAUDE.md', token))
88
- ?? '';
88
+ const systemPrompt = await fetchSystemPrompt(token);
89
89
  writeRestricted(tmpFile(`agent-${uid}.md`), systemPrompt);
90
90
  if (VERBOSE) {
91
91
  console.log('\n[bridge:verbose] ─── system prompt ───────────────────────');
92
92
  console.log(systemPrompt);
93
93
  console.log('[bridge:verbose] ─── end system prompt ───────────────────\n');
94
94
  }
95
- // MCP config pointing at gateway's /mcp endpoint (proxied to agent-api)
95
+ }
96
+ function writeMcpConfig(auth) {
97
+ const { uid, token } = auth;
96
98
  const mcpConfig = {
97
99
  mcpServers: {
98
100
  '1presence': {
@@ -104,6 +106,10 @@ async function writeSetupFiles(auth) {
104
106
  };
105
107
  writeRestricted(tmpFile(`mcp-${uid}.json`), JSON.stringify(mcpConfig, null, 2));
106
108
  }
109
+ async function writeSetupFiles(auth) {
110
+ await writeSystemPrompt(auth);
111
+ writeMcpConfig(auth);
112
+ }
107
113
  // The MCP config embeds a Bearer JWT and the system prompt may contain vault
108
114
  // state. writeFileSync's mode only takes effect on file creation — chmodSync
109
115
  // covers the overwrite case so a legacy 0644 file gets tightened on next run.
@@ -136,6 +142,24 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
136
142
  }
137
143
  console.warn(`[bridge] token refresh failed (proceeding with current token): ${err.message}`);
138
144
  }
145
+ // Refresh the system prompt on every turn — the hosted runtime rebuilds its
146
+ // dynamic context per turn (vault state, connector status, palace, onboarding
147
+ // phase, newly enabled skills). If this fails we abort the turn rather than
148
+ // silently reuse a stale snapshot — parity with agent-api is the whole point
149
+ // of bridge mode, and stale prompts have caused user-visible regressions
150
+ // (e.g. agent vault-hunting for skills because the Skills authoritative
151
+ // rule was missing from the previous snapshot).
152
+ try {
153
+ await writeSystemPrompt(activeAuth);
154
+ }
155
+ catch (err) {
156
+ const message = `System prompt refresh failed: ${err.message}`;
157
+ console.error(`[${new Date().toLocaleTimeString()}] ✗ ${message}`);
158
+ if (currentWs?.readyState === ws_1.default.OPEN) {
159
+ currentWs.send(JSON.stringify({ type: 'error', conversationId, message }));
160
+ }
161
+ return;
162
+ }
139
163
  let responding = false;
140
164
  (0, claude_1.spawnClaude)({
141
165
  conversationId,
@@ -242,7 +266,9 @@ function connect(auth, retryDelay = 1000) {
242
266
  try {
243
267
  msg = JSON.parse(raw.toString());
244
268
  }
245
- catch {
269
+ catch (err) {
270
+ const preview = raw.toString().slice(0, 200);
271
+ console.error(`[bridge] failed to parse ws message as JSON: ${err.message} (raw: ${preview})`);
246
272
  return;
247
273
  }
248
274
  // Application-level pong — clear the timeout
@@ -277,19 +303,26 @@ function connect(auth, retryDelay = 1000) {
277
303
  console.log(`Bridge disconnected (${code}). Reconnecting in ${delay / 1000}s…`);
278
304
  setTimeout(async () => {
279
305
  try {
280
- // Refresh setup files on reconnect in case token was refreshed
306
+ // Refresh setup files on reconnect in case token was refreshed.
307
+ // If /system-prompt is down this throws — log and retry; the bridge
308
+ // is useless without a current prompt so don't paper over it.
281
309
  if (currentAuth)
282
310
  await writeSetupFiles(currentAuth);
283
311
  connect(currentAuth, Math.min(retryDelay * 2, 30_000));
284
312
  }
285
313
  catch (err) {
286
- console.error('Reconnect failed:', err.message);
314
+ console.error(`[bridge] reconnect setup failed: ${err.message}`);
315
+ console.error('[bridge] will retry connection anyway — system prompt may be stale until next refresh');
316
+ if (currentAuth)
317
+ connect(currentAuth, Math.min(retryDelay * 2, 30_000));
287
318
  }
288
319
  }, delay);
289
320
  });
290
321
  ws.on('error', (err) => {
291
322
  // close event fires after error — reconnect handled there
292
323
  console.error(`[bridge] ws error: ${err.message}`);
324
+ if (VERBOSE && err.stack)
325
+ console.error(err.stack);
293
326
  });
294
327
  return ws;
295
328
  }
@@ -309,9 +342,19 @@ async function main() {
309
342
  // ~/.1presence/config.json). In a non-TTY environment this is a no-op and
310
343
  // Claude Code's own default is used.
311
344
  await (0, config_1.ensureModelChoice)();
312
- // Write system prompt + MCP config
345
+ // Write system prompt + MCP config. If this fails the bridge is dead in the
346
+ // water — surface the underlying error rather than letting it bubble up as
347
+ // a generic "Fatal:" with no context.
313
348
  process.stdout.write('Setting up…');
314
- await writeSetupFiles(auth);
349
+ try {
350
+ await writeSetupFiles(auth);
351
+ }
352
+ catch (err) {
353
+ process.stdout.write(' FAILED.\n');
354
+ console.error(`[bridge] setup failed: ${err.message}`);
355
+ console.error('[bridge] cannot start without a system prompt from the gateway. Check network, auth, and that the gateway is reachable.');
356
+ process.exit(1);
357
+ }
315
358
  process.stdout.write(' done.\n');
316
359
  // Connect
317
360
  connect(auth);
@@ -323,6 +366,21 @@ async function main() {
323
366
  };
324
367
  process.on('SIGINT', shutdown);
325
368
  process.on('SIGTERM', shutdown);
369
+ // Surface anything that would otherwise vanish into the void. Without these,
370
+ // a thrown error inside an async callback (ws handler, child process event,
371
+ // setTimeout) silently kills the bridge with no diagnostic.
372
+ process.on('uncaughtException', (err) => {
373
+ console.error(`[bridge] uncaughtException: ${err.message}`);
374
+ if (err.stack)
375
+ console.error(err.stack);
376
+ });
377
+ process.on('unhandledRejection', (reason) => {
378
+ const message = reason instanceof Error ? reason.message : String(reason);
379
+ const stack = reason instanceof Error ? reason.stack : undefined;
380
+ console.error(`[bridge] unhandledRejection: ${message}`);
381
+ if (stack)
382
+ console.error(stack);
383
+ });
326
384
  }
327
385
  main().catch((err) => {
328
386
  if (err instanceof auth_1.AuthCancelledError) {
@@ -331,5 +389,7 @@ main().catch((err) => {
331
389
  process.exit(0);
332
390
  }
333
391
  console.error('Fatal:', err.message);
392
+ if (err.stack)
393
+ console.error(err.stack);
334
394
  process.exit(1);
335
395
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.14.0",
3
+ "version": "0.16.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"