agentlinkops 0.6.7 → 0.6.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
5
5
  "author": {
6
6
  "name": "AgentLinkOps"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.",
5
5
  "author": {
6
6
  "name": "AgentLinkOps"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to the `agentlinkops` package are recorded here. The version follows the
4
4
  plugin manifests in `.claude-plugin/` and `.codex-plugin/`.
5
5
 
6
+ ## 0.6.8 (2026-09-20)
7
+
8
+ - Recognize the anonymous ChatGPT response layout and use the latest visible assistant response consistently when reading answers and citations. Exclude user messages and response controls from answer text.
9
+ - Read citation URLs from a newly opened anonymous Sources panel after one normal click on the latest response's Sources control. Require the exact controlled panel layout, visible publisher-label coverage and three matching valid reads; unresolved, hidden, ambiguous or changed evidence remains unknown.
10
+ - Correct the bundled agent reference version. Package, web and documentation preflight checks now reject stale reference versions or mismatched generated copies.
11
+
6
12
  ## 0.6.7 (2026-09-20)
7
13
 
8
14
  - Default browser citation measurement to accountless mode. Saved session files are ignored unless `AGENTLINKOPS_BROWSER_AUTH=saved-session` is explicitly selected.
package/cli/client.js CHANGED
@@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js';
2
2
  // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
3
3
  // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
4
4
  // The version follows the plugin manifests; bump it with them.
5
- export const CLI_VERSION = '0.6.7';
5
+ export const CLI_VERSION = '0.6.8';
6
6
  export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
7
7
  export class CloudError extends Error {
8
8
  constructor(code, status, details = null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "A backlink and AI citation ledger that lives in your repository: local link and citation checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
5
5
  "author": "AgentLinkOps (https://agentlinkops.com)",
6
6
  "license": "Apache-2.0",
@@ -9,7 +9,7 @@ records. This page is the complete agent reference: the same text prints from
9
9
  full once per session, then use `describe` for any command's exact schema.
10
10
 
11
11
  <!-- generated:skill-version begin (npm run generate:tools) -->
12
- Pack version 0.6.5. 125 commands in 13 toolsets.
12
+ Pack version 0.6.8. 125 commands in 13 toolsets.
13
13
  <!-- generated:skill-version end -->
14
14
 
15
15
  ## Install and connect (once per machine)
@@ -0,0 +1,215 @@
1
+ import { PROVIDER_MODEL_RESPONSE_SELECTORS } from './shims/utils.js';
2
+
3
+ // This function is serialized into the browser. Keep all lookups within the
4
+ // latest visible, explicitly identified assistant response.
5
+ export function readChatgptCitationState(input) {
6
+ const selectors = Array.isArray(input) ? input : input.selectors;
7
+ const visible = element => {
8
+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
9
+ const style = window.getComputedStyle(element);
10
+ if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
11
+ const rect = element.getBoundingClientRect();
12
+ return rect.width > 0 && rect.height > 0;
13
+ };
14
+ const latestResponse = () => {
15
+ const elements = Array.from(document.querySelectorAll(selectors.join(', '))).filter(visible);
16
+ return elements.filter(element => !elements.some(other => other !== element && other.contains(element))).at(-1);
17
+ };
18
+ const root = latestResponse();
19
+ if (!root || root.getAttribute('aria-busy') === 'true' ||
20
+ (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
21
+ (root.innerText || '').trim().length <= 50) return { found: false, groups: 0, unresolved: true, sources: [] };
22
+ if (input.prepareBinding) {
23
+ const text = root.innerText;
24
+ return {isCurrent:() => latestResponse() === root && visible(root) && root.innerText === text &&
25
+ root.getAttribute('aria-busy') !== 'true' && !(root.getAttribute('data-message-id') || '').startsWith('request-placeholder')};
26
+ }
27
+ const groups = Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"]'));
28
+ const concealed = element => {
29
+ for (let node = element; node; node = node.parentElement) {
30
+ const style = window.getComputedStyle(node);
31
+ if (node.hidden || node.getAttribute('aria-hidden') === 'true' || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return true;
32
+ if (node === root) break;
33
+ }
34
+ return !visible(element);
35
+ };
36
+ const sources = [];
37
+ let unresolved = false;
38
+ const anonymous = root.matches(selectors.at(-1));
39
+ let panelEligible = anonymous && groups.length > 0 && groups.length <= 50;
40
+ const publisherLabels = [];
41
+ if (anonymous) {
42
+ // A new anonymous layout with unrecognized citation controls is not proof
43
+ // of absence. Never use unrelated page-wide links to fill this gap.
44
+ const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
45
+ const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
46
+ if (unsupported.some(element => !actions?.contains(element) && !groups.some(group => group.contains(element)))) {
47
+ unresolved = true; panelEligible = false;
48
+ }
49
+ }
50
+ for (const group of groups) {
51
+ if (concealed(group)) { unresolved = true; panelEligible = false; continue; }
52
+ if (anonymous) {
53
+ const buttons = Array.from(group.querySelectorAll('button'));
54
+ if (!buttons.length || buttons.length > 50 || group.querySelector('a[href], [role="button"]:not(button)')) panelEligible = false;
55
+ for (const button of buttons) {
56
+ const label = (button.getAttribute('aria-label') || '').trim();
57
+ if (!label || label.length > 200 || concealed(button)) panelEligible = false;
58
+ else publisherLabels.push(label);
59
+ }
60
+ }
61
+ let resolved = 0;
62
+ for (const anchor of group.querySelectorAll('a[href]')) {
63
+ if (concealed(anchor)) { unresolved = true; continue; }
64
+ try {
65
+ const url = new URL(anchor.href);
66
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw Error();
67
+ sources.push({ url: url.href, title: (anchor.textContent || '').trim() });
68
+ resolved++;
69
+ } catch { unresolved = true; }
70
+ }
71
+ // Observed anonymous pills are buttons without URLs. Their labels are
72
+ // not URL evidence; a partial anchor list cannot resolve those controls.
73
+ if (!resolved || group.querySelector('button, [role="button"]')) unresolved = true;
74
+ }
75
+ const labels = [...new Set(publisherLabels)];
76
+ panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
77
+ const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible };
78
+ if (!input.prepare) return state;
79
+ if (!panelEligible || !unresolved) return {state};
80
+ // Keep the exact assistant and control references in-page. No DOM identities,
81
+ // tokens or control IDs are persisted in observations.
82
+ const footer = root.querySelector(':scope > [role="group"][aria-label="Response actions"]');
83
+ const buttons = Array.from(footer?.querySelectorAll('button') || []).filter(button =>
84
+ !concealed(button) && !button.disabled && Array.from(button.children).some(child =>
85
+ !concealed(child) && child.children.length === 0 && (child.textContent || '').trim() === 'Sources'));
86
+ if (buttons.length !== 1) return {state};
87
+ const button = buttons[0], controlledId = button.getAttribute('aria-controls');
88
+ if (!controlledId || controlledId.length > 256) return {state};
89
+ const before = document.getElementById(controlledId);
90
+ // Only the observed absent/empty target is supported. A populated old panel
91
+ // must not be mistaken for freshly resolved citations from this answer.
92
+ if (before && (visible(before) || (before.textContent || '').trim() || before.querySelector('a'))) return {state};
93
+ const responseText = root.innerText;
94
+ const unavailable = () => ({status:'unavailable'});
95
+ const inspect = () => {
96
+ if (latestResponse() !== root || !root.isConnected || !root.matches(selectors.at(-1)) || concealed(root) ||
97
+ root.innerText !== responseText || root.getAttribute('aria-busy') === 'true' ||
98
+ (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
99
+ !button.isConnected || !footer.contains(button) || !root.contains(footer) || button.getAttribute('aria-controls') !== controlledId) return unavailable();
100
+ const currentState = readChatgptCitationState(selectors);
101
+ if (!currentState.panelEligible || currentState.groups !== state.groups) return unavailable();
102
+ const currentLabels = [...new Set(Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"] button')).map(node => (node.getAttribute('aria-label') || '').trim()))];
103
+ if (currentLabels.length !== labels.length || currentLabels.some(label => !labels.includes(label))) return unavailable();
104
+ const target = document.getElementById(controlledId);
105
+ if (!target || !visible(target)) return {status:'pending'};
106
+ if (target === root || target.contains(root) || ['BODY','MAIN','HTML','NAV'].includes(target.tagName)) return unavailable();
107
+ const articles = (target.matches('article') ? [target] : Array.from(target.querySelectorAll('article'))).filter(visible);
108
+ if (articles.length !== 1) return unavailable();
109
+ const article = articles[0];
110
+ if (concealed(article) || article.querySelector('form,input,textarea,select,[contenteditable="true"],button,[role="button"],[data-message-author-role="user"],[data-turn="user"],[aria-busy="true"],[role="progressbar"]')) return unavailable();
111
+ const headings = article.querySelectorAll('h1,h2,h3,[role="heading"]');
112
+ const header = article.querySelector(':scope > header > h2');
113
+ if (headings.length !== 1 || headings[0] !== header || concealed(header) || (header.textContent || '').trim() !== 'Sources') return unavailable();
114
+ const lists = article.querySelectorAll('ol');
115
+ if (lists.length !== 1 || article.querySelector('ul')) return unavailable();
116
+ const list = lists[0], cards = Array.from(list.children);
117
+ if (concealed(list) || !cards.length || cards.length > 50 || list.querySelectorAll('li').length !== cards.length || article.querySelectorAll('a').length !== cards.length) return unavailable();
118
+ const foundLabels = new Set(), results = [];
119
+ for (const card of cards) {
120
+ if (card.tagName !== 'LI' || concealed(card) || card.children.length !== 1 || card.firstElementChild.tagName !== 'A') return unavailable();
121
+ const anchor = card.firstElementChild, titles = anchor.querySelectorAll('strong');
122
+ if (concealed(anchor) || titles.length !== 1 || concealed(titles[0])) return unavailable();
123
+ const title = (titles[0].textContent || '').trim();
124
+ if (!title || title.length > 1000) return unavailable();
125
+ let url;
126
+ try {
127
+ const raw = anchor.getAttribute('href');
128
+ if (!raw || raw.length > 8192 || /[\u0000-\u0020\u007f]/.test(raw)) return unavailable();
129
+ url = new URL(raw);
130
+ if (!['http:','https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return unavailable();
131
+ } catch { return unavailable(); }
132
+ // Exact visible publisher labels, not guessed domains or URL derivation.
133
+ const matched = [...new Set(Array.from(anchor.querySelectorAll('span')).filter(node =>
134
+ node.children.length === 0 && !concealed(node)).map(node => (node.textContent || '').trim()).filter(label => labels.includes(label)))];
135
+ if (matched.length !== 1) return unavailable();
136
+ foundLabels.add(matched[0]);
137
+ results.push({url:url.href,title});
138
+ }
139
+ if (foundLabels.size !== labels.length) return unavailable();
140
+ return {status:'resolved',sources:results};
141
+ };
142
+ return {state,button,inspect};
143
+ }
144
+
145
+ const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
146
+ const responseBindings = new WeakMap();
147
+ const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent());
148
+ export async function releaseChatgptResponseBinding(page) {
149
+ const binding = responseBindings.get(page);
150
+ responseBindings.delete(page);
151
+ await binding?.dispose().catch(() => {});
152
+ }
153
+ export async function extractBoundChatgptResponse(page, legacyExtract) {
154
+ const previous = responseBindings.get(page);
155
+ responseBindings.delete(page);
156
+ if (previous) await previous.dispose().catch(() => {});
157
+ let binding;
158
+ try {
159
+ binding = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepareBinding:true});
160
+ if (!await bindingCurrent(binding)) throw unavailable();
161
+ const answer = await legacyExtract(page);
162
+ if (!await bindingCurrent(binding)) throw unavailable();
163
+ responseBindings.set(page,binding);
164
+ return answer;
165
+ } catch {
166
+ await binding?.dispose().catch(() => {});
167
+ throw unavailable();
168
+ }
169
+ }
170
+ async function extractControlledPanel(page, binding) {
171
+ let handle, buttonHandle;
172
+ try {
173
+ handle = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepare:true});
174
+ buttonHandle = await handle.getProperty('button');
175
+ const button = buttonHandle.asElement();
176
+ if (!button) throw unavailable();
177
+ await button.click({timeout:3000});
178
+ // Bounded DOM render polling only; never another click/provider invocation.
179
+ let previous = null, stable = 0;
180
+ for (let attempt=0; attempt<12; attempt++) {
181
+ const result = await handle.evaluate((state,guard) => guard && !guard.isCurrent() ? {status:'unavailable'} : state.inspect(),binding || null);
182
+ if (result.status === 'resolved') {
183
+ const signature = JSON.stringify(result.sources);
184
+ stable = signature === previous ? stable + 1 : 1;
185
+ previous = signature;
186
+ // Publisher coverage alone is insufficient while more same-publisher
187
+ // cards may still be rendering. Require three identical valid reads.
188
+ if (stable >= 3) return [...new Map(result.sources.map(source => [source.url,source])).values()];
189
+ } else if (result.status === 'pending') { previous = null; stable = 0; }
190
+ else throw unavailable();
191
+ await page.waitForTimeout(250);
192
+ }
193
+ throw unavailable();
194
+ } catch { throw unavailable(); }
195
+ finally { await Promise.allSettled([buttonHandle?.dispose(),handle?.dispose()]); }
196
+ }
197
+
198
+ export async function extractChatgptSources(page, legacyExtract) {
199
+ const binding = responseBindings.get(page);
200
+ try {
201
+ if (binding && !await bindingCurrent(binding)) throw unavailable();
202
+ const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
203
+ let sources;
204
+ if (!state.found || state.unresolved) {
205
+ if (!state.found || !state.panelEligible) throw unavailable();
206
+ sources = await extractControlledPanel(page,binding);
207
+ } else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
208
+ else sources = await legacyExtract(page);
209
+ if (binding && !await bindingCurrent(binding)) throw unavailable();
210
+ return sources;
211
+ } finally {
212
+ responseBindings.delete(page);
213
+ await binding?.dispose().catch(() => {});
214
+ }
215
+ }
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
 
16
16
  import { EngineError } from '../adapter.js';
17
17
  import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
18
+ import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js';
18
19
 
19
20
  const MODULE_LOADERS = {
20
21
  'playwright-core': () => import('playwright-core'),
@@ -101,6 +102,13 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
101
102
  const { OWN_PROVIDER_CONFIGS } = await load('./providers.js');
102
103
  providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
103
104
  if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
105
+ if (engineName === 'chatgpt') {
106
+ const legacyExtract = providerConfig.extractSources;
107
+ const legacyResponse = providerConfig.extractResponse;
108
+ providerConfig = { ...providerConfig,
109
+ extractResponse: page => extractBoundChatgptResponse(page, legacyResponse),
110
+ extractSources: page => extractChatgptSources(page, legacyExtract) };
111
+ }
104
112
  return providerConfig;
105
113
  }
106
114
 
@@ -221,6 +229,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
221
229
  locale: 'en-US',
222
230
  timezoneId: 'America/New_York',
223
231
  });
232
+ let page;
224
233
  try {
225
234
  // Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
226
235
  // images and media are the bulk of the 3-8 MB measured per observation.
@@ -232,7 +241,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
232
241
  return route.continue();
233
242
  });
234
243
  }
235
- const page = await context.newPage();
244
+ page = await context.newPage();
236
245
  const { runPageDomOp } = await load('./gen/lib/browser/domOps.js');
237
246
  page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params);
238
247
  let answer = '';
@@ -317,7 +326,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
317
326
  await config.beforeSubmitHook?.(page);
318
327
 
319
328
  // Submit: the surface's own send button when it exposes one; Enter fallback.
320
- // Submission is verified: the composer clears or a user turn appears.
329
+ // Allow the surface to settle; response extraction remains fail-closed.
321
330
  const send = await page.locator('button[aria-label="Send message"], button[data-testid="send-button"], button[aria-label*="Submit" i], button[aria-label*="Send" i]').first();
322
331
  if (await send.isVisible().catch(() => false)) { await send.click(); }
323
332
  else { await editor.locator.press('Enter'); }
@@ -355,6 +364,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
355
364
  screenshotPng,
356
365
  };
357
366
  } catch (cause) {
367
+ if (cause?.code === 'CITATION_EXTRACTION_UNAVAILABLE') {
368
+ throw Object.assign(new EngineError('Citation response could not be bound.'), {code:'CITATION_EXTRACTION_UNAVAILABLE',unknown:true});
369
+ }
358
370
  if (cause instanceof EngineError) throw cause;
359
371
  const msg = String(cause?.message ?? cause);
360
372
  const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
@@ -362,6 +374,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
362
374
  error.unknown = true;
363
375
  throw error;
364
376
  } finally {
377
+ if (engineName === 'chatgpt' && page) await closeQuietly(() => releaseChatgptResponseBinding(page));
365
378
  if(!await closeQuietly(() => context.close()))contextCleanupFailed=true;
366
379
  }
367
380
  },
@@ -378,10 +391,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
378
391
  },
379
392
  };
380
393
  const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine);
381
- const policyCodes=new Set(['BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']);
394
+ const policyCodes=new Set(['CITATION_EXTRACTION_UNAVAILABLE','BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']);
382
395
  const safeError=cause=>{
383
396
  const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED';
384
- const error=Object.assign(new EngineError(code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
397
+ const error=Object.assign(new EngineError(code==='CITATION_EXTRACTION_UNAVAILABLE'?'Citation binding or extraction was unavailable.':code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
385
398
  if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
386
399
  return error;
387
400
  };
@@ -96,9 +96,11 @@ async function runPageDomOp(page, operation, params) {
96
96
  function findLatestResponseElement(selectors) {
97
97
  const selector = (selectors || []).join(", ");
98
98
  if (!selector.trim()) return null;
99
- const element = Array.from(document.querySelectorAll(selector)).filter(
100
- (el) => el instanceof HTMLElement && isVisible(el) && !isResponsePlaceholder(el) && el.innerText.trim().length > 50
101
- ).pop() ?? null;
99
+ const elements = Array.from(document.querySelectorAll(selector)).filter(
100
+ (el) => el instanceof HTMLElement && isVisible(el)
101
+ );
102
+ const element = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null;
103
+ if (!element || isResponsePlaceholder(element) || element.innerText.trim().length <= 50) return null;
102
104
  return element ? { selector, element } : null;
103
105
  }
104
106
  function getCachedRawSources(key) {
@@ -34,15 +34,16 @@ async function hasVisibleGenerationIndicator(page, provider) {
34
34
  async function getResponseStateSignature(page, provider) {
35
35
  return await page.evaluate((selectors) => {
36
36
  const visible = (element) => {
37
- if (!(element instanceof HTMLElement)) return false;
37
+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
38
38
  const style = window.getComputedStyle(element);
39
- return element.offsetParent !== null && style.visibility !== "hidden" && style.display !== "none";
39
+ if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
40
+ const rect = element.getBoundingClientRect();
41
+ return rect.width > 0 && rect.height > 0;
40
42
  };
41
- const elements = (selectors || []).flatMap((selector) => Array.from(document.querySelectorAll(selector))).filter(
42
- (el) => visible(el) && (el.innerText || "").trim().length > 0
43
- );
44
- const latest = elements.at(-1) ?? null;
45
- if (!latest) {
43
+ const selector = (selectors || []).join(", ");
44
+ const elements = selector.trim() ? Array.from(document.querySelectorAll(selector)).filter(visible) : [];
45
+ const latest = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null;
46
+ if (!latest || latest.getAttribute("aria-busy") === "true" || (latest.getAttribute("data-message-id") || "").startsWith("request-placeholder") || (latest.innerText || "").trim().length <= 50) {
46
47
  return { signature: "", textLength: 0 };
47
48
  }
48
49
  const text = (latest.innerText || "").replace(/\s+/g, " ").trim();
@@ -1,15 +1,23 @@
1
1
  // Shim for @oneglanse/utils runtime exports. The selector/timeout constants
2
- // are re-exported from the vendored agent-constants module unchanged; logger,
2
+ // are re-exported except the evidenced anonymous ChatGPT response boundary; logger,
3
3
  // getDomain and getFaviconUrls are minimal equivalents.
4
4
  export {
5
5
  PROVIDER_NO_OUTPUT_TIMEOUT_MS,
6
6
  PROVIDER_FORCE_EXIT_STABLE_MS,
7
7
  PROVIDER_EDITOR_SELECTORS,
8
8
  PROVIDER_SUBMIT_BTN_SELECTORS,
9
- PROVIDER_MODEL_RESPONSE_SELECTORS,
10
9
  PROVIDER_RESPONSE_GENERATION_SELECTORS,
11
10
  RETRYABLE_ERRORS,
12
11
  } from '../gen/utils/agent-constants.js';
12
+ import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } from '../gen/utils/agent-constants.js';
13
+
14
+ // Anonymous surface observed 2026-09-20: conversation LI, distinct from its
15
+ // user sibling, with response actions. Never select the conversation itself.
16
+ export const CHATGPT_ANONYMOUS_RESPONSE_SELECTOR = 'ol[aria-label="Conversation"] > li:has(> [role="group"][aria-label="Response actions"] button[aria-label="Copy response"]):not(:has(button[aria-label="Copy message"], [data-message-author-role="user"], [data-turn="user"], form, input, textarea, [contenteditable="true"]))';
17
+ export const PROVIDER_MODEL_RESPONSE_SELECTORS = Object.freeze({
18
+ ...vendoredResponseSelectors,
19
+ chatgpt: Object.freeze([...vendoredResponseSelectors.chatgpt, CHATGPT_ANONYMOUS_RESPONSE_SELECTOR]),
20
+ });
13
21
 
14
22
  const envQuiet = () => Boolean(process.env.AGENTLINKOPS_BROWSER_QUIET);
15
23