impel-cli 0.20.41 → 0.20.42

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.
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
 
5
6
  import {
6
7
  CONFIG_DIR,
@@ -27,6 +28,7 @@ import {
27
28
  } from "./selfInvocation.js";
28
29
  import { normalizeTenantId } from "./tenants.js";
29
30
  import { renameWithWindowsRetry } from "./windowsFs.js";
31
+ import { CURRENT_CONFIG_VERSION } from "./managedProfileVersion.js";
30
32
 
31
33
  export {
32
34
  NATIVE_AGENT_ANSWER_TOOL,
@@ -38,6 +40,9 @@ export {
38
40
  export const NATIVE_AGENT_HANDLE_SCHEMA = "impel.native-agent-run.v1";
39
41
  export const NATIVE_AGENT_RESULT_SCHEMA = "impel.native-agent-result.v1";
40
42
  export const NATIVE_AGENT_RECOVERY_SCHEMA = "impel.native-agent-recovery.v1";
43
+ export const NATIVE_AGENT_CLIENT_BUILD = `cli/${JSON.parse(
44
+ fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"),
45
+ ).version}+manifest.v${CURRENT_CONFIG_VERSION}`;
41
46
 
42
47
  const STATE_SCHEMA = "impel.native-agent-state.v1";
43
48
  const DELETION_SCHEMA = "impel.native-agent-deletion.v1";
@@ -49,6 +54,7 @@ const DEFAULT_WAIT_SECONDS = 20;
49
54
  const MAX_WAIT_SECONDS = 35;
50
55
  const DEFAULT_ATTACHMENT_WINDOW_MS = 40_000;
51
56
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 42_000;
57
+ const DEFAULT_ANSWER_HEDGE_MS = 38_000;
52
58
  const DEFAULT_MAX_POLLS = 8;
53
59
  const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
54
60
  const DEFAULT_MAX_RETAINED_RUNS = 500;
@@ -68,6 +74,11 @@ const MANAGED_VIRTUAL_KEY_MISS_RETRY_DELAY_MS = 250;
68
74
  const NEXT_AMBIGUOUS_ANSWER_CODE = "native_agent_answer_ambiguous";
69
75
  const MCP_TOOL_RESULT_ERROR = Symbol("native-agent MCP tool result error");
70
76
  const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled", "canceled"]);
77
+ const SAFE_CLIENT_BUILD_RE = /^[A-Za-z0-9._+/-]{1,128}$/u;
78
+
79
+ if (!SAFE_CLIENT_BUILD_RE.test(NATIVE_AGENT_CLIENT_BUILD)) {
80
+ throw new Error("native-agent client build header is invalid");
81
+ }
71
82
 
72
83
  export function nativeAgentAttachmentWindowMs(environment = process.env) {
73
84
  const configured = environment?.[IMPEL_NATIVE_AGENT_ATTACHMENT_WINDOW_ENV];
@@ -82,6 +93,30 @@ export function nativeAgentAttachmentWindowMs(environment = process.env) {
82
93
  return parsed;
83
94
  }
84
95
 
96
+ export function nativeAgentAnswerHedgeMs(environment = process.env, { warn = console.warn } = {}) {
97
+ const configured = environment?.IMPEL_NATIVE_ANSWER_HEDGE_MS;
98
+ if (configured === undefined) return DEFAULT_ANSWER_HEDGE_MS;
99
+ if (!/^\d{1,9}$/u.test(configured)) {
100
+ warn(`IMPEL_NATIVE_ANSWER_HEDGE_MS must be a non-negative integer; using ${DEFAULT_ANSWER_HEDGE_MS}ms`);
101
+ return DEFAULT_ANSWER_HEDGE_MS;
102
+ }
103
+ const parsed = Number(configured);
104
+ if (!Number.isSafeInteger(parsed)) {
105
+ warn(`IMPEL_NATIVE_ANSWER_HEDGE_MS must be a safe integer; using ${DEFAULT_ANSWER_HEDGE_MS}ms`);
106
+ return DEFAULT_ANSWER_HEDGE_MS;
107
+ }
108
+ if (parsed === 0) return 0;
109
+ const maximum = Math.min(DEFAULT_ATTACHMENT_WINDOW_MS, DEFAULT_UPSTREAM_TIMEOUT_MS) - 1;
110
+ if (parsed >= DEFAULT_ATTACHMENT_WINDOW_MS || parsed >= DEFAULT_UPSTREAM_TIMEOUT_MS) {
111
+ warn(
112
+ `IMPEL_NATIVE_ANSWER_HEDGE_MS must be below ${DEFAULT_ATTACHMENT_WINDOW_MS}ms `
113
+ + `and ${DEFAULT_UPSTREAM_TIMEOUT_MS}ms; clamping to ${maximum}ms`,
114
+ );
115
+ return maximum;
116
+ }
117
+ return parsed;
118
+ }
119
+
85
120
  function stableValue(value) {
86
121
  if (Array.isArray(value)) return value.map(stableValue);
87
122
  if (!value || typeof value !== "object") return value;
@@ -704,6 +739,9 @@ export class NativeAgentUpstreamSession {
704
739
  telemetry = () => {},
705
740
  now = Date.now,
706
741
  requestIdFactory = crypto.randomUUID,
742
+ answerHedgeMs = nativeAgentAnswerHedgeMs(),
743
+ setTimeoutImpl = setTimeout,
744
+ clearTimeoutImpl = clearTimeout,
707
745
  }) {
708
746
  this.endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
709
747
  this.credential = credential;
@@ -714,6 +752,9 @@ export class NativeAgentUpstreamSession {
714
752
  this.telemetry = telemetry;
715
753
  this.now = now;
716
754
  this.requestIdFactory = requestIdFactory;
755
+ this.answerHedgeMs = Math.max(0, answerHedgeMs);
756
+ this.setTimeoutImpl = setTimeoutImpl;
757
+ this.clearTimeoutImpl = clearTimeoutImpl;
717
758
  this.sessionId = null;
718
759
  this.nextId = 1;
719
760
  }
@@ -729,7 +770,7 @@ export class NativeAgentUpstreamSession {
729
770
  let timedOut = false;
730
771
  const onAbort = () => controller.abort();
731
772
  signal?.addEventListener("abort", onAbort, { once: true });
732
- const timeout = setTimeout(() => {
773
+ const timeout = this.setTimeoutImpl(() => {
733
774
  timedOut = true;
734
775
  controller.abort();
735
776
  }, this.timeoutMs);
@@ -743,6 +784,7 @@ export class NativeAgentUpstreamSession {
743
784
  "Content-Type": "application/json",
744
785
  Accept: "application/json, text/event-stream",
745
786
  "X-Impel-Client-Request-Id": correlationId,
787
+ "X-Impel-Client-Build": NATIVE_AGENT_CLIENT_BUILD,
746
788
  ...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}),
747
789
  },
748
790
  body: JSON.stringify(message),
@@ -760,7 +802,7 @@ export class NativeAgentUpstreamSession {
760
802
  const detail = timedOut ? "request timed out" : redactSecretText(error?.message || error);
761
803
  throw new NativeAgentUpstreamError(`native-agent MCP request failed: ${detail}`, { ambiguous: true });
762
804
  } finally {
763
- clearTimeout(timeout);
805
+ this.clearTimeoutImpl(timeout);
764
806
  signal?.removeEventListener("abort", onAbort);
765
807
  }
766
808
  this.telemetry("upstream_request_completed", {
@@ -814,17 +856,78 @@ export class NativeAgentUpstreamSession {
814
856
  return toolsResponse.result.tools;
815
857
  }
816
858
 
817
- async call(name, args, { signal } = {}) {
859
+ async ping({ signal } = {}) {
860
+ const id = this.nextId++;
861
+ const messages = await this.post({ jsonrpc: "2.0", id, method: "ping", params: {} }, { signal });
862
+ const response = messages.find((message) => message?.id === id);
863
+ if (response?.error || !response?.result || typeof response.result !== "object") {
864
+ throw new NativeAgentUpstreamError("native-agent MCP ping failed");
865
+ }
866
+ }
867
+
868
+ async callAnswerWithHedge(message, { signal, onHedge } = {}) {
869
+ throwIfAborted(signal);
870
+ const startedAt = this.now();
871
+ const controller = new AbortController();
872
+ let hedgeFired = false;
873
+ let elapsedMs = 0;
874
+ let replayTerminal = false;
875
+ const onAbort = () => controller.abort();
876
+ signal?.addEventListener("abort", onAbort, { once: true });
877
+ const timer = this.setTimeoutImpl(() => {
878
+ if (controller.signal.aborted) return;
879
+ hedgeFired = true;
880
+ elapsedMs = Math.max(0, this.now() - startedAt);
881
+ onHedge?.();
882
+ controller.abort();
883
+ }, this.answerHedgeMs);
884
+ try {
885
+ try {
886
+ const messages = await this.post(message, { signal: controller.signal });
887
+ return toolPayload(messages.find((candidate) => candidate?.id === message.id));
888
+ } catch (error) {
889
+ if (!hedgeFired || signal?.aborted) throw error;
890
+ } finally {
891
+ this.clearTimeoutImpl(timer);
892
+ signal?.removeEventListener("abort", onAbort);
893
+ }
894
+
895
+ try {
896
+ const messages = await this.post(message, { signal });
897
+ const payload = toolPayload(messages.find((candidate) => candidate?.id === message.id));
898
+ replayTerminal = TERMINAL_STATUSES.has(payload?.status);
899
+ return payload;
900
+ } finally {
901
+ this.telemetry("hedge_fired", { elapsedMs, replayTerminal });
902
+ }
903
+ } finally {
904
+ this.clearTimeoutImpl(timer);
905
+ signal?.removeEventListener("abort", onAbort);
906
+ }
907
+ }
908
+
909
+ async call(name, args, { signal = this.signal, hedge = true } = {}) {
910
+ let hedgeFiredForCall = false;
818
911
  for (let attempt = 0; attempt < 2; attempt += 1) {
819
912
  const id = this.nextId++;
913
+ const message = {
914
+ jsonrpc: "2.0",
915
+ id,
916
+ method: "tools/call",
917
+ params: { name, arguments: args },
918
+ };
820
919
  try {
821
- const messages = await this.post({
822
- jsonrpc: "2.0",
823
- id,
824
- method: "tools/call",
825
- params: { name, arguments: args },
826
- }, { signal });
827
- return toolPayload(messages.find((message) => message?.id === id));
920
+ if (name === NATIVE_AGENT_UPSTREAM_ANSWER_TOOL
921
+ && hedge
922
+ && !hedgeFiredForCall
923
+ && this.answerHedgeMs > 0) {
924
+ return await this.callAnswerWithHedge(message, {
925
+ signal,
926
+ onHedge: () => { hedgeFiredForCall = true; },
927
+ });
928
+ }
929
+ const messages = await this.post(message, { signal });
930
+ return toolPayload(messages.find((candidate) => candidate?.id === id));
828
931
  } catch (error) {
829
932
  if (attempt === 0 && retryableManagedVirtualKeyMiss(error)) {
830
933
  // This gateway-authored miss happens before a native run can exist.
@@ -1700,7 +1803,7 @@ export class NativeAgentCompositeTransport {
1700
1803
  }
1701
1804
  }
1702
1805
 
1703
- async prepareExistingSession(signal) {
1806
+ async prepareExistingSession(signal, { prewarmed = false } = {}) {
1704
1807
  const reused = Boolean(this.preparedSession);
1705
1808
  const startedAt = this.now();
1706
1809
  if (!this.preparedSession) {
@@ -1717,15 +1820,16 @@ export class NativeAgentCompositeTransport {
1717
1820
  this.telemetry("session_prepared", {
1718
1821
  durationMs: this.now() - startedAt,
1719
1822
  handshakeReused: reused,
1823
+ prewarmed,
1720
1824
  });
1721
1825
  return prepared;
1722
1826
  }
1723
1827
 
1724
- async prepareNewSession(signal) {
1725
- const prepared = await this.prepareExistingSession(signal);
1828
+ async prepareNewSession(signal, { prewarmed = false } = {}) {
1829
+ const prepared = await this.prepareExistingSession(signal, { prewarmed });
1726
1830
  const startedAt = this.now();
1727
1831
  if (this.preparedAgent) {
1728
- this.telemetry("binding_completed", { durationMs: 0, bindingReused: true });
1832
+ this.telemetry("binding_completed", { durationMs: 0, bindingReused: true, prewarmed });
1729
1833
  return { ...prepared, agent: this.preparedAgent };
1730
1834
  }
1731
1835
  const catalog = normalizeNativeAgentCatalog(
@@ -1746,10 +1850,29 @@ export class NativeAgentCompositeTransport {
1746
1850
  this.telemetry("binding_completed", {
1747
1851
  durationMs: this.now() - startedAt,
1748
1852
  bindingReused: false,
1853
+ prewarmed,
1749
1854
  });
1750
1855
  return { ...prepared, agent: this.preparedAgent };
1751
1856
  }
1752
1857
 
1858
+ async refreshPreparedSession(signal, { prewarmed = true } = {}) {
1859
+ const prepared = await this.prepareNewSession(signal, { prewarmed });
1860
+ try {
1861
+ await prepared.session.ping({ signal });
1862
+ } catch (error) {
1863
+ const cached = this.preparedSession;
1864
+ if (cached) {
1865
+ try {
1866
+ if ((await cached).session === prepared.session) this.preparedSession = null;
1867
+ } catch {
1868
+ this.preparedSession = null;
1869
+ }
1870
+ }
1871
+ throw error;
1872
+ }
1873
+ return prepared;
1874
+ }
1875
+
1753
1876
  async preparePersistedStartSession(signal, state) {
1754
1877
  const prepared = await this.prepareExistingSession(signal);
1755
1878
  const catalog = normalizeNativeAgentCatalog(
@@ -2189,7 +2312,7 @@ export class NativeAgentCompositeTransport {
2189
2312
  started = await session.call(
2190
2313
  answerMode ? NATIVE_AGENT_UPSTREAM_ANSWER_TOOL : NATIVE_AGENT_START_TOOL,
2191
2314
  outboundArguments,
2192
- { signal },
2315
+ { signal, hedge: answerMode && attempt === 0 },
2193
2316
  );
2194
2317
  if (answerMode) {
2195
2318
  if (!started?.runId) {
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { impelCliInvocation } from "./selfInvocation.js";
5
+ import { renameWithWindowsRetry } from "./windowsFs.js";
6
+
7
+ export const IMPEL_NATIVE_INTERCEPT_ENV = "IMPEL_NATIVE_INTERCEPT";
8
+ export const IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV = "IMPEL_NATIVE_INTERCEPT_TIMEOUT_MS";
9
+ export const DEFAULT_NATIVE_INTERCEPT_TIMEOUT_MS = 100_000;
10
+ export const MANAGED_NATIVE_INTERCEPT_FLAG = "impel-managed-native-intercept-v1";
11
+
12
+ export function nativeInterceptEnabled(environment = process.env) {
13
+ return ["1", "true"].includes(
14
+ String(environment?.[IMPEL_NATIVE_INTERCEPT_ENV] || "").toLowerCase(),
15
+ );
16
+ }
17
+
18
+ export function nativeInterceptTimeoutMs(environment = process.env) {
19
+ const configured = environment?.[IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV];
20
+ if (configured === undefined || configured === "") return DEFAULT_NATIVE_INTERCEPT_TIMEOUT_MS;
21
+ if (!/^\d{1,6}$/u.test(configured)) {
22
+ throw new Error(`${IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV} must be an integer from 1000 through 300000`);
23
+ }
24
+ const timeoutMs = Number(configured);
25
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 300_000) {
26
+ throw new Error(`${IMPEL_NATIVE_INTERCEPT_TIMEOUT_ENV} must be an integer from 1000 through 300000`);
27
+ }
28
+ return timeoutMs;
29
+ }
30
+
31
+ function readJsonObject(filePath) {
32
+ if (!fs.existsSync(filePath)) return {};
33
+ const raw = fs.readFileSync(filePath, "utf8").trim();
34
+ if (!raw) return {};
35
+ try {
36
+ const value = JSON.parse(raw);
37
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error();
38
+ return value;
39
+ } catch {
40
+ throw new Error(`${filePath} exists but isn't a valid JSON object. Fix or remove it, then re-run.`);
41
+ }
42
+ }
43
+
44
+ function writePrivateJson(filePath, value) {
45
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
46
+ const temporaryPath = `${filePath}.tmp-${process.pid}`;
47
+ try {
48
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
49
+ renameWithWindowsRetry(temporaryPath, filePath);
50
+ try { fs.chmodSync(filePath, 0o600); } catch { /* Best effort on Windows. */ }
51
+ } finally {
52
+ try { fs.rmSync(temporaryPath, { force: true }); } catch { /* Rename removed it. */ }
53
+ }
54
+ }
55
+
56
+ function handlerIsManaged(handler) {
57
+ return handler
58
+ && typeof handler === "object"
59
+ && Array.isArray(handler.args)
60
+ && handler.args.includes(`--${MANAGED_NATIVE_INTERCEPT_FLAG}`);
61
+ }
62
+
63
+ function removeManagedHandlers(groups) {
64
+ if (!Array.isArray(groups)) return { groups, changed: false };
65
+ let changed = false;
66
+ const preserved = [];
67
+ for (const group of groups) {
68
+ if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
69
+ preserved.push(group);
70
+ continue;
71
+ }
72
+ const hooks = group.hooks.filter((handler) => {
73
+ if (!handlerIsManaged(handler)) return true;
74
+ changed = true;
75
+ return false;
76
+ });
77
+ if (hooks.length > 0) preserved.push({ ...group, hooks });
78
+ }
79
+ return { groups: preserved, changed };
80
+ }
81
+
82
+ export function claudeNativeInterceptHandler(tenantId, timeoutMs, invocation = null) {
83
+ const command = invocation || impelCliInvocation([
84
+ "native", "intercept",
85
+ "--tenant", tenantId,
86
+ `--${MANAGED_NATIVE_INTERCEPT_FLAG}`,
87
+ ]);
88
+ return {
89
+ type: "command",
90
+ command: command.command,
91
+ args: [...command.args],
92
+ timeout: Math.ceil(timeoutMs / 1000),
93
+ async: false,
94
+ };
95
+ }
96
+
97
+ /** Install or remove only Impel's synchronous UserPromptSubmit interception hook. */
98
+ export function ensureClaudeNativeInterceptHook(configDir, tenantId, {
99
+ enabled = false,
100
+ timeoutMs = DEFAULT_NATIVE_INTERCEPT_TIMEOUT_MS,
101
+ invocation = null,
102
+ } = {}) {
103
+ const settingsPath = path.join(configDir, "settings.json");
104
+ if (!enabled) {
105
+ try {
106
+ if (!fs.readFileSync(settingsPath, "utf8").includes(`--${MANAGED_NATIVE_INTERCEPT_FLAG}`)) {
107
+ return { settingsPath, enabled: false, changed: false };
108
+ }
109
+ } catch (error) {
110
+ if (error?.code === "ENOENT") return { settingsPath, enabled: false, changed: false };
111
+ throw error;
112
+ }
113
+ }
114
+ const settings = readJsonObject(settingsPath);
115
+ const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
116
+ ? { ...settings.hooks }
117
+ : {};
118
+ const removed = removeManagedHandlers(hooks.UserPromptSubmit);
119
+ if (!enabled && !removed.changed) {
120
+ return { settingsPath, enabled: false, changed: false };
121
+ }
122
+ if (enabled) {
123
+ removed.groups.push({
124
+ hooks: [claudeNativeInterceptHandler(tenantId, timeoutMs, invocation)],
125
+ });
126
+ }
127
+ if (removed.groups.length > 0) hooks.UserPromptSubmit = removed.groups;
128
+ else delete hooks.UserPromptSubmit;
129
+ if (Object.keys(hooks).length > 0) settings.hooks = hooks;
130
+ else delete settings.hooks;
131
+ writePrivateJson(settingsPath, settings);
132
+ return { settingsPath, enabled, changed: true };
133
+ }