graphlin 0.1.0 → 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.0",
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.0",
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.0",
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.0",
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": {
@@ -1,9 +1,8 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { lstat, open } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
3
4
  import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
5
 
6
- const PLUGIN_ROOT = fileURLToPath(new URL('../../', import.meta.url));
7
6
  const PROFILES = new Set(['claude', 'codex', 'portable']);
8
7
  const MAX_PATH_BYTES = 4096;
9
8
  const MAX_COMMAND_BYTES = 8192;
@@ -68,18 +67,6 @@ async function packageMarker(root) {
68
67
  return PROFILES.has(profile) ? { status: 'present', profile } : { status: 'invalid' };
69
68
  }
70
69
 
71
- async function sourceCheckout(root, current) {
72
- const [claude, codex, builder] = await Promise.all([
73
- json(path.join(root, '.claude-plugin/plugin.json')),
74
- json(path.join(root, '.codex-plugin/plugin.json')),
75
- regularFile(path.join(root, 'scripts/build-packages.mjs')),
76
- ]);
77
- return builder && manifest(claude) && manifest(codex) &&
78
- claude.version === current.version && codex.version === current.version &&
79
- claude.hooks === './adapters/claude/hooks.json' &&
80
- current.extensions?.['com.openai']?.hooks === './adapters/codex/hooks.json';
81
- }
82
-
83
70
  async function hostPackage(root, host, currentVersion) {
84
71
  const [isDirectory, marker, portable, native, mcp, hooks, ...files] = await Promise.all([
85
72
  directory(root), packageMarker(root), json(path.join(root, 'plugin.json')),
@@ -101,17 +88,6 @@ async function hostPackage(root, host, currentVersion) {
101
88
  portable.extensions?.['com.openai']?.hooks === './adapters/codex/hooks.json');
102
89
  }
103
90
 
104
- async function localMarketplace(root, codexRoot) {
105
- const value = await json(path.join(root, '.agents/plugins/marketplace.json'));
106
- if (!record(value) || value.name !== 'graphlin-local' || !Array.isArray(value.plugins)) return false;
107
- const entries = value.plugins.filter(entry => entry?.name === 'graphlin');
108
- if (entries.length !== 1) return false;
109
- const entry = entries[0];
110
- return entry.source?.source === 'local' && entry.source.path === './graphlin' &&
111
- path.join(root, 'graphlin') === codexRoot &&
112
- entry.policy?.installation === 'AVAILABLE' && entry.policy?.authentication === 'ON_INSTALL';
113
- }
114
-
115
91
  export async function inspectInstalledPackages({ dataDir, version: currentVersion }) {
116
92
  if (!version(currentVersion)) return { claude: false, codex: false };
117
93
  const root = path.join(absolute(dataDir), 'plugins', 'graphlin', currentVersion);
@@ -120,116 +96,55 @@ export async function inspectInstalledPackages({ dataDir, version: currentVersio
120
96
  return { claude, codex };
121
97
  }
122
98
 
123
- // Discovery reads only fixed package metadata and checks file availability.
124
- // It never scans the project, reads state/credentials, or executes commands.
125
- async function discover(pluginRoot, dataDir) {
126
- const [marker, current, isDirectory] = await Promise.all([
127
- packageMarker(pluginRoot), json(path.join(pluginRoot, 'plugin.json')), directory(pluginRoot),
128
- ]);
129
- if (!isDirectory || !manifest(current) || marker.status === 'invalid') return { available: false };
130
- if (marker.status === 'missing') {
131
- if (!await sourceCheckout(pluginRoot, current)) return { available: false };
132
- // npm installations can be read-only. Generated host packages belong in
133
- // the same user-owned data directory as the running service, not beside
134
- // installed code. Versioned paths stay stable across projects and restarts.
135
- const output = path.join(dataDir, 'plugins', 'graphlin', current.version);
136
- const claudeRoot = path.join(output, 'claude/graphlin');
137
- const codexRoot = path.join(output, 'codex/graphlin');
138
- const [claudeReady, codexReady, marketplaceReady] = await Promise.all([
139
- hostPackage(claudeRoot, 'claude', current.version),
140
- hostPackage(codexRoot, 'codex', current.version),
141
- localMarketplace(path.dirname(codexRoot), codexRoot),
142
- ]);
143
- return {
144
- available: true,
145
- ...(claudeReady && codexReady && marketplaceReady ? {} : { build: path.join(pluginRoot, 'scripts/build-packages.mjs') }),
146
- output,
147
- claude: claudeRoot,
148
- marketplace: path.join(output, 'codex'),
149
- };
150
- }
151
- const distribution = path.resolve(pluginRoot, '../..');
152
- const claudeRoot = marker.profile === 'claude' ? pluginRoot : path.join(distribution, 'claude/graphlin');
153
- const codexRoot = marker.profile === 'codex' ? pluginRoot : path.join(distribution, 'codex/graphlin');
154
- const marketplaceRoot = path.dirname(codexRoot);
155
- const [claude, codex, marketplace] = await Promise.all([
156
- hostPackage(claudeRoot, 'claude', current.version),
157
- hostPackage(codexRoot, 'codex', current.version),
158
- localMarketplace(marketplaceRoot, codexRoot),
159
- ]);
160
- return {
161
- available: true, claude: claude ? claudeRoot : null,
162
- marketplace: codex && marketplace ? marketplaceRoot : null,
163
- };
164
- }
165
-
166
- function format({ projectRoot, dataDir, mode }, found) {
99
+ // Connection guidance uses only the current instance's trusted startup context.
100
+ // npm onboarding owns package installation; the guide neither discovers local
101
+ // package paths nor reads project settings, credentials, or host configuration.
102
+ function format({ projectRoot, dataDir, mode }) {
167
103
  const result = { projectRoot, mode, instructions: [], notes: [] };
168
- if (mode === 'demo') {
169
- result.notes.push('This viewer is in demo mode and uses fixture classifications. Open a live Graphlin viewer for your own project before connecting your work.');
170
- }
171
- if (!found.available) {
172
- result.notes.push('Connection setup is unavailable because this Graphlin source checkout or package could not be verified. Obtain a complete Graphlin distribution.');
173
- return result;
174
- }
175
- const terminal = words => `cd ${quote(projectRoot)} && GRAPHLIN_DATA_DIR=${quote(dataDir)} ${words}`;
104
+ const demo = mode === 'demo';
105
+ // defaultDataDir() includes the daemon's environment override. Comparing to
106
+ // it would wrongly hide custom directories needed by a second terminal.
107
+ const customDataDir = !demo && dataDir !== path.resolve(homedir(), '.local/state/graphlin');
108
+ const terminal = words => `${demo ? '' : `cd ${quote(projectRoot)} && `}${customDataDir ? `GRAPHLIN_DATA_DIR=${quote(dataDir)} ` : ''}${words}`;
176
109
  const instruction = (id, title, description, steps) => ({ id, title, description, steps });
177
- const fits = entries => entries.every(item => item.steps.every(step =>
178
- Buffer.byteLength(step.command) <= MAX_COMMAND_BYTES));
179
- function append(entries, host) {
180
- if (fits(entries)) result.instructions.push(...entries);
181
- else result.notes.push(`${host} commands are too long to display safely. Use shorter project, package, or data directory paths.`);
182
- }
183
-
184
- if (found.build) {
185
- const build = [instruction('build-packages', 'Build current plugin packages',
186
- 'Run this prerequisite first. It rebuilds both host profiles in your Graphlin data directory; existing generated packages may be out of date.',
187
- [{ label: 'Build packages in Terminal', command: terminal(`node ${quote(found.build)} --out ${quote(found.output)}`) }])];
188
- if (!fits(build)) {
189
- result.notes.push('The build command is too long to display safely. Use shorter project, package, or data directory paths.');
190
- return result;
191
- }
192
- result.instructions.push(...build);
193
- }
194
- const prerequisite = found.build ? 'Complete the build step above first. ' : '';
195
- if (found.claude) {
196
- const launch = terminal(`claude --plugin-dir ${quote(found.claude)}`);
197
- append([
198
- instruction('claude-new', 'Claude: new session',
199
- `${prerequisite}Start a new Claude session with this Graphlin plugin loaded.`,
200
- [{ label: 'Start Claude in Terminal', command: launch }]),
201
- instruction('claude-resume', 'Claude: resume',
202
- `${prerequisite}Instead of starting a new session, continue the most recent conversation in this project.`,
203
- [{ label: 'Resume Claude in Terminal', command: `${launch} --continue` }]),
204
- ], 'Claude');
110
+ const entries = [
111
+ instruction('npm-setup', demo ? '1. Start a live viewer' : 'Set up only if needed',
112
+ demo
113
+ ? 'Open a terminal in your own project. Run guided setup, choose Claude Code or Codex and source or metadata mode, then keep that terminal running. The live viewer opens automatically.'
114
+ : 'This viewer is already running. If you completed guided setup for this project and your chosen host, skip this step. Otherwise, run setup alone in a second terminal; init does not start another server.',
115
+ [{ label: demo ? 'In your project terminal' : 'Set up this project in a second terminal',
116
+ command: terminal(`npx --yes graphlin@latest${demo ? '' : ' init'}`),
117
+ description: 'Choose your host and source or metadata mode. Source mode needs a TypeSafe API key; enter it at the masked prompt if asked. Metadata mode needs no key.' }]),
118
+ instruction('claude-new', demo ? '2. Start Claude Code (choose one agent)' : 'Start Claude Code (choose one agent)',
119
+ 'After setup, open a second terminal in the same project and start a new agent session. Accept the project trust prompt.',
120
+ [{ label: 'Start Claude in the second terminal', command: terminal('claude') },
121
+ { label: 'Inside Claude: /plugin', command: '/plugin',
122
+ description: 'Confirm Graphlin is enabled.' }]),
123
+ instruction('codex-new', demo ? '2. Start Codex (choose one agent)' : 'Start Codex (choose one agent)',
124
+ 'After setup, open a second terminal in the same project and start a new agent session. Accept the project trust prompt.',
125
+ [{ label: 'Start Codex in the second terminal', command: terminal('codex') },
126
+ { label: 'Inside Codex: /hooks', command: '/hooks',
127
+ description: 'Review and trust Graphlin hooks before starting your work.' }]),
128
+ ];
129
+ // Omit the whole guide rather than offering launch commands without setup or
130
+ // truncating a shell argument. The viewer applies the same command limit.
131
+ if (entries.every(item => item.steps.every(step => Buffer.byteLength(step.command) <= MAX_COMMAND_BYTES))) {
132
+ result.instructions.push(...entries);
205
133
  } else {
206
- result.notes.push('Claude connection is unavailable: matching packaged files were not found. Rebuild or obtain the complete Graphlin distribution, including its Claude profile.');
134
+ result.notes.push('Connection commands are too long to display safely. Use shorter project or data directory paths.');
207
135
  }
208
- if (found.marketplace) {
209
- const hooks = () => ({
210
- label: 'Inside Codex: /hooks', command: '/hooks',
211
- description: 'Inside Codex, review and trust Graphlin hooks; then start your work',
212
- });
213
- const launch = terminal(`codex -C ${quote(projectRoot)}`);
214
- append([
215
- instruction('codex-setup', 'Codex: install the local plugin',
216
- `${prerequisite}Run both setup commands before launching. They register this local marketplace and install its Graphlin package in Codex.`,
217
- [
218
- { label: 'Register marketplace in Terminal', command: terminal(`codex plugin marketplace add ${quote(found.marketplace)}`) },
219
- { label: 'Install Graphlin in Terminal', command: terminal(`codex plugin add ${quote('graphlin@graphlin-local')}`) },
220
- ]),
221
- instruction('codex-new', 'Codex: new session',
222
- 'Complete Codex setup above, then start a new session and review the hooks inside Codex.',
223
- [{ label: 'Start Codex in Terminal', command: launch }, hooks()]),
224
- instruction('codex-resume', 'Codex: resume',
225
- 'Complete Codex setup above. Instead of starting a new session, resume the most recent conversation in this project.',
226
- [{ label: 'Resume Codex in Terminal', command: `${launch} resume --last` }, hooks()]),
227
- ], 'Codex');
136
+ if (demo) {
137
+ result.notes.push('This demo uses fixture classifications. Run the commands in your own project; its setup uses the normal data directory, not this demo’s custom directory.');
228
138
  } else {
229
- result.notes.push('Codex connection is unavailable: matching packaged files and a valid local marketplace were not found. Rebuild or obtain the complete Graphlin distribution, including its Codex marketplace.');
139
+ result.notes.push('Next time: npx --yes graphlin@latest in this project, then claude or codex in a second terminal. Keep the viewer running.');
140
+ result.notes.push('Consent or key changes require stopping and restarting this project’s viewer with the same data directory. Until then, its current policy and classifier configuration stay in effect.');
230
141
  }
231
- result.notes.push('Choose either a new session or resume. Keep the Graphlin viewer running while you work.');
232
- result.notes.push('Package discovery does not confirm that host hooks are active. Follow the host prompts to load and trust the plugin.');
142
+ result.notes.push(customDataDir
143
+ ? 'Commands preserve this viewer’s custom data directory. Use the same GRAPHLIN_DATA_DIR for future viewer launches.'
144
+ : 'Default data: ~/.local/state/graphlin. Unset GRAPHLIN_DATA_DIR in both terminals to use it.' +
145
+ (demo ? ' For an intentional custom location, set the same GRAPHLIN_DATA_DIR in both terminals.' : ''));
146
+ result.notes.push('Plugins install across projects; source consent is per project. Source mode sends locally filtered source excerpts, user prompts, and public agent messages to TypeSafe.');
147
+ result.notes.push('Installation does not confirm hook activation. After setup and trust, ask: “Orient yourself in this project: read its main files and explain how the components connect.” Watch for hook delivery and diagram updates.');
233
148
  return result;
234
149
  }
235
150
 
@@ -239,11 +154,7 @@ function format({ projectRoot, dataDir, mode }, found) {
239
154
  * body/query. projectRoot and dataDir are already canonicalized by projectPaths.
240
155
  * No environment, graph, launch URL, or credential is copied into the result.
241
156
  */
242
- export async function createConnectionInfo({
243
- projectRoot, dataDir, mode = 'live', pluginRoot = PLUGIN_ROOT,
244
- } = {}) {
157
+ export async function createConnectionInfo({ projectRoot, dataDir, mode = 'live' } = {}) {
245
158
  if (!['live', 'demo'].includes(mode)) throw new TypeError('invalid_connection_info');
246
- const context = { projectRoot: absolute(projectRoot), dataDir: absolute(dataDir), mode };
247
- const root = absolute(pluginRoot);
248
- return format(context, await discover(root, context.dataDir));
159
+ return format({ projectRoot: absolute(projectRoot), dataDir: absolute(dataDir), mode });
249
160
  }
@@ -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
@@ -876,6 +981,7 @@ export function startConnectionDialog({ load = () => request('/api/connection-in
876
981
  $('connection-project').hidden = true;
877
982
  $('connection-project').textContent = '';
878
983
  $('connection-demo').hidden = true;
984
+ $('connection-dialog-intro').textContent = 'Connect Claude Code or Codex with guided npm setup.';
879
985
  $('connection-copy-status').textContent = '';
880
986
  }
881
987
  function renderInfo(info, ticket) {
@@ -931,6 +1037,10 @@ export function startConnectionDialog({ load = () => request('/api/connection-in
931
1037
  $('connection-project').textContent = `Project: ${info.projectRoot}`;
932
1038
  $('connection-project').hidden = !info.projectRoot;
933
1039
  $('connection-demo').hidden = info.mode !== 'demo';
1040
+ $('connection-demo').textContent = 'This is an offline demo. Start a live viewer in your own project to connect an agent.';
1041
+ $('connection-dialog-intro').textContent = info.mode === 'demo'
1042
+ ? 'Start Graphlin in your project, then start your agent in a second terminal in that same project.'
1043
+ : 'Keep this viewer running. In a second terminal, set up if needed, then start a new agent session for the project below.';
934
1044
  $('connection-notes').replaceChildren(...info.notes.map(note => html('li', note)));
935
1045
  $('connection-notes').hidden = !info.notes.length;
936
1046
  }
@@ -1445,12 +1555,13 @@ export function startViewer() {
1445
1555
  connection: 'connecting', busy: false, exporting: false, epoch: 0, connectEpoch: 0,
1446
1556
  viewport: null, fitBounds: null, zoom: 1, followFit: true, lastGraphSignature: '', inspectorSignature: '',
1447
1557
  nodeElements: new Map(), edgeElements: new Map(), activityElements: new Map(),
1448
- views: new Map(), viewKey: null, view: null, displayGraph: null,
1449
- 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: '',
1450
1560
  };
1451
1561
  const motionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
1452
1562
  const sketches = createSketchCache();
1453
1563
  const detailSketches = createSketchCache(sketchDetails);
1564
+ const dashboardInfo = startDashboardInfo();
1454
1565
  const connectionDialog = startConnectionDialog({ onInfo: info => {
1455
1566
  state.projectName = friendlyProjectName(info.projectRoot);
1456
1567
  renderStatus();
@@ -1519,7 +1630,7 @@ export function startViewer() {
1519
1630
  $('onboarding-action').dataset.action = progress.next.action;
1520
1631
  // Preserve a manual text selection while live snapshots arrive.
1521
1632
  if ($('orientation-prompt').textContent !== ORIENTATION_PROMPT) $('orientation-prompt').textContent = ORIENTATION_PROMPT;
1522
- $('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');
1523
1634
  }
1524
1635
  function currentGraph() { return state.replayFrame?.graph || state.snapshot?.graph; }
1525
1636
  function applyTheme() {
@@ -1532,6 +1643,7 @@ export function startViewer() {
1532
1643
  if (state.viewKey !== key) {
1533
1644
  finishPan();
1534
1645
  clearMotion();
1646
+ state.liveReady = false;
1535
1647
  state.motionReady = false;
1536
1648
  let view = state.views.get(key);
1537
1649
  if (!view) view = createPresentation();
@@ -1575,14 +1687,14 @@ export function startViewer() {
1575
1687
  state.effects.set(id, effect);
1576
1688
  target.addEventListener('animationend', finish);
1577
1689
  }
1578
- function cancelMovement() {
1690
+ function cancelMovement({ fit = true } = {}) {
1579
1691
  const movement = state.movement;
1580
1692
  if (!movement) return;
1581
1693
  state.movement = null;
1582
1694
  window.cancelAnimationFrame?.(movement.frame);
1583
1695
  clearTimeout(movement.timer);
1584
1696
  if (state.displayGraph) paintGeometry(state.displayGraph);
1585
- if (movement.fitAfter) fitCamera(movement.fitAfter);
1697
+ if (fit && movement.fitAfter) fitCamera(movement.fitAfter);
1586
1698
  }
1587
1699
  function clearMotion() {
1588
1700
  cancelMovement();
@@ -1590,10 +1702,11 @@ export function startViewer() {
1590
1702
  }
1591
1703
  function resetMotionBaseline() {
1592
1704
  finishPan();
1705
+ state.liveReady = false;
1593
1706
  state.motionReady = false;
1594
1707
  clearMotion();
1595
1708
  }
1596
- function animateChanges(changes, before) {
1709
+ function animateChanges(changes, before, focusNodeId) {
1597
1710
  // Re-addition always cancels a removal, even when motion is suppressed.
1598
1711
  for (const node of currentGraph().nodes) if (state.effects.get(node.id)?.element) finishEffect(node.id);
1599
1712
  if (!motionAllowed()) return;
@@ -1622,7 +1735,7 @@ export function startViewer() {
1622
1735
  removals.push(decoration);
1623
1736
  }
1624
1737
  if (removals.length) {
1625
- fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
1738
+ if (!focusNodeId) fitCamera(state.movement?.fitDuring || graphBounds(state.displayGraph));
1626
1739
  $('empty-canvas').hidden = true;
1627
1740
  $('effects-layer').append(...removals);
1628
1741
  }
@@ -1672,15 +1785,21 @@ export function startViewer() {
1672
1785
  const eligible = streamed && state.motionReady && !switched && !state.replayFrame &&
1673
1786
  snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay' && motionAllowed();
1674
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);
1675
1793
  const before = state.displayGraph;
1676
- cancelMovement();
1794
+ cancelMovement({ fit: !focusNodeId });
1677
1795
  if (switched) resetView();
1678
1796
  state.snapshot = snapshot;
1679
1797
  state.epoch += 1;
1680
1798
  state.frames = historyFrames(snapshot);
1681
1799
  state.replayFrame = reconcileReplayFrame(state.frames, state.replayFrame);
1682
- render();
1683
- animateChanges(changes, before);
1800
+ render({ focusNodeId });
1801
+ animateChanges(changes, before, focusNodeId);
1802
+ state.liveReady = streamed && !state.replayFrame && snapshot.mode !== 'replay';
1684
1803
  state.motionReady = streamed && !state.replayFrame && snapshot.mode !== 'replay' && motionAllowed();
1685
1804
  $('updated-at').textContent = `Snapshot received ${formatTime(Date.now())}`;
1686
1805
  }
@@ -1864,7 +1983,24 @@ export function startViewer() {
1864
1983
  cancelMovement();
1865
1984
  fitCamera(graphBounds(graph));
1866
1985
  }
1867
- function zoom(factor) {
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
+ }
2003
+ function zoom(factor, anchor = { x: .5, y: .5 }) {
1868
2004
  if (!state.viewport || !state.fitBounds) return;
1869
2005
  cancelMovement();
1870
2006
  finishPan();
@@ -1872,28 +2008,31 @@ export function startViewer() {
1872
2008
  const scale = state.zoom / next;
1873
2009
  const old = state.viewport;
1874
2010
  state.viewport = {
1875
- x: old.x + old.width * (1 - scale) / 2,
1876
- y: old.y + old.height * (1 - scale) / 2,
2011
+ x: old.x + old.width * (1 - scale) * anchor.x,
2012
+ y: old.y + old.height * (1 - scale) * anchor.y,
1877
2013
  width: old.width * scale, height: old.height * scale,
1878
2014
  };
1879
2015
  state.zoom = next;
1880
2016
  state.followFit = false;
1881
2017
  setViewBox();
1882
2018
  }
1883
- function renderGraph({ forceFit = false } = {}) {
2019
+ function renderGraph({ forceFit = false, focusNodeId } = {}) {
1884
2020
  const canonical = currentGraph();
1885
2021
  if (!canonical) return;
1886
2022
  const view = presentation();
1887
2023
  applyTheme();
1888
- 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);
1889
2027
  state.displayGraph = graph;
1890
2028
  const routes = graphEdgeRoutes(graph);
1891
2029
  const bounds = graphBounds(graph, routes);
1892
2030
  const signature = cameraGraphSignature(graph, view.algorithm);
1893
- // Set the physical viewport before inserting newcomers or starting motion.
1894
- // A manual camera survives status updates, but the next diagram change fits
1895
- // the complete map, even with automatic arrangement disabled.
1896
- 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);
1897
2036
  state.lastGraphSignature = signature;
1898
2037
  $('layout').value = view.algorithm;
1899
2038
  $('auto-arrange').checked = view.auto;
@@ -1961,6 +2100,7 @@ export function startViewer() {
1961
2100
  center,
1962
2101
  svgElement('rect', { class: 'selection-ring', x: -7, y: -7, width: NODE_WIDTH + 14, height: NODE_HEIGHT + 14, rx: 14 }),
1963
2102
  );
2103
+ group.setAttribute('transform', `translate(${node.x} ${node.y})`);
1964
2104
  state.nodeElements.set(node.id, group);
1965
2105
  $('node-layer').append(group);
1966
2106
  }
@@ -2004,6 +2144,9 @@ export function startViewer() {
2004
2144
  $('diagram-title').textContent = `${state.replayFrame ? 'Historical' : 'Live'} architecture, revision ${graph.revision}`;
2005
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.`;
2006
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;
2007
2150
  $('empty-canvas').hidden = graph.nodes.length > 0 || removalBounds().length > 0;
2008
2151
  const classifier = state.snapshot.paused ? 'paused' : state.snapshot.status.classifier;
2009
2152
  const emptyMessages = {
@@ -2013,7 +2156,9 @@ export function startViewer() {
2013
2156
  unavailable: ['Waiting for classification.', 'The classifier is unavailable. Safe activity continues below; supported architecture will appear when classification recovers.'],
2014
2157
  timeout: ['Evidence needs another moment.', 'Classification exceeded its deadline. Activity still appears below, and no unsupported components are added.'],
2015
2158
  };
2016
- 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
2017
2162
  ? ['No components in this revision.', 'Move through the recent revisions or return to Live to follow the current map.']
2018
2163
  : emptyMessages[classifier] || ['Your architecture starts here.', 'Work in a connected agent session. Components appear when approved evidence supports them; activity can arrive first.'];
2019
2164
  $('empty-title').textContent = message[0];
@@ -2300,10 +2445,10 @@ export function startViewer() {
2300
2445
  $('activity-empty').hidden = events.length > 0;
2301
2446
  $('activity-list').hidden = events.length === 0;
2302
2447
  }
2303
- function render() {
2448
+ function render(graphOptions) {
2304
2449
  renderStatus();
2305
2450
  renderOnboarding();
2306
- renderGraph();
2451
+ renderGraph(graphOptions);
2307
2452
  renderInspector();
2308
2453
  renderHistory();
2309
2454
  renderActivity();
@@ -2379,6 +2524,7 @@ export function startViewer() {
2379
2524
  connection('reconnecting');
2380
2525
  error('The live connection was lost. Displaying the last received snapshot while the viewer reconnects.');
2381
2526
  });
2527
+ void dashboardInfo.refresh();
2382
2528
  // Optional authenticated metadata must not hold up the event stream or
2383
2529
  // turn an older server's missing endpoint into a connection failure.
2384
2530
  const controller = new AbortController();
@@ -2387,6 +2533,7 @@ export function startViewer() {
2387
2533
  const info = normalizeConnectionInfo(await request('/api/connection-info', { signal: controller.signal }));
2388
2534
  if (!state.closed && attempt === state.connectEpoch) {
2389
2535
  state.projectName = friendlyProjectName(info.projectRoot);
2536
+ $('project-path').textContent = info.projectRoot;
2390
2537
  renderStatus();
2391
2538
  }
2392
2539
  } catch { /* The project ID remains a usable fallback. */ }
@@ -2450,6 +2597,37 @@ export function startViewer() {
2450
2597
  $('onboarding-action').addEventListener('click', onOnboardingAction);
2451
2598
  $('orientation-copy').addEventListener('click', onCopyOrientation);
2452
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);
2453
2631
  $('pause').addEventListener('click', () => control(state.snapshot?.paused ? 'resume' : 'pause'));
2454
2632
  $('session').addEventListener('change', () => control('session', $('session').value));
2455
2633
  $('export').addEventListener('click', exportJSON);
@@ -2493,6 +2671,28 @@ export function startViewer() {
2493
2671
  });
2494
2672
  $('zoom-in').addEventListener('click', () => zoom(1.25));
2495
2673
  $('zoom-out').addEventListener('click', () => zoom(.8));
2674
+ const onDiagramWheel = event => {
2675
+ if (state.closed || !state.viewport || !currentGraph()?.nodes.length || event.defaultPrevented ||
2676
+ event.shiftKey || !Number.isFinite(event.deltaY) || event.deltaY === 0 ||
2677
+ Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
2678
+ const rect = $('architecture').getBoundingClientRect();
2679
+ if (!(rect.width > 0 && rect.height > 0) ||
2680
+ !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
2681
+ // Wheel units are pixels, lines, or pages. Keep trackpad motion continuous,
2682
+ // but cap each event so a coarse wheel or a page delta cannot jump too far.
2683
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rect.height : 1;
2684
+ const delta = Math.max(-100, Math.min(100, event.deltaY * unit));
2685
+ const box = state.viewport;
2686
+ const scale = Math.min(rect.width / box.width, rect.height / box.height);
2687
+ const width = box.width * scale, height = box.height * scale;
2688
+ const anchor = {
2689
+ x: (event.clientX - rect.left - (rect.width - width) / 2) / width,
2690
+ y: (event.clientY - rect.top - (rect.height - height) / 2) / height,
2691
+ };
2692
+ event.preventDefault();
2693
+ zoom(Math.exp(-delta * .002), anchor);
2694
+ };
2695
+ $('architecture').addEventListener('wheel', onDiagramWheel, { passive: false });
2496
2696
  $('architecture').addEventListener('keydown', event => {
2497
2697
  if (event.target !== $('architecture')) return;
2498
2698
  if (event.key === '+' || event.key === '=') { event.preventDefault(); zoom(1.25); }
@@ -2568,9 +2768,14 @@ export function startViewer() {
2568
2768
  state.closed = true;
2569
2769
  projectController?.abort();
2570
2770
  projectController = null;
2771
+ dashboardInfo.close();
2571
2772
  $('onboarding-action').removeEventListener('click', onOnboardingAction);
2572
2773
  $('orientation-copy').removeEventListener('click', onCopyOrientation);
2573
2774
  $('activity-toggle').removeEventListener('click', onToggleActivity);
2775
+ $('diagram-search').removeEventListener('input', onSearchInput);
2776
+ $('diagram-search-clear').removeEventListener('click', clearSearch);
2777
+ window.removeEventListener?.('keydown', onSearchKeyDown);
2778
+ $('architecture').removeEventListener('wheel', onDiagramWheel);
2574
2779
  connectionDialog.dispose();
2575
2780
  diagnosticsDialog.dispose();
2576
2781
  sidebar.destroy();
@@ -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.0' },
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({});