pi-oracle 0.4.0 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-04-12
4
+
5
+ ### Added
6
+ - `oracle_preflight`, a lightweight readiness tool that lets `/oracle` fail fast on missing persisted-session or local auth/config blockers before archive/context work begins
7
+
8
+ ### Changed
9
+ - `/oracle` now follows a stricter preflight-first flow, biases toward minimal context gathering for explicitly narrow requests, and prefers the configured default model unless a different preset is clearly needed
10
+ - `oracle_read`, `/oracle-status`, and wake-up messaging now keep the true terminal event prominent, separate wake-up bookkeeping from failure state, and stop implying that a missing `response.md` is ready
11
+ - `/oracle-auth` failure guidance now reports the effective agent config path for the active agent dir and explains when a project config was also read but could not override `auth.*`
12
+ - `/oracle-clean` now documents the short post-send retention grace window and returns a retry-after timestamp when a terminal job is still intentionally retained
13
+
14
+ ### Fixed
15
+ - `oracle_submit` now rejects locally knowable auth-seed blockers before archive creation or job persistence while still preserving direct archive-input validation errors like symlink escapes
16
+ - oracle tool results now use consistent structured `details.job` / `details.error` payloads and preserve `isError` for structured failures through the tool-result hook
17
+ - `/oracle` no-session and missing-seed flows now stop before unnecessary repo exploration, and narrow prompt-template runs dispatch more quickly with smaller archives when the user scope is explicit
18
+ - repeated oracle sanity runs now quiesce background pollers before isolated-state teardown so release verification no longer emits a noisy temp-lock ENOENT race
19
+
3
20
  ## 0.4.0 - 2026-04-12
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -42,12 +42,17 @@ pi install https://github.com/fitchmultz/pi-oracle
42
42
 
43
43
  ## Quickstart
44
44
 
45
- 1. Make sure ChatGPT already works in your local Chrome profile.
46
- 2. Make sure these are installed: Google Chrome, `agent-browser`, `tar`, and `zstd`.
47
- 3. Optional: create `~/.pi/agent/extensions/oracle.json` if you want non-default settings.
48
- 4. Run `/oracle-auth`.
49
- 5. Run `/oracle Review the current pending changes. Include the whole repo unless a narrower archive is clearly better.`
50
- 6. Wait for a best-effort wake-up, or check `/oracle-status`.
45
+ 1. Start a normal persisted `pi` session. Do not use `pi --no-session` for oracle.
46
+ 2. Make sure ChatGPT already works in your local Chrome profile.
47
+ 3. Make sure these are installed: Google Chrome, `agent-browser`, `tar`, and `zstd`.
48
+ 4. Optional: create `~/.pi/agent/extensions/oracle.json` if you want non-default settings.
49
+ 5. Run `/oracle-auth`.
50
+ 6. Run `/oracle Review the current pending changes. Include the whole repo unless a narrower archive is clearly better.`
51
+ 7. Wait for a best-effort wake-up, or check `/oracle-status`.
52
+
53
+ The `/oracle` prompt now runs an early oracle preflight before it gathers repo context, so missing persisted-session or local auth/config blockers fail before the agent spends time reading files.
54
+
55
+ For explicitly narrow requests, `/oracle` should gather only minimal context and prefer a minimal archive instead of broad repo exploration. It should also omit `preset` and use the configured default model unless the task clearly needs a different one.
51
56
 
52
57
  If you miss the wake-up, the result is still saved durably in the oracle job directory and can be read later.
53
58
 
@@ -61,11 +66,15 @@ If you miss the wake-up, the result is still saved durably in the oracle job dir
61
66
  /oracle Read the codebase and explain the highest-risk auth/session failure modes, including what to test before shipping.
62
67
  ```
63
68
 
69
+ ```text
70
+ /oracle Explain the README guidance for /oracle-clean retention grace. Only archive README.md unless another file is clearly necessary.
71
+ ```
72
+
64
73
  ## High-level flow
65
74
 
66
75
  ```mermaid
67
76
  flowchart LR
68
- A["/oracle request"] --> B["Agent gathers repo context"]
77
+ A["/oracle request"] --> B["Agent preflights, then gathers only the needed repo context"]
69
78
  B --> C["oracle_submit builds archive"]
70
79
  C --> D["Detached worker starts isolated ChatGPT runtime"]
71
80
  D --> E["Archive + prompt sent to ChatGPT.com"]
@@ -82,9 +91,10 @@ User-facing commands:
82
91
  - `/oracle-auth` — sync ChatGPT cookies from your real Chrome profile into the isolated oracle auth profile
83
92
  - `/oracle-status [job-id]` — inspect job status
84
93
  - `/oracle-cancel [job-id]` — cancel queued or active job
85
- - `/oracle-clean <job-id|all>` — remove temp files for terminal jobs
94
+ - `/oracle-clean <job-id|all>` — remove temp files for terminal jobs; recently woken terminal jobs may stay retained briefly and return a retry-after hint
86
95
 
87
96
  Agent-facing tools:
97
+ - `oracle_preflight`
88
98
  - `oracle_submit`
89
99
  - `oracle_read`
90
100
  - `oracle_cancel`
@@ -143,6 +153,7 @@ Project config should only override safe, non-privileged settings.
143
153
  - Jobs can queue automatically if runtime capacity is full.
144
154
  - Completion delivery into `pi` is best-effort wake-up based.
145
155
  - If you miss the wake-up, use `oracle_read(jobId)` or `/oracle-status`.
156
+ - `/oracle-clean` can still refuse a terminal job briefly after a wake-up send so saved response/artifact paths survive the follow-up turn; when that guard applies, it returns the next eligible cleanup time.
146
157
 
147
158
  ## Requirements
148
159
 
@@ -177,6 +188,12 @@ Project config should only override safe, non-privileged settings.
177
188
  - Use `/oracle-status [job-id]` or `oracle_read(jobId)`.
178
189
  - Results are still saved on disk even if the reminder turn does not land.
179
190
 
191
+ ### `/oracle-clean` refuses a terminal job right after completion
192
+
193
+ - This can happen during the short post-send retention grace window after a wake-up was sent.
194
+ - The command now returns a `Retry after ...` timestamp when that guard is active.
195
+ - Wait until that time, then rerun `/oracle-clean [job-id|all]`.
196
+
180
197
  ### `agent-browser`, `tar`, or `zstd` is missing
181
198
 
182
199
  - Install the missing local dependency and rerun the command.
@@ -79,6 +79,9 @@ The extension now follows the current `pi` session lifecycle model:
79
79
 
80
80
  ### Tools
81
81
 
82
+ - `oracle_preflight`
83
+ - lightweight agent-facing readiness check for persisted-session and local oracle prerequisites
84
+ - intended to run before expensive `/oracle` context gathering
82
85
  - `oracle_submit`
83
86
  - low-level agent-facing dispatch tool
84
87
  - creates archive and launches a detached worker
@@ -97,12 +100,15 @@ It expands through the prompt-template path so pi can apply its native queueing
97
100
 
98
101
  Instead it instructs the agent to:
99
102
 
100
- 1. understand the task
101
- 2. gather repo context
102
- 3. choose exact archive inputs
103
- 4. craft the oracle prompt
104
- 5. call `oracle_submit`
105
- 6. stop and wait for the completion wake-up (best-effort; durable oracle response/artifact state is already persisted outside session history)
103
+ 1. call `oracle_preflight` immediately
104
+ 2. stop right away if preflight reports the session or local oracle setup is not ready
105
+ 3. understand whether the request is explicitly narrow or genuinely broad
106
+ 4. gather only the smallest repo context needed to submit well
107
+ 5. if the request is narrow, prefer a minimal targeted archive and dispatch as soon as enough context is in hand
108
+ 6. if the request is broad/repo-wide, gather broader context and usually archive `.`
109
+ 7. craft the oracle prompt
110
+ 8. call `oracle_submit`
111
+ 9. stop and wait for the completion wake-up (best-effort; durable oracle response/artifact state is already persisted outside session history)
106
112
 
107
113
  ### `/oracle-auth`
108
114
 
@@ -133,7 +139,7 @@ The authenticated seed profile remains the source of truth for future oracle run
133
139
 
134
140
  ### `oracle_submit`
135
141
 
136
- Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields. Submit-time inputs accept canonical preset ids plus matching human-readable labels/common hyphen-space variants, and the tool normalizes them back to the canonical id before persisting job state.
142
+ Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields. Submit-time inputs accept canonical preset ids plus matching human-readable labels/common hyphen-space variants, and the tool normalizes them back to the canonical id before persisting job state. Prompt-template guidance biases toward omitting `preset` and using the configured default unless the task clearly needs a different model or the user explicitly asked for one.
137
143
 
138
144
  1. resolve the preset (submit-time or config default) into an execution snapshot
139
145
  2. resolve optional `followUpJobId` into a prior `chatUrl` and `conversationId`
@@ -262,7 +268,7 @@ Long-run hygiene is intentionally conservative:
262
268
 
263
269
  - runtime profiles, runtime leases, and conversation leases are cleaned immediately as part of worker/command cleanup paths
264
270
  - browser close is time-bounded so cleanup can continue even if `agent-browser close` wedges
265
- - `/oracle-clean` performs runtime cleanup before removing the persisted job directory, but refuses terminal jobs whose worker is still live or whose wake-up was just sent inside a short post-send retention grace window
271
+ - `/oracle-clean` performs runtime cleanup before removing the persisted job directory, but refuses terminal jobs whose worker is still live or whose wake-up was just sent inside a short post-send retention grace window; when blocked by that grace it returns a retry-after timestamp
266
272
  - stale lock directories are swept before reconcile maintenance
267
273
  - old auth `.staging-*` profiles are swept during `/oracle-auth` startup when the auth browser session is not still active
268
274
  - terminal job directories are retained for inspection, then pruned later based on configurable retention windows
@@ -29,6 +29,17 @@ pi --no-extensions -e "$REPO/extensions/oracle/index.ts"
29
29
 
30
30
  That ensures the session is exercising the in-repo code, not a globally installed package.
31
31
 
32
+ If you also need the in-repo `/oracle` prompt template, load it explicitly instead of installing this repository as a project-local package:
33
+
34
+ ```bash
35
+ pi --no-extensions -e "$REPO/extensions/oracle/index.ts" \
36
+ --no-prompt-templates --prompt-template "$REPO/prompts/oracle.md"
37
+ ```
38
+
39
+ Do not add `https://github.com/fitchmultz/pi-oracle` to this repository's `.pi/settings.json` just to test local oracle changes. If you already keep `npm:pi-oracle` installed globally, mixing the global npm package with a project-local git package creates two distinct package identities and can trigger prompt/tool conflicts. Use the explicit CLI resource flags above instead.
40
+
41
+ `oracle_submit` now preflights a missing or unreadable auth seed profile before it creates an archive or persists a job. For archive-inspection smoke tests that intentionally run without real auth, create an empty isolated seed-profile directory under the temporary agent dir so submission can proceed far enough to write the archive while still staying isolated from your normal Chrome state.
42
+
32
43
  ## Preset requirement
33
44
 
34
45
  Use either:
@@ -70,6 +81,8 @@ mkdir -p \
70
81
  "$TEST2_AGENT" "$TEST2_SESSIONS" "$TEST2_JOBS" \
71
82
  "$FIXTURE" "$OUTSIDE"
72
83
 
84
+ mkdir -p "$TEST1_AGENT/extensions/oracle-auth-seed-profile"
85
+
73
86
  echo 'secret' > "$OUTSIDE/secret.txt"
74
87
  ln -s "$OUTSIDE" "$FIXTURE/linked-outside"
75
88
 
@@ -149,7 +162,8 @@ Expected behavior:
149
162
  Notes:
150
163
 
151
164
  - this smoke test does not require `/oracle-auth`
152
- - without an auth seed profile, the worker fails after archive creation, which is useful because the archive remains on disk for inspection
165
+ - the snippet creates an empty isolated auth seed profile for `TEST1_AGENT` because `oracle_submit` now rejects a missing seed profile before archiving
166
+ - with that empty seed profile, the worker still fails later due to missing real auth, which is useful because the archive remains on disk for inspection
153
167
 
154
168
  ### Test 2: symlink escape rejection
155
169
 
@@ -159,6 +173,19 @@ Expected behavior:
159
173
  - the error should say the archive input must resolve inside the project cwd without symlink escapes
160
174
  - no oracle job directory should be created for the rejected submit
161
175
 
176
+ ## Testing local `/oracle` prompt changes too
177
+
178
+ The main smoke test above calls `oracle_submit` directly, so it only needs the local extension entrypoint. If you also changed `prompts/oracle.md`, start the isolated session with the local prompt template explicitly loaded:
179
+
180
+ ```bash
181
+ LOCAL_ORACLE_PI_CMD="pi --session-dir '$TEST1_SESSIONS' --no-extensions -e '$REPO/extensions/oracle/index.ts' --no-prompt-templates --prompt-template '$REPO/prompts/oracle.md'"
182
+ TMUX_CMD1="cd '$REPO' && env PI_CODING_AGENT_DIR='$TEST1_AGENT' PI_ORACLE_JOBS_DIR='$TEST1_JOBS' PATH='$PATH' $LOCAL_ORACLE_PI_CMD"
183
+ ```
184
+
185
+ Use the same pattern for additional sessions, swapping the session/job directories as needed. This keeps the test on the in-repo extension and in-repo prompt template without depending on `.pi/settings.json` package entries.
186
+
187
+ `/oracle` now starts by calling `oracle_preflight`. If you want the prompt flow to proceed past that early guard in an isolated test without using your normal auth state, create an empty isolated auth seed profile first (for example `mkdir -p "$TEST1_AGENT/extensions/oracle-auth-seed-profile"`) or run `/oracle-auth` in the isolated agent dir.
188
+
162
189
  ## Additional failure-mode smoke tests
163
190
 
164
191
  ### `/oracle-auth` should fail fast when `agent-browser` hangs
@@ -4,9 +4,10 @@
4
4
  // Usage: Imported by the oracle extension entrypoint to register /oracle-* commands with pi.
5
5
  // Invariants/Assumptions: Commands operate on persisted project-scoped jobs and rely on shared observability formatting for detached-state clarity.
6
6
  import { spawn } from "node:child_process";
7
+ import { existsSync } from "node:fs";
7
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
8
9
  import { formatOracleJobSummary } from "../shared/job-observability-helpers.mjs";
9
- import { loadOracleConfig } from "./config.js";
10
+ import { formatOracleAuthConfigRemediation, formatOracleAuthConfigSummary, getOracleConfigLoadDetails, loadOracleConfig } from "./config.js";
10
11
  import {
11
12
  cancelOracleJob,
12
13
  getJobDir,
@@ -29,9 +30,11 @@ function summarizeJob(jobId: string): string {
29
30
  const job = readJob(jobId);
30
31
  if (!job) return `Oracle job ${jobId} not found.`;
31
32
 
33
+ const responseAvailable = Boolean(job.responsePath && existsSync(job.responsePath));
32
34
  return formatOracleJobSummary(job, {
33
35
  queuePosition: job.status === "queued" ? getQueuePosition(job.id) : undefined,
34
36
  artifactsPath: `${getJobDir(job.id)}/artifacts`,
37
+ responseAvailable,
35
38
  });
36
39
  }
37
40
 
@@ -47,6 +50,12 @@ function readScopedJob(jobId: string, cwd: string) {
47
50
 
48
51
  async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<string> {
49
52
  const config = loadOracleConfig(cwd);
53
+ const configLoad = getOracleConfigLoadDetails(cwd);
54
+ const authConfigGuidance = {
55
+ ...configLoad,
56
+ remediation: formatOracleAuthConfigRemediation(configLoad),
57
+ summary: formatOracleAuthConfigSummary(configLoad),
58
+ };
50
59
  try {
51
60
  await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
52
61
  await reconcileStaleOracleJobs();
@@ -57,7 +66,7 @@ async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<st
57
66
  }
58
67
 
59
68
  return await new Promise<string>((resolve, reject) => {
60
- const child = spawn(process.execPath, [authWorkerPath, JSON.stringify(config)], {
69
+ const child = spawn(process.execPath, [authWorkerPath, JSON.stringify({ config, configLoad: authConfigGuidance })], {
61
70
  cwd,
62
71
  stdio: ["ignore", "pipe", "pipe"],
63
72
  });
@@ -151,7 +160,7 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
151
160
  });
152
161
 
153
162
  pi.registerCommand("oracle-clean", {
154
- description: "Remove oracle temp files for a job or all project jobs",
163
+ description: "Remove oracle temp files for terminal jobs; recently woken jobs may stay retained briefly",
155
164
  handler: async (args, ctx: ExtensionCommandContext) => {
156
165
  const target = args.trim();
157
166
  if (!target) {
@@ -196,10 +205,10 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
196
205
  }
197
206
 
198
207
  refreshOracleStatus(ctx);
199
- const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup warnings:\n${cleanupWarnings.join("\n")}` : "";
208
+ const warningSuffix = cleanupWarnings.length > 0 ? ` Cleanup blockers/warnings:\n${cleanupWarnings.join("\n")}` : "";
200
209
  const removalSummary = removedCount === jobs.length
201
210
  ? `Removed ${removedCount} oracle job director${removedCount === 1 ? "y" : "ies"}.`
202
- : `Removed ${removedCount} of ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}; retained ${jobs.length - removedCount} with cleanup warnings.`;
211
+ : `Removed ${removedCount} of ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}; retained ${jobs.length - removedCount} due to cleanup blockers or warnings.`;
203
212
  ctx.ui.notify(`${removalSummary}${warningSuffix}`, cleanupWarnings.length > 0 ? "warning" : "info");
204
213
  },
205
214
  });
@@ -258,6 +258,54 @@ const detectedChromeUserAgent = detectDefaultChromeUserAgent(detectedChromeExecu
258
258
  const agentExtensionsDir = join(getAgentDir(), "extensions");
259
259
  const detectedChromeProfileName = detectDefaultChromeProfileName();
260
260
 
261
+ export interface OracleConfigLoadDetails {
262
+ agentDir: string;
263
+ agentConfigPath: string;
264
+ agentConfigExists: boolean;
265
+ projectConfigPath: string;
266
+ projectConfigExists: boolean;
267
+ effectiveAuthConfigPath: string;
268
+ effectiveAuthScope: "agent";
269
+ }
270
+
271
+ export function getOracleConfigLoadDetails(cwd: string): OracleConfigLoadDetails {
272
+ const agentDir = getAgentDir();
273
+ const agentConfigPath = join(agentDir, "extensions", "oracle.json");
274
+ const projectConfigPath = join(cwd, ".pi", "extensions", "oracle.json");
275
+ return {
276
+ agentDir,
277
+ agentConfigPath,
278
+ agentConfigExists: existsSync(agentConfigPath),
279
+ projectConfigPath,
280
+ projectConfigExists: existsSync(projectConfigPath),
281
+ effectiveAuthConfigPath: agentConfigPath,
282
+ effectiveAuthScope: "agent",
283
+ };
284
+ }
285
+
286
+ export function formatOracleAuthConfigRemediation(details: OracleConfigLoadDetails): string {
287
+ if (!details.projectConfigExists) {
288
+ return `Set auth.chromeProfile / auth.chromeCookiePath in ${details.effectiveAuthConfigPath}.`;
289
+ }
290
+ return (
291
+ `Set auth.chromeProfile / auth.chromeCookiePath in ${details.effectiveAuthConfigPath}. ` +
292
+ `Project overrides are also read from ${details.projectConfigPath}, but auth.* is loaded from ${details.effectiveAuthConfigPath}.`
293
+ );
294
+ }
295
+
296
+ export function formatOracleAuthConfigSummary(details: OracleConfigLoadDetails): string {
297
+ const lines = [
298
+ `Effective oracle auth config: ${details.effectiveAuthConfigPath} (agent dir: ${details.agentDir}${details.agentConfigExists ? "" : "; create this file to override auth.*"})`,
299
+ ];
300
+ if (details.projectConfigExists) {
301
+ lines.push(
302
+ `Project oracle config also loaded: ${details.projectConfigPath} ` +
303
+ `(project scope can override ${[...PROJECT_OVERRIDE_KEYS].join("/")} only; auth.* still comes from ${details.effectiveAuthConfigPath}).`,
304
+ );
305
+ }
306
+ return lines.join("\n");
307
+ }
308
+
261
309
  export const DEFAULT_CONFIG: OracleConfig = {
262
310
  defaults: {
263
311
  preset: "pro_extended",
@@ -514,7 +562,8 @@ function validateOracleConfig(value: unknown): OracleConfig {
514
562
  }
515
563
 
516
564
  export function loadOracleConfig(cwd: string): OracleConfig {
517
- const globalConfig = readJson(join(getAgentDir(), "extensions", "oracle.json"));
518
- const projectConfig = filterProjectConfig(readJson(join(cwd, ".pi", "extensions", "oracle.json")));
565
+ const details = getOracleConfigLoadDetails(cwd);
566
+ const globalConfig = readJson(details.agentConfigPath);
567
+ const projectConfig = filterProjectConfig(readJson(details.projectConfigPath));
519
568
  return validateOracleConfig(deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig));
520
569
  }
@@ -285,6 +285,16 @@ function notificationClaimIsLive(job: Pick<OracleJob, "notifyClaimedAt" | "notif
285
285
  return now - claimedAtMs < ORACLE_NOTIFICATION_CLAIM_TTL_MS;
286
286
  }
287
287
 
288
+ function getWakeupRetentionGraceDeadline(job: Pick<OracleJob, "wakeupLastRequestedAt">, now = Date.now()): { retryAt: string; remainingMs: number } | undefined {
289
+ const lastRequestedAtMs = parseTimestamp(job.wakeupLastRequestedAt);
290
+ if (lastRequestedAtMs === undefined) return undefined;
291
+ const retryAtMs = lastRequestedAtMs + ORACLE_WAKEUP_POST_SEND_RETENTION_MS;
292
+ return {
293
+ retryAt: new Date(retryAtMs).toISOString(),
294
+ remainingMs: Math.max(0, retryAtMs - now),
295
+ };
296
+ }
297
+
288
298
  function wakeupRetentionGraceIsActive(job: Pick<OracleJob, "wakeupLastRequestedAt">, now = Date.now()): boolean {
289
299
  const lastRequestedAtMs = parseTimestamp(job.wakeupLastRequestedAt);
290
300
  if (lastRequestedAtMs === undefined) return false;
@@ -464,12 +474,17 @@ export async function removeTerminalOracleJob(job: OracleJob): Promise<{ removed
464
474
  },
465
475
  };
466
476
  }
467
- if (wakeupRetentionGraceIsActive(current)) {
477
+ const nowMs = Date.now();
478
+ if (wakeupRetentionGraceIsActive(current, nowMs)) {
479
+ const graceDeadline = getWakeupRetentionGraceDeadline(current, nowMs);
480
+ const retryHint = graceDeadline
481
+ ? ` Retry after ${graceDeadline.retryAt} (${Math.ceil(graceDeadline.remainingMs / 1000)}s remaining).`
482
+ : "";
468
483
  return {
469
484
  removed: false,
470
485
  cleanupReport: {
471
486
  attempted: [],
472
- warnings: [`Refusing to remove terminal oracle job ${current.id} because its wake-up delivery is still within the post-send retention grace window.`],
487
+ warnings: [`Refusing to remove terminal oracle job ${current.id} because its wake-up delivery is still within the post-send retention grace window.${retryHint}`],
473
488
  },
474
489
  };
475
490
  }
@@ -3,6 +3,7 @@
3
3
  // Scope: Poller/orchestration only; durable lifecycle mutations live in jobs.ts and shared observability formatting lives in extensions/oracle/shared.
4
4
  // Usage: Imported by the oracle extension entrypoint to start or stop per-session oracle polling.
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
+ import { existsSync } from "node:fs";
6
7
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
7
8
  import { buildOracleStatusText, buildOracleWakeupNotificationContent } from "../shared/job-observability-helpers.mjs";
8
9
  import { isProcessAlive, readProcessStartedAt } from "../shared/process-helpers.mjs";
@@ -154,7 +155,8 @@ function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
154
155
  customType: ORACLE_WAKEUP_REMINDER_CUSTOM_TYPE,
155
156
  display: false,
156
157
  content: buildOracleWakeupNotificationContent(job, {
157
- responsePath: job.responsePath || `${getJobDir(job.id)}/response.md`,
158
+ responsePath: job.responsePath,
159
+ responseAvailable: Boolean(job.responsePath && existsSync(job.responsePath)),
158
160
  artifactsPath: `${getJobDir(job.id)}/artifacts`,
159
161
  }),
160
162
  details: { jobId: job.id, status: job.status },
@@ -292,12 +294,33 @@ export function stopPollerForSession(sessionFile: string | undefined, cwd: strin
292
294
  if (timer) {
293
295
  clearInterval(timer);
294
296
  activePollers.delete(sessionKey);
295
- scansInFlight.delete(sessionKey);
296
297
  }
297
298
  const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
298
299
  void releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
299
300
  }
300
301
 
302
+ export async function stopAllPollers(): Promise<void> {
303
+ const sessionKeys = [...activePollers.keys()];
304
+ for (const timer of activePollers.values()) {
305
+ clearInterval(timer);
306
+ }
307
+ activePollers.clear();
308
+ await Promise.all(sessionKeys.map(async (sessionKey) => {
309
+ const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
310
+ await releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
311
+ }));
312
+ }
313
+
314
+ export async function waitForAllPollersToQuiesce(timeoutMs = 2_000): Promise<void> {
315
+ const startedAt = Date.now();
316
+ while (scansInFlight.size > 0) {
317
+ if (Date.now() - startedAt >= timeoutMs) {
318
+ throw new Error(`Timed out waiting for oracle pollers to quiesce after ${timeoutMs}ms`);
319
+ }
320
+ await new Promise((resolve) => setTimeout(resolve, 25));
321
+ }
322
+ }
323
+
301
324
  export function stopPoller(ctx: ExtensionContext): void {
302
325
  const sessionFile = getSessionFile(ctx);
303
326
  if (!sessionFile) return;
@@ -5,8 +5,8 @@
5
5
  // Invariants/Assumptions: Lease metadata is the admission source of truth, tracked worker identity checks defend against PID reuse, and runtime cleanup always attempts lease release.
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { spawn } from "node:child_process";
8
- import { existsSync, realpathSync, readFileSync } from "node:fs";
9
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { constants as fsConstants, existsSync, realpathSync, readFileSync } from "node:fs";
9
+ import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { dirname, join } from "node:path";
11
11
  import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
12
12
  import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
@@ -98,6 +98,45 @@ export function authSessionName(config: OracleConfig): string {
98
98
  return `${config.browser.sessionPrefix}-auth`;
99
99
  }
100
100
 
101
+ function missingAuthSeedProfileMessage(seedDir: string): string {
102
+ return `Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`;
103
+ }
104
+
105
+ function invalidAuthSeedProfileTypeMessage(seedDir: string): string {
106
+ return `Oracle auth seed profile is not a directory: ${seedDir}. Remove the invalid path or rerun /oracle-auth.`;
107
+ }
108
+
109
+ function unreadableAuthSeedProfileMessage(seedDir: string): string {
110
+ return `Oracle auth seed profile is not readable: ${seedDir}. Fix its permissions or rerun /oracle-auth.`;
111
+ }
112
+
113
+ export async function assertOracleAuthSeedProfileReady(config: OracleConfig): Promise<void> {
114
+ const seedDir = config.browser.authSeedProfileDir;
115
+ let seedStats;
116
+ try {
117
+ seedStats = await stat(seedDir);
118
+ } catch (error) {
119
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
120
+ if (code === "ENOENT") throw new Error(missingAuthSeedProfileMessage(seedDir));
121
+ if (code === "EACCES" || code === "EPERM") throw new Error(unreadableAuthSeedProfileMessage(seedDir));
122
+ throw new Error(`Failed to inspect oracle auth seed profile ${seedDir}: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+
125
+ if (!seedStats.isDirectory()) {
126
+ throw new Error(invalidAuthSeedProfileTypeMessage(seedDir));
127
+ }
128
+
129
+ try {
130
+ await access(seedDir, fsConstants.R_OK | fsConstants.X_OK);
131
+ } catch {
132
+ throw new Error(unreadableAuthSeedProfileMessage(seedDir));
133
+ }
134
+ }
135
+
136
+ export async function assertOracleSubmitPrerequisites(config: OracleConfig): Promise<void> {
137
+ await assertOracleAuthSeedProfileReady(config);
138
+ }
139
+
101
140
  export function getSeedGeneration(config: OracleConfig): string | undefined {
102
141
  const path = join(config.browser.authSeedProfileDir, SEED_GENERATION_FILE);
103
142
  if (!existsSync(path)) return undefined;
@@ -267,9 +306,7 @@ export async function cloneSeedProfileToRuntime(
267
306
  options?: { cpTimeoutMs?: number },
268
307
  ): Promise<string | undefined> {
269
308
  const seedDir = config.browser.authSeedProfileDir;
270
- if (!existsSync(seedDir)) {
271
- throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
272
- }
309
+ await assertOracleAuthSeedProfileReady(config);
273
310
 
274
311
  await withAuthLock({ runtimeProfileDir, seedDir }, async () => {
275
312
  await rm(runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);