pi-oracle 0.1.0 → 0.1.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/README.md CHANGED
@@ -32,6 +32,12 @@ An oracle job:
32
32
  5. persists the response and any artifacts under `/tmp/oracle-<job-id>/`
33
33
  6. wakes the originating `pi` session on completion
34
34
 
35
+ ## Example
36
+
37
+ ```text
38
+ /oracle Invoke the Oracle to have it generate a thorough code review of the current pending changes. Include all modified files, and adjacent files, in the archive. Use the Pro Model with Extended effort.
39
+ ```
40
+
35
41
  ## Why this exists
36
42
 
37
43
  The goal is to get strong ChatGPT web-model answers without:
@@ -6,7 +6,7 @@ import { loadOracleConfig } from "./config.js";
6
6
  import { buildOracleDispatchPrompt } from "./instructions.js";
7
7
  import { cancelOracleJob, isActiveOracleJob, listJobsForCwd, readJob, reconcileStaleOracleJobs } from "./jobs.js";
8
8
  import { refreshOracleStatus } from "./poller.js";
9
- import { withGlobalReconcileLock } from "./locks.js";
9
+ import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
10
10
  import { getProjectId } from "./runtime.js";
11
11
 
12
12
  function summarizeJob(jobId: string): string {
@@ -45,9 +45,13 @@ function readScopedJob(jobId: string, cwd: string) {
45
45
 
46
46
  async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<string> {
47
47
  const config = loadOracleConfig(cwd);
48
- await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
49
- await reconcileStaleOracleJobs();
50
- });
48
+ try {
49
+ await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
50
+ await reconcileStaleOracleJobs();
51
+ });
52
+ } catch (error) {
53
+ if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
54
+ }
51
55
 
52
56
  return await new Promise<string>((resolve, reject) => {
53
57
  const child = spawn(process.execPath, [authWorkerPath, JSON.stringify(config)], {
@@ -16,6 +16,8 @@ export function buildOracleDispatchPrompt(request: string): string {
16
16
  "- Always include an archive. Do not submit without context files.",
17
17
  "- Keep the archive narrowly scoped and relevant.",
18
18
  "- Prefer the configured default model/effort unless the task clearly needs something else.",
19
+ "- Only use autoSwitchToThinking with the instant model family.",
20
+ "- If oracle_submit fails, stop and report the error. Do not retry automatically.",
19
21
  "- After oracle_submit returns, end your turn. Do not keep working while the oracle runs.",
20
22
  "",
21
23
  "User request:",
@@ -53,6 +53,36 @@ async function writeMetadata(path: string, metadata: unknown): Promise<void> {
53
53
  await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
54
54
  }
55
55
 
56
+ function readLockProcessPid(path: string): number | undefined {
57
+ const metadataPath = join(path, "metadata.json");
58
+ if (!existsSync(metadataPath)) return undefined;
59
+ try {
60
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as { processPid?: unknown };
61
+ return typeof metadata.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
62
+ ? metadata.processPid
63
+ : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function isProcessAlive(pid: number): boolean {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (error) {
74
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
75
+ return true;
76
+ }
77
+ }
78
+
79
+ async function maybeReclaimStaleLock(path: string): Promise<boolean> {
80
+ const processPid = readLockProcessPid(path);
81
+ if (!processPid || isProcessAlive(processPid)) return false;
82
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
83
+ return true;
84
+ }
85
+
56
86
  export async function acquireLock(
57
87
  kind: string,
58
88
  key: string,
@@ -70,6 +100,7 @@ export async function acquireLock(
70
100
  return { path };
71
101
  } catch (error) {
72
102
  if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
103
+ if (await maybeReclaimStaleLock(path)) continue;
73
104
  }
74
105
  await sleep(POLL_MS);
75
106
  }
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { StringEnum } from "@mariozechner/pi-ai";
6
6
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
7
  import { Type } from "@sinclair/typebox";
8
- import { withGlobalReconcileLock, withLock } from "./locks.js";
8
+ import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
9
9
  import { loadOracleConfig, EFFORTS, MODEL_FAMILIES, type OracleEffort, type OracleModelFamily } from "./config.js";
10
10
  import {
11
11
  cancelOracleJob,
@@ -37,9 +37,11 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
37
37
  description: "Exact project-relative files/directories to include in the oracle archive.",
38
38
  minItems: 1,
39
39
  }),
40
- modelFamily: Type.Optional(StringEnum(MODEL_FAMILIES)),
41
- effort: Type.Optional(StringEnum(EFFORTS)),
42
- autoSwitchToThinking: Type.Optional(Type.Boolean()),
40
+ modelFamily: Type.Optional(StringEnum(MODEL_FAMILIES, { description: "ChatGPT model family: instant, thinking, or pro." })),
41
+ effort: Type.Optional(StringEnum(EFFORTS, { description: "Reasoning effort. Use only values supported by the chosen model family." })),
42
+ autoSwitchToThinking: Type.Optional(
43
+ Type.Boolean({ description: "Only valid when modelFamily is instant. Omit for thinking and pro." }),
44
+ ),
43
45
  followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
44
46
  });
45
47
 
@@ -209,6 +211,8 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
209
211
  "Gather context before calling oracle_submit.",
210
212
  "Always include a narrowly scoped archive of exact relevant files/directories.",
211
213
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
214
+ "If oracle_submit fails, stop and report the error instead of retrying automatically.",
215
+ "Only use autoSwitchToThinking with modelFamily=instant.",
212
216
  ],
213
217
  parameters: ORACLE_SUBMIT_PARAMS,
214
218
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -224,9 +228,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
224
228
  const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
225
229
 
226
230
  validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
227
- await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
228
- await reconcileStaleOracleJobs();
229
- });
231
+ try {
232
+ await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
233
+ await reconcileStaleOracleJobs();
234
+ });
235
+ } catch (error) {
236
+ if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
237
+ }
230
238
 
231
239
  const jobId = randomUUID();
232
240
  const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { appendFile, chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { appendFile, chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { getCookies } from "@steipete/sweet-cookie";
@@ -49,6 +49,36 @@ function leaseKey(kind, key) {
49
49
  return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
50
50
  }
51
51
 
52
+ async function readLockProcessPid(path) {
53
+ const metadataPath = join(path, "metadata.json");
54
+ if (!existsSync(metadataPath)) return undefined;
55
+ try {
56
+ const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
57
+ return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
58
+ ? metadata.processPid
59
+ : undefined;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ function isProcessAlive(pid) {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (error) {
70
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
71
+ return true;
72
+ }
73
+ }
74
+
75
+ async function maybeReclaimStaleLock(path) {
76
+ const processPid = await readLockProcessPid(path);
77
+ if (!processPid || isProcessAlive(processPid)) return false;
78
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
79
+ return true;
80
+ }
81
+
52
82
  async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
53
83
  const path = join(LOCKS_DIR, leaseKey(kind, key));
54
84
  const deadline = Date.now() + timeoutMs;
@@ -62,6 +92,7 @@ async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
62
92
  return path;
63
93
  } catch (error) {
64
94
  if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
95
+ if (await maybeReclaimStaleLock(path)) continue;
65
96
  }
66
97
  await sleep(200);
67
98
  }
@@ -53,6 +53,36 @@ function leaseKey(kind, key) {
53
53
  return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
54
54
  }
55
55
 
56
+ async function readLockProcessPid(path) {
57
+ const metadataPath = join(path, "metadata.json");
58
+ if (!existsSync(metadataPath)) return undefined;
59
+ try {
60
+ const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
61
+ return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
62
+ ? metadata.processPid
63
+ : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function isProcessAlive(pid) {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (error) {
74
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
75
+ return true;
76
+ }
77
+ }
78
+
79
+ async function maybeReclaimStaleLock(path) {
80
+ const processPid = await readLockProcessPid(path);
81
+ if (!processPid || isProcessAlive(processPid)) return false;
82
+ await rm(path, { recursive: true, force: true }).catch(() => undefined);
83
+ return true;
84
+ }
85
+
56
86
  async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
57
87
  const path = join(LOCKS_DIR, leaseKey(kind, key));
58
88
  const deadline = Date.now() + timeoutMs;
@@ -66,6 +96,7 @@ async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
66
96
  return path;
67
97
  } catch (error) {
68
98
  if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
99
+ if (await maybeReclaimStaleLock(path)) continue;
69
100
  }
70
101
  await sleep(200);
71
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",