@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,181 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { SessionModel, SessionState } from "../../../shared/types";
|
|
4
|
+
import type { ClaudeSessionMeta } from "../../../shared/types";
|
|
5
|
+
|
|
6
|
+
export interface ClaudeHookFileData {
|
|
7
|
+
eventType?: string;
|
|
8
|
+
eventTime?: number;
|
|
9
|
+
hookSeq?: number;
|
|
10
|
+
state?: SessionState;
|
|
11
|
+
hasAttention?: boolean;
|
|
12
|
+
attentionKind?: "permission" | "question" | "turn" | "none";
|
|
13
|
+
summary?: string;
|
|
14
|
+
activeTool?: string;
|
|
15
|
+
toolName?: string;
|
|
16
|
+
toolInputSummary?: string;
|
|
17
|
+
toolResultStatus?: "started" | "completed" | "error" | "blocked" | "denied" | "unknown";
|
|
18
|
+
permissionMode?: string;
|
|
19
|
+
startSource?: string;
|
|
20
|
+
endReason?: string;
|
|
21
|
+
errorType?: string;
|
|
22
|
+
agentId?: string;
|
|
23
|
+
agentType?: string;
|
|
24
|
+
transcriptPath?: string;
|
|
25
|
+
currentPath?: string;
|
|
26
|
+
lastActivity?: number;
|
|
27
|
+
parentSessionId?: string;
|
|
28
|
+
detectionSource?: "claude-hook";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasClaudePermissionPrompt(sample: SessionModel["sample"] | null | undefined): boolean {
|
|
32
|
+
if (!sample) return false;
|
|
33
|
+
return /Do you want to proceed\?/i.test(sample.lines.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isValidHookData(data: ClaudeHookFileData): boolean {
|
|
37
|
+
return typeof data.eventType === "string"
|
|
38
|
+
&& data.eventType.length > 0
|
|
39
|
+
&& typeof data.eventTime === "number"
|
|
40
|
+
&& Number.isFinite(data.eventTime)
|
|
41
|
+
&& typeof data.hookSeq === "number"
|
|
42
|
+
&& Number.isFinite(data.hookSeq)
|
|
43
|
+
&& data.state !== undefined
|
|
44
|
+
&& typeof data.hasAttention === "boolean"
|
|
45
|
+
&& typeof data.attentionKind === "string";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readHookData(hooksDir: string, sessionId: string): Partial<SessionModel> | null {
|
|
49
|
+
if (!existsSync(hooksDir)) return null;
|
|
50
|
+
const hookFile = join(hooksDir, `${sessionId}.json`);
|
|
51
|
+
if (!existsSync(hookFile)) return null;
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(hookFile, "utf8");
|
|
54
|
+
const data = JSON.parse(raw) as ClaudeHookFileData;
|
|
55
|
+
if (!isValidHookData(data)) return null;
|
|
56
|
+
return {
|
|
57
|
+
...(data.state !== undefined ? { state: data.state } : {}),
|
|
58
|
+
...(data.hasAttention !== undefined ? { hasAttention: data.hasAttention } : {}),
|
|
59
|
+
...(data.summary !== undefined ? { summary: data.summary } : {}),
|
|
60
|
+
...(data.activeTool !== undefined ? { activeTool: data.activeTool } : {}),
|
|
61
|
+
...(data.currentPath !== undefined ? { currentPath: data.currentPath } : {}),
|
|
62
|
+
...(data.lastActivity !== undefined ? { lastActivity: data.lastActivity } : {}),
|
|
63
|
+
...(data.parentSessionId !== undefined ? { parentSessionId: data.parentSessionId } : {}),
|
|
64
|
+
...(data.detectionSource !== undefined ? { detectionSource: data.detectionSource } : {}),
|
|
65
|
+
claude: {
|
|
66
|
+
sessionId,
|
|
67
|
+
transcriptPath: data.transcriptPath ?? "",
|
|
68
|
+
...(data.eventType !== undefined ? { hookEventType: data.eventType } : {}),
|
|
69
|
+
...(data.eventTime !== undefined ? { hookEventTime: data.eventTime } : {}),
|
|
70
|
+
...(data.hookSeq !== undefined ? { hookSeq: data.hookSeq } : {}),
|
|
71
|
+
...(data.permissionMode !== undefined ? { permissionMode: data.permissionMode } : {}),
|
|
72
|
+
...(data.startSource !== undefined ? { startSource: data.startSource } : {}),
|
|
73
|
+
...(data.endReason !== undefined ? { endReason: data.endReason } : {}),
|
|
74
|
+
...(data.errorType !== undefined ? { errorType: data.errorType } : {}),
|
|
75
|
+
...(data.attentionKind !== undefined ? { attentionKind: data.attentionKind } : {}),
|
|
76
|
+
...(data.toolName !== undefined ? { toolName: data.toolName } : {}),
|
|
77
|
+
...(data.toolInputSummary !== undefined ? { toolInputSummary: data.toolInputSummary } : {}),
|
|
78
|
+
...(data.toolResultStatus !== undefined ? { toolResultStatus: data.toolResultStatus } : {}),
|
|
79
|
+
...(data.agentId !== undefined ? { agentId: data.agentId } : {}),
|
|
80
|
+
...(data.agentType !== undefined ? { agentType: data.agentType } : {}),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isHookNewer(existing: SessionModel | undefined, hookData: Partial<SessionModel>): boolean {
|
|
89
|
+
const incomingSeq = hookData.claude?.hookSeq;
|
|
90
|
+
const existingSeq = existing?.claude?.hookSeq;
|
|
91
|
+
if (typeof incomingSeq === "number" && typeof existingSeq === "number") {
|
|
92
|
+
return incomingSeq >= existingSeq;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const incomingTime = hookData.claude?.hookEventTime ?? hookData.lastActivity ?? 0;
|
|
96
|
+
const existingTime = existing?.claude?.hookEventTime ?? existing?.lastActivity ?? 0;
|
|
97
|
+
return incomingTime >= existingTime;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getHookTime(session: Partial<SessionModel> | undefined): number {
|
|
101
|
+
return session?.claude?.hookEventTime ?? session?.lastActivity ?? 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getExistingHookData(existing: SessionModel | undefined): Partial<SessionModel> | null {
|
|
105
|
+
if (!existing) return null;
|
|
106
|
+
if (existing.detectionSource !== "claude-hook") return null;
|
|
107
|
+
if (!existing.claude?.hookEventType || typeof existing.claude.hookEventTime !== "number" || typeof existing.claude.hookSeq !== "number") {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
state: existing.state,
|
|
113
|
+
hasAttention: existing.hasAttention,
|
|
114
|
+
summary: existing.summary,
|
|
115
|
+
activeTool: existing.activeTool,
|
|
116
|
+
currentPath: existing.currentPath,
|
|
117
|
+
lastActivity: existing.lastActivity,
|
|
118
|
+
parentSessionId: existing.parentSessionId,
|
|
119
|
+
detectionSource: "claude-hook",
|
|
120
|
+
claude: {
|
|
121
|
+
sessionId: existing.claude.sessionId,
|
|
122
|
+
transcriptPath: existing.claude.transcriptPath,
|
|
123
|
+
hookEventType: existing.claude.hookEventType,
|
|
124
|
+
hookEventTime: existing.claude.hookEventTime,
|
|
125
|
+
hookSeq: existing.claude.hookSeq,
|
|
126
|
+
...(existing.claude.permissionMode ? { permissionMode: existing.claude.permissionMode } : {}),
|
|
127
|
+
...(existing.claude.startSource ? { startSource: existing.claude.startSource } : {}),
|
|
128
|
+
...(existing.claude.endReason ? { endReason: existing.claude.endReason } : {}),
|
|
129
|
+
...(existing.claude.errorType ? { errorType: existing.claude.errorType } : {}),
|
|
130
|
+
...(existing.claude.attentionKind ? { attentionKind: existing.claude.attentionKind } : {}),
|
|
131
|
+
...(existing.claude.toolName ? { toolName: existing.claude.toolName } : {}),
|
|
132
|
+
...(existing.claude.toolInputSummary ? { toolInputSummary: existing.claude.toolInputSummary } : {}),
|
|
133
|
+
...(existing.claude.toolResultStatus ? { toolResultStatus: existing.claude.toolResultStatus } : {}),
|
|
134
|
+
...(existing.claude.agentId ? { agentId: existing.claude.agentId } : {}),
|
|
135
|
+
...(existing.claude.agentType ? { agentType: existing.claude.agentType } : {}),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const MERGEABLE_FIELDS: (keyof ClaudeSessionMeta)[] = [
|
|
141
|
+
"hookEventType", "hookEventTime", "hookSeq",
|
|
142
|
+
"permissionMode", "startSource", "endReason", "errorType",
|
|
143
|
+
"attentionKind", "toolName", "toolInputSummary",
|
|
144
|
+
"toolResultStatus", "agentId", "agentType",
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
export function mergeClaudeMeta(
|
|
148
|
+
base: ClaudeSessionMeta,
|
|
149
|
+
incoming: Partial<ClaudeSessionMeta>,
|
|
150
|
+
): ClaudeSessionMeta {
|
|
151
|
+
const result = { ...base };
|
|
152
|
+
for (const key of MERGEABLE_FIELDS) {
|
|
153
|
+
if (incoming[key] !== undefined) {
|
|
154
|
+
(result as Record<string, unknown>)[key] = incoming[key];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function mergeHookIntoModel(model: SessionModel, hookData: Partial<SessionModel>, transcriptPath: string): SessionModel {
|
|
161
|
+
const hookClaude = hookData.claude ?? { sessionId: model.claude?.sessionId ?? model.id.replace(/^claude:/, ""), transcriptPath };
|
|
162
|
+
return {
|
|
163
|
+
...model,
|
|
164
|
+
...hookData,
|
|
165
|
+
state: (hookData.state as SessionState | undefined) ?? model.state,
|
|
166
|
+
hasAttention: hookData.hasAttention ?? model.hasAttention,
|
|
167
|
+
id: model.id,
|
|
168
|
+
source: model.source,
|
|
169
|
+
summary: hookData.summary ?? model.summary,
|
|
170
|
+
lastActivity: typeof hookData.lastActivity === "number" ? hookData.lastActivity : model.lastActivity,
|
|
171
|
+
activeTool: hookData.activeTool ?? model.activeTool,
|
|
172
|
+
detectionSource: "claude-hook",
|
|
173
|
+
claude: mergeClaudeMeta(
|
|
174
|
+
{
|
|
175
|
+
sessionId: hookClaude.sessionId ?? model.claude?.sessionId ?? model.id.replace(/^claude:/, ""),
|
|
176
|
+
transcriptPath: hookClaude.transcriptPath || transcriptPath,
|
|
177
|
+
},
|
|
178
|
+
hookClaude,
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ClaudeCollector } from "./collector";
|
|
2
|
+
export type { ClaudeHookFileData } from "./hooks";
|
|
3
|
+
|
|
4
|
+
// Test-accessible pure functions (not part of collector public API,
|
|
5
|
+
// but exported for direct unit testing without replicating logic)
|
|
6
|
+
export { inferState, extractSummary, extractTitle, isLocalCommandTranscript } from "./transcript";
|
|
7
|
+
export { decodeClaudeProjectSlug } from "./discovery";
|
|
8
|
+
export { mergeClaudeMeta } from "./hooks";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface JsonlEntry {
|
|
4
|
+
type?: string;
|
|
5
|
+
role?: string;
|
|
6
|
+
content?: unknown;
|
|
7
|
+
message?: {
|
|
8
|
+
role?: string;
|
|
9
|
+
content?: unknown;
|
|
10
|
+
};
|
|
11
|
+
toolUse?: { name?: string };
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ContentBlock {
|
|
16
|
+
type?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
input?: Record<string, unknown>;
|
|
20
|
+
tool_use_id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseJsonlFile(path: string, maxBytes?: number): JsonlEntry[] {
|
|
24
|
+
try {
|
|
25
|
+
const content = readTextTail(path, maxBytes);
|
|
26
|
+
return parseJsonlContent(content);
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseJsonlHead(path: string, maxBytes: number): JsonlEntry[] {
|
|
33
|
+
try {
|
|
34
|
+
return parseJsonlContent(readTextHead(path, maxBytes));
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseJsonlContent(content: string): JsonlEntry[] {
|
|
41
|
+
return content
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((line) => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(line) as JsonlEntry;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.filter((entry): entry is JsonlEntry => entry !== null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readTextHead(path: string, maxBytes: number): string {
|
|
55
|
+
const stat = statSync(path);
|
|
56
|
+
if (stat.size <= maxBytes) return readFileSync(path, "utf8");
|
|
57
|
+
|
|
58
|
+
const fd = openSync(path, "r");
|
|
59
|
+
try {
|
|
60
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
61
|
+
readSync(fd, buffer, 0, maxBytes, 0);
|
|
62
|
+
const text = buffer.toString("utf8");
|
|
63
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
64
|
+
return lastNewline >= 0 ? text.slice(0, lastNewline) : text;
|
|
65
|
+
} finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readTextTail(path: string, maxBytes?: number): string {
|
|
71
|
+
if (!maxBytes || maxBytes <= 0) return readFileSync(path, "utf8");
|
|
72
|
+
const stat = statSync(path);
|
|
73
|
+
if (stat.size <= maxBytes) return readFileSync(path, "utf8");
|
|
74
|
+
|
|
75
|
+
const fd = openSync(path, "r");
|
|
76
|
+
try {
|
|
77
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
78
|
+
readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
|
|
79
|
+
const text = buffer.toString("utf8");
|
|
80
|
+
const firstNewline = text.indexOf("\n");
|
|
81
|
+
return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
|
82
|
+
} finally {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { SessionModel, SessionState } from "../../../shared/types";
|
|
2
|
+
import type { JsonlEntry, ContentBlock } from "./jsonl";
|
|
3
|
+
|
|
4
|
+
export function getRole(entry: JsonlEntry): string | undefined {
|
|
5
|
+
return entry.role ?? entry.message?.role;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getBlocks(entry: JsonlEntry): ContentBlock[] {
|
|
9
|
+
const content = entry.content ?? entry.message?.content;
|
|
10
|
+
if (!Array.isArray(content)) return [];
|
|
11
|
+
return content.filter((block): block is ContentBlock => typeof block === "object" && block !== null);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getTextBlocks(entry: JsonlEntry): string[] {
|
|
15
|
+
return getBlocks(entry)
|
|
16
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
17
|
+
.map((block) => block.text!.trim())
|
|
18
|
+
.filter((text) => text.length > 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeText(text: string): string {
|
|
22
|
+
return text
|
|
23
|
+
.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "")
|
|
24
|
+
.replace(/<command-name>(.*?)<\/command-name>/g, "$1")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isQuestionText(text: string): boolean {
|
|
29
|
+
const trimmed = normalizeText(text);
|
|
30
|
+
return /[?]/.test(trimmed) || /^(would you|do you|can you|should i|which|what|how|please confirm|shall i|want me to)/i.test(trimmed);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isInterruptedRequestText(text: string): boolean {
|
|
34
|
+
return /^\[Request interrupted by user(?: for tool use)?\]$/i.test(text.trim());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isSyntheticUserEntry(entry: JsonlEntry): boolean {
|
|
38
|
+
if (getRole(entry) !== "user") return false;
|
|
39
|
+
if (isToolResultOnlyEntry(entry)) return true;
|
|
40
|
+
const texts = getTextBlocks(entry).map((text) => normalizeText(text));
|
|
41
|
+
return texts.length > 0 && texts.every((text) => isInterruptedRequestText(text));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isToolResultOnlyEntry(entry: JsonlEntry): boolean {
|
|
45
|
+
const blocks = getBlocks(entry);
|
|
46
|
+
return blocks.length > 0 && blocks.every((block) => block.type === "tool_result");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isThinkingOnlyAssistantEntry(entry: JsonlEntry): boolean {
|
|
50
|
+
if (getRole(entry) !== "assistant") return false;
|
|
51
|
+
const blocks = getBlocks(entry);
|
|
52
|
+
return blocks.length > 0 && blocks.every((block) => block.type === "thinking");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findLastToolResultIndex(entries: JsonlEntry[]): number {
|
|
56
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
57
|
+
if (isToolResultOnlyEntry(entries[i]!)) return i;
|
|
58
|
+
}
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function findLastMeaningfulEntry(entries: JsonlEntry[]): JsonlEntry | undefined {
|
|
63
|
+
const lastToolResultIndex = findLastToolResultIndex(entries);
|
|
64
|
+
|
|
65
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
66
|
+
const entry = entries[i]!;
|
|
67
|
+
const role = getRole(entry);
|
|
68
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
69
|
+
if (isThinkingOnlyAssistantEntry(entry)) continue;
|
|
70
|
+
if (isSyntheticUserEntry(entry)) continue;
|
|
71
|
+
|
|
72
|
+
if (role === "assistant") {
|
|
73
|
+
const blocks = getBlocks(entry);
|
|
74
|
+
const hasToolUse = blocks.some((block) => block.type === "tool_use");
|
|
75
|
+
const hasVisibleText = getTextBlocks(entry).length > 0;
|
|
76
|
+
if (hasToolUse && !hasVisibleText && lastToolResultIndex > i) {
|
|
77
|
+
// The most recent assistant entry is a fulfilled tool_use cycle.
|
|
78
|
+
// In agentic mode, a completed tool cycle means the agent is
|
|
79
|
+
// processing the result — return the entry so inferState sees
|
|
80
|
+
// it as "working" (assistant with tool_use, no visible text).
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Infer session state from the last meaningful transcript turn */
|
|
92
|
+
export function inferState(entries: JsonlEntry[]): SessionState {
|
|
93
|
+
if (entries.length === 0) return "idle";
|
|
94
|
+
|
|
95
|
+
const last = findLastMeaningfulEntry(entries);
|
|
96
|
+
if (!last) return "idle";
|
|
97
|
+
|
|
98
|
+
const role = getRole(last);
|
|
99
|
+
if (role === "assistant") {
|
|
100
|
+
const blocks = getBlocks(last);
|
|
101
|
+
const texts = getTextBlocks(last);
|
|
102
|
+
const hasToolUse = blocks.some((block) => block.type === "tool_use");
|
|
103
|
+
|
|
104
|
+
if (texts.some((value) => isQuestionText(value))) return "waiting-input";
|
|
105
|
+
if (texts.length > 0) return "idle";
|
|
106
|
+
if (hasToolUse) return "working";
|
|
107
|
+
return "idle";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (role === "user") {
|
|
111
|
+
return isSyntheticUserEntry(last) ? "idle" : "working";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return "idle";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Extract a human-readable summary from the last assistant message */
|
|
118
|
+
export function extractSummary(entries: JsonlEntry[]): string {
|
|
119
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
120
|
+
const entry = entries[i]!;
|
|
121
|
+
const role = getRole(entry);
|
|
122
|
+
if (role !== "assistant") continue;
|
|
123
|
+
if (isThinkingOnlyAssistantEntry(entry)) continue;
|
|
124
|
+
|
|
125
|
+
const content = getBlocks(entry);
|
|
126
|
+
if (content.length === 0) continue;
|
|
127
|
+
|
|
128
|
+
for (const block of content) {
|
|
129
|
+
if (block.type === "tool_use" && block.name) {
|
|
130
|
+
const cmd = typeof block.input?.command === "string" ? `: ${block.input.command.slice(0, 60)}` : "";
|
|
131
|
+
return `Using ${block.name}${cmd}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const block of content) {
|
|
136
|
+
if (block.type === "text" && block.text) {
|
|
137
|
+
const first = normalizeText(block.text).split("\n")[0] ?? "";
|
|
138
|
+
if (first.length > 0) return first.slice(0, 120);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return "Claude session";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A transcript whose user text entries are only the initialization caveat,
|
|
148
|
+
* `/usage` slash-command invocations, or local-command stdout blocks.
|
|
149
|
+
* Generated by third-party probe tools (e.g. CodexBar's ClaudeProbe) that
|
|
150
|
+
* launch `claude` headlessly to read usage stats — not interactive work.
|
|
151
|
+
* Limited to `/usage` to avoid filtering sessions that run other slash
|
|
152
|
+
* commands as part of real work.
|
|
153
|
+
*/
|
|
154
|
+
export function isUsageProbeTranscript(entries: JsonlEntry[]): boolean {
|
|
155
|
+
const texts: string[] = [];
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (getRole(entry) !== "user") continue;
|
|
158
|
+
const content = entry.content ?? entry.message?.content;
|
|
159
|
+
if (Array.isArray(content)) {
|
|
160
|
+
for (const block of content) {
|
|
161
|
+
if (typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string") {
|
|
162
|
+
texts.push(block.text);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} else if (typeof content === "string") {
|
|
166
|
+
texts.push(content);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (texts.length === 0) return false;
|
|
170
|
+
// Require at least one /usage slash command — a bare caveat alone is the
|
|
171
|
+
// interactive session init message, not a probe.
|
|
172
|
+
const hasUsageCommand = texts.some((text) => /<command-name>\s*\/usage[\s<]/.test(text));
|
|
173
|
+
if (!hasUsageCommand) return false;
|
|
174
|
+
return texts.every((text) =>
|
|
175
|
+
text.startsWith("<local-command-caveat>")
|
|
176
|
+
|| /<command-name>\s*\/usage[\s<]/.test(text)
|
|
177
|
+
|| text.startsWith("<local-command-stdout>"),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Returns true when the transcript was generated in Claude Code's local-commands
|
|
183
|
+
* mode (e.g. `claude -p "..."`). These are headless batch runs, not interactive
|
|
184
|
+
* sessions — they clutter the TUI and carry no meaningful state.
|
|
185
|
+
*/
|
|
186
|
+
export function isLocalCommandTranscript(entries: JsonlEntry[]): boolean {
|
|
187
|
+
// Detect transcripts emitted by headless probe tools that only run `/usage`
|
|
188
|
+
// (e.g. CodexBar's ClaudeProbe). These look like interactive sessions to the
|
|
189
|
+
// caveat check below (bare init caveat + slash command), but carry no real
|
|
190
|
+
// work state. Checked first because the caveat loop returns early.
|
|
191
|
+
if (isUsageProbeTranscript(entries)) return true;
|
|
192
|
+
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const role = entry.role ?? entry.message?.role;
|
|
195
|
+
if (role !== "user") continue;
|
|
196
|
+
const content = entry.content ?? entry.message?.content;
|
|
197
|
+
let text = "";
|
|
198
|
+
if (Array.isArray(content)) {
|
|
199
|
+
for (const block of content) {
|
|
200
|
+
if (typeof block === "object" && block !== null && "type" in block) {
|
|
201
|
+
const b = block as { type: string; text?: string };
|
|
202
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
203
|
+
text = b.text;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else if (typeof content === "string") {
|
|
209
|
+
text = content;
|
|
210
|
+
}
|
|
211
|
+
// Stop at first user entry regardless
|
|
212
|
+
if (!text.startsWith("<local-command-caveat>")) break;
|
|
213
|
+
// A bare caveat with no trailing task content is the initialization message
|
|
214
|
+
// Claude Code sends at the start of interactive sessions — not a headless
|
|
215
|
+
// `claude -p "task"` invocation. Real local commands have the task prompt
|
|
216
|
+
// appended after the closing tag.
|
|
217
|
+
const closingTag = "</local-command-caveat>";
|
|
218
|
+
const afterCaveat = text.slice(text.indexOf(closingTag) + closingTag.length).trim();
|
|
219
|
+
return afterCaveat.length > 0;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Extract a title from the first user message (the initial task/request) */
|
|
225
|
+
export function extractTitle(entries: JsonlEntry[]): string | undefined {
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const role = getRole(entry);
|
|
228
|
+
if (role !== "user") continue;
|
|
229
|
+
if (isToolResultOnlyEntry(entry)) continue;
|
|
230
|
+
|
|
231
|
+
const content = entry.content ?? entry.message?.content;
|
|
232
|
+
if (Array.isArray(content)) {
|
|
233
|
+
for (const block of getBlocks(entry)) {
|
|
234
|
+
if (block.type === "text" && block.text) {
|
|
235
|
+
const first = normalizeText(block.text).split("\n")[0] ?? "";
|
|
236
|
+
if (first.length === 0) continue;
|
|
237
|
+
return first.slice(0, 80);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} else if (typeof content === "string" && content.trim().length > 0) {
|
|
241
|
+
const first = normalizeText(content).split("\n")[0] ?? "";
|
|
242
|
+
if (first.length > 0) return first.slice(0, 80);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Codex Collector
|
|
2
|
+
|
|
3
|
+
This module provides session discovery and real-time state tracking for OpenAI Codex CLI.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
~/.codex/sessions/YYYY/MM/DD/*.jsonl ~/.codex/archived_sessions/*.jsonl
|
|
9
|
+
│ │
|
|
10
|
+
│ │
|
|
11
|
+
▼ ▼
|
|
12
|
+
┌──────────────┐ ┌──────────────┐
|
|
13
|
+
│ discovery │ discoverTranscripts() │ hooks │ readHookData()
|
|
14
|
+
│ │ │ │
|
|
15
|
+
└──────┬───────┘ └──────┬───────┘
|
|
16
|
+
│ │
|
|
17
|
+
│ │
|
|
18
|
+
▼ │
|
|
19
|
+
┌──────────────┐ │
|
|
20
|
+
│ jsonl │ parseJsonlFile() │
|
|
21
|
+
└──────┬───────┘ │
|
|
22
|
+
│ │
|
|
23
|
+
▼ │
|
|
24
|
+
┌──────────────┐ │
|
|
25
|
+
│ transcript │ extractMeta() │
|
|
26
|
+
│ │ (no state inference) │
|
|
27
|
+
└──────┬───────┘ │
|
|
28
|
+
│ │
|
|
29
|
+
└────────────┬───────────────────────────┘
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
┌─────────────────────────────────┐
|
|
33
|
+
│ collector.ts │
|
|
34
|
+
│ │
|
|
35
|
+
│ ┌──────────────┐ ┌──────────┐ │
|
|
36
|
+
│ │ 30s poll │ │ fs.watch │ │
|
|
37
|
+
│ │ run() │ │ hooksDir │ │
|
|
38
|
+
│ └──────┬───────┘ └────┬─────┘ │
|
|
39
|
+
│ │ │ │
|
|
40
|
+
│ └──────┬────────┘ │
|
|
41
|
+
│ │ │
|
|
42
|
+
│ ▼ │
|
|
43
|
+
│ processTranscript() │
|
|
44
|
+
│ │ │
|
|
45
|
+
│ │ ┌──────────────┴─────────────┐
|
|
46
|
+
│ └──│ mergeHookIntoModel() │
|
|
47
|
+
│ │ (if hook newer than transcript)│
|
|
48
|
+
│ └──────────────────────────────┘
|
|
49
|
+
│ │ │
|
|
50
|
+
│ ▼ │
|
|
51
|
+
│ store.upsertSession() │
|
|
52
|
+
│ store.syncChildSessionIds() │
|
|
53
|
+
└─────────────────────────────────┘
|
|
54
|
+
│
|
|
55
|
+
▼
|
|
56
|
+
DaemonStore ──► /api/events SSE ──► TUI
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Modules
|
|
60
|
+
|
|
61
|
+
| File | Responsibility | Key Exports |
|
|
62
|
+
|------|-------------|-------------|
|
|
63
|
+
| `discovery.ts` | Scan `~/.codex/sessions/YYYY/MM/DD/*.jsonl` and `~/.codex/archived_sessions/*.jsonl`; respect `$CODEX_HOME` | `discoverTranscripts()`, `getCodexDir()` |
|
|
64
|
+
| `jsonl.ts` | Read JSONL tail with byte-size limits; parse lines into typed entries | `parseJsonlFile()`, `readTextTail()`, `CodexJsonlEntry` |
|
|
65
|
+
| `transcript.ts` | Extract session metadata from JSONL (`session_meta`, `turn_context`, `token_count`). No state inference. | `extractMeta()`, `CodexTranscriptMeta` |
|
|
66
|
+
| `hooks.ts` | Read/validate hook JSON files; merge hook data into a session model; merge codex metadata without overwriting existing fields | `readHookData()`, `mergeHookIntoModel()`, `mergeCodexMeta()`, `isHookNewer()`, `getHookTime()`, `isValidHookData()`, `CodexHookFileData` |
|
|
67
|
+
| `collector.ts` | Orchestrate discovery, transcript watching, hook watching, deduplication, and store writes | `CodexCollector` (implements `Collector`) |
|
|
68
|
+
| `index.ts` | Public re-exports | `CodexCollector`, `extractMeta` |
|
|
69
|
+
|
|
70
|
+
## JSONL Format
|
|
71
|
+
|
|
72
|
+
Codex JSONL files contain three processed line types:
|
|
73
|
+
|
|
74
|
+
- `session_meta` — session identity and fork lineage
|
|
75
|
+
- `turn_context` — active model slug
|
|
76
|
+
- `event_msg` with `payload.type === "token_count"` — token usage
|
|
77
|
+
|
|
78
|
+
No conversational content is parsed (format is unstable).
|
|
79
|
+
|
|
80
|
+
## Hook Requirement
|
|
81
|
+
|
|
82
|
+
Hooks are **required** for real-time state. Without hooks:
|
|
83
|
+
|
|
84
|
+
- Sessions are discovered from JSONL files
|
|
85
|
+
- State is always `idle`
|
|
86
|
+
- `detectionSource` is `"codex-transcript"`
|
|
87
|
+
|
|
88
|
+
With hooks (`codex.hooksEnabled: true`):
|
|
89
|
+
|
|
90
|
+
- State is derived from Codex lifecycle events (`SessionStart`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `UserPromptSubmit`, `Stop`)
|
|
91
|
+
- `detectionSource` is `"codex-hook"`
|
|
92
|
+
- `turn_id` is captured for turn-level tracking
|
|
93
|
+
|
|
94
|
+
## Install Hooks
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
scrylog codex hooks install
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This writes `~/.codex/hooks.json` with entries for all 6 Codex hook events pointing to `scrylog-codex-hook`.
|
|
101
|
+
|
|
102
|
+
## Hook Merge Priority
|
|
103
|
+
|
|
104
|
+
When both transcript and hook data exist, the collector uses this priority:
|
|
105
|
+
|
|
106
|
+
1. **Hook newer** (`hookSeq` or `hookEventTime` >= existing): hook data wins for `state`, `hasAttention`, `summary`, `activeTool`
|
|
107
|
+
2. **Hook older**: transcript data wins (except `codex` metadata merged via `mergeCodexMeta`)
|
|
108
|
+
3. **Hook-managed session** (`detectionSource === "codex-hook"`): transcript can only fill missing metadata (e.g. `transcriptPath`), never override state
|
|
109
|
+
|
|
110
|
+
## State Derivation Rules
|
|
111
|
+
|
|
112
|
+
| Event | State | Reason |
|
|
113
|
+
|-------|-------|--------|
|
|
114
|
+
| `SessionStart` | `idle` | Session just started |
|
|
115
|
+
| `UserPromptSubmit` | `working` | User submitted a prompt, Codex is working |
|
|
116
|
+
| `PreToolUse` | `working` | Codex is about to use a tool |
|
|
117
|
+
| `PermissionRequest` | `waiting-input` | Codex is waiting for user permission |
|
|
118
|
+
| `PostToolUse` | `working` | Tool completed, Codex continues |
|
|
119
|
+
| `Stop` with `stop_hook_active: true` | `working` | Codex will continue automatically |
|
|
120
|
+
| `Stop` with `stop_hook_active: false` | `idle` | Session ended |
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"codex": {
|
|
127
|
+
"codexDir": "/path/to/.codex",
|
|
128
|
+
"hooksEnabled": true,
|
|
129
|
+
"hooksDir": "/tmp/scrylog-codex-hooks"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Interactions
|
|
135
|
+
|
|
136
|
+
| Component | How |
|
|
137
|
+
|-----------|-----|
|
|
138
|
+
| `GitCollector` | Resolves `currentPath` → `project` when hooks/transcript provide a directory |
|
|
139
|
+
| `DaemonStore` | Upserts sessions; clears old bindings via `syncChildSessionIds()` |
|
|
140
|
+
| `TmuxCollector` | Reads `@codex-session` pane option for authoritative pane→session linking |
|
|
141
|
+
| `Scheduler` | Calls `start()` → sets up `30s` discovery interval + `5s` fallback re-read |
|
|
142
|
+
| `TUI` | Receives updates via SSE from the store |
|
|
143
|
+
|
|
144
|
+
## Detection Sources Produced
|
|
145
|
+
|
|
146
|
+
- `"codex-transcript"` — session discovered from `.jsonl` file, state is `idle`
|
|
147
|
+
- `"codex-hook"` — session discovered/enriched from hook JSON, state is authoritative from Codex CLI directly
|