@termhub/agent 0.4.2 → 0.4.3

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 +158 -19
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -76,11 +76,18 @@ var RPC = {
76
76
  "file.paste": def(z.object({ name: pasteName, data_b64: z.string().min(1).max(28 * 1024 * 1024) }), z.object({ path: z.string() }), 6e4),
77
77
  /** Monitor hooks (see @termhub/machine-ops hooks.ts): the agent writes the script, env and config entries under its own $HOME. */
78
78
  /** `claude_dirs`: Claude config dirs besides ~/.claude (accounts with CLAUDE_CONFIG_DIR), hooked when they exist; since agent 0.1.5. */
79
+ /** `cursor` in the result: ~/.cursor/hooks.json (Cursor CLI), written when ~/.cursor exists; since agent 0.4.3 (older agents leave it out). */
79
80
  "hooks.install": def(z.object({
80
81
  hooks_url: z.string().min(1).max(2048).regex(/^https?:\/\/[^\s'"]+$/),
81
82
  token: z.string().min(1).max(256).regex(/^[A-Za-z0-9_-]+$/),
82
83
  claude_dirs: z.array(machinePath).max(16).optional()
83
- }), z.object({ home: z.string(), claude: z.enum(["installed", "skipped"]), codex: z.enum(["installed", "skipped"]), claude_dirs: z.array(z.string()).optional() }), 15e3),
84
+ }), z.object({
85
+ home: z.string(),
86
+ claude: z.enum(["installed", "skipped"]),
87
+ codex: z.enum(["installed", "skipped"]),
88
+ cursor: z.enum(["installed", "skipped"]).optional(),
89
+ claude_dirs: z.array(z.string()).optional()
90
+ }), 15e3),
84
91
  "hooks.uninstall": def(z.object({ claude_dirs: z.array(machinePath).max(16).optional() }), z.object({ removed: z.boolean() }), 15e3),
85
92
  /** Installs `version` of @termhub/agent with npm; when the agent runs as a service it then exits so the service relaunches the new code (since agent 0.2.1). */
86
93
  "agent.update": def(z.object({ version: z.string().regex(/^\d+\.\d+\.\d+$/) }), z.object({ installed_version: z.string(), restart: z.enum(["service", "manual"]) }), 18e4)
@@ -420,7 +427,7 @@ function shellQuote(s) {
420
427
  var REMOTE_PATH_PREFIX = 'export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ';
421
428
 
422
429
  // ../../packages/machine-ops/dist/detect.js
423
- var DETECT_TOOLS = ["tmux", "claude", "codex", "gh", "git", "node", "pnpm", "xcodebuild", "docker", "adb", "python3"];
430
+ var DETECT_TOOLS = ["tmux", "claude", "codex", "cursor-agent", "gh", "git", "node", "pnpm", "xcodebuild", "docker", "adb", "python3"];
424
431
  var WDA_RUNNER_APP = "$HOME/.termhub/WebDriverAgent/DerivedData/Build/Products/Debug-iphonesimulator/WebDriverAgentRunner-Runner.app";
425
432
  var DETECT_SCRIPT = `echo OS:$(uname -s); for t in ${DETECT_TOOLS.join(" ")}; do command -v $t >/dev/null 2>&1 && echo CAP:$t; done; [ -d "${WDA_RUNNER_APP}" ] && echo CAP:wda; exit 0`;
426
433
  function parseDetect(stdout) {
@@ -612,10 +619,12 @@ function expandHome(dir, home) {
612
619
  return dir.startsWith("~/") ? `${home}/${dir.slice(2)}` : dir;
613
620
  }
614
621
  var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"];
622
+ var CURSOR_HOOK_EVENTS = ["sessionStart", "beforeSubmitPrompt", "afterAgentResponse", "stop", "sessionEnd"];
615
623
  var HOOK_SCRIPT = `#!/bin/sh
616
- # termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex hook events to
617
- # termhub tagged with the tmux session, so the app knows which tab is waiting for you.
618
- # Safe to delete (also remove the entries in ~/.claude/settings.json and ~/.codex/config.toml).
624
+ # termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex / Cursor CLI hook
625
+ # events to termhub tagged with the tmux session, so the app knows which tab is waiting for you.
626
+ # Safe to delete (also remove the entries in ~/.claude/settings.json, ~/.codex/config.toml and
627
+ # ~/.cursor/hooks.json).
619
628
  TOOL="\${1:-claude}"
620
629
  [ -f "$HOME/${HOOK_ENV_REL}" ] || exit 0
621
630
  . "$HOME/${HOOK_ENV_REL}"
@@ -686,26 +695,35 @@ function hookEnvFile(hooksUrl, token) {
686
695
  TERMHUB_HOOK_TOKEN=${shellQuote(token)}
687
696
  `;
688
697
  }
698
+ var asObject = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
699
+ var asHooksRecord = (v) => {
700
+ if (v == null || Array.isArray(v) && v.length === 0)
701
+ return {};
702
+ return asObject(v);
703
+ };
689
704
  var isOurs = (e) => !!e && typeof e === "object" && Array.isArray(e.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes(HOOK_MARK));
690
- function mergeClaudeSettings(current, scriptPath) {
705
+ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.json") {
691
706
  let settings = {};
692
707
  if (current.trim()) {
693
708
  const parsed = JSON.parse(current);
694
709
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
695
- throw new Error("~/.claude/settings.json n\xE3o \xE9 um objeto JSON");
710
+ throw new Error(`${shown} n\xE3o \xE9 um objeto JSON`);
696
711
  settings = parsed;
697
712
  }
698
- const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
713
+ const hooks = asHooksRecord(settings.hooks);
714
+ if (settings.hooks != null && hooks === null)
715
+ throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
716
+ const next = hooks ?? {};
699
717
  for (const event of CLAUDE_HOOK_EVENTS) {
700
- const list3 = Array.isArray(hooks[event]) ? hooks[event] : [];
718
+ const list3 = Array.isArray(next[event]) ? next[event] : [];
701
719
  const others = list3.filter((e) => !isOurs(e));
702
720
  const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
703
721
  if (event === "PreToolUse")
704
722
  entry.matcher = "*";
705
723
  others.push(entry);
706
- hooks[event] = others;
724
+ next[event] = others;
707
725
  }
708
- settings.hooks = hooks;
726
+ settings.hooks = next;
709
727
  return `${JSON.stringify(settings, null, 2)}
710
728
  `;
711
729
  }
@@ -750,6 +768,50 @@ function stripCodexConfig(current) {
750
768
  const lines = current.split("\n").filter((l) => !(/^\s*notify\s*=/.test(l) && l.includes(HOOK_MARK)));
751
769
  return lines.join("\n");
752
770
  }
771
+ var isOurCursorEntry = (e) => !!e && typeof e === "object" && typeof e.command === "string" && e.command.includes(HOOK_MARK);
772
+ function mergeCursorHooks(current, scriptPath, shown = "~/.cursor/hooks.json") {
773
+ let file = {};
774
+ if (current.trim()) {
775
+ const parsed = asObject(JSON.parse(current));
776
+ if (!parsed)
777
+ throw new Error(`${shown} n\xE3o \xE9 um objeto JSON`);
778
+ file = parsed;
779
+ }
780
+ const hooks = asHooksRecord(file.hooks);
781
+ if (file.hooks != null && hooks === null)
782
+ throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
783
+ const next = hooks ?? {};
784
+ for (const event of CURSOR_HOOK_EVENTS) {
785
+ const others = (Array.isArray(next[event]) ? next[event] : []).filter((e) => !isOurCursorEntry(e));
786
+ others.push({ command: `${scriptPath} cursor` });
787
+ next[event] = others;
788
+ }
789
+ return `${JSON.stringify({ ...file, version: typeof file.version === "number" ? file.version : 1, hooks: next }, null, 2)}
790
+ `;
791
+ }
792
+ function stripCursorHooks(current) {
793
+ if (!current.trim())
794
+ return current;
795
+ const file = asObject(JSON.parse(current));
796
+ if (!file)
797
+ return current;
798
+ const hooks = asObject(file.hooks);
799
+ if (hooks) {
800
+ for (const key of Object.keys(hooks)) {
801
+ if (!Array.isArray(hooks[key]))
802
+ continue;
803
+ const kept = hooks[key].filter((e) => !isOurCursorEntry(e));
804
+ if (kept.length)
805
+ hooks[key] = kept;
806
+ else
807
+ delete hooks[key];
808
+ }
809
+ if (Object.keys(hooks).length === 0)
810
+ delete file.hooks;
811
+ }
812
+ return `${JSON.stringify(file, null, 2)}
813
+ `;
814
+ }
753
815
 
754
816
  // ../../packages/machine-ops/dist/discover.js
755
817
  var CLAUDE_MARKERS = ["settings.json", "projects", ".credentials.json"];
@@ -897,6 +959,8 @@ function sh(script, opts = {}) {
897
959
  // src/rpc/hooks.ts
898
960
  var CODEX_DIR_REL = ".codex";
899
961
  var CODEX_CONFIG_REL = ".codex/config.toml";
962
+ var CURSOR_DIR_REL = ".cursor";
963
+ var CURSOR_HOOKS_REL = ".cursor/hooks.json";
900
964
  var isEnoent = (err) => err?.code === "ENOENT";
901
965
  async function readOrEmpty2(file) {
902
966
  try {
@@ -934,6 +998,15 @@ async function claudeTargets(dirs, home) {
934
998
  }
935
999
  return out;
936
1000
  }
1001
+ async function mergedCursorHooks(home, scriptPath) {
1002
+ if (!await isDir2(path3.join(home, CURSOR_DIR_REL))) return null;
1003
+ try {
1004
+ return mergeCursorHooks(await readOrEmpty2(path3.join(home, CURSOR_HOOKS_REL)), scriptPath, `~/${CURSOR_HOOKS_REL}`);
1005
+ } catch (err) {
1006
+ const message2 = err instanceof SyntaxError || err instanceof Error && err.message.includes("n\xE3o \xE9 um objeto JSON") ? `~/${CURSOR_HOOKS_REL} n\xE3o \xE9 JSON v\xE1lido` : err instanceof Error ? err.message : String(err);
1007
+ throw new RpcFailure("failed", message2, CURSOR_HOOKS_REL);
1008
+ }
1009
+ }
937
1010
  async function install(params, home = os3.homedir()) {
938
1011
  const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
939
1012
  const codexFile = path3.join(home, CODEX_CONFIG_REL);
@@ -941,7 +1014,7 @@ async function install(params, home = os3.homedir()) {
941
1014
  const merged = [];
942
1015
  for (const target of targets) {
943
1016
  try {
944
- merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath) });
1017
+ merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath, target.shown) });
945
1018
  } catch (err) {
946
1019
  const message2 = err instanceof SyntaxError || err instanceof Error && err.message.includes("n\xE3o \xE9 um objeto JSON") ? `${target.shown} n\xE3o \xE9 JSON v\xE1lido` : err instanceof Error ? err.message : String(err);
947
1020
  throw new RpcFailure("failed", message2, relPath(target.shown));
@@ -949,6 +1022,7 @@ async function install(params, home = os3.homedir()) {
949
1022
  }
950
1023
  const hasCodex = await isDir2(path3.join(home, CODEX_DIR_REL));
951
1024
  const mergedCodex = hasCodex ? mergeCodexConfig(await readOrEmpty2(codexFile), scriptPath) : null;
1025
+ const mergedCursor = await mergedCursorHooks(home, scriptPath);
952
1026
  let current = `~/${HOOK_SCRIPT_REL}`;
953
1027
  try {
954
1028
  await mkdir(path3.dirname(scriptPath), { recursive: true });
@@ -965,6 +1039,10 @@ async function install(params, home = os3.homedir()) {
965
1039
  current = `~/${CODEX_CONFIG_REL}`;
966
1040
  await writeAtomic(codexFile, mergedCodex, 420);
967
1041
  }
1042
+ if (mergedCursor !== null) {
1043
+ current = `~/${CURSOR_HOOKS_REL}`;
1044
+ await writeAtomic(path3.join(home, CURSOR_HOOKS_REL), mergedCursor, 420);
1045
+ }
968
1046
  } catch (err) {
969
1047
  throw fsFailure(err, current);
970
1048
  }
@@ -972,6 +1050,7 @@ async function install(params, home = os3.homedir()) {
972
1050
  home,
973
1051
  claude: "installed",
974
1052
  codex: mergedCodex !== null ? "installed" : "skipped",
1053
+ cursor: mergedCursor !== null ? "installed" : "skipped",
975
1054
  claude_dirs: merged.map(({ target }) => target.shown.replace(/\/settings\.json$/, ""))
976
1055
  };
977
1056
  }
@@ -981,22 +1060,71 @@ async function heal(home = os3.homedir()) {
981
1060
  const script = await readOrEmpty2(scriptPath);
982
1061
  if (!env.trim() || !script) return [];
983
1062
  if (script !== HOOK_SCRIPT) await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
1063
+ const steps = [healClaudeDirs, healCursor, healCodex];
1064
+ const healed = [];
1065
+ for (const step of steps) {
1066
+ try {
1067
+ healed.push(...await step(home, scriptPath));
1068
+ } catch {
1069
+ }
1070
+ }
1071
+ return healed;
1072
+ }
1073
+ var healSkipLogged = /* @__PURE__ */ new Set();
1074
+ function logHealSkip(shown, err, key = shown) {
1075
+ if (healSkipLogged.has(key)) return;
1076
+ healSkipLogged.add(key);
1077
+ const code = err?.code;
1078
+ console.error(
1079
+ `[termhub-agent] monitor hooks heal skipped ${JSON.stringify({ path: shown, error: code ?? (err instanceof Error ? err.message : String(err)) })}`
1080
+ );
1081
+ }
1082
+ async function healClaudeDirs(home, scriptPath) {
984
1083
  const healed = [];
985
1084
  for (const dir of await discoverClaudeDirs(home)) {
986
1085
  const file = path3.join(expandHome(dir, home), "settings.json");
987
- const current = await readOrEmpty2(file);
988
- let body;
989
1086
  try {
990
- body = mergeClaudeSettings(current, scriptPath);
991
- } catch {
1087
+ const current = await readOrEmpty2(file);
1088
+ const body = mergeClaudeSettings(current, scriptPath, `${dir}/settings.json`);
1089
+ if (body === current) continue;
1090
+ await writeAtomic(file, body, 420);
1091
+ } catch (err) {
1092
+ logHealSkip(dir, err, file);
992
1093
  continue;
993
1094
  }
994
- if (body === current) continue;
995
- await writeAtomic(file, body, 420);
996
1095
  healed.push(dir);
997
1096
  }
998
1097
  return healed;
999
1098
  }
1099
+ async function healCursor(home, scriptPath) {
1100
+ if (!await isDir2(path3.join(home, CURSOR_DIR_REL))) return [];
1101
+ const file = path3.join(home, CURSOR_HOOKS_REL);
1102
+ const shown = `~/${CURSOR_DIR_REL}`;
1103
+ try {
1104
+ const current = await readOrEmpty2(file);
1105
+ const body = mergeCursorHooks(current, scriptPath);
1106
+ if (body === current) return [];
1107
+ await writeAtomic(file, body, 420);
1108
+ } catch (err) {
1109
+ logHealSkip(shown, err, file);
1110
+ return [];
1111
+ }
1112
+ return [shown];
1113
+ }
1114
+ async function healCodex(home, scriptPath) {
1115
+ if (!await isDir2(path3.join(home, CODEX_DIR_REL))) return [];
1116
+ const shown = `~/${CODEX_DIR_REL}`;
1117
+ try {
1118
+ const file = path3.join(home, CODEX_CONFIG_REL);
1119
+ const current = await readOrEmpty2(file);
1120
+ if (/^\s*notify\s*=/m.test(current)) return [];
1121
+ await writeAtomic(file, mergeCodexConfig(current, scriptPath), 420);
1122
+ return [shown];
1123
+ } catch (err) {
1124
+ logHealSkip(shown, err, path3.join(home, CODEX_CONFIG_REL));
1125
+ return [];
1126
+ }
1127
+ }
1000
1128
  async function uninstall(params, home = os3.homedir()) {
1001
1129
  const codexFile = path3.join(home, CODEX_CONFIG_REL);
1002
1130
  const stripped = [];
@@ -1009,6 +1137,13 @@ async function uninstall(params, home = os3.homedir()) {
1009
1137
  }
1010
1138
  }
1011
1139
  const codexConfig = await isDir2(path3.join(home, CODEX_DIR_REL)) ? await readOrEmpty2(codexFile) : "";
1140
+ const cursorFile = path3.join(home, CURSOR_HOOKS_REL);
1141
+ const cursorHooks = await readOrEmpty2(cursorFile);
1142
+ let strippedCursor = null;
1143
+ try {
1144
+ strippedCursor = cursorHooks.includes(HOOK_MARK) ? stripCursorHooks(cursorHooks) : null;
1145
+ } catch {
1146
+ }
1012
1147
  let current = `~/${HOOK_SCRIPT_REL}`;
1013
1148
  try {
1014
1149
  await rm(path3.join(home, HOOK_SCRIPT_REL), { force: true });
@@ -1022,6 +1157,10 @@ async function uninstall(params, home = os3.homedir()) {
1022
1157
  current = `~/${CODEX_CONFIG_REL}`;
1023
1158
  await writeAtomic(codexFile, stripCodexConfig(codexConfig), 420);
1024
1159
  }
1160
+ if (strippedCursor !== null) {
1161
+ current = `~/${CURSOR_HOOKS_REL}`;
1162
+ await writeAtomic(cursorFile, strippedCursor, 420);
1163
+ }
1025
1164
  } catch (err) {
1026
1165
  throw fsFailure(err, current);
1027
1166
  }
@@ -1888,7 +2027,7 @@ async function status3() {
1888
2027
  }
1889
2028
 
1890
2029
  // src/version.ts
1891
- var AGENT_VERSION = "0.4.2";
2030
+ var AGENT_VERSION = "0.4.3";
1892
2031
 
1893
2032
  // src/rpc/update.ts
1894
2033
  var PACKAGE = "@termhub/agent";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Agente do termhub: conecta esta máquina ao servidor por WebSocket de saída (sem SSH) e expõe os terminais tmux no navegador.",
5
5
  "license": "MIT",
6
6
  "private": false,