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