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.
Files changed (116) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +3 -3
  3. package/CHANGELOG.md +38 -0
  4. package/README.ja.md +65 -21
  5. package/README.ko.md +65 -21
  6. package/README.md +70 -23
  7. package/README.zh-CN.md +65 -21
  8. package/README.zh-TW.md +68 -22
  9. package/dist/src/api-contracts.d.ts +77 -0
  10. package/dist/src/api-contracts.js +2 -0
  11. package/dist/src/api-contracts.js.map +1 -0
  12. package/dist/src/api-controller-handoff.d.ts +6 -0
  13. package/dist/src/api-controller-handoff.js +253 -0
  14. package/dist/src/api-controller-handoff.js.map +1 -0
  15. package/dist/src/api-runtime-lifecycle.d.ts +22 -0
  16. package/dist/src/api-runtime-lifecycle.js +568 -0
  17. package/dist/src/api-runtime-lifecycle.js.map +1 -0
  18. package/dist/src/api.d.ts +9 -27
  19. package/dist/src/api.js +138 -42
  20. package/dist/src/api.js.map +1 -1
  21. package/dist/src/browser/browser-adapter.d.ts +11 -1
  22. package/dist/src/browser/codex-iab/bootstrap.d.ts +16 -0
  23. package/dist/src/browser/codex-iab/bootstrap.js +49 -0
  24. package/dist/src/browser/codex-iab/bootstrap.js.map +1 -1
  25. package/dist/src/browser/codex-iab/chatgpt-client.js +346 -120
  26. package/dist/src/browser/codex-iab/chatgpt-client.js.map +1 -1
  27. package/dist/src/browser/codex-iab/recovery-evidence.d.ts +6 -0
  28. package/dist/src/browser/codex-iab/recovery-evidence.js +37 -0
  29. package/dist/src/browser/codex-iab/recovery-evidence.js.map +1 -0
  30. package/dist/src/browser/codex-iab/submission-url.d.ts +2 -0
  31. package/dist/src/browser/codex-iab/submission-url.js +53 -0
  32. package/dist/src/browser/codex-iab/submission-url.js.map +1 -0
  33. package/dist/src/cli/main.js +305 -20
  34. package/dist/src/cli/main.js.map +1 -1
  35. package/dist/src/core/controller-abort.d.ts +1 -0
  36. package/dist/src/core/controller-abort.js +11 -0
  37. package/dist/src/core/controller-abort.js.map +1 -0
  38. package/dist/src/core/controller-command-execution.d.ts +9 -0
  39. package/dist/src/core/controller-command-execution.js +299 -0
  40. package/dist/src/core/controller-command-execution.js.map +1 -0
  41. package/dist/src/core/controller-loop.d.ts +3 -37
  42. package/dist/src/core/controller-loop.js +386 -291
  43. package/dist/src/core/controller-loop.js.map +1 -1
  44. package/dist/src/core/controller-turn.d.ts +16 -0
  45. package/dist/src/core/controller-turn.js +309 -0
  46. package/dist/src/core/controller-turn.js.map +1 -0
  47. package/dist/src/core/controller-types.d.ts +58 -0
  48. package/dist/src/core/controller-types.js +2 -0
  49. package/dist/src/core/controller-types.js.map +1 -0
  50. package/dist/src/core/persisted-run.d.ts +4 -0
  51. package/dist/src/core/persisted-run.js +19 -0
  52. package/dist/src/core/persisted-run.js.map +1 -0
  53. package/dist/src/core/process-liveness.d.ts +2 -0
  54. package/dist/src/core/process-liveness.js +44 -0
  55. package/dist/src/core/process-liveness.js.map +1 -0
  56. package/dist/src/core/run-status.d.ts +53 -0
  57. package/dist/src/core/run-status.js +224 -0
  58. package/dist/src/core/run-status.js.map +1 -0
  59. package/dist/src/core/state-machine.d.ts +17 -4
  60. package/dist/src/core/state-machine.js +170 -16
  61. package/dist/src/core/state-machine.js.map +1 -1
  62. package/dist/src/jobs/status.d.ts +7 -1
  63. package/dist/src/jobs/status.js.map +1 -1
  64. package/dist/src/jobs/supervisor.d.ts +2 -0
  65. package/dist/src/jobs/supervisor.js +47 -13
  66. package/dist/src/jobs/supervisor.js.map +1 -1
  67. package/dist/src/protocol/types.d.ts +1 -1
  68. package/dist/src/protocol/validate-command.js +18 -0
  69. package/dist/src/protocol/validate-command.js.map +1 -1
  70. package/dist/src/router/resolver.d.ts +2 -1
  71. package/dist/src/router/resolver.js +31 -13
  72. package/dist/src/router/resolver.js.map +1 -1
  73. package/dist/src/runners/process-runner.d.ts +2 -2
  74. package/dist/src/runners/process-runner.js +113 -9
  75. package/dist/src/runners/process-runner.js.map +1 -1
  76. package/dist/src/runners/runner-adapter.d.ts +9 -2
  77. package/dist/src/runners/runner-adapter.js.map +1 -1
  78. package/dist/src/state/atomic-write.js +17 -2
  79. package/dist/src/state/atomic-write.js.map +1 -1
  80. package/dist/src/state/cancellation.d.ts +42 -0
  81. package/dist/src/state/cancellation.js +168 -0
  82. package/dist/src/state/cancellation.js.map +1 -0
  83. package/dist/src/state/event-log.d.ts +17 -1
  84. package/dist/src/state/event-log.js +328 -18
  85. package/dist/src/state/event-log.js.map +1 -1
  86. package/dist/src/state/paths.d.ts +4 -0
  87. package/dist/src/state/paths.js +4 -0
  88. package/dist/src/state/paths.js.map +1 -1
  89. package/dist/src/state/runtime-lease.d.ts +53 -0
  90. package/dist/src/state/runtime-lease.js +727 -0
  91. package/dist/src/state/runtime-lease.js.map +1 -0
  92. package/dist/src/state/runtime-retirement.d.ts +16 -0
  93. package/dist/src/state/runtime-retirement.js +95 -0
  94. package/dist/src/state/runtime-retirement.js.map +1 -0
  95. package/dist/src/state/runtime-takeover-intent.d.ts +1 -0
  96. package/dist/src/state/runtime-takeover-intent.js +18 -0
  97. package/dist/src/state/runtime-takeover-intent.js.map +1 -0
  98. package/dist/src/state/store.d.ts +12 -1
  99. package/dist/src/state/store.js +246 -40
  100. package/dist/src/state/store.js.map +1 -1
  101. package/dist/src/version.d.ts +1 -1
  102. package/dist/src/version.js +1 -1
  103. package/docs/architecture.md +16 -11
  104. package/docs/assets/cueline-loop-en.svg +11 -10
  105. package/docs/assets/cueline-loop-ja.svg +11 -10
  106. package/docs/assets/cueline-loop-ko.svg +11 -10
  107. package/docs/assets/cueline-loop-zh-CN.svg +11 -10
  108. package/docs/assets/cueline-loop-zh-TW.svg +11 -10
  109. package/docs/compatibility.md +16 -8
  110. package/docs/controller-protocol.md +8 -4
  111. package/docs/runner-contract.md +26 -6
  112. package/docs/state-and-recovery.md +69 -16
  113. package/package.json +4 -3
  114. package/schemas/controller-observation.schema.json +1 -1
  115. package/scripts/validate-plugin.mjs +66 -0
  116. 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
- function delay(milliseconds) {
14
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
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 normalizedConversationUrl(url) {
49
- try {
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
- if (sessionCandidate?.id && browser.tabs.get) {
153
- tab = await browser.tabs.get(sessionCandidate.id);
154
- }
155
- const openTabs = tab === undefined ? await browser.user?.openTabs?.().catch(() => []) : [];
156
- const candidate = openTabs?.find((openTab) => {
157
- const url = String(openTab.url ?? "");
158
- return matchesTarget(url);
159
- });
160
- if (tab === undefined && candidate && browser.user?.claimTab) {
161
- tab = await browser.user.claimTab(candidate);
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
- else if (tab === undefined) {
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 #clickSend(tab) {
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
- for (let attempt = 0; attempt < 2; attempt += 1) {
218
- const button = await findUniqueLocator(tab, "button", SEND_BUTTON_NAMES);
219
- if (!button) {
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
- catch (error) {
230
- if (await this.#submissionStarted(tab, previousUrl)) {
231
- return;
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
- const state = await readPageChatState(tab);
274
- if (normalizedMessageText(state.lastUserText) !== normalizedMessageText(expectedPrompt)) {
275
- 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.");
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
- const conversationUrl = (await tab.url()) ?? "";
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 #sendTurnOnce(input, context, hooks) {
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
- await this.#emitCheckpoint(tab, context, hooks, "possibly_sent");
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
- const completed = await this.#waitForCompletion(tab, context.baseline);
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
- if (isConversationUrl(currentUrl))
344
- this.#conversationUrl = currentUrl;
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
- ...(isConversationUrl(currentUrl) ? { conversationUrl: currentUrl } : {}),
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 recoverTurn(input) {
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
- const tab = await this.#getTab();
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
- const message = error instanceof Error ? error.message : String(error);
446
- const existingDetails = error instanceof CueLineError &&
447
- typeof error.details === "object" &&
448
- error.details !== null &&
449
- !Array.isArray(error.details)
450
- ? error.details
451
- : {};
452
- throw new CueLineError(error instanceof CueLineError ? error.code : "IAB_RECONCILIATION_FAILED", message, {
453
- cause: error,
454
- details: {
455
- ...existingDetails,
456
- stage: "reconciling",
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
  }