agentapprove 0.1.20 → 0.1.22

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.
Files changed (2) hide show
  1. package/dist/cli.js +977 -73
  2. package/package.json +2 -1
package/dist/cli.js CHANGED
@@ -2313,9 +2313,9 @@ var source_default = chalk;
2313
2313
 
2314
2314
  // src/cli.ts
2315
2315
  var import_qrcode_terminal = __toESM(require_main(), 1);
2316
- import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, readdirSync as readdirSync2, statSync, lstatSync, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
2317
- import { homedir, hostname, platform } from "os";
2318
- import { join, dirname as dirname2 } from "path";
2316
+ import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, readdirSync as readdirSync2, statSync, lstatSync, realpathSync as realpathSync2, rmSync as rmSync2 } from "fs";
2317
+ import { homedir, hostname, platform, tmpdir } from "os";
2318
+ import { join, dirname as dirname2, sep as sep2 } from "path";
2319
2319
  import { execSync, spawnSync } from "child_process";
2320
2320
  import { randomBytes, createHash } from "crypto";
2321
2321
 
@@ -2630,8 +2630,204 @@ function shouldCreateFreshPairing(connectionMethod) {
2630
2630
  return connectionMethod === "qr" || connectionMethod === "copy";
2631
2631
  }
2632
2632
 
2633
+ // src/install-validation.ts
2634
+ async function safeReadText(response) {
2635
+ try {
2636
+ return await response.text();
2637
+ } catch {
2638
+ return "";
2639
+ }
2640
+ }
2641
+ function parseErrorBody(body) {
2642
+ if (!body)
2643
+ return null;
2644
+ try {
2645
+ const parsed = JSON.parse(body);
2646
+ if (parsed && typeof parsed === "object") {
2647
+ return parsed;
2648
+ }
2649
+ } catch {}
2650
+ return null;
2651
+ }
2652
+ async function validateExistingToken(token, apiUrl, apiVersion, options = {}) {
2653
+ const fetchImpl = options.fetchImpl || fetch;
2654
+ const filename = options.preflightFilename || "common.sh";
2655
+ const url = `${apiUrl}/${apiVersion}/hooks/${filename}?format=raw`;
2656
+ let response;
2657
+ try {
2658
+ response = await fetchImpl(url, {
2659
+ headers: {
2660
+ Authorization: `Bearer ${token}`
2661
+ }
2662
+ });
2663
+ } catch (err) {
2664
+ const message = err instanceof Error ? err.message : "network error";
2665
+ return {
2666
+ kind: "network_error",
2667
+ summary: "cannot reach Agent Approve from this network",
2668
+ serverMessage: message
2669
+ };
2670
+ }
2671
+ const status = response.status;
2672
+ if (response.ok) {
2673
+ const body2 = await safeReadText(response);
2674
+ return {
2675
+ kind: "valid",
2676
+ summary: "verified - token is valid",
2677
+ cachedContent: body2,
2678
+ status
2679
+ };
2680
+ }
2681
+ const body = await safeReadText(response);
2682
+ const parsed = parseErrorBody(body);
2683
+ const code = parsed?.code;
2684
+ const serverMessage = parsed?.error;
2685
+ if (status === 401 && code === "TOKEN_EXPIRED") {
2686
+ return {
2687
+ kind: "expired",
2688
+ summary: "expired (refresh required)",
2689
+ serverMessage,
2690
+ status
2691
+ };
2692
+ }
2693
+ if (status === 401 && code === "INVALID_TOKEN") {
2694
+ return {
2695
+ kind: "invalid",
2696
+ summary: "invalid (re-pair required)",
2697
+ serverMessage,
2698
+ status
2699
+ };
2700
+ }
2701
+ if (status === 403 && code === "AUTH_SCOPE_DENIED") {
2702
+ return {
2703
+ kind: "scope_denied",
2704
+ summary: "scope denied (re-pair required)",
2705
+ serverMessage,
2706
+ status
2707
+ };
2708
+ }
2709
+ if (status === 503) {
2710
+ return {
2711
+ kind: "unavailable",
2712
+ summary: "validation temporarily unavailable",
2713
+ serverMessage,
2714
+ status
2715
+ };
2716
+ }
2717
+ if (status === 403) {
2718
+ return {
2719
+ kind: "network_blocked",
2720
+ summary: "cannot reach service from this network",
2721
+ serverMessage,
2722
+ status
2723
+ };
2724
+ }
2725
+ if (status === 401) {
2726
+ return {
2727
+ kind: "invalid",
2728
+ summary: "invalid (re-pair required)",
2729
+ serverMessage,
2730
+ status
2731
+ };
2732
+ }
2733
+ return {
2734
+ kind: "unavailable",
2735
+ summary: "validation temporarily unavailable",
2736
+ serverMessage,
2737
+ status
2738
+ };
2739
+ }
2740
+ function classifyHookDownloadFailure(status, body) {
2741
+ const parsed = parseErrorBody(body);
2742
+ const code = parsed?.code;
2743
+ if (status === 401 && code === "TOKEN_EXPIRED")
2744
+ return "token_expired";
2745
+ if (status === 401 && code === "INVALID_TOKEN")
2746
+ return "token_invalid";
2747
+ if (status === 403 && code === "AUTH_SCOPE_DENIED")
2748
+ return "scope_denied";
2749
+ if (status === 503)
2750
+ return "validation_unavailable";
2751
+ if (status === 403)
2752
+ return "network_blocked";
2753
+ if (status === 401)
2754
+ return "token_invalid";
2755
+ return "recoverable";
2756
+ }
2757
+
2758
+ // src/copy-hook-scripts.ts
2759
+ import { writeFileSync as fsWriteFileSync } from "fs";
2760
+ import { join as pathJoin } from "path";
2761
+ async function copyHookScripts(hooksDir, token, files, options) {
2762
+ const fetchImpl = options.fetchImpl || fetch;
2763
+ const writeFile = options.writeFileSync || fsWriteFileSync;
2764
+ const join = options.joinPath || pathJoin;
2765
+ let downloaded = 0;
2766
+ const failed = [];
2767
+ for (const file of files) {
2768
+ try {
2769
+ const response = await fetchImpl(`${options.apiUrl}/${options.apiVersion}/hooks/${file}?format=raw`, {
2770
+ headers: {
2771
+ Authorization: `Bearer ${token}`
2772
+ }
2773
+ });
2774
+ if (response.ok) {
2775
+ const content = await response.text();
2776
+ const isShellScript = content.startsWith("#!/") || content.startsWith("# ");
2777
+ const isJsBundle = file.endsWith(".js") && (content.startsWith("//") || content.startsWith("import ") || content.startsWith("var ") || content.startsWith("const ") || content.startsWith("export "));
2778
+ if (isShellScript || isJsBundle) {
2779
+ const filePath = join(hooksDir, file);
2780
+ writeFile(filePath, content, { mode: isShellScript ? 493 : 420 });
2781
+ downloaded++;
2782
+ } else {
2783
+ failed.push(`${file} (invalid content)`);
2784
+ }
2785
+ } else {
2786
+ const body = await response.text().catch(() => "");
2787
+ const failureKind = classifyHookDownloadFailure(response.status, body);
2788
+ if (failureKind !== "recoverable") {
2789
+ return {
2790
+ downloaded,
2791
+ failed,
2792
+ terminalFailure: {
2793
+ kind: failureKind,
2794
+ file,
2795
+ status: response.status
2796
+ }
2797
+ };
2798
+ }
2799
+ failed.push(`${file} (${response.status})`);
2800
+ }
2801
+ } catch (err) {
2802
+ failed.push(`${file} (${err instanceof Error ? err.message : "network error"})`);
2803
+ }
2804
+ }
2805
+ return { downloaded, failed };
2806
+ }
2807
+
2808
+ // src/pairing-api-url.ts
2809
+ function looksLikeHostedAgentApproveApi(baseUrl) {
2810
+ try {
2811
+ const host = new URL(baseUrl).hostname.toLowerCase();
2812
+ return host === "agentapprove.com" || host.endsWith(".agentapprove.com");
2813
+ } catch {
2814
+ return false;
2815
+ }
2816
+ }
2817
+ function resolvePairingApiBaseUrl(options) {
2818
+ const fromEnv = options.agentapproveApiEnv?.trim();
2819
+ if (fromEnv) {
2820
+ return fromEnv;
2821
+ }
2822
+ const fromFile = options.savedApiUrl?.trim();
2823
+ if (fromFile) {
2824
+ return fromFile;
2825
+ }
2826
+ return options.processDefaultApiUrl;
2827
+ }
2828
+
2633
2829
  // src/cli.ts
2634
- var VERSION = "0.1.20";
2830
+ var VERSION = "0.1.22";
2635
2831
  function getApiUrl() {
2636
2832
  return process.env.AGENTAPPROVE_API || "https://api.agentapprove.com";
2637
2833
  }
@@ -2703,13 +2899,15 @@ function getCommand() {
2703
2899
  }
2704
2900
  return filtered[0] || "install";
2705
2901
  }
2706
- var OPENCODE_PLUGIN_VERSION = "0.1.16";
2902
+ var OPENCODE_PLUGIN_VERSION = "0.1.18";
2707
2903
  var OPENCODE_PLUGIN_SPEC = `@agentapprove/opencode@${OPENCODE_PLUGIN_VERSION}`;
2708
- var OPENCLAW_PLUGIN_VERSION = "0.2.10";
2904
+ var OPENCLAW_PLUGIN_VERSION = "0.2.11";
2709
2905
  var OPENCLAW_PLUGIN_SPEC = `@agentapprove/openclaw@${OPENCLAW_PLUGIN_VERSION}`;
2710
- var PI_PLUGIN_VERSION = "0.1.5";
2906
+ var PI_PLUGIN_VERSION = "0.1.7";
2711
2907
  var PI_PLUGIN_SPEC = `npm:@agentapprove/pi@${PI_PLUGIN_VERSION}`;
2712
2908
  var PI_STATUS_TIMEOUT_MS = 5000;
2909
+ var HERMES_PLUGIN_VERSION = "0.1.0";
2910
+ var HERMES_PIP_DEPS = ["cryptography>=44.0,<46", "httpx>=0.27,<0.29"];
2713
2911
  var AGENTS = {
2714
2912
  "claude-code": {
2715
2913
  name: "Claude Code",
@@ -2748,6 +2946,23 @@ var AGENTS = {
2748
2946
  { name: "afterAgentResponse", file: "cursor-response.sh", description: "Agent response" }
2749
2947
  ]
2750
2948
  },
2949
+ windsurf: {
2950
+ name: "Windsurf",
2951
+ configPath: join(homedir(), ".codeium", "windsurf", "hooks.json"),
2952
+ hooksKey: "hooks",
2953
+ hooks: [
2954
+ { name: "pre_user_prompt", file: "windsurf-hook.sh", description: "User prompt (logging only)" },
2955
+ { name: "pre_read_code", file: "windsurf-hook.sh", description: "File read approval", isApprovalHook: true },
2956
+ { name: "post_read_code", file: "windsurf-hook.sh", description: "File read completion logging" },
2957
+ { name: "pre_write_code", file: "windsurf-hook.sh", description: "File write approval", isApprovalHook: true },
2958
+ { name: "post_write_code", file: "windsurf-hook.sh", description: "File write completion logging" },
2959
+ { name: "pre_run_command", file: "windsurf-hook.sh", description: "Shell command approval", isApprovalHook: true },
2960
+ { name: "post_run_command", file: "windsurf-hook.sh", description: "Shell command completion logging" },
2961
+ { name: "pre_mcp_tool_use", file: "windsurf-hook.sh", description: "MCP tool approval", isApprovalHook: true },
2962
+ { name: "post_mcp_tool_use", file: "windsurf-hook.sh", description: "MCP tool completion logging" },
2963
+ { name: "post_cascade_response", file: "windsurf-hook.sh", description: "Cascade response (logging)" }
2964
+ ]
2965
+ },
2751
2966
  "gemini-cli": {
2752
2967
  name: "Gemini CLI",
2753
2968
  configPath: join(homedir(), ".gemini", "settings.json"),
@@ -2827,6 +3042,27 @@ var AGENTS = {
2827
3042
  hooks: [
2828
3043
  { name: "agentapprove", file: "@agentapprove/pi", description: "Tool approval + event monitoring", isApprovalHook: true, isPlugin: true }
2829
3044
  ]
3045
+ },
3046
+ hermes: {
3047
+ name: "Hermes",
3048
+ configPath: join(homedir(), ".hermes", "config.yaml"),
3049
+ hooksKey: "plugins",
3050
+ hooks: [
3051
+ { name: "agentapprove", file: "agentapprove", description: "Tool approval + event monitoring + follow-up input", isApprovalHook: true, isPlugin: true }
3052
+ ]
3053
+ },
3054
+ openhands: {
3055
+ name: "OpenHands",
3056
+ configPath: join(homedir(), ".openhands", "hooks.json"),
3057
+ hooksKey: "",
3058
+ hooks: [
3059
+ { name: "session_start", file: "openhands-hook.sh", description: "Session started" },
3060
+ { name: "user_prompt_submit", file: "openhands-hook.sh", description: "User prompt (logging only)" },
3061
+ { name: "pre_tool_use", file: "openhands-hook.sh", description: "Tool approval", isApprovalHook: true, timeout: 300 },
3062
+ { name: "post_tool_use", file: "openhands-hook.sh", description: "Tool completion logging" },
3063
+ { name: "stop", file: "openhands-hook.sh", description: "Agent stopped (iOS input)", isApprovalHook: true, timeout: 600 },
3064
+ { name: "session_end", file: "openhands-hook.sh", description: "Session ended" }
3065
+ ]
2830
3066
  }
2831
3067
  };
2832
3068
  var SHARED_HOOK_FILES = ["common.sh"];
@@ -2874,7 +3110,24 @@ var HOOK_STATUS_LABELS = {
2874
3110
  userPromptSubmitted: "Prompt submitted",
2875
3111
  errorOccurred: "Error",
2876
3112
  SubagentStart: "Subagent start",
2877
- SubagentStop: "Subagent stop"
3113
+ SubagentStop: "Subagent stop",
3114
+ pre_user_prompt: "User prompt",
3115
+ pre_read_code: "File read approval",
3116
+ post_read_code: "File read completed",
3117
+ pre_write_code: "File write approval",
3118
+ post_write_code: "File write completed",
3119
+ pre_run_command: "Shell approval",
3120
+ post_run_command: "Shell completed",
3121
+ pre_mcp_tool_use: "MCP approval",
3122
+ post_mcp_tool_use: "MCP completed",
3123
+ post_cascade_response: "Cascade response",
3124
+ pre_tool_call: "Tool approval",
3125
+ post_tool_call: "Tool completed",
3126
+ pre_llm_call: "Prompt submitted",
3127
+ post_llm_call: "Stop",
3128
+ on_session_start: "Session start",
3129
+ on_session_end: "Session end",
3130
+ subagent_stop: "Subagent stop"
2878
3131
  };
2879
3132
  function findGitBash() {
2880
3133
  if (!isWindows())
@@ -3073,6 +3326,15 @@ function detectInstalledAgents() {
3073
3326
  installed.push(id);
3074
3327
  } catch {}
3075
3328
  }
3329
+ } else if (id === "hermes") {
3330
+ if (existsSync2(join(homedir(), ".hermes"))) {
3331
+ installed.push(id);
3332
+ } else {
3333
+ try {
3334
+ execSync("hermes --version", { stdio: "ignore" });
3335
+ installed.push(id);
3336
+ } catch {}
3337
+ }
3076
3338
  } else {
3077
3339
  const configDir = dirname2(agent.configPath);
3078
3340
  if (existsSync2(configDir)) {
@@ -3590,10 +3852,10 @@ function disableCodexFeatureFlag(configPath = join(homedir(), ".codex", "config.
3590
3852
  writeFileSync(configPath, updated, { mode: 384 });
3591
3853
  return { updated: true, backupPath };
3592
3854
  }
3593
- async function createPairingSession(configuredAgents, e2eKeyId) {
3855
+ async function createPairingSession(configuredAgents, e2eKeyId, apiBaseUrl = API_URL) {
3594
3856
  try {
3595
3857
  const machineHostname = hostname();
3596
- const response = await fetch(`${API_URL}/${API_VERSION}/pair`, {
3858
+ const response = await fetch(`${apiBaseUrl}/${API_VERSION}/pair`, {
3597
3859
  method: "POST",
3598
3860
  headers: { "Content-Type": "application/json" },
3599
3861
  body: JSON.stringify({
@@ -3612,9 +3874,9 @@ async function createPairingSession(configuredAgents, e2eKeyId) {
3612
3874
  return { sessionCode: "", qrUrl: "", expiresIn: 0, error: String(err) };
3613
3875
  }
3614
3876
  }
3615
- async function pollPairingSession(sessionCode) {
3877
+ async function pollPairingSession(sessionCode, apiBaseUrl = API_URL) {
3616
3878
  try {
3617
- const response = await fetch(`${API_URL}/${API_VERSION}/pair/${sessionCode}`);
3879
+ const response = await fetch(`${apiBaseUrl}/${API_VERSION}/pair/${sessionCode}`);
3618
3880
  return await response.json();
3619
3881
  } catch {
3620
3882
  return { status: "error" };
@@ -3623,7 +3885,7 @@ async function pollPairingSession(sessionCode) {
3623
3885
  function sleep(ms) {
3624
3886
  return new Promise((resolve) => setTimeout(resolve, ms));
3625
3887
  }
3626
- async function waitForPairing(sessionCode, onProgress, onCancel) {
3888
+ async function waitForPairing(sessionCode, onProgress, onCancel, apiBaseUrl = API_URL) {
3627
3889
  let cancelled = false;
3628
3890
  const handleKeypress = (key) => {
3629
3891
  const char = key.toString();
@@ -3650,14 +3912,14 @@ async function waitForPairing(sessionCode, onProgress, onCancel) {
3650
3912
  cleanup();
3651
3913
  return "cancelled";
3652
3914
  }
3653
- const result = await pollPairingSession(sessionCode);
3915
+ const result = await pollPairingSession(sessionCode, apiBaseUrl);
3654
3916
  if (result.status === "completed" && result.token) {
3655
3917
  cleanup();
3656
3918
  return {
3657
3919
  token: result.token,
3658
3920
  privacy: result.privacy || "full",
3659
3921
  email: result.email || "",
3660
- apiUrl: result.apiUrl || API_URL,
3922
+ apiUrl: result.apiUrl || apiBaseUrl,
3661
3923
  e2eServerKey: result.e2eServerKey
3662
3924
  };
3663
3925
  }
@@ -3911,12 +4173,27 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
3911
4173
  }
3912
4174
  return { success: true, backupPath: null, hooks: [installResult.label] };
3913
4175
  }
4176
+ if (agentId === "hermes") {
4177
+ if (mode === "observe") {
4178
+ const disableResult = disableHermesPluginViaCli();
4179
+ if (!disableResult.success) {
4180
+ return { success: false, backupPath: null, hooks: [], error: disableResult.error };
4181
+ }
4182
+ return { success: true, backupPath: null, hooks: [] };
4183
+ }
4184
+ const installResult = await installHermesPluginViaCli();
4185
+ if (!installResult.success) {
4186
+ return { success: false, backupPath: null, hooks: [], error: installResult.error };
4187
+ }
4188
+ return { success: true, backupPath: null, hooks: [installResult.label] };
4189
+ }
3914
4190
  const backupPath = backupConfig(agent.configPath);
3915
4191
  const config = readJsonConfig(agent.configPath);
3916
- if (!config[agent.hooksKey]) {
4192
+ const useTopLevelHooks = agent.hooksKey === "";
4193
+ if (!useTopLevelHooks && !config[agent.hooksKey]) {
3917
4194
  config[agent.hooksKey] = {};
3918
4195
  }
3919
- const hooksConfig = config[agent.hooksKey];
4196
+ const hooksConfig = useTopLevelHooks ? config : config[agent.hooksKey];
3920
4197
  const installedHooks = [];
3921
4198
  if (agentId === "openclaw") {
3922
4199
  const installResult = installOpenClawPluginViaCli();
@@ -4096,6 +4373,19 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4096
4373
  }
4097
4374
  }
4098
4375
  installedHooks.push(hook.name);
4376
+ } else if (agentId === "windsurf") {
4377
+ const existing = hooksConfig[hook.name];
4378
+ const existingArray = Array.isArray(existing) ? existing : existing && typeof existing === "object" ? [existing] : typeof existing === "string" ? [{ command: existing }] : [];
4379
+ const cleanedArray = existingArray.filter((h2) => {
4380
+ const command = h2.command || h2.powershell || "";
4381
+ return !command.includes("agentapprove");
4382
+ });
4383
+ cleanedArray.push({
4384
+ command: hookCommand,
4385
+ show_output: false
4386
+ });
4387
+ hooksConfig[hook.name] = cleanedArray;
4388
+ installedHooks.push(hook.name);
4099
4389
  } else if (agentId === "gemini-cli") {
4100
4390
  if (!hooksConfig[hook.name]) {
4101
4391
  hooksConfig[hook.name] = [];
@@ -4144,6 +4434,41 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4144
4434
  cleanedArray.push(hookEntry);
4145
4435
  hooksConfig[hook.name] = cleanedArray;
4146
4436
  installedHooks.push(hook.name);
4437
+ } else if (agentId === "openhands") {
4438
+ if (!hooksConfig[hook.name]) {
4439
+ hooksConfig[hook.name] = [];
4440
+ }
4441
+ const hookArray = hooksConfig[hook.name];
4442
+ const hookTimeout = hook.timeout;
4443
+ const isApproval = hook.isApprovalHook;
4444
+ const existingIdx = hookArray.findIndex((h2) => h2.hooks?.some((hookScript) => {
4445
+ if (typeof hookScript === "string")
4446
+ return hookScript.includes("agentapprove");
4447
+ if (typeof hookScript === "object" && hookScript.command)
4448
+ return hookScript.command.includes("agentapprove");
4449
+ return false;
4450
+ }));
4451
+ const hookObject = {
4452
+ type: "command",
4453
+ command: hookCommand
4454
+ };
4455
+ if (isApproval === true || hookTimeout !== undefined) {
4456
+ hookObject.timeout = hookTimeout ?? 300;
4457
+ hookObject.async = false;
4458
+ } else {
4459
+ hookObject.timeout = 30;
4460
+ hookObject.async = true;
4461
+ }
4462
+ const hookEntry = {
4463
+ matcher: "*",
4464
+ hooks: [hookObject]
4465
+ };
4466
+ if (existingIdx >= 0) {
4467
+ hookArray[existingIdx] = hookEntry;
4468
+ } else {
4469
+ hookArray.push(hookEntry);
4470
+ }
4471
+ installedHooks.push(hook.name);
4147
4472
  } else if (agentId === "copilot-cli") {
4148
4473
  if (typeof config["version"] !== "number") {
4149
4474
  config["version"] = 1;
@@ -4176,7 +4501,7 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4176
4501
  const approvalHooks = agent.hooks.filter((h2) => h2.isApprovalHook);
4177
4502
  for (const hook of approvalHooks) {
4178
4503
  if (hooksConfig[hook.name]) {
4179
- if (agentId === "claude-code" || agentId === "gemini-cli" || agentId === "codex") {
4504
+ if (agentId === "claude-code" || agentId === "gemini-cli" || agentId === "codex" || agentId === "openhands") {
4180
4505
  const hookArray = hooksConfig[hook.name];
4181
4506
  if (Array.isArray(hookArray)) {
4182
4507
  const cleaned = hookArray.filter((h2) => {
@@ -4237,6 +4562,31 @@ async function installHooksForAgent(agentId, hooksDir, mode = "approval") {
4237
4562
  hooksConfig[hook.name] = cleaned;
4238
4563
  }
4239
4564
  }
4565
+ } else if (agentId === "windsurf") {
4566
+ const existing = hooksConfig[hook.name];
4567
+ if (Array.isArray(existing)) {
4568
+ const cleaned = existing.filter((h2) => {
4569
+ if (typeof h2 === "object" && h2 !== null) {
4570
+ const cmd = h2.command || h2.powershell || "";
4571
+ return !cmd.includes("agentapprove");
4572
+ }
4573
+ if (typeof h2 === "string")
4574
+ return !h2.includes("agentapprove");
4575
+ return true;
4576
+ });
4577
+ if (cleaned.length === 0) {
4578
+ delete hooksConfig[hook.name];
4579
+ } else {
4580
+ hooksConfig[hook.name] = cleaned;
4581
+ }
4582
+ } else if (typeof existing === "object" && existing !== null) {
4583
+ const cmd = existing.command || existing.powershell || "";
4584
+ if (cmd.includes("agentapprove")) {
4585
+ delete hooksConfig[hook.name];
4586
+ }
4587
+ } else if (typeof existing === "string" && existing.includes("agentapprove")) {
4588
+ delete hooksConfig[hook.name];
4589
+ }
4240
4590
  } else if (agentId === "openclaw") {
4241
4591
  const pluginsObj = hooksConfig;
4242
4592
  const entries = pluginsObj?.entries;
@@ -4314,6 +4664,14 @@ codex_hooks = true`;
4314
4664
  hooksObj[hook.name] = [entry];
4315
4665
  }
4316
4666
  return JSON.stringify({ version: 1, hooks: hooksObj }, null, 2);
4667
+ } else if (agentId === "windsurf") {
4668
+ const hooksObj = {};
4669
+ for (const hook of agent.hooks) {
4670
+ const hookPath = join(hooksDir, hook.file);
4671
+ const hookCmd = buildHookCommand(hookPath, agentId);
4672
+ hooksObj[hook.name] = [{ command: hookCmd, show_output: false }];
4673
+ }
4674
+ return JSON.stringify({ hooks: hooksObj }, null, 2);
4317
4675
  } else if (agentId === "gemini-cli") {
4318
4676
  const hooksObj = {};
4319
4677
  for (const hook of agent.hooks) {
@@ -4361,6 +4719,23 @@ codex_hooks = true`;
4361
4719
  hooksObj[hook.name] = [entry];
4362
4720
  }
4363
4721
  return JSON.stringify({ version: 1, hooks: hooksObj }, null, 2);
4722
+ } else if (agentId === "openhands") {
4723
+ const hooksObj = {};
4724
+ for (const hook of agent.hooks) {
4725
+ const hookPath = join(hooksDir, hook.file);
4726
+ const hookCmd = buildHookCommand(hookPath, agentId);
4727
+ const hookTimeout = hook.timeout;
4728
+ const isApproval = hook.isApprovalHook;
4729
+ const blocking = isApproval === true || hookTimeout !== undefined;
4730
+ const hookObject = {
4731
+ type: "command",
4732
+ command: hookCmd,
4733
+ timeout: blocking ? hookTimeout ?? 300 : 30,
4734
+ async: !blocking
4735
+ };
4736
+ hooksObj[hook.name] = [{ matcher: "*", hooks: [hookObject] }];
4737
+ }
4738
+ return JSON.stringify(hooksObj, null, 2);
4364
4739
  } else if (agentId === "openclaw") {
4365
4740
  const configJson = JSON.stringify({
4366
4741
  plugins: {
@@ -4424,39 +4799,50 @@ codex_hooks = true`;
4424
4799
  "",
4425
4800
  "Restart Pi to activate the extension."
4426
4801
  ].join(`
4802
+ `);
4803
+ } else if (agentId === "hermes") {
4804
+ return [
4805
+ "Install the Agent Approve plugin for Hermes:",
4806
+ "",
4807
+ " npx agentapprove # automatic: download + verify + extract + enable",
4808
+ "",
4809
+ `Manual: download ${HERMES_BUNDLE_FILENAME} from your Agent Approve API,`,
4810
+ `verify SHA-256 matches ${HERMES_BUNDLE_SHA256.slice(0, 16)}…, extract into`,
4811
+ `~/.hermes/plugins/agentapprove/, write the SHA-256 to a .bundle-hash sidecar`,
4812
+ "in that directory, then:",
4813
+ "",
4814
+ " pip install --user cryptography httpx",
4815
+ " hermes plugins enable agentapprove",
4816
+ "",
4817
+ "The plugin reads your existing Agent Approve config from ~/.agentapprove/env.",
4818
+ "",
4819
+ "Hermes loads the plugin on the next session — no restart needed."
4820
+ ].join(`
4427
4821
  `);
4428
4822
  }
4429
4823
  return "";
4430
4824
  }
4431
- async function copyHookScripts(hooksDir, token, files) {
4432
- let downloaded = 0;
4433
- const failed = [];
4434
- for (const file of files) {
4435
- try {
4436
- const response = await fetch(`${API_URL}/${API_VERSION}/hooks/${file}?format=raw`, {
4437
- headers: {
4438
- Authorization: `Bearer ${token}`
4439
- }
4440
- });
4441
- if (response.ok) {
4442
- const content = await response.text();
4443
- const isShellScript = content.startsWith("#!/") || content.startsWith("# ");
4444
- const isJsBundle = file.endsWith(".js") && (content.startsWith("//") || content.startsWith("import ") || content.startsWith("var ") || content.startsWith("const ") || content.startsWith("export "));
4445
- if (isShellScript || isJsBundle) {
4446
- const filePath = join(hooksDir, file);
4447
- writeFileSync(filePath, content, { mode: isShellScript ? 493 : 420 });
4448
- downloaded++;
4449
- } else {
4450
- failed.push(`${file} (invalid content)`);
4451
- }
4452
- } else {
4453
- failed.push(`${file} (${response.status})`);
4454
- }
4455
- } catch (err) {
4456
- failed.push(`${file} (${err instanceof Error ? err.message : "network error"})`);
4457
- }
4458
- }
4459
- return { downloaded, failed };
4825
+ function describeDownloadTerminalFailure(kind) {
4826
+ switch (kind) {
4827
+ case "token_expired":
4828
+ return "Hook download stopped: your saved token has expired. Run `npx agentapprove refresh` to re-pair, or run the installer again to choose Refresh / Re-pair.";
4829
+ case "token_invalid":
4830
+ return "Hook download stopped: your saved token is no longer valid. Run `npx agentapprove pair` to pair this computer again.";
4831
+ case "scope_denied":
4832
+ return "Hook download stopped: your saved token cannot download hooks. Run `npx agentapprove pair` to pair this computer again.";
4833
+ case "validation_unavailable":
4834
+ return "Hook download stopped: Agent Approve could not validate your token right now. Please try again in a few minutes.";
4835
+ case "network_blocked":
4836
+ return "Hook download stopped: Service temporarily unavailable. Try again later.";
4837
+ case "recoverable":
4838
+ return "Hook download stopped: encountered an unexpected response.";
4839
+ }
4840
+ }
4841
+ async function copyHookScripts2(hooksDir, token, files, options = {}) {
4842
+ return copyHookScripts(hooksDir, token, files, {
4843
+ apiUrl: options.apiUrl || API_URL,
4844
+ apiVersion: API_VERSION
4845
+ });
4460
4846
  }
4461
4847
  function readOpenClawInstalledVersion() {
4462
4848
  const packagePath = join(homedir(), ".openclaw", "extensions", "openclaw", "package.json");
@@ -4603,6 +4989,346 @@ function removePiPluginViaCli() {
4603
4989
  };
4604
4990
  }
4605
4991
  }
4992
+ var HERMES_BUNDLE_SHA256 = "2572cec0a2d8467e9ffc2000e5c52cf652265d20638244a8a70e2e3a6c9d2734";
4993
+ var HERMES_BUNDLE_FILENAME = `agentapprove-hermes-${HERMES_PLUGIN_VERSION}.tar.gz`;
4994
+ var HERMES_PLUGIN_DIR = join(homedir(), ".hermes", "plugins", "agentapprove");
4995
+ function findPython() {
4996
+ for (const candidate of ["python3.12", "python3.11", "python3.10", "python3"]) {
4997
+ try {
4998
+ const out = execSync(`${candidate} -c "import sys; print('%d.%d' % sys.version_info[:2])"`, {
4999
+ encoding: "utf-8",
5000
+ stdio: ["pipe", "pipe", "ignore"]
5001
+ }).trim();
5002
+ const [major, minor] = out.split(".").map(Number);
5003
+ if (major === 3 && minor >= 10) {
5004
+ return { command: candidate, version: out };
5005
+ }
5006
+ } catch {
5007
+ continue;
5008
+ }
5009
+ }
5010
+ return null;
5011
+ }
5012
+ function isSafeApiUrl(candidate) {
5013
+ try {
5014
+ const parsed = new URL(candidate);
5015
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
5016
+ return false;
5017
+ if (!parsed.hostname)
5018
+ return false;
5019
+ return true;
5020
+ } catch {
5021
+ return false;
5022
+ }
5023
+ }
5024
+ function isSafeApiVersion(candidate) {
5025
+ return /^[a-z0-9-]+$/i.test(candidate);
5026
+ }
5027
+ function readInstallerConfig() {
5028
+ const envPath = join(getAgentApproveDir(), "env");
5029
+ let apiUrl = API_URL;
5030
+ let apiVersion = "v001";
5031
+ let token = "";
5032
+ if (existsSync2(envPath)) {
5033
+ try {
5034
+ const content = readFileSync(envPath, "utf-8");
5035
+ for (const line of content.split(`
5036
+ `)) {
5037
+ if (line.startsWith("#"))
5038
+ continue;
5039
+ const assignment = parseEnvAssignment(line);
5040
+ if (!assignment)
5041
+ continue;
5042
+ if (assignment.key === "AGENTAPPROVE_TOKEN") {
5043
+ token = assignment.value;
5044
+ } else if (assignment.key === "AGENTAPPROVE_API") {
5045
+ const normalized = assignment.value.replace(/\/+$/, "");
5046
+ if (isSafeApiUrl(normalized)) {
5047
+ apiUrl = normalized;
5048
+ }
5049
+ } else if (assignment.key === "AGENTAPPROVE_API_VERSION") {
5050
+ const normalized = assignment.value.replace(/^\/+|\/+$/g, "");
5051
+ if (isSafeApiVersion(normalized)) {
5052
+ apiVersion = normalized;
5053
+ }
5054
+ }
5055
+ }
5056
+ } catch {}
5057
+ }
5058
+ return { apiUrl: apiUrl.replace(/\/+$/, ""), apiVersion, token };
5059
+ }
5060
+ var HERMES_BUNDLE_MAX_BYTES = 10 * 1024 * 1024;
5061
+ async function downloadHermesBundle(apiUrl, apiVersion, token) {
5062
+ if (!isSafeApiUrl(apiUrl) || !isSafeApiVersion(apiVersion)) {
5063
+ return { ok: false, error: `Refusing to download bundle: invalid API URL or version in ~/.agentapprove/env` };
5064
+ }
5065
+ let url;
5066
+ try {
5067
+ url = new URL(`${apiVersion}/hooks/${encodeURIComponent(HERMES_BUNDLE_FILENAME)}`, apiUrl.endsWith("/") ? apiUrl : apiUrl + "/");
5068
+ } catch (err) {
5069
+ return { ok: false, error: `Could not build download URL: ${err instanceof Error ? err.message : String(err)}` };
5070
+ }
5071
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
5072
+ return { ok: false, error: `Refusing to download bundle: non-http(s) URL ${url.protocol}` };
5073
+ }
5074
+ let response;
5075
+ try {
5076
+ response = await fetch(url.toString(), {
5077
+ headers: { Authorization: `Bearer ${token}` }
5078
+ });
5079
+ } catch (err) {
5080
+ return { ok: false, error: `Network error downloading ${HERMES_BUNDLE_FILENAME}: ${err instanceof Error ? err.message : String(err)}` };
5081
+ }
5082
+ if (!response.ok) {
5083
+ return { ok: false, error: `Bundle download returned ${response.status} from ${url}` };
5084
+ }
5085
+ const declared = parseInt(response.headers.get("content-length") || "", 10);
5086
+ if (Number.isFinite(declared) && declared > HERMES_BUNDLE_MAX_BYTES) {
5087
+ return { ok: false, error: `Bundle exceeds ${HERMES_BUNDLE_MAX_BYTES} byte limit (server reported ${declared}). Refusing to download.` };
5088
+ }
5089
+ try {
5090
+ const buffer = await response.arrayBuffer();
5091
+ if (buffer.byteLength > HERMES_BUNDLE_MAX_BYTES) {
5092
+ return { ok: false, error: `Bundle body exceeded ${HERMES_BUNDLE_MAX_BYTES} byte limit (got ${buffer.byteLength}).` };
5093
+ }
5094
+ return { ok: true, bytes: new Uint8Array(buffer) };
5095
+ } catch (err) {
5096
+ return { ok: false, error: `Failed to read bundle body: ${err instanceof Error ? err.message : String(err)}` };
5097
+ }
5098
+ }
5099
+ function verifyBundleHash(bytes) {
5100
+ return createHash("sha256").update(bytes).digest("hex");
5101
+ }
5102
+ function validateExtractedTree(root) {
5103
+ const realRoot = realpathSync2(root);
5104
+ const stack = [root];
5105
+ while (stack.length > 0) {
5106
+ const dir = stack.pop();
5107
+ let entries;
5108
+ try {
5109
+ entries = readdirSync2(dir, { withFileTypes: true });
5110
+ } catch (err) {
5111
+ return { ok: false, error: `Cannot read staged dir ${dir}: ${err instanceof Error ? err.message : String(err)}` };
5112
+ }
5113
+ for (const entry of entries) {
5114
+ const fullPath = join(dir, String(entry.name));
5115
+ let resolved;
5116
+ try {
5117
+ resolved = realpathSync2(fullPath);
5118
+ } catch (err) {
5119
+ return { ok: false, error: `Cannot resolve ${fullPath}: ${err instanceof Error ? err.message : String(err)}` };
5120
+ }
5121
+ if (resolved !== realRoot && !resolved.startsWith(realRoot + sep2)) {
5122
+ return { ok: false, error: `Bundle entry escapes staging dir: ${String(entry.name)} → ${resolved}` };
5123
+ }
5124
+ if (!entry.isFile() && !entry.isDirectory()) {
5125
+ return { ok: false, error: `Bundle contains non-regular entry: ${String(entry.name)} (${entry.isSymbolicLink() ? "symlink" : "other"})` };
5126
+ }
5127
+ if (entry.isDirectory()) {
5128
+ stack.push(fullPath);
5129
+ }
5130
+ }
5131
+ }
5132
+ return { ok: true };
5133
+ }
5134
+ function hermesDepsAlreadyInstalled(pythonCommand) {
5135
+ try {
5136
+ execSync(`${pythonCommand} -c "import cryptography, httpx"`, {
5137
+ stdio: "pipe",
5138
+ timeout: 5000
5139
+ });
5140
+ return true;
5141
+ } catch {
5142
+ return false;
5143
+ }
5144
+ }
5145
+ async function installHermesPluginViaCli() {
5146
+ try {
5147
+ execSync("hermes --version", { stdio: "pipe" });
5148
+ } catch {
5149
+ return {
5150
+ success: false,
5151
+ label: "Agent Approve plugin",
5152
+ error: "Hermes CLI not found on PATH. Install Hermes from https://hermes-agent.nousresearch.com first, then re-run npx agentapprove."
5153
+ };
5154
+ }
5155
+ const python = findPython();
5156
+ if (!python) {
5157
+ return {
5158
+ success: false,
5159
+ label: "Agent Approve plugin",
5160
+ error: "Python 3.10+ not found on PATH. Install Python 3.10 or newer (e.g. `brew install python@3.12`) and re-run npx agentapprove."
5161
+ };
5162
+ }
5163
+ const cfg = readInstallerConfig();
5164
+ if (!cfg.token) {
5165
+ return {
5166
+ success: false,
5167
+ label: "Agent Approve plugin",
5168
+ error: "Missing API token. Pair your phone first by running `npx agentapprove` and completing the QR scan."
5169
+ };
5170
+ }
5171
+ const download = await downloadHermesBundle(cfg.apiUrl, cfg.apiVersion, cfg.token);
5172
+ if (!download.ok) {
5173
+ return { success: false, label: "Agent Approve plugin", error: download.error };
5174
+ }
5175
+ const actualHash = verifyBundleHash(download.bytes);
5176
+ if (actualHash !== HERMES_BUNDLE_SHA256) {
5177
+ return {
5178
+ success: false,
5179
+ label: "Agent Approve plugin",
5180
+ error: `Bundle integrity check failed: expected ${HERMES_BUNDLE_SHA256.slice(0, 16)}…, got ${actualHash.slice(0, 16)}…. Refusing to install.`
5181
+ };
5182
+ }
5183
+ let tmpDir;
5184
+ try {
5185
+ tmpDir = mkdtempSync(join(tmpdir(), "aa-hermes-"));
5186
+ } catch (err) {
5187
+ return {
5188
+ success: false,
5189
+ label: "Agent Approve plugin",
5190
+ error: `Could not create temp directory for bundle staging: ${err instanceof Error ? err.message : String(err)}`
5191
+ };
5192
+ }
5193
+ const tarballPath = join(tmpDir, HERMES_BUNDLE_FILENAME);
5194
+ let backupPath = null;
5195
+ try {
5196
+ writeFileSync(tarballPath, download.bytes);
5197
+ try {
5198
+ execSync(`tar -xzf "${tarballPath}" -C "${tmpDir}" --no-same-owner --no-same-permissions`, { stdio: "pipe" });
5199
+ } catch (err) {
5200
+ return {
5201
+ success: false,
5202
+ label: "Agent Approve plugin",
5203
+ error: `tar extract failed: ${err instanceof Error ? err.message : String(err)}`
5204
+ };
5205
+ }
5206
+ const stagedDir = join(tmpDir, "agentapprove");
5207
+ if (!existsSync2(stagedDir)) {
5208
+ return {
5209
+ success: false,
5210
+ label: "Agent Approve plugin",
5211
+ error: "Bundle did not contain expected `agentapprove/` directory."
5212
+ };
5213
+ }
5214
+ const validation = validateExtractedTree(stagedDir);
5215
+ if (!validation.ok) {
5216
+ return {
5217
+ success: false,
5218
+ label: "Agent Approve plugin",
5219
+ error: `Bundle integrity violation: ${validation.error}`
5220
+ };
5221
+ }
5222
+ mkdirSync(dirname2(HERMES_PLUGIN_DIR), { recursive: true });
5223
+ if (existsSync2(HERMES_PLUGIN_DIR)) {
5224
+ backupPath = `${HERMES_PLUGIN_DIR}.bak-${Date.now()}`;
5225
+ renameSync(HERMES_PLUGIN_DIR, backupPath);
5226
+ }
5227
+ try {
5228
+ renameSync(stagedDir, HERMES_PLUGIN_DIR);
5229
+ } catch (err) {
5230
+ if (backupPath) {
5231
+ try {
5232
+ renameSync(backupPath, HERMES_PLUGIN_DIR);
5233
+ backupPath = null;
5234
+ } catch {}
5235
+ }
5236
+ const restoreNote = backupPath ? ` Your previous install was preserved at ${backupPath} — recover with: mv "${backupPath}" "${HERMES_PLUGIN_DIR}"` : "";
5237
+ return {
5238
+ success: false,
5239
+ label: "Agent Approve plugin",
5240
+ error: `Could not install bundle to ${HERMES_PLUGIN_DIR}: ${err instanceof Error ? err.message : String(err)}.${restoreNote}`
5241
+ };
5242
+ }
5243
+ writeFileSync(join(HERMES_PLUGIN_DIR, ".bundle-hash"), HERMES_BUNDLE_SHA256, { mode: 420 });
5244
+ if (backupPath) {
5245
+ rmSync2(backupPath, { recursive: true, force: true });
5246
+ backupPath = null;
5247
+ }
5248
+ } catch (err) {
5249
+ if (backupPath) {
5250
+ try {
5251
+ rmSync2(HERMES_PLUGIN_DIR, { recursive: true, force: true });
5252
+ renameSync(backupPath, HERMES_PLUGIN_DIR);
5253
+ backupPath = null;
5254
+ } catch {}
5255
+ }
5256
+ const restoreNote = backupPath ? ` Your previous install was preserved at ${backupPath} — recover with: mv "${backupPath}" "${HERMES_PLUGIN_DIR}"` : "";
5257
+ return {
5258
+ success: false,
5259
+ label: "Agent Approve plugin",
5260
+ error: `Bundle install failed: ${err instanceof Error ? err.message : String(err)}.${restoreNote}`
5261
+ };
5262
+ } finally {
5263
+ try {
5264
+ rmSync2(tmpDir, { recursive: true, force: true });
5265
+ } catch {}
5266
+ if (backupPath && existsSync2(HERMES_PLUGIN_DIR)) {
5267
+ try {
5268
+ rmSync2(backupPath, { recursive: true, force: true });
5269
+ } catch {}
5270
+ }
5271
+ }
5272
+ if (!hermesDepsAlreadyInstalled(python.command)) {
5273
+ const depsArg = HERMES_PIP_DEPS.map((d2) => `"${d2}"`).join(" ");
5274
+ try {
5275
+ execSync(`${python.command} -m pip install --user --quiet ${depsArg}`, { stdio: "pipe" });
5276
+ } catch (err) {
5277
+ return {
5278
+ success: false,
5279
+ label: "Agent Approve plugin",
5280
+ error: `Installed bundle but could not install Python runtime deps: ${err instanceof Error ? err.message : String(err)}. ` + `Try manually: ${python.command} -m pip install --user ${HERMES_PIP_DEPS.join(" ")}`
5281
+ };
5282
+ }
5283
+ }
5284
+ try {
5285
+ execSync("hermes plugins enable agentapprove", { stdio: "pipe" });
5286
+ } catch (err) {
5287
+ return {
5288
+ success: false,
5289
+ label: "Agent Approve plugin",
5290
+ error: `Installed bundle but could not enable plugin in Hermes: ${err instanceof Error ? err.message : String(err)}. ` + `Try manually: hermes plugins enable agentapprove`
5291
+ };
5292
+ }
5293
+ return { success: true, label: `Agent Approve plugin ${HERMES_PLUGIN_VERSION}` };
5294
+ }
5295
+ function isHermesDisableNoOp(err) {
5296
+ if (!err || typeof err !== "object")
5297
+ return false;
5298
+ const e2 = err;
5299
+ const stderr = Buffer.isBuffer(e2.stderr) ? e2.stderr.toString() : String(e2.stderr || "");
5300
+ const stdout = Buffer.isBuffer(e2.stdout) ? e2.stdout.toString() : String(e2.stdout || "");
5301
+ const haystack = `${e2.message || ""}
5302
+ ${stderr}
5303
+ ${stdout}`.toLowerCase();
5304
+ return haystack.includes("not enabled") || haystack.includes("not installed") || haystack.includes("is not enabled") || haystack.includes("plugin agentapprove not found") || haystack.includes("plugin 'agentapprove' not found");
5305
+ }
5306
+ function disableHermesPluginViaCli() {
5307
+ try {
5308
+ execSync("hermes plugins disable agentapprove", { stdio: "pipe" });
5309
+ return { success: true };
5310
+ } catch (err) {
5311
+ if (isHermesDisableNoOp(err)) {
5312
+ return { success: true, alreadyDisabled: true };
5313
+ }
5314
+ return {
5315
+ success: false,
5316
+ error: err instanceof Error ? err.message : String(err)
5317
+ };
5318
+ }
5319
+ }
5320
+ function removeHermesPlugin() {
5321
+ const disabled = disableHermesPluginViaCli();
5322
+ try {
5323
+ rmSync2(HERMES_PLUGIN_DIR, { recursive: true, force: true });
5324
+ return disabled.success ? { success: true } : { success: false, error: `disable failed: ${disabled.error || "unknown"}` };
5325
+ } catch (err) {
5326
+ return {
5327
+ success: false,
5328
+ error: err instanceof Error ? err.message : String(err)
5329
+ };
5330
+ }
5331
+ }
4606
5332
  var SYSTEM_DEPS = [
4607
5333
  {
4608
5334
  name: "curl",
@@ -4810,16 +5536,107 @@ async function checkSystemDependencies() {
4810
5536
  }
4811
5537
  return true;
4812
5538
  }
5539
+ async function runExistingTokenPreflight(token, apiUrlForPreflight) {
5540
+ const preflightSpinner = _2();
5541
+ preflightSpinner.start("Checking saved token");
5542
+ let result = await validateExistingToken(token, apiUrlForPreflight, API_VERSION);
5543
+ if (result.kind === "unavailable" || result.kind === "network_error") {
5544
+ const message = result.kind === "unavailable" ? "Token validation is temporarily unavailable." : "Could not reach Agent Approve from this network.";
5545
+ preflightSpinner.stop(message);
5546
+ const retryChoice = await le({
5547
+ message: "How would you like to continue?",
5548
+ options: [
5549
+ { value: "retry", label: "Retry" },
5550
+ { value: "cancel", label: "Cancel" }
5551
+ ]
5552
+ });
5553
+ if (lD(retryChoice) || retryChoice === "cancel") {
5554
+ he("Installation cancelled");
5555
+ process.exit(0);
5556
+ }
5557
+ preflightSpinner.start("Checking saved token");
5558
+ result = await validateExistingToken(token, apiUrlForPreflight, API_VERSION);
5559
+ }
5560
+ switch (result.kind) {
5561
+ case "valid":
5562
+ preflightSpinner.stop("Saved token verified");
5563
+ break;
5564
+ case "expired":
5565
+ preflightSpinner.stop("Saved token has expired");
5566
+ break;
5567
+ case "invalid":
5568
+ case "scope_denied":
5569
+ preflightSpinner.stop("Saved token is no longer valid");
5570
+ break;
5571
+ case "network_blocked":
5572
+ preflightSpinner.stop("Service temporarily unavailable");
5573
+ break;
5574
+ default:
5575
+ preflightSpinner.stop("Saved token check finished");
5576
+ }
5577
+ return result;
5578
+ }
5579
+ function describeExistingTokenStatus(preflight) {
5580
+ if (!preflight) {
5581
+ return "already linked to your Agent Approve account";
5582
+ }
5583
+ switch (preflight.kind) {
5584
+ case "valid":
5585
+ return "verified - linked to your Agent Approve account";
5586
+ case "expired":
5587
+ return "expired (refresh required)";
5588
+ case "invalid":
5589
+ return "invalid (re-pair required)";
5590
+ case "scope_denied":
5591
+ return "scope denied (re-pair required)";
5592
+ case "unavailable":
5593
+ return "validation temporarily unavailable";
5594
+ case "network_blocked":
5595
+ return "cannot reach service from this network";
5596
+ case "network_error":
5597
+ return "cannot reach Agent Approve from this network";
5598
+ }
5599
+ }
5600
+ async function promptTokenRecoveryAction(preflight) {
5601
+ if (preflight.kind === "expired") {
5602
+ const choice2 = await le({
5603
+ message: "Your saved token has expired. How would you like to continue?",
5604
+ options: [
5605
+ { value: "refresh", label: "Refresh token", hint: "Keeps the existing encryption key and current settings" },
5606
+ { value: "repair", label: "Pair this computer again", hint: "You can rotate the encryption key" },
5607
+ { value: "cancel", label: "Cancel", hint: "Exit the installer" }
5608
+ ]
5609
+ });
5610
+ if (lD(choice2))
5611
+ return "cancel";
5612
+ return choice2;
5613
+ }
5614
+ const choice = await le({
5615
+ message: "Your saved token is no longer valid. How would you like to continue?",
5616
+ options: [
5617
+ { value: "repair", label: "Pair this computer again" },
5618
+ { value: "cancel", label: "Cancel", hint: "Exit the installer" }
5619
+ ]
5620
+ });
5621
+ if (lD(choice))
5622
+ return "cancel";
5623
+ return choice;
5624
+ }
4813
5625
  async function installCommand() {
4814
5626
  console.clear();
4815
5627
  pe(source_default.bgCyan.black(" Agent Approve ") + source_default.gray(` Hooks Installer v${VERSION}`));
4816
5628
  migrateE2ERootKey();
4817
- const isCustomApi = !API_URL.includes("agentapprove.com");
4818
- if (isCustomApi) {
4819
- v2.warn(`Using custom API: ${API_URL}`);
4820
- }
4821
5629
  await checkSystemDependencies();
4822
5630
  const existingConfig = readExistingConfig();
5631
+ const pairingApiBase = resolvePairingApiBaseUrl({
5632
+ agentapproveApiEnv: process.env.AGENTAPPROVE_API,
5633
+ savedApiUrl: existingConfig?.apiUrl,
5634
+ processDefaultApiUrl: API_URL
5635
+ });
5636
+ const isCustomApi = !looksLikeHostedAgentApproveApi(pairingApiBase);
5637
+ if (isCustomApi) {
5638
+ v2.warn(`Using custom API: ${pairingApiBase}`);
5639
+ }
4823
5640
  const hasExistingToken = !!(existingConfig?.token && existingConfig.token.length > 10);
4824
5641
  let existingTokenPreview = "Not set";
4825
5642
  let existingKeyId = null;
@@ -4831,8 +5648,14 @@ async function installCommand() {
4831
5648
  existingKeyId = createHash("sha256").update(Buffer.from(keyHex, "hex")).digest("hex").slice(0, 8);
4832
5649
  }
4833
5650
  }
5651
+ const existingTokenPreflight = hasExistingToken ? await runExistingTokenPreflight(existingConfig.token, pairingApiBase) : null;
5652
+ if (existingTokenPreflight?.kind === "network_blocked") {
5653
+ v2.error("Service temporarily unavailable. Try again later.");
5654
+ he("Installation cancelled");
5655
+ process.exit(1);
5656
+ }
4834
5657
  me(`Approve AI agent actions from your iPhone or Apple Watch.
4835
- Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
5658
+ Installs hooks and extensions for Cursor, Windsurf, Claude, Gemini, Pi, OpenCode, OpenClaw, and more.`, "About");
4836
5659
  const installedAgents = detectInstalledAgents();
4837
5660
  const agentOptions = Object.entries(AGENTS).map(([id, agent]) => ({
4838
5661
  value: id,
@@ -4850,7 +5673,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
4850
5673
  process.exit(0);
4851
5674
  }
4852
5675
  const setupProfileSummary = existingConfig ? formatSetupProfileBlock("Existing config", getInitialInstallConfig("existing-config", existingConfig), [
4853
- `Connection token: ${existingTokenPreview} - already linked to your Agent Approve account.`,
5676
+ `Connection token: ${existingTokenPreview} - ${describeExistingTokenStatus(existingTokenPreflight)}.`,
4854
5677
  ...existingKeyId ? [`Encryption key ID: ${existingKeyId} - already installed for this computer.`] : []
4855
5678
  ]) : formatSetupProfileBlock("Recommended setup", getInitialInstallConfig("recommended", existingConfig));
4856
5679
  me(setupProfileSummary, "Setup profiles");
@@ -4862,13 +5685,23 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
4862
5685
  he("Installation cancelled");
4863
5686
  process.exit(0);
4864
5687
  }
5688
+ let pendingRecoveryAction = null;
5689
+ if (hasExistingToken && existingTokenPreflight && (existingTokenPreflight.kind === "expired" || existingTokenPreflight.kind === "invalid" || existingTokenPreflight.kind === "scope_denied")) {
5690
+ pendingRecoveryAction = await promptTokenRecoveryAction(existingTokenPreflight);
5691
+ if (pendingRecoveryAction === "cancel") {
5692
+ he("Installation cancelled");
5693
+ process.exit(0);
5694
+ }
5695
+ }
5696
+ const requiresFreshPair = pendingRecoveryAction !== null;
5697
+ const forceKeyReuseForRecovery = pendingRecoveryAction === "refresh";
4865
5698
  ensureAgentApproveDir();
4866
5699
  const hooksDir = join(getAgentApproveDir(), "hooks");
4867
5700
  const selectedInstallConfig = getInitialInstallConfig(setupProfile, existingConfig);
4868
5701
  let token = null;
4869
5702
  let finalPrivacy = selectedInstallConfig.privacy;
4870
5703
  let email = "";
4871
- let apiUrl = API_URL;
5704
+ let apiUrl = pairingApiBase;
4872
5705
  let debugLog = selectedInstallConfig.debugLog;
4873
5706
  let retentionDays = selectedInstallConfig.retentionDays;
4874
5707
  let failBehavior = selectedInstallConfig.failBehavior;
@@ -4956,7 +5789,8 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
4956
5789
  if (existsSync2(existingKeyPath)) {
4957
5790
  const oldKeyHex = readFileSync(existingKeyPath, "utf-8").trim();
4958
5791
  const oldKeyId = createHash("sha256").update(Buffer.from(oldKeyHex, "hex")).digest("hex").slice(0, 8);
4959
- if (setupProfile === "existing-config") {
5792
+ const autoReuseKey = forceKeyReuseForRecovery || setupProfile === "existing-config" && !requiresFreshPair;
5793
+ if (autoReuseKey) {
4960
5794
  e2eUserKey = oldKeyHex;
4961
5795
  } else {
4962
5796
  v2.info(`Existing E2E key found (Key ID: ${oldKeyId})`);
@@ -5014,9 +5848,10 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5014
5848
  }
5015
5849
  debugLog = debugLogChoice;
5016
5850
  }
5017
- const silentlyReuseExistingToken = setupProfile === "existing-config" && hasExistingToken;
5851
+ const canReuseExistingToken = hasExistingToken && !requiresFreshPair;
5852
+ const silentlyReuseExistingToken = setupProfile === "existing-config" && canReuseExistingToken;
5018
5853
  const connectionOptions = [];
5019
- if (hasExistingToken) {
5854
+ if (canReuseExistingToken) {
5020
5855
  const tokenPreview = existingConfig.token.slice(0, 15) + "...";
5021
5856
  connectionOptions.push({
5022
5857
  value: "existing",
@@ -5024,10 +5859,20 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5024
5859
  hint: tokenPreview
5025
5860
  });
5026
5861
  }
5027
- connectionOptions.push({ value: "qr", label: "Scan QR code", hint: hasExistingToken ? undefined : "Recommended" }, { value: "copy", label: "Copy and paste code", hint: "No camera" });
5862
+ connectionOptions.push({ value: "qr", label: "Scan QR code", hint: canReuseExistingToken ? undefined : "Recommended" }, { value: "copy", label: "Copy and paste code", hint: "No camera" });
5028
5863
  let connectionMethod;
5029
5864
  if (silentlyReuseExistingToken) {
5030
5865
  connectionMethod = "existing";
5866
+ } else if (requiresFreshPair) {
5867
+ const connectionChoice = await le({
5868
+ message: pendingRecoveryAction === "refresh" ? "How would you like to refresh the token?" : "How would you like to pair this computer?",
5869
+ options: connectionOptions
5870
+ });
5871
+ if (lD(connectionChoice)) {
5872
+ he("Installation cancelled");
5873
+ process.exit(0);
5874
+ }
5875
+ connectionMethod = connectionChoice;
5031
5876
  } else {
5032
5877
  const connectionChoice = await le({
5033
5878
  message: "Connect to iOS app (required for setup and any hook downloads)",
@@ -5041,7 +5886,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5041
5886
  }
5042
5887
  if (connectionMethod === "existing") {
5043
5888
  token = existingConfig.token;
5044
- apiUrl = existingConfig.apiUrl || API_URL;
5889
+ apiUrl = pairingApiBase;
5045
5890
  if (setupProfile === "existing-config" && existingConfig?.configSetAt) {
5046
5891
  configSetAt = existingConfig.configSetAt;
5047
5892
  }
@@ -5067,7 +5912,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5067
5912
  configSetAt,
5068
5913
  e2eEnabled: useE2E
5069
5914
  };
5070
- const session = await createPairingSession(selectedAgents, e2eKeyId);
5915
+ const session = await createPairingSession(selectedAgents, e2eKeyId, pairingApiBase);
5071
5916
  if (!session || session.error) {
5072
5917
  v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
5073
5918
  he("Cannot continue without token");
@@ -5093,7 +5938,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5093
5938
  const minutes = Math.floor(expiresIn / 60);
5094
5939
  const seconds = expiresIn % 60;
5095
5940
  pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
5096
- }, () => {});
5941
+ }, () => {}, pairingApiBase);
5097
5942
  if (result === "cancelled") {
5098
5943
  pairingSpinner.stop("Cancelled");
5099
5944
  he("Installation cancelled");
@@ -5102,6 +5947,7 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5102
5947
  token = result.token;
5103
5948
  finalPrivacy = result.privacy;
5104
5949
  email = result.email;
5950
+ apiUrl = result.apiUrl || pairingApiBase;
5105
5951
  if (e2eUserKey && e2eKeyId) {
5106
5952
  const rootKeyPath = join(agentApproveDir, "e2e-root-key");
5107
5953
  writeFileSync(rootKeyPath, e2eUserKey, { mode: 384 });
@@ -5152,9 +5998,15 @@ Installs hooks and extensions for Cursor, Claude, Gemini, Pi, OpenCode, OpenClaw
5152
5998
  if (hookDownloadPlan.files.length > 0) {
5153
5999
  const downloadSpinner = _2();
5154
6000
  downloadSpinner.start("Downloading hook scripts");
5155
- const downloadResult = await copyHookScripts(hooksDir, token, hookDownloadPlan.files);
6001
+ const downloadResult = await copyHookScripts2(hooksDir, token, hookDownloadPlan.files, { apiUrl });
5156
6002
  const summary = formatHookDownloadSummary(hookDownloadPlan);
5157
- if (downloadResult.failed.length > 0) {
6003
+ if (downloadResult.terminalFailure) {
6004
+ downloadSpinner.stop(`Downloaded ${downloadResult.downloaded} of ${hookDownloadPlan.files.length} hook files (${summary})`);
6005
+ const message = describeDownloadTerminalFailure(downloadResult.terminalFailure.kind);
6006
+ v2.error(message);
6007
+ he("Hook download stopped before completion.");
6008
+ process.exit(1);
6009
+ } else if (downloadResult.failed.length > 0) {
5158
6010
  downloadSpinner.stop(`Downloaded ${downloadResult.downloaded} of ${hookDownloadPlan.files.length} hook files (${summary})`);
5159
6011
  v2.warn(`Failed to download: ${downloadResult.failed.join(", ")}`);
5160
6012
  } else {
@@ -5217,6 +6069,9 @@ AGENTAPPROVE_E2E_MODE=${installMode}
5217
6069
  if (agentId === "pi") {
5218
6070
  filesToModify.push("Pi package registry (via `pi install`)");
5219
6071
  }
6072
+ if (agentId === "hermes") {
6073
+ filesToModify.push(`Hermes plugin directory (download + verify ${HERMES_BUNDLE_FILENAME} → extract to ~/.hermes/plugins/agentapprove/ → \`hermes plugins enable agentapprove\`)`);
6074
+ }
5220
6075
  if (agentId === "vscode-agent") {
5221
6076
  const vsCodeVariants = findInstalledVSCodeVariants();
5222
6077
  for (const { path: settingsPath, variant } of vsCodeVariants) {
@@ -5238,7 +6093,7 @@ Backups will be created with timestamp`, "Files to be modified");
5238
6093
  for (const agentId of selectedAgents) {
5239
6094
  const agent = AGENTS[agentId];
5240
6095
  const spinner = _2();
5241
- const spinnerMsg = agentId === "pi" ? `Installing ${agent.name} extension` : agentId === "openclaw" ? `Installing ${agent.name} plugin` : `Configuring ${agent.name}`;
6096
+ const spinnerMsg = agentId === "pi" ? `Installing ${agent.name} extension` : agentId === "openclaw" || agentId === "hermes" ? `Installing ${agent.name} plugin` : `Configuring ${agent.name}`;
5242
6097
  spinner.start(spinnerMsg);
5243
6098
  const result = await installHooksForAgent(agentId, hooksDir, installMode);
5244
6099
  if (result.success) {
@@ -5279,6 +6134,17 @@ Backups will be created with timestamp`, "Files to be modified");
5279
6134
  v2.warn(`Could not install ${PI_PLUGIN_SPEC} via Pi.
5280
6135
  ` + ` Error: ${result.error || "unknown"}
5281
6136
  ` + ` Install manually: pi install ${PI_PLUGIN_SPEC}
6137
+ ` + ` Then re-run: npx agentapprove`);
6138
+ } else if (agentId === "hermes") {
6139
+ v2.warn(`Could not install Agent Approve plugin for Hermes.
6140
+ ` + ` Error: ${result.error || "unknown"}
6141
+ ` + ` Install manually:
6142
+ ` + ` 1. Download ${HERMES_BUNDLE_FILENAME} from your Agent Approve API
6143
+ ` + ` 2. Verify SHA-256 matches ${HERMES_BUNDLE_SHA256.slice(0, 16)}…
6144
+ ` + ` 3. Extract into ~/.hermes/plugins/agentapprove/
6145
+ ` + ` 4. Write the SHA-256 to ~/.hermes/plugins/agentapprove/.bundle-hash
6146
+ ` + ` 5. pip install --user cryptography httpx
6147
+ ` + ` 6. hermes plugins enable agentapprove
5282
6148
  ` + ` Then re-run: npx agentapprove`);
5283
6149
  }
5284
6150
  }
@@ -5354,6 +6220,22 @@ async function statusCommand() {
5354
6220
  } catch {}
5355
6221
  continue;
5356
6222
  }
6223
+ if (agentId === "hermes") {
6224
+ try {
6225
+ const listOutput = execSync("hermes plugins list", {
6226
+ encoding: "utf-8",
6227
+ stdio: ["pipe", "pipe", "ignore"],
6228
+ timeout: PI_STATUS_TIMEOUT_MS
6229
+ });
6230
+ const enabled = /^.*agentapprove.*enabled/im.test(listOutput);
6231
+ if (enabled) {
6232
+ console.log(` ${source_default.green("✓")} ${agent.name}: Agent Approve plugin`);
6233
+ } else if (listOutput.includes("agentapprove")) {
6234
+ console.log(` ${source_default.yellow("⚠")} ${agent.name}: Agent Approve plugin installed but disabled (run: hermes plugins enable agentapprove)`);
6235
+ }
6236
+ } catch {}
6237
+ continue;
6238
+ }
5357
6239
  if (existsSync2(agent.configPath)) {
5358
6240
  const config = readJsonConfig(agent.configPath);
5359
6241
  if (agentId === "opencode") {
@@ -5370,7 +6252,7 @@ async function statusCommand() {
5370
6252
  }
5371
6253
  continue;
5372
6254
  }
5373
- const hooksConfig = config[agent.hooksKey];
6255
+ const hooksConfig = agent.hooksKey === "" ? config : config[agent.hooksKey];
5374
6256
  if (hooksConfig) {
5375
6257
  const installedHooks = agent.hooks.filter((h2) => {
5376
6258
  const hookEntry = hooksConfig[h2.name];
@@ -5450,10 +6332,19 @@ async function performUninstall(mode) {
5450
6332
  }
5451
6333
  continue;
5452
6334
  }
6335
+ if (agentId === "hermes") {
6336
+ const result = removeHermesPlugin();
6337
+ if (result.success) {
6338
+ console.log(` ${source_default.green("✓")} Hermes plugin disabled and removed`);
6339
+ } else {
6340
+ console.log(` ${source_default.yellow("⚠")} Hermes plugin removal partial (${result.error || "unknown error"})`);
6341
+ }
6342
+ continue;
6343
+ }
5453
6344
  if (!existsSync2(agent.configPath))
5454
6345
  continue;
5455
6346
  const config = readJsonConfig(agent.configPath);
5456
- const hooksConfig = config[agent.hooksKey] ?? {};
6347
+ const hooksConfig = agent.hooksKey === "" ? config : config[agent.hooksKey] ?? {};
5457
6348
  let modified = false;
5458
6349
  if (agentId === "opencode") {
5459
6350
  const pluginArray = config.plugin;
@@ -5697,10 +6588,15 @@ async function refreshCommand() {
5697
6588
  v2.error('No existing configuration found. Run "npx agentapprove install" first.');
5698
6589
  process.exit(1);
5699
6590
  }
6591
+ const pairingApiBase = resolvePairingApiBaseUrl({
6592
+ agentapproveApiEnv: process.env.AGENTAPPROVE_API,
6593
+ savedApiUrl: existingConfig?.apiUrl,
6594
+ processDefaultApiUrl: API_URL
6595
+ });
5700
6596
  me(`Your API token has expired (30 days). Tokens extend automatically on use,
5701
6597
  but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
5702
6598
  let token = null;
5703
- let apiUrl = API_URL;
6599
+ let apiUrl = pairingApiBase;
5704
6600
  const installedAgents = detectInstalledAgents();
5705
6601
  const e2eEnabled = existingConfig.e2eEnabled !== false;
5706
6602
  const e2eKeyPath = join(getAgentApproveDir(), "e2e-key");
@@ -5727,7 +6623,7 @@ but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
5727
6623
  configSetAt,
5728
6624
  e2eEnabled
5729
6625
  };
5730
- const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId);
6626
+ const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId, pairingApiBase);
5731
6627
  if (!session || session.error) {
5732
6628
  v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
5733
6629
  process.exit(1);
@@ -5753,13 +6649,14 @@ but if unused for 30 days they expire. Get a new one below.`, "Token Expired");
5753
6649
  const minutes = Math.floor(expiresIn / 60);
5754
6650
  const seconds = expiresIn % 60;
5755
6651
  pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
5756
- }, () => {});
6652
+ }, () => {}, pairingApiBase);
5757
6653
  if (result === "cancelled") {
5758
6654
  pairingSpinner.stop("Cancelled");
5759
6655
  he("Refresh cancelled");
5760
6656
  process.exit(0);
5761
6657
  } else if (result) {
5762
6658
  token = result.token;
6659
+ apiUrl = result.apiUrl || pairingApiBase;
5763
6660
  pairingSpinner.stop(`Connected! ${result.email ? `Account: ${result.email}` : ""}`);
5764
6661
  } else {
5765
6662
  pairingSpinner.stop("Session expired");
@@ -5797,6 +6694,11 @@ async function pairCommand() {
5797
6694
  console.clear();
5798
6695
  pe(source_default.bgCyan.black(" Agent Approve ") + source_default.gray(" Device Pairing"));
5799
6696
  const existingConfig = readExistingConfig();
6697
+ const pairingApiBase = resolvePairingApiBaseUrl({
6698
+ agentapproveApiEnv: process.env.AGENTAPPROVE_API,
6699
+ savedApiUrl: existingConfig?.apiUrl,
6700
+ processDefaultApiUrl: API_URL
6701
+ });
5800
6702
  const keys = discoverE2EKeys();
5801
6703
  if (keys.length === 0 && !existingConfig) {
5802
6704
  v2.error("No E2E keys or configuration found.");
@@ -5887,7 +6789,7 @@ async function pairCommand() {
5887
6789
  configSetAt: pairingConfigSetAt,
5888
6790
  e2eEnabled: !!e2eUserKey
5889
6791
  };
5890
- const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId);
6792
+ const session = await createPairingSession(installedAgents.length > 0 ? installedAgents : undefined, e2eKeyId, pairingApiBase);
5891
6793
  if (!session || session.error) {
5892
6794
  v2.error(`Failed to create pairing session: ${session?.error || "Unknown error"}`);
5893
6795
  process.exit(1);
@@ -5913,7 +6815,7 @@ async function pairCommand() {
5913
6815
  const minutes = Math.floor(expiresIn / 60);
5914
6816
  const seconds = expiresIn % 60;
5915
6817
  pairingSpinner.message(`Waiting for iOS app... ${minutes}:${seconds.toString().padStart(2, "0")}`);
5916
- }, () => {});
6818
+ }, () => {}, pairingApiBase);
5917
6819
  if (result === "cancelled") {
5918
6820
  pairingSpinner.stop("Cancelled");
5919
6821
  he("Pairing cancelled");
@@ -5943,7 +6845,7 @@ async function pairCommand() {
5943
6845
  writeFileSync(serverKeyPath, result.e2eServerKey, { mode: 384 });
5944
6846
  }
5945
6847
  }
5946
- const apiUrl = API_URL;
6848
+ const apiUrl = result.apiUrl || pairingApiBase;
5947
6849
  const configSetAt = Math.floor(Date.now() / 1000);
5948
6850
  saveEnvConfig({
5949
6851
  apiUrl,
@@ -5992,7 +6894,9 @@ async function updateHookScriptsWithToken(token, apiUrl) {
5992
6894
  "cursor-thought.sh",
5993
6895
  "cursor-response.sh",
5994
6896
  "gemini-approval.sh",
5995
- "gemini-complete.sh"
6897
+ "gemini-complete.sh",
6898
+ "windsurf-hook.sh",
6899
+ "openhands-hook.sh"
5996
6900
  ];
5997
6901
  for (const file of hookFiles) {
5998
6902
  const filePath = join(hooksDir, file);