Last active
June 17, 2026 18:55
-
-
Save Keboo/08fc73fd0bec49e58da14d6a6ad7eb96 to your computer and use it in GitHub Desktop.
Copilot extension: chronicle-sessions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "chronicle", | |
| "version": 1 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { createServer } from "node:http"; | |
| import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; | |
| import { homedir } from "node:os"; | |
| import { join as joinPath } from "node:path"; | |
| import { readdir, readFile, stat, rm } from "node:fs/promises"; | |
| const servers = new Map(); | |
| const copilotHome = process.env.COPILOT_HOME || joinPath(homedir(), ".copilot"); | |
| const sessionStateRoot = joinPath(copilotHome, "session-state"); | |
| function escapeHtml(value) { | |
| return String(value ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function sendJson(res, statusCode, payload) { | |
| res.statusCode = statusCode; | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify(payload)); | |
| } | |
| async function readJsonBody(req) { | |
| const chunks = []; | |
| for await (const chunk of req) { | |
| chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | |
| } | |
| const raw = Buffer.concat(chunks).toString("utf8").trim(); | |
| if (!raw) { | |
| return {}; | |
| } | |
| return JSON.parse(raw); | |
| } | |
| function errorMessage(error) { | |
| return error instanceof Error ? error.message : String(error); | |
| } | |
| function parseYamlScalar(rawValue) { | |
| const value = rawValue.trim(); | |
| if (value === "") return ""; | |
| if (value === "true") return true; | |
| if (value === "false") return false; | |
| if (value === "null") return ""; | |
| if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { | |
| return value.slice(1, -1); | |
| } | |
| return value; | |
| } | |
| function parseWorkspaceYaml(content) { | |
| const data = {}; | |
| const lines = content.split(/\r?\n/); | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith("#")) continue; | |
| const index = line.indexOf(":"); | |
| if (index <= 0) continue; | |
| const key = line.slice(0, index).trim(); | |
| const rawValue = line.slice(index + 1); | |
| data[key] = parseYamlScalar(rawValue); | |
| } | |
| return data; | |
| } | |
| function coerceIso(value) { | |
| if (typeof value !== "string" || !value) return ""; | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) return ""; | |
| return date.toISOString(); | |
| } | |
| function isAlivePid(pid) { | |
| try { | |
| process.kill(pid, 0); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function hasLiveInUseLock(sessionDir) { | |
| try { | |
| const entries = await readdir(sessionDir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| if (!entry.isFile()) continue; | |
| const match = /^inuse\.(\d+)\.lock$/i.exec(entry.name); | |
| if (!match) continue; | |
| const pid = Number(match[1]); | |
| if (Number.isFinite(pid) && pid > 0 && isAlivePid(pid)) { | |
| return true; | |
| } | |
| } | |
| } catch { | |
| // Ignore listing failures and treat as no lock evidence. | |
| } | |
| return false; | |
| } | |
| async function getActiveSessionSet(session, sessionIds) { | |
| const active = new Set(); | |
| try { | |
| const chunkSize = 200; | |
| for (let index = 0; index < sessionIds.length; index += chunkSize) { | |
| const chunk = sessionIds.slice(index, index + chunkSize); | |
| const inUse = await session.rpc.sessions.checkInUse({ sessionIds: chunk }); | |
| for (const sessionId of inUse?.inUse ?? []) { | |
| active.add(sessionId); | |
| } | |
| } | |
| return active; | |
| } catch { | |
| // Fall back to lock-file scanning when RPC availability is limited. | |
| await Promise.all( | |
| sessionIds.map(async (sessionId) => { | |
| const sessionDir = joinPath(sessionStateRoot, sessionId); | |
| if (await hasLiveInUseLock(sessionDir)) { | |
| active.add(sessionId); | |
| } | |
| }), | |
| ); | |
| return active; | |
| } | |
| } | |
| async function listSessionsFromSessionState(session) { | |
| const currentSessionId = session.sessionId; | |
| const entries = await readdir(sessionStateRoot, { withFileTypes: true }); | |
| const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); | |
| const activeSessions = await getActiveSessionSet(session, candidates); | |
| const sessions = await Promise.all( | |
| candidates.map(async (sessionId) => { | |
| const sessionDir = joinPath(sessionStateRoot, sessionId); | |
| const yamlPath = joinPath(sessionDir, "workspace.yaml"); | |
| let metadata = {}; | |
| try { | |
| const yaml = await readFile(yamlPath, "utf8"); | |
| metadata = parseWorkspaceYaml(yaml); | |
| } catch { | |
| // Ignore missing/invalid workspace metadata for this session. | |
| } | |
| let folderTimes = null; | |
| try { | |
| folderTimes = await stat(sessionDir); | |
| } catch { | |
| // Ignore stat failures; we'll still return what we have. | |
| } | |
| const startTime = | |
| coerceIso(metadata.created_at) || | |
| coerceIso(metadata.start_time) || | |
| (folderTimes ? folderTimes.birthtime.toISOString() : ""); | |
| const modifiedTime = | |
| coerceIso(metadata.updated_at) || | |
| coerceIso(metadata.modified_time) || | |
| (folderTimes ? folderTimes.mtime.toISOString() : ""); | |
| return { | |
| sessionId, | |
| name: typeof metadata.name === "string" ? metadata.name : "", | |
| summary: typeof metadata.summary === "string" ? metadata.summary : "", | |
| startTime, | |
| modifiedTime, | |
| isRemote: Boolean(metadata.remote_steerable), | |
| isDetached: false, | |
| cwd: typeof metadata.cwd === "string" ? metadata.cwd : "", | |
| gitRoot: typeof metadata.git_root === "string" ? metadata.git_root : "", | |
| branch: typeof metadata.branch === "string" ? metadata.branch : "", | |
| repository: typeof metadata.repository === "string" ? metadata.repository : "", | |
| isCurrent: sessionId === currentSessionId, | |
| isActive: activeSessions.has(sessionId) || sessionId === currentSessionId, | |
| }; | |
| }), | |
| ); | |
| sessions.sort((a, b) => { | |
| const aTime = Date.parse(a.modifiedTime || a.startTime || "") || 0; | |
| const bTime = Date.parse(b.modifiedTime || b.startTime || "") || 0; | |
| return bTime - aTime; | |
| }); | |
| return { | |
| sessions, | |
| source: "session-state", | |
| }; | |
| } | |
| async function listChronicleSessions(session) { | |
| try { | |
| return await listSessionsFromSessionState(session); | |
| } catch (error) { | |
| throw new CanvasError("session_state_read_failed", `Unable to read local sessions: ${errorMessage(error)}`); | |
| } | |
| } | |
| async function countEntries(path) { | |
| try { | |
| const entries = await readdir(path, { withFileTypes: true }); | |
| return entries.length; | |
| } catch { | |
| return 0; | |
| } | |
| } | |
| async function readSize(path) { | |
| try { | |
| const fileStat = await stat(path); | |
| return fileStat.size; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async function getSessionDetailsFromSessionState(session, sessionId) { | |
| if (!sessionId || typeof sessionId !== "string") { | |
| throw new CanvasError("invalid_session_id", "sessionId is required."); | |
| } | |
| const sessionDir = joinPath(sessionStateRoot, sessionId); | |
| const dirExists = await stat(sessionDir).then( | |
| () => true, | |
| () => false, | |
| ); | |
| if (!dirExists) { | |
| throw new CanvasError("session_not_found", `Session not found: ${sessionId}`); | |
| } | |
| const yamlPath = joinPath(sessionDir, "workspace.yaml"); | |
| let metadata = {}; | |
| try { | |
| const yaml = await readFile(yamlPath, "utf8"); | |
| metadata = parseWorkspaceYaml(yaml); | |
| } catch { | |
| // Keep metadata empty when unavailable. | |
| } | |
| const folderStats = await stat(sessionDir); | |
| const checkpointsDir = joinPath(sessionDir, "checkpoints"); | |
| const filesDir = joinPath(sessionDir, "files"); | |
| const researchDir = joinPath(sessionDir, "research"); | |
| const eventsPath = joinPath(sessionDir, "events.jsonl"); | |
| const dbPath = joinPath(sessionDir, "session.db"); | |
| let active = false; | |
| try { | |
| const inUse = await session.rpc.sessions.checkInUse({ sessionIds: [sessionId] }); | |
| active = Array.isArray(inUse?.inUse) && inUse.inUse.includes(sessionId); | |
| } catch { | |
| active = await hasLiveInUseLock(sessionDir); | |
| } | |
| return { | |
| sessionId, | |
| name: typeof metadata.name === "string" ? metadata.name : "", | |
| summary: typeof metadata.summary === "string" ? metadata.summary : "", | |
| cwd: typeof metadata.cwd === "string" ? metadata.cwd : "", | |
| gitRoot: typeof metadata.git_root === "string" ? metadata.git_root : "", | |
| repository: typeof metadata.repository === "string" ? metadata.repository : "", | |
| branch: typeof metadata.branch === "string" ? metadata.branch : "", | |
| hostType: typeof metadata.host_type === "string" ? metadata.host_type : "", | |
| clientName: typeof metadata.client_name === "string" ? metadata.client_name : "", | |
| mcTaskId: typeof metadata.mc_task_id === "string" ? metadata.mc_task_id : "", | |
| mcSessionId: typeof metadata.mc_session_id === "string" ? metadata.mc_session_id : "", | |
| startTime: | |
| coerceIso(metadata.created_at) || | |
| coerceIso(metadata.start_time) || | |
| folderStats.birthtime.toISOString(), | |
| modifiedTime: | |
| coerceIso(metadata.updated_at) || | |
| coerceIso(metadata.modified_time) || | |
| folderStats.mtime.toISOString(), | |
| path: sessionDir, | |
| checkpointsCount: await countEntries(checkpointsDir), | |
| filesCount: await countEntries(filesDir), | |
| researchCount: await countEntries(researchDir), | |
| eventsBytes: await readSize(eventsPath), | |
| dbBytes: await readSize(dbPath), | |
| isCurrent: sessionId === session.sessionId, | |
| isActive: active || sessionId === session.sessionId, | |
| }; | |
| } | |
| function truncateText(value, maxLength = 12000) { | |
| if (typeof value !== "string") return ""; | |
| if (value.length <= maxLength) return value; | |
| return `${value.slice(0, maxLength)}\n…(truncated)…`; | |
| } | |
| function previewText(value, maxLength = 220) { | |
| if (typeof value !== "string") return ""; | |
| const singleLine = value.replace(/\s+/g, " ").trim(); | |
| if (singleLine.length <= maxLength) return singleLine; | |
| return `${singleLine.slice(0, maxLength)}…`; | |
| } | |
| function titleCaseFromType(type) { | |
| if (!type || typeof type !== "string") return "Event"; | |
| const normalized = type.replace(/[._-]+/g, " "); | |
| return normalized.replace(/\b\w/g, (char) => char.toUpperCase()); | |
| } | |
| function formatEventBody(type, data, fallback) { | |
| if (type === "user.message" || type === "assistant.message") { | |
| const message = data?.content; | |
| if (typeof message === "string" && message.trim()) { | |
| return message; | |
| } | |
| } | |
| if (type === "tool.execution_start") { | |
| const toolName = data?.toolName ? `Tool: ${data.toolName}` : "Tool execution started"; | |
| const args = data?.arguments ? `Arguments:\n${JSON.stringify(data.arguments, null, 2)}` : ""; | |
| return `${toolName}${args ? `\n${args}` : ""}`; | |
| } | |
| if (type === "tool.execution_complete") { | |
| const toolName = data?.toolName ? `Tool: ${data.toolName}` : "Tool execution completed"; | |
| const success = typeof data?.success === "boolean" ? `Success: ${data.success}` : ""; | |
| const result = | |
| data?.result !== undefined | |
| ? `Result:\n${typeof data.result === "string" ? data.result : JSON.stringify(data.result, null, 2)}` | |
| : ""; | |
| const error = data?.error ? `Error:\n${data.error}` : ""; | |
| return [toolName, success, result, error].filter(Boolean).join("\n"); | |
| } | |
| if (data !== undefined) { | |
| try { | |
| return JSON.stringify(data, null, 2); | |
| } catch { | |
| return String(data); | |
| } | |
| } | |
| return fallback; | |
| } | |
| function formatDebugLogName(type, data) { | |
| if (type === "session.info") { | |
| return data?.infoType ? `${titleCaseFromType(String(data.infoType))} Info` : "Session Info"; | |
| } | |
| if (type === "user.message") return "User Message"; | |
| if (type === "assistant.message") return data?.model ? String(data.model) : "Agent Response"; | |
| if (type === "tool.execution_start") return data?.toolName ? String(data.toolName) : "Tool Start"; | |
| if (type === "tool.execution_complete") return data?.toolName ? String(data.toolName) : "Tool Complete"; | |
| if (type === "hook.start" || type === "hook.end") { | |
| return data?.hookType ? `${String(data.hookType)} Hook` : titleCaseFromType(type); | |
| } | |
| if (type === "permission.requested") return "Permission Requested"; | |
| if (type === "permission.completed") return "Permission Completed"; | |
| if (type === "external_tool.requested") return "External Tool Requested"; | |
| if (type === "external_tool.completed") return "External Tool Completed"; | |
| if (type === "skill.invoked") return "Skill Invoked"; | |
| if (type === "session.start") return "Session Start"; | |
| if (type === "session.resume") return "Session Resume"; | |
| if (type === "session.shutdown") return "Session Shutdown"; | |
| if (type === "session.error") return "Session Error"; | |
| return titleCaseFromType(type); | |
| } | |
| function formatDebugLogDetails(type, data, eventBody) { | |
| if (type === "session.start") { | |
| const repository = data?.context?.repository ? String(data.context.repository) : ""; | |
| const branch = data?.context?.branch ? String(data.context.branch) : ""; | |
| const cwd = data?.context?.cwd ? String(data.context.cwd) : ""; | |
| return [repository, branch, cwd].filter(Boolean).join(" • "); | |
| } | |
| if (type === "session.resume") return previewText(data?.message ?? data?.reason ?? ""); | |
| if (type === "session.shutdown") return previewText(data?.reason ?? ""); | |
| if (type === "session.info") return previewText(data?.message ?? ""); | |
| if (type === "user.message") return previewText(data?.message ?? ""); | |
| if (type === "assistant.message") return previewText(data?.content ?? ""); | |
| if (type === "tool.execution_start") { | |
| const args = | |
| data?.arguments && typeof data.arguments === "object" ? previewText(JSON.stringify(data.arguments)) : ""; | |
| return args ? `started ${args}` : "started"; | |
| } | |
| if (type === "tool.execution_complete") { | |
| const parts = []; | |
| if (typeof data?.success === "boolean") parts.push(data.success ? "success" : "failed"); | |
| if (data?.error) parts.push(previewText(String(data.error))); | |
| if (data?.result !== undefined) { | |
| const resultText = | |
| typeof data.result === "string" ? previewText(data.result) : previewText(JSON.stringify(data.result)); | |
| if (resultText) parts.push(resultText); | |
| } | |
| return parts.join(" • "); | |
| } | |
| if (type === "hook.start") return "start"; | |
| if (type === "hook.end") return "end"; | |
| if (type === "external_tool.requested" || type === "external_tool.completed") { | |
| const toolName = typeof data?.toolName === "string" ? data.toolName : ""; | |
| const status = type.endsWith("completed") ? "completed" : "requested"; | |
| return [toolName, status].filter(Boolean).join(" • "); | |
| } | |
| if (type === "permission.requested" || type === "permission.completed") { | |
| const status = type.endsWith("completed") ? "granted" : "requested"; | |
| const target = typeof data?.resource === "string" ? data.resource : typeof data?.toolName === "string" ? data.toolName : ""; | |
| return [target, status].filter(Boolean).join(" • "); | |
| } | |
| if (type === "skill.invoked") { | |
| if (typeof data?.name === "string" && data.name) return data.name; | |
| if (typeof data?.skill === "string" && data.skill) return data.skill; | |
| } | |
| if (type === "session.error") return previewText(data?.message ?? data?.error ?? ""); | |
| return previewText(eventBody ?? ""); | |
| } | |
| async function getSessionContentFromSessionState(session, sessionId) { | |
| const details = await getSessionDetailsFromSessionState(session, sessionId); | |
| const eventsPath = joinPath(sessionStateRoot, sessionId, "events.jsonl"); | |
| let eventsRaw = ""; | |
| try { | |
| eventsRaw = await readFile(eventsPath, "utf8"); | |
| } catch { | |
| eventsRaw = ""; | |
| } | |
| const lines = eventsRaw | |
| .split(/\r?\n/) | |
| .map((line) => line.trim()) | |
| .filter(Boolean); | |
| const maxEvents = 5000; | |
| const start = Math.max(0, lines.length - maxEvents); | |
| const selectedLines = lines.slice(start); | |
| const events = selectedLines.map((line, index) => { | |
| let parsed = null; | |
| try { | |
| parsed = JSON.parse(line); | |
| } catch { | |
| parsed = null; | |
| } | |
| const type = typeof parsed?.type === "string" ? parsed.type : "event"; | |
| const data = parsed && typeof parsed === "object" ? parsed.data : undefined; | |
| const timestampCandidate = | |
| parsed?.timestamp ?? parsed?.time ?? parsed?.createdAt ?? data?.timestamp ?? data?.time; | |
| const timestamp = typeof timestampCandidate === "string" ? timestampCandidate : ""; | |
| const fallbackBody = parsed ? JSON.stringify(parsed, null, 2) : line; | |
| const body = truncateText( | |
| formatEventBody(type, data, fallbackBody), | |
| ); | |
| const rawEvent = truncateText(fallbackBody, 12000); | |
| return { | |
| sequence: start + index + 1, | |
| type, | |
| timestamp, | |
| body, | |
| rawEvent, | |
| data, | |
| }; | |
| }); | |
| const debugLogs = events.map((event) => ({ | |
| sequence: event.sequence, | |
| created: event.timestamp, | |
| name: formatDebugLogName(event.type, event.data), | |
| details: truncateText(formatDebugLogDetails(event.type, event.data, event.body), 1200), | |
| type: event.type, | |
| rawEvent: event.rawEvent, | |
| })); | |
| return { | |
| session: details, | |
| events, | |
| debugLogs, | |
| totalEventLines: lines.length, | |
| truncated: lines.length > maxEvents, | |
| }; | |
| } | |
| async function deleteSessionViaChronicle(session, sessionId) { | |
| if (!sessionId || typeof sessionId !== "string") { | |
| throw new CanvasError("invalid_session_id", "sessionId is required."); | |
| } | |
| if (sessionId === session.sessionId) { | |
| throw new CanvasError("cannot_delete_current_session", "Refusing to delete the current active session."); | |
| } | |
| const sessionDir = joinPath(sessionStateRoot, sessionId); | |
| const existsBeforeDelete = await stat(sessionDir).then( | |
| () => true, | |
| () => false, | |
| ); | |
| if (!existsBeforeDelete) { | |
| throw new CanvasError("session_not_found", `Session not found: ${sessionId}`); | |
| } | |
| try { | |
| const inUseResult = await session.rpc.sessions.checkInUse({ sessionIds: [sessionId] }); | |
| if (Array.isArray(inUseResult.inUse) && inUseResult.inUse.includes(sessionId)) { | |
| throw new CanvasError( | |
| "session_in_use", | |
| "That session is currently in use by another running process and cannot be deleted yet.", | |
| ); | |
| } | |
| } catch (error) { | |
| if (error instanceof CanvasError) throw error; | |
| // If in-use detection is unavailable, continue to deletion attempt. | |
| } | |
| try { | |
| const result = await session.rpc.sessions.bulkDelete({ sessionIds: [sessionId] }); | |
| const freed = result?.freedBytes ?? {}; | |
| if (Object.prototype.hasOwnProperty.call(freed, sessionId)) { | |
| return { deleted: true }; | |
| } | |
| } catch (error) { | |
| if (error instanceof CanvasError) throw error; | |
| // Fall through and verify by filesystem check below. | |
| } | |
| const existsAfterDelete = await stat(sessionDir).then( | |
| () => true, | |
| () => false, | |
| ); | |
| if (!existsAfterDelete) { | |
| return { deleted: true }; | |
| } | |
| if (await hasLiveInUseLock(sessionDir)) { | |
| throw new CanvasError( | |
| "session_in_use", | |
| "That session is currently in use by another running process and cannot be deleted yet.", | |
| ); | |
| } | |
| try { | |
| await rm(sessionDir, { recursive: true, force: true }); | |
| const stillExists = await stat(sessionDir).then( | |
| () => true, | |
| () => false, | |
| ); | |
| if (!stillExists) { | |
| return { deleted: true }; | |
| } | |
| } catch { | |
| // Fall through to user-facing error below. | |
| } | |
| throw new CanvasError("chronicle_delete_failed", "Unable to delete that session."); | |
| } | |
| function renderHtml(instanceId) { | |
| return `<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Copilot Session Manager</title> | |
| <style> | |
| :root { | |
| color-scheme: light dark; | |
| } | |
| body { | |
| margin: 0; | |
| background: var(--background-color-default, #fff); | |
| color: var(--text-color-default, #1f2328); | |
| font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); | |
| font-size: var(--text-body-medium, 14px); | |
| line-height: var(--leading-body-medium, 20px); | |
| } | |
| .wrap { | |
| padding: 12px; | |
| display: grid; | |
| gap: 12px; | |
| } | |
| .toolbar { | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| input[type="search"] { | |
| min-width: 280px; | |
| max-width: 520px; | |
| width: 100%; | |
| flex: 1 1 280px; | |
| border: 1px solid var(--border-color-default, #d1d9e0); | |
| border-radius: 6px; | |
| padding: 6px 10px; | |
| font: inherit; | |
| background: var(--background-color-default, #fff); | |
| color: var(--text-color-default, #1f2328); | |
| } | |
| button { | |
| border: 1px solid var(--border-color-default, #d1d9e0); | |
| border-radius: 6px; | |
| padding: 6px 10px; | |
| background: var(--background-color-default, #fff); | |
| color: var(--text-color-default, #1f2328); | |
| font: inherit; | |
| cursor: pointer; | |
| } | |
| button:disabled { | |
| cursor: default; | |
| opacity: 0.6; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| th, td { | |
| border-bottom: 1px solid var(--border-color-default, #d1d9e0); | |
| text-align: left; | |
| vertical-align: top; | |
| padding: 8px 6px; | |
| } | |
| th { | |
| color: var(--text-color-muted, #59636e); | |
| font-weight: var(--font-weight-semibold, 600); | |
| } | |
| code { | |
| font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); | |
| font-size: var(--text-code-inline, 12px); | |
| } | |
| .meta { | |
| color: var(--text-color-muted, #59636e); | |
| } | |
| .error { | |
| color: var(--true-color-red, #d1242f); | |
| } | |
| .empty { | |
| padding: 12px 0; | |
| color: var(--text-color-muted, #59636e); | |
| } | |
| .name { | |
| font-weight: var(--font-weight-semibold, 600); | |
| } | |
| .session-open { | |
| all: unset; | |
| cursor: pointer; | |
| color: var(--text-color-default, #1f2328); | |
| } | |
| .session-open:hover { | |
| text-decoration: underline; | |
| } | |
| .badges { | |
| display: flex; | |
| gap: 6px; | |
| margin-top: 4px; | |
| flex-wrap: wrap; | |
| } | |
| .badge { | |
| border: 1px solid var(--border-color-default, #d1d9e0); | |
| border-radius: 999px; | |
| padding: 1px 8px; | |
| font-size: 12px; | |
| line-height: 16px; | |
| color: var(--text-color-muted, #59636e); | |
| } | |
| .badge-active { | |
| color: var(--true-color-blue, #0969da); | |
| border-color: var(--true-color-blue-muted, #54aeff66); | |
| } | |
| .badge-current { | |
| color: var(--true-color-red, #d1242f); | |
| border-color: var(--true-color-red-muted, #ff818266); | |
| } | |
| tr.active-session > td { | |
| background: color-mix(in srgb, var(--true-color-blue-muted, #54aeff66) 14%, transparent); | |
| } | |
| .actions { | |
| display: inline-flex; | |
| gap: 4px; | |
| flex-wrap: nowrap; | |
| white-space: nowrap; | |
| align-items: center; | |
| } | |
| .icon-button { | |
| width: 30px; | |
| height: 30px; | |
| padding: 0; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| line-height: 1; | |
| } | |
| .icon-delete { | |
| color: var(--true-color-red, #d1242f); | |
| } | |
| .details-panel { | |
| border: 1px solid var(--border-color-default, #d1d9e0); | |
| border-radius: 8px; | |
| padding: 10px 12px; | |
| background: color-mix(in srgb, var(--background-color-default, #fff) 92%, var(--true-color-blue-muted, #54aeff66) 8%); | |
| } | |
| .details-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| } | |
| .details-title { | |
| font-weight: var(--font-weight-semibold, 600); | |
| } | |
| .details-close { | |
| width: 24px; | |
| height: 24px; | |
| padding: 0; | |
| line-height: 1; | |
| } | |
| .details-grid { | |
| display: grid; | |
| grid-template-columns: 160px 1fr; | |
| gap: 4px 10px; | |
| } | |
| .details-meta-grid { | |
| margin-top: 8px; | |
| } | |
| .details-key { | |
| color: var(--text-color-muted, #59636e); | |
| } | |
| .details-value { | |
| word-break: break-word; | |
| } | |
| .details-code { | |
| font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); | |
| font-size: 12px; | |
| } | |
| .session-view { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .session-tabs { | |
| display: inline-flex; | |
| gap: 6px; | |
| } | |
| .tab-button { | |
| padding: 4px 10px; | |
| } | |
| .tab-button.active { | |
| border-color: var(--true-color-blue, #0969da); | |
| color: var(--true-color-blue, #0969da); | |
| background: color-mix(in srgb, var(--true-color-blue-muted, #54aeff66) 22%, transparent); | |
| } | |
| .session-events { | |
| display: grid; | |
| gap: 8px; | |
| max-height: 68vh; | |
| overflow: auto; | |
| padding-right: 4px; | |
| } | |
| .debug-tools { | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .debug-filter { | |
| min-width: 220px; | |
| max-width: 520px; | |
| width: 100%; | |
| flex: 1 1 220px; | |
| } | |
| .debug-list-wrap { | |
| max-height: 44vh; | |
| overflow: auto; | |
| } | |
| .debug-row { | |
| cursor: pointer; | |
| } | |
| .debug-row.selected > td { | |
| background: color-mix(in srgb, var(--true-color-blue-muted, #54aeff66) 25%, transparent); | |
| } | |
| .event-card { | |
| border: 1px solid var(--border-color-default, #d1d9e0); | |
| border-radius: 8px; | |
| padding: 8px; | |
| background: color-mix(in srgb, var(--background-color-default, #fff) 95%, var(--true-color-blue-muted, #54aeff66) 5%); | |
| } | |
| .event-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| margin-bottom: 6px; | |
| } | |
| .event-type { | |
| border: 1px solid var(--border-color-default, #d1d9e0); | |
| border-radius: 999px; | |
| padding: 1px 8px; | |
| font-size: 12px; | |
| line-height: 16px; | |
| color: var(--text-color-muted, #59636e); | |
| } | |
| .event-content { | |
| margin: 0; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); | |
| font-size: 12px; | |
| line-height: 18px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <div class="toolbar" id="toolbar"> | |
| <input id="search" type="search" placeholder="Search old sessions by name, summary, path, branch, id..." /> | |
| <button id="refresh" type="button">Refresh</button> | |
| </div> | |
| <div id="status" class="meta"></div> | |
| <div id="error" class="error"></div> | |
| <div id="details"></div> | |
| <div id="table"></div> | |
| </div> | |
| <script> | |
| const searchInput = document.getElementById("search"); | |
| const refreshButton = document.getElementById("refresh"); | |
| const toolbarEl = document.getElementById("toolbar"); | |
| const statusEl = document.getElementById("status"); | |
| const errorEl = document.getElementById("error"); | |
| const detailsEl = document.getElementById("details"); | |
| const tableEl = document.getElementById("table"); | |
| const state = { | |
| sessions: [], | |
| source: "", | |
| loading: false, | |
| retryTimer: null, | |
| viewMode: "list", | |
| selectedSessionId: null, | |
| sessionContentById: {}, | |
| sessionViewLoading: false, | |
| sessionViewTab: "timeline", | |
| timelineFilter: "", | |
| timelineFilterDraft: "", | |
| timelineFilterDebounceTimer: null, | |
| debugFilter: "", | |
| debugFilterDraft: "", | |
| debugFilterDebounceTimer: null, | |
| selectedDebugSequence: null, | |
| }; | |
| function escapeHtml(value) { | |
| return String(value ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function formatTime(value) { | |
| if (!value) return ""; | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) return value; | |
| return date.toLocaleString(); | |
| } | |
| function formatBytes(value) { | |
| if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return ""; | |
| if (value < 1024) return value + " B"; | |
| if (value < 1024 * 1024) return (value / 1024).toFixed(1) + " KB"; | |
| return (value / (1024 * 1024)).toFixed(1) + " MB"; | |
| } | |
| function searchSnippet(value, maxLength = 800) { | |
| if (typeof value !== "string") return ""; | |
| if (value.length <= maxLength) return value; | |
| return value.slice(0, maxLength); | |
| } | |
| function getFilteredSessions() { | |
| const q = searchInput.value.trim().toLowerCase(); | |
| if (!q) return state.sessions; | |
| return state.sessions.filter((s) => { | |
| const haystack = [ | |
| s.sessionId, | |
| s.name, | |
| s.summary, | |
| s.cwd, | |
| s.gitRoot, | |
| s.branch, | |
| s.repository, | |
| s.modifiedTime, | |
| s.startTime, | |
| ] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| return haystack.includes(q); | |
| }); | |
| } | |
| function setLoading(loading) { | |
| state.loading = loading; | |
| refreshButton.disabled = loading; | |
| statusEl.textContent = loading ? "Loading sessions..." : statusEl.textContent; | |
| } | |
| function clearDebugFilterDebounce() { | |
| if (state.debugFilterDebounceTimer != null) { | |
| window.clearTimeout(state.debugFilterDebounceTimer); | |
| state.debugFilterDebounceTimer = null; | |
| } | |
| } | |
| function clearTimelineFilterDebounce() { | |
| if (state.timelineFilterDebounceTimer != null) { | |
| window.clearTimeout(state.timelineFilterDebounceTimer); | |
| state.timelineFilterDebounceTimer = null; | |
| } | |
| } | |
| function restoreFocusedFilterInput(focusState) { | |
| if (!focusState || !focusState.id) return; | |
| const input = tableEl.querySelector("#" + focusState.id); | |
| if (!input) return; | |
| input.focus(); | |
| if (typeof focusState.start === "number" && typeof focusState.end === "number") { | |
| const start = Math.max(0, Math.min(focusState.start, input.value.length)); | |
| const end = Math.max(0, Math.min(focusState.end, input.value.length)); | |
| try { | |
| input.setSelectionRange(start, end); | |
| } catch {} | |
| } | |
| } | |
| function renderSessionView() { | |
| const sessionId = state.selectedSessionId; | |
| if (!sessionId) { | |
| state.viewMode = "list"; | |
| render(); | |
| return; | |
| } | |
| if (state.sessionViewLoading && !state.sessionContentById[sessionId]) { | |
| tableEl.innerHTML = '<div class="details-panel">Loading session content...</div>'; | |
| return; | |
| } | |
| const content = state.sessionContentById[sessionId]; | |
| if (!content || !content.session) { | |
| tableEl.innerHTML = '<div class="details-panel">Session content unavailable.</div>'; | |
| return; | |
| } | |
| const activeElement = document.activeElement; | |
| const focusedFilterInput = | |
| activeElement && (activeElement.id === "timeline-filter" || activeElement.id === "debug-filter") | |
| ? { | |
| id: activeElement.id, | |
| start: activeElement.selectionStart, | |
| end: activeElement.selectionEnd, | |
| } | |
| : null; | |
| const sessionDetails = content.session; | |
| const displayName = sessionDetails.name || sessionDetails.summary || "(unnamed session)"; | |
| const summary = sessionDetails.summary ? '<div class="meta">' + escapeHtml(sessionDetails.summary) + "</div>" : ""; | |
| const context = [sessionDetails.repository, sessionDetails.branch, sessionDetails.cwd].filter(Boolean).join(" • "); | |
| const events = Array.isArray(content.events) ? content.events : []; | |
| const timelineQuery = (state.timelineFilter || "").trim().toLowerCase(); | |
| const filteredEvents = !timelineQuery | |
| ? events | |
| : events.filter((event) => { | |
| const haystack = [event.type, searchSnippet(event.body, 1200), event.timestamp, String(event.sequence || "")] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| return haystack.includes(timelineQuery); | |
| }); | |
| const debugLogs = Array.isArray(content.debugLogs) ? content.debugLogs : []; | |
| const debugQuery = (state.debugFilter || "").trim().toLowerCase(); | |
| const filteredDebugLogs = !debugQuery | |
| ? debugLogs | |
| : debugLogs.filter((log) => { | |
| const haystack = [log.name, searchSnippet(log.details, 800), log.type] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| return haystack.includes(debugQuery); | |
| }); | |
| if (state.sessionViewTab === "debug" && !filteredDebugLogs.some((log) => log.sequence === state.selectedDebugSequence)) { | |
| state.selectedDebugSequence = filteredDebugLogs.length ? filteredDebugLogs[0].sequence : null; | |
| } | |
| const selectedDebugLog = | |
| state.sessionViewTab === "debug" | |
| ? filteredDebugLogs.find((log) => log.sequence === state.selectedDebugSequence) || null | |
| : null; | |
| const eventsMarkup = filteredEvents.length | |
| ? filteredEvents | |
| .map((event) => { | |
| return \` | |
| <article class="event-card"> | |
| <div class="event-head"> | |
| <span class="event-type">\${escapeHtml(event.type || "event")}</span> | |
| <span class="meta">\${escapeHtml(event.timestamp ? formatTime(event.timestamp) : "#" + String(event.sequence || ""))}</span> | |
| </div> | |
| <pre class="event-content">\${escapeHtml(event.body || "")}</pre> | |
| </article> | |
| \`; | |
| }) | |
| .join("") | |
| : '<div class="empty">No timeline events match the current filter.</div>'; | |
| const timelineFilterValue = state.timelineFilterDraft != null ? state.timelineFilterDraft : state.timelineFilter; | |
| const timelineViewMarkup = \` | |
| <div class="debug-tools"> | |
| <input | |
| id="timeline-filter" | |
| class="debug-filter" | |
| type="search" | |
| placeholder="Filter timeline events (type, content, timestamp...)" | |
| value="\${escapeHtml(timelineFilterValue || "")}" | |
| /> | |
| <div class="meta">Showing \${String(filteredEvents.length)} of \${String(events.length)} events</div> | |
| </div> | |
| <div class="session-events">\${eventsMarkup}</div> | |
| \`; | |
| const debugRowsMarkup = filteredDebugLogs.length | |
| ? filteredDebugLogs | |
| .map((log) => { | |
| const isSelected = log.sequence === state.selectedDebugSequence; | |
| return \` | |
| <tr class="debug-row \${isSelected ? "selected" : ""}" data-debug-seq="\${String(log.sequence)}"> | |
| <td>\${escapeHtml(log.created ? formatTime(log.created) : "")}</td> | |
| <td>\${escapeHtml(log.name || "")}</td> | |
| <td>\${escapeHtml(log.details || "")}</td> | |
| </tr> | |
| \`; | |
| }) | |
| .join("") | |
| : '<tr><td colspan="3" class="meta">No debug logs match the current filter.</td></tr>'; | |
| const debugFilterValue = state.debugFilterDraft != null ? state.debugFilterDraft : state.debugFilter; | |
| const debugViewMarkup = \` | |
| <div class="debug-tools"> | |
| <input | |
| id="debug-filter" | |
| class="debug-filter" | |
| type="search" | |
| placeholder="Filter debug logs (name, details, type...)" | |
| value="\${escapeHtml(debugFilterValue || "")}" | |
| /> | |
| <div class="meta">Showing \${String(filteredDebugLogs.length)} of \${String(debugLogs.length)} logs</div> | |
| </div> | |
| <div class="debug-list-wrap"> | |
| <table class="debug-table"> | |
| <thead> | |
| <tr> | |
| <th>Created</th> | |
| <th>Name</th> | |
| <th>Details</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| \${debugRowsMarkup} | |
| </tbody> | |
| </table> | |
| </div> | |
| \${selectedDebugLog ? \` | |
| <div class="details-panel"> | |
| <div class="details-title">Debug event details</div> | |
| <div class="meta" style="margin-bottom: 6px;">\${escapeHtml(selectedDebugLog.type || "")}</div> | |
| <pre class="event-content">\${escapeHtml(selectedDebugLog.rawEvent || "")}</pre> | |
| </div> | |
| \` : ""} | |
| \`; | |
| statusEl.textContent = "Viewing session " + (sessionDetails.sessionId || sessionId); | |
| tableEl.innerHTML = \` | |
| <div class="session-view"> | |
| <div class="details-header"> | |
| <button type="button" class="details-close" data-back-to-list aria-label="Back to sessions" title="Back">←</button> | |
| <div class="details-title">Session Viewer</div> | |
| <div class="session-tabs"> | |
| <button type="button" class="tab-button \${state.sessionViewTab === "timeline" ? "active" : ""}" data-session-tab="timeline">Timeline</button> | |
| <button type="button" class="tab-button \${state.sessionViewTab === "debug" ? "active" : ""}" data-session-tab="debug">Debug Logs</button> | |
| </div> | |
| </div> | |
| <div class="details-panel"> | |
| <div class="name">\${escapeHtml(displayName)}</div> | |
| \${summary} | |
| <div class="meta">\${escapeHtml(context)}</div> | |
| <div class="details-grid details-meta-grid"> | |
| <div class="details-key">Session ID</div><div class="details-value details-code">\${escapeHtml(sessionDetails.sessionId || "")}</div> | |
| <div class="details-key">Modified</div><div class="details-value">\${escapeHtml(formatTime(sessionDetails.modifiedTime || sessionDetails.startTime))}</div> | |
| <div class="details-key">Path</div><div class="details-value details-code">\${escapeHtml(sessionDetails.path || "")}</div> | |
| <div class="details-key">Events</div><div class="details-value">\${escapeHtml(String(content.totalEventLines ?? events.length))}\${content.truncated ? " (showing recent 5000)" : ""}</div> | |
| </div> | |
| </div> | |
| \${state.sessionViewTab === "debug" ? debugViewMarkup : timelineViewMarkup} | |
| </div> | |
| \`; | |
| const backButton = tableEl.querySelector("button[data-back-to-list]"); | |
| if (backButton) { | |
| backButton.addEventListener("click", () => { | |
| clearDebugFilterDebounce(); | |
| clearTimelineFilterDebounce(); | |
| state.viewMode = "list"; | |
| state.selectedSessionId = null; | |
| state.sessionViewTab = "timeline"; | |
| state.timelineFilter = ""; | |
| state.timelineFilterDraft = ""; | |
| state.debugFilter = ""; | |
| state.debugFilterDraft = ""; | |
| state.selectedDebugSequence = null; | |
| render(); | |
| }); | |
| } | |
| for (const button of tableEl.querySelectorAll("button[data-session-tab]")) { | |
| button.addEventListener("click", () => { | |
| clearDebugFilterDebounce(); | |
| clearTimelineFilterDebounce(); | |
| const tab = button.getAttribute("data-session-tab"); | |
| if (tab !== "timeline" && tab !== "debug") return; | |
| state.sessionViewTab = tab; | |
| if (tab === "debug" && state.selectedDebugSequence == null && filteredDebugLogs.length) { | |
| state.selectedDebugSequence = filteredDebugLogs[0].sequence; | |
| } | |
| renderSessionView(); | |
| }); | |
| } | |
| const debugFilterInput = tableEl.querySelector("#debug-filter"); | |
| if (debugFilterInput) { | |
| debugFilterInput.addEventListener("input", () => { | |
| state.debugFilterDraft = debugFilterInput.value || ""; | |
| clearDebugFilterDebounce(); | |
| state.debugFilterDebounceTimer = window.setTimeout(() => { | |
| state.debugFilterDebounceTimer = null; | |
| state.debugFilter = state.debugFilterDraft || ""; | |
| renderSessionView(); | |
| }, 350); | |
| }); | |
| } | |
| const timelineFilterInput = tableEl.querySelector("#timeline-filter"); | |
| if (timelineFilterInput) { | |
| timelineFilterInput.addEventListener("input", () => { | |
| state.timelineFilterDraft = timelineFilterInput.value || ""; | |
| clearTimelineFilterDebounce(); | |
| state.timelineFilterDebounceTimer = window.setTimeout(() => { | |
| state.timelineFilterDebounceTimer = null; | |
| state.timelineFilter = state.timelineFilterDraft || ""; | |
| renderSessionView(); | |
| }, 350); | |
| }); | |
| } | |
| for (const row of tableEl.querySelectorAll("tr[data-debug-seq]")) { | |
| row.addEventListener("click", () => { | |
| const rawSeq = row.getAttribute("data-debug-seq"); | |
| const seq = rawSeq ? Number.parseInt(rawSeq, 10) : NaN; | |
| if (!Number.isFinite(seq)) return; | |
| state.selectedDebugSequence = seq; | |
| renderSessionView(); | |
| }); | |
| } | |
| restoreFocusedFilterInput(focusedFilterInput); | |
| } | |
| async function openSessionViewer(sessionId) { | |
| if (!sessionId) return; | |
| clearDebugFilterDebounce(); | |
| clearTimelineFilterDebounce(); | |
| state.viewMode = "session"; | |
| state.selectedSessionId = sessionId; | |
| state.sessionViewLoading = true; | |
| state.sessionViewTab = "timeline"; | |
| state.timelineFilter = ""; | |
| state.timelineFilterDraft = ""; | |
| state.debugFilter = ""; | |
| state.debugFilterDraft = ""; | |
| state.selectedDebugSequence = null; | |
| render(); | |
| try { | |
| const response = await fetch("/api/session/content?sessionId=" + encodeURIComponent(sessionId)); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error((data && data.error) || ("HTTP " + response.status)); | |
| } | |
| state.sessionContentById[sessionId] = data || null; | |
| } catch (error) { | |
| errorEl.textContent = error instanceof Error ? error.message : "Unable to load session content."; | |
| } finally { | |
| state.sessionViewLoading = false; | |
| render(); | |
| } | |
| } | |
| function scheduleRetry() { | |
| if (state.retryTimer) return; | |
| state.retryTimer = window.setTimeout(() => { | |
| state.retryTimer = null; | |
| loadSessions(); | |
| }, 1200); | |
| } | |
| function render() { | |
| detailsEl.innerHTML = ""; | |
| if (state.viewMode === "session" && state.selectedSessionId) { | |
| if (toolbarEl) toolbarEl.style.display = "none"; | |
| renderSessionView(); | |
| return; | |
| } | |
| if (toolbarEl) toolbarEl.style.display = ""; | |
| const rows = getFilteredSessions(); | |
| const filteredCount = rows.length; | |
| const totalCount = state.sessions.length; | |
| statusEl.textContent = | |
| "Showing " + filteredCount + " of " + totalCount + " sessions" + (state.source ? " via " + state.source : ""); | |
| if (rows.length === 0) { | |
| tableEl.innerHTML = '<div class="empty">No sessions match your search.</div>'; | |
| return; | |
| } | |
| tableEl.innerHTML = \` | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Name / Summary</th> | |
| <th>Modified</th> | |
| <th>Context</th> | |
| <th>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| \${rows | |
| .map((s) => { | |
| const displayName = s.name || s.summary || "(unnamed session)"; | |
| const summary = s.summary && s.summary !== s.name ? \`<div class="meta">\${escapeHtml(s.summary)}</div>\` : ""; | |
| const badges = [ | |
| s.isCurrent ? '<span class="badge badge-current">Current</span>' : "", | |
| s.isActive ? '<span class="badge badge-active">Active</span>' : "", | |
| ].filter(Boolean).join(""); | |
| const badgeMarkup = badges ? \`<div class="badges">\${badges}</div>\` : ""; | |
| const context = [s.repository, s.branch, s.cwd].filter(Boolean).join(" • "); | |
| const remote = s.isRemote ? " (remote)" : ""; | |
| return \` | |
| <tr class="\${s.isActive ? "active-session" : ""}"> | |
| <td> | |
| <button | |
| type="button" | |
| class="name session-open" | |
| data-open-id="\${escapeHtml(s.sessionId)}" | |
| title="Open session viewer" | |
| >\${escapeHtml(displayName)}\${remote}</button> | |
| \${summary} | |
| \${badgeMarkup} | |
| </td> | |
| <td>\${escapeHtml(formatTime(s.modifiedTime || s.startTime))}</td> | |
| <td>\${escapeHtml(context)}</td> | |
| <td> | |
| <div class="actions"> | |
| <button | |
| type="button" | |
| class="icon-button" | |
| data-copy-id="\${escapeHtml(s.sessionId)}" | |
| aria-label="Copy session ID" | |
| title="Copy session ID" | |
| >⧉</button> | |
| <button | |
| type="button" | |
| class="icon-button icon-delete" | |
| data-delete-id="\${escapeHtml(s.sessionId)}" | |
| aria-label="Delete session" | |
| title="Delete session" | |
| >🗑</button> | |
| </div> | |
| </td> | |
| </tr> | |
| \`; | |
| }) | |
| .join("")} | |
| </tbody> | |
| </table> | |
| \`; | |
| for (const button of tableEl.querySelectorAll("button[data-copy-id]")) { | |
| button.addEventListener("click", async () => { | |
| const sessionId = button.getAttribute("data-copy-id"); | |
| if (!sessionId) return; | |
| await copySessionId(sessionId, button); | |
| }); | |
| } | |
| for (const button of tableEl.querySelectorAll("button[data-open-id]")) { | |
| button.addEventListener("click", async () => { | |
| const sessionId = button.getAttribute("data-open-id"); | |
| if (!sessionId) return; | |
| await openSessionViewer(sessionId); | |
| }); | |
| } | |
| for (const button of tableEl.querySelectorAll("button[data-delete-id]")) { | |
| button.addEventListener("click", async () => { | |
| const sessionId = button.getAttribute("data-delete-id"); | |
| if (!sessionId) return; | |
| const ok = window.confirm("Delete session " + sessionId + "? This cannot be undone."); | |
| if (!ok) return; | |
| await deleteSession(sessionId, button); | |
| }); | |
| } | |
| } | |
| async function copySessionId(sessionId, button) { | |
| errorEl.textContent = ""; | |
| const prior = button.textContent; | |
| try { | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| await navigator.clipboard.writeText(sessionId); | |
| } else { | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = sessionId; | |
| textArea.setAttribute("readonly", ""); | |
| textArea.style.position = "absolute"; | |
| textArea.style.left = "-9999px"; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| document.execCommand("copy"); | |
| document.body.removeChild(textArea); | |
| } | |
| button.textContent = "✓"; | |
| window.setTimeout(() => { | |
| button.textContent = prior; | |
| }, 900); | |
| } catch { | |
| errorEl.textContent = "Unable to copy session ID."; | |
| } | |
| } | |
| async function loadSessions() { | |
| errorEl.textContent = ""; | |
| setLoading(true); | |
| if (state.retryTimer) { | |
| window.clearTimeout(state.retryTimer); | |
| state.retryTimer = null; | |
| } | |
| try { | |
| const response = await fetch("/api/sessions"); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| if (data && data.retryable) { | |
| statusEl.textContent = data.error || "Waiting for Copilot to become idle..."; | |
| if (!state.sessions.length) { | |
| tableEl.innerHTML = '<div class="empty">Waiting to load sessions...</div>'; | |
| } | |
| scheduleRetry(); | |
| return; | |
| } | |
| throw new Error((data && data.error) || ("HTTP " + response.status)); | |
| } | |
| state.sessions = Array.isArray(data.sessions) ? data.sessions : []; | |
| state.source = data.source || ""; | |
| if (state.selectedSessionId && !state.sessions.some((s) => s.sessionId === state.selectedSessionId)) { | |
| state.selectedSessionId = null; | |
| state.viewMode = "list"; | |
| } | |
| render(); | |
| } catch (error) { | |
| errorEl.textContent = "Unable to load sessions right now."; | |
| statusEl.textContent = "Failed to load sessions."; | |
| if (!state.sessions.length) { | |
| tableEl.innerHTML = '<div class="empty">No sessions available yet.</div>'; | |
| } | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| async function deleteSession(sessionId, button) { | |
| errorEl.textContent = ""; | |
| const prior = button.textContent; | |
| button.disabled = true; | |
| button.textContent = "⋯"; | |
| try { | |
| const response = await fetch("/api/delete", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ sessionId }), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error((data && data.error) || ("HTTP " + response.status)); | |
| } | |
| state.sessions = Array.isArray(data.sessions) ? data.sessions : state.sessions; | |
| state.source = data.source || state.source; | |
| delete state.sessionContentById[sessionId]; | |
| if (state.selectedSessionId === sessionId) { | |
| state.selectedSessionId = null; | |
| state.viewMode = "list"; | |
| } | |
| render(); | |
| } catch (error) { | |
| errorEl.textContent = error instanceof Error ? error.message : "Unable to delete session."; | |
| button.disabled = false; | |
| button.textContent = prior; | |
| } | |
| } | |
| refreshButton.addEventListener("click", () => loadSessions()); | |
| searchInput.addEventListener("input", () => render()); | |
| loadSessions(); | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| async function startServer(instanceId, session) { | |
| const server = createServer(async (req, res) => { | |
| try { | |
| const method = req.method ?? "GET"; | |
| const url = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| if (method === "GET" && url.pathname === "/") { | |
| res.statusCode = 200; | |
| res.setHeader("Content-Type", "text/html; charset=utf-8"); | |
| res.end(renderHtml(instanceId)); | |
| return; | |
| } | |
| if (method === "GET" && url.pathname === "/api/sessions") { | |
| try { | |
| const result = await listChronicleSessions(session); | |
| sendJson(res, 200, result); | |
| } catch (error) { | |
| if (error instanceof CanvasError && error.code === "session_busy") { | |
| sendJson(res, 409, { error: error.message, retryable: true }); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| return; | |
| } | |
| if (method === "GET" && url.pathname === "/api/session") { | |
| const sessionId = url.searchParams.get("sessionId") ?? ""; | |
| try { | |
| const details = await getSessionDetailsFromSessionState(session, sessionId); | |
| sendJson(res, 200, { session: details }); | |
| } catch (error) { | |
| if (error instanceof CanvasError) { | |
| if (error.code === "invalid_session_id") { | |
| sendJson(res, 400, { error: "A valid session ID is required." }); | |
| } else if (error.code === "session_not_found") { | |
| sendJson(res, 404, { error: error.message }); | |
| } else { | |
| sendJson(res, 500, { error: "Unable to load session details." }); | |
| } | |
| } else { | |
| throw error; | |
| } | |
| } | |
| return; | |
| } | |
| if (method === "GET" && url.pathname === "/api/session/content") { | |
| const sessionId = url.searchParams.get("sessionId") ?? ""; | |
| try { | |
| const content = await getSessionContentFromSessionState(session, sessionId); | |
| sendJson(res, 200, content); | |
| } catch (error) { | |
| if (error instanceof CanvasError) { | |
| if (error.code === "invalid_session_id") { | |
| sendJson(res, 400, { error: "A valid session ID is required." }); | |
| } else if (error.code === "session_not_found") { | |
| sendJson(res, 404, { error: error.message }); | |
| } else { | |
| sendJson(res, 500, { error: "Unable to load session content." }); | |
| } | |
| } else { | |
| throw error; | |
| } | |
| } | |
| return; | |
| } | |
| if (method === "POST" && url.pathname === "/api/delete") { | |
| const body = await readJsonBody(req); | |
| const sessionId = typeof body.sessionId === "string" ? body.sessionId : ""; | |
| try { | |
| await deleteSessionViaChronicle(session, sessionId); | |
| const updated = await listChronicleSessions(session); | |
| sendJson(res, 200, updated); | |
| } catch (error) { | |
| if (error instanceof CanvasError) { | |
| if (error.code === "session_busy") { | |
| sendJson(res, 409, { error: error.message, retryable: true }); | |
| } else if (error.code === "invalid_session_id") { | |
| sendJson(res, 400, { error: "A valid session ID is required." }); | |
| } else if (error.code === "cannot_delete_current_session") { | |
| sendJson(res, 400, { error: "You can't delete the currently active session." }); | |
| } else if (error.code === "session_in_use") { | |
| sendJson(res, 409, { error: error.message, retryable: true }); | |
| } else if (error.code === "session_not_found") { | |
| sendJson(res, 404, { error: error.message }); | |
| } else { | |
| sendJson(res, 500, { error: "Unable to delete that session." }); | |
| } | |
| } else { | |
| throw error; | |
| } | |
| } | |
| return; | |
| } | |
| sendJson(res, 404, { error: `Not found: ${escapeHtml(url.pathname)}` }); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| sendJson(res, 500, { error: message }); | |
| } | |
| }); | |
| await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); | |
| const address = server.address(); | |
| const port = typeof address === "object" && address ? address.port : 0; | |
| return { server, url: `http://127.0.0.1:${port}/` }; | |
| } | |
| const session = await joinSession({ | |
| canvases: [ | |
| createCanvas({ | |
| id: "chronicle-sessions", | |
| displayName: "Copilot Session Manager", | |
| description: "View, search, and delete old sessions from chronicle session listings.", | |
| actions: [ | |
| { | |
| name: "refresh_sessions", | |
| description: "Refresh and return sessions from chronicle.", | |
| handler: async () => { | |
| try { | |
| const result = await listChronicleSessions(session); | |
| return { ok: true, ...result }; | |
| } catch (error) { | |
| return { | |
| ok: false, | |
| error: error instanceof Error ? error.message : String(error), | |
| }; | |
| } | |
| }, | |
| }, | |
| { | |
| name: "delete_session", | |
| description: "Delete a session by id through chronicle command routing.", | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| sessionId: { type: "string", description: "Session ID to delete." }, | |
| }, | |
| required: ["sessionId"], | |
| additionalProperties: false, | |
| }, | |
| handler: async (ctx) => { | |
| const sessionId = typeof ctx.input?.sessionId === "string" ? ctx.input.sessionId : ""; | |
| try { | |
| await deleteSessionViaChronicle(session, sessionId); | |
| const result = await listChronicleSessions(session); | |
| return { ok: true, ...result }; | |
| } catch (error) { | |
| return { | |
| ok: false, | |
| error: error instanceof Error ? error.message : String(error), | |
| }; | |
| } | |
| }, | |
| }, | |
| ], | |
| open: async (ctx) => { | |
| let entry = servers.get(ctx.instanceId); | |
| if (!entry) { | |
| entry = await startServer(ctx.instanceId, session); | |
| servers.set(ctx.instanceId, entry); | |
| } | |
| return { | |
| title: "Copilot Session Manager", | |
| status: "Search, inspect, and delete sessions", | |
| url: entry.url, | |
| }; | |
| }, | |
| onClose: async (ctx) => { | |
| const entry = servers.get(ctx.instanceId); | |
| if (entry) { | |
| servers.delete(ctx.instanceId); | |
| await new Promise((resolve) => entry.server.close(() => resolve())); | |
| } | |
| }, | |
| }), | |
| ], | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment