@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.
- package/dist/bin/oracle-cli.js +66 -25
- package/dist/src/browser/actions/modelSelection.js +36 -4
- package/dist/src/browser/actions/thinkingTime.js +11 -5
- package/dist/src/browser/browserConnection.js +59 -0
- package/dist/src/browser/chatgptImages.js +1 -1
- package/dist/src/browser/chromeLifecycle.js +105 -36
- package/dist/src/browser/index.js +41 -8
- package/dist/src/browser/liveTabs.js +94 -29
- package/dist/src/browser/profileState.js +6 -1
- package/dist/src/browser/promptFingerprint.js +54 -0
- package/dist/src/browser/reattach.js +157 -108
- package/dist/src/browser/recoveryTarget.js +31 -6
- package/dist/src/browser/sessionRunner.js +30 -7
- package/dist/src/browser/targetClaim.js +2 -2
- package/dist/src/cli/browserDefaults.js +3 -0
- package/dist/src/cli/browserTabs.js +105 -20
- package/dist/src/cli/detach.js +10 -1
- package/dist/src/cli/detachedSession.js +36 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/sessionDisplay.js +11 -3
- package/dist/src/cli/sessionRunner.js +150 -37
- package/dist/src/mcp/tools/consult.js +1 -7
- package/dist/src/remote/client.js +71 -14
- package/dist/src/remote/health.js +1 -0
- package/dist/src/remote/server.js +52 -10
- package/dist/src/remote/types.js +13 -0
- package/dist/src/sessionManager.js +8 -1
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +9 -9
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
256
|
-
port: endpoint.port,
|
|
341
|
+
...endpoint,
|
|
257
342
|
ref: browserTabRef,
|
|
258
343
|
});
|
|
259
344
|
const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
|
package/dist/src/cli/detach.js
CHANGED
|
@@ -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" ||
|
|
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" ||
|
|
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" ||
|
|
525
|
+
if (settled.status === "completed" ||
|
|
526
|
+
settled.status === "partial" ||
|
|
527
|
+
settled.status === "cancelled") {
|
|
520
528
|
continue;
|
|
521
529
|
}
|
|
522
530
|
await printNew();
|