@agentxm/extension-lifecycle 0.28.4 → 0.28.6

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.
@@ -20,8 +20,9 @@ import * as Array from "effect/Array";
20
20
  import * as Effect from "effect/Effect";
21
21
  import * as Option from "effect/Option";
22
22
  import * as Schema from "effect/Schema";
23
- import { CodingAgentRepository, McpSharedTargetConflict, applyProjectionPlansWithResults, planSingletonProjection, inspectAgentMcpServer, sharedMcpTargetPolicyConflict, } from "@agentxm/extension-workspace";
24
- import { isPathSafe } from "@agentxm/workspace-state";
23
+ import { createHash } from "node:crypto";
24
+ import { CodingAgentRepository, applyProjectionPlansWithResults, planSingletonProjection, } from "@agentxm/extension-workspace";
25
+ import { isPathSafe, mcpRegistryResolutionKey } from "@agentxm/workspace-state";
25
26
  import { acceptedRegistryVersionForRef, validateExactResolvedVersion, } from "@agentxm/workspace-state";
26
27
  import { appendWarningsToMessage } from "@agentxm/workspace-operations";
27
28
  import { WorkspaceMutations } from "@agentxm/workspace-state";
@@ -32,7 +33,6 @@ import { printSourceParams } from "@agentxm/extension-model/unstable/sources/pri
32
33
  import { computeMaterializedTreeIntegrity } from "@agentxm/workspace-state";
33
34
  import { decodeVersionSync } from "@agentxm/extension-model/unstable/version-constraints";
34
35
  import { MCP_SERVER_MANIFEST_FILENAME, McpServerManifestSchema, } from "@agentxm/extension-model/unstable/mcps/manifest-schema";
35
- import { isMcpServerApplicableToAgent } from "@agentxm/workspace-state";
36
36
  import { agentConfigTargets, mcpServerArtifact, mcpSettingsTarget, mcpSourceTarget, } from "./artifact.js";
37
37
  import { LifecycleFailureAdapter, withAdaptedStepFailures } from "../../failure-adapter.js";
38
38
  import { ExtensionLifecycleFailed } from "../../errors.js";
@@ -55,7 +55,9 @@ const buildLockEntry = (ref, treeIntegrity) => ({
55
55
  treeIntegrity,
56
56
  });
57
57
  const MCP_SECRET_SERVICE = "axm-mcp";
58
- const mcpSecretAccount = (serverName, inputName) => `${serverName}:${inputName}`;
58
+ export const mcpSecretAccount = (args) => createHash("sha256")
59
+ .update([args.scopeRoot, args.localName, args.sourceIdentity, args.inputName].join("\0"))
60
+ .digest("hex");
59
61
  const keyringModuleSpecifier = ["@napi-rs", "keyring"].join("/");
60
62
  const loadKeyringEntry = Effect.tryPromise({
61
63
  try: async () => {
@@ -64,22 +66,22 @@ const loadKeyringEntry = Effect.tryPromise({
64
66
  },
65
67
  catch: () => undefined,
66
68
  });
67
- const saveMcpSecret = (serverName, inputName, value) => Effect.gen(function* () {
69
+ const saveMcpSecret = (account, inputName, value) => Effect.gen(function* () {
68
70
  const Entry = yield* loadKeyringEntry;
69
71
  return yield* Effect.try({
70
72
  try: () => {
71
- const entry = new Entry(MCP_SECRET_SERVICE, mcpSecretAccount(serverName, inputName));
73
+ const entry = new Entry(MCP_SECRET_SERVICE, account);
72
74
  entry.setPassword(value);
73
75
  return { _tag: "saved", inputName };
74
76
  },
75
77
  catch: () => undefined,
76
78
  });
77
79
  }).pipe(Effect.catch(() => Effect.succeed({ _tag: "failed", inputName })));
78
- const loadMcpSecret = (serverName, inputName) => Effect.gen(function* () {
80
+ const loadMcpSecret = (account) => Effect.gen(function* () {
79
81
  const Entry = yield* loadKeyringEntry;
80
82
  return yield* Effect.try({
81
83
  try: () => {
82
- const entry = new Entry(MCP_SECRET_SERVICE, mcpSecretAccount(serverName, inputName));
84
+ const entry = new Entry(MCP_SECRET_SERVICE, account);
83
85
  return Option.fromNullOr(entry.getPassword());
84
86
  },
85
87
  catch: () => undefined,
@@ -116,7 +118,7 @@ const collectRequiredInputNames = (manifest) => {
116
118
  }
117
119
  return names;
118
120
  };
119
- const collectSecretInputNames = (manifest) => {
121
+ export const collectSecretInputNames = (manifest) => {
120
122
  const names = new Set();
121
123
  const add = (input) => {
122
124
  const name = maybeSecretInputName(input);
@@ -179,7 +181,7 @@ const installFromRegistry = (ref, reuse) => Effect.gen(function* () {
179
181
  }
180
182
  return canonicalPath;
181
183
  });
182
- const readManifest = (canonicalPath) => Effect.gen(function* () {
184
+ export const readMcpServerManifest = (canonicalPath) => Effect.gen(function* () {
183
185
  const fs = yield* FileSystem.FileSystem;
184
186
  const path = yield* Path.Path;
185
187
  const manifestPath = path.join(canonicalPath, MCP_SERVER_MANIFEST_FILENAME);
@@ -204,8 +206,8 @@ const isNothingRunnableManifest = (manifest) => Option.match(manifest, {
204
206
  onSome: (value) => (value.server.packages === undefined || value.server.packages.length === 0) &&
205
207
  (value.server.remotes === undefined || value.server.remotes.length === 0),
206
208
  });
207
- const loadStoredMcpSecrets = (serverName, secretNames) => Effect.gen(function* () {
208
- const entries = yield* Effect.forEach(secretNames, (name) => loadMcpSecret(serverName, name).pipe(Effect.map((value) => ({ name, value }))), { concurrency: "unbounded" });
209
+ const loadStoredMcpSecrets = (identity, secretNames) => Effect.gen(function* () {
210
+ const entries = yield* Effect.forEach(secretNames, (name) => loadMcpSecret(mcpSecretAccount({ ...identity, inputName: name })).pipe(Effect.map((value) => ({ name, value }))), { concurrency: "unbounded" });
209
211
  const loaded = {};
210
212
  for (const { name, value } of entries) {
211
213
  if (Option.isSome(value))
@@ -213,12 +215,24 @@ const loadStoredMcpSecrets = (serverName, secretNames) => Effect.gen(function* (
213
215
  }
214
216
  return loaded;
215
217
  });
216
- const persistMcpSecrets = (serverName, secretNames, values) => Effect.forEach(secretNames, (name) => {
218
+ const persistMcpSecrets = (identity, secretNames, values) => Effect.forEach(secretNames, (name) => {
217
219
  const value = values[name];
218
220
  return value === undefined
219
221
  ? Effect.succeed({ _tag: "skipped", inputName: name })
220
- : saveMcpSecret(serverName, name, value);
222
+ : saveMcpSecret(mcpSecretAccount({ ...identity, inputName: name }), name, value);
221
223
  }, { concurrency: "unbounded" });
224
+ export const deleteMcpSecrets = (identity, secretNames) => Effect.forEach(secretNames, (inputName) => Effect.gen(function* () {
225
+ const Entry = yield* loadKeyringEntry;
226
+ return yield* Effect.try({
227
+ try: () => {
228
+ const entry = new Entry(MCP_SECRET_SERVICE, mcpSecretAccount({ ...identity, inputName }));
229
+ return entry.deletePassword()
230
+ ? { _tag: "deleted", inputName }
231
+ : { _tag: "absent", inputName };
232
+ },
233
+ catch: () => undefined,
234
+ });
235
+ }).pipe(Effect.catch(() => Effect.succeed({ _tag: "failed", inputName }))), { concurrency: "unbounded" });
222
236
  const redactSettingsEnv = (values, secretNames) => {
223
237
  const redacted = {};
224
238
  for (const [name, value] of Object.entries(values)) {
@@ -280,17 +294,6 @@ const syncConfiguredAgentsOnInstall = (args) => Effect.gen(function* () {
280
294
  warnings.push(`Skipping unknown configured agents: ${unknownConfiguredAgentIds.join(", ")}`);
281
295
  }
282
296
  const configuredAgents = yield* agentRepo.getConfiguredAgents();
283
- const sharedTargetConflict = sharedMcpTargetPolicyConflict({
284
- entry: args.entry,
285
- agentIds: configuredAgents.map((agent) => agent.id),
286
- scope: args.scope,
287
- });
288
- if (sharedTargetConflict !== undefined) {
289
- return yield* new ExtensionLifecycleFailed({
290
- category: "conflict",
291
- detail: sharedTargetConflict,
292
- });
293
- }
294
297
  let outcomes;
295
298
  if (args.nothingRunnable) {
296
299
  outcomes = configuredAgents.map((agent) => ({
@@ -316,28 +319,6 @@ const syncConfiguredAgentsOnInstall = (args) => Effect.gen(function* () {
316
319
  observedContributors: [],
317
320
  }),
318
321
  apply: () => Effect.gen(function* () {
319
- if (!isMcpServerApplicableToAgent(args.entry, agent.id)) {
320
- const inspection = yield* inspectAgentMcpServer({
321
- workspaceRoot: args.wsBaseDir,
322
- scope: args.scope,
323
- agentId: agent.id,
324
- serverName: args.serverName,
325
- entry: args.entry,
326
- });
327
- if (inspection.status === "unmanaged") {
328
- return yield* new McpSharedTargetConflict({
329
- reason: `${agent.id} has an unmanaged MCP server named ${args.serverName}; AXM will not remove it while applying the target policy`,
330
- });
331
- }
332
- const outcome = inspection.status === "drift"
333
- ? yield* agent.removeMcpServer({
334
- workspaceRoot: args.wsBaseDir,
335
- scope: args.scope,
336
- serverName: args.serverName,
337
- })
338
- : { _tag: "success", targets: [] };
339
- return { agentId: agent.id, outcome };
340
- }
341
322
  const outcome = yield* agent.addMcpServer({
342
323
  workspaceRoot: args.wsBaseDir,
343
324
  scope: args.scope,
@@ -398,9 +379,10 @@ const syncConfiguredAgentsOnInstall = (args) => Effect.gen(function* () {
398
379
  * then update lockfile/settings.
399
380
  */
400
381
  export const installMcpServer = (op) => Effect.gen(function* () {
401
- const adapter = yield* LifecycleFailureAdapter;
402
382
  const ws = yield* WorkspaceMutations;
383
+ const path = yield* Path.Path;
403
384
  const { ref } = op.args;
385
+ const localName = op.args.localName ?? ref.server.name;
404
386
  if (ref.refType !== "registry" && ref.refType !== "workspace") {
405
387
  return yield* new ExtensionLifecycleFailed({
406
388
  category: "usage",
@@ -415,8 +397,26 @@ export const installMcpServer = (op) => Effect.gen(function* () {
415
397
  }
416
398
  const strictAgentSync = Option.getOrElse(op.args.strictAgentSync ?? Option.none(), () => false);
417
399
  const env = Option.getOrElse(op.args.env ?? Option.none(), () => ({}));
400
+ const resolutionKey = ref.refType === "registry"
401
+ ? mcpRegistryResolutionKey({
402
+ authority: ref.source.location,
403
+ owner: ref.owner,
404
+ name: ref.server.name,
405
+ })
406
+ : undefined;
407
+ const sourceIdentity = resolutionKey ?? `workspace:${ref.owner}/mcps/${ref.server.name}`;
408
+ const desiredGraph = yield* ws.getDesiredStateGraph();
409
+ const existingLocalNode = desiredGraph.nodes.find((node) => node.type === "mcp-server" && node.name === localName);
410
+ if (existingLocalNode !== undefined &&
411
+ (existingLocalNode.authority === "inline" || existingLocalNode.identity !== sourceIdentity)) {
412
+ return yield* new ExtensionLifecycleFailed({
413
+ category: "conflict",
414
+ detail: `Local MCP name "${localName}" is already owned by a different source`,
415
+ });
416
+ }
417
+ const existingClosure = desiredGraph.mcpSourceClosures.find((closure) => closure.identity === sourceIdentity);
418
418
  const lockedVersion = ref.refType === "registry"
419
- ? acceptedRegistryVersionForRef(yield* ws.getLockedMcpServer(ref.server.name), ref)
419
+ ? acceptedRegistryVersionForRef(yield* ws.getLockedMcpServer(resolutionKey ?? ""), ref)
420
420
  : undefined;
421
421
  const canonicalPath = ref.refType === "registry"
422
422
  ? yield* installFromRegistry(ref, { force: op.args.force, lockedVersion })
@@ -446,7 +446,7 @@ export const installMcpServer = (op) => Effect.gen(function* () {
446
446
  }
447
447
  return ref.location;
448
448
  });
449
- const manifest = yield* readManifest(canonicalPath);
449
+ const manifest = yield* readMcpServerManifest(canonicalPath);
450
450
  const nothingRunnable = isNothingRunnableManifest(manifest);
451
451
  const secretNames = Option.match(manifest, {
452
452
  onNone: () => new Set(),
@@ -459,8 +459,13 @@ export const installMcpServer = (op) => Effect.gen(function* () {
459
459
  ? buildLockEntry(ref, yield* computeMaterializedTreeIntegrity(canonicalPath))
460
460
  : undefined;
461
461
  const currentMcpServers = yield* ws.getConfiguredMcpServerEntries();
462
- const currentEntry = currentMcpServers[ref.server.name];
463
- const storedSecrets = yield* loadStoredMcpSecrets(ref.server.name, secretNames);
462
+ const currentEntry = currentMcpServers[localName];
463
+ const secretIdentity = {
464
+ scopeRoot: path.resolve(ws.baseDir),
465
+ localName,
466
+ sourceIdentity,
467
+ };
468
+ const storedSecrets = yield* loadStoredMcpSecrets(secretIdentity, secretNames);
464
469
  const mergedEnv = { ...storedSecrets, ...(currentEntry?.env ?? {}), ...env };
465
470
  // Under --non-interactive there is nobody to prompt, so a required input
466
471
  // that nothing supplied would otherwise install a server that cannot start.
@@ -475,7 +480,7 @@ export const installMcpServer = (op) => Effect.gen(function* () {
475
480
  if (missingInputs.length > 0 && op.args.nonInteractive) {
476
481
  return yield* new ExtensionLifecycleFailed({
477
482
  category: "usage",
478
- detail: `${ref.server.name} needs ${missingInputs.join(", ")}, and --non-interactive cannot prompt for them`,
483
+ detail: `${localName} needs ${missingInputs.join(", ")}, and --non-interactive cannot prompt for them`,
479
484
  suggestions: [
480
485
  {
481
486
  description: "Supply each required input on the command line",
@@ -486,58 +491,83 @@ export const installMcpServer = (op) => Effect.gen(function* () {
486
491
  }
487
492
  const persistedEnv = redactSettingsEnv(mergedEnv, secretNames);
488
493
  const enabled = currentEntry?.enabled ?? true;
489
- const agents = op.args.agents ?? currentEntry?.agents;
490
494
  const settingsEntry = {
491
495
  kind: "sourced",
492
496
  source: ref.refType === "workspace" ? "workspace" : printSourceParams(ref.source),
493
497
  env: persistedEnv,
494
498
  enabled,
495
- ...(agents === undefined ? {} : { agents }),
496
499
  };
497
- const agentSync = yield* syncConfiguredAgentsOnInstall({
498
- wsBaseDir: ws.baseDir,
499
- scope: ws.scope,
500
- strict: strictAgentSync,
501
- serverName: ref.server.name,
502
- canonicalPath,
503
- owner: ref.owner,
504
- resolvedVersion: ref.version,
505
- nothingRunnable,
506
- enabled,
507
- configValues: preserveSecretReferences(mergedEnv, secretNames),
508
- entry: settingsEntry,
509
- });
510
- const secretPersistence = yield* persistMcpSecrets(ref.server.name, secretNames, mergedEnv);
511
- const secretWarnings = secretPersistence.flatMap((outcome) => outcome._tag === "failed"
512
- ? [`${outcome.inputName} could not be saved to the system keychain`]
513
- : []);
514
500
  const writeEffect = op.args.skipStateWrites === true
515
501
  ? Effect.void
516
502
  : Option.getOrElse(op.args.skipSettings, () => false)
517
503
  ? lockEntry === undefined
518
504
  ? Effect.void
519
505
  : ws.setMcpServerLock({
520
- name: ref.server.name,
506
+ name: resolutionKey ?? ref.server.name,
507
+ resolutionKey: resolutionKey ?? ref.server.name,
521
508
  lockEntry,
522
509
  versionRange: Option.none(),
523
510
  })
524
511
  : lockEntry === undefined
525
- ? ws.setMcpServerEntry(ref.server.name, {
512
+ ? ws.setMcpServerEntry(localName, {
526
513
  ...settingsEntry,
527
514
  })
528
515
  : ws.setMcpServer({
529
- name: ref.server.name,
516
+ name: localName,
517
+ resolutionKey: resolutionKey ?? localName,
530
518
  lockEntry,
531
519
  versionRange: op.args.versionRange,
532
520
  env: persistedEnv,
533
521
  enabled,
534
- ...(agents === undefined ? {} : { agents }),
535
522
  });
536
- const writeWarning = yield* writeEffect.pipe(Effect.as(Option.none()), Effect.catch((e) => Effect.succeed(Option.some(`MCP server update failed: ${adapter.describeFailure(e)}`))));
537
- const warnings = Option.match(writeWarning, {
538
- onNone: () => [...secretWarnings, ...agentSync.warnings],
539
- onSome: (warning) => [warning, ...secretWarnings, ...agentSync.warnings],
540
- });
523
+ yield* writeEffect;
524
+ const projectionNames = ref.refType === "registry" && lockedVersion !== undefined && lockedVersion !== ref.version
525
+ ? [...new Set([...(existingClosure?.localNames ?? []), localName])].sort()
526
+ : [localName];
527
+ const agentSyncResults = yield* Effect.forEach(projectionNames, (projectionName) => Effect.gen(function* () {
528
+ const projectionEntry = projectionName === localName ? settingsEntry : currentMcpServers[projectionName];
529
+ if (projectionEntry === undefined || projectionEntry.kind === "inline") {
530
+ return undefined;
531
+ }
532
+ const projectionSecretIdentity = {
533
+ scopeRoot: path.resolve(ws.baseDir),
534
+ localName: projectionName,
535
+ sourceIdentity,
536
+ };
537
+ const projectionStoredSecrets = yield* loadStoredMcpSecrets(projectionSecretIdentity, secretNames);
538
+ const projectionEnv = projectionName === localName
539
+ ? mergedEnv
540
+ : { ...projectionStoredSecrets, ...projectionEntry.env };
541
+ return yield* syncConfiguredAgentsOnInstall({
542
+ wsBaseDir: ws.baseDir,
543
+ scope: ws.scope,
544
+ strict: strictAgentSync,
545
+ serverName: projectionName,
546
+ canonicalPath,
547
+ owner: ref.owner,
548
+ resolvedVersion: ref.version,
549
+ nothingRunnable,
550
+ enabled: projectionEntry.enabled,
551
+ configValues: preserveSecretReferences(projectionEnv, secretNames),
552
+ entry: projectionEntry,
553
+ });
554
+ }), { concurrency: 1 });
555
+ const agentSyncSummaries = agentSyncResults.filter((summary) => summary !== undefined);
556
+ const agentSync = {
557
+ status: agentSyncSummaries.some((summary) => summary.status === "degraded")
558
+ ? "degraded"
559
+ : "green",
560
+ details: agentSyncSummaries.flatMap((summary) => summary.details),
561
+ warnings: agentSyncSummaries.flatMap((summary) => summary.warnings),
562
+ outcomes: agentSyncSummaries.flatMap((summary) => summary.outcomes),
563
+ };
564
+ const secretPersistence = yield* persistMcpSecrets(secretIdentity, secretNames, mergedEnv);
565
+ const secretWarnings = secretPersistence.flatMap((outcome) => outcome._tag === "failed"
566
+ ? [
567
+ `${outcome.inputName} could not be saved to the system keychain; AXM state was applied and credential action is required`,
568
+ ]
569
+ : []);
570
+ const warnings = [...secretWarnings, ...agentSync.warnings];
541
571
  const change = currentEntry === undefined ? "created" : "updated";
542
572
  const agentOutcomes = agentSync.outcomes.flatMap(({ agentId, outcome }) => outcome._tag === "success" || outcome._tag === "fallback"
543
573
  ? [
@@ -549,7 +579,7 @@ export const installMcpServer = (op) => Effect.gen(function* () {
549
579
  : []);
550
580
  return {
551
581
  result: "success",
552
- message: appendWarningsToMessage(`Installed ${ref.server.name} (canonical=success, agent-sync=${agentSync.status})`, warnings),
582
+ message: appendWarningsToMessage(`Installed ${localName} from ${ref.owner}/mcps/${ref.server.name} (canonical=success, agent-sync=${agentSync.status})`, warnings),
553
583
  artifact: mcpServerArtifact({
554
584
  lockEntry,
555
585
  scope: ws.scope,
@@ -20,6 +20,7 @@ import { acceptedCanonicalObservation, acceptedLockedCanonicalPath, removableAcc
20
20
  import { agentConfigTarget, mcpServerArtifact, mcpSettingsTarget } from "./artifact.js";
21
21
  import { LifecycleFailureAdapter, withAdaptedStepFailures } from "../../failure-adapter.js";
22
22
  import { ExtensionLifecycleFailed } from "../../errors.js";
23
+ import { collectSecretInputNames, deleteMcpSecrets, readMcpServerManifest } from "./install.js";
23
24
  const REQUIRED_AGENT_IDS = new Set([
24
25
  "claude-code",
25
26
  "opencode",
@@ -119,8 +120,8 @@ const syncConfiguredAgentsOnUninstall = (args) => Effect.gen(function* () {
119
120
  * 3. Remove settings and accepted resolution
120
121
  */
121
122
  export const uninstallMcpServer = (op) => Effect.gen(function* () {
122
- const adapter = yield* LifecycleFailureAdapter;
123
123
  const fs = yield* FileSystem.FileSystem;
124
+ const path = yield* Path.Path;
124
125
  const ws = yield* WorkspaceMutations;
125
126
  const strictAgentSync = Option.getOrElse(op.args.strictAgentSync ?? Option.none(), () => false);
126
127
  const desired = yield* ws.getDesiredStateGraph();
@@ -132,6 +133,12 @@ export const uninstallMcpServer = (op) => Effect.gen(function* () {
132
133
  });
133
134
  }
134
135
  const desiredNode = desired.nodes.find((node) => node.type === "mcp-server" && node.name === op.args.serverName);
136
+ const sourceClosure = desiredNode === undefined || desiredNode.authority === "inline"
137
+ ? undefined
138
+ : desired.mcpSourceClosures.find((closure) => closure.identity === desiredNode.identity);
139
+ const keepSharedResolution = sourceClosure !== undefined &&
140
+ (sourceClosure.localNames.some((name) => name !== op.args.serverName) ||
141
+ sourceClosure.origins.some((origin) => origin.type === "pack"));
135
142
  const acceptedCanonical = yield* acceptedCanonicalObservation({
136
143
  workspace: ws,
137
144
  type: "mcp-server",
@@ -150,31 +157,43 @@ export const uninstallMcpServer = (op) => Effect.gen(function* () {
150
157
  if (desiredNode === undefined && !installedOnDisk) {
151
158
  return { result: "success", message: "not installed" };
152
159
  }
153
- if (desiredNode?.origins.some((origin) => origin.type === "pack") === true) {
154
- yield* ws.removeMcpServerSettings(op.args.serverName);
155
- return {
156
- result: "success",
157
- message: "Kept on disk because dependency is still required by an installed pack",
158
- };
159
- }
160
- if (Option.isSome(removableCanonical))
160
+ const manifest = yield* Option.match(removableCanonical, {
161
+ onNone: () => Effect.succeed(Option.none()),
162
+ onSome: readMcpServerManifest,
163
+ });
164
+ if (!keepSharedResolution && Option.isSome(removableCanonical)) {
161
165
  yield* removeIfExists(fs, removableCanonical.value);
162
- // Remove from settings + lockfile (best-effort; preserve warning in result).
163
- const removeWarning = yield* ws.removeMcpServer(op.args.serverName).pipe(Effect.as(Option.none()), Effect.catch((e) => Effect.succeed(Option.some(`MCP server removal from settings failed: ${adapter.describeFailure(e)}`))));
166
+ }
167
+ // Workspace files and projections are transaction-owned. Keychain cleanup
168
+ // intentionally runs afterward and reports recoverable credential residue.
169
+ yield* ws.removeMcpServer(op.args.serverName);
164
170
  const agentSync = yield* syncConfiguredAgentsOnUninstall({
165
171
  wsBaseDir: ws.baseDir,
166
172
  scope: ws.scope,
167
173
  strict: strictAgentSync,
168
174
  serverName: op.args.serverName,
169
175
  });
170
- const warnings = Option.match(removeWarning, {
171
- onNone: () => agentSync.warnings,
172
- onSome: (warning) => [warning, ...agentSync.warnings],
176
+ const secretNames = Option.match(manifest, {
177
+ onNone: () => new Set(),
178
+ onSome: collectSecretInputNames,
173
179
  });
180
+ const secretDeletion = desiredNode === undefined || desiredNode.authority === "inline"
181
+ ? []
182
+ : yield* deleteMcpSecrets({
183
+ scopeRoot: path.resolve(ws.baseDir),
184
+ localName: op.args.serverName,
185
+ sourceIdentity: desiredNode.identity,
186
+ }, secretNames);
187
+ const secretWarnings = secretDeletion.flatMap((outcome) => outcome._tag === "failed"
188
+ ? [
189
+ `${outcome.inputName} could not be deleted from the system keychain; AXM state was applied and credential cleanup is required`,
190
+ ]
191
+ : []);
192
+ const warnings = [...secretWarnings, ...agentSync.warnings];
174
193
  const agentTarget = agentConfigTarget("removed", agentSync.agentIds);
175
194
  return {
176
195
  result: "success",
177
- message: appendWarningsToMessage(`Uninstalled ${op.args.serverName} (canonical=success, agent-sync=${agentSync.status})`, warnings),
196
+ message: appendWarningsToMessage(`Uninstalled ${op.args.serverName} (canonical=${keepSharedResolution ? "retained-shared" : "success"}, agent-sync=${agentSync.status})`, warnings),
178
197
  artifact: mcpServerArtifact({
179
198
  lockEntry: undefined,
180
199
  scope: ws.scope,
@@ -17,6 +17,7 @@ import { computeIntegrity } from "./internal/integrity.js";
17
17
  import { ArchiveIntegrityMismatch, recoverCanonicalDirectory, replaceCanonicalDirectoryWithInspection, } from "@agentxm/extension-workspace";
18
18
  import { CoupledDependencyFailure } from "@agentxm/extension-workspace";
19
19
  import { coupleLifecycleDependencyFailure } from "./errors.js";
20
+ import { makeThrottledUnitProgress } from "@agentxm/workspace-operations";
20
21
  import { computeMaterializedTreeIntegrity, } from "@agentxm/workspace-state";
21
22
  const registryLocationForClient = (location) => location.protocol === "file:" ? location.pathname : location.href;
22
23
  /**
@@ -35,12 +36,16 @@ export const materializeRegistryPackageWithTreeIntegrity = (args) => Effect.gen(
35
36
  canonicalPath: args.destinationPath,
36
37
  });
37
38
  const client = yield* createRegistryClient(registryLocationForClient(args.sourceLocation));
39
+ // Continuous download progress reaches the lifecycle broadcast throttled:
40
+ // tens of events per archive, attributed to the unit that is running.
41
+ const reportProgress = yield* makeThrottledUnitProgress({ unit: "bytes" });
38
42
  const { archive } = yield* client
39
43
  .getExtensionPackage({
40
44
  owner: args.owner,
41
45
  type: args.type,
42
46
  name: args.name,
43
47
  version: Option.some(args.version),
48
+ onProgress: (progress) => reportProgress(progress.done, progress.total),
44
49
  })
45
50
  .pipe(Effect.mapError(coupleLifecycleDependencyFailure));
46
51
  if (Option.isSome(args.integrity)) {
@@ -10,7 +10,7 @@ import * as Layer from "effect/Layer";
10
10
  import * as Option from "effect/Option";
11
11
  import * as Path from "effect/Path";
12
12
  import * as Schema from "effect/Schema";
13
- import { RuleDefinitionInvalid, RuleInstallStateMissing, activeContributors, applyProjectionPlans, planAggregateProjection, reconcileManagedRegionFile, MARKER_KIND_POINT, MARKER_VERSION, parseMarker, serializeMarker, assertInstructionTargetsSafe, assertInstructionsGitignoreSafe, observeInstructionProjection, reconcileInstructionTargets, resolveInstructionsConfig, } from "@agentxm/extension-workspace";
13
+ import { RuleDefinitionInvalid, RuleInstallStateMissing, activeContributors, applyProjectionPlans, planAggregateProjection, reconcileManagedRegionFile, projectionGeneration, MARKER_KIND_POINT, MARKER_VERSION, serializeMarker, assertInstructionTargetsSafe, assertInstructionsGitignoreSafe, observeInstructionProjection, reconcileInstructionTargets, resolveInstructionsConfig, } from "@agentxm/extension-workspace";
14
14
  import { decodeExtensionNameSync, formatFqn } from "@agentxm/extension-model/unstable/extensions";
15
15
  import { canReuseInstalledPackage, enabledConfiguredEntries, materializeExternalPackageWithTreeIntegrity, } from "@agentxm/extension-workspace";
16
16
  import { materializeRegistryPackageWithTreeIntegrity } from "../registry-materialization.js";
@@ -252,20 +252,21 @@ export const RuleManagerLive = Layer.effect(RuleManager, Effect.gen(function* ()
252
252
  });
253
253
  return sorted;
254
254
  }));
255
- const observedRuleContributors = (content) => content.split(/\r?\n/u).flatMap((line) => {
256
- const parsed = parseMarker(line, { kind: "block", open: "<!--", close: "-->" });
257
- if (parsed.state !== "complete" ||
258
- parsed.marker.kind !== MARKER_KIND_POINT ||
259
- parsed.marker.pointKind !== "rule") {
260
- return [];
261
- }
262
- const separator = parsed.marker.ext.lastIndexOf("@");
263
- return separator > 0 ? [parsed.marker.ext.slice(0, separator)] : [];
264
- });
265
255
  const reconcileRulesRegion = (args) => Effect.gen(function* () {
266
256
  const { target } = args;
267
257
  const contributors = args.input.contributors;
268
258
  const rendered = contributors.map(renderRuleBlock).join("\n\n");
259
+ const generation = projectionGeneration([
260
+ "rule-instructions-region-v1",
261
+ target.relative,
262
+ RULES_REGION_OWNER,
263
+ ...contributors.flatMap((contributor) => [
264
+ contributor.name,
265
+ contributor.marker,
266
+ contributor.body,
267
+ JSON.stringify(contributor.manifest),
268
+ ]),
269
+ ]);
269
270
  const instructions = args.instructions;
270
271
  if (args.dryRun !== true && Option.isSome(instructions)) {
271
272
  yield* provide(Effect.gen(function* () {
@@ -285,6 +286,7 @@ export const RuleManagerLive = Layer.effect(RuleManager, Effect.gen(function* ()
285
286
  region: RULES_REGION,
286
287
  owner: RULES_REGION_OWNER,
287
288
  rendered,
289
+ generation,
288
290
  ...(args.dryRun === undefined ? {} : { dryRun: args.dryRun }),
289
291
  writeWhenMissing: true,
290
292
  unsupportedTargetDetail: `Instruction source does not support managed regions: ${target.relative}`,
@@ -302,10 +304,6 @@ export const RuleManagerLive = Layer.effect(RuleManager, Effect.gen(function* ()
302
304
  present: Option.isSome(observedRegion),
303
305
  current: !changed,
304
306
  expectedContributors: contributors.map(({ marker }) => marker),
305
- observedContributors: Option.match(observedRegion, {
306
- onNone: () => [],
307
- onSome: observedRuleContributors,
308
- }),
309
307
  };
310
308
  if (args.dryRun === true) {
311
309
  return {
@@ -336,7 +334,9 @@ export const RuleManagerLive = Layer.effect(RuleManager, Effect.gen(function* ()
336
334
  unitId: "rule:instructions-region",
337
335
  targetFile: target.absolute,
338
336
  graph,
339
- select: (completeGraph) => selectRuleContributors({ graph: completeGraph, locked }),
337
+ // Rule contributors are decided from desired state alone, so the
338
+ // instructions region never excludes one.
339
+ select: (completeGraph) => selectRuleContributors({ graph: completeGraph, locked }).pipe(Effect.map((contributors) => ({ contributors, exclusions: [] }))),
340
340
  adapter: {
341
341
  observe: (input) => reconcileRulesRegion({ input, target, instructions, dryRun: true }).pipe(Effect.map(({ projectionUnitObservation }) => projectionUnitObservation)),
342
342
  apply: (input) => reconcileRulesRegion({ input, target, instructions }).pipe(Effect.asVoid),
@@ -15,7 +15,7 @@ import * as Layer from "effect/Layer";
15
15
  import * as Option from "effect/Option";
16
16
  import * as Schema from "effect/Schema";
17
17
  import { WorkspaceMutations } from "@agentxm/workspace-state";
18
- import { CodingAgentRepository, renderManagedSubagentOutputs, computeSubagentPathsForLayout, subagentContentFilename, subagentContentPath, SubagentContentUnreadable, SubagentDefinitionInvalid, SubagentInstallStateMissing, SubagentIoFailed, warnOnOrphanOverrides, buildRooModeEntry, buildSubagentLockEntry, findManagedSubagentFiles, hasAxmManagedMarker, applyProjectionPlansWithResults, planSingletonProjection, managedSubagentFile, } from "@agentxm/extension-workspace";
18
+ import { CodingAgentRepository, renderManagedSubagentOutputs, managedFileFormatForPath, managedFileMarker, projectionGeneration, computeSubagentPathsForLayout, subagentContentFilename, subagentContentPath, SubagentContentUnreadable, SubagentDefinitionInvalid, SubagentInstallStateMissing, SubagentIoFailed, warnOnOrphanOverrides, buildRooModeEntry, buildSubagentLockEntry, findManagedSubagentFiles, hasAxmManagedMarker, applyProjectionPlansWithResults, planSingletonProjection, managedSubagentFile, } from "@agentxm/extension-workspace";
19
19
  import { copyExtensionDirectory } from "@agentxm/extension-workspace";
20
20
  import { sanitizeName } from "@agentxm/workspace-state";
21
21
  import { stripFileProtocol } from "../internal/fs-helpers.js";
@@ -80,6 +80,16 @@ export const SubagentManagerLive = Layer.effect(SubagentManager, Effect.gen(func
80
80
  ...args.managedFile,
81
81
  helpTopic: "subagents",
82
82
  format: "markdown",
83
+ generation: projectionGeneration([
84
+ "subagent-role-skill-v1",
85
+ args.managedFile.ext,
86
+ args.managedFile.source.kind,
87
+ args.managedFile.source.path,
88
+ args.agentId,
89
+ args.name,
90
+ args.description,
91
+ args.body,
92
+ ]),
83
93
  });
84
94
  const jsonValuesEqual = (left, right) => {
85
95
  if (left === right)
@@ -101,6 +111,31 @@ export const SubagentManagerLive = Layer.effect(SubagentManager, Effect.gen(func
101
111
  }
102
112
  return false;
103
113
  };
114
+ const serializedJsonValuesEqual = (left, right) => {
115
+ try {
116
+ const leftValue = JSON.parse(left);
117
+ const rightValue = JSON.parse(right);
118
+ return jsonValuesEqual(leftValue, rightValue);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ };
124
+ const generatedFileCurrent = (args) => {
125
+ const format = managedFileFormatForPath(args.outputPath);
126
+ if (format === undefined) {
127
+ return args.outputPath.endsWith(".json")
128
+ ? serializedJsonValuesEqual(args.content, args.expected)
129
+ : args.content === args.expected;
130
+ }
131
+ const actualMarker = managedFileMarker(args.content, format);
132
+ const expectedMarker = managedFileMarker(args.expected, format);
133
+ return (Option.isSome(actualMarker) &&
134
+ Option.isSome(expectedMarker) &&
135
+ actualMarker.value.ext === expectedMarker.value.ext &&
136
+ actualMarker.value.src === expectedMarker.value.src &&
137
+ actualMarker.value.generation === expectedMarker.value.generation);
138
+ };
104
139
  const materializeRoleSkillFallback = (args) => Effect.gen(function* () {
105
140
  const polyfillHash = computeSourceHash(JSON.stringify({ agent: args.agentId, name: args.name, body: args.body }));
106
141
  const polyfillDir = path.join(baseDir, ".axm", "build", "polyfills", "subagents", args.sanitized, polyfillHash);
@@ -530,7 +565,13 @@ export const SubagentManagerLive = Layer.effect(SubagentManager, Effect.gen(func
530
565
  }
531
566
  return Effect.forEach(rendered.outputs, (output) => fs.readFileString(path.resolve(baseDir, output.path)).pipe(Effect.option)).pipe(Effect.map((contents) => ({
532
567
  present: contents.every(Option.isSome),
533
- current: contents.every((content, index) => Option.isSome(content) && content.value === rendered.outputs[index]?.content),
568
+ current: contents.every((content, index) => Option.isSome(content) &&
569
+ rendered.outputs[index] !== undefined &&
570
+ generatedFileCurrent({
571
+ content: content.value,
572
+ expected: rendered.outputs[index].content,
573
+ outputPath: rendered.outputs[index].path,
574
+ })),
534
575
  })));
535
576
  }
536
577
  if ((ref.fallback ?? manifestFallback) === "none") {
@@ -555,7 +596,11 @@ export const SubagentManagerLive = Layer.effect(SubagentManager, Effect.gen(func
555
596
  .readFileString(path.join(path.normalize(skills.dir), sanitized, "SKILL.md"))
556
597
  .pipe(Effect.option, Effect.map((content) => ({
557
598
  present: Option.isSome(content),
558
- current: Option.exists(content, (value) => value === expected),
599
+ current: Option.exists(content, (value) => generatedFileCurrent({
600
+ content: value,
601
+ expected,
602
+ outputPath: "SKILL.md",
603
+ })),
559
604
  })));
560
605
  }));
561
606
  })));
@@ -11,7 +11,6 @@ import type * as Scope from "effect/Scope";
11
11
  import type { Plan } from "@agentxm/workspace-operations";
12
12
  import type { OperationResolution } from "@agentxm/workspace-operations";
13
13
  import type { PlanExecution } from "@agentxm/workspace-operations";
14
- import { LifecycleResolutionProgress } from "../../resolution-progress.js";
15
14
  /**
16
15
  * Type-specific actions for install command workflows.
17
16
  *
@@ -53,5 +52,5 @@ export declare const runInstallCommandWorkflow: <Args, Parsed, Req, Ref, Intent,
53
52
  readonly execution: PlanExecution;
54
53
  readonly transformIntent?: (intent: Intent) => Intent;
55
54
  readonly transformPlan?: (plan: Plan) => Effect.Effect<Plan, TransformError, TransformRequirements>;
56
- }) => Effect.Effect<OperationResolution<never>, import("@agentxm/workspace-state").WorkspaceSettingsReadFailure | ParseError | ResolveError | TransformError | import("@agentxm/workspace-operations").CandidateFingerprintFailed | import("@agentxm/workspace-operations").ApprovalRecoveryMissing | import("@agentxm/workspace-operations").PlanInteractionFailed | import("@agentxm/workspace-state").WorkspaceTransitionAcquireFailure | import("@agentxm/workspace-state").LockfileValidationError, import("@agentxm/workspace-state").WorkspaceMutations | import("effect/FileSystem").FileSystem | import("effect/Path").Path | LifecycleResolutionProgress | import("@agentxm/workspace-operations").ResolvePlanInteraction | Exclude<TransformRequirements, Scope.Scope>>;
55
+ }) => Effect.Effect<OperationResolution<never>, import("@agentxm/workspace-state").WorkspaceSettingsReadFailure | ParseError | ResolveError | TransformError | import("@agentxm/workspace-operations").CandidateFingerprintFailed | import("@agentxm/workspace-operations").ApprovalRecoveryMissing | import("@agentxm/workspace-operations").PlanInteractionFailed | import("@agentxm/workspace-state").WorkspaceTransitionAcquireFailure | import("@agentxm/workspace-state").LockfileValidationError, import("@agentxm/workspace-state").WorkspaceMutations | import("effect/FileSystem").FileSystem | import("effect/Path").Path | import("@agentxm/workspace-operations").ResolvePlanInteraction | Exclude<TransformRequirements, Scope.Scope>>;
57
56
  //# sourceMappingURL=workflow.d.ts.map