From c7739774cd13cd184b65095dc8fb68ca50017adb Mon Sep 17 00:00:00 2001 From: sunlover7 Date: Fri, 25 Sep 2026 17:14:40 -0400 Subject: [PATCH] Release 0.6.9: current ChatGPT citation layout, prompt integrity and schema verb - Read ChatGPT citations in the current anonymous layout (buttons with a JSON sources payload) - Verify the prompt before and after submitting; find the current composer directly - Treat a ChatGPT failure turn as unknown, never a not-cited answer - End orphaned Xvfb when a measurement process dies - Name unrecognized element kinds when citations stay unknown - Add the schema verb for exact command schemas --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 9 + cli/client.js | 2 +- cli/commands.js | 25 +- cli/main.js | 2 +- package-lock.json | 4 +- package.json | 2 +- shared/exec-grammar.js | 342 ++++++++++++++++++ .../references/agentlinkops.md | 43 ++- src/citations/browser/chatgpt-citations.js | 66 +++- src/citations/browser/engine.js | 94 ++++- src/citations/browser/shims/utils.js | 11 +- src/tool-views.js | 31 +- test/citation-browser-provider.test.js | 85 ++++- test/citation-chatgpt-surface.test.js | 79 ++++ 16 files changed, 747 insertions(+), 52 deletions(-) create mode 100644 shared/exec-grammar.js diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e601960..94c1fe5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.8", + "version": "0.6.9", "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.", "author": { "name": "AgentLinkOps" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ad8d21e..ebc1ccf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.8", + "version": "0.6.9", "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.", "author": { "name": "AgentLinkOps" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4510b1a..304be01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `agentlinkops` package are recorded here. The version follows the plugin manifests in `.claude-plugin/` and `.codex-plugin/`. +## 0.6.9 (2026-09-25) + +- Read ChatGPT citations in the current anonymous layout, where inline citations and source pills are buttons carrying their sources as JSON. 0.6.8 read only anchor links and recorded these answers as unknown. Code-copy, table-copy and entity controls are recognized as non-citations; any other unrecognized control still keeps the answer unknown. +- Verify the prompt before and after submitting: the composer must hold the whole panel prompt (one refill, otherwise a retriable unknown), and the submitted turn must match it. 0.6.8 could submit a truncated prompt and score the answer to a different question. +- Find the current anonymous ChatGPT composer (`textarea#mobile-composer-prompt`) directly instead of through the broad fallback. +- Treat a ChatGPT failure message shown as the assistant turn ("Something went wrong...") as a retriable unknown. 0.6.8 recorded it as an answer and a false not-cited observation. +- Name the kinds of unrecognized element (tag and role counts, never URLs or text) when an anonymous ChatGPT answer's citations cannot be read and the observation stays unknown. +- End the virtual display (Xvfb) when a browser measurement process is killed or crashes. A detached watchdog stops Xvfb within five seconds of the process disappearing and only signals the pid while it is still Xvfb. It stays armed if a normal close cannot be confirmed. + ## 0.6.8 (2026-09-20) - 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. diff --git a/cli/client.js b/cli/client.js index 7ed8583..2ba5a37 100644 --- a/cli/client.js +++ b/cli/client.js @@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js'; // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here. // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers. // The version follows the plugin manifests; bump it with them. -export const CLI_VERSION = '0.6.8'; +export const CLI_VERSION = '0.6.9'; export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`; export class CloudError extends Error { constructor(code, status, details = null) { diff --git a/cli/commands.js b/cli/commands.js index 49b87c7..94ab5f2 100644 --- a/cli/commands.js +++ b/cli/commands.js @@ -7,17 +7,19 @@ import { parseArgs } from './args.js'; import { NUDGE } from './skill.js'; import { CATALOG_COMMANDS, CATALOG_TOOLSETS } from './catalog.js'; import { oneLiner } from '../src/tool-views.js'; +import { resolveSchemaPath, listAvailablePaths } from '../shared/exec-grammar.js'; // DP-0036-T05: the CLI's three-level discovery over the cloud catalog — index (`tools`), // describe (`describe NAME`) and call (`call NAME`). `tools` and `describe` answer from the // bundled snapshot in cli/catalog.js with no account and no network; `--refresh` reads the // live catalog instead. Output is JSON whenever stdout is not a terminal or --json is given, // and one error shape ({ error: { code, message, next } }) on every failure. -const USAGE = 'tools [TOOLSET] [--json] [--refresh] | describe NAME [--examples] [--output-schema] [--json] [--refresh] | call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y] [--json]'; +const USAGE = 'tools [TOOLSET] [--json] [--refresh] | describe NAME [--examples] [--output-schema] [--json] [--refresh] | schema NAME [FIELD.PATH] [--json] | call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y] [--json]'; const NAME = /^[a-z][a-z0-9_]*$/; const NEXT = { TOOL_NOT_FOUND: 'Run `agentlinkops tools` to list commands, or `agentlinkops tools TOOLSET` for one group.', INVALID_INPUT: 'Run `agentlinkops describe NAME` for the exact input schema.', + SCHEMA_PATH_NOT_FOUND: 'Run `agentlinkops schema NAME` for the top level, or drill a listed child path.', INSUFFICIENT_SCOPE: 'This credential lacks a scope the command needs; request it through your connection or a new project-scoped key.', NO_CLOUD_ORIGIN: 'Run `agentlinkops connect` or set AGENTLINKOPS_API_URL.', INVALID_CLOUD_ORIGIN: 'Cloud commands need an HTTPS origin without a path, for example https://app.agentlinkops.com.', @@ -102,8 +104,9 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en const [command, name] = args._; const json = args.json === true || !isTTY; const fail = (code, message) => { if (json) err(errorJson(code, message, explain(code))); else err(`${message}\n${explain(code)}`); return 2; }; - const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] }; - if (!allowed[command] || Object.keys(args).some(key => !allowed[command].includes(key)) || (command === 'tools' && args._.length > 2) || (command !== 'tools' && (args._.length !== 2 || !NAME.test(name)))) throw new ConfigError(USAGE); + const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], schema: ['_', 'json'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] }; + const positionalCount = command === 'tools' ? args._.length > 2 : command === 'schema' ? args._.length !== 2 && args._.length !== 3 : args._.length !== 2; + if (!allowed[command] || Object.keys(args).some(key => !allowed[command].includes(key)) || positionalCount || (command !== 'tools' && !NAME.test(name))) throw new ConfigError(USAGE); if (args.set?.length && (args.set.some(v => typeof v !== 'string') || args.args !== undefined || args.file !== undefined)) throw new ConfigError('--set cannot be combined with --args or --file'); if (args.args !== undefined && args.file !== undefined) throw new ConfigError(USAGE); @@ -160,6 +163,22 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en out(renderDescription(found, name, { examples: args.examples === true, outputSchema: args['output-schema'] === true, json })); return 0; } + if (command === 'schema') { + // DP-0059: the same drill the MCP exec tool serves, over the same bundled schema, so one + // grammar holds across surfaces: `schema monitor_link scope.target` prints that field. + const found = resolveName(commands, name); + if (!found) return fail('TOOL_NOT_FOUND', `No command named ${name}.`); + const path = args._[2] ?? null; + if (!path) { out(JSON.stringify({ name: found.name, schema: found.inputSchema }, null, 2)); return 0; } + const subschema = resolveSchemaPath(found.inputSchema, path); + if (!subschema) { + const parent = resolveSchemaPath(found.inputSchema, path.split('.').slice(0, -1).join('.')) ?? found.inputSchema; + const available = listAvailablePaths(parent); + return fail('SCHEMA_PATH_NOT_FOUND', `"${path}" is not a path in ${found.name}'s schema.${available.length ? ` Available here: ${available.join(', ')}.` : ''}`); + } + out(JSON.stringify({ name: found.name, path, schema: subschema }, null, 2)); + return 0; + } // call const found = resolveName(commands, name); diff --git a/cli/main.js b/cli/main.js index b36cd16..b0b36bf 100644 --- a/cli/main.js +++ b/cli/main.js @@ -152,7 +152,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out}); if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out}); if (command === 'setup') return await setupPlanMain(argv, { cwd, out }); - if (command === 'tools' || command === 'describe' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl }); + if (command === 'tools' || command === 'describe' || command === 'schema' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl }); if (command === 'skill') return await skillMain(argv, { out }); if (command === 'agent') return await agentMain(argv, { cwd, out, err }); if (command === 'connect') { diff --git a/package-lock.json b/package-lock.json index fe6c9ce..b6a4315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentlinkops", - "version": "0.6.8", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentlinkops", - "version": "0.6.8", + "version": "0.6.9", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index 8694f0e..cbea0e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.8", + "version": "0.6.9", "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.", "author": "AgentLinkOps (https://agentlinkops.com)", "license": "Apache-2.0", diff --git a/shared/exec-grammar.js b/shared/exec-grammar.js new file mode 100644 index 0000000..3d02638 --- /dev/null +++ b/shared/exec-grammar.js @@ -0,0 +1,342 @@ +// DP-0059: the exec grammar — one command string, five verbs, shared by the MCP exec tool, +// the CLI and the measurement scripts so one grammar serves every surface (the PostHog +// single-exec pattern: the catalog is searched and inspected on demand instead of listed). +// Pure: no imports, no environment, safe in Workerd, Node and the bundler. +// +// search [--toolset T] [--limit N] +// tools [toolset] +// describe [--output-schema] [--examples] [ ...] +// schema [] +// call [--json] +// +// Parsing is quote-aware so a JSON body or a quoted pattern survives intact, and the body of +// `call` is taken as the raw remainder after the command name (it may span lines). A request +// that stacks several commands is detected and reported, never executed partially. + +export const EXEC_VERBS = ['search', 'tools', 'describe', 'schema', 'call']; +const VERB_ALIASES = { info: 'describe', run: 'call', help: 'help' }; + +export const EXEC_USAGE = [ + 'CLI-style command string. One command per exec call; several exec calls can run in parallel.', + ' search [--toolset T] [--limit N] find commands by task (name, toolset, description, parameters, keywords)', + ' tools [toolset] grouped command names', + ' describe [--output-schema] [--examples] [ ...] exact input schema, scopes, aliases, examples', + ' schema [] drill into one field of a schema (dot paths, e.g. scope.target, members.0.role)', + ' call [--json] run the command with one JSON object; --json returns full rows instead of the concise projection', + 'Find with search, describe a name once, then call it and reuse the schema. Never guess a schema; never describe before every call.', +].join('\n'); + +export const BATCH_MESSAGE = count => + `exec runs one command per call and this request held ${count}. Send each one as its own exec call; you can issue them in parallel.`; + +// A describe or schema answer larger than this is summarized, with drill-down hints on the +// fields that need them (chars, not tokens: ~5.5k tokens at the measurement ratio). +export const INFO_CHAR_LIMIT = 20000; + +// --- tokenizer --------------------------------------------------------------------------------- + +// Splits on whitespace outside single or double quotes; backslash escapes the quote character +// inside a quoted span. Returns tokens with their span in the raw string so `call` can slice +// its JSON body out of the original text. +function tokenize(input) { + const tokens = []; + let index = 0; + while (index < input.length) { + while (index < input.length && /\s/.test(input[index])) index++; + if (index >= input.length) break; + const start = index; + let value = ''; + let quoted = false; + while (index < input.length) { + const char = input[index]; + if (char === '"' || char === "'") { + if (quoted === false) { quoted = char; index++; continue; } + if (quoted === char) { + if (input[index + 1] === char) { value += char; index += 2; continue; } // doubled quote escapes itself + quoted = false; index++; continue; + } + } + if (char === '\\' && quoted && input[index + 1] === quoted) { value += quoted; index += 2; continue; } + if (!quoted && /\s/.test(char)) break; + value += char; index++; + } + if (quoted) throw new GrammarError('UNTERMINATED_QUOTE', `A ${quoted}-quoted span never closes.`, 'Quote the whole argument, or drop the quotes.'); + tokens.push({ value, start, end: index }); + } + return tokens; +} + +class GrammarError extends Error { + constructor(code, message, next) { super(message); this.code = code; this.next = next; } +} + +const usage = (message, next) => ({ ok: false, error: { code: 'EXEC_USAGE', message, next: next ?? EXEC_USAGE } }); + +const BOOLEAN_FLAGS = { + describe: ['--output-schema', '--examples'], + call: ['--json'], + search: [], + tools: [], + schema: [], +}; +const VALUE_FLAGS = { + search: ['--toolset', '--limit'], + describe: [], + call: [], + tools: [], + schema: [], +}; + +/** + * Parse one exec command string. + * + * @returns {{ok:true, verb:string} & Record | {ok:false, error:{code:string, message:string, next:string}}} + */ +export function parseExecCommand(command) { + if (typeof command !== 'string' || !command.trim()) return usage('The command string is empty.', 'Pick a verb: ' + EXEC_VERBS.join(', ') + '.'); + let tokens; + try { tokens = tokenize(command); } catch (error) { + return { ok: false, error: { code: error.code, message: error.message, next: error.next } }; + } + if (!tokens.length) return usage('The command string is empty.', 'Pick a verb: ' + EXEC_VERBS.join(', ') + '.'); + const requested = tokens[0].value; + const verb = VERB_ALIASES[requested] ?? requested; + if (![...EXEC_VERBS, 'help'].includes(verb)) { + return { ok: false, error: { code: 'UNKNOWN_VERB', message: `"${requested}" is not an exec verb.`, next: 'Verbs: ' + EXEC_VERBS.join(', ') + '.' } }; + } + if (verb === 'help') return { ok: true, verb: 'help' }; + + const flags = {}; + let cursor = 1; + const takeFlags = () => { + while (cursor < tokens.length && tokens[cursor].value.startsWith('--')) { + const flag = tokens[cursor].value; + const booleanOk = BOOLEAN_FLAGS[verb]?.includes(flag); + const valueOk = VALUE_FLAGS[verb]?.includes(flag); + if (!booleanOk && !valueOk) throw new GrammarError('UNKNOWN_FLAG', `--${flag.slice(2)} is not a ${verb} flag.`, EXEC_USAGE); + if (valueOk) { + const valueToken = tokens[cursor + 1]; + if (!valueToken) throw new GrammarError('FLAG_NEEDS_VALUE', `${flag} needs a value.`, EXEC_USAGE); + flags[flag.slice(2)] = valueToken.value; + cursor += 2; + } else { + flags[flag.slice(2)] = true; + cursor += 1; + } + } + }; + try { takeFlags(); } catch (error) { return { ok: false, error: { code: error.code, message: error.message, next: error.next } }; } + const operands = tokens.slice(cursor); + + if (verb === 'search') { + const query = command.slice(tokens[cursor]?.start ?? command.length).trim(); + if (!query) return usage('search needs words to find commands by.', 'Example: search destination health'); + const limit = flags.limit === undefined ? undefined : Number(flags.limit); + if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 25)) return usage(`--limit must be an integer from 1 to 25, not "${flags.limit}".`); + return { ok: true, verb, query, toolset: flags.toolset ?? null, limit }; + } + if (verb === 'tools') { + if (operands.length > 1) return usage('tools takes at most one toolset name.'); + return { ok: true, verb, toolset: operands[0]?.value ?? null }; + } + if (verb === 'describe') { + const names = operands.map(token => token.value); + if (!names.length) return usage('describe needs at least one command name.', 'search finds the right name first.'); + if (names.length > 10) return usage('describe takes at most ten names per call.'); + return { ok: true, verb, names, outputSchema: flags['output-schema'] === true, examples: flags.examples === true }; + } + if (verb === 'schema') { + if (!operands.length) return usage('schema needs a command name.', 'Example: schema monitor_link scope.target'); + if (operands.length > 2) return usage('schema takes a command name and at most one field path.'); + return { ok: true, verb, name: operands[0].value, path: operands[1]?.value ?? null }; + } + // call: flags and the command name are tokens; the JSON body is the raw remainder. + if (!operands.length) return usage('call needs a command name.', 'search finds the right name first.'); + const name = operands[0].value; + const jsonText = command.slice(operands[0].end).trim(); + return { ok: true, verb, name, jsonText: jsonText || '{}', detailed: flags.json === true }; +} + +// --- batch detection ---------------------------------------------------------------------------- + +const startsWithVerb = line => { + const match = /^[^\S\n]*([A-Za-z_][A-Za-z0-9_-]*)/.exec(line ?? ''); + if (!match) return false; + const verb = VERB_ALIASES[match[1]] ?? match[1].toLowerCase(); + return [...EXEC_VERBS, 'help'].includes(verb); +}; + +// Only `call` can legitimately span lines (its JSON body); it is complete once that body parses. +function isComplete(command) { + const parsed = parseExecCommand(command); + if (!parsed.ok || parsed.verb !== 'call') return true; + try { JSON.parse(parsed.jsonText); return true; } catch { return false; } +} + +/** Returns the stacked commands when the string holds several, else undefined. */ +export function splitBatchedCommands(command) { + if (typeof command !== 'string') return undefined; + const lines = command.split('\n'); + if (lines.length < 2 || !startsWithVerb(lines[0])) return undefined; + const commands = []; + let current = lines[0]; + for (const line of lines.slice(1)) { + if (startsWithVerb(line) && isComplete(current)) { commands.push(current); current = line; continue; } + current += '\n' + line; + } + if (!commands.length) return undefined; + commands.push(current); + return commands.map(entry => entry.trim()).filter(Boolean); +} + +// --- schema drill-down --------------------------------------------------------------------------- + +// zod's toJSONSchema factors shared shapes into $defs with #/$defs/name pointers. Resolve one +// against the root, with a depth guard so a recursive schema cannot loop the walk. +function deref(schema, root, depth = 0) { + let cursor = schema; + let steps = 0; + while (cursor && typeof cursor === 'object' && typeof cursor.$ref === 'string' && steps++ < 20) { + if (!cursor.$ref.startsWith('#/')) return null; + let node = root; + for (const segment of cursor.$ref.slice(2).split('/')) { + const key = segment.replace(/~1/g, '/').replace(/~0/g, '~'); + if (node == null || typeof node !== 'object' || !(key in node)) return null; + node = node[key]; + } + cursor = node; + } + return depth > 20 ? null : cursor; +} + +const isComplex = schema => + schema != null && typeof schema === 'object' && ( + (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length > 0) || + schema.type === 'array' || + Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || + JSON.stringify(schema).length > 800 + ); + +const firstSentence = (text, max = 120) => { + const sentence = String(text ?? '').split(/(?<=\.)\s/)[0] ?? ''; + if (sentence.length <= max) return sentence; + const cut = sentence.slice(0, max + 1); + return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 40)).replace(/[,;:]$/, '')}…`; +}; + +// One line of scalar facts for a schema node: type, enum, default, bounds, description. +export function shapeOf(schema, root) { + const node = deref(schema, root) ?? schema; + if (node == null || typeof node !== 'object') return { type: 'any' }; + const variants = node.anyOf ?? node.oneOf; + if (Array.isArray(variants)) return { type: 'one of', variants: variants.map(v => shapeOf(v, root).type ?? 'any'), description: firstSentence(node.description) || undefined }; + const shape = {}; + if (node.type) shape.type = node.type; + if (Array.isArray(node.enum)) shape.enum = node.enum.slice(0, 12); + if (node.default !== undefined) shape.default = node.default; + if (node.minimum !== undefined || node.maximum !== undefined) shape.bounds = [node.minimum, node.maximum]; + if (node.minLength !== undefined || node.maxLength !== undefined) shape.length = [node.minLength, node.maxLength]; + if (node.format) shape.format = node.format; + if (node.description) shape.description = firstSentence(node.description); + return shape; +} + +/** + * Summarize a schema for a describe or schema answer that exceeded the character budget: + * object properties collapse to their shape, complex children carry a hint naming the + * `schema ` call that expands them. + */ +export function summarizeSchema(schema, name, path = '') { + const root = schema; + const summarizeNode = (node, nodePath) => { + if (node == null || typeof node !== 'object') return node; + if (Array.isArray(node.anyOf) || Array.isArray(node.oneOf)) { + return { ...node, anyOf: undefined, oneOf: undefined, variants: (node.anyOf ?? node.oneOf).map(v => shapeOf(v, root)), note: `Summarized; drill with schema ${name}${nodePath ? ` ${nodePath}.0` : ''} for one variant.` }; + } + if (node.type === 'array') { + const items = node.items ? shapeOf(node.items, root) : { type: 'any' }; + return { type: 'array', items, ...(node.minItems !== undefined || node.maxItems !== undefined ? { length: [node.minItems, node.maxItems] } : {}), ...(isComplex(node.items) ? { hint: `schema ${name}${nodePath ? ` ${nodePath}.0` : ''}` } : {}), note: 'Summarized array.' }; + } + if (node.type === 'object' || node.properties) { + const properties = {}; + for (const [key, child] of Object.entries(node.properties ?? {})) { + const drill = `schema ${name}${nodePath ? ` ${nodePath}.${key}` : ` ${key}`}`; + const summarized = shapeOf(child, root); + if (isComplex(child)) properties[key] = { ...summarized, hint: drill }; + else properties[key] = child; // small scalars travel whole + } + return { + type: 'object', + ...(Array.isArray(node.required) ? { required: node.required } : {}), + properties, + ...(node.additionalProperties !== undefined ? { additionalProperties: node.additionalProperties } : {}), + note: `Summarized to fit the context budget; expand any field carrying a hint with schema ${name} .`, + }; + } + return shapeOf(node, root); + }; + return summarizeNode(deref(schema, root) ?? schema, path); +} + +/** + * Resolve a dot path against a JSON schema: object properties by name, array items by a + * numeric segment or by a property of the item type, anyOf/oneOf by variant index or by a + * property any object variant defines. Returns the subschema, or null when the path does not + * exist. + */ +export function resolveSchemaPath(schema, dotPath) { + if (!dotPath) return schema; + const root = schema; + let cursor = deref(schema, root); + for (const rawSegment of dotPath.split('.')) { + if (cursor == null || typeof cursor !== 'object') return null; + const segment = rawSegment.trim(); + if (!segment) return null; + const variants = cursor.anyOf ?? cursor.oneOf; + if (cursor.properties?.[segment] !== undefined) { cursor = deref(cursor.properties[segment], root); continue; } + if (variants) { + const index = /^\d+$/.test(segment) ? Number(segment) : NaN; + if (!Number.isNaN(index)) { cursor = deref(variants[index], root); continue; } + for (const variant of variants) { + const candidate = deref(variant, root); + if (candidate?.properties?.[segment] !== undefined) { cursor = deref(candidate.properties[segment], root); break; } + } + if (cursor == null) return null; + continue; + } + const items = deref(cursor.items, root); + if (items) { + if (/^\d+$/.test(segment)) { cursor = items; continue; } + if (items.properties?.[segment] !== undefined) { cursor = deref(items.properties[segment], root); continue; } + const itemVariants = items.anyOf ?? items.oneOf; + if (itemVariants) { + const index = /^\d+$/.test(segment) ? Number(segment) : NaN; + if (!Number.isNaN(index)) { cursor = deref(itemVariants[index], root); continue; } + for (const variant of itemVariants) { + const candidate = deref(variant, root); + if (candidate?.properties?.[segment] !== undefined) { cursor = deref(candidate.properties[segment], root); break; } + } + if (cursor == null) return null; + continue; + } + } + return null; + } + return cursor ?? null; +} + +/** The child paths a wrong drill attempt can name, one level down. */ +export function listAvailablePaths(schema) { + const root = schema; + const node = deref(schema, root); + if (node == null || typeof node !== 'object') return []; + if (node.properties) return Object.keys(node.properties); + const variants = node.anyOf ?? node.oneOf; + if (Array.isArray(variants)) return variants.map((_, index) => String(index)); + if (node.items) { + const items = deref(node.items, root); + if (items?.properties) return Object.keys(items.properties); + return ['items']; + } + return []; +} diff --git a/skills/agentlinkops-connect/references/agentlinkops.md b/skills/agentlinkops-connect/references/agentlinkops.md index dd3b49b..3cf826c 100644 --- a/skills/agentlinkops-connect/references/agentlinkops.md +++ b/skills/agentlinkops-connect/references/agentlinkops.md @@ -6,10 +6,10 @@ for links a person supplies. Your agent keeps campaign judgment, browsing, email records. This page is the complete agent reference: the same text prints from `agentlinkops skill`, ships inside the installed `agentlinkops-connect` skill as `references/agentlinkops.md`, and is served at `https://agentlinkops.com/SKILL.md`. Read it in -full once per session, then use `describe` for any command's exact schema. +full once per session, then use `describe` (or `exec describe` on MCP) for any command's exact schema. -Pack version 0.6.8. 125 commands in 13 toolsets. +Pack version 0.6.9. 125 commands in 13 toolsets. ## Install and connect (once per machine) @@ -40,7 +40,8 @@ environment (`AGENTLINKOPS_TOKEN` also works; the older `LINKTRAIL_*` names keep during the pilot compatibility window with one warning per process). MCP OAuth tokens are bound to `/mcp` and are not API keys. -Verify: on MCP call `get_workspace` with `{}` (a workspace name in the answer means the +Verify: on MCP call `get_workspace` with `{}` — directly, or as +`exec: {"command": "call get_workspace {}"}` (a workspace name in the answer means the connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token` lines say `ok` when the origin answers and the key is accepted). @@ -49,9 +50,12 @@ connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token` One catalog, several endpoints. Every command is callable on every view; only the listing changes. `agentlinkops agent setup` picks the right one per client. -- `/mcp` (default): `get_workspace`, `list_projects`, `list_link_watches`, `monitor_link` and - `list_events` listed directly, plus `search_tools`, `describe_tools`, `run_read_command` and - `run_write_command` for the rest; the server instructions list every command by toolset. +- `/mcp` (default): one tool, `exec`, carries the whole catalog. Pass it CLI-style command + strings — `search`, `tools`, `describe`, `schema`, `call` — and the server instructions list + every command by toolset. Every command is also callable directly on this view (a client + that already knows the name may call it), and the older discovery tools + (`search_tools`, `describe_tools`, `run_read_command`, `run_write_command`) still answer + direct calls, unlisted. - `/mcp/all`: the flat catalog, for a client with its own tool search (Claude Code, Hermes). - `/mcp/{toolset}`: one group from the table at the end. - `/mcp/code`: `search`, `describe` and `execute`; `execute` runs your JavaScript in an isolated @@ -61,18 +65,29 @@ changes. `agentlinkops agent setup` picks the right one per client. ## Find, describe, call -The same three verbs on every surface. Never invent a command name or an argument. +The same verbs on every surface. Never invent a command name or an argument. On MCP everything +goes through the one `exec` tool; its `command` string is one command per call, and several +`exec` calls can run in parallel. -| Step | MCP | CLI | HTTP | +| Step | MCP (`exec`) | CLI | HTTP | | --- | --- | --- | --- | -| index | `search_tools` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` | -| describe | `describe_tools` | `agentlinkops describe NAME` | `GET /v1/commands/{name}` | -| call | the tool, or `run_read_command` / `run_write_command` | `agentlinkops call NAME --args JSON` | `POST /v1/commands/{name}` | +| index | `exec: {"command": "tools"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` | +| find | `exec: {"command": "search destination health"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` | +| describe | `exec: {"command": "describe monitor_link"}` | `agentlinkops describe NAME` | `GET /v1/commands/{name}` | +| drill one field | `exec: {"command": "schema monitor_link targetScope"}` | `agentlinkops schema NAME [PATH]` | `GET /v1/commands/{name}` | +| call | `exec: {"command": "call monitor_link {\"projectId\":\"pr_..\",\"sourceUrl\":\"https://..\",\"targetUrl\":\"https://..\"}"}` | `agentlinkops call NAME --args JSON` | `POST /v1/commands/{name}` | + +`search` takes `--toolset` and `--limit`; `describe` takes `--output-schema`; `call` takes +`--json` for full rows instead of the concise projection. A `describe` or `schema` answer that +exceeds the context budget is summarized, and any field carrying a `hint` expands with +`schema `. Find with `search`, `describe` a name once, then `call` and +reuse the schema; an unknown name answers with the `search` that finds the current one. Read the description first; `describe` before any write. List commands answer concise rows on -MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`) -to choose and read `defaults_applied` to know which you got. A retired command name still -resolves to its canonical command for twelve months and the description says so. +MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`), +or `call --json` through `exec`, to choose, and read `defaults_applied` to know which you got. +A retired command name still resolves to its canonical command for twelve months and the +description says so. ## Start a session diff --git a/src/citations/browser/chatgpt-citations.js b/src/citations/browser/chatgpt-citations.js index 5946d3e..00409b7 100644 --- a/src/citations/browser/chatgpt-citations.js +++ b/src/citations/browser/chatgpt-citations.js @@ -38,13 +38,48 @@ export function readChatgptCitationState(input) { const anonymous = root.matches(selectors.at(-1)); let panelEligible = anonymous && groups.length > 0 && groups.length <= 50; const publisherLabels = []; + const strayKinds = {}; + // Current anonymous layout (observed 2026-09-25): inline citations and source pills are + // buttons whose sources travel as JSON in data-assistant-sources-payload, each entry + // {attribution, title, url}. A payload that does not parse to valid URLs is unresolved. + const payloadButtons = new Set(); + const payloadSources = []; + if (anonymous) { + for (const element of Array.from(root.querySelectorAll('[data-assistant-sources-payload]'))) { + let entries = null; + try { entries = JSON.parse(element.getAttribute('data-assistant-sources-payload')); } catch { entries = null; } + const parsed = (Array.isArray(entries) ? entries : []).map(entry => { + try { + const url = new URL(entry?.url); + return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? { url: url.href, title: String(entry.title || entry.attribution || '').trim() } : null; + } catch { return null; } + }); + if (!parsed.length || parsed.some(item => !item) || concealed(element)) { unresolved = true; panelEligible = false; continue; } + payloadButtons.add(element); + payloadSources.push(...parsed); + } + } if (anonymous) { // A new anonymous layout with unrecognized citation controls is not proof - // of absence. Never use unrelated page-wide links to fill this gap. + // of absence. Never use unrelated page-wide links to fill this gap. Three control kinds + // observed 2026-09-25 never carry sources and are recognized explicitly: code-block + // Copy code buttons inside
, table Copy table buttons (data-table-copy-state), and
+    // entity chips (data-content-reference-type="entity"), which open an entity panel. A brand
+    // named in an entity chip is still counted as a mention from the answer text.
     const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
     const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
-    if (unsupported.some(element => !actions?.contains(element) && !groups.some(group => group.contains(element)))) {
-      unresolved = true; panelEligible = false;
+    const recognizedControl = element => element.tagName === 'BUTTON' && (Boolean(element.closest?.('pre')) ||
+      element.getAttribute('data-table-copy-state') !== null ||
+      (element.getAttribute('data-content-reference-type') === 'entity' && element.getAttribute('data-assistant-entity-reference') !== null));
+    const stray = unsupported.filter(element => !actions?.contains(element) && !groups.some(group => group.contains(element)) &&
+      !payloadButtons.has(element) && !recognizedControl(element));
+    if (stray.length) { unresolved = true; panelEligible = false; }
+    // Diagnostics only: which kinds of element blocked the read, by tag and a sanitized
+    // role. Never URLs, text or attributes that could carry page content.
+    for (const element of stray) {
+      const role = (element.getAttribute('role') || '').toLowerCase();
+      const kind = String(element.tagName || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 20) + (role ? `[${/^[a-z-]{1,20}$/.test(role) ? role : 'other'}]` : '');
+      if (kind in strayKinds || Object.keys(strayKinds).length < 10) strayKinds[kind] = (strayKinds[kind] || 0) + 1;
     }
   }
   for (const group of groups) {
@@ -70,11 +105,14 @@ export function readChatgptCitationState(input) {
     }
     // Observed anonymous pills are buttons without URLs. Their labels are
     // not URL evidence; a partial anchor list cannot resolve those controls.
-    if (!resolved || group.querySelector('button, [role="button"]')) unresolved = true;
+    // A group whose every control carries a parsed sources payload is resolved by it.
+    const payloadResolved = payloadButtons.size > 0 && (() => { const controls = Array.from(group.querySelectorAll('button, [role="button"]')); return controls.length > 0 && controls.every(control => payloadButtons.has(control)); })();
+    if (!payloadResolved && (!resolved || group.querySelector('button, [role="button"]'))) unresolved = true;
   }
+  sources.push(...payloadSources);
   const labels = [...new Set(publisherLabels)];
   panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
-  const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible };
+  const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible, strayKinds };
   if (!input.prepare) return state;
   if (!panelEligible || !unresolved) return {state};
   // Keep the exact assistant and control references in-page. No DOM identities,
@@ -145,6 +183,22 @@ export function readChatgptCitationState(input) {
 const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
 const responseBindings = new WeakMap();
 const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent());
+// ChatGPT renders some failures as the assistant turn itself ("Something went wrong...").
+// Such a turn is not an answer: read as one, it would record a false not_cited. Only a short
+// turn made of a known failure message counts, so a real answer that quotes one is kept.
+const CHATGPT_FAILURE_MESSAGES = [
+  /^something went wrong\b/i,
+  /^there was an error generating a response\b/i,
+  /^hmm\.{0,3}\s*something seems to have gone wrong\b/i,
+  /^network error\b/i,
+  /^too many requests\b/i,
+  /^you('|’)ve reached our limit of messages\b/i,
+];
+export function isChatgptFailureMessage(text) {
+  const body = String(text ?? '').replace(/^\s*#{1,6}\s*ChatGPT said:\s*/i, '').trim();
+  return body.length > 0 && body.length < 400 && CHATGPT_FAILURE_MESSAGES.some(pattern => pattern.test(body));
+}
+
 export async function releaseChatgptResponseBinding(page) {
   const binding = responseBindings.get(page);
   responseBindings.delete(page);
@@ -202,7 +256,7 @@ export async function extractChatgptSources(page, legacyExtract) {
     const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
     let sources;
     if (!state.found || state.unresolved) {
-      if (!state.found || !state.panelEligible) throw unavailable();
+      if (!state.found || !state.panelEligible) throw Object.assign(unavailable(), { strayKinds: state.strayKinds ?? {} });
       sources = await extractControlledPanel(page,binding);
     } else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
     else sources = await legacyExtract(page);
diff --git a/src/citations/browser/engine.js b/src/citations/browser/engine.js
index 9087e42..23c8000 100644
--- a/src/citations/browser/engine.js
+++ b/src/citations/browser/engine.js
@@ -11,11 +11,13 @@
 // browser tooling still runs every non-browser engine, and `citation doctor`
 // reports the gap instead of the whole CLI failing to load.
 import { homedir } from 'node:os';
+import { spawn } from 'node:child_process';
+import { readFileSync } from 'node:fs';
 import { join } from 'node:path';
 
 import { EngineError } from '../adapter.js';
 import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
-import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js';
+import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding, isChatgptFailureMessage } from './chatgpt-citations.js';
 
 const MODULE_LOADERS = {
   'playwright-core': () => import('playwright-core'),
@@ -58,6 +60,46 @@ const ENGINE_NOMINAL_COST = Object.freeze({
 });
 const DEFAULT_NOMINAL_COST = 0.03;
 
+// The vendored display bootstrap stops Xvfb only through cleanup(). A Node process that is
+// killed or crashes leaves Xvfb running under init (the measurement VPS held 32 of them on
+// 2026-09-24), so a detached watchdog ends it within five seconds of this process
+// disappearing. The pid comes from Xvfb's own lock file and is killed only while it is still
+// Xvfb, so a recycled pid is never signalled. Returns the function that retires the watchdog.
+export function guardDisplay(display, { spawnProcess = spawn, readLock = readFileSync, parentPid = process.pid } = {}) {
+  const number = Number(String(display).replace(/^:/, ''));
+  let xvfbPid;
+  try { xvfbPid = Number(String(readLock(`/tmp/.X${number}-lock`, 'utf8')).trim()); } catch { return () => {}; }
+  if (![number, xvfbPid, parentPid].every(Number.isInteger) || xvfbPid <= 1) return () => {};
+  const script = `while kill -0 ${parentPid} 2>/dev/null; do sleep 5; done; [ "$(cat /proc/${xvfbPid}/comm 2>/dev/null)" = Xvfb ] && kill ${xvfbPid}`;
+  let watchdog;
+  try { watchdog = spawnProcess('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }); watchdog.unref?.(); }
+  catch { return () => {}; }
+  return () => { try { watchdog.kill('SIGKILL'); } catch {} };
+}
+
+// A browser composer can lose keystrokes (a login modal taking focus mid-typing is the
+// observed case: on 2026-09-25, 4 of 30 ChatGPT slots submitted a truncated prompt and were
+// answered as a different question). The prompt must be exactly what the panel says, both
+// in the composer before submit and in the submitted turn afterwards.
+export const normalizePrompt = text => String(text ?? '').replace(/^\s*you said:\s*/i, '').replace(/\s+/g, ' ').trim();
+export async function ensurePromptEntered(prompt, { readComposer, fill, settle = async () => {} }) {
+  const expected = normalizePrompt(prompt);
+  if (normalizePrompt(await readComposer()) === expected) return 'typed';
+  await fill(prompt); await settle();
+  if (normalizePrompt(await readComposer()) === expected) return 'filled';
+  throw new EngineError('the prompt could not be entered completely', { retriable: true });
+}
+// Runs in the page: the text of the latest user turn, or null when none is found.
+// Observed 2026-09-25: li[data-message-role="user"] > ... > p[data-user-message-copy].
+// Older layouts used data-message-author-role / data-turn.
+export function readChatgptSubmittedPrompt() {
+  for (const selector of ['[data-message-role="user"] [data-user-message-copy]', '[data-message-role="user"]', '[data-message-author-role="user"]', '[data-turn="user"]']) {
+    const node = Array.from(document.querySelectorAll(selector)).at(-1);
+    if (node) return node.innerText;
+  }
+  return null;
+}
+
 export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) {
   if (!BROWSER_PROVIDERS.includes(engineName)) {
     throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
@@ -89,6 +131,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
   let running=false,contextCleanupFailed=false;
   let browserHandle = null;   // { browser, cleanup } for the whole epoch
   let displayHandle = null;
+  let releaseDisplayGuard = null;
   let providerConfig = null;
   async function closeQuietly(action) {
     let timer;
@@ -96,6 +139,17 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
     finally { clearTimeout(timer); }
   }
 
+  // The watchdog is retired only after a confirmed close; otherwise it stays armed and
+  // ends Xvfb once this process exits.
+  async function closeDisplay() {
+    // A second call after a failed close finds no handle; that is not a confirmed close.
+    if (!displayHandle) return true;
+    const closed = await closeQuietly(() => displayHandle.cleanup?.());
+    if (closed) { releaseDisplayGuard?.(); releaseDisplayGuard = null; }
+    displayHandle = null;
+    return closed;
+  }
+
   async function ensureProviderConfig() {
     if (providerConfig) return providerConfig;
     const { PROVIDER_CONFIGS } = await load('./gen/core/providers/index.js');
@@ -124,6 +178,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
     // engines that block headless render headfully inside a virtual display.
     if (platform === 'linux' && !display.detectDisplay()) {
       displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
+      releaseDisplayGuard = (runtime.guardDisplay ?? guardDisplay)(displayHandle.display);
       await event(`display: ${displayHandle.display}`);
     }
 
@@ -151,8 +206,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
       browser,
       cleanup: async () => {
         const browserClosed=await closeQuietly(() => browser.close());
-        const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
-        displayHandle = null;
+        const displayClosed=await closeDisplay();
         if(browserClosed)contextCleanupFailed=false;
         return browserClosed&&displayClosed;
       },
@@ -318,12 +372,16 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
             await editor.locator.fill(prompt);
           });
           await page.waitForTimeout(600);
-          const typedCheck = await editor.locator.inputValue().catch(() => '');
-          if (typedCheck.trim().length === 0) {
-            await editor.locator.fill(prompt).catch(() => {});
-          }
           await config.afterTypingHook?.(page);
           await config.beforeSubmitHook?.(page);
+          // Checked after the modal hooks, immediately before submit: the composer must hold
+          // the whole prompt. One fill is the recovery; otherwise the sample fails closed.
+          const entered = await ensurePromptEntered(prompt, {
+            readComposer: () => editor.locator.evaluate(node => (typeof node.value === 'string' ? node.value : node.innerText) || '').catch(() => ''),
+            fill: text => editor.locator.fill(text).catch(() => {}),
+            settle: () => page.waitForTimeout(400),
+          });
+          if (entered === 'filled') await event('prompt refilled after incomplete typing');
 
           // Submit: the surface's own send button when it exposes one; Enter fallback.
           // Allow the surface to settle; response extraction remains fail-closed.
@@ -336,15 +394,29 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
 
         await config.waitForResponse(page);
         if (!answer) answer = await config.extractResponse(page);
+        if (engineName === 'chatgpt') {
+          // The answer belongs to the prompt only if the submitted turn is the panel prompt.
+          const submitted = await page.evaluate(readChatgptSubmittedPrompt).catch(() => null);
+          if (typeof submitted !== 'string') await event('submitted prompt could not be read; not verified');
+          else if (normalizePrompt(submitted) !== normalizePrompt(prompt)) {
+            throw new EngineError('chatgpt: the submitted prompt differs from the panel prompt', { retriable: true });
+          }
+        }
+        if (engineName === 'chatgpt' && isChatgptFailureMessage(answer)) {
+          throw new EngineError('chatgpt: the surface returned an error message instead of an answer', { retriable: true });
+        }
         if (!answer || answer.trim().length < 40) {
           const reason = engineName === 'bing'
             ? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
             : `${engineName}: answer extracted empty`;
           throw new EngineError(reason, { retriable: true });        }
         let extractionFailed = config.citationExtraction === 'unsupported';
-        const sources = await config.extractSources(page).catch(async () => {
+        const sources = await config.extractSources(page).catch(async cause => {
           extractionFailed = true;
-          await event('sources extraction failed; observation retained as unknown');
+          // Tag and role counts only (see readChatgptCitationState); they say which layout
+          // change blocked the read without carrying page content.
+          const kinds = Object.entries(cause?.strayKinds ?? {}).map(([kind, count]) => `${kind}x${count}`).join(', ');
+          await event(`sources extraction failed; observation retained as unknown${kinds ? ` (unrecognized: ${kinds})` : ''}`);
           return [];
         });
         const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
@@ -384,8 +456,8 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
       let confirmed=true;
       try{if(browserHandle)confirmed=await browserHandle.cleanup();}
       finally{
-        const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
-        confirmed=confirmed&&displayClosed;displayHandle=null;browserHandle=null;
+        const displayClosed=await closeDisplay();
+        confirmed=confirmed&&displayClosed;browserHandle=null;
       }
       return confirmed;
     },
diff --git a/src/citations/browser/shims/utils.js b/src/citations/browser/shims/utils.js
index 4f7c0b2..1dc85cf 100644
--- a/src/citations/browser/shims/utils.js
+++ b/src/citations/browser/shims/utils.js
@@ -4,12 +4,19 @@
 export {
   PROVIDER_NO_OUTPUT_TIMEOUT_MS,
   PROVIDER_FORCE_EXIT_STABLE_MS,
-  PROVIDER_EDITOR_SELECTORS,
   PROVIDER_SUBMIT_BTN_SELECTORS,
   PROVIDER_RESPONSE_GENERATION_SELECTORS,
   RETRYABLE_ERRORS,
 } from '../gen/utils/agent-constants.js';
-import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } from '../gen/utils/agent-constants.js';
+import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors, PROVIDER_EDITOR_SELECTORS as vendoredEditorSelectors } from '../gen/utils/agent-constants.js';
+
+// Anonymous composer observed 2026-09-25: a plain textarea (#mobile-composer-prompt,
+// name="prompt"). None of the vendored ChatGPT selectors match it; without these the engine
+// reached it only through the broad shadow-DOM fallback.
+export const PROVIDER_EDITOR_SELECTORS = Object.freeze({
+  ...vendoredEditorSelectors,
+  chatgpt: Object.freeze(['textarea#mobile-composer-prompt', 'textarea[name="prompt"]', ...vendoredEditorSelectors.chatgpt]),
+});
 
 // Anonymous surface observed 2026-09-20: conversation LI, distinct from its
 // user sibling, with response actions. Never select the conversation itself.
diff --git a/src/tool-views.js b/src/tool-views.js
index 0545242..7543695 100644
--- a/src/tool-views.js
+++ b/src/tool-views.js
@@ -58,7 +58,7 @@ export function oneLiner(description, max = 60) {
 export function manifestText(entries, { max = 36 } = {}) {
   const groups = new Map(TOOLSETS.map(t => [t, []]));
   for (const entry of entries) (groups.get(entry.toolset) ?? groups.set(entry.toolset, []).get(entry.toolset)).push(entry);
-  const lines = ['Commands by toolset (name: what it does). Use search_tools or describe_tools for the full schema before calling.'];
+  const lines = ['Commands by toolset (name: what it does). On /mcp use exec: `exec search `, `exec describe `, `exec call  `; the CLI mirrors the verbs as `agentlinkops tools | describe | call`.'];
   for (const [toolset, members] of groups) {
     if (!members.length) continue;
     lines.push(`\n${toolset}:`);
@@ -94,11 +94,34 @@ export const CODE_TOOLS = [
 ];
 export const CODE_LIMITS = { outputChars:24000, timeoutMs:30000, maxCalls:200 };
 
+// DP-0059: the default view's single exec tool. One definition carries the whole registry: the
+// agent searches, inspects and calls commands through CLI-style strings instead of loading
+// schemas upfront (the pattern PostHog's MCP shipped and we adopted). The definition lives here
+// beside META_TOOLS and CODE_TOOLS so the measurement script counts the real shape the router
+// registers; the four meta tools remain registered on /mcp for direct calls by clients that
+// learned them, unlisted.
+export const EXEC_TOOL = {
+  name: 'exec',
+  description: 'Run any AgentLinkOps command through one CLI-style command string: search finds commands by task, describe shows an exact schema, call runs it (reads and writes, under this connection\'s scopes). Find with search, describe once, then call and reuse the schema; never guess a schema. One command per call; several exec calls can run in parallel.',
+  readOnly: false,
+  inputSchema: {
+    type: 'object',
+    properties: {
+      command: {
+        type: 'string', minLength: 1, maxLength: 8000,
+        description: 'CLI-style command. Verbs: search [--toolset T] [--limit N] ; tools [toolset]; describe [--output-schema] [--examples] ...; schema  []; call [--json]  . --json returns full rows instead of the concise projection. A schema larger than the context budget is summarized; drill any field carrying a hint with schema  . Unknown name: run search .',
+      },
+    },
+    required: ['command'],
+    additionalProperties: false,
+  },
+};
+
 // Views. `entries` carry name, toolset, tier, readOnly and (from tool-exposure.js) unavailable.
 // An unavailable command stays a member of its views: it is listed with its marker, never
 // hidden, so a client that calls it gets the stable configuration error rather than not-found.
 export const VIEW_BUDGETS = {
-  default: { definitions:12, tokens:6000 },
+  default: { definitions:1, tokens:3500 },
   toolset: { definitions:22, tokens:8000 },
   code: { definitions:3, tokens:2000 },
   manifest: { tokens:2500 },
@@ -108,7 +131,9 @@ export function viewMembers(entries, view) {
   const core = visible.filter(e => e.tier === 'core');
   if (view === 'all') return visible;
   if (view === 'readonly') return visible.filter(e => e.readOnly);
-  if (view === 'default') return core;
+  // DP-0059: the default view lists exactly one tool, exec. Every command (core tier included)
+  // stays registered and callable there through exec call and by a direct tools/call.
+  if (view === 'default') return [];
   if (view === 'code') return [];
   const m = /^toolset:([a-z]+)(:readonly)?$/.exec(view);
   if (!m) throw new Error(`Unknown view ${view}`);
diff --git a/test/citation-browser-provider.test.js b/test/citation-browser-provider.test.js
index f0cd6b4..2787baa 100644
--- a/test/citation-browser-provider.test.js
+++ b/test/citation-browser-provider.test.js
@@ -1,28 +1,32 @@
 import test from 'node:test';
 import assert from 'node:assert/strict';
-import { createBrowserEngine, BROWSER_PROVIDERS } from '../src/citations/browser/engine.js';
+import { spawn } from 'node:child_process';
+import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createBrowserEngine, BROWSER_PROVIDERS, guardDisplay, ensurePromptEntered, normalizePrompt, readChatgptSubmittedPrompt } from '../src/citations/browser/engine.js';
 import { OWN_PROVIDER_CONFIGS } from '../src/citations/browser/providers.js';
 
 let fixtureSequence=0;
-function harness({ authPolicy, bindingValid = () => true, citationState = {found:true,groups:0,unresolved:false,sources:[]}, stalledCleanup = false, sourcesFail = false, unavailable = false, answer = 'A retained fixture answer long enough to be interpreted independently.' } = {}) {
+function harness({ authPolicy, guard, displayCleanupFails = false, strayKinds, submittedPrompt, bindingValid = () => true, citationState = {found:true,groups:0,unresolved:false,sources:[]}, stalledCleanup = false, sourcesFail = false, unavailable = false, answer = 'A retained fixture answer long enough to be interpreted independently.' } = {}) {
   const contexts = []; const launches = []; const resolves = []; const events = []; let closed = 0; let displayClosed = 0; let stateReads = 0; let bindingsDisposed = 0;
   const config = { navigateToPrompt: async () => {}, waitForResponse: async () => {}, extractResponse: async page => page.runDomOp('response-text', {}),
     ...(unavailable ? { citationExtraction: 'unsupported' } : {}),
-    extractSources: async () => { if (sourcesFail) throw Error('secret-proxy-password'); return [{ url: 'https://example.com' }]; } };
+    extractSources: async () => { if (strayKinds) throw Object.assign(Error('secret-proxy-password'), { strayKinds }); if (sourcesFail) throw Error('secret-proxy-password'); return [{ url: 'https://example.com' }]; } };
   const browser = { async newContext(options) {
     const context = { options, closed: false, async route() {}, async close() { this.closed = true; if (stalledCleanup) return new Promise(() => {}); },
-      async newPage() { return { setDefaultTimeout() {}, evaluate: async () => citationState,
+      async newPage() { return { setDefaultTimeout() {}, evaluate: async fn => (fn === readChatgptSubmittedPrompt && submittedPrompt !== undefined ? submittedPrompt : citationState),
         evaluateHandle: async()=>({evaluate:async fn=>fn({isCurrent:bindingValid}),dispose:async()=>{bindingsDisposed++;}}),
         screenshot: async () => Buffer.from('fixture-pixels') }; } };
     contexts.push(context); return context;
   }, async close() { closed++; if (stalledCleanup) return new Promise(() => {}); } };
-  const runtime = { cleanupTimeoutMs: 5, platform: 'linux', sessionDir: '/fixture/sessions', firefox: { async launch(options) { launches.push(options); return browser; } },
+  const runtime = { cleanupTimeoutMs: 5, platform: 'linux', sessionDir: '/fixture/sessions', guardDisplay: guard ?? (() => () => {}), firefox: { async launch(options) { launches.push(options); return browser; } },
     async loadModule(path) {
       if (path.endsWith('/providers/index.js')) return { PROVIDER_CONFIGS: { chatgpt: config } };
       if (path === './providers.js') return { OWN_PROVIDER_CONFIGS: {} };
       if (path.endsWith('/domOps.js')) return { runPageDomOp: async () => answer };
       if (path.endsWith('/camoufox.js')) return { resolveCamoufoxLaunchOptions: async options => { resolves.push(options); return { executablePath: '/fixture/firefox' }; } };
-      if (path.endsWith('/display.js')) return { detectDisplay: () => false, ensureDisplay: async () => ({ display: ':991', cleanup: async () => { displayClosed++; } }) };
+      if (path.endsWith('/display.js')) return { detectDisplay: () => false, ensureDisplay: async () => ({ display: ':991', cleanup: async () => { if (displayCleanupFails) throw Error('xvfb stuck'); displayClosed++; } }) };
       if (path === 'node:fs/promises') return { readFile: async () => {stateReads++; return JSON.stringify({ cookies: [{ name: 'account-cookie', value: 'saved-state-secret', domain: '.chatgpt.com', path: '/' }], origins: [] });} };
       throw Error('Unexpected import ' + path);
     } };
@@ -111,3 +115,72 @@ test('late ambient profile override is refused before launch or session access',
   try{await assert.rejects(h.engine.run({prompt:'fixture'}),e=>e.code==='BROWSER_AUTH_POLICY_INVALID'&&!String(e).includes('secret-profile'));assert.equal(h.launches.length,0);assert.equal(h.stateReads(),0);assert.equal(h.contexts.length,0);}
   finally{if(previous===undefined)delete process.env[key];else process.env[key]=previous;await h.engine.close();}
 });
+
+test('display watchdog arms on the Xvfb lock pid and is retired only after a confirmed close', async () => {
+  const spawned = [];
+  const release = guardDisplay(':604', { parentPid: 4242, readLock: path => { assert.equal(path, '/tmp/.X604-lock'); return '      9876\n'; },
+    spawnProcess: (cmd, args, options) => { const child = { killed: 0, unref() { this.unrefd = true; }, kill() { this.killed++; } }; spawned.push({ cmd, args, options, child }); return child; } });
+  assert.equal(spawned.length, 1);
+  const [{ cmd, args, options, child }] = spawned;
+  assert.equal(cmd, '/bin/sh'); assert.equal(options.detached, true); assert.equal(options.stdio, 'ignore'); assert.equal(child.unrefd, true);
+  assert.match(args[1], /kill -0 4242/); assert.match(args[1], /\/proc\/9876\/comm/); assert.match(args[1], /= Xvfb \] && kill 9876$/);
+  release(); assert.equal(child.killed, 1);
+  const none = []; const spawnProcess = () => { none.push(1); return {}; };
+  guardDisplay(':604', { spawnProcess, readLock: () => { throw Error('ENOENT'); } });
+  guardDisplay(':604', { spawnProcess, readLock: () => '1' });
+  guardDisplay(':x', { spawnProcess, readLock: () => '9876' });
+  assert.equal(none.length, 0, 'no lock, pid 1 or a malformed display arms nothing');
+
+  const calls = []; const guard = display => { calls.push(display); return () => calls.push('released'); };
+  const h = harness({ guard }); await h.engine.run({ prompt: 'fixture' });
+  assert.deepEqual(calls, [':991']); await h.engine.close(); assert.deepEqual(calls, [':991', 'released']);
+  const armed = []; const stuck = harness({ displayCleanupFails: true, guard: display => { armed.push(display); return () => armed.push('released'); } });
+  await stuck.engine.run({ prompt: 'fixture' });
+  await assert.rejects(stuck.engine.close(), error => error.code === 'BROWSER_CLEANUP_UNCONFIRMED');
+  assert.deepEqual(armed, [':991'], 'an unconfirmed display close keeps the watchdog armed');
+});
+test('display watchdog ends an orphaned Xvfb after its parent dies (Linux)', { skip: !existsSync('/proc/self/comm') }, async () => {
+  const dir = mkdtempSync(join(tmpdir(), 'xvfb-guard-'));
+  const fake = join(dir, 'Xvfb'); copyFileSync('/bin/sleep', fake);
+  const xvfb = spawn(fake, ['60'], { stdio: 'ignore' }); const parent = spawn('/bin/sleep', ['1'], { stdio: 'ignore' });
+  const exited = new Promise(resolve => xvfb.once('exit', (code, signal) => resolve(signal)));
+  try {
+    guardDisplay(':1', { parentPid: parent.pid, readLock: () => String(xvfb.pid) });
+    const signal = await Promise.race([exited, new Promise(resolve => setTimeout(() => resolve('still-running'), 9000))]);
+    assert.equal(signal, 'SIGTERM');
+  } finally { xvfb.kill('SIGKILL'); parent.kill('SIGKILL'); rmSync(dir, { recursive: true, force: true }); }
+});
+
+test('an unknown extraction names the unrecognized element kinds in its event, never the error text', async () => {
+  const h = harness({ strayKinds: { a: 2, sup: 1 } }); const run = await h.engine.run({ prompt: 'fixture' });
+  assert.equal(run.unknown, true); assert.equal(run.failure.code, 'CITATION_EXTRACTION_UNAVAILABLE');
+  assert.ok(h.events.includes('sources extraction failed; observation retained as unknown (unrecognized: ax2, supx1)'));
+  assert.ok(!h.events.join('').includes('secret-proxy-password')); await h.engine.close();
+  const plain = harness({ sourcesFail: true }); await plain.engine.run({ prompt: 'fixture' });
+  assert.ok(plain.events.includes('sources extraction failed; observation retained as unknown')); await plain.engine.close();
+});
+
+test('a ChatGPT failure message becomes a retriable unknown, never a not-cited observation', async () => {
+  const h = harness({ answer: '#### ChatGPT said:\n\nSomething went wrong. If this issue persists please contact us through our help center at help.openai.com.' });
+  await assert.rejects(h.engine.run({ prompt: 'fixture' }), error => error.code === 'BROWSER_RUN_FAILED' && error.unknown === true && error.retriable === true);
+  assert.equal(h.contexts[0].closed, true); await h.engine.close();
+});
+
+test('a prompt must be entered whole before submit: one fill recovers, otherwise the sample fails', async () => {
+  const run = async (reads, fillWorks = true) => { const seen = [...reads]; let filled = 0;
+    const result = await ensurePromptEntered('tools for monitoring lost backlinks', { readComposer: async () => seen.shift(), fill: async () => { filled++; if (!fillWorks) return; } });
+    return { result, filled }; };
+  assert.deepEqual(await run(['tools for monitoring lost backlinks']), { result: 'typed', filled: 0 });
+  assert.deepEqual(await run(['tools for monitoring lost b', ' tools  for monitoring lost backlinks ']), { result: 'filled', filled: 1 });
+  await assert.rejects(run(['tools for monitoring lost b', 'tools for monitoring lost b'], false), error => error.retriable === true && /entered completely/.test(error.message));
+  assert.equal(normalizePrompt('You said:\n\nwhat is  backlink monitoring'), 'what is backlink monitoring');
+});
+test('an answer to a different submitted prompt is never an observation', async () => {
+  const ok = harness({ submittedPrompt: 'You said:\nfixture' }); const run = await ok.engine.run({ prompt: 'fixture' });
+  assert.equal(run.answer.length > 0, true); await ok.engine.close();
+  const truncated = harness({ submittedPrompt: 'fixt' });
+  await assert.rejects(truncated.engine.run({ prompt: 'fixture' }), error => error.code === 'BROWSER_RUN_FAILED' && error.unknown === true && error.retriable === true);
+  assert.equal(truncated.contexts[0].closed, true); await truncated.engine.close();
+  const unread = harness(); await unread.engine.run({ prompt: 'fixture' });
+  assert.ok(unread.events.includes('submitted prompt could not be read; not verified')); await unread.engine.close();
+});
diff --git a/test/citation-chatgpt-surface.test.js b/test/citation-chatgpt-surface.test.js
index fcdd870..f074c58 100644
--- a/test/citation-chatgpt-surface.test.js
+++ b/test/citation-chatgpt-surface.test.js
@@ -100,3 +100,82 @@ test('missing verified footer handle refuses interaction and still disposes hand
   await assert.rejects(extractChatgptSources(h.page,async()=>[]),{code:'CITATION_EXTRACTION_UNAVAILABLE'});
   assert.deepEqual(h.calls,{clicks:0,polls:0,waits:0,disposed:2});
 });
+
+test('unrecognized anonymous controls are counted by tag and sanitized role, never content', async () => {
+  const el = (tagName, role = null) => ({ tagName, getAttribute: key => key === 'role' ? role : null, href: 'https://secret.example/token', textContent: 'page text' });
+  const kept = el('BUTTON'); const strays = [el('A'), el('A'), el('SUP'), el('DIV', 'button'), el('SPAN', 'Evil Role