pi-inspect 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,14 @@ Then use `/inspect start | stop | restart | status | open | list | snapshot` fro
31
31
 
32
32
  State is driven entirely through the `?session=` URL param — share or refresh URLs to pin views. The in-page picker also writes to the URL.
33
33
 
34
+ ## Sharing a snapshot
35
+
36
+ Click **Share** in the topbar to copy a self-contained link of the current snapshot. The snapshot is `deflate-raw` compressed and base64url-encoded into the URL hash (`#s=…`) — no server, no upload, no account.
37
+
38
+ Recipients open the link on the hosted static dashboard at **https://nikiforovall.blog/pi-inspect/** and see the exact same tools / commands / skills / system prompt. The page makes no network requests; everything is in the URL.
39
+
40
+ Heads up: the link includes the system prompt and `cwd`. Don't share secrets you wouldn't paste in chat.
41
+
34
42
  ## What it captures
35
43
 
36
44
  - **Tools** — name, description, parameter schema, source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inspect",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Introspection dashboard for the pi coding agent — tools, slash commands, skills, and the system prompt injected on init.",
5
5
  "keywords": [
6
6
  "pi-package",
package/public/app.js CHANGED
@@ -10,6 +10,7 @@ const state = {
10
10
  expandAll: true,
11
11
  highlight: -1,
12
12
  visibleRows: [],
13
+ staticMode: false,
13
14
  };
14
15
  const els = {};
15
16
 
@@ -358,7 +359,9 @@ function renderTree() {
358
359
  const root = $('treeContainer');
359
360
  const items = filterItems(buildItems());
360
361
  if (!state.snapshot) {
361
- root.innerHTML = `<div class="loading">No snapshot for this session. Run <code>/inspect snapshot</code> in a pi session.</div>`;
362
+ root.innerHTML = state.staticMode
363
+ ? `<div class="loading">No snapshot in this URL. Open a shared link, or run pi /inspect locally.</div>`
364
+ : `<div class="loading">No snapshot for this session. Run <code>/inspect snapshot</code> in a pi session.</div>`;
362
365
  return;
363
366
  }
364
367
  if (!items.length) {
@@ -542,12 +545,13 @@ function renderDetail() {
542
545
 
543
546
  const ghUrl = githubUrlFor(it);
544
547
  const toggleInfo = toggleInfoFor(it);
548
+ const allowEditor = !state.staticMode && it.path;
545
549
  panel.innerHTML = `
546
550
  <div class="detail-header">
547
551
  <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
548
552
  <div class="detail-header-actions">
549
553
  ${toggleInfo ? `<button class="detail-action" id="toggleBtn" title="${toggleInfo.enable ? 'Enable' : 'Disable'} (${toggleInfo.scope})">${toggleInfo.enable ? 'Enable' : 'Disable'}</button>` : ''}
550
- ${it.path ? `<button class="detail-action" id="openEditorBtn" title="Open in $EDITOR"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>` : ''}
554
+ ${allowEditor ? `<button class="detail-action" id="openEditorBtn" title="Open in $EDITOR"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>` : ''}
551
555
  ${it.path ? `<button class="detail-action" id="copyPathBtn" title="Copy path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>` : ''}
552
556
  ${ghUrl ? `<button class="detail-action" id="openGithubBtn" title="Open on GitHub"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.27-1.68-1.27-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.96 10.96 0 015.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56C20.21 21.38 23.5 17.07 23.5 12 23.5 5.65 18.35.5 12 .5z"/></svg></button>` : ''}
553
557
  <button class="detail-close" id="detailCloseBtn" title="Close">&#10005;</button>
@@ -588,6 +592,7 @@ function renderDetail() {
588
592
  });
589
593
  const toggleBtn = $('toggleBtn');
590
594
  if (toggleBtn && toggleInfo) toggleBtn.addEventListener('click', async () => {
595
+ if (state.staticMode) { toast('shared view is read-only — run pi-inspect locally to toggle'); return; }
591
596
  toggleBtn.disabled = true;
592
597
  try {
593
598
  const r = await fetch('/api/toggle', {
@@ -677,6 +682,24 @@ function bindEvents() {
677
682
  renderTree();
678
683
  });
679
684
 
685
+ $('shareBtn').addEventListener('click', async () => {
686
+ if (!state.snapshot) { toast('no snapshot to share'); return; }
687
+ if (!window.piShare) { toast('share module not loaded'); return; }
688
+ const btn = $('shareBtn');
689
+ btn.classList.add('loading');
690
+ try {
691
+ const encoded = await window.piShare.encodeSnapshot(state.snapshot);
692
+ const url = window.piShare.buildShareUrl(encoded);
693
+ await navigator.clipboard.writeText(url);
694
+ const kb = Math.max(1, Math.round(url.length / 1024));
695
+ toast(`Share link copied — ${kb} KB. Paths redacted to <home>.`);
696
+ } catch (e) {
697
+ toast(`Share failed: ${e.message}`);
698
+ } finally {
699
+ btn.classList.remove('loading');
700
+ }
701
+ });
702
+
680
703
  $('refreshBtn').addEventListener('click', async () => {
681
704
  const btn = $('refreshBtn');
682
705
  btn.classList.add('loading');
@@ -887,6 +910,34 @@ function bindSse() {
887
910
  //#endregion
888
911
 
889
912
  //#region INIT
913
+ async function loadSharedSnapshot() {
914
+ const param = window.piShare?.getSharedSnapshotParam();
915
+ if (!param) return false;
916
+ try {
917
+ state.snapshot = await window.piShare.decodeSnapshot(param);
918
+ state.currentSessionId = state.snapshot?.sessionId ?? null;
919
+ state.sessions = state.currentSessionId
920
+ ? [{ id: state.currentSessionId, name: state.snapshot.sessionName, cwd: state.snapshot.cwd }]
921
+ : [];
922
+ state.staticMode = true;
923
+ return true;
924
+ } catch (e) {
925
+ console.warn('Failed to decode shared snapshot:', e);
926
+ toast(`shared link decode failed: ${e.message}`, 'error');
927
+ return false;
928
+ }
929
+ }
930
+
931
+ function applyStaticModeUi() {
932
+ document.body.classList.add('static-mode');
933
+ for (const id of ['sessionSelect', 'cleanupSessionsBtn', 'refreshBtn']) {
934
+ const el = $(id);
935
+ if (el) el.style.display = 'none';
936
+ }
937
+ const share = $('shareBtn');
938
+ if (share) share.title = 'Re-copy this shared snapshot link';
939
+ }
940
+
890
941
  (async function init() {
891
942
  try {
892
943
  if (localStorage.getItem('inspect.theme') === 'light') document.body.classList.add('light');
@@ -895,10 +946,20 @@ function bindSse() {
895
946
  bindResize();
896
947
  bindEvents();
897
948
 
898
- await loadSessions();
899
- const requested = getUrlSession();
900
- await loadSnapshot(requested);
901
- if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
949
+ const sharedLoaded = await loadSharedSnapshot();
950
+ if (!sharedLoaded) {
951
+ try {
952
+ await loadSessions();
953
+ const requested = getUrlSession();
954
+ await loadSnapshot(requested);
955
+ if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
956
+ } catch {
957
+ state.staticMode = true;
958
+ state.snapshot = null;
959
+ }
960
+ }
961
+
962
+ if (state.staticMode) applyStaticModeUi();
902
963
 
903
964
  if (state.snapshot?.systemPrompt) {
904
965
  const firstCtx = buildItems().find((x) => x.kind === 'context');
@@ -908,6 +969,6 @@ function bindSse() {
908
969
  renderTopbar();
909
970
  renderTree();
910
971
  renderDetail();
911
- bindSse();
972
+ if (!state.staticMode) bindSse();
912
973
  })();
913
974
  //#endregion
package/public/index.html CHANGED
@@ -42,6 +42,10 @@
42
42
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
43
43
  <span id="projectPath">—</span>
44
44
  </div>
45
+ <button class="topbar-btn" id="shareBtn" title="Copy a shareable link for the current snapshot">
46
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
47
+ Share
48
+ </button>
45
49
  <button class="topbar-btn" id="refreshBtn" title="Refresh data">
46
50
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
47
51
  Refresh
@@ -111,11 +115,12 @@
111
115
  </div>
112
116
  </div>
113
117
 
118
+ <script src="share.js"></script>
114
119
  <script src="app.js"></script>
115
120
  <script>
116
121
  if ('serviceWorker' in navigator) {
117
122
  window.addEventListener('load', () => {
118
- navigator.serviceWorker.register('/sw.js').catch((e) => console.warn('sw register failed:', e));
123
+ navigator.serviceWorker.register('./sw.js').catch((e) => console.warn('sw register failed:', e));
119
124
  });
120
125
  }
121
126
  </script>
@@ -0,0 +1,119 @@
1
+ // Shareable snapshot encoding: JSON → deflate-raw → base64url.
2
+ // URL shape mirrors plannotator.ai: `#s=<base64url>`.
3
+
4
+ const HASH_PREFIX = '#s=';
5
+
6
+ function bytesToBase64Url(bytes) {
7
+ let bin = '';
8
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
9
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
10
+ }
11
+
12
+ function base64UrlToBytes(str) {
13
+ const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4));
14
+ const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
+ const bin = atob(b64);
16
+ const out = new Uint8Array(bin.length);
17
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
18
+ return out;
19
+ }
20
+
21
+ async function streamThrough(transformer, bytes) {
22
+ const stream = new Blob([bytes]).stream().pipeThrough(transformer);
23
+ const buf = await new Response(stream).arrayBuffer();
24
+ return new Uint8Array(buf);
25
+ }
26
+
27
+ // Path-redaction: strip the sender's HOME prefix from every string and key,
28
+ // in both slash flavors. Preserves project basename + relative tail so the
29
+ // shared view still has useful structure ("<home>/dev/pi-inspect").
30
+ function escapeRegex(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+
34
+ function deriveHome(cwd) {
35
+ if (!cwd || typeof cwd !== 'string') return null;
36
+ const patterns = [
37
+ /^([a-zA-Z]:[\\/]Users[\\/][^\\/]+)/, // Windows: C:\Users\<user>
38
+ /^(\/Users\/[^/]+)/, // macOS: /Users/<user>
39
+ /^(\/home\/[^/]+)/, // Linux: /home/<user>
40
+ /^(\/[a-zA-Z]\/Users\/[^/]+)/, // Git Bash: /c/Users/<user>
41
+ ];
42
+ for (const re of patterns) {
43
+ const m = cwd.match(re);
44
+ if (m) return m[1];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function buildHomeReplacers(cwd) {
50
+ const home = deriveHome(cwd);
51
+ if (!home) return [];
52
+ const alt = home.includes('\\') ? home.replace(/\\/g, '/') : home.replace(/\//g, '\\');
53
+ const variants = new Set([home, alt]);
54
+ return [...variants].map((v) => [new RegExp(escapeRegex(v), 'g'), '<home>']);
55
+ }
56
+
57
+ function redactString(s, reps) {
58
+ if (typeof s !== 'string') return s;
59
+ let out = s;
60
+ for (const [re, rep] of reps) out = out.replace(re, rep);
61
+ return out;
62
+ }
63
+
64
+ // Keys whose values are home-path-keyed maps — redact keys here too.
65
+ const PATH_KEYED_MAPS = new Set(['githubSources']);
66
+
67
+ function redactDeep(value, reps, redactKeys) {
68
+ if (value == null) return value;
69
+ if (typeof value === 'string') return redactString(value, reps);
70
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, reps, false));
71
+ if (typeof value === 'object') {
72
+ const out = {};
73
+ for (const [k, v] of Object.entries(value)) {
74
+ const newKey = redactKeys ? redactString(k, reps) : k;
75
+ out[newKey] = redactDeep(v, reps, PATH_KEYED_MAPS.has(k));
76
+ }
77
+ return out;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function redactSnapshot(snapshot) {
83
+ if (!snapshot) return snapshot;
84
+ const reps = buildHomeReplacers(snapshot.cwd || '');
85
+ if (!reps.length) return snapshot;
86
+ return redactDeep(snapshot, reps, false);
87
+ }
88
+
89
+ async function encodeSnapshot(snapshot) {
90
+ const json = JSON.stringify(redactSnapshot(snapshot));
91
+ const raw = new TextEncoder().encode(json);
92
+ const compressed = await streamThrough(new CompressionStream('deflate-raw'), raw);
93
+ return bytesToBase64Url(compressed);
94
+ }
95
+
96
+ async function decodeSnapshot(encoded) {
97
+ const compressed = base64UrlToBytes(encoded);
98
+ const raw = await streamThrough(new DecompressionStream('deflate-raw'), compressed);
99
+ return JSON.parse(new TextDecoder().decode(raw));
100
+ }
101
+
102
+ function getSharedSnapshotParam() {
103
+ const h = location.hash || '';
104
+ return h.startsWith(HASH_PREFIX) ? h.slice(HASH_PREFIX.length) : null;
105
+ }
106
+
107
+ const PUBLIC_BASE_URL = 'https://nikiforovall.blog/pi-inspect/';
108
+
109
+ function buildShareUrl(encoded) {
110
+ // Local pi-inspect runs on localhost — recipients can't open that. Always
111
+ // anchor share links on the hosted static dashboard. When the current page
112
+ // is already a non-localhost origin (e.g. the hosted site itself), reuse it.
113
+ const host = location.hostname;
114
+ const isLocal = !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
115
+ const base = isLocal ? PUBLIC_BASE_URL : `${location.origin}${location.pathname}`;
116
+ return `${base}${HASH_PREFIX}${encoded}`;
117
+ }
118
+
119
+ window.piShare = { encodeSnapshot, decodeSnapshot, getSharedSnapshotParam, buildShareUrl, redactSnapshot };
package/public/sw.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // pi-inspect service worker — network-first for dynamic data, cache-first for static shell.
2
- const VERSION = 'pi-inspect-v1';
3
- const SHELL = ['/', '/index.html', '/style.css', '/app.js', '/manifest.webmanifest', '/icon.svg'];
2
+ const VERSION = 'pi-inspect-v6';
3
+ // Resolve relative to the SW scope so this works under any subpath (e.g. GitHub Pages).
4
+ const BASE = new URL('./', self.registration?.scope || self.location.href).pathname;
5
+ const SHELL = ['', 'index.html', 'style.css', 'app.js', 'share.js', 'manifest.webmanifest', 'icon.svg'].map((p) => BASE + p);
4
6
 
5
7
  self.addEventListener('install', (event) => {
6
8
  event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).catch(() => {}));