qq-codex-bridge 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 (39) hide show
  1. package/.env.example +62 -0
  2. package/README.md +232 -287
  3. package/bin/chatgpt-desktop.js +2 -0
  4. package/bin/qq-codex-weixin-gateway.js +14 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
  6. package/dist/apps/bridge-daemon/src/cli.js +5 -1
  7. package/dist/apps/bridge-daemon/src/config.js +168 -37
  8. package/dist/apps/bridge-daemon/src/http-server.js +23 -11
  9. package/dist/apps/bridge-daemon/src/main.js +163 -29
  10. package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
  11. package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
  12. package/dist/apps/weixin-gateway/src/cli.js +446 -0
  13. package/dist/apps/weixin-gateway/src/config.js +135 -0
  14. package/dist/apps/weixin-gateway/src/dev.js +2 -0
  15. package/dist/apps/weixin-gateway/src/message-store.js +50 -0
  16. package/dist/apps/weixin-gateway/src/server.js +216 -0
  17. package/dist/apps/weixin-gateway/src/state.js +163 -0
  18. package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
  19. package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
  20. package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
  21. package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
  22. package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
  23. package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
  24. package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
  25. package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
  26. package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
  27. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
  28. package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
  29. package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
  30. package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
  31. package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
  32. package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
  33. package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
  34. package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
  35. package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
  36. package/dist/packages/ports/src/chat.js +1 -0
  37. package/dist/packages/store/src/session-repo.js +16 -3
  38. package/dist/packages/store/src/sqlite.js +3 -0
  39. package/package.json +8 -2
@@ -12,8 +12,12 @@ export class CodexDesktopDriver {
12
12
  replyPollIntervalMs;
13
13
  replyStablePolls;
14
14
  partialReplyStablePolls;
15
+ composerSubmitPollIntervalMs;
15
16
  sleep;
17
+ localRolloutReader;
18
+ localSubmissionReader;
16
19
  pendingReplyBaselines = new Map();
20
+ pendingLocalRolloutCursors = new Map();
17
21
  constructor(cdp, options = {}) {
18
22
  this.cdp = cdp;
19
23
  this.replyPollAttempts = Math.max(1, options.replyPollAttempts ?? 60);
@@ -21,9 +25,12 @@ export class CodexDesktopDriver {
21
25
  this.replyPollIntervalMs = options.replyPollIntervalMs ?? 500;
22
26
  this.replyStablePolls = Math.max(1, options.replyStablePolls ?? 3);
23
27
  this.partialReplyStablePolls = Math.max(1, options.partialReplyStablePolls ?? 2);
28
+ this.composerSubmitPollIntervalMs = Math.max(50, options.composerSubmitPollIntervalMs ?? 300);
24
29
  this.sleep =
25
30
  options.sleep ??
26
31
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
32
+ this.localRolloutReader = options.localRolloutReader ?? null;
33
+ this.localSubmissionReader = options.localSubmissionReader ?? null;
27
34
  }
28
35
  async ensureAppReady() {
29
36
  await this.cdp.connect();
@@ -152,9 +159,12 @@ export class CodexDesktopDriver {
152
159
  const targetId = await this.ensureThreadSelected(binding);
153
160
  const baselineReply = await this.readLatestAssistantSnapshot(targetId);
154
161
  this.pendingReplyBaselines.set(binding.sessionKey, baselineReply);
162
+ await this.capturePendingLocalRolloutCursor(binding);
163
+ const submissionCursor = this.capturePendingLocalSubmissionCursor(binding);
155
164
  const focusResult = (await this.cdp.evaluateOnPage(this.buildFocusComposerScript(), targetId));
156
165
  if (!focusResult?.ok) {
157
166
  this.pendingReplyBaselines.delete(binding.sessionKey);
167
+ this.pendingLocalRolloutCursors.delete(binding.sessionKey);
158
168
  throw new DesktopDriverError(`Codex desktop input box not found: ${focusResult?.reason ?? "unknown"}`, "input_not_found");
159
169
  }
160
170
  await this.cdp.dispatchKeyEvent({
@@ -177,9 +187,20 @@ export class CodexDesktopDriver {
177
187
  }, targetId);
178
188
  await this.cdp.insertText(message.text, targetId);
179
189
  const result = (await this.cdp.evaluateOnPage(this.buildSubmitComposerScript(), targetId));
180
- if (result?.ok) {
190
+ if (result?.ok && !submissionCursor) {
181
191
  return;
182
192
  }
193
+ const confirmedAfterInitialSubmit = await this.waitForSubmissionConfirmation(binding.sessionKey, targetId, submissionCursor, 4);
194
+ if (confirmedAfterInitialSubmit.submitted) {
195
+ return;
196
+ }
197
+ console.warn("[qq-codex-bridge] codex composer submit not yet confirmed", {
198
+ sessionKey: binding.sessionKey,
199
+ messageId: message.messageId,
200
+ targetId,
201
+ initialResult: result ?? null,
202
+ confirmedAfterInitialSubmit
203
+ });
183
204
  await this.cdp.dispatchKeyEvent({
184
205
  type: "keyDown",
185
206
  key: "Enter",
@@ -194,14 +215,62 @@ export class CodexDesktopDriver {
194
215
  windowsVirtualKeyCode: 13,
195
216
  nativeVirtualKeyCode: 13
196
217
  }, targetId);
197
- const retryResult = (await this.cdp.evaluateOnPage(this.buildComposerSubmissionStateScript(), targetId));
218
+ const retryResult = await this.waitForSubmissionConfirmation(binding.sessionKey, targetId, submissionCursor, 4);
198
219
  if (retryResult?.submitted) {
199
220
  return;
200
221
  }
201
222
  this.pendingReplyBaselines.delete(binding.sessionKey);
223
+ this.pendingLocalRolloutCursors.delete(binding.sessionKey);
224
+ console.error("[qq-codex-bridge] codex composer submit failed", {
225
+ sessionKey: binding.sessionKey,
226
+ messageId: message.messageId,
227
+ targetId,
228
+ initialResult: result ?? null,
229
+ confirmedAfterInitialSubmit,
230
+ retryResult
231
+ });
202
232
  throw new DesktopDriverError(`Codex desktop composer submit failed: ${retryResult?.reason ?? result?.reason ?? "unknown"}`, "submit_failed");
203
233
  }
234
+ async waitForSubmissionConfirmation(sessionKey, targetId, submissionCursor, attempts) {
235
+ if (submissionCursor && this.localSubmissionReader) {
236
+ const result = await this.localSubmissionReader.waitForTurnSubmission(submissionCursor, {
237
+ pollAttempts: attempts,
238
+ pollIntervalMs: this.composerSubmitPollIntervalMs
239
+ });
240
+ if (result.submitted) {
241
+ this.attachPendingTurnId(sessionKey, result.turnId);
242
+ }
243
+ return result;
244
+ }
245
+ return this.waitForComposerSubmission(targetId, attempts);
246
+ }
247
+ async waitForComposerSubmission(targetId, attempts) {
248
+ let lastResult;
249
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
250
+ lastResult = (await this.cdp.evaluateOnPage(this.buildComposerSubmissionStateScript(), targetId));
251
+ if (lastResult?.submitted) {
252
+ return lastResult;
253
+ }
254
+ if (attempt + 1 < attempts) {
255
+ await this.sleep(this.composerSubmitPollIntervalMs);
256
+ }
257
+ }
258
+ return lastResult ?? { submitted: false, reason: "submit_not_confirmed" };
259
+ }
204
260
  async collectAssistantReply(binding, options = {}) {
261
+ const localCursor = this.pendingLocalRolloutCursors.get(binding.sessionKey)
262
+ ?? this.captureAdHocLocalRolloutCursor(binding);
263
+ if (localCursor) {
264
+ const localReply = await this.localRolloutReader?.waitForTurnCompletion(localCursor, {
265
+ pollAttempts: this.maxReplyPollAttempts,
266
+ pollIntervalMs: this.replyPollIntervalMs
267
+ });
268
+ this.pendingLocalRolloutCursors.delete(binding.sessionKey);
269
+ if (localReply) {
270
+ this.pendingReplyBaselines.delete(binding.sessionKey);
271
+ return this.collectAssistantReplyFromLocalRollout(binding, localReply, options);
272
+ }
273
+ }
205
274
  const targetId = await this.ensureThreadSelected(binding);
206
275
  const baselineReply = this.pendingReplyBaselines.get(binding.sessionKey);
207
276
  let candidateReply = null;
@@ -346,6 +415,20 @@ export class CodexDesktopDriver {
346
415
  createdAt: new Date().toISOString()
347
416
  };
348
417
  }
418
+ buildOutboundDraftFromText(sessionKey, text, mediaReferences, turnId) {
419
+ return {
420
+ draftId: randomUUID(),
421
+ ...(turnId ? { turnId } : {}),
422
+ sessionKey,
423
+ text,
424
+ ...(mediaReferences.length > 0
425
+ ? {
426
+ mediaArtifacts: mediaReferences.map((reference) => buildMediaArtifactFromReference(reference))
427
+ }
428
+ : {}),
429
+ createdAt: new Date().toISOString()
430
+ };
431
+ }
349
432
  buildIncrementalDraftFromSnapshot(sessionKey, snapshot, emittedReplyText, emittedMediaReferences, turnId) {
350
433
  const fullReply = snapshot.reply ?? "";
351
434
  const deltaText = this.extractReplyDelta(emittedReplyText, fullReply).trim();
@@ -392,29 +475,187 @@ export class CodexDesktopDriver {
392
475
  hasAssistantContent(snapshot) {
393
476
  return Boolean(snapshot.reply && snapshot.reply.trim()) || snapshot.mediaReferences.length > 0;
394
477
  }
478
+ async collectAssistantReplyFromLocalRollout(binding, localReply, options) {
479
+ const turnId = localReply.turnId ?? randomUUID();
480
+ let turnSequence = 0;
481
+ const emitTurnEvent = async (eventType, payload, isFinal) => {
482
+ if (!options.onTurnEvent) {
483
+ return;
484
+ }
485
+ turnSequence += 1;
486
+ await options.onTurnEvent({
487
+ sessionKey: binding.sessionKey,
488
+ turnId,
489
+ sequence: turnSequence,
490
+ eventType,
491
+ createdAt: new Date().toISOString(),
492
+ isFinal,
493
+ payload
494
+ });
495
+ };
496
+ if (options.onDraft) {
497
+ let assembledText = "";
498
+ for (const commentary of localReply.commentaryMessages) {
499
+ const draft = this.buildOutboundDraftFromText(binding.sessionKey, commentary, [], turnId);
500
+ assembledText = assembledText ? `${assembledText}\n${commentary}` : commentary;
501
+ await emitTurnEvent(TurnEventType.Delta, {
502
+ text: commentary,
503
+ fullText: assembledText,
504
+ mediaReferences: []
505
+ }, false);
506
+ await options.onDraft(draft);
507
+ }
508
+ await emitTurnEvent(TurnEventType.Completed, {
509
+ fullText: localReply.fullText,
510
+ mediaReferences: localReply.mediaReferences,
511
+ completionReason: "stable"
512
+ }, true);
513
+ return [];
514
+ }
515
+ await emitTurnEvent(TurnEventType.Delta, {
516
+ fullText: localReply.finalText,
517
+ mediaReferences: localReply.mediaReferences
518
+ }, false);
519
+ await emitTurnEvent(TurnEventType.Completed, {
520
+ fullText: localReply.finalText,
521
+ mediaReferences: localReply.mediaReferences,
522
+ completionReason: "stable"
523
+ }, true);
524
+ return [
525
+ this.buildOutboundDraftFromText(binding.sessionKey, localReply.finalText, localReply.mediaReferences, turnId)
526
+ ];
527
+ }
395
528
  async markSessionBroken(_sessionKey, _reason) {
396
529
  return;
397
530
  }
398
- async ensureThreadSelected(binding) {
399
- const targetId = await this.resolveTargetId(binding);
400
- const locator = binding.codexThreadRef
531
+ async capturePendingLocalRolloutCursor(binding) {
532
+ if (!this.localRolloutReader) {
533
+ return;
534
+ }
535
+ const locator = typeof binding.codexThreadRef === "string" && binding.codexThreadRef.startsWith(THREAD_REF_PREFIX)
401
536
  ? this.decodeThreadRef(binding.codexThreadRef)
402
537
  : null;
538
+ if (!locator?.title) {
539
+ this.pendingLocalRolloutCursors.delete(binding.sessionKey);
540
+ return;
541
+ }
542
+ const cursor = this.localRolloutReader.captureCursorForThreadTitle(locator.title);
543
+ if (!cursor) {
544
+ this.pendingLocalRolloutCursors.delete(binding.sessionKey);
545
+ return;
546
+ }
547
+ this.pendingLocalRolloutCursors.set(binding.sessionKey, cursor);
548
+ }
549
+ capturePendingLocalSubmissionCursor(binding) {
550
+ if (!this.localSubmissionReader) {
551
+ return null;
552
+ }
553
+ const rolloutCursor = this.pendingLocalRolloutCursors.get(binding.sessionKey);
554
+ if (!rolloutCursor?.threadId) {
555
+ return null;
556
+ }
557
+ return this.localSubmissionReader.captureCursorForThreadId(rolloutCursor.threadId);
558
+ }
559
+ attachPendingTurnId(sessionKey, turnId) {
560
+ if (!turnId) {
561
+ return;
562
+ }
563
+ const cursor = this.pendingLocalRolloutCursors.get(sessionKey);
564
+ if (!cursor) {
565
+ return;
566
+ }
567
+ cursor.targetTurnId = turnId;
568
+ cursor.competingTurnStarted = false;
569
+ }
570
+ captureAdHocLocalRolloutCursor(binding) {
571
+ if (!this.localRolloutReader) {
572
+ return null;
573
+ }
574
+ const locator = typeof binding.codexThreadRef === "string" && binding.codexThreadRef.startsWith(THREAD_REF_PREFIX)
575
+ ? this.decodeThreadRef(binding.codexThreadRef)
576
+ : null;
577
+ if (!locator?.title) {
578
+ return null;
579
+ }
580
+ return this.localRolloutReader.captureCursorForThreadTitle(locator.title);
581
+ }
582
+ async ensureThreadSelected(binding) {
583
+ const targetId = await this.resolveTargetId(binding);
584
+ const boundThreadRef = binding.codexThreadRef;
585
+ if (!boundThreadRef) {
586
+ return targetId;
587
+ }
588
+ const locator = this.decodeThreadRef(boundThreadRef);
403
589
  if (!locator) {
404
590
  return targetId;
405
591
  }
406
592
  const threads = await this.listRecentThreads(200);
407
593
  const currentThread = threads.find((thread) => thread.isCurrent);
408
- if (currentThread?.threadRef === binding.codexThreadRef) {
594
+ if (currentThread?.threadRef === boundThreadRef) {
409
595
  return targetId;
410
596
  }
597
+ const previousFingerprint = await this.readConversationViewportFingerprint(targetId);
411
598
  const switchResult = (await this.cdp.evaluateOnPage(this.buildSelectThreadScript(locator), targetId));
412
599
  if (!switchResult?.ok) {
413
600
  throw new DesktopDriverError(`Codex desktop thread switch failed: ${switchResult?.reason ?? "unknown"}`, "session_not_found");
414
601
  }
415
- await this.sleep(100);
602
+ await this.waitForThreadActivation(boundThreadRef, targetId, previousFingerprint);
416
603
  return targetId;
417
604
  }
605
+ async waitForThreadActivation(threadRef, targetId, previousFingerprint) {
606
+ let currentThreadStablePolls = 0;
607
+ for (let attempt = 0; attempt < 20; attempt += 1) {
608
+ const currentThread = (await this.listRecentThreads(200)).find((thread) => thread.isCurrent);
609
+ const currentMatches = currentThread?.threadRef === threadRef;
610
+ if (currentMatches) {
611
+ currentThreadStablePolls += 1;
612
+ const currentFingerprint = await this.readConversationViewportFingerprint(targetId);
613
+ if (!this.isSameConversationViewportFingerprint(currentFingerprint, previousFingerprint)
614
+ || currentThreadStablePolls >= 4) {
615
+ return;
616
+ }
617
+ }
618
+ else {
619
+ currentThreadStablePolls = 0;
620
+ }
621
+ if (attempt + 1 < 20) {
622
+ await this.sleep(100);
623
+ }
624
+ }
625
+ throw new DesktopDriverError("Codex desktop thread switch failed: thread_activation_timeout", "session_not_found");
626
+ }
627
+ async readConversationViewportFingerprint(targetId) {
628
+ const fingerprint = await this.cdp.evaluateOnPage(this.buildConversationViewportFingerprintProbeScript(), targetId);
629
+ if (!fingerprint ||
630
+ typeof fingerprint !== "object" ||
631
+ !("latestUnitKey" in fingerprint) ||
632
+ !("latestSnippet" in fingerprint) ||
633
+ !("unitCount" in fingerprint)) {
634
+ return null;
635
+ }
636
+ return {
637
+ latestUnitKey: typeof fingerprint.latestUnitKey === "string" && fingerprint.latestUnitKey.trim()
638
+ ? fingerprint.latestUnitKey
639
+ : null,
640
+ latestSnippet: typeof fingerprint.latestSnippet === "string" && fingerprint.latestSnippet.trim()
641
+ ? fingerprint.latestSnippet
642
+ : null,
643
+ unitCount: typeof fingerprint.unitCount === "number" && Number.isFinite(fingerprint.unitCount)
644
+ ? fingerprint.unitCount
645
+ : 0
646
+ };
647
+ }
648
+ isSameConversationViewportFingerprint(left, right) {
649
+ if (!left && !right) {
650
+ return true;
651
+ }
652
+ if (!left || !right) {
653
+ return false;
654
+ }
655
+ return (left.latestUnitKey === right.latestUnitKey
656
+ && left.latestSnippet === right.latestSnippet
657
+ && left.unitCount === right.unitCount);
658
+ }
418
659
  async readLatestAssistantSnapshot(targetId) {
419
660
  const structuredReply = await this.cdp.evaluateOnPage(this.buildAssistantReplyProbeScript(), targetId);
420
661
  if (structuredReply &&
@@ -793,6 +1034,38 @@ export class CodexDesktopDriver {
793
1034
  .replace(/^function\s+isLikelyComposerSubmitButton/, "function isLikelyComposerSubmitButton");
794
1035
  return `(() => {
795
1036
  ${submitButtonMatcher}
1037
+ const readConversationFingerprint = () => {
1038
+ const units = Array.from(document.querySelectorAll('[data-content-search-unit-key]'))
1039
+ .filter((node) => node instanceof HTMLElement)
1040
+ .map((node) => {
1041
+ if (!(node instanceof HTMLElement)) {
1042
+ return null;
1043
+ }
1044
+ const rect = node.getBoundingClientRect();
1045
+ if (rect.width <= 0 || rect.height <= 0) {
1046
+ return null;
1047
+ }
1048
+ return node;
1049
+ })
1050
+ .filter((node) => node instanceof HTMLElement);
1051
+ const latestUnit = units.at(-1);
1052
+ return {
1053
+ latestUnitKey:
1054
+ latestUnit instanceof HTMLElement
1055
+ ? latestUnit.getAttribute('data-content-search-unit-key')
1056
+ : null,
1057
+ latestSnippet:
1058
+ latestUnit instanceof HTMLElement
1059
+ ? (latestUnit.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 200)
1060
+ : null,
1061
+ unitCount: units.length
1062
+ };
1063
+ };
1064
+ const isSameConversationFingerprint = (left, right) =>
1065
+ Boolean(left && right)
1066
+ && left.latestUnitKey === right.latestUnitKey
1067
+ && left.latestSnippet === right.latestSnippet
1068
+ && left.unitCount === right.unitCount;
796
1069
  const resolveComposer = () => {
797
1070
  const selectors = [
798
1071
  '[data-codex-composer="true"]',
@@ -830,6 +1103,67 @@ export class CodexDesktopDriver {
830
1103
  }
831
1104
  return node.textContent || '';
832
1105
  };
1106
+ const resolveComposerSubmitButton = (allowDisabled, strictMatch) =>
1107
+ Array.from(document.querySelectorAll('button, [role="button"]'))
1108
+ .filter((candidate) => {
1109
+ if (!(candidate instanceof HTMLElement)) {
1110
+ return false;
1111
+ }
1112
+ const rect = candidate.getBoundingClientRect();
1113
+ if (rect.width <= 0 || rect.height <= 0) {
1114
+ return false;
1115
+ }
1116
+ if (
1117
+ !allowDisabled
1118
+ && (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true')
1119
+ ) {
1120
+ return false;
1121
+ }
1122
+ const centerX = rect.x + rect.width / 2;
1123
+ const centerY = rect.y + rect.height / 2;
1124
+ const nearComposerBottomRight =
1125
+ centerX >= inputRect.right - 140
1126
+ && centerY >= inputRect.y - 24
1127
+ && centerY <= inputRect.bottom + 40;
1128
+ if (!nearComposerBottomRight) {
1129
+ return false;
1130
+ }
1131
+ if (!strictMatch) {
1132
+ return true;
1133
+ }
1134
+ return isLikelyComposerSubmitButton({
1135
+ text: candidate.textContent ?? '',
1136
+ aria: candidate.getAttribute('aria-label'),
1137
+ title: candidate.getAttribute('title'),
1138
+ className: candidate.className ?? ''
1139
+ });
1140
+ })
1141
+ .sort((left, right) => {
1142
+ const leftRect = left.getBoundingClientRect();
1143
+ const rightRect = right.getBoundingClientRect();
1144
+ const leftLabel = {
1145
+ text: left.textContent ?? '',
1146
+ aria: left.getAttribute('aria-label'),
1147
+ title: left.getAttribute('title'),
1148
+ className: left.className ?? ''
1149
+ };
1150
+ const rightLabel = {
1151
+ text: right.textContent ?? '',
1152
+ aria: right.getAttribute('aria-label'),
1153
+ title: right.getAttribute('title'),
1154
+ className: right.className ?? ''
1155
+ };
1156
+ const leftStrictScore = isLikelyComposerSubmitButton(leftLabel) ? 1000 : 0;
1157
+ const rightStrictScore = isLikelyComposerSubmitButton(rightLabel) ? 1000 : 0;
1158
+ const leftPrimaryScore = /\bsize-token-button-composer\b/i.test(leftLabel.className) ? 100 : 0;
1159
+ const rightPrimaryScore = /\bsize-token-button-composer\b/i.test(rightLabel.className) ? 100 : 0;
1160
+ const leftScore =
1161
+ leftStrictScore + leftPrimaryScore + leftRect.x - Math.abs(leftRect.y - inputRect.bottom);
1162
+ const rightScore =
1163
+ rightStrictScore + rightPrimaryScore + rightRect.x - Math.abs(rightRect.y - inputRect.bottom);
1164
+ return rightScore - leftScore;
1165
+ })
1166
+ .at(0) ?? null;
833
1167
  const input = resolveComposer();
834
1168
  if (!(input instanceof HTMLElement)) {
835
1169
  return { ok: false, reason: 'input_not_found' };
@@ -839,47 +1173,32 @@ export class CodexDesktopDriver {
839
1173
  if (!currentText) {
840
1174
  return { ok: false, reason: 'empty_input' };
841
1175
  }
842
- const sendButton = Array.from(document.querySelectorAll('button, [role="button"]'))
843
- .filter((candidate) => {
844
- if (!(candidate instanceof HTMLElement)) {
845
- return false;
846
- }
847
- const rect = candidate.getBoundingClientRect();
848
- if (rect.width <= 0 || rect.height <= 0) {
849
- return false;
850
- }
851
- if (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true') {
852
- return false;
853
- }
854
- return isLikelyComposerSubmitButton({
855
- text: candidate.textContent ?? '',
856
- aria: candidate.getAttribute('aria-label'),
857
- title: candidate.getAttribute('title'),
858
- className: candidate.className ?? ''
859
- });
860
- })
861
- .sort((left, right) => {
862
- const leftRect = left.getBoundingClientRect();
863
- const rightRect = right.getBoundingClientRect();
864
- const leftDistance = Math.abs(leftRect.y - inputRect.y) + Math.max(0, inputRect.x - leftRect.x);
865
- const rightDistance = Math.abs(rightRect.y - inputRect.y) + Math.max(0, inputRect.x - rightRect.x);
866
- return leftDistance - rightDistance;
867
- })
868
- .find((candidate) => {
869
- const rect = candidate.getBoundingClientRect();
870
- return rect.x >= inputRect.x - 24 && Math.abs(rect.y - inputRect.y) <= 120;
871
- });
1176
+ const beforeConversationFingerprint = readConversationFingerprint();
1177
+ window.__qqCodexLastSubmitConversationFingerprint = beforeConversationFingerprint;
1178
+ const sendButton = resolveComposerSubmitButton(false, true);
872
1179
  const beforeButtonHtml = sendButton instanceof HTMLElement ? sendButton.innerHTML : '';
1180
+ window.__qqCodexLastSubmitButtonHtml = beforeButtonHtml;
873
1181
  const confirmSubmission = (reason) => new Promise((resolve) => {
874
1182
  window.setTimeout(() => {
875
1183
  const afterText = readComposerText(input).trim();
876
- const afterButtonHtml = sendButton instanceof HTMLElement ? sendButton.innerHTML : '';
1184
+ const currentSendButton = resolveComposerSubmitButton(true, false);
1185
+ const afterButtonHtml =
1186
+ currentSendButton instanceof HTMLElement ? currentSendButton.innerHTML : '';
877
1187
  const buttonChanged = beforeButtonHtml !== '' && beforeButtonHtml !== afterButtonHtml;
1188
+ const afterConversationFingerprint = readConversationFingerprint();
1189
+ const conversationAdvanced = !isSameConversationFingerprint(
1190
+ beforeConversationFingerprint,
1191
+ afterConversationFingerprint
1192
+ );
878
1193
  resolve({
879
- ok: afterText.length === 0 || buttonChanged,
1194
+ ok: afterText.length === 0 || buttonChanged || conversationAdvanced,
880
1195
  reason: afterText.length === 0
881
1196
  ? reason
882
- : (buttonChanged ? 'entered_streaming_state' : 'submit_not_confirmed')
1197
+ : (
1198
+ buttonChanged
1199
+ ? 'entered_streaming_state'
1200
+ : (conversationAdvanced ? 'conversation_advanced' : 'submit_not_confirmed')
1201
+ )
883
1202
  });
884
1203
  }, 300);
885
1204
  });
@@ -891,10 +1210,9 @@ export class CodexDesktopDriver {
891
1210
  if (sendButton instanceof HTMLElement) {
892
1211
  if (typeof sendButton.click === 'function') {
893
1212
  sendButton.click();
894
- }
895
- for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
1213
+ } else {
896
1214
  sendButton.dispatchEvent(
897
- new MouseEvent(type, {
1215
+ new MouseEvent('click', {
898
1216
  bubbles: true,
899
1217
  cancelable: true,
900
1218
  view: window
@@ -919,7 +1237,43 @@ export class CodexDesktopDriver {
919
1237
  })();`;
920
1238
  }
921
1239
  buildComposerSubmissionStateScript() {
1240
+ const submitButtonMatcher = isLikelyComposerSubmitButton
1241
+ .toString()
1242
+ .replace(/^function\s+isLikelyComposerSubmitButton/, "function isLikelyComposerSubmitButton");
922
1243
  return `(() => {
1244
+ ${submitButtonMatcher}
1245
+ const readConversationFingerprint = () => {
1246
+ const units = Array.from(document.querySelectorAll('[data-content-search-unit-key]'))
1247
+ .filter((node) => node instanceof HTMLElement)
1248
+ .map((node) => {
1249
+ if (!(node instanceof HTMLElement)) {
1250
+ return null;
1251
+ }
1252
+ const rect = node.getBoundingClientRect();
1253
+ if (rect.width <= 0 || rect.height <= 0) {
1254
+ return null;
1255
+ }
1256
+ return node;
1257
+ })
1258
+ .filter((node) => node instanceof HTMLElement);
1259
+ const latestUnit = units.at(-1);
1260
+ return {
1261
+ latestUnitKey:
1262
+ latestUnit instanceof HTMLElement
1263
+ ? latestUnit.getAttribute('data-content-search-unit-key')
1264
+ : null,
1265
+ latestSnippet:
1266
+ latestUnit instanceof HTMLElement
1267
+ ? (latestUnit.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 200)
1268
+ : null,
1269
+ unitCount: units.length
1270
+ };
1271
+ };
1272
+ const isSameConversationFingerprint = (left, right) =>
1273
+ Boolean(left && right)
1274
+ && left.latestUnitKey === right.latestUnitKey
1275
+ && left.latestSnippet === right.latestSnippet
1276
+ && left.unitCount === right.unitCount;
923
1277
  const selectors = [
924
1278
  '[data-codex-composer="true"]',
925
1279
  'textarea',
@@ -927,9 +1281,9 @@ export class CodexDesktopDriver {
927
1281
  '[contenteditable="true"]',
928
1282
  '[role="textbox"]'
929
1283
  ];
930
- const input = selectors
1284
+ const inputCandidates = selectors
931
1285
  .flatMap((selector) => Array.from(document.querySelectorAll(selector)))
932
- .find((candidate) => {
1286
+ .filter((candidate) => {
933
1287
  if (!(candidate instanceof HTMLElement)) {
934
1288
  return false;
935
1289
  }
@@ -939,25 +1293,112 @@ export class CodexDesktopDriver {
939
1293
  const rect = candidate.getBoundingClientRect();
940
1294
  return rect.width > 0 && rect.height > 0;
941
1295
  });
1296
+ const activeElement = document.activeElement;
1297
+ const input =
1298
+ activeElement instanceof HTMLElement && inputCandidates.includes(activeElement)
1299
+ ? activeElement
1300
+ : (inputCandidates
1301
+ .sort((left, right) => right.getBoundingClientRect().y - left.getBoundingClientRect().y)
1302
+ .at(0) ?? null);
942
1303
  if (!(input instanceof HTMLElement)) {
943
1304
  return { submitted: false, reason: 'input_not_found' };
944
1305
  }
1306
+ const inputRect = input.getBoundingClientRect();
1307
+ const resolveComposerSubmitButton = (allowDisabled) =>
1308
+ Array.from(document.querySelectorAll('button, [role="button"]'))
1309
+ .filter((candidate) => {
1310
+ if (!(candidate instanceof HTMLElement)) {
1311
+ return false;
1312
+ }
1313
+ const rect = candidate.getBoundingClientRect();
1314
+ if (rect.width <= 0 || rect.height <= 0) {
1315
+ return false;
1316
+ }
1317
+ if (
1318
+ !allowDisabled
1319
+ && (candidate.hasAttribute('disabled') || candidate.getAttribute('aria-disabled') === 'true')
1320
+ ) {
1321
+ return false;
1322
+ }
1323
+ const centerX = rect.x + rect.width / 2;
1324
+ const centerY = rect.y + rect.height / 2;
1325
+ return (
1326
+ centerX >= inputRect.right - 140
1327
+ && centerY >= inputRect.y - 24
1328
+ && centerY <= inputRect.bottom + 40
1329
+ );
1330
+ })
1331
+ .sort((left, right) => {
1332
+ const leftRect = left.getBoundingClientRect();
1333
+ const rightRect = right.getBoundingClientRect();
1334
+ const leftLabel = {
1335
+ text: left.textContent ?? '',
1336
+ aria: left.getAttribute('aria-label'),
1337
+ title: left.getAttribute('title'),
1338
+ className: left.className ?? ''
1339
+ };
1340
+ const rightLabel = {
1341
+ text: right.textContent ?? '',
1342
+ aria: right.getAttribute('aria-label'),
1343
+ title: right.getAttribute('title'),
1344
+ className: right.className ?? ''
1345
+ };
1346
+ const leftStrictScore = isLikelyComposerSubmitButton(leftLabel) ? 1000 : 0;
1347
+ const rightStrictScore = isLikelyComposerSubmitButton(rightLabel) ? 1000 : 0;
1348
+ const leftPrimaryScore = /\bsize-token-button-composer\b/i.test(leftLabel.className) ? 100 : 0;
1349
+ const rightPrimaryScore = /\bsize-token-button-composer\b/i.test(rightLabel.className) ? 100 : 0;
1350
+ const leftScore =
1351
+ leftStrictScore + leftPrimaryScore + leftRect.x - Math.abs(leftRect.y - inputRect.bottom);
1352
+ const rightScore =
1353
+ rightStrictScore + rightPrimaryScore + rightRect.x - Math.abs(rightRect.y - inputRect.bottom);
1354
+ return rightScore - leftScore;
1355
+ })
1356
+ .at(0) ?? null;
945
1357
  const currentText =
946
1358
  'value' in input && typeof input.value === 'string'
947
1359
  ? input.value.trim()
948
1360
  : (input.textContent || '').trim();
949
- const sendButton = Array.from(document.querySelectorAll('button, [role="button"]')).find((candidate) => {
950
- if (!(candidate instanceof HTMLElement)) {
951
- return false;
952
- }
953
- const className = typeof candidate.className === 'string' ? candidate.className : '';
954
- return className.includes('size-token-button-composer') && className.includes('bg-token-foreground');
955
- });
1361
+ const sendButton = resolveComposerSubmitButton(true);
956
1362
  const buttonHtml = sendButton instanceof HTMLElement ? sendButton.innerHTML : '';
957
- const isStreamingButton = buttonHtml.includes('M4.5 5.75C4.5 5.05964');
1363
+ const buttonClassName = sendButton instanceof HTMLElement ? String(sendButton.className || '') : '';
1364
+ const baselineButtonHtml =
1365
+ typeof window.__qqCodexLastSubmitButtonHtml === 'string'
1366
+ ? window.__qqCodexLastSubmitButtonHtml
1367
+ : '';
1368
+ const isStreamingButton = baselineButtonHtml !== '' && buttonHtml !== '' && baselineButtonHtml !== buttonHtml;
1369
+ const baselineConversationFingerprint = window.__qqCodexLastSubmitConversationFingerprint;
1370
+ const currentConversationFingerprint = readConversationFingerprint();
1371
+ const conversationAdvanced =
1372
+ baselineConversationFingerprint
1373
+ && !isSameConversationFingerprint(
1374
+ baselineConversationFingerprint,
1375
+ currentConversationFingerprint
1376
+ );
958
1377
  return {
959
- submitted: currentText.length === 0 || isStreamingButton,
960
- reason: currentText.length === 0 ? 'composer_cleared' : (isStreamingButton ? 'entered_streaming_state' : 'submit_not_confirmed')
1378
+ submitted: currentText.length === 0 || isStreamingButton || conversationAdvanced,
1379
+ reason: currentText.length === 0
1380
+ ? 'composer_cleared'
1381
+ : (
1382
+ isStreamingButton
1383
+ ? 'entered_streaming_state'
1384
+ : (conversationAdvanced ? 'conversation_advanced' : 'submit_not_confirmed')
1385
+ ),
1386
+ diagnostics: {
1387
+ currentTextLength: currentText.length,
1388
+ inputRect: {
1389
+ x: inputRect.x,
1390
+ y: inputRect.y,
1391
+ width: inputRect.width,
1392
+ height: inputRect.height,
1393
+ right: inputRect.right,
1394
+ bottom: inputRect.bottom
1395
+ },
1396
+ baselineButtonHtml: baselineButtonHtml.slice(0, 160),
1397
+ buttonHtml: buttonHtml.slice(0, 160),
1398
+ buttonClassName,
1399
+ sendButtonFound: Boolean(sendButton),
1400
+ conversationAdvanced
1401
+ }
961
1402
  };
962
1403
  })();`;
963
1404
  }
@@ -965,73 +1406,171 @@ export class CodexDesktopDriver {
965
1406
  return `
966
1407
  (() => {
967
1408
  const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
968
- const buttons = Array.from(document.querySelectorAll('button,[role="button"],[aria-label]'))
969
- .map((node) => {
970
- const rect = node instanceof HTMLElement ? node.getBoundingClientRect() : null;
971
- return {
972
- text: normalize(node.textContent || ''),
973
- aria: normalize(node.getAttribute('aria-label') || ''),
974
- title: normalize(node.getAttribute('title') || ''),
975
- className: String(node.className || ''),
976
- x: rect ? rect.x : 0,
977
- y: rect ? rect.y : 0,
978
- width: rect ? rect.width : 0,
979
- height: rect ? rect.height : 0
980
- };
981
- })
982
- .filter((item) => (item.text || item.aria || item.title) && item.y >= window.innerHeight - 180 && item.x >= 320)
983
- .sort((left, right) => (left.y - right.y) || (left.x - right.x));
984
-
985
1409
  const modelPattern = /(?:gpt|o1|o3|o4|4\\.1|4\\.5|5\\.4|mini|nano|sonnet|haiku|opus|gemini|claude|qwen|deepseek)/i;
986
1410
  const effortPattern = /^(?:低|中|高|minimal|low|medium|high)$/i;
987
1411
  const permissionPattern = /(?:访问权限|permission|sandbox)/i;
988
- const quotaPattern = /(?:quota|usage|remaining|allowance|credit|credits|余额|额度|剩余|配额|使用量)/i;
989
- const ignoredQuotaPattern = /(?:QQBOT_RUNTIME_CONTEXT|<qqmedia>|<!--|-->|会话类型|bridge|runtime\\/media|内部实现|相对路径)/i;
1412
+ const workspacePattern = /^(?:本地工作|在本地处理|本地项目|本地|云端|local|cloud|worktree)$/i;
1413
+ const branchPattern = /^(?:[A-Za-z0-9._-]+\\/[A-Za-z0-9._/-]+|main|master|develop|development|dev|staging|production|release\\/[A-Za-z0-9._/-]+|hotfix\\/[A-Za-z0-9._/-]+|feature\\/[A-Za-z0-9._/-]+|bugfix\\/[A-Za-z0-9._/-]+)$/;
1414
+ const ignoredLinePattern = /(?:QQBOT_RUNTIME_CONTEXT|<qqmedia>|<!--|-->|会话类型|runtime\\/media|内部实现|相对路径)/i;
1415
+ const ignoredBranchPattern = /^(?:https?:\\/\\/|app:\\/\\/|\\/|继续使用|在本地处理|本地工作|本地项目|升级至\\s*Pro|了解更多|移至工作树|剩余额度|remaining usage|quota|usage|额度|配额|GPT-|Claude|Gemini|完全访问权限|听写)$/i;
1416
+ const isVisible = (node) => {
1417
+ if (!(node instanceof HTMLElement)) {
1418
+ return false;
1419
+ }
1420
+ const rect = node.getBoundingClientRect();
1421
+ return rect.width > 0 && rect.height > 0;
1422
+ };
1423
+ const clickNode = (node) => {
1424
+ node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
1425
+ node.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
1426
+ node.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
1427
+ node.dispatchEvent(new MouseEvent('click', { bubbles: true }));
1428
+ };
1429
+ const collectControls = () =>
1430
+ Array.from(document.querySelectorAll('button,[role="button"],[role="menuitem"],[role="option"],[aria-label]'))
1431
+ .filter(isVisible)
1432
+ .map((node) => {
1433
+ const rect = node instanceof HTMLElement ? node.getBoundingClientRect() : null;
1434
+ return {
1435
+ node,
1436
+ text: normalize(node.textContent || ''),
1437
+ aria: normalize(node.getAttribute('aria-label') || ''),
1438
+ title: normalize(node.getAttribute('title') || ''),
1439
+ className: String(node.className || ''),
1440
+ x: rect ? rect.x : 0,
1441
+ y: rect ? rect.y : 0,
1442
+ width: rect ? rect.width : 0,
1443
+ height: rect ? rect.height : 0
1444
+ };
1445
+ });
1446
+ const collectFooterControls = () =>
1447
+ collectControls()
1448
+ .filter((item) => {
1449
+ const text = item.text || item.aria || item.title;
1450
+ if (!text) {
1451
+ return false;
1452
+ }
990
1453
 
991
- let model = null;
992
- let reasoningEffort = null;
993
- let workspace = null;
994
- let branch = null;
995
- let permissionMode = null;
1454
+ if (item.y < window.innerHeight - 120 || item.y > window.innerHeight || item.x < 380) {
1455
+ return false;
1456
+ }
996
1457
 
997
- for (const item of buttons) {
998
- const text = item.text || item.aria || item.title;
999
- if (!model && modelPattern.test(text)) {
1000
- model = text;
1001
- continue;
1002
- }
1003
- if (!reasoningEffort && effortPattern.test(text)) {
1004
- reasoningEffort = text;
1005
- continue;
1006
- }
1007
- if (!permissionMode && permissionPattern.test(text)) {
1008
- permissionMode = text;
1009
- continue;
1458
+ return /(?:h-token-button-composer|size-token-button-composer)/.test(item.className);
1459
+ })
1460
+ .sort((left, right) => (left.y - right.y) || (left.x - right.x));
1461
+ const getBodyLines = () =>
1462
+ (document.body ? document.body.innerText : '')
1463
+ .split('\\n')
1464
+ .map((line) => normalize(line))
1465
+ .filter((line) => line && !ignoredLinePattern.test(line));
1466
+ const isQuotaHeader = (line) => line === '剩余额度' || /^remaining usage$/i.test(line);
1467
+ const parseQuotaEntries = (lines) => {
1468
+ const entries = [];
1469
+ for (let index = 0; index < lines.length; index += 1) {
1470
+ const line = lines[index];
1471
+ if (
1472
+ !line ||
1473
+ isQuotaHeader(line) ||
1474
+ /^\\d+%$/.test(line) ||
1475
+ /^(?:继续使用|本地项目|本地工作|在本地处理|云端|升级至\\s*Pro|了解更多|移至工作树)$/i.test(line)
1476
+ ) {
1477
+ continue;
1478
+ }
1479
+
1480
+ const combinedMatch = line.match(/^(.+?(?:分钟|小时|天|周|月|minutes?|hours?|days?|weeks?|months?))\\s+(\\d+%)\\s+(.+)$/i);
1481
+ if (combinedMatch) {
1482
+ entries.push(\`\${combinedMatch[1]} \${combinedMatch[2]}(\${combinedMatch[3]} 重置)\`);
1483
+ continue;
1484
+ }
1485
+
1486
+ const timeframeMatch = line.match(/^(.+?(?:分钟|小时|天|周|月|minutes?|hours?|days?|weeks?|months?))$/i);
1487
+ if (timeframeMatch && index + 2 < lines.length) {
1488
+ const percentLine = lines[index + 1];
1489
+ const resetLine = lines[index + 2];
1490
+ if (/^\\d+%$/.test(percentLine) && resetLine) {
1491
+ entries.push(\`\${timeframeMatch[1]} \${percentLine}(\${resetLine} 重置)\`);
1492
+ index += 2;
1493
+ }
1494
+ }
1010
1495
  }
1011
- if (!workspace && /^(?:本地|local|worktree)$/i.test(text)) {
1012
- workspace = text;
1013
- continue;
1496
+
1497
+ return entries;
1498
+ };
1499
+ const parseBranch = (lines) =>
1500
+ lines.find((line) => branchPattern.test(line) && !ignoredBranchPattern.test(line)) || null;
1501
+ const findWorkspaceButton = (controls) =>
1502
+ controls.find((item) => workspacePattern.test(item.text || item.aria || item.title));
1503
+ const findFooterBranch = (controls) =>
1504
+ controls.find((item) => {
1505
+ const text = item.text || item.aria || item.title;
1506
+ return branchPattern.test(text) && !ignoredBranchPattern.test(text);
1507
+ });
1508
+ const readState = () => {
1509
+ const footerControls = collectFooterControls();
1510
+ const workspaceButton = findWorkspaceButton(footerControls);
1511
+ const footerBranch = findFooterBranch(footerControls);
1512
+ const lines = getBodyLines();
1513
+ const quotaEntries = parseQuotaEntries(lines);
1514
+
1515
+ let model = null;
1516
+ let reasoningEffort = null;
1517
+ let workspace = null;
1518
+ let permissionMode = null;
1519
+
1520
+ for (const item of footerControls) {
1521
+ const text = item.text || item.aria || item.title;
1522
+ if (!model && modelPattern.test(text)) {
1523
+ model = text;
1524
+ continue;
1525
+ }
1526
+ if (!reasoningEffort && effortPattern.test(text)) {
1527
+ reasoningEffort = text;
1528
+ continue;
1529
+ }
1530
+ if (!permissionMode && permissionPattern.test(text)) {
1531
+ permissionMode = text;
1532
+ continue;
1533
+ }
1534
+ if (!workspace && workspacePattern.test(text)) {
1535
+ workspace = text;
1536
+ }
1014
1537
  }
1015
- if (!branch && /[\\w.-]+\\/[\\w./-]+/.test(text)) {
1016
- branch = text;
1538
+
1539
+ return {
1540
+ workspaceButton,
1541
+ state: {
1542
+ model,
1543
+ reasoningEffort,
1544
+ workspace,
1545
+ branch: footerBranch ? (footerBranch.text || footerBranch.aria || footerBranch.title) : parseBranch(lines),
1546
+ permissionMode,
1547
+ quotaSummary: quotaEntries.length > 0 ? quotaEntries.join('\\n') : null
1548
+ }
1549
+ };
1550
+ };
1551
+
1552
+ return new Promise((resolve) => {
1553
+ const initial = readState();
1554
+ if (!initial.workspaceButton) {
1555
+ resolve(initial.state);
1556
+ return;
1017
1557
  }
1018
- }
1019
1558
 
1020
- const bodyLines = (document.body ? document.body.innerText : '')
1021
- .split('\\n')
1022
- .map((line) => normalize(line))
1023
- .filter(Boolean);
1024
- const quotaLine =
1025
- bodyLines.find((line) => quotaPattern.test(line) && !ignoredQuotaPattern.test(line)) || null;
1559
+ clickNode(initial.workspaceButton.node);
1560
+ setTimeout(() => {
1561
+ const opened = readState();
1562
+ const quotaToggle = collectControls().find((item) => /剩余额度|remaining usage/i.test(item.text || item.aria || item.title));
1563
+ if (!(quotaToggle && quotaToggle.node instanceof HTMLElement)) {
1564
+ resolve(opened.state);
1565
+ return;
1566
+ }
1026
1567
 
1027
- return {
1028
- model,
1029
- reasoningEffort,
1030
- workspace,
1031
- branch,
1032
- permissionMode,
1033
- quotaSummary: quotaLine
1034
- };
1568
+ clickNode(quotaToggle.node);
1569
+ setTimeout(() => {
1570
+ resolve(readState().state);
1571
+ }, 120);
1572
+ }, 120);
1573
+ });
1035
1574
  })()
1036
1575
  `;
1037
1576
  }
@@ -1098,8 +1637,8 @@ export class CodexDesktopDriver {
1098
1637
  return (
1099
1638
  rect.y >= window.innerHeight - 120 &&
1100
1639
  rect.height <= 40 &&
1101
- rect.width <= 120 &&
1102
- /^(?:本地|云端|local|cloud)(?:\\d+%)?$/i.test(text)
1640
+ rect.width <= 140 &&
1641
+ /^(?:本地工作|在本地处理|本地项目|本地|云端|local|cloud|worktree)(?:\\d+%)?$/i.test(text)
1103
1642
  );
1104
1643
  });
1105
1644
 
@@ -1178,7 +1717,7 @@ export class CodexDesktopDriver {
1178
1717
  }
1179
1718
  const rect = node.getBoundingClientRect();
1180
1719
  const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
1181
- return rect.y >= window.innerHeight - 180 && rect.x >= 320 && matchesModelText(text);
1720
+ return rect.y >= window.innerHeight - 200 && matchesModelText(text);
1182
1721
  });
1183
1722
  const clickNode = (node) => {
1184
1723
  node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
@@ -1226,10 +1765,48 @@ export class CodexDesktopDriver {
1226
1765
  }
1227
1766
  buildAssistantReplyProbeScript() {
1228
1767
  return `(() => {
1229
- const assistantUnits = Array.from(
1768
+ const allAssistantUnits = Array.from(
1230
1769
  document.querySelectorAll('[data-content-search-unit-key$=":assistant"]')
1231
1770
  );
1232
- const latestAssistantUnit = assistantUnits.at(-1);
1771
+ const composer = document.querySelector(
1772
+ '[data-codex-composer="true"], textarea, input[type="text"], [contenteditable="true"], [role="textbox"]'
1773
+ );
1774
+ const composerRect = composer instanceof HTMLElement
1775
+ ? composer.getBoundingClientRect()
1776
+ : null;
1777
+ const visibleAssistantUnits = allAssistantUnits
1778
+ .filter((node) => node instanceof HTMLElement)
1779
+ .filter((node) => {
1780
+ const rect = node.getBoundingClientRect();
1781
+ return (
1782
+ rect.width > 0 &&
1783
+ rect.height > 0 &&
1784
+ rect.bottom >= -120 &&
1785
+ rect.top <= window.innerHeight + 480
1786
+ );
1787
+ });
1788
+ const viewportAnchoredAssistantUnits = visibleAssistantUnits.filter((node) => {
1789
+ if (!(node instanceof HTMLElement) || !composerRect) {
1790
+ return true;
1791
+ }
1792
+ const rect = node.getBoundingClientRect();
1793
+ return (
1794
+ rect.bottom >= composerRect.top - window.innerHeight * 0.75 &&
1795
+ rect.top <= composerRect.bottom + window.innerHeight
1796
+ );
1797
+ });
1798
+ const candidateAssistantUnits =
1799
+ viewportAnchoredAssistantUnits.length > 0
1800
+ ? viewportAnchoredAssistantUnits
1801
+ : (visibleAssistantUnits.length > 0 ? visibleAssistantUnits : allAssistantUnits);
1802
+ const latestAssistantUnit = candidateAssistantUnits
1803
+ .filter((node) => node instanceof HTMLElement)
1804
+ .sort((left, right) => {
1805
+ const leftRect = left.getBoundingClientRect();
1806
+ const rightRect = right.getBoundingClientRect();
1807
+ return rightRect.bottom - leftRect.bottom;
1808
+ })
1809
+ .at(0);
1233
1810
  if (!(latestAssistantUnit instanceof HTMLElement)) {
1234
1811
  return null;
1235
1812
  }
@@ -1432,12 +2009,6 @@ export class CodexDesktopDriver {
1432
2009
  return null;
1433
2010
  })
1434
2011
  .filter((value, index, values) => typeof value === 'string' && values.indexOf(value) === index);
1435
- const composer = document.querySelector(
1436
- '[data-codex-composer="true"], textarea, input[type="text"], [contenteditable="true"], [role="textbox"]'
1437
- );
1438
- const composerRect = composer instanceof HTMLElement
1439
- ? composer.getBoundingClientRect()
1440
- : null;
1441
2012
  const streamingMatcher = /(\\bstop\\b|\\bthinking\\b|\\bworking\\b|\\brunning\\b|停止|中止|取消|思考中|生成中)/i;
1442
2013
  const assistantStatusMatcher = /(Reconnecting\\.{3}|Searching\\.{3}|Running\\.{3}|Working\\.{3}|连接中\\.{0,3}|重新连接中\\.{0,3}|搜索中\\.{0,3}|执行中\\.{0,3}|处理中\\.{0,3})/i;
1443
2014
  const isComposerBusyButton = (node) => {
@@ -1521,6 +2092,39 @@ export class CodexDesktopDriver {
1521
2092
  : null;
1522
2093
  })();`;
1523
2094
  }
2095
+ buildConversationViewportFingerprintProbeScript() {
2096
+ return `(() => {
2097
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
2098
+ const units = Array.from(document.querySelectorAll('[data-content-search-unit-key]'))
2099
+ .filter((node) => node instanceof HTMLElement)
2100
+ .map((node) => {
2101
+ if (!(node instanceof HTMLElement)) {
2102
+ return null;
2103
+ }
2104
+ const rect = node.getBoundingClientRect();
2105
+ if (rect.width <= 0 || rect.height <= 0) {
2106
+ return null;
2107
+ }
2108
+ return node;
2109
+ })
2110
+ .filter((node) => node instanceof HTMLElement);
2111
+ const latestUnit = units.at(-1);
2112
+ if (!(latestUnit instanceof HTMLElement)) {
2113
+ return {
2114
+ latestUnitKey: null,
2115
+ latestSnippet: null,
2116
+ unitCount: 0
2117
+ };
2118
+ }
2119
+ const snippet = normalize(latestUnit.innerText)
2120
+ .slice(0, 200);
2121
+ return {
2122
+ latestUnitKey: latestUnit.getAttribute('data-content-search-unit-key'),
2123
+ latestSnippet: snippet || null,
2124
+ unitCount: units.length
2125
+ };
2126
+ })();`;
2127
+ }
1524
2128
  }
1525
2129
  function buildMediaArtifactFromReference(reference) {
1526
2130
  const normalizedReference = reference.trim();