trackly-cli 0.22.2 → 0.23.0

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
@@ -7,7 +7,7 @@
7
7
 
8
8
  The only job tracking CLI built for AI agents.
9
9
 
10
- Search 250K+ jobs across 5,400+ companies and 40+ ATS types. Track applications, get AI-powered recommendations, and manage your job search -- from the terminal or through Claude, ChatGPT, Cursor, and other MCP-compatible AI agents.
10
+ Search Thousands of jobs across Thousands of companies and 40+ ATS types. Track applications, get AI-powered recommendations, and manage your job search -- from the terminal or through Claude, ChatGPT, Cursor, and other MCP-compatible AI agents.
11
11
 
12
12
  ## Two ways to connect
13
13
 
@@ -35,7 +35,7 @@ trackly jobs --function product
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 5,400+ companies | 250K+ jobs | 40+ ATS types | CLI + MCP | 58 local + 55 hosted MCP tools
38
+ Thousands of companies | Thousands of jobs | 40+ ATS types | CLI + MCP | 58 local + 55 hosted MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -351,15 +351,12 @@ job-search and application set plus `get_more_tools`, which lets agents report
351
351
  a missing capability. Run `trackly mcp` or use
352
352
  `trackly agent setup --client claude`.
353
353
 
354
- MCP usage analytics are on by default and relay through Trackly so the free
355
- service can be debugged and improved. They exclude résumé text, profile answers,
356
- demographic or work-authorization answers, and application notes. Client attribution
357
- uses a fixed family label; errors use categories, and search text is retained only
358
- as length. Raw queries and conversation text are excluded. Turn off future
359
- collection with **Share usage analytics** in Account Settings. Operators may also
360
- disable local MCP instrumentation with `TRACKLY_MCP_ANALYTICS_DISABLED=1` (or
361
- `TRACKLY_MCP_ANALYTICS_ENABLED=0`); the account setting remains authoritative
362
- for authenticated backend capture.
354
+ MCP usage analytics are part of the free Trackly service and relay through
355
+ Trackly so it can be debugged and improved; there is no account-level opt-out.
356
+ They exclude résumé text, profile answers, demographic or work-authorization
357
+ answers, and application notes. Client attribution uses a fixed family label;
358
+ errors use categories, and search text is retained only as length. Raw queries
359
+ and conversation text are excluded.
363
360
 
364
361
  **How do I use Claude Code for job hunting?**
365
362
 
@@ -367,7 +364,7 @@ Add trackly as an MCP server in Claude Code. Then ask questions naturally: "Find
367
364
 
368
365
  **What are the best CLI tools for job search?**
369
366
 
370
- trackly-cli is the first dedicated job tracking CLI. It provides direct terminal access to 250K+ jobs across 5,400+ companies, with filters for job function, location, and work modality. It also integrates with AI agents via the Model Context Protocol (MCP).
367
+ trackly-cli is the first dedicated job tracking CLI. It provides direct terminal access to Thousands of jobs across Thousands of companies, with filters for job function, location, and work modality. It also integrates with AI agents via the Model Context Protocol (MCP).
371
368
 
372
369
  ## Security
373
370
 
@@ -47,5 +47,8 @@
47
47
  },
48
48
  "3.9.2": {
49
49
  "contractSha256": "d3a82489330d0b0062a1baec8d226b510628accc434731136dca9951ec64c302"
50
+ },
51
+ "3.9.3": {
52
+ "contractSha256": "0773b7fef8ac012fe028c7e61ecf00272122949290fe35f01aa577f63a4f01df"
50
53
  }
51
54
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "3.9.2",
2
+ "contractVersion": "3.9.3",
3
3
  "constants": {
4
4
  "applyExecutionProtocolVersion": "trackly-apply-execution/v3",
5
5
  "applyAccessKnowledgeOrderingVersion": 3,
@@ -73,7 +73,7 @@
73
73
  "trackly_get_application_profile": "{includeSensitive:z.boolean().optional(),provider:z.string().max(100).optional(),companyId:z.string().max(100).optional(),jurisdiction:iso3166Alpha2Schema.optional(),office:officeScopeSchema.optional()}",
74
74
  "trackly_get_profile_onboarding": "{}",
75
75
  "trackly_update_application_profile": "{expectedRevision:z.number().int().min(1),source:z.enum(['web','ios','macos','codex','claude','mcp']).optional(),changes:z.array(z.discriminatedUnion('scope',[z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('global'),questionLabel:z.string().max(1000).optional()}),z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('provider'),scopeValue:z.string().min(1).max(200),questionLabel:z.string().max(1000).optional()}),z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('company'),scopeValue:z.string().min(1).max(200),questionLabel:z.string().max(1000).optional()}),z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('jurisdiction'),scopeValue:iso3166Alpha2Schema,questionLabel:z.string().max(1000).optional()}),z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('office'),scopeValue:officeScopeSchema,questionLabel:z.string().max(1000).optional()})])).max(100).optional(),education:z.array(z.object({school:z.string().min(1).max(500),degree:z.string().max(500).nullable().optional(),fieldOfStudy:z.string().max(500).nullable().optional(),gpa:z.string().max(50).nullable().optional(),startDate:z.string().max(50).nullable().optional(),endDate:z.string().max(50).nullable().optional()})).max(20).optional(),confirmProfile:z.boolean().optional(),sensitiveStorageConsent:z.boolean().optional(),sensitiveRevocationConfirmToken:z.string().regex(/^[a-f0-9]{64}$/).optional()}",
76
- "trackly_start_apply_execution": "{mode:z.literal('complete_next_n_accessible'),target:z.number().int().min(1).max(APPLY_EXECUTION_MAX_TARGET),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
76
+ "trackly_start_apply_execution": "{mode:z.literal('complete_next_n_accessible'),target:z.number().int().min(1).max(APPLY_EXECUTION_MAX_TARGET),jobIds:z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET).refine((ids)=>newSet(ids).size===ids.length,{message:'jobIdsmustbeunique'}).optional(),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
77
77
  "trackly_get_active_apply_execution": "{}",
78
78
  "trackly_get_apply_execution": "{executionId:z.number().int().min(1)}",
79
79
  "trackly_list_recoverable_apply_executions": "{}",
@@ -72,7 +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
+ - **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.
76
76
  - **trackly_search_jobs** — Search/filter jobs. A successful page may have `total: null`, `pagination.total: null` and `countStatus: "unavailable"` when its total cannot be computed. The returned jobs remain valid: explain `countGuidance`, use `hasMore` and pagination to continue, and avoid retrying just to obtain a total. Zero is a known count; null is unknown. Genuine page failures remain tool errors.
77
77
  - `function` (one value) or `jobFunctions` (nonempty array, OR; use one spelling only). The 14 values are: `product`, `engineering`, `design`, `data`, `marketing`, `sales`, `partnerships`, `finance`, `strategy`, `operations`, `people`, `legal`, `support`, `other`
78
78
  - `region`: nonempty array of `us`, `europe`, `latam`, `middle_east`, `asia`, `africa`, `canada`, `oceania`, `remote`, `unknown`, combined with OR. Use instead of `locationFilter`; unlike its legacy list form, this supports US plus other regions.
@@ -124,7 +124,7 @@ Actual failures retain `isError: true`, HTTP status when available, a closed `ca
124
124
  - **trackly_get_application_profile** — Get the versioned, scoped application profile.
125
125
  - **trackly_get_profile_onboarding** — Get backend-owned questions plus only missing/unconfirmed answers.
126
126
  - **trackly_update_application_profile** — Save explicit answer states with optimistic concurrency and global/provider/company scope. Setting `sensitiveStorageConsent=false` is destructive and requires a second call echoing the `sensitiveRevocationConfirmToken` from the returned challenge.
127
- - **trackly_start_apply_execution** — Start a server-owned `complete_next_n_accessible` execution with a target from 1–20, then consume the returned authoritative `progress` and `nextAction` before doing browser work.
127
+ - **trackly_start_apply_execution** — Start a server-owned `complete_next_n_accessible` execution with a target from 1–20. For explicitly selected jobs, pass their exact ordered `jobIds` and set `target` to the list length; no unrelated replacements are allowed. Omit `jobIds` only for a requested recent-first queue target. Consume authoritative `progress` and `nextAction` before browser work; never retry a rejected exact selection as count-only.
128
128
  - **trackly_get_active_apply_execution** — Recover the active execution before legacy batch recovery.
129
129
  - **trackly_get_apply_execution** — Read an execution's latest current-wave identity and authoritative aggregate progress funnel.
130
130
  - **trackly_list_recoverable_apply_executions** — List a bounded, value-free menu of exact recovery candidates after local context loss.
package/lib/agent.js CHANGED
@@ -8,7 +8,7 @@ const { spawnSync } = require('node:child_process');
8
8
  const { apiRequest, downloadFile, getConfigPaths, hasAuth, maintenanceOutput } = require('./client');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
10
  const { contractVersion: MCP_CONTRACT_VERSION } = require('../contracts/trackly-apply-tools.json');
11
- const SKILL_VERSION = '4.9.2';
11
+ const SKILL_VERSION = '4.9.3';
12
12
  const SKILL_POLICY_REVISION = SKILL_VERSION;
13
13
  const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
14
14
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
package/lib/client.js CHANGED
@@ -11,7 +11,11 @@ const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
11
11
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.trackly');
12
12
  const DEFAULT_BASE_URL = 'https://closeai.mba';
13
13
  const DEFAULT_HTTP_TIMEOUT_MS = 30000;
14
- const ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS = 1500;
14
+ // Retired local analytics opt-out marker. Usage analytics are part of the free
15
+ // product and cannot be turned off from the account, so older installs that
16
+ // persisted this key must resume capture: it is ignored on load and dropped on
17
+ // every save.
18
+ const RETIRED_ANALYTICS_OPT_OUT_KEY = 'mcpAnalyticsOptOut';
15
19
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
16
20
  const MAINTENANCE_CODE = 'maintenance_mode';
17
21
  const LEGACY_MAINTENANCE_CODE = 'planned_maintenance';
@@ -68,14 +72,23 @@ function loadConfig() {
68
72
  }
69
73
  throw error;
70
74
  }
75
+ let parsed;
71
76
  try {
72
- return JSON.parse(raw);
77
+ parsed = JSON.parse(raw);
73
78
  } catch (error) {
74
79
  if (error instanceof SyntaxError) {
75
80
  throw new TracklyConfigError(`Trackly config is invalid JSON. Fix or delete ${file}.`);
76
81
  }
77
82
  throw error;
78
83
  }
84
+ return withoutRetiredKeys(parsed);
85
+ }
86
+
87
+ function withoutRetiredKeys(config) {
88
+ if (!isRecord(config) || !Object.hasOwn(config, RETIRED_ANALYTICS_OPT_OUT_KEY)) return config;
89
+ const next = { ...config };
90
+ delete next[RETIRED_ANALYTICS_OPT_OUT_KEY];
91
+ return next;
79
92
  }
80
93
 
81
94
  function ensureConfigDir() {
@@ -95,7 +108,7 @@ function saveConfig(config) {
95
108
  // a zero-byte or partial config.json, and the next CLI invocation silently loses all auth.
96
109
  // Temp file is created mode 0600 so tokens are never world-readable, even briefly.
97
110
  const tmp = `${file}.tmp.${process.pid}`;
98
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
111
+ fs.writeFileSync(tmp, JSON.stringify(withoutRetiredKeys(config), null, 2), { mode: 0o600 });
99
112
  try { fs.chmodSync(tmp, 0o600); } catch (e) {}
100
113
  try {
101
114
  fs.renameSync(tmp, file);
@@ -108,25 +121,6 @@ function saveConfig(config) {
108
121
  }
109
122
 
110
123
  function clearConfig() {
111
- let config = {};
112
- try {
113
- const loaded = loadConfig();
114
- if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
115
- } catch {
116
- // Logout must still remove an invalid or unreadable credential file. The
117
- // opt-out can be preserved only when the existing config is recoverable.
118
- }
119
- // A logout clears credentials and endpoint overrides, but the content-free
120
- // analytics opt-out must continue suppressing anonymous MCP lifecycle events.
121
- if (config.mcpAnalyticsOptOut === true) {
122
- try {
123
- saveConfig({ mcpAnalyticsOptOut: true });
124
- return;
125
- } catch {
126
- // Preserving the marker is best-effort. A disk/write failure must never
127
- // leave credentials behind, so fall through to unlinking the old file.
128
- }
129
- }
130
124
  const { file } = getConfigPaths();
131
125
  try { fs.unlinkSync(file); } catch (e) {}
132
126
  }
@@ -135,13 +129,6 @@ function isRecord(value) {
135
129
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
136
130
  }
137
131
 
138
- function hasAuthenticationInConfig(config) {
139
- return Boolean(
140
- (process.env.TRACKLY_API_KEY || '').trim()
141
- || (isRecord(config) && (config.apiKey || config.token || config.refreshToken))
142
- );
143
- }
144
-
145
132
  function configAfterCredentialRemoval(config, scope) {
146
133
  const next = isRecord(config) ? { ...config } : {};
147
134
  if (scope === 'all' || scope === 'apiKey') delete next.apiKey;
@@ -152,81 +139,14 @@ function configAfterCredentialRemoval(config, scope) {
152
139
  return next;
153
140
  }
154
141
 
155
- async function resolveAnalyticsOptOutBeforeCredentialLoss(
156
- request = apiRequest,
157
- timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS
158
- ) {
159
- const controller = new AbortController();
160
- let timeout;
161
- try {
162
- const preference = await Promise.race([
163
- Promise.resolve().then(() => request(
164
- 'GET',
165
- '/api/jobscout/analytics-preference',
166
- null,
167
- false,
168
- true,
169
- null,
170
- null,
171
- { signal: controller.signal, timeoutMs }
172
- )),
173
- new Promise((_, reject) => {
174
- timeout = setTimeout(() => {
175
- controller.abort();
176
- reject(new Error('analytics_preference_sync_timeout'));
177
- }, timeoutMs);
178
- }),
179
- ]);
180
- if (typeof preference?.shareUsageAnalytics !== 'boolean') {
181
- throw new Error('analytics_preference_invalid');
182
- }
183
- return preference.shareUsageAnalytics === false;
184
- } catch {
185
- // Fail closed: if the authoritative preference cannot be read before the
186
- // final credential disappears, suppress future anonymous MCP capture.
187
- return true;
188
- } finally {
189
- clearTimeout(timeout);
190
- }
191
- }
192
-
193
- async function synchronizeAnalyticsPreferenceBeforeLogout(
194
- request = apiRequest,
195
- options = {}
196
- ) {
197
- const timeoutMs = options.timeoutMs ?? ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS;
198
- try {
199
- if (!hasAuth()) return;
200
- } catch {
201
- // A corrupt or unreadable config must never prevent logout. clearConfig()
202
- // owns the best-effort removal path and will run immediately afterward.
142
+ async function removeCredentials({ scope = 'all' } = {}) {
143
+ if (scope === 'all') {
144
+ // Logout clears credentials, endpoint overrides and every other config key.
145
+ const { file } = getConfigPaths();
146
+ try { fs.unlinkSync(file); } catch (_) {}
203
147
  return;
204
148
  }
205
- const optedOut = await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
206
- let config = {};
207
- try {
208
- const loaded = loadConfig();
209
- if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
210
- } catch {
211
- // Environment-provided authentication can still make the preference
212
- // request valid when the on-disk config is corrupt. Replace only the
213
- // recoverable analytics marker and let clearConfig() remove credentials.
214
- }
215
- if (optedOut) config.mcpAnalyticsOptOut = true;
216
- else delete config.mcpAnalyticsOptOut;
217
- try {
218
- saveConfig(config);
219
- } catch {
220
- // Analytics synchronization is best-effort and must never block logout.
221
- }
222
- }
223
149
 
224
- async function removeCredentials({
225
- scope = 'all',
226
- request = apiRequest,
227
- timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS,
228
- preferenceUnavailable = false,
229
- } = {}) {
230
150
  let config = {};
231
151
  try {
232
152
  const loaded = loadConfig();
@@ -236,36 +156,7 @@ async function removeCredentials({
236
156
  // path below. There is no safe credential state to preserve from it.
237
157
  }
238
158
 
239
- let next = configAfterCredentialRemoval(config, scope);
240
- const removesEphemeralCredential = scope === 'all'
241
- && Boolean((process.env.TRACKLY_API_KEY || '').trim());
242
- const removesFinalCredential = removesEphemeralCredential
243
- || (hasAuthenticationInConfig(config) && !hasAuthenticationInConfig(next));
244
-
245
- if (removesFinalCredential) {
246
- const optedOut = preferenceUnavailable
247
- ? true
248
- : await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
249
- if (optedOut) next.mcpAnalyticsOptOut = true;
250
- else delete next.mcpAnalyticsOptOut;
251
- }
252
-
253
- if (scope === 'all') {
254
- // Preserve logout's historical behavior of clearing endpoint overrides and
255
- // all future config keys, with only the anonymous opt-out allowed through.
256
- if (next.mcpAnalyticsOptOut === true) {
257
- try {
258
- saveConfig({ mcpAnalyticsOptOut: true });
259
- return;
260
- } catch {
261
- // Credential removal wins over marker preservation on write failure.
262
- }
263
- }
264
- const { file } = getConfigPaths();
265
- try { fs.unlinkSync(file); } catch (_) {}
266
- return;
267
- }
268
-
159
+ const next = configAfterCredentialRemoval(config, scope);
269
160
  if (Object.keys(next).length === 0) {
270
161
  const { file } = getConfigPaths();
271
162
  try { fs.unlinkSync(file); } catch (_) {}
@@ -661,9 +552,8 @@ async function _doRefresh() {
661
552
  // every subsequent 401. Without this, a backend that signals failure via JSON instead of
662
553
  // a 4xx status would leave the user stuck in a doomed-refresh loop forever.
663
554
  // Use the centralized OAuth-only removal — NOT clearConfig() — so the user's
664
- // baseUrl, apiKey, and other persisted settings survive a token expiry while
665
- // final-credential loss still preserves the analytics preference safely.
666
- try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
555
+ // baseUrl, apiKey, and other persisted settings survive a token expiry.
556
+ try { await removeCredentials({ scope: 'oauth' }); } catch (_) {}
667
557
  } catch (e) {
668
558
  if (isMaintenanceError(e)) {
669
559
  throw e;
@@ -675,7 +565,7 @@ async function _doRefresh() {
675
565
  // purge the token. Surgical OAuth-only clear preserves baseUrl + apiKey + timeout overrides.
676
566
  const status = e && typeof e.status === 'number' ? e.status : null;
677
567
  if (status === 400 || status === 401 || status === 403) {
678
- try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
568
+ try { await removeCredentials({ scope: 'oauth' }); } catch (_) {}
679
569
  }
680
570
  }
681
571
  return null;
@@ -1024,7 +914,6 @@ module.exports = {
1024
914
  refreshAccessToken,
1025
915
  removeCredentials,
1026
916
  saveConfig,
1027
- synchronizeAnalyticsPreferenceBeforeLogout,
1028
917
  CONFIG_DIR: DEFAULT_CONFIG_DIR,
1029
918
  CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
1030
919
  };
package/mcp/analytics.js CHANGED
@@ -7,9 +7,7 @@ const {
7
7
  ensureSecureUrl,
8
8
  getRequestHeaders,
9
9
  hasAuth,
10
- loadConfig,
11
10
  normalizeEndpoint,
12
- saveConfig,
13
11
  } = require('../lib/client');
14
12
 
15
13
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000;
@@ -23,7 +21,6 @@ const MAX_SANITIZE_STRING_LENGTH = 4_096;
23
21
  const AUTHENTICATED_RELAY_PATH = '/api/jobscout/mcp-analytics';
24
22
  const ANONYMOUS_RELAY_PATH = '/api/jobscout/mcp-analytics/anonymous';
25
23
  const ANALYTICS_PREFERENCE_PATH = '/api/jobscout/analytics-preference';
26
- const ANALYTICS_OPT_OUT_CONFIG_KEY = 'mcpAnalyticsOptOut';
27
24
  const MCP_ANALYTICS_USER_AGENT = `trackly-mcp-analytics/${PACKAGE_VERSION}`;
28
25
  const REDACTED = '[redacted]';
29
26
  const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on', 'enabled']);
@@ -217,6 +214,9 @@ function truthy(value) {
217
214
  return TRUE_VALUES.has(String(value || '').trim().toLowerCase());
218
215
  }
219
216
 
217
+ // Internal test/CI isolation only (keeps the repo's own smoke and package
218
+ // tests from sending real analytics). Not a user setting: usage analytics are
219
+ // part of the free product and there is no user opt-out.
220
220
  function isMcpAnalyticsEnabled(env = process.env) {
221
221
  if (truthy(env.TRACKLY_MCP_ANALYTICS_DISABLED)) return false;
222
222
  if (env.TRACKLY_MCP_ANALYTICS_ENABLED !== undefined
@@ -713,41 +713,13 @@ function runtimeEventProperties(env = process.env) {
713
713
 
714
714
  function createBackendRelay(options = {}) {
715
715
  const fetchImpl = options.fetch || globalThis.fetch;
716
- const loadConfigImpl = options.loadConfig || loadConfig;
717
- const saveConfigImpl = options.saveConfig || saveConfig;
718
716
  const pending = new Set();
719
717
  const maxPendingDeliveries = Number.isSafeInteger(options.maxPendingDeliveries)
720
718
  && options.maxPendingDeliveries > 0
721
719
  ? options.maxPendingDeliveries
722
720
  : DEFAULT_MAX_PENDING_DELIVERIES;
723
- let cachedOptOut = readCachedAnalyticsOptOut();
724
721
  let preferenceLookup = null;
725
722
 
726
- function readCachedAnalyticsOptOut() {
727
- try {
728
- return loadConfigImpl()[ANALYTICS_OPT_OUT_CONFIG_KEY] === true;
729
- } catch {
730
- return true;
731
- }
732
- }
733
-
734
- function persistCachedAnalyticsOptOut(optedOut) {
735
- cachedOptOut = optedOut;
736
- try {
737
- const config = loadConfigImpl();
738
- if (optedOut && config[ANALYTICS_OPT_OUT_CONFIG_KEY] === true) return;
739
- if (optedOut) config[ANALYTICS_OPT_OUT_CONFIG_KEY] = true;
740
- else if (Object.hasOwn(config, ANALYTICS_OPT_OUT_CONFIG_KEY)) {
741
- delete config[ANALYTICS_OPT_OUT_CONFIG_KEY];
742
- } else {
743
- return;
744
- }
745
- saveConfigImpl(config);
746
- } catch {
747
- // The in-memory privacy gate remains authoritative for this process.
748
- }
749
- }
750
-
751
723
  function authenticatedRequestContext() {
752
724
  const headers = getRequestHeaders(false, MCP_ANALYTICS_USER_AGENT);
753
725
  const authorization = headers.Authorization;
@@ -789,7 +761,6 @@ function createBackendRelay(options = {}) {
789
761
  throw new Error('analytics_preference_invalid');
790
762
  }
791
763
  if (!isCurrentAuthenticatedContext(authContext.key)) return false;
792
- persistCachedAnalyticsOptOut(!preference.shareUsageAnalytics);
793
764
  return preference.shareUsageAnalytics;
794
765
  })().catch(() => false).finally(() => {
795
766
  if (preferenceLookup === lookup) preferenceLookup = null;
@@ -805,12 +776,6 @@ function createBackendRelay(options = {}) {
805
776
  const authContext = authenticated ? authenticatedRequestContext() : null;
806
777
  if (authenticated && !authContext) return;
807
778
  if (!authenticated && !ANONYMOUS_RELAY_EVENTS.has(event.event)) return;
808
- // Another MCP process can persist an opt-out before this process loses
809
- // authentication. Refresh the anonymous gate from disk before every
810
- // unauthenticated delivery, while preserving a known in-memory opt-out
811
- // if its earlier persistence attempt failed.
812
- if (!authenticated) cachedOptOut ||= readCachedAnalyticsOptOut();
813
- if (!authenticated && cachedOptOut) return;
814
779
  if (pending.size >= maxPendingDeliveries) return;
815
780
 
816
781
  const endpoint = authenticated ? AUTHENTICATED_RELAY_PATH : ANONYMOUS_RELAY_PATH;
@@ -976,13 +976,18 @@ function registerApplyTools(
976
976
 
977
977
  server.tool(
978
978
  'trackly_start_apply_execution',
979
- 'Start a server-owned execution that keeps selecting from one recent-first Check Later snapshot until the requested number of accessible forms is durably ready for manual review. Consume the returned authoritative progress and nextAction immediately; never infer the first wave or next step locally.',
979
+ 'Start a server-owned no-submit execution. For named or explicitly selected jobs, supply their exact ordered jobIds and set target to their count; no replacement outside that set is allowed. Omit jobIds only for a user-requested recent-first queue target. Consume authoritative progress and nextAction; never fall back to count-only after an exact selection is rejected.',
980
980
  {
981
981
  mode: z.literal('complete_next_n_accessible'),
982
982
  target: z.number().int().min(1).max(APPLY_EXECUTION_MAX_TARGET),
983
+ jobIds: z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET)
984
+ .refine((ids) => new Set(ids).size === ids.length, { message: 'jobIds must be unique' }).optional(),
983
985
  idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
984
986
  },
985
987
  wrapTool(async ({ idempotencyKey, ...body }) => {
988
+ if (body.jobIds !== undefined && body.target !== body.jobIds.length) {
989
+ throw new Error('target must equal the jobIds selection length.');
990
+ }
986
991
  const rawResponse = await applyControlRequest(
987
992
  'POST', '/api/jobscout/apply/executions', body, idempotencyKey,
988
993
  );
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "trackly-cli",
9
- "version": "0.22.2",
9
+ "version": "0.23.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "1.32.1",
@@ -95,9 +95,9 @@
95
95
  }
96
96
  },
97
97
  "node_modules/@hono/node-server": {
98
- "version": "2.0.12",
99
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz",
100
- "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==",
98
+ "version": "2.1.3",
99
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
100
+ "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
101
101
  "license": "MIT",
102
102
  "engines": {
103
103
  "node": ">=20"
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "mcpName": "io.github.trackly-app/trackly",
5
- "description": "Search 250K+ jobs across 5,400+ companies with the Trackly CLI and MCP server for Claude, ChatGPT, Codex and Cursor. Filter by role, location, salary and sponsorship; track jobs and prepare applications.",
5
+ "description": "Search Thousands of jobs across Thousands of companies with the Trackly CLI and MCP server for Claude, ChatGPT, Codex and Cursor. Filter by role, location, salary and sponsorship; track jobs and prepare applications.",
6
6
  "bin": {
7
7
  "trackly": "./bin/trackly"
8
8
  },
@@ -82,7 +82,7 @@
82
82
  "node": "^20.20.0 || >=22.22.0"
83
83
  },
84
84
  "overrides": {
85
- "@hono/node-server": "2.0.12",
85
+ "@hono/node-server": "2.1.3",
86
86
  "fast-uri": "3.1.9",
87
87
  "ip-address": "10.7.4",
88
88
  "qs": "6.16.1",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.trackly-app/trackly",
4
4
  "title": "Trackly CLI",
5
- "description": "Search 250K+ jobs, 5,400+ companies. MCP for Claude, ChatGPT, Codex and Cursor.",
5
+ "description": "Search Thousands of jobs, Thousands of companies. MCP for Claude, ChatGPT, Codex and Cursor.",
6
6
  "repository": {
7
7
  "url": "https://github.com/trackly-app/trackly-cli",
8
8
  "source": "github"
@@ -17,12 +17,12 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "0.22.2",
20
+ "version": "0.23.0",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.22.2",
25
+ "version": "0.23.0",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {
@@ -11,7 +11,7 @@ Before application work, read [onboarding guidance](references/onboarding.md). G
11
11
 
12
12
  ## Skill version
13
13
 
14
- Skill version: `4.9.2`. Before browser readiness read [references/handoff-compatibility.md](references/handoff-compatibility.md). This policy takes precedence over returned-persistence-receipt or finalizer requirements elsewhere when using the documented markHandoff path. Other review and safety gates remain unchanged.
14
+ Skill version: `4.9.3`. Before browser readiness read [references/handoff-compatibility.md](references/handoff-compatibility.md). This policy takes precedence over returned-persistence-receipt or finalizer requirements elsewhere when using the documented markHandoff path. Other review and safety gates remain unchanged.
15
15
 
16
16
  ## Non-negotiable rules
17
17
 
@@ -41,7 +41,7 @@ value-free phase receipt with `scripts/validate-phase-checkpoint.js` before
41
41
  claiming that phase complete.
42
42
  Read [references/legal-clarifications.md](references/legal-clarifications.md) when a form asks for a legal, regulatory, consent, or employer-relationship decision.
43
43
 
44
- 1. Call `trackly_get_apply_protocol`. Skill 4.9.2 requires protocol 3.7.0 or newer for new reliability work. Protocol 3.2 remains valid only for an already-active explicit legacy single run; an already-active explicit 3.2 single run may finish through its legacy path. Preserve the protocol's documented recovery paths for already-active older work. Require `compatibleSkillMajor: 4` and `compatibleSkillMinimumVersion` no newer than this installed skill. Reject an older or incompatible version for new work and report that the backend must finish updating or `trackly agent setup` must update the skill. Protocol 3.2 added exact-origin trust for jobs Trackly ingested directly from employer careers sources; do not recreate the retired ownership-timestamp gate in the client.
44
+ 1. Call `trackly_get_apply_protocol`. Skill 4.9.3 requires protocol 3.7.0 or newer for new reliability work. Protocol 3.2 remains valid only for an already-active explicit legacy single run; an already-active explicit 3.2 single run may finish through its legacy path. Preserve the protocol's documented recovery paths for already-active older work. Require `compatibleSkillMajor: 4` and `compatibleSkillMinimumVersion` no newer than this installed skill. Reject an older or incompatible version for new work and report that the backend must finish updating or `trackly agent setup` must update the skill. Protocol 3.2 added exact-origin trust for jobs Trackly ingested directly from employer careers sources; do not recreate the retired ownership-timestamp gate in the client.
45
45
  2. Call `trackly_get_profile_onboarding` or fetch both the profile schema and application profile. When present, render `schema.screens` in ascending `order` as one grouped question packet per screen. Within a screen, preserve category `order` and then field `order`; use each field's user-facing `rationale` when the user asks why it is needed. Ask only unknown or confirmation-needed fields and honor `consistencyRules` before submitting profile changes, resolving contradictions with the user instead of guessing. When `schema.screens` is absent on a legacy backend, fall back to the existing category-based onboarding behavior. Treat `completeness.percent` as required onboarding readiness only. Use `coverage.missingReusableKeys` to explain reusable optional gaps, while `coverage.contextualKeys` are intentionally asked only on the relevant employer form. Do not claim that 100% required completeness answers every possible application question.
46
46
  3. Save answers with `trackly_update_application_profile`:
47
47
  - Use `answered`, `intentionally_blank`, `declined`, or `unknown` exactly.
@@ -59,6 +59,7 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
59
59
  - Infer a direct prior-employer answer of No only when the profile explicitly marks history complete, the target and backend-confirmed aliases are absent, and the question is limited to direct employment. Ask for subsidiary, affiliate, acquisition, contractor, or otherwise ambiguous relationships.
60
60
  4. Require the one-time profile confirmation, complete education entries, and default-resume metadata before browser work. Do not prepare or upload the resume when the form has no attachment control.
61
61
  5. For every protocol 3.4 or newer request, call `trackly_get_active_apply_execution` before legacy recovery. Resume execution work only when the response says `active: true`. A response with `preserved: true` and `active: false` exposes a stopped, closed, or otherwise terminal execution only for read-only reconciliation. Never fetch its compact snapshot, continue its wave, mutate its members, or interpret historical `currentlyFilling` or reservation counts as current work; require `progress.nextAction: none`. Only with protocol 3.5 or newer and an enabled compact-snapshot capability may an active recovery or new execution call `trackly_get_apply_execution_snapshot`, using only the current member IDs, profile keys needed by the visible forms, and actual browser surface. An already-active protocol 3.4 execution is read-only legacy recovery: use only its published get or stop operations, never call the 3.5-only snapshot, resume, approval, advance, or disposition tools, and never mutate its browser forms. For protocol 3.5 or newer, treat snapshot `mutable` and `allowedOperations` values as authoritative. Never mutate or reopen authentication, account-creation, OTP, pre-form-CAPTCHA, or manual-only members. Only an explicit user request may call `trackly_resume_parked_apply_member`; the returned member still requires a fresh non-mutating probe before private data or form mutation. Resume unresolved waves in ascending order only for `active: true` and obey the server `nextAction` and funnel. Never reconstruct progress or choose replacements locally. Treat backend `achievementCount`, target-capped `completed`, `targetReached`, and `nextAction` as authoritative. `durablyReviewReady` and `submitted` remain current operator projections and must never be added together by the client to reconstruct cumulative completion. Let the backend own reservations, capacity, attempted-job deduplication, immutable waves, and advancement. Continue until `targetReached`, `queueExhausted`, a blocking `nextAction`, or the user stops.
62
+ - For named jobs or an explicit selected set, resolve the exact Trackly job IDs and send their ordered `jobIds` to `trackly_start_apply_execution`, with `target` equal to that list length. The server must freeze only that set; blocked or inaccessible members never authorize unrelated replacements. Retain the original start idempotency key for an exact replay. A matching count alone never proves an active execution matches the selection. If the server cannot verify the exact selection, stop and explain the scope conflict; never omit `jobIds`, reorder the saved queue, or use count-only start as a fallback. Changing an active target still requires the existing explicit stop/change confirmation.
62
63
  - After full local context loss with no active execution, call `trackly_list_recoverable_apply_executions`. For every distinct returned `jobId`, call `trackly_get_job`, bind that Trackly-owned job record to the returned `candidateId`, and show the user its company, role, requisition identity when available, source-execution identity, and `accessKnowledge` scheduling reason when present; do not identify candidates from chat, tab order, search results, or page copy. A missing or mismatched Trackly job record blocks confirmation. An active personal deferment on any confirmed candidate blocks recovery until that deferment is cleared. Require explicit confirmation of one exact candidate set, then call `trackly_recover_exact_apply_members` with that source execution, snapshot hash, and only those candidate IDs. Verify `assertedCandidateIds`, `eligibleCandidateIds`, and the eligibility candidate IDs each contain exactly the confirmed set with no duplicates. Never add substitutes, newly saved jobs, or merely similar roles. Curated or historical walls stay in the exact recovered set as parked members and still require explicit resume plus a fresh probe.
63
64
  - Exact recovery restores server lineage only. Treat tab restored, form state restored, and mutation authority restored as three independent receipts. Bind every recovered surface with a fresh recovery binding and inspection epoch, revalidate the full form, and preserve every user-edited or unknown non-empty value before any write. Earlier-epoch browser, field, upload, integrity, and submission evidence cannot authorize the recovered form.
64
65
  - Every compact snapshot request must contain a non-empty list of only the current member IDs. Never request an empty or inferred all-members projection.
@@ -87,7 +88,7 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
87
88
  - For each fixed batch member, preserve an explicit job ID -> application run ID -> browser tab mapping. Complete the full start -> conditional resume preparation/confirmation/verification when an upload control exists -> form completion -> `review_ready` lifecycle for every member. A review-ready run does not block the next member. Never submit any of them.
88
89
  6. When the selected job or current fixed batch member has no `runId`, call `trackly_start_apply_run` with the complete `batchId`, `memberId`, `expectedMemberVersion`, `expectedInspectionEpoch`, and `leaseToken` returned by Trackly. This is the sanctioned idempotent lookup for a recovered member whose `runId` is absent; call it with the exact recovered binding and Trackly will reuse the bound active run. When the member already has a `runId`, do not call `trackly_start_apply_run` in this or any subsequent start step: reuse that exact run and bind or recover its browser surface through `trackly_bind_apply_surface` as required. Never create a replacement run because browser control was interrupted.
89
90
  - If a bound start returns a transport failure, a non-access HTTP 5xx response, or an error explicitly marked `retryable: true`, preserve the frozen member and browser state. Refetch the active batch, renew its lease, and retry the same complete binding once. Classify the retry response independently with these same rules: route canonical `maintenance_mode` or legacy `planned_maintenance` directly through **Resume after maintenance**; surface controlled-access/request errors marked `retryable: false` and every other HTTP 4xx response unchanged; only a second transport failure, non-access HTTP 5xx response, or explicitly retryable error becomes `backend_run_start_unavailable`. Route maintenance on either attempt without consuming or relabeling the retry, and never relabel a permanent retry response as an outage. For `backend_run_start_unavailable`, continue other members and report a Trackly control-plane failure. Do not call `trackly_checkpoint_apply_batch` for this condition because start failure has not produced the required run ID. The unchanged frozen member is the durable resume point. Never switch that frozen member to an unbound legacy run or blame the employer form.
90
- - Require `run.protocolVersion` to be 3.1.0 or newer and to share protocol major 3 with the fetched protocol. A new reliability execution member requires 3.7.0 or newer; already-active older work may use only the recovery path published by its fetched protocol. Never continue a pre-evidence 3.0.x run under skill 4.9.2. Preserve that run instead of starting a replacement, record it `blocked` with a value-free protocol-upgrade reason when possible, and tell the user the saved job can be retried only after the stale run is cleared through Trackly's supported lifecycle. Stop and refetch the protocol and active execution or batch if support level, execution mode, provider, required scenarios, authorized origin policy, member version, or inspection epoch changes after run creation.
91
+ - Require `run.protocolVersion` to be 3.1.0 or newer and to share protocol major 3 with the fetched protocol. A new reliability execution member requires 3.7.0 or newer; already-active older work may use only the recovery path published by its fetched protocol. Never continue a pre-evidence 3.0.x run under skill 4.9.3. Preserve that run instead of starting a replacement, record it `blocked` with a value-free protocol-upgrade reason when possible, and tell the user the saved job can be retried only after the stale run is cleared through Trackly's supported lifecycle. Stop and refetch the protocol and active execution or batch if support level, execution mode, provider, required scenarios, authorized origin policy, member version, or inspection epoch changes after run creation.
91
92
  - In guided mode, inspect the page before preparing any resume bytes. Confirm the employer, role, HTTPS origin, reachable review path, semantic controls, whether an attachment control exists, and absence of a credential, verification, CAPTCHA, or submit-only wall. A missing file input is not itself a blocker; skip the resume path when the application has no attachment control. Any other failed precondition is an execution blocker, not permission to improvise.
92
93
  7. Pass the browser readiness gate before preparing resume bytes:
93
94
  - Use semantic browser control through Codex in-app browser controls, Chrome MCP/extension browser control, or Claude in Chrome.
@@ -24,6 +24,7 @@ runs remain recovery-only compatibility paths.
24
24
  active execution first or start one with target 1–20. Continue through its
25
25
  original recent-first Check Later snapshot until N forms are durably ready
26
26
  for manual review, the snapshot is exhausted, or the user stops it.
27
+ - A request naming specific jobs means only those jobs. Resolve their Trackly IDs, pass the exact ordered `jobIds` with `target` equal to their count, and preserve that immutable selection through retries and later waves. Never fill unrelated jobs to replace blocked selected members. Keep the original idempotency key for an exact start replay; a changed key or set cannot authorize resuming a different active execution. A rejected selection must not fall back to a count-only start or queue reordering.
27
28
  - “Inspect the next N records” means a fixed immutable batch of exactly those
28
29
  queue records. Never replenish or replace that inspection batch.
29
30
  - A saved/check-later job is already approved. Neither mode rescans fit.