impel-cli 0.20.37 → 0.20.39

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/src/agents.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "./config.js";
18
18
  import {
19
19
  CODEX_NATIVE_AGENT_ATTACHMENT_WINDOW_MS,
20
+ IMPEL_MANAGED_MCP_ENV,
20
21
  IMPEL_NATIVE_AGENT_ATTACHMENT_WINDOW_ENV,
21
22
  IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES,
22
23
  IMPEL_NATIVE_AGENT_MCP_TARGET,
@@ -55,7 +56,7 @@ export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
55
56
  export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
56
57
  export const NATIVE_AGENT_CONTINUATION_SCHEMA = "impel.native-agent-continuation.v1";
57
58
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
58
- export const MANAGED_AGENT_MANIFEST_VERSION = 19;
59
+ export const MANAGED_AGENT_MANIFEST_VERSION = 22;
59
60
 
60
61
  // The host model only selects the fixed MCP tool and faithfully returns its
61
62
  // result. Spark minimizes those transport-only turns while the selected Eve
@@ -82,6 +83,7 @@ const TERMINAL_NATIVE_AGENT_STATUSES = new Set(["succeeded", "failed", "cancelle
82
83
  const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
83
84
  const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
84
85
  const MAX_CATALOG_ITEMS = 500;
86
+ const MAX_CLAUDE_INLINE_AGENT_JSON_BYTES = 12 * 1024;
85
87
  const RETRYABLE_AGENT_CATALOG_ERROR = /request timed out|fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network error/iu;
86
88
  const RESERVED_AGENT_NAMES = Object.freeze({
87
89
  claude: new Set(["explore", "general-purpose", "plan"]),
@@ -621,7 +623,7 @@ function claudeAdapterInstructions(tenantId, agent) {
621
623
  `Confirm that the request fits the synchronized capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)}.${contextRequirement}`,
622
624
  sideEffectInstruction,
623
625
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
624
- `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema or add handle metadata. Never call answer_native_agent again for this request.`,
626
+ `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema, add handle metadata, or fabricate a continuation from any other field. If the answer call is cancelled or fails before returning any continuation, call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} again with the same question; once a continuation has been returned, never call it again for this request.`,
625
627
  completionGuidance,
626
628
  ].join(" ");
627
629
  }
@@ -671,7 +673,7 @@ function codexAdapterInstructions(tenantId, agent) {
671
673
  `Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before answering.${contextRequirement}`,
672
674
  sideEffectInstruction,
673
675
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
674
- `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema or add handle metadata. Never call answer_native_agent again for this request.`,
676
+ `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema, add handle metadata, or fabricate a continuation from any other field. If the answer call is cancelled or fails before returning any continuation, call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} again with the same question; once a continuation has been returned, never call it again for this request.`,
675
677
  completionGuidance,
676
678
  ].join("\n\n");
677
679
  }
@@ -711,44 +713,73 @@ function customAgentDescription(tenantId, agent) {
711
713
  ).slice(0, 900);
712
714
  }
713
715
 
714
- function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = false }) {
716
+ function claudeAgentDefinition({ tenantId, agent, invocation, recoveryOnly = false }) {
715
717
  const description = recoveryOnly
716
718
  ? `Recovery-only access to pending runs for retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
717
719
  : customAgentDescription(tenantId, agent);
718
720
  const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : nativeAgentToolNames(agent);
719
721
  const eager = eagerNativeAgentTransportEnabled();
722
+ return {
723
+ description,
724
+ prompt: recoveryOnly ? retiredAdapterInstructions(tenantId, agent) : claudeAdapterInstructions(tenantId, agent),
725
+ model: "haiku",
726
+ ...(eager ? {
727
+ permissionMode: "bypassPermissions",
728
+ tools: toolNames.map((tool) => nativeToolName(tool)),
729
+ } : {}),
730
+ mcpServers: [{
731
+ [MANAGED_AGENT_MCP_SERVER]: {
732
+ type: "stdio",
733
+ command: invocation.command,
734
+ args: [...invocation.args],
735
+ env: { ...(invocation.env || {}) },
736
+ },
737
+ }],
738
+ };
739
+ }
740
+
741
+ function renderClaudeAgentDefinition(name, definition) {
742
+ const server = definition.mcpServers[0][MANAGED_AGENT_MCP_SERVER];
720
743
  const lines = [
721
744
  "---",
722
745
  `name: ${JSON.stringify(name)}`,
723
- `description: ${JSON.stringify(description)}`,
724
- "model: haiku",
725
- ...(eager
746
+ `description: ${JSON.stringify(definition.description)}`,
747
+ `model: ${definition.model}`,
748
+ ...(definition.permissionMode
726
749
  ? [
727
750
  // This agent receives only the exact fixed-binding MCP allowlist below,
728
751
  // so bypassing prompts cannot grant filesystem, shell, connector, or
729
752
  // arbitrary MCP access. It lets non-interactive/dontAsk hosts execute
730
753
  // the selected managed adapter instead of auto-denying its sole call.
731
- "permissionMode: bypassPermissions",
754
+ `permissionMode: ${definition.permissionMode}`,
732
755
  "tools:",
733
- ...toolNames.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
756
+ ...definition.tools.map((tool) => ` - ${JSON.stringify(tool)}`),
734
757
  ]
735
758
  : []),
736
759
  "mcpServers:",
737
760
  ` - ${MANAGED_AGENT_MCP_SERVER}:`,
738
761
  " type: stdio",
739
- ` command: ${JSON.stringify(invocation.command)}`,
762
+ ` command: ${JSON.stringify(server.command)}`,
740
763
  " args:",
741
- ...invocation.args.map((argument) => ` - ${JSON.stringify(argument)}`),
764
+ ...server.args.map((argument) => ` - ${JSON.stringify(argument)}`),
742
765
  " env:",
743
- ...Object.entries(invocation.env || {}).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
766
+ ...Object.entries(server.env).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
744
767
  "---",
745
768
  "",
746
- recoveryOnly ? retiredAdapterInstructions(tenantId, agent) : claudeAdapterInstructions(tenantId, agent),
769
+ definition.prompt,
747
770
  "",
748
771
  ];
749
772
  return lines.join("\n");
750
773
  }
751
774
 
775
+ function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = false }) {
776
+ const definition = claudeAgentDefinition({ tenantId, agent, invocation, recoveryOnly });
777
+ return {
778
+ contents: renderClaudeAgentDefinition(name, definition),
779
+ launchDefinition: definition,
780
+ };
781
+ }
782
+
752
783
  function codexInvocationEnvironment(invocation, { durableProfile = false } = {}) {
753
784
  const entries = Object.entries(invocation.env || {});
754
785
  const transient = new Set(IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES);
@@ -796,6 +827,13 @@ function renderCodexConfiguration({
796
827
  ...(directCodeMode ? [
797
828
  "[features]",
798
829
  "multi_agent = false",
830
+ // Fixed-binding adapters cannot use host plugins. Disable both local and
831
+ // remote plugin paths so profile layering does not load the ordinary
832
+ // tenant Codex plugin catalog or make unrelated catalog requests.
833
+ "plugins = false",
834
+ "remote_plugin = false",
835
+ "plugin_sharing = false",
836
+ "recommended_plugins = false",
799
837
  "",
800
838
  "[features.code_mode]",
801
839
  "enabled = true",
@@ -871,9 +909,11 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
871
909
  // collision-safe, filesystem-safe identifiers, so use them as filenames.
872
910
  const fileStem = client === "claude" ? name : fileStems[index];
873
911
  const boundInvocation = boundNativeAgentInvocation(normalizedTenant, agent, invocation);
874
- const contents = client === "claude"
912
+ const claudeRendered = client === "claude"
875
913
  ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation })
876
- : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation, directCodeMode });
914
+ : null;
915
+ const contents = claudeRendered?.contents
916
+ ?? renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation, directCodeMode });
877
917
  const profileName = client === "codex" ? fileStem : null;
878
918
  return {
879
919
  agentId: agent.agentId,
@@ -885,6 +925,7 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
885
925
  name,
886
926
  fileName: `${fileStem}${extension}`,
887
927
  contents,
928
+ ...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
888
929
  ...(profileName ? {
889
930
  profileName,
890
931
  profileFileName: `${profileName}.config.toml`,
@@ -939,9 +980,11 @@ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocati
939
980
  policyFingerprint: binding.policyFingerprint,
940
981
  mode: "recovery",
941
982
  });
942
- const contents = client === "claude"
983
+ const claudeRendered = client === "claude"
943
984
  ? renderClaudeAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true })
944
- : renderCodexAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true, directCodeMode });
985
+ : null;
986
+ const contents = claudeRendered?.contents
987
+ ?? renderCodexAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true, directCodeMode });
945
988
  const profileName = client === "codex" ? path.basename(fileName, extension) : null;
946
989
  return {
947
990
  agentId: binding.agentId,
@@ -953,6 +996,7 @@ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocati
953
996
  name,
954
997
  fileName,
955
998
  contents,
999
+ ...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
956
1000
  ...(profileName ? {
957
1001
  profileName,
958
1002
  profileFileName: `${profileName}.config.toml`,
@@ -999,7 +1043,8 @@ function managedManifestPath(root) {
999
1043
  }
1000
1044
 
1001
1045
  function contentDigest(contents) {
1002
- return crypto.createHash("sha256").update(contents, "utf8").digest("hex");
1046
+ const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents, "utf8");
1047
+ return crypto.createHash("sha256").update(bytes).digest("hex");
1003
1048
  }
1004
1049
 
1005
1050
  function managedArtifactIsCurrent(root, relativePath, expectedDigest) {
@@ -1009,8 +1054,9 @@ function managedArtifactIsCurrent(root, relativePath, expectedDigest) {
1009
1054
  if (!/^[a-f0-9]{64}$/u.test(expectedDigest || "")) return false;
1010
1055
  const artifactPath = path.join(root, ...segments);
1011
1056
  try {
1012
- if (fs.lstatSync(artifactPath).isSymbolicLink()) return false;
1013
- return contentDigest(fs.readFileSync(artifactPath, "utf8")) === expectedDigest;
1057
+ const stat = fs.lstatSync(artifactPath);
1058
+ if (stat.isSymbolicLink() || !stat.isFile()) return false;
1059
+ return contentDigest(fs.readFileSync(artifactPath)) === expectedDigest;
1014
1060
  } catch {
1015
1061
  return false;
1016
1062
  }
@@ -1033,6 +1079,156 @@ export function readManagedAgentManifest(root) {
1033
1079
  return readManifest(managedManifestPath(root));
1034
1080
  }
1035
1081
 
1082
+ function exactObjectKeys(value, keys) {
1083
+ return value
1084
+ && typeof value === "object"
1085
+ && !Array.isArray(value)
1086
+ && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
1087
+ }
1088
+
1089
+ function validateClaudeLaunchDefinition(record, tenantId) {
1090
+ const definition = record.launchDefinition;
1091
+ if (!exactObjectKeys(definition, [
1092
+ "description", "prompt", "model", "permissionMode", "tools", "mcpServers",
1093
+ ])
1094
+ || typeof definition.description !== "string"
1095
+ || !definition.description
1096
+ || definition.description.length > 900
1097
+ || typeof definition.prompt !== "string"
1098
+ || !definition.prompt
1099
+ || Buffer.byteLength(definition.prompt, "utf8") > 256 * 1024
1100
+ || definition.model !== "haiku"
1101
+ || definition.permissionMode !== "bypassPermissions"
1102
+ || !Array.isArray(definition.tools)
1103
+ || definition.tools.length < 2
1104
+ || definition.tools.length > 3
1105
+ || new Set(definition.tools).size !== definition.tools.length
1106
+ || !definition.tools.every((tool) => [
1107
+ NATIVE_AGENT_ANSWER_TOOL,
1108
+ NATIVE_AGENT_RUN_TOOL,
1109
+ NATIVE_AGENT_RESUME_TOOL,
1110
+ NATIVE_AGENT_RECOVER_TOOL,
1111
+ ].map((name) => nativeToolName(name)).includes(tool))
1112
+ || !Array.isArray(definition.mcpServers)
1113
+ || definition.mcpServers.length !== 1
1114
+ || !exactObjectKeys(definition.mcpServers[0], [MANAGED_AGENT_MCP_SERVER])) {
1115
+ throw new Error("the managed Claude launch definition is invalid; run `impel agents sync claude`");
1116
+ }
1117
+ const server = definition.mcpServers[0][MANAGED_AGENT_MCP_SERVER];
1118
+ const allowedEnvironment = new Set([IMPEL_MANAGED_MCP_ENV, ...IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES]);
1119
+ if (!exactObjectKeys(server, ["type", "command", "args", "env"])
1120
+ || server.type !== "stdio"
1121
+ || typeof server.command !== "string"
1122
+ || !server.command
1123
+ || !Array.isArray(server.args)
1124
+ || !server.args.every((argument) => typeof argument === "string")
1125
+ || !server.env
1126
+ || typeof server.env !== "object"
1127
+ || Array.isArray(server.env)
1128
+ || server.env[IMPEL_MANAGED_MCP_ENV] !== "1"
1129
+ || !Object.entries(server.env).every(([key, value]) =>
1130
+ allowedEnvironment.has(key)
1131
+ && typeof value === "string"
1132
+ && value.length <= 4096
1133
+ && redactCredentialText(value) === value
1134
+ )) {
1135
+ throw new Error("the managed Claude MCP definition is invalid; run `impel agents sync claude`");
1136
+ }
1137
+ const answerOnly = server.args.at(-1) === "--answer-only";
1138
+ const trustedInvocation = impelNativeAgentMcpInvocation({
1139
+ tenantId,
1140
+ agentId: record.agentId,
1141
+ scopeParam: record.scopeParam,
1142
+ policyFingerprint: record.policyFingerprint,
1143
+ mode: answerOnly ? "answer" : "durable",
1144
+ });
1145
+ const expectedTools = (answerOnly
1146
+ ? [NATIVE_AGENT_ANSWER_TOOL, NATIVE_AGENT_RESUME_TOOL]
1147
+ : NATIVE_AGENT_TOOL_NAMES
1148
+ ).map((name) => nativeToolName(name));
1149
+ if (server.command !== trustedInvocation.command
1150
+ || JSON.stringify(server.args) !== JSON.stringify(trustedInvocation.args)
1151
+ || JSON.stringify(definition.tools) !== JSON.stringify(expectedTools)) {
1152
+ throw new Error("the managed Claude binding is invalid; run `impel agents sync claude`");
1153
+ }
1154
+ return definition;
1155
+ }
1156
+
1157
+ // A selector absent from the Impel manifest remains Claude-native. A matching
1158
+ // record is integrity checked, then re-declared under a random per-launch name
1159
+ // through Claude's higher-priority --agents source. This prevents project,
1160
+ // user, plugin, and fixed managed-settings names from replacing the adapter.
1161
+ export function resolveManagedClaudeAgent(root, tenantId, selector) {
1162
+ const normalizedTenant = normalizeTenantId(tenantId);
1163
+ if (typeof selector !== "string" || !selector || selector.length > 512) return null;
1164
+ const manifest = readManagedAgentManifest(root);
1165
+ if (!manifest
1166
+ || manifest.version !== MANAGED_AGENT_MANIFEST_VERSION
1167
+ || manifest.client !== "claude"
1168
+ || manifest.tenantId !== normalizedTenant
1169
+ || !Array.isArray(manifest.agents)) {
1170
+ return null;
1171
+ }
1172
+ const records = manifest.agents.filter((record) => record
1173
+ && typeof record === "object"
1174
+ && typeof record.name === "string");
1175
+ const active = records.filter((record) => !record.retired);
1176
+ let matches = active.filter((record) => record.agentId === selector);
1177
+ if (matches.length === 0) matches = active.filter((record) => record.title === selector);
1178
+ if (matches.length === 0) matches = active.filter((record) => record.name === selector);
1179
+ if (matches.length === 0 && records.some((record) => record.retired && [
1180
+ record.agentId,
1181
+ record.title,
1182
+ record.name,
1183
+ ].includes(selector))) {
1184
+ throw new Error(`managed Claude agent ${JSON.stringify(selector)} is retired and cannot start new work`);
1185
+ }
1186
+ if (matches.length === 0) return null;
1187
+ if (matches.length !== 1) {
1188
+ throw new Error(
1189
+ `managed Claude agent ${JSON.stringify(selector)} is ambiguous for tenant ${JSON.stringify(normalizedTenant)}`,
1190
+ );
1191
+ }
1192
+ const [record] = matches;
1193
+ if (typeof record.agentId !== "string"
1194
+ || !SAFE_AGENT_ID_RE.test(record.agentId)
1195
+ || typeof record.scopeParam !== "string"
1196
+ || !SAFE_SCOPE_PARAM_RE.test(record.scopeParam)
1197
+ || !/^[a-f0-9]{64}$/u.test(record.policyFingerprint || "")) {
1198
+ throw new Error("the managed Claude binding metadata is invalid; run `impel agents sync claude`");
1199
+ }
1200
+ const fileName = `${record.name}.md`;
1201
+ if (path.basename(fileName) !== fileName || !manifest.files.includes(fileName)) {
1202
+ throw new Error("the managed Claude agent mapping is invalid; run `impel agents sync claude`");
1203
+ }
1204
+ const relativePath = `agents/${fileName}`;
1205
+ if (!managedArtifactIsCurrent(root, relativePath, manifest.contentDigests?.[relativePath])) {
1206
+ throw new Error("the managed Claude agent failed its integrity check; run `impel agents sync claude`");
1207
+ }
1208
+ const launchDefinition = validateClaudeLaunchDefinition(record, normalizedTenant);
1209
+ if (contentDigest(renderClaudeAgentDefinition(record.name, launchDefinition))
1210
+ !== manifest.contentDigests?.[relativePath]) {
1211
+ throw new Error("the managed Claude launch definition does not match its agent file; run `impel agents sync claude`");
1212
+ }
1213
+ const suffix = crypto.randomBytes(12).toString("hex");
1214
+ const prefix = `impel-${record.name}`.slice(0, 63 - suffix.length - 1).replace(/-+$/u, "");
1215
+ const launchName = `${prefix}-${suffix}`;
1216
+ const launchAgentsJson = JSON.stringify({ [launchName]: launchDefinition });
1217
+ if (Buffer.byteLength(launchAgentsJson, "utf8") > MAX_CLAUDE_INLINE_AGENT_JSON_BYTES) {
1218
+ throw new Error(
1219
+ `managed Claude agent ${JSON.stringify(selector)} is too large for a safe cross-platform direct launch; shorten its synchronized catalog policy`,
1220
+ );
1221
+ }
1222
+ return {
1223
+ ...record,
1224
+ fileName,
1225
+ path: path.join(root, "agents", fileName),
1226
+ launchName,
1227
+ launchDefinition,
1228
+ launchAgentsJson,
1229
+ };
1230
+ }
1231
+
1036
1232
  export function resolveManagedCodexAgentProfile(root, tenantId, selector) {
1037
1233
  const normalizedTenant = normalizeTenantId(tenantId);
1038
1234
  const normalizedSelector = boundedString(selector, "agent selector", { max: 512 });
@@ -1144,9 +1340,23 @@ export function syncAgentProfile({
1144
1340
  const priorUsesDiscoveryRoot = Number.isInteger(prior?.version)
1145
1341
  && prior.version >= 2
1146
1342
  && prior.version <= MANAGED_AGENT_MANIFEST_VERSION;
1147
- const priorOwnsCodexProfiles = priorUsesDiscoveryRoot
1148
- && prior?.client === "codex"
1343
+ // Ownership requires the client and tenant to match on every leg; a manifest
1344
+ // from another scope must neither authorize overwrites nor deletions.
1345
+ const priorOwnsManagedFiles = priorUsesDiscoveryRoot
1346
+ && prior?.client === client
1149
1347
  && prior?.tenantId === tenantId;
1348
+ // A colliding artifact whose bytes match the recorded digest of a
1349
+ // non-owning manifest is provably impel-generated (e.g. after a tenant
1350
+ // scope-key drift), so reclaiming it cannot lose operator-authored content.
1351
+ const reclaimable = (digestKey, destination) => {
1352
+ const recorded = prior?.contentDigests?.[digestKey];
1353
+ if (typeof recorded !== "string" || !recorded) return false;
1354
+ try {
1355
+ return contentDigest(fs.readFileSync(destination)) === recorded;
1356
+ } catch {
1357
+ return false;
1358
+ }
1359
+ };
1150
1360
 
1151
1361
  // Native clients discover standalone definitions directly under `agents/`.
1152
1362
  // Preflight every destination before writing so an unmanaged file with the
@@ -1154,12 +1364,13 @@ export function syncAgentProfile({
1154
1364
  for (const agent of rendered) {
1155
1365
  const destination = path.join(agentsDir, agent.fileName);
1156
1366
  if (fs.existsSync(destination)) {
1157
- if (!priorUsesDiscoveryRoot || !priorFiles.has(agent.fileName)) {
1158
- throw new Error(`refusing to overwrite unmanaged native-agent file ${destination}`);
1159
- }
1160
1367
  if (fs.lstatSync(destination).isSymbolicLink()) {
1161
1368
  throw new Error(`refusing to overwrite symlinked native-agent file ${destination}`);
1162
1369
  }
1370
+ if ((!priorOwnsManagedFiles || !priorFiles.has(agent.fileName))
1371
+ && !reclaimable(`agents/${agent.fileName}`, destination)) {
1372
+ throw new Error(`refusing to overwrite unmanaged native-agent file ${destination}`);
1373
+ }
1163
1374
  }
1164
1375
  if (client === "codex" && directProfiles) {
1165
1376
  const profileDestination = path.join(root, agent.profileFileName);
@@ -1167,7 +1378,8 @@ export function syncAgentProfile({
1167
1378
  if (fs.lstatSync(profileDestination).isSymbolicLink()) {
1168
1379
  throw new Error(`refusing to overwrite symlinked Codex profile ${profileDestination}`);
1169
1380
  }
1170
- if (!priorOwnsCodexProfiles || !priorProfiles.has(agent.profileFileName)) {
1381
+ if ((!priorOwnsManagedFiles || !priorProfiles.has(agent.profileFileName))
1382
+ && !reclaimable(agent.profileFileName, profileDestination)) {
1171
1383
  throw new Error(`refusing to overwrite unmanaged Codex profile ${profileDestination}`);
1172
1384
  }
1173
1385
  }
@@ -1181,20 +1393,29 @@ export function syncAgentProfile({
1181
1393
  const currentProfiles = new Set(client === "codex" && directProfiles
1182
1394
  ? rendered.map((agent) => agent.profileFileName)
1183
1395
  : []);
1396
+ // Stale-file cleanup in the shared discovery root: an owning manifest's
1397
+ // listing is authoritative, while a non-owning manifest (another tenant or
1398
+ // a drifted scope) may only remove files it digest-proves impel wrote —
1399
+ // native-profile roots are shared across tenants, so skipping cleanup
1400
+ // entirely would orphan the previous tenant's agents forever, and deleting
1401
+ // unproven listings could destroy another owner's live files. Legacy
1402
+ // nested manifests only ever clean impel's private managed directory below.
1403
+ const staleRemovable = (stale, digestKey, destination) =>
1404
+ typeof stale === "string"
1405
+ && path.basename(stale) === stale
1406
+ && (priorOwnsManagedFiles || reclaimable(digestKey, destination));
1184
1407
  for (const stale of prior?.files || []) {
1185
1408
  if (
1186
- typeof stale === "string"
1187
- && path.basename(stale) === stale
1409
+ staleRemovable(stale, `agents/${stale}`, path.join(agentsDir, stale))
1188
1410
  && !currentFiles.has(stale)
1189
1411
  && (stale.endsWith(".md") || stale.endsWith(".toml"))
1190
1412
  ) {
1191
- fs.rmSync(path.join(priorUsesDiscoveryRoot ? agentsDir : managedDir, stale), { force: true });
1413
+ fs.rmSync(path.join(agentsDir, stale), { force: true });
1192
1414
  }
1193
1415
  }
1194
- for (const stale of priorOwnsCodexProfiles ? prior.profiles || [] : []) {
1416
+ for (const stale of client === "codex" ? prior?.profiles || [] : []) {
1195
1417
  if (
1196
- typeof stale === "string"
1197
- && path.basename(stale) === stale
1418
+ staleRemovable(stale, stale, path.join(root, stale))
1198
1419
  && stale.endsWith(".config.toml")
1199
1420
  && !currentProfiles.has(stale)
1200
1421
  ) {
@@ -1236,6 +1457,9 @@ export function syncAgentProfile({
1236
1457
  policyFingerprint: agent.policyFingerprint,
1237
1458
  retired: agent.retired,
1238
1459
  name: agent.name,
1460
+ ...(client === "claude" ? {
1461
+ launchDefinition: agent.launchDefinition,
1462
+ } : {}),
1239
1463
  ...(client === "codex" && directProfiles ? {
1240
1464
  profileName: agent.profileName,
1241
1465
  profileFileName: agent.profileFileName,