@scrylog/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/package.json +75 -0
- package/src/api/docs/sessions.keys.ts +6 -0
- package/src/api/docs/sessions.ts +81 -0
- package/src/api/docs/system.keys.ts +5 -0
- package/src/api/docs/system.ts +66 -0
- package/src/api/docs/usage.keys.ts +3 -0
- package/src/api/docs/usage.ts +24 -0
- package/src/api/docs/webhooks.keys.ts +5 -0
- package/src/api/docs/webhooks.ts +78 -0
- package/src/api/index.ts +18 -0
- package/src/api/lib/auth.ts +43 -0
- package/src/api/lib/errors.ts +28 -0
- package/src/api/lib/route.ts +18 -0
- package/src/api/models/claude.ts +4 -0
- package/src/api/models/codex.ts +4 -0
- package/src/api/models/opencode.ts +11 -0
- package/src/api/routes/sessions.ts +47 -0
- package/src/api/routes/system.ts +103 -0
- package/src/api/routes/usage.ts +15 -0
- package/src/api/routes/webhooks/claude.ts +161 -0
- package/src/api/routes/webhooks/codex.ts +175 -0
- package/src/api/routes/webhooks/opencode.ts +107 -0
- package/src/api/routes.ts +93 -0
- package/src/api/schemas/common.ts +21 -0
- package/src/api/schemas/sessions.ts +72 -0
- package/src/api/schemas/usage.ts +50 -0
- package/src/api/schemas/webhooks/claude.ts +32 -0
- package/src/api/schemas/webhooks/codex.ts +34 -0
- package/src/api/schemas/webhooks/opencode.ts +29 -0
- package/src/api/services/claude-session.ts +9 -0
- package/src/api/services/codex-session.ts +8 -0
- package/src/api/services/opencode-session.ts +73 -0
- package/src/classifier.ts +206 -0
- package/src/cli/commands.ts +498 -0
- package/src/cli/prompt.ts +44 -0
- package/src/daemon/cli.ts +27 -0
- package/src/daemon/collectors/claude/README.md +108 -0
- package/src/daemon/collectors/claude/collector.ts +413 -0
- package/src/daemon/collectors/claude/discovery.ts +148 -0
- package/src/daemon/collectors/claude/hooks.ts +181 -0
- package/src/daemon/collectors/claude/index.ts +8 -0
- package/src/daemon/collectors/claude/jsonl.ts +85 -0
- package/src/daemon/collectors/claude/transcript.ts +246 -0
- package/src/daemon/collectors/codex/README.md +147 -0
- package/src/daemon/collectors/codex/collector.ts +661 -0
- package/src/daemon/collectors/codex/discovery.ts +104 -0
- package/src/daemon/collectors/codex/hooks.ts +179 -0
- package/src/daemon/collectors/codex/index.ts +7 -0
- package/src/daemon/collectors/codex/jsonl.ts +108 -0
- package/src/daemon/collectors/codex/transcript.ts +299 -0
- package/src/daemon/collectors/git.ts +295 -0
- package/src/daemon/collectors/opencode/README.md +106 -0
- package/src/daemon/collectors/opencode/collector.ts +543 -0
- package/src/daemon/collectors/opencode/discovery.ts +257 -0
- package/src/daemon/collectors/opencode/index.ts +5 -0
- package/src/daemon/collectors/opencode/session-builder.ts +59 -0
- package/src/daemon/collectors/opencode-db.ts +282 -0
- package/src/daemon/collectors/process.ts +201 -0
- package/src/daemon/collectors/tmux/README.md +128 -0
- package/src/daemon/collectors/tmux/collector.ts +472 -0
- package/src/daemon/collectors/tmux/enrichment.ts +241 -0
- package/src/daemon/collectors/tmux/index.ts +4 -0
- package/src/daemon/collectors/tmux/parse.ts +128 -0
- package/src/daemon/collectors/usage.ts +193 -0
- package/src/daemon/index.ts +207 -0
- package/src/daemon/scheduler.ts +86 -0
- package/src/daemon/store.ts +535 -0
- package/src/detect.ts +143 -0
- package/src/hooks/claude.ts +596 -0
- package/src/hooks/codex.ts +517 -0
- package/src/legacy/classifier.ts +215 -0
- package/src/legacy/detect.ts +143 -0
- package/src/mcp/client-server.ts +21 -0
- package/src/mcp/http-handler.ts +126 -0
- package/src/mcp/index.ts +61 -0
- package/src/mcp/resources/config-current.ts +19 -0
- package/src/mcp/resources/index.ts +11 -0
- package/src/mcp/resources/sessions-active.ts +19 -0
- package/src/mcp/resources/sessions-all.ts +19 -0
- package/src/mcp/schemas/sessions.ts +3 -0
- package/src/mcp/server.ts +17 -0
- package/src/mcp/services/api-adapter.ts +48 -0
- package/src/mcp/services/data-source.ts +22 -0
- package/src/mcp/services/store-adapter.ts +40 -0
- package/src/mcp/tools/get-active-sessions.ts +16 -0
- package/src/mcp/tools/get-session-children.ts +24 -0
- package/src/mcp/tools/get-session-messages.ts +24 -0
- package/src/mcp/tools/get-session.ts +23 -0
- package/src/mcp/tools/index.ts +15 -0
- package/src/mcp/tools/list-sessions.ts +16 -0
- package/src/neo-blessed.d.ts +4 -0
- package/src/oc/index.ts +302 -0
- package/src/service/manager.ts +242 -0
- package/src/shared/config.ts +478 -0
- package/src/shared/ipc.ts +80 -0
- package/src/shared/logger.ts +248 -0
- package/src/shared/paths.ts +73 -0
- package/src/shared/platform.ts +110 -0
- package/src/shared/session-view.ts +241 -0
- package/src/shared/types.ts +299 -0
- package/src/shared/utils.ts +339 -0
- package/src/tui/app.ts +920 -0
- package/src/tui/client.ts +149 -0
- package/src/tui/index.ts +181 -0
- package/src/types.ts +113 -0
- package/src/usage/README.md +323 -0
- package/src/usage/api.ts +99 -0
- package/src/usage/client.ts +3 -0
- package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
- package/src/usage/clients/anthropic-web-usage.ts +245 -0
- package/src/usage/clients/claude-cli-probe.ts +187 -0
- package/src/usage/clients/codex-oauth-usage.ts +113 -0
- package/src/usage/clients/opencode-go-usage.ts +441 -0
- package/src/usage/index.ts +19 -0
- package/src/usage/mappers.ts +273 -0
- package/src/usage/planner.ts +54 -0
- package/src/usage/providers/claude.ts +113 -0
- package/src/usage/providers/codex.ts +34 -0
- package/src/usage/providers/opencode-go.ts +37 -0
- package/src/usage/resolver.ts +4 -0
- package/src/usage/resolvers/browser-cookies.ts +198 -0
- package/src/usage/resolvers/claude-auth.ts +99 -0
- package/src/usage/resolvers/claude-cli.ts +24 -0
- package/src/usage/resolvers/claude-web.ts +16 -0
- package/src/usage/resolvers/codex-auth.ts +117 -0
- package/src/usage/resolvers/opencode-go-web.ts +14 -0
- package/src/usage/types.ts +190 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* scrylog-claude-hook — Claude Code lifecycle hook handler.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code calls this binary at each lifecycle event (Stop, PreToolUse,
|
|
6
|
+
* PostToolUse, Notification, SubagentStop) with a JSON payload on stdin.
|
|
7
|
+
* This script writes a state file for the ClaudeCollector to pick up and,
|
|
8
|
+
* when running inside tmux, sets the @claude-session pane option so the
|
|
9
|
+
* TmuxCollector can link the pane to the Claude session.
|
|
10
|
+
*
|
|
11
|
+
* Configure in ~/.claude/settings.json via:
|
|
12
|
+
* scrylog claude hooks install
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync, renameSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
16
|
+
import { join, basename } from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { getHooksDir, ensureHooksDirSafe } from "../shared/platform";
|
|
19
|
+
import type { SessionState } from "../shared/types";
|
|
20
|
+
|
|
21
|
+
type AttentionKind = "permission" | "question" | "turn" | "none";
|
|
22
|
+
type ToolResultStatus = "started" | "completed" | "error" | "blocked" | "denied" | "unknown";
|
|
23
|
+
|
|
24
|
+
// ── Hook payload ──────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface ClaudeHookPayload {
|
|
27
|
+
session_id: string;
|
|
28
|
+
transcript_path?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
permission_mode?: string;
|
|
31
|
+
hook_event_name?: string;
|
|
32
|
+
source?: string;
|
|
33
|
+
model?: string;
|
|
34
|
+
prompt?: string;
|
|
35
|
+
stop_hook_active?: boolean;
|
|
36
|
+
last_assistant_message?: string;
|
|
37
|
+
// PreToolUse / PostToolUse
|
|
38
|
+
tool_name?: string;
|
|
39
|
+
tool_input?: Record<string, unknown>;
|
|
40
|
+
tool_response?: unknown;
|
|
41
|
+
tool_use_id?: string;
|
|
42
|
+
duration_ms?: number;
|
|
43
|
+
error?: string;
|
|
44
|
+
error_details?: string;
|
|
45
|
+
is_interrupt?: boolean;
|
|
46
|
+
reason?: string;
|
|
47
|
+
old_cwd?: string;
|
|
48
|
+
new_cwd?: string;
|
|
49
|
+
permission_suggestions?: unknown[];
|
|
50
|
+
tool_calls?: Array<{
|
|
51
|
+
tool_name?: string;
|
|
52
|
+
tool_input?: Record<string, unknown>;
|
|
53
|
+
tool_use_id?: string;
|
|
54
|
+
tool_response?: unknown;
|
|
55
|
+
}>;
|
|
56
|
+
// Notification / async events
|
|
57
|
+
message?: string;
|
|
58
|
+
title?: string;
|
|
59
|
+
// Subagents
|
|
60
|
+
agent_id?: string;
|
|
61
|
+
agent_type?: string;
|
|
62
|
+
agent_transcript_path?: string;
|
|
63
|
+
subagent_session_id?: string;
|
|
64
|
+
subagent_transcript_path?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface DerivedHookState {
|
|
68
|
+
state: SessionState;
|
|
69
|
+
hasAttention: boolean;
|
|
70
|
+
attentionKind: AttentionKind;
|
|
71
|
+
activeTool?: string;
|
|
72
|
+
toolName?: string;
|
|
73
|
+
toolInputSummary?: string;
|
|
74
|
+
toolResultStatus?: ToolResultStatus;
|
|
75
|
+
summary?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function resolveHooksDir(): string {
|
|
81
|
+
const envConfig = process.env["SCRYLOG_CONFIG"];
|
|
82
|
+
if (envConfig && existsSync(envConfig)) {
|
|
83
|
+
try {
|
|
84
|
+
const raw = JSON.parse(readFileSync(envConfig, "utf8")) as Record<string, unknown>;
|
|
85
|
+
const claude = raw["claude"] as Record<string, unknown> | undefined;
|
|
86
|
+
if (typeof claude?.["hooksDir"] === "string") return claude["hooksDir"];
|
|
87
|
+
} catch {
|
|
88
|
+
// fall through to default
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return getHooksDir("claude-hooks");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveDaemonPort(): number {
|
|
95
|
+
const envConfig = process.env["SCRYLOG_CONFIG"];
|
|
96
|
+
if (envConfig && existsSync(envConfig)) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(readFileSync(envConfig, "utf8")) as Record<string, unknown>;
|
|
99
|
+
const api = raw["api"] as Record<string, unknown> | undefined;
|
|
100
|
+
if (typeof api?.["port"] === "number") return api["port"] as number;
|
|
101
|
+
} catch {
|
|
102
|
+
// fall through to default
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return 7788;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function tryHttpDelivery(payload: HookStateFile & { sessionId: string }): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
const port = resolveDaemonPort();
|
|
111
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/webhook/claude`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify(payload),
|
|
115
|
+
signal: AbortSignal.timeout(1500),
|
|
116
|
+
});
|
|
117
|
+
return res.ok;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Transcript inspection ─────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function inferPermissionModeFromTranscript(transcriptPath?: string): string | undefined {
|
|
126
|
+
if (!transcriptPath) return undefined;
|
|
127
|
+
try {
|
|
128
|
+
const content = readFileSync(transcriptPath, "utf8");
|
|
129
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
130
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
131
|
+
let entry: Record<string, unknown>;
|
|
132
|
+
try {
|
|
133
|
+
entry = JSON.parse(lines[i]!) as Record<string, unknown>;
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (entry["type"] !== "permission-mode") continue;
|
|
138
|
+
const mode = entry["permissionMode"];
|
|
139
|
+
if (typeof mode === "string" && mode.length > 0) return mode;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Ignore transcript read issues
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function summarizeToolInput(toolName?: string, input?: Record<string, unknown>): string | undefined {
|
|
148
|
+
if (!toolName) return undefined;
|
|
149
|
+
const command = typeof input?.["command"] === "string" ? input["command"].slice(0, 120) : undefined;
|
|
150
|
+
if (command) return command;
|
|
151
|
+
const filePath = typeof input?.["file_path"] === "string" ? input["file_path"] : undefined;
|
|
152
|
+
if (filePath) return filePath;
|
|
153
|
+
const description = typeof input?.["description"] === "string" ? input["description"] : undefined;
|
|
154
|
+
if (description) return description.slice(0, 120);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summarizeText(text?: string): string | undefined {
|
|
159
|
+
if (!text) return undefined;
|
|
160
|
+
const first = text.trim().split("\n")[0] ?? "";
|
|
161
|
+
return first.length > 0 ? first.slice(0, 120) : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isQuestion(text?: string): boolean {
|
|
165
|
+
if (!text) return false;
|
|
166
|
+
const trimmed = text.trim();
|
|
167
|
+
return /\?/.test(trimmed) || /^(would you|do you|can you|should i|which|what|how|please confirm|shall i|want me to)/i.test(trimmed);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isInterruptedRequestText(text: string): boolean {
|
|
171
|
+
return /^\[Request interrupted by user(?: for tool use)?\]$/i.test(text.trim());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function classifyAttention(message?: string, title?: string): AttentionKind {
|
|
175
|
+
const text = `${title ?? ""} ${message ?? ""}`.trim();
|
|
176
|
+
if (!text) return "turn";
|
|
177
|
+
if (/(approval|approve|permission|confirm|proceed|allow)/i.test(text)) return "permission";
|
|
178
|
+
if (/[?]/.test(text) || /^(would you|do you|can you|should i|which|what|how|shall i|want me to)/i.test(text)) {
|
|
179
|
+
return "question";
|
|
180
|
+
}
|
|
181
|
+
return "turn";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function inferStopState(payload: ClaudeHookPayload): SessionState {
|
|
185
|
+
if (isQuestion(payload.last_assistant_message)) return "waiting-input";
|
|
186
|
+
return "idle";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── State derivation ──────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
interface PreviousState {
|
|
192
|
+
state?: SessionState;
|
|
193
|
+
hasAttention?: boolean;
|
|
194
|
+
attentionKind?: AttentionKind;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function deriveHookState(event: string, payload: ClaudeHookPayload, previous?: PreviousState | null): DerivedHookState {
|
|
198
|
+
const toolInputSummary = summarizeToolInput(payload.tool_name, payload.tool_input);
|
|
199
|
+
switch (event) {
|
|
200
|
+
case "SessionStart":
|
|
201
|
+
return {
|
|
202
|
+
state: "idle",
|
|
203
|
+
hasAttention: false,
|
|
204
|
+
attentionKind: "none",
|
|
205
|
+
summary: payload.source ? `Claude session ${payload.source}` : "Claude session started",
|
|
206
|
+
};
|
|
207
|
+
case "UserPromptSubmit":
|
|
208
|
+
return {
|
|
209
|
+
state: "working",
|
|
210
|
+
hasAttention: false,
|
|
211
|
+
attentionKind: "none",
|
|
212
|
+
summary: summarizeText(payload.prompt) ?? "Claude is working",
|
|
213
|
+
};
|
|
214
|
+
case "PreToolUse":
|
|
215
|
+
return {
|
|
216
|
+
state: "working",
|
|
217
|
+
hasAttention: false,
|
|
218
|
+
attentionKind: "none",
|
|
219
|
+
activeTool: payload.tool_name,
|
|
220
|
+
toolName: payload.tool_name,
|
|
221
|
+
toolInputSummary,
|
|
222
|
+
toolResultStatus: "started",
|
|
223
|
+
summary: payload.tool_name ? `Using ${payload.tool_name}${toolInputSummary ? `: ${toolInputSummary}` : ""}` : undefined,
|
|
224
|
+
};
|
|
225
|
+
case "PermissionRequest":
|
|
226
|
+
return {
|
|
227
|
+
state: "waiting-input",
|
|
228
|
+
hasAttention: true,
|
|
229
|
+
attentionKind: "permission",
|
|
230
|
+
activeTool: payload.tool_name,
|
|
231
|
+
toolName: payload.tool_name,
|
|
232
|
+
toolInputSummary,
|
|
233
|
+
toolResultStatus: "blocked",
|
|
234
|
+
summary: payload.tool_name ? `Permission required for ${payload.tool_name}${toolInputSummary ? `: ${toolInputSummary}` : ""}` : "Permission required",
|
|
235
|
+
};
|
|
236
|
+
case "PermissionDenied":
|
|
237
|
+
return {
|
|
238
|
+
state: "working",
|
|
239
|
+
hasAttention: false,
|
|
240
|
+
attentionKind: "none",
|
|
241
|
+
activeTool: payload.tool_name,
|
|
242
|
+
toolName: payload.tool_name,
|
|
243
|
+
toolInputSummary,
|
|
244
|
+
toolResultStatus: "denied",
|
|
245
|
+
summary: payload.reason?.slice(0, 120) ?? (payload.tool_name ? `Permission denied for ${payload.tool_name}` : "Permission denied"),
|
|
246
|
+
};
|
|
247
|
+
case "PostToolUse":
|
|
248
|
+
return {
|
|
249
|
+
state: "working",
|
|
250
|
+
hasAttention: false,
|
|
251
|
+
attentionKind: "none",
|
|
252
|
+
activeTool: payload.tool_name,
|
|
253
|
+
toolName: payload.tool_name,
|
|
254
|
+
toolInputSummary,
|
|
255
|
+
toolResultStatus: typeof payload.tool_response === "object" && payload.tool_response !== null && "is_error" in (payload.tool_response as Record<string, unknown>)
|
|
256
|
+
? ((payload.tool_response as Record<string, unknown>)["is_error"] === true ? "error" : "completed")
|
|
257
|
+
: "completed",
|
|
258
|
+
summary: payload.tool_name ? `Using ${payload.tool_name}${toolInputSummary ? `: ${toolInputSummary}` : ""}` : undefined,
|
|
259
|
+
};
|
|
260
|
+
case "PostToolUseFailure":
|
|
261
|
+
return {
|
|
262
|
+
state: payload.is_interrupt ? "idle" : "working",
|
|
263
|
+
hasAttention: false,
|
|
264
|
+
attentionKind: "none",
|
|
265
|
+
activeTool: payload.tool_name,
|
|
266
|
+
toolName: payload.tool_name,
|
|
267
|
+
toolInputSummary,
|
|
268
|
+
toolResultStatus: "error",
|
|
269
|
+
summary: payload.error?.slice(0, 120) ?? (payload.tool_name ? `Failed ${payload.tool_name}` : "Tool failed"),
|
|
270
|
+
};
|
|
271
|
+
case "PostToolBatch":
|
|
272
|
+
return {
|
|
273
|
+
state: "working",
|
|
274
|
+
hasAttention: false,
|
|
275
|
+
attentionKind: "none",
|
|
276
|
+
summary: "Processing tool results",
|
|
277
|
+
};
|
|
278
|
+
case "Stop": {
|
|
279
|
+
const state = inferStopState(payload);
|
|
280
|
+
return {
|
|
281
|
+
state,
|
|
282
|
+
hasAttention: state === "waiting-input",
|
|
283
|
+
attentionKind: state === "waiting-input" ? "question" : "none",
|
|
284
|
+
summary: summarizeText(payload.last_assistant_message),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
case "StopFailure":
|
|
288
|
+
return {
|
|
289
|
+
state: "error",
|
|
290
|
+
hasAttention: true,
|
|
291
|
+
attentionKind: "turn",
|
|
292
|
+
summary: summarizeText(payload.last_assistant_message) ?? payload.error_details?.slice(0, 120) ?? payload.error?.slice(0, 120),
|
|
293
|
+
};
|
|
294
|
+
case "CwdChanged":
|
|
295
|
+
return {
|
|
296
|
+
state: previous?.state ?? "idle",
|
|
297
|
+
hasAttention: previous?.hasAttention ?? false,
|
|
298
|
+
attentionKind: previous?.attentionKind ?? "none",
|
|
299
|
+
};
|
|
300
|
+
case "SubagentStart":
|
|
301
|
+
return {
|
|
302
|
+
state: "working",
|
|
303
|
+
hasAttention: false,
|
|
304
|
+
attentionKind: "none",
|
|
305
|
+
summary: payload.agent_type ? `Subagent ${payload.agent_type}` : "Subagent started",
|
|
306
|
+
};
|
|
307
|
+
case "SubagentStop":
|
|
308
|
+
return {
|
|
309
|
+
state: "idle",
|
|
310
|
+
hasAttention: false,
|
|
311
|
+
attentionKind: "none",
|
|
312
|
+
summary: summarizeText(payload.last_assistant_message),
|
|
313
|
+
};
|
|
314
|
+
case "SessionEnd":
|
|
315
|
+
return {
|
|
316
|
+
state: "idle",
|
|
317
|
+
hasAttention: false,
|
|
318
|
+
attentionKind: "none",
|
|
319
|
+
summary: payload.reason ? `Claude session ended: ${payload.reason}` : "Claude session ended",
|
|
320
|
+
};
|
|
321
|
+
case "Notification":
|
|
322
|
+
return {
|
|
323
|
+
state: previous?.state ?? "idle",
|
|
324
|
+
hasAttention: true,
|
|
325
|
+
attentionKind: classifyAttention(payload.message, payload.title),
|
|
326
|
+
summary: summarizeText(payload.message ?? payload.title),
|
|
327
|
+
};
|
|
328
|
+
default:
|
|
329
|
+
return {
|
|
330
|
+
state: previous?.state ?? "idle",
|
|
331
|
+
hasAttention: previous?.hasAttention ?? false,
|
|
332
|
+
attentionKind: previous?.attentionKind ?? "none",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function deriveSummary(event: string, payload: ClaudeHookPayload): string | undefined {
|
|
338
|
+
if (event === "UserPromptSubmit" && payload.prompt) {
|
|
339
|
+
return summarizeText(payload.prompt);
|
|
340
|
+
}
|
|
341
|
+
if (event === "SessionStart" && payload.source) {
|
|
342
|
+
return `Claude session ${payload.source}`;
|
|
343
|
+
}
|
|
344
|
+
if (event === "PermissionRequest" && payload.tool_name) {
|
|
345
|
+
const target = summarizeToolInput(payload.tool_name, payload.tool_input);
|
|
346
|
+
return `Permission required for ${payload.tool_name}${target ? `: ${target}` : ""}`;
|
|
347
|
+
}
|
|
348
|
+
if (event === "PermissionDenied") {
|
|
349
|
+
return payload.reason?.slice(0, 120);
|
|
350
|
+
}
|
|
351
|
+
if ((event === "PreToolUse" || event === "PostToolUse") && payload.tool_name) {
|
|
352
|
+
const cmd = (payload.tool_input?.["command"] as string | undefined)?.slice(0, 60);
|
|
353
|
+
return cmd ? `Using ${payload.tool_name}: ${cmd}` : `Using ${payload.tool_name}`;
|
|
354
|
+
}
|
|
355
|
+
if (event === "PostToolUseFailure") {
|
|
356
|
+
return payload.error?.slice(0, 120);
|
|
357
|
+
}
|
|
358
|
+
if (event === "Stop" || event === "StopFailure" || event === "SubagentStop") {
|
|
359
|
+
return summarizeText(payload.last_assistant_message);
|
|
360
|
+
}
|
|
361
|
+
if (event === "SessionEnd" && payload.reason) {
|
|
362
|
+
return `Claude session ended: ${payload.reason}`;
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Tmux integration ──────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
function setTmuxPaneOption(sessionId: string): void {
|
|
370
|
+
const tmuxPane = process.env["TMUX_PANE"];
|
|
371
|
+
if (!tmuxPane) return;
|
|
372
|
+
|
|
373
|
+
const result = spawnSync("tmux", ["set-option", "-p", "-t", tmuxPane, "@claude-session", sessionId], {
|
|
374
|
+
stdio: "ignore",
|
|
375
|
+
timeout: 2000,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (result.error || result.status !== 0) {
|
|
379
|
+
// Non-fatal — tmux may not be available or the pane might have closed
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function clearTmuxPaneOption(): void {
|
|
384
|
+
const tmuxPane = process.env["TMUX_PANE"];
|
|
385
|
+
if (!tmuxPane) return;
|
|
386
|
+
|
|
387
|
+
spawnSync("tmux", ["set-option", "-up", "-t", tmuxPane, "@claude-session"], {
|
|
388
|
+
stdio: "ignore",
|
|
389
|
+
timeout: 2000,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── File writing ──────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
interface HookStateFile {
|
|
396
|
+
eventType: string;
|
|
397
|
+
eventTime: number;
|
|
398
|
+
hookSeq: number;
|
|
399
|
+
state: SessionState;
|
|
400
|
+
hasAttention: boolean;
|
|
401
|
+
attentionKind: AttentionKind;
|
|
402
|
+
summary?: string;
|
|
403
|
+
activeTool?: string;
|
|
404
|
+
toolName?: string;
|
|
405
|
+
toolInputSummary?: string;
|
|
406
|
+
toolResultStatus?: ToolResultStatus;
|
|
407
|
+
permissionMode?: string;
|
|
408
|
+
startSource?: string;
|
|
409
|
+
endReason?: string;
|
|
410
|
+
errorType?: string;
|
|
411
|
+
agentId?: string;
|
|
412
|
+
agentType?: string;
|
|
413
|
+
transcriptPath?: string;
|
|
414
|
+
currentPath?: string;
|
|
415
|
+
lastActivity: number;
|
|
416
|
+
parentSessionId?: string;
|
|
417
|
+
detectionSource: "claude-hook";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function readExistingHookState(hooksDir: string, sessionId: string): HookStateFile | null {
|
|
421
|
+
const path = join(hooksDir, `${sessionId}.json`);
|
|
422
|
+
if (!existsSync(path)) return null;
|
|
423
|
+
try {
|
|
424
|
+
return JSON.parse(readFileSync(path, "utf8")) as HookStateFile;
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function writeHookFile(hooksDir: string, sessionId: string, data: HookStateFile): void {
|
|
431
|
+
ensureHooksDirSafe(hooksDir);
|
|
432
|
+
const targetPath = join(hooksDir, `${sessionId}.json`);
|
|
433
|
+
const tmpPath = join(hooksDir, `${sessionId}.json.tmp`);
|
|
434
|
+
writeFileSync(tmpPath, JSON.stringify(data), "utf8");
|
|
435
|
+
try {
|
|
436
|
+
renameSync(tmpPath, targetPath);
|
|
437
|
+
} catch {
|
|
438
|
+
writeFileSync(targetPath, JSON.stringify(data), "utf8");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
async function main(): Promise<void> {
|
|
445
|
+
let raw = "";
|
|
446
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
447
|
+
raw += new TextDecoder().decode(chunk);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let payload: ClaudeHookPayload;
|
|
451
|
+
try {
|
|
452
|
+
payload = JSON.parse(raw) as ClaudeHookPayload;
|
|
453
|
+
} catch {
|
|
454
|
+
process.exit(0); // Malformed input — exit cleanly so Claude Code is not disrupted
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const event = payload.hook_event_name ?? "";
|
|
458
|
+
const sessionId = payload.session_id;
|
|
459
|
+
if (!sessionId) process.exit(0);
|
|
460
|
+
|
|
461
|
+
const hooksDir = resolveHooksDir();
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
|
|
464
|
+
if (event === "SessionEnd") {
|
|
465
|
+
await tryHttpDelivery({
|
|
466
|
+
sessionId,
|
|
467
|
+
eventType: event,
|
|
468
|
+
eventTime: now,
|
|
469
|
+
hookSeq: 0,
|
|
470
|
+
state: "idle",
|
|
471
|
+
hasAttention: false,
|
|
472
|
+
attentionKind: "none",
|
|
473
|
+
summary: deriveSummary(event, payload),
|
|
474
|
+
...(payload.reason ? { endReason: payload.reason } : {}),
|
|
475
|
+
...(payload.transcript_path ? { transcriptPath: payload.transcript_path } : {}),
|
|
476
|
+
currentPath: payload.cwd,
|
|
477
|
+
lastActivity: now,
|
|
478
|
+
detectionSource: "claude-hook",
|
|
479
|
+
});
|
|
480
|
+
try {
|
|
481
|
+
rmSync(join(hooksDir, `${sessionId}.json`), { force: true });
|
|
482
|
+
} catch {
|
|
483
|
+
// Ignore cleanup issues
|
|
484
|
+
}
|
|
485
|
+
clearTmuxPaneOption();
|
|
486
|
+
process.exit(0);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (event === "SubagentStart" || event === "SubagentStop") {
|
|
490
|
+
const subTranscriptPath = payload.agent_transcript_path ?? payload.subagent_transcript_path;
|
|
491
|
+
const subId = payload.agent_id ?? payload.subagent_session_id ?? (subTranscriptPath ? basename(subTranscriptPath, ".jsonl") : undefined);
|
|
492
|
+
|
|
493
|
+
if (subId) {
|
|
494
|
+
const previous = readExistingHookState(hooksDir, subId);
|
|
495
|
+
const derived = deriveHookState(event, payload, previous);
|
|
496
|
+
const subBaseSeq = previous?.hookSeq ?? 0;
|
|
497
|
+
const subNowMs = Math.floor(Date.now() / 100);
|
|
498
|
+
const subHookSeq = Math.max(subBaseSeq + 1, subNowMs);
|
|
499
|
+
const subData: HookStateFile = {
|
|
500
|
+
eventType: event,
|
|
501
|
+
eventTime: now,
|
|
502
|
+
hookSeq: subHookSeq,
|
|
503
|
+
state: derived.state,
|
|
504
|
+
hasAttention: derived.hasAttention,
|
|
505
|
+
attentionKind: derived.attentionKind,
|
|
506
|
+
...(derived.summary ?? deriveSummary(event, payload) ? { summary: (derived.summary ?? deriveSummary(event, payload)) } : {}),
|
|
507
|
+
...(payload.permission_mode ? { permissionMode: payload.permission_mode } : {}),
|
|
508
|
+
...(payload.agent_id ? { agentId: payload.agent_id } : {}),
|
|
509
|
+
...(payload.agent_type ? { agentType: payload.agent_type } : {}),
|
|
510
|
+
...(subTranscriptPath ? { transcriptPath: subTranscriptPath } : {}),
|
|
511
|
+
currentPath: payload.cwd,
|
|
512
|
+
lastActivity: now,
|
|
513
|
+
parentSessionId: `claude:${sessionId}`,
|
|
514
|
+
detectionSource: "claude-hook",
|
|
515
|
+
};
|
|
516
|
+
writeHookFile(hooksDir, subId, subData);
|
|
517
|
+
await tryHttpDelivery({ sessionId: subId, ...subData });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (event === "SubagentStop") {
|
|
521
|
+
// Update the parent session metadata (summary, lastActivity) but preserve
|
|
522
|
+
// its state — SubagentStop is a sub-agent event, not a parent state change.
|
|
523
|
+
// deriveHookState("SubagentStop") returns "idle" which would incorrectly
|
|
524
|
+
// set the parent to idle while it may still be working.
|
|
525
|
+
const parentPrevious = readExistingHookState(hooksDir, sessionId);
|
|
526
|
+
const parentBaseSeq = parentPrevious?.hookSeq ?? 0;
|
|
527
|
+
const parentNowMs = Math.floor(Date.now() / 100);
|
|
528
|
+
const parentHookSeq = Math.max(parentBaseSeq + 1, parentNowMs);
|
|
529
|
+
const parentSummary = deriveSummary("SubagentStop", payload);
|
|
530
|
+
const parentData: HookStateFile = {
|
|
531
|
+
eventType: "SubagentStop",
|
|
532
|
+
eventTime: now,
|
|
533
|
+
hookSeq: parentHookSeq,
|
|
534
|
+
state: parentPrevious?.state ?? "working",
|
|
535
|
+
hasAttention: parentPrevious?.hasAttention ?? false,
|
|
536
|
+
attentionKind: parentPrevious?.attentionKind ?? "none",
|
|
537
|
+
...(parentSummary ? { summary: parentSummary } : {}),
|
|
538
|
+
currentPath: payload.new_cwd ?? payload.cwd,
|
|
539
|
+
lastActivity: now,
|
|
540
|
+
detectionSource: "claude-hook",
|
|
541
|
+
};
|
|
542
|
+
writeHookFile(hooksDir, sessionId, parentData);
|
|
543
|
+
await tryHttpDelivery({ sessionId, ...parentData });
|
|
544
|
+
// Ensure tmux pane option is refreshed so the parent session remains linked
|
|
545
|
+
setTmuxPaneOption(sessionId);
|
|
546
|
+
process.exit(0);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// For all other events: write state for the current session
|
|
551
|
+
const previous = readExistingHookState(hooksDir, sessionId);
|
|
552
|
+
const derived = deriveHookState(event, payload, previous);
|
|
553
|
+
const summary = derived.summary ?? deriveSummary(event, payload) ?? previous?.summary;
|
|
554
|
+
|
|
555
|
+
// Use a timestamp-based hookSeq to avoid collisions when multiple hooks
|
|
556
|
+
// fire concurrently. The previous seq is used as a floor to ensure monotonicity.
|
|
557
|
+
const baseSeq = previous?.hookSeq ?? 0;
|
|
558
|
+
const nowMs = Math.floor(Date.now() / 100);
|
|
559
|
+
const hookSeq = Math.max(baseSeq + 1, nowMs);
|
|
560
|
+
|
|
561
|
+
const hookData: HookStateFile = {
|
|
562
|
+
eventType: event,
|
|
563
|
+
eventTime: now,
|
|
564
|
+
hookSeq,
|
|
565
|
+
state: derived.state,
|
|
566
|
+
hasAttention: derived.hasAttention,
|
|
567
|
+
attentionKind: derived.attentionKind,
|
|
568
|
+
...(summary ? { summary } : {}),
|
|
569
|
+
...(derived.activeTool ? { activeTool: derived.activeTool } : {}),
|
|
570
|
+
...(derived.toolName ? { toolName: derived.toolName } : {}),
|
|
571
|
+
...(derived.toolInputSummary ? { toolInputSummary: derived.toolInputSummary } : {}),
|
|
572
|
+
...(derived.toolResultStatus ? { toolResultStatus: derived.toolResultStatus } : {}),
|
|
573
|
+
...(payload.permission_mode ? { permissionMode: payload.permission_mode } : {}),
|
|
574
|
+
...(payload.source ? { startSource: payload.source } : {}),
|
|
575
|
+
...(payload.reason ? { endReason: payload.reason } : {}),
|
|
576
|
+
...(payload.error ? { errorType: payload.error } : {}),
|
|
577
|
+
...(payload.agent_id ? { agentId: payload.agent_id } : {}),
|
|
578
|
+
...(payload.agent_type ? { agentType: payload.agent_type } : {}),
|
|
579
|
+
...(payload.transcript_path ? { transcriptPath: payload.transcript_path } : {}),
|
|
580
|
+
currentPath: payload.new_cwd ?? payload.cwd,
|
|
581
|
+
lastActivity: now,
|
|
582
|
+
detectionSource: "claude-hook",
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
writeHookFile(hooksDir, sessionId, hookData);
|
|
586
|
+
await tryHttpDelivery({ sessionId, ...hookData });
|
|
587
|
+
|
|
588
|
+
// Update tmux binding for the main session, especially on SessionStart or resume.
|
|
589
|
+
if (event === "SessionStart" || event === "UserPromptSubmit" || event === "PermissionRequest" || event === "PreToolUse") {
|
|
590
|
+
setTmuxPaneOption(sessionId);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
process.exit(0);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
main().catch(() => process.exit(0));
|