impel-cli 0.20.37 → 0.20.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,24 +180,35 @@ impel claude --model opus
180
180
  impel codex exec "review this repository"
181
181
  ```
182
182
 
183
- To run one explicitly selected tenant agent in a single Codex thread, select
183
+ To run one explicitly selected tenant agent in a single native thread, select
184
184
  it by exact agent ID (preferred) or unique exact title:
185
185
 
186
186
  ```sh
187
+ impel claude --agent research-agent --print "answer this question"
187
188
  impel codex --agent research-agent exec "answer this question"
188
189
  ```
189
190
 
190
- This path loads a generated, tenant-bound profile with the ultra-fast Codex
191
- Spark model at low reasoning for the thin adapter turns, a read-only local
192
- sandbox, the exact fixed MCP binding, and only that binding's answer/run,
193
- resume, and recovery tools. It omits unrelated local repository, app,
194
- collaboration, permission, environment, and skill-catalog prompt context; the
195
- selected Eve agent still performs the substantive work. The path also omits
196
- the normal parent specialist instructions, so there is no parent spawn, parent
197
- wait, or relay turn. Model, profile, developer-instruction, MCP, approval,
198
- sandbox, and feature overrides are rejected with `--agent`. The selected
199
- tenant and profile integrity must match before Codex starts. Ordinary `impel
200
- codex` passthrough is unchanged.
191
+ Both managed paths verify the selected tenant manifest and generated adapter
192
+ bytes, resolve the canonical native agent name, and omit the normal parent
193
+ specialist instructions. Claude Code receives the explicit 2.1.220 opt-in and
194
+ session permission mode required to run only that adapter's fixed MCP
195
+ allowlist. Impel re-declares the verified adapter under a random per-launch
196
+ name and loads only the isolated user settings source, so project, user, plugin,
197
+ and fixed organization agent names cannot replace it. User-authored Claude
198
+ agents that are absent from the Impel manifest keep Claude's ordinary
199
+ permission behavior. Model, prompt, agent-definition, tool, MCP, settings,
200
+ plugin, worktree, and permission overrides are rejected for a verified managed
201
+ Claude selection.
202
+
203
+ Codex loads a generated, tenant-bound profile with the ultra-fast Codex Spark
204
+ model at low reasoning for the thin adapter turns, a read-only local sandbox,
205
+ the exact fixed MCP binding, and only that binding's answer/run, resume, and
206
+ recovery tools. It omits unrelated local repository, app, collaboration,
207
+ permission, environment, and skill-catalog prompt context; the selected Eve
208
+ agent still performs the substantive work. Model, profile,
209
+ developer-instruction, MCP, approval, sandbox, and feature overrides are
210
+ rejected for its managed path. Ordinary `impel claude`, user-authored Claude
211
+ agent, and `impel codex` passthrough are unchanged.
201
212
 
202
213
  ## Remote Fargate sessions
203
214
 
@@ -518,6 +529,7 @@ must remain explicit and destructive-state aware.
518
529
  impel setup
519
530
  impel update
520
531
  impel claude
532
+ impel claude --agent <id|exact-title> ...
521
533
  impel codex
522
534
  impel codex --agent <id|exact-title> ...
523
535
  impel tenant list|current|use
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.38 — Restore managed Claude agent permissions
4
+
5
+ - Verifies an exact synchronized Claude agent and its generated file digest
6
+ before enabling Claude Code 2.1.220's required bypass-permission opt-in and
7
+ session mode for that fixed MCP-only adapter.
8
+ - Resolves exact agent IDs and titles to a byte-verified definition, declares
9
+ it under a random per-launch name with user-only setting sources, omits the
10
+ parent delegation prompt, and rejects model, prompt, tool, MCP, settings,
11
+ plugin, worktree, and permission overrides on the managed path.
12
+ - Leaves ordinary Claude launches, user-authored Claude agents, and every
13
+ Codex launch path unchanged.
14
+
3
15
  ## 0.20.37 — Resume ambiguous native answers
4
16
 
5
17
  - Retries Next's typed ambiguous direct-answer response with the exact persisted
@@ -6,7 +6,7 @@ is treated as an eager host.
6
6
 
7
7
  | Surface | Pinned build | Exact selected tools | Selected path | Host behavior |
8
8
  | --- | --- | --- | --- | --- |
9
- | Claude Code | 2.1.220 | Yes, agent frontmatter `tools` | Eager | The bound run/answer, resume, and recovery tools are direct child tools. No catalog discovery or code-cell polling is required. |
9
+ | Claude Code | 2.1.220 | Yes, agent frontmatter `tools` | Eager; optional single-thread selection | The bound run/answer, resume, and recovery tools are direct child tools. `impel claude --agent` verifies the exact managed adapter before enabling 2.1.220's bypass-permission opt-in and mode. |
10
10
  | Claude Desktop | 1.26832.0 (embedded Code 2.1.222) | Yes, agent frontmatter `tools` | Eager | Same direct-tool contract as Claude Code. |
11
11
  | Codex CLI through `impel codex` | 0.147.0 | Yes, with `features.code_mode.direct_only_tool_namespaces = ["mcp__impel_agent"]` | Eager; optional single-thread profile | Existing `@Agent` children call the fixed MCP namespace directly. `impel codex --agent` also removes the parent spawn/wait/relay thread. |
12
12
  | ChatGPT desktop Codex | 26.803.41515 (embedded Codex 0.147.0-alpha.6.5) | Yes, with the same exact native namespace | Eager child, compatible parent | Keeps the app's supported `@Agent` parent/child route, but the child no longer needs code cells or code-mode waits. |
@@ -16,6 +16,17 @@ is treated as an eager host.
16
16
  - Eager profiles expose only the exact fixed binding's answer/run, resume, and
17
17
  recovery tools. `IMPEL_NATIVE_EAGER_TRANSPORT=0` independently restores the
18
18
  legacy host-visible tool configuration without deleting profiles or runs.
19
+ - A top-level Claude selection receives `--allow-dangerously-skip-permissions`
20
+ plus `--permission-mode bypassPermissions` only after its manifest record,
21
+ canonical definition, fixed MCP binding, and generated file bytes have been
22
+ verified. User-authored agents keep native Claude behavior.
23
+ - The verified definition is passed through `--agents` under a random
24
+ per-launch name while `--setting-sources user` excludes project/local
25
+ definitions. This outranks user and plugin names and makes collisions with a
26
+ fixed organization-managed agent name impractical.
27
+ - Verified Claude selections reject model, prompt, agent-definition, tool,
28
+ MCP, settings, plugin, worktree, and permission-policy overrides before
29
+ native launch.
19
30
  - Every local composite keeps one upstream MCP session for its process lifetime,
20
31
  validates the exact binding once, and persists the authoritative run handle
21
32
  before ambiguous retries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.37",
3
+ "version": "0.20.38",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
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 = 20;
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"]),
@@ -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);
@@ -871,9 +902,11 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
871
902
  // collision-safe, filesystem-safe identifiers, so use them as filenames.
872
903
  const fileStem = client === "claude" ? name : fileStems[index];
873
904
  const boundInvocation = boundNativeAgentInvocation(normalizedTenant, agent, invocation);
874
- const contents = client === "claude"
905
+ const claudeRendered = client === "claude"
875
906
  ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation })
876
- : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation, directCodeMode });
907
+ : null;
908
+ const contents = claudeRendered?.contents
909
+ ?? renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation, directCodeMode });
877
910
  const profileName = client === "codex" ? fileStem : null;
878
911
  return {
879
912
  agentId: agent.agentId,
@@ -885,6 +918,7 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
885
918
  name,
886
919
  fileName: `${fileStem}${extension}`,
887
920
  contents,
921
+ ...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
888
922
  ...(profileName ? {
889
923
  profileName,
890
924
  profileFileName: `${profileName}.config.toml`,
@@ -939,9 +973,11 @@ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocati
939
973
  policyFingerprint: binding.policyFingerprint,
940
974
  mode: "recovery",
941
975
  });
942
- const contents = client === "claude"
976
+ const claudeRendered = client === "claude"
943
977
  ? renderClaudeAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true })
944
- : renderCodexAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true, directCodeMode });
978
+ : null;
979
+ const contents = claudeRendered?.contents
980
+ ?? renderCodexAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true, directCodeMode });
945
981
  const profileName = client === "codex" ? path.basename(fileName, extension) : null;
946
982
  return {
947
983
  agentId: binding.agentId,
@@ -953,6 +989,7 @@ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocati
953
989
  name,
954
990
  fileName,
955
991
  contents,
992
+ ...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
956
993
  ...(profileName ? {
957
994
  profileName,
958
995
  profileFileName: `${profileName}.config.toml`,
@@ -999,7 +1036,8 @@ function managedManifestPath(root) {
999
1036
  }
1000
1037
 
1001
1038
  function contentDigest(contents) {
1002
- return crypto.createHash("sha256").update(contents, "utf8").digest("hex");
1039
+ const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents, "utf8");
1040
+ return crypto.createHash("sha256").update(bytes).digest("hex");
1003
1041
  }
1004
1042
 
1005
1043
  function managedArtifactIsCurrent(root, relativePath, expectedDigest) {
@@ -1009,8 +1047,9 @@ function managedArtifactIsCurrent(root, relativePath, expectedDigest) {
1009
1047
  if (!/^[a-f0-9]{64}$/u.test(expectedDigest || "")) return false;
1010
1048
  const artifactPath = path.join(root, ...segments);
1011
1049
  try {
1012
- if (fs.lstatSync(artifactPath).isSymbolicLink()) return false;
1013
- return contentDigest(fs.readFileSync(artifactPath, "utf8")) === expectedDigest;
1050
+ const stat = fs.lstatSync(artifactPath);
1051
+ if (stat.isSymbolicLink() || !stat.isFile()) return false;
1052
+ return contentDigest(fs.readFileSync(artifactPath)) === expectedDigest;
1014
1053
  } catch {
1015
1054
  return false;
1016
1055
  }
@@ -1033,6 +1072,156 @@ export function readManagedAgentManifest(root) {
1033
1072
  return readManifest(managedManifestPath(root));
1034
1073
  }
1035
1074
 
1075
+ function exactObjectKeys(value, keys) {
1076
+ return value
1077
+ && typeof value === "object"
1078
+ && !Array.isArray(value)
1079
+ && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
1080
+ }
1081
+
1082
+ function validateClaudeLaunchDefinition(record, tenantId) {
1083
+ const definition = record.launchDefinition;
1084
+ if (!exactObjectKeys(definition, [
1085
+ "description", "prompt", "model", "permissionMode", "tools", "mcpServers",
1086
+ ])
1087
+ || typeof definition.description !== "string"
1088
+ || !definition.description
1089
+ || definition.description.length > 900
1090
+ || typeof definition.prompt !== "string"
1091
+ || !definition.prompt
1092
+ || Buffer.byteLength(definition.prompt, "utf8") > 256 * 1024
1093
+ || definition.model !== "haiku"
1094
+ || definition.permissionMode !== "bypassPermissions"
1095
+ || !Array.isArray(definition.tools)
1096
+ || definition.tools.length < 2
1097
+ || definition.tools.length > 3
1098
+ || new Set(definition.tools).size !== definition.tools.length
1099
+ || !definition.tools.every((tool) => [
1100
+ NATIVE_AGENT_ANSWER_TOOL,
1101
+ NATIVE_AGENT_RUN_TOOL,
1102
+ NATIVE_AGENT_RESUME_TOOL,
1103
+ NATIVE_AGENT_RECOVER_TOOL,
1104
+ ].map((name) => nativeToolName(name)).includes(tool))
1105
+ || !Array.isArray(definition.mcpServers)
1106
+ || definition.mcpServers.length !== 1
1107
+ || !exactObjectKeys(definition.mcpServers[0], [MANAGED_AGENT_MCP_SERVER])) {
1108
+ throw new Error("the managed Claude launch definition is invalid; run `impel agents sync claude`");
1109
+ }
1110
+ const server = definition.mcpServers[0][MANAGED_AGENT_MCP_SERVER];
1111
+ const allowedEnvironment = new Set([IMPEL_MANAGED_MCP_ENV, ...IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES]);
1112
+ if (!exactObjectKeys(server, ["type", "command", "args", "env"])
1113
+ || server.type !== "stdio"
1114
+ || typeof server.command !== "string"
1115
+ || !server.command
1116
+ || !Array.isArray(server.args)
1117
+ || !server.args.every((argument) => typeof argument === "string")
1118
+ || !server.env
1119
+ || typeof server.env !== "object"
1120
+ || Array.isArray(server.env)
1121
+ || server.env[IMPEL_MANAGED_MCP_ENV] !== "1"
1122
+ || !Object.entries(server.env).every(([key, value]) =>
1123
+ allowedEnvironment.has(key)
1124
+ && typeof value === "string"
1125
+ && value.length <= 4096
1126
+ && redactCredentialText(value) === value
1127
+ )) {
1128
+ throw new Error("the managed Claude MCP definition is invalid; run `impel agents sync claude`");
1129
+ }
1130
+ const answerOnly = server.args.at(-1) === "--answer-only";
1131
+ const trustedInvocation = impelNativeAgentMcpInvocation({
1132
+ tenantId,
1133
+ agentId: record.agentId,
1134
+ scopeParam: record.scopeParam,
1135
+ policyFingerprint: record.policyFingerprint,
1136
+ mode: answerOnly ? "answer" : "durable",
1137
+ });
1138
+ const expectedTools = (answerOnly
1139
+ ? [NATIVE_AGENT_ANSWER_TOOL, NATIVE_AGENT_RESUME_TOOL]
1140
+ : NATIVE_AGENT_TOOL_NAMES
1141
+ ).map((name) => nativeToolName(name));
1142
+ if (server.command !== trustedInvocation.command
1143
+ || JSON.stringify(server.args) !== JSON.stringify(trustedInvocation.args)
1144
+ || JSON.stringify(definition.tools) !== JSON.stringify(expectedTools)) {
1145
+ throw new Error("the managed Claude binding is invalid; run `impel agents sync claude`");
1146
+ }
1147
+ return definition;
1148
+ }
1149
+
1150
+ // A selector absent from the Impel manifest remains Claude-native. A matching
1151
+ // record is integrity checked, then re-declared under a random per-launch name
1152
+ // through Claude's higher-priority --agents source. This prevents project,
1153
+ // user, plugin, and fixed managed-settings names from replacing the adapter.
1154
+ export function resolveManagedClaudeAgent(root, tenantId, selector) {
1155
+ const normalizedTenant = normalizeTenantId(tenantId);
1156
+ if (typeof selector !== "string" || !selector || selector.length > 512) return null;
1157
+ const manifest = readManagedAgentManifest(root);
1158
+ if (!manifest
1159
+ || manifest.version !== MANAGED_AGENT_MANIFEST_VERSION
1160
+ || manifest.client !== "claude"
1161
+ || manifest.tenantId !== normalizedTenant
1162
+ || !Array.isArray(manifest.agents)) {
1163
+ return null;
1164
+ }
1165
+ const records = manifest.agents.filter((record) => record
1166
+ && typeof record === "object"
1167
+ && typeof record.name === "string");
1168
+ const active = records.filter((record) => !record.retired);
1169
+ let matches = active.filter((record) => record.agentId === selector);
1170
+ if (matches.length === 0) matches = active.filter((record) => record.title === selector);
1171
+ if (matches.length === 0) matches = active.filter((record) => record.name === selector);
1172
+ if (matches.length === 0 && records.some((record) => record.retired && [
1173
+ record.agentId,
1174
+ record.title,
1175
+ record.name,
1176
+ ].includes(selector))) {
1177
+ throw new Error(`managed Claude agent ${JSON.stringify(selector)} is retired and cannot start new work`);
1178
+ }
1179
+ if (matches.length === 0) return null;
1180
+ if (matches.length !== 1) {
1181
+ throw new Error(
1182
+ `managed Claude agent ${JSON.stringify(selector)} is ambiguous for tenant ${JSON.stringify(normalizedTenant)}`,
1183
+ );
1184
+ }
1185
+ const [record] = matches;
1186
+ if (typeof record.agentId !== "string"
1187
+ || !SAFE_AGENT_ID_RE.test(record.agentId)
1188
+ || typeof record.scopeParam !== "string"
1189
+ || !SAFE_SCOPE_PARAM_RE.test(record.scopeParam)
1190
+ || !/^[a-f0-9]{64}$/u.test(record.policyFingerprint || "")) {
1191
+ throw new Error("the managed Claude binding metadata is invalid; run `impel agents sync claude`");
1192
+ }
1193
+ const fileName = `${record.name}.md`;
1194
+ if (path.basename(fileName) !== fileName || !manifest.files.includes(fileName)) {
1195
+ throw new Error("the managed Claude agent mapping is invalid; run `impel agents sync claude`");
1196
+ }
1197
+ const relativePath = `agents/${fileName}`;
1198
+ if (!managedArtifactIsCurrent(root, relativePath, manifest.contentDigests?.[relativePath])) {
1199
+ throw new Error("the managed Claude agent failed its integrity check; run `impel agents sync claude`");
1200
+ }
1201
+ const launchDefinition = validateClaudeLaunchDefinition(record, normalizedTenant);
1202
+ if (contentDigest(renderClaudeAgentDefinition(record.name, launchDefinition))
1203
+ !== manifest.contentDigests?.[relativePath]) {
1204
+ throw new Error("the managed Claude launch definition does not match its agent file; run `impel agents sync claude`");
1205
+ }
1206
+ const suffix = crypto.randomBytes(12).toString("hex");
1207
+ const prefix = `impel-${record.name}`.slice(0, 63 - suffix.length - 1).replace(/-+$/u, "");
1208
+ const launchName = `${prefix}-${suffix}`;
1209
+ const launchAgentsJson = JSON.stringify({ [launchName]: launchDefinition });
1210
+ if (Buffer.byteLength(launchAgentsJson, "utf8") > MAX_CLAUDE_INLINE_AGENT_JSON_BYTES) {
1211
+ throw new Error(
1212
+ `managed Claude agent ${JSON.stringify(selector)} is too large for a safe cross-platform direct launch; shorten its synchronized catalog policy`,
1213
+ );
1214
+ }
1215
+ return {
1216
+ ...record,
1217
+ fileName,
1218
+ path: path.join(root, "agents", fileName),
1219
+ launchName,
1220
+ launchDefinition,
1221
+ launchAgentsJson,
1222
+ };
1223
+ }
1224
+
1036
1225
  export function resolveManagedCodexAgentProfile(root, tenantId, selector) {
1037
1226
  const normalizedTenant = normalizeTenantId(tenantId);
1038
1227
  const normalizedSelector = boundedString(selector, "agent selector", { max: 512 });
@@ -1236,6 +1425,9 @@ export function syncAgentProfile({
1236
1425
  policyFingerprint: agent.policyFingerprint,
1237
1426
  retired: agent.retired,
1238
1427
  name: agent.name,
1428
+ ...(client === "claude" ? {
1429
+ launchDefinition: agent.launchDefinition,
1430
+ } : {}),
1239
1431
  ...(client === "codex" && directProfiles ? {
1240
1432
  profileName: agent.profileName,
1241
1433
  profileFileName: agent.profileFileName,
package/src/cli.js CHANGED
@@ -34,6 +34,7 @@ Get started:
34
34
 
35
35
  Work:
36
36
  impel claude [args...] Launch Claude Code with an isolated Impel profile
37
+ impel claude --agent <id|exact-title> ... Run one fixed tenant agent without a parent hop
37
38
  impel codex [args...] Launch Codex with an isolated Impel profile
38
39
  impel codex --agent <id|exact-title> ... Run one fixed tenant agent without a parent hop
39
40
  impel remote handoff|dispatch|handback Move or control provider-native sessions remotely
@@ -6,7 +6,11 @@ import {
6
6
  ensureImpelClaudeProfile,
7
7
  ensureImpelCodexProfile,
8
8
  } from "../cliProfiles.js";
9
- import { resolveManagedCodexAgentProfile, syncAgentProfilesSafe } from "../agents.js";
9
+ import {
10
+ resolveManagedClaudeAgent,
11
+ resolveManagedCodexAgentProfile,
12
+ syncAgentProfilesSafe,
13
+ } from "../agents.js";
10
14
  import {
11
15
  crossAppModelsEnabled,
12
16
  loadConfig,
@@ -77,10 +81,43 @@ const IMPEL_CODEX_RUNTIME_OVERRIDES = [
77
81
  ];
78
82
 
79
83
  const IMPEL_CODEX_UNRESTRICTED_ARGUMENT = "--dangerously-bypass-approvals-and-sandbox";
84
+ const IMPEL_CLAUDE_MANAGED_AGENT_ARGUMENTS = [
85
+ "--allow-dangerously-skip-permissions",
86
+ "--permission-mode",
87
+ "bypassPermissions",
88
+ // Pinned Claude 2.1.220 treats agents, skills, commands, and settings as
89
+ // source-scoped. Load only the isolated user profile so project/local agent
90
+ // definitions cannot replace the integrity-verified adapter after launch.
91
+ "--setting-sources",
92
+ "user",
93
+ ];
80
94
 
81
- export function impelLaunchArguments(tool, argv, { codexAgentProfile = null } = {}) {
95
+ export function impelLaunchArguments(tool, argv, {
96
+ claudeManagedAgent = null,
97
+ codexAgentProfile = null,
98
+ } = {}) {
82
99
  if (!RUNTIME_BRAND.features.agents) return [...argv];
83
100
  if (tool === "claude") {
101
+ if (claudeManagedAgent) {
102
+ const { launchName, launchAgentsJson } = claudeManagedAgent;
103
+ if (typeof launchName !== "string"
104
+ || !launchName
105
+ || typeof launchAgentsJson !== "string"
106
+ || !launchAgentsJson) {
107
+ throw new Error("managed Claude launch configuration is invalid");
108
+ }
109
+ if (Buffer.byteLength(launchAgentsJson, "utf8") > 12 * 1024) {
110
+ throw new Error("managed Claude launch configuration is too large");
111
+ }
112
+ return [
113
+ ...IMPEL_CLAUDE_MANAGED_AGENT_ARGUMENTS,
114
+ "--agents",
115
+ launchAgentsJson,
116
+ "--agent",
117
+ launchName,
118
+ ...argv,
119
+ ];
120
+ }
84
121
  return ["--append-system-prompt", IMPEL_CLAUDE_PARENT_DELEGATION_INSTRUCTIONS, ...argv];
85
122
  }
86
123
  if (tool === "codex") {
@@ -99,6 +136,113 @@ export function impelLaunchArguments(tool, argv, { codexAgentProfile = null } =
99
136
  throw new Error(`unsupported CLI launcher: ${tool}`);
100
137
  }
101
138
 
139
+ const CLAUDE_MANAGED_AGENT_LOCKED_OPTIONS = new Set([
140
+ "--add-dir",
141
+ "--agents",
142
+ "--allow-tools",
143
+ "--allow-dangerously-skip-permissions",
144
+ "--allowedTools",
145
+ "--allowed-tools",
146
+ "--append-subagent-system-prompt",
147
+ "--append-system-prompt",
148
+ "--append-system-prompt-file",
149
+ "--bare",
150
+ "--brief",
151
+ "--chrome",
152
+ "--dangerously-skip-permissions",
153
+ "--disable-slash-commands",
154
+ "--disallowedTools",
155
+ "--disallowed-tools",
156
+ "--effort",
157
+ "--exclude-dynamic-system-prompt-sections",
158
+ "--fallback-model",
159
+ "--file",
160
+ "--ide",
161
+ "--managed-settings",
162
+ "--mcp-config",
163
+ "--model",
164
+ "--no-chrome",
165
+ "--permission-mode",
166
+ "--permission-prompt-tool",
167
+ "--plugin-dir",
168
+ "--plugin-dir-no-mcp",
169
+ "--plugin-url",
170
+ "--safe-mode",
171
+ "--setting-sources",
172
+ "--settings",
173
+ "--strict-mcp-config",
174
+ "--system-prompt",
175
+ "--system-prompt-file",
176
+ "--tmux",
177
+ "--tools",
178
+ "--worktree",
179
+ ]);
180
+
181
+ function claudeManagedAgentLockedOption(argument) {
182
+ if (argument === "-w" || (argument.startsWith("-w") && argument.length > 2)) return "-w";
183
+ if (CLAUDE_MANAGED_AGENT_LOCKED_OPTIONS.has(argument)) return argument;
184
+ return [...CLAUDE_MANAGED_AGENT_LOCKED_OPTIONS].find((option) =>
185
+ argument.startsWith(`${option}=`)
186
+ ) || null;
187
+ }
188
+
189
+ export function parseClaudeAgentLaunchArguments(argv) {
190
+ const passthrough = [];
191
+ const selectors = [];
192
+ let literal = false;
193
+ for (let index = 0; index < argv.length; index += 1) {
194
+ const argument = argv[index];
195
+ if (literal) {
196
+ passthrough.push(argument);
197
+ continue;
198
+ }
199
+ if (argument === "--") {
200
+ literal = true;
201
+ passthrough.push(argument);
202
+ continue;
203
+ }
204
+ if (argument === "--agent") {
205
+ const value = argv[index += 1];
206
+ selectors.push(typeof value === "string" && value && !value.startsWith("-") ? value : null);
207
+ continue;
208
+ }
209
+ if (argument.startsWith("--agent=")) {
210
+ const value = argument.slice("--agent=".length);
211
+ selectors.push(value && !value.startsWith("-") ? value : null);
212
+ continue;
213
+ }
214
+ passthrough.push(argument);
215
+ }
216
+ const selector = selectors.length === 1 ? selectors[0] : null;
217
+ return {
218
+ selector,
219
+ argv: selector === null ? [...argv] : passthrough,
220
+ };
221
+ }
222
+
223
+ export function assertManagedClaudeAgentLaunchArguments(argv) {
224
+ let literal = false;
225
+ for (let index = 0; index < argv.length; index += 1) {
226
+ const argument = argv[index];
227
+ if (literal) continue;
228
+ if (argument === "--") {
229
+ literal = true;
230
+ continue;
231
+ }
232
+ if (argument === "--agent") {
233
+ index += 1;
234
+ continue;
235
+ }
236
+ if (argument.startsWith("--agent=")) continue;
237
+ const locked = claudeManagedAgentLockedOption(argument);
238
+ if (locked) {
239
+ throw new Error(
240
+ `${locked} cannot be used with a verified managed Claude agent; Impel locks model, instructions, tools, MCP, settings, and permission policy`,
241
+ );
242
+ }
243
+ }
244
+ }
245
+
102
246
  const CODEX_AGENT_LOCKED_OPTIONS = new Set([
103
247
  "-c",
104
248
  "--config",
@@ -264,8 +408,11 @@ function runNativeCli(tool, argv, environment) {
264
408
 
265
409
  export async function cmdLaunch(tool, argv) {
266
410
  let nativeArgv = [...argv];
411
+ let claudeAgentSelector = null;
267
412
  let codexAgentSelector = null;
268
- if (tool === "codex") {
413
+ if (tool === "claude" && RUNTIME_BRAND.features.agents) {
414
+ ({ selector: claudeAgentSelector, argv: nativeArgv } = parseClaudeAgentLaunchArguments(argv));
415
+ } else if (tool === "codex") {
269
416
  try {
270
417
  ({ selector: codexAgentSelector, argv: nativeArgv } = parseCodexAgentLaunchArguments(argv));
271
418
  } catch (error) {
@@ -356,6 +503,29 @@ export async function cmdLaunch(tool, argv) {
356
503
  });
357
504
  }
358
505
 
506
+ let claudeManagedAgent = null;
507
+ if (RUNTIME_BRAND.features.agents && claudeAgentSelector !== null) {
508
+ try {
509
+ claudeManagedAgent = resolveManagedClaudeAgent(
510
+ agentProfile.root,
511
+ tenantId,
512
+ claudeAgentSelector,
513
+ );
514
+ if (claudeManagedAgent) {
515
+ assertManagedClaudeAgentLaunchArguments(nativeArgv);
516
+ } else {
517
+ // `--agent` is also Claude's native user-agent selector. If the exact
518
+ // value is not in the integrity-tracked Impel manifest, preserve the
519
+ // user's original argv and ordinary permission behavior byte-for-byte.
520
+ nativeArgv = [...argv];
521
+ }
522
+ } catch (error) {
523
+ console.error(`impel claude: ${redactSecretText(error?.message || error)}`);
524
+ process.exitCode = 1;
525
+ return;
526
+ }
527
+ }
528
+
359
529
  let codexAgentProfile = null;
360
530
  if (codexAgentSelector !== null) {
361
531
  try {
@@ -377,7 +547,10 @@ export async function cmdLaunch(tool, argv) {
377
547
  const { env: launchEnvironment } = withGitEnvironment(environment, { baseEnvironment: environment });
378
548
  const exitCode = await runNativeCli(
379
549
  tool,
380
- impelLaunchArguments(tool, nativeArgv, { codexAgentProfile }),
550
+ impelLaunchArguments(tool, nativeArgv, {
551
+ claudeManagedAgent,
552
+ codexAgentProfile,
553
+ }),
381
554
  launchEnvironment,
382
555
  );
383
556
  if (exitCode !== 0) process.exitCode = exitCode;
@@ -5,8 +5,18 @@ import { environmentValue } from "./nativeProcess.js";
5
5
  import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
6
6
 
7
7
  /** The running package's own bin script, used directly for live child spawns. */
8
- export const IMPEL_CLI_ENTRYPOINT = process.env.IMPEL_CLI_EXTENSION_ENTRYPOINT
9
- || fileURLToPath(new URL("../bin/impel.js", import.meta.url));
8
+ const PACKAGE_CLI_ENTRYPOINT = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
9
+ const extensionEntrypoint = process.env.IMPEL_CLI_EXTENSION_ENTRYPOINT;
10
+ // The public Impel package never delegates its trusted MCP command prefix to
11
+ // inherited process state. First-party extension wrappers install a validated
12
+ // non-Impel runtime brand before import, must actually be executing the named
13
+ // entrypoint, and then prove that same path in createImpelCliExtension.
14
+ export const IMPEL_CLI_ENTRYPOINT = RUNTIME_BRAND.cli.packageName === "impel-cli"
15
+ || typeof extensionEntrypoint !== "string"
16
+ || !path.isAbsolute(extensionEntrypoint)
17
+ || path.resolve(process.argv[1] || "") !== path.resolve(extensionEntrypoint)
18
+ ? PACKAGE_CLI_ENTRYPOINT
19
+ : extensionEntrypoint;
10
20
 
11
21
  // Matches a real package install (npm/pnpm/volta all place the package under
12
22
  // node_modules/impel-cli; an unsupported project-local install matches too,