@steipete/oracle 0.20.0 → 0.20.2

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 (32) hide show
  1. package/dist/bin/oracle-cli.js +66 -25
  2. package/dist/src/browser/actions/modelSelection.js +36 -4
  3. package/dist/src/browser/actions/thinkingTime.js +11 -5
  4. package/dist/src/browser/browserConnection.js +59 -0
  5. package/dist/src/browser/chatgptImages.js +1 -1
  6. package/dist/src/browser/chromeLifecycle.js +105 -36
  7. package/dist/src/browser/index.js +41 -8
  8. package/dist/src/browser/liveTabs.js +94 -29
  9. package/dist/src/browser/profileState.js +6 -1
  10. package/dist/src/browser/promptFingerprint.js +54 -0
  11. package/dist/src/browser/reattach.js +157 -108
  12. package/dist/src/browser/recoveryTarget.js +31 -6
  13. package/dist/src/browser/sessionRunner.js +30 -7
  14. package/dist/src/browser/targetClaim.js +2 -2
  15. package/dist/src/cli/browserDefaults.js +3 -0
  16. package/dist/src/cli/browserTabs.js +105 -20
  17. package/dist/src/cli/detach.js +10 -1
  18. package/dist/src/cli/detachedSession.js +36 -0
  19. package/dist/src/cli/errorUtils.js +9 -0
  20. package/dist/src/cli/sessionDisplay.js +11 -3
  21. package/dist/src/cli/sessionRunner.js +150 -37
  22. package/dist/src/mcp/tools/consult.js +1 -7
  23. package/dist/src/remote/client.js +71 -14
  24. package/dist/src/remote/health.js +1 -0
  25. package/dist/src/remote/server.js +52 -10
  26. package/dist/src/remote/types.js +13 -0
  27. package/dist/src/sessionManager.js +8 -1
  28. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  29. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  30. package/package.json +9 -9
  31. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  32. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -2,6 +2,9 @@ import fs from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import chalk from "chalk";
4
4
  import { sessionStore } from "../sessionStore.js";
5
+ import { resolveBrowserConfig } from "../browser/config.js";
6
+ import { formatWebSocketHost, readDevToolsActivePortInfo } from "../browser/detect.js";
7
+ import { browserPromptFingerprint } from "../browser/promptFingerprint.js";
5
8
  import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
6
9
  import { isRecoveredConversationHarvestReady, recoverConversationTab, } from "../browser/recoverConversation.js";
7
10
  import { resolveOutputPath } from "./writeOutputPath.js";
@@ -9,6 +12,7 @@ import { persistBrowserHarvest } from "./harvestIntegrity.js";
9
12
  import { completeOwnedBrowserHarvest } from "./recoveredBrowserHarvest.js";
10
13
  const LIVE_POLL_MS = 2000;
11
14
  const DEFAULT_STALL_THRESHOLD_MS = 60_000;
15
+ const HARVEST_FRESHNESS_POLL_MS = 250;
12
16
  function isRecoverableMissingTabError(message) {
13
17
  return (message.includes("No ChatGPT tab matched") ||
14
18
  message.includes("No live ChatGPT tabs found") ||
@@ -31,7 +35,39 @@ function finishRecoveredChrome(recoveredChrome, closeAfterRecover) {
31
35
  // best-effort cleanup
32
36
  }
33
37
  }
34
- function sessionBrowserEndpoint(meta) {
38
+ function harvestMatchesSessionPrompt(harvested, fingerprint) {
39
+ const answer = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText;
40
+ if (harvested.assistantFollowsLatestUser !== true || !answer?.trim())
41
+ return false;
42
+ return (fingerprint === undefined ||
43
+ (typeof harvested.lastUserMessageId === "string" &&
44
+ harvested.lastUserMessageId.trim().length > 0 &&
45
+ // ChatGPT can append transient status text outside the user's content after submission.
46
+ // Keep legacy full-container hashes valid and require an exact match for either form.
47
+ [harvested.lastUserTextRaw ?? harvested.lastUserText, harvested.lastUserContentText].some((text) => typeof text === "string" &&
48
+ browserPromptFingerprint(text, harvested.lastUserMessageId) === fingerprint)));
49
+ }
50
+ async function harvestSessionPrompt(meta, options, requireSessionPrompt = true) {
51
+ const fingerprint = requireSessionPrompt ? meta.browser?.runtime?.submittedPromptHash : undefined;
52
+ if (fingerprint === null) {
53
+ throw new Error("This browser session has no confirmed submitted user turn; retry after submission or use --browser-tab to inspect a specific tab.");
54
+ }
55
+ if (requireSessionPrompt && fingerprint === undefined) {
56
+ console.warn("Legacy browser session: submitted-turn identity is unavailable; verifying only latest user/assistant pairing.");
57
+ }
58
+ const freshnessTimeoutMs = resolveBrowserConfig(meta.browser?.config).inputTimeoutMs;
59
+ const deadline = Date.now() + freshnessTimeoutMs;
60
+ let harvested = await harvestChatGptTab(options);
61
+ while (!harvestMatchesSessionPrompt(harvested, fingerprint) && Date.now() < deadline) {
62
+ await new Promise((resolve) => setTimeout(resolve, HARVEST_FRESHNESS_POLL_MS));
63
+ harvested = await harvestChatGptTab(options);
64
+ }
65
+ if (!harvestMatchesSessionPrompt(harvested, fingerprint)) {
66
+ throw new Error(`Latest ChatGPT turn did not contain an assistant answer paired with this session prompt after ${Math.ceil(freshnessTimeoutMs / 1000)}s; refusing to harvest stale output.`);
67
+ }
68
+ return harvested;
69
+ }
70
+ async function sessionBrowserEndpoint(meta) {
35
71
  const runtime = meta?.browser?.runtime ?? {};
36
72
  const remote = meta?.browser?.config?.remoteChrome ?? {};
37
73
  const host = runtime.chromeHost ?? remote.host;
@@ -39,20 +75,65 @@ function sessionBrowserEndpoint(meta) {
39
75
  if (!host || !port) {
40
76
  return null;
41
77
  }
42
- return { host, port };
78
+ let browserWSEndpoint = runtime.chromeBrowserWSEndpoint;
79
+ let livePort = port;
80
+ if (browserWSEndpoint) {
81
+ const active = runtime.chromeProfileRoot
82
+ ? await readDevToolsActivePortInfo(runtime.chromeProfileRoot, { host }).catch(() => null)
83
+ : null;
84
+ if (active) {
85
+ browserWSEndpoint = active.browserWSEndpoint;
86
+ livePort = active.port;
87
+ }
88
+ else {
89
+ // A restarted Chrome can keep its port while changing its browser socket ID.
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), 1000);
92
+ try {
93
+ const response = await fetch(`http://${formatWebSocketHost(host)}:${port}/json/version`, {
94
+ signal: controller.signal,
95
+ });
96
+ if (response.ok) {
97
+ const version = (await response.json());
98
+ const advertised = new URL(version.webSocketDebuggerUrl ?? "");
99
+ if (advertised.pathname.startsWith("/devtools/browser/")) {
100
+ const refreshed = new URL(browserWSEndpoint);
101
+ refreshed.pathname = advertised.pathname;
102
+ browserWSEndpoint = refreshed.toString();
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ // Attach-running Chrome may disable HTTP discovery; keep its saved socket.
108
+ }
109
+ finally {
110
+ clearTimeout(timeout);
111
+ controller.abort();
112
+ }
113
+ }
114
+ }
115
+ return {
116
+ host,
117
+ port: livePort,
118
+ ...(browserWSEndpoint
119
+ ? {
120
+ browserWSEndpoint,
121
+ approvalWaitMs: resolveBrowserConfig(meta?.browser?.config).approvalWaitMs,
122
+ }
123
+ : {}),
124
+ };
43
125
  }
44
- function collectUniqueEndpoints(metas) {
126
+ async function collectUniqueEndpoints(metas) {
45
127
  const entries = new Map();
46
- entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}`, {
128
+ entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}:http`, {
47
129
  host: DEFAULT_REMOTE_CHROME_HOST,
48
130
  port: DEFAULT_REMOTE_CHROME_PORT,
49
131
  });
50
- for (const meta of metas) {
51
- const endpoint = sessionBrowserEndpoint(meta);
132
+ for (const endpoint of await Promise.all(metas.map(sessionBrowserEndpoint))) {
52
133
  if (!endpoint) {
53
134
  continue;
54
135
  }
55
- entries.set(`${endpoint.host}:${endpoint.port}`, endpoint);
136
+ entries.set(`${endpoint.host}:${endpoint.port}:${endpoint.browserWSEndpoint ?? "http"}`, endpoint);
56
137
  }
57
138
  return Array.from(entries.values());
58
139
  }
@@ -119,7 +200,7 @@ async function maybeWriteHarvestOutput(pathInput, cwd, content) {
119
200
  }
120
201
  export async function showBrowserTabsStatus() {
121
202
  const metas = await sessionStore.listSessions().catch(() => []);
122
- const endpoints = collectUniqueEndpoints(metas);
203
+ const endpoints = await collectUniqueEndpoints(metas);
123
204
  let printedAny = false;
124
205
  for (const endpoint of endpoints) {
125
206
  let tabs;
@@ -156,7 +237,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
156
237
  if (!meta) {
157
238
  throw new Error(`No session found with ID ${sessionId}.`);
158
239
  }
159
- const recordedEndpoint = sessionBrowserEndpoint(meta);
240
+ const recordedEndpoint = await sessionBrowserEndpoint(meta);
160
241
  const initialEndpoint = recordedEndpoint ?? {
161
242
  host: DEFAULT_REMOTE_CHROME_HOST,
162
243
  port: DEFAULT_REMOTE_CHROME_PORT,
@@ -167,12 +248,11 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
167
248
  try {
168
249
  let harvested;
169
250
  try {
170
- harvested = await harvestChatGptTab({
171
- host: initialEndpoint.host,
172
- port: initialEndpoint.port,
251
+ harvested = await harvestSessionPrompt(meta, {
252
+ ...initialEndpoint,
173
253
  ref,
174
254
  stallWindowMs: options.stallWindowMs,
175
- });
255
+ }, !options.browserTabRef);
176
256
  }
177
257
  catch (error) {
178
258
  const message = error instanceof Error ? error.message : String(error);
@@ -184,9 +264,11 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
184
264
  existingEndpoint: recordedEndpoint ?? undefined,
185
265
  });
186
266
  recoveredChrome = recovered.chrome;
187
- harvested = await harvestChatGptTab({
267
+ harvested = await harvestSessionPrompt(meta, {
188
268
  host: recovered.host,
189
269
  port: recovered.port,
270
+ browserWSEndpoint: recovered.browserWSEndpoint,
271
+ approvalWaitMs: recovered.approvalWaitMs,
190
272
  ref: recovered.ref,
191
273
  stallWindowMs: options.stallWindowMs,
192
274
  });
@@ -212,7 +294,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
212
294
  if (!meta) {
213
295
  throw new Error(`No session found with ID ${sessionId}.`);
214
296
  }
215
- const recordedEndpoint = sessionBrowserEndpoint(meta);
297
+ const recordedEndpoint = await sessionBrowserEndpoint(meta);
216
298
  let endpoint = recordedEndpoint ?? {
217
299
  host: DEFAULT_REMOTE_CHROME_HOST,
218
300
  port: DEFAULT_REMOTE_CHROME_PORT,
@@ -229,8 +311,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
229
311
  // Probe once to see if the live tab is still alive; recover if not.
230
312
  try {
231
313
  await harvestChatGptTab({
232
- host: endpoint.host,
233
- port: endpoint.port,
314
+ ...endpoint,
234
315
  ref: browserTabRef,
235
316
  });
236
317
  }
@@ -245,15 +326,19 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
245
326
  waitForReady: false,
246
327
  });
247
328
  recoveredChrome = recovered.chrome;
248
- endpoint = { host: recovered.host, port: recovered.port };
329
+ endpoint = {
330
+ host: recovered.host,
331
+ port: recovered.port,
332
+ browserWSEndpoint: recovered.browserWSEndpoint,
333
+ approvalWaitMs: recovered.approvalWaitMs,
334
+ };
249
335
  browserTabRef = recovered.ref;
250
336
  requireRecoveredContent = true;
251
337
  recoveredContentDeadlineMs = Date.now() + stallThresholdMs;
252
338
  }
253
339
  while (true) {
254
340
  const harvested = await harvestChatGptTab({
255
- host: endpoint.host,
256
- port: endpoint.port,
341
+ ...endpoint,
257
342
  ref: browserTabRef,
258
343
  });
259
344
  const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
@@ -1,4 +1,5 @@
1
1
  import { isProModel } from "../oracle/modelResolver.js";
2
+ import { isGpt6ProAlias } from "./browserConfig.js";
2
3
  export function shouldDetachSession({
3
4
  // Params kept for policy tweaks.
4
5
  engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
@@ -7,7 +8,7 @@ engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
7
8
  // Keep long local browser Pro work in a separate process even while the CLI
8
9
  // stays attached to its session log. If the foreground stream is interrupted,
9
10
  // the worker can still finish the browser run and persist the answer.
10
- if (engine === "browser" && isProModel(model))
11
+ if (engine === "browser" && (isProModel(model) || isGpt6ProAlias(model)))
11
12
  return true;
12
13
  // For API runs, explicit --wait keeps execution in the foreground.
13
14
  if (waitPreference)
@@ -17,6 +18,14 @@ engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
17
18
  return true;
18
19
  return false;
19
20
  }
21
+ export function shouldExitAfterTopLevelSigint(remainingListenerCount) {
22
+ return remainingListenerCount === 0;
23
+ }
24
+ export function detachedCancellationExitCode(cancelled, finalStatus, currentExitCode) {
25
+ if (!cancelled)
26
+ return currentExitCode;
27
+ return finalStatus === "completed" || finalStatus === "partial" ? 0 : 130;
28
+ }
20
29
  export function stopDetachedWorker(workerPid, kill = process.kill) {
21
30
  try {
22
31
  kill(workerPid, "SIGTERM");
@@ -1,5 +1,41 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { access, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
2
5
  import { fileURLToPath } from "node:url";
6
+ const CANCELLATION_MARKER = ".cancel-requested";
7
+ export function detachedSessionCancellationPath(sessionDir, workerPid) {
8
+ return path.join(sessionDir, `${CANCELLATION_MARKER}-${workerPid}`);
9
+ }
10
+ export async function requestDetachedSessionCancellation(markerPath) {
11
+ await writeFile(markerPath, "cancel\n", "utf8");
12
+ }
13
+ export async function clearDetachedSessionCancellation(markerPath) {
14
+ await rm(markerPath, { force: true });
15
+ }
16
+ export async function waitForDetachedSessionCancellation({ markerPath, signal, pollIntervalMs = 100, }) {
17
+ while (!signal.aborted) {
18
+ try {
19
+ await access(markerPath);
20
+ return true;
21
+ }
22
+ catch (error) {
23
+ if (!(error instanceof Error) || error.code !== "ENOENT") {
24
+ throw error;
25
+ }
26
+ }
27
+ try {
28
+ await delay(pollIntervalMs, undefined, { signal });
29
+ }
30
+ catch (error) {
31
+ if (signal.aborted && error instanceof Error && error.name === "AbortError") {
32
+ return false;
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ return false;
38
+ }
3
39
  export function resolveOracleCliEntrypoint(moduleUrl = import.meta.url) {
4
40
  const extension = fileURLToPath(moduleUrl).endsWith(".ts") ? "ts" : "js";
5
41
  return fileURLToPath(new URL(`../../bin/oracle-cli.${extension}`, moduleUrl));
@@ -1,4 +1,13 @@
1
1
  const LOGGED_SYMBOL = Symbol("oracle.alreadyLogged");
2
+ export function formatCliError(error) {
3
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
4
+ if (message.trim())
5
+ return message;
6
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
7
+ if (typeof code === "string" && code.trim())
8
+ return `Operation failed (${code}).`;
9
+ return "An unexpected error occurred. Retry with --verbose for more details.";
10
+ }
2
11
  export function markErrorLogged(error) {
3
12
  if (error instanceof Error) {
4
13
  error[LOGGED_SYMBOL] = true;
@@ -359,7 +359,10 @@ export async function attachSession(sessionId, options) {
359
359
  console.log(dim(`User error: ${userErrorSummary}`));
360
360
  }
361
361
  }
362
- const shouldTrimIntro = initialStatus === "completed" || initialStatus === "partial" || initialStatus === "error";
362
+ const shouldTrimIntro = initialStatus === "completed" ||
363
+ initialStatus === "partial" ||
364
+ initialStatus === "error" ||
365
+ initialStatus === "cancelled";
363
366
  if (options?.renderPrompt !== false) {
364
367
  const prompt = await readStoredPrompt(sessionId);
365
368
  if (prompt) {
@@ -485,7 +488,10 @@ export async function attachSession(sessionId, options) {
485
488
  if (!latest) {
486
489
  break;
487
490
  }
488
- if (latest.status === "completed" || latest.status === "partial" || latest.status === "error") {
491
+ if (latest.status === "completed" ||
492
+ latest.status === "partial" ||
493
+ latest.status === "error" ||
494
+ latest.status === "cancelled") {
489
495
  await printNew();
490
496
  flushRemainder();
491
497
  if (!options?.suppressMetadata) {
@@ -516,7 +522,9 @@ export async function attachSession(sessionId, options) {
516
522
  if (!settled) {
517
523
  break;
518
524
  }
519
- if (settled.status === "completed" || settled.status === "partial") {
525
+ if (settled.status === "completed" ||
526
+ settled.status === "partial" ||
527
+ settled.status === "cancelled") {
520
528
  continue;
521
529
  }
522
530
  await printNew();