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:
parent
1827baaf39
commit
f31d33f992
3 changed files with 296 additions and 3 deletions
136
src/cli.test.ts
Normal file
136
src/cli.test.ts
Normal 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();
|
||||||
|
});
|
||||||
73
src/cli.ts
73
src/cli.ts
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
90
src/types.ts
90
src/types.ts
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue