first commit

This commit is contained in:
iceBear67
2026-08-09 04:00:13 +00:00
commit b3b6bf3f70
46 changed files with 7146 additions and 0 deletions
+297
View File
@@ -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;
}
}