@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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. 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));