primitive 1.72.2 → 1.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -10
- package/bin/claude-pending-mail.mjs +292 -136
- package/bin/claude-wake.mjs +129 -60
- package/dist/api-command-fKP3NYa7.js +2904 -0
- package/dist/{cli-config--YTRdJ3t.js → cli-config-C0Y_Ppol.js} +3 -598
- package/dist/client.gen-CaaMP86o.js +597 -0
- package/dist/{listen-background--0peztKE.js → listen-background-cD6n8VYO.js} +1 -1
- package/dist/{machine-session-Be6j29GE.js → machine-session-B6V5-grn.js} +907 -207
- package/dist/{notify-session-Byt5sPWW.js → notify-session-DJAb9SGm.js} +3 -3
- package/dist/oclif/auto-signal-worker.js +4 -3
- package/dist/oclif/auto-signals.js +2 -2
- package/dist/oclif/index.js +260 -198
- package/dist/oclif/listen-supervisor.js +1 -1
- package/dist/oclif/receiver-heal.js +4 -4
- package/dist/oclif/root-signup-hint.js +1 -1
- package/dist/{repeats-BL7ZC-_6-CKMKBlx7.js → repeats-BL7ZC-_6-B_ESsWTH.js} +4005 -1
- package/dist/{scoped-chat-DTFkP360.js → scoped-chat-DAiOb622.js} +10 -2
- package/dist/{shared-mail-files-MwlVE90c.js → shared-mail-files-CZfm7E2k.js} +21 -1
- package/dist/{shared-mail-watch-DtKpsgg8.js → shared-mail-watch-DyQB0MK0.js} +1 -1
- package/dist/{signal-command-DeEEZPbQ.js → signal-command-Cte4mAL3.js} +8 -7
- package/package.json +1 -1
- package/dist/api-command-BrwPbyPP.js +0 -6546
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { B as parseConnectedAgentProfile, F as agentProfileName, G as PrimitiveApiClient, H as profileAlreadyConnectedMessage, I as agentProfilesDirectory, L as connectedAgentIdentity, M as AGENT_PROFILE_ENV, N as AgentConnectionSetupError, P as agentProfileDirectory, R as connectedApiBaseUrl, V as parseOwnerMemberAddress, W as saveConnectedAgentProfile, x as loadCliCredentials, z as loadConnectedAgentProfile } from "./cli-config
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { _ as detectAgentRuntime, a as NotificationRetryError, d as defaultSessionSocket, f as NativeSessionDisconnectedError, l as SESSION_UUID, t as notificationScope, u as connectNativeSession, v as installConnectSkill, x as readBundledConnectSkill } from "./notify-session-
|
|
6
|
-
import {
|
|
7
|
-
import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background
|
|
8
|
-
import { t as readSharedMailOwner } from "./shared-mail-watch-
|
|
1
|
+
import { $t as listDomains, At as getAgentContactPolicy, Dn as sendEmail, Ft as getEmail, Jt as listAgentContacts, Kn as __exportAll, Kt as inviteAgentConnection, Xt as listContacts, cn as listSentEmails, gn as putContact, hn as putAgentContactPolicy, jt as getContact, mn as putAgentContact, qt as listAgentConnections, vt as deleteAgentContact, wn as searchEmails, yt as deleteContact } from "./repeats-BL7ZC-_6-B_ESsWTH.js";
|
|
2
|
+
import { B as parseConnectedAgentProfile, F as agentProfileName, G as PrimitiveApiClient, H as profileAlreadyConnectedMessage, I as agentProfilesDirectory, L as connectedAgentIdentity, M as AGENT_PROFILE_ENV, N as AgentConnectionSetupError, P as agentProfileDirectory, R as connectedApiBaseUrl, V as parseOwnerMemberAddress, W as saveConnectedAgentProfile, x as loadCliCredentials, z as loadConnectedAgentProfile } from "./cli-config-C0Y_Ppol.js";
|
|
3
|
+
import { _ as acquireListenLock, b as listenLockHeld, c as mailTime, d as readMailJson, f as removeMailFile, g as ListenStateError, h as writeMailJson, i as mailId, l as privateMailDirectory, o as mailObject, p as syncMailDirectory, r as mailAddress, s as mailString } from "./shared-mail-files-CZfm7E2k.js";
|
|
4
|
+
import { gt as defaultConnectionName, jt as refreshStoredCliCredentials, mt as cliInvocation } from "./api-command-fKP3NYa7.js";
|
|
5
|
+
import { _ as detectAgentRuntime, a as NotificationRetryError, d as defaultSessionSocket, f as NativeSessionDisconnectedError, l as SESSION_UUID, t as notificationScope, u as connectNativeSession, v as installConnectSkill, x as readBundledConnectSkill } from "./notify-session-DJAb9SGm.js";
|
|
6
|
+
import { o as scopedChatSenderTrust } from "./scoped-chat-DAiOb622.js";
|
|
7
|
+
import { l as stopBackgroundListen, r as backgroundListenStatus } from "./listen-background-cD6n8VYO.js";
|
|
8
|
+
import { t as readSharedMailOwner } from "./shared-mail-watch-DyQB0MK0.js";
|
|
9
9
|
import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
11
|
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -146,6 +146,146 @@ async function withOwnerMemberAddress(result, params) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
//#endregion
|
|
149
|
+
//#region src/oclif/claude-session-receivers.ts
|
|
150
|
+
const FILE = /^([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\.json$/;
|
|
151
|
+
const LOCK_WAIT$2 = new Int32Array(new SharedArrayBuffer(4));
|
|
152
|
+
function sessionReceiversDirectory(configDir) {
|
|
153
|
+
return join(agentProfilesDirectory(resolve(configDir)), "claude-sessions");
|
|
154
|
+
}
|
|
155
|
+
function sessionId(value) {
|
|
156
|
+
const id = value.toLowerCase();
|
|
157
|
+
if (!SESSION_UUID.test(id)) throw new Error("A session must be a UUID.");
|
|
158
|
+
return id;
|
|
159
|
+
}
|
|
160
|
+
function sessionReceiversPath(configDir, session) {
|
|
161
|
+
return join(sessionReceiversDirectory(configDir), `${sessionId(session)}.json`);
|
|
162
|
+
}
|
|
163
|
+
function receiver(value) {
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
165
|
+
const row = value;
|
|
166
|
+
try {
|
|
167
|
+
return {
|
|
168
|
+
profile: agentProfileName(String(row.profile ?? "")),
|
|
169
|
+
address: mailAddress(String(row.address ?? ""))
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** The profiles one session receives as; empty when it has none. */
|
|
176
|
+
function readSessionReceivers(configDir, session) {
|
|
177
|
+
let value;
|
|
178
|
+
try {
|
|
179
|
+
value = readMailJson(sessionReceiversPath(configDir, session));
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const row = value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
184
|
+
if (row?.version !== 1 || row.session_id !== sessionId(session) || !Array.isArray(row.receivers)) return [];
|
|
185
|
+
return row.receivers.flatMap((item) => {
|
|
186
|
+
const parsed = receiver(item);
|
|
187
|
+
return parsed ? [parsed] : [];
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/** Every session with an entry, and the profiles it receives as. */
|
|
191
|
+
function listSessionReceivers(configDir) {
|
|
192
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
193
|
+
let names;
|
|
194
|
+
try {
|
|
195
|
+
names = readdirSync(sessionReceiversDirectory(configDir));
|
|
196
|
+
} catch {
|
|
197
|
+
return sessions;
|
|
198
|
+
}
|
|
199
|
+
for (const name of names.sort()) {
|
|
200
|
+
const session = FILE.exec(name)?.[1];
|
|
201
|
+
if (!session) continue;
|
|
202
|
+
const receivers = readSessionReceivers(configDir, session);
|
|
203
|
+
if (receivers.length) sessions.set(session, receivers);
|
|
204
|
+
}
|
|
205
|
+
return sessions;
|
|
206
|
+
}
|
|
207
|
+
function withLock(configDir, action) {
|
|
208
|
+
const directory = sessionReceiversDirectory(configDir);
|
|
209
|
+
let release;
|
|
210
|
+
for (let attempt = 0; attempt < 80 && !release; attempt++) try {
|
|
211
|
+
release = acquireListenLock(directory, "claude-session-receivers");
|
|
212
|
+
} catch {
|
|
213
|
+
Atomics.wait(LOCK_WAIT$2, 0, 0, 25);
|
|
214
|
+
}
|
|
215
|
+
if (!release) throw new Error("Another Primitive command is editing session receivers.");
|
|
216
|
+
try {
|
|
217
|
+
return action();
|
|
218
|
+
} finally {
|
|
219
|
+
release();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function write(configDir, session, receivers) {
|
|
223
|
+
const path = sessionReceiversPath(configDir, session);
|
|
224
|
+
if (receivers.length === 0) {
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(path);
|
|
227
|
+
syncMailDirectory(sessionReceiversDirectory(configDir));
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error.code !== "ENOENT") throw error;
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
writeMailJson(path, {
|
|
234
|
+
version: 1,
|
|
235
|
+
session_id: sessionId(session),
|
|
236
|
+
receivers
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Add or update one profile for a session (its address follows the
|
|
241
|
+
* profile). Returns whether the entry changed.
|
|
242
|
+
*/
|
|
243
|
+
function addSessionReceiver(configDir, session, value) {
|
|
244
|
+
const next = receiver(value);
|
|
245
|
+
if (!next) throw new Error("A session receiver needs a profile and address.");
|
|
246
|
+
return withLock(configDir, () => {
|
|
247
|
+
const current = readSessionReceivers(configDir, session);
|
|
248
|
+
if (current.find((row) => row.profile === next.profile)?.address === next.address) return false;
|
|
249
|
+
write(configDir, session, [...current.filter((row) => row.profile !== next.profile), next]);
|
|
250
|
+
return true;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/** Remove one profile from a session. Returns whether it was there. */
|
|
254
|
+
function removeSessionReceiver(configDir, session, profile) {
|
|
255
|
+
return withLock(configDir, () => {
|
|
256
|
+
const current = readSessionReceivers(configDir, session);
|
|
257
|
+
const next = current.filter((row) => row.profile !== profile);
|
|
258
|
+
if (next.length === current.length) return false;
|
|
259
|
+
write(configDir, session, next);
|
|
260
|
+
return true;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Drop entries for profiles that are no longer saved as connected, or whose
|
|
265
|
+
* saved address changed to another. Returns how many were dropped.
|
|
266
|
+
*/
|
|
267
|
+
function pruneSessionReceivers(configDir) {
|
|
268
|
+
const live = (row) => {
|
|
269
|
+
try {
|
|
270
|
+
const profile = loadConnectedAgentProfile(resolve(configDir), row.profile);
|
|
271
|
+
return profile !== null && profile.agent_address === row.address;
|
|
272
|
+
} catch {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
let dropped = 0;
|
|
277
|
+
for (const [session, receivers] of listSessionReceivers(configDir)) {
|
|
278
|
+
if (receivers.every(live)) continue;
|
|
279
|
+
withLock(configDir, () => {
|
|
280
|
+
const current = readSessionReceivers(configDir, session);
|
|
281
|
+
const next = current.filter(live);
|
|
282
|
+
dropped += current.length - next.length;
|
|
283
|
+
if (next.length !== current.length) write(configDir, session, next);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return dropped;
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
149
289
|
//#region src/oclif/hook-launcher.ts
|
|
150
290
|
/**
|
|
151
291
|
* Claude Code hooks run Primitive through a small POSIX launcher instead of a
|
|
@@ -730,7 +870,8 @@ const STATE_PHRASES = {
|
|
|
730
870
|
stale: "session disconnected or removed",
|
|
731
871
|
duplicate: "duplicate",
|
|
732
872
|
outdated: "old CLI path",
|
|
733
|
-
old_address: "old agent address"
|
|
873
|
+
old_address: "old agent address",
|
|
874
|
+
per_session: "per-session hook, now served by the shared receive hooks"
|
|
734
875
|
};
|
|
735
876
|
/** "profile my-agent, session 1234...": who a session hook serves. */
|
|
736
877
|
function describeSessionHookOwner(item) {
|
|
@@ -799,7 +940,15 @@ function globalHookCurrent(hook, cli) {
|
|
|
799
940
|
return nodeCommandCurrent(hook.command, cli.node) && samePath(args[0], cli.entry);
|
|
800
941
|
}
|
|
801
942
|
const WAKE_MARKER = "primitive-agent-wake-v1";
|
|
802
|
-
const PENDING_MARKER
|
|
943
|
+
const PENDING_MARKER = "primitive-pending-mail-v1";
|
|
944
|
+
/**
|
|
945
|
+
* The shared receive hooks: one set per config directory serves every Claude
|
|
946
|
+
* session. Each run reads its session id from the hook input and looks up
|
|
947
|
+
* the profiles it receives as (claude-session-receivers.ts), exiting at once
|
|
948
|
+
* for a session with none.
|
|
949
|
+
*/
|
|
950
|
+
const SHARED_WAKE_MARKER = "primitive-agent-wake-v2";
|
|
951
|
+
const SHARED_PENDING_MARKER = "primitive-pending-mail-v2";
|
|
803
952
|
const SESSION_EVENTS = [
|
|
804
953
|
"Stop",
|
|
805
954
|
"SessionStart",
|
|
@@ -818,6 +967,7 @@ const SESSION_HOOK_KIND = {
|
|
|
818
967
|
* both use it, so they never disagree about a hook being present.
|
|
819
968
|
*/
|
|
820
969
|
function hasSessionReceiveHook(hooks, event, target, node) {
|
|
970
|
+
if (hasSharedHook(hooks, event, target.configDir, node) && readSessionReceivers(target.configDir, target.session).some((row) => row.profile === target.profile && row.address === target.address)) return true;
|
|
821
971
|
const entries = hooks[event];
|
|
822
972
|
if (!Array.isArray(entries)) return false;
|
|
823
973
|
const pending = SESSION_HOOK_KIND[event] === "pending";
|
|
@@ -826,9 +976,64 @@ function hasSessionReceiveHook(hooks, event, target, node) {
|
|
|
826
976
|
return entries.some((entry) => record$2(entry) && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
|
|
827
977
|
if (!record$2(hook) || hook.type !== "command") return false;
|
|
828
978
|
const args = hook.args;
|
|
829
|
-
return Array.isArray(args) && args.length === 7 && args.every((value) => typeof value === "string") && basename(args[0]) === (pending ? "claude-pending-mail.mjs" : "claude-wake.mjs") && resolve(args[2]) === configDir && args[3] === target.profile && args[4] === target.address && args[5].toLowerCase() === session && args[6] === (pending ? PENDING_MARKER
|
|
979
|
+
return Array.isArray(args) && args.length === 7 && args.every((value) => typeof value === "string") && basename(args[0]) === (pending ? "claude-pending-mail.mjs" : "claude-wake.mjs") && resolve(args[2]) === configDir && args[3] === target.profile && args[4] === target.address && args[5].toLowerCase() === session && args[6] === (pending ? PENDING_MARKER : WAKE_MARKER) && (node === null || samePath(hook.command, node));
|
|
980
|
+
}));
|
|
981
|
+
}
|
|
982
|
+
/** Whether an event carries the shared receive hook for a config directory. */
|
|
983
|
+
function hasSharedHook(hooks, event, configDir, node) {
|
|
984
|
+
const entries = hooks[event];
|
|
985
|
+
if (!Array.isArray(entries)) return false;
|
|
986
|
+
const pending = SESSION_HOOK_KIND[event] === "pending";
|
|
987
|
+
return entries.some((entry) => record$2(entry) && (event !== "SessionStart" || entry.matcher === "resume") && Array.isArray(entry.hooks) && entry.hooks.some((hook) => {
|
|
988
|
+
const parsed = parseSessionHook(hook);
|
|
989
|
+
return parsed?.shared === true && parsed.kind === (pending ? "pending" : "wake") && resolve(parsed.configDir) === resolve(configDir) && (node === null || record$2(hook) && samePath(hook.command, node));
|
|
830
990
|
}));
|
|
831
991
|
}
|
|
992
|
+
/** The shared hook entry for one event, as installers write it. */
|
|
993
|
+
function sharedHookEntry(event, cli, configDir, node = cli.node) {
|
|
994
|
+
const pending = SESSION_HOOK_KIND[event] === "pending";
|
|
995
|
+
const hook = {
|
|
996
|
+
type: "command",
|
|
997
|
+
command: node,
|
|
998
|
+
args: [
|
|
999
|
+
pending ? cli.pending : cli.wake,
|
|
1000
|
+
cli.entry,
|
|
1001
|
+
resolve(configDir),
|
|
1002
|
+
pending ? SHARED_PENDING_MARKER : SHARED_WAKE_MARKER
|
|
1003
|
+
],
|
|
1004
|
+
...pending ? { timeout: 10 } : {
|
|
1005
|
+
asyncRewake: true,
|
|
1006
|
+
timeout: 604800
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
return event === "SessionStart" ? {
|
|
1010
|
+
matcher: "resume",
|
|
1011
|
+
hooks: [hook]
|
|
1012
|
+
} : { hooks: [hook] };
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* The receiver an exact-session hook from an earlier version stands for:
|
|
1016
|
+
* its profile, with the address the profile is connected as now, and its
|
|
1017
|
+
* session. Null when the profile is no longer connected.
|
|
1018
|
+
*/
|
|
1019
|
+
function foldedReceiver(parsed) {
|
|
1020
|
+
if (!parsed.profile || !parsed.session || !SESSION_UUID.test(parsed.session)) return null;
|
|
1021
|
+
try {
|
|
1022
|
+
const profile = loadConnectedAgentProfile(resolve(parsed.configDir), parsed.profile);
|
|
1023
|
+
return profile ? {
|
|
1024
|
+
configDir: resolve(parsed.configDir),
|
|
1025
|
+
profile: parsed.profile,
|
|
1026
|
+
address: profile.agent_address,
|
|
1027
|
+
session: parsed.session.toLowerCase()
|
|
1028
|
+
} : null;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/** A per-session receive hook (any version), shared or not. Exported for installers. */
|
|
1034
|
+
function parseReceiveHook(hook) {
|
|
1035
|
+
return parseSessionHook(hook);
|
|
1036
|
+
}
|
|
832
1037
|
/** A per-session receive hook written by `agent connect` or `agent enroll`, any version. */
|
|
833
1038
|
function parseSessionHook(hook) {
|
|
834
1039
|
if (!record$2(hook) || hook.type !== "command" || !Array.isArray(hook.args)) return null;
|
|
@@ -836,7 +1041,15 @@ function parseSessionHook(hook) {
|
|
|
836
1041
|
if (!args.every((value) => typeof value === "string")) return null;
|
|
837
1042
|
const strings = args;
|
|
838
1043
|
const script = basename(strings[0] ?? "");
|
|
839
|
-
if (script === "claude-
|
|
1044
|
+
if (strings.length === 4 && (script === "claude-wake.mjs" && strings[3] === "primitive-agent-wake-v2" || script === "claude-pending-mail.mjs" && strings[3] === "primitive-pending-mail-v2")) return {
|
|
1045
|
+
kind: script === "claude-wake.mjs" ? "wake" : "pending",
|
|
1046
|
+
configDir: strings[2] ?? "",
|
|
1047
|
+
profile: null,
|
|
1048
|
+
address: null,
|
|
1049
|
+
session: null,
|
|
1050
|
+
shared: true
|
|
1051
|
+
};
|
|
1052
|
+
if (script === "claude-pending-mail.mjs") return strings.length === 7 && strings[6] === PENDING_MARKER ? {
|
|
840
1053
|
kind: "pending",
|
|
841
1054
|
configDir: strings[2] ?? "",
|
|
842
1055
|
profile: strings[3] ?? null,
|
|
@@ -892,10 +1105,14 @@ function judgeSessionHooks(hooks, cli, bound = []) {
|
|
|
892
1105
|
const parsed = parseSessionHook(hook);
|
|
893
1106
|
if (!parsed || !record$2(hook)) continue;
|
|
894
1107
|
const pathsExist = existsSync(String(hook.command)) && existsSync(String(hook.args[0])) && existsSync(String(hook.args[1]));
|
|
895
|
-
if (!(parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
|
|
1108
|
+
if (!(parsed.shared ? existsSync(parsed.configDir) : parsed.profile ? existsSync(parsed.configDir) && profileStillConnected(parsed.configDir, parsed.profile) : existsSync(parsed.configDir) && pathsExist)) {
|
|
896
1109
|
verdicts.set(hook, "stale");
|
|
897
1110
|
continue;
|
|
898
1111
|
}
|
|
1112
|
+
if (parsed.profile && parsed.session) {
|
|
1113
|
+
verdicts.set(hook, "per_session");
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
899
1116
|
const current = bound.find((item) => item.configDir === resolve(parsed.configDir) && item.profile === parsed.profile && item.session === parsed.session?.toLowerCase());
|
|
900
1117
|
if (current && parsed.address !== current.address) {
|
|
901
1118
|
verdicts.set(hook, "old_address");
|
|
@@ -905,7 +1122,7 @@ function judgeSessionHooks(hooks, cli, bound = []) {
|
|
|
905
1122
|
event,
|
|
906
1123
|
parsed.kind,
|
|
907
1124
|
resolve(parsed.configDir),
|
|
908
|
-
parsed.profile ?? "",
|
|
1125
|
+
parsed.shared ? "shared" : parsed.profile ?? "",
|
|
909
1126
|
parsed.session?.toLowerCase() ?? ""
|
|
910
1127
|
].join("\0");
|
|
911
1128
|
if (seen.has(key)) {
|
|
@@ -974,8 +1191,28 @@ function boundKey(item) {
|
|
|
974
1191
|
* disconnected or removed profile is stale (machine doctor removes it), so
|
|
975
1192
|
* the session does not receive through it.
|
|
976
1193
|
*/
|
|
977
|
-
function receivingProfilesBySession(hooks, always) {
|
|
1194
|
+
function receivingProfilesBySession(hooks, always, configDirs = []) {
|
|
978
1195
|
const receiving = /* @__PURE__ */ new Map();
|
|
1196
|
+
const shared = new Set(configDirs.map((dir) => resolve(dir)));
|
|
1197
|
+
for (const event of SESSION_EVENTS) {
|
|
1198
|
+
const entries = hooks[event];
|
|
1199
|
+
if (!Array.isArray(entries)) continue;
|
|
1200
|
+
for (const entry of entries) if (record$2(entry) && Array.isArray(entry.hooks)) for (const hook of entry.hooks) {
|
|
1201
|
+
const parsed = parseSessionHook(hook);
|
|
1202
|
+
if (parsed?.shared) shared.add(resolve(parsed.configDir));
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
for (const configDir of shared) for (const [session, receivers] of listSessionReceivers(configDir)) for (const row of receivers) {
|
|
1206
|
+
const key = [
|
|
1207
|
+
configDir,
|
|
1208
|
+
row.profile,
|
|
1209
|
+
session
|
|
1210
|
+
].join("\0");
|
|
1211
|
+
if (key !== always && !profileStillConnected(configDir, row.profile)) continue;
|
|
1212
|
+
const profiles = receiving.get(session) ?? /* @__PURE__ */ new Set();
|
|
1213
|
+
profiles.add(key);
|
|
1214
|
+
receiving.set(session, profiles);
|
|
1215
|
+
}
|
|
979
1216
|
for (const event of SESSION_EVENTS) {
|
|
980
1217
|
const entries = hooks[event];
|
|
981
1218
|
if (!Array.isArray(entries)) continue;
|
|
@@ -1014,7 +1251,7 @@ function otherSessionReceivers(hooks, target) {
|
|
|
1014
1251
|
target.profile,
|
|
1015
1252
|
session
|
|
1016
1253
|
].join("\0");
|
|
1017
|
-
const present = receivingProfilesBySession(hooks, own).get(session) ?? /* @__PURE__ */ new Set();
|
|
1254
|
+
const present = receivingProfilesBySession(hooks, own, [target.configDir]).get(session) ?? /* @__PURE__ */ new Set();
|
|
1018
1255
|
if (present.has(own)) return [];
|
|
1019
1256
|
return [...present].map((other) => other.split("\0")[1] ?? "").sort();
|
|
1020
1257
|
}
|
|
@@ -1073,31 +1310,6 @@ function sessionHookItems(hooks, verdicts, missing) {
|
|
|
1073
1310
|
}
|
|
1074
1311
|
return items;
|
|
1075
1312
|
}
|
|
1076
|
-
/** The exact entry `agent connect` and `session-register` install. */
|
|
1077
|
-
function sessionHookEntry(event, item, cli) {
|
|
1078
|
-
const pending = SESSION_HOOK_KIND[event] === "pending";
|
|
1079
|
-
const hook = {
|
|
1080
|
-
type: "command",
|
|
1081
|
-
command: cli.node,
|
|
1082
|
-
args: [
|
|
1083
|
-
pending ? cli.pending : cli.wake,
|
|
1084
|
-
cli.entry,
|
|
1085
|
-
item.configDir,
|
|
1086
|
-
item.profile,
|
|
1087
|
-
item.address,
|
|
1088
|
-
item.session,
|
|
1089
|
-
pending ? PENDING_MARKER$1 : WAKE_MARKER
|
|
1090
|
-
],
|
|
1091
|
-
...pending ? { timeout: 10 } : {
|
|
1092
|
-
asyncRewake: true,
|
|
1093
|
-
timeout: 604800
|
|
1094
|
-
}
|
|
1095
|
-
};
|
|
1096
|
-
return event === "SessionStart" ? {
|
|
1097
|
-
matcher: "resume",
|
|
1098
|
-
hooks: [hook]
|
|
1099
|
-
} : { hooks: [hook] };
|
|
1100
|
-
}
|
|
1101
1313
|
/** Inspect every Primitive-owned Claude hook without changing anything. */
|
|
1102
1314
|
function inspectClaudeHooks(settings, context) {
|
|
1103
1315
|
const hooks = record$2(settings.hooks) ? settings.hooks : {};
|
|
@@ -1154,7 +1366,8 @@ function inspectClaudeHooks(settings, context) {
|
|
|
1154
1366
|
stale: 0,
|
|
1155
1367
|
duplicate: 0,
|
|
1156
1368
|
outdated: 0,
|
|
1157
|
-
old_address: 0
|
|
1369
|
+
old_address: 0,
|
|
1370
|
+
per_session: 0
|
|
1158
1371
|
};
|
|
1159
1372
|
const verdicts = judgeSessionHooks(hooks, context.cli, bound);
|
|
1160
1373
|
for (const verdict of verdicts.values()) counts[verdict]++;
|
|
@@ -1166,16 +1379,17 @@ function inspectClaudeHooks(settings, context) {
|
|
|
1166
1379
|
counts.old_address ? `${counts.old_address} naming an old agent address` : "",
|
|
1167
1380
|
counts.stale ? `${counts.stale} for disconnected or removed sessions` : "",
|
|
1168
1381
|
counts.duplicate ? `${counts.duplicate} duplicates` : "",
|
|
1169
|
-
counts.outdated ? `${counts.outdated} pointing at an old CLI path` : ""
|
|
1382
|
+
counts.outdated ? `${counts.outdated} pointing at an old CLI path` : "",
|
|
1383
|
+
counts.per_session ? `${counts.per_session} per-session hooks to fold into the shared receive hooks` : ""
|
|
1170
1384
|
].filter(Boolean);
|
|
1171
1385
|
findings["claude.hook.stop"] = problems.length ? {
|
|
1172
1386
|
status: "fail",
|
|
1173
|
-
detail: `
|
|
1174
|
-
fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null,
|
|
1387
|
+
detail: `Receive hooks need cleanup: ${problems.join(", ")}. Affected: ${summarizeOwners(items)}.${blockedNote}${heldNote}`,
|
|
1388
|
+
fixable: canFix || counts.outdated === 0 && counts.per_session === 0 && missing === 0 && context.blocked === null,
|
|
1175
1389
|
items
|
|
1176
1390
|
} : {
|
|
1177
1391
|
status: "ok",
|
|
1178
|
-
detail: `${counts.keep ? `${counts.keep}
|
|
1392
|
+
detail: `${counts.keep ? `${counts.keep} receive hooks, each present once and current; the shared hooks serve every registered session.` : "No receive hooks; registering a session adds the shared receive hooks."}${heldNote}`,
|
|
1179
1393
|
fixable: false
|
|
1180
1394
|
};
|
|
1181
1395
|
return findings;
|
|
@@ -1210,6 +1424,7 @@ function rewriteEntries(entries, transform) {
|
|
|
1210
1424
|
function repairClaudeHooks(settings, context, checks, options = {}) {
|
|
1211
1425
|
const changed = /* @__PURE__ */ new Set();
|
|
1212
1426
|
const changes = [];
|
|
1427
|
+
const receivers = [];
|
|
1213
1428
|
const selected = (profile) => !options.profiles || profile !== null && options.profiles.has(profile);
|
|
1214
1429
|
const hooks = record$2(settings.hooks) ? { ...settings.hooks } : {};
|
|
1215
1430
|
const cli = context.blocked === null ? context.cli : null;
|
|
@@ -1246,6 +1461,13 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
|
|
|
1246
1461
|
const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
|
|
1247
1462
|
const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
|
|
1248
1463
|
const verdicts = judgeSessionHooks(hooks, context.cli, bound);
|
|
1464
|
+
const missingBefore = missingSessionHooks(hooks, bound);
|
|
1465
|
+
const planned = /* @__PURE__ */ new Set();
|
|
1466
|
+
const plan = (item) => {
|
|
1467
|
+
if (planned.has(boundKey(item))) return;
|
|
1468
|
+
planned.add(boundKey(item));
|
|
1469
|
+
receivers.push(item);
|
|
1470
|
+
};
|
|
1249
1471
|
for (const event of SESSION_EVENTS) {
|
|
1250
1472
|
const entries = eventEntries(hooks, event);
|
|
1251
1473
|
if (entries === "invalid" || entries.length === 0) continue;
|
|
@@ -1253,13 +1475,23 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
|
|
|
1253
1475
|
const verdict = verdicts.get(hook);
|
|
1254
1476
|
const parsed = parseSessionHook(hook);
|
|
1255
1477
|
if (!verdict || verdict === "keep" || !parsed) return hook;
|
|
1256
|
-
if (!selected(parsed.profile)) return hook;
|
|
1478
|
+
if (!parsed.shared && !selected(parsed.profile)) return hook;
|
|
1257
1479
|
const item = {
|
|
1258
1480
|
profile: parsed.profile,
|
|
1259
1481
|
session: parsed.session?.toLowerCase() ?? null,
|
|
1260
1482
|
hook: event,
|
|
1261
1483
|
state: verdict
|
|
1262
1484
|
};
|
|
1485
|
+
if (verdict === "per_session") {
|
|
1486
|
+
if (!cli) return hook;
|
|
1487
|
+
const current = foldedReceiver(parsed);
|
|
1488
|
+
if (current) plan(current);
|
|
1489
|
+
changes.push({
|
|
1490
|
+
...item,
|
|
1491
|
+
action: "removed"
|
|
1492
|
+
});
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1263
1495
|
if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") {
|
|
1264
1496
|
changes.push({
|
|
1265
1497
|
...item,
|
|
@@ -1286,26 +1518,33 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
|
|
|
1286
1518
|
changed.add("claude.hook.stop");
|
|
1287
1519
|
}
|
|
1288
1520
|
}
|
|
1289
|
-
if (cli)
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1521
|
+
if (cli) {
|
|
1522
|
+
for (const missing of missingBefore) {
|
|
1523
|
+
if (!selected(missing.bound.profile)) continue;
|
|
1524
|
+
changes.push({
|
|
1525
|
+
profile: missing.bound.profile,
|
|
1526
|
+
session: missing.bound.session,
|
|
1527
|
+
hook: missing.event,
|
|
1528
|
+
state: "missing",
|
|
1529
|
+
action: "added"
|
|
1530
|
+
});
|
|
1531
|
+
plan(missing.bound);
|
|
1532
|
+
}
|
|
1533
|
+
for (const configDir of new Set(receivers.map((item) => resolve(item.configDir)))) for (const event of SESSION_EVENTS) {
|
|
1534
|
+
const entries = eventEntries(hooks, event);
|
|
1535
|
+
if (entries === "invalid" || hasSharedHook(hooks, event, configDir, null)) continue;
|
|
1536
|
+
hooks[event] = [...entries, sharedHookEntry(event, cli, configDir)];
|
|
1537
|
+
changed.add("claude.hook.stop");
|
|
1538
|
+
}
|
|
1539
|
+
if (receivers.length) changed.add("claude.hook.stop");
|
|
1302
1540
|
}
|
|
1303
1541
|
}
|
|
1304
1542
|
}
|
|
1305
1543
|
if (!changed.size) return {
|
|
1306
1544
|
settings,
|
|
1307
1545
|
changed,
|
|
1308
|
-
changes
|
|
1546
|
+
changes,
|
|
1547
|
+
receivers
|
|
1309
1548
|
};
|
|
1310
1549
|
return {
|
|
1311
1550
|
settings: {
|
|
@@ -1313,15 +1552,17 @@ function repairClaudeHooks(settings, context, checks, options = {}) {
|
|
|
1313
1552
|
hooks
|
|
1314
1553
|
},
|
|
1315
1554
|
changed,
|
|
1316
|
-
changes
|
|
1555
|
+
changes,
|
|
1556
|
+
receivers
|
|
1317
1557
|
};
|
|
1318
1558
|
}
|
|
1319
1559
|
//#endregion
|
|
1320
1560
|
//#region src/oclif/claude-wake-install.ts
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1561
|
+
const RECEIVE_EVENTS = [
|
|
1562
|
+
"Stop",
|
|
1563
|
+
"SessionStart",
|
|
1564
|
+
"PostToolUse"
|
|
1565
|
+
];
|
|
1325
1566
|
const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
1326
1567
|
function record$1(value) {
|
|
1327
1568
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -1445,7 +1686,14 @@ function editClaudeSettings(claudeDir, edit) {
|
|
|
1445
1686
|
release();
|
|
1446
1687
|
}
|
|
1447
1688
|
}
|
|
1448
|
-
/**
|
|
1689
|
+
/**
|
|
1690
|
+
* Record this profile as a receiver for the session and make sure the one
|
|
1691
|
+
* shared set of receive hooks is installed, preserving unrelated settings.
|
|
1692
|
+
* Per-session hooks written by earlier versions for this config directory
|
|
1693
|
+
* are folded in: a still-bound profile becomes a recorded receiver and its
|
|
1694
|
+
* hooks are removed; hooks for disconnected profiles or removed config
|
|
1695
|
+
* directories are pruned. Hooks of other tools are never touched.
|
|
1696
|
+
*/
|
|
1449
1697
|
function installClaudeWakeHook(options) {
|
|
1450
1698
|
try {
|
|
1451
1699
|
const configDir = resolve(options.configDir);
|
|
@@ -1455,9 +1703,6 @@ function installClaudeWakeHook(options) {
|
|
|
1455
1703
|
platform: options.platform
|
|
1456
1704
|
});
|
|
1457
1705
|
if (!cli) return "unavailable";
|
|
1458
|
-
const cliPath = cli.entry;
|
|
1459
|
-
const wrapperPath = cli.wake;
|
|
1460
|
-
const pendingPath = cli.pending;
|
|
1461
1706
|
const profileName = agentProfileName(options.profileName);
|
|
1462
1707
|
const agentAddress = mailAddress(options.agentAddress);
|
|
1463
1708
|
if (!SESSION_UUID.test(options.sessionId)) return "unavailable";
|
|
@@ -1469,52 +1714,19 @@ function installClaudeWakeHook(options) {
|
|
|
1469
1714
|
} catch {
|
|
1470
1715
|
node = cli.runtimeNode;
|
|
1471
1716
|
}
|
|
1717
|
+
pruneSessionReceivers(configDir);
|
|
1472
1718
|
let held = false;
|
|
1473
1719
|
if (!editClaudeSettings(claudeDir, (settings) => {
|
|
1474
1720
|
const hooks = settings.hooks ?? {};
|
|
1475
1721
|
if (!record$1(hooks)) throw new Error("Invalid hook settings");
|
|
1476
|
-
for (const event of
|
|
1477
|
-
if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
|
|
1722
|
+
for (const event of RECEIVE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
|
|
1478
1723
|
held = options.yieldToOtherProfiles === true && otherSessionReceivers(hooks, {
|
|
1479
1724
|
configDir,
|
|
1480
1725
|
profile: profileName,
|
|
1481
1726
|
session: sessionId
|
|
1482
1727
|
}).length > 0;
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
command: node,
|
|
1486
|
-
args: [
|
|
1487
|
-
wrapperPath,
|
|
1488
|
-
cliPath,
|
|
1489
|
-
configDir,
|
|
1490
|
-
profileName,
|
|
1491
|
-
agentAddress,
|
|
1492
|
-
sessionId,
|
|
1493
|
-
HOOK_MARKER
|
|
1494
|
-
],
|
|
1495
|
-
asyncRewake: true,
|
|
1496
|
-
timeout: 604800
|
|
1497
|
-
};
|
|
1498
|
-
const pendingHook = {
|
|
1499
|
-
type: "command",
|
|
1500
|
-
command: node,
|
|
1501
|
-
args: [
|
|
1502
|
-
pendingPath,
|
|
1503
|
-
cliPath,
|
|
1504
|
-
configDir,
|
|
1505
|
-
profileName,
|
|
1506
|
-
agentAddress,
|
|
1507
|
-
sessionId,
|
|
1508
|
-
PENDING_MARKER
|
|
1509
|
-
],
|
|
1510
|
-
timeout: 10
|
|
1511
|
-
};
|
|
1512
|
-
const isOwnPendingHook = (candidate) => record$1(candidate) && candidate.type === "command" && Array.isArray(candidate.args) && candidate.args.length === 7 && typeof candidate.args[0] === "string" && basename(candidate.args[0]) === "claude-pending-mail.mjs" && candidate.args[2] === configDir && candidate.args[3] === profileName && candidate.args[5] === sessionId && candidate.args[6] === PENDING_MARKER;
|
|
1513
|
-
const isOwnHook = (candidate) => {
|
|
1514
|
-
if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
|
|
1515
|
-
if (typeof candidate.args[0] !== "string" || basename(candidate.args[0]) !== "claude-wake.mjs" || candidate.args[2] !== configDir || candidate.args[3] !== profileName) return false;
|
|
1516
|
-
if (candidate.args.length === 7 && candidate.args[5] === sessionId && candidate.args[6] === HOOK_MARKER) return true;
|
|
1517
|
-
if (candidate.args.length !== 6 || candidate.args[5] !== HOOK_MARKER) return false;
|
|
1728
|
+
const isOwnLegacyHook = (parsed) => {
|
|
1729
|
+
if (!parsed || parsed.shared || parsed.session !== null || parsed.profile !== profileName || resolve(parsed.configDir) !== configDir) return false;
|
|
1518
1730
|
try {
|
|
1519
1731
|
const state = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
|
|
1520
1732
|
return record$1(state) && state.session === sessionId;
|
|
@@ -1522,39 +1734,50 @@ function installClaudeWakeHook(options) {
|
|
|
1522
1734
|
return false;
|
|
1523
1735
|
}
|
|
1524
1736
|
};
|
|
1737
|
+
const perSession = (parsed) => parsed !== null && !parsed.shared && parsed.profile !== null && parsed.session !== null;
|
|
1738
|
+
if (!held) addSessionReceiver(configDir, sessionId, {
|
|
1739
|
+
profile: profileName,
|
|
1740
|
+
address: agentAddress
|
|
1741
|
+
});
|
|
1525
1742
|
const nextHooks = { ...hooks };
|
|
1526
|
-
for (const event of
|
|
1527
|
-
const entries = hooks[event];
|
|
1528
|
-
const
|
|
1743
|
+
for (const event of RECEIVE_EVENTS) {
|
|
1744
|
+
const entries = hooks[event] ?? [];
|
|
1745
|
+
const ownShared = (candidate) => {
|
|
1746
|
+
const parsed = parseReceiveHook(candidate);
|
|
1747
|
+
return parsed?.shared === true && resolve(parsed.configDir) === configDir;
|
|
1748
|
+
};
|
|
1749
|
+
const wanted = sharedHookEntry(event, cli, configDir, node);
|
|
1750
|
+
const [wantedHook] = wanted.hooks;
|
|
1751
|
+
const existing = entries.flatMap((entry) => record$1(entry) && Array.isArray(entry.hooks) ? entry.hooks.filter(ownShared) : []);
|
|
1752
|
+
const reuse = held || existing.length === 1 && JSON.stringify(existing[0]) === JSON.stringify(wantedHook) && hasSharedHook(hooks, event, configDir, node);
|
|
1753
|
+
const keep = (candidate) => {
|
|
1754
|
+
const parsed = parseReceiveHook(candidate);
|
|
1755
|
+
if (!parsed) return true;
|
|
1756
|
+
if (isOwnLegacyHook(parsed)) return false;
|
|
1757
|
+
if (!existsSync(parsed.configDir)) return false;
|
|
1758
|
+
if (parsed.shared) return resolve(parsed.configDir) !== configDir || reuse;
|
|
1759
|
+
if (!perSession(parsed)) return true;
|
|
1760
|
+
if (held && !(parsed.profile === profileName && parsed.session?.toLowerCase() === sessionId && resolve(parsed.configDir) === configDir)) return true;
|
|
1761
|
+
if (resolve(parsed.configDir) !== configDir) return profileStillConnected(parsed.configDir, parsed.profile ?? "");
|
|
1762
|
+
const current = foldedReceiver(parsed);
|
|
1763
|
+
if (current && !held) addSessionReceiver(configDir, current.session, {
|
|
1764
|
+
profile: current.profile,
|
|
1765
|
+
address: current.address
|
|
1766
|
+
});
|
|
1767
|
+
return false;
|
|
1768
|
+
};
|
|
1769
|
+
const retained = entries.flatMap((entry) => {
|
|
1529
1770
|
if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
|
|
1530
|
-
const siblings = entry.hooks.filter(
|
|
1771
|
+
const siblings = entry.hooks.filter(keep);
|
|
1531
1772
|
if (siblings.length === entry.hooks.length) return [entry];
|
|
1532
1773
|
return siblings.length ? [{
|
|
1533
1774
|
...entry,
|
|
1534
1775
|
hooks: siblings
|
|
1535
1776
|
}] : [];
|
|
1536
1777
|
});
|
|
1537
|
-
if (
|
|
1538
|
-
if (entries) nextHooks[event] = retained;
|
|
1539
|
-
} else nextHooks[event] = [...retained, event === "SessionStart" ? {
|
|
1540
|
-
matcher: "resume",
|
|
1541
|
-
hooks: [hook]
|
|
1542
|
-
} : { hooks: [hook] }];
|
|
1778
|
+
if (hooks[event] !== void 0 || !reuse) nextHooks[event] = reuse ? retained : [...retained, wanted];
|
|
1543
1779
|
}
|
|
1544
|
-
|
|
1545
|
-
const pendingRetained = (pendingEntries ?? []).flatMap((entry) => {
|
|
1546
|
-
if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
|
|
1547
|
-
const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
|
|
1548
|
-
if (siblings.length === entry.hooks.length) return [entry];
|
|
1549
|
-
return siblings.length ? [{
|
|
1550
|
-
...entry,
|
|
1551
|
-
hooks: siblings
|
|
1552
|
-
}] : [];
|
|
1553
|
-
});
|
|
1554
|
-
if (held) {
|
|
1555
|
-
if (pendingEntries) nextHooks[PENDING_EVENT] = pendingRetained;
|
|
1556
|
-
} else nextHooks[PENDING_EVENT] = [...pendingRetained, { hooks: [pendingHook] }];
|
|
1557
|
-
if (held && JSON.stringify(nextHooks) === JSON.stringify(hooks)) return null;
|
|
1780
|
+
if (JSON.stringify(nextHooks) === JSON.stringify(hooks)) return null;
|
|
1558
1781
|
return {
|
|
1559
1782
|
...settings,
|
|
1560
1783
|
hooks: nextHooks
|
|
@@ -1565,7 +1788,12 @@ function installClaudeWakeHook(options) {
|
|
|
1565
1788
|
return "unavailable";
|
|
1566
1789
|
}
|
|
1567
1790
|
}
|
|
1568
|
-
/**
|
|
1791
|
+
/**
|
|
1792
|
+
* Stop receiving as this profile in the session after disconnection: its
|
|
1793
|
+
* receiver record goes, with any per-session hooks an earlier version wrote
|
|
1794
|
+
* for it. The shared hooks stay; for a session with no receivers they exit
|
|
1795
|
+
* at once.
|
|
1796
|
+
*/
|
|
1569
1797
|
function uninstallClaudeWakeHook(options) {
|
|
1570
1798
|
try {
|
|
1571
1799
|
const configDir = resolve(options.configDir);
|
|
@@ -1575,12 +1803,12 @@ function uninstallClaudeWakeHook(options) {
|
|
|
1575
1803
|
const sessionId = options.sessionId.toLowerCase();
|
|
1576
1804
|
const env = options.env ?? process.env;
|
|
1577
1805
|
const claudeDir = resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
|
|
1806
|
+
removeSessionReceiver(configDir, sessionId, profileName);
|
|
1578
1807
|
if (!existsSync(join(claudeDir, "settings.json"))) return true;
|
|
1579
1808
|
return editClaudeSettings(claudeDir, (settings) => {
|
|
1580
1809
|
const hooks = settings.hooks ?? {};
|
|
1581
1810
|
if (!record$1(hooks)) throw new Error("Invalid hook settings");
|
|
1582
|
-
for (const event of
|
|
1583
|
-
if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
|
|
1811
|
+
for (const event of RECEIVE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
|
|
1584
1812
|
const legacySessionMatches = () => {
|
|
1585
1813
|
try {
|
|
1586
1814
|
const setup = readMailJson(join(agentProfileDirectory(configDir, profileName), "setup.json"));
|
|
@@ -1590,38 +1818,25 @@ function uninstallClaudeWakeHook(options) {
|
|
|
1590
1818
|
}
|
|
1591
1819
|
};
|
|
1592
1820
|
const isOwnHook = (candidate) => {
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
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());
|
|
1596
|
-
};
|
|
1597
|
-
const isOwnPendingHook = (candidate) => {
|
|
1598
|
-
if (!record$1(candidate) || !Array.isArray(candidate.args)) return false;
|
|
1599
|
-
const args = candidate.args;
|
|
1600
|
-
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;
|
|
1821
|
+
const parsed = parseReceiveHook(candidate);
|
|
1822
|
+
return parsed !== null && !parsed.shared && resolve(parsed.configDir) === configDir && parsed.profile === profileName && parsed.address === agentAddress && (parsed.session === null ? legacySessionMatches() : parsed.session.toLowerCase() === sessionId);
|
|
1601
1823
|
};
|
|
1602
1824
|
let removed = false;
|
|
1603
1825
|
const nextHooks = { ...hooks };
|
|
1604
|
-
for (const event of
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if (siblings.length === entry.hooks.length) return [entry];
|
|
1619
|
-
removed = true;
|
|
1620
|
-
return siblings.length ? [{
|
|
1621
|
-
...entry,
|
|
1622
|
-
hooks: siblings
|
|
1623
|
-
}] : [];
|
|
1624
|
-
});
|
|
1826
|
+
for (const event of RECEIVE_EVENTS) {
|
|
1827
|
+
const entries = hooks[event];
|
|
1828
|
+
if (!entries) continue;
|
|
1829
|
+
nextHooks[event] = entries.flatMap((entry) => {
|
|
1830
|
+
if (!record$1(entry) || !Array.isArray(entry.hooks)) return [entry];
|
|
1831
|
+
const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
|
|
1832
|
+
if (siblings.length === entry.hooks.length) return [entry];
|
|
1833
|
+
removed = true;
|
|
1834
|
+
return siblings.length ? [{
|
|
1835
|
+
...entry,
|
|
1836
|
+
hooks: siblings
|
|
1837
|
+
}] : [];
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1625
1840
|
return removed ? {
|
|
1626
1841
|
...settings,
|
|
1627
1842
|
hooks: nextHooks
|
|
@@ -1821,6 +2036,12 @@ var AgentInvitationRejectedError = class extends AgentConnectionSetupError {
|
|
|
1821
2036
|
this.reason = reason;
|
|
1822
2037
|
}
|
|
1823
2038
|
};
|
|
2039
|
+
/**
|
|
2040
|
+
* The claim was never sent: another setup on this machine held the claim
|
|
2041
|
+
* lock for the whole wait. Unlike an uncertain claim, the invitation is
|
|
2042
|
+
* untouched, so a caller may discard what it created for this run.
|
|
2043
|
+
*/
|
|
2044
|
+
var ClaimNotSubmittedError = class extends AgentConnectionSetupError {};
|
|
1824
2045
|
const NOTHING_CHANGED = "Nothing was changed on this machine.";
|
|
1825
2046
|
/**
|
|
1826
2047
|
* Restates a refusal that followed a replacement: the agent being replaced
|
|
@@ -2122,9 +2343,16 @@ async function connectAgent(params) {
|
|
|
2122
2343
|
let release;
|
|
2123
2344
|
try {
|
|
2124
2345
|
privateMailDirectory(directory, true);
|
|
2125
|
-
release = params.claimLockHeld ? () => {} : acquireListenLock(directory, "agent-connection-setup");
|
|
2126
2346
|
} catch {
|
|
2127
|
-
throw new
|
|
2347
|
+
throw new ClaimNotSubmittedError("This machine's private agent directory is unavailable. No invitation was submitted.");
|
|
2348
|
+
}
|
|
2349
|
+
if (params.claimLockHeld) release = () => {};
|
|
2350
|
+
const deadline = Date.now() + Math.max(0, params.claimLockWaitMs ?? 0);
|
|
2351
|
+
while (!release) try {
|
|
2352
|
+
release = acquireListenLock(directory, "agent-connection-setup");
|
|
2353
|
+
} catch {
|
|
2354
|
+
if (Date.now() >= deadline) throw new ClaimNotSubmittedError("Agent setup is already running on this machine, so this invitation was not submitted; rerun the same command when it finishes.");
|
|
2355
|
+
await setTimeout$1(Math.min(250, Math.max(1, deadline - Date.now())));
|
|
2128
2356
|
}
|
|
2129
2357
|
try {
|
|
2130
2358
|
const existing = loadConnectedAgentProfile(params.configDir, profileName);
|
|
@@ -3411,6 +3639,140 @@ function createNotificationContactPolicy(options) {
|
|
|
3411
3639
|
};
|
|
3412
3640
|
}
|
|
3413
3641
|
//#endregion
|
|
3642
|
+
//#region src/oclif/registration-log.ts
|
|
3643
|
+
/**
|
|
3644
|
+
* A short private record of this profile's registration attempts, so a
|
|
3645
|
+
* stuck registration can be explained by `agent connect --status` and
|
|
3646
|
+
* `whoami` without anyone opening profile files. It holds phase changes,
|
|
3647
|
+
* error codes, the organization id and the agent address only: never a
|
|
3648
|
+
* token, key, claim URL or response body.
|
|
3649
|
+
*/
|
|
3650
|
+
const LOG_FILE = "registration-log.json";
|
|
3651
|
+
const MAX_DETAIL = 400;
|
|
3652
|
+
const MAX_BYTES = 65536;
|
|
3653
|
+
/**
|
|
3654
|
+
* Removes anything that could carry a credential: setup URLs and their
|
|
3655
|
+
* fragments, token parameters and Primitive key or token prefixes. The
|
|
3656
|
+
* messages written here are the CLI's own refusals, so this is a second
|
|
3657
|
+
* line of defence, not the only one.
|
|
3658
|
+
*/
|
|
3659
|
+
function scrubRegistrationDetail(text) {
|
|
3660
|
+
return text.replace(/https?:\/\/\S*agent-connections\/setup\S*/gi, "[setup URL]").replace(/#?token=[^\s"'&]+/gi, "token=[redacted]").replace(/\b(?:prim|pconn|invite|invitation)_[A-Za-z0-9_-]+/g, "[redacted]").replace(/\bBearer\s+\S+/gi, "Bearer [redacted]").slice(0, MAX_DETAIL);
|
|
3661
|
+
}
|
|
3662
|
+
function logPath(configDir, profileName) {
|
|
3663
|
+
return join(agentProfileDirectory(configDir, profileName), LOG_FILE);
|
|
3664
|
+
}
|
|
3665
|
+
function entryFrom(value) {
|
|
3666
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
3667
|
+
const row = value;
|
|
3668
|
+
const text = (key) => typeof row[key] === "string" ? row[key] : null;
|
|
3669
|
+
const at = text("at");
|
|
3670
|
+
const event = text("event");
|
|
3671
|
+
if (!at || !event || !Number.isFinite(Date.parse(at))) return null;
|
|
3672
|
+
return {
|
|
3673
|
+
at,
|
|
3674
|
+
event,
|
|
3675
|
+
phase: text("phase"),
|
|
3676
|
+
code: text("code"),
|
|
3677
|
+
orgId: text("orgId"),
|
|
3678
|
+
address: text("address"),
|
|
3679
|
+
detail: text("detail")
|
|
3680
|
+
};
|
|
3681
|
+
}
|
|
3682
|
+
/** Every saved entry, oldest first. Empty when there is no readable log. */
|
|
3683
|
+
function readRegistrationLog(configDir, profileName) {
|
|
3684
|
+
try {
|
|
3685
|
+
const saved = readMailJson(logPath(configDir, profileName), MAX_BYTES);
|
|
3686
|
+
if (saved?.version !== 1 || !Array.isArray(saved.entries)) return [];
|
|
3687
|
+
return saved.entries.map(entryFrom).filter((row) => row !== null);
|
|
3688
|
+
} catch {
|
|
3689
|
+
return [];
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
/** Appends one entry, keeping the most recent ones. Best effort. */
|
|
3693
|
+
function appendRegistrationLog(configDir, profileName, entry) {
|
|
3694
|
+
try {
|
|
3695
|
+
const row = {
|
|
3696
|
+
at: entry.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3697
|
+
event: entry.event,
|
|
3698
|
+
phase: entry.phase,
|
|
3699
|
+
code: entry.code,
|
|
3700
|
+
orgId: entry.orgId,
|
|
3701
|
+
address: entry.address,
|
|
3702
|
+
detail: entry.detail === void 0 || entry.detail === null ? null : scrubRegistrationDetail(entry.detail)
|
|
3703
|
+
};
|
|
3704
|
+
const entries = [...readRegistrationLog(configDir, profileName), row].slice(-30);
|
|
3705
|
+
writeMailJson(logPath(configDir, profileName), {
|
|
3706
|
+
version: 1,
|
|
3707
|
+
entries
|
|
3708
|
+
});
|
|
3709
|
+
} catch {}
|
|
3710
|
+
}
|
|
3711
|
+
/** What the last registration attempt did, or null when none was recorded. */
|
|
3712
|
+
function registrationSummary(configDir, profileName) {
|
|
3713
|
+
const entries = readRegistrationLog(configDir, profileName);
|
|
3714
|
+
const last = entries.at(-1);
|
|
3715
|
+
if (!last) return null;
|
|
3716
|
+
const address = [...entries].reverse().find((row) => row.address !== null)?.address ?? null;
|
|
3717
|
+
const orgId = [...entries].reverse().find((row) => row.orgId !== null)?.orgId ?? null;
|
|
3718
|
+
return {
|
|
3719
|
+
lastAttemptAt: last.at,
|
|
3720
|
+
lastEvent: last.event,
|
|
3721
|
+
address,
|
|
3722
|
+
orgId,
|
|
3723
|
+
lastFailure: last.event === "failed" ? {
|
|
3724
|
+
at: last.at,
|
|
3725
|
+
code: last.code,
|
|
3726
|
+
detail: last.detail
|
|
3727
|
+
} : null
|
|
3728
|
+
};
|
|
3729
|
+
}
|
|
3730
|
+
/** The lock scope an enrollment holds on its profile's enrollment directory. */
|
|
3731
|
+
const ENROLLMENT_LOCK_SCOPE = "agent-enrollment";
|
|
3732
|
+
/** Profiles whose registration is running right now on this machine. */
|
|
3733
|
+
function registrationsInFlight(configDir) {
|
|
3734
|
+
let names;
|
|
3735
|
+
try {
|
|
3736
|
+
names = readdirSync(join(agentProfilesDirectory(configDir), "profiles"));
|
|
3737
|
+
} catch {
|
|
3738
|
+
return [];
|
|
3739
|
+
}
|
|
3740
|
+
return names.sort().filter((name) => listenLockHeld(join(agentProfilesDirectory(configDir), "profiles", name, "enrollment"), ENROLLMENT_LOCK_SCOPE));
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3743
|
+
* Waits up to `budgetMs` for running registrations to finish, so a login
|
|
3744
|
+
* switch does not change the member login underneath one. Returns the
|
|
3745
|
+
* profiles still registering when the budget ran out.
|
|
3746
|
+
*/
|
|
3747
|
+
async function waitForRegistrations(configDir, budgetMs, options = {}) {
|
|
3748
|
+
const deadline = Date.now() + budgetMs;
|
|
3749
|
+
let running = registrationsInFlight(configDir);
|
|
3750
|
+
if (running.length) options.onWait?.(running);
|
|
3751
|
+
while (running.length && Date.now() < deadline) {
|
|
3752
|
+
await setTimeout$1(Math.min(options.pollMs ?? 250, deadline - Date.now()));
|
|
3753
|
+
running = registrationsInFlight(configDir);
|
|
3754
|
+
}
|
|
3755
|
+
return running;
|
|
3756
|
+
}
|
|
3757
|
+
/** One line for people: what the last registration attempt did. */
|
|
3758
|
+
function registrationSummaryLine(summary) {
|
|
3759
|
+
const failure = summary.lastFailure;
|
|
3760
|
+
return failure ? `The last registration attempt for this session${summary.address ? ` (${summary.address})` : ""} failed at ${failure.at}${failure.code ? ` (${failure.code})` : ""}: ${failure.detail ?? "no detail was recorded."}` : `The last registration attempt for this session${summary.address ? ` (${summary.address})` : ""} reached "${summary.lastEvent}" at ${summary.lastAttemptAt}.`;
|
|
3761
|
+
}
|
|
3762
|
+
/**
|
|
3763
|
+
* The failed registration of this session's own profile, when the session
|
|
3764
|
+
* has no connected credential there. Null when it is connected, never
|
|
3765
|
+
* registered, or its last attempt did not fail. Offline and read-only.
|
|
3766
|
+
*/
|
|
3767
|
+
function failedSessionRegistration(configDir, session) {
|
|
3768
|
+
const profile = `session-${session.toLowerCase()}`;
|
|
3769
|
+
try {
|
|
3770
|
+
if (loadConnectedAgentProfile(configDir, profile)) return null;
|
|
3771
|
+
} catch {}
|
|
3772
|
+
const summary = registrationSummary(configDir, profile);
|
|
3773
|
+
return summary?.lastFailure ? summary : null;
|
|
3774
|
+
}
|
|
3775
|
+
//#endregion
|
|
3414
3776
|
//#region src/oclif/agent-setup.ts
|
|
3415
3777
|
const SUBJECT = "Connect your agent to Primitive";
|
|
3416
3778
|
const MARKER = /\bprimitive-connection:([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}):([1-9]\d*)\b/g;
|
|
@@ -3786,6 +4148,44 @@ function refuseSetupConflicts(state, params) {
|
|
|
3786
4148
|
];
|
|
3787
4149
|
if (conflicts.length) throw fail(`This profile's saved setup conflicts with ${conflicts.join(" and ")}. Omit the option to reuse the saved setup. Its session and notification preferences were not changed.${params.resume ? "" : " Do not create a separate profile on your own: if this session should get a different address, ask the user whether to keep the existing one or disconnect it first."}`);
|
|
3788
4150
|
}
|
|
4151
|
+
/** How long a claim waits for another claim on this machine to finish. */
|
|
4152
|
+
const CLAIM_LOCK_WAIT_MS = 3e4;
|
|
4153
|
+
/**
|
|
4154
|
+
* Moves a file aside as `replaced-<stamp>-<name>`, keeping it for recovery.
|
|
4155
|
+
* Returns where it went, or null when there was no file.
|
|
4156
|
+
*/
|
|
4157
|
+
function moveAside(path, now) {
|
|
4158
|
+
const stamp = now.toISOString().replace(/[^0-9]/g, "");
|
|
4159
|
+
const target = join(dirname(path), `replaced-${stamp}-${basename(path)}`);
|
|
4160
|
+
try {
|
|
4161
|
+
renameSync(path, target);
|
|
4162
|
+
return target;
|
|
4163
|
+
} catch (error) {
|
|
4164
|
+
if (error.code !== "ENOENT") throw error;
|
|
4165
|
+
return null;
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
/** The address an unfinished enrollment in this profile reserved, if any. */
|
|
4169
|
+
function pendingEnrollmentAddress(directory) {
|
|
4170
|
+
try {
|
|
4171
|
+
const saved = readMailJson(join(directory, "enrollment", "state.json"));
|
|
4172
|
+
return typeof saved?.address === "string" ? saved.address : null;
|
|
4173
|
+
} catch {
|
|
4174
|
+
return null;
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
/** A connected credential is saved in this profile. Unreadable counts as one. */
|
|
4178
|
+
function holdsCredential(configDir, profileName) {
|
|
4179
|
+
try {
|
|
4180
|
+
return loadConnectedAgentProfile(configDir, profileName) !== null;
|
|
4181
|
+
} catch {
|
|
4182
|
+
return true;
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
/** The one next step when a profile has setup progress but no credential. */
|
|
4186
|
+
function unfinishedSetupStep(address) {
|
|
4187
|
+
return address ? `This session's agent ${address} is created but not connected: in the Primitive app open it and choose Copy again, then paste that setup instruction here; \`agent connect\` finishes the same address.` : "This profile's earlier setup never finished and holds no credential: ask the owner to copy a fresh setup instruction from the Primitive app and paste it here; `agent connect` claims it into this same profile.";
|
|
4188
|
+
}
|
|
3789
4189
|
function invitationHashOrNull(invitation) {
|
|
3790
4190
|
if (invitation === void 0) return null;
|
|
3791
4191
|
try {
|
|
@@ -3815,7 +4215,8 @@ async function setupAgent(params) {
|
|
|
3815
4215
|
} catch {}
|
|
3816
4216
|
const newHash = params.resume ? null : invitationHashOrNull(params.invitation);
|
|
3817
4217
|
const replacesRevoked = (state) => newHash !== null && state.invitationHash !== newHash && savedSetupRevoked(params.configDir, profileName, state.invitationHash);
|
|
3818
|
-
|
|
4218
|
+
const replacesUnfinished = (state) => newHash !== null && state.invitationHash !== newHash && state.phase === "waiting" && !holdsCredential(params.configDir, profileName);
|
|
4219
|
+
if (saved && !replacesRevoked(saved) && !replacesUnfinished(saved)) refuseSetupConflicts(saved, params);
|
|
3819
4220
|
const receiverMode = params.receiverMode ?? (params.resume ? saved?.receiverMode : void 0);
|
|
3820
4221
|
if (params.session === void 0 && (receiverMode ?? "native") !== "poll") throw fail("--session requires the exact loaded session UUID. Without one, use --receiver poll. No invitation was claimed.");
|
|
3821
4222
|
if ((receiverMode ?? "native") === "native" && params.session) try {
|
|
@@ -3826,13 +4227,31 @@ async function setupAgent(params) {
|
|
|
3826
4227
|
privateMailDirectory(directory, true);
|
|
3827
4228
|
const release = acquireListenLock(directory, "setup");
|
|
3828
4229
|
let releaseClaim;
|
|
4230
|
+
let releaseEnrollment;
|
|
4231
|
+
let replacedUnfinished;
|
|
4232
|
+
const moved = [];
|
|
4233
|
+
let retireEnrollment = false;
|
|
4234
|
+
const setAside = (from, at) => {
|
|
4235
|
+
const to = moveAside(from, at);
|
|
4236
|
+
if (to) moved.push({
|
|
4237
|
+
from,
|
|
4238
|
+
to
|
|
4239
|
+
});
|
|
4240
|
+
};
|
|
3829
4241
|
let discardStub = false;
|
|
3830
4242
|
let claimedName;
|
|
4243
|
+
const takeEnrollment = () => {
|
|
4244
|
+
try {
|
|
4245
|
+
releaseEnrollment = acquireListenLock(join(directory, "enrollment"), ENROLLMENT_LOCK_SCOPE);
|
|
4246
|
+
} catch {
|
|
4247
|
+
throw fail("This session's background registration is still running, so this invitation was not claimed; wait a few seconds and rerun the same command.");
|
|
4248
|
+
}
|
|
4249
|
+
};
|
|
3831
4250
|
try {
|
|
3832
4251
|
const path = join(directory, "setup.json");
|
|
3833
4252
|
const value = readMailJson(path);
|
|
3834
4253
|
let state = value === null ? null : parseState(value);
|
|
3835
|
-
if (state && replacesRevoked(state)) {
|
|
4254
|
+
if (state && (replacesRevoked(state) || replacesUnfinished(state))) {
|
|
3836
4255
|
try {
|
|
3837
4256
|
releaseClaim = acquireListenLock(agentProfilesDirectory(params.configDir), "agent-connection-setup");
|
|
3838
4257
|
} catch {
|
|
@@ -3843,8 +4262,19 @@ async function setupAgent(params) {
|
|
|
3843
4262
|
if (state && replacesRevoked(state)) {
|
|
3844
4263
|
archiveRevokedSetup(params.configDir, profileName, new Date(dependencies.now()));
|
|
3845
4264
|
state = null;
|
|
4265
|
+
} else if (state && replacesUnfinished(state)) {
|
|
4266
|
+
if (!params.enrollmentOwned) takeEnrollment();
|
|
4267
|
+
replacedUnfinished = { address: pendingEnrollmentAddress(directory) };
|
|
4268
|
+
setAside(path, new Date(dependencies.now()));
|
|
4269
|
+
retireEnrollment = !params.enrollmentOwned;
|
|
4270
|
+
state = null;
|
|
3846
4271
|
}
|
|
3847
4272
|
}
|
|
4273
|
+
if (!params.resume && !state && !params.enrollmentOwned && !releaseEnrollment && !holdsCredential(params.configDir, profileName) && existsSync(join(directory, "enrollment", "state.json"))) {
|
|
4274
|
+
takeEnrollment();
|
|
4275
|
+
replacedUnfinished = { address: pendingEnrollmentAddress(directory) };
|
|
4276
|
+
retireEnrollment = true;
|
|
4277
|
+
}
|
|
3848
4278
|
if (state) refuseSetupConflicts(state, params);
|
|
3849
4279
|
if (!state && loadConnectedAgentProfile(params.configDir, profileName)) throw fail("This profile was configured without a setup binding. Use its existing session setup; do not adopt it into another session.");
|
|
3850
4280
|
if (params.resume && !state) throw fail("No resumable setup exists for this profile. Do not adopt another session's identity.");
|
|
@@ -3874,16 +4304,29 @@ async function setupAgent(params) {
|
|
|
3874
4304
|
invitation: params.invitation ?? "",
|
|
3875
4305
|
fetch: params.fetch,
|
|
3876
4306
|
presence: true,
|
|
3877
|
-
claimLockHeld: releaseClaim !== void 0
|
|
4307
|
+
claimLockHeld: releaseClaim !== void 0,
|
|
4308
|
+
claimLockWaitMs: params.claimLockWaitMs ?? CLAIM_LOCK_WAIT_MS
|
|
3878
4309
|
})).name;
|
|
4310
|
+
if (retireEnrollment) moveAside(join(directory, "enrollment", "state.json"), new Date(dependencies.now()));
|
|
4311
|
+
if (replacedUnfinished?.address) appendRegistrationLog(params.configDir, profileName, {
|
|
4312
|
+
event: "replaced_unfinished",
|
|
4313
|
+
phase: null,
|
|
4314
|
+
code: null,
|
|
4315
|
+
orgId: null,
|
|
4316
|
+
address: replacedUnfinished.address,
|
|
4317
|
+
detail: "A pasted setup instruction finished this session's unfinished registration."
|
|
4318
|
+
});
|
|
3879
4319
|
} catch (error) {
|
|
3880
|
-
if (error instanceof AgentInvitationRejectedError && created) {
|
|
4320
|
+
if ((error instanceof AgentInvitationRejectedError || error instanceof ClaimNotSubmittedError) && created) {
|
|
3881
4321
|
discardStub = true;
|
|
3882
4322
|
try {
|
|
3883
4323
|
removeMailFile(path);
|
|
3884
4324
|
} catch {
|
|
3885
4325
|
discardStub = false;
|
|
3886
4326
|
}
|
|
4327
|
+
if (discardStub) for (const { from, to } of moved.splice(0).reverse()) try {
|
|
4328
|
+
renameSync(to, from);
|
|
4329
|
+
} catch {}
|
|
3887
4330
|
}
|
|
3888
4331
|
throw error;
|
|
3889
4332
|
} finally {
|
|
@@ -3892,7 +4335,8 @@ async function setupAgent(params) {
|
|
|
3892
4335
|
}
|
|
3893
4336
|
}
|
|
3894
4337
|
const profile = loadConnectedAgentProfile(params.configDir, profileName);
|
|
3895
|
-
if (!profile
|
|
4338
|
+
if (!profile) throw fail(unfinishedSetupStep(pendingEnrollmentAddress(directory)));
|
|
4339
|
+
if (!state || profile.invitation_hash !== state.invitationHash) throw fail("The claim has no matching saved credential. Preserve private state and request a fresh invitation; do not replay this one.");
|
|
3896
4340
|
const setupBudgetMs = Math.max(9e4, timeoutMs + 3e4);
|
|
3897
4341
|
const readBudget = {
|
|
3898
4342
|
deadline: dependencies.now() + setupBudgetMs,
|
|
@@ -3923,6 +4367,10 @@ async function setupAgent(params) {
|
|
|
3923
4367
|
claimedName ??= savedConnectionName(params.configDir, profileName);
|
|
3924
4368
|
const result = (receiving, ownerNotifications) => ({
|
|
3925
4369
|
identity: context.identity,
|
|
4370
|
+
...replacedUnfinished ? { replacedUnfinished: {
|
|
4371
|
+
address: replacedUnfinished.address,
|
|
4372
|
+
sameAddress: replacedUnfinished.address === null ? null : replacedUnfinished.address.toLowerCase() === context.identity.agentAddress.toLowerCase()
|
|
4373
|
+
} } : {},
|
|
3926
4374
|
...claimedName === void 0 ? {} : { connectionName: claimedName },
|
|
3927
4375
|
sessionId: params.session ?? state?.session ?? null,
|
|
3928
4376
|
verification: {
|
|
@@ -3979,6 +4427,7 @@ async function setupAgent(params) {
|
|
|
3979
4427
|
return result(state.receiverMode === "external" ? EXTERNAL_HOOKS_PENDING : state.receiverMode === "poll" ? "poll" : healthy ? "healthy" : "not_ready", ownerNotifications);
|
|
3980
4428
|
} finally {
|
|
3981
4429
|
releaseClaim?.();
|
|
4430
|
+
releaseEnrollment?.();
|
|
3982
4431
|
release();
|
|
3983
4432
|
if (discardStub) removeEmptyDirectory(directory);
|
|
3984
4433
|
}
|
|
@@ -4209,6 +4658,11 @@ async function seedAgentInfoNote(configDir, profileName, value, fetchImpl) {
|
|
|
4209
4658
|
return "failed";
|
|
4210
4659
|
}
|
|
4211
4660
|
}
|
|
4661
|
+
/** What a claim that replaced an unfinished setup did to its agent. */
|
|
4662
|
+
function unfinishedNote(replaced) {
|
|
4663
|
+
if (!replaced?.address) return "";
|
|
4664
|
+
return replaced.sameAddress ? `This finished the session's unfinished agent ${replaced.address}. ` : `This invitation was for a different address, so the session's earlier unfinished agent ${replaced.address} stays unconnected in the Primitive app; tell the owner so they can remove it there if it is not needed. `;
|
|
4665
|
+
}
|
|
4212
4666
|
/**
|
|
4213
4667
|
* One call: self check, install the matching skill, claim and verify, start
|
|
4214
4668
|
* receiving, seed AGENT_INFO, and report everything as one result. Every step
|
|
@@ -4419,13 +4873,14 @@ async function runAgentConnect(options) {
|
|
|
4419
4873
|
verification: result.verification,
|
|
4420
4874
|
receiving,
|
|
4421
4875
|
...result.ownerNotifications ? { ownerNotifications: result.ownerNotifications } : {},
|
|
4876
|
+
...result.replacedUnfinished ? { replacedUnfinished: result.replacedUnfinished } : {},
|
|
4422
4877
|
agentInfo: agentInfoState,
|
|
4423
4878
|
resumeNote,
|
|
4424
4879
|
skipped,
|
|
4425
4880
|
warnings,
|
|
4426
4881
|
selectProfile: `PRIMITIVE_AGENT_PROFILE=${result.identity.profileName}`,
|
|
4427
4882
|
resumeCommand,
|
|
4428
|
-
guidance: `${result.guidance} ${ownerReportGuidance(identity)}`,
|
|
4883
|
+
guidance: `${unfinishedNote(result.replacedUnfinished)}${result.guidance} ${ownerReportGuidance(identity)}`,
|
|
4429
4884
|
identity,
|
|
4430
4885
|
externalHook
|
|
4431
4886
|
};
|
|
@@ -4437,7 +4892,23 @@ const CONNECTION_LIST_LIMIT = 131072;
|
|
|
4437
4892
|
const CONFIRMATION_BUDGET_MS = 12e4;
|
|
4438
4893
|
const CONFIRMATION_MAX_ATTEMPTS = 40;
|
|
4439
4894
|
const CONFIRMATION_PAGE_LIMIT = 20;
|
|
4895
|
+
/** How long a member-login refresh waits out another credential operation. */
|
|
4896
|
+
const CREDENTIAL_LOCK_WAIT_MS = 15e3;
|
|
4897
|
+
/** Fresh invitations a stuck enrollment may issue for itself, in total. */
|
|
4898
|
+
const HEAL_MAX_ATTEMPTS = 3;
|
|
4440
4899
|
const refuse = (message) => new AgentConnectionSetupError(message);
|
|
4900
|
+
/**
|
|
4901
|
+
* The one next step for a session whose agent exists but never connected.
|
|
4902
|
+
* Copying the setup instruction again in the app issues a fresh invitation
|
|
4903
|
+
* for the same address, and `agent connect` claims it into this session.
|
|
4904
|
+
*/
|
|
4905
|
+
function stuckEnrollmentStep(address) {
|
|
4906
|
+
return unfinishedSetupStep(address);
|
|
4907
|
+
}
|
|
4908
|
+
/** The one next step for a session whose agent was disconnected or taken over. */
|
|
4909
|
+
function reconnectStep(address, why) {
|
|
4910
|
+
return `This session's agent ${address} ${why}, so this machine did not change it: in the Primitive app open it and choose Reconnect, then paste that setup instruction here; \`agent connect\` finishes the same address.`;
|
|
4911
|
+
}
|
|
4441
4912
|
function plainRecord(value) {
|
|
4442
4913
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
4443
4914
|
}
|
|
@@ -4632,15 +5103,14 @@ async function enableAgentContactRequests(options) {
|
|
|
4632
5103
|
}
|
|
4633
5104
|
async function readOwnerConnection(enrollment, params, deadline, listed) {
|
|
4634
5105
|
const saved = loadCliCredentials(params.configDir);
|
|
4635
|
-
if (!saved || saved.org_id !== enrollment.orgId || saved.oauth_grant_id !== enrollment.grantId || saved.api_base_url !== enrollment.apiBaseUrl)
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
if (credentials.org_id !== enrollment.orgId || credentials.oauth_grant_id !== enrollment.grantId || credentials.api_base_url !== enrollment.apiBaseUrl) throw refuse("The saved member login changed before connection confirmation. Preserve this session's profile and resume with the original member login.");
|
|
5106
|
+
if (!saved || saved.org_id !== enrollment.orgId || saved.oauth_grant_id !== enrollment.grantId || saved.api_base_url !== enrollment.apiBaseUrl) return "unavailable";
|
|
5107
|
+
let credentials;
|
|
5108
|
+
try {
|
|
5109
|
+
credentials = await refreshMemberLogin(enrollment.apiBaseUrl, saved, params);
|
|
5110
|
+
} catch {
|
|
5111
|
+
return "unavailable";
|
|
5112
|
+
}
|
|
5113
|
+
if (credentials.org_id !== enrollment.orgId || credentials.oauth_grant_id !== enrollment.grantId || credentials.api_base_url !== enrollment.apiBaseUrl) return "unavailable";
|
|
4644
5114
|
let cursor;
|
|
4645
5115
|
for (let page = 0; page < CONFIRMATION_PAGE_LIMIT; page++) {
|
|
4646
5116
|
const remaining = deadline - Date.now();
|
|
@@ -4672,7 +5142,7 @@ async function readOwnerConnection(enrollment, params, deadline, listed) {
|
|
|
4672
5142
|
for (const candidate of envelope.data) {
|
|
4673
5143
|
const connection = plainRecord(candidate);
|
|
4674
5144
|
if (connection?.address !== enrollment.address) continue;
|
|
4675
|
-
if (connection.owner_address !== enrollment.ownerAddress) throw refuse(
|
|
5145
|
+
if (connection.owner_address !== enrollment.ownerAddress) throw refuse(`The owner connection list names a different control address for ${enrollment.address}, so this machine changed nothing: in the Primitive app open it and choose Reconnect, then paste that setup instruction here.`);
|
|
4676
5146
|
if (typeof connection.name === "string") listed.name = connection.name;
|
|
4677
5147
|
if (connection.owner_active === false) return "owner_inactive";
|
|
4678
5148
|
if (connection.status === "revoked") return "revoked";
|
|
@@ -4716,6 +5186,171 @@ function enrollmentResumes(configDir, session) {
|
|
|
4716
5186
|
return false;
|
|
4717
5187
|
}
|
|
4718
5188
|
}
|
|
5189
|
+
function credentialLockBusy(error) {
|
|
5190
|
+
return error instanceof Error && error.message.includes("credential operation is already in progress");
|
|
5191
|
+
}
|
|
5192
|
+
/**
|
|
5193
|
+
* Refreshes the saved member login, waiting out another credential
|
|
5194
|
+
* operation (a login or logout in another terminal) instead of failing on
|
|
5195
|
+
* its lock. The caller compares the returned login with the one it expects.
|
|
5196
|
+
*/
|
|
5197
|
+
async function refreshMemberLogin(apiBaseUrl, saved, params) {
|
|
5198
|
+
const deadline = Date.now() + (params.credentialLockWaitMs ?? CREDENTIAL_LOCK_WAIT_MS);
|
|
5199
|
+
for (;;) try {
|
|
5200
|
+
return await refreshStoredCliCredentials({
|
|
5201
|
+
apiBaseUrl,
|
|
5202
|
+
configDir: params.configDir,
|
|
5203
|
+
credentials: saved,
|
|
5204
|
+
fetch: params.fetch,
|
|
5205
|
+
now: params.now
|
|
5206
|
+
});
|
|
5207
|
+
} catch (error) {
|
|
5208
|
+
if (!credentialLockBusy(error) || Date.now() >= deadline) throw error;
|
|
5209
|
+
await setTimeout$1(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
/**
|
|
5213
|
+
* One connection the signed-in member personally owns, read page by page
|
|
5214
|
+
* from their own list (`owner=self`), so a login that does not own the
|
|
5215
|
+
* agent never finds it.
|
|
5216
|
+
*/
|
|
5217
|
+
async function findOwnedConnection(client, address) {
|
|
5218
|
+
let cursor;
|
|
5219
|
+
for (let page = 0; page < CONFIRMATION_PAGE_LIMIT; page++) {
|
|
5220
|
+
let value;
|
|
5221
|
+
try {
|
|
5222
|
+
const response = await listAgentConnections({
|
|
5223
|
+
client: client.client,
|
|
5224
|
+
query: {
|
|
5225
|
+
limit: 50,
|
|
5226
|
+
owner: "self",
|
|
5227
|
+
...cursor ? { cursor } : {}
|
|
5228
|
+
},
|
|
5229
|
+
signal: AbortSignal.timeout(1e4),
|
|
5230
|
+
responseStyle: "fields"
|
|
5231
|
+
});
|
|
5232
|
+
if (response.error) return "unavailable";
|
|
5233
|
+
value = response.data;
|
|
5234
|
+
} catch {
|
|
5235
|
+
return "unavailable";
|
|
5236
|
+
}
|
|
5237
|
+
const envelope = plainRecord(value);
|
|
5238
|
+
if (envelope?.success !== true || !Array.isArray(envelope.data)) return "unavailable";
|
|
5239
|
+
for (const candidate of envelope.data) {
|
|
5240
|
+
const row = plainRecord(candidate);
|
|
5241
|
+
if (row?.address !== address) continue;
|
|
5242
|
+
return {
|
|
5243
|
+
status: String(row.status),
|
|
5244
|
+
ownerAddress: row.owner_address
|
|
5245
|
+
};
|
|
5246
|
+
}
|
|
5247
|
+
const next = plainRecord(envelope.meta)?.cursor;
|
|
5248
|
+
if (next === null) return "absent";
|
|
5249
|
+
if (typeof next !== "string" || !SESSION_UUID.test(next) || next === cursor) return "unavailable";
|
|
5250
|
+
cursor = next;
|
|
5251
|
+
}
|
|
5252
|
+
return "unavailable";
|
|
5253
|
+
}
|
|
5254
|
+
function healAttempts(path) {
|
|
5255
|
+
try {
|
|
5256
|
+
const saved = readMailJson(path);
|
|
5257
|
+
return saved?.version === 1 && Number.isSafeInteger(saved.attempts) ? Number(saved.attempts) : 0;
|
|
5258
|
+
} catch {
|
|
5259
|
+
return HEAL_MAX_ATTEMPTS;
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
/**
|
|
5263
|
+
* A stuck enrollment: its address exists and its claim never produced a
|
|
5264
|
+
* saved credential (the claim was never sent, was refused, or its response
|
|
5265
|
+
* was lost). With the owner's member login for the same organization, it
|
|
5266
|
+
* asks Primitive for a fresh invitation for the same address and returns it
|
|
5267
|
+
* for this session to claim. It never creates another address and only
|
|
5268
|
+
* heals a pending connection, with a pending-only invitation. Every refusal
|
|
5269
|
+
* names the one next step.
|
|
5270
|
+
*/
|
|
5271
|
+
async function healStuckEnrollment(state, params, saved, directory) {
|
|
5272
|
+
const stuck = stuckEnrollmentStep(state.address);
|
|
5273
|
+
if (saved.org_id !== state.orgId || connectedApiBaseUrl(saved.api_base_url) !== state.apiBaseUrl) throw refuse(stuck);
|
|
5274
|
+
const healPath = join(directory, "heal.json");
|
|
5275
|
+
const attempts = healAttempts(healPath);
|
|
5276
|
+
if (attempts >= HEAL_MAX_ATTEMPTS) throw refuse(stuck);
|
|
5277
|
+
let credentials;
|
|
5278
|
+
try {
|
|
5279
|
+
credentials = await refreshMemberLogin(state.apiBaseUrl, saved, params);
|
|
5280
|
+
} catch (error) {
|
|
5281
|
+
if (credentialLockBusy(error)) throw refuse(`Another Primitive login is still in progress, so this session's agent ${state.address} was not finished yet; rerun this command once that login completes.`);
|
|
5282
|
+
throw refuse(stuck);
|
|
5283
|
+
}
|
|
5284
|
+
if (credentials.org_id !== state.orgId || credentials.api_base_url !== state.apiBaseUrl) throw refuse(stuck);
|
|
5285
|
+
const client = new PrimitiveApiClient({
|
|
5286
|
+
apiKey: credentials.access_token,
|
|
5287
|
+
apiBaseUrl: state.apiBaseUrl,
|
|
5288
|
+
fetch: params.fetch
|
|
5289
|
+
});
|
|
5290
|
+
const row = await findOwnedConnection(client, state.address);
|
|
5291
|
+
if (row === "unavailable") throw refuse(`Primitive could not be reached to finish this session's agent ${state.address}; rerun this command (or start the session again) to retry.`);
|
|
5292
|
+
if (row === "absent" || row.ownerAddress !== state.ownerAddress) throw refuse(stuck);
|
|
5293
|
+
if (row.status === "revoked") throw refuse(reconnectStep(state.address, "was disconnected in the Primitive app"));
|
|
5294
|
+
if (row.status !== "pending") throw refuse(reconnectStep(state.address, row.status === "connected" ? "is connected from somewhere else" : "was claimed by another setup"));
|
|
5295
|
+
const record = (count) => writeMailJson(healPath, {
|
|
5296
|
+
version: 1,
|
|
5297
|
+
attempts: count,
|
|
5298
|
+
lastAttemptAt: new Date((params.now ?? Date.now)()).toISOString()
|
|
5299
|
+
});
|
|
5300
|
+
record(attempts + 1);
|
|
5301
|
+
let claimedElsewhere = false;
|
|
5302
|
+
try {
|
|
5303
|
+
const response = await inviteAgentConnection({
|
|
5304
|
+
client: client.client,
|
|
5305
|
+
path: { address: state.address },
|
|
5306
|
+
body: { pending_only: true },
|
|
5307
|
+
signal: AbortSignal.timeout(25e3),
|
|
5308
|
+
responseStyle: "fields"
|
|
5309
|
+
});
|
|
5310
|
+
if (response.error) {
|
|
5311
|
+
claimedElsewhere = plainRecord(plainRecord(response.error)?.error)?.code === "connection_already_claimed";
|
|
5312
|
+
throw new Error();
|
|
5313
|
+
}
|
|
5314
|
+
const invitation = invitationFrom(response.data, state, (params.now ?? Date.now)());
|
|
5315
|
+
if (invitation.ownerAddress !== state.ownerAddress) throw new Error();
|
|
5316
|
+
return {
|
|
5317
|
+
...invitation,
|
|
5318
|
+
grantId: credentials.oauth_grant_id
|
|
5319
|
+
};
|
|
5320
|
+
} catch {
|
|
5321
|
+
if (claimedElsewhere) throw refuse(reconnectStep(state.address, "was claimed by another setup"));
|
|
5322
|
+
try {
|
|
5323
|
+
record(HEAL_MAX_ATTEMPTS);
|
|
5324
|
+
} catch {}
|
|
5325
|
+
throw refuse(stuck);
|
|
5326
|
+
}
|
|
5327
|
+
}
|
|
5328
|
+
/** A short code for the registration log, from the refusal it explains. */
|
|
5329
|
+
function failureCode(error) {
|
|
5330
|
+
if (!(error instanceof AgentConnectionSetupError)) return "unexpected_error";
|
|
5331
|
+
const text = error.message;
|
|
5332
|
+
if (text.includes("is created but not connected")) return "setup_unfinished";
|
|
5333
|
+
if (text.includes("choose Reconnect")) return "reconnect_needed";
|
|
5334
|
+
if (text.includes("could not be reached")) return "api_unavailable";
|
|
5335
|
+
if (text.includes("login is still in progress")) return "login_in_progress";
|
|
5336
|
+
if (text.includes("Sign in") || text.includes("sign in")) return "login_required";
|
|
5337
|
+
return "refused";
|
|
5338
|
+
}
|
|
5339
|
+
/**
|
|
5340
|
+
* The address this session's enrollment reserved when it has no saved
|
|
5341
|
+
* credential yet, so a refusal can name the step that finishes it. Null when
|
|
5342
|
+
* there is no such enrollment. Offline and read-only.
|
|
5343
|
+
*/
|
|
5344
|
+
function unfinishedEnrollmentAddress(configDir, session) {
|
|
5345
|
+
const profile = `session-${session}`;
|
|
5346
|
+
try {
|
|
5347
|
+
if (loadConnectedAgentProfile(configDir, profile)) return null;
|
|
5348
|
+
const raw = readMailJson(join(agentProfileDirectory(configDir, profile), "enrollment", "state.json"));
|
|
5349
|
+
return raw === null ? null : savedEnrollment(raw).address;
|
|
5350
|
+
} catch {
|
|
5351
|
+
return null;
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
4719
5354
|
/** Trusted-machine enrollment using only saved member OAuth and the public API. */
|
|
4720
5355
|
async function enrollAgent(params) {
|
|
4721
5356
|
if (!SESSION_UUID.test(params.session)) throw refuse("Enrollment requires the exact current session UUID.");
|
|
@@ -4726,24 +5361,58 @@ async function enrollAgent(params) {
|
|
|
4726
5361
|
const env = params.env ?? process.env;
|
|
4727
5362
|
if (env["PRIMITIVE_AGENT_PROFILE"]?.trim() || env.PRIMITIVE_API_KEY?.trim() || env.PRIMITIVE_KEY?.trim()) throw refuse("Enrollment uses only the saved member login; rerun it under `env -u PRIMITIVE_AGENT_PROFILE -u PRIMITIVE_API_KEY -u PRIMITIVE_KEY` so no agent profile or API key override applies.");
|
|
4728
5363
|
const saved = loadCliCredentials(params.configDir);
|
|
4729
|
-
if (!saved)
|
|
5364
|
+
if (!saved) {
|
|
5365
|
+
const pending = unfinishedEnrollmentAddress(params.configDir, params.session);
|
|
5366
|
+
throw refuse(pending ? stuckEnrollmentStep(pending) : "Sign in with `primitive signin` as a member of this organization first.");
|
|
5367
|
+
}
|
|
4730
5368
|
const apiBaseUrl = connectedApiBaseUrl(saved.api_base_url);
|
|
4731
5369
|
const profile = `session-${params.session}`;
|
|
4732
5370
|
const directory = join(agentProfileDirectory(params.configDir, profile), "enrollment");
|
|
4733
5371
|
privateMailDirectory(directory, true);
|
|
4734
|
-
const release = acquireListenLock(directory,
|
|
5372
|
+
const release = acquireListenLock(directory, ENROLLMENT_LOCK_SCOPE);
|
|
5373
|
+
const progress = {
|
|
5374
|
+
phase: null,
|
|
5375
|
+
orgId: saved.org_id,
|
|
5376
|
+
address: null
|
|
5377
|
+
};
|
|
5378
|
+
const log = (event, extra = {}) => appendRegistrationLog(params.configDir, profile, {
|
|
5379
|
+
event,
|
|
5380
|
+
phase: progress.phase,
|
|
5381
|
+
code: extra.code ?? null,
|
|
5382
|
+
orgId: progress.orgId,
|
|
5383
|
+
address: progress.address,
|
|
5384
|
+
detail: extra.detail ?? null
|
|
5385
|
+
});
|
|
5386
|
+
const track = (current) => {
|
|
5387
|
+
if (!current) return;
|
|
5388
|
+
progress.phase = current.phase;
|
|
5389
|
+
progress.orgId = current.orgId;
|
|
5390
|
+
progress.address = current.address;
|
|
5391
|
+
};
|
|
4735
5392
|
try {
|
|
4736
5393
|
await (params.preflight ?? ((session, receiver) => preflight(session, receiver, env)))(params.session, receiverMode);
|
|
4737
5394
|
await params.beforeCreate?.();
|
|
4738
5395
|
const path = join(directory, "state.json");
|
|
4739
5396
|
const old = readMailJson(path);
|
|
4740
5397
|
let state = old === null ? null : savedEnrollment(old);
|
|
5398
|
+
track(state);
|
|
5399
|
+
log("started");
|
|
4741
5400
|
if (state && params.name === void 0) name = state.name;
|
|
4742
|
-
if (state && (state.session !== params.session || state.profile !== profile || state.name !== name || state.
|
|
4743
|
-
if (state && Date.parse(state.startedAt) > (params.now ?? Date.now)() + 6e4) throw refuse("Saved enrollment time is inconsistent. Preserve its private files.");
|
|
5401
|
+
if (state && (state.session !== params.session || state.profile !== profile || state.name !== name || state.receiverMode !== receiverMode || state.contactRequests !== contactRequests)) throw refuse("This session's enrollment was started with different setup choices (name, receiver or contact requests); rerun it with the original options. Nothing was changed.");
|
|
4744
5402
|
const existing = loadConnectedAgentProfile(params.configDir, profile);
|
|
5403
|
+
const resumesClaim = Boolean(existing && state?.phase === "setup_attempted");
|
|
5404
|
+
const sameOrganization = state?.orgId === saved.org_id && state.apiBaseUrl === apiBaseUrl;
|
|
5405
|
+
if (state && !sameOrganization && !resumesClaim) throw refuse(state.address && !existing ? stuckEnrollmentStep(state.address) : `This session's enrollment belongs to organization ${state.orgId}, and the saved member login is now for another organization: sign in to ${state.orgId} with \`primitive login\` and rerun this command. Nothing was changed.`);
|
|
5406
|
+
if (state && sameOrganization && state.grantId !== saved.oauth_grant_id) {
|
|
5407
|
+
state = {
|
|
5408
|
+
...state,
|
|
5409
|
+
grantId: saved.oauth_grant_id
|
|
5410
|
+
};
|
|
5411
|
+
writeMailJson(path, state);
|
|
5412
|
+
}
|
|
5413
|
+
if (state && Date.parse(state.startedAt) > (params.now ?? Date.now)() + 6e4) throw refuse("Saved enrollment time is inconsistent. Preserve its private files.");
|
|
4745
5414
|
if (existing && (!state || existing.agent_address !== state.address || existing.org_id !== state.orgId || existing.api_base_url !== state.apiBaseUrl || state.phase === "setup_attempted" && (existing.invitation_hash !== state.invitationHash || existing.owner_address !== state.ownerAddress))) throw refuse("This session profile already belongs to another connection. Nothing was changed.");
|
|
4746
|
-
const
|
|
5415
|
+
const finishSetup = async (enrollment, resume, invitation) => {
|
|
4747
5416
|
const setup = await (params.setup ?? setupAgent)({
|
|
4748
5417
|
configDir: params.configDir,
|
|
4749
5418
|
profileName: profile,
|
|
@@ -4751,6 +5420,7 @@ async function enrollAgent(params) {
|
|
|
4751
5420
|
receiverMode,
|
|
4752
5421
|
...resume ? { resume: true } : { invitation },
|
|
4753
5422
|
contactRequests,
|
|
5423
|
+
enrollmentOwned: true,
|
|
4754
5424
|
fetch: params.fetch
|
|
4755
5425
|
});
|
|
4756
5426
|
const ownerMemberAddress = await refreshOwnerMemberAddress({
|
|
@@ -4796,23 +5466,33 @@ async function enrollAgent(params) {
|
|
|
4796
5466
|
name: current.name ?? name
|
|
4797
5467
|
};
|
|
4798
5468
|
};
|
|
5469
|
+
const finish = async (enrollment, resume, invitation) => {
|
|
5470
|
+
const value = await finishSetup(enrollment, resume, invitation);
|
|
5471
|
+
log("finished", { code: value.connection.status });
|
|
5472
|
+
return value;
|
|
5473
|
+
};
|
|
4799
5474
|
if (state?.phase === "setup_attempted") {
|
|
4800
|
-
if (
|
|
4801
|
-
|
|
5475
|
+
if (existing) return await finish(resolvedEnrollment(state), true);
|
|
5476
|
+
const resolved = resolvedEnrollment(state);
|
|
5477
|
+
const healed = await healStuckEnrollment(resolved, params, saved, directory);
|
|
5478
|
+
state = {
|
|
5479
|
+
...resolved,
|
|
5480
|
+
grantId: healed.grantId,
|
|
5481
|
+
invitationHash: healed.hash,
|
|
5482
|
+
ownerAddress: healed.ownerAddress
|
|
5483
|
+
};
|
|
5484
|
+
writeMailJson(path, state);
|
|
5485
|
+
track(state);
|
|
5486
|
+
log("heal_invited", { code: "pending_only" });
|
|
5487
|
+
return await finish(resolvedEnrollment(state), false, healed.url);
|
|
4802
5488
|
}
|
|
4803
5489
|
if (existing) throw refuse("This profile is already connected without a matching enrollment. Nothing was changed.");
|
|
4804
|
-
if (state?.version === 1) throw refuse(
|
|
4805
|
-
const credentials = await
|
|
4806
|
-
apiBaseUrl,
|
|
4807
|
-
configDir: params.configDir,
|
|
4808
|
-
credentials: saved,
|
|
4809
|
-
fetch: params.fetch,
|
|
4810
|
-
now: params.now
|
|
4811
|
-
});
|
|
5490
|
+
if (state?.version === 1) throw refuse(stuckEnrollmentStep(resolvedEnrollment(state).address));
|
|
5491
|
+
const credentials = await refreshMemberLogin(apiBaseUrl, saved, params);
|
|
4812
5492
|
if (credentials.org_id !== saved.org_id || credentials.oauth_grant_id !== saved.oauth_grant_id || credentials.api_base_url !== apiBaseUrl) throw refuse("The saved member login changed during enrollment. No address was created. Sign in to the intended organization and retry this exact session.");
|
|
4813
5493
|
const fetchImpl = params.fetch ?? fetch;
|
|
4814
5494
|
const firstAttempt = state === null;
|
|
4815
|
-
if (state?.continueAttempted) throw refuse(
|
|
5495
|
+
if (state?.continueAttempted) throw refuse(stuckEnrollmentStep(resolvedEnrollment(state).address));
|
|
4816
5496
|
if (!state) {
|
|
4817
5497
|
if (params.continueSetup) throw refuse("There is no saved enrollment to continue. Start enrollment normally first.");
|
|
4818
5498
|
if (!(await managedDomains(new PrimitiveApiClient({
|
|
@@ -4894,9 +5574,11 @@ async function enrollAgent(params) {
|
|
|
4894
5574
|
address: connection.address
|
|
4895
5575
|
};
|
|
4896
5576
|
writeMailJson(path, state);
|
|
5577
|
+
track(state);
|
|
5578
|
+
log("created");
|
|
4897
5579
|
if (data?.recovered === true) {
|
|
4898
5580
|
if (data.invitation !== null) throw refuse("Recovered setup unexpectedly included an invitation. Preserve the saved identity; no invitation was claimed.");
|
|
4899
|
-
if (connection.status !== "pending") throw refuse("
|
|
5581
|
+
if (connection.status !== "pending") throw refuse(reconnectStep(connection.address, connection.status === "revoked" ? "was disconnected in the Primitive app and its credential was not changed" : "was already claimed and its credential was not changed"));
|
|
4900
5582
|
if (!params.continueSetup) throw refuse(`Recovered ${state.address}. Run this exact command with --continue-setup to explicitly obtain one pending-only invitation.`);
|
|
4901
5583
|
state = {
|
|
4902
5584
|
...state,
|
|
@@ -4916,13 +5598,13 @@ async function enrollAgent(params) {
|
|
|
4916
5598
|
body: JSON.stringify({ pending_only: true })
|
|
4917
5599
|
});
|
|
4918
5600
|
if (response.status !== 200) {
|
|
4919
|
-
if (plainRecord(plainRecord(await boundedJson(response, "pending setup continuation"))?.error)?.code === "connection_already_claimed") throw refuse("
|
|
5601
|
+
if (plainRecord(plainRecord(await boundedJson(response, "pending setup continuation"))?.error)?.code === "connection_already_claimed") throw refuse(reconnectStep(connection.address, "was claimed before Continue setup and its credential was not changed"));
|
|
4920
5602
|
throw new Error();
|
|
4921
5603
|
}
|
|
4922
5604
|
payload = await boundedJson(response);
|
|
4923
5605
|
} catch (error) {
|
|
4924
5606
|
if (error instanceof AgentConnectionSetupError) throw error;
|
|
4925
|
-
throw refuse(
|
|
5607
|
+
throw refuse(`Continue setup has an uncertain outcome, so it is not repeated automatically. ${stuckEnrollmentStep(connection.address)}`);
|
|
4926
5608
|
}
|
|
4927
5609
|
}
|
|
4928
5610
|
const invitation = invitationFrom(payload, state, (params.now ?? Date.now)());
|
|
@@ -4933,7 +5615,21 @@ async function enrollAgent(params) {
|
|
|
4933
5615
|
ownerAddress: invitation.ownerAddress
|
|
4934
5616
|
};
|
|
4935
5617
|
writeMailJson(path, state);
|
|
4936
|
-
|
|
5618
|
+
track(state);
|
|
5619
|
+
let current = null;
|
|
5620
|
+
try {
|
|
5621
|
+
current = loadCliCredentials(params.configDir);
|
|
5622
|
+
} catch {
|
|
5623
|
+
current = null;
|
|
5624
|
+
}
|
|
5625
|
+
log("setup_attempted", { code: current?.org_id === state.orgId && current.oauth_grant_id === state.grantId ? null : "login_changed" });
|
|
5626
|
+
return await finish(resolvedEnrollment(state), false, invitation.url);
|
|
5627
|
+
} catch (error) {
|
|
5628
|
+
log("failed", {
|
|
5629
|
+
code: failureCode(error),
|
|
5630
|
+
detail: error instanceof AgentConnectionSetupError ? error.message : "Registration stopped on an unexpected error."
|
|
5631
|
+
});
|
|
5632
|
+
throw error;
|
|
4937
5633
|
} finally {
|
|
4938
5634
|
release();
|
|
4939
5635
|
}
|
|
@@ -5604,11 +6300,15 @@ async function registerSession(options) {
|
|
|
5604
6300
|
} catch {
|
|
5605
6301
|
credentials = null;
|
|
5606
6302
|
}
|
|
5607
|
-
if (!credentials)
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
6303
|
+
if (!credentials) {
|
|
6304
|
+
const pending = unfinishedEnrollmentAddress(options.configDir, sessionId);
|
|
6305
|
+
return {
|
|
6306
|
+
...known,
|
|
6307
|
+
...pending ? { address: pending } : {},
|
|
6308
|
+
status: "not_logged_in",
|
|
6309
|
+
detail: pending ? stuckEnrollmentStep(pending) : "No saved member login on this machine. Run `primitive signin`, then start the session again."
|
|
6310
|
+
};
|
|
6311
|
+
}
|
|
5612
6312
|
record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("session-register", null));
|
|
5613
6313
|
if (!record) throw new Error("session record unavailable");
|
|
5614
6314
|
if (record.endedAt) return await ended(record);
|
|
@@ -6072,4 +6772,4 @@ async function runDetached(params) {
|
|
|
6072
6772
|
}
|
|
6073
6773
|
}
|
|
6074
6774
|
//#endregion
|
|
6075
|
-
export {
|
|
6775
|
+
export { saveConnectionName as $, ContactPolicyReadRetryError as A, AddressNotesApiError as B, RECEIVER_MODES as C, upsertManagedBlock as Ct, registrationSummary as D, refreshOwnerMemberAddressPeriodically as Dt, failedSessionRegistration as E, addSessionReceiver as Et, canonicalContactSelector as F, contactAgentAddress as G, runAddressNotesRequest as H, AgentDisconnectError as I, agentInvitationHash as J, runContactRequest as K, claimOverRevokedSetup as L, NetworkAdmissionPendingError as M, createNotificationContactPolicy as N, registrationSummaryLine as O, withOwnerMemberAddress as Ot, evaluateContactPolicy as P, refusalAfterReplacement as Q, clearRevokedAddresses as R, runAgentConnect as S, readManagedFile as St, verificationReplySubmitted as T, readHookLauncherWarning as Tt, ContactsApiError as U, addressNoteTarget as V, contactAddress as W, readAgentInvitation as X, connectAgent as Y, receiverStatusCommand as Z, enrollmentResumes as _, samePath as _t, endSession as a, boundSessionProfiles as at, invitationProfileName as b, inspectManagedBlock as bt, pendingSessionDisconnects as c, describeSessionHookChange as ct, registerSession as d, ephemeralCliReason as dt, savedReceiver as et, retryPendingDisconnects as f, findOnPath as ft, enrollAgent as g, repairClaudeHooks as gt, staleSessionAgents as h, readClaudeSettings as ht, connectedProfilesForSession as i, recordHookMailCheck as it, NETWORK_ADMISSION_PENDING_RETRY_MS as j, waitForRegistrations as k, psProcessInfo as l, describeSessionHookItem as lt, runDetached as m, inspectClaudeHooks as mt, OMP_MAIN as n, releaseRegistrationsForProfile as nt, headlessClaudeRun as o, claudeConfigDir as ot, revokeStaleSessionAgents as p, hasMachineSessionHooks as pt, agentConnectionStatus as q, agentConnectionState as r, installClaudeWakeHook as rt, machine_session_exports as s, currentCliLocation as st, HOOK_END_GRACE_MS as t, MACHINE_RUNTIMES as tt, readClaudeHookInput as u, ensureHookLauncher as ut, connectWarnings as v, writeClaudeSettings as vt, externalReceivingState as w, writeManagedFile as wt, pollCheckCommand as x, jsonIndentation as xt, defaultAgentProfileName as y, MachineFileError as yt, disconnectAgent as z };
|