@steipete/oracle 0.5.2 → 0.5.4

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
@@ -17,6 +17,8 @@ Oracle bundles your prompt and files so another AI can answer with real context.
17
17
 
18
18
  Install globally: `npm install -g @steipete/oracle`
19
19
 
20
+ Use `npx -y @steipete/oracle …` (not `pnpx`)—pnpx's sandboxed cache can’t load the sqlite bindings and will throw missing `node_sqlite3.node` errors.
21
+
20
22
  ```bash
21
23
  # Copy the bundle and paste into ChatGPT
22
24
  npx @steipete/oracle --render --copy -p "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"
@@ -38,7 +40,7 @@ npx @steipete/oracle status --hours 72
38
40
  npx @steipete/oracle session <id> --render
39
41
 
40
42
  # TUI (interactive, only for humans)
41
- npx @steipete/oracle
43
+ npx @steipete/oracle tui
42
44
  ```
43
45
 
44
46
  Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS and works on Linux and Windows. On Linux pass `--browser-chrome-path/--browser-cookie-path` if detection fails; on Windows prefer `--browser-manual-login` or inline cookies if decryption is blocked.
package/dist/.DS_Store CHANGED
Binary file
@@ -49,7 +49,6 @@ const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
49
49
  const rawCliArgs = process.argv.slice(2);
50
50
  const userCliArgs = rawCliArgs[0] === CLI_ENTRYPOINT ? rawCliArgs.slice(1) : rawCliArgs;
51
51
  const isTty = process.stdout.isTTY;
52
- const tuiEnabled = () => isTty && process.env.ORACLE_NO_TUI !== '1';
53
52
  const program = new Command();
54
53
  let introPrinted = false;
55
54
  program.hook('preAction', () => {
@@ -66,8 +65,8 @@ program.hook('preAction', (thisCommand) => {
66
65
  if (userCliArgs.some((arg) => arg === '--help' || arg === '-h')) {
67
66
  return;
68
67
  }
69
- if (userCliArgs.length === 0 && tuiEnabled()) {
70
- // Skip prompt enforcement; runRootCommand will launch the TUI.
68
+ if (userCliArgs.length === 0) {
69
+ // Let the root action handle zero-arg entry (help + hint to `oracle tui`).
71
70
  return;
72
71
  }
73
72
  const opts = thisCommand.optsWithGlobals();
@@ -212,6 +211,13 @@ program
212
211
  token: commandOptions.token,
213
212
  });
214
213
  });
214
+ program
215
+ .command('tui')
216
+ .description('Launch the interactive terminal UI for humans (no automation).')
217
+ .action(async () => {
218
+ await sessionStore.ensureStorage();
219
+ await launchTui({ version: VERSION, printIntro: false });
220
+ });
215
221
  const sessionCommand = program
216
222
  .command('session [id]')
217
223
  .description('Attach to a stored session or list recent sessions when no ID is provided.')
@@ -422,12 +428,8 @@ async function runRootCommand(options) {
422
428
  console.log(chalk.dim(`Remote browser host detected: ${remoteHost}`));
423
429
  }
424
430
  if (userCliArgs.length === 0) {
425
- if (tuiEnabled()) {
426
- await launchTui({ version: VERSION, printIntro: false });
427
- return;
428
- }
429
- console.log(chalk.yellow('No prompt or subcommand supplied. See `oracle --help` for usage.'));
430
- program.help({ error: false });
431
+ console.log(chalk.yellow('No prompt or subcommand supplied. Run `oracle --help` or `oracle tui` for the TUI.'));
432
+ program.outputHelp();
431
433
  return;
432
434
  }
433
435
  const retentionHours = typeof options.retainHours === 'number' ? options.retainHours : undefined;
@@ -291,8 +291,9 @@ function buildResponseObserverExpression(timeoutMs) {
291
291
  if (!stop) {
292
292
  return;
293
293
  }
294
- const ariaLabel = stop.getAttribute('aria-label') || '';
295
- if (ariaLabel.toLowerCase().includes('stop')) {
294
+ const isStopButton =
295
+ stop.getAttribute('data-testid') === 'stop-button' || stop.getAttribute('aria-label')?.toLowerCase()?.includes('stop');
296
+ if (isStopButton) {
296
297
  return;
297
298
  }
298
299
  dispatchClickSequence(stop);
@@ -7,32 +7,145 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
7
7
  if (!dom) {
8
8
  throw new Error('DOM domain unavailable while uploading attachments.');
9
9
  }
10
- const documentNode = await dom.getDocument();
11
- const selectors = FILE_INPUT_SELECTORS;
12
- let targetNodeId;
13
- for (const selector of selectors) {
14
- const result = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector });
15
- if (result.nodeId) {
16
- targetNodeId = result.nodeId;
17
- break;
10
+ const isAttachmentPresent = async (name) => {
11
+ const check = await runtime.evaluate({
12
+ expression: `(() => {
13
+ const expected = ${JSON.stringify(name.toLowerCase())};
14
+ const selectors = [
15
+ '[data-testid*="attachment"]',
16
+ '[data-testid*="chip"]',
17
+ '[data-testid*="upload"]'
18
+ ];
19
+ const chips = selectors.some((selector) =>
20
+ Array.from(document.querySelectorAll(selector)).some((node) =>
21
+ (node?.textContent || '').toLowerCase().includes(expected),
22
+ ),
23
+ );
24
+ if (chips) return true;
25
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
26
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
27
+ );
28
+ if (cardTexts.some((text) => text.includes(expected))) return true;
29
+
30
+ const filesPill = Array.from(document.querySelectorAll('button,div')).some((node) => {
31
+ const text = (node?.textContent || '').toLowerCase();
32
+ return /\bfiles\b/.test(text) && text.includes('file');
33
+ });
34
+ if (filesPill) return true;
35
+
36
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
37
+ Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
38
+ );
39
+ return inputs;
40
+ })()`,
41
+ returnByValue: true,
42
+ });
43
+ return Boolean(check?.result?.value);
44
+ };
45
+ // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
46
+ await Promise.resolve(runtime.evaluate({
47
+ expression: `(() => {
48
+ const selectors = [
49
+ '#composer-plus-btn',
50
+ 'button[data-testid="composer-plus-btn"]',
51
+ '[data-testid*="plus"]',
52
+ 'button[aria-label*="add"]',
53
+ 'button[aria-label*="attachment"]',
54
+ 'button[aria-label*="file"]',
55
+ ];
56
+ for (const selector of selectors) {
57
+ const el = document.querySelector(selector);
58
+ if (el instanceof HTMLElement) {
59
+ el.click();
60
+ return true;
61
+ }
18
62
  }
63
+ return false;
64
+ })()`,
65
+ returnByValue: true,
66
+ })).catch(() => undefined);
67
+ await delay(250);
68
+ // Helper to click the upload menu item (if present) to reveal the real attachment input.
69
+ await Promise.resolve(runtime.evaluate({
70
+ expression: `(() => {
71
+ const menuItems = Array.from(document.querySelectorAll('[data-testid*="upload"],[data-testid*="attachment"], [role="menuitem"], [data-radix-collection-item]'));
72
+ for (const el of menuItems) {
73
+ const text = (el.textContent || '').toLowerCase();
74
+ const tid = el.getAttribute?.('data-testid')?.toLowerCase?.() || '';
75
+ if (tid.includes('upload') || tid.includes('attachment') || text.includes('upload') || text.includes('file')) {
76
+ if (el instanceof HTMLElement) { el.click(); return true; }
77
+ }
78
+ }
79
+ return false;
80
+ })()`,
81
+ returnByValue: true,
82
+ })).catch(() => undefined);
83
+ const expectedName = path.basename(attachment.path);
84
+ if (await isAttachmentPresent(expectedName)) {
85
+ logger(`Attachment already present: ${path.basename(attachment.path)}`);
86
+ return;
19
87
  }
20
- if (!targetNodeId) {
21
- await logDomFailure(runtime, logger, 'file-input');
88
+ // Find a real input; prefer non-image accept fields and tag it for DOM.setFileInputFiles.
89
+ const markResult = await runtime.evaluate({
90
+ expression: `(() => {
91
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
92
+ const acceptIsImageOnly = (accept) => {
93
+ if (!accept) return false;
94
+ const parts = String(accept)
95
+ .split(',')
96
+ .map((p) => p.trim().toLowerCase())
97
+ .filter(Boolean);
98
+ return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
99
+ };
100
+ const nonImage = inputs.filter((el) => !acceptIsImageOnly(el.getAttribute('accept')));
101
+ const target = (nonImage.length ? nonImage[nonImage.length - 1] : inputs[inputs.length - 1]) ?? null;
102
+ if (target) {
103
+ target.setAttribute('data-oracle-upload-target', 'true');
104
+ return true;
105
+ }
106
+ return false;
107
+ })()`,
108
+ returnByValue: true,
109
+ });
110
+ const marked = Boolean(markResult?.result?.value);
111
+ if (!marked) {
112
+ await logDomFailure(runtime, logger, 'file-input-missing');
22
113
  throw new Error('Unable to locate ChatGPT file attachment input.');
23
114
  }
24
- await dom.setFileInputFiles({ nodeId: targetNodeId, files: [attachment.path] });
25
- const expectedName = path.basename(attachment.path);
26
- const ready = await waitForAttachmentSelection(runtime, expectedName, 10_000);
27
- if (!ready) {
28
- await logDomFailure(runtime, logger, 'file-upload');
29
- throw new Error('Attachment did not register with the ChatGPT composer in time.');
115
+ const documentNode = await dom.getDocument();
116
+ const resultNode = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector: 'input[type="file"][data-oracle-upload-target="true"]' });
117
+ if (!resultNode?.nodeId) {
118
+ await logDomFailure(runtime, logger, 'file-input-missing');
119
+ throw new Error('Unable to locate ChatGPT file attachment input.');
30
120
  }
31
- await waitForAttachmentVisible(runtime, expectedName, 10_000, logger);
32
- logger('Attachment queued');
121
+ const resolvedNodeId = resultNode.nodeId;
122
+ const dispatchEvents = FILE_INPUT_SELECTORS
123
+ .map((selector) => `
124
+ (() => {
125
+ const el = document.querySelector(${JSON.stringify(selector)});
126
+ if (el instanceof HTMLInputElement) {
127
+ el.dispatchEvent(new Event('input', { bubbles: true }));
128
+ el.dispatchEvent(new Event('change', { bubbles: true }));
129
+ }
130
+ })();
131
+ `)
132
+ .join('\\n');
133
+ const tryFileInput = async () => {
134
+ await dom.setFileInputFiles({ nodeId: resolvedNodeId, files: [attachment.path] });
135
+ await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
136
+ };
137
+ await tryFileInput();
138
+ if (await waitForAttachmentAnchored(runtime, expectedName, 20_000)) {
139
+ await waitForAttachmentVisible(runtime, expectedName, 20_000, logger);
140
+ logger('Attachment queued (file input)');
141
+ return;
142
+ }
143
+ await logDomFailure(runtime, logger, 'file-upload-missing');
144
+ throw new Error('Attachment did not register with the ChatGPT composer in time.');
33
145
  }
34
- export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
146
+ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
35
147
  const deadline = Date.now() + timeoutMs;
148
+ const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
36
149
  const expression = `(() => {
37
150
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
38
151
  let button = null;
@@ -58,24 +171,44 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
58
171
  return text.includes('upload') || text.includes('processing') || text.includes('uploading');
59
172
  });
60
173
  });
61
- const fileSelectors = ${JSON.stringify(FILE_INPUT_SELECTORS)};
62
- const filesAttached = fileSelectors.some((selector) =>
63
- Array.from(document.querySelectorAll(selector)).some((node) => {
64
- const el = node instanceof HTMLInputElement ? node : null;
65
- return Boolean(el?.files?.length);
66
- }),
174
+ const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
175
+ const attachedNames = [];
176
+ for (const selector of attachmentSelectors) {
177
+ for (const node of Array.from(document.querySelectorAll(selector))) {
178
+ const text = node?.textContent?.toLowerCase?.();
179
+ if (text) attachedNames.push(text);
180
+ }
181
+ }
182
+ for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
183
+ if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
184
+ for (const file of Array.from(input.files)) {
185
+ if (file?.name) attachedNames.push(file.name.toLowerCase());
186
+ }
187
+ }
188
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
189
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
67
190
  );
68
- return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached };
191
+ attachedNames.push(...cardTexts.filter(Boolean));
192
+ const filesPills = Array.from(document.querySelectorAll('button,div'))
193
+ .map((node) => (node?.textContent || '').toLowerCase())
194
+ .filter((text) => /\bfiles\b/.test(text));
195
+ attachedNames.push(...filesPills);
196
+ const filesAttached = attachedNames.length > 0;
197
+ return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
69
198
  })()`;
70
199
  while (Date.now() < deadline) {
71
200
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
72
201
  const value = result?.value;
73
202
  if (value && !value.uploading) {
74
- if (value.state === 'ready') {
75
- return;
76
- }
77
- if (value.state === 'missing' && value.filesAttached) {
78
- return;
203
+ const attached = new Set((value.attachedNames ?? []).map((name) => name.toLowerCase()));
204
+ const missing = expectedNormalized.filter((name) => !attached.has(name));
205
+ if (missing.length === 0) {
206
+ if (value.state === 'ready') {
207
+ return;
208
+ }
209
+ if (value.state === 'missing' && value.filesAttached) {
210
+ return;
211
+ }
79
212
  }
80
213
  }
81
214
  await delay(250);
@@ -85,15 +218,83 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
85
218
  throw new Error('Attachments did not finish uploading before timeout.');
86
219
  }
87
220
  export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
88
- const deadline = Date.now() + Math.min(timeoutMs, 2_000);
221
+ // Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
222
+ // so respect the caller-provided timeout instead of capping at 2s.
223
+ const deadline = Date.now() + timeoutMs;
89
224
  const expression = `(() => {
90
225
  const expected = ${JSON.stringify(expectedName)};
226
+ const normalized = expected.toLowerCase();
227
+ const matchNode = (node) => {
228
+ if (!node) return false;
229
+ const text = (node.textContent || '').toLowerCase();
230
+ const aria = node.getAttribute?.('aria-label')?.toLowerCase?.() ?? '';
231
+ const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
232
+ const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
233
+ const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
234
+ return [text, aria, title, testId, alt].some((value) => value.includes(normalized));
235
+ };
236
+
91
237
  const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
92
238
  const userTurns = turns.filter((node) => node.querySelector('[data-message-author-role="user"]'));
93
239
  const lastUser = userTurns[userTurns.length - 1];
94
- if (!lastUser) return { found: false, userTurns: userTurns.length };
95
- const chips = Array.from(lastUser.querySelectorAll('a, div')).some((el) => (el.textContent || '').includes(expected));
96
- return { found: chips, userTurns: userTurns.length };
240
+ if (lastUser) {
241
+ const turnMatch = Array.from(lastUser.querySelectorAll('*')).some(matchNode);
242
+ if (turnMatch) return { found: true, userTurns: userTurns.length, source: 'turn' };
243
+ }
244
+
245
+ const composerSelectors = [
246
+ '[data-testid*="composer"]',
247
+ 'form textarea',
248
+ 'form [data-testid*="attachment"]',
249
+ '[data-testid*="upload"]',
250
+ '[data-testid*="chip"]',
251
+ 'form',
252
+ 'button',
253
+ 'label'
254
+ ];
255
+ const composerMatch = composerSelectors.some((selector) =>
256
+ Array.from(document.querySelectorAll(selector)).some(matchNode),
257
+ );
258
+ if (composerMatch) {
259
+ return { found: true, userTurns: userTurns.length, source: 'composer' };
260
+ }
261
+
262
+ const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
263
+ const attachmentMatch = attachmentSelectors.some((selector) =>
264
+ Array.from(document.querySelectorAll(selector)).some(matchNode),
265
+ );
266
+ if (attachmentMatch) {
267
+ return { found: true, userTurns: userTurns.length, source: 'attachments' };
268
+ }
269
+
270
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
271
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
272
+ );
273
+ if (cardTexts.some((text) => text.includes(normalized))) {
274
+ return { found: true, userTurns: userTurns.length, source: 'attachment-cards' };
275
+ }
276
+
277
+ const filesPills = Array.from(document.querySelectorAll('button,div')).map((node) =>
278
+ (node?.textContent || '').toLowerCase(),
279
+ );
280
+ if (filesPills.some((text) => /\bfiles\b/.test(text))) {
281
+ return { found: true, userTurns: userTurns.length, source: 'files-pill' };
282
+ }
283
+
284
+ const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
285
+ if (attrMatch) {
286
+ return { found: true, userTurns: userTurns.length, source: 'attrs' };
287
+ }
288
+
289
+ const bodyMatch = (document.body?.innerText || '').toLowerCase().includes(normalized);
290
+ if (bodyMatch) {
291
+ return { found: true, userTurns: userTurns.length, source: 'body' };
292
+ }
293
+
294
+ const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
295
+ Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
296
+ );
297
+ return { found: inputHit, userTurns: userTurns.length, source: inputHit ? 'input' : undefined };
97
298
  })()`;
98
299
  while (Date.now() < deadline) {
99
300
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
@@ -107,31 +308,48 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
107
308
  await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-visible');
108
309
  throw new Error('Attachment did not appear in ChatGPT composer.');
109
310
  }
110
- async function waitForAttachmentSelection(Runtime, expectedName, timeoutMs) {
311
+ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
111
312
  const deadline = Date.now() + timeoutMs;
112
313
  const expression = `(() => {
113
- const selectors = ${JSON.stringify(FILE_INPUT_SELECTORS)};
314
+ const normalized = ${JSON.stringify(expectedName.toLowerCase())};
315
+ const selectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
114
316
  for (const selector of selectors) {
115
- const inputs = Array.from(document.querySelectorAll(selector));
116
- for (const input of inputs) {
117
- if (!(input instanceof HTMLInputElement) || !input.files) {
118
- continue;
119
- }
120
- const names = Array.from(input.files ?? []).map((file) => file?.name ?? '');
121
- if (names.some((name) => name === ${JSON.stringify(expectedName)})) {
122
- return { matched: true, names };
317
+ for (const node of Array.from(document.querySelectorAll(selector))) {
318
+ const text = (node?.textContent || '').toLowerCase();
319
+ if (text.includes(normalized)) {
320
+ return { found: true, text };
123
321
  }
124
322
  }
125
323
  }
126
- return { matched: false, names: [] };
324
+ const cards = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
325
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
326
+ );
327
+ if (cards.some((text) => text.includes(normalized))) {
328
+ return { found: true, text: cards.find((t) => t.includes(normalized)) };
329
+ }
330
+
331
+ const filesPills = Array.from(document.querySelectorAll('button,div')).map((node) =>
332
+ (node?.textContent || '').toLowerCase(),
333
+ );
334
+ if (filesPills.some((text) => /\bfiles\b/.test(text))) {
335
+ return { found: true, text: filesPills.find((t) => /\bfiles\b/.test(t)) };
336
+ }
337
+
338
+ // As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
339
+ const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
340
+ Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
341
+ );
342
+ if (inputHit) {
343
+ return { found: true, text: 'input-only' };
344
+ }
345
+ return { found: false };
127
346
  })()`;
128
347
  while (Date.now() < deadline) {
129
348
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
130
- const matched = Boolean(result?.value?.matched);
131
- if (matched) {
349
+ if (result?.value?.found) {
132
350
  return true;
133
351
  }
134
- await delay(150);
352
+ await delay(200);
135
353
  }
136
354
  return false;
137
355
  }
@@ -11,6 +11,7 @@ const ENTER_KEY_EVENT = {
11
11
  const ENTER_KEY_TEXT = '\r';
12
12
  export async function submitPrompt(deps, prompt, logger) {
13
13
  const { runtime, input } = deps;
14
+ await waitForDomReady(runtime, logger);
14
15
  const encodedPrompt = JSON.stringify(prompt);
15
16
  const focusResult = await runtime.evaluate({
16
17
  expression: `(() => {
@@ -89,7 +90,7 @@ export async function submitPrompt(deps, prompt, logger) {
89
90
  })()`,
90
91
  });
91
92
  }
92
- const clicked = await attemptSendButton(runtime);
93
+ const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
93
94
  if (!clicked) {
94
95
  await input.dispatchKeyEvent({
95
96
  type: 'keyDown',
@@ -109,7 +110,27 @@ export async function submitPrompt(deps, prompt, logger) {
109
110
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
110
111
  await clickAnswerNowIfPresent(runtime, logger);
111
112
  }
112
- async function attemptSendButton(Runtime) {
113
+ async function waitForDomReady(Runtime, logger) {
114
+ const deadline = Date.now() + 10_000;
115
+ while (Date.now() < deadline) {
116
+ const { result } = await Runtime.evaluate({
117
+ expression: `(() => {
118
+ const ready = document.readyState === 'complete';
119
+ const composer = document.querySelector('[data-testid*="composer"]') || document.querySelector('form');
120
+ const fileInput = document.querySelector('input[type="file"]');
121
+ return { ready, composer: Boolean(composer), fileInput: Boolean(fileInput) };
122
+ })()`,
123
+ returnByValue: true,
124
+ });
125
+ const value = result?.value;
126
+ if (value?.ready && value.composer) {
127
+ return;
128
+ }
129
+ await delay(150);
130
+ }
131
+ logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
132
+ }
133
+ async function attemptSendButton(Runtime, _logger, attachmentNames) {
113
134
  const script = `(() => {
114
135
  ${buildClickDispatcher()}
115
136
  const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
@@ -133,8 +154,31 @@ async function attemptSendButton(Runtime) {
133
154
  dispatchClickSequence(button);
134
155
  return 'clicked';
135
156
  })()`;
136
- const deadline = Date.now() + 2_000;
157
+ const deadline = Date.now() + 8_000;
137
158
  while (Date.now() < deadline) {
159
+ const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
160
+ if (needAttachment) {
161
+ const ready = await Runtime.evaluate({
162
+ expression: `(() => {
163
+ const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
164
+ const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
165
+ const chipsReady = names.every((name) =>
166
+ Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
167
+ );
168
+ const inputsReady = names.every((name) =>
169
+ Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
170
+ Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
171
+ ),
172
+ );
173
+ return chipsReady || inputsReady;
174
+ })()`,
175
+ returnByValue: true,
176
+ });
177
+ if (!ready?.result?.value) {
178
+ await delay(150);
179
+ continue;
180
+ }
181
+ }
138
182
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
139
183
  if (result.value === 'clicked') {
140
184
  return true;
@@ -32,7 +32,7 @@ export async function launchChrome(config, userDataDir, logger) {
32
32
  logger(`Launched Chrome${pidLabel} on port ${launcher.port}${hostLabel}`);
33
33
  return Object.assign(launcher, { host: connectHost ?? '127.0.0.1' });
34
34
  }
35
- export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger) {
35
+ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger, opts) {
36
36
  const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
37
37
  let handling;
38
38
  const handleSignal = (signal) => {
@@ -40,14 +40,23 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
40
40
  return;
41
41
  }
42
42
  handling = true;
43
- if (keepBrowser) {
44
- logger(`Received ${signal}; leaving Chrome running for potential reattach`);
43
+ const inFlight = opts?.isInFlight?.() ?? false;
44
+ const leaveRunning = keepBrowser || inFlight;
45
+ if (leaveRunning) {
46
+ logger(`Received ${signal}; leaving Chrome running${inFlight ? ' (assistant response pending)' : ''}`);
45
47
  }
46
48
  else {
47
49
  logger(`Received ${signal}; terminating Chrome process`);
48
50
  }
49
51
  void (async () => {
50
- if (!keepBrowser) {
52
+ if (leaveRunning) {
53
+ // Ensure reattach hints are written before we exit.
54
+ await opts?.emitRuntimeHint?.().catch(() => undefined);
55
+ if (inFlight) {
56
+ logger('Session still in flight; reattach with "oracle session <slug>" to continue.');
57
+ }
58
+ }
59
+ else {
51
60
  try {
52
61
  await chrome.kill();
53
62
  }
@@ -29,6 +29,9 @@ export const FILE_INPUT_SELECTORS = [
29
29
  'input[type="file"][multiple]:not([accept])',
30
30
  'input[type="file"][multiple]',
31
31
  'input[type="file"]:not([accept])',
32
+ 'form input[type="file"][accept]',
33
+ 'input[type="file"][accept]',
34
+ 'input[type="file"]',
32
35
  'input[type="file"][data-testid*="file"]',
33
36
  ];
34
37
  // Legacy single selectors kept for compatibility with older call-sites
@@ -48,11 +51,11 @@ export const UPLOAD_STATUS_SELECTORS = [
48
51
  ];
49
52
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
50
53
  export const SEND_BUTTON_SELECTORS = [
51
- '[data-testid="send-button"]',
52
- 'button[data-testid="composer-send-button"]',
53
- 'button[aria-label="Send message"]',
54
- 'button[aria-label*="Send"]',
54
+ 'button[data-testid="send-button"]',
55
+ 'button[data-testid*="composer-send"]',
56
+ 'form button[type="submit"]',
55
57
  'button[type="submit"][data-testid*="send"]',
58
+ 'button[aria-label*="Send"]',
56
59
  ];
57
60
  export const SEND_BUTTON_SELECTOR = SEND_BUTTON_SELECTORS[0];
58
61
  export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"]';
@@ -98,7 +98,10 @@ export async function runBrowserMode(options) {
98
98
  const chromeHost = chrome.host ?? '127.0.0.1';
99
99
  let removeTerminationHooks = null;
100
100
  try {
101
- removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger);
101
+ removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
102
+ isInFlight: () => runStatus !== 'complete',
103
+ emitRuntimeHint,
104
+ });
102
105
  }
103
106
  catch {
104
107
  // ignore failure; cleanup still happens below
@@ -251,6 +254,7 @@ export async function runBrowserMode(options) {
251
254
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
252
255
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
253
256
  }
257
+ const attachmentNames = attachments.map((a) => path.basename(a.path));
254
258
  if (attachments.length > 0) {
255
259
  if (!DOM) {
256
260
  throw new Error('Chrome DOM domain unavailable while uploading attachments.');
@@ -260,10 +264,10 @@ export async function runBrowserMode(options) {
260
264
  await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
261
265
  }
262
266
  const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
263
- await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, logger));
267
+ await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger));
264
268
  logger('All attachments uploaded');
265
269
  }
266
- await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input }, promptText, logger));
270
+ await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger));
267
271
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
268
272
  const answer = await raceWithDisconnect(waitForAssistantResponse(Runtime, config.timeoutMs, logger));
269
273
  answerText = answer.text;
@@ -632,6 +636,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
632
636
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
633
637
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
634
638
  }
639
+ const attachmentNames = attachments.map((a) => path.basename(a.path));
635
640
  if (attachments.length > 0) {
636
641
  if (!DOM) {
637
642
  throw new Error('Chrome DOM domain unavailable while uploading attachments.');
@@ -642,10 +647,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
642
647
  await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
643
648
  }
644
649
  const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
645
- await waitForAttachmentCompletion(Runtime, waitBudget, logger);
650
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
646
651
  logger('All attachments uploaded');
647
652
  }
648
- await submitPrompt({ runtime: Runtime, input: Input }, promptText, logger);
653
+ await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger);
649
654
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
650
655
  const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
651
656
  answerText = answer.text;
@@ -21,6 +21,8 @@ const PAGE_SIZE = 10;
21
21
  export async function launchTui({ version, printIntro = true }) {
22
22
  const userConfig = (await loadUserConfig()).config;
23
23
  const rich = isTty();
24
+ let pagingFailures = 0;
25
+ let exitMessageShown = false;
24
26
  if (printIntro) {
25
27
  if (rich) {
26
28
  console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
@@ -76,15 +78,31 @@ export async function launchTui({ version, printIntro = true }) {
76
78
  prompt
77
79
  .then(({ selection: answer }) => resolve(answer))
78
80
  .catch((error) => {
79
- console.error(chalk.red('Paging failed; returning to recent list.'), error instanceof Error ? error.message : error);
81
+ pagingFailures += 1;
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ if (message.includes('SIGINT') || message.includes('force closed the prompt')) {
84
+ console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
85
+ exitMessageShown = true;
86
+ resolve('__exit__');
87
+ return;
88
+ }
89
+ console.error(chalk.red('Paging failed; returning to recent list.'), message);
90
+ if (message.includes('setRawMode') || message.includes('EIO') || pagingFailures >= 3) {
91
+ console.error(chalk.red('Terminal input unavailable; exiting TUI.'), dim('Try `stty sane` then rerun oracle, or use `oracle recent`.'));
92
+ resolve('__exit__');
93
+ return;
94
+ }
80
95
  resolve('__reset__');
81
96
  });
82
97
  });
83
98
  if (process.env.ORACLE_DEBUG_TUI === '1') {
84
99
  console.error(`[tui] selection=${JSON.stringify(selection)}`);
85
100
  }
101
+ pagingFailures = 0;
86
102
  if (selection === '__exit__') {
87
- console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
103
+ if (!exitMessageShown) {
104
+ console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
105
+ }
88
106
  return;
89
107
  }
90
108
  if (selection === '__ask__') {
@@ -156,14 +174,26 @@ async function showSessionDetail(sessionId) {
156
174
  ...(isRunning ? [{ name: 'Refresh', value: 'refresh' }] : []),
157
175
  { name: 'Back', value: 'back' },
158
176
  ];
159
- const { next } = await inquirer.prompt([
160
- {
161
- name: 'next',
162
- type: 'select',
163
- message: 'Actions',
164
- choices: actions,
165
- },
166
- ]);
177
+ let next;
178
+ try {
179
+ ({ next } = await inquirer.prompt([
180
+ {
181
+ name: 'next',
182
+ type: 'select',
183
+ message: 'Actions',
184
+ choices: actions,
185
+ },
186
+ ]));
187
+ }
188
+ catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ if (message.includes('SIGINT') || message.includes('force closed the prompt')) {
191
+ console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
192
+ return;
193
+ }
194
+ console.error(chalk.red('Paging failed; returning to session list.'), message);
195
+ return;
196
+ }
167
197
  if (next === 'back') {
168
198
  return;
169
199
  }
@@ -101,7 +101,7 @@ export function toTransportError(error, model) {
101
101
  messageText.includes('does not exist') ||
102
102
  messageText.includes('unknown model') ||
103
103
  messageText.includes('model_not_found'))) {
104
- return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Use gpt-5-pro for now until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
104
+ return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Using gpt-5-pro until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
105
105
  }
106
106
  if (apiError.status === 404 || apiError.status === 405) {
107
107
  return new OracleTransportError('unsupported-endpoint', 'HTTP 404/405 from the Responses API; this base URL or gateway likely does not expose /v1/responses. Set OPENAI_BASE_URL to api.openai.com/v1, update your Azure API version/deployment, or use the browser engine.', apiError);
@@ -186,14 +186,17 @@ export async function runOracle(options, deps = {}) {
186
186
  : DEFAULT_TIMEOUT_NON_PRO_MS / 1000
187
187
  : options.timeoutSeconds;
188
188
  const timeoutMs = timeoutSeconds * 1000;
189
+ const apiModelFromConfig = modelConfig.apiModel ?? modelConfig.model;
190
+ const modelDowngraded = apiModelFromConfig === 'gpt-5.1-pro';
191
+ const resolvedApiModelId = modelDowngraded ? 'gpt-5-pro' : apiModelFromConfig;
189
192
  // Track the concrete model id we dispatch to (especially for Gemini preview aliases)
190
193
  const effectiveModelId = options.effectiveModelId ??
191
194
  (options.model.startsWith('gemini')
192
195
  ? resolveGeminiModelId(options.model)
193
- : modelConfig.apiModel ?? modelConfig.model);
196
+ : resolvedApiModelId);
194
197
  const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
195
198
  const requestBody = buildRequestBody({
196
- modelConfig,
199
+ modelConfig: { ...modelConfig, apiModel: resolvedApiModelId },
197
200
  systemPrompt,
198
201
  userPrompt: promptWithFiles,
199
202
  searchEnabled,
@@ -219,6 +222,9 @@ export async function runOracle(options, deps = {}) {
219
222
  if (baseUrl) {
220
223
  log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
221
224
  }
225
+ if (modelDowngraded) {
226
+ log(dim('gpt-5.1-pro is not yet available via API; sending request with gpt-5-pro instead.'));
227
+ }
222
228
  if (options.background && !supportsBackground) {
223
229
  log(dim('Background runs are not supported for this model; streaming in foreground instead.'));
224
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.1 Pro, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",