Parse state and stats events from Claude Code hooks

Add parsing of structured JSON events from Claude Code hooks in PTY
output. State events track session lifecycle (ready/thinking/permission/
question/complete/interrupted). Stats events provide session metrics
(prompts, completions, tools, thinking time, etc).

Events are detected by parsing each line of PTY output, extracting valid
JSON objects with a type field, and forwarding state/stats events to the
server via WebSocket while preserving all output passthrough.
This commit is contained in:
Jared Miller 2026-01-28 13:48:56 -05:00
parent 1827baaf39
commit f31d33f992
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 296 additions and 3 deletions

136
src/cli.test.ts Normal file
View file

@ -0,0 +1,136 @@
import { expect, test } from "bun:test";
// Export the parsing function from cli.ts for testing
function tryParseHookEvent(
line: string,
): { type: string; [key: string]: unknown } | null {
// Look for lines that are valid JSON objects
const trimmed = line.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
// Check if it has a type field
if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
return parsed as { type: string; [key: string]: unknown };
}
return null;
} catch {
return null;
}
}
test("tryParseHookEvent - parses valid state event", () => {
const line = '{"type":"state","state":"thinking","timestamp":1706472000000}';
const result = tryParseHookEvent(line);
expect(result).not.toBeNull();
expect(result?.type).toBe("state");
expect(result?.state).toBe("thinking");
expect(result?.timestamp).toBe(1706472000000);
});
test("tryParseHookEvent - parses valid stats event", () => {
const line =
'{"type":"stats","prompts":5,"completions":3,"tools":12,"compressions":1,"thinking_seconds":45,"work_seconds":180,"mode":"normal","model":"claude-opus-4-5-20251101","prompts_changed_at":1706472000.0,"completions_changed_at":1706471980.0,"tool_timestamps":[1706471900.0,1706471920.0],"session_start":1706471800.0,"idle_since":1706472100.0}';
const result = tryParseHookEvent(line);
expect(result).not.toBeNull();
expect(result?.type).toBe("stats");
expect(result?.prompts).toBe(5);
expect(result?.completions).toBe(3);
expect(result?.tools).toBe(12);
expect(result?.mode).toBe("normal");
expect(result?.model).toBe("claude-opus-4-5-20251101");
});
test("tryParseHookEvent - handles whitespace around JSON", () => {
const line =
' {"type":"state","state":"ready","timestamp":1706472000000} \n';
const result = tryParseHookEvent(line);
expect(result).not.toBeNull();
expect(result?.type).toBe("state");
expect(result?.state).toBe("ready");
});
test("tryParseHookEvent - returns null for non-JSON lines", () => {
const line = "This is just regular output text";
const result = tryParseHookEvent(line);
expect(result).toBeNull();
});
test("tryParseHookEvent - returns null for partial JSON", () => {
const line = '{"type":"state","state":"thinking"';
const result = tryParseHookEvent(line);
expect(result).toBeNull();
});
test("tryParseHookEvent - returns null for JSON without type field", () => {
const line = '{"something":"else","value":123}';
const result = tryParseHookEvent(line);
expect(result).toBeNull();
});
test("tryParseHookEvent - returns null for JSON arrays", () => {
const line = '[{"type":"state"}]';
const result = tryParseHookEvent(line);
expect(result).toBeNull();
});
test("tryParseHookEvent - handles invalid JSON gracefully", () => {
const line = '{type:"state",state:"thinking"}';
const result = tryParseHookEvent(line);
expect(result).toBeNull();
});
test("tryParseHookEvent - parses state with different states", () => {
const states = [
"ready",
"thinking",
"permission",
"question",
"complete",
"interrupted",
];
for (const state of states) {
const line = `{"type":"state","state":"${state}","timestamp":1706472000000}`;
const result = tryParseHookEvent(line);
expect(result).not.toBeNull();
expect(result?.type).toBe("state");
expect(result?.state).toBe(state);
}
});
test("tryParseHookEvent - parses stats with different modes", () => {
const modes = ["normal", "auto_accept", "plan"];
for (const mode of modes) {
const line = `{"type":"stats","prompts":1,"completions":1,"tools":1,"compressions":0,"thinking_seconds":10,"work_seconds":20,"mode":"${mode}","model":"claude-opus-4-5","prompts_changed_at":1706472000.0,"completions_changed_at":1706471980.0,"tool_timestamps":[],"session_start":1706471800.0,"idle_since":null}`;
const result = tryParseHookEvent(line);
expect(result).not.toBeNull();
expect(result?.type).toBe("stats");
expect(result?.mode).toBe(mode);
}
});
test("tryParseHookEvent - preserves null values in stats", () => {
const line =
'{"type":"stats","prompts":1,"completions":1,"tools":1,"compressions":0,"thinking_seconds":10,"work_seconds":20,"mode":"normal","model":null,"prompts_changed_at":1706472000.0,"completions_changed_at":1706471980.0,"tool_timestamps":[],"session_start":1706471800.0,"idle_since":null}';
const result = tryParseHookEvent(line);
expect(result).not.toBeNull();
expect(result?.type).toBe("stats");
expect(result?.model).toBeNull();
expect(result?.idle_since).toBeNull();
});

View file

@ -53,6 +53,27 @@ function getTerminalSize(): { cols: number; rows: number } {
return { cols: 80, rows: 24 }; return { cols: 80, rows: 24 };
} }
function tryParseHookEvent(
line: string,
): { type: string; [key: string]: unknown } | null {
// Look for lines that are valid JSON objects
const trimmed = line.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
// Check if it has a type field
if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
return parsed as { type: string; [key: string]: unknown };
}
return null;
} catch {
return null;
}
}
async function main() { async function main() {
const args = parseArgs(); const args = parseArgs();
const { cols, rows } = getTerminalSize(); const { cols, rows } = getTerminalSize();
@ -63,6 +84,7 @@ async function main() {
let isAuthenticated = false; let isAuthenticated = false;
let reconnectTimer: Timer | null = null; let reconnectTimer: Timer | null = null;
const disposables: Array<{ dispose: () => void }> = []; const disposables: Array<{ dispose: () => void }> = [];
let lineBuffer = "";
const cleanup = () => { const cleanup = () => {
if (isExiting) return; if (isExiting) return;
@ -116,8 +138,55 @@ async function main() {
process.stdout.write(data); process.stdout.write(data);
if (ws && ws.readyState === WebSocket.OPEN && isAuthenticated) { if (ws && ws.readyState === WebSocket.OPEN && isAuthenticated) {
const msg: ClientMessage = { type: "output", data }; // Send output to server
ws.send(JSON.stringify(msg)); const outputMsg: ClientMessage = { type: "output", data };
ws.send(JSON.stringify(outputMsg));
// Buffer data and process complete lines for hook events
lineBuffer += data;
const lines = lineBuffer.split("\n");
// Keep the last incomplete line in the buffer
lineBuffer = lines.pop() || "";
// Process each complete line for hook events
for (const line of lines) {
const event = tryParseHookEvent(line);
if (event && (event.type === "state" || event.type === "stats")) {
// Send state or stats event to server
if (event.type === "state") {
const stateMsg: ClientMessage = {
type: "state",
state: event.state as
| "ready"
| "thinking"
| "permission"
| "question"
| "complete"
| "interrupted",
timestamp: event.timestamp as number,
};
ws.send(JSON.stringify(stateMsg));
} else if (event.type === "stats") {
const statsMsg: ClientMessage = {
type: "stats",
prompts: event.prompts as number,
completions: event.completions as number,
tools: event.tools as number,
compressions: event.compressions as number,
thinking_seconds: event.thinking_seconds as number,
work_seconds: event.work_seconds as number,
mode: event.mode as "normal" | "auto_accept" | "plan",
model: (event.model as string) || null,
prompts_changed_at: event.prompts_changed_at as number,
completions_changed_at: event.completions_changed_at as number,
tool_timestamps: event.tool_timestamps as number[],
session_start: event.session_start as number,
idle_since: (event.idle_since as number) || null,
};
ws.send(JSON.stringify(statsMsg));
}
}
}
} }
}), }),
); );

View file

@ -17,6 +17,20 @@ export interface Session {
ended_at: number | null; ended_at: number | null;
cwd: string | null; cwd: string | null;
command: string | null; command: string | null;
// Phase 2.3: Session state and stats
state: string;
prompts: number;
completions: number;
tools: number;
compressions: number;
thinking_seconds: number;
work_seconds: number;
mode: string;
model: string | null;
idle_since: number | null;
// Git state (for later phase)
git_branch: string | null;
git_files_json: string | null;
} }
export interface Prompt { export interface Prompt {
@ -89,7 +103,34 @@ export type ClientMessage =
| { type: "output"; data: string } | { type: "output"; data: string }
| { type: "resize"; cols: number; rows: number } | { type: "resize"; cols: number; rows: number }
| { type: "exit"; code: number } | { type: "exit"; code: number }
| { type: "prompt"; session_id: number; prompt_text: string }; | { type: "prompt"; session_id: number; prompt_text: string }
| {
type: "state";
state:
| "ready"
| "thinking"
| "permission"
| "question"
| "complete"
| "interrupted";
timestamp: number;
}
| {
type: "stats";
prompts: number;
completions: number;
tools: number;
compressions: number;
thinking_seconds: number;
work_seconds: number;
mode: "normal" | "auto_accept" | "plan";
model: string | null;
prompts_changed_at: number;
completions_changed_at: number;
tool_timestamps: number[];
session_start: number;
idle_since: number | null;
};
export type ServerMessage = export type ServerMessage =
| { type: "authenticated"; session_id: number } | { type: "authenticated"; session_id: number }
@ -119,4 +160,51 @@ export type SSEEvent =
type: "prompt_response"; type: "prompt_response";
prompt_id: number; prompt_id: number;
response: string; response: string;
}
| {
type: "state";
session_id: number;
state:
| "ready"
| "thinking"
| "permission"
| "question"
| "complete"
| "interrupted";
timestamp: number;
}
| {
type: "stats";
session_id: number;
prompts: number;
completions: number;
tools: number;
compressions: number;
thinking_seconds: number;
work_seconds: number;
mode: "normal" | "auto_accept" | "plan";
model: string | null;
idle_since: number | null;
}; };
// Session state tracking (in-memory)
export interface SessionState {
state:
| "ready"
| "thinking"
| "permission"
| "question"
| "complete"
| "interrupted";
prompts: number;
completions: number;
tools: number;
compressions: number;
thinking_seconds: number;
work_seconds: number;
mode: "normal" | "auto_accept" | "plan";
model: string | null;
idle_since: number | null;
dirty: boolean;
}