@double_geste/agent-standup 1.26.0 → 1.28.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/package.json +4 -2
- package/public/index.html +8 -8
- package/public/locales/ko.json +221 -0
- 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/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.28.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",
|
package/public/index.html
CHANGED
|
@@ -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
|
|
@@ -355,7 +354,7 @@ let prefsTimer = null;
|
|
|
355
354
|
const savePrefs = () => { prefs._t = Date.now(); try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} clearTimeout(prefsTimer); prefsTimer = setTimeout(() => { fetch('/api/prefs', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs) }).catch(() => {}); }, 400); };
|
|
356
355
|
// Languages: French and English live in the code as pairs L(french, english); every other language is a catalogue public/locales/<code>.json mapping the English text to its translation.
|
|
357
356
|
// A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
|
|
358
|
-
const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['da', 'Dansk', 'da'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja']];
|
|
357
|
+
const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['da', 'Dansk', 'da'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja'], ['ko', '한국어', 'ko']];
|
|
359
358
|
let LOC = {};
|
|
360
359
|
const FR = () => prefs.lang === 'fr';
|
|
361
360
|
const FR_W = /\b(le|la|les|des|une|un|et|est|pour|dans|que|qui|pas|sur|avec|je|tu|nous|vous|ce|cette|ça|du|au|aux|mais|sont|été|fait|plus|ne|ont|il|elle|ils|elles|très|aussi|donc|bien|tout|voici|voilà|merci|oui|non|comme|où|déjà|encore)\b/gi, EN_W = /\b(the|and|is|are|to|of|in|that|with|for|this|it|on|you|we|not|have|has|was|be|will|can|from|your|at|as|but|all|now|also|done|here|there|which|what|then|just|so)\b/gi;
|
|
@@ -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) };
|
|
@@ -525,6 +524,7 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
|
|
|
525
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,
|
|
526
525
|
speakers: new Set(), say: {}, lastTool: {}, level: {}, detail: null, collapsed: new Set(), title: '', idle: false, locked: true, logTo: 0, trOk: true,
|
|
527
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)
|
|
528
528
|
const VANISH_MS = 120000; // a finished agent leaves the call after 2 minutes (playback time)
|
|
529
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
|
|
530
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
|
|
@@ -1041,7 +1041,7 @@ const applyStatus = (ev) => {
|
|
|
1041
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)
|
|
1042
1042
|
};
|
|
1043
1043
|
function recomputeStatus(upTo) {
|
|
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.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();
|
|
1045
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 = ''; });
|
|
1046
1046
|
for (let i = 0; i < upTo; i++) {
|
|
1047
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;
|
|
@@ -1060,7 +1060,7 @@ function syncUI() {
|
|
|
1060
1060
|
|
|
1061
1061
|
// ---------- tool ticker, shared screen, flying messages ----------
|
|
1062
1062
|
// ---------- what each agent is doing: tool calls with their real outputs ----------
|
|
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.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;
|
|
1064
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
|
|
1065
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');
|
|
1066
1066
|
const tokTotal = (u) => (u ? u.i + u.o + u.w : 0); // headline = input + output + cache writes; cache reads are cheap and shown separately
|
|
@@ -1078,14 +1078,14 @@ function ctxWin(model, ctx) { const base = WIN1M.test(model || '') ? 1e6 : 2e5;
|
|
|
1078
1078
|
function tally(ev) { // numbers that pile up over a session: token usage, errors, last sign of life
|
|
1079
1079
|
if (ev.kind === 'compact') { S.compacts[ev.agent] = (S.compacts[ev.agent] || 0) + 1; delete S.ctxWarned[ev.agent]; }
|
|
1080
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); }
|
|
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; 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); }
|
|
1082
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); });
|
|
1083
1083
|
else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
|
|
1084
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
|
|
1085
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
|
|
1086
1086
|
}
|
|
1087
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)}`;
|
|
1088
|
-
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' }; };
|
|
1089
1089
|
function renderCtx() { // "context N% full": per agent chip (from 50%), a header chip for the lead, and one warning before auto-compaction
|
|
1090
1090
|
for (const id in S.agents) { const t = $('#t-' + id); if (!t) continue; const el = t.querySelector('.cx'), x = ctxInfo(id);
|
|
1091
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'; } }
|
|
@@ -1912,7 +1912,7 @@ async function loadSession(proj, id) {
|
|
|
1912
1912
|
if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
|
|
1913
1913
|
$('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
|
|
1914
1914
|
}
|
|
1915
|
-
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('/'));
|
|
1916
1916
|
async function fillProjects() { // live view: follow the latest activity anywhere, or pin one project
|
|
1917
1917
|
const sel = $('#liveProj'); let rows = []; try { rows = await (await fetch('/api/projects')).json(); } catch {}
|
|
1918
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('');
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
3
|
+
"language": "ko",
|
|
4
|
+
"reviewed": false,
|
|
5
|
+
"note": "AI draft, native review welcome"
|
|
6
|
+
},
|
|
7
|
+
" full — compaction soon": " 사용 — 곧 압축됩니다",
|
|
8
|
+
"”": "”",
|
|
9
|
+
"↗ Open in browser": "↗ 브라우저에서 열기",
|
|
10
|
+
"▶ Join": "▶ 참가",
|
|
11
|
+
"▶ Watch the demo": "▶ 데모 보기",
|
|
12
|
+
"☕ Buy the (replaced) dev a coffee": "☕ (일자리를 잃은) 개발자에게 커피 사 주기",
|
|
13
|
+
"🎞 Export video": "🎞 동영상 내보내기",
|
|
14
|
+
"🎬 Clip to share (25 s)": "🎬 공유용 클립 (25초)",
|
|
15
|
+
"📋 Recap": "📋 요약",
|
|
16
|
+
"Agent voices": "에이전트 목소리",
|
|
17
|
+
"Agents": "에이전트",
|
|
18
|
+
"Agents list (left)": "에이전트 목록 (왼쪽)",
|
|
19
|
+
"All projects (most recent)": "모든 프로젝트 (최신순)",
|
|
20
|
+
"Ambience volume": "환경음 볼륨",
|
|
21
|
+
"An agent may be waiting for you ✋": "에이전트가 기다리고 있을 수 있어요 ✋",
|
|
22
|
+
"Animal Crossing (babble)": "Animal Crossing (옹알이)",
|
|
23
|
+
"Asking a question": "질문하는 중",
|
|
24
|
+
"brief": "지시",
|
|
25
|
+
"Busiest agents": "가장 바쁜 에이전트",
|
|
26
|
+
"Buy a coffee": "커피 사 주기",
|
|
27
|
+
"cache read": "캐시 읽기",
|
|
28
|
+
"cache write": "캐시 쓰기",
|
|
29
|
+
"Call": "통화",
|
|
30
|
+
"Cartoon voices (real voice)": "만화 목소리 (실제 음성)",
|
|
31
|
+
"Claude Code is not logged in: run “claude” once in a terminal to sign in.": "Claude Code에 로그인되어 있지 않습니다. 터미널에서 “claude”를 한 번 실행해 로그인하세요.",
|
|
32
|
+
"Claude Code not found: install it (npm i -g @anthropic-ai/claude-code) or set its path in the “agentStandup.claudePath” setting.": "Claude Code를 찾을 수 없습니다. 설치하거나(npm i -g @anthropic-ai/claude-code) “agentStandup.claudePath” 설정에 경로를 지정하세요.",
|
|
33
|
+
"Click to enable sound and follow your Claude Code session live.": "클릭하면 소리가 켜지고 Claude Code 세션을 실시간으로 따라갑니다.",
|
|
34
|
+
"Close": "닫기",
|
|
35
|
+
"commands": "명령",
|
|
36
|
+
"compaction": "압축",
|
|
37
|
+
"Context": "컨텍스트",
|
|
38
|
+
"context ": "컨텍스트 ",
|
|
39
|
+
"context compacted 🗜": "컨텍스트 압축됨 🗜",
|
|
40
|
+
"Copied": "복사했습니다",
|
|
41
|
+
"Copy as Markdown": "Markdown으로 복사",
|
|
42
|
+
"Copy failed": "복사 실패",
|
|
43
|
+
"Demo — a team fixes a slow login": "데모 — 팀이 느린 로그인을 고칩니다",
|
|
44
|
+
"done": "완료",
|
|
45
|
+
"Done": "완료",
|
|
46
|
+
"Done. ": "완료. ",
|
|
47
|
+
"duration": "소요 시간",
|
|
48
|
+
"Editing ": "편집 중: ",
|
|
49
|
+
"Editor": "편집기",
|
|
50
|
+
"EPISODE 1": "제1화",
|
|
51
|
+
"Episode recap": "에피소드 요약",
|
|
52
|
+
"errors": "오류",
|
|
53
|
+
"failing in a loop?": "실패를 반복하는 중?",
|
|
54
|
+
"Fetching ": "가져오는 중: ",
|
|
55
|
+
"files": "파일",
|
|
56
|
+
"Files": "파일",
|
|
57
|
+
"files changed": "변경된 파일",
|
|
58
|
+
"Files touched": "건드린 파일",
|
|
59
|
+
"Final report": "최종 보고",
|
|
60
|
+
"first error": "첫 오류",
|
|
61
|
+
"Hide code & file names in videos": "동영상에서 코드와 파일 이름 숨기기",
|
|
62
|
+
"Highlights": "하이라이트",
|
|
63
|
+
"Human": "사람",
|
|
64
|
+
"idle": "유휴",
|
|
65
|
+
"input": "입력",
|
|
66
|
+
"is sharing their screen": "님이 화면 공유 중",
|
|
67
|
+
"Join the call": "통화 참가",
|
|
68
|
+
"Language": "언어",
|
|
69
|
+
"Lead": "메인 에이전트",
|
|
70
|
+
"Lead context": "메인 에이전트 컨텍스트",
|
|
71
|
+
"left the call": "님이 통화에서 나갔습니다",
|
|
72
|
+
"lines": "줄",
|
|
73
|
+
"Listing ": "목록 보는 중: ",
|
|
74
|
+
"Little lines & animations": "짧은 대사와 애니메이션",
|
|
75
|
+
"longest wait ": "최장 대기 ",
|
|
76
|
+
"Looking for a tool": "도구 찾는 중",
|
|
77
|
+
"looping?": "반복 중?",
|
|
78
|
+
"Music volume": "음악 볼륨",
|
|
79
|
+
"Natural voices": "자연스러운 목소리",
|
|
80
|
+
"need you": "응답 필요",
|
|
81
|
+
"needs you?": "호출 중?",
|
|
82
|
+
"needs you? ": "호출 중? ",
|
|
83
|
+
"No agents were harmed.": "이 과정에서 다친 에이전트는 없습니다.",
|
|
84
|
+
"No edits.": "편집 없음.",
|
|
85
|
+
"No French voice installed (Windows: Settings › Time & language › Speech).": "프랑스어 음성이 설치되어 있지 않습니다 (Windows: 설정 › 시간 및 언어 › 음성).",
|
|
86
|
+
"No recent session": "최근 세션 없음",
|
|
87
|
+
"No setting found": "일치하는 설정 없음",
|
|
88
|
+
"No system voice found: agents will mumble.": "시스템 음성을 찾을 수 없습니다. 에이전트가 웅얼거립니다.",
|
|
89
|
+
"Notify me when agents finish": "에이전트가 끝나면 알림",
|
|
90
|
+
"Office ambience (keys, coffee…)": "사무실 환경음 (키보드, 커피…)",
|
|
91
|
+
"Office music (FIP radio)": "사무실 음악 (FIP 라디오)",
|
|
92
|
+
"OFFLINE": "오프라인",
|
|
93
|
+
"Opening file…": "파일 여는 중…",
|
|
94
|
+
"output": "출력",
|
|
95
|
+
"Pause / Play (space)": "일시정지 / 재생 (스페이스)",
|
|
96
|
+
"PAUSED": "일시정지됨",
|
|
97
|
+
"Project to follow": "따라갈 프로젝트",
|
|
98
|
+
"Prompt from": "프롬프트 보낸 이:",
|
|
99
|
+
"Radio unreachable: check your connection or pick another station.": "라디오에 연결할 수 없습니다. 연결을 확인하거나 다른 방송국을 선택하세요.",
|
|
100
|
+
"Reactions": "리액션",
|
|
101
|
+
"Reactions & jokes": "리액션과 농담",
|
|
102
|
+
"Reactions bar (👍 👏 ☕)": "리액션 바 (👍 👏 ☕)",
|
|
103
|
+
"read-only": "읽기 전용",
|
|
104
|
+
"Reading ": "읽는 중: ",
|
|
105
|
+
"READY": "준비 완료",
|
|
106
|
+
"Recent activity": "최근 활동",
|
|
107
|
+
"Remove finished agents (2 min)": "끝난 에이전트 제거 (2분 후)",
|
|
108
|
+
"Repeated failures": "반복된 실패",
|
|
109
|
+
"report": "보고",
|
|
110
|
+
"report to": "보고 대상:",
|
|
111
|
+
"Role": "역할",
|
|
112
|
+
"running": "실행 중",
|
|
113
|
+
"Running ": "실행 중: ",
|
|
114
|
+
"running…": "실행 중…",
|
|
115
|
+
"Said": "발언",
|
|
116
|
+
"Same call repeated": "같은 호출 반복",
|
|
117
|
+
"Search the transcript": "대화 기록 검색",
|
|
118
|
+
"Search…": "검색…",
|
|
119
|
+
"Searching “": "검색 중: “",
|
|
120
|
+
"session": "세션",
|
|
121
|
+
"Sessions": "세션",
|
|
122
|
+
"Settings": "설정",
|
|
123
|
+
"Simlish (babble)": "Simlish (옹알이)",
|
|
124
|
+
"Sound": "소리",
|
|
125
|
+
"Sound (M key)": "소리 (M 키)",
|
|
126
|
+
"Sound is playing in another Agent Standup window. Click 🔈 to take it over here.": "다른 Agent Standup 창에서 소리가 재생 중입니다. 🔈 를 클릭하면 이 창으로 가져옵니다.",
|
|
127
|
+
"Sound is playing in another window — click to take it over here": "다른 창에서 소리가 재생 중입니다 — 클릭하면 이 창으로 가져옵니다",
|
|
128
|
+
"Source": "소스",
|
|
129
|
+
"Speed": "속도",
|
|
130
|
+
"stalled": "멈춤",
|
|
131
|
+
"stalled · ": "멈춤 · ",
|
|
132
|
+
"standing by": "대기 중",
|
|
133
|
+
"Start Claude Code in this folder — the call will start by itself.": "이 폴더에서 Claude Code를 시작하세요 — 통화가 자동으로 시작됩니다.",
|
|
134
|
+
"Start Claude Code in {0} — the call will start by itself.": "{0}에서 Claude Code를 시작하세요 — 통화가 자동으로 시작됩니다.",
|
|
135
|
+
"sub-agents active": "서브에이전트 활동 중",
|
|
136
|
+
"task": "작업",
|
|
137
|
+
"Tasks": "작업",
|
|
138
|
+
"THE END": "끝",
|
|
139
|
+
"This dev was replaced by his own agents. Spare a coffee for his career change? ☕": "이 개발자는 자기 에이전트들에게 일자리를 빼앗겼습니다. 새 출발을 응원하는 커피 한 잔 어떠세요? ☕",
|
|
140
|
+
"Time": "시간",
|
|
141
|
+
"to": "대상:",
|
|
142
|
+
"tokens": "토큰",
|
|
143
|
+
"Tokens": "토큰",
|
|
144
|
+
"Tokens recorded by Claude Code (counted once per message, not a bill)": "Claude Code가 기록한 토큰 (메시지당 한 번 집계, 청구 금액 아님)",
|
|
145
|
+
"tool calls": "도구 호출",
|
|
146
|
+
"tool errors": "도구 오류",
|
|
147
|
+
"tools": "도구",
|
|
148
|
+
"Tools": "도구",
|
|
149
|
+
"Translation unavailable right now: check that “claude” works in a terminal.": "지금은 번역을 사용할 수 없습니다. 터미널에서 “claude”가 동작하는지 확인하세요.",
|
|
150
|
+
"Tree": "트리",
|
|
151
|
+
"Untitled session": "제목 없는 세션",
|
|
152
|
+
"Updating todo list": "할 일 목록 업데이트 중",
|
|
153
|
+
"Using skill ": "스킬 사용 중: ",
|
|
154
|
+
"VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.": "VS Code가 Claude Code 세션 안에서 실행되었습니다. 번역을 사용하려면 VS Code를 일반적인 방법(Dock, Spotlight)으로 다시 시작하세요.",
|
|
155
|
+
"waiting": "대기 중",
|
|
156
|
+
"Waiting for a session": "세션을 기다리는 중",
|
|
157
|
+
"waiting on ": "기다리는 중: ",
|
|
158
|
+
"waiting on sub-agents": "서브에이전트를 기다리는 중",
|
|
159
|
+
"Web search": "웹 검색",
|
|
160
|
+
"working": "작업 중",
|
|
161
|
+
"Writing ": "작성 중: ",
|
|
162
|
+
"You": "나",
|
|
163
|
+
"Your agents are done 🎉": "에이전트 작업이 끝났습니다 🎉",
|
|
164
|
+
"Your Claude Code agents as a live team call": "Claude Code 에이전트를 실시간 팀 통화로",
|
|
165
|
+
"your turn": "내 차례",
|
|
166
|
+
"Characters": "캐릭터",
|
|
167
|
+
"Reload": "새로고침",
|
|
168
|
+
"Open folder": "폴더 열기",
|
|
169
|
+
"custom character(s) loaded": "개의 커스텀 캐릭터를 불러왔습니다",
|
|
170
|
+
"Drop your pixel art (PNG) in this folder to replace the characters:": "이 폴더에 픽셀 아트(PNG)를 넣으면 캐릭터가 바뀝니다:",
|
|
171
|
+
"lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 메인 에이전트 · you.png = 나 · 나머지 = 서브에이전트 · name-talk.png = 입을 벌린 모습",
|
|
172
|
+
"Downloading, you can carry on: system voices are used meanwhile.": "다운로드 중입니다. 계속 사용하셔도 됩니다. 그동안은 시스템 음성을 사용합니다.",
|
|
173
|
+
"Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "품질 좋은 로컬 음성으로, 대부분 영어입니다 (프랑스어 음성은 하나). 무료이며 아무것도 컴퓨터 밖으로 전송되지 않습니다.",
|
|
174
|
+
"Installing…": "설치 중…",
|
|
175
|
+
"Loading the model (downloaded on first use, about 305 MB)…": "모델 불러오는 중 (처음 사용할 때 다운로드, 약 305 MB)…",
|
|
176
|
+
"npm not found: install Node.js to enable this option.": "npm을 찾을 수 없습니다. 이 옵션을 사용하려면 Node.js를 설치하세요.",
|
|
177
|
+
"The engine stopped: system voices are used instead.": "엔진이 멈췄습니다. 대신 시스템 음성을 사용합니다.",
|
|
178
|
+
"Very natural voices (installs on demand)": "매우 자연스러운 목소리 (필요할 때 설치)",
|
|
179
|
+
"Voices ready.": "음성이 준비되었습니다.",
|
|
180
|
+
"Voice style": "목소리 스타일",
|
|
181
|
+
"⌨ Keyboard shortcuts (?)": "⌨ 키보드 단축키 (?)",
|
|
182
|
+
"Keyboard shortcuts": "키보드 단축키",
|
|
183
|
+
"Mute / unmute": "음소거 / 해제",
|
|
184
|
+
"Pause / play": "일시정지 / 재생",
|
|
185
|
+
"Recap": "요약",
|
|
186
|
+
"This help": "이 도움말",
|
|
187
|
+
"Transcript": "대화 기록",
|
|
188
|
+
"Tree view ↔ call": "트리 보기 ↔ 통화",
|
|
189
|
+
"Tree view: T to go back, ? for help": "트리 보기: T로 돌아가기, ?로 도움말",
|
|
190
|
+
"You can also switch between Call, Tree and Sessions at the top of the window.": "창 위쪽에서 통화, 트리, 세션을 전환할 수도 있습니다.",
|
|
191
|
+
"Display": "표시",
|
|
192
|
+
"Search settings…": "설정 검색…",
|
|
193
|
+
"may be waiting for your approval": "승인을 기다리고 있을 수 있습니다",
|
|
194
|
+
"Next / previous agent that needs you": "응답이 필요한 다음 / 이전 에이전트",
|
|
195
|
+
"Nobody needs you right now": "지금 응답을 기다리는 에이전트가 없습니다",
|
|
196
|
+
"stalled: a sub-agent has been silent for a long time": "멈춤: 서브에이전트가 오랫동안 조용합니다",
|
|
197
|
+
"waiting on another agent": "다른 에이전트를 기다리는 중",
|
|
198
|
+
"What the states mean": "상태의 의미",
|
|
199
|
+
"Find a session": "세션 찾기",
|
|
200
|
+
"Find a session…": "세션 찾기…",
|
|
201
|
+
"Go to a session": "세션으로 이동",
|
|
202
|
+
"No highlight in this session": "이 세션에는 하이라이트가 없습니다",
|
|
203
|
+
"Replay: next / previous highlight": "다시 보기: 다음 / 이전 하이라이트",
|
|
204
|
+
"Replay: previous / next event": "다시 보기: 이전 / 다음 이벤트",
|
|
205
|
+
"Resumed where you left off": "마지막으로 본 곳부터 이어서 재생합니다",
|
|
206
|
+
"Chatter between agents": "에이전트 간 잡담",
|
|
207
|
+
"Lively": "활발하게",
|
|
208
|
+
"Normal": "보통",
|
|
209
|
+
"nothing to report": "보고할 것 없음",
|
|
210
|
+
"Quiet": "조용하게",
|
|
211
|
+
"Laptop in front of your own characters": "내 캐릭터 앞에 노트북 놓기",
|
|
212
|
+
"Also ": "또한 ",
|
|
213
|
+
" on ": " → ",
|
|
214
|
+
"To sum up: ": "정리하면: ",
|
|
215
|
+
"Cat pack": "고양이 팩",
|
|
216
|
+
"characters from the pack": "개의 팩 캐릭터",
|
|
217
|
+
"Drawn characters": "기본 캐릭터",
|
|
218
|
+
"To tweak them, copy them into your folder and pick “My folder”.": "수정하려면 내 폴더에 복사한 뒤 “내 폴더”를 선택하세요.",
|
|
219
|
+
"My folder": "내 폴더",
|
|
220
|
+
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "앱이 그리는 캐릭터입니다. 직접 만든 이미지를 쓰려면 “내 폴더”를, 아니면 팩을 선택하세요."
|
|
221
|
+
}
|
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)
|