@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,661 @@
|
|
|
1
|
+
import { existsSync, statSync, unwatchFile, watch, watchFile } from "node:fs";
|
|
2
|
+
import type { FSWatcher } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import type { ScrylogConfig } from "../../../shared/config";
|
|
5
|
+
import type { SessionModel } from "../../../shared/types";
|
|
6
|
+
import type { DaemonStore } from "../../store";
|
|
7
|
+
import type { Collector } from "../../scheduler";
|
|
8
|
+
import type { Logger } from "../../../shared/logger";
|
|
9
|
+
import { NOOP_LOGGER } from "../../../shared/logger";
|
|
10
|
+
import { ensureHooksDirSafe } from "../../../shared/platform";
|
|
11
|
+
import type { GitCollector } from "../git";
|
|
12
|
+
import { parseJsonlFile } from "./jsonl";
|
|
13
|
+
import { extractActiveSpawnedSubagents, extractMeta, extractState, extractSummary } from "./transcript";
|
|
14
|
+
import { CodexHookFileData, isValidHookData, readHookData, isHookNewer, getHookTime, mergeHookIntoModel, mergeCodexMeta } from "./hooks";
|
|
15
|
+
import { discoverTranscripts, getCodexDir } from "./discovery";
|
|
16
|
+
|
|
17
|
+
interface WatchedTranscript {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
path: string;
|
|
20
|
+
lastModified: number;
|
|
21
|
+
size: number;
|
|
22
|
+
processedLastModified?: number;
|
|
23
|
+
processedSize?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface TranscriptEntry {
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
payload?: {
|
|
30
|
+
type?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
role?: string;
|
|
33
|
+
content?: Array<{ type: string; text?: string }>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type TerminalTranscriptEvent = "task_complete" | "turn_aborted";
|
|
38
|
+
|
|
39
|
+
/** Detects a request_user_input function call in the transcript.
|
|
40
|
+
* This is the ONLY case where the transcript may override hook state,
|
|
41
|
+
* because Codex does not fire a hook for this specific tool. */
|
|
42
|
+
function hasRequestUserInput(entries: TranscriptEntry[]): boolean {
|
|
43
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
44
|
+
const entry = entries[i];
|
|
45
|
+
if (entry?.type === "response_item" && entry.payload?.type === "function_call" && entry.payload?.name === "request_user_input") {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
// Skip turn_aborted placeholders — they are not real user responses
|
|
49
|
+
if (entry?.type === "response_item" && entry.payload?.type === "message" && entry.payload?.role === "user") {
|
|
50
|
+
const text = entry.payload.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
51
|
+
if (text.includes("<turn_aborted>")) continue;
|
|
52
|
+
}
|
|
53
|
+
// Stop at the first meaningful response_item we hit backwards
|
|
54
|
+
if (entry?.type === "response_item") break;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasPendingProposedPlan(entries: TranscriptEntry[]): boolean {
|
|
60
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
61
|
+
const entry = entries[i];
|
|
62
|
+
if (entry?.type !== "response_item") continue;
|
|
63
|
+
|
|
64
|
+
if (entry.payload?.type === "message" && entry.payload?.role === "user") {
|
|
65
|
+
const text = entry.payload.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
66
|
+
if (text.includes("<turn_aborted>")) continue;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (entry.payload?.type !== "message" || entry.payload.role !== "assistant") {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = entry.payload.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
75
|
+
return text.trimStart().startsWith("<proposed_plan>");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasAnsweredRequestUserInputWork(entries: TranscriptEntry[]): boolean {
|
|
82
|
+
let sawWorkAfterAnswer = false;
|
|
83
|
+
let sawRequestUserInputOutput = false;
|
|
84
|
+
|
|
85
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
86
|
+
const entry = entries[i];
|
|
87
|
+
if (entry?.type !== "response_item" && entry?.type !== "event_msg") continue;
|
|
88
|
+
|
|
89
|
+
if (entry.type === "response_item") {
|
|
90
|
+
const payloadType = entry.payload?.type;
|
|
91
|
+
if (payloadType === "message" && entry.payload?.role === "user") {
|
|
92
|
+
const text = entry.payload.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
93
|
+
if (text.includes("<turn_aborted>")) continue;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (payloadType === "function_call" && entry.payload?.name === "request_user_input") {
|
|
98
|
+
return sawRequestUserInputOutput && sawWorkAfterAnswer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (payloadType === "function_call_output") {
|
|
102
|
+
sawRequestUserInputOutput = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (payloadType === "reasoning" || payloadType === "function_call") {
|
|
107
|
+
sawWorkAfterAnswer = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (payloadType === "message" && entry.payload?.role === "assistant") {
|
|
112
|
+
sawWorkAfterAnswer = true;
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (entry.payload?.type === "agent_message" || entry.payload?.type === "item_completed") {
|
|
118
|
+
sawWorkAfterAnswer = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
|
|
126
|
+
if (!existing) return model;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
...model,
|
|
130
|
+
source: existing.source,
|
|
131
|
+
project: existing.project ?? model.project,
|
|
132
|
+
tmux: existing.tmux ?? model.tmux,
|
|
133
|
+
legacyPaneId: existing.legacyPaneId ?? model.legacyPaneId,
|
|
134
|
+
processTree: existing.processTree ?? model.processTree,
|
|
135
|
+
processChain: existing.processChain ?? model.processChain,
|
|
136
|
+
sample: existing.sample ?? model.sample,
|
|
137
|
+
toolReasons: existing.toolReasons ?? model.toolReasons,
|
|
138
|
+
stateReasons: existing.stateReasons ?? model.stateReasons,
|
|
139
|
+
stableForMs: existing.stableForMs ?? model.stableForMs,
|
|
140
|
+
currentCommand: existing.currentCommand ?? model.currentCommand,
|
|
141
|
+
currentPath: existing.currentPath ?? model.currentPath,
|
|
142
|
+
panePid: existing.panePid ?? model.panePid,
|
|
143
|
+
tty: existing.tty ?? model.tty,
|
|
144
|
+
paneTitle: existing.paneTitle ?? model.paneTitle,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findTerminalEventAfterHook(entries: TranscriptEntry[], hookEventTime?: number): TerminalTranscriptEvent | null {
|
|
149
|
+
if (!hookEventTime) return null;
|
|
150
|
+
|
|
151
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
152
|
+
const entry = entries[i];
|
|
153
|
+
if (entry?.type !== "event_msg") continue;
|
|
154
|
+
const eventType = entry.payload?.type;
|
|
155
|
+
if (eventType !== "task_complete" && eventType !== "turn_aborted") continue;
|
|
156
|
+
|
|
157
|
+
const timestamp = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
|
|
158
|
+
if (!Number.isFinite(timestamp) || timestamp <= hookEventTime) return null;
|
|
159
|
+
return eventType;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class CodexCollector implements Collector {
|
|
166
|
+
name = "codex";
|
|
167
|
+
private store?: DaemonStore;
|
|
168
|
+
private config?: ScrylogConfig;
|
|
169
|
+
private logger: Logger = NOOP_LOGGER;
|
|
170
|
+
private watched = new Map<string, WatchedTranscript>();
|
|
171
|
+
private hookWatched = new Set<string>();
|
|
172
|
+
private hookDirWatcher?: FSWatcher;
|
|
173
|
+
private lastProcessedSeq = new Map<string, number>();
|
|
174
|
+
private discoveryTimer?: ReturnType<typeof setInterval>;
|
|
175
|
+
private fallbackTimer?: ReturnType<typeof setInterval>;
|
|
176
|
+
|
|
177
|
+
constructor(private readonly gitCollector?: GitCollector) {}
|
|
178
|
+
|
|
179
|
+
async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
|
|
180
|
+
this.store = store;
|
|
181
|
+
this.config = config;
|
|
182
|
+
this.logger = logger;
|
|
183
|
+
|
|
184
|
+
const codexDir = getCodexDir(config);
|
|
185
|
+
logger.info("codex", "Collector started", { event: "collector.started", collector: this.name, codexDir, hooksEnabled: config.codex.hooksEnabled });
|
|
186
|
+
|
|
187
|
+
if (config.codex.hooksEnabled) {
|
|
188
|
+
this.startHooksDirWatcher(config.codex.hooksDir);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.run();
|
|
192
|
+
this.discoveryTimer = setInterval(() => this.run(), 30_000);
|
|
193
|
+
this.fallbackTimer = setInterval(() => this.rereadWatched(), 5_000);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async stop(): Promise<void> {
|
|
197
|
+
if (this.discoveryTimer) clearInterval(this.discoveryTimer);
|
|
198
|
+
if (this.fallbackTimer) clearInterval(this.fallbackTimer);
|
|
199
|
+
|
|
200
|
+
for (const { path } of this.watched.values()) {
|
|
201
|
+
try { unwatchFile(path); } catch { /* ignore */ }
|
|
202
|
+
}
|
|
203
|
+
this.watched.clear();
|
|
204
|
+
|
|
205
|
+
for (const hookFile of this.hookWatched) {
|
|
206
|
+
try { unwatchFile(hookFile); } catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
this.hookWatched.clear();
|
|
209
|
+
this.lastProcessedSeq.clear();
|
|
210
|
+
|
|
211
|
+
if (this.hookDirWatcher) {
|
|
212
|
+
try { this.hookDirWatcher.close(); } catch { /* ignore */ }
|
|
213
|
+
this.hookDirWatcher = undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.logger.info("codex", "Collector stopped", { event: "collector.stopped", collector: this.name });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Internal ───────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
private run(): void {
|
|
222
|
+
const store = this.store!;
|
|
223
|
+
const config = this.config!;
|
|
224
|
+
const codexDir = getCodexDir(config);
|
|
225
|
+
|
|
226
|
+
// Build set of session IDs that are hook-managed, so discoverTranscripts
|
|
227
|
+
// can include their transcripts even past the 24h cutoff.
|
|
228
|
+
const hookManagedIds = new Set<string>();
|
|
229
|
+
for (const session of store.getSessions()) {
|
|
230
|
+
if (session.detectionSource === "codex-hook" && session.tool === "codex") {
|
|
231
|
+
hookManagedIds.add(session.codex?.sessionId ?? session.id.replace(/^codex:/, ""));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const transcripts = discoverTranscripts(codexDir, hookManagedIds);
|
|
236
|
+
|
|
237
|
+
this.logger.debug("codex", "Discovery cycle", {
|
|
238
|
+
event: "collector.discovery_completed",
|
|
239
|
+
collector: this.name,
|
|
240
|
+
codexDir,
|
|
241
|
+
transcripts: transcripts.length,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
for (const transcript of transcripts) {
|
|
245
|
+
this.processTranscript(transcript);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const trackedPaths = new Set(transcripts.map((t) => t.path));
|
|
249
|
+
for (const path of trackedPaths) {
|
|
250
|
+
if (!this.watched.has(path)) {
|
|
251
|
+
const transcript = transcripts.find((t) => t.path === path);
|
|
252
|
+
if (!transcript) continue;
|
|
253
|
+
this.watched.set(path, transcript);
|
|
254
|
+
this.logger.debug("codex", "Watching transcript", { sessionId: transcript.sessionId, path });
|
|
255
|
+
watchFile(path, { interval: 2000 }, () => {
|
|
256
|
+
const t = this.watched.get(path);
|
|
257
|
+
if (t) this.processTranscript(t);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const [path, transcript] of this.watched) {
|
|
263
|
+
if (!trackedPaths.has(path)) {
|
|
264
|
+
try { unwatchFile(path); } catch { /* ignore */ }
|
|
265
|
+
this.watched.delete(path);
|
|
266
|
+
this.logger.debug("codex", "Stopped watching transcript", { sessionId: transcript.sessionId });
|
|
267
|
+
// Don't remove hook-managed sessions — they have an authoritative lifecycle
|
|
268
|
+
// managed by hooks. Only remove transcript-sourced sessions.
|
|
269
|
+
const existingSession = store.getSession(`codex:${transcript.sessionId}`);
|
|
270
|
+
if (existingSession?.detectionSource !== "codex-hook") {
|
|
271
|
+
store.removeSession(`codex:${transcript.sessionId}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private processTranscript(transcript: import("./discovery").DiscoveredTranscript): void {
|
|
280
|
+
const store = this.store!;
|
|
281
|
+
const config = this.config!;
|
|
282
|
+
const transcriptMaxBytes = config.retention?.transcriptMaxBytes;
|
|
283
|
+
const entries = parseJsonlFile(transcript.path, transcriptMaxBytes);
|
|
284
|
+
const meta = extractMeta(entries);
|
|
285
|
+
const sessionId = meta.sessionId || transcript.sessionId;
|
|
286
|
+
const storeId = `codex:${sessionId}`;
|
|
287
|
+
const existingSession = store.getSession(storeId);
|
|
288
|
+
const watched = this.watched.get(transcript.path);
|
|
289
|
+
if (
|
|
290
|
+
watched?.processedLastModified === transcript.lastModified
|
|
291
|
+
&& watched.processedSize === transcript.size
|
|
292
|
+
&& existingSession
|
|
293
|
+
) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const transcriptState = extractState(entries);
|
|
298
|
+
const transcriptSummary = extractSummary(entries);
|
|
299
|
+
|
|
300
|
+
// Transcript-based state: detect questions and agent messages
|
|
301
|
+
let model: SessionModel = {
|
|
302
|
+
id: `codex:${transcript.sessionId}`,
|
|
303
|
+
source: "standalone",
|
|
304
|
+
tool: "codex",
|
|
305
|
+
state: transcriptState,
|
|
306
|
+
summary: transcriptSummary ?? (meta.sessionId ? `Codex session` : "Codex session"),
|
|
307
|
+
lastActivity: transcript.lastModified,
|
|
308
|
+
codex: {
|
|
309
|
+
sessionId,
|
|
310
|
+
transcriptPath: transcript.path,
|
|
311
|
+
...(meta.model ? { model: meta.model } : {}),
|
|
312
|
+
...(meta.forkedFromId ? { forkedFromId: meta.forkedFromId } : {}),
|
|
313
|
+
...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
|
|
314
|
+
...(meta.agentNickname ? { agentNickname: meta.agentNickname } : {}),
|
|
315
|
+
...(meta.agentRole ? { agentRole: meta.agentRole } : {}),
|
|
316
|
+
},
|
|
317
|
+
...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
|
|
318
|
+
hasAttention: transcriptState === "waiting-input",
|
|
319
|
+
detectionSource: "codex-transcript",
|
|
320
|
+
};
|
|
321
|
+
model.id = storeId;
|
|
322
|
+
if (meta.agentNickname && !transcriptSummary) {
|
|
323
|
+
model.summary = `Codex sub-agent ${meta.agentNickname}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Hooks are the authoritative source of state for Codex sessions.
|
|
327
|
+
// Transcripts enrich metadata (model, subagents, transcriptPath) but
|
|
328
|
+
// never override state, hasAttention, or activeTool.
|
|
329
|
+
//
|
|
330
|
+
// EXCEPTION: Codex does not fire a hook for request_user_input. When the
|
|
331
|
+
// transcript detects this specific tool call, it may override the hook state
|
|
332
|
+
// to waiting-input. This is the ONLY transcript override permitted.
|
|
333
|
+
const transcriptHasRequestUserInput = transcriptState === "waiting-input" && hasRequestUserInput(entries);
|
|
334
|
+
const transcriptHasProposedPlan = hasPendingProposedPlan(entries);
|
|
335
|
+
const transcriptIndicatesWaitingInput = transcriptHasRequestUserInput || transcriptHasProposedPlan;
|
|
336
|
+
const transcriptIndicatesWorking = !transcriptIndicatesWaitingInput && hasAnsweredRequestUserInputWork(entries);
|
|
337
|
+
const transcriptOverridesHook = existingSession?.detectionSource === "codex-hook" && transcriptIndicatesWaitingInput;
|
|
338
|
+
const terminalTranscriptEvent = existingSession?.detectionSource === "codex-hook"
|
|
339
|
+
? findTerminalEventAfterHook(entries, existingSession.codex?.hookEventTime)
|
|
340
|
+
: null;
|
|
341
|
+
const terminalOverridesHook = Boolean(terminalTranscriptEvent && !transcriptIndicatesWaitingInput && !transcriptIndicatesWorking);
|
|
342
|
+
|
|
343
|
+
// Check for hook data first — hooks are always authoritative when present.
|
|
344
|
+
let hookDataFromFile: Partial<SessionModel> | null = null;
|
|
345
|
+
if (config.codex.hooksEnabled) {
|
|
346
|
+
const fileHookData = readHookData(config.codex.hooksDir, sessionId) ?? readHookData(config.codex.hooksDir, transcript.sessionId);
|
|
347
|
+
if (fileHookData && getHookTime(fileHookData) >= transcript.lastModified && isHookNewer(existingSession, fileHookData)) {
|
|
348
|
+
hookDataFromFile = fileHookData;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (hookDataFromFile) {
|
|
353
|
+
// Hook data exists and is authoritative — migrate to codex-hook if needed.
|
|
354
|
+
model = mergeHookIntoModel(model, hookDataFromFile, transcript.path);
|
|
355
|
+
model = preserveLocationMetadata(model, existingSession);
|
|
356
|
+
} else if (existingSession?.detectionSource === "codex-hook") {
|
|
357
|
+
// Hook-managed session with no fresh hook data. Transcript may override
|
|
358
|
+
// only for request_user_input (the one case Codex does not fire a hook).
|
|
359
|
+
const resolvedState = transcriptOverridesHook ? "waiting-input"
|
|
360
|
+
: transcriptIndicatesWorking ? "working"
|
|
361
|
+
: terminalOverridesHook ? "idle"
|
|
362
|
+
: existingSession.state;
|
|
363
|
+
const resolvedHasAttention = transcriptOverridesHook ? true
|
|
364
|
+
: transcriptIndicatesWorking ? false
|
|
365
|
+
: terminalOverridesHook ? false
|
|
366
|
+
: existingSession.hasAttention;
|
|
367
|
+
const resolvedSummary = transcriptOverridesHook
|
|
368
|
+
? (extractSummary(entries) ?? existingSession.summary)
|
|
369
|
+
: (existingSession.summary || model.summary);
|
|
370
|
+
|
|
371
|
+
model = {
|
|
372
|
+
...model,
|
|
373
|
+
state: resolvedState,
|
|
374
|
+
hasAttention: resolvedHasAttention,
|
|
375
|
+
summary: resolvedSummary,
|
|
376
|
+
activeTool: (terminalOverridesHook || transcriptIndicatesWorking) ? undefined : existingSession.activeTool,
|
|
377
|
+
detectionSource: "codex-hook",
|
|
378
|
+
codex: mergeCodexMeta(
|
|
379
|
+
{
|
|
380
|
+
sessionId: existingSession.codex?.sessionId ?? transcript.sessionId,
|
|
381
|
+
transcriptPath: existingSession.codex?.transcriptPath || transcript.path,
|
|
382
|
+
...(meta.model ? { model: meta.model } : {}),
|
|
383
|
+
...(meta.forkedFromId ? { forkedFromId: meta.forkedFromId } : {}),
|
|
384
|
+
...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
|
|
385
|
+
...(meta.agentNickname ? { agentNickname: meta.agentNickname } : {}),
|
|
386
|
+
...(meta.agentRole ? { agentRole: meta.agentRole } : {}),
|
|
387
|
+
...(transcriptOverridesHook ? { attentionKind: "question" } : {}),
|
|
388
|
+
...(terminalOverridesHook ? { attentionKind: "none" } : {}),
|
|
389
|
+
},
|
|
390
|
+
existingSession.codex ?? {},
|
|
391
|
+
),
|
|
392
|
+
...(existingSession.parentSessionId ?? meta.parentSessionId ? { parentSessionId: existingSession.parentSessionId ?? meta.parentSessionId } : {}),
|
|
393
|
+
lastActivity: existingSession.lastActivity,
|
|
394
|
+
};
|
|
395
|
+
model = preserveLocationMetadata(model, existingSession);
|
|
396
|
+
} else if (existingSession?.detectionSource === "codex-transcript" && transcriptIndicatesWaitingInput) {
|
|
397
|
+
// Transcript-only session where the agent is waiting for user input.
|
|
398
|
+
// Codex did not fire a hook for this state, so the transcript is the
|
|
399
|
+
// authoritative source. When a hook finally arrives, detectionSource
|
|
400
|
+
// migrates to codex-hook automatically via the hook handlers.
|
|
401
|
+
model = {
|
|
402
|
+
...model,
|
|
403
|
+
state: "waiting-input",
|
|
404
|
+
hasAttention: true,
|
|
405
|
+
summary: extractSummary(entries) ?? model.summary,
|
|
406
|
+
detectionSource: "codex-transcript",
|
|
407
|
+
codex: mergeCodexMeta(
|
|
408
|
+
{
|
|
409
|
+
sessionId: existingSession.codex?.sessionId ?? transcript.sessionId,
|
|
410
|
+
transcriptPath: existingSession.codex?.transcriptPath || transcript.path,
|
|
411
|
+
...(meta.model ? { model: meta.model } : {}),
|
|
412
|
+
...(meta.forkedFromId ? { forkedFromId: meta.forkedFromId } : {}),
|
|
413
|
+
...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
|
|
414
|
+
...(meta.agentNickname ? { agentNickname: meta.agentNickname } : {}),
|
|
415
|
+
...(meta.agentRole ? { agentRole: meta.agentRole } : {}),
|
|
416
|
+
attentionKind: "question",
|
|
417
|
+
},
|
|
418
|
+
existingSession.codex ?? {},
|
|
419
|
+
),
|
|
420
|
+
...(existingSession.parentSessionId ?? meta.parentSessionId ? { parentSessionId: existingSession.parentSessionId ?? meta.parentSessionId } : {}),
|
|
421
|
+
};
|
|
422
|
+
model = preserveLocationMetadata(model, existingSession);
|
|
423
|
+
} else if (!existingSession && transcriptIndicatesWaitingInput) {
|
|
424
|
+
model = {
|
|
425
|
+
...model,
|
|
426
|
+
state: "waiting-input",
|
|
427
|
+
hasAttention: true,
|
|
428
|
+
codex: {
|
|
429
|
+
sessionId,
|
|
430
|
+
transcriptPath: transcript.path,
|
|
431
|
+
...(model.codex ?? {}),
|
|
432
|
+
attentionKind: "question",
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (existingSession && existingSession.detectionSource !== "codex-hook" && existingSession.detectionSource !== "codex-transcript") {
|
|
438
|
+
model = preserveLocationMetadata(model, existingSession);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Resolve git project info from currentPath (populated by hooks)
|
|
442
|
+
if (this.gitCollector && model.currentPath && !model.project) {
|
|
443
|
+
const project = this.gitCollector.resolve(model.currentPath);
|
|
444
|
+
if (project) model = { ...model, project };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
store.upsertSession(model);
|
|
448
|
+
this.upsertActiveSpawnedSubagents(model, extractActiveSpawnedSubagents(entries));
|
|
449
|
+
const currentWatched = this.watched.get(transcript.path);
|
|
450
|
+
if (currentWatched) {
|
|
451
|
+
this.watched.set(transcript.path, {
|
|
452
|
+
...currentWatched,
|
|
453
|
+
lastModified: transcript.lastModified,
|
|
454
|
+
size: transcript.size,
|
|
455
|
+
processedLastModified: transcript.lastModified,
|
|
456
|
+
processedSize: transcript.size,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
this.logger.debug("codex", "Processed transcript", { event: "transcript.processed", sessionId, state: model.state, detectionSource: model.detectionSource, path: transcript.path });
|
|
460
|
+
|
|
461
|
+
this.syncChildSessionIds();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private upsertActiveSpawnedSubagents(parent: SessionModel, spawned: ReturnType<typeof extractActiveSpawnedSubagents>): void {
|
|
465
|
+
for (const subagent of spawned) {
|
|
466
|
+
const id = `codex:${subagent.agentId}`;
|
|
467
|
+
const existing = this.store!.getSession(id);
|
|
468
|
+
if (existing) {
|
|
469
|
+
if (existing.state === "working" || existing.state === "waiting-input") continue;
|
|
470
|
+
this.store!.upsertSession({
|
|
471
|
+
...existing,
|
|
472
|
+
state: "working",
|
|
473
|
+
lastActivity: Math.max(existing.lastActivity, parent.lastActivity),
|
|
474
|
+
parentSessionId: existing.parentSessionId ?? parent.id,
|
|
475
|
+
codex: mergeCodexMeta(
|
|
476
|
+
{
|
|
477
|
+
sessionId: existing.codex?.sessionId ?? subagent.agentId,
|
|
478
|
+
transcriptPath: existing.codex?.transcriptPath ?? "",
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
...(existing.codex ?? {}),
|
|
482
|
+
agentId: existing.codex?.agentId ?? subagent.agentId,
|
|
483
|
+
...(subagent.agentType ? { agentType: subagent.agentType } : {}),
|
|
484
|
+
...(subagent.nickname ? { agentNickname: subagent.nickname } : {}),
|
|
485
|
+
parentSessionId: existing.parentSessionId ?? parent.id,
|
|
486
|
+
},
|
|
487
|
+
),
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
this.store!.upsertSession({
|
|
492
|
+
id,
|
|
493
|
+
source: "standalone",
|
|
494
|
+
tool: "codex",
|
|
495
|
+
state: "working",
|
|
496
|
+
summary: subagent.nickname ? `Codex sub-agent ${subagent.nickname}` : "Codex sub-agent",
|
|
497
|
+
lastActivity: parent.lastActivity,
|
|
498
|
+
parentSessionId: parent.id,
|
|
499
|
+
detectionSource: "codex-transcript",
|
|
500
|
+
codex: {
|
|
501
|
+
sessionId: subagent.agentId,
|
|
502
|
+
transcriptPath: "",
|
|
503
|
+
agentId: subagent.agentId,
|
|
504
|
+
...(subagent.agentType ? { agentType: subagent.agentType } : {}),
|
|
505
|
+
...(subagent.nickname ? { agentNickname: subagent.nickname } : {}),
|
|
506
|
+
parentSessionId: parent.id,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Re-process all watched transcripts (fallback polling) */
|
|
513
|
+
private rereadWatched(): void {
|
|
514
|
+
for (const transcript of this.watched.values()) {
|
|
515
|
+
try {
|
|
516
|
+
const stat = statSync(transcript.path);
|
|
517
|
+
this.processTranscript({ ...transcript, lastModified: stat.mtimeMs, size: stat.size });
|
|
518
|
+
} catch {
|
|
519
|
+
// File may have been removed
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Watch the hooks directory for new/changed files so state updates are near-immediate */
|
|
525
|
+
private startHooksDirWatcher(hooksDir: string): void {
|
|
526
|
+
try {
|
|
527
|
+
ensureHooksDirSafe(hooksDir);
|
|
528
|
+
} catch {
|
|
529
|
+
// Directory may be unwritable or fail ownership/permission validation — handled below
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (!existsSync(hooksDir)) {
|
|
533
|
+
this.logger.debug("codex", "Hooks dir not found — skipping watcher", { hooksDir });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Watch for new files appearing in the hooks dir
|
|
538
|
+
try {
|
|
539
|
+
this.hookDirWatcher = watch(hooksDir, (eventType, filename) => {
|
|
540
|
+
if (!filename || !filename.endsWith(".json")) return;
|
|
541
|
+
const sessionId = basename(filename, ".json");
|
|
542
|
+
this.onHookFileChanged(hooksDir, sessionId, filename);
|
|
543
|
+
});
|
|
544
|
+
} catch {
|
|
545
|
+
this.logger.debug("codex", "Could not watch hooks dir", { hooksDir });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Called when a hook file is created or modified */
|
|
550
|
+
private onHookFileChanged(hooksDir: string, sessionId: string, filename: string): void {
|
|
551
|
+
const hookFile = join(hooksDir, filename);
|
|
552
|
+
|
|
553
|
+
// Start watching this specific file if not already
|
|
554
|
+
if (!this.hookWatched.has(hookFile)) {
|
|
555
|
+
this.hookWatched.add(hookFile);
|
|
556
|
+
watchFile(hookFile, { interval: 500 }, () => {
|
|
557
|
+
this.onHookFileChanged(hooksDir, sessionId, filename);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Skip duplicate processing: if the hookSeq hasn't changed, this is a
|
|
562
|
+
// redundant event from the directory watcher and per-file watchFile.
|
|
563
|
+
const hookData = readHookData(hooksDir, sessionId);
|
|
564
|
+
if (!hookData) {
|
|
565
|
+
// Hook file was deleted (Stop with stop_hook_active=false) — clean up watcher
|
|
566
|
+
this.unwatchHookFile(hookFile);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const incomingSeq = hookData.codex?.hookSeq ?? 0;
|
|
570
|
+
const lastSeq = this.lastProcessedSeq.get(sessionId) ?? -1;
|
|
571
|
+
if (incomingSeq <= lastSeq) return;
|
|
572
|
+
this.lastProcessedSeq.set(sessionId, incomingSeq);
|
|
573
|
+
|
|
574
|
+
this.logger.debug("codex", "Hook received — refreshing session", { event: "integration.event_received", sessionId, hookSeq: incomingSeq });
|
|
575
|
+
|
|
576
|
+
// Find the corresponding transcript to re-process
|
|
577
|
+
const transcriptEntry = [...this.watched.values()].find((t) => t.sessionId === sessionId);
|
|
578
|
+
if (transcriptEntry) {
|
|
579
|
+
try {
|
|
580
|
+
const stat = statSync(transcriptEntry.path);
|
|
581
|
+
this.processTranscript({ ...transcriptEntry, lastModified: stat.mtimeMs, size: stat.size });
|
|
582
|
+
} catch {
|
|
583
|
+
// Transcript may have been removed
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The transcript may not be watched yet (e.g. a session created via hook before transcript is discovered).
|
|
589
|
+
// hookData was already read above for dedup — use it to create a minimal session entry.
|
|
590
|
+
|
|
591
|
+
const existing = this.store!.getSession(`codex:${sessionId}`);
|
|
592
|
+
if (existing && !isHookNewer(existing, hookData)) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (existing) {
|
|
596
|
+
// Merge hook data into existing session
|
|
597
|
+
const mergedSummary = hookData.summary ?? existing.summary;
|
|
598
|
+
this.store!.upsertSession({
|
|
599
|
+
...existing,
|
|
600
|
+
...hookData,
|
|
601
|
+
id: existing.id,
|
|
602
|
+
source: existing.source,
|
|
603
|
+
summary: mergedSummary,
|
|
604
|
+
lastActivity: (hookData.lastActivity as number | undefined) ?? existing.lastActivity,
|
|
605
|
+
activeTool: hookData.activeTool ?? existing.activeTool,
|
|
606
|
+
codex: mergeCodexMeta(
|
|
607
|
+
{
|
|
608
|
+
sessionId,
|
|
609
|
+
transcriptPath: (hookData.codex?.transcriptPath || existing.codex?.transcriptPath || "") as string,
|
|
610
|
+
},
|
|
611
|
+
hookData.codex ?? {},
|
|
612
|
+
),
|
|
613
|
+
});
|
|
614
|
+
this.logger.debug("codex", "Hook merged into session (no transcript)", { event: "session.upsert_from_hook", sessionId, state: hookData.state });
|
|
615
|
+
} else {
|
|
616
|
+
// Minimal stub for a session discovered only via hook
|
|
617
|
+
const currentPath = hookData.currentPath as string | undefined;
|
|
618
|
+
const project = (this.gitCollector && currentPath)
|
|
619
|
+
? this.gitCollector.resolve(currentPath) ?? undefined
|
|
620
|
+
: undefined;
|
|
621
|
+
const stub: SessionModel = {
|
|
622
|
+
id: `codex:${sessionId}`,
|
|
623
|
+
source: "standalone",
|
|
624
|
+
tool: "codex",
|
|
625
|
+
state: (hookData.state as SessionModel["state"]) ?? "idle",
|
|
626
|
+
summary: hookData.summary ?? "Codex session",
|
|
627
|
+
lastActivity: (hookData.lastActivity as number) ?? Date.now(),
|
|
628
|
+
hasAttention: hookData.hasAttention,
|
|
629
|
+
activeTool: hookData.activeTool,
|
|
630
|
+
currentPath,
|
|
631
|
+
project,
|
|
632
|
+
parentSessionId: hookData.parentSessionId,
|
|
633
|
+
detectionSource: "codex-hook",
|
|
634
|
+
codex: mergeCodexMeta(
|
|
635
|
+
{
|
|
636
|
+
sessionId,
|
|
637
|
+
transcriptPath: (hookData.codex?.transcriptPath ?? "") as string,
|
|
638
|
+
},
|
|
639
|
+
hookData.codex ?? {},
|
|
640
|
+
),
|
|
641
|
+
};
|
|
642
|
+
this.store!.upsertSession(stub);
|
|
643
|
+
this.logger.debug("codex", "Session created from hook", { event: "session.upsert_from_hook", sessionId, parentSessionId: hookData.parentSessionId, state: stub.state });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
this.syncChildSessionIds();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Stop watching a per-file watcher and clean up tracking state */
|
|
650
|
+
private unwatchHookFile(hookFile: string): void {
|
|
651
|
+
if (this.hookWatched.has(hookFile)) {
|
|
652
|
+
this.hookWatched.delete(hookFile);
|
|
653
|
+
try { unwatchFile(hookFile); } catch { /* ignore */ }
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Rebuild childSessionIds on parent sessions after any session upsert */
|
|
658
|
+
private syncChildSessionIds(): void {
|
|
659
|
+
this.store!.syncChildSessionIds();
|
|
660
|
+
}
|
|
661
|
+
}
|