accrete-kit 0.1.0 → 0.2.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/CHANGELOG.md +14 -0
- package/README.md +9 -2
- package/dist/scaffold/assets/generated/release.json +1 -1
- package/dist/scaffold/manifest.json +2 -2
- package/dist/ui/LICENSE +21 -0
- package/dist/ui/THIRD-PARTY-NOTICES +212 -0
- package/dist/ui/bin/accrete-ui.mjs +46 -0
- package/dist/ui/page/app.js +49 -0
- package/dist/ui/page/index.html +17 -0
- package/dist/ui/page/panels/activity.css +80 -0
- package/dist/ui/page/panels/activity.js +85 -0
- package/dist/ui/page/panels/agents.css +61 -0
- package/dist/ui/page/panels/agents.js +173 -0
- package/dist/ui/page/panels/objects.css +155 -0
- package/dist/ui/page/panels/objects.js +309 -0
- package/dist/ui/page/panels/running.css +35 -0
- package/dist/ui/page/panels/running.js +159 -0
- package/dist/ui/page/style.css +86 -0
- package/dist/ui/page/terminal.js +88 -0
- package/dist/ui/server/agent-sessions.mjs +600 -0
- package/dist/ui/server/auth.mjs +29 -0
- package/dist/ui/server/deps.mjs +12 -0
- package/dist/ui/server/documents.mjs +285 -0
- package/dist/ui/server/events.mjs +18 -0
- package/dist/ui/server/processes.mjs +110 -0
- package/dist/ui/server/readers/activity.mjs +134 -0
- package/dist/ui/server/readers/agents.mjs +45 -0
- package/dist/ui/server/readers/objects.mjs +261 -0
- package/dist/ui/server/readers/running.mjs +245 -0
- package/dist/ui/server/scope.mjs +36 -0
- package/dist/ui/server/server.mjs +156 -0
- package/dist/ui/server/terminal.mjs +134 -0
- package/dist/ui/server/workspace.mjs +26 -0
- package/dist/ui/ui.json +3 -0
- package/dist/ui/vendor/@xterm/addon-fit/lib/addon-fit.mjs +18 -0
- package/dist/ui/vendor/@xterm/addon-unicode11/lib/addon-unicode11.mjs +44 -0
- package/dist/ui/vendor/@xterm/addon-webgl/lib/addon-webgl.mjs +101 -0
- package/dist/ui/vendor/@xterm/xterm/css/xterm.css +285 -0
- package/dist/ui/vendor/@xterm/xterm/lib/xterm.mjs +53 -0
- package/dist/ui/vendor/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/ui/vendor/node-pty/lib/index.js +52 -0
- package/dist/ui/vendor/node-pty/lib/interfaces.js +7 -0
- package/dist/ui/vendor/node-pty/lib/terminal.js +190 -0
- package/dist/ui/vendor/node-pty/lib/types.js +7 -0
- package/dist/ui/vendor/node-pty/lib/unixTerminal.js +346 -0
- package/dist/ui/vendor/node-pty/lib/utils.js +39 -0
- package/dist/ui/vendor/node-pty/package.json +64 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/ui/vendor/ws/index.js +22 -0
- package/dist/ui/vendor/ws/lib/buffer-util.js +142 -0
- package/dist/ui/vendor/ws/lib/constants.js +19 -0
- package/dist/ui/vendor/ws/lib/event-target.js +292 -0
- package/dist/ui/vendor/ws/lib/extension.js +203 -0
- package/dist/ui/vendor/ws/lib/limiter.js +55 -0
- package/dist/ui/vendor/ws/lib/permessage-deflate.js +530 -0
- package/dist/ui/vendor/ws/lib/receiver.js +743 -0
- package/dist/ui/vendor/ws/lib/sender.js +610 -0
- package/dist/ui/vendor/ws/lib/stream.js +161 -0
- package/dist/ui/vendor/ws/lib/subprotocol.js +62 -0
- package/dist/ui/vendor/ws/lib/validation.js +152 -0
- package/dist/ui/vendor/ws/lib/websocket-server.js +562 -0
- package/dist/ui/vendor/ws/lib/websocket.js +1426 -0
- package/dist/ui/vendor/ws/package.json +74 -0
- package/lib/cli.mjs +22 -4
- package/lib/ui.mjs +24 -0
- package/package.json +5 -5
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
// Everything that knows the private file formats of Claude Code and Codex. They
|
|
2
|
+
// change without notice, so anything not understood comes back as null, which the
|
|
3
|
+
// page shows as "unknown", and nothing here throws because of what a file contains.
|
|
4
|
+
// Reads only: bounded heads and tails, never a whole transcript, and never Codex's
|
|
5
|
+
// SQLite files, since even a read-only connection to a WAL database touches its -shm.
|
|
6
|
+
import { open, readdir, readFile, stat } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { agentOf } from './processes.mjs';
|
|
10
|
+
import { inScope } from './scope.mjs';
|
|
11
|
+
|
|
12
|
+
const CLAUDE_HEAD = 64 * 1024;
|
|
13
|
+
const CODEX_META_HEAD = 4 * 1024; // session_meta's first fields; the whole line is over 20 KB
|
|
14
|
+
const CODEX_TITLE_HEAD = 256 * 1024; // the first message can sit past 150 KB of instructions
|
|
15
|
+
const TAIL_STEPS = [64 * 1024, 256 * 1024, 1024 * 1024]; // widen only while no tool call has been found
|
|
16
|
+
const MAX_SESSIONS = 40;
|
|
17
|
+
const MAX_SUBAGENTS = 8;
|
|
18
|
+
const COLD_AFTER_MS = 60 * 60 * 1000;
|
|
19
|
+
const COLD_STAT_MS = 60 * 1000;
|
|
20
|
+
const ACTIVE_MS = 10 * 60 * 1000;
|
|
21
|
+
const SUMMARY_CHARS = 200;
|
|
22
|
+
|
|
23
|
+
// Bounded reads
|
|
24
|
+
|
|
25
|
+
async function readRange(path, start, length, counters) {
|
|
26
|
+
const file = await open(path, 'r');
|
|
27
|
+
try {
|
|
28
|
+
const buffer = Buffer.alloc(length);
|
|
29
|
+
const { bytesRead } = await file.read(buffer, 0, length, start);
|
|
30
|
+
if (counters) counters.bytesRead += bytesRead;
|
|
31
|
+
return buffer.toString('utf8', 0, bytesRead);
|
|
32
|
+
} finally {
|
|
33
|
+
await file.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Whole JSON objects only: a cut first or last line, or any other junk, is skipped.
|
|
38
|
+
function jsonLines(text, dropFirst = false) {
|
|
39
|
+
const lines = text.split('\n');
|
|
40
|
+
if (dropFirst) lines.shift();
|
|
41
|
+
const objects = [];
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (!line.startsWith('{')) continue;
|
|
44
|
+
try {
|
|
45
|
+
objects.push(JSON.parse(line));
|
|
46
|
+
} catch {
|
|
47
|
+
// not a whole line, or not JSON
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return objects;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A top-level string field out of JSON that may be cut off before it closes.
|
|
54
|
+
function fieldOf(text, name) {
|
|
55
|
+
const m = new RegExp(`"${name}":"((?:[^"\\\\]|\\\\.)*)"`).exec(text);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(`"${m[1]}"`);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asString(value) {
|
|
65
|
+
return typeof value === 'string' ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function oneLine(text) {
|
|
69
|
+
if (typeof text !== 'string') return null;
|
|
70
|
+
const line = text.split('\n').find((l) => l.trim());
|
|
71
|
+
if (!line) return null;
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
return trimmed.length > SUMMARY_CHARS ? `${trimmed.slice(0, SUMMARY_CHARS)}...` : trimmed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Summaries of tool input
|
|
77
|
+
|
|
78
|
+
const INPUT_KEYS = ['command', 'cmd', 'file_path', 'notebook_path', 'path', 'pattern', 'query', 'url', 'description', 'task_name', 'skill', 'prompt', 'target', 'to', 'message'];
|
|
79
|
+
|
|
80
|
+
function summarizeInput(input) {
|
|
81
|
+
if (typeof input === 'string') return oneLine(input);
|
|
82
|
+
if (Array.isArray(input)) return oneLine(input.filter((part) => typeof part === 'string').join(' '));
|
|
83
|
+
if (input === null || typeof input !== 'object') return null;
|
|
84
|
+
for (const key of INPUT_KEYS) {
|
|
85
|
+
const summary = typeof input[key] === 'string' || Array.isArray(input[key]) ? summarizeInput(input[key]) : null;
|
|
86
|
+
if (summary) return summary;
|
|
87
|
+
}
|
|
88
|
+
const first = Object.values(input).find((value) => typeof value === 'string');
|
|
89
|
+
return first ? oneLine(first) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textOfContent(content) {
|
|
93
|
+
if (typeof content === 'string') return content;
|
|
94
|
+
if (!Array.isArray(content)) return null;
|
|
95
|
+
const block = content.find((b) => typeof b?.text === 'string');
|
|
96
|
+
return block ? block.text : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Claude Code: one transcript
|
|
100
|
+
|
|
101
|
+
export function parseClaudeHead(text) {
|
|
102
|
+
const head = { cwd: fieldOf(text, 'cwd'), firstWords: null };
|
|
103
|
+
for (const line of jsonLines(text)) {
|
|
104
|
+
if (line.type !== 'user' || line.isMeta) continue;
|
|
105
|
+
const words = textOfContent(line.message?.content)?.trim();
|
|
106
|
+
if (words && !words.startsWith('<')) {
|
|
107
|
+
head.firstWords = oneLine(words);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return head;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseClaudeTail(lines) {
|
|
115
|
+
const out = { title: null, lastPrompt: null, cwd: null, tool: null, turnEndedAt: null };
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (typeof line.cwd === 'string') out.cwd = line.cwd;
|
|
118
|
+
if (line.type === 'ai-title') out.title = asString(line.aiTitle) ?? out.title;
|
|
119
|
+
else if (line.type === 'custom-title') out.title = asString(line.customTitle) ?? out.title;
|
|
120
|
+
else if (line.type === 'last-prompt') out.lastPrompt = oneLine(line.lastPrompt) ?? out.lastPrompt;
|
|
121
|
+
else if (line.type === 'assistant') {
|
|
122
|
+
const stop = line.message?.stop_reason;
|
|
123
|
+
out.turnEndedAt = stop === 'end_turn' || stop === 'stop_sequence' ? asString(line.timestamp) : null;
|
|
124
|
+
const blocks = line.message?.content;
|
|
125
|
+
if (!Array.isArray(blocks)) continue;
|
|
126
|
+
for (const block of blocks) {
|
|
127
|
+
if (block?.type !== 'tool_use') continue;
|
|
128
|
+
out.tool = { id: block.id, name: asString(block.name), summary: summarizeInput(block.input), at: asString(line.timestamp), pending: true };
|
|
129
|
+
}
|
|
130
|
+
} else if (line.type === 'user') {
|
|
131
|
+
out.turnEndedAt = null;
|
|
132
|
+
const blocks = line.message?.content;
|
|
133
|
+
if (!Array.isArray(blocks) || !out.tool) continue;
|
|
134
|
+
if (blocks.some((block) => block?.type === 'tool_result' && block.tool_use_id === out.tool.id)) out.tool.pending = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Codex: one rollout
|
|
141
|
+
|
|
142
|
+
export function parseCodexMeta(text, file) {
|
|
143
|
+
const firstLine = text.split('\n', 1)[0];
|
|
144
|
+
const understood = firstLine.includes('"session_meta"');
|
|
145
|
+
return {
|
|
146
|
+
understood,
|
|
147
|
+
id: (understood && fieldOf(firstLine, 'id')) || (/([0-9a-f]{8}-[0-9a-f-]{27})\.jsonl$/.exec(file)?.[1] ?? file),
|
|
148
|
+
parent: understood ? fieldOf(firstLine, 'parent_thread_id') : null,
|
|
149
|
+
cwd: understood ? fieldOf(firstLine, 'cwd') : null,
|
|
150
|
+
nickname: understood ? fieldOf(firstLine, 'agent_nickname') : null,
|
|
151
|
+
role: understood ? fieldOf(firstLine, 'agent_role') : null,
|
|
152
|
+
task: understood ? (fieldOf(firstLine, 'agent_path')?.split('/').pop() ?? null) : null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The code-mode `exec` tool takes a script; what it calls is the useful part.
|
|
157
|
+
function codexTool(payload, at) {
|
|
158
|
+
let name = asString(payload.name) ?? asString(payload.type);
|
|
159
|
+
let summary = null;
|
|
160
|
+
const raw = payload.input ?? payload.arguments ?? payload.action;
|
|
161
|
+
if (typeof raw === 'string') {
|
|
162
|
+
let parsed = null;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(raw);
|
|
165
|
+
} catch {
|
|
166
|
+
// free text, such as the script of `exec` or a patch
|
|
167
|
+
}
|
|
168
|
+
if (parsed !== null && typeof parsed === 'object') summary = summarizeInput(parsed);
|
|
169
|
+
else {
|
|
170
|
+
const call = /tools\.(\w+)\(\{?\s*(?:cmd:\s*)?(.*)/s.exec(raw);
|
|
171
|
+
if (call) name = call[1];
|
|
172
|
+
// Inside the script, the argument is itself a quoted string with \n in it.
|
|
173
|
+
const quoted = call && /^(["'`])((?:[^\\]|\\.)*?)\1/s.exec(call[2]);
|
|
174
|
+
const patched = /\*\*\* (?:Update|Add|Delete) File: ([^\\\n]+)/.exec(raw);
|
|
175
|
+
summary = oneLine(patched ? patched[1] : quoted ? quoted[2].replaceAll('\\n', '\n') : call ? call[2] : raw);
|
|
176
|
+
}
|
|
177
|
+
} else summary = summarizeInput(raw);
|
|
178
|
+
return { id: asString(payload.call_id), name, summary, at, pending: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseCodexTail(lines) {
|
|
182
|
+
const out = { title: null, cwd: null, tool: null, turnEndedAt: null };
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
const payload = line.payload;
|
|
185
|
+
if (payload === null || typeof payload !== 'object') continue;
|
|
186
|
+
const at = asString(line.timestamp);
|
|
187
|
+
if (line.type === 'turn_context') out.cwd = asString(payload.cwd) ?? out.cwd;
|
|
188
|
+
else if (line.type === 'event_msg') {
|
|
189
|
+
if (payload.type === 'task_complete' || payload.type === 'turn_aborted') out.turnEndedAt = at;
|
|
190
|
+
else if (payload.type === 'task_started') out.turnEndedAt = null;
|
|
191
|
+
else if (payload.type === 'thread_name_updated') out.title = asString(payload.thread_name) ?? out.title;
|
|
192
|
+
} else if (line.type === 'response_item' && typeof payload.type === 'string') {
|
|
193
|
+
if (payload.type.endsWith('_call')) out.tool = codexTool(payload, at);
|
|
194
|
+
else if (payload.type.endsWith('_call_output') && out.tool && payload.call_id === out.tool.id) out.tool.pending = false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The first thing the thread was asked, past the instructions injected ahead of it.
|
|
201
|
+
export function parseCodexTitle(lines) {
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
const payload = line.payload;
|
|
204
|
+
if (payload === null || typeof payload !== 'object') continue;
|
|
205
|
+
let text = null;
|
|
206
|
+
if (line.type === 'event_msg' && payload.type === 'user_message') text = asString(payload.message);
|
|
207
|
+
else if (line.type === 'response_item' && payload.type === 'agent_message') text = textOfContent(payload.content);
|
|
208
|
+
else if (line.type === 'response_item' && payload.type === 'message' && payload.role === 'user') text = textOfContent(payload.content);
|
|
209
|
+
if (!text || /^\s*(# AGENTS\.md|<environment_context|<permissions|<user_instructions)/.test(text)) continue;
|
|
210
|
+
const task = /^Message Type:[^]*?\nPayload:\n/.exec(text); // what a parent sends a subagent
|
|
211
|
+
const words = oneLine((task ? text.slice(task[0].length) : text).replace(/^\s*<[^>\n]*>\s*/, ''));
|
|
212
|
+
if (words) return words;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The index
|
|
218
|
+
|
|
219
|
+
const slugOf = (path) => path.replace(/[^A-Za-z0-9]/g, '-');
|
|
220
|
+
|
|
221
|
+
async function eachLimit(items, limit, work) {
|
|
222
|
+
let next = 0;
|
|
223
|
+
await Promise.all(
|
|
224
|
+
Array.from({ length: limit }, async () => {
|
|
225
|
+
while (next < items.length) await work(items[next++]);
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function listDir(path, options) {
|
|
231
|
+
return readdir(path, options).catch(() => []);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function createSessionIndex({ claudeDir = join(homedir(), '.claude'), codexDir = join(homedir(), '.codex') } = {}) {
|
|
235
|
+
const claude = new Map(); // transcript path -> entry
|
|
236
|
+
const codex = new Map(); // rollout path -> entry
|
|
237
|
+
const codexById = new Map();
|
|
238
|
+
const counters = { bytesRead: 0, tailReads: 0 };
|
|
239
|
+
let codexScan = null;
|
|
240
|
+
let codexScanned = false;
|
|
241
|
+
|
|
242
|
+
// Refresh size and mtime. A file quiet for an hour is looked at once a minute.
|
|
243
|
+
async function refresh(entry, now) {
|
|
244
|
+
if (entry.statAt && entry.mtimeMs && now - entry.mtimeMs > COLD_AFTER_MS && now - entry.statAt < COLD_STAT_MS) return;
|
|
245
|
+
entry.statAt = now;
|
|
246
|
+
try {
|
|
247
|
+
const info = await stat(entry.path);
|
|
248
|
+
entry.mtimeMs = info.mtimeMs;
|
|
249
|
+
entry.size = info.size;
|
|
250
|
+
} catch {
|
|
251
|
+
entry.gone = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Re-read the tail only when the file has changed since the last read.
|
|
256
|
+
async function readTail(entry, parse) {
|
|
257
|
+
if (!entry.path || entry.gone || (entry.tailMtime === entry.mtimeMs && entry.tailSize === entry.size)) return;
|
|
258
|
+
entry.tailMtime = entry.mtimeMs;
|
|
259
|
+
entry.tailSize = entry.size;
|
|
260
|
+
try {
|
|
261
|
+
let parsed = null;
|
|
262
|
+
for (const step of TAIL_STEPS) {
|
|
263
|
+
const start = Math.max(0, entry.size - step);
|
|
264
|
+
counters.tailReads++;
|
|
265
|
+
const text = await readRange(entry.path, start, entry.size - start, counters);
|
|
266
|
+
parsed = parse(jsonLines(text, start > 0));
|
|
267
|
+
if (parsed.tool || start === 0) break;
|
|
268
|
+
}
|
|
269
|
+
// What a narrower tail no longer holds is still the last thing seen.
|
|
270
|
+
entry.tool = parsed.tool ?? entry.tool ?? null;
|
|
271
|
+
entry.title = parsed.title ?? entry.title ?? null;
|
|
272
|
+
entry.lastPrompt = parsed.lastPrompt ?? entry.lastPrompt ?? null;
|
|
273
|
+
entry.cwdNow = parsed.cwd ?? entry.cwdNow ?? null;
|
|
274
|
+
entry.turnEndedAt = parsed.turnEndedAt;
|
|
275
|
+
} catch {
|
|
276
|
+
// unreadable right now; what was known stands
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Claude Code
|
|
281
|
+
|
|
282
|
+
async function indexClaude(now) {
|
|
283
|
+
const root = join(claudeDir, 'projects');
|
|
284
|
+
for (const dir of await listDir(root, { withFileTypes: true })) {
|
|
285
|
+
if (!dir.isDirectory()) continue;
|
|
286
|
+
for (const name of await listDir(join(root, dir.name))) {
|
|
287
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
288
|
+
const path = join(root, dir.name, name);
|
|
289
|
+
if (!claude.has(path)) claude.set(path, { agent: 'claude', id: name.slice(0, -6), path, dir: dir.name, children: new Map() });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
await eachLimit([...claude.values()], 16, async (entry) => {
|
|
293
|
+
await refresh(entry, now);
|
|
294
|
+
if (entry.gone) return void claude.delete(entry.path);
|
|
295
|
+
if (entry.headDone || entry.headMtime === entry.mtimeMs) return;
|
|
296
|
+
entry.headMtime = entry.mtimeMs;
|
|
297
|
+
try {
|
|
298
|
+
const head = parseClaudeHead(await readRange(entry.path, 0, Math.min(entry.size, CLAUDE_HEAD), counters));
|
|
299
|
+
entry.cwd = head.cwd;
|
|
300
|
+
entry.firstWords = head.firstWords;
|
|
301
|
+
entry.headDone = Boolean(head.cwd && head.firstWords) || entry.size >= CLAUDE_HEAD;
|
|
302
|
+
} catch {
|
|
303
|
+
// gone or unreadable; looked at again when it changes
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function readClaudeSessionFiles() {
|
|
309
|
+
const files = new Map(); // session id -> what the file says
|
|
310
|
+
const dir = join(claudeDir, 'sessions');
|
|
311
|
+
for (const name of await listDir(dir)) {
|
|
312
|
+
if (!name.endsWith('.json')) continue;
|
|
313
|
+
try {
|
|
314
|
+
const file = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
315
|
+
if (typeof file.sessionId !== 'string' || !Number.isInteger(file.pid)) continue;
|
|
316
|
+
files.set(file.sessionId, {
|
|
317
|
+
pid: file.pid,
|
|
318
|
+
sessionId: file.sessionId,
|
|
319
|
+
cwd: asString(file.cwd),
|
|
320
|
+
status: asString(file.status),
|
|
321
|
+
startedAt: typeof file.startedAt === 'number' ? file.startedAt : null,
|
|
322
|
+
});
|
|
323
|
+
} catch {
|
|
324
|
+
// being rewritten, or a format we do not know
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return files;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Subagents sit in <session>/subagents, and workflow agents one or two levels below.
|
|
331
|
+
async function indexClaudeSubagents(session, now, relist) {
|
|
332
|
+
if (relist) {
|
|
333
|
+
const walk = async (dir, depth) => {
|
|
334
|
+
for (const item of await listDir(dir, { withFileTypes: true })) {
|
|
335
|
+
const path = join(dir, item.name);
|
|
336
|
+
if (item.isDirectory() && depth < 2) await walk(path, depth + 1);
|
|
337
|
+
else if (item.isFile() && /^agent-.*\.jsonl$/.test(item.name) && !session.children.has(path)) {
|
|
338
|
+
session.children.set(path, { agent: 'claude', id: item.name.replace(/^agent-|\.jsonl$/g, ''), path });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
await walk(`${session.path.slice(0, -'.jsonl'.length)}/subagents`, 0);
|
|
343
|
+
}
|
|
344
|
+
await eachLimit([...session.children.values()], 16, async (child) => {
|
|
345
|
+
await refresh(child, now);
|
|
346
|
+
if (child.gone) session.children.delete(child.path);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function describeClaudeSubagent(child) {
|
|
351
|
+
if (!child.metaRead) {
|
|
352
|
+
child.metaRead = true;
|
|
353
|
+
try {
|
|
354
|
+
const meta = JSON.parse(await readFile(child.path.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
|
|
355
|
+
child.kind = asString(meta.agentType);
|
|
356
|
+
child.title = asString(meta.description);
|
|
357
|
+
} catch {
|
|
358
|
+
// no meta file, or a format we do not know
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const metaTitle = child.title;
|
|
362
|
+
await readTail(child, parseClaudeTail);
|
|
363
|
+
child.title = metaTitle ?? child.title;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Codex: heads never change, so each rollout's first 4 KB is read once.
|
|
367
|
+
|
|
368
|
+
async function addRollout(path, name) {
|
|
369
|
+
try {
|
|
370
|
+
const meta = parseCodexMeta(await readRange(path, 0, CODEX_META_HEAD, counters), name);
|
|
371
|
+
const entry = { agent: 'codex', path, ...meta, kind: meta.role };
|
|
372
|
+
codex.set(path, entry);
|
|
373
|
+
codexById.set(meta.id, entry);
|
|
374
|
+
} catch {
|
|
375
|
+
// gone already
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function scanCodex(newestOnly) {
|
|
380
|
+
const root = join(codexDir, 'sessions');
|
|
381
|
+
const dayDirs = [];
|
|
382
|
+
for (const year of (await listDir(root)).sort().reverse()) {
|
|
383
|
+
for (const month of (await listDir(join(root, year))).sort().reverse()) {
|
|
384
|
+
for (const day of (await listDir(join(root, year, month))).sort().reverse()) dayDirs.push(join(root, year, month, day));
|
|
385
|
+
if (newestOnly && dayDirs.length >= 2) break;
|
|
386
|
+
}
|
|
387
|
+
if (newestOnly && dayDirs.length >= 2) break;
|
|
388
|
+
}
|
|
389
|
+
for (const dir of newestOnly ? dayDirs.slice(0, 2) : dayDirs) {
|
|
390
|
+
const fresh = (await listDir(dir)).filter((name) => name.endsWith('.jsonl') && !codex.has(join(dir, name))).sort().reverse();
|
|
391
|
+
await eachLimit(fresh, 16, (name) => addRollout(join(dir, name), name));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The first scan reads thousands of small heads, so it runs beside the polls,
|
|
396
|
+
// newest first, and the list fills in from the top.
|
|
397
|
+
function keepCodexIndexed() {
|
|
398
|
+
if (!codexScan) {
|
|
399
|
+
codexScan = scanCodex(false)
|
|
400
|
+
.catch(() => {})
|
|
401
|
+
.then(() => {
|
|
402
|
+
codexScanned = true;
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return codexScanned ? scanCodex(true).catch(() => {}) : Promise.resolve();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function codexGroups(scope) {
|
|
409
|
+
const groups = new Map();
|
|
410
|
+
for (const entry of codex.values()) {
|
|
411
|
+
let root = entry;
|
|
412
|
+
let missing = null;
|
|
413
|
+
for (let hops = 0; hops < 32 && root.parent; hops++) {
|
|
414
|
+
const up = codexById.get(root.parent);
|
|
415
|
+
if (!up) {
|
|
416
|
+
missing = root.parent;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
root = up;
|
|
420
|
+
}
|
|
421
|
+
const key = missing ?? root.id;
|
|
422
|
+
let group = groups.get(key);
|
|
423
|
+
if (!group) groups.set(key, (group = { root: missing ? { agent: 'codex', id: missing, path: null } : root, children: [], inScope: false }));
|
|
424
|
+
if (entry !== group.root) group.children.push(entry);
|
|
425
|
+
if (inScope(scope, entry.cwd)) group.inScope = true;
|
|
426
|
+
}
|
|
427
|
+
return [...groups.values()].filter((group) => group.inScope);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function describeCodex(entry) {
|
|
431
|
+
await readTail(entry, parseCodexTail);
|
|
432
|
+
if (!entry.path || entry.gone || entry.titleDone || entry.titleSize === entry.size) return;
|
|
433
|
+
entry.titleSize = entry.size;
|
|
434
|
+
try {
|
|
435
|
+
const head = await readRange(entry.path, 0, Math.min(entry.size, CODEX_TITLE_HEAD), counters);
|
|
436
|
+
entry.firstWords = parseCodexTitle(jsonLines(head));
|
|
437
|
+
entry.titleDone = Boolean(entry.firstWords) || entry.size >= CODEX_TITLE_HEAD;
|
|
438
|
+
} catch {
|
|
439
|
+
// gone or unreadable
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The result
|
|
444
|
+
|
|
445
|
+
function publicEntry(entry) {
|
|
446
|
+
const { tool } = entry;
|
|
447
|
+
return {
|
|
448
|
+
agent: entry.agent,
|
|
449
|
+
id: entry.id,
|
|
450
|
+
kind: entry.kind ?? null,
|
|
451
|
+
nickname: entry.nickname ?? null,
|
|
452
|
+
title: entry.title ?? entry.firstWords ?? entry.task ?? entry.lastPrompt ?? null,
|
|
453
|
+
cwd: entry.cwdNow ?? entry.cwd ?? null,
|
|
454
|
+
mtimeMs: entry.mtimeMs ?? null,
|
|
455
|
+
size: entry.size ?? null,
|
|
456
|
+
tool: tool ? { name: tool.name, summary: tool.summary, at: tool.at, pending: tool.pending } : null,
|
|
457
|
+
turnEndedAt: entry.turnEndedAt ?? null,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// update() takes the process table and what lsof said, so that all of this can be
|
|
462
|
+
// tried on fixtures. table: ps rows. cwds: pid -> cwd. open: pid -> open files.
|
|
463
|
+
async function update({ scope, table, cwds = new Map(), open: openByPid = new Map() }) {
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
const rows = new Map(table.map((row) => [row.pid, row]));
|
|
466
|
+
|
|
467
|
+
await indexClaude(now);
|
|
468
|
+
await keepCodexIndexed();
|
|
469
|
+
const sessionFiles = await readClaudeSessionFiles();
|
|
470
|
+
|
|
471
|
+
// Which codex process holds which rollout open
|
|
472
|
+
const rolloutHolders = new Map();
|
|
473
|
+
for (const [pid, files] of openByPid) {
|
|
474
|
+
for (const file of files) {
|
|
475
|
+
if (file.name && /\/rollout-[^/]*\.jsonl$/.test(file.name)) rolloutHolders.set(file.name, pid);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const holder = (entry) => {
|
|
479
|
+
const pid = rolloutHolders.get(entry.path);
|
|
480
|
+
return pid === undefined ? null : { pid, command: rows.get(pid)?.command ?? null, via: 'open rollout' };
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const sessions = [];
|
|
484
|
+
const claimed = new Set();
|
|
485
|
+
const scopeSlugs = new Set(scope.map(slugOf));
|
|
486
|
+
|
|
487
|
+
// Claude Code sessions: a transcript started here, or a session file that says here
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
for (const entry of claude.values()) {
|
|
490
|
+
const file = sessionFiles.get(entry.id);
|
|
491
|
+
// The directory name is ambiguous (/a/b-c and /a/b/c share one), so it only decides when no cwd could be read.
|
|
492
|
+
if (!inScope(scope, entry.cwd) && !inScope(scope, file?.cwd) && (entry.cwd || !scopeSlugs.has(entry.dir))) continue;
|
|
493
|
+
seen.add(entry.id);
|
|
494
|
+
sessions.push({ entry, file });
|
|
495
|
+
}
|
|
496
|
+
for (const file of sessionFiles.values()) {
|
|
497
|
+
if (seen.has(file.sessionId) || !inScope(scope, file.cwd)) continue;
|
|
498
|
+
sessions.push({ entry: { agent: 'claude', id: file.sessionId, path: null, cwd: file.cwd, children: new Map() }, file });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const groups = [];
|
|
502
|
+
for (const { entry, file } of sessions) {
|
|
503
|
+
const row = file ? rows.get(file.pid) : null;
|
|
504
|
+
// A pid can be reused; a process younger than the session file is not its process.
|
|
505
|
+
const present = row && !(file.startedAt && row.startedAt > file.startedAt + 60000);
|
|
506
|
+
const hot = present || !entry.mtimeMs || now - entry.mtimeMs < COLD_AFTER_MS;
|
|
507
|
+
const relist = entry.path && (hot || !entry.listedAt || now - entry.listedAt > COLD_STAT_MS);
|
|
508
|
+
if (relist) entry.listedAt = now;
|
|
509
|
+
if (entry.path) await indexClaudeSubagents(entry, now, relist);
|
|
510
|
+
if (present) claimed.add(file.pid);
|
|
511
|
+
groups.push({
|
|
512
|
+
entry,
|
|
513
|
+
children: [...entry.children.values()],
|
|
514
|
+
sessionFile: file ? { pid: file.pid, status: file.status, present: Boolean(present) } : null,
|
|
515
|
+
process: present ? { pid: file.pid, command: row.command, via: 'session file' } : null,
|
|
516
|
+
describe: (e) => (e === entry ? readTail(e, parseClaudeTail) : describeClaudeSubagent(e)),
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Codex threads, grouped under their top thread
|
|
521
|
+
const inScopeCodex = codexGroups(scope);
|
|
522
|
+
await eachLimit(inScopeCodex.flatMap((group) => [group.root, ...group.children]).filter((e) => e.path), 16, async (entry) => {
|
|
523
|
+
await refresh(entry, now);
|
|
524
|
+
if (entry.gone) {
|
|
525
|
+
codex.delete(entry.path);
|
|
526
|
+
codexById.delete(entry.id);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
for (const group of inScopeCodex) {
|
|
530
|
+
const held = holder(group.root);
|
|
531
|
+
if (held) claimed.add(held.pid);
|
|
532
|
+
for (const child of group.children) {
|
|
533
|
+
const pid = rolloutHolders.get(child.path);
|
|
534
|
+
if (pid !== undefined) claimed.add(pid);
|
|
535
|
+
}
|
|
536
|
+
groups.push({
|
|
537
|
+
entry: group.root,
|
|
538
|
+
children: group.children,
|
|
539
|
+
sessionFile: null,
|
|
540
|
+
process: held,
|
|
541
|
+
missingParent: group.root.path === null,
|
|
542
|
+
describe: describeCodex,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
for (const group of groups) {
|
|
547
|
+
group.latest = group.children.reduce((most, child) => Math.max(most, child.mtimeMs ?? 0), group.entry.mtimeMs ?? 0);
|
|
548
|
+
}
|
|
549
|
+
groups.sort((a, b) => Number(Boolean(b.process)) - Number(Boolean(a.process)) || b.latest - a.latest);
|
|
550
|
+
|
|
551
|
+
const shown = groups.slice(0, MAX_SESSIONS);
|
|
552
|
+
const result = new Array(shown.length);
|
|
553
|
+
await eachLimit([...shown.keys()], 4, async (i) => {
|
|
554
|
+
const group = shown[i];
|
|
555
|
+
await group.describe(group.entry);
|
|
556
|
+
const children = group.children
|
|
557
|
+
.slice()
|
|
558
|
+
.sort((a, b) => Number(rolloutHolders.has(b.path)) - Number(rolloutHolders.has(a.path)) || (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0));
|
|
559
|
+
const top = children.slice(0, MAX_SUBAGENTS);
|
|
560
|
+
for (const child of top) await group.describe(child);
|
|
561
|
+
result[i] = {
|
|
562
|
+
...publicEntry(group.entry),
|
|
563
|
+
process: group.process,
|
|
564
|
+
sessionFile: group.sessionFile,
|
|
565
|
+
missingParent: group.missingParent ?? false,
|
|
566
|
+
subagents: {
|
|
567
|
+
total: children.length,
|
|
568
|
+
active: children.filter((c) => c.mtimeMs && now - c.mtimeMs < ACTIVE_MS).length,
|
|
569
|
+
shown: top.map((child) => ({ ...publicEntry(child), process: holder(child) })),
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Agent processes here that no session file or open rollout ties to a session
|
|
575
|
+
const unattached = [];
|
|
576
|
+
for (const row of table) {
|
|
577
|
+
const agent = agentOf(row.command);
|
|
578
|
+
if (agent && !claimed.has(row.pid) && inScope(scope, cwds.get(row.pid))) {
|
|
579
|
+
unattached.push({ agent, pid: row.pid, command: row.command, cwd: cwds.get(row.pid), startedAt: row.startedAt });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
scope,
|
|
585
|
+
sessions: result,
|
|
586
|
+
hiddenSessions: groups.length - shown.length,
|
|
587
|
+
unattached,
|
|
588
|
+
sources: {
|
|
589
|
+
claude: { transcripts: claude.size, noCwd: [...claude.values()].filter((e) => !e.cwd).length, sessionFiles: sessionFiles.size },
|
|
590
|
+
codex: {
|
|
591
|
+
rollouts: codex.size,
|
|
592
|
+
notUnderstood: [...codex.values()].filter((e) => !e.understood).length,
|
|
593
|
+
scanning: !codexScanned,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return { update, counters, codexScanDone: () => codexScan };
|
|
600
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// This server is a shell, so nothing is reachable without the token.
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export function createAuth(port) {
|
|
5
|
+
const token = randomBytes(32).toString('hex');
|
|
6
|
+
// Cookies ignore the port, so two servers on 127.0.0.1 need different names.
|
|
7
|
+
const cookieName = `accrete_ui_${port}`;
|
|
8
|
+
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
9
|
+
const origins = hosts.map((host) => `http://${host}`);
|
|
10
|
+
|
|
11
|
+
const isToken = (value) => {
|
|
12
|
+
const a = Buffer.from(String(value));
|
|
13
|
+
const b = Buffer.from(token);
|
|
14
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
token,
|
|
19
|
+
setCookie: `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`,
|
|
20
|
+
isToken,
|
|
21
|
+
hostOk: (req) => hosts.includes(req.headers.host),
|
|
22
|
+
originOk: (req) => origins.includes(req.headers.origin),
|
|
23
|
+
hasCookie: (req) =>
|
|
24
|
+
(req.headers.cookie ?? '').split(/;\s*/).some((pair) => {
|
|
25
|
+
const eq = pair.indexOf('=');
|
|
26
|
+
return pair.slice(0, eq) === cookieName && isToken(pair.slice(eq + 1));
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Runtime dependencies are found through this one directory: `vendor/` when a
|
|
2
|
+
// bundled copy exists, `node_modules/` otherwise. A bundle copies the packages
|
|
3
|
+
// it needs into `vendor/` in the same shape and nothing else has to change.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const vendor = fileURLToPath(new URL('../vendor/', import.meta.url));
|
|
9
|
+
export const depsDir = existsSync(vendor) ? vendor : fileURLToPath(new URL('../node_modules/', import.meta.url));
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
export const loadDep = (name) => require(depsDir + name);
|