@termhub/agent 0.4.2 → 0.4.4

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 +171 -23
  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)
@@ -95,6 +102,7 @@ var channel = z2.number().int().min(1).max(4294967295);
95
102
  var rpcId = z2.string().min(1).max(64);
96
103
  var closedReason = z2.enum(["cli_missing", "run_failed", "killed", "missing_session", "cli_rejected"]);
97
104
  var CAPABILITY_CLAUDE = "claude";
105
+ var CAPABILITY_CLAUDE_SYSTEM_PROMPT = "claude.system_prompt";
98
106
  var helloMessage = z2.object({
99
107
  type: z2.literal("hello"),
100
108
  protocol: z2.number().int().min(1),
@@ -137,7 +145,10 @@ var claudeOpenParams = z2.object({
137
145
  // opening a channel to the user's own machine is exactly the trust boundary this grant
138
146
  // belongs at, so there is no separate, later place to hand it over.
139
147
  token: z2.string(),
140
- model: z2.string().nullable().optional()
148
+ model: z2.string().nullable().optional(),
149
+ // Project chats only: the server-composed focus text forwarded onto the CLI's argv (see
150
+ // `CAPABILITY_CLAUDE_SYSTEM_PROMPT`). Absent for the account-wide chat, whose argv must not change.
151
+ append_system_prompt: z2.string().max(4e3).nullable().optional()
141
152
  });
142
153
  var openPty = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("pty"), params: ptyOpenParams });
143
154
  var openClaude = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("claude"), params: claudeOpenParams });
@@ -420,7 +431,7 @@ function shellQuote(s) {
420
431
  var REMOTE_PATH_PREFIX = 'export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ';
421
432
 
422
433
  // ../../packages/machine-ops/dist/detect.js
423
- var DETECT_TOOLS = ["tmux", "claude", "codex", "gh", "git", "node", "pnpm", "xcodebuild", "docker", "adb", "python3"];
434
+ var DETECT_TOOLS = ["tmux", "claude", "codex", "cursor-agent", "gh", "git", "node", "pnpm", "xcodebuild", "docker", "adb", "python3"];
424
435
  var WDA_RUNNER_APP = "$HOME/.termhub/WebDriverAgent/DerivedData/Build/Products/Debug-iphonesimulator/WebDriverAgentRunner-Runner.app";
425
436
  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
437
  function parseDetect(stdout) {
@@ -612,10 +623,12 @@ function expandHome(dir, home) {
612
623
  return dir.startsWith("~/") ? `${home}/${dir.slice(2)}` : dir;
613
624
  }
614
625
  var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"];
626
+ var CURSOR_HOOK_EVENTS = ["sessionStart", "beforeSubmitPrompt", "afterAgentResponse", "stop", "sessionEnd"];
615
627
  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).
628
+ # termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex / Cursor CLI hook
629
+ # events to termhub tagged with the tmux session, so the app knows which tab is waiting for you.
630
+ # Safe to delete (also remove the entries in ~/.claude/settings.json, ~/.codex/config.toml and
631
+ # ~/.cursor/hooks.json).
619
632
  TOOL="\${1:-claude}"
620
633
  [ -f "$HOME/${HOOK_ENV_REL}" ] || exit 0
621
634
  . "$HOME/${HOOK_ENV_REL}"
@@ -686,26 +699,35 @@ function hookEnvFile(hooksUrl, token) {
686
699
  TERMHUB_HOOK_TOKEN=${shellQuote(token)}
687
700
  `;
688
701
  }
702
+ var asObject = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
703
+ var asHooksRecord = (v) => {
704
+ if (v == null || Array.isArray(v) && v.length === 0)
705
+ return {};
706
+ return asObject(v);
707
+ };
689
708
  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) {
709
+ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.json") {
691
710
  let settings = {};
692
711
  if (current.trim()) {
693
712
  const parsed = JSON.parse(current);
694
713
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
695
- throw new Error("~/.claude/settings.json n\xE3o \xE9 um objeto JSON");
714
+ throw new Error(`${shown} n\xE3o \xE9 um objeto JSON`);
696
715
  settings = parsed;
697
716
  }
698
- const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {};
717
+ const hooks = asHooksRecord(settings.hooks);
718
+ if (settings.hooks != null && hooks === null)
719
+ throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
720
+ const next = hooks ?? {};
699
721
  for (const event of CLAUDE_HOOK_EVENTS) {
700
- const list3 = Array.isArray(hooks[event]) ? hooks[event] : [];
722
+ const list3 = Array.isArray(next[event]) ? next[event] : [];
701
723
  const others = list3.filter((e) => !isOurs(e));
702
724
  const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
703
725
  if (event === "PreToolUse")
704
726
  entry.matcher = "*";
705
727
  others.push(entry);
706
- hooks[event] = others;
728
+ next[event] = others;
707
729
  }
708
- settings.hooks = hooks;
730
+ settings.hooks = next;
709
731
  return `${JSON.stringify(settings, null, 2)}
710
732
  `;
711
733
  }
@@ -750,6 +772,50 @@ function stripCodexConfig(current) {
750
772
  const lines = current.split("\n").filter((l) => !(/^\s*notify\s*=/.test(l) && l.includes(HOOK_MARK)));
751
773
  return lines.join("\n");
752
774
  }
775
+ var isOurCursorEntry = (e) => !!e && typeof e === "object" && typeof e.command === "string" && e.command.includes(HOOK_MARK);
776
+ function mergeCursorHooks(current, scriptPath, shown = "~/.cursor/hooks.json") {
777
+ let file = {};
778
+ if (current.trim()) {
779
+ const parsed = asObject(JSON.parse(current));
780
+ if (!parsed)
781
+ throw new Error(`${shown} n\xE3o \xE9 um objeto JSON`);
782
+ file = parsed;
783
+ }
784
+ const hooks = asHooksRecord(file.hooks);
785
+ if (file.hooks != null && hooks === null)
786
+ throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
787
+ const next = hooks ?? {};
788
+ for (const event of CURSOR_HOOK_EVENTS) {
789
+ const others = (Array.isArray(next[event]) ? next[event] : []).filter((e) => !isOurCursorEntry(e));
790
+ others.push({ command: `${scriptPath} cursor` });
791
+ next[event] = others;
792
+ }
793
+ return `${JSON.stringify({ ...file, version: typeof file.version === "number" ? file.version : 1, hooks: next }, null, 2)}
794
+ `;
795
+ }
796
+ function stripCursorHooks(current) {
797
+ if (!current.trim())
798
+ return current;
799
+ const file = asObject(JSON.parse(current));
800
+ if (!file)
801
+ return current;
802
+ const hooks = asObject(file.hooks);
803
+ if (hooks) {
804
+ for (const key of Object.keys(hooks)) {
805
+ if (!Array.isArray(hooks[key]))
806
+ continue;
807
+ const kept = hooks[key].filter((e) => !isOurCursorEntry(e));
808
+ if (kept.length)
809
+ hooks[key] = kept;
810
+ else
811
+ delete hooks[key];
812
+ }
813
+ if (Object.keys(hooks).length === 0)
814
+ delete file.hooks;
815
+ }
816
+ return `${JSON.stringify(file, null, 2)}
817
+ `;
818
+ }
753
819
 
754
820
  // ../../packages/machine-ops/dist/discover.js
755
821
  var CLAUDE_MARKERS = ["settings.json", "projects", ".credentials.json"];
@@ -897,6 +963,8 @@ function sh(script, opts = {}) {
897
963
  // src/rpc/hooks.ts
898
964
  var CODEX_DIR_REL = ".codex";
899
965
  var CODEX_CONFIG_REL = ".codex/config.toml";
966
+ var CURSOR_DIR_REL = ".cursor";
967
+ var CURSOR_HOOKS_REL = ".cursor/hooks.json";
900
968
  var isEnoent = (err) => err?.code === "ENOENT";
901
969
  async function readOrEmpty2(file) {
902
970
  try {
@@ -934,6 +1002,15 @@ async function claudeTargets(dirs, home) {
934
1002
  }
935
1003
  return out;
936
1004
  }
1005
+ async function mergedCursorHooks(home, scriptPath) {
1006
+ if (!await isDir2(path3.join(home, CURSOR_DIR_REL))) return null;
1007
+ try {
1008
+ return mergeCursorHooks(await readOrEmpty2(path3.join(home, CURSOR_HOOKS_REL)), scriptPath, `~/${CURSOR_HOOKS_REL}`);
1009
+ } catch (err) {
1010
+ 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);
1011
+ throw new RpcFailure("failed", message2, CURSOR_HOOKS_REL);
1012
+ }
1013
+ }
937
1014
  async function install(params, home = os3.homedir()) {
938
1015
  const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
939
1016
  const codexFile = path3.join(home, CODEX_CONFIG_REL);
@@ -941,7 +1018,7 @@ async function install(params, home = os3.homedir()) {
941
1018
  const merged = [];
942
1019
  for (const target of targets) {
943
1020
  try {
944
- merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath) });
1021
+ merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath, target.shown) });
945
1022
  } catch (err) {
946
1023
  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
1024
  throw new RpcFailure("failed", message2, relPath(target.shown));
@@ -949,6 +1026,7 @@ async function install(params, home = os3.homedir()) {
949
1026
  }
950
1027
  const hasCodex = await isDir2(path3.join(home, CODEX_DIR_REL));
951
1028
  const mergedCodex = hasCodex ? mergeCodexConfig(await readOrEmpty2(codexFile), scriptPath) : null;
1029
+ const mergedCursor = await mergedCursorHooks(home, scriptPath);
952
1030
  let current = `~/${HOOK_SCRIPT_REL}`;
953
1031
  try {
954
1032
  await mkdir(path3.dirname(scriptPath), { recursive: true });
@@ -965,6 +1043,10 @@ async function install(params, home = os3.homedir()) {
965
1043
  current = `~/${CODEX_CONFIG_REL}`;
966
1044
  await writeAtomic(codexFile, mergedCodex, 420);
967
1045
  }
1046
+ if (mergedCursor !== null) {
1047
+ current = `~/${CURSOR_HOOKS_REL}`;
1048
+ await writeAtomic(path3.join(home, CURSOR_HOOKS_REL), mergedCursor, 420);
1049
+ }
968
1050
  } catch (err) {
969
1051
  throw fsFailure(err, current);
970
1052
  }
@@ -972,6 +1054,7 @@ async function install(params, home = os3.homedir()) {
972
1054
  home,
973
1055
  claude: "installed",
974
1056
  codex: mergedCodex !== null ? "installed" : "skipped",
1057
+ cursor: mergedCursor !== null ? "installed" : "skipped",
975
1058
  claude_dirs: merged.map(({ target }) => target.shown.replace(/\/settings\.json$/, ""))
976
1059
  };
977
1060
  }
@@ -981,22 +1064,71 @@ async function heal(home = os3.homedir()) {
981
1064
  const script = await readOrEmpty2(scriptPath);
982
1065
  if (!env.trim() || !script) return [];
983
1066
  if (script !== HOOK_SCRIPT) await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
1067
+ const steps = [healClaudeDirs, healCursor, healCodex];
1068
+ const healed = [];
1069
+ for (const step of steps) {
1070
+ try {
1071
+ healed.push(...await step(home, scriptPath));
1072
+ } catch {
1073
+ }
1074
+ }
1075
+ return healed;
1076
+ }
1077
+ var healSkipLogged = /* @__PURE__ */ new Set();
1078
+ function logHealSkip(shown, err, key = shown) {
1079
+ if (healSkipLogged.has(key)) return;
1080
+ healSkipLogged.add(key);
1081
+ const code = err?.code;
1082
+ console.error(
1083
+ `[termhub-agent] monitor hooks heal skipped ${JSON.stringify({ path: shown, error: code ?? (err instanceof Error ? err.message : String(err)) })}`
1084
+ );
1085
+ }
1086
+ async function healClaudeDirs(home, scriptPath) {
984
1087
  const healed = [];
985
1088
  for (const dir of await discoverClaudeDirs(home)) {
986
1089
  const file = path3.join(expandHome(dir, home), "settings.json");
987
- const current = await readOrEmpty2(file);
988
- let body;
989
1090
  try {
990
- body = mergeClaudeSettings(current, scriptPath);
991
- } catch {
1091
+ const current = await readOrEmpty2(file);
1092
+ const body = mergeClaudeSettings(current, scriptPath, `${dir}/settings.json`);
1093
+ if (body === current) continue;
1094
+ await writeAtomic(file, body, 420);
1095
+ } catch (err) {
1096
+ logHealSkip(dir, err, file);
992
1097
  continue;
993
1098
  }
994
- if (body === current) continue;
995
- await writeAtomic(file, body, 420);
996
1099
  healed.push(dir);
997
1100
  }
998
1101
  return healed;
999
1102
  }
1103
+ async function healCursor(home, scriptPath) {
1104
+ if (!await isDir2(path3.join(home, CURSOR_DIR_REL))) return [];
1105
+ const file = path3.join(home, CURSOR_HOOKS_REL);
1106
+ const shown = `~/${CURSOR_DIR_REL}`;
1107
+ try {
1108
+ const current = await readOrEmpty2(file);
1109
+ const body = mergeCursorHooks(current, scriptPath);
1110
+ if (body === current) return [];
1111
+ await writeAtomic(file, body, 420);
1112
+ } catch (err) {
1113
+ logHealSkip(shown, err, file);
1114
+ return [];
1115
+ }
1116
+ return [shown];
1117
+ }
1118
+ async function healCodex(home, scriptPath) {
1119
+ if (!await isDir2(path3.join(home, CODEX_DIR_REL))) return [];
1120
+ const shown = `~/${CODEX_DIR_REL}`;
1121
+ try {
1122
+ const file = path3.join(home, CODEX_CONFIG_REL);
1123
+ const current = await readOrEmpty2(file);
1124
+ if (/^\s*notify\s*=/m.test(current)) return [];
1125
+ await writeAtomic(file, mergeCodexConfig(current, scriptPath), 420);
1126
+ return [shown];
1127
+ } catch (err) {
1128
+ logHealSkip(shown, err, path3.join(home, CODEX_CONFIG_REL));
1129
+ return [];
1130
+ }
1131
+ }
1000
1132
  async function uninstall(params, home = os3.homedir()) {
1001
1133
  const codexFile = path3.join(home, CODEX_CONFIG_REL);
1002
1134
  const stripped = [];
@@ -1009,6 +1141,13 @@ async function uninstall(params, home = os3.homedir()) {
1009
1141
  }
1010
1142
  }
1011
1143
  const codexConfig = await isDir2(path3.join(home, CODEX_DIR_REL)) ? await readOrEmpty2(codexFile) : "";
1144
+ const cursorFile = path3.join(home, CURSOR_HOOKS_REL);
1145
+ const cursorHooks = await readOrEmpty2(cursorFile);
1146
+ let strippedCursor = null;
1147
+ try {
1148
+ strippedCursor = cursorHooks.includes(HOOK_MARK) ? stripCursorHooks(cursorHooks) : null;
1149
+ } catch {
1150
+ }
1012
1151
  let current = `~/${HOOK_SCRIPT_REL}`;
1013
1152
  try {
1014
1153
  await rm(path3.join(home, HOOK_SCRIPT_REL), { force: true });
@@ -1022,6 +1161,10 @@ async function uninstall(params, home = os3.homedir()) {
1022
1161
  current = `~/${CODEX_CONFIG_REL}`;
1023
1162
  await writeAtomic(codexFile, stripCodexConfig(codexConfig), 420);
1024
1163
  }
1164
+ if (strippedCursor !== null) {
1165
+ current = `~/${CURSOR_HOOKS_REL}`;
1166
+ await writeAtomic(cursorFile, strippedCursor, 420);
1167
+ }
1025
1168
  } catch (err) {
1026
1169
  throw fsFailure(err, current);
1027
1170
  }
@@ -1052,7 +1195,11 @@ function buildClaudeArgs(spec) {
1052
1195
  "mcp__termhub__*",
1053
1196
  "--disallowed-tools",
1054
1197
  DISALLOWED_TOOLS,
1055
- ...spec.model ? ["--model", spec.model] : []
1198
+ ...spec.model ? ["--model", spec.model] : [],
1199
+ // Last, and only when set: the account-wide chat's argv stays exactly what it was. It is our own
1200
+ // server-composed text (a project's name, key and paths), never the user's prompt, which still
1201
+ // travels on stdin only.
1202
+ ...spec.append_system_prompt ? ["--append-system-prompt", spec.append_system_prompt] : []
1056
1203
  ];
1057
1204
  }
1058
1205
  function mcpConfig(url, token) {
@@ -1112,7 +1259,8 @@ function createClaudeManager(deps) {
1112
1259
  session_id: params.session_id,
1113
1260
  resume: params.resume,
1114
1261
  mcp_config_path: mcpConfigPath,
1115
- model: params.model ?? null
1262
+ model: params.model ?? null,
1263
+ append_system_prompt: params.append_system_prompt ?? null
1116
1264
  });
1117
1265
  let child;
1118
1266
  try {
@@ -1888,7 +2036,7 @@ async function status3() {
1888
2036
  }
1889
2037
 
1890
2038
  // src/version.ts
1891
- var AGENT_VERSION = "0.4.2";
2039
+ var AGENT_VERSION = "0.4.4";
1892
2040
 
1893
2041
  // src/rpc/update.ts
1894
2042
  var PACKAGE = "@termhub/agent";
@@ -1977,7 +2125,7 @@ function detectOs(platform = process.platform) {
1977
2125
  if (platform === "linux") return "linux";
1978
2126
  return null;
1979
2127
  }
1980
- var CAPABILITIES = [CAPABILITY_CLAUDE];
2128
+ var CAPABILITIES = [CAPABILITY_CLAUDE, CAPABILITY_CLAUDE_SYSTEM_PROMPT];
1981
2129
  async function buildHello(osName) {
1982
2130
  let tools = [];
1983
2131
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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,