codex-chatgpt-control 0.5.1-alpha.3 → 0.5.1-alpha.4

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +16 -0
  3. package/contracts/v1/fixtures/download-blocked-by-browser.json +22 -0
  4. package/contracts/v1/fixtures/download-receipt-failed.json +22 -0
  5. package/contracts/v1/fixtures/download-receipt-timeout.json +22 -0
  6. package/contracts/v1/fixtures/journal-rpc-indeterminate.json +32 -0
  7. package/contracts/v1/fixtures/journal-rpc-unsupported-platform.json +32 -0
  8. package/contracts/v1/fixtures/journal-runtime-unavailable.json +32 -0
  9. package/contracts/v1/manifest.json +30 -0
  10. package/contracts/v1/parity-suite.json +10 -7
  11. package/dist/codex-chatgpt-control-backend.mjs +3795 -2077
  12. package/dist/codex-chatgpt-control-journal.mjs +5547 -0
  13. package/dist/codex-chatgpt-control-live-smoke.bundle.mjs +51677 -0
  14. package/dist/codex-chatgpt-control-release-canary.bundle.mjs +52104 -0
  15. package/dist/codex-chatgpt-control.bundle.mjs +3403 -1369
  16. package/dist/src/browser/chatgpt-url.d.ts +2 -0
  17. package/dist/src/browser/chatgpt-url.js +9 -0
  18. package/dist/src/browser/downloads.d.ts +15 -1
  19. package/dist/src/browser/downloads.js +150 -28
  20. package/dist/src/browser/page-state.js +6 -0
  21. package/dist/src/client.d.ts +5 -0
  22. package/dist/src/client.js +69 -12
  23. package/dist/src/commands/artifacts.js +6 -4
  24. package/dist/src/commands/chat-popover.d.ts +37 -0
  25. package/dist/src/commands/chat-popover.js +537 -0
  26. package/dist/src/commands/configuration.d.ts +5 -0
  27. package/dist/src/commands/configuration.js +121 -14
  28. package/dist/src/commands/context.js +10 -3
  29. package/dist/src/commands/doctor.js +1 -1
  30. package/dist/src/commands/experience.d.ts +5 -0
  31. package/dist/src/commands/experience.js +69 -21
  32. package/dist/src/commands/files.js +10 -6
  33. package/dist/src/commands/modes.js +36 -4
  34. package/dist/src/commands/power-discovery.js +52 -10
  35. package/dist/src/commands/session.js +12 -4
  36. package/dist/src/dom/composer-text.d.ts +6 -0
  37. package/dist/src/dom/composer-text.js +85 -0
  38. package/dist/src/operations/artifact-transfer.d.ts +1 -1
  39. package/dist/src/operations/artifact-transfer.js +3 -3
  40. package/dist/src/operations/browser-adapter.d.ts +10 -4
  41. package/dist/src/operations/browser-adapter.js +95 -15
  42. package/dist/src/operations/browser-observation.d.ts +1 -1
  43. package/dist/src/operations/browser-observation.js +97 -49
  44. package/dist/src/operations/browser-target.d.ts +3 -1
  45. package/dist/src/operations/browser-target.js +49 -7
  46. package/dist/src/operations/chatgpt-runtime.js +85 -3
  47. package/dist/src/operations/client.js +2 -0
  48. package/dist/src/operations/configuration-routing.d.ts +4 -0
  49. package/dist/src/operations/configuration-routing.js +14 -0
  50. package/dist/src/operations/index.d.ts +3 -0
  51. package/dist/src/operations/index.js +3 -0
  52. package/dist/src/operations/journal-authority.d.ts +12 -0
  53. package/dist/src/operations/journal-authority.js +1 -0
  54. package/dist/src/operations/journal-rpc-client.d.ts +12 -0
  55. package/dist/src/operations/journal-rpc-client.js +225 -0
  56. package/dist/src/operations/journal-rpc-protocol.d.ts +32 -0
  57. package/dist/src/operations/journal-rpc-protocol.js +214 -0
  58. package/dist/src/operations/journal-rpc-server.d.ts +15 -0
  59. package/dist/src/operations/journal-rpc-server.js +272 -0
  60. package/dist/src/operations/journal.d.ts +1 -0
  61. package/dist/src/operations/journal.js +28 -2
  62. package/dist/src/operations/production-attachments.d.ts +3 -1
  63. package/dist/src/operations/production-attachments.js +25 -8
  64. package/dist/src/operations/production-chatgpt-artifacts.js +3 -3
  65. package/dist/src/operations/production-chatgpt-attachments.d.ts +10 -1
  66. package/dist/src/operations/production-chatgpt-attachments.js +133 -57
  67. package/dist/src/operations/production-configuration.d.ts +1 -1
  68. package/dist/src/operations/production-configuration.js +205 -24
  69. package/dist/src/operations/production-primitives.js +35 -252
  70. package/dist/src/operations/production-work-steer.js +35 -35
  71. package/dist/src/operations/runtime-adapter.d.ts +8 -0
  72. package/dist/src/operations/runtime-adapter.js +20 -4
  73. package/dist/src/operations/service.d.ts +7 -2
  74. package/dist/src/operations/service.js +80 -59
  75. package/dist/src/operations/transactional-chat-power.d.ts +17 -0
  76. package/dist/src/operations/transactional-chat-power.js +212 -0
  77. package/dist/src/runtime/coordinated-page.d.ts +2 -0
  78. package/dist/src/runtime/coordinated-page.js +23 -6
  79. package/dist/src/safety/blockers.js +8 -1
  80. package/dist/src/scripts/capture-surface-profile.js +7 -2
  81. package/dist/src/scripts/journal-server.d.ts +2 -0
  82. package/dist/src/scripts/journal-server.js +37 -0
  83. package/dist/src/scripts/live-smoke/scenarios.d.ts +16 -3
  84. package/dist/src/scripts/live-smoke/scenarios.js +47 -22
  85. package/dist/src/scripts/live-smoke/transactional.d.ts +17 -0
  86. package/dist/src/scripts/live-smoke/transactional.js +254 -0
  87. package/package.json +8 -3
  88. package/references/2026-09-06-journal-service.md +140 -0
  89. package/references/backend-protocol.md +103 -0
  90. package/references/python-parity.md +65 -0
@@ -6,3 +6,5 @@ export declare const CHATGPT_HOME = "https://chatgpt.com/";
6
6
  */
7
7
  export declare function isChatGPTUrl(value: string | undefined): boolean;
8
8
  export declare function requireChatGPTUrl(value: string, label: string): string;
9
+ /** Public context for ChatGPT's signed attachment endpoint, never its token. */
10
+ export declare function chatGPTAttachmentContextUrl(value: string | undefined): string | undefined;
@@ -26,3 +26,12 @@ export function requireChatGPTUrl(value, label) {
26
26
  }
27
27
  return value;
28
28
  }
29
+ /** Public context for ChatGPT's signed attachment endpoint, never its token. */
30
+ export function chatGPTAttachmentContextUrl(value) {
31
+ if (!isChatGPTUrl(value))
32
+ return undefined;
33
+ const url = new URL(value);
34
+ if (url.pathname !== "/backend-api/estuary/content")
35
+ return undefined;
36
+ return `${url.origin}${url.pathname}`;
37
+ }
@@ -1,9 +1,23 @@
1
1
  import type { DownloadedFile, PageLike } from "../types.js";
2
+ import { ChatGPTControlError } from "../errors.js";
2
3
  export type DownloadLike = {
3
4
  suggestedFilename?: () => string;
4
5
  /** Capability-based stream exposed by Playwright's Download implementation. */
5
6
  createReadStream?: () => Promise<AsyncIterable<Uint8Array>>;
6
7
  saveAs?: (path: string) => Promise<void>;
7
- path?: () => Promise<string | null>;
8
+ path?: (options?: {
9
+ timeoutMs?: number;
10
+ }) => Promise<string | null>;
8
11
  };
12
+ export declare class DownloadReceiptTimeoutError extends ChatGPTControlError {
13
+ constructor(stage: string);
14
+ }
15
+ export declare class DownloadBrowserBlockedError extends ChatGPTControlError {
16
+ constructor();
17
+ }
18
+ export declare class DownloadReceiptFailedError extends ChatGPTControlError {
19
+ constructor();
20
+ }
21
+ export declare function isTerminalDownloadError(error: unknown): error is DownloadReceiptTimeoutError | DownloadBrowserBlockedError | DownloadReceiptFailedError;
22
+ export declare function isTerminalDownloadCode(code: string | undefined): boolean;
9
23
  export declare function waitForDownloadFromClick(page: PageLike, click: () => Promise<void>, destDir: string, timeoutMs: number, filenameHint?: string): Promise<DownloadedFile>;
@@ -1,39 +1,161 @@
1
1
  import { copyFile, mkdir, stat } from "node:fs/promises";
2
2
  import { basename, join, resolve } from "node:path";
3
- import { localGuardTimeout, withTimeout } from "../commands/timeouts.js";
3
+ import { ChatGPTControlError } from "../errors.js";
4
+ import { createDeadline, remainingMs } from "../commands/deadline.js";
5
+ import { withTimeout } from "../commands/timeouts.js";
6
+ import { coordinatedEventRegistrationBarrier, withCoordinatedMutationGuard } from "../runtime/coordinated-page.js";
7
+ export class DownloadReceiptTimeoutError extends ChatGPTControlError {
8
+ constructor(stage) {
9
+ super(`Download ${stage} did not complete before the deadline. Completion is unverified; inspect the existing download before retrying.`, "download_unavailable", false, undefined, { code: "download_receipt_timeout", resumable: false });
10
+ }
11
+ }
12
+ export class DownloadBrowserBlockedError extends ChatGPTControlError {
13
+ constructor() {
14
+ super("Chrome displayed ERR_BLOCKED_BY_CLIENT during the download. Inspect the existing download and Chrome restrictions before retrying; completion is unverified.", "download_unavailable", false, undefined, { code: "download_blocked_by_browser", resumable: false });
15
+ }
16
+ }
17
+ export class DownloadReceiptFailedError extends ChatGPTControlError {
18
+ constructor() {
19
+ super("The browser did not provide a verified download receipt. Inspect the existing download before retrying; completion is unverified.", "download_unavailable", false, undefined, { code: "download_receipt_failed", resumable: false });
20
+ }
21
+ }
22
+ export function isTerminalDownloadError(error) {
23
+ return error instanceof DownloadReceiptTimeoutError || error instanceof DownloadBrowserBlockedError || error instanceof DownloadReceiptFailedError;
24
+ }
25
+ export function isTerminalDownloadCode(code) {
26
+ return code === "download_receipt_timeout" || code === "download_blocked_by_browser" || code === "download_receipt_failed";
27
+ }
28
+ async function checkBrowserDownloadError(page, deadline) {
29
+ if (typeof page.evaluate !== "function")
30
+ return;
31
+ // A missing or stalled diagnostic must leave time for the native receipt.
32
+ // Both probes are read-only, bounded by the existing operation deadline,
33
+ // and return an enum instead of exporting error-page URLs or body text.
34
+ const timeoutMs = Math.min(1000, Math.floor(remainingMs(deadline) / 4));
35
+ if (timeoutMs <= 0)
36
+ return;
37
+ let state;
38
+ try {
39
+ state = await withTimeout(page.evaluate(function inspectBrowserDownloadError() {
40
+ if (!document.body?.getAttribute("class")?.split(/\s+/).includes("neterror"))
41
+ return "unavailable";
42
+ const frames = document.querySelectorAll("#main-frame-error");
43
+ if (frames.length !== 1)
44
+ return "unavailable";
45
+ const codes = frames[0].querySelectorAll(".error-code");
46
+ return codes.length === 1 && codes[0].textContent?.trim() === "ERR_BLOCKED_BY_CLIENT"
47
+ ? "blocked_by_client" : "unavailable";
48
+ }, undefined, { timeoutMs }), timeoutMs, "Browser download diagnostic timed out.");
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ if (state === "blocked_by_client")
54
+ throw new DownloadBrowserBlockedError();
55
+ }
56
+ async function downloadStep(deadline, stage, run, capMs) {
57
+ const timeoutMs = Math.min(remainingMs(deadline), capMs ?? Number.MAX_SAFE_INTEGER);
58
+ const timeoutError = new DownloadReceiptTimeoutError(stage);
59
+ if (timeoutMs <= 0)
60
+ throw timeoutError;
61
+ try {
62
+ return await withTimeout(run(), timeoutMs, timeoutError.message);
63
+ }
64
+ catch (error) {
65
+ if (error instanceof Error && error.message === timeoutError.message)
66
+ throw timeoutError;
67
+ throw error;
68
+ }
69
+ }
4
70
  export async function waitForDownloadFromClick(page, click, destDir, timeoutMs, filenameHint) {
71
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
72
+ throw new Error("Download timeout must be positive and within the supported timer range.");
73
+ }
74
+ const deadline = createDeadline(timeoutMs);
75
+ await checkBrowserDownloadError(page, deadline);
5
76
  const absoluteDest = resolve(destDir);
6
- await mkdir(absoluteDest, { recursive: true });
7
- const downloadPromise = page.waitForEvent?.("download", { timeout: timeoutMs, timeoutMs });
8
- if (downloadPromise === undefined) {
77
+ await downloadStep(deadline, "destination preparation", () => mkdir(absoluteDest, { recursive: true }));
78
+ if (typeof page.waitForEvent !== "function") {
9
79
  throw new Error("The active browser page does not expose download events.");
10
80
  }
11
- await withTimeout(click(), localGuardTimeout(timeoutMs, 10000), "Download control click did not complete before the local guard timeout.");
12
- const download = await downloadPromise;
13
- const sourcePath = typeof download.path === "function" ? await download.path() : null;
14
- const suggestedFilename = filenameHint
15
- ?? download.suggestedFilename?.()
16
- ?? (sourcePath === null ? undefined : basename(sourcePath))
17
- ?? `chatgpt-download-${Date.now()}`;
18
- const targetPath = join(absoluteDest, basename(suggestedFilename));
19
- if (typeof download.saveAs === "function") {
20
- await download.saveAs(targetPath);
21
- }
22
- else if (sourcePath !== null) {
23
- if (resolve(sourcePath) !== resolve(targetPath)) {
24
- await copyFile(sourcePath, targetPath);
81
+ // Arm before activation. Promise.all observes both rejections immediately,
82
+ // including a late event rejection after a failed or timed-out click.
83
+ const nativeTimeoutMs = remainingMs(deadline);
84
+ if (nativeTimeoutMs <= 0)
85
+ throw new DownloadReceiptTimeoutError("event registration");
86
+ const activation = new AbortController();
87
+ let failure;
88
+ const assertActive = () => {
89
+ if (failure !== undefined)
90
+ throw failure;
91
+ if (activation.signal.aborted || remainingMs(deadline) <= 0)
92
+ throw new DownloadReceiptTimeoutError("control click");
93
+ };
94
+ try {
95
+ const rawWait = page.waitForEvent("download", { timeout: nativeTimeoutMs, timeoutMs: nativeTimeoutMs });
96
+ const registration = coordinatedEventRegistrationBarrier(rawWait);
97
+ let receiptFailed = false;
98
+ const downloadPromise = downloadStep(deadline, "event receipt", () => rawWait).catch(error => {
99
+ receiptFailed = true;
100
+ failure = isTerminalDownloadError(error) ? error : new DownloadReceiptFailedError();
101
+ activation.abort(failure);
102
+ throw failure;
103
+ });
104
+ const [download] = await Promise.all([
105
+ downloadPromise,
106
+ (async () => {
107
+ // Actor fairness may choose a queued mutation before another read.
108
+ // Registration must finish before the activating click is even queued.
109
+ if (registration !== undefined)
110
+ await downloadStep(deadline, "event registration", () => registration);
111
+ try {
112
+ await downloadStep(deadline, "control click", () => withCoordinatedMutationGuard(activation.signal, assertActive, click), 10_000);
113
+ }
114
+ catch (error) {
115
+ if (!receiptFailed)
116
+ await checkBrowserDownloadError(page, deadline);
117
+ throw error;
118
+ }
119
+ if (!receiptFailed)
120
+ await checkBrowserDownloadError(page, deadline);
121
+ })()
122
+ ]);
123
+ // saveAs is already a complete capability; an optional path() call can hang
124
+ // on bridge implementations and is unnecessary when saveAs is available.
125
+ const sourcePath = typeof download.saveAs !== "function" && typeof download.path === "function"
126
+ ? await downloadStep(deadline, "source path", () => download.path({ timeoutMs: remainingMs(deadline) })) : null;
127
+ const suggestedFilename = filenameHint
128
+ ?? download.suggestedFilename?.()
129
+ ?? (sourcePath === null ? undefined : basename(sourcePath))
130
+ ?? `chatgpt-download-${Date.now()}`;
131
+ const targetPath = join(absoluteDest, basename(suggestedFilename));
132
+ if (typeof download.saveAs === "function") {
133
+ await downloadStep(deadline, "file save", () => download.saveAs(targetPath));
134
+ }
135
+ else if (sourcePath !== null) {
136
+ if (resolve(sourcePath) !== resolve(targetPath)) {
137
+ await downloadStep(deadline, "file copy", () => copyFile(sourcePath, targetPath));
138
+ }
139
+ }
140
+ else {
141
+ throw new Error("The browser download object exposes neither saveAs() nor a completed local path().");
25
142
  }
143
+ const saved = await downloadStep(deadline, "file verification", () => stat(targetPath));
144
+ if (saved.size <= 0) {
145
+ throw new Error(`Downloaded file is empty: ${targetPath}`);
146
+ }
147
+ return {
148
+ path: targetPath,
149
+ suggestedFilename,
150
+ bytes: saved.size
151
+ };
26
152
  }
27
- else {
28
- throw new Error("The browser download object exposes neither saveAs() nor a completed local path().");
153
+ catch (error) {
154
+ throw failure ?? (isTerminalDownloadError(error) ? error : new DownloadReceiptFailedError());
29
155
  }
30
- const saved = await stat(targetPath);
31
- if (saved.size <= 0) {
32
- throw new Error(`Downloaded file is empty: ${targetPath}`);
156
+ finally {
157
+ // Cancels locally queued activation only. A native download already in
158
+ // progress, or its native event listener, is not claimed to be cancelled.
159
+ activation.abort();
33
160
  }
34
- return {
35
- path: targetPath,
36
- suggestedFilename,
37
- bytes: saved.size
38
- };
39
161
  }
@@ -2,6 +2,7 @@ import { classifyVisibleText } from "../safety/blockers.js";
2
2
  import { compactVisibleText } from "../safety/redaction.js";
3
3
  import { escapeRegExp, localeLabels } from "../dom/locale-labels.js";
4
4
  import { withTimeout } from "../commands/timeouts.js";
5
+ import { chatGPTAttachmentContextUrl } from "./chatgpt-url.js";
5
6
  export function parseConversationId(url) {
6
7
  let parsed;
7
8
  try {
@@ -19,6 +20,11 @@ export function parseConversationId(url) {
19
20
  export async function readPageState(page) {
20
21
  const rawUrl = typeof page.url === "function" ? await Promise.resolve(page.url()).catch(() => "") : "";
21
22
  const url = typeof rawUrl === "string" ? rawUrl : "";
23
+ // This is a file transfer endpoint, not a conversation surface. Keep the
24
+ // internal URL for origin checks without inspecting error-page content.
25
+ if (chatGPTAttachmentContextUrl(url) !== undefined) {
26
+ return { url, visibleText: "", signedIn: false };
27
+ }
22
28
  const rawTitle = typeof page.title === "function" ? await page.title().catch(() => undefined) : undefined;
23
29
  const title = typeof rawTitle === "string" ? rawTitle : undefined;
24
30
  const surface = await readPageSurfaceSnapshot(page);
@@ -38,6 +38,11 @@ export type ChatGPTClientOptions = RuntimeEnv & {
38
38
  */
39
39
  export type ChatGPTOperationsOptions = Readonly<{
40
40
  stateRoot?: string;
41
+ /** Explicit private-file connection to a normal Node journal authority. */
42
+ journalService?: Readonly<{
43
+ descriptorPath: string;
44
+ timeoutMs?: number;
45
+ }>;
41
46
  adapter?: OperationBrowserAdapter;
42
47
  adapterFactory?: OperationAdapterFactory;
43
48
  handleAdapterFactory?: OperationHandleAdapterFactory;
@@ -23,7 +23,8 @@ import { streamFromRunResult } from "./runner/stream.js";
23
23
  import { redactReportValue } from "./safety/report-redaction.js";
24
24
  import { explainCommandBlocker } from "./diagnostics/blockers.js";
25
25
  import { OperationClient, OperationClientError } from "./operations/client.js";
26
- import { OperationJournal } from "./operations/journal.js";
26
+ import { JOURNAL_RUNTIME_UNAVAILABLE_MESSAGE, OperationJournal, OperationJournalError } from "./operations/journal.js";
27
+ import { createJournalRpcClientFromDescriptor } from "./operations/journal-rpc-client.js";
27
28
  import { OperationService } from "./operations/service.js";
28
29
  import { OPERATION_CONTROL_REQUEST_SCHEMA_VERSION, OPERATION_REQUEST_SCHEMA_VERSION } from "./operations/types.js";
29
30
  import { createRuntimeEnvSession } from "./runtime/runtime-session.js";
@@ -634,7 +635,14 @@ function runtimeEnv(options) {
634
635
  }
635
636
  async function createOperationClientForChatGPT(options, runtimeEnvironment, owner) {
636
637
  const operationOptions = options.operations ?? {};
637
- const journal = await OperationJournal.open(operationOptions.stateRoot === undefined ? {} : { stateRoot: operationOptions.stateRoot });
638
+ if (operationOptions.journalService !== undefined && operationOptions.stateRoot !== undefined) {
639
+ throw new OperationJournalError("journal_service_configuration_conflict", "The journal service owns its state root; configure either journalService or stateRoot.");
640
+ }
641
+ const journal = operationOptions.journalService === undefined
642
+ ? await OperationJournal.open(operationOptions.stateRoot === undefined ? {} : { stateRoot: operationOptions.stateRoot })
643
+ : await createJournalRpcClientFromDescriptor(operationOptions.journalService.descriptorPath, {
644
+ ...(operationOptions.journalService.timeoutMs === undefined ? {} : { timeoutMs: operationOptions.journalService.timeoutMs })
645
+ });
638
646
  const serviceOptions = {
639
647
  ...(operationOptions.maxCasRetries === undefined ? {} : { maxCasRetries: operationOptions.maxCasRetries }),
640
648
  ...(options.now === undefined ? {} : { now: () => options.now().getTime() })
@@ -1158,11 +1166,15 @@ function transactionalWorkBlockerResult(data, code, uncertain, recoverable) {
1158
1166
  }
1159
1167
  function transactionalWorkError(operationId, error, controlActionId, parentHandle) {
1160
1168
  const code = safeOwnErrorCode(error) ?? "operation_error";
1161
- const message = `Transactional Work operation failed (${code.replaceAll("_", " ")}).`;
1169
+ const runtimeUnavailable = code === "journal_runtime_unavailable";
1170
+ const serviceUnavailable = JOURNAL_SERVICE_FAILURE_CODES.has(code);
1171
+ const indeterminate = code === "journal_rpc_outcome_indeterminate";
1172
+ const message = runtimeUnavailable ? JOURNAL_RUNTIME_UNAVAILABLE_MESSAGE : serviceUnavailable ? journalServiceFailureMessage(code) : `Transactional Work operation failed (${code.replaceAll("_", " ")}).`;
1162
1173
  const blocker = code === "adapter_unavailable"
1163
1174
  || code === "browser_bridge_unavailable"
1164
1175
  || code === "target_evidence_unavailable"
1165
- || code === "backend_unavailable";
1176
+ || code === "backend_unavailable"
1177
+ || runtimeUnavailable || serviceUnavailable;
1166
1178
  const data = parentHandle === undefined
1167
1179
  ? {
1168
1180
  operationId,
@@ -1177,11 +1189,21 @@ function transactionalWorkError(operationId, error, controlActionId, parentHandl
1177
1189
  };
1178
1190
  return {
1179
1191
  ok: false,
1180
- status: blocker ? "blocked" : "error",
1192
+ status: indeterminate ? "partial" : blocker ? "blocked" : "error",
1181
1193
  data,
1182
1194
  warnings: [],
1183
- ...(blocker ? { blocker: { kind: transactionalBlockerKind(code), code, message, resumable: true } } : {}),
1184
- error: { name: "OperationError", message, recoverable: blocker },
1195
+ ...(blocker ? { blocker: {
1196
+ kind: transactionalBlockerKind(code), code, message, resumable: !runtimeUnavailable && !serviceUnavailable,
1197
+ ...(runtimeUnavailable ? { remediation: [{
1198
+ label: "Use a supported transactional host",
1199
+ instruction: "Start the packaged journal service in a supported Node host and configure operations.journalService.descriptorPath in the browser client.",
1200
+ userActionRequired: false
1201
+ }] } : serviceUnavailable ? { remediation: [{
1202
+ ...journalServiceRemediation(code),
1203
+ userActionRequired: false
1204
+ }] } : {})
1205
+ } } : {}),
1206
+ error: { name: "OperationError", message, recoverable: blocker && !runtimeUnavailable && !serviceUnavailable },
1185
1207
  context: { timestamp: new Date().toISOString(), experience: "work" }
1186
1208
  };
1187
1209
  }
@@ -1619,17 +1641,52 @@ function transactionalBlockerKind(code) {
1619
1641
  return "upload_failed";
1620
1642
  return "unknown";
1621
1643
  }
1644
+ const JOURNAL_SERVICE_FAILURE_CODES = new Set([
1645
+ "journal_rpc_unavailable", "journal_rpc_authentication_failed", "journal_rpc_protocol_error",
1646
+ "journal_rpc_limit_exceeded", "journal_rpc_outcome_indeterminate", "journal_rpc_request_rejected",
1647
+ "journal_rpc_unsupported_platform"
1648
+ ]);
1649
+ function journalServiceFailureMessage(code) {
1650
+ if (code === "journal_rpc_unsupported_platform") {
1651
+ return "The private-file journal service requires POSIX file ownership and permissions and is unavailable on Windows.";
1652
+ }
1653
+ return code === "journal_rpc_outcome_indeterminate"
1654
+ ? "The journal service connection ended without confirming a durable write. Preserve the operation identity and reconcile its state before any further browser action."
1655
+ : "The configured journal service could not provide authenticated operation state. Restore its private connection before continuing.";
1656
+ }
1657
+ function journalServiceRemediation(code) {
1658
+ return code === "journal_rpc_unsupported_platform" ? {
1659
+ label: "Use a supported journal host",
1660
+ instruction: "On Windows, use the local journal in an ordinary Node browser host without operations.journalService. The private-file journal service requires a POSIX host. Preserve any existing operation identity."
1661
+ } : {
1662
+ label: "Reconnect the same journal",
1663
+ instruction: "Restore the private journal-service connection, then inspect or reconcile the same operation identity. Never create a new operation ID to retry an uncertain Send."
1664
+ };
1665
+ }
1622
1666
  function transactionalAskError(operationId, error) {
1623
1667
  const code = safeOwnErrorCode(error) ?? "operation_error";
1624
- const blocker = code === "adapter_unavailable" || code === "browser_bridge_unavailable" || code === "target_evidence_unavailable";
1625
- const message = `Transactional operation failed (${code.replaceAll("_", " ")}).`;
1668
+ const runtimeUnavailable = code === "journal_runtime_unavailable";
1669
+ const serviceUnavailable = JOURNAL_SERVICE_FAILURE_CODES.has(code);
1670
+ const indeterminate = code === "journal_rpc_outcome_indeterminate";
1671
+ const blocker = runtimeUnavailable || serviceUnavailable || code === "adapter_unavailable" || code === "browser_bridge_unavailable" || code === "target_evidence_unavailable";
1672
+ const message = runtimeUnavailable ? JOURNAL_RUNTIME_UNAVAILABLE_MESSAGE : serviceUnavailable ? journalServiceFailureMessage(code) : `Transactional operation failed (${code.replaceAll("_", " ")}).`;
1626
1673
  return {
1627
1674
  ok: false,
1628
- status: blocker ? "blocked" : "error",
1675
+ status: indeterminate ? "partial" : blocker ? "blocked" : "error",
1629
1676
  data: { operationId },
1630
1677
  warnings: [],
1631
- ...(blocker ? { blocker: { kind: transactionalBlockerKind(code), code, message, resumable: true } } : {}),
1632
- error: { name: "OperationError", message, recoverable: blocker },
1678
+ ...(blocker ? { blocker: {
1679
+ kind: transactionalBlockerKind(code), code, message, resumable: !runtimeUnavailable && !serviceUnavailable,
1680
+ ...(runtimeUnavailable ? { remediation: [{
1681
+ label: "Use a supported transactional host",
1682
+ instruction: "Start the packaged journal service in a supported Node host and configure operations.journalService.descriptorPath in the browser client.",
1683
+ userActionRequired: false
1684
+ }] } : serviceUnavailable ? { remediation: [{
1685
+ ...journalServiceRemediation(code),
1686
+ userActionRequired: false
1687
+ }] } : {})
1688
+ } } : {}),
1689
+ error: { name: "OperationError", message, recoverable: blocker && !runtimeUnavailable && !serviceUnavailable },
1633
1690
  context: { timestamp: new Date().toISOString() }
1634
1691
  };
1635
1692
  }
@@ -1,11 +1,11 @@
1
1
  import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
2
2
  import { basename, join, resolve } from "node:path";
3
- import { waitForDownloadFromClick } from "../browser/downloads.js";
3
+ import { isTerminalDownloadCode, isTerminalDownloadError, waitForDownloadFromClick } from "../browser/downloads.js";
4
4
  import { readPageState } from "../browser/page-state.js";
5
5
  import { listPageArtifacts, readLatestImageDataUrl } from "../dom/artifacts.js";
6
6
  import { cssSelectors, requiredLocator } from "../dom/selectors.js";
7
7
  import { localeLabels } from "../dom/locale-labels.js";
8
- import { resultOk } from "../errors.js";
8
+ import { resultError, resultOk } from "../errors.js";
9
9
  import { contextFromPage } from "./context.js";
10
10
  import { ensurePage } from "./session.js";
11
11
  import { localGuardTimeout, withTimeout } from "./timeouts.js";
@@ -112,7 +112,7 @@ export async function downloadLatestArtifact(env, args) {
112
112
  const timeoutMs = args.timeoutMs ?? 120000;
113
113
  if (args.prefer !== "visible_image_source") {
114
114
  const byDownload = await tryDownloadControl(page, args, timeoutMs);
115
- if (byDownload.ok || args.prefer === "download_control") {
115
+ if (byDownload.ok || isTerminalDownloadCode(byDownload.blocker?.code) || args.prefer === "download_control") {
116
116
  return byDownload;
117
117
  }
118
118
  }
@@ -156,7 +156,7 @@ async function tryDownloadControl(page, args, timeoutMs) {
156
156
  return resultOk(downloaded, await contextFromPage(page));
157
157
  }
158
158
  catch (error) {
159
- return artifactDownloadBlocker(error, await contextFromPage(page));
159
+ return artifactDownloadBlocker(error, await contextFromPage(page, {}, { minimal: isTerminalDownloadError(error) }));
160
160
  }
161
161
  }
162
162
  async function saveLatestVisibleImageSource(page, destDir, timeoutMs) {
@@ -395,6 +395,8 @@ function artifactSelectorBlocker(error, context) {
395
395
  };
396
396
  }
397
397
  function artifactDownloadBlocker(error, context) {
398
+ if (isTerminalDownloadError(error))
399
+ return resultError(error, context);
398
400
  return {
399
401
  ok: false,
400
402
  status: "unsupported",
@@ -0,0 +1,37 @@
1
+ import type { PageLike } from "../types.js";
2
+ export type ChatPopoverSnapshot = {
3
+ view: "simple" | "advanced";
4
+ rootIndex: number;
5
+ triggerId?: string;
6
+ toggleIndex: number;
7
+ modelOptions: Array<{
8
+ label: string;
9
+ checked: boolean;
10
+ index: number;
11
+ }>;
12
+ activeModel?: string;
13
+ effort?: string;
14
+ speed?: "Standard" | "Fast";
15
+ speedIndex?: number;
16
+ slider?: {
17
+ index: number;
18
+ minimum: number;
19
+ maximum: number;
20
+ current: number;
21
+ valueText?: string;
22
+ };
23
+ };
24
+ /** Inactive model radios retain geometry. Require an owned, active, non-inert view. */
25
+ export declare function readChatPopover(page: PageLike): Promise<{
26
+ snapshot?: ChatPopoverSnapshot;
27
+ acted: boolean;
28
+ }>;
29
+ export declare function closeChatPopover(page: PageLike, before?: ChatPopoverSnapshot): Promise<boolean>;
30
+ export declare function setChatPopoverView(page: PageLike, view: ChatPopoverSnapshot["view"]): Promise<ChatPopoverSnapshot | undefined>;
31
+ /** Opens the model view for evidence; does not probe slider values during inspection. */
32
+ export declare function inspectChatPopover(page: PageLike): Promise<ChatPopoverSnapshot | undefined>;
33
+ export declare function selectChatPopoverModel(page: PageLike, labels: string[]): Promise<string | undefined>;
34
+ /** Search the observed bounded range during mutation, restoring an unmatched search. */
35
+ export declare function selectChatPopoverEffort(page: PageLike, labels: string[]): Promise<string | undefined>;
36
+ /** The optional Work fast-mode checkbox is independent of model and effort. */
37
+ export declare function selectChatPopoverSpeed(page: PageLike, labels: string[]): Promise<string | undefined>;