@termhub/agent 0.4.1 → 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 +185 -24
  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}"
@@ -626,8 +635,9 @@ SESSION=$(tmux display-message -p -t "$TMUX_PANE" '#{session_name}' 2>/dev/null)
626
635
  [ -n "$SESSION" ] || exit 0
627
636
  if [ "$TOOL" = codex ]; then EVENT="$2"; else EVENT=$(cat 2>/dev/null); fi
628
637
  [ -n "$EVENT" ] || EVENT='{}'
629
- # Tool calls: only the tool's name travels (never its input), and only when it changed since the
630
- # last one for this session \u2014 twenty edits in a row are one request. The marker is per tmux
638
+ # Tool calls: only the tool's name travels (never its input), with the spinner's verb when one is on
639
+ # screen, and only when the pair changed since the last one for this session \u2014 twenty edits in a row
640
+ # are one request as long as the verb stays the same (a new verb mid-run is a new request). The marker is per tmux
631
641
  # session, under TMPDIR, with the session name reduced to filename-safe characters.
632
642
  MARK="\${TMPDIR:-/tmp}/termhub-hook-$(printf '%s' "$SESSION" | tr -c 'A-Za-z0-9_-' '_')"
633
643
  case "$EVENT" in
@@ -643,9 +653,30 @@ case "$EVENT" in
643
653
  REST=\${REST#*'"'}
644
654
  NAME=\${REST%%'"'*}
645
655
  case "$NAME" in '' | *[!A-Za-z0-9_.-]*) exit 0 ;; esac
646
- [ "$(cat "$MARK" 2>/dev/null)" = "$NAME" ] && exit 0
647
- printf '%s' "$NAME" 2>/dev/null > "$MARK"
648
- EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
656
+ # Claude Code's spinner verb ("\u273B Moonwalking\u2026 (12s \xB7 esc to interrupt)"): the visible pane is
657
+ # read here, on the machine, and only the verb may leave it \u2014 one word of 2 to 24 ASCII letters
658
+ # right after a spinner glyph at column 0 and a single space, immediately followed by "\u2026" or
659
+ # "...", then the end of the line or a space. Column 0 because Claude Code draws its spinner
660
+ # there, while a draft in the input box or indented tool output can look just like one. The
661
+ # lowest such line of the last 24 non-blank rows wins (the live spinner sits above the todo list
662
+ # and the input box; blank rows under a short session are skipped). Both grep and sed run under
663
+ # LC_ALL=C: bytes the locale calls invalid then neither trip grep's "binary file matches" (which
664
+ # would also swallow the rest of the screen) nor sed's "illegal byte sequence" on macOS, and
665
+ # the match works the same on GNU, BSD and busybox. ASCII only on purpose: a customised verb
666
+ # with accents is dropped rather than half-matched. The case below checks the result again, so
667
+ # the hand-built JSON only ever gets letters.
668
+ VERB=$(tmux capture-pane -p -t "$TMUX_PANE" 2>/dev/null | LC_ALL=C grep -v '^[[:space:]]*$' 2>/dev/null | tail -n 24 |
669
+ LC_ALL=C sed -n -E 's/^(\xB7|\u2722|\u2733|\u2736|\u273B|\u273D|\\*) ([A-Za-z]{2,24})(\u2026|\\.\\.\\.)( .*)?$/\\2/p' | tail -n 1)
670
+ case "$VERB" in *[!A-Za-z]*) VERB= ;; esac
671
+ [ "\${#VERB}" -le 24 ] || VERB=
672
+ KEY="$NAME\${VERB:+ $VERB}"
673
+ [ "$(cat "$MARK" 2>/dev/null)" = "$KEY" ] && exit 0
674
+ printf '%s' "$KEY" 2>/dev/null > "$MARK"
675
+ if [ -n "$VERB" ]; then
676
+ EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s","verb":"%s"}' "$NAME" "$VERB")
677
+ else
678
+ EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
679
+ fi
649
680
  ;;
650
681
  # A new turn starts fresh, and so does an answered notification: a permission prompt takes the tab
651
682
  # out of working, and the tool the person approves is the same one that set the marker, so without
@@ -664,26 +695,35 @@ function hookEnvFile(hooksUrl, token) {
664
695
  TERMHUB_HOOK_TOKEN=${shellQuote(token)}
665
696
  `;
666
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
+ };
667
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));
668
- function mergeClaudeSettings(current, scriptPath) {
705
+ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.json") {
669
706
  let settings = {};
670
707
  if (current.trim()) {
671
708
  const parsed = JSON.parse(current);
672
709
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
673
- throw new Error("~/.claude/settings.json n\xE3o \xE9 um objeto JSON");
710
+ throw new Error(`${shown} n\xE3o \xE9 um objeto JSON`);
674
711
  settings = parsed;
675
712
  }
676
- 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 ?? {};
677
717
  for (const event of CLAUDE_HOOK_EVENTS) {
678
- const list3 = Array.isArray(hooks[event]) ? hooks[event] : [];
718
+ const list3 = Array.isArray(next[event]) ? next[event] : [];
679
719
  const others = list3.filter((e) => !isOurs(e));
680
720
  const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
681
721
  if (event === "PreToolUse")
682
722
  entry.matcher = "*";
683
723
  others.push(entry);
684
- hooks[event] = others;
724
+ next[event] = others;
685
725
  }
686
- settings.hooks = hooks;
726
+ settings.hooks = next;
687
727
  return `${JSON.stringify(settings, null, 2)}
688
728
  `;
689
729
  }
@@ -728,6 +768,50 @@ function stripCodexConfig(current) {
728
768
  const lines = current.split("\n").filter((l) => !(/^\s*notify\s*=/.test(l) && l.includes(HOOK_MARK)));
729
769
  return lines.join("\n");
730
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
+ }
731
815
 
732
816
  // ../../packages/machine-ops/dist/discover.js
733
817
  var CLAUDE_MARKERS = ["settings.json", "projects", ".credentials.json"];
@@ -875,6 +959,8 @@ function sh(script, opts = {}) {
875
959
  // src/rpc/hooks.ts
876
960
  var CODEX_DIR_REL = ".codex";
877
961
  var CODEX_CONFIG_REL = ".codex/config.toml";
962
+ var CURSOR_DIR_REL = ".cursor";
963
+ var CURSOR_HOOKS_REL = ".cursor/hooks.json";
878
964
  var isEnoent = (err) => err?.code === "ENOENT";
879
965
  async function readOrEmpty2(file) {
880
966
  try {
@@ -912,6 +998,15 @@ async function claudeTargets(dirs, home) {
912
998
  }
913
999
  return out;
914
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
+ }
915
1010
  async function install(params, home = os3.homedir()) {
916
1011
  const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
917
1012
  const codexFile = path3.join(home, CODEX_CONFIG_REL);
@@ -919,7 +1014,7 @@ async function install(params, home = os3.homedir()) {
919
1014
  const merged = [];
920
1015
  for (const target of targets) {
921
1016
  try {
922
- merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath) });
1017
+ merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath, target.shown) });
923
1018
  } catch (err) {
924
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);
925
1020
  throw new RpcFailure("failed", message2, relPath(target.shown));
@@ -927,6 +1022,7 @@ async function install(params, home = os3.homedir()) {
927
1022
  }
928
1023
  const hasCodex = await isDir2(path3.join(home, CODEX_DIR_REL));
929
1024
  const mergedCodex = hasCodex ? mergeCodexConfig(await readOrEmpty2(codexFile), scriptPath) : null;
1025
+ const mergedCursor = await mergedCursorHooks(home, scriptPath);
930
1026
  let current = `~/${HOOK_SCRIPT_REL}`;
931
1027
  try {
932
1028
  await mkdir(path3.dirname(scriptPath), { recursive: true });
@@ -943,6 +1039,10 @@ async function install(params, home = os3.homedir()) {
943
1039
  current = `~/${CODEX_CONFIG_REL}`;
944
1040
  await writeAtomic(codexFile, mergedCodex, 420);
945
1041
  }
1042
+ if (mergedCursor !== null) {
1043
+ current = `~/${CURSOR_HOOKS_REL}`;
1044
+ await writeAtomic(path3.join(home, CURSOR_HOOKS_REL), mergedCursor, 420);
1045
+ }
946
1046
  } catch (err) {
947
1047
  throw fsFailure(err, current);
948
1048
  }
@@ -950,6 +1050,7 @@ async function install(params, home = os3.homedir()) {
950
1050
  home,
951
1051
  claude: "installed",
952
1052
  codex: mergedCodex !== null ? "installed" : "skipped",
1053
+ cursor: mergedCursor !== null ? "installed" : "skipped",
953
1054
  claude_dirs: merged.map(({ target }) => target.shown.replace(/\/settings\.json$/, ""))
954
1055
  };
955
1056
  }
@@ -959,22 +1060,71 @@ async function heal(home = os3.homedir()) {
959
1060
  const script = await readOrEmpty2(scriptPath);
960
1061
  if (!env.trim() || !script) return [];
961
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) {
962
1083
  const healed = [];
963
1084
  for (const dir of await discoverClaudeDirs(home)) {
964
1085
  const file = path3.join(expandHome(dir, home), "settings.json");
965
- const current = await readOrEmpty2(file);
966
- let body;
967
1086
  try {
968
- body = mergeClaudeSettings(current, scriptPath);
969
- } 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);
970
1093
  continue;
971
1094
  }
972
- if (body === current) continue;
973
- await writeAtomic(file, body, 420);
974
1095
  healed.push(dir);
975
1096
  }
976
1097
  return healed;
977
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
+ }
978
1128
  async function uninstall(params, home = os3.homedir()) {
979
1129
  const codexFile = path3.join(home, CODEX_CONFIG_REL);
980
1130
  const stripped = [];
@@ -987,6 +1137,13 @@ async function uninstall(params, home = os3.homedir()) {
987
1137
  }
988
1138
  }
989
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
+ }
990
1147
  let current = `~/${HOOK_SCRIPT_REL}`;
991
1148
  try {
992
1149
  await rm(path3.join(home, HOOK_SCRIPT_REL), { force: true });
@@ -1000,6 +1157,10 @@ async function uninstall(params, home = os3.homedir()) {
1000
1157
  current = `~/${CODEX_CONFIG_REL}`;
1001
1158
  await writeAtomic(codexFile, stripCodexConfig(codexConfig), 420);
1002
1159
  }
1160
+ if (strippedCursor !== null) {
1161
+ current = `~/${CURSOR_HOOKS_REL}`;
1162
+ await writeAtomic(cursorFile, strippedCursor, 420);
1163
+ }
1003
1164
  } catch (err) {
1004
1165
  throw fsFailure(err, current);
1005
1166
  }
@@ -1866,7 +2027,7 @@ async function status3() {
1866
2027
  }
1867
2028
 
1868
2029
  // src/version.ts
1869
- var AGENT_VERSION = "0.4.1";
2030
+ var AGENT_VERSION = "0.4.3";
1870
2031
 
1871
2032
  // src/rpc/update.ts
1872
2033
  var PACKAGE = "@termhub/agent";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.4.1",
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,