pi-oracle 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
+ import { getAgentDir, hasProjectTrustInputs, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
10
10
  import { isAbsolute, join, normalize } from "node:path";
11
11
  import {
12
12
  assertNotKnownBrowserUserDataPath,
@@ -313,27 +313,76 @@ const detectedChromeUserAgent = detectDefaultChromeUserAgent(detectedChromeExecu
313
313
  const agentExtensionsDir = join(getAgentDir(), "extensions");
314
314
  const detectedChromeProfileName = detectDefaultBrowserProfileSource(process.platform);
315
315
 
316
+ export interface OracleConfigLoadOptions {
317
+ /**
318
+ * Whether project-local oracle config may be loaded. Omit for the runtime
319
+ * policy that preserves oracle's historical project-config behavior while
320
+ * respecting explicit --no-approve and saved distrust decisions.
321
+ */
322
+ projectConfigTrusted?: boolean;
323
+ /** Session cwd used for Pi's saved project-trust decision when config lookup is anchored to a derived project root. */
324
+ projectConfigTrustCwd?: string;
325
+ }
326
+
316
327
  export interface OracleConfigLoadDetails {
317
328
  agentDir: string;
318
329
  agentConfigPath: string;
319
330
  agentConfigExists: boolean;
320
331
  projectConfigPath: string;
321
332
  projectConfigExists: boolean;
333
+ projectConfigTrusted: boolean;
334
+ projectConfigLoaded: boolean;
335
+ projectConfigSkippedReason?: string;
322
336
  effectiveAuthConfigPath: string;
323
337
  effectiveAuthScope: "agent";
324
338
  }
325
339
 
326
- export function getOracleConfigLoadDetails(cwd: string): OracleConfigLoadDetails {
340
+ function getProjectTrustCliOverride(argv = process.argv): boolean | undefined {
341
+ let trusted: boolean | undefined;
342
+ for (const arg of argv.slice(2)) {
343
+ if (arg === "--approve" || arg === "-a") trusted = true;
344
+ if (arg === "--no-approve" || arg === "-na") trusted = false;
345
+ }
346
+ return trusted;
347
+ }
348
+
349
+ function isProjectConfigTrusted(cwd: string, agentDir: string, projectConfigExists: boolean, options?: OracleConfigLoadOptions): boolean {
350
+ if (options?.projectConfigTrusted !== undefined) return options.projectConfigTrusted;
351
+ const trustCwd = options?.projectConfigTrustCwd ?? cwd;
352
+ const cliOverride = getProjectTrustCliOverride();
353
+ if (cliOverride !== undefined) return cliOverride;
354
+ if (!projectConfigExists && !hasProjectTrustInputs(trustCwd)) return true;
355
+ try {
356
+ const trustStore = new ProjectTrustStore(agentDir);
357
+ const trustDecision = trustStore.get(trustCwd);
358
+ const rootDecision = trustCwd !== cwd ? trustStore.get(cwd) : null;
359
+ if (trustDecision !== null) return trustDecision;
360
+ if (rootDecision !== null) return rootDecision;
361
+ } catch {
362
+ return false;
363
+ }
364
+ return true;
365
+ }
366
+
367
+ export function getOracleConfigLoadDetails(cwd: string, options?: OracleConfigLoadOptions): OracleConfigLoadDetails {
327
368
  const agentDir = getAgentDir();
328
369
  const projectRoot = getProjectId(cwd);
329
370
  const agentConfigPath = join(agentDir, "extensions", "oracle.json");
330
371
  const projectConfigPath = join(projectRoot, ".pi", "extensions", "oracle.json");
372
+ const projectConfigExists = existsSync(projectConfigPath);
373
+ const projectConfigTrusted = isProjectConfigTrusted(projectRoot, agentDir, projectConfigExists, options);
374
+ const projectConfigLoaded = projectConfigExists && projectConfigTrusted;
331
375
  return {
332
376
  agentDir,
333
377
  agentConfigPath,
334
378
  agentConfigExists: existsSync(agentConfigPath),
335
379
  projectConfigPath,
336
- projectConfigExists: existsSync(projectConfigPath),
380
+ projectConfigExists,
381
+ projectConfigTrusted,
382
+ projectConfigLoaded,
383
+ projectConfigSkippedReason: projectConfigExists && !projectConfigTrusted
384
+ ? "Project oracle config is ignored because this run used --no-approve or the project has a saved untrusted decision."
385
+ : undefined,
337
386
  effectiveAuthConfigPath: agentConfigPath,
338
387
  effectiveAuthScope: "agent",
339
388
  };
@@ -341,8 +390,11 @@ export function getOracleConfigLoadDetails(cwd: string): OracleConfigLoadDetails
341
390
 
342
391
  export function formatOracleAuthConfigRemediation(details: OracleConfigLoadDetails): string {
343
392
  const authFields = "auth.chromeProfile / auth.chromeCookiePath / auth.chromiumKeychain";
344
- if (!details.projectConfigExists) {
345
- return `Set ${authFields} in ${details.effectiveAuthConfigPath}.`;
393
+ if (!details.projectConfigLoaded) {
394
+ const projectNote = details.projectConfigSkippedReason
395
+ ? ` Project config at ${details.projectConfigPath} is present but not loaded because this run explicitly does not trust project-local inputs.`
396
+ : "";
397
+ return `Set ${authFields} in ${details.effectiveAuthConfigPath}.${projectNote}`;
346
398
  }
347
399
  return (
348
400
  `Set ${authFields} in ${details.effectiveAuthConfigPath}. ` +
@@ -354,11 +406,13 @@ export function formatOracleAuthConfigSummary(details: OracleConfigLoadDetails):
354
406
  const lines = [
355
407
  `Effective oracle auth config: ${details.effectiveAuthConfigPath} (agent dir: ${details.agentDir}${details.agentConfigExists ? "" : "; create this file to override auth.*"})`,
356
408
  ];
357
- if (details.projectConfigExists) {
409
+ if (details.projectConfigLoaded) {
358
410
  lines.push(
359
411
  `Project oracle config also loaded: ${details.projectConfigPath} ` +
360
412
  `(project scope can override ${[...PROJECT_OVERRIDE_KEYS].join("/")} only; auth.* still comes from ${details.effectiveAuthConfigPath}).`,
361
413
  );
414
+ } else if (details.projectConfigSkippedReason) {
415
+ lines.push(`Project oracle config present but not loaded: ${details.projectConfigPath}. ${details.projectConfigSkippedReason}`);
362
416
  }
363
417
  return lines.join("\n");
364
418
  }
@@ -665,9 +719,9 @@ function validateOracleConfig(value: unknown): OracleConfig {
665
719
  };
666
720
  }
667
721
 
668
- export function loadOracleConfig(cwd: string): OracleConfig {
669
- const details = getOracleConfigLoadDetails(cwd);
722
+ export function loadOracleConfig(cwd: string, options?: OracleConfigLoadOptions): OracleConfig {
723
+ const details = getOracleConfigLoadDetails(cwd, options);
670
724
  const globalConfig = readJson(details.agentConfigPath);
671
- const projectConfig = filterProjectConfig(readJson(details.projectConfigPath));
725
+ const projectConfig = details.projectConfigLoaded ? filterProjectConfig(readJson(details.projectConfigPath)) : undefined;
672
726
  return validateOracleConfig(deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig));
673
727
  }
@@ -5,7 +5,7 @@
5
5
  // Invariants/Assumptions: Poller scans are serialized per session key, wake-up delivery is best-effort, and terminal-job notifications always re-read durable job state before send.
6
6
  import { existsSync } from "node:fs";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { buildOracleStatusText, buildOracleWakeupNotificationContent } from "../shared/job-observability-helpers.mjs";
8
+ import { buildOracleStatusText, buildOracleWakeupNotificationContent, type OracleReadinessStatus } from "../shared/job-observability-helpers.mjs";
9
9
  import { isProcessAlive, readProcessStartedAt } from "../shared/process-helpers.mjs";
10
10
  import { isLockTimeoutError, listLeaseMetadata, releaseLease, withGlobalReconcileLock, writeLeaseMetadata } from "./locks.js";
11
11
  import {
@@ -46,6 +46,7 @@ interface OraclePollerLifecycle {
46
46
  }
47
47
 
48
48
  const activePollers = new Map<string, OracleActivePoller>();
49
+ const readinessBySession = new Map<string, OracleReadinessStatus>();
49
50
  const scansInFlight = new Set<string>();
50
51
  const POLLER_LOCK_TIMEOUT_MS = 50;
51
52
  const WAKEUP_TARGET_LEASE_KIND = "wakeup-target";
@@ -167,19 +168,31 @@ function getJobCountsForSession(sessionFile: string | undefined, cwd: string): {
167
168
  function refreshOracleStatusSnapshot(snapshot: OraclePollerContextSnapshot): void {
168
169
  if (!snapshot.hasUI) return;
169
170
  if (!snapshot.sessionFile) {
170
- snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg("accent", "oracle: unavailable"));
171
+ snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg("error", "oracle: unavailable"));
171
172
  return;
172
173
  }
173
174
  const counts = getJobCountsForSession(snapshot.sessionFile, snapshot.cwd);
174
- const statusText = buildOracleStatusText(counts);
175
- const tone = counts.active > 0 ? "success" : "accent";
176
- snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg(tone, statusText));
175
+ const readiness = readinessBySession.get(getPollerSessionKey(snapshot.sessionFile, snapshot.cwd)) ?? "loaded";
176
+ const statusText = buildOracleStatusText(counts, readiness);
177
+ if (counts.active > 0) {
178
+ snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg("success", statusText));
179
+ } else if (readiness === "auth_needed" || readiness === "config_error") {
180
+ snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg("error", statusText));
181
+ } else {
182
+ snapshot.ui.setStatus("oracle", statusText);
183
+ }
177
184
  }
178
185
 
179
186
  export function refreshOracleStatus(ctx: ExtensionContext): void {
180
187
  refreshOracleStatusSnapshot(snapshotPollerContext(ctx));
181
188
  }
182
189
 
190
+ export function setOracleReadiness(ctx: ExtensionContext, readiness: OracleReadinessStatus): void {
191
+ const snapshot = snapshotPollerContext(ctx);
192
+ if (snapshot.sessionFile) readinessBySession.set(getPollerSessionKey(snapshot.sessionFile, snapshot.cwd), readiness);
193
+ refreshOracleStatusSnapshot(snapshot);
194
+ }
195
+
183
196
  function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
184
197
  pi.sendMessage(
185
198
  {
@@ -412,6 +425,7 @@ export function stopPollerForSession(sessionFile: string | undefined, cwd: strin
412
425
  if (handle.timer) clearInterval(handle.timer);
413
426
  activePollers.delete(sessionKey);
414
427
  }
428
+ readinessBySession.delete(sessionKey);
415
429
  const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
416
430
  void releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
417
431
  }
@@ -423,6 +437,7 @@ export async function stopAllPollers(): Promise<void> {
423
437
  if (handle.timer) clearInterval(handle.timer);
424
438
  }
425
439
  activePollers.clear();
440
+ readinessBySession.clear();
426
441
  await Promise.all(handles.map(async (handle) => {
427
442
  const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(handle.sessionKey);
428
443
  await releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
@@ -169,6 +169,10 @@ function unreadableAuthSeedProfileMessage(seedDir: string): string {
169
169
  return `Oracle auth seed profile is not readable: ${seedDir}. Fix its permissions or rerun /oracle-auth.`;
170
170
  }
171
171
 
172
+ function unauthenticatedAuthSeedProfileMessage(seedDir: string): string {
173
+ return `Oracle auth seed profile exists but is not authenticated: ${seedDir}. Run /oracle-auth to create a verified auth seed before submitting oracle jobs.`;
174
+ }
175
+
172
176
  function missingBrowserExecutableMessage(executablePath: string): string {
173
177
  return `Configured oracle browser executable does not exist: ${executablePath}. Fix browser.executablePath or install Chrome there.`;
174
178
  }
@@ -315,6 +319,10 @@ export async function assertOracleAuthSeedProfileReady(config: OracleConfig): Pr
315
319
  } catch {
316
320
  throw new Error(unreadableAuthSeedProfileMessage(seedDir));
317
321
  }
322
+
323
+ if (!getSeedGeneration(config)) {
324
+ throw new Error(unauthenticatedAuthSeedProfileMessage(seedDir));
325
+ }
318
326
  }
319
327
 
320
328
  export async function assertOracleSubmitPrerequisites(config: OracleConfig): Promise<void> {
@@ -914,6 +914,15 @@ function buildOracleToolErrorDetails(toolName: OracleToolErrorSource, error: unk
914
914
  };
915
915
  }
916
916
 
917
+ if (message.startsWith("Oracle auth seed profile exists but is not authenticated: ")) {
918
+ return {
919
+ code: "auth_seed_profile_unauthenticated",
920
+ message,
921
+ rejectedValue: message.replace(/^Oracle auth seed profile exists but is not authenticated: /, "").replace(/\. Run \/oracle-auth.*$/, ""),
922
+ suggestedNextStep: "Call oracle_auth or run /oracle-auth once to create a verified auth seed, then retry the oracle tool call.",
923
+ };
924
+ }
925
+
917
926
  if (message.startsWith("Failed to parse oracle config ") || message.startsWith("Invalid oracle config:") || message.startsWith("Invalid oracle project config:")) {
918
927
  return {
919
928
  code: "oracle_config_invalid",
@@ -1151,6 +1160,10 @@ function formatOraclePreflightResponse(details: OraclePreflightDetails): string
1151
1160
  ].filter(Boolean).join("\n");
1152
1161
  }
1153
1162
 
1163
+ function isProjectTrusted(ctx: ExtensionContext): boolean {
1164
+ return (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? true;
1165
+ }
1166
+
1154
1167
  async function runOraclePreflight(ctx: ExtensionContext, params: { provider?: unknown; followUpJobId?: unknown } = {}): Promise<OraclePreflightDetails> {
1155
1168
  const sessionFile = getSessionFile(ctx);
1156
1169
  if (!hasPersistedSessionFile(sessionFile)) {
@@ -1174,7 +1187,7 @@ async function runOraclePreflight(ctx: ExtensionContext, params: { provider?: un
1174
1187
  if (followUpJobId !== undefined && typeof followUpJobId !== "string") {
1175
1188
  throw new Error("oracle_preflight followUpJobId must be a string");
1176
1189
  }
1177
- const baseConfig = loadOracleConfig(ctx.cwd);
1190
+ const baseConfig = loadOracleConfig(ctx.cwd, { projectConfigTrusted: isProjectTrusted(ctx) });
1178
1191
  const followUp = resolveFollowUp(followUpJobId, ctx.cwd);
1179
1192
  provider = normalizeOracleProvider(params.provider, followUp.provider ?? baseConfig.defaults.provider, "oracle_preflight");
1180
1193
  if (followUp.provider && provider !== followUp.provider) {
@@ -1202,7 +1215,7 @@ async function runOraclePreflight(ctx: ExtensionContext, params: { provider?: un
1202
1215
  session: { persisted: true, sessionFile },
1203
1216
  config: { ready: true },
1204
1217
  auth: {
1205
- ready: !["auth_seed_profile_missing", "auth_seed_profile_unreadable", "auth_seed_profile_invalid_type"].includes(errorDetails.code),
1218
+ ready: !["auth_seed_profile_missing", "auth_seed_profile_unreadable", "auth_seed_profile_invalid_type", "auth_seed_profile_unauthenticated"].includes(errorDetails.code),
1206
1219
  seedProfileDir: config.browser.authSeedProfileDir,
1207
1220
  },
1208
1221
  error: errorDetails,
@@ -1264,9 +1277,9 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
1264
1277
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1265
1278
  try {
1266
1279
  const projectCwd = getProjectId(ctx.cwd);
1267
- const baseConfig = loadOracleConfig(projectCwd);
1280
+ const baseConfig = loadOracleConfig(projectCwd, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isProjectTrusted(ctx) });
1268
1281
  const provider = normalizeOracleProvider(params.provider, baseConfig.defaults.provider, "oracle_auth");
1269
- const message = await runOracleAuthBootstrap(authWorkerPath, projectCwd, provider);
1282
+ const message = await runOracleAuthBootstrap(authWorkerPath, projectCwd, provider, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isProjectTrusted(ctx) });
1270
1283
  return {
1271
1284
  content: [{ type: "text" as const, text: message }],
1272
1285
  details: {
@@ -1311,7 +1324,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
1311
1324
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1312
1325
  try {
1313
1326
  const projectCwd = getProjectId(ctx.cwd);
1314
- const baseConfig = loadOracleConfig(projectCwd);
1327
+ const baseConfig = loadOracleConfig(projectCwd, { projectConfigTrustCwd: ctx.cwd, projectConfigTrusted: isProjectTrusted(ctx) });
1315
1328
  const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
1316
1329
  const projectId = getProjectId(projectCwd);
1317
1330
  const sessionId = getSessionId(originSessionFile, projectId);
@@ -10,6 +10,12 @@ export interface ExecutableSearchOptions {
10
10
  pathDelimiter?: string;
11
11
  }
12
12
 
13
+ export interface KnownBrowserUserDataPathMatchDetails {
14
+ root: string;
15
+ source: "knownBrowserUserDataDir" | "auth.chromeCookiePath" | "auth.chromeProfile" | "extraProtectedPath";
16
+ configuredPath?: string;
17
+ }
18
+
13
19
  export const SWEET_COOKIE_SAFE_STORAGE_PASSWORD_ENV_NAMES: readonly [
14
20
  "SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD",
15
21
  "SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD",
@@ -40,6 +46,15 @@ export function knownBrowserUserDataPathMatch(
40
46
  cookieSources?: { chromeProfile?: string; chromeCookiePath?: string };
41
47
  },
42
48
  ): string | undefined;
49
+ export function knownBrowserUserDataPathMatchDetails(
50
+ pathValue: string,
51
+ options?: BrowserPathOptions & {
52
+ platform?: OraclePlatform;
53
+ includeUnsupported?: boolean;
54
+ extraProtectedPaths?: string[];
55
+ cookieSources?: { chromeProfile?: string; chromeCookiePath?: string };
56
+ },
57
+ ): KnownBrowserUserDataPathMatchDetails | undefined;
43
58
  export function assertNotKnownBrowserUserDataPath(
44
59
  pathValue: string,
45
60
  label: string,
@@ -224,23 +224,40 @@ function protectedPathsForCookieDb(cookiePath) {
224
224
  * @returns {string[]}
225
225
  */
226
226
  export function protectedCookieSourcePaths(cookieSources) {
227
+ return protectedCookieSourcePathEntries(cookieSources).map((entry) => entry.path);
228
+ }
229
+
230
+ function protectedCookieSourcePathEntries(cookieSources) {
227
231
  if (!cookieSources) return [];
228
232
  const roots = [];
229
233
  const cookiePath = typeof cookieSources.chromeCookiePath === "string" && cookieSources.chromeCookiePath.trim()
230
234
  ? cookieSources.chromeCookiePath.trim()
231
235
  : undefined;
232
- if (cookiePath) roots.push(...protectedPathsForCookieDb(cookiePath));
236
+ if (cookiePath) {
237
+ roots.push(...protectedPathsForCookieDb(cookiePath).map((path) => ({ path, source: "auth.chromeCookiePath", configuredPath: cookiePath })));
238
+ }
233
239
 
234
240
  const profile = typeof cookieSources.chromeProfile === "string" && cookieSources.chromeProfile.trim()
235
241
  ? cookieSources.chromeProfile.trim()
236
242
  : undefined;
237
243
  if (profile && looksLikeFilesystemPath(profile)) {
238
244
  const normalizedProfile = normalizedAbsolutePath(profile);
239
- if (isCookiesDbPath(normalizedProfile)) roots.push(...protectedPathsForCookieDb(normalizedProfile));
240
- else roots.push(normalizedProfile, dirname(normalizedProfile));
245
+ if (isCookiesDbPath(normalizedProfile)) {
246
+ roots.push(...protectedPathsForCookieDb(normalizedProfile).map((path) => ({ path, source: "auth.chromeProfile", configuredPath: profile })));
247
+ } else {
248
+ roots.push({ path: normalizedProfile, source: "auth.chromeProfile", configuredPath: profile }, { path: dirname(normalizedProfile), source: "auth.chromeProfile", configuredPath: profile });
249
+ }
241
250
  }
242
251
 
243
- return [...new Set(roots.map((root) => normalize(root)))];
252
+ const seen = new Set();
253
+ return roots
254
+ .map((root) => ({ ...root, path: normalize(root.path) }))
255
+ .filter((root) => {
256
+ const key = `${root.path}\0${root.source}\0${root.configuredPath}`;
257
+ if (seen.has(key)) return false;
258
+ seen.add(key);
259
+ return true;
260
+ });
244
261
  }
245
262
 
246
263
  /**
@@ -249,19 +266,24 @@ export function protectedCookieSourcePaths(cookieSources) {
249
266
  * @returns {string | undefined}
250
267
  */
251
268
  export function knownBrowserUserDataPathMatch(pathValue, options = {}) {
269
+ return knownBrowserUserDataPathMatchDetails(pathValue, options)?.root;
270
+ }
271
+
272
+ export function knownBrowserUserDataPathMatchDetails(pathValue, options = {}) {
252
273
  const platform = options.platform ?? process.platform;
253
274
  const normalizedPath = normalizedAbsolutePath(pathValue, options);
254
275
  const resolvedPath = resolvePathThroughExistingAncestorsSync(normalizedPath);
255
276
  const roots = [
256
- ...browserUserDataDirsForPlatform(platform, { ...options, includeUnsupported: options.includeUnsupported ?? true }),
257
- ...protectedCookieSourcePaths(options.cookieSources),
258
- ...((options.extraProtectedPaths ?? [])),
277
+ ...browserUserDataDirsForPlatform(platform, { ...options, includeUnsupported: options.includeUnsupported ?? true })
278
+ .map((path) => ({ path, source: "knownBrowserUserDataDir" })),
279
+ ...protectedCookieSourcePathEntries(options.cookieSources),
280
+ ...((options.extraProtectedPaths ?? [])).map((path) => ({ path, source: "extraProtectedPath" })),
259
281
  ];
260
282
  for (const root of roots) {
261
- const normalizedRoot = normalizedAbsolutePath(root, options);
262
- if (pathInsideOrEqual(normalizedPath, normalizedRoot)) return normalizedRoot;
283
+ const normalizedRoot = normalizedAbsolutePath(root.path, options);
284
+ if (pathInsideOrEqual(normalizedPath, normalizedRoot)) return { root: normalizedRoot, source: root.source, configuredPath: root.configuredPath };
263
285
  const resolvedRoot = resolvePathThroughExistingAncestorsSync(normalizedRoot) ?? normalizedRoot;
264
- if (resolvedPath && pathInsideOrEqual(resolvedPath, resolvedRoot)) return resolvedRoot;
286
+ if (resolvedPath && pathInsideOrEqual(resolvedPath, resolvedRoot)) return { root: resolvedRoot, source: root.source, configuredPath: root.configuredPath };
265
287
  }
266
288
  return undefined;
267
289
  }
@@ -273,10 +295,12 @@ export function knownBrowserUserDataPathMatch(pathValue, options = {}) {
273
295
  * @returns {void}
274
296
  */
275
297
  export function assertNotKnownBrowserUserDataPath(pathValue, label, options = {}) {
276
- const match = knownBrowserUserDataPathMatch(pathValue, options);
277
- if (match) {
278
- throw new Error(`${label} must not point into a real browser user-data directory (${match}): ${pathValue}`);
298
+ const match = knownBrowserUserDataPathMatchDetails(pathValue, options);
299
+ if (!match) return;
300
+ if (match.source === "auth.chromeCookiePath" || match.source === "auth.chromeProfile") {
301
+ throw new Error(`${label} is inside the browser profile root inferred from ${match.source} (${match.configuredPath} -> ${match.root}): ${pathValue}`);
279
302
  }
303
+ throw new Error(`${label} must not point into a real browser user-data directory (${match.root}): ${pathValue}`);
280
304
  }
281
305
 
282
306
  /**
@@ -62,6 +62,8 @@ export interface OracleStatusCounts {
62
62
  queued: number;
63
63
  }
64
64
 
65
+ export type OracleReadinessStatus = "unavailable" | "loaded" | "auth_needed" | "config_error" | "ready";
66
+
65
67
  export declare function formatBytes(bytes: number): string;
66
68
  export declare function formatOracleLifecycleEvent(event: OracleJobLifecycleEvent | undefined): string | undefined;
67
69
  export declare function formatOracleCancelOutcome(job: { id: string; status: string }): string;
@@ -71,4 +73,4 @@ export declare function buildOracleWakeupNotificationContent(
71
73
  options?: { responsePath?: string; responseAvailable?: boolean; artifactsPath?: string },
72
74
  ): string;
73
75
  export declare function formatOracleSubmitResponse(job: OracleJobSummaryLike & { promptPath: string; archivePath: string }, options: OracleSubmitResponseOptions): string;
74
- export declare function buildOracleStatusText(counts: OracleStatusCounts): string;
76
+ export declare function buildOracleStatusText(counts: OracleStatusCounts, readiness?: OracleReadinessStatus): string;
@@ -254,19 +254,28 @@ export function formatOracleSubmitResponse(job, options) {
254
254
 
255
255
  /**
256
256
  * @param {OracleStatusCounts} counts
257
+ * @param {"unavailable" | "loaded" | "auth_needed" | "config_error" | "ready"} [readiness]
257
258
  * @returns {string}
258
259
  */
259
- export function buildOracleStatusText(counts) {
260
+ export function buildOracleStatusText(counts, readiness = "loaded") {
261
+ const readinessText = {
262
+ unavailable: "unavailable",
263
+ loaded: "loaded",
264
+ auth_needed: "auth needed",
265
+ config_error: "config error",
266
+ ready: "ready",
267
+ }[readiness] ?? "loaded";
268
+ const readinessSuffix = readiness === "ready" ? "" : `, ${readinessText}`;
260
269
  if (counts.active > 0 && counts.queued > 0) {
261
- return `oracle: running (${counts.active}), queued (${counts.queued})`;
270
+ return `oracle: running (${counts.active}), queued (${counts.queued})${readinessSuffix}`;
262
271
  }
263
272
  if (counts.active > 0) {
264
273
  const suffix = counts.active > 1 ? ` (${counts.active})` : "";
265
- return `oracle: running${suffix}`;
274
+ return `oracle: running${suffix}${readinessSuffix}`;
266
275
  }
267
276
  if (counts.queued > 0) {
268
277
  const suffix = counts.queued > 1 ? ` (${counts.queued})` : "";
269
- return `oracle: queued${suffix}`;
278
+ return `oracle: queued${suffix}${readinessSuffix}`;
270
279
  }
271
- return "oracle: ready";
280
+ return `oracle: ${readinessText}`;
272
281
  }
@@ -3,9 +3,18 @@ export interface OracleStableValueState {
3
3
  stableCount: number;
4
4
  }
5
5
 
6
+ export interface OracleSendAcceptanceState {
7
+ url?: string;
8
+ urlKnown?: boolean;
9
+ assistantCount?: number;
10
+ stopStreaming?: boolean;
11
+ }
12
+
6
13
  export declare function assistantSnapshotSlice(snapshot: string, composerLabel: string, responseIndex: number): string | undefined;
7
14
  export declare function stripUrlQueryAndHash(url: string | undefined): string;
8
15
  export declare function isConversationPathUrl(url: string): boolean;
16
+ export declare function conversationIdFromUrl(url: string | undefined): string | undefined;
17
+ export declare function providerSendAccepted(before: OracleSendAcceptanceState, after: OracleSendAcceptanceState): boolean;
9
18
  export declare function resolveStableConversationUrlCandidate(url: string, previousChatUrl?: string): string | undefined;
10
19
  export declare function nextStableValueState(
11
20
  state: Partial<OracleStableValueState> | undefined,
@@ -5,6 +5,7 @@
5
5
  // Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`; URL inputs may be malformed and must fail safely.
6
6
 
7
7
  /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState} OracleStableValueState */
8
+ /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleSendAcceptanceState} OracleSendAcceptanceState */
8
9
 
9
10
  /**
10
11
  * @param {string} snapshot
@@ -52,13 +53,39 @@ export function stripUrlQueryAndHash(url) {
52
53
  * @returns {boolean}
53
54
  */
54
55
  export function isConversationPathUrl(url) {
56
+ return Boolean(conversationIdFromUrl(url));
57
+ }
58
+
59
+ /**
60
+ * @param {string | undefined} url
61
+ * @returns {string | undefined}
62
+ */
63
+ export function conversationIdFromUrl(url) {
64
+ if (typeof url !== "string" || !url.trim()) return undefined;
55
65
  try {
56
- return /\/(?:c|chat)\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
66
+ const match = new URL(url).pathname.match(/\/(?:c|chat)\/([A-Za-z0-9-]+)$/i);
67
+ return match?.[1];
57
68
  } catch {
58
- return false;
69
+ return undefined;
59
70
  }
60
71
  }
61
72
 
73
+ /**
74
+ * @param {OracleSendAcceptanceState} before
75
+ * @param {OracleSendAcceptanceState} after
76
+ * @returns {boolean}
77
+ */
78
+ export function providerSendAccepted(before, after) {
79
+ const beforeUrlKnown = before.urlKnown !== false;
80
+ const afterUrlKnown = after.urlKnown !== false;
81
+ const beforeConversationId = beforeUrlKnown ? conversationIdFromUrl(before.url) : undefined;
82
+ const afterConversationId = afterUrlKnown ? conversationIdFromUrl(after.url) : undefined;
83
+ if (beforeUrlKnown && afterUrlKnown && afterConversationId && afterConversationId !== beforeConversationId) return true;
84
+ if ((after.assistantCount ?? 0) > (before.assistantCount ?? 0)) return true;
85
+ if (after.stopStreaming === true && before.stopStreaming !== true) return true;
86
+ return false;
87
+ }
88
+
62
89
  /**
63
90
  * @param {string} url
64
91
  * @param {string | undefined} previousChatUrl
@@ -10,6 +10,7 @@ export interface OracleUiSelection {
10
10
  export declare const CHATGPT_CANONICAL_APP_ORIGINS: readonly string[];
11
11
 
12
12
  export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: string): string[];
13
+ export declare function stripChatGptResponseChrome(value: string | undefined): string;
13
14
  export declare function matchesModelFamilyLabel(label: string | undefined, family: OracleUiModelFamily): boolean;
14
15
  export declare function matchesRequestedModelControlLabel(label: string | undefined, selection: OracleUiSelection): boolean;
15
16
  export declare function matchesCompactIntelligenceOpenerLabel(label: string | undefined): boolean;