@primitivedotdev/cli 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/oclif/index.js +458 -196
  2. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { A as PrimitiveApiClient, C as saveCliCredentials, D as loadActiveChatState, E as deleteChatState, M as createConfig, O as loadChatConversationByLocalId, S as resolveCliAuth, T as chatStatePath, _ as deleteCliCredentials, a as normalizeCliEnvironmentName, b as loadCliCredentials, c as resolveConfigEnvironment, d as validateCliHeaderName, f as validateCliHeaderValue, g as credentialsPath, h as credentialsLockPath, i as loadCliConfig, j as createClient, k as saveActiveChatState, l as saveCliConfig, m as cliAccessTokenExpiresAt, n as deleteCliConfig, o as redactCliEnvironment, p as acquireCliCredentialsLock, r as emptyCliConfig, s as removeCliEnvironment, u as upsertCliEnvironment, v as deleteCliCredentialsLock, w as saveSignupCredentials, x as normalizeApiBaseUrl, y as detectPrimitiveKeyEnvMisname } from "../cli-config-B5hrwe8q.js";
2
2
  import { Args, Command, Errors, Flags, ux } from "@oclif/core";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { hostname } from "node:os";
@@ -14742,6 +14742,173 @@ function readAttachmentFiles(paths, readFile = readFileSync) {
14742
14742
  });
14743
14743
  }
14744
14744
  //#endregion
14745
+ //#region src/oclif/chat-lock.ts
14746
+ /**
14747
+ * Filesystem mutex around the chat-state read → POST → save sequence.
14748
+ *
14749
+ * Why this exists. The chat reply flow is read-modify-write across an
14750
+ * RTT to the API: `loadActiveChatState` → `replyToEmail` → poll → save.
14751
+ * Two concurrent invocations (e.g. the user re-runs `primitive chat
14752
+ * reply` before the first cycle finishes its 5–12 s poll) read the
14753
+ * same stale `last_reply_email_id` and POST to it, producing a
14754
+ * duplicate /v1/emails/{id}/reply that the server deduplicates by
14755
+ * content_hash. The second invocation then polls forever for a reply
14756
+ * that arrived in response to the *first* send and has already been
14757
+ * surfaced by the *first* invocation.
14758
+ *
14759
+ * The lock is per-process-config-dir, not per-conversation: holding it
14760
+ * for a few seconds while one chat reply completes is a reasonable UX
14761
+ * constraint and clearly explained on contention. The lock is
14762
+ * re-entrant within a single Node process (ChatReplyCommand wraps
14763
+ * ChatCommand internally), but rejects cross-process contention.
14764
+ *
14765
+ * Liveness. The lock file stores the holder's PID. On EEXIST we probe
14766
+ * the holder with `process.kill(pid, 0)`; if the holder is gone (e.g.
14767
+ * a previous chat invocation crashed without releasing), we steal the
14768
+ * lock. This avoids needing a heartbeat or mtime-based stale check,
14769
+ * either of which has its own race surface.
14770
+ *
14771
+ * Releases. The returned function is idempotent. Callers must call it
14772
+ * in a finally block. We also register process-exit / signal handlers
14773
+ * so a Ctrl-C during the poll loop still cleans up.
14774
+ */
14775
+ const LOCK_FILENAME = "chat-state.lock";
14776
+ let processHolder = null;
14777
+ /**
14778
+ * Whether we've installed our exit / signal listeners on the
14779
+ * `process` object. Done lazily on first acquire and never undone:
14780
+ * adding handlers per-acquire would let them accumulate (each
14781
+ * acquire registers four listeners, and `process.once` can't be
14782
+ * un-once'd), which is harmless in production but produces
14783
+ * `MaxListenersExceededWarning` + cascading no-op fires in tests
14784
+ * that acquire/release across many `it()` blocks. Greptile P2.
14785
+ *
14786
+ * The handlers consult the current `processHolder` and act only if
14787
+ * a lock is actually held, so leaving them installed across
14788
+ * release boundaries is safe: a released or never-acquired lock
14789
+ * makes them no-ops.
14790
+ */
14791
+ let exitListenersInstalled = false;
14792
+ function installExitListenersOnce() {
14793
+ if (exitListenersInstalled) return;
14794
+ exitListenersInstalled = true;
14795
+ const cleanup = () => {
14796
+ if (processHolder === null) return;
14797
+ try {
14798
+ unlinkSync(lockPath(processHolder.configDir));
14799
+ } catch {}
14800
+ processHolder = null;
14801
+ };
14802
+ process.on("exit", cleanup);
14803
+ for (const signal of [
14804
+ "SIGINT",
14805
+ "SIGTERM",
14806
+ "SIGHUP"
14807
+ ]) {
14808
+ const handler = () => {
14809
+ cleanup();
14810
+ process.removeListener(signal, handler);
14811
+ process.kill(process.pid, signal);
14812
+ };
14813
+ process.on(signal, handler);
14814
+ }
14815
+ }
14816
+ function lockPath(configDir) {
14817
+ return join(configDir, LOCK_FILENAME);
14818
+ }
14819
+ /**
14820
+ * `process.kill(pid, 0)` returns true if the process exists and we
14821
+ * have permission to signal it. Throws ESRCH when the pid is gone,
14822
+ * EPERM if it exists but isn't ours. Both "exists" cases mean we
14823
+ * should NOT steal the lock; only ESRCH proves the holder is dead.
14824
+ */
14825
+ function pidIsAlive(pid) {
14826
+ try {
14827
+ process.kill(pid, 0);
14828
+ return true;
14829
+ } catch (err) {
14830
+ if (err.code === "ESRCH") return false;
14831
+ return true;
14832
+ }
14833
+ }
14834
+ function readHolderPid(configDir) {
14835
+ try {
14836
+ const raw = readFileSync(lockPath(configDir), "utf8").trim();
14837
+ const pid = Number.parseInt(raw, 10);
14838
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
14839
+ } catch {
14840
+ return null;
14841
+ }
14842
+ }
14843
+ var ChatLockContentionError = class extends Error {
14844
+ constructor(holderPid) {
14845
+ super(`Another \`primitive chat\` invocation (pid ${holderPid}) is in progress. Wait for it to finish, or kill it before retrying.`);
14846
+ this.holderPid = holderPid;
14847
+ this.name = "ChatLockContentionError";
14848
+ }
14849
+ };
14850
+ /**
14851
+ * Acquire the chat-state mutex for this configDir. Returns a release
14852
+ * function that is safe to call any number of times. Throws
14853
+ * `ChatLockContentionError` if another live process holds the lock.
14854
+ */
14855
+ function acquireChatLock(configDir) {
14856
+ if (processHolder?.configDir === configDir) {
14857
+ processHolder.depth += 1;
14858
+ let released = false;
14859
+ return () => {
14860
+ if (released) return;
14861
+ released = true;
14862
+ if (processHolder !== null) processHolder.depth -= 1;
14863
+ };
14864
+ }
14865
+ mkdirSync(configDir, {
14866
+ mode: 448,
14867
+ recursive: true
14868
+ });
14869
+ for (let attempt = 0; attempt < 2; attempt++) {
14870
+ let fd;
14871
+ try {
14872
+ fd = openSync(lockPath(configDir), "wx", 384);
14873
+ } catch (err) {
14874
+ if (err.code !== "EEXIST") throw err;
14875
+ const holder = readHolderPid(configDir);
14876
+ if (holder === null || !pidIsAlive(holder)) {
14877
+ try {
14878
+ unlinkSync(lockPath(configDir));
14879
+ } catch (unlinkErr) {
14880
+ if (unlinkErr.code !== "ENOENT") throw unlinkErr;
14881
+ }
14882
+ continue;
14883
+ }
14884
+ throw new ChatLockContentionError(holder);
14885
+ }
14886
+ writeSync(fd, `${process.pid}\n`);
14887
+ closeSync(fd);
14888
+ processHolder = {
14889
+ configDir,
14890
+ depth: 1
14891
+ };
14892
+ installExitListenersOnce();
14893
+ let released = false;
14894
+ return () => {
14895
+ if (released) return;
14896
+ released = true;
14897
+ if (processHolder?.configDir === configDir) {
14898
+ processHolder.depth -= 1;
14899
+ if (processHolder.depth <= 0) {
14900
+ try {
14901
+ unlinkSync(lockPath(configDir));
14902
+ } catch {}
14903
+ processHolder = null;
14904
+ }
14905
+ }
14906
+ };
14907
+ }
14908
+ /* v8 ignore next 4 -- the for-loop returns or throws on every iteration; the unreachable trailer keeps TS happy. */
14909
+ throw new Error("acquireChatLock: exhausted retries (this is a bug — should not be reachable)");
14910
+ }
14911
+ //#endregion
14745
14912
  //#region src/oclif/outbound-defaults.ts
14746
14913
  const SUBJECT_MAX_LENGTH = 200;
14747
14914
  function deriveSubject(body) {
@@ -15055,7 +15222,7 @@ function matchDescription(strategy) {
15055
15222
  return strategy === "strict" ? "strict, matched by reply_to_sent_email_id" : "fallback, matched by sender/time window";
15056
15223
  }
15057
15224
  function normalizeEmailAddress(value) {
15058
- return value.trim().toLowerCase();
15225
+ return (value.match(/<([^>]+)>/)?.[1] ?? value).trim().toLowerCase();
15059
15226
  }
15060
15227
  function derivedReplySubject(parent) {
15061
15228
  const subject = parent.subject?.trim();
@@ -15233,6 +15400,28 @@ function persistActiveChat(params) {
15233
15400
  return null;
15234
15401
  }
15235
15402
  }
15403
+ /**
15404
+ * Pick the email id to surface from the one-shot strict search we run
15405
+ * when the server returned `idempotent_replay: true`. The search has
15406
+ * no `since` filter (the existing reply, if any, predates this
15407
+ * attempt's `sentAtIso`), so we may receive multiple historical
15408
+ * inbounds matching `reply_to_sent_email_id = sent.id` if the user
15409
+ * has been hammering the same content. Prefer the most recent
15410
+ * accepted/completed row; reject pending/processing rows the way the
15411
+ * normal poll does.
15412
+ */
15413
+ async function resolveIdempotentReplayReply(page) {
15414
+ if (!page.ok || page.rows.length === 0) return null;
15415
+ let latest = null;
15416
+ for (const row of page.rows) {
15417
+ if (row.status !== "accepted" && row.status !== "completed") continue;
15418
+ if (latest === null || row.received_at.localeCompare(latest.receivedAt) > 0) latest = {
15419
+ id: row.id,
15420
+ receivedAt: row.received_at
15421
+ };
15422
+ }
15423
+ return latest?.id ?? null;
15424
+ }
15236
15425
  function formatChatResponse(context) {
15237
15426
  const accepted = context.sent.accepted.join(", ") || context.recipient;
15238
15427
  const responseBody = resolveChatResponseBody(context.reply);
@@ -15447,181 +15636,243 @@ var ChatCommand = class ChatCommand extends Command {
15447
15636
  const message = flags.reply !== void 0 ? flags.reply : args.message !== void 0 && args.message !== "" ? args.message : await readStdinToString();
15448
15637
  if (!message.trim()) throw cliError$6(replyMode ? "Reply body is empty." : "Message body is empty.");
15449
15638
  await runWithTiming(flags.time, async () => {
15450
- const { apiClient, auth, baseUrlOverridden } = await createAuthenticatedCliApiClient({
15451
- apiKey: flags["api-key"],
15452
- apiBaseUrl: flags["api-base-url"],
15453
- configDir: this.config.configDir
15454
- });
15455
- const authFailureContext = {
15456
- auth,
15457
- baseUrlOverridden,
15458
- configDir: this.config.configDir
15459
- };
15460
- const progress = flags.quiet ? null : new ChatProgressIndicator(process.stderr);
15461
- const attachments = readAttachmentFiles(flags.attachment);
15462
- let from;
15463
- let parentReply;
15464
- let subject;
15465
- if (replyMode) {
15466
- const replyContext = await (async () => {
15467
- let replyContextFailureMessage = "Could not load reply context.";
15468
- try {
15469
- if (flags["reply-to-email-id"] !== void 0) {
15470
- progress?.start(`Loading reply context for ${flags["reply-to-email-id"]}`);
15471
- const exactParentReply = await loadInboundEmailDetail({
15639
+ let releaseLock;
15640
+ try {
15641
+ releaseLock = acquireChatLock(this.config.configDir);
15642
+ } catch (err) {
15643
+ if (err instanceof ChatLockContentionError) throw cliError$6(err.message);
15644
+ throw err;
15645
+ }
15646
+ try {
15647
+ const { apiClient, auth, baseUrlOverridden } = await createAuthenticatedCliApiClient({
15648
+ apiKey: flags["api-key"],
15649
+ apiBaseUrl: flags["api-base-url"],
15650
+ configDir: this.config.configDir
15651
+ });
15652
+ const authFailureContext = {
15653
+ auth,
15654
+ baseUrlOverridden,
15655
+ configDir: this.config.configDir
15656
+ };
15657
+ const progress = flags.quiet ? null : new ChatProgressIndicator(process.stderr);
15658
+ const attachments = readAttachmentFiles(flags.attachment);
15659
+ let from;
15660
+ let parentReply;
15661
+ let subject;
15662
+ if (replyMode) {
15663
+ const replyContext = await (async () => {
15664
+ let replyContextFailureMessage = "Could not load reply context.";
15665
+ try {
15666
+ if (flags["reply-to-email-id"] !== void 0) {
15667
+ progress?.start(`Loading reply context for ${flags["reply-to-email-id"]}`);
15668
+ const exactParentReply = await loadInboundEmailDetail({
15669
+ apiClient,
15670
+ authFailureContext,
15671
+ id: flags["reply-to-email-id"]
15672
+ });
15673
+ replyContextFailureMessage = `Inbound email ${flags["reply-to-email-id"]} does not match recipient ${args.recipient}.`;
15674
+ assertParentMatchesRecipient(exactParentReply, args.recipient);
15675
+ return {
15676
+ from: flags.from ?? exactParentReply.to_email,
15677
+ parentReply: exactParentReply
15678
+ };
15679
+ }
15680
+ const replyFrom = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15681
+ progress?.start(`Finding latest inbound email from ${args.recipient}`);
15682
+ const latestParentReply = await findLatestInboundFromRecipient({
15472
15683
  apiClient,
15473
15684
  authFailureContext,
15474
- id: flags["reply-to-email-id"]
15685
+ from: replyFrom,
15686
+ pageSize: flags["page-size"],
15687
+ recipient: args.recipient
15475
15688
  });
15476
- replyContextFailureMessage = `Inbound email ${flags["reply-to-email-id"]} does not match recipient ${args.recipient}.`;
15477
- assertParentMatchesRecipient(exactParentReply, args.recipient);
15689
+ if (!latestParentReply) {
15690
+ replyContextFailureMessage = "No prior inbound email found.";
15691
+ throw cliError$6(`No prior inbound email from ${args.recipient} to ${replyFrom}. Start a new chat with \`primitive chat ${args.recipient} <message>\`, pass --from, or pass --reply-to-email-id <inbound-email-id>.`);
15692
+ }
15693
+ replyContextFailureMessage = `Inbound email ${latestParentReply.id} does not match recipient ${args.recipient}.`;
15694
+ assertParentMatchesRecipient(latestParentReply, args.recipient);
15478
15695
  return {
15479
- from: flags.from ?? exactParentReply.to_email,
15480
- parentReply: exactParentReply
15696
+ from: replyFrom,
15697
+ parentReply: latestParentReply
15481
15698
  };
15699
+ } catch (error) {
15700
+ progress?.fail(replyContextFailureMessage);
15701
+ throw error;
15482
15702
  }
15483
- const replyFrom = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15484
- progress?.start(`Finding latest inbound email from ${args.recipient}`);
15485
- const latestParentReply = await findLatestInboundFromRecipient({
15486
- apiClient,
15487
- authFailureContext,
15488
- from: replyFrom,
15489
- pageSize: flags["page-size"],
15490
- recipient: args.recipient
15491
- });
15492
- if (!latestParentReply) {
15493
- replyContextFailureMessage = "No prior inbound email found.";
15494
- throw cliError$6(`No prior inbound email from ${args.recipient} to ${replyFrom}. Start a new chat with \`primitive chat ${args.recipient} <message>\`, pass --from, or pass --reply-to-email-id <inbound-email-id>.`);
15495
- }
15496
- replyContextFailureMessage = `Inbound email ${latestParentReply.id} does not match recipient ${args.recipient}.`;
15497
- assertParentMatchesRecipient(latestParentReply, args.recipient);
15498
- return {
15499
- from: replyFrom,
15500
- parentReply: latestParentReply
15501
- };
15502
- } catch (error) {
15503
- progress?.fail(replyContextFailureMessage);
15504
- throw error;
15505
- }
15506
- })();
15507
- from = replyContext.from;
15508
- parentReply = replyContext.parentReply;
15509
- subject = derivedReplySubject(replyContext.parentReply);
15510
- } else {
15511
- from = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15512
- subject = flags.subject ?? deriveSubject(message);
15513
- }
15514
- const sentAtIso = (/* @__PURE__ */ new Date()).toISOString();
15515
- if (replyMode) progress?.update(`Sending reply to ${args.recipient}`);
15516
- else progress?.start(`Sending message to ${args.recipient}`);
15517
- const sendResult = parentReply !== void 0 ? await replyToEmail({
15518
- body: {
15519
- body_text: message,
15520
- from,
15521
- ...attachments !== void 0 ? { attachments } : {}
15522
- },
15523
- client: apiClient.client,
15524
- path: { id: parentReply.id },
15525
- responseStyle: "fields"
15526
- }) : await sendEmail({
15527
- body: {
15528
- from,
15529
- to: args.recipient,
15530
- subject,
15531
- body_text: message,
15532
- ...flags["in-reply-to"] !== void 0 ? { in_reply_to: flags["in-reply-to"] } : {},
15533
- ...attachments !== void 0 ? { attachments } : {}
15534
- },
15535
- client: apiClient.client,
15536
- responseStyle: "fields"
15537
- });
15538
- if (sendResult.error) {
15539
- progress?.fail(replyMode ? "Reply send failed." : "Message send failed.");
15540
- const errorPayload = extractErrorPayload(sendResult.error);
15541
- writeErrorWithHints(errorPayload);
15542
- surfaceUnauthorizedHint({
15543
- ...authFailureContext,
15544
- payload: errorPayload
15703
+ })();
15704
+ from = replyContext.from;
15705
+ parentReply = replyContext.parentReply;
15706
+ subject = derivedReplySubject(replyContext.parentReply);
15707
+ } else {
15708
+ from = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15709
+ subject = flags.subject ?? deriveSubject(message);
15710
+ }
15711
+ const sentAtIso = (/* @__PURE__ */ new Date()).toISOString();
15712
+ if (replyMode) progress?.update(`Sending reply to ${args.recipient}`);
15713
+ else progress?.start(`Sending message to ${args.recipient}`);
15714
+ const sendResult = parentReply !== void 0 ? await replyToEmail({
15715
+ body: {
15716
+ body_text: message,
15717
+ from,
15718
+ ...attachments !== void 0 ? { attachments } : {}
15719
+ },
15720
+ client: apiClient.client,
15721
+ path: { id: parentReply.id },
15722
+ responseStyle: "fields"
15723
+ }) : await sendEmail({
15724
+ body: {
15725
+ from,
15726
+ to: args.recipient,
15727
+ subject,
15728
+ body_text: message,
15729
+ ...flags["in-reply-to"] !== void 0 ? { in_reply_to: flags["in-reply-to"] } : {},
15730
+ ...attachments !== void 0 ? { attachments } : {}
15731
+ },
15732
+ client: apiClient.client,
15733
+ responseStyle: "fields"
15545
15734
  });
15546
- process.exitCode = 1;
15547
- return;
15548
- }
15549
- const sent = sendResult.data?.data;
15550
- if (!sent) {
15551
- progress?.fail("Send succeeded but the API returned no data.");
15552
- throw cliError$6("Send succeeded but the API returned no data.");
15553
- }
15554
- const replyAddress = sent.from || from;
15555
- progress?.update(`${replyMode ? "Reply" : "Message"} sent; waiting for reply from ${args.recipient}`, {
15556
- heartbeatMs: 15e3,
15557
- timeoutSeconds: flags.timeout
15558
- });
15559
- const baseContext = {
15560
- from: replyAddress,
15561
- json: flags.json,
15562
- parentReply,
15563
- quiet: flags.quiet,
15564
- recipient: args.recipient,
15565
- sent,
15566
- sentAtIso,
15567
- strictOnly: flags["strict-only"],
15568
- strictPhaseSeconds: flags["strict-phase-seconds"],
15569
- subject,
15570
- timeoutSeconds: flags.timeout
15571
- };
15572
- let replyResult;
15573
- try {
15574
- replyResult = await waitForReply({
15575
- apiClient,
15576
- authFailureContext,
15735
+ if (sendResult.error) {
15736
+ progress?.fail(replyMode ? "Reply send failed." : "Message send failed.");
15737
+ const errorPayload = extractErrorPayload(sendResult.error);
15738
+ writeErrorWithHints(errorPayload);
15739
+ surfaceUnauthorizedHint({
15740
+ ...authFailureContext,
15741
+ payload: errorPayload
15742
+ });
15743
+ process.exitCode = 1;
15744
+ return;
15745
+ }
15746
+ const sent = sendResult.data?.data;
15747
+ if (!sent) {
15748
+ progress?.fail("Send succeeded but the API returned no data.");
15749
+ throw cliError$6("Send succeeded but the API returned no data.");
15750
+ }
15751
+ const replyAddress = sent.from || from;
15752
+ const baseContext = {
15577
15753
  from: replyAddress,
15578
- interval: flags.interval,
15579
- notice: (message) => {
15580
- if (progress) {
15581
- progress.notice(message);
15582
- return;
15583
- }
15584
- process.stderr.write(`${message}\n`);
15585
- },
15586
- pageSize: flags["page-size"],
15754
+ json: flags.json,
15755
+ parentReply,
15756
+ quiet: flags.quiet,
15587
15757
  recipient: args.recipient,
15758
+ sent,
15588
15759
  sentAtIso,
15589
- sentId: sent.id,
15590
15760
  strictOnly: flags["strict-only"],
15591
15761
  strictPhaseSeconds: flags["strict-phase-seconds"],
15762
+ subject,
15763
+ timeoutSeconds: flags.timeout
15764
+ };
15765
+ if (sent.idempotent_replay) {
15766
+ progress?.update("Server returned idempotent_replay: looking up the existing reply");
15767
+ const replyId = await resolveIdempotentReplayReply(await fetchEmailSearchPage({
15768
+ apiClient,
15769
+ cursor: null,
15770
+ filters: { replyToSentEmailId: sent.id },
15771
+ pageSize: flags["page-size"]
15772
+ }));
15773
+ if (replyId) {
15774
+ const full = await getEmail({
15775
+ client: apiClient.client,
15776
+ path: { id: replyId },
15777
+ responseStyle: "fields"
15778
+ });
15779
+ if (full.error) {
15780
+ const payload = extractErrorPayload(full.error);
15781
+ writeErrorWithHints(payload);
15782
+ surfaceUnauthorizedHint({
15783
+ ...authFailureContext,
15784
+ payload
15785
+ });
15786
+ throw cliError$6(`Idempotent replay: existing reply found but fetching it failed (id=${replyId}).`);
15787
+ }
15788
+ const envelope = full.data;
15789
+ const detail = envelope?.data ?? envelope ?? null;
15790
+ if (!detail) throw cliError$6(`Idempotent replay: existing reply body could not be loaded (id=${replyId}).`);
15791
+ progress?.succeed(`Idempotent replay; surfacing existing reply from ${detail.from_email ?? args.recipient}`);
15792
+ let outputContext = {
15793
+ ...baseContext,
15794
+ matchStrategy: "strict",
15795
+ reply: detail
15796
+ };
15797
+ const localChatId = persistActiveChat({
15798
+ configDir: this.config.configDir,
15799
+ context: outputContext,
15800
+ preferredLocalId: flags["chat-local-id"],
15801
+ writeWarning: (message) => process.stderr.write(message)
15802
+ });
15803
+ if (localChatId !== null) outputContext = {
15804
+ ...outputContext,
15805
+ localChatId
15806
+ };
15807
+ if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
15808
+ else this.log(formatChatResponse(outputContext));
15809
+ return;
15810
+ }
15811
+ progress?.fail("Server deduplicated this send (idempotent_replay: true).");
15812
+ process.stderr.write(`${chatNoticeText(`The server detected this exact content was sent earlier and did not put a new message on the wire. The original send (sent.id=${sent.id}) has not received a reply yet. Vary the body — or change the parent (reply to a different inbound) — to retry with a fresh send.`)}\n`);
15813
+ process.exitCode = 1;
15814
+ return;
15815
+ }
15816
+ progress?.update(`${replyMode ? "Reply" : "Message"} sent; waiting for reply from ${args.recipient}`, {
15817
+ heartbeatMs: 15e3,
15592
15818
  timeoutSeconds: flags.timeout
15593
15819
  });
15594
- } catch (error) {
15595
- progress?.fail("Reply polling failed.");
15596
- process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15597
- throw error;
15598
- }
15599
- if (replyResult === null) {
15600
- const timeoutMessage = `Timed out after ${flags.timeout}s waiting for a reply from ${args.recipient}.`;
15601
- progress?.fail(timeoutMessage);
15602
- if (progress === null) process.stderr.write(`${timeoutMessage}\n`);
15603
- process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15604
- process.exitCode = 1;
15605
- return;
15820
+ let replyResult;
15821
+ try {
15822
+ replyResult = await waitForReply({
15823
+ apiClient,
15824
+ authFailureContext,
15825
+ from: replyAddress,
15826
+ interval: flags.interval,
15827
+ notice: (message) => {
15828
+ if (progress) {
15829
+ progress.notice(message);
15830
+ return;
15831
+ }
15832
+ process.stderr.write(`${message}\n`);
15833
+ },
15834
+ pageSize: flags["page-size"],
15835
+ recipient: args.recipient,
15836
+ sentAtIso,
15837
+ sentId: sent.id,
15838
+ strictOnly: flags["strict-only"],
15839
+ strictPhaseSeconds: flags["strict-phase-seconds"],
15840
+ timeoutSeconds: flags.timeout
15841
+ });
15842
+ } catch (error) {
15843
+ progress?.fail("Reply polling failed.");
15844
+ process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15845
+ throw error;
15846
+ }
15847
+ if (replyResult === null) {
15848
+ const timeoutMessage = `Timed out after ${flags.timeout}s waiting for a reply from ${args.recipient}.`;
15849
+ progress?.fail(timeoutMessage);
15850
+ if (progress === null) process.stderr.write(`${timeoutMessage}\n`);
15851
+ process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15852
+ process.exitCode = 1;
15853
+ return;
15854
+ }
15855
+ progress?.succeed(`Reply received from ${replyResult.reply.from_email}`);
15856
+ let outputContext = {
15857
+ ...baseContext,
15858
+ matchStrategy: replyResult.matchStrategy,
15859
+ reply: replyResult.reply
15860
+ };
15861
+ const localChatId = persistActiveChat({
15862
+ configDir: this.config.configDir,
15863
+ context: outputContext,
15864
+ preferredLocalId: flags["chat-local-id"],
15865
+ writeWarning: (message) => process.stderr.write(message)
15866
+ });
15867
+ if (localChatId !== null) outputContext = {
15868
+ ...outputContext,
15869
+ localChatId
15870
+ };
15871
+ if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
15872
+ else this.log(formatChatResponse(outputContext));
15873
+ } finally {
15874
+ releaseLock();
15606
15875
  }
15607
- progress?.succeed(`Reply received from ${replyResult.reply.from_email}`);
15608
- let outputContext = {
15609
- ...baseContext,
15610
- matchStrategy: replyResult.matchStrategy,
15611
- reply: replyResult.reply
15612
- };
15613
- const localChatId = persistActiveChat({
15614
- configDir: this.config.configDir,
15615
- context: outputContext,
15616
- preferredLocalId: flags["chat-local-id"],
15617
- writeWarning: (message) => process.stderr.write(message)
15618
- });
15619
- if (localChatId !== null) outputContext = {
15620
- ...outputContext,
15621
- localChatId
15622
- };
15623
- if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
15624
- else this.log(formatChatResponse(outputContext));
15625
15876
  });
15626
15877
  }
15627
15878
  };
@@ -15699,38 +15950,49 @@ var ChatReplyCommand = class ChatReplyCommand extends Command {
15699
15950
  const positionalLocalId = flags.id === void 0 && args.message !== void 0 ? parseLocalChatIdArg(args.idOrMessage) : void 0;
15700
15951
  if (flags.id === void 0 && args.message !== void 0 && positionalLocalId === null) throw cliError$6("When passing two positional arguments to `primitive chat reply`, the first must be a local chat id. Use `primitive chat reply '<message>'` for the active chat or `primitive chat reply --id <id> '<message>'` for a specific chat.");
15701
15952
  if (flags.id !== void 0 && args.message !== void 0) throw cliError$6("With --id, pass the reply body as a single positional argument or pipe it via stdin.");
15702
- const localId = flags.id ?? (typeof positionalLocalId === "number" ? positionalLocalId : void 0);
15703
- const state = localId === void 0 ? loadActiveChatState(this.config.configDir) : loadChatConversationByLocalId(this.config.configDir, localId);
15704
- if (!state) throw cliError$6(localId === void 0 ? "No open chat. Start one with `primitive chat <email> '<message>'`." : `No local chat ${localId}. Start one with \`primitive chat <email> '<message>'\` or omit --id to use the active chat.`);
15705
- const message = args.message !== void 0 ? args.message : args.idOrMessage !== void 0 && args.idOrMessage !== "" ? args.idOrMessage : await readStdinToString("No reply body provided. Pass the reply body as a positional argument or pipe it via stdin.");
15706
- if (!message.trim()) throw cliError$6("Reply body is empty.");
15707
- const argv = [
15708
- state.recipient,
15709
- "--reply",
15710
- message,
15711
- "--from",
15712
- state.from,
15713
- "--reply-to-email-id",
15714
- state.last_reply_email_id,
15715
- "--timeout",
15716
- String(flags.timeout ?? state.timeout_seconds),
15717
- "--strict-phase-seconds",
15718
- String(flags["strict-phase-seconds"] ?? state.strict_phase_seconds),
15719
- "--interval",
15720
- String(flags.interval ?? 2),
15721
- "--page-size",
15722
- String(flags["page-size"] ?? 50),
15723
- "--chat-local-id",
15724
- String(state.local_id)
15725
- ];
15726
- if (flags["api-key"] !== void 0) argv.push("--api-key", flags["api-key"]);
15727
- if (flags["api-base-url"] !== void 0) argv.push("--api-base-url", flags["api-base-url"]);
15728
- if (flags.json) argv.push("--json");
15729
- if (flags.quiet) argv.push("--quiet");
15730
- for (const attachment of flags.attachment ?? []) argv.push("--attachment", attachment);
15731
- if (state.strict_only || flags["strict-only"]) argv.push("--strict-only");
15732
- if (flags.time) argv.push("--time");
15733
- await ChatCommand.run(argv, { root: this.config.root });
15953
+ let release;
15954
+ try {
15955
+ release = acquireChatLock(this.config.configDir);
15956
+ } catch (err) {
15957
+ if (err instanceof ChatLockContentionError) throw cliError$6(err.message);
15958
+ throw err;
15959
+ }
15960
+ try {
15961
+ const localId = flags.id ?? (typeof positionalLocalId === "number" ? positionalLocalId : void 0);
15962
+ const state = localId === void 0 ? loadActiveChatState(this.config.configDir) : loadChatConversationByLocalId(this.config.configDir, localId);
15963
+ if (!state) throw cliError$6(localId === void 0 ? "No open chat. Start one with `primitive chat <email> '<message>'`." : `No local chat ${localId}. Start one with \`primitive chat <email> '<message>'\` or omit --id to use the active chat.`);
15964
+ const message = args.message !== void 0 ? args.message : args.idOrMessage !== void 0 && args.idOrMessage !== "" ? args.idOrMessage : await readStdinToString("No reply body provided. Pass the reply body as a positional argument or pipe it via stdin.");
15965
+ if (!message.trim()) throw cliError$6("Reply body is empty.");
15966
+ const argv = [
15967
+ state.recipient,
15968
+ "--reply",
15969
+ message,
15970
+ "--from",
15971
+ state.from,
15972
+ "--reply-to-email-id",
15973
+ state.last_reply_email_id,
15974
+ "--timeout",
15975
+ String(flags.timeout ?? state.timeout_seconds),
15976
+ "--strict-phase-seconds",
15977
+ String(flags["strict-phase-seconds"] ?? state.strict_phase_seconds),
15978
+ "--interval",
15979
+ String(flags.interval ?? 2),
15980
+ "--page-size",
15981
+ String(flags["page-size"] ?? 50),
15982
+ "--chat-local-id",
15983
+ String(state.local_id)
15984
+ ];
15985
+ if (flags["api-key"] !== void 0) argv.push("--api-key", flags["api-key"]);
15986
+ if (flags["api-base-url"] !== void 0) argv.push("--api-base-url", flags["api-base-url"]);
15987
+ if (flags.json) argv.push("--json");
15988
+ if (flags.quiet) argv.push("--quiet");
15989
+ for (const attachment of flags.attachment ?? []) argv.push("--attachment", attachment);
15990
+ if (state.strict_only || flags["strict-only"]) argv.push("--strict-only");
15991
+ if (flags.time) argv.push("--time");
15992
+ await ChatCommand.run(argv, { root: this.config.root });
15993
+ } finally {
15994
+ release();
15995
+ }
15734
15996
  }
15735
15997
  };
15736
15998
  async function waitForReply(params) {
@@ -17708,8 +17970,8 @@ const PRIMITIVE_TEAM_AUTHOR = {
17708
17970
  name: "Primitive Team",
17709
17971
  url: "https://primitive.dev"
17710
17972
  };
17711
- const SDK_VERSION_RANGE = "^0.38.0";
17712
- const CLI_VERSION_RANGE = "^0.38.0";
17973
+ const SDK_VERSION_RANGE = "^1.0.0";
17974
+ const CLI_VERSION_RANGE = "^1.0.0";
17713
17975
  const ESBUILD_VERSION_RANGE = "^0.27.0";
17714
17976
  function renderHandler() {
17715
17977
  return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.38.0",
3
+ "version": "1.0.0",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,