agentlinkops 0.6.7 → 0.6.9

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.9",
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.9",
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,21 @@
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.9 (2026-09-25)
7
+
8
+ - 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.
9
+ - 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.
10
+ - Find the current anonymous ChatGPT composer (`textarea#mobile-composer-prompt`) directly instead of through the broad fallback.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+
15
+ ## 0.6.8 (2026-09-20)
16
+
17
+ - 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.
18
+ - 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.
19
+ - Correct the bundled agent reference version. Package, web and documentation preflight checks now reject stale reference versions or mismatched generated copies.
20
+
6
21
  ## 0.6.7 (2026-09-20)
7
22
 
8
23
  - 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.9';
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/cli/commands.js CHANGED
@@ -7,17 +7,19 @@ import { parseArgs } from './args.js';
7
7
  import { NUDGE } from './skill.js';
8
8
  import { CATALOG_COMMANDS, CATALOG_TOOLSETS } from './catalog.js';
9
9
  import { oneLiner } from '../src/tool-views.js';
10
+ import { resolveSchemaPath, listAvailablePaths } from '../shared/exec-grammar.js';
10
11
 
11
12
  // DP-0036-T05: the CLI's three-level discovery over the cloud catalog — index (`tools`),
12
13
  // describe (`describe NAME`) and call (`call NAME`). `tools` and `describe` answer from the
13
14
  // bundled snapshot in cli/catalog.js with no account and no network; `--refresh` reads the
14
15
  // live catalog instead. Output is JSON whenever stdout is not a terminal or --json is given,
15
16
  // and one error shape ({ error: { code, message, next } }) on every failure.
16
- 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]';
17
+ 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]';
17
18
  const NAME = /^[a-z][a-z0-9_]*$/;
18
19
  const NEXT = {
19
20
  TOOL_NOT_FOUND: 'Run `agentlinkops tools` to list commands, or `agentlinkops tools TOOLSET` for one group.',
20
21
  INVALID_INPUT: 'Run `agentlinkops describe NAME` for the exact input schema.',
22
+ SCHEMA_PATH_NOT_FOUND: 'Run `agentlinkops schema NAME` for the top level, or drill a listed child path.',
21
23
  INSUFFICIENT_SCOPE: 'This credential lacks a scope the command needs; request it through your connection or a new project-scoped key.',
22
24
  NO_CLOUD_ORIGIN: 'Run `agentlinkops connect` or set AGENTLINKOPS_API_URL.',
23
25
  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
102
104
  const [command, name] = args._;
103
105
  const json = args.json === true || !isTTY;
104
106
  const fail = (code, message) => { if (json) err(errorJson(code, message, explain(code))); else err(`${message}\n${explain(code)}`); return 2; };
105
- const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] };
106
- 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);
107
+ const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], schema: ['_', 'json'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] };
108
+ const positionalCount = command === 'tools' ? args._.length > 2 : command === 'schema' ? args._.length !== 2 && args._.length !== 3 : args._.length !== 2;
109
+ if (!allowed[command] || Object.keys(args).some(key => !allowed[command].includes(key)) || positionalCount || (command !== 'tools' && !NAME.test(name))) throw new ConfigError(USAGE);
107
110
  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');
108
111
  if (args.args !== undefined && args.file !== undefined) throw new ConfigError(USAGE);
109
112
 
@@ -160,6 +163,22 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
160
163
  out(renderDescription(found, name, { examples: args.examples === true, outputSchema: args['output-schema'] === true, json }));
161
164
  return 0;
162
165
  }
166
+ if (command === 'schema') {
167
+ // DP-0059: the same drill the MCP exec tool serves, over the same bundled schema, so one
168
+ // grammar holds across surfaces: `schema monitor_link scope.target` prints that field.
169
+ const found = resolveName(commands, name);
170
+ if (!found) return fail('TOOL_NOT_FOUND', `No command named ${name}.`);
171
+ const path = args._[2] ?? null;
172
+ if (!path) { out(JSON.stringify({ name: found.name, schema: found.inputSchema }, null, 2)); return 0; }
173
+ const subschema = resolveSchemaPath(found.inputSchema, path);
174
+ if (!subschema) {
175
+ const parent = resolveSchemaPath(found.inputSchema, path.split('.').slice(0, -1).join('.')) ?? found.inputSchema;
176
+ const available = listAvailablePaths(parent);
177
+ return fail('SCHEMA_PATH_NOT_FOUND', `"${path}" is not a path in ${found.name}'s schema.${available.length ? ` Available here: ${available.join(', ')}.` : ''}`);
178
+ }
179
+ out(JSON.stringify({ name: found.name, path, schema: subschema }, null, 2));
180
+ return 0;
181
+ }
163
182
 
164
183
  // call
165
184
  const found = resolveName(commands, name);
package/cli/main.js CHANGED
@@ -152,7 +152,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
152
152
  if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
153
153
  if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out});
154
154
  if (command === 'setup') return await setupPlanMain(argv, { cwd, out });
155
- if (command === 'tools' || command === 'describe' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl });
155
+ if (command === 'tools' || command === 'describe' || command === 'schema' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl });
156
156
  if (command === 'skill') return await skillMain(argv, { out });
157
157
  if (command === 'agent') return await agentMain(argv, { cwd, out, err });
158
158
  if (command === 'connect') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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",
@@ -0,0 +1,342 @@
1
+ // DP-0059: the exec grammar — one command string, five verbs, shared by the MCP exec tool,
2
+ // the CLI and the measurement scripts so one grammar serves every surface (the PostHog
3
+ // single-exec pattern: the catalog is searched and inspected on demand instead of listed).
4
+ // Pure: no imports, no environment, safe in Workerd, Node and the bundler.
5
+ //
6
+ // search [--toolset T] [--limit N] <words>
7
+ // tools [toolset]
8
+ // describe [--output-schema] [--examples] <name> [<name> ...]
9
+ // schema <name> [<field.path>]
10
+ // call [--json] <name> <json_input>
11
+ //
12
+ // Parsing is quote-aware so a JSON body or a quoted pattern survives intact, and the body of
13
+ // `call` is taken as the raw remainder after the command name (it may span lines). A request
14
+ // that stacks several commands is detected and reported, never executed partially.
15
+
16
+ export const EXEC_VERBS = ['search', 'tools', 'describe', 'schema', 'call'];
17
+ const VERB_ALIASES = { info: 'describe', run: 'call', help: 'help' };
18
+
19
+ export const EXEC_USAGE = [
20
+ 'CLI-style command string. One command per exec call; several exec calls can run in parallel.',
21
+ ' search [--toolset T] [--limit N] <words> find commands by task (name, toolset, description, parameters, keywords)',
22
+ ' tools [toolset] grouped command names',
23
+ ' describe [--output-schema] [--examples] <name> [<name> ...] exact input schema, scopes, aliases, examples',
24
+ ' schema <name> [<field.path>] drill into one field of a schema (dot paths, e.g. scope.target, members.0.role)',
25
+ ' call [--json] <name> <json_input> run the command with one JSON object; --json returns full rows instead of the concise projection',
26
+ 'Find with search, describe a name once, then call it and reuse the schema. Never guess a schema; never describe before every call.',
27
+ ].join('\n');
28
+
29
+ export const BATCH_MESSAGE = count =>
30
+ `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.`;
31
+
32
+ // A describe or schema answer larger than this is summarized, with drill-down hints on the
33
+ // fields that need them (chars, not tokens: ~5.5k tokens at the measurement ratio).
34
+ export const INFO_CHAR_LIMIT = 20000;
35
+
36
+ // --- tokenizer ---------------------------------------------------------------------------------
37
+
38
+ // Splits on whitespace outside single or double quotes; backslash escapes the quote character
39
+ // inside a quoted span. Returns tokens with their span in the raw string so `call` can slice
40
+ // its JSON body out of the original text.
41
+ function tokenize(input) {
42
+ const tokens = [];
43
+ let index = 0;
44
+ while (index < input.length) {
45
+ while (index < input.length && /\s/.test(input[index])) index++;
46
+ if (index >= input.length) break;
47
+ const start = index;
48
+ let value = '';
49
+ let quoted = false;
50
+ while (index < input.length) {
51
+ const char = input[index];
52
+ if (char === '"' || char === "'") {
53
+ if (quoted === false) { quoted = char; index++; continue; }
54
+ if (quoted === char) {
55
+ if (input[index + 1] === char) { value += char; index += 2; continue; } // doubled quote escapes itself
56
+ quoted = false; index++; continue;
57
+ }
58
+ }
59
+ if (char === '\\' && quoted && input[index + 1] === quoted) { value += quoted; index += 2; continue; }
60
+ if (!quoted && /\s/.test(char)) break;
61
+ value += char; index++;
62
+ }
63
+ if (quoted) throw new GrammarError('UNTERMINATED_QUOTE', `A ${quoted}-quoted span never closes.`, 'Quote the whole argument, or drop the quotes.');
64
+ tokens.push({ value, start, end: index });
65
+ }
66
+ return tokens;
67
+ }
68
+
69
+ class GrammarError extends Error {
70
+ constructor(code, message, next) { super(message); this.code = code; this.next = next; }
71
+ }
72
+
73
+ const usage = (message, next) => ({ ok: false, error: { code: 'EXEC_USAGE', message, next: next ?? EXEC_USAGE } });
74
+
75
+ const BOOLEAN_FLAGS = {
76
+ describe: ['--output-schema', '--examples'],
77
+ call: ['--json'],
78
+ search: [],
79
+ tools: [],
80
+ schema: [],
81
+ };
82
+ const VALUE_FLAGS = {
83
+ search: ['--toolset', '--limit'],
84
+ describe: [],
85
+ call: [],
86
+ tools: [],
87
+ schema: [],
88
+ };
89
+
90
+ /**
91
+ * Parse one exec command string.
92
+ *
93
+ * @returns {{ok:true, verb:string} & Record<string, unknown> | {ok:false, error:{code:string, message:string, next:string}}}
94
+ */
95
+ export function parseExecCommand(command) {
96
+ if (typeof command !== 'string' || !command.trim()) return usage('The command string is empty.', 'Pick a verb: ' + EXEC_VERBS.join(', ') + '.');
97
+ let tokens;
98
+ try { tokens = tokenize(command); } catch (error) {
99
+ return { ok: false, error: { code: error.code, message: error.message, next: error.next } };
100
+ }
101
+ if (!tokens.length) return usage('The command string is empty.', 'Pick a verb: ' + EXEC_VERBS.join(', ') + '.');
102
+ const requested = tokens[0].value;
103
+ const verb = VERB_ALIASES[requested] ?? requested;
104
+ if (![...EXEC_VERBS, 'help'].includes(verb)) {
105
+ return { ok: false, error: { code: 'UNKNOWN_VERB', message: `"${requested}" is not an exec verb.`, next: 'Verbs: ' + EXEC_VERBS.join(', ') + '.' } };
106
+ }
107
+ if (verb === 'help') return { ok: true, verb: 'help' };
108
+
109
+ const flags = {};
110
+ let cursor = 1;
111
+ const takeFlags = () => {
112
+ while (cursor < tokens.length && tokens[cursor].value.startsWith('--')) {
113
+ const flag = tokens[cursor].value;
114
+ const booleanOk = BOOLEAN_FLAGS[verb]?.includes(flag);
115
+ const valueOk = VALUE_FLAGS[verb]?.includes(flag);
116
+ if (!booleanOk && !valueOk) throw new GrammarError('UNKNOWN_FLAG', `--${flag.slice(2)} is not a ${verb} flag.`, EXEC_USAGE);
117
+ if (valueOk) {
118
+ const valueToken = tokens[cursor + 1];
119
+ if (!valueToken) throw new GrammarError('FLAG_NEEDS_VALUE', `${flag} needs a value.`, EXEC_USAGE);
120
+ flags[flag.slice(2)] = valueToken.value;
121
+ cursor += 2;
122
+ } else {
123
+ flags[flag.slice(2)] = true;
124
+ cursor += 1;
125
+ }
126
+ }
127
+ };
128
+ try { takeFlags(); } catch (error) { return { ok: false, error: { code: error.code, message: error.message, next: error.next } }; }
129
+ const operands = tokens.slice(cursor);
130
+
131
+ if (verb === 'search') {
132
+ const query = command.slice(tokens[cursor]?.start ?? command.length).trim();
133
+ if (!query) return usage('search needs words to find commands by.', 'Example: search destination health');
134
+ const limit = flags.limit === undefined ? undefined : Number(flags.limit);
135
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 25)) return usage(`--limit must be an integer from 1 to 25, not "${flags.limit}".`);
136
+ return { ok: true, verb, query, toolset: flags.toolset ?? null, limit };
137
+ }
138
+ if (verb === 'tools') {
139
+ if (operands.length > 1) return usage('tools takes at most one toolset name.');
140
+ return { ok: true, verb, toolset: operands[0]?.value ?? null };
141
+ }
142
+ if (verb === 'describe') {
143
+ const names = operands.map(token => token.value);
144
+ if (!names.length) return usage('describe needs at least one command name.', 'search finds the right name first.');
145
+ if (names.length > 10) return usage('describe takes at most ten names per call.');
146
+ return { ok: true, verb, names, outputSchema: flags['output-schema'] === true, examples: flags.examples === true };
147
+ }
148
+ if (verb === 'schema') {
149
+ if (!operands.length) return usage('schema needs a command name.', 'Example: schema monitor_link scope.target');
150
+ if (operands.length > 2) return usage('schema takes a command name and at most one field path.');
151
+ return { ok: true, verb, name: operands[0].value, path: operands[1]?.value ?? null };
152
+ }
153
+ // call: flags and the command name are tokens; the JSON body is the raw remainder.
154
+ if (!operands.length) return usage('call needs a command name.', 'search finds the right name first.');
155
+ const name = operands[0].value;
156
+ const jsonText = command.slice(operands[0].end).trim();
157
+ return { ok: true, verb, name, jsonText: jsonText || '{}', detailed: flags.json === true };
158
+ }
159
+
160
+ // --- batch detection ----------------------------------------------------------------------------
161
+
162
+ const startsWithVerb = line => {
163
+ const match = /^[^\S\n]*([A-Za-z_][A-Za-z0-9_-]*)/.exec(line ?? '');
164
+ if (!match) return false;
165
+ const verb = VERB_ALIASES[match[1]] ?? match[1].toLowerCase();
166
+ return [...EXEC_VERBS, 'help'].includes(verb);
167
+ };
168
+
169
+ // Only `call` can legitimately span lines (its JSON body); it is complete once that body parses.
170
+ function isComplete(command) {
171
+ const parsed = parseExecCommand(command);
172
+ if (!parsed.ok || parsed.verb !== 'call') return true;
173
+ try { JSON.parse(parsed.jsonText); return true; } catch { return false; }
174
+ }
175
+
176
+ /** Returns the stacked commands when the string holds several, else undefined. */
177
+ export function splitBatchedCommands(command) {
178
+ if (typeof command !== 'string') return undefined;
179
+ const lines = command.split('\n');
180
+ if (lines.length < 2 || !startsWithVerb(lines[0])) return undefined;
181
+ const commands = [];
182
+ let current = lines[0];
183
+ for (const line of lines.slice(1)) {
184
+ if (startsWithVerb(line) && isComplete(current)) { commands.push(current); current = line; continue; }
185
+ current += '\n' + line;
186
+ }
187
+ if (!commands.length) return undefined;
188
+ commands.push(current);
189
+ return commands.map(entry => entry.trim()).filter(Boolean);
190
+ }
191
+
192
+ // --- schema drill-down ---------------------------------------------------------------------------
193
+
194
+ // zod's toJSONSchema factors shared shapes into $defs with #/$defs/name pointers. Resolve one
195
+ // against the root, with a depth guard so a recursive schema cannot loop the walk.
196
+ function deref(schema, root, depth = 0) {
197
+ let cursor = schema;
198
+ let steps = 0;
199
+ while (cursor && typeof cursor === 'object' && typeof cursor.$ref === 'string' && steps++ < 20) {
200
+ if (!cursor.$ref.startsWith('#/')) return null;
201
+ let node = root;
202
+ for (const segment of cursor.$ref.slice(2).split('/')) {
203
+ const key = segment.replace(/~1/g, '/').replace(/~0/g, '~');
204
+ if (node == null || typeof node !== 'object' || !(key in node)) return null;
205
+ node = node[key];
206
+ }
207
+ cursor = node;
208
+ }
209
+ return depth > 20 ? null : cursor;
210
+ }
211
+
212
+ const isComplex = schema =>
213
+ schema != null && typeof schema === 'object' && (
214
+ (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length > 0) ||
215
+ schema.type === 'array' ||
216
+ Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) ||
217
+ JSON.stringify(schema).length > 800
218
+ );
219
+
220
+ const firstSentence = (text, max = 120) => {
221
+ const sentence = String(text ?? '').split(/(?<=\.)\s/)[0] ?? '';
222
+ if (sentence.length <= max) return sentence;
223
+ const cut = sentence.slice(0, max + 1);
224
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 40)).replace(/[,;:]$/, '')}…`;
225
+ };
226
+
227
+ // One line of scalar facts for a schema node: type, enum, default, bounds, description.
228
+ export function shapeOf(schema, root) {
229
+ const node = deref(schema, root) ?? schema;
230
+ if (node == null || typeof node !== 'object') return { type: 'any' };
231
+ const variants = node.anyOf ?? node.oneOf;
232
+ if (Array.isArray(variants)) return { type: 'one of', variants: variants.map(v => shapeOf(v, root).type ?? 'any'), description: firstSentence(node.description) || undefined };
233
+ const shape = {};
234
+ if (node.type) shape.type = node.type;
235
+ if (Array.isArray(node.enum)) shape.enum = node.enum.slice(0, 12);
236
+ if (node.default !== undefined) shape.default = node.default;
237
+ if (node.minimum !== undefined || node.maximum !== undefined) shape.bounds = [node.minimum, node.maximum];
238
+ if (node.minLength !== undefined || node.maxLength !== undefined) shape.length = [node.minLength, node.maxLength];
239
+ if (node.format) shape.format = node.format;
240
+ if (node.description) shape.description = firstSentence(node.description);
241
+ return shape;
242
+ }
243
+
244
+ /**
245
+ * Summarize a schema for a describe or schema answer that exceeded the character budget:
246
+ * object properties collapse to their shape, complex children carry a hint naming the
247
+ * `schema <name> <path>` call that expands them.
248
+ */
249
+ export function summarizeSchema(schema, name, path = '') {
250
+ const root = schema;
251
+ const summarizeNode = (node, nodePath) => {
252
+ if (node == null || typeof node !== 'object') return node;
253
+ if (Array.isArray(node.anyOf) || Array.isArray(node.oneOf)) {
254
+ 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.` };
255
+ }
256
+ if (node.type === 'array') {
257
+ const items = node.items ? shapeOf(node.items, root) : { type: 'any' };
258
+ 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.' };
259
+ }
260
+ if (node.type === 'object' || node.properties) {
261
+ const properties = {};
262
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
263
+ const drill = `schema ${name}${nodePath ? ` ${nodePath}.${key}` : ` ${key}`}`;
264
+ const summarized = shapeOf(child, root);
265
+ if (isComplex(child)) properties[key] = { ...summarized, hint: drill };
266
+ else properties[key] = child; // small scalars travel whole
267
+ }
268
+ return {
269
+ type: 'object',
270
+ ...(Array.isArray(node.required) ? { required: node.required } : {}),
271
+ properties,
272
+ ...(node.additionalProperties !== undefined ? { additionalProperties: node.additionalProperties } : {}),
273
+ note: `Summarized to fit the context budget; expand any field carrying a hint with schema ${name} <field.path>.`,
274
+ };
275
+ }
276
+ return shapeOf(node, root);
277
+ };
278
+ return summarizeNode(deref(schema, root) ?? schema, path);
279
+ }
280
+
281
+ /**
282
+ * Resolve a dot path against a JSON schema: object properties by name, array items by a
283
+ * numeric segment or by a property of the item type, anyOf/oneOf by variant index or by a
284
+ * property any object variant defines. Returns the subschema, or null when the path does not
285
+ * exist.
286
+ */
287
+ export function resolveSchemaPath(schema, dotPath) {
288
+ if (!dotPath) return schema;
289
+ const root = schema;
290
+ let cursor = deref(schema, root);
291
+ for (const rawSegment of dotPath.split('.')) {
292
+ if (cursor == null || typeof cursor !== 'object') return null;
293
+ const segment = rawSegment.trim();
294
+ if (!segment) return null;
295
+ const variants = cursor.anyOf ?? cursor.oneOf;
296
+ if (cursor.properties?.[segment] !== undefined) { cursor = deref(cursor.properties[segment], root); continue; }
297
+ if (variants) {
298
+ const index = /^\d+$/.test(segment) ? Number(segment) : NaN;
299
+ if (!Number.isNaN(index)) { cursor = deref(variants[index], root); continue; }
300
+ for (const variant of variants) {
301
+ const candidate = deref(variant, root);
302
+ if (candidate?.properties?.[segment] !== undefined) { cursor = deref(candidate.properties[segment], root); break; }
303
+ }
304
+ if (cursor == null) return null;
305
+ continue;
306
+ }
307
+ const items = deref(cursor.items, root);
308
+ if (items) {
309
+ if (/^\d+$/.test(segment)) { cursor = items; continue; }
310
+ if (items.properties?.[segment] !== undefined) { cursor = deref(items.properties[segment], root); continue; }
311
+ const itemVariants = items.anyOf ?? items.oneOf;
312
+ if (itemVariants) {
313
+ const index = /^\d+$/.test(segment) ? Number(segment) : NaN;
314
+ if (!Number.isNaN(index)) { cursor = deref(itemVariants[index], root); continue; }
315
+ for (const variant of itemVariants) {
316
+ const candidate = deref(variant, root);
317
+ if (candidate?.properties?.[segment] !== undefined) { cursor = deref(candidate.properties[segment], root); break; }
318
+ }
319
+ if (cursor == null) return null;
320
+ continue;
321
+ }
322
+ }
323
+ return null;
324
+ }
325
+ return cursor ?? null;
326
+ }
327
+
328
+ /** The child paths a wrong drill attempt can name, one level down. */
329
+ export function listAvailablePaths(schema) {
330
+ const root = schema;
331
+ const node = deref(schema, root);
332
+ if (node == null || typeof node !== 'object') return [];
333
+ if (node.properties) return Object.keys(node.properties);
334
+ const variants = node.anyOf ?? node.oneOf;
335
+ if (Array.isArray(variants)) return variants.map((_, index) => String(index));
336
+ if (node.items) {
337
+ const items = deref(node.items, root);
338
+ if (items?.properties) return Object.keys(items.properties);
339
+ return ['items'];
340
+ }
341
+ return [];
342
+ }
@@ -6,10 +6,10 @@ for links a person supplies. Your agent keeps campaign judgment, browsing, email
6
6
  records. This page is the complete agent reference: the same text prints from
7
7
  `agentlinkops skill`, ships inside the installed `agentlinkops-connect` skill as
8
8
  `references/agentlinkops.md`, and is served at `https://agentlinkops.com/SKILL.md`. Read it in
9
- full once per session, then use `describe` for any command's exact schema.
9
+ full once per session, then use `describe` (or `exec describe` on MCP) 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.9. 125 commands in 13 toolsets.
13
13
  <!-- generated:skill-version end -->
14
14
 
15
15
  ## Install and connect (once per machine)
@@ -40,7 +40,8 @@ environment (`AGENTLINKOPS_TOKEN` also works; the older `LINKTRAIL_*` names keep
40
40
  during the pilot compatibility window with one warning per process). MCP OAuth tokens are
41
41
  bound to `/mcp` and are not API keys.
42
42
 
43
- Verify: on MCP call `get_workspace` with `{}` (a workspace name in the answer means the
43
+ Verify: on MCP call `get_workspace` with `{}` — directly, or as
44
+ `exec: {"command": "call get_workspace {}"}` (a workspace name in the answer means the
44
45
  connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token` lines say
45
46
  `ok` when the origin answers and the key is accepted).
46
47
 
@@ -49,9 +50,12 @@ connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token`
49
50
  One catalog, several endpoints. Every command is callable on every view; only the listing
50
51
  changes. `agentlinkops agent setup` picks the right one per client.
51
52
 
52
- - `/mcp` (default): `get_workspace`, `list_projects`, `list_link_watches`, `monitor_link` and
53
- `list_events` listed directly, plus `search_tools`, `describe_tools`, `run_read_command` and
54
- `run_write_command` for the rest; the server instructions list every command by toolset.
53
+ - `/mcp` (default): one tool, `exec`, carries the whole catalog. Pass it CLI-style command
54
+ strings — `search`, `tools`, `describe`, `schema`, `call` — and the server instructions list
55
+ every command by toolset. Every command is also callable directly on this view (a client
56
+ that already knows the name may call it), and the older discovery tools
57
+ (`search_tools`, `describe_tools`, `run_read_command`, `run_write_command`) still answer
58
+ direct calls, unlisted.
55
59
  - `/mcp/all`: the flat catalog, for a client with its own tool search (Claude Code, Hermes).
56
60
  - `/mcp/{toolset}`: one group from the table at the end.
57
61
  - `/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.
61
65
 
62
66
  ## Find, describe, call
63
67
 
64
- The same three verbs on every surface. Never invent a command name or an argument.
68
+ The same verbs on every surface. Never invent a command name or an argument. On MCP everything
69
+ goes through the one `exec` tool; its `command` string is one command per call, and several
70
+ `exec` calls can run in parallel.
65
71
 
66
- | Step | MCP | CLI | HTTP |
72
+ | Step | MCP (`exec`) | CLI | HTTP |
67
73
  | --- | --- | --- | --- |
68
- | index | `search_tools` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
69
- | describe | `describe_tools` | `agentlinkops describe NAME` | `GET /v1/commands/{name}` |
70
- | call | the tool, or `run_read_command` / `run_write_command` | `agentlinkops call NAME --args JSON` | `POST /v1/commands/{name}` |
74
+ | index | `exec: {"command": "tools"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
75
+ | find | `exec: {"command": "search destination health"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
76
+ | describe | `exec: {"command": "describe monitor_link"}` | `agentlinkops describe NAME` | `GET /v1/commands/{name}` |
77
+ | drill one field | `exec: {"command": "schema monitor_link targetScope"}` | `agentlinkops schema NAME [PATH]` | `GET /v1/commands/{name}` |
78
+ | call | `exec: {"command": "call monitor_link {\"projectId\":\"pr_..\",\"sourceUrl\":\"https://..\",\"targetUrl\":\"https://..\"}"}` | `agentlinkops call NAME --args JSON` | `POST /v1/commands/{name}` |
79
+
80
+ `search` takes `--toolset` and `--limit`; `describe` takes `--output-schema`; `call` takes
81
+ `--json` for full rows instead of the concise projection. A `describe` or `schema` answer that
82
+ exceeds the context budget is summarized, and any field carrying a `hint` expands with
83
+ `schema <name> <field.path>`. Find with `search`, `describe` a name once, then `call` and
84
+ reuse the schema; an unknown name answers with the `search` that finds the current one.
71
85
 
72
86
  Read the description first; `describe` before any write. List commands answer concise rows on
73
- MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`)
74
- to choose and read `defaults_applied` to know which you got. A retired command name still
75
- resolves to its canonical command for twelve months and the description says so.
87
+ MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`),
88
+ or `call --json` through `exec`, to choose, and read `defaults_applied` to know which you got.
89
+ A retired command name still resolves to its canonical command for twelve months and the
90
+ description says so.
76
91
 
77
92
  ## Start a session
78
93
 
@@ -0,0 +1,269 @@
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
+ const strayKinds = {};
42
+ // Current anonymous layout (observed 2026-09-25): inline citations and source pills are
43
+ // buttons whose sources travel as JSON in data-assistant-sources-payload, each entry
44
+ // {attribution, title, url}. A payload that does not parse to valid URLs is unresolved.
45
+ const payloadButtons = new Set();
46
+ const payloadSources = [];
47
+ if (anonymous) {
48
+ for (const element of Array.from(root.querySelectorAll('[data-assistant-sources-payload]'))) {
49
+ let entries = null;
50
+ try { entries = JSON.parse(element.getAttribute('data-assistant-sources-payload')); } catch { entries = null; }
51
+ const parsed = (Array.isArray(entries) ? entries : []).map(entry => {
52
+ try {
53
+ const url = new URL(entry?.url);
54
+ return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? { url: url.href, title: String(entry.title || entry.attribution || '').trim() } : null;
55
+ } catch { return null; }
56
+ });
57
+ if (!parsed.length || parsed.some(item => !item) || concealed(element)) { unresolved = true; panelEligible = false; continue; }
58
+ payloadButtons.add(element);
59
+ payloadSources.push(...parsed);
60
+ }
61
+ }
62
+ if (anonymous) {
63
+ // A new anonymous layout with unrecognized citation controls is not proof
64
+ // of absence. Never use unrelated page-wide links to fill this gap. Three control kinds
65
+ // observed 2026-09-25 never carry sources and are recognized explicitly: code-block
66
+ // Copy code buttons inside <pre>, table Copy table buttons (data-table-copy-state), and
67
+ // entity chips (data-content-reference-type="entity"), which open an entity panel. A brand
68
+ // named in an entity chip is still counted as a mention from the answer text.
69
+ const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
70
+ const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
71
+ const recognizedControl = element => element.tagName === 'BUTTON' && (Boolean(element.closest?.('pre')) ||
72
+ element.getAttribute('data-table-copy-state') !== null ||
73
+ (element.getAttribute('data-content-reference-type') === 'entity' && element.getAttribute('data-assistant-entity-reference') !== null));
74
+ const stray = unsupported.filter(element => !actions?.contains(element) && !groups.some(group => group.contains(element)) &&
75
+ !payloadButtons.has(element) && !recognizedControl(element));
76
+ if (stray.length) { unresolved = true; panelEligible = false; }
77
+ // Diagnostics only: which kinds of element blocked the read, by tag and a sanitized
78
+ // role. Never URLs, text or attributes that could carry page content.
79
+ for (const element of stray) {
80
+ const role = (element.getAttribute('role') || '').toLowerCase();
81
+ const kind = String(element.tagName || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 20) + (role ? `[${/^[a-z-]{1,20}$/.test(role) ? role : 'other'}]` : '');
82
+ if (kind in strayKinds || Object.keys(strayKinds).length < 10) strayKinds[kind] = (strayKinds[kind] || 0) + 1;
83
+ }
84
+ }
85
+ for (const group of groups) {
86
+ if (concealed(group)) { unresolved = true; panelEligible = false; continue; }
87
+ if (anonymous) {
88
+ const buttons = Array.from(group.querySelectorAll('button'));
89
+ if (!buttons.length || buttons.length > 50 || group.querySelector('a[href], [role="button"]:not(button)')) panelEligible = false;
90
+ for (const button of buttons) {
91
+ const label = (button.getAttribute('aria-label') || '').trim();
92
+ if (!label || label.length > 200 || concealed(button)) panelEligible = false;
93
+ else publisherLabels.push(label);
94
+ }
95
+ }
96
+ let resolved = 0;
97
+ for (const anchor of group.querySelectorAll('a[href]')) {
98
+ if (concealed(anchor)) { unresolved = true; continue; }
99
+ try {
100
+ const url = new URL(anchor.href);
101
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw Error();
102
+ sources.push({ url: url.href, title: (anchor.textContent || '').trim() });
103
+ resolved++;
104
+ } catch { unresolved = true; }
105
+ }
106
+ // Observed anonymous pills are buttons without URLs. Their labels are
107
+ // not URL evidence; a partial anchor list cannot resolve those controls.
108
+ // A group whose every control carries a parsed sources payload is resolved by it.
109
+ const payloadResolved = payloadButtons.size > 0 && (() => { const controls = Array.from(group.querySelectorAll('button, [role="button"]')); return controls.length > 0 && controls.every(control => payloadButtons.has(control)); })();
110
+ if (!payloadResolved && (!resolved || group.querySelector('button, [role="button"]'))) unresolved = true;
111
+ }
112
+ sources.push(...payloadSources);
113
+ const labels = [...new Set(publisherLabels)];
114
+ panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
115
+ const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible, strayKinds };
116
+ if (!input.prepare) return state;
117
+ if (!panelEligible || !unresolved) return {state};
118
+ // Keep the exact assistant and control references in-page. No DOM identities,
119
+ // tokens or control IDs are persisted in observations.
120
+ const footer = root.querySelector(':scope > [role="group"][aria-label="Response actions"]');
121
+ const buttons = Array.from(footer?.querySelectorAll('button') || []).filter(button =>
122
+ !concealed(button) && !button.disabled && Array.from(button.children).some(child =>
123
+ !concealed(child) && child.children.length === 0 && (child.textContent || '').trim() === 'Sources'));
124
+ if (buttons.length !== 1) return {state};
125
+ const button = buttons[0], controlledId = button.getAttribute('aria-controls');
126
+ if (!controlledId || controlledId.length > 256) return {state};
127
+ const before = document.getElementById(controlledId);
128
+ // Only the observed absent/empty target is supported. A populated old panel
129
+ // must not be mistaken for freshly resolved citations from this answer.
130
+ if (before && (visible(before) || (before.textContent || '').trim() || before.querySelector('a'))) return {state};
131
+ const responseText = root.innerText;
132
+ const unavailable = () => ({status:'unavailable'});
133
+ const inspect = () => {
134
+ if (latestResponse() !== root || !root.isConnected || !root.matches(selectors.at(-1)) || concealed(root) ||
135
+ root.innerText !== responseText || root.getAttribute('aria-busy') === 'true' ||
136
+ (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
137
+ !button.isConnected || !footer.contains(button) || !root.contains(footer) || button.getAttribute('aria-controls') !== controlledId) return unavailable();
138
+ const currentState = readChatgptCitationState(selectors);
139
+ if (!currentState.panelEligible || currentState.groups !== state.groups) return unavailable();
140
+ const currentLabels = [...new Set(Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"] button')).map(node => (node.getAttribute('aria-label') || '').trim()))];
141
+ if (currentLabels.length !== labels.length || currentLabels.some(label => !labels.includes(label))) return unavailable();
142
+ const target = document.getElementById(controlledId);
143
+ if (!target || !visible(target)) return {status:'pending'};
144
+ if (target === root || target.contains(root) || ['BODY','MAIN','HTML','NAV'].includes(target.tagName)) return unavailable();
145
+ const articles = (target.matches('article') ? [target] : Array.from(target.querySelectorAll('article'))).filter(visible);
146
+ if (articles.length !== 1) return unavailable();
147
+ const article = articles[0];
148
+ 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();
149
+ const headings = article.querySelectorAll('h1,h2,h3,[role="heading"]');
150
+ const header = article.querySelector(':scope > header > h2');
151
+ if (headings.length !== 1 || headings[0] !== header || concealed(header) || (header.textContent || '').trim() !== 'Sources') return unavailable();
152
+ const lists = article.querySelectorAll('ol');
153
+ if (lists.length !== 1 || article.querySelector('ul')) return unavailable();
154
+ const list = lists[0], cards = Array.from(list.children);
155
+ if (concealed(list) || !cards.length || cards.length > 50 || list.querySelectorAll('li').length !== cards.length || article.querySelectorAll('a').length !== cards.length) return unavailable();
156
+ const foundLabels = new Set(), results = [];
157
+ for (const card of cards) {
158
+ if (card.tagName !== 'LI' || concealed(card) || card.children.length !== 1 || card.firstElementChild.tagName !== 'A') return unavailable();
159
+ const anchor = card.firstElementChild, titles = anchor.querySelectorAll('strong');
160
+ if (concealed(anchor) || titles.length !== 1 || concealed(titles[0])) return unavailable();
161
+ const title = (titles[0].textContent || '').trim();
162
+ if (!title || title.length > 1000) return unavailable();
163
+ let url;
164
+ try {
165
+ const raw = anchor.getAttribute('href');
166
+ if (!raw || raw.length > 8192 || /[\u0000-\u0020\u007f]/.test(raw)) return unavailable();
167
+ url = new URL(raw);
168
+ if (!['http:','https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return unavailable();
169
+ } catch { return unavailable(); }
170
+ // Exact visible publisher labels, not guessed domains or URL derivation.
171
+ const matched = [...new Set(Array.from(anchor.querySelectorAll('span')).filter(node =>
172
+ node.children.length === 0 && !concealed(node)).map(node => (node.textContent || '').trim()).filter(label => labels.includes(label)))];
173
+ if (matched.length !== 1) return unavailable();
174
+ foundLabels.add(matched[0]);
175
+ results.push({url:url.href,title});
176
+ }
177
+ if (foundLabels.size !== labels.length) return unavailable();
178
+ return {status:'resolved',sources:results};
179
+ };
180
+ return {state,button,inspect};
181
+ }
182
+
183
+ const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
184
+ const responseBindings = new WeakMap();
185
+ const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent());
186
+ // ChatGPT renders some failures as the assistant turn itself ("Something went wrong...").
187
+ // Such a turn is not an answer: read as one, it would record a false not_cited. Only a short
188
+ // turn made of a known failure message counts, so a real answer that quotes one is kept.
189
+ const CHATGPT_FAILURE_MESSAGES = [
190
+ /^something went wrong\b/i,
191
+ /^there was an error generating a response\b/i,
192
+ /^hmm\.{0,3}\s*something seems to have gone wrong\b/i,
193
+ /^network error\b/i,
194
+ /^too many requests\b/i,
195
+ /^you('|’)ve reached our limit of messages\b/i,
196
+ ];
197
+ export function isChatgptFailureMessage(text) {
198
+ const body = String(text ?? '').replace(/^\s*#{1,6}\s*ChatGPT said:\s*/i, '').trim();
199
+ return body.length > 0 && body.length < 400 && CHATGPT_FAILURE_MESSAGES.some(pattern => pattern.test(body));
200
+ }
201
+
202
+ export async function releaseChatgptResponseBinding(page) {
203
+ const binding = responseBindings.get(page);
204
+ responseBindings.delete(page);
205
+ await binding?.dispose().catch(() => {});
206
+ }
207
+ export async function extractBoundChatgptResponse(page, legacyExtract) {
208
+ const previous = responseBindings.get(page);
209
+ responseBindings.delete(page);
210
+ if (previous) await previous.dispose().catch(() => {});
211
+ let binding;
212
+ try {
213
+ binding = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepareBinding:true});
214
+ if (!await bindingCurrent(binding)) throw unavailable();
215
+ const answer = await legacyExtract(page);
216
+ if (!await bindingCurrent(binding)) throw unavailable();
217
+ responseBindings.set(page,binding);
218
+ return answer;
219
+ } catch {
220
+ await binding?.dispose().catch(() => {});
221
+ throw unavailable();
222
+ }
223
+ }
224
+ async function extractControlledPanel(page, binding) {
225
+ let handle, buttonHandle;
226
+ try {
227
+ handle = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepare:true});
228
+ buttonHandle = await handle.getProperty('button');
229
+ const button = buttonHandle.asElement();
230
+ if (!button) throw unavailable();
231
+ await button.click({timeout:3000});
232
+ // Bounded DOM render polling only; never another click/provider invocation.
233
+ let previous = null, stable = 0;
234
+ for (let attempt=0; attempt<12; attempt++) {
235
+ const result = await handle.evaluate((state,guard) => guard && !guard.isCurrent() ? {status:'unavailable'} : state.inspect(),binding || null);
236
+ if (result.status === 'resolved') {
237
+ const signature = JSON.stringify(result.sources);
238
+ stable = signature === previous ? stable + 1 : 1;
239
+ previous = signature;
240
+ // Publisher coverage alone is insufficient while more same-publisher
241
+ // cards may still be rendering. Require three identical valid reads.
242
+ if (stable >= 3) return [...new Map(result.sources.map(source => [source.url,source])).values()];
243
+ } else if (result.status === 'pending') { previous = null; stable = 0; }
244
+ else throw unavailable();
245
+ await page.waitForTimeout(250);
246
+ }
247
+ throw unavailable();
248
+ } catch { throw unavailable(); }
249
+ finally { await Promise.allSettled([buttonHandle?.dispose(),handle?.dispose()]); }
250
+ }
251
+
252
+ export async function extractChatgptSources(page, legacyExtract) {
253
+ const binding = responseBindings.get(page);
254
+ try {
255
+ if (binding && !await bindingCurrent(binding)) throw unavailable();
256
+ const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
257
+ let sources;
258
+ if (!state.found || state.unresolved) {
259
+ if (!state.found || !state.panelEligible) throw Object.assign(unavailable(), { strayKinds: state.strayKinds ?? {} });
260
+ sources = await extractControlledPanel(page,binding);
261
+ } else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
262
+ else sources = await legacyExtract(page);
263
+ if (binding && !await bindingCurrent(binding)) throw unavailable();
264
+ return sources;
265
+ } finally {
266
+ responseBindings.delete(page);
267
+ await binding?.dispose().catch(() => {});
268
+ }
269
+ }
@@ -11,10 +11,13 @@
11
11
  // browser tooling still runs every non-browser engine, and `citation doctor`
12
12
  // reports the gap instead of the whole CLI failing to load.
13
13
  import { homedir } from 'node:os';
14
+ import { spawn } from 'node:child_process';
15
+ import { readFileSync } from 'node:fs';
14
16
  import { join } from 'node:path';
15
17
 
16
18
  import { EngineError } from '../adapter.js';
17
19
  import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
20
+ import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding, isChatgptFailureMessage } from './chatgpt-citations.js';
18
21
 
19
22
  const MODULE_LOADERS = {
20
23
  'playwright-core': () => import('playwright-core'),
@@ -57,6 +60,46 @@ const ENGINE_NOMINAL_COST = Object.freeze({
57
60
  });
58
61
  const DEFAULT_NOMINAL_COST = 0.03;
59
62
 
63
+ // The vendored display bootstrap stops Xvfb only through cleanup(). A Node process that is
64
+ // killed or crashes leaves Xvfb running under init (the measurement VPS held 32 of them on
65
+ // 2026-09-24), so a detached watchdog ends it within five seconds of this process
66
+ // disappearing. The pid comes from Xvfb's own lock file and is killed only while it is still
67
+ // Xvfb, so a recycled pid is never signalled. Returns the function that retires the watchdog.
68
+ export function guardDisplay(display, { spawnProcess = spawn, readLock = readFileSync, parentPid = process.pid } = {}) {
69
+ const number = Number(String(display).replace(/^:/, ''));
70
+ let xvfbPid;
71
+ try { xvfbPid = Number(String(readLock(`/tmp/.X${number}-lock`, 'utf8')).trim()); } catch { return () => {}; }
72
+ if (![number, xvfbPid, parentPid].every(Number.isInteger) || xvfbPid <= 1) return () => {};
73
+ const script = `while kill -0 ${parentPid} 2>/dev/null; do sleep 5; done; [ "$(cat /proc/${xvfbPid}/comm 2>/dev/null)" = Xvfb ] && kill ${xvfbPid}`;
74
+ let watchdog;
75
+ try { watchdog = spawnProcess('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }); watchdog.unref?.(); }
76
+ catch { return () => {}; }
77
+ return () => { try { watchdog.kill('SIGKILL'); } catch {} };
78
+ }
79
+
80
+ // A browser composer can lose keystrokes (a login modal taking focus mid-typing is the
81
+ // observed case: on 2026-09-25, 4 of 30 ChatGPT slots submitted a truncated prompt and were
82
+ // answered as a different question). The prompt must be exactly what the panel says, both
83
+ // in the composer before submit and in the submitted turn afterwards.
84
+ export const normalizePrompt = text => String(text ?? '').replace(/^\s*you said:\s*/i, '').replace(/\s+/g, ' ').trim();
85
+ export async function ensurePromptEntered(prompt, { readComposer, fill, settle = async () => {} }) {
86
+ const expected = normalizePrompt(prompt);
87
+ if (normalizePrompt(await readComposer()) === expected) return 'typed';
88
+ await fill(prompt); await settle();
89
+ if (normalizePrompt(await readComposer()) === expected) return 'filled';
90
+ throw new EngineError('the prompt could not be entered completely', { retriable: true });
91
+ }
92
+ // Runs in the page: the text of the latest user turn, or null when none is found.
93
+ // Observed 2026-09-25: li[data-message-role="user"] > ... > p[data-user-message-copy].
94
+ // Older layouts used data-message-author-role / data-turn.
95
+ export function readChatgptSubmittedPrompt() {
96
+ for (const selector of ['[data-message-role="user"] [data-user-message-copy]', '[data-message-role="user"]', '[data-message-author-role="user"]', '[data-turn="user"]']) {
97
+ const node = Array.from(document.querySelectorAll(selector)).at(-1);
98
+ if (node) return node.innerText;
99
+ }
100
+ return null;
101
+ }
102
+
60
103
  export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) {
61
104
  if (!BROWSER_PROVIDERS.includes(engineName)) {
62
105
  throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
@@ -88,6 +131,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
88
131
  let running=false,contextCleanupFailed=false;
89
132
  let browserHandle = null; // { browser, cleanup } for the whole epoch
90
133
  let displayHandle = null;
134
+ let releaseDisplayGuard = null;
91
135
  let providerConfig = null;
92
136
  async function closeQuietly(action) {
93
137
  let timer;
@@ -95,12 +139,30 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
95
139
  finally { clearTimeout(timer); }
96
140
  }
97
141
 
142
+ // The watchdog is retired only after a confirmed close; otherwise it stays armed and
143
+ // ends Xvfb once this process exits.
144
+ async function closeDisplay() {
145
+ // A second call after a failed close finds no handle; that is not a confirmed close.
146
+ if (!displayHandle) return true;
147
+ const closed = await closeQuietly(() => displayHandle.cleanup?.());
148
+ if (closed) { releaseDisplayGuard?.(); releaseDisplayGuard = null; }
149
+ displayHandle = null;
150
+ return closed;
151
+ }
152
+
98
153
  async function ensureProviderConfig() {
99
154
  if (providerConfig) return providerConfig;
100
155
  const { PROVIDER_CONFIGS } = await load('./gen/core/providers/index.js');
101
156
  const { OWN_PROVIDER_CONFIGS } = await load('./providers.js');
102
157
  providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
103
158
  if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
159
+ if (engineName === 'chatgpt') {
160
+ const legacyExtract = providerConfig.extractSources;
161
+ const legacyResponse = providerConfig.extractResponse;
162
+ providerConfig = { ...providerConfig,
163
+ extractResponse: page => extractBoundChatgptResponse(page, legacyResponse),
164
+ extractSources: page => extractChatgptSources(page, legacyExtract) };
165
+ }
104
166
  return providerConfig;
105
167
  }
106
168
 
@@ -116,6 +178,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
116
178
  // engines that block headless render headfully inside a virtual display.
117
179
  if (platform === 'linux' && !display.detectDisplay()) {
118
180
  displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
181
+ releaseDisplayGuard = (runtime.guardDisplay ?? guardDisplay)(displayHandle.display);
119
182
  await event(`display: ${displayHandle.display}`);
120
183
  }
121
184
 
@@ -143,8 +206,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
143
206
  browser,
144
207
  cleanup: async () => {
145
208
  const browserClosed=await closeQuietly(() => browser.close());
146
- const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
147
- displayHandle = null;
209
+ const displayClosed=await closeDisplay();
148
210
  if(browserClosed)contextCleanupFailed=false;
149
211
  return browserClosed&&displayClosed;
150
212
  },
@@ -221,6 +283,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
221
283
  locale: 'en-US',
222
284
  timezoneId: 'America/New_York',
223
285
  });
286
+ let page;
224
287
  try {
225
288
  // Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
226
289
  // images and media are the bulk of the 3-8 MB measured per observation.
@@ -232,7 +295,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
232
295
  return route.continue();
233
296
  });
234
297
  }
235
- const page = await context.newPage();
298
+ page = await context.newPage();
236
299
  const { runPageDomOp } = await load('./gen/lib/browser/domOps.js');
237
300
  page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params);
238
301
  let answer = '';
@@ -309,15 +372,19 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
309
372
  await editor.locator.fill(prompt);
310
373
  });
311
374
  await page.waitForTimeout(600);
312
- const typedCheck = await editor.locator.inputValue().catch(() => '');
313
- if (typedCheck.trim().length === 0) {
314
- await editor.locator.fill(prompt).catch(() => {});
315
- }
316
375
  await config.afterTypingHook?.(page);
317
376
  await config.beforeSubmitHook?.(page);
377
+ // Checked after the modal hooks, immediately before submit: the composer must hold
378
+ // the whole prompt. One fill is the recovery; otherwise the sample fails closed.
379
+ const entered = await ensurePromptEntered(prompt, {
380
+ readComposer: () => editor.locator.evaluate(node => (typeof node.value === 'string' ? node.value : node.innerText) || '').catch(() => ''),
381
+ fill: text => editor.locator.fill(text).catch(() => {}),
382
+ settle: () => page.waitForTimeout(400),
383
+ });
384
+ if (entered === 'filled') await event('prompt refilled after incomplete typing');
318
385
 
319
386
  // 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.
387
+ // Allow the surface to settle; response extraction remains fail-closed.
321
388
  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
389
  if (await send.isVisible().catch(() => false)) { await send.click(); }
323
390
  else { await editor.locator.press('Enter'); }
@@ -327,15 +394,29 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
327
394
 
328
395
  await config.waitForResponse(page);
329
396
  if (!answer) answer = await config.extractResponse(page);
397
+ if (engineName === 'chatgpt') {
398
+ // The answer belongs to the prompt only if the submitted turn is the panel prompt.
399
+ const submitted = await page.evaluate(readChatgptSubmittedPrompt).catch(() => null);
400
+ if (typeof submitted !== 'string') await event('submitted prompt could not be read; not verified');
401
+ else if (normalizePrompt(submitted) !== normalizePrompt(prompt)) {
402
+ throw new EngineError('chatgpt: the submitted prompt differs from the panel prompt', { retriable: true });
403
+ }
404
+ }
405
+ if (engineName === 'chatgpt' && isChatgptFailureMessage(answer)) {
406
+ throw new EngineError('chatgpt: the surface returned an error message instead of an answer', { retriable: true });
407
+ }
330
408
  if (!answer || answer.trim().length < 40) {
331
409
  const reason = engineName === 'bing'
332
410
  ? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
333
411
  : `${engineName}: answer extracted empty`;
334
412
  throw new EngineError(reason, { retriable: true }); }
335
413
  let extractionFailed = config.citationExtraction === 'unsupported';
336
- const sources = await config.extractSources(page).catch(async () => {
414
+ const sources = await config.extractSources(page).catch(async cause => {
337
415
  extractionFailed = true;
338
- await event('sources extraction failed; observation retained as unknown');
416
+ // Tag and role counts only (see readChatgptCitationState); they say which layout
417
+ // change blocked the read without carrying page content.
418
+ const kinds = Object.entries(cause?.strayKinds ?? {}).map(([kind, count]) => `${kind}x${count}`).join(', ');
419
+ await event(`sources extraction failed; observation retained as unknown${kinds ? ` (unrecognized: ${kinds})` : ''}`);
339
420
  return [];
340
421
  });
341
422
  const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
@@ -355,6 +436,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
355
436
  screenshotPng,
356
437
  };
357
438
  } catch (cause) {
439
+ if (cause?.code === 'CITATION_EXTRACTION_UNAVAILABLE') {
440
+ throw Object.assign(new EngineError('Citation response could not be bound.'), {code:'CITATION_EXTRACTION_UNAVAILABLE',unknown:true});
441
+ }
358
442
  if (cause instanceof EngineError) throw cause;
359
443
  const msg = String(cause?.message ?? cause);
360
444
  const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
@@ -362,6 +446,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
362
446
  error.unknown = true;
363
447
  throw error;
364
448
  } finally {
449
+ if (engineName === 'chatgpt' && page) await closeQuietly(() => releaseChatgptResponseBinding(page));
365
450
  if(!await closeQuietly(() => context.close()))contextCleanupFailed=true;
366
451
  }
367
452
  },
@@ -371,17 +456,17 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
371
456
  let confirmed=true;
372
457
  try{if(browserHandle)confirmed=await browserHandle.cleanup();}
373
458
  finally{
374
- const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
375
- confirmed=confirmed&&displayClosed;displayHandle=null;browserHandle=null;
459
+ const displayClosed=await closeDisplay();
460
+ confirmed=confirmed&&displayClosed;browserHandle=null;
376
461
  }
377
462
  return confirmed;
378
463
  },
379
464
  };
380
465
  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']);
466
+ 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
467
  const safeError=cause=>{
383
468
  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});
469
+ 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
470
  if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
386
471
  return error;
387
472
  };
@@ -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,30 @@
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
- PROVIDER_EDITOR_SELECTORS,
8
7
  PROVIDER_SUBMIT_BTN_SELECTORS,
9
- PROVIDER_MODEL_RESPONSE_SELECTORS,
10
8
  PROVIDER_RESPONSE_GENERATION_SELECTORS,
11
9
  RETRYABLE_ERRORS,
12
10
  } from '../gen/utils/agent-constants.js';
11
+ import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors, PROVIDER_EDITOR_SELECTORS as vendoredEditorSelectors } from '../gen/utils/agent-constants.js';
12
+
13
+ // Anonymous composer observed 2026-09-25: a plain textarea (#mobile-composer-prompt,
14
+ // name="prompt"). None of the vendored ChatGPT selectors match it; without these the engine
15
+ // reached it only through the broad shadow-DOM fallback.
16
+ export const PROVIDER_EDITOR_SELECTORS = Object.freeze({
17
+ ...vendoredEditorSelectors,
18
+ chatgpt: Object.freeze(['textarea#mobile-composer-prompt', 'textarea[name="prompt"]', ...vendoredEditorSelectors.chatgpt]),
19
+ });
20
+
21
+ // Anonymous surface observed 2026-09-20: conversation LI, distinct from its
22
+ // user sibling, with response actions. Never select the conversation itself.
23
+ 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"]))';
24
+ export const PROVIDER_MODEL_RESPONSE_SELECTORS = Object.freeze({
25
+ ...vendoredResponseSelectors,
26
+ chatgpt: Object.freeze([...vendoredResponseSelectors.chatgpt, CHATGPT_ANONYMOUS_RESPONSE_SELECTOR]),
27
+ });
13
28
 
14
29
  const envQuiet = () => Boolean(process.env.AGENTLINKOPS_BROWSER_QUIET);
15
30
 
package/src/tool-views.js CHANGED
@@ -58,7 +58,7 @@ export function oneLiner(description, max = 60) {
58
58
  export function manifestText(entries, { max = 36 } = {}) {
59
59
  const groups = new Map(TOOLSETS.map(t => [t, []]));
60
60
  for (const entry of entries) (groups.get(entry.toolset) ?? groups.set(entry.toolset, []).get(entry.toolset)).push(entry);
61
- const lines = ['Commands by toolset (name: what it does). Use search_tools or describe_tools for the full schema before calling.'];
61
+ const lines = ['Commands by toolset (name: what it does). On /mcp use exec: `exec search <words>`, `exec describe <name>`, `exec call <name> <json>`; the CLI mirrors the verbs as `agentlinkops tools | describe | call`.'];
62
62
  for (const [toolset, members] of groups) {
63
63
  if (!members.length) continue;
64
64
  lines.push(`\n${toolset}:`);
@@ -94,11 +94,34 @@ export const CODE_TOOLS = [
94
94
  ];
95
95
  export const CODE_LIMITS = { outputChars:24000, timeoutMs:30000, maxCalls:200 };
96
96
 
97
+ // DP-0059: the default view's single exec tool. One definition carries the whole registry: the
98
+ // agent searches, inspects and calls commands through CLI-style strings instead of loading
99
+ // schemas upfront (the pattern PostHog's MCP shipped and we adopted). The definition lives here
100
+ // beside META_TOOLS and CODE_TOOLS so the measurement script counts the real shape the router
101
+ // registers; the four meta tools remain registered on /mcp for direct calls by clients that
102
+ // learned them, unlisted.
103
+ export const EXEC_TOOL = {
104
+ name: 'exec',
105
+ 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.',
106
+ readOnly: false,
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ command: {
111
+ type: 'string', minLength: 1, maxLength: 8000,
112
+ description: 'CLI-style command. Verbs: search [--toolset T] [--limit N] <words>; tools [toolset]; describe [--output-schema] [--examples] <name>...; schema <name> [<field.path>]; call [--json] <name> <json_input>. --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 <name> <field.path>. Unknown name: run search <words>.',
113
+ },
114
+ },
115
+ required: ['command'],
116
+ additionalProperties: false,
117
+ },
118
+ };
119
+
97
120
  // Views. `entries` carry name, toolset, tier, readOnly and (from tool-exposure.js) unavailable.
98
121
  // An unavailable command stays a member of its views: it is listed with its marker, never
99
122
  // hidden, so a client that calls it gets the stable configuration error rather than not-found.
100
123
  export const VIEW_BUDGETS = {
101
- default: { definitions:12, tokens:6000 },
124
+ default: { definitions:1, tokens:3500 },
102
125
  toolset: { definitions:22, tokens:8000 },
103
126
  code: { definitions:3, tokens:2000 },
104
127
  manifest: { tokens:2500 },
@@ -108,7 +131,9 @@ export function viewMembers(entries, view) {
108
131
  const core = visible.filter(e => e.tier === 'core');
109
132
  if (view === 'all') return visible;
110
133
  if (view === 'readonly') return visible.filter(e => e.readOnly);
111
- if (view === 'default') return core;
134
+ // DP-0059: the default view lists exactly one tool, exec. Every command (core tier included)
135
+ // stays registered and callable there through exec call and by a direct tools/call.
136
+ if (view === 'default') return [];
112
137
  if (view === 'code') return [];
113
138
  const m = /^toolset:([a-z]+)(:readonly)?$/.exec(view);
114
139
  if (!m) throw new Error(`Unknown view ${view}`);