@bitpub/cli 2.0.5 → 2.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.0.5",
3
+ "version": "2.1.1",
4
4
  "description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
5
5
  "bin": {
6
6
  "bitpub": "./bin/bitpub.js"
@@ -17,7 +17,7 @@ const http = require('http');
17
17
  const path = require('path');
18
18
  const fs = require('fs');
19
19
  const os = require('os');
20
- const { exec } = require('child_process');
20
+ const { exec, spawn } = require('child_process');
21
21
  const { readConfig, BITPUB_DIR } = require('../config');
22
22
  const { getSyncedNamespaces, initCache } = require('../db/cache');
23
23
  const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
@@ -108,6 +108,396 @@ const ASSET_MIME = {
108
108
  '.webm': 'video/webm',
109
109
  };
110
110
 
111
+ /* ── Bridge (apps → local CLI) ──────────────────────────────────────
112
+ *
113
+ * Sandboxed app iframes (default-src 'none', no allow-same-origin)
114
+ * cannot fetch, but they CAN postMessage to the parent window. The
115
+ * parent is the BitPub Browser shell; it forwards approved calls to
116
+ * this CLI process, which spawns a child and streams stdout/stderr
117
+ * back over Server-Sent Events.
118
+ *
119
+ * Security floor for v1:
120
+ * - Only functions listed in FUNCTIONS are runnable. Apps cannot
121
+ * execute arbitrary shell.
122
+ * - Arguments are typed and validated per function — no string
123
+ * interpolation into the shell.
124
+ * - The HTTP server binds to localhost only (default port 4141),
125
+ * so the surface is the same trust boundary as the CLI itself:
126
+ * anyone with shell access already has more power than the bridge.
127
+ * - The next layer (permission grants per app + audit log) is a
128
+ * console.html responsibility, not the CLI's.
129
+ *
130
+ * Functions in v1 are hardcoded here. The end state — functions
131
+ * resolved from slices, e.g. bitpub://group:tollbit.com/Functions/<id>
132
+ * — needs the same permission UX and a small sandbox for the script,
133
+ * so we ship the registry shape first and back-fill the resolver.
134
+ */
135
+ const FUNCTIONS = {
136
+ 'granola.ingest': {
137
+ description: 'Pull new Granola meetings into your private namespace.',
138
+ build(args) {
139
+ const limit = clampInt(args && args.limit, 1, 200, 5);
140
+ const script =
141
+ 'set -e; ' +
142
+ 'bitpub load bitpub://group:tollbit.com/Agents/granola-ingest/script > "$T" && ' +
143
+ `python3 "$T" --limit ${limit}`;
144
+ return {
145
+ cmd: 'bash',
146
+ args: ['-lc', script],
147
+ env: { T: path.join(os.tmpdir(), `.bitpub-fn-granola-ingest.py`) },
148
+ };
149
+ },
150
+ },
151
+
152
+ // Orchestration script for sales_brief.generate lives as a slice at
153
+ // bitpub://private:.../Apps/sales-brief-generator/orchestrator so the
154
+ // logic can be iterated without restarting the CLI. The function below
155
+ // is the thin entry point that loads + runs it.
156
+ 'sales_brief.generate': {
157
+ description: 'Synthesize a bespoke sales brief PDF from selected call transcripts.',
158
+ build(args) {
159
+ const a = args || {};
160
+ const transcriptHcus = Array.isArray(a.transcript_hcus) ? a.transcript_hcus.slice(0, 25) : [];
161
+ const template = String(a.template || 'discovery').slice(0, 32).replace(/[^a-z_]/gi, '');
162
+ const customer = String(a.customer || '').slice(0, 120);
163
+ const dealStage = String(a.deal_stage || '').slice(0, 80);
164
+ const extraNotes = String(a.extra_notes || '').slice(0, 2000);
165
+ const outputDir = path.join(os.homedir(), 'Downloads', 'Briefs');
166
+
167
+ if (transcriptHcus.length === 0) {
168
+ throw new Error('no transcripts selected');
169
+ }
170
+
171
+ const payload = JSON.stringify({
172
+ transcript_hcus: transcriptHcus,
173
+ template,
174
+ customer,
175
+ deal_stage: dealStage,
176
+ extra_notes: extraNotes,
177
+ output_dir: outputDir,
178
+ });
179
+
180
+ const script =
181
+ 'set -e; ' +
182
+ 'bitpub load bitpub://private:agent_ewim9gf01nq3/Apps/sales-brief-generator/orchestrator > "$ORCH" && ' +
183
+ 'python3 "$ORCH"';
184
+
185
+ return {
186
+ cmd: 'bash',
187
+ args: ['-lc', script],
188
+ env: {
189
+ ORCH: path.join(os.tmpdir(), '.bitpub-fn-sales-brief.py'),
190
+ BRIEF_PAYLOAD: payload,
191
+ },
192
+ };
193
+ },
194
+ },
195
+
196
+ 'sales_brief.open': {
197
+ description: 'Open a generated PDF in the default viewer.',
198
+ build(args) {
199
+ const p = String((args && args.path) || '');
200
+ if (!p || !p.startsWith('/') || p.includes('"')) throw new Error('invalid path');
201
+ return { cmd: 'bash', args: ['-lc', `open "${p}"`] };
202
+ },
203
+ },
204
+
205
+ 'sales_brief.reveal': {
206
+ description: 'Reveal a generated PDF in Finder.',
207
+ build(args) {
208
+ const p = String((args && args.path) || '');
209
+ if (!p || !p.startsWith('/') || p.includes('"')) throw new Error('invalid path');
210
+ return { cmd: 'bash', args: ['-lc', `open -R "${p}"`] };
211
+ },
212
+ },
213
+
214
+ // ── GitHub Pack ────────────────────────────────────────────
215
+ // The orchestrator slice lives at a GROUP address so every teammate
216
+ // in the group can load the same logic — they don't fork it. Each
217
+ // entry point injects BITPUB_OWNER from local config so the script
218
+ // writes to *the running user's* private namespace, not the author's.
219
+ //
220
+ // Auth model: orchestrator probes (1) macOS keychain entry from
221
+ // GitHub Desktop, (2) gh CLI, (3) BitPub-managed token. Tokens are
222
+ // never passed through args/URLs — child reads them itself so
223
+ // secrets never traverse the bridge.
224
+
225
+ 'github.check_auth': {
226
+ description: 'Detect available GitHub credentials (Desktop keychain, gh CLI, BitPub-managed token).',
227
+ build() {
228
+ return {
229
+ cmd: 'bash',
230
+ args: ['-lc',
231
+ 'set -e; ' +
232
+ 'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
233
+ 'python3 "$T"',
234
+ ],
235
+ env: {
236
+ T: path.join(os.tmpdir(), '.bitpub-fn-github-authcheck.py'),
237
+ BITPUB_GH_ACTION: 'auth_check',
238
+ BITPUB_OWNER: (readConfig() || {}).owner || '',
239
+ },
240
+ };
241
+ },
242
+ },
243
+
244
+ 'github.ingest': {
245
+ description: 'Mirror your GitHub PRs, issues, and recent activity into your namespace.',
246
+ build(args) {
247
+ const a = args || {};
248
+ const recipes = Array.isArray(a.recipes) && a.recipes.length
249
+ ? a.recipes.filter(r => typeof r === 'string' && /^[a-z_]+$/.test(r)).slice(0, 8)
250
+ : ['prs_review_requested', 'prs_mine', 'prs_org_recent', 'issues_assigned', 'activity_recent'];
251
+ return {
252
+ cmd: 'bash',
253
+ args: ['-lc',
254
+ 'set -e; ' +
255
+ 'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
256
+ 'python3 "$T"',
257
+ ],
258
+ env: {
259
+ T: path.join(os.tmpdir(), '.bitpub-fn-github-ingest.py'),
260
+ BITPUB_GH_RECIPES: recipes.join(','),
261
+ BITPUB_OWNER: (readConfig() || {}).owner || '',
262
+ },
263
+ };
264
+ },
265
+ },
266
+
267
+ 'github.open': {
268
+ description: 'Open a GitHub URL in the default browser.',
269
+ build(args) {
270
+ const url = String((args && args.url) || '');
271
+ if (!/^https:\/\/github\.com\//.test(url) || url.includes('"')) {
272
+ throw new Error('invalid github url');
273
+ }
274
+ return { cmd: 'bash', args: ['-lc', `open "${url}"`] };
275
+ },
276
+ },
277
+
278
+ 'github.open_clone': {
279
+ description: 'Open a repo\'s local clone in Finder (or VS Code if "in" === "vscode").',
280
+ build(args) {
281
+ const repo = String((args && args.repo) || '');
282
+ const where = String((args && args.in) || 'finder');
283
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
284
+ throw new Error('invalid repo "owner/name"');
285
+ }
286
+ const opener = where === 'vscode' ? 'code' :
287
+ where === 'iterm' ? `open -a iTerm` :
288
+ 'open';
289
+ // Search common project roots for a directory whose `git remote -v`
290
+ // points at this repo. Local-first move: we surface the user's
291
+ // clone if they have one, instead of dumping them at github.com.
292
+ const script = `
293
+ set -e
294
+ REPO='${repo}'
295
+ ROOTS=(~/projects ~/code ~/dev ~/src ~/Documents/projects ~/work)
296
+ for root in "\${ROOTS[@]}"; do
297
+ [ -d "$root" ] || continue
298
+ while IFS= read -r dir; do
299
+ if (cd "$dir" && git remote -v 2>/dev/null) | grep -qiE "[:/]\${REPO}(\\.git)?( |\\\$)"; then
300
+ echo "found clone: $dir"
301
+ ${opener} "$dir"
302
+ exit 0
303
+ fi
304
+ done < <(find "$root" -mindepth 1 -maxdepth 3 -type d -name .git -prune -exec dirname {} \\; 2>/dev/null)
305
+ done
306
+ echo "no local clone of $REPO found in common roots — opening on github.com instead"
307
+ open "https://github.com/$REPO"
308
+ `;
309
+ return { cmd: 'bash', args: ['-lc', script] };
310
+ },
311
+ },
312
+
313
+ 'github.summarize_pr': {
314
+ description: 'Summarize a PR with claude (the only LLM-in-the-loop step).',
315
+ build(args) {
316
+ const hcu = String((args && args.hcu) || '');
317
+ // Any private namespace, as long as it's pointed at GitHub/PRs/.
318
+ // We constrain the shape so an app can't trick the bridge into
319
+ // pulling arbitrary slices through claude.
320
+ if (!/^bitpub:\/\/private:[a-z0-9_-]+\/GitHub\/PRs\//i.test(hcu) || hcu.includes('"')) {
321
+ throw new Error('invalid PR hcu');
322
+ }
323
+ // Deterministic read (bitpub load) -> single LLM call (claude -p) ->
324
+ // structured text back to the UI. No MCP, no network round-trip for
325
+ // the data — only for the synthesis.
326
+ const script =
327
+ 'set -e; ' +
328
+ `bitpub load "${hcu}" > "$T" && ` +
329
+ 'claude -p "$(printf \'%s\\n\\nSummarize this GitHub PR in 3 short bullets: what it does, who it affects, anything risky. Be concrete.\' "$(cat "$T")")" ' +
330
+ '--output-format text --permission-mode bypassPermissions';
331
+ return {
332
+ cmd: 'bash',
333
+ args: ['-lc', script],
334
+ env: { T: path.join(os.tmpdir(), `.bitpub-fn-github-summarize.md`) },
335
+ };
336
+ },
337
+ },
338
+
339
+ 'github.action.approve': {
340
+ description: 'Approve a pull request via the GitHub API (uses inherited credentials).',
341
+ build(args) {
342
+ const repo = String((args && args.repo) || '');
343
+ const number = parseInt((args && args.number), 10);
344
+ const body = String((args && args.body) || '');
345
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) throw new Error('invalid repo');
346
+ if (!Number.isFinite(number) || number <= 0) throw new Error('invalid PR number');
347
+ return {
348
+ cmd: 'bash',
349
+ args: ['-lc',
350
+ 'set -e; ' +
351
+ 'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
352
+ 'python3 "$T"',
353
+ ],
354
+ env: {
355
+ T: path.join(os.tmpdir(), '.bitpub-fn-github-action.py'),
356
+ BITPUB_GH_ACTION: 'approve',
357
+ BITPUB_GH_REPO: repo,
358
+ BITPUB_GH_PR: String(number),
359
+ BITPUB_GH_BODY: body,
360
+ BITPUB_OWNER: (readConfig() || {}).owner || '',
361
+ },
362
+ };
363
+ },
364
+ },
365
+ };
366
+
367
+ function clampInt(value, lo, hi, dflt) {
368
+ const n = parseInt(value, 10);
369
+ if (!Number.isFinite(n)) return dflt;
370
+ return Math.max(lo, Math.min(hi, n));
371
+ }
372
+
373
+ /**
374
+ * Test whether an HCU matches a simple prefix pattern.
375
+ *
376
+ * Two pattern shapes are supported, which is enough for the
377
+ * "deterministic read against a namespace folder" idiom that apps use:
378
+ *
379
+ * - exact: bitpub://private:tp/Apps/foo
380
+ * - prefix: bitpub://private:tp/Transcripts/Raw/*
381
+ *
382
+ * The trailing `/*` means "any direct or transitive descendant," which
383
+ * matches how `bitpub list <folder>` already behaves and what apps want
384
+ * when they say "give me all my transcripts."
385
+ */
386
+ function hcuMatchesPattern(hcu, pattern) {
387
+ if (!hcu || !pattern) return false;
388
+ if (pattern.endsWith('/*')) {
389
+ return hcu.startsWith(pattern.slice(0, -2) + '/') || hcu === pattern.slice(0, -2);
390
+ }
391
+ return hcu === pattern;
392
+ }
393
+
394
+ /* Compact list of functions for the bridge router to advertise. */
395
+ function listFunctions() {
396
+ return Object.entries(FUNCTIONS).map(([id, fn]) => ({
397
+ id,
398
+ description: fn.description,
399
+ }));
400
+ }
401
+
402
+ /**
403
+ * Stream a function execution to the response as an SSE stream.
404
+ *
405
+ * Wire format:
406
+ * event: started data: { functionId }
407
+ * event: stdout data: { line }
408
+ * event: stderr data: { line }
409
+ * event: error data: { message }
410
+ * event: exit data: { code } ← terminal; connection closes
411
+ *
412
+ * Lines are flushed by newline so the UI shows progress as it happens
413
+ * instead of waiting for the whole stdout buffer to drain at exit.
414
+ */
415
+ function handleBridgeRun(req, res, url) {
416
+ const functionId = url.searchParams.get('functionId') || '';
417
+ const argsParam = url.searchParams.get('args') || '{}';
418
+ let args = {};
419
+ try { args = JSON.parse(argsParam); } catch { /* ignore — treat as no args */ }
420
+
421
+ const fn = FUNCTIONS[functionId];
422
+ if (!fn) {
423
+ res.statusCode = 404;
424
+ res.setHeader('Content-Type', 'application/json');
425
+ res.end(JSON.stringify({ error: 'unknown_function', functionId }));
426
+ return;
427
+ }
428
+
429
+ res.statusCode = 200;
430
+ res.setHeader('Content-Type', 'text/event-stream');
431
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
432
+ res.setHeader('Connection', 'keep-alive');
433
+ // Defeat any reverse-proxy buffering between us and the parent.
434
+ res.setHeader('X-Accel-Buffering', 'no');
435
+
436
+ function send(event, payload) {
437
+ if (res.writableEnded) return;
438
+ res.write(`event: ${event}\n`);
439
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
440
+ }
441
+
442
+ send('started', { functionId });
443
+
444
+ let spec;
445
+ try {
446
+ spec = fn.build(args || {});
447
+ } catch (err) {
448
+ send('error', { message: `bad arguments: ${err.message}` });
449
+ send('exit', { code: 2 });
450
+ res.end();
451
+ return;
452
+ }
453
+
454
+ let child;
455
+ try {
456
+ child = spawn(spec.cmd, spec.args, {
457
+ env: { ...process.env, ...(spec.env || {}) },
458
+ stdio: ['ignore', 'pipe', 'pipe'],
459
+ });
460
+ } catch (err) {
461
+ send('error', { message: `spawn failed: ${err.message}` });
462
+ send('exit', { code: 1 });
463
+ res.end();
464
+ return;
465
+ }
466
+
467
+ // Buffer per-stream so we can emit one event per line — keeps the
468
+ // log readable and avoids splitting multi-byte UTF-8 sequences.
469
+ function lineSink(streamName) {
470
+ let buf = '';
471
+ return (chunk) => {
472
+ buf += chunk.toString('utf-8');
473
+ let i;
474
+ while ((i = buf.indexOf('\n')) >= 0) {
475
+ const line = buf.slice(0, i).replace(/\r$/, '');
476
+ buf = buf.slice(i + 1);
477
+ send(streamName, { line });
478
+ }
479
+ };
480
+ }
481
+ child.stdout.on('data', lineSink('stdout'));
482
+ child.stderr.on('data', lineSink('stderr'));
483
+
484
+ child.on('error', (err) => {
485
+ send('error', { message: err.message });
486
+ });
487
+
488
+ child.on('close', (code) => {
489
+ send('exit', { code });
490
+ res.end();
491
+ });
492
+
493
+ // If the parent navigates away or the user cancels, reap the child.
494
+ req.on('close', () => {
495
+ if (child && !child.killed) {
496
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
497
+ }
498
+ });
499
+ }
500
+
111
501
  function openInBrowser(url) {
112
502
  const cmd = process.platform === 'darwin' ? 'open' :
113
503
  process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -177,6 +567,86 @@ function startBrowserServer(opts = {}) {
177
567
  return;
178
568
  }
179
569
 
570
+ // Bridge: who is this BitPub installation? Apps that ship via a
571
+ // group/public namespace can't hardcode an owner ID — they need to
572
+ // know "what is the current user's private prefix" at runtime so
573
+ // their namespace.list() / save() calls hit the right cache.
574
+ // Returns the minimum info needed: owner id, the domain (for
575
+ // display), and the canonical private namespace prefix.
576
+ if (url.pathname === '/bridge/identity') {
577
+ res.setHeader('Content-Type', 'application/json');
578
+ const owner = (cfg && cfg.owner) || null;
579
+ const domain = (cfg && cfg.domain) || null;
580
+ res.end(JSON.stringify({
581
+ owner,
582
+ domain,
583
+ private_prefix: owner ? `bitpub://private:${owner}` : null,
584
+ }));
585
+ return;
586
+ }
587
+
588
+ // Bridge: list registered functions (for the permission UI to
589
+ // describe what an app is about to run).
590
+ if (url.pathname === '/bridge/functions') {
591
+ res.setHeader('Content-Type', 'application/json');
592
+ res.end(JSON.stringify({ functions: listFunctions() }));
593
+ return;
594
+ }
595
+
596
+ // Bridge: stream a function execution to the console as SSE.
597
+ // Console.html forwards the events back to the requesting app
598
+ // iframe via postMessage. See FUNCTIONS above for the registry.
599
+ if (url.pathname === '/bridge/run') {
600
+ handleBridgeRun(req, res, url);
601
+ return;
602
+ }
603
+
604
+ // Bridge: deterministic namespace read. Apps call this to fetch
605
+ // any slices that match an HCU pattern (exact or prefix-with-/*).
606
+ // No LLM, no MCP, no auth in the loop — this is the read path
607
+ // that the ingest/read split makes free. Returns full decrypted
608
+ // payload + metadata for each matching slice, capped to a sane
609
+ // limit so a runaway pattern doesn't ship the whole cache.
610
+ if (url.pathname === '/bridge/namespace/list') {
611
+ res.setHeader('Content-Type', 'application/json');
612
+ const pattern = url.searchParams.get('pattern') || '';
613
+ const limit = clampInt(url.searchParams.get('limit'), 1, 1000, 200);
614
+ const fields = (url.searchParams.get('fields') || 'preview').toLowerCase();
615
+ if (!pattern || !/^bitpub:\/\//.test(pattern)) {
616
+ res.statusCode = 400;
617
+ res.end(JSON.stringify({ error: 'bad_pattern', pattern }));
618
+ return;
619
+ }
620
+ const matched = [];
621
+ for (const raw of getAllSlices()) {
622
+ if (!hcuMatchesPattern(raw.hcu, pattern)) continue;
623
+ const slice = (cfg && cfg.api_key) ? decryptSlice(raw, cfg.api_key) : raw;
624
+ let content = '';
625
+ try {
626
+ const payload = typeof slice.payload === 'string' ? JSON.parse(slice.payload) : slice.payload;
627
+ content = (payload && typeof payload.content === 'string') ? payload.content : '';
628
+ } catch { /* leave content blank if payload is malformed */ }
629
+ const item = {
630
+ hcu: slice.hcu,
631
+ version: slice.version,
632
+ last_synced: slice.last_synced,
633
+ author: slice.author,
634
+ byte_size: content.length,
635
+ };
636
+ if (fields === 'full') {
637
+ item.content = content;
638
+ } else {
639
+ // 'preview' = first 480 chars, lets the picker render a
640
+ // useful snippet without shipping every transcript body.
641
+ item.preview = content.slice(0, 480);
642
+ }
643
+ matched.push(item);
644
+ if (matched.length >= limit) break;
645
+ }
646
+ res.end(JSON.stringify({ pattern, count: matched.length, slices: matched }));
647
+ return;
648
+ }
649
+
180
650
  if (url.pathname === '/' || url.pathname === '/index.html') {
181
651
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
182
652
  res.end(loadHtml());
@@ -473,7 +473,11 @@ svg { flex-shrink: 0; }
473
473
  .file-row .f-when { font-size: 11px; color: var(--text-subtle); white-space: nowrap; font-variant-numeric: tabular-nums; }
474
474
 
475
475
  /* ── Slice panel (blob) ─────────────────────────────── */
476
- .blob-wrap { }
476
+ /* Slice-view wrapper. Slimmer top/bottom padding than the default
477
+ * .panel so the framed content (especially app iframes) sits closer
478
+ * to both edges of the main area. Top and bottom padding are kept
479
+ * equal so the visible margins around the framed window match. */
480
+ .blob-wrap { padding-top: 12px; padding-bottom: 12px; }
477
481
  .blob-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 2px 12px; font-size: 12px; color: var(--text-muted); }
478
482
  .blob-meta .sep { color: var(--border-strong); }
479
483
 
@@ -490,7 +494,28 @@ svg { flex-shrink: 0; }
490
494
  .encrypted-note svg { color: var(--scope-private); }
491
495
  .encrypted-note code { font-family: var(--mono); font-size: 12px; background: var(--bg-card); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
492
496
 
493
- .blob-container { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
497
+ /* Blob container acts as a "browser window" framing whatever's
498
+ * inside (markdown / source / sandboxed iframe). For app slices we
499
+ * add .is-app so the container stretches to claim most of the viewport
500
+ * and reads like a real OS window. For markdown/raw content the
501
+ * container hugs its content as before. */
502
+ .blob-container {
503
+ background: var(--bg-card);
504
+ border: 1px solid var(--border);
505
+ border-radius: var(--radius);
506
+ overflow: hidden;
507
+ display: flex; flex-direction: column;
508
+ box-shadow: 0 1px 2px rgba(20,20,20,.03), 0 6px 16px rgba(20,20,20,.04);
509
+ }
510
+ /* Container height math (so top/bottom margins around the iframe
511
+ * read as symmetric): viewport - (addressbar 52 + snapshot-banner ~38
512
+ * + 12 top pad + 12 bottom pad + ~6 safety) ≈ viewport - 120. The
513
+ * safety pixels prevent the page from showing a scrollbar when the
514
+ * snapshot-banner wraps to a second line on narrow widths. */
515
+ .blob-container.is-app { min-height: calc(100vh - 120px); }
516
+ .blob-container.is-app > .app-frame,
517
+ .blob-container.is-app > .content-body,
518
+ .blob-container.is-app > .content-raw { flex: 1; min-height: 0; }
494
519
  .blob-toolbar { padding: 8px 12px; background: var(--bg-canvas); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
495
520
  .blob-toolbar .info { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
496
521
  .blob-toolbar .spacer { flex: 1; }
@@ -743,9 +768,14 @@ svg { flex-shrink: 0; }
743
768
  }
744
769
  .cell-state.deleted .pip { background: #b91c1c; text-decoration: none; }
745
770
 
746
- /* ── Snapshot banner (slice view) ───────────────────── */
771
+ /* ── Snapshot banner (slice view) ─────────────────────
772
+ * Acts as the "title bar" above the content/iframe. Carries the
773
+ * "Latest value v#" label plus the consolidated identity chips
774
+ * (scope, mutability, last-write freshness, author, tags) that used
775
+ * to live in a separate row. Reads as one strip on top of the
776
+ * browser-window-style container below. */
747
777
  .snapshot-banner {
748
- display: flex; align-items: center; gap: 10px;
778
+ display: flex; align-items: center; gap: 8px;
749
779
  padding: 9px 14px;
750
780
  background: var(--bg-canvas);
751
781
  border: 1px solid var(--border);
@@ -756,8 +786,12 @@ svg { flex-shrink: 0; }
756
786
  }
757
787
  .snapshot-banner .title { color: var(--text); font-weight: 600; letter-spacing: -.005em; }
758
788
  .snapshot-banner .ver { font-family: var(--mono); color: var(--text-muted); }
759
- .snapshot-banner .caveat { margin-left: auto; font-size: 11px; color: var(--text-subtle); display: inline-flex; align-items: center; gap: 5px; }
760
- .snapshot-banner .caveat svg { color: var(--text-subtle); }
789
+ .snapshot-banner .meta-text { color: var(--text-muted); }
790
+ .snapshot-banner .sep { color: var(--border-strong); }
791
+ .snapshot-banner .scope-pill,
792
+ .snapshot-banner .cell-state,
793
+ .snapshot-banner .agent-chip,
794
+ .snapshot-banner .pill { flex-shrink: 0; }
761
795
  .blob-container.has-banner { border-top-left-radius: 0; border-top-right-radius: 0; }
762
796
 
763
797
  /* ── Write guide (slice view) ───────────────────────── */
@@ -835,9 +869,14 @@ svg { flex-shrink: 0; }
835
869
  frame fills the blob container's horizontal space and grows to a
836
870
  workable default height (the app can be taller; the user scrolls
837
871
  the main pane to follow). */
872
+ /* The iframe itself stretches to fill its (flex-sized) parent. The
873
+ * parent .blob-container's min-height drives the visible size, so this
874
+ * just makes sure the iframe occupies it fully and a long app scrolls
875
+ * inside the frame rather than expanding the outer page. */
838
876
  .app-frame {
839
877
  display: block;
840
878
  width: 100%;
879
+ flex: 1;
841
880
  min-height: 480px;
842
881
  border: 0;
843
882
  background: var(--bg-page);
@@ -1176,20 +1215,41 @@ async function pollData() {
1176
1215
  const k = s.hcu + '|' + (s.last_synced || '');
1177
1216
  if (!oldKey.has(k)) arrivals.add(s.hcu);
1178
1217
  }
1218
+
1219
+ // Nothing changed → skip the re-render entirely. Previously we
1220
+ // unconditionally called renderAll() every 30s, which tore down and
1221
+ // rebuilt the main pane — including any sandboxed iframe and the
1222
+ // state inside it (live bridge runs, scroll position, form input).
1223
+ if (arrivals.size === 0) {
1224
+ // Boot-race recovery still needs to fire on every tick: if the
1225
+ // welcome slice appears via some other path, navigate to it.
1226
+ maybeOpenWelcomeSlice();
1227
+ return;
1228
+ }
1229
+
1179
1230
  S.slices = nextSlices;
1180
1231
  S.mode = data.mode || S.mode;
1181
1232
  S.tree = buildTree(S.slices);
1182
1233
  S.stats = computeStats(S.slices);
1183
1234
  S.lastUpdated = maxTs(S.slices);
1184
- if (arrivals.size) {
1185
- S.newArrivals = arrivals;
1186
- triggerLivePulse();
1187
- setTimeout(() => { S.newArrivals = new Set(); }, 2500);
1188
- }
1189
- // Boot-race recovery: if install.sh opened us with ?welcome=1 before
1190
- // the welcome push had synced down, the slice may show up here on the
1191
- // first poll. Take the user to it as soon as it appears.
1235
+ S.newArrivals = arrivals;
1236
+ triggerLivePulse();
1237
+ setTimeout(() => { S.newArrivals = new Set(); }, 2500);
1192
1238
  maybeOpenWelcomeSlice();
1239
+
1240
+ // If we're currently viewing a slice and *that* slice didn't change,
1241
+ // only refresh the surrounding chrome (sidebar tree, address bar).
1242
+ // The main pane (which holds the iframe) is left alone so the app
1243
+ // keeps its DOM and runtime state.
1244
+ const viewedSliceChanged = S.view === 'slice' && S.selectedHcu && arrivals.has(S.selectedHcu);
1245
+ if (S.view === 'slice' && !viewedSliceChanged) {
1246
+ renderModeBadge();
1247
+ renderAddress();
1248
+ renderTree();
1249
+ $('clear-filter-btn').classList.toggle('hidden', !filterActive());
1250
+ return;
1251
+ }
1252
+
1193
1253
  renderAll();
1194
1254
  }
1195
1255
 
@@ -1553,8 +1613,18 @@ function renderBranch(scope, children, path) {
1553
1613
 
1554
1614
  let html = '';
1555
1615
 
1556
- // Aggregated direct-leaf entry for this level
1557
- if (directLeaves.length > 0) {
1616
+ // Direct leaves: render by name when small enough to be useful as a
1617
+ // package contents listing (e.g. an Apps/github folder showing its
1618
+ // `orchestrator` child by name). Aggregate into a single "N context
1619
+ // slices" summary above a threshold so very large folders (282
1620
+ // transcripts, hundreds of PRs) don't blow up the tree.
1621
+ const LEAF_NAME_THRESHOLD = 8;
1622
+ if (directLeaves.length > 0 && directLeaves.length <= LEAF_NAME_THRESHOLD) {
1623
+ const sorted = [...directLeaves].sort((a, b) => a.name.localeCompare(b.name));
1624
+ for (const { name, slice } of sorted) {
1625
+ html += renderLeaf(slice, name);
1626
+ }
1627
+ } else if (directLeaves.length > LEAF_NAME_THRESHOLD) {
1558
1628
  const mostRecent = directLeaves.reduce(
1559
1629
  (a, b) => ((a.slice.last_synced || '') > (b.slice.last_synced || '') ? a : b)
1560
1630
  );
@@ -1563,7 +1633,11 @@ function renderBranch(scope, children, path) {
1563
1633
  const pathArg = JSON.stringify(path).replace(/"/g, '&quot;');
1564
1634
  const isActiveSummary = S.view === 'namespace' && S.currentScope === scope && arrayEq(S.currentPath, path);
1565
1635
  const when = mostRecent.slice.last_synced ? timeAgo(mostRecent.slice.last_synced) : '';
1566
- html += `<div class="tree-leaf tree-summary${isActiveSummary ? ' active' : ''}" onclick="navigateTo('${escAttr(scope)}', ${pathArg})" title="View ${directLeaves.length} slice${directLeaves.length !== 1 ? 's' : ''} at this level">
1636
+ // Aggregated summary goes to the namespace listing (not the self-slice).
1637
+ // Calls navigateToNamespace to bypass the directory-index resolution in
1638
+ // navigateTo() — clicking "47 PRs" should show the listing, not whatever
1639
+ // happens to live at the parent address.
1640
+ html += `<div class="tree-leaf tree-summary${isActiveSummary ? ' active' : ''}" onclick="navigateToNamespace('${escAttr(scope)}', ${pathArg})" title="View ${directLeaves.length} slice${directLeaves.length !== 1 ? 's' : ''} at this level">
1567
1641
  <span class="fresh-dot ${freshClass}"></span>
1568
1642
  <span class="tree-label">${directLeaves.length} context slice${directLeaves.length !== 1 ? 's' : ''}</span>
1569
1643
  <span class="tree-badge">${esc(when)}</span>
@@ -1874,6 +1948,24 @@ function goRoot() {
1874
1948
  }
1875
1949
 
1876
1950
  function navigateTo(scope, path) {
1951
+ const segs = Array.isArray(path) ? path : [];
1952
+ // Directory-index semantics: if a slice exists AT this folder address
1953
+ // (e.g. an HTML app at `Apps/github` that also has `Apps/github/orchestrator`
1954
+ // as a child), render the slice as the primary view — same as a
1955
+ // browser serving /foo/index.html when you visit /foo/. Children stay
1956
+ // reachable via the tree chevron and named-leaf entries underneath.
1957
+ const selfHcu = `bitpub://${scope}` + (segs.length ? '/' + segs.join('/') : '');
1958
+ const self = S.slices.find(s => s.hcu === selfHcu);
1959
+ if (self) { selectSlice(selfHcu); return; }
1960
+
1961
+ navigateToNamespace(scope, segs);
1962
+ }
1963
+
1964
+ // Explicit "show me the folder listing" navigation. Used by the
1965
+ // aggregated "N context slices" summary in the tree, and anywhere
1966
+ // else we want to skip the directory-index check (e.g. "see all in
1967
+ // this namespace" links).
1968
+ function navigateToNamespace(scope, path) {
1877
1969
  S.view = 'namespace';
1878
1970
  S.currentScope = scope;
1879
1971
  S.currentPath = Array.isArray(path) ? path : [];
@@ -2334,25 +2426,11 @@ function renderSlice(sl) {
2334
2426
  // same info is now shown in the address bar at the top of the window
2335
2427
  // — keeping it in the panel was redundant.)
2336
2428
 
2337
- // Meta strip identity + mutability signal
2338
- html += '<div class="blob-meta">';
2339
- html += renderScopePill(type, scope);
2340
- html += renderCellState(sl);
2341
- html += `<span class="fresh-dot ${freshClass}" title="${fresh}" style="width:7px;height:7px"></span><span>last write ${sl.last_synced ? timeAgo(sl.last_synced) : '—'}</span>`;
2342
- html += `<span class="sep">·</span>`;
2343
- html += `<span>by</span> ${renderAgentChip(author)}`;
2344
- if (tags.length) {
2345
- html += `<span class="sep">·</span>`;
2346
- for (const t of tags) {
2347
- const active = S.filter.tags.has(t);
2348
- html += `<span class="pill tag${active ? ' active' : ''}" onclick="toggleTag('${escAttr(t)}')">${esc(t)}${active ? '<span class="x">×</span>' : ''}</span>`;
2349
- }
2350
- }
2351
- html += '</div>';
2352
-
2353
- // Write-guide + neighbors surfaced above the content so they're visible
2354
- // without scrolling — long slices can push them far offscreen otherwise.
2355
- html += renderWriteGuide(sl);
2429
+ // Sibling navigation still goes above the content. The write-guide
2430
+ // (push/append/safe-write CLI snippets) is intentionally hidden in
2431
+ // this view — it's geek-only and the non-technical audience doesn't
2432
+ // need to see CLI invocations next to their app. A future "developer
2433
+ // mode" toggle can bring it back behind an opt-in.
2356
2434
  html += renderSiblingsPanel(sl);
2357
2435
 
2358
2436
  // Encrypted note when remote + private
@@ -2362,11 +2440,26 @@ function renderSlice(sl) {
2362
2440
  </div>`;
2363
2441
  }
2364
2442
 
2365
- // Snapshot banner — reminds this is the current value, not a finished doc
2366
- html += `<div class="snapshot-banner" style="margin-top:${isEncryptedRemote ? '0' : '16px'}">
2443
+ // Snapshot banner — now also carries the identity/mutability chips
2444
+ // that used to live in a separate `.blob-meta` row above. Consolidating
2445
+ // saves a row and reads as one continuous strip above the iframe,
2446
+ // closer to a browser-tab metadata bar than two stacked headers.
2447
+ const tagsHtml = tags.map(t => {
2448
+ const active = S.filter.tags.has(t);
2449
+ return `<span class="pill tag${active ? ' active' : ''}" onclick="toggleTag('${escAttr(t)}')">${esc(t)}${active ? '<span class="x">×</span>' : ''}</span>`;
2450
+ }).join('');
2451
+ html += `<div class="snapshot-banner" style="margin-top:${isEncryptedRemote ? '0' : '12px'}">
2367
2452
  <span class="title">Latest value</span>
2368
2453
  <span class="ver">v${ver}</span>
2369
- <span class="caveat">${ICON.info}<span>Next push to this address replaces this slice in place.</span></span>
2454
+ <span class="sep">·</span>
2455
+ ${renderScopePill(type, scope)}
2456
+ ${renderCellState(sl)}
2457
+ <span class="sep">·</span>
2458
+ <span class="fresh-dot ${freshClass}" title="${fresh}" style="width:7px;height:7px"></span>
2459
+ <span class="meta-text">last write ${sl.last_synced ? timeAgo(sl.last_synced) : '—'}</span>
2460
+ <span class="sep">·</span>
2461
+ <span class="meta-text">by</span> ${renderAgentChip(author)}
2462
+ ${tagsHtml ? '<span class="sep">·</span>' + tagsHtml : ''}
2370
2463
  </div>`;
2371
2464
 
2372
2465
  // Content-type dispatcher. The payload's declared format is the
@@ -2379,7 +2472,7 @@ function renderSlice(sl) {
2379
2472
  // the v1 cut from product discussion.
2380
2473
  const canRenderAsApp = kind === 'html' && (type === 'private' || type === 'group');
2381
2474
 
2382
- html += '<div class="blob-container has-banner">';
2475
+ html += `<div class="blob-container has-banner${canRenderAsApp ? ' is-app' : ''}">`;
2383
2476
  html += '<div class="blob-toolbar">';
2384
2477
  html += '<div class="btn-group">';
2385
2478
  const previewLabel = canRenderAsApp ? 'App' : 'Preview';
@@ -2454,14 +2547,22 @@ function detectContentKind(sl, content) {
2454
2547
  * - referrerpolicy="no-referrer" so even allowed sub-resources can't
2455
2548
  * leak the user's location.
2456
2549
  *
2457
- * No bridge to bitpub is exposed yet — apps in v1 can render but cannot
2458
- * read from or write to the user's namespaces. The bridge is the next
2459
- * milestone and will be opt-in per Pack via a declared manifest.
2550
+ * Bridge (added in this version):
2551
+ * - A small inline shim defines `window.bitpub.run(id, args, cb)`
2552
+ * and `window.bitpub.watch(...)`. Calls postMessage the parent;
2553
+ * parent (this console.html) forwards approved invocations to the
2554
+ * local CLI's /bridge/run SSE endpoint and streams events back.
2555
+ * - The iframe never reaches the network itself — only the parent
2556
+ * does. CSP can stay locked down (default-src 'none').
2460
2557
  */
2461
2558
  function renderHtmlAppFrame(html) {
2462
2559
  const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'">`;
2463
2560
  const baseTag = `<base target="_blank">`;
2464
- const headInject = `${csp}${baseTag}`;
2561
+ // The shim tag uses \x73 + escaped slash so the closing pattern can't
2562
+ // be detected by the outer HTML parser. The escapes are transparent to
2563
+ // the JS parser but defeat the HTML scanner's greedy script-end match.
2564
+ const shim = `<\x73cript>${BRIDGE_SHIM_SOURCE}<\/script>`;
2565
+ const headInject = `${csp}${baseTag}${shim}`;
2465
2566
 
2466
2567
  let doc;
2467
2568
  if (/<head[\s>]/i.test(html)) {
@@ -2480,6 +2581,340 @@ function renderHtmlAppFrame(html) {
2480
2581
  return `<iframe class="app-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${srcdoc}"></iframe>`;
2481
2582
  }
2482
2583
 
2584
+ /**
2585
+ * Source of the in-iframe bridge shim. Kept as a template-literal so
2586
+ * we can embed it verbatim in the srcdoc above. The shim's only job is
2587
+ * to round-trip postMessage between the app code and the parent
2588
+ * window — it neither validates inputs nor reaches the network. The
2589
+ * parent (handleBridgeMessage below) is the trust boundary.
2590
+ *
2591
+ * The shim exposes a deliberately small callback-based API:
2592
+ *
2593
+ * window.bitpub.run(functionId, args, {
2594
+ * onStart(payload) // bridge accepted the call
2595
+ * onStdout(line) // one line of stdout
2596
+ * onStderr(line) // one line of stderr
2597
+ * onError({message}) // bridge or spawn-level error
2598
+ * onExit(code) // terminal — handler removed after this
2599
+ * })
2600
+ *
2601
+ * Returns the requestId so the app can cancel later (not yet wired).
2602
+ */
2603
+ const BRIDGE_SHIM_SOURCE = String.raw`
2604
+ (function () {
2605
+ if (window.bitpub) return;
2606
+ var nextId = 0;
2607
+ var runHandlers = new Map();
2608
+ var readHandlers = new Map();
2609
+ window.addEventListener('message', function (e) {
2610
+ var d = e.data;
2611
+ if (!d || d.__bitpub !== true) return;
2612
+ if (d.type === 'bitpub.event') {
2613
+ var h = runHandlers.get(d.requestId);
2614
+ if (!h) return;
2615
+ var p = d.payload || {};
2616
+ if (d.event === 'started' && h.onStart) h.onStart(p);
2617
+ else if (d.event === 'stdout' && h.onStdout) h.onStdout(p.line || '');
2618
+ else if (d.event === 'stderr' && h.onStderr) h.onStderr(p.line || '');
2619
+ else if (d.event === 'error' && h.onError) h.onError(p);
2620
+ else if (d.event === 'exit') {
2621
+ if (h.onExit) h.onExit(typeof p.code === 'number' ? p.code : null);
2622
+ runHandlers.delete(d.requestId);
2623
+ }
2624
+ } else if (d.type === 'bitpub.namespace.list.result') {
2625
+ var rh = readHandlers.get(d.requestId);
2626
+ if (!rh) return;
2627
+ readHandlers.delete(d.requestId);
2628
+ if (d.ok) rh.resolve({ slices: d.slices || [], count: d.count || 0, pattern: d.pattern || '' });
2629
+ else rh.reject(new Error(d.error || 'namespace read failed'));
2630
+ } else if (d.type === 'bitpub.identity.result') {
2631
+ var ih = readHandlers.get(d.requestId);
2632
+ if (!ih) return;
2633
+ readHandlers.delete(d.requestId);
2634
+ if (d.ok) ih.resolve({
2635
+ owner: d.owner,
2636
+ domain: d.domain,
2637
+ private_prefix: d.private_prefix,
2638
+ });
2639
+ else ih.reject(new Error(d.error || 'identity lookup failed'));
2640
+ }
2641
+ });
2642
+ window.bitpub = {
2643
+ run: function (functionId, args, callbacks) {
2644
+ var requestId = ++nextId;
2645
+ runHandlers.set(requestId, callbacks || {});
2646
+ window.parent.postMessage({
2647
+ __bitpub: true,
2648
+ type: 'bitpub.run',
2649
+ requestId: requestId,
2650
+ functionId: String(functionId),
2651
+ args: args || {}
2652
+ }, '*');
2653
+ return requestId;
2654
+ },
2655
+ namespace: {
2656
+ // list(pattern, { fields, limit })
2657
+ // pattern bitpub://... or bitpub://.../* (folder + descendants)
2658
+ // fields 'preview' (first ~480 chars) | 'full' default 'preview'
2659
+ // limit max slices returned default 200
2660
+ // Returns Promise<{ slices, count, pattern }>.
2661
+ // This is the deterministic-read path: no LLM, no MCP, no auth
2662
+ // dance. Apps that need to render data already in the user's
2663
+ // namespace should use this, not bitpub.run.
2664
+ list: function (pattern, opts) {
2665
+ return new Promise(function (resolve, reject) {
2666
+ var requestId = ++nextId;
2667
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2668
+ window.parent.postMessage({
2669
+ __bitpub: true,
2670
+ type: 'bitpub.namespace.list',
2671
+ requestId: requestId,
2672
+ pattern: String(pattern || ''),
2673
+ fields: (opts && opts.fields) || 'preview',
2674
+ limit: (opts && opts.limit) || 200
2675
+ }, '*');
2676
+ setTimeout(function () {
2677
+ if (!readHandlers.has(requestId)) return;
2678
+ readHandlers.delete(requestId);
2679
+ reject(new Error('namespace.list timed out'));
2680
+ }, 10000);
2681
+ });
2682
+ }
2683
+ },
2684
+ navigate: function (hcu) {
2685
+ // Hand off to another slice in the parent BitPub Browser
2686
+ // (e.g. discovery page → installed app). HCU must be a
2687
+ // bitpub:// address; everything else is dropped silently.
2688
+ window.parent.postMessage({
2689
+ __bitpub: true,
2690
+ type: 'bitpub.navigate',
2691
+ hcu: String(hcu || ''),
2692
+ }, '*');
2693
+ },
2694
+ identity: {
2695
+ // me() -> Promise<{ owner, domain, private_prefix }>
2696
+ // owner e.g. "agent_ewim9gf01nq3"
2697
+ // domain e.g. "tollbit.com"
2698
+ // private_prefix e.g. "bitpub://private:agent_ewim9gf01nq3"
2699
+ //
2700
+ // Lets apps that are distributed via a group/public namespace
2701
+ // construct addresses that point at the *running user's* private
2702
+ // data without hardcoding an owner ID. Use this if your app ships
2703
+ // to other people.
2704
+ me: function () {
2705
+ return new Promise(function (resolve, reject) {
2706
+ var requestId = ++nextId;
2707
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2708
+ window.parent.postMessage({
2709
+ __bitpub: true,
2710
+ type: 'bitpub.identity.me',
2711
+ requestId: requestId,
2712
+ }, '*');
2713
+ setTimeout(function () {
2714
+ if (!readHandlers.has(requestId)) return;
2715
+ readHandlers.delete(requestId);
2716
+ reject(new Error('identity.me timed out'));
2717
+ }, 5000);
2718
+ });
2719
+ }
2720
+ }
2721
+ };
2722
+ })();
2723
+ `;
2724
+
2725
+ /* ── Bridge router (parent side) ─────────────────────────────────────
2726
+ * Receives postMessages from rendered app iframes and forwards the
2727
+ * approved `bitpub.run(...)` calls to the local CLI's SSE endpoint at
2728
+ * /bridge/run. Each SSE event is reformatted as a `bitpub.event`
2729
+ * postMessage and sent back to the originating iframe so the app's
2730
+ * onStdout / onStderr / onExit callbacks fire.
2731
+ *
2732
+ * Trust model for v1:
2733
+ * - The iframe is at an opaque (null) origin, so we can't whitelist
2734
+ * by origin string. Instead we validate the message shape and
2735
+ * trust the in-process renderHtmlAppFrame to only mint iframes
2736
+ * for slices we already render (private + group scopes).
2737
+ * - The CLI enforces the function whitelist + argument types, so a
2738
+ * malformed message just gets rejected with HTTP 404 / SSE error.
2739
+ * - First UX iteration shows a non-blocking "Running" badge in the
2740
+ * chrome while at least one bridge call is active. Permission
2741
+ * banners with per-app grants come next iteration.
2742
+ */
2743
+ const BRIDGE_RUNS = new Map(); // requestId → { es, source, functionId }
2744
+ let BRIDGE_RUN_COUNT = 0;
2745
+
2746
+ function ensureBridgeBadge() {
2747
+ let el = document.getElementById('bridge-badge');
2748
+ if (el) return el;
2749
+ el = document.createElement('div');
2750
+ el.id = 'bridge-badge';
2751
+ el.style.cssText = [
2752
+ 'position:fixed', 'bottom:14px', 'right:14px', 'z-index:9999',
2753
+ 'background:#1A1A19', 'color:#fff', 'padding:8px 12px',
2754
+ 'border-radius:8px', 'font:500 12.5px -apple-system,BlinkMacSystemFont,sans-serif',
2755
+ 'box-shadow:0 6px 24px rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.12)',
2756
+ 'display:none', 'align-items:center', 'gap:8px',
2757
+ ].join(';');
2758
+ el.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#7ee787;display:inline-block;box-shadow:0 0 0 0 rgba(126,231,135,.6);animation:bp-pulse 1.6s ease-out infinite"></span><span id="bridge-badge-text">Running</span>';
2759
+ const style = document.createElement('style');
2760
+ style.textContent = '@keyframes bp-pulse{0%{box-shadow:0 0 0 0 rgba(126,231,135,.6)}70%{box-shadow:0 0 0 8px rgba(126,231,135,0)}100%{box-shadow:0 0 0 0 rgba(126,231,135,0)}}';
2761
+ document.head.appendChild(style);
2762
+ document.body.appendChild(el);
2763
+ return el;
2764
+ }
2765
+
2766
+ function updateBridgeBadge() {
2767
+ const el = ensureBridgeBadge();
2768
+ if (BRIDGE_RUN_COUNT <= 0) { el.style.display = 'none'; return; }
2769
+ const label = BRIDGE_RUN_COUNT === 1
2770
+ ? Array.from(BRIDGE_RUNS.values()).map(r => r.functionId).filter(Boolean)[0]
2771
+ : `${BRIDGE_RUN_COUNT} functions running`;
2772
+ document.getElementById('bridge-badge-text').textContent = `Running ${label}`;
2773
+ el.style.display = 'inline-flex';
2774
+ }
2775
+
2776
+ function bridgeForward(source, requestId, event, payload) {
2777
+ if (!source) return;
2778
+ try {
2779
+ source.postMessage({
2780
+ __bitpub: true,
2781
+ type: 'bitpub.event',
2782
+ requestId, event, payload,
2783
+ }, '*');
2784
+ } catch (_) { /* iframe gone */ }
2785
+ }
2786
+
2787
+ function handleBridgeRunMessage(source, msg) {
2788
+ const { requestId, functionId, args } = msg;
2789
+ if (typeof requestId !== 'number' || !functionId) return;
2790
+
2791
+ const qs = new URLSearchParams({
2792
+ functionId: String(functionId),
2793
+ args: JSON.stringify(args || {}),
2794
+ });
2795
+
2796
+ let es;
2797
+ try {
2798
+ es = new EventSource(`/bridge/run?${qs.toString()}`);
2799
+ } catch (err) {
2800
+ bridgeForward(source, requestId, 'error', { message: err.message });
2801
+ bridgeForward(source, requestId, 'exit', { code: 1 });
2802
+ return;
2803
+ }
2804
+
2805
+ BRIDGE_RUNS.set(requestId, { es, source, functionId });
2806
+ BRIDGE_RUN_COUNT++;
2807
+ updateBridgeBadge();
2808
+
2809
+ function relay(name) {
2810
+ es.addEventListener(name, (ev) => {
2811
+ let payload = {};
2812
+ try { payload = JSON.parse(ev.data); } catch { /* ignore malformed */ }
2813
+ bridgeForward(source, requestId, name, payload);
2814
+ });
2815
+ }
2816
+ relay('started');
2817
+ relay('stdout');
2818
+ relay('stderr');
2819
+ relay('error');
2820
+
2821
+ es.addEventListener('exit', (ev) => {
2822
+ let payload = {};
2823
+ try { payload = JSON.parse(ev.data); } catch { /* ignore */ }
2824
+ bridgeForward(source, requestId, 'exit', payload);
2825
+ es.close();
2826
+ BRIDGE_RUNS.delete(requestId);
2827
+ BRIDGE_RUN_COUNT--;
2828
+ updateBridgeBadge();
2829
+ });
2830
+
2831
+ es.onerror = () => {
2832
+ // Connection loss / endpoint down. The bridge already emits an
2833
+ // explicit `exit` for graceful termination, so reaching here means
2834
+ // an unexpected drop — surface it and clean up.
2835
+ if (!BRIDGE_RUNS.has(requestId)) return;
2836
+ bridgeForward(source, requestId, 'error', { message: 'bridge stream closed unexpectedly' });
2837
+ bridgeForward(source, requestId, 'exit', { code: -1 });
2838
+ try { es.close(); } catch (_) {}
2839
+ BRIDGE_RUNS.delete(requestId);
2840
+ BRIDGE_RUN_COUNT--;
2841
+ updateBridgeBadge();
2842
+ };
2843
+ }
2844
+
2845
+ async function handleBridgeNamespaceListMessage(source, msg) {
2846
+ const { requestId, pattern, fields, limit } = msg;
2847
+ if (typeof requestId !== 'number' || !pattern) return;
2848
+ const reply = (extra) => {
2849
+ try {
2850
+ source.postMessage({
2851
+ __bitpub: true,
2852
+ type: 'bitpub.namespace.list.result',
2853
+ requestId, pattern,
2854
+ ...extra,
2855
+ }, '*');
2856
+ } catch (_) { /* iframe gone */ }
2857
+ };
2858
+ try {
2859
+ const qs = new URLSearchParams({
2860
+ pattern: String(pattern),
2861
+ fields: String(fields || 'preview'),
2862
+ limit: String(limit || 200),
2863
+ });
2864
+ const r = await fetch(`/bridge/namespace/list?${qs.toString()}`);
2865
+ const body = await r.json();
2866
+ if (!r.ok) {
2867
+ reply({ ok: false, error: body && body.error || `http ${r.status}` });
2868
+ return;
2869
+ }
2870
+ reply({ ok: true, slices: body.slices || [], count: body.count || 0 });
2871
+ } catch (err) {
2872
+ reply({ ok: false, error: err && err.message || 'fetch failed' });
2873
+ }
2874
+ }
2875
+
2876
+ async function handleBridgeIdentityMessage(source, msg) {
2877
+ const { requestId } = msg;
2878
+ if (typeof requestId !== 'number') return;
2879
+ const reply = (extra) => {
2880
+ try {
2881
+ source.postMessage({
2882
+ __bitpub: true,
2883
+ type: 'bitpub.identity.result',
2884
+ requestId,
2885
+ ...extra,
2886
+ }, '*');
2887
+ } catch (_) { /* iframe gone */ }
2888
+ };
2889
+ try {
2890
+ const r = await fetch('/bridge/identity');
2891
+ const body = await r.json();
2892
+ if (!r.ok) { reply({ ok: false, error: `http ${r.status}` }); return; }
2893
+ reply({ ok: true, owner: body.owner, domain: body.domain, private_prefix: body.private_prefix });
2894
+ } catch (err) {
2895
+ reply({ ok: false, error: err && err.message || 'fetch failed' });
2896
+ }
2897
+ }
2898
+
2899
+ function handleBridgeNavigateMessage(_source, msg) {
2900
+ // Apps can hand off to another slice in the running user's Browser.
2901
+ // We only allow bitpub:// addresses (no http:, no javascript:), and
2902
+ // we route through submitAddress() which already does all the
2903
+ // existing scope/path parsing.
2904
+ const hcu = String((msg && msg.hcu) || '').trim();
2905
+ if (!/^bitpub:\/\/[^\s]+$/.test(hcu)) return;
2906
+ try { submitAddress(hcu); } catch (_) { /* ignore */ }
2907
+ }
2908
+
2909
+ window.addEventListener('message', (e) => {
2910
+ const d = e.data;
2911
+ if (!d || typeof d !== 'object' || d.__bitpub !== true) return;
2912
+ if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
2913
+ else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
2914
+ else if (d.type === 'bitpub.identity.me') handleBridgeIdentityMessage(e.source, d);
2915
+ else if (d.type === 'bitpub.navigate' || d.type === 'navigate') handleBridgeNavigateMessage(e.source, d);
2916
+ });
2917
+
2483
2918
  function renderWriteGuide(sl) {
2484
2919
  const addr = sl.hcu;
2485
2920
  const ver = sl.metadata.version || 1;