impel-cli 0.14.0 → 0.14.2

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
@@ -630,8 +630,8 @@ every managed profile. This catalog is separate from automatic specialists: it
630
630
  may include truthfully declared write-capable agents, but those agents require
631
631
  explicit user selection before the server will start a run.
632
632
 
633
- - Claude Code: `<CLAUDE_CONFIG_DIR>/agents/impel-managed/*.md`
634
- - Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-managed/*.toml`
633
+ - Claude Code / Claude Desktop: `<CLAUDE_CONFIG_DIR>/agents/<agent-name>.md`
634
+ - Codex / ChatGPT Codex mode: `<CODEX_HOME>/agents/impel-<tenant>-<agent-id>.toml`
635
635
 
636
636
  Each generated agent is bound to one exact `agentId` and
637
637
  `scopeParam`. It verifies that the exact agent is still available, starts one
@@ -643,8 +643,8 @@ when invoked.
643
643
 
644
644
  Visible agent names come from the registry title. Codex keeps that title as the
645
645
  custom-agent name; Claude uses the lowercase, hyphenated form required by its
646
- native agent schema. The generated filenames remain stable tenant-and-agent
647
- identifiers so title changes update in place without colliding with user files.
646
+ native agent schema and Desktop's `@` picker requires the Markdown filename to
647
+ match that name. Codex filenames remain stable tenant-and-agent identifiers.
648
648
  Duplicate or built-in-reserved titles receive a deterministic disambiguator.
649
649
 
650
650
  The sync writes atomically and records a manifest beside the generated files.
@@ -657,6 +657,10 @@ without contacting the gateway.
657
657
  Start a new task after syncing. Claude Code detects updates to an existing
658
658
  `agents/` directory within a few seconds, but if the sync created that directory
659
659
  for the first time while Claude was already running, restart Claude once.
660
+ In Claude Desktop, selecting an agent on the first turn binds that Code session
661
+ to the agent. Follow-up `@` menus continue to offer that same bound agent and
662
+ insert a normal mention chip. Other agents remain available from a new task,
663
+ because the running Claude session cannot safely switch agents mid-session.
660
664
 
661
665
  Invocation uses the clients' native agent behavior:
662
666
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -398,10 +398,16 @@ export function renderManagedAgents(client, tenantId, agents, invocation = impel
398
398
  return agents.map((agent, index) => {
399
399
  const name = names[index];
400
400
  const extension = client === "claude" ? ".md" : ".toml";
401
+ // Claude Desktop decides whether an SDK-reported agent is user-defined by
402
+ // comparing its reported name with the Markdown filename (without .md).
403
+ // A stable tenant/agent filename causes Desktop to classify the agent as a
404
+ // builtin and remove it from the @ mention picker. Claude names are already
405
+ // collision-safe, filesystem-safe identifiers, so use them as filenames.
406
+ const fileStem = client === "claude" ? name : fileStems[index];
401
407
  const contents = client === "claude"
402
408
  ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
403
409
  : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
404
- return { agentId: agent.agentId, name, fileName: `${fileStems[index]}${extension}`, contents };
410
+ return { agentId: agent.agentId, name, fileName: `${fileStem}${extension}`, contents };
405
411
  });
406
412
  }
407
413
 
@@ -432,7 +438,7 @@ function readManifest(manifestPath) {
432
438
 
433
439
  function profileIsFresh(profile, tenantId, now, ttlMs) {
434
440
  const manifest = readManifest(path.join(profile.root, "agents", MANAGED_AGENT_DIRECTORY, MANAGED_AGENT_MANIFEST));
435
- if (!manifest || manifest.version !== 2 || manifest.tenantId !== tenantId) return false;
441
+ if (!manifest || manifest.version !== 3 || manifest.tenantId !== tenantId) return false;
436
442
  const syncedAt = Date.parse(manifest.syncedAt || "");
437
443
  if (!Number.isFinite(syncedAt) || now - syncedAt >= ttlMs) return false;
438
444
  return manifest.files.every((fileName) =>
@@ -455,7 +461,7 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
455
461
  const prior = readManifest(manifestPath);
456
462
  const rendered = renderManagedAgents(client, tenantId, agents);
457
463
  const priorFiles = new Set(prior?.files || []);
458
- const priorUsesDiscoveryRoot = prior?.version === 2;
464
+ const priorUsesDiscoveryRoot = prior?.version === 2 || prior?.version === 3;
459
465
 
460
466
  // Native clients discover standalone definitions directly under `agents/`.
461
467
  // Preflight every destination before writing so an unmanaged file with the
@@ -497,7 +503,7 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
497
503
  }
498
504
  }
499
505
  atomicPrivateWrite(manifestPath, `${JSON.stringify({
500
- version: 2,
506
+ version: 3,
501
507
  tenantId,
502
508
  client,
503
509
  syncedAt: new Date(now).toISOString(),
package/src/apps.js CHANGED
@@ -83,6 +83,12 @@ const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr(
83
83
  const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
84
84
  const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
85
85
  const IMPEL_CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:dR(Oe()).apiKey}})'.padEnd(CLAUDE_PLAN_USAGE_REQUEST.length, " ");
86
+ const CLAUDE_DRAFT_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===Be&&s';
87
+ const IMPEL_CLAUDE_BOUND_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===Be?s:Le?.agent??!1';
88
+ const CLAUDE_AGENT_MENTION_FILTER = 'return e.filter(e=>"user"===e.source&&(!s||e.name.toLowerCase().includes(s))).slice(0,10)';
89
+ const IMPEL_CLAUDE_BOUND_AGENT_MENTION_FILTER = 'return e.filter(e=>"user"===e.source&&(!s||e.name.toLowerCase().includes(s))&&("string"!=typeof y||e.name===y)).slice(0,10)';
90
+ const CLAUDE_AGENT_MENTION_SELECT = 'onSelect:t=>(e(String(t)),Promise.resolve(null))';
91
+ const IMPEL_CLAUDE_BOUND_AGENT_MENTION_SELECT = 'onSelect:t=>"string"==typeof y?Promise.resolve({chipText:String(t)}):(e(String(t)),Promise.resolve(null))';
86
92
  const LEGACY_CLAUDE_SAFE_STORAGE_NAME = "Claude";
87
93
  const CLAUDE_SAFE_STORAGE_METADATA = "safe-storage.json";
88
94
 
@@ -154,7 +160,7 @@ export const CURRENT_CONFIG_VERSION = 10;
154
160
  // — which is what made every `impel update` re-trigger macOS permission
155
161
  // prompts. Bump this ONLY when a code change alters the bytes of a built
156
162
  // bundle; leave it alone for changes that don't touch bundle contents.
157
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-16.3";
163
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-17.1";
158
164
 
159
165
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
160
166
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -205,6 +211,12 @@ export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } =
205
211
  && compatibility.browserData === status.browserData
206
212
  ))
207
213
  && managedBundleMatchesCompatibility(status, compatibility)
214
+ && (status.target !== "claude" || (
215
+ compatibility.patches?.followupBoundAgentEnable === 1
216
+ && compatibility.patches?.followupBoundAgentFilter === 1
217
+ && compatibility.patches?.followupBoundAgentChip === 1
218
+ && managedClaudeRendererMatchesCompatibility(status, compatibility)
219
+ ))
208
220
  && (status.target !== "chatgpt" || (
209
221
  compatibility.vendorSignaturesPreserved === true
210
222
  && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
@@ -214,6 +226,19 @@ export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } =
214
226
  );
215
227
  }
216
228
 
229
+ function managedClaudeRendererMatchesCompatibility(status, compatibility) {
230
+ try {
231
+ const renderer = compatibility.rendererAssets?.agentMentions;
232
+ if (!renderer?.path || !renderer.sha256 || path.isAbsolute(renderer.path)) return false;
233
+ const root = path.resolve(status.launcher, "Contents", "Resources", "ion-dist");
234
+ const target = path.resolve(root, renderer.path);
235
+ if (!target.startsWith(`${root}${path.sep}`)) return false;
236
+ return sha256(fs.readFileSync(target)) === renderer.sha256;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
217
242
  function managedBundleMatchesCompatibility(status, compatibility) {
218
243
  try {
219
244
  const managedVendorBundle = status.target === "claude"
@@ -1153,6 +1178,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1153
1178
  );
1154
1179
  }
1155
1180
 
1181
+ const agentMentionPatches = patchClaudeFollowupAgentMentions(staging);
1156
1182
  const oldAsarHash = asarHeaderHash(asarPath);
1157
1183
  let planUsageGuardPatchCount = 0;
1158
1184
  let planUsageRequestPatchCount = 0;
@@ -1193,7 +1219,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1193
1219
 
1194
1220
  const macos = path.join(staging, "Contents", "MacOS");
1195
1221
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1196
- schemaVersion: 3,
1222
+ schemaVersion: 4,
1197
1223
  cliVersion: CLI_VERSION,
1198
1224
  bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1199
1225
  signingMode: desiredSigningMode(),
@@ -1209,6 +1235,15 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1209
1235
  aggregatePlanUsageGuard: planUsageGuardPatchCount,
1210
1236
  aggregatePlanUsageRequest: planUsageRequestPatchCount,
1211
1237
  safeStorageName: safeStorageNamePatchCount,
1238
+ followupBoundAgentEnable: agentMentionPatches.enable,
1239
+ followupBoundAgentFilter: agentMentionPatches.filter,
1240
+ followupBoundAgentChip: agentMentionPatches.chip,
1241
+ },
1242
+ rendererAssets: {
1243
+ agentMentions: {
1244
+ path: agentMentionPatches.path,
1245
+ sha256: agentMentionPatches.sha256,
1246
+ },
1212
1247
  },
1213
1248
  asarHeaderSha256: newAsarHash,
1214
1249
  }, null, 2) + "\n", 0o644);
@@ -1330,6 +1365,85 @@ function linkVendoredChatGPTResources(bundle, vendorBundleName) {
1330
1365
  }
1331
1366
  }
1332
1367
 
1368
+ function patchClaudeFollowupAgentMentions(bundle) {
1369
+ const version = bundleVersion(bundle);
1370
+ if (version !== PINNED_VENDOR_APPS.claude.version) {
1371
+ throw new Error(
1372
+ `Claude follow-up agent mentions support is pinned to ${PINNED_VENDOR_APPS.claude.version}; found ${version || "unknown"}`,
1373
+ );
1374
+ }
1375
+
1376
+ const ionDist = path.join(bundle, "Contents", "Resources", "ion-dist");
1377
+ const contracts = [
1378
+ CLAUDE_DRAFT_AGENT_MENTIONS,
1379
+ CLAUDE_AGENT_MENTION_FILTER,
1380
+ CLAUDE_AGENT_MENTION_SELECT,
1381
+ ];
1382
+ const candidates = listJavaScriptFiles(ionDist).filter((candidate) => {
1383
+ const source = fs.readFileSync(candidate, "utf8");
1384
+ return contracts.every((contract) => source.includes(contract));
1385
+ });
1386
+ if (candidates.length !== 1) {
1387
+ throw new Error(
1388
+ `Claude follow-up agent-mention renderer expected 1 matching asset, found ${candidates.length}; update impel-cli before installing this desktop app version`,
1389
+ );
1390
+ }
1391
+
1392
+ const renderer = candidates[0];
1393
+ let source = fs.readFileSync(renderer, "utf8");
1394
+ const enable = exactTextMatchCount(source, CLAUDE_DRAFT_AGENT_MENTIONS);
1395
+ const filter = exactTextMatchCount(source, CLAUDE_AGENT_MENTION_FILTER);
1396
+ const chip = exactTextMatchCount(source, CLAUDE_AGENT_MENTION_SELECT);
1397
+ for (const [label, count] of [["enable", enable], ["filter", filter], ["chip", chip]]) {
1398
+ if (count !== 1) {
1399
+ throw new Error(`Claude follow-up agent-mention ${label} patch expected 1 match, found ${count}`);
1400
+ }
1401
+ }
1402
+
1403
+ source = source
1404
+ .replace(CLAUDE_DRAFT_AGENT_MENTIONS, IMPEL_CLAUDE_BOUND_AGENT_MENTIONS)
1405
+ .replace(CLAUDE_AGENT_MENTION_FILTER, IMPEL_CLAUDE_BOUND_AGENT_MENTION_FILTER)
1406
+ .replace(CLAUDE_AGENT_MENTION_SELECT, IMPEL_CLAUDE_BOUND_AGENT_MENTION_SELECT);
1407
+ writeAtomic(renderer, source, fs.statSync(renderer).mode & 0o777);
1408
+ return {
1409
+ enable,
1410
+ filter,
1411
+ chip,
1412
+ path: path.relative(ionDist, renderer).split(path.sep).join("/"),
1413
+ sha256: sha256(Buffer.from(source, "utf8")),
1414
+ };
1415
+ }
1416
+
1417
+ function listJavaScriptFiles(root) {
1418
+ const files = [];
1419
+ const visit = (directory) => {
1420
+ let entries;
1421
+ try {
1422
+ entries = fs.readdirSync(directory, { withFileTypes: true });
1423
+ } catch (error) {
1424
+ if (error?.code === "ENOENT") return;
1425
+ throw error;
1426
+ }
1427
+ for (const entry of entries) {
1428
+ const target = path.join(directory, entry.name);
1429
+ if (entry.isDirectory()) visit(target);
1430
+ else if (entry.isFile() && entry.name.endsWith(".js")) files.push(target);
1431
+ }
1432
+ };
1433
+ visit(root);
1434
+ return files.sort();
1435
+ }
1436
+
1437
+ function exactTextMatchCount(source, expected) {
1438
+ let count = 0;
1439
+ for (
1440
+ let offset = source.indexOf(expected);
1441
+ offset !== -1;
1442
+ offset = source.indexOf(expected, offset + expected.length)
1443
+ ) count += 1;
1444
+ return count;
1445
+ }
1446
+
1333
1447
  function patchFixedWidthAsarString(asarPath, original, replacement, label) {
1334
1448
  if (Buffer.byteLength(original, "latin1") !== Buffer.byteLength(replacement, "latin1")) {
1335
1449
  throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);