agentlinkops 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +44 -12
  5. package/cli/admission.js +9 -1
  6. package/cli/agent-setup.js +189 -20
  7. package/cli/catalog.js +7938 -1357
  8. package/cli/client.js +89 -23
  9. package/cli/cloud-error.js +21 -0
  10. package/cli/commands.js +37 -6
  11. package/cli/connection.js +74 -12
  12. package/cli/doctor.js +24 -9
  13. package/cli/gsc-browser.js +19 -6
  14. package/cli/ledger.js +4 -7
  15. package/cli/lifecycle.js +5 -2
  16. package/cli/local-result.js +4 -4
  17. package/cli/main.js +43 -28
  18. package/cli/mcp-client.js +198 -0
  19. package/cli/native-lock.js +22 -0
  20. package/cli/oauth-credentials.js +196 -0
  21. package/cli/oauth-flow.js +259 -0
  22. package/cli/setup-plan.js +5 -5
  23. package/cli/status.js +14 -2
  24. package/cli/sync-lock.js +105 -0
  25. package/cli/sync.js +14 -6
  26. package/package.json +6 -3
  27. package/references/cli.md +22 -0
  28. package/references/local-crm-draft-handoff.md +76 -0
  29. package/references/portable-ledger-handoff.md +50 -0
  30. package/references/recipes/catalog.json +9 -9
  31. package/references/recipes/command-notes.md +17 -1
  32. package/references/recipes/placement-reconciliation.md +19 -5
  33. package/references/recipes/qualified-campaign-handoff.md +16 -4
  34. package/references/recipes/site-context-brief.md +2 -2
  35. package/references/recipes/sourced-linkable-asset.md +8 -8
  36. package/scripts/agentlinkops.py +30 -5
  37. package/scripts/outreach-crm-draft.py +121 -0
  38. package/scripts/portable-handoff.py +184 -0
  39. package/shared/exec-grammar.js +342 -0
  40. package/shared/lifecycle-contract.js +2 -2
  41. package/shared/response-contract.js +7 -1
  42. package/skills/agentlinkops-assets/SKILL.md +2 -2
  43. package/skills/agentlinkops-campaigns/SKILL.md +3 -3
  44. package/skills/agentlinkops-connect/SKILL.md +29 -6
  45. package/skills/agentlinkops-connect/references/agentlinkops.md +62 -26
  46. package/skills/agentlinkops-crm/SKILL.md +17 -3
  47. package/src/citations/adapter.js +3 -2
  48. package/src/citations/browser/chatgpt-citations.js +60 -6
  49. package/src/citations/browser/engine.js +106 -18
  50. package/src/citations/browser/shims/utils.js +9 -2
  51. package/src/citations/login.js +42 -5
  52. package/src/discovery/contract.js +1 -1
  53. package/src/response-shaping.js +58 -0
  54. package/src/tool-views.js +31 -4
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agentlinkops-connect
3
3
  description: How to use AgentLinkOps correctly through its MCP tools, the agentlinkops CLI or HTTP. Load before the first AgentLinkOps tool call in a session, and whenever a task connects a client, picks an MCP view, finds a command, watches an earned link, reads a check result or evidence, or checks usage. Not for outreach, browsing or local ledger edits.
4
- allowed-tools: Read Bash(agentlinkops skill *) Bash(agentlinkops tools *) Bash(agentlinkops describe *) Bash(agentlinkops call *) Bash(agentlinkops agent *) Bash(agentlinkops doctor *)
4
+ allowed-tools: Read Bash(agentlinkops skill *) Bash(agentlinkops check *) Bash(agentlinkops setup --plan *) Bash(agentlinkops tools *) Bash(agentlinkops describe *) Bash(agentlinkops call *) Bash(agentlinkops agent *) Bash(agentlinkops doctor *)
5
5
  ---
6
6
 
7
7
  # Connect to AgentLinkOps
@@ -14,9 +14,26 @@ AgentLinkOps stores verified backlink observations, monitoring history and evide
14
14
 
15
15
  ## Set up once
16
16
 
17
- `npx -y agentlinkops agent setup` installs this pack for each detected agent client and writes the MCP entry for the view that client should use; it stores no credential. Sign in through the client's OAuth flow. If the CLI is not installed, connect the client to `https://app.agentlinkops.com/mcp` by hand and let the person approve the workspace and scopes. `agentlinkops agent status` shows what is configured.
17
+ Inspect `npx -y agentlinkops agent setup --help` for the installed version before selecting payloads. CLI 0.6.10 installs the connection skill and its reference by default, with MCP setup for detected clients. Select optional guidance explicitly. CLI 0.6.9 and earlier can install the complete pack. Native-client acceptance remains separate from package checks. Setup stores no credential.
18
18
 
19
- ## Minimal example (after reading the reference)
19
+ For a chosen hosted task, sign in through the client's OAuth flow. If the CLI is not installed, connect the client to `https://app.agentlinkops.com/mcp` by hand and let the person approve the workspace and scopes. `agentlinkops agent status` shows what is configured.
20
+
21
+ ## Check a supplied pair (after reading the reference)
22
+
23
+ With an available CLI, use the person's chosen URLs as literal arguments:
24
+
25
+ ```sh
26
+ agentlinkops check --source SOURCE_URL --target TARGET_URL --scope exact --json
27
+ ```
28
+
29
+ When the person asks to save the portable result, add `--out FILE` with their
30
+ chosen path, such as `--out native-result.json`. The command saves the portable
31
+ JSON result and still prints it. Read the saved JSON before reporting its state,
32
+ check date, coverage and evidence limits.
33
+
34
+ This fetches the source page and returns an accountless observation. It needs no hosted connection, repository, ledger, CRM or publisher recipe. Read the structured state and evidence limits: exit zero means a result was returned, and unknown does not prove removal. A usage or configuration failure supplies no observation.
35
+
36
+ ## Hosted example (after reading the reference)
20
37
 
21
38
  1. `get_workspace` with `{}`: limits, usage and membership.
22
39
  2. `list_projects` with `{"limit": 20}`: the websites this credential can see. Take ids from this answer.
@@ -34,8 +51,14 @@ On the CLI the same three verbs are `agentlinkops tools [TOOLSET]`, `agentlinkop
34
51
 
35
52
  ## Installed skill set
36
53
 
37
- Run `agentlinkops skill --list` for the versioned set installed with this CLI. It includes connect (access and evidence rules), discovery (sourced prospect research), campaigns (campaign judgment), CRM (local records) and assets (a specific linkable resource). Read an individual skill with its full name, for example `agentlinkops skill agentlinkops-discovery`. Research and local work do not require buying a supplier subscription.
54
+ Run `agentlinkops skill --list` for the versioned set installed with this CLI. The bundled optional set includes discovery (sourced prospect research), campaigns (campaign judgment), CRM (local records) and assets (a specific linkable resource). Install only what the person chooses with `agentlinkops agent setup --skill ID`. These skills and local records are not prerequisites for checking supplied links. Read an individual skill with its full name, such as `agentlinkops skill agentlinkops-discovery`. Research and local work do not require buying a supplier subscription.
55
+
56
+ ## Optional recipes and customer policy
57
+
58
+ CLI 0.6.10's default connection setup excludes campaign, CRM and publisher workflows. Use the selectors only when the installed CLI’s help supports them. Inspect `agentlinkops setup --plan --goal GOAL --mode MODE` when a task needs a plan. Select an optional recipe with `agentlinkops agent setup --recipe ID`: `site-context-brief`, `sourced-linkable-asset`, `qualified-campaign-handoff` or `placement-reconciliation`. Repeat the flag for multiple recipes. The installer reports their versions and associated skills. Installed recipes live in `../../references/recipes/`; read a selected recipe that fits the person's task. For the optional site brief, select it separately with `agentlinkops agent setup --recipe site-context-brief` before reading [the site brief recipe](../../references/recipes/site-context-brief.md).
59
+
60
+ Preview `agentlinkops agent remove --recipe ID` or `--skill ID`, then add `--apply` to remove unchanged installer-owned files. The installer refuses skill removal while a recipe it owns still needs the skill. Keep the skill or preview selecting that recipe and its skill together after reconciling customer edits. The installer preserves edited dependent bodies, which can still block joint removal. It also refuses removal when it cannot trust the dependency metadata. Targeted connection-skill removal refuses surviving optional payloads or retained MCP ownership.
38
61
 
39
- ## Choose the task
62
+ For an intended complete uninstall, preview `agentlinkops agent remove --scope project` (or `user`), then add `--apply`. Recipe-only removal preserves the connection, other skills, shared support and customer edits. A connection-skill upgrade retains earlier optional payloads until explicitly removed. Full-pack setup is available with `--all-skills`.
40
63
 
41
- Use `agentlinkops setup --plan --goal GOAL --mode MODE` to inspect requirements without changing records. Goals include `build-content`, `prepare-campaign` and `verify-links`; modes are `local`, `hosted` and `external`. Read the [site and context brief](../../references/recipes/site-context-brief.md) when the task needs a site brief. Route sourced drafts to `agentlinkops-assets`, campaign handoffs to `agentlinkops-campaigns`, and placement reconciliation to `agentlinkops-crm`. The [recipe catalog](../../references/recipes/catalog.json) records versions, required inputs and untested capabilities.
64
+ Follow the customer's existing instructions, editorial rules and tools. These optional methods never authorize outreach, publication, spending or provider activation. The installer reports conflicts instead of replacing customer files. A connection or installation is separate from a successful link check and a published package release.
@@ -6,21 +6,33 @@ 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.10. 139 commands in 15 toolsets.
13
13
  <!-- generated:skill-version end -->
14
14
 
15
15
  ## Install and connect (once per machine)
16
16
 
17
17
  ```bash
18
- npx -y agentlinkops agent setup # installs the skill pack for each detected agent client and
19
- # writes its MCP entry; stores no credential
18
+ npx -y agentlinkops --help # inspect this package version's setup options
19
+ npx -y agentlinkops agent setup # writes the detected client's MCP entry; stores no credential
20
20
  agentlinkops agent status # what is installed where
21
21
  ```
22
22
 
23
- `npx -y skills add https://agentlinkops.com` installs the skills alone; then add
23
+ CLI 0.6.10 installs the connection skill and its reference by default. Select
24
+ optional guidance explicitly. CLI 0.6.9 and earlier can install the complete pack.
25
+ Check the installed version's help before using selection flags. Native-client
26
+ acceptance remains separate from package checks.
27
+
28
+ On a version supporting selections, add only the guidance the person chooses:
29
+ `agentlinkops agent setup --skill ID` or `--recipe ID`, repeating the flag for several
30
+ choices. `--all-skills` selects the complete pack. Recipes remain optional; their
31
+ versioned procedures do not replace the person's existing instructions or authorize
32
+ publication, outreach, spending or provider access. Checking supplied links does not
33
+ require a campaign, content workflow or local CRM.
34
+
35
+ `npx -y skills add https://agentlinkops.com` installs the product skills alone; then add
24
36
  `https://app.agentlinkops.com/mcp` as a remote HTTP MCP server in the client by hand. If the
25
37
  CLI is not installed, give the person that server URL and the next step instead of claiming
26
38
  the connection exists.
@@ -34,13 +46,18 @@ works:
34
46
  npx -y agentlinkops check --source https://publisher.example/resources --target https://your-site.example/guide
35
47
  ```
36
48
 
49
+ When the person asks to save the portable JSON result, add `--json --out FILE`
50
+ with their chosen file path. Read the saved result before reporting its state,
51
+ check date, coverage and evidence limits. Preserve the person's other records.
52
+
37
53
  REST and the CLI use a scoped API key created in **Agent access > Create API key** in the
38
54
  [workspace](https://app.agentlinkops.com/app), supplied as `AGENTLINKOPS_API_KEY` in the
39
55
  environment (`AGENTLINKOPS_TOKEN` also works; the older `LINKTRAIL_*` names keep working
40
56
  during the pilot compatibility window with one warning per process). MCP OAuth tokens are
41
57
  bound to `/mcp` and are not API keys.
42
58
 
43
- Verify: on MCP call `get_workspace` with `{}` (a workspace name in the answer means the
59
+ Verify: on MCP call `get_workspace` with `{}` — directly, or as
60
+ `exec: {"command": "call get_workspace {}"}` (a workspace name in the answer means the
44
61
  connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token` lines say
45
62
  `ok` when the origin answers and the key is accepted).
46
63
 
@@ -49,9 +66,12 @@ connection works); on the CLI run `agentlinkops doctor` (its `cloud` and `token`
49
66
  One catalog, several endpoints. Every command is callable on every view; only the listing
50
67
  changes. `agentlinkops agent setup` picks the right one per client.
51
68
 
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.
69
+ - `/mcp` (default): one tool, `exec`, carries the whole catalog. Pass it CLI-style command
70
+ strings — `search`, `tools`, `describe`, `schema`, `call` — and the server instructions list
71
+ every command by toolset. Every command is also callable directly on this view (a client
72
+ that already knows the name may call it), and the older discovery tools
73
+ (`search_tools`, `describe_tools`, `run_read_command`, `run_write_command`) still answer
74
+ direct calls, unlisted.
55
75
  - `/mcp/all`: the flat catalog, for a client with its own tool search (Claude Code, Hermes).
56
76
  - `/mcp/{toolset}`: one group from the table at the end.
57
77
  - `/mcp/code`: `search`, `describe` and `execute`; `execute` runs your JavaScript in an isolated
@@ -61,21 +81,35 @@ changes. `agentlinkops agent setup` picks the right one per client.
61
81
 
62
82
  ## Find, describe, call
63
83
 
64
- The same three verbs on every surface. Never invent a command name or an argument.
84
+ The same verbs on every surface. Never invent a command name or an argument. On MCP everything
85
+ goes through the one `exec` tool; its `command` string is one command per call, and several
86
+ `exec` calls can run in parallel.
65
87
 
66
- | Step | MCP | CLI | HTTP |
88
+ | Step | MCP (`exec`) | CLI | HTTP |
67
89
  | --- | --- | --- | --- |
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}` |
90
+ | index | `exec: {"command": "tools"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
91
+ | find | `exec: {"command": "search destination health"}` | `agentlinkops tools [TOOLSET]` | `GET /v1/commands` |
92
+ | describe | `exec: {"command": "describe monitor_link"}` | `agentlinkops describe NAME` | `GET /v1/commands/{name}` |
93
+ | drill one field | `exec: {"command": "schema monitor_link targetScope"}` | `agentlinkops schema NAME [PATH]` | `GET /v1/commands/{name}` |
94
+ | call | `exec: {"command": "call monitor_link {\"projectId\":\"pr_..\",\"sourceUrl\":\"https://..\",\"targetUrl\":\"https://..\"}"}` | `agentlinkops call NAME --args JSON` | `POST /v1/commands/{name}` |
95
+
96
+ `search` takes `--toolset` and `--limit`; `describe` takes `--output-schema`; `call` takes
97
+ `--json` for full rows instead of the concise projection. A `describe` or `schema` answer that
98
+ exceeds the context budget is summarized, and any field carrying a `hint` expands with
99
+ `schema <name> <field.path>`. Find with `search`, `describe` a name once, then `call` and
100
+ reuse the schema; an unknown name answers with the `search` that finds the current one.
71
101
 
72
102
  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.
103
+ MCP and code mode, detailed rows on REST and the CLI; pass `format` (`concise` or `detailed`),
104
+ or `call --json` through `exec`, to choose, and read `defaults_applied` to know which you got.
105
+ A retired command name still resolves to its canonical command for twelve months and the
106
+ description says so.
76
107
 
77
108
  ## Start a session
78
109
 
110
+ For a hosted task, inspect the connection and granted scopes first. The accountless local
111
+ check above needs no hosted session, repository, ledger or optional recipe.
112
+
79
113
  1. `get_workspace` with `{}`: limits, usage and membership. Needs `projects:read`, the default
80
114
  advertised scope; an authenticated connection alone does not grant more.
81
115
  2. `list_projects` with `{"limit": 20}`: the websites this credential can see. Take ids from
@@ -225,9 +259,9 @@ counts, superlatives or claims about other products beyond dated public statemen
225
259
  invitation-only hosted access; `cadenceSeconds` between 3600 and 2592000; no availability
226
260
  percentage is published.
227
261
  4. **Usage and exit are readable:** `get_usage` totals with a `check_budget` block;
228
- `export_link_watches` pages monitored placements out for local CRM recovery. Boundary:
229
- payment collection is not active in this build; the ledger and CRM stay in the user's own
230
- files.
262
+ `export_link_watches` pages monitored placements out for the person's records. Boundary:
263
+ payment collection is not active in this build; the person chooses where to keep their
264
+ campaign records.
231
265
 
232
266
  The human-readable version is
233
267
  [what your agent can prove to you](https://agentlinkops.com/what-your-agent-can-prove/).
@@ -235,16 +269,18 @@ The human-readable version is
235
269
  ## Tool reference
236
270
 
237
271
  <!-- generated:skill-tools begin (npm run generate:tools; source: src/tool-registry.js) -->
238
- 125 commands in 13 toolsets, generated from `src/tool-registry.js` (`npm run generate:tools`). Core on every view: `get_workspace`, `list_projects`, `list_link_watches`, `monitor_link`, `list_events`. The index verbs list the rest by name; `describe` is the source of truth for input schemas. Admission-gated today: request_competitor_inventory, request_domain_overview, discover_backlinks; these refuse before any persistence.
272
+ 139 commands in 15 toolsets, generated from `src/tool-registry.js` (`npm run generate:tools`). Core on every view: `get_workspace`, `list_projects`, `list_link_watches`, `monitor_link`, `list_events`. The index verbs list the rest by name; `describe` is the source of truth for input schemas. Admission-gated today: prepare_domain_baseline, start_domain_baseline, request_competitor_inventory, request_domain_overview, discover_backlinks; these refuse before any persistence.
239
273
 
240
274
  | Toolset | Commands | What it covers |
241
275
  | --- | --- | --- |
242
276
  | `monitoring` | 17 | Watch earned links and destination URLs: create, list, update, import, export, recheck. |
243
277
  | `evidence` | 17 | What a check observed: histories, snapshots, change feeds, check jobs, published contacts. |
278
+ | `rank` | 5 | Synthetic DataForSEO rank schedules and dated history; hosted collection disabled. |
244
279
  | `discovery` | 16 | Import candidate rows, read stored runs, verify selected candidates, enroll them. |
280
+ | `baseline` | 7 | Scoped backlink baselines; supplier admission disabled. |
245
281
  | `library` | 3 | Read and export the coverage-stated opportunity library. |
246
282
  | `competitors` | 17 | Competitor sets, dated inventories, scheduled refresh, gap and domain-mix reports. |
247
- | `reports` | 5 | Profile, anchor and report summaries over the tracked dataset. |
283
+ | `reports` | 7 | Profile, anchor and report summaries over the tracked dataset. |
248
284
  | `workspace` | 16 | Workspace, projects, usage, members, invitations, scratch-resource cleanup. |
249
285
  | `notifications` | 6 | Email notification preferences, previews, tests and deliveries. |
250
286
  | `webhooks` | 6 | Webhook endpoints, state, secrets and delivery records. |
@@ -260,11 +296,11 @@ AgentLinkOps grants no access to the mailbox or local files, has no independent
260
296
  backlink index, authority metrics or email sending, does not browse sites for you and
261
297
  monitors supplied URLs. Finding prospects, campaign decisions, navigating sites and authorized
262
298
  outreach stay with the person's existing agent, research, browser and email tools; prospects,
263
- contacts, outreach references and earned placements stay together in their existing local
264
- CRM. The `agentlinkops-campaigns` and `agentlinkops-crm` skills cover those.
299
+ contacts, outreach references and earned placements remain in their chosen files, CRM or
300
+ other record system. The campaign and local CRM skills are optional guidance.
265
301
 
266
- With the installed ledger CLI, `agentlinkops connect --workspace WORKSPACE_ID --project-id
302
+ If the person chooses the ledger CLI, `agentlinkops connect --workspace WORKSPACE_ID --project-id
267
303
  PROJECT_ID --selection selected-links.json` saves only connection metadata and a selection of
268
304
  existing ledger ids; `sync --dry-run` previews uploads offline and `sync --pull-only`
269
305
  retrieves history without creating watches. Review the plan before authorizing `sync`. The
270
- Python CRM helper separately needs `AGENTLINKOPS_API_URL` and `AGENTLINKOPS_API_KEY`.
306
+ optional Python CRM helper separately needs `AGENTLINKOPS_API_URL` and `AGENTLINKOPS_API_KEY`.
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: agentlinkops-crm
3
3
  description: Manage the AgentLinkOps local backlink and outreach CRM, user-selected disavow lists, branded HTML reports and cloud event sync. Use for durable campaign records, link reporting or explicit disavow-list management; does not connect a mailbox, send outreach or upload disavow files.
4
- allowed-tools: Read Bash(python3 ../../scripts/agentlinkops.py *) Bash(node cli/agentlinkops.mjs *) Bash(agentlinkops *)
4
+ allowed-tools: Read Bash(python3 ../../scripts/agentlinkops.py *) Bash(python3 ../../scripts/outreach-crm-draft.py *) Bash(node cli/agentlinkops.mjs *) Bash(agentlinkops *)
5
5
  ---
6
6
 
7
- **The local surface is the ledger file, not this CRM.** `.agentlinkops/links.jsonl` in the user's repository (or a `.linktrail/` directory from an earlier install, read until `agentlinkops migrate` renames it) declares the links they expect, want and have earned, and `agentlinkops check` runs the exact cloud verifier against it without an account. Run the CLI as `node cli/agentlinkops.mjs` from the repository or plugin-package root (`cli/linktrail.mjs` still works and prints a one-line notice); the packaged plugin ships the CLI, its pinned verifier modules and their one dependency (parse5, MIT) inside the package, so `check` runs with no npm install and no network. It needs Node 22 or newer (`adopt` reads SQLite databases through `node:sqlite`); the Python helper below needs Python 3.10+. This CRM is one optional adapter beside CSV and JSONL: it is available, tested and unchanged, and nothing requires it. When a user already keeps links here, `agentlinkops adopt <db>` reads it into ledger entries — read-only, adding no schema — and previews unless `--write` is given. Do not propose the CRM as the place a user should start keeping links; propose it when they want contacts and outreach activity beside them, or when they already have one.
7
+ **For the optional bundled CLI, the local surface is the ledger file.** `.agentlinkops/links.jsonl` in the user's repository (or a `.linktrail/` directory from an earlier install, read until `agentlinkops migrate` renames it) declares the links they expect, want and have earned, and `agentlinkops check` runs the exact cloud verifier against it without an account. Run the CLI as `node cli/agentlinkops.mjs` from the repository or plugin-package root (`cli/linktrail.mjs` still works and prints a one-line notice); the packaged plugin ships the CLI, its pinned verifier modules and their pinned runtime dependencies inside the package, so the installed package needs no npm install; checking a publisher still requires a read-only network fetch. It needs Node 22 or newer (`adopt` reads SQLite databases through `node:sqlite`); the Python helper below needs Python 3.10+. This CRM is one optional adapter beside CSV and JSONL: it is available, tested and unchanged, and nothing requires it. When a user already keeps links here, `agentlinkops adopt <db>` reads it into ledger entries — read-only, adding no schema — and previews unless `--write` is given. Do not propose the CRM as the place a user should start keeping links; propose it when they want contacts and outreach activity beside them, or when they already have one.
8
8
 
9
9
  To record a link in the ledger: `agentlinkops add --source URL --target URL --intent expected [--anchor TEXT] [--ref ID] [--note TEXT]` (`--intent wanted` for a link not yet earned; `agentlinkops init` first in a repository without `.agentlinkops/`). `agentlinkops check` then verifies every expected link and exits 0 when all are present, 1 when one is observed absent with complete evidence, 2 on a usage or ledger error; an unknown never fails.
10
10
 
@@ -26,4 +26,18 @@ For recurring cloud changes, use the host's already connected AgentLinkOps tools
26
26
 
27
27
  Sync updates `cloud_state` on placements and preserves local status, notes and opportunity relationships. A fetch failure is not proof that a backlink disappeared. Keep `unknown`, `suspected_missing` and confirmed outcomes distinct. Read the new events and decide what action, if any, the user's agent should take; receipt of an event does not authorize sending an email.
28
28
 
29
- Use the [placement reconciliation recipe](../../references/recipes/placement-reconciliation.md) to reconcile an exact source and destination with retained evidence, checkpoints and an optional monitoring plan.
29
+ Recipes are optional. Choose `agentlinkops agent setup --recipe placement-reconciliation` before reading `../../references/recipes/placement-reconciliation.md` for an exact source and destination, retained evidence, checkpoints and optional monitoring. Removing the recipe preserves your local records and existing reconciliation process.
30
+
31
+ For a draft-only file from an existing frozen report, read [portable ledger
32
+ handoff](../../references/portable-ledger-handoff.md). It needs explicit customer
33
+ workspace/project/site and external-ID mappings, verifies the report hash and
34
+ refuses mismatched sites or conflicting output. It does not verify hosted grants,
35
+ import into a vendor or send messages. Keep replied, suppressed and unknown facts
36
+ separate from monitoring state; a saved file does not establish destination
37
+ readback or permission to send.
38
+
39
+ When the user selects an existing local Outreach CRM with `projects/<project>/drafts`,
40
+ use [local CRM draft handoff](../../references/local-crm-draft-handoff.md) for exact
41
+ body/manifest saving, interrupted-save recovery and readback. This adapter preserves
42
+ the complete original request and explicit site binding. It does not import contacts,
43
+ check current sender authority, activate sequences or provide a native vendor API.
@@ -256,7 +256,8 @@ export function createOpenAiCompatibleEngine({ baseUrl, model, apiKey, fetchImpl
256
256
  // overview linked to — the citation equivalent of a chat engine's source list.
257
257
  // The fixture below proves the parser against the documented response shape;
258
258
  // the live adapter activates with credentials in fleet, never before.
259
- export function createGoogleAioEngine({ fetchImpl, credentials, costEstimateUsd = 0.0012, locationCode = 2840, country, languageCode = 'en', device = 'desktop', timeoutMs = CITATION_LIMITS.timeoutMs, maxResponseBytes = CITATION_LIMITS.answerBytes } = {}) {
259
+ const GOOGLE_AIO_BASE_COST_USD = 0.002;
260
+ export function createGoogleAioEngine({ fetchImpl, credentials, costEstimateUsd = GOOGLE_AIO_BASE_COST_USD, locationCode = 2840, country, languageCode = 'en', device = 'desktop', timeoutMs = CITATION_LIMITS.timeoutMs, maxResponseBytes = CITATION_LIMITS.answerBytes } = {}) {
260
261
  const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
261
262
  const error = (code, message, details = {}) => Object.assign(new EngineError(message), { code, ...details });
262
263
  if (country !== undefined) {
@@ -390,6 +391,6 @@ function parseAioResponse(body, prompt) {
390
391
  citations,
391
392
  fanOut: [],
392
393
  usage: { input_tokens: 0, output_tokens: 0 },
393
- costEstimateUsd: body?.cost ?? 0.0012,
394
+ costEstimateUsd: body?.cost ?? GOOGLE_AIO_BASE_COST_USD,
394
395
  };
395
396
  }
@@ -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
  },
@@ -163,11 +217,26 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
163
217
  async function loadStorageState() {
164
218
  // Accountless measurement never opens session files, even when they exist.
165
219
  if (authentication === 'accountless') return undefined;
220
+ const failure = () => Object.assign(new EngineError('Saved browser session is unavailable or unsafe. Restore private session storage or run citation login again.'), { code: 'BROWSER_SESSION_INVALID' });
221
+ let handle;
166
222
  try {
167
- const { readFile } = await load('node:fs/promises');
168
- return JSON.parse(await readFile(join(sessionDir, `${engineName}.json`), 'utf8'));
169
- } catch {
170
- return undefined; // anonymous session: several surfaces work logged-out
223
+ const storage = await load('node:fs/promises');
224
+ const directory = await storage.lstat(sessionDir);
225
+ if (!directory.isDirectory() || (platform !== 'win32' && (directory.mode & 0o7777) !== 0o700)) throw failure();
226
+ const filename = join(sessionDir, `${engineName}.json`);
227
+ const before = await storage.lstat(filename);
228
+ if (!before.isFile() || (platform !== 'win32' && (before.mode & 0o7777) !== 0o600)) throw failure();
229
+ handle = await storage.open(filename, 'r');
230
+ const opened = await handle.stat();
231
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || (platform !== 'win32' && (opened.mode & 0o7777) !== 0o600)) throw failure();
232
+ const state = JSON.parse(await handle.readFile('utf8'));
233
+ if (!state || !Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw failure();
234
+ return state;
235
+ } catch (error) {
236
+ if (error.code === 'ENOENT') return undefined;
237
+ throw failure();
238
+ } finally {
239
+ try { await handle?.close(); } catch { throw failure(); }
171
240
  }
172
241
  }
173
242
 
@@ -220,10 +289,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
220
289
  estimateCostUsd: () => ENGINE_NOMINAL_COST[engineName] ?? DEFAULT_NOMINAL_COST,
221
290
 
222
291
  async run({ prompt }) {
292
+ const storageState = await loadStorageState();
223
293
  const config = await ensureProviderConfig();
224
294
  const { browser } = await ensureBrowser();
225
295
  admission.assertReady();
226
- const storageState = await loadStorageState();
227
296
  const context = await browser.newContext({
228
297
  storageState,
229
298
  locale: 'en-US',
@@ -318,12 +387,16 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
318
387
  await editor.locator.fill(prompt);
319
388
  });
320
389
  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
390
  await config.afterTypingHook?.(page);
326
391
  await config.beforeSubmitHook?.(page);
392
+ // Checked after the modal hooks, immediately before submit: the composer must hold
393
+ // the whole prompt. One fill is the recovery; otherwise the sample fails closed.
394
+ const entered = await ensurePromptEntered(prompt, {
395
+ readComposer: () => editor.locator.evaluate(node => (typeof node.value === 'string' ? node.value : node.innerText) || '').catch(() => ''),
396
+ fill: text => editor.locator.fill(text).catch(() => {}),
397
+ settle: () => page.waitForTimeout(400),
398
+ });
399
+ if (entered === 'filled') await event('prompt refilled after incomplete typing');
327
400
 
328
401
  // Submit: the surface's own send button when it exposes one; Enter fallback.
329
402
  // Allow the surface to settle; response extraction remains fail-closed.
@@ -336,15 +409,29 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
336
409
 
337
410
  await config.waitForResponse(page);
338
411
  if (!answer) answer = await config.extractResponse(page);
412
+ if (engineName === 'chatgpt') {
413
+ // The answer belongs to the prompt only if the submitted turn is the panel prompt.
414
+ const submitted = await page.evaluate(readChatgptSubmittedPrompt).catch(() => null);
415
+ if (typeof submitted !== 'string') await event('submitted prompt could not be read; not verified');
416
+ else if (normalizePrompt(submitted) !== normalizePrompt(prompt)) {
417
+ throw new EngineError('chatgpt: the submitted prompt differs from the panel prompt', { retriable: true });
418
+ }
419
+ }
420
+ if (engineName === 'chatgpt' && isChatgptFailureMessage(answer)) {
421
+ throw new EngineError('chatgpt: the surface returned an error message instead of an answer', { retriable: true });
422
+ }
339
423
  if (!answer || answer.trim().length < 40) {
340
424
  const reason = engineName === 'bing'
341
425
  ? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
342
426
  : `${engineName}: answer extracted empty`;
343
427
  throw new EngineError(reason, { retriable: true }); }
344
428
  let extractionFailed = config.citationExtraction === 'unsupported';
345
- const sources = await config.extractSources(page).catch(async () => {
429
+ const sources = await config.extractSources(page).catch(async cause => {
346
430
  extractionFailed = true;
347
- await event('sources extraction failed; observation retained as unknown');
431
+ // Tag and role counts only (see readChatgptCitationState); they say which layout
432
+ // change blocked the read without carrying page content.
433
+ const kinds = Object.entries(cause?.strayKinds ?? {}).map(([kind, count]) => `${kind}x${count}`).join(', ');
434
+ await event(`sources extraction failed; observation retained as unknown${kinds ? ` (unrecognized: ${kinds})` : ''}`);
348
435
  return [];
349
436
  });
350
437
  const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
@@ -384,17 +471,18 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
384
471
  let confirmed=true;
385
472
  try{if(browserHandle)confirmed=await browserHandle.cleanup();}
386
473
  finally{
387
- const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
388
- confirmed=confirmed&&displayClosed;displayHandle=null;browserHandle=null;
474
+ const displayClosed=await closeDisplay();
475
+ confirmed=confirmed&&displayClosed;browserHandle=null;
389
476
  }
390
477
  return confirmed;
391
478
  },
392
479
  };
393
480
  const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine);
394
- const policyCodes=new Set(['CITATION_EXTRACTION_UNAVAILABLE','BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']);
481
+ const policyCodes=new Set(['CITATION_EXTRACTION_UNAVAILABLE','BROWSER_AUTH_POLICY_INVALID','BROWSER_SESSION_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']);
395
482
  const safeError=cause=>{
396
483
  const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED';
397
- const error=Object.assign(new EngineError(code==='CITATION_EXTRACTION_UNAVAILABLE'?'Citation binding or extraction was unavailable.':code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
484
+ const message=code==='BROWSER_SESSION_INVALID'?'Saved browser session is unavailable or unsafe. Restore private session storage or run citation login again.':code==='CITATION_EXTRACTION_UNAVAILABLE'?'Citation binding or extraction was unavailable.':code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.';
485
+ const error=Object.assign(new EngineError(message,{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code});
398
486
  if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
399
487
  return error;
400
488
  };