primcli 1.39.0 → 1.41.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.
@@ -1,10 +1,10 @@
1
1
  import { a as REPEAT_IDLE_MAX_MINUTES, d as parseRepeatTick, f as preparePresenceAliveEmail, g as isEmailReceivedEvent, i as REPEAT_EVERY_MAX_MINUTES, l as parseInteractionEnvelope$1, m as repeatStopCommand, o as REPEAT_MAX_SENDS_MAX, p as prepareSignalEmail, s as buildRepeatStopBody, u as parsePresenceEnvelope, v as parseWebhookEvent, y as parseFromHeader } from "../repeats-CoK0tpCK-Bx7nmyRi.js";
2
2
  import { a as agentProfilesDirectory, c as loadConnectedAgentProfile, d as requireDefaultLoginProfile, f as saveConnectedAgentProfile, i as agentProfileName, l as parseConnectedAgentProfile, n as AgentConnectionSetupError, o as connectedAgentIdentity, r as agentProfileDirectory, s as connectedApiBaseUrl, t as AGENT_PROFILE_ENV, u as parseOwnerMemberAddress } from "../connected-agent-profile-8tpOMdq3.js";
3
- import { $ as putAgentContactPolicy, A as getRepeatingSend, B as listEmails, C as getEmail, Ct as updateFunction, D as getInboxStatus, Dt as updateWakeSchedule, E as getFunctionTestRunTrace, Et as updateWakeAuthorization, F as listAgentNetworks, G as listRoutes, H as listFunctionLogs, I as listContacts, J as listWakeDispatches, K as listSentEmails, L as listDefaultNetworkAgents, M as getThread, N as getWakeSchedule, O as getMemory, Ot as verifyAgentClaim, P as listAgentContacts, Q as putAgentContact, R as listDefaultNetworkMembers, S as getDefaultNetworkAgent, St as updateDefaultNetworkMember, T as getFunctionRouting, Tt as updateRoute, U as listFunctions, V as listEndpoints, W as listRepeatingSends, X as pollCliLogin, Y as listWakeSchedules, Z as pullWebhookEvent, _ as getAccount, _t as startAgentSignup, a as createEndpoint, at as resendAgentSignupVerification, b as getConversation, bt as testFunction, c as createWakeAuthorization, ct as searchEmails, d as deleteContact, dt as sendEmail, et as putContact, f as deleteMemory, ft as setFunctionRoute, gt as startAgentClaim, h as deleteWakeSchedule, ht as simulateRoute, i as completeWebhookEvent, it as replyToEmail, j as getSentEmail, k as getOrgRoutingTopology, kt as verifyAgentSignup, l as createWakeSchedule, lt as searchMemories, m as deleteWakeAuthorization, mt as setMemory, n as checkDefaultNetworkContactAdmission, nt as removeDefaultNetworkMember, o as createFunction, ot as runWakeSchedule, p as deleteRoute, pt as setFunctionSecret, q as listWakeAuthorizations, r as cliLogout, rt as reorderRoutes, s as createRoute, t as addDefaultNetworkMember, tt as redeemCreditCode, u as deleteAgentContact, ut as semanticSearch, v as getAgentContactPolicy, vt as startCliLogin, w as getFunction, wt as updateRepeatingSend, x as getCreditBalance, xt as unsetFunctionRoute, y as getContact, yt as stopRepeatFromEmail, z as listDomains } from "../sdk.gen-CgKZwHs3.js";
3
+ import { $ as putAgentContactPolicy, A as getRepeatingSend, B as listEmails, C as getEmail, Ct as updateFunction, D as getInboxStatus, Dt as updateWakeSchedule, E as getFunctionTestRunTrace, Et as updateWakeAuthorization, F as listAgentNetworks, G as listRoutes, H as listFunctionLogs, I as listContacts, J as listWakeDispatches, K as listSentEmails, L as listDefaultNetworkAgents, M as getThread, N as getWakeSchedule, O as getMemory, Ot as verifyAgentClaim, P as listAgentContacts, Q as putAgentContact, R as listDefaultNetworkMembers, S as getDefaultNetworkAgent, St as updateDefaultNetworkMember, T as getFunctionRouting, Tt as updateRoute, U as listFunctions, V as listEndpoints, W as listRepeatingSends, X as pollCliLogin, Y as listWakeSchedules, Z as pullWebhookEvent, _ as getAccount, _t as startAgentSignup, a as createEndpoint, at as resendAgentSignupVerification, b as getConversation, bt as testFunction, c as createWakeAuthorization, ct as searchEmails, d as deleteContact, dt as sendEmail, et as putContact, f as deleteMemory, ft as setFunctionRoute, gt as startAgentClaim, h as deleteWakeSchedule, ht as simulateRoute, i as completeWebhookEvent, it as replyToEmail, j as getSentEmail, k as getOrgRoutingTopology, kt as verifyAgentSignup, l as createWakeSchedule, lt as searchMemories, m as deleteWakeAuthorization, mt as setMemory, n as checkDefaultNetworkContactAdmission, nt as removeDefaultNetworkMember, o as createFunction, ot as runWakeSchedule, p as deleteRoute, pt as setFunctionSecret, q as listWakeAuthorizations, r as cliLogout, rt as reorderRoutes, s as createRoute, t as addDefaultNetworkMember, tt as redeemCreditCode, u as deleteAgentContact, ut as semanticSearch, v as getAgentContactPolicy, vt as startCliLogin, w as getFunction, wt as updateRepeatingSend, x as getCreditBalance, xt as unsetFunctionRoute, y as getContact, yt as stopRepeatFromEmail, z as listDomains } from "../sdk.gen-I4mhUuUW.js";
4
4
  import { A as saveActiveChatState, C as resolveCliAuth, D as deleteChatState, E as chatStatePath, O as loadActiveChatState, T as saveSignupCredentials, _ as deleteCliCredentials, a as normalizeCliEnvironmentName, b as hasStoredCliLogin, c as resolveConfigEnvironment, g as credentialsPath, h as credentialsLockPath, i as loadCliConfig, j as PrimitiveApiClient, k as loadChatConversationByLocalId, 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 saveCliCredentials, x as loadCliCredentials, y as detectPrimitiveKeyEnvMisname } from "../cli-config-UOlrfcPz.js";
5
5
  import { S as resolveListenSubscription, _ as acquireListenLock, a as mailLockBusy, c as mailTime, d as readMailJson, f as removeMailFile, g as ListenStateError, h as writeMailJson, i as mailId, l as privateMailDirectory, m as withMailLock, o as mailObject, p as syncMailDirectory, r as mailAddress, s as mailString, u as privateMailPermissions, x as normalizeListenOrigin, y as listenIdentity } from "../shared-mail-files-CZqI1kj6.js";
6
- import { $ as extractErrorCode, A as reserveSharedMailSubscription, At as queryUsesAutomated, B as sentInThread, C as thrownErrorExitCode, Ct as AutomatedFilterUnsupportedError, D as ensureSharedMailSubscription, Dt as expectedAutomatedVerdicts, E as sharedMailScope, Et as ensureSearchReportsAutomated, F as describeWake, Ft as resolveCliApiRequestConfig, G as EventConnection, H as serverRelationship, I as displayAddress, J as pushFile, K as EventReceiverError, L as formatWakeContext, M as readConversationFollow, Mt as createAuthenticatedCliApiClient, N as otherParticipants, Nt as createCliApiClient, O as createSharedMailWaiter, Ot as hasAutomatedVerdict, P as bareAddress, Pt as refreshStoredCliCredentials, Q as createOperationCommand, R as latestOwnOutbound, S as successfulSendOutcome, St as AUTOMATED_VALUES, T as openSharedMailReceiver, Tt as automatedRejectedError, U as wakeRelationship, V as serverMuted, W as readSharedMailOwner, X as API_ERROR_CODES, Y as API_BASE_URL_FLAG_DESCRIPTION, Z as TIME_FLAG_DESCRIPTION, _ as reportSendCommandResult, _t as queryUsesAwaiting, a as assertValidIdempotencyKey, at as writeErrorWithHints, b as sentHistoryWindowStart, bt as AUTOMATED_FILTER_UNSUPPORTED_CODE, c as checkPriorReplies, ct as REPLY_STATE_UNSUPPORTED_CODE, d as formatDeletedEarlierSendNotice, dt as awaitingRejectedError, et as extractErrorPayload, f as formatPriorRepliesCheckSkipped, ft as ensureSearchReportsReplyState, g as priorRepliesThatWentOut, gt as isAwaitingRejectedError, h as formatSendRecordFailureSummary, ht as hasReplyState, i as apiResultHttpStatus, it as surfaceUnauthorizedHint, j as followEmailConversation, k as openSharedMailStore, kt as isAutomatedRejectedError, l as classifySendError, lt as ReplyStateUnsupportedError, m as formatSendFailureSummary, mt as formatRepliesCell, n as IDEMPOTENCY_KEY_FLAG_DESCRIPTION, nt as readTextFileFlag, o as buildFollowUpCommand, ot as AWAITING_FLAG_DESCRIPTION, p as formatPriorRepliesWarning, pt as formatAwaitingCell, q as pullFile, r as SEND_OUTCOME_HELP, rt as runWithTiming, s as buildThrownSendFailureEnvelope, st as AWAITING_VALUES, t as sendSignal, tt as formatErrorPayload, u as deriveSendIdempotencyKey, ut as assertAwaitingFilterRows, v as sendIdentityFields, vt as formatAlreadySentNotice, w as reconcileChatSend, wt as assertAutomatedVerdict, x as serializeErrorPayload, xt as AUTOMATED_FLAG_DESCRIPTION, y as sendOutcomeExitCode, yt as writeIdempotentReplayBannerIfReplay, z as readThreadContext } from "../signal-command-C8Ph0ylh.js";
7
- import { _ as NativeSessionDisconnectedError, a as readBundledConnectSkill, b as NotificationOutcomeUnknownError, c as openSessionNotifications, d as isRoutineNotificationContent, f as notificationPartReader, g as defaultSessionSocket, h as connectNativeSession, i as installedVersion, l as notificationReceiptPage, m as SESSION_UUID$1, n as detectAgentRuntime, o as notificationScope, p as readConversationStatusContent, r as installConnectSkill, s as notificationSenders, t as connectSkillTarget, u as NotificationRetryError, v as NativeSessionError, y as NativeSessionNotLoadedError } from "../connect-skill-DjKt8NC8.js";
6
+ import { $ as extractErrorCode, A as reserveSharedMailSubscription, At as queryUsesAutomated, B as sentInThread, C as thrownErrorExitCode, Ct as AutomatedFilterUnsupportedError, D as ensureSharedMailSubscription, Dt as expectedAutomatedVerdicts, E as sharedMailScope, Et as ensureSearchReportsAutomated, F as describeWake, Ft as resolveCliApiRequestConfig, G as EventConnection, H as serverRelationship, I as displayAddress, J as pushFile, K as EventReceiverError, L as formatWakeContext, M as readConversationFollow, Mt as createAuthenticatedCliApiClient, N as otherParticipants, Nt as createCliApiClient, O as createSharedMailWaiter, Ot as hasAutomatedVerdict, P as bareAddress, Pt as refreshStoredCliCredentials, Q as createOperationCommand, R as latestOwnOutbound, S as successfulSendOutcome, St as AUTOMATED_VALUES, T as openSharedMailReceiver, Tt as automatedRejectedError, U as wakeRelationship, V as serverMuted, W as readSharedMailOwner, X as API_ERROR_CODES, Y as API_BASE_URL_FLAG_DESCRIPTION, Z as TIME_FLAG_DESCRIPTION, _ as reportSendCommandResult, _t as queryUsesAwaiting, a as assertValidIdempotencyKey, at as writeErrorWithHints, b as sentHistoryWindowStart, bt as AUTOMATED_FILTER_UNSUPPORTED_CODE, c as checkPriorReplies, ct as REPLY_STATE_UNSUPPORTED_CODE, d as formatDeletedEarlierSendNotice, dt as awaitingRejectedError, et as extractErrorPayload, f as formatPriorRepliesCheckSkipped, ft as ensureSearchReportsReplyState, g as priorRepliesThatWentOut, gt as isAwaitingRejectedError, h as formatSendRecordFailureSummary, ht as hasReplyState, i as apiResultHttpStatus, it as surfaceUnauthorizedHint, j as followEmailConversation, k as openSharedMailStore, kt as isAutomatedRejectedError, l as classifySendError, lt as ReplyStateUnsupportedError, m as formatSendFailureSummary, mt as formatRepliesCell, n as IDEMPOTENCY_KEY_FLAG_DESCRIPTION, nt as readTextFileFlag, o as buildFollowUpCommand, ot as AWAITING_FLAG_DESCRIPTION, p as formatPriorRepliesWarning, pt as formatAwaitingCell, q as pullFile, r as SEND_OUTCOME_HELP, rt as runWithTiming, s as buildThrownSendFailureEnvelope, st as AWAITING_VALUES, t as sendSignal, tt as formatErrorPayload, u as deriveSendIdempotencyKey, ut as assertAwaitingFilterRows, v as sendIdentityFields, vt as formatAlreadySentNotice, w as reconcileChatSend, wt as assertAutomatedVerdict, x as serializeErrorPayload, xt as AUTOMATED_FLAG_DESCRIPTION, y as sendOutcomeExitCode, yt as writeIdempotentReplayBannerIfReplay, z as readThreadContext } from "../signal-command-BFPKbatO.js";
7
+ import { _ as NativeSessionDisconnectedError, a as readBundledConnectSkill, b as NotificationOutcomeUnknownError, c as openSessionNotifications, d as isRoutineNotificationContent, f as notificationPartReader, g as defaultSessionSocket, h as connectNativeSession, i as installedVersion, l as notificationReceiptPage, m as SESSION_UUID$1, n as detectAgentRuntime, o as notificationScope, p as readConversationStatusContent, r as installConnectSkill, s as notificationSenders, t as connectSkillTarget, u as NotificationRetryError, v as NativeSessionError, y as NativeSessionNotLoadedError } from "../connect-skill-DuanRI34.js";
8
8
  import { c as startBackgroundListen, i as backgroundListenToken, l as stopBackgroundListen, o as runBackgroundListen, r as backgroundListenStatus, u as verifyBackgroundListenTarget } from "../listen-background-Bsofqq_6.js";
9
9
  import { F as readBeforeDeadline, I as scopedChatSenderTrust, L as presenceDisposition, M as isConnectedChatCredential, N as isPlainChatReply, P as isScopedChatReply, R as readPresenceProjection, T as restoreAutoWorking, g as haltAutoWorking, h as dispatchAutoWorking, l as autoSignalEligible, m as dispatchAutoRead } from "../auto-signals-C8nMME8J.js";
10
10
  import { t as guardJsonOutputCommands } from "../json-output-BVRgl6jf.js";
@@ -1695,8 +1695,15 @@ const openapiDocument = {
1695
1695
  "get": {
1696
1696
  "operationId": "getConversation",
1697
1697
  "summary": "Get the conversation an email belongs to",
1698
- "description": "Returns the full conversation the given inbound email belongs\nto, as ordered, ready-to-prompt turns WITH bodies. It resolves\nthe thread from the email and returns every message oldest-first,\nso an agent that received an email can pass `messages` straight\nto a chat model in one call instead of walking `/threads/{id}`\nplus `/emails/{id}` and `/sent-emails/{id}` per message.\n\nEach message carries a `direction` (`inbound` | `outbound`) and a\nderived `role`: `inbound` -> `user`, `outbound` -> `assistant`\n(your own prior replies). The role mapping assumes the caller\nowns the outbound side, which is the agent-reply case this exists\nfor. If the email has no thread yet (a brand-new message), the\nconversation is just that one message as a single user turn.\n\nThe message list is capped; check `truncated` to detect when\nolder messages were omitted. Consecutive same-role turns are not\nmerged here; that normalization is model-specific and left to the\ncaller.\n",
1698
+ "description": "Returns the full conversation the given inbound email belongs\nto, as ordered, ready-to-prompt turns WITH bodies. It resolves\nthe thread from the email and returns every message oldest-first,\nso an agent that received an email can pass `messages` straight\nto a chat model in one call instead of walking `/threads/{id}`\nplus `/emails/{id}` and `/sent-emails/{id}` per message.\n\nEach message carries a `direction` (`inbound` | `outbound`) and a\nderived `role`: `inbound` -> `user`, `outbound` -> `assistant`\n(your own prior replies). The role mapping assumes the caller\nowns the outbound side, which is the agent-reply case this exists\nfor. If the email has no thread yet (a brand-new message), the\nconversation is just that one message as a single user turn.\n\nThe message list is capped; check `truncated` to detect when\nolder messages were omitted. Consecutive same-role turns are not\nmerged here; that normalization is model-specific and left to the\ncaller.\n\nPass `since` for an incremental read. `since=start` returns the\nfull conversation plus a `cursor`. Sending that `cursor` back as\n`since` returns only the messages created or changed after it,\noldest first, with the same per-message shape, and a new\n`cursor`. Without `since` the response carries no `cursor` and\nno per-message `status`.\n",
1699
1699
  "tags": ["Emails"],
1700
+ "parameters": [{
1701
+ "name": "since",
1702
+ "in": "query",
1703
+ "required": false,
1704
+ "schema": { "type": "string" },
1705
+ "description": "Incremental read position. Either the literal `start` or a\n`cursor` returned by a previous read of this endpoint. Any\nother value returns 400 `validation_error`.\n\nWith `start`, the response holds every message the read\nwithout `since` would return, plus `cursor`. With a cursor,\n`messages` holds only the messages created or changed after\nit, oldest first. A change includes an inbound message\nbecoming visible or its body being filled, withheld or\ndiscarded, and a delivery status change on an outbound\nmessage. `thread_id`, `subject`, `message_count` and\n`truncated` always describe the whole conversation, not the\nreturned messages.\n\nThe cursor is an opaque string. Send it back verbatim and do\nnot parse it or build one; its format can change. A message\ncan be returned again by a later read (for example while\nolder writes are still committing), so upsert messages by\n`id`. Deleted messages are not reported; a periodic\n`since=start` read (or a read without `since`) reconciles.\n"
1706
+ }],
1700
1707
  "responses": {
1701
1708
  "200": {
1702
1709
  "description": "Conversation",
@@ -12247,6 +12254,10 @@ const openapiDocument = {
12247
12254
  "messages": {
12248
12255
  "type": "array",
12249
12256
  "items": { "$ref": "#/components/schemas/ConversationMessage" }
12257
+ },
12258
+ "cursor": {
12259
+ "type": "string",
12260
+ "description": "Present only on a `since` read: the position to send as\n`since` on the next read. An opaque string: send it back\nverbatim and do not parse it or build one.\n"
12250
12261
  }
12251
12262
  },
12252
12263
  "required": [
@@ -12258,7 +12269,7 @@ const openapiDocument = {
12258
12269
  },
12259
12270
  "ConversationMessage": {
12260
12271
  "type": "object",
12261
- "description": "One message in the conversation, with its body and a chat role.",
12272
+ "description": "One message in the conversation, with its body and a chat role.\n`status` is the delivery status of an outbound message, as on\n`/sent-emails/{id}`, and is present only on a `since` read and\nonly on outbound messages.\n",
12262
12273
  "properties": {
12263
12274
  "role": {
12264
12275
  "type": "string",
@@ -12287,397 +12298,398 @@ const openapiDocument = {
12287
12298
  "format": "date-time",
12288
12299
  "description": "received_at for inbound, created_at for outbound."
12289
12300
  },
12290
- "presence_control": { "$ref": "#/components/schemas/PresenceControl" },
12291
- "repeat": {
12292
- "type": ["object", "null"],
12293
- "description": "Set when Primitive sent this message as part of a repeating send. Resolved from Primitive's own records, never from the message content. Null on every other message and on servers that predate repeating sends.",
12294
- "properties": {
12295
- "repeat_id": {
12296
- "type": "string",
12297
- "format": "uuid"
12298
- },
12299
- "sequence": {
12300
- "type": "integer",
12301
- "minimum": 1
12302
- }
12303
- },
12304
- "required": ["repeat_id", "sequence"]
12305
- },
12306
- "sender_member": {
12307
- "type": ["object", "null"],
12308
- "description": "Verified human authorship, projected only within the member organization. Historical attribution is not current sending or owner authority.",
12309
- "properties": {
12310
- "address": {
12311
- "type": "string",
12312
- "format": "email"
12313
- },
12314
- "user_id": { "type": "string" },
12315
- "name": { "type": ["string", "null"] }
12316
- },
12317
- "required": [
12318
- "address",
12319
- "user_id",
12320
- "name"
12321
- ]
12322
- }
12323
- },
12324
- "required": [
12325
- "role",
12326
- "direction",
12327
- "id",
12328
- "text"
12329
- ]
12330
- },
12331
- "SendMailAttachment": {
12332
- "type": "object",
12333
- "additionalProperties": false,
12334
- "properties": {
12335
- "filename": {
12336
- "type": "string",
12337
- "minLength": 1,
12338
- "maxLength": 255,
12339
- "description": "Attachment filename. Control characters are rejected."
12340
- },
12341
- "content_type": {
12342
- "type": "string",
12343
- "minLength": 1,
12344
- "maxLength": 255,
12345
- "description": "Optional MIME content type. Control characters are rejected."
12346
- },
12347
- "content_base64": {
12348
- "type": "string",
12349
- "minLength": 1,
12350
- "maxLength": 44040192,
12351
- "description": "Base64-encoded attachment bytes."
12352
- }
12353
- },
12354
- "required": ["filename", "content_base64"]
12355
- },
12356
- "SendMailPayloadRef": {
12357
- "type": "object",
12358
- "additionalProperties": false,
12359
- "description": "A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes - the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held CEK the server never sees), then reference it here.",
12360
- "properties": {
12361
- "root": {
12362
- "type": "string",
12363
- "pattern": "^[0-9a-f]{64}$",
12364
- "description": "The 64-char lowercase-hex Merkle root of a finalized payloads object."
12365
- },
12366
- "filename": {
12367
- "type": "string",
12368
- "minLength": 1,
12369
- "maxLength": 255,
12370
- "description": "Attachment filename presented to the recipient."
12371
- },
12372
- "content_type": {
12373
- "type": "string",
12374
- "minLength": 1,
12375
- "maxLength": 255,
12376
- "description": "Optional MIME content type."
12377
- },
12378
- "cek": {
12379
- "type": "string",
12380
- "pattern": "^[A-Za-z0-9_-]{1,128}$",
12381
- "description": "Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the email; the object store only ever holds ciphertext."
12382
- }
12383
- },
12384
- "required": [
12385
- "root",
12386
- "filename",
12387
- "cek"
12388
- ]
12389
- },
12390
- "SendMailInput": {
12391
- "type": "object",
12392
- "additionalProperties": false,
12393
- "properties": {
12394
- "repeat": { "$ref": "#/components/schemas/RepeatInput" },
12395
- "from": {
12396
- "type": "string",
12397
- "minLength": 3,
12398
- "maxLength": 998,
12399
- "description": "RFC 5322 From header. The sender domain must be a verified outbound domain for your organization."
12400
- },
12401
- "to": {
12402
- "type": "string",
12403
- "minLength": 3,
12404
- "maxLength": 320,
12405
- "description": "Recipient address. Recipient eligibility depends on your account's outbound entitlements."
12406
- },
12407
- "cc": {
12408
- "description": "Carbon-copy recipients. Either a single address or an array of addresses. Each entry must be a single address; use the array form for multiple recipients. Cc recipients are visible to everyone who receives the message. The combined number of to, cc, and bcc recipients must not exceed 100.",
12409
- "oneOf": [{
12410
- "type": "string",
12411
- "minLength": 1
12412
- }, {
12413
- "type": "array",
12414
- "minItems": 1,
12415
- "maxItems": 100,
12416
- "items": {
12417
- "type": "string",
12418
- "minLength": 1
12419
- }
12420
- }]
12421
- },
12422
- "bcc": {
12423
- "description": "Blind-carbon-copy recipients. Either a single address or an array of addresses. Each entry must be a single address; use the array form for multiple recipients. Bcc recipients receive the message but are not disclosed to the other recipients: no Bcc header is sent. The combined number of to, cc, and bcc recipients must not exceed 100.",
12424
- "oneOf": [{
12425
- "type": "string",
12426
- "minLength": 1
12427
- }, {
12428
- "type": "array",
12429
- "minItems": 1,
12430
- "maxItems": 100,
12431
- "items": {
12432
- "type": "string",
12433
- "minLength": 1
12434
- }
12435
- }]
12436
- },
12437
- "subject": {
12438
- "type": "string",
12439
- "minLength": 1,
12440
- "maxLength": 998,
12441
- "description": "Subject line for the outbound message"
12442
- },
12443
- "body_text": {
12444
- "type": "string",
12445
- "description": "Plain-text message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes."
12446
- },
12447
- "body_html": {
12448
- "type": "string",
12449
- "description": "HTML message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes."
12450
- },
12451
- "in_reply_to": {
12452
- "type": "string",
12453
- "minLength": 1,
12454
- "maxLength": 998,
12455
- "pattern": "^[^\\x00-\\x1F\\x7F]+$",
12456
- "description": "Message-ID of the direct parent email when sending a threaded reply."
12457
- },
12458
- "references": {
12459
- "type": "array",
12460
- "maxItems": 100,
12461
- "description": "Full ordered message-id chain for the thread.",
12462
- "items": {
12463
- "type": "string",
12464
- "minLength": 1,
12465
- "maxLength": 998,
12466
- "pattern": "^[^\\x00-\\x1F\\x7F]+$"
12467
- }
12468
- },
12469
- "attachments": {
12470
- "type": "array",
12471
- "maxItems": 100,
12472
- "description": "Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280.",
12473
- "items": { "$ref": "#/components/schemas/SendMailAttachment" }
12474
- },
12475
- "payload_attachments": {
12476
- "type": "array",
12477
- "maxItems": 1,
12478
- "description": "Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes - the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one.",
12479
- "items": { "$ref": "#/components/schemas/SendMailPayloadRef" }
12480
- },
12481
- "wait": {
12482
- "type": "boolean",
12483
- "description": "When true, wait for the first downstream SMTP delivery outcome before returning."
12484
- },
12485
- "wait_timeout_ms": {
12486
- "type": "integer",
12487
- "minimum": 1e3,
12488
- "maximum": 3e4,
12489
- "description": "Maximum time to wait for a delivery outcome when wait is true. Defaults to 30000."
12490
- },
12491
- "scheduled_at": {
12492
- "type": "string",
12493
- "format": "date-time",
12494
- "description": "Optional future execution time (ISO 8601). When set, the\nsend is recorded with status `scheduled` and executed at\nthe requested time instead of immediately. Must be in the\nfuture and at most 30 days out. Incompatible with `wait`\n(a scheduled send resolves after this request completes)\nand with `attachments` / `payload_attachments` (not yet\nsupported on scheduled sends). Reschedule via PATCH\n/sent-emails/{id}; cancel via /sent-emails/{id}/cancel.\n"
12495
- }
12496
- },
12497
- "required": [
12498
- "from",
12499
- "to",
12500
- "subject"
12501
- ]
12502
- },
12503
- "EmailStatus": {
12504
- "type": "string",
12505
- "description": "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n",
12506
- "enum": [
12507
- "pending",
12508
- "accepted",
12509
- "completed",
12510
- "rejected"
12511
- ]
12512
- },
12513
- "EmailWebhookStatus": {
12514
- "type": ["string", "null"],
12515
- "description": "Webhook-delivery state for an inbound email. Tracks a\nSEPARATE lifecycle from the email's `status` field; the\nsame row carries both. Possible values:\n\n - `pending`: ingestion is past `pending` (the email itself\n is `accepted`) but the webhook fan-out has not yet\n started for this row.\n - `in_flight`: at least one delivery attempt is in flight.\n - `fired`: terminal success. Every active endpoint\n acknowledged the delivery (or accepted it after retries).\n - `failed`: terminal partial-failure. At least one endpoint\n exhausted its retry budget; some endpoints may still\n have succeeded.\n - `exhausted`: terminal failure. Every endpoint exhausted\n its retry budget without success.\n - `null`: no endpoints configured, so no webhook lifecycle\n applies.\n\nNote that the value `pending` here does NOT mean the email\nis `pending`; it means the email is past ingestion but\nwebhook delivery has not yet begun. Two overlapping uses\nof the word `pending` for distinct lifecycle phases.\n",
12516
- "enum": [
12517
- "pending",
12518
- "in_flight",
12519
- "fired",
12520
- "failed",
12521
- "exhausted",
12522
- null
12523
- ]
12524
- },
12525
- "SentEmailStatus": {
12526
- "type": "string",
12527
- "description": "Lifecycle status of a sent_emails row. Possible values:\n\n - `queued`: pre-call INSERT; the outbound agent has not\n yet replied.\n - `submitted_to_agent`: agent accepted; `queue_id` is set.\n - `agent_failed`: agent rejected; `error_code` and\n `error_message` carry the reason.\n - `gate_denied`: a recipient-scope gate denied the send;\n the agent was never called. The `gates` array carries\n the denial detail. /send-mail returns 403 in this case\n so callers see the denial synchronously; /sent-emails\n additionally records the row for historical lookup,\n which is when this status appears in a listing.\n - `unknown`: terminal indeterminate; the on-box log\n poller couldn't classify the receiver's response.\n - `delivered` / `bounced` / `deferred` / `wait_timeout`:\n terminal delivery outcomes (see DeliveryStatus).\n - `scheduled`: created with a future `scheduled_at` and\n not yet executed; `scheduled_at` carries the pending\n execution time. Reschedulable via PATCH\n /sent-emails/{id} and cancelable via\n /sent-emails/{id}/cancel while in this status.\n - `canceled`: terminal; a scheduled send canceled before\n execution. `canceled_at` carries the cancellation time\n and nothing was dispatched.\n",
12528
- "enum": [
12529
- "queued",
12530
- "submitted_to_agent",
12531
- "agent_failed",
12532
- "gate_denied",
12533
- "unknown",
12534
- "delivered",
12535
- "bounced",
12536
- "deferred",
12537
- "wait_timeout",
12538
- "scheduled",
12539
- "canceled"
12540
- ]
12541
- },
12542
- "DeliveryStatus": {
12543
- "type": "string",
12544
- "description": "Narrower enum covering only the four terminal delivery\noutcomes returned to a synchronous `wait: true` send.\n\nOn the SendMailResult shape, `delivery_status` is always\nequal to `status` whenever both are present (i.e. on\nterminal-state replays and live wait=true responses).\nThe two fields exist so callers that want to type-narrow\non \"this is a delivery outcome\" can pattern-match against\nthe four-value enum without handling the broader\nSentEmailStatus value set (which also covers `queued`,\n`submitted_to_agent`, `agent_failed`, `gate_denied`,\n`unknown`).\n\nOn async-mode and pre-terminal responses, `delivery_status`\nis absent and only `status` is populated. Use `status` if\nyou want a single field that's always present.\n",
12545
- "enum": [
12546
- "delivered",
12547
- "bounced",
12548
- "deferred",
12549
- "wait_timeout"
12550
- ]
12551
- },
12552
- "SentEmailSummary": {
12553
- "type": "object",
12554
- "description": "List-row projection of a sent-email record. Drops\n`body_text` and `body_html` to keep paginated responses\nsmall; fetch /sent-emails/{id} for the full record with\nbodies.\n",
12555
- "properties": {
12556
- "id": {
12557
- "type": "string",
12558
- "format": "uuid"
12559
- },
12560
12301
  "status": { "$ref": "#/components/schemas/SentEmailStatus" },
12561
- "status_changed_at": {
12562
- "type": "string",
12563
- "format": "date-time",
12564
- "description": "Timestamp of the most recent status transition.\nPolling clients should treat `status='queued'` AND\n`status_changed_at` older than 5 minutes as\n\"stuck-queued\" (the post-tx UPDATE failed and the\nactual delivery state is recoverable from on-box logs\nvia `queue_id` when populated, or `request_id`).\n"
12565
- },
12566
- "created_at": {
12567
- "type": "string",
12568
- "format": "date-time"
12569
- },
12570
- "updated_at": {
12571
- "type": "string",
12572
- "format": "date-time"
12573
- },
12574
- "client_idempotency_key": {
12575
- "type": ["string", "null"],
12576
- "description": "Effective idempotency key used for this send. If the\ncaller passed the `Idempotency-Key` header, this is\nthat value; otherwise it's a server-derived hash of\nthe canonical request payload.\n"
12577
- },
12578
- "content_hash": {
12579
- "type": "string",
12580
- "description": "Stable hash of the canonical send payload."
12581
- },
12582
- "from_header": {
12583
- "type": "string",
12584
- "description": "Raw `From:` header as sent on the wire, including any\ndisplay name (e.g. `\"Acme Support\" <agent@acme.test>`).\n"
12585
- },
12586
- "from_address": {
12587
- "type": "string",
12588
- "description": "Bare email address parsed from `from_header`."
12589
- },
12590
- "to_header": {
12591
- "type": "string",
12592
- "description": "Raw `To:` header as sent on the wire, including any\ndisplay name.\n"
12593
- },
12594
- "to_address": {
12595
- "type": "string",
12596
- "description": "Bare email address parsed from `to_header`."
12597
- },
12598
- "subject": { "type": "string" },
12599
- "body_size_bytes": {
12600
- "type": "integer",
12601
- "description": "Total UTF-8 byte length of `body_text` + `body_html`.\nSurfaced on the list endpoint so callers can see \"this\nrow has a 4MB body\" without fetching it.\n"
12602
- },
12603
- "content_discarded_at": {
12604
- "type": ["string", "null"],
12605
- "format": "date-time",
12606
- "description": "Timestamp at which the bodies were discarded by an\nentitlement-driven retention policy. Null when bodies\nare still present. The detail endpoint returns\nnull-valued `body_text`/`body_html` for discarded rows.\n"
12607
- },
12608
- "message_id": {
12609
- "type": ["string", "null"],
12610
- "description": "Wire-level Message-ID assigned to the outbound message\n(RFC 5322). Null on rows that never reached signing\n(queued, gate_denied, agent_failed before signing).\n"
12611
- },
12612
- "in_reply_to": {
12613
- "type": ["string", "null"],
12614
- "description": "Wire-level In-Reply-To header value, when this send\nwas a reply.\n"
12615
- },
12616
- "email_references": {
12617
- "type": ["string", "null"],
12618
- "description": "Wire-level References header value, when this send\nwas a reply.\n"
12619
- },
12620
- "in_reply_to_email_id": {
12621
- "type": ["string", "null"],
12622
- "format": "uuid",
12623
- "description": "Reference to the inbound `emails.id` that this send\nreplied to, when known. Populated when the caller used\n/emails/{id}/reply or when /send-mail's `in_reply_to`\nmatched a stored inbound message_id in the same org.\n"
12624
- },
12625
- "thread_id": {
12626
- "type": ["string", "null"],
12627
- "format": "uuid",
12628
- "description": "Conversation thread this send belongs to. A reply inherits\nthe thread of the inbound it answers; a fresh send starts a\nnew thread. Fetch `/threads/{thread_id}` for the full\nordered thread (inbound + outbound interleaved). NULL on\ngate-denied sends and on sends created before threading was\nenabled.\n"
12629
- },
12630
- "queue_id": {
12631
- "type": ["string", "null"],
12632
- "description": "Message identifier assigned by Primitive's outbound\nrelay once the agent accepts the message. Null on\nqueued, gate_denied, and agent_failed rows.\n"
12633
- },
12634
- "smtp_response_code": {
12635
- "type": ["integer", "null"],
12636
- "description": "Receiver's 3-digit SMTP code (e.g. 250, 550, 451).\nPopulated on terminal delivery statuses; may be null\non a deferred where the agent never got an SMTP-level\nresponse (TCP refused, DNS failed, TLS handshake\nfailed). `smtp_response_text` still carries Postfix's\ndescriptive text in those cases.\n"
12637
- },
12638
- "smtp_response_text": {
12639
- "type": ["string", "null"],
12640
- "description": "Free-form text portion of the receiver's SMTP\nresponse. The most useful debugging signal on a\n`bounced` or `deferred` row.\n"
12641
- },
12642
- "smtp_enhanced_status_code": {
12643
- "type": ["string", "null"],
12644
- "description": "RFC 3463 enhanced status code (e.g. `5.1.1` for \"Bad\ndestination mailbox address\"). Distinct from\n`smtp_response_code`: the basic 3-digit code is coarse\n(550 = \"permanent failure\"), the enhanced code is\nfiner-grained.\n"
12645
- },
12646
- "dkim_selector": {
12647
- "type": ["string", "null"],
12648
- "description": "DKIM selector used to sign the outbound message.\nPublic DNS data; useful for diagnosing why a downstream\nverifier rejected the signature.\n"
12649
- },
12650
- "dkim_domain": {
12651
- "type": ["string", "null"],
12652
- "description": "DKIM signing domain."
12653
- },
12654
- "error_code": {
12655
- "type": ["string", "null"],
12656
- "description": "Stable public error code on `agent_failed` rows. The\nagent's internal codes are remapped to a stable public\ntaxonomy (see `publicAgentError` in the server) so this\nfield is safe to branch on across agent versions.\n"
12657
- },
12658
- "error_message": {
12659
- "type": ["string", "null"],
12660
- "description": "Free-form error message accompanying `error_code`."
12661
- },
12662
- "gates": {
12663
- "type": ["array", "null"],
12664
- "items": { "$ref": "#/components/schemas/GateDenial" },
12665
- "description": "Gate-denial detail on `gate_denied` rows. Mirrors the\nsynchronous /send-mail 403 contract so a caller's\nGateDenial handler is the same across live denies and\nhistorical lookups. Null on every other status.\n"
12666
- },
12667
- "request_id": {
12668
- "type": ["string", "null"],
12669
- "description": "Server-issued request identifier from the original\n/send-mail call. Surfaced as the `X-Request-Id`\nresponse header on the live send and recorded here\nfor support escalation.\n"
12670
- },
12671
- "scheduled_at": {
12672
- "type": ["string", "null"],
12673
- "format": "date-time",
12674
- "description": "Requested execution time for a scheduled send. Kept\nafter execution as the historical schedule; null on\nordinary immediate sends.\n"
12675
- },
12676
- "canceled_at": {
12677
- "type": ["string", "null"],
12678
- "format": "date-time",
12679
- "description": "When a scheduled send was canceled. Null unless the row\nreached the `canceled` status.\n"
12680
- },
12302
+ "presence_control": { "$ref": "#/components/schemas/PresenceControl" },
12303
+ "repeat": {
12304
+ "type": ["object", "null"],
12305
+ "description": "Set when Primitive sent this message as part of a repeating send. Resolved from Primitive's own records, never from the message content. Null on every other message and on servers that predate repeating sends.",
12306
+ "properties": {
12307
+ "repeat_id": {
12308
+ "type": "string",
12309
+ "format": "uuid"
12310
+ },
12311
+ "sequence": {
12312
+ "type": "integer",
12313
+ "minimum": 1
12314
+ }
12315
+ },
12316
+ "required": ["repeat_id", "sequence"]
12317
+ },
12318
+ "sender_member": {
12319
+ "type": ["object", "null"],
12320
+ "description": "Verified human authorship, projected only within the member organization. Historical attribution is not current sending or owner authority.",
12321
+ "properties": {
12322
+ "address": {
12323
+ "type": "string",
12324
+ "format": "email"
12325
+ },
12326
+ "user_id": { "type": "string" },
12327
+ "name": { "type": ["string", "null"] }
12328
+ },
12329
+ "required": [
12330
+ "address",
12331
+ "user_id",
12332
+ "name"
12333
+ ]
12334
+ }
12335
+ },
12336
+ "required": [
12337
+ "role",
12338
+ "direction",
12339
+ "id",
12340
+ "text"
12341
+ ]
12342
+ },
12343
+ "SendMailAttachment": {
12344
+ "type": "object",
12345
+ "additionalProperties": false,
12346
+ "properties": {
12347
+ "filename": {
12348
+ "type": "string",
12349
+ "minLength": 1,
12350
+ "maxLength": 255,
12351
+ "description": "Attachment filename. Control characters are rejected."
12352
+ },
12353
+ "content_type": {
12354
+ "type": "string",
12355
+ "minLength": 1,
12356
+ "maxLength": 255,
12357
+ "description": "Optional MIME content type. Control characters are rejected."
12358
+ },
12359
+ "content_base64": {
12360
+ "type": "string",
12361
+ "minLength": 1,
12362
+ "maxLength": 44040192,
12363
+ "description": "Base64-encoded attachment bytes."
12364
+ }
12365
+ },
12366
+ "required": ["filename", "content_base64"]
12367
+ },
12368
+ "SendMailPayloadRef": {
12369
+ "type": "object",
12370
+ "additionalProperties": false,
12371
+ "description": "A reference to an already-uploaded Primitive Payloads object, delivered as an attachment without inlining the bytes - the way to send an attachment larger than the inline cap. Upload the object via /v1/payloads (with a client-held CEK the server never sees), then reference it here.",
12372
+ "properties": {
12373
+ "root": {
12374
+ "type": "string",
12375
+ "pattern": "^[0-9a-f]{64}$",
12376
+ "description": "The 64-char lowercase-hex Merkle root of a finalized payloads object."
12377
+ },
12378
+ "filename": {
12379
+ "type": "string",
12380
+ "minLength": 1,
12381
+ "maxLength": 255,
12382
+ "description": "Attachment filename presented to the recipient."
12383
+ },
12384
+ "content_type": {
12385
+ "type": "string",
12386
+ "minLength": 1,
12387
+ "maxLength": 255,
12388
+ "description": "Optional MIME content type."
12389
+ },
12390
+ "cek": {
12391
+ "type": "string",
12392
+ "pattern": "^[A-Za-z0-9_-]{1,128}$",
12393
+ "description": "Base64url-encoded (unpadded) content-encryption key the recipient uses to decrypt. Travels with the email; the object store only ever holds ciphertext."
12394
+ }
12395
+ },
12396
+ "required": [
12397
+ "root",
12398
+ "filename",
12399
+ "cek"
12400
+ ]
12401
+ },
12402
+ "SendMailInput": {
12403
+ "type": "object",
12404
+ "additionalProperties": false,
12405
+ "properties": {
12406
+ "repeat": { "$ref": "#/components/schemas/RepeatInput" },
12407
+ "from": {
12408
+ "type": "string",
12409
+ "minLength": 3,
12410
+ "maxLength": 998,
12411
+ "description": "RFC 5322 From header. The sender domain must be a verified outbound domain for your organization."
12412
+ },
12413
+ "to": {
12414
+ "type": "string",
12415
+ "minLength": 3,
12416
+ "maxLength": 320,
12417
+ "description": "Recipient address. Recipient eligibility depends on your account's outbound entitlements."
12418
+ },
12419
+ "cc": {
12420
+ "description": "Carbon-copy recipients. Either a single address or an array of addresses. Each entry must be a single address; use the array form for multiple recipients. Cc recipients are visible to everyone who receives the message. The combined number of to, cc, and bcc recipients must not exceed 100.",
12421
+ "oneOf": [{
12422
+ "type": "string",
12423
+ "minLength": 1
12424
+ }, {
12425
+ "type": "array",
12426
+ "minItems": 1,
12427
+ "maxItems": 100,
12428
+ "items": {
12429
+ "type": "string",
12430
+ "minLength": 1
12431
+ }
12432
+ }]
12433
+ },
12434
+ "bcc": {
12435
+ "description": "Blind-carbon-copy recipients. Either a single address or an array of addresses. Each entry must be a single address; use the array form for multiple recipients. Bcc recipients receive the message but are not disclosed to the other recipients: no Bcc header is sent. The combined number of to, cc, and bcc recipients must not exceed 100.",
12436
+ "oneOf": [{
12437
+ "type": "string",
12438
+ "minLength": 1
12439
+ }, {
12440
+ "type": "array",
12441
+ "minItems": 1,
12442
+ "maxItems": 100,
12443
+ "items": {
12444
+ "type": "string",
12445
+ "minLength": 1
12446
+ }
12447
+ }]
12448
+ },
12449
+ "subject": {
12450
+ "type": "string",
12451
+ "minLength": 1,
12452
+ "maxLength": 998,
12453
+ "description": "Subject line for the outbound message"
12454
+ },
12455
+ "body_text": {
12456
+ "type": "string",
12457
+ "description": "Plain-text message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes."
12458
+ },
12459
+ "body_html": {
12460
+ "type": "string",
12461
+ "description": "HTML message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes."
12462
+ },
12463
+ "in_reply_to": {
12464
+ "type": "string",
12465
+ "minLength": 1,
12466
+ "maxLength": 998,
12467
+ "pattern": "^[^\\x00-\\x1F\\x7F]+$",
12468
+ "description": "Message-ID of the direct parent email when sending a threaded reply."
12469
+ },
12470
+ "references": {
12471
+ "type": "array",
12472
+ "maxItems": 100,
12473
+ "description": "Full ordered message-id chain for the thread.",
12474
+ "items": {
12475
+ "type": "string",
12476
+ "minLength": 1,
12477
+ "maxLength": 998,
12478
+ "pattern": "^[^\\x00-\\x1F\\x7F]+$"
12479
+ }
12480
+ },
12481
+ "attachments": {
12482
+ "type": "array",
12483
+ "maxItems": 100,
12484
+ "description": "Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280.",
12485
+ "items": { "$ref": "#/components/schemas/SendMailAttachment" }
12486
+ },
12487
+ "payload_attachments": {
12488
+ "type": "array",
12489
+ "maxItems": 1,
12490
+ "description": "Deliver an already-uploaded Primitive Payloads object as an attachment by reference, without inlining the bytes - the way to send attachments larger than the inline cap. Upload the object via /v1/payloads (client-held CEK), then reference it here. v1 supports at most one.",
12491
+ "items": { "$ref": "#/components/schemas/SendMailPayloadRef" }
12492
+ },
12493
+ "wait": {
12494
+ "type": "boolean",
12495
+ "description": "When true, wait for the first downstream SMTP delivery outcome before returning."
12496
+ },
12497
+ "wait_timeout_ms": {
12498
+ "type": "integer",
12499
+ "minimum": 1e3,
12500
+ "maximum": 3e4,
12501
+ "description": "Maximum time to wait for a delivery outcome when wait is true. Defaults to 30000."
12502
+ },
12503
+ "scheduled_at": {
12504
+ "type": "string",
12505
+ "format": "date-time",
12506
+ "description": "Optional future execution time (ISO 8601). When set, the\nsend is recorded with status `scheduled` and executed at\nthe requested time instead of immediately. Must be in the\nfuture and at most 30 days out. Incompatible with `wait`\n(a scheduled send resolves after this request completes)\nand with `attachments` / `payload_attachments` (not yet\nsupported on scheduled sends). Reschedule via PATCH\n/sent-emails/{id}; cancel via /sent-emails/{id}/cancel.\n"
12507
+ }
12508
+ },
12509
+ "required": [
12510
+ "from",
12511
+ "to",
12512
+ "subject"
12513
+ ]
12514
+ },
12515
+ "EmailStatus": {
12516
+ "type": "string",
12517
+ "description": "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n",
12518
+ "enum": [
12519
+ "pending",
12520
+ "accepted",
12521
+ "completed",
12522
+ "rejected"
12523
+ ]
12524
+ },
12525
+ "EmailWebhookStatus": {
12526
+ "type": ["string", "null"],
12527
+ "description": "Webhook-delivery state for an inbound email. Tracks a\nSEPARATE lifecycle from the email's `status` field; the\nsame row carries both. Possible values:\n\n - `pending`: ingestion is past `pending` (the email itself\n is `accepted`) but the webhook fan-out has not yet\n started for this row.\n - `in_flight`: at least one delivery attempt is in flight.\n - `fired`: terminal success. Every active endpoint\n acknowledged the delivery (or accepted it after retries).\n - `failed`: terminal partial-failure. At least one endpoint\n exhausted its retry budget; some endpoints may still\n have succeeded.\n - `exhausted`: terminal failure. Every endpoint exhausted\n its retry budget without success.\n - `null`: no endpoints configured, so no webhook lifecycle\n applies.\n\nNote that the value `pending` here does NOT mean the email\nis `pending`; it means the email is past ingestion but\nwebhook delivery has not yet begun. Two overlapping uses\nof the word `pending` for distinct lifecycle phases.\n",
12528
+ "enum": [
12529
+ "pending",
12530
+ "in_flight",
12531
+ "fired",
12532
+ "failed",
12533
+ "exhausted",
12534
+ null
12535
+ ]
12536
+ },
12537
+ "SentEmailStatus": {
12538
+ "type": "string",
12539
+ "description": "Lifecycle status of a sent_emails row. Possible values:\n\n - `queued`: pre-call INSERT; the outbound agent has not\n yet replied.\n - `submitted_to_agent`: agent accepted; `queue_id` is set.\n - `agent_failed`: agent rejected; `error_code` and\n `error_message` carry the reason.\n - `gate_denied`: a recipient-scope gate denied the send;\n the agent was never called. The `gates` array carries\n the denial detail. /send-mail returns 403 in this case\n so callers see the denial synchronously; /sent-emails\n additionally records the row for historical lookup,\n which is when this status appears in a listing.\n - `unknown`: terminal indeterminate; the on-box log\n poller couldn't classify the receiver's response.\n - `delivered` / `bounced` / `deferred` / `wait_timeout`:\n terminal delivery outcomes (see DeliveryStatus).\n - `scheduled`: created with a future `scheduled_at` and\n not yet executed; `scheduled_at` carries the pending\n execution time. Reschedulable via PATCH\n /sent-emails/{id} and cancelable via\n /sent-emails/{id}/cancel while in this status.\n - `canceled`: terminal; a scheduled send canceled before\n execution. `canceled_at` carries the cancellation time\n and nothing was dispatched.\n",
12540
+ "enum": [
12541
+ "queued",
12542
+ "submitted_to_agent",
12543
+ "agent_failed",
12544
+ "gate_denied",
12545
+ "unknown",
12546
+ "delivered",
12547
+ "bounced",
12548
+ "deferred",
12549
+ "wait_timeout",
12550
+ "scheduled",
12551
+ "canceled"
12552
+ ]
12553
+ },
12554
+ "DeliveryStatus": {
12555
+ "type": "string",
12556
+ "description": "Narrower enum covering only the four terminal delivery\noutcomes returned to a synchronous `wait: true` send.\n\nOn the SendMailResult shape, `delivery_status` is always\nequal to `status` whenever both are present (i.e. on\nterminal-state replays and live wait=true responses).\nThe two fields exist so callers that want to type-narrow\non \"this is a delivery outcome\" can pattern-match against\nthe four-value enum without handling the broader\nSentEmailStatus value set (which also covers `queued`,\n`submitted_to_agent`, `agent_failed`, `gate_denied`,\n`unknown`).\n\nOn async-mode and pre-terminal responses, `delivery_status`\nis absent and only `status` is populated. Use `status` if\nyou want a single field that's always present.\n",
12557
+ "enum": [
12558
+ "delivered",
12559
+ "bounced",
12560
+ "deferred",
12561
+ "wait_timeout"
12562
+ ]
12563
+ },
12564
+ "SentEmailSummary": {
12565
+ "type": "object",
12566
+ "description": "List-row projection of a sent-email record. Drops\n`body_text` and `body_html` to keep paginated responses\nsmall; fetch /sent-emails/{id} for the full record with\nbodies.\n",
12567
+ "properties": {
12568
+ "id": {
12569
+ "type": "string",
12570
+ "format": "uuid"
12571
+ },
12572
+ "status": { "$ref": "#/components/schemas/SentEmailStatus" },
12573
+ "status_changed_at": {
12574
+ "type": "string",
12575
+ "format": "date-time",
12576
+ "description": "Timestamp of the most recent status transition.\nPolling clients should treat `status='queued'` AND\n`status_changed_at` older than 5 minutes as\n\"stuck-queued\" (the post-tx UPDATE failed and the\nactual delivery state is recoverable from on-box logs\nvia `queue_id` when populated, or `request_id`).\n"
12577
+ },
12578
+ "created_at": {
12579
+ "type": "string",
12580
+ "format": "date-time"
12581
+ },
12582
+ "updated_at": {
12583
+ "type": "string",
12584
+ "format": "date-time"
12585
+ },
12586
+ "client_idempotency_key": {
12587
+ "type": ["string", "null"],
12588
+ "description": "Effective idempotency key used for this send. If the\ncaller passed the `Idempotency-Key` header, this is\nthat value; otherwise it's a server-derived hash of\nthe canonical request payload.\n"
12589
+ },
12590
+ "content_hash": {
12591
+ "type": "string",
12592
+ "description": "Stable hash of the canonical send payload."
12593
+ },
12594
+ "from_header": {
12595
+ "type": "string",
12596
+ "description": "Raw `From:` header as sent on the wire, including any\ndisplay name (e.g. `\"Acme Support\" <agent@acme.test>`).\n"
12597
+ },
12598
+ "from_address": {
12599
+ "type": "string",
12600
+ "description": "Bare email address parsed from `from_header`."
12601
+ },
12602
+ "to_header": {
12603
+ "type": "string",
12604
+ "description": "Raw `To:` header as sent on the wire, including any\ndisplay name.\n"
12605
+ },
12606
+ "to_address": {
12607
+ "type": "string",
12608
+ "description": "Bare email address parsed from `to_header`."
12609
+ },
12610
+ "subject": { "type": "string" },
12611
+ "body_size_bytes": {
12612
+ "type": "integer",
12613
+ "description": "Total UTF-8 byte length of `body_text` + `body_html`.\nSurfaced on the list endpoint so callers can see \"this\nrow has a 4MB body\" without fetching it.\n"
12614
+ },
12615
+ "content_discarded_at": {
12616
+ "type": ["string", "null"],
12617
+ "format": "date-time",
12618
+ "description": "Timestamp at which the bodies were discarded by an\nentitlement-driven retention policy. Null when bodies\nare still present. The detail endpoint returns\nnull-valued `body_text`/`body_html` for discarded rows.\n"
12619
+ },
12620
+ "message_id": {
12621
+ "type": ["string", "null"],
12622
+ "description": "Wire-level Message-ID assigned to the outbound message\n(RFC 5322). Null on rows that never reached signing\n(queued, gate_denied, agent_failed before signing).\n"
12623
+ },
12624
+ "in_reply_to": {
12625
+ "type": ["string", "null"],
12626
+ "description": "Wire-level In-Reply-To header value, when this send\nwas a reply.\n"
12627
+ },
12628
+ "email_references": {
12629
+ "type": ["string", "null"],
12630
+ "description": "Wire-level References header value, when this send\nwas a reply.\n"
12631
+ },
12632
+ "in_reply_to_email_id": {
12633
+ "type": ["string", "null"],
12634
+ "format": "uuid",
12635
+ "description": "Reference to the inbound `emails.id` that this send\nreplied to, when known. Populated when the caller used\n/emails/{id}/reply or when /send-mail's `in_reply_to`\nmatched a stored inbound message_id in the same org.\n"
12636
+ },
12637
+ "thread_id": {
12638
+ "type": ["string", "null"],
12639
+ "format": "uuid",
12640
+ "description": "Conversation thread this send belongs to. A reply inherits\nthe thread of the inbound it answers; a fresh send starts a\nnew thread. Fetch `/threads/{thread_id}` for the full\nordered thread (inbound + outbound interleaved). NULL on\ngate-denied sends and on sends created before threading was\nenabled.\n"
12641
+ },
12642
+ "queue_id": {
12643
+ "type": ["string", "null"],
12644
+ "description": "Message identifier assigned by Primitive's outbound\nrelay once the agent accepts the message. Null on\nqueued, gate_denied, and agent_failed rows.\n"
12645
+ },
12646
+ "smtp_response_code": {
12647
+ "type": ["integer", "null"],
12648
+ "description": "Receiver's 3-digit SMTP code (e.g. 250, 550, 451).\nPopulated on terminal delivery statuses; may be null\non a deferred where the agent never got an SMTP-level\nresponse (TCP refused, DNS failed, TLS handshake\nfailed). `smtp_response_text` still carries Postfix's\ndescriptive text in those cases.\n"
12649
+ },
12650
+ "smtp_response_text": {
12651
+ "type": ["string", "null"],
12652
+ "description": "Free-form text portion of the receiver's SMTP\nresponse. The most useful debugging signal on a\n`bounced` or `deferred` row.\n"
12653
+ },
12654
+ "smtp_enhanced_status_code": {
12655
+ "type": ["string", "null"],
12656
+ "description": "RFC 3463 enhanced status code (e.g. `5.1.1` for \"Bad\ndestination mailbox address\"). Distinct from\n`smtp_response_code`: the basic 3-digit code is coarse\n(550 = \"permanent failure\"), the enhanced code is\nfiner-grained.\n"
12657
+ },
12658
+ "dkim_selector": {
12659
+ "type": ["string", "null"],
12660
+ "description": "DKIM selector used to sign the outbound message.\nPublic DNS data; useful for diagnosing why a downstream\nverifier rejected the signature.\n"
12661
+ },
12662
+ "dkim_domain": {
12663
+ "type": ["string", "null"],
12664
+ "description": "DKIM signing domain."
12665
+ },
12666
+ "error_code": {
12667
+ "type": ["string", "null"],
12668
+ "description": "Stable public error code on `agent_failed` rows. The\nagent's internal codes are remapped to a stable public\ntaxonomy (see `publicAgentError` in the server) so this\nfield is safe to branch on across agent versions.\n"
12669
+ },
12670
+ "error_message": {
12671
+ "type": ["string", "null"],
12672
+ "description": "Free-form error message accompanying `error_code`."
12673
+ },
12674
+ "gates": {
12675
+ "type": ["array", "null"],
12676
+ "items": { "$ref": "#/components/schemas/GateDenial" },
12677
+ "description": "Gate-denial detail on `gate_denied` rows. Mirrors the\nsynchronous /send-mail 403 contract so a caller's\nGateDenial handler is the same across live denies and\nhistorical lookups. Null on every other status.\n"
12678
+ },
12679
+ "request_id": {
12680
+ "type": ["string", "null"],
12681
+ "description": "Server-issued request identifier from the original\n/send-mail call. Surfaced as the `X-Request-Id`\nresponse header on the live send and recorded here\nfor support escalation.\n"
12682
+ },
12683
+ "scheduled_at": {
12684
+ "type": ["string", "null"],
12685
+ "format": "date-time",
12686
+ "description": "Requested execution time for a scheduled send. Kept\nafter execution as the historical schedule; null on\nordinary immediate sends.\n"
12687
+ },
12688
+ "canceled_at": {
12689
+ "type": ["string", "null"],
12690
+ "format": "date-time",
12691
+ "description": "When a scheduled send was canceled. Null unless the row\nreached the `canceled` status.\n"
12692
+ },
12681
12693
  "presence_control": { "$ref": "#/components/schemas/PresenceControl" },
12682
12694
  "repeat": {
12683
12695
  "type": ["object", "null"],
@@ -22645,7 +22657,7 @@ const operationManifest = [
22645
22657
  "binaryResponse": false,
22646
22658
  "bodyRequired": false,
22647
22659
  "command": "get-conversation",
22648
- "description": "Returns the full conversation the given inbound email belongs\nto, as ordered, ready-to-prompt turns WITH bodies. It resolves\nthe thread from the email and returns every message oldest-first,\nso an agent that received an email can pass `messages` straight\nto a chat model in one call instead of walking `/threads/{id}`\nplus `/emails/{id}` and `/sent-emails/{id}` per message.\n\nEach message carries a `direction` (`inbound` | `outbound`) and a\nderived `role`: `inbound` -> `user`, `outbound` -> `assistant`\n(your own prior replies). The role mapping assumes the caller\nowns the outbound side, which is the agent-reply case this exists\nfor. If the email has no thread yet (a brand-new message), the\nconversation is just that one message as a single user turn.\n\nThe message list is capped; check `truncated` to detect when\nolder messages were omitted. Consecutive same-role turns are not\nmerged here; that normalization is model-specific and left to the\ncaller.\n",
22660
+ "description": "Returns the full conversation the given inbound email belongs\nto, as ordered, ready-to-prompt turns WITH bodies. It resolves\nthe thread from the email and returns every message oldest-first,\nso an agent that received an email can pass `messages` straight\nto a chat model in one call instead of walking `/threads/{id}`\nplus `/emails/{id}` and `/sent-emails/{id}` per message.\n\nEach message carries a `direction` (`inbound` | `outbound`) and a\nderived `role`: `inbound` -> `user`, `outbound` -> `assistant`\n(your own prior replies). The role mapping assumes the caller\nowns the outbound side, which is the agent-reply case this exists\nfor. If the email has no thread yet (a brand-new message), the\nconversation is just that one message as a single user turn.\n\nThe message list is capped; check `truncated` to detect when\nolder messages were omitted. Consecutive same-role turns are not\nmerged here; that normalization is model-specific and left to the\ncaller.\n\nPass `since` for an incremental read. `since=start` returns the\nfull conversation plus a `cursor`. Sending that `cursor` back as\n`since` returns only the messages created or changed after it,\noldest first, with the same per-message shape, and a new\n`cursor`. Without `since` the response carries no `cursor` and\nno per-message `status`.\n",
22649
22661
  "hasJsonBody": false,
22650
22662
  "method": "GET",
22651
22663
  "operationId": "getConversation",
@@ -22657,7 +22669,13 @@ const operationManifest = [
22657
22669
  "required": true,
22658
22670
  "type": "string"
22659
22671
  }],
22660
- "queryParams": [],
22672
+ "queryParams": [{
22673
+ "description": "Incremental read position. Either the literal `start` or a\n`cursor` returned by a previous read of this endpoint. Any\nother value returns 400 `validation_error`.\n\nWith `start`, the response holds every message the read\nwithout `since` would return, plus `cursor`. With a cursor,\n`messages` holds only the messages created or changed after\nit, oldest first. A change includes an inbound message\nbecoming visible or its body being filled, withheld or\ndiscarded, and a delivery status change on an outbound\nmessage. `thread_id`, `subject`, `message_count` and\n`truncated` always describe the whole conversation, not the\nreturned messages.\n\nThe cursor is an opaque string. Send it back verbatim and do\nnot parse it or build one; its format can change. A message\ncan be returned again by a later read (for example while\nolder writes are still committing), so upsert messages by\n`id`. Deleted messages are not reported; a periodic\n`since=start` read (or a read without `since`) reconciles.\n",
22674
+ "enum": null,
22675
+ "name": "since",
22676
+ "required": false,
22677
+ "type": "string"
22678
+ }],
22661
22679
  "requestSchema": null,
22662
22680
  "responseSchema": {
22663
22681
  "type": "object",
@@ -22684,7 +22702,7 @@ const operationManifest = [
22684
22702
  "type": "array",
22685
22703
  "items": {
22686
22704
  "type": "object",
22687
- "description": "One message in the conversation, with its body and a chat role.",
22705
+ "description": "One message in the conversation, with its body and a chat role.\n`status` is the delivery status of an outbound message, as on\n`/sent-emails/{id}`, and is present only on a `since` read and\nonly on outbound messages.\n",
22688
22706
  "properties": {
22689
22707
  "role": {
22690
22708
  "type": "string",
@@ -22713,6 +22731,23 @@ const operationManifest = [
22713
22731
  "format": "date-time",
22714
22732
  "description": "received_at for inbound, created_at for outbound."
22715
22733
  },
22734
+ "status": {
22735
+ "type": "string",
22736
+ "description": "Lifecycle status of a sent_emails row. Possible values:\n\n - `queued`: pre-call INSERT; the outbound agent has not\n yet replied.\n - `submitted_to_agent`: agent accepted; `queue_id` is set.\n - `agent_failed`: agent rejected; `error_code` and\n `error_message` carry the reason.\n - `gate_denied`: a recipient-scope gate denied the send;\n the agent was never called. The `gates` array carries\n the denial detail. /send-mail returns 403 in this case\n so callers see the denial synchronously; /sent-emails\n additionally records the row for historical lookup,\n which is when this status appears in a listing.\n - `unknown`: terminal indeterminate; the on-box log\n poller couldn't classify the receiver's response.\n - `delivered` / `bounced` / `deferred` / `wait_timeout`:\n terminal delivery outcomes (see DeliveryStatus).\n - `scheduled`: created with a future `scheduled_at` and\n not yet executed; `scheduled_at` carries the pending\n execution time. Reschedulable via PATCH\n /sent-emails/{id} and cancelable via\n /sent-emails/{id}/cancel while in this status.\n - `canceled`: terminal; a scheduled send canceled before\n execution. `canceled_at` carries the cancellation time\n and nothing was dispatched.\n",
22737
+ "enum": [
22738
+ "queued",
22739
+ "submitted_to_agent",
22740
+ "agent_failed",
22741
+ "gate_denied",
22742
+ "unknown",
22743
+ "delivered",
22744
+ "bounced",
22745
+ "deferred",
22746
+ "wait_timeout",
22747
+ "scheduled",
22748
+ "canceled"
22749
+ ]
22750
+ },
22716
22751
  "presence_control": { "anyOf": [{
22717
22752
  "type": "object",
22718
22753
  "properties": {
@@ -22773,6 +22808,10 @@ const operationManifest = [
22773
22808
  "text"
22774
22809
  ]
22775
22810
  }
22811
+ },
22812
+ "cursor": {
22813
+ "type": "string",
22814
+ "description": "Present only on a `since` read: the position to send as\n`since` on the next read. An opaque string: send it back\nverbatim and do not parse it or build one.\n"
22776
22815
  }
22777
22816
  },
22778
22817
  "required": [
@@ -34983,440 +35022,1355 @@ function isMemoryJsonValue(value, seen = /* @__PURE__ */ new Set()) {
34983
35022
  }
34984
35023
  }
34985
35024
  //#endregion
34986
- //#region src/oclif/claude-wake-install.ts
34987
- const HOOK_MARKER = "primitive-agent-wake-v1";
34988
- const WAKE_EVENTS = ["Stop", "SessionStart"];
34989
- const PENDING_MARKER$1 = "primitive-pending-mail-v1";
34990
- const PENDING_EVENT = "PostToolUse";
34991
- const LOCK_WAIT$1 = new Int32Array(new SharedArrayBuffer(4));
34992
- function record$4(value) {
34993
- return value !== null && typeof value === "object" && !Array.isArray(value);
34994
- }
34995
- function claudeWakeHookStatus(options) {
34996
- const unavailable = {
34997
- installed: false,
34998
- lastFiredAt: null,
34999
- liveness: "unknown"
35000
- };
35001
- try {
35002
- const configDir = resolve(options.configDir);
35003
- const profileName = agentProfileName(options.profileName);
35004
- const agentAddress = mailAddress(options.agentAddress);
35005
- if (!SESSION_UUID$1.test(options.sessionId)) return unavailable;
35006
- const sessionId = options.sessionId.toLowerCase();
35007
- const settings = readMailJson(join(resolve(options.env?.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), "settings.json"));
35008
- if (!record$4(settings) || !record$4(settings.hooks)) return unavailable;
35009
- const hooks = settings.hooks;
35010
- const owns = (event, script, marker) => {
35011
- const entries = hooks[event];
35012
- return Array.isArray(entries) && entries.some((entry) => record$4(entry) && Array.isArray(entry.hooks) && entry.hooks.some((candidate) => {
35013
- if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35014
- const args = candidate.args;
35015
- const offset = 2;
35016
- return candidate.type === "command" && candidate.command === process.execPath && args.length === 7 && typeof args[0] === "string" && basename(args[0]) === script && args[offset] === configDir && args[offset + 1] === profileName && args[offset + 2] === agentAddress && args[offset + 3] === sessionId && args[offset + 4] === marker;
35017
- }));
35018
- };
35019
- const installed = owns("Stop", "claude-wake.mjs", HOOK_MARKER) && owns("SessionStart", "claude-wake.mjs", HOOK_MARKER) && owns(PENDING_EVENT, "claude-pending-mail.mjs", PENDING_MARKER$1);
35020
- let lastFiredAt = null;
35021
- const fired = readMailJson(join(agentProfileDirectory(configDir, profileName), `pending-mail-${sessionId}.fired.json`));
35022
- if (record$4(fired) && fired.version === 1 && typeof fired.at === "string" && Number.isFinite(Date.parse(fired.at))) lastFiredAt = fired.at;
35023
- return {
35024
- installed,
35025
- lastFiredAt,
35026
- liveness: "unknown"
35027
- };
35028
- } catch {
35029
- return unavailable;
35030
- }
35031
- }
35032
- function editClaudeSettings(claudeDir, edit) {
35033
- mkdirSync(claudeDir, {
35034
- recursive: true,
35035
- mode: 448
35036
- });
35037
- let release;
35038
- for (let attempt = 0; attempt < 40; attempt++) try {
35039
- release = acquireListenLock(claudeDir, "primitive-claude-settings");
35040
- break;
35041
- } catch {
35042
- Atomics.wait(LOCK_WAIT$1, 0, 0, 25);
35025
+ //#region src/oclif/agent-mail-check.ts
35026
+ /**
35027
+ * One check for mail that arrived since this profile's previous check. It is
35028
+ * the receiver for runtimes that nothing can wake (poll receiving): the agent
35029
+ * runs it at the start of each turn and after it sends.
35030
+ *
35031
+ * It tails `GET /emails?since=<cursor>`, which returns mail newer than the
35032
+ * cursor oldest first, without mail marked fyi (acknowledgements) or in
35033
+ * threads this address muted. The first check starts before the address's
35034
+ * first email. The cursor is saved privately per profile, only after the
35035
+ * result was handed to `emit`, so a failed or interrupted check repeats mail
35036
+ * rather than losing it. Reads also show this address's receiver as live.
35037
+ */
35038
+ const CURSOR_FILE = "mail-check.json";
35039
+ var MailCheckApiError = class extends Error {
35040
+ constructor(payload) {
35041
+ super("The mail check request failed.");
35042
+ this.payload = payload;
35043
35043
  }
35044
- if (!release) return false;
35044
+ };
35045
+ function readCursor(path) {
35045
35046
  try {
35046
- const settingsPath = join(claudeDir, "settings.json");
35047
- for (let attempt = 0; attempt < 3; attempt++) {
35048
- let original = null;
35049
- let settings = {};
35050
- if (existsSync(settingsPath)) {
35051
- const stat = lstatSync(settingsPath);
35052
- if (!stat.isFile() || stat.size > 1048576) return false;
35053
- original = readFileSync(settingsPath, "utf8");
35054
- const parsed = JSON.parse(original);
35055
- if (!record$4(parsed)) return false;
35056
- settings = parsed;
35057
- }
35058
- const next = edit(settings);
35059
- if (next === null) return true;
35060
- const temporary = join(claudeDir, `.settings.json.${randomUUID()}.tmp`);
35061
- const fd = openSync(temporary, "wx", 384);
35062
- try {
35063
- writeFileSync(fd, `${JSON.stringify(next, null, 2)}\n`, "utf8");
35064
- } finally {
35065
- closeSync(fd);
35066
- }
35067
- try {
35068
- if ((existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : null) !== original) continue;
35069
- renameSync(temporary, settingsPath);
35070
- return true;
35071
- } finally {
35072
- if (existsSync(temporary)) unlinkSync(temporary);
35073
- }
35047
+ const saved = readMailJson(path);
35048
+ if (saved && typeof saved === "object" && !Array.isArray(saved) && saved.version === 1) {
35049
+ const cursor = saved.cursor;
35050
+ if (typeof cursor === "string" && cursor.length > 0) return cursor;
35074
35051
  }
35075
- return false;
35076
- } finally {
35077
- release();
35078
- }
35052
+ } catch {}
35053
+ return null;
35079
35054
  }
35080
- /** Add exact-session fail-open receiving hooks while preserving unrelated settings. */
35081
- function installClaudeWakeHook(options) {
35082
- try {
35083
- const cliPath = realpathSync(options.cliPath);
35084
- const wrapperPath = realpathSync(join(dirname(cliPath), "claude-wake.mjs"));
35085
- const pendingPath = realpathSync(join(dirname(cliPath), "claude-pending-mail.mjs"));
35086
- const configDir = resolve(options.configDir);
35087
- const profileName = agentProfileName(options.profileName);
35088
- const agentAddress = mailAddress(options.agentAddress);
35089
- if (!SESSION_UUID$1.test(options.sessionId)) return "unavailable";
35090
- const sessionId = options.sessionId.toLowerCase();
35091
- return editClaudeSettings(resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), (settings) => {
35092
- const hooks = settings.hooks ?? {};
35093
- if (!record$4(hooks)) throw new Error("Invalid hook settings");
35094
- for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
35095
- if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
35096
- const hook = {
35097
- type: "command",
35098
- command: process.execPath,
35099
- args: [
35100
- wrapperPath,
35101
- cliPath,
35102
- configDir,
35103
- profileName,
35104
- agentAddress,
35105
- sessionId,
35106
- HOOK_MARKER
35107
- ],
35108
- asyncRewake: true,
35109
- timeout: 604800
35110
- };
35111
- const pendingHook = {
35112
- type: "command",
35113
- command: process.execPath,
35114
- args: [
35115
- pendingPath,
35116
- cliPath,
35117
- configDir,
35118
- profileName,
35119
- agentAddress,
35120
- sessionId,
35121
- PENDING_MARKER$1
35122
- ],
35123
- timeout: 10
35124
- };
35125
- const isOwnPendingHook = (candidate) => record$4(candidate) && candidate.type === "command" && Array.isArray(candidate.args) && candidate.args.length === 7 && typeof candidate.args[0] === "string" && basename(candidate.args[0]) === "claude-pending-mail.mjs" && candidate.args[2] === configDir && candidate.args[5] === sessionId && candidate.args[6] === PENDING_MARKER$1;
35126
- const isOwnHook = (candidate) => {
35127
- if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35128
- if (typeof candidate.args[0] !== "string" || basename(candidate.args[0]) !== "claude-wake.mjs" || candidate.args[2] !== configDir) return false;
35129
- if (candidate.args.length === 7 && candidate.args[5] === sessionId && candidate.args[6] === HOOK_MARKER) return true;
35130
- if (candidate.args.length !== 6 || candidate.args[5] !== HOOK_MARKER) return false;
35131
- try {
35132
- const state = readMailJson(join(agentProfileDirectory(configDir, agentProfileName(String(candidate.args[3]))), "setup.json"));
35133
- return record$4(state) && state.session === sessionId;
35134
- } catch {
35135
- return false;
35136
- }
35137
- };
35138
- const nextHooks = { ...hooks };
35139
- for (const event of WAKE_EVENTS) nextHooks[event] = [...(hooks[event] ?? []).flatMap((entry) => {
35140
- if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35141
- const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
35142
- if (siblings.length === entry.hooks.length) return [entry];
35143
- return siblings.length ? [{
35144
- ...entry,
35145
- hooks: siblings
35146
- }] : [];
35147
- }), event === "SessionStart" ? {
35148
- matcher: "resume",
35149
- hooks: [hook]
35150
- } : { hooks: [hook] }];
35151
- nextHooks[PENDING_EVENT] = [...(hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
35152
- if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35153
- const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
35154
- if (siblings.length === entry.hooks.length) return [entry];
35155
- return siblings.length ? [{
35156
- ...entry,
35157
- hooks: siblings
35158
- }] : [];
35159
- }), { hooks: [pendingHook] }];
35160
- return {
35161
- ...settings,
35162
- hooks: nextHooks
35163
- };
35164
- }) ? "installed_unverified" : "unavailable";
35165
- } catch {
35166
- return "unavailable";
35167
- }
35055
+ /** Setup challenges and presence probes come from the control address. */
35056
+ function isControlMail(email, identity) {
35057
+ return email.presence_control !== void 0 || email.sender.trim().toLowerCase() === identity.ownerAddress.trim().toLowerCase();
35168
35058
  }
35169
- /** Remove only this profile's exact-session hooks after disconnection. */
35170
- function uninstallClaudeWakeHook(options) {
35059
+ async function checkAgentMail(options) {
35060
+ const { identity } = options;
35061
+ const directory = agentProfileDirectory(options.configDir, identity.profileName);
35062
+ privateMailDirectory(directory, true);
35063
+ const release = acquireListenLock(directory, "mail-check");
35171
35064
  try {
35172
- const configDir = resolve(options.configDir);
35173
- const profileName = agentProfileName(options.profileName);
35174
- const agentAddress = mailAddress(options.agentAddress);
35175
- if (!SESSION_UUID$1.test(options.sessionId)) return false;
35176
- const sessionId = options.sessionId.toLowerCase();
35177
- const claudeDir = resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
35178
- if (!existsSync(join(claudeDir, "settings.json"))) return true;
35179
- return editClaudeSettings(claudeDir, (settings) => {
35180
- const hooks = settings.hooks ?? {};
35181
- if (!record$4(hooks)) throw new Error("Invalid hook settings");
35182
- for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
35183
- if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
35184
- const legacySessionMatches = () => {
35185
- try {
35186
- const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
35187
- return record$4(setup) && setup.session === sessionId;
35188
- } catch {
35189
- return false;
35190
- }
35191
- };
35192
- const isOwnHook = (candidate) => {
35193
- if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35194
- const args = candidate.args;
35195
- return candidate.type === "command" && typeof candidate.command === "string" && candidate.asyncRewake === true && typeof args[0] === "string" && basename(args[0]) === "claude-wake.mjs" && typeof args[1] === "string" && args[2] === configDir && args[3] === profileName && args[4] === agentAddress && (args.length === 7 && args[5] === sessionId && args[6] === HOOK_MARKER || args.length === 6 && args[5] === HOOK_MARKER && legacySessionMatches());
35196
- };
35197
- const isOwnPendingHook = (candidate) => {
35198
- if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35199
- const args = candidate.args;
35200
- return candidate.type === "command" && typeof candidate.command === "string" && typeof args[0] === "string" && basename(args[0]) === "claude-pending-mail.mjs" && args[2] === configDir && args[3] === profileName && args[4] === agentAddress && args[5] === sessionId && args[6] === PENDING_MARKER$1 && args.length === 7;
35065
+ const path = join(directory, CURSOR_FILE);
35066
+ const saved = readCursor(path);
35067
+ let cursor = saved;
35068
+ const emails = [];
35069
+ let controlSkipped = 0;
35070
+ let more = false;
35071
+ const maxPages = options.maxPages ?? 5;
35072
+ for (let page = 0; page < maxPages; page++) {
35073
+ const query = {
35074
+ since: cursor ?? "start",
35075
+ limit: 100,
35076
+ exclude_fyi: "true",
35077
+ exclude_muted: "true"
35201
35078
  };
35202
- let removed = false;
35203
- const nextHooks = { ...hooks };
35204
- for (const event of WAKE_EVENTS) nextHooks[event] = (hooks[event] ?? []).flatMap((entry) => {
35205
- if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35206
- const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
35207
- if (siblings.length === entry.hooks.length) return [entry];
35208
- removed = true;
35209
- return siblings.length ? [{
35210
- ...entry,
35211
- hooks: siblings
35212
- }] : [];
35079
+ const result = await listEmails({
35080
+ client: options.client.client,
35081
+ query,
35082
+ responseStyle: "fields"
35213
35083
  });
35214
- nextHooks[PENDING_EVENT] = (hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
35215
- if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35216
- const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
35217
- if (siblings.length === entry.hooks.length) return [entry];
35218
- removed = true;
35219
- return siblings.length ? [{
35220
- ...entry,
35221
- hooks: siblings
35222
- }] : [];
35084
+ if (result.error || !result.data) throw new MailCheckApiError(result.error);
35085
+ const rows = result.data.data ?? [];
35086
+ for (const row of rows) if (isControlMail(row, identity)) controlSkipped++;
35087
+ else emails.push({
35088
+ id: row.id,
35089
+ received_at: row.received_at,
35090
+ sender: row.sender,
35091
+ thread_id: row.thread_id ?? null
35223
35092
  });
35224
- return removed ? {
35225
- ...settings,
35226
- hooks: nextHooks
35227
- } : null;
35093
+ const next = result.data.meta?.cursor ?? null;
35094
+ if (rows.length === 0 || !next) break;
35095
+ cursor = next;
35096
+ more = page === maxPages - 1;
35097
+ }
35098
+ const output = {
35099
+ outcome: emails.length > 0 ? "mail" : "empty",
35100
+ emails,
35101
+ more,
35102
+ control_skipped: controlSkipped,
35103
+ read_command: `PRIMITIVE_AGENT_PROFILE=${identity.profileName} ${options.invocation ?? "primitive"} emails get --id <id> --brief`
35104
+ };
35105
+ options.emit(output);
35106
+ if (cursor && cursor !== saved) writeMailJson(path, {
35107
+ version: 1,
35108
+ cursor
35228
35109
  });
35229
- } catch {
35230
- return false;
35110
+ return output;
35111
+ } finally {
35112
+ release();
35231
35113
  }
35232
35114
  }
35233
35115
  //#endregion
35234
- //#region src/oclif/agent-connect.ts
35235
- const MAX_INVITATION_BYTES = 4096;
35236
- const MAX_RESPONSE_BYTES$2 = 32768;
35237
- const UNCERTAIN_CLAIM = "Setup did not complete safely. The invitation may have been consumed. Request a fresh invitation from the owner; do not retry this invitation.";
35238
- /** Offline metadata only; a saved profile is not proof of current API or listener readiness. */
35239
- function agentConnectionStatus(configDir, profileName) {
35240
- agentProfileName(profileName);
35241
- const profile = loadConnectedAgentProfile(configDir, profileName);
35242
- const setup = profile ? readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json")) : null;
35243
- const saved = setup && typeof setup === "object" && !Array.isArray(setup) ? setup : null;
35244
- const session = saved && typeof saved.session === "string" && SESSION_UUID$1.test(saved.session) ? saved.session : null;
35245
- const mode = saved && saved.receiverMode === "external" ? "external" : "native";
35246
- const receiving = profile && session && mode === "native" ? (() => {
35247
- const scope = notificationScope(profile.api_base_url, profile.api_key);
35248
- const listener = backgroundListenStatus({
35249
- configDir,
35250
- scope,
35251
- threadId: session
35252
- });
35253
- const mailOwner = readSharedMailOwner({ directory: join(configDir, "shared-mail", createHash("sha256").update(scope).digest("hex")) });
35116
+ //#region src/oclif/commands/agent-check-mail.ts
35117
+ /** npx runs the CLI from its cache; print follow-up commands the same way. */
35118
+ function invocation$2(entry) {
35119
+ return entry && /[\\/]_npx[\\/]/.test(entry) ? "npx -y primitive@latest" : "primitive";
35120
+ }
35121
+ var AgentCheckMailCommand = class AgentCheckMailCommand extends Command {
35122
+ static summary = "Check a connected agent's mail since its last check";
35123
+ static description = `Print the mail that reached the selected connected agent profile since its previous check, oldest first, once, without waiting. This is how an agent connected with poll receiving (no local session ID or hooks, so nothing can wake it) receives mail: run it at the start of each turn and after sending, then read each email with \`primitive emails get --id <id> --brief\`.
35124
+
35125
+ Acknowledgements marked fyi and mail in muted threads are left out. Setup and presence mail from the connection's control address is handled by the CLI and only counted. The position is saved privately with the profile and moves forward only after mail was read, so mail is reported again rather than lost when a check fails or is interrupted; deduplicate by email ID. The first check starts before the address's first email. \`more: true\` means more new mail remains: handle these and check again.
35126
+
35127
+ Requires a connected agent profile selected with ${AGENT_PROFILE_ENV}. Output is one JSON document: \`outcome\` ("mail" | "empty"), \`emails\` (id, received_at, sender, thread_id; never subject or body), \`more\`, \`control_skipped\` and \`read_command\`.`;
35128
+ static examples = [`${AGENT_PROFILE_ENV}=connection-0123456789ab <%= config.bin %> agent check-mail --json`];
35129
+ static flags = { json: Flags.boolean({ description: "Print JSON (already the default)" }) };
35130
+ async run() {
35131
+ await this.parse(AgentCheckMailCommand);
35132
+ if (!process.env["PRIMITIVE_AGENT_PROFILE"]?.trim()) this.error(`Select a connected agent profile with ${AGENT_PROFILE_ENV}=<profile>. The profile is printed by agent connect.`, { exit: 1 });
35133
+ const { apiClient, auth, baseUrlOverridden } = await createAuthenticatedCliApiClient({ configDir: this.config.configDir });
35134
+ if (!auth.connectedAgent) this.error(`Select a connected agent profile with ${AGENT_PROFILE_ENV}=<profile>.`, { exit: 1 });
35135
+ try {
35136
+ await checkAgentMail({
35137
+ configDir: this.config.configDir,
35138
+ identity: auth.connectedAgent,
35139
+ client: apiClient,
35140
+ invocation: invocation$2(process.argv[1]),
35141
+ emit: (result) => this.log(JSON.stringify(result))
35142
+ });
35143
+ } catch (error) {
35144
+ if (!(error instanceof MailCheckApiError)) throw error;
35145
+ const payload = extractErrorPayload(error.payload);
35146
+ writeErrorWithHints(payload);
35147
+ surfaceUnauthorizedHint({
35148
+ auth,
35149
+ baseUrlOverridden,
35150
+ configDir: this.config.configDir,
35151
+ payload
35152
+ });
35153
+ process.exitCode = 1;
35154
+ }
35155
+ }
35156
+ };
35157
+ //#endregion
35158
+ //#region src/oclif/machine-files.ts
35159
+ /** Hand-edited files larger than this are reported, never rewritten. */
35160
+ const MAX_MANAGED_FILE_BYTES = 1048576;
35161
+ /** Backups kept per file; older Primitive backups of the same file are pruned. */
35162
+ const BACKUPS_KEPT = 10;
35163
+ const BACKUP_INFIX = ".primitive-bak-";
35164
+ /** A safe, user-facing reason a file was not changed. */
35165
+ var MachineFileError = class extends Error {};
35166
+ /**
35167
+ * Read a user-owned text file without following it anywhere surprising. A
35168
+ * symlink is followed to its real file so a later write updates the file the
35169
+ * user linked instead of replacing the link.
35170
+ */
35171
+ function readManagedFile(path) {
35172
+ let info;
35173
+ try {
35174
+ info = lstatSync(path);
35175
+ } catch (error) {
35176
+ if (error.code === "ENOENT") return {
35177
+ state: "absent",
35178
+ path,
35179
+ target: path
35180
+ };
35254
35181
  return {
35255
- mode,
35256
- sessionId: session,
35257
- state: listener.healthy ? listener.phase === "receiving" ? "running" : "degraded" : listener.reason === "restarting" ? "degraded" : listener.reason === "absent" ? "unknown" : "down",
35258
- reason: listener.reason ?? listener.failureCode,
35259
- failureCode: listener.failureCode,
35260
- detail: listener.detail ?? null,
35261
- lastSuccessfulMailCheckAt: mailOwner?.lastMailCheckAt ?? null,
35262
- liveness: listener.healthy && listener.phase === "receiving" ? "live" : "unknown",
35263
- listener
35182
+ state: "invalid",
35183
+ path,
35184
+ detail: `${path} cannot be read (${error.code ?? "error"}).`
35264
35185
  };
35265
- })() : profile && session ? {
35266
- mode,
35267
- sessionId: session,
35268
- state: "unknown",
35269
- reason: "hook_liveness_unverified",
35270
- lastSuccessfulMailCheckAt: null,
35271
- liveness: "unknown",
35272
- hook: claudeWakeHookStatus({
35273
- configDir,
35274
- profileName,
35275
- agentAddress: profile.agent_address,
35276
- sessionId: session
35277
- })
35278
- } : {
35279
- mode: "unknown",
35280
- sessionId: null,
35281
- state: "unknown",
35282
- reason: "session_not_configured",
35283
- lastSuccessfulMailCheckAt: null,
35284
- liveness: "unknown"
35186
+ }
35187
+ let target = path;
35188
+ if (info.isSymbolicLink()) try {
35189
+ target = realpathSync(path);
35190
+ info = statSync(target);
35191
+ } catch {
35192
+ return {
35193
+ state: "invalid",
35194
+ path,
35195
+ detail: `${path} is a symlink to a missing file; it was left unchanged.`
35196
+ };
35197
+ }
35198
+ if (!info.isFile()) return {
35199
+ state: "invalid",
35200
+ path,
35201
+ detail: `${path} is not a regular file; it was left unchanged.`
35285
35202
  };
35286
- return profile ? {
35287
- status: "configured",
35288
- identity: connectedAgentIdentity(profileName, profile),
35289
- receiving
35290
- } : {
35291
- status: "not_configured",
35292
- profileName
35203
+ if (info.size > MAX_MANAGED_FILE_BYTES) return {
35204
+ state: "invalid",
35205
+ path,
35206
+ detail: `${path} is larger than 1 MiB; it was left unchanged.`
35293
35207
  };
35294
- }
35295
- function parseAgentInvitation(input) {
35208
+ let raw;
35296
35209
  try {
35297
- if (Buffer.byteLength(input) > MAX_INVITATION_BYTES) throw new Error();
35298
- const text = input.trim();
35299
- let token;
35300
- let base;
35301
- if (text.startsWith("{")) {
35302
- const value = JSON.parse(text);
35303
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
35304
- const row = value;
35305
- if (Object.keys(row).some((key) => key !== "token" && key !== "api_base_url")) throw new Error();
35306
- token = row.token;
35307
- base = row.api_base_url ?? "https://api.primitive.dev/v1";
35308
- } else {
35309
- const url = new URL(text);
35310
- if (url.username || url.password || url.search || url.pathname !== "/v1/agent-connections/setup") throw new Error();
35311
- const fragment = new URLSearchParams(url.hash.slice(1));
35312
- if (Array.from(fragment.keys()).length !== 1 || fragment.getAll("token").length !== 1) throw new Error();
35313
- token = fragment.get("token");
35314
- base = `${url.origin}/v1`;
35315
- }
35316
- if (typeof token !== "string" || token.length < 32 || token.length > 256 || /\s/.test(token) || Array.from(token).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) throw new Error();
35210
+ raw = readFileSync(target);
35211
+ } catch (error) {
35317
35212
  return {
35318
- token,
35319
- apiBaseUrl: connectedApiBaseUrl(base)
35213
+ state: "invalid",
35214
+ path,
35215
+ detail: `${path} cannot be read (${error.code ?? "error"}).`
35320
35216
  };
35321
- } catch {
35322
- throw new AgentConnectionSetupError("Pipe the private Primitive setup URL or a JSON object containing token and optional api_base_url to stdin. Invitation values are never accepted as command arguments.");
35323
35217
  }
35218
+ const text = raw.toString("utf8");
35219
+ if (text.includes("\0") || !Buffer.from(text, "utf8").equals(raw)) return {
35220
+ state: "invalid",
35221
+ path,
35222
+ detail: `${path} is not UTF-8 text; it was left unchanged.`
35223
+ };
35224
+ return {
35225
+ state: "present",
35226
+ path,
35227
+ target,
35228
+ text,
35229
+ mode: info.mode & 511
35230
+ };
35324
35231
  }
35325
- async function readAgentInvitation(input, isTTY) {
35326
- if (isTTY) throw new AgentConnectionSetupError("Pipe the private setup invitation to stdin.");
35327
- const chunks = [];
35328
- let size = 0;
35329
- for await (const chunk of input) {
35330
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
35331
- size += bytes.length;
35332
- if (size > MAX_INVITATION_BYTES) throw new AgentConnectionSetupError("Setup invitation exceeds the input limit.");
35333
- chunks.push(bytes);
35334
- }
35335
- return Buffer.concat(chunks).toString("utf8");
35232
+ function backupStamp$1(now) {
35233
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
35336
35234
  }
35337
- async function readClaimResponse(response) {
35338
- const reader = response.body?.getReader();
35339
- if (!reader) throw new Error();
35340
- const chunks = [];
35341
- let size = 0;
35235
+ function syncDirectory(directory) {
35236
+ if (process.platform === "win32") return;
35342
35237
  try {
35343
- for (;;) {
35344
- const part = await reader.read();
35345
- if (part.done) break;
35346
- size += part.value.byteLength;
35347
- if (size > MAX_RESPONSE_BYTES$2) throw new Error();
35348
- chunks.push(part.value);
35238
+ const fd = openSync(directory, "r");
35239
+ try {
35240
+ fsyncSync(fd);
35241
+ } finally {
35242
+ closeSync(fd);
35349
35243
  }
35350
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
35351
- } finally {
35352
- await reader.cancel().catch(() => void 0);
35353
- }
35244
+ } catch {}
35354
35245
  }
35355
- function profileFromClaim(value, invitation, invitationHash, now) {
35356
- const response = value;
35357
- const data = response?.data;
35358
- const connection = data?.connection;
35359
- if (response?.success !== true || !data || !connection || data.api_base_url !== invitation.apiBaseUrl || data.owner_address !== connection.owner_address || !["claimed", "connected"].includes(String(connection.status))) throw new Error();
35360
- let ownerMember;
35246
+ function pruneBackups(target) {
35247
+ const directory = dirname(target);
35248
+ const prefix = `${basename(target)}${BACKUP_INFIX}`;
35361
35249
  try {
35362
- ownerMember = Object.hasOwn(data, "owner_member_address") ? parseOwnerMemberAddress(data.owner_member_address, mailAddress(data.owner_address), mailAddress(connection.address)) : void 0;
35363
- } catch {
35364
- ownerMember = void 0;
35365
- }
35366
- return parseConnectedAgentProfile({
35367
- version: 1,
35368
- auth_method: "agent_connection",
35369
- api_key: data.api_key,
35370
- api_base_url: data.api_base_url,
35371
- org_id: data.org_id,
35372
- agent_address: connection.address,
35373
- owner_address: data.owner_address,
35374
- ...ownerMember === void 0 ? {} : { owner_member_address: ownerMember },
35375
- invitation_hash: invitationHash,
35376
- created_at: new Date(now()).toISOString(),
35377
- ...data.presence_profile === void 0 ? {} : { presence_profile: data.presence_profile }
35378
- });
35250
+ const backups = readdirSync(directory).filter((name) => name.startsWith(prefix)).sort();
35251
+ for (const name of backups.slice(0, Math.max(0, backups.length - BACKUPS_KEPT))) unlinkSync(join(directory, name));
35252
+ } catch {}
35379
35253
  }
35380
- /** Claims once; saved identity is local configuration, not proof of email readiness. */
35381
- async function connectAgent(params) {
35382
- const profileName = agentProfileName(params.profileName);
35383
- const invitation = parseAgentInvitation(params.invitation);
35384
- const invitationHash = createHash("sha256").update(invitation.apiBaseUrl).update("\0").update(invitation.token).digest("hex");
35385
- const directory = agentProfilesDirectory(params.configDir);
35386
- let release;
35387
- try {
35388
- privateMailDirectory(directory, true);
35389
- release = acquireListenLock(directory, "agent-connection-setup");
35390
- } catch {
35391
- throw new AgentConnectionSetupError("Agent setup is already running, or its private directory is unavailable. No invitation was submitted.");
35392
- }
35393
- try {
35394
- const existing = loadConnectedAgentProfile(params.configDir, profileName);
35395
- if (existing) {
35396
- if (existing.invitation_hash !== invitationHash) throw new AgentConnectionSetupError("This agent profile is already configured. Its identity and credentials were not changed. Use a separate profile for another invitation.");
35397
- return {
35398
- status: "already_configured",
35399
- identity: connectedAgentIdentity(profileName, existing)
35400
- };
35401
- }
35402
- privateMailDirectory(join(directory, "profiles"), true);
35403
- privateMailDirectory(agentProfileDirectory(params.configDir, profileName), true);
35404
- const journal = join(directory, "claims", `${invitationHash}.json`);
35405
- privateMailDirectory(join(directory, "claims"), true);
35406
- if (readMailJson(journal) !== null) throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
35407
- writeMailJson(journal, {
35408
- version: 1,
35409
- profile_name: profileName,
35410
- status: "attempted"
35411
- });
35254
+ /** Copy the current file beside itself before any change. Returns the copy's path. */
35255
+ function backupManagedFile(target, mode, now) {
35256
+ const base = `${target}${BACKUP_INFIX}${backupStamp$1(now)}`;
35257
+ for (let attempt = 0; attempt < 100; attempt++) {
35258
+ const candidate = attempt === 0 ? base : `${base}-${attempt}`;
35412
35259
  try {
35413
- const response = await (params.fetch ?? fetch)(`${invitation.apiBaseUrl}/agent-connections/claim`, {
35414
- method: "POST",
35415
- redirect: "error",
35416
- signal: AbortSignal.timeout(25e3),
35417
- headers: {
35418
- "content-type": "application/json",
35419
- accept: "application/json"
35260
+ copyFileSync(target, candidate, constants.COPYFILE_EXCL);
35261
+ chmodSync(candidate, mode);
35262
+ return candidate;
35263
+ } catch (error) {
35264
+ if (error.code !== "EEXIST") throw error;
35265
+ }
35266
+ }
35267
+ throw new MachineFileError(`Could not create a backup of ${target}.`);
35268
+ }
35269
+ /**
35270
+ * Replace a file atomically (temporary file plus rename in the same
35271
+ * directory), after backing up the existing content. The write is refused when
35272
+ * the file changed since it was read, so a concurrent edit is never lost.
35273
+ */
35274
+ function writeManagedFile(params) {
35275
+ const { read } = params;
35276
+ const target = read.target;
35277
+ const expected = read.state === "present" ? read.text : null;
35278
+ const directory = dirname(target);
35279
+ mkdirSync(directory, {
35280
+ recursive: true,
35281
+ mode: 448
35282
+ });
35283
+ const current = () => {
35284
+ try {
35285
+ return readFileSync(target, "utf8");
35286
+ } catch (error) {
35287
+ if (error.code === "ENOENT") return null;
35288
+ throw error;
35289
+ }
35290
+ };
35291
+ if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
35292
+ const mode = read.state === "present" ? read.mode : params.newFileMode ?? 420;
35293
+ const backup = read.state === "present" ? backupManagedFile(target, mode, (params.now ?? (() => /* @__PURE__ */ new Date()))()) : null;
35294
+ const temporary = join(directory, `.${basename(target)}.${randomUUID()}.tmp`);
35295
+ try {
35296
+ const fd = openSync(temporary, "wx", mode);
35297
+ try {
35298
+ writeFileSync(fd, params.content, "utf8");
35299
+ fsyncSync(fd);
35300
+ } finally {
35301
+ closeSync(fd);
35302
+ }
35303
+ chmodSync(temporary, mode);
35304
+ if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
35305
+ renameSync(temporary, target);
35306
+ syncDirectory(directory);
35307
+ } finally {
35308
+ if (existsSync(temporary)) unlinkSync(temporary);
35309
+ }
35310
+ if (backup) pruneBackups(target);
35311
+ return { backup };
35312
+ }
35313
+ const START = /^<!-- primitive:managed-block v=(\d+) START -->\r?$/;
35314
+ const END = /^<!-- primitive:managed-block END -->\r?$/;
35315
+ /** Character offsets of the single managed block, or why the markers are unusable. */
35316
+ function locateBlock(text) {
35317
+ const starts = [];
35318
+ const ends = [];
35319
+ let offset = 0;
35320
+ for (const line of text.split("\n")) {
35321
+ const start = START.exec(line);
35322
+ if (start) starts.push({
35323
+ offset,
35324
+ version: Number(start[1])
35325
+ });
35326
+ else if (END.test(line)) ends.push({
35327
+ offset,
35328
+ length: line.endsWith("\r") ? line.length - 1 : line.length
35329
+ });
35330
+ else if (line.includes("primitive:managed-block")) return { malformed: "a primitive:managed-block marker line was edited; fix or remove it by hand" };
35331
+ offset += line.length + 1;
35332
+ }
35333
+ if (starts.length === 0 && ends.length === 0) return null;
35334
+ if (starts.length !== 1 || ends.length !== 1) return { malformed: `found ${starts.length} START and ${ends.length} END markers; expected one of each` };
35335
+ const [start] = starts;
35336
+ const [end] = ends;
35337
+ if (!start || !end || end.offset < start.offset) return { malformed: "the END marker comes before the START marker" };
35338
+ return {
35339
+ start: start.offset,
35340
+ end: end.offset + end.length,
35341
+ version: start.version
35342
+ };
35343
+ }
35344
+ /** The exact block for a body, using the file's own line ending. */
35345
+ function renderManagedBlock(body, eol = "\n") {
35346
+ return [
35347
+ `<!-- primitive:managed-block v=1 START -->`,
35348
+ ...body.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n"),
35349
+ "<!-- primitive:managed-block END -->"
35350
+ ].join(eol);
35351
+ }
35352
+ function lineEnding(text) {
35353
+ return text.includes("\r\n") ? "\r\n" : "\n";
35354
+ }
35355
+ function inspectManagedBlock(text, body) {
35356
+ const found = locateBlock(text);
35357
+ if (found === null) return { state: "missing" };
35358
+ if ("malformed" in found) return {
35359
+ state: "malformed",
35360
+ detail: found.malformed
35361
+ };
35362
+ return text.slice(found.start, found.end).replace(/\r\n/g, "\n") === renderManagedBlock(body) ? { state: "ok" } : {
35363
+ state: "outdated",
35364
+ version: found.version
35365
+ };
35366
+ }
35367
+ /**
35368
+ * Replace only the managed block, or append it when absent. Text outside the
35369
+ * markers is preserved byte for byte. Throws for malformed markers.
35370
+ */
35371
+ function upsertManagedBlock(text, body) {
35372
+ const found = locateBlock(text);
35373
+ if (found !== null && "malformed" in found) throw new MachineFileError(found.malformed);
35374
+ const eol = lineEnding(text);
35375
+ const block = renderManagedBlock(body, eol);
35376
+ if (found) return `${text.slice(0, found.start)}${block}${text.slice(found.end)}`;
35377
+ if (text.length === 0) return `${block}${eol}`;
35378
+ return `${text}${text.endsWith(`${eol}${eol}`) ? "" : text.endsWith(eol) ? eol : `${eol}${eol}`}${block}${eol}`;
35379
+ }
35380
+ /** Indentation the file already uses, so a rewrite keeps its style. */
35381
+ function jsonIndentation(text) {
35382
+ return /^[{[][^\n]*\n([ \t]+)\S/.exec(text)?.[1] ?? 2;
35383
+ }
35384
+ //#endregion
35385
+ //#region src/oclif/claude-machine-hooks.ts
35386
+ function record$4(value) {
35387
+ return value !== null && typeof value === "object" && !Array.isArray(value);
35388
+ }
35389
+ function realpathOrNull(path) {
35390
+ try {
35391
+ return realpathSync(path);
35392
+ } catch {
35393
+ return null;
35394
+ }
35395
+ }
35396
+ /** Two paths name the same existing file. */
35397
+ function samePath(a, b) {
35398
+ if (typeof a !== "string" || !a) return false;
35399
+ if (a === b) return existsSync(a);
35400
+ const left = realpathOrNull(a);
35401
+ return left !== null && left === realpathOrNull(b);
35402
+ }
35403
+ function executable(path) {
35404
+ try {
35405
+ accessSync(path, constants.X_OK);
35406
+ return true;
35407
+ } catch {
35408
+ return false;
35409
+ }
35410
+ }
35411
+ /** The first PATH entry for a command, without running a shell. */
35412
+ function findOnPath(name, env) {
35413
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
35414
+ if (!directory) continue;
35415
+ const candidate = join(directory, name);
35416
+ if (existsSync(candidate) && executable(candidate)) return candidate;
35417
+ }
35418
+ return null;
35419
+ }
35420
+ /**
35421
+ * Prefer a PATH link (for example a package manager's `bin/node`) over a
35422
+ * versioned install directory when both are the same binary, so a hook keeps
35423
+ * working after the versioned directory is replaced by an upgrade.
35424
+ */
35425
+ function stableNodePath(env, execPath = process.execPath) {
35426
+ const onPath = findOnPath("node", env);
35427
+ if (onPath && onPath !== execPath && samePath(onPath, execPath)) return onPath;
35428
+ return execPath;
35429
+ }
35430
+ /** Locate the running CLI. Null when its files are not where a package install puts them. */
35431
+ function currentCliLocation(entryArg, env, execPath = process.execPath) {
35432
+ const entry = realpathOrNull(entryArg);
35433
+ if (!entry) return null;
35434
+ const wake = realpathOrNull(join(dirname(entry), "claude-wake.mjs"));
35435
+ const pending = realpathOrNull(join(dirname(entry), "claude-pending-mail.mjs"));
35436
+ if (!wake || !pending) return null;
35437
+ return {
35438
+ node: stableNodePath(env, execPath),
35439
+ entry,
35440
+ wake,
35441
+ pending
35442
+ };
35443
+ }
35444
+ /** Package-runner caches are cleaned up behind the user's back. */
35445
+ function ephemeralCliReason(entry) {
35446
+ if (/[\\/]_npx[\\/]/.test(entry)) return "an npx cache";
35447
+ if (/[\\/]dlx[-\\/]/.test(entry)) return "a pnpm dlx cache";
35448
+ if (/[\\/]bunx-/.test(entry)) return "a bunx cache";
35449
+ return null;
35450
+ }
35451
+ function claudeConfigDir(env, home) {
35452
+ return resolve(env.CLAUDE_CONFIG_DIR || join(home, ".claude"));
35453
+ }
35454
+ function jsonErrorDetail(error) {
35455
+ const message = error instanceof Error ? error.message : "";
35456
+ const position = /position (\d+)/.exec(message)?.[1];
35457
+ const line = /line (\d+)/.exec(message)?.[1];
35458
+ return line ? ` near line ${line}` : position ? ` near character ${position}` : "";
35459
+ }
35460
+ /** Parse settings.json. Anything unexpected is reported, never repaired. */
35461
+ function readClaudeSettings(claudeDir) {
35462
+ const path = join(claudeDir, "settings.json");
35463
+ const read = readManagedFile(path);
35464
+ if (read.state === "invalid") return {
35465
+ ok: false,
35466
+ detail: read.detail
35467
+ };
35468
+ if (read.state === "absent") return {
35469
+ ok: true,
35470
+ read,
35471
+ settings: {}
35472
+ };
35473
+ let parsed;
35474
+ try {
35475
+ parsed = JSON.parse(read.text);
35476
+ } catch (error) {
35477
+ return {
35478
+ ok: false,
35479
+ detail: `${path} is not valid JSON${jsonErrorDetail(error)}. Fix it by hand; it was not modified.`
35480
+ };
35481
+ }
35482
+ if (!record$4(parsed)) return {
35483
+ ok: false,
35484
+ detail: `${path} must contain a JSON object. Fix it by hand; it was not modified.`
35485
+ };
35486
+ if (parsed.hooks !== void 0 && !record$4(parsed.hooks)) return {
35487
+ ok: false,
35488
+ detail: `${path} has a "hooks" value that is not an object. Fix it by hand; it was not modified.`
35489
+ };
35490
+ return {
35491
+ ok: true,
35492
+ read,
35493
+ settings: parsed
35494
+ };
35495
+ }
35496
+ const LOCK_WAIT$1 = new Int32Array(new SharedArrayBuffer(4));
35497
+ /**
35498
+ * Write settings under the same lock every Primitive hook installer uses,
35499
+ * keeping the file's indentation, after a timestamped backup.
35500
+ */
35501
+ function writeClaudeSettings(params) {
35502
+ let release;
35503
+ for (let attempt = 0; attempt < 80; attempt++) try {
35504
+ release = acquireListenLock(params.claudeDir, "primitive-claude-settings");
35505
+ break;
35506
+ } catch {
35507
+ Atomics.wait(LOCK_WAIT$1, 0, 0, 25);
35508
+ }
35509
+ if (!release) throw new MachineFileError("Another Primitive command is editing Claude settings; try again shortly.");
35510
+ try {
35511
+ const original = params.read.state === "present" ? params.read.text : null;
35512
+ const indent = original === null ? 2 : jsonIndentation(original);
35513
+ const trailing = original === null || original.endsWith("\n") ? "\n" : "";
35514
+ return writeManagedFile({
35515
+ read: params.read,
35516
+ content: `${JSON.stringify(params.settings, null, indent)}${trailing}`,
35517
+ now: params.now,
35518
+ newFileMode: 384
35519
+ });
35520
+ } finally {
35521
+ release();
35522
+ }
35523
+ }
35524
+ const GLOBAL_HOOKS = {
35525
+ "claude.hook.session_start": {
35526
+ event: "SessionStart",
35527
+ subcommand: "session-register",
35528
+ timeout: 15
35529
+ },
35530
+ "claude.hook.session_end": {
35531
+ event: "SessionEnd",
35532
+ subcommand: "session-end",
35533
+ timeout: 10
35534
+ }
35535
+ };
35536
+ /** A hook this CLI installed for every Claude session, from any CLI path or version. */
35537
+ function isGlobalManaged(hook, spec) {
35538
+ if (!record$4(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return false;
35539
+ const args = hook.args;
35540
+ return args.length === 6 && typeof args[0] === "string" && args[1] === "agent" && args[2] === spec.subcommand && args[3] === "--runtime" && args[4] === "claude" && args[5] === "--hook";
35541
+ }
35542
+ function globalHookArgs(cli, spec) {
35543
+ return [
35544
+ cli.entry,
35545
+ "agent",
35546
+ spec.subcommand,
35547
+ "--runtime",
35548
+ "claude",
35549
+ "--hook"
35550
+ ];
35551
+ }
35552
+ function globalHookCurrent(hook, cli) {
35553
+ const args = hook.args;
35554
+ return samePath(hook.command, cli.node) && samePath(args[0], cli.entry);
35555
+ }
35556
+ const WAKE_MARKER = "primitive-agent-wake-v1";
35557
+ const PENDING_MARKER$1 = "primitive-pending-mail-v1";
35558
+ const SESSION_EVENTS = [
35559
+ "Stop",
35560
+ "SessionStart",
35561
+ "PostToolUse"
35562
+ ];
35563
+ /** Which receive hook each event carries. */
35564
+ const SESSION_HOOK_KIND = {
35565
+ Stop: "wake",
35566
+ SessionStart: "wake",
35567
+ PostToolUse: "pending"
35568
+ };
35569
+ /**
35570
+ * The one test of whether an event already carries a current receive hook
35571
+ * for this exact profile, address and session, run by the Node binary at
35572
+ * `node` (compared as files). `agent connect --status` and machine doctor
35573
+ * both use it, so they never disagree about a hook being present.
35574
+ */
35575
+ function hasSessionReceiveHook(hooks, event, target, node) {
35576
+ const entries = hooks[event];
35577
+ if (!Array.isArray(entries)) return false;
35578
+ const pending = SESSION_HOOK_KIND[event] === "pending";
35579
+ const configDir = resolve(target.configDir);
35580
+ const session = target.session.toLowerCase();
35581
+ return entries.some((entry) => record$4(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
35582
+ if (!record$4(hook) || hook.type !== "command") return false;
35583
+ const args = hook.args;
35584
+ return Array.isArray(args) && args.length === 7 && args.every((value) => typeof value === "string") && basename(args[0]) === (pending ? "claude-pending-mail.mjs" : "claude-wake.mjs") && resolve(args[2]) === configDir && args[3] === target.profile && args[4] === target.address && args[5].toLowerCase() === session && args[6] === (pending ? PENDING_MARKER$1 : WAKE_MARKER) && samePath(hook.command, node);
35585
+ }));
35586
+ }
35587
+ /** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
35588
+ function parseSessionHook(hook) {
35589
+ if (!record$4(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return null;
35590
+ const args = hook.args;
35591
+ if (!args.every((value) => typeof value === "string")) return null;
35592
+ const strings = args;
35593
+ const script = basename(strings[0] ?? "");
35594
+ if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER$1 ? {
35595
+ kind: "pending",
35596
+ configDir: strings[2] ?? "",
35597
+ profile: strings[3] ?? null,
35598
+ address: strings[4] ?? null,
35599
+ session: strings[5] ?? null
35600
+ } : null;
35601
+ if (script !== "claude-wake.mjs") return null;
35602
+ if (strings.length === 7 && strings[6] === WAKE_MARKER) return {
35603
+ kind: "wake",
35604
+ configDir: strings[2] ?? "",
35605
+ profile: strings[3] ?? null,
35606
+ address: strings[4] ?? null,
35607
+ session: strings[5] ?? null
35608
+ };
35609
+ if (strings.length === 6 && strings[5] === WAKE_MARKER) return {
35610
+ kind: "wake",
35611
+ configDir: strings[2] ?? "",
35612
+ profile: strings[3] ?? null,
35613
+ address: strings[4] ?? null,
35614
+ session: null
35615
+ };
35616
+ if (strings.length === 4 && strings[3] === WAKE_MARKER) return {
35617
+ kind: "wake",
35618
+ configDir: strings[2] ?? "",
35619
+ profile: null,
35620
+ address: null,
35621
+ session: null
35622
+ };
35623
+ return null;
35624
+ }
35625
+ /** A saved connected profile still holds a credential. Unreadable counts as live. */
35626
+ function profileStillConnected(configDir, profile) {
35627
+ try {
35628
+ return loadConnectedAgentProfile(configDir, profile) !== null;
35629
+ } catch {
35630
+ return true;
35631
+ }
35632
+ }
35633
+ function sessionHookPathsCurrent(hook, parsed, cli) {
35634
+ const args = hook.args;
35635
+ if (!cli) return existsSync(String(hook.command)) && existsSync(args[0] ?? "") && existsSync(args[1] ?? "");
35636
+ return samePath(hook.command, cli.node) && samePath(args[0], parsed.kind === "wake" ? cli.wake : cli.pending) && samePath(args[1], cli.entry);
35637
+ }
35638
+ function judgeSessionHooks(hooks, cli, bound = []) {
35639
+ const verdicts = /* @__PURE__ */ new Map();
35640
+ const seen = /* @__PURE__ */ new Set();
35641
+ for (const event of SESSION_EVENTS) {
35642
+ const entries = hooks[event];
35643
+ if (!Array.isArray(entries)) continue;
35644
+ for (const entry of entries) {
35645
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) continue;
35646
+ for (const hook of entry.hooks) {
35647
+ const parsed = parseSessionHook(hook);
35648
+ if (!parsed || !record$4(hook)) continue;
35649
+ const pathsExist = existsSync(String(hook.command)) && existsSync(String(hook.args[0])) && existsSync(String(hook.args[1]));
35650
+ if (!(parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
35651
+ verdicts.set(hook, "stale");
35652
+ continue;
35653
+ }
35654
+ const current = bound.find((item) => item.configDir === resolve(parsed.configDir) && item.profile === parsed.profile && item.session === parsed.session?.toLowerCase());
35655
+ if (current && parsed.address !== current.address) {
35656
+ verdicts.set(hook, "old_address");
35657
+ continue;
35658
+ }
35659
+ const key = [
35660
+ event,
35661
+ parsed.kind,
35662
+ resolve(parsed.configDir),
35663
+ parsed.profile ?? "",
35664
+ parsed.session?.toLowerCase() ?? ""
35665
+ ].join("\0");
35666
+ if (seen.has(key)) {
35667
+ verdicts.set(hook, "duplicate");
35668
+ continue;
35669
+ }
35670
+ seen.add(key);
35671
+ verdicts.set(hook, sessionHookPathsCurrent(hook, parsed, cli) ? "keep" : "outdated");
35672
+ }
35673
+ }
35674
+ }
35675
+ return verdicts;
35676
+ }
35677
+ function eventEntries(hooks, event) {
35678
+ const entries = hooks[event];
35679
+ if (entries === void 0) return [];
35680
+ return Array.isArray(entries) ? entries : "invalid";
35681
+ }
35682
+ /**
35683
+ * Connected profiles whose setup binds an external (hook) receiver to a
35684
+ * Claude session that this machine has not recorded as ended.
35685
+ */
35686
+ function boundSessionProfiles(configDir) {
35687
+ const root = resolve(configDir);
35688
+ let names;
35689
+ try {
35690
+ names = readdirSync(join(agentProfilesDirectory(root), "profiles")).sort();
35691
+ } catch {
35692
+ return [];
35693
+ }
35694
+ const bound = [];
35695
+ for (const name of names) try {
35696
+ agentProfileName(name);
35697
+ const setup = readMailJson(join(agentProfileDirectory(root, name), "setup.json"));
35698
+ if (setup?.receiverMode !== "external" || typeof setup.session !== "string" || !SESSION_UUID$1.test(setup.session)) continue;
35699
+ const session = setup.session.toLowerCase();
35700
+ if (typeof readMailJson(join(root, "machine", "sessions", `${session}.json`))?.endedAt === "string") continue;
35701
+ const profile = loadConnectedAgentProfile(root, name);
35702
+ if (profile) bound.push({
35703
+ configDir: root,
35704
+ profile: name,
35705
+ address: profile.agent_address,
35706
+ session
35707
+ });
35708
+ } catch {}
35709
+ return bound;
35710
+ }
35711
+ /** Receive hooks each bound profile should have and does not. */
35712
+ function missingSessionHooks(hooks, bound, node) {
35713
+ return bound.flatMap((item) => SESSION_EVENTS.filter((event) => !hasSessionReceiveHook(hooks, event, item, node)).map((event) => ({
35714
+ event,
35715
+ bound: item
35716
+ })));
35717
+ }
35718
+ /** The exact entry `agent connect` and `session-register` install. */
35719
+ function sessionHookEntry(event, item, cli) {
35720
+ const pending = SESSION_HOOK_KIND[event] === "pending";
35721
+ const hook = {
35722
+ type: "command",
35723
+ command: cli.node,
35724
+ args: [
35725
+ pending ? cli.pending : cli.wake,
35726
+ cli.entry,
35727
+ item.configDir,
35728
+ item.profile,
35729
+ item.address,
35730
+ item.session,
35731
+ pending ? PENDING_MARKER$1 : WAKE_MARKER
35732
+ ],
35733
+ ...pending ? { timeout: 10 } : {
35734
+ asyncRewake: true,
35735
+ timeout: 604800
35736
+ }
35737
+ };
35738
+ return event === "SessionStart" ? {
35739
+ matcher: "resume",
35740
+ hooks: [hook]
35741
+ } : { hooks: [hook] };
35742
+ }
35743
+ /** Inspect every Primitive-owned Claude hook without changing anything. */
35744
+ function inspectClaudeHooks(settings, context) {
35745
+ const hooks = record$4(settings.hooks) ? settings.hooks : {};
35746
+ const canFix = context.cli !== null && context.blocked === null;
35747
+ const blockedNote = context.blocked ? ` ${context.blocked}` : "";
35748
+ const findings = {};
35749
+ for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
35750
+ const entries = eventEntries(hooks, spec.event);
35751
+ if (entries === "invalid") {
35752
+ findings[id] = {
35753
+ status: "fail",
35754
+ detail: `hooks.${spec.event} is not a list. Fix settings.json by hand; it was not modified.`,
35755
+ fixable: false
35756
+ };
35757
+ continue;
35758
+ }
35759
+ const managed = entries.flatMap((entry) => record$4(entry) && Array.isArray(entry.hooks) ? entry.hooks.filter((hook) => isGlobalManaged(hook, spec)) : []);
35760
+ const [first] = managed;
35761
+ if (!first) findings[id] = {
35762
+ status: "fail",
35763
+ detail: `No ${spec.event} hook runs \`primitive agent ${spec.subcommand}\`.${blockedNote}`,
35764
+ fixable: canFix
35765
+ };
35766
+ else if (managed.length > 1) findings[id] = {
35767
+ status: "fail",
35768
+ detail: `${managed.length} ${spec.event} hooks run \`primitive agent ${spec.subcommand}\`; expected exactly one.${blockedNote}`,
35769
+ fixable: canFix
35770
+ };
35771
+ else if (!context.cli || !globalHookCurrent(first, context.cli)) findings[id] = {
35772
+ status: "fail",
35773
+ detail: `The ${spec.event} hook runs ${String(first.args[0])}, which ${existsSync(String(first.args[0])) ? "is not this CLI" : "no longer exists"}.${blockedNote}`,
35774
+ fixable: canFix
35775
+ };
35776
+ else findings[id] = {
35777
+ status: "ok",
35778
+ detail: `${spec.event} runs \`primitive agent ${spec.subcommand}\` from ${context.cli.entry}.`,
35779
+ fixable: false
35780
+ };
35781
+ }
35782
+ const invalid = SESSION_EVENTS.find((event) => eventEntries(hooks, event) === "invalid");
35783
+ if (invalid) {
35784
+ findings["claude.hook.stop"] = {
35785
+ status: "fail",
35786
+ detail: `hooks.${invalid} is not a list. Fix settings.json by hand; it was not modified.`,
35787
+ fixable: false
35788
+ };
35789
+ return findings;
35790
+ }
35791
+ const bound = context.bound ?? [];
35792
+ const counts = {
35793
+ keep: 0,
35794
+ stale: 0,
35795
+ duplicate: 0,
35796
+ outdated: 0,
35797
+ old_address: 0
35798
+ };
35799
+ for (const verdict of judgeSessionHooks(hooks, context.cli, bound).values()) counts[verdict]++;
35800
+ const missing = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath).length;
35801
+ const problems = [
35802
+ missing ? `${missing} missing for connected sessions` : "",
35803
+ counts.old_address ? `${counts.old_address} naming an old agent address` : "",
35804
+ counts.stale ? `${counts.stale} for disconnected or removed sessions` : "",
35805
+ counts.duplicate ? `${counts.duplicate} duplicates` : "",
35806
+ counts.outdated ? `${counts.outdated} pointing at an old CLI path` : ""
35807
+ ].filter(Boolean);
35808
+ findings["claude.hook.stop"] = problems.length ? {
35809
+ status: "fail",
35810
+ detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}.${blockedNote}`,
35811
+ fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null
35812
+ } : {
35813
+ status: "ok",
35814
+ detail: counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks.",
35815
+ fixable: false
35816
+ };
35817
+ return findings;
35818
+ }
35819
+ function rewriteEntries(entries, transform) {
35820
+ let changed = false;
35821
+ return {
35822
+ entries: entries.flatMap((entry) => {
35823
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35824
+ let touched = false;
35825
+ const kept = entry.hooks.flatMap((hook) => {
35826
+ const replacement = transform(hook);
35827
+ if (replacement === hook) return [hook];
35828
+ touched = true;
35829
+ return replacement === void 0 ? [] : [replacement];
35830
+ });
35831
+ if (!touched) return [entry];
35832
+ changed = true;
35833
+ return kept.length ? [{
35834
+ ...entry,
35835
+ hooks: kept
35836
+ }] : [];
35837
+ }),
35838
+ changed
35839
+ };
35840
+ }
35841
+ /**
35842
+ * Apply fixes for the requested checks to a copy of the settings. Only
35843
+ * Primitive-owned hooks are added, rewritten or removed; every other key,
35844
+ * entry and sibling hook is carried over unchanged.
35845
+ */
35846
+ function repairClaudeHooks(settings, context, checks) {
35847
+ const changed = /* @__PURE__ */ new Set();
35848
+ const hooks = record$4(settings.hooks) ? { ...settings.hooks } : {};
35849
+ const cli = context.blocked === null ? context.cli : null;
35850
+ for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
35851
+ if (!checks.has(id) || !cli) continue;
35852
+ const entries = eventEntries(hooks, spec.event);
35853
+ if (entries === "invalid") continue;
35854
+ let placed = false;
35855
+ const result = rewriteEntries(entries, (hook) => {
35856
+ if (!isGlobalManaged(hook, spec)) return hook;
35857
+ if (placed) return void 0;
35858
+ placed = true;
35859
+ if (globalHookCurrent(hook, cli)) return hook;
35860
+ return {
35861
+ ...hook,
35862
+ command: cli.node,
35863
+ args: globalHookArgs(cli, spec)
35864
+ };
35865
+ });
35866
+ let next = result.entries;
35867
+ if (!placed) next = [...next, { hooks: [{
35868
+ type: "command",
35869
+ command: cli.node,
35870
+ args: globalHookArgs(cli, spec),
35871
+ timeout: spec.timeout
35872
+ }] }];
35873
+ if (result.changed || !placed) {
35874
+ hooks[spec.event] = next;
35875
+ changed.add(id);
35876
+ }
35877
+ }
35878
+ if (checks.has("claude.hook.stop") && context.blocked === null) {
35879
+ if (!SESSION_EVENTS.some((event) => eventEntries(hooks, event) === "invalid")) {
35880
+ const bound = context.bound ?? [];
35881
+ const verdicts = judgeSessionHooks(hooks, context.cli, bound);
35882
+ for (const event of SESSION_EVENTS) {
35883
+ const entries = eventEntries(hooks, event);
35884
+ if (entries === "invalid" || entries.length === 0) continue;
35885
+ const result = rewriteEntries(entries, (hook) => {
35886
+ const verdict = verdicts.get(hook);
35887
+ if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") return void 0;
35888
+ if (verdict !== "outdated" || !cli || !record$4(hook)) return hook;
35889
+ const parsed = parseSessionHook(hook);
35890
+ const args = [...hook.args];
35891
+ args[0] = parsed?.kind === "pending" ? cli.pending : cli.wake;
35892
+ args[1] = cli.entry;
35893
+ return {
35894
+ ...hook,
35895
+ command: cli.node,
35896
+ args
35897
+ };
35898
+ });
35899
+ if (result.changed) {
35900
+ hooks[event] = result.entries;
35901
+ changed.add("claude.hook.stop");
35902
+ }
35903
+ }
35904
+ if (cli) for (const missing of missingSessionHooks(hooks, bound, cli.node)) {
35905
+ const entries = eventEntries(hooks, missing.event);
35906
+ if (entries === "invalid") continue;
35907
+ hooks[missing.event] = [...entries, sessionHookEntry(missing.event, missing.bound, cli)];
35908
+ changed.add("claude.hook.stop");
35909
+ }
35910
+ }
35911
+ }
35912
+ if (!changed.size) return {
35913
+ settings,
35914
+ changed
35915
+ };
35916
+ return {
35917
+ settings: {
35918
+ ...settings,
35919
+ hooks
35920
+ },
35921
+ changed
35922
+ };
35923
+ }
35924
+ //#endregion
35925
+ //#region src/oclif/claude-wake-install.ts
35926
+ const HOOK_MARKER = "primitive-agent-wake-v1";
35927
+ const WAKE_EVENTS = ["Stop", "SessionStart"];
35928
+ const PENDING_MARKER = "primitive-pending-mail-v1";
35929
+ const PENDING_EVENT = "PostToolUse";
35930
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
35931
+ function record$3(value) {
35932
+ return value !== null && typeof value === "object" && !Array.isArray(value);
35933
+ }
35934
+ function claudeWakeHookStatus(options) {
35935
+ const unavailable = {
35936
+ installed: false,
35937
+ lastFiredAt: null,
35938
+ liveness: "unknown"
35939
+ };
35940
+ try {
35941
+ const configDir = resolve(options.configDir);
35942
+ const profileName = agentProfileName(options.profileName);
35943
+ const agentAddress = mailAddress(options.agentAddress);
35944
+ if (!SESSION_UUID$1.test(options.sessionId)) return unavailable;
35945
+ const sessionId = options.sessionId.toLowerCase();
35946
+ const read = readManagedFile(join(resolve(options.env?.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), "settings.json"));
35947
+ if (read.state !== "present") return unavailable;
35948
+ const settings = JSON.parse(read.text);
35949
+ if (!record$3(settings) || !record$3(settings.hooks)) return unavailable;
35950
+ const hooks = settings.hooks;
35951
+ const target = {
35952
+ configDir,
35953
+ profile: profileName,
35954
+ address: agentAddress,
35955
+ session: sessionId
35956
+ };
35957
+ const installed = [
35958
+ "Stop",
35959
+ "SessionStart",
35960
+ "PostToolUse"
35961
+ ].every((event) => hasSessionReceiveHook(hooks, event, target, process.execPath));
35962
+ let lastFiredAt = null;
35963
+ const fired = readMailJson(join(agentProfileDirectory(configDir, profileName), `pending-mail-${sessionId}.fired.json`));
35964
+ if (record$3(fired) && fired.version === 1 && typeof fired.at === "string" && Number.isFinite(Date.parse(fired.at))) lastFiredAt = fired.at;
35965
+ return {
35966
+ installed,
35967
+ lastFiredAt,
35968
+ liveness: "unknown"
35969
+ };
35970
+ } catch {
35971
+ return unavailable;
35972
+ }
35973
+ }
35974
+ /**
35975
+ * Apply one edit to Claude's settings under the shared lock, backing up the
35976
+ * previous file. Exported for tests.
35977
+ */
35978
+ function editClaudeSettings(claudeDir, edit) {
35979
+ mkdirSync(claudeDir, {
35980
+ recursive: true,
35981
+ mode: 448
35982
+ });
35983
+ let release;
35984
+ for (let attempt = 0; attempt < 40; attempt++) try {
35985
+ release = acquireListenLock(claudeDir, "primitive-claude-settings");
35986
+ break;
35987
+ } catch {
35988
+ Atomics.wait(LOCK_WAIT, 0, 0, 25);
35989
+ }
35990
+ if (!release) return false;
35991
+ try {
35992
+ const settingsPath = join(claudeDir, "settings.json");
35993
+ for (let attempt = 0; attempt < 3; attempt++) {
35994
+ const read = readManagedFile(settingsPath);
35995
+ if (read.state === "invalid") return false;
35996
+ let settings = {};
35997
+ if (read.state === "present") {
35998
+ const parsed = JSON.parse(read.text);
35999
+ if (!record$3(parsed)) return false;
36000
+ settings = parsed;
36001
+ }
36002
+ const next = edit(settings);
36003
+ if (next === null) return true;
36004
+ const content = `${JSON.stringify(next, null, 2)}\n`;
36005
+ if (read.state === "present" && content === read.text) return true;
36006
+ try {
36007
+ writeManagedFile({
36008
+ read,
36009
+ content,
36010
+ newFileMode: 384
36011
+ });
36012
+ return true;
36013
+ } catch (error) {
36014
+ if (!(error instanceof MachineFileError)) throw error;
36015
+ }
36016
+ }
36017
+ return false;
36018
+ } finally {
36019
+ release();
36020
+ }
36021
+ }
36022
+ /** Add exact-session fail-open receiving hooks while preserving unrelated settings. */
36023
+ function installClaudeWakeHook(options) {
36024
+ try {
36025
+ const cliPath = realpathSync(options.cliPath);
36026
+ const wrapperPath = realpathSync(join(dirname(cliPath), "claude-wake.mjs"));
36027
+ const pendingPath = realpathSync(join(dirname(cliPath), "claude-pending-mail.mjs"));
36028
+ const configDir = resolve(options.configDir);
36029
+ const profileName = agentProfileName(options.profileName);
36030
+ const agentAddress = mailAddress(options.agentAddress);
36031
+ if (!SESSION_UUID$1.test(options.sessionId)) return "unavailable";
36032
+ const sessionId = options.sessionId.toLowerCase();
36033
+ return editClaudeSettings(resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), (settings) => {
36034
+ const hooks = settings.hooks ?? {};
36035
+ if (!record$3(hooks)) throw new Error("Invalid hook settings");
36036
+ for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
36037
+ if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
36038
+ const hook = {
36039
+ type: "command",
36040
+ command: process.execPath,
36041
+ args: [
36042
+ wrapperPath,
36043
+ cliPath,
36044
+ configDir,
36045
+ profileName,
36046
+ agentAddress,
36047
+ sessionId,
36048
+ HOOK_MARKER
36049
+ ],
36050
+ asyncRewake: true,
36051
+ timeout: 604800
36052
+ };
36053
+ const pendingHook = {
36054
+ type: "command",
36055
+ command: process.execPath,
36056
+ args: [
36057
+ pendingPath,
36058
+ cliPath,
36059
+ configDir,
36060
+ profileName,
36061
+ agentAddress,
36062
+ sessionId,
36063
+ PENDING_MARKER
36064
+ ],
36065
+ timeout: 10
36066
+ };
36067
+ const isOwnPendingHook = (candidate) => record$3(candidate) && candidate.type === "command" && Array.isArray(candidate.args) && candidate.args.length === 7 && typeof candidate.args[0] === "string" && basename(candidate.args[0]) === "claude-pending-mail.mjs" && candidate.args[2] === configDir && candidate.args[3] === profileName && candidate.args[5] === sessionId && candidate.args[6] === PENDING_MARKER;
36068
+ const isOwnHook = (candidate) => {
36069
+ if (!record$3(candidate) || !Array.isArray(candidate.args)) return false;
36070
+ if (typeof candidate.args[0] !== "string" || basename(candidate.args[0]) !== "claude-wake.mjs" || candidate.args[2] !== configDir || candidate.args[3] !== profileName) return false;
36071
+ if (candidate.args.length === 7 && candidate.args[5] === sessionId && candidate.args[6] === HOOK_MARKER) return true;
36072
+ if (candidate.args.length !== 6 || candidate.args[5] !== HOOK_MARKER) return false;
36073
+ try {
36074
+ const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
36075
+ return record$3(state) && state.session === sessionId;
36076
+ } catch {
36077
+ return false;
36078
+ }
36079
+ };
36080
+ const nextHooks = { ...hooks };
36081
+ for (const event of WAKE_EVENTS) nextHooks[event] = [...(hooks[event] ?? []).flatMap((entry) => {
36082
+ if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36083
+ const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
36084
+ if (siblings.length === entry.hooks.length) return [entry];
36085
+ return siblings.length ? [{
36086
+ ...entry,
36087
+ hooks: siblings
36088
+ }] : [];
36089
+ }), event === "SessionStart" ? {
36090
+ matcher: "resume",
36091
+ hooks: [hook]
36092
+ } : { hooks: [hook] }];
36093
+ nextHooks[PENDING_EVENT] = [...(hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
36094
+ if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36095
+ const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
36096
+ if (siblings.length === entry.hooks.length) return [entry];
36097
+ return siblings.length ? [{
36098
+ ...entry,
36099
+ hooks: siblings
36100
+ }] : [];
36101
+ }), { hooks: [pendingHook] }];
36102
+ return {
36103
+ ...settings,
36104
+ hooks: nextHooks
36105
+ };
36106
+ }) ? "installed_unverified" : "unavailable";
36107
+ } catch {
36108
+ return "unavailable";
36109
+ }
36110
+ }
36111
+ /** Remove only this profile's exact-session hooks after disconnection. */
36112
+ function uninstallClaudeWakeHook(options) {
36113
+ try {
36114
+ const configDir = resolve(options.configDir);
36115
+ const profileName = agentProfileName(options.profileName);
36116
+ const agentAddress = mailAddress(options.agentAddress);
36117
+ if (!SESSION_UUID$1.test(options.sessionId)) return false;
36118
+ const sessionId = options.sessionId.toLowerCase();
36119
+ const claudeDir = resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
36120
+ if (!existsSync(join(claudeDir, "settings.json"))) return true;
36121
+ return editClaudeSettings(claudeDir, (settings) => {
36122
+ const hooks = settings.hooks ?? {};
36123
+ if (!record$3(hooks)) throw new Error("Invalid hook settings");
36124
+ for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
36125
+ if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
36126
+ const legacySessionMatches = () => {
36127
+ try {
36128
+ const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
36129
+ return record$3(setup) && setup.session === sessionId;
36130
+ } catch {
36131
+ return false;
36132
+ }
36133
+ };
36134
+ const isOwnHook = (candidate) => {
36135
+ if (!record$3(candidate) || !Array.isArray(candidate.args)) return false;
36136
+ const args = candidate.args;
36137
+ return candidate.type === "command" && typeof candidate.command === "string" && candidate.asyncRewake === true && typeof args[0] === "string" && basename(args[0]) === "claude-wake.mjs" && typeof args[1] === "string" && args[2] === configDir && args[3] === profileName && args[4] === agentAddress && (args.length === 7 && args[5] === sessionId && args[6] === HOOK_MARKER || args.length === 6 && args[5] === HOOK_MARKER && legacySessionMatches());
36138
+ };
36139
+ const isOwnPendingHook = (candidate) => {
36140
+ if (!record$3(candidate) || !Array.isArray(candidate.args)) return false;
36141
+ const args = candidate.args;
36142
+ return candidate.type === "command" && typeof candidate.command === "string" && typeof args[0] === "string" && basename(args[0]) === "claude-pending-mail.mjs" && args[2] === configDir && args[3] === profileName && args[4] === agentAddress && args[5] === sessionId && args[6] === PENDING_MARKER && args.length === 7;
36143
+ };
36144
+ let removed = false;
36145
+ const nextHooks = { ...hooks };
36146
+ for (const event of WAKE_EVENTS) nextHooks[event] = (hooks[event] ?? []).flatMap((entry) => {
36147
+ if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36148
+ const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
36149
+ if (siblings.length === entry.hooks.length) return [entry];
36150
+ removed = true;
36151
+ return siblings.length ? [{
36152
+ ...entry,
36153
+ hooks: siblings
36154
+ }] : [];
36155
+ });
36156
+ nextHooks[PENDING_EVENT] = (hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
36157
+ if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36158
+ const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
36159
+ if (siblings.length === entry.hooks.length) return [entry];
36160
+ removed = true;
36161
+ return siblings.length ? [{
36162
+ ...entry,
36163
+ hooks: siblings
36164
+ }] : [];
36165
+ });
36166
+ return removed ? {
36167
+ ...settings,
36168
+ hooks: nextHooks
36169
+ } : null;
36170
+ });
36171
+ } catch {
36172
+ return false;
36173
+ }
36174
+ }
36175
+ //#endregion
36176
+ //#region src/oclif/agent-connect.ts
36177
+ const MAX_INVITATION_BYTES = 4096;
36178
+ const MAX_RESPONSE_BYTES$2 = 32768;
36179
+ const UNCERTAIN_CLAIM = "Setup did not complete safely. The invitation may have been consumed. Request a fresh invitation from the owner; do not retry this invitation.";
36180
+ /** Offline metadata only; a saved profile is not proof of current API or listener readiness. */
36181
+ function agentConnectionStatus(configDir, profileName, env = process.env) {
36182
+ agentProfileName(profileName);
36183
+ const profile = loadConnectedAgentProfile(configDir, profileName);
36184
+ const setup = profile ? readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json")) : null;
36185
+ const saved = setup && typeof setup === "object" && !Array.isArray(setup) ? setup : null;
36186
+ const session = saved && typeof saved.session === "string" && SESSION_UUID$1.test(saved.session) ? saved.session : null;
36187
+ const mode = saved?.receiverMode === "external" || saved?.receiverMode === "poll" ? saved.receiverMode : "native";
36188
+ const receiving = profile && saved && mode === "poll" ? {
36189
+ mode,
36190
+ sessionId: session,
36191
+ state: "poll",
36192
+ reason: "agent_checks_mail",
36193
+ lastSuccessfulMailCheckAt: null,
36194
+ liveness: "unknown"
36195
+ } : profile && session && mode === "native" ? (() => {
36196
+ const scope = notificationScope(profile.api_base_url, profile.api_key);
36197
+ const listener = backgroundListenStatus({
36198
+ configDir,
36199
+ scope,
36200
+ threadId: session
36201
+ });
36202
+ const mailOwner = readSharedMailOwner({ directory: join(configDir, "shared-mail", createHash("sha256").update(scope).digest("hex")) });
36203
+ return {
36204
+ mode,
36205
+ sessionId: session,
36206
+ state: listener.healthy ? listener.phase === "receiving" ? "running" : "degraded" : listener.reason === "restarting" ? "degraded" : listener.reason === "absent" ? "unknown" : "down",
36207
+ reason: listener.reason ?? listener.failureCode,
36208
+ failureCode: listener.failureCode,
36209
+ detail: listener.detail ?? null,
36210
+ lastSuccessfulMailCheckAt: mailOwner?.lastMailCheckAt ?? null,
36211
+ liveness: listener.healthy && listener.phase === "receiving" ? "live" : "unknown",
36212
+ listener
36213
+ };
36214
+ })() : profile && session ? (() => {
36215
+ const hook = claudeWakeHookStatus({
36216
+ configDir,
36217
+ profileName,
36218
+ agentAddress: profile.agent_address,
36219
+ sessionId: session,
36220
+ env
36221
+ });
36222
+ return {
36223
+ mode,
36224
+ sessionId: session,
36225
+ state: hook.installed ? "unknown" : "down",
36226
+ reason: hook.installed ? "hook_liveness_unverified" : "hook_missing",
36227
+ detail: hook.installed ? null : "The receive hook for this session is missing, so mail will not wake this session. Run `primitive machine doctor --fix` to reinstall it.",
36228
+ lastSuccessfulMailCheckAt: null,
36229
+ liveness: "unknown",
36230
+ hook
36231
+ };
36232
+ })() : {
36233
+ mode: "unknown",
36234
+ sessionId: null,
36235
+ state: "unknown",
36236
+ reason: "session_not_configured",
36237
+ lastSuccessfulMailCheckAt: null,
36238
+ liveness: "unknown"
36239
+ };
36240
+ return profile ? {
36241
+ status: "configured",
36242
+ identity: connectedAgentIdentity(profileName, profile),
36243
+ receiving
36244
+ } : {
36245
+ status: "not_configured",
36246
+ profileName
36247
+ };
36248
+ }
36249
+ function parseAgentInvitation(input) {
36250
+ try {
36251
+ if (Buffer.byteLength(input) > MAX_INVITATION_BYTES) throw new Error();
36252
+ const text = input.trim();
36253
+ let token;
36254
+ let base;
36255
+ if (text.startsWith("{")) {
36256
+ const value = JSON.parse(text);
36257
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
36258
+ const row = value;
36259
+ if (Object.keys(row).some((key) => key !== "token" && key !== "api_base_url")) throw new Error();
36260
+ token = row.token;
36261
+ base = row.api_base_url ?? "https://api.primitive.dev/v1";
36262
+ } else {
36263
+ const url = new URL(text);
36264
+ if (url.username || url.password || url.search || url.pathname !== "/v1/agent-connections/setup") throw new Error();
36265
+ const fragment = new URLSearchParams(url.hash.slice(1));
36266
+ if (Array.from(fragment.keys()).length !== 1 || fragment.getAll("token").length !== 1) throw new Error();
36267
+ token = fragment.get("token");
36268
+ base = `${url.origin}/v1`;
36269
+ }
36270
+ if (typeof token !== "string" || token.length < 32 || token.length > 256 || /\s/.test(token) || Array.from(token).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) throw new Error();
36271
+ return {
36272
+ token,
36273
+ apiBaseUrl: connectedApiBaseUrl(base)
36274
+ };
36275
+ } catch {
36276
+ throw new AgentConnectionSetupError("Pipe the private Primitive setup URL or a JSON object containing token and optional api_base_url to stdin. Invitation values are never accepted as command arguments.");
36277
+ }
36278
+ }
36279
+ async function readAgentInvitation(input, isTTY) {
36280
+ if (isTTY) throw new AgentConnectionSetupError("Pipe the private setup invitation to stdin.");
36281
+ const chunks = [];
36282
+ let size = 0;
36283
+ for await (const chunk of input) {
36284
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
36285
+ size += bytes.length;
36286
+ if (size > MAX_INVITATION_BYTES) throw new AgentConnectionSetupError("Setup invitation exceeds the input limit.");
36287
+ chunks.push(bytes);
36288
+ }
36289
+ return Buffer.concat(chunks).toString("utf8");
36290
+ }
36291
+ async function readClaimResponse(response) {
36292
+ const reader = response.body?.getReader();
36293
+ if (!reader) throw new Error();
36294
+ const chunks = [];
36295
+ let size = 0;
36296
+ try {
36297
+ for (;;) {
36298
+ const part = await reader.read();
36299
+ if (part.done) break;
36300
+ size += part.value.byteLength;
36301
+ if (size > MAX_RESPONSE_BYTES$2) throw new Error();
36302
+ chunks.push(part.value);
36303
+ }
36304
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
36305
+ } finally {
36306
+ await reader.cancel().catch(() => void 0);
36307
+ }
36308
+ }
36309
+ function profileFromClaim(value, invitation, invitationHash, now) {
36310
+ const response = value;
36311
+ const data = response?.data;
36312
+ const connection = data?.connection;
36313
+ if (response?.success !== true || !data || !connection || data.api_base_url !== invitation.apiBaseUrl || data.owner_address !== connection.owner_address || !["claimed", "connected"].includes(String(connection.status))) throw new Error();
36314
+ let ownerMember;
36315
+ try {
36316
+ ownerMember = Object.hasOwn(data, "owner_member_address") ? parseOwnerMemberAddress(data.owner_member_address, mailAddress(data.owner_address), mailAddress(connection.address)) : void 0;
36317
+ } catch {
36318
+ ownerMember = void 0;
36319
+ }
36320
+ return parseConnectedAgentProfile({
36321
+ version: 1,
36322
+ auth_method: "agent_connection",
36323
+ api_key: data.api_key,
36324
+ api_base_url: data.api_base_url,
36325
+ org_id: data.org_id,
36326
+ agent_address: connection.address,
36327
+ owner_address: data.owner_address,
36328
+ ...ownerMember === void 0 ? {} : { owner_member_address: ownerMember },
36329
+ invitation_hash: invitationHash,
36330
+ created_at: new Date(now()).toISOString(),
36331
+ ...data.presence_profile === void 0 ? {} : { presence_profile: data.presence_profile }
36332
+ });
36333
+ }
36334
+ /** Claims once; saved identity is local configuration, not proof of email readiness. */
36335
+ async function connectAgent(params) {
36336
+ const profileName = agentProfileName(params.profileName);
36337
+ const invitation = parseAgentInvitation(params.invitation);
36338
+ const invitationHash = createHash("sha256").update(invitation.apiBaseUrl).update("\0").update(invitation.token).digest("hex");
36339
+ const directory = agentProfilesDirectory(params.configDir);
36340
+ let release;
36341
+ try {
36342
+ privateMailDirectory(directory, true);
36343
+ release = acquireListenLock(directory, "agent-connection-setup");
36344
+ } catch {
36345
+ throw new AgentConnectionSetupError("Agent setup is already running, or its private directory is unavailable. No invitation was submitted.");
36346
+ }
36347
+ try {
36348
+ const existing = loadConnectedAgentProfile(params.configDir, profileName);
36349
+ if (existing) {
36350
+ if (existing.invitation_hash !== invitationHash) throw new AgentConnectionSetupError("This agent profile is already configured. Its identity and credentials were not changed. Use a separate profile for another invitation.");
36351
+ return {
36352
+ status: "already_configured",
36353
+ identity: connectedAgentIdentity(profileName, existing)
36354
+ };
36355
+ }
36356
+ privateMailDirectory(join(directory, "profiles"), true);
36357
+ privateMailDirectory(agentProfileDirectory(params.configDir, profileName), true);
36358
+ const journal = join(directory, "claims", `${invitationHash}.json`);
36359
+ privateMailDirectory(join(directory, "claims"), true);
36360
+ if (readMailJson(journal) !== null) throw new AgentConnectionSetupError(UNCERTAIN_CLAIM);
36361
+ writeMailJson(journal, {
36362
+ version: 1,
36363
+ profile_name: profileName,
36364
+ status: "attempted"
36365
+ });
36366
+ try {
36367
+ const response = await (params.fetch ?? fetch)(`${invitation.apiBaseUrl}/agent-connections/claim`, {
36368
+ method: "POST",
36369
+ redirect: "error",
36370
+ signal: AbortSignal.timeout(25e3),
36371
+ headers: {
36372
+ "content-type": "application/json",
36373
+ accept: "application/json"
35420
36374
  },
35421
36375
  body: JSON.stringify({
35422
36376
  token: invitation.token,
@@ -35885,7 +36839,7 @@ function matchesContactSelector(input, sender) {
35885
36839
  }
35886
36840
  //#endregion
35887
36841
  //#region src/oclif/contact-policy.ts
35888
- function record$3(value) {
36842
+ function record$2(value) {
35889
36843
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid contact policy.");
35890
36844
  return value;
35891
36845
  }
@@ -35894,11 +36848,11 @@ function fingerprint(value) {
35894
36848
  return value;
35895
36849
  }
35896
36850
  function document(value, organization) {
35897
- const row = record$3(value);
36851
+ const row = record$2(value);
35898
36852
  if (!Array.isArray(row.rules) || row.rules.length > 100 || !(typeof row.allow_contact_requests === "boolean" || !organization && row.allow_contact_requests === null)) throw new Error("Invalid contact policy document.");
35899
36853
  const seen = /* @__PURE__ */ new Set();
35900
36854
  const rules = row.rules.map((value) => {
35901
- const rule = record$3(value);
36855
+ const rule = record$2(value);
35902
36856
  if (typeof rule.pattern !== "string" || rule.effect !== "allow" && rule.effect !== "silence") throw new Error("Invalid contact policy rule.");
35903
36857
  const pattern = canonicalContactSelector({
35904
36858
  kind: "pattern",
@@ -35939,7 +36893,7 @@ function document(value, organization) {
35939
36893
  };
35940
36894
  }
35941
36895
  function parseAgentContactPolicy(value, recipient) {
35942
- const row = record$3(value);
36896
+ const row = record$2(value);
35943
36897
  if (row.agent_address !== recipient || typeof row.allow_contact_requests !== "boolean") throw new Error("Contact policy recipient mismatch.");
35944
36898
  const org = document(row.org_policy, true);
35945
36899
  const agent = document(row.agent_policy, false);
@@ -36225,6 +37179,11 @@ function createNotificationContactPolicy(options) {
36225
37179
  const SUBJECT = "Connect your agent to Primitive";
36226
37180
  const MARKER = /\bprimitive-connection:([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}):([1-9]\d*)\b/g;
36227
37181
  const fail = (message) => new AgentConnectionSetupError(message);
37182
+ const RECEIVER_MODES = [
37183
+ "native",
37184
+ "external",
37185
+ "poll"
37186
+ ];
36228
37187
  const RECEIPT_STATUSES = new Set([
36229
37188
  "queued",
36230
37189
  "submitted_to_agent",
@@ -36333,7 +37292,7 @@ function parseState(value) {
36333
37292
  "receipt"
36334
37293
  ];
36335
37294
  const row = mailObject(value, value && typeof value === "object" && Object.hasOwn(value, "receiverMode") ? [...keys, "receiverMode"] : keys);
36336
- if (row.version !== 1 || row.receiverMode !== void 0 && !["native", "external"].includes(String(row.receiverMode)) || typeof row.contactRequests !== "boolean" || ![
37295
+ if (row.version !== 1 || row.receiverMode !== void 0 && !RECEIVER_MODES.includes(row.receiverMode) || typeof row.contactRequests !== "boolean" || ![
36337
37296
  "waiting",
36338
37297
  "sending",
36339
37298
  "sent"
@@ -36348,7 +37307,7 @@ function parseState(value) {
36348
37307
  if (!/^[a-f0-9]{64}$/.test(invitationHash)) throw fail("Saved setup identity is invalid.");
36349
37308
  const state = {
36350
37309
  version: 1,
36351
- session: mailId(row.session),
37310
+ session: row.session === null && row.receiverMode === "poll" ? null : mailId(row.session),
36352
37311
  receiverMode: row.receiverMode ?? "native",
36353
37312
  invitationHash,
36354
37313
  since: mailTime(row.since),
@@ -36569,7 +37528,7 @@ function defaults$1() {
36569
37528
  */
36570
37529
  function refuseSetupConflicts(state, params) {
36571
37530
  const conflicts = [
36572
- ...state.session !== params.session ? [`--session (this profile is bound to session ${state.session})`] : [],
37531
+ ...(params.session === void 0 ? state.receiverMode !== "poll" : state.session !== params.session) ? [state.session ? `--session (this profile is bound to session ${state.session})` : "--session (this profile's setup binds no session)"] : [],
36573
37532
  ...params.receiverMode !== void 0 && state.receiverMode !== params.receiverMode ? [`--receiver ${params.receiverMode} (saved: ${state.receiverMode})`] : [],
36574
37533
  ...params.contactRequests !== void 0 && state.contactRequests !== params.contactRequests ? [state.contactRequests ? "contact requests off (saved setup has --contact-requests)" : "--contact-requests (saved setup did not enable it)"] : []
36575
37534
  ];
@@ -36578,7 +37537,7 @@ function refuseSetupConflicts(state, params) {
36578
37537
  /** One resumable operation owns setup plumbing; it never stores or replays the invitation. */
36579
37538
  async function setupAgent(params) {
36580
37539
  const profileName = agentProfileName(params.profileName);
36581
- if (!SESSION_UUID$1.test(params.session)) throw fail("--session requires the exact loaded session UUID.");
37540
+ if (params.session !== void 0 && !SESSION_UUID$1.test(params.session)) throw fail("--session requires the exact loaded session UUID.");
36582
37541
  if (params.resume ? params.invitation !== void 0 : params.invitation === void 0) throw fail("Use an invitation on stdin for setup, or --resume without an invitation.");
36583
37542
  const timeoutMs = params.timeoutMs ?? 3e4;
36584
37543
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 12e4) throw fail("Setup timeout must be between zero and two minutes.");
@@ -36595,7 +37554,9 @@ async function setupAgent(params) {
36595
37554
  saved = value === null ? null : parseState(value);
36596
37555
  } catch {}
36597
37556
  if (saved) refuseSetupConflicts(saved, params);
36598
- if ((params.receiverMode ?? (params.resume ? saved?.receiverMode : void 0) ?? "native") === "native") try {
37557
+ const receiverMode = params.receiverMode ?? (params.resume ? saved?.receiverMode : void 0);
37558
+ if (params.session === void 0 && (receiverMode ?? "native") !== "poll") throw fail("--session requires the exact loaded session UUID. Without one, use --receiver poll. No invitation was claimed.");
37559
+ if ((receiverMode ?? "native") === "native" && params.session) try {
36599
37560
  await dependencies.preflight(params.session);
36600
37561
  } catch {
36601
37562
  throw fail("This exact session is not available for native receiving. No invitation was claimed. Open a supported session and retry setup.");
@@ -36616,7 +37577,7 @@ async function setupAgent(params) {
36616
37577
  if (!state) {
36617
37578
  state = {
36618
37579
  version: 1,
36619
- session: params.session,
37580
+ session: params.session ?? null,
36620
37581
  receiverMode: params.receiverMode ?? "native",
36621
37582
  invitationHash: hash,
36622
37583
  since: (/* @__PURE__ */ new Date(dependencies.now() - 15 * 6e4)).toISOString(),
@@ -36666,7 +37627,7 @@ async function setupAgent(params) {
36666
37627
  ].includes(state.receipt?.status ?? "") ? "reply_failed" : ["unknown", "wait_timeout"].includes(state.receipt?.status ?? "") ? "delivery_unknown" : "reply_submitted" : state?.phase === "sending" ? "send_unknown" : "challenge_pending";
36667
37628
  const result = (receiving, ownerNotifications) => ({
36668
37629
  identity: context.identity,
36669
- sessionId: params.session,
37630
+ sessionId: params.session ?? state?.session ?? null,
36670
37631
  verification: {
36671
37632
  state: confirmed && submittedState() === "reply_submitted" ? "verified" : submittedState(),
36672
37633
  ...confirmed ? { verifiedAt: confirmed.verifiedAt } : {},
@@ -36677,7 +37638,7 @@ async function setupAgent(params) {
36677
37638
  },
36678
37639
  receiving: { state: receiving },
36679
37640
  ...ownerNotifications ? { ownerNotifications } : {},
36680
- resumeCommand: `primitive agent connect --profile ${profileName} --session ${params.session}${state?.receiverMode === "external" ? " --receiver external" : ""} --resume${state?.contactRequests ? " --contact-requests" : ""} --json`,
37641
+ resumeCommand: `primitive agent connect --profile ${profileName}${state?.session ? ` --session ${state.session}` : ""}${state?.receiverMode === "external" || state?.receiverMode === "poll" ? ` --receiver ${state.receiverMode}` : ""} --resume${state?.contactRequests ? " --contact-requests" : ""} --json`,
36681
37642
  guidance: confirmed ? "Primitive verified this connection. Receiving health is reported separately. Keep this profile for all mail commands." : submittedState() === "reply_submitted" ? "The verification reply was submitted and Primitive has not confirmed it yet. Verification usually completes within seconds; check it later with GET /agent-connections/me. Receiving health is reported separately. Keep this profile for all mail commands." : "Reply submission is not proof of delivery or app verification. Receiving health is reported separately. Keep this profile for all mail commands."
36682
37643
  });
36683
37644
  const key = `connection-verification-${state.invitationHash}`;
@@ -36711,14 +37672,14 @@ async function setupAgent(params) {
36711
37672
  }
36712
37673
  const ownerNotifications = await dependencies.enableOwner(context);
36713
37674
  let healthy = false;
36714
- if (state.receiverMode !== "external") try {
36715
- healthy = await dependencies.startListener(profileName, params.session, state.contactRequests, params.configDir);
37675
+ if ((state.receiverMode ?? "native") === "native" && state.session) try {
37676
+ healthy = await dependencies.startListener(profileName, state.session, state.contactRequests, params.configDir);
36716
37677
  } catch {}
36717
37678
  if (verificationTimeoutMs > 0 && submittedState() === "reply_submitted") {
36718
37679
  const check = await awaitVerification(context, dependencies, verificationTimeoutMs);
36719
37680
  if (check.state === "verified") confirmed = { verifiedAt: check.verifiedAt };
36720
37681
  }
36721
- return result(state.receiverMode === "external" ? "external_setup_required" : healthy ? "healthy" : "not_ready", ownerNotifications);
37682
+ return result(state.receiverMode === "external" ? "external_setup_required" : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
36722
37683
  } finally {
36723
37684
  release();
36724
37685
  }
@@ -36814,7 +37775,8 @@ const AGENT_CONNECT_CAPABILITIES = [
36814
37775
  "claude_external_hooks",
36815
37776
  "bundled_skill_install",
36816
37777
  "agent_info_seed",
36817
- "resume"
37778
+ "resume",
37779
+ "poll_receiver"
36818
37780
  ];
36819
37781
  const AGENT_INFO_NOTE = "AGENT_INFO";
36820
37782
  const MAX_NAME_LENGTH = 80;
@@ -36837,7 +37799,7 @@ function savedSetup(configDir, profileName) {
36837
37799
  if (!saved || typeof saved !== "object" || Array.isArray(saved)) return null;
36838
37800
  const row = saved;
36839
37801
  return {
36840
- receiverMode: row.receiverMode === "external" ? "external" : "native",
37802
+ receiverMode: row.receiverMode === "external" || row.receiverMode === "poll" ? row.receiverMode : "native",
36841
37803
  contactRequests: row.contactRequests === true
36842
37804
  };
36843
37805
  } catch {
@@ -36861,6 +37823,20 @@ function clearPendingAgentInfo(path) {
36861
37823
  function defaultAgentProfileName(session) {
36862
37824
  return `session-${session.toLowerCase()}`;
36863
37825
  }
37826
+ /**
37827
+ * The profile for a setup that binds no session. It is derived from the
37828
+ * invitation, so running the same invitation again finds the same profile and
37829
+ * its saved claim instead of claiming twice. A one-way hash prefix reveals
37830
+ * nothing usable about the invitation.
37831
+ */
37832
+ function invitationProfileName(invitation) {
37833
+ const parsed = parseAgentInvitation(invitation);
37834
+ return `connection-${createHash("sha256").update(parsed.apiBaseUrl).update("\0").update(parsed.token).digest("hex").slice(0, 12)}`;
37835
+ }
37836
+ /** The command that checks a poll-receiving profile for new mail. */
37837
+ function pollCheckCommand(invocation, profileName) {
37838
+ return `PRIMITIVE_AGENT_PROFILE=${profileName} ${invocation} agent check-mail --json`;
37839
+ }
36864
37840
  /** A short private role note; never a transcript or secret. */
36865
37841
  function agentInfoValue(name, info) {
36866
37842
  const clean = (value, max, label) => {
@@ -36948,11 +37924,15 @@ async function runAgentConnect(options) {
36948
37924
  ...defaults(options),
36949
37925
  ...Object.fromEntries(Object.entries(options.dependencies ?? {}).filter(([, value]) => value !== void 0))
36950
37926
  };
36951
- if (!SESSION_UUID$1.test(options.session)) throw new AgentConnectionSetupError("--session requires the exact loaded session UUID.");
36952
- const runtime = detectAgentRuntime(options.session, env);
36953
- const profileName = agentProfileName(options.profileName ?? defaultAgentProfileName(options.session));
36954
- const receiver = options.receiver ?? (options.resume ? savedSetup(options.configDir, profileName)?.receiverMode ?? null : null) ?? (runtime === "claude" ? "external" : "native");
36955
- if (receiver === "external" && env.CLAUDE_CODE_SESSION_ID?.trim().toLowerCase() !== options.session.toLowerCase()) throw new AgentConnectionSetupError("External receiving requires this exact Claude session ID. No invitation was claimed.");
37927
+ const session = options.session?.trim() ? options.session : void 0;
37928
+ if (session !== void 0 && !SESSION_UUID$1.test(session)) throw new AgentConnectionSetupError("--session requires the exact loaded session UUID.");
37929
+ if (session === void 0 && options.receiver && options.receiver !== "poll") throw new AgentConnectionSetupError(`--receiver ${options.receiver} requires the exact loaded session UUID. Without one, use --receiver poll. No invitation was claimed.`);
37930
+ if (session === void 0 && options.resume && !options.profileName) throw new AgentConnectionSetupError("Pass --profile to resume a setup that binds no session.");
37931
+ const runtime = session ? detectAgentRuntime(session, env) : null;
37932
+ const invitation = options.resume || session || options.profileName ? void 0 : await options.readInvitation();
37933
+ const profileName = agentProfileName(options.profileName ?? (session ? defaultAgentProfileName(session) : invitationProfileName(invitation ?? "")));
37934
+ const receiver = options.receiver ?? (options.resume ? savedSetup(options.configDir, profileName)?.receiverMode ?? null : null) ?? (!session ? "poll" : runtime === "claude" ? "external" : "native");
37935
+ if (receiver === "external" && env.CLAUDE_CODE_SESSION_ID?.trim().toLowerCase() !== session?.toLowerCase()) throw new AgentConnectionSetupError("External receiving requires this exact Claude session ID. No invitation was claimed.");
36956
37936
  const agentInfo = agentInfoValue(options.name, options.info);
36957
37937
  const skipped = [];
36958
37938
  let skill;
@@ -36989,11 +37969,11 @@ async function runAgentConnect(options) {
36989
37969
  const result = await dependencies.setupAgent({
36990
37970
  configDir: options.configDir,
36991
37971
  profileName,
36992
- session: options.session,
37972
+ ...session ? { session } : {},
36993
37973
  receiverMode: receiver,
36994
37974
  resume: options.resume,
36995
37975
  contactRequests: options.contactRequests,
36996
- ...options.resume ? {} : { invitation: await options.readInvitation() }
37976
+ ...options.resume ? {} : { invitation: invitation ?? await options.readInvitation() }
36997
37977
  });
36998
37978
  const verified = verificationReplySubmitted(result.verification.state);
36999
37979
  const ownerMemberAddress = await dependencies.refreshOwnerMemberAddress(result.identity.profileName);
@@ -37007,7 +37987,7 @@ async function runAgentConnect(options) {
37007
37987
  configDir: options.configDir,
37008
37988
  profileName: result.identity.profileName,
37009
37989
  agentAddress: result.identity.agentAddress,
37010
- sessionId: options.session,
37990
+ sessionId: session ?? "",
37011
37991
  env
37012
37992
  });
37013
37993
  else skipped.push({
@@ -37053,22 +38033,27 @@ async function runAgentConnect(options) {
37053
38033
  reason: "already_present"
37054
38034
  });
37055
38035
  }
38036
+ const invocation = options.invocation ?? "primitive";
37056
38037
  const receiving = receiver === "external" ? {
37057
38038
  ...result.receiving,
37058
38039
  mode: "external",
37059
38040
  hook: externalHook
38041
+ } : receiver === "poll" ? {
38042
+ ...result.receiving,
38043
+ mode: "poll",
38044
+ checkCommand: pollCheckCommand(invocation, result.identity.profileName)
37060
38045
  } : {
37061
38046
  ...result.receiving,
37062
38047
  mode: "native",
37063
38048
  mailCheck: mailCheck?.state ?? "not_started",
37064
38049
  lastSuccessfulMailCheckAt: mailCheck?.lastSuccessfulMailCheckAt ?? null
37065
38050
  };
37066
- const receiverReady = receiver === "external" ? externalHook === "installed_unverified" : result.receiving.state === "healthy";
38051
+ const receiverReady = receiver === "external" ? externalHook === "installed_unverified" : receiver === "poll" || result.receiving.state === "healthy";
37067
38052
  const resumeCommand = [
37068
- options.invocation ?? "primitive",
38053
+ invocation,
37069
38054
  "agent connect",
37070
38055
  `--profile ${result.identity.profileName}`,
37071
- `--session ${options.session}`,
38056
+ ...session ? [`--session ${session}`] : [],
37072
38057
  `--receiver ${receiver}`,
37073
38058
  "--resume",
37074
38059
  ...options.contactRequests ?? savedSetup(options.configDir, profileName)?.contactRequests ? ["--contact-requests"] : [],
@@ -37083,7 +38068,7 @@ async function runAgentConnect(options) {
37083
38068
  ownerAddress: result.identity.ownerAddress,
37084
38069
  ownerMemberAddress,
37085
38070
  profile: result.identity.profileName,
37086
- sessionId: options.session,
38071
+ sessionId: session ?? null,
37087
38072
  runtime,
37088
38073
  cli: {
37089
38074
  version: options.cliVersion,
@@ -37105,15 +38090,16 @@ async function runAgentConnect(options) {
37105
38090
  //#endregion
37106
38091
  //#region src/oclif/commands/agent-connect.ts
37107
38092
  /** npx runs the CLI from its cache; resume through npx too so the command works without a global install. */
37108
- function invocation(entry) {
38093
+ function invocation$1(entry) {
37109
38094
  return entry && /[\\/]_npx[\\/]/.test(entry) ? "npx -y primitive@latest" : "primitive";
37110
38095
  }
37111
38096
  var AgentConnectCommand = class AgentConnectCommand extends Command {
37112
- static description = "Connect this coding session to Primitive in one call. With --session, the command checks this CLI's capabilities, installs or refreshes the matching primitive-connect skill for the detected runtime (Claude Code or Codex) from files bundled in this CLI, claims the owner's private setup invitation from piped stdin, answers one authenticated setup challenge, enables owner notifications, starts receiving, seeds a private AGENT_INFO note from --name and --info when absent, and prints one JSON result. The receiver defaults to external hooks in Claude Code and the native background listener elsewhere. Native receiving starts or reuses a supervised background listener and waits briefly for its first successful mail check; external receiving installs the exact Claude session's fail-open Stop hook and resume SessionStart hook after the verification reply; a real idle mail event must still verify wake. The profile defaults to session-<session>. --resume continues saved progress without reading stdin or replaying a claim or uncertain verification send. After the reply is submitted it waits up to a minute for the server to confirm the connection and reports verification.state verified, or reply_submitted while confirmation is still pending. Verification is separate from receiver health. Both official production (https://api.primitive.dev/v1) and staging (https://api.primitive-staging-1.com/v1) invitations pin their API origin. Never pass an invitation as a command argument. Select the saved identity with PRIMITIVE_AGENT_PROFILE. Use --profile with --status --json to inspect saved identity and local receiver health offline.";
38097
+ static description = "Connect this coding session to Primitive in one call. With --session, the command checks this CLI's capabilities, installs or refreshes the matching primitive-connect skill for the detected runtime (Claude Code or Codex) from files bundled in this CLI, claims the owner's private setup invitation from piped stdin, answers one authenticated setup challenge, enables owner notifications, starts receiving, seeds a private AGENT_INFO note from --name and --info when absent, and prints one JSON result. The receiver defaults to external hooks in Claude Code and the native background listener elsewhere. Without a session (no --session, or an empty one) the receiver is poll: for runtimes with no local session ID or hooks, such as a cloud-hosted conversation whose commands run in a separate sandbox, the invitation is claimed and verified the same way, nothing is installed, and the result's receiving.checkCommand is the command the agent runs to check for new mail at the start of each turn and after it sends. Native receiving starts or reuses a supervised background listener and waits briefly for its first successful mail check; external receiving installs the exact Claude session's fail-open Stop hook and resume SessionStart hook after the verification reply; a real idle mail event must still verify wake. The profile defaults to session-<session>, or to connection-<invitation hash prefix> without a session. --resume continues saved progress without reading stdin or replaying a claim or uncertain verification send. After the reply is submitted it waits up to a minute for the server to confirm the connection and reports verification.state verified, or reply_submitted while confirmation is still pending. Verification is separate from receiver health. Both official production (https://api.primitive.dev/v1) and staging (https://api.primitive-staging-1.com/v1) invitations pin their API origin. Never pass an invitation as a command argument. Select the saved identity with PRIMITIVE_AGENT_PROFILE. Use --profile with --status --json to inspect saved identity and local receiver health offline.";
37113
38098
  static summary = "Connect and verify an agent address";
37114
38099
  static examples = [
37115
38100
  "<%= config.bin %> agent connect --session \"$CODEX_THREAD_ID\" --name Research --info \"Reviews pull requests\" --json < private-invitation.txt",
37116
38101
  "<%= config.bin %> agent connect --session \"$CLAUDE_CODE_SESSION_ID\" --contact-requests --json < private-invitation.txt",
38102
+ "<%= config.bin %> agent connect --receiver poll --name Research --json < private-invitation.txt",
37117
38103
  "<%= config.bin %> agent connect --profile work --session 11111111-1111-4111-8111-111111111111 --contact-requests --json < private-invitation.txt",
37118
38104
  "<%= config.bin %> agent connect --profile work --session 11111111-1111-4111-8111-111111111111 --contact-requests --resume --json",
37119
38105
  "<%= config.bin %> agent connect --profile work --session 11111111-1111-4111-8111-111111111111 --receiver external --contact-requests --json < private-invitation.txt",
@@ -37122,33 +38108,31 @@ var AgentConnectCommand = class AgentConnectCommand extends Command {
37122
38108
  "PRIMITIVE_AGENT_PROFILE=work <%= config.bin %> whoami --json"
37123
38109
  ];
37124
38110
  static flags = {
37125
- profile: Flags.string({ description: "Local agent profile name; defaults to session-<session> with --session. Existing OAuth login is preserved" }),
38111
+ profile: Flags.string({ description: "Local agent profile name; defaults to session-<session> with --session, or connection-<invitation hash prefix> for poll receiving. Existing OAuth login is preserved" }),
37126
38112
  session: Flags.string({
37127
- description: "Exact current session UUID; verify email and configure receiving after claiming",
38113
+ description: "Exact current session UUID; verify email and configure receiving after claiming. Omit it (or pass it empty) when the runtime exposes none, which selects poll receiving",
37128
38114
  exclusive: ["status"]
37129
38115
  }),
37130
38116
  receiver: Flags.string({
37131
- description: "Native starts a supported session receiver; external installs the exact Claude session's fail-open Stop hook and resume SessionStart hook. Defaults to external in Claude Code and native elsewhere",
37132
- options: ["native", "external"],
37133
- dependsOn: ["session"]
38117
+ description: "Native starts a supported session receiver; external installs the exact Claude session's fail-open Stop hook and resume SessionStart hook; poll installs nothing and the agent checks for mail itself with `agent check-mail`. Defaults to external in Claude Code, native elsewhere, and poll without a session",
38118
+ options: [...RECEIVER_MODES],
38119
+ exclusive: ["status"]
37134
38120
  }),
37135
38121
  resume: Flags.boolean({
37136
38122
  description: "Resume this session's saved setup without reading stdin, claiming again, or resending verification",
37137
- dependsOn: ["session"],
37138
38123
  exclusive: ["status"]
37139
38124
  }),
37140
38125
  "contact-requests": Flags.boolean({
37141
38126
  description: "Receive owner-enabled first contact requests in this session; grants no task authority",
37142
- dependsOn: ["session"],
37143
38127
  exclusive: ["status"]
37144
38128
  }),
37145
38129
  name: Flags.string({
37146
38130
  description: "Short agent name for a private AGENT_INFO note, written only when the note is absent",
37147
- dependsOn: ["session"]
38131
+ exclusive: ["status"]
37148
38132
  }),
37149
38133
  info: Flags.string({
37150
38134
  description: "Short role and capability description for a private AGENT_INFO note, written only when the note is absent",
37151
- dependsOn: ["session"]
38135
+ exclusive: ["status"]
37152
38136
  }),
37153
38137
  skill: Flags.boolean({
37154
38138
  description: "Install or refresh the primitive-connect skill bundled with this CLI for the detected runtime",
@@ -37157,13 +38141,14 @@ var AgentConnectCommand = class AgentConnectCommand extends Command {
37157
38141
  }),
37158
38142
  project: Flags.boolean({
37159
38143
  description: "Install the skill into this project (.claude/skills or .agents/skills) instead of the user skills folder",
37160
- dependsOn: ["session"]
38144
+ exclusive: ["status"]
37161
38145
  }),
37162
38146
  status: Flags.boolean({ description: "Read saved identity and local receiver health offline without reading stdin" }),
37163
38147
  json: Flags.boolean({ description: "Print setup status and identity metadata, never the credential" })
37164
38148
  };
37165
38149
  async run() {
37166
38150
  const { flags } = await this.parse(AgentConnectCommand);
38151
+ let pollProfile;
37167
38152
  try {
37168
38153
  if (flags.status) {
37169
38154
  if (!flags.profile) throw new AgentConnectionSetupError("Pass --profile with --status.");
@@ -37177,15 +38162,24 @@ var AgentConnectCommand = class AgentConnectCommand extends Command {
37177
38162
  }
37178
38163
  return;
37179
38164
  }
37180
- if (flags.session) {
38165
+ const session = flags.session?.trim() || void 0;
38166
+ if (!(flags.session === void 0 && flags.profile !== void 0 && flags.receiver === void 0 && !flags.resume && !flags["contact-requests"] && flags.name === void 0 && flags.info === void 0)) {
38167
+ if (!session && (flags.receiver === "native" || flags.receiver === "external")) throw new AgentConnectionSetupError(`--receiver ${flags.receiver} requires the exact loaded session UUID. Without one, use --receiver poll. No invitation was claimed.`);
38168
+ let readInvitation = () => readAgentInvitation(process.stdin, process.stdin.isTTY);
38169
+ if (!session && !flags.resume) if (flags.profile) pollProfile = flags.profile;
38170
+ else {
38171
+ const text = await readInvitation();
38172
+ pollProfile = invitationProfileName(text);
38173
+ readInvitation = async () => text;
38174
+ }
37181
38175
  const output = await runAgentConnect({
37182
38176
  configDir: this.config.configDir,
37183
38177
  packageRoot: this.config.root,
37184
38178
  cliVersion: this.config.version,
37185
38179
  cliPath: process.argv[1] ?? "",
37186
- invocation: invocation(process.argv[1]),
37187
- session: flags.session,
37188
- profileName: flags.profile,
38180
+ invocation: invocation$1(process.argv[1]),
38181
+ session,
38182
+ profileName: flags.profile ?? pollProfile,
37189
38183
  receiver: flags.receiver,
37190
38184
  resume: flags.resume,
37191
38185
  contactRequests: flags["contact-requests"],
@@ -37193,21 +38187,23 @@ var AgentConnectCommand = class AgentConnectCommand extends Command {
37193
38187
  info: flags.info,
37194
38188
  skill: flags.skill,
37195
38189
  project: flags.project,
37196
- readInvitation: () => readAgentInvitation(process.stdin, process.stdin.isTTY)
38190
+ readInvitation
37197
38191
  });
37198
38192
  const external = output.receiving.mode === "external";
38193
+ const poll = output.receiving.mode === "poll";
37199
38194
  if (flags.json) this.log(JSON.stringify(output));
37200
38195
  else {
37201
38196
  this.log(`Agent ${output.address}: verification ${output.verification.state}; receiving ${output.receiving.state}; skill ${output.skill.state}.`);
37202
38197
  this.log(`Select it with ${output.selectProfile}.`);
37203
38198
  if (output.ownerNotifications === "silenced") this.log("Owner notifications remain silenced by the existing preference or policy.");
37204
38199
  if (external && verificationReplySubmitted(output.verification.state)) this.log(output.externalHook === "installed_unverified" ? "External receive hook installed. Idle wake still needs a live mail check." : "External receive hook is unavailable. Pairing may complete, but this session will not wake automatically.");
38200
+ if (poll && "checkCommand" in output.receiving) this.log(`Nothing wakes this session for new mail. Check it at the start of each turn and after sending with: ${output.receiving.checkCommand}`);
37205
38201
  if (output.status !== "connected") this.log(`Resume: ${output.resumeCommand}`);
37206
38202
  }
37207
- if (external && output.externalHook !== "installed_unverified" || !external && output.receiving.state !== "healthy" || !verificationReplySubmitted(output.verification.state)) process.exitCode = 2;
38203
+ if (external && output.externalHook !== "installed_unverified" || !external && !poll && output.receiving.state !== "healthy" || !verificationReplySubmitted(output.verification.state)) process.exitCode = 2;
37208
38204
  return;
37209
38205
  }
37210
- if (!flags.profile) throw new AgentConnectionSetupError("Pass --session to connect this session, or --profile for a claim-only or status check.");
38206
+ if (!flags.profile) throw new AgentConnectionSetupError("Pass --profile for claim-only.");
37211
38207
  const claimed = await connectAgent({
37212
38208
  configDir: this.config.configDir,
37213
38209
  profileName: flags.profile,
@@ -37224,7 +38220,7 @@ var AgentConnectCommand = class AgentConnectCommand extends Command {
37224
38220
  this.log(result.ownerReportGuidance);
37225
38221
  }
37226
38222
  } catch (error) {
37227
- throw new Errors.CLIError(error instanceof AgentConnectionSetupError ? error.message : flags.session ? `Setup paused. Preserve the private profile and run ${invocation(process.argv[1])} agent connect --profile ${flags.profile ?? defaultAgentProfileName(flags.session)} --session ${flags.session}${flags.receiver ? ` --receiver ${flags.receiver}` : ""} --resume --json. Do not manually resend verification or reclaim the invitation.` : "Agent setup did not complete. Preserve the private profile and request a fresh owner invitation if a claim may have been submitted.", { exit: 1 });
38223
+ throw new Errors.CLIError(error instanceof AgentConnectionSetupError ? error.message : flags.session?.trim() ? `Setup paused. Preserve the private profile and run ${invocation$1(process.argv[1])} agent connect --profile ${flags.profile ?? defaultAgentProfileName(flags.session.trim())} --session ${flags.session.trim()}${flags.receiver ? ` --receiver ${flags.receiver}` : ""} --resume --json. Do not manually resend verification or reclaim the invitation.` : pollProfile ? `Setup paused. Preserve the private profile and run ${invocation$1(process.argv[1])} agent connect --profile ${pollProfile} --receiver poll --resume --json. Do not manually resend verification or reclaim the invitation.` : "Agent setup did not complete. Preserve the private profile and request a fresh owner invitation if a claim may have been submitted.", { exit: 1 });
37228
38224
  }
37229
38225
  }
37230
38226
  };
@@ -37413,6 +38409,7 @@ function boundSession(directory, profile) {
37413
38409
  if (raw === null) return null;
37414
38410
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new AgentDisconnectError("Saved session setup is invalid. The connection was not changed.");
37415
38411
  const row = raw;
38412
+ if (row.receiverMode === "poll" && row.session === null && row.invitationHash === profile.invitation_hash) return null;
37416
38413
  if (typeof row.session !== "string" || !SESSION_UUID$1.test(row.session) || row.invitationHash !== profile.invitation_hash) throw new AgentDisconnectError("Saved session binding does not match this profile. The connection was not changed.");
37417
38414
  return row.session.toLowerCase();
37418
38415
  }
@@ -37580,7 +38577,7 @@ function savedEnrollment(value) {
37580
38577
  "ownerAddress"
37581
38578
  ];
37582
38579
  if (row?.version === 2) keys.push("createRequestId", "continueAttempted");
37583
- if (!row || Object.keys(row).length !== keys.length || keys.some((key) => !Object.hasOwn(row, key)) || row.version !== 1 && row.version !== 2 || row.version === 2 && (typeof row.createRequestId !== "string" || !SESSION_UUID$1.test(row.createRequestId) || typeof row.continueAttempted !== "boolean") || typeof row.session !== "string" || !SESSION_UUID$1.test(row.session) || row.profile !== `session-${row.session}` || typeof row.name !== "string" || !validName(row.name) || !(typeof row.address === "string" && validAddress(row.address) || row.version === 2 && row.address === null && row.phase === "create_attempted") || typeof row.orgId !== "string" || !row.orgId || typeof row.grantId !== "string" || !row.grantId || typeof row.apiBaseUrl !== "string" || !["native", "external"].includes(String(row.receiverMode)) || typeof row.contactRequests !== "boolean" || typeof row.startedAt !== "string" || !Number.isFinite(Date.parse(row.startedAt)) || !["create_attempted", "setup_attempted"].includes(String(row.phase)) || row.phase === "create_attempted" && (row.invitationHash !== null || row.ownerAddress !== null) || row.phase === "setup_attempted" && (typeof row.address !== "string" || typeof row.invitationHash !== "string" || !/^[a-f0-9]{64}$/.test(row.invitationHash) || typeof row.ownerAddress !== "string" || !row.ownerAddress.includes("@"))) throw refuse("Saved enrollment state is invalid. Preserve its private files.");
38580
+ if (!row || Object.keys(row).length !== keys.length || keys.some((key) => !Object.hasOwn(row, key)) || row.version !== 1 && row.version !== 2 || row.version === 2 && (typeof row.createRequestId !== "string" || !SESSION_UUID$1.test(row.createRequestId) || typeof row.continueAttempted !== "boolean") || typeof row.session !== "string" || !SESSION_UUID$1.test(row.session) || row.profile !== `session-${row.session}` || typeof row.name !== "string" || !validName(row.name) || !(typeof row.address === "string" && validAddress(row.address) || row.version === 2 && row.address === null && row.phase === "create_attempted") || typeof row.orgId !== "string" || !row.orgId || typeof row.grantId !== "string" || !row.grantId || typeof row.apiBaseUrl !== "string" || !RECEIVER_MODES.includes(row.receiverMode) || typeof row.contactRequests !== "boolean" || typeof row.startedAt !== "string" || !Number.isFinite(Date.parse(row.startedAt)) || !["create_attempted", "setup_attempted"].includes(String(row.phase)) || row.phase === "create_attempted" && (row.invitationHash !== null || row.ownerAddress !== null) || row.phase === "setup_attempted" && (typeof row.address !== "string" || typeof row.invitationHash !== "string" || !/^[a-f0-9]{64}$/.test(row.invitationHash) || typeof row.ownerAddress !== "string" || !row.ownerAddress.includes("@"))) throw refuse("Saved enrollment state is invalid. Preserve its private files.");
37584
38581
  connectedApiBaseUrl(row.apiBaseUrl);
37585
38582
  if (row.ownerAddress !== null) try {
37586
38583
  if (mailAddress(row.ownerAddress) !== row.ownerAddress) throw new Error();
@@ -37606,6 +38603,7 @@ function resolvedEnrollment(state) {
37606
38603
  };
37607
38604
  }
37608
38605
  async function preflight(session, receiver, env) {
38606
+ if (receiver === "poll") return;
37609
38607
  if (receiver === "external") {
37610
38608
  if (env.CLAUDE_CODE_SESSION_ID !== session) throw refuse("External receiving requires this exact Claude session ID. No address was created.");
37611
38609
  return;
@@ -38036,6 +39034,10 @@ async function enrollAgent(params) {
38036
39034
  }
38037
39035
  //#endregion
38038
39036
  //#region src/oclif/commands/agent-enroll.ts
39037
+ /** npx runs the CLI from its cache; print follow-up commands the same way. */
39038
+ function invocation(entry) {
39039
+ return entry && /[\\/]_npx[\\/]/.test(entry) ? "npx -y primitive@latest" : "primitive";
39040
+ }
38039
39041
  var AgentEnrollCommand = class AgentEnrollCommand extends Command {
38040
39042
  static summary = "Give this coding session an address in your organization";
38041
39043
  static description = "On this trusted machine, use the saved member OAuth login to create one address for the exact loaded session, claim its one-use invitation privately, answer the email challenge, and poll the owner's connection list for confirmed pairing. Native receiving starts when supported. With --receiver external in the exact Claude session, install a fail-open Stop hook and resume SessionStart hook in that runtime's settings after the verification reply; idle wake remains unverified until tested with real mail. With --contact-requests, the login conditionally enables first-contact intake for this exact address after verification, preserving existing policy rules. An explicit disable or uncertain policy update is reported separately without losing pairing or receiver setup. The server allocates a readable address. An uncertain creation resumes the same saved request; recovered results do not include another invitation. Use --continue-setup once to explicitly continue a recovered pending connection. An uncertain continuation or claim is held for owner recovery, never repeated automatically. Requires a verified Primitive-managed domain. This command does not accept API keys, a connection profile, or an invitation argument.";
@@ -38048,8 +39050,8 @@ var AgentEnrollCommand = class AgentEnrollCommand extends Command {
38048
39050
  name: Flags.string({ description: "Short agent display name; defaults to Coding agent" }),
38049
39051
  "continue-setup": Flags.boolean({ description: "Explicitly issue one invitation only if the recovered agent is still pending; never reconnect a claimed agent" }),
38050
39052
  receiver: Flags.string({
38051
- options: ["native", "external"],
38052
- description: "Native session receiver, or external runtime event hook"
39053
+ options: [...RECEIVER_MODES],
39054
+ description: "Native session receiver, external runtime event hook, or poll: nothing is installed and the agent checks for mail itself with `agent check-mail`"
38053
39055
  }),
38054
39056
  "contact-requests": Flags.boolean({ description: "Enable this agent's first-contact policy with the saved member login, then receive relevant requests" }),
38055
39057
  json: Flags.boolean({ description: "Print status without credentials or invitation" })
@@ -38072,7 +39074,15 @@ var AgentEnrollCommand = class AgentEnrollCommand extends Command {
38072
39074
  agentAddress: result.identity.agentAddress,
38073
39075
  sessionId: flags.session
38074
39076
  }) : null;
38075
- const output = {
39077
+ const output = result.receiving.state === "poll" ? {
39078
+ ...result,
39079
+ receiving: {
39080
+ ...result.receiving,
39081
+ mode: "poll",
39082
+ checkCommand: pollCheckCommand(invocation(process.argv[1]), result.identity.profileName)
39083
+ },
39084
+ externalHook
39085
+ } : {
38076
39086
  ...result,
38077
39087
  externalHook
38078
39088
  };
@@ -38086,9 +39096,10 @@ var AgentEnrollCommand = class AgentEnrollCommand extends Command {
38086
39096
  if (externalHook === "installed_unverified") this.log("External receive hook installed for this runtime. Idle wake still needs a live mail check.");
38087
39097
  else if (externalHook === "unavailable") this.log("Could not install the external receive hook. Pairing is confirmed, but later mail will not wake this session automatically.");
38088
39098
  if (result.connection.status !== "connected") this.log("Pairing is not yet confirmed. Rerun this command with the same options to resume the saved session; do not create another address.");
39099
+ else if ("checkCommand" in output.receiving) this.log(`Nothing wakes this session for new mail. Check it at the start of each turn and after sending with: ${output.receiving.checkCommand}`);
38089
39100
  else if (result.receiving.state !== "healthy") this.log("Pairing is confirmed; receiving needs separate setup or recovery.");
38090
39101
  }
38091
- if (externalHook === "unavailable" || flags["contact-requests"] && result.contactRequestPolicy !== "enabled" || result.connection.status !== "connected" || flags.receiver !== "external" && result.receiving.state !== "healthy") process.exitCode = 2;
39102
+ if (externalHook === "unavailable" || flags["contact-requests"] && result.contactRequestPolicy !== "enabled" || result.connection.status !== "connected" || flags.receiver !== "external" && flags.receiver !== "poll" && result.receiving.state !== "healthy") process.exitCode = 2;
38092
39103
  } catch (error) {
38093
39104
  throw new Errors.CLIError(error instanceof AgentConnectionSetupError ? error.message : "Enrollment paused. Preserve this session's private profile and inspect the pending agent in the app before retrying. No second address was requested automatically.", { exit: 1 });
38094
39105
  }
@@ -38482,6 +39493,33 @@ function enrollmentAddress(configDir, profileName) {
38482
39493
  };
38483
39494
  }
38484
39495
  }
39496
+ /**
39497
+ * Saved profiles, other than the session's own, whose setup says they were
39498
+ * connected for this exact session. Sorted for a stable choice.
39499
+ */
39500
+ function profilesBoundToSession(configDir, sessionId, ownProfile, mode) {
39501
+ let names;
39502
+ try {
39503
+ names = readdirSync(join(agentProfilesDirectory(configDir), "profiles"));
39504
+ } catch {
39505
+ return [];
39506
+ }
39507
+ const bound = [];
39508
+ for (const name of names.sort()) {
39509
+ if (name === ownProfile) continue;
39510
+ try {
39511
+ const setup = readMailJson(join(agentProfileDirectory(configDir, name), "setup.json"));
39512
+ const saved = setup?.receiverMode === void 0 ? "native" : setup.receiverMode;
39513
+ if (typeof setup?.session !== "string" || setup.session.toLowerCase() !== sessionId || saved !== mode) continue;
39514
+ const profile = loadConnectedAgentProfile(configDir, name);
39515
+ if (profile) bound.push({
39516
+ name,
39517
+ profile
39518
+ });
39519
+ } catch {}
39520
+ }
39521
+ return bound;
39522
+ }
38485
39523
  /** `agent disconnect` already confirmed revocation of this profile's credential. */
38486
39524
  function disconnectConfirmedLocally(configDir, profileName) {
38487
39525
  try {
@@ -38618,7 +39656,34 @@ async function registerSession(options) {
38618
39656
  detail: "The session ID is not a UUID. Nothing was changed."
38619
39657
  };
38620
39658
  const sessionId = session.toLowerCase();
38621
- const profileName = `session-${sessionId}`;
39659
+ const ownProfile = `session-${sessionId}`;
39660
+ let profileName = ownProfile;
39661
+ try {
39662
+ const saved = readSessionRecord(options.configDir, sessionId);
39663
+ if (saved?.createdBy === "existing" && saved.profile !== ownProfile) profileName = agentProfileName(saved.profile);
39664
+ else if (!saved && !loadConnectedAgentProfile(options.configDir, ownProfile) && !enrollmentAddress(options.configDir, ownProfile).exists) {
39665
+ let offline = false;
39666
+ for (const candidate of profilesBoundToSession(options.configDir, sessionId, ownProfile, runtime === "codex" ? "native" : "external")) {
39667
+ const state = await deps.connectionState(candidate.profile);
39668
+ if (state === "connected") {
39669
+ profileName = candidate.name;
39670
+ offline = false;
39671
+ break;
39672
+ }
39673
+ if (state === "unavailable") offline = true;
39674
+ }
39675
+ if (offline) return {
39676
+ ...base,
39677
+ session: sessionId,
39678
+ receiving: RECEIVING[runtime],
39679
+ status: "offline",
39680
+ detail: "A saved profile is connected for this session, but its status could not be checked right now. No new address was created."
39681
+ };
39682
+ }
39683
+ } catch {
39684
+ profileName = ownProfile;
39685
+ }
39686
+ const reused = profileName !== ownProfile;
38622
39687
  const known = {
38623
39688
  ...base,
38624
39689
  session: sessionId,
@@ -38651,7 +39716,7 @@ async function registerSession(options) {
38651
39716
  let record = readSessionRecord(options.configDir, sessionId);
38652
39717
  if (record?.endedAt) return await ended(record);
38653
39718
  const finishAgentInfo = async () => {
38654
- if (record?.agentInfo) return;
39719
+ if (reused || record?.agentInfo) return;
38655
39720
  const seeded = await deps.seedAgentInfo(options.configDir, profileName, agentInfoForSession(runtime, cwd));
38656
39721
  if (seeded !== "created" && seeded !== "already_present") return;
38657
39722
  record = await updateSessionRecord(options.configDir, sessionId, (current) => current ? {
@@ -40386,10 +41451,10 @@ function contactRequestSessionKey(context, env = process.env) {
40386
41451
  }
40387
41452
  if (typeof raw !== "object" || Array.isArray(raw)) throw sessionRequired();
40388
41453
  const setup = raw;
40389
- const session = mailId(setup.session);
40390
41454
  const receiverMode = setup.receiverMode ?? "native";
41455
+ const poll = receiverMode === "poll" && setup.session === null;
40391
41456
  const receipt = setup.receipt;
40392
- if (setup.version !== 1 || setup.invitationHash !== profile.invitation_hash || setup.phase !== "sent" || !["native", "external"].includes(String(receiverMode)) || !receipt || typeof receipt !== "object" || Array.isArray(receipt) || ![
41457
+ if (setup.version !== 1 || setup.invitationHash !== profile.invitation_hash || setup.phase !== "sent" || !(poll || ["native", "external"].includes(String(receiverMode))) || !receipt || typeof receipt !== "object" || Array.isArray(receipt) || ![
40393
41458
  "queued",
40394
41459
  "submitted_to_agent",
40395
41460
  "delivered",
@@ -40397,6 +41462,8 @@ function contactRequestSessionKey(context, env = process.env) {
40397
41462
  "scheduled"
40398
41463
  ].includes(String(receipt.status))) throw sessionRequired();
40399
41464
  mailId(receipt.id);
41465
+ if (poll) return null;
41466
+ const session = mailId(setup.session);
40400
41467
  const key = `${receiverMode === "external" ? "claude" : "codex"}:${session}`;
40401
41468
  if (hasRuntime && runtime !== key || !hasRuntime && receiverMode !== "external") throw sessionRequired();
40402
41469
  return key;
@@ -45882,7 +46949,7 @@ const PRIMITIVE_TEAM_AUTHOR = {
45882
46949
  url: "https://primitive.dev"
45883
46950
  };
45884
46951
  const SDK_VERSION_RANGE = "^1.34.0";
45885
- const CLI_VERSION_RANGE = "^1.39.0";
46952
+ const CLI_VERSION_RANGE = "^1.41.0";
45886
46953
  const ESBUILD_VERSION_RANGE = "^0.27.0";
45887
46954
  function renderHandler() {
45888
46955
  return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
@@ -50327,6 +51394,22 @@ const RESUME_LOCK_RETRY_STEP_MS = 250;
50327
51394
  function isListenLockContention(error) {
50328
51395
  return error instanceof ListenStateError && error.message.startsWith("Another listener is using this subscription");
50329
51396
  }
51397
+ const CLAUDE_NOTIFY_SESSION_GUIDANCE = "This is a Claude Code session. Claude Code sessions receive mail through hooks, not --notify-session. Run `primitive agent connect --status --profile <profile>` to check the hook, or `primitive machine doctor --fix` to install it.";
51398
+ /**
51399
+ * The session is a Claude Code session rather than a Codex thread: it is the
51400
+ * current Claude session, or this machine registered it as one.
51401
+ */
51402
+ function claudeCodeSession(sessionId, env, configDir) {
51403
+ const id = sessionId.toLowerCase();
51404
+ if (env.CODEX_THREAD_ID?.toLowerCase() === id) return false;
51405
+ if (env.CLAUDE_CODE_SESSION_ID?.toLowerCase() === id) return true;
51406
+ try {
51407
+ const saved = readMailJson(join(configDir, "machine", "sessions", `${id}.json`));
51408
+ return saved?.session === id && saved.runtime === "claude";
51409
+ } catch {
51410
+ return false;
51411
+ }
51412
+ }
50330
51413
  var ListenCommand = class ListenCommand extends Command {
50331
51414
  static summary = "Receive webhook events locally without a public endpoint";
50332
51415
  static description = "Subscribe once and reconnect using the same durable server queue. Connected-agent credentials automatically receive only their assigned address. Use --notify-session with an exact loaded session UUID and --contacts for saved contact preferences and eligible same-org network peers, or approved --sender addresses, for external mail events at tool-output authority, never synthetic user messages. Explicit silence still wins. Use a short --exec hook to durably accept an event, --forward-to for a local webhook, or newline-delimited JSON on stdout. Add --background to keep native receiving independent of the calling terminal process; --status reports receiver health and receipts, add --email-id for one email's routing evidence, and --stop stops that receiver. Status is JSON and stdout events are JSONL by default; --json is accepted explicitly without changing delivery mode. Notifications require an existing native local-session socket; the CLI subscribes only to the exact already-loaded thread and never launches a terminal or creates a session. Verified presence controls are handled without model turns and do not consume --once or --number. Claude hook capability: primitive-hook-profile-bound-v2.";
@@ -50538,6 +51621,7 @@ var ListenCommand = class ListenCommand extends Command {
50538
51621
  }
50539
51622
  if (flags.wake && !hookSessionId) throw new Errors.CLIError("--wake requires --hook-session with an exact session_id.");
50540
51623
  if (flags["notify-session"] !== void 0 && !SESSION_UUID$1.test(flags["notify-session"])) throw new Errors.CLIError("--notify-session requires an exact session UUID.");
51624
+ if (flags["notify-session"] && !flags.status && !flags.stop && claudeCodeSession(flags["notify-session"], hookSessionId ? {} : process.env, this.config.configDir)) throw new Errors.CLIError(CLAUDE_NOTIFY_SESSION_GUIDANCE);
50541
51625
  if (flags["notify-session"] && flags.transport === "poll") throw new Errors.CLIError("--notify-session requires --transport websocket.");
50542
51626
  if (flags["notify-session"] && flags.subscription !== void 0) throw new Errors.CLIError("--notify-session uses the shared saved subscription; omit --subscription.");
50543
51627
  if (flags.subscription?.trim().startsWith("local-mail-")) throw new Errors.CLIError("Subscription names beginning local-mail- are reserved for shared mail receiving.");
@@ -51811,426 +52895,83 @@ async function runSignupInteractiveWithCredentialLock(params) {
51811
52895
  const verificationCode = await promptRequiredFn(`Verification code (${start.verification_code_length} digits): `);
51812
52896
  if (verificationCode.toLowerCase() === "resend") {
51813
52897
  const resend = await resendVerificationCode({
51814
- apiBaseUrl,
51815
- apiClient,
51816
- configDir,
51817
- deps,
51818
- start
51819
- });
51820
- start = resend.pending;
51821
- if (resend.resent) process$1.stderr.write(`Sent a new ${start.verification_code_length}-digit verification code. It expires in ${formatSignupSeconds(start.expires_in)}.\n`);
51822
- continue;
51823
- }
51824
- try {
51825
- await runSignupConfirmWithCredentialLock({
51826
- code: verificationCode,
51827
- configDir,
51828
- deps,
51829
- email: start.email,
51830
- flags: {
51831
- "api-base-url": flags["api-base-url"],
51832
- force: true
51833
- },
51834
- skipExistingCredentialCheck: true
51835
- });
51836
- return;
51837
- } catch (error) {
51838
- if (error instanceof Errors.CLIError && error.message.startsWith("Invalid verification code.")) {
51839
- process$1.stderr.write("Invalid verification code. Try again or type `resend`.\n");
51840
- continue;
51841
- }
51842
- throw error;
51843
- }
51844
- }
51845
- }
51846
- function commonStartFlags() {
51847
- return {
51848
- "accept-terms": Flags.boolean({ description: "Confirm acceptance of Primitive's Terms of Service and Privacy Policy" }),
51849
- "api-base-url": Flags.string({
51850
- description: "Override the primary API base URL. Internal testing only; not documented to customers.",
51851
- env: "PRIMITIVE_API_BASE_URL",
51852
- hidden: true
51853
- }),
51854
- "device-name": Flags.string({ description: "Device name used for the created CLI OAuth session" }),
51855
- force: Flags.boolean({
51856
- char: "f",
51857
- description: "Replace saved credentials or pending signup state when needed"
51858
- }),
51859
- "signup-code": Flags.string({
51860
- description: "Optional signup code. Omit if you do not have one.",
51861
- env: "PRIMITIVE_SIGNUP_CODE"
51862
- })
51863
- };
51864
- }
51865
- var SignupCommand = class SignupCommand extends Command {
51866
- static args = { email: Args.string({
51867
- description: "Email address to sign up",
51868
- required: false
51869
- }) };
51870
- static description = "Start a Primitive account signup, send an email verification code, and save a pending signup token locally.";
51871
- static summary = "Start account signup";
51872
- static examples = [
51873
- "<%= config.bin %> signup user@example.com",
51874
- "<%= config.bin %> signup user@example.com --accept-terms",
51875
- "<%= config.bin %> signup user@example.com --signup-code invite-code --accept-terms",
51876
- "<%= config.bin %> signup confirm user@example.com 123456"
51877
- ];
51878
- static flags = commonStartFlags();
51879
- async run() {
51880
- const { args, flags } = await this.parse(SignupCommand);
51881
- let releaseCredentialsLock;
51882
- try {
51883
- releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
51884
- } catch (error) {
51885
- throw cliError$2(error instanceof Error ? error.message : String(error));
51886
- }
51887
- try {
51888
- await runSignupStartWithCredentialLock({
51889
- configDir: this.config.configDir,
51890
- email: args.email,
51891
- flags
51892
- });
51893
- } finally {
51894
- releaseCredentialsLock();
51895
- }
51896
- }
51897
- };
51898
- function resolveVerificationCode(input) {
51899
- const sources = [
51900
- input.positional !== void 0 ? "positional" : null,
51901
- input.fromStdin === true ? "--code-from-stdin" : null,
51902
- input.fromFile !== void 0 ? "--code-from-file" : null,
51903
- input.fromEnv !== void 0 ? "--code-from-env" : null
51904
- ].filter((v) => v !== null);
51905
- if (sources.length === 0) return {
51906
- kind: "error",
51907
- message: "Pass the verification code as a positional argument or via one of --code-from-stdin, --code-from-file, or --code-from-env."
51908
- };
51909
- if (sources.length > 1) return {
51910
- kind: "error",
51911
- message: `Pass exactly one source for the verification code; got ${sources.join(", ")}.`
51912
- };
51913
- if (input.positional !== void 0) return {
51914
- kind: "ok",
51915
- code: input.positional
51916
- };
51917
- if (input.fromEnv !== void 0) {
51918
- const value = (input.env ?? process$1.env)[input.fromEnv];
51919
- if (value === void 0) return {
51920
- kind: "error",
51921
- message: `--code-from-env ${input.fromEnv}: environment variable is not set.`
51922
- };
51923
- return {
51924
- kind: "ok",
51925
- code: stripTrailingNewline(value)
51926
- };
51927
- }
51928
- if (input.fromFile !== void 0) {
51929
- const readFile = input.readFile ?? defaultReadCodeFile;
51930
- try {
51931
- return {
51932
- kind: "ok",
51933
- code: stripTrailingNewline(readFile(input.fromFile))
51934
- };
51935
- } catch (error) {
51936
- const detail = error instanceof Error ? error.message : String(error);
51937
- return {
51938
- kind: "error",
51939
- message: `--code-from-file ${input.fromFile}: could not read file: ${detail}`
51940
- };
51941
- }
51942
- }
51943
- const readStdin = input.readStdin ?? defaultReadCodeStdin;
51944
- try {
51945
- return {
51946
- kind: "ok",
51947
- code: stripTrailingNewline(readStdin())
51948
- };
51949
- } catch (error) {
51950
- return {
51951
- kind: "error",
51952
- message: `--code-from-stdin: ${error instanceof Error ? error.message : String(error)}`
51953
- };
51954
- }
51955
- }
51956
- function stripTrailingNewline(value) {
51957
- return value.replace(/\r?\n$/, "");
51958
- }
51959
- function defaultReadCodeFile(path) {
51960
- return readFileSync(path, "utf8");
51961
- }
51962
- function defaultReadCodeStdin() {
51963
- if (process$1.stdin.isTTY) throw new Error("stdin is a TTY; pipe the code into this command or use --code-from-file / --code-from-env instead.");
51964
- return readFileSync(0, "utf8");
51965
- }
51966
- var SignupConfirmCommand = class SignupConfirmCommand extends Command {
51967
- static args = {
51968
- email: Args.string({
51969
- description: "Email address used to start signup",
51970
- required: true
51971
- }),
51972
- code: Args.string({
51973
- description: "Verification code from the signup email. Optional when one of --code-from-stdin / --code-from-file / --code-from-env is passed; exactly one source must be set.",
51974
- required: false
51975
- })
51976
- };
51977
- static description = "Confirm a pending Primitive signup, create an OAuth session, and save CLI credentials locally.";
51978
- static summary = "Confirm account signup";
51979
- static examples = [
51980
- "<%= config.bin %> signup confirm user@example.com 123456",
51981
- "<%= config.bin %> signup confirm user@example.com 123456 --org-id 00000000-0000-4000-8000-000000000000",
51982
- "read -rs CODE && CODE=\"$CODE\" <%= config.bin %> signup confirm user@example.com --code-from-env CODE && unset CODE",
51983
- "read -rs CODE && printf '%s' \"$CODE\" | <%= config.bin %> signup confirm user@example.com --code-from-stdin && unset CODE",
51984
- "<%= config.bin %> signup confirm user@example.com --code-from-file /run/user/$(id -u)/verification-code"
51985
- ];
51986
- static flags = {
51987
- "api-base-url": Flags.string({
51988
- description: "Override the primary API base URL. Internal testing only; not documented to customers.",
51989
- env: "PRIMITIVE_API_BASE_URL",
51990
- hidden: true
51991
- }),
51992
- "code-from-stdin": Flags.boolean({ description: "Read the verification code from stdin instead of the positional argument. Use when an agent is constructing the command for the user to run, so the code never enters the agent's prompt context." }),
51993
- "code-from-file": Flags.string({ description: "Read the verification code from a UTF-8 file at this path. Trailing newlines are stripped." }),
51994
- "code-from-env": Flags.string({ description: "Read the verification code from this environment variable. Pair with `read -rs CODE && CODE=\"$CODE\" primitive signup confirm <email> --code-from-env CODE && unset CODE` so the value never appears on the command line or in shell history. Plain `read` creates a shell-local variable that child processes cannot see; the inline `CODE=\"$CODE\"` exports it for just the one command." }),
51995
- force: Flags.boolean({
51996
- char: "f",
51997
- description: "Replace saved credentials after verification"
51998
- }),
51999
- "org-id": Flags.string({ description: "Workspace id to target when the email belongs to multiple workspaces" })
52000
- };
52001
- async run() {
52002
- const { args, flags } = await this.parse(SignupConfirmCommand);
52003
- const resolvedCode = resolveVerificationCode({
52004
- positional: args.code,
52005
- fromStdin: flags["code-from-stdin"] === true,
52006
- fromFile: flags["code-from-file"],
52007
- fromEnv: flags["code-from-env"]
52008
- });
52009
- if (resolvedCode.kind === "error") throw cliError$2(resolvedCode.message);
52010
- let releaseCredentialsLock;
52011
- try {
52012
- releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
52013
- } catch (error) {
52014
- throw cliError$2(error instanceof Error ? error.message : String(error));
52015
- }
52016
- try {
52017
- await runSignupConfirmWithCredentialLock({
52018
- code: resolvedCode.code,
52019
- configDir: this.config.configDir,
52020
- email: args.email,
52021
- flags
52022
- });
52023
- } finally {
52024
- releaseCredentialsLock();
52025
- }
52026
- }
52027
- };
52028
- var SignupResendCommand = class SignupResendCommand extends Command {
52029
- static args = { email: Args.string({
52030
- description: "Email address used to start signup. Defaults to the saved pending signup.",
52031
- required: false
52032
- }) };
52033
- static description = "Resend the verification code for a pending signup.";
52034
- static summary = "Resend signup verification code";
52035
- static examples = ["<%= config.bin %> signup resend", "<%= config.bin %> signup resend user@example.com"];
52036
- static flags = { "api-base-url": Flags.string({
52037
- description: "Override the primary API base URL. Internal testing only; not documented to customers.",
52038
- env: "PRIMITIVE_API_BASE_URL",
52039
- hidden: true
52040
- }) };
52041
- async run() {
52042
- const { args, flags } = await this.parse(SignupResendCommand);
52043
- let releaseCredentialsLock;
52044
- try {
52045
- releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
52046
- } catch (error) {
52047
- throw cliError$2(error instanceof Error ? error.message : String(error));
52048
- }
52049
- try {
52050
- await runSignupResendWithCredentialLock({
52051
- configDir: this.config.configDir,
52052
- email: args.email,
52053
- flags
52054
- });
52055
- } finally {
52056
- releaseCredentialsLock();
52057
- }
52058
- }
52059
- };
52060
- var SignupStatusCommand = class SignupStatusCommand extends Command {
52061
- static args = { email: Args.string({
52062
- description: "Email address expected in the pending signup",
52063
- required: false
52064
- }) };
52065
- static description = "Inspect the locally saved pending Primitive signup state.";
52066
- static summary = "Show pending signup status";
52067
- static examples = [
52068
- "<%= config.bin %> signup status",
52069
- "<%= config.bin %> signup status user@example.com",
52070
- "<%= config.bin %> signup status --json"
52071
- ];
52072
- static flags = {
52073
- "api-base-url": Flags.string({
52074
- description: "Override the primary API base URL. Internal testing only; not documented to customers.",
52075
- env: "PRIMITIVE_API_BASE_URL",
52076
- hidden: true
52077
- }),
52078
- json: Flags.boolean({ description: "Print pending signup status as JSON" })
52079
- };
52080
- async run() {
52081
- const { args, flags } = await this.parse(SignupStatusCommand);
52082
- runSignupStatus({
52083
- configDir: this.config.configDir,
52084
- email: args.email,
52085
- flags
52086
- });
52087
- }
52088
- };
52089
- var SignupInteractiveCommand = class SignupInteractiveCommand extends Command {
52090
- static description = "Run the full signup flow in one interactive terminal session.";
52091
- static summary = "Run interactive account signup";
52092
- static examples = ["<%= config.bin %> signup interactive"];
52093
- static flags = commonStartFlags();
52094
- async run() {
52095
- const { flags } = await this.parse(SignupInteractiveCommand);
52096
- let releaseCredentialsLock;
52097
- try {
52098
- releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
52099
- } catch (error) {
52100
- throw cliError$2(error instanceof Error ? error.message : String(error));
52101
- }
52102
- try {
52103
- await runSignupInteractiveWithCredentialLock({
52104
- configDir: this.config.configDir,
52105
- flags
52106
- });
52107
- } finally {
52108
- releaseCredentialsLock();
52109
- }
52110
- }
52111
- };
52112
- //#endregion
52113
- //#region src/oclif/commands/logout.ts
52114
- function cliError$1(message) {
52115
- return new Errors.CLIError(message, { exit: 1 });
52116
- }
52117
- function unwrapData(value) {
52118
- return value?.data ?? null;
52119
- }
52120
- function isSavedOAuthSessionExpiredError(error) {
52121
- return error instanceof Error && error.message === "Saved Primitive CLI OAuth session expired or was revoked. Run `primitive signin` to authenticate again.";
52122
- }
52123
- async function runLogoutWithCredentialLock(params) {
52124
- requireDefaultLoginProfile();
52125
- const deps = {
52126
- cliLogout,
52127
- createAuthenticatedCliApiClient,
52128
- ...params.deps
52129
- };
52130
- let credentials;
52131
- try {
52132
- credentials = loadCliCredentials(params.configDir);
52133
- } catch (error) {
52134
- deleteCliCredentials(params.configDir);
52135
- const detail = error instanceof Error ? error.message : String(error);
52136
- process.stderr.write(`Removed unreadable Primitive CLI credentials. Backing OAuth grant was not revoked: ${detail}\n`);
52137
- process.exitCode = 1;
52138
- return;
52139
- }
52140
- if (!credentials) throw cliError$1("Not logged in. Run `primitive signin` to create saved CLI credentials.");
52141
- let authenticated;
52142
- try {
52143
- authenticated = await deps.createAuthenticatedCliApiClient({
52144
- apiBaseUrl: params.flags["api-base-url"],
52145
- configDir: params.configDir,
52146
- credentialsLockHeld: true
52147
- });
52148
- } catch (error) {
52149
- if (isSavedOAuthSessionExpiredError(error) && loadCliCredentials(params.configDir) === null) {
52150
- process.stderr.write("Logged out (OAuth session was already expired or revoked on the server).\n");
52151
- return;
52898
+ apiBaseUrl,
52899
+ apiClient,
52900
+ configDir,
52901
+ deps,
52902
+ start
52903
+ });
52904
+ start = resend.pending;
52905
+ if (resend.resent) process$1.stderr.write(`Sent a new ${start.verification_code_length}-digit verification code. It expires in ${formatSignupSeconds(start.expires_in)}.\n`);
52906
+ continue;
52152
52907
  }
52153
- throw error;
52154
- }
52155
- const freshCredentials = authenticated.auth.credentials ?? credentials;
52156
- const result = await deps.cliLogout({
52157
- body: { key_id: freshCredentials.oauth_grant_id },
52158
- client: authenticated.apiClient.client,
52159
- responseStyle: "fields"
52160
- });
52161
- if (result.error) {
52162
- const payload = extractErrorPayload(result.error);
52163
- const code = extractErrorCode(payload);
52164
- if (code === API_ERROR_CODES.unauthorized || code === API_ERROR_CODES.notFound) {
52165
- deleteCliCredentials(params.configDir);
52166
- writeErrorWithHints(payload);
52167
- process.stderr.write("Removed saved Primitive CLI credentials because the backing OAuth grant is already unavailable.\n");
52168
- process.exitCode = 1;
52908
+ try {
52909
+ await runSignupConfirmWithCredentialLock({
52910
+ code: verificationCode,
52911
+ configDir,
52912
+ deps,
52913
+ email: start.email,
52914
+ flags: {
52915
+ "api-base-url": flags["api-base-url"],
52916
+ force: true
52917
+ },
52918
+ skipExistingCredentialCheck: true
52919
+ });
52169
52920
  return;
52921
+ } catch (error) {
52922
+ if (error instanceof Errors.CLIError && error.message.startsWith("Invalid verification code.")) {
52923
+ process$1.stderr.write("Invalid verification code. Try again or type `resend`.\n");
52924
+ continue;
52925
+ }
52926
+ throw error;
52170
52927
  }
52171
- writeErrorWithHints(payload);
52172
- throw cliError$1("Could not revoke the saved Primitive CLI OAuth grant.");
52173
- }
52174
- const logout = unwrapData(result.data);
52175
- deleteCliCredentials(params.configDir);
52176
- const grantId = logout?.oauth_grant_id ?? freshCredentials.oauth_grant_id;
52177
- process.stderr.write(`Logged out and revoked OAuth grant ${grantId}.\n`);
52178
- }
52179
- function runForceLogout(params) {
52180
- requireDefaultLoginProfile();
52181
- const localCredentialsPath = credentialsPath(params.configDir);
52182
- const pendingPath = pendingSignupPath(params.configDir);
52183
- const lockPath = credentialsLockPath(params.configDir);
52184
- const removed = [
52185
- existsSync(localCredentialsPath) ? "local Primitive CLI credentials" : null,
52186
- existsSync(chatStatePath(params.configDir)) ? "local chat reply state" : null,
52187
- existsSync(pendingPath) ? "pending email-code auth state" : null,
52188
- existsSync(lockPath) ? "credential lock" : null
52189
- ].filter((value) => value !== null);
52190
- deleteCliCredentials(params.configDir);
52191
- deletePendingAgentSignup(params.configDir);
52192
- deleteCliCredentialsLock(params.configDir);
52193
- if (removed.length === 0) {
52194
- process.stderr.write("No local Primitive CLI auth state was present. Backing OAuth grant was not revoked.\n");
52195
- return;
52196
52928
  }
52197
- process.stderr.write(`Removed ${formatList(removed)}. Backing OAuth grant was not revoked.\n`);
52198
- }
52199
- function formatList(values) {
52200
- if (values.length <= 1) return values[0] ?? "";
52201
- if (values.length === 2) return `${values[0]} and ${values[1]}`;
52202
- return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
52203
52929
  }
52204
- var LogoutCommand = class LogoutCommand extends Command {
52205
- static description = "Log out by revoking the saved Primitive CLI OAuth grant and deleting local credentials. Use --force to remove local credentials, pending email-code auth state, and stale credential locks without contacting Primitive.";
52206
- static summary = "Log out and revoke the saved CLI OAuth grant";
52207
- static examples = ["<%= config.bin %> logout", "<%= config.bin %> logout --force"];
52208
- static flags = {
52930
+ function commonStartFlags() {
52931
+ return {
52932
+ "accept-terms": Flags.boolean({ description: "Confirm acceptance of Primitive's Terms of Service and Privacy Policy" }),
52209
52933
  "api-base-url": Flags.string({
52210
52934
  description: "Override the primary API base URL. Internal testing only; not documented to customers.",
52211
52935
  env: "PRIMITIVE_API_BASE_URL",
52212
52936
  hidden: true
52213
52937
  }),
52938
+ "device-name": Flags.string({ description: "Device name used for the created CLI OAuth session" }),
52214
52939
  force: Flags.boolean({
52215
52940
  char: "f",
52216
- description: "Remove local CLI credentials, pending email-code auth state, and any credential lock without revoking the server OAuth grant"
52941
+ description: "Replace saved credentials or pending signup state when needed"
52942
+ }),
52943
+ "signup-code": Flags.string({
52944
+ description: "Optional signup code. Omit if you do not have one.",
52945
+ env: "PRIMITIVE_SIGNUP_CODE"
52217
52946
  })
52218
52947
  };
52948
+ }
52949
+ var SignupCommand = class SignupCommand extends Command {
52950
+ static args = { email: Args.string({
52951
+ description: "Email address to sign up",
52952
+ required: false
52953
+ }) };
52954
+ static description = "Start a Primitive account signup, send an email verification code, and save a pending signup token locally.";
52955
+ static summary = "Start account signup";
52956
+ static examples = [
52957
+ "<%= config.bin %> signup user@example.com",
52958
+ "<%= config.bin %> signup user@example.com --accept-terms",
52959
+ "<%= config.bin %> signup user@example.com --signup-code invite-code --accept-terms",
52960
+ "<%= config.bin %> signup confirm user@example.com 123456"
52961
+ ];
52962
+ static flags = commonStartFlags();
52219
52963
  async run() {
52220
- const { flags } = await this.parse(LogoutCommand);
52221
- if (flags.force) {
52222
- runForceLogout({ configDir: this.config.configDir });
52223
- return;
52224
- }
52964
+ const { args, flags } = await this.parse(SignupCommand);
52225
52965
  let releaseCredentialsLock;
52226
52966
  try {
52227
52967
  releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
52228
52968
  } catch (error) {
52229
- throw cliError$1(error instanceof Error ? error.message : String(error));
52969
+ throw cliError$2(error instanceof Error ? error.message : String(error));
52230
52970
  }
52231
52971
  try {
52232
- await runLogoutWithCredentialLock({
52972
+ await runSignupStartWithCredentialLock({
52233
52973
  configDir: this.config.configDir,
52974
+ email: args.email,
52234
52975
  flags
52235
52976
  });
52236
52977
  } finally {
@@ -52238,667 +52979,349 @@ var LogoutCommand = class LogoutCommand extends Command {
52238
52979
  }
52239
52980
  }
52240
52981
  };
52241
- //#endregion
52242
- //#region src/oclif/machine-files.ts
52243
- /** Hand-edited files larger than this are reported, never rewritten. */
52244
- const MAX_MANAGED_FILE_BYTES = 1048576;
52245
- /** Backups kept per file; older Primitive backups of the same file are pruned. */
52246
- const BACKUPS_KEPT = 10;
52247
- const BACKUP_INFIX = ".primitive-bak-";
52248
- /** A safe, user-facing reason a file was not changed. */
52249
- var MachineFileError = class extends Error {};
52250
- /**
52251
- * Read a user-owned text file without following it anywhere surprising. A
52252
- * symlink is followed to its real file so a later write updates the file the
52253
- * user linked instead of replacing the link.
52254
- */
52255
- function readManagedFile(path) {
52256
- let info;
52257
- try {
52258
- info = lstatSync(path);
52259
- } catch (error) {
52260
- if (error.code === "ENOENT") return {
52261
- state: "absent",
52262
- path,
52263
- target: path
52264
- };
52265
- return {
52266
- state: "invalid",
52267
- path,
52268
- detail: `${path} cannot be read (${error.code ?? "error"}).`
52269
- };
52270
- }
52271
- let target = path;
52272
- if (info.isSymbolicLink()) try {
52273
- target = realpathSync(path);
52274
- info = statSync(target);
52275
- } catch {
52276
- return {
52277
- state: "invalid",
52278
- path,
52279
- detail: `${path} is a symlink to a missing file; it was left unchanged.`
52280
- };
52281
- }
52282
- if (!info.isFile()) return {
52283
- state: "invalid",
52284
- path,
52285
- detail: `${path} is not a regular file; it was left unchanged.`
52982
+ function resolveVerificationCode(input) {
52983
+ const sources = [
52984
+ input.positional !== void 0 ? "positional" : null,
52985
+ input.fromStdin === true ? "--code-from-stdin" : null,
52986
+ input.fromFile !== void 0 ? "--code-from-file" : null,
52987
+ input.fromEnv !== void 0 ? "--code-from-env" : null
52988
+ ].filter((v) => v !== null);
52989
+ if (sources.length === 0) return {
52990
+ kind: "error",
52991
+ message: "Pass the verification code as a positional argument or via one of --code-from-stdin, --code-from-file, or --code-from-env."
52286
52992
  };
52287
- if (info.size > MAX_MANAGED_FILE_BYTES) return {
52288
- state: "invalid",
52289
- path,
52290
- detail: `${path} is larger than 1 MiB; it was left unchanged.`
52993
+ if (sources.length > 1) return {
52994
+ kind: "error",
52995
+ message: `Pass exactly one source for the verification code; got ${sources.join(", ")}.`
52291
52996
  };
52292
- let raw;
52293
- try {
52294
- raw = readFileSync(target);
52295
- } catch (error) {
52997
+ if (input.positional !== void 0) return {
52998
+ kind: "ok",
52999
+ code: input.positional
53000
+ };
53001
+ if (input.fromEnv !== void 0) {
53002
+ const value = (input.env ?? process$1.env)[input.fromEnv];
53003
+ if (value === void 0) return {
53004
+ kind: "error",
53005
+ message: `--code-from-env ${input.fromEnv}: environment variable is not set.`
53006
+ };
52296
53007
  return {
52297
- state: "invalid",
52298
- path,
52299
- detail: `${path} cannot be read (${error.code ?? "error"}).`
53008
+ kind: "ok",
53009
+ code: stripTrailingNewline(value)
52300
53010
  };
52301
53011
  }
52302
- const text = raw.toString("utf8");
52303
- if (text.includes("\0") || !Buffer.from(text, "utf8").equals(raw)) return {
52304
- state: "invalid",
52305
- path,
52306
- detail: `${path} is not UTF-8 text; it was left unchanged.`
52307
- };
52308
- return {
52309
- state: "present",
52310
- path,
52311
- target,
52312
- text,
52313
- mode: info.mode & 511
52314
- };
52315
- }
52316
- function backupStamp$1(now) {
52317
- return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
52318
- }
52319
- function syncDirectory(directory) {
52320
- if (process.platform === "win32") return;
52321
- try {
52322
- const fd = openSync(directory, "r");
52323
- try {
52324
- fsyncSync(fd);
52325
- } finally {
52326
- closeSync(fd);
52327
- }
52328
- } catch {}
52329
- }
52330
- function pruneBackups(target) {
52331
- const directory = dirname(target);
52332
- const prefix = `${basename(target)}${BACKUP_INFIX}`;
52333
- try {
52334
- const backups = readdirSync(directory).filter((name) => name.startsWith(prefix)).sort();
52335
- for (const name of backups.slice(0, Math.max(0, backups.length - BACKUPS_KEPT))) unlinkSync(join(directory, name));
52336
- } catch {}
52337
- }
52338
- /** Copy the current file beside itself before any change. Returns the copy's path. */
52339
- function backupManagedFile(target, mode, now) {
52340
- const base = `${target}${BACKUP_INFIX}${backupStamp$1(now)}`;
52341
- for (let attempt = 0; attempt < 100; attempt++) {
52342
- const candidate = attempt === 0 ? base : `${base}-${attempt}`;
52343
- try {
52344
- copyFileSync(target, candidate, constants.COPYFILE_EXCL);
52345
- chmodSync(candidate, mode);
52346
- return candidate;
52347
- } catch (error) {
52348
- if (error.code !== "EEXIST") throw error;
52349
- }
52350
- }
52351
- throw new MachineFileError(`Could not create a backup of ${target}.`);
52352
- }
52353
- /**
52354
- * Replace a file atomically (temporary file plus rename in the same
52355
- * directory), after backing up the existing content. The write is refused when
52356
- * the file changed since it was read, so a concurrent edit is never lost.
52357
- */
52358
- function writeManagedFile(params) {
52359
- const { read } = params;
52360
- const target = read.target;
52361
- const expected = read.state === "present" ? read.text : null;
52362
- const directory = dirname(target);
52363
- mkdirSync(directory, {
52364
- recursive: true,
52365
- mode: 448
52366
- });
52367
- const current = () => {
53012
+ if (input.fromFile !== void 0) {
53013
+ const readFile = input.readFile ?? defaultReadCodeFile;
52368
53014
  try {
52369
- return readFileSync(target, "utf8");
53015
+ return {
53016
+ kind: "ok",
53017
+ code: stripTrailingNewline(readFile(input.fromFile))
53018
+ };
52370
53019
  } catch (error) {
52371
- if (error.code === "ENOENT") return null;
52372
- throw error;
52373
- }
52374
- };
52375
- if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
52376
- const mode = read.state === "present" ? read.mode : params.newFileMode ?? 420;
52377
- const backup = read.state === "present" ? backupManagedFile(target, mode, (params.now ?? (() => /* @__PURE__ */ new Date()))()) : null;
52378
- const temporary = join(directory, `.${basename(target)}.${randomUUID()}.tmp`);
52379
- try {
52380
- const fd = openSync(temporary, "wx", mode);
52381
- try {
52382
- writeFileSync(fd, params.content, "utf8");
52383
- fsyncSync(fd);
52384
- } finally {
52385
- closeSync(fd);
53020
+ const detail = error instanceof Error ? error.message : String(error);
53021
+ return {
53022
+ kind: "error",
53023
+ message: `--code-from-file ${input.fromFile}: could not read file: ${detail}`
53024
+ };
52386
53025
  }
52387
- chmodSync(temporary, mode);
52388
- if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
52389
- renameSync(temporary, target);
52390
- syncDirectory(directory);
52391
- } finally {
52392
- if (existsSync(temporary)) unlinkSync(temporary);
52393
- }
52394
- if (backup) pruneBackups(target);
52395
- return { backup };
52396
- }
52397
- const START = /^<!-- primitive:managed-block v=(\d+) START -->\r?$/;
52398
- const END = /^<!-- primitive:managed-block END -->\r?$/;
52399
- /** Character offsets of the single managed block, or why the markers are unusable. */
52400
- function locateBlock(text) {
52401
- const starts = [];
52402
- const ends = [];
52403
- let offset = 0;
52404
- for (const line of text.split("\n")) {
52405
- const start = START.exec(line);
52406
- if (start) starts.push({
52407
- offset,
52408
- version: Number(start[1])
52409
- });
52410
- else if (END.test(line)) ends.push({
52411
- offset,
52412
- length: line.endsWith("\r") ? line.length - 1 : line.length
52413
- });
52414
- else if (line.includes("primitive:managed-block")) return { malformed: "a primitive:managed-block marker line was edited; fix or remove it by hand" };
52415
- offset += line.length + 1;
52416
- }
52417
- if (starts.length === 0 && ends.length === 0) return null;
52418
- if (starts.length !== 1 || ends.length !== 1) return { malformed: `found ${starts.length} START and ${ends.length} END markers; expected one of each` };
52419
- const [start] = starts;
52420
- const [end] = ends;
52421
- if (!start || !end || end.offset < start.offset) return { malformed: "the END marker comes before the START marker" };
52422
- return {
52423
- start: start.offset,
52424
- end: end.offset + end.length,
52425
- version: start.version
52426
- };
52427
- }
52428
- /** The exact block for a body, using the file's own line ending. */
52429
- function renderManagedBlock(body, eol = "\n") {
52430
- return [
52431
- `<!-- primitive:managed-block v=1 START -->`,
52432
- ...body.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n"),
52433
- "<!-- primitive:managed-block END -->"
52434
- ].join(eol);
52435
- }
52436
- function lineEnding(text) {
52437
- return text.includes("\r\n") ? "\r\n" : "\n";
52438
- }
52439
- function inspectManagedBlock(text, body) {
52440
- const found = locateBlock(text);
52441
- if (found === null) return { state: "missing" };
52442
- if ("malformed" in found) return {
52443
- state: "malformed",
52444
- detail: found.malformed
52445
- };
52446
- return text.slice(found.start, found.end).replace(/\r\n/g, "\n") === renderManagedBlock(body) ? { state: "ok" } : {
52447
- state: "outdated",
52448
- version: found.version
52449
- };
52450
- }
52451
- /**
52452
- * Replace only the managed block, or append it when absent. Text outside the
52453
- * markers is preserved byte for byte. Throws for malformed markers.
52454
- */
52455
- function upsertManagedBlock(text, body) {
52456
- const found = locateBlock(text);
52457
- if (found !== null && "malformed" in found) throw new MachineFileError(found.malformed);
52458
- const eol = lineEnding(text);
52459
- const block = renderManagedBlock(body, eol);
52460
- if (found) return `${text.slice(0, found.start)}${block}${text.slice(found.end)}`;
52461
- if (text.length === 0) return `${block}${eol}`;
52462
- return `${text}${text.endsWith(`${eol}${eol}`) ? "" : text.endsWith(eol) ? eol : `${eol}${eol}`}${block}${eol}`;
52463
- }
52464
- /** Indentation the file already uses, so a rewrite keeps its style. */
52465
- function jsonIndentation(text) {
52466
- return /^[{[][^\n]*\n([ \t]+)\S/.exec(text)?.[1] ?? 2;
52467
- }
52468
- //#endregion
52469
- //#region src/oclif/claude-machine-hooks.ts
52470
- function record$2(value) {
52471
- return value !== null && typeof value === "object" && !Array.isArray(value);
52472
- }
52473
- function realpathOrNull(path) {
52474
- try {
52475
- return realpathSync(path);
52476
- } catch {
52477
- return null;
52478
53026
  }
52479
- }
52480
- /** Two paths name the same existing file. */
52481
- function samePath(a, b) {
52482
- if (typeof a !== "string" || !a) return false;
52483
- if (a === b) return existsSync(a);
52484
- const left = realpathOrNull(a);
52485
- return left !== null && left === realpathOrNull(b);
52486
- }
52487
- function executable(path) {
53027
+ const readStdin = input.readStdin ?? defaultReadCodeStdin;
52488
53028
  try {
52489
- accessSync(path, constants.X_OK);
52490
- return true;
52491
- } catch {
52492
- return false;
52493
- }
52494
- }
52495
- /** The first PATH entry for a command, without running a shell. */
52496
- function findOnPath(name, env) {
52497
- for (const directory of (env.PATH ?? "").split(delimiter)) {
52498
- if (!directory) continue;
52499
- const candidate = join(directory, name);
52500
- if (existsSync(candidate) && executable(candidate)) return candidate;
53029
+ return {
53030
+ kind: "ok",
53031
+ code: stripTrailingNewline(readStdin())
53032
+ };
53033
+ } catch (error) {
53034
+ return {
53035
+ kind: "error",
53036
+ message: `--code-from-stdin: ${error instanceof Error ? error.message : String(error)}`
53037
+ };
52501
53038
  }
52502
- return null;
52503
- }
52504
- /**
52505
- * Prefer a PATH link (for example a package manager's `bin/node`) over a
52506
- * versioned install directory when both are the same binary, so a hook keeps
52507
- * working after the versioned directory is replaced by an upgrade.
52508
- */
52509
- function stableNodePath(env, execPath = process.execPath) {
52510
- const onPath = findOnPath("node", env);
52511
- if (onPath && onPath !== execPath && samePath(onPath, execPath)) return onPath;
52512
- return execPath;
52513
- }
52514
- /** Locate the running CLI. Null when its files are not where a package install puts them. */
52515
- function currentCliLocation(entryArg, env, execPath = process.execPath) {
52516
- const entry = realpathOrNull(entryArg);
52517
- if (!entry) return null;
52518
- const wake = realpathOrNull(join(dirname(entry), "claude-wake.mjs"));
52519
- const pending = realpathOrNull(join(dirname(entry), "claude-pending-mail.mjs"));
52520
- if (!wake || !pending) return null;
52521
- return {
52522
- node: stableNodePath(env, execPath),
52523
- entry,
52524
- wake,
52525
- pending
52526
- };
52527
53039
  }
52528
- /** Package-runner caches are cleaned up behind the user's back. */
52529
- function ephemeralCliReason(entry) {
52530
- if (/[\\/]_npx[\\/]/.test(entry)) return "an npx cache";
52531
- if (/[\\/]dlx[-\\/]/.test(entry)) return "a pnpm dlx cache";
52532
- if (/[\\/]bunx-/.test(entry)) return "a bunx cache";
52533
- return null;
53040
+ function stripTrailingNewline(value) {
53041
+ return value.replace(/\r?\n$/, "");
52534
53042
  }
52535
- function claudeConfigDir(env, home) {
52536
- return resolve(env.CLAUDE_CONFIG_DIR || join(home, ".claude"));
53043
+ function defaultReadCodeFile(path) {
53044
+ return readFileSync(path, "utf8");
52537
53045
  }
52538
- function jsonErrorDetail(error) {
52539
- const message = error instanceof Error ? error.message : "";
52540
- const position = /position (\d+)/.exec(message)?.[1];
52541
- const line = /line (\d+)/.exec(message)?.[1];
52542
- return line ? ` near line ${line}` : position ? ` near character ${position}` : "";
53046
+ function defaultReadCodeStdin() {
53047
+ if (process$1.stdin.isTTY) throw new Error("stdin is a TTY; pipe the code into this command or use --code-from-file / --code-from-env instead.");
53048
+ return readFileSync(0, "utf8");
52543
53049
  }
52544
- /** Parse settings.json. Anything unexpected is reported, never repaired. */
52545
- function readClaudeSettings(claudeDir) {
52546
- const path = join(claudeDir, "settings.json");
52547
- const read = readManagedFile(path);
52548
- if (read.state === "invalid") return {
52549
- ok: false,
52550
- detail: read.detail
52551
- };
52552
- if (read.state === "absent") return {
52553
- ok: true,
52554
- read,
52555
- settings: {}
52556
- };
52557
- let parsed;
52558
- try {
52559
- parsed = JSON.parse(read.text);
52560
- } catch (error) {
52561
- return {
52562
- ok: false,
52563
- detail: `${path} is not valid JSON${jsonErrorDetail(error)}. Fix it by hand; it was not modified.`
52564
- };
52565
- }
52566
- if (!record$2(parsed)) return {
52567
- ok: false,
52568
- detail: `${path} must contain a JSON object. Fix it by hand; it was not modified.`
52569
- };
52570
- if (parsed.hooks !== void 0 && !record$2(parsed.hooks)) return {
52571
- ok: false,
52572
- detail: `${path} has a "hooks" value that is not an object. Fix it by hand; it was not modified.`
53050
+ var SignupConfirmCommand = class SignupConfirmCommand extends Command {
53051
+ static args = {
53052
+ email: Args.string({
53053
+ description: "Email address used to start signup",
53054
+ required: true
53055
+ }),
53056
+ code: Args.string({
53057
+ description: "Verification code from the signup email. Optional when one of --code-from-stdin / --code-from-file / --code-from-env is passed; exactly one source must be set.",
53058
+ required: false
53059
+ })
52573
53060
  };
52574
- return {
52575
- ok: true,
52576
- read,
52577
- settings: parsed
53061
+ static description = "Confirm a pending Primitive signup, create an OAuth session, and save CLI credentials locally.";
53062
+ static summary = "Confirm account signup";
53063
+ static examples = [
53064
+ "<%= config.bin %> signup confirm user@example.com 123456",
53065
+ "<%= config.bin %> signup confirm user@example.com 123456 --org-id 00000000-0000-4000-8000-000000000000",
53066
+ "read -rs CODE && CODE=\"$CODE\" <%= config.bin %> signup confirm user@example.com --code-from-env CODE && unset CODE",
53067
+ "read -rs CODE && printf '%s' \"$CODE\" | <%= config.bin %> signup confirm user@example.com --code-from-stdin && unset CODE",
53068
+ "<%= config.bin %> signup confirm user@example.com --code-from-file /run/user/$(id -u)/verification-code"
53069
+ ];
53070
+ static flags = {
53071
+ "api-base-url": Flags.string({
53072
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
53073
+ env: "PRIMITIVE_API_BASE_URL",
53074
+ hidden: true
53075
+ }),
53076
+ "code-from-stdin": Flags.boolean({ description: "Read the verification code from stdin instead of the positional argument. Use when an agent is constructing the command for the user to run, so the code never enters the agent's prompt context." }),
53077
+ "code-from-file": Flags.string({ description: "Read the verification code from a UTF-8 file at this path. Trailing newlines are stripped." }),
53078
+ "code-from-env": Flags.string({ description: "Read the verification code from this environment variable. Pair with `read -rs CODE && CODE=\"$CODE\" primitive signup confirm <email> --code-from-env CODE && unset CODE` so the value never appears on the command line or in shell history. Plain `read` creates a shell-local variable that child processes cannot see; the inline `CODE=\"$CODE\"` exports it for just the one command." }),
53079
+ force: Flags.boolean({
53080
+ char: "f",
53081
+ description: "Replace saved credentials after verification"
53082
+ }),
53083
+ "org-id": Flags.string({ description: "Workspace id to target when the email belongs to multiple workspaces" })
52578
53084
  };
52579
- }
52580
- const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
52581
- /**
52582
- * Write settings under the same lock every Primitive hook installer uses,
52583
- * keeping the file's indentation, after a timestamped backup.
52584
- */
52585
- function writeClaudeSettings(params) {
52586
- let release;
52587
- for (let attempt = 0; attempt < 80; attempt++) try {
52588
- release = acquireListenLock(params.claudeDir, "primitive-claude-settings");
52589
- break;
52590
- } catch {
52591
- Atomics.wait(LOCK_WAIT, 0, 0, 25);
53085
+ async run() {
53086
+ const { args, flags } = await this.parse(SignupConfirmCommand);
53087
+ const resolvedCode = resolveVerificationCode({
53088
+ positional: args.code,
53089
+ fromStdin: flags["code-from-stdin"] === true,
53090
+ fromFile: flags["code-from-file"],
53091
+ fromEnv: flags["code-from-env"]
53092
+ });
53093
+ if (resolvedCode.kind === "error") throw cliError$2(resolvedCode.message);
53094
+ let releaseCredentialsLock;
53095
+ try {
53096
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
53097
+ } catch (error) {
53098
+ throw cliError$2(error instanceof Error ? error.message : String(error));
53099
+ }
53100
+ try {
53101
+ await runSignupConfirmWithCredentialLock({
53102
+ code: resolvedCode.code,
53103
+ configDir: this.config.configDir,
53104
+ email: args.email,
53105
+ flags
53106
+ });
53107
+ } finally {
53108
+ releaseCredentialsLock();
53109
+ }
52592
53110
  }
52593
- if (!release) throw new MachineFileError("Another Primitive command is editing Claude settings; try again shortly.");
52594
- try {
52595
- const original = params.read.state === "present" ? params.read.text : null;
52596
- const indent = original === null ? 2 : jsonIndentation(original);
52597
- const trailing = original === null || original.endsWith("\n") ? "\n" : "";
52598
- return writeManagedFile({
52599
- read: params.read,
52600
- content: `${JSON.stringify(params.settings, null, indent)}${trailing}`,
52601
- now: params.now,
52602
- newFileMode: 384
53111
+ };
53112
+ var SignupResendCommand = class SignupResendCommand extends Command {
53113
+ static args = { email: Args.string({
53114
+ description: "Email address used to start signup. Defaults to the saved pending signup.",
53115
+ required: false
53116
+ }) };
53117
+ static description = "Resend the verification code for a pending signup.";
53118
+ static summary = "Resend signup verification code";
53119
+ static examples = ["<%= config.bin %> signup resend", "<%= config.bin %> signup resend user@example.com"];
53120
+ static flags = { "api-base-url": Flags.string({
53121
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
53122
+ env: "PRIMITIVE_API_BASE_URL",
53123
+ hidden: true
53124
+ }) };
53125
+ async run() {
53126
+ const { args, flags } = await this.parse(SignupResendCommand);
53127
+ let releaseCredentialsLock;
53128
+ try {
53129
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
53130
+ } catch (error) {
53131
+ throw cliError$2(error instanceof Error ? error.message : String(error));
53132
+ }
53133
+ try {
53134
+ await runSignupResendWithCredentialLock({
53135
+ configDir: this.config.configDir,
53136
+ email: args.email,
53137
+ flags
53138
+ });
53139
+ } finally {
53140
+ releaseCredentialsLock();
53141
+ }
53142
+ }
53143
+ };
53144
+ var SignupStatusCommand = class SignupStatusCommand extends Command {
53145
+ static args = { email: Args.string({
53146
+ description: "Email address expected in the pending signup",
53147
+ required: false
53148
+ }) };
53149
+ static description = "Inspect the locally saved pending Primitive signup state.";
53150
+ static summary = "Show pending signup status";
53151
+ static examples = [
53152
+ "<%= config.bin %> signup status",
53153
+ "<%= config.bin %> signup status user@example.com",
53154
+ "<%= config.bin %> signup status --json"
53155
+ ];
53156
+ static flags = {
53157
+ "api-base-url": Flags.string({
53158
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
53159
+ env: "PRIMITIVE_API_BASE_URL",
53160
+ hidden: true
53161
+ }),
53162
+ json: Flags.boolean({ description: "Print pending signup status as JSON" })
53163
+ };
53164
+ async run() {
53165
+ const { args, flags } = await this.parse(SignupStatusCommand);
53166
+ runSignupStatus({
53167
+ configDir: this.config.configDir,
53168
+ email: args.email,
53169
+ flags
52603
53170
  });
52604
- } finally {
52605
- release();
52606
53171
  }
52607
- }
52608
- const GLOBAL_HOOKS = {
52609
- "claude.hook.session_start": {
52610
- event: "SessionStart",
52611
- subcommand: "session-register",
52612
- timeout: 15
52613
- },
52614
- "claude.hook.session_end": {
52615
- event: "SessionEnd",
52616
- subcommand: "session-end",
52617
- timeout: 10
53172
+ };
53173
+ var SignupInteractiveCommand = class SignupInteractiveCommand extends Command {
53174
+ static description = "Run the full signup flow in one interactive terminal session.";
53175
+ static summary = "Run interactive account signup";
53176
+ static examples = ["<%= config.bin %> signup interactive"];
53177
+ static flags = commonStartFlags();
53178
+ async run() {
53179
+ const { flags } = await this.parse(SignupInteractiveCommand);
53180
+ let releaseCredentialsLock;
53181
+ try {
53182
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
53183
+ } catch (error) {
53184
+ throw cliError$2(error instanceof Error ? error.message : String(error));
53185
+ }
53186
+ try {
53187
+ await runSignupInteractiveWithCredentialLock({
53188
+ configDir: this.config.configDir,
53189
+ flags
53190
+ });
53191
+ } finally {
53192
+ releaseCredentialsLock();
53193
+ }
52618
53194
  }
52619
53195
  };
52620
- /** A hook this CLI installed for every Claude session, from any CLI path or version. */
52621
- function isGlobalManaged(hook, spec) {
52622
- if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return false;
52623
- const args = hook.args;
52624
- return args.length === 6 && typeof args[0] === "string" && args[1] === "agent" && args[2] === spec.subcommand && args[3] === "--runtime" && args[4] === "claude" && args[5] === "--hook";
53196
+ //#endregion
53197
+ //#region src/oclif/commands/logout.ts
53198
+ function cliError$1(message) {
53199
+ return new Errors.CLIError(message, { exit: 1 });
52625
53200
  }
52626
- function globalHookArgs(cli, spec) {
52627
- return [
52628
- cli.entry,
52629
- "agent",
52630
- spec.subcommand,
52631
- "--runtime",
52632
- "claude",
52633
- "--hook"
52634
- ];
53201
+ function unwrapData(value) {
53202
+ return value?.data ?? null;
52635
53203
  }
52636
- function globalHookCurrent(hook, cli) {
52637
- const args = hook.args;
52638
- return samePath(hook.command, cli.node) && samePath(args[0], cli.entry);
53204
+ function isSavedOAuthSessionExpiredError(error) {
53205
+ return error instanceof Error && error.message === "Saved Primitive CLI OAuth session expired or was revoked. Run `primitive signin` to authenticate again.";
52639
53206
  }
52640
- const WAKE_MARKER = "primitive-agent-wake-v1";
52641
- const PENDING_MARKER = "primitive-pending-mail-v1";
52642
- const SESSION_EVENTS = [
52643
- "Stop",
52644
- "SessionStart",
52645
- "PostToolUse"
52646
- ];
52647
- /** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
52648
- function parseSessionHook(hook) {
52649
- if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return null;
52650
- const args = hook.args;
52651
- if (!args.every((value) => typeof value === "string")) return null;
52652
- const strings = args;
52653
- const script = basename(strings[0] ?? "");
52654
- if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER ? {
52655
- kind: "pending",
52656
- configDir: strings[2] ?? "",
52657
- profile: strings[3] ?? null,
52658
- session: strings[5] ?? null
52659
- } : null;
52660
- if (script !== "claude-wake.mjs") return null;
52661
- if (strings.length === 7 && strings[6] === WAKE_MARKER) return {
52662
- kind: "wake",
52663
- configDir: strings[2] ?? "",
52664
- profile: strings[3] ?? null,
52665
- session: strings[5] ?? null
52666
- };
52667
- if (strings.length === 6 && strings[5] === WAKE_MARKER) return {
52668
- kind: "wake",
52669
- configDir: strings[2] ?? "",
52670
- profile: strings[3] ?? null,
52671
- session: null
52672
- };
52673
- if (strings.length === 4 && strings[3] === WAKE_MARKER) return {
52674
- kind: "wake",
52675
- configDir: strings[2] ?? "",
52676
- profile: null,
52677
- session: null
53207
+ async function runLogoutWithCredentialLock(params) {
53208
+ requireDefaultLoginProfile();
53209
+ const deps = {
53210
+ cliLogout,
53211
+ createAuthenticatedCliApiClient,
53212
+ ...params.deps
52678
53213
  };
52679
- return null;
52680
- }
52681
- /** A saved connected profile still holds a credential. Unreadable counts as live. */
52682
- function profileStillConnected(configDir, profile) {
53214
+ let credentials;
52683
53215
  try {
52684
- return loadConnectedAgentProfile(configDir, profile) !== null;
52685
- } catch {
52686
- return true;
53216
+ credentials = loadCliCredentials(params.configDir);
53217
+ } catch (error) {
53218
+ deleteCliCredentials(params.configDir);
53219
+ const detail = error instanceof Error ? error.message : String(error);
53220
+ process.stderr.write(`Removed unreadable Primitive CLI credentials. Backing OAuth grant was not revoked: ${detail}\n`);
53221
+ process.exitCode = 1;
53222
+ return;
52687
53223
  }
52688
- }
52689
- function sessionHookPathsCurrent(hook, parsed, cli) {
52690
- const args = hook.args;
52691
- if (!cli) return existsSync(String(hook.command)) && existsSync(args[0] ?? "") && existsSync(args[1] ?? "");
52692
- return samePath(hook.command, cli.node) && samePath(args[0], parsed.kind === "wake" ? cli.wake : cli.pending) && samePath(args[1], cli.entry);
52693
- }
52694
- function judgeSessionHooks(hooks, cli) {
52695
- const verdicts = /* @__PURE__ */ new Map();
52696
- const seen = /* @__PURE__ */ new Set();
52697
- for (const event of SESSION_EVENTS) {
52698
- const entries = hooks[event];
52699
- if (!Array.isArray(entries)) continue;
52700
- for (const entry of entries) {
52701
- if (!record$2(entry) || !Array.isArray(entry.hooks)) continue;
52702
- for (const hook of entry.hooks) {
52703
- const parsed = parseSessionHook(hook);
52704
- if (!parsed || !record$2(hook)) continue;
52705
- const pathsExist = existsSync(String(hook.command)) && existsSync(String(hook.args[0])) && existsSync(String(hook.args[1]));
52706
- if (!(parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
52707
- verdicts.set(hook, "stale");
52708
- continue;
52709
- }
52710
- const key = [
52711
- event,
52712
- parsed.kind,
52713
- resolve(parsed.configDir),
52714
- parsed.profile ?? "",
52715
- parsed.session?.toLowerCase() ?? ""
52716
- ].join("\0");
52717
- if (seen.has(key)) {
52718
- verdicts.set(hook, "duplicate");
52719
- continue;
52720
- }
52721
- seen.add(key);
52722
- verdicts.set(hook, sessionHookPathsCurrent(hook, parsed, cli) ? "keep" : "outdated");
52723
- }
53224
+ if (!credentials) throw cliError$1("Not logged in. Run `primitive signin` to create saved CLI credentials.");
53225
+ let authenticated;
53226
+ try {
53227
+ authenticated = await deps.createAuthenticatedCliApiClient({
53228
+ apiBaseUrl: params.flags["api-base-url"],
53229
+ configDir: params.configDir,
53230
+ credentialsLockHeld: true
53231
+ });
53232
+ } catch (error) {
53233
+ if (isSavedOAuthSessionExpiredError(error) && loadCliCredentials(params.configDir) === null) {
53234
+ process.stderr.write("Logged out (OAuth session was already expired or revoked on the server).\n");
53235
+ return;
52724
53236
  }
53237
+ throw error;
52725
53238
  }
52726
- return verdicts;
52727
- }
52728
- function eventEntries(hooks, event) {
52729
- const entries = hooks[event];
52730
- if (entries === void 0) return [];
52731
- return Array.isArray(entries) ? entries : "invalid";
52732
- }
52733
- /** Inspect every Primitive-owned Claude hook without changing anything. */
52734
- function inspectClaudeHooks(settings, context) {
52735
- const hooks = record$2(settings.hooks) ? settings.hooks : {};
52736
- const canFix = context.cli !== null && context.blocked === null;
52737
- const blockedNote = context.blocked ? ` ${context.blocked}` : "";
52738
- const findings = {};
52739
- for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
52740
- const entries = eventEntries(hooks, spec.event);
52741
- if (entries === "invalid") {
52742
- findings[id] = {
52743
- status: "fail",
52744
- detail: `hooks.${spec.event} is not a list. Fix settings.json by hand; it was not modified.`,
52745
- fixable: false
52746
- };
52747
- continue;
53239
+ const freshCredentials = authenticated.auth.credentials ?? credentials;
53240
+ const result = await deps.cliLogout({
53241
+ body: { key_id: freshCredentials.oauth_grant_id },
53242
+ client: authenticated.apiClient.client,
53243
+ responseStyle: "fields"
53244
+ });
53245
+ if (result.error) {
53246
+ const payload = extractErrorPayload(result.error);
53247
+ const code = extractErrorCode(payload);
53248
+ if (code === API_ERROR_CODES.unauthorized || code === API_ERROR_CODES.notFound) {
53249
+ deleteCliCredentials(params.configDir);
53250
+ writeErrorWithHints(payload);
53251
+ process.stderr.write("Removed saved Primitive CLI credentials because the backing OAuth grant is already unavailable.\n");
53252
+ process.exitCode = 1;
53253
+ return;
52748
53254
  }
52749
- const managed = entries.flatMap((entry) => record$2(entry) && Array.isArray(entry.hooks) ? entry.hooks.filter((hook) => isGlobalManaged(hook, spec)) : []);
52750
- const [first] = managed;
52751
- if (!first) findings[id] = {
52752
- status: "fail",
52753
- detail: `No ${spec.event} hook runs \`primitive agent ${spec.subcommand}\`.${blockedNote}`,
52754
- fixable: canFix
52755
- };
52756
- else if (managed.length > 1) findings[id] = {
52757
- status: "fail",
52758
- detail: `${managed.length} ${spec.event} hooks run \`primitive agent ${spec.subcommand}\`; expected exactly one.${blockedNote}`,
52759
- fixable: canFix
52760
- };
52761
- else if (!context.cli || !globalHookCurrent(first, context.cli)) findings[id] = {
52762
- status: "fail",
52763
- detail: `The ${spec.event} hook runs ${String(first.args[0])}, which ${existsSync(String(first.args[0])) ? "is not this CLI" : "no longer exists"}.${blockedNote}`,
52764
- fixable: canFix
52765
- };
52766
- else findings[id] = {
52767
- status: "ok",
52768
- detail: `${spec.event} runs \`primitive agent ${spec.subcommand}\` from ${context.cli.entry}.`,
52769
- fixable: false
52770
- };
53255
+ writeErrorWithHints(payload);
53256
+ throw cliError$1("Could not revoke the saved Primitive CLI OAuth grant.");
52771
53257
  }
52772
- const invalid = SESSION_EVENTS.find((event) => eventEntries(hooks, event) === "invalid");
52773
- if (invalid) {
52774
- findings["claude.hook.stop"] = {
52775
- status: "fail",
52776
- detail: `hooks.${invalid} is not a list. Fix settings.json by hand; it was not modified.`,
52777
- fixable: false
52778
- };
52779
- return findings;
53258
+ const logout = unwrapData(result.data);
53259
+ deleteCliCredentials(params.configDir);
53260
+ const grantId = logout?.oauth_grant_id ?? freshCredentials.oauth_grant_id;
53261
+ process.stderr.write(`Logged out and revoked OAuth grant ${grantId}.\n`);
53262
+ }
53263
+ function runForceLogout(params) {
53264
+ requireDefaultLoginProfile();
53265
+ const localCredentialsPath = credentialsPath(params.configDir);
53266
+ const pendingPath = pendingSignupPath(params.configDir);
53267
+ const lockPath = credentialsLockPath(params.configDir);
53268
+ const removed = [
53269
+ existsSync(localCredentialsPath) ? "local Primitive CLI credentials" : null,
53270
+ existsSync(chatStatePath(params.configDir)) ? "local chat reply state" : null,
53271
+ existsSync(pendingPath) ? "pending email-code auth state" : null,
53272
+ existsSync(lockPath) ? "credential lock" : null
53273
+ ].filter((value) => value !== null);
53274
+ deleteCliCredentials(params.configDir);
53275
+ deletePendingAgentSignup(params.configDir);
53276
+ deleteCliCredentialsLock(params.configDir);
53277
+ if (removed.length === 0) {
53278
+ process.stderr.write("No local Primitive CLI auth state was present. Backing OAuth grant was not revoked.\n");
53279
+ return;
52780
53280
  }
52781
- const counts = {
52782
- keep: 0,
52783
- stale: 0,
52784
- duplicate: 0,
52785
- outdated: 0
52786
- };
52787
- for (const verdict of judgeSessionHooks(hooks, context.cli).values()) counts[verdict]++;
52788
- const problems = [
52789
- counts.stale ? `${counts.stale} for disconnected or removed sessions` : "",
52790
- counts.duplicate ? `${counts.duplicate} duplicates` : "",
52791
- counts.outdated ? `${counts.outdated} pointing at an old CLI path` : ""
52792
- ].filter(Boolean);
52793
- findings["claude.hook.stop"] = problems.length ? {
52794
- status: "fail",
52795
- detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}.${blockedNote}`,
52796
- fixable: canFix || counts.outdated === 0 && context.blocked === null
52797
- } : {
52798
- status: "ok",
52799
- detail: counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks.",
52800
- fixable: false
52801
- };
52802
- return findings;
53281
+ process.stderr.write(`Removed ${formatList(removed)}. Backing OAuth grant was not revoked.\n`);
52803
53282
  }
52804
- function rewriteEntries(entries, transform) {
52805
- let changed = false;
52806
- return {
52807
- entries: entries.flatMap((entry) => {
52808
- if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
52809
- let touched = false;
52810
- const kept = entry.hooks.flatMap((hook) => {
52811
- const replacement = transform(hook);
52812
- if (replacement === hook) return [hook];
52813
- touched = true;
52814
- return replacement === void 0 ? [] : [replacement];
52815
- });
52816
- if (!touched) return [entry];
52817
- changed = true;
52818
- return kept.length ? [{
52819
- ...entry,
52820
- hooks: kept
52821
- }] : [];
53283
+ function formatList(values) {
53284
+ if (values.length <= 1) return values[0] ?? "";
53285
+ if (values.length === 2) return `${values[0]} and ${values[1]}`;
53286
+ return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
53287
+ }
53288
+ var LogoutCommand = class LogoutCommand extends Command {
53289
+ static description = "Log out by revoking the saved Primitive CLI OAuth grant and deleting local credentials. Use --force to remove local credentials, pending email-code auth state, and stale credential locks without contacting Primitive.";
53290
+ static summary = "Log out and revoke the saved CLI OAuth grant";
53291
+ static examples = ["<%= config.bin %> logout", "<%= config.bin %> logout --force"];
53292
+ static flags = {
53293
+ "api-base-url": Flags.string({
53294
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
53295
+ env: "PRIMITIVE_API_BASE_URL",
53296
+ hidden: true
52822
53297
  }),
52823
- changed
53298
+ force: Flags.boolean({
53299
+ char: "f",
53300
+ description: "Remove local CLI credentials, pending email-code auth state, and any credential lock without revoking the server OAuth grant"
53301
+ })
52824
53302
  };
52825
- }
52826
- /**
52827
- * Apply fixes for the requested checks to a copy of the settings. Only
52828
- * Primitive-owned hooks are added, rewritten or removed; every other key,
52829
- * entry and sibling hook is carried over unchanged.
52830
- */
52831
- function repairClaudeHooks(settings, context, checks) {
52832
- const changed = /* @__PURE__ */ new Set();
52833
- const hooks = record$2(settings.hooks) ? { ...settings.hooks } : {};
52834
- const cli = context.blocked === null ? context.cli : null;
52835
- for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
52836
- if (!checks.has(id) || !cli) continue;
52837
- const entries = eventEntries(hooks, spec.event);
52838
- if (entries === "invalid") continue;
52839
- let placed = false;
52840
- const result = rewriteEntries(entries, (hook) => {
52841
- if (!isGlobalManaged(hook, spec)) return hook;
52842
- if (placed) return void 0;
52843
- placed = true;
52844
- if (globalHookCurrent(hook, cli)) return hook;
52845
- return {
52846
- ...hook,
52847
- command: cli.node,
52848
- args: globalHookArgs(cli, spec)
52849
- };
52850
- });
52851
- let next = result.entries;
52852
- if (!placed) next = [...next, { hooks: [{
52853
- type: "command",
52854
- command: cli.node,
52855
- args: globalHookArgs(cli, spec),
52856
- timeout: spec.timeout
52857
- }] }];
52858
- if (result.changed || !placed) {
52859
- hooks[spec.event] = next;
52860
- changed.add(id);
53303
+ async run() {
53304
+ const { flags } = await this.parse(LogoutCommand);
53305
+ if (flags.force) {
53306
+ runForceLogout({ configDir: this.config.configDir });
53307
+ return;
52861
53308
  }
52862
- }
52863
- if (checks.has("claude.hook.stop") && context.blocked === null) {
52864
- if (!SESSION_EVENTS.some((event) => eventEntries(hooks, event) === "invalid")) {
52865
- const verdicts = judgeSessionHooks(hooks, context.cli);
52866
- for (const event of SESSION_EVENTS) {
52867
- const entries = eventEntries(hooks, event);
52868
- if (entries === "invalid" || entries.length === 0) continue;
52869
- const result = rewriteEntries(entries, (hook) => {
52870
- const verdict = verdicts.get(hook);
52871
- if (verdict === "stale" || verdict === "duplicate") return void 0;
52872
- if (verdict !== "outdated" || !cli || !record$2(hook)) return hook;
52873
- const parsed = parseSessionHook(hook);
52874
- const args = [...hook.args];
52875
- args[0] = parsed?.kind === "pending" ? cli.pending : cli.wake;
52876
- args[1] = cli.entry;
52877
- return {
52878
- ...hook,
52879
- command: cli.node,
52880
- args
52881
- };
52882
- });
52883
- if (result.changed) {
52884
- hooks[event] = result.entries;
52885
- changed.add("claude.hook.stop");
52886
- }
52887
- }
53309
+ let releaseCredentialsLock;
53310
+ try {
53311
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
53312
+ } catch (error) {
53313
+ throw cliError$1(error instanceof Error ? error.message : String(error));
53314
+ }
53315
+ try {
53316
+ await runLogoutWithCredentialLock({
53317
+ configDir: this.config.configDir,
53318
+ flags
53319
+ });
53320
+ } finally {
53321
+ releaseCredentialsLock();
52888
53322
  }
52889
53323
  }
52890
- if (!changed.size) return {
52891
- settings,
52892
- changed
52893
- };
52894
- return {
52895
- settings: {
52896
- ...settings,
52897
- hooks
52898
- },
52899
- changed
52900
- };
52901
- }
53324
+ };
52902
53325
  //#endregion
52903
53326
  //#region src/oclif/codex-machine-hooks.ts
52904
53327
  function record$1(value) {
@@ -53496,7 +53919,10 @@ async function runMachineDoctor(options) {
53496
53919
  let settingsRead = readClaudeSettings(paths.claudeDir);
53497
53920
  const hookContext = {
53498
53921
  cli,
53499
- blocked: hookBlocked
53922
+ blocked: hookBlocked,
53923
+ get bound() {
53924
+ return boundSessionProfiles(options.configDir);
53925
+ }
53500
53926
  };
53501
53927
  const hookCheck = (id) => ({
53502
53928
  inspect: () => {
@@ -60422,6 +60848,7 @@ const COMMANDS = {
60422
60848
  completion: CompletionCommand,
60423
60849
  "agent:upgrade": AgentUpgradeCommand,
60424
60850
  "agent:connect": AgentConnectCommand,
60851
+ "agent:check-mail": AgentCheckMailCommand,
60425
60852
  "agent:disconnect": AgentDisconnectCommand,
60426
60853
  "agent:enroll": AgentEnrollCommand,
60427
60854
  "agent:session-register": AgentSessionRegisterCommand,