agentlinkops 0.6.8 → 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.8",
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.8",
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,15 @@
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
+
6
15
  ## 0.6.8 (2026-09-20)
7
16
 
8
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.
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.8';
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.8",
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.8. 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
 
@@ -38,13 +38,48 @@ export function readChatgptCitationState(input) {
38
38
  const anonymous = root.matches(selectors.at(-1));
39
39
  let panelEligible = anonymous && groups.length > 0 && groups.length <= 50;
40
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
+ }
41
62
  if (anonymous) {
42
63
  // A new anonymous layout with unrecognized citation controls is not proof
43
- // of absence. Never use unrelated page-wide links to fill this gap.
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.
44
69
  const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
45
70
  const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
46
- if (unsupported.some(element => !actions?.contains(element) && !groups.some(group => group.contains(element)))) {
47
- unresolved = true; panelEligible = false;
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;
48
83
  }
49
84
  }
50
85
  for (const group of groups) {
@@ -70,11 +105,14 @@ export function readChatgptCitationState(input) {
70
105
  }
71
106
  // Observed anonymous pills are buttons without URLs. Their labels are
72
107
  // not URL evidence; a partial anchor list cannot resolve those controls.
73
- if (!resolved || group.querySelector('button, [role="button"]')) unresolved = true;
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;
74
111
  }
112
+ sources.push(...payloadSources);
75
113
  const labels = [...new Set(publisherLabels)];
76
114
  panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
77
- const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible };
115
+ const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible, strayKinds };
78
116
  if (!input.prepare) return state;
79
117
  if (!panelEligible || !unresolved) return {state};
80
118
  // Keep the exact assistant and control references in-page. No DOM identities,
@@ -145,6 +183,22 @@ export function readChatgptCitationState(input) {
145
183
  const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
146
184
  const responseBindings = new WeakMap();
147
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
+
148
202
  export async function releaseChatgptResponseBinding(page) {
149
203
  const binding = responseBindings.get(page);
150
204
  responseBindings.delete(page);
@@ -202,7 +256,7 @@ export async function extractChatgptSources(page, legacyExtract) {
202
256
  const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
203
257
  let sources;
204
258
  if (!state.found || state.unresolved) {
205
- if (!state.found || !state.panelEligible) throw unavailable();
259
+ if (!state.found || !state.panelEligible) throw Object.assign(unavailable(), { strayKinds: state.strayKinds ?? {} });
206
260
  sources = await extractControlledPanel(page,binding);
207
261
  } else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
208
262
  else sources = await legacyExtract(page);
@@ -11,11 +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';
18
- import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js';
20
+ import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding, isChatgptFailureMessage } from './chatgpt-citations.js';
19
21
 
20
22
  const MODULE_LOADERS = {
21
23
  'playwright-core': () => import('playwright-core'),
@@ -58,6 +60,46 @@ const ENGINE_NOMINAL_COST = Object.freeze({
58
60
  });
59
61
  const DEFAULT_NOMINAL_COST = 0.03;
60
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
+
61
103
  export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) {
62
104
  if (!BROWSER_PROVIDERS.includes(engineName)) {
63
105
  throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
@@ -89,6 +131,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
89
131
  let running=false,contextCleanupFailed=false;
90
132
  let browserHandle = null; // { browser, cleanup } for the whole epoch
91
133
  let displayHandle = null;
134
+ let releaseDisplayGuard = null;
92
135
  let providerConfig = null;
93
136
  async function closeQuietly(action) {
94
137
  let timer;
@@ -96,6 +139,17 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
96
139
  finally { clearTimeout(timer); }
97
140
  }
98
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
+
99
153
  async function ensureProviderConfig() {
100
154
  if (providerConfig) return providerConfig;
101
155
  const { PROVIDER_CONFIGS } = await load('./gen/core/providers/index.js');
@@ -124,6 +178,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
124
178
  // engines that block headless render headfully inside a virtual display.
125
179
  if (platform === 'linux' && !display.detectDisplay()) {
126
180
  displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
181
+ releaseDisplayGuard = (runtime.guardDisplay ?? guardDisplay)(displayHandle.display);
127
182
  await event(`display: ${displayHandle.display}`);
128
183
  }
129
184
 
@@ -151,8 +206,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
151
206
  browser,
152
207
  cleanup: async () => {
153
208
  const browserClosed=await closeQuietly(() => browser.close());
154
- const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
155
- displayHandle = null;
209
+ const displayClosed=await closeDisplay();
156
210
  if(browserClosed)contextCleanupFailed=false;
157
211
  return browserClosed&&displayClosed;
158
212
  },
@@ -318,12 +372,16 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
318
372
  await editor.locator.fill(prompt);
319
373
  });
320
374
  await page.waitForTimeout(600);
321
- const typedCheck = await editor.locator.inputValue().catch(() => '');
322
- if (typedCheck.trim().length === 0) {
323
- await editor.locator.fill(prompt).catch(() => {});
324
- }
325
375
  await config.afterTypingHook?.(page);
326
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');
327
385
 
328
386
  // Submit: the surface's own send button when it exposes one; Enter fallback.
329
387
  // Allow the surface to settle; response extraction remains fail-closed.
@@ -336,15 +394,29 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
336
394
 
337
395
  await config.waitForResponse(page);
338
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
+ }
339
408
  if (!answer || answer.trim().length < 40) {
340
409
  const reason = engineName === 'bing'
341
410
  ? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
342
411
  : `${engineName}: answer extracted empty`;
343
412
  throw new EngineError(reason, { retriable: true }); }
344
413
  let extractionFailed = config.citationExtraction === 'unsupported';
345
- const sources = await config.extractSources(page).catch(async () => {
414
+ const sources = await config.extractSources(page).catch(async cause => {
346
415
  extractionFailed = true;
347
- 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})` : ''}`);
348
420
  return [];
349
421
  });
350
422
  const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
@@ -384,8 +456,8 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
384
456
  let confirmed=true;
385
457
  try{if(browserHandle)confirmed=await browserHandle.cleanup();}
386
458
  finally{
387
- const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
388
- confirmed=confirmed&&displayClosed;displayHandle=null;browserHandle=null;
459
+ const displayClosed=await closeDisplay();
460
+ confirmed=confirmed&&displayClosed;browserHandle=null;
389
461
  }
390
462
  return confirmed;
391
463
  },
@@ -4,12 +4,19 @@
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
8
  PROVIDER_RESPONSE_GENERATION_SELECTORS,
10
9
  RETRYABLE_ERRORS,
11
10
  } from '../gen/utils/agent-constants.js';
12
- import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } 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
+ });
13
20
 
14
21
  // Anonymous surface observed 2026-09-20: conversation LI, distinct from its
15
22
  // user sibling, with response actions. Never select the conversation itself.
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}`);