@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 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.26.0",
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
- 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)