@leadbay/mcp 0.34.1 → 0.35.1

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/dist/bin.js CHANGED
@@ -1513,7 +1513,7 @@ Some Leadbay tool responses include a \`_meta.notifications\` array listing **ba
1513
1513
  - \`leadbay_qualify_status\` \u2192 \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read \u2014 so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run \u2014 return the handle/progress and let completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline.
1514
1514
  - \`leadbay_import_status\` \u2192 \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it \u2014 surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly.
1515
1515
 
1516
- Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the bulk_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
1516
+ Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the notification_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
1517
1517
 
1518
1518
  Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
1519
1519
 
@@ -1820,23 +1820,57 @@ WHEN TO USE: the user asks for a clickable / interactive artifact, dashboard, or
1820
1820
 
1821
1821
  WHEN NOT TO USE: the user wants a plain data answer (route to leadbay_pull_leads / leadbay_pull_followups) or to log a single real outreach you just did (leadbay_report_outreach).
1822
1822
  `;
1823
- leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the \`bulk_id\`, progress per lead (done/total enrichable contacts), and overall progress. When \`include_contacts=true\` (opt-in), includes each contact's email/phone_number/job_title/enrichment.done.
1823
+ leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Pass the \`notification_id\` for the job counters in one call, and/or the \`lead_ids\` + \`titles\` + \`email\` / \`phone\` the launch returned for per-lead progress. \`lead_ids\` alone is a valid call and is the reliable one: the job lookup is a scan of your recent notifications, so an archived job may not be found, and an enrichment notification does not always carry counters (then the tool answers \`ENRICH_JOB_NO_COUNTERS\` with the backend's running/finished flag and asks for \`lead_ids\`) \u2014 but the leads always answer. When \`include_contacts=true\` (opt-in), includes each contact's email/phone_number/job_title/enrichment.done.
1824
1824
 
1825
- WHEN TO USE: poll this REPEATEDLY after leadbay_enrich_titles returns a \`bulk_id\`, staying active until the job is done \u2014 don't stop after one check, and don't hand the turn back to the user while progress is still climbing. "Done" = \`all_done:true\`, OR \`overall_progress.done\` has held steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (some contacts are unresolvable and never flip, so \`all_done\` can stay false forever \u2014 don't spin indefinitely). Do NOT declare a plateau from the first few back-to-back reads: right after launch, \`overall_progress.done\` can sit flat while the backend is still spinning the job up, so space your polls out and give it real elapsed time before treating a flat count as terminal. Also do NOT declare a plateau while the result carries \`partial_failures\` \u2014 a flat \`done\` there means a transient per-lead fetch error (e.g. a 429), NOT an unresolvable contact; keep polling (respecting any \`retry_after\`) or surface it as a temporary status failure, rather than reporting those leads as permanently unresolved. Default \`include_contacts=false\` for the cheap interim polls; set \`include_contacts=true\` on the read you report from to pull each lead's enriched contacts for the completion report.
1825
+ WHEN TO USE: poll this REPEATEDLY after leadbay_enrich_titles returns a \`notification_id\`, staying active until the job is done \u2014 don't stop after one check, and don't hand the turn back to the user while progress is still climbing. "Done" = \`all_done:true\`, OR \`overall_progress.done\` has held steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (some contacts are unresolvable and never flip, so \`all_done\` can stay false forever \u2014 don't spin indefinitely). Do NOT declare a plateau from the first few back-to-back reads: right after launch, \`overall_progress.done\` can sit flat while the backend is still spinning the job up, so space your polls out and give it real elapsed time before treating a flat count as terminal. Also do NOT declare a plateau while the result carries \`partial_failures\` \u2014 a flat \`done\` there means a transient per-lead fetch error (e.g. a 429), NOT an unresolvable contact; keep polling (respecting any \`retry_after\`) or surface it as a temporary status failure, rather than reporting those leads as permanently unresolved. Default \`include_contacts=false\` for the cheap interim polls; set \`include_contacts=true\` on the read you report from to pull each lead's enriched contacts for the completion report.
1826
1826
 
1827
1827
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that already includes enriched contacts for a single lead.
1828
1828
 
1829
+ ## A launched job cannot be stopped
1830
+
1831
+ Leadbay has no cancel. A job started by \`leadbay_enrich_titles\`,
1832
+ \`leadbay_bulk_qualify_leads\`, \`leadbay_import_leads\` or
1833
+ \`leadbay_import_and_qualify\` runs to completion on Leadbay. The user cancelling
1834
+ in the chat, a request timeout, or a closed stream stops YOUR waiting, never the
1835
+ job, and \`cancelled: true\` on an earlier result means we stopped watching, not
1836
+ that the work stopped.
1837
+
1838
+ **This tool only reads.** Calling it again launches nothing and spends no quota,
1839
+ so poll it as often as the job needs \u2014 a timeout here is a reason to call it
1840
+ again, not a reason to stop.
1841
+
1842
+ One import state does NOT progress: a chunk cancelled before its mappings were
1843
+ committed reads \`running\` / \`committing\` forever. If the counts hold flat across
1844
+ several spaced polls, say so and stop, rather than polling on.
1845
+
1846
+ What must not be repeated is the LAUNCH \u2014 for work that actually launched. Re-run
1847
+ a launcher only for a subset that never started, never for the whole batch:
1848
+
1849
+ - \`failed[]\` entries with \`error:"not_queued"\`;
1850
+ - a \`rows_pending_upload\` count;
1851
+ - leads in \`still_running\` after a CANCELLED \`leadbay_import_and_qualify\`. Its
1852
+ fan-out is sequential, so an interruption leaves the remainder unlaunched and
1853
+ folds them in with the ones that did launch. Nothing in the result tells the
1854
+ two apart, and this tool cannot start either. Wait until the REST of the batch
1855
+ has settled: what launched settles in order, so leads still unanswered after
1856
+ that are the ones that never started. Only then call
1857
+ \`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
1858
+ that is merely slow looks identical to one that never launched over a few
1859
+ polls, and re-launching it charges the user twice \u2014 when unsure, tell the user
1860
+ rather than guess.
1861
+
1862
+
1829
1863
  ## QUOTA \u2014 show where the user stands after the spend
1830
1864
 
1831
1865
  Enrichment consumes QUOTA (the per-window allowance), not a separate credit wall. Once the job is done (all_done, or a plateau \u2014 see WHEN TO USE), show the user their refreshed quota: call \`leadbay_account_status\` and render the per-window quota it returns (the canonical surface). The result's \`credits_remaining\` field is **advisory internal context only \u2014 do NOT display it**: it comes from \`billing.ai_credits\` (a consumed counter, not remaining), so printing \`_(N credits remaining)_\` can show a fresh/quota-backed account a false "0 remaining." Never render a credits balance; the \`leadbay_account_status\` quota gauge is the only place the user's standing is shown. Do NOT report a "credits used" figure for this run either: the per-contact cost can't be scoped to this specific enrichment (a lead's contact list mixes in earlier runs), so any "X used" number would be misleading. Do the account_status refresh ONCE at completion \u2014 not on every in-progress poll.
1832
1866
 
1833
1867
  ## COMPLETION REPORT \u2014 what to tell the user when the job is done
1834
1868
 
1835
- The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. When the read came back on the notification fast path it ALSO carries \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` \u2014 but the legacy per-lead fallback (older records with no \`notification_id\`, or a notification not yet visible) returns NO \`bulk_progress\`, so derive counts from \`overall_progress\` in that case rather than assuming \`bulk_progress\` is present. A contact counts as done only when the REQUESTED channel actually landed \u2014 for a phone run, \`enrichment.done:true\` with no \`phone_number\` is NOT done (the contact may have been email-enriched earlier); read \`email\` / \`phone_number\` against the requested channels, don't rely on the \`enrichment.done\` flag alone (\`overall_progress\` already accounts for this). \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished. Then show refreshed quota via \`leadbay_account_status\` (see QUOTA above); do NOT print a credits-remaining line.
1869
+ The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` is present only when you passed a \`notification_id\` AND the job was found; derive counts from \`overall_progress\` rather than assuming \`bulk_progress\` is there. With \`lead_ids\`, each entry carries \`enrichment_progress:{done,total}\` \u2014 \`done\` counts only contacts whose REQUESTED channel has landed, scoped to the \`titles\` this run enriched, so a lead's pre-existing CFO email cannot inflate a CEO run. A contact counts as done only when the REQUESTED channel actually landed \u2014 for a phone run, \`enrichment.done:true\` with no \`phone_number\` is NOT done (the contact may have been email-enriched earlier); read \`email\` / \`phone_number\` against the requested channels, don't rely on the \`enrichment.done\` flag alone (\`overall_progress\` already accounts for this). \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished. Then show refreshed quota via \`leadbay_account_status\` (see QUOTA above); do NOT print a credits-remaining line.
1836
1870
  `;
1837
- leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', qualify_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
1871
+ leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', notification_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
1838
1872
 
1839
- **Default to \`wait_for_completion:false\`** for any \`count > 5\` or when chained inside a multi-phase workflow \u2014 the blocking default can hit the MCP per-call timeout and surface as \`"Request timed out"\` even when the server is still working fine. The async pattern (capture \`qualify_id\`, poll \`leadbay_qualify_status\` every ~10s) is timeout-proof. Reserve the blocking form for tiny single-digit counts in interactive use.
1873
+ **Default to \`wait_for_completion:false\`** for any \`count > 5\` or when chained inside a multi-phase workflow \u2014 the blocking default can hit the MCP per-call timeout and surface as \`"Request timed out"\` even when the server is still working fine. The async pattern (capture \`notification_id\`, poll \`leadbay_qualify_status\` every ~10s) is timeout-proof. Reserve the blocking form for tiny single-digit counts in interactive use.
1840
1874
 
1841
1875
  Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet.
1842
1876
 
@@ -1844,6 +1878,36 @@ WHEN TO USE: when the user wants more qualified leads than what's currently show
1844
1878
 
1845
1879
  WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).
1846
1880
 
1881
+ ## A launched job cannot be stopped
1882
+
1883
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
1884
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
1885
+ running result, that work is queued on Leadbay and runs to completion, and the
1886
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
1887
+ launched nothing and is not covered here.
1888
+
1889
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
1890
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
1891
+ work stopped. What to do next depends on what you are holding:
1892
+
1893
+ - **A handle.** Poll the status tool with it, and do not launch the work that
1894
+ handle covers a second time \u2014 that spends the quota again on the same rows.
1895
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
1896
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
1897
+ notification of its own: resume it with
1898
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
1899
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
1900
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
1901
+ for what was launched and re-run for that subset only, never for the whole
1902
+ batch.
1903
+ - **No result at all**, because the call timed out or the stream closed before it
1904
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
1905
+ finished. Calling the same tool again with the same arguments will usually hand
1906
+ back the job already launched rather than starting a second one, but that guard
1907
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
1908
+ are about to re-run before you spend the user's quota on it.
1909
+
1910
+
1847
1911
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
1848
1912
 
1849
1913
 
@@ -1865,7 +1929,7 @@ After the status line, propose the obvious refresh / progress-check / recovery a
1865
1929
 
1866
1930
  Specifically for bulk qualify:
1867
1931
 
1868
- - Kicked off async \u2192 \`"\u2713 Qualifying N lead(s) (qualify_id <id>) \u2014 typically ~M minutes. I'll refresh your leads view when it's done."\`
1932
+ - Kicked off async \u2192 \`"\u2713 Qualifying N lead(s) (notification_id <id>) \u2014 typically ~M minutes. I'll refresh your leads view when it's done."\`
1869
1933
  - Blocking call returned with answers \u2192 \`"\u2713 Qualified N lead(s). Refresh your leads to see the new \u2756 caps."\`
1870
1934
  - Already-qualified short-circuit \u2192 \`"All N leads are already qualified \u2014 no work to do."\`
1871
1935
  - 429 mid-fanout \u2192 \`"\u26A0 Rate-limited after launching M of N \u2014 already-launched leads will complete; re-call later for the rest."\`
@@ -2328,6 +2392,23 @@ WHEN TO USE: when the user has already picked WHO they want on a company and you
2328
2392
 
2329
2393
  WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow. Not to mark someone as the priority contact \u2014 that is leadbay_pin_contact, and pinning does not enrich anyone.
2330
2394
 
2395
+ ## A launched job cannot be stopped, and this tool has no retry guard
2396
+
2397
+ Leadbay has no cancel. Once this call returns having actually launched, the work
2398
+ is queued on Leadbay and runs to completion, and the quota it costs is already
2399
+ committed. A \`dry_run\` result reached no backend and spent nothing. The user
2400
+ cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
2401
+ never the job.
2402
+
2403
+ Unlike the composite launchers, this tool has **no double-launch guard**: calling
2404
+ it again always issues a new paid launch, even seconds later with identical
2405
+ arguments. So when a call returns nothing at all, do not simply retry. Read the
2406
+ record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
2407
+ lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
2408
+ see whether the launch already landed, and tell the user what you are about to
2409
+ spend before spending it again.
2410
+
2411
+
2331
2412
  ## QUOTA, NOT CREDITS
2332
2413
 
2333
2414
  Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead. The actual per-contact cost (\`enrichment.credits_used\`) appears on the contact after enrichment.
@@ -2342,6 +2423,36 @@ WHEN TO USE: as the agent's go-to enrichment entry point, immediately before pro
2342
2423
 
2343
2424
  WHEN NOT TO USE: to enrich a single named contact \u2014 that's leadbay_enrich_contacts. Speculatively, before the user has committed to outreaching \u2014 enrichment consumes quota. **NOT to add "titles" or "LinkedIn" to a list** \u2014 a contact's \`job_title\` and \`linkedin_page\` already ride on the contact record; they are FREE and need no enrichment. If the user asks for "title and LinkedIn only", read those fields directly (e.g. leadbay_get_contacts / leadbay_research_lead_by_id); do NOT launch a job here. This tool is strictly the email / phone reveal, which consumes quota.
2344
2425
 
2426
+ ## A launched job cannot be stopped
2427
+
2428
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
2429
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
2430
+ running result, that work is queued on Leadbay and runs to completion, and the
2431
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
2432
+ launched nothing and is not covered here.
2433
+
2434
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
2435
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
2436
+ work stopped. What to do next depends on what you are holding:
2437
+
2438
+ - **A handle.** Poll the status tool with it, and do not launch the work that
2439
+ handle covers a second time \u2014 that spends the quota again on the same rows.
2440
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
2441
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
2442
+ notification of its own: resume it with
2443
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
2444
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
2445
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
2446
+ for what was launched and re-run for that subset only, never for the whole
2447
+ batch.
2448
+ - **No result at all**, because the call timed out or the stream closed before it
2449
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
2450
+ finished. Calling the same tool again with the same arguments will usually hand
2451
+ back the job already launched rather than starting a second one, but that guard
2452
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
2453
+ are about to re-run before you spend the user's quota on it.
2454
+
2455
+
2345
2456
  ## ENRICHMENT CONSUMES QUOTA \u2014 the model to reason with
2346
2457
 
2347
2458
  Each email reveal and each phone reveal **consumes quota** (the per-window daily / weekly / monthly allowance shown in \`leadbay_account_status\`). That is the ONLY thing that gates enrichment. Do **NOT** reason about, mention, or block on "credits": there is no separate credit wall the user must clear first \u2014 enrichment either fits the user's remaining quota or the backend returns 429 (\`{status:'quota_exceeded'}\`) when a window is actually exhausted. **Never pre-refuse enrichment because a credit number looks low or zero** \u2014 a fresh/freemium account with quota still available can enrich even when its credit counter reads 0. If and only if the backend returns \`quota_exceeded\`, tell the user which window is exhausted and offer the wait-or-top-up choice (see \`leadbay_account_status\`).
@@ -2364,7 +2475,7 @@ Do NOT rely on a bare call (no \`confirm\`, no \`dry_run\`, no channels) as a "s
2364
2475
 
2365
2476
  ## AFTER LAUNCH \u2014 STAY ACTIVE UNTIL DONE
2366
2477
 
2367
- When a launch returns \`mode:"launched"\` with a \`bulk_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`bulk_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({bulk_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the legacy per-lead fallback returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT). Then show refreshed quota via \`leadbay_account_status\` (see AFTER above).
2478
+ When a launch returns \`mode:"launched"\` with a \`notification_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`notification_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({notification_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the per-lead path returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT). Then show refreshed quota via \`leadbay_account_status\` (see AFTER above).
2368
2479
 
2369
2480
  **"Done" = \`all_done:true\` OR the resolvable work has plateaued.** Keep polling while \`overall_progress.done\` is still climbing. But \`total\` counts every matching contact, and some (unresolvable titles, contacts with no findable email) never flip to done \u2014 so a job can sit below 100% with \`all_done:false\` forever. A plateau is only real once the job has had time to run: do NOT declare it from the first few back-to-back reads (early on \`done\` can sit at its initial value while the backend is still spinning the job up). Give it at least ~90s\u20132 min of actual elapsed polling \u2014 space your polls out (~15\u201330s apart) rather than firing them back-to-back \u2014 and only treat the set as complete when \`overall_progress.done\` has held steady across several spaced polls over that window. Then stop polling and report what resolved, naming the ones that didn't. Key the "didn't resolve" wording off the channels the user actually requested and the returned contact fields (contacts carry \`email\` and \`phone_number\`) \u2014 a contact enriched for phone that came back with no \`phone_number\` is "no phone number found", one with no \`email\` is "no email found", email+phone that got neither is "no contact details found"; if \`quota_hit_count\` is non-zero say those were skipped because the quota window was exhausted. Do NOT hard-label every non-success as "no email found" when phone was requested. Do NOT spin indefinitely waiting for \`all_done\` on contacts the engine won't resolve, and do NOT \`ScheduleWakeup\` / defer the finished list to a later turn \u2014 deliver the resolved results in THIS reply.
2370
2481
 
@@ -3017,9 +3128,39 @@ WHEN TO USE: agent has a list of companies (domains, or CSV-shaped rows from the
3017
3128
 
3018
3129
  WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead_by_id); high-cadence or untrusted automation \u2014 this mutates user state and consumes ai_rescore + web_fetch quota.
3019
3130
 
3020
- Budgets: \`total_budget_ms\` caps wall-clock; \`per_lead_budget_ms\` caps each lead's poll. For short transport timeouts, pass \`wait_for_completion:false\` and poll \`leadbay_import_status\`. Outputs \`qualified[]\`, \`still_running[]\`, \`not_imported[]\`, \`qualify_id\` (resumable handle). Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
3131
+ ## A launched job cannot be stopped
3021
3132
 
3022
- \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` reports the rows the wizard has already placed, not leads a later crawl adds, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
3133
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
3134
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
3135
+ running result, that work is queued on Leadbay and runs to completion, and the
3136
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
3137
+ launched nothing and is not covered here.
3138
+
3139
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
3140
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
3141
+ work stopped. What to do next depends on what you are holding:
3142
+
3143
+ - **A handle.** Poll the status tool with it, and do not launch the work that
3144
+ handle covers a second time \u2014 that spends the quota again on the same rows.
3145
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
3146
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
3147
+ notification of its own: resume it with
3148
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
3149
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
3150
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
3151
+ for what was launched and re-run for that subset only, never for the whole
3152
+ batch.
3153
+ - **No result at all**, because the call timed out or the stream closed before it
3154
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
3155
+ finished. Calling the same tool again with the same arguments will usually hand
3156
+ back the job already launched rather than starting a second one, but that guard
3157
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
3158
+ are about to re-run before you spend the user's quota on it.
3159
+
3160
+
3161
+ Budgets: \`total_budget_ms\` caps wall-clock; \`per_lead_budget_ms\` caps each lead's poll. For short transport timeouts, pass \`wait_for_completion:false\` and poll \`leadbay_import_status\`. Outputs \`qualified[]\`, \`still_running[]\`, \`not_imported[]\`, plus the ids that resume it: \`lead_ids\` + \`lens_id\` for leadbay_qualify_status, \`import_ids\` for leadbay_import_status. There is no qualification \`notification_id\` \u2014 the qualify phase runs per-lead, so no job notification exists; \`notification_ids[]\` are the file-import ones. Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
3162
+
3163
+ \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` returns status/progress only, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
3023
3164
 
3024
3165
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
3025
3166
 
@@ -3044,9 +3185,9 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
3044
3185
  **Header \u2014 single line, choose by status:**
3045
3186
 
3046
3187
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
3047
- - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
3048
- - Running with \`timed_out:true\` (blocking call ran out of poll budget): the import is FINE and still running server-side \u2014 never render this as an error or a failure. \`"\u23F3 Import still running (the backend is slow today) \u2014 I'll check back."\` Then call \`leadbay_import_status({importIds})\`, do NOT re-run leadbay_import_leads. If \`rows_pending_upload\` is present, add \`"\u26A0 K rows weren't submitted \u2014 re-import just those."\`
3049
- - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
3188
+ - Running: \`"\u23F3 Import running \u2014 importIds <ids>; poll leadbay_import_status"\`
3189
+ - Running with \`timed_out:true\` (the blocking call ran out of poll budget): the import is FINE and still running server-side \u2014 never render this as an error or a failure. \`"\u23F3 Import still running (the backend is slow today) \u2014 I'll check back."\` Then call \`leadbay_import_status({importIds})\`, do NOT re-run leadbay_import_leads. If \`rows_pending_upload\` is present, add \`"\u26A0 K rows weren't submitted \u2014 re-import just those."\`
3190
+ - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 I'll pick it up with leadbay_qualify_status"\` (its resume ids are \`lead_ids\` + \`lens_id\`; there is no qualification notification_id to quote)
3050
3191
 
3051
3192
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
3052
3193
 
@@ -3094,7 +3235,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
3094
3235
 
3095
3236
  | Observation | Suggest | Calls |
3096
3237
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
3097
- | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
3238
+ | Status: running | "Check progress" | leadbay_import_status(importIds) |
3098
3239
  | Status: running with \`timed_out:true\` | "Check progress" \u2014 NOT "retry the import" | leadbay_import_status(importIds, dry_run if the result carried it) after ~30s; \`result.leads\` carries the leadIds once complete |
3099
3240
  | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
3100
3241
  | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
@@ -3104,9 +3245,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
3104
3245
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
3105
3246
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
3106
3247
  `;
3107
- leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead_by_id. For MCP clients with short transport timeouts, pass \`wait_for_completion:false\` to return quickly with \`{status:'running', handle_id}\`; poll leadbay_import_status with that handle. For end-to-end import+qualify in one call, prefer leadbay_import_and_qualify. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
3248
+ leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead_by_id. For MCP clients with short transport timeouts, pass \`wait_for_completion:false\` to return quickly with \`{status:'running', importIds}\`; poll leadbay_import_status with that handle. For end-to-end import+qualify in one call, prefer leadbay_import_and_qualify. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
3108
3249
 
3109
- SLOW BACKEND \u21D2 \`{status:'running', timed_out:true, importIds}\`. The wizard is sometimes slow; when the poll budget runs out this tool returns that SUCCESS result, not an error. The import is still running server-side. **Do NOT call leadbay_import_leads again** \u2014 that re-uploads the file and leaves a duplicate CRM-imports row. Call \`leadbay_import_status({importIds})\` after ~30s \u2014 and pass \`dry_run:true\` too if the result carried it; on \`complete\` it returns \`result.leads\` with the leadIds, while \`phase:"committing"\` just means keep polling. Tell the user it's running and you'll check back \u2014 it is not a problem to report. Exception: \`rows_pending_upload\` rows never reached the backend and DO need a fresh call for that subset only. In records mode the result also carries \`row_ids\` \u2014 the synthetic id of each input row, in your \`records[]\` order \u2014 because \`leadbay_import_status\` reports recovered leads by that id; keep it to map them back to your source rows.
3250
+ SLOW BACKEND \u21D2 \`{status:'running', timed_out:true, importIds}\`. The wizard is sometimes slow; when the poll budget runs out this tool returns that SUCCESS result, not an error. The import is still running server-side. **Do NOT call leadbay_import_leads again** \u2014 that re-uploads the file and leaves a duplicate CRM-imports row. Leadbay has no cancel, so a Cancel or timeout is no reason to call it either. Sole exception: a \`wait_for_completion:false\` call that returned NOTHING \u2014 and even that can re-upload, so check CRM-imports. Call \`leadbay_import_status({importIds})\` after ~30s \u2014 and pass \`dry_run:true\` too if the result carried it; on \`complete\` it returns \`result.leads\` with the leadIds, while \`phase:"committing"\` just means keep polling. Tell the user it's running and you'll check back \u2014 it is not a problem to report. Exception: \`rows_pending_upload\` rows never reached the backend and DO need a fresh call for that subset only. In records mode the result also carries \`row_ids\` \u2014 the synthetic id of each input row, in your \`records[]\` order \u2014 because \`leadbay_import_status\` reports recovered leads by that id; keep it to map them back to your source rows.
3110
3251
 
3111
3252
  TWO MODES: (A) Domain-list shortcut \u2014 pass \`domains: [{domain, name?}]\`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. (B) Custom records + mapping \u2014 pass \`records: [{Col1, Col2, ...}]\` plus \`mappings.fields: {Col1: 'LEAD_NAME', ...}\`. \`mappings.fields\` must include LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE (resolver needs at least one identity key). Pass exactly one of \`domains\` / \`records\`. Reserved column \`MCP_ROW_ID\` cannot appear in records/mappings \u2014 the tool injects it for stable reconciliation.
3112
3253
 
@@ -3141,9 +3282,9 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
3141
3282
  **Header \u2014 single line, choose by status:**
3142
3283
 
3143
3284
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
3144
- - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
3145
- - Running with \`timed_out:true\` (blocking call ran out of poll budget): the import is FINE and still running server-side \u2014 never render this as an error or a failure. \`"\u23F3 Import still running (the backend is slow today) \u2014 I'll check back."\` Then call \`leadbay_import_status({importIds})\`, do NOT re-run leadbay_import_leads. If \`rows_pending_upload\` is present, add \`"\u26A0 K rows weren't submitted \u2014 re-import just those."\`
3146
- - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
3285
+ - Running: \`"\u23F3 Import running \u2014 importIds <ids>; poll leadbay_import_status"\`
3286
+ - Running with \`timed_out:true\` (the blocking call ran out of poll budget): the import is FINE and still running server-side \u2014 never render this as an error or a failure. \`"\u23F3 Import still running (the backend is slow today) \u2014 I'll check back."\` Then call \`leadbay_import_status({importIds})\`, do NOT re-run leadbay_import_leads. If \`rows_pending_upload\` is present, add \`"\u26A0 K rows weren't submitted \u2014 re-import just those."\`
3287
+ - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 I'll pick it up with leadbay_qualify_status"\` (its resume ids are \`lead_ids\` + \`lens_id\`; there is no qualification notification_id to quote)
3147
3288
 
3148
3289
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
3149
3290
 
@@ -3191,7 +3332,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
3191
3332
 
3192
3333
  | Observation | Suggest | Calls |
3193
3334
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
3194
- | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
3335
+ | Status: running | "Check progress" | leadbay_import_status(importIds) |
3195
3336
  | Status: running with \`timed_out:true\` | "Check progress" \u2014 NOT "retry the import" | leadbay_import_status(importIds, dry_run if the result carried it) after ~30s; \`result.leads\` carries the leadIds once complete |
3196
3337
  | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
3197
3338
  | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
@@ -3201,11 +3342,45 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
3201
3342
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
3202
3343
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
3203
3344
  `;
3204
- leadbay_import_status = `Retrieve the current **status/progress** of a lead import, and its leadIds once it finishes. Pass \`handle_id\` \u2014 returned by either \`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` when called with \`wait_for_completion:false\` \u2014 to resolve the stored result (leads + not_imported) for that async run. Pass \`importIds[]\` when you don't have a handle: after a blocking \`leadbay_import_leads\` returned \`{status:'running', timed_out:true, importIds}\`, or from any completed import's \`importIds\` (\`leadbay_import_and_qualify\` calls the same field \`import_ids\`). **Both paths return leads.** The \`importIds[]\` path reads the wizard's records directly, so once every named import is \`complete\` (and it wasn't a dry run) the response carries \`result.leads\` + \`result.not_imported\` \u2014 that is how you recover the leadIds from an import that timed out mid-poll, without re-importing. \`result.still_settling\` counts rows the wizard hasn't finished placing; they are neither imported nor failed, so poll again rather than reporting them. If \`result\` is absent on a \`complete\` import the records weren't readable \u2014 report completion without inventing counts. \`phase:"committing"\` means the import is still being committed, NOT that it finished with nothing: keep polling. Pass \`dry_run:true\` when the importIds came from a dry run (a \`{timed_out:true}\` result carries \`dry_run\` \u2014 hand it straight back); a finished dry run and an import mid-commit are identical on the wire, so without that flag this tool reports \`committing\` rather than risk rendering a validation pass as a real import. This status call performs a single refresh pass and never polls in a loop.
3345
+ leadbay_import_status = `Retrieve the current **status/progress** of a lead import, and its leadIds once it finishes. Pass the \`importIds\` the launch returned \u2014 \`leadbay_import_leads\` returns \`importIds\`, \`leadbay_import_and_qualify\` returns \`import_ids\`. These are the backend's own import ids, so they resolve from a later message, a later conversation, or the next day; nothing is stored on the MCP side. Also pass the \`dry_run\` the import was launched with, so completion is judged against the right phase (a dry run finishes at preprocess, a real import at processing). This status call performs a single refresh pass and never polls in a loop.
3346
+
3347
+ WHEN TO USE: after an async import returns its ids \u2014 \`leadbay_import_leads\` as \`{status:'running', importIds}\`, \`leadbay_import_and_qualify\` as \`import_ids\` \u2014 poll with those; OR to check whether a finished import is still processing. This tool does NOT surface the leads Leadbay adds later for pending-crawl (\`uncrawled\`) rows \u2014 those populate in the user's Leadbay account as the crawl completes; no tool here fetches them on demand (re-run the import to pull them back through the MCP).
3348
+
3349
+ WHEN NOT TO USE: for the qualification half \u2014 use leadbay_qualify_status, with the \`lead_ids\` + \`lens_id\` an \`leadbay_import_and_qualify\` launch returned (it has no qualification \`notification_id\`; its \`notification_ids[]\` are these same file imports); or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
3350
+
3351
+ ## A launched job cannot be stopped
3205
3352
 
3206
- WHEN TO USE: after an async import (\`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` with \`wait_for_completion:false\`) returns \`{status:'running', handle_id}\`, poll with that \`handle_id\`; OR to check whether a finished import is still processing. This tool does NOT surface the leads Leadbay adds later for pending-crawl (\`uncrawled\`) rows \u2014 those populate in the user's Leadbay account as the crawl completes; no tool here fetches them on demand (re-run the import to pull them back through the MCP).
3353
+ Leadbay has no cancel. A job started by \`leadbay_enrich_titles\`,
3354
+ \`leadbay_bulk_qualify_leads\`, \`leadbay_import_leads\` or
3355
+ \`leadbay_import_and_qualify\` runs to completion on Leadbay. The user cancelling
3356
+ in the chat, a request timeout, or a closed stream stops YOUR waiting, never the
3357
+ job, and \`cancelled: true\` on an earlier result means we stopped watching, not
3358
+ that the work stopped.
3359
+
3360
+ **This tool only reads.** Calling it again launches nothing and spends no quota,
3361
+ so poll it as often as the job needs \u2014 a timeout here is a reason to call it
3362
+ again, not a reason to stop.
3363
+
3364
+ One import state does NOT progress: a chunk cancelled before its mappings were
3365
+ committed reads \`running\` / \`committing\` forever. If the counts hold flat across
3366
+ several spaced polls, say so and stop, rather than polling on.
3367
+
3368
+ What must not be repeated is the LAUNCH \u2014 for work that actually launched. Re-run
3369
+ a launcher only for a subset that never started, never for the whole batch:
3370
+
3371
+ - \`failed[]\` entries with \`error:"not_queued"\`;
3372
+ - a \`rows_pending_upload\` count;
3373
+ - leads in \`still_running\` after a CANCELLED \`leadbay_import_and_qualify\`. Its
3374
+ fan-out is sequential, so an interruption leaves the remainder unlaunched and
3375
+ folds them in with the ones that did launch. Nothing in the result tells the
3376
+ two apart, and this tool cannot start either. Wait until the REST of the batch
3377
+ has settled: what launched settles in order, so leads still unanswered after
3378
+ that are the ones that never started. Only then call
3379
+ \`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
3380
+ that is merely slow looks identical to one that never launched over a few
3381
+ polls, and re-launching it charges the user twice \u2014 when unsure, tell the user
3382
+ rather than guess.
3207
3383
 
3208
- WHEN NOT TO USE: for qualification handles returned as \`qualify_id\` \u2014 use leadbay_qualify_status for those; or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
3209
3384
 
3210
3385
  ---
3211
3386
 
@@ -3225,12 +3400,11 @@ After the status line, propose the obvious refresh / progress-check / recovery a
3225
3400
 
3226
3401
  Specifically for import status:
3227
3402
 
3228
- This tool returns \`status\`, \`importIds\`, and \`progress\` ({phase, records_processed, records_total}). It carries a \`result\` object (with \`leads\` + \`not_imported\`) when resolving an async \`handle_id\` whose run completed in this MCP instance, AND on the \`importIds[]\` path once every named import is \`complete\` and it wasn't a dry run. A \`complete\` import with no \`result\` means the records weren't readable \u2014 render completion only. **Render only from the fields actually present; never invent counts.**
3403
+ This tool returns \`status\`, \`importIds\`, and \`progress\` ({phase, records_processed, records_total}). Once every named import is \`complete\` and it wasn't a dry run, it also reconciles the wizard's records and carries \`result\` ({leads, not_imported, importIds, still_settling?}) \u2014 that is how you recover the leadIds of an import you stopped watching, without re-importing. \`result.still_settling\` counts rows the wizard hasn't finished placing; they are neither imported nor failed, so poll again rather than reporting them. If \`result\` is absent on a \`complete\` import the records weren't readable \u2014 report completion without inventing counts. **Render only from the fields actually present; never invent counts.**
3229
3404
 
3230
3405
  Caveat on \`progress\`: \`records_processed\` counts only the rows that MATCHED an existing lead (backend \`imported_records\`), not every row that finished processing \u2014 so for a complete import whose rows are mostly/all \`uncrawled\` (pending crawl), \`records_processed\` is legitimately low or 0. Never read a low \`records_processed\` on a \`complete\` import as "stuck" or "failed": once \`status:"complete"\`, processing is done; the pending-crawl rows just matched no existing lead yet.
3231
3406
 
3232
- - Running \u2192 \`"\u23F3 Import still running \u2014 phase <phase>; check back in ~M minutes."\` (use the phase; don't turn the matched-count into an "X/Y processed" progress bar). \`phase:"committing"\` is normal for an import that timed out \u2014 say it's still being committed, never that it failed or finished empty.
3233
- - Complete with **\`dry_run:true\`** on the response \u2192 a VALIDATION pass; nothing was committed. \`"\u{1F50E} Dry run complete \u2014 input validated, nothing imported. Re-run without dry_run to commit."\` Never render this as a completed import, and never quote a lead count.
3407
+ - Running \u2192 \`"\u23F3 Import still running \u2014 phase <phase>; check back in ~M minutes."\` (use the phase; don't turn the matched-count into an "X/Y processed" progress bar). \`phase:"committing"\` means the mappings are still being committed \u2014 say it's still being committed, never that it failed or finished empty.
3234
3408
  - Complete, **no \`result\`** \u2192 \`"\u2713 Import complete."\` Do NOT append a \`records_processed/records_total\` fraction (it undercounts pending-crawl rows and looks stuck) and do NOT report pending-crawl / need-attention bucket counts \u2014 the row-level \`not_imported\` breakdown isn't in this response.
3235
3409
  - Complete with \`result.still_settling > 0\` \u2192 say \`"\u2713 Import complete \u2014 N imported, S rows still being placed."\` Never count \`still_settling\` rows as failures.
3236
3410
  - Complete, **\`result\` present AND it was a dry run** (\`result.dry_run:true\`, or every \`result.not_imported\` row has \`reason:"dry_run"\`) \u2192 this resolved handle was a VALIDATION pass, nothing committed. Render \`"\u{1F50E} Dry run complete \u2014 V rows validated, nothing imported. Re-run without dry_run to commit."\` \u2014 do NOT render it as a real import completion or use the pending/attention buckets.
@@ -3262,12 +3436,29 @@ How the OTHER reasons map to the "Need attention" bucket (see the render block a
3262
3436
  | Status: running (incl. \`committing\`) | "Check again in N minutes" | leadbay_import_status \u2014 re-call (pass \`dry_run:true\` if the ids came from a dry run) |
3263
3437
  | Status: error / failed (true error) | "Diagnose the failure" | leadbay_resolve_import_rows |
3264
3438
  `;
3265
- leadbay_launch_bulk_enrichment = `Launch a bulk-enrichment job against the current selection. The backend requires \`email=true\` OR \`phone=true\` (both can be true). Returns 204 with no body \u2014 there is no bulk_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; a contact is done for this run only when the REQUESTED channel landed (requested \`email\` and/or \`phone_number\` present), not \`contact.enrichment.done\` alone (that flag is already true for a contact enriched on the other channel earlier). \`dry_run:true\` returns the call shape without contacting the backend.
3439
+ leadbay_launch_bulk_enrichment = `Launch a bulk-enrichment job against the current selection. The backend requires \`email=true\` OR \`phone=true\` (both can be true). Returns 204 with no body \u2014 there is no notification_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; a contact is done for this run only when the REQUESTED channel landed (requested \`email\` and/or \`phone_number\` present), not \`contact.enrichment.done\` alone (that flag is already true for a contact enriched on the other channel earlier). \`dry_run:true\` returns the call shape without contacting the backend.
3266
3440
 
3267
3441
  WHEN TO USE: low-level.
3268
3442
 
3269
3443
  WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.
3270
3444
 
3445
+ ## A launched job cannot be stopped, and this tool has no retry guard
3446
+
3447
+ Leadbay has no cancel. Once this call returns having actually launched, the work
3448
+ is queued on Leadbay and runs to completion, and the quota it costs is already
3449
+ committed. A \`dry_run\` result reached no backend and spent nothing. The user
3450
+ cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
3451
+ never the job.
3452
+
3453
+ Unlike the composite launchers, this tool has **no double-launch guard**: calling
3454
+ it again always issues a new paid launch, even seconds later with identical
3455
+ arguments. So when a call returns nothing at all, do not simply retry. Read the
3456
+ record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
3457
+ lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
3458
+ see whether the launch already landed, and tell the user what you are about to
3459
+ spend before spending it again.
3460
+
3461
+
3271
3462
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
3272
3463
  `;
3273
3464
  leadbay_like_lead = `## WHEN TO USE
@@ -4337,13 +4528,70 @@ WHEN TO USE: low-level \u2014 when you need to kick qualification on exactly one
4337
4528
 
4338
4529
  WHEN NOT TO USE: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualify_leads, which paginates past already-qualified leads, fans out, polls, and bails out cleanly on 429.
4339
4530
 
4531
+ ## A launched job cannot be stopped, and this tool has no retry guard
4532
+
4533
+ Leadbay has no cancel. Once this call returns having actually launched, the work
4534
+ is queued on Leadbay and runs to completion, and the quota it costs is already
4535
+ committed. A \`dry_run\` result reached no backend and spent nothing. The user
4536
+ cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
4537
+ never the job.
4538
+
4539
+ Unlike the composite launchers, this tool has **no double-launch guard**: calling
4540
+ it again always issues a new paid launch, even seconds later with identical
4541
+ arguments. So when a call returns nothing at all, do not simply retry. Read the
4542
+ record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
4543
+ lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
4544
+ see whether the launch already landed, and tell the user what you are about to
4545
+ spend before spending it again.
4546
+
4547
+
4340
4548
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
4341
4549
  `;
4342
- leadbay_qualify_status = `Retrieve the current state of an import_and_qualify (or bulk_qualify_leads) launch by \`qualify_id\`. Returns the same \`qualified[]\` / \`still_running[]\` shape as the original composite, refreshed against the backend at call time. The handle is persisted to \`~/.leadbay/bulks.json\` with a 30-day TTL and survives MCP restart.
4550
+ leadbay_qualify_status = `Retrieve the current state of a bulk_qualify_leads or import_and_qualify launch. Which ids to pass depends on which tool launched it, because only one of them creates a qualification job on the backend:
4343
4551
 
4344
- WHEN TO USE: after leadbay_import_and_qualify or leadbay_bulk_qualify_leads returned a \`qualify_id\` with non-empty \`still_running[]\`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.
4552
+ - **\`leadbay_bulk_qualify_leads\`** returns a \`notification_id\`. Pass it for progress in ONE call, and add the \`lead_ids\` + \`lens_id\` it also returned for per-lead detail (which settled, which are still running).
4553
+ - **\`leadbay_import_and_qualify\`** returns NO qualification \`notification_id\` \u2014 its qualify phase runs per-lead, so no job notification exists. Pass the \`lead_ids\` + \`lens_id\` it returned. Its \`notification_ids[]\` are the FILE-IMPORT notifications; handing one of those to this tool is rejected as the wrong kind, and \`leadbay_import_status({importIds})\` is where the import half is polled.
4554
+
4555
+ **When it is finished:** \`status\` is always \`"launched"\` \u2014 it is not a progress field. On the \`notification_id\` path the job is done when \`in_progress\` is false (or \`bulk_progress.success_count + failure_count\` reaches \`total_count\`); \`still_running[]\` is empty on that path from the very first poll and must NOT be read as "done". On the \`lead_ids\` path the job is done when \`still_running[]\` is empty. Pass both and you get both signals in one call.
4556
+
4557
+ Everything comes straight out of the launch response \u2014 nothing is stored on the MCP side. A backend job is scoped to the user who launched it, so a \`notification_id\` resolves from a later message, a later conversation, or the next day.
4558
+
4559
+ WHEN TO USE: after leadbay_bulk_qualify_leads or leadbay_import_and_qualify came back with a non-empty \`still_running[]\`, call this tool a few minutes later (or hours) with those ids to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.
4345
4560
 
4346
4561
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.
4562
+
4563
+ ## A launched job cannot be stopped
4564
+
4565
+ Leadbay has no cancel. A job started by \`leadbay_enrich_titles\`,
4566
+ \`leadbay_bulk_qualify_leads\`, \`leadbay_import_leads\` or
4567
+ \`leadbay_import_and_qualify\` runs to completion on Leadbay. The user cancelling
4568
+ in the chat, a request timeout, or a closed stream stops YOUR waiting, never the
4569
+ job, and \`cancelled: true\` on an earlier result means we stopped watching, not
4570
+ that the work stopped.
4571
+
4572
+ **This tool only reads.** Calling it again launches nothing and spends no quota,
4573
+ so poll it as often as the job needs \u2014 a timeout here is a reason to call it
4574
+ again, not a reason to stop.
4575
+
4576
+ One import state does NOT progress: a chunk cancelled before its mappings were
4577
+ committed reads \`running\` / \`committing\` forever. If the counts hold flat across
4578
+ several spaced polls, say so and stop, rather than polling on.
4579
+
4580
+ What must not be repeated is the LAUNCH \u2014 for work that actually launched. Re-run
4581
+ a launcher only for a subset that never started, never for the whole batch:
4582
+
4583
+ - \`failed[]\` entries with \`error:"not_queued"\`;
4584
+ - a \`rows_pending_upload\` count;
4585
+ - leads in \`still_running\` after a CANCELLED \`leadbay_import_and_qualify\`. Its
4586
+ fan-out is sequential, so an interruption leaves the remainder unlaunched and
4587
+ folds them in with the ones that did launch. Nothing in the result tells the
4588
+ two apart, and this tool cannot start either. Wait until the REST of the batch
4589
+ has settled: what launched settles in order, so leads still unanswered after
4590
+ that are the ones that never started. Only then call
4591
+ \`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
4592
+ that is merely slow looks identical to one that never launched over a few
4593
+ polls, and re-launching it charges the user twice \u2014 when unsure, tell the user
4594
+ rather than guess.
4347
4595
  `;
4348
4596
  leadbay_recall_ordered_titles = `Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED \u2014 a selection-scoped preview call that reads \`previously_enriched_titles\` from the backend (newer prod field). (2) FALLBACK \u2014 live aggregation across each lead's enriched contacts. The composite picks transparently.
4349
4597
 
@@ -5939,7 +6187,7 @@ Some Leadbay tool responses include a \`_meta.notifications\` array listing **ba
5939
6187
  - \`leadbay_qualify_status\` \u2192 \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read \u2014 so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run \u2014 return the handle/progress and let completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline.
5940
6188
  - \`leadbay_import_status\` \u2192 \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it \u2014 surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly.
5941
6189
 
5942
- Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the bulk_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
6190
+ Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the notification_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
5943
6191
 
5944
6192
  Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
5945
6193
 
@@ -6852,7 +7100,9 @@ var init_qualify_lead = __esm({
6852
7100
  title: "Qualify a single lead",
6853
7101
  readOnlyHint: false,
6854
7102
  destructiveHint: true,
6855
- idempotentHint: true,
7103
+ // No double-launch guard: this POSTs straight through, so an identical
7104
+ // repeat is a second paid launch, not a no-op (product#4039).
7105
+ idempotentHint: false,
6856
7106
  openWorldHint: true
6857
7107
  },
6858
7108
  description: leadbay_qualify_lead,
@@ -6928,7 +7178,9 @@ var init_enrich_contacts = __esm({
6928
7178
  title: "Enrich contacts for a lead",
6929
7179
  readOnlyHint: false,
6930
7180
  destructiveHint: true,
6931
- idempotentHint: true,
7181
+ // No double-launch guard: this POSTs straight through, so an identical
7182
+ // repeat is a second paid launch, not a no-op (product#4039).
7183
+ idempotentHint: false,
6932
7184
  openWorldHint: true
6933
7185
  },
6934
7186
  description: leadbay_enrich_contacts,
@@ -9012,6 +9264,74 @@ var init_qualify_helpers = __esm({
9012
9264
  }
9013
9265
  });
9014
9266
 
9267
+ // ../core/dist/jobs/launch-guard.js
9268
+ import { createHash as createHash2 } from "crypto";
9269
+ function launchFingerprint(parts) {
9270
+ const flat = parts.map((p) => Array.isArray(p) ? [...p].sort().join(",") : String(p)).join("|");
9271
+ return createHash2("sha256").update(flat).digest("hex");
9272
+ }
9273
+ function sweep(now) {
9274
+ for (const [k, v] of recent) {
9275
+ if (now - v.at >= WINDOW_MS)
9276
+ recent.delete(k);
9277
+ }
9278
+ }
9279
+ function beginLaunch(fingerprint, now = Date.now()) {
9280
+ const prior = recallLaunch(fingerprint, now);
9281
+ if (prior) {
9282
+ return prior.in_flight ? { state: "in_flight", seconds_since: prior.seconds_since } : { state: "settled", record: prior };
9283
+ }
9284
+ recent.set(fingerprint, {
9285
+ notification_id: null,
9286
+ in_flight: true,
9287
+ launched_at: new Date(now).toISOString(),
9288
+ at: now
9289
+ });
9290
+ return { state: "owned" };
9291
+ }
9292
+ function abandonLaunch(fingerprint) {
9293
+ const held = recent.get(fingerprint);
9294
+ if (held?.in_flight)
9295
+ recent.delete(fingerprint);
9296
+ }
9297
+ function recallLaunch(fingerprint, now = Date.now()) {
9298
+ sweep(now);
9299
+ const hit = recent.get(fingerprint);
9300
+ if (!hit)
9301
+ return void 0;
9302
+ return { ...hit, seconds_since: Math.round((now - hit.at) / 1e3) };
9303
+ }
9304
+ function rememberLaunch(fingerprint, notificationId, now = Date.now(), importIds) {
9305
+ sweep(now);
9306
+ while (recent.size >= MAX_ENTRIES) {
9307
+ const oldest = recent.keys().next();
9308
+ if (oldest.done)
9309
+ break;
9310
+ recent.delete(oldest.value);
9311
+ }
9312
+ const rec = {
9313
+ notification_id: notificationId,
9314
+ in_flight: false,
9315
+ ...importIds ? { import_ids: importIds } : {},
9316
+ launched_at: new Date(now).toISOString(),
9317
+ at: now
9318
+ };
9319
+ recent.set(fingerprint, rec);
9320
+ return rec;
9321
+ }
9322
+ function resetLaunchGuard() {
9323
+ recent.clear();
9324
+ }
9325
+ var WINDOW_MS, MAX_ENTRIES, recent;
9326
+ var init_launch_guard = __esm({
9327
+ "../core/dist/jobs/launch-guard.js"() {
9328
+ "use strict";
9329
+ WINDOW_MS = 5 * 60 * 1e3;
9330
+ MAX_ENTRIES = 1e3;
9331
+ recent = /* @__PURE__ */ new Map();
9332
+ }
9333
+ });
9334
+
9015
9335
  // ../core/dist/composite/_import-records.js
9016
9336
  function normalizeDomain(input) {
9017
9337
  if (!input || typeof input !== "string")
@@ -9215,7 +9535,7 @@ var init_import_records = __esm({
9215
9535
 
9216
9536
  // ../core/dist/composite/_import-commit-log.js
9217
9537
  function recordCommitFailure(importId, reason) {
9218
- if (failures.size >= MAX_ENTRIES) {
9538
+ if (failures.size >= MAX_ENTRIES2) {
9219
9539
  const oldest = failures.keys().next().value;
9220
9540
  if (oldest !== void 0)
9221
9541
  failures.delete(oldest);
@@ -9230,17 +9550,17 @@ function commitFailureFor(importIds) {
9230
9550
  }
9231
9551
  return void 0;
9232
9552
  }
9233
- var MAX_ENTRIES, failures;
9553
+ var MAX_ENTRIES2, failures;
9234
9554
  var init_import_commit_log = __esm({
9235
9555
  "../core/dist/composite/_import-commit-log.js"() {
9236
9556
  "use strict";
9237
- MAX_ENTRIES = 500;
9557
+ MAX_ENTRIES2 = 500;
9238
9558
  failures = /* @__PURE__ */ new Map();
9239
9559
  }
9240
9560
  });
9241
9561
 
9242
9562
  // ../core/dist/composite/import-leads.js
9243
- import { createHash as createHash2, randomUUID } from "crypto";
9563
+ import { createHash as createHash3, randomUUID } from "crypto";
9244
9564
  function isImportLeadsRunningResult(result) {
9245
9565
  return "status" in result && result.status === "running";
9246
9566
  }
@@ -9302,7 +9622,7 @@ function importFingerprint(params, prep) {
9302
9622
  mappings: prep.mappings,
9303
9623
  dry_run: Boolean(params.dry_run)
9304
9624
  };
9305
- return createHash2("sha256").update(stableStringify(payload)).digest("hex");
9625
+ return createHash3("sha256").update(stableStringify(payload)).digest("hex");
9306
9626
  }
9307
9627
  function checkAborted(signal) {
9308
9628
  if (signal?.aborted) {
@@ -9677,7 +9997,7 @@ async function commitMappings(client, importId, mappings, ctx) {
9677
9997
  throw err;
9678
9998
  }
9679
9999
  }
9680
- async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId) {
10000
+ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId, onCommitSent) {
9681
10001
  const { importId, chunk } = upload;
9682
10002
  const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
9683
10003
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
@@ -9686,6 +10006,7 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
9686
10006
  return { importId, records: [], notification_id: null };
9687
10007
  }
9688
10008
  const importNotificationId = await commitMappings(client, importId, mappings, ctx);
10009
+ onCommitSent?.();
9689
10010
  if (importNotificationId) {
9690
10011
  onNotificationId?.(importNotificationId);
9691
10012
  ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
@@ -9851,27 +10172,24 @@ function resumeParkedUpload(client, upload, mappings, ctx) {
9851
10172
  });
9852
10173
  }, 0);
9853
10174
  }
9854
- async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
9855
- const tracker = ctx.bulkTracker;
9856
- if (!tracker)
9857
- return;
9858
- void tracker.setImportProgress(handleId, {
9859
- phase: "preprocess",
9860
- records_processed: 0,
9861
- records_total: prep.validInputs.length
9862
- }).catch(() => {
9863
- });
10175
+ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx) {
9864
10176
  setTimeout(() => {
9865
10177
  void (async () => {
9866
- const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
10178
+ const bgCtx = { logger: ctx.logger };
9867
10179
  const importIds = uploadedChunks.map((chunk) => chunk.importId);
9868
10180
  const notificationIds = [];
9869
10181
  const matched = /* @__PURE__ */ new Map();
9870
10182
  const notImported = /* @__PURE__ */ new Map();
10183
+ let inFlight = 0;
10184
+ let committed = false;
9871
10185
  try {
9872
10186
  const totalDeadline = Date.now() + opts.totalBudget;
9873
- for (const upload of uploadedChunks) {
9874
- const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
10187
+ for (inFlight = 0; inFlight < uploadedChunks.length; inFlight++) {
10188
+ const upload = uploadedChunks[inFlight];
10189
+ committed = false;
10190
+ const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0, void 0, () => {
10191
+ committed = true;
10192
+ });
9875
10193
  if (out.notification_id && !notificationIds.includes(out.notification_id)) {
9876
10194
  notificationIds.push(out.notification_id);
9877
10195
  }
@@ -9879,22 +10197,28 @@ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, ha
9879
10197
  reconcileOneChunk(prep, out, matched, notImported);
9880
10198
  }
9881
10199
  }
9882
- const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
9883
- await tracker.markImportComplete(handleId, {
9884
- leads: result.leads,
9885
- not_imported: result.not_imported,
9886
- importIds: result.importIds
9887
- });
10200
+ buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
9888
10201
  } catch (err) {
9889
- await tracker.markImportFailed(handleId, err?.message ?? err?.code ?? "unknown");
10202
+ if (!opts.dryRun) {
10203
+ const parkedFrom = committed ? inFlight + 1 : inFlight;
10204
+ for (const parked of uploadedChunks.slice(parkedFrom)) {
10205
+ resumeParkedUpload(client, parked, prep.mappings, bgCtx);
10206
+ }
10207
+ }
10208
+ ctx?.logger?.warn?.(`import-leads: background import failed for ${importIds.join(",")}: ${err?.message ?? err?.code ?? err}`);
9890
10209
  }
9891
- })();
10210
+ })().catch((e) => (
10211
+ // Terminal guard. Nothing may escape a detached task — an unhandled
10212
+ // rejection would take the whole multi-tenant hosted process down.
10213
+ ctx?.logger?.error?.(`import-leads: background import crashed: ${e?.message ?? e}`)
10214
+ ));
9892
10215
  }, 0);
9893
10216
  }
9894
10217
  var CHUNK_SIZE, POLL_INTERVAL_MS2, DEFAULT_PER_PHASE_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS, STABILIZATION_POLLS, MAX_COLUMN_NAME_LEN, RESERVED_COLUMN_RE, CUSTOM_FIELD_RE, IMPORT_RESOLVER_FIELDS, ImportPhaseTimeout, LEAD_STATUSES, LEAD_STATUS_SET, importLeads, DEFAULT_RESUME_COMMIT_BUDGET_MS, resumeCommitBudgetMs;
9895
10218
  var init_import_leads = __esm({
9896
10219
  "../core/dist/composite/import-leads.js"() {
9897
10220
  "use strict";
10221
+ init_launch_guard();
9898
10222
  init_tool_descriptions_generated();
9899
10223
  init_import_records();
9900
10224
  init_import_commit_log();
@@ -10017,7 +10341,7 @@ var init_import_leads = __esm({
10017
10341
  },
10018
10342
  wait_for_completion: {
10019
10343
  type: "boolean",
10020
- description: "When false, validate and enqueue the import in the background, then return `{status:'running', handle_id}` immediately. Poll leadbay_import_status(handle_id). Default is true for 0.6.x backwards compatibility."
10344
+ description: "When false, upload the rows and return `{status:'running', importIds}` immediately. Poll leadbay_import_status({importIds, dry_run}) \u2014 those importIds are the backend's own and keep working from any conversation. Default is true."
10021
10345
  }
10022
10346
  },
10023
10347
  // Neither field is "required" at the schema level; xor + presence is
@@ -10036,10 +10360,6 @@ var init_import_leads = __esm({
10036
10360
  type: "string",
10037
10361
  description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
10038
10362
  },
10039
- handle_id: {
10040
- type: "string",
10041
- description: "Persisted UUID handle to pass to leadbay_import_status. Only on the wait_for_completion=false path; absent when a blocking call timed out (use importIds then)."
10042
- },
10043
10363
  timed_out: {
10044
10364
  type: "boolean",
10045
10365
  description: "True when a blocking call ran out of poll budget. The import is still running server-side \u2014 poll leadbay_import_status(importIds). Do NOT re-issue the import."
@@ -10070,7 +10390,7 @@ var init_import_leads = __esm({
10070
10390
  region: { type: "string" },
10071
10391
  cancelled: {
10072
10392
  type: "boolean",
10073
- description: "True when ctx.signal aborted the call mid-flight."
10393
+ description: "True when the HOST cancelled the call (a user Cancel, or the host's own request timeout); a Leadbay-side or phase-budget timeout reports `timed_out` instead. Rows already uploaded keep importing on Leadbay \u2014 poll leadbay_import_status with importIds, but a chunk cancelled before its mappings were committed can read `running` without ever progressing; if the counts stop moving, say so rather than polling on. Rows past the interruption may never have been uploaded; re-run the import for those rows only."
10074
10394
  },
10075
10395
  dry_run: {
10076
10396
  type: "boolean",
@@ -10138,62 +10458,72 @@ var init_import_leads = __esm({
10138
10458
  }
10139
10459
  const chunks = chunkAt100(prep.validInputs);
10140
10460
  if (!waitForCompletion) {
10141
- if (!ctx?.bulkTracker) {
10142
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_leads wait_for_completion=false needs a BulkTracker so the handle survives restart.", "");
10143
- }
10144
- const reservation = await ctx.bulkTracker.findOrCreatePendingImport({
10145
- import_fingerprint: importFingerprint(params, prep),
10146
- mode: prep.mode,
10147
- dry_run: dryRun,
10148
- records_total: prep.validInputs.length
10149
- });
10150
- const importIds2 = [...reservation.record.import_ids];
10151
- const uploadedChunks = [];
10152
- if (!reservation.reused || reservation.record.import_ids.length === 0) {
10153
- try {
10154
- for (let i = 0; i < chunks.length; i++) {
10155
- const upload = await uploadOneChunk(client, chunks[i], i, chunks.length, prep.header, ctx, (id) => {
10156
- if (!importIds2.includes(id))
10157
- importIds2.push(id);
10158
- });
10159
- uploadedChunks.push(upload);
10160
- await ctx.bulkTracker.setImportIds(reservation.record.bulk_id, importIds2);
10161
- }
10162
- await ctx.bulkTracker.setImportProgress(reservation.record.bulk_id, {
10461
+ const fingerprint = launchFingerprint([
10462
+ "import",
10463
+ prep.mode,
10464
+ dryRun,
10465
+ // Cached /users/me — the admin gate above already read it, so this
10466
+ // costs no round trip. Scopes the fingerprint to the organization.
10467
+ await client.resolveOrgId(),
10468
+ importFingerprint(params, prep)
10469
+ ]);
10470
+ const claim = beginLaunch(fingerprint);
10471
+ if (claim.state === "in_flight") {
10472
+ throw client.makeError("IMPORT_LAUNCH_IN_FLIGHT", `An identical import was started ${claim.seconds_since}s ago and has not returned its import ids yet`, "Nothing was uploaded twice. Call leadbay_import_leads again with the same arguments in a few seconds to receive the importIds.", "");
10473
+ }
10474
+ const already = claim.state === "settled" ? claim.record : void 0;
10475
+ if (already) {
10476
+ return {
10477
+ status: "running",
10478
+ importIds: already.import_ids ?? [],
10479
+ notification_ids: [],
10480
+ progress: {
10163
10481
  phase: "preprocess",
10164
10482
  records_processed: 0,
10165
10483
  records_total: prep.validInputs.length
10484
+ },
10485
+ region: client.region,
10486
+ reused: true,
10487
+ seconds_since_original: already.seconds_since,
10488
+ _meta: client.lastMeta ?? {
10489
+ region: client.region,
10490
+ endpoint: "POST /imports",
10491
+ latency_ms: null,
10492
+ retry_after: null
10493
+ }
10494
+ };
10495
+ }
10496
+ const importIds2 = [];
10497
+ const uploadedChunks = [];
10498
+ try {
10499
+ for (let i = 0; i < chunks.length; i++) {
10500
+ const upload = await uploadOneChunk(client, chunks[i], i, chunks.length, prep.header, ctx, (id) => {
10501
+ if (!importIds2.includes(id))
10502
+ importIds2.push(id);
10166
10503
  });
10167
- } catch (err) {
10168
- await ctx.bulkTracker.markImportFailed(reservation.record.bulk_id, err?.message ?? err?.code ?? "unknown");
10169
- throw err;
10504
+ uploadedChunks.push(upload);
10170
10505
  }
10506
+ } catch (err) {
10507
+ abandonLaunch(fingerprint);
10508
+ throw err;
10171
10509
  }
10510
+ rememberLaunch(fingerprint, null, void 0, importIds2);
10172
10511
  if (uploadedChunks.length > 0) {
10173
- void runImportInBackground(client, prep, uploadedChunks, {
10174
- dryRun,
10175
- perPhaseBudget,
10176
- totalBudget
10177
- }, ctx, reservation.record.bulk_id);
10512
+ void runImportInBackground(client, prep, uploadedChunks, { dryRun, perPhaseBudget, totalBudget }, ctx ?? {});
10178
10513
  }
10179
10514
  return {
10180
10515
  status: "running",
10181
- handle_id: reservation.record.bulk_id,
10182
10516
  importIds: importIds2,
10183
- // Notifications fire from update_mappings, which the background
10184
- // task hasn't called yet at this point. They surface via the WS
10185
- // listener / catch-up REST on subsequent agent turns.
10517
+ // Notifications fire from update_mappings, which the background task
10518
+ // hasn't called yet. They surface via the WS listener / catch-up REST
10519
+ // on subsequent turns.
10186
10520
  notification_ids: [],
10187
10521
  progress: {
10188
- phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
10189
- records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
10190
- records_total: reservation.record.records_total
10522
+ phase: importIds2.length > 0 ? "preprocess" : "queued",
10523
+ records_processed: 0,
10524
+ records_total: prep.validInputs.length
10191
10525
  },
10192
10526
  region: client.region,
10193
- ...reservation.reused ? {
10194
- reused: true,
10195
- seconds_since_original: reservation.seconds_since_original
10196
- } : {},
10197
10527
  _meta: client.lastMeta ?? {
10198
10528
  region: client.region,
10199
10529
  endpoint: "POST /imports",
@@ -11446,7 +11776,9 @@ var init_launch_bulk_enrichment = __esm({
11446
11776
  title: "Launch bulk enrichment",
11447
11777
  readOnlyHint: false,
11448
11778
  destructiveHint: true,
11449
- idempotentHint: true,
11779
+ // No double-launch guard: this POSTs straight through, so an identical
11780
+ // repeat is a second paid launch, not a no-op (product#4039).
11781
+ idempotentHint: false,
11450
11782
  openWorldHint: true
11451
11783
  },
11452
11784
  description: leadbay_launch_bulk_enrichment,
@@ -15385,7 +15717,7 @@ var init_getting_started = __esm({
15385
15717
  leadIds: "[<the ONE lead you drafted for at step 3>] \u2014 an ARRAY, always",
15386
15718
  lensId: "<the pinned lens id from step 2>"
15387
15719
  },
15388
- spend: "TWO BEATS \u2014 free preview FIRST, the real reveal only after the user confirms. Beat 1: call leadbay_enrich_titles with the drafted lead's id + lensId and NO titles / NO confirm / NO email / NO phone. That returns mode:'discover' \u2014 the FREE list of job titles at that company. Say plainly that nothing has been spent yet. Beat 2: name the title the draft is addressed to, tell them BEFORE they decide what it costs (one credit per contact revealed \u2014 here that is ONE contact, one credit), and ask them to confirm. Only then call leadbay_enrich_titles AGAIN with leadIds: [<that lead id>] \u2014 ALWAYS the array, even for a single lead: `leadId` singular is not a key this tool reads, so it is dropped and the paid call falls back to the default wishlist selection, charging for the whole batch \u2014 plus the chosen title, confirm:true and email:true. Poll leadbay_bulk_enrich_status with the returned bulk_id until all_done (or the count plateaus), and report the contact that actually resolved. NEVER launch the reveal without an explicit confirm: silence is not consent, and neither is 'they clicked the gate'. If they decline, keep the draft and the title and move on \u2014 that is a normal outcome, not a failure.",
15720
+ spend: "TWO BEATS \u2014 free preview FIRST, the real reveal only after the user confirms. Beat 1: call leadbay_enrich_titles with the drafted lead's id + lensId and NO titles / NO confirm / NO email / NO phone. That returns mode:'discover' \u2014 the FREE list of job titles at that company. Say plainly that nothing has been spent yet. Beat 2: name the title the draft is addressed to, tell them BEFORE they decide what it costs (one credit per contact revealed \u2014 here that is ONE contact, one credit), and ask them to confirm. Only then call leadbay_enrich_titles AGAIN with leadIds: [<that lead id>] \u2014 ALWAYS the array, even for a single lead: `leadId` singular is not a key this tool reads, so it is dropped and the paid call falls back to the default wishlist selection, charging for the whole batch \u2014 plus the chosen title, confirm:true and email:true. Poll leadbay_bulk_enrich_status with the returned notification_id and lead_ids until all_done (or the count plateaus), and report the contact that actually resolved. NEVER launch the reveal without an explicit confirm: silence is not consent, and neither is 'they clicked the gate'. If they decline, keep the draft and the title and move on \u2014 that is a normal outcome, not a failure.",
15389
15721
  quota_note: "After the reveal, close the loop on gate 1 in one line: one credit per contact revealed, so this cost one. Then say the thing that makes it land \u2014 the draft from gate 3 now has a real person and a real address to go to. Re-check leadbay_account_status if you want to show the moved windows. This is where gate 1's numbers stop being abstract: they just watched them move, and got something for it. Keep it to a line; no pricing pitch."
15390
15722
  }
15391
15723
  ],
@@ -16444,6 +16776,7 @@ var PAGE_SIZE, DEFAULT_COUNT2, MAX_COUNT, DEFAULT_PER_LEAD_BUDGET_MS, DEFAULT_TO
16444
16776
  var init_bulk_qualify_leads = __esm({
16445
16777
  "../core/dist/composite/bulk-qualify-leads.js"() {
16446
16778
  "use strict";
16779
+ init_launch_guard();
16447
16780
  init_tool_descriptions_generated();
16448
16781
  PAGE_SIZE = 50;
16449
16782
  DEFAULT_COUNT2 = 10;
@@ -16488,7 +16821,7 @@ var init_bulk_qualify_leads = __esm({
16488
16821
  },
16489
16822
  wait_for_completion: {
16490
16823
  type: "boolean",
16491
- description: "When false, launch qualification and return `{status:'running', qualify_id}` immediately. Poll leadbay_qualify_status. Default is true for 0.6.x backwards compatibility."
16824
+ description: "When false, launch qualification and return `{status:'running', notification_id, lead_ids, lens_id}` immediately. Poll leadbay_qualify_status with them. Default is true for 0.6.x backwards compatibility."
16492
16825
  }
16493
16826
  },
16494
16827
  additionalProperties: false
@@ -16505,13 +16838,15 @@ var init_bulk_qualify_leads = __esm({
16505
16838
  type: "string",
16506
16839
  description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
16507
16840
  },
16508
- handle_id: { type: "string", description: "Alias of qualify_id for handle-oriented callers." },
16509
- qualify_id: { type: "string", description: "UUIDv4 to poll via leadbay_qualify_status." },
16841
+ notification_id: {
16842
+ type: ["string", "null"],
16843
+ description: "The backend's job id. Carry it to leadbay_qualify_status."
16844
+ },
16510
16845
  lead_ids: { type: "array", items: { type: "string" } },
16511
16846
  launched_count: { type: "number" },
16512
16847
  still_running: {
16513
16848
  type: "array",
16514
- description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with the bulk_id (when present).",
16849
+ description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with notification_id (when present) or lead_ids + lens_id.",
16515
16850
  items: { type: "object" }
16516
16851
  },
16517
16852
  failed: {
@@ -16545,8 +16880,7 @@ var init_bulk_qualify_leads = __esm({
16545
16880
  {
16546
16881
  required: [
16547
16882
  "status",
16548
- "handle_id",
16549
- "qualify_id",
16883
+ "notification_id",
16550
16884
  "lead_ids",
16551
16885
  "launched_count",
16552
16886
  "failed",
@@ -16606,45 +16940,45 @@ var init_bulk_qualify_leads = __esm({
16606
16940
  };
16607
16941
  }
16608
16942
  if (!waitForCompletion) {
16609
- if (!ctx?.bulkTracker) {
16610
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_bulk_qualify_leads wait_for_completion=false needs a BulkTracker so qualify_id survives restart.", "");
16943
+ const fingerprint = launchFingerprint(["qualify", candidates, lensId]);
16944
+ const claim = beginLaunch(fingerprint);
16945
+ if (claim.state === "in_flight") {
16946
+ throw client.makeError("QUALIFY_LAUNCH_IN_FLIGHT", `An identical qualification was started ${claim.seconds_since}s ago and has not returned its job id yet`, "Nothing was launched twice. Call leadbay_bulk_qualify_leads again with the same arguments in a few seconds to receive the notification_id.", "");
16611
16947
  }
16612
- const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
16613
- lead_ids: candidates,
16614
- import_ids: [],
16615
- lens_id: lensId,
16616
- mapping_fingerprint: "bulk_qualify_leads",
16617
- per_lead_budget_ms: perLeadBudget,
16618
- total_budget_ms: totalBudget
16619
- });
16620
- let launchedCount = 0;
16621
- let notificationId = null;
16622
- let quotaExceeded2 = false;
16623
- let failed2 = [];
16624
- if (!reservation.reused) {
16625
- const launch = await launchBulkQualify(client, candidates, ctx);
16626
- quotaExceeded2 = launch.quotaExceeded;
16627
- notificationId = launch.resp?.notification_id ?? null;
16628
- const queuedIds = launch.resp?.queued_ids ?? [];
16629
- const skippedIds = launch.resp?.skipped_ids ?? [];
16630
- launchedCount = queuedIds.length;
16631
- const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
16632
- failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
16633
- if (queuedIds.length > 0 || quotaExceeded2 || skippedIds.length > 0 || failed2.length === candidates.length) {
16634
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id, notificationId);
16635
- }
16636
- } else {
16637
- notificationId = reservation.record.notification_id ?? null;
16638
- launchedCount = reservation.record.lead_ids.length;
16948
+ const already = claim.state === "settled" ? claim.record : void 0;
16949
+ if (already) {
16950
+ return {
16951
+ status: "running",
16952
+ lead_ids: candidates,
16953
+ launched_count: candidates.length,
16954
+ failed: [],
16955
+ quota_exceeded: false,
16956
+ lens_id: lensId,
16957
+ notification_id: already.notification_id,
16958
+ reused: true,
16959
+ seconds_since_original_launch: already.seconds_since,
16960
+ _meta: { region: client.region }
16961
+ };
16962
+ }
16963
+ let launch;
16964
+ try {
16965
+ launch = await launchBulkQualify(client, candidates, ctx);
16966
+ } catch (err) {
16967
+ abandonLaunch(fingerprint);
16968
+ throw err;
16639
16969
  }
16970
+ const notificationId = launch.resp?.notification_id ?? null;
16971
+ const queuedIds = launch.resp?.queued_ids ?? [];
16972
+ const skippedIds = launch.resp?.skipped_ids ?? [];
16973
+ const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
16974
+ const failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
16975
+ rememberLaunch(fingerprint, notificationId);
16640
16976
  const out = {
16641
16977
  status: "running",
16642
- handle_id: reservation.record.bulk_id,
16643
- qualify_id: reservation.record.bulk_id,
16644
16978
  lead_ids: candidates,
16645
- launched_count: launchedCount,
16979
+ launched_count: queuedIds.length,
16646
16980
  failed: failed2,
16647
- quota_exceeded: quotaExceeded2,
16981
+ quota_exceeded: launch.quotaExceeded,
16648
16982
  lens_id: lensId,
16649
16983
  notification_id: notificationId,
16650
16984
  _meta: { region: client.region }
@@ -17255,6 +17589,7 @@ var DEFAULT_PER_LEAD_BUDGET_MS2, DEFAULT_TOTAL_BUDGET_MS3, DEFAULT_PER_PHASE_BUD
17255
17589
  var init_import_and_qualify = __esm({
17256
17590
  "../core/dist/composite/import-and-qualify.js"() {
17257
17591
  "use strict";
17592
+ init_launch_guard();
17258
17593
  init_tool_descriptions_generated();
17259
17594
  init_import_leads();
17260
17595
  init_qualify_helpers();
@@ -17341,7 +17676,7 @@ var init_import_and_qualify = __esm({
17341
17676
  },
17342
17677
  total_budget_ms: {
17343
17678
  type: "number",
17344
- description: `Total wall-clock budget across import + qualify in ms (default ${DEFAULT_TOTAL_BUDGET_MS3}). When exhausted, the response returns qualify_id for resume via leadbay_qualify_status.`
17679
+ description: `Total wall-clock budget across import + qualify in ms (default ${DEFAULT_TOTAL_BUDGET_MS3}). When exhausted, the response returns lead_ids + lens_id for resume via leadbay_qualify_status.`
17345
17680
  },
17346
17681
  per_phase_budget_ms: {
17347
17682
  type: "number",
@@ -17349,7 +17684,7 @@ var init_import_and_qualify = __esm({
17349
17684
  },
17350
17685
  wait_for_completion: {
17351
17686
  type: "boolean",
17352
- description: "When false, enqueue the import phase and return `{kind:'result', status:'running', handle_id}` immediately. Poll leadbay_import_status. Default is true for 0.6.x backwards compatibility."
17687
+ description: "When false, upload the rows and return `{kind:'result', status:'running', import_ids}` immediately. Poll leadbay_import_status({importIds, dry_run}); the qualify phase does NOT run on this path \u2014 call leadbay_bulk_qualify_leads on the imported leads yourself. Default is true."
17353
17688
  },
17354
17689
  lensId: {
17355
17690
  type: "number",
@@ -17372,7 +17707,7 @@ var init_import_and_qualify = __esm({
17372
17707
  },
17373
17708
  outputSchema: {
17374
17709
  type: "object",
17375
- description: "Two return shapes: kind:'preview' (when dry_run='preview') with mapping hints; kind:'result' (default) with imported + qualified leads + qualify_id handle.",
17710
+ description: "Two return shapes: kind:'preview' (when dry_run='preview') with mapping hints; kind:'result' (default) with imported + qualified leads + lead_ids/lens_id to resume via leadbay_qualify_status.",
17376
17711
  properties: {
17377
17712
  kind: {
17378
17713
  type: "string",
@@ -17382,10 +17717,6 @@ var init_import_and_qualify = __esm({
17382
17717
  type: "string",
17383
17718
  description: "`running` when wait_for_completion=false."
17384
17719
  },
17385
- handle_id: {
17386
- type: "string",
17387
- description: "Import handle to pass to leadbay_import_status when wait_for_completion=false."
17388
- },
17389
17720
  timed_out: {
17390
17721
  type: "boolean",
17391
17722
  description: "True when the underlying import ran out of poll budget. Still running server-side \u2014 poll leadbay_import_status(import_ids). Do NOT re-issue the import."
@@ -17430,10 +17761,12 @@ var init_import_and_qualify = __esm({
17430
17761
  type: "object",
17431
17762
  description: "Adaptive budgets the composite selected (when caller didn't override): {per_lead_budget_ms, total_budget_ms, per_phase_budget_ms, wall_clock_estimate_ms, strategy}."
17432
17763
  },
17433
- qualify_id: {
17434
- type: ["string", "null"],
17435
- description: "UUIDv4 handle for polling via leadbay_qualify_status. Null when no leads were qualified."
17764
+ lead_ids: {
17765
+ type: "array",
17766
+ description: "Leads the qualify phase covers. Pass to leadbay_qualify_status for per-lead detail.",
17767
+ items: { type: "string" }
17436
17768
  },
17769
+ lens_id: { type: "number", description: "Lens the qualification ran against." },
17437
17770
  import_ids: {
17438
17771
  type: "array",
17439
17772
  description: "Backend file-import handles (one per chunk).",
@@ -17456,7 +17789,7 @@ var init_import_and_qualify = __esm({
17456
17789
  },
17457
17790
  still_running: {
17458
17791
  type: "array",
17459
- description: "Leads still being qualified at deadline; agent calls leadbay_qualify_status with qualify_id.",
17792
+ description: "Leads still being qualified at deadline; agent calls leadbay_qualify_status with lead_ids + lens_id.",
17460
17793
  items: { type: "object" }
17461
17794
  },
17462
17795
  failed: {
@@ -17477,10 +17810,13 @@ var init_import_and_qualify = __esm({
17477
17810
  },
17478
17811
  reused: {
17479
17812
  type: "boolean",
17480
- description: "True when an identical qualify_id was launched within the idempotency window."
17813
+ description: "True when an identical launch was reused within the idempotency window."
17481
17814
  },
17482
17815
  seconds_since_original: { type: "number" },
17483
- cancelled: { type: "boolean", description: "True when ctx.signal aborted mid-flight." },
17816
+ cancelled: {
17817
+ type: "boolean",
17818
+ description: "True when the HOST cancelled the call (a user Cancel, or the host's own request timeout); a budget timeout reports `budget_exhausted` instead. The import and the qualifications already launched keep running on Leadbay, except a chunk cancelled before its mappings were committed, which can read `running` without ever progressing \u2014 if the counts stop moving, say so rather than polling on. Poll leadbay_import_status with the `import_ids` values passed as `importIds`, and leadbay_qualify_status with lead_ids + lens_id. `still_running` can also hold leads whose qualification was never launched; those need a fresh qualification, which qualify_status cannot start."
17819
+ },
17484
17820
  budget_exhausted: { type: "boolean", description: "True when total_budget_ms hit before all leads finished." },
17485
17821
  quota_blocked: { type: "boolean", description: "True when quota was exhausted before launching all leads." },
17486
17822
  region: { type: "string" },
@@ -17502,9 +17838,6 @@ var init_import_and_qualify = __esm({
17502
17838
  if (params.dry_run === "preview") {
17503
17839
  return await runPreview(client, params, ctx, perPhaseBudget, totalBudget);
17504
17840
  }
17505
- if (!ctx?.bulkTracker) {
17506
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_and_qualify needs a BulkTracker (qualify_id persistence). Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
17507
- }
17508
17841
  if (params.wait_for_completion === false) {
17509
17842
  const queued = await importLeads.execute(client, {
17510
17843
  domains: params.domains,
@@ -17519,7 +17852,8 @@ var init_import_and_qualify = __esm({
17519
17852
  return {
17520
17853
  kind: "result",
17521
17854
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
17522
- qualify_id: null,
17855
+ lead_ids: [],
17856
+ lens_id: 0,
17523
17857
  import_ids: queued.importIds,
17524
17858
  notification_ids: queued.notification_ids ?? [],
17525
17859
  imported: queued.leads.map((l) => ({
@@ -17542,9 +17876,9 @@ var init_import_and_qualify = __esm({
17542
17876
  return {
17543
17877
  kind: "result",
17544
17878
  status: "running",
17545
- ...queued.handle_id ? { handle_id: queued.handle_id } : {},
17546
17879
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
17547
- qualify_id: null,
17880
+ lead_ids: [],
17881
+ lens_id: 0,
17548
17882
  import_ids: queued.importIds,
17549
17883
  notification_ids: queued.notification_ids ?? [],
17550
17884
  imported: [],
@@ -17577,7 +17911,6 @@ var init_import_and_qualify = __esm({
17577
17911
  return {
17578
17912
  kind: "result",
17579
17913
  status: "running",
17580
- ...importResultRaw.handle_id ? { handle_id: importResultRaw.handle_id } : {},
17581
17914
  // Everything the rendering contract keys off has to survive the
17582
17915
  // wrapper. Without `timed_out` the agent can't tell this from a
17583
17916
  // deliberate async launch; without `rows_pending_upload` a >100-row
@@ -17589,7 +17922,8 @@ var init_import_and_qualify = __esm({
17589
17922
  ...importResultRaw.dry_run ? { dry_run: true } : {},
17590
17923
  ...importResultRaw.row_ids ? { row_ids: importResultRaw.row_ids } : {},
17591
17924
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
17592
- qualify_id: null,
17925
+ lead_ids: [],
17926
+ lens_id: 0,
17593
17927
  import_ids: importResultRaw.importIds,
17594
17928
  notification_ids: importResultRaw.notification_ids ?? [],
17595
17929
  imported: [],
@@ -17610,7 +17944,8 @@ var init_import_and_qualify = __esm({
17610
17944
  kind: "result",
17611
17945
  ...params.dry_run === true ? { dry_run: true } : {},
17612
17946
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
17613
- qualify_id: null,
17947
+ lead_ids: [],
17948
+ lens_id: 0,
17614
17949
  import_ids: importResult.importIds,
17615
17950
  notification_ids: importResult.notification_ids ?? [],
17616
17951
  imported: [],
@@ -17648,7 +17983,8 @@ var init_import_and_qualify = __esm({
17648
17983
  kind: "result",
17649
17984
  ...params.dry_run === true ? { dry_run: true } : {},
17650
17985
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
17651
- qualify_id: null,
17986
+ lead_ids: [],
17987
+ lens_id: 0,
17652
17988
  import_ids: importResult.importIds,
17653
17989
  notification_ids: importResult.notification_ids ?? [],
17654
17990
  imported,
@@ -17694,29 +18030,17 @@ var init_import_and_qualify = __esm({
17694
18030
  // targets do NOT collide on the same qualify_id.
17695
18031
  buildFingerprintInput(params.mappings)
17696
18032
  );
17697
- const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
17698
- lead_ids: leadIds,
17699
- import_ids: importResult.importIds,
17700
- lens_id: lensId,
17701
- mapping_fingerprint: mappingFp,
17702
- per_lead_budget_ms: perLeadBudget,
17703
- total_budget_ms: totalBudget
17704
- });
17705
- if (reservation.reused) {
17706
- ctx?.logger?.info?.(`import_and_qualify: reusing qualify_id=${reservation.record.bulk_id} (seconds_since_original=${reservation.seconds_since_original})`);
17707
- }
17708
- let launchMarked = false;
17709
- for (const attempt of [1, 2]) {
17710
- try {
17711
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
17712
- launchMarked = true;
17713
- break;
17714
- } catch (err) {
17715
- ctx?.logger?.warn?.(`import_and_qualify: markLaunched attempt ${attempt} failed: ${err?.message ?? err}`);
17716
- }
17717
- }
17718
- if (!launchMarked) {
17719
- ctx?.logger?.warn?.(`import_and_qualify: markLaunched failed twice \u2014 qualify_status may BULK_PENDING-trap immediate retrieval; agent should poll, not relaunch`);
18033
+ const qualifyFingerprint = launchFingerprint([
18034
+ "import_qualify",
18035
+ leadIds,
18036
+ importResult.importIds,
18037
+ lensId,
18038
+ mappingFp
18039
+ ]);
18040
+ const qualifyClaim = beginLaunch(qualifyFingerprint);
18041
+ const alreadyQualified = qualifyClaim.state === "settled" ? qualifyClaim.record : void 0;
18042
+ if (alreadyQualified) {
18043
+ ctx?.logger?.info?.(`import_and_qualify: identical qualify ran ${alreadyQualified.seconds_since}s ago; re-running the fan-out to report current per-lead state (already-qualified leads are skipped inside it)`);
17720
18044
  }
17721
18045
  let questionOrder = void 0;
17722
18046
  try {
@@ -17730,22 +18054,24 @@ var init_import_and_qualify = __esm({
17730
18054
  total: 3,
17731
18055
  message: `Qualifying ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"} (phase 3/3)`
17732
18056
  });
17733
- const fanOut = await fanOutWebFetchAndPoll(client, leadIds, {
17734
- perLeadBudgetMs: perLeadBudget,
17735
- totalDeadlineMs: totalDeadline,
17736
- signal,
17737
- ctx,
17738
- skipAlreadyQualifiedLensId: lensId,
17739
- skipAlreadyQualifiedLaunch: skipAlreadyQualified,
17740
- ...questionOrder ? { questionOrder } : {}
17741
- });
17742
- if (fanOut.cancelled) {
17743
- try {
17744
- await ctx.bulkTracker.markCancelled(reservation.record.bulk_id);
17745
- } catch (err) {
17746
- ctx?.logger?.warn?.(`import_and_qualify: tracker.markCancelled failed: ${err?.message ?? err}`);
17747
- }
18057
+ let fanOut;
18058
+ try {
18059
+ fanOut = await fanOutWebFetchAndPoll(client, leadIds, {
18060
+ perLeadBudgetMs: perLeadBudget,
18061
+ totalDeadlineMs: totalDeadline,
18062
+ signal,
18063
+ ctx,
18064
+ skipAlreadyQualifiedLensId: lensId,
18065
+ skipAlreadyQualifiedLaunch: skipAlreadyQualified,
18066
+ ...questionOrder ? { questionOrder } : {}
18067
+ });
18068
+ } catch (err) {
18069
+ if (!alreadyQualified)
18070
+ abandonLaunch(qualifyFingerprint);
18071
+ throw err;
17748
18072
  }
18073
+ if (!alreadyQualified)
18074
+ rememberLaunch(qualifyFingerprint, null);
17749
18075
  const qualified = fanOut.results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
17750
18076
  const notInLensSet = new Set(fanOut.not_in_lens);
17751
18077
  const stillRunningIds = new Set([
@@ -17759,7 +18085,8 @@ var init_import_and_qualify = __esm({
17759
18085
  return {
17760
18086
  kind: "result",
17761
18087
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
17762
- qualify_id: reservation.record.bulk_id,
18088
+ lead_ids: leadIds,
18089
+ lens_id: lensId,
17763
18090
  import_ids: importResult.importIds,
17764
18091
  notification_ids: importResult.notification_ids ?? [],
17765
18092
  imported,
@@ -17770,9 +18097,9 @@ var init_import_and_qualify = __esm({
17770
18097
  quota_exceeded: fanOut.quota_exceeded,
17771
18098
  skipped_already_qualified,
17772
18099
  not_in_lens: fanOut.not_in_lens,
17773
- ...reservation.reused ? {
18100
+ ...alreadyQualified ? {
17774
18101
  reused: true,
17775
- seconds_since_original: reservation.seconds_since_original
18102
+ seconds_since_original: alreadyQualified.seconds_since
17776
18103
  } : {},
17777
18104
  ...fanOut.cancelled ? { cancelled: true } : {},
17778
18105
  ...budgetExhausted ? { budget_exhausted: true } : {},
@@ -17790,708 +18117,72 @@ var init_import_and_qualify = __esm({
17790
18117
  }
17791
18118
  });
17792
18119
 
17793
- // ../core/dist/jobs/bulk-store.js
17794
- import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unlink } from "fs/promises";
17795
- import { constants as fsConstants } from "fs";
17796
- import { dirname, resolve as resolvePath } from "path";
17797
- import { homedir, platform } from "os";
17798
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
17799
- function isValidBulkId(v) {
17800
- return typeof v === "string" && UUIDV4_RE.test(v);
17801
- }
17802
- function computeIdempotencyKey(args) {
17803
- const parts = [
17804
- [...args.lead_ids].sort().join(","),
17805
- [...args.titles].sort().join(","),
17806
- args.email ? "e1" : "e0",
17807
- args.phone ? "p1" : "p0",
17808
- `l${args.lens_id}`
17809
- ];
17810
- return createHash3("sha256").update(parts.join("|")).digest("hex");
17811
- }
17812
- function computeQualifyIdempotencyKey(args) {
17813
- const parts = [
17814
- "qualify",
17815
- [...args.lead_ids].sort().join(","),
17816
- [...args.import_ids].sort().join(","),
17817
- `l${args.lens_id}`,
17818
- args.mapping_fingerprint
17819
- ];
17820
- return createHash3("sha256").update(parts.join("|")).digest("hex");
17821
- }
17822
- function computeImportIdempotencyKey(args) {
17823
- const parts = [
17824
- "import",
17825
- args.mode,
17826
- args.dry_run ? "dry1" : "dry0",
17827
- args.import_fingerprint
17828
- ];
17829
- return createHash3("sha256").update(parts.join("|")).digest("hex");
17830
- }
17831
- function normalizeLaunchInputs(args) {
18120
+ // ../core/dist/composite/import-status.js
18121
+ function summarizeImports(imports, dryRun) {
18122
+ let recordsTotal = 0;
18123
+ let recordsProcessed = 0;
18124
+ let hasPreprocess = false;
18125
+ let hasProcess = false;
18126
+ let hasFailed = false;
18127
+ for (const imp of imports) {
18128
+ recordsTotal += Number(imp.total_records ?? 0);
18129
+ recordsProcessed += Number(imp.imported_records ?? 0);
18130
+ if (!imp.pre_processing?.finished) {
18131
+ hasPreprocess = true;
18132
+ continue;
18133
+ }
18134
+ if (imp.pre_processing?.error) {
18135
+ hasFailed = true;
18136
+ continue;
18137
+ }
18138
+ if (dryRun === true) {
18139
+ continue;
18140
+ }
18141
+ if (!imp.processing?.finished) {
18142
+ if (dryRun === false || imp.processing != null)
18143
+ hasProcess = true;
18144
+ continue;
18145
+ }
18146
+ if (imp.processing?.error) {
18147
+ hasFailed = true;
18148
+ }
18149
+ }
18150
+ const phase = hasFailed ? "failed" : hasPreprocess ? "preprocess" : hasProcess ? "process" : imports.length > 0 ? "complete" : "queued";
17832
18151
  return {
17833
- lead_ids: [...new Set(args.lead_ids)].sort(),
17834
- titles: [...new Set(args.titles)].sort()
18152
+ phase,
18153
+ records_processed: recordsProcessed,
18154
+ records_total: recordsTotal
17835
18155
  };
17836
18156
  }
17837
- async function openTmpFileExclusive(path) {
17838
- try {
17839
- return await fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
17840
- } catch (err) {
17841
- if (err?.code === "EEXIST") {
17842
- await unlink(path).catch(() => {
17843
- });
17844
- return fsOpen(path, fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL, 384);
17845
- }
17846
- throw err;
17847
- }
18157
+ function isInProgress(err) {
18158
+ return /in_progress/i.test(String(err?.message ?? ""));
17848
18159
  }
17849
- async function createDefaultBulkStore(opts = {}) {
17850
- const env = opts.env ?? process.env;
17851
- const allowMemory = env.LEADBAY_BULK_STORE_ALLOW_MEMORY === "1";
17852
- const allowUnsafePath = env.LEADBAY_BULK_STORE_PATH_UNSAFE === "1";
17853
- const path = env.LEADBAY_BULK_STORE_PATH ?? resolvePath(homedir(), ".leadbay", "bulks.json");
17854
- try {
17855
- const store = new LocalBulkStore({
17856
- backend: "file",
17857
- path,
17858
- logger: opts.logger,
17859
- allowUnsafePath
17860
- });
17861
- await store.ensureInitialized();
17862
- await stat(dirname(path));
17863
- return store;
17864
- } catch (err) {
17865
- if (!allowMemory) {
17866
- const msg = `bulk store init failed at ${path}: ${err?.message ?? err}. Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory (handles won't survive MCP restart), or set LEADBAY_BULK_STORE_PATH to a writable path.`;
17867
- opts.logger?.error?.(msg);
17868
- throw new Error(msg);
18160
+ async function fetchReconciledRecords(client, importIds, declaredTotal, ctx) {
18161
+ const canonicalLeadIds = /* @__PURE__ */ new Set();
18162
+ for (const importId of importIds) {
18163
+ try {
18164
+ const res = await client.request("GET", `/imports/${importId}/leads`);
18165
+ for (const id of res?.lead_ids ?? [])
18166
+ canonicalLeadIds.add(id);
18167
+ } catch (err) {
18168
+ if (isInProgress(err))
18169
+ throw new ImportNotReady();
18170
+ if (err?.code !== "NOT_FOUND" && err?._meta?.http_status !== 404)
18171
+ throw err;
18172
+ ctx?.logger?.warn?.(`import-status: /imports/${importId}/leads not available on this backend (404) \u2014 using records only`);
17869
18173
  }
17870
- opts.logger?.warn?.(`bulk.fallback_memory path=${path} reason=${err?.message ?? err}`);
17871
- return new LocalBulkStore({ backend: "memory", logger: opts.logger });
17872
18174
  }
17873
- }
17874
- var DEFAULT_IDEMPOTENCY_WINDOW_MS, TTL_MS, UUIDV4_RE, AsyncMutex, LocalBulkStore, InMemoryBulkStore;
17875
- var init_bulk_store = __esm({
17876
- "../core/dist/jobs/bulk-store.js"() {
17877
- "use strict";
17878
- DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
17879
- TTL_MS = 30 * 24 * 60 * 60 * 1e3;
17880
- UUIDV4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17881
- AsyncMutex = class {
17882
- locked = false;
17883
- queue = [];
17884
- async lock() {
17885
- if (!this.locked) {
17886
- this.locked = true;
17887
- return;
17888
- }
17889
- return new Promise((resolve) => {
17890
- this.queue.push(() => {
17891
- this.locked = true;
17892
- resolve();
17893
- });
17894
- });
17895
- }
17896
- unlock() {
17897
- this.locked = false;
17898
- const next = this.queue.shift();
17899
- if (next)
17900
- next();
17901
- }
17902
- async run(fn) {
17903
- await this.lock();
17904
- try {
17905
- return await fn();
17906
- } finally {
17907
- this.unlock();
17908
- }
17909
- }
17910
- };
17911
- LocalBulkStore = class {
17912
- backend;
17913
- path;
17914
- logger;
17915
- allowUnsafePath;
17916
- now;
17917
- mutex = new AsyncMutex();
17918
- memory = [];
17919
- // Cached file resolution — computed lazily on first access.
17920
- initialized = false;
17921
- constructor(opts) {
17922
- this.backend = opts.backend;
17923
- this.logger = opts.logger;
17924
- this.allowUnsafePath = !!opts.allowUnsafePath;
17925
- this.now = opts.now ?? Date.now;
17926
- if (this.backend === "file") {
17927
- if (!opts.path) {
17928
- throw new Error("LocalBulkStore: path is required when backend=file");
17929
- }
17930
- this.path = resolvePath(opts.path);
17931
- this.validatePath(this.path);
17932
- }
17933
- }
17934
- get durability() {
17935
- return this.backend;
17936
- }
17937
- // Exposed for tests and ops tooling.
17938
- get resolvedPath() {
17939
- return this.path;
17940
- }
17941
- validatePath(p) {
17942
- if (this.allowUnsafePath)
17943
- return;
17944
- const home = resolvePath(homedir());
17945
- if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
17946
- throw new Error(`LocalBulkStore: path ${p} is outside $HOME (${home}). Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
17947
- }
17948
- }
17949
- async ensureInitialized() {
17950
- if (this.initialized || this.backend !== "file") {
17951
- this.initialized = true;
17952
- return;
17953
- }
17954
- const dir = dirname(this.path);
17955
- await mkdirAsync(dir, { recursive: true, mode: 448 });
17956
- try {
17957
- const st = await lstat(this.path);
17958
- if (st.isSymbolicLink()) {
17959
- throw new Error(`LocalBulkStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_BULK_STORE_PATH_UNSAFE=1 to override.`);
17960
- }
17961
- } catch (err) {
17962
- if (err?.code !== "ENOENT")
17963
- throw err;
17964
- }
17965
- this.initialized = true;
17966
- }
17967
- // ─── Storage layer (file or memory) ──────────────────────────────────────
17968
- async readAll() {
17969
- if (this.backend === "memory")
17970
- return [...this.memory];
17971
- await this.ensureInitialized();
17972
- let raw;
17973
- try {
17974
- raw = await readFile(this.path, "utf8");
17975
- } catch (err) {
17976
- if (err?.code === "ENOENT")
17977
- return [];
17978
- throw err;
17979
- }
17980
- let parsed;
17981
- try {
17982
- parsed = JSON.parse(raw);
17983
- } catch (err) {
17984
- this.logger?.warn?.(`bulk.record_dropped file_parse_failed ${err?.message ?? err}`);
17985
- return [];
17986
- }
17987
- if (!Array.isArray(parsed)) {
17988
- this.logger?.warn?.("bulk.record_dropped file_not_array");
17989
- return [];
17990
- }
17991
- const out = [];
17992
- for (const entry of parsed) {
17993
- try {
17994
- out.push(this.validateRecord(entry));
17995
- } catch (err) {
17996
- this.logger?.warn?.(`bulk.record_dropped invalid_record ${err?.message ?? err}`);
17997
- }
17998
- }
17999
- return out;
18000
- }
18001
- validateRecord(raw) {
18002
- if (!raw || typeof raw !== "object")
18003
- throw new Error("not an object");
18004
- const r = raw;
18005
- if (!isValidBulkId(r.bulk_id))
18006
- throw new Error("invalid bulk_id");
18007
- if (typeof r.launched_at !== "string")
18008
- throw new Error("missing launched_at");
18009
- if (!Array.isArray(r.lead_ids) || !r.lead_ids.every((x) => typeof x === "string"))
18010
- throw new Error("invalid lead_ids");
18011
- if (r.status !== "pending" && r.status !== "launched" && r.status !== "complete" && r.status !== "failed" && r.status !== "cancelled")
18012
- throw new Error("invalid status");
18013
- if (typeof r.idempotency_key !== "string")
18014
- throw new Error("invalid idempotency_key");
18015
- const kind = r.kind ?? "enrich";
18016
- if (kind === "qualify") {
18017
- if (!Array.isArray(r.import_ids) || !r.import_ids.every((x) => typeof x === "string"))
18018
- throw new Error("invalid import_ids");
18019
- if (typeof r.lens_id !== "number")
18020
- throw new Error("invalid lens_id");
18021
- const out = {
18022
- kind: "qualify",
18023
- bulk_id: r.bulk_id,
18024
- launched_at: r.launched_at,
18025
- lead_ids: r.lead_ids,
18026
- import_ids: r.import_ids,
18027
- lens_id: r.lens_id,
18028
- status: r.status,
18029
- idempotency_key: r.idempotency_key,
18030
- durability: this.backend
18031
- };
18032
- if (typeof r.per_lead_budget_ms === "number")
18033
- out.per_lead_budget_ms = r.per_lead_budget_ms;
18034
- if (typeof r.total_budget_ms === "number")
18035
- out.total_budget_ms = r.total_budget_ms;
18036
- return out;
18037
- }
18038
- if (kind === "import") {
18039
- if (!Array.isArray(r.import_ids) || !r.import_ids.every((x) => typeof x === "string"))
18040
- throw new Error("invalid import_ids");
18041
- if (r.mode !== "domains" && r.mode !== "records")
18042
- throw new Error("invalid mode");
18043
- if (typeof r.dry_run !== "boolean")
18044
- throw new Error("invalid dry_run");
18045
- if (typeof r.records_total !== "number")
18046
- throw new Error("invalid records_total");
18047
- const out = {
18048
- kind: "import",
18049
- bulk_id: r.bulk_id,
18050
- launched_at: r.launched_at,
18051
- lead_ids: r.lead_ids,
18052
- import_ids: r.import_ids,
18053
- mode: r.mode,
18054
- dry_run: r.dry_run,
18055
- records_total: r.records_total,
18056
- status: r.status,
18057
- idempotency_key: r.idempotency_key,
18058
- durability: this.backend
18059
- };
18060
- if (r.result && typeof r.result === "object") {
18061
- const result = r.result;
18062
- if (Array.isArray(result.leads) && Array.isArray(result.not_imported) && Array.isArray(result.importIds) && result.importIds.every((x) => typeof x === "string")) {
18063
- out.result = {
18064
- leads: result.leads,
18065
- not_imported: result.not_imported,
18066
- importIds: result.importIds
18067
- };
18068
- }
18069
- }
18070
- if (r.progress && typeof r.progress === "object") {
18071
- const p = r.progress;
18072
- if (typeof p.phase === "string" && typeof p.records_processed === "number" && typeof p.records_total === "number") {
18073
- out.progress = {
18074
- phase: p.phase,
18075
- records_processed: p.records_processed,
18076
- records_total: p.records_total
18077
- };
18078
- }
18079
- }
18080
- if (typeof r.error === "string")
18081
- out.error = r.error;
18082
- return out;
18083
- }
18084
- if (kind === "enrich") {
18085
- if (!Array.isArray(r.titles) || !r.titles.every((x) => typeof x === "string"))
18086
- throw new Error("invalid titles");
18087
- if (typeof r.email !== "boolean")
18088
- throw new Error("invalid email");
18089
- if (typeof r.phone !== "boolean")
18090
- throw new Error("invalid phone");
18091
- if (typeof r.lens_id !== "number")
18092
- throw new Error("invalid lens_id");
18093
- if (r.selection_source !== "explicit" && r.selection_source !== "wishlist")
18094
- throw new Error("invalid selection_source");
18095
- if (r.notification_id != null && typeof r.notification_id !== "string")
18096
- throw new Error("invalid notification_id");
18097
- return {
18098
- kind: "enrich",
18099
- bulk_id: r.bulk_id,
18100
- launched_at: r.launched_at,
18101
- lead_ids: r.lead_ids,
18102
- titles: r.titles,
18103
- email: r.email,
18104
- phone: r.phone,
18105
- lens_id: r.lens_id,
18106
- selection_source: r.selection_source,
18107
- status: r.status,
18108
- idempotency_key: r.idempotency_key,
18109
- // Preserve the persisted notification_id — dropping it on reload forced
18110
- // bulk_enrich_status onto the legacy per-lead fallback every time
18111
- // (the fast path keys off record.notification_id), even in production
18112
- // where the default store is file-backed.
18113
- ...r.notification_id != null ? { notification_id: r.notification_id } : {},
18114
- durability: this.backend
18115
- };
18116
- }
18117
- throw new Error(`unknown kind: ${String(kind)}`);
18118
- }
18119
- async writeAll(records) {
18120
- if (this.backend === "memory") {
18121
- this.memory = records.map((r) => ({ ...r, durability: "memory" }));
18122
- return;
18123
- }
18124
- await this.ensureInitialized();
18125
- const payload = records.map((r) => ({ ...r, durability: "file" }));
18126
- const json = JSON.stringify(payload, null, 2);
18127
- const tmp = this.path + ".tmp";
18128
- let fh = await openTmpFileExclusive(tmp);
18129
- try {
18130
- await fh.writeFile(json, { encoding: "utf8" });
18131
- await fh.sync();
18132
- } finally {
18133
- await fh.close();
18134
- }
18135
- if (platform() === "win32") {
18136
- try {
18137
- await unlink(this.path);
18138
- } catch (err) {
18139
- if (err?.code !== "ENOENT")
18140
- throw err;
18141
- }
18142
- }
18143
- await rename(tmp, this.path);
18144
- try {
18145
- const dirFh = await fsOpen(dirname(this.path), "r");
18146
- try {
18147
- await dirFh.sync();
18148
- } finally {
18149
- await dirFh.close();
18150
- }
18151
- } catch {
18152
- }
18153
- }
18154
- // ─── TTL cleanup ─────────────────────────────────────────────────────────
18155
- prune(records) {
18156
- const cutoff = this.now() - TTL_MS;
18157
- const kept = [];
18158
- for (const r of records) {
18159
- const launched = Date.parse(r.launched_at);
18160
- if (Number.isFinite(launched) && launched >= cutoff) {
18161
- kept.push(r);
18162
- } else {
18163
- this.logger?.info?.(`bulk.ttl_dropped bulk_id=${r.bulk_id} launched_at=${r.launched_at}`);
18164
- }
18165
- }
18166
- return kept;
18167
- }
18168
- // ─── BulkTracker API ────────────────────────────────────────────────────
18169
- async findOrCreatePending(args) {
18170
- const { lead_ids, titles } = normalizeLaunchInputs(args);
18171
- const idempotency_key = computeIdempotencyKey({
18172
- lead_ids,
18173
- titles,
18174
- email: args.email,
18175
- phone: args.phone,
18176
- lens_id: args.lens_id
18177
- });
18178
- const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
18179
- return this.mutex.run(async () => {
18180
- const all = this.prune(await this.readAll());
18181
- const nowMs = this.now();
18182
- const existing = all.find((r) => r.kind === "enrich" && r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
18183
- if (existing) {
18184
- this.logger?.info?.(`bulk.reused bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
18185
- return {
18186
- record: existing,
18187
- reused: true,
18188
- seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
18189
- };
18190
- }
18191
- const record = {
18192
- kind: "enrich",
18193
- bulk_id: randomUUID2(),
18194
- launched_at: new Date(nowMs).toISOString(),
18195
- lead_ids,
18196
- titles,
18197
- email: args.email,
18198
- phone: args.phone,
18199
- lens_id: args.lens_id,
18200
- selection_source: args.selection_source,
18201
- status: "pending",
18202
- idempotency_key,
18203
- durability: this.backend
18204
- };
18205
- all.push(record);
18206
- await this.writeAll(all);
18207
- this.logger?.info?.(`bulk.registered kind=enrich bulk_id=${record.bulk_id} lens_id=${record.lens_id} lead_count=${record.lead_ids.length} titles_count=${record.titles.length} durability=${record.durability}`);
18208
- return { record, reused: false };
18209
- });
18210
- }
18211
- async findOrCreatePendingQualify(args) {
18212
- const lead_ids = [...new Set(args.lead_ids)].sort();
18213
- const import_ids = [...new Set(args.import_ids)].sort();
18214
- const idempotency_key = computeQualifyIdempotencyKey({
18215
- lead_ids,
18216
- import_ids,
18217
- lens_id: args.lens_id,
18218
- mapping_fingerprint: args.mapping_fingerprint
18219
- });
18220
- const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
18221
- return this.mutex.run(async () => {
18222
- const all = this.prune(await this.readAll());
18223
- const nowMs = this.now();
18224
- const existing = all.find((r) => r.kind === "qualify" && r.idempotency_key === idempotency_key && r.status !== "failed" && nowMs - Date.parse(r.launched_at) < window);
18225
- if (existing) {
18226
- this.logger?.info?.(`bulk.reused kind=qualify bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
18227
- return {
18228
- record: existing,
18229
- reused: true,
18230
- seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
18231
- };
18232
- }
18233
- const record = {
18234
- kind: "qualify",
18235
- bulk_id: randomUUID2(),
18236
- launched_at: new Date(nowMs).toISOString(),
18237
- lead_ids,
18238
- import_ids,
18239
- lens_id: args.lens_id,
18240
- status: "pending",
18241
- idempotency_key,
18242
- durability: this.backend
18243
- };
18244
- if (args.per_lead_budget_ms !== void 0)
18245
- record.per_lead_budget_ms = args.per_lead_budget_ms;
18246
- if (args.total_budget_ms !== void 0)
18247
- record.total_budget_ms = args.total_budget_ms;
18248
- all.push(record);
18249
- await this.writeAll(all);
18250
- this.logger?.info?.(`bulk.registered kind=qualify bulk_id=${record.bulk_id} lens_id=${record.lens_id} lead_count=${record.lead_ids.length} import_count=${record.import_ids.length} durability=${record.durability}`);
18251
- return { record, reused: false };
18252
- });
18253
- }
18254
- async findOrCreatePendingImport(args) {
18255
- const idempotency_key = computeImportIdempotencyKey({
18256
- import_fingerprint: args.import_fingerprint,
18257
- mode: args.mode,
18258
- dry_run: args.dry_run
18259
- });
18260
- const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
18261
- return this.mutex.run(async () => {
18262
- const all = this.prune(await this.readAll());
18263
- const nowMs = this.now();
18264
- const existing = all.find((r) => r.kind === "import" && r.idempotency_key === idempotency_key && r.status !== "failed" && r.status !== "cancelled" && nowMs - Date.parse(r.launched_at) < window);
18265
- if (existing) {
18266
- this.logger?.info?.(`bulk.reused kind=import bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
18267
- return {
18268
- record: existing,
18269
- reused: true,
18270
- seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
18271
- };
18272
- }
18273
- const record = {
18274
- kind: "import",
18275
- bulk_id: randomUUID2(),
18276
- launched_at: new Date(nowMs).toISOString(),
18277
- lead_ids: [],
18278
- import_ids: [],
18279
- mode: args.mode,
18280
- dry_run: args.dry_run,
18281
- records_total: args.records_total,
18282
- progress: {
18283
- phase: "queued",
18284
- records_processed: 0,
18285
- records_total: args.records_total
18286
- },
18287
- status: "pending",
18288
- idempotency_key,
18289
- durability: this.backend
18290
- };
18291
- all.push(record);
18292
- await this.writeAll(all);
18293
- this.logger?.info?.(`bulk.registered kind=import bulk_id=${record.bulk_id} mode=${record.mode} records_total=${record.records_total} durability=${record.durability}`);
18294
- return { record, reused: false };
18295
- });
18296
- }
18297
- async getQualify(bulk_id) {
18298
- const r = await this.get(bulk_id);
18299
- return r && r.kind === "qualify" ? r : void 0;
18300
- }
18301
- async getImport(bulk_id) {
18302
- const r = await this.get(bulk_id);
18303
- return r && r.kind === "import" ? r : void 0;
18304
- }
18305
- async setImportIds(bulk_id, import_ids) {
18306
- return this.mutex.run(async () => {
18307
- const all = this.prune(await this.readAll());
18308
- const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
18309
- if (idx < 0)
18310
- throw new Error(`import bulk_id not found: ${bulk_id}`);
18311
- const record = all[idx];
18312
- all[idx] = {
18313
- ...record,
18314
- import_ids: [...new Set(import_ids)].sort(),
18315
- status: record.status === "pending" ? "launched" : record.status
18316
- };
18317
- await this.writeAll(all);
18318
- return all[idx];
18319
- });
18320
- }
18321
- async setImportProgress(bulk_id, progress) {
18322
- return this.mutex.run(async () => {
18323
- const all = this.prune(await this.readAll());
18324
- const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
18325
- if (idx < 0)
18326
- throw new Error(`import bulk_id not found: ${bulk_id}`);
18327
- const record = all[idx];
18328
- all[idx] = { ...record, progress };
18329
- await this.writeAll(all);
18330
- return all[idx];
18331
- });
18332
- }
18333
- async markImportComplete(bulk_id, result) {
18334
- return this.mutex.run(async () => {
18335
- const all = this.prune(await this.readAll());
18336
- const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
18337
- if (idx < 0)
18338
- throw new Error(`import bulk_id not found: ${bulk_id}`);
18339
- const record = all[idx];
18340
- all[idx] = {
18341
- ...record,
18342
- import_ids: [...new Set(result.importIds)].sort(),
18343
- result,
18344
- progress: {
18345
- phase: "complete",
18346
- records_processed: record.records_total,
18347
- records_total: record.records_total
18348
- },
18349
- status: "complete"
18350
- };
18351
- await this.writeAll(all);
18352
- this.logger?.info?.(`bulk.import_complete bulk_id=${bulk_id}`);
18353
- return all[idx];
18354
- });
18355
- }
18356
- async markImportFailed(bulk_id, error) {
18357
- return this.mutex.run(async () => {
18358
- const all = this.prune(await this.readAll());
18359
- const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
18360
- if (idx < 0)
18361
- return;
18362
- all[idx] = { ...all[idx], status: "failed", error };
18363
- await this.writeAll(all);
18364
- this.logger?.info?.(`bulk.import_failed bulk_id=${bulk_id}`);
18365
- });
18366
- }
18367
- async markLaunched(bulk_id, notification_id) {
18368
- return this.mutex.run(async () => {
18369
- const all = this.prune(await this.readAll());
18370
- const idx = all.findIndex((r) => r.bulk_id === bulk_id);
18371
- if (idx < 0) {
18372
- throw new Error(`bulk_id not found: ${bulk_id}`);
18373
- }
18374
- all[idx] = {
18375
- ...all[idx],
18376
- status: "launched",
18377
- ...notification_id ? { notification_id } : {}
18378
- };
18379
- await this.writeAll(all);
18380
- this.logger?.info?.(`bulk.launched bulk_id=${bulk_id}${notification_id ? ` notification_id=${notification_id}` : ""}`);
18381
- return all[idx];
18382
- });
18383
- }
18384
- async markFailed(bulk_id) {
18385
- return this.mutex.run(async () => {
18386
- const all = this.prune(await this.readAll());
18387
- const idx = all.findIndex((r) => r.bulk_id === bulk_id);
18388
- if (idx < 0) {
18389
- return;
18390
- }
18391
- all[idx] = { ...all[idx], status: "failed" };
18392
- await this.writeAll(all);
18393
- this.logger?.info?.(`bulk.launch_failed bulk_id=${bulk_id}`);
18394
- });
18395
- }
18396
- async markCancelled(bulk_id) {
18397
- return this.mutex.run(async () => {
18398
- const all = this.prune(await this.readAll());
18399
- const idx = all.findIndex((r) => r.bulk_id === bulk_id);
18400
- if (idx < 0) {
18401
- return;
18402
- }
18403
- all[idx] = { ...all[idx], status: "cancelled" };
18404
- await this.writeAll(all);
18405
- this.logger?.info?.(`bulk.cancelled bulk_id=${bulk_id}`);
18406
- });
18407
- }
18408
- async get(bulk_id) {
18409
- return this.mutex.run(async () => {
18410
- const all = this.prune(await this.readAll());
18411
- return all.find((r) => r.bulk_id === bulk_id);
18412
- });
18413
- }
18414
- async list() {
18415
- return this.mutex.run(async () => {
18416
- const all = this.prune(await this.readAll());
18417
- return [...all].sort((a, b) => Date.parse(b.launched_at) - Date.parse(a.launched_at));
18418
- });
18419
- }
18420
- };
18421
- InMemoryBulkStore = class extends LocalBulkStore {
18422
- constructor(opts = {}) {
18423
- super({ backend: "memory", logger: opts.logger, now: opts.now });
18424
- }
18425
- };
18426
- }
18427
- });
18428
-
18429
- // ../core/dist/composite/import-status.js
18430
- function summarizeImports(imports, dryRun) {
18431
- let recordsTotal = 0;
18432
- let recordsProcessed = 0;
18433
- let hasPreprocess = false;
18434
- let hasProcess = false;
18435
- let hasFailed = false;
18436
- for (const imp of imports) {
18437
- recordsTotal += Number(imp.total_records ?? 0);
18438
- recordsProcessed += Number(imp.imported_records ?? 0);
18439
- if (!imp.pre_processing?.finished) {
18440
- hasPreprocess = true;
18441
- continue;
18442
- }
18443
- if (imp.pre_processing?.error) {
18444
- hasFailed = true;
18445
- continue;
18446
- }
18447
- if (dryRun === true) {
18448
- continue;
18449
- }
18450
- if (!imp.processing?.finished) {
18451
- if (dryRun === false || imp.processing != null)
18452
- hasProcess = true;
18453
- continue;
18454
- }
18455
- if (imp.processing?.error) {
18456
- hasFailed = true;
18457
- }
18458
- }
18459
- const phase = hasFailed ? "failed" : hasPreprocess ? "preprocess" : hasProcess ? "process" : imports.length > 0 ? "complete" : "queued";
18460
- return {
18461
- phase,
18462
- records_processed: recordsProcessed,
18463
- records_total: recordsTotal
18464
- };
18465
- }
18466
- function isInProgress(err) {
18467
- return /in_progress/i.test(String(err?.message ?? ""));
18468
- }
18469
- async function fetchReconciledRecords(client, importIds, declaredTotal, ctx) {
18470
- const canonicalLeadIds = /* @__PURE__ */ new Set();
18471
- for (const importId of importIds) {
18472
- try {
18473
- const res = await client.request("GET", `/imports/${importId}/leads`);
18474
- for (const id of res?.lead_ids ?? [])
18475
- canonicalLeadIds.add(id);
18476
- } catch (err) {
18477
- if (isInProgress(err))
18478
- throw new ImportNotReady();
18479
- if (err?.code !== "NOT_FOUND" && err?._meta?.http_status !== 404)
18480
- throw err;
18481
- ctx?.logger?.warn?.(`import-status: /imports/${importId}/leads not available on this backend (404) \u2014 using records only`);
18482
- }
18483
- }
18484
- const all = [];
18485
- for (const importId of importIds) {
18486
- for (let page = 0; page < RECORDS_MAX_PAGES; page++) {
18487
- const qs = `count=${RECORDS_PAGE_SIZE}&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
18488
- let res;
18489
- try {
18490
- res = await client.request("GET", `/imports/${importId}/records?${qs}`);
18491
- } catch (err) {
18492
- if (isInProgress(err))
18493
- throw new ImportNotReady();
18494
- throw err;
18175
+ const all = [];
18176
+ for (const importId of importIds) {
18177
+ for (let page = 0; page < RECORDS_MAX_PAGES; page++) {
18178
+ const qs = `count=${RECORDS_PAGE_SIZE}&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
18179
+ let res;
18180
+ try {
18181
+ res = await client.request("GET", `/imports/${importId}/records?${qs}`);
18182
+ } catch (err) {
18183
+ if (isInProgress(err))
18184
+ throw new ImportNotReady();
18185
+ throw err;
18495
18186
  }
18496
18187
  all.push(...res.items);
18497
18188
  const totalPages = res.pagination.pages ?? 0;
@@ -18529,7 +18220,6 @@ var RECORDS_PAGE_SIZE, RECORDS_MAX_PAGES, ImportNotReady, importStatus;
18529
18220
  var init_import_status = __esm({
18530
18221
  "../core/dist/composite/import-status.js"() {
18531
18222
  "use strict";
18532
- init_bulk_store();
18533
18223
  init_import_records();
18534
18224
  init_import_commit_log();
18535
18225
  init_tool_descriptions_generated();
@@ -18550,10 +18240,6 @@ var init_import_status = __esm({
18550
18240
  inputSchema: {
18551
18241
  type: "object",
18552
18242
  properties: {
18553
- handle_id: {
18554
- type: "string",
18555
- description: "UUIDv4 handle returned by leadbay_import_leads when wait_for_completion=false."
18556
- },
18557
18243
  importIds: {
18558
18244
  type: "array",
18559
18245
  description: "Backend file-import ids to inspect directly \u2014 from a completed import's `importIds`, or from a `{status:'running', timed_out:true}` result.",
@@ -18570,12 +18256,11 @@ var init_import_status = __esm({
18570
18256
  type: "object",
18571
18257
  properties: {
18572
18258
  status: { type: "string", description: "running, complete, or failed." },
18573
- handle_id: { type: "string" },
18574
18259
  importIds: { type: "array", items: { type: "string" } },
18575
18260
  progress: { type: "object" },
18576
18261
  result: {
18577
18262
  type: "object",
18578
- description: "Final import result: {leads, not_imported, importIds, still_settling?}. Present when a handle_id resolves a completed run in this MCP instance, OR when the importIds[] path finds every import complete and reconciles the wizard's records."
18263
+ description: "Final import result: {leads, not_imported, importIds, still_settling?}. Present when the importIds[] path finds every import complete and reconciles the wizard's records."
18579
18264
  },
18580
18265
  error: { type: "string" },
18581
18266
  dry_run: {
@@ -18588,99 +18273,20 @@ var init_import_status = __esm({
18588
18273
  required: ["status", "importIds", "progress", "region", "_meta"]
18589
18274
  },
18590
18275
  execute: async (client, params, ctx) => {
18591
- let handleId = params.handle_id;
18592
18276
  let importIds = params.importIds ?? [];
18593
- let handleDryRun = params.dry_run;
18594
- if (handleId) {
18595
- if (!isValidBulkId(handleId)) {
18596
- throw client.makeError("BULK_INVALID_ID", "handle_id is not a valid UUIDv4", "Pass the handle_id returned by leadbay_import_leads verbatim.", "");
18597
- }
18598
- if (!ctx?.bulkTracker) {
18599
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_status needs a BulkTracker to resolve handle_id. Pass importIds[] directly as a fallback.", "");
18600
- }
18601
- const record = await ctx.bulkTracker.getImport(handleId);
18602
- if (!record) {
18603
- const any = await ctx.bulkTracker.get(handleId);
18604
- if (any && any.kind !== "import") {
18605
- throw client.makeError("BULK_WRONG_KIND", "This handle was not created by leadbay_import_leads", "Use leadbay_qualify_status for qualify ids or leadbay_bulk_enrich_status for enrich ids.", "");
18606
- }
18607
- throw client.makeError("BULK_NOT_FOUND", "No import record for that handle_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence.", "");
18608
- }
18609
- importIds = record.import_ids;
18610
- handleDryRun = record.dry_run ?? handleDryRun;
18611
- if (record.status === "complete" && record.result) {
18612
- return {
18613
- status: "complete",
18614
- handle_id: handleId,
18615
- importIds,
18616
- progress: record.progress ?? {
18617
- phase: "complete",
18618
- records_processed: record.records_total,
18619
- records_total: record.records_total
18620
- },
18621
- result: record.result,
18622
- region: client.region,
18623
- _meta: client.lastMeta ?? {
18624
- region: client.region,
18625
- endpoint: "bulk-store",
18626
- latency_ms: null,
18627
- retry_after: null
18628
- }
18629
- };
18630
- }
18631
- if (record.status === "failed") {
18632
- return {
18633
- status: "failed",
18634
- handle_id: handleId,
18635
- importIds,
18636
- progress: record.progress ?? {
18637
- phase: "failed",
18638
- records_processed: 0,
18639
- records_total: record.records_total
18640
- },
18641
- error: record.error ?? "import failed",
18642
- region: client.region,
18643
- _meta: client.lastMeta ?? {
18644
- region: client.region,
18645
- endpoint: "bulk-store",
18646
- latency_ms: null,
18647
- retry_after: null
18648
- }
18649
- };
18650
- }
18651
- if (importIds.length === 0) {
18652
- return {
18653
- status: "running",
18654
- handle_id: handleId,
18655
- importIds,
18656
- progress: record.progress ?? {
18657
- phase: "queued",
18658
- records_processed: 0,
18659
- records_total: record.records_total
18660
- },
18661
- region: client.region,
18662
- _meta: client.lastMeta ?? {
18663
- region: client.region,
18664
- endpoint: "bulk-store",
18665
- latency_ms: null,
18666
- retry_after: null
18667
- }
18668
- };
18669
- }
18670
- }
18671
18277
  importIds = [...new Set(importIds)];
18672
18278
  if (importIds.length === 0) {
18673
- throw client.makeError("IMPORT_STATUS_INPUT_REQUIRED", "Pass either handle_id or importIds[]", "Call leadbay_import_leads with wait_for_completion=false first, then pass its handle_id.", "");
18279
+ throw client.makeError("IMPORT_STATUS_INPUT_REQUIRED", "Pass importIds[]", "Call leadbay_import_leads with wait_for_completion=false first, then pass back the importIds it returned.", "");
18674
18280
  }
18675
18281
  const imports = await Promise.all(importIds.map((id) => client.request("GET", `/imports/${id}`)));
18676
- const progress = summarizeImports(imports, handleDryRun);
18282
+ const progress = summarizeImports(imports, params.dry_run);
18677
18283
  const failed = imports.find((i) => i.pre_processing?.error || i.processing?.error);
18678
18284
  const complete = imports.every((i) => {
18679
18285
  if (i.pre_processing?.error || i.processing?.error)
18680
18286
  return false;
18681
- if (handleDryRun === true)
18287
+ if (params.dry_run === true)
18682
18288
  return Boolean(i.pre_processing?.finished);
18683
- if (handleDryRun === false)
18289
+ if (params.dry_run === false)
18684
18290
  return Boolean(i.processing?.finished);
18685
18291
  return Boolean(i.processing?.finished || i.pre_processing?.finished && !i.processing);
18686
18292
  });
@@ -18688,7 +18294,7 @@ var init_import_status = __esm({
18688
18294
  const commitError = commitFailureFor(importIds);
18689
18295
  let notReady = false;
18690
18296
  const declaredTotal = imports.reduce((n, i) => n + Number(i.total_records ?? 0), 0);
18691
- if (!failed && complete && handleDryRun !== true && importIds.length > 0) {
18297
+ if (!failed && complete && params.dry_run !== true && importIds.length > 0) {
18692
18298
  try {
18693
18299
  reconciled = await fetchReconciledRecords(client, importIds, declaredTotal, ctx);
18694
18300
  } catch (err) {
@@ -18703,9 +18309,8 @@ var init_import_status = __esm({
18703
18309
  const settled = complete && !notReady;
18704
18310
  return {
18705
18311
  status: failed || commitError ? "failed" : settled ? "complete" : "running",
18706
- ...handleId ? { handle_id: handleId } : {},
18707
18312
  importIds,
18708
- ...handleDryRun === true ? { dry_run: true } : {},
18313
+ ...params.dry_run === true ? { dry_run: true } : {},
18709
18314
  progress: notReady ? { ...progress, phase: "committing" } : progress,
18710
18315
  ...reconciled ? {
18711
18316
  result: {
@@ -18731,20 +18336,42 @@ var init_import_status = __esm({
18731
18336
  }
18732
18337
  });
18733
18338
 
18734
- // ../core/dist/composite/qualify-status.js
18735
- async function readNotification(client, notificationId) {
18736
- try {
18737
- const page = await client.listNotifications({ archived: false, count: 50 });
18738
- return page.items.find((n) => n.id === notificationId) ?? null;
18739
- } catch {
18740
- return null;
18339
+ // ../core/dist/notifications/read-by-id.js
18340
+ async function readNotificationById(client, notificationId) {
18341
+ for (const archived of [false, true]) {
18342
+ for (let page = 0; page < MAX_PAGES; page += 1) {
18343
+ let res;
18344
+ try {
18345
+ res = await client.listNotifications({ archived, page, count: PAGE_SIZE2 });
18346
+ } catch {
18347
+ return null;
18348
+ }
18349
+ const hit = res.items.find((n) => n.id === notificationId);
18350
+ if (hit)
18351
+ return hit;
18352
+ const pages = res.pagination?.pages ?? 1;
18353
+ if (page + 1 >= pages)
18354
+ break;
18355
+ }
18741
18356
  }
18357
+ return null;
18742
18358
  }
18359
+ var PAGE_SIZE2, MAX_PAGES;
18360
+ var init_read_by_id = __esm({
18361
+ "../core/dist/notifications/read-by-id.js"() {
18362
+ "use strict";
18363
+ PAGE_SIZE2 = 50;
18364
+ MAX_PAGES = 4;
18365
+ }
18366
+ });
18367
+
18368
+ // ../core/dist/composite/qualify-status.js
18743
18369
  var qualifyStatus;
18744
18370
  var init_qualify_status = __esm({
18745
18371
  "../core/dist/composite/qualify-status.js"() {
18746
18372
  "use strict";
18747
- init_bulk_store();
18373
+ init_read_by_id();
18374
+ init_revise_hint();
18748
18375
  init_qualify_helpers();
18749
18376
  init_tool_descriptions_generated();
18750
18377
  qualifyStatus = {
@@ -18760,18 +18387,30 @@ var init_qualify_status = __esm({
18760
18387
  inputSchema: {
18761
18388
  type: "object",
18762
18389
  properties: {
18763
- qualify_id: {
18390
+ notification_id: {
18764
18391
  type: "string",
18765
- description: "UUIDv4 returned by leadbay_import_and_qualify when at least one lead was still running."
18392
+ description: "The `notification_id` returned by leadbay_import_and_qualify / leadbay_bulk_qualify_leads. Answers progress in ONE call."
18393
+ },
18394
+ lead_ids: {
18395
+ type: "array",
18396
+ description: "The `lead_ids` the launch returned. Supply them for per-lead detail (which settled, which are still running). Progress alone needs only notification_id.",
18397
+ items: { type: "string" }
18398
+ },
18399
+ lens_id: {
18400
+ type: "number",
18401
+ description: "The `lens_id` the launch returned. Used to flag leads no longer in the lens."
18766
18402
  }
18767
18403
  },
18768
- required: ["qualify_id"],
18404
+ anyOf: [{ required: ["notification_id"] }, { required: ["lead_ids"] }],
18769
18405
  additionalProperties: false
18770
18406
  },
18771
18407
  outputSchema: {
18772
18408
  type: "object",
18773
18409
  properties: {
18774
- qualify_id: { type: "string", description: "Echoed UUIDv4 handle." },
18410
+ notification_id: {
18411
+ type: ["string", "null"],
18412
+ description: "The backend job id this status is for; pass it back to poll again."
18413
+ },
18775
18414
  launched_at: { type: "string", description: "ISO timestamp of original launch." },
18776
18415
  status: { type: "string", description: "'launched' on success (other states surface as error envelopes)." },
18777
18416
  import_ids: {
@@ -18782,7 +18421,7 @@ var init_qualify_status = __esm({
18782
18421
  lens_id: { type: "number", description: "Lens id the qualification ran against." },
18783
18422
  lead_ids: {
18784
18423
  type: "array",
18785
- description: "Lead UUIDs covered by this qualify_id (echoed from launch).",
18424
+ description: "Lead UUIDs covered by this status (echoed from launch).",
18786
18425
  items: { type: "string" }
18787
18426
  },
18788
18427
  qualified: {
@@ -18817,7 +18456,7 @@ var init_qualify_status = __esm({
18817
18456
  _meta: { type: "object" }
18818
18457
  },
18819
18458
  required: [
18820
- "qualify_id",
18459
+ "notification_id",
18821
18460
  "status",
18822
18461
  "import_ids",
18823
18462
  "lens_id",
@@ -18831,29 +18470,55 @@ var init_qualify_status = __esm({
18831
18470
  ]
18832
18471
  },
18833
18472
  execute: async (client, params, ctx) => {
18834
- if (!isValidBulkId(params.qualify_id)) {
18835
- throw client.makeError("BULK_INVALID_ID", "qualify_id is not a valid UUIDv4", "Pass the qualify_id returned by leadbay_import_and_qualify verbatim.", "");
18836
- }
18837
- if (!ctx?.bulkTracker) {
18838
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_qualify_status needs a BulkTracker. Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
18473
+ const notifId = params.notification_id ?? null;
18474
+ const leadIds = params.lead_ids ?? [];
18475
+ if (!notifId && leadIds.length === 0) {
18476
+ throw client.makeError("QUALIFY_STATUS_INPUT_REQUIRED", "Pass notification_id (for progress) and/or lead_ids (for per-lead detail)", "Both are in the launch response from leadbay_bulk_qualify_leads / leadbay_import_and_qualify. Re-read that result and pass them back.", "");
18839
18477
  }
18840
- const record = await ctx.bulkTracker.getQualify(params.qualify_id);
18841
- if (!record) {
18842
- const any = await ctx.bulkTracker.get(params.qualify_id);
18843
- if (any && any.kind !== "qualify") {
18844
- const hint = any.kind === "import" ? "Call leadbay_import_status with this id instead." : "Call leadbay_bulk_enrich_status with this id instead.";
18845
- throw client.makeError("BULK_WRONG_KIND", `This bulk_id was created by ${any.kind}, not leadbay_import_and_qualify`, hint, "");
18478
+ let bulkProgress = null;
18479
+ let inProgressFlag = null;
18480
+ let launchedAt = null;
18481
+ if (notifId) {
18482
+ const n = await readNotificationById(client, notifId);
18483
+ if (!n && leadIds.length === 0) {
18484
+ throw client.makeError("QUALIFY_JOB_NOT_FOUND", "No job for that notification_id", "The lookup scans this user's recent notifications (archived included); a job behind many newer ones will not be found. Re-call with the `lead_ids` + `lens_id` the launch returned \u2014 that answers per lead without the notification.", "");
18485
+ }
18486
+ if (n && inferKind(n) !== "bulk_qualify") {
18487
+ const kind = inferKind(n);
18488
+ throw client.makeError("QUALIFY_JOB_WRONG_KIND", `That notification_id is a ${kind === "bulk_enrich" ? "contact enrichment" : kind === "import" ? "file import" : "non-bulk"} notification, not a lead qualification`, kind === "bulk_enrich" ? "Poll it with leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone}) instead \u2014 carry the titles and channel the enrichment was launched with, or a contact enriched earlier counts as done and all_done flips true before anything landed." : kind === "import" ? "Poll it with leadbay_import_status({importIds}) instead." : "Pass the notification_id returned by leadbay_bulk_qualify_leads.", "");
18489
+ }
18490
+ if (n) {
18491
+ bulkProgress = n.bulk_progress;
18492
+ inProgressFlag = n.in_progress;
18493
+ launchedAt = n.created_at;
18846
18494
  }
18847
- throw client.makeError("BULK_NOT_FOUND", "No qualify record for that qualify_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence. Re-launch via leadbay_import_and_qualify.", "");
18848
- }
18849
- if (record.status === "pending") {
18850
- throw client.makeError("BULK_PENDING", "Qualify record is in 'pending' state \u2014 the launch may be in flight or crashed before launch ack", "Retry leadbay_qualify_status in a few seconds. If it persists >60s, relaunch via leadbay_import_and_qualify.", "");
18851
- }
18852
- if (record.status === "failed") {
18853
- throw client.makeError("BULK_LAUNCH_FAILED", "The original import_and_qualify launch failed; no qualifications were ordered", "Call leadbay_import_and_qualify again \u2014 the failed record won't block a fresh launch.", "");
18854
18495
  }
18855
- if (record.status === "cancelled") {
18856
- throw client.makeError("BULK_CANCELLED", "The qualify run was cancelled (ctx.signal aborted by the client mid-flight); no further qualifications are in flight", "Call leadbay_import_and_qualify again with the same input to relaunch \u2014 the cancelled record won't block a fresh launch.", "");
18496
+ if (leadIds.length === 0) {
18497
+ const out2 = {
18498
+ notification_id: notifId,
18499
+ launched_at: launchedAt ?? "",
18500
+ status: "launched",
18501
+ import_ids: [],
18502
+ lens_id: params.lens_id ?? 0,
18503
+ lead_ids: [],
18504
+ qualified: [],
18505
+ still_running: [],
18506
+ failed: [],
18507
+ not_in_lens: [],
18508
+ bulk_progress: bulkProgress,
18509
+ in_progress: inProgressFlag,
18510
+ region: client.region,
18511
+ _meta: client.lastMeta ?? {
18512
+ region: client.region,
18513
+ endpoint: "GET /notifications",
18514
+ latency_ms: null,
18515
+ retry_after: null
18516
+ }
18517
+ };
18518
+ if (bulkProgress && bulkProgress.quota_hit_count > 0) {
18519
+ out2.quota_hit_hint = "Some leads hit the AI-credits quota during qualification. Top up via leadbay_create_topup_link to clear the throttle immediately, or wait until the daily/weekly window resets.";
18520
+ }
18521
+ return out2;
18857
18522
  }
18858
18523
  ctx?.progress?.({
18859
18524
  progress: 1,
@@ -18869,20 +18534,20 @@ var init_qualify_status = __esm({
18869
18534
  ctx?.progress?.({
18870
18535
  progress: 2,
18871
18536
  total: 3,
18872
- message: `Checking lens membership for ${record.lead_ids.length} lead${record.lead_ids.length === 1 ? "" : "s"}\u2026`
18537
+ message: `Checking lens membership for ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"}\u2026`
18873
18538
  });
18874
18539
  let notInLensSet = /* @__PURE__ */ new Set();
18875
18540
  try {
18876
- const pre = await prequalifiedLeads(client, record.lead_ids, record.lens_id, ctx);
18541
+ const pre = await prequalifiedLeads(client, leadIds, params.lens_id ?? 0, ctx);
18877
18542
  notInLensSet = pre.not_in_lens;
18878
18543
  } catch {
18879
18544
  }
18880
18545
  ctx?.progress?.({
18881
18546
  progress: 3,
18882
18547
  total: 3,
18883
- message: `Refreshing qualification state for ${record.lead_ids.length} lead${record.lead_ids.length === 1 ? "" : "s"}\u2026`
18548
+ message: `Refreshing qualification state for ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"}\u2026`
18884
18549
  });
18885
- const fresh = await refreshLeadStates(client, record.lead_ids, questionOrder);
18550
+ const fresh = await refreshLeadStates(client, leadIds, questionOrder);
18886
18551
  const failed = [];
18887
18552
  const qualified = [];
18888
18553
  const still_running = [];
@@ -18901,28 +18566,17 @@ var init_qualify_status = __esm({
18901
18566
  const { _stillRunning, _failedCode, ...rest } = r;
18902
18567
  qualified.push(rest);
18903
18568
  }
18904
- let bulkProgress = null;
18905
- let inProgressFlag = null;
18906
- const notifId = record.notification_id ?? null;
18907
- if (notifId) {
18908
- const n = await readNotification(client, notifId);
18909
- if (n) {
18910
- bulkProgress = n.bulk_progress;
18911
- inProgressFlag = n.in_progress;
18912
- }
18913
- }
18914
18569
  const out = {
18915
- qualify_id: record.bulk_id,
18916
- launched_at: record.launched_at,
18917
- status: record.status === "complete" ? "launched" : record.status,
18918
- import_ids: record.import_ids,
18919
- lens_id: record.lens_id,
18920
- lead_ids: record.lead_ids,
18570
+ notification_id: notifId,
18571
+ launched_at: launchedAt ?? "",
18572
+ status: "launched",
18573
+ import_ids: [],
18574
+ lens_id: params.lens_id ?? 0,
18575
+ lead_ids: leadIds,
18921
18576
  qualified,
18922
18577
  still_running,
18923
18578
  failed,
18924
18579
  not_in_lens: [...notInLensSet],
18925
- notification_id: notifId,
18926
18580
  bulk_progress: bulkProgress,
18927
18581
  in_progress: inProgressFlag,
18928
18582
  region: client.region,
@@ -18933,10 +18587,6 @@ var init_qualify_status = __esm({
18933
18587
  retry_after: null
18934
18588
  }
18935
18589
  };
18936
- if (record.per_lead_budget_ms !== void 0)
18937
- out.per_lead_budget_ms = record.per_lead_budget_ms;
18938
- if (record.total_budget_ms !== void 0)
18939
- out.total_budget_ms = record.total_budget_ms;
18940
18590
  if (bulkProgress && bulkProgress.quota_hit_count > 0) {
18941
18591
  out.quota_hit_hint = "Some leads hit the AI-credits quota during qualification. Top up via leadbay_create_topup_link to clear the throttle immediately, or wait until the daily/weekly window resets.";
18942
18592
  }
@@ -18949,171 +18599,81 @@ var init_qualify_status = __esm({
18949
18599
  // ../core/dist/composite/enrich-titles.js
18950
18600
  async function launchOnSelection(client, args, ctx) {
18951
18601
  const { leadIds, titles, email, phone, lensId, selectionSource, preview } = args;
18952
- {
18953
- {
18954
- const tracker = ctx?.bulkTracker;
18955
- let bulkRecord;
18956
- let bulkReused = false;
18957
- let bulkSecondsSinceOriginal;
18958
- if (tracker) {
18959
- const res = await tracker.findOrCreatePending({
18960
- lead_ids: leadIds,
18961
- titles,
18962
- email,
18963
- phone,
18964
- lens_id: lensId,
18965
- selection_source: selectionSource
18966
- });
18967
- bulkRecord = {
18968
- bulk_id: res.record.bulk_id,
18969
- launched_at: res.record.launched_at,
18970
- durability: res.record.durability
18971
- };
18972
- bulkReused = res.reused;
18973
- bulkSecondsSinceOriginal = res.seconds_since_original;
18974
- const AGED_PENDING_S = 60;
18975
- const agedPending = bulkReused && res.record.status === "pending" && (bulkSecondsSinceOriginal ?? 0) >= AGED_PENDING_S;
18976
- if (agedPending) {
18977
- const staleBulkId = res.record.bulk_id;
18978
- try {
18979
- await tracker.markFailed(staleBulkId);
18980
- } catch (e) {
18981
- ctx?.logger?.warn?.(`enrich_titles: markFailed on aged pending failed: ${e?.message ?? e}`);
18982
- }
18983
- bulkReused = false;
18984
- const fresh = await tracker.findOrCreatePending({
18985
- lead_ids: leadIds,
18986
- titles,
18987
- email,
18988
- phone,
18989
- lens_id: lensId,
18990
- selection_source: selectionSource
18991
- });
18992
- if (fresh.reused && fresh.record.bulk_id !== staleBulkId) {
18993
- return {
18994
- mode: "already_launched",
18995
- re_used: true,
18996
- bulk_id: fresh.record.bulk_id,
18997
- launched_at: fresh.record.launched_at,
18998
- durability: fresh.record.durability,
18999
- notification_id: fresh.record.notification_id ?? null,
19000
- seconds_since_original_launch: fresh.seconds_since_original ?? 0,
19001
- lead_ids: leadIds,
19002
- titles,
19003
- email,
19004
- phone,
19005
- preview,
19006
- message: "No new enrichment was ordered; quota not spent. A concurrent identical launch is already in flight. Unless the user asked NOT to wait, poll leadbay_bulk_enrich_status with this bulk_id for results (see next_action); if they asked not to wait, hand back the bulk_id.",
19007
- next_action: "Unless the user explicitly asked NOT to wait, poll leadbay_bulk_enrich_status({bulk_id}) until all_done \u2014 OR until overall_progress.done plateaus across spaced polls (~90s\u20132min; unresolvable contacts never flip). include_contacts=true on the read you report from, then report the resolved enrichment in this turn. If the user asked not to wait, hand back the bulk_id instead."
19008
- };
19009
- }
19010
- bulkRecord = {
19011
- bulk_id: fresh.record.bulk_id,
19012
- launched_at: fresh.record.launched_at,
19013
- durability: fresh.record.durability
19014
- };
19015
- } else if (bulkReused && res.record.status !== "failed") {
19016
- return {
19017
- mode: "already_launched",
19018
- re_used: true,
19019
- bulk_id: res.record.bulk_id,
19020
- launched_at: res.record.launched_at,
19021
- durability: res.record.durability,
19022
- notification_id: res.record.notification_id ?? null,
19023
- seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
19024
- lead_ids: leadIds,
19025
- titles,
19026
- email,
19027
- phone,
19028
- preview,
19029
- message: `No new enrichment was ordered; quota not spent. An identical bulk was launched ${bulkSecondsSinceOriginal ?? 0}s ago. Unless the user asked NOT to wait (background/'I'll check later'), poll leadbay_bulk_enrich_status with this bulk_id for results; if they DID ask not to wait, hand back the bulk_id instead.`,
19030
- next_action: "Unless the user explicitly asked NOT to wait (background/'I'll check later'), poll leadbay_bulk_enrich_status({bulk_id}) until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15\u201330s apart, ~90s\u20132min elapsed; unresolvable contacts never flip, so a reused bulk can stay all_done:false forever). include_contacts=true on the read you report from, then report the resolved enrichment in this turn \u2014 don't end your turn waiting or spin forever. If the user DID ask not to wait, hand back the bulk_id instead of polling."
19031
- };
19032
- }
19033
- }
19034
- ctx?.progress?.({
19035
- progress: 3,
19036
- total: 3,
19037
- message: `Launching enrichment for ${titles.length} title${titles.length === 1 ? "" : "s"}\u2026`
19038
- });
19039
- let launchResp = null;
19040
- try {
19041
- launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles, email, phone });
19042
- } catch (err) {
19043
- const aborted = err?.name === "AbortError" || ctx?.signal?.aborted === true;
19044
- if (bulkRecord && tracker) {
19045
- try {
19046
- if (aborted) {
19047
- await tracker.markCancelled(bulkRecord.bulk_id);
19048
- } else {
19049
- await tracker.markFailed(bulkRecord.bulk_id);
19050
- }
19051
- } catch (e) {
19052
- ctx?.logger?.warn?.(`enrich_titles: tracker.${aborted ? "markCancelled" : "markFailed"} failed: ${e?.message ?? e}`);
19053
- }
19054
- }
19055
- if (err?.code === "QUOTA_EXCEEDED") {
19056
- return {
19057
- status: "quota_exceeded",
19058
- preview,
19059
- message: "Quota exceeded on launch",
19060
- retry_after_seconds: err?._meta?.retry_after ?? null
19061
- };
19062
- }
19063
- throw err;
19064
- }
19065
- const notificationId = launchResp?.notification_id ?? null;
19066
- if (bulkRecord && tracker) {
19067
- try {
19068
- await tracker.markLaunched(bulkRecord.bulk_id, notificationId);
19069
- } catch (e) {
19070
- ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
19071
- return {
19072
- mode: "launched_tracker_pending",
19073
- launched: true,
19074
- preview,
19075
- bulk_id: bulkRecord.bulk_id,
19076
- launched_at: bulkRecord.launched_at,
19077
- durability: bulkRecord.durability,
19078
- // Surface the resolved lead IDs so the agent can follow the backend
19079
- // job per-lead — bulk_enrich_status is unusable for this stuck handle,
19080
- // and the caller may have omitted leadIds (wishlist default), so
19081
- // without these it has no identifiers to poll.
19082
- lead_ids: leadIds,
19083
- titles,
19084
- email,
19085
- phone,
19086
- message: "Enrichment job launched on the backend, but the local tracker record could not be flipped to 'launched' and will NOT heal on its own this session. leadbay_bulk_enrich_status({bulk_id}) will keep returning status:'pending' (BULK_PENDING) \u2014 do NOT poll it in a loop expecting completion. The backend job is running regardless; track it per-lead instead.",
19087
- next_action: "Do NOT poll leadbay_bulk_enrich_status \u2014 this bulk_id is stuck 'pending' and won't flip. If the user asked NOT to wait (background/'I'll check later'), just hand back the returned lead_ids and let them re-check later. Otherwise track results per lead via leadbay_get_contacts(leadId) / leadbay_research_lead_by_id for the returned lead_ids (re-check every ~30s). get_contacts returns each lead's FULL contact list, so only count/report contacts whose job_title matches the enriched titles (" + titles.join(", ") + ") \u2014 don't attribute a pre-existing CFO/Sales email to this run \u2014 and a contact is done only when the REQUESTED channel landed (requested email and/or phone_number present, not contact.enrichment.done alone). Stop once the done set plateaus (~90s\u20132min), then report the resolved contacts and name the rest. (The launch already succeeded \u2014 do not relaunch.)"
19088
- };
19089
- }
19090
- }
18602
+ const fingerprint = launchFingerprint([
18603
+ "enrich",
18604
+ leadIds,
18605
+ titles,
18606
+ email,
18607
+ phone,
18608
+ lensId
18609
+ ]);
18610
+ const claim = beginLaunch(fingerprint);
18611
+ if (claim.state === "in_flight") {
18612
+ return {
18613
+ mode: "launch_in_flight",
18614
+ launched: false,
18615
+ preview,
18616
+ lead_ids: leadIds,
18617
+ titles,
18618
+ message: `An identical enrichment was started ${claim.seconds_since}s ago and has not returned its job id yet. Nothing was launched twice and no quota was spent.`,
18619
+ next_action: "Wait a few seconds and call leadbay_enrich_titles again with the same arguments \u2014 it will hand back the job id once the first call settles. Do not treat this as a running job; there is no id to poll yet."
18620
+ };
18621
+ }
18622
+ const already = claim.state === "settled" ? claim.record : void 0;
18623
+ if (already) {
18624
+ return {
18625
+ mode: "already_launched",
18626
+ launched: true,
18627
+ preview,
18628
+ reused: true,
18629
+ seconds_since_original_launch: already.seconds_since,
18630
+ lead_ids: leadIds,
18631
+ titles,
18632
+ email,
18633
+ phone,
18634
+ notification_id: already.notification_id,
18635
+ launched_at: already.launched_at,
18636
+ message: `An identical enrichment was launched ${already.seconds_since}s ago; this call did NOT spend quota again. Poll the original job rather than relaunching.`,
18637
+ next_action: "Poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done, or until overall_progress.done holds steady across spaced polls (~15-30s apart) \u2014 unresolvable contacts never flip. Pass titles/email/phone every time: they scope counting to the roles and channel THIS run asked for, so a contact enriched earlier cannot report the run as finished. Then report what landed and name what didn't."
18638
+ };
18639
+ }
18640
+ ctx?.progress?.({
18641
+ progress: 3,
18642
+ total: 3,
18643
+ message: `Launching enrichment for ${titles.length} title${titles.length === 1 ? "" : "s"}\u2026`
18644
+ });
18645
+ let launchResp = null;
18646
+ try {
18647
+ launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles, email, phone });
18648
+ } catch (err) {
18649
+ abandonLaunch(fingerprint);
18650
+ if (err?.code === "QUOTA_EXCEEDED") {
19091
18651
  return {
19092
- mode: "launched",
18652
+ status: "quota_exceeded",
19093
18653
  preview,
19094
- launched: true,
19095
- titles,
19096
- email,
19097
- phone,
19098
- // Always surface the resolved lead IDs — in the no-tracker branch there's
19099
- // no bulk_id to poll, and the caller may have omitted leadIds (wishlist
19100
- // default), so without these the agent has no identifiers to follow the
19101
- // job it just launched via leadbay_get_contacts / research_lead_by_id.
19102
- lead_ids: leadIds,
19103
- bulk_id: bulkRecord?.bulk_id,
19104
- launched_at: bulkRecord?.launched_at,
19105
- durability: bulkRecord?.durability,
19106
- notification_id: notificationId,
19107
- // Branch on bulkRecord FIRST: leadbay_bulk_enrich_status needs a real
19108
- // bulk_id (tracker handle). A notification_id can come back even with no
19109
- // tracker (legacy / OpenClaw raw-launch fall-through) — in that case
19110
- // bulk_id is undefined, so the agent must use the per-lead fallback, not
19111
- // poll a nonexistent bulk_id.
19112
- message: bulkRecord ? notificationId ? "Enrichment job launched (runs async). Unless the user asked NOT to wait (background/'I'll check later'), do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({bulk_id}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 unresolvable contacts keep all_done:false forever), then report the finished contacts yourself. (If the user DID ask not to wait, hand back the bulk_id instead. Either way, if you leave the conversation the completion also surfaces later via _meta.notifications / leadbay_account_status.notifications \u2014 but for a job you launched this turn and were NOT told to background, poll it now.)" : "Enrichment job launched (runs async). Unless the user asked NOT to wait (background/'I'll check later'), do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({bulk_id}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 unresolvable contacts keep all_done:false forever), then report the finished contacts yourself. (No notification id was returned, so there is NO automatic _meta.notifications completion for this job \u2014 if you background it or don't finish this turn, you (or the user) must poll leadbay_bulk_enrich_status({bulk_id}) again later; it will NOT surface on its own.)" : "Enrichment job launched. No bulk_id tracker configured. Unless the user asked NOT to wait (background/'I'll check later' \u2014 in which case hand back the lead_ids and let them re-check later), poll leadbay_get_contacts per lead (re-check every ~30s). get_contacts returns each lead's FULL contact list, so only count/report contacts whose job_title matches the enriched titles (" + titles.join(", ") + ") \u2014 don't attribute a pre-existing email of an unrelated role to this run \u2014 and a contact is done only when the REQUESTED channel landed (requested email and/or phone_number present, not contact.enrichment.done alone). Then report the results. Stop once the set of done contacts stops growing across a couple of spaced re-checks (~90s\u20132min elapsed): some contacts are unresolvable and never flip, so report the resolved ones and name the rest rather than polling forever.",
19113
- next_action: bulkRecord ? "Unless the user explicitly asked NOT to wait (background/'I'll check later'), poll leadbay_bulk_enrich_status({bulk_id}) in a loop until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15\u201330s apart, ~90s\u20132min elapsed; don't call a plateau from the first back-to-back reads while the backend spins up, and don't call it a plateau while partial_failures is present \u2014 that's a transient fetch error, keep polling/respect retry_after). Pass include_contacts=true on the read you report from, then report the resolved enrichment in THIS turn (name what landed and what didn't). If the user DID ask not to wait, hand back the bulk_id instead of polling (and if notification_id is null, tell them to ask again later \u2014 nothing auto-surfaces)." : "Unless the user asked not to wait, re-check via leadbay_research_lead_by_id or leadbay_get_contacts for the returned lead_ids (every ~30s). get_contacts returns each lead's FULL contact list, so only count/report contacts whose job_title matches the enriched titles (" + titles.join(", ") + ") \u2014 don't attribute a pre-existing email of an unrelated role to this run. Treat a contact as done only when the REQUESTED channel landed \u2014 the requested email present and/or phone_number present \u2014 NOT contact.enrichment.done alone (it's already true for a contact enriched on the other channel earlier). Stop once the done set stops growing across a couple of spaced re-checks (~90s\u20132min elapsed) \u2014 unresolvable contacts never flip \u2014 then report the resolved ones and name the rest. Don't poll forever or end your turn waiting. If the user asked not to wait, hand back the lead_ids and let them re-check later."
18654
+ message: "Quota exceeded on launch",
18655
+ retry_after_seconds: err?._meta?.retry_after ?? null
19114
18656
  };
19115
18657
  }
18658
+ throw err;
19116
18659
  }
18660
+ const notificationId = launchResp?.notification_id ?? null;
18661
+ const remembered = rememberLaunch(fingerprint, notificationId);
18662
+ return {
18663
+ mode: "launched",
18664
+ preview,
18665
+ launched: true,
18666
+ titles,
18667
+ email,
18668
+ phone,
18669
+ // Always surfaced: these are the coordinates the agent polls with. There is
18670
+ // no server-side handle to look them up from, by design.
18671
+ lead_ids: leadIds,
18672
+ notification_id: notificationId,
18673
+ launched_at: remembered.launched_at,
18674
+ message: notificationId ? "Enrichment job launched (runs async). Unless the user asked NOT to wait, do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 unresolvable contacts keep all_done:false forever), then report the finished contacts yourself. The notification_id keeps working across conversations and days; completion also surfaces via _meta.notifications / leadbay_account_status.notifications." : "Enrichment job launched, but the backend returned no notification_id, so there is no job id to poll. Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone}) instead \u2014 it answers per lead without a job id.",
18675
+ next_action: notificationId ? "Unless the user explicitly asked NOT to wait, poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15-30s apart, ~90s-2min elapsed; don't call a plateau from the first back-to-back reads, and not while partial_failures is present \u2014 that's transient, respect retry_after). Carry titles/email/phone on every poll \u2014 they scope counting to the roles and channel THIS run asked for, and without them a contact enriched months ago counts as done and all_done flips true before anything landed. Then report the resolved enrichment in THIS turn, naming what landed and what didn't. If the user DID ask not to wait, hand back the notification_id \u2014 it resolves later from any conversation." : "Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone, include_contacts: true}) every ~30s. It scopes counting to these titles and treats a contact as done only when the REQUESTED channel landed, so you do not have to do that yourself. Stop once overall_progress.done stops growing across a couple of spaced re-checks (~90s-2min) \u2014 unresolvable contacts never flip."
18676
+ };
19117
18677
  }
19118
18678
  async function launchEnrichment(client, args, ctx) {
19119
18679
  await client.acquireSelectionLock();
@@ -19137,6 +18697,7 @@ var DEFAULT_CANDIDATE_COUNT, enrichTitles;
19137
18697
  var init_enrich_titles = __esm({
19138
18698
  "../core/dist/composite/enrich-titles.js"() {
19139
18699
  "use strict";
18700
+ init_launch_guard();
19140
18701
  init_credits_helpers();
19141
18702
  init_tool_descriptions_generated();
19142
18703
  DEFAULT_CANDIDATE_COUNT = 25;
@@ -19150,7 +18711,7 @@ var init_enrich_titles = __esm({
19150
18711
  // destructive because the dominant flow mutates state.
19151
18712
  destructiveHint: true,
19152
18713
  // Idempotent against the same selection + titles set (same hash → same
19153
- // bulk_id; backend silently no-ops on already-enriched contacts).
18714
+ // the launch; backend silently no-ops on already-enriched contacts).
19154
18715
  idempotentHint: true,
19155
18716
  openWorldHint: true
19156
18717
  },
@@ -19191,11 +18752,11 @@ var init_enrich_titles = __esm({
19191
18752
  },
19192
18753
  outputSchema: {
19193
18754
  type: "object",
19194
- description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'needs_confirmation' (paid launch withheld pending user consent), 'already_launched' (idempotent reuse), 'launched_tracker_pending' (rare, soft-fail), 'launched' (happy path). Status: 'quota_exceeded' (429).",
18755
+ description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'needs_confirmation' (paid launch withheld pending user consent), 'already_launched' (idempotent reuse), 'launch_in_flight' (an identical launch is mid-flight and has no id yet), 'launched' (happy path). Status: 'quota_exceeded' (429).",
19195
18756
  properties: {
19196
18757
  mode: {
19197
18758
  type: "string",
19198
- description: "'discover' | 'preview_only' | 'dry_run' | 'needs_confirmation' | 'already_launched' | 'launched_tracker_pending' | 'launched'."
18759
+ description: "'discover' | 'preview_only' | 'dry_run' | 'needs_confirmation' | 'already_launched' | 'launch_in_flight' | 'launched'."
19199
18760
  },
19200
18761
  status: {
19201
18762
  type: "string",
@@ -19245,22 +18806,23 @@ var init_enrich_titles = __esm({
19245
18806
  type: "object",
19246
18807
  description: "What dry_run WOULD have launched (titles, email, phone)."
19247
18808
  },
19248
- re_used: {
19249
- type: "boolean",
19250
- description: "True when an identical bulk was launched within the idempotency window (already_launched mode)."
18809
+ notification_id: {
18810
+ type: ["string", "null"],
18811
+ description: "The backend's job id. Carry it to leadbay_bulk_enrich_status. Null when the backend returned none \u2014 then poll by lead_ids instead."
19251
18812
  },
19252
- bulk_id: {
19253
- type: "string",
19254
- description: "UUIDv4 to poll via leadbay_bulk_enrich_status."
18813
+ lead_ids: {
18814
+ type: "array",
18815
+ description: "The leads this run enriched. Carry them to leadbay_bulk_enrich_status for per-lead progress; they also work when the notification is archived.",
18816
+ items: { type: "string" }
18817
+ },
18818
+ reused: {
18819
+ type: "boolean",
18820
+ description: "True when an identical launch inside the 5-minute window was reused instead of spending quota again."
19255
18821
  },
19256
18822
  launched_at: {
19257
18823
  type: "string",
19258
18824
  description: "ISO timestamp of the (re-used or fresh) launch."
19259
18825
  },
19260
- durability: {
19261
- type: "string",
19262
- description: "'file' (persisted bulks.json) or 'memory'."
19263
- },
19264
18826
  seconds_since_original_launch: {
19265
18827
  type: "number",
19266
18828
  description: "Age of the re-used bulk record (already_launched mode)."
@@ -19519,14 +19081,6 @@ var init_enrich_titles = __esm({
19519
19081
  });
19520
19082
 
19521
19083
  // ../core/dist/composite/bulk-enrich-status.js
19522
- async function readNotification2(client, notificationId) {
19523
- try {
19524
- const page = await client.listNotifications({ archived: false, count: 50 });
19525
- return page.items.find((n) => n.id === notificationId) ?? null;
19526
- } catch {
19527
- return null;
19528
- }
19529
- }
19530
19084
  async function pMap(items, fn, concurrency) {
19531
19085
  const out = new Array(items.length);
19532
19086
  let next = 0;
@@ -19545,8 +19099,9 @@ var STATUS_FETCH_CONCURRENCY, bulkEnrichStatus;
19545
19099
  var init_bulk_enrich_status = __esm({
19546
19100
  "../core/dist/composite/bulk-enrich-status.js"() {
19547
19101
  "use strict";
19102
+ init_read_by_id();
19103
+ init_revise_hint();
19548
19104
  init_get_contacts();
19549
- init_bulk_store();
19550
19105
  init_credits_helpers();
19551
19106
  init_tool_descriptions_generated();
19552
19107
  STATUS_FETCH_CONCURRENCY = 5;
@@ -19563,44 +19118,45 @@ var init_bulk_enrich_status = __esm({
19563
19118
  inputSchema: {
19564
19119
  type: "object",
19565
19120
  properties: {
19566
- bulk_id: {
19121
+ notification_id: {
19567
19122
  type: "string",
19568
- description: "UUIDv4 returned by leadbay_enrich_titles at launch time. Required."
19123
+ description: "The `notification_id` returned by leadbay_enrich_titles. Gives the job-level counters in one call."
19124
+ },
19125
+ lead_ids: {
19126
+ type: "array",
19127
+ description: "The `lead_ids` the launch returned. Gives per-lead progress, and answers on its own if the notification is archived or has aged off page 1.",
19128
+ items: { type: "string" }
19129
+ },
19130
+ titles: {
19131
+ type: "array",
19132
+ description: "The `titles` the launch returned. Scopes progress to the roles THIS run enriched, so a lead's pre-existing CFO email cannot inflate a CEO run.",
19133
+ items: { type: "string" }
19134
+ },
19135
+ email: {
19136
+ type: "boolean",
19137
+ description: "The `email` flag the launch returned. A contact counts as done only once the requested channel has landed."
19138
+ },
19139
+ phone: {
19140
+ type: "boolean",
19141
+ description: "The `phone` flag the launch returned. Same rule as `email`."
19569
19142
  },
19570
19143
  include_contacts: {
19571
19144
  type: "boolean",
19572
19145
  description: "If true, return the full contact list per lead (email, phone, enrichment.done). Default false \u2014 cheap status polls."
19573
19146
  }
19574
19147
  },
19575
- required: ["bulk_id"],
19148
+ anyOf: [{ required: ["notification_id"] }, { required: ["lead_ids"] }],
19576
19149
  additionalProperties: false
19577
19150
  },
19578
19151
  outputSchema: {
19579
19152
  type: "object",
19580
19153
  properties: {
19581
- bulk_id: { type: "string", description: "Echoed UUIDv4 handle." },
19154
+ notification_id: { type: "string", description: "The backend job id; pass it back to poll again." },
19582
19155
  launched_at: { type: "string", description: "ISO timestamp of /enrichment/launch ack." },
19583
19156
  status: {
19584
19157
  type: "string",
19585
19158
  description: "'launched' on success. Errors return error envelopes (handled separately)."
19586
19159
  },
19587
- durability: {
19588
- type: "string",
19589
- description: "'persistent' (file-backed bulks.json) or 'memory' (LEADBAY_BULK_STORE_ALLOW_MEMORY)."
19590
- },
19591
- titles: {
19592
- type: "array",
19593
- description: "Titles ordered at launch time (echoed from the original enrich_titles call).",
19594
- items: { type: "string" }
19595
- },
19596
- email: { type: "boolean", description: "True if email enrichment was requested." },
19597
- phone: { type: "boolean", description: "True if phone enrichment was requested." },
19598
- lens_id: { type: "number", description: "Lens id used to scope the enrichment." },
19599
- leads: {
19600
- type: "array",
19601
- description: "Per-lead rollup: {lead_id, enrichment_progress:{done,total}, contacts? (when include_contacts=true)}.",
19602
- items: { type: "object" }
19603
- },
19604
19160
  overall_progress: {
19605
19161
  type: "object",
19606
19162
  description: "Aggregate progress across all leads.",
@@ -19624,243 +19180,180 @@ var init_bulk_enrich_status = __esm({
19624
19180
  items: { type: "object" }
19625
19181
  }
19626
19182
  },
19627
- required: ["bulk_id", "status", "leads", "overall_progress", "all_done"]
19183
+ required: ["status", "leads", "overall_progress", "all_done"]
19628
19184
  },
19629
19185
  execute: async (client, params, ctx) => {
19630
- if (!isValidBulkId(params.bulk_id)) {
19631
- return {
19632
- error: true,
19633
- code: "BULK_INVALID_ID",
19634
- message: "bulk_id is not a valid UUIDv4",
19635
- hint: "Pass the bulk_id returned by leadbay_enrich_titles verbatim."
19636
- };
19637
- }
19638
- if (!ctx?.bulkTracker) {
19639
- return {
19640
- error: true,
19641
- code: "BULK_TRACKER_UNAVAILABLE",
19642
- message: "No BulkTracker configured on this MCP instance",
19643
- hint: "This composite requires a BulkTracker in ToolContext. Upgrade to @leadbay/mcp \u22650.3.0 or run with LEADBAY_BULK_STORE_ALLOW_MEMORY=1."
19644
- };
19645
- }
19646
19186
  const includeContacts = params.include_contacts ?? false;
19187
+ const leadIds = params.lead_ids ?? [];
19647
19188
  const startMs = Date.now();
19648
- let record;
19649
- try {
19650
- record = await ctx.bulkTracker.get(params.bulk_id);
19651
- } catch (err) {
19652
- return {
19653
- error: true,
19654
- code: "BULK_STORE_UNAVAILABLE",
19655
- message: `Bulk store read failed: ${err?.message ?? err}`,
19656
- hint: "Check the file at $LEADBAY_BULK_STORE_PATH (default ~/.leadbay/bulks.json). Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory storage on startup (handles won't survive restart)."
19657
- };
19658
- }
19659
- if (!record) {
19660
- return {
19661
- error: true,
19662
- code: "BULK_NOT_FOUND",
19663
- message: "No bulk record for that bulk_id",
19664
- hint: "The record may have aged out (30-day TTL) or the MCP process was restarted without persistence. Launch a new enrichment via leadbay_enrich_titles."
19665
- };
19666
- }
19667
- if (record.kind !== "enrich") {
19668
- return {
19669
- error: true,
19670
- code: "BULK_WRONG_KIND",
19671
- message: `This bulk_id was created by ${record.kind === "qualify" ? "leadbay_import_and_qualify" : "leadbay_import_leads"}, not leadbay_enrich_titles.`,
19672
- hint: record.kind === "qualify" ? "Call leadbay_qualify_status with this id instead." : "Call leadbay_import_status with this id instead.",
19673
- bulk_id: record.bulk_id
19674
- };
19675
- }
19676
- if (record.status === "pending") {
19677
- return {
19678
- error: true,
19679
- code: "BULK_PENDING",
19680
- message: "Bulk is in 'pending' state \u2014 the launch is in flight or the MCP crashed between launch and ack.",
19681
- hint: "Retry leadbay_bulk_enrich_status in a few seconds. If it persists >60s, relaunch via leadbay_enrich_titles.",
19682
- bulk_id: record.bulk_id,
19683
- launched_at: record.launched_at
19684
- };
19685
- }
19686
- if (record.status === "failed") {
19687
- return {
19688
- error: true,
19689
- code: "BULK_LAUNCH_FAILED",
19690
- message: "The original /enrichment/launch POST failed; no backend enrichment was ordered.",
19691
- hint: "Call leadbay_enrich_titles again \u2014 the failed record won't block a fresh launch.",
19692
- bulk_id: record.bulk_id,
19693
- launched_at: record.launched_at
19694
- };
19695
- }
19696
- if (record.status === "cancelled") {
19189
+ if (!params.notification_id && leadIds.length === 0) {
19697
19190
  return {
19698
19191
  error: true,
19699
- code: "BULK_CANCELLED",
19700
- message: "The bulk was cancelled (ctx.signal aborted by the client mid-launch). No further work is in flight.",
19701
- hint: "Call leadbay_enrich_titles again with the same titles to relaunch \u2014 the cancelled record won't block a fresh launch.",
19702
- bulk_id: record.bulk_id,
19703
- launched_at: record.launched_at
19192
+ code: "ENRICH_STATUS_INPUT_REQUIRED",
19193
+ message: "Pass notification_id and/or lead_ids",
19194
+ hint: "Both are in the leadbay_enrich_titles result. notification_id gives the job counters; lead_ids gives per-lead progress and works even when the notification has been archived."
19704
19195
  };
19705
19196
  }
19706
- const notifId = record.notification_id ?? null;
19707
- if (notifId) {
19708
- const n = await readNotification2(client, notifId);
19709
- if (n && n.bulk_progress) {
19710
- const bp = n.bulk_progress;
19711
- const inProgress = n.in_progress;
19712
- let leads2 = [];
19713
- const fastPartialFailures = [];
19714
- if (includeContacts) {
19715
- leads2 = await pMap(record.lead_ids, async (leadId) => {
19716
- try {
19717
- const out = await getContacts.execute(client, { leadId });
19718
- const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
19719
- const fe = Array.isArray(out?._fetch_errors) ? out._fetch_errors : [];
19720
- if (fe.length > 0) {
19721
- fastPartialFailures.push({
19722
- lead_id: leadId,
19723
- code: fe[0]?.code ?? "FETCH_ERROR",
19724
- ...fe[0]?.retry_after !== void 0 ? { retry_after: fe[0].retry_after } : {}
19725
- });
19726
- }
19727
- return { lead_id: leadId, contacts };
19728
- } catch (err) {
19729
- fastPartialFailures.push({
19730
- lead_id: leadId,
19731
- code: err?.code ?? "UNKNOWN",
19732
- ...err?._meta?.retry_after !== void 0 ? { retry_after: err._meta.retry_after } : {}
19733
- });
19734
- return { lead_id: leadId };
19735
- }
19736
- }, STATUS_FETCH_CONCURRENCY);
19737
- } else {
19738
- leads2 = record.lead_ids.map((id) => ({ lead_id: id }));
19739
- }
19740
- ctx?.logger?.info?.(`bulk.status_checked_via_notification bulk_id=${record.bulk_id} notification_id=${notifId} done=${bp.success_count}/${bp.total_count} in_progress=${inProgress} wall_ms=${Date.now() - startMs}`);
19741
- const isReportRead = !inProgress || includeContacts;
19742
- const creditsRemaining2 = isReportRead ? await readCreditsRemaining(client, true) : null;
19197
+ let bp = null;
19198
+ let inProgress = null;
19199
+ let launchedAt = null;
19200
+ if (params.notification_id) {
19201
+ const n = await readNotificationById(client, params.notification_id);
19202
+ if (n && inferKind(n) !== "bulk_enrich") {
19203
+ const kind = inferKind(n);
19743
19204
  return {
19744
- bulk_id: record.bulk_id,
19745
- notification_id: notifId,
19746
- launched_at: record.launched_at,
19747
- status: record.status,
19748
- durability: record.durability,
19749
- titles: record.titles,
19750
- email: record.email,
19751
- phone: record.phone,
19752
- lens_id: record.lens_id,
19753
- leads: leads2,
19754
- overall_progress: {
19755
- done: bp.success_count + bp.failure_count + bp.quota_hit_count,
19756
- total: bp.total_count,
19757
- done_ratio: bp.total_count === 0 ? 0 : (bp.success_count + bp.failure_count + bp.quota_hit_count) / bp.total_count
19758
- },
19759
- bulk_progress: bp,
19760
- in_progress: inProgress,
19761
- all_done: !inProgress,
19762
- ...fastPartialFailures.length > 0 ? { partial_failures: fastPartialFailures } : {},
19763
- ...isReportRead ? { credits_remaining: creditsRemaining2 } : {},
19764
- ...bp.quota_hit_count > 0 ? {
19765
- quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
19766
- } : {}
19205
+ error: true,
19206
+ code: "ENRICH_JOB_WRONG_KIND",
19207
+ message: `That notification_id is a ${kind === "bulk_qualify" ? "lead qualification" : kind === "import" ? "file import" : "non-bulk"} notification, not a contact enrichment`,
19208
+ hint: kind === "bulk_qualify" ? "Poll it with leadbay_qualify_status({notification_id}) instead." : kind === "import" ? "Poll it with leadbay_import_status({importIds}) instead \u2014 the import ids came back from the import launch." : "Pass the notification_id returned by leadbay_enrich_titles."
19767
19209
  };
19768
19210
  }
19769
- ctx?.logger?.info?.(`bulk_enrich_status: notification ${notifId} not yet visible; falling back to per-lead fan-out`);
19770
- }
19771
- let doneSoFar = 0;
19772
- const totalLeads = record.lead_ids.length;
19773
- const results = await pMap(record.lead_ids, async (leadId) => {
19774
- try {
19775
- const out = await getContacts.execute(client, { leadId });
19776
- const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
19777
- const wantTitles = new Set((record.titles ?? []).map((t) => t.trim().toLowerCase()));
19778
- const enrichable = contacts.filter((c) => c && c.enrichment && (wantTitles.size === 0 || typeof c.job_title === "string" && wantTitles.has(c.job_title.trim().toLowerCase())));
19779
- const channelResolved = (c) => {
19780
- if (c.enrichment?.done !== true)
19781
- return false;
19782
- if (record.email && !c.email)
19783
- return false;
19784
- if (record.phone && !c.phone_number)
19785
- return false;
19786
- return true;
19787
- };
19788
- const done = enrichable.filter(channelResolved).length;
19789
- const total = enrichable.length;
19790
- doneSoFar += 1;
19791
- ctx?.progress?.({
19792
- progress: doneSoFar,
19793
- total: totalLeads,
19794
- message: `Fetched contacts for ${leadId} (${doneSoFar}/${totalLeads})`
19795
- });
19796
- return {
19797
- kind: "ok",
19798
- lead_id: leadId,
19799
- done,
19800
- total,
19801
- contacts: includeContacts ? contacts : void 0
19802
- };
19803
- } catch (err) {
19804
- doneSoFar += 1;
19805
- ctx?.progress?.({
19806
- progress: doneSoFar,
19807
- total: totalLeads,
19808
- message: `Fetch failed for ${leadId} (${doneSoFar}/${totalLeads}): ${err?.code ?? "UNKNOWN"}`
19809
- });
19211
+ if (n) {
19212
+ bp = n.bulk_progress;
19213
+ inProgress = n.in_progress;
19214
+ launchedAt = n.created_at;
19215
+ } else if (leadIds.length === 0) {
19810
19216
  return {
19811
- kind: "fail",
19812
- lead_id: leadId,
19813
- code: err?.code ?? "UNKNOWN",
19814
- retry_after: err?._meta?.retry_after
19217
+ error: true,
19218
+ code: "ENRICH_JOB_NOT_FOUND",
19219
+ message: "That notification_id is not in the recent notification list",
19220
+ hint: "The lookup scans your recent unarchived notifications; an archived job, or one behind many newer ones, will not be found. Re-call with the `lead_ids` the launch returned \u2014 that answers without the notification."
19815
19221
  };
19816
19222
  }
19817
- }, STATUS_FETCH_CONCURRENCY);
19818
- const leads = [];
19819
- const partialFailures = [];
19820
- let totalDone = 0;
19821
- let totalAll = 0;
19822
- for (const r of results) {
19823
- if (r.kind === "fail") {
19824
- partialFailures.push({
19223
+ }
19224
+ if (leadIds.length > 0) {
19225
+ const wantTitles = new Set((params.titles ?? []).map((t) => t.trim().toLowerCase()));
19226
+ const channelResolved = (c) => {
19227
+ if (c?.enrichment?.done !== true)
19228
+ return false;
19229
+ if (params.email && !c.email)
19230
+ return false;
19231
+ if (params.phone && !c.phone_number)
19232
+ return false;
19233
+ return true;
19234
+ };
19235
+ let doneSoFar = 0;
19236
+ const totalLeads = leadIds.length;
19237
+ const results = await pMap(leadIds, async (leadId) => {
19238
+ try {
19239
+ const out = await getContacts.execute(client, { leadId });
19240
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
19241
+ const enrichable = contacts.filter((c) => c && c.enrichment && (wantTitles.size === 0 || typeof c.job_title === "string" && wantTitles.has(c.job_title.trim().toLowerCase())));
19242
+ const fe = Array.isArray(out?._fetch_errors) ? out._fetch_errors : [];
19243
+ doneSoFar += 1;
19244
+ ctx?.progress?.({
19245
+ progress: doneSoFar,
19246
+ total: totalLeads,
19247
+ message: `Fetched contacts for ${leadId} (${doneSoFar}/${totalLeads})`
19248
+ });
19249
+ if (fe.length > 0) {
19250
+ return {
19251
+ kind: "fail",
19252
+ lead_id: leadId,
19253
+ code: fe[0]?.code ?? "FETCH_ERROR",
19254
+ ...fe[0]?.retry_after !== void 0 ? { retry_after: fe[0].retry_after } : {}
19255
+ };
19256
+ }
19257
+ return {
19258
+ kind: "ok",
19259
+ lead_id: leadId,
19260
+ done: enrichable.filter(channelResolved).length,
19261
+ total: enrichable.length,
19262
+ ...includeContacts ? { contacts } : {}
19263
+ };
19264
+ } catch (err) {
19265
+ doneSoFar += 1;
19266
+ ctx?.progress?.({
19267
+ progress: doneSoFar,
19268
+ total: totalLeads,
19269
+ message: `Fetch failed for ${leadId} (${doneSoFar}/${totalLeads}): ${err?.code ?? "UNKNOWN"}`
19270
+ });
19271
+ return {
19272
+ kind: "fail",
19273
+ lead_id: leadId,
19274
+ code: err?.code ?? "UNKNOWN",
19275
+ ...err?._meta?.retry_after !== void 0 ? { retry_after: err._meta.retry_after } : {}
19276
+ };
19277
+ }
19278
+ }, STATUS_FETCH_CONCURRENCY);
19279
+ const leads = [];
19280
+ const partialFailures = [];
19281
+ let totalDone = 0;
19282
+ let totalAll = 0;
19283
+ for (const r of results) {
19284
+ if (r.kind === "fail") {
19285
+ partialFailures.push({
19286
+ lead_id: r.lead_id,
19287
+ code: r.code,
19288
+ ...r.retry_after !== void 0 ? { retry_after: r.retry_after } : {}
19289
+ });
19290
+ continue;
19291
+ }
19292
+ leads.push({
19825
19293
  lead_id: r.lead_id,
19826
- code: r.code,
19827
- ...r.retry_after !== void 0 ? { retry_after: r.retry_after } : {}
19294
+ ...r.contacts ? { contacts: r.contacts } : {},
19295
+ enrichment_progress: { done: r.done, total: r.total }
19828
19296
  });
19829
- continue;
19297
+ totalDone += r.done;
19298
+ totalAll += r.total;
19830
19299
  }
19831
- leads.push({
19832
- lead_id: r.lead_id,
19833
- ...r.contacts ? { contacts: r.contacts } : {},
19834
- enrichment_progress: { done: r.done, total: r.total }
19835
- });
19836
- totalDone += r.done;
19837
- totalAll += r.total;
19300
+ const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
19301
+ ctx?.logger?.info?.(`bulk.status leads=${leadIds.length} done=${totalDone}/${totalAll} wall_ms=${Date.now() - startMs}`);
19302
+ const creditsRemaining2 = allDone ? await readCreditsRemaining(client, true) : null;
19303
+ return {
19304
+ ...params.notification_id ? { notification_id: params.notification_id } : {},
19305
+ ...launchedAt ? { launched_at: launchedAt } : {},
19306
+ status: allDone ? "complete" : "launched",
19307
+ // Echo what was asked for, so the reply states its own scope.
19308
+ ...params.titles ? { titles: params.titles } : {},
19309
+ ...params.email !== void 0 ? { email: params.email } : {},
19310
+ ...params.phone !== void 0 ? { phone: params.phone } : {},
19311
+ leads,
19312
+ overall_progress: {
19313
+ done: totalDone,
19314
+ total: totalAll,
19315
+ done_ratio: totalAll === 0 ? 0 : totalDone / totalAll
19316
+ },
19317
+ ...bp ? { bulk_progress: bp } : {},
19318
+ ...inProgress !== null ? { in_progress: inProgress } : {},
19319
+ all_done: allDone,
19320
+ ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {},
19321
+ ...allDone ? { credits_remaining: creditsRemaining2 } : {},
19322
+ ...bp && bp.quota_hit_count > 0 ? {
19323
+ quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
19324
+ } : {}
19325
+ };
19838
19326
  }
19839
- const overallProgress = {
19840
- done: totalDone,
19841
- total: totalAll,
19842
- done_ratio: totalAll === 0 ? 0 : totalDone / totalAll
19843
- };
19844
- const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
19845
- ctx?.logger?.info?.(`bulk.status_checked bulk_id=${record.bulk_id} done=${totalDone} total=${totalAll} wall_ms=${Date.now() - startMs}`);
19846
- let creditsRemaining = null;
19847
- if (allDone) {
19848
- creditsRemaining = await readCreditsRemaining(client, true);
19327
+ if (!bp) {
19328
+ return {
19329
+ error: true,
19330
+ code: "ENRICH_JOB_NO_COUNTERS",
19331
+ message: `This enrichment notification carries no per-contact counters; the backend reports it as ${inProgress ? "still running" : "finished"}`,
19332
+ hint: "Re-call with the `lead_ids` returned by leadbay_enrich_titles (plus titles/email/phone) \u2014 that path counts contacts directly and works whether or not the notification has counters.",
19333
+ ...inProgress !== null ? { in_progress: inProgress } : {},
19334
+ ...launchedAt ? { launched_at: launchedAt } : {}
19335
+ };
19849
19336
  }
19337
+ const done = bp.success_count + bp.failure_count + bp.quota_hit_count;
19338
+ const isReportRead = !inProgress;
19339
+ const creditsRemaining = isReportRead ? await readCreditsRemaining(client, true) : null;
19850
19340
  return {
19851
- bulk_id: record.bulk_id,
19852
- launched_at: record.launched_at,
19853
- status: record.status,
19854
- durability: record.durability,
19855
- titles: record.titles,
19856
- email: record.email,
19857
- phone: record.phone,
19858
- lens_id: record.lens_id,
19859
- leads,
19860
- overall_progress: overallProgress,
19861
- all_done: allDone,
19862
- ...allDone ? { credits_remaining: creditsRemaining } : {},
19863
- ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {}
19341
+ notification_id: params.notification_id,
19342
+ launched_at: launchedAt,
19343
+ status: inProgress ? "launched" : "complete",
19344
+ leads: [],
19345
+ overall_progress: {
19346
+ done,
19347
+ total: bp.total_count,
19348
+ done_ratio: bp.total_count === 0 ? 0 : done / bp.total_count
19349
+ },
19350
+ bulk_progress: bp,
19351
+ in_progress: inProgress,
19352
+ all_done: !inProgress,
19353
+ ...isReportRead ? { credits_remaining: creditsRemaining } : {},
19354
+ ...bp.quota_hit_count > 0 ? {
19355
+ quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
19356
+ } : {}
19864
19357
  };
19865
19358
  }
19866
19359
  };
@@ -21991,7 +21484,7 @@ var init_artifact_runtime_generated = __esm({
21991
21484
  "../core/dist/artifact-runtime.generated.js"() {
21992
21485
  "use strict";
21993
21486
  ARTIFACT_KIT_VERSION = "0.5.0";
21994
- ARTIFACT_RUNTIME = '"use strict";(()=>{var L=Object.defineProperty;var A=(e,r,t)=>r in e?L(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var a=(e,r,t)=>A(e,typeof r!="symbol"?r+"":r,t);var S=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n--lb-radius:1rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:0 1rem 2.5rem color-mix(in srgb,var(--color-gray-9) 6%,transparent),\n0 0.125rem 0.5rem color-mix(in srgb,var(--color-gray-9) 4%,transparent)}\n.lb-card-head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--lb-gap)}\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.25rem;color:var(--lb-fg)}\n.lb-sub{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-6)}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px}\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--color-gray-2);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:left;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n.lb-spacer{flex:1 1 auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:none}\n.lb-btn{transition-property:none}}\n`,p="lb-styles";var x="0.5.0",c=class extends Error{constructor(t,o={}){super(t);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},y=null,f=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(r,t)=>e.callMcpTool(r,t):null}function _(e){if(e&&typeof e=="object"&&"content"in e){let r=e.content;if(Array.isArray(r)&&r[0]&&typeof r[0].text=="string")return r[0].text}return null}function E(e){if(!e||typeof e!="object")return e;let r=e;if(r.isError)throw new c(_(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let t=_(e);if(t!=null)try{return JSON.parse(t)}catch{return t}return e}function I(e){return e instanceof Error?e.message:String(e)}function v(e){let r=e instanceof c?e.code:void 0;return{message:I(e),unavailable:r==="unavailable",code:r}}function O(e={}){y=e.call??null,f=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(p);if(e)return e;let r=document.createElement("style");return r.id=p,r.textContent=S,document.head.appendChild(r),r}async function T(e,r){if(!f||f<=0)return e;let t,o=new Promise((n,i)=>{t=setTimeout(()=>i(new c(`"${r}" timed out after ${f}ms`,{code:"timeout"})),f)});try{return await Promise.race([e,o])}finally{t&&clearTimeout(t)}}async function l(e,r={}){if(y)return E(await T(Promise.resolve(y(e,r)),e));let t=C();if(!t)throw new c("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return E(await T(Promise.resolve(t(e,r)),e))}var g=class{constructor(){a(this,"subs",new Set)}subscribe(r){return this.subs.add(r),r(this),()=>this.subs.delete(r)}emit(){for(let r of this.subs)r(this)}};function N(e){return Array.isArray(e)?e.map(r=>r&&typeof r=="object"?r:{value:r,label:String(r)}):[]}var b=class extends g{constructor(t={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=t,this.kind=t.kind,this.value=t.value??"";for(let o of t.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}t.load&&(t.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let t=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(t!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(t!==this.seq)return;this.options=[],this.error=v(o)}finally{t===this.seq&&(this.loading=!1,this.emit())}}setValue(t){this.value=t;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let t of this.depUnsubs)t();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let r=e;if(r.error!==!0)return null;let t=typeof r.message=="string"&&r.message?r.message:"tool call failed",o=typeof r.hint=="string"&&r.hint?` \\u2014 ${r.hint}`:"";return`${t}${o}`}var s=class extends g{constructor(t){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=t}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let t;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};t=await l(this.cfg.tool,n)}catch(n){this.error=v(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(t)??this.cfg.checkResult?.(t)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=t,this.loading=!1,this.emit(),this.cfg.onSuccess?.(t),t}reset(){this.error=null,this.lastResult=null,this.emit()}},d=class extends g{constructor(t){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=t,(t.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let t=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(t!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(t!==this.seq)return;this.error=v(n)}finally{t===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},h=class extends g{constructor(t){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=t,this.pageSize=t.pageSize??20,(t.autoLoad??!0)&&this.loadPage(0)}async loadPage(t){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:t,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=t}catch(n){if(o!==this.seq)return;this.error=v(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,r){let t=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":"ready";e.setAttribute("data-lb-state",t),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")}function D(e,r){let t=()=>r.setValue(e.value);e.addEventListener("change",t);let o=r.subscribe(()=>{M(e,r),e.disabled=r.loading,e.innerHTML="";for(let n of r.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=r.value==null?"":String(r.value)});return()=>{e.removeEventListener("change",t),o()}}function z(e,r){let t=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>r.setValue(t?e.checked:e.value);e.addEventListener(o,n);let i=r.subscribe(()=>{if(t)e.checked=!!r.value;else{let u=r.value==null?"":String(r.value);e.value!==u&&(e.value=u)}e.setAttribute("data-lb-state",r.error?"error":"ready"),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function F(e,r){let t=n=>{n.preventDefault(),r.run()};e.addEventListener("click",t);let o=r.subscribe(()=>{let n=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":r.lastResult!=null?"success":"idle";e.setAttribute("data-lb-state",n),"disabled"in e&&(e.disabled=r.loading),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener("click",t),o()}}var q=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],k=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let r=String(e??"").trim().toUpperCase(),t=k.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",load:async()=>k.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"\\u2014 Not set \\u2014"};function W(e){let r=String(e??"").trim().toUpperCase(),t=m.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>t?m.slice():[H,...m]})}function $(e){let r=()=>{let t=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(t)?t:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:r(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:t=>{let o=t?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=r().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function j(e){return new b({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:r=>(r?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function V(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function Z(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Y(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function B(e,r){return new d({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:r})})}function K(e,r){return new d({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:r})})}function J(e){let r=null;return new d({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:t=>!!t?.all_done,load:async()=>{if(!r){let t=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask});if(r=t?.bulk_id??null,!r)return{...t,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{bulk_id:r,_triggered_by:e.ask})}})}function Q(e){let r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:t,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:t,count:o,...e.lensId?{lensId:e.lensId}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let r=e.source??"followups",t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let u=r==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),w=u.leads??u.items??[];return{items:w,total:u.total_leads??u.pagination?.total??w.length}}})}function ee(e){return new d({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var re={VERSION:x,configure:O,styles:R,call:l,field:e=>new b(e),action:e=>new s(e),resource:e=>new d(e),list:e=>new h(e),bindSelect:D,bindValue:z,bindAction:F,campaigns:j,outreach:V,note:G,like:Z,dislike:Y,leadStatus:W,setStatus:$,sortOrder:U,leadHistory:B,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:q,LEAD_STATUSES:m,SORT_ORDERS:k};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=re);})();';
21487
+ ARTIFACT_RUNTIME = '"use strict";(()=>{var L=Object.defineProperty;var A=(e,r,t)=>r in e?L(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var a=(e,r,t)=>A(e,typeof r!="symbol"?r+"":r,t);var _=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n--lb-radius:1rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:0 1rem 2.5rem color-mix(in srgb,var(--color-gray-9) 6%,transparent),\n0 0.125rem 0.5rem color-mix(in srgb,var(--color-gray-9) 4%,transparent)}\n.lb-card-head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--lb-gap)}\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.25rem;color:var(--lb-fg)}\n.lb-sub{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-6)}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px}\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--color-gray-2);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:left;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n.lb-spacer{flex:1 1 auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:none}\n.lb-btn{transition-property:none}}\n`,p="lb-styles";var x="0.5.0",c=class extends Error{constructor(t,o={}){super(t);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},y=null,f=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(r,t)=>e.callMcpTool(r,t):null}function S(e){if(e&&typeof e=="object"&&"content"in e){let r=e.content;if(Array.isArray(r)&&r[0]&&typeof r[0].text=="string")return r[0].text}return null}function E(e){if(!e||typeof e!="object")return e;let r=e;if(r.isError)throw new c(S(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let t=S(e);if(t!=null)try{return JSON.parse(t)}catch{return t}return e}function I(e){return e instanceof Error?e.message:String(e)}function v(e){let r=e instanceof c?e.code:void 0;return{message:I(e),unavailable:r==="unavailable",code:r}}function O(e={}){y=e.call??null,f=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(p);if(e)return e;let r=document.createElement("style");return r.id=p,r.textContent=_,document.head.appendChild(r),r}async function T(e,r){if(!f||f<=0)return e;let t,o=new Promise((n,i)=>{t=setTimeout(()=>i(new c(`"${r}" timed out after ${f}ms`,{code:"timeout"})),f)});try{return await Promise.race([e,o])}finally{t&&clearTimeout(t)}}async function l(e,r={}){if(y)return E(await T(Promise.resolve(y(e,r)),e));let t=C();if(!t)throw new c("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return E(await T(Promise.resolve(t(e,r)),e))}var g=class{constructor(){a(this,"subs",new Set)}subscribe(r){return this.subs.add(r),r(this),()=>this.subs.delete(r)}emit(){for(let r of this.subs)r(this)}};function N(e){return Array.isArray(e)?e.map(r=>r&&typeof r=="object"?r:{value:r,label:String(r)}):[]}var b=class extends g{constructor(t={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=t,this.kind=t.kind,this.value=t.value??"";for(let o of t.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}t.load&&(t.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let t=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(t!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(t!==this.seq)return;this.options=[],this.error=v(o)}finally{t===this.seq&&(this.loading=!1,this.emit())}}setValue(t){this.value=t;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let t of this.depUnsubs)t();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let r=e;if(r.error!==!0)return null;let t=typeof r.message=="string"&&r.message?r.message:"tool call failed",o=typeof r.hint=="string"&&r.hint?` \\u2014 ${r.hint}`:"";return`${t}${o}`}var s=class extends g{constructor(t){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=t}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let t;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};t=await l(this.cfg.tool,n)}catch(n){this.error=v(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(t)??this.cfg.checkResult?.(t)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=t,this.loading=!1,this.emit(),this.cfg.onSuccess?.(t),t}reset(){this.error=null,this.lastResult=null,this.emit()}},u=class extends g{constructor(t){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=t,(t.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let t=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(t!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(t!==this.seq)return;this.error=v(n)}finally{t===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},h=class extends g{constructor(t){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=t,this.pageSize=t.pageSize??20,(t.autoLoad??!0)&&this.loadPage(0)}async loadPage(t){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:t,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=t}catch(n){if(o!==this.seq)return;this.error=v(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,r){let t=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":"ready";e.setAttribute("data-lb-state",t),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")}function D(e,r){let t=()=>r.setValue(e.value);e.addEventListener("change",t);let o=r.subscribe(()=>{M(e,r),e.disabled=r.loading,e.innerHTML="";for(let n of r.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=r.value==null?"":String(r.value)});return()=>{e.removeEventListener("change",t),o()}}function z(e,r){let t=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>r.setValue(t?e.checked:e.value);e.addEventListener(o,n);let i=r.subscribe(()=>{if(t)e.checked=!!r.value;else{let d=r.value==null?"":String(r.value);e.value!==d&&(e.value=d)}e.setAttribute("data-lb-state",r.error?"error":"ready"),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function F(e,r){let t=n=>{n.preventDefault(),r.run()};e.addEventListener("click",t);let o=r.subscribe(()=>{let n=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":r.lastResult!=null?"success":"idle";e.setAttribute("data-lb-state",n),"disabled"in e&&(e.disabled=r.loading),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener("click",t),o()}}var q=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],k=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let r=String(e??"").trim().toUpperCase(),t=k.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",load:async()=>k.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"\\u2014 Not set \\u2014"};function j(e){let r=String(e??"").trim().toUpperCase(),t=m.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>t?m.slice():[H,...m]})}function W(e){let r=()=>{let t=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(t)?t:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:r(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:t=>{let o=t?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=r().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function $(e){return new b({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:r=>(r?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function V(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function Z(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Y(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function B(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:r})})}function K(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:r})})}function J(e){let r=null;return new u({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:t=>!!t?.all_done,load:async()=>{if(!r){let t=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),o=Array.isArray(t?.lead_ids)?t.lead_ids:[],n=t?.notification_id??null;if(r=n||o.length>0?{notification_id:n,lead_ids:o}:null,!r)return{...t,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{...r.notification_id?{notification_id:r.notification_id}:{},...r.lead_ids.length>0?{lead_ids:r.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Q(e){let r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:t,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:t,count:o,...e.lensId?{lensId:e.lensId}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let r=e.source??"followups",t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let d=r==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),w=d.leads??d.items??[];return{items:w,total:d.total_leads??d.pagination?.total??w.length}}})}function ee(e){return new u({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var re={VERSION:x,configure:O,styles:R,call:l,field:e=>new b(e),action:e=>new s(e),resource:e=>new u(e),list:e=>new h(e),bindSelect:D,bindValue:z,bindAction:F,campaigns:$,outreach:V,note:G,like:Z,dislike:Y,leadStatus:j,setStatus:W,sortOrder:U,leadHistory:B,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:q,LEAD_STATUSES:m,SORT_ORDERS:k};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=re);})();';
21995
21488
  ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1rem` / `0.625rem` radii and `corner-shape: squircle` as the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Four lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-why></div> <!-- 4. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **right-hand end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n ```html\n <div class="lb-row">\n <button class="lb-btn">Like</button>\n <button class="lb-btn">Set status</button>\n <span class="lb-spacer"></span> <!-- pushes the link right -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No named contact";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n const phone = (lead.phone_numbers || [])[0] || null;\n // The API returns the STRING "null" for a missing email \u2014 guard for it or\n // you will print the word "null" as an address.\n const email = lead.email && lead.email !== "null" ? lead.email : null;\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No direct channel \u2014 enrich to reveal";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub">\u{1F464} Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div> <!-- WHO -->\n <div class="lb-sub">\u{1F3E2} \u260E 01 23 45 67 89</div> <!-- HOW: company -->\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 run qualification to generate one"*\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` + Apply button. You write the markup;\n`lb.leadStatus` fills the options and holds the value, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select"></select>\n <button id="go" class="lb-btn">Apply</button>\n <span id="msg" class="lb-msg"></span>\n </div>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nlb.bindSelect(document.getElementById("st"), status); // populates the 4 options\nlb.bindAction(document.getElementById("go"), save); // click \u2192 write\n\nsave.subscribe((a) => { // render your own feedback\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\nSave-on-change instead of an Apply button \u2014 drop `bindAction` and run it yourself:\n\n```js\ndocument.getElementById("st").addEventListener("change", () => save.run());\n```\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
21996
21489
  }
21997
21490
  });
@@ -22042,13 +21535,12 @@ __export(dist_exports, {
22042
21535
  COMPOSITE_FILE_TOOL_NAMES: () => COMPOSITE_FILE_TOOL_NAMES,
22043
21536
  DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
22044
21537
  GETTING_STARTED_MANIFEST: () => GETTING_STARTED_MANIFEST,
22045
- InMemoryBulkStore: () => InMemoryBulkStore,
22046
21538
  LeadbayClient: () => LeadbayClient,
22047
- LocalBulkStore: () => LocalBulkStore,
22048
21539
  NO_COMMERCE_TOOL_DESCRIPTIONS: () => NO_COMMERCE_TOOL_DESCRIPTIONS,
22049
21540
  NotificationsInbox: () => NotificationsInbox,
22050
21541
  NotificationsWsClient: () => NotificationsWsClient,
22051
21542
  REGIONS: () => REGIONS,
21543
+ abandonLaunch: () => abandonLaunch,
22052
21544
  accountHistory: () => accountHistory,
22053
21545
  accountStatus: () => accountStatus,
22054
21546
  acknowledgeNotification: () => acknowledgeNotification,
@@ -22058,6 +21550,7 @@ __export(dist_exports, {
22058
21550
  anchorIdFor: () => anchorIdFor,
22059
21551
  answerClarification: () => answerClarification,
22060
21552
  artifactKit: () => artifactKit,
21553
+ beginLaunch: () => beginLaunch,
22061
21554
  bulkEnrichStatus: () => bulkEnrichStatus,
22062
21555
  bulkQualifyLeads: () => bulkQualifyLeads,
22063
21556
  campaignCallSheet: () => campaignCallSheet,
@@ -22072,7 +21565,6 @@ __export(dist_exports, {
22072
21565
  createCampaign: () => createCampaign,
22073
21566
  createClient: () => createClient,
22074
21567
  createCustomField: () => createCustomField,
22075
- createDefaultBulkStore: () => createDefaultBulkStore,
22076
21568
  createLens: () => createLens,
22077
21569
  createLensDraft: () => createLensDraft,
22078
21570
  createTopupLink: () => createTopupLink,
@@ -22112,8 +21604,8 @@ __export(dist_exports, {
22112
21604
  importLeads: () => importLeads,
22113
21605
  importStatus: () => importStatus,
22114
21606
  inferKind: () => inferKind,
22115
- isValidBulkId: () => isValidBulkId,
22116
21607
  launchBulkEnrichment: () => launchBulkEnrichment,
21608
+ launchFingerprint: () => launchFingerprint,
22117
21609
  likeLead: () => likeLead,
22118
21610
  listCampaigns: () => listCampaigns,
22119
21611
  listLenses: () => listLenses,
@@ -22130,8 +21622,10 @@ __export(dist_exports, {
22130
21622
  pullLeads: () => pullLeads,
22131
21623
  qualifyLead: () => qualifyLead,
22132
21624
  qualifyStatus: () => qualifyStatus,
21625
+ recallLaunch: () => recallLaunch,
22133
21626
  recallOrderedTitles: () => recallOrderedTitles,
22134
21627
  refinePrompt: () => refinePrompt,
21628
+ rememberLaunch: () => rememberLaunch,
22135
21629
  removeEpilogue: () => removeEpilogue,
22136
21630
  removeLeadsFromCampaign: () => removeLeadsFromCampaign,
22137
21631
  removePushback: () => removePushback,
@@ -22139,6 +21633,7 @@ __export(dist_exports, {
22139
21633
  reportOutreach: () => reportOutreach,
22140
21634
  researchLeadById: () => researchLeadById,
22141
21635
  researchLeadByNameFuzzy: () => researchLeadByNameFuzzy,
21636
+ resetLaunchGuard: () => resetLaunchGuard,
22142
21637
  resolveImportRows: () => resolveImportRows,
22143
21638
  resolveRegion: () => resolveRegion,
22144
21639
  reviseHintFor: () => reviseHintFor,
@@ -22269,9 +21764,9 @@ var init_dist = __esm({
22269
21764
  init_team_activity();
22270
21765
  init_send_feedback();
22271
21766
  init_artifact_kit();
22272
- init_bulk_store();
22273
21767
  init_getting_started();
22274
21768
  init_tool_descriptions_generated();
21769
+ init_launch_guard();
22275
21770
  granularReadTools = [
22276
21771
  listLenses,
22277
21772
  discoverLeads,
@@ -22549,7 +22044,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
22549
22044
 
22550
22045
  # Resilience rules for Leadbay long-running tools
22551
22046
 
22552
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
22047
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
22553
22048
 
22554
22049
  ## Rule 1 \u2014 Pin the lens
22555
22050
 
@@ -22557,7 +22052,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
22557
22052
 
22558
22053
  ## Rule 2 \u2014 Prefer async for bulk operations
22559
22054
 
22560
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
22055
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
22561
22056
 
22562
22057
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
22563
22058
 
@@ -22573,6 +22068,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
22573
22068
 
22574
22069
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
22575
22070
 
22071
+ ## A launched job cannot be stopped
22072
+
22073
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
22074
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
22075
+ running result, that work is queued on Leadbay and runs to completion, and the
22076
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
22077
+ launched nothing and is not covered here.
22078
+
22079
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
22080
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
22081
+ work stopped. What to do next depends on what you are holding:
22082
+
22083
+ - **A handle.** Poll the status tool with it, and do not launch the work that
22084
+ handle covers a second time \u2014 that spends the quota again on the same rows.
22085
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
22086
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
22087
+ notification of its own: resume it with
22088
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
22089
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
22090
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
22091
+ for what was launched and re-run for that subset only, never for the whole
22092
+ batch.
22093
+ - **No result at all**, because the call timed out or the stream closed before it
22094
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
22095
+ finished. Calling the same tool again with the same arguments will usually hand
22096
+ back the job already launched rather than starting a second one, but that guard
22097
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
22098
+ are about to re-run before you spend the user's quota on it.
22099
+
22100
+
22576
22101
 
22577
22102
  # PHASE 0 \u2014 STATE + AUDIENCE
22578
22103
 
@@ -22741,7 +22266,7 @@ Run the Leadbay daily check-in for me. Treat this prompt the same way for any eq
22741
22266
 
22742
22267
  # Resilience rules for Leadbay long-running tools
22743
22268
 
22744
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
22269
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
22745
22270
 
22746
22271
  ## Rule 1 \u2014 Pin the lens
22747
22272
 
@@ -22749,7 +22274,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
22749
22274
 
22750
22275
  ## Rule 2 \u2014 Prefer async for bulk operations
22751
22276
 
22752
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
22277
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
22753
22278
 
22754
22279
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
22755
22280
 
@@ -22765,6 +22290,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
22765
22290
 
22766
22291
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
22767
22292
 
22293
+ ## A launched job cannot be stopped
22294
+
22295
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
22296
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
22297
+ running result, that work is queued on Leadbay and runs to completion, and the
22298
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
22299
+ launched nothing and is not covered here.
22300
+
22301
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
22302
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
22303
+ work stopped. What to do next depends on what you are holding:
22304
+
22305
+ - **A handle.** Poll the status tool with it, and do not launch the work that
22306
+ handle covers a second time \u2014 that spends the quota again on the same rows.
22307
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
22308
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
22309
+ notification of its own: resume it with
22310
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
22311
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
22312
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
22313
+ for what was launched and re-run for that subset only, never for the whole
22314
+ batch.
22315
+ - **No result at all**, because the call timed out or the stream closed before it
22316
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
22317
+ finished. Calling the same tool again with the same arguments will usually hand
22318
+ back the job already launched rather than starting a second one, but that guard
22319
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
22320
+ are about to re-run before you spend the user's quota on it.
22321
+
22322
+
22768
22323
 
22769
22324
  # PHASE 0 \u2014 RESUME CHECK
22770
22325
 
@@ -22859,7 +22414,7 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
22859
22414
 
22860
22415
  ABOVE the table, add a 2\u20134 sentence "Today's nudges" paragraph for the 3 most-promising rows. The nudges speak to urgency / opportunity / freshness \u2014 what makes acting on these RIGHT NOW the right call. Do NOT repeat the "why it fits" column from the table; the nudges should add fresh framing the table doesn't carry (e.g., recent news from the \`qualification_summary\` excerpt, a window closing, a competitor activity the user mentioned earlier in the session). One sentence per nudge, salesperson voice, not coachspeak.
22861
22416
 
22862
- If the batch returns fewer than 10 qualified leads, top it up: call \`leadbay_bulk_qualify_leads\` with \`lensId:<captured>\`, \`count:<1.5x deficit, capped at 25>\`, and **\`wait_for_completion:false\`**. Capture \`qualify_id\` from the response and poll \`leadbay_qualify_status\` every ~10s until \`status:'done'\`. Then re-pull with the same \`lensId\` to pick up the newly qualified leads. **Never re-pull without \`lensId\` \u2014 you will lose your batch to a lens shift.** (The \`leadbay_qualify_top_n\` slash-prompt wraps this same tool with a friendlier surface for users; agents should call the underlying tool directly here.)
22417
+ If the batch returns fewer than 10 qualified leads, top it up: call \`leadbay_bulk_qualify_leads\` with \`lensId:<captured>\`, \`count:<1.5x deficit, capped at 25>\`, and **\`wait_for_completion:false\`**. Capture \`notification_id\` from the response and poll \`leadbay_qualify_status\` every ~10s until \`status:'done'\`. Then re-pull with the same \`lensId\` to pick up the newly qualified leads. **Never re-pull without \`lensId\` \u2014 you will lose your batch to a lens shift.** (The \`leadbay_qualify_top_n\` slash-prompt wraps this same tool with a friendlier surface for users; agents should call the underlying tool directly here.)
22863
22418
 
22864
22419
  # PHASE 4 \u2014 DEEP DIVE (every promising lead)
22865
22420
 
@@ -22973,7 +22528,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
22973
22528
 
22974
22529
  # Resilience rules for Leadbay long-running tools
22975
22530
 
22976
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
22531
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
22977
22532
 
22978
22533
  ## Rule 1 \u2014 Pin the lens
22979
22534
 
@@ -22981,7 +22536,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
22981
22536
 
22982
22537
  ## Rule 2 \u2014 Prefer async for bulk operations
22983
22538
 
22984
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
22539
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
22985
22540
 
22986
22541
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
22987
22542
 
@@ -22997,6 +22552,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
22997
22552
 
22998
22553
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
22999
22554
 
22555
+ ## A launched job cannot be stopped
22556
+
22557
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
22558
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
22559
+ running result, that work is queued on Leadbay and runs to completion, and the
22560
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
22561
+ launched nothing and is not covered here.
22562
+
22563
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
22564
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
22565
+ work stopped. What to do next depends on what you are holding:
22566
+
22567
+ - **A handle.** Poll the status tool with it, and do not launch the work that
22568
+ handle covers a second time \u2014 that spends the quota again on the same rows.
22569
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
22570
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
22571
+ notification of its own: resume it with
22572
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
22573
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
22574
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
22575
+ for what was launched and re-run for that subset only, never for the whole
22576
+ batch.
22577
+ - **No result at all**, because the call timed out or the stream closed before it
22578
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
22579
+ finished. Calling the same tool again with the same arguments will usually hand
22580
+ back the job already launched rather than starting a second one, but that guard
22581
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
22582
+ are about to re-run before you spend the user's quota on it.
22583
+
22584
+
23000
22585
 
23001
22586
  # THE ONE-FORWARD-OPTION RULE \u2014 the structural contract of this walkthrough
23002
22587
 
@@ -23432,7 +23017,7 @@ account's **default wishlist selection** while \`confirm\`/\`email\` are set \u2
23432
23017
  it would reveal and charge for the whole batch instead of the one lead the user
23433
23018
  agreed to.
23434
23019
 
23435
- It returns a \`bulk_id\` and runs async \u2014 poll \`leadbay_bulk_enrich_status\`
23020
+ It returns a \`notification_id\` and runs async \u2014 poll \`leadbay_bulk_enrich_status\`
23436
23021
  with that id (\`include_contacts=true\`) until \`all_done\`, or until the resolved
23437
23022
  count plateaus across a few spaced polls. Then report the contact that actually
23438
23023
  resolved: name, title, and the email/phone that came back. Contacts sometimes
@@ -23670,7 +23255,7 @@ Build the final mappings yourself. Start from \`leadbay_resolve_import_rows.mapp
23670
23255
 
23671
23256
  # PHASE 5 \u2014 QUALIFY (optional) + REPORT
23672
23257
 
23673
- Prefer \`leadbay_import_and_qualify\` when the user asks to qualify/research after import; otherwise use \`leadbay_import_leads\`. For large files or short client timeouts, pass \`wait_for_completion=false\` and poll \`leadbay_import_status\`. After import, qualify only lead IDs returned by the import. Rows that came back \`uncrawled\` are pending a background crawl (not failures); the leads Leadbay adds for them populate in the user's Leadbay account as the crawl completes \u2014 tell the user that, not that a tool call will fetch them (\`import_status\` reports the rows the wizard already placed, not leads a later crawl adds; \`pull_leads\` reads the active lens, so an imported lead outside it may not appear; re-running the import later re-reconciles those companies).
23258
+ Prefer \`leadbay_import_and_qualify\` when the user asks to qualify/research after import; otherwise use \`leadbay_import_leads\`. For large files or short client timeouts, pass \`wait_for_completion=false\` and poll \`leadbay_import_status\`. After import, qualify only lead IDs returned by the import. Rows that came back \`uncrawled\` are pending a background crawl (not failures); the leads Leadbay adds for them populate in the user's Leadbay account as the crawl completes \u2014 tell the user that, not that a tool call will fetch them (\`import_status\` refreshes status/progress only; \`pull_leads\` reads the active lens, so an imported lead outside it may not appear; re-running the import later re-reconciles those companies).
23674
23259
 
23675
23260
  **Deliver the augmented file back to the user**: the original file plus a new \`LEADBAY_ID\` column populated from the resolution step. This is the second deliverable of a job well done.
23676
23261
 
@@ -23842,7 +23427,7 @@ You are working with Leadbay through the \`leadbay_*\` MCP tools. This prompt or
23842
23427
 
23843
23428
  # Resilience rules for Leadbay long-running tools
23844
23429
 
23845
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
23430
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
23846
23431
 
23847
23432
  ## Rule 1 \u2014 Pin the lens
23848
23433
 
@@ -23850,7 +23435,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
23850
23435
 
23851
23436
  ## Rule 2 \u2014 Prefer async for bulk operations
23852
23437
 
23853
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
23438
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
23854
23439
 
23855
23440
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
23856
23441
 
@@ -23866,6 +23451,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
23866
23451
 
23867
23452
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
23868
23453
 
23454
+ ## A launched job cannot be stopped
23455
+
23456
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
23457
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
23458
+ running result, that work is queued on Leadbay and runs to completion, and the
23459
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
23460
+ launched nothing and is not covered here.
23461
+
23462
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
23463
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
23464
+ work stopped. What to do next depends on what you are holding:
23465
+
23466
+ - **A handle.** Poll the status tool with it, and do not launch the work that
23467
+ handle covers a second time \u2014 that spends the quota again on the same rows.
23468
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
23469
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
23470
+ notification of its own: resume it with
23471
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
23472
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
23473
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
23474
+ for what was launched and re-run for that subset only, never for the whole
23475
+ batch.
23476
+ - **No result at all**, because the call timed out or the stream closed before it
23477
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
23478
+ finished. Calling the same tool again with the same arguments will usually hand
23479
+ back the job already launched rather than starting a second one, but that guard
23480
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
23481
+ are about to re-run before you spend the user's quota on it.
23482
+
23483
+
23869
23484
 
23870
23485
  ## The two entry points
23871
23486
 
@@ -24026,7 +23641,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
24026
23641
  # PHASE 1 \u2014 LAUNCH
24027
23642
  Call \`leadbay_bulk_qualify_leads\` with \`count={{arg:count_or_default}}\` and \`wait_for_completion=true\` (synchronous mode \u2014 waits for results before returning).
24028
23643
 
24029
- **Resilience rule:** If \`leadbay_bulk_qualify_leads\` returns a BulkTracker-not-configured error or similar infrastructure error, do NOT retry with \`wait_for_completion=false\`. Instead, proceed directly to Phase 3 and call \`leadbay_pull_leads\` to surface the already-qualified leads in the current batch.
23644
+ **Resilience rule:** If \`leadbay_bulk_qualify_leads\` returns an infrastructure error, do NOT retry with \`wait_for_completion=false\`. Instead, proceed directly to Phase 3 and call \`leadbay_pull_leads\` to surface the already-qualified leads in the current batch.
24030
23645
 
24031
23646
  # PHASE 2 \u2014 POLL
24032
23647
  While it polls, expect notifications / progress events showing per-lead transitions. Surface meaningful ones (e.g. "lead X just finished") to me as they arrive \u2014 one inline status sentence per check, never expanded into a card:
@@ -24525,7 +24140,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
24525
24140
 
24526
24141
  # Resilience rules for Leadbay long-running tools
24527
24142
 
24528
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
24143
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
24529
24144
 
24530
24145
  ## Rule 1 \u2014 Pin the lens
24531
24146
 
@@ -24533,7 +24148,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
24533
24148
 
24534
24149
  ## Rule 2 \u2014 Prefer async for bulk operations
24535
24150
 
24536
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
24151
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
24537
24152
 
24538
24153
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
24539
24154
 
@@ -24549,6 +24164,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
24549
24164
 
24550
24165
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
24551
24166
 
24167
+ ## A launched job cannot be stopped
24168
+
24169
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
24170
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
24171
+ running result, that work is queued on Leadbay and runs to completion, and the
24172
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
24173
+ launched nothing and is not covered here.
24174
+
24175
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
24176
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
24177
+ work stopped. What to do next depends on what you are holding:
24178
+
24179
+ - **A handle.** Poll the status tool with it, and do not launch the work that
24180
+ handle covers a second time \u2014 that spends the quota again on the same rows.
24181
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
24182
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
24183
+ notification of its own: resume it with
24184
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
24185
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
24186
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
24187
+ for what was launched and re-run for that subset only, never for the whole
24188
+ batch.
24189
+ - **No result at all**, because the call timed out or the stream closed before it
24190
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
24191
+ finished. Calling the same tool again with the same arguments will usually hand
24192
+ back the job already launched rather than starting a second one, but that guard
24193
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
24194
+ are about to re-run before you spend the user's quota on it.
24195
+
24196
+
24552
24197
 
24553
24198
  # PHASE 0 \u2014 SCOPE + STATE
24554
24199
 
@@ -24646,7 +24291,7 @@ So: **read the persisted filter first** (the response reports \`active_filters\`
24646
24291
 
24647
24292
  \u26A0 **Pass explicit \`leadIds\` whenever the cohort isn't simply "the next N on the lens"** \u2014 e.g. after you've selected a shortlist, or when the plan mixes Monitor and Discover rows. The \`count\`-based path selects the next *unqualified leads from the lens wishlist*, so on any other cohort it qualifies unrelated leads and hands you handles whose pills belong to different companies. Use \`leadbay_bulk_qualify_leads({leadIds:[\u2026\u226425 of the cohort], wait_for_completion:false})\` and chunk through the cohort's own ids. The \`{lensId, count}\` form is only right when the cohort genuinely *is* the lens's top N.
24648
24293
 
24649
- **Qualify the plan cohort, not the whole base.** Select your ~{{arg:count_or_default}} candidates (plus a modest buffer for drop-outs) BEFORE qualifying \u2014 qualification is async and quota-bearing, so running it across an entire portfolio to produce a top-{{arg:count_or_default}} burns the user's quota for rows that will never appear. **Keep every returned \`qualify_id\`** \u2014 the deck's live qualification layer is wired from those handles, and a deck with none is a dead deck that still looks finished. Never ship a plan whose lower ranks have empty qualification pills because only the first 25 were ever qualified.
24294
+ **Qualify the plan cohort, not the whole base.** Select your ~{{arg:count_or_default}} candidates (plus a modest buffer for drop-outs) BEFORE qualifying \u2014 qualification is async and quota-bearing, so running it across an entire portfolio to produce a top-{{arg:count_or_default}} burns the user's quota for rows that will never appear. **Keep every returned \`notification_id\`** \u2014 the deck's live qualification layer is wired from those handles, and a deck with none is a dead deck that still looks finished. Never ship a plan whose lower ranks have empty qualification pills because only the first 25 were ever qualified.
24650
24295
 
24651
24296
  **Signals \u2014 scoped to the cohort.** \u26A0 **Always pass the selected \`leadIds\`.** With \`leadIds\` omitted, \`leadbay_scan_portfolio_signals\` builds its own portfolio by paging \`/monitor\` \u2014 so on an imported cohort or a freshly-pulled Discover set it would scan a *different population* and you'd render dashes for accounts whose signals were never read.
24652
24297
 
@@ -24773,7 +24418,7 @@ Each card needs a reachable decision-maker. \`leadbay_enrich_titles({leadIds, le
24773
24418
 
24774
24419
  \u26A0 **Do NOT quote a cost or a credits figure.** The per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance; \`credits_remaining\` is advisory context only. A spend number invented to make the offer concrete is the same failure as an invented euro on a card.
24775
24420
 
24776
- On an explicit yes, launch with the agreed \`titles\` + channels, then poll \`leadbay_bulk_enrich_status\` until done and **keep the \`bulk_id\` handles** for the deck.
24421
+ On an explicit yes, launch with the agreed \`titles\` + channels, then poll \`leadbay_bulk_enrich_status\` until done and **keep the \`notification_id\` handles** for the deck.
24777
24422
 
24778
24423
  \u26A0 **Render only the channels that actually came back.** The default reveal is email-only unless phone was explicitly requested, so never emit a \`tel:\` link for a contact whose phone was never revealed \u2014 show the channels enrichment returned and mark the rest omitted. A fabricated phone link is the same failure as a fabricated euro.
24779
24424
 
@@ -24919,9 +24564,9 @@ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We
24919
24564
  - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
24920
24565
 
24921
24566
 
24922
- \u26A0 **The deck's contact layer depends on what actually happened in Phase 5.** Bind a \`leadbay_bulk_enrich_status\` resource ONLY if a paid reveal was launched and you hold a \`bulk_id\`. If the user accepted the deck but not the reveal, render the contacts already on record and carry the paid-reveal offer inside the deck \u2014 never wire a status resource with no handle (it renders permanently empty) and never launch enrichment from the deck to manufacture one.
24567
+ \u26A0 **The deck's contact layer depends on what actually happened in Phase 5.** Bind a \`leadbay_bulk_enrich_status\` resource ONLY if a paid reveal was launched and you hold a \`notification_id\`. If the user accepted the deck but not the reveal, render the contacts already on record and carry the paid-reveal offer inside the deck \u2014 never wire a status resource with no handle (it renders permanently empty) and never launch enrichment from the deck to manufacture one.
24923
24568
 
24924
- On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before writing any code, and build a single-file deck. Wire the live layer from the handles you kept: a poll-until-done resource per \`qualify_id\` for the qualification pills, and one over \`leadbay_bulk_enrich_status\` for the contacts. \u26A0 **If enrichment already ran this session, bind the existing \`bulk_id\` \u2014 re-launching enrichment from the deck double-spends my quota.** Per-card notes and outcomes go through the pre-wired note/outreach view-models (they carry the required verification and \`_triggered_by\` fields; hand-rolling those is where it breaks). Keep the checklists in local storage, and always wire a Refresh \u2014 auto-poll is host-dependent. List every tool the deck calls in its \`mcp_tools\`, and render the bridge-unavailable branch, or the pills silently show empty.
24569
+ On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before writing any code, and build a single-file deck. Wire the live layer from the handles you kept: a poll-until-done resource per \`notification_id\` for the qualification pills, and one over \`leadbay_bulk_enrich_status\` for the contacts. \u26A0 **If enrichment already ran this session, bind the existing \`notification_id\` \u2014 re-launching enrichment from the deck double-spends my quota.** Per-card notes and outcomes go through the pre-wired note/outreach view-models (they carry the required verification and \`_triggered_by\` fields; hand-rolling those is where it breaks). Keep the checklists in local storage, and always wire a Refresh \u2014 auto-poll is host-dependent. List every tool the deck calls in its \`mcp_tools\`, and render the bridge-unavailable branch, or the pills silently show empty.
24925
24570
 
24926
24571
  # Iron laws
24927
24572
 
@@ -26197,7 +25842,7 @@ function buildProtocolPrimitivesParagraph(has) {
26197
25842
  }
26198
25843
  if (longRunners.length > 0) {
26199
25844
  parts.push(
26200
- "(2) `notifications/cancelled` \u2014 when the user clicks Cancel in the host UI, the polling loop exits within \u22642 seconds AND the bulk-store entry transitions to 'cancelled'; subsequent status polls return `BULK_CANCELLED` so the agent stops polling."
25845
+ "(2) `notifications/cancelled` \u2014 when the user clicks Cancel in the host UI, the polling loop exits within \u22642 seconds. The job itself keeps running on the backend; poll its notification_id / importIds later to pick it up."
26201
25846
  );
26202
25847
  } else {
26203
25848
  parts.push(
@@ -26717,7 +26362,6 @@ ${url}
26717
26362
  const shapeError = findShapeMismatch(tool, args);
26718
26363
  const result = shapeError ?? await runWithRequestSignal(extra.signal, () => tool.execute(client, args, {
26719
26364
  logger: opts.logger,
26720
- bulkTracker: opts.bulkTracker,
26721
26365
  notificationsInbox: opts.notificationsInbox,
26722
26366
  signal: extra.signal,
26723
26367
  progress,
@@ -26971,10 +26615,10 @@ ${url}
26971
26615
  import { spawn } from "child_process";
26972
26616
  import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
26973
26617
  import { join as join2 } from "path";
26974
- import { homedir as homedir2 } from "os";
26618
+ import { homedir } from "os";
26975
26619
  var HOSTED_MCP_URL_CHATGPT = "https://mcp.leadbay.app/chatgpt/mcp";
26976
- function formatInstallOsLabel(platform2 = process.platform, arch = process.arch) {
26977
- const name = platform2 === "darwin" ? "macOS" : platform2 === "win32" ? "Windows" : platform2 === "linux" ? "Linux" : platform2;
26620
+ function formatInstallOsLabel(platform = process.platform, arch = process.arch) {
26621
+ const name = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform === "linux" ? "Linux" : platform;
26978
26622
  return `${name} (${arch})`;
26979
26623
  }
26980
26624
  function detectClaudeDesktopMode(claudeSupportDir) {
@@ -27089,7 +26733,7 @@ async function isCursorInstalled(home) {
27089
26733
  }
27090
26734
  async function detectClients() {
27091
26735
  const out = [];
27092
- const home = homedir2();
26736
+ const home = homedir();
27093
26737
  const claudeBin = await findOnPath("claude");
27094
26738
  if (claudeBin) {
27095
26739
  out.push({ id: "claude-code", label: "Claude Code", detail: `${claudeBin} mcp add ...` });
@@ -27198,7 +26842,7 @@ async function uninstallFromClaudeCode() {
27198
26842
  async function installInJsonConfig(configPath, token, region, includeWrite, telemetryEnabled, localBinPath) {
27199
26843
  try {
27200
26844
  const { readFileSync: readFileSync3, writeFileSync, existsSync: existsSync4, mkdirSync, statSync } = await import("fs");
27201
- const { dirname: dirname3 } = await import("path");
26845
+ const { dirname: dirname2 } = await import("path");
27202
26846
  let parsed = {};
27203
26847
  let preserved = {};
27204
26848
  const existed = existsSync4(configPath);
@@ -27211,7 +26855,7 @@ async function installInJsonConfig(configPath, token, region, includeWrite, tele
27211
26855
  return { ok: false, message: `existing ${configPath} is not valid JSON; refusing to overwrite` };
27212
26856
  }
27213
26857
  } else {
27214
- mkdirSync(dirname3(configPath), { recursive: true });
26858
+ mkdirSync(dirname2(configPath), { recursive: true });
27215
26859
  }
27216
26860
  parsed.mcpServers = parsed.mcpServers ?? {};
27217
26861
  const env = {
@@ -27336,13 +26980,13 @@ function stripShellExportBlock(existing) {
27336
26980
  async function installInCodexConfig(configPath, includeWrite, telemetryEnabled, localBinPath) {
27337
26981
  try {
27338
26982
  const { readFileSync: readFileSync3, writeFileSync, existsSync: existsSync4, mkdirSync, statSync, renameSync, chmodSync } = await import("fs");
27339
- const { dirname: dirname3 } = await import("path");
26983
+ const { dirname: dirname2 } = await import("path");
27340
26984
  let existing = "";
27341
26985
  const existed = existsSync4(configPath);
27342
26986
  if (existed) {
27343
26987
  existing = readFileSync3(configPath, "utf8");
27344
26988
  } else {
27345
- mkdirSync(dirname3(configPath), { recursive: true });
26989
+ mkdirSync(dirname2(configPath), { recursive: true });
27346
26990
  }
27347
26991
  const hadLeadbayConfig = /(^|\r?\n)\[mcp_servers\.leadbay\]\r?\n/.test(existing);
27348
26992
  const next = mergeCodexConfig(
@@ -27660,17 +27304,17 @@ function parseWriteEnv(env = process.env) {
27660
27304
 
27661
27305
  // src/update-state.ts
27662
27306
  import {
27663
- mkdir as mkdirAsync2,
27664
- lstat as lstat2,
27665
- open as fsOpen2,
27666
- readFile as readFile2,
27667
- rename as rename2,
27668
- stat as stat2,
27669
- unlink as unlink2
27307
+ mkdir as mkdirAsync,
27308
+ lstat,
27309
+ open as fsOpen,
27310
+ readFile,
27311
+ rename,
27312
+ stat,
27313
+ unlink
27670
27314
  } from "fs/promises";
27671
- import { constants as fsConstants2 } from "fs";
27672
- import { dirname as dirname2, resolve as resolvePath2 } from "path";
27673
- import { homedir as homedir3 } from "os";
27315
+ import { constants as fsConstants } from "fs";
27316
+ import { dirname, resolve as resolvePath } from "path";
27317
+ import { homedir as homedir2 } from "os";
27674
27318
  function emptyState() {
27675
27319
  return {
27676
27320
  last_check_time: 0,
@@ -27694,7 +27338,7 @@ var UpdateStateStore = class {
27694
27338
  if (!opts.path) {
27695
27339
  throw new Error("UpdateStateStore: path is required when backend=file");
27696
27340
  }
27697
- this.path = resolvePath2(opts.path);
27341
+ this.path = resolvePath(opts.path);
27698
27342
  this.validatePath(this.path);
27699
27343
  }
27700
27344
  }
@@ -27706,7 +27350,7 @@ var UpdateStateStore = class {
27706
27350
  }
27707
27351
  validatePath(p) {
27708
27352
  if (this.allowUnsafePath) return;
27709
- const home = resolvePath2(homedir3());
27353
+ const home = resolvePath(homedir2());
27710
27354
  if (p !== home && !p.startsWith(home + "/") && !p.startsWith(home + "\\")) {
27711
27355
  throw new Error(
27712
27356
  `UpdateStateStore: path ${p} is outside $HOME (${home}). Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
@@ -27718,10 +27362,10 @@ var UpdateStateStore = class {
27718
27362
  this.initialized = true;
27719
27363
  return;
27720
27364
  }
27721
- const dir = dirname2(this.path);
27722
- await mkdirAsync2(dir, { recursive: true, mode: 448 });
27365
+ const dir = dirname(this.path);
27366
+ await mkdirAsync(dir, { recursive: true, mode: 448 });
27723
27367
  try {
27724
- const st = await lstat2(this.path);
27368
+ const st = await lstat(this.path);
27725
27369
  if (st.isSymbolicLink()) {
27726
27370
  throw new Error(
27727
27371
  `UpdateStateStore: refusing to use ${this.path} \u2014 path is a symlink. Set LEADBAY_UPDATE_STATE_PATH_UNSAFE=1 to override.`
@@ -27737,7 +27381,7 @@ var UpdateStateStore = class {
27737
27381
  await this.ensureInitialized();
27738
27382
  let raw;
27739
27383
  try {
27740
- raw = await readFile2(this.path, "utf8");
27384
+ raw = await readFile(this.path, "utf8");
27741
27385
  } catch (err) {
27742
27386
  if (err?.code === "ENOENT") return emptyState();
27743
27387
  throw err;
@@ -27759,13 +27403,13 @@ var UpdateStateStore = class {
27759
27403
  }
27760
27404
  await this.ensureInitialized();
27761
27405
  const tmp = `${this.path}.tmp.${process.pid}.${this.now()}`;
27762
- const handle = await openTmpFileExclusive2(tmp);
27406
+ const handle = await openTmpFileExclusive(tmp);
27763
27407
  try {
27764
27408
  await handle.writeFile(JSON.stringify(state, null, 2));
27765
27409
  } finally {
27766
27410
  await handle.close();
27767
27411
  }
27768
- await rename2(tmp, this.path);
27412
+ await rename(tmp, this.path);
27769
27413
  }
27770
27414
  /**
27771
27415
  * Apply a partial mutation atomically (read → merge → write). Caller
@@ -27811,20 +27455,20 @@ var UpdateStateStore = class {
27811
27455
  return out;
27812
27456
  }
27813
27457
  };
27814
- async function openTmpFileExclusive2(path) {
27458
+ async function openTmpFileExclusive(path) {
27815
27459
  try {
27816
- return await fsOpen2(
27460
+ return await fsOpen(
27817
27461
  path,
27818
- fsConstants2.O_CREAT | fsConstants2.O_WRONLY | fsConstants2.O_EXCL,
27462
+ fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL,
27819
27463
  384
27820
27464
  );
27821
27465
  } catch (err) {
27822
27466
  if (err?.code === "EEXIST") {
27823
- await unlink2(path).catch(() => {
27467
+ await unlink(path).catch(() => {
27824
27468
  });
27825
- return fsOpen2(
27469
+ return fsOpen(
27826
27470
  path,
27827
- fsConstants2.O_CREAT | fsConstants2.O_WRONLY | fsConstants2.O_EXCL,
27471
+ fsConstants.O_CREAT | fsConstants.O_WRONLY | fsConstants.O_EXCL,
27828
27472
  384
27829
27473
  );
27830
27474
  }
@@ -27834,7 +27478,7 @@ async function openTmpFileExclusive2(path) {
27834
27478
  async function createDefaultUpdateStateStore(opts = {}) {
27835
27479
  const env = opts.env ?? process.env;
27836
27480
  const allowUnsafePath = env.LEADBAY_UPDATE_STATE_PATH_UNSAFE === "1";
27837
- const path = env.LEADBAY_UPDATE_STATE_PATH ?? resolvePath2(homedir3(), ".leadbay", "update-state.json");
27481
+ const path = env.LEADBAY_UPDATE_STATE_PATH ?? resolvePath(homedir2(), ".leadbay", "update-state.json");
27838
27482
  try {
27839
27483
  const store = new UpdateStateStore({
27840
27484
  backend: "file",
@@ -27843,7 +27487,7 @@ async function createDefaultUpdateStateStore(opts = {}) {
27843
27487
  allowUnsafePath
27844
27488
  });
27845
27489
  await store.ensureInitialized();
27846
- await stat2(dirname2(path));
27490
+ await stat(dirname(path));
27847
27491
  return store;
27848
27492
  } catch (err) {
27849
27493
  opts.logger?.warn?.(
@@ -28154,14 +27798,14 @@ async function exchangeCodeForToken(opts) {
28154
27798
  return { accessToken: parsed.access_token };
28155
27799
  }
28156
27800
  function browserOpenCandidates(url) {
28157
- const platform2 = process.platform;
28158
- if (platform2 === "darwin") {
27801
+ const platform = process.platform;
27802
+ if (platform === "darwin") {
28159
27803
  return [
28160
27804
  { cmd: "/usr/bin/open", args: [url] },
28161
27805
  { cmd: "open", args: [url] }
28162
27806
  ];
28163
27807
  }
28164
- if (platform2 === "win32") {
27808
+ if (platform === "win32") {
28165
27809
  const sysRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
28166
27810
  const cmdExe = `${sysRoot}\\System32\\cmd.exe`;
28167
27811
  const quoted = `"${url}"`;
@@ -28401,7 +28045,7 @@ var OAUTH_BASE_URLS = {
28401
28045
  fr: "https://staging.api.leadbay.app"
28402
28046
  }
28403
28047
  };
28404
- var VERSION = "0.34.1";
28048
+ var VERSION = "0.35.1";
28405
28049
  var HELP = `
28406
28050
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
28407
28051
 
@@ -28524,9 +28168,9 @@ function hydrateEnvFromCredentialsFile() {
28524
28168
  function resolveOAuthBootstrapCredentialsPath() {
28525
28169
  const resolved = resolveDefaultCredentialsPath();
28526
28170
  if (process.env.LEADBAY_OAUTH_STAGING !== "1") return resolved;
28527
- const { dirname: dirname3, join: join3 } = require_("node:path");
28171
+ const { dirname: dirname2, join: join3 } = require_("node:path");
28528
28172
  return {
28529
- path: join3(dirname3(resolved.path), "credentials.staging.json"),
28173
+ path: join3(dirname2(resolved.path), "credentials.staging.json"),
28530
28174
  legacy: resolved.legacy
28531
28175
  };
28532
28176
  }
@@ -28537,8 +28181,8 @@ function bootstrapDebug(msg) {
28537
28181
  try {
28538
28182
  const { appendFileSync, mkdirSync } = require_("node:fs");
28539
28183
  const { join: join3 } = require_("node:path");
28540
- const { homedir: homedir4 } = require_("node:os");
28541
- const dir = join3(homedir4(), ".leadbay");
28184
+ const { homedir: homedir3 } = require_("node:os");
28185
+ const dir = join3(homedir3(), ".leadbay");
28542
28186
  mkdirSync(dir, { recursive: true });
28543
28187
  const ts = (/* @__PURE__ */ new Date()).toISOString();
28544
28188
  appendFileSync(join3(dir, "oauth-bootstrap-debug.log"), `${ts} [pid ${process.pid}] ${msg}
@@ -28548,8 +28192,8 @@ function bootstrapDebug(msg) {
28548
28192
  }
28549
28193
  function oauthClientCachePath() {
28550
28194
  const { join: join3 } = require_("node:path");
28551
- const { homedir: homedir4 } = require_("node:os");
28552
- return join3(homedir4(), ".leadbay", "oauth-client.json");
28195
+ const { homedir: homedir3 } = require_("node:os");
28196
+ return join3(homedir3(), ".leadbay", "oauth-client.json");
28553
28197
  }
28554
28198
  function getCachedOAuthClientId(authServerBaseUrl, port) {
28555
28199
  try {
@@ -28565,7 +28209,7 @@ function getCachedOAuthClientId(authServerBaseUrl, port) {
28565
28209
  function cacheOAuthClientId(authServerBaseUrl, clientId, port) {
28566
28210
  try {
28567
28211
  const { readFileSync: readFileSync3, writeFileSync, mkdirSync } = require_("node:fs");
28568
- const { dirname: dirname3 } = require_("node:path");
28212
+ const { dirname: dirname2 } = require_("node:path");
28569
28213
  const path = oauthClientCachePath();
28570
28214
  let data = { clients: {} };
28571
28215
  try {
@@ -28577,7 +28221,7 @@ function cacheOAuthClientId(authServerBaseUrl, clientId, port) {
28577
28221
  const byPort = server && typeof server === "object" && server.byPort && typeof server.byPort === "object" ? server.byPort : {};
28578
28222
  byPort[String(port)] = clientId;
28579
28223
  data.clients[authServerBaseUrl] = { byPort };
28580
- mkdirSync(dirname3(path), { recursive: true });
28224
+ mkdirSync(dirname2(path), { recursive: true });
28581
28225
  writeFileSync(path, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
28582
28226
  } catch {
28583
28227
  }
@@ -28670,7 +28314,7 @@ async function bootstrapOAuthIfMissing(logger) {
28670
28314
  });
28671
28315
  try {
28672
28316
  const { writeFileSync, mkdirSync, chmodSync } = require_("node:fs");
28673
- const { dirname: dirname3 } = require_("node:path");
28317
+ const { dirname: dirname2 } = require_("node:path");
28674
28318
  const { path } = resolveOAuthBootstrapCredentialsPath();
28675
28319
  const envBlock = {
28676
28320
  LEADBAY_TOKEN: accessToken,
@@ -28686,7 +28330,7 @@ async function bootstrapOAuthIfMissing(logger) {
28686
28330
  }
28687
28331
  }
28688
28332
  };
28689
- mkdirSync(dirname3(path), { recursive: true });
28333
+ mkdirSync(dirname2(path), { recursive: true });
28690
28334
  writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
28691
28335
  try {
28692
28336
  chmodSync(path, 384);
@@ -29127,8 +28771,8 @@ Restart your MCP client to pick up the new server.
29127
28771
  let actualMode;
29128
28772
  try {
29129
28773
  const { writeFileSync, chmodSync, mkdirSync, renameSync, statSync, unlinkSync } = await import("fs");
29130
- const { dirname: dirname3 } = await import("path");
29131
- mkdirSync(dirname3(targetPath), { recursive: true });
28774
+ const { dirname: dirname2 } = await import("path");
28775
+ mkdirSync(dirname2(targetPath), { recursive: true });
29132
28776
  const tmp = targetPath + ".tmp." + process.pid;
29133
28777
  writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", {
29134
28778
  encoding: "utf8",
@@ -29586,7 +29230,6 @@ async function main() {
29586
29230
  });
29587
29231
  const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1";
29588
29232
  const includeWrite = parseWriteEnv();
29589
- const bulkTracker = await createDefaultBulkStore({ logger });
29590
29233
  const updateStateStore = await createDefaultUpdateStateStore({ logger });
29591
29234
  void recordRunningVersion(VERSION, updateStateStore, telemetry).catch((err) => {
29592
29235
  logger.warn?.(
@@ -29623,7 +29266,6 @@ async function main() {
29623
29266
  includeAdvanced,
29624
29267
  includeWrite,
29625
29268
  logger,
29626
- bulkTracker,
29627
29269
  notificationsInbox,
29628
29270
  version: VERSION,
29629
29271
  telemetry,
@@ -29643,7 +29285,7 @@ async function main() {
29643
29285
  });
29644
29286
  const transport = new StdioServerTransport();
29645
29287
  logger.info?.(
29646
- `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, notifications_ws=${WS_DISABLED ? "disabled" : "enabled"}, auth_state=${authState})`
29288
+ `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, notifications_ws=${WS_DISABLED ? "disabled" : "enabled"}, auth_state=${authState})`
29647
29289
  );
29648
29290
  await server.connect(transport);
29649
29291
  if (bootstrapPending) {