primcli 1.37.0 → 1.38.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.
@@ -4,16 +4,16 @@ import { $ as putAgentContactPolicy, A as getRepeatingSend, B as listEmails, C a
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
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-CKaEqXro.js";
7
- import { _ as NativeSessionNotLoadedError, a as notificationSenders, c as NotificationRetryError, d as readConversationStatusContent, f as SESSION_UUID$1, g as NativeSessionError, h as NativeSessionDisconnectedError, i as notificationScope, l as isRoutineNotificationContent, m as defaultSessionSocket, n as installConnectSkill, o as openSessionNotifications, p as connectNativeSession, r as readBundledConnectSkill, s as notificationReceiptPage, t as detectAgentRuntime, u as notificationPartReader, v as NotificationOutcomeUnknownError } from "../connect-skill-DednypYJ.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-HJNQC3A9.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-UPcJmV3j.js";
10
10
  import { t as guardJsonOutputCommands } from "../json-output-BVRgl6jf.js";
11
11
  import { Args, Command, Errors, Flags, ux } from "@oclif/core";
12
- import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, cpSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
13
13
  import { createHash, randomUUID, scryptSync } from "node:crypto";
14
- import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
14
+ import path, { basename, delimiter, dirname, join, relative, resolve, sep } from "node:path";
15
15
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
16
- import { execFile, spawn } from "node:child_process";
16
+ import { execFile, execFileSync, spawn } from "node:child_process";
17
17
  import { homedir, hostname } from "node:os";
18
18
  import { promisify } from "node:util";
19
19
  import process$1 from "node:process";
@@ -34954,10 +34954,10 @@ function isMemoryJsonValue(value, seen = /* @__PURE__ */ new Set()) {
34954
34954
  //#region src/oclif/claude-wake-install.ts
34955
34955
  const HOOK_MARKER = "primitive-agent-wake-v1";
34956
34956
  const WAKE_EVENTS = ["Stop", "SessionStart"];
34957
- const PENDING_MARKER = "primitive-pending-mail-v1";
34957
+ const PENDING_MARKER$1 = "primitive-pending-mail-v1";
34958
34958
  const PENDING_EVENT = "PostToolUse";
34959
- const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
34960
- function record$2(value) {
34959
+ const LOCK_WAIT$1 = new Int32Array(new SharedArrayBuffer(4));
34960
+ function record$4(value) {
34961
34961
  return value !== null && typeof value === "object" && !Array.isArray(value);
34962
34962
  }
34963
34963
  function claudeWakeHookStatus(options) {
@@ -34973,21 +34973,21 @@ function claudeWakeHookStatus(options) {
34973
34973
  if (!SESSION_UUID$1.test(options.sessionId)) return unavailable;
34974
34974
  const sessionId = options.sessionId.toLowerCase();
34975
34975
  const settings = readMailJson(join(resolve(options.env?.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), "settings.json"));
34976
- if (!record$2(settings) || !record$2(settings.hooks)) return unavailable;
34976
+ if (!record$4(settings) || !record$4(settings.hooks)) return unavailable;
34977
34977
  const hooks = settings.hooks;
34978
34978
  const owns = (event, script, marker) => {
34979
34979
  const entries = hooks[event];
34980
- return Array.isArray(entries) && entries.some((entry) => record$2(entry) && Array.isArray(entry.hooks) && entry.hooks.some((candidate) => {
34981
- if (!record$2(candidate) || !Array.isArray(candidate.args)) return false;
34980
+ return Array.isArray(entries) && entries.some((entry) => record$4(entry) && Array.isArray(entry.hooks) && entry.hooks.some((candidate) => {
34981
+ if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
34982
34982
  const args = candidate.args;
34983
34983
  const offset = 2;
34984
34984
  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;
34985
34985
  }));
34986
34986
  };
34987
- 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);
34987
+ 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);
34988
34988
  let lastFiredAt = null;
34989
34989
  const fired = readMailJson(join(agentProfileDirectory(configDir, profileName), `pending-mail-${sessionId}.fired.json`));
34990
- if (record$2(fired) && fired.version === 1 && typeof fired.at === "string" && Number.isFinite(Date.parse(fired.at))) lastFiredAt = fired.at;
34990
+ if (record$4(fired) && fired.version === 1 && typeof fired.at === "string" && Number.isFinite(Date.parse(fired.at))) lastFiredAt = fired.at;
34991
34991
  return {
34992
34992
  installed,
34993
34993
  lastFiredAt,
@@ -35007,7 +35007,7 @@ function editClaudeSettings(claudeDir, edit) {
35007
35007
  release = acquireListenLock(claudeDir, "primitive-claude-settings");
35008
35008
  break;
35009
35009
  } catch {
35010
- Atomics.wait(LOCK_WAIT, 0, 0, 25);
35010
+ Atomics.wait(LOCK_WAIT$1, 0, 0, 25);
35011
35011
  }
35012
35012
  if (!release) return false;
35013
35013
  try {
@@ -35020,7 +35020,7 @@ function editClaudeSettings(claudeDir, edit) {
35020
35020
  if (!stat.isFile() || stat.size > 1048576) return false;
35021
35021
  original = readFileSync(settingsPath, "utf8");
35022
35022
  const parsed = JSON.parse(original);
35023
- if (!record$2(parsed)) return false;
35023
+ if (!record$4(parsed)) return false;
35024
35024
  settings = parsed;
35025
35025
  }
35026
35026
  const next = edit(settings);
@@ -35058,7 +35058,7 @@ function installClaudeWakeHook(options) {
35058
35058
  const sessionId = options.sessionId.toLowerCase();
35059
35059
  return editClaudeSettings(resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), (settings) => {
35060
35060
  const hooks = settings.hooks ?? {};
35061
- if (!record$2(hooks)) throw new Error("Invalid hook settings");
35061
+ if (!record$4(hooks)) throw new Error("Invalid hook settings");
35062
35062
  for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
35063
35063
  if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
35064
35064
  const hook = {
@@ -35086,26 +35086,26 @@ function installClaudeWakeHook(options) {
35086
35086
  profileName,
35087
35087
  agentAddress,
35088
35088
  sessionId,
35089
- PENDING_MARKER
35089
+ PENDING_MARKER$1
35090
35090
  ],
35091
35091
  timeout: 10
35092
35092
  };
35093
- const isOwnPendingHook = (candidate) => record$2(candidate) && candidate.type === "command" && candidate.command === process.execPath && Array.isArray(candidate.args) && candidate.args.length === 7 && candidate.args[0] === pendingPath && candidate.args[1] === cliPath && candidate.args[2] === configDir && candidate.args[5] === sessionId && candidate.args[6] === PENDING_MARKER;
35093
+ 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;
35094
35094
  const isOwnHook = (candidate) => {
35095
- if (!record$2(candidate) || !Array.isArray(candidate.args)) return false;
35096
- if (candidate.command !== process.execPath || candidate.args[0] !== wrapperPath || candidate.args[1] !== cliPath || candidate.args[2] !== configDir) return false;
35095
+ if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35096
+ if (typeof candidate.args[0] !== "string" || basename(candidate.args[0]) !== "claude-wake.mjs" || candidate.args[2] !== configDir) return false;
35097
35097
  if (candidate.args.length === 7 && candidate.args[5] === sessionId && candidate.args[6] === HOOK_MARKER) return true;
35098
35098
  if (candidate.args.length !== 6 || candidate.args[5] !== HOOK_MARKER) return false;
35099
35099
  try {
35100
35100
  const state = readMailJson(join(agentProfileDirectory(configDir, agentProfileName(String(candidate.args[3]))), "setup.json"));
35101
- return record$2(state) && state.session === sessionId;
35101
+ return record$4(state) && state.session === sessionId;
35102
35102
  } catch {
35103
35103
  return false;
35104
35104
  }
35105
35105
  };
35106
35106
  const nextHooks = { ...hooks };
35107
35107
  for (const event of WAKE_EVENTS) nextHooks[event] = [...(hooks[event] ?? []).flatMap((entry) => {
35108
- if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
35108
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35109
35109
  const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
35110
35110
  if (siblings.length === entry.hooks.length) return [entry];
35111
35111
  return siblings.length ? [{
@@ -35117,7 +35117,7 @@ function installClaudeWakeHook(options) {
35117
35117
  hooks: [hook]
35118
35118
  } : { hooks: [hook] }];
35119
35119
  nextHooks[PENDING_EVENT] = [...(hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
35120
- if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
35120
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35121
35121
  const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
35122
35122
  if (siblings.length === entry.hooks.length) return [entry];
35123
35123
  return siblings.length ? [{
@@ -35146,31 +35146,31 @@ function uninstallClaudeWakeHook(options) {
35146
35146
  if (!existsSync(join(claudeDir, "settings.json"))) return true;
35147
35147
  return editClaudeSettings(claudeDir, (settings) => {
35148
35148
  const hooks = settings.hooks ?? {};
35149
- if (!record$2(hooks)) throw new Error("Invalid hook settings");
35149
+ if (!record$4(hooks)) throw new Error("Invalid hook settings");
35150
35150
  for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
35151
35151
  if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
35152
35152
  const legacySessionMatches = () => {
35153
35153
  try {
35154
35154
  const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
35155
- return record$2(setup) && setup.session === sessionId;
35155
+ return record$4(setup) && setup.session === sessionId;
35156
35156
  } catch {
35157
35157
  return false;
35158
35158
  }
35159
35159
  };
35160
35160
  const isOwnHook = (candidate) => {
35161
- if (!record$2(candidate) || !Array.isArray(candidate.args)) return false;
35161
+ if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35162
35162
  const args = candidate.args;
35163
35163
  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());
35164
35164
  };
35165
35165
  const isOwnPendingHook = (candidate) => {
35166
- if (!record$2(candidate) || !Array.isArray(candidate.args)) return false;
35166
+ if (!record$4(candidate) || !Array.isArray(candidate.args)) return false;
35167
35167
  const args = candidate.args;
35168
- 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;
35168
+ 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;
35169
35169
  };
35170
35170
  let removed = false;
35171
35171
  const nextHooks = { ...hooks };
35172
35172
  for (const event of WAKE_EVENTS) nextHooks[event] = (hooks[event] ?? []).flatMap((entry) => {
35173
- if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
35173
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35174
35174
  const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
35175
35175
  if (siblings.length === entry.hooks.length) return [entry];
35176
35176
  removed = true;
@@ -35180,7 +35180,7 @@ function uninstallClaudeWakeHook(options) {
35180
35180
  }] : [];
35181
35181
  });
35182
35182
  nextHooks[PENDING_EVENT] = (hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
35183
- if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
35183
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) return [entry];
35184
35184
  const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
35185
35185
  if (siblings.length === entry.hooks.length) return [entry];
35186
35186
  removed = true;
@@ -35853,7 +35853,7 @@ function matchesContactSelector(input, sender) {
35853
35853
  }
35854
35854
  //#endregion
35855
35855
  //#region src/oclif/contact-policy.ts
35856
- function record$1(value) {
35856
+ function record$3(value) {
35857
35857
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid contact policy.");
35858
35858
  return value;
35859
35859
  }
@@ -35862,11 +35862,11 @@ function fingerprint(value) {
35862
35862
  return value;
35863
35863
  }
35864
35864
  function document(value, organization) {
35865
- const row = record$1(value);
35865
+ const row = record$3(value);
35866
35866
  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.");
35867
35867
  const seen = /* @__PURE__ */ new Set();
35868
35868
  const rules = row.rules.map((value) => {
35869
- const rule = record$1(value);
35869
+ const rule = record$3(value);
35870
35870
  if (typeof rule.pattern !== "string" || rule.effect !== "allow" && rule.effect !== "silence") throw new Error("Invalid contact policy rule.");
35871
35871
  const pattern = canonicalContactSelector({
35872
35872
  kind: "pattern",
@@ -35907,7 +35907,7 @@ function document(value, organization) {
35907
35907
  };
35908
35908
  }
35909
35909
  function parseAgentContactPolicy(value, recipient) {
35910
- const row = record$1(value);
35910
+ const row = record$3(value);
35911
35911
  if (row.agent_address !== recipient || typeof row.allow_contact_requests !== "boolean") throw new Error("Contact policy recipient mismatch.");
35912
35912
  const org = document(row.org_policy, true);
35913
35913
  const agent = document(row.agent_policy, false);
@@ -36858,28 +36858,7 @@ function defaults(options) {
36858
36858
  env: options.env
36859
36859
  });
36860
36860
  },
36861
- async seedAgentInfo(profileName, value) {
36862
- const profile = loadConnectedAgentProfile(options.configDir, profileName);
36863
- if (!profile) return "failed";
36864
- const client = new PrimitiveApiClient({
36865
- apiKey: profile.api_key,
36866
- apiBaseUrl: profile.api_base_url
36867
- });
36868
- try {
36869
- await runAddressNotesRequest(client.client, {
36870
- action: "set",
36871
- address: profile.agent_address,
36872
- name: AGENT_INFO_NOTE,
36873
- value,
36874
- visibility: "private",
36875
- ifAbsent: true
36876
- });
36877
- return "created";
36878
- } catch (error) {
36879
- if (error instanceof AddressNotesApiError && error.status === 409) return "already_present";
36880
- return "failed";
36881
- }
36882
- },
36861
+ seedAgentInfo: (profileName, value) => seedAgentInfoNote(options.configDir, profileName, value),
36883
36862
  refreshOwnerMemberAddress: (profileName) => refreshOwnerMemberAddress({
36884
36863
  configDir: options.configDir,
36885
36864
  profileName
@@ -36902,6 +36881,30 @@ function defaults(options) {
36902
36881
  }
36903
36882
  };
36904
36883
  }
36884
+ /** Create the private AGENT_INFO note only when the address has none. */
36885
+ async function seedAgentInfoNote(configDir, profileName, value, fetchImpl) {
36886
+ const profile = loadConnectedAgentProfile(configDir, profileName);
36887
+ if (!profile) return "failed";
36888
+ const client = new PrimitiveApiClient({
36889
+ apiKey: profile.api_key,
36890
+ apiBaseUrl: profile.api_base_url,
36891
+ ...fetchImpl ? { fetch: fetchImpl } : {}
36892
+ });
36893
+ try {
36894
+ await runAddressNotesRequest(client.client, {
36895
+ action: "set",
36896
+ address: profile.agent_address,
36897
+ name: AGENT_INFO_NOTE,
36898
+ value,
36899
+ visibility: "private",
36900
+ ifAbsent: true
36901
+ });
36902
+ return "created";
36903
+ } catch (error) {
36904
+ if (error instanceof AddressNotesApiError && error.status === 409) return "already_present";
36905
+ return "failed";
36906
+ }
36907
+ }
36905
36908
  /**
36906
36909
  * One call: self check, install the matching skill, claim and verify, start
36907
36910
  * receiving, seed AGENT_INFO, and report everything as one result. Every step
@@ -38210,6 +38213,853 @@ var AgentNotesDeleteCommand = class AgentNotesDeleteCommand extends Command {
38210
38213
  }
38211
38214
  };
38212
38215
  //#endregion
38216
+ //#region src/oclif/machine-session.ts
38217
+ const MACHINE_RUNTIMES = [
38218
+ "claude",
38219
+ "codex",
38220
+ "omp"
38221
+ ];
38222
+ function sessionRecordPath(configDir, session) {
38223
+ return join(configDir, "machine", "sessions", `${session}.json`);
38224
+ }
38225
+ function readSessionRecord(configDir, session) {
38226
+ try {
38227
+ const value = readMailJson(sessionRecordPath(configDir, session));
38228
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
38229
+ const row = value;
38230
+ if (row.version !== 1 || row.session !== session || typeof row.profile !== "string" || typeof row.name !== "string" || !MACHINE_RUNTIMES.includes(row.runtime) || row.createdBy !== "session-register" && row.createdBy !== "existing") return null;
38231
+ return {
38232
+ version: 1,
38233
+ runtime: row.runtime,
38234
+ session,
38235
+ profile: row.profile,
38236
+ name: row.name,
38237
+ createdBy: row.createdBy,
38238
+ address: typeof row.address === "string" ? row.address : null,
38239
+ agentInfo: row.agentInfo === "created" || row.agentInfo === "already_present" ? row.agentInfo : null,
38240
+ registeredAt: typeof row.registeredAt === "string" ? row.registeredAt : (/* @__PURE__ */ new Date(0)).toISOString(),
38241
+ endedAt: typeof row.endedAt === "string" ? row.endedAt : null,
38242
+ disconnect: row.disconnect === "done" || row.disconnect === "pending" || row.disconnect === "pending_enrollment" ? row.disconnect : typeof row.endedAt === "string" ? "done" : null
38243
+ };
38244
+ } catch {
38245
+ return null;
38246
+ }
38247
+ }
38248
+ function saveSessionRecord(configDir, row) {
38249
+ writeMailJson(sessionRecordPath(configDir, row.session), row);
38250
+ }
38251
+ /** The repository (or directory) a session works in, for its default name. */
38252
+ function workspaceName(cwd) {
38253
+ let current = resolve(cwd);
38254
+ for (let depth = 0; depth < 64; depth++) {
38255
+ if (existsSync(join(current, ".git"))) return basename(current);
38256
+ const parent = dirname(current);
38257
+ if (parent === current) break;
38258
+ current = parent;
38259
+ }
38260
+ return basename(resolve(cwd));
38261
+ }
38262
+ function defaultSessionName(runtime, cwd) {
38263
+ return Array.from(`${runtime}-${workspaceName(cwd)}`).filter((character) => {
38264
+ const code = character.charCodeAt(0);
38265
+ return code >= 32 && code !== 127;
38266
+ }).join("").replace(/\s+/g, " ").trim().slice(0, 80).trim() || runtime;
38267
+ }
38268
+ /** A short private role note. Never transcript content. */
38269
+ function agentInfoForSession(runtime, cwd) {
38270
+ return `${runtime === "claude" ? "Claude Code" : runtime === "codex" ? "Codex" : "omp"} coding session. Repository: ${workspaceName(cwd)}. Directory: ${basename(resolve(cwd))}.`.slice(0, 1e3);
38271
+ }
38272
+ function psProcessInfo(pid) {
38273
+ try {
38274
+ const line = execFileSync("ps", [
38275
+ "-o",
38276
+ "ppid=,lstart=,command=",
38277
+ "-p",
38278
+ String(pid)
38279
+ ], {
38280
+ encoding: "utf8",
38281
+ timeout: 2e3,
38282
+ env: {
38283
+ ...process.env,
38284
+ LC_ALL: "C"
38285
+ },
38286
+ stdio: [
38287
+ "ignore",
38288
+ "pipe",
38289
+ "ignore"
38290
+ ]
38291
+ }).trim();
38292
+ const match = /^(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(.*)$/.exec(line);
38293
+ if (!match) return null;
38294
+ return {
38295
+ ppid: Number(match[1]),
38296
+ started: match[2] ?? "",
38297
+ command: match[3] ?? ""
38298
+ };
38299
+ } catch {
38300
+ return null;
38301
+ }
38302
+ }
38303
+ const OMP_MAIN = /(?:^|[\s/\\])omp(?:\s|$)|pi-coding-agent[\\/]dist[\\/]cli\.js(?:\s|$)/;
38304
+ /**
38305
+ * omp does not expose a session ID to the commands it runs. Use one generated
38306
+ * ID per running omp process, keyed by its PID and start time so a reused PID
38307
+ * never inherits another process's identity. A command reached through one of
38308
+ * omp's shared worker processes gets no ID rather than a guessed one.
38309
+ */
38310
+ function ompProcessSession(configDir, options = {}) {
38311
+ if ((options.platform ?? process.platform) === "win32") return null;
38312
+ const info = options.processInfo ?? psProcessInfo;
38313
+ let pid = options.startPid ?? process.ppid;
38314
+ for (let hop = 0; hop < 16 && pid > 1; hop++) {
38315
+ const row = info(pid);
38316
+ if (!row) return null;
38317
+ if (row.command.includes("__omp_worker")) return null;
38318
+ if (OMP_MAIN.test(row.command)) {
38319
+ const key = createHash("sha256").update(`${pid}\0${row.started}`).digest("hex").slice(0, 32);
38320
+ const directory = join(configDir, "machine", "omp-processes");
38321
+ const path = join(directory, `${key}.json`);
38322
+ privateMailDirectory(directory, true);
38323
+ try {
38324
+ const fd = openSync(path, "wx", 384);
38325
+ try {
38326
+ writeFileSync(fd, `${JSON.stringify({
38327
+ version: 1,
38328
+ session: randomUUID()
38329
+ })}\n`);
38330
+ fsyncSync(fd);
38331
+ } finally {
38332
+ closeSync(fd);
38333
+ }
38334
+ } catch (error) {
38335
+ if (error.code !== "EEXIST") return null;
38336
+ }
38337
+ const saved = readMailJson(path);
38338
+ return typeof saved?.session === "string" && SESSION_UUID$1.test(saved.session) ? saved.session : null;
38339
+ }
38340
+ pid = row.ppid;
38341
+ }
38342
+ return null;
38343
+ }
38344
+ /** The session this runtime says the command is running in. */
38345
+ function runtimeSessionId(runtime, env, configDir, ompSession = ompProcessSession) {
38346
+ return (runtime === "claude" ? env.CLAUDE_CODE_SESSION_ID : runtime === "codex" ? env.CODEX_THREAD_ID?.trim() || env.CODEX_SESSION_ID : ompSession(configDir))?.trim() || null;
38347
+ }
38348
+ /**
38349
+ * Claude sets CLAUDE_CODE_ENTRYPOINT to "cli" for an interactive session and
38350
+ * to "sdk-cli" for `claude -p`; SDK hosts set "sdk-ts" or "sdk-py", and the
38351
+ * GitHub action and MCP server modes have their own values. Those runs are
38352
+ * not sessions a person works in, so they get no address. Any other or
38353
+ * missing value is treated as interactive.
38354
+ */
38355
+ const HEADLESS_CLAUDE_ENTRYPOINTS = new Set([
38356
+ "sdk-cli",
38357
+ "sdk-ts",
38358
+ "sdk-py",
38359
+ "claude-code-github-action",
38360
+ "mcp"
38361
+ ]);
38362
+ function headlessClaudeRun(env) {
38363
+ return HEADLESS_CLAUDE_ENTRYPOINTS.has(env.CLAUDE_CODE_ENTRYPOINT?.trim() ?? "");
38364
+ }
38365
+ const HEADLESS_DETAIL = "This is a non-interactive Claude run (CLAUDE_CODE_ENTRYPOINT), so no address is used for it.";
38366
+ /** Read the connection's own record with its credential. */
38367
+ async function agentConnectionState(profile, fetchImpl = fetch, timeoutMs = 5e3) {
38368
+ try {
38369
+ const response = await fetchImpl(`${profile.api_base_url}/agent-connections/me`, {
38370
+ method: "GET",
38371
+ redirect: "error",
38372
+ signal: AbortSignal.timeout(timeoutMs),
38373
+ headers: {
38374
+ authorization: `Bearer ${profile.api_key}`,
38375
+ accept: "application/json"
38376
+ }
38377
+ });
38378
+ if (response.status === 401 || response.status === 403) {
38379
+ await response.body?.cancel().catch(() => void 0);
38380
+ return "rejected";
38381
+ }
38382
+ if (response.status !== 200) {
38383
+ await response.body?.cancel().catch(() => void 0);
38384
+ return "unavailable";
38385
+ }
38386
+ const text = await response.text();
38387
+ if (text.length > 65536) return "unavailable";
38388
+ const body = JSON.parse(text);
38389
+ const connection = body?.data?.connection;
38390
+ if (body?.success !== true || typeof connection?.address !== "string" || connection.address.toLowerCase() !== profile.agent_address.toLowerCase()) return "unavailable";
38391
+ if (connection.status === "connected") return "connected";
38392
+ if (connection.status === "revoked") return "revoked";
38393
+ if (connection.status === "claimed" || connection.status === "pending") return "pending";
38394
+ return "unavailable";
38395
+ } catch {
38396
+ return "unavailable";
38397
+ }
38398
+ }
38399
+ function receiverMode(setupDirectory) {
38400
+ try {
38401
+ const saved = readMailJson(join(setupDirectory, "setup.json"));
38402
+ if (!saved) return null;
38403
+ return saved.receiverMode === "external" ? "external" : "native";
38404
+ } catch {
38405
+ return null;
38406
+ }
38407
+ }
38408
+ /** Serialize every read-modify-write of one session's record. */
38409
+ async function withRecordLock(configDir, session, action) {
38410
+ const directory = join(configDir, "machine", "sessions");
38411
+ privateMailDirectory(directory, true);
38412
+ let release;
38413
+ for (let attempt = 0; attempt < 200 && !release; attempt++) try {
38414
+ release = acquireListenLock(directory, `session-record-${session}`);
38415
+ } catch {
38416
+ await setTimeout$1(50);
38417
+ }
38418
+ if (!release) throw new ListenStateError("The session record is locked by another run.");
38419
+ try {
38420
+ return await action();
38421
+ } finally {
38422
+ release();
38423
+ }
38424
+ }
38425
+ /** Read, change and save one record under its lock. Returns the saved record. */
38426
+ function updateSessionRecord(configDir, session, change) {
38427
+ return withRecordLock(configDir, session, () => {
38428
+ const current = readSessionRecord(configDir, session);
38429
+ const next = change(current);
38430
+ if (next && next !== current) saveSessionRecord(configDir, next);
38431
+ return next ?? current;
38432
+ });
38433
+ }
38434
+ /** The address an interrupted enrollment saved, when there is one. */
38435
+ function enrollmentAddress(configDir, profileName) {
38436
+ try {
38437
+ const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "enrollment", "state.json"), 65536);
38438
+ if (state === null) return {
38439
+ exists: false,
38440
+ address: null
38441
+ };
38442
+ return {
38443
+ exists: true,
38444
+ address: typeof state.address === "string" ? state.address : null
38445
+ };
38446
+ } catch {
38447
+ return {
38448
+ exists: true,
38449
+ address: null
38450
+ };
38451
+ }
38452
+ }
38453
+ /** `agent disconnect` already confirmed revocation of this profile's credential. */
38454
+ function disconnectConfirmedLocally(configDir, profileName) {
38455
+ try {
38456
+ return readdirSync(agentProfileDirectory(configDir, profileName)).some((name) => /^disconnected-[a-f0-9]{64}\.json$/.test(name));
38457
+ } catch {
38458
+ return false;
38459
+ }
38460
+ }
38461
+ /**
38462
+ * Revoke a connection by address with the saved member login, for a session
38463
+ * whose local credential is gone. True only when Primitive confirms it.
38464
+ */
38465
+ async function revokeWithMemberLogin(configDir, address, fetchImpl = fetch) {
38466
+ try {
38467
+ const saved = loadCliCredentials(configDir);
38468
+ if (!saved) return false;
38469
+ const credentials = await refreshStoredCliCredentials({
38470
+ apiBaseUrl: saved.api_base_url,
38471
+ configDir,
38472
+ credentials: saved,
38473
+ fetch: fetchImpl
38474
+ });
38475
+ const response = await fetchImpl(`${credentials.api_base_url}/agent-connections/${encodeURIComponent(address)}`, {
38476
+ method: "DELETE",
38477
+ redirect: "error",
38478
+ signal: AbortSignal.timeout(15e3),
38479
+ headers: {
38480
+ authorization: `Bearer ${credentials.access_token}`,
38481
+ accept: "application/json"
38482
+ }
38483
+ });
38484
+ if (response.status !== 200) {
38485
+ await response.body?.cancel().catch(() => void 0);
38486
+ return false;
38487
+ }
38488
+ const text = await response.text();
38489
+ if (text.length > 65536) return false;
38490
+ const body = JSON.parse(text);
38491
+ return body?.success === true && body.data?.connection?.address === address && body.data.connection.status === "revoked";
38492
+ } catch {
38493
+ return false;
38494
+ }
38495
+ }
38496
+ /** Enrollment saves its state before any request, so a quiet record this old created nothing. */
38497
+ const ENROLLMENT_SETTLED_MS = 10 * 6e4;
38498
+ async function disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
38499
+ try {
38500
+ if (loadConnectedAgentProfile(configDir, record.profile)) {
38501
+ await deps.disconnect({
38502
+ configDir,
38503
+ profileName: record.profile,
38504
+ fetch: fetchImpl,
38505
+ env
38506
+ });
38507
+ return "done";
38508
+ }
38509
+ if (disconnectConfirmedLocally(configDir, record.profile)) return "done";
38510
+ const enrollment = enrollmentAddress(configDir, record.profile);
38511
+ const address = record.address ?? enrollment.address;
38512
+ if (!address) {
38513
+ if (enrollment.exists) return "pending";
38514
+ return enrollmentSettled || Date.now() - Date.parse(record.registeredAt) > ENROLLMENT_SETTLED_MS ? "done" : "pending_enrollment";
38515
+ }
38516
+ return await deps.revokeByAddress(configDir, address, fetchImpl) ? "done" : "pending";
38517
+ } catch {
38518
+ return "pending";
38519
+ }
38520
+ }
38521
+ async function finishSessionDisconnect(configDir, record, deps, env, fetchImpl, enrollmentSettled = false) {
38522
+ const outcome = await disconnectSessionAgent(configDir, record, deps, env, fetchImpl, enrollmentSettled);
38523
+ return (await updateSessionRecord(configDir, record.session, (current) => !current || current.disconnect === "done" || current.disconnect === outcome ? current : {
38524
+ ...current,
38525
+ disconnect: outcome
38526
+ }))?.disconnect === "done" ? "done" : outcome;
38527
+ }
38528
+ const RECEIVING = {
38529
+ claude: "external_hook",
38530
+ codex: "native",
38531
+ omp: "unsupported"
38532
+ };
38533
+ function withDefined(defaults, overrides) {
38534
+ return {
38535
+ ...defaults,
38536
+ ...Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== void 0))
38537
+ };
38538
+ }
38539
+ const ENDED_DETAIL = "This session ended, so its agent was disconnected. A new address is never created for the same session.";
38540
+ const ENDED_PENDING_DETAIL = "This session ended, but disconnecting its agent has not been confirmed yet; `primitive machine doctor --fix` retries it.";
38541
+ /**
38542
+ * Give this session an address once. Safe to run on every start and resume:
38543
+ * a session already bound to a profile is only re-verified, and a session
38544
+ * whose agent was disconnected or removed is never given a second address.
38545
+ * Never throws; every failure is a status.
38546
+ */
38547
+ async function registerSession(options) {
38548
+ const env = options.env ?? process.env;
38549
+ const deps = withDefined({
38550
+ enroll: enrollAgent,
38551
+ installClaudeHook: installClaudeWakeHook,
38552
+ seedAgentInfo: (configDir, profileName, value) => seedAgentInfoNote(configDir, profileName, value, options.fetch),
38553
+ connectionState: (profile) => agentConnectionState(profile, options.fetch),
38554
+ ompSession: ompProcessSession,
38555
+ now: () => /* @__PURE__ */ new Date(),
38556
+ disconnect: disconnectAgent,
38557
+ revokeByAddress: revokeWithMemberLogin
38558
+ }, options.dependencies);
38559
+ const runtime = options.runtime;
38560
+ const base = {
38561
+ runtime,
38562
+ session: null,
38563
+ profile: null,
38564
+ address: null,
38565
+ receiving: null
38566
+ };
38567
+ if (runtime === "claude" && headlessClaudeRun(env)) return {
38568
+ ...base,
38569
+ status: "skipped_headless",
38570
+ detail: HEADLESS_DETAIL
38571
+ };
38572
+ let session;
38573
+ try {
38574
+ session = options.session?.trim() || runtimeSessionId(runtime, env, options.configDir, deps.ompSession);
38575
+ } catch {
38576
+ session = null;
38577
+ }
38578
+ if (!session) return {
38579
+ ...base,
38580
+ status: "no_session",
38581
+ detail: runtime === "omp" ? "omp does not expose a session ID to commands, and no running omp process was found. Pass --session." : `No ${runtime === "claude" ? "CLAUDE_CODE_SESSION_ID" : "CODEX_THREAD_ID"} in this environment. Pass --session.`
38582
+ };
38583
+ if (!SESSION_UUID$1.test(session)) return {
38584
+ ...base,
38585
+ status: "failed",
38586
+ detail: "The session ID is not a UUID. Nothing was changed."
38587
+ };
38588
+ const sessionId = session.toLowerCase();
38589
+ const profileName = `session-${sessionId}`;
38590
+ const known = {
38591
+ ...base,
38592
+ session: sessionId,
38593
+ profile: profileName
38594
+ };
38595
+ const cwd = resolve(options.cwd ?? process.cwd());
38596
+ const ended = async (current, enrolledNow = false) => {
38597
+ const outcome = current.disconnect === "done" && !enrolledNow ? "done" : await finishSessionDisconnect(options.configDir, current, deps, env, options.fetch, enrolledNow);
38598
+ return {
38599
+ ...known,
38600
+ address: current.address,
38601
+ status: "ended",
38602
+ detail: outcome === "done" ? ENDED_DETAIL : ENDED_PENDING_DETAIL
38603
+ };
38604
+ };
38605
+ const newRecord = (createdBy, address) => ({
38606
+ version: 1,
38607
+ runtime,
38608
+ session: sessionId,
38609
+ profile: profileName,
38610
+ name: defaultSessionName(runtime, cwd),
38611
+ createdBy,
38612
+ address,
38613
+ agentInfo: null,
38614
+ registeredAt: deps.now().toISOString(),
38615
+ endedAt: null,
38616
+ disconnect: null
38617
+ });
38618
+ try {
38619
+ let record = readSessionRecord(options.configDir, sessionId);
38620
+ if (record?.endedAt) return await ended(record);
38621
+ const finishAgentInfo = async () => {
38622
+ if (record?.agentInfo) return;
38623
+ const seeded = await deps.seedAgentInfo(options.configDir, profileName, agentInfoForSession(runtime, cwd));
38624
+ if (seeded !== "created" && seeded !== "already_present") return;
38625
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ? {
38626
+ ...current,
38627
+ agentInfo: seeded
38628
+ } : current);
38629
+ };
38630
+ const installHook = (profile) => runtime !== "claude" || receiverMode(agentProfileDirectory(options.configDir, profileName)) !== "external" || deps.installClaudeHook({
38631
+ cliPath: options.cliPath,
38632
+ configDir: options.configDir,
38633
+ profileName,
38634
+ agentAddress: profile.agent_address,
38635
+ sessionId,
38636
+ env
38637
+ }) === "installed_unverified";
38638
+ const existing = loadConnectedAgentProfile(options.configDir, profileName);
38639
+ if (existing) {
38640
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("existing", existing.agent_address));
38641
+ if (!record) throw new Error("session record unavailable");
38642
+ if (record.endedAt) return await ended(record);
38643
+ const state = await deps.connectionState(existing);
38644
+ const withAddress = {
38645
+ ...known,
38646
+ address: existing.agent_address
38647
+ };
38648
+ if (state === "revoked" || state === "rejected") return {
38649
+ ...withAddress,
38650
+ status: "revoked",
38651
+ detail: "This session's agent was disconnected in Primitive. No new address was created. `primitive machine doctor --fix` removes the stale local profile."
38652
+ };
38653
+ if (state === "unavailable") return {
38654
+ ...withAddress,
38655
+ receiving: RECEIVING[runtime],
38656
+ status: "offline",
38657
+ detail: "This session already has an address; its status could not be checked right now."
38658
+ };
38659
+ if (state === "pending" && record.createdBy !== "session-register") return {
38660
+ ...withAddress,
38661
+ status: "pending",
38662
+ detail: "This session's agent is waiting for pairing confirmation. Finish it with the command that started it."
38663
+ };
38664
+ if (state === "connected") {
38665
+ const hooked = installHook(existing);
38666
+ await finishAgentInfo();
38667
+ return {
38668
+ ...withAddress,
38669
+ receiving: RECEIVING[runtime],
38670
+ status: "already_registered",
38671
+ detail: hooked ? "This session is already connected." : "This session is already connected, but its receive hook could not be installed. Run `primitive machine doctor`."
38672
+ };
38673
+ }
38674
+ } else if (record?.address && record.createdBy === "session-register" && !enrollmentAddress(options.configDir, profileName).exists || record?.createdBy === "existing") return {
38675
+ ...known,
38676
+ address: record.address,
38677
+ status: "removed",
38678
+ detail: "This session's local profile was removed. No new address was created for the same session."
38679
+ };
38680
+ let credentials;
38681
+ try {
38682
+ credentials = loadCliCredentials(options.configDir);
38683
+ } catch {
38684
+ credentials = null;
38685
+ }
38686
+ if (!credentials) return {
38687
+ ...known,
38688
+ status: "not_logged_in",
38689
+ detail: "No saved member login on this machine. Run `primitive signin`, then start the session again."
38690
+ };
38691
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("session-register", null));
38692
+ if (!record) throw new Error("session record unavailable");
38693
+ if (record.endedAt) return await ended(record);
38694
+ const enrollEnv = { ...env };
38695
+ delete enrollEnv.PRIMITIVE_AGENT_PROFILE;
38696
+ delete enrollEnv.PRIMITIVE_API_KEY;
38697
+ delete enrollEnv.PRIMITIVE_KEY;
38698
+ if (runtime === "claude" && options.trustedSession) enrollEnv.CLAUDE_CODE_SESSION_ID = sessionId;
38699
+ let result = null;
38700
+ let enrollError = null;
38701
+ try {
38702
+ result = await deps.enroll({
38703
+ configDir: options.configDir,
38704
+ session: sessionId,
38705
+ name: record.name,
38706
+ receiverMode: runtime === "codex" ? "native" : "external",
38707
+ contactRequests: false,
38708
+ env: enrollEnv,
38709
+ fetch: options.fetch,
38710
+ ...runtime === "omp" ? { preflight: async () => void 0 } : {}
38711
+ });
38712
+ } catch (error) {
38713
+ if (error instanceof ListenStateError) throw error;
38714
+ enrollError = error;
38715
+ }
38716
+ let created = result?.identity.agentAddress ?? null;
38717
+ if (!created) try {
38718
+ created = loadConnectedAgentProfile(options.configDir, profileName)?.agent_address ?? enrollmentAddress(options.configDir, profileName).address;
38719
+ } catch {
38720
+ created = enrollmentAddress(options.configDir, profileName).address;
38721
+ }
38722
+ record = await updateSessionRecord(options.configDir, sessionId, (current) => current && created && current.address !== created ? {
38723
+ ...current,
38724
+ address: created
38725
+ } : current);
38726
+ if (!record) throw new Error("session record unavailable");
38727
+ if (record.endedAt) return await ended(record, true);
38728
+ if (enrollError || !result) throw enrollError;
38729
+ const address = result.identity.agentAddress;
38730
+ const submitted = verificationReplySubmitted(result.verification.state);
38731
+ const status = result.connection.status;
38732
+ let hooked = true;
38733
+ if (runtime === "claude" && submitted && status !== "owner_inactive") hooked = deps.installClaudeHook({
38734
+ cliPath: options.cliPath,
38735
+ configDir: options.configDir,
38736
+ profileName,
38737
+ agentAddress: address,
38738
+ sessionId,
38739
+ env
38740
+ }) === "installed_unverified";
38741
+ if (status === "connected") await finishAgentInfo();
38742
+ const withAddress = {
38743
+ ...known,
38744
+ address,
38745
+ receiving: RECEIVING[runtime]
38746
+ };
38747
+ if (status === "connected") return {
38748
+ ...withAddress,
38749
+ status: "registered",
38750
+ detail: hooked ? `Connected as ${address}.` : `Connected as ${address}, but the receive hook could not be installed. Run \`primitive machine doctor\`.`
38751
+ };
38752
+ if (status === "revoked" || status === "owner_inactive") return {
38753
+ ...withAddress,
38754
+ status,
38755
+ detail: status === "revoked" ? "The new agent was disconnected in Primitive before setup finished." : "The member who owns this login is no longer active in the organization."
38756
+ };
38757
+ return {
38758
+ ...withAddress,
38759
+ status: "pending",
38760
+ detail: "The address exists and pairing is not confirmed yet. The next session start resumes it; no second address is created."
38761
+ };
38762
+ } catch (error) {
38763
+ if (error instanceof ListenStateError) return {
38764
+ ...known,
38765
+ status: "busy",
38766
+ detail: "Another registration for this session is already running."
38767
+ };
38768
+ return {
38769
+ ...known,
38770
+ status: "failed",
38771
+ detail: error instanceof AgentConnectionSetupError ? error.message : "Registration stopped before finishing. The session continues normally; the next start resumes it."
38772
+ };
38773
+ }
38774
+ }
38775
+ /**
38776
+ * Disconnect the agent a session got from `session-register`. Agents
38777
+ * connected any other way are left alone. The end is recorded first, under
38778
+ * the record's lock, so a registration still enrolling sees it and
38779
+ * disconnects the agent it creates. Never throws.
38780
+ */
38781
+ async function endSession(options) {
38782
+ const env = options.env ?? process.env;
38783
+ const deps = withDefined({
38784
+ disconnect: disconnectAgent,
38785
+ revokeByAddress: revokeWithMemberLogin,
38786
+ now: () => /* @__PURE__ */ new Date()
38787
+ }, options.dependencies);
38788
+ const runtime = options.runtime;
38789
+ const base = {
38790
+ runtime,
38791
+ session: null,
38792
+ address: null
38793
+ };
38794
+ if (runtime === "claude" && headlessClaudeRun(env)) return {
38795
+ ...base,
38796
+ status: "skipped_headless",
38797
+ detail: HEADLESS_DETAIL
38798
+ };
38799
+ let session;
38800
+ try {
38801
+ session = options.session?.trim() || runtimeSessionId(runtime, env, options.configDir, () => null);
38802
+ } catch {
38803
+ session = null;
38804
+ }
38805
+ if (!session || !SESSION_UUID$1.test(session)) return {
38806
+ ...base,
38807
+ status: "no_session",
38808
+ detail: "No session ID was given or found. Nothing was changed."
38809
+ };
38810
+ const sessionId = session.toLowerCase();
38811
+ try {
38812
+ let managed = false;
38813
+ let alreadyDone = false;
38814
+ const record = await updateSessionRecord(options.configDir, sessionId, (current) => {
38815
+ if (!current || current.createdBy !== "session-register") return current;
38816
+ managed = true;
38817
+ if (current.endedAt && current.disconnect === "done") {
38818
+ alreadyDone = true;
38819
+ return current;
38820
+ }
38821
+ return {
38822
+ ...current,
38823
+ endedAt: current.endedAt ?? deps.now().toISOString(),
38824
+ disconnect: "pending"
38825
+ };
38826
+ });
38827
+ const known = {
38828
+ ...base,
38829
+ session: sessionId,
38830
+ address: record?.address ?? null
38831
+ };
38832
+ if (!record || !managed) return {
38833
+ ...known,
38834
+ status: "not_managed",
38835
+ detail: "This session's agent was not created by `agent session-register`, so it was left connected."
38836
+ };
38837
+ if (alreadyDone) return {
38838
+ ...known,
38839
+ status: "already_ended",
38840
+ detail: "This session's agent was already disconnected."
38841
+ };
38842
+ return await finishSessionDisconnect(options.configDir, record, deps, env, options.fetch) === "done" ? {
38843
+ ...known,
38844
+ status: "disconnected",
38845
+ detail: "This session's agent was disconnected."
38846
+ } : {
38847
+ ...known,
38848
+ status: "disconnect_pending",
38849
+ detail: "The session is marked ended, but disconnecting its agent is not confirmed yet. The next `primitive machine doctor --fix` (or a registration still finishing) retries it."
38850
+ };
38851
+ } catch (error) {
38852
+ return {
38853
+ ...base,
38854
+ session: sessionId,
38855
+ status: "failed",
38856
+ detail: error instanceof AgentDisconnectError ? error.message : "Disconnect did not finish. The agent was left as it was."
38857
+ };
38858
+ }
38859
+ }
38860
+ /** Ended sessions whose agent disconnect still needs confirming. */
38861
+ function pendingSessionDisconnects(configDir) {
38862
+ const directory = join(configDir, "machine", "sessions");
38863
+ let names;
38864
+ try {
38865
+ names = readdirSync(directory);
38866
+ } catch {
38867
+ return [];
38868
+ }
38869
+ return names.flatMap((name) => {
38870
+ const match = /^([0-9a-f-]{36})\.json$/.exec(name);
38871
+ if (!match?.[1]) return [];
38872
+ const record = readSessionRecord(configDir, match[1]);
38873
+ return record?.endedAt && (record.disconnect === "pending" || record.disconnect === "pending_enrollment") ? [record] : [];
38874
+ });
38875
+ }
38876
+ /** Retry every pending disconnect. Returns how many are now confirmed. */
38877
+ async function retryPendingDisconnects(configDir, options = {}) {
38878
+ const deps = withDefined({
38879
+ disconnect: disconnectAgent,
38880
+ revokeByAddress: revokeWithMemberLogin
38881
+ }, options.dependencies);
38882
+ let done = 0;
38883
+ for (const record of pendingSessionDisconnects(configDir)) if (await finishSessionDisconnect(configDir, record, deps, options.env ?? process.env, options.fetch) === "done") done++;
38884
+ return done;
38885
+ }
38886
+ /** Read Claude's hook JSON from stdin within a short budget. */
38887
+ async function readClaudeHookInput(stdin, timeoutMs = 2e3) {
38888
+ let input = "";
38889
+ const read = (async () => {
38890
+ for await (const chunk of stdin) {
38891
+ input += String(chunk);
38892
+ if (input.length > 65536) return false;
38893
+ }
38894
+ return true;
38895
+ })().catch(() => false);
38896
+ let timer;
38897
+ const complete = await Promise.race([read, new Promise((done) => {
38898
+ timer = setTimeout(() => done(false), timeoutMs);
38899
+ timer.unref();
38900
+ })]);
38901
+ if (timer) clearTimeout(timer);
38902
+ if (!complete) return null;
38903
+ try {
38904
+ const value = JSON.parse(input);
38905
+ if (typeof value?.session_id !== "string" || !SESSION_UUID$1.test(value.session_id)) return null;
38906
+ return {
38907
+ sessionId: value.session_id.toLowerCase(),
38908
+ cwd: typeof value.cwd === "string" && value.cwd ? value.cwd : null
38909
+ };
38910
+ } catch {
38911
+ return null;
38912
+ }
38913
+ }
38914
+ /**
38915
+ * Run the real work in a detached process so a hook never holds up the
38916
+ * session; wait for it only up to the budget.
38917
+ */
38918
+ async function runDetached(params) {
38919
+ try {
38920
+ const child = spawn(params.node, [params.entry, ...params.args], {
38921
+ detached: true,
38922
+ stdio: "ignore",
38923
+ env: params.env,
38924
+ ...params.cwd && existsSync(params.cwd) ? { cwd: params.cwd } : {}
38925
+ });
38926
+ child.unref();
38927
+ return await new Promise((done) => {
38928
+ const timer = setTimeout(() => done("detached"), params.waitMs);
38929
+ child.once("error", () => {
38930
+ clearTimeout(timer);
38931
+ done("failed");
38932
+ });
38933
+ child.once("exit", () => {
38934
+ clearTimeout(timer);
38935
+ done("finished");
38936
+ });
38937
+ });
38938
+ } catch {
38939
+ return "failed";
38940
+ }
38941
+ }
38942
+ //#endregion
38943
+ //#region src/oclif/commands/agent-session-end.ts
38944
+ var AgentSessionEndCommand = class AgentSessionEndCommand extends Command {
38945
+ static summary = "Disconnect the agent a session registered when it ends";
38946
+ static description = "Disconnects the agent that `primitive agent session-register` created for this session, and removes its exact-session receive hooks. Agents connected any other way are left connected. Never blocks or fails a session: every outcome exits 0 with a status. --hook reads Claude's SessionEnd hook input from stdin and disconnects in the background.";
38947
+ static examples = ["<%= config.bin %> agent session-end --runtime claude --json", "<%= config.bin %> agent session-end --runtime codex --session 11111111-1111-4111-8111-111111111111"];
38948
+ static flags = {
38949
+ runtime: Flags.string({
38950
+ required: true,
38951
+ options: [...MACHINE_RUNTIMES],
38952
+ description: "The coding agent runtime this session runs in"
38953
+ }),
38954
+ session: Flags.string({ description: "Exact session UUID; defaults to the runtime's own session ID" }),
38955
+ hook: Flags.boolean({ description: "Read Claude hook JSON from stdin and disconnect in the background" }),
38956
+ json: Flags.boolean({ description: "Print the result as JSON" }),
38957
+ quiet: Flags.boolean({ description: "Print nothing" })
38958
+ };
38959
+ async run() {
38960
+ const { flags } = await this.parse(AgentSessionEndCommand);
38961
+ const runtime = flags.runtime;
38962
+ if (flags.hook) {
38963
+ const input = await readClaudeHookInput(process.stdin);
38964
+ if (!input || runtime === "claude" && headlessClaudeRun(process.env)) return;
38965
+ await runDetached({
38966
+ node: process.execPath,
38967
+ entry: process.argv[1] ?? "",
38968
+ args: [
38969
+ "agent",
38970
+ "session-end",
38971
+ "--runtime",
38972
+ runtime,
38973
+ "--session",
38974
+ input.sessionId,
38975
+ "--quiet"
38976
+ ],
38977
+ env: process.env,
38978
+ waitMs: 0
38979
+ });
38980
+ return;
38981
+ }
38982
+ const result = await endSession({
38983
+ configDir: this.config.configDir,
38984
+ runtime,
38985
+ session: flags.session
38986
+ });
38987
+ const { status, ...rest } = result;
38988
+ if (flags.json) this.log(JSON.stringify({
38989
+ status,
38990
+ ...rest
38991
+ }));
38992
+ else if (!flags.quiet) this.log(`${result.status}: ${result.detail}`);
38993
+ }
38994
+ };
38995
+ //#endregion
38996
+ //#region src/oclif/commands/agent-session-register.ts
38997
+ /** A hook waits this long for registration before letting the session start. */
38998
+ const HOOK_BUDGET_MS = 3e3;
38999
+ var AgentSessionRegisterCommand = class AgentSessionRegisterCommand extends Command {
39000
+ static summary = "Register this coding session with Primitive, once";
39001
+ static description = "Gives the current Claude Code, Codex or omp session an address in your organization using the saved member login, without a browser. Safe to run on every start and resume: a session that already has a profile is only re-verified, and a session whose agent was disconnected or removed never gets a second address. The session ID comes from CLAUDE_CODE_SESSION_ID (Claude), CODEX_THREAD_ID or CODEX_SESSION_ID (Codex), or --session. omp does not expose a session ID to commands, so omp sessions use one generated ID per running omp process, and receiving mail is not supported for them. The agent is named <runtime>-<repository> and gets a private AGENT_INFO note naming the runtime and repository when it has none. Claude sessions receive mail through exact-session hooks; Codex through the native receiver. Non-interactive Claude runs (claude -p and SDK hosts, by CLAUDE_CODE_ENTRYPOINT) are skipped with status skipped_headless. Never blocks a session: every outcome, including no login or no network, exits 0 with a status. --hook reads Claude's or Codex's SessionStart hook input from stdin, returns within a few seconds and finishes slower work in the background.";
39002
+ static examples = [
39003
+ "<%= config.bin %> agent session-register --runtime codex --quiet",
39004
+ "<%= config.bin %> agent session-register --runtime claude --json",
39005
+ "<%= config.bin %> agent session-register --runtime omp --session 11111111-1111-4111-8111-111111111111 --json"
39006
+ ];
39007
+ static flags = {
39008
+ runtime: Flags.string({
39009
+ required: true,
39010
+ options: [...MACHINE_RUNTIMES],
39011
+ description: "The coding agent runtime this session runs in"
39012
+ }),
39013
+ session: Flags.string({ description: "Exact session UUID; defaults to the runtime's own session ID" }),
39014
+ cwd: Flags.string({ description: "Working directory used for the agent's name and AGENT_INFO; defaults to the current directory" }),
39015
+ hook: Flags.boolean({ description: "Read Claude or Codex SessionStart hook JSON from stdin and finish in the background if registration is slow" }),
39016
+ json: Flags.boolean({ description: "Print the result as JSON" }),
39017
+ quiet: Flags.boolean({ description: "Print nothing" })
39018
+ };
39019
+ async run() {
39020
+ const { flags } = await this.parse(AgentSessionRegisterCommand);
39021
+ const runtime = flags.runtime;
39022
+ if (flags.hook) {
39023
+ const input = await readClaudeHookInput(process.stdin);
39024
+ if (!input || runtime !== "claude" && runtime !== "codex" || runtime === "claude" && headlessClaudeRun(process.env)) return;
39025
+ await runDetached({
39026
+ node: process.execPath,
39027
+ entry: process.argv[1] ?? "",
39028
+ args: [
39029
+ "agent",
39030
+ "session-register",
39031
+ "--runtime",
39032
+ runtime,
39033
+ "--session",
39034
+ input.sessionId,
39035
+ ...input.cwd ? ["--cwd", input.cwd] : [],
39036
+ "--quiet"
39037
+ ],
39038
+ env: {
39039
+ ...process.env,
39040
+ ...runtime === "claude" ? { CLAUDE_CODE_SESSION_ID: input.sessionId } : { CODEX_THREAD_ID: input.sessionId }
39041
+ },
39042
+ cwd: input.cwd ?? void 0,
39043
+ waitMs: HOOK_BUDGET_MS
39044
+ });
39045
+ return;
39046
+ }
39047
+ const result = await registerSession({
39048
+ configDir: this.config.configDir,
39049
+ runtime,
39050
+ session: flags.session,
39051
+ cwd: flags.cwd,
39052
+ cliPath: process.argv[1] ?? ""
39053
+ });
39054
+ const { status, ...rest } = result;
39055
+ if (flags.json) this.log(JSON.stringify({
39056
+ status,
39057
+ ...rest
39058
+ }));
39059
+ else if (!flags.quiet) this.log(`${result.status}: ${result.detail}`);
39060
+ }
39061
+ };
39062
+ //#endregion
38213
39063
  //#region src/oclif/commands/agent-upgrade.ts
38214
39064
  /**
38215
39065
  * Interactive upgrade of an emailless agent account to a full developer
@@ -45000,7 +45850,7 @@ const PRIMITIVE_TEAM_AUTHOR = {
45000
45850
  url: "https://primitive.dev"
45001
45851
  };
45002
45852
  const SDK_VERSION_RANGE = "^1.34.0";
45003
- const CLI_VERSION_RANGE = "^1.37.0";
45853
+ const CLI_VERSION_RANGE = "^1.38.0";
45004
45854
  const ESBUILD_VERSION_RANGE = "^0.27.0";
45005
45855
  function renderHandler() {
45006
45856
  return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
@@ -51336,6 +52186,1627 @@ var LogoutCommand = class LogoutCommand extends Command {
51336
52186
  }
51337
52187
  };
51338
52188
  //#endregion
52189
+ //#region src/oclif/machine-files.ts
52190
+ /** Hand-edited files larger than this are reported, never rewritten. */
52191
+ const MAX_MANAGED_FILE_BYTES = 1048576;
52192
+ /** Backups kept per file; older Primitive backups of the same file are pruned. */
52193
+ const BACKUPS_KEPT = 10;
52194
+ const BACKUP_INFIX = ".primitive-bak-";
52195
+ /** A safe, user-facing reason a file was not changed. */
52196
+ var MachineFileError = class extends Error {};
52197
+ /**
52198
+ * Read a user-owned text file without following it anywhere surprising. A
52199
+ * symlink is followed to its real file so a later write updates the file the
52200
+ * user linked instead of replacing the link.
52201
+ */
52202
+ function readManagedFile(path) {
52203
+ let info;
52204
+ try {
52205
+ info = lstatSync(path);
52206
+ } catch (error) {
52207
+ if (error.code === "ENOENT") return {
52208
+ state: "absent",
52209
+ path,
52210
+ target: path
52211
+ };
52212
+ return {
52213
+ state: "invalid",
52214
+ path,
52215
+ detail: `${path} cannot be read (${error.code ?? "error"}).`
52216
+ };
52217
+ }
52218
+ let target = path;
52219
+ if (info.isSymbolicLink()) try {
52220
+ target = realpathSync(path);
52221
+ info = statSync(target);
52222
+ } catch {
52223
+ return {
52224
+ state: "invalid",
52225
+ path,
52226
+ detail: `${path} is a symlink to a missing file; it was left unchanged.`
52227
+ };
52228
+ }
52229
+ if (!info.isFile()) return {
52230
+ state: "invalid",
52231
+ path,
52232
+ detail: `${path} is not a regular file; it was left unchanged.`
52233
+ };
52234
+ if (info.size > MAX_MANAGED_FILE_BYTES) return {
52235
+ state: "invalid",
52236
+ path,
52237
+ detail: `${path} is larger than 1 MiB; it was left unchanged.`
52238
+ };
52239
+ let raw;
52240
+ try {
52241
+ raw = readFileSync(target);
52242
+ } catch (error) {
52243
+ return {
52244
+ state: "invalid",
52245
+ path,
52246
+ detail: `${path} cannot be read (${error.code ?? "error"}).`
52247
+ };
52248
+ }
52249
+ const text = raw.toString("utf8");
52250
+ if (text.includes("\0") || !Buffer.from(text, "utf8").equals(raw)) return {
52251
+ state: "invalid",
52252
+ path,
52253
+ detail: `${path} is not UTF-8 text; it was left unchanged.`
52254
+ };
52255
+ return {
52256
+ state: "present",
52257
+ path,
52258
+ target,
52259
+ text,
52260
+ mode: info.mode & 511
52261
+ };
52262
+ }
52263
+ function backupStamp$1(now) {
52264
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
52265
+ }
52266
+ function syncDirectory(directory) {
52267
+ if (process.platform === "win32") return;
52268
+ try {
52269
+ const fd = openSync(directory, "r");
52270
+ try {
52271
+ fsyncSync(fd);
52272
+ } finally {
52273
+ closeSync(fd);
52274
+ }
52275
+ } catch {}
52276
+ }
52277
+ function pruneBackups(target) {
52278
+ const directory = dirname(target);
52279
+ const prefix = `${basename(target)}${BACKUP_INFIX}`;
52280
+ try {
52281
+ const backups = readdirSync(directory).filter((name) => name.startsWith(prefix)).sort();
52282
+ for (const name of backups.slice(0, Math.max(0, backups.length - BACKUPS_KEPT))) unlinkSync(join(directory, name));
52283
+ } catch {}
52284
+ }
52285
+ /** Copy the current file beside itself before any change. Returns the copy's path. */
52286
+ function backupManagedFile(target, mode, now) {
52287
+ const base = `${target}${BACKUP_INFIX}${backupStamp$1(now)}`;
52288
+ for (let attempt = 0; attempt < 100; attempt++) {
52289
+ const candidate = attempt === 0 ? base : `${base}-${attempt}`;
52290
+ try {
52291
+ copyFileSync(target, candidate, constants.COPYFILE_EXCL);
52292
+ chmodSync(candidate, mode);
52293
+ return candidate;
52294
+ } catch (error) {
52295
+ if (error.code !== "EEXIST") throw error;
52296
+ }
52297
+ }
52298
+ throw new MachineFileError(`Could not create a backup of ${target}.`);
52299
+ }
52300
+ /**
52301
+ * Replace a file atomically (temporary file plus rename in the same
52302
+ * directory), after backing up the existing content. The write is refused when
52303
+ * the file changed since it was read, so a concurrent edit is never lost.
52304
+ */
52305
+ function writeManagedFile(params) {
52306
+ const { read } = params;
52307
+ const target = read.target;
52308
+ const expected = read.state === "present" ? read.text : null;
52309
+ const directory = dirname(target);
52310
+ mkdirSync(directory, {
52311
+ recursive: true,
52312
+ mode: 448
52313
+ });
52314
+ const current = () => {
52315
+ try {
52316
+ return readFileSync(target, "utf8");
52317
+ } catch (error) {
52318
+ if (error.code === "ENOENT") return null;
52319
+ throw error;
52320
+ }
52321
+ };
52322
+ if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
52323
+ const mode = read.state === "present" ? read.mode : params.newFileMode ?? 420;
52324
+ const backup = read.state === "present" ? backupManagedFile(target, mode, (params.now ?? (() => /* @__PURE__ */ new Date()))()) : null;
52325
+ const temporary = join(directory, `.${basename(target)}.${randomUUID()}.tmp`);
52326
+ try {
52327
+ const fd = openSync(temporary, "wx", mode);
52328
+ try {
52329
+ writeFileSync(fd, params.content, "utf8");
52330
+ fsyncSync(fd);
52331
+ } finally {
52332
+ closeSync(fd);
52333
+ }
52334
+ chmodSync(temporary, mode);
52335
+ if (current() !== expected) throw new MachineFileError(`${read.path} changed while it was being repaired; it was left unchanged.`);
52336
+ renameSync(temporary, target);
52337
+ syncDirectory(directory);
52338
+ } finally {
52339
+ if (existsSync(temporary)) unlinkSync(temporary);
52340
+ }
52341
+ if (backup) pruneBackups(target);
52342
+ return { backup };
52343
+ }
52344
+ const START = /^<!-- primitive:managed-block v=(\d+) START -->\r?$/;
52345
+ const END = /^<!-- primitive:managed-block END -->\r?$/;
52346
+ /** Character offsets of the single managed block, or why the markers are unusable. */
52347
+ function locateBlock(text) {
52348
+ const starts = [];
52349
+ const ends = [];
52350
+ let offset = 0;
52351
+ for (const line of text.split("\n")) {
52352
+ const start = START.exec(line);
52353
+ if (start) starts.push({
52354
+ offset,
52355
+ version: Number(start[1])
52356
+ });
52357
+ else if (END.test(line)) ends.push({
52358
+ offset,
52359
+ length: line.endsWith("\r") ? line.length - 1 : line.length
52360
+ });
52361
+ else if (line.includes("primitive:managed-block")) return { malformed: "a primitive:managed-block marker line was edited; fix or remove it by hand" };
52362
+ offset += line.length + 1;
52363
+ }
52364
+ if (starts.length === 0 && ends.length === 0) return null;
52365
+ if (starts.length !== 1 || ends.length !== 1) return { malformed: `found ${starts.length} START and ${ends.length} END markers; expected one of each` };
52366
+ const [start] = starts;
52367
+ const [end] = ends;
52368
+ if (!start || !end || end.offset < start.offset) return { malformed: "the END marker comes before the START marker" };
52369
+ return {
52370
+ start: start.offset,
52371
+ end: end.offset + end.length,
52372
+ version: start.version
52373
+ };
52374
+ }
52375
+ /** The exact block for a body, using the file's own line ending. */
52376
+ function renderManagedBlock(body, eol = "\n") {
52377
+ return [
52378
+ `<!-- primitive:managed-block v=1 START -->`,
52379
+ ...body.replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n"),
52380
+ "<!-- primitive:managed-block END -->"
52381
+ ].join(eol);
52382
+ }
52383
+ function lineEnding(text) {
52384
+ return text.includes("\r\n") ? "\r\n" : "\n";
52385
+ }
52386
+ function inspectManagedBlock(text, body) {
52387
+ const found = locateBlock(text);
52388
+ if (found === null) return { state: "missing" };
52389
+ if ("malformed" in found) return {
52390
+ state: "malformed",
52391
+ detail: found.malformed
52392
+ };
52393
+ return text.slice(found.start, found.end).replace(/\r\n/g, "\n") === renderManagedBlock(body) ? { state: "ok" } : {
52394
+ state: "outdated",
52395
+ version: found.version
52396
+ };
52397
+ }
52398
+ /**
52399
+ * Replace only the managed block, or append it when absent. Text outside the
52400
+ * markers is preserved byte for byte. Throws for malformed markers.
52401
+ */
52402
+ function upsertManagedBlock(text, body) {
52403
+ const found = locateBlock(text);
52404
+ if (found !== null && "malformed" in found) throw new MachineFileError(found.malformed);
52405
+ const eol = lineEnding(text);
52406
+ const block = renderManagedBlock(body, eol);
52407
+ if (found) return `${text.slice(0, found.start)}${block}${text.slice(found.end)}`;
52408
+ if (text.length === 0) return `${block}${eol}`;
52409
+ return `${text}${text.endsWith(`${eol}${eol}`) ? "" : text.endsWith(eol) ? eol : `${eol}${eol}`}${block}${eol}`;
52410
+ }
52411
+ /** Indentation the file already uses, so a rewrite keeps its style. */
52412
+ function jsonIndentation(text) {
52413
+ return /^[{[][^\n]*\n([ \t]+)\S/.exec(text)?.[1] ?? 2;
52414
+ }
52415
+ //#endregion
52416
+ //#region src/oclif/claude-machine-hooks.ts
52417
+ function record$2(value) {
52418
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52419
+ }
52420
+ function realpathOrNull(path) {
52421
+ try {
52422
+ return realpathSync(path);
52423
+ } catch {
52424
+ return null;
52425
+ }
52426
+ }
52427
+ /** Two paths name the same existing file. */
52428
+ function samePath(a, b) {
52429
+ if (typeof a !== "string" || !a) return false;
52430
+ if (a === b) return existsSync(a);
52431
+ const left = realpathOrNull(a);
52432
+ return left !== null && left === realpathOrNull(b);
52433
+ }
52434
+ function executable(path) {
52435
+ try {
52436
+ accessSync(path, constants.X_OK);
52437
+ return true;
52438
+ } catch {
52439
+ return false;
52440
+ }
52441
+ }
52442
+ /** The first PATH entry for a command, without running a shell. */
52443
+ function findOnPath(name, env) {
52444
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
52445
+ if (!directory) continue;
52446
+ const candidate = join(directory, name);
52447
+ if (existsSync(candidate) && executable(candidate)) return candidate;
52448
+ }
52449
+ return null;
52450
+ }
52451
+ /**
52452
+ * Prefer a PATH link (for example a package manager's `bin/node`) over a
52453
+ * versioned install directory when both are the same binary, so a hook keeps
52454
+ * working after the versioned directory is replaced by an upgrade.
52455
+ */
52456
+ function stableNodePath(env, execPath = process.execPath) {
52457
+ const onPath = findOnPath("node", env);
52458
+ if (onPath && onPath !== execPath && samePath(onPath, execPath)) return onPath;
52459
+ return execPath;
52460
+ }
52461
+ /** Locate the running CLI. Null when its files are not where a package install puts them. */
52462
+ function currentCliLocation(entryArg, env, execPath = process.execPath) {
52463
+ const entry = realpathOrNull(entryArg);
52464
+ if (!entry) return null;
52465
+ const wake = realpathOrNull(join(dirname(entry), "claude-wake.mjs"));
52466
+ const pending = realpathOrNull(join(dirname(entry), "claude-pending-mail.mjs"));
52467
+ if (!wake || !pending) return null;
52468
+ return {
52469
+ node: stableNodePath(env, execPath),
52470
+ entry,
52471
+ wake,
52472
+ pending
52473
+ };
52474
+ }
52475
+ /** Package-runner caches are cleaned up behind the user's back. */
52476
+ function ephemeralCliReason(entry) {
52477
+ if (/[\\/]_npx[\\/]/.test(entry)) return "an npx cache";
52478
+ if (/[\\/]dlx[-\\/]/.test(entry)) return "a pnpm dlx cache";
52479
+ if (/[\\/]bunx-/.test(entry)) return "a bunx cache";
52480
+ return null;
52481
+ }
52482
+ function claudeConfigDir(env, home) {
52483
+ return resolve(env.CLAUDE_CONFIG_DIR || join(home, ".claude"));
52484
+ }
52485
+ function jsonErrorDetail(error) {
52486
+ const message = error instanceof Error ? error.message : "";
52487
+ const position = /position (\d+)/.exec(message)?.[1];
52488
+ const line = /line (\d+)/.exec(message)?.[1];
52489
+ return line ? ` near line ${line}` : position ? ` near character ${position}` : "";
52490
+ }
52491
+ /** Parse settings.json. Anything unexpected is reported, never repaired. */
52492
+ function readClaudeSettings(claudeDir) {
52493
+ const path = join(claudeDir, "settings.json");
52494
+ const read = readManagedFile(path);
52495
+ if (read.state === "invalid") return {
52496
+ ok: false,
52497
+ detail: read.detail
52498
+ };
52499
+ if (read.state === "absent") return {
52500
+ ok: true,
52501
+ read,
52502
+ settings: {}
52503
+ };
52504
+ let parsed;
52505
+ try {
52506
+ parsed = JSON.parse(read.text);
52507
+ } catch (error) {
52508
+ return {
52509
+ ok: false,
52510
+ detail: `${path} is not valid JSON${jsonErrorDetail(error)}. Fix it by hand; it was not modified.`
52511
+ };
52512
+ }
52513
+ if (!record$2(parsed)) return {
52514
+ ok: false,
52515
+ detail: `${path} must contain a JSON object. Fix it by hand; it was not modified.`
52516
+ };
52517
+ if (parsed.hooks !== void 0 && !record$2(parsed.hooks)) return {
52518
+ ok: false,
52519
+ detail: `${path} has a "hooks" value that is not an object. Fix it by hand; it was not modified.`
52520
+ };
52521
+ return {
52522
+ ok: true,
52523
+ read,
52524
+ settings: parsed
52525
+ };
52526
+ }
52527
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
52528
+ /**
52529
+ * Write settings under the same lock every Primitive hook installer uses,
52530
+ * keeping the file's indentation, after a timestamped backup.
52531
+ */
52532
+ function writeClaudeSettings(params) {
52533
+ let release;
52534
+ for (let attempt = 0; attempt < 80; attempt++) try {
52535
+ release = acquireListenLock(params.claudeDir, "primitive-claude-settings");
52536
+ break;
52537
+ } catch {
52538
+ Atomics.wait(LOCK_WAIT, 0, 0, 25);
52539
+ }
52540
+ if (!release) throw new MachineFileError("Another Primitive command is editing Claude settings; try again shortly.");
52541
+ try {
52542
+ const original = params.read.state === "present" ? params.read.text : null;
52543
+ const indent = original === null ? 2 : jsonIndentation(original);
52544
+ const trailing = original === null || original.endsWith("\n") ? "\n" : "";
52545
+ return writeManagedFile({
52546
+ read: params.read,
52547
+ content: `${JSON.stringify(params.settings, null, indent)}${trailing}`,
52548
+ now: params.now,
52549
+ newFileMode: 384
52550
+ });
52551
+ } finally {
52552
+ release();
52553
+ }
52554
+ }
52555
+ const GLOBAL_HOOKS = {
52556
+ "claude.hook.session_start": {
52557
+ event: "SessionStart",
52558
+ subcommand: "session-register",
52559
+ timeout: 15
52560
+ },
52561
+ "claude.hook.session_end": {
52562
+ event: "SessionEnd",
52563
+ subcommand: "session-end",
52564
+ timeout: 10
52565
+ }
52566
+ };
52567
+ /** A hook this CLI installed for every Claude session, from any CLI path or version. */
52568
+ function isGlobalManaged(hook, spec) {
52569
+ if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return false;
52570
+ const args = hook.args;
52571
+ return args.length === 6 && typeof args[0] === "string" && args[1] === "agent" && args[2] === spec.subcommand && args[3] === "--runtime" && args[4] === "claude" && args[5] === "--hook";
52572
+ }
52573
+ function globalHookArgs(cli, spec) {
52574
+ return [
52575
+ cli.entry,
52576
+ "agent",
52577
+ spec.subcommand,
52578
+ "--runtime",
52579
+ "claude",
52580
+ "--hook"
52581
+ ];
52582
+ }
52583
+ function globalHookCurrent(hook, cli) {
52584
+ const args = hook.args;
52585
+ return samePath(hook.command, cli.node) && samePath(args[0], cli.entry);
52586
+ }
52587
+ const WAKE_MARKER = "primitive-agent-wake-v1";
52588
+ const PENDING_MARKER = "primitive-pending-mail-v1";
52589
+ const SESSION_EVENTS = [
52590
+ "Stop",
52591
+ "SessionStart",
52592
+ "PostToolUse"
52593
+ ];
52594
+ /** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
52595
+ function parseSessionHook(hook) {
52596
+ if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return null;
52597
+ const args = hook.args;
52598
+ if (!args.every((value) => typeof value === "string")) return null;
52599
+ const strings = args;
52600
+ const script = basename(strings[0] ?? "");
52601
+ if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER ? {
52602
+ kind: "pending",
52603
+ configDir: strings[2] ?? "",
52604
+ profile: strings[3] ?? null,
52605
+ session: strings[5] ?? null
52606
+ } : null;
52607
+ if (script !== "claude-wake.mjs") return null;
52608
+ if (strings.length === 7 && strings[6] === WAKE_MARKER) return {
52609
+ kind: "wake",
52610
+ configDir: strings[2] ?? "",
52611
+ profile: strings[3] ?? null,
52612
+ session: strings[5] ?? null
52613
+ };
52614
+ if (strings.length === 6 && strings[5] === WAKE_MARKER) return {
52615
+ kind: "wake",
52616
+ configDir: strings[2] ?? "",
52617
+ profile: strings[3] ?? null,
52618
+ session: null
52619
+ };
52620
+ if (strings.length === 4 && strings[3] === WAKE_MARKER) return {
52621
+ kind: "wake",
52622
+ configDir: strings[2] ?? "",
52623
+ profile: null,
52624
+ session: null
52625
+ };
52626
+ return null;
52627
+ }
52628
+ /** A saved connected profile still holds a credential. Unreadable counts as live. */
52629
+ function profileStillConnected(configDir, profile) {
52630
+ try {
52631
+ return loadConnectedAgentProfile(configDir, profile) !== null;
52632
+ } catch {
52633
+ return true;
52634
+ }
52635
+ }
52636
+ function sessionHookPathsCurrent(hook, parsed, cli) {
52637
+ const args = hook.args;
52638
+ if (!cli) return existsSync(String(hook.command)) && existsSync(args[0] ?? "") && existsSync(args[1] ?? "");
52639
+ return samePath(hook.command, cli.node) && samePath(args[0], parsed.kind === "wake" ? cli.wake : cli.pending) && samePath(args[1], cli.entry);
52640
+ }
52641
+ function judgeSessionHooks(hooks, cli) {
52642
+ const verdicts = /* @__PURE__ */ new Map();
52643
+ const seen = /* @__PURE__ */ new Set();
52644
+ for (const event of SESSION_EVENTS) {
52645
+ const entries = hooks[event];
52646
+ if (!Array.isArray(entries)) continue;
52647
+ for (const entry of entries) {
52648
+ if (!record$2(entry) || !Array.isArray(entry.hooks)) continue;
52649
+ for (const hook of entry.hooks) {
52650
+ const parsed = parseSessionHook(hook);
52651
+ if (!parsed || !record$2(hook)) continue;
52652
+ const pathsExist = existsSync(String(hook.command)) && existsSync(String(hook.args[0])) && existsSync(String(hook.args[1]));
52653
+ if (!(parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
52654
+ verdicts.set(hook, "stale");
52655
+ continue;
52656
+ }
52657
+ const key = [
52658
+ event,
52659
+ parsed.kind,
52660
+ resolve(parsed.configDir),
52661
+ parsed.profile ?? "",
52662
+ parsed.session?.toLowerCase() ?? ""
52663
+ ].join("\0");
52664
+ if (seen.has(key)) {
52665
+ verdicts.set(hook, "duplicate");
52666
+ continue;
52667
+ }
52668
+ seen.add(key);
52669
+ verdicts.set(hook, sessionHookPathsCurrent(hook, parsed, cli) ? "keep" : "outdated");
52670
+ }
52671
+ }
52672
+ }
52673
+ return verdicts;
52674
+ }
52675
+ function eventEntries(hooks, event) {
52676
+ const entries = hooks[event];
52677
+ if (entries === void 0) return [];
52678
+ return Array.isArray(entries) ? entries : "invalid";
52679
+ }
52680
+ /** Inspect every Primitive-owned Claude hook without changing anything. */
52681
+ function inspectClaudeHooks(settings, context) {
52682
+ const hooks = record$2(settings.hooks) ? settings.hooks : {};
52683
+ const canFix = context.cli !== null && context.blocked === null;
52684
+ const blockedNote = context.blocked ? ` ${context.blocked}` : "";
52685
+ const findings = {};
52686
+ for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
52687
+ const entries = eventEntries(hooks, spec.event);
52688
+ if (entries === "invalid") {
52689
+ findings[id] = {
52690
+ status: "fail",
52691
+ detail: `hooks.${spec.event} is not a list. Fix settings.json by hand; it was not modified.`,
52692
+ fixable: false
52693
+ };
52694
+ continue;
52695
+ }
52696
+ const managed = entries.flatMap((entry) => record$2(entry) && Array.isArray(entry.hooks) ? entry.hooks.filter((hook) => isGlobalManaged(hook, spec)) : []);
52697
+ const [first] = managed;
52698
+ if (!first) findings[id] = {
52699
+ status: "fail",
52700
+ detail: `No ${spec.event} hook runs \`primitive agent ${spec.subcommand}\`.${blockedNote}`,
52701
+ fixable: canFix
52702
+ };
52703
+ else if (managed.length > 1) findings[id] = {
52704
+ status: "fail",
52705
+ detail: `${managed.length} ${spec.event} hooks run \`primitive agent ${spec.subcommand}\`; expected exactly one.${blockedNote}`,
52706
+ fixable: canFix
52707
+ };
52708
+ else if (!context.cli || !globalHookCurrent(first, context.cli)) findings[id] = {
52709
+ status: "fail",
52710
+ detail: `The ${spec.event} hook runs ${String(first.args[0])}, which ${existsSync(String(first.args[0])) ? "is not this CLI" : "no longer exists"}.${blockedNote}`,
52711
+ fixable: canFix
52712
+ };
52713
+ else findings[id] = {
52714
+ status: "ok",
52715
+ detail: `${spec.event} runs \`primitive agent ${spec.subcommand}\` from ${context.cli.entry}.`,
52716
+ fixable: false
52717
+ };
52718
+ }
52719
+ const invalid = SESSION_EVENTS.find((event) => eventEntries(hooks, event) === "invalid");
52720
+ if (invalid) {
52721
+ findings["claude.hook.stop"] = {
52722
+ status: "fail",
52723
+ detail: `hooks.${invalid} is not a list. Fix settings.json by hand; it was not modified.`,
52724
+ fixable: false
52725
+ };
52726
+ return findings;
52727
+ }
52728
+ const counts = {
52729
+ keep: 0,
52730
+ stale: 0,
52731
+ duplicate: 0,
52732
+ outdated: 0
52733
+ };
52734
+ for (const verdict of judgeSessionHooks(hooks, context.cli).values()) counts[verdict]++;
52735
+ const problems = [
52736
+ counts.stale ? `${counts.stale} for disconnected or removed sessions` : "",
52737
+ counts.duplicate ? `${counts.duplicate} duplicates` : "",
52738
+ counts.outdated ? `${counts.outdated} pointing at an old CLI path` : ""
52739
+ ].filter(Boolean);
52740
+ findings["claude.hook.stop"] = problems.length ? {
52741
+ status: "fail",
52742
+ detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}.${blockedNote}`,
52743
+ fixable: canFix || counts.outdated === 0 && context.blocked === null
52744
+ } : {
52745
+ status: "ok",
52746
+ 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.",
52747
+ fixable: false
52748
+ };
52749
+ return findings;
52750
+ }
52751
+ function rewriteEntries(entries, transform) {
52752
+ let changed = false;
52753
+ return {
52754
+ entries: entries.flatMap((entry) => {
52755
+ if (!record$2(entry) || !Array.isArray(entry.hooks)) return [entry];
52756
+ let touched = false;
52757
+ const kept = entry.hooks.flatMap((hook) => {
52758
+ const replacement = transform(hook);
52759
+ if (replacement === hook) return [hook];
52760
+ touched = true;
52761
+ return replacement === void 0 ? [] : [replacement];
52762
+ });
52763
+ if (!touched) return [entry];
52764
+ changed = true;
52765
+ return kept.length ? [{
52766
+ ...entry,
52767
+ hooks: kept
52768
+ }] : [];
52769
+ }),
52770
+ changed
52771
+ };
52772
+ }
52773
+ /**
52774
+ * Apply fixes for the requested checks to a copy of the settings. Only
52775
+ * Primitive-owned hooks are added, rewritten or removed; every other key,
52776
+ * entry and sibling hook is carried over unchanged.
52777
+ */
52778
+ function repairClaudeHooks(settings, context, checks) {
52779
+ const changed = /* @__PURE__ */ new Set();
52780
+ const hooks = record$2(settings.hooks) ? { ...settings.hooks } : {};
52781
+ const cli = context.blocked === null ? context.cli : null;
52782
+ for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
52783
+ if (!checks.has(id) || !cli) continue;
52784
+ const entries = eventEntries(hooks, spec.event);
52785
+ if (entries === "invalid") continue;
52786
+ let placed = false;
52787
+ const result = rewriteEntries(entries, (hook) => {
52788
+ if (!isGlobalManaged(hook, spec)) return hook;
52789
+ if (placed) return void 0;
52790
+ placed = true;
52791
+ if (globalHookCurrent(hook, cli)) return hook;
52792
+ return {
52793
+ ...hook,
52794
+ command: cli.node,
52795
+ args: globalHookArgs(cli, spec)
52796
+ };
52797
+ });
52798
+ let next = result.entries;
52799
+ if (!placed) next = [...next, { hooks: [{
52800
+ type: "command",
52801
+ command: cli.node,
52802
+ args: globalHookArgs(cli, spec),
52803
+ timeout: spec.timeout
52804
+ }] }];
52805
+ if (result.changed || !placed) {
52806
+ hooks[spec.event] = next;
52807
+ changed.add(id);
52808
+ }
52809
+ }
52810
+ if (checks.has("claude.hook.stop") && context.blocked === null) {
52811
+ if (!SESSION_EVENTS.some((event) => eventEntries(hooks, event) === "invalid")) {
52812
+ const verdicts = judgeSessionHooks(hooks, context.cli);
52813
+ for (const event of SESSION_EVENTS) {
52814
+ const entries = eventEntries(hooks, event);
52815
+ if (entries === "invalid" || entries.length === 0) continue;
52816
+ const result = rewriteEntries(entries, (hook) => {
52817
+ const verdict = verdicts.get(hook);
52818
+ if (verdict === "stale" || verdict === "duplicate") return void 0;
52819
+ if (verdict !== "outdated" || !cli || !record$2(hook)) return hook;
52820
+ const parsed = parseSessionHook(hook);
52821
+ const args = [...hook.args];
52822
+ args[0] = parsed?.kind === "pending" ? cli.pending : cli.wake;
52823
+ args[1] = cli.entry;
52824
+ return {
52825
+ ...hook,
52826
+ command: cli.node,
52827
+ args
52828
+ };
52829
+ });
52830
+ if (result.changed) {
52831
+ hooks[event] = result.entries;
52832
+ changed.add("claude.hook.stop");
52833
+ }
52834
+ }
52835
+ }
52836
+ }
52837
+ if (!changed.size) return {
52838
+ settings,
52839
+ changed
52840
+ };
52841
+ return {
52842
+ settings: {
52843
+ ...settings,
52844
+ hooks
52845
+ },
52846
+ changed
52847
+ };
52848
+ }
52849
+ //#endregion
52850
+ //#region src/oclif/codex-machine-hooks.ts
52851
+ function record$1(value) {
52852
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52853
+ }
52854
+ /** Codex hook commands are shell strings, so every path is single-quoted. */
52855
+ function shellQuote(value) {
52856
+ return `'${value.replace(/'/g, `'\\''`)}'`;
52857
+ }
52858
+ const MANAGED_SUFFIX = /\bagent session-register --runtime codex --hook\s*$/;
52859
+ function codexHookCommand(cli) {
52860
+ return `${shellQuote(cli.node)} ${shellQuote(cli.entry)} agent session-register --runtime codex --hook`;
52861
+ }
52862
+ /** A SessionStart hook this CLI installed, from any CLI path or version. */
52863
+ function isManaged(hook) {
52864
+ return record$1(hook) && hook.type === "command" && typeof hook.command === "string" && MANAGED_SUFFIX.test(hook.command);
52865
+ }
52866
+ /** Parse hooks.json. Anything unexpected is reported, never repaired. */
52867
+ function readCodexHooks(codexHome) {
52868
+ const path = join(codexHome, "hooks.json");
52869
+ const read = readManagedFile(path);
52870
+ if (read.state === "invalid") return {
52871
+ ok: false,
52872
+ path,
52873
+ detail: read.detail
52874
+ };
52875
+ if (read.state === "absent") return {
52876
+ ok: true,
52877
+ path,
52878
+ read,
52879
+ settings: {}
52880
+ };
52881
+ let parsed;
52882
+ try {
52883
+ parsed = JSON.parse(read.text);
52884
+ } catch {
52885
+ return {
52886
+ ok: false,
52887
+ path,
52888
+ detail: `${path} is not valid JSON. Fix it by hand; it was not modified.`
52889
+ };
52890
+ }
52891
+ if (!record$1(parsed) || parsed.hooks !== void 0 && !record$1(parsed.hooks)) return {
52892
+ ok: false,
52893
+ path,
52894
+ detail: `${path} must be a JSON object with a "hooks" object. Fix it by hand; it was not modified.`
52895
+ };
52896
+ const entries = record$1(parsed.hooks) ? parsed.hooks.SessionStart : void 0;
52897
+ if (entries !== void 0 && !Array.isArray(entries)) return {
52898
+ ok: false,
52899
+ path,
52900
+ detail: `hooks.SessionStart in ${path} is not a list. Fix it by hand; it was not modified.`
52901
+ };
52902
+ return {
52903
+ ok: true,
52904
+ path,
52905
+ read,
52906
+ settings: parsed
52907
+ };
52908
+ }
52909
+ function sessionStartEntries(settings) {
52910
+ const hooks = record$1(settings.hooks) ? settings.hooks : {};
52911
+ return Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
52912
+ }
52913
+ /** Where each managed hook sits, as Codex numbers hooks for trust records. */
52914
+ function managedPositions(settings) {
52915
+ const found = [];
52916
+ sessionStartEntries(settings).forEach((entry, entryIndex) => {
52917
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return;
52918
+ const hooks = entry.hooks;
52919
+ hooks.forEach((hook, hookIndex) => {
52920
+ if (isManaged(hook)) found.push({
52921
+ entry: entryIndex,
52922
+ hook: hookIndex,
52923
+ value: hook,
52924
+ last: hookIndex === hooks.length - 1,
52925
+ matcher: typeof entry.matcher === "string" ? entry.matcher : null
52926
+ });
52927
+ });
52928
+ });
52929
+ return found;
52930
+ }
52931
+ /** Codex's default spill threshold; a hook set to it hashes as if unset. */
52932
+ const DEFAULT_ADDITIONAL_CONTEXT_LIMIT = 2500;
52933
+ function sortKeys(value) {
52934
+ if (Array.isArray(value)) return value.map(sortKeys);
52935
+ if (record$1(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]));
52936
+ return value;
52937
+ }
52938
+ /**
52939
+ * The trust hash Codex computes for a hooks.json command hook: SHA-256 of the
52940
+ * key-sorted JSON of its normalized identity (event, matcher, and the
52941
+ * handler with its default timeout filled in and unset options omitted).
52942
+ * Checked against the trust records Codex wrote on a real machine.
52943
+ */
52944
+ function codexHookTrustHash(event, matcher, hook) {
52945
+ const timeout = typeof hook.timeout === "number" && Number.isInteger(hook.timeout) ? Math.max(1, hook.timeout) : 600;
52946
+ const handler = {
52947
+ type: "command",
52948
+ command: hook.command,
52949
+ timeout,
52950
+ async: hook.async === true
52951
+ };
52952
+ if (typeof hook.statusMessage === "string") handler.statusMessage = hook.statusMessage;
52953
+ if (typeof hook.additionalContextLimit === "number" && hook.additionalContextLimit !== DEFAULT_ADDITIONAL_CONTEXT_LIMIT) handler.additionalContextLimit = hook.additionalContextLimit;
52954
+ const identity = {
52955
+ event_name: event,
52956
+ hooks: [handler]
52957
+ };
52958
+ if (matcher !== null) identity.matcher = matcher;
52959
+ return `sha256:${createHash("sha256").update(JSON.stringify(sortKeys(identity))).digest("hex")}`;
52960
+ }
52961
+ /**
52962
+ * Codex runs a hooks.json hook only once the person approves it. It records
52963
+ * the approval under `[hooks.state."<file>:session_start:<entry>:<hook>"]` in
52964
+ * config.toml with the hash of the hook as approved; a different current hash
52965
+ * means the approval no longer applies.
52966
+ */
52967
+ function trustState(configToml, key, currentHash) {
52968
+ if (configToml === null) return "untrusted";
52969
+ const lines = configToml.split(/\r?\n/);
52970
+ const header = `[hooks.state."${key}"]`;
52971
+ const start = lines.findIndex((line) => line.trim() === header);
52972
+ if (start < 0) return "untrusted";
52973
+ let trusted = null;
52974
+ let disabled = false;
52975
+ for (const line of lines.slice(start + 1)) {
52976
+ if (line.trim().startsWith("[")) break;
52977
+ const hash = /^\s*trusted_hash\s*=\s*"([^"]*)"/.exec(line);
52978
+ if (hash) trusted = hash[1] ?? null;
52979
+ if (/^\s*enabled\s*=\s*false\b/.test(line)) disabled = true;
52980
+ }
52981
+ if (disabled) return "disabled";
52982
+ if (trusted === null) return "untrusted";
52983
+ return trusted === currentHash ? "trusted" : "modified";
52984
+ }
52985
+ function inspectCodexHook(read, context, configToml) {
52986
+ const canFix = context.cli !== null && context.blocked === null;
52987
+ const blockedNote = context.blocked ? ` ${context.blocked}` : "";
52988
+ const managed = managedPositions(read.settings);
52989
+ const [first] = managed;
52990
+ if (!first) return {
52991
+ status: "fail",
52992
+ detail: `No SessionStart hook in ${read.path} runs \`primitive agent session-register --runtime codex --hook\`.${blockedNote}`,
52993
+ fixable: canFix
52994
+ };
52995
+ const desired = context.cli ? codexHookCommand(context.cli) : null;
52996
+ const extras = managed.slice(1);
52997
+ const removable = extras.filter((copy) => copy.last).length;
52998
+ const stale = managed.filter((copy) => copy.value.command !== desired).length;
52999
+ if (removable || stale) return {
53000
+ status: "fail",
53001
+ detail: [stale ? `${stale} SessionStart hooks run \`primitive agent session-register\` from another CLI path.` : "", removable ? `${removable} extra copies can be removed without moving other hooks.` : ""].filter(Boolean).join(" ").concat(blockedNote),
53002
+ fixable: canFix
53003
+ };
53004
+ if (extras.length) return {
53005
+ status: "warn",
53006
+ detail: `${extras.length} extra copies of the hook sit before other hooks in their entries; they were left in place so those hooks keep their Codex approval. Remove them by hand if you like.`,
53007
+ fixable: false
53008
+ };
53009
+ const trust = trustState(configToml, `${read.path}:session_start:${first.entry}:${first.hook}`, codexHookTrustHash("session_start", first.matcher, first.value));
53010
+ if (trust === "disabled") return {
53011
+ status: "warn",
53012
+ detail: "The Codex SessionStart hook is installed but disabled in Codex.",
53013
+ fixable: false
53014
+ };
53015
+ if (trust !== "trusted") return {
53016
+ status: "warn",
53017
+ detail: trust === "modified" ? "The Codex SessionStart hook changed since it was approved; approval needs to be confirmed in Codex." : "The Codex SessionStart hook is installed; approval needs to be confirmed in Codex before it runs.",
53018
+ fixable: false
53019
+ };
53020
+ return {
53021
+ status: "ok",
53022
+ detail: `SessionStart runs \`primitive agent session-register --runtime codex --hook\` from ${context.cli?.entry ?? "this CLI"}, approved in Codex.`,
53023
+ fixable: false
53024
+ };
53025
+ }
53026
+ /**
53027
+ * Codex keys approvals by entry and hook position, so this never removes or
53028
+ * reorders an entry, and never moves a hook that is not Primitive's. Managed
53029
+ * hooks are rewritten in place; an extra copy is removed only when it is the
53030
+ * last hook in its entry (its entry stays, even if empty); a hook is appended
53031
+ * as a new last entry when none exists.
53032
+ */
53033
+ function repairCodexHook(settings, cli) {
53034
+ const desired = codexHookCommand(cli);
53035
+ let placed = false;
53036
+ let changed = false;
53037
+ const entries = sessionStartEntries(settings).map((entry) => {
53038
+ if (!record$1(entry) || !Array.isArray(entry.hooks)) return entry;
53039
+ const hooks = entry.hooks;
53040
+ let touched = false;
53041
+ const next = hooks.flatMap((hook, index) => {
53042
+ if (!isManaged(hook)) return [hook];
53043
+ const isLast = index === hooks.length - 1;
53044
+ if (placed && isLast) {
53045
+ touched = true;
53046
+ return [];
53047
+ }
53048
+ placed = true;
53049
+ if (hook.command === desired) return [hook];
53050
+ touched = true;
53051
+ return [{
53052
+ ...hook,
53053
+ command: desired
53054
+ }];
53055
+ });
53056
+ if (!touched) return entry;
53057
+ changed = true;
53058
+ return {
53059
+ ...entry,
53060
+ hooks: next
53061
+ };
53062
+ });
53063
+ if (!placed) {
53064
+ entries.push({ hooks: [{
53065
+ type: "command",
53066
+ command: desired
53067
+ }] });
53068
+ changed = true;
53069
+ }
53070
+ if (!changed) return {
53071
+ settings,
53072
+ changed
53073
+ };
53074
+ const hooks = record$1(settings.hooks) ? settings.hooks : {};
53075
+ return {
53076
+ settings: {
53077
+ ...settings,
53078
+ hooks: {
53079
+ ...hooks,
53080
+ SessionStart: entries
53081
+ }
53082
+ },
53083
+ changed
53084
+ };
53085
+ }
53086
+ function writeCodexHooks(params) {
53087
+ const original = params.read.state === "present" ? params.read.text : null;
53088
+ const indent = original === null ? 2 : jsonIndentation(original);
53089
+ const trailing = original === null || original.endsWith("\n") ? "\n" : "";
53090
+ writeManagedFile({
53091
+ read: params.read,
53092
+ content: `${JSON.stringify(params.settings, null, indent)}${trailing}`,
53093
+ now: params.now,
53094
+ newFileMode: 384
53095
+ });
53096
+ }
53097
+ const DOCTOR_CHECK_IDS = [
53098
+ "cli.installed",
53099
+ "cli.version",
53100
+ "cli.path_stable",
53101
+ "auth.member_login",
53102
+ "claude.settings_valid",
53103
+ "claude.hook.session_start",
53104
+ "claude.hook.session_end",
53105
+ "claude.hook.stop",
53106
+ "claude.instructions",
53107
+ "codex.hook.session_start",
53108
+ "codex.instructions",
53109
+ "omp.instructions",
53110
+ "skill.claude",
53111
+ "skill.codex",
53112
+ "profiles.orphaned"
53113
+ ];
53114
+ const TITLES = {
53115
+ "cli.installed": "Primitive CLI installed",
53116
+ "cli.version": "Primitive CLI version",
53117
+ "cli.path_stable": "CLI location is stable",
53118
+ "auth.member_login": "Member login",
53119
+ "claude.settings_valid": "Claude Code settings file",
53120
+ "claude.hook.session_start": "Claude SessionStart hook",
53121
+ "claude.hook.session_end": "Claude SessionEnd hook",
53122
+ "claude.hook.stop": "Claude per-session receive hooks",
53123
+ "claude.instructions": "Claude Code instructions",
53124
+ "codex.hook.session_start": "Codex SessionStart hook",
53125
+ "codex.instructions": "Codex instructions",
53126
+ "omp.instructions": "omp instructions",
53127
+ "skill.claude": "primitive-connect skill for Claude Code",
53128
+ "skill.codex": "primitive-connect skill for Codex",
53129
+ "profiles.orphaned": "Saved agent profiles"
53130
+ };
53131
+ const WORKING_LINE = "Keep AGENT_WORKING current: `primitive agent working set \"<task>: <files>\"` when work starts and `primitive agent working clear` when it ends.";
53132
+ const REPLY_LINE = "Answer Primitive mail on Primitive with `primitive reply`; read receipts are automatic.";
53133
+ const SECRET_LINE = "Never print invitation tokens or credentials.";
53134
+ /** The managed instruction block for one runtime. Kept short on purpose. */
53135
+ function managedInstructions(runtime) {
53136
+ return [
53137
+ "## Primitive",
53138
+ "",
53139
+ `- ${runtime === "claude" || runtime === "codex" ? `A Primitive SessionStart hook registers this session with \`primitive agent session-register --runtime ${runtime} --quiet\`; if it has not run, run that command once.` : `At session start, if this session is not registered yet, run \`primitive agent session-register --runtime ${runtime} --quiet\`.`}`,
53140
+ `- ${REPLY_LINE}`,
53141
+ `- ${WORKING_LINE}`,
53142
+ `- ${SECRET_LINE}`
53143
+ ].join("\n");
53144
+ }
53145
+ /** Compare dotted numeric versions; pre-release suffixes are ignored. */
53146
+ function compareVersions(a, b) {
53147
+ const parse = (value) => {
53148
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
53149
+ return match ? match.slice(1, 4).map(Number) : null;
53150
+ };
53151
+ const left = parse(a);
53152
+ const right = parse(b);
53153
+ if (!left || !right) return null;
53154
+ for (let index = 0; index < 3; index++) {
53155
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
53156
+ if (difference) return Math.sign(difference);
53157
+ }
53158
+ return 0;
53159
+ }
53160
+ async function npmLatestVersion(fetchImpl) {
53161
+ try {
53162
+ const response = await fetchImpl("https://registry.npmjs.org/primitive/latest", {
53163
+ headers: { accept: "application/json" },
53164
+ redirect: "follow",
53165
+ signal: AbortSignal.timeout(3e3)
53166
+ });
53167
+ if (!response.ok) return null;
53168
+ const text = await response.text();
53169
+ if (text.length > 1048576) return null;
53170
+ const version = JSON.parse(text).version;
53171
+ return typeof version === "string" ? version : null;
53172
+ } catch {
53173
+ return null;
53174
+ }
53175
+ }
53176
+ const SKILL_BACKUPS_KEPT = 2;
53177
+ /**
53178
+ * Skill backups are named <runtime>-<sequence>-<timestamp>. The sequence is
53179
+ * one past the highest existing one for the runtime, so names order strictly
53180
+ * by creation even when several are made within the same second. Backups
53181
+ * from the earlier <runtime>-<timestamp>[-<n>] naming have no sequence; they
53182
+ * sort before every sequenced backup, oldest first, so pruning removes them
53183
+ * first.
53184
+ */
53185
+ function skillBackups(directory, runtime) {
53186
+ const sequenced = new RegExp(`^${runtime}-(\\d{6,})-\\d{8}T\\d{6}Z$`);
53187
+ const legacy = new RegExp(`^${runtime}-(\\d{8}T\\d{6}Z)(?:-(\\d+))?$`);
53188
+ let names;
53189
+ try {
53190
+ names = readdirSync(directory);
53191
+ } catch {
53192
+ return [];
53193
+ }
53194
+ const legacyNames = names.flatMap((name) => {
53195
+ const match = legacy.exec(name);
53196
+ return match ? [{
53197
+ name,
53198
+ stamp: match[1] ?? "",
53199
+ suffix: Number(match[2] ?? 0)
53200
+ }] : [];
53201
+ }).sort((left, right) => left.stamp === right.stamp ? left.suffix - right.suffix : left.stamp < right.stamp ? -1 : 1).map(({ name }, index, all) => ({
53202
+ name,
53203
+ sequence: index - all.length
53204
+ }));
53205
+ const sequencedNames = names.flatMap((name) => {
53206
+ const match = sequenced.exec(name);
53207
+ return match ? [{
53208
+ name,
53209
+ sequence: Number(match[1])
53210
+ }] : [];
53211
+ });
53212
+ return [...legacyNames, ...sequencedNames].sort((left, right) => left.sequence - right.sequence);
53213
+ }
53214
+ function nextSkillBackupName(directory, runtime, now) {
53215
+ const last = Math.max(0, skillBackups(directory, runtime).at(-1)?.sequence ?? 0);
53216
+ return `${runtime}-${String(last + 1).padStart(6, "0")}-${backupStamp(now)}`;
53217
+ }
53218
+ function pruneSkillBackups(directory, runtime) {
53219
+ const backups = skillBackups(directory, runtime);
53220
+ for (const { name } of backups.slice(0, Math.max(0, backups.length - SKILL_BACKUPS_KEPT))) try {
53221
+ rmSync(join(directory, name), {
53222
+ recursive: true,
53223
+ force: true
53224
+ });
53225
+ } catch {}
53226
+ }
53227
+ function backupStamp(now) {
53228
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
53229
+ }
53230
+ function runtimePresent(runtime, paths, env) {
53231
+ return existsSync(runtime === "claude" ? paths.claudeDir : runtime === "codex" ? paths.codexHome : paths.ompRoot) || findOnPath(runtime, env) !== null;
53232
+ }
53233
+ function runtimePaths(env, home) {
53234
+ const ompRoot = resolve(home, env.PI_CONFIG_DIR || ".omp");
53235
+ return {
53236
+ claudeDir: claudeConfigDir(env, home),
53237
+ codexHome: resolve(env.CODEX_HOME || join(home, ".codex")),
53238
+ ompRoot,
53239
+ ompAgentDir: resolve(env.PI_CODING_AGENT_DIR || join(ompRoot, "agent"))
53240
+ };
53241
+ }
53242
+ /** Codex reads AGENTS.override.md instead of AGENTS.md when it has content. */
53243
+ function codexInstructionsPath(codexHome) {
53244
+ const override = join(codexHome, "AGENTS.override.md");
53245
+ const read = readManagedFile(override);
53246
+ if (read.state === "present" && read.text.trim()) return override;
53247
+ return join(codexHome, "AGENTS.md");
53248
+ }
53249
+ async function memberCredentialsFor(options) {
53250
+ try {
53251
+ const saved = loadCliCredentials(options.configDir);
53252
+ if (!saved) return null;
53253
+ return await refreshStoredCliCredentials({
53254
+ apiBaseUrl: saved.api_base_url,
53255
+ configDir: options.configDir,
53256
+ credentials: saved,
53257
+ fetch: options.fetch
53258
+ });
53259
+ } catch {
53260
+ return null;
53261
+ }
53262
+ }
53263
+ /** Find an address in the member's connection list; null when it cannot be confirmed. */
53264
+ async function ownerListedStatus(credentials, address, fetchImpl) {
53265
+ let cursor;
53266
+ for (let page = 0; page < 10; page++) {
53267
+ const url = new URL(`${credentials.api_base_url}/agent-connections`);
53268
+ url.searchParams.set("limit", "50");
53269
+ if (cursor) url.searchParams.set("cursor", cursor);
53270
+ try {
53271
+ const response = await fetchImpl(url, {
53272
+ method: "GET",
53273
+ redirect: "error",
53274
+ signal: AbortSignal.timeout(5e3),
53275
+ headers: {
53276
+ authorization: `Bearer ${credentials.access_token}`,
53277
+ accept: "application/json"
53278
+ }
53279
+ });
53280
+ if (response.status !== 200) {
53281
+ await response.body?.cancel().catch(() => void 0);
53282
+ return null;
53283
+ }
53284
+ const text = await response.text();
53285
+ if (text.length > 1048576) return null;
53286
+ const body = JSON.parse(text);
53287
+ if (body.success !== true || !Array.isArray(body.data)) return null;
53288
+ for (const row of body.data) if (row?.address === address && typeof row.status === "string") return row.status;
53289
+ const next = body.meta?.cursor;
53290
+ if (typeof next !== "string" || !next || next === cursor) return null;
53291
+ cursor = next;
53292
+ } catch {
53293
+ return null;
53294
+ }
53295
+ }
53296
+ return null;
53297
+ }
53298
+ function check(id, status, detail, extra = {}) {
53299
+ return {
53300
+ id,
53301
+ title: TITLES[id],
53302
+ status,
53303
+ detail,
53304
+ fixable: false,
53305
+ ...extra
53306
+ };
53307
+ }
53308
+ /**
53309
+ * Inspect this machine's Primitive setup for every agent runtime, and with
53310
+ * `fix`, repair what can be repaired without the person. Repairs only touch
53311
+ * Primitive-owned content, back up any file before changing it, write
53312
+ * atomically, and are no-ops when nothing drifted.
53313
+ */
53314
+ async function runMachineDoctor(options) {
53315
+ const env = options.env;
53316
+ const fetchImpl = options.fetch ?? fetch;
53317
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
53318
+ const paths = runtimePaths(env, options.home);
53319
+ const selected = (runtime) => !options.runtimes || options.runtimes.has(runtime);
53320
+ const present = Object.fromEntries(MACHINE_RUNTIMES.map((runtime) => [runtime, runtimePresent(runtime, paths, env)]));
53321
+ const skipRuntime = (runtime) => !selected(runtime) ? "Not selected with --runtime." : !present[runtime] ? `${runtime === "claude" ? "Claude Code" : runtime === "codex" ? "Codex" : "omp"} was not found on this machine.` : null;
53322
+ const cli = currentCliLocation(options.cliEntry, env, options.execPath);
53323
+ const ephemeral = cli ? ephemeralCliReason(cli.entry) : null;
53324
+ const hookBlocked = !cli ? "This CLI's hook scripts were not found next to it." : ephemeral ? `This CLI runs from ${ephemeral}, which can be deleted; install it with \`npm install -g primitive\` before repairing hooks.` : null;
53325
+ const skillEnv = {
53326
+ ...env,
53327
+ CLAUDE_CONFIG_DIR: paths.claudeDir,
53328
+ CODEX_HOME: paths.codexHome
53329
+ };
53330
+ let bundle;
53331
+ const loadBundle = () => {
53332
+ if (bundle !== void 0) return bundle;
53333
+ try {
53334
+ bundle = (options.bundle ?? (() => readBundledConnectSkill(options.packageRoot)))();
53335
+ } catch {
53336
+ bundle = null;
53337
+ }
53338
+ return bundle;
53339
+ };
53340
+ const instruction = (id, runtime, path) => ({
53341
+ inspect: () => {
53342
+ const skip = skipRuntime(runtime);
53343
+ if (skip) return check(id, "skip", skip);
53344
+ const target = path();
53345
+ const body = managedInstructions(runtime);
53346
+ const read = readManagedFile(target);
53347
+ if (read.state === "invalid") return check(id, "fail", read.detail, {
53348
+ path: target,
53349
+ action: "edit_file"
53350
+ });
53351
+ if (read.state === "absent") return check(id, "fail", `${target} does not exist yet.`, {
53352
+ path: target,
53353
+ fixable: true
53354
+ });
53355
+ const state = inspectManagedBlock(read.text, body);
53356
+ if (state.state === "ok") return check(id, "ok", `The Primitive block in ${target} is current.`, { path: target });
53357
+ if (state.state === "malformed") return check(id, "fail", `${target}: ${state.detail}. It was left unchanged.`, {
53358
+ path: target,
53359
+ action: "edit_file"
53360
+ });
53361
+ return check(id, "fail", state.state === "missing" ? `${target} has no Primitive block.` : `The Primitive block in ${target} is out of date (v${state.version}).`, {
53362
+ path: target,
53363
+ fixable: true
53364
+ });
53365
+ },
53366
+ repair: () => {
53367
+ const read = readManagedFile(path());
53368
+ if (read.state === "invalid") return false;
53369
+ const next = upsertManagedBlock(read.state === "present" ? read.text : "", managedInstructions(runtime));
53370
+ if (read.state === "present" && next === read.text) return false;
53371
+ writeManagedFile({
53372
+ read,
53373
+ content: next,
53374
+ now
53375
+ });
53376
+ return true;
53377
+ }
53378
+ });
53379
+ const skill = (id, runtime) => ({
53380
+ inspect: () => {
53381
+ const skip = skipRuntime(runtime);
53382
+ if (skip) return check(id, "skip", skip);
53383
+ const current = loadBundle();
53384
+ if (!current) return check(id, "skip", "This CLI build does not include the bundled skill.");
53385
+ const target = connectSkillTarget({
53386
+ runtime,
53387
+ env: skillEnv
53388
+ });
53389
+ let info = null;
53390
+ try {
53391
+ info = lstatSync(target);
53392
+ } catch {
53393
+ info = null;
53394
+ }
53395
+ if (!info) return check(id, "fail", `Not installed at ${target}.`, {
53396
+ path: target,
53397
+ fixable: true
53398
+ });
53399
+ if (info.isSymbolicLink()) return check(id, "warn", `${target} is a symlink you manage; it was left unchanged.`, { path: target });
53400
+ const version = installedVersion(target);
53401
+ if (version === current.version) return check(id, "ok", `Version ${version} matches this CLI.`, { path: target });
53402
+ return check(id, "fail", version ? `Installed version ${version} differs from this CLI's ${current.version}.` : `${target} is not a readable skill directory.`, {
53403
+ path: target,
53404
+ fixable: version !== null
53405
+ });
53406
+ },
53407
+ repair: () => {
53408
+ const current = loadBundle();
53409
+ if (!current) return false;
53410
+ const target = connectSkillTarget({
53411
+ runtime,
53412
+ env: skillEnv
53413
+ });
53414
+ const backups = join(options.configDir, "machine", "backups", "skills");
53415
+ if (existsSync(target)) {
53416
+ mkdirSync(backups, {
53417
+ recursive: true,
53418
+ mode: 448
53419
+ });
53420
+ const backup = join(backups, nextSkillBackupName(backups, runtime, now()));
53421
+ mkdirSync(backup, { mode: 448 });
53422
+ cpSync(target, backup, {
53423
+ recursive: true,
53424
+ verbatimSymlinks: true
53425
+ });
53426
+ }
53427
+ const result = installConnectSkill({
53428
+ bundle: current,
53429
+ runtime,
53430
+ env: skillEnv
53431
+ });
53432
+ if (result.state === "failed") throw new MachineFileError(`The skill could not be installed (${result.reason ?? "unknown"}).`);
53433
+ pruneSkillBackups(backups, runtime);
53434
+ return result.state === "installed" || result.state === "updated";
53435
+ }
53436
+ });
53437
+ const HOOK_CHECK_IDS = [
53438
+ "claude.hook.session_start",
53439
+ "claude.hook.session_end",
53440
+ "claude.hook.stop"
53441
+ ];
53442
+ const repairedAhead = /* @__PURE__ */ new Set();
53443
+ let settingsRead = readClaudeSettings(paths.claudeDir);
53444
+ const hookContext = {
53445
+ cli,
53446
+ blocked: hookBlocked
53447
+ };
53448
+ const hookCheck = (id) => ({
53449
+ inspect: () => {
53450
+ const skip = skipRuntime("claude");
53451
+ if (skip) return check(id, "skip", skip);
53452
+ if (!settingsRead.ok) return check(id, "fail", "Claude settings.json is invalid; repair it first.", { path: join(paths.claudeDir, "settings.json") });
53453
+ const finding = inspectClaudeHooks(settingsRead.settings, hookContext)[id];
53454
+ return check(id, finding.status, finding.detail, {
53455
+ fixable: finding.fixable,
53456
+ path: join(paths.claudeDir, "settings.json"),
53457
+ ...finding.status === "fail" && !finding.fixable && hookBlocked ? { action: "install_cli" } : {}
53458
+ });
53459
+ },
53460
+ repair: () => {
53461
+ if (!settingsRead.ok) return false;
53462
+ const findings = inspectClaudeHooks(settingsRead.settings, hookContext);
53463
+ const ids = HOOK_CHECK_IDS.filter((hook) => (hook === id || !options.only || options.only.has(hook)) && findings[hook].status === "fail" && findings[hook].fixable);
53464
+ const repaired = repairClaudeHooks(settingsRead.settings, hookContext, new Set(ids));
53465
+ if (!repaired.changed.size) return false;
53466
+ for (const hook of repaired.changed) repairedAhead.add(hook);
53467
+ writeClaudeSettings({
53468
+ claudeDir: paths.claudeDir,
53469
+ read: settingsRead.read,
53470
+ settings: repaired.settings,
53471
+ now
53472
+ });
53473
+ settingsRead = readClaudeSettings(paths.claudeDir);
53474
+ return repaired.changed.has(id);
53475
+ }
53476
+ });
53477
+ const codexHook = {
53478
+ inspect: () => {
53479
+ const id = "codex.hook.session_start";
53480
+ const skip = skipRuntime("codex");
53481
+ if (skip) return check(id, "skip", skip);
53482
+ const read = readCodexHooks(paths.codexHome);
53483
+ if (!read.ok) return check(id, "fail", read.detail, {
53484
+ path: read.path,
53485
+ action: "edit_file"
53486
+ });
53487
+ const configToml = readManagedFile(join(paths.codexHome, "config.toml"));
53488
+ const finding = inspectCodexHook(read, hookContext, configToml.state === "present" ? configToml.text : null);
53489
+ return check(id, finding.status, finding.detail, {
53490
+ fixable: finding.fixable,
53491
+ path: read.path,
53492
+ ...finding.status === "fail" && !finding.fixable && hookBlocked ? { action: "install_cli" } : {}
53493
+ });
53494
+ },
53495
+ repair: () => {
53496
+ const read = readCodexHooks(paths.codexHome);
53497
+ if (!read.ok || !cli || hookBlocked) return false;
53498
+ const repaired = repairCodexHook(read.settings, cli);
53499
+ if (!repaired.changed) return false;
53500
+ writeCodexHooks({
53501
+ read: read.read,
53502
+ settings: repaired.settings,
53503
+ now
53504
+ });
53505
+ return true;
53506
+ }
53507
+ };
53508
+ const orphans = [];
53509
+ const inspectProfiles = async () => {
53510
+ orphans.length = 0;
53511
+ const directory = join(agentProfilesDirectory(options.configDir), "profiles");
53512
+ let names = [];
53513
+ try {
53514
+ names = readdirSync(directory).filter((name) => {
53515
+ try {
53516
+ agentProfileName(name);
53517
+ return true;
53518
+ } catch {
53519
+ return false;
53520
+ }
53521
+ });
53522
+ } catch {
53523
+ names = [];
53524
+ }
53525
+ const profiles = [];
53526
+ let unreadable = 0;
53527
+ for (const name of names) try {
53528
+ const profile = loadConnectedAgentProfile(options.configDir, name);
53529
+ if (profile) profiles.push({
53530
+ name,
53531
+ profile
53532
+ });
53533
+ } catch {
53534
+ unreadable++;
53535
+ }
53536
+ if (!profiles.length) return unreadable ? check("profiles.orphaned", "warn", `${unreadable} saved profiles could not be read; they were left unchanged.`) : check("profiles.orphaned", "ok", "No saved agent profiles.");
53537
+ let member;
53538
+ let unconfirmed = 0;
53539
+ let unknown = 0;
53540
+ let connected = 0;
53541
+ const states = [];
53542
+ for (let start = 0; start < profiles.length; start += 8) states.push(...await Promise.all(profiles.slice(start, start + 8).map(async (entry) => ({
53543
+ ...entry,
53544
+ state: await agentConnectionState(entry.profile, fetchImpl)
53545
+ }))));
53546
+ for (const entry of states) if (entry.state === "revoked") orphans.push(entry);
53547
+ else if (entry.state === "rejected") {
53548
+ member ??= await memberCredentialsFor(options);
53549
+ if ((member && member.org_id === entry.profile.org_id && member.api_base_url === entry.profile.api_base_url ? await ownerListedStatus(member, entry.profile.agent_address, fetchImpl) : null) === "revoked") orphans.push(entry);
53550
+ else unconfirmed++;
53551
+ } else if (entry.state === "unavailable") unknown++;
53552
+ else connected++;
53553
+ if (orphans.length) return check("profiles.orphaned", "fail", `${orphans.length} saved profiles were disconnected in Primitive: ${orphans.map((entry) => entry.profile.agent_address).join(", ")}. A repair moves them aside locally; nothing changes in Primitive.`, { fixable: true });
53554
+ if (unconfirmed || unreadable) return check("profiles.orphaned", "warn", [unconfirmed ? `${unconfirmed} profiles' credentials were rejected but disconnection could not be confirmed` : "", unreadable ? `${unreadable} profiles could not be read` : ""].filter(Boolean).join("; ").concat("; they were left unchanged."));
53555
+ if (unknown && !connected) return check("profiles.orphaned", "skip", "Primitive could not be reached to check saved profiles.");
53556
+ return check("profiles.orphaned", "ok", `${connected} saved profiles are connected${unknown ? `; ${unknown} could not be checked` : ""}.`);
53557
+ };
53558
+ const moveOrphans = async () => {
53559
+ let moved = 0;
53560
+ for (const entry of orphans) {
53561
+ const directory = agentProfileDirectory(options.configDir, entry.name);
53562
+ let session = null;
53563
+ try {
53564
+ const setup = readMailJson(join(directory, "setup.json"));
53565
+ if (typeof setup?.session === "string" && SESSION_UUID$1.test(setup.session)) session = setup.session.toLowerCase();
53566
+ } catch {
53567
+ session = null;
53568
+ }
53569
+ if (session) {
53570
+ const stopped = await (options.stopReceiver ?? stopBackgroundListen)({
53571
+ configDir: options.configDir,
53572
+ scope: notificationScope(entry.profile.api_base_url, entry.profile.api_key),
53573
+ threadId: session
53574
+ }).catch(() => null);
53575
+ if (!stopped || !(stopped.phase === null || stopped.phase === "stopped" || stopped.phase === "failed" || stopped.reason === "exited")) continue;
53576
+ }
53577
+ const aside = join(agentProfilesDirectory(options.configDir), "orphaned");
53578
+ mkdirSync(aside, {
53579
+ recursive: true,
53580
+ mode: 448
53581
+ });
53582
+ renameSync(directory, join(aside, `${entry.name}-${backupStamp(now())}`));
53583
+ moved++;
53584
+ }
53585
+ return moved > 0;
53586
+ };
53587
+ const runners = {
53588
+ "cli.installed": { inspect: () => cli ? check("cli.installed", "ok", `primitive ${options.cliVersion} at ${cli.entry}.`, { path: cli.entry }) : check("cli.installed", "fail", "The running CLI's files could not be located.", { action: "install_cli" }) },
53589
+ "cli.version": { inspect: async () => {
53590
+ const version = options.cliVersion;
53591
+ if (options.minCliVersion) {
53592
+ const compared = compareVersions(version, options.minCliVersion);
53593
+ if (compared === null) return check("cli.version", "warn", `Cannot compare ${version} with the required ${options.minCliVersion}.`);
53594
+ if (compared < 0) return check("cli.version", "fail", `${version} is older than the required ${options.minCliVersion}.`, { action: "update_cli" });
53595
+ }
53596
+ const latest = await (options.latestVersion ?? (() => npmLatestVersion(fetchImpl)))();
53597
+ if (!latest) return options.minCliVersion ? check("cli.version", "ok", `${version} meets the required ${options.minCliVersion}; the latest release could not be checked.`) : check("cli.version", "skip", `${version}; the latest release could not be checked.`);
53598
+ const behind = compareVersions(version, latest);
53599
+ if (behind !== null && behind < 0) return check("cli.version", "warn", `${version} is older than the latest release ${latest}.`, { action: "update_cli" });
53600
+ return check("cli.version", "ok", `${version} is current.`);
53601
+ } },
53602
+ "cli.path_stable": { inspect: () => {
53603
+ if (!cli) return check("cli.path_stable", "fail", "The running CLI's files could not be located.", { action: "install_cli" });
53604
+ if (ephemeral) return check("cli.path_stable", "fail", `This CLI runs from ${ephemeral}; hooks pointing at it would break. Install it globally with \`npm install -g primitive\`.`, {
53605
+ action: "install_cli",
53606
+ path: cli.entry
53607
+ });
53608
+ const onPath = findOnPath("primitive", env);
53609
+ if (onPath && !samePath(onPath, cli.entry)) return check("cli.path_stable", "warn", `\`primitive\` on PATH is ${onPath}, but this run is ${cli.entry}; hooks follow the copy that runs the doctor.`, { path: cli.entry });
53610
+ return check("cli.path_stable", "ok", `Hooks use ${cli.node} and ${cli.entry}.`, { path: cli.entry });
53611
+ } },
53612
+ "auth.member_login": { inspect: async () => {
53613
+ let saved;
53614
+ try {
53615
+ saved = loadCliCredentials(options.configDir);
53616
+ } catch {
53617
+ return check("auth.member_login", "fail", "The saved login file is unreadable. Sign in again.", { action: "login" });
53618
+ }
53619
+ if (!saved) return check("auth.member_login", "fail", "No saved member login on this machine.", { action: "login" });
53620
+ let credentials;
53621
+ try {
53622
+ credentials = await refreshStoredCliCredentials({
53623
+ apiBaseUrl: saved.api_base_url,
53624
+ configDir: options.configDir,
53625
+ credentials: saved,
53626
+ fetch: options.fetch
53627
+ });
53628
+ } catch (error) {
53629
+ return error instanceof Error && error.message === "Saved Primitive CLI OAuth session expired or was revoked. Run `primitive signin` to authenticate again." ? check("auth.member_login", "fail", "The saved login expired or was revoked.", { action: "login" }) : check("auth.member_login", "warn", "The saved login could not be refreshed right now (offline?).");
53630
+ }
53631
+ try {
53632
+ const response = await fetchImpl(`${credentials.api_base_url}/account`, {
53633
+ method: "GET",
53634
+ redirect: "error",
53635
+ signal: AbortSignal.timeout(5e3),
53636
+ headers: {
53637
+ authorization: `Bearer ${credentials.access_token}`,
53638
+ accept: "application/json"
53639
+ }
53640
+ });
53641
+ await response.body?.cancel().catch(() => void 0);
53642
+ if (response.status === 401 || response.status === 403) return check("auth.member_login", "fail", "The saved login was rejected.", { action: "login" });
53643
+ if (!response.ok) return check("auth.member_login", "warn", `The saved login could not be verified (HTTP ${response.status}).`);
53644
+ } catch {
53645
+ return check("auth.member_login", "warn", "The saved login could not be verified right now (offline?).");
53646
+ }
53647
+ return check("auth.member_login", "ok", `Signed in to ${credentials.org_name ?? credentials.org_id}.`);
53648
+ } },
53649
+ "claude.settings_valid": { inspect: () => {
53650
+ const skip = skipRuntime("claude");
53651
+ if (skip) return check("claude.settings_valid", "skip", skip);
53652
+ const path = join(paths.claudeDir, "settings.json");
53653
+ if (!settingsRead.ok) return check("claude.settings_valid", "fail", settingsRead.detail, {
53654
+ path,
53655
+ action: "edit_file"
53656
+ });
53657
+ return check("claude.settings_valid", "ok", settingsRead.read.state === "absent" ? `${path} does not exist yet; a repair creates it.` : `${path} is valid JSON.`, { path });
53658
+ } },
53659
+ "claude.hook.session_start": hookCheck("claude.hook.session_start"),
53660
+ "claude.hook.session_end": hookCheck("claude.hook.session_end"),
53661
+ "claude.hook.stop": hookCheck("claude.hook.stop"),
53662
+ "codex.hook.session_start": codexHook,
53663
+ "claude.instructions": instruction("claude.instructions", "claude", () => join(paths.claudeDir, "CLAUDE.md")),
53664
+ "codex.instructions": instruction("codex.instructions", "codex", () => codexInstructionsPath(paths.codexHome)),
53665
+ "omp.instructions": instruction("omp.instructions", "omp", () => join(paths.ompAgentDir, "AGENTS.md")),
53666
+ "skill.claude": skill("skill.claude", "claude"),
53667
+ "skill.codex": skill("skill.codex", "codex"),
53668
+ "profiles.orphaned": {
53669
+ inspect: async () => {
53670
+ const result = await inspectProfiles();
53671
+ const pending = pendingSessionDisconnects(options.configDir);
53672
+ if (!pending.length) return result;
53673
+ const addresses = pending.flatMap((record) => record.address ? [record.address] : []);
53674
+ return check("profiles.orphaned", "fail", `${pending.length} ended sessions still need their agent disconnected${addresses.length ? ` (${addresses.join(", ")})` : ""}; a repair retries it.${result.status === "ok" ? "" : ` ${result.detail}`}`, { fixable: true });
53675
+ },
53676
+ repair: async () => {
53677
+ const retried = await retryPendingDisconnects(options.configDir, {
53678
+ env,
53679
+ fetch: options.fetch,
53680
+ ...options.sessionDisconnect ? { dependencies: options.sessionDisconnect } : {}
53681
+ });
53682
+ return await moveOrphans() || retried > 0;
53683
+ }
53684
+ }
53685
+ };
53686
+ let release;
53687
+ let busy = false;
53688
+ if (options.fix) {
53689
+ for (let attempt = 0; attempt < 40 && !release; attempt++) try {
53690
+ release = acquireListenLock(join(options.configDir, "machine"), "machine-doctor");
53691
+ } catch {
53692
+ await new Promise((done) => setTimeout(done, 250));
53693
+ }
53694
+ busy = !release;
53695
+ }
53696
+ const results = /* @__PURE__ */ new Map();
53697
+ try {
53698
+ const order = ["profiles.orphaned", ...DOCTOR_CHECK_IDS.filter((id) => id !== "profiles.orphaned")];
53699
+ for (const id of order) {
53700
+ const runner = runners[id];
53701
+ let result;
53702
+ try {
53703
+ result = await runner.inspect();
53704
+ } catch {
53705
+ result = check(id, "fail", "This check could not run.");
53706
+ }
53707
+ if (result.status === "ok" && repairedAhead.has(id)) result = {
53708
+ ...result,
53709
+ fixed: true
53710
+ };
53711
+ const wanted = !options.only || options.only.has(id);
53712
+ if (options.fix && !busy && wanted && result.fixable && result.status !== "ok" && runner.repair) try {
53713
+ const changed = await runner.repair(result);
53714
+ const after = await runner.inspect();
53715
+ result = {
53716
+ ...after,
53717
+ fixed: changed && after.status !== "fail"
53718
+ };
53719
+ if (changed && after.status === "fail") result.detail = `${after.detail} (A repair was applied but did not resolve this.)`;
53720
+ } catch (error) {
53721
+ result = {
53722
+ ...result,
53723
+ fixed: false,
53724
+ detail: `${result.detail} Repair failed: ${error instanceof MachineFileError ? error.message : "an unexpected error; nothing further was changed."}`
53725
+ };
53726
+ }
53727
+ results.set(id, result);
53728
+ }
53729
+ } finally {
53730
+ release?.();
53731
+ }
53732
+ const checks = DOCTOR_CHECK_IDS.map((id) => results.get(id) ?? check(id, "fail", "This check did not run."));
53733
+ const summary = {
53734
+ ok: 0,
53735
+ warn: 0,
53736
+ fail: 0,
53737
+ skip: 0
53738
+ };
53739
+ for (const item of checks) summary[item.status]++;
53740
+ return {
53741
+ version: 1,
53742
+ cliVersion: options.cliVersion,
53743
+ checks,
53744
+ summary,
53745
+ fixedCount: checks.filter((item) => item.fixed).length,
53746
+ ...busy ? { repair: "skipped_busy" } : {}
53747
+ };
53748
+ }
53749
+ //#endregion
53750
+ //#region src/oclif/commands/machine-doctor.ts
53751
+ function renderCheck(check) {
53752
+ const tag = check.status === "ok" ? "[OK] " : check.status === "warn" ? "[WARN]" : check.status === "skip" ? "[SKIP]" : "[FAIL]";
53753
+ const fixed = check.fixed ? " (fixed)" : "";
53754
+ return `${tag} ${check.id}${fixed}: ${check.detail}`;
53755
+ }
53756
+ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
53757
+ static summary = "Check, and optionally repair, this machine's Primitive setup for coding agents";
53758
+ static description = "Checks everything that lets Claude Code, Codex and omp sessions on this machine register with Primitive and answer mail: the CLI install and version, the saved member login, Claude settings and Primitive's SessionStart, SessionEnd and per-session receive hooks, the managed instruction block in each runtime's global instructions file, the bundled primitive-connect skill, and saved agent profiles disconnected in Primitive. Each check reports ok, warn, fail or skip, whether it can be repaired automatically, and an action (login, install_cli, update_cli, edit_file) when only a person can fix it. --fix repairs what it safely can: it only adds, rewrites or removes Primitive-owned hooks, blocks and skill copies, backs up any existing file beside it as <file>.primitive-bak-<timestamp> before changing it, writes atomically, and changes nothing when nothing drifted, so it is safe to run on a timer. Malformed or hand-edited files it cannot parse are reported and left unchanged. Exits 0 when every check is ok or skipped, 1 otherwise.";
53759
+ static examples = [
53760
+ "<%= config.bin %> machine doctor",
53761
+ "<%= config.bin %> machine doctor --json",
53762
+ "<%= config.bin %> machine doctor --fix --json",
53763
+ "<%= config.bin %> machine doctor --fix --check claude.hook.stop --json",
53764
+ "<%= config.bin %> machine doctor --runtime claude,codex --min-cli-version 1.38.0 --json"
53765
+ ];
53766
+ static flags = {
53767
+ fix: Flags.boolean({ description: "Repair every fixable check (or only those named with --check)" }),
53768
+ check: Flags.string({
53769
+ description: "With --fix, repair only this check; repeatable. Every check is still reported",
53770
+ multiple: true,
53771
+ options: [...DOCTOR_CHECK_IDS]
53772
+ }),
53773
+ runtime: Flags.string({ description: "Comma-separated runtimes to check: claude, codex, omp. Defaults to every runtime found on this machine" }),
53774
+ "min-cli-version": Flags.string({ description: "Report cli.version as failing when this CLI is older than the given version" }),
53775
+ json: Flags.boolean({ description: "Print the report as JSON" })
53776
+ };
53777
+ async run() {
53778
+ const { flags } = await this.parse(MachineDoctorCommand);
53779
+ let runtimes;
53780
+ if (flags.runtime) {
53781
+ const names = flags.runtime.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
53782
+ const unknown = names.filter((name) => !MACHINE_RUNTIMES.includes(name));
53783
+ if (unknown.length) this.error(`Unknown runtime ${unknown.join(", ")}. Use claude, codex or omp.`, { exit: 2 });
53784
+ runtimes = new Set(names);
53785
+ }
53786
+ if (flags.check?.length && !flags.fix) this.error("--check selects which checks --fix repairs; add --fix.", { exit: 2 });
53787
+ const report = await runMachineDoctor({
53788
+ fix: flags.fix,
53789
+ only: flags.check?.length ? new Set(flags.check) : void 0,
53790
+ runtimes,
53791
+ configDir: this.config.configDir,
53792
+ home: homedir(),
53793
+ env: process.env,
53794
+ packageRoot: this.config.root,
53795
+ cliVersion: this.config.version,
53796
+ cliEntry: process.argv[1] ?? "",
53797
+ minCliVersion: flags["min-cli-version"]
53798
+ });
53799
+ if (flags.json) this.log(JSON.stringify(report));
53800
+ else {
53801
+ for (const check of report.checks) this.log(renderCheck(check));
53802
+ this.log(`${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skipped${report.fixedCount ? `; ${report.fixedCount} fixed` : ""}.`);
53803
+ if (report.repair === "skipped_busy") this.log("Another repair was running, so nothing was changed.");
53804
+ else if (!flags.fix && report.checks.some((check) => check.fixable)) this.log("Run `primitive machine doctor --fix` to repair fixable checks.");
53805
+ }
53806
+ if (report.summary.fail || report.summary.warn) process.exitCode = 1;
53807
+ }
53808
+ };
53809
+ //#endregion
51339
53810
  //#region src/oclif/commands/memories.ts
51340
53811
  var MemoryCliInputError = class extends Error {};
51341
53812
  const API_KEY_FLAG = Flags.string({
@@ -57900,6 +60371,8 @@ const COMMANDS = {
57900
60371
  "agent:connect": AgentConnectCommand,
57901
60372
  "agent:disconnect": AgentDisconnectCommand,
57902
60373
  "agent:enroll": AgentEnrollCommand,
60374
+ "agent:session-register": AgentSessionRegisterCommand,
60375
+ "agent:session-end": AgentSessionEndCommand,
57903
60376
  "contacts:request": ContactsRequestCommand,
57904
60377
  "contacts:accept": ContactsAcceptCommand,
57905
60378
  "contacts:wait": ContactsWaitCommand,
@@ -57965,6 +60438,7 @@ const COMMANDS = {
57965
60438
  whoami: WhoamiCommand,
57966
60439
  signal: SignalCommand,
57967
60440
  doctor: DoctorCommand,
60441
+ "machine:doctor": MachineDoctorCommand,
57968
60442
  "emails:latest": EmailsLatestCommand,
57969
60443
  "emails:watch": EmailsWatchCommand,
57970
60444
  "emails:wait": EmailsWaitCommand,