@xiaolei.shawn/mcp-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AL Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @xiaolei.shawn/mcp-server
2
+
3
+ Local-first MCP server for AI agent session auditing.
4
+
5
+ - Records canonical session events via MCP tools
6
+ - Persists events as local JSONL files
7
+ - Serves a local web dashboard + API from the same process
8
+ - Data never leaves the machine unless you explicitly move files
9
+
10
+ ## Open-source connector model
11
+
12
+ This package is intended to be the open-source MCP connector layer.
13
+
14
+ - Open source: MCP tools + canonical event capture + local storage/API serving
15
+ - Proprietary (optional): advanced analyzer dashboard/heuristics binaries can be served separately
16
+
17
+ You can point the built-in dashboard server to any static bundle via `AL_DASHBOARD_WEBAPP_DIR`.
18
+
19
+ ## Features
20
+
21
+ - Canonical event capture with sequence ordering and timestamps
22
+ - Gateway tools for low-friction agent instrumentation
23
+ - Local dashboard server (`/api/sessions`, `/api/sessions/:key`)
24
+ - Session storage on local disk (`AL_SESSIONS_DIR`)
25
+ - Local gateway API for middleware (`/api/gateway/*`)
26
+ - Export session JSON with normalized snapshot (`agentlens export`)
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install @xiaolei.shawn/mcp-server
32
+ ```
33
+
34
+ ## Run
35
+
36
+ ```bash
37
+ agentlens start --open
38
+ ```
39
+
40
+ This starts the local dashboard + gateway API on `http://127.0.0.1:4317` and opens a browser tab.
41
+
42
+ MCP mode (for Cursor/Codex MCP config):
43
+
44
+ ```bash
45
+ agentlens mcp
46
+ ```
47
+
48
+ ## MCP Tools
49
+
50
+ ### Canonical recorders
51
+
52
+ - `record_session_start`
53
+ - `record_intent`
54
+ - `record_activity`
55
+ - `record_decision`
56
+ - `record_assumption`
57
+ - `record_verification`
58
+ - `record_session_end`
59
+
60
+ ### Gateway tools
61
+
62
+ - `gateway_begin_run`
63
+ - `gateway_act`
64
+ - `gateway_end_run`
65
+
66
+ ## Local Dashboard
67
+
68
+ When the server starts, it also runs a local HTTP server (enabled by default).
69
+
70
+ Default URL:
71
+
72
+ - `http://127.0.0.1:4317`
73
+
74
+ API endpoints:
75
+
76
+ - `GET /api/health`
77
+ - `GET /api/sessions`
78
+ - `GET /api/sessions/:key`
79
+ - `GET /api/sessions/:key/export`
80
+ - `POST /api/gateway/begin`
81
+ - `POST /api/gateway/act`
82
+ - `POST /api/gateway/end`
83
+
84
+ If web assets are available (default `../webapp/dist`), they are served by the same server.
85
+
86
+ ## Automatic instrumentation defaults
87
+
88
+ To reduce agent friction:
89
+
90
+ - `gateway_act` auto-creates a session if no active session exists.
91
+ - `gateway_act` auto-creates an intent when activity arrives without an active intent.
92
+ - `record_session_end` and `gateway_end_run` persist both raw JSONL and a normalized session snapshot.
93
+
94
+ ## Environment Variables
95
+
96
+ - `AL_SESSIONS_DIR` (default: `./sessions`): local session file directory.
97
+ - `AL_DASHBOARD_ENABLED` (default: `true`): enable/disable dashboard server.
98
+ - `AL_DASHBOARD_HOST` (default: `127.0.0.1`): dashboard bind host.
99
+ - `AL_DASHBOARD_PORT` (default: `4317`): dashboard bind port.
100
+ - `AL_DASHBOARD_WEBAPP_DIR` (default: auto): static webapp build directory.
101
+ - `AL_WORKSPACE_ROOT` (default: `process.cwd()`): workspace root for safe path operations.
102
+ - `AL_AUTO_GOAL` (default: `Agent task execution`): fallback goal for auto-started sessions.
103
+ - `AL_AUTO_USER_PROMPT` (default: `Auto-instrumented run`): fallback prompt for auto-started sessions.
104
+
105
+ ## Cursor/Codex MCP configuration example
106
+
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "agentlens": {
111
+ "command": "agentlens",
112
+ "args": ["mcp"],
113
+ "env": {
114
+ "AL_SESSIONS_DIR": "/absolute/path/to/sessions"
115
+ }
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ ## Build from source
122
+
123
+ ```bash
124
+ pnpm install
125
+ pnpm --filter @xiaolei.shawn/mcp-server build
126
+ pnpm --filter @xiaolei.shawn/mcp-server start
127
+ ```
128
+
129
+ ## Export session JSON
130
+
131
+ Export latest session:
132
+
133
+ ```bash
134
+ agentlens export --latest --out ./latest.session.json
135
+ ```
136
+
137
+ Export by session id:
138
+
139
+ ```bash
140
+ agentlens export --session sess_1771256059058_2bd2bd8f --out ./session.json
141
+ ```
142
+
143
+ ## Publish checklist
144
+
145
+ 1. Update version in `package.json`.
146
+ 2. Confirm repository URLs in `package.json` are correct.
147
+ 3. Run:
148
+
149
+ ```bash
150
+ npm run build
151
+ npm pack --dry-run
152
+ npm publish --access public --dry-run
153
+ ```
154
+
155
+ 4. Publish:
156
+
157
+ ```bash
158
+ npm publish --access public
159
+ ```
@@ -0,0 +1,14 @@
1
+ /**
2
+ * MCP server config from environment.
3
+ */
4
+ export declare function getSessionsDir(): string;
5
+ export declare function isDashboardEnabled(): boolean;
6
+ export declare function getDashboardHost(): string;
7
+ export declare function getDashboardPort(): number;
8
+ export declare function getDashboardWebappDir(): string;
9
+ export declare function isWatcherEnabled(): boolean;
10
+ export declare function getWatcherDir(): string;
11
+ /** Workspace root for file_op: all paths are resolved and validated against this. */
12
+ export declare function getWorkspaceRoot(): string;
13
+ /** Resolve path relative to workspace; throw if it escapes workspace (path traversal). */
14
+ export declare function resolveWithinWorkspace(workspaceRoot: string, rawPath: string): string;
package/dist/config.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * MCP server config from environment.
3
+ */
4
+ import { dirname, resolve, sep } from "node:path";
5
+ import { existsSync, realpathSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ export function getSessionsDir() {
8
+ return process.env.AL_SESSIONS_DIR ?? process.env.MCP_AL_SESSIONS_DIR ?? "./sessions";
9
+ }
10
+ export function isDashboardEnabled() {
11
+ const raw = process.env.AL_DASHBOARD_ENABLED ?? process.env.MCP_AL_DASHBOARD_ENABLED;
12
+ if (raw === undefined)
13
+ return true;
14
+ return raw === "1" || raw === "true";
15
+ }
16
+ export function getDashboardHost() {
17
+ return process.env.AL_DASHBOARD_HOST ?? process.env.MCP_AL_DASHBOARD_HOST ?? "127.0.0.1";
18
+ }
19
+ export function getDashboardPort() {
20
+ const raw = process.env.AL_DASHBOARD_PORT ?? process.env.MCP_AL_DASHBOARD_PORT;
21
+ if (!raw)
22
+ return 4317;
23
+ const parsed = Number(raw);
24
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
25
+ throw new Error(`Invalid AL_DASHBOARD_PORT: ${raw}`);
26
+ }
27
+ return parsed;
28
+ }
29
+ export function getDashboardWebappDir() {
30
+ const explicit = process.env.AL_DASHBOARD_WEBAPP_DIR ?? process.env.MCP_AL_DASHBOARD_WEBAPP_DIR;
31
+ if (explicit)
32
+ return resolve(explicit);
33
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
34
+ // dist/config.js -> ../.. lands in mcp-server root
35
+ const serverRoot = resolve(moduleDir, "..");
36
+ return resolve(serverRoot, "../webapp/dist");
37
+ }
38
+ export function isWatcherEnabled() {
39
+ return process.env.AL_WATCHER_ENABLED === "1" || process.env.AL_WATCHER_ENABLED === "true";
40
+ }
41
+ export function getWatcherDir() {
42
+ return process.env.AL_WATCHER_DIR ?? process.env.MCP_AL_WATCHER_DIR ?? "./watcher-events";
43
+ }
44
+ /** Workspace root for file_op: all paths are resolved and validated against this. */
45
+ export function getWorkspaceRoot() {
46
+ const root = process.env.AL_WORKSPACE_ROOT ?? process.env.MCP_AL_WORKSPACE_ROOT ?? process.cwd();
47
+ try {
48
+ return realpathSync(resolve(root));
49
+ }
50
+ catch {
51
+ return resolve(root);
52
+ }
53
+ }
54
+ /** Resolve path relative to workspace; throw if it escapes workspace (path traversal). */
55
+ export function resolveWithinWorkspace(workspaceRoot, rawPath) {
56
+ const normalized = resolve(workspaceRoot, rawPath);
57
+ const real = existsSync(normalized) ? realpathSync(normalized) : resolve(normalized);
58
+ const prefix = workspaceRoot.endsWith(sep) ? workspaceRoot : workspaceRoot + sep;
59
+ if (real !== workspaceRoot && !real.startsWith(prefix)) {
60
+ throw new Error(`Path escapes workspace: ${rawPath}`);
61
+ }
62
+ return real;
63
+ }
@@ -0,0 +1,4 @@
1
+ export declare function startDashboardServer(): {
2
+ host: string;
3
+ port: number;
4
+ } | null;
@@ -0,0 +1,401 @@
1
+ import { createServer } from "node:http";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { extname, join, normalize, resolve } from "node:path";
4
+ import { getDashboardHost, getDashboardPort, getDashboardWebappDir, getSessionsDir, isDashboardEnabled, } from "./config.js";
5
+ import { exportSessionJson } from "./store.js";
6
+ import { handleGatewayAct, handleGatewayBeginRun, handleGatewayEndRun } from "./tools.js";
7
+ function json(res, status, payload) {
8
+ res.writeHead(status, {
9
+ "content-type": "application/json; charset=utf-8",
10
+ "cache-control": "no-store",
11
+ });
12
+ res.end(JSON.stringify(payload));
13
+ }
14
+ async function readJsonBody(req) {
15
+ const chunks = [];
16
+ for await (const chunk of req) {
17
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
18
+ }
19
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
20
+ if (!raw)
21
+ return {};
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ throw new Error("Invalid JSON body.");
28
+ }
29
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
30
+ throw new Error("Request body must be a JSON object.");
31
+ }
32
+ return parsed;
33
+ }
34
+ function parseToolResult(result) {
35
+ if (!result || typeof result !== "object") {
36
+ return { ok: false, payload: null, error: "Invalid tool response." };
37
+ }
38
+ const tool = result;
39
+ const text = tool.content?.[0]?.text;
40
+ if (tool.isError) {
41
+ return { ok: false, payload: null, error: text ?? "Tool failed." };
42
+ }
43
+ if (!text)
44
+ return { ok: true, payload: {} };
45
+ try {
46
+ return { ok: true, payload: JSON.parse(text) };
47
+ }
48
+ catch {
49
+ return { ok: true, payload: { message: text } };
50
+ }
51
+ }
52
+ function isCanonicalEvent(raw) {
53
+ if (!raw || typeof raw !== "object")
54
+ return false;
55
+ const event = raw;
56
+ return (typeof event.id === "string" &&
57
+ typeof event.session_id === "string" &&
58
+ typeof event.seq === "number" &&
59
+ typeof event.ts === "string" &&
60
+ typeof event.kind === "string" &&
61
+ !!event.actor &&
62
+ typeof event.actor.type === "string" &&
63
+ !!event.payload &&
64
+ typeof event.payload === "object" &&
65
+ typeof event.schema_version === "number");
66
+ }
67
+ function parseSessionContent(content) {
68
+ const text = content.trim();
69
+ if (!text)
70
+ throw new Error("Empty session file.");
71
+ if (text.startsWith("{") || text.startsWith("[")) {
72
+ try {
73
+ const parsed = JSON.parse(text);
74
+ if (Array.isArray(parsed)) {
75
+ const events = parsed.filter(isCanonicalEvent);
76
+ if (events.length !== parsed.length)
77
+ throw new Error("Invalid event in JSON array.");
78
+ return toSessionPayload(events);
79
+ }
80
+ if (parsed && typeof parsed === "object") {
81
+ const obj = parsed;
82
+ if (Array.isArray(obj.events) && obj.events.every(isCanonicalEvent)) {
83
+ return {
84
+ session_id: obj.session_id ?? obj.events[0]?.session_id ?? "unknown",
85
+ goal: typeof obj.goal === "string" ? obj.goal : undefined,
86
+ user_prompt: typeof obj.user_prompt === "string" ? obj.user_prompt : undefined,
87
+ started_at: typeof obj.started_at === "string" ? obj.started_at : undefined,
88
+ ended_at: typeof obj.ended_at === "string" ? obj.ended_at : undefined,
89
+ events: [...obj.events].sort((a, b) => (a.seq === b.seq ? a.ts.localeCompare(b.ts) : a.seq - b.seq)),
90
+ };
91
+ }
92
+ }
93
+ }
94
+ catch {
95
+ // Fall back to JSONL parsing below.
96
+ }
97
+ }
98
+ const lines = text
99
+ .split("\n")
100
+ .map((line) => line.trim())
101
+ .filter((line) => line.length > 0);
102
+ const events = lines.map((line, idx) => {
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(line);
106
+ }
107
+ catch {
108
+ throw new Error(`Invalid JSONL line ${idx + 1}`);
109
+ }
110
+ if (!isCanonicalEvent(parsed)) {
111
+ throw new Error(`Invalid canonical event at JSONL line ${idx + 1}`);
112
+ }
113
+ return parsed;
114
+ });
115
+ return toSessionPayload(events);
116
+ }
117
+ function toSessionPayload(events) {
118
+ const sorted = [...events].sort((a, b) => (a.seq === b.seq ? a.ts.localeCompare(b.ts) : a.seq - b.seq));
119
+ const start = sorted.find((event) => event.kind === "session_start");
120
+ const end = [...sorted].reverse().find((event) => event.kind === "session_end");
121
+ const startPayload = (start?.payload ?? {});
122
+ return {
123
+ session_id: sorted[0]?.session_id ?? "unknown",
124
+ goal: typeof startPayload.goal === "string" ? startPayload.goal : undefined,
125
+ user_prompt: typeof startPayload.user_prompt === "string" ? startPayload.user_prompt : undefined,
126
+ started_at: start?.ts ?? sorted[0]?.ts,
127
+ ended_at: end?.ts,
128
+ events: sorted,
129
+ };
130
+ }
131
+ function readSessionFile(absolutePath) {
132
+ const raw = readFileSync(absolutePath, "utf-8");
133
+ return parseSessionContent(raw);
134
+ }
135
+ function deriveOutcome(events) {
136
+ const end = [...events].reverse().find((event) => event.kind === "session_end");
137
+ const outcome = end?.payload?.outcome;
138
+ return outcome === "completed" || outcome === "partial" || outcome === "failed" || outcome === "aborted"
139
+ ? outcome
140
+ : "unknown";
141
+ }
142
+ function listSessionFiles() {
143
+ const sessionsDir = resolve(getSessionsDir());
144
+ if (!existsSync(sessionsDir))
145
+ return [];
146
+ const files = readdirSync(sessionsDir).filter((file) => file.endsWith(".jsonl") || file.endsWith(".json"));
147
+ const summaries = [];
148
+ for (const file of files) {
149
+ const absolutePath = join(sessionsDir, file);
150
+ const stats = statSync(absolutePath);
151
+ if (!stats.isFile())
152
+ continue;
153
+ try {
154
+ const payload = readSessionFile(absolutePath);
155
+ summaries.push({
156
+ key: file,
157
+ file,
158
+ absolute_path: absolutePath,
159
+ session_id: payload.session_id,
160
+ started_at: payload.started_at,
161
+ ended_at: payload.ended_at,
162
+ goal: payload.goal,
163
+ outcome: deriveOutcome(payload.events),
164
+ event_count: payload.events.length,
165
+ size_bytes: stats.size,
166
+ updated_at: stats.mtime.toISOString(),
167
+ });
168
+ }
169
+ catch {
170
+ // Skip malformed files from API listing to keep dashboard stable.
171
+ }
172
+ }
173
+ summaries.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
174
+ return summaries;
175
+ }
176
+ function contentType(pathname) {
177
+ const ext = extname(pathname).toLowerCase();
178
+ if (ext === ".html")
179
+ return "text/html; charset=utf-8";
180
+ if (ext === ".css")
181
+ return "text/css; charset=utf-8";
182
+ if (ext === ".js")
183
+ return "application/javascript; charset=utf-8";
184
+ if (ext === ".json")
185
+ return "application/json; charset=utf-8";
186
+ if (ext === ".svg")
187
+ return "image/svg+xml";
188
+ if (ext === ".png")
189
+ return "image/png";
190
+ if (ext === ".jpg" || ext === ".jpeg")
191
+ return "image/jpeg";
192
+ if (ext === ".ico")
193
+ return "image/x-icon";
194
+ return "application/octet-stream";
195
+ }
196
+ function serveMissingWebapp(res) {
197
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8" });
198
+ res.end([
199
+ "<!doctype html>",
200
+ "<html><body style='font-family:sans-serif;background:#020617;color:#e2e8f0;padding:24px'>",
201
+ "<h1>AL Dashboard Not Built</h1>",
202
+ "<p>Build the web app first:</p>",
203
+ "<pre>cd /path/to/AL/webapp && npm run build</pre>",
204
+ "</body></html>",
205
+ ].join(""));
206
+ }
207
+ function safeJoin(root, requestPath) {
208
+ const normalizedPath = normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
209
+ const joined = resolve(root, `.${normalizedPath}`);
210
+ if (joined !== root && !joined.startsWith(`${root}/`))
211
+ return null;
212
+ return joined;
213
+ }
214
+ async function handleApi(req, res, pathname) {
215
+ if (!pathname.startsWith("/api/"))
216
+ return false;
217
+ if (pathname === "/api/health") {
218
+ if (req.method !== "GET") {
219
+ json(res, 405, { error: "Method not allowed" });
220
+ return true;
221
+ }
222
+ json(res, 200, {
223
+ ok: true,
224
+ local_only: true,
225
+ sessions_dir: resolve(getSessionsDir()),
226
+ ts: new Date().toISOString(),
227
+ });
228
+ return true;
229
+ }
230
+ if (pathname === "/api/sessions") {
231
+ if (req.method !== "GET") {
232
+ json(res, 405, { error: "Method not allowed" });
233
+ return true;
234
+ }
235
+ json(res, 200, { sessions: listSessionFiles() });
236
+ return true;
237
+ }
238
+ if (pathname === "/api/gateway/begin") {
239
+ if (req.method !== "POST") {
240
+ json(res, 405, { error: "Method not allowed" });
241
+ return true;
242
+ }
243
+ try {
244
+ const body = await readJsonBody(req);
245
+ const parsed = parseToolResult(await handleGatewayBeginRun(body));
246
+ if (!parsed.ok) {
247
+ json(res, 400, { error: parsed.error });
248
+ return true;
249
+ }
250
+ json(res, 200, parsed.payload);
251
+ }
252
+ catch (error) {
253
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) });
254
+ }
255
+ return true;
256
+ }
257
+ if (pathname === "/api/gateway/act") {
258
+ if (req.method !== "POST") {
259
+ json(res, 405, { error: "Method not allowed" });
260
+ return true;
261
+ }
262
+ try {
263
+ const body = await readJsonBody(req);
264
+ const parsed = parseToolResult(await handleGatewayAct(body));
265
+ if (!parsed.ok) {
266
+ json(res, 400, { error: parsed.error });
267
+ return true;
268
+ }
269
+ json(res, 200, parsed.payload);
270
+ }
271
+ catch (error) {
272
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) });
273
+ }
274
+ return true;
275
+ }
276
+ if (pathname === "/api/gateway/end") {
277
+ if (req.method !== "POST") {
278
+ json(res, 405, { error: "Method not allowed" });
279
+ return true;
280
+ }
281
+ try {
282
+ const body = await readJsonBody(req);
283
+ const parsed = parseToolResult(await handleGatewayEndRun(body));
284
+ if (!parsed.ok) {
285
+ json(res, 400, { error: parsed.error });
286
+ return true;
287
+ }
288
+ json(res, 200, parsed.payload);
289
+ }
290
+ catch (error) {
291
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) });
292
+ }
293
+ return true;
294
+ }
295
+ if (pathname.startsWith("/api/sessions/")) {
296
+ if (req.method !== "GET") {
297
+ json(res, 405, { error: "Method not allowed" });
298
+ return true;
299
+ }
300
+ const key = decodeURIComponent(pathname.slice("/api/sessions/".length));
301
+ if (!key) {
302
+ json(res, 400, { error: "Missing session key." });
303
+ return true;
304
+ }
305
+ if (key.endsWith("/export")) {
306
+ const rawKey = key.slice(0, -"/export".length);
307
+ const summary = listSessionFiles().find((item) => item.key === rawKey || item.session_id === rawKey);
308
+ if (!summary) {
309
+ json(res, 404, { error: "Session not found." });
310
+ return true;
311
+ }
312
+ try {
313
+ const exported = exportSessionJson(summary.session_id);
314
+ res.writeHead(200, {
315
+ "content-type": "application/json; charset=utf-8",
316
+ "cache-control": "no-store",
317
+ "content-disposition": `attachment; filename="${summary.session_id}.session.json"`,
318
+ });
319
+ res.end(exported);
320
+ }
321
+ catch (error) {
322
+ json(res, 500, {
323
+ error: error instanceof Error ? error.message : "Failed to export session.",
324
+ });
325
+ }
326
+ return true;
327
+ }
328
+ const summary = listSessionFiles().find((item) => item.key === key || item.session_id === key);
329
+ if (!summary) {
330
+ json(res, 404, { error: "Session not found." });
331
+ return true;
332
+ }
333
+ try {
334
+ const payload = readSessionFile(summary.absolute_path);
335
+ json(res, 200, payload);
336
+ }
337
+ catch (error) {
338
+ json(res, 500, {
339
+ error: error instanceof Error ? error.message : "Failed to read session file.",
340
+ });
341
+ }
342
+ return true;
343
+ }
344
+ json(res, 404, { error: "Unknown API endpoint." });
345
+ return true;
346
+ }
347
+ function handleStatic(req, res) {
348
+ const url = new URL(req.url ?? "/", "http://localhost");
349
+ const pathname = url.pathname;
350
+ const webappDir = getDashboardWebappDir();
351
+ handleApi(req, res, pathname)
352
+ .then((handled) => {
353
+ if (handled)
354
+ return;
355
+ if (!existsSync(webappDir)) {
356
+ serveMissingWebapp(res);
357
+ return;
358
+ }
359
+ const relative = pathname === "/" ? "/index.html" : pathname;
360
+ const resolved = safeJoin(webappDir, relative);
361
+ if (!resolved) {
362
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
363
+ res.end("Bad request");
364
+ return;
365
+ }
366
+ const hasExt = extname(relative).length > 0;
367
+ const target = existsSync(resolved) ? resolved : hasExt ? null : join(webappDir, "index.html");
368
+ if (!target || !existsSync(target)) {
369
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
370
+ res.end("Not found");
371
+ return;
372
+ }
373
+ const body = readFileSync(target);
374
+ res.writeHead(200, {
375
+ "content-type": contentType(target),
376
+ "cache-control": target.endsWith("index.html") ? "no-cache" : "public, max-age=3600",
377
+ });
378
+ res.end(body);
379
+ })
380
+ .catch((error) => {
381
+ json(res, 500, {
382
+ error: error instanceof Error ? error.message : "Unhandled server error.",
383
+ });
384
+ });
385
+ }
386
+ export function startDashboardServer() {
387
+ if (!isDashboardEnabled()) {
388
+ process.stderr.write("AL dashboard disabled (AL_DASHBOARD_ENABLED=false)\n");
389
+ return null;
390
+ }
391
+ const host = getDashboardHost();
392
+ const port = getDashboardPort();
393
+ const server = createServer((req, res) => handleStatic(req, res));
394
+ server.listen(port, host, () => {
395
+ process.stderr.write(`AL dashboard listening at http://${host}:${port} (sessions: ${resolve(getSessionsDir())}, webapp: ${getDashboardWebappDir()})\n`);
396
+ });
397
+ server.on("error", (error) => {
398
+ process.stderr.write(`AL dashboard failed: ${error instanceof Error ? error.message : String(error)}\n`);
399
+ });
400
+ return { host, port };
401
+ }
@@ -0,0 +1,33 @@
1
+ export type ActorType = "agent" | "user" | "system" | "tool";
2
+ export interface CanonicalEvent {
3
+ id: string;
4
+ session_id: string;
5
+ seq: number;
6
+ ts: string;
7
+ kind: string;
8
+ actor: {
9
+ type: ActorType;
10
+ id?: string;
11
+ };
12
+ scope?: {
13
+ intent_id?: string;
14
+ file?: string;
15
+ module?: string;
16
+ };
17
+ payload: Record<string, unknown>;
18
+ derived?: boolean;
19
+ confidence?: number;
20
+ visibility?: "raw" | "review" | "debug";
21
+ schema_version: number;
22
+ }
23
+ export declare const EVENT_SCHEMA_VERSION = 1;
24
+ export interface SessionLogFile {
25
+ session_id: string;
26
+ goal: string;
27
+ user_prompt?: string;
28
+ repo?: string;
29
+ branch?: string;
30
+ started_at: string;
31
+ ended_at?: string;
32
+ events: CanonicalEvent[];
33
+ }