@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,517 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* scrylog-codex-hook — Codex CLI lifecycle hook handler.
|
|
4
|
+
*
|
|
5
|
+
* Codex CLI calls this binary at each lifecycle event (SessionStart, PreToolUse,
|
|
6
|
+
* PermissionRequest, PostToolUse, UserPromptSubmit, SubagentStart,
|
|
7
|
+
* SubagentStop, Stop) with a JSON payload on stdin.
|
|
8
|
+
* This script writes a state file for the CodexCollector to pick up and,
|
|
9
|
+
* when running inside tmux, sets the @codex-session pane option so the
|
|
10
|
+
* TmuxCollector can link the pane to the Codex session.
|
|
11
|
+
*
|
|
12
|
+
* Configure in ~/.codex/hooks.json via:
|
|
13
|
+
* scrylog codex hooks install
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { writeFileSync, renameSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { getHooksDir, ensureHooksDirSafe } from "../shared/platform";
|
|
20
|
+
import type { SessionState } from "../shared/types";
|
|
21
|
+
|
|
22
|
+
type AttentionKind = "permission" | "question" | "turn" | "none";
|
|
23
|
+
type ToolResultStatus = "started" | "completed" | "error" | "blocked" | "denied" | "unknown";
|
|
24
|
+
|
|
25
|
+
// ── Hook payload ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface CodexHookPayload {
|
|
28
|
+
session_id: string;
|
|
29
|
+
transcript_path?: string;
|
|
30
|
+
cwd?: string;
|
|
31
|
+
permission_mode?: string;
|
|
32
|
+
hook_event_name?: string;
|
|
33
|
+
model?: string;
|
|
34
|
+
prompt?: string;
|
|
35
|
+
stop_hook_active?: boolean;
|
|
36
|
+
last_assistant_message?: string;
|
|
37
|
+
source?: string;
|
|
38
|
+
turn_id?: string;
|
|
39
|
+
// Subagents
|
|
40
|
+
agent_id?: string;
|
|
41
|
+
agent_type?: string;
|
|
42
|
+
agent_transcript_path?: string;
|
|
43
|
+
// PreToolUse / PermissionRequest / PostToolUse
|
|
44
|
+
tool_name?: string;
|
|
45
|
+
tool_input?: Record<string, unknown>;
|
|
46
|
+
tool_response?: unknown;
|
|
47
|
+
tool_use_id?: string;
|
|
48
|
+
tool_input_description?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface DerivedHookState {
|
|
52
|
+
state: SessionState;
|
|
53
|
+
hasAttention: boolean;
|
|
54
|
+
attentionKind: AttentionKind;
|
|
55
|
+
activeTool?: string;
|
|
56
|
+
toolName?: string;
|
|
57
|
+
toolInputSummary?: string;
|
|
58
|
+
toolResultStatus?: ToolResultStatus;
|
|
59
|
+
summary?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function resolveHooksDir(): string {
|
|
65
|
+
const envConfig = process.env["SCRYLOG_CONFIG"];
|
|
66
|
+
if (envConfig && existsSync(envConfig)) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = JSON.parse(readFileSync(envConfig, "utf8")) as Record<string, unknown>;
|
|
69
|
+
const codex = raw["codex"] as Record<string, unknown> | undefined;
|
|
70
|
+
if (typeof codex?.["hooksDir"] === "string") return codex["hooksDir"];
|
|
71
|
+
} catch {
|
|
72
|
+
// fall through to default
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return getHooksDir("codex-hooks");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveDaemonPort(): number {
|
|
79
|
+
const envConfig = process.env["SCRYLOG_CONFIG"];
|
|
80
|
+
if (envConfig && existsSync(envConfig)) {
|
|
81
|
+
try {
|
|
82
|
+
const raw = JSON.parse(readFileSync(envConfig, "utf8")) as Record<string, unknown>;
|
|
83
|
+
const api = raw["api"] as Record<string, unknown> | undefined;
|
|
84
|
+
if (typeof api?.["port"] === "number") return api["port"] as number;
|
|
85
|
+
} catch {
|
|
86
|
+
// fall through to default
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return 7788;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function tryHttpDelivery(payload: HookStateFile & { sessionId: string }): Promise<boolean> {
|
|
93
|
+
try {
|
|
94
|
+
const port = resolveDaemonPort();
|
|
95
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/webhook/codex`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify(payload),
|
|
99
|
+
signal: AbortSignal.timeout(1500),
|
|
100
|
+
});
|
|
101
|
+
return res.ok;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function summarizeToolInput(toolName?: string, input?: Record<string, unknown>): string | undefined {
|
|
110
|
+
if (!toolName) return undefined;
|
|
111
|
+
const command = typeof input?.["command"] === "string" ? input["command"].slice(0, 120) : undefined;
|
|
112
|
+
if (command) return command;
|
|
113
|
+
const filePath = typeof input?.["file_path"] === "string" ? input["file_path"] : undefined;
|
|
114
|
+
if (filePath) return filePath;
|
|
115
|
+
const description = typeof input?.["description"] === "string" ? input["description"] : undefined;
|
|
116
|
+
if (description) return description.slice(0, 120);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function summarizeText(text?: string): string | undefined {
|
|
121
|
+
if (!text) return undefined;
|
|
122
|
+
const first = text.trim().split("\n")[0] ?? "";
|
|
123
|
+
return first.length > 0 ? first.slice(0, 120) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isQuestion(text?: string): boolean {
|
|
127
|
+
if (!text) return false;
|
|
128
|
+
const trimmed = text.trim();
|
|
129
|
+
return /^(would you|do you|can you|should i|please confirm|shall i|want me to|implement this plan|implemente this plan|apply this plan|continue with this plan|proceed with this plan)\b/i.test(trimmed)
|
|
130
|
+
|| /^(quieres que|debo|puedo|confirmas|por favor confirma|contin[uú]o|procedo)\b/i.test(trimmed)
|
|
131
|
+
|| /^¿?(qu[eé]|cu[aá]l|cu[aá]les|c[oó]mo|d[oó]nde|cu[aá]ndo|por qu[eé])(?:\s|\?|$)/i.test(trimmed);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function deriveStopState(payload: CodexHookPayload): SessionState {
|
|
135
|
+
if (payload.stop_hook_active === true) return "working";
|
|
136
|
+
if (isQuestion(payload.last_assistant_message)) return "waiting-input";
|
|
137
|
+
return "idle";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── State derivation ──────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function deriveHookState(event: string, payload: CodexHookPayload, previous?: PreviousState | null): DerivedHookState {
|
|
143
|
+
const toolInputSummary = summarizeToolInput(payload.tool_name, payload.tool_input);
|
|
144
|
+
switch (event) {
|
|
145
|
+
case "SessionStart":
|
|
146
|
+
return {
|
|
147
|
+
state: "idle",
|
|
148
|
+
hasAttention: false,
|
|
149
|
+
attentionKind: "none",
|
|
150
|
+
summary: payload.source ? `Codex session ${payload.source}` : "Codex session started",
|
|
151
|
+
};
|
|
152
|
+
case "UserPromptSubmit":
|
|
153
|
+
return {
|
|
154
|
+
state: "working",
|
|
155
|
+
hasAttention: false,
|
|
156
|
+
attentionKind: "none",
|
|
157
|
+
summary: summarizeText(payload.prompt) ?? "Codex is working",
|
|
158
|
+
};
|
|
159
|
+
case "PreToolUse":
|
|
160
|
+
return {
|
|
161
|
+
state: "working",
|
|
162
|
+
hasAttention: false,
|
|
163
|
+
attentionKind: "none",
|
|
164
|
+
activeTool: payload.tool_name,
|
|
165
|
+
toolName: payload.tool_name,
|
|
166
|
+
toolInputSummary,
|
|
167
|
+
toolResultStatus: "started",
|
|
168
|
+
summary: payload.tool_name ? `Using ${payload.tool_name}${toolInputSummary ? `: ${toolInputSummary}` : ""}` : undefined,
|
|
169
|
+
};
|
|
170
|
+
case "PermissionRequest":
|
|
171
|
+
return {
|
|
172
|
+
state: "waiting-input",
|
|
173
|
+
hasAttention: true,
|
|
174
|
+
attentionKind: "permission",
|
|
175
|
+
activeTool: payload.tool_name,
|
|
176
|
+
toolName: payload.tool_name,
|
|
177
|
+
toolInputSummary,
|
|
178
|
+
toolResultStatus: "blocked",
|
|
179
|
+
summary: payload.tool_name ? `Permission required for ${payload.tool_name}${toolInputSummary ? `: ${toolInputSummary}` : ""}` : "Permission required",
|
|
180
|
+
};
|
|
181
|
+
case "PostToolUse": {
|
|
182
|
+
// request_user_input means the agent asked a question and is waiting for
|
|
183
|
+
// the user to respond — this is waiting-input, not working.
|
|
184
|
+
if (payload.tool_name === "request_user_input") {
|
|
185
|
+
const question = typeof payload.tool_input?.["question"] === "string"
|
|
186
|
+
? (payload.tool_input["question"] as string).slice(0, 120)
|
|
187
|
+
: undefined;
|
|
188
|
+
return {
|
|
189
|
+
state: "waiting-input",
|
|
190
|
+
hasAttention: true,
|
|
191
|
+
attentionKind: "question",
|
|
192
|
+
activeTool: payload.tool_name,
|
|
193
|
+
toolName: payload.tool_name,
|
|
194
|
+
toolInputSummary: question,
|
|
195
|
+
toolResultStatus: "completed",
|
|
196
|
+
summary: question ? `Waiting for input: ${question}` : "Waiting for user input",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
state: "working",
|
|
201
|
+
hasAttention: false,
|
|
202
|
+
attentionKind: "none",
|
|
203
|
+
activeTool: payload.tool_name,
|
|
204
|
+
toolName: payload.tool_name,
|
|
205
|
+
toolInputSummary,
|
|
206
|
+
toolResultStatus: typeof payload.tool_response === "object" && payload.tool_response !== null && "is_error" in (payload.tool_response as Record<string, unknown>)
|
|
207
|
+
? ((payload.tool_response as Record<string, unknown>)["is_error"] === true ? "error" : "completed")
|
|
208
|
+
: "completed",
|
|
209
|
+
summary: payload.tool_name ? `Using ${payload.tool_name}${toolInputSummary ? `: ${toolInputSummary}` : ""}` : undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
case "SubagentStart":
|
|
213
|
+
return {
|
|
214
|
+
state: "working",
|
|
215
|
+
hasAttention: false,
|
|
216
|
+
attentionKind: "none",
|
|
217
|
+
summary: payload.agent_type ? `Codex sub-agent ${payload.agent_type}` : "Codex sub-agent",
|
|
218
|
+
};
|
|
219
|
+
case "SubagentStop": {
|
|
220
|
+
const state = deriveStopState(payload);
|
|
221
|
+
return {
|
|
222
|
+
state,
|
|
223
|
+
hasAttention: state === "waiting-input",
|
|
224
|
+
attentionKind: state === "waiting-input" ? "question" : "none",
|
|
225
|
+
summary: summarizeText(payload.last_assistant_message) ?? (payload.agent_type ? `Codex sub-agent ${payload.agent_type}` : "Codex sub-agent"),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
case "Stop": {
|
|
229
|
+
const state = deriveStopState(payload);
|
|
230
|
+
return {
|
|
231
|
+
state,
|
|
232
|
+
hasAttention: state === "waiting-input",
|
|
233
|
+
attentionKind: state === "waiting-input" ? "question" : "none",
|
|
234
|
+
summary: summarizeText(payload.last_assistant_message),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
return {
|
|
239
|
+
state: previous?.state ?? "idle",
|
|
240
|
+
hasAttention: previous?.hasAttention ?? false,
|
|
241
|
+
attentionKind: previous?.attentionKind ?? "none",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function deriveSummary(event: string, payload: CodexHookPayload): string | undefined {
|
|
247
|
+
if (event === "UserPromptSubmit" && payload.prompt) {
|
|
248
|
+
return summarizeText(payload.prompt);
|
|
249
|
+
}
|
|
250
|
+
if (event === "SessionStart" && payload.source) {
|
|
251
|
+
return `Codex session ${payload.source}`;
|
|
252
|
+
}
|
|
253
|
+
if (event === "PermissionRequest" && payload.tool_name) {
|
|
254
|
+
const target = summarizeToolInput(payload.tool_name, payload.tool_input);
|
|
255
|
+
return `Permission required for ${payload.tool_name}${target ? `: ${target}` : ""}`;
|
|
256
|
+
}
|
|
257
|
+
if ((event === "PreToolUse" || event === "PostToolUse") && payload.tool_name) {
|
|
258
|
+
if (event === "PostToolUse" && payload.tool_name === "request_user_input") {
|
|
259
|
+
const question = typeof payload.tool_input?.["question"] === "string"
|
|
260
|
+
? (payload.tool_input["question"] as string).slice(0, 120)
|
|
261
|
+
: undefined;
|
|
262
|
+
return question ? `Waiting for input: ${question}` : "Waiting for user input";
|
|
263
|
+
}
|
|
264
|
+
const cmd = (payload.tool_input?.["command"] as string | undefined)?.slice(0, 60);
|
|
265
|
+
return cmd ? `Using ${payload.tool_name}: ${cmd}` : `Using ${payload.tool_name}`;
|
|
266
|
+
}
|
|
267
|
+
if (event === "Stop") {
|
|
268
|
+
return summarizeText(payload.last_assistant_message);
|
|
269
|
+
}
|
|
270
|
+
if (event === "SubagentStart" || event === "SubagentStop") {
|
|
271
|
+
return summarizeText(payload.last_assistant_message) ?? (payload.agent_type ? `Codex sub-agent ${payload.agent_type}` : "Codex sub-agent");
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Tmux integration ──────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function setTmuxPaneOption(sessionId: string): void {
|
|
279
|
+
const tmuxPane = process.env["TMUX_PANE"];
|
|
280
|
+
if (!tmuxPane) return;
|
|
281
|
+
|
|
282
|
+
const result = spawnSync("tmux", ["set-option", "-p", "-t", tmuxPane, "@codex-session", sessionId], {
|
|
283
|
+
stdio: "ignore",
|
|
284
|
+
timeout: 2000,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (result.error || result.status !== 0) {
|
|
288
|
+
// Non-fatal — tmux may not be available or the pane might have closed
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clearTmuxPaneOption(): void {
|
|
293
|
+
const tmuxPane = process.env["TMUX_PANE"];
|
|
294
|
+
if (!tmuxPane) return;
|
|
295
|
+
|
|
296
|
+
spawnSync("tmux", ["set-option", "-up", "-t", tmuxPane, "@codex-session"], {
|
|
297
|
+
stdio: "ignore",
|
|
298
|
+
timeout: 2000,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── File writing ──────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
interface PreviousState {
|
|
305
|
+
state?: SessionState;
|
|
306
|
+
hasAttention?: boolean;
|
|
307
|
+
attentionKind?: AttentionKind;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface HookStateFile {
|
|
311
|
+
eventType: string;
|
|
312
|
+
eventTime: number;
|
|
313
|
+
hookSeq: number;
|
|
314
|
+
state: SessionState;
|
|
315
|
+
hasAttention: boolean;
|
|
316
|
+
attentionKind: AttentionKind;
|
|
317
|
+
summary?: string;
|
|
318
|
+
activeTool?: string;
|
|
319
|
+
toolName?: string;
|
|
320
|
+
toolInputSummary?: string;
|
|
321
|
+
toolResultStatus?: ToolResultStatus;
|
|
322
|
+
permissionMode?: string;
|
|
323
|
+
startSource?: string;
|
|
324
|
+
model?: string;
|
|
325
|
+
endReason?: string;
|
|
326
|
+
transcriptPath?: string;
|
|
327
|
+
currentPath?: string;
|
|
328
|
+
turnId?: string;
|
|
329
|
+
agentId?: string;
|
|
330
|
+
agentType?: string;
|
|
331
|
+
lastActivity: number;
|
|
332
|
+
parentSessionId?: string;
|
|
333
|
+
detectionSource: "codex-hook";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function readExistingHookState(hooksDir: string, sessionId: string): HookStateFile | null {
|
|
337
|
+
const path = join(hooksDir, `${sessionId}.json`);
|
|
338
|
+
if (!existsSync(path)) return null;
|
|
339
|
+
try {
|
|
340
|
+
return JSON.parse(readFileSync(path, "utf8")) as HookStateFile;
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function writeHookFile(hooksDir: string, sessionId: string, data: HookStateFile): void {
|
|
347
|
+
ensureHooksDirSafe(hooksDir);
|
|
348
|
+
const targetPath = join(hooksDir, `${sessionId}.json`);
|
|
349
|
+
const tmpPath = join(hooksDir, `${sessionId}.json.tmp`);
|
|
350
|
+
writeFileSync(tmpPath, JSON.stringify(data), "utf8");
|
|
351
|
+
try {
|
|
352
|
+
renameSync(tmpPath, targetPath);
|
|
353
|
+
} catch {
|
|
354
|
+
writeFileSync(targetPath, JSON.stringify(data), "utf8");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
async function main(): Promise<void> {
|
|
361
|
+
let raw = "";
|
|
362
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
363
|
+
raw += new TextDecoder().decode(chunk);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let payload: CodexHookPayload;
|
|
367
|
+
try {
|
|
368
|
+
payload = JSON.parse(raw) as CodexHookPayload;
|
|
369
|
+
} catch {
|
|
370
|
+
process.exit(0); // Malformed input — exit cleanly so Codex CLI is not disrupted
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const event = payload.hook_event_name ?? "";
|
|
374
|
+
const sessionId = payload.session_id;
|
|
375
|
+
if (!sessionId) process.exit(0);
|
|
376
|
+
|
|
377
|
+
const hooksDir = resolveHooksDir();
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
const previous = readExistingHookState(hooksDir, sessionId);
|
|
380
|
+
const previousState: PreviousState | null = previous
|
|
381
|
+
? { state: previous.state, hasAttention: previous.hasAttention, attentionKind: previous.attentionKind }
|
|
382
|
+
: null;
|
|
383
|
+
const baseSeq = previous?.hookSeq ?? 0;
|
|
384
|
+
const nowMs = Math.floor(Date.now() / 100);
|
|
385
|
+
const hookSeq = Math.max(baseSeq + 1, nowMs);
|
|
386
|
+
|
|
387
|
+
if (event === "SubagentStart" || event === "SubagentStop") {
|
|
388
|
+
const childSessionId = payload.agent_id;
|
|
389
|
+
if (!childSessionId) {
|
|
390
|
+
if (event === "SubagentStop") process.stdout.write("{}\n");
|
|
391
|
+
process.exit(0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const childPrevious = readExistingHookState(hooksDir, childSessionId);
|
|
395
|
+
const childPreviousState: PreviousState | null = childPrevious
|
|
396
|
+
? { state: childPrevious.state, hasAttention: childPrevious.hasAttention, attentionKind: childPrevious.attentionKind }
|
|
397
|
+
: null;
|
|
398
|
+
const childBaseSeq = childPrevious?.hookSeq ?? 0;
|
|
399
|
+
const childHookSeq = Math.max(childBaseSeq + 1, hookSeq);
|
|
400
|
+
const derived = deriveHookState(event, payload, childPreviousState);
|
|
401
|
+
const summary = derived.summary ?? deriveSummary(event, payload) ?? childPrevious?.summary;
|
|
402
|
+
const hookData: HookStateFile = {
|
|
403
|
+
eventType: event,
|
|
404
|
+
eventTime: now,
|
|
405
|
+
hookSeq: childHookSeq,
|
|
406
|
+
state: derived.state,
|
|
407
|
+
hasAttention: derived.hasAttention,
|
|
408
|
+
attentionKind: derived.attentionKind,
|
|
409
|
+
...(summary ? { summary } : {}),
|
|
410
|
+
...(payload.permission_mode ? { permissionMode: payload.permission_mode } : {}),
|
|
411
|
+
...(payload.model ? { model: payload.model } : {}),
|
|
412
|
+
transcriptPath: payload.agent_transcript_path ?? childPrevious?.transcriptPath ?? "",
|
|
413
|
+
...(payload.turn_id ? { turnId: payload.turn_id } : {}),
|
|
414
|
+
...(payload.agent_id ? { agentId: payload.agent_id } : {}),
|
|
415
|
+
...(payload.agent_type ? { agentType: payload.agent_type } : {}),
|
|
416
|
+
currentPath: payload.cwd,
|
|
417
|
+
parentSessionId: `codex:${sessionId}`,
|
|
418
|
+
lastActivity: now,
|
|
419
|
+
detectionSource: "codex-hook",
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
writeHookFile(hooksDir, childSessionId, hookData);
|
|
423
|
+
await tryHttpDelivery({ sessionId: childSessionId, ...hookData });
|
|
424
|
+
setTmuxPaneOption(sessionId);
|
|
425
|
+
|
|
426
|
+
if (event === "SubagentStop") {
|
|
427
|
+
process.stdout.write("{}\n");
|
|
428
|
+
}
|
|
429
|
+
process.exit(0);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Stop with stop_hook_active=false signals the end of a turn, not the session itself.
|
|
433
|
+
// We delete the state file but leave the @codex-session pane option so the pane
|
|
434
|
+
// remains linked to the Codex session between turns.
|
|
435
|
+
if (event === "Stop" && payload.stop_hook_active === false) {
|
|
436
|
+
const derived = deriveHookState(event, payload, previousState);
|
|
437
|
+
const summary = derived.summary ?? deriveSummary(event, payload) ?? previous?.summary;
|
|
438
|
+
const hookData: HookStateFile = {
|
|
439
|
+
eventType: event,
|
|
440
|
+
eventTime: now,
|
|
441
|
+
hookSeq,
|
|
442
|
+
state: derived.state,
|
|
443
|
+
hasAttention: derived.hasAttention,
|
|
444
|
+
attentionKind: derived.attentionKind,
|
|
445
|
+
...(summary ? { summary } : {}),
|
|
446
|
+
...(payload.transcript_path ? { transcriptPath: payload.transcript_path } : {}),
|
|
447
|
+
currentPath: payload.cwd,
|
|
448
|
+
lastActivity: now,
|
|
449
|
+
detectionSource: "codex-hook",
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
await tryHttpDelivery({
|
|
453
|
+
sessionId,
|
|
454
|
+
...hookData,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
if (derived.state === "waiting-input") {
|
|
458
|
+
writeHookFile(hooksDir, sessionId, hookData);
|
|
459
|
+
setTmuxPaneOption(sessionId);
|
|
460
|
+
} else {
|
|
461
|
+
try {
|
|
462
|
+
rmSync(join(hooksDir, `${sessionId}.json`), { force: true });
|
|
463
|
+
} catch {
|
|
464
|
+
// Ignore cleanup issues
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Codex expects JSON on stdout for Stop events
|
|
468
|
+
process.stdout.write("{}\n");
|
|
469
|
+
process.exit(0);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// For all other events: write state for the current session
|
|
473
|
+
const derived = deriveHookState(event, payload, previousState);
|
|
474
|
+
const summary = derived.summary ?? deriveSummary(event, payload) ?? previous?.summary;
|
|
475
|
+
|
|
476
|
+
// Use a timestamp-based hookSeq to avoid collisions when multiple hooks
|
|
477
|
+
// fire concurrently. The previous seq is used as a floor to ensure monotonicity.
|
|
478
|
+
|
|
479
|
+
const hookData: HookStateFile = {
|
|
480
|
+
eventType: event,
|
|
481
|
+
eventTime: now,
|
|
482
|
+
hookSeq,
|
|
483
|
+
state: derived.state,
|
|
484
|
+
hasAttention: derived.hasAttention,
|
|
485
|
+
attentionKind: derived.attentionKind,
|
|
486
|
+
...(summary ? { summary } : {}),
|
|
487
|
+
...(derived.activeTool ? { activeTool: derived.activeTool } : {}),
|
|
488
|
+
...(derived.toolName ? { toolName: derived.toolName } : {}),
|
|
489
|
+
...(derived.toolInputSummary ? { toolInputSummary: derived.toolInputSummary } : {}),
|
|
490
|
+
...(derived.toolResultStatus ? { toolResultStatus: derived.toolResultStatus } : {}),
|
|
491
|
+
...(payload.permission_mode ? { permissionMode: payload.permission_mode } : {}),
|
|
492
|
+
...(payload.source ? { startSource: payload.source } : {}),
|
|
493
|
+
...(payload.model ? { model: payload.model } : {}),
|
|
494
|
+
...(payload.transcript_path ? { transcriptPath: payload.transcript_path } : {}),
|
|
495
|
+
...(payload.turn_id ? { turnId: payload.turn_id } : {}),
|
|
496
|
+
currentPath: payload.cwd,
|
|
497
|
+
lastActivity: now,
|
|
498
|
+
detectionSource: "codex-hook",
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
writeHookFile(hooksDir, sessionId, hookData);
|
|
502
|
+
await tryHttpDelivery({ sessionId, ...hookData });
|
|
503
|
+
|
|
504
|
+
// Update tmux binding on session start, prompt, and tool use events
|
|
505
|
+
if (event === "SessionStart" || event === "UserPromptSubmit" || event === "PreToolUse" || event === "PermissionRequest") {
|
|
506
|
+
setTmuxPaneOption(sessionId);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Codex expects JSON on stdout for Stop events
|
|
510
|
+
if (event === "Stop") {
|
|
511
|
+
process.stdout.write("{}\n");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
process.exit(0);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
main().catch(() => process.exit(0));
|