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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/runtime/daemon/connection-info.mjs +46 -135
- package/runtime/daemon/dashboard-info.mjs +175 -0
- package/runtime/daemon/server.mjs +9 -1
- package/runtime/web/app.js +227 -22
- package/runtime/web/index.html +23 -0
- package/runtime/web/style.css +18 -0
- package/scripts/control.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphlin",
|
|
3
|
-
"version": "0.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.
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
'
|
|
187
|
-
[{ label: '
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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('
|
|
134
|
+
result.notes.push('Connection commands are too long to display safely. Use shorter project or data directory paths.');
|
|
207
135
|
}
|
|
208
|
-
if (
|
|
209
|
-
|
|
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('
|
|
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(
|
|
232
|
-
|
|
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
|
-
|
|
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
|
}
|
package/runtime/web/app.js
CHANGED
|
@@ -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
|
|
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)
|
|
1876
|
-
y: old.y + old.height * (1 - scale)
|
|
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
|
-
|
|
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
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1896
|
-
if (
|
|
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.
|
|
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();
|
package/runtime/web/index.html
CHANGED
|
@@ -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>
|
package/runtime/web/style.css
CHANGED
|
@@ -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; }
|
package/scripts/control.mjs
CHANGED
|
@@ -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.
|
|
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({});
|