@bitpub/cli 2.0.5 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.0.5",
3
+ "version": "2.1.0",
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,244 @@ 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
+
215
+ function clampInt(value, lo, hi, dflt) {
216
+ const n = parseInt(value, 10);
217
+ if (!Number.isFinite(n)) return dflt;
218
+ return Math.max(lo, Math.min(hi, n));
219
+ }
220
+
221
+ /**
222
+ * Test whether an HCU matches a simple prefix pattern.
223
+ *
224
+ * Two pattern shapes are supported, which is enough for the
225
+ * "deterministic read against a namespace folder" idiom that apps use:
226
+ *
227
+ * - exact: bitpub://private:tp/Apps/foo
228
+ * - prefix: bitpub://private:tp/Transcripts/Raw/*
229
+ *
230
+ * The trailing `/*` means "any direct or transitive descendant," which
231
+ * matches how `bitpub list <folder>` already behaves and what apps want
232
+ * when they say "give me all my transcripts."
233
+ */
234
+ function hcuMatchesPattern(hcu, pattern) {
235
+ if (!hcu || !pattern) return false;
236
+ if (pattern.endsWith('/*')) {
237
+ return hcu.startsWith(pattern.slice(0, -2) + '/') || hcu === pattern.slice(0, -2);
238
+ }
239
+ return hcu === pattern;
240
+ }
241
+
242
+ /* Compact list of functions for the bridge router to advertise. */
243
+ function listFunctions() {
244
+ return Object.entries(FUNCTIONS).map(([id, fn]) => ({
245
+ id,
246
+ description: fn.description,
247
+ }));
248
+ }
249
+
250
+ /**
251
+ * Stream a function execution to the response as an SSE stream.
252
+ *
253
+ * Wire format:
254
+ * event: started data: { functionId }
255
+ * event: stdout data: { line }
256
+ * event: stderr data: { line }
257
+ * event: error data: { message }
258
+ * event: exit data: { code } ← terminal; connection closes
259
+ *
260
+ * Lines are flushed by newline so the UI shows progress as it happens
261
+ * instead of waiting for the whole stdout buffer to drain at exit.
262
+ */
263
+ function handleBridgeRun(req, res, url) {
264
+ const functionId = url.searchParams.get('functionId') || '';
265
+ const argsParam = url.searchParams.get('args') || '{}';
266
+ let args = {};
267
+ try { args = JSON.parse(argsParam); } catch { /* ignore — treat as no args */ }
268
+
269
+ const fn = FUNCTIONS[functionId];
270
+ if (!fn) {
271
+ res.statusCode = 404;
272
+ res.setHeader('Content-Type', 'application/json');
273
+ res.end(JSON.stringify({ error: 'unknown_function', functionId }));
274
+ return;
275
+ }
276
+
277
+ res.statusCode = 200;
278
+ res.setHeader('Content-Type', 'text/event-stream');
279
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
280
+ res.setHeader('Connection', 'keep-alive');
281
+ // Defeat any reverse-proxy buffering between us and the parent.
282
+ res.setHeader('X-Accel-Buffering', 'no');
283
+
284
+ function send(event, payload) {
285
+ if (res.writableEnded) return;
286
+ res.write(`event: ${event}\n`);
287
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
288
+ }
289
+
290
+ send('started', { functionId });
291
+
292
+ let spec;
293
+ try {
294
+ spec = fn.build(args || {});
295
+ } catch (err) {
296
+ send('error', { message: `bad arguments: ${err.message}` });
297
+ send('exit', { code: 2 });
298
+ res.end();
299
+ return;
300
+ }
301
+
302
+ let child;
303
+ try {
304
+ child = spawn(spec.cmd, spec.args, {
305
+ env: { ...process.env, ...(spec.env || {}) },
306
+ stdio: ['ignore', 'pipe', 'pipe'],
307
+ });
308
+ } catch (err) {
309
+ send('error', { message: `spawn failed: ${err.message}` });
310
+ send('exit', { code: 1 });
311
+ res.end();
312
+ return;
313
+ }
314
+
315
+ // Buffer per-stream so we can emit one event per line — keeps the
316
+ // log readable and avoids splitting multi-byte UTF-8 sequences.
317
+ function lineSink(streamName) {
318
+ let buf = '';
319
+ return (chunk) => {
320
+ buf += chunk.toString('utf-8');
321
+ let i;
322
+ while ((i = buf.indexOf('\n')) >= 0) {
323
+ const line = buf.slice(0, i).replace(/\r$/, '');
324
+ buf = buf.slice(i + 1);
325
+ send(streamName, { line });
326
+ }
327
+ };
328
+ }
329
+ child.stdout.on('data', lineSink('stdout'));
330
+ child.stderr.on('data', lineSink('stderr'));
331
+
332
+ child.on('error', (err) => {
333
+ send('error', { message: err.message });
334
+ });
335
+
336
+ child.on('close', (code) => {
337
+ send('exit', { code });
338
+ res.end();
339
+ });
340
+
341
+ // If the parent navigates away or the user cancels, reap the child.
342
+ req.on('close', () => {
343
+ if (child && !child.killed) {
344
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
345
+ }
346
+ });
347
+ }
348
+
111
349
  function openInBrowser(url) {
112
350
  const cmd = process.platform === 'darwin' ? 'open' :
113
351
  process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -177,6 +415,68 @@ function startBrowserServer(opts = {}) {
177
415
  return;
178
416
  }
179
417
 
418
+ // Bridge: list registered functions (for the permission UI to
419
+ // describe what an app is about to run).
420
+ if (url.pathname === '/bridge/functions') {
421
+ res.setHeader('Content-Type', 'application/json');
422
+ res.end(JSON.stringify({ functions: listFunctions() }));
423
+ return;
424
+ }
425
+
426
+ // Bridge: stream a function execution to the console as SSE.
427
+ // Console.html forwards the events back to the requesting app
428
+ // iframe via postMessage. See FUNCTIONS above for the registry.
429
+ if (url.pathname === '/bridge/run') {
430
+ handleBridgeRun(req, res, url);
431
+ return;
432
+ }
433
+
434
+ // Bridge: deterministic namespace read. Apps call this to fetch
435
+ // any slices that match an HCU pattern (exact or prefix-with-/*).
436
+ // No LLM, no MCP, no auth in the loop — this is the read path
437
+ // that the ingest/read split makes free. Returns full decrypted
438
+ // payload + metadata for each matching slice, capped to a sane
439
+ // limit so a runaway pattern doesn't ship the whole cache.
440
+ if (url.pathname === '/bridge/namespace/list') {
441
+ res.setHeader('Content-Type', 'application/json');
442
+ const pattern = url.searchParams.get('pattern') || '';
443
+ const limit = clampInt(url.searchParams.get('limit'), 1, 1000, 200);
444
+ const fields = (url.searchParams.get('fields') || 'preview').toLowerCase();
445
+ if (!pattern || !/^bitpub:\/\//.test(pattern)) {
446
+ res.statusCode = 400;
447
+ res.end(JSON.stringify({ error: 'bad_pattern', pattern }));
448
+ return;
449
+ }
450
+ const matched = [];
451
+ for (const raw of getAllSlices()) {
452
+ if (!hcuMatchesPattern(raw.hcu, pattern)) continue;
453
+ const slice = (cfg && cfg.api_key) ? decryptSlice(raw, cfg.api_key) : raw;
454
+ let content = '';
455
+ try {
456
+ const payload = typeof slice.payload === 'string' ? JSON.parse(slice.payload) : slice.payload;
457
+ content = (payload && typeof payload.content === 'string') ? payload.content : '';
458
+ } catch { /* leave content blank if payload is malformed */ }
459
+ const item = {
460
+ hcu: slice.hcu,
461
+ version: slice.version,
462
+ last_synced: slice.last_synced,
463
+ author: slice.author,
464
+ byte_size: content.length,
465
+ };
466
+ if (fields === 'full') {
467
+ item.content = content;
468
+ } else {
469
+ // 'preview' = first 480 chars, lets the picker render a
470
+ // useful snippet without shipping every transcript body.
471
+ item.preview = content.slice(0, 480);
472
+ }
473
+ matched.push(item);
474
+ if (matched.length >= limit) break;
475
+ }
476
+ res.end(JSON.stringify({ pattern, count: matched.length, slices: matched }));
477
+ return;
478
+ }
479
+
180
480
  if (url.pathname === '/' || url.pathname === '/index.html') {
181
481
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
182
482
  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
 
@@ -2334,25 +2394,11 @@ function renderSlice(sl) {
2334
2394
  // same info is now shown in the address bar at the top of the window
2335
2395
  // — keeping it in the panel was redundant.)
2336
2396
 
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);
2397
+ // Sibling navigation still goes above the content. The write-guide
2398
+ // (push/append/safe-write CLI snippets) is intentionally hidden in
2399
+ // this view — it's geek-only and the non-technical audience doesn't
2400
+ // need to see CLI invocations next to their app. A future "developer
2401
+ // mode" toggle can bring it back behind an opt-in.
2356
2402
  html += renderSiblingsPanel(sl);
2357
2403
 
2358
2404
  // Encrypted note when remote + private
@@ -2362,11 +2408,26 @@ function renderSlice(sl) {
2362
2408
  </div>`;
2363
2409
  }
2364
2410
 
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'}">
2411
+ // Snapshot banner — now also carries the identity/mutability chips
2412
+ // that used to live in a separate `.blob-meta` row above. Consolidating
2413
+ // saves a row and reads as one continuous strip above the iframe,
2414
+ // closer to a browser-tab metadata bar than two stacked headers.
2415
+ const tagsHtml = tags.map(t => {
2416
+ const active = S.filter.tags.has(t);
2417
+ return `<span class="pill tag${active ? ' active' : ''}" onclick="toggleTag('${escAttr(t)}')">${esc(t)}${active ? '<span class="x">×</span>' : ''}</span>`;
2418
+ }).join('');
2419
+ html += `<div class="snapshot-banner" style="margin-top:${isEncryptedRemote ? '0' : '12px'}">
2367
2420
  <span class="title">Latest value</span>
2368
2421
  <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>
2422
+ <span class="sep">·</span>
2423
+ ${renderScopePill(type, scope)}
2424
+ ${renderCellState(sl)}
2425
+ <span class="sep">·</span>
2426
+ <span class="fresh-dot ${freshClass}" title="${fresh}" style="width:7px;height:7px"></span>
2427
+ <span class="meta-text">last write ${sl.last_synced ? timeAgo(sl.last_synced) : '—'}</span>
2428
+ <span class="sep">·</span>
2429
+ <span class="meta-text">by</span> ${renderAgentChip(author)}
2430
+ ${tagsHtml ? '<span class="sep">·</span>' + tagsHtml : ''}
2370
2431
  </div>`;
2371
2432
 
2372
2433
  // Content-type dispatcher. The payload's declared format is the
@@ -2379,7 +2440,7 @@ function renderSlice(sl) {
2379
2440
  // the v1 cut from product discussion.
2380
2441
  const canRenderAsApp = kind === 'html' && (type === 'private' || type === 'group');
2381
2442
 
2382
- html += '<div class="blob-container has-banner">';
2443
+ html += `<div class="blob-container has-banner${canRenderAsApp ? ' is-app' : ''}">`;
2383
2444
  html += '<div class="blob-toolbar">';
2384
2445
  html += '<div class="btn-group">';
2385
2446
  const previewLabel = canRenderAsApp ? 'App' : 'Preview';
@@ -2454,14 +2515,22 @@ function detectContentKind(sl, content) {
2454
2515
  * - referrerpolicy="no-referrer" so even allowed sub-resources can't
2455
2516
  * leak the user's location.
2456
2517
  *
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.
2518
+ * Bridge (added in this version):
2519
+ * - A small inline shim defines `window.bitpub.run(id, args, cb)`
2520
+ * and `window.bitpub.watch(...)`. Calls postMessage the parent;
2521
+ * parent (this console.html) forwards approved invocations to the
2522
+ * local CLI's /bridge/run SSE endpoint and streams events back.
2523
+ * - The iframe never reaches the network itself — only the parent
2524
+ * does. CSP can stay locked down (default-src 'none').
2460
2525
  */
2461
2526
  function renderHtmlAppFrame(html) {
2462
2527
  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
2528
  const baseTag = `<base target="_blank">`;
2464
- const headInject = `${csp}${baseTag}`;
2529
+ // The shim tag uses \x73 + escaped slash so the closing pattern can't
2530
+ // be detected by the outer HTML parser. The escapes are transparent to
2531
+ // the JS parser but defeat the HTML scanner's greedy script-end match.
2532
+ const shim = `<\x73cript>${BRIDGE_SHIM_SOURCE}<\/script>`;
2533
+ const headInject = `${csp}${baseTag}${shim}`;
2465
2534
 
2466
2535
  let doc;
2467
2536
  if (/<head[\s>]/i.test(html)) {
@@ -2480,6 +2549,258 @@ function renderHtmlAppFrame(html) {
2480
2549
  return `<iframe class="app-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${srcdoc}"></iframe>`;
2481
2550
  }
2482
2551
 
2552
+ /**
2553
+ * Source of the in-iframe bridge shim. Kept as a template-literal so
2554
+ * we can embed it verbatim in the srcdoc above. The shim's only job is
2555
+ * to round-trip postMessage between the app code and the parent
2556
+ * window — it neither validates inputs nor reaches the network. The
2557
+ * parent (handleBridgeMessage below) is the trust boundary.
2558
+ *
2559
+ * The shim exposes a deliberately small callback-based API:
2560
+ *
2561
+ * window.bitpub.run(functionId, args, {
2562
+ * onStart(payload) // bridge accepted the call
2563
+ * onStdout(line) // one line of stdout
2564
+ * onStderr(line) // one line of stderr
2565
+ * onError({message}) // bridge or spawn-level error
2566
+ * onExit(code) // terminal — handler removed after this
2567
+ * })
2568
+ *
2569
+ * Returns the requestId so the app can cancel later (not yet wired).
2570
+ */
2571
+ const BRIDGE_SHIM_SOURCE = String.raw`
2572
+ (function () {
2573
+ if (window.bitpub) return;
2574
+ var nextId = 0;
2575
+ var runHandlers = new Map();
2576
+ var readHandlers = new Map();
2577
+ window.addEventListener('message', function (e) {
2578
+ var d = e.data;
2579
+ if (!d || d.__bitpub !== true) return;
2580
+ if (d.type === 'bitpub.event') {
2581
+ var h = runHandlers.get(d.requestId);
2582
+ if (!h) return;
2583
+ var p = d.payload || {};
2584
+ if (d.event === 'started' && h.onStart) h.onStart(p);
2585
+ else if (d.event === 'stdout' && h.onStdout) h.onStdout(p.line || '');
2586
+ else if (d.event === 'stderr' && h.onStderr) h.onStderr(p.line || '');
2587
+ else if (d.event === 'error' && h.onError) h.onError(p);
2588
+ else if (d.event === 'exit') {
2589
+ if (h.onExit) h.onExit(typeof p.code === 'number' ? p.code : null);
2590
+ runHandlers.delete(d.requestId);
2591
+ }
2592
+ } else if (d.type === 'bitpub.namespace.list.result') {
2593
+ var rh = readHandlers.get(d.requestId);
2594
+ if (!rh) return;
2595
+ readHandlers.delete(d.requestId);
2596
+ if (d.ok) rh.resolve({ slices: d.slices || [], count: d.count || 0, pattern: d.pattern || '' });
2597
+ else rh.reject(new Error(d.error || 'namespace read failed'));
2598
+ }
2599
+ });
2600
+ window.bitpub = {
2601
+ run: function (functionId, args, callbacks) {
2602
+ var requestId = ++nextId;
2603
+ runHandlers.set(requestId, callbacks || {});
2604
+ window.parent.postMessage({
2605
+ __bitpub: true,
2606
+ type: 'bitpub.run',
2607
+ requestId: requestId,
2608
+ functionId: String(functionId),
2609
+ args: args || {}
2610
+ }, '*');
2611
+ return requestId;
2612
+ },
2613
+ namespace: {
2614
+ // list(pattern, { fields, limit })
2615
+ // pattern bitpub://... or bitpub://.../* (folder + descendants)
2616
+ // fields 'preview' (first ~480 chars) | 'full' default 'preview'
2617
+ // limit max slices returned default 200
2618
+ // Returns Promise<{ slices, count, pattern }>.
2619
+ // This is the deterministic-read path: no LLM, no MCP, no auth
2620
+ // dance. Apps that need to render data already in the user's
2621
+ // namespace should use this, not bitpub.run.
2622
+ list: function (pattern, opts) {
2623
+ return new Promise(function (resolve, reject) {
2624
+ var requestId = ++nextId;
2625
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2626
+ window.parent.postMessage({
2627
+ __bitpub: true,
2628
+ type: 'bitpub.namespace.list',
2629
+ requestId: requestId,
2630
+ pattern: String(pattern || ''),
2631
+ fields: (opts && opts.fields) || 'preview',
2632
+ limit: (opts && opts.limit) || 200
2633
+ }, '*');
2634
+ setTimeout(function () {
2635
+ if (!readHandlers.has(requestId)) return;
2636
+ readHandlers.delete(requestId);
2637
+ reject(new Error('namespace.list timed out'));
2638
+ }, 10000);
2639
+ });
2640
+ }
2641
+ }
2642
+ };
2643
+ })();
2644
+ `;
2645
+
2646
+ /* ── Bridge router (parent side) ─────────────────────────────────────
2647
+ * Receives postMessages from rendered app iframes and forwards the
2648
+ * approved `bitpub.run(...)` calls to the local CLI's SSE endpoint at
2649
+ * /bridge/run. Each SSE event is reformatted as a `bitpub.event`
2650
+ * postMessage and sent back to the originating iframe so the app's
2651
+ * onStdout / onStderr / onExit callbacks fire.
2652
+ *
2653
+ * Trust model for v1:
2654
+ * - The iframe is at an opaque (null) origin, so we can't whitelist
2655
+ * by origin string. Instead we validate the message shape and
2656
+ * trust the in-process renderHtmlAppFrame to only mint iframes
2657
+ * for slices we already render (private + group scopes).
2658
+ * - The CLI enforces the function whitelist + argument types, so a
2659
+ * malformed message just gets rejected with HTTP 404 / SSE error.
2660
+ * - First UX iteration shows a non-blocking "Running" badge in the
2661
+ * chrome while at least one bridge call is active. Permission
2662
+ * banners with per-app grants come next iteration.
2663
+ */
2664
+ const BRIDGE_RUNS = new Map(); // requestId → { es, source, functionId }
2665
+ let BRIDGE_RUN_COUNT = 0;
2666
+
2667
+ function ensureBridgeBadge() {
2668
+ let el = document.getElementById('bridge-badge');
2669
+ if (el) return el;
2670
+ el = document.createElement('div');
2671
+ el.id = 'bridge-badge';
2672
+ el.style.cssText = [
2673
+ 'position:fixed', 'bottom:14px', 'right:14px', 'z-index:9999',
2674
+ 'background:#1A1A19', 'color:#fff', 'padding:8px 12px',
2675
+ 'border-radius:8px', 'font:500 12.5px -apple-system,BlinkMacSystemFont,sans-serif',
2676
+ 'box-shadow:0 6px 24px rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.12)',
2677
+ 'display:none', 'align-items:center', 'gap:8px',
2678
+ ].join(';');
2679
+ 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>';
2680
+ const style = document.createElement('style');
2681
+ 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)}}';
2682
+ document.head.appendChild(style);
2683
+ document.body.appendChild(el);
2684
+ return el;
2685
+ }
2686
+
2687
+ function updateBridgeBadge() {
2688
+ const el = ensureBridgeBadge();
2689
+ if (BRIDGE_RUN_COUNT <= 0) { el.style.display = 'none'; return; }
2690
+ const label = BRIDGE_RUN_COUNT === 1
2691
+ ? Array.from(BRIDGE_RUNS.values()).map(r => r.functionId).filter(Boolean)[0]
2692
+ : `${BRIDGE_RUN_COUNT} functions running`;
2693
+ document.getElementById('bridge-badge-text').textContent = `Running ${label}`;
2694
+ el.style.display = 'inline-flex';
2695
+ }
2696
+
2697
+ function bridgeForward(source, requestId, event, payload) {
2698
+ if (!source) return;
2699
+ try {
2700
+ source.postMessage({
2701
+ __bitpub: true,
2702
+ type: 'bitpub.event',
2703
+ requestId, event, payload,
2704
+ }, '*');
2705
+ } catch (_) { /* iframe gone */ }
2706
+ }
2707
+
2708
+ function handleBridgeRunMessage(source, msg) {
2709
+ const { requestId, functionId, args } = msg;
2710
+ if (typeof requestId !== 'number' || !functionId) return;
2711
+
2712
+ const qs = new URLSearchParams({
2713
+ functionId: String(functionId),
2714
+ args: JSON.stringify(args || {}),
2715
+ });
2716
+
2717
+ let es;
2718
+ try {
2719
+ es = new EventSource(`/bridge/run?${qs.toString()}`);
2720
+ } catch (err) {
2721
+ bridgeForward(source, requestId, 'error', { message: err.message });
2722
+ bridgeForward(source, requestId, 'exit', { code: 1 });
2723
+ return;
2724
+ }
2725
+
2726
+ BRIDGE_RUNS.set(requestId, { es, source, functionId });
2727
+ BRIDGE_RUN_COUNT++;
2728
+ updateBridgeBadge();
2729
+
2730
+ function relay(name) {
2731
+ es.addEventListener(name, (ev) => {
2732
+ let payload = {};
2733
+ try { payload = JSON.parse(ev.data); } catch { /* ignore malformed */ }
2734
+ bridgeForward(source, requestId, name, payload);
2735
+ });
2736
+ }
2737
+ relay('started');
2738
+ relay('stdout');
2739
+ relay('stderr');
2740
+ relay('error');
2741
+
2742
+ es.addEventListener('exit', (ev) => {
2743
+ let payload = {};
2744
+ try { payload = JSON.parse(ev.data); } catch { /* ignore */ }
2745
+ bridgeForward(source, requestId, 'exit', payload);
2746
+ es.close();
2747
+ BRIDGE_RUNS.delete(requestId);
2748
+ BRIDGE_RUN_COUNT--;
2749
+ updateBridgeBadge();
2750
+ });
2751
+
2752
+ es.onerror = () => {
2753
+ // Connection loss / endpoint down. The bridge already emits an
2754
+ // explicit `exit` for graceful termination, so reaching here means
2755
+ // an unexpected drop — surface it and clean up.
2756
+ if (!BRIDGE_RUNS.has(requestId)) return;
2757
+ bridgeForward(source, requestId, 'error', { message: 'bridge stream closed unexpectedly' });
2758
+ bridgeForward(source, requestId, 'exit', { code: -1 });
2759
+ try { es.close(); } catch (_) {}
2760
+ BRIDGE_RUNS.delete(requestId);
2761
+ BRIDGE_RUN_COUNT--;
2762
+ updateBridgeBadge();
2763
+ };
2764
+ }
2765
+
2766
+ async function handleBridgeNamespaceListMessage(source, msg) {
2767
+ const { requestId, pattern, fields, limit } = msg;
2768
+ if (typeof requestId !== 'number' || !pattern) return;
2769
+ const reply = (extra) => {
2770
+ try {
2771
+ source.postMessage({
2772
+ __bitpub: true,
2773
+ type: 'bitpub.namespace.list.result',
2774
+ requestId, pattern,
2775
+ ...extra,
2776
+ }, '*');
2777
+ } catch (_) { /* iframe gone */ }
2778
+ };
2779
+ try {
2780
+ const qs = new URLSearchParams({
2781
+ pattern: String(pattern),
2782
+ fields: String(fields || 'preview'),
2783
+ limit: String(limit || 200),
2784
+ });
2785
+ const r = await fetch(`/bridge/namespace/list?${qs.toString()}`);
2786
+ const body = await r.json();
2787
+ if (!r.ok) {
2788
+ reply({ ok: false, error: body && body.error || `http ${r.status}` });
2789
+ return;
2790
+ }
2791
+ reply({ ok: true, slices: body.slices || [], count: body.count || 0 });
2792
+ } catch (err) {
2793
+ reply({ ok: false, error: err && err.message || 'fetch failed' });
2794
+ }
2795
+ }
2796
+
2797
+ window.addEventListener('message', (e) => {
2798
+ const d = e.data;
2799
+ if (!d || typeof d !== 'object' || d.__bitpub !== true) return;
2800
+ if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
2801
+ else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
2802
+ });
2803
+
2483
2804
  function renderWriteGuide(sl) {
2484
2805
  const addr = sl.hcu;
2485
2806
  const ver = sl.metadata.version || 1;