impel-cli 0.20.41 → 0.20.43

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
@@ -21,7 +21,10 @@ import {
21
21
  IMPEL_NATIVE_AGENT_ATTACHMENT_WINDOW_ENV,
22
22
  IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES,
23
23
  IMPEL_NATIVE_AGENT_MCP_TARGET,
24
+ IMPEL_NATIVE_BENCHMARK_ENV,
25
+ impelCliInvocation,
24
26
  impelNativeAgentMcpInvocation,
27
+ nativeBenchmarkHeaderValue,
25
28
  } from "./selfInvocation.js";
26
29
  import {
27
30
  adapterAnswerFaithfulCompletionGuidance,
@@ -31,6 +34,11 @@ import {
31
34
  usesDirectAnswer,
32
35
  } from "./directAnswer.js";
33
36
  import { normalizeTenantId } from "./tenants.js";
37
+ import {
38
+ ensureClaudeNativeInterceptHook,
39
+ nativeInterceptEnabled,
40
+ nativeInterceptTimeoutMs,
41
+ } from "./nativeInterception.js";
34
42
  import {
35
43
  adapterCallerSpawnGuidance,
36
44
  adapterFaithfulCompletionGuidance,
@@ -56,7 +64,9 @@ export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
56
64
  export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
57
65
  export const NATIVE_AGENT_CONTINUATION_SCHEMA = "impel.native-agent-continuation.v1";
58
66
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
59
- export const MANAGED_AGENT_MANIFEST_VERSION = 22;
67
+ export const MANAGED_AGENT_MANIFEST_VERSION = 23;
68
+ export const IMPEL_NATIVE_PARENT_DIRECT_ENV = "IMPEL_NATIVE_PARENT_DIRECT";
69
+ export const IMPEL_NATIVE_SLASH_COMMANDS_ENV = "IMPEL_NATIVE_SLASH_COMMANDS";
60
70
 
61
71
  // The host model only selects the fixed MCP tool and faithfully returns its
62
72
  // result. Spark minimizes those transport-only turns while the selected Eve
@@ -77,6 +87,18 @@ function nativeAgentToolNames(agent) {
77
87
  function eagerNativeAgentTransportEnabled() {
78
88
  return process.env.IMPEL_NATIVE_EAGER_TRANSPORT !== "0";
79
89
  }
90
+
91
+ function enabledProfileFlag(environment, name) {
92
+ return ["1", "true"].includes(String(environment?.[name] || "").toLowerCase());
93
+ }
94
+
95
+ export function nativeParentDirectEnabled(environment = process.env) {
96
+ return enabledProfileFlag(environment, IMPEL_NATIVE_PARENT_DIRECT_ENV);
97
+ }
98
+
99
+ export function nativeSlashCommandsEnabled(environment = process.env) {
100
+ return enabledProfileFlag(environment, IMPEL_NATIVE_SLASH_COMMANDS_ENV);
101
+ }
80
102
  const MAX_RETIRED_AGENT_BINDINGS = 50;
81
103
  const MAX_NATIVE_AGENT_STATE_BYTES = 512 * 1024;
82
104
  const TERMINAL_NATIVE_AGENT_STATUSES = new Set(["succeeded", "failed", "cancelled", "canceled"]);
@@ -650,6 +672,116 @@ function nativeToolName(toolName) {
650
672
  return `${nativeToolNamespace()}__${toolName}`;
651
673
  }
652
674
 
675
+ const CLAUDE_PARENT_DIRECT_TOOL_PERMISSIONS = Object.freeze([
676
+ NATIVE_AGENT_ANSWER_TOOL,
677
+ NATIVE_AGENT_RESUME_TOOL,
678
+ ].map((tool) => nativeToolName(tool)));
679
+
680
+ function parentDirectPermissionTools(renderedAgents) {
681
+ const trusted = new Set(CLAUDE_PARENT_DIRECT_TOOL_PERMISSIONS);
682
+ const tools = [...new Set(renderedAgents.flatMap((agent) =>
683
+ agent.parentLaunchDefinition?.tools || []
684
+ ))];
685
+ if (!tools.every((tool) => trusted.has(tool))) {
686
+ throw new Error("the managed Claude parent-direct permission grant is invalid");
687
+ }
688
+ return tools;
689
+ }
690
+
691
+ function priorParentDirectPermissionGrants(prior, ownsManagedProfile) {
692
+ const grants = ownsManagedProfile ? prior?.nativeParentDirectPermissionGrants : null;
693
+ const trusted = new Set(CLAUDE_PARENT_DIRECT_TOOL_PERMISSIONS);
694
+ if (!Array.isArray(grants)
695
+ || new Set(grants).size !== grants.length
696
+ || !grants.every((tool) => trusted.has(tool))) return [];
697
+ return [...grants];
698
+ }
699
+
700
+ function readClaudeSettingsForPermissions(settingsPath) {
701
+ let raw;
702
+ try {
703
+ const stat = fs.lstatSync(settingsPath);
704
+ if (stat.isSymbolicLink() || !stat.isFile()) {
705
+ throw new Error(`refusing to update unsafe Claude settings path ${settingsPath}`);
706
+ }
707
+ raw = fs.readFileSync(settingsPath, "utf8");
708
+ } catch (error) {
709
+ if (error?.code === "ENOENT") return { settings: {} };
710
+ throw error;
711
+ }
712
+ try {
713
+ const settings = raw.trim() ? JSON.parse(raw) : {};
714
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error();
715
+ return { settings };
716
+ } catch {
717
+ throw new Error(`${settingsPath} exists but isn't a valid JSON object. Fix or remove it, then re-run.`);
718
+ }
719
+ }
720
+
721
+ function syncClaudeParentDirectPermissions(root, desiredTools, priorGrants) {
722
+ if (desiredTools.length === 0 && priorGrants.length === 0) return [];
723
+
724
+ const settingsPath = path.join(root, "settings.json");
725
+ const { settings } = readClaudeSettingsForPermissions(settingsPath);
726
+ const permissionsExisted = Object.hasOwn(settings, "permissions");
727
+ if (permissionsExisted && (
728
+ !settings.permissions
729
+ || typeof settings.permissions !== "object"
730
+ || Array.isArray(settings.permissions)
731
+ )) {
732
+ throw new Error(`${settingsPath} has an invalid permissions value. Fix or remove it, then re-run.`);
733
+ }
734
+ const permissions = permissionsExisted ? { ...settings.permissions } : {};
735
+ if (Object.hasOwn(permissions, "allow") && (
736
+ !Array.isArray(permissions.allow)
737
+ || !permissions.allow.every((rule) => typeof rule === "string")
738
+ )) {
739
+ throw new Error(`${settingsPath} has an invalid permissions.allow value. Fix or remove it, then re-run.`);
740
+ }
741
+
742
+ const desired = new Set(desiredTools);
743
+ const previouslyManaged = new Set(priorGrants);
744
+ const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
745
+ const nextAllow = allow.filter((rule) => !previouslyManaged.has(rule) || desired.has(rule));
746
+ const managedGrants = [];
747
+ for (const tool of desiredTools) {
748
+ if (previouslyManaged.has(tool)) {
749
+ managedGrants.push(tool);
750
+ if (!nextAllow.includes(tool)) nextAllow.push(tool);
751
+ } else if (!nextAllow.includes(tool)) {
752
+ managedGrants.push(tool);
753
+ nextAllow.push(tool);
754
+ }
755
+ }
756
+
757
+ // permissions.allow is Claude's ordinary pre-approval surface. Record only
758
+ // entries this sync actually adds, so turning the experiment back off can
759
+ // remove them without claiming any operator-authored permission rule.
760
+ if (JSON.stringify(allow) !== JSON.stringify(nextAllow)) {
761
+ settings.permissions = { ...permissions, allow: nextAllow };
762
+ atomicPrivateWrite(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
763
+ }
764
+ return managedGrants;
765
+ }
766
+
767
+ function claudeParentDirectPermissionsCurrent(root, manifest) {
768
+ try {
769
+ const desiredTools = parentDirectPermissionTools(
770
+ Array.isArray(manifest.agents) ? manifest.agents.map((record) => ({
771
+ parentLaunchDefinition: record?.launchMode === "parent-direct"
772
+ ? record.parentLaunchDefinition
773
+ : null,
774
+ })) : [],
775
+ );
776
+ if (desiredTools.length === 0) return true;
777
+ const { settings } = readClaudeSettingsForPermissions(path.join(root, "settings.json"));
778
+ const allow = settings.permissions?.allow;
779
+ return Array.isArray(allow) && desiredTools.every((tool) => allow.includes(tool));
780
+ } catch {
781
+ return false;
782
+ }
783
+ }
784
+
653
785
  function codexAdapterInstructions(tenantId, agent) {
654
786
  const contextRequirement = agent.requiredContext.length
655
787
  ? ` Required context keys are ${JSON.stringify(agent.requiredContext)}; if any are absent, ask for them before starting the run.`
@@ -780,11 +912,61 @@ function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = f
780
912
  };
781
913
  }
782
914
 
915
+ function claudeParentDirectInstructions(tenantId, agent) {
916
+ const attribution = `Response from managed agent ${JSON.stringify(agent.title)} (${agent.agentId}):`;
917
+ return [
918
+ `The user explicitly selected the managed agent ${JSON.stringify(agent.title)} (${agent.agentId}) for tenant ${JSON.stringify(tenantId)}.`,
919
+ `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the user's complete request. Do not spawn a relay subagent and do not perform the request yourself.`,
920
+ `If the 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; after a continuation exists, never call the answer tool again.`,
921
+ `On success, present the attribution line ${JSON.stringify(attribution)}, followed by the returned finalText verbatim with no rewriting, Markdown changes, or independent synthesis. On failure, attribute the failure to the same named managed agent and do not invent a replacement answer.`,
922
+ ].join(" ");
923
+ }
924
+
925
+ function claudeParentDirectDefinition({ tenantId, agent, invocation }) {
926
+ return {
927
+ prompt: claudeParentDirectInstructions(tenantId, agent),
928
+ permissionMode: "bypassPermissions",
929
+ tools: [...CLAUDE_PARENT_DIRECT_TOOL_PERMISSIONS],
930
+ mcpServers: [{
931
+ [MANAGED_AGENT_MCP_SERVER]: {
932
+ type: "stdio",
933
+ command: invocation.command,
934
+ args: [...invocation.args],
935
+ env: { ...(invocation.env || {}) },
936
+ },
937
+ }],
938
+ };
939
+ }
940
+
941
+ function nativeAgentTelemetryEnvironment(environment = process.env) {
942
+ return Object.fromEntries(IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES.flatMap((name) => {
943
+ const value = environment?.[name];
944
+ return typeof value === "string" && value.length > 0 ? [[name, value]] : [];
945
+ }));
946
+ }
947
+
948
+ function telemetryEnvironmentMatches(recorded, expected) {
949
+ const normalizedRecorded = recorded === undefined ? {} : recorded;
950
+ return normalizedRecorded
951
+ && typeof normalizedRecorded === "object"
952
+ && !Array.isArray(normalizedRecorded)
953
+ && JSON.stringify(normalizedRecorded) === JSON.stringify(expected);
954
+ }
955
+
783
956
  function codexInvocationEnvironment(invocation, { durableProfile = false } = {}) {
784
957
  const entries = Object.entries(invocation.env || {});
785
958
  const transient = new Set(IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES);
959
+ // Ordinary durable profiles must never retain a one-off telemetry path.
960
+ // The native Codex profiler is different: it generates a throwaway profile
961
+ // for the whole benchmark cohort and needs Codex's explicit MCP environment
962
+ // map to carry that cohort path into the server process. The isolated root is
963
+ // removed after the cohort, so benchmark-tagged telemetry cannot linger in
964
+ // an operator's managed profile.
965
+ const isolatedBenchmark = nativeBenchmarkHeaderValue(invocation.env || {}) !== null;
786
966
  return [
787
- ...(durableProfile ? entries.filter(([key]) => !transient.has(key)) : entries),
967
+ ...(durableProfile && !isolatedBenchmark
968
+ ? entries.filter(([key]) => !transient.has(key))
969
+ : entries),
788
970
  [IMPEL_NATIVE_AGENT_ATTACHMENT_WINDOW_ENV, String(CODEX_NATIVE_AGENT_ATTACHMENT_WINDOW_MS)],
789
971
  ];
790
972
  }
@@ -867,34 +1049,44 @@ function renderCodexProfile(options) {
867
1049
  function boundNativeAgentInvocation(tenantId, agent, invocation, {
868
1050
  policyFingerprint = nativeAgentPolicyFingerprint(agent),
869
1051
  mode = usesDirectAnswer(agent) ? "answer" : "durable",
1052
+ answerToolAttribution = false,
1053
+ environment = process.env,
870
1054
  } = {}) {
871
- if (!invocation) {
872
- return impelNativeAgentMcpInvocation({
1055
+ const bound = !invocation
1056
+ ? impelNativeAgentMcpInvocation({
873
1057
  tenantId,
874
1058
  agentId: agent.agentId,
875
1059
  scopeParam: agent.scopeParam,
876
1060
  policyFingerprint,
877
1061
  mode,
878
- });
879
- }
880
- const mcpIndex = invocation.args?.lastIndexOf("mcp") ?? -1;
881
- if (mcpIndex < 0) throw new Error("native-agent MCP invocation has no mcp command");
882
- return {
883
- ...invocation,
884
- args: [
885
- ...invocation.args.slice(0, mcpIndex + 1),
886
- "--target", IMPEL_NATIVE_AGENT_MCP_TARGET,
887
- "--tenant", tenantId,
888
- "--agent-id", agent.agentId,
889
- "--scope-param", agent.scopeParam,
890
- "--policy-fingerprint", policyFingerprint,
891
- ...(mode === "recovery" ? ["--recovery-only"] : []),
892
- ...(mode === "answer" ? ["--answer-only"] : []),
893
- ],
894
- };
1062
+ }, { environment })
1063
+ : (() => {
1064
+ const mcpIndex = invocation.args?.lastIndexOf("mcp") ?? -1;
1065
+ if (mcpIndex < 0) throw new Error("native-agent MCP invocation has no mcp command");
1066
+ return {
1067
+ ...invocation,
1068
+ args: [
1069
+ ...invocation.args.slice(0, mcpIndex + 1),
1070
+ "--target", IMPEL_NATIVE_AGENT_MCP_TARGET,
1071
+ "--tenant", tenantId,
1072
+ "--agent-id", agent.agentId,
1073
+ "--scope-param", agent.scopeParam,
1074
+ "--policy-fingerprint", policyFingerprint,
1075
+ ...(mode === "recovery" ? ["--recovery-only"] : []),
1076
+ ...(mode === "answer" ? ["--answer-only"] : []),
1077
+ ],
1078
+ };
1079
+ })();
1080
+ return answerToolAttribution
1081
+ ? { ...bound, args: [...bound.args, "--agent-title", agent.title] }
1082
+ : bound;
895
1083
  }
896
1084
 
897
- export function renderManagedAgents(client, tenantId, agents, invocation = null, { directCodeMode = true } = {}) {
1085
+ export function renderManagedAgents(client, tenantId, agents, invocation = null, {
1086
+ directCodeMode = true,
1087
+ nativeParentDirect = false,
1088
+ environment = process.env,
1089
+ } = {}) {
898
1090
  if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
899
1091
  const normalizedTenant = normalizeTenantId(tenantId);
900
1092
  const fileStems = generatedAgentFileStems(normalizedTenant, agents);
@@ -908,12 +1100,21 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
908
1100
  // builtin and remove it from the @ mention picker. Claude names are already
909
1101
  // collision-safe, filesystem-safe identifiers, so use them as filenames.
910
1102
  const fileStem = client === "claude" ? name : fileStems[index];
911
- const boundInvocation = boundNativeAgentInvocation(normalizedTenant, agent, invocation);
912
- const claudeRendered = client === "claude"
1103
+ const parentDirect = client === "claude" && nativeParentDirect && usesDirectAnswer(agent);
1104
+ const boundInvocation = boundNativeAgentInvocation(normalizedTenant, agent, invocation, {
1105
+ answerToolAttribution: parentDirect,
1106
+ environment,
1107
+ });
1108
+ const claudeRendered = client === "claude" && !parentDirect
913
1109
  ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation })
914
1110
  : null;
1111
+ const parentLaunchDefinition = parentDirect
1112
+ ? claudeParentDirectDefinition({ tenantId: normalizedTenant, agent, invocation: boundInvocation })
1113
+ : null;
915
1114
  const contents = claudeRendered?.contents
916
- ?? renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation, directCodeMode });
1115
+ ?? (client === "codex"
1116
+ ? renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation, directCodeMode })
1117
+ : null);
917
1118
  const profileName = client === "codex" ? fileStem : null;
918
1119
  return {
919
1120
  agentId: agent.agentId,
@@ -923,9 +1124,10 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
923
1124
  policyFingerprint: nativeAgentPolicyFingerprint(agent),
924
1125
  retired: false,
925
1126
  name,
926
- fileName: `${fileStem}${extension}`,
1127
+ fileName: parentDirect ? null : `${fileStem}${extension}`,
927
1128
  contents,
928
1129
  ...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
1130
+ ...(parentLaunchDefinition ? { parentLaunchDefinition } : {}),
929
1131
  ...(profileName ? {
930
1132
  profileName,
931
1133
  profileFileName: `${profileName}.config.toml`,
@@ -941,7 +1143,65 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
941
1143
  });
942
1144
  }
943
1145
 
944
- function renderRetiredManagedAgents(client, tenantId, bindings, active, invocation = null, { directCodeMode = true } = {}) {
1146
+ function shellQuote(value) {
1147
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
1148
+ }
1149
+
1150
+ function claudeSlashCommandName(agent, generatedAgentName) {
1151
+ const base = `ask-${generatedAgentName}`;
1152
+ if (base.length <= 63) return base;
1153
+ const prefix = generatedAgentName.slice(0, 50).replace(/-+$/u, "") || "agent";
1154
+ return `ask-${prefix}-${agentBindingHash(agent)}`;
1155
+ }
1156
+
1157
+ function renderClaudeSlashCommand(tenantId, agent, commandName, invocation) {
1158
+ const tokens = [invocation.command, ...invocation.args].map(shellQuote).join(" ");
1159
+ const bashCommand = `${tokens} --prompt "$ARGUMENTS" --json`;
1160
+ const attribution = `Response from managed agent ${JSON.stringify(agent.title)} (${agent.agentId}):`;
1161
+ return [
1162
+ "---",
1163
+ `description: ${JSON.stringify(`Ask managed agent ${agent.title} (${agent.agentId}) in Impel tenant ${tenantId}; its rendered answer is explicitly attributed.`)}`,
1164
+ `argument-hint: ${JSON.stringify("<task>")}`,
1165
+ `allowed-tools: ${JSON.stringify(`Bash(${tokens}:*)`)}`,
1166
+ "---",
1167
+ "",
1168
+ `The JSON below came from managed agent ${JSON.stringify(agent.title)} (${agent.agentId}), not from the host model.`,
1169
+ `Run the fixed command exactly once. If ok is true, output the attribution line ${JSON.stringify(attribution)} followed by finalText verbatim. If ok is false, attribute the reported error to the same managed agent. Do not perform, rewrite, or independently answer the task.`,
1170
+ "",
1171
+ `!\`${bashCommand}\``,
1172
+ "",
1173
+ ].join("\n");
1174
+ }
1175
+
1176
+ export function renderManagedClaudeSlashCommands(tenantId, agents, invocation = null) {
1177
+ const normalizedTenant = normalizeTenantId(tenantId);
1178
+ const names = generatedClientAgentNames("claude", agents);
1179
+ return agents.flatMap((agent, index) => {
1180
+ if (!usesDirectAnswer(agent)) return [];
1181
+ const commandName = claudeSlashCommandName(agent, names[index]);
1182
+ const commandInvocation = invocation || impelCliInvocation([
1183
+ "native", "answer",
1184
+ "--tenant", normalizedTenant,
1185
+ "--agent", agent.agentId,
1186
+ ]);
1187
+ return [{
1188
+ agentId: agent.agentId,
1189
+ name: commandName,
1190
+ fileName: `${commandName}.md`,
1191
+ contents: renderClaudeSlashCommand(
1192
+ normalizedTenant,
1193
+ agent,
1194
+ commandName,
1195
+ commandInvocation,
1196
+ ),
1197
+ }];
1198
+ });
1199
+ }
1200
+
1201
+ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocation = null, {
1202
+ directCodeMode = true,
1203
+ environment = process.env,
1204
+ } = {}) {
945
1205
  const usedNames = new Set(active.map(({ name }) => name));
946
1206
  const usedFiles = new Set(active.map(({ fileName }) => fileName));
947
1207
  return bindings.map((binding) => {
@@ -979,6 +1239,7 @@ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocati
979
1239
  const boundInvocation = boundNativeAgentInvocation(tenantId, agent, invocation, {
980
1240
  policyFingerprint: binding.policyFingerprint,
981
1241
  mode: "recovery",
1242
+ environment,
982
1243
  });
983
1244
  const claudeRendered = client === "claude"
984
1245
  ? renderClaudeAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true })
@@ -1115,7 +1376,11 @@ function validateClaudeLaunchDefinition(record, tenantId) {
1115
1376
  throw new Error("the managed Claude launch definition is invalid; run `impel agents sync claude`");
1116
1377
  }
1117
1378
  const server = definition.mcpServers[0][MANAGED_AGENT_MCP_SERVER];
1118
- const allowedEnvironment = new Set([IMPEL_MANAGED_MCP_ENV, ...IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES]);
1379
+ const allowedEnvironment = new Set([
1380
+ IMPEL_MANAGED_MCP_ENV,
1381
+ ...IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES,
1382
+ IMPEL_NATIVE_BENCHMARK_ENV,
1383
+ ]);
1119
1384
  if (!exactObjectKeys(server, ["type", "command", "args", "env"])
1120
1385
  || server.type !== "stdio"
1121
1386
  || typeof server.command !== "string"
@@ -1154,6 +1419,57 @@ function validateClaudeLaunchDefinition(record, tenantId) {
1154
1419
  return definition;
1155
1420
  }
1156
1421
 
1422
+ function validateClaudeParentLaunchDefinition(record, tenantId) {
1423
+ const definition = record.parentLaunchDefinition;
1424
+ const expectedTools = CLAUDE_PARENT_DIRECT_TOOL_PERMISSIONS;
1425
+ if (!exactObjectKeys(definition, ["prompt", "permissionMode", "tools", "mcpServers"])
1426
+ || definition.prompt !== claudeParentDirectInstructions(tenantId, record)
1427
+ || definition.permissionMode !== "bypassPermissions"
1428
+ || JSON.stringify(definition.tools) !== JSON.stringify(expectedTools)
1429
+ || !Array.isArray(definition.mcpServers)
1430
+ || definition.mcpServers.length !== 1
1431
+ || !exactObjectKeys(definition.mcpServers[0], [MANAGED_AGENT_MCP_SERVER])) {
1432
+ throw new Error("the managed Claude parent-direct definition is invalid; run `impel agents sync claude`");
1433
+ }
1434
+ const server = definition.mcpServers[0][MANAGED_AGENT_MCP_SERVER];
1435
+ const allowedEnvironment = new Set([
1436
+ IMPEL_MANAGED_MCP_ENV,
1437
+ ...IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES,
1438
+ IMPEL_NATIVE_BENCHMARK_ENV,
1439
+ ]);
1440
+ if (!exactObjectKeys(server, ["type", "command", "args", "env"])
1441
+ || server.type !== "stdio"
1442
+ || typeof server.command !== "string"
1443
+ || !server.command
1444
+ || !Array.isArray(server.args)
1445
+ || !server.args.every((argument) => typeof argument === "string")
1446
+ || !server.env
1447
+ || typeof server.env !== "object"
1448
+ || Array.isArray(server.env)
1449
+ || server.env[IMPEL_MANAGED_MCP_ENV] !== "1"
1450
+ || !Object.entries(server.env).every(([key, value]) =>
1451
+ allowedEnvironment.has(key)
1452
+ && typeof value === "string"
1453
+ && value.length <= 4096
1454
+ && redactCredentialText(value) === value
1455
+ )) {
1456
+ throw new Error("the managed Claude parent-direct MCP definition is invalid; run `impel agents sync claude`");
1457
+ }
1458
+ const trustedInvocation = impelNativeAgentMcpInvocation({
1459
+ tenantId,
1460
+ agentId: record.agentId,
1461
+ scopeParam: record.scopeParam,
1462
+ policyFingerprint: record.policyFingerprint,
1463
+ mode: "answer",
1464
+ });
1465
+ const trustedArgs = [...trustedInvocation.args, "--agent-title", record.title];
1466
+ if (server.command !== trustedInvocation.command
1467
+ || JSON.stringify(server.args) !== JSON.stringify(trustedArgs)) {
1468
+ throw new Error("the managed Claude parent-direct binding is invalid; run `impel agents sync claude`");
1469
+ }
1470
+ return definition;
1471
+ }
1472
+
1157
1473
  // A selector absent from the Impel manifest remains Claude-native. A matching
1158
1474
  // record is integrity checked, then re-declared under a random per-launch name
1159
1475
  // through Claude's higher-priority --agents source. This prevents project,
@@ -1192,11 +1508,34 @@ export function resolveManagedClaudeAgent(root, tenantId, selector) {
1192
1508
  const [record] = matches;
1193
1509
  if (typeof record.agentId !== "string"
1194
1510
  || !SAFE_AGENT_ID_RE.test(record.agentId)
1511
+ || typeof record.title !== "string"
1512
+ || !record.title.trim()
1513
+ || record.title.length > 160
1195
1514
  || typeof record.scopeParam !== "string"
1196
1515
  || !SAFE_SCOPE_PARAM_RE.test(record.scopeParam)
1197
1516
  || !/^[a-f0-9]{64}$/u.test(record.policyFingerprint || "")) {
1198
1517
  throw new Error("the managed Claude binding metadata is invalid; run `impel agents sync claude`");
1199
1518
  }
1519
+ if (record.launchMode === "parent-direct") {
1520
+ if (!manifest.nativeParentDirect) {
1521
+ throw new Error("the managed Claude parent-direct binding is stale; run `impel agents sync claude`");
1522
+ }
1523
+ const parentLaunchDefinition = validateClaudeParentLaunchDefinition(record, normalizedTenant);
1524
+ const server = parentLaunchDefinition.mcpServers[0][MANAGED_AGENT_MCP_SERVER];
1525
+ return {
1526
+ ...record,
1527
+ fileName: null,
1528
+ path: null,
1529
+ parentDirect: true,
1530
+ parentLaunchDefinition,
1531
+ parentMcpConfigJson: JSON.stringify({
1532
+ mcpServers: { [MANAGED_AGENT_MCP_SERVER]: server },
1533
+ }),
1534
+ };
1535
+ }
1536
+ if (record.launchMode !== undefined) {
1537
+ throw new Error("the managed Claude launch mode is invalid; run `impel agents sync claude`");
1538
+ }
1200
1539
  const fileName = `${record.name}.md`;
1201
1540
  if (path.basename(fileName) !== fileName || !manifest.files.includes(fileName)) {
1202
1541
  throw new Error("the managed Claude agent mapping is invalid; run `impel agents sync claude`");
@@ -1229,6 +1568,59 @@ export function resolveManagedClaudeAgent(root, tenantId, selector) {
1229
1568
  };
1230
1569
  }
1231
1570
 
1571
+ /**
1572
+ * Resolve a synchronized, read-only Claude binding that is allowed to use the
1573
+ * bounded direct-answer transport. The public `impel native answer` command
1574
+ * deliberately trusts the integrity-tracked profile rather than accepting
1575
+ * model- or user-supplied scope/fingerprint routing arguments.
1576
+ */
1577
+ export function resolveManagedClaudeAnswerBinding(root, tenantId, selector) {
1578
+ const resolved = resolveManagedClaudeAgent(root, tenantId, selector);
1579
+ if (!resolved) {
1580
+ throw new Error(
1581
+ `managed Claude agent ${JSON.stringify(selector)} was not found for tenant ${JSON.stringify(normalizeTenantId(tenantId))}`,
1582
+ );
1583
+ }
1584
+ const definition = resolved.parentDirect
1585
+ ? resolved.parentLaunchDefinition
1586
+ : resolved.launchDefinition;
1587
+ const server = definition?.mcpServers?.[0]?.[MANAGED_AGENT_MCP_SERVER];
1588
+ if (!Array.isArray(server?.args) || !server.args.includes("--answer-only")) {
1589
+ throw new Error(
1590
+ `managed Claude agent ${JSON.stringify(selector)} is not configured for direct answers`,
1591
+ );
1592
+ }
1593
+ return {
1594
+ tenantId: normalizeTenantId(tenantId),
1595
+ agentId: resolved.agentId,
1596
+ title: resolved.title,
1597
+ name: resolved.name,
1598
+ scopeParam: resolved.scopeParam,
1599
+ policyFingerprint: resolved.policyFingerprint,
1600
+ };
1601
+ }
1602
+
1603
+ export function managedClaudeAnswerBindings(root, tenantId) {
1604
+ const normalizedTenant = normalizeTenantId(tenantId);
1605
+ const manifest = readManagedAgentManifest(root);
1606
+ if (!manifest
1607
+ || manifest.version !== MANAGED_AGENT_MANIFEST_VERSION
1608
+ || manifest.client !== "claude"
1609
+ || manifest.tenantId !== normalizedTenant
1610
+ || !Array.isArray(manifest.agents)) {
1611
+ return [];
1612
+ }
1613
+ return manifest.agents.flatMap((record) => {
1614
+ if (!record || record.retired || typeof record.agentId !== "string") return [];
1615
+ const definition = record.launchMode === "parent-direct"
1616
+ ? record.parentLaunchDefinition
1617
+ : record.launchDefinition;
1618
+ const args = definition?.mcpServers?.[0]?.[MANAGED_AGENT_MCP_SERVER]?.args;
1619
+ if (!Array.isArray(args) || !args.includes("--answer-only")) return [];
1620
+ return [resolveManagedClaudeAnswerBinding(root, normalizedTenant, record.agentId)];
1621
+ });
1622
+ }
1623
+
1232
1624
  export function resolveManagedCodexAgentProfile(root, tenantId, selector) {
1233
1625
  const normalizedTenant = normalizeTenantId(tenantId);
1234
1626
  const normalizedSelector = boundedString(selector, "agent selector", { max: 512 });
@@ -1279,12 +1671,43 @@ function profileIsFresh(profile, tenantId, now, ttlMs) {
1279
1671
  || Array.isArray(manifest.contentDigests)) return false;
1280
1672
  const expectedDirectProfiles = profile.client === "codex" && profile.directProfiles !== false;
1281
1673
  const expectedDirectCodeMode = profile.client === "codex" && profile.directCodeMode !== false;
1674
+ const expectedNativeParentDirect = profile.client === "claude"
1675
+ && (profile.nativeParentDirect ?? nativeParentDirectEnabled());
1676
+ const expectedNativeSlashCommands = profile.client === "claude"
1677
+ && (profile.nativeSlashCommands ?? nativeSlashCommandsEnabled());
1678
+ const expectedNativeIntercept = profile.client === "claude"
1679
+ && (profile.nativeIntercept ?? nativeInterceptEnabled());
1680
+ const expectedNativeInterceptTimeoutMs = expectedNativeIntercept
1681
+ ? (profile.nativeInterceptTimeoutMs ?? nativeInterceptTimeoutMs())
1682
+ : null;
1683
+ const expectedTelemetryEnvironment = nativeAgentTelemetryEnvironment(
1684
+ profile.environment ?? process.env,
1685
+ );
1686
+ const expectedBenchmark = nativeBenchmarkHeaderValue(
1687
+ profile.environment ?? process.env,
1688
+ ) !== null;
1282
1689
  if (manifest.directProfiles !== expectedDirectProfiles
1283
- || manifest.directCodeMode !== expectedDirectCodeMode) return false;
1690
+ || manifest.directCodeMode !== expectedDirectCodeMode
1691
+ || Boolean(manifest.nativeParentDirect) !== expectedNativeParentDirect
1692
+ || Boolean(manifest.nativeSlashCommands) !== expectedNativeSlashCommands
1693
+ || Boolean(manifest.nativeIntercept) !== expectedNativeIntercept
1694
+ || !telemetryEnvironmentMatches(
1695
+ manifest.nativeAgentTelemetryEnvironment,
1696
+ expectedTelemetryEnvironment,
1697
+ )
1698
+ || Boolean(manifest.nativeBenchmark) !== expectedBenchmark
1699
+ || (expectedNativeIntercept
1700
+ && manifest.nativeInterceptTimeoutMs !== expectedNativeInterceptTimeoutMs)) return false;
1701
+ if (expectedNativeParentDirect && !claudeParentDirectPermissionsCurrent(profile.root, manifest)) {
1702
+ return false;
1703
+ }
1284
1704
  const syncedAt = Date.parse(manifest.syncedAt || "");
1285
1705
  if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
1286
1706
  const artifacts = [
1287
1707
  ...manifest.files.map((fileName) => `agents/${fileName}`),
1708
+ ...(Array.isArray(manifest.commands)
1709
+ ? manifest.commands.map((fileName) => `commands/${fileName}`)
1710
+ : []),
1288
1711
  ...(Array.isArray(manifest.profiles) ? manifest.profiles : []),
1289
1712
  ];
1290
1713
  // Renderer changes are invalidated by the manifest version/capability fields
@@ -1306,10 +1729,17 @@ export function syncAgentProfile({
1306
1729
  agents,
1307
1730
  directProfiles = client === "codex",
1308
1731
  directCodeMode = client === "codex",
1732
+ nativeParentDirect = client === "claude" && nativeParentDirectEnabled(),
1733
+ nativeSlashCommands = client === "claude" && nativeSlashCommandsEnabled(),
1734
+ nativeIntercept = client === "claude" && nativeInterceptEnabled(),
1735
+ nativeInterceptTimeoutMs: interceptTimeoutMs = nativeIntercept ? nativeInterceptTimeoutMs() : null,
1736
+ environment = process.env,
1737
+ invocation = null,
1309
1738
  now = Date.now(),
1310
1739
  nativeAgentRunsRoot = path.join(CONFIG_DIR, "native-agent-runs"),
1311
1740
  }) {
1312
1741
  const agentsDir = path.join(root, "agents");
1742
+ const commandsDir = path.join(root, "commands");
1313
1743
  for (const candidate of [root, agentsDir]) {
1314
1744
  if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
1315
1745
  throw new Error(`refusing to use symlinked native-agent path ${candidate}`);
@@ -1319,7 +1749,16 @@ export function syncAgentProfile({
1319
1749
  privateDirectory(managedDir);
1320
1750
  const manifestPath = path.join(managedDir, MANAGED_AGENT_MANIFEST);
1321
1751
  const prior = readManifest(manifestPath);
1322
- const active = renderManagedAgents(client, tenantId, agents, null, { directCodeMode });
1752
+ if ((nativeSlashCommands || prior?.commands?.length > 0)
1753
+ && fs.existsSync(commandsDir)
1754
+ && fs.lstatSync(commandsDir).isSymbolicLink()) {
1755
+ throw new Error(`refusing to use symlinked native-agent path ${commandsDir}`);
1756
+ }
1757
+ const active = renderManagedAgents(client, tenantId, agents, invocation, {
1758
+ directCodeMode,
1759
+ nativeParentDirect,
1760
+ environment,
1761
+ });
1323
1762
  const activeBindings = new Set(active.map((agent) =>
1324
1763
  `${agent.agentId}\0${agent.scopeParam}\0${agent.policyFingerprint}`
1325
1764
  ));
@@ -1327,16 +1766,27 @@ export function syncAgentProfile({
1327
1766
  .filter((binding) => !activeBindings.has(
1328
1767
  `${binding.agentId}\0${binding.scopeParam}\0${binding.policyFingerprint}`,
1329
1768
  ));
1330
- const retired = renderRetiredManagedAgents(client, tenantId, retiredBindings, active, null, { directCodeMode });
1769
+ const retired = renderRetiredManagedAgents(client, tenantId, retiredBindings, active, invocation, {
1770
+ directCodeMode,
1771
+ environment,
1772
+ });
1331
1773
  const rendered = [...active, ...retired];
1774
+ const commands = client === "claude" && nativeSlashCommands
1775
+ ? renderManagedClaudeSlashCommands(tenantId, agents)
1776
+ : [];
1777
+ const renderedFiles = rendered.filter(({ fileName }) => typeof fileName === "string");
1332
1778
  if (new Set(rendered.map(({ name }) => name)).size !== rendered.length
1333
- || new Set(rendered.map(({ fileName }) => fileName)).size !== rendered.length
1779
+ || new Set(renderedFiles.map(({ fileName }) => fileName)).size !== renderedFiles.length
1334
1780
  || (client === "codex" && directProfiles
1335
1781
  && new Set(rendered.map(({ profileFileName }) => profileFileName)).size !== rendered.length)) {
1336
1782
  throw new Error("generated native-agent destinations are not unique");
1337
1783
  }
1784
+ if (new Set(commands.map(({ fileName }) => fileName)).size !== commands.length) {
1785
+ throw new Error("generated native-agent slash command destinations are not unique");
1786
+ }
1338
1787
  const priorFiles = new Set(prior?.files || []);
1339
1788
  const priorProfiles = new Set(prior?.profiles || []);
1789
+ const priorCommands = new Set(prior?.commands || []);
1340
1790
  const priorUsesDiscoveryRoot = Number.isInteger(prior?.version)
1341
1791
  && prior.version >= 2
1342
1792
  && prior.version <= MANAGED_AGENT_MANIFEST_VERSION;
@@ -1362,14 +1812,16 @@ export function syncAgentProfile({
1362
1812
  // Preflight every destination before writing so an unmanaged file with the
1363
1813
  // same generated name is never overwritten.
1364
1814
  for (const agent of rendered) {
1365
- const destination = path.join(agentsDir, agent.fileName);
1366
- if (fs.existsSync(destination)) {
1367
- if (fs.lstatSync(destination).isSymbolicLink()) {
1368
- throw new Error(`refusing to overwrite symlinked native-agent file ${destination}`);
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}`);
1815
+ if (agent.fileName) {
1816
+ const destination = path.join(agentsDir, agent.fileName);
1817
+ if (fs.existsSync(destination)) {
1818
+ if (fs.lstatSync(destination).isSymbolicLink()) {
1819
+ throw new Error(`refusing to overwrite symlinked native-agent file ${destination}`);
1820
+ }
1821
+ if ((!priorOwnsManagedFiles || !priorFiles.has(agent.fileName))
1822
+ && !reclaimable(`agents/${agent.fileName}`, destination)) {
1823
+ throw new Error(`refusing to overwrite unmanaged native-agent file ${destination}`);
1824
+ }
1373
1825
  }
1374
1826
  }
1375
1827
  if (client === "codex" && directProfiles) {
@@ -1385,11 +1837,34 @@ export function syncAgentProfile({
1385
1837
  }
1386
1838
  }
1387
1839
  }
1840
+ for (const command of commands) {
1841
+ const destination = path.join(commandsDir, command.fileName);
1842
+ if (fs.existsSync(destination)) {
1843
+ if (fs.lstatSync(destination).isSymbolicLink()) {
1844
+ throw new Error(`refusing to overwrite symlinked native-agent slash command ${destination}`);
1845
+ }
1846
+ if ((!priorOwnsManagedFiles || !priorCommands.has(command.fileName))
1847
+ && !reclaimable(`commands/${command.fileName}`, destination)) {
1848
+ throw new Error(`refusing to overwrite unmanaged native-agent slash command ${destination}`);
1849
+ }
1850
+ }
1851
+ }
1852
+ const parentDirectPermissions = client === "claude"
1853
+ ? syncClaudeParentDirectPermissions(
1854
+ root,
1855
+ parentDirectPermissionTools(rendered),
1856
+ priorParentDirectPermissionGrants(prior, priorOwnsManagedFiles),
1857
+ )
1858
+ : null;
1388
1859
  for (const agent of rendered) {
1389
- atomicPrivateWrite(path.join(agentsDir, agent.fileName), agent.contents);
1860
+ if (agent.fileName) atomicPrivateWrite(path.join(agentsDir, agent.fileName), agent.contents);
1390
1861
  if (client === "codex" && directProfiles) atomicPrivateWrite(path.join(root, agent.profileFileName), agent.profileContents);
1391
1862
  }
1392
- const currentFiles = new Set(rendered.map((agent) => agent.fileName));
1863
+ for (const command of commands) {
1864
+ atomicPrivateWrite(path.join(commandsDir, command.fileName), command.contents);
1865
+ }
1866
+ const currentFiles = new Set(renderedFiles.map((agent) => agent.fileName));
1867
+ const currentCommands = new Set(commands.map((command) => command.fileName));
1393
1868
  const currentProfiles = new Set(client === "codex" && directProfiles
1394
1869
  ? rendered.map((agent) => agent.profileFileName)
1395
1870
  : []);
@@ -1422,6 +1897,15 @@ export function syncAgentProfile({
1422
1897
  fs.rmSync(path.join(root, stale), { force: true });
1423
1898
  }
1424
1899
  }
1900
+ for (const stale of client === "claude" ? prior?.commands || [] : []) {
1901
+ if (
1902
+ staleRemovable(stale, `commands/${stale}`, path.join(commandsDir, stale))
1903
+ && stale.endsWith(".md")
1904
+ && !currentCommands.has(stale)
1905
+ ) {
1906
+ fs.rmSync(path.join(commandsDir, stale), { force: true });
1907
+ }
1908
+ }
1425
1909
  if (!priorUsesDiscoveryRoot) {
1426
1910
  for (const legacy of prior?.files || []) {
1427
1911
  if (
@@ -1433,20 +1917,45 @@ export function syncAgentProfile({
1433
1917
  }
1434
1918
  }
1435
1919
  }
1920
+ if (client === "claude") {
1921
+ ensureClaudeNativeInterceptHook(root, tenantId, {
1922
+ enabled: nativeIntercept,
1923
+ ...(nativeIntercept ? { timeoutMs: interceptTimeoutMs } : {}),
1924
+ });
1925
+ }
1436
1926
  const contentDigests = Object.fromEntries(rendered.flatMap((agent) => [
1437
- [`agents/${agent.fileName}`, contentDigest(agent.contents)],
1927
+ ...(agent.fileName ? [[`agents/${agent.fileName}`, contentDigest(agent.contents)]] : []),
1438
1928
  ...(client === "codex" && directProfiles
1439
1929
  ? [[agent.profileFileName, contentDigest(agent.profileContents)]]
1440
1930
  : []),
1441
- ]).sort(([left], [right]) => left.localeCompare(right)));
1931
+ ]).concat(commands.map((command) => [
1932
+ `commands/${command.fileName}`,
1933
+ contentDigest(command.contents),
1934
+ ])).sort(([left], [right]) => left.localeCompare(right)));
1935
+ const telemetryEnvironment = nativeAgentTelemetryEnvironment(environment);
1936
+ const benchmark = nativeBenchmarkHeaderValue(environment);
1442
1937
  atomicPrivateWrite(manifestPath, `${JSON.stringify({
1443
1938
  version: MANAGED_AGENT_MANIFEST_VERSION,
1444
1939
  tenantId,
1445
1940
  client,
1446
1941
  directProfiles: client === "codex" && directProfiles,
1447
1942
  directCodeMode: client === "codex" && directCodeMode,
1943
+ ...(client === "claude" && nativeParentDirect ? { nativeParentDirect: true } : {}),
1944
+ ...(client === "claude" && parentDirectPermissions.length > 0 ? {
1945
+ nativeParentDirectPermissionGrants: parentDirectPermissions,
1946
+ } : {}),
1947
+ ...(client === "claude" && nativeSlashCommands ? { nativeSlashCommands: true } : {}),
1948
+ ...(client === "claude" && nativeIntercept ? {
1949
+ nativeIntercept: true,
1950
+ nativeInterceptTimeoutMs: interceptTimeoutMs,
1951
+ } : {}),
1952
+ ...(Object.keys(telemetryEnvironment).length > 0 ? {
1953
+ nativeAgentTelemetryEnvironment: telemetryEnvironment,
1954
+ } : {}),
1955
+ ...(benchmark ? { nativeBenchmark: true } : {}),
1448
1956
  syncedAt: new Date(now).toISOString(),
1449
1957
  files: [...currentFiles].sort(),
1958
+ ...(client === "claude" && nativeSlashCommands ? { commands: [...currentCommands].sort() } : {}),
1450
1959
  profiles: [...currentProfiles].sort(),
1451
1960
  contentDigests,
1452
1961
  agents: rendered.map((agent) => ({
@@ -1458,7 +1967,9 @@ export function syncAgentProfile({
1458
1967
  retired: agent.retired,
1459
1968
  name: agent.name,
1460
1969
  ...(client === "claude" ? {
1461
- launchDefinition: agent.launchDefinition,
1970
+ ...(agent.parentLaunchDefinition
1971
+ ? { launchMode: "parent-direct", parentLaunchDefinition: agent.parentLaunchDefinition }
1972
+ : { launchDefinition: agent.launchDefinition }),
1462
1973
  } : {}),
1463
1974
  ...(client === "codex" && directProfiles ? {
1464
1975
  profileName: agent.profileName,
@@ -1474,6 +1985,7 @@ export function syncAgentProfile({
1474
1985
  count: active.length,
1475
1986
  recoveryCount: retired.length,
1476
1987
  files: [...currentFiles],
1988
+ ...(client === "claude" && nativeSlashCommands ? { commands: [...currentCommands] } : {}),
1477
1989
  profiles: [...currentProfiles],
1478
1990
  };
1479
1991
  }