impel-cli 0.20.0 → 0.20.2-beta.0

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
@@ -9,8 +9,16 @@ import fs from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
11
 
12
- import { normalizeGatewayUrl, redactSecretText } from "./config.js";
13
- import { impelMcpInvocation } from "./selfInvocation.js";
12
+ import {
13
+ CONFIG_DIR,
14
+ normalizeGatewayUrl,
15
+ redactCredentialText,
16
+ redactSecretText,
17
+ } from "./config.js";
18
+ import {
19
+ IMPEL_NATIVE_AGENT_MCP_TARGET,
20
+ impelNativeAgentMcpInvocation,
21
+ } from "./selfInvocation.js";
14
22
  import { normalizeTenantId } from "./tenants.js";
15
23
  import { renameWithWindowsRetry } from "./windowsFs.js";
16
24
 
@@ -20,14 +28,21 @@ export const MANAGED_AGENT_MANIFEST = ".manifest.json";
20
28
  export const NATIVE_AGENT_LIST_TOOL = "impel_specialists-list_native_agents";
21
29
  export const NATIVE_AGENT_START_TOOL = "impel_specialists-start_native_agent_run";
22
30
  export const NATIVE_AGENT_READ_TOOL = "impel_specialists-read_native_agent_run";
31
+ export const NATIVE_AGENT_RUN_TOOL = "run_native_agent";
32
+ export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
33
+ export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
23
34
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
24
- export const MANAGED_AGENT_MANIFEST_VERSION = 4;
35
+ export const MANAGED_AGENT_MANIFEST_VERSION = 6;
25
36
 
26
37
  const NATIVE_AGENT_TOOL_NAMES = [
27
- NATIVE_AGENT_LIST_TOOL,
28
- NATIVE_AGENT_START_TOOL,
29
- NATIVE_AGENT_READ_TOOL,
38
+ NATIVE_AGENT_RUN_TOOL,
39
+ NATIVE_AGENT_RESUME_TOOL,
40
+ NATIVE_AGENT_RECOVER_TOOL,
30
41
  ];
42
+ const NATIVE_AGENT_RECOVERY_TOOL_NAMES = [NATIVE_AGENT_RESUME_TOOL, NATIVE_AGENT_RECOVER_TOOL];
43
+ const MAX_RETIRED_AGENT_BINDINGS = 50;
44
+ const MAX_NATIVE_AGENT_STATE_BYTES = 512 * 1024;
45
+ const TERMINAL_NATIVE_AGENT_STATUSES = new Set(["succeeded", "failed", "cancelled", "canceled"]);
31
46
  const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
32
47
  const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
33
48
  const MAX_CATALOG_ITEMS = 500;
@@ -284,6 +299,222 @@ function agentBindingHash(agent) {
284
299
  .slice(0, 8);
285
300
  }
286
301
 
302
+ function stablePolicyValue(value) {
303
+ if (Array.isArray(value)) return value.map(stablePolicyValue);
304
+ if (!value || typeof value !== "object") return value;
305
+ const result = {};
306
+ for (const key of Object.keys(value).sort()) result[key] = stablePolicyValue(value[key]);
307
+ return result;
308
+ }
309
+
310
+ /** Fingerprint the complete synchronized routing and side-effect policy. */
311
+ export function nativeAgentPolicyFingerprint(agent) {
312
+ return crypto.createHash("sha256")
313
+ .update(JSON.stringify(stablePolicyValue(agent)))
314
+ .digest("hex");
315
+ }
316
+
317
+ function validatedPendingState(filePath, invocationId) {
318
+ let serialized;
319
+ try {
320
+ const stat = fs.lstatSync(filePath);
321
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_NATIVE_AGENT_STATE_BYTES) {
322
+ throw new Error("unsafe state file");
323
+ }
324
+ serialized = fs.readFileSync(filePath, "utf8");
325
+ } catch {
326
+ throw new Error("native-agent run state is unsafe or invalid during profile sync");
327
+ }
328
+ if (redactCredentialText(serialized) !== serialized) {
329
+ throw new Error("native-agent run state is unsafe or invalid during profile sync");
330
+ }
331
+ let state;
332
+ try {
333
+ state = JSON.parse(serialized);
334
+ } catch {
335
+ throw new Error("native-agent run state is invalid during profile sync");
336
+ }
337
+ let stateTenant;
338
+ try {
339
+ stateTenant = normalizeTenantId(state?.tenantId);
340
+ } catch {
341
+ throw new Error("native-agent run state failed integrity validation during profile sync");
342
+ }
343
+ const allowed = new Set([
344
+ "schema", "invocationId", "tenantId", "agentId", "scopeParam", "policyFingerprint",
345
+ "requestFingerprint", "idempotencyKey", "startArguments", "upstreamRunId", "status",
346
+ "startAttempts", "createdAt", "updatedAt", "fence", "sequence", "lastTransportError",
347
+ "result", "output", "error",
348
+ ]);
349
+ if (!state || typeof state !== "object" || Array.isArray(state)
350
+ || Object.keys(state).some((key) => !allowed.has(key))
351
+ || state.schema !== "impel.native-agent-state.v1"
352
+ || state.invocationId !== invocationId
353
+ || !/^[a-f0-9-]{36}$/u.test(state.invocationId)
354
+ || stateTenant !== state.tenantId
355
+ || !SAFE_AGENT_ID_RE.test(state.agentId)
356
+ || !SAFE_SCOPE_PARAM_RE.test(state.scopeParam)
357
+ || !/^[a-f0-9]{64}$/u.test(state.policyFingerprint)
358
+ || !/^[a-f0-9]{64}$/u.test(state.requestFingerprint)
359
+ || typeof state.idempotencyKey !== "string"
360
+ || !/^[A-Za-z0-9._:-]{16,200}$/u.test(state.idempotencyKey)
361
+ || typeof state.status !== "string"
362
+ || !state.status
363
+ || !Number.isSafeInteger(state.startAttempts)
364
+ || state.startAttempts < 0
365
+ || !Number.isFinite(Date.parse(state.createdAt))
366
+ || !Number.isFinite(Date.parse(state.updatedAt))
367
+ || (state.fence !== undefined && (!Number.isSafeInteger(state.fence) || state.fence < 0))
368
+ || (state.sequence !== undefined && (!Number.isSafeInteger(state.sequence) || state.sequence < 0))
369
+ || (state.upstreamRunId !== null
370
+ && (typeof state.upstreamRunId !== "string" || !state.upstreamRunId))) {
371
+ throw new Error("native-agent run state failed integrity validation during profile sync");
372
+ }
373
+ const args = state.startArguments;
374
+ const allowedArgs = new Set([
375
+ "agentId", "scopeParam", "task", "context", "contextKeys", "confirmedSideEffects", "idempotencyKey",
376
+ ]);
377
+ if (!args || typeof args !== "object" || Array.isArray(args)
378
+ || Object.keys(args).some((key) => !allowedArgs.has(key))
379
+ || args.agentId !== state.agentId
380
+ || args.scopeParam !== state.scopeParam
381
+ || args.idempotencyKey !== state.idempotencyKey
382
+ || typeof args.task !== "string"
383
+ || !args.task.trim()
384
+ || args.task.length > 40_000
385
+ || (args.context !== undefined && (typeof args.context !== "string" || args.context.length > 40_000))
386
+ || !Array.isArray(args.contextKeys)
387
+ || args.contextKeys.length > 30
388
+ || args.contextKeys.some((key) => typeof key !== "string" || !key.trim() || key.length > 160)
389
+ || new Set(args.contextKeys).size !== args.contextKeys.length
390
+ || (args.confirmedSideEffects !== undefined && typeof args.confirmedSideEffects !== "boolean")) {
391
+ throw new Error("native-agent run state failed integrity validation during profile sync");
392
+ }
393
+ const fingerprint = crypto.createHash("sha256").update(JSON.stringify(stablePolicyValue({
394
+ tenantId: state.tenantId,
395
+ agentId: state.agentId,
396
+ scopeParam: state.scopeParam,
397
+ policyFingerprint: state.policyFingerprint,
398
+ arguments: args,
399
+ }))).digest("hex");
400
+ if (fingerprint !== state.requestFingerprint) {
401
+ throw new Error("native-agent run state failed integrity validation during profile sync");
402
+ }
403
+ return state;
404
+ }
405
+
406
+ function hasNativeAgentDeletionTombstone(tenantRoot, invocationId) {
407
+ const tombstonePath = path.join(tenantRoot, `${invocationId}.tombstone`);
408
+ if (!fs.existsSync(tombstonePath)) return false;
409
+ const stat = fs.lstatSync(tombstonePath);
410
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) {
411
+ throw new Error("native-agent deletion tombstone is unsafe or invalid during profile sync");
412
+ }
413
+ let tombstone;
414
+ try {
415
+ tombstone = JSON.parse(fs.readFileSync(tombstonePath, "utf8"));
416
+ } catch {
417
+ throw new Error("native-agent deletion tombstone is invalid during profile sync");
418
+ }
419
+ const allowed = new Set(["schema", "invocationId", "fence", "deletedAt"]);
420
+ if (!tombstone
421
+ || typeof tombstone !== "object"
422
+ || Array.isArray(tombstone)
423
+ || Object.keys(tombstone).some((key) => !allowed.has(key))
424
+ || tombstone.schema !== "impel.native-agent-deletion.v1"
425
+ || tombstone.invocationId !== invocationId
426
+ || !Number.isSafeInteger(tombstone.fence)
427
+ || tombstone.fence < 1
428
+ || !Number.isFinite(Date.parse(tombstone.deletedAt))) {
429
+ throw new Error("native-agent deletion tombstone is invalid during profile sync");
430
+ }
431
+ return true;
432
+ }
433
+
434
+ /** Discover only pending bindings from the highest valid fenced state revision. */
435
+ export function pendingNativeAgentBindings(tenantId, {
436
+ runsRoot = path.join(CONFIG_DIR, "native-agent-runs"),
437
+ } = {}) {
438
+ const normalizedTenant = normalizeTenantId(tenantId);
439
+ const tenantRoot = path.join(runsRoot, normalizedTenant);
440
+ if (!fs.existsSync(tenantRoot)) return [];
441
+ const rootStat = fs.lstatSync(tenantRoot);
442
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
443
+ throw new Error("native-agent run directory is unsafe or invalid");
444
+ }
445
+ const bindings = new Map();
446
+ for (const name of fs.readdirSync(tenantRoot)) {
447
+ if (!/^[a-f0-9-]{36}\.json$/u.test(name)) continue;
448
+ const invocationId = path.basename(name, ".json");
449
+ if (hasNativeAgentDeletionTombstone(tenantRoot, invocationId)) continue;
450
+ const candidates = [{ filePath: path.join(tenantRoot, name), expected: null }];
451
+ const versionsPath = path.join(tenantRoot, `${invocationId}.state-versions`);
452
+ if (fs.existsSync(versionsPath)) {
453
+ const stat = fs.lstatSync(versionsPath);
454
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
455
+ throw new Error("native-agent state revisions are unsafe or invalid during profile sync");
456
+ }
457
+ for (const revision of fs.readdirSync(versionsPath)) {
458
+ if (!revision.endsWith(".json")) continue;
459
+ const match = /^(\d{16})-(\d{16})-[a-f0-9]{32}\.json$/u.exec(revision);
460
+ const expected = match ? [Number(match[1]), Number(match[2])] : null;
461
+ if (!match
462
+ || path.basename(revision) !== revision
463
+ || !expected.every(Number.isSafeInteger)) {
464
+ throw new Error("native-agent state revision is invalid during profile sync");
465
+ }
466
+ candidates.push({
467
+ filePath: path.join(versionsPath, revision),
468
+ expected,
469
+ });
470
+ }
471
+ }
472
+ let latest = null;
473
+ for (const candidate of candidates) {
474
+ const state = validatedPendingState(candidate.filePath, invocationId);
475
+ if (state.tenantId !== normalizedTenant) {
476
+ throw new Error("native-agent run state failed tenant validation during profile sync");
477
+ }
478
+ const coordinates = [state.fence ?? 0, state.sequence ?? 0];
479
+ if (candidate.expected
480
+ && (coordinates[0] !== candidate.expected[0]
481
+ || coordinates[1] !== candidate.expected[1])) {
482
+ throw new Error("native-agent state revision failed integrity validation during profile sync");
483
+ }
484
+ if (!latest
485
+ || coordinates[0] > latest.coordinates[0]
486
+ || (coordinates[0] === latest.coordinates[0] && coordinates[1] > latest.coordinates[1])) {
487
+ latest = { state, coordinates };
488
+ } else if (coordinates[0] === latest.coordinates[0]
489
+ && coordinates[1] === latest.coordinates[1]
490
+ && JSON.stringify(stablePolicyValue(state)) !== JSON.stringify(stablePolicyValue(latest.state))) {
491
+ throw new Error("native-agent state revisions conflict during profile sync");
492
+ }
493
+ }
494
+ if (!latest || TERMINAL_NATIVE_AGENT_STATUSES.has(latest.state.status)) continue;
495
+ const state = latest.state;
496
+ const key = `${state.agentId}\0${state.scopeParam}\0${state.policyFingerprint}`;
497
+ const prior = bindings.get(key);
498
+ if (!prior || Date.parse(state.updatedAt) > Date.parse(prior.updatedAt)) {
499
+ bindings.set(key, {
500
+ agentId: state.agentId,
501
+ scopeParam: state.scopeParam,
502
+ policyFingerprint: state.policyFingerprint,
503
+ updatedAt: state.updatedAt,
504
+ });
505
+ }
506
+ }
507
+ const result = [...bindings.values()].sort((left, right) =>
508
+ Date.parse(right.updatedAt) - Date.parse(left.updatedAt)
509
+ || left.agentId.localeCompare(right.agentId)
510
+ || left.scopeParam.localeCompare(right.scopeParam)
511
+ );
512
+ if (result.length > MAX_RETIRED_AGENT_BINDINGS) {
513
+ throw new Error("too many pending native-agent bindings to preserve recovery adapters");
514
+ }
515
+ return result;
516
+ }
517
+
287
518
  function generatedAgentFileStems(tenantId, agents) {
288
519
  const used = new Map();
289
520
  return agents.map((agent) => {
@@ -322,21 +553,20 @@ function generatedClientAgentNames(client, agents) {
322
553
  }
323
554
 
324
555
  function claudeAdapterInstructions(tenantId, agent) {
325
- const toolName = nativeToolName;
326
556
  const contextRequirement = agent.requiredContext.length
327
557
  ? ` Required context keys are ${JSON.stringify(agent.requiredContext)}; if any are absent, ask for them before starting the run.`
328
558
  : "";
329
559
  const sideEffectInstruction = agent.sideEffects === "writes"
330
- ? `The catalog declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request; that explicit selection is the required side-effect confirmation, so pass confirmedSideEffects true. If the agent was chosen automatically or the selection is ambiguous, do not start it and ask the user to select it explicitly.`
331
- : `The catalog declares that this agent is read-only; omit confirmedSideEffects.`;
560
+ ? `The synchronized policy declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request. If the agent was chosen automatically or the selection is ambiguous, do not start it and ask the user to select it explicitly.`
561
+ : `The synchronized policy declares that this agent is read-only.`;
332
562
  return [
333
563
  `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
334
564
  `Do not perform the assigned task yourself and do not delegate to any other agent.`,
335
- `First call ${toolName(NATIVE_AGENT_LIST_TOOL)} and verify that the exact agentId is still available with sideEffects ${JSON.stringify(agent.sideEffects)}. If it is unavailable or its policy excludes the request, stop with that explicit error.`,
565
+ `Confirm that the request fits the synchronized capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)}.${contextRequirement}`,
336
566
  sideEffectInstruction,
337
- `Call ${toolName(NATIVE_AGENT_START_TOOL)} exactly once with agentId ${JSON.stringify(agent.agentId)}, scopeParam ${JSON.stringify(agent.scopeParam)}, task set to the complete assigned task, optional context set to one string containing all supplied context (omit it when no context was supplied), contextKeys naming the context fields present in that string, confirmedSideEffects as directed above, and one stable idempotencyKey that you reuse for this logical task.${contextRequirement}`,
338
- `Then call ${toolName(NATIVE_AGENT_READ_TOOL)} with the returned runId and waitSeconds 20 until the run reaches a terminal state.`,
339
- `When it succeeds, return result.finalText faithfully as the answer. When it fails, return the durable runId, preserved output, and error. Never invent or independently synthesize a replacement result.`,
567
+ `Call ${nativeToolName(NATIVE_AGENT_RUN_TOOL)} exactly once with task 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.`,
568
+ `If that call returns an ${JSON.stringify("impel.native-agent-run.v1")} handle, call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with exactly that same handle until the transport returns a terminal result. If the call is cancelled, the connection closes, or its result is unknown before you receive a handle, call ${nativeToolName(NATIVE_AGENT_RECOVER_TOOL)} with {} and resume the newest returned pending handle; if multiple handles cannot be safely associated with this request, report them instead of choosing. Never call run_native_agent again for this request, and never restart, nudge, replace, or independently poll the upstream run.`,
569
+ `When it succeeds, return finalText byte-for-byte as the answer with no preface or rewriting. When it fails, return the preserved runId, output, and error. Never invent or independently synthesize a replacement result.`,
340
570
  ].join(" ");
341
571
  }
342
572
 
@@ -344,179 +574,46 @@ function nativeToolName(toolName) {
344
574
  return `mcp__${MANAGED_AGENT_MCP_SERVER}__${toolName}`;
345
575
  }
346
576
 
347
- const CODEX_TASK_PLACEHOLDER = "__IMPEL_COMPLETE_ASSIGNED_TASK_JSON__";
348
- const CODEX_CONTEXT_PLACEHOLDER = "__IMPEL_OPTIONAL_CONTEXT_STRING_OR_NULL_JSON__";
349
- const CODEX_CONTEXT_KEYS_PLACEHOLDER = "__IMPEL_SUPPLIED_CONTEXT_KEYS_JSON__";
350
- const CODEX_IDEMPOTENCY_KEY_PLACEHOLDER = "__IMPEL_LOGICAL_INVOCATION_IDEMPOTENCY_KEY_JSON__";
351
-
352
- export function renderCodexAdapterOrchestration(tenantId, agent) {
353
- const expectedTenantId = normalizeTenantId(tenantId);
354
- const nestedToolName = (toolName) => nativeToolName(toolName).replaceAll("-", "_");
355
- const listTool = nestedToolName(NATIVE_AGENT_LIST_TOOL);
356
- const startTool = nestedToolName(NATIVE_AGENT_START_TOOL);
357
- const readTool = nestedToolName(NATIVE_AGENT_READ_TOOL);
358
- return [
359
- '// @exec: {"yield_time_ms": 30000, "max_output_tokens": 30000}',
360
- `const assignedTask = ${CODEX_TASK_PLACEHOLDER};`,
361
- `const suppliedContext = ${CODEX_CONTEXT_PLACEHOLDER};`,
362
- `const suppliedContextKeys = ${CODEX_CONTEXT_KEYS_PLACEHOLDER};`,
363
- `const idempotencyKey = ${CODEX_IDEMPOTENCY_KEY_PLACEHOLDER};`,
364
- `const expectedTenantId = ${JSON.stringify(expectedTenantId)};`,
365
- `const expectedAgent = ${JSON.stringify(agent)};`,
366
- "const waitSeconds = 20; // Compatible fallback until the bound server advertises a larger ceiling.",
367
- `const listToolName = ${JSON.stringify(listTool)};`,
368
- `const startToolName = ${JSON.stringify(startTool)};`,
369
- `const readToolName = ${JSON.stringify(readTool)};`,
370
- "",
371
- "function stableValue(value) {",
372
- " if (Array.isArray(value)) return value.map(stableValue);",
373
- ' if (!value || typeof value !== "object") return value;',
374
- " const result = {};",
375
- " for (const key of Object.keys(value).sort()) result[key] = stableValue(value[key]);",
376
- " return result;",
377
- "}",
378
- "",
379
- "function contentText(response) {",
380
- ' return response?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;',
381
- "}",
382
- "",
383
- "function toolPayload(response, label) {",
384
- ' if (!response || typeof response !== "object") throw new Error(label + " returned no result");',
385
- ' if (response.isError) throw new Error(contentText(response) || label + " failed");',
386
- ' if (response.structuredContent && typeof response.structuredContent === "object") return response.structuredContent;',
387
- " const serialized = contentText(response);",
388
- " if (serialized !== undefined) {",
389
- " try {",
390
- " return JSON.parse(serialized);",
391
- " } catch {",
392
- ' throw new Error(label + " returned invalid JSON");',
393
- " }",
394
- " }",
395
- " return response;",
396
- "}",
397
- "",
398
- "function preservedOutput(run) {",
399
- ' if (run && Object.prototype.hasOwnProperty.call(run, "output")) return run.output;',
400
- ' if (run && Object.prototype.hasOwnProperty.call(run, "result")) return run.result;',
401
- " return null;",
402
- "}",
403
- "",
404
- "function errorValue(error, fallback) {",
405
- " if (error === undefined || error === null) return fallback;",
406
- ' if (error instanceof Error) return error.message;',
407
- " return error;",
408
- "}",
409
- "",
410
- "async function orchestrate() {",
411
- " let runId = null;",
412
- " let latestRun = null;",
413
- " try {",
414
- ' if (typeof assignedTask !== "string" || !assignedTask.trim()) throw new Error("the complete assigned task is required");',
415
- ' if (suppliedContext !== null && typeof suppliedContext !== "string") throw new Error("supplied context must be a string or null");',
416
- ' if (expectedAgent.requiredContext.length && (typeof suppliedContext !== "string" || !suppliedContext.trim())) {',
417
- ' throw new Error("nonblank supplied context is required for required context keys");',
418
- " }",
419
- ' if (!Array.isArray(suppliedContextKeys)) throw new Error("supplied context keys must be an array");',
420
- ' if (typeof idempotencyKey !== "string" || !/^[A-Za-z0-9._:-]{16,160}$/u.test(idempotencyKey)) {',
421
- ' throw new Error("a unique stable logical-invocation idempotencyKey is required");',
422
- " }",
423
- " const missingContext = expectedAgent.requiredContext.filter((key) =>",
424
- " !suppliedContextKeys.includes(key)",
425
- " );",
426
- ' if (missingContext.length) throw new Error("missing required context: " + missingContext.join(", "));',
427
- "",
428
- " const catalog = toolPayload(await tools[listToolName]({}), \"native-agent catalog\");",
429
- ' if (catalog.orgId !== expectedTenantId) throw new Error("native-agent catalog tenant mismatch");',
430
- ' if (!Array.isArray(catalog.agents)) throw new Error("native-agent catalog returned no agents");',
431
- " const matches = catalog.agents.filter((candidate) =>",
432
- " candidate?.agentId === expectedAgent.agentId && candidate?.scopeParam === expectedAgent.scopeParam",
433
- " );",
434
- ' if (matches.length !== 1) throw new Error("exact native-agent binding is unavailable");',
435
- " const actualAgent = {};",
436
- " for (const key of Object.keys(expectedAgent)) actualAgent[key] = matches[0][key];",
437
- " if (JSON.stringify(stableValue(actualAgent)) !== JSON.stringify(stableValue(expectedAgent))) {",
438
- ' throw new Error("native-agent catalog policy mismatch");',
439
- " }",
440
- "",
441
- " const startArguments = {",
442
- " agentId: expectedAgent.agentId,",
443
- " scopeParam: expectedAgent.scopeParam,",
444
- " task: assignedTask,",
445
- " contextKeys: suppliedContextKeys,",
446
- " idempotencyKey,",
447
- " };",
448
- " if (suppliedContext !== null) startArguments.context = suppliedContext;",
449
- ' if (expectedAgent.sideEffects === "writes") startArguments.confirmedSideEffects = true;',
450
- " latestRun = toolPayload(await tools[startToolName](startArguments), \"native-agent start\");",
451
- ' if (typeof latestRun.runId !== "string" || !latestRun.runId) throw new Error("native-agent start returned no runId");',
452
- " runId = latestRun.runId;",
453
- "",
454
- " for (;;) {",
455
- " const observed = toolPayload(",
456
- " await tools[readToolName]({ runId, waitSeconds }),",
457
- ' "native-agent read",',
458
- " );",
459
- ' if (observed.runId !== undefined && observed.runId !== runId) throw new Error("native-agent read returned a different runId");',
460
- " latestRun = observed;",
461
- ' if (observed.status === "succeeded") {',
462
- ' const finalText = observed.result?.finalText;',
463
- ' if (typeof finalText !== "string") throw new Error("succeeded native-agent run returned no finalText");',
464
- " text(finalText);",
465
- " return;",
466
- " }",
467
- ' if (observed.status === "failed") {',
468
- " text(JSON.stringify({",
469
- " runId,",
470
- " output: preservedOutput(observed),",
471
- ' error: errorValue(observed.error, "native-agent run failed"),',
472
- " }));",
473
- " return;",
474
- " }",
475
- " }",
476
- " } catch (error) {",
477
- " text(JSON.stringify({",
478
- " runId,",
479
- " output: preservedOutput(latestRun),",
480
- ' error: errorValue(error, "native-agent adapter failed"),',
481
- " }));",
482
- " }",
483
- "}",
484
- "",
485
- "await orchestrate();",
486
- ].join("\n");
487
- }
488
-
489
577
  function codexAdapterInstructions(tenantId, agent) {
490
578
  const contextRequirement = agent.requiredContext.length
491
579
  ? ` Required context keys are ${JSON.stringify(agent.requiredContext)}; if any are absent, ask for them before starting the run.`
492
580
  : "";
493
581
  const sideEffectInstruction = agent.sideEffects === "writes"
494
- ? `The catalog declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request; that explicit selection is the required side-effect confirmation. If the agent was chosen automatically or the selection is ambiguous, do not run the orchestration and ask the user to select this exact agent explicitly.`
495
- : `The catalog declares that this agent is read-only; the orchestration omits confirmedSideEffects.`;
496
- const source = renderCodexAdapterOrchestration(tenantId, agent);
582
+ ? `The synchronized policy declares that this agent writes user or workspace data. Use this adapter only when the user explicitly selected this exact agent for the current request. If the agent was chosen automatically or the selection is ambiguous, ask the user to select this exact agent explicitly.`
583
+ : `The synchronized policy declares that this agent is read-only.`;
497
584
  return [
498
585
  `You are a thin transport adapter for the exact Impel native agent ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
499
586
  `Callers must spawn this explicit custom Codex agent with fork_turns="none" and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior.`,
500
587
  `Do not perform the assigned task yourself, do not delegate to any other agent, and do not independently synthesize or rewrite the result.`,
501
588
  `Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before starting.${contextRequirement}`,
502
589
  sideEffectInstruction,
503
- `Invoke functions.exec exactly once for the orchestration below. Do not call the MCP tools directly or select a separate MCP call for any poll. Replace ${CODEX_TASK_PLACEHOLDER} with a JSON string literal for the complete assigned task. Replace ${CODEX_CONTEXT_PLACEHOLDER} with one JSON string literal containing all supplied caller context, or with null when no context was supplied; never use an object or array. Replace ${CODEX_CONTEXT_KEYS_PLACEHOLDER} with a JSON array naming the context fields present in that string, or [] when context is absent. Replace ${CODEX_IDEMPOTENCY_KEY_PLACEHOLDER} with one new opaque idempotency key for this logical invocation: choose it exactly once, reuse it unchanged for any retry of this invocation, and never reuse it for a separate request even when task and context are identical. Then pass the raw JavaScript without Markdown fences.`,
504
- `The JavaScript validates the exact tenant, agent binding, and catalog policy; passes the one stable logical-invocation idempotencyKey; starts exactly once; and polls deterministically with the compatible 20-second server wait until status is succeeded or failed. If functions.exec yields a running cell, use functions.wait with max_tokens 30000 only to resume that same orchestration; never start another orchestration or poll the MCP tool yourself.`,
505
- `After the orchestration completes, return its single text output verbatim with no preface, rewriting, Markdown changes, or independent synthesis. A successful output is result.finalText exactly. A failure output preserves the durable runId, output, and error.`,
506
- "",
507
- source,
590
+ `Call ${nativeToolName(NATIVE_AGENT_RUN_TOOL)} exactly once with the complete task, optional context string, and supplied context keys. If it returns an ${JSON.stringify("impel.native-agent-run.v1")} handle, call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with exactly that same handle until the transport returns a terminal result. If the call is cancelled, the connection closes, or its result is unknown before you receive a handle, call ${nativeToolName(NATIVE_AGENT_RECOVER_TOOL)} with {} and resume the newest returned pending handle; if multiple handles cannot be safely associated with this request, report them instead of choosing. Never call run_native_agent again for this request, and never restart, nudge, replace, or independently poll the upstream run.`,
591
+ `While a remote wait is healthy, the parent must not message, follow up with, or interrupt this adapter.`,
592
+ `Return successful finalText byte-for-byte with no preface, rewriting, Markdown changes, or independent synthesis. A failure preserves the durable runId, output, and error.`,
508
593
  ].join("\n\n");
509
594
  }
510
595
 
511
- function renderClaudeAgent({ tenantId, agent, name, invocation }) {
512
- const description = `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
596
+ function retiredAdapterInstructions(tenantId, agent) {
597
+ return [
598
+ `You are a recovery-only transport adapter for retired Impel native-agent binding ${JSON.stringify(agent.agentId)} in tenant ${JSON.stringify(tenantId)}.`,
599
+ `You cannot start new work. Never call run_native_agent and never perform or recreate the assigned task yourself.`,
600
+ `Call ${nativeToolName(NATIVE_AGENT_RECOVER_TOOL)} with {} to obtain integrity-validated pending handles for this fixed binding, then call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with the intended handle until it returns a terminal result. If multiple handles cannot be safely associated with the request, report them instead of choosing.`,
601
+ `Return successful finalText byte-for-byte. For failure, return the preserved runId, output, and error without inventing a replacement result.`,
602
+ ].join(" ");
603
+ }
604
+
605
+ function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = false }) {
606
+ const description = recoveryOnly
607
+ ? `Recovery-only access to pending runs for retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
608
+ : `Explicitly runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
609
+ const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : NATIVE_AGENT_TOOL_NAMES;
513
610
  const lines = [
514
611
  "---",
515
612
  `name: ${JSON.stringify(name)}`,
516
613
  `description: ${JSON.stringify(description)}`,
517
614
  "model: inherit",
518
615
  "tools:",
519
- ...NATIVE_AGENT_TOOL_NAMES.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
616
+ ...toolNames.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
520
617
  "mcpServers:",
521
618
  ` - ${MANAGED_AGENT_MCP_SERVER}:`,
522
619
  " type: stdio",
@@ -527,14 +624,17 @@ function renderClaudeAgent({ tenantId, agent, name, invocation }) {
527
624
  ...Object.entries(invocation.env || {}).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`),
528
625
  "---",
529
626
  "",
530
- claudeAdapterInstructions(tenantId, agent),
627
+ recoveryOnly ? retiredAdapterInstructions(tenantId, agent) : claudeAdapterInstructions(tenantId, agent),
531
628
  "",
532
629
  ];
533
630
  return lines.join("\n");
534
631
  }
535
632
 
536
- function renderCodexAgent({ tenantId, agent, name, invocation }) {
537
- const description = `Explicit custom agent: callers must use fork_turns="none" and relay its result verbatim. Runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
633
+ function renderCodexAgent({ tenantId, agent, name, invocation, recoveryOnly = false }) {
634
+ const description = recoveryOnly
635
+ ? `Recovery-only custom agent for pending runs from retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
636
+ : `Explicit custom agent: callers must use fork_turns="none" and relay its result verbatim. Runs ${agent.title} for Impel tenant ${tenantId}${agent.sideEffects === "writes" ? " (may write user or workspace data)" : " (read-only)"}: ${agent.description}`.slice(0, 900);
637
+ const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : NATIVE_AGENT_TOOL_NAMES;
538
638
  const envEntries = Object.entries(invocation.env || {})
539
639
  .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
540
640
  .join(", ");
@@ -542,22 +642,51 @@ function renderCodexAgent({ tenantId, agent, name, invocation }) {
542
642
  `name = ${JSON.stringify(name)}`,
543
643
  `description = ${JSON.stringify(description)}`,
544
644
  'sandbox_mode = "read-only"',
545
- `developer_instructions = ${JSON.stringify(codexAdapterInstructions(tenantId, agent))}`,
645
+ `developer_instructions = ${JSON.stringify(recoveryOnly ? retiredAdapterInstructions(tenantId, agent) : codexAdapterInstructions(tenantId, agent))}`,
546
646
  "",
547
647
  `[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
548
648
  `command = ${JSON.stringify(invocation.command)}`,
549
649
  `args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
550
- `enabled_tools = [${NATIVE_AGENT_TOOL_NAMES.map((tool) => JSON.stringify(tool)).join(", ")}]`,
650
+ `enabled_tools = [${toolNames.map((tool) => JSON.stringify(tool)).join(", ")}]`,
551
651
  ...(envEntries ? [`env = { ${envEntries} }`] : []),
552
652
  "",
553
653
  ];
554
- for (const tool of NATIVE_AGENT_TOOL_NAMES) {
654
+ for (const tool of toolNames) {
555
655
  lines.push(`[mcp_servers.${MANAGED_AGENT_MCP_SERVER}.tools.${JSON.stringify(tool)}]`, 'approval_mode = "approve"', "");
556
656
  }
557
657
  return lines.join("\n");
558
658
  }
559
659
 
560
- export function renderManagedAgents(client, tenantId, agents, invocation = impelMcpInvocation(["--tenant", tenantId])) {
660
+ function boundNativeAgentInvocation(tenantId, agent, invocation, {
661
+ policyFingerprint = nativeAgentPolicyFingerprint(agent),
662
+ recoveryOnly = false,
663
+ } = {}) {
664
+ if (!invocation) {
665
+ return impelNativeAgentMcpInvocation({
666
+ tenantId,
667
+ agentId: agent.agentId,
668
+ scopeParam: agent.scopeParam,
669
+ policyFingerprint,
670
+ recoveryOnly,
671
+ });
672
+ }
673
+ const mcpIndex = invocation.args?.lastIndexOf("mcp") ?? -1;
674
+ if (mcpIndex < 0) throw new Error("native-agent MCP invocation has no mcp command");
675
+ return {
676
+ ...invocation,
677
+ args: [
678
+ ...invocation.args.slice(0, mcpIndex + 1),
679
+ "--target", IMPEL_NATIVE_AGENT_MCP_TARGET,
680
+ "--tenant", tenantId,
681
+ "--agent-id", agent.agentId,
682
+ "--scope-param", agent.scopeParam,
683
+ "--policy-fingerprint", policyFingerprint,
684
+ ...(recoveryOnly ? ["--recovery-only"] : []),
685
+ ],
686
+ };
687
+ }
688
+
689
+ export function renderManagedAgents(client, tenantId, agents, invocation = null) {
561
690
  if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
562
691
  const normalizedTenant = normalizeTenantId(tenantId);
563
692
  const fileStems = generatedAgentFileStems(normalizedTenant, agents);
@@ -571,10 +700,73 @@ export function renderManagedAgents(client, tenantId, agents, invocation = impel
571
700
  // builtin and remove it from the @ mention picker. Claude names are already
572
701
  // collision-safe, filesystem-safe identifiers, so use them as filenames.
573
702
  const fileStem = client === "claude" ? name : fileStems[index];
703
+ const boundInvocation = boundNativeAgentInvocation(normalizedTenant, agent, invocation);
574
704
  const contents = client === "claude"
575
- ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
576
- : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
577
- return { agentId: agent.agentId, name, fileName: `${fileStem}${extension}`, contents };
705
+ ? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation })
706
+ : renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation: boundInvocation });
707
+ return {
708
+ agentId: agent.agentId,
709
+ scopeParam: agent.scopeParam,
710
+ policyFingerprint: nativeAgentPolicyFingerprint(agent),
711
+ retired: false,
712
+ name,
713
+ fileName: `${fileStem}${extension}`,
714
+ contents,
715
+ };
716
+ });
717
+ }
718
+
719
+ function renderRetiredManagedAgents(client, tenantId, bindings, active, invocation = null) {
720
+ const usedNames = new Set(active.map(({ name }) => name));
721
+ const usedFiles = new Set(active.map(({ fileName }) => fileName));
722
+ return bindings.map((binding) => {
723
+ const hash = binding.policyFingerprint.slice(0, 8);
724
+ const safeAgent = binding.agentId.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "") || "agent";
725
+ const baseName = client === "claude"
726
+ ? `impel-recover-${safeAgent.slice(0, 35)}-${hash}`
727
+ : `Recover ${binding.agentId} (${hash})`;
728
+ const baseFileStem = client === "claude" ? baseName : `impel-${tenantId}-recovery-${hash}`;
729
+ const extension = client === "claude" ? ".md" : ".toml";
730
+ let name = baseName;
731
+ let fileName = `${baseFileStem}${extension}`;
732
+ for (let collision = 1; usedNames.has(name) || usedFiles.has(fileName); collision += 1) {
733
+ const suffix = `retired-${hash}-${collision}`;
734
+ name = client === "claude"
735
+ ? `${baseName.slice(0, Math.max(1, 63 - suffix.length - 1))}-${suffix}`
736
+ : `${baseName} [${suffix}]`;
737
+ fileName = client === "claude"
738
+ ? `${name}${extension}`
739
+ : `${baseFileStem}-${suffix}${extension}`;
740
+ }
741
+ usedNames.add(name);
742
+ usedFiles.add(fileName);
743
+ const agent = {
744
+ agentId: binding.agentId,
745
+ title: `Retired ${binding.agentId}`,
746
+ description: "Recovery-only access to pending durable runs.",
747
+ scopeParam: binding.scopeParam,
748
+ provider: "recovery",
749
+ capabilities: [],
750
+ exclusions: ["starting new runs"],
751
+ requiredContext: [],
752
+ sideEffects: "read-only",
753
+ };
754
+ const boundInvocation = boundNativeAgentInvocation(tenantId, agent, invocation, {
755
+ policyFingerprint: binding.policyFingerprint,
756
+ recoveryOnly: true,
757
+ });
758
+ const contents = client === "claude"
759
+ ? renderClaudeAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true })
760
+ : renderCodexAgent({ tenantId, agent, name, invocation: boundInvocation, recoveryOnly: true });
761
+ return {
762
+ agentId: binding.agentId,
763
+ scopeParam: binding.scopeParam,
764
+ policyFingerprint: binding.policyFingerprint,
765
+ retired: true,
766
+ name,
767
+ fileName,
768
+ contents,
769
+ };
578
770
  });
579
771
  }
580
772
 
@@ -615,7 +807,15 @@ function profileIsFresh(profile, tenantId, now, ttlMs) {
615
807
  );
616
808
  }
617
809
 
618
- export function syncAgentProfile({ client, root, label, tenantId, agents, now = Date.now() }) {
810
+ export function syncAgentProfile({
811
+ client,
812
+ root,
813
+ label,
814
+ tenantId,
815
+ agents,
816
+ now = Date.now(),
817
+ nativeAgentRunsRoot = path.join(CONFIG_DIR, "native-agent-runs"),
818
+ }) {
619
819
  const agentsDir = path.join(root, "agents");
620
820
  for (const candidate of [root, agentsDir]) {
621
821
  if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
@@ -626,9 +826,22 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
626
826
  privateDirectory(managedDir);
627
827
  const manifestPath = path.join(managedDir, MANAGED_AGENT_MANIFEST);
628
828
  const prior = readManifest(manifestPath);
629
- const rendered = renderManagedAgents(client, tenantId, agents);
829
+ const active = renderManagedAgents(client, tenantId, agents);
830
+ const activeBindings = new Set(active.map((agent) =>
831
+ `${agent.agentId}\0${agent.scopeParam}\0${agent.policyFingerprint}`
832
+ ));
833
+ const retiredBindings = pendingNativeAgentBindings(tenantId, { runsRoot: nativeAgentRunsRoot })
834
+ .filter((binding) => !activeBindings.has(
835
+ `${binding.agentId}\0${binding.scopeParam}\0${binding.policyFingerprint}`,
836
+ ));
837
+ const retired = renderRetiredManagedAgents(client, tenantId, retiredBindings, active);
838
+ const rendered = [...active, ...retired];
839
+ if (new Set(rendered.map(({ name }) => name)).size !== rendered.length
840
+ || new Set(rendered.map(({ fileName }) => fileName)).size !== rendered.length) {
841
+ throw new Error("generated native-agent destinations are not unique");
842
+ }
630
843
  const priorFiles = new Set(prior?.files || []);
631
- const priorUsesDiscoveryRoot = [2, 3, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
844
+ const priorUsesDiscoveryRoot = [2, 3, 4, 5, MANAGED_AGENT_MANIFEST_VERSION].includes(prior?.version);
632
845
 
633
846
  // Native clients discover standalone definitions directly under `agents/`.
634
847
  // Preflight every destination before writing so an unmanaged file with the
@@ -675,13 +888,23 @@ export function syncAgentProfile({ client, root, label, tenantId, agents, now =
675
888
  client,
676
889
  syncedAt: new Date(now).toISOString(),
677
890
  files: [...currentFiles].sort(),
678
- agents: rendered.map(({ agentId, name }, index) => ({
679
- agentId,
680
- scopeParam: agents[index].scopeParam,
681
- name,
891
+ agents: rendered.map((agent) => ({
892
+ agentId: agent.agentId,
893
+ scopeParam: agent.scopeParam,
894
+ policyFingerprint: agent.policyFingerprint,
895
+ retired: agent.retired,
896
+ name: agent.name,
682
897
  })),
683
898
  }, null, 2)}\n`);
684
- return { client, root, label, synced: true, count: rendered.length, files: [...currentFiles] };
899
+ return {
900
+ client,
901
+ root,
902
+ label,
903
+ synced: true,
904
+ count: active.length,
905
+ recoveryCount: retired.length,
906
+ files: [...currentFiles],
907
+ };
685
908
  }
686
909
 
687
910
  export async function syncAgentProfiles({
@@ -694,6 +917,7 @@ export async function syncAgentProfiles({
694
917
  now = Date.now(),
695
918
  fetchCatalog = fetchNativeAgentCatalog,
696
919
  logger = console,
920
+ nativeAgentRunsRoot = path.join(CONFIG_DIR, "native-agent-runs"),
697
921
  }) {
698
922
  if (process.env.IMPEL_SKIP_AGENT_SYNC === "1" || process.env.IMPEL_SKIP_AGENT_SYNC === "true") {
699
923
  return profiles.map((profile) => ({ ...profile, skipped: true, reason: "disabled" }));
@@ -722,6 +946,7 @@ export async function syncAgentProfiles({
722
946
  tenantId: normalizedTenant,
723
947
  agents: catalog.agents,
724
948
  now,
949
+ nativeAgentRunsRoot,
725
950
  });
726
951
  logger.log(`Agents: ${profile.label || profile.client} up to date (${result.count} explicit tenant agent${result.count === 1 ? "" : "s"}).`);
727
952
  results.push(result);