pi-oracle 0.1.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.
@@ -0,0 +1,156 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
3
+ import {
4
+ getSessionFile,
5
+ getStaleOracleJobReason,
6
+ isActiveOracleJob,
7
+ listOracleJobDirs,
8
+ markJobNotified,
9
+ readJob,
10
+ reconcileStaleOracleJobs,
11
+ releaseNotificationClaim,
12
+ tryClaimNotification,
13
+ } from "./jobs.js";
14
+ import { getProjectId, getSessionId } from "./runtime.js";
15
+
16
+ const activePollers = new Map<string, NodeJS.Timeout>();
17
+ const scansInFlight = new Set<string>();
18
+ const POLLER_LOCK_TIMEOUT_MS = 50;
19
+
20
+ export function getPollerSessionKey(sessionFile: string | undefined, cwd: string): string {
21
+ const projectId = getProjectId(cwd);
22
+ const sessionId = getSessionId(sessionFile, projectId);
23
+ return `${projectId}::${sessionId}`;
24
+ }
25
+
26
+ function jobMatchesContext(job: { projectId: string; sessionId: string }, sessionFile: string | undefined, cwd: string): boolean {
27
+ const projectId = getProjectId(cwd);
28
+ const sessionId = getSessionId(sessionFile, projectId);
29
+ return job.projectId === projectId && job.sessionId === sessionId;
30
+ }
31
+
32
+ function getActiveJobCount(ctx: ExtensionContext): number {
33
+ const currentSessionFile = getSessionFile(ctx);
34
+ return listOracleJobDirs()
35
+ .map((jobDir) => readJob(jobDir))
36
+ .filter((job): job is NonNullable<typeof job> => Boolean(job))
37
+ .filter((job) => {
38
+ if (!isActiveOracleJob(job)) return false;
39
+ if (getStaleOracleJobReason(job)) return false;
40
+ return jobMatchesContext(job, currentSessionFile, ctx.cwd);
41
+ }).length;
42
+ }
43
+
44
+ export function refreshOracleStatus(ctx: ExtensionContext): void {
45
+ const activeJobCount = getActiveJobCount(ctx);
46
+ if (activeJobCount > 0) {
47
+ const suffix = activeJobCount > 1 ? ` (${activeJobCount})` : "";
48
+ ctx.ui.setStatus("oracle", ctx.ui.theme.fg("success", `oracle: running${suffix}`));
49
+ return;
50
+ }
51
+
52
+ ctx.ui.setStatus("oracle", ctx.ui.theme.fg("accent", "oracle: ready"));
53
+ }
54
+
55
+ function notifyForJob(pi: ExtensionAPI, job: NonNullable<ReturnType<typeof readJob>>): void {
56
+ const responsePath = job.responsePath || `${job.id}/response.md`;
57
+ const artifactsPath = `/tmp/oracle-${job.id}/artifacts`;
58
+ pi.sendMessage(
59
+ {
60
+ customType: "oracle-job-complete",
61
+ display: true,
62
+ content: [
63
+ `Oracle job ${job.id} is ${job.status}.`,
64
+ `Read response: ${responsePath}`,
65
+ `Artifacts: ${artifactsPath}`,
66
+ job.error ? `Error: ${job.error}` : "Continue from the oracle output.",
67
+ ].join("\n"),
68
+ details: { jobId: job.id, status: job.status },
69
+ },
70
+ { triggerTurn: true },
71
+ );
72
+ }
73
+
74
+ async function scan(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
75
+ const currentSessionFile = getSessionFile(ctx);
76
+ const pollerKey = getPollerSessionKey(currentSessionFile, ctx.cwd);
77
+ const notificationClaimant = `${pollerKey}:${process.pid}`;
78
+
79
+ try {
80
+ await withGlobalReconcileLock(
81
+ { processPid: process.pid, cwd: ctx.cwd, sessionFile: currentSessionFile, source: "poller" },
82
+ async () => {
83
+ await reconcileStaleOracleJobs();
84
+ },
85
+ { timeoutMs: POLLER_LOCK_TIMEOUT_MS },
86
+ );
87
+ } catch (error) {
88
+ if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
89
+ }
90
+
91
+ const candidateJobIds = listOracleJobDirs()
92
+ .map((jobDir) => readJob(jobDir))
93
+ .filter((job): job is NonNullable<typeof job> => Boolean(job))
94
+ .filter((job) => {
95
+ if (job.status !== "complete" && job.status !== "failed" && job.status !== "cancelled") return false;
96
+ if (!jobMatchesContext(job, currentSessionFile, ctx.cwd)) return false;
97
+ return !job.notifiedAt;
98
+ })
99
+ .map((job) => job.id);
100
+
101
+ for (const jobId of candidateJobIds) {
102
+ const claimed = await tryClaimNotification(jobId, notificationClaimant);
103
+ if (!claimed) continue;
104
+ if (!jobMatchesContext(claimed, currentSessionFile, ctx.cwd)) {
105
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
106
+ continue;
107
+ }
108
+
109
+ try {
110
+ notifyForJob(pi, claimed);
111
+ await markJobNotified(jobId, notificationClaimant);
112
+ } catch (error) {
113
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
114
+ throw error;
115
+ }
116
+ }
117
+ }
118
+
119
+ export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs: number): void {
120
+ const sessionKey = getPollerSessionKey(getSessionFile(ctx), ctx.cwd);
121
+ const existing = activePollers.get(sessionKey);
122
+ if (existing) clearInterval(existing);
123
+
124
+ const runScan = async () => {
125
+ if (scansInFlight.has(sessionKey)) return;
126
+ scansInFlight.add(sessionKey);
127
+ try {
128
+ await scan(pi, ctx);
129
+ } catch (error) {
130
+ console.error(`Oracle poller scan failed (${sessionKey}):`, error);
131
+ } finally {
132
+ scansInFlight.delete(sessionKey);
133
+ refreshOracleStatus(ctx);
134
+ }
135
+ };
136
+
137
+ refreshOracleStatus(ctx);
138
+ void runScan();
139
+ const timer = setInterval(() => {
140
+ void runScan();
141
+ }, intervalMs);
142
+ activePollers.set(sessionKey, timer);
143
+ }
144
+
145
+ export function stopPollerForSession(sessionFile: string | undefined, cwd: string): void {
146
+ const sessionKey = getPollerSessionKey(sessionFile, cwd);
147
+ const timer = activePollers.get(sessionKey);
148
+ if (!timer) return;
149
+ clearInterval(timer);
150
+ activePollers.delete(sessionKey);
151
+ scansInFlight.delete(sessionKey);
152
+ }
153
+
154
+ export function stopPoller(ctx: ExtensionContext): void {
155
+ stopPollerForSession(getSessionFile(ctx), ctx.cwd);
156
+ }
@@ -0,0 +1,197 @@
1
+ import { randomUUID, createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, realpathSync, readFileSync } from "node:fs";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, join } from "node:path";
6
+ import type { OracleConfig } from "./config.js";
7
+ import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withAuthLock } from "./locks.js";
8
+
9
+ const SEED_GENERATION_FILE = ".oracle-seed-generation";
10
+
11
+ export interface OracleRuntimeLeaseMetadata {
12
+ jobId: string;
13
+ runtimeId: string;
14
+ runtimeSessionName: string;
15
+ runtimeProfileDir: string;
16
+ projectId: string;
17
+ sessionId: string;
18
+ createdAt: string;
19
+ }
20
+
21
+ export interface OracleConversationLeaseMetadata {
22
+ jobId: string;
23
+ conversationId: string;
24
+ projectId: string;
25
+ sessionId: string;
26
+ createdAt: string;
27
+ }
28
+
29
+ export function getProjectId(cwd: string): string {
30
+ try {
31
+ return realpathSync(cwd);
32
+ } catch {
33
+ return cwd;
34
+ }
35
+ }
36
+
37
+ export function getSessionId(originSessionFile: string | undefined, projectId: string): string {
38
+ return originSessionFile || `ephemeral:${projectId}`;
39
+ }
40
+
41
+ export function parseConversationId(chatUrl: string | undefined): string | undefined {
42
+ if (!chatUrl) return undefined;
43
+ try {
44
+ const parsed = new URL(chatUrl);
45
+ const match = parsed.pathname.match(/\/c\/([^/?#]+)/i);
46
+ return match?.[1];
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ export function allocateRuntime(config: OracleConfig): { runtimeId: string; runtimeSessionName: string; runtimeProfileDir: string } {
53
+ const runtimeId = randomUUID();
54
+ return {
55
+ runtimeId,
56
+ runtimeSessionName: `${config.browser.sessionPrefix}-${runtimeId}`,
57
+ runtimeProfileDir: join(config.browser.runtimeProfilesDir, runtimeId),
58
+ };
59
+ }
60
+
61
+ export function authSessionName(config: OracleConfig): string {
62
+ return `${config.browser.sessionPrefix}-auth`;
63
+ }
64
+
65
+ export function getSeedGeneration(config: OracleConfig): string | undefined {
66
+ const path = join(config.browser.authSeedProfileDir, SEED_GENERATION_FILE);
67
+ if (!existsSync(path)) return undefined;
68
+ try {
69
+ const value = readFileSync(path, "utf8").trim();
70
+ return value || undefined;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ export async function writeSeedGeneration(config: OracleConfig, value = new Date().toISOString()): Promise<string> {
77
+ await mkdir(config.browser.authSeedProfileDir, { recursive: true, mode: 0o700 });
78
+ await writeFile(join(config.browser.authSeedProfileDir, SEED_GENERATION_FILE), `${value}\n`, { encoding: "utf8", mode: 0o600 });
79
+ return value;
80
+ }
81
+
82
+ function activeJobExists(jobId: string): boolean {
83
+ const path = join("/tmp", `oracle-${jobId}`, "job.json");
84
+ if (!existsSync(path)) return false;
85
+ try {
86
+ const job = JSON.parse(readFileSync(path, "utf8")) as { status?: string };
87
+ return ["preparing", "submitted", "waiting"].includes(job.status || "");
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export async function acquireRuntimeLease(config: OracleConfig, metadata: OracleRuntimeLeaseMetadata): Promise<void> {
94
+ const existing = listLeaseMetadata<OracleRuntimeLeaseMetadata>("runtime");
95
+ const liveLeases: OracleRuntimeLeaseMetadata[] = [];
96
+ for (const lease of existing) {
97
+ if (!activeJobExists(lease.jobId)) {
98
+ await releaseLease("runtime", lease.runtimeId).catch(() => undefined);
99
+ continue;
100
+ }
101
+ liveLeases.push(lease);
102
+ }
103
+ if (liveLeases.length >= config.browser.maxConcurrentJobs) {
104
+ const blocker = liveLeases[0];
105
+ throw new Error(
106
+ `Oracle is busy (${liveLeases.length}/${config.browser.maxConcurrentJobs} active). ` +
107
+ `Blocking job ${blocker?.jobId ?? "unknown"} in project ${blocker?.projectId ?? "unknown"}.`,
108
+ );
109
+ }
110
+ await createLease("runtime", metadata.runtimeId, metadata);
111
+ }
112
+
113
+ export async function releaseRuntimeLease(runtimeId: string | undefined): Promise<void> {
114
+ if (!runtimeId) return;
115
+ await releaseLease("runtime", runtimeId);
116
+ }
117
+
118
+ export async function acquireConversationLease(metadata: OracleConversationLeaseMetadata): Promise<void> {
119
+ const existing = await readLeaseMetadata<OracleConversationLeaseMetadata>("conversation", metadata.conversationId);
120
+ if (existing && existing.jobId !== metadata.jobId) {
121
+ if (!activeJobExists(existing.jobId)) {
122
+ await releaseLease("conversation", metadata.conversationId).catch(() => undefined);
123
+ } else {
124
+ throw new Error(
125
+ `Oracle conversation ${metadata.conversationId} is already in use by job ${existing.jobId}. ` +
126
+ `Concurrent follow-ups to the same ChatGPT thread are not allowed.`,
127
+ );
128
+ }
129
+ }
130
+ await createLease("conversation", metadata.conversationId, metadata);
131
+ }
132
+
133
+ export async function releaseConversationLease(conversationId: string | undefined): Promise<void> {
134
+ if (!conversationId) return;
135
+ await releaseLease("conversation", conversationId);
136
+ }
137
+
138
+ function profileCloneArgs(config: OracleConfig, sourceDir: string, destinationDir: string): string[] {
139
+ if (config.browser.cloneStrategy === "apfs-clone") {
140
+ return ["-cR", sourceDir, destinationDir];
141
+ }
142
+ return ["-R", sourceDir, destinationDir];
143
+ }
144
+
145
+ async function spawnCp(args: string[]): Promise<void> {
146
+ await new Promise<void>((resolve, reject) => {
147
+ const child = spawn("cp", args, { stdio: ["ignore", "pipe", "pipe"] });
148
+ let stderr = "";
149
+ child.stderr.on("data", (data) => {
150
+ stderr += String(data);
151
+ });
152
+ child.on("error", reject);
153
+ child.on("close", (code) => {
154
+ if (code === 0) resolve();
155
+ else reject(new Error(stderr || `cp exited with code ${code}`));
156
+ });
157
+ });
158
+ }
159
+
160
+ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimeProfileDir: string): Promise<string | undefined> {
161
+ const seedDir = config.browser.authSeedProfileDir;
162
+ if (!existsSync(seedDir)) {
163
+ throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
164
+ }
165
+
166
+ await withAuthLock({ runtimeProfileDir, seedDir }, async () => {
167
+ await rm(runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
168
+ await mkdir(dirname(runtimeProfileDir), { recursive: true, mode: 0o700 }).catch(() => undefined);
169
+ await spawnCp(profileCloneArgs(config, seedDir, runtimeProfileDir));
170
+ });
171
+
172
+ return getSeedGeneration(config);
173
+ }
174
+
175
+ export async function cleanupRuntimeArtifacts(runtime: {
176
+ runtimeId?: string;
177
+ runtimeProfileDir?: string;
178
+ runtimeSessionName?: string;
179
+ conversationId?: string;
180
+ }): Promise<void> {
181
+ if (runtime.runtimeSessionName) {
182
+ await new Promise<void>((resolve) => {
183
+ const child = spawn("agent-browser", ["--session", runtime.runtimeSessionName, "close"], { stdio: "ignore" });
184
+ child.on("error", () => resolve());
185
+ child.on("close", () => resolve());
186
+ });
187
+ }
188
+ if (runtime.runtimeProfileDir) {
189
+ await rm(runtime.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
190
+ }
191
+ await releaseConversationLease(runtime.conversationId);
192
+ await releaseRuntimeLease(runtime.runtimeId);
193
+ }
194
+
195
+ export function stableProjectLabel(projectId: string): string {
196
+ return basename(projectId) || createHash("sha256").update(projectId).digest("hex").slice(0, 8);
197
+ }