graphlin 0.1.1 → 0.1.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local architecture and activity viewer with passive, bounded event hooks.",
5
5
  "author": {
6
6
  "name": "Graphlin contributors"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local architecture and activity diagrams from observable coding-agent work.",
5
5
  "author": {
6
6
  "name": "Graphlin contributors"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlin",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Live architecture and activity diagrams from observable coding-agent work.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -76,6 +76,7 @@
76
76
  "./runtime/core/tool-discovery.mjs",
77
77
  "./runtime/daemon/auth.mjs",
78
78
  "./runtime/daemon/connection-info.mjs",
79
+ "./runtime/daemon/dashboard-info.mjs",
79
80
  "./runtime/daemon/demo.mjs",
80
81
  "./runtime/daemon/diagnostics.mjs",
81
82
  "./runtime/daemon/export.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "graphlin",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "description": "Local architecture and activity diagrams from observable coding-agent work.",
6
6
  "extensions": {
7
7
  "com.openai": {
@@ -0,0 +1,175 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const REGISTRY = 'https://registry.npmjs.org/graphlin/latest';
6
+ const CACHE_MS = 30 * 60 * 1000;
7
+ const MAX_REGISTRY_BYTES = 32 * 1024;
8
+ const MAX_COMMAND_BYTES = 8192;
9
+ const PACKAGE = new URL('../../package.json', import.meta.url);
10
+ const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
11
+ const INSTRUCTIONS = [
12
+ 'Press Ctrl+C in the terminal running Graphlin to stop the viewer.',
13
+ 'Run the update command to restart the viewer in this project with the same Graphlin data directory.',
14
+ 'Start a new Claude Code or Codex agent session after the viewer restarts.',
15
+ ];
16
+ const DEMO_INSTRUCTIONS = [
17
+ 'Press Ctrl+C in the terminal running Graphlin to stop the demo viewer.',
18
+ 'Run the update command to restart the offline demo with the same Graphlin data directory.',
19
+ ];
20
+
21
+ const quote = value => `'${value.replaceAll("'", "'\"'\"'")}'`;
22
+ function absolute(value) {
23
+ if (typeof value !== 'string' || !path.isAbsolute(value) ||
24
+ Buffer.byteLength(value) > 4096 || CONTROLS.test(value)) throw new Error('dashboard_info_unavailable');
25
+ return path.resolve(value);
26
+ }
27
+
28
+ function stableVersion(value) {
29
+ if (typeof value !== 'string' || value.length > 80) return null;
30
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
31
+ return match ? match.slice(1, 4).map(part => BigInt(part)) : null;
32
+ }
33
+
34
+ // null means unsupported/invalid, never "already latest". Build metadata does
35
+ // not affect stable SemVer precedence; numeric components are not lexical.
36
+ export function compareStableVersions(first, second) {
37
+ const a = stableVersion(first), b = stableVersion(second);
38
+ if (!a || !b) return null;
39
+ for (let index = 0; index < 3; index++) {
40
+ if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ async function runningVersion(read) {
46
+ try {
47
+ const text = await read(PACKAGE, 'utf8');
48
+ if (Buffer.byteLength(text) > 64 * 1024) return null;
49
+ const metadata = JSON.parse(text);
50
+ return metadata.name === 'graphlin' && stableVersion(metadata.version) ? metadata.version : null;
51
+ } catch { return null; }
52
+ }
53
+
54
+ function branchInfo(projectRoot, execute) {
55
+ return new Promise(resolve => {
56
+ const unavailable = () => resolve({ status: 'unavailable' });
57
+ try {
58
+ execute('git', ['-c', 'core.fsmonitor=false', 'symbolic-ref', '--quiet', '--short', 'HEAD'], {
59
+ cwd: projectRoot, shell: false, timeout: 750, killSignal: 'SIGKILL',
60
+ maxBuffer: 4096, encoding: 'utf8',
61
+ // Do not inherit credentials, Git overrides, or global config. This
62
+ // local ref lookup never runs a shell, hook, installer or remote.
63
+ env: {
64
+ PATH: process.env.PATH || '/usr/bin:/bin', LC_ALL: 'C',
65
+ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
66
+ GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0',
67
+ },
68
+ }, (error, stdout, stderr) => {
69
+ if (error) {
70
+ if (error.code === 1 && !error.killed) return resolve({ status: 'detached' });
71
+ if (error.code === 128 && !error.killed && typeof stderr === 'string' &&
72
+ stderr.includes('not a git repository')) return resolve({ status: 'not_git' });
73
+ return unavailable();
74
+ }
75
+ const name = typeof stdout === 'string' ? stdout.replace(/\r?\n$/, '') : '';
76
+ if (!name || Buffer.byteLength(name) > 1024 || CONTROLS.test(name)) return unavailable();
77
+ resolve({ status: 'branch', name });
78
+ });
79
+ } catch { unavailable(); }
80
+ });
81
+ }
82
+
83
+ async function registryVersion(fetchImpl, timeoutMs) {
84
+ const controller = new AbortController();
85
+ let timer;
86
+ try {
87
+ return await Promise.race([
88
+ (async () => {
89
+ // Fixed destination and headers: project paths, branch names, daemon
90
+ // state, credentials and registry overrides never enter this request.
91
+ const response = await fetchImpl(REGISTRY, {
92
+ method: 'GET', headers: { Accept: 'application/json' },
93
+ redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer',
94
+ signal: controller.signal,
95
+ });
96
+ if (!response.ok || Number(response.headers.get('content-length')) > MAX_REGISTRY_BYTES ||
97
+ !response.body) throw new Error('registry_unavailable');
98
+ const reader = response.body.getReader();
99
+ const chunks = [];
100
+ let length = 0;
101
+ try {
102
+ while (true) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ length += value.byteLength;
106
+ if (length > MAX_REGISTRY_BYTES) throw new Error('registry_unavailable');
107
+ chunks.push(Buffer.from(value));
108
+ }
109
+ } finally {
110
+ void reader.cancel().catch(() => {});
111
+ reader.releaseLock();
112
+ }
113
+ const metadata = JSON.parse(Buffer.concat(chunks, length).toString('utf8'));
114
+ if (metadata.name !== 'graphlin' || !stableVersion(metadata.version)) throw new Error('registry_unavailable');
115
+ return metadata.version;
116
+ })(),
117
+ new Promise((_, reject) => {
118
+ timer = setTimeout(() => {
119
+ controller.abort();
120
+ reject(new Error('registry_unavailable'));
121
+ }, timeoutMs);
122
+ }),
123
+ ]);
124
+ } catch {
125
+ // Raw network errors and response bodies may contain private content.
126
+ return null;
127
+ } finally {
128
+ clearTimeout(timer);
129
+ controller.abort();
130
+ }
131
+ }
132
+
133
+ // Trusted startup context only; dependencies are injectable for offline tests.
134
+ // Creating the provider performs no registry request or Git subprocess.
135
+ export function createDashboardInfoProvider({ projectRoot, dataDir, mode = 'live' }, {
136
+ fetch: fetchImpl = globalThis.fetch, execFile: execute = execFile,
137
+ readFile: read = readFile, now = Date.now, timeoutMs = 1500,
138
+ } = {}) {
139
+ const version = runningVersion(read);
140
+ let cached, pending;
141
+ async function latestVersion() {
142
+ if (cached && now() < cached.expiresAt) return cached.latest;
143
+ if (!pending) {
144
+ pending = registryVersion(fetchImpl, timeoutMs).then(latest => {
145
+ cached = { latest, expiresAt: now() + CACHE_MS };
146
+ return latest;
147
+ }).finally(() => { pending = null; });
148
+ }
149
+ return pending;
150
+ }
151
+ return async () => {
152
+ // Metadata that cannot be displayed safely disables this optional endpoint,
153
+ // never startup, capture, or the rest of the viewer.
154
+ if (!['live', 'demo'].includes(mode)) throw new Error('dashboard_info_unavailable');
155
+ const root = absolute(projectRoot), directory = absolute(dataDir);
156
+ const demo = mode === 'demo';
157
+ const command = `${demo ? '' : `cd ${quote(root)} && `}GRAPHLIN_DATA_DIR=${quote(directory)} npx --yes graphlin@latest${demo ? ' demo' : ''}`;
158
+ // Never truncate a shell argument or offer instructions without a usable
159
+ // command. Quoting can expand otherwise valid paths beyond the UI limit.
160
+ const guide = Buffer.byteLength(command) <= MAX_COMMAND_BYTES
161
+ ? { command, instructions: [...(demo ? DEMO_INSTRUCTIONS : INSTRUCTIONS)] } : {};
162
+ const [current, branch, latest] = await Promise.all([
163
+ version, branchInfo(root, execute), latestVersion(),
164
+ ]);
165
+ if (!current) throw new Error('dashboard_info_unavailable');
166
+ const comparison = compareStableVersions(latest, current);
167
+ return {
168
+ projectRoot: root, mode, branch, version: current,
169
+ update: {
170
+ status: comparison === null ? 'unavailable' : comparison > 0 ? 'available' : 'current',
171
+ current, ...(latest ? { latest } : {}), ...guide,
172
+ },
173
+ };
174
+ };
175
+ }
@@ -10,6 +10,7 @@ import { createAuth } from './auth.mjs';
10
10
  import { createPersistence } from './persistence.mjs';
11
11
  import { exportSnapshot } from './export.mjs';
12
12
  import { createDiagnostics } from './diagnostics.mjs';
13
+ import { createDashboardInfoProvider } from './dashboard-info.mjs';
13
14
 
14
15
  const WEB = new URL('../web/', import.meta.url);
15
16
  const assets = new Map([
@@ -52,10 +53,13 @@ async function bodyJSON(req) {
52
53
  }
53
54
 
54
55
  export async function startServer({ projectRoot, dataDir, policy: policyOptions,
55
- decisionService, apiKey: configuredKey, mode = 'live', port = 0 } = {}) {
56
+ decisionService, apiKey: configuredKey, mode = 'live', port = 0, dashboardInfoDependencies } = {}) {
56
57
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw runtimeError('invalid_port');
57
58
  if (!['live', 'demo'].includes(mode)) throw runtimeError('invalid_mode');
58
59
  const paths = await projectPaths(projectRoot, dataDir, { create: true });
60
+ const dashboardInfo = createDashboardInfoProvider({
61
+ projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode,
62
+ }, dashboardInfoDependencies);
59
63
  const lock = await acquireLock(paths);
60
64
  let finished;
61
65
  const whenClosed = new Promise(resolve => { finished = resolve; });
@@ -157,6 +161,10 @@ export async function startServer({ projectRoot, dataDir, policy: policyOptions,
157
161
  return;
158
162
  }
159
163
  if (!auth.authorized(req)) return json(res, 401, { error: 'authentication_required' });
164
+ if (req.method === 'GET' && req.url === '/api/about') {
165
+ try { return json(res, 200, await dashboardInfo()); }
166
+ catch { return json(res, 503, { error: 'dashboard_info_unavailable' }); }
167
+ }
160
168
  if (req.method === 'GET' && req.url === '/api/diagnostics') {
161
169
  return json(res, 200, diagnostics.snapshot());
162
170
  }
@@ -157,6 +157,23 @@ export function liveNodeChanges(previous, next, eligible) {
157
157
  };
158
158
  }
159
159
 
160
+ export function filterDiagram(graph, query) {
161
+ if (!query) return graph;
162
+ const needle = query.toLowerCase();
163
+ const nodes = graph.nodes.filter(node => node.label.toLowerCase().includes(needle));
164
+ const visible = new Set(nodes.map(node => node.id));
165
+ return { ...graph, nodes, edges: graph.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target)) };
166
+ }
167
+
168
+ function isSearchTypingTarget(target) {
169
+ for (let element = target; element; element = element.parentElement) {
170
+ if (['input', 'textarea', 'select', 'dialog'].includes(element.tagName?.toLowerCase()) ||
171
+ element.isContentEditable || ['textbox', 'combobox', 'searchbox'].includes(element.getAttribute?.('role')) ||
172
+ ['true', '', 'plaintext-only'].includes(element.getAttribute?.('contenteditable'))) return true;
173
+ }
174
+ return false;
175
+ }
176
+
160
177
  function nodeTitleWidth(shapeName) {
161
178
  return { queue: 132, component: 142, parallelogram: 144, diamond: 140 }[shapeName] || 158;
162
179
  }
@@ -816,6 +833,94 @@ export function friendlyProjectName(projectRoot) {
816
833
  return safeText(projectRoot, 4096).replace(/\/+$/, '').split('/').at(-1)?.slice(0, 120) || 'Local project';
817
834
  }
818
835
 
836
+ export function startDashboardInfo({ load = signal => request('/api/about', { signal }) } = {}) {
837
+ const $ = id => document.getElementById(id);
838
+ let closed = false, controller = null, timer = null, pending = null, command = '';
839
+ function render(info) {
840
+ if (!record(info) || typeof info.projectRoot !== 'string' ||
841
+ !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/.test(info.version ?? '')) {
842
+ throw new Error('invalid_dashboard_info');
843
+ }
844
+ $('project-path').textContent = safeText(info.projectRoot, 4096) || 'Path unavailable';
845
+ $('graphlin-version').textContent = info.version;
846
+ $('version-update-steps').textContent = info.mode === 'demo'
847
+ ? 'Press Ctrl+C in the demo terminal, then run this command to restart the updated offline demo.'
848
+ : 'Press Ctrl+C in the viewer terminal, then run this command. Start a new Claude Code or Codex session after setup finishes.';
849
+ const branch = info.branch;
850
+ $('project-branch').textContent = branch?.status === 'branch'
851
+ ? safeText(branch.name, 1024) || 'Branch unavailable'
852
+ : branch?.status === 'detached' ? `Detached HEAD${branch.commit ? ` · ${safeText(branch.commit, 12)}` : ''}`
853
+ : branch?.status === 'not_git' ? 'Not a Git repository' : 'Branch unavailable';
854
+ const latest = typeof info.update?.latest === 'string' &&
855
+ /^\d+\.\d+\.\d+$/.test(info.update.latest) ? info.update.latest : null;
856
+ const available = info.update?.status === 'available' && latest;
857
+ $('version-update-status').textContent = available ? `Graphlin ${latest} is available`
858
+ : info.update?.status === 'current' ? 'No newer release found'
859
+ : 'Update check unavailable';
860
+ const nextCommand = info.update?.command;
861
+ command = available && typeof nextCommand === 'string' && nextCommand.trim() &&
862
+ nextCommand.length <= 8192 && !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(nextCommand)
863
+ ? nextCommand : '';
864
+ $('version-update-guide').hidden = !command;
865
+ if ($('version-update-command').textContent !== command) {
866
+ $('version-update-command').textContent = command;
867
+ $('version-update-copy-status').textContent = '';
868
+ }
869
+ }
870
+ function refresh() {
871
+ if (closed) return Promise.resolve();
872
+ if (pending) return pending;
873
+ clearTimeout(timer);
874
+ controller = new AbortController();
875
+ pending = (async () => {
876
+ try {
877
+ await Promise.resolve();
878
+ if (closed) return;
879
+ const info = await load(controller.signal);
880
+ if (!closed) render(info);
881
+ } catch {
882
+ if (!closed) {
883
+ $('project-branch').textContent = 'Branch unavailable';
884
+ if ($('project-path').textContent === 'Checking…') $('project-path').textContent = 'Path unavailable';
885
+ if ($('graphlin-version').textContent === 'Checking…') $('graphlin-version').textContent = 'Version unavailable';
886
+ $('version-update-status').textContent = 'Update check unavailable';
887
+ $('version-update-guide').hidden = true;
888
+ $('version-update-command').textContent = '';
889
+ command = '';
890
+ }
891
+ } finally {
892
+ pending = null;
893
+ controller = null;
894
+ if (!closed) timer = setTimeout(() => { void refresh(); }, 30000);
895
+ }
896
+ })();
897
+ return pending;
898
+ }
899
+ const onCopy = async () => {
900
+ if (closed || !command) return;
901
+ const copied = command;
902
+ try {
903
+ await window.navigator.clipboard.writeText(copied);
904
+ if (!closed && command === copied) $('version-update-copy-status').textContent = 'Copied';
905
+ } catch {
906
+ if (!closed && command === copied) {
907
+ $('version-update-copy-status').textContent = 'Select the command and copy it manually.';
908
+ $('version-update-command').focus();
909
+ }
910
+ }
911
+ };
912
+ $('version-update-copy').addEventListener('click', onCopy);
913
+ return {
914
+ refresh,
915
+ close() {
916
+ closed = true;
917
+ controller?.abort();
918
+ clearTimeout(timer);
919
+ $('version-update-copy').removeEventListener('click', onCopy);
920
+ },
921
+ };
922
+ }
923
+
819
924
  export const ORIENTATION_PROMPT = 'Orient yourself in this project: read its main files and explain how the components connect.';
820
925
 
821
926
  // Observations are not an installation or trust audit. Activity (including
@@ -1450,12 +1555,13 @@ export function startViewer() {
1450
1555
  connection: 'connecting', busy: false, exporting: false, epoch: 0, connectEpoch: 0,
1451
1556
  viewport: null, fitBounds: null, zoom: 1, followFit: true, lastGraphSignature: '', inspectorSignature: '',
1452
1557
  nodeElements: new Map(), edgeElements: new Map(), activityElements: new Map(),
1453
- views: new Map(), viewKey: null, view: null, displayGraph: null,
1454
- effects: new Map(), motionReady: false, movement: null, closed: false, projectName: '',
1558
+ views: new Map(), viewKey: null, view: null, displayGraph: null, searchQuery: '',
1559
+ effects: new Map(), liveReady: false, motionReady: false, movement: null, closed: false, projectName: '',
1455
1560
  };
1456
1561
  const motionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
1457
1562
  const sketches = createSketchCache();
1458
1563
  const detailSketches = createSketchCache(sketchDetails);
1564
+ const dashboardInfo = startDashboardInfo();
1459
1565
  const connectionDialog = startConnectionDialog({ onInfo: info => {
1460
1566
  state.projectName = friendlyProjectName(info.projectRoot);
1461
1567
  renderStatus();
@@ -1524,7 +1630,7 @@ export function startViewer() {
1524
1630
  $('onboarding-action').dataset.action = progress.next.action;
1525
1631
  // Preserve a manual text selection while live snapshots arrive.
1526
1632
  if ($('orientation-prompt').textContent !== ORIENTATION_PROMPT) $('orientation-prompt').textContent = ORIENTATION_PROMPT;
1527
- $('orientation').hidden = Boolean(state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
1633
+ $('orientation').hidden = Boolean(state.searchQuery || state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
1528
1634
  }
1529
1635
  function currentGraph() { return state.replayFrame?.graph || state.snapshot?.graph; }
1530
1636
  function applyTheme() {
@@ -1537,6 +1643,7 @@ export function startViewer() {
1537
1643
  if (state.viewKey !== key) {
1538
1644
  finishPan();
1539
1645
  clearMotion();
1646
+ state.liveReady = false;
1540
1647
  state.motionReady = false;
1541
1648
  let view = state.views.get(key);
1542
1649
  if (!view) view = createPresentation();
@@ -1580,14 +1687,14 @@ export function startViewer() {
1580
1687
  state.effects.set(id, effect);
1581
1688
  target.addEventListener('animationend', finish);
1582
1689
  }
1583
- function cancelMovement() {
1690
+ function cancelMovement({ fit = true } = {}) {
1584
1691
  const movement = state.movement;
1585
1692
  if (!movement) return;
1586
1693
  state.movement = null;
1587
1694
  window.cancelAnimationFrame?.(movement.frame);
1588
1695
  clearTimeout(movement.timer);
1589
1696
  if (state.displayGraph) paintGeometry(state.displayGraph);
1590
- if (movement.fitAfter) fitCamera(movement.fitAfter);
1697
+ if (fit && movement.fitAfter) fitCamera(movement.fitAfter);
1591
1698
  }
1592
1699
  function clearMotion() {
1593
1700
  cancelMovement();
@@ -1595,10 +1702,11 @@ export function startViewer() {
1595
1702
  }
1596
1703
  function resetMotionBaseline() {
1597
1704
  finishPan();
1705
+ state.liveReady = false;
1598
1706
  state.motionReady = false;
1599
1707
  clearMotion();
1600
1708
  }
1601
- function animateChanges(changes, before) {
1709
+ function animateChanges(changes, before, focusNodeId) {
1602
1710
  // Re-addition always cancels a removal, even when motion is suppressed.
1603
1711
  for (const node of currentGraph().nodes) if (state.effects.get(node.id)?.element) finishEffect(node.id);
1604
1712
  if (!motionAllowed()) return;
@@ -1627,7 +1735,7 @@ export function startViewer() {
1627
1735
  removals.push(decoration);
1628
1736
  }
1629
1737
  if (removals.length) {
1630
- fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
1738
+ if (!focusNodeId) fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
1631
1739
  $('empty-canvas').hidden = true;
1632
1740
  $('effects-layer').append(...removals);
1633
1741
  }
@@ -1677,15 +1785,21 @@ export function startViewer() {
1677
1785
  const eligible = streamed && state.motionReady && !switched && !state.replayFrame &&
1678
1786
  snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay' && motionAllowed();
1679
1787
  const changes = liveNodeChanges(state.snapshot?.graph, snapshot.graph, eligible);
1788
+ // A live baseline is independent of animation preferences. Reconnects and
1789
+ // initial/session snapshots establish it without focusing an old arrival.
1790
+ const live = streamed && state.liveReady && !switched && !state.replayFrame &&
1791
+ snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay';
1792
+ const focusNodeId = liveNodeChanges(state.snapshot?.graph, snapshot.graph, live).added.at(-1);
1680
1793
  const before = state.displayGraph;
1681
- cancelMovement();
1794
+ cancelMovement({ fit: !focusNodeId });
1682
1795
  if (switched) resetView();
1683
1796
  state.snapshot = snapshot;
1684
1797
  state.epoch += 1;
1685
1798
  state.frames = historyFrames(snapshot);
1686
1799
  state.replayFrame = reconcileReplayFrame(state.frames, state.replayFrame);
1687
- render();
1688
- animateChanges(changes, before);
1800
+ render({ focusNodeId });
1801
+ animateChanges(changes, before, focusNodeId);
1802
+ state.liveReady = streamed && !state.replayFrame && snapshot.mode !== 'replay';
1689
1803
  state.motionReady = streamed && !state.replayFrame && snapshot.mode !== 'replay' && motionAllowed();
1690
1804
  $('updated-at').textContent = `Snapshot received ${formatTime(Date.now())}`;
1691
1805
  }
@@ -1869,6 +1983,23 @@ export function startViewer() {
1869
1983
  cancelMovement();
1870
1984
  fitCamera(graphBounds(graph));
1871
1985
  }
1986
+ function focusNode(node, bounds) {
1987
+ finishPan();
1988
+ const zoom = Math.max(.5, state.zoom);
1989
+ const scale = state.zoom / zoom;
1990
+ const width = state.viewport.width * scale;
1991
+ const height = state.viewport.height * scale;
1992
+ state.viewport = {
1993
+ x: node.x + NODE_WIDTH / 2 - width / 2,
1994
+ y: node.y + NODE_HEIGHT / 2 - height / 2,
1995
+ width, height,
1996
+ };
1997
+ state.zoom = zoom;
1998
+ state.fitBounds = bounds;
1999
+ // Removal cleanup must not replace arrival focus with a later fit-all.
2000
+ state.followFit = false;
2001
+ setViewBox();
2002
+ }
1872
2003
  function zoom(factor, anchor = { x: .5, y: .5 }) {
1873
2004
  if (!state.viewport || !state.fitBounds) return;
1874
2005
  cancelMovement();
@@ -1885,20 +2016,23 @@ export function startViewer() {
1885
2016
  state.followFit = false;
1886
2017
  setViewBox();
1887
2018
  }
1888
- function renderGraph({ forceFit = false } = {}) {
2019
+ function renderGraph({ forceFit = false, focusNodeId } = {}) {
1889
2020
  const canonical = currentGraph();
1890
2021
  if (!canonical) return;
1891
2022
  const view = presentation();
1892
2023
  applyTheme();
1893
- const graph = projectPresentation(canonical, view);
2024
+ // Search only projects visibility; layout, evidence and live arrival
2025
+ // detection retain the complete source graph.
2026
+ const graph = filterDiagram(projectPresentation(canonical, view), state.searchQuery);
1894
2027
  state.displayGraph = graph;
1895
2028
  const routes = graphEdgeRoutes(graph);
1896
2029
  const bounds = graphBounds(graph, routes);
1897
2030
  const signature = cameraGraphSignature(graph, view.algorithm);
1898
- // Set the physical viewport before inserting newcomers or starting motion.
1899
- // A manual camera survives status updates, but the next diagram change fits
1900
- // the complete map, even with automatic arrangement disabled.
1901
- if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
2031
+ // Focus the final projected position before inserting newcomers. Other
2032
+ // diagram changes retain fit-all; metadata-only updates keep the camera.
2033
+ const newest = graph.nodes.find(node => node.id === focusNodeId);
2034
+ if (newest && state.viewport && !forceFit) focusNode(newest, bounds);
2035
+ else if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
1902
2036
  state.lastGraphSignature = signature;
1903
2037
  $('layout').value = view.algorithm;
1904
2038
  $('auto-arrange').checked = view.auto;
@@ -1966,6 +2100,7 @@ export function startViewer() {
1966
2100
  center,
1967
2101
  svgElement('rect', { class: 'selection-ring', x: -7, y: -7, width: NODE_WIDTH + 14, height: NODE_HEIGHT + 14, rx: 14 }),
1968
2102
  );
2103
+ group.setAttribute('transform', `translate(${node.x} ${node.y})`);
1969
2104
  state.nodeElements.set(node.id, group);
1970
2105
  $('node-layer').append(group);
1971
2106
  }
@@ -2009,6 +2144,9 @@ export function startViewer() {
2009
2144
  $('diagram-title').textContent = `${state.replayFrame ? 'Historical' : 'Live'} architecture, revision ${graph.revision}`;
2010
2145
  $('diagram-desc').textContent = `${graph.nodes.length} components and ${graph.edges.length} relationships. Code interpretation does not establish runtime connectivity. Use Tab and Enter to inspect a component or relationship. With the diagram focused, use plus and minus to zoom, arrow keys to pan, and 0 to fit.`;
2011
2146
  $('graph-count').textContent = `${graph.nodes.length} components · ${graph.edges.length} relationships`;
2147
+ $('diagram-search-status').textContent = state.searchQuery
2148
+ ? `${graph.nodes.length} of ${canonical.nodes.length} components shown` : '';
2149
+ $('diagram-search-clear').hidden = !state.searchQuery;
2012
2150
  $('empty-canvas').hidden = graph.nodes.length > 0 || removalBounds().length > 0;
2013
2151
  const classifier = state.snapshot.paused ? 'paused' : state.snapshot.status.classifier;
2014
2152
  const emptyMessages = {
@@ -2018,7 +2156,9 @@ export function startViewer() {
2018
2156
  unavailable: ['Waiting for classification.', 'The classifier is unavailable. Safe activity continues below; supported architecture will appear when classification recovers.'],
2019
2157
  timeout: ['Evidence needs another moment.', 'Classification exceeded its deadline. Activity still appears below, and no unsupported components are added.'],
2020
2158
  };
2021
- const message = state.replayFrame
2159
+ const message = state.searchQuery
2160
+ ? ['No matching components.', `No labels contain “${state.searchQuery}”. Try another search or press Esc to restore the diagram.`]
2161
+ : state.replayFrame
2022
2162
  ? ['No components in this revision.', 'Move through the recent revisions or return to Live to follow the current map.']
2023
2163
  : emptyMessages[classifier] || ['Your architecture starts here.', 'Work in a connected agent session. Components appear when approved evidence supports them; activity can arrive first.'];
2024
2164
  $('empty-title').textContent = message[0];
@@ -2305,10 +2445,10 @@ export function startViewer() {
2305
2445
  $('activity-empty').hidden = events.length > 0;
2306
2446
  $('activity-list').hidden = events.length === 0;
2307
2447
  }
2308
- function render() {
2448
+ function render(graphOptions) {
2309
2449
  renderStatus();
2310
2450
  renderOnboarding();
2311
- renderGraph();
2451
+ renderGraph(graphOptions);
2312
2452
  renderInspector();
2313
2453
  renderHistory();
2314
2454
  renderActivity();
@@ -2384,6 +2524,7 @@ export function startViewer() {
2384
2524
  connection('reconnecting');
2385
2525
  error('The live connection was lost. Displaying the last received snapshot while the viewer reconnects.');
2386
2526
  });
2527
+ void dashboardInfo.refresh();
2387
2528
  // Optional authenticated metadata must not hold up the event stream or
2388
2529
  // turn an older server's missing endpoint into a connection failure.
2389
2530
  const controller = new AbortController();
@@ -2392,6 +2533,7 @@ export function startViewer() {
2392
2533
  const info = normalizeConnectionInfo(await request('/api/connection-info', { signal: controller.signal }));
2393
2534
  if (!state.closed && attempt === state.connectEpoch) {
2394
2535
  state.projectName = friendlyProjectName(info.projectRoot);
2536
+ $('project-path').textContent = info.projectRoot;
2395
2537
  renderStatus();
2396
2538
  }
2397
2539
  } catch { /* The project ID remains a usable fallback. */ }
@@ -2455,6 +2597,37 @@ export function startViewer() {
2455
2597
  $('onboarding-action').addEventListener('click', onOnboardingAction);
2456
2598
  $('orientation-copy').addEventListener('click', onCopyOrientation);
2457
2599
  $('activity-toggle').addEventListener('click', onToggleActivity);
2600
+ const onSearchInput = () => {
2601
+ if (state.closed || state.searchQuery === $('diagram-search').value) return;
2602
+ state.searchQuery = $('diagram-search').value;
2603
+ // Filtering is not a source change: cancel existing decoration/movement
2604
+ // and render directly, without changing the snapshot arrival baseline.
2605
+ finishPan();
2606
+ clearMotion();
2607
+ renderOnboarding();
2608
+ renderGraph({ forceFit: true });
2609
+ };
2610
+ const clearSearch = () => {
2611
+ $('diagram-search').value = '';
2612
+ onSearchInput();
2613
+ $('diagram-search').focus({ preventScroll: true });
2614
+ };
2615
+ const onSearchKeyDown = event => {
2616
+ if (state.closed || event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey ||
2617
+ $('diagnostics-dialog').open || $('connection-dialog').open || document.querySelector?.('dialog[open], [role="dialog"][aria-modal="true"]')) return;
2618
+ const target = event.target || document.activeElement;
2619
+ if (target !== $('diagram-search') && isSearchTypingTarget(target)) return;
2620
+ if (event.key === '/' && target !== $('diagram-search')) {
2621
+ event.preventDefault();
2622
+ $('diagram-search').focus({ preventScroll: true });
2623
+ } else if (event.key === 'Escape' && state.searchQuery) {
2624
+ event.preventDefault();
2625
+ clearSearch();
2626
+ }
2627
+ };
2628
+ $('diagram-search').addEventListener('input', onSearchInput);
2629
+ $('diagram-search-clear').addEventListener('click', clearSearch);
2630
+ window.addEventListener('keydown', onSearchKeyDown);
2458
2631
  $('pause').addEventListener('click', () => control(state.snapshot?.paused ? 'resume' : 'pause'));
2459
2632
  $('session').addEventListener('change', () => control('session', $('session').value));
2460
2633
  $('export').addEventListener('click', exportJSON);
@@ -2595,9 +2768,13 @@ export function startViewer() {
2595
2768
  state.closed = true;
2596
2769
  projectController?.abort();
2597
2770
  projectController = null;
2771
+ dashboardInfo.close();
2598
2772
  $('onboarding-action').removeEventListener('click', onOnboardingAction);
2599
2773
  $('orientation-copy').removeEventListener('click', onCopyOrientation);
2600
2774
  $('activity-toggle').removeEventListener('click', onToggleActivity);
2775
+ $('diagram-search').removeEventListener('input', onSearchInput);
2776
+ $('diagram-search-clear').removeEventListener('click', clearSearch);
2777
+ window.removeEventListener?.('keydown', onSearchKeyDown);
2601
2778
  $('architecture').removeEventListener('wheel', onDiagramWheel);
2602
2779
  connectionDialog.dispose();
2603
2780
  diagnosticsDialog.dispose();
@@ -32,6 +32,22 @@
32
32
  <p id="project-label" class="project-label">Local project</p>
33
33
  </div>
34
34
 
35
+ <section class="project-details" aria-label="Project and Graphlin version">
36
+ <dl>
37
+ <div><dt>Branch</dt><dd id="project-branch">Checking…</dd></div>
38
+ <div class="project-path"><dt>Path</dt><dd id="project-path">Checking…</dd></div>
39
+ <div><dt>Graphlin</dt><dd id="graphlin-version">Checking…</dd></div>
40
+ </dl>
41
+ <p id="version-update-status" role="status">Checking for updates…</p>
42
+ <details id="version-update-guide" hidden>
43
+ <summary>How to update</summary>
44
+ <p id="version-update-steps">Press Ctrl+C in the viewer terminal, then run this command. Start a new Claude Code or Codex session after setup finishes.</p>
45
+ <pre id="version-update-command" tabindex="0"></pre>
46
+ <button id="version-update-copy" type="button">Copy update command</button>
47
+ <span id="version-update-copy-status" role="status"></span>
48
+ </details>
49
+ </section>
50
+
35
51
  <div id="demo-banner" class="notice demo-notice" role="status" hidden>
36
52
  <strong>Demo session</strong>
37
53
  <span>Generated project and fixture classifier answers. This is an offline illustration, not a live Jev evaluation.</span>
@@ -107,6 +123,13 @@
107
123
  <span class="screen-reader" id="theme-note">Colors follow component types. Themes change appearance only.</span>
108
124
  <span id="layout-note">Arranges when components or connections change.</span>
109
125
  </div>
126
+ <div class="diagram-search-bar" role="search" aria-label="Diagram search">
127
+ <label for="diagram-search">Find components</label>
128
+ <input id="diagram-search" type="search" maxlength="200" autocomplete="off" spellcheck="false" placeholder="Search labels…" aria-controls="architecture" aria-describedby="diagram-search-hint" aria-keyshortcuts="/">
129
+ <button id="diagram-search-clear" type="button" hidden>Clear search</button>
130
+ <span id="diagram-search-hint">/ to search · Esc to clear</span>
131
+ <span id="diagram-search-status" role="status" aria-live="polite" aria-atomic="true"></span>
132
+ </div>
110
133
  <div class="diagram-stage" id="diagram-stage">
111
134
  <svg id="architecture" viewBox="0 0 920 510" role="group" tabindex="0" aria-labelledby="diagram-title diagram-desc">
112
135
  <title id="diagram-title">Architecture diagram</title>
@@ -171,6 +171,22 @@ h1 { font-size: clamp(27px, 2.3vw, 36px); letter-spacing: -1px; font-weight: 650
171
171
  .zoom-controls button { border: 0; background: transparent; min-width: 31px; min-height: 32px; padding: 2px 8px; font-size: 17px; }
172
172
  .zoom-controls #fit { border: 1px solid var(--line); font-size: 11px; margin-left: 5px; min-width: 39px; }
173
173
  .layout-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 21px 4px; font-size: 11px; }
174
+ .project-details { margin: 0 0 18px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--white); font-size: 12px; }
175
+ .project-details dl { display: flex; flex-wrap: wrap; gap: 12px 24px; margin: 0; }
176
+ .project-details dl > div { min-width: 0; }
177
+ .project-details dt { color: var(--muted); font-size: 10px; margin-bottom: 4px; }
178
+ .project-details dd { margin: 0; overflow-wrap: anywhere; }
179
+ .project-details .project-path { flex: 1 1 240px; }
180
+ .project-details > p { margin: 10px 0 0; color: var(--muted); font-size: 11px; }
181
+ .project-details details { margin-top: 10px; }
182
+ .project-details summary { cursor: pointer; font-weight: 600; }
183
+ .project-details pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 10px; background: var(--paper); }
184
+ .project-details button { min-height: 36px; font-size: 11px; }
185
+ .diagram-search-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 21px 4px; font-size: 11px; color: var(--muted); }
186
+ .diagram-search-bar input { flex: 1 1 160px; min-width: 0; min-height: 40px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 5px; background: var(--white); color: var(--ink); }
187
+ .diagram-search-bar input::placeholder { color: var(--muted); opacity: 1; }
188
+ .diagram-search-bar button { min-height: 40px; font-size: 11px; }
189
+ #diagram-search-status:empty { display: none; }
174
190
  .layout-toolbar label { display: flex; align-items: center; gap: 8px; color: var(--muted); }
175
191
  .layout-toolbar select, .shape-picker select { color: var(--ink); background: var(--white); border: 1px solid var(--line); border-radius: 5px; padding: 7px 25px 7px 9px; font: inherit; min-height: 34px; }
176
192
  .layout-toolbar button { font-size: 11px; min-height: 34px; padding: 5px 12px; }
@@ -490,6 +506,7 @@ footer span { margin: 0 8px; color: #91a7c3; }
490
506
  #replay-note { grid-column: 2; margin-top: -9px; text-align: left; }
491
507
  .drawing-toolbar { padding: 15px 15px 0; }
492
508
  .layout-toolbar { padding-right: 15px; padding-left: 15px; }
509
+ .diagram-search-bar { padding-right: 15px; padding-left: 15px; }
493
510
  .drawing-heading { gap: 3px 10px; }
494
511
  .drawing-footer { padding-right: 15px; padding-left: 15px; }
495
512
  .diagram-note { padding-left: 15px; }
@@ -558,6 +575,7 @@ footer span { margin: 0 8px; color: #91a7c3; }
558
575
  .coverage-status { flex-direction: row; text-align: left; gap: 5px 14px; font-size: 10px; max-width: none; }
559
576
  .drawing-toolbar { padding: 10px 10px 0; gap: 7px; align-items: start; }
560
577
  .layout-toolbar { padding: 10px 12px 3px; gap: 9px; }
578
+ .diagram-search-bar { padding: 10px 12px 3px; gap: 8px; }
561
579
  .layout-toolbar select, .layout-toolbar button { min-height: 40px; }
562
580
  .auto-arrange { min-height: 30px; }
563
581
  .shape-key { padding-right: 12px; padding-left: 12px; }
@@ -39,7 +39,7 @@ async function handle(line) {
39
39
  const supported = ['2024-11-05', '2025-03-26', '2025-06-18'];
40
40
  return result({ protocolVersion: supported.includes(message.params?.protocolVersion) ? message.params.protocolVersion : '2025-06-18',
41
41
  capabilities: { tools: { listChanged: false } },
42
- serverInfo: { name: 'graphlin', version: '0.1.1' },
42
+ serverInfo: { name: 'graphlin', version: '0.1.2' },
43
43
  instructions: 'Controls only. Passive host hooks provide observations when separately activated. No drawing calls after each action.' });
44
44
  }
45
45
  if (message.method === 'ping') return result({});