cueline 0.1.2 → 0.1.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.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/CHANGELOG.md +38 -0
- package/README.ja.md +65 -21
- package/README.ko.md +65 -21
- package/README.md +70 -23
- package/README.zh-CN.md +65 -21
- package/README.zh-TW.md +68 -22
- package/dist/src/api-contracts.d.ts +77 -0
- package/dist/src/api-contracts.js +2 -0
- package/dist/src/api-contracts.js.map +1 -0
- package/dist/src/api-controller-handoff.d.ts +6 -0
- package/dist/src/api-controller-handoff.js +253 -0
- package/dist/src/api-controller-handoff.js.map +1 -0
- package/dist/src/api-runtime-lifecycle.d.ts +22 -0
- package/dist/src/api-runtime-lifecycle.js +568 -0
- package/dist/src/api-runtime-lifecycle.js.map +1 -0
- package/dist/src/api.d.ts +9 -27
- package/dist/src/api.js +138 -42
- package/dist/src/api.js.map +1 -1
- package/dist/src/browser/browser-adapter.d.ts +11 -1
- package/dist/src/browser/codex-iab/bootstrap.d.ts +16 -0
- package/dist/src/browser/codex-iab/bootstrap.js +49 -0
- package/dist/src/browser/codex-iab/bootstrap.js.map +1 -1
- package/dist/src/browser/codex-iab/chatgpt-client.js +346 -120
- package/dist/src/browser/codex-iab/chatgpt-client.js.map +1 -1
- package/dist/src/browser/codex-iab/recovery-evidence.d.ts +6 -0
- package/dist/src/browser/codex-iab/recovery-evidence.js +37 -0
- package/dist/src/browser/codex-iab/recovery-evidence.js.map +1 -0
- package/dist/src/browser/codex-iab/submission-url.d.ts +2 -0
- package/dist/src/browser/codex-iab/submission-url.js +53 -0
- package/dist/src/browser/codex-iab/submission-url.js.map +1 -0
- package/dist/src/cli/main.js +305 -20
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/core/controller-abort.d.ts +1 -0
- package/dist/src/core/controller-abort.js +11 -0
- package/dist/src/core/controller-abort.js.map +1 -0
- package/dist/src/core/controller-command-execution.d.ts +9 -0
- package/dist/src/core/controller-command-execution.js +299 -0
- package/dist/src/core/controller-command-execution.js.map +1 -0
- package/dist/src/core/controller-loop.d.ts +3 -37
- package/dist/src/core/controller-loop.js +386 -291
- package/dist/src/core/controller-loop.js.map +1 -1
- package/dist/src/core/controller-turn.d.ts +16 -0
- package/dist/src/core/controller-turn.js +309 -0
- package/dist/src/core/controller-turn.js.map +1 -0
- package/dist/src/core/controller-types.d.ts +58 -0
- package/dist/src/core/controller-types.js +2 -0
- package/dist/src/core/controller-types.js.map +1 -0
- package/dist/src/core/persisted-run.d.ts +4 -0
- package/dist/src/core/persisted-run.js +19 -0
- package/dist/src/core/persisted-run.js.map +1 -0
- package/dist/src/core/process-liveness.d.ts +2 -0
- package/dist/src/core/process-liveness.js +44 -0
- package/dist/src/core/process-liveness.js.map +1 -0
- package/dist/src/core/run-status.d.ts +53 -0
- package/dist/src/core/run-status.js +224 -0
- package/dist/src/core/run-status.js.map +1 -0
- package/dist/src/core/state-machine.d.ts +17 -4
- package/dist/src/core/state-machine.js +170 -16
- package/dist/src/core/state-machine.js.map +1 -1
- package/dist/src/jobs/status.d.ts +7 -1
- package/dist/src/jobs/status.js.map +1 -1
- package/dist/src/jobs/supervisor.d.ts +2 -0
- package/dist/src/jobs/supervisor.js +47 -13
- package/dist/src/jobs/supervisor.js.map +1 -1
- package/dist/src/protocol/types.d.ts +1 -1
- package/dist/src/protocol/validate-command.js +18 -0
- package/dist/src/protocol/validate-command.js.map +1 -1
- package/dist/src/router/resolver.d.ts +2 -1
- package/dist/src/router/resolver.js +31 -13
- package/dist/src/router/resolver.js.map +1 -1
- package/dist/src/runners/process-runner.d.ts +2 -2
- package/dist/src/runners/process-runner.js +113 -9
- package/dist/src/runners/process-runner.js.map +1 -1
- package/dist/src/runners/runner-adapter.d.ts +9 -2
- package/dist/src/runners/runner-adapter.js.map +1 -1
- package/dist/src/state/atomic-write.js +17 -2
- package/dist/src/state/atomic-write.js.map +1 -1
- package/dist/src/state/cancellation.d.ts +42 -0
- package/dist/src/state/cancellation.js +168 -0
- package/dist/src/state/cancellation.js.map +1 -0
- package/dist/src/state/event-log.d.ts +17 -1
- package/dist/src/state/event-log.js +328 -18
- package/dist/src/state/event-log.js.map +1 -1
- package/dist/src/state/paths.d.ts +4 -0
- package/dist/src/state/paths.js +4 -0
- package/dist/src/state/paths.js.map +1 -1
- package/dist/src/state/runtime-lease.d.ts +53 -0
- package/dist/src/state/runtime-lease.js +727 -0
- package/dist/src/state/runtime-lease.js.map +1 -0
- package/dist/src/state/runtime-retirement.d.ts +16 -0
- package/dist/src/state/runtime-retirement.js +95 -0
- package/dist/src/state/runtime-retirement.js.map +1 -0
- package/dist/src/state/runtime-takeover-intent.d.ts +1 -0
- package/dist/src/state/runtime-takeover-intent.js +18 -0
- package/dist/src/state/runtime-takeover-intent.js.map +1 -0
- package/dist/src/state/store.d.ts +12 -1
- package/dist/src/state/store.js +246 -40
- package/dist/src/state/store.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/docs/architecture.md +16 -11
- package/docs/assets/cueline-loop-en.svg +11 -10
- package/docs/assets/cueline-loop-ja.svg +11 -10
- package/docs/assets/cueline-loop-ko.svg +11 -10
- package/docs/assets/cueline-loop-zh-CN.svg +11 -10
- package/docs/assets/cueline-loop-zh-TW.svg +11 -10
- package/docs/compatibility.md +16 -8
- package/docs/controller-protocol.md +8 -4
- package/docs/runner-contract.md +26 -6
- package/docs/state-and-recovery.md +69 -16
- package/package.json +4 -3
- package/schemas/controller-observation.schema.json +1 -1
- package/scripts/validate-plugin.mjs +66 -0
- package/skills/cueline/SKILL.md +89 -13
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CueLineError } from "../../core/errors.js";
|
|
2
|
-
import { readPageChatState, resolveIabBrowser, } from "./bootstrap.js";
|
|
2
|
+
import { readPageChatState, readPageComposerState, resolveIabBrowser, } from "./bootstrap.js";
|
|
3
3
|
import { CHATGPT_URL, COMPOSER_TEXTBOX_NAMES, SEND_BUTTON_NAMES } from "./selectors.js";
|
|
4
|
+
import { hasExactControllerEnvelopeIdentity, isProLabel, isProModelSlug, normalizedConversationUrl, normalizedMessageText } from "./recovery-evidence.js";
|
|
5
|
+
import { captureConversationUrlAfterSubmit } from "./submission-url.js";
|
|
4
6
|
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
5
7
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
6
8
|
const DEFAULT_STABLE_MS = 1_500;
|
|
@@ -10,8 +12,36 @@ const MODEL_PICKER_SELECTOR = "button.__composer-pill";
|
|
|
10
12
|
const REQUIRED_MODEL_LABEL = "Pro";
|
|
11
13
|
const MODEL_LABEL_READ_ATTEMPTS = 50;
|
|
12
14
|
const MODEL_LABEL_RETRY_INTERVAL_MS = 100;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const TAB_DISCOVERY_RETRY_MS = 100;
|
|
16
|
+
const COMPOSER_READY_TIMEOUT_MS = 30_000;
|
|
17
|
+
const COMPOSER_READY_STABLE_MS = 250;
|
|
18
|
+
function throwIfCancelled(signal) {
|
|
19
|
+
if (signal?.aborted !== true)
|
|
20
|
+
return;
|
|
21
|
+
if (signal.reason instanceof CueLineError)
|
|
22
|
+
throw signal.reason;
|
|
23
|
+
throw new CueLineError("RUN_CANCELLED", "CueLine run cancellation was requested.", {
|
|
24
|
+
cause: signal.reason,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function delay(milliseconds, signal) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
throwIfCancelled(signal);
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
signal?.removeEventListener("abort", onAbort);
|
|
32
|
+
resolve();
|
|
33
|
+
}, milliseconds);
|
|
34
|
+
const onAbort = () => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
try {
|
|
37
|
+
throwIfCancelled(signal);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
reject(error);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
44
|
+
});
|
|
15
45
|
}
|
|
16
46
|
async function findUniqueLocator(tab, role, names) {
|
|
17
47
|
for (const name of names) {
|
|
@@ -22,6 +52,45 @@ async function findUniqueLocator(tab, role, names) {
|
|
|
22
52
|
}
|
|
23
53
|
return undefined;
|
|
24
54
|
}
|
|
55
|
+
async function findVisibleSendButtonCoordinates(tab) {
|
|
56
|
+
if (!tab.cua?.click)
|
|
57
|
+
return undefined;
|
|
58
|
+
const target = await tab.playwright.evaluate(({ sendButtonNames }) => {
|
|
59
|
+
const normalize = (value) => String(value ?? "").trim().replace(/\s+/g, " ");
|
|
60
|
+
const candidates = Array.from(document.querySelectorAll("button")).filter((element) => {
|
|
61
|
+
const button = element;
|
|
62
|
+
const style = window.getComputedStyle(button);
|
|
63
|
+
const rect = button.getBoundingClientRect();
|
|
64
|
+
const label = normalize(button.getAttribute("aria-label") ?? button.innerText ?? button.textContent);
|
|
65
|
+
return (sendButtonNames.some((name) => name === label) &&
|
|
66
|
+
!button.disabled &&
|
|
67
|
+
button.getAttribute("aria-disabled") !== "true" &&
|
|
68
|
+
style.display !== "none" &&
|
|
69
|
+
style.visibility !== "hidden" &&
|
|
70
|
+
rect.width > 0 &&
|
|
71
|
+
rect.height > 0 &&
|
|
72
|
+
rect.right > 0 &&
|
|
73
|
+
rect.bottom > 0 &&
|
|
74
|
+
rect.left < window.innerWidth &&
|
|
75
|
+
rect.top < window.innerHeight);
|
|
76
|
+
});
|
|
77
|
+
if (candidates.length !== 1)
|
|
78
|
+
return null;
|
|
79
|
+
const rect = candidates[0].getBoundingClientRect();
|
|
80
|
+
return {
|
|
81
|
+
x: rect.left + rect.width / 2,
|
|
82
|
+
y: rect.top + rect.height / 2,
|
|
83
|
+
};
|
|
84
|
+
}, { sendButtonNames: [...SEND_BUTTON_NAMES] }).catch(() => null);
|
|
85
|
+
if (target === null ||
|
|
86
|
+
!Number.isFinite(target.x) ||
|
|
87
|
+
!Number.isFinite(target.y) ||
|
|
88
|
+
target.x < 0 ||
|
|
89
|
+
target.y < 0) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return { x: Math.round(target.x), y: Math.round(target.y) };
|
|
93
|
+
}
|
|
25
94
|
async function findHydratedComposer(tab) {
|
|
26
95
|
if (!tab.playwright.locator)
|
|
27
96
|
return undefined;
|
|
@@ -37,31 +106,12 @@ async function findHydratedComposer(tab) {
|
|
|
37
106
|
function isConversationUrl(url) {
|
|
38
107
|
return /^https:\/\/chatgpt\.com\/c\/[A-Za-z0-9-]+/.test(url);
|
|
39
108
|
}
|
|
40
|
-
function isTransientClickError(error) {
|
|
41
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
-
return /deadline exceeded|timed? out|timeout|detached|stale|not enabled|not visible/i.test(message);
|
|
43
|
-
}
|
|
44
109
|
function isTabUnavailableError(error) {
|
|
45
110
|
const message = error instanceof Error ? error.message : String(error);
|
|
46
111
|
return /tab not found|existing tabs: none|webview attach|cdp operation exceeded|target closed|page closed|browser.*disconnected/i.test(message);
|
|
47
112
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
const parsed = new URL(url);
|
|
51
|
-
return `${parsed.origin}${parsed.pathname}`;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return url;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
function isProLabel(label) {
|
|
58
|
-
return /^Pro(?:\s+(?:Standard|Extended))?$/i.test(label ?? "");
|
|
59
|
-
}
|
|
60
|
-
function isProModelSlug(slug) {
|
|
61
|
-
return /(?:^|-)pro(?:-|$)/i.test(slug ?? "");
|
|
62
|
-
}
|
|
63
|
-
function normalizedMessageText(value) {
|
|
64
|
-
return (value ?? "").replace(/\r\n/g, "\n").trim();
|
|
113
|
+
function ambiguousSubmissionError(error) {
|
|
114
|
+
return new CueLineError("CONTROLLER_SUBMISSION_AMBIGUOUS", "The send-button click failed without proving whether ChatGPT accepted the prompt. Refusing a second click; reconcile the exact conversation instead.", { cause: error });
|
|
65
115
|
}
|
|
66
116
|
function submissionStateForStage(stage) {
|
|
67
117
|
if (stage === "pre_submit")
|
|
@@ -89,8 +139,9 @@ async function readComposerModelLabel(tab) {
|
|
|
89
139
|
return labels.length === 1 ? labels[0] : null;
|
|
90
140
|
}, { modelPickerSelector: MODEL_PICKER_SELECTOR });
|
|
91
141
|
}
|
|
92
|
-
async function readComposerModelLabelWhenReady(tab) {
|
|
142
|
+
async function readComposerModelLabelWhenReady(tab, signal) {
|
|
93
143
|
for (let attempt = 0; attempt < MODEL_LABEL_READ_ATTEMPTS; attempt += 1) {
|
|
144
|
+
throwIfCancelled(signal);
|
|
94
145
|
const label = await readComposerModelLabel(tab);
|
|
95
146
|
if (label !== null)
|
|
96
147
|
return label;
|
|
@@ -141,34 +192,42 @@ class CodexIabAdapter {
|
|
|
141
192
|
const matchesTarget = (url) => targetUrl
|
|
142
193
|
? normalizedConversationUrl(url) === normalizedConversationUrl(targetUrl)
|
|
143
194
|
: url.startsWith(CHATGPT_URL);
|
|
144
|
-
const selected = await browser.tabs.selected?.().catch(() => undefined);
|
|
145
|
-
if (selected && matchesTarget((await selected.url().catch(() => "")) ?? "")) {
|
|
146
|
-
this.#tab = selected;
|
|
147
|
-
return selected;
|
|
148
|
-
}
|
|
149
|
-
const sessionTabs = await browser.tabs.list?.().catch(() => []);
|
|
150
|
-
const sessionCandidate = sessionTabs?.find((tab) => matchesTarget(String(tab.url ?? "")));
|
|
151
195
|
let tab;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
const canDiscover = browser.tabs.selected !== undefined ||
|
|
197
|
+
browser.tabs.list !== undefined ||
|
|
198
|
+
browser.user?.openTabs !== undefined;
|
|
199
|
+
const discoveryPasses = canDiscover ? 2 : 1;
|
|
200
|
+
for (let pass = 0; pass < discoveryPasses && tab === undefined; pass += 1) {
|
|
201
|
+
const selected = await browser.tabs.selected?.();
|
|
202
|
+
if (selected && matchesTarget((await selected.url()) ?? "")) {
|
|
203
|
+
tab = selected;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
const sessionTabs = (await browser.tabs.list?.()) ?? [];
|
|
207
|
+
const sessionCandidate = sessionTabs.find((candidate) => matchesTarget(String(candidate.url ?? "")));
|
|
208
|
+
if (sessionCandidate?.id && browser.tabs.get) {
|
|
209
|
+
tab = await browser.tabs.get(sessionCandidate.id);
|
|
210
|
+
}
|
|
211
|
+
if (tab === undefined) {
|
|
212
|
+
const openTabs = (await browser.user?.openTabs?.()) ?? [];
|
|
213
|
+
const candidate = openTabs.find((openTab) => matchesTarget(String(openTab.url ?? "")));
|
|
214
|
+
if (candidate && browser.user?.claimTab) {
|
|
215
|
+
tab = await browser.user.claimTab(candidate);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (tab === undefined && pass < discoveryPasses - 1) {
|
|
219
|
+
await delay(TAB_DISCOVERY_RETRY_MS);
|
|
220
|
+
}
|
|
162
221
|
}
|
|
163
|
-
|
|
222
|
+
if (tab === undefined) {
|
|
164
223
|
tab = await browser.tabs.new();
|
|
165
224
|
await tab.goto(targetUrl ?? CHATGPT_URL);
|
|
166
225
|
}
|
|
167
|
-
this.#tab = tab;
|
|
168
226
|
await tab.playwright.waitForLoadState?.({
|
|
169
227
|
state: "domcontentloaded",
|
|
170
228
|
timeoutMs: 30_000,
|
|
171
229
|
});
|
|
230
|
+
this.#tab = tab;
|
|
172
231
|
return tab;
|
|
173
232
|
}
|
|
174
233
|
async #ensureProModel(tab) {
|
|
@@ -204,6 +263,7 @@ class CodexIabAdapter {
|
|
|
204
263
|
return true;
|
|
205
264
|
}
|
|
206
265
|
return (await readPageChatState(tab).catch(() => ({
|
|
266
|
+
pageUrl: "",
|
|
207
267
|
isAnswering: false,
|
|
208
268
|
assistantText: "",
|
|
209
269
|
assistantMessageCount: 0,
|
|
@@ -212,38 +272,41 @@ class CodexIabAdapter {
|
|
|
212
272
|
lastMessageRole: null,
|
|
213
273
|
}))).isAnswering;
|
|
214
274
|
}
|
|
215
|
-
async #
|
|
275
|
+
async #resolveSendTarget(tab) {
|
|
276
|
+
const locator = await findUniqueLocator(tab, "button", SEND_BUTTON_NAMES);
|
|
277
|
+
if (locator)
|
|
278
|
+
return { kind: "locator", locator };
|
|
279
|
+
const coordinate = await findVisibleSendButtonCoordinates(tab);
|
|
280
|
+
if (coordinate)
|
|
281
|
+
return { kind: "coordinate", ...coordinate };
|
|
282
|
+
throw new CueLineError("SEND_BUTTON_MISSING", "Could not find ChatGPT's send button.");
|
|
283
|
+
}
|
|
284
|
+
async #clickSend(tab, target) {
|
|
216
285
|
const previousUrl = (await tab.url()) ?? "";
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (attempt > 0 && await this.#submissionStarted(tab, previousUrl)) {
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
throw new CueLineError("SEND_BUTTON_MISSING", "Could not find ChatGPT's send button.");
|
|
224
|
-
}
|
|
225
|
-
try {
|
|
226
|
-
await button.click({ timeoutMs: 10_000 });
|
|
227
|
-
return;
|
|
286
|
+
try {
|
|
287
|
+
if (target.kind === "locator") {
|
|
288
|
+
await target.locator.click({ timeoutMs: 10_000 });
|
|
228
289
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
if (attempt === 1 || !isTransientClickError(error)) {
|
|
234
|
-
throw error;
|
|
235
|
-
}
|
|
236
|
-
await tab.playwright.domSnapshot();
|
|
237
|
-
await tab.playwright.waitForTimeout(250);
|
|
290
|
+
else {
|
|
291
|
+
await tab.cua.click({ x: target.x, y: target.y });
|
|
292
|
+
await tab.playwright.waitForTimeout(100);
|
|
238
293
|
}
|
|
239
294
|
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (await this.#submissionStarted(tab, previousUrl))
|
|
297
|
+
return;
|
|
298
|
+
if (isTabUnavailableError(error))
|
|
299
|
+
throw error;
|
|
300
|
+
throw ambiguousSubmissionError(error);
|
|
301
|
+
}
|
|
240
302
|
}
|
|
241
|
-
async #waitForCompletion(tab, baseline) {
|
|
303
|
+
async #waitForCompletion(tab, baseline, signal) {
|
|
242
304
|
const deadline = Date.now() + this.#options.timeoutMs;
|
|
243
305
|
let stableText = "";
|
|
244
306
|
let stableSince = 0;
|
|
245
307
|
let responseStarted = false;
|
|
246
308
|
while (Date.now() < deadline) {
|
|
309
|
+
throwIfCancelled(signal);
|
|
247
310
|
const state = await readPageChatState(tab);
|
|
248
311
|
if (state.isAnswering || state.assistantMessageCount > baseline.assistantMessageCount) {
|
|
249
312
|
responseStarted = true;
|
|
@@ -261,22 +324,26 @@ class CodexIabAdapter {
|
|
|
261
324
|
stableText = state.assistantText;
|
|
262
325
|
stableSince = Date.now();
|
|
263
326
|
}
|
|
264
|
-
await delay(this.#options.pollIntervalMs);
|
|
327
|
+
await delay(this.#options.pollIntervalMs, signal);
|
|
265
328
|
}
|
|
266
329
|
throw new CueLineError("CHATGPT_RESPONSE_TIMEOUT", "ChatGPT did not finish before timeout.");
|
|
267
330
|
}
|
|
268
|
-
async #waitForRecoveredCompletion(tab, expectedPrompt) {
|
|
331
|
+
async #waitForRecoveredCompletion(tab, expectedPrompt, allowVisiblePromptMismatch, manualSendConfirmed, baselineAssistantMessageCount, expectedConversationUrl, expectedIdentity, signal) {
|
|
332
|
+
const attachmentBaseline = allowVisiblePromptMismatch
|
|
333
|
+
? baselineAssistantMessageCount
|
|
334
|
+
: undefined;
|
|
335
|
+
if (allowVisiblePromptMismatch &&
|
|
336
|
+
attachmentBaseline === undefined &&
|
|
337
|
+
!manualSendConfirmed) {
|
|
338
|
+
throw new CueLineError("CONTROLLER_RECONCILIATION_BASELINE_REQUIRED", "Attachment recovery requires the durable pre-submit assistant message count; refusing to import a possibly stale response.");
|
|
339
|
+
}
|
|
269
340
|
const deadline = Date.now() + this.#options.timeoutMs;
|
|
270
341
|
let stableText = "";
|
|
271
342
|
let stableSince = 0;
|
|
272
343
|
while (Date.now() < deadline) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
if (!state.isAnswering &&
|
|
278
|
-
state.lastMessageRole === "assistant" &&
|
|
279
|
-
state.assistantText !== "") {
|
|
344
|
+
throwIfCancelled(signal);
|
|
345
|
+
const state = await this.#readRecoveredCandidate(tab, expectedPrompt, allowVisiblePromptMismatch, manualSendConfirmed, attachmentBaseline, expectedConversationUrl, expectedIdentity);
|
|
346
|
+
if (state !== undefined) {
|
|
280
347
|
if (state.assistantText !== stableText) {
|
|
281
348
|
stableText = state.assistantText;
|
|
282
349
|
stableSince = Date.now();
|
|
@@ -288,10 +355,87 @@ class CodexIabAdapter {
|
|
|
288
355
|
stableText = "";
|
|
289
356
|
stableSince = Date.now();
|
|
290
357
|
}
|
|
291
|
-
await delay(this.#options.pollIntervalMs);
|
|
358
|
+
await delay(this.#options.pollIntervalMs, signal);
|
|
292
359
|
}
|
|
293
360
|
throw new CueLineError("CONTROLLER_RECONCILIATION_TIMEOUT", "The existing ChatGPT response did not become readable before timeout; no prompt was sent.");
|
|
294
361
|
}
|
|
362
|
+
async #readRecoveredCandidate(tab, expectedPrompt, allowVisiblePromptMismatch, manualSendConfirmed, attachmentBaseline, expectedConversationUrl, expectedIdentity) {
|
|
363
|
+
const state = await readPageChatState(tab);
|
|
364
|
+
if (normalizedConversationUrl(state.pageUrl) !==
|
|
365
|
+
normalizedConversationUrl(expectedConversationUrl)) {
|
|
366
|
+
throw new CueLineError("CONTROLLER_RECONCILIATION_CONVERSATION_MISMATCH", "The response evidence was read from a different ChatGPT conversation DOM.");
|
|
367
|
+
}
|
|
368
|
+
if (attachmentBaseline !== undefined &&
|
|
369
|
+
state.assistantMessageCount <= attachmentBaseline)
|
|
370
|
+
return undefined;
|
|
371
|
+
if (manualSendConfirmed &&
|
|
372
|
+
attachmentBaseline === undefined &&
|
|
373
|
+
!hasExactControllerEnvelopeIdentity(state.assistantText, expectedIdentity))
|
|
374
|
+
return undefined;
|
|
375
|
+
if (state.lastUserText === null && !manualSendConfirmed)
|
|
376
|
+
return undefined;
|
|
377
|
+
if (!allowVisiblePromptMismatch &&
|
|
378
|
+
normalizedMessageText(state.lastUserText) !== normalizedMessageText(expectedPrompt)) {
|
|
379
|
+
throw new CueLineError("CONTROLLER_RECONCILIATION_MISMATCH", "The exact conversation's last user message does not match the pending CueLine prompt. Refusing to import an unrelated response.");
|
|
380
|
+
}
|
|
381
|
+
return !state.isAnswering &&
|
|
382
|
+
state.lastMessageRole === "assistant" &&
|
|
383
|
+
state.assistantText !== ""
|
|
384
|
+
? state
|
|
385
|
+
: undefined;
|
|
386
|
+
}
|
|
387
|
+
async #observeRecoveredCompletion(tab, expectedPrompt, allowVisiblePromptMismatch, manualSendConfirmed, baselineAssistantMessageCount, expectedConversationUrl, expectedIdentity, signal) {
|
|
388
|
+
if (allowVisiblePromptMismatch &&
|
|
389
|
+
baselineAssistantMessageCount === undefined &&
|
|
390
|
+
!manualSendConfirmed) {
|
|
391
|
+
throw new CueLineError("CONTROLLER_RECONCILIATION_BASELINE_REQUIRED", "Attachment recovery requires the durable pre-submit assistant message count; refusing to import a possibly stale response.");
|
|
392
|
+
}
|
|
393
|
+
throwIfCancelled(signal);
|
|
394
|
+
const first = await this.#readRecoveredCandidate(tab, expectedPrompt, allowVisiblePromptMismatch, manualSendConfirmed, allowVisiblePromptMismatch ? baselineAssistantMessageCount : undefined, expectedConversationUrl, expectedIdentity);
|
|
395
|
+
if (first === undefined)
|
|
396
|
+
return undefined;
|
|
397
|
+
if (this.#options.stableMs > 0)
|
|
398
|
+
await delay(this.#options.stableMs, signal);
|
|
399
|
+
const second = await this.#readRecoveredCandidate(tab, expectedPrompt, allowVisiblePromptMismatch, manualSendConfirmed, allowVisiblePromptMismatch ? baselineAssistantMessageCount : undefined, expectedConversationUrl, expectedIdentity);
|
|
400
|
+
return second !== undefined &&
|
|
401
|
+
second.assistantText === first.assistantText &&
|
|
402
|
+
second.assistantMessageCount === first.assistantMessageCount &&
|
|
403
|
+
second.assistantModelSlug === first.assistantModelSlug
|
|
404
|
+
? second
|
|
405
|
+
: undefined;
|
|
406
|
+
}
|
|
407
|
+
async #waitForComposerReady(tab, expectedPrompt, baselineAttachmentCount, signal) {
|
|
408
|
+
const deadline = Date.now() + Math.min(COMPOSER_READY_TIMEOUT_MS, this.#options.timeoutMs);
|
|
409
|
+
const stableRequirement = Math.min(COMPOSER_READY_STABLE_MS, this.#options.stableMs);
|
|
410
|
+
let stableSignature = "";
|
|
411
|
+
let stableSince = 0;
|
|
412
|
+
let lastState = await readPageComposerState(tab, expectedPrompt, SEND_BUTTON_NAMES);
|
|
413
|
+
while (Date.now() < deadline) {
|
|
414
|
+
throwIfCancelled(signal);
|
|
415
|
+
const state = await readPageComposerState(tab, expectedPrompt, SEND_BUTTON_NAMES);
|
|
416
|
+
lastState = state;
|
|
417
|
+
const ready = state.sendButtonEnabled &&
|
|
418
|
+
(state.state === "inline_ready" ||
|
|
419
|
+
(state.state === "attachment_ready" &&
|
|
420
|
+
state.attachmentCount > baselineAttachmentCount));
|
|
421
|
+
const signature = `${state.state}:${state.inlineTextLength}:${state.attachmentCount}:${state.sendButtonEnabled}`;
|
|
422
|
+
if (ready) {
|
|
423
|
+
if (signature !== stableSignature) {
|
|
424
|
+
stableSignature = signature;
|
|
425
|
+
stableSince = Date.now();
|
|
426
|
+
}
|
|
427
|
+
if (Date.now() - stableSince >= stableRequirement && ready) {
|
|
428
|
+
return state.state === "inline_ready" ? "inline_ready" : "attachment_ready";
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
stableSignature = "";
|
|
433
|
+
stableSince = 0;
|
|
434
|
+
}
|
|
435
|
+
await delay(Math.min(this.#options.pollIntervalMs, 100), signal);
|
|
436
|
+
}
|
|
437
|
+
throw new CueLineError("CONTROLLER_PROMPT_NOT_READY", "ChatGPT exposed neither the exact inline prompt nor an attachment with an enabled send button after the composer settled.", { details: { composer_state: lastState.state, attachment_count: lastState.attachmentCount } });
|
|
438
|
+
}
|
|
295
439
|
async #resultFromCompletedTurn(tab, selectedModelLabel, completed) {
|
|
296
440
|
if (!isProModelSlug(completed.assistantModelSlug)) {
|
|
297
441
|
throw new CueLineError("PRO_MODEL_MISMATCH", `ChatGPT returned model '${completed.assistantModelSlug ?? "unknown"}' after Pro was selected. Refusing the controller response.`, {
|
|
@@ -301,7 +445,10 @@ class CodexIabAdapter {
|
|
|
301
445
|
},
|
|
302
446
|
});
|
|
303
447
|
}
|
|
304
|
-
|
|
448
|
+
// Bind response text and URL from one DOM evaluation. A later CDP URL
|
|
449
|
+
// read can observe navigation to another conversation and must never be
|
|
450
|
+
// paired with this completed assistant message.
|
|
451
|
+
const conversationUrl = completed.pageUrl;
|
|
305
452
|
if (isConversationUrl(conversationUrl))
|
|
306
453
|
this.#conversationUrl = conversationUrl;
|
|
307
454
|
const title = await tab.title?.();
|
|
@@ -318,8 +465,10 @@ class CodexIabAdapter {
|
|
|
318
465
|
...(title === undefined || title === "" ? {} : { title }),
|
|
319
466
|
};
|
|
320
467
|
}
|
|
321
|
-
async #
|
|
468
|
+
async #submitTurnOnce(input, context, hooks, requireRecoverableCheckpoint = false) {
|
|
469
|
+
throwIfCancelled(input.signal);
|
|
322
470
|
const tab = await this.#getTab();
|
|
471
|
+
throwIfCancelled(input.signal);
|
|
323
472
|
const composer = (await findHydratedComposer(tab)) ??
|
|
324
473
|
(await findUniqueLocator(tab, "textbox", COMPOSER_TEXTBOX_NAMES));
|
|
325
474
|
if (!composer) {
|
|
@@ -327,24 +476,48 @@ class CodexIabAdapter {
|
|
|
327
476
|
}
|
|
328
477
|
context.selectedModelLabel = await this.#ensureProModel(tab);
|
|
329
478
|
context.baseline = await readPageChatState(tab);
|
|
479
|
+
const composerBaseline = await readPageComposerState(tab, input.prompt, SEND_BUTTON_NAMES);
|
|
480
|
+
if (composerBaseline.attachmentCount > 0) {
|
|
481
|
+
throw new CueLineError("CONTROLLER_PROMPT_NOT_READY", "The ChatGPT composer already contains an attachment. Refusing to mix it with the current controller prompt.", {
|
|
482
|
+
details: {
|
|
483
|
+
composer_state: composerBaseline.state,
|
|
484
|
+
attachment_count: composerBaseline.attachmentCount,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|
|
330
488
|
await composer.fill(input.prompt, {});
|
|
331
|
-
|
|
489
|
+
throwIfCancelled(input.signal);
|
|
490
|
+
context.composerPromptState = await this.#waitForComposerReady(tab, input.prompt, composerBaseline.attachmentCount, input.signal);
|
|
491
|
+
const sendTarget = await this.#resolveSendTarget(tab);
|
|
492
|
+
await this.#emitCheckpoint(tab, context, hooks, "submitting");
|
|
332
493
|
context.stage = "submitting";
|
|
333
|
-
await this.#clickSend(tab);
|
|
494
|
+
await this.#clickSend(tab, sendTarget);
|
|
495
|
+
if (requireRecoverableCheckpoint) {
|
|
496
|
+
this.#conversationUrl = await captureConversationUrlAfterSubmit(tab, this.#conversationUrl, this.#options.timeoutMs, this.#options.pollIntervalMs, input.signal);
|
|
497
|
+
}
|
|
334
498
|
context.stage = "submitted";
|
|
335
|
-
await this.#emitCheckpoint(tab, context, hooks, "submitted");
|
|
336
|
-
|
|
499
|
+
await this.#emitCheckpoint(tab, context, hooks, "submitted", requireRecoverableCheckpoint ? this.#conversationUrl : undefined);
|
|
500
|
+
return tab;
|
|
501
|
+
}
|
|
502
|
+
async #sendTurnOnce(input, context, hooks) {
|
|
503
|
+
const tab = await this.#submitTurnOnce(input, context, hooks);
|
|
504
|
+
const completed = await this.#waitForCompletion(tab, context.baseline, input.signal);
|
|
337
505
|
return this.#resultFromCompletedTurn(tab, context.selectedModelLabel, completed);
|
|
338
506
|
}
|
|
339
|
-
async #emitCheckpoint(tab, context, hooks, submissionState) {
|
|
340
|
-
if (!context.baseline || !context.selectedModelLabel)
|
|
507
|
+
async #emitCheckpoint(tab, context, hooks, submissionState, capturedConversationUrl) {
|
|
508
|
+
if (!context.baseline || !context.selectedModelLabel || !context.composerPromptState)
|
|
341
509
|
return;
|
|
342
|
-
const currentUrl = (await tab.url().catch(() => "")) ?? "";
|
|
343
|
-
|
|
344
|
-
|
|
510
|
+
const currentUrl = capturedConversationUrl ?? (await tab.url().catch(() => "")) ?? "";
|
|
511
|
+
const checkpointUrl = isConversationUrl(currentUrl) ? currentUrl : this.#conversationUrl;
|
|
512
|
+
if (checkpointUrl !== undefined && isConversationUrl(checkpointUrl)) {
|
|
513
|
+
this.#conversationUrl = checkpointUrl;
|
|
514
|
+
}
|
|
345
515
|
await hooks?.onCheckpoint?.({
|
|
346
516
|
submissionState,
|
|
347
|
-
|
|
517
|
+
composerPromptState: context.composerPromptState,
|
|
518
|
+
...(checkpointUrl !== undefined && isConversationUrl(checkpointUrl)
|
|
519
|
+
? { conversationUrl: checkpointUrl }
|
|
520
|
+
: {}),
|
|
348
521
|
selectedModelLabel: context.selectedModelLabel,
|
|
349
522
|
baselineAssistantMessageCount: context.baseline.assistantMessageCount,
|
|
350
523
|
});
|
|
@@ -380,6 +553,13 @@ class CodexIabAdapter {
|
|
|
380
553
|
catch (error) {
|
|
381
554
|
if (!isTabUnavailableError(error))
|
|
382
555
|
throw this.#browserFailure(error, context, input);
|
|
556
|
+
this.#tab = undefined;
|
|
557
|
+
if (context.stage === "pre_submit") {
|
|
558
|
+
const retryContext = { stage: "pre_submit" };
|
|
559
|
+
return this.#sendTurnOnce(input, retryContext, hooks).catch((retryError) => {
|
|
560
|
+
throw this.#browserFailure(retryError, retryContext, input);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
383
563
|
if (this.#conversationUrl === undefined) {
|
|
384
564
|
throw new CueLineError("TAB_RECOVERY_UNSAFE", "The ChatGPT tab disappeared before CueLine captured an exact conversation URL; refusing a potentially duplicate send.", {
|
|
385
565
|
cause: error,
|
|
@@ -390,13 +570,6 @@ class CodexIabAdapter {
|
|
|
390
570
|
},
|
|
391
571
|
});
|
|
392
572
|
}
|
|
393
|
-
this.#tab = undefined;
|
|
394
|
-
if (context.stage === "pre_submit") {
|
|
395
|
-
const retryContext = { stage: "pre_submit" };
|
|
396
|
-
return this.#sendTurnOnce(input, retryContext, hooks).catch((retryError) => {
|
|
397
|
-
throw this.#browserFailure(retryError, retryContext, input);
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
573
|
if (!context.baseline || !context.selectedModelLabel)
|
|
401
574
|
throw error;
|
|
402
575
|
try {
|
|
@@ -414,7 +587,7 @@ class CodexIabAdapter {
|
|
|
414
587
|
},
|
|
415
588
|
});
|
|
416
589
|
}
|
|
417
|
-
const completed = await this.#waitForCompletion(recoveredTab, context.baseline);
|
|
590
|
+
const completed = await this.#waitForCompletion(recoveredTab, context.baseline, input.signal);
|
|
418
591
|
return this.#resultFromCompletedTurn(recoveredTab, context.selectedModelLabel, completed);
|
|
419
592
|
}
|
|
420
593
|
catch (recoveryError) {
|
|
@@ -422,7 +595,24 @@ class CodexIabAdapter {
|
|
|
422
595
|
}
|
|
423
596
|
}
|
|
424
597
|
}
|
|
425
|
-
async
|
|
598
|
+
async submitTurn(input, hooks) {
|
|
599
|
+
const context = { stage: "pre_submit" };
|
|
600
|
+
try {
|
|
601
|
+
await this.#submitTurnOnce(input, context, hooks, true);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
if (!isTabUnavailableError(error) || context.stage !== "pre_submit") {
|
|
605
|
+
throw this.#browserFailure(error, context, input);
|
|
606
|
+
}
|
|
607
|
+
this.#tab = undefined;
|
|
608
|
+
const retryContext = { stage: "pre_submit" };
|
|
609
|
+
await this.#submitTurnOnce(input, retryContext, hooks, true).catch((retryError) => {
|
|
610
|
+
throw this.#browserFailure(retryError, retryContext, input);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async #readExistingTurn(input, waitForCompletion) {
|
|
615
|
+
throwIfCancelled(input.signal);
|
|
426
616
|
if (this.#conversationUrl === undefined) {
|
|
427
617
|
throw new CueLineError("CONTROLLER_RECONCILIATION_URL_REQUIRED", "CueLine needs the exact persisted ChatGPT conversation URL to recover without sending.", {
|
|
428
618
|
details: {
|
|
@@ -432,32 +622,68 @@ class CodexIabAdapter {
|
|
|
432
622
|
},
|
|
433
623
|
});
|
|
434
624
|
}
|
|
625
|
+
const tab = await this.#getTab();
|
|
626
|
+
throwIfCancelled(input.signal);
|
|
627
|
+
const selectedModelLabel = await readComposerModelLabelWhenReady(tab, input.signal);
|
|
628
|
+
if (!isProLabel(selectedModelLabel)) {
|
|
629
|
+
throw new CueLineError("CONTROLLER_RECONCILIATION_MODEL_UNVERIFIED", "The existing conversation does not currently expose a Pro composer label; refusing to import the response without both model checks.");
|
|
630
|
+
}
|
|
631
|
+
const allowVisiblePromptMismatch = input.manualSendConfirmed === true || input.attachmentPromptExpected === true;
|
|
632
|
+
const baselineAssistantMessageCount = typeof input.baselineAssistantMessageCount === "number" &&
|
|
633
|
+
Number.isSafeInteger(input.baselineAssistantMessageCount) &&
|
|
634
|
+
input.baselineAssistantMessageCount >= 0
|
|
635
|
+
? input.baselineAssistantMessageCount
|
|
636
|
+
: undefined;
|
|
637
|
+
const expectedIdentity = {
|
|
638
|
+
runId: input.runId, round: input.round, requestId: input.requestId,
|
|
639
|
+
};
|
|
640
|
+
const completed = waitForCompletion
|
|
641
|
+
? await this.#waitForRecoveredCompletion(tab, input.prompt, allowVisiblePromptMismatch, input.manualSendConfirmed === true, baselineAssistantMessageCount, this.#conversationUrl, expectedIdentity, input.signal)
|
|
642
|
+
: await this.#observeRecoveredCompletion(tab, input.prompt, allowVisiblePromptMismatch, input.manualSendConfirmed === true, baselineAssistantMessageCount, this.#conversationUrl, expectedIdentity, input.signal);
|
|
643
|
+
if (completed === undefined)
|
|
644
|
+
return undefined;
|
|
645
|
+
const recoveredUrl = (await tab.url()) ?? "";
|
|
646
|
+
if (normalizedConversationUrl(recoveredUrl) !==
|
|
647
|
+
normalizedConversationUrl(this.#conversationUrl)) {
|
|
648
|
+
throw new CueLineError("CONTROLLER_RECONCILIATION_CONVERSATION_MISMATCH", "The recovered response is no longer on the exact persisted ChatGPT conversation URL.");
|
|
649
|
+
}
|
|
650
|
+
return this.#resultFromCompletedTurn(tab, selectedModelLabel, completed);
|
|
651
|
+
}
|
|
652
|
+
#reconciliationFailure(error, input) {
|
|
653
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
654
|
+
const existingDetails = error instanceof CueLineError &&
|
|
655
|
+
typeof error.details === "object" &&
|
|
656
|
+
error.details !== null &&
|
|
657
|
+
!Array.isArray(error.details)
|
|
658
|
+
? error.details
|
|
659
|
+
: {};
|
|
660
|
+
return new CueLineError(error instanceof CueLineError ? error.code : "IAB_RECONCILIATION_FAILED", message, {
|
|
661
|
+
cause: error,
|
|
662
|
+
details: {
|
|
663
|
+
...existingDetails,
|
|
664
|
+
stage: "reconciling",
|
|
665
|
+
submission_state: "possibly_sent",
|
|
666
|
+
request_id: input.requestId,
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
async observeTurn(input) {
|
|
435
671
|
try {
|
|
436
|
-
|
|
437
|
-
const selectedModelLabel = await readComposerModelLabelWhenReady(tab);
|
|
438
|
-
if (!isProLabel(selectedModelLabel)) {
|
|
439
|
-
throw new CueLineError("CONTROLLER_RECONCILIATION_MODEL_UNVERIFIED", "The existing conversation does not currently expose a Pro composer label; refusing to import the response without both model checks.");
|
|
440
|
-
}
|
|
441
|
-
const completed = await this.#waitForRecoveredCompletion(tab, input.prompt);
|
|
442
|
-
return this.#resultFromCompletedTurn(tab, selectedModelLabel, completed);
|
|
672
|
+
return await this.#readExistingTurn(input, false);
|
|
443
673
|
}
|
|
444
674
|
catch (error) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
submission_state: "possibly_sent",
|
|
458
|
-
request_id: input.requestId,
|
|
459
|
-
},
|
|
460
|
-
});
|
|
675
|
+
throw this.#reconciliationFailure(error, input);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async recoverTurn(input) {
|
|
679
|
+
try {
|
|
680
|
+
const turn = await this.#readExistingTurn(input, true);
|
|
681
|
+
if (turn === undefined)
|
|
682
|
+
throw new Error("IAB_RECOVERY_RETURNED_WITHOUT_RESPONSE");
|
|
683
|
+
return turn;
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
throw this.#reconciliationFailure(error, input);
|
|
461
687
|
}
|
|
462
688
|
}
|
|
463
689
|
}
|