primcli 1.43.0 → 1.45.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 CHANGED
@@ -923,6 +923,7 @@ session on this machine register itself and answer Primitive mail, and
923
923
  primitive machine doctor --json
924
924
  primitive machine doctor --fix --json
925
925
  primitive machine doctor --fix --check claude.hook.stop --json
926
+ primitive machine doctor --fix --check claude.hook.stop --profile my-agent
926
927
  ```
927
928
 
928
929
  Each check reports `{id, title, status, detail, fixable, fixed?, action?, path?}`
@@ -942,6 +943,17 @@ content) and `~/.omp/agent/AGENTS.md`, the bundled primitive-connect skill, and
942
943
  saved agent profiles that were disconnected in Primitive (moved aside locally;
943
944
  nothing changes server side).
944
945
 
946
+ `claude.hook.stop` names every per-session receive hook a repair would change
947
+ in `items: [{profile, session, hook, state}]`, where `hook` is the Claude event
948
+ (`Stop`, `SessionStart` or `PostToolUse`) and `state` is `missing`, `stale`,
949
+ `duplicate`, `outdated` or `old_address`; `profile` and `session` are null for
950
+ hooks written by old CLI versions that did not record them. After `--fix` it
951
+ also reports `changes`, the same fields plus `action` (`added`, `removed` or
952
+ `updated`), for each hook it changed. `--profile <name>` (repeatable, with
953
+ `--fix`) limits those per-session hook changes to the named profiles; combine it
954
+ with `--check claude.hook.stop` so nothing else is repaired. With `--profile`,
955
+ each item also carries `selected: false` when this run leaves it unchanged.
956
+
945
957
  Repairs only touch Primitive-owned hooks, blocks and skill copies. Any existing
946
958
  file is backed up beside itself as `<file>.primitive-bak-<timestamp>` before it
947
959
  changes, writes are atomic, and a second `--fix` with nothing drifted changes
@@ -13069,6 +13069,38 @@ const openapiDocument = {
13069
13069
  "attachments_download_available": {
13070
13070
  "type": "boolean",
13071
13071
  "description": "Whether an inline attachment archive has been successfully retained and is available for download. Download authorization is checked separately; address-bound agent connection keys cannot download archives."
13072
+ },
13073
+ "to_addresses": {
13074
+ "type": ["array", "null"],
13075
+ "items": { "type": "string" },
13076
+ "description": "Every normalized To recipient of the send. Always populated on\nnew sends (a single-recipient send carries a one-element array);\nnull only on records that predate this field. `to_address` keeps\ncarrying the first recipient.\n"
13077
+ },
13078
+ "cc": {
13079
+ "type": ["array", "null"],
13080
+ "items": { "type": "string" },
13081
+ "description": "Normalized Cc address list. Null when the send had no Cc\nrecipients.\n"
13082
+ },
13083
+ "bcc": {
13084
+ "type": ["array", "null"],
13085
+ "items": { "type": "string" },
13086
+ "description": "Normalized Bcc address list, surfaced on the sending\norganization's own record. Bcc recipients are never written\ninto the transmitted message. Null when the send had no Bcc\nrecipients. Also null when the request uses an address-bound\nagent connection key whose address is not the sender, because\nBcc is withheld from recipients.\n"
13087
+ },
13088
+ "reply_to": {
13089
+ "type": ["array", "null"],
13090
+ "items": { "type": "string" },
13091
+ "description": "Normalized Reply-To address list. Null when the send had none.\n"
13092
+ },
13093
+ "tags": {
13094
+ "type": ["array", "null"],
13095
+ "description": "Customer metadata tags stored verbatim at send time. Null when\nthe send had none. Also null when the request uses an\naddress-bound agent connection key whose address is not the\nsender, because tags are withheld from recipients.\n",
13096
+ "items": {
13097
+ "type": "object",
13098
+ "properties": {
13099
+ "name": { "type": "string" },
13100
+ "value": { "type": "string" }
13101
+ },
13102
+ "required": ["name", "value"]
13103
+ }
13072
13104
  }
13073
13105
  }
13074
13106
  }],
@@ -31319,6 +31351,38 @@ const operationManifest = [
31319
31351
  "attachments_download_available": {
31320
31352
  "type": "boolean",
31321
31353
  "description": "Whether an inline attachment archive has been successfully retained and is available for download. Download authorization is checked separately; address-bound agent connection keys cannot download archives."
31354
+ },
31355
+ "to_addresses": {
31356
+ "type": ["array", "null"],
31357
+ "items": { "type": "string" },
31358
+ "description": "Every normalized To recipient of the send. Always populated on\nnew sends (a single-recipient send carries a one-element array);\nnull only on records that predate this field. `to_address` keeps\ncarrying the first recipient.\n"
31359
+ },
31360
+ "cc": {
31361
+ "type": ["array", "null"],
31362
+ "items": { "type": "string" },
31363
+ "description": "Normalized Cc address list. Null when the send had no Cc\nrecipients.\n"
31364
+ },
31365
+ "bcc": {
31366
+ "type": ["array", "null"],
31367
+ "items": { "type": "string" },
31368
+ "description": "Normalized Bcc address list, surfaced on the sending\norganization's own record. Bcc recipients are never written\ninto the transmitted message. Null when the send had no Bcc\nrecipients. Also null when the request uses an address-bound\nagent connection key whose address is not the sender, because\nBcc is withheld from recipients.\n"
31369
+ },
31370
+ "reply_to": {
31371
+ "type": ["array", "null"],
31372
+ "items": { "type": "string" },
31373
+ "description": "Normalized Reply-To address list. Null when the send had none.\n"
31374
+ },
31375
+ "tags": {
31376
+ "type": ["array", "null"],
31377
+ "description": "Customer metadata tags stored verbatim at send time. Null when\nthe send had none. Also null when the request uses an\naddress-bound agent connection key whose address is not the\nsender, because tags are withheld from recipients.\n",
31378
+ "items": {
31379
+ "type": "object",
31380
+ "properties": {
31381
+ "name": { "type": "string" },
31382
+ "value": { "type": "string" }
31383
+ },
31384
+ "required": ["name", "value"]
31385
+ }
31322
31386
  }
31323
31387
  }
31324
31388
  }],
@@ -32022,6 +32086,38 @@ const operationManifest = [
32022
32086
  "attachments_download_available": {
32023
32087
  "type": "boolean",
32024
32088
  "description": "Whether an inline attachment archive has been successfully retained and is available for download. Download authorization is checked separately; address-bound agent connection keys cannot download archives."
32089
+ },
32090
+ "to_addresses": {
32091
+ "type": ["array", "null"],
32092
+ "items": { "type": "string" },
32093
+ "description": "Every normalized To recipient of the send. Always populated on\nnew sends (a single-recipient send carries a one-element array);\nnull only on records that predate this field. `to_address` keeps\ncarrying the first recipient.\n"
32094
+ },
32095
+ "cc": {
32096
+ "type": ["array", "null"],
32097
+ "items": { "type": "string" },
32098
+ "description": "Normalized Cc address list. Null when the send had no Cc\nrecipients.\n"
32099
+ },
32100
+ "bcc": {
32101
+ "type": ["array", "null"],
32102
+ "items": { "type": "string" },
32103
+ "description": "Normalized Bcc address list, surfaced on the sending\norganization's own record. Bcc recipients are never written\ninto the transmitted message. Null when the send had no Bcc\nrecipients. Also null when the request uses an address-bound\nagent connection key whose address is not the sender, because\nBcc is withheld from recipients.\n"
32104
+ },
32105
+ "reply_to": {
32106
+ "type": ["array", "null"],
32107
+ "items": { "type": "string" },
32108
+ "description": "Normalized Reply-To address list. Null when the send had none.\n"
32109
+ },
32110
+ "tags": {
32111
+ "type": ["array", "null"],
32112
+ "description": "Customer metadata tags stored verbatim at send time. Null when\nthe send had none. Also null when the request uses an\naddress-bound agent connection key whose address is not the\nsender, because tags are withheld from recipients.\n",
32113
+ "items": {
32114
+ "type": "object",
32115
+ "properties": {
32116
+ "name": { "type": "string" },
32117
+ "value": { "type": "string" }
32118
+ },
32119
+ "required": ["name", "value"]
32120
+ }
32025
32121
  }
32026
32122
  }
32027
32123
  }],
@@ -33009,6 +33105,38 @@ const operationManifest = [
33009
33105
  "attachments_download_available": {
33010
33106
  "type": "boolean",
33011
33107
  "description": "Whether an inline attachment archive has been successfully retained and is available for download. Download authorization is checked separately; address-bound agent connection keys cannot download archives."
33108
+ },
33109
+ "to_addresses": {
33110
+ "type": ["array", "null"],
33111
+ "items": { "type": "string" },
33112
+ "description": "Every normalized To recipient of the send. Always populated on\nnew sends (a single-recipient send carries a one-element array);\nnull only on records that predate this field. `to_address` keeps\ncarrying the first recipient.\n"
33113
+ },
33114
+ "cc": {
33115
+ "type": ["array", "null"],
33116
+ "items": { "type": "string" },
33117
+ "description": "Normalized Cc address list. Null when the send had no Cc\nrecipients.\n"
33118
+ },
33119
+ "bcc": {
33120
+ "type": ["array", "null"],
33121
+ "items": { "type": "string" },
33122
+ "description": "Normalized Bcc address list, surfaced on the sending\norganization's own record. Bcc recipients are never written\ninto the transmitted message. Null when the send had no Bcc\nrecipients. Also null when the request uses an address-bound\nagent connection key whose address is not the sender, because\nBcc is withheld from recipients.\n"
33123
+ },
33124
+ "reply_to": {
33125
+ "type": ["array", "null"],
33126
+ "items": { "type": "string" },
33127
+ "description": "Normalized Reply-To address list. Null when the send had none.\n"
33128
+ },
33129
+ "tags": {
33130
+ "type": ["array", "null"],
33131
+ "description": "Customer metadata tags stored verbatim at send time. Null when\nthe send had none. Also null when the request uses an\naddress-bound agent connection key whose address is not the\nsender, because tags are withheld from recipients.\n",
33132
+ "items": {
33133
+ "type": "object",
33134
+ "properties": {
33135
+ "name": { "type": "string" },
33136
+ "value": { "type": "string" }
33137
+ },
33138
+ "required": ["name", "value"]
33139
+ }
33012
33140
  }
33013
33141
  }
33014
33142
  }],
@@ -35617,6 +35745,36 @@ function writeClaudeSettings(params) {
35617
35745
  release();
35618
35746
  }
35619
35747
  }
35748
+ const STATE_PHRASES = {
35749
+ missing: "missing",
35750
+ stale: "session disconnected or removed",
35751
+ duplicate: "duplicate",
35752
+ outdated: "old CLI path",
35753
+ old_address: "old agent address"
35754
+ };
35755
+ /** "profile my-agent, session 1234...": who a session hook serves. */
35756
+ function describeSessionHookOwner(item) {
35757
+ const profile = item.profile ? `profile ${item.profile}` : "a profile an old CLI did not record";
35758
+ return item.session ? `${profile}, session ${item.session}` : profile;
35759
+ }
35760
+ /** One short line naming a hook, its owner and its state. */
35761
+ function describeSessionHookItem(item) {
35762
+ return `${item.hook} hook for ${describeSessionHookOwner(item)}: ${STATE_PHRASES[item.state]}`;
35763
+ }
35764
+ /** One short line naming what a repair did to a hook. */
35765
+ function describeSessionHookChange(change) {
35766
+ return `${change.action === "added" ? "Added" : change.action === "removed" ? "Removed" : "Rewrote"} ${change.hook} hook for ${describeSessionHookOwner(change)} (${STATE_PHRASES[change.state]})`;
35767
+ }
35768
+ /** Group items by owner, at most `limit` owners, for a one-line detail. */
35769
+ function summarizeOwners(items, limit = 3) {
35770
+ const owners = [];
35771
+ for (const item of items) {
35772
+ const owner = describeSessionHookOwner(item);
35773
+ if (!owners.includes(owner)) owners.push(owner);
35774
+ }
35775
+ const shown = owners.slice(0, limit).join("; ");
35776
+ return owners.length > limit ? `${shown}; and ${owners.length - limit} more` : shown;
35777
+ }
35620
35778
  const GLOBAL_HOOKS = {
35621
35779
  "claude.hook.session_start": {
35622
35780
  event: "SessionStart",
@@ -35804,6 +35962,87 @@ function boundSessionProfiles(configDir) {
35804
35962
  } catch {}
35805
35963
  return bound;
35806
35964
  }
35965
+ function boundKey(item) {
35966
+ return [
35967
+ resolve(item.configDir),
35968
+ item.profile,
35969
+ item.session
35970
+ ].join("\0");
35971
+ }
35972
+ /**
35973
+ * Bound profiles whose receive hooks are entirely absent while the same
35974
+ * session receives through another connected profile, or while several profiles are
35975
+ * bound to it and none receives. Such a profile is not restored without
35976
+ * being named: its hooks were removed on purpose, or it would give the
35977
+ * session a second address. A profile that still has any receive hook for
35978
+ * the session, or the only profile bound to a session, is restored as usual.
35979
+ */
35980
+ /**
35981
+ * For each session, the connected profiles it receives as through the
35982
+ * per-session receive hooks in `hooks`, keyed like `boundKey`. A hook for a
35983
+ * disconnected or removed profile is stale (machine doctor removes it), so
35984
+ * the session does not receive through it.
35985
+ */
35986
+ function receivingProfilesBySession(hooks, always) {
35987
+ const receiving = /* @__PURE__ */ new Map();
35988
+ for (const event of SESSION_EVENTS) {
35989
+ const entries = hooks[event];
35990
+ if (!Array.isArray(entries)) continue;
35991
+ for (const entry of entries) {
35992
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) continue;
35993
+ for (const hook of entry.hooks) {
35994
+ const parsed = parseSessionHook(hook);
35995
+ if (!parsed?.profile || !parsed.session) continue;
35996
+ const session = parsed.session.toLowerCase();
35997
+ const key = [
35998
+ resolve(parsed.configDir),
35999
+ parsed.profile,
36000
+ session
36001
+ ].join("\0");
36002
+ if (key !== always && (!existsSync(parsed.configDir) || !profileStillConnected(parsed.configDir, parsed.profile))) continue;
36003
+ const profiles = receiving.get(session) ?? /* @__PURE__ */ new Set();
36004
+ profiles.add(key);
36005
+ receiving.set(session, profiles);
36006
+ }
36007
+ }
36008
+ }
36009
+ return receiving;
36010
+ }
36011
+ /**
36012
+ * The other connected profiles a session already receives as, when `target`
36013
+ * itself has no receive hook for it. Adding `target`'s hooks then would give
36014
+ * the session a second address; an empty list means it would not (the
36015
+ * session receives as nobody else, or `target` already has hooks there and
36016
+ * only needs them repaired). Uses the same rule as machine doctor, so an
36017
+ * automatic install never binds a profile doctor would leave alone.
36018
+ */
36019
+ function otherSessionReceivers(hooks, target) {
36020
+ const session = target.session.toLowerCase();
36021
+ const own = [
36022
+ resolve(target.configDir),
36023
+ target.profile,
36024
+ session
36025
+ ].join("\0");
36026
+ const present = receivingProfilesBySession(hooks, own).get(session) ?? /* @__PURE__ */ new Set();
36027
+ if (present.has(own)) return [];
36028
+ return [...present].map((other) => other.split("\0")[1] ?? "").sort();
36029
+ }
36030
+ function heldSessionProfiles(hooks, bound, restore) {
36031
+ const receiving = receivingProfilesBySession(hooks);
36032
+ const held = /* @__PURE__ */ new Map();
36033
+ for (const item of bound) {
36034
+ const key = boundKey(item);
36035
+ const present = receiving.get(item.session) ?? /* @__PURE__ */ new Set();
36036
+ if (present.has(key) || restore?.has(item.profile)) continue;
36037
+ const others = [...present].map((other) => other.split("\0")[1] ?? "");
36038
+ const siblings = bound.filter((candidate) => candidate.session === item.session);
36039
+ if (others.length > 0 || siblings.length > 1) held.set(key, {
36040
+ item,
36041
+ receivingAs: others.sort()
36042
+ });
36043
+ }
36044
+ return held;
36045
+ }
35807
36046
  /** Receive hooks each bound profile should have and does not. */
35808
36047
  function missingSessionHooks(hooks, bound, node) {
35809
36048
  return bound.flatMap((item) => SESSION_EVENTS.filter((event) => !hasSessionReceiveHook(hooks, event, item, node)).map((event) => ({
@@ -35811,6 +36050,34 @@ function missingSessionHooks(hooks, bound, node) {
35811
36050
  bound: item
35812
36051
  })));
35813
36052
  }
36053
+ /** Every hook judged for repair, in settings order, then every missing one. */
36054
+ function sessionHookItems(hooks, verdicts, missing) {
36055
+ const items = missing.map(({ event, bound }) => ({
36056
+ profile: bound.profile,
36057
+ session: bound.session,
36058
+ hook: event,
36059
+ state: "missing"
36060
+ }));
36061
+ for (const event of SESSION_EVENTS) {
36062
+ const entries = hooks[event];
36063
+ if (!Array.isArray(entries)) continue;
36064
+ for (const entry of entries) {
36065
+ if (!record$4(entry) || !Array.isArray(entry.hooks)) continue;
36066
+ for (const hook of entry.hooks) {
36067
+ const verdict = verdicts.get(hook);
36068
+ const parsed = parseSessionHook(hook);
36069
+ if (!verdict || verdict === "keep" || !parsed) continue;
36070
+ items.push({
36071
+ profile: parsed.profile,
36072
+ session: parsed.session?.toLowerCase() ?? null,
36073
+ hook: event,
36074
+ state: verdict
36075
+ });
36076
+ }
36077
+ }
36078
+ }
36079
+ return items;
36080
+ }
35814
36081
  /** The exact entry `agent connect` and `session-register` install. */
35815
36082
  function sessionHookEntry(event, item, cli) {
35816
36083
  const pending = SESSION_HOOK_KIND[event] === "pending";
@@ -35884,7 +36151,9 @@ function inspectClaudeHooks(settings, context) {
35884
36151
  };
35885
36152
  return findings;
35886
36153
  }
35887
- const bound = context.bound ?? [];
36154
+ const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
36155
+ const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
36156
+ const heldNote = held.size ? ` Left alone: ${[...held.values()].map(({ item, receivingAs }) => `profile ${item.profile} is bound to session ${item.session}, which ${receivingAs.length ? `receives as ${receivingAs.join(", ")}` : "has several bound profiles and receives as none"}`).join("; ")}. Run \`primitive machine doctor --fix --profile <profile>\` to restore one deliberately.` : "";
35888
36157
  const counts = {
35889
36158
  keep: 0,
35890
36159
  stale: 0,
@@ -35892,8 +36161,11 @@ function inspectClaudeHooks(settings, context) {
35892
36161
  outdated: 0,
35893
36162
  old_address: 0
35894
36163
  };
35895
- for (const verdict of judgeSessionHooks(hooks, context.cli, bound).values()) counts[verdict]++;
35896
- const missing = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath).length;
36164
+ const verdicts = judgeSessionHooks(hooks, context.cli, bound);
36165
+ for (const verdict of verdicts.values()) counts[verdict]++;
36166
+ const missingHooks = missingSessionHooks(hooks, bound, context.cli?.node ?? process.execPath);
36167
+ const missing = missingHooks.length;
36168
+ const items = sessionHookItems(hooks, verdicts, missingHooks);
35897
36169
  const problems = [
35898
36170
  missing ? `${missing} missing for connected sessions` : "",
35899
36171
  counts.old_address ? `${counts.old_address} naming an old agent address` : "",
@@ -35903,11 +36175,12 @@ function inspectClaudeHooks(settings, context) {
35903
36175
  ].filter(Boolean);
35904
36176
  findings["claude.hook.stop"] = problems.length ? {
35905
36177
  status: "fail",
35906
- detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}.${blockedNote}`,
35907
- fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null
36178
+ detail: `Per-session receive hooks need cleanup: ${problems.join(", ")}. Affected: ${summarizeOwners(items)}.${blockedNote}${heldNote}`,
36179
+ fixable: canFix || counts.outdated === 0 && missing === 0 && context.blocked === null,
36180
+ items
35908
36181
  } : {
35909
36182
  status: "ok",
35910
- detail: counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks.",
36183
+ detail: `${counts.keep ? `${counts.keep} per-session receive hooks, each present once and current.` : "No per-session receive hooks; each registered session adds its own exact-session hooks."}${heldNote}`,
35911
36184
  fixable: false
35912
36185
  };
35913
36186
  return findings;
@@ -35939,8 +36212,10 @@ function rewriteEntries(entries, transform) {
35939
36212
  * Primitive-owned hooks are added, rewritten or removed; every other key,
35940
36213
  * entry and sibling hook is carried over unchanged.
35941
36214
  */
35942
- function repairClaudeHooks(settings, context, checks) {
36215
+ function repairClaudeHooks(settings, context, checks, options = {}) {
35943
36216
  const changed = /* @__PURE__ */ new Set();
36217
+ const changes = [];
36218
+ const selected = (profile) => !options.profiles || profile !== null && options.profiles.has(profile);
35944
36219
  const hooks = record$4(settings.hooks) ? { ...settings.hooks } : {};
35945
36220
  const cli = context.blocked === null ? context.cli : null;
35946
36221
  for (const [id, spec] of Object.entries(GLOBAL_HOOKS)) {
@@ -35973,19 +36248,38 @@ function repairClaudeHooks(settings, context, checks) {
35973
36248
  }
35974
36249
  if (checks.has("claude.hook.stop") && context.blocked === null) {
35975
36250
  if (!SESSION_EVENTS.some((event) => eventEntries(hooks, event) === "invalid")) {
35976
- const bound = context.bound ?? [];
36251
+ const held = heldSessionProfiles(hooks, context.bound ?? [], context.restoreProfiles);
36252
+ const bound = (context.bound ?? []).filter((item) => !held.has(boundKey(item)));
35977
36253
  const verdicts = judgeSessionHooks(hooks, context.cli, bound);
35978
36254
  for (const event of SESSION_EVENTS) {
35979
36255
  const entries = eventEntries(hooks, event);
35980
36256
  if (entries === "invalid" || entries.length === 0) continue;
35981
36257
  const result = rewriteEntries(entries, (hook) => {
35982
36258
  const verdict = verdicts.get(hook);
35983
- if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") return void 0;
35984
- if (verdict !== "outdated" || !cli || !record$4(hook)) return hook;
35985
36259
  const parsed = parseSessionHook(hook);
36260
+ if (!verdict || verdict === "keep" || !parsed) return hook;
36261
+ if (!selected(parsed.profile)) return hook;
36262
+ const item = {
36263
+ profile: parsed.profile,
36264
+ session: parsed.session?.toLowerCase() ?? null,
36265
+ hook: event,
36266
+ state: verdict
36267
+ };
36268
+ if (verdict === "stale" || verdict === "duplicate" || verdict === "old_address") {
36269
+ changes.push({
36270
+ ...item,
36271
+ action: "removed"
36272
+ });
36273
+ return;
36274
+ }
36275
+ if (!cli || !record$4(hook)) return hook;
35986
36276
  const args = [...hook.args];
35987
- args[0] = parsed?.kind === "pending" ? cli.pending : cli.wake;
36277
+ args[0] = parsed.kind === "pending" ? cli.pending : cli.wake;
35988
36278
  args[1] = cli.entry;
36279
+ changes.push({
36280
+ ...item,
36281
+ action: "updated"
36282
+ });
35989
36283
  return {
35990
36284
  ...hook,
35991
36285
  command: cli.node,
@@ -35998,8 +36292,16 @@ function repairClaudeHooks(settings, context, checks) {
35998
36292
  }
35999
36293
  }
36000
36294
  if (cli) for (const missing of missingSessionHooks(hooks, bound, cli.node)) {
36295
+ if (!selected(missing.bound.profile)) continue;
36001
36296
  const entries = eventEntries(hooks, missing.event);
36002
36297
  if (entries === "invalid") continue;
36298
+ changes.push({
36299
+ profile: missing.bound.profile,
36300
+ session: missing.bound.session,
36301
+ hook: missing.event,
36302
+ state: "missing",
36303
+ action: "added"
36304
+ });
36003
36305
  hooks[missing.event] = [...entries, sessionHookEntry(missing.event, missing.bound, cli)];
36004
36306
  changed.add("claude.hook.stop");
36005
36307
  }
@@ -36007,14 +36309,16 @@ function repairClaudeHooks(settings, context, checks) {
36007
36309
  }
36008
36310
  if (!changed.size) return {
36009
36311
  settings,
36010
- changed
36312
+ changed,
36313
+ changes
36011
36314
  };
36012
36315
  return {
36013
36316
  settings: {
36014
36317
  ...settings,
36015
36318
  hooks
36016
36319
  },
36017
- changed
36320
+ changed,
36321
+ changes
36018
36322
  };
36019
36323
  }
36020
36324
  //#endregion
@@ -36126,11 +36430,18 @@ function installClaudeWakeHook(options) {
36126
36430
  const agentAddress = mailAddress(options.agentAddress);
36127
36431
  if (!SESSION_UUID$1.test(options.sessionId)) return "unavailable";
36128
36432
  const sessionId = options.sessionId.toLowerCase();
36129
- return editClaudeSettings(resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), (settings) => {
36433
+ const claudeDir = resolve((options.env ?? process.env).CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
36434
+ let held = false;
36435
+ if (!editClaudeSettings(claudeDir, (settings) => {
36130
36436
  const hooks = settings.hooks ?? {};
36131
36437
  if (!record$3(hooks)) throw new Error("Invalid hook settings");
36132
36438
  for (const event of WAKE_EVENTS) if (!Array.isArray(hooks[event] ?? [])) throw new Error(`Invalid ${event} hooks`);
36133
36439
  if (!Array.isArray(hooks[PENDING_EVENT] ?? [])) throw new Error(`Invalid ${PENDING_EVENT} hooks`);
36440
+ held = options.yieldToOtherProfiles === true && otherSessionReceivers(hooks, {
36441
+ configDir,
36442
+ profile: profileName,
36443
+ session: sessionId
36444
+ }).length > 0;
36134
36445
  const hook = {
36135
36446
  type: "command",
36136
36447
  command: process.execPath,
@@ -36174,19 +36485,26 @@ function installClaudeWakeHook(options) {
36174
36485
  }
36175
36486
  };
36176
36487
  const nextHooks = { ...hooks };
36177
- for (const event of WAKE_EVENTS) nextHooks[event] = [...(hooks[event] ?? []).flatMap((entry) => {
36178
- if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36179
- const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
36180
- if (siblings.length === entry.hooks.length) return [entry];
36181
- return siblings.length ? [{
36182
- ...entry,
36183
- hooks: siblings
36184
- }] : [];
36185
- }), event === "SessionStart" ? {
36186
- matcher: "resume",
36187
- hooks: [hook]
36188
- } : { hooks: [hook] }];
36189
- nextHooks[PENDING_EVENT] = [...(hooks[PENDING_EVENT] ?? []).flatMap((entry) => {
36488
+ for (const event of WAKE_EVENTS) {
36489
+ const entries = hooks[event];
36490
+ const retained = (entries ?? []).flatMap((entry) => {
36491
+ if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36492
+ const siblings = entry.hooks.filter((candidate) => !isOwnHook(candidate));
36493
+ if (siblings.length === entry.hooks.length) return [entry];
36494
+ return siblings.length ? [{
36495
+ ...entry,
36496
+ hooks: siblings
36497
+ }] : [];
36498
+ });
36499
+ if (held) {
36500
+ if (entries) nextHooks[event] = retained;
36501
+ } else nextHooks[event] = [...retained, event === "SessionStart" ? {
36502
+ matcher: "resume",
36503
+ hooks: [hook]
36504
+ } : { hooks: [hook] }];
36505
+ }
36506
+ const pendingEntries = hooks[PENDING_EVENT];
36507
+ const pendingRetained = (pendingEntries ?? []).flatMap((entry) => {
36190
36508
  if (!record$3(entry) || !Array.isArray(entry.hooks)) return [entry];
36191
36509
  const siblings = entry.hooks.filter((candidate) => !isOwnPendingHook(candidate));
36192
36510
  if (siblings.length === entry.hooks.length) return [entry];
@@ -36194,12 +36512,17 @@ function installClaudeWakeHook(options) {
36194
36512
  ...entry,
36195
36513
  hooks: siblings
36196
36514
  }] : [];
36197
- }), { hooks: [pendingHook] }];
36515
+ });
36516
+ if (held) {
36517
+ if (pendingEntries) nextHooks[PENDING_EVENT] = pendingRetained;
36518
+ } else nextHooks[PENDING_EVENT] = [...pendingRetained, { hooks: [pendingHook] }];
36519
+ if (held && JSON.stringify(nextHooks) === JSON.stringify(hooks)) return null;
36198
36520
  return {
36199
36521
  ...settings,
36200
36522
  hooks: nextHooks
36201
36523
  };
36202
- }) ? "installed_unverified" : "unavailable";
36524
+ })) return "unavailable";
36525
+ return held ? "held_for_other_profile" : "installed_unverified";
36203
36526
  } catch {
36204
36527
  return "unavailable";
36205
36528
  }
@@ -39210,6 +39533,7 @@ function withDefined(defaults, overrides) {
39210
39533
  ...Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== void 0))
39211
39534
  };
39212
39535
  }
39536
+ const HELD_DETAIL = "This session already receives as another connected profile, so this profile's receive hooks were not added. Run `primitive machine doctor --fix --profile <profile>` to bind it deliberately.";
39213
39537
  const ENDED_DETAIL = "This session ended, so its agent was disconnected. A new address is never created for the same session.";
39214
39538
  const ENDED_PENDING_DETAIL = "This session ended, but disconnecting its agent has not been confirmed yet; `primitive machine doctor --fix` retries it.";
39215
39539
  /**
@@ -39328,14 +39652,19 @@ async function registerSession(options) {
39328
39652
  agentInfo: seeded
39329
39653
  } : current);
39330
39654
  };
39331
- const installHook = (profile) => runtime !== "claude" || receiverMode(agentProfileDirectory(options.configDir, profileName)) !== "external" || deps.installClaudeHook({
39332
- cliPath: options.cliPath,
39333
- configDir: options.configDir,
39334
- profileName,
39335
- agentAddress: profile.agent_address,
39336
- sessionId,
39337
- env
39338
- }) === "installed_unverified";
39655
+ const claudeHook = (agentAddress) => {
39656
+ const outcome = deps.installClaudeHook({
39657
+ cliPath: options.cliPath,
39658
+ configDir: options.configDir,
39659
+ profileName,
39660
+ agentAddress,
39661
+ sessionId,
39662
+ env,
39663
+ yieldToOtherProfiles: true
39664
+ });
39665
+ return outcome === "installed_unverified" ? "installed" : outcome === "held_for_other_profile" ? "held" : "failed";
39666
+ };
39667
+ const installHook = (profile) => runtime !== "claude" || receiverMode(agentProfileDirectory(options.configDir, profileName)) !== "external" ? "installed" : claudeHook(profile.agent_address);
39339
39668
  const existing = loadConnectedAgentProfile(options.configDir, profileName);
39340
39669
  if (existing) {
39341
39670
  record = await updateSessionRecord(options.configDir, sessionId, (current) => current ?? newRecord("existing", existing.agent_address));
@@ -39369,7 +39698,7 @@ async function registerSession(options) {
39369
39698
  ...withAddress,
39370
39699
  receiving: RECEIVING[runtime],
39371
39700
  status: "already_registered",
39372
- detail: hooked ? "This session is already connected." : "This session is already connected, but its receive hook could not be installed. Run `primitive machine doctor`."
39701
+ detail: hooked === "installed" ? "This session is already connected." : hooked === "held" ? HELD_DETAIL : "This session is already connected, but its receive hook could not be installed. Run `primitive machine doctor`."
39373
39702
  };
39374
39703
  }
39375
39704
  } else if (record?.address && record.createdBy === "session-register" && !enrollmentAddress(options.configDir, profileName).exists || record?.createdBy === "existing") return {
@@ -39430,15 +39759,8 @@ async function registerSession(options) {
39430
39759
  const address = result.identity.agentAddress;
39431
39760
  const submitted = verificationReplySubmitted(result.verification.state);
39432
39761
  const status = result.connection.status;
39433
- let hooked = true;
39434
- if (runtime === "claude" && submitted && status !== "owner_inactive") hooked = deps.installClaudeHook({
39435
- cliPath: options.cliPath,
39436
- configDir: options.configDir,
39437
- profileName,
39438
- agentAddress: address,
39439
- sessionId,
39440
- env
39441
- }) === "installed_unverified";
39762
+ let hooked = "installed";
39763
+ if (runtime === "claude" && submitted && status !== "owner_inactive") hooked = claudeHook(address);
39442
39764
  if (status === "connected") await finishAgentInfo();
39443
39765
  const withAddress = {
39444
39766
  ...known,
@@ -39448,7 +39770,7 @@ async function registerSession(options) {
39448
39770
  if (status === "connected") return {
39449
39771
  ...withAddress,
39450
39772
  status: "registered",
39451
- detail: hooked ? `Connected as ${address}.` : `Connected as ${address}, but the receive hook could not be installed. Run \`primitive machine doctor\`.`
39773
+ detail: hooked === "installed" ? `Connected as ${address}.` : hooked === "held" ? `Connected as ${address}. ${HELD_DETAIL}` : `Connected as ${address}, but the receive hook could not be installed. Run \`primitive machine doctor\`.`
39452
39774
  };
39453
39775
  if (status === "revoked" || status === "owner_inactive") return {
39454
39776
  ...withAddress,
@@ -47192,7 +47514,7 @@ const PRIMITIVE_TEAM_AUTHOR = {
47192
47514
  url: "https://primitive.dev"
47193
47515
  };
47194
47516
  const SDK_VERSION_RANGE = "^1.34.0";
47195
- const CLI_VERSION_RANGE = "^1.43.0";
47517
+ const CLI_VERSION_RANGE = "^1.45.0";
47196
47518
  const ESBUILD_VERSION_RANGE = "^0.27.0";
47197
47519
  function renderHandler() {
47198
47520
  return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
@@ -54258,13 +54580,15 @@ async function runMachineDoctor(options) {
54258
54580
  "claude.hook.stop"
54259
54581
  ];
54260
54582
  const repairedAhead = /* @__PURE__ */ new Set();
54583
+ const sessionHookChanges = [];
54261
54584
  let settingsRead = readClaudeSettings(paths.claudeDir);
54262
54585
  const hookContext = {
54263
54586
  cli,
54264
54587
  blocked: hookBlocked,
54265
54588
  get bound() {
54266
54589
  return boundSessionProfiles(options.configDir);
54267
- }
54590
+ },
54591
+ restoreProfiles: options.profiles
54268
54592
  };
54269
54593
  const hookCheck = (id) => ({
54270
54594
  inspect: () => {
@@ -54275,22 +54599,28 @@ async function runMachineDoctor(options) {
54275
54599
  return check(id, finding.status, finding.detail, {
54276
54600
  fixable: finding.fixable,
54277
54601
  path: join(paths.claudeDir, "settings.json"),
54278
- ...finding.status === "fail" && !finding.fixable && hookBlocked ? { action: "install_cli" } : {}
54602
+ ...finding.status === "fail" && !finding.fixable && hookBlocked ? { action: "install_cli" } : {},
54603
+ ...finding.items?.length ? { items: finding.items.map((item) => options.fix && options.profiles ? {
54604
+ ...item,
54605
+ selected: item.profile !== null && options.profiles.has(item.profile)
54606
+ } : item) } : {},
54607
+ ...id === "claude.hook.stop" && sessionHookChanges.length ? { changes: [...sessionHookChanges] } : {}
54279
54608
  });
54280
54609
  },
54281
54610
  repair: () => {
54282
54611
  if (!settingsRead.ok) return false;
54283
54612
  const findings = inspectClaudeHooks(settingsRead.settings, hookContext);
54284
54613
  const ids = HOOK_CHECK_IDS.filter((hook) => (hook === id || !options.only || options.only.has(hook)) && findings[hook].status === "fail" && findings[hook].fixable);
54285
- const repaired = repairClaudeHooks(settingsRead.settings, hookContext, new Set(ids));
54614
+ const repaired = repairClaudeHooks(settingsRead.settings, hookContext, new Set(ids), { profiles: options.profiles });
54286
54615
  if (!repaired.changed.size) return false;
54287
- for (const hook of repaired.changed) repairedAhead.add(hook);
54288
54616
  writeClaudeSettings({
54289
54617
  claudeDir: paths.claudeDir,
54290
54618
  read: settingsRead.read,
54291
54619
  settings: repaired.settings,
54292
54620
  now
54293
54621
  });
54622
+ sessionHookChanges.push(...repaired.changes);
54623
+ for (const hook of repaired.changed) repairedAhead.add(hook);
54294
54624
  settingsRead = readClaudeSettings(paths.claudeDir);
54295
54625
  return repaired.changed.has(id);
54296
54626
  }
@@ -54537,7 +54867,7 @@ async function runMachineDoctor(options) {
54537
54867
  ...after,
54538
54868
  fixed: changed && after.status !== "fail"
54539
54869
  };
54540
- if (changed && after.status === "fail") result.detail = `${after.detail} (A repair was applied but did not resolve this.)`;
54870
+ if (changed && after.status === "fail") result.detail = id === "claude.hook.stop" && options.profiles ? `${after.detail} (Only the selected profiles were repaired.)` : `${after.detail} (A repair was applied but did not resolve this.)`;
54541
54871
  } catch (error) {
54542
54872
  result = {
54543
54873
  ...result,
@@ -54569,10 +54899,25 @@ async function runMachineDoctor(options) {
54569
54899
  }
54570
54900
  //#endregion
54571
54901
  //#region src/oclif/commands/machine-doctor.ts
54902
+ const ITEM_LINES = 10;
54903
+ /** At most `limit` indented lines, then "and N more". */
54904
+ function capped(lines, limit = ITEM_LINES) {
54905
+ const shown = lines.slice(0, limit).map((line) => ` ${line}`);
54906
+ if (lines.length > limit) shown.push(` and ${lines.length - limit} more`);
54907
+ return shown;
54908
+ }
54572
54909
  function renderCheck(check) {
54573
54910
  const tag = check.status === "ok" ? "[OK] " : check.status === "warn" ? "[WARN]" : check.status === "skip" ? "[SKIP]" : "[FAIL]";
54574
54911
  const fixed = check.fixed ? " (fixed)" : "";
54575
- return `${tag} ${check.id}${fixed}: ${check.detail}`;
54912
+ const lines = [`${tag} ${check.id}${fixed}: ${check.detail}`];
54913
+ if (check.changes?.length) lines.push(" Changed:", ...capped(check.changes.map(describeSessionHookChange)));
54914
+ if (check.status !== "ok" && check.items?.length) {
54915
+ const selected = check.items.filter((item) => item.selected !== false);
54916
+ const excluded = check.items.filter((item) => item.selected === false);
54917
+ if (selected.length) lines.push(check.changes?.length ? " Still needing repair:" : " Would change:", ...capped(selected.map(describeSessionHookItem)));
54918
+ if (excluded.length) lines.push(" Not selected (left unchanged by --profile):", ...capped(excluded.map(describeSessionHookItem)));
54919
+ }
54920
+ return lines.join("\n");
54576
54921
  }
54577
54922
  var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54578
54923
  static summary = "Check, and optionally repair, this machine's Primitive setup for coding agents";
@@ -54582,6 +54927,7 @@ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54582
54927
  "<%= config.bin %> machine doctor --json",
54583
54928
  "<%= config.bin %> machine doctor --fix --json",
54584
54929
  "<%= config.bin %> machine doctor --fix --check claude.hook.stop --json",
54930
+ "<%= config.bin %> machine doctor --fix --check claude.hook.stop --profile my-agent",
54585
54931
  "<%= config.bin %> machine doctor --runtime claude,codex --min-cli-version 1.38.0 --json"
54586
54932
  ];
54587
54933
  static flags = {
@@ -54591,6 +54937,10 @@ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54591
54937
  multiple: true,
54592
54938
  options: [...DOCTOR_CHECK_IDS]
54593
54939
  }),
54940
+ profile: Flags.string({
54941
+ description: "With --fix, change per-session receive hooks only for this saved agent profile; repeatable. Naming a profile is also the only way --fix restores hooks for a profile bound to a session that already receives as another profile. Other checks are unaffected, so combine with --check claude.hook.stop to repair nothing else",
54942
+ multiple: true
54943
+ }),
54594
54944
  runtime: Flags.string({ description: "Comma-separated runtimes to check: claude, codex, omp. Defaults to every runtime found on this machine" }),
54595
54945
  "min-cli-version": Flags.string({ description: "Report cli.version as failing when this CLI is older than the given version" }),
54596
54946
  json: Flags.boolean({ description: "Print the report as JSON" })
@@ -54605,9 +54955,11 @@ var MachineDoctorCommand = class MachineDoctorCommand extends Command {
54605
54955
  runtimes = new Set(names);
54606
54956
  }
54607
54957
  if (flags.check?.length && !flags.fix) this.error("--check selects which checks --fix repairs; add --fix.", { exit: 2 });
54958
+ if (flags.profile?.length && !flags.fix) this.error("--profile narrows what --fix repairs; add --fix.", { exit: 2 });
54608
54959
  const report = await runMachineDoctor({
54609
54960
  fix: flags.fix,
54610
54961
  only: flags.check?.length ? new Set(flags.check) : void 0,
54962
+ profiles: flags.profile?.length ? new Set(flags.profile) : void 0,
54611
54963
  runtimes,
54612
54964
  configDir: this.config.configDir,
54613
54965
  home: homedir(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primcli",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,