@termhub/agent 0.1.4 → 0.1.6

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 +128 -72
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -63,8 +63,13 @@ var RPC = {
63
63
  "ai.credential": def(z.object({ provider: aiProvider, config_dir: machinePath.nullable() }), z.object({ stdout: z.string() }), 1e4),
64
64
  "file.paste": def(z.object({ name: pasteName, data_b64: z.string().min(1).max(28 * 1024 * 1024) }), z.object({ path: z.string() }), 6e4),
65
65
  /** Monitor hooks (see @termhub/machine-ops hooks.ts): the agent writes the script, env and config entries under its own $HOME. */
66
- "hooks.install": def(z.object({ hooks_url: z.string().min(1).max(2048).regex(/^https?:\/\/[^\s'"]+$/), token: z.string().min(1).max(256).regex(/^[A-Za-z0-9_-]+$/) }), z.object({ home: z.string(), claude: z.enum(["installed", "skipped"]), codex: z.enum(["installed", "skipped"]) }), 15e3),
67
- "hooks.uninstall": def(z.object({}), z.object({ removed: z.boolean() }), 15e3)
66
+ /** `claude_dirs`: Claude config dirs besides ~/.claude (accounts with CLAUDE_CONFIG_DIR), hooked when they exist; since agent 0.1.5. */
67
+ "hooks.install": def(z.object({
68
+ hooks_url: z.string().min(1).max(2048).regex(/^https?:\/\/[^\s'"]+$/),
69
+ token: z.string().min(1).max(256).regex(/^[A-Za-z0-9_-]+$/),
70
+ claude_dirs: z.array(machinePath).max(16).optional()
71
+ }), z.object({ home: z.string(), claude: z.enum(["installed", "skipped"]), codex: z.enum(["installed", "skipped"]), claude_dirs: z.array(z.string()).optional() }), 15e3),
72
+ "hooks.uninstall": def(z.object({ claude_dirs: z.array(machinePath).max(16).optional() }), z.object({ removed: z.boolean() }), 15e3)
68
73
  };
69
74
  var RPC_METHODS = Object.keys(RPC);
70
75
  var rpcMethod = z.enum(RPC_METHODS);
@@ -534,6 +539,23 @@ function ptyEnv(base, shell) {
534
539
  var HOOK_SCRIPT_REL = ".termhub/bin/termhub-hook";
535
540
  var HOOK_ENV_REL = ".termhub/hook.env";
536
541
  var HOOK_MARK = "termhub-hook";
542
+ var CLAUDE_DEFAULT_DIR = "~/.claude";
543
+ function claudeConfigDirs(accountDirs) {
544
+ const out = [CLAUDE_DEFAULT_DIR];
545
+ for (const raw of accountDirs) {
546
+ let d = (raw ?? "").trim().replace(/\/+$/, "");
547
+ if (!d || d === "~" || /[\0\n\r]/.test(d))
548
+ continue;
549
+ if (!d.startsWith("~/") && !d.startsWith("/"))
550
+ d = `~/${d}`;
551
+ if (!out.includes(d))
552
+ out.push(d);
553
+ }
554
+ return out;
555
+ }
556
+ function expandHome(dir, home) {
557
+ return dir.startsWith("~/") ? `${home}/${dir.slice(2)}` : dir;
558
+ }
537
559
  var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Notification", "Stop", "SessionEnd"];
538
560
  var HOOK_SCRIPT = `#!/bin/sh
539
561
  # termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex hook events to
@@ -728,25 +750,61 @@ function createDispatcher(deps) {
728
750
  }
729
751
 
730
752
  // src/pty.ts
753
+ import fs3 from "fs";
754
+ import path3 from "path";
755
+
756
+ // src/pty-health.ts
731
757
  import fs2 from "fs";
732
758
  import path2 from "path";
733
- function expandHome(rawCwd, home) {
759
+ import { createRequire } from "module";
760
+ function findSpawnHelper(resolveFrom = import.meta.url) {
761
+ let pkgJson;
762
+ try {
763
+ pkgJson = createRequire(resolveFrom).resolve("node-pty/package.json");
764
+ } catch {
765
+ return null;
766
+ }
767
+ const root = path2.dirname(pkgJson);
768
+ const candidates = [
769
+ path2.join(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
770
+ path2.join(root, "build", "Release", "spawn-helper")
771
+ ];
772
+ return candidates.find((p) => fs2.existsSync(p)) ?? null;
773
+ }
774
+ var EXEC_BITS = 73;
775
+ function ensureSpawnHelperExecutable(helper = findSpawnHelper()) {
776
+ if (!helper) return { path: null, executable: true, repaired: false };
777
+ try {
778
+ const mode = fs2.statSync(helper).mode;
779
+ if (mode & EXEC_BITS) return { path: helper, executable: true, repaired: false };
780
+ fs2.chmodSync(helper, (mode | 493) & 4095);
781
+ return { path: helper, executable: true, repaired: true };
782
+ } catch (err) {
783
+ return { path: helper, executable: false, repaired: false, error: err instanceof Error ? err.message : String(err) };
784
+ }
785
+ }
786
+
787
+ // src/pty.ts
788
+ function expandHome2(rawCwd, home) {
734
789
  if (rawCwd === "~") return home;
735
- if (rawCwd.startsWith("~/")) return path2.join(home, rawCwd.slice(2));
790
+ if (rawCwd.startsWith("~/")) return path3.join(home, rawCwd.slice(2));
736
791
  return rawCwd;
737
792
  }
738
793
  function existsDir(p) {
739
794
  try {
740
- return fs2.statSync(p).isDirectory();
795
+ return fs3.statSync(p).isDirectory();
741
796
  } catch {
742
797
  return false;
743
798
  }
744
799
  }
745
800
  function resolveCwd(rawCwd) {
746
801
  const home = process.env.HOME || "/";
747
- const expanded = expandHome(rawCwd, home);
802
+ const expanded = expandHome2(rawCwd, home);
748
803
  return existsDir(expanded) ? expanded : home;
749
804
  }
805
+ function isSpawnHelperFailure(err) {
806
+ return /posix_spawnp failed/.test(err instanceof Error ? err.message : String(err));
807
+ }
750
808
  function isEnoent(err) {
751
809
  const e = err;
752
810
  if (e?.code === "ENOENT") return true;
@@ -756,6 +814,7 @@ function isEnoent(err) {
756
814
  function createPtyManager(deps) {
757
815
  const procs = /* @__PURE__ */ new Map();
758
816
  const tmux = deps.tmuxPath ?? tmuxPath();
817
+ const repairSpawnHelper = deps.repairSpawnHelper ?? (() => ensureSpawnHelperExecutable());
759
818
  let spawnFn = deps.spawn;
760
819
  async function resolveSpawn() {
761
820
  if (!spawnFn) {
@@ -782,7 +841,16 @@ function createPtyManager(deps) {
782
841
  TERMHUB_SESSION: params.session
783
842
  };
784
843
  const spawn = await resolveSpawn();
785
- proc = spawn(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env });
844
+ const spawnTmux = () => spawn(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env });
845
+ try {
846
+ proc = spawnTmux();
847
+ } catch (err) {
848
+ if (!isSpawnHelperFailure(err)) throw err;
849
+ const helper = repairSpawnHelper();
850
+ if (!helper.repaired) throw err;
851
+ deps.log("spawn-helper exec bit repaired on open", { path: helper.path });
852
+ proc = spawnTmux();
853
+ }
786
854
  } catch (err) {
787
855
  deps.log("pty open failed", { ch, session: params.session, tmux, cwd: params.cwd, error: err instanceof Error ? err.message : String(err) });
788
856
  socket.sendControl({
@@ -866,37 +934,6 @@ function createPtyManager(deps) {
866
934
  };
867
935
  }
868
936
 
869
- // src/pty-health.ts
870
- import fs3 from "fs";
871
- import path3 from "path";
872
- import { createRequire } from "module";
873
- function findSpawnHelper(resolveFrom = import.meta.url) {
874
- let pkgJson;
875
- try {
876
- pkgJson = createRequire(resolveFrom).resolve("node-pty/package.json");
877
- } catch {
878
- return null;
879
- }
880
- const root = path3.dirname(pkgJson);
881
- const candidates = [
882
- path3.join(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
883
- path3.join(root, "build", "Release", "spawn-helper")
884
- ];
885
- return candidates.find((p) => fs3.existsSync(p)) ?? null;
886
- }
887
- var EXEC_BITS = 73;
888
- function ensureSpawnHelperExecutable(helper = findSpawnHelper()) {
889
- if (!helper) return { path: null, executable: true, repaired: false };
890
- try {
891
- const mode = fs3.statSync(helper).mode;
892
- if (mode & EXEC_BITS) return { path: helper, executable: true, repaired: false };
893
- fs3.chmodSync(helper, (mode | 493) & 4095);
894
- return { path: helper, executable: true, repaired: true };
895
- } catch (err) {
896
- return { path: helper, executable: false, repaired: false, error: err instanceof Error ? err.message : String(err) };
897
- }
898
- }
899
-
900
937
  // src/rpc/ai.ts
901
938
  async function credential(params) {
902
939
  let prefix;
@@ -935,7 +972,6 @@ async function mkdir(params) {
935
972
  import { chmod, mkdir as mkdir2, readFile, rename, rm, stat, writeFile } from "fs/promises";
936
973
  import os3 from "os";
937
974
  import path4 from "path";
938
- var CLAUDE_SETTINGS_REL = ".claude/settings.json";
939
975
  var CODEX_DIR_REL = ".codex";
940
976
  var CODEX_CONFIG_REL = ".codex/config.toml";
941
977
  var isEnoent2 = (err) => err?.code === "ENOENT";
@@ -960,65 +996,85 @@ async function writeAtomic(file, body, mode) {
960
996
  await chmod(tmp, mode);
961
997
  await rename(tmp, file);
962
998
  }
963
- function fsFailure(err, rel) {
999
+ var relPath = (shown) => shown.startsWith("~/") ? shown.slice(2) : shown;
1000
+ function fsFailure(err, shown) {
964
1001
  const code = err?.code;
965
- if (code === "EACCES" || code === "EPERM") return new RpcFailure("eperm", `sem permiss\xE3o em ~/${rel}`, rel);
966
- return new RpcFailure("failed", `n\xE3o foi poss\xEDvel escrever ~/${rel}: ${err instanceof Error ? err.message : String(err)}`, rel);
1002
+ if (code === "EACCES" || code === "EPERM") return new RpcFailure("eperm", `sem permiss\xE3o em ${shown}`, relPath(shown));
1003
+ return new RpcFailure("failed", `n\xE3o foi poss\xEDvel escrever ${shown}: ${err instanceof Error ? err.message : String(err)}`, relPath(shown));
1004
+ }
1005
+ async function claudeTargets(dirs, home) {
1006
+ const out = [];
1007
+ for (const d of claudeConfigDirs(dirs ?? [])) {
1008
+ const dir = expandHome(d, home);
1009
+ if (d !== CLAUDE_DEFAULT_DIR && !await isDir(dir)) continue;
1010
+ out.push({ dir, file: path4.join(dir, "settings.json"), shown: `${d}/settings.json` });
1011
+ }
1012
+ return out;
967
1013
  }
968
1014
  async function install(params, home = os3.homedir()) {
969
1015
  const scriptPath = path4.join(home, HOOK_SCRIPT_REL);
970
- const claudeFile = path4.join(home, CLAUDE_SETTINGS_REL);
971
1016
  const codexFile = path4.join(home, CODEX_CONFIG_REL);
972
- const claudeSettings = await readOrEmpty(claudeFile);
973
- let mergedClaude;
974
- try {
975
- mergedClaude = mergeClaudeSettings(claudeSettings, scriptPath);
976
- } catch (err) {
977
- throw new RpcFailure("failed", err instanceof SyntaxError ? "~/.claude/settings.json n\xE3o \xE9 JSON v\xE1lido" : err instanceof Error ? err.message : String(err), CLAUDE_SETTINGS_REL);
1017
+ const targets = await claudeTargets(params.claude_dirs, home);
1018
+ const merged = [];
1019
+ for (const target of targets) {
1020
+ try {
1021
+ merged.push({ target, body: mergeClaudeSettings(await readOrEmpty(target.file), scriptPath) });
1022
+ } catch (err) {
1023
+ const message = 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);
1024
+ throw new RpcFailure("failed", message, relPath(target.shown));
1025
+ }
978
1026
  }
979
1027
  const hasCodex = await isDir(path4.join(home, CODEX_DIR_REL));
980
1028
  const mergedCodex = hasCodex ? mergeCodexConfig(await readOrEmpty(codexFile), scriptPath) : null;
981
- let current = HOOK_SCRIPT_REL;
1029
+ let current = `~/${HOOK_SCRIPT_REL}`;
982
1030
  try {
983
1031
  await mkdir2(path4.dirname(scriptPath), { recursive: true });
984
- await mkdir2(path4.dirname(claudeFile), { recursive: true });
985
- current = HOOK_ENV_REL;
1032
+ current = `~/${HOOK_ENV_REL}`;
986
1033
  await writeAtomic(path4.join(home, HOOK_ENV_REL), hookEnvFile(params.hooks_url, params.token), 384);
987
- current = HOOK_SCRIPT_REL;
1034
+ current = `~/${HOOK_SCRIPT_REL}`;
988
1035
  await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
989
- current = CLAUDE_SETTINGS_REL;
990
- await writeAtomic(claudeFile, mergedClaude, 420);
1036
+ for (const { target, body } of merged) {
1037
+ current = target.shown;
1038
+ await mkdir2(target.dir, { recursive: true });
1039
+ await writeAtomic(target.file, body, 420);
1040
+ }
991
1041
  if (mergedCodex !== null) {
992
- current = CODEX_CONFIG_REL;
1042
+ current = `~/${CODEX_CONFIG_REL}`;
993
1043
  await writeAtomic(codexFile, mergedCodex, 420);
994
1044
  }
995
1045
  } catch (err) {
996
1046
  throw fsFailure(err, current);
997
1047
  }
998
- return { home, claude: "installed", codex: mergedCodex !== null ? "installed" : "skipped" };
1048
+ return {
1049
+ home,
1050
+ claude: "installed",
1051
+ codex: mergedCodex !== null ? "installed" : "skipped",
1052
+ claude_dirs: merged.map(({ target }) => target.shown.replace(/\/settings\.json$/, ""))
1053
+ };
999
1054
  }
1000
- async function uninstall(_params, home = os3.homedir()) {
1001
- const claudeFile = path4.join(home, CLAUDE_SETTINGS_REL);
1055
+ async function uninstall(params, home = os3.homedir()) {
1002
1056
  const codexFile = path4.join(home, CODEX_CONFIG_REL);
1003
- const claudeSettings = await readOrEmpty(claudeFile);
1004
- let stripped = null;
1005
- try {
1006
- stripped = claudeSettings.trim() ? stripClaudeSettings(claudeSettings) : null;
1007
- } catch {
1008
- stripped = null;
1057
+ const stripped = [];
1058
+ for (const target of await claudeTargets(params.claude_dirs, home)) {
1059
+ const current2 = await readOrEmpty(target.file);
1060
+ try {
1061
+ const body = current2.trim() ? stripClaudeSettings(current2) : current2;
1062
+ if (body !== current2) stripped.push({ target, body });
1063
+ } catch {
1064
+ }
1009
1065
  }
1010
1066
  const codexConfig = await isDir(path4.join(home, CODEX_DIR_REL)) ? await readOrEmpty(codexFile) : "";
1011
- let current = HOOK_SCRIPT_REL;
1067
+ let current = `~/${HOOK_SCRIPT_REL}`;
1012
1068
  try {
1013
1069
  await rm(path4.join(home, HOOK_SCRIPT_REL), { force: true });
1014
- current = HOOK_ENV_REL;
1070
+ current = `~/${HOOK_ENV_REL}`;
1015
1071
  await rm(path4.join(home, HOOK_ENV_REL), { force: true });
1016
- if (stripped !== null && stripped !== claudeSettings) {
1017
- current = CLAUDE_SETTINGS_REL;
1018
- await writeAtomic(claudeFile, stripped, 420);
1072
+ for (const { target, body } of stripped) {
1073
+ current = target.shown;
1074
+ await writeAtomic(target.file, body, 420);
1019
1075
  }
1020
1076
  if (codexConfig.includes(HOOK_MARK)) {
1021
- current = CODEX_CONFIG_REL;
1077
+ current = `~/${CODEX_CONFIG_REL}`;
1022
1078
  await writeAtomic(codexFile, stripCodexConfig(codexConfig), 420);
1023
1079
  }
1024
1080
  } catch (err) {
@@ -1186,7 +1242,7 @@ async function status(deps = {}) {
1186
1242
  }
1187
1243
 
1188
1244
  // src/version.ts
1189
- var AGENT_VERSION = "0.1.4";
1245
+ var AGENT_VERSION = "0.1.6";
1190
1246
 
1191
1247
  // src/run.ts
1192
1248
  function detectOs(platform = process.platform) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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,