@adhdev/daemon-core 0.6.56 → 0.6.58

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.
Files changed (37) hide show
  1. package/dist/index.d.ts +23 -0
  2. package/dist/index.js +423 -95
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/providers/_builtin/cli/aider-cli/scripts/1.0/parse_output.js +51 -3
  6. package/providers/_builtin/cli/claude-cli/provider.json +18 -6
  7. package/providers/_builtin/cli/claude-cli/scripts/1.0/detect_status.js +68 -16
  8. package/providers/_builtin/cli/claude-cli/scripts/1.0/parse_approval.js +81 -22
  9. package/providers/_builtin/cli/claude-cli/scripts/1.0/parse_output.js +347 -94
  10. package/providers/_builtin/cli/codex-cli/provider.json +2 -0
  11. package/providers/_builtin/cli/codex-cli/scripts/1.0/detect_status.js +44 -10
  12. package/providers/_builtin/cli/codex-cli/scripts/1.0/parse_approval.js +83 -7
  13. package/providers/_builtin/cli/codex-cli/scripts/1.0/parse_output.js +501 -47
  14. package/providers/_builtin/cli/cursor-cli/scripts/1.0/parse_output.js +1 -1
  15. package/providers/_builtin/cli/github-copilot-cli/scripts/1.0/parse_output.js +1 -1
  16. package/providers/_builtin/cli/goose-cli/scripts/1.0/parse_output.js +1 -1
  17. package/providers/_builtin/cli/opencode-cli/scripts/1.0/parse_output.js +1 -1
  18. package/providers/_builtin/ide/vscode/provider.json +5 -1
  19. package/providers/_builtin/ide/vscode/scripts/1.0/focus_editor.js +1 -0
  20. package/providers/_builtin/ide/vscode/scripts/1.0/list_models.js +1 -0
  21. package/providers/_builtin/ide/vscode/scripts/1.0/list_sessions.js +1 -0
  22. package/providers/_builtin/ide/vscode/scripts/1.0/new_session.js +1 -0
  23. package/providers/_builtin/ide/vscode/scripts/1.0/open_panel.js +1 -0
  24. package/providers/_builtin/ide/vscode/scripts/1.0/read_chat.js +1 -0
  25. package/providers/_builtin/ide/vscode/scripts/1.0/resolve_action.js +1 -0
  26. package/providers/_builtin/ide/vscode/scripts/1.0/scripts.js +25 -0
  27. package/providers/_builtin/ide/vscode/scripts/1.0/send_message.js +1 -0
  28. package/providers/_builtin/ide/vscode/scripts/1.0/set_model.js +1 -0
  29. package/providers/_builtin/ide/vscode/scripts/1.0/switch_session.js +1 -0
  30. package/providers/_builtin/registry.json +1 -1
  31. package/src/cli-adapters/provider-cli-adapter.ts +410 -65
  32. package/src/commands/chat-commands.ts +7 -1
  33. package/src/config/chat-history.ts +53 -1
  34. package/src/daemon/dev-server.ts +7 -9
  35. package/src/providers/cli-provider-instance.ts +10 -23
  36. package/src/providers/provider-instance.ts +1 -0
  37. package/src/providers/version-archive.ts +4 -1
@@ -1,156 +1,409 @@
1
1
  /**
2
2
  * Claude Code — parse_output
3
3
  *
4
- * Full PTY buffer ReadChatResult conversion.
5
- * Called less frequently than detectStatus (on demand, not polling).
6
- *
7
- * Input: {
8
- * buffer: string, // Full ANSI-stripped accumulated PTY output
9
- * rawBuffer: string, // Raw PTY output (with ANSI)
10
- * recentBuffer: string, // Recent 1000 chars (ANSI-stripped)
11
- * messages: Array, // Previously parsed messages (for delta)
12
- * partialResponse: string, // Current partial response being generated
13
- * }
14
- *
15
- * Output: ReadChatResult {
16
- * messages: [{ id, role, content, index, kind?, meta? }],
17
- * status: AgentStatus,
18
- * activeModal?: ModalInfo | null,
19
- * title?: string,
20
- * }
4
+ * Reference implementation for CLI PTY parsing:
5
+ * - prefer the visible screen snapshot (`screenText`)
6
+ * - keep transcript state incrementally via `messages`
7
+ * - fall back to noisy rolling buffers when older runtimes do not provide screenText
21
8
  */
22
9
 
23
10
  'use strict';
24
11
 
25
- const detectStatus = require('./detect_status.js');
12
+ const detectStatus = require('./detect_status.js');
26
13
  const parseApproval = require('./parse_approval.js');
27
14
 
15
+ function splitLines(text) {
16
+ return String(text || '')
17
+ .replace(/\u0007/g, '')
18
+ .split(/\r\n|\n|\r/g)
19
+ .map(line => line.replace(/\s+$/, ''));
20
+ }
21
+
22
+ function sanitizeLine(line) {
23
+ return String(line || '')
24
+ .replace(/\u0007/g, '')
25
+ .replace(/^\d+;/, '')
26
+ .replace(/\s+$/, '');
27
+ }
28
+
28
29
  function normalizeText(text) {
29
30
  return String(text || '')
30
31
  .replace(/\s+/g, ' ')
31
32
  .trim();
32
33
  }
33
34
 
34
- function sanitizeLine(line) {
35
- return String(line || '').replace(/\s+$/, '');
35
+ function looksLikeSamePrompt(left, right) {
36
+ const a = normalizeText(left);
37
+ const b = normalizeText(right);
38
+ if (!a || !b) return false;
39
+ if (a === b) return true;
40
+ const minLength = Math.min(a.length, b.length);
41
+ if (minLength < 24) return false;
42
+ return a.startsWith(b) || b.startsWith(a) || a.includes(b) || b.includes(a);
43
+ }
44
+
45
+ function isSpinnerOnlyText(text) {
46
+ const lines = splitLines(text)
47
+ .map(line => stripAssistantPrefix(sanitizeLine(line).trim()))
48
+ .filter(Boolean);
49
+ if (lines.length === 0) return true;
50
+ return lines.every(line => isStatusLine(line) || /^(?:[A-Z][a-z]+ing\u2026?|[✻✶✳✢✽⠂⠐⠒⠓⠦⠴⠶⠷⠿]+)$/.test(line));
51
+ }
52
+
53
+ function looksLikePromptEchoText(candidate, promptText, previousMessages) {
54
+ const normalizedCandidate = normalizeText(candidate);
55
+ if (!normalizedCandidate) return false;
56
+ if (promptText && looksLikeSamePrompt(normalizedCandidate, promptText)) return true;
57
+
58
+ const lastUser = [...(Array.isArray(previousMessages) ? previousMessages : [])]
59
+ .reverse()
60
+ .find(message => message?.role === 'user' && typeof message.content === 'string');
61
+ return !!lastUser && looksLikeSamePrompt(normalizedCandidate, lastUser.content);
62
+ }
63
+
64
+ function parsePromptLine(line) {
65
+ const trimmed = sanitizeLine(line).trim();
66
+ const match = trimmed.match(/^[❯›>]\s*(.*)$/);
67
+ if (!match) return null;
68
+ const body = match[1].trim();
69
+ if (/^\d+[.)]\s+/.test(body)) return null;
70
+ return body;
71
+ }
72
+
73
+ function isBoxLine(trimmed) {
74
+ return /^[─═╭╮╰╯│┌┐└┘├┤┬┴┼]+$/.test(trimmed);
75
+ }
76
+
77
+ function isFooterLine(trimmed) {
78
+ return /^➜\s+\S+/.test(trimmed)
79
+ || /^Update available!/i.test(trimmed)
80
+ || /Claude Code v\d/i.test(trimmed)
81
+ || /^(Sonnet|Opus|Haiku)\b/i.test(trimmed)
82
+ || /^[◐◑◒◓◴◵◶◷◸◹◺◿].*\/effort/i.test(trimmed)
83
+ || /^⏵⏵\s+accept edits on/i.test(trimmed)
84
+ || /^ctrl\+g to edit in VS Code/i.test(trimmed)
85
+ || /^✳\s*Claude Code/i.test(trimmed)
86
+ || /^[▗▖▘▝\s]+~\//.test(trimmed);
87
+ }
88
+
89
+ function isStatusLine(trimmed) {
90
+ if (!trimmed) return true;
91
+ if (/^[✻✶✳✢✽⠂⠐⠒⠓⠦⠴⠶⠷⠿]+$/.test(trimmed)) return true;
92
+ if (/^[⠂⠐⠒⠓⠦⠴⠶⠷⠿]\s+/.test(trimmed)) return true;
93
+ if (/esc to (cancel|interrupt|stop)/i.test(trimmed)) return true;
94
+ if (/(?:Finagling|Scurrying|Bloviating|Whatchamacallit(?:ing)?|Hatching|Tinkering|Thinking|Processing|Working|Analyzing|Planning|Drafting|Synthesizing|Inspecting|Reading|Searching)\u2026?$/i.test(trimmed)) return true;
95
+ if (/Allow\s*once|Always\s*allow|\(y\/n\)|\[Y\/n\]/i.test(trimmed)) return true;
96
+ return false;
36
97
  }
37
98
 
38
99
  function isNoiseLine(line) {
39
- const trimmed = line.trim();
100
+ const trimmed = sanitizeLine(line).trim();
40
101
  if (!trimmed) return true;
41
- if (/^[─═╭╮╰╯│┌┐└┘├┤┬┴┼]+$/.test(trimmed)) return true;
102
+ if (/^…\s+\+\d+\s+lines\b/i.test(trimmed)) return true;
103
+ if (isBoxLine(trimmed)) return true;
104
+ if (isFooterLine(trimmed)) return true;
105
+ if (isStatusLine(trimmed)) return true;
42
106
  if (/^Type your message/i.test(trimmed)) return true;
43
107
  if (/^for\s*shortcuts/i.test(trimmed)) return true;
44
108
  if (/^\? for help/i.test(trimmed)) return true;
45
109
  if (/^Press enter/i.test(trimmed)) return true;
46
- if (/^[\u2800-\u28ff]+$/.test(trimmed)) return true;
47
- if (/^esc to (cancel|interrupt|stop)/i.test(trimmed)) return true;
48
- if (/^Allow\s*once/i.test(trimmed)) return true;
49
- if (/^Always\s*allow/i.test(trimmed)) return true;
50
- if (/^\[(Y\/n|y\/n)\]$/i.test(trimmed)) return true;
51
110
  return false;
52
111
  }
53
112
 
54
- function extractPromptLine(lines) {
55
- for (let i = lines.length - 1; i >= 0; i--) {
56
- const trimmed = lines[i].trim();
57
- const match = trimmed.match(/^[❯›>]\s+(.+)$/);
58
- if (match && match[1].trim().length > 0) {
59
- return { index: i, text: match[1].trim() };
113
+ function stripAssistantPrefix(trimmed) {
114
+ return trimmed
115
+ .replace(/^[⏺•]\s+/, '')
116
+ .replace(/^⎿\s+/, '')
117
+ .replace(/^[✻✶✳✢✽]\s+/, '')
118
+ .trim();
119
+ }
120
+
121
+ function collectMeaningfulLines(lines) {
122
+ const out = [];
123
+ let captureDetails = false;
124
+ for (let index = 0; index < lines.length; index++) {
125
+ const rawLine = lines[index];
126
+ const promptText = parsePromptLine(rawLine);
127
+ if (promptText !== null) continue;
128
+
129
+ const sanitized = sanitizeLine(rawLine);
130
+ const trimmed = sanitized.trim();
131
+ if (isNoiseLine(trimmed)) continue;
132
+ const nextTrimmed = sanitizeLine(lines[index + 1] || '').trim();
133
+ if (/\u2026\)$/.test(trimmed) && /^⎿\s+/.test(nextTrimmed)) continue;
134
+
135
+ const cleaned = stripAssistantPrefix(trimmed);
136
+ if (!cleaned) continue;
137
+ if (/^⏺\s+/.test(trimmed)) {
138
+ captureDetails = /^(?:Bash|Read|Task)\(/.test(cleaned)
139
+ || /^(?:Exact output|Output|Result):/i.test(cleaned);
140
+ if (out[out.length - 1] !== cleaned) out.push(cleaned);
141
+ continue;
142
+ }
143
+
144
+ if (/^⎿\s+/.test(trimmed)) {
145
+ if (!captureDetails || /^…\s+\+\d+\s+lines\b/i.test(cleaned)) continue;
146
+ if (out[out.length - 1] !== cleaned) out.push(cleaned);
147
+ continue;
60
148
  }
149
+
150
+ if (!captureDetails && /^\d+\s+/.test(trimmed)) continue;
151
+ if (cleaned.length === 1 && /^[A-Za-z]$/.test(cleaned)) continue;
152
+ if (out[out.length - 1] !== cleaned) out.push(cleaned);
61
153
  }
62
- return { index: -1, text: '' };
154
+ return out;
155
+ }
156
+
157
+ function looksLikeStructuredDataLine(text) {
158
+ const line = stripAssistantPrefix(sanitizeLine(text).trim());
159
+ if (!line) return false;
160
+ return /^[{\[]/.test(line)
161
+ || /^[A-Z0-9_]+=/.test(line)
162
+ || /^\/[A-Za-z0-9._/-]+$/.test(line)
163
+ || /^\d+$/.test(line);
63
164
  }
64
165
 
65
- function extractVisibleAssistant(screenText) {
66
- if (!screenText) return { promptText: '', assistantText: '' };
67
- const lines = screenText.split('\n').map(sanitizeLine);
68
- const prompt = extractPromptLine(lines);
69
- const afterPrompt = prompt.index >= 0 ? lines.slice(prompt.index + 1) : lines;
70
- const contentLines = afterPrompt.filter(line => !isNoiseLine(line));
166
+ function extractDenseOutputBlock(text) {
167
+ const lines = splitLines(text);
168
+ const blocks = [];
169
+ let current = [];
170
+
171
+ for (const rawLine of lines) {
172
+ const promptText = parsePromptLine(rawLine);
173
+ const sanitized = sanitizeLine(rawLine).trim();
174
+ if (promptText !== null || isNoiseLine(sanitized)) {
175
+ if (current.length > 0) {
176
+ blocks.push(current);
177
+ current = [];
178
+ }
179
+ continue;
180
+ }
181
+
182
+ const cleaned = stripAssistantPrefix(sanitized);
183
+ if (!cleaned) {
184
+ if (current.length > 0) {
185
+ blocks.push(current);
186
+ current = [];
187
+ }
188
+ continue;
189
+ }
190
+ current.push(cleaned);
191
+ }
192
+
193
+ if (current.length > 0) blocks.push(current);
194
+
195
+ const scored = blocks
196
+ .map(block => ({
197
+ block,
198
+ structured: block.filter(looksLikeStructuredDataLine).length,
199
+ }))
200
+ .filter(entry => entry.block.length >= 5 && entry.structured >= Math.max(3, Math.floor(entry.block.length * 0.6)));
201
+
202
+ const mergeBlocks = (existing, next) => {
203
+ if (existing.length === 0) return [...next];
204
+ const maxOverlap = Math.min(existing.length, next.length);
205
+ for (let overlap = maxOverlap; overlap >= 1; overlap--) {
206
+ let matches = true;
207
+ for (let i = 0; i < overlap; i++) {
208
+ if (existing[existing.length - overlap + i] !== next[i]) {
209
+ matches = false;
210
+ break;
211
+ }
212
+ }
213
+ if (matches) {
214
+ return existing.concat(next.slice(overlap));
215
+ }
216
+ }
217
+ return existing.concat(next);
218
+ };
219
+
220
+ const merged = scored.reduce((acc, entry) => mergeBlocks(acc, entry.block), []);
221
+ return merged.join('\n').trim();
222
+ }
223
+
224
+ function collectAssistantBlocks(lines) {
225
+ const blocks = [];
226
+ let current = null;
227
+ for (const rawLine of lines) {
228
+ const sanitized = sanitizeLine(rawLine);
229
+ const trimmed = sanitized.trim();
230
+ if (!trimmed || isNoiseLine(trimmed)) continue;
231
+
232
+ if (/^⏺\s+/.test(trimmed)) {
233
+ const title = stripAssistantPrefix(trimmed);
234
+ if (!title) {
235
+ current = null;
236
+ continue;
237
+ }
238
+ current = {
239
+ title,
240
+ lines: [title],
241
+ isTool: /^(?:Bash|Read|Write|Edit|MultiEdit|Task|Glob|Grep|LS|NotebookEdit)\(/.test(title),
242
+ };
243
+ blocks.push(current);
244
+ continue;
245
+ }
246
+
247
+ if (!current) continue;
248
+ const cleaned = stripAssistantPrefix(trimmed);
249
+ if (!cleaned || /^…\s+\+\d+\s+lines\b/i.test(cleaned)) continue;
250
+ current.lines.push(cleaned);
251
+ }
252
+ return blocks;
253
+ }
254
+
255
+ function extractVisibleTurn(text, previousMessages) {
256
+ const lines = splitLines(text);
257
+ const emptyPromptIndex = (() => {
258
+ for (let i = lines.length - 1; i >= 0; i--) {
259
+ if (parsePromptLine(lines[i]) === '') return i;
260
+ }
261
+ return -1;
262
+ })();
263
+
264
+ const userPrompt = (() => {
265
+ const upperBound = emptyPromptIndex >= 0 ? emptyPromptIndex - 1 : lines.length - 1;
266
+ for (let i = upperBound; i >= 0; i--) {
267
+ const parsed = parsePromptLine(lines[i]);
268
+ if (parsed) return { index: i, text: parsed };
269
+ }
270
+ return { index: -1, text: '' };
271
+ })();
272
+
273
+ const promptLines = [];
274
+ let assistantStart = userPrompt.index >= 0 ? userPrompt.index + 1 : 0;
275
+ if (userPrompt.index >= 0) {
276
+ promptLines.push(userPrompt.text);
277
+ for (let i = userPrompt.index + 1; i < lines.length; i++) {
278
+ const trimmed = sanitizeLine(lines[i]).trim();
279
+ if (!trimmed) {
280
+ assistantStart = i + 1;
281
+ break;
282
+ }
283
+ if (/^[⏺•]/.test(trimmed) || isBoxLine(trimmed) || isFooterLine(trimmed) || isStatusLine(trimmed) || /^([❯›>]\s*)?\d+[.)]\s+/.test(trimmed)) {
284
+ assistantStart = i;
285
+ break;
286
+ }
287
+ promptLines.push(trimmed);
288
+ assistantStart = i + 1;
289
+ }
290
+ }
291
+
292
+ const end = emptyPromptIndex >= 0 ? emptyPromptIndex : lines.length;
293
+ const assistantWindow = lines.slice(assistantStart, end);
294
+ const blocks = collectAssistantBlocks(assistantWindow);
295
+ const lastNarrativeBlock = [...blocks].reverse().find(block => !block.isTool);
296
+ let assistantLines = lastNarrativeBlock
297
+ ? lastNarrativeBlock.lines
298
+ : collectMeaningfulLines(assistantWindow);
299
+
300
+ if (assistantLines.length === 0 && Array.isArray(previousMessages) && previousMessages.length > 0) {
301
+ assistantLines = collectMeaningfulLines(lines);
302
+ }
303
+
71
304
  return {
72
- promptText: prompt.text,
73
- assistantText: contentLines.join('\n').trim(),
305
+ promptText: promptLines.join(' ').trim(),
306
+ assistantText: assistantLines.join('\n').trim(),
74
307
  };
75
308
  }
76
309
 
77
- function toMessageObjects(messages, status) {
78
- const max = 50;
79
- const slice = messages.slice(-max);
80
- return slice.map((message, index) => ({
81
- id: `msg_${index}`,
82
- role: message.role,
83
- content: typeof message.content === 'string' && message.content.length > 6000
84
- ? message.content.slice(0, 6000) + '\n[... truncated]'
85
- : message.content,
86
- index,
87
- kind: 'standard',
88
- ...(status === 'generating' && index === slice.length - 1 && message.role === 'assistant'
89
- ? { meta: { streaming: true } }
90
- : {}),
91
- }));
310
+ function extractPartialAssistant(text) {
311
+ const meaningful = collectMeaningfulLines(splitLines(text));
312
+ return meaningful.join('\n').trim();
92
313
  }
93
314
 
94
- function buildMessages(previousMessages, promptText, assistantText, status, partialResponse) {
315
+ function buildMessages(previousMessages, promptText, assistantText, partialText) {
95
316
  const base = Array.isArray(previousMessages)
96
- ? previousMessages.map(m => ({
97
- role: m.role,
98
- content: typeof m.content === 'string' ? m.content : String(m.content || ''),
99
- timestamp: m.timestamp,
100
- }))
317
+ ? previousMessages
318
+ .filter(message => message && (message.role === 'user' || message.role === 'assistant'))
319
+ .map(message => ({
320
+ role: message.role,
321
+ content: typeof message.content === 'string' ? message.content : String(message.content || ''),
322
+ timestamp: message.timestamp,
323
+ }))
101
324
  : [];
102
325
 
103
- const last = base[base.length - 1];
326
+ if (!promptText && base.length === 0) {
327
+ return base;
328
+ }
329
+
104
330
  if (promptText) {
105
331
  const normalizedPrompt = normalizeText(promptText);
106
- if (!last || last.role !== 'user' || normalizeText(last.content) !== normalizedPrompt) {
332
+ const last = base[base.length - 1];
333
+ const previousUser = last?.role === 'assistant' ? base[base.length - 2] : last;
334
+ if (!previousUser || previousUser.role !== 'user' || !looksLikeSamePrompt(previousUser.content, normalizedPrompt)) {
107
335
  base.push({ role: 'user', content: promptText });
108
336
  }
109
337
  }
110
338
 
111
- const candidateAssistant = normalizeText(partialResponse || '') || assistantText;
112
- if (candidateAssistant) {
113
- const normalizedAssistant = normalizeText(candidateAssistant);
114
- const lastMsg = base[base.length - 1];
115
- if (lastMsg && lastMsg.role === 'assistant') {
116
- const existing = normalizeText(lastMsg.content);
117
- if (normalizedAssistant !== existing) {
118
- lastMsg.content = candidateAssistant;
119
- }
120
- } else {
121
- base.push({ role: 'assistant', content: candidateAssistant });
122
- }
123
- }
339
+ const candidateAssistant = assistantText || partialText;
340
+ if (!candidateAssistant) return base;
124
341
 
125
- if (status !== 'generating') {
126
- const lastMsg = base[base.length - 1];
127
- if (lastMsg?.role === 'assistant') {
128
- const visible = normalizeText(assistantText);
129
- if (visible && visible !== normalizeText(lastMsg.content)) {
130
- lastMsg.content = assistantText;
131
- }
342
+ const normalizedAssistant = normalizeText(candidateAssistant);
343
+ if (!normalizedAssistant) return base;
344
+ if (looksLikePromptEchoText(candidateAssistant, promptText, previousMessages)) return base;
345
+ if (!assistantText && isSpinnerOnlyText(candidateAssistant)) return base;
346
+
347
+ const last = base[base.length - 1];
348
+ if (last && last.role === 'assistant') {
349
+ if (normalizeText(last.content) !== normalizedAssistant) {
350
+ last.content = candidateAssistant;
132
351
  }
352
+ } else {
353
+ base.push({ role: 'assistant', content: candidateAssistant });
133
354
  }
134
355
 
135
356
  return base;
136
357
  }
137
358
 
359
+ function toMessageObjects(messages, status) {
360
+ return messages.slice(-50).map((message, index, slice) => ({
361
+ id: `msg_${index}`,
362
+ role: message.role,
363
+ content: typeof message.content === 'string' && message.content.length > 6000
364
+ ? message.content.slice(0, 6000) + '\n[... truncated]'
365
+ : message.content,
366
+ index,
367
+ kind: 'standard',
368
+ ...(status === 'generating' && index === slice.length - 1 && message.role === 'assistant'
369
+ ? { meta: { streaming: true } }
370
+ : {}),
371
+ }));
372
+ }
373
+
138
374
  module.exports = function parseOutput(input) {
139
- const { buffer, recentBuffer, partialResponse, screenText, messages: previousMessages } = input;
140
- const transcript = screenText || buffer || '';
375
+ const screenText = String(input?.screenText || '');
376
+ const buffer = String(input?.buffer || '');
377
+ const terminalHistory = String(input?.terminalHistory || '');
378
+ const tail = String(input?.recentBuffer || (screenText || buffer).slice(-500));
379
+ const previousMessages = Array.isArray(input?.messages) ? input.messages : [];
380
+ const transcriptSource = screenText || buffer;
141
381
 
142
- // Status
143
- const tail = (recentBuffer || (transcript || '').slice(-500));
144
- const status = detectStatus({ tail });
382
+ const status = detectStatus({
383
+ tail,
384
+ screenText,
385
+ rawBuffer: input?.rawBuffer || '',
386
+ });
145
387
 
146
- // Modal
147
388
  const activeModal = status === 'waiting_approval'
148
- ? parseApproval({ buffer: transcript, tail })
389
+ ? parseApproval({ buffer: screenText || buffer, rawBuffer: input?.rawBuffer || '', tail })
149
390
  : null;
150
391
 
151
- const { promptText, assistantText } = extractVisibleAssistant(transcript);
392
+ const { promptText, assistantText: visibleAssistantText } = status === 'waiting_approval'
393
+ ? { promptText: '', assistantText: '' }
394
+ : extractVisibleTurn(transcriptSource, previousMessages);
395
+ const denseTerminalOutput = extractDenseOutputBlock(terminalHistory || buffer);
396
+ const assistantText = denseTerminalOutput || visibleAssistantText;
397
+ const rawPartialText = status === 'generating'
398
+ ? extractPartialAssistant(input?.partialResponse || '')
399
+ : '';
400
+ const partialText = (!rawPartialText
401
+ || isSpinnerOnlyText(rawPartialText)
402
+ || looksLikePromptEchoText(rawPartialText, promptText, previousMessages))
403
+ ? ''
404
+ : rawPartialText;
152
405
  const messages = toMessageObjects(
153
- buildMessages(previousMessages, promptText, assistantText, status, partialResponse),
406
+ buildMessages(previousMessages, promptText, assistantText, partialText),
154
407
  status
155
408
  );
156
409
 
@@ -57,6 +57,8 @@
57
57
  }
58
58
  },
59
59
  "binary": "codex",
60
+ "sendDelayMs": 1200,
61
+ "sendKey": "\r",
60
62
  "spawn": {
61
63
  "command": "codex",
62
64
  "args": [],
@@ -1,20 +1,54 @@
1
1
  /**
2
2
  * Codex CLI — detect_status
3
- * OpenAI Codex CLI uses a different TUI pattern.
4
3
  */
5
4
  'use strict';
5
+
6
+ function sourceText(input) {
7
+ return `${String(input?.screenText || '')}\n${String(input?.tail || '')}`;
8
+ }
9
+
10
+ function hasWelcomeScreen(text) {
11
+ return /OpenAI Codex/i.test(text)
12
+ && /To get started, describe a task/i.test(text);
13
+ }
14
+
15
+ function hasStartupApproval(text) {
16
+ return /You are running Codex in/i.test(text)
17
+ && /Press Enter to continue/i.test(text)
18
+ && /(?:^|\n)[▌> \t]*1\.\s+/m.test(text)
19
+ && /(?:^|\n)[▌> \t]*2\.\s+/m.test(text);
20
+ }
21
+
22
+ function hasCommandApproval(text) {
23
+ const hasAllowPrompt = /Allow Codex to (?:run|apply)/i.test(text)
24
+ || /Allow command\?/i.test(text);
25
+ const hasButtons = /Approve and run now/i.test(text)
26
+ || /Always approve this session/i.test(text)
27
+ || /(?:^|\n)[▌> \t]*1\.\s+.*(?:approve|allow|run)/im.test(text)
28
+ || /(?:^|\n)[▌> \t]*2\.\s+.*(?:always|session)/im.test(text);
29
+ const hasFooter = /Press Enter to confirm/i.test(text)
30
+ || /Esc to cancel/i.test(text);
31
+ return hasAllowPrompt
32
+ || (hasButtons && hasFooter)
33
+ || /Approve and run now/i.test(text)
34
+ || /Always approve this session/i.test(text)
35
+ || /(?:^|\n)[▌> \t]*1\.\s+.*(?:approve|allow|run)/im.test(text);
36
+ }
37
+
6
38
  module.exports = function detectStatus(input) {
7
- const { tail } = input;
8
- if (!tail) return 'idle';
39
+ const text = sourceText(input);
40
+ if (!text.trim()) return 'idle';
41
+
42
+ if (hasStartupApproval(text) || hasCommandApproval(text)) return 'waiting_approval';
9
43
 
10
- // waiting_approval
11
- if (/approve|deny|allow|reject/i.test(tail) && /\[.*\]/i.test(tail)) return 'waiting_approval';
12
- if (/Run command/i.test(tail) && /\(y\/n\)/i.test(tail)) return 'waiting_approval';
44
+ if (/Esc to interrupt/i.test(text)) return 'generating';
45
+ if (/(?:Thinking|Planning|Searching|Reading|Working|Analyzing|Inspecting|Responding|Following instructions clearly)[^\n]*\(\d+s\b/i.test(text)) {
46
+ return 'generating';
47
+ }
48
+ if (/[⠁-⣿]/.test(text) && /(?:Working|Thinking|Esc to interrupt)/i.test(text)) return 'generating';
13
49
 
14
- // generating
15
- if (/[\u2800-\u28ff]/.test(tail)) return 'generating';
16
- if (/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/.test(tail)) return 'generating';
17
- if (/thinking|processing|running/i.test(tail)) return 'generating';
50
+ if (hasWelcomeScreen(text)) return 'idle';
51
+ if (/⏎\s+send/i.test(text)) return 'idle';
18
52
 
19
53
  return 'idle';
20
54
  };
@@ -2,14 +2,90 @@
2
2
  * Codex CLI — parse_approval
3
3
  */
4
4
  'use strict';
5
+
6
+ function splitLines(text) {
7
+ return String(text || '')
8
+ .replace(/\u0007/g, '')
9
+ .split(/\r\n|\n|\r/g)
10
+ .map(line => line.replace(/\s+$/, ''));
11
+ }
12
+
13
+ function normalize(line) {
14
+ return String(line || '')
15
+ .replace(/\u0007/g, '')
16
+ .replace(/^\d+;/, '')
17
+ .replace(/\s+/g, ' ')
18
+ .trim();
19
+ }
20
+
21
+ function isBoxLine(line) {
22
+ return /^[─═╭╮╰╯│┌┐└┘├┤┬┴┼]+$/.test(line);
23
+ }
24
+
25
+ function isFooterLine(line) {
26
+ return /⏎\s+send/i.test(line)
27
+ || /⌃J\s+newline/i.test(line)
28
+ || /⌃T\s+transcript/i.test(line)
29
+ || /⌃C\s+quit/i.test(line)
30
+ || /Press Enter to (?:continue|confirm)/i.test(line)
31
+ || /Esc to cancel/i.test(line);
32
+ }
33
+
34
+ function stripLeadingMarkers(s) {
35
+ return s.replace(/^(?:[▌>]\s*)+/, '').trim();
36
+ }
37
+
38
+ function normalizeButton(line) {
39
+ return stripLeadingMarkers(normalize(line))
40
+ .replace(/^\d+\.\s+/, '')
41
+ .replace(/\s{2,}\([A-Za-z]\)\s.*$/, '')
42
+ .trim();
43
+ }
44
+
45
+ function isButtonLine(line) {
46
+ return /^\d+\.\s+/.test(stripLeadingMarkers(normalize(line)));
47
+ }
48
+
5
49
  module.exports = function parseApproval(input) {
6
- const { tail } = input;
7
- if (!tail) return null;
8
- const hasApproval = /approve|allow/i.test(tail) && /deny|reject/i.test(tail);
9
- if (!hasApproval && !/\(y\/n\)/i.test(tail)) return null;
10
- const lines = tail.split('\n').map(l => l.trim()).filter(l => l && !/^[─═╭╮╰╯│]+$/.test(l));
50
+ const text = String(input?.buffer || input?.tail || '');
51
+ const lines = splitLines(text);
52
+ if (lines.length === 0) return null;
53
+
54
+ const buttons = [];
55
+ let currentButton = '';
56
+ const recentLines = lines.slice(-60);
57
+ for (const rawLine of recentLines) {
58
+ const line = normalize(rawLine);
59
+ if (!line) continue;
60
+ if (isButtonLine(rawLine)) {
61
+ if (currentButton && !buttons.includes(currentButton)) buttons.push(currentButton);
62
+ currentButton = normalizeButton(rawLine);
63
+ continue;
64
+ }
65
+ if (!currentButton) continue;
66
+ if (isFooterLine(line) || isBoxLine(line)) continue;
67
+ if (/^(?:model|directory):/i.test(line)) continue;
68
+ const continuation = stripLeadingMarkers(line);
69
+ if (!continuation) continue;
70
+ currentButton = `${currentButton} ${continuation}`.replace(/\s+/g, ' ').trim();
71
+ }
72
+ if (currentButton && !buttons.includes(currentButton)) buttons.push(currentButton);
73
+
74
+ const approvalText = lines
75
+ .map(normalize)
76
+ .filter(line => line && !isBoxLine(line) && !isButtonLine(line) && !isFooterLine(line))
77
+ .filter(line => !/^OpenAI Codex\b/i.test(line))
78
+ .filter(line => !/^model:/i.test(line))
79
+ .filter(line => !/^directory:/i.test(line));
80
+
81
+ const hasApproval = /You are running Codex in/i.test(text)
82
+ || /Allow Codex to (?:run|apply)/i.test(text)
83
+ || /Allow command\?/i.test(text)
84
+ || buttons.length > 0;
85
+ if (!hasApproval) return null;
86
+
11
87
  return {
12
- message: lines.slice(-5).join(' ').slice(0, 200) || 'Approval required',
13
- buttons: ['Approve', 'Deny'],
88
+ message: approvalText.slice(-3).join(' ').slice(0, 240) || 'Codex approval required',
89
+ buttons: buttons.length > 0 ? buttons : ['Approve', 'Deny'],
14
90
  };
15
91
  };