@double_geste/agent-standup 1.25.10 → 1.27.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 +2 -2
- package/bin/agent-standup.js +5 -3
- package/codex.js +209 -0
- package/media/icon.png +0 -0
- package/package.json +4 -2
- package/public/favicon.svg +1 -0
- package/public/icon.svg +1 -0
- package/public/index.html +15 -16
- package/server.js +25 -19
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agent Standup (command line)
|
|
2
2
|
|
|
3
|
-
Your Claude Code sessions as a live team call, without any editor. Free, MIT, and it never calls Claude: it only reads the session files Claude Code already
|
|
3
|
+
Your Claude Code and Codex sessions as a live team call, without any editor. Free, MIT, and it never calls Claude: it only reads the session files Claude Code and Codex already write.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @double_geste/agent-standup # then open http://localhost:4747 (add --open to open it for you)
|
|
@@ -15,4 +15,4 @@ Install once for status bars (faster than npx): `npm i -g @double_geste/agent-st
|
|
|
15
15
|
|
|
16
16
|
Full documentation, the VS Code extension, the desktop app and the demo: https://atoll92.github.io/agent-standup/ · https://github.com/Atoll92/agent-standup.git
|
|
17
17
|
|
|
18
|
-
Unofficial: not affiliated with Anthropic.
|
|
18
|
+
Unofficial: not affiliated with Anthropic or OpenAI.
|
package/bin/agent-standup.js
CHANGED
|
@@ -9,12 +9,12 @@ const pkg = require('../package.json');
|
|
|
9
9
|
const argv = process.argv.slice(2), flags = {}, rest = [];
|
|
10
10
|
for (let i = 0; i < argv.length; i++) {
|
|
11
11
|
const a = argv[i], val = () => argv[++i];
|
|
12
|
-
if (a === '--port') flags.port = val(); else if (a === '--projects') flags.projects = val(); else if (a === '--characters') flags.characters = val(); else if (a === '--config-dir') flags.config = val();
|
|
12
|
+
if (a === '--port') flags.port = val(); else if (a === '--projects') flags.projects = val(); else if (a === '--codex') flags.codex = val(); else if (a === '--characters') flags.characters = val(); else if (a === '--config-dir') flags.config = val();
|
|
13
13
|
else if (a === '--open') flags.open = true; else if (a === '--here') flags.here = true; else if (a === '--json') flags.json = true; else if (a === '--titles') flags.titles = true;
|
|
14
14
|
else if (a === '-h' || a === '--help') flags.help = true; else if (a === '-v' || a === '--version') flags.version = true; else rest.push(a);
|
|
15
15
|
}
|
|
16
16
|
const cmd = rest[0] || 'serve';
|
|
17
|
-
const HELP = `Agent Standup ${pkg.version} — your Claude Code agents as a live video call
|
|
17
|
+
const HELP = `Agent Standup ${pkg.version} — your Claude Code and Codex agents as a live video call
|
|
18
18
|
|
|
19
19
|
Usage
|
|
20
20
|
agent-standup [serve] [--port 4747] [--open] start the local server (http://localhost:4747)
|
|
@@ -25,6 +25,7 @@ Options
|
|
|
25
25
|
--port N port of the local server (default 4747)
|
|
26
26
|
--open open the page in your browser
|
|
27
27
|
--projects DIR where the session files are (default ~/.claude/projects, or $CLAUDE_CONFIG_DIR/projects)
|
|
28
|
+
--codex DIR where Codex keeps its files (default ~/.codex, or $CODEX_HOME); "off" to leave Codex sessions out
|
|
28
29
|
--characters DIR folder with your own pixel-art characters (default ~/.agent-standup/characters)
|
|
29
30
|
--here status: only sessions of the current folder (matched on its real path, as Claude Code names it)
|
|
30
31
|
--json status: machine-readable output (add --titles to include session titles)
|
|
@@ -32,6 +33,7 @@ Options
|
|
|
32
33
|
if (flags.help || cmd === 'help') { process.stdout.write(HELP); process.exit(0); }
|
|
33
34
|
if (flags.version || cmd === 'version') { console.log(pkg.version); process.exit(0); }
|
|
34
35
|
if (flags.projects) process.env.AGENT_STANDUP_PROJECTS = path.resolve(flags.projects);
|
|
36
|
+
if (flags.codex !== undefined) process.env.AGENT_STANDUP_CODEX = /^(0|off|no|false)?$/i.test(String(flags.codex)) ? 'off' : path.resolve(String(flags.codex));
|
|
35
37
|
if (flags.config) process.env.CLAUDE_CONFIG_DIR = path.resolve(flags.config);
|
|
36
38
|
if (flags.characters) process.env.AGENT_STANDUP_CHARACTERS = path.resolve(flags.characters);
|
|
37
39
|
if (flags.port !== undefined) { // a real port number or nothing: no silent fallback to another port
|
|
@@ -42,7 +44,7 @@ const server = require('../server.js');
|
|
|
42
44
|
const projKey = (dir) => String(dir || '').replace(/[^a-zA-Z0-9]/g, '-'); // Claude Code's project folder naming
|
|
43
45
|
|
|
44
46
|
function snapshot(dir) {
|
|
45
|
-
const now = Date.now(), all = server.boardRows(), rows = dir ? all.filter((r) => r.proj === projKey(dir)) : all;
|
|
47
|
+
const now = Date.now(), all = server.boardRows(), rows = dir ? all.filter((r) => r.proj === projKey(dir) || r.dir === projKey(dir)) : all; // dir: the folder a Codex session was started in, named the same way
|
|
46
48
|
const act = rows.filter((r) => r.state !== 'idle' || now - r.mtime < 15 * 60e3), n = (st) => act.filter((r) => r.state === st).length;
|
|
47
49
|
return { rows: act, needsYou: n('blocked'), working: n('working') + n('waiting'), yourTurn: n('turn'), idle: rows.length - act.length };
|
|
48
50
|
}
|
package/codex.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Codex sessions (OpenAI's Codex: the desktop app and the command line) shown as the same call as Claude Code sessions.
|
|
2
|
+
// Codex writes one "rollout" file per thread in <home>/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl. Nothing is ever written there, and nothing else in the Codex folder is read
|
|
3
|
+
// but session_index.jsonl (the names of the threads). server.js reads the files with its own incremental reader and hands each record to parser() below, which turns it into the
|
|
4
|
+
// events a Claude Code record gives (say, user, tool, results, usage, compact), so everything after that is shared: the timeline, the live feed, the page.
|
|
5
|
+
// All Codex sessions are listed under one made-up project, PROJ. No Claude Code project folder can have that name: theirs are paths with every other character turned into "-".
|
|
6
|
+
const fs = require('fs'), os = require('os'), path = require('path');
|
|
7
|
+
const PROJ = '_codex';
|
|
8
|
+
// Where Codex keeps its files: AGENT_STANDUP_CODEX (a folder; "0" or "off" turns Codex sessions off), else $CODEX_HOME, else ~/.codex.
|
|
9
|
+
// When AGENT_STANDUP_PROJECTS points the app at one folder of Claude Code sessions (--projects, the tests), Codex is only read if it is asked for too.
|
|
10
|
+
function home() {
|
|
11
|
+
const v = process.env.AGENT_STANDUP_CODEX;
|
|
12
|
+
if (v != null) return /^(0|off|no|false)?$/i.test(v.trim()) ? null : v;
|
|
13
|
+
if (process.env.AGENT_STANDUP_PROJECTS) return null;
|
|
14
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
15
|
+
}
|
|
16
|
+
const dirKey = (cwd) => String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-'); // the name Claude Code gives the same folder: how "this project" is asked for everywhere else
|
|
17
|
+
|
|
18
|
+
// ---- finding the files
|
|
19
|
+
const NAME = /^rollout-.*?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
20
|
+
let memo = { at: 0, home: undefined, rows: [], byId: new Map() };
|
|
21
|
+
function scan() { // every rollout file with its date and size, newest first; shared by all the polls of two seconds
|
|
22
|
+
const h = home(), now = Date.now(); if (memo.home === h && now - memo.at < 2000) return memo;
|
|
23
|
+
const rows = [], byId = new Map();
|
|
24
|
+
const walk = (dir, depth) => {
|
|
25
|
+
let names; try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
26
|
+
for (const e of names) {
|
|
27
|
+
const fp = path.join(dir, e.name);
|
|
28
|
+
if (e.isDirectory()) { if (depth < 4) walk(fp, depth + 1); continue; }
|
|
29
|
+
const m = NAME.exec(e.name); if (!m) continue;
|
|
30
|
+
let st; try { st = fs.statSync(fp); } catch { continue; }
|
|
31
|
+
const row = { id: m[1].toLowerCase(), file: fp, mtime: st.mtimeMs, size: st.size }; rows.push(row); byId.set(row.id, row);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
if (h) walk(path.join(h, 'sessions'), 0);
|
|
35
|
+
rows.sort((a, b) => b.mtime - a.mtime);
|
|
36
|
+
memo = { at: now, home: h, rows, byId }; return memo;
|
|
37
|
+
}
|
|
38
|
+
const row = (id) => scan().byId.get(String(id).toLowerCase()) || null;
|
|
39
|
+
const fileOf = (id) => { const r = row(id); return r ? r.file : null; };
|
|
40
|
+
|
|
41
|
+
// ---- the first line of a file says what the thread is: its folder, and whether another thread started it
|
|
42
|
+
const headCache = new Map(); // file → { ok, cwd, dir, child } (the first line never changes)
|
|
43
|
+
function firstLine(file, max = 1024 * 1024) {
|
|
44
|
+
let fd; try {
|
|
45
|
+
fd = fs.openSync(file, 'r'); const parts = []; let pos = 0;
|
|
46
|
+
while (pos < max) { const buf = Buffer.alloc(64 * 1024), n = fs.readSync(fd, buf, 0, buf.length, pos); if (!n) break; const nl = buf.subarray(0, n).indexOf(10); if (nl >= 0) { parts.push(buf.subarray(0, nl)); return { text: Buffer.concat(parts).toString('utf8'), whole: true }; } parts.push(buf.subarray(0, n)); pos += n; }
|
|
47
|
+
return { text: Buffer.concat(parts).toString('utf8'), whole: false, capped: pos >= max };
|
|
48
|
+
} catch { return { text: '', whole: false }; } finally { if (fd != null) fs.closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
function head(file) {
|
|
51
|
+
const hit = headCache.get(file); if (hit) return hit;
|
|
52
|
+
const { text, whole, capped } = firstLine(file); let out = { ok: false, cwd: '', dir: '', child: false };
|
|
53
|
+
if (!text) return out; // not written yet: looked at again next time
|
|
54
|
+
let p = null; try { const r = JSON.parse(text); if (r && r.type === 'session_meta' && r.payload && typeof r.payload === 'object') p = r.payload; } catch {}
|
|
55
|
+
if (p) out = { ok: true, cwd: typeof p.cwd === 'string' ? p.cwd : '', child: !!p.parent_thread_id || !!(p.source && typeof p.source === 'object' && p.source.subagent) };
|
|
56
|
+
else if (capped && /^\{"timestamp":"[^"]*","(?:ordinal":\d+,")?type":"session_meta"/.test(text)) { // a first line longer than what was read: the two facts, without the rest
|
|
57
|
+
const cwd = text.match(/"cwd":"((?:[^"\\]|\\.)*)"/); let c = ''; try { c = cwd ? JSON.parse('"' + cwd[1] + '"') : ''; } catch {}
|
|
58
|
+
out = { ok: true, cwd: c, child: /"parent_thread_id":"[^"]+"/.test(text) || /"source":\{"subagent"/.test(text) };
|
|
59
|
+
}
|
|
60
|
+
out.dir = dirKey(out.cwd);
|
|
61
|
+
if (whole || capped) { if (headCache.size > 5000) headCache.clear(); headCache.set(file, out); } // a first line still being written is read again next time
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
// The threads a person started, newest first. A thread started by another thread (Codex's review of a command before it runs, a sub-agent) is not a session of its own.
|
|
65
|
+
function sessions(limit = 80) {
|
|
66
|
+
const out = []; for (const r of scan().rows) { const h = head(r.file); if (!h.ok || h.child) continue; out.push({ ...r, cwd: h.cwd, dir: h.dir }); if (out.length >= limit) break; }
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
const inProject = (id, key) => { const r = row(id); if (!r || !key) return false; if (key === PROJ) return true; const d = head(r.file).dir; return d === key || d.startsWith(key + '-'); };
|
|
70
|
+
const projectLabel = (cwd) => { const h = os.homedir(), rel = cwd && cwd.startsWith(h + path.sep) ? cwd.slice(h.length + 1) : cwd; return 'Codex' + (rel ? ' · ' + rel.split(path.sep).join('/') : ''); };
|
|
71
|
+
|
|
72
|
+
// ---- names: the one Codex gave the thread (session_index.jsonl, the last line for an id wins), else the first thing the person typed
|
|
73
|
+
let names = { sig: '', map: new Map() };
|
|
74
|
+
function threadNames() {
|
|
75
|
+
const h = home(); if (!h) return names.map;
|
|
76
|
+
const f = path.join(h, 'session_index.jsonl'); let st; try { st = fs.statSync(f); } catch { return names.map; }
|
|
77
|
+
const sig = f + ':' + st.mtimeMs + ':' + st.size; if (sig === names.sig) return names.map;
|
|
78
|
+
const map = new Map(); try { for (const l of fs.readFileSync(f, 'utf8').split('\n')) { if (!l) continue; try { const j = JSON.parse(l); if (j && j.id && typeof j.thread_name === 'string' && j.thread_name.trim()) map.set(String(j.id).toLowerCase(), j.thread_name.trim()); } catch {} } } catch {}
|
|
79
|
+
names = { sig, map }; return map;
|
|
80
|
+
}
|
|
81
|
+
function typed(t) { // what the person typed, without what Codex wraps around it: the tags of an answer to a question, and the list of attached files ("# Files mentioned by the user:" … "## My request:")
|
|
82
|
+
let s = String(t == null ? '' : t).replace(/<\/?send_user_message_question_reply>/g, ''); const i = s.indexOf('# Files mentioned by the user:');
|
|
83
|
+
if (i >= 0) { const rest = s.slice(i), m = /\n## My request[^\n]*:[ \t]*\n?/.exec(rest); s = s.slice(0, i) + (m ? '\n' + rest.slice(m.index + m[0].length) : ''); }
|
|
84
|
+
return s.trim();
|
|
85
|
+
}
|
|
86
|
+
const partsText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && typeof x.text === 'string' ? x.text : '')).filter(Boolean).join('\n') : '');
|
|
87
|
+
const promptCache = new Map(); // file → the first prompt ('' = looked for in the first megabyte and not there)
|
|
88
|
+
function firstPrompt(r) {
|
|
89
|
+
const hit = promptCache.get(r.file); if (hit != null) return hit;
|
|
90
|
+
let text = ''; try { const fd = fs.openSync(r.file, 'r'); try { const buf = Buffer.alloc(Math.min(1024 * 1024, r.size)); fs.readSync(fd, buf, 0, buf.length, 0); text = buf.toString('utf8'); } finally { fs.closeSync(fd); } } catch {}
|
|
91
|
+
let out = '';
|
|
92
|
+
for (const l of text.split('\n')) {
|
|
93
|
+
if (!l.includes('"UserMessage"') && !l.includes('"user_message"')) continue; let j; try { j = JSON.parse(l); } catch { continue; }
|
|
94
|
+
const p = (j && j.payload) || {}, t = p.type === 'user_message' ? typed(p.message) : p.type === 'item_completed' && p.item && p.item.type === 'UserMessage' ? typed(partsText(p.item.content)) : '';
|
|
95
|
+
if (t) { out = t.replace(/\s+/g, ' ').slice(0, 90); break; }
|
|
96
|
+
}
|
|
97
|
+
if (out || r.size > 64 * 1024) { if (promptCache.size > 2000) promptCache.clear(); promptCache.set(r.file, out); } // a file that has just been created is looked at again
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
const title = (r) => threadNames().get(r.id) || firstPrompt(r) || r.id;
|
|
101
|
+
|
|
102
|
+
// ---- the board: is the agent in the middle of a turn, or is it the person's turn? Read from the end of the file.
|
|
103
|
+
const tailCache = new Map();
|
|
104
|
+
function boardRow(r, now) {
|
|
105
|
+
const sig = r.mtime + ':' + r.size; let t = tailCache.get(r.file);
|
|
106
|
+
if (!t || t.sig !== sig) {
|
|
107
|
+
t = { sig, turn: '' };
|
|
108
|
+
try {
|
|
109
|
+
const fd = fs.openSync(r.file, 'r'); let text = ''; try { const n = Math.min(128 * 1024, r.size), buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, r.size - n); text = buf.toString('utf8'); } finally { fs.closeSync(fd); }
|
|
110
|
+
for (const l of text.split('\n')) { const m = /"type":"(task_started|task_complete|turn_started|turn_complete|turn_aborted)"/.exec(l.slice(0, 400)); if (m && l.includes('"event_msg"')) t.turn = /started/.test(m[1]) ? 'open' : 'closed'; }
|
|
111
|
+
} catch {}
|
|
112
|
+
if (tailCache.size > 200) tailCache.delete(tailCache.keys().next().value); tailCache.set(r.file, t);
|
|
113
|
+
}
|
|
114
|
+
const age = now - r.mtime, state = age >= 30 * 60e3 ? 'idle' : t.turn === 'closed' ? 'turn' : age < 10 * 60e3 ? 'working' : 'idle';
|
|
115
|
+
return { proj: PROJ, id: r.id, title: title(r), mtime: r.mtime, state, tool: '', ageMs: age, subActive: 0, subTotal: 0, dir: r.dir, source: 'codex' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- one record of a rollout file → the events of the call
|
|
119
|
+
// Two generations of the format are read. The current one (2026) writes an "item_completed" line for everything that happened: what was typed, what the agent said,
|
|
120
|
+
// each command with its output, each file it changed, each search. The earlier one wrote the messages as "user_message" / "agent_message" events and the tool calls as
|
|
121
|
+
// raw "function_call" records. A file of the current kind also carries those raw records (the script the model wrote around its commands), which would say everything twice:
|
|
122
|
+
// once the first item is seen, the raw tool calls are left alone.
|
|
123
|
+
function parser({ toolLabel, clip, resultMax = {} }) {
|
|
124
|
+
const shellText = (c) => (Array.isArray(c) ? (c.length === 3 && /^-l?c$/.test(c[1]) ? c[2] : c.join(' ')) : String(c == null ? '' : c));
|
|
125
|
+
const diffSides = (d) => { const add = [], del = []; for (const l of String(d || '').split('\n')) { if (/^(\+\+\+ |--- |@@|diff |index |\*\*\* )/.test(l)) continue; if (l[0] === '+') add.push(l.slice(1)); else if (l[0] === '-') del.push(l.slice(1)); } return { add: add.join('\n'), del: del.join('\n') }; };
|
|
126
|
+
const patchFiles = (text) => { // the "*** Update File: x" blocks of an apply_patch call
|
|
127
|
+
const out = []; let cur = null;
|
|
128
|
+
for (const l of String(text || '').split('\n')) { const m = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(l); if (m) { cur = { type: m[1].toLowerCase(), file: m[2].trim(), body: [] }; out.push(cur); } else if (cur && !/^\*\*\* (Begin|End) Patch/.test(l)) cur.body.push(l); }
|
|
129
|
+
return out.map((x) => ({ type: x.type, file: x.file, ...diffSides(x.body.join('\n')) }));
|
|
130
|
+
};
|
|
131
|
+
const NO_RESULT = new Set(['TodoWrite']);
|
|
132
|
+
return function record(st, r) {
|
|
133
|
+
if (r.type !== 'event_msg' && r.type !== 'response_item' && r.type !== 'turn_context' && r.type !== 'compacted') return;
|
|
134
|
+
const agent = st.agent, p = r.payload && typeof r.payload === 'object' ? r.payload : {}, cx = st.cx || (st.cx = { items: false, model: '', n: 0, tot: null, last: 0, userAt: -1 });
|
|
135
|
+
const ts = Math.max(Date.parse(r.timestamp) || 0, st.lastTs || 0); st.lastTs = ts; // the lines of one file are in order; a line without a time takes the last one
|
|
136
|
+
const push = (e) => { e.k = agent + ':' + st.raw.length; st.raw.push(e); if (e.ts > cx.last) cx.last = e.ts; return e; };
|
|
137
|
+
const th = () => { const t = st.th; st.th = ''; return t ? { th: t } : {}; };
|
|
138
|
+
const say = (text, final) => { const t = String(text == null ? '' : text).trim(); if (!t) return; const e = push({ ts, agent, kind: 'say', text: t, ...th() }); st.act = st.raw.length - 1; if (final && agent === 'main') e.end = 1; };
|
|
139
|
+
const user = (text) => { const t = typed(text); if (!t) return; push({ ts, agent: 'user', kind: 'user', text: t }); cx.userAt = st.raw.length - 1; };
|
|
140
|
+
const call = (id, name, input, at) => { const when = Math.max(Math.min(at || ts, ts), cx.last); st.toolName[id] = name; push({ ts: when, agent, kind: 'tool', tool: toolLabel({ id, name, input: input || {} }, when), ...th() }); st.act = st.raw.length - 1; }; // a command is placed when it started, never before what the file already said
|
|
141
|
+
const result = (id, out, err) => { const n = st.toolName[id]; if (!n || NO_RESULT.has(n)) return; push({ ts, agent, kind: 'results', items: [{ id, err: !!err, out: clip(String(out == null ? '' : out).trimEnd(), resultMax[n] || (/^mcp__/.test(n) ? 900 : 600)) }] }); };
|
|
142
|
+
const endTurn = () => { if (agent === 'main' && st.act > cx.userAt && st.raw[st.act]) st.raw[st.act].end = 1; }; // the turn is over (or was stopped): the last thing the lead said or did closes it
|
|
143
|
+
|
|
144
|
+
if (r.type === 'turn_context') { if (typeof p.model === 'string') cx.model = p.model; return; }
|
|
145
|
+
if (r.type === 'compacted') { push({ ts, agent, kind: 'compact' }); return; }
|
|
146
|
+
if (r.type === 'event_msg') {
|
|
147
|
+
if (p.type === 'item_completed' && p.item && typeof p.item === 'object') {
|
|
148
|
+
const it = p.item, id = String(it.id || 'cx' + st.raw.length), at = +p.started_at_ms || 0; cx.items = true;
|
|
149
|
+
if (it.type === 'UserMessage') user(partsText(it.content));
|
|
150
|
+
else if (it.type === 'AgentMessage') say(partsText(it.content), it.phase === 'final_answer');
|
|
151
|
+
else if (it.type === 'CommandExecution') { call(id, 'Bash', { command: shellText(it.command) }, at); result(id, it.aggregated_output != null ? it.aggregated_output : [it.stdout, it.stderr].filter(Boolean).join('\n'), it.status === 'failed' || (typeof it.exit_code === 'number' && it.exit_code !== 0)); }
|
|
152
|
+
else if (it.type === 'FileChange') Object.entries(it.changes && typeof it.changes === 'object' ? it.changes : {}).forEach(([file, c], i) => {
|
|
153
|
+
const cid = i ? id + ':' + i : id, kind = c && c.type, d = diffSides(c && c.unified_diff);
|
|
154
|
+
if (kind === 'add') call(cid, 'Write', { file_path: file, content: c.content != null ? String(c.content) : d.add }, at);
|
|
155
|
+
else call(cid, 'Edit', { file_path: file, old_string: kind === 'delete' && c.content != null ? String(c.content) : d.del, new_string: d.add }, at);
|
|
156
|
+
result(cid, '', it.status === 'failed'); // the line is written once the change is made: without an answer the call would look like it is still waiting (for an approval, on a file change)
|
|
157
|
+
});
|
|
158
|
+
else if (it.type === 'McpToolCall') { call(id, 'mcp__' + (it.server || 'mcp') + '__' + (it.tool || 'tool'), it.arguments && typeof it.arguments === 'object' ? it.arguments : {}, at); result(id, partsText(it.result && it.result.content), it.status === 'failed' || !!(it.result && it.result.isError)); }
|
|
159
|
+
else if (it.type === 'ImageView') { call(id, 'Read', { file_path: it.path }, at); result(id, ''); }
|
|
160
|
+
else if (it.type === 'Extension' && /^web\./.test(it.kind || '')) {
|
|
161
|
+
const a = it.action && typeof it.action === 'object' ? it.action : {}, q = it.query || a.query || (Array.isArray(a.queries) && a.queries[0]) || '';
|
|
162
|
+
if (a.url) call(id, 'WebFetch', { url: a.url }, at); else call(id, 'WebSearch', { query: q }, at);
|
|
163
|
+
result(id, (Array.isArray(it.results) ? it.results : []).slice(0, 6).map((x) => (x.title || '') + (x.url ? ' · ' + x.url : '')).join('\n'));
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (p.type === 'user_message') { if (!cx.items) user(p.message); return; }
|
|
168
|
+
if (p.type === 'agent_message') { if (!cx.items) say(p.message, p.phase === 'final_answer'); return; }
|
|
169
|
+
if (p.type === 'token_count') { // what the last request used. The same numbers are written again when only the rate limits moved: the running total tells
|
|
170
|
+
const info = p.info && typeof p.info === 'object' ? p.info : null, u = info && info.last_token_usage, tot = info && info.total_token_usage ? info.total_token_usage.total_tokens : null;
|
|
171
|
+
if (!u || (tot != null && tot === cx.tot)) return; cx.tot = tot;
|
|
172
|
+
const cached = u.cached_input_tokens || 0, w = u.cache_write_input_tokens || 0;
|
|
173
|
+
st.msgUsage.set('t' + cx.n++, { ts, u: { i: Math.max(0, (u.input_tokens || 0) - cached - w), o: u.output_tokens || 0, r: cached, w }, model: cx.model || 'codex', ...(info.model_context_window > 0 ? { cw: info.model_context_window } : {}) }); // cw: the size of the context window, which Codex states
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (p.type === 'task_complete' || p.type === 'turn_complete' || p.type === 'turn_aborted') endTurn();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// response_item: what the model sent, raw
|
|
180
|
+
if (p.type === 'reasoning') { // the summary Codex keeps of what the agent was thinking: an inner thought on its next line or call
|
|
181
|
+
const s = (Array.isArray(p.summary) ? p.summary : []).map((x) => (x && typeof x.text === 'string' ? x.text : '')).filter(Boolean).join('\n').replace(/\*\*/g, '');
|
|
182
|
+
if (s.trim()) st.th = clip(s.replace(/[^\S\n]+/g, ' ').replace(/ ?\n[\n ]*/g, '\n').trim(), 900);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const isCall = p.type === 'function_call' || p.type === 'custom_tool_call' || p.type === 'local_shell_call', id = String(p.call_id || p.id || '');
|
|
186
|
+
if (isCall && id) {
|
|
187
|
+
let a = {}; if (p.type === 'custom_tool_call') a = { input: p.input }; else if (p.type === 'local_shell_call') a = p.action && typeof p.action === 'object' ? p.action : {}; else { try { const v = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : p.arguments; if (v && typeof v === 'object') a = v; } catch {} }
|
|
188
|
+
const n = String(p.name || (p.type === 'local_shell_call' ? 'shell' : ''));
|
|
189
|
+
if (/^request_user_input/.test(n)) { call(id, 'AskUserQuestion', { questions: (Array.isArray(a.questions) ? a.questions : []).map((q) => ({ question: (q && (q.question || q.title)) || '', options: (q && Array.isArray(q.options) && q.options) || [] })) }); return; } // the agent asks the person something: in both generations
|
|
190
|
+
if (cx.items) return;
|
|
191
|
+
if (/^(shell|shell_command|exec_command|local_shell|unified_exec|container\.exec)$/.test(n)) call(id, 'Bash', { command: shellText(a.command != null ? a.command : a.cmd) });
|
|
192
|
+
else if (n === 'apply_patch') { const fl = patchFiles(a.input != null ? a.input : a.patch); if (!fl.length) call(id, 'Edit', { file_path: '', old_string: '', new_string: clip(a.input || a.patch || '', 500) }); if (fl.length > 1) (cx.more = cx.more || {})[id] = fl.length; fl.forEach((f, i) => { const cid = i ? id + ':' + i : id; if (f.type === 'add') call(cid, 'Write', { file_path: f.file, content: f.add }); else call(cid, 'Edit', { file_path: f.file, old_string: f.del, new_string: f.add }); }); }
|
|
193
|
+
else if (n === 'update_plan') call(id, 'TodoWrite', { todos: (Array.isArray(a.plan) ? a.plan : []).map((x) => ({ content: (x && x.step) || '', status: (x && x.status) || 'pending' })) });
|
|
194
|
+
else if (n === 'view_image') call(id, 'Read', { file_path: a.path });
|
|
195
|
+
else if (n) call(id, p.namespace ? String(p.namespace).replace(/::$/, '') + '__' + n : n, a);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if ((p.type === 'function_call_output' || p.type === 'custom_tool_call_output' || p.type === 'local_shell_call_output') && id) {
|
|
199
|
+
if (cx.items && st.toolName[id] !== 'AskUserQuestion') return; // in the current format the item already carried the output (an MCP call has the same id in both: it would be answered twice, the second time without its failure)
|
|
200
|
+
let out = p.output, err = false;
|
|
201
|
+
if (typeof out === 'string' && out[0] === '{') { try { const j = JSON.parse(out); if (j && typeof j === 'object' && ('output' in j || 'metadata' in j)) { err = !!(j.metadata && typeof j.metadata.exit_code === 'number' && j.metadata.exit_code !== 0); out = j.output != null ? j.output : ''; } } catch {} }
|
|
202
|
+
else if (out && typeof out === 'object' && !Array.isArray(out)) { err = out.success === false; out = out.content != null ? out.content : ''; }
|
|
203
|
+
result(id, partsText(out), err); for (let i = 1; i < ((cx.more || {})[id] || 0); i++) result(id + ':' + i, '', err);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (p.type === 'web_search_call' && !cx.items && id) call(id, 'WebSearch', { query: (p.action && p.action.query) || '' });
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
module.exports = { PROJ, home, dirKey, sessions, row, fileOf, inProject, projectLabel, title, boardRow, parser };
|
package/media/icon.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@double_geste/agent-standup",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
3
|
+
"version": "1.27.0",
|
|
4
|
+
"description": "Your Claude Code and Codex agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-standup": "bin/agent-standup.js"
|
|
7
7
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"server.js",
|
|
12
|
+
"codex.js",
|
|
12
13
|
"kokoro.js",
|
|
13
14
|
"kokoro-worker.js",
|
|
14
15
|
"demo.js",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"keywords": [
|
|
34
35
|
"claude-code",
|
|
35
36
|
"claude",
|
|
37
|
+
"codex",
|
|
36
38
|
"agents",
|
|
37
39
|
"sub-agents",
|
|
38
40
|
"monitoring",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><clipPath id="room_16_c"><rect width="1024" height="1024" rx="230"/></clipPath><linearGradient id="room_16_room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient><linearGradient id="room_16_body" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="#efa98f"/><stop offset="1" stop-color="#e8845f"/></linearGradient><linearGradient id="room_16_desk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#506b73"/><stop offset="1" stop-color="#2f4850"/></linearGradient><linearGradient id="room_16_scene" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient></defs><g clip-path="url(#room_16_c)"><rect width="1024" height="1024" fill="url(#room_16_room)"/><g transform="translate(-355.797 -138.847) scale(17.35593)"><ellipse cx="25" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(12 25 56)"/><rect x="27" y="20" width="46" height="56" rx="22" fill="url(#room_16_body)"/><ellipse cx="75" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(-12 75 56)"/><ellipse cx="41.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><ellipse cx="58.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><path d="M37.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.8" stroke-linecap="round" transform="rotate(13 41.5 32.2) translate(0 .5)"/><path d="M54.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.8" stroke-linecap="round" transform="rotate(-13 58.5 32.2) translate(0 .5)"/><path d="M46 50.5q4 3.6 8 0" fill="none" stroke="#2a2233" stroke-width="2.9" stroke-linecap="round"/><path d="M26.5 37Q27 13 50 13Q73 13 73.5 37" fill="none" stroke="#2c2f40" stroke-width="4.2" stroke-linecap="round"/><rect x="23" y="33" width="7" height="14" rx="3.5" fill="#f5c04a"/><rect x="70" y="33" width="7" height="14" rx="3.5" fill="#f5c04a"/></g></g></svg>
|
package/public/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><clipPath id="night_32_c"><rect width="1024" height="1024" rx="230"/></clipPath><linearGradient id="night_32_room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient><linearGradient id="night_32_body" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="#efa98f"/><stop offset="1" stop-color="#e8845f"/></linearGradient><linearGradient id="night_32_desk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#506b73"/><stop offset="1" stop-color="#2f4850"/></linearGradient><linearGradient id="night_32_scene" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient></defs><g clip-path="url(#night_32_c)"><rect width="1024" height="1024" fill="url(#night_32_room)"/><g transform="translate(-300.698 -97.524) scale(16.25397)"><ellipse cx="25" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(12 25 56)"/><rect x="27" y="20" width="46" height="56" rx="22" fill="url(#night_32_body)"/><ellipse cx="75" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(-12 75 56)"/><ellipse cx="35.5" cy="46" rx="3.3" ry="2" fill="#ff6f91" opacity=".34"/><ellipse cx="64.5" cy="46" rx="3.3" ry="2" fill="#ff6f91" opacity=".34"/><ellipse cx="41.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><circle cx="42.5" cy="38.5" r="1.1" fill="#fff"/><ellipse cx="58.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><circle cx="59.5" cy="38.5" r="1.1" fill="#fff"/><path d="M37.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.1" stroke-linecap="round" transform="rotate(13 41.5 32.2) translate(0 .5)"/><path d="M54.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.1" stroke-linecap="round" transform="rotate(-13 58.5 32.2) translate(0 .5)"/><path d="M46 50.5q4 3.6 8 0" fill="none" stroke="#2a2233" stroke-width="2.2" stroke-linecap="round"/><path d="M26.5 37Q27 13 50 13Q73 13 73.5 37" fill="none" stroke="#2c2f40" stroke-width="3.7" stroke-linecap="round"/><rect x="23" y="33" width="7" height="14" rx="3.5" fill="#f5c04a" stroke="#2c2f40" stroke-width="1.4"/><rect x="70" y="33" width="7" height="14" rx="3.5" fill="#f5c04a" stroke="#2c2f40" stroke-width="1.4"/><path d="M27 45q-2 11 13 12" fill="none" stroke="#2c2f40" stroke-width="2" stroke-linecap="round"/><circle cx="40" cy="57" r="2.53" fill="#2c2f40"/><rect x="-20" y="62" width="140" height="33" fill="url(#night_32_desk)"/><rect x="-20" y="62" width="140" height="1.3" fill="#fff" opacity=".38"/><rect x="-20" y="63.3" width="140" height="2.6" fill="#000" opacity=".1"/><g transform="translate(-11.5 0)"><rect x="79" y="55.6" width="7.4" height="6.8" rx="1.6" fill="#f6f2ea"/><path d="M86.2 57q3.2 0 3.2 2.2t-3.2 2.2" fill="none" stroke="#f6f2ea" stroke-width="1.3"/><g fill="none" stroke="#fff" stroke-width=".8" stroke-linecap="round" opacity=".7"><path d="M81.5 53q-1.4-1.8 0-3.4M84.3 53q-1.4-1.8 0-3.4"/></g></g></g></g></svg>
|
package/public/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>Agent Standup</title>
|
|
7
|
-
<link rel="icon" href="
|
|
7
|
+
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
|
8
8
|
<style>
|
|
9
9
|
:root { --bg:#12131a; --panel:#1b1d27; --panel2:#232634; --line:#2a2d3b; --text:#e9eaf0; --dim:#9aa0b6; --ease:cubic-bezier(.2,.8,.2,1); --accent:#7c9cff; --gold:#f5c04a; --ok:#58d68d; --warn:#f0a020; --red:#ff5a5a; --r:10px }
|
|
10
10
|
* { box-sizing:border-box }
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
.brand { display:flex; align-items:center; gap:9px; min-width:0; overflow:hidden } .brand .logo { flex:none } .brand > div { min-width:0 } .brand b { overflow:hidden; text-overflow:ellipsis }
|
|
21
21
|
header > * { flex-shrink:0 } header .brand { flex-shrink:100 } header #board { flex-shrink:50 } .tok, .ctxp { flex-shrink:2; min-width:0; overflow:hidden; text-overflow:ellipsis } #pill { flex-shrink:.05; min-width:0; overflow:hidden; text-overflow:ellipsis } /* in a narrow window the buttons (the gear above all) stay whole: the name gives way first, then the counters */
|
|
22
22
|
@media (max-width:720px) { .ctxp { display:none !important } } @media (max-width:620px) { .tok { display:none !important } } @media (max-width:560px) { #bCoffee { display:none !important } } @media (max-width:480px) { header { gap:6px; padding-left:8px; padding-right:8px } } /* narrow: the coffee link stays in ⚙ › Tools */
|
|
23
|
-
.logo { width:26px; height:26px;
|
|
23
|
+
.logo { width:26px; height:26px; display:block; flex:none }
|
|
24
24
|
.brand b { font:700 14px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif; white-space:nowrap }
|
|
25
25
|
.brand small { display:block; color:var(--dim); font-size:11px; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
26
26
|
.sp { flex:1 }
|
|
@@ -255,7 +255,7 @@
|
|
|
255
255
|
</head>
|
|
256
256
|
<body>
|
|
257
257
|
<header>
|
|
258
|
-
<div class="brand"><
|
|
258
|
+
<div class="brand"><img class="logo" src="icon.svg" alt=""><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
|
|
259
259
|
<span id="pill">REPLAY</span><button id="tok" class="tok" style="display:none"></button><button id="ctxp" class="ctxp" style="display:none"></button><span id="board" aria-live="polite"></span><span id="meet" class="meet"></span>
|
|
260
260
|
<div class="sp"></div>
|
|
261
261
|
<div class="seg" id="viewSeg"><button data-v="call" class="on" data-ic="👥" data-fr="Appel" data-en="Call">Appel</button><button data-v="tree" data-ic="🌳" data-fr="Arbre" data-en="Tree">Arbre</button><button data-v="board" data-ic="🗂" data-fr="Sessions" data-en="Sessions">Sessions</button></div>
|
|
@@ -343,7 +343,6 @@ const SUPPORT = { url: 'https://www.buymeacoffee.com/bored_dev_and_his_robots' }
|
|
|
343
343
|
const CID = Math.random().toString(36).slice(2, 12); // identifies this window for the audio-ownership handshake
|
|
344
344
|
const Q = new URLSearchParams(location.search);
|
|
345
345
|
const EMBED = Q.get('embed') === '1' || window.parent !== window;
|
|
346
|
-
if (Q.get('pin')) S.pin = Q.get('pin');
|
|
347
346
|
|
|
348
347
|
let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call', hideCode: false };
|
|
349
348
|
{ let loc = {}; try { loc = JSON.parse(localStorage.getItem('cc.prefs') || '{}') || {}; } catch {} // the settings file (shared by every window) and this origin's copy: the more recent one wins
|
|
@@ -414,7 +413,7 @@ function renderCharInfo() {
|
|
|
414
413
|
function persona(a) { const p = persona0(a); p.custom = customFor(a.id); return p; }
|
|
415
414
|
function persona0(a) {
|
|
416
415
|
const h = hash(a.id), base = { ...a };
|
|
417
|
-
if (a.id === 'main') return { ...base, name: 'Claude', role: L('Chef', 'Lead'), shirt: '#e8845f', shape: 0, acc: 1, eye: 0, glasses: false, scene: 6, prop: 1, acc2: '#f5c04a', lead: true, vi: 0, gid: 'main' };
|
|
416
|
+
if (a.id === 'main') return { ...base, name: a.name || 'Claude', role: L('Chef', 'Lead'), shirt: '#e8845f', shape: 0, acc: 1, eye: 0, glasses: false, scene: 6, prop: 1, acc2: '#f5c04a', lead: true, vi: 0, gid: 'main' };
|
|
418
417
|
if (a.id === 'user') return { ...base, name: L('Vous', 'You'), role: L('Humain', 'Human'), shirt: '#8a94c9', shape: 1, acc: 2, eye: 1, glasses: false, scene: 7, prop: 1, acc2: '#ff7a6b', vi: 3, gid: 'user' };
|
|
419
418
|
return { ...base, name: a.name || uniqueName(a.id, h), roleEn: a.roleEn || a.role, role: (FR() && a.roleFr) || a.roleEn || a.role, shirt: PAL[(h >>> 6) % 8], shape: (h >>> 3) % 5, acc: (h >>> 9) % 6, eye: (h >>> 12) % 3, glasses: ((h >>> 15) % 4) === 0,
|
|
420
419
|
scene: (h >>> 18) % 6, prop: (h >>> 21) % 3, acc2: PAL[(h >>> 24) % 8], vi: h >>> 4, gid: h.toString(36) };
|
|
@@ -452,10 +451,9 @@ const SCENES = [
|
|
|
452
451
|
<circle cx="22" cy="26" r="11" fill="#ff9bb8"/><ellipse cx="22" cy="26" rx="18" ry="3.6" fill="none" stroke="#ffe0a8" stroke-width="1.6" transform="rotate(-18 22 26)"/><path d="M13 22q9 4 18 1" stroke="#e86f98" stroke-width="1.4" fill="none"/>
|
|
453
452
|
<circle cx="80" cy="20" r="6" fill="#6fd3ff"/><circle cx="77.5" cy="18" r="1.4" fill="#4ab6e6"/>${star(60, 14, 3)}${star(90, 40, 2.2, '#cfc2ff')}` },
|
|
454
453
|
{ desk: ['#506b73', '#2f4850'], svg: () => `<defs>${grad('gs6', '#10282f', '#18444f')}</defs><rect width="100" height="75" fill="url(#gs6)"/>
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
<g fill="#ffe9a8" opacity=".7"><circle cx="20" cy="36" r="1" class="pulse"/><circle cx="50" cy="38" r="1" class="pulse" style="animation-delay:-.6s"/><circle cx="80" cy="36" r="1" class="pulse" style="animation-delay:-1.1s"/></g>` },
|
|
454
|
+
<rect x="9" y="-6" width="82" height="42" rx="3" fill="#0a1a1f"/>
|
|
455
|
+
${[[12, 22, 9], [22, 31, 8], [68, 27, 9], [78, 19, 9]].map(([x, h, w], i) => `<rect x="${x}" y="${36 - h}" width="${w}" height="${h}" fill="${i % 2 ? '#15373f' : '#123038'}"/>` + [0, 1, 2].map((r) => `<rect x="${x + 1.6 + ((i + r) % 2) * 3.4}" y="${39 - h + r * 6.4}" width="1.8" height="2.4" rx=".4" fill="#ffd84a" opacity="${r === 1 ? '.9' : '.5'}"${r === 1 ? ` class="tw" style="animation-delay:-${i * .8}s"` : ''}/>`).join('')).join('')}
|
|
456
|
+
<path d="M36.6 0V36M63.4 0V36" stroke="#2b6a78" stroke-width="1.2"/><rect x="9" y="-6" width="82" height="42" rx="3" fill="none" stroke="#2b6a78" stroke-width="1.2"/>` },
|
|
459
457
|
{ desk: ['#a87a56', '#7c5438'], svg: () => `<defs>${grad('gs7', '#3a2b52', '#6b4a68')}<radialGradient id="gl7"><stop offset="0" stop-color="#ffd98a" stop-opacity=".75"/><stop offset="1" stop-color="#ffd98a" stop-opacity="0"/></radialGradient></defs>
|
|
460
458
|
<rect width="100" height="75" fill="url(#gs7)"/><circle cx="82" cy="30" r="26" fill="url(#gl7)"/>
|
|
461
459
|
<path d="M82 54V36" stroke="#2a2233" stroke-width="1.6"/><path d="M75 36q7-9 14 0z" fill="#ffd98a"/>
|
|
@@ -526,6 +524,7 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
|
|
|
526
524
|
const S = { owner: null, errRun: {}, exQ: [], agents: {}, events: [], idx: 0, playing: false, token: 0, status: {}, tc: {}, cur: null, live: true, userPaused: false, boost: 1, stopAt: 0,
|
|
527
525
|
speakers: new Set(), say: {}, lastTool: {}, level: {}, detail: null, collapsed: new Set(), title: '', idle: false, locked: true, logTo: 0, trOk: true,
|
|
528
526
|
clock: 0, doneAt: {}, gone: new Set(), leaving: new Set(), demo: false, hold: false, late: [], byK: new Map(), pos: 0 };
|
|
527
|
+
if (Q.get('pin')) S.pin = Q.get('pin'); // a session asked for in the address (the status bar opens the call on one): set here, once S exists (set above it, it stopped the whole page)
|
|
529
528
|
const VANISH_MS = 120000; // a finished agent leaves the call after 2 minutes (playback time)
|
|
530
529
|
// Live: how far the call is behind the real session, and what to do about it (speak faster, say less, skip decoration) so it keeps the rhythm of the real conversation
|
|
531
530
|
const lagMs = (i = S.idx) => { // how much newer the newest event of the session is than the one being played: 0 when the call is up to date, however long the session has been idle
|
|
@@ -1042,7 +1041,7 @@ const applyStatus = (ev) => {
|
|
|
1042
1041
|
if (ev.kind === 'user' || (ev.agent === 'main' && (ev.kind === 'say' || ev.kind === 'tools' || (ev.kind === 'order' && !ev.wf)))) S.turnOver = !!ev.end; // Claude Code marks the message that closes the lead's turn; anything after it opens the next one (not the agents a workflow launches by itself)
|
|
1043
1042
|
};
|
|
1044
1043
|
function recomputeStatus(upTo) {
|
|
1045
|
-
S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.turnOver = false; S.th = {}; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
|
|
1044
|
+
S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.turnOver = false; S.th = {}; S.ctx = {}; S.model = {}; S.cw = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
|
|
1046
1045
|
S.status = {}; S.tc = {}; S.doneAt = {}; S.gone.clear(); S.leaving.clear(); document.querySelectorAll('.tile.vanishing').forEach((t) => t.classList.remove('vanishing')); const doneIdx = {}; document.querySelectorAll('.ticker').forEach((x) => { x.classList.remove('on'); x.innerHTML = ''; });
|
|
1047
1046
|
for (let i = 0; i < upTo; i++) {
|
|
1048
1047
|
const e = S.events[i]; applyStatus(e); tally(e); if (e.kind === 'tools') S.tc[e.agent] = (S.tc[e.agent] || 0) + e.tools.length;
|
|
@@ -1061,7 +1060,7 @@ function syncUI() {
|
|
|
1061
1060
|
|
|
1062
1061
|
// ---------- tool ticker, shared screen, flying messages ----------
|
|
1063
1062
|
// ---------- what each agent is doing: tool calls with their real outputs ----------
|
|
1064
|
-
S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.present = null; S.camFor = null; let presentTimer = null;
|
|
1063
|
+
S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.ctx = {}; S.model = {}; S.cw = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.present = null; S.camFor = null; let presentTimer = null;
|
|
1065
1064
|
const later = (k, fn, ms = 160) => { later.p = later.p || {}; if (later.p[k]) return; later.p[k] = setTimeout(() => { later.p[k] = 0; fn(); }, ms); }; // coalesce bursts of repaints
|
|
1066
1065
|
const fmtTok = (n) => (n < 1000 ? String(n) : n < 1e6 ? (n / 1000).toFixed(n < 1e4 ? 1 : 0) + 'k' : (n / 1e6).toFixed(n < 1e7 ? 2 : 1) + 'M');
|
|
1067
1066
|
const tokTotal = (u) => (u ? u.i + u.o + u.w : 0); // headline = input + output + cache writes; cache reads are cheap and shown separately
|
|
@@ -1079,14 +1078,14 @@ function ctxWin(model, ctx) { const base = WIN1M.test(model || '') ? 1e6 : 2e5;
|
|
|
1079
1078
|
function tally(ev) { // numbers that pile up over a session: token usage, errors, last sign of life
|
|
1080
1079
|
if (ev.kind === 'compact') { S.compacts[ev.agent] = (S.compacts[ev.agent] || 0) + 1; delete S.ctxWarned[ev.agent]; }
|
|
1081
1080
|
const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? (S.loops[ev.agent].kind === 'errors' && ev.items.every((it) => !it.err)) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
|
|
1082
|
-
if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
|
|
1081
|
+
if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; if (ev.cw) S.cw[ev.agent] = ev.cw; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
|
|
1083
1082
|
else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); noteTrouble(ev.agent, false); } else noteTrouble(ev.agent, true); });
|
|
1084
1083
|
else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
|
|
1085
1084
|
if (ev.th) S.th[ev.agent] = { text: ev.th, ts: ev.ts }; else if (ev.kind === 'user') delete S.th.main; // your order starts a new chain of thought: what the lead thought before is no longer what it is on
|
|
1086
1085
|
if (ev.kind === 'results') S.lastAct[ev.agent] = ev.ts; else if (ev.kind === 'user') S.lastAct.main = ev.ts; // a result coming back and your own order are signs of life too: the lead is thinking about them
|
|
1087
1086
|
}
|
|
1088
1087
|
const tokTip = (u) => `${L('Jetons enregistrés par Claude Code (comptés une fois par message, ce n’est pas une facture)', 'Tokens recorded by Claude Code (counted once per message, not a bill)')}\n${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}`;
|
|
1089
|
-
const ctxInfo = (id) => { const c = S.ctx[id]; if (!c) return null; const w = ctxWin(S.model[id], c), p = Math.round((c / w) * 100); return { c, w, p, lvl: p >= 88 ? 'hi' : p >= 70 ? 'mid' : 'lo' }; };
|
|
1088
|
+
const ctxInfo = (id) => { const c = S.ctx[id]; if (!c) return null; const w = S.cw[id] || ctxWin(S.model[id], c), p = Math.round((c / w) * 100); return { c, w, p, lvl: p >= 88 ? 'hi' : p >= 70 ? 'mid' : 'lo' }; };
|
|
1090
1089
|
function renderCtx() { // "context N% full": per agent chip (from 50%), a header chip for the lead, and one warning before auto-compaction
|
|
1091
1090
|
for (const id in S.agents) { const t = $('#t-' + id); if (!t) continue; const el = t.querySelector('.cx'), x = ctxInfo(id);
|
|
1092
1091
|
if (el) { if (x && x.p >= 50) { el.textContent = `ctx ${x.p}%`; el.className = 'cx ' + x.lvl; el.title = `${L('Contexte', 'Context')}: ${fmtTok(x.c)} / ${fmtTok(x.w)}`; } else { el.textContent = ''; el.className = 'cx'; } }
|
|
@@ -1180,11 +1179,11 @@ function watchDone(n) { // live: the team was busy for a while and has now bee
|
|
|
1180
1179
|
if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*');
|
|
1181
1180
|
else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} }
|
|
1182
1181
|
}
|
|
1183
|
-
const FAV0 = (document.querySelector('link[rel="icon"]') || {}).href;
|
|
1182
|
+
const FAV0 = (document.querySelector('link[rel="icon"]') || {}).href, FAVI = new Image(); FAVI.src = FAV0 || '';
|
|
1184
1183
|
function setFav(color) {
|
|
1185
1184
|
const l = document.querySelector('link[rel="icon"]'); if (!l || setFav.cur === color) return; setFav.cur = color;
|
|
1186
1185
|
if (!color) { l.href = FAV0; return; }
|
|
1187
|
-
const c = document.createElement('canvas'); c.width = c.height = 32; const g = c.getContext('2d');
|
|
1186
|
+
const c = document.createElement('canvas'); c.width = c.height = 32; const g = c.getContext('2d'); try { g.drawImage(FAVI, 0, 0, 32, 32); } catch {} g.fillStyle = color; g.beginPath(); g.arc(24, 8, 8, 0, 7); g.fill(); g.strokeStyle = '#12131a'; g.lineWidth = 2; g.stroke(); l.href = c.toDataURL();
|
|
1188
1187
|
}
|
|
1189
1188
|
function updateTitle() {
|
|
1190
1189
|
paintStates(); if (S.live && S.nBlocked && !S.wasBlocked && prefs.notify) { AUD.alert(); const msg = L('Un agent attend peut-être votre feu vert ✋', 'An agent may be waiting for you ✋'); if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*'); else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} } } S.wasBlocked = S.nBlocked > 0;
|
|
@@ -1913,7 +1912,7 @@ async function loadSession(proj, id) {
|
|
|
1913
1912
|
if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
|
|
1914
1913
|
$('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
|
|
1915
1914
|
}
|
|
1916
|
-
const niceProj = (p) => (p === '_demo' ? '' : String(p).replace(/^-+/, '').replace(/-/g, '/').split('/').slice(-2).join('/'));
|
|
1915
|
+
const niceProj = (p) => (p === '_demo' ? '' : p === '_codex' ? 'Codex' : String(p).replace(/^-+/, '').replace(/-/g, '/').split('/').slice(-2).join('/'));
|
|
1917
1916
|
async function fillProjects() { // live view: follow the latest activity anywhere, or pin one project
|
|
1918
1917
|
const sel = $('#liveProj'); let rows = []; try { rows = await (await fetch('/api/projects')).json(); } catch {}
|
|
1919
1918
|
sel.innerHTML = `<option value="">${L('Tous les projets (le plus récent)', 'All projects (most recent)')}</option>` + rows.map((r) => `<option value="${esc(r.proj)}">${esc(niceProj(r.proj))}</option>`).join('');
|
package/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const crypto = require('crypto');
|
|
9
|
+
const codex = require('./codex.js'); // Codex sessions, read as the same call (see codex.js)
|
|
9
10
|
const kokoro = require('./kokoro.js'); // natural voices, installed on demand (see kokoro.js)
|
|
10
11
|
const zlib = require('zlib');
|
|
11
12
|
const { execFileSync } = require('child_process');
|
|
@@ -102,8 +103,8 @@ function listSessions() {
|
|
|
102
103
|
}
|
|
103
104
|
function listSessions0() {
|
|
104
105
|
const rows = [];
|
|
105
|
-
|
|
106
|
-
for (const proj of fs.readdirSync(ROOT)) {
|
|
106
|
+
for (const r of codex.sessions(30)) rows.push({ proj: codex.PROJ, id: r.id, mtime: r.mtime, size: r.size, agents: 0, cx: r });
|
|
107
|
+
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
107
108
|
const dir = path.join(ROOT, proj);
|
|
108
109
|
let files;
|
|
109
110
|
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
@@ -114,8 +115,10 @@ function listSessions0() {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
rows.sort((a, b) => b.mtime - a.mtime);
|
|
117
|
-
const top = rows.slice(0, 60);
|
|
118
|
-
for (const r of top) {
|
|
118
|
+
const top = rows.filter((r) => !r.cx).slice(0, 60).concat(rows.filter((r) => r.cx)).sort((a, b) => b.mtime - a.mtime); // the 60 most recent Claude Code sessions as always, and up to 30 Codex ones beside them: neither pushes the other out
|
|
119
|
+
for (const r of top) {
|
|
120
|
+
if (r.cx) { const c = r.cx; delete r.cx; r.title = codex.title(c); r.project = codex.projectLabel(c.cwd); r.source = 'codex'; continue; }
|
|
121
|
+
// titles come from the cached tail read (custom title, then AI title, then first prompt): no whole-file reads
|
|
119
122
|
let t = null; try { const f = path.join(ROOT, r.proj, r.id + '.jsonl'); t = parseTail(f, fs.statSync(f)).title; } catch {}
|
|
120
123
|
r.title = t || r.id; r.project = r.proj.replace(/^-Users-[^-]+-/, '').replace(/-/g, '/');
|
|
121
124
|
}
|
|
@@ -125,7 +128,7 @@ function listSessions0() {
|
|
|
125
128
|
function stamp(proj, id) {
|
|
126
129
|
const base = path.join(ROOT, proj); let n = 0;
|
|
127
130
|
const add = (f) => { try { const s = fs.statSync(f); n += s.mtimeMs + s.size; } catch {} };
|
|
128
|
-
add(path.join(base, id + '.jsonl'));
|
|
131
|
+
add(proj === codex.PROJ ? codex.fileOf(id) || '' : path.join(base, id + '.jsonl'));
|
|
129
132
|
const subs = subFiles(proj, id); // the transcripts and the folders they are in (a folder changes when a file appears in it, e.g. an agent's meta file), not every file around them
|
|
130
133
|
for (const x of subs) add(x.file); for (const x of subs.journals) add(x.file); for (const d of subs.dirs) n += mtimeOf(d);
|
|
131
134
|
return n;
|
|
@@ -210,13 +213,14 @@ function boardRows() {
|
|
|
210
213
|
}
|
|
211
214
|
function boardRows0() {
|
|
212
215
|
const now = Date.now(), rows = [];
|
|
213
|
-
|
|
214
|
-
for (const proj of fs.readdirSync(ROOT)) {
|
|
216
|
+
for (const r of codex.sessions()) if (now - r.mtime < 12 * 3600e3) rows.push({ proj: codex.PROJ, id: r.id, st: { mtimeMs: r.mtime, size: r.size }, cx: r });
|
|
217
|
+
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
215
218
|
const dir = path.join(ROOT, proj); let files; try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
216
219
|
for (const f of files) { try { const st = fs.statSync(path.join(dir, f)); if (now - st.mtimeMs < 12 * 3600e3) rows.push({ proj, id: f.slice(0, -6), st }); } catch {} }
|
|
217
220
|
}
|
|
218
221
|
rows.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
219
|
-
return rows.slice(0, 40).map(({ proj, id, st }) => {
|
|
222
|
+
return rows.filter((r) => !r.cx).slice(0, 40).concat(rows.filter((r) => r.cx).slice(0, 20)).sort((a, b) => b.st.mtimeMs - a.st.mtimeMs).map(({ proj, id, st, cx }) => {
|
|
223
|
+
if (cx) return codex.boardRow(cx, now);
|
|
220
224
|
const file = path.join(ROOT, proj, id + '.jsonl'); let p; try { p = parseTail(file, st); } catch { p = { pending: [] }; }
|
|
221
225
|
const age = now - st.mtimeMs, lp = (p.pending || [])[(p.pending || []).length - 1]; let state = 'idle', tool = '', ageMs = age;
|
|
222
226
|
if (age < 30 * 60e3) {
|
|
@@ -237,17 +241,18 @@ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true });
|
|
|
237
241
|
// Most recently active session (optionally limited to one project dir) — drives the live feed.
|
|
238
242
|
const STICK_MS = 90000; // a session written to in the last 90 s keeps the call, so another session writing elsewhere does not steal it mid-line
|
|
239
243
|
function sessionMtime(proj, id) {
|
|
244
|
+
if (proj === codex.PROJ) { const r = codex.row(id); return r ? r.mtime : 0; }
|
|
240
245
|
let m = mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs = subFiles(proj, id);
|
|
241
246
|
for (const x of subs) m = Math.max(m, mtimeOf(x.file)); for (const d of subs.dirs) m = Math.max(m, mtimeOf(d)); return m; // the folders too: a new file in them is activity, as when every file around the transcripts was looked at
|
|
242
247
|
}
|
|
243
248
|
function currentSession(projFilter, idFilter, stick) {
|
|
244
249
|
const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
|
|
245
|
-
if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-')) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
|
|
246
|
-
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl'))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
|
|
250
|
+
if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-') || (stick.proj === codex.PROJ && codex.inProject(stick.id, projFilter))) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
|
|
251
|
+
if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && (projFilter === codex.PROJ ? !!codex.fileOf(idFilter) : fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl')))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
|
|
247
252
|
let best = null;
|
|
248
|
-
if (!fs.existsSync(ROOT)) return {};
|
|
249
253
|
const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
|
|
250
|
-
for (const
|
|
254
|
+
for (const r of codex.sessions()) if (!projFilter || codex.inProject(r.id, projFilter)) consider(codex.PROJ, r.id, r.mtime); // a Codex session counts for a project when it was started in that folder
|
|
255
|
+
for (const proj of fs.existsSync(ROOT) ? fs.readdirSync(ROOT) : []) {
|
|
251
256
|
if (projFilter && proj !== projFilter && !proj.startsWith(projFilter + '-')) continue; // the folder itself, or a folder inside it
|
|
252
257
|
const dir = path.join(ROOT, proj); let files;
|
|
253
258
|
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
@@ -267,7 +272,7 @@ let fileGen = 0; // each time a transcript is read from its start it gets a ne
|
|
|
267
272
|
const sameBytes = (file, st) => { // are the last bytes we read still where we read them? If not, the file was rewritten and not appended to
|
|
268
273
|
let fd; try { fd = fs.openSync(file, 'r'); const b = Buffer.alloc(st.fp.length); fs.readSync(fd, b, 0, b.length, st.offset - b.length); return b.equals(st.fp); } catch { return false; } finally { if (fd != null) fs.closeSync(fd); }
|
|
269
274
|
};
|
|
270
|
-
function readNew(file, agent, journal) {
|
|
275
|
+
function readNew(file, agent, journal, record = parseRecord) {
|
|
271
276
|
let size, mtime; try { const s = fs.statSync(file); size = s.size; mtime = s.mtimeMs; } catch { fileStates.delete(file); return null; }
|
|
272
277
|
let st = fileStates.get(file);
|
|
273
278
|
if (st && size >= st.offset && mtime !== st.mtime && st.fp.length && !sameBytes(file, st)) st = null; // rewritten in place and not shorter: checked only when the file was written to, with one small read
|
|
@@ -284,7 +289,7 @@ function readNew(file, agent, journal) {
|
|
|
284
289
|
if (!line) continue;
|
|
285
290
|
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
286
291
|
if (!r || typeof r !== 'object') continue;
|
|
287
|
-
try {
|
|
292
|
+
try { record(st, r); } catch {} // one odd record must not take the whole timeline down
|
|
288
293
|
}
|
|
289
294
|
}
|
|
290
295
|
return st;
|
|
@@ -341,16 +346,17 @@ function parseRecord(st, r) {
|
|
|
341
346
|
}
|
|
342
347
|
}
|
|
343
348
|
}
|
|
349
|
+
const codexRecord = codex.parser({ toolLabel, clip, resultMax: RESULT_MAX });
|
|
344
350
|
const metaCache = new Map(); // meta file path → { mtime, value }
|
|
345
351
|
function readMeta(f) {
|
|
346
352
|
try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); if (metaCache.size > 3000) metaCache.clear(); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
|
|
347
353
|
}
|
|
348
354
|
|
|
349
355
|
function buildTimeline(proj, id, files = []) {
|
|
350
|
-
const base = path.join(ROOT, proj), mainFile = path.join(base, id + '.jsonl');
|
|
356
|
+
const cx = proj === codex.PROJ, base = path.join(ROOT, proj), mainFile = cx ? codex.fileOf(id) || '' : path.join(base, id + '.jsonl'); // a Codex session is one file somewhere else, in another format (codexRecord); from here on it is a session like any other, without sub-agents
|
|
351
357
|
if (!fs.existsSync(mainFile)) throw new Error('session not found');
|
|
352
|
-
const agents = { main: { id: 'main', role: 'Lead', type: 'main' } };
|
|
353
|
-
const states = [readNew(mainFile, 'main')], subs = subFiles(proj, id), runs = {}; files.push(mainFile); // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
|
|
358
|
+
const agents = { main: { id: 'main', role: 'Lead', type: 'main', ...(cx ? { name: 'Codex' } : {}) } };
|
|
359
|
+
const states = [readNew(mainFile, 'main', false, cx ? codexRecord : parseRecord)], subs = subFiles(proj, id), runs = {}; files.push(mainFile); // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
|
|
354
360
|
for (const x of subs) {
|
|
355
361
|
const aid = x.aid, m = readMeta(x.file.replace(/\.jsonl$/, '.meta.json'));
|
|
356
362
|
agents[aid] = { id: aid, role: m.description || m.workflowPhase || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
|
|
@@ -383,7 +389,7 @@ function buildTimeline(proj, id, files = []) {
|
|
|
383
389
|
else events.push(e);
|
|
384
390
|
});
|
|
385
391
|
if (run && run.done.has(st.agent) && st.lastTs) finish(st.agent, st.lastTs, parent[st.agent] || 'main'); // the journal has no times: the agent finished when it last wrote
|
|
386
|
-
for (const [mid, mu] of st.msgUsage) events.push({ ts: mu.ts, agent: st.agent, kind: 'usage', u: mu.u, model: mu.model, k: 'u/' + st.agent + '/' + mid });
|
|
392
|
+
for (const [mid, mu] of st.msgUsage) events.push({ ts: mu.ts, agent: st.agent, kind: 'usage', u: mu.u, model: mu.model, ...(mu.cw ? { cw: mu.cw } : {}), k: 'u/' + st.agent + '/' + mid });
|
|
387
393
|
}
|
|
388
394
|
for (const e of events) if (e.kind === 'report' && e.to === null) e.to = parent[e.agent] || 'main';
|
|
389
395
|
|
|
@@ -647,7 +653,7 @@ function start(port = PORT) {
|
|
|
647
653
|
if (url.pathname === '/api/session') {
|
|
648
654
|
const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
|
|
649
655
|
if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
|
|
650
|
-
if (proj !== '_demo' && !fs.existsSync(path.join(ROOT, proj, sid + '.jsonl'))) return json({ error: 'session not found' }, 404);
|
|
656
|
+
if (proj !== '_demo' && !(proj === codex.PROJ ? codex.fileOf(sid) : fs.existsSync(path.join(ROOT, proj, sid + '.jsonl')))) return json({ error: 'session not found' }, 404);
|
|
651
657
|
const entry = proj === '_demo' ? demoEntry() : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
|
|
652
658
|
const after = url.searchParams.get('after'), ep = url.searchParams.get('ep');
|
|
653
659
|
if (after == null) { // the whole timeline: serialize once per version, send gzip-compressed when the browser allows it (12 MB → ~1.5 MB)
|