mirror of
https://github.com/joelhooks/joelclaw.git
synced 2026-09-19 01:24:04 +08:00
64581c91f2
You work on sessions for days. SessionEnd never fires in that workflow,
so capture would never happen. Pivoting to Stop (per Rule 3 — Run = one
invocation), with per-session byte-offset state so each Stop only ships
the delta.
~/.joelclaw/session-state.json tracks { last_byte_offset, last_run_id,
turn_count, last_captured_at } keyed by Claude Code session_id. Each
capture uses the prior last_run_id as parent_run_id, so a long session
becomes a chain of per-turn Runs with tree linkage intact.
Safety: ALWAYS exits 0 (never blocks Claude continuation). Failures
outbox the delta and leave state unadvanced so the next Stop retries
the same range.
Verified manually:
- 3.4MB / 694-turn backfill of this live session → 1 POST, state
advanced to current EOF
- 15 seconds later, second invocation captured a 504-byte delta
(the conversation that happened in between) with parent_run_id
pointing back at the first
settings.json flipped from SessionEnd → Stop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
7.6 KiB
TypeScript
Executable File
265 lines
7.6 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* Claude Code Stop-hook entry point — ADR-0243 Rules 3 + 8.
|
|
*
|
|
* Rule 3: one Run per invocation. In claude-code that = one user→assistant
|
|
* turn. This script fires on every Stop event and captures only the NEW
|
|
* turn(s) since the last capture, tracking per-session byte offsets in
|
|
* ~/.joelclaw/session-state.json.
|
|
*
|
|
* Hook protocol (Claude Code Stop hook, stdin JSON):
|
|
* {
|
|
* "session_id": "uuid",
|
|
* "transcript_path": "/path/to/<uuid>.jsonl",
|
|
* "stop_hook_active": bool
|
|
* }
|
|
*
|
|
* Behavior guarantees:
|
|
* - ALWAYS exits 0. Never blocks Claude Code's continuation.
|
|
* - On network/Central failure: jsonl gets outboxed, state is NOT advanced,
|
|
* so next Stop picks up the same delta (idempotent retry).
|
|
* - On success: state advances to current EOF; Claude can continue.
|
|
* - Noop (nothing new since last capture) is a silent exit 0.
|
|
*/
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { basename, dirname, join } from "node:path";
|
|
|
|
interface HookContext {
|
|
session_id?: string;
|
|
transcript_path?: string;
|
|
stop_hook_active?: boolean;
|
|
}
|
|
|
|
interface AuthFile {
|
|
user_id: string;
|
|
machine_id: string;
|
|
token: string;
|
|
}
|
|
|
|
interface SessionState {
|
|
last_byte_offset: number;
|
|
last_run_id: string | null;
|
|
last_captured_at: string;
|
|
turn_count: number;
|
|
}
|
|
|
|
const CENTRAL_URL = process.env.JOELCLAW_CENTRAL_URL ?? "http://localhost:3000";
|
|
const AUTH_PATH =
|
|
process.env.JOELCLAW_AUTH_PATH ?? join(homedir(), ".joelclaw", "auth.json");
|
|
const STATE_PATH = join(homedir(), ".joelclaw", "session-state.json");
|
|
const OUTBOX_DIR = join(homedir(), ".joelclaw", "outbox");
|
|
const LOG_PATH = join(homedir(), ".joelclaw", "capture.log");
|
|
const RUNTIME = process.env.JOELCLAW_RUNTIME ?? "claude-code";
|
|
|
|
function log(message: string) {
|
|
const line = `[${new Date().toISOString()}] ${message}\n`;
|
|
try {
|
|
mkdirSync(dirname(LOG_PATH), { recursive: true });
|
|
const existing = existsSync(LOG_PATH) ? readFileSync(LOG_PATH, "utf8") : "";
|
|
writeFileSync(LOG_PATH, existing + line);
|
|
} catch {
|
|
// can't log; don't crash
|
|
}
|
|
}
|
|
|
|
function loadAuth(): AuthFile | null {
|
|
try {
|
|
if (!existsSync(AUTH_PATH)) return null;
|
|
const auth = JSON.parse(readFileSync(AUTH_PATH, "utf8")) as AuthFile;
|
|
if (!auth.token || !auth.user_id || !auth.machine_id) return null;
|
|
return auth;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadAllState(): Record<string, SessionState> {
|
|
try {
|
|
if (!existsSync(STATE_PATH)) return {};
|
|
return JSON.parse(readFileSync(STATE_PATH, "utf8")) as Record<
|
|
string,
|
|
SessionState
|
|
>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveAllState(state: Record<string, SessionState>) {
|
|
mkdirSync(dirname(STATE_PATH), { recursive: true });
|
|
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
}
|
|
|
|
async function readHookContext(): Promise<HookContext> {
|
|
const fileArgIdx = process.argv.indexOf("--file");
|
|
if (fileArgIdx >= 0) {
|
|
const path = process.argv[fileArgIdx + 1];
|
|
if (!path) throw new Error("--file requires a path");
|
|
return JSON.parse(readFileSync(path, "utf8")) as HookContext;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of Bun.stdin.stream()) {
|
|
chunks.push(Buffer.from(chunk as unknown as Uint8Array));
|
|
}
|
|
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
if (!raw) return {};
|
|
return JSON.parse(raw) as HookContext;
|
|
}
|
|
|
|
function writeToOutbox(runId: string, body: unknown): string {
|
|
mkdirSync(OUTBOX_DIR, { recursive: true });
|
|
const outboxPath = join(OUTBOX_DIR, `${runId}.json`);
|
|
writeFileSync(outboxPath, JSON.stringify(body));
|
|
return outboxPath;
|
|
}
|
|
|
|
function newRunId(): string {
|
|
return crypto.randomUUID().replace(/-/g, "").slice(0, 26);
|
|
}
|
|
|
|
function countTurns(jsonlDelta: string): number {
|
|
// Quick heuristic — count assistant messages in the delta.
|
|
let turns = 0;
|
|
for (const line of jsonlDelta.split("\n")) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const entry = JSON.parse(line) as {
|
|
type?: string;
|
|
message?: { role?: string };
|
|
};
|
|
if (entry.type === "assistant" && entry.message?.role === "assistant") {
|
|
turns += 1;
|
|
}
|
|
} catch {
|
|
// tolerate malformed lines
|
|
}
|
|
}
|
|
return turns;
|
|
}
|
|
|
|
async function main() {
|
|
let ctx: HookContext;
|
|
try {
|
|
ctx = await readHookContext();
|
|
} catch (err) {
|
|
log(`hook context read failed: ${(err as Error).message}`);
|
|
process.exit(0); // never block
|
|
}
|
|
|
|
const sessionId = ctx.session_id;
|
|
const transcriptPath = ctx.transcript_path;
|
|
|
|
if (!sessionId || !transcriptPath) {
|
|
log(`no session_id/transcript_path in hook context — skipping`);
|
|
process.exit(0);
|
|
}
|
|
if (!existsSync(transcriptPath)) {
|
|
log(`transcript missing at ${transcriptPath} — skipping`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const auth = loadAuth();
|
|
if (!auth) {
|
|
log(`auth missing/invalid at ${AUTH_PATH} — skipping`);
|
|
process.exit(0);
|
|
}
|
|
|
|
let currentSize: number;
|
|
try {
|
|
currentSize = statSync(transcriptPath).size;
|
|
} catch (err) {
|
|
log(`cannot stat transcript: ${(err as Error).message}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const allState = loadAllState();
|
|
const prior = allState[sessionId];
|
|
const lastOffset = prior?.last_byte_offset ?? 0;
|
|
|
|
if (currentSize <= lastOffset) {
|
|
// Nothing new since last capture (or transcript shrunk — either way skip).
|
|
process.exit(0);
|
|
}
|
|
|
|
// Read only the delta. Bun.file().slice() would be ideal but .text() reads
|
|
// the whole file; for now accept the O(n) read and slice in memory.
|
|
const fullText = readFileSync(transcriptPath, "utf8");
|
|
const delta = fullText.slice(lastOffset);
|
|
if (!delta.trim()) {
|
|
process.exit(0);
|
|
}
|
|
|
|
const turnCount = countTurns(delta);
|
|
if (turnCount === 0 && !prior) {
|
|
// First capture of a session that has no assistant turns yet — skip,
|
|
// we'll come back on a later Stop when there's something to embed.
|
|
process.exit(0);
|
|
}
|
|
|
|
const runId = newRunId();
|
|
const body: Record<string, unknown> = {
|
|
run_id: runId,
|
|
agent_runtime: RUNTIME,
|
|
tags: [
|
|
"captured",
|
|
`basename:${basename(transcriptPath)}`,
|
|
`session:${sessionId}`,
|
|
],
|
|
started_at: Date.now(),
|
|
conversation_id: sessionId,
|
|
jsonl: delta,
|
|
};
|
|
if (prior?.last_run_id) {
|
|
body.parent_run_id = prior.last_run_id;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${CENTRAL_URL}/api/runs`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${auth.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const errText = await res.text();
|
|
const outbox = writeToOutbox(runId, body);
|
|
log(
|
|
`POST failed ${res.status} for session=${sessionId}; outboxed to ${outbox}: ${errText.slice(0, 200)}`
|
|
);
|
|
process.exit(0); // outbox drained later; don't block
|
|
}
|
|
const resp = (await res.json()) as { run_id?: string };
|
|
allState[sessionId] = {
|
|
last_byte_offset: currentSize,
|
|
last_run_id: resp.run_id ?? runId,
|
|
last_captured_at: new Date().toISOString(),
|
|
turn_count: (prior?.turn_count ?? 0) + turnCount,
|
|
};
|
|
saveAllState(allState);
|
|
log(
|
|
`captured run_id=${resp.run_id} session=${sessionId} delta_bytes=${delta.length} turns=${turnCount}`
|
|
);
|
|
process.exit(0);
|
|
} catch (err) {
|
|
const outbox = writeToOutbox(runId, body);
|
|
log(
|
|
`network error for session=${sessionId}; outboxed to ${outbox}: ${(err as Error).message}`
|
|
);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
log(`fatal: ${(err as Error).message}`);
|
|
process.exit(0); // NEVER block Claude Code
|
|
});
|