@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,299 @@
|
|
|
1
|
+
import type { SessionState } from "../../../shared/types";
|
|
2
|
+
import type { CodexJsonlEntry } from "./jsonl";
|
|
3
|
+
import { isResponseItem, getResponseItemText, getResponseItemRole, getFunctionCallName, isEventMsg } from "./jsonl";
|
|
4
|
+
|
|
5
|
+
export interface CodexTranscriptMeta {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
forkedFromId?: string;
|
|
9
|
+
parentSessionId?: string;
|
|
10
|
+
agentNickname?: string;
|
|
11
|
+
agentRole?: string;
|
|
12
|
+
totalInputTokens?: number;
|
|
13
|
+
totalOutputTokens?: number;
|
|
14
|
+
totalCachedInputTokens?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CodexSpawnedSubagent {
|
|
18
|
+
agentId: string;
|
|
19
|
+
agentType?: string;
|
|
20
|
+
nickname?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractMeta(entries: CodexJsonlEntry[]): CodexTranscriptMeta {
|
|
24
|
+
const meta: CodexTranscriptMeta = { sessionId: "" };
|
|
25
|
+
let lastTurnModel: string | undefined;
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
switch (entry.type) {
|
|
29
|
+
case "session_meta": {
|
|
30
|
+
const payload = entry.payload ?? {};
|
|
31
|
+
const sid = (payload.session_id as string | undefined)
|
|
32
|
+
?? (payload.sessionId as string | undefined)
|
|
33
|
+
?? (payload.id as string | undefined)
|
|
34
|
+
?? entry.session_id
|
|
35
|
+
?? entry.sessionId
|
|
36
|
+
?? entry.id;
|
|
37
|
+
if (sid && typeof sid === "string") {
|
|
38
|
+
meta.sessionId = sid;
|
|
39
|
+
}
|
|
40
|
+
const forkedFrom = (payload.forked_from_id as string | undefined)
|
|
41
|
+
?? (payload.forkedFromId as string | undefined)
|
|
42
|
+
?? (payload.parent_session_id as string | undefined)
|
|
43
|
+
?? (payload.parentSessionId as string | undefined);
|
|
44
|
+
if (forkedFrom && typeof forkedFrom === "string") {
|
|
45
|
+
meta.forkedFromId = forkedFrom;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const source = payload.source as Record<string, unknown> | undefined;
|
|
49
|
+
const subagent = source?.subagent as Record<string, unknown> | undefined;
|
|
50
|
+
const threadSpawn = subagent?.thread_spawn as Record<string, unknown> | undefined;
|
|
51
|
+
const parentThreadId = threadSpawn?.parent_thread_id;
|
|
52
|
+
if (typeof parentThreadId === "string" && parentThreadId.length > 0) {
|
|
53
|
+
meta.parentSessionId = `codex:${parentThreadId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const agentNickname = payload.agent_nickname ?? threadSpawn?.agent_nickname;
|
|
57
|
+
if (typeof agentNickname === "string" && agentNickname.length > 0) {
|
|
58
|
+
meta.agentNickname = agentNickname;
|
|
59
|
+
}
|
|
60
|
+
const agentRole = payload.agent_role ?? threadSpawn?.agent_role;
|
|
61
|
+
if (typeof agentRole === "string" && agentRole.length > 0) {
|
|
62
|
+
meta.agentRole = agentRole;
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "turn_context": {
|
|
67
|
+
const payload = entry.payload ?? {};
|
|
68
|
+
const model = (payload.model as string | undefined)
|
|
69
|
+
?? ((payload.info as Record<string, unknown> | undefined)?.model as string | undefined);
|
|
70
|
+
if (model && typeof model === "string") {
|
|
71
|
+
lastTurnModel = model;
|
|
72
|
+
meta.model = model;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "event_msg": {
|
|
77
|
+
const payload = entry.payload ?? {};
|
|
78
|
+
if (payload.type !== "token_count") break;
|
|
79
|
+
const info = payload.info as Record<string, unknown> | undefined;
|
|
80
|
+
if (!info) break;
|
|
81
|
+
|
|
82
|
+
// Model resolution
|
|
83
|
+
const model = (info.model as string | undefined)
|
|
84
|
+
?? (info.model_name as string | undefined)
|
|
85
|
+
?? (payload.model as string | undefined)
|
|
86
|
+
?? entry.model
|
|
87
|
+
?? lastTurnModel;
|
|
88
|
+
if (model && typeof model === "string") {
|
|
89
|
+
meta.model = model;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Token usage
|
|
93
|
+
const totalUsage = info.total_token_usage as Record<string, number> | undefined;
|
|
94
|
+
if (totalUsage) {
|
|
95
|
+
meta.totalInputTokens = totalUsage.input_tokens;
|
|
96
|
+
meta.totalOutputTokens = totalUsage.output_tokens;
|
|
97
|
+
const cached = totalUsage.cached_input_tokens ?? totalUsage.cache_read_input_tokens ?? 0;
|
|
98
|
+
meta.totalCachedInputTokens = Math.min(cached, totalUsage.input_tokens ?? 0);
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return meta;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeText(text: string): string {
|
|
109
|
+
return text.trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isQuestionText(text: string): boolean {
|
|
113
|
+
const trimmed = normalizeText(text);
|
|
114
|
+
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)
|
|
115
|
+
|| /^(quieres que|debo|puedo|confirmas|por favor confirma|contin[uú]o|procedo)\b/i.test(trimmed)
|
|
116
|
+
|| /^¿?(qu[eé]|cu[aá]l|cu[aá]les|c[oó]mo|d[oó]nde|cu[aá]ndo|por qu[eé])(?:\s|\?|$)/i.test(trimmed);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseJsonObject(value: unknown): Record<string, unknown> | undefined {
|
|
120
|
+
if (typeof value !== "string") return undefined;
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(value) as unknown;
|
|
123
|
+
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : undefined;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readCompletedSubagentIds(entries: CodexJsonlEntry[]): Set<string> {
|
|
130
|
+
const completed = new Set<string>();
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!isResponseItem(entry)) continue;
|
|
133
|
+
const text = getResponseItemText(entry);
|
|
134
|
+
if (text) {
|
|
135
|
+
const match = /<subagent_notification>\s*([\s\S]*?)\s*<\/subagent_notification>/i.exec(text);
|
|
136
|
+
const payload = match ? parseJsonObject(match[1]) : undefined;
|
|
137
|
+
const agentPath = payload?.agent_path;
|
|
138
|
+
if (typeof agentPath === "string") completed.add(agentPath);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (entry.payload.type !== "function_call_output") continue;
|
|
142
|
+
const output = parseJsonObject(entry.payload.output);
|
|
143
|
+
const status = output?.status;
|
|
144
|
+
if (typeof status !== "object" || status === null) continue;
|
|
145
|
+
for (const [agentId, value] of Object.entries(status as Record<string, unknown>)) {
|
|
146
|
+
if (typeof value === "object" && value !== null && "completed" in value) {
|
|
147
|
+
completed.add(agentId);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return completed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function extractActiveSpawnedSubagents(entries: CodexJsonlEntry[]): CodexSpawnedSubagent[] {
|
|
155
|
+
const completed = readCompletedSubagentIds(entries);
|
|
156
|
+
const callTypes = new Map<string, string | undefined>();
|
|
157
|
+
const spawned = new Map<string, CodexSpawnedSubagent>();
|
|
158
|
+
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (!isResponseItem(entry)) continue;
|
|
161
|
+
|
|
162
|
+
if (entry.payload.type === "function_call" && entry.payload.name === "spawn_agent") {
|
|
163
|
+
const callId = entry.payload.call_id;
|
|
164
|
+
const args = parseJsonObject(entry.payload.arguments);
|
|
165
|
+
const agentType = args?.agent_type;
|
|
166
|
+
if (typeof callId === "string") {
|
|
167
|
+
callTypes.set(callId, typeof agentType === "string" ? agentType : undefined);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (entry.payload.type !== "function_call_output") continue;
|
|
173
|
+
const callId = entry.payload.call_id;
|
|
174
|
+
if (typeof callId !== "string" || !callTypes.has(callId)) continue;
|
|
175
|
+
const output = parseJsonObject(entry.payload.output);
|
|
176
|
+
const agentId = output?.agent_id;
|
|
177
|
+
if (typeof agentId !== "string" || completed.has(agentId)) continue;
|
|
178
|
+
const nickname = output?.nickname;
|
|
179
|
+
const agentType = callTypes.get(callId);
|
|
180
|
+
spawned.set(agentId, {
|
|
181
|
+
agentId,
|
|
182
|
+
...(agentType ? { agentType } : {}),
|
|
183
|
+
...(typeof nickname === "string" ? { nickname } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return [...spawned.values()];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function isResponseItemAskingQuestion(entry: CodexJsonlEntry): boolean {
|
|
191
|
+
if (!isResponseItem(entry)) return false;
|
|
192
|
+
if (getResponseItemRole(entry) !== "assistant") return false;
|
|
193
|
+
const text = getResponseItemText(entry);
|
|
194
|
+
if (!text) return false;
|
|
195
|
+
return isQuestionText(text);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function isRequestUserInputCall(entry: CodexJsonlEntry): boolean {
|
|
199
|
+
if (!isResponseItem(entry)) return false;
|
|
200
|
+
return getFunctionCallName(entry) === "request_user_input";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isAsyncProcessRunningOutput(entry: CodexJsonlEntry): boolean {
|
|
204
|
+
if (!isResponseItem(entry)) return false;
|
|
205
|
+
if (entry.payload.type !== "function_call_output") return false;
|
|
206
|
+
const output = entry.payload.output;
|
|
207
|
+
return typeof output === "string" && /Process running with session ID\s+\d+/i.test(output);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function findLastMeaningfulResponseIndex(entries: CodexJsonlEntry[]): number {
|
|
211
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
212
|
+
const entry = entries[i]!;
|
|
213
|
+
if (!isResponseItem(entry)) continue;
|
|
214
|
+
// Skip turn_aborted placeholder messages — these are system-generated
|
|
215
|
+
// and don't represent actual user responses.
|
|
216
|
+
const role = getResponseItemRole(entry);
|
|
217
|
+
const text = getResponseItemText(entry);
|
|
218
|
+
if (role === "user" && text?.includes("<turn_aborted>")) continue;
|
|
219
|
+
return i;
|
|
220
|
+
}
|
|
221
|
+
return -1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function extractState(entries: CodexJsonlEntry[]): SessionState {
|
|
225
|
+
if (entries.length === 0) return "idle";
|
|
226
|
+
|
|
227
|
+
const hasTaskStarted = entries.some((e) => isEventMsg(e) && e.payload.type === "task_started");
|
|
228
|
+
const hasTaskComplete = entries.some((e) => isEventMsg(e) && e.payload.type === "task_complete");
|
|
229
|
+
|
|
230
|
+
const lastIdx = findLastMeaningfulResponseIndex(entries);
|
|
231
|
+
if (lastIdx < 0) {
|
|
232
|
+
if (hasTaskStarted && !hasTaskComplete) return "working";
|
|
233
|
+
return "idle";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const lastEntry = entries[lastIdx]! as CodexJsonlEntry & { payload: Record<string, unknown> };
|
|
237
|
+
|
|
238
|
+
if (isRequestUserInputCall(lastEntry)) return "waiting-input";
|
|
239
|
+
|
|
240
|
+
const role = getResponseItemRole(lastEntry as Parameters<typeof getResponseItemRole>[0]);
|
|
241
|
+
const text = getResponseItemText(lastEntry as Parameters<typeof getResponseItemText>[0]);
|
|
242
|
+
const payloadType = lastEntry.payload?.type;
|
|
243
|
+
|
|
244
|
+
if (role === "assistant" && text && isQuestionText(text)) return "waiting-input";
|
|
245
|
+
|
|
246
|
+
if (role === "assistant" && text) return "idle";
|
|
247
|
+
|
|
248
|
+
if (payloadType === "function_call") return "working";
|
|
249
|
+
if (isAsyncProcessRunningOutput(lastEntry)) return "working";
|
|
250
|
+
|
|
251
|
+
if (role === "user") return "working";
|
|
252
|
+
|
|
253
|
+
const afterEntries = entries.slice(lastIdx + 1);
|
|
254
|
+
const hasTaskStartedAfter = afterEntries.some((e) => isEventMsg(e) && e.payload.type === "task_started");
|
|
255
|
+
const hasTaskCompleteAfter = afterEntries.some((e) => isEventMsg(e) && e.payload.type === "task_complete");
|
|
256
|
+
if (hasTaskStartedAfter && !hasTaskCompleteAfter) return "working";
|
|
257
|
+
if (hasTaskStarted && !hasTaskComplete) return "working";
|
|
258
|
+
|
|
259
|
+
return "idle";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function extractSummary(entries: CodexJsonlEntry[]): string | undefined {
|
|
263
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
264
|
+
const entry = entries[i]!;
|
|
265
|
+
if (!isResponseItem(entry)) continue;
|
|
266
|
+
if (getResponseItemRole(entry) !== "assistant") continue;
|
|
267
|
+
|
|
268
|
+
const funcName = getFunctionCallName(entry);
|
|
269
|
+
if (funcName) {
|
|
270
|
+
const args = entry.payload?.arguments;
|
|
271
|
+
if (funcName === "request_user_input" && typeof args === "object" && args !== null) {
|
|
272
|
+
const question = (args as Record<string, unknown>).question;
|
|
273
|
+
if (typeof question === "string") {
|
|
274
|
+
const first = question.split("\n")[0] ?? "";
|
|
275
|
+
if (first.length > 0) return first.slice(0, 120);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return `Using ${funcName}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const text = getResponseItemText(entry);
|
|
282
|
+
if (text) {
|
|
283
|
+
const first = normalizeText(text).split("\n")[0] ?? "";
|
|
284
|
+
if (first.length > 0) return first.slice(0, 120);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
289
|
+
const entry = entries[i]!;
|
|
290
|
+
if (!isEventMsg(entry)) continue;
|
|
291
|
+
if (entry.payload.type !== "agent_message") continue;
|
|
292
|
+
const msg = entry.payload.message;
|
|
293
|
+
if (typeof msg === "string" && msg.trim().length > 0) {
|
|
294
|
+
return msg.trim().split("\n")[0]!.slice(0, 120);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import type { ProjectInfo } from "../../shared/types";
|
|
4
|
+
import { maybeRunCommandInDirectory, safeBasename } from "../../shared/utils";
|
|
5
|
+
import type { ScrylogConfig } from "../../shared/config";
|
|
6
|
+
import type { DaemonStore } from "../store";
|
|
7
|
+
import type { Collector } from "../scheduler";
|
|
8
|
+
import { IS_WSL } from "../../shared/platform";
|
|
9
|
+
|
|
10
|
+
import type { Logger } from "../../shared/logger";
|
|
11
|
+
import { NOOP_LOGGER } from "../../shared/logger";
|
|
12
|
+
|
|
13
|
+
export type { ProjectInfo };
|
|
14
|
+
|
|
15
|
+
interface GitCommandContext {
|
|
16
|
+
cwd: string;
|
|
17
|
+
gitDir?: string;
|
|
18
|
+
workTree?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveProjectInfo(currentPath: string, cache = new Map<string, ProjectInfo>(), timeoutMs?: number): ProjectInfo {
|
|
22
|
+
const normalizedPath = normalizePathForLookup(currentPath);
|
|
23
|
+
const cached = lookupCachedProject(normalizedPath, cache);
|
|
24
|
+
if (cached) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const visited: string[] = [];
|
|
29
|
+
const searchStart = resolveSearchStart(normalizedPath);
|
|
30
|
+
const gitMarkerDirectory = findGitMarkerDirectory(searchStart, visited, cache);
|
|
31
|
+
|
|
32
|
+
let project = buildFallbackProjectInfo(normalizedPath);
|
|
33
|
+
if (gitMarkerDirectory) {
|
|
34
|
+
project = resolveGitProjectInfo(gitMarkerDirectory, normalizedPath, timeoutMs) ?? buildFallbackProjectInfo(normalizedPath, gitMarkerDirectory);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cacheKeys = project.isGitRepository
|
|
38
|
+
? [...visited, normalizedPath, project.gitRootPath].filter((value): value is string => Boolean(value))
|
|
39
|
+
: [normalizedPath];
|
|
40
|
+
for (const cacheKey of cacheKeys) {
|
|
41
|
+
cache.set(cacheKey, project);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return project;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildCompactProjectLabel(project: Pick<ProjectInfo, "displayName" | "isGitRepository" | "branch" | "isWorktree">): string {
|
|
48
|
+
if (!project.isGitRepository) {
|
|
49
|
+
return project.displayName;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const branch = project.branch || "detached";
|
|
53
|
+
const repoKind = project.isWorktree ? "wt" : "git";
|
|
54
|
+
return `${project.displayName}@${branch} [${repoKind}]`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseRemoteProjectName(remoteUrl: string): string {
|
|
58
|
+
const trimmed = remoteUrl.trim().replace(/[\\/]+$/, "");
|
|
59
|
+
if (!trimmed) {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const normalized = trimmed.replace(/[:](?=[^/]+\/)/, "/");
|
|
64
|
+
const lastSegment = normalized.split("/").filter(Boolean).pop() ?? "";
|
|
65
|
+
return lastSegment.replace(/\.git$/i, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isGitWorktree(gitDir: string, gitCommonDir: string, cwd: string): boolean {
|
|
69
|
+
return resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Thin collector wrapper around the existing git resolution logic.
|
|
74
|
+
* Exposes a shared cache for other collectors.
|
|
75
|
+
*/
|
|
76
|
+
export class GitCollector implements Collector {
|
|
77
|
+
name = "git";
|
|
78
|
+
readonly cache = new Map<string, ProjectInfo>();
|
|
79
|
+
private logger: Logger = NOOP_LOGGER;
|
|
80
|
+
private timeoutMs: number | undefined;
|
|
81
|
+
private maxEntries = 1_000;
|
|
82
|
+
|
|
83
|
+
start(_store: DaemonStore, config: ScrylogConfig, logger: Logger): void {
|
|
84
|
+
this.logger = logger;
|
|
85
|
+
this.timeoutMs = config.daemon.commandTimeoutMs;
|
|
86
|
+
this.maxEntries = config.retention.cacheMaxEntries;
|
|
87
|
+
logger.info("git", "Collector started", { event: "collector.started", collector: this.name, mode: "on-demand" });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stop(): void {
|
|
91
|
+
// Nothing to stop
|
|
92
|
+
this.logger.info("git", "Collector stopped", { event: "collector.stopped", collector: this.name });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
resolve(path: string): ProjectInfo {
|
|
96
|
+
const cached = this.cache.has(path);
|
|
97
|
+
if (!cached) {
|
|
98
|
+
this.logger.debug("git", "Cache miss — resolving git info", { event: "cache.miss", path });
|
|
99
|
+
}
|
|
100
|
+
const result = resolveProjectInfo(path, this.cache, this.timeoutMs);
|
|
101
|
+
trimMap(this.cache, this.maxEntries);
|
|
102
|
+
if (!cached && result.isGitRepository) {
|
|
103
|
+
this.logger.debug("git", "Resolved git project", {
|
|
104
|
+
event: "project.resolved",
|
|
105
|
+
path,
|
|
106
|
+
displayName: result.displayName,
|
|
107
|
+
branch: result.branch,
|
|
108
|
+
isWorktree: result.isWorktree,
|
|
109
|
+
gitRootPath: result.gitRootPath,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveGitProjectInfo(gitMarkerDirectory: string, currentPath: string, timeoutMs?: number): ProjectInfo | null {
|
|
117
|
+
const commandContext = resolveGitCommandContext(gitMarkerDirectory);
|
|
118
|
+
const topLevel = runGit(commandContext, ["rev-parse", "--show-toplevel"], timeoutMs);
|
|
119
|
+
if (!topLevel.ok) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const gitDir = runGit(commandContext, ["rev-parse", "--git-dir"], timeoutMs);
|
|
124
|
+
const gitCommonDir = runGit(commandContext, ["rev-parse", "--git-common-dir"], timeoutMs);
|
|
125
|
+
const remoteNamesResult = runGit(commandContext, ["remote"], timeoutMs);
|
|
126
|
+
|
|
127
|
+
const gitRootPath = normalizeWslWindowsPath(topLevel.output.trim());
|
|
128
|
+
const gitDirPath = gitDir.ok ? normalizeWslWindowsPath(gitDir.output.trim()) : undefined;
|
|
129
|
+
const gitCommonDirPath = gitCommonDir.ok ? normalizeWslWindowsPath(gitCommonDir.output.trim()) : undefined;
|
|
130
|
+
const repositoryIdentityPath = gitCommonDir.ok
|
|
131
|
+
? resolveRepositoryIdentityPath(gitMarkerDirectory, gitCommonDirPath ?? "", gitRootPath)
|
|
132
|
+
: gitRootPath;
|
|
133
|
+
const branch = resolveBranchName(commandContext, timeoutMs);
|
|
134
|
+
const remoteNames = remoteNamesResult.ok
|
|
135
|
+
? remoteNamesResult.output.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
|
136
|
+
: [];
|
|
137
|
+
const preferredRemote = remoteNames.includes("origin") ? "origin" : remoteNames[0];
|
|
138
|
+
const remoteUrlResult = preferredRemote ? runGit(commandContext, ["remote", "get-url", preferredRemote], timeoutMs) : null;
|
|
139
|
+
const remoteUrl = remoteUrlResult?.ok ? remoteUrlResult.output.trim() : undefined;
|
|
140
|
+
const displayName = parseRemoteProjectName(remoteUrl ?? "") || basename(repositoryIdentityPath) || safeBasename(currentPath);
|
|
141
|
+
const isWorktree = gitDirPath && gitCommonDirPath ? isGitWorktree(gitDirPath, gitCommonDirPath, gitMarkerDirectory) : false;
|
|
142
|
+
|
|
143
|
+
const project: ProjectInfo = {
|
|
144
|
+
displayName,
|
|
145
|
+
compactLabel: "",
|
|
146
|
+
sourcePath: currentPath,
|
|
147
|
+
isGitRepository: true,
|
|
148
|
+
gitRootPath,
|
|
149
|
+
repositoryIdentityPath,
|
|
150
|
+
branch,
|
|
151
|
+
isWorktree,
|
|
152
|
+
remoteName: preferredRemote,
|
|
153
|
+
remoteUrl,
|
|
154
|
+
};
|
|
155
|
+
project.compactLabel = buildCompactProjectLabel(project);
|
|
156
|
+
return project;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildFallbackProjectInfo(currentPath: string, displayPath = currentPath): ProjectInfo {
|
|
160
|
+
const displayName = safeBasename(displayPath);
|
|
161
|
+
const project: ProjectInfo = {
|
|
162
|
+
displayName,
|
|
163
|
+
compactLabel: displayName,
|
|
164
|
+
sourcePath: currentPath,
|
|
165
|
+
isGitRepository: false,
|
|
166
|
+
};
|
|
167
|
+
return project;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveSearchStart(currentPath: string): string {
|
|
171
|
+
if (!currentPath) return currentPath;
|
|
172
|
+
try {
|
|
173
|
+
return statSync(currentPath).isDirectory() ? currentPath : dirname(currentPath);
|
|
174
|
+
} catch {
|
|
175
|
+
return dirname(currentPath) === currentPath ? currentPath : dirname(currentPath);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findGitMarkerDirectory(startPath: string, visited: string[], cache: Map<string, ProjectInfo>): string | null {
|
|
180
|
+
let current = startPath;
|
|
181
|
+
while (current) {
|
|
182
|
+
visited.push(current);
|
|
183
|
+
const cached = cache.get(current);
|
|
184
|
+
if (cached) {
|
|
185
|
+
if (cached.isGitRepository) return null;
|
|
186
|
+
}
|
|
187
|
+
if (hasGitMarker(current)) return current;
|
|
188
|
+
const parent = dirname(current);
|
|
189
|
+
if (parent === current) return null;
|
|
190
|
+
current = parent;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hasGitMarker(directory: string): boolean {
|
|
196
|
+
return existsSync(join(directory, ".git"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function lookupCachedProject(currentPath: string, cache: Map<string, ProjectInfo>): ProjectInfo | undefined {
|
|
200
|
+
if (cache.has(currentPath)) return cache.get(currentPath);
|
|
201
|
+
let current = resolveSearchStart(currentPath);
|
|
202
|
+
while (current) {
|
|
203
|
+
const cached = cache.get(current);
|
|
204
|
+
if (cached) {
|
|
205
|
+
if (cached.isGitRepository) return cached;
|
|
206
|
+
}
|
|
207
|
+
const parent = dirname(current);
|
|
208
|
+
if (parent === current) return undefined;
|
|
209
|
+
current = parent;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeBranch(branch: string): string {
|
|
215
|
+
if (!branch || branch === "HEAD") return "detached";
|
|
216
|
+
return branch;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveRepositoryIdentityPath(cwd: string, gitCommonDir: string, gitRootPath: string): string {
|
|
220
|
+
const resolvedGitCommonDir = resolve(cwd, gitCommonDir);
|
|
221
|
+
if (basename(resolvedGitCommonDir) !== ".git") return gitRootPath;
|
|
222
|
+
return dirname(resolvedGitCommonDir);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function normalizePathForLookup(currentPath: string): string {
|
|
226
|
+
return currentPath ? resolve(currentPath) : currentPath;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function runGit(context: string | GitCommandContext, args: string[], timeoutMs?: number): { ok: true; output: string } | { ok: false; error: string } {
|
|
230
|
+
const commandContext = typeof context === "string" ? { cwd: context } : context;
|
|
231
|
+
const gitArgs = commandContext.gitDir
|
|
232
|
+
? ["--git-dir", commandContext.gitDir, ...(commandContext.workTree ? ["--work-tree", commandContext.workTree] : []), ...args]
|
|
233
|
+
: args;
|
|
234
|
+
return maybeRunCommandInDirectory(["git", ...gitArgs], { cwd: commandContext.cwd, timeoutMs });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function resolveBranchName(context: string | GitCommandContext, timeoutMs?: number): string {
|
|
238
|
+
const symbolicRef = runGit(context, ["symbolic-ref", "--quiet", "--short", "HEAD"], timeoutMs);
|
|
239
|
+
if (symbolicRef.ok) return normalizeBranch(symbolicRef.output.trim());
|
|
240
|
+
const abbreviated = runGit(context, ["rev-parse", "--abbrev-ref", "HEAD"], timeoutMs);
|
|
241
|
+
if (abbreviated.ok) return normalizeBranch(abbreviated.output.trim());
|
|
242
|
+
return "detached";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function isWindowsAbsolutePath(value: string): boolean {
|
|
246
|
+
return /^[a-zA-Z]:[\\/]/.test(value.trim());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function windowsPathToWslPath(value: string): string {
|
|
250
|
+
const trimmed = value.trim();
|
|
251
|
+
const drive = trimmed[0]?.toLowerCase();
|
|
252
|
+
if (!drive || !isWindowsAbsolutePath(trimmed)) return value;
|
|
253
|
+
const rest = trimmed.slice(2).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
254
|
+
return `/mnt/${drive}${rest ? `/${rest}` : ""}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function normalizeWslWindowsPath(value: string, isWsl = IS_WSL): string {
|
|
258
|
+
return isWsl && isWindowsAbsolutePath(value) ? windowsPathToWslPath(value) : value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function parseGitDirFromGitFile(contents: string): string | null {
|
|
262
|
+
const line = contents.split(/\r?\n/).find((entry) => entry.trim().toLowerCase().startsWith("gitdir:"));
|
|
263
|
+
if (!line) return null;
|
|
264
|
+
const gitDir = line.slice(line.indexOf(":") + 1).trim();
|
|
265
|
+
return gitDir || null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function resolveGitCommandContext(gitMarkerDirectory: string, isWsl = IS_WSL): GitCommandContext {
|
|
269
|
+
const gitDir = readGitFileGitDir(gitMarkerDirectory);
|
|
270
|
+
if (!gitDir) return { cwd: gitMarkerDirectory };
|
|
271
|
+
|
|
272
|
+
const normalizedGitDir = normalizeWslWindowsPath(gitDir, isWsl);
|
|
273
|
+
if (normalizedGitDir === gitDir) return { cwd: gitMarkerDirectory };
|
|
274
|
+
|
|
275
|
+
return { cwd: gitMarkerDirectory, gitDir: normalizedGitDir, workTree: gitMarkerDirectory };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readGitFileGitDir(gitMarkerDirectory: string): string | null {
|
|
279
|
+
const gitPath = join(gitMarkerDirectory, ".git");
|
|
280
|
+
try {
|
|
281
|
+
if (!statSync(gitPath).isFile()) return null;
|
|
282
|
+
return parseGitDirFromGitFile(readFileSync(gitPath, "utf8"));
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function trimMap<K, V>(map: Map<K, V>, maxEntries: number): void {
|
|
289
|
+
if (maxEntries <= 0) return;
|
|
290
|
+
while (map.size > maxEntries) {
|
|
291
|
+
const first = map.keys().next();
|
|
292
|
+
if (first.done) return;
|
|
293
|
+
map.delete(first.value);
|
|
294
|
+
}
|
|
295
|
+
}
|