trackly-cli 0.14.0 → 0.14.2

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  [![npm](https://img.shields.io/npm/v/trackly-cli.svg)](https://www.npmjs.com/package/trackly-cli)
2
2
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
- [![Node 20+](https://img.shields.io/badge/node-20+-brightgreen.svg)](https://nodejs.org/)
3
+ [![Node 20.20+ or 22.22+](https://img.shields.io/badge/node-20.20%2B%20%7C%2022.22%2B-brightgreen.svg)](https://nodejs.org/)
4
4
  [![MCP Server](https://img.shields.io/badge/MCP-Server-blue.svg)](https://modelcontextprotocol.io/)
5
5
 
6
6
  # trackly-cli
@@ -31,11 +31,11 @@ trackly login
31
31
  trackly jobs --function product
32
32
  ```
33
33
 
34
- > **Prerequisites:** [Node.js 20+](https://nodejs.org/) (LTS recommended). On macOS with the official `.pkg` installer, global npm installs may require `sudo`.
34
+ > **Prerequisites:** [Node.js 20.20+ or 22.22+](https://nodejs.org/) (LTS recommended). On macOS with the official `.pkg` installer, global npm installs may require `sudo`.
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 3,800+ companies | 170K+ jobs | 40+ ATS types | CLI + MCP | 54 local MCP tools
38
+ 3,800+ companies | 170K+ jobs | 40+ ATS types | CLI + MCP | 55 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -215,6 +215,7 @@ Then use natural language in any of these clients:
215
215
  | trackly_record_application_outcomes | Bulk-record up to 20 leased batch outcomes with per-member conflicts |
216
216
  | trackly_prepare_resume | Prepare a private expiring resume file for upload |
217
217
  | trackly_verify_prepared_resume | Recheck the confirmed resume immediately before attachment |
218
+ | get_more_tools | Report a missing capability so Trackly can improve its MCP surface |
218
219
 
219
220
  ## Authentication
220
221
 
@@ -280,7 +281,7 @@ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
280
281
  | Job search + filters | Yes | Yes | Yes |
281
282
  | Apply/save/dismiss | Yes | Yes | Yes |
282
283
  | AI-powered search | Yes (trackly ask) | Yes | Yes |
283
- | MCP integration | Yes (54 local tools) | -- | -- |
284
+ | MCP integration | Yes (55 local tools) | -- | -- |
284
285
  | Browser required | No | Yes | No |
285
286
  | Best for | Terminal + AI agents | Visual browsing | Custom integrations |
286
287
 
@@ -294,7 +295,18 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
294
295
 
295
296
  **What MCP servers exist for job searching?**
296
297
 
297
- trackly-cli includes a built-in MCP server with 54 tools for job search, company lookup, discovery preferences, application tracking, accessible execution and frozen-batch orchestration, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
298
+ trackly-cli includes a built-in MCP server with 55 tools: the complete Trackly
299
+ job-search and application set plus `get_more_tools`, which lets agents report
300
+ a missing capability. Run `trackly mcp` or use
301
+ `trackly agent setup --client claude`.
302
+
303
+ MCP usage analytics are on by default and relay through Trackly so the free
304
+ service can be debugged and improved. They exclude résumé text, profile answers,
305
+ demographic or work-authorization answers, and application notes. Turn off future
306
+ collection with **Share usage analytics** in Account Settings. Operators may also
307
+ disable local MCP instrumentation with `TRACKLY_MCP_ANALYTICS_DISABLED=1` (or
308
+ `TRACKLY_MCP_ANALYTICS_ENABLED=0`); the account setting remains authoritative
309
+ for authenticated backend capture.
298
310
 
299
311
  **How do I use Claude Code for job hunting?**
300
312
 
package/bin/trackly CHANGED
@@ -4,7 +4,6 @@
4
4
  const {
5
5
  DEFAULT_BASE_URL,
6
6
  apiRequest,
7
- clearConfig,
8
7
  createTracklyAccessError,
9
8
  getApiKey,
10
9
  getAuthMethod,
@@ -13,6 +12,7 @@ const {
13
12
  hasAuth,
14
13
  maintenanceOutput,
15
14
  loadConfig,
15
+ removeCredentials,
16
16
  saveConfig,
17
17
  } = require('../lib/client');
18
18
  const { color, isJSON, outputJobs, outputCompanies, outputStats, outputContacts, outputReferralCampaign, outputNetworkBrief, outputCompanyBrief, outputCompanyWorkspace, outputError } = require('../lib/formatters');
@@ -544,7 +544,7 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
544
544
  }
545
545
 
546
546
  async function cmdLogout() {
547
- clearConfig();
547
+ await removeCredentials({ scope: 'all' });
548
548
  console.log(color('green', 'Logged out. Credentials cleared.'));
549
549
  }
550
550
 
@@ -1055,11 +1055,12 @@ async function cmdConfig(args) {
1055
1055
  failNow('Cannot use --base-url and --clear-base-url together.');
1056
1056
  }
1057
1057
 
1058
- const config = loadConfig();
1058
+ let config = loadConfig();
1059
1059
  let updated = false;
1060
1060
 
1061
1061
  if (args['clear-api-key']) {
1062
- delete config.apiKey;
1062
+ await removeCredentials({ scope: 'apiKey' });
1063
+ config = loadConfig();
1063
1064
  updated = true;
1064
1065
  console.log(color('green', 'Stored API key cleared.'));
1065
1066
  }
@@ -1123,8 +1124,10 @@ async function cmdVersion() {
1123
1124
  console.log(VERSION);
1124
1125
  }
1125
1126
 
1126
- async function cmdMcp() {
1127
- await require('../mcp/server').startMcpServer();
1127
+ async function cmdMcp(options = {}) {
1128
+ const mcpServer = options.mcpServer || require('../mcp/server');
1129
+ const server = await mcpServer.startMcpServer(options.startOptions);
1130
+ return mcpServer.installMcpSignalHandlers(server, options.signalOptions);
1128
1131
  }
1129
1132
 
1130
1133
  async function cmdAgent(args) {
@@ -1504,6 +1507,7 @@ if (require.main === module) {
1504
1507
  module.exports = {
1505
1508
  COMMAND_FLAGS,
1506
1509
  UNIVERSAL_FLAGS,
1510
+ cmdMcp,
1507
1511
  cmdLogin,
1508
1512
  formatMaintenanceForHuman,
1509
1513
  getPublicConfig,
@@ -49,9 +49,9 @@ None of these receipts implies another.
49
49
  - R1. Trackly must persist one cumulative completion credit per execution candidate after the candidate first reaches durable manual review or a valid submitted-state repair.
50
50
  - R2. A credited candidate must continue counting after manual submission; submitting it must not create replacement capacity.
51
51
  - R3. Execution status, raw cumulative achievement count, target-capped `completed`, current ready/submitted counts, reservations, `targetReached`, and `nextAction` must come from one transactionally consistent state.
52
- - R4. Trackly must recover an exact set of eligible members from one prior terminal or expired execution without changing queue recency or selecting newer jobs.
53
- - R5. Exact recovery must assert the requested source membership, report each member's eligibility, create no substitutes, reuse valid run identity, and require a fresh browser binding and inspection epoch.
54
- - R6. Exact recovery must reject foreign-user, revoked, changed-requisition, duplicate, and concurrently owned members. Already-applied members are reconciliation-only and inactive or access-blocked members remain typed non-counting dispositions.
52
+ - R4. Trackly must recover the exact confirmed set from one prior terminal or expired execution only when every confirmed member is eligible, without changing queue recency or selecting newer jobs; otherwise it must create no recovery execution.
53
+ - R5. Exact recovery must assert the requested source membership, evaluate and report each member's eligibility without partial creation, create no substitutes, reuse valid run identity, and require a fresh browser binding and inspection epoch. The KTD3 addendum below supersedes the original partial-eligibility model: any ineligible confirmed member rejects the whole set and creates no recovery execution.
54
+ - R6. Exact recovery must reject foreign-user, revoked, changed-requisition, duplicate, and concurrently owned members. Already-applied members are reconciliation-only, not recovery-eligible; including one in the confirmed set must fail the request atomically. Reconcile that member through its review-handoff/outcome path, and recover any still-eligible sibling only through a separately confirmed exact set. An inactive member likewise makes the confirmed exact set ineligible. Authentication, account creation, OTP, CAPTCHA, and other runtime access barriers are not pre-creation eligibility facts; discover and persist them afterward as typed, non-counting live dispositions.
55
55
  - R7. Tab restoration, form restoration, and mutation authority must be exposed as separate value-free recovery facts.
56
56
 
57
57
  **Review and submission reconciliation**
@@ -84,8 +84,8 @@ None of these receipts implies another.
84
84
  - F1. Exact interrupted-member recovery
85
85
  - **Trigger:** A prior execution expired or ended after tabs or form state were lost.
86
86
  - **Actors:** User, Trackly backend, MCP client, browser harness.
87
- - **Steps:** The client lists bounded backend-owned recoverable source summaries when local identity is absent; the user confirms the exact candidate set; the client requests those source candidates; the backend validates the immutable source set and returns eligibility; the backend creates one exact recovery execution; the harness verifies returned membership; the harness obtains fresh browser and mutation receipts; the harness rehydrates only blank or agent-owned controls.
88
- - **Outcome:** Only the requested eligible jobs become mutable. Queue ordering is unchanged.
87
+ - **Steps:** The client lists bounded backend-owned recoverable source summaries when local identity is absent; the user confirms the exact candidate set; the client requests those source candidates; before creation, the backend validates the immutable source set and confirms that every requested candidate is eligible; only then does the backend create one exact recovery execution; the harness verifies returned membership; the harness obtains fresh browser and mutation receipts; the harness rehydrates only blank or agent-owned controls.
88
+ - **Outcome:** Either the full confirmed set becomes mutable or no recovery execution is created. Queue ordering is unchanged.
89
89
  - **Covered by:** R4-R7, R18, R22-R23.
90
90
 
91
91
  - F2. Cumulative two-job completion
@@ -132,15 +132,15 @@ None of these receipts implies another.
132
132
 
133
133
  - AE3. **Exact recovery with newer jobs present**
134
134
  - **Covers:** R4-R7.
135
- - **Given:** Two expired source members and newer Check Later jobs exist.
135
+ - **Given:** Two eligible expired source members and newer Check Later jobs exist.
136
136
  - **When:** The user requests recovery of those two source members.
137
- - **Then:** Only the eligible requested jobs enter the recovery execution and queue timestamps remain unchanged.
137
+ - **Then:** Both requested jobs, and no others, enter the recovery execution; queue timestamps remain unchanged.
138
138
 
139
139
  - AE4. **Mixed exact recovery set**
140
140
  - **Covers:** R5-R7, R13.
141
141
  - **Given:** One source member is applied, one is recoverable, and one is inactive.
142
142
  - **When:** Recovery is requested for all three.
143
- - **Then:** Identity assertion covers all three, per-member results explain each disposition, and the recovery execution contains only the recoverable member with no replacement.
143
+ - **Then:** Identity assertion and per-member eligibility evaluation cover all three, the request fails atomically, and no recovery execution or replacement is created.
144
144
 
145
145
  - AE5. **Ashby same-route success**
146
146
  - **Covers:** R9-R12, R21.
@@ -209,7 +209,9 @@ None of these receipts implies another.
209
209
 
210
210
  - KTD1. **Persist completion credit in an append-only achievement ledger.** (session-settled: user-approved — chosen over concurrent unsubmitted inventory: once a job is ready or submitted it remains part of the requested total.) Create one value-free credit per `(execution_id, user_id, frozen_job_id)`, constrained to reviewed sources such as `review_ready` and `submitted_repair`. Insert the credit in the same transaction as the durable outcome. Expose `achievementCount` as the raw unique effective-credit count and compute target-facing `completed = LEAST(achievementCount, target)`. Credits are the sole target-achievement input; current ready/submitted counts remain operator projections. Do not add mutable columns to immutable execution candidates. A credit later proven to originate from invalid evidence may be neutralized only by an append-only compensating revocation through an audited internal repair operation; later submission or lifecycle change is never a revocation reason. Governs R1-R3.
211
211
  - KTD2. **Characterize the live state sequence before changing accounting.** Current unit tests already cover `ready + submitted`, so implementation begins with a Postgres-backed checkpoint, certification, bulk-outcome, submission, and progress-read reproduction. A passing unit calculator test is not sufficient evidence. Governs R1-R3, R8.
212
- - KTD3. **Model exact recovery as a new execution mode over stable source candidates.** Use `recover_exact_members` with `sourceExecutionId`, exact execution-candidate IDs proven inside the source snapshot hash, optimistic revision, and idempotency key. Persist `source_execution_id`, `source_candidate_id`, and the attempt-specific `source_member_id` when present. The identity assertion is atomic; eligibility may be partial, but the server creates no substitutes. Governs R4-R7.
212
+ - KTD3. **Model exact recovery as a new execution mode over stable source candidates.** The original August 5 decision used `recover_exact_members` with `sourceExecutionId`, exact execution-candidate IDs proven inside the source snapshot hash, optimistic revision, and idempotency key. It persisted `source_execution_id`, `source_candidate_id`, and the attempt-specific `source_member_id` when present; identity assertion was atomic, eligibility could be partial (superseded—see the shipped-contract addendum immediately below), and the server created no substitutes. Governs R4-R7.
213
+
214
+ **Shipped-contract addendum (2026-08-13):** Backend implementation and adversarial review superseded partial eligibility with an atomic all-or-nothing eligibility check. If any confirmed candidate is no longer eligible, the server creates no recovery execution and no substitutes. AE4 and U4 below reflect this shipped contract while the original decision remains recorded here.
213
215
  - KTD4. **Restrict initial recovery to one source execution.** This keeps authorization, ordering, and historical semantics bounded. Arbitrary job IDs or cross-execution recovery remain deferred. Governs R4-R6.
214
216
  - KTD5. **Reuse the existing evidence and outcome repair path behind a typed local detection boundary.** Provider recognition produces a local `DetectedSubmission` containing provider strategy version, handoff/member identity, inspection epoch, bounded evidence type, confidence, and a value-free fingerprint. The fingerprint is a canonical digest only of provider strategy version, bounded evidence type, backend-owned handoff/member identity, and inspection epoch; DOM text, field values, URLs, paths, filenames, and raw browser identifiers are prohibited inputs. Group reconciliation yields one of `detected`, `user_confirmed`, `unresolved`, or `contradictory` per member. Only the existing typed evidence/outcome path writes backend state; page text stays local. Governs R10-R12, R21.
215
217
  - KTD6. **Create a first-class execution-owned review-handoff receipt.** Truth attestations remain batch-scoped certification evidence, but they cannot safely own a visible handoff spanning replacement waves. Persist an ordered set of `{batchId, memberId, runId, memberVersion, inspectionEpoch}`, its hash, and lifecycle `active | partially_reconciled | resolved | superseded | expired`. Creating a new receipt may supersede an older unresolved receipt only when the new ordered set contains every unresolved older member; otherwise both remain addressable and an unqualified group phrase is ambiguous. The first group-language reconciliation atomically claims the receipt under the outcome request idempotency key and stores the exact per-member result; retries return that result. Later confirmation must name the receipt and its unresolved members. A receipt becomes partially reconciled after a strict subset reaches durable submission, resolved after all members do, and expired only after its bounded handoff window. Group phrases resolve to an explicit receipt ID or to the sole unambiguous active/partially reconciled receipt. Governs R9-R10.
@@ -333,9 +335,9 @@ The backend owns durable identity, membership, milestones, and reconciliation. T
333
335
  - **Dependencies:** U2-U3.
334
336
  - **Repositories:** `close-ai`.
335
337
  - **Files:** `src/services/application-profile/apply-execution-contract.ts`, `src/services/application-profile/batch-service.ts`, `src/services/application-profile/service.ts`, `src/routes/trackly-apply.ts`, `src/mcp/server.ts`, execution service/route/MCP contract tests.
336
- - **Approach:** Add an authenticated discovery read returning only the user's recoverable execution IDs, snapshot hashes, stable candidate IDs, bounded lifecycle/eligibility codes, and expiry. It must not return profile values, browser data, or local paths. After explicit exact-set confirmation, create recovery with source execution, stable execution-candidate list, source snapshot hash, and idempotency key. Validate ownership and no competing mutable execution under the documented lock order. Return eligibility for the asserted source set, preserve source execution/candidate/member lineage, create one immutable non-replenishing recovery execution for eligible candidates, and require fresh epochs and browser bindings. Separate operational expiry from a bounded recovery-retention boundary so the nightly purge cannot erase eligible identity mid-window.
337
- - **Test scenarios:** Complete local context loss followed by discovery; cross-user discovery rejection; explicit exact-set confirmation; newer queue candidates present; saved timestamps unchanged; mixed eligible/applied/inactive set; duplicate IDs; changed requisition; foreign user; concurrent identical recovery; concurrent different recovery; old evidence rejected; second interruption; recovery immediately before and after operational expiry; recovery after daily purge but before `recoverable_until`; rejection after retention expiry.
338
- - **Verification:** AE3-AE4 pass and the response membership hash exactly matches the eligible subset of the asserted source set.
338
+ - **Approach:** Add an authenticated discovery read returning only the user's recoverable execution IDs, snapshot hashes, stable candidate IDs, bounded lifecycle/eligibility codes, and expiry. It must not return profile values, browser data, or local paths. After explicit exact-set confirmation, create recovery with source execution, stable execution-candidate list, source snapshot hash, and idempotency key. Validate ownership and no competing mutable execution under the documented lock order. Evaluate eligibility for the entire asserted source set before creation; if any confirmed candidate is ineligible, fail atomically without creating an execution or substituting another candidate. When all are eligible, preserve source execution/candidate/member lineage, create one immutable non-replenishing recovery execution for the exact confirmed set, and require fresh epochs and browser bindings. Separate operational expiry from a bounded recovery-retention boundary so the nightly purge cannot erase eligible identity mid-window.
339
+ - **Test scenarios:** Complete local context loss followed by discovery; cross-user discovery rejection; explicit exact-set confirmation; newer queue candidates present; saved timestamps unchanged; wholly eligible set; mixed eligible/applied/inactive set rejected with no execution; duplicate IDs; changed requisition; foreign user; concurrent identical recovery; concurrent different recovery; old evidence rejected; second interruption; recovery immediately before and after operational expiry; recovery after daily purge but before `recoverable_until`; rejection after retention expiry.
340
+ - **Verification:** AE3-AE4 pass; AE4 explicitly proves that an ineligible confirmed member yields zero recovery executions, and every execution created by an eligible case has a response membership hash that exactly matches the full asserted source set.
339
341
 
340
342
  ### U5. Publish protocol and MCP recovery parity
341
343
 
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Durable exact recovery after browser state loss
3
+ date: 2026-08-13
4
+ category: integration-issues
5
+ module: Trackly Apply durable recovery
6
+ problem_type: integration_issue
7
+ component: assistant
8
+ symptoms:
9
+ - Restored browser tabs had lost unsaved application-form values after a browser or laptop restart
10
+ - A Zoox submission attempt reached ERR_FILE_NOT_FOUND without evidence identifying the failed upload stage or cause
11
+ - Earlier browser observations could appear valid even though they belonged to an obsolete inspection epoch
12
+ - Authentication and pre-form challenge pages risked being mistaken for completed accessible applications
13
+ root_cause: missing_workflow_step
14
+ resolution_type: code_fix
15
+ severity: high
16
+ related_components: [service_object, tooling, development_workflow]
17
+ tags: [trackly-apply, exact-recovery, browser-restart, inspection-epoch, manual-submit, durable-handoff]
18
+ ---
19
+
20
+ # Durable exact recovery after browser state loss
21
+
22
+ ## Problem
23
+
24
+ Trackly Apply previously had no complete recovery contract for the gap between durable execution state and ephemeral browser state. A laptop restart, browser relaunch, renderer crash, lost automation connection, or missing local upload artifact could leave a server execution intact while destroying the tab handle, DOM snapshot, unsaved employer-form values, field provenance, or upload handle that had authorized the next mutation.
25
+
26
+ The shipped recovery contract separates those layers. Exact recovery restores only a user-confirmed, server-retained candidate lineage; the agent must independently restore the browser surface, revalidate or reconstruct the form, and reacquire current mutation authority before writing anything.
27
+
28
+ ## Symptoms
29
+
30
+ - Session evidence from the August 4–5 Apply run: after Chrome relaunched, previously filled Ontic and Lila forms appeared empty even though their tabs had been restored. The tab list survived, but the unsaved application DOM did not.
31
+ - Session evidence from the same run: a Zoox submission attempt navigated to Chrome's `ERR_FILE_NOT_FOUND`. The failed upload stage and underlying cause were not captured, so the cause remains unknown.
32
+ - A tab could remain visible to the user while disappearing from the automation controller, or the controller could retain a stale handle after navigation or a renderer restart. Application identity must not be inferred from window position, a transient tab number, or title text alone (`skills/trackly-apply/references/browser-lifecycle.md`, opening identity rules).
33
+ - Earlier field checks, upload evidence, and review receipts could look plausible after recovery while belonging to an obsolete inspection epoch (`skills/trackly-apply/references/batch-orchestration.md`, “Exact recovery after local context loss”).
34
+ - Authentication, account creation, OTP, and pre-form CAPTCHA pages could be mistakenly counted toward the review-ready target even though no safely accessible form existed. These classifications consume no completed slot (`skills/trackly-apply/references/batch-orchestration.md`, “Parent execution and child waves”).
35
+
36
+ ## What Didn't Work
37
+
38
+ ### Choosing external Chrome or the in-app browser as the persistence strategy
39
+
40
+ Neither browser is a durable store. External Chrome can restore tabs without restoring page-local draft state; an in-app renderer or its automation connection can also restart or lose its controller inventory. Changing browser hosts may improve control availability, but it cannot make DOM state, tab handles, file chooser handles, or local upload paths authoritative.
41
+
42
+ ### Treating tab presence as proof of saved form state
43
+
44
+ A restored URL proves only that a page can be reopened. It does not prove that the employer saved the draft, that the same requisition is loaded, that user edits survived, or that Trackly still authorizes mutation. Recovery therefore requires three separate proofs: tab restoration, form-state restoration or safe reconstruction, and current mutation authority (`skills/trackly-apply/references/browser-lifecycle.md`, “Missing-tab recovery”).
45
+
46
+ ### Reusing cached resume paths
47
+
48
+ Regardless of the unclassified Zoox failure's cause, an earlier local path cannot be reused as attachment evidence. A prepared artifact may expire, move, or be deleted while the browser still displays an old reference. The supported sequence verifies the prepared resume immediately before attachment, checks the employer-facing committed filename, and rechecks parser-sensitive fields (`skills/trackly-apply/references/browser-upload.md`, “Capability gate”).
49
+
50
+ ### Using generic advancement or replacement after context loss
51
+
52
+ Generic “continue with the next job” behavior is wrong for exact recovery because it can substitute newly saved or similar jobs for the user's original applications. Fixed membership may not be replaced, rescored, or expanded (`skills/trackly-apply/references/batch-orchestration.md`, “Freeze before browser work”). Recovery must use one retained source snapshot and exactly the candidates the user confirmed.
53
+
54
+ ### Refilling from profile data without provenance
55
+
56
+ Blind autofill can overwrite a correction the user made after the agent's last observation. After ledger loss, every unexplained non-empty field becomes `unknown_external_change` and must be preserved; only safely empty or still agent-owned fields may be filled (`skills/trackly-apply/references/browser-lifecycle.md`, “Local tab ledger”).
57
+
58
+ ## Solution
59
+
60
+ The coordinated durable recovery work shipped through [trackly-cli PR #100](https://github.com/trackly-app/trackly-cli/pull/100), which enforces exact-set discovery and response validation in the CLI/MCP/skill layer, and [close-ai PR #1391](https://github.com/trackly-app/close-ai/pull/1391), whose backend implementation owns atomic all-or-nothing recovery creation.
61
+
62
+ ### 1. Discover a bounded, value-free recovery menu
63
+
64
+ Before discovery, call `trackly_get_apply_protocol` and require the advertised CLI, skill, MCP-contract, rollout, and browser capabilities to be compatible with exact recovery. If they are incompatible or exact recovery is disabled, stop without mutation; retained older work may use only the legacy recovery path explicitly published by that protocol (`skills/trackly-apply/SKILL.md`, “Preflight”). Then call `trackly_get_active_apply_execution`. When it returns `active: true`, reclaim that execution and its unresolved waves instead of listing or creating terminal exact recovery. Only when it proves there is no active execution may the compatible server's retained terminal lineage be listed with `trackly_list_recoverable_apply_executions`. The response exposes stable source execution IDs, snapshot hashes, candidate IDs, job IDs, queue positions, and eligibility codes; duplicate source and candidate identities are rejected (`mcp/apply-tools.js`, `recoverableCandidateSchema` and `recoverableExecutionsResponseSchema`). Resolve each job through Trackly and present the exact company, role, requisition identity when available, and source execution. Never reconstruct identity from chat, tab order, page copy, or search results (`skills/trackly-apply/references/batch-orchestration.md`, “Exact recovery after local context loss”).
65
+
66
+ ### 2. Require explicit confirmation of one exact set
67
+
68
+ Call `trackly_recover_exact_apply_members` only with one discovered source execution, that source's snapshot hash, unique confirmed candidate IDs, `explicitExactSetConfirmation: true`, and a fresh idempotency key. Before the write, the CLI rejects an undiscovered source, changed snapshot hash, or candidate outside the latest discovery menu (`mcp/apply-tools.js`, `trackly_recover_exact_apply_members` registration).
69
+
70
+ Recovery is all-or-nothing at the client boundary. The response validator requires `assertedCandidateIds`, `eligibleCandidateIds`, and every eligibility row to equal the requested set exactly, without duplicates or non-recoverable results (`mcp/apply-tools.js`, `validateExactRecoveryResponse`). This prevents partial recovery, substitution, and replenishment. Frozen order and membership remain server-owned.
71
+
72
+ ### 3. Restore browser state and authority independently
73
+
74
+ Recovery creation does not grant a usable batch lease. First consume the recovery response's authoritative `progress` and obey `progress.nextAction`; stop or perform the specified non-browser action when the directive is blocking or terminal, and claim browser work only when that directive makes it actionable. Then consume every actionable entry in the recovered execution's `unresolvedWaves` in ascending `waveOrder`; `currentWave` is only the latest scheduling identity, never the complete recovery set. For each actionable unresolved wave, page only its exact linked batch with `trackly_get_apply_batch`, then acquire or renew ownership with `trackly_claim_apply_batch` using the latest revision. Use only the member/version/epoch/lease binding returned by that claim (`skills/trackly-apply/SKILL.md`, “Start every run”; `skills/trackly-apply/references/batch-orchestration.md`, “Backend tool sequence”).
75
+
76
+ Branch on each claimed recovered member's run binding. If `runId` is absent, call `trackly_start_apply_run` with that exact current binding. Stop without browser binding or private-data mutation when the returned run has a non-null `executionBlocker`; otherwise call `trackly_bind_apply_surface` for the returned run with `initial_binding` and its current batch/member/version/epoch/lease plus browser-binding inputs. If `runId` already exists, first verify that run's published protocol remains compatible with the negotiated exact-recovery contract, then reuse it and, after the same non-null `executionBlocker` stop gate, call `trackly_bind_apply_surface` with `recovery_binding` for every recovered surface, including a still-visible, reopened, or reclaimed exact tab. Never start a replacement run merely because browser control was interrupted. The returned member version and inspection epoch are authoritative, and prior-epoch evidence is invalid (`skills/trackly-apply/SKILL.md`, “Start every run”; `skills/trackly-apply/references/browser-lifecycle.md`, “Reconciliation”). Execute the run's published origin policy, then revalidate HTTPS origin, employer, role, requisition, job identity, semantic controls, and ATS tenant only when that policy makes a separate tenant applicable; `trackly_employer_source_exact_origin` does not require one. Reinventory the entire form because a draft must never be assumed to have survived a crash or handoff (`skills/trackly-apply/references/browser-lifecycle.md`, “Missing-tab recovery”).
77
+
78
+ The durable operator receipt must separately report whether the exact tab was restored, whether employer form state was restored or safely reconstructed, and whether current mutation authority was reacquired. It must also carry completed work, unresolved members, the user's exact next action, the exact resume phrase or observable event that unblocks it, the agent's next action, each browser surface, and the authoritative execution funnel. A missing original tab may be reopened from the exact backend URL; mutation requires the resulting form to be restored or safely reconstructed and current authority to be reacquired, while the tab-restoration fact remains independently reported.
79
+
80
+ Before each claimed batch's first recovered form mutation, offer that exact batch's optional receipt-deduplication check once, keyed by its immutable batch ID and membership hash. Declining or lacking an approved inbox connector never blocks browser work. If the user opts in and a receipt matches, resolve its exact requisition and submission evidence through the documented confirmation and outcome path before mutating that affected member; never infer a duplicate from employer name alone or let the optional check expand the frozen set (`skills/trackly-apply/SKILL.md`, “Start every run”).
81
+
82
+ Before any recovered form write, prove the adapter can preserve every target tab through either a usable session finalizer with a complete controller-owned plus user-owned inventory union, or a documented per-tab durable-handoff primitive with a verifiable persistence receipt. If neither preservation path works end to end, stop before private-data mutation. Then report a committed same-run `browser_ready` observation for every current run that will proceed, using its exact current batch, member, inspection epoch, browser surface, and browser-binding hash. Stop the affected run without private-data mutation if Trackly does not durably accept that readiness attestation; this gate applies whether or not the form has a Resume/CV control (`skills/trackly-apply/SKILL.md`, “Start every run”; `skills/trackly-apply/references/browser-lifecycle.md`, “Preserving browser state at turn end”).
83
+
84
+ ### 4. Park access barriers before form mutation
85
+
86
+ Classify the entire current wave with the typed value-free set: `accessible`, `authentication_required`, `account_creation_required`, `otp_required`, `captcha_before_form`, `captcha_at_submit`, `manual_only`, or `unknown_unobservable` (`skills/trackly-apply/references/batch-orchestration.md`, “Parent execution and child waves”). Record every live result through `trackly_record_apply_execution_dispositions` with the exact current-wave job, batch, member, run, member-version, inspection-epoch, and browser-surface binding. Do not begin provenance inventory, resume preparation, attachment, or any other form mutation for an accessible sibling until the complete current probe set is durably accepted. Authentication, account creation, OTP, and pre-form CAPTCHA consume no target slot. A submit-time CAPTCHA can remain review-ready because the user owns the final action; the agent never solves, stores, or bypasses it (`skills/trackly-apply/references/batch-orchestration.md`, “Challenge placement”).
87
+
88
+ ### 5. Preserve user-owned and unknown values
89
+
90
+ Take a fresh semantic inventory before the first write. Maintain provenance by execution, run, inspection epoch, and semantic field fingerprint. Preserve `user_edited` values byte-for-byte. If the ledger was lost, classify every unknown non-empty value as `unknown_external_change` instead of refilling (`skills/trackly-apply/references/browser-lifecycle.md`, “Local tab ledger”). Resume parsing, rerendering, recovery, and final validation cannot overwrite those values (`skills/trackly-apply/SKILL.md`, “Fill the form”).
91
+
92
+ ### 6. Reverify the exact resume at attachment time
93
+
94
+ Before inspecting attachment controls, require the active browser adapter to advertise semantic control discovery; fail closed without inspecting or guessing when that capability is absent. Use it to discover whether the form has a real semantic Resume/CV attachment control. If it does not, skip the entire resume approval and upload sequence. For each current recovered run with a real Resume/CV control, call `trackly_prepare_resume` with that exact run ID, browser surface, and browser-binding hash, and let the user inspect the prepared file plus its exact resume ID, SHA-256, filename, size, and expiry. Reuse execution-scoped resume approval only when Trackly then proves the same prepared resume hash, profile revision, snapshot lineage, and an unexpired approval; otherwise obtain fresh approval for that verified prepared artifact and recovered execution. Bind confirmation to the current run and SHA-256. Then require chooser arming, file attachment, committed-filename inspection, and parser-field recheck. If approval, run-bound preparation and confirmation, or any capability is missing, fail closed and hand the upload to the user before opening a chooser. Arm and prove the discovered control's chooser, run `trackly_verify_prepared_resume` with the current prepare-issued proof, and immediately attach only that verified artifact. Verify the visible employer-facing filename and recheck every field the parser may have changed. Pass the six ordered value-free stage outcomes to `trackly_validate_apply_resume_upload`; stop or hand off unless it returns `safeToClaimAttachment: true` (`skills/trackly-apply/references/browser-upload.md`, “Capability gate”). An earlier approval proves which content the user authorized; it does not prove which bytes the current employer form received.
95
+
96
+ ### 7. Stop at manual review
97
+
98
+ Submit remains a hard human boundary. The skill forbids the agent from clicking Submit even when the user previously approved submission (`skills/trackly-apply/SKILL.md`, “Non-negotiable rules”). When a recovered form passes every review gate, checkpoint it with `review/manual_submit` and its exact current epoch, then obtain final truth certification for the exact complete ready subset with the correct `resumeDependency` branch: use `approved` plus the exact approved resume identity when any certified member depends on an attachment, or `not_applicable` with no resume ID/hash when no certified member has a Resume/CV control. Record literal `outcome: review_ready`, and verify every returned run is `awaiting_manual_submit` before presenting the handoff. Focus or reveal each exact review tab and prove current adapter-visible presentation or a current tab-bound handoff receipt; inventory membership alone is not visibility proof. If presentation cannot be proven, use the visibility-unverified handoff and do not instruct the user to Submit (`skills/trackly-apply/SKILL.md`, “Review handoff”; `skills/trackly-apply/references/browser-lifecycle.md`, “Visible review tabs”). After any grouped human submission statement, first call `trackly_list_apply_review_handoffs` and resolve the statement to the named receipt or sole active receipt; if more than one receipt could match, ask instead of guessing. For a fresh receipt, classify every member in its exact set and call `trackly_claim_apply_review_handoff` once. For a `partially_reconciled` receipt, preserve its immutable stored claim and classifications, skip every member already reconciled, and never issue a fresh claim for that receipt. In either branch, write submission evidence and outcomes only for unreconciled members classified `detected` or `user_confirmed`; ask the user once about `unresolved` members, and preserve `contradictory` members without a success write. Refetch every affected member and require both member lifecycle `submitted` and job state `applied_confirmed`; on conflict, preserve the tab, refresh once, and replay only the documented idempotent evidence/outcome sequence. If either durable state remains unproven, report the reconciliation defect and leave the tab open without claiming completion (`skills/trackly-apply/SKILL.md`, “Review handoff”; `skills/trackly-apply/references/review-handoff.md`, “Group submission reconciliation”).
99
+
100
+ At browser-turn end, execute—not merely advertise—the preservation path proven before mutation. For the session-finalizer path, immediately before finalization call `trackly_validate_apply_tab_keep_set` with the expected set, keep set, and complete controller-owned plus user-owned inventories; continue only when it returns `safeToFinalize: true`, then call the one finalizer with that validated keep set. Otherwise perform and verify the documented durable handoff for every live application tab. Require the finalizer result or every per-tab persistence receipt before reporting the recovered review handoff complete (`skills/trackly-apply/SKILL.md`, “Non-negotiable rules”; `skills/trackly-apply/references/browser-lifecycle.md`, “Preserving browser state at turn end”).
101
+
102
+ ## Why This Works
103
+
104
+ The solution places authority in records that survive a browser crash: the server-retained source execution, immutable snapshot hash, exact candidate identities, member versions, inspection epochs, and idempotent mutation receipts. Browser state remains evidence, never authority by itself.
105
+
106
+ Set equality closes substitution. Discovery establishes the eligible menu, explicit confirmation binds user intent to it, the pre-write cache rejects undiscovered candidates, and post-write validation requires every candidate collection to equal the request. A partially eligible response cannot be rationalized into a “close enough” recovery.
107
+
108
+ Inspection epochs close stale-evidence reuse. Field provenance protects user corrections. Point-of-use resume verification distinguishes authorized content from the file actually committed to the employer form. Parking access barriers and preserving manual Submit prevent recovery pressure from weakening authentication, anti-bot, or consent boundaries.
109
+
110
+ ## Prevention
111
+
112
+ - Model browser tabs, DOM snapshots, control handles, chooser handles, and local artifact paths as leases or observations, never durable state.
113
+ - Bind every exact-recovery member's sanctioned surface: use `initial_binding` only for a newly started run, and use `recovery_binding` whenever recovery reuses an existing run—even when its exact tab is still visible. After ordinary navigation or rerender outside recovery, retain the current binding and revalidate identity, form state, and the semantic DOM before mutation.
114
+ - Keep tab restored, form restored/reconstructed, and mutation authority reacquired as separate assertions in code, tests, and operator output.
115
+ - Preserve exact recovery as a dedicated operation; never route it through normal advancement, refill, replacement, or “next candidate” logic.
116
+ - Assert set equality and uniqueness on both sides of the mutation. Cover duplicate discovery identities, undiscovered candidates, changed snapshot hashes, partial eligible sets, substitutions, and non-recoverable rows.
117
+ - Treat every unexplained non-empty field as protected after provenance loss.
118
+ - Verify resume identity immediately before attachment, verify the committed filename immediately afterward, and re-snapshot parser-sensitive fields.
119
+ - Keep authentication, account creation, OTP, and pre-form CAPTCHA parked and value-free; never count or auto-resume them.
120
+ - Keep “Never Submit” in behavioral tests and handoff templates.
121
+
122
+ ## Related Issues
123
+
124
+ - [Trackly CLI PR #100](https://github.com/trackly-app/trackly-cli/pull/100) — CLI, MCP, and skill-side durable exact recovery contract.
125
+ - [Close AI PR #1391](https://github.com/trackly-app/close-ai/pull/1391) — backend durable recovery lineage and exact-member control plane.
126
+ - [Durable recovery implementation plan](../../plans/2026-08-05-001-trackly-apply-durable-recovery-reconciliation-plan.md) — historical design record aligned with the released all-or-nothing recovery contract.
@@ -72,6 +72,7 @@ API-key creation attempts.
72
72
 
73
73
  ### Available Tools
74
74
 
75
+ - **get_more_tools** — Report what the agent wanted to do when no Trackly tool fit, so Trackly can improve its MCP surface. The tool is always available; report delivery is best-effort and occurs only when usage analytics are enabled.
75
76
  - **trackly_search_jobs** — Search/filter jobs.
76
77
  - `function` (14 values): `product`, `engineering`, `design`, `data`, `marketing`, `sales`, `partnerships`, `finance`, `strategy`, `operations`, `people`, `legal`, `support`, `other`
77
78
  - `locationFilter`: pass ONE of
package/lib/client.js CHANGED
@@ -10,6 +10,7 @@ const { version: PACKAGE_VERSION } = require('../package.json');
10
10
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.trackly');
11
11
  const DEFAULT_BASE_URL = 'https://closeai.mba';
12
12
  const DEFAULT_HTTP_TIMEOUT_MS = 30000;
13
+ const ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS = 1500;
13
14
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
14
15
  const MAINTENANCE_CODE = 'maintenance_mode';
15
16
  const LEGACY_MAINTENANCE_CODE = 'planned_maintenance';
@@ -106,10 +107,180 @@ function saveConfig(config) {
106
107
  }
107
108
 
108
109
  function clearConfig() {
110
+ let config = {};
111
+ try {
112
+ const loaded = loadConfig();
113
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
114
+ } catch {
115
+ // Logout must still remove an invalid or unreadable credential file. The
116
+ // opt-out can be preserved only when the existing config is recoverable.
117
+ }
118
+ // A logout clears credentials and endpoint overrides, but the content-free
119
+ // analytics opt-out must continue suppressing anonymous MCP lifecycle events.
120
+ if (config.mcpAnalyticsOptOut === true) {
121
+ try {
122
+ saveConfig({ mcpAnalyticsOptOut: true });
123
+ return;
124
+ } catch {
125
+ // Preserving the marker is best-effort. A disk/write failure must never
126
+ // leave credentials behind, so fall through to unlinking the old file.
127
+ }
128
+ }
109
129
  const { file } = getConfigPaths();
110
130
  try { fs.unlinkSync(file); } catch (e) {}
111
131
  }
112
132
 
133
+ function isRecord(value) {
134
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
135
+ }
136
+
137
+ function hasAuthenticationInConfig(config) {
138
+ return Boolean(
139
+ (process.env.TRACKLY_API_KEY || '').trim()
140
+ || (isRecord(config) && (config.apiKey || config.token || config.refreshToken))
141
+ );
142
+ }
143
+
144
+ function configAfterCredentialRemoval(config, scope) {
145
+ const next = isRecord(config) ? { ...config } : {};
146
+ if (scope === 'all' || scope === 'apiKey') delete next.apiKey;
147
+ if (scope === 'all' || scope === 'oauth') {
148
+ delete next.token;
149
+ delete next.refreshToken;
150
+ }
151
+ return next;
152
+ }
153
+
154
+ async function resolveAnalyticsOptOutBeforeCredentialLoss(
155
+ request = apiRequest,
156
+ timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS
157
+ ) {
158
+ const controller = new AbortController();
159
+ let timeout;
160
+ try {
161
+ const preference = await Promise.race([
162
+ Promise.resolve().then(() => request(
163
+ 'GET',
164
+ '/api/jobscout/analytics-preference',
165
+ null,
166
+ false,
167
+ true,
168
+ null,
169
+ null,
170
+ { signal: controller.signal, timeoutMs }
171
+ )),
172
+ new Promise((_, reject) => {
173
+ timeout = setTimeout(() => {
174
+ controller.abort();
175
+ reject(new Error('analytics_preference_sync_timeout'));
176
+ }, timeoutMs);
177
+ }),
178
+ ]);
179
+ if (typeof preference?.shareUsageAnalytics !== 'boolean') {
180
+ throw new Error('analytics_preference_invalid');
181
+ }
182
+ return preference.shareUsageAnalytics === false;
183
+ } catch {
184
+ // Fail closed: if the authoritative preference cannot be read before the
185
+ // final credential disappears, suppress future anonymous MCP capture.
186
+ return true;
187
+ } finally {
188
+ clearTimeout(timeout);
189
+ }
190
+ }
191
+
192
+ async function synchronizeAnalyticsPreferenceBeforeLogout(
193
+ request = apiRequest,
194
+ options = {}
195
+ ) {
196
+ const timeoutMs = options.timeoutMs ?? ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS;
197
+ try {
198
+ if (!hasAuth()) return;
199
+ } catch {
200
+ // A corrupt or unreadable config must never prevent logout. clearConfig()
201
+ // owns the best-effort removal path and will run immediately afterward.
202
+ return;
203
+ }
204
+ const optedOut = await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
205
+ let config = {};
206
+ try {
207
+ const loaded = loadConfig();
208
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
209
+ } catch {
210
+ // Environment-provided authentication can still make the preference
211
+ // request valid when the on-disk config is corrupt. Replace only the
212
+ // recoverable analytics marker and let clearConfig() remove credentials.
213
+ }
214
+ if (optedOut) config.mcpAnalyticsOptOut = true;
215
+ else delete config.mcpAnalyticsOptOut;
216
+ try {
217
+ saveConfig(config);
218
+ } catch {
219
+ // Analytics synchronization is best-effort and must never block logout.
220
+ }
221
+ }
222
+
223
+ async function removeCredentials({
224
+ scope = 'all',
225
+ request = apiRequest,
226
+ timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS,
227
+ preferenceUnavailable = false,
228
+ } = {}) {
229
+ let config = {};
230
+ try {
231
+ const loaded = loadConfig();
232
+ if (isRecord(loaded)) config = loaded;
233
+ } catch {
234
+ // Corrupt or unreadable config still belongs to the best-effort removal
235
+ // path below. There is no safe credential state to preserve from it.
236
+ }
237
+
238
+ let next = configAfterCredentialRemoval(config, scope);
239
+ const removesEphemeralCredential = scope === 'all'
240
+ && Boolean((process.env.TRACKLY_API_KEY || '').trim());
241
+ const removesFinalCredential = removesEphemeralCredential
242
+ || (hasAuthenticationInConfig(config) && !hasAuthenticationInConfig(next));
243
+
244
+ if (removesFinalCredential) {
245
+ const optedOut = preferenceUnavailable
246
+ ? true
247
+ : await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
248
+ if (optedOut) next.mcpAnalyticsOptOut = true;
249
+ else delete next.mcpAnalyticsOptOut;
250
+ }
251
+
252
+ if (scope === 'all') {
253
+ // Preserve logout's historical behavior of clearing endpoint overrides and
254
+ // all future config keys, with only the anonymous opt-out allowed through.
255
+ if (next.mcpAnalyticsOptOut === true) {
256
+ try {
257
+ saveConfig({ mcpAnalyticsOptOut: true });
258
+ return;
259
+ } catch {
260
+ // Credential removal wins over marker preservation on write failure.
261
+ }
262
+ }
263
+ const { file } = getConfigPaths();
264
+ try { fs.unlinkSync(file); } catch (_) {}
265
+ return;
266
+ }
267
+
268
+ if (Object.keys(next).length === 0) {
269
+ const { file } = getConfigPaths();
270
+ try { fs.unlinkSync(file); } catch (_) {}
271
+ return;
272
+ }
273
+ try {
274
+ saveConfig(next);
275
+ } catch (error) {
276
+ // If the surgical rewrite cannot be persisted, do not leave known-invalid
277
+ // credentials in the old file. Best-effort unlink is the safer fallback.
278
+ const { file } = getConfigPaths();
279
+ try { fs.unlinkSync(file); } catch (_) {}
280
+ throw error;
281
+ }
282
+ }
283
+
113
284
  // Surgical alternative to clearConfig() for the refresh-failure path. Deletes ONLY the
114
285
  // OAuth credentials (token + refreshToken) while preserving everything else on disk
115
286
  // (baseUrl, apiKey, timeout overrides, future keys). A refresh token being invalidated
@@ -488,10 +659,10 @@ async function _doRefresh() {
488
659
  // Treat as an invalidated session and purge ONLY the OAuth pair so we don't retry on
489
660
  // every subsequent 401. Without this, a backend that signals failure via JSON instead of
490
661
  // a 4xx status would leave the user stuck in a doomed-refresh loop forever.
491
- // Use clearOAuthTokens() — NOT clearConfig() — so the user's baseUrl, apiKey, and any
492
- // other persisted settings survive a token expiry. (Bug caught by Cursor Bugbot on
493
- // PR #20 post-review: the earlier clearConfig() call silently wiped baseUrl too.)
494
- try { clearOAuthTokens(); } catch (_) {}
662
+ // Use the centralized OAuth-only removal — NOT clearConfig() — so the user's
663
+ // baseUrl, apiKey, and other persisted settings survive a token expiry while
664
+ // final-credential loss still preserves the analytics preference safely.
665
+ try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
495
666
  } catch (e) {
496
667
  if (isMaintenanceError(e)) {
497
668
  throw e;
@@ -503,7 +674,7 @@ async function _doRefresh() {
503
674
  // purge the token. Surgical OAuth-only clear preserves baseUrl + apiKey + timeout overrides.
504
675
  const status = e && typeof e.status === 'number' ? e.status : null;
505
676
  if (status === 400 || status === 401 || status === 403) {
506
- try { clearOAuthTokens(); } catch (_) {}
677
+ try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
507
678
  }
508
679
  }
509
680
  return null;
@@ -516,7 +687,8 @@ function apiRequest(
516
687
  skipAuth = false,
517
688
  _isRetry = false,
518
689
  userAgent = null,
519
- additionalHeaders = null
690
+ additionalHeaders = null,
691
+ requestOptions = null
520
692
  ) {
521
693
  return new Promise((resolve, reject) => {
522
694
  let url;
@@ -552,7 +724,15 @@ function apiRequest(
552
724
  headers['Idempotency-Key'] = idempotencyEntry[1];
553
725
  }
554
726
  const authMethod = getAuthMethod();
555
- const timeoutMs = getRequestTimeoutMs();
727
+ const requestedTimeoutMs = Number(requestOptions?.timeoutMs);
728
+ const timeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0
729
+ ? Math.min(getRequestTimeoutMs(), requestedTimeoutMs)
730
+ : getRequestTimeoutMs();
731
+ const signal = requestOptions?.signal;
732
+ if (signal?.aborted) {
733
+ reject(new Error('Trackly request aborted.'));
734
+ return;
735
+ }
556
736
  const hasSensitivePayload = Boolean(data && typeof data === 'object' && 'refreshToken' in data);
557
737
 
558
738
  try {
@@ -640,7 +820,8 @@ function apiRequest(
640
820
  false,
641
821
  true,
642
822
  userAgent,
643
- additionalHeaders
823
+ additionalHeaders,
824
+ requestOptions
644
825
  );
645
826
  return resolve(retried);
646
827
  } catch (e) { return reject(e); }
@@ -671,6 +852,9 @@ function apiRequest(
671
852
  req.on('timeout', () => {
672
853
  req.destroy(new Error(`Trackly request timed out after ${timeoutMs}ms`));
673
854
  });
855
+ const abortRequest = () => req.destroy(new Error('Trackly request aborted.'));
856
+ signal?.addEventListener('abort', abortRequest, { once: true });
857
+ req.on('close', () => signal?.removeEventListener('abort', abortRequest));
674
858
  req.on('error', reject);
675
859
  if (data) req.write(JSON.stringify(data));
676
860
  req.end();
@@ -827,7 +1011,9 @@ module.exports = {
827
1011
  normalizeMaintenance,
828
1012
  normalizeEndpoint,
829
1013
  refreshAccessToken,
1014
+ removeCredentials,
830
1015
  saveConfig,
1016
+ synchronizeAnalyticsPreferenceBeforeLogout,
831
1017
  CONFIG_DIR: DEFAULT_CONFIG_DIR,
832
1018
  CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
833
1019
  };