@termhub/agent 0.4.4 → 0.5.1

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 +387 -56
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -39,12 +39,19 @@ var sessionName = z.string().min(1).max(128).regex(SESSION_RE);
39
39
  var machinePath = z.string().min(1).max(4096).refine((p) => (p === "~" || p.startsWith("~/") || p.startsWith("/")) && !/[\0\n\r]/.test(p), "invalid path");
40
40
  var pasteName = z.string().min(1).max(255).regex(/^[A-Za-z0-9._-]+$/);
41
41
  var aiProvider = z.enum(["claude", "chatgpt", "gemini", "antigravity"]);
42
+ var UDID_RE = /^[A-Fa-f0-9-]{8,64}$/;
43
+ var udid = z.string().regex(UDID_RE);
44
+ function isWdaPort(port) {
45
+ return Number.isInteger(port) && (port >= 8100 && port <= 8199 || port >= 9100 && port <= 9199);
46
+ }
47
+ var wdaPort = z.number().int().refine(isWdaPort, "port outside the WDA ranges");
42
48
  var TMUX_KEYS = ["Enter", "Escape", "C-c", "Up", "Down", "Tab", "y", "n", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
43
49
  var tmuxKey = z.enum(TMUX_KEYS);
44
50
  var TEXT_MAX_CHARS = 4e3;
45
51
  var rpcErrorSchema = z.object({
46
- /** `failed`: the operation ran on the machine and `message` says why it failed, in words meant for the user. */
47
- code: z.enum(["eperm", "notfound", "no_tmux", "timeout", "invalid", "internal", "failed"]),
52
+ /** `failed`: the operation ran on the machine and `message` says why it failed, in words meant for the user.
53
+ * `refused`: a `tcp` open found nothing listening on the port (ECONNREFUSED). */
54
+ code: z.enum(["eperm", "notfound", "no_tmux", "timeout", "invalid", "internal", "failed", "refused"]),
48
55
  message: z.string().max(2e3),
49
56
  path: z.string().max(4096).optional()
50
57
  });
@@ -90,7 +97,19 @@ var RPC = {
90
97
  }), 15e3),
91
98
  "hooks.uninstall": def(z.object({ claude_dirs: z.array(machinePath).max(16).optional() }), z.object({ removed: z.boolean() }), 15e3),
92
99
  /** 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). */
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)
100
+ "agent.update": def(z.object({ version: z.string().regex(/^\d+\.\d+\.\d+$/) }), z.object({ installed_version: z.string(), restart: z.enum(["service", "manual"]) }), 18e4),
101
+ /** iOS simulator over the agent (spec 2026-09-24): raw `xcrun simctl list devices -j`; the server parses it. */
102
+ "sim.list": def(z.object({}), z.object({ stdout: z.string() }), 15e3),
103
+ /** `xcrun simctl boot`; combined output, "already booted" included — the server decides what is a failure. */
104
+ "sim.boot": def(z.object({ udid }), z.object({ stdout: z.string() }), 6e4),
105
+ /** Starts the WDA runner in its tmux session; `started: false` when the session already existed. */
106
+ "wda.runner.start": def(z.object({ udid, wda_port: wdaPort, mjpeg_port: wdaPort }), z.object({ started: z.boolean() }), 1e4),
107
+ "wda.runner.alive": def(z.object({ udid }), z.object({ alive: z.boolean() })),
108
+ "wda.runner.tail": def(z.object({ udid, lines: z.number().int().min(1).max(200) }), z.object({ lines: z.array(z.string()) })),
109
+ /** Writes ~/.termhub/wda-setup.sh and runs it in tmux `termhub-wda-setup`; `started: false` when already running. */
110
+ "wda.setup.start": def(z.object({}), z.object({ started: z.boolean() }), 1e4),
111
+ /** Raw `STATE:/VERSION:/TAIL:` text; `parseSetupOutput` on the server reads it. */
112
+ "wda.setup.state": def(z.object({}), z.object({ stdout: z.string() }))
94
113
  };
95
114
  var RPC_METHODS = Object.keys(RPC);
96
115
  var rpcMethod = z.enum(RPC_METHODS);
@@ -100,9 +119,10 @@ var PROTOCOL_VERSION = 1;
100
119
  var CLOSE = { UNAUTHORIZED: 4401, CONFLICT: 4409, VIOLATION: 1008 };
101
120
  var channel = z2.number().int().min(1).max(4294967295);
102
121
  var rpcId = z2.string().min(1).max(64);
103
- var closedReason = z2.enum(["cli_missing", "run_failed", "killed", "missing_session", "cli_rejected"]);
122
+ var closedReason = z2.enum(["cli_missing", "run_failed", "killed", "missing_session", "cli_rejected", "reset"]);
104
123
  var CAPABILITY_CLAUDE = "claude";
105
124
  var CAPABILITY_CLAUDE_SYSTEM_PROMPT = "claude.system_prompt";
125
+ var CAPABILITY_SIM = "sim";
106
126
  var helloMessage = z2.object({
107
127
  type: z2.literal("hello"),
108
128
  protocol: z2.number().int().min(1),
@@ -150,17 +170,21 @@ var claudeOpenParams = z2.object({
150
170
  // `CAPABILITY_CLAUDE_SYSTEM_PROMPT`). Absent for the account-wide chat, whose argv must not change.
151
171
  append_system_prompt: z2.string().max(4e3).nullable().optional()
152
172
  });
173
+ var tcpOpenParams = z2.object({ port: wdaPort }).strict();
153
174
  var openPty = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("pty"), params: ptyOpenParams });
154
175
  var openClaude = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("claude"), params: claudeOpenParams });
176
+ var openTcp = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("tcp"), params: tcpOpenParams });
155
177
  var serverMessage = z2.union([
156
178
  z2.object({ type: z2.literal("rpc"), id: rpcId, method: rpcMethod, params: z2.unknown() }),
157
179
  openPty,
158
180
  openClaude,
181
+ openTcp,
159
182
  z2.object({ type: z2.literal("resize"), ch: channel, cols: z2.number().int().min(2).max(500), rows: z2.number().int().min(2).max(200) }),
160
183
  z2.object({ type: z2.literal("close"), ch: channel })
161
184
  ]);
162
185
 
163
186
  // src/client.ts
187
+ var MAX_STREAM_PAYLOAD = MAX_FRAME - HEADER_BYTES;
164
188
  var RevokedError = class extends Error {
165
189
  };
166
190
  var ProtocolMismatchError = class extends Error {
@@ -249,7 +273,14 @@ function connectOnce(opts, signal) {
249
273
  }, opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS);
250
274
  socket = {
251
275
  sendControl: (msg) => ws.send(encodeFrame(CONTROL_CHANNEL, JSON.stringify(msg))),
252
- sendStream: (ch, data) => ws.send(encodeFrame(ch, data))
276
+ sendStream: (ch, data) => {
277
+ if (data.length <= MAX_STREAM_PAYLOAD) {
278
+ ws.send(encodeFrame(ch, data));
279
+ return;
280
+ }
281
+ for (let off = 0; off < data.length; off += MAX_STREAM_PAYLOAD) ws.send(encodeFrame(ch, data.subarray(off, off + MAX_STREAM_PAYLOAD)));
282
+ },
283
+ bufferedAmount: () => ws.bufferedAmount
253
284
  };
254
285
  const hello = { type: "hello", protocol: PROTOCOL_VERSION, ...opts.hello };
255
286
  socket.sendControl(hello);
@@ -622,7 +653,8 @@ function claudeConfigDirs(accountDirs) {
622
653
  function expandHome(dir, home) {
623
654
  return dir.startsWith("~/") ? `${home}/${dir.slice(2)}` : dir;
624
655
  }
625
- var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"];
656
+ var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "Notification", "Stop", "SessionEnd"];
657
+ var CLAUDE_TOOL_EVENTS = /* @__PURE__ */ new Set(["PreToolUse", "PermissionRequest"]);
626
658
  var CURSOR_HOOK_EVENTS = ["sessionStart", "beforeSubmitPrompt", "afterAgentResponse", "stop", "sessionEnd"];
627
659
  var HOOK_SCRIPT = `#!/bin/sh
628
660
  # termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex / Cursor CLI hook
@@ -643,9 +675,21 @@ if [ "$TOOL" = codex ]; then EVENT="$2"; else EVENT=$(cat 2>/dev/null); fi
643
675
  # screen, and only when the pair changed since the last one for this session \u2014 twenty edits in a row
644
676
  # 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
645
677
  # session, under TMPDIR, with the session name reduced to filename-safe characters.
678
+ # AskUserQuestion is the one exception (below).
646
679
  MARK="\${TMPDIR:-/tmp}/termhub-hook-$(printf '%s' "$SESSION" | tr -c 'A-Za-z0-9_-' '_')"
647
- case "$EVENT" in
648
- *'"hook_event_name":"PreToolUse"'*|*'"hook_event_name": "PreToolUse"'*)
680
+ # The branch below is picked on the event's OWN hook_event_name \u2014 the FIRST "hook_event_name" key of
681
+ # the payload (Claude Code serialises it before tool_input, same reasoning as tool_name below) \u2014
682
+ # never a value a substring search could find nested inside a tool's input (e.g. a PermissionRequest
683
+ # whose tool_input happened to contain the text "hook_event_name":"PreToolUse").
684
+ KIND_REST=\${EVENT#*'"hook_event_name"'}
685
+ if [ "$KIND_REST" != "$EVENT" ]; then
686
+ KIND_REST=\${KIND_REST#*'"'}
687
+ KIND=\${KIND_REST%%'"'*}
688
+ else
689
+ KIND=
690
+ fi
691
+ case "$KIND" in
692
+ PreToolUse)
649
693
  # The event's own tool name is the FIRST "tool_name" of the payload (Claude Code serialises it
650
694
  # before tool_input), so the shortest prefix is cut \u2014 a "tool_name" nested in a tool's input
651
695
  # must not win. Only letters, digits, "_", "." and "-" are posted (a bare Claude Code tool name,
@@ -657,35 +701,63 @@ case "$EVENT" in
657
701
  REST=\${REST#*'"'}
658
702
  NAME=\${REST%%'"'*}
659
703
  case "$NAME" in '' | *[!A-Za-z0-9_.-]*) exit 0 ;; esac
660
- # Claude Code's spinner verb ("\u273B Moonwalking\u2026 (12s \xB7 esc to interrupt)"): the visible pane is
661
- # read here, on the machine, and only the verb may leave it \u2014 one word of 2 to 24 ASCII letters
662
- # right after a spinner glyph at column 0 and a single space, immediately followed by "\u2026" or
663
- # "...", then the end of the line or a space. Column 0 because Claude Code draws its spinner
664
- # there, while a draft in the input box or indented tool output can look just like one. The
665
- # lowest such line of the last 24 non-blank rows wins (the live spinner sits above the todo list
666
- # and the input box; blank rows under a short session are skipped). Both grep and sed run under
667
- # LC_ALL=C: bytes the locale calls invalid then neither trip grep's "binary file matches" (which
668
- # would also swallow the rest of the screen) nor sed's "illegal byte sequence" on macOS, and
669
- # the match works the same on GNU, BSD and busybox. ASCII only on purpose: a customised verb
670
- # with accents is dropped rather than half-matched. The case below checks the result again, so
671
- # the hand-built JSON only ever gets letters.
672
- VERB=$(tmux capture-pane -p -t "$TMUX_PANE" 2>/dev/null | LC_ALL=C grep -v '^[[:space:]]*$' 2>/dev/null | tail -n 24 |
673
- LC_ALL=C sed -n -E 's/^(\xB7|\u2722|\u2733|\u2736|\u273B|\u273D|\\*) ([A-Za-z]{2,24})(\u2026|\\.\\.\\.)( .*)?$/\\2/p' | tail -n 1)
674
- case "$VERB" in *[!A-Za-z]*) VERB= ;; esac
675
- [ "\${#VERB}" -le 24 ] || VERB=
676
- KEY="$NAME\${VERB:+ $VERB}"
677
- [ "$(cat "$MARK" 2>/dev/null)" = "$KEY" ] && exit 0
678
- printf '%s' "$KEY" 2>/dev/null > "$MARK"
679
- if [ -n "$VERB" ]; then
680
- EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s","verb":"%s"}' "$NAME" "$VERB")
681
- else
682
- EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
704
+ # AskUserQuestion's input is the question itself, written to be shown to the person (spec
705
+ # 2026-09-25 \xA74.1): the whole event goes as it came \u2014 the server keeps tool_use_id and
706
+ # tool_input and drops the rest \u2014 and the marker is neither read nor written, so two questions
707
+ # in a row are two questions. NAME is real only when $REST (everything from the first "tool_name"
708
+ # on) holds no SECOND "tool_name": a real question never repeats that key, so a second one means
709
+ # the first was actually nested inside another tool's tool_input (tool_input serialised before
710
+ # tool_name) and NAME does not name the real tool \u2014 fall back to the ordinary name-only path below
711
+ # (which, worst case, mislabels that one event; it never forwards the input).
712
+ ASK=false
713
+ if [ "$NAME" = AskUserQuestion ]; then
714
+ case "$REST" in
715
+ *'"tool_name"'*) ;;
716
+ *) ASK=true ;;
717
+ esac
718
+ fi
719
+ if [ "$ASK" != true ]; then
720
+ # Claude Code's spinner verb ("\u273B Moonwalking\u2026 (12s \xB7 esc to interrupt)"): the visible pane is
721
+ # read here, on the machine, and only the verb may leave it \u2014 one word of 2 to 24 ASCII letters
722
+ # right after a spinner glyph at column 0 and a single space, immediately followed by "\u2026" or
723
+ # "...", then the end of the line or a space. Column 0 because Claude Code draws its spinner
724
+ # there, while a draft in the input box or indented tool output can look just like one. The
725
+ # lowest such line of the last 24 non-blank rows wins (the live spinner sits above the todo list
726
+ # and the input box; blank rows under a short session are skipped). Both grep and sed run under
727
+ # LC_ALL=C: bytes the locale calls invalid then neither trip grep's "binary file matches" (which
728
+ # would also swallow the rest of the screen) nor sed's "illegal byte sequence" on macOS, and
729
+ # the match works the same on GNU, BSD and busybox. ASCII only on purpose: a customised verb
730
+ # with accents is dropped rather than half-matched. The case below checks the result again, so
731
+ # the hand-built JSON only ever gets letters.
732
+ VERB=$(tmux capture-pane -p -t "$TMUX_PANE" 2>/dev/null | LC_ALL=C grep -v '^[[:space:]]*$' 2>/dev/null | tail -n 24 |
733
+ LC_ALL=C sed -n -E 's/^(\xB7|\u2722|\u2733|\u2736|\u273B|\u273D|\\*) ([A-Za-z]{2,24})(\u2026|\\.\\.\\.)( .*)?$/\\2/p' | tail -n 1)
734
+ case "$VERB" in *[!A-Za-z]*) VERB= ;; esac
735
+ [ "\${#VERB}" -le 24 ] || VERB=
736
+ KEY="$NAME\${VERB:+ $VERB}"
737
+ [ "$(cat "$MARK" 2>/dev/null)" = "$KEY" ] && exit 0
738
+ printf '%s' "$KEY" 2>/dev/null > "$MARK"
739
+ if [ -n "$VERB" ]; then
740
+ EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s","verb":"%s"}' "$NAME" "$VERB")
741
+ else
742
+ EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
743
+ fi
683
744
  fi
684
745
  ;;
746
+ PermissionRequest)
747
+ # A permission prompt: only the tool's name travels, exactly like a tool call (never its input,
748
+ # never the suggestions). AskUserQuestion's own prompt is dropped \u2014 its PreToolUse already carried
749
+ # the question. Same first-"tool_name" rule and character set as above.
750
+ REST=\${EVENT#*'"tool_name"'}
751
+ [ "$REST" != "$EVENT" ] || exit 0
752
+ REST=\${REST#*'"'}
753
+ NAME=\${REST%%'"'*}
754
+ case "$NAME" in '' | *[!A-Za-z0-9_.-]* | AskUserQuestion) exit 0 ;; esac
755
+ EVENT=$(printf '{"hook_event_name":"PermissionRequest","tool_name":"%s"}' "$NAME")
756
+ ;;
685
757
  # A new turn starts fresh, and so does an answered notification: a permission prompt takes the tab
686
758
  # out of working, and the tool the person approves is the same one that set the marker, so without
687
759
  # this reset the retry is suppressed and nothing says the tab is working again.
688
- *'"hook_event_name":"SessionStart"'*|*'"hook_event_name": "SessionStart"'*|*'"hook_event_name":"UserPromptSubmit"'*|*'"hook_event_name": "UserPromptSubmit"'*|*'"hook_event_name":"Notification"'*|*'"hook_event_name": "Notification"'*)
760
+ SessionStart | UserPromptSubmit | Notification)
689
761
  rm -f "$MARK"
690
762
  ;;
691
763
  esac
@@ -719,10 +791,10 @@ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.js
719
791
  throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
720
792
  const next = hooks ?? {};
721
793
  for (const event of CLAUDE_HOOK_EVENTS) {
722
- const list3 = Array.isArray(next[event]) ? next[event] : [];
723
- const others = list3.filter((e) => !isOurs(e));
794
+ const list4 = Array.isArray(next[event]) ? next[event] : [];
795
+ const others = list4.filter((e) => !isOurs(e));
724
796
  const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
725
- if (event === "PreToolUse")
797
+ if (CLAUDE_TOOL_EVENTS.has(event))
726
798
  entry.matcher = "*";
727
799
  others.push(entry);
728
800
  next[event] = others;
@@ -860,6 +932,47 @@ function configDirsFromRc(text) {
860
932
  return out;
861
933
  }
862
934
 
935
+ // ../../packages/machine-ops/dist/simulator.js
936
+ var WDA_DIR = "$HOME/.termhub/WebDriverAgent";
937
+ var WDA_SETUP_SESSION = "termhub-wda-setup";
938
+ var UDID_RE2 = /^[A-Fa-f0-9-]{8,64}$/;
939
+ function runnerSessionName(udid2) {
940
+ return `termhub-wda-${udid2.slice(0, 8).toLowerCase()}`;
941
+ }
942
+ var SIMCTL_LIST_SCRIPT = "xcrun simctl list devices -j";
943
+ var SIMCTL_BOOT_SCRIPT = 'xcrun simctl boot "$UDID" 2>&1 || true';
944
+ var WDA_RUNNER_START_SCRIPT = `tmux new-session -d -s "$SESSION" "cd ${WDA_DIR} && xcodebuild test-without-building -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination id=$UDID -derivedDataPath DerivedData USE_PORT=$WDA_PORT MJPEG_SERVER_PORT=$MJPEG_PORT"`;
945
+ var WDA_RUNNER_ALIVE_SCRIPT = `tmux has-session -t "=$SESSION" 2>/dev/null && echo yes || echo no`;
946
+ var WDA_RUNNER_TAIL_SCRIPT = `tmux capture-pane -p -t "=$SESSION" 2>/dev/null | grep -v '^$' | tail -n "$LINES"`;
947
+ var WDA_SETUP_SH = [
948
+ "#!/bin/sh",
949
+ 'export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"',
950
+ 'mkdir -p "$HOME/.termhub"',
951
+ 'rm -f "$HOME/.termhub/wda-setup.status"',
952
+ "{",
953
+ ` if [ -d "${WDA_DIR}/.git" ]; then git -C "${WDA_DIR}" pull --ff-only; else git clone --depth 1 https://github.com/appium/WebDriverAgent "${WDA_DIR}"; fi &&`,
954
+ ` cd "${WDA_DIR}" &&`,
955
+ " xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'generic/platform=iOS Simulator' -derivedDataPath DerivedData CODE_SIGNING_ALLOWED=NO",
956
+ '} > "$HOME/.termhub/wda-setup.log" 2>&1',
957
+ 'echo $? > "$HOME/.termhub/wda-setup.status"',
958
+ ""
959
+ ].join("\n");
960
+ var WDA_SETUP_START_SCRIPT = [
961
+ `if tmux has-session -t '=${WDA_SETUP_SESSION}' 2>/dev/null; then echo STARTED:no; exit 0; fi`,
962
+ `mkdir -p "$HOME/.termhub" && cat > "$HOME/.termhub/wda-setup.sh" <<'TERMHUB_EOF'`,
963
+ WDA_SETUP_SH + "TERMHUB_EOF",
964
+ `chmod +x "$HOME/.termhub/wda-setup.sh" && tmux new-session -d -s ${WDA_SETUP_SESSION} 'sh "$HOME/.termhub/wda-setup.sh"' && echo STARTED:yes`
965
+ ].join("\n");
966
+ var WDA_SETUP_STATE_SCRIPT = `
967
+ if tmux has-session -t '=${WDA_SETUP_SESSION}' 2>/dev/null; then echo STATE:running;
968
+ elif [ -f "$HOME/.termhub/wda-setup.status" ]; then
969
+ if [ "$(cat "$HOME/.termhub/wda-setup.status")" = 0 ]; then echo STATE:ok; else echo STATE:failed; fi;
970
+ else echo STATE:idle; fi
971
+ echo VERSION:$(sed -n 's/.*"version": *"\\([^"]*\\)".*/\\1/p' "${WDA_DIR}/package.json" 2>/dev/null | head -1)
972
+ echo TAIL:
973
+ tail -n 40 "$HOME/.termhub/wda-setup.log" 2>/dev/null
974
+ exit 0`;
975
+
863
976
  // src/claude-dirs.ts
864
977
  import { readdir, readFile, stat } from "fs/promises";
865
978
  import path2 from "path";
@@ -887,8 +1000,8 @@ async function fileNames(dir) {
887
1000
  }
888
1001
  async function candidateNames(home) {
889
1002
  try {
890
- const list3 = await readdir(home, { withFileTypes: true });
891
- return list3.filter((d) => d.isDirectory() && d.name.startsWith(".claude")).map((d) => d.name);
1003
+ const list4 = await readdir(home, { withFileTypes: true });
1004
+ return list4.filter((d) => d.isDirectory() && d.name.startsWith(".claude")).map((d) => d.name);
892
1005
  } catch {
893
1006
  return [];
894
1007
  }
@@ -933,12 +1046,12 @@ function tmuxPath() {
933
1046
  return process.env.TMUX_PATH || "tmux";
934
1047
  }
935
1048
  function run(file, args, opts = {}) {
936
- const { timeoutMs = DEFAULT_TIMEOUT_MS, input, env = agentEnv() } = opts;
1049
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, input, env: env2 = agentEnv() } = opts;
937
1050
  return new Promise((resolve) => {
938
1051
  const child = execFile(
939
1052
  file,
940
1053
  args,
941
- { timeout: timeoutMs, env, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
1054
+ { timeout: timeoutMs, env: env2, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
942
1055
  (err, stdout, stderr) => {
943
1056
  const e = err;
944
1057
  resolve({
@@ -1060,9 +1173,9 @@ async function install(params, home = os3.homedir()) {
1060
1173
  }
1061
1174
  async function heal(home = os3.homedir()) {
1062
1175
  const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
1063
- const env = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
1176
+ const env2 = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
1064
1177
  const script = await readOrEmpty2(scriptPath);
1065
- if (!env.trim() || !script) return [];
1178
+ if (!env2.trim() || !script) return [];
1066
1179
  if (script !== HOOK_SCRIPT) await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
1067
1180
  const steps = [healClaudeDirs, healCursor, healCodex];
1068
1181
  const healed = [];
@@ -1252,9 +1365,9 @@ function createClaudeManager(deps) {
1252
1365
  const runDir = dir;
1253
1366
  socket.sendControl({ type: "opened", ch });
1254
1367
  deps.log("claude run starting", { ch, resume: params.resume, model: params.model ?? null });
1255
- const env = { ...deps.env ?? agentEnv() };
1256
- if (params.config_dir === null) delete env.CLAUDE_CONFIG_DIR;
1257
- else env.CLAUDE_CONFIG_DIR = params.config_dir;
1368
+ const env2 = { ...deps.env ?? agentEnv() };
1369
+ if (params.config_dir === null) delete env2.CLAUDE_CONFIG_DIR;
1370
+ else env2.CLAUDE_CONFIG_DIR = params.config_dir;
1258
1371
  const args = buildClaudeArgs({
1259
1372
  session_id: params.session_id,
1260
1373
  resume: params.resume,
@@ -1264,7 +1377,7 @@ function createClaudeManager(deps) {
1264
1377
  });
1265
1378
  let child;
1266
1379
  try {
1267
- child = spawn(CLI, args, { env, stdio: ["pipe", "pipe", "pipe"], detached: true });
1380
+ child = spawn(CLI, args, { env: env2, stdio: ["pipe", "pipe", "pipe"], detached: true });
1268
1381
  } catch (err) {
1269
1382
  deps.log("claude run could not be started", { ch, cli: CLI, error: message(err) });
1270
1383
  rmSync(runDir, { recursive: true, force: true });
@@ -1463,7 +1576,7 @@ function createDispatcher(deps) {
1463
1576
  void handleRpc(msg, socket, deps.handlers, deps.log);
1464
1577
  break;
1465
1578
  case "open": {
1466
- const opened = msg.kind === "claude" ? deps.claude.open(msg.ch, msg.params, socket) : deps.pty.open(msg.ch, msg.params, socket);
1579
+ const opened = msg.kind === "claude" ? deps.claude.open(msg.ch, msg.params, socket) : msg.kind === "tcp" ? deps.tcp.open(msg.ch, msg.params, socket) : deps.pty.open(msg.ch, msg.params, socket);
1467
1580
  opened.catch((err) => {
1468
1581
  deps.log(`${msg.kind}.open rejected unexpectedly`, { ch: msg.ch, error: err instanceof Error ? err.message : String(err) });
1469
1582
  });
@@ -1475,6 +1588,7 @@ function createDispatcher(deps) {
1475
1588
  case "close":
1476
1589
  deps.pty.close(msg.ch);
1477
1590
  deps.claude.close(msg.ch);
1591
+ deps.tcp.close(msg.ch);
1478
1592
  break;
1479
1593
  }
1480
1594
  };
@@ -1566,13 +1680,13 @@ function createPtyManager(deps) {
1566
1680
  try {
1567
1681
  ({ cols, rows } = clampSize(params));
1568
1682
  const cwd = resolveCwd(params.cwd);
1569
- const env = {
1683
+ const env2 = {
1570
1684
  ...ptyEnv(agentEnv(), process.env.SHELL ?? "/bin/sh"),
1571
1685
  TERMHUB_TAB_ID: params.session,
1572
1686
  TERMHUB_SESSION: params.session
1573
1687
  };
1574
1688
  const spawn2 = await resolveSpawn();
1575
- const spawnTmux = () => spawn2(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env });
1689
+ const spawnTmux = () => spawn2(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env: env2 });
1576
1690
  try {
1577
1691
  proc = spawnTmux();
1578
1692
  } catch (err) {
@@ -1665,6 +1779,141 @@ function createPtyManager(deps) {
1665
1779
  };
1666
1780
  }
1667
1781
 
1782
+ // src/tcp.ts
1783
+ import net from "net";
1784
+ var DEFAULT_HIGH_WATER = 4 * 1024 * 1024;
1785
+ var DEFAULT_LOW_WATER = 1024 * 1024;
1786
+ var DEFAULT_RESUME_POLL_MS = 50;
1787
+ function isRefused(err) {
1788
+ return err?.code === "ECONNREFUSED";
1789
+ }
1790
+ function createTcpManager(deps) {
1791
+ const channels = /* @__PURE__ */ new Map();
1792
+ const allowPort = deps.allowPort ?? isWdaPort;
1793
+ const highWater = deps.highWater ?? DEFAULT_HIGH_WATER;
1794
+ const lowWater = deps.lowWater ?? DEFAULT_LOW_WATER;
1795
+ const resumePollMs = deps.resumePollMs ?? DEFAULT_RESUME_POLL_MS;
1796
+ const control = (socket, msg) => {
1797
+ try {
1798
+ socket.sendControl(msg);
1799
+ } catch (err) {
1800
+ deps.log("tcp control send failed", { type: msg.type, error: err instanceof Error ? err.message : String(err) });
1801
+ }
1802
+ };
1803
+ const stopResumeTimer = (entry) => {
1804
+ if (entry.resumeTimer) clearInterval(entry.resumeTimer);
1805
+ entry.resumeTimer = null;
1806
+ };
1807
+ const drop = (ch, entry) => {
1808
+ entry.closed = true;
1809
+ stopResumeTimer(entry);
1810
+ if (channels.get(ch) === entry) channels.delete(ch);
1811
+ entry.sock.destroy();
1812
+ };
1813
+ return {
1814
+ async open(ch, params, socket) {
1815
+ if (channels.has(ch)) {
1816
+ control(socket, { type: "open_error", ch, error: { code: "invalid", message: "channel in use" } });
1817
+ return;
1818
+ }
1819
+ if (!allowPort(params.port)) {
1820
+ deps.log("tcp open refused: port not allowed", { ch, port: params.port });
1821
+ control(socket, { type: "open_error", ch, error: { code: "invalid", message: "port not allowed" } });
1822
+ return;
1823
+ }
1824
+ const sock = net.connect({ host: "127.0.0.1", port: params.port });
1825
+ let opened = false;
1826
+ let closedSent = false;
1827
+ const entry = {
1828
+ sock,
1829
+ paused: false,
1830
+ resumeTimer: null,
1831
+ closed: false,
1832
+ sendClosed: (reason) => {
1833
+ if (closedSent) return;
1834
+ closedSent = true;
1835
+ control(socket, reason ? { type: "closed", ch, code: null, reason } : { type: "closed", ch, code: null });
1836
+ }
1837
+ };
1838
+ channels.set(ch, entry);
1839
+ await new Promise((resolve) => {
1840
+ sock.once("connect", () => {
1841
+ opened = true;
1842
+ deps.log("tcp opened", { ch, port: params.port });
1843
+ control(socket, { type: "opened", ch });
1844
+ resolve();
1845
+ });
1846
+ sock.once("error", (err) => {
1847
+ if (!opened) {
1848
+ channels.delete(ch);
1849
+ stopResumeTimer(entry);
1850
+ deps.log("tcp open failed", { ch, port: params.port, code: err.code ?? "unknown" });
1851
+ control(socket, { type: "open_error", ch, error: isRefused(err) ? { code: "refused", message: "connection refused" } : { code: "internal", message: "connect failed" } });
1852
+ resolve();
1853
+ return;
1854
+ }
1855
+ deps.log("tcp socket error", { ch, port: params.port, code: err.code ?? "unknown" });
1856
+ entry.sock.destroy();
1857
+ });
1858
+ });
1859
+ if (!channels.has(ch)) return;
1860
+ sock.on("data", (chunk) => {
1861
+ if (entry.closed) return;
1862
+ try {
1863
+ socket.sendStream(ch, chunk);
1864
+ } catch (err) {
1865
+ deps.log("tcp stream send failed", { ch, error: err instanceof Error ? err.message : String(err) });
1866
+ return;
1867
+ }
1868
+ const queued = socket.bufferedAmount?.() ?? 0;
1869
+ if (queued > highWater && !entry.paused) {
1870
+ entry.paused = true;
1871
+ sock.pause();
1872
+ deps.log("tcp paused", { ch, queued });
1873
+ entry.resumeTimer = setInterval(() => {
1874
+ if (entry.closed) {
1875
+ stopResumeTimer(entry);
1876
+ return;
1877
+ }
1878
+ if ((socket.bufferedAmount?.() ?? 0) <= lowWater) {
1879
+ stopResumeTimer(entry);
1880
+ entry.paused = false;
1881
+ sock.resume();
1882
+ deps.log("tcp resumed", { ch });
1883
+ }
1884
+ }, resumePollMs);
1885
+ }
1886
+ });
1887
+ sock.on("close", (hadError) => {
1888
+ if (!opened) return;
1889
+ stopResumeTimer(entry);
1890
+ if (channels.get(ch) === entry) channels.delete(ch);
1891
+ if (entry.closed) return;
1892
+ deps.log("tcp closed", { ch, port: params.port, hadError });
1893
+ entry.sendClosed(hadError ? "reset" : void 0);
1894
+ });
1895
+ },
1896
+ write(ch, data) {
1897
+ const entry = channels.get(ch);
1898
+ if (!entry || entry.closed) return false;
1899
+ entry.sock.write(data);
1900
+ return true;
1901
+ },
1902
+ close(ch) {
1903
+ const entry = channels.get(ch);
1904
+ if (!entry) return;
1905
+ drop(ch, entry);
1906
+ entry.sendClosed();
1907
+ },
1908
+ closeAll() {
1909
+ for (const [ch, entry] of channels) drop(ch, entry);
1910
+ },
1911
+ isPaused(ch) {
1912
+ return channels.get(ch)?.paused ?? false;
1913
+ }
1914
+ };
1915
+ }
1916
+
1668
1917
  // src/rpc/ai.ts
1669
1918
  async function credential(params) {
1670
1919
  let prefix;
@@ -1720,6 +1969,33 @@ async function pasteFile(params) {
1720
1969
  return { path: path12 };
1721
1970
  }
1722
1971
 
1972
+ // src/rpc/sim.ts
1973
+ function checkUdid(udid2) {
1974
+ if (!UDID_RE2.test(udid2)) throw new RpcFailure("invalid", "invalid udid");
1975
+ }
1976
+ function scriptFailure(r, what) {
1977
+ if (r.timedOut) return new RpcFailure("timeout", `${what} timed out`);
1978
+ if (r.error === "enoent") return new RpcFailure("internal", "/bin/sh not found");
1979
+ if (r.code !== 0) {
1980
+ const why2 = r.stderr.trim().split("\n")[0] || r.stdout.trim().split("\n")[0] || `${what} exited with code ${r.code}`;
1981
+ return /tmux: (command )?not found/.test(r.stderr) ? new RpcFailure("no_tmux", "tmux not found") : new RpcFailure("failed", why2);
1982
+ }
1983
+ return null;
1984
+ }
1985
+ async function list2(_params) {
1986
+ const r = await sh(SIMCTL_LIST_SCRIPT, { timeoutMs: 14e3 });
1987
+ const failure = scriptFailure(r, "sim.list");
1988
+ if (failure) throw failure;
1989
+ return { stdout: r.stdout };
1990
+ }
1991
+ async function boot(params) {
1992
+ checkUdid(params.udid);
1993
+ const r = await sh(SIMCTL_BOOT_SCRIPT, { timeoutMs: 59e3, env: { ...agentEnv(), UDID: params.udid } });
1994
+ const failure = scriptFailure(r, "sim.boot");
1995
+ if (failure) throw failure;
1996
+ return { stdout: r.stdout + r.stderr };
1997
+ }
1998
+
1723
1999
  // src/rpc/tmux.ts
1724
2000
  import { randomUUID } from "crypto";
1725
2001
  var ENTER_PAUSE_MS = 300;
@@ -1729,7 +2005,7 @@ function processFailure2(r) {
1729
2005
  if (r.error === "maxbuffer") return new RpcFailure("internal", "output too large");
1730
2006
  return null;
1731
2007
  }
1732
- async function list2(_params) {
2008
+ async function list3(_params) {
1733
2009
  const r = await run(tmuxPath(), ["list-sessions", "-F", "#{session_name}"]);
1734
2010
  const failure = processFailure2(r);
1735
2011
  if (failure) throw failure;
@@ -2036,7 +2312,7 @@ async function status3() {
2036
2312
  }
2037
2313
 
2038
2314
  // src/version.ts
2039
- var AGENT_VERSION = "0.4.4";
2315
+ var AGENT_VERSION = "0.5.1";
2040
2316
 
2041
2317
  // src/rpc/update.ts
2042
2318
  var PACKAGE = "@termhub/agent";
@@ -2100,9 +2376,52 @@ async function doUpdate(version, deps) {
2100
2376
  }
2101
2377
  var update = (params) => updateAgent(params);
2102
2378
 
2379
+ // src/rpc/wda.ts
2380
+ function env(vars) {
2381
+ return { ...agentEnv(), ...vars };
2382
+ }
2383
+ async function runnerStart(params) {
2384
+ checkUdid(params.udid);
2385
+ if (!isWdaPort(params.wda_port) || !isWdaPort(params.mjpeg_port)) throw new RpcFailure("invalid", "port outside the WDA ranges");
2386
+ const vars = { SESSION: runnerSessionName(params.udid), UDID: params.udid, WDA_PORT: String(params.wda_port), MJPEG_PORT: String(params.mjpeg_port) };
2387
+ const r = await sh(WDA_RUNNER_START_SCRIPT, { timeoutMs: 9e3, env: env(vars) });
2388
+ if (r.code !== 0 && r.stderr.includes("duplicate session")) return { started: false };
2389
+ const failure = scriptFailure(r, "wda.runner.start");
2390
+ if (failure) throw failure;
2391
+ return { started: true };
2392
+ }
2393
+ async function runnerAlive(params) {
2394
+ checkUdid(params.udid);
2395
+ const r = await sh(WDA_RUNNER_ALIVE_SCRIPT, { env: env({ SESSION: runnerSessionName(params.udid) }) });
2396
+ const failure = scriptFailure(r, "wda.runner.alive");
2397
+ if (failure) throw failure;
2398
+ return { alive: r.stdout.includes("yes") };
2399
+ }
2400
+ async function runnerTail(params) {
2401
+ checkUdid(params.udid);
2402
+ const r = await sh(WDA_RUNNER_TAIL_SCRIPT, { env: env({ SESSION: runnerSessionName(params.udid), LINES: String(params.lines) }) });
2403
+ const failure = scriptFailure(r, "wda.runner.tail");
2404
+ if (failure) throw failure;
2405
+ return { lines: r.stdout.split("\n").filter((l) => l.trim()) };
2406
+ }
2407
+ async function setupStart(_params) {
2408
+ const r = await sh(WDA_SETUP_START_SCRIPT, { timeoutMs: 9e3 });
2409
+ const failure = scriptFailure(r, "wda.setup.start");
2410
+ if (failure) throw failure;
2411
+ if (r.stdout.includes("STARTED:no")) return { started: false };
2412
+ if (r.stdout.includes("STARTED:yes")) return { started: true };
2413
+ throw new RpcFailure("failed", r.stderr.trim().split("\n")[0] || "setup did not start");
2414
+ }
2415
+ async function setupState(_params) {
2416
+ const r = await sh(WDA_SETUP_STATE_SCRIPT);
2417
+ const failure = scriptFailure(r, "wda.setup.state");
2418
+ if (failure) throw failure;
2419
+ return { stdout: r.stdout };
2420
+ }
2421
+
2103
2422
  // src/rpc/index.ts
2104
2423
  var handlers = {
2105
- "tmux.list": list2,
2424
+ "tmux.list": list3,
2106
2425
  "tmux.kill": kill,
2107
2426
  "tmux.capture": capture,
2108
2427
  "tmux.ensure": ensure,
@@ -2116,7 +2435,14 @@ var handlers = {
2116
2435
  "file.paste": pasteFile,
2117
2436
  "hooks.install": install,
2118
2437
  "hooks.uninstall": uninstall,
2119
- "agent.update": update
2438
+ "agent.update": update,
2439
+ "sim.list": list2,
2440
+ "sim.boot": boot,
2441
+ "wda.runner.start": runnerStart,
2442
+ "wda.runner.alive": runnerAlive,
2443
+ "wda.runner.tail": runnerTail,
2444
+ "wda.setup.start": setupStart,
2445
+ "wda.setup.state": setupState
2120
2446
  };
2121
2447
 
2122
2448
  // src/run.ts
@@ -2126,6 +2452,9 @@ function detectOs(platform = process.platform) {
2126
2452
  return null;
2127
2453
  }
2128
2454
  var CAPABILITIES = [CAPABILITY_CLAUDE, CAPABILITY_CLAUDE_SYSTEM_PROMPT];
2455
+ function capabilitiesFor(osName) {
2456
+ return osName === "macos" ? [...CAPABILITIES, CAPABILITY_SIM] : [...CAPABILITIES];
2457
+ }
2129
2458
  async function buildHello(osName) {
2130
2459
  let tools = [];
2131
2460
  try {
@@ -2141,7 +2470,7 @@ async function buildHello(osName) {
2141
2470
  hostname: os6.hostname(),
2142
2471
  tmux: tools.includes("tmux"),
2143
2472
  tools,
2144
- capabilities: CAPABILITIES
2473
+ capabilities: capabilitiesFor(osName)
2145
2474
  };
2146
2475
  }
2147
2476
  var REVOKED_MESSAGE = "Token inv\xE1lido ou revogado";
@@ -2159,7 +2488,7 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
2159
2488
  {
2160
2489
  url: config.url,
2161
2490
  token: config.token,
2162
- hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], capabilities: CAPABILITIES, probe: true },
2491
+ hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], capabilities: capabilitiesFor(osName), probe: true },
2163
2492
  onServerMessage: () => {
2164
2493
  },
2165
2494
  onStream: () => {
@@ -2202,7 +2531,8 @@ async function runAgent(config, opts) {
2202
2531
  else if (!helper.executable) opts.log("spawn-helper is not executable and could not be fixed", { path: helper.path, error: helper.error });
2203
2532
  const pty = createPtyManager({ log: opts.log });
2204
2533
  const claude = createClaudeManager({ log: opts.log });
2205
- const dispatch = createDispatcher({ handlers, pty, claude, log: opts.log });
2534
+ const tcp = createTcpManager({ log: opts.log });
2535
+ const dispatch = createDispatcher({ handlers, pty, claude, tcp, log: opts.log });
2206
2536
  const healHooks = () => {
2207
2537
  heal().then((dirs) => {
2208
2538
  if (dirs.length) opts.log("monitor hooks repaired", { dirs: dirs.length });
@@ -2219,12 +2549,13 @@ async function runAgent(config, opts) {
2219
2549
  // A frame belongs to whichever manager holds that channel: the claude one says so, and
2220
2550
  // anything it does not own is a terminal's.
2221
2551
  onStream: (ch, data) => {
2222
- if (!claude.write(ch, data)) pty.write(ch, data);
2552
+ if (!claude.write(ch, data) && !tcp.write(ch, data)) pty.write(ch, data);
2223
2553
  },
2224
2554
  onConnect: healHooks,
2225
2555
  onDisconnect: () => {
2226
2556
  pty.closeAll();
2227
2557
  claude.closeAll();
2558
+ tcp.closeAll();
2228
2559
  },
2229
2560
  log: opts.log
2230
2561
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
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,