clideck 1.27.0 → 1.29.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.
@@ -6,10 +6,11 @@ const ioActivity = require('./activity');
6
6
  const activity = new Map(); // sessionId → has received events
7
7
  const lastEvent = new Map(); // sessionId → last OTEL event name (+ kind)
8
8
  const pendingSetup = new Map(); // sessionId → timer (waiting for first event)
9
- const pendingIdle = new Map(); // sessionId → timer (PTY silence → idle)
10
9
  const codexMenuPoll = new Map(); // sessionId → interval (polling for menu after response.completed)
11
- const escPendingIdle = new Map(); // sessionId → timer (Esc interrupt confirm idle after output silence)
12
- const escSuppressUntil = new Map(); // sessionId → ts (briefly ignore telemetry reassertions after Esc)
10
+ const codexPendingStop = new Map(); // sessionId → ts (notify hook arrived; wait for next response.completed)
11
+ const codexOutputDone = new Map(); // sessionId → ts (fallback if notify never fires)
12
+ const codexPendingIdle = new Map(); // sessionId → timer (tiny settle before committing idle)
13
+ const escPendingIdle = new Map(); // sessionId → timer (Esc interrupt → short grace before forcing idle)
13
14
  let broadcastFn = null;
14
15
  let sessionsFn = null;
15
16
 
@@ -76,33 +77,70 @@ function handleLogs(req, res) {
76
77
 
77
78
  // Debug telemetry logs — uncomment as needed, do not delete
78
79
  // if (serviceName === 'claude-code' && eventName) console.log(`[telemetry:claude] ${eventName}`);
79
- // if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName} ${attrs['event.kind'] ? 'kind=' + attrs['event.kind'] : ''} ${attrs['tool'] ? 'tool=' + attrs['tool'] : ''} session=${resolvedId.slice(0,8)}`);
80
+ // if (serviceName === 'codex_cli_rs' && eventName) {
81
+ // const details = Object.entries(attrs)
82
+ // .filter(([k]) => k !== 'event.name')
83
+ // .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
84
+ // .join(' ');
85
+ // console.log(`[telemetry:codex] ${eventName} session=${resolvedId.slice(0,8)}${details ? ' ' + details : ''}`);
86
+ // }
80
87
  // if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
81
88
 
82
89
  // Track last event per session (used by menu detection validation)
83
90
  if (eventName) lastEvent.set(resolvedId, eventName + (attrs['event.kind'] ? ':' + attrs['event.kind'] : ''));
84
91
 
85
- // Status: user_prompt working
86
- // Claude uses hooks; Codex uses notify hook; Gemini uses PTY heuristic
87
- const startEvents = new Set(['gemini_cli.user_prompt', 'codex.user_prompt']);
88
- if (startEvents.has(eventName)) {
89
- cancelPendingIdle(resolvedId);
92
+ // Codex can emit a brief completion between tool phases. Keep idle
93
+ // pending for a tiny settle window and cancel it on any fresh Codex
94
+ // activity before the idle is committed to UI/notifications.
95
+ if (serviceName === 'codex_cli_rs' && eventName) {
96
+ const isTrustedCompletion = eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed';
97
+ if (!isTrustedCompletion) cancelCodexPendingIdle(resolvedId);
98
+ }
99
+
100
+ // Status: Codex user_prompt → working. Claude and Gemini use hooks.
101
+ if (eventName === 'codex.user_prompt') {
102
+ codexPendingStop.delete(resolvedId);
103
+ codexOutputDone.delete(resolvedId);
90
104
  broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
91
- // Gemini uses PTY silence heuristic for idle; Codex idle comes from notify hook
92
- if (serviceName !== 'codex_cli_rs') startPendingIdle(resolvedId, serviceName);
93
105
  }
94
106
 
95
- // Codex: response.completed poll for menu until found or timeout
107
+ // Fallback: when notify does not fire, require an output item to finish
108
+ // before treating the next response.completed as a real end-of-turn.
109
+ if (eventName === 'codex.websocket_event' && attrs['event.kind'] === 'response.output_item.done') {
110
+ codexOutputDone.set(resolvedId, Date.now());
111
+ }
112
+
113
+ // Codex: after notify hook arms a pending stop, the next response.completed commits idle.
114
+ // Also poll briefly for a visible choice menu.
96
115
  if (eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed') {
116
+ const pendingStopAt = codexPendingStop.get(resolvedId);
117
+ if (pendingStopAt && Date.now() - pendingStopAt <= 5000) {
118
+ // console.log(`[codex] complete matched pending-stop session=${resolvedId.slice(0,8)} age=${Date.now() - pendingStopAt}ms`);
119
+ codexPendingStop.delete(resolvedId);
120
+ codexOutputDone.delete(resolvedId);
121
+ scheduleCodexIdle(resolvedId, 'telemetry-stop');
122
+ } else {
123
+ const outputDoneAt = codexOutputDone.get(resolvedId);
124
+ if (outputDoneAt && Date.now() - outputDoneAt <= 5000) {
125
+ // console.log(`[codex] complete matched output-item.done fallback session=${resolvedId.slice(0,8)} age=${Date.now() - outputDoneAt}ms`);
126
+ codexOutputDone.delete(resolvedId);
127
+ scheduleCodexIdle(resolvedId, 'telemetry-fallback');
128
+ } else {
129
+ // console.log(`[codex] response.completed with no notify-stop and no output-item.done fallback session=${resolvedId.slice(0,8)} outputDone=${outputDoneAt ? Date.now() - outputDoneAt + 'ms' : 'none'}`);
130
+ }
131
+ }
97
132
  startCodexMenuPoll(resolvedId);
98
133
  }
99
134
  // Codex: tool_decision → user approved, cancel menu poll, back to working
100
135
  if (eventName === 'codex.tool_decision') {
136
+ codexPendingStop.delete(resolvedId);
137
+ codexOutputDone.delete(resolvedId);
101
138
  cancelCodexMenuPoll(resolvedId);
102
139
  broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
103
140
  }
104
141
  // Codex: user_prompt or next sse_event cancels menu poll
105
142
  if ((eventName === 'codex.user_prompt' || (eventName === 'codex.sse_event' && attrs['event.kind'] !== 'response.completed'))) {
143
+ codexOutputDone.delete(resolvedId);
106
144
  cancelCodexMenuPoll(resolvedId);
107
145
  }
108
146
 
@@ -147,55 +185,14 @@ function cancelPendingSetup(sessionId) {
147
185
  }
148
186
  }
149
187
 
150
- // PTY activity monitor: 2s silent idle, 2s active or user_prompt → working.
151
- // Used by Gemini only — Claude uses hooks, Codex uses sse_event completion.
152
- // Agent working indicators in PTY output.
153
- const GEMINI_WORKING_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
154
-
155
- function startPendingIdle(id, agent) {
156
- if (pendingIdle.has(id)) return; // already monitoring
157
- let isIdle = false;
158
- let activeStart = 0;
159
- const check = setInterval(() => {
160
- const lastOut = ioActivity.lastOutputAt(id);
161
- const lastIn = ioActivity.lastInputAt(id);
162
- // Ignore echo: if last output is within 100ms of last input, treat as silent
163
- const agentOut = (lastIn && lastOut - lastIn >= 0 && lastOut - lastIn < 100) ? 0 : lastOut;
164
- let silent = (Date.now() - agentOut) >= 2000;
165
- // Agent override: if recent output has spinner/working chars, not silent
166
- if (silent && (Date.now() - lastOut) < 2000) {
167
- const chunk = ioActivity.lastChunk(id);
168
- if (GEMINI_WORKING_RE.test(chunk)) silent = false;
169
- }
170
- if (silent && !isIdle) {
171
- isIdle = true;
172
- activeStart = 0;
173
- broadcastFn?.({ type: 'session.status', id, working: false, source: 'telemetry' });
174
- } else if (!silent && isIdle) {
175
- if (!activeStart) activeStart = Date.now();
176
- if (Date.now() - activeStart >= 2000) {
177
- isIdle = false;
178
- broadcastFn?.({ type: 'session.status', id, working: true, source: 'telemetry' });
179
- }
180
- } else if (!silent) {
181
- activeStart = 0;
182
- }
183
- }, 250);
184
- pendingIdle.set(id, check);
185
- }
186
-
187
- function cancelPendingIdle(id) {
188
- const timer = pendingIdle.get(id);
189
- if (timer) { clearInterval(timer); pendingIdle.delete(id); }
190
- }
191
-
192
- // Codex: after response.completed, poll screen capture every 500ms for up to 3s
188
+ // Codex: after response.completed, poll terminal capture every 500ms for up to 3s
193
189
  function startCodexMenuPoll(id) {
194
190
  cancelCodexMenuPoll(id);
195
191
  const started = Date.now();
196
192
  const poll = setInterval(() => {
197
193
  if (Date.now() - started > 3000) { cancelCodexMenuPoll(id); return; }
198
- broadcastFn?.({ type: 'screen.capture', id });
194
+ // console.log(`[terminal.capture] session=${id.slice(0,8)} source=codex-menu-poll`);
195
+ broadcastFn?.({ type: 'terminal.capture', id });
199
196
  }, 500);
200
197
  codexMenuPoll.set(id, poll);
201
198
  }
@@ -205,42 +202,48 @@ function cancelCodexMenuPoll(id) {
205
202
  if (timer) { clearInterval(timer); codexMenuPoll.delete(id); }
206
203
  }
207
204
 
205
+ function armCodexStop(id) {
206
+ codexPendingStop.set(id, Date.now());
207
+ codexOutputDone.delete(id);
208
+ // console.log(`[codex] pending-stop armed session=${id.slice(0,8)}`);
209
+ }
210
+
211
+ function scheduleCodexIdle(id, source) {
212
+ cancelCodexPendingIdle(id);
213
+ const timer = setTimeout(() => {
214
+ codexPendingIdle.delete(id);
215
+ broadcastFn?.({ type: 'session.status', id, working: false, source });
216
+ }, 300);
217
+ codexPendingIdle.set(id, timer);
218
+ }
219
+
220
+ function cancelCodexPendingIdle(id) {
221
+ const timer = codexPendingIdle.get(id);
222
+ if (timer) { clearTimeout(timer); codexPendingIdle.delete(id); }
223
+ }
224
+
208
225
  function startEscIdle(id) {
209
226
  cancelEscIdle(id);
210
- const started = Date.now();
211
- const ignoreUntil = started + 500;
212
- // console.log(`[escIdle] start session=${id.slice(0,8)}`);
213
- const check = setInterval(() => {
214
- const lastOut = ioActivity.lastOutputAt(id);
215
- const silence = Date.now() - Math.max(ignoreUntil, lastOut);
216
- const elapsed = Date.now() - started;
217
- if (elapsed > 10000) {
218
- // console.log(`[escIdle] timeout session=${id.slice(0,8)} silence=${silence}ms`);
219
- cancelEscIdle(id);
220
- return;
221
- }
222
- if (silence >= 2000) {
223
- // console.log(`[escIdle] idle session=${id.slice(0,8)} silence=${silence}ms`);
224
- escSuppressUntil.set(id, Date.now() + 2000);
225
- cancelEscIdle(id);
226
- broadcastFn?.({ type: 'session.status', id, working: false, source: 'esc' });
227
- }
228
- }, 250);
229
- escPendingIdle.set(id, check);
227
+ const timer = setTimeout(() => {
228
+ escPendingIdle.delete(id);
229
+ broadcastFn?.({ type: 'session.status', id, working: false, source: 'esc' });
230
+ }, 350);
231
+ escPendingIdle.set(id, timer);
230
232
  }
231
233
 
232
234
  function cancelEscIdle(id) {
233
235
  const timer = escPendingIdle.get(id);
234
- if (timer) { clearInterval(timer); escPendingIdle.delete(id); }
236
+ if (timer) { clearTimeout(timer); escPendingIdle.delete(id); }
235
237
  }
236
238
 
237
239
  function clear(id) {
238
240
  activity.delete(id);
239
241
  lastEvent.delete(id);
240
- cancelPendingIdle(id);
241
242
  cancelCodexMenuPoll(id);
243
+ cancelCodexPendingIdle(id);
244
+ codexPendingStop.delete(id);
245
+ codexOutputDone.delete(id);
242
246
  cancelEscIdle(id);
243
- escSuppressUntil.delete(id);
244
247
  const pending = pendingSetup.get(id);
245
248
  if (pending) { clearTimeout(pending.timer); pendingSetup.delete(id); }
246
249
  }
@@ -252,4 +255,4 @@ function hasEvents(id) {
252
255
  return activity.has(id);
253
256
  }
254
257
 
255
- module.exports = { init, handleLogs, clear, hasEvents, getLastEvent, cancelCodexMenuPoll, watchSession, startPendingIdle, startEscIdle };
258
+ module.exports = { init, handleLogs, clear, hasEvents, getLastEvent, cancelCodexMenuPoll, watchSession, startEscIdle, armCodexStop };
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync } from 'fs';
4
+
5
+ const isCodex = process.argv.includes('--codex');
6
+ const file = process.argv.filter(a => !a.startsWith('--'))[2];
7
+ if (!file) {
8
+ console.error('Usage: node merge-jsonl-roles.mjs <file.jsonl> [--codex]');
9
+ process.exit(1);
10
+ }
11
+
12
+ const lines = readFileSync(file, 'utf8').trim().split('\n').map(l => JSON.parse(l));
13
+ const merged = [];
14
+ const SPINNER_WORD = 'Working';
15
+ const SPINNER_TAIL = 's • esc to interrupt)';
16
+ const SPINNER_PREFIXES = ['Working', 'Workin', 'Worki', 'Work', 'Wor', 'Wo', 'W'];
17
+ const SPINNER_SUFFIXES = ['Working', 'orking', 'rking', 'king', 'ing', 'ng', 'g'];
18
+ const SPINNER_OUTRO = /^\d+s • esc to interr?upt\)/;
19
+
20
+ for (const entry of lines) {
21
+ const prev = merged[merged.length - 1];
22
+ if (prev && prev.role === entry.role && entry.role === 'user') {
23
+ prev.text += '\n' + entry.text;
24
+ } else {
25
+ merged.push({ ...entry });
26
+ }
27
+ }
28
+
29
+ function cleanText(text) {
30
+ const extra = isCodex ? '\u2022\u203a' : '';
31
+ const re = new RegExp(`[^a-zA-Z0-9.,;:!?'"()\\-/\\s@#$%&*+=<>\\[\\]{}\\\\|_~\`^${extra}]`, 'g');
32
+
33
+ let out = text;
34
+ if (isCodex) {
35
+ out = collapseCodexSpinner(out);
36
+ // collapse consecutive • (with optional whitespace between) into one
37
+ out = out.replace(/\u2022(?:\s*\u2022)+/g, '\u2022');
38
+ }
39
+
40
+ return out
41
+ .replace(re, ' ')
42
+ .replace(/[ \t]+/g, ' ')
43
+ .replace(/\n(?:[ \t]*\n)+/g, '\n')
44
+ .trim();
45
+ }
46
+
47
+ function collapseCodexSpinner(text) {
48
+ let out = '';
49
+ for (let i = 0; i < text.length;) {
50
+ const buildLen = consumeSpinnerBuildUp(text, i);
51
+ if (buildLen > 0) {
52
+ out += '\u2022';
53
+ i += buildLen;
54
+ continue;
55
+ }
56
+
57
+ const fragmentLen = consumeSpinnerFragments(text, i);
58
+ if (fragmentLen > 0) {
59
+ out += '\u2022';
60
+ i += fragmentLen;
61
+ continue;
62
+ }
63
+
64
+ out += text[i];
65
+ i += 1;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function consumeSpinnerBuildUp(text, start) {
71
+ if (text[start] !== '\u2022') return 0;
72
+
73
+ let i = start + 1;
74
+ let matched = 0;
75
+ while (matched < SPINNER_WORD.length && text[i] === SPINNER_WORD[matched]) {
76
+ i += 1;
77
+ matched += 1;
78
+ }
79
+
80
+ if (matched === 0) return 0;
81
+ if (matched < SPINNER_WORD.length) return i - start;
82
+ if (text[i] !== '(') return i - start;
83
+
84
+ i += 1;
85
+ while (/[0-9]/.test(text[i] || '')) i += 1;
86
+
87
+ let tail = 0;
88
+ while (tail < SPINNER_TAIL.length && text[i] === SPINNER_TAIL[tail]) {
89
+ i += 1;
90
+ tail += 1;
91
+ }
92
+
93
+ return i - start;
94
+ }
95
+
96
+ function consumeSpinnerFragments(text, start) {
97
+ let i = start;
98
+ let tokens = 0;
99
+ let sawBullet = false;
100
+ let startsWithBulletPrefix = false;
101
+
102
+ while (true) {
103
+ const bulletPrefix = text[i] === '\u2022' ? matchSpinnerPart(text, i + 1, SPINNER_PREFIXES) : '';
104
+ if (bulletPrefix) {
105
+ startsWithBulletPrefix ||= tokens === 0;
106
+ sawBullet = true;
107
+ i += 1 + bulletPrefix.length;
108
+ tokens += 1;
109
+ continue;
110
+ }
111
+
112
+ const suffix = matchSpinnerPart(text, i, SPINNER_SUFFIXES);
113
+ if (suffix && text[i + suffix.length] === '\u2022') {
114
+ sawBullet = true;
115
+ i += suffix.length + 1;
116
+ tokens += 1;
117
+ continue;
118
+ }
119
+
120
+ const prefix = matchSpinnerPart(text, i, SPINNER_PREFIXES);
121
+ if (prefix) {
122
+ i += prefix.length;
123
+ tokens += 1;
124
+ continue;
125
+ }
126
+
127
+ if (suffix) {
128
+ i += suffix.length;
129
+ tokens += 1;
130
+ continue;
131
+ }
132
+
133
+ const outro = text.slice(i).match(SPINNER_OUTRO)?.[0];
134
+ if (outro) {
135
+ sawBullet = true;
136
+ i += outro.length;
137
+ tokens += 1;
138
+ continue;
139
+ }
140
+
141
+ const digits = text.slice(i).match(/^\d+/)?.[0];
142
+ if (digits) {
143
+ i += digits.length;
144
+ tokens += 1;
145
+ continue;
146
+ }
147
+
148
+ break;
149
+ }
150
+
151
+ if (startsWithBulletPrefix) return i - start;
152
+ return sawBullet && tokens >= 2 ? i - start : 0;
153
+ }
154
+
155
+ function matchSpinnerPart(text, start, parts) {
156
+ return parts.find(part => text.startsWith(part, start)) || '';
157
+ }
158
+
159
+ function splitCodexAgentEntry(entry) {
160
+ if (!isCodex || entry.role !== 'agent') return [entry];
161
+
162
+ const work = { ...entry, role: 'agent_work' };
163
+ const cut = entry.text.lastIndexOf('\u2022');
164
+ if (cut === -1) return [work];
165
+
166
+ const output = cleanAgentOutput(entry.text.slice(cut + 1));
167
+ if (!output) return [work];
168
+
169
+ return [work, { ts: entry.ts, role: 'agent_output', text: output }];
170
+ }
171
+
172
+ function cleanAgentOutput(text) {
173
+ const out = text.trim();
174
+ const footer = out.lastIndexOf('\u203a');
175
+ return (footer === -1 ? out : out.slice(0, footer)).trim();
176
+ }
177
+
178
+ const cleaned = merged.map(e => ({ ...e, text: cleanText(e.text) }));
179
+ const transformed = cleaned.flatMap(splitCodexAgentEntry);
180
+ const out = transformed.map(e => JSON.stringify(e)).join('\n') + '\n';
181
+ writeFileSync(file, out);
182
+ console.log(`${lines.length} lines → ${merged.length} lines`);
@@ -0,0 +1,53 @@
1
+ function cleanAgentText(presetId, text) {
2
+ let out = String(text || '').split('\n').map(l => l.replace(/[ \t]+$/g, '')).join('\n').trim();
3
+ out = out.replace(/\n\n─{5,}\s*$/u, '').trim();
4
+ if (presetId === 'codex' || presetId === 'claude-code') {
5
+ const cut = Math.max(out.lastIndexOf('›'), out.lastIndexOf('❯'));
6
+ if (cut !== -1) out = out.slice(0, cut).trim();
7
+ }
8
+ if (presetId === 'gemini-cli') {
9
+ const cut = out.lastIndexOf('\n >');
10
+ if (cut !== -1) out = out.slice(0, cut).trim();
11
+ out = out.replace(/\n\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]?\s*Executing Hook:[\s\S]*$/, '').trim();
12
+ }
13
+ if (presetId === 'claude-code') {
14
+ out = out.replace(/\n\s*.*\(running stop hook\)[\s\S]*$/, '').trim();
15
+ out = out.replace(/\n\s*\?\s*for shortcuts[\s\S]*$/, '').trim();
16
+ out = out.replace(/\n\s*esc to interrupt[\s\S]*$/, '').trim();
17
+ out = out.replace(/\n\s*[✻✢✣✤✥✦✧]\s+[^\n]*$/, '').trim();
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function normalizeEntry(entry, presetId) {
23
+ const text = entry.role === 'agent'
24
+ ? cleanAgentText(presetId, entry.text)
25
+ : String(entry.text || '').trim();
26
+ return text ? { ...entry, text } : null;
27
+ }
28
+
29
+ function addEntry(entries, entry, presetId) {
30
+ const next = normalizeEntry(entry, presetId);
31
+ if (!next) return entries;
32
+ const prev = entries[entries.length - 1];
33
+ if (prev && prev.role === 'user' && next.role === 'user') {
34
+ prev.text += '\n' + next.text;
35
+ prev.ts = next.ts;
36
+ return entries;
37
+ }
38
+ if (prev && prev.role === 'agent' && next.role === 'agent') {
39
+ prev.text = next.text;
40
+ prev.ts = next.ts;
41
+ return entries;
42
+ }
43
+ entries.push(next);
44
+ return entries;
45
+ }
46
+
47
+ function compactEntries(entries, presetId) {
48
+ const out = [];
49
+ for (const entry of entries || []) addEntry(out, entry, presetId);
50
+ return out;
51
+ }
52
+
53
+ module.exports = { addEntry, compactEntries, cleanAgentText };
@@ -0,0 +1,135 @@
1
+ function parseTurns(presetId, lines, users) {
2
+ const parser = parsers[presetId];
3
+ return collapseAgentTurns(parser ? parser(lines, users) : anchorParse(lines, users));
4
+ }
5
+
6
+ function parseLastAgentOnly(presetId, lines) {
7
+ const turns = collapseAgentTurns((parsers[presetId] || (() => null))(lines, null));
8
+ if (!turns?.length) return null;
9
+ const last = [...turns].reverse().find(t => t.role === 'agent');
10
+ return last || null;
11
+ }
12
+
13
+ const parsers = {
14
+ 'claude-code': (lines, users) => {
15
+ const known = users?.length ? new Set(users) : null;
16
+ const turns = [];
17
+ let current = null;
18
+ for (const line of lines) {
19
+ const agent = line.match(/^(?:[│ ]\s*)?[⏺•●]\s(.*)$/);
20
+ if (agent) {
21
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
22
+ current = { role: 'agent', text: agent[1] };
23
+ continue;
24
+ }
25
+ const userM = line.match(/^(?:[│ ]\s*)?[❯›]\s(.*)$/);
26
+ if (userM && (known ? known.has(userM[1].trim()) : true)) {
27
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
28
+ current = { role: 'user', text: userM[1] };
29
+ continue;
30
+ }
31
+ if (!current) continue;
32
+ let cont = line;
33
+ if (cont.startsWith('│ ')) cont = cont.slice(2);
34
+ else if (cont.startsWith(' ')) cont = cont.slice(2);
35
+ current.text += '\n' + cont;
36
+ }
37
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
38
+ return turns.length >= 2 ? turns : null;
39
+ },
40
+ codex: (lines, users) => {
41
+ const known = users?.length ? new Set(users) : null;
42
+ const turns = [];
43
+ let current = null;
44
+ for (const line of lines) {
45
+ const agent = line.match(/^(?:│\s*)?•\s(.*)$/);
46
+ if (agent) {
47
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
48
+ current = { role: 'agent', text: agent[1] };
49
+ continue;
50
+ }
51
+ const userM = line.match(/^(?:│\s*)?›\s(.*)$/);
52
+ if (userM && (known ? known.has(userM[1].trim()) : true)) {
53
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
54
+ current = { role: 'user', text: userM[1] };
55
+ continue;
56
+ }
57
+ if (!current) continue;
58
+ let cont = line;
59
+ if (cont.startsWith('│ ')) cont = cont.slice(2);
60
+ else if (cont.startsWith(' ')) cont = cont.slice(2);
61
+ current.text += '\n' + cont;
62
+ }
63
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
64
+ return turns.length >= 2 ? turns : null;
65
+ },
66
+ 'gemini-cli': (lines, users) => {
67
+ const known = users?.length ? new Set(users) : null;
68
+ const isChrome = t => {
69
+ const s = t.trim();
70
+ return /^shift\+tab to accept/i.test(s)
71
+ || /^(Type your message|@path\/to\/)/i.test(s)
72
+ || /^(\/\w+ |no sandbox|\/model )/i.test(s)
73
+ || /^[~\/\\].*\(main[*]?\)\s*$/i.test(s)
74
+ || /^(Logged in with|Plan:|Tips for getting started)/i.test(s)
75
+ || /^\d+\.\s+(Ask questions|Be specific|Create GEMINI)/i.test(s)
76
+ || /^ℹ\s/.test(s);
77
+ };
78
+ const turns = [];
79
+ let current = null;
80
+ for (const line of lines) {
81
+ if (isChrome(line)) continue;
82
+ const isAgent = line.startsWith('✦ ');
83
+ const userM = line.startsWith(' > ') ? line.slice(3) : null;
84
+ const isUser = userM && (known ? known.has(userM.trim()) : true);
85
+ if (isUser || isAgent) {
86
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
87
+ current = { role: isUser ? 'user' : 'agent', text: isUser ? userM : line.slice(2) };
88
+ continue;
89
+ }
90
+ if (!current) continue;
91
+ current.text += '\n' + line;
92
+ }
93
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
94
+ return turns.length >= 2 ? turns : null;
95
+ },
96
+ };
97
+
98
+ function anchorParse(lines, users) {
99
+ if (!users?.length) return null;
100
+ const isPrompt = (line, text) => { const t = line.trim(); return t.endsWith(text) && t.length - text.length <= 6; };
101
+ const lastUser = users[users.length - 1];
102
+ let lastIdx = -1;
103
+ for (let i = lines.length - 1; i >= 0; i--) {
104
+ if (isPrompt(lines[i], lastUser)) { lastIdx = i; break; }
105
+ }
106
+ if (lastIdx < 0) return null;
107
+ const anchors = [{ idx: lastIdx, text: lastUser }];
108
+ for (let u = users.length - 2; u >= 0 && anchors.length < 3; u--) {
109
+ for (let i = anchors[anchors.length - 1].idx - 1; i >= 0; i--) {
110
+ if (isPrompt(lines[i], users[u])) { anchors.push({ idx: i, text: users[u] }); break; }
111
+ }
112
+ }
113
+ anchors.reverse();
114
+ const turns = [];
115
+ for (let p = 0; p < anchors.length; p++) {
116
+ turns.push({ role: 'user', text: anchors[p].text });
117
+ const start = anchors[p].idx + 1;
118
+ const end = p + 1 < anchors.length ? anchors[p + 1].idx : lines.length;
119
+ const agentLines = lines.slice(start, end).filter(l => l.trim());
120
+ if (agentLines.length) turns.push({ role: 'agent', text: agentLines.join('\n') });
121
+ }
122
+ return turns.length >= 2 && turns[turns.length - 1].role === 'agent' ? turns : null;
123
+ }
124
+
125
+ function collapseAgentTurns(turns) {
126
+ if (!turns?.length) return turns;
127
+ const out = [];
128
+ for (let i = 0; i < turns.length; i++) {
129
+ if (turns[i].role === 'agent' && i + 1 < turns.length && turns[i + 1].role === 'agent') continue;
130
+ out.push(turns[i]);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ module.exports = { parseTurns, parseLastAgentOnly };