pi-oracle 0.4.0 → 0.6.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.
@@ -7,14 +7,16 @@ import { randomUUID } from "node:crypto";
7
7
  import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import { basename, join, posix } from "node:path";
10
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { runOracleAuthBootstrap } from "./auth.js";
11
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
12
  import { Type } from "@sinclair/typebox";
12
13
  import { formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
13
- import { transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
14
+ import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
14
15
  import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
15
16
  import {
16
17
  coerceOracleSubmitPresetId,
17
18
  loadOracleConfig,
19
+ ORACLE_SUBMIT_PRESET_IDS,
18
20
  resolveOracleSubmitPreset,
19
21
  } from "./config.js";
20
22
  import {
@@ -44,9 +46,11 @@ import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLo
44
46
  import { refreshOracleStatus } from "./poller.js";
45
47
  import {
46
48
  allocateRuntime,
49
+ assertOracleSubmitPrerequisites,
47
50
  cleanupRuntimeArtifacts,
48
51
  getProjectId,
49
52
  getSessionId,
53
+ hasPersistedSessionFile,
50
54
  parseConversationId,
51
55
  requirePersistedSessionFile,
52
56
  tryAcquireConversationLease,
@@ -55,7 +59,11 @@ import {
55
59
 
56
60
  const ORACLE_SUBMIT_PARAMS = Type.Object({
57
61
  prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
58
- files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
62
+ files: Type.Array(Type.String({
63
+ description: "Project-relative file or directory path to include in the archive.",
64
+ minLength: 1,
65
+ pattern: ".*\\S.*",
66
+ }), {
59
67
  description: "Exact project-relative files/directories to include in the oracle archive.",
60
68
  minItems: 1,
61
69
  }),
@@ -565,7 +573,54 @@ function resolveFollowUp(previousJobId: string | undefined, cwd: string): {
565
573
  };
566
574
  }
567
575
 
568
- function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
576
+ type OracleToolName = "oracle_auth" | "oracle_submit" | "oracle_read" | "oracle_cancel";
577
+ type OracleToolErrorSource = OracleToolName | "oracle_preflight";
578
+ type OracleQueueSnapshot = { queued: boolean; position?: number; depth?: number };
579
+ type OracleToolErrorDetails = {
580
+ code: string;
581
+ message: string;
582
+ rejectedValue?: string;
583
+ allowedValues?: string[];
584
+ suggestedNextStep?: string;
585
+ };
586
+ type OracleToolJobDetailsOptions = {
587
+ queue?: OracleQueueSnapshot;
588
+ archiveBytes?: number;
589
+ initialArchiveBytes?: number;
590
+ autoPrunedArchivePaths?: ArchiveSizeBreakdownRow[];
591
+ responsePreview?: string;
592
+ responseAvailable?: boolean;
593
+ };
594
+
595
+ const ORACLE_TOOL_NAMES = new Set<OracleToolName>(["oracle_auth", "oracle_submit", "oracle_read", "oracle_cancel"]);
596
+
597
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
598
+ return typeof value === "object" && value !== null && !Array.isArray(value)
599
+ ? value as Record<string, unknown>
600
+ : undefined;
601
+ }
602
+
603
+ function getErrorMessage(error: unknown): string {
604
+ return error instanceof Error ? error.message : String(error);
605
+ }
606
+
607
+ function buildOracleQueueSnapshot(
608
+ job: NonNullable<ReturnType<typeof readJob>>,
609
+ queuePosition?: { position: number; depth: number },
610
+ ): OracleQueueSnapshot {
611
+ return {
612
+ queued: job.status === "queued",
613
+ position: queuePosition?.position,
614
+ depth: queuePosition?.depth,
615
+ };
616
+ }
617
+
618
+ function redactJobDetails(
619
+ job: NonNullable<ReturnType<typeof readJob>>,
620
+ options: OracleToolJobDetailsOptions = {},
621
+ ) {
622
+ const lastEvent = getLatestOracleJobLifecycleEvent(job);
623
+ const terminalEvent = getLatestOracleTerminalLifecycleEvent(job);
569
624
  return {
570
625
  id: job.id,
571
626
  status: job.status,
@@ -576,24 +631,416 @@ function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
576
631
  queuedAt: job.queuedAt,
577
632
  submittedAt: job.submittedAt,
578
633
  completedAt: job.completedAt,
634
+ promptPath: job.promptPath,
635
+ archivePath: job.archivePath,
636
+ archiveSha256: job.archiveSha256,
637
+ archiveBytes: options.archiveBytes,
638
+ initialArchiveBytes: options.initialArchiveBytes,
639
+ autoPrunedArchivePaths: options.autoPrunedArchivePaths ?? [],
640
+ queue: options.queue ?? buildOracleQueueSnapshot(job),
579
641
  followUpToJobId: job.followUpToJobId,
580
642
  chatUrl: job.chatUrl,
581
643
  conversationId: job.conversationId,
582
644
  responsePath: job.responsePath,
583
645
  responseFormat: job.responseFormat,
646
+ responseAvailable: options.responseAvailable ?? false,
647
+ responsePreview: options.responsePreview,
648
+ artifactsPath: `${getJobDir(job.id)}/artifacts`,
584
649
  artifactPaths: job.artifactPaths,
585
650
  artifactFailureCount: job.artifactFailureCount,
586
651
  artifactsManifestPath: job.artifactsManifestPath,
652
+ workerLogPath: job.workerLogPath,
587
653
  archiveDeletedAfterUpload: job.archiveDeletedAfterUpload,
588
654
  runtimeId: job.runtimeId,
589
655
  cleanupWarnings: job.cleanupWarnings,
590
656
  lastCleanupAt: job.lastCleanupAt,
657
+ terminalEvent: terminalEvent ? { ...terminalEvent } : undefined,
658
+ lastEvent: lastEvent ? { ...lastEvent } : undefined,
591
659
  error: job.error,
592
660
  lifecycleEvents: job.lifecycleEvents,
593
661
  };
594
662
  }
595
663
 
596
- export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
664
+ function buildOracleToolErrorDetails(toolName: OracleToolErrorSource, error: unknown, params: Record<string, unknown>): OracleToolErrorDetails {
665
+ const message = getErrorMessage(error);
666
+
667
+ if (toolName === "oracle_submit" && typeof params.preset === "string" && message.startsWith("Unknown oracle_submit preset:")) {
668
+ return {
669
+ code: "invalid_preset",
670
+ message,
671
+ rejectedValue: params.preset,
672
+ allowedValues: [...ORACLE_SUBMIT_PRESET_IDS],
673
+ suggestedNextStep: "Retry with one of the canonical preset ids, or omit preset to use the configured default.",
674
+ };
675
+ }
676
+
677
+ if (message.startsWith("Oracle requires a persisted pi session")) {
678
+ return {
679
+ code: "persisted_session_required",
680
+ message,
681
+ suggestedNextStep: "Start or save a persisted pi session, then retry oracle_submit.",
682
+ };
683
+ }
684
+
685
+ if (message.startsWith("Oracle auth seed profile not found: ")) {
686
+ return {
687
+ code: "auth_seed_profile_missing",
688
+ message,
689
+ rejectedValue: message.replace(/^Oracle auth seed profile not found: /, "").replace(/\. Run \/oracle-auth first\.$/, ""),
690
+ suggestedNextStep: "Call oracle_auth or run /oracle-auth once, then retry the oracle tool call.",
691
+ };
692
+ }
693
+
694
+ if (message.startsWith("Oracle auth seed profile is not readable: ")) {
695
+ return {
696
+ code: "auth_seed_profile_unreadable",
697
+ message,
698
+ rejectedValue: message.replace(/^Oracle auth seed profile is not readable: /, "").replace(/\. Fix its permissions or rerun \/oracle-auth\.$/, ""),
699
+ suggestedNextStep: "Fix the auth seed profile permissions or call oracle_auth / rerun /oracle-auth once, then retry.",
700
+ };
701
+ }
702
+
703
+ if (message.startsWith("Oracle auth seed profile is not a directory: ")) {
704
+ return {
705
+ code: "auth_seed_profile_invalid_type",
706
+ message,
707
+ rejectedValue: message.replace(/^Oracle auth seed profile is not a directory: /, "").replace(/\. Remove the invalid path or rerun \/oracle-auth\.$/, ""),
708
+ suggestedNextStep: "Remove the invalid auth seed path or call oracle_auth / rerun /oracle-auth once to recreate it.",
709
+ };
710
+ }
711
+
712
+ if (message.startsWith("Failed to parse oracle config ") || message.startsWith("Invalid oracle config:") || message.startsWith("Invalid oracle project config:")) {
713
+ return {
714
+ code: "oracle_config_invalid",
715
+ message,
716
+ suggestedNextStep: "Fix the oracle config and retry once the configured paths and values are valid.",
717
+ };
718
+ }
719
+
720
+ if (message.startsWith("Configured oracle browser executable does not exist: ")) {
721
+ return {
722
+ code: "browser_executable_missing",
723
+ message,
724
+ rejectedValue: message.replace(/^Configured oracle browser executable does not exist: /, "").replace(/\. Fix browser\.executablePath or install Chrome there\.$/, ""),
725
+ suggestedNextStep: "Fix browser.executablePath or install Chrome at that path, then retry.",
726
+ };
727
+ }
728
+
729
+ if (message.startsWith("Configured oracle browser executable is not executable: ")) {
730
+ return {
731
+ code: "browser_executable_not_executable",
732
+ message,
733
+ rejectedValue: message.replace(/^Configured oracle browser executable is not executable: /, "").replace(/\. Fix browser\.executablePath permissions or point it at a runnable Chrome binary\.$/, ""),
734
+ suggestedNextStep: "Fix browser.executablePath permissions or point it at a runnable Chrome binary, then retry.",
735
+ };
736
+ }
737
+
738
+ if (message.startsWith("Oracle prerequisite not found on PATH: ")) {
739
+ const rejectedValue = message.replace(/^Oracle prerequisite not found on PATH: /, "").replace(/\. Install .*$/, "");
740
+ return {
741
+ code: "local_dependency_missing",
742
+ message,
743
+ rejectedValue,
744
+ suggestedNextStep: `Install ${rejectedValue || "the missing dependency"} and retry.`,
745
+ };
746
+ }
747
+
748
+ if (message.startsWith("Oracle runtime profiles directory is not writable: ")) {
749
+ return {
750
+ code: "runtime_profiles_dir_unwritable",
751
+ message,
752
+ rejectedValue: message.replace(/^Oracle runtime profiles directory is not writable: /, "").replace(/\. Fix its permissions or configure a writable path, then retry\.$/, ""),
753
+ suggestedNextStep: "Fix browser.runtimeProfilesDir permissions or configure a writable directory, then retry.",
754
+ };
755
+ }
756
+
757
+ if (message.startsWith("Oracle jobs directory is not writable: ")) {
758
+ return {
759
+ code: "jobs_dir_unwritable",
760
+ message,
761
+ rejectedValue: message.replace(/^Oracle jobs directory is not writable: /, "").replace(/\. Fix its permissions or configure a writable path, then retry\.$/, ""),
762
+ suggestedNextStep: "Fix PI_ORACLE_JOBS_DIR permissions or point it at a writable directory, then retry.",
763
+ };
764
+ }
765
+
766
+ if (toolName === "oracle_submit" && message === "oracle_submit requires at least one file or directory to archive") {
767
+ return {
768
+ code: "archive_input_required",
769
+ message,
770
+ suggestedNextStep: "Pass at least one project-relative file or directory in files.",
771
+ };
772
+ }
773
+
774
+ if (toolName === "oracle_submit" && message === "Archive input must be a non-empty project-relative path") {
775
+ return {
776
+ code: "archive_input_blank",
777
+ message,
778
+ suggestedNextStep: "Retry with a non-empty project-relative file or directory path. Use '.' only when you intentionally want a whole-repo archive.",
779
+ };
780
+ }
781
+
782
+ if (toolName === "oracle_submit" && message === "Archive input must use '.' exactly for a whole-repo archive") {
783
+ return {
784
+ code: "archive_input_whole_repo_sentinel_invalid",
785
+ message,
786
+ suggestedNextStep: "If you want a whole-repo archive, pass '.' exactly. Otherwise pass an exact project-relative path without extra padding.",
787
+ };
788
+ }
789
+
790
+ if (toolName === "oracle_submit" && message.startsWith("Archive input does not exist: ")) {
791
+ return {
792
+ code: "archive_input_missing",
793
+ message,
794
+ rejectedValue: message.replace(/^Archive input does not exist: /, ""),
795
+ suggestedNextStep: "Retry with an existing project-relative file or directory.",
796
+ };
797
+ }
798
+
799
+ if (toolName === "oracle_submit" && message.startsWith("Archive input must be inside the project cwd: ")) {
800
+ return {
801
+ code: "archive_input_outside_project",
802
+ message,
803
+ rejectedValue: message.replace(/^Archive input must be inside the project cwd: /, ""),
804
+ suggestedNextStep: "Retry with a path inside the current project cwd.",
805
+ };
806
+ }
807
+
808
+ if (toolName === "oracle_submit" && message.startsWith("Archive input must resolve inside the project cwd without symlink escapes: ")) {
809
+ return {
810
+ code: "archive_input_symlink_escape",
811
+ message,
812
+ rejectedValue: message.replace(/^Archive input must resolve inside the project cwd without symlink escapes: /, ""),
813
+ suggestedNextStep: "Retry with a real project-relative path that does not escape the repo through symlinks.",
814
+ };
815
+ }
816
+
817
+ if (toolName === "oracle_submit" && message.startsWith("Follow-up oracle job not found: ")) {
818
+ return {
819
+ code: "follow_up_job_not_found",
820
+ message,
821
+ rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
822
+ suggestedNextStep: "Retry with a completed oracle job id from this project that has a persisted chat URL.",
823
+ };
824
+ }
825
+
826
+ if (toolName === "oracle_submit" && message.includes("belongs to a different project")) {
827
+ return {
828
+ code: "follow_up_job_wrong_project",
829
+ message,
830
+ rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
831
+ suggestedNextStep: "Retry with a follow-up job id from the current project.",
832
+ };
833
+ }
834
+
835
+ if (toolName === "oracle_submit" && message.includes("is not complete")) {
836
+ return {
837
+ code: "follow_up_job_not_complete",
838
+ message,
839
+ rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
840
+ suggestedNextStep: "Wait for the earlier oracle job to finish, then retry the follow-up.",
841
+ };
842
+ }
843
+
844
+ if (toolName === "oracle_submit" && message.includes("has no persisted chat URL")) {
845
+ return {
846
+ code: "follow_up_job_missing_chat_url",
847
+ message,
848
+ rejectedValue: typeof params.followUpJobId === "string" ? params.followUpJobId : undefined,
849
+ suggestedNextStep: "Retry with an earlier completed oracle job that recorded a chat URL.",
850
+ };
851
+ }
852
+
853
+ if ((toolName === "oracle_read" || toolName === "oracle_cancel") && typeof params.jobId === "string" && message.startsWith("Oracle job not found in this project:")) {
854
+ return {
855
+ code: "job_not_found",
856
+ message,
857
+ rejectedValue: params.jobId,
858
+ suggestedNextStep: "Use /oracle-status to discover a valid job id for this project, then retry.",
859
+ };
860
+ }
861
+
862
+ if (toolName === "oracle_submit" && message.startsWith("Oracle archive exceeds ChatGPT upload limit")) {
863
+ return {
864
+ code: "archive_too_large",
865
+ message,
866
+ suggestedNextStep: "Retry with a narrower archive, starting with modified files plus adjacent files plus directly relevant subtrees.",
867
+ };
868
+ }
869
+
870
+ return {
871
+ code: `${toolName}_failed`,
872
+ message,
873
+ suggestedNextStep: "Inspect the error message, correct the inputs or environment, and retry.",
874
+ };
875
+ }
876
+
877
+ function buildOracleToolErrorResult(
878
+ toolName: OracleToolName,
879
+ error: unknown,
880
+ params: Record<string, unknown>,
881
+ options?: { job?: NonNullable<ReturnType<typeof readJob>>; jobDetails?: OracleToolJobDetailsOptions },
882
+ ) {
883
+ const errorDetails = buildOracleToolErrorDetails(toolName, error, params);
884
+ return {
885
+ content: [{
886
+ type: "text" as const,
887
+ text: [
888
+ errorDetails.message,
889
+ errorDetails.suggestedNextStep ? `Suggested next step: ${errorDetails.suggestedNextStep}` : undefined,
890
+ ].filter(Boolean).join("\n"),
891
+ }],
892
+ details: {
893
+ job: options?.job ? redactJobDetails(options.job, options.jobDetails) : undefined,
894
+ error: errorDetails,
895
+ },
896
+ };
897
+ }
898
+
899
+ type OraclePreflightDetails = {
900
+ ready: boolean;
901
+ session: {
902
+ persisted: boolean;
903
+ sessionFile?: string;
904
+ };
905
+ config: {
906
+ ready: boolean;
907
+ };
908
+ auth: {
909
+ ready: boolean;
910
+ seedProfileDir?: string;
911
+ };
912
+ error?: OracleToolErrorDetails;
913
+ };
914
+
915
+ function formatOraclePreflightResponse(details: OraclePreflightDetails): string {
916
+ if (details.ready) {
917
+ return [
918
+ "Oracle preflight ready.",
919
+ details.session.sessionFile ? `Persisted session: ${details.session.sessionFile}` : undefined,
920
+ details.auth.seedProfileDir ? `Auth seed profile: ${details.auth.seedProfileDir}` : undefined,
921
+ "You can continue with oracle context gathering and submission.",
922
+ ].filter(Boolean).join("\n");
923
+ }
924
+
925
+ return [
926
+ `Oracle preflight blocked: ${details.error?.message ?? "unknown blocker"}`,
927
+ details.error?.suggestedNextStep ? `Suggested next step: ${details.error.suggestedNextStep}` : undefined,
928
+ ].filter(Boolean).join("\n");
929
+ }
930
+
931
+ async function runOraclePreflight(ctx: ExtensionContext): Promise<OraclePreflightDetails> {
932
+ const sessionFile = getSessionFile(ctx);
933
+ if (!hasPersistedSessionFile(sessionFile)) {
934
+ return {
935
+ ready: false,
936
+ session: { persisted: false },
937
+ config: { ready: false },
938
+ auth: { ready: false },
939
+ error: buildOracleToolErrorDetails(
940
+ "oracle_preflight",
941
+ new Error("Oracle requires a persisted pi session to submit oracle jobs. Start or save a real session before using oracle."),
942
+ {},
943
+ ),
944
+ };
945
+ }
946
+
947
+ let config;
948
+ try {
949
+ config = loadOracleConfig(ctx.cwd);
950
+ } catch (error) {
951
+ return {
952
+ ready: false,
953
+ session: { persisted: true, sessionFile },
954
+ config: { ready: false },
955
+ auth: { ready: false },
956
+ error: buildOracleToolErrorDetails("oracle_preflight", error, {}),
957
+ };
958
+ }
959
+
960
+ try {
961
+ await assertOracleSubmitPrerequisites(config);
962
+ } catch (error) {
963
+ const errorDetails = buildOracleToolErrorDetails("oracle_preflight", error, {});
964
+ return {
965
+ ready: false,
966
+ session: { persisted: true, sessionFile },
967
+ config: { ready: true },
968
+ auth: {
969
+ ready: !["auth_seed_profile_missing", "auth_seed_profile_unreadable", "auth_seed_profile_invalid_type"].includes(errorDetails.code),
970
+ seedProfileDir: config.browser.authSeedProfileDir,
971
+ },
972
+ error: errorDetails,
973
+ };
974
+ }
975
+
976
+ return {
977
+ ready: true,
978
+ session: { persisted: true, sessionFile },
979
+ config: { ready: true },
980
+ auth: {
981
+ ready: true,
982
+ seedProfileDir: config.browser.authSeedProfileDir,
983
+ },
984
+ };
985
+ }
986
+
987
+ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWorkerPath = workerPath): void {
988
+ pi.on("tool_result", async (event) => {
989
+ if (!ORACLE_TOOL_NAMES.has(event.toolName as OracleToolName)) return;
990
+ if (event.isError) return;
991
+ const details = asRecord(event.details);
992
+ const errorDetails = asRecord(details?.error);
993
+ if (typeof errorDetails?.code === "string" && typeof errorDetails?.message === "string") {
994
+ return { isError: true };
995
+ }
996
+ });
997
+
998
+ pi.registerTool({
999
+ name: "oracle_preflight",
1000
+ label: "Oracle Preflight",
1001
+ description: "Check whether oracle is ready in this session before spending time gathering context or preparing a submission.",
1002
+ promptSnippet: "Check oracle readiness before expensive /oracle preparation.",
1003
+ promptGuidelines: [
1004
+ "Call oracle_preflight before doing expensive /oracle preparation. If ready is false, stop immediately and report the suggested next step instead of reading files or crafting archive inputs.",
1005
+ ],
1006
+ parameters: Type.Object({}),
1007
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1008
+ const details = await runOraclePreflight(ctx);
1009
+ return {
1010
+ content: [{ type: "text" as const, text: formatOraclePreflightResponse(details) }],
1011
+ details,
1012
+ };
1013
+ },
1014
+ });
1015
+
1016
+ pi.registerTool({
1017
+ name: "oracle_auth",
1018
+ label: "Oracle Auth",
1019
+ description: "Refresh the shared oracle auth seed profile by importing ChatGPT cookies from your configured real Chrome profile.",
1020
+ promptSnippet: "Refresh oracle auth before retrying a login-required oracle run.",
1021
+ promptGuidelines: [
1022
+ "Call oracle_auth when an oracle run failed because ChatGPT login is required, the worker said to rerun /oracle-auth, or stale auth appears to be blocking submission execution.",
1023
+ "At most once per user request, refresh auth and then retry the blocked oracle submission.",
1024
+ "If oracle_auth itself fails, stop and report the failure instead of looping.",
1025
+ ],
1026
+ parameters: Type.Object({}),
1027
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1028
+ try {
1029
+ const projectCwd = getProjectId(ctx.cwd);
1030
+ const message = await runOracleAuthBootstrap(authWorkerPath, projectCwd);
1031
+ return {
1032
+ content: [{ type: "text" as const, text: message }],
1033
+ details: {
1034
+ refreshed: true,
1035
+ authSeedProfileDir: loadOracleConfig(projectCwd).browser.authSeedProfileDir,
1036
+ },
1037
+ };
1038
+ } catch (error) {
1039
+ return buildOracleToolErrorResult("oracle_auth", error, {});
1040
+ }
1041
+ },
1042
+ });
1043
+
597
1044
  pi.registerTool({
598
1045
  name: "oracle_submit",
599
1046
  label: "Oracle Submit",
@@ -603,9 +1050,11 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
603
1050
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
604
1051
  promptGuidelines: [
605
1052
  "Gather context before calling oracle_submit.",
606
- "By default, archive the whole repo by passing '.'; default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like .env files, key material, credential dotfiles, local database files, and nested secrets directories anywhere in the repo.",
607
- "Only narrow file selection when the user explicitly asks, the task is clearly scoped smaller, or privacy/sensitivity requires it.",
608
- "For very targeted asks like a single function or stack trace, a smaller archive is preferable.",
1053
+ "If the immediately preceding oracle run failed because ChatGPT login is required or the worker explicitly said to rerun /oracle-auth, call oracle_auth once before retrying the submission. Do not loop auth refreshes.",
1054
+ "Prefer context-rich archives up to the 250 MB ceiling because more relevant surrounding context is usually better than less.",
1055
+ "By default, archive the whole repo by passing '.' for broad or unclear requests; default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like .env files, key material, credential dotfiles, local database files, and nested secrets directories anywhere in the repo.",
1056
+ "For narrower asks, still include nearby tests, docs, configs, and adjacent modules when they may improve answer quality. Only narrow aggressively when the user explicitly asks, privacy/sensitivity requires it, or size pressure forces it.",
1057
+ "Do not default to a one-file archive for a single function, file, or stack trace if the relevant surrounding context still fits comfortably within the limit.",
609
1058
  "When files='.' and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like build/, dist/, out/, coverage/, and tmp/ outside obvious source roots like src/ and lib/ until the archive fits or no candidate remains; successful submissions report what was pruned.",
610
1059
  "If a submitted oracle job later fails because upload is rejected, retry smaller: remove the largest obviously irrelevant/generated content first, then narrow to modified files plus adjacent files plus directly relevant subtrees, then explain the cut or ask the user if still needed.",
611
1060
  "If oracle_submit itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error instead of retrying automatically.",
@@ -615,68 +1064,116 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
615
1064
  ],
616
1065
  parameters: ORACLE_SUBMIT_PARAMS,
617
1066
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
618
- const config = loadOracleConfig(ctx.cwd);
619
- const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
620
- const projectId = getProjectId(ctx.cwd);
621
- const sessionId = getSessionId(originSessionFile, projectId);
622
- const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
623
- const selection = resolveOracleSubmitPreset(presetId);
624
- const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
625
1067
  try {
626
- await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
627
- await reconcileStaleOracleJobs();
628
- await pruneTerminalOracleJobs();
629
- });
630
- } catch (error) {
631
- if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
632
- }
1068
+ const projectCwd = getProjectId(ctx.cwd);
1069
+ const config = loadOracleConfig(projectCwd);
1070
+ const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
1071
+ const projectId = getProjectId(projectCwd);
1072
+ const sessionId = getSessionId(originSessionFile, projectId);
1073
+ const presetId = typeof params.preset === "string" ? coerceOracleSubmitPresetId(params.preset) : config.defaults.preset;
1074
+ const selection = resolveOracleSubmitPreset(presetId);
1075
+ const followUp = resolveFollowUp(params.followUpJobId, projectCwd);
1076
+ // Validate caller-specified archive paths before surfacing unrelated local setup failures such as a missing auth seed profile.
1077
+ resolveArchiveInputs(projectCwd, params.files);
1078
+ await assertOracleSubmitPrerequisites(config);
1079
+ try {
1080
+ await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: projectCwd }, async () => {
1081
+ await reconcileStaleOracleJobs();
1082
+ await pruneTerminalOracleJobs();
1083
+ });
1084
+ } catch (error) {
1085
+ if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
1086
+ }
633
1087
 
634
- const jobId = randomUUID();
635
- const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
636
- const runtime = allocateRuntime(config);
637
- let job: OracleJob | undefined;
638
- let archive: ArchiveCreationResult | undefined;
639
- let queued = false;
640
- let queuedSubmissionDurable = false;
641
- let runtimeLeaseAcquired = false;
642
- let conversationLeaseAcquired = false;
643
- let workerSpawned = false;
644
- let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
1088
+ const jobId = randomUUID();
1089
+ const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
1090
+ const runtime = allocateRuntime(config);
1091
+ let job: OracleJob | undefined;
1092
+ let archive: ArchiveCreationResult | undefined;
1093
+ let queued = false;
1094
+ let queuedSubmissionDurable = false;
1095
+ let runtimeLeaseAcquired = false;
1096
+ let conversationLeaseAcquired = false;
1097
+ let workerSpawned = false;
1098
+ let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
645
1099
 
646
- try {
647
- archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
648
- const currentArchive = archive;
649
- await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
650
- await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
651
-
652
- const admittedAt = new Date().toISOString();
653
- const runtimeAttempt = await tryAcquireRuntimeLease(config, {
654
- jobId,
655
- runtimeId: runtime.runtimeId,
656
- runtimeSessionName: runtime.runtimeSessionName,
657
- runtimeProfileDir: runtime.runtimeProfileDir,
658
- projectId,
659
- sessionId,
660
- createdAt: admittedAt,
661
- });
1100
+ try {
1101
+ archive = await createArchive(projectCwd, params.files, tempArchivePath);
1102
+ const currentArchive = archive;
1103
+ await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
1104
+ await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
662
1105
 
663
- if (!runtimeAttempt.acquired) {
664
- const queuePressure = await getQueuedArchivePressure();
665
- const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
666
- const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
667
- const queueAdmissionFailure = getQueueAdmissionFailure({
668
- queuePressure,
669
- archiveBytes: currentArchive.archiveBytes,
670
- activeJobs: runtimeAttempt.liveLeases.length,
671
- maxActiveJobs: config.browser.maxConcurrentJobs,
672
- maxQueuedJobs,
673
- maxQueuedArchiveBytes,
1106
+ const admittedAt = new Date().toISOString();
1107
+ const runtimeAttempt = await tryAcquireRuntimeLease(config, {
1108
+ jobId,
1109
+ runtimeId: runtime.runtimeId,
1110
+ runtimeSessionName: runtime.runtimeSessionName,
1111
+ runtimeProfileDir: runtime.runtimeProfileDir,
1112
+ projectId,
1113
+ sessionId,
1114
+ createdAt: admittedAt,
674
1115
  });
675
- if (queueAdmissionFailure) {
676
- throw new Error(queueAdmissionFailure);
1116
+
1117
+ if (!runtimeAttempt.acquired) {
1118
+ const queuePressure = await getQueuedArchivePressure();
1119
+ const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
1120
+ const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
1121
+ const queueAdmissionFailure = getQueueAdmissionFailure({
1122
+ queuePressure,
1123
+ archiveBytes: currentArchive.archiveBytes,
1124
+ activeJobs: runtimeAttempt.liveLeases.length,
1125
+ maxActiveJobs: config.browser.maxConcurrentJobs,
1126
+ maxQueuedJobs,
1127
+ maxQueuedArchiveBytes,
1128
+ });
1129
+ if (queueAdmissionFailure) {
1130
+ throw new Error(queueAdmissionFailure);
1131
+ }
1132
+
1133
+ queued = true;
1134
+ job = await createJob(
1135
+ jobId,
1136
+ {
1137
+ prompt: params.prompt,
1138
+ files: params.files,
1139
+ selection,
1140
+ followUpToJobId: followUp.followUpToJobId,
1141
+ chatUrl: followUp.chatUrl,
1142
+ requestSource: "tool",
1143
+ },
1144
+ projectCwd,
1145
+ originSessionFile,
1146
+ config,
1147
+ runtime,
1148
+ { initialState: "queued", createdAt: admittedAt },
1149
+ );
1150
+ await rename(tempArchivePath, job.archivePath);
1151
+ job = await updateJob(job.id, (current) => ({
1152
+ ...current,
1153
+ archiveSha256: currentArchive.sha256,
1154
+ }));
1155
+ queuedSubmissionDurable = true;
1156
+ return;
1157
+ }
1158
+
1159
+ runtimeLeaseAcquired = true;
1160
+ if (followUp.conversationId) {
1161
+ const conversationAttempt = await tryAcquireConversationLease({
1162
+ jobId,
1163
+ conversationId: followUp.conversationId,
1164
+ projectId,
1165
+ sessionId,
1166
+ createdAt: admittedAt,
1167
+ });
1168
+ if (!conversationAttempt.acquired) {
1169
+ throw new Error(
1170
+ `Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
1171
+ "Concurrent follow-ups to the same ChatGPT thread are not allowed.",
1172
+ );
1173
+ }
1174
+ conversationLeaseAcquired = true;
677
1175
  }
678
1176
 
679
- queued = true;
680
1177
  job = await createJob(
681
1178
  jobId,
682
1179
  {
@@ -687,179 +1184,137 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
687
1184
  chatUrl: followUp.chatUrl,
688
1185
  requestSource: "tool",
689
1186
  },
690
- ctx.cwd,
1187
+ projectCwd,
691
1188
  originSessionFile,
692
1189
  config,
693
1190
  runtime,
694
- { initialState: "queued", createdAt: admittedAt },
1191
+ { initialState: "submitted", createdAt: admittedAt },
695
1192
  );
696
1193
  await rename(tempArchivePath, job.archivePath);
1194
+ spawnedWorker = await spawnWorker(workerPath, job.id);
1195
+ workerSpawned = true;
1196
+ const worker = spawnedWorker;
697
1197
  job = await updateJob(job.id, (current) => ({
698
1198
  ...current,
699
1199
  archiveSha256: currentArchive.sha256,
1200
+ workerPid: worker.pid,
1201
+ workerNonce: worker.nonce,
1202
+ workerStartedAt: worker.startedAt,
700
1203
  }));
701
- queuedSubmissionDurable = true;
702
- return;
703
- }
704
-
705
- runtimeLeaseAcquired = true;
706
- if (followUp.conversationId) {
707
- const conversationAttempt = await tryAcquireConversationLease({
708
- jobId,
709
- conversationId: followUp.conversationId,
710
- projectId,
711
- sessionId,
712
- createdAt: admittedAt,
713
- });
714
- if (!conversationAttempt.acquired) {
715
- throw new Error(
716
- `Oracle conversation ${followUp.conversationId} is already in use by job ${conversationAttempt.blocker?.jobId ?? "unknown"}. ` +
717
- "Concurrent follow-ups to the same ChatGPT thread are not allowed.",
718
- );
719
- }
720
- conversationLeaseAcquired = true;
721
- }
722
-
723
- job = await createJob(
724
- jobId,
725
- {
726
- prompt: params.prompt,
727
- files: params.files,
728
- selection,
729
- followUpToJobId: followUp.followUpToJobId,
730
- chatUrl: followUp.chatUrl,
731
- requestSource: "tool",
732
- },
733
- ctx.cwd,
734
- originSessionFile,
735
- config,
736
- runtime,
737
- { initialState: "submitted", createdAt: admittedAt },
738
- );
739
- await rename(tempArchivePath, job.archivePath);
740
- spawnedWorker = await spawnWorker(workerPath, job.id);
741
- workerSpawned = true;
742
- const worker = spawnedWorker;
743
- job = await updateJob(job.id, (current) => ({
744
- ...current,
745
- archiveSha256: currentArchive.sha256,
746
- workerPid: worker.pid,
747
- workerNonce: worker.nonce,
748
- workerStartedAt: worker.startedAt,
749
- }));
750
- });
751
- if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
752
- if (ctx.hasUI) refreshOracleStatus(ctx);
753
-
754
- const queuePosition = queued ? getQueuePosition(job.id) : undefined;
755
- return {
756
- content: [
757
- {
758
- type: "text",
759
- text: formatOracleSubmitResponse(job, {
760
- autoPrunedPrefixes: currentArchive.autoPrunedPrefixes,
761
- queued,
762
- queuePosition: queuePosition?.position,
763
- queueDepth: queuePosition?.depth,
764
- }),
765
- },
766
- ],
767
- details: {
768
- jobId: job.id,
769
- queued,
770
- queuePosition: queuePosition?.position,
771
- queueDepth: queuePosition?.depth,
772
- archiveSha256: currentArchive.sha256,
773
- archiveBytes: currentArchive.archiveBytes,
774
- initialArchiveBytes: currentArchive.initialArchiveBytes,
775
- autoPrunedArchivePaths: currentArchive.autoPrunedPrefixes,
776
- runtimeId: job.runtimeId,
777
- followUpToJobId: followUp.followUpToJobId,
778
- },
779
- };
780
- } catch (error) {
781
- const message = error instanceof Error ? error.message : String(error);
782
- const latest = job ? readJob(job.id) : undefined;
783
- if (latest?.status === "queued" && queuedSubmissionDurable) {
1204
+ });
1205
+ if (!job || !archive) throw new Error(`Oracle submission ${jobId} did not persist job metadata durably`);
784
1206
  if (ctx.hasUI) refreshOracleStatus(ctx);
785
- const queuePosition = getQueuePosition(latest.id);
1207
+
1208
+ const queuePosition = queued ? getQueuePosition(job.id) : undefined;
786
1209
  return {
787
1210
  content: [
788
1211
  {
789
1212
  type: "text",
790
- text: formatOracleSubmitResponse(latest, {
791
- autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
792
- queued: true,
1213
+ text: formatOracleSubmitResponse(job, {
1214
+ autoPrunedPrefixes: archive.autoPrunedPrefixes,
1215
+ queued,
793
1216
  queuePosition: queuePosition?.position,
794
1217
  queueDepth: queuePosition?.depth,
795
1218
  }),
796
1219
  },
797
1220
  ],
798
1221
  details: {
799
- jobId: latest.id,
800
- queued: true,
801
- queuePosition: queuePosition?.position,
802
- queueDepth: queuePosition?.depth,
803
- archiveSha256: latest.archiveSha256,
804
- archiveBytes: archive?.archiveBytes,
805
- initialArchiveBytes: archive?.initialArchiveBytes,
806
- autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
807
- runtimeId: latest.runtimeId,
808
- followUpToJobId: latest.followUpToJobId,
1222
+ job: redactJobDetails(job, {
1223
+ queue: buildOracleQueueSnapshot(job, queuePosition),
1224
+ archiveBytes: archive.archiveBytes,
1225
+ initialArchiveBytes: archive.initialArchiveBytes,
1226
+ autoPrunedArchivePaths: archive.autoPrunedPrefixes,
1227
+ }),
809
1228
  },
810
1229
  };
811
- }
812
- if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
813
- if (ctx.hasUI) refreshOracleStatus(ctx);
814
- return {
815
- content: [
816
- {
817
- type: "text",
818
- text: formatOracleSubmitResponse(latest, {
819
- autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
820
- queued: false,
1230
+ } catch (error) {
1231
+ const message = getErrorMessage(error);
1232
+ const latest = job ? readJob(job.id) : undefined;
1233
+ if (latest?.status === "queued" && queuedSubmissionDurable) {
1234
+ if (ctx.hasUI) refreshOracleStatus(ctx);
1235
+ const queuePosition = getQueuePosition(latest.id);
1236
+ return {
1237
+ content: [
1238
+ {
1239
+ type: "text",
1240
+ text: formatOracleSubmitResponse(latest, {
1241
+ autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
1242
+ queued: true,
1243
+ queuePosition: queuePosition?.position,
1244
+ queueDepth: queuePosition?.depth,
1245
+ }),
1246
+ },
1247
+ ],
1248
+ details: {
1249
+ job: redactJobDetails(latest, {
1250
+ queue: buildOracleQueueSnapshot(latest, queuePosition),
1251
+ archiveBytes: archive?.archiveBytes,
1252
+ initialArchiveBytes: archive?.initialArchiveBytes,
1253
+ autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
821
1254
  }),
822
1255
  },
823
- ],
824
- details: {
825
- jobId: latest.id,
826
- queued: false,
827
- archiveSha256: latest.archiveSha256,
1256
+ };
1257
+ }
1258
+ if (workerSpawned && latest && hasDurableWorkerHandoff(latest)) {
1259
+ if (ctx.hasUI) refreshOracleStatus(ctx);
1260
+ return {
1261
+ content: [
1262
+ {
1263
+ type: "text",
1264
+ text: formatOracleSubmitResponse(latest, {
1265
+ autoPrunedPrefixes: archive?.autoPrunedPrefixes ?? [],
1266
+ queued: false,
1267
+ }),
1268
+ },
1269
+ ],
1270
+ details: {
1271
+ job: redactJobDetails(latest, {
1272
+ queue: buildOracleQueueSnapshot(latest),
1273
+ archiveBytes: archive?.archiveBytes,
1274
+ initialArchiveBytes: archive?.initialArchiveBytes,
1275
+ autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
1276
+ }),
1277
+ },
1278
+ };
1279
+ }
1280
+ if (spawnedWorker) {
1281
+ await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
1282
+ }
1283
+ if (job && (!latest || !isTerminalOracleJob(latest))) {
1284
+ const failedAt = new Date().toISOString();
1285
+ await updateJob(job.id, (current) => transitionOracleJobPhase(current, "failed", {
1286
+ at: failedAt,
1287
+ source: "oracle:submit",
1288
+ message: `Submission failed before durable worker handoff: ${message}`,
1289
+ patch: {
1290
+ error: message,
1291
+ },
1292
+ })).catch(() => undefined);
1293
+ }
1294
+ const cleanupReport = await cleanupRuntimeArtifacts({
1295
+ runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
1296
+ runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
1297
+ runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
1298
+ conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
1299
+ }).catch(() => ({ attempted: [], warnings: [] }));
1300
+ if (job && cleanupReport.warnings.length > 0) {
1301
+ await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
1302
+ }
1303
+ if (ctx.hasUI) refreshOracleStatus(ctx);
1304
+ return buildOracleToolErrorResult("oracle_submit", error, params as unknown as Record<string, unknown>, {
1305
+ job: latest ?? job,
1306
+ jobDetails: {
1307
+ queue: latest ? buildOracleQueueSnapshot(latest, latest.status === "queued" ? getQueuePosition(latest.id) : undefined) : undefined,
828
1308
  archiveBytes: archive?.archiveBytes,
829
1309
  initialArchiveBytes: archive?.initialArchiveBytes,
830
1310
  autoPrunedArchivePaths: archive?.autoPrunedPrefixes,
831
- runtimeId: latest.runtimeId,
832
- followUpToJobId: latest.followUpToJobId,
833
1311
  },
834
- };
835
- }
836
- if (spawnedWorker) {
837
- await terminateWorkerPid(spawnedWorker.pid, spawnedWorker.startedAt).catch(() => undefined);
838
- }
839
- if (job && (!latest || !isTerminalOracleJob(latest))) {
840
- const failedAt = new Date().toISOString();
841
- await updateJob(job.id, (current) => transitionOracleJobPhase(current, "failed", {
842
- at: failedAt,
843
- source: "oracle:submit",
844
- message: `Submission failed before durable worker handoff: ${message}`,
845
- patch: {
846
- error: message,
847
- },
848
- })).catch(() => undefined);
849
- }
850
- const cleanupReport = await cleanupRuntimeArtifacts({
851
- runtimeId: runtimeLeaseAcquired ? runtime.runtimeId : undefined,
852
- runtimeProfileDir: runtimeLeaseAcquired ? runtime.runtimeProfileDir : undefined,
853
- runtimeSessionName: workerSpawned ? runtime.runtimeSessionName : undefined,
854
- conversationId: conversationLeaseAcquired ? followUp.conversationId : undefined,
855
- }).catch(() => ({ attempted: [], warnings: [] }));
856
- if (job && cleanupReport.warnings.length > 0) {
857
- await appendCleanupWarnings(job.id, cleanupReport.warnings).catch(() => undefined);
1312
+ });
1313
+ } finally {
1314
+ await rm(tempArchivePath, { force: true }).catch(() => undefined);
858
1315
  }
859
- if (ctx.hasUI) refreshOracleStatus(ctx);
860
- throw error;
861
- } finally {
862
- await rm(tempArchivePath, { force: true }).catch(() => undefined);
1316
+ } catch (error) {
1317
+ return buildOracleToolErrorResult("oracle_submit", error, params as unknown as Record<string, unknown>);
863
1318
  }
864
1319
  },
865
1320
  });
@@ -870,40 +1325,54 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
870
1325
  description: "Read the status and outputs of a previously dispatched oracle job.",
871
1326
  parameters: ORACLE_READ_PARAMS,
872
1327
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
873
- const job = readJob(params.jobId);
874
- if (!job || job.projectId !== getProjectId(ctx.cwd)) {
875
- throw new Error(`Oracle job not found in this project: ${params.jobId}`);
876
- }
877
- const latest = isTerminalOracleJob(job)
878
- ? await markWakeupSettled(job.id, {
879
- source: "oracle_read",
880
- sessionFile: getSessionFile(ctx),
881
- cwd: ctx.cwd,
882
- })
883
- : job;
884
- const current = latest ?? readJob(job.id) ?? job;
885
-
886
- let responsePreview = "";
887
1328
  try {
888
- const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
889
- responsePreview = response.slice(0, 4000);
890
- } catch {
891
- responsePreview = "(response not available yet)";
892
- }
1329
+ const job = readJob(params.jobId);
1330
+ if (!job || job.projectId !== getProjectId(ctx.cwd)) {
1331
+ throw new Error(`Oracle job not found in this project: ${params.jobId}`);
1332
+ }
1333
+ const latest = isTerminalOracleJob(job)
1334
+ ? await markWakeupSettled(job.id, {
1335
+ source: "oracle_read",
1336
+ sessionFile: getSessionFile(ctx),
1337
+ cwd: ctx.cwd,
1338
+ })
1339
+ : job;
1340
+ const current = latest ?? readJob(job.id) ?? job;
1341
+
1342
+ let responsePreview: string | undefined;
1343
+ let responseAvailable = false;
1344
+ try {
1345
+ const response = await import("node:fs/promises").then((fs) => fs.readFile(current.responsePath || "", "utf8"));
1346
+ responsePreview = response.slice(0, 4000);
1347
+ responseAvailable = true;
1348
+ } catch {
1349
+ responsePreview = undefined;
1350
+ }
893
1351
 
894
- return {
895
- content: [
896
- {
897
- type: "text",
898
- text: formatOracleJobSummary(current, {
899
- queuePosition: current.status === "queued" ? getQueuePosition(current.id) : undefined,
900
- artifactsPath: `${getJobDir(current.id)}/artifacts`,
1352
+ const queuePosition = current.status === "queued" ? getQueuePosition(current.id) : undefined;
1353
+ return {
1354
+ content: [
1355
+ {
1356
+ type: "text",
1357
+ text: formatOracleJobSummary(current, {
1358
+ queuePosition,
1359
+ artifactsPath: `${getJobDir(current.id)}/artifacts`,
1360
+ responsePreview,
1361
+ responseAvailable,
1362
+ }),
1363
+ },
1364
+ ],
1365
+ details: {
1366
+ job: redactJobDetails(current, {
1367
+ queue: buildOracleQueueSnapshot(current, queuePosition),
901
1368
  responsePreview,
1369
+ responseAvailable,
902
1370
  }),
903
1371
  },
904
- ],
905
- details: { job: redactJobDetails(current) },
906
- };
1372
+ };
1373
+ } catch (error) {
1374
+ return buildOracleToolErrorResult("oracle_read", error, params as unknown as Record<string, unknown>);
1375
+ }
907
1376
  },
908
1377
  });
909
1378
 
@@ -913,26 +1382,30 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
913
1382
  description: "Cancel a queued or active oracle job.",
914
1383
  parameters: ORACLE_CANCEL_PARAMS,
915
1384
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
916
- const job = readJob(params.jobId);
917
- if (!job || job.projectId !== getProjectId(ctx.cwd)) {
918
- throw new Error(`Oracle job not found in this project: ${params.jobId}`);
919
- }
920
- if (!isOpenOracleJob(job)) {
1385
+ try {
1386
+ const job = readJob(params.jobId);
1387
+ if (!job || job.projectId !== getProjectId(ctx.cwd)) {
1388
+ throw new Error(`Oracle job not found in this project: ${params.jobId}`);
1389
+ }
1390
+ if (!isOpenOracleJob(job)) {
1391
+ return {
1392
+ content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
1393
+ details: { job: redactJobDetails(job, { queue: buildOracleQueueSnapshot(job, job.status === "queued" ? getQueuePosition(job.id) : undefined) }) },
1394
+ };
1395
+ }
1396
+
1397
+ const cancelled = await cancelOracleJob(params.jobId);
1398
+ if (shouldAdvanceQueueAfterCancellation(cancelled)) {
1399
+ await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
1400
+ }
1401
+ if (ctx.hasUI) refreshOracleStatus(ctx);
921
1402
  return {
922
- content: [{ type: "text", text: `Oracle job ${job.id} is not cancellable (${job.status}).` }],
923
- details: { job: redactJobDetails(job) },
1403
+ content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
1404
+ details: { job: redactJobDetails(cancelled, { queue: buildOracleQueueSnapshot(cancelled, cancelled.status === "queued" ? getQueuePosition(cancelled.id) : undefined) }) },
924
1405
  };
1406
+ } catch (error) {
1407
+ return buildOracleToolErrorResult("oracle_cancel", error, params as unknown as Record<string, unknown>);
925
1408
  }
926
-
927
- const cancelled = await cancelOracleJob(params.jobId);
928
- if (shouldAdvanceQueueAfterCancellation(cancelled)) {
929
- await promoteQueuedJobs({ workerPath, source: "oracle_cancel_tool" });
930
- }
931
- if (ctx.hasUI) refreshOracleStatus(ctx);
932
- return {
933
- content: [{ type: "text", text: cancelled.status === "cancelled" || cancelled.status === "failed" ? `Cancelled oracle job ${cancelled.id}.` : `Oracle job ${cancelled.id} was already ${cancelled.status}.` }],
934
- details: { job: redactJobDetails(cancelled) },
935
- };
936
1409
  },
937
1410
  });
938
1411
  }