@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 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 writes.
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.
@@ -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.25.10",
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>
@@ -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="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🎥%3C/text%3E%3C/svg%3E">
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; border-radius:8px; background:#d97757; display:grid; place-items:center; font-size:15px; flex:none }
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"><div class="logo">🎥</div><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
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
- ${[[5, 8], [36, 6], [67, 8]].map(([x, y], i) => `<rect x="${x}" y="${y}" width="28" height="22" rx="2.5" fill="#0a1a1f" stroke="#2b6a78" stroke-width=".8"/>
456
- <polyline points="${x + 3},${y + 17} ${x + 9},${y + 11} ${x + 14},${y + 14} ${x + 20},${y + 6} ${x + 25},${y + 9}" fill="none" stroke="${['#ff9a6b', '#4fd1a5', '#ffd84a'][i]}" stroke-width="1.3" stroke-linejoin="round"/>
457
- <rect x="${x + 3}" y="${y + 3}" width="9" height="1.4" rx=".7" fill="#4cc3ff" opacity=".7"/>`).join('')}
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'); g.font = '26px serif'; g.fillText('🎥', 0, 26); 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();
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
- if (!fs.existsSync(ROOT)) return rows;
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) { // titles come from the cached tail read (custom title, then AI title, then first prompt): no whole-file reads
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
- if (!fs.existsSync(ROOT)) return rows;
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 proj of fs.readdirSync(ROOT)) {
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 { parseRecord(st, r); } catch {} // one odd record must not take the whole timeline down
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)