agent-companion 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/README.md +401 -0
- package/bridge/defaultState.mjs +504 -0
- package/bridge/directIngest.mjs +712 -0
- package/bridge/server.mjs +2812 -0
- package/package.json +86 -0
- package/relay/server.mjs +2056 -0
- package/scripts/add-pending.mjs +51 -0
- package/scripts/agent-runner.mjs +1475 -0
- package/scripts/background-service.mjs +122 -0
- package/scripts/banner.mjs +36 -0
- package/scripts/cli.mjs +77 -0
- package/scripts/dev-stack.mjs +64 -0
- package/scripts/laptop-companion.mjs +1179 -0
- package/scripts/laptop-service.mjs +282 -0
- package/scripts/repair-codex-resume.mjs +300 -0
- package/scripts/reset-bridge.mjs +19 -0
- package/scripts/start-task.mjs +108 -0
- package/scripts/ui-claude-delegate.mjs +220 -0
- package/wake-proxy/server.mjs +162 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const MAX_FILES_PER_PROVIDER = 24;
|
|
6
|
+
const MAX_SESSION_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const PENDING_FRESH_WINDOW_MS = 7_500;
|
|
8
|
+
const MAX_CHAT_TURNS_PER_SESSION = 24;
|
|
9
|
+
const MAX_CHAT_TURNS_TOTAL = 160;
|
|
10
|
+
const PENDING_PATTERN =
|
|
11
|
+
/input required|needs input|waiting for input|approval[_\s-]*required|awaiting approval|please approve|approve (?:this|the) plan|should i (?:proceed|implement|execute)|would you like me to (?:proceed|implement|execute)|ready to (?:implement|execute)|want me to (?:implement|execute)|proceed with implementation/i;
|
|
12
|
+
|
|
13
|
+
export function collectDirectSnapshot(nowMs = Date.now()) {
|
|
14
|
+
const codexSessions = collectCodexSessions(nowMs);
|
|
15
|
+
const claudeSessions = collectClaudeSessions(nowMs);
|
|
16
|
+
|
|
17
|
+
const sessionsById = new Map();
|
|
18
|
+
for (const item of [...codexSessions.sessions, ...claudeSessions.sessions]) {
|
|
19
|
+
if (!item?.id) continue;
|
|
20
|
+
if (nowMs - item.lastUpdated > MAX_SESSION_AGE_MS) continue;
|
|
21
|
+
const prev = sessionsById.get(item.id);
|
|
22
|
+
if (!prev || prev.lastUpdated < item.lastUpdated) {
|
|
23
|
+
sessionsById.set(item.id, item);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const sessions = [...sessionsById.values()]
|
|
27
|
+
.sort((a, b) => b.lastUpdated - a.lastUpdated)
|
|
28
|
+
.slice(0, 12);
|
|
29
|
+
|
|
30
|
+
const pendingById = new Map();
|
|
31
|
+
for (const pending of [...codexSessions.pendingInputs, ...claudeSessions.pendingInputs]) {
|
|
32
|
+
if (!pending?.id) continue;
|
|
33
|
+
if (nowMs - pending.requestedAt > MAX_SESSION_AGE_MS) continue;
|
|
34
|
+
pendingById.set(pending.id, pending);
|
|
35
|
+
}
|
|
36
|
+
const pendingInputs = [...pendingById.values()]
|
|
37
|
+
.sort((a, b) => b.requestedAt - a.requestedAt)
|
|
38
|
+
.slice(0, 12);
|
|
39
|
+
|
|
40
|
+
const eventById = new Map();
|
|
41
|
+
for (const event of [...codexSessions.events, ...claudeSessions.events]) {
|
|
42
|
+
if (!event?.id) continue;
|
|
43
|
+
if (nowMs - event.timestamp > MAX_SESSION_AGE_MS) continue;
|
|
44
|
+
eventById.set(event.id, event);
|
|
45
|
+
}
|
|
46
|
+
const events = [...eventById.values()]
|
|
47
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
48
|
+
.slice(0, 30);
|
|
49
|
+
|
|
50
|
+
const chatTurnById = new Map();
|
|
51
|
+
for (const turn of [...codexSessions.chatTurns, ...claudeSessions.chatTurns]) {
|
|
52
|
+
if (!turn?.id || !turn?.sessionId) continue;
|
|
53
|
+
if (nowMs - safeNumber(turn.createdAt, 0) > MAX_SESSION_AGE_MS) continue;
|
|
54
|
+
chatTurnById.set(turn.id, turn);
|
|
55
|
+
}
|
|
56
|
+
const chatTurns = [...chatTurnById.values()]
|
|
57
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
58
|
+
.slice(-MAX_CHAT_TURNS_TOTAL);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
sessions,
|
|
62
|
+
pendingInputs,
|
|
63
|
+
events,
|
|
64
|
+
chatTurns,
|
|
65
|
+
settings: {
|
|
66
|
+
pairingHealthy: true,
|
|
67
|
+
metadataOnly: true,
|
|
68
|
+
networkOnline: true
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function collectCodexSessions(nowMs) {
|
|
74
|
+
const root = path.join(os.homedir(), ".codex", "sessions");
|
|
75
|
+
const files = getRecentJsonlFiles(root, MAX_FILES_PER_PROVIDER);
|
|
76
|
+
|
|
77
|
+
const sessions = [];
|
|
78
|
+
const pendingInputs = [];
|
|
79
|
+
const events = [];
|
|
80
|
+
const chatTurns = [];
|
|
81
|
+
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const summary = parseCodexFile(file, nowMs);
|
|
84
|
+
if (!summary) continue;
|
|
85
|
+
|
|
86
|
+
sessions.push(summary.session);
|
|
87
|
+
if (summary.pendingInput) pendingInputs.push(summary.pendingInput);
|
|
88
|
+
if (summary.event) events.push(summary.event);
|
|
89
|
+
if (Array.isArray(summary.chatTurns)) {
|
|
90
|
+
chatTurns.push(...summary.chatTurns);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { sessions, pendingInputs, events, chatTurns };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectClaudeSessions(nowMs) {
|
|
98
|
+
const root = path.join(os.homedir(), ".claude", "projects");
|
|
99
|
+
const files = getRecentJsonlFiles(root, MAX_FILES_PER_PROVIDER);
|
|
100
|
+
|
|
101
|
+
const sessions = [];
|
|
102
|
+
const pendingInputs = [];
|
|
103
|
+
const events = [];
|
|
104
|
+
const chatTurns = [];
|
|
105
|
+
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const summary = parseClaudeFile(file, nowMs);
|
|
108
|
+
if (!summary) continue;
|
|
109
|
+
|
|
110
|
+
sessions.push(summary.session);
|
|
111
|
+
if (summary.pendingInput) pendingInputs.push(summary.pendingInput);
|
|
112
|
+
if (summary.event) events.push(summary.event);
|
|
113
|
+
if (Array.isArray(summary.chatTurns)) {
|
|
114
|
+
chatTurns.push(...summary.chatTurns);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { sessions, pendingInputs, events, chatTurns };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseCodexFile(file, nowMs) {
|
|
122
|
+
let raw = "";
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(file, "utf8");
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!raw.trim()) return null;
|
|
130
|
+
|
|
131
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
132
|
+
let sourceSessionId = "";
|
|
133
|
+
let cwd = "";
|
|
134
|
+
let latestTs = 0;
|
|
135
|
+
let firstPrompt = "";
|
|
136
|
+
let latestAgentMessage = "";
|
|
137
|
+
let pendingHint = "";
|
|
138
|
+
let pendingHintTs = 0;
|
|
139
|
+
let sawFinalAnswer = false;
|
|
140
|
+
let sawError = false;
|
|
141
|
+
const chatTurns = [];
|
|
142
|
+
let usage = {
|
|
143
|
+
promptTokens: 0,
|
|
144
|
+
completionTokens: 0,
|
|
145
|
+
totalTokens: 0
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
let record;
|
|
150
|
+
try {
|
|
151
|
+
record = JSON.parse(line);
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ts = safeDateMs(record.timestamp);
|
|
157
|
+
if (ts > latestTs) latestTs = ts;
|
|
158
|
+
|
|
159
|
+
if (record.type === "session_meta") {
|
|
160
|
+
sourceSessionId = record.payload?.id || sourceSessionId;
|
|
161
|
+
cwd = record.payload?.cwd || cwd;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (record.type === "event_msg" && record.payload?.type === "user_message") {
|
|
166
|
+
const message = String(record.payload?.message || "").trim();
|
|
167
|
+
if (message && !isNoisePrompt(message) && !firstPrompt) firstPrompt = message;
|
|
168
|
+
appendDirectTurn(chatTurns, {
|
|
169
|
+
sessionId: "",
|
|
170
|
+
role: "USER",
|
|
171
|
+
text: message,
|
|
172
|
+
createdAt: ts || readFileMtimeMs(file, nowMs)
|
|
173
|
+
});
|
|
174
|
+
if (PENDING_PATTERN.test(message)) {
|
|
175
|
+
pendingHint = message;
|
|
176
|
+
pendingHintTs = ts || pendingHintTs;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (record.type === "event_msg" && record.payload?.type === "agent_message") {
|
|
182
|
+
const message = String(record.payload?.message || "").trim();
|
|
183
|
+
if (message) latestAgentMessage = message;
|
|
184
|
+
appendDirectTurn(chatTurns, {
|
|
185
|
+
sessionId: "",
|
|
186
|
+
role: "ASSISTANT",
|
|
187
|
+
text: message,
|
|
188
|
+
createdAt: ts || readFileMtimeMs(file, nowMs)
|
|
189
|
+
});
|
|
190
|
+
if (PENDING_PATTERN.test(message)) {
|
|
191
|
+
pendingHint = message;
|
|
192
|
+
pendingHintTs = ts || pendingHintTs;
|
|
193
|
+
}
|
|
194
|
+
if (/error|failed|exception/i.test(message)) sawError = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (record.type === "event_msg" && record.payload?.type === "token_count") {
|
|
199
|
+
const info = record.payload?.info?.total_token_usage || record.payload?.info?.last_token_usage;
|
|
200
|
+
if (info) {
|
|
201
|
+
usage = {
|
|
202
|
+
promptTokens: safeNumber(info.input_tokens) + safeNumber(info.cached_input_tokens),
|
|
203
|
+
completionTokens: safeNumber(info.output_tokens),
|
|
204
|
+
totalTokens: safeNumber(info.total_tokens)
|
|
205
|
+
};
|
|
206
|
+
if (!usage.totalTokens) {
|
|
207
|
+
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (record.type === "response_item" && record.payload?.phase === "final_answer") {
|
|
214
|
+
sawFinalAnswer = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (record.type === "response_item" && record.payload?.type === "message") {
|
|
219
|
+
const role = record.payload?.role;
|
|
220
|
+
if (role === "user") {
|
|
221
|
+
const text = extractCodexText(record.payload?.content);
|
|
222
|
+
if (text && !isNoisePrompt(text) && !firstPrompt) firstPrompt = text;
|
|
223
|
+
appendDirectTurn(chatTurns, {
|
|
224
|
+
sessionId: "",
|
|
225
|
+
role: "USER",
|
|
226
|
+
text,
|
|
227
|
+
createdAt: ts || readFileMtimeMs(file, nowMs)
|
|
228
|
+
});
|
|
229
|
+
if (text && PENDING_PATTERN.test(text)) {
|
|
230
|
+
pendingHint = text;
|
|
231
|
+
pendingHintTs = ts || pendingHintTs;
|
|
232
|
+
}
|
|
233
|
+
} else if (role === "assistant") {
|
|
234
|
+
const text = extractCodexText(record.payload?.content);
|
|
235
|
+
if (text) {
|
|
236
|
+
latestAgentMessage = text;
|
|
237
|
+
appendDirectTurn(chatTurns, {
|
|
238
|
+
sessionId: "",
|
|
239
|
+
role: "ASSISTANT",
|
|
240
|
+
text,
|
|
241
|
+
createdAt: ts || readFileMtimeMs(file, nowMs)
|
|
242
|
+
});
|
|
243
|
+
if (PENDING_PATTERN.test(text)) {
|
|
244
|
+
pendingHint = text;
|
|
245
|
+
pendingHintTs = ts || pendingHintTs;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const fallbackId = path.basename(file).replace(/\.jsonl$/i, "");
|
|
254
|
+
const sessionId = `codex:${sourceSessionId || fallbackId}`;
|
|
255
|
+
const finalizedTurns = finalizeDirectTurns(chatTurns, sessionId);
|
|
256
|
+
const effectiveLastUpdated = latestTs || readFileMtimeMs(file, nowMs);
|
|
257
|
+
const ageSec = Math.max(0, Math.floor((nowMs - effectiveLastUpdated) / 1000));
|
|
258
|
+
const pendingStillActive =
|
|
259
|
+
Boolean(pendingHint) &&
|
|
260
|
+
pendingHintTs > 0 &&
|
|
261
|
+
pendingHintTs >= effectiveLastUpdated - PENDING_FRESH_WINDOW_MS;
|
|
262
|
+
|
|
263
|
+
const state = deriveState({
|
|
264
|
+
ageSec,
|
|
265
|
+
sawFinalAnswer,
|
|
266
|
+
sawError,
|
|
267
|
+
hasPendingHint: pendingStillActive
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const title = truncate(firstPrompt || latestAgentMessage || "Codex session", 88);
|
|
271
|
+
const repo = cwd ? path.basename(cwd) : "unknown-repo";
|
|
272
|
+
|
|
273
|
+
if (!firstPrompt && !latestAgentMessage && finalizedTurns.length === 0) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const progress =
|
|
278
|
+
state === "COMPLETED"
|
|
279
|
+
? 100
|
|
280
|
+
: state === "FAILED"
|
|
281
|
+
? 100
|
|
282
|
+
: state === "WAITING_INPUT"
|
|
283
|
+
? 82
|
|
284
|
+
: Math.max(14, Math.min(94, 95 - Math.floor(ageSec / 3)));
|
|
285
|
+
|
|
286
|
+
const costUsd = Number((usage.totalTokens * 0.00001).toFixed(2));
|
|
287
|
+
|
|
288
|
+
const session = {
|
|
289
|
+
id: sessionId,
|
|
290
|
+
agentType: "CODEX",
|
|
291
|
+
title,
|
|
292
|
+
repo,
|
|
293
|
+
branch: "main",
|
|
294
|
+
state,
|
|
295
|
+
lastUpdated: effectiveLastUpdated,
|
|
296
|
+
progress,
|
|
297
|
+
tokenUsage: {
|
|
298
|
+
promptTokens: usage.promptTokens,
|
|
299
|
+
completionTokens: usage.completionTokens,
|
|
300
|
+
totalTokens: usage.totalTokens,
|
|
301
|
+
costUsd
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const pendingInput =
|
|
306
|
+
state === "WAITING_INPUT"
|
|
307
|
+
? {
|
|
308
|
+
id: `pending:${sessionId}`,
|
|
309
|
+
sessionId,
|
|
310
|
+
prompt: truncate(pendingHint || "Input requested by Codex", 180),
|
|
311
|
+
requestedAt: pendingHintTs || effectiveLastUpdated,
|
|
312
|
+
priority: "HIGH",
|
|
313
|
+
actionable: false,
|
|
314
|
+
source: "DIRECT"
|
|
315
|
+
}
|
|
316
|
+
: null;
|
|
317
|
+
|
|
318
|
+
const event = {
|
|
319
|
+
id: `event:${sessionId}`,
|
|
320
|
+
sessionId,
|
|
321
|
+
summary:
|
|
322
|
+
state === "WAITING_INPUT"
|
|
323
|
+
? "Direct Codex session is waiting for input."
|
|
324
|
+
: state === "RUNNING"
|
|
325
|
+
? "Direct Codex session is running."
|
|
326
|
+
: state === "FAILED"
|
|
327
|
+
? "Direct Codex session ended with an error."
|
|
328
|
+
: "Direct Codex session completed.",
|
|
329
|
+
timestamp: effectiveLastUpdated,
|
|
330
|
+
category: state === "FAILED" ? "ERROR" : state === "WAITING_INPUT" ? "INPUT" : "INFO"
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return { session, pendingInput, event, chatTurns: finalizedTurns };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseClaudeFile(file, nowMs) {
|
|
337
|
+
let raw = "";
|
|
338
|
+
try {
|
|
339
|
+
raw = fs.readFileSync(file, "utf8");
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!raw.trim()) return null;
|
|
345
|
+
|
|
346
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
347
|
+
let sourceSessionId = "";
|
|
348
|
+
let cwd = "";
|
|
349
|
+
let branch = "main";
|
|
350
|
+
let latestTs = 0;
|
|
351
|
+
let firstPrompt = "";
|
|
352
|
+
let latestAssistantText = "";
|
|
353
|
+
let pendingHint = "";
|
|
354
|
+
let pendingHintTs = 0;
|
|
355
|
+
let sawError = false;
|
|
356
|
+
const chatTurns = [];
|
|
357
|
+
let usage = {
|
|
358
|
+
promptTokens: 0,
|
|
359
|
+
completionTokens: 0,
|
|
360
|
+
totalTokens: 0
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
for (const line of lines) {
|
|
364
|
+
let record;
|
|
365
|
+
try {
|
|
366
|
+
record = JSON.parse(line);
|
|
367
|
+
} catch {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (record?.isSidechain) continue;
|
|
372
|
+
if (String(record?.type || "").trim().toLowerCase() === "queue-operation") continue;
|
|
373
|
+
|
|
374
|
+
const ts = safeDateMs(record.timestamp);
|
|
375
|
+
if (ts > latestTs) latestTs = ts;
|
|
376
|
+
|
|
377
|
+
sourceSessionId = record.sessionId || sourceSessionId;
|
|
378
|
+
cwd = record.cwd || cwd;
|
|
379
|
+
branch = record.gitBranch && record.gitBranch !== "HEAD" ? record.gitBranch : branch;
|
|
380
|
+
|
|
381
|
+
if (record.type === "user") {
|
|
382
|
+
const userText = extractClaudeUserText(record.message);
|
|
383
|
+
if (userText && !firstPrompt) firstPrompt = userText;
|
|
384
|
+
appendDirectTurn(chatTurns, {
|
|
385
|
+
sessionId: "",
|
|
386
|
+
role: "USER",
|
|
387
|
+
text: userText,
|
|
388
|
+
createdAt: ts || readFileMtimeMs(file, nowMs)
|
|
389
|
+
});
|
|
390
|
+
if (PENDING_PATTERN.test(userText)) {
|
|
391
|
+
pendingHint = userText;
|
|
392
|
+
pendingHintTs = ts || pendingHintTs;
|
|
393
|
+
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (record.type === "assistant") {
|
|
398
|
+
const assistantText = extractClaudeAssistantText(record.message);
|
|
399
|
+
if (assistantText) latestAssistantText = assistantText;
|
|
400
|
+
appendDirectTurn(chatTurns, {
|
|
401
|
+
sessionId: "",
|
|
402
|
+
role: "ASSISTANT",
|
|
403
|
+
text: assistantText,
|
|
404
|
+
createdAt: ts || readFileMtimeMs(file, nowMs)
|
|
405
|
+
});
|
|
406
|
+
if (PENDING_PATTERN.test(assistantText)) {
|
|
407
|
+
pendingHint = assistantText;
|
|
408
|
+
pendingHintTs = ts || pendingHintTs;
|
|
409
|
+
}
|
|
410
|
+
if (/error|failed|exception/i.test(assistantText)) sawError = true;
|
|
411
|
+
|
|
412
|
+
const u = record.message?.usage;
|
|
413
|
+
if (u) {
|
|
414
|
+
usage.promptTokens =
|
|
415
|
+
safeNumber(u.input_tokens) +
|
|
416
|
+
safeNumber(u.cache_read_input_tokens) +
|
|
417
|
+
safeNumber(u.cache_creation_input_tokens);
|
|
418
|
+
usage.completionTokens = safeNumber(u.output_tokens);
|
|
419
|
+
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const fallbackId = path.basename(file).replace(/\.jsonl$/i, "");
|
|
425
|
+
const sessionId = `claude:${sourceSessionId || fallbackId}`;
|
|
426
|
+
const finalizedTurns = finalizeDirectTurns(chatTurns, sessionId);
|
|
427
|
+
const effectiveLastUpdated = latestTs || readFileMtimeMs(file, nowMs);
|
|
428
|
+
const ageSec = Math.max(0, Math.floor((nowMs - effectiveLastUpdated) / 1000));
|
|
429
|
+
const pendingStillActive =
|
|
430
|
+
Boolean(pendingHint) &&
|
|
431
|
+
pendingHintTs > 0 &&
|
|
432
|
+
pendingHintTs >= effectiveLastUpdated - PENDING_FRESH_WINDOW_MS;
|
|
433
|
+
|
|
434
|
+
const state = deriveState({
|
|
435
|
+
ageSec,
|
|
436
|
+
sawFinalAnswer: ageSec > 25,
|
|
437
|
+
sawError,
|
|
438
|
+
hasPendingHint: pendingStillActive
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
if (!firstPrompt && !latestAssistantText && finalizedTurns.length === 0) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const title = truncate(firstPrompt || latestAssistantText || "Claude Code session", 88);
|
|
446
|
+
const repo = cwd ? path.basename(cwd) : "unknown-repo";
|
|
447
|
+
|
|
448
|
+
const progress =
|
|
449
|
+
state === "COMPLETED"
|
|
450
|
+
? 100
|
|
451
|
+
: state === "FAILED"
|
|
452
|
+
? 100
|
|
453
|
+
: state === "WAITING_INPUT"
|
|
454
|
+
? 80
|
|
455
|
+
: Math.max(12, Math.min(93, 94 - Math.floor(ageSec / 3)));
|
|
456
|
+
|
|
457
|
+
const costUsd = Number((usage.totalTokens * 0.00001).toFixed(2));
|
|
458
|
+
|
|
459
|
+
const session = {
|
|
460
|
+
id: sessionId,
|
|
461
|
+
agentType: "CLAUDE",
|
|
462
|
+
title,
|
|
463
|
+
repo,
|
|
464
|
+
branch,
|
|
465
|
+
state,
|
|
466
|
+
lastUpdated: effectiveLastUpdated,
|
|
467
|
+
progress,
|
|
468
|
+
tokenUsage: {
|
|
469
|
+
promptTokens: usage.promptTokens,
|
|
470
|
+
completionTokens: usage.completionTokens,
|
|
471
|
+
totalTokens: usage.totalTokens,
|
|
472
|
+
costUsd
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const pendingInput =
|
|
477
|
+
state === "WAITING_INPUT"
|
|
478
|
+
? {
|
|
479
|
+
id: `pending:${sessionId}`,
|
|
480
|
+
sessionId,
|
|
481
|
+
prompt: truncate(pendingHint || "Input requested by Claude Code", 180),
|
|
482
|
+
requestedAt: pendingHintTs || effectiveLastUpdated,
|
|
483
|
+
priority: "HIGH",
|
|
484
|
+
actionable: false,
|
|
485
|
+
source: "DIRECT"
|
|
486
|
+
}
|
|
487
|
+
: null;
|
|
488
|
+
|
|
489
|
+
const event = {
|
|
490
|
+
id: `event:${sessionId}`,
|
|
491
|
+
sessionId,
|
|
492
|
+
summary:
|
|
493
|
+
state === "WAITING_INPUT"
|
|
494
|
+
? "Direct Claude Code session is waiting for input."
|
|
495
|
+
: state === "RUNNING"
|
|
496
|
+
? "Direct Claude Code session is running."
|
|
497
|
+
: state === "FAILED"
|
|
498
|
+
? "Direct Claude Code session ended with an error."
|
|
499
|
+
: "Direct Claude Code session completed.",
|
|
500
|
+
timestamp: effectiveLastUpdated,
|
|
501
|
+
category: state === "FAILED" ? "ERROR" : state === "WAITING_INPUT" ? "INPUT" : "INFO"
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
return { session, pendingInput, event, chatTurns: finalizedTurns };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function deriveState({ ageSec, sawFinalAnswer, sawError, hasPendingHint }) {
|
|
508
|
+
if (hasPendingHint) return "WAITING_INPUT";
|
|
509
|
+
if (sawError && ageSec > 20) return "FAILED";
|
|
510
|
+
if (ageSec < 20) return "RUNNING";
|
|
511
|
+
if (sawFinalAnswer) return "COMPLETED";
|
|
512
|
+
return "COMPLETED";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function extractCodexText(content) {
|
|
516
|
+
if (!Array.isArray(content)) return "";
|
|
517
|
+
for (const item of content) {
|
|
518
|
+
if (typeof item?.text === "string" && item.text.trim()) {
|
|
519
|
+
const text = sanitizeDirectText(item.text);
|
|
520
|
+
if (!isNoisePrompt(text)) return text;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return "";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function extractClaudeUserText(message) {
|
|
527
|
+
if (!message) return "";
|
|
528
|
+
if (typeof message.content === "string") {
|
|
529
|
+
const text = sanitizeDirectText(message.content);
|
|
530
|
+
return isNoisePrompt(text) ? "" : text;
|
|
531
|
+
}
|
|
532
|
+
if (!Array.isArray(message.content)) return "";
|
|
533
|
+
|
|
534
|
+
for (const part of message.content) {
|
|
535
|
+
if (typeof part === "string") {
|
|
536
|
+
const text = sanitizeDirectText(part);
|
|
537
|
+
if (text && !isNoisePrompt(text)) return text;
|
|
538
|
+
}
|
|
539
|
+
if (typeof part?.text === "string") {
|
|
540
|
+
const text = sanitizeDirectText(part.text);
|
|
541
|
+
if (text && !isNoisePrompt(text)) return text;
|
|
542
|
+
}
|
|
543
|
+
if (typeof part?.content === "string" && part.content.trim()) {
|
|
544
|
+
const text = sanitizeDirectText(part.content);
|
|
545
|
+
if (text && !isNoisePrompt(text)) return text;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return "";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function extractClaudeAssistantText(message) {
|
|
553
|
+
if (!message) return "";
|
|
554
|
+
if (typeof message.content === "string") {
|
|
555
|
+
const text = sanitizeDirectText(message.content);
|
|
556
|
+
return isNoisePrompt(text) ? "" : text;
|
|
557
|
+
}
|
|
558
|
+
if (!Array.isArray(message.content)) return "";
|
|
559
|
+
|
|
560
|
+
const collected = [];
|
|
561
|
+
|
|
562
|
+
for (const part of message.content) {
|
|
563
|
+
if (part?.type === "text" && typeof part?.text === "string" && part.text.trim()) {
|
|
564
|
+
const text = sanitizeDirectText(part.text);
|
|
565
|
+
if (!isNoisePrompt(text)) {
|
|
566
|
+
collected.push(text);
|
|
567
|
+
}
|
|
568
|
+
} else if (!part?.type && typeof part?.text === "string" && part.text.trim()) {
|
|
569
|
+
const text = sanitizeDirectText(part.text);
|
|
570
|
+
if (!isNoisePrompt(text)) {
|
|
571
|
+
collected.push(text);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return collected.join("\n\n").trim();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function safeDateMs(value) {
|
|
580
|
+
const parsed = Date.parse(String(value || ""));
|
|
581
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function safeNumber(value, fallback = 0) {
|
|
585
|
+
const parsed = Number(value);
|
|
586
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function readFileMtimeMs(file, fallback = Date.now()) {
|
|
590
|
+
try {
|
|
591
|
+
return safeNumber(fs.statSync(file).mtimeMs, fallback);
|
|
592
|
+
} catch {
|
|
593
|
+
return fallback;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function truncate(value, max) {
|
|
598
|
+
const text = String(value || "").trim();
|
|
599
|
+
if (text.length <= max) return text;
|
|
600
|
+
return `${text.slice(0, max - 3)}...`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isNoisePrompt(text) {
|
|
604
|
+
const raw = String(text || "");
|
|
605
|
+
if (!raw.trim()) return true;
|
|
606
|
+
if (raw.includes("# AGENTS.md instructions")) return true;
|
|
607
|
+
if (raw.includes("<environment_context>")) return true;
|
|
608
|
+
if (raw.includes("Filesystem sandboxing defines")) return true;
|
|
609
|
+
if (raw.includes("AGENT_COMPANION_PERSIST_SERVER_HINT_V1")) return true;
|
|
610
|
+
if (raw.includes("[Request interrupted by user for tool use]")) return true;
|
|
611
|
+
if (raw === "Answer questions?") return true;
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function appendDirectTurn(turns, input) {
|
|
616
|
+
const text = sanitizeDirectText(input?.text);
|
|
617
|
+
if (!text || isNoisePrompt(text)) return;
|
|
618
|
+
|
|
619
|
+
const role = input?.role === "ASSISTANT" ? "ASSISTANT" : "USER";
|
|
620
|
+
const createdAt = safeNumber(input?.createdAt, Date.now());
|
|
621
|
+
const normalized = normalizeComparableText(text);
|
|
622
|
+
const last = turns[turns.length - 1];
|
|
623
|
+
if (
|
|
624
|
+
last &&
|
|
625
|
+
last.role === role &&
|
|
626
|
+
normalizeComparableText(last.text) === normalized &&
|
|
627
|
+
Math.abs(safeNumber(last.createdAt, 0) - createdAt) <= 3_000
|
|
628
|
+
) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
turns.push({
|
|
633
|
+
sessionId: input?.sessionId || "",
|
|
634
|
+
role,
|
|
635
|
+
kind: "MESSAGE",
|
|
636
|
+
text,
|
|
637
|
+
createdAt,
|
|
638
|
+
source: "DIRECT"
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function finalizeDirectTurns(turns, sessionId) {
|
|
643
|
+
if (!Array.isArray(turns) || !sessionId) return [];
|
|
644
|
+
|
|
645
|
+
return turns
|
|
646
|
+
.slice(-MAX_CHAT_TURNS_PER_SESSION)
|
|
647
|
+
.map((turn, index) => ({
|
|
648
|
+
...turn,
|
|
649
|
+
id: `direct:${sessionId}:${safeNumber(turn.createdAt, 0)}:${turn.role}:${index}`,
|
|
650
|
+
sessionId
|
|
651
|
+
}))
|
|
652
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function normalizeComparableText(value) {
|
|
656
|
+
return String(value || "")
|
|
657
|
+
.toLowerCase()
|
|
658
|
+
.replace(/\s+/g, " ")
|
|
659
|
+
.trim();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function sanitizeDirectText(value) {
|
|
663
|
+
const text = String(value || "")
|
|
664
|
+
.replace(/\r/g, "")
|
|
665
|
+
.trim();
|
|
666
|
+
if (!text) return "";
|
|
667
|
+
|
|
668
|
+
return text
|
|
669
|
+
.replace(/\n*AGENT_COMPANION_PERSIST_SERVER_HINT_V1:[\s\S]*$/i, "")
|
|
670
|
+
.replace(/\n*\[Request interrupted by user for tool use\]\s*$/i, "")
|
|
671
|
+
.trim();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function getRecentJsonlFiles(rootDir, limit) {
|
|
675
|
+
const records = [];
|
|
676
|
+
|
|
677
|
+
if (!fs.existsSync(rootDir)) return records;
|
|
678
|
+
|
|
679
|
+
const stack = [rootDir];
|
|
680
|
+
while (stack.length) {
|
|
681
|
+
const current = stack.pop();
|
|
682
|
+
if (!current) continue;
|
|
683
|
+
|
|
684
|
+
let entries = [];
|
|
685
|
+
try {
|
|
686
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
687
|
+
} catch {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
for (const entry of entries) {
|
|
692
|
+
const fullPath = path.join(current, entry.name);
|
|
693
|
+
if (entry.isDirectory()) {
|
|
694
|
+
if (entry.name === "subagents") continue;
|
|
695
|
+
stack.push(fullPath);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
const stat = fs.statSync(fullPath);
|
|
703
|
+
records.push({ file: fullPath, mtimeMs: stat.mtimeMs });
|
|
704
|
+
} catch {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
records.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
711
|
+
return records.slice(0, limit).map((item) => item.file);
|
|
712
|
+
}
|