impel-cli 0.20.4 → 0.20.6

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.6 — Native-agent canary controls
4
+
5
+ - Gives Codex verbatim-relay parents one explicit 60-second blocking wait so
6
+ direct-answer children do not trigger short-poll model turns.
7
+ - Forwards only the three opt-in, closed-schema native telemetry settings into
8
+ managed MCP processes; unrelated environment values remain excluded.
9
+
10
+ ## 0.20.5 — Faster managed native agents
11
+
12
+ - Exposes exact bound tools eagerly on qualified Claude hosts while preserving
13
+ a measured durable fallback for Codex/ChatGPT code-mode hosts.
14
+ - Reuses one upstream MCP session, validates one exact binding, and removes
15
+ duplicate model-catalog/auth-helper launch work.
16
+ - Adds terminal-only 35-second reads, direct-answer continuation, durable
17
+ ambiguous-start recovery, and opt-in privacy-safe timing telemetry.
18
+ - Adds independent rollback flags for eager exposure and terminal-only reads;
19
+ rollback never deletes active handles or runs.
20
+
3
21
  ## Native remote migration
4
22
 
5
23
  The former `impel remote handoff` implementation started a one-shot headless
@@ -0,0 +1,38 @@
1
+ # Native-agent host capability matrix
2
+
3
+ Validated on 2026-08-07 with isolated temporary homes and tenant profiles. The
4
+ matrix is intentionally pinned: a client upgrade must be re-qualified before it
5
+ is treated as an eager host.
6
+
7
+ | Surface | Pinned build | Exact selected tools | Selected path | Host behavior |
8
+ | --- | --- | --- | --- | --- |
9
+ | Claude Code | 2.1.212 | 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. |
10
+ | Claude Desktop | 1.24012.9 (embedded Code 2.1.219) | Yes, agent frontmatter `tools` | Eager | Same direct-tool contract as Claude Code. |
11
+ | Codex CLI | 0.146.0 | No; MCP tools remain code-mode-only | Durable fallback | `enabled_tools` narrows the server contract but the host still invokes through code mode. Discovery/polling overhead is measured separately and is not classified as eager completion. |
12
+ | ChatGPT desktop Codex | 26.727.40816 (embedded Codex alpha) | No; MCP tools remain code-mode-only | Durable fallback | Uses the same bounded durable composite and recovery state as Codex CLI. |
13
+
14
+ ## Qualified contract
15
+
16
+ - Eager profiles expose only the exact fixed binding's answer/run, resume, and
17
+ recovery tools. `IMPEL_NATIVE_EAGER_TRANSPORT=0` independently restores the
18
+ legacy host-visible tool configuration without deleting profiles or runs.
19
+ - Every local composite keeps one upstream MCP session for its process lifetime,
20
+ validates the exact binding once, and persists the authoritative run handle
21
+ before ambiguous retries.
22
+ - Forwarded waits are negotiated at no more than 35 seconds under the gateway's
23
+ 45-second tool deadline. A local attachment returns a typed handle before the
24
+ outer host deadline and resume never opens a replacement run.
25
+ - Parent guidance permits one spawn and one blocking wait. It forbids follow-up
26
+ nudges and independent synthesis while the child is running.
27
+ - Host and transport traces must never include prompts, answers, credentials, or
28
+ decrypted provider material. Production measurements use correlation ids,
29
+ phase timestamps, counts, durations, build identifiers, and terminal classes
30
+ only.
31
+
32
+ ## Requalification
33
+
34
+ Re-run temporary-home profile parsing, direct tool visibility, cancellation,
35
+ typed continuation, byte-exact relay, and the 10/30/45/60/120-second bounded
36
+ wait fixture after a pinned client changes. A build stays on durable fallback
37
+ unless the child trace has zero `ALL_TOOLS`, `functions.exec`, and
38
+ `functions.wait` entries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.4",
3
+ "version": "0.20.6",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "RELEASE_NOTES.md",
15
15
  "bin",
16
16
  "docs/experimental-managed-cursor.md",
17
+ "docs/native-agent-host-capability-matrix.md",
17
18
  "src",
18
19
  "README.md"
19
20
  ],
package/src/agents.js CHANGED
@@ -42,6 +42,7 @@ export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
42
42
  export const MANAGED_AGENT_DIRECTORY = "impel-managed";
43
43
  export const MANAGED_AGENT_MANIFEST = ".manifest.json";
44
44
  export const NATIVE_AGENT_LIST_TOOL = "impel_specialists-list_native_agents";
45
+ export const NATIVE_AGENT_GET_TOOL = "impel_specialists-get_native_agent";
45
46
  export const NATIVE_AGENT_START_TOOL = "impel_specialists-start_native_agent_run";
46
47
  export const NATIVE_AGENT_READ_TOOL = "impel_specialists-read_native_agent_run";
47
48
  export const NATIVE_AGENT_UPSTREAM_ANSWER_TOOL = "impel_specialists-answer_native_agent";
@@ -50,7 +51,7 @@ export const NATIVE_AGENT_RUN_TOOL = "run_native_agent";
50
51
  export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
51
52
  export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
52
53
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
53
- export const MANAGED_AGENT_MANIFEST_VERSION = 7;
54
+ export const MANAGED_AGENT_MANIFEST_VERSION = 8;
54
55
 
55
56
  const NATIVE_AGENT_TOOL_NAMES = [
56
57
  NATIVE_AGENT_RUN_TOOL,
@@ -59,7 +60,12 @@ const NATIVE_AGENT_TOOL_NAMES = [
59
60
  ];
60
61
  const NATIVE_AGENT_RECOVERY_TOOL_NAMES = [NATIVE_AGENT_RESUME_TOOL, NATIVE_AGENT_RECOVER_TOOL];
61
62
  function nativeAgentToolNames(agent) {
62
- return usesDirectAnswer(agent) ? [NATIVE_AGENT_ANSWER_TOOL] : NATIVE_AGENT_TOOL_NAMES;
63
+ return usesDirectAnswer(agent)
64
+ ? [NATIVE_AGENT_ANSWER_TOOL, ...NATIVE_AGENT_RECOVERY_TOOL_NAMES]
65
+ : NATIVE_AGENT_TOOL_NAMES;
66
+ }
67
+ function eagerNativeAgentTransportEnabled() {
68
+ return process.env.IMPEL_NATIVE_EAGER_TRANSPORT !== "0";
63
69
  }
64
70
  const MAX_RETIRED_AGENT_BINDINGS = 50;
65
71
  const MAX_NATIVE_AGENT_STATE_BYTES = 512 * 1024;
@@ -602,6 +608,7 @@ function claudeAdapterInstructions(tenantId, agent) {
602
608
  `Confirm that the request fits the synchronized capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)}.${contextRequirement}`,
603
609
  sideEffectInstruction,
604
610
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
611
+ `If the bounded answer returns an ${JSON.stringify("impel.native-agent-run.v1")} continuation handle, call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with exactly that handle until terminal. Never call answer_native_agent again for this request.`,
605
612
  completionGuidance,
606
613
  ].join(" ");
607
614
  }
@@ -645,6 +652,7 @@ function codexAdapterInstructions(tenantId, agent) {
645
652
  `Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before answering.${contextRequirement}`,
646
653
  sideEffectInstruction,
647
654
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
655
+ `If the bounded answer returns an ${JSON.stringify("impel.native-agent-run.v1")} continuation handle, call ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} with exactly that handle until terminal. Never call answer_native_agent again for this request.`,
648
656
  completionGuidance,
649
657
  ].join("\n\n");
650
658
  }
@@ -687,13 +695,15 @@ function renderClaudeAgent({ tenantId, agent, name, invocation, recoveryOnly = f
687
695
  ? `Recovery-only access to pending runs for retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
688
696
  : customAgentDescription(tenantId, agent);
689
697
  const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : nativeAgentToolNames(agent);
698
+ const eager = eagerNativeAgentTransportEnabled();
690
699
  const lines = [
691
700
  "---",
692
701
  `name: ${JSON.stringify(name)}`,
693
702
  `description: ${JSON.stringify(description)}`,
694
- "model: inherit",
695
- "tools:",
696
- ...toolNames.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`),
703
+ "model: haiku",
704
+ ...(eager
705
+ ? ["tools:", ...toolNames.map((tool) => ` - ${JSON.stringify(nativeToolName(tool))}`)]
706
+ : []),
697
707
  "mcpServers:",
698
708
  ` - ${MANAGED_AGENT_MCP_SERVER}:`,
699
709
  " type: stdio",
@@ -715,19 +725,24 @@ function renderCodexAgent({ tenantId, agent, name, invocation, recoveryOnly = fa
715
725
  ? `Recovery-only custom agent for pending runs from retired Impel binding ${agent.agentId} in tenant ${tenantId}.`
716
726
  : customAgentDescription(tenantId, agent);
717
727
  const toolNames = recoveryOnly ? NATIVE_AGENT_RECOVERY_TOOL_NAMES : nativeAgentToolNames(agent);
728
+ const eager = eagerNativeAgentTransportEnabled();
718
729
  const envEntries = Object.entries(invocation.env || {})
719
730
  .map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`)
720
731
  .join(", ");
721
732
  const lines = [
722
733
  `name = ${JSON.stringify(name)}`,
723
734
  `description = ${JSON.stringify(description)}`,
735
+ 'model = "gpt-5.6-luna"',
736
+ 'model_reasoning_effort = "low"',
724
737
  'sandbox_mode = "read-only"',
725
738
  `developer_instructions = ${JSON.stringify(recoveryOnly ? retiredAdapterInstructions(tenantId, agent) : codexAdapterInstructions(tenantId, agent))}`,
726
739
  "",
727
740
  `[mcp_servers.${MANAGED_AGENT_MCP_SERVER}]`,
728
741
  `command = ${JSON.stringify(invocation.command)}`,
729
742
  `args = [${invocation.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
730
- `enabled_tools = [${toolNames.map((tool) => JSON.stringify(tool)).join(", ")}]`,
743
+ ...(eager
744
+ ? [`enabled_tools = [${toolNames.map((tool) => JSON.stringify(tool)).join(", ")}]`]
745
+ : []),
731
746
  ...(envEntries ? [`env = { ${envEntries} }`] : []),
732
747
  "",
733
748
  ];
@@ -21,8 +21,12 @@ import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
21
21
  import { renameWithWindowsRetry } from "./windowsFs.js";
22
22
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
23
23
  import { RUNTIME_BRAND } from "./runtimeBrand.js";
24
+ import { managedCodexCliCatalog } from "./codexCliCatalog.js";
24
25
 
25
26
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
27
+ export const CODEX_GATEWAY_TOKEN_ENV = `${RUNTIME_BRAND.cli.providerId
28
+ .toUpperCase()
29
+ .replace(/[^A-Z0-9]+/gu, "_")}_CODEX_GATEWAY_TOKEN`;
26
30
 
27
31
  export function tenantCliProfilePaths(tenantId) {
28
32
  const root = path.join(IMPEL_CLI_PROFILES_DIR, "tenants", normalizeTenantId(tenantId));
@@ -30,6 +34,7 @@ export function tenantCliProfilePaths(tenantId) {
30
34
  root,
31
35
  claudeConfigDir: path.join(root, "claude"),
32
36
  codexHome: path.join(root, "codex"),
37
+ codexCatalog: path.join(root, "codex", "models.json"),
33
38
  };
34
39
  }
35
40
 
@@ -191,7 +196,6 @@ function splitTomlPreamble(text) {
191
196
  }
192
197
 
193
198
  function codexManagedBlock(gatewayUrl, tenantId) {
194
- const auth = impelCliInvocation(["token", "--tenant", tenantId]);
195
199
  const providerId = RUNTIME_BRAND.cli.providerId;
196
200
  const lines = [
197
201
  CODEX_START_MARK,
@@ -200,12 +204,7 @@ function codexManagedBlock(gatewayUrl, tenantId) {
200
204
  `name = ${JSON.stringify(`${RUNTIME_BRAND.product.displayName} Gateway`)}`,
201
205
  `base_url = ${JSON.stringify(impelCodexBaseUrl(gatewayUrl))}`,
202
206
  'wire_api = "responses"',
203
- "",
204
- `[model_providers.${providerId}.auth]`,
205
- `command = ${JSON.stringify(auth.command)}`,
206
- `args = [${auth.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
207
- "timeout_ms = 5000",
208
- "refresh_interval_ms = 300000",
207
+ `env_key = ${JSON.stringify(CODEX_GATEWAY_TOKEN_ENV)}`,
209
208
  ];
210
209
  if (RUNTIME_BRAND.features.mcp) {
211
210
  const mcp = impelCliInvocation(["mcp", "--tenant", tenantId]);
@@ -231,7 +230,7 @@ function codexManagedBlock(gatewayUrl, tenantId) {
231
230
 
232
231
  export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
233
232
  secureAllManagedCodexHomes({ cliRoot: IMPEL_CLI_PROFILES_DIR });
234
- const codexHome = tenantCliProfilePaths(tenantId).codexHome;
233
+ const { codexHome, codexCatalog } = tenantCliProfilePaths(tenantId);
235
234
  secureManagedCodexHome(codexHome);
236
235
  const configPath = path.join(codexHome, "config.toml");
237
236
  const original = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
@@ -259,6 +258,10 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
259
258
  let nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
260
259
  ? preamble.replace(CODEX_PROVIDER_LINE_RE, providerLine)
261
260
  : `${preamble.trimEnd()}${preamble.trim() ? "\n" : ""}${providerLine}\n`;
261
+ const catalogLine = `model_catalog_json = ${JSON.stringify(codexCatalog)}`;
262
+ nextPreamble = /^model_catalog_json[ \t]*=/mu.test(nextPreamble)
263
+ ? nextPreamble.replace(/^model_catalog_json[ \t]*=.*$/mu, catalogLine)
264
+ : `${nextPreamble.trimEnd()}\n${catalogLine}\n`;
262
265
  nextPreamble = nextPreamble.replace(/^bypass_hook_trust[ \t]*=[ \t]*(?:true|false)[ \t]*\n?/gm, "");
263
266
  const restText = rest.trim();
264
267
  const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
@@ -267,7 +270,8 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
267
270
  .concat("\n");
268
271
 
269
272
  writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
273
+ writePrivateFile(codexCatalog, `${JSON.stringify(managedCodexCliCatalog(), null, 2)}\n`);
270
274
  if (RUNTIME_BRAND.features.sessions) ensureCodexSessionHooks(codexHome, tenantId, "codex_cli");
271
275
  secureManagedCodexHome(codexHome);
272
- return { codexHome, configPath };
276
+ return { codexHome, configPath, catalogPath: codexCatalog };
273
277
  }
@@ -0,0 +1,56 @@
1
+ const LEVELS = ["low", "medium", "high", "xhigh"].map((effort) => ({
2
+ effort,
3
+ description: `${effort} reasoning effort`,
4
+ }));
5
+
6
+ const MODELS = [
7
+ ["gpt-5.6-sol", "GPT-5.6-Sol", 1, "medium", "code_mode_only", "v2"],
8
+ ["gpt-5.6-terra", "GPT-5.6-Terra", 2, "medium", "code_mode_only", "v2"],
9
+ ["gpt-5.6-luna", "GPT-5.6-Luna", 3, "medium", "code_mode_only", "v1"],
10
+ ["gpt-5.5", "GPT-5.5", 7, "medium", null, null],
11
+ ["gpt-5.4", "GPT-5.4", 16, "medium", null, null],
12
+ ["gpt-5.4-mini", "GPT-5.4-Mini", 23, "medium", null, null],
13
+ ["gpt-5.3-codex-spark", "GPT-5.3-Codex-Spark", 26, "high", null, null],
14
+ ];
15
+
16
+ // Reviewed static projection of the exact pinned Codex 0.146 catalog. Keeping
17
+ // it process-local prevents the client from issuing unsupported online model
18
+ // refreshes during every managed launch.
19
+ export function managedCodexCliCatalog(now = new Date()) {
20
+ return {
21
+ fetched_at: now.toISOString(),
22
+ client_version: "impel-managed-codex-0.146",
23
+ models: MODELS.map(([slug, displayName, priority, effort, toolMode, multiAgentVersion]) => ({
24
+ slug,
25
+ display_name: displayName,
26
+ description: `Available through the Impel gateway (${slug}).`,
27
+ default_reasoning_level: effort,
28
+ supported_reasoning_levels: LEVELS,
29
+ service_tiers: [],
30
+ shell_type: "shell_command",
31
+ visibility: "list",
32
+ supported_in_api: true,
33
+ priority,
34
+ base_instructions: "You are Codex, an AI coding agent. Follow repository instructions and verify your work.",
35
+ include_skills_usage_instructions: false,
36
+ supports_reasoning_summaries: true,
37
+ default_reasoning_summary: "none",
38
+ support_verbosity: true,
39
+ default_verbosity: "low",
40
+ apply_patch_tool_type: "freeform",
41
+ web_search_tool_type: "text_and_image",
42
+ truncation_policy: { mode: "tokens", limit: 10_000 },
43
+ context_window: slug === "gpt-5.3-codex-spark" ? 128_000 : 272_000,
44
+ max_context_window: slug === "gpt-5.4" ? 1_000_000 : (slug === "gpt-5.3-codex-spark" ? 128_000 : 272_000),
45
+ effective_context_window_percent: 95,
46
+ experimental_supported_tools: [],
47
+ input_modalities: ["text", "image"],
48
+ supports_parallel_tool_calls: true,
49
+ supports_image_detail_original: true,
50
+ supports_search_tool: true,
51
+ use_responses_lite: slug.startsWith("gpt-5.6-"),
52
+ tool_mode: toolMode,
53
+ multi_agent_version: multiAgentVersion,
54
+ })),
55
+ };
56
+ }
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import os from "node:os";
3
3
 
4
4
  import {
5
+ CODEX_GATEWAY_TOKEN_ENV,
5
6
  ensureImpelClaudeProfile,
6
7
  ensureImpelCodexProfile,
7
8
  } from "../cliProfiles.js";
@@ -40,7 +41,13 @@ const CLAUDE_DIRECT_AUTH_ENV = [
40
41
  "CLAUDE_CODE_USE_VERTEX",
41
42
  ];
42
43
 
43
- const CODEX_DIRECT_AUTH_ENV = ["CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"];
44
+ const CODEX_DIRECT_AUTH_ENV = [
45
+ "CODEX_ACCESS_TOKEN",
46
+ "CODEX_API_KEY",
47
+ "OPENAI_API_KEY",
48
+ "OPENAI_BASE_URL",
49
+ CODEX_GATEWAY_TOKEN_ENV,
50
+ ];
44
51
  export {
45
52
  escapeWindowsBareArgument,
46
53
  escapeWindowsBatchArgument,
@@ -231,6 +238,7 @@ export async function cmdLaunch(tool, argv) {
231
238
  agentProfile = { client: "codex", root: profile.codexHome, label: "Impel isolated Codex (impel codex)" };
232
239
  deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
233
240
  environment.CODEX_HOME = profile.codexHome;
241
+ environment[CODEX_GATEWAY_TOKEN_ENV] = gatewayCredential;
234
242
  } else {
235
243
  throw new Error(`unsupported CLI launcher: ${tool}`);
236
244
  }
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import readline from "node:readline";
2
3
 
3
4
  import {
@@ -18,6 +19,7 @@ import {
18
19
  nativeAgentToolCallResult,
19
20
  } from "../nativeAgentTransport.js";
20
21
  import { IMPEL_NATIVE_AGENT_MCP_TARGET } from "../selfInvocation.js";
22
+ import { createNativeAgentTelemetry } from "../nativeAgentTelemetry.js";
21
23
  import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
22
24
 
23
25
  const TASKS_TARGET = "tasks";
@@ -195,6 +197,7 @@ export function runNativeAgentMcpServer({
195
197
  input = process.stdin,
196
198
  output = process.stdout,
197
199
  mode = "durable",
200
+ telemetry = () => {},
198
201
  }) {
199
202
  if (!["durable", "recovery", "answer"].includes(mode)) throw new Error("invalid native-agent MCP mode");
200
203
  const lines = readline.createInterface({ input, crlfDelay: Infinity });
@@ -219,6 +222,9 @@ export function runNativeAgentMcpServer({
219
222
  const controller = new AbortController();
220
223
  active.set(message.id, controller);
221
224
  const task = (async () => {
225
+ const correlationId = crypto.randomUUID();
226
+ const startedAt = Date.now();
227
+ let telemetryTool;
222
228
  try {
223
229
  if (message.method === "initialize") {
224
230
  write(nativeRpcResult(message.id, {
@@ -242,7 +248,9 @@ export function runNativeAgentMcpServer({
242
248
  throw new Error("unsupported native-agent MCP method");
243
249
  }
244
250
  const name = message.params?.name;
251
+ telemetryTool = name;
245
252
  const args = message.params?.arguments ?? {};
253
+ telemetry("local_tool_received", { correlationId, tool: name });
246
254
  const progressToken = nativeProgressToken(message);
247
255
  const onProgress = progressToken === null ? undefined : ({ status }) => {
248
256
  progress += 1;
@@ -261,17 +269,32 @@ export function runNativeAgentMcpServer({
261
269
  if (mode === "answer") throw new Error("direct-answer native-agent bindings cannot start durable runs");
262
270
  value = await transport.run(args, { signal: controller.signal, onProgress });
263
271
  } else if (name === NATIVE_AGENT_RESUME_TOOL) {
264
- if (mode === "answer") throw new Error("direct-answer native-agent bindings have no runs to resume");
265
272
  value = await transport.resume(args, { signal: controller.signal, onProgress });
266
273
  } else if (name === NATIVE_AGENT_RECOVER_TOOL) {
267
- if (mode === "answer") throw new Error("direct-answer native-agent bindings have no runs to recover");
268
274
  value = transport.recover(args);
269
275
  } else {
270
276
  throw new Error("unsupported native-agent MCP tool");
271
277
  }
278
+ const continued = value?.schema === "impel.native-agent-run.v1";
279
+ telemetry("local_tool_completed", {
280
+ correlationId,
281
+ tool: name,
282
+ outcome: continued ? "continued" : "succeeded",
283
+ status: typeof value?.status === "string" ? value.status : undefined,
284
+ durationMs: Date.now() - startedAt,
285
+ durableRunCreated: Boolean(value?.runId || value?.handle?.runId),
286
+ });
272
287
  write(nativeRpcResult(message.id, nativeAgentToolCallResult(value)));
273
288
  } catch (error) {
274
289
  const cancelled = error?.name === "AbortError";
290
+ if (message.method === "tools/call") {
291
+ telemetry("local_tool_completed", {
292
+ correlationId,
293
+ tool: telemetryTool,
294
+ outcome: cancelled ? "cancelled" : "failed",
295
+ durationMs: Date.now() - startedAt,
296
+ });
297
+ }
275
298
  write(JSON.stringify({
276
299
  jsonrpc: "2.0",
277
300
  id: message.id,
@@ -369,6 +392,15 @@ export async function cmdMcp(argv = []) {
369
392
  const gatewayUrl = config.gatewayUrl || resolveDefaultGateway();
370
393
  const credential = tasksTarget ? null : tenantCredential(config.pat, tenantId);
371
394
  if (nativeAgentTarget) {
395
+ const telemetry = createNativeAgentTelemetry({
396
+ tenantId,
397
+ agentId: flags["agent-id"],
398
+ scopeParam: flags["scope-param"],
399
+ mode: flags["recovery-only"] === true
400
+ ? "recovery"
401
+ : (flags["answer-only"] === true ? "answer" : "durable"),
402
+ });
403
+ telemetry("local_server_started");
372
404
  return runNativeAgentMcpServer({
373
405
  transport: new NativeAgentCompositeTransport({
374
406
  tenantId,
@@ -377,10 +409,12 @@ export async function cmdMcp(argv = []) {
377
409
  policyFingerprint: flags["policy-fingerprint"],
378
410
  gatewayUrl,
379
411
  credential,
412
+ telemetry,
380
413
  }),
381
414
  mode: flags["recovery-only"] === true
382
415
  ? "recovery"
383
416
  : (flags["answer-only"] === true ? "answer" : "durable"),
417
+ telemetry,
384
418
  });
385
419
  }
386
420
  const endpoint = tasksTarget ? null : `${gatewayUrl}/mcp`;
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { redactCredentialText, redactSecretText } from "./config.js";
5
+
6
+ export const NATIVE_AGENT_TRANSPORT_VERSION = 2;
7
+
8
+ const SAFE_ID = /^[A-Za-z0-9_.:@/-]{1,200}$/u;
9
+ const SAFE_EVENTS = new Set([
10
+ "local_server_started",
11
+ "local_tool_received",
12
+ "local_tool_completed",
13
+ "session_prepared",
14
+ "upstream_request_completed",
15
+ "binding_completed",
16
+ ]);
17
+ const SAFE_TOOLS = new Set([
18
+ "initialize",
19
+ "notifications/initialized",
20
+ "tools/list",
21
+ "get_native_agent",
22
+ "answer_native_agent",
23
+ "start_native_agent_run",
24
+ "read_native_agent_run",
25
+ "run_native_agent",
26
+ "resume_native_agent_run",
27
+ "recover_native_agent_runs",
28
+ ]);
29
+ const SAFE_OUTCOMES = new Set(["succeeded", "continued", "failed", "cancelled", "ok", "error"]);
30
+
31
+ function safeId(value) {
32
+ return typeof value === "string" && SAFE_ID.test(value) ? value : undefined;
33
+ }
34
+
35
+ function safeInteger(value) {
36
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
37
+ }
38
+
39
+ function appendPrivateJsonLine(filePath, value) {
40
+ const directory = path.dirname(filePath);
41
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
42
+ try {
43
+ fs.chmodSync(directory, 0o700);
44
+ } catch {
45
+ // Best effort on Windows.
46
+ }
47
+ const serialized = JSON.stringify(value);
48
+ if (redactCredentialText(serialized) !== serialized || redactSecretText(serialized) !== serialized) {
49
+ throw new Error("refusing to record sensitive native-agent telemetry");
50
+ }
51
+ fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", mode: 0o600 });
52
+ try {
53
+ fs.chmodSync(filePath, 0o600);
54
+ } catch {
55
+ // Best effort on Windows.
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Creates an opt-in, closed-schema performance recorder. It accepts identifiers,
61
+ * counters, durations, and terminal classes only; prompt and result fields do
62
+ * not exist in the schema.
63
+ */
64
+ export function createNativeAgentTelemetry({
65
+ tenantId,
66
+ agentId,
67
+ scopeParam,
68
+ mode,
69
+ filePath = process.env.IMPEL_NATIVE_AGENT_TELEMETRY_PATH,
70
+ host = process.env.IMPEL_NATIVE_HOST,
71
+ hostBuild = process.env.IMPEL_NATIVE_HOST_BUILD,
72
+ now = Date.now,
73
+ } = {}) {
74
+ if (!filePath) return () => {};
75
+ const fixed = {
76
+ schema: "impel.native-agent-telemetry.v1",
77
+ transportVersion: NATIVE_AGENT_TRANSPORT_VERSION,
78
+ tenantId: safeId(tenantId),
79
+ agentId: safeId(agentId),
80
+ scopeParam: safeId(scopeParam),
81
+ mode: safeId(mode),
82
+ host: safeId(host),
83
+ hostBuild: safeId(hostBuild),
84
+ };
85
+ return (event, fields = {}) => {
86
+ if (!SAFE_EVENTS.has(event)) return;
87
+ const record = {
88
+ ...fixed,
89
+ event,
90
+ timestamp: new Date(now()).toISOString(),
91
+ correlationId: safeId(fields.correlationId),
92
+ gatewayRequestId: safeId(fields.gatewayRequestId),
93
+ tool: SAFE_TOOLS.has(fields.tool) ? fields.tool : undefined,
94
+ outcome: SAFE_OUTCOMES.has(fields.outcome) ? fields.outcome : undefined,
95
+ status: safeId(fields.status),
96
+ durationMs: safeInteger(fields.durationMs),
97
+ pollCount: safeInteger(fields.pollCount),
98
+ handshakeReused: typeof fields.handshakeReused === "boolean" ? fields.handshakeReused : undefined,
99
+ bindingReused: typeof fields.bindingReused === "boolean" ? fields.bindingReused : undefined,
100
+ durableRunCreated: typeof fields.durableRunCreated === "boolean" ? fields.durableRunCreated : undefined,
101
+ };
102
+ try {
103
+ appendPrivateJsonLine(filePath, Object.fromEntries(
104
+ Object.entries(record).filter(([, value]) => value !== undefined),
105
+ ));
106
+ } catch {
107
+ // Performance telemetry is opt-in and best effort; it cannot break a run.
108
+ }
109
+ };
110
+ }
@@ -10,7 +10,7 @@ import {
10
10
  } from "./config.js";
11
11
  import {
12
12
  NATIVE_AGENT_ANSWER_TOOL,
13
- NATIVE_AGENT_LIST_TOOL,
13
+ NATIVE_AGENT_GET_TOOL,
14
14
  NATIVE_AGENT_READ_TOOL,
15
15
  NATIVE_AGENT_RECOVER_TOOL,
16
16
  NATIVE_AGENT_RESUME_TOOL,
@@ -41,9 +41,9 @@ const SAFE_BINDING_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
41
41
  const SAFE_FINGERPRINT_RE = /^[a-f0-9]{64}$/u;
42
42
  const SAFE_INVOCATION_RE = /^[a-f0-9-]{36}$/u;
43
43
  const DEFAULT_WAIT_SECONDS = 20;
44
- const MAX_WAIT_SECONDS = 50;
45
- const DEFAULT_ATTACHMENT_WINDOW_MS = 55_000;
46
- const DEFAULT_UPSTREAM_TIMEOUT_MS = 55_000;
44
+ const MAX_WAIT_SECONDS = 35;
45
+ const DEFAULT_ATTACHMENT_WINDOW_MS = 40_000;
46
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 42_000;
47
47
  const DEFAULT_MAX_POLLS = 8;
48
48
  const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
49
49
  const DEFAULT_MAX_RETAINED_RUNS = 500;
@@ -288,6 +288,7 @@ function readPrivateState(filePath, maxBytes) {
288
288
  "upstreamRunId",
289
289
  "status",
290
290
  "startAttempts",
291
+ "startMode",
291
292
  "createdAt",
292
293
  "updatedAt",
293
294
  "fence",
@@ -296,6 +297,7 @@ function readPrivateState(filePath, maxBytes) {
296
297
  "result",
297
298
  "output",
298
299
  "error",
300
+ "version",
299
301
  ], "native-agent run state");
300
302
  if (!SAFE_INVOCATION_RE.test(state.invocationId)
301
303
  || normalizeTenantId(state.tenantId) !== state.tenantId
@@ -309,10 +311,14 @@ function readPrivateState(filePath, maxBytes) {
309
311
  || !state.status
310
312
  || !Number.isSafeInteger(state.startAttempts)
311
313
  || state.startAttempts < 0
314
+ || (state.startMode !== undefined
315
+ && state.startMode !== "durable"
316
+ && state.startMode !== "answer")
312
317
  || !Number.isFinite(Date.parse(state.createdAt))
313
318
  || !Number.isFinite(Date.parse(state.updatedAt))
314
319
  || (state.fence !== undefined && (!Number.isSafeInteger(state.fence) || state.fence < 0))
315
320
  || (state.sequence !== undefined && (!Number.isSafeInteger(state.sequence) || state.sequence < 0))
321
+ || (state.version !== undefined && (!Number.isSafeInteger(state.version) || state.version < 0))
316
322
  || (state.upstreamRunId !== null && (typeof state.upstreamRunId !== "string" || !state.upstreamRunId))) {
317
323
  throw new Error("invalid field");
318
324
  }
@@ -449,10 +455,13 @@ function applyUpstreamPayload(state, payload, now) {
449
455
  if (Object.hasOwn(payload, "error")) {
450
456
  state.error = redactOpaqueValue(payload.error, state.idempotencyKey);
451
457
  }
458
+ if (Number.isSafeInteger(payload.version) && payload.version >= 0) {
459
+ state.version = payload.version;
460
+ }
452
461
  state.updatedAt = new Date(now).toISOString();
453
462
  }
454
463
 
455
- /** Use the read tool's advertised integer maximum, never exceeding 50 seconds. */
464
+ /** Use the read tool's advertised integer maximum, never exceeding 35 seconds. */
456
465
  export function negotiateNativeAgentWaitSeconds(tools) {
457
466
  const readTool = Array.isArray(tools)
458
467
  ? tools.find((tool) => tool?.name === NATIVE_AGENT_READ_TOOL)
@@ -575,6 +584,9 @@ export class NativeAgentUpstreamSession {
575
584
  timeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS,
576
585
  maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
577
586
  signal,
587
+ telemetry = () => {},
588
+ now = Date.now,
589
+ requestIdFactory = crypto.randomUUID,
578
590
  }) {
579
591
  this.endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
580
592
  this.credential = credential;
@@ -582,16 +594,24 @@ export class NativeAgentUpstreamSession {
582
594
  this.timeoutMs = timeoutMs;
583
595
  this.maxResponseBytes = maxResponseBytes;
584
596
  this.signal = signal;
597
+ this.telemetry = telemetry;
598
+ this.now = now;
599
+ this.requestIdFactory = requestIdFactory;
585
600
  this.sessionId = null;
586
601
  this.nextId = 1;
587
602
  }
588
603
 
589
- async post(message) {
590
- throwIfAborted(this.signal);
604
+ async post(message, { signal = this.signal } = {}) {
605
+ throwIfAborted(signal);
606
+ const startedAt = this.now();
607
+ const correlationId = this.requestIdFactory();
608
+ const tool = message.method === "tools/call"
609
+ ? String(message.params?.name || "").replace(/^impel_specialists-/u, "")
610
+ : message.method;
591
611
  const controller = new AbortController();
592
612
  let timedOut = false;
593
613
  const onAbort = () => controller.abort();
594
- this.signal?.addEventListener("abort", onAbort, { once: true });
614
+ signal?.addEventListener("abort", onAbort, { once: true });
595
615
  const timeout = setTimeout(() => {
596
616
  timedOut = true;
597
617
  controller.abort();
@@ -605,6 +625,7 @@ export class NativeAgentUpstreamSession {
605
625
  Authorization: `Bearer ${this.credential}`,
606
626
  "Content-Type": "application/json",
607
627
  Accept: "application/json, text/event-stream",
628
+ "X-Impel-Client-Request-Id": correlationId,
608
629
  ...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}),
609
630
  },
610
631
  body: JSON.stringify(message),
@@ -612,13 +633,26 @@ export class NativeAgentUpstreamSession {
612
633
  });
613
634
  body = await readBoundedResponseBody(response, this.maxResponseBytes);
614
635
  } catch (error) {
615
- if (this.signal?.aborted) throw abortError();
636
+ this.telemetry("upstream_request_completed", {
637
+ correlationId,
638
+ tool,
639
+ outcome: signal?.aborted ? "cancelled" : "error",
640
+ durationMs: this.now() - startedAt,
641
+ });
642
+ if (signal?.aborted) throw abortError();
616
643
  const detail = timedOut ? "request timed out" : redactSecretText(error?.message || error);
617
644
  throw new NativeAgentUpstreamError(`native-agent MCP request failed: ${detail}`, { ambiguous: true });
618
645
  } finally {
619
646
  clearTimeout(timeout);
620
- this.signal?.removeEventListener("abort", onAbort);
621
- }
647
+ signal?.removeEventListener("abort", onAbort);
648
+ }
649
+ this.telemetry("upstream_request_completed", {
650
+ correlationId,
651
+ gatewayRequestId: response.headers.get("x-request-id") || undefined,
652
+ tool,
653
+ outcome: response.ok ? "ok" : "error",
654
+ durationMs: this.now() - startedAt,
655
+ });
622
656
  this.sessionId = response.headers.get("mcp-session-id") || this.sessionId;
623
657
  if (!response.ok) {
624
658
  throw new NativeAgentUpstreamError(`native-agent MCP returned HTTP ${response.status}`, {
@@ -634,7 +668,7 @@ export class NativeAgentUpstreamSession {
634
668
  }
635
669
  }
636
670
 
637
- async initialize() {
671
+ async initialize({ signal } = {}) {
638
672
  const id = this.nextId++;
639
673
  const initialized = await this.post({
640
674
  jsonrpc: "2.0",
@@ -645,14 +679,17 @@ export class NativeAgentUpstreamSession {
645
679
  capabilities: {},
646
680
  clientInfo: { name: "impel-cli-native-agent-transport", version: "1.0.0" },
647
681
  },
648
- });
682
+ }, { signal });
649
683
  const response = initialized.find((message) => message?.id === id);
650
684
  if (!response?.result || response.error) {
651
685
  throw new NativeAgentUpstreamError("native-agent MCP initialization failed");
652
686
  }
653
- await this.post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
687
+ await this.post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, { signal });
654
688
  const toolsId = this.nextId++;
655
- const listed = await this.post({ jsonrpc: "2.0", id: toolsId, method: "tools/list", params: {} });
689
+ const listed = await this.post(
690
+ { jsonrpc: "2.0", id: toolsId, method: "tools/list", params: {} },
691
+ { signal },
692
+ );
656
693
  const toolsResponse = listed.find((message) => message?.id === toolsId);
657
694
  if (toolsResponse?.error || !Array.isArray(toolsResponse?.result?.tools)) {
658
695
  throw new NativeAgentUpstreamError("native-agent MCP returned no tool catalog");
@@ -660,7 +697,7 @@ export class NativeAgentUpstreamSession {
660
697
  return toolsResponse.result.tools;
661
698
  }
662
699
 
663
- async call(name, args) {
700
+ async call(name, args, { signal } = {}) {
664
701
  const id = this.nextId++;
665
702
  try {
666
703
  const messages = await this.post({
@@ -668,7 +705,7 @@ export class NativeAgentUpstreamSession {
668
705
  id,
669
706
  method: "tools/call",
670
707
  params: { name, arguments: args },
671
- });
708
+ }, { signal });
672
709
  return toolPayload(messages.find((message) => message?.id === id));
673
710
  } catch (error) {
674
711
  if (name === NATIVE_AGENT_START_TOOL
@@ -715,6 +752,7 @@ export class NativeAgentCompositeTransport {
715
752
  beforeStateRename,
716
753
  beforeRevisionCompaction,
717
754
  beforePruneDelete,
755
+ telemetry = () => {},
718
756
  }) {
719
757
  this.tenantId = normalizeTenantId(tenantId);
720
758
  for (const [field, value] of Object.entries({ agentId, scopeParam })) {
@@ -756,14 +794,19 @@ export class NativeAgentCompositeTransport {
756
794
  this.beforeStateRename = beforeStateRename;
757
795
  this.beforeRevisionCompaction = beforeRevisionCompaction;
758
796
  this.beforePruneDelete = beforePruneDelete;
759
- this.sessionFactory = sessionFactory || (({ signal }) => new NativeAgentUpstreamSession({
797
+ this.telemetry = telemetry;
798
+ this.sessionFactory = sessionFactory || (() => new NativeAgentUpstreamSession({
760
799
  gatewayUrl: this.gatewayUrl,
761
800
  credential: this.credential,
762
801
  fetchImpl: this.fetchImpl,
763
802
  timeoutMs: this.upstreamTimeoutMs,
764
803
  maxResponseBytes: this.maxResponseBytes,
765
- signal,
804
+ telemetry: this.telemetry,
805
+ now: this.now,
806
+ requestIdFactory: this.randomUUID,
766
807
  }));
808
+ this.preparedSession = null;
809
+ this.preparedAgent = null;
767
810
  }
768
811
 
769
812
  statePath(invocationId) {
@@ -1505,15 +1548,38 @@ export class NativeAgentCompositeTransport {
1505
1548
  }
1506
1549
 
1507
1550
  async prepareExistingSession(signal) {
1508
- const session = await this.sessionFactory({ signal });
1509
- const tools = await session.initialize();
1510
- return { session, waitSeconds: negotiateNativeAgentWaitSeconds(tools) };
1551
+ const reused = Boolean(this.preparedSession);
1552
+ const startedAt = this.now();
1553
+ if (!this.preparedSession) {
1554
+ this.preparedSession = (async () => {
1555
+ const session = await this.sessionFactory({ signal });
1556
+ const tools = await session.initialize({ signal });
1557
+ return { session, waitSeconds: negotiateNativeAgentWaitSeconds(tools) };
1558
+ })();
1559
+ this.preparedSession.catch(() => {
1560
+ this.preparedSession = null;
1561
+ });
1562
+ }
1563
+ const prepared = await this.preparedSession;
1564
+ this.telemetry("session_prepared", {
1565
+ durationMs: this.now() - startedAt,
1566
+ handshakeReused: reused,
1567
+ });
1568
+ return prepared;
1511
1569
  }
1512
1570
 
1513
1571
  async prepareNewSession(signal) {
1514
1572
  const prepared = await this.prepareExistingSession(signal);
1573
+ const startedAt = this.now();
1574
+ if (this.preparedAgent) {
1575
+ this.telemetry("binding_completed", { durationMs: 0, bindingReused: true });
1576
+ return { ...prepared, agent: this.preparedAgent };
1577
+ }
1515
1578
  const catalog = normalizeNativeAgentCatalog(
1516
- await prepared.session.call(NATIVE_AGENT_LIST_TOOL, {}),
1579
+ await prepared.session.call(NATIVE_AGENT_GET_TOOL, {
1580
+ agentId: this.agentId,
1581
+ scopeParam: this.scopeParam,
1582
+ }, { signal }),
1517
1583
  this.tenantId,
1518
1584
  );
1519
1585
  const matches = catalog.agents.filter((agent) =>
@@ -1523,13 +1589,21 @@ export class NativeAgentCompositeTransport {
1523
1589
  if (nativeAgentPolicyFingerprint(matches[0]) !== this.policyFingerprint) {
1524
1590
  throw new Error("native-agent catalog policy fingerprint changed; synchronize agents again");
1525
1591
  }
1526
- return { ...prepared, agent: matches[0] };
1592
+ this.preparedAgent = matches[0];
1593
+ this.telemetry("binding_completed", {
1594
+ durationMs: this.now() - startedAt,
1595
+ bindingReused: false,
1596
+ });
1597
+ return { ...prepared, agent: this.preparedAgent };
1527
1598
  }
1528
1599
 
1529
1600
  async preparePersistedStartSession(signal, state) {
1530
1601
  const prepared = await this.prepareExistingSession(signal);
1531
1602
  const catalog = normalizeNativeAgentCatalog(
1532
- await prepared.session.call(NATIVE_AGENT_LIST_TOOL, {}),
1603
+ await prepared.session.call(NATIVE_AGENT_GET_TOOL, {
1604
+ agentId: state.agentId,
1605
+ scopeParam: state.scopeParam,
1606
+ }, { signal }),
1533
1607
  this.tenantId,
1534
1608
  );
1535
1609
  const matches = catalog.agents.filter((agent) =>
@@ -1586,47 +1660,14 @@ export class NativeAgentCompositeTransport {
1586
1660
  };
1587
1661
  }
1588
1662
 
1589
- async answer(args, { signal } = {}) {
1590
- throwIfAborted(signal);
1591
- const prepared = await this.prepareNewSession(signal);
1592
- if (prepared.agent.directAnswer !== true) {
1593
- throw new Error("native-agent binding is not configured for direct answers");
1594
- }
1595
- if (prepared.agent.sideEffects === "writes") {
1596
- throw new Error(
1597
- "write-capable native agents must use start/resume instead of answer_native_agent",
1598
- );
1599
- }
1600
- const payload = await prepared.session.call(
1601
- NATIVE_AGENT_UPSTREAM_ANSWER_TOOL,
1602
- this.normalizeAnswerArguments(args, prepared.agent),
1603
- );
1604
- const finalText = extractAnswerFinalText(payload);
1605
- if (finalText === null) throw new Error("native-agent answer returned no forUser or answer");
1606
- return {
1607
- ...(typeof payload.forUser === "string" && payload.forUser.trim()
1608
- ? { forUser: payload.forUser }
1609
- : {}),
1610
- ...(typeof payload.answer === "string" && payload.answer.trim()
1611
- ? { answer: payload.answer }
1612
- : {}),
1613
- agentId: this.agentId,
1614
- scopeParam: this.scopeParam,
1615
- };
1616
- }
1617
-
1618
- async run(args, { signal, onProgress } = {}) {
1619
- throwIfAborted(signal);
1620
- this.ensureRoots();
1621
- const attachment = boundedAttachmentSignal(signal, this.attachmentWindowMs);
1622
- let state = null;
1623
- let lock = null;
1663
+ allocateState(startArguments, { startMode = "durable" } = {}) {
1624
1664
  let admission = null;
1625
1665
  try {
1626
- const prepared = await this.prepareNewSession(attachment.signal);
1627
- const startArguments = this.normalizeRunArguments(args, prepared.agent);
1666
+ this.ensureRoots();
1628
1667
  admission = this.acquireAdmissionLock();
1629
- if (!admission) throw new Error("native-agent run allocation is busy; retry without changing the request");
1668
+ if (!admission) {
1669
+ throw new Error("native-agent run allocation is busy; retry without changing the request");
1670
+ }
1630
1671
  this.prune(this.now(), { reserve: 1, guard: () => admission.assert() });
1631
1672
  admission.assert();
1632
1673
  const invocationId = this.randomUUID();
@@ -1640,7 +1681,7 @@ export class NativeAgentCompositeTransport {
1640
1681
  startArguments,
1641
1682
  });
1642
1683
  const timestamp = new Date(this.now()).toISOString();
1643
- state = {
1684
+ const state = {
1644
1685
  schema: STATE_SCHEMA,
1645
1686
  invocationId,
1646
1687
  tenantId: this.tenantId,
@@ -1653,15 +1694,83 @@ export class NativeAgentCompositeTransport {
1653
1694
  upstreamRunId: null,
1654
1695
  status: "starting",
1655
1696
  startAttempts: 0,
1697
+ ...(startMode === "answer" ? { startMode } : {}),
1656
1698
  createdAt: timestamp,
1657
1699
  updatedAt: timestamp,
1658
1700
  };
1701
+ this.persist(state);
1702
+ return state;
1703
+ } finally {
1704
+ admission?.release();
1705
+ }
1706
+ }
1707
+
1708
+ async answer(args, { signal } = {}) {
1709
+ throwIfAborted(signal);
1710
+ const attachment = boundedAttachmentSignal(signal, this.attachmentWindowMs);
1711
+ let state = null;
1712
+ let lock = null;
1713
+ try {
1714
+ const prepared = await this.prepareNewSession(attachment.signal);
1715
+ if (prepared.agent.directAnswer !== true) {
1716
+ throw new Error("native-agent binding is not configured for direct answers");
1717
+ }
1718
+ if (prepared.agent.sideEffects === "writes") {
1719
+ throw new Error(
1720
+ "write-capable native agents must use start/resume instead of answer_native_agent",
1721
+ );
1722
+ }
1723
+ const answerArguments = this.normalizeAnswerArguments(args, prepared.agent);
1724
+ const startArguments = this.normalizeRunArguments({
1725
+ task: answerArguments.question,
1726
+ ...(args.context !== undefined ? { context: args.context } : {}),
1727
+ contextKeys: args.contextKeys ?? [],
1728
+ }, prepared.agent);
1729
+ state = this.allocateState(startArguments, { startMode: "answer" });
1730
+ lock = this.acquireLock(state.invocationId);
1731
+ if (!lock) return handleForState(state);
1732
+ const result = await this.attach(state, prepared, {
1733
+ signal: attachment.signal,
1734
+ lock,
1735
+ });
1736
+ if (result?.schema === NATIVE_AGENT_RESULT_SCHEMA && result.status === "succeeded") {
1737
+ return {
1738
+ forUser: result.finalText,
1739
+ answer: result.finalText,
1740
+ agentId: this.agentId,
1741
+ scopeParam: this.scopeParam,
1742
+ };
1743
+ }
1744
+ return result;
1745
+ } catch (error) {
1746
+ if (!state) throw error;
1747
+ if (lock) {
1748
+ try {
1749
+ this.persistDiagnostic(state, error, lock);
1750
+ } catch {
1751
+ // The persisted handle remains authoritative after a lost lease.
1752
+ }
1753
+ }
1754
+ return handleForState(state);
1755
+ } finally {
1756
+ lock?.release();
1757
+ attachment.dispose();
1758
+ }
1759
+ }
1760
+
1761
+ async run(args, { signal, onProgress } = {}) {
1762
+ throwIfAborted(signal);
1763
+ this.ensureRoots();
1764
+ const attachment = boundedAttachmentSignal(signal, this.attachmentWindowMs);
1765
+ let state = null;
1766
+ let lock = null;
1767
+ try {
1768
+ const prepared = await this.prepareNewSession(attachment.signal);
1769
+ const startArguments = this.normalizeRunArguments(args, prepared.agent);
1770
+ state = this.allocateState(startArguments);
1659
1771
  // The durable identity and exact retry arguments exist before the first
1660
1772
  // upstream byte is sent.
1661
- this.persist(state);
1662
- admission.release();
1663
- admission = null;
1664
- lock = this.acquireLock(invocationId);
1773
+ lock = this.acquireLock(state.invocationId);
1665
1774
  if (!lock) return handleForState(state);
1666
1775
  return await this.attach(state, prepared, {
1667
1776
  signal: attachment.signal,
@@ -1677,7 +1786,6 @@ export class NativeAgentCompositeTransport {
1677
1786
  }
1678
1787
  throw error;
1679
1788
  } finally {
1680
- admission?.release();
1681
1789
  lock?.release();
1682
1790
  attachment.dispose();
1683
1791
  }
@@ -1893,7 +2001,47 @@ export class NativeAgentCompositeTransport {
1893
2001
  state.updatedAt = new Date(this.now()).toISOString();
1894
2002
  this.persist(state, lock);
1895
2003
  try {
1896
- started = await session.call(NATIVE_AGENT_START_TOOL, state.startArguments);
2004
+ const answerMode = state.startMode === "answer";
2005
+ const outboundArguments = answerMode
2006
+ ? {
2007
+ agentId: state.agentId,
2008
+ scopeParam: state.scopeParam,
2009
+ question: state.startArguments.task,
2010
+ ...(state.startArguments.context !== undefined
2011
+ ? { context: state.startArguments.context }
2012
+ : {}),
2013
+ contextKeys: state.startArguments.contextKeys ?? [],
2014
+ idempotencyKey: state.idempotencyKey,
2015
+ }
2016
+ : state.startArguments;
2017
+ started = await session.call(
2018
+ answerMode ? NATIVE_AGENT_UPSTREAM_ANSWER_TOOL : NATIVE_AGENT_START_TOOL,
2019
+ outboundArguments,
2020
+ { signal },
2021
+ );
2022
+ if (answerMode) {
2023
+ if (!started?.runId) {
2024
+ throw new Error("native-agent answer returned no durable runId");
2025
+ }
2026
+ const answerText = extractAnswerFinalText(started);
2027
+ if (started.status === "succeeded" && answerText !== null) {
2028
+ started = {
2029
+ runId: started.runId,
2030
+ status: "succeeded",
2031
+ result: { finalText: answerText },
2032
+ ...(Number.isSafeInteger(started.version)
2033
+ ? { version: started.version }
2034
+ : {}),
2035
+ };
2036
+ } else if (
2037
+ started.status !== "continuation_required"
2038
+ || started.durableRunCreated !== true
2039
+ ) {
2040
+ throw new Error(
2041
+ "native-agent answer returned neither a terminal answer nor a durable continuation",
2042
+ );
2043
+ }
2044
+ }
1897
2045
  break;
1898
2046
  } catch (error) {
1899
2047
  if (error?.name === "AbortError") throw error;
@@ -1935,7 +2083,9 @@ export class NativeAgentCompositeTransport {
1935
2083
  const observed = await session.call(NATIVE_AGENT_READ_TOOL, {
1936
2084
  runId: state.upstreamRunId,
1937
2085
  waitSeconds: boundedWait,
1938
- });
2086
+ terminalOnly: process.env.IMPEL_NATIVE_TERMINAL_ONLY_READS !== "0",
2087
+ ...(Number.isSafeInteger(state.version) ? { sinceVersion: state.version } : {}),
2088
+ }, { signal });
1939
2089
  this.commitPayload(state, observed, lock);
1940
2090
  onProgress?.({ status: state.status, runId: state.upstreamRunId, poll: poll + 1 });
1941
2091
  result = resultForState(state);
@@ -1980,31 +2130,29 @@ const HANDLE_SCHEMA = {
1980
2130
 
1981
2131
  export function nativeAgentCompositeTools({ mode = "durable" } = {}) {
1982
2132
  if (!["durable", "recovery", "answer"].includes(mode)) throw new Error("invalid native-agent MCP mode");
1983
- if (mode === "answer") {
1984
- return [{
1985
- name: NATIVE_AGENT_ANSWER_TOOL,
1986
- description: "Answer once through the direct-answer native agent fixed by this MCP server.",
1987
- inputSchema: {
1988
- type: "object",
1989
- additionalProperties: false,
1990
- anyOf: [
1991
- { required: ["question"], not: { required: ["task"] } },
1992
- { required: ["task"], not: { required: ["question"] } },
1993
- ],
1994
- properties: {
1995
- question: { type: "string", minLength: 1, maxLength: 40_000 },
1996
- task: { type: "string", minLength: 1, maxLength: 40_000 },
1997
- context: { type: "string", maxLength: 40_000 },
1998
- contextKeys: {
1999
- type: "array",
2000
- maxItems: 30,
2001
- uniqueItems: true,
2002
- items: { type: "string", minLength: 1, maxLength: 160 },
2003
- },
2133
+ const answerTool = {
2134
+ name: NATIVE_AGENT_ANSWER_TOOL,
2135
+ description: "Answer once through the direct-answer native agent fixed by this MCP server.",
2136
+ inputSchema: {
2137
+ type: "object",
2138
+ additionalProperties: false,
2139
+ anyOf: [
2140
+ { required: ["question"], not: { required: ["task"] } },
2141
+ { required: ["task"], not: { required: ["question"] } },
2142
+ ],
2143
+ properties: {
2144
+ question: { type: "string", minLength: 1, maxLength: 40_000 },
2145
+ task: { type: "string", minLength: 1, maxLength: 40_000 },
2146
+ context: { type: "string", maxLength: 40_000 },
2147
+ contextKeys: {
2148
+ type: "array",
2149
+ maxItems: 30,
2150
+ uniqueItems: true,
2151
+ items: { type: "string", minLength: 1, maxLength: 160 },
2004
2152
  },
2005
2153
  },
2006
- }];
2007
- }
2154
+ },
2155
+ };
2008
2156
  const tools = [
2009
2157
  {
2010
2158
  name: NATIVE_AGENT_RUN_TOOL,
@@ -2045,6 +2193,9 @@ export function nativeAgentCompositeTools({ mode = "durable" } = {}) {
2045
2193
  },
2046
2194
  },
2047
2195
  ];
2196
+ if (mode === "answer") {
2197
+ return [answerTool, ...tools.filter(({ name }) => name !== NATIVE_AGENT_RUN_TOOL)];
2198
+ }
2048
2199
  return mode === "recovery" ? tools.filter(({ name }) => name !== NATIVE_AGENT_RUN_TOOL) : tools;
2049
2200
  }
2050
2201
 
@@ -67,12 +67,27 @@ export function impelCliInvocation(args = [], options = {}) {
67
67
  export const IMPEL_MANAGED_MCP_ENV = brandedEnvironmentName("MANAGED_MCP");
68
68
  export const IMPEL_TASKS_MCP_SERVER_NAME = `${RUNTIME_BRAND.cli.providerId}-tasks`;
69
69
  export const IMPEL_NATIVE_AGENT_MCP_TARGET = "native-agent";
70
+ export const IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES = [
71
+ "IMPEL_NATIVE_AGENT_TELEMETRY_PATH",
72
+ "IMPEL_NATIVE_HOST",
73
+ "IMPEL_NATIVE_HOST_BUILD",
74
+ ];
75
+
76
+ function managedMcpEnvironment(environment = process.env) {
77
+ const telemetry = Object.fromEntries(
78
+ IMPEL_NATIVE_AGENT_TELEMETRY_ENV_NAMES.flatMap((name) => {
79
+ const value = environment?.[name];
80
+ return typeof value === "string" && value.length > 0 ? [[name, value]] : [];
81
+ }),
82
+ );
83
+ return { [IMPEL_MANAGED_MCP_ENV]: "1", ...telemetry };
84
+ }
70
85
 
71
86
  export function impelMcpInvocation(args = [], options = {}) {
72
87
  return {
73
88
  type: "stdio",
74
89
  ...impelCliInvocation(["mcp", ...args], options),
75
- env: { [IMPEL_MANAGED_MCP_ENV]: "1" },
90
+ env: managedMcpEnvironment(options.environment),
76
91
  };
77
92
  }
78
93
 
@@ -13,7 +13,8 @@ export function parentVerbatimRelayAppendix() {
13
13
  return (
14
14
  `When an explicit custom agent's catalog-derived description declares ${VERBATIM_RELAY_OPT_IN_MARKER}, ` +
15
15
  `spawn it with ${VERBATIM_SPAWN_REQUIREMENT} and relay its finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}; ` +
16
- "preserve Sources sections and citations exactly. " +
16
+ "preserve Sources sections and citations exactly. Start one child, then use one blocking wait sized for the child budget; " +
17
+ "on Codex, call wait_agent exactly once with timeout_ms=60000. Do not send follow-ups, short-poll the child, or synthesize while it is running. " +
17
18
  "Custom agents without that declaration keep the default delegation behavior."
18
19
  );
19
20
  }