first commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { VERSION, type Config } from "./config.js";
|
||||
import { appendEventLog, readRecentEvents } from "./store.js";
|
||||
import {
|
||||
labelForWorkspace,
|
||||
summarizeNotification,
|
||||
summarizePrompt,
|
||||
summarizeTool,
|
||||
truncateDetail,
|
||||
truncateTitle,
|
||||
} from "./summarize.js";
|
||||
import type {
|
||||
EventKind,
|
||||
GlanceEvent,
|
||||
PendingApproval,
|
||||
SessionState,
|
||||
SessionView,
|
||||
Snapshot,
|
||||
} from "./protocol.js";
|
||||
|
||||
/** A session that has said nothing for this long is treated as idle, not working. */
|
||||
const STALE_WORKING_MS = 10 * 60_000;
|
||||
|
||||
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
|
||||
SessionStart: "session_start",
|
||||
SessionEnd: "session_end",
|
||||
UserPromptSubmit: "prompt",
|
||||
PreToolUse: "tool_start",
|
||||
PostToolUse: "tool_end",
|
||||
PostToolUseFailure: "tool_fail",
|
||||
PermissionDenied: "permission_denied",
|
||||
Stop: "turn_end",
|
||||
StopFailure: "turn_error",
|
||||
Notification: "notification",
|
||||
SubagentStart: "subagent_start",
|
||||
SubagentStop: "subagent_end",
|
||||
PreCompact: "compact",
|
||||
PostCompact: "compact",
|
||||
};
|
||||
|
||||
export interface HookPayload {
|
||||
hookEventName?: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class GlanceState {
|
||||
private events: GlanceEvent[] = [];
|
||||
private sessions = new Map<string, SessionView>();
|
||||
/** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */
|
||||
private toolStarts = new Map<string, number>();
|
||||
private nextId = 1;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly cfg: Config) {
|
||||
// Warm start: keep recent history across daemon restarts.
|
||||
const recent = readRecentEvents(cfg.retainEvents);
|
||||
this.events = recent;
|
||||
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
|
||||
}
|
||||
|
||||
onChange(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* a broken listener must not break ingestion */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private session(id: string, payload: HookPayload): SessionView {
|
||||
let existing = this.sessions.get(id);
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id,
|
||||
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
|
||||
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
|
||||
state: "idle",
|
||||
startedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
counts: { tools: 0, failures: 0, denials: 0 },
|
||||
};
|
||||
this.sessions.set(id, existing);
|
||||
} else if (payload.workspaceRoot || payload.cwd) {
|
||||
// Keep the label fresh if the session moved.
|
||||
existing.label = labelForWorkspace(payload.workspaceRoot, payload.cwd ?? existing.cwd);
|
||||
existing.cwd = payload.workspaceRoot ?? payload.cwd ?? existing.cwd;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private push(event: GlanceEvent): void {
|
||||
this.events.push(event);
|
||||
if (this.events.length > this.cfg.retainEvents) {
|
||||
this.events.splice(0, this.events.length - this.cfg.retainEvents);
|
||||
}
|
||||
appendEventLog(event);
|
||||
}
|
||||
|
||||
/** Record a raw hook payload. Returns the event it produced, if any. */
|
||||
ingest(payload: HookPayload): GlanceEvent | null {
|
||||
const hookName = payload.hookEventName ?? "";
|
||||
const kind = EVENT_KIND_BY_HOOK[hookName];
|
||||
if (!kind) return null;
|
||||
|
||||
const sessionId = payload.sessionId ?? "unknown";
|
||||
const session = this.session(sessionId, payload);
|
||||
const now = Date.now();
|
||||
session.lastActivity = now;
|
||||
|
||||
const tool = typeof payload.toolName === "string" ? payload.toolName : undefined;
|
||||
let title = hookName;
|
||||
let detail: string | undefined;
|
||||
let durationMs: number | undefined;
|
||||
|
||||
switch (kind) {
|
||||
case "session_start":
|
||||
session.state = "idle";
|
||||
title = `Session started in ${session.label}`;
|
||||
detail = session.cwd || undefined;
|
||||
break;
|
||||
|
||||
case "session_end":
|
||||
session.state = "ended";
|
||||
session.currentTool = undefined;
|
||||
title = "Session ended";
|
||||
break;
|
||||
|
||||
case "prompt":
|
||||
session.state = "working";
|
||||
session.lastPrompt = summarizePrompt(payload);
|
||||
title = session.lastPrompt;
|
||||
break;
|
||||
|
||||
case "tool_start": {
|
||||
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
||||
session.state = "working";
|
||||
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
|
||||
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now);
|
||||
title = summary.title;
|
||||
detail = summary.detail;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_end":
|
||||
case "tool_fail": {
|
||||
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
||||
const key = `${sessionId}|${tool ?? "tool"}`;
|
||||
const startedAt = this.toolStarts.get(key);
|
||||
if (startedAt) {
|
||||
durationMs = now - startedAt;
|
||||
this.toolStarts.delete(key);
|
||||
}
|
||||
if (session.currentTool?.name === tool) session.currentTool = undefined;
|
||||
session.state = "working";
|
||||
title = summary.title;
|
||||
detail = summary.detail;
|
||||
if (kind === "tool_end") {
|
||||
session.counts.tools += 1;
|
||||
} else {
|
||||
session.counts.failures += 1;
|
||||
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || detail;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "permission_denied":
|
||||
session.counts.denials += 1;
|
||||
title = tool ? `Permission denied: ${tool}` : "Permission denied";
|
||||
detail = summarizeTool(tool ?? "tool", payload.toolInput).title;
|
||||
break;
|
||||
|
||||
case "turn_end":
|
||||
session.state = "idle";
|
||||
session.currentTool = undefined;
|
||||
title = "Turn finished";
|
||||
break;
|
||||
|
||||
case "turn_error":
|
||||
session.state = "error";
|
||||
session.currentTool = undefined;
|
||||
title = "Turn failed";
|
||||
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
|
||||
break;
|
||||
|
||||
case "notification":
|
||||
title = summarizeNotification(payload);
|
||||
break;
|
||||
|
||||
case "subagent_start":
|
||||
title = "Subagent started";
|
||||
detail = truncateDetail(String(payload["description"] ?? payload["subagentType"] ?? "")) || undefined;
|
||||
break;
|
||||
|
||||
case "subagent_end":
|
||||
title = "Subagent finished";
|
||||
break;
|
||||
|
||||
case "compact":
|
||||
title = hookName === "PreCompact" ? "Compacting conversation" : "Compaction done";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const event: GlanceEvent = {
|
||||
id: this.nextId++,
|
||||
ts: now,
|
||||
sessionId,
|
||||
kind,
|
||||
tool,
|
||||
title: truncateTitle(title),
|
||||
detail,
|
||||
durationMs,
|
||||
};
|
||||
this.push(event);
|
||||
this.notify();
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Record something the daemon itself decided, e.g. an approval outcome. */
|
||||
record(
|
||||
sessionId: string,
|
||||
kind: EventKind,
|
||||
title: string,
|
||||
opts: { tool?: string; detail?: string } = {},
|
||||
): GlanceEvent {
|
||||
const now = Date.now();
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.lastActivity = now;
|
||||
if (kind === "approval_request") session.state = "waiting";
|
||||
else if (kind === "approval_allowed" || kind === "approval_denied") session.state = "working";
|
||||
}
|
||||
const event: GlanceEvent = {
|
||||
id: this.nextId++,
|
||||
ts: now,
|
||||
sessionId,
|
||||
kind,
|
||||
tool: opts.tool,
|
||||
title: truncateTitle(title),
|
||||
detail: opts.detail,
|
||||
};
|
||||
this.push(event);
|
||||
this.notify();
|
||||
return event;
|
||||
}
|
||||
|
||||
private effectiveState(session: SessionView, now: number): SessionState {
|
||||
if (session.state === "working" && now - session.lastActivity > STALE_WORKING_MS) {
|
||||
return "idle";
|
||||
}
|
||||
return session.state;
|
||||
}
|
||||
|
||||
snapshot(pending: PendingApproval[]): Snapshot {
|
||||
const now = Date.now();
|
||||
const waiting = new Set(pending.map((p) => p.sessionId));
|
||||
const sessions = [...this.sessions.values()]
|
||||
.map((s) => ({
|
||||
...s,
|
||||
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
|
||||
}))
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
|
||||
return {
|
||||
now,
|
||||
version: VERSION,
|
||||
sessions,
|
||||
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
|
||||
pending,
|
||||
approval: this.cfg.approval,
|
||||
};
|
||||
}
|
||||
|
||||
sessionLabel(sessionId: string): string {
|
||||
return this.sessions.get(sessionId)?.label ?? "workspace";
|
||||
}
|
||||
|
||||
get sessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
get eventCount(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user