@termhub/agent 0.4.4 → 0.5.0

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 +318 -28
  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);
@@ -719,8 +750,8 @@ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.js
719
750
  throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
720
751
  const next = hooks ?? {};
721
752
  for (const event of CLAUDE_HOOK_EVENTS) {
722
- const list3 = Array.isArray(next[event]) ? next[event] : [];
723
- const others = list3.filter((e) => !isOurs(e));
753
+ const list4 = Array.isArray(next[event]) ? next[event] : [];
754
+ const others = list4.filter((e) => !isOurs(e));
724
755
  const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
725
756
  if (event === "PreToolUse")
726
757
  entry.matcher = "*";
@@ -860,6 +891,47 @@ function configDirsFromRc(text) {
860
891
  return out;
861
892
  }
862
893
 
894
+ // ../../packages/machine-ops/dist/simulator.js
895
+ var WDA_DIR = "$HOME/.termhub/WebDriverAgent";
896
+ var WDA_SETUP_SESSION = "termhub-wda-setup";
897
+ var UDID_RE2 = /^[A-Fa-f0-9-]{8,64}$/;
898
+ function runnerSessionName(udid2) {
899
+ return `termhub-wda-${udid2.slice(0, 8).toLowerCase()}`;
900
+ }
901
+ var SIMCTL_LIST_SCRIPT = "xcrun simctl list devices -j";
902
+ var SIMCTL_BOOT_SCRIPT = 'xcrun simctl boot "$UDID" 2>&1 || true';
903
+ 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"`;
904
+ var WDA_RUNNER_ALIVE_SCRIPT = `tmux has-session -t "=$SESSION" 2>/dev/null && echo yes || echo no`;
905
+ var WDA_RUNNER_TAIL_SCRIPT = `tmux capture-pane -p -t "=$SESSION" 2>/dev/null | grep -v '^$' | tail -n "$LINES"`;
906
+ var WDA_SETUP_SH = [
907
+ "#!/bin/sh",
908
+ 'export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"',
909
+ 'mkdir -p "$HOME/.termhub"',
910
+ 'rm -f "$HOME/.termhub/wda-setup.status"',
911
+ "{",
912
+ ` 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 &&`,
913
+ ` cd "${WDA_DIR}" &&`,
914
+ " xcodebuild build-for-testing -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -destination 'generic/platform=iOS Simulator' -derivedDataPath DerivedData CODE_SIGNING_ALLOWED=NO",
915
+ '} > "$HOME/.termhub/wda-setup.log" 2>&1',
916
+ 'echo $? > "$HOME/.termhub/wda-setup.status"',
917
+ ""
918
+ ].join("\n");
919
+ var WDA_SETUP_START_SCRIPT = [
920
+ `if tmux has-session -t '=${WDA_SETUP_SESSION}' 2>/dev/null; then echo STARTED:no; exit 0; fi`,
921
+ `mkdir -p "$HOME/.termhub" && cat > "$HOME/.termhub/wda-setup.sh" <<'TERMHUB_EOF'`,
922
+ WDA_SETUP_SH + "TERMHUB_EOF",
923
+ `chmod +x "$HOME/.termhub/wda-setup.sh" && tmux new-session -d -s ${WDA_SETUP_SESSION} 'sh "$HOME/.termhub/wda-setup.sh"' && echo STARTED:yes`
924
+ ].join("\n");
925
+ var WDA_SETUP_STATE_SCRIPT = `
926
+ if tmux has-session -t '=${WDA_SETUP_SESSION}' 2>/dev/null; then echo STATE:running;
927
+ elif [ -f "$HOME/.termhub/wda-setup.status" ]; then
928
+ if [ "$(cat "$HOME/.termhub/wda-setup.status")" = 0 ]; then echo STATE:ok; else echo STATE:failed; fi;
929
+ else echo STATE:idle; fi
930
+ echo VERSION:$(sed -n 's/.*"version": *"\\([^"]*\\)".*/\\1/p' "${WDA_DIR}/package.json" 2>/dev/null | head -1)
931
+ echo TAIL:
932
+ tail -n 40 "$HOME/.termhub/wda-setup.log" 2>/dev/null
933
+ exit 0`;
934
+
863
935
  // src/claude-dirs.ts
864
936
  import { readdir, readFile, stat } from "fs/promises";
865
937
  import path2 from "path";
@@ -887,8 +959,8 @@ async function fileNames(dir) {
887
959
  }
888
960
  async function candidateNames(home) {
889
961
  try {
890
- const list3 = await readdir(home, { withFileTypes: true });
891
- return list3.filter((d) => d.isDirectory() && d.name.startsWith(".claude")).map((d) => d.name);
962
+ const list4 = await readdir(home, { withFileTypes: true });
963
+ return list4.filter((d) => d.isDirectory() && d.name.startsWith(".claude")).map((d) => d.name);
892
964
  } catch {
893
965
  return [];
894
966
  }
@@ -933,12 +1005,12 @@ function tmuxPath() {
933
1005
  return process.env.TMUX_PATH || "tmux";
934
1006
  }
935
1007
  function run(file, args, opts = {}) {
936
- const { timeoutMs = DEFAULT_TIMEOUT_MS, input, env = agentEnv() } = opts;
1008
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, input, env: env2 = agentEnv() } = opts;
937
1009
  return new Promise((resolve) => {
938
1010
  const child = execFile(
939
1011
  file,
940
1012
  args,
941
- { timeout: timeoutMs, env, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
1013
+ { timeout: timeoutMs, env: env2, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
942
1014
  (err, stdout, stderr) => {
943
1015
  const e = err;
944
1016
  resolve({
@@ -1060,9 +1132,9 @@ async function install(params, home = os3.homedir()) {
1060
1132
  }
1061
1133
  async function heal(home = os3.homedir()) {
1062
1134
  const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
1063
- const env = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
1135
+ const env2 = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
1064
1136
  const script = await readOrEmpty2(scriptPath);
1065
- if (!env.trim() || !script) return [];
1137
+ if (!env2.trim() || !script) return [];
1066
1138
  if (script !== HOOK_SCRIPT) await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
1067
1139
  const steps = [healClaudeDirs, healCursor, healCodex];
1068
1140
  const healed = [];
@@ -1252,9 +1324,9 @@ function createClaudeManager(deps) {
1252
1324
  const runDir = dir;
1253
1325
  socket.sendControl({ type: "opened", ch });
1254
1326
  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;
1327
+ const env2 = { ...deps.env ?? agentEnv() };
1328
+ if (params.config_dir === null) delete env2.CLAUDE_CONFIG_DIR;
1329
+ else env2.CLAUDE_CONFIG_DIR = params.config_dir;
1258
1330
  const args = buildClaudeArgs({
1259
1331
  session_id: params.session_id,
1260
1332
  resume: params.resume,
@@ -1264,7 +1336,7 @@ function createClaudeManager(deps) {
1264
1336
  });
1265
1337
  let child;
1266
1338
  try {
1267
- child = spawn(CLI, args, { env, stdio: ["pipe", "pipe", "pipe"], detached: true });
1339
+ child = spawn(CLI, args, { env: env2, stdio: ["pipe", "pipe", "pipe"], detached: true });
1268
1340
  } catch (err) {
1269
1341
  deps.log("claude run could not be started", { ch, cli: CLI, error: message(err) });
1270
1342
  rmSync(runDir, { recursive: true, force: true });
@@ -1463,7 +1535,7 @@ function createDispatcher(deps) {
1463
1535
  void handleRpc(msg, socket, deps.handlers, deps.log);
1464
1536
  break;
1465
1537
  case "open": {
1466
- const opened = msg.kind === "claude" ? deps.claude.open(msg.ch, msg.params, socket) : deps.pty.open(msg.ch, msg.params, socket);
1538
+ 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
1539
  opened.catch((err) => {
1468
1540
  deps.log(`${msg.kind}.open rejected unexpectedly`, { ch: msg.ch, error: err instanceof Error ? err.message : String(err) });
1469
1541
  });
@@ -1475,6 +1547,7 @@ function createDispatcher(deps) {
1475
1547
  case "close":
1476
1548
  deps.pty.close(msg.ch);
1477
1549
  deps.claude.close(msg.ch);
1550
+ deps.tcp.close(msg.ch);
1478
1551
  break;
1479
1552
  }
1480
1553
  };
@@ -1566,13 +1639,13 @@ function createPtyManager(deps) {
1566
1639
  try {
1567
1640
  ({ cols, rows } = clampSize(params));
1568
1641
  const cwd = resolveCwd(params.cwd);
1569
- const env = {
1642
+ const env2 = {
1570
1643
  ...ptyEnv(agentEnv(), process.env.SHELL ?? "/bin/sh"),
1571
1644
  TERMHUB_TAB_ID: params.session,
1572
1645
  TERMHUB_SESSION: params.session
1573
1646
  };
1574
1647
  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 });
1648
+ const spawnTmux = () => spawn2(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env: env2 });
1576
1649
  try {
1577
1650
  proc = spawnTmux();
1578
1651
  } catch (err) {
@@ -1665,6 +1738,141 @@ function createPtyManager(deps) {
1665
1738
  };
1666
1739
  }
1667
1740
 
1741
+ // src/tcp.ts
1742
+ import net from "net";
1743
+ var DEFAULT_HIGH_WATER = 4 * 1024 * 1024;
1744
+ var DEFAULT_LOW_WATER = 1024 * 1024;
1745
+ var DEFAULT_RESUME_POLL_MS = 50;
1746
+ function isRefused(err) {
1747
+ return err?.code === "ECONNREFUSED";
1748
+ }
1749
+ function createTcpManager(deps) {
1750
+ const channels = /* @__PURE__ */ new Map();
1751
+ const allowPort = deps.allowPort ?? isWdaPort;
1752
+ const highWater = deps.highWater ?? DEFAULT_HIGH_WATER;
1753
+ const lowWater = deps.lowWater ?? DEFAULT_LOW_WATER;
1754
+ const resumePollMs = deps.resumePollMs ?? DEFAULT_RESUME_POLL_MS;
1755
+ const control = (socket, msg) => {
1756
+ try {
1757
+ socket.sendControl(msg);
1758
+ } catch (err) {
1759
+ deps.log("tcp control send failed", { type: msg.type, error: err instanceof Error ? err.message : String(err) });
1760
+ }
1761
+ };
1762
+ const stopResumeTimer = (entry) => {
1763
+ if (entry.resumeTimer) clearInterval(entry.resumeTimer);
1764
+ entry.resumeTimer = null;
1765
+ };
1766
+ const drop = (ch, entry) => {
1767
+ entry.closed = true;
1768
+ stopResumeTimer(entry);
1769
+ if (channels.get(ch) === entry) channels.delete(ch);
1770
+ entry.sock.destroy();
1771
+ };
1772
+ return {
1773
+ async open(ch, params, socket) {
1774
+ if (channels.has(ch)) {
1775
+ control(socket, { type: "open_error", ch, error: { code: "invalid", message: "channel in use" } });
1776
+ return;
1777
+ }
1778
+ if (!allowPort(params.port)) {
1779
+ deps.log("tcp open refused: port not allowed", { ch, port: params.port });
1780
+ control(socket, { type: "open_error", ch, error: { code: "invalid", message: "port not allowed" } });
1781
+ return;
1782
+ }
1783
+ const sock = net.connect({ host: "127.0.0.1", port: params.port });
1784
+ let opened = false;
1785
+ let closedSent = false;
1786
+ const entry = {
1787
+ sock,
1788
+ paused: false,
1789
+ resumeTimer: null,
1790
+ closed: false,
1791
+ sendClosed: (reason) => {
1792
+ if (closedSent) return;
1793
+ closedSent = true;
1794
+ control(socket, reason ? { type: "closed", ch, code: null, reason } : { type: "closed", ch, code: null });
1795
+ }
1796
+ };
1797
+ channels.set(ch, entry);
1798
+ await new Promise((resolve) => {
1799
+ sock.once("connect", () => {
1800
+ opened = true;
1801
+ deps.log("tcp opened", { ch, port: params.port });
1802
+ control(socket, { type: "opened", ch });
1803
+ resolve();
1804
+ });
1805
+ sock.once("error", (err) => {
1806
+ if (!opened) {
1807
+ channels.delete(ch);
1808
+ stopResumeTimer(entry);
1809
+ deps.log("tcp open failed", { ch, port: params.port, code: err.code ?? "unknown" });
1810
+ control(socket, { type: "open_error", ch, error: isRefused(err) ? { code: "refused", message: "connection refused" } : { code: "internal", message: "connect failed" } });
1811
+ resolve();
1812
+ return;
1813
+ }
1814
+ deps.log("tcp socket error", { ch, port: params.port, code: err.code ?? "unknown" });
1815
+ entry.sock.destroy();
1816
+ });
1817
+ });
1818
+ if (!channels.has(ch)) return;
1819
+ sock.on("data", (chunk) => {
1820
+ if (entry.closed) return;
1821
+ try {
1822
+ socket.sendStream(ch, chunk);
1823
+ } catch (err) {
1824
+ deps.log("tcp stream send failed", { ch, error: err instanceof Error ? err.message : String(err) });
1825
+ return;
1826
+ }
1827
+ const queued = socket.bufferedAmount?.() ?? 0;
1828
+ if (queued > highWater && !entry.paused) {
1829
+ entry.paused = true;
1830
+ sock.pause();
1831
+ deps.log("tcp paused", { ch, queued });
1832
+ entry.resumeTimer = setInterval(() => {
1833
+ if (entry.closed) {
1834
+ stopResumeTimer(entry);
1835
+ return;
1836
+ }
1837
+ if ((socket.bufferedAmount?.() ?? 0) <= lowWater) {
1838
+ stopResumeTimer(entry);
1839
+ entry.paused = false;
1840
+ sock.resume();
1841
+ deps.log("tcp resumed", { ch });
1842
+ }
1843
+ }, resumePollMs);
1844
+ }
1845
+ });
1846
+ sock.on("close", (hadError) => {
1847
+ if (!opened) return;
1848
+ stopResumeTimer(entry);
1849
+ if (channels.get(ch) === entry) channels.delete(ch);
1850
+ if (entry.closed) return;
1851
+ deps.log("tcp closed", { ch, port: params.port, hadError });
1852
+ entry.sendClosed(hadError ? "reset" : void 0);
1853
+ });
1854
+ },
1855
+ write(ch, data) {
1856
+ const entry = channels.get(ch);
1857
+ if (!entry || entry.closed) return false;
1858
+ entry.sock.write(data);
1859
+ return true;
1860
+ },
1861
+ close(ch) {
1862
+ const entry = channels.get(ch);
1863
+ if (!entry) return;
1864
+ drop(ch, entry);
1865
+ entry.sendClosed();
1866
+ },
1867
+ closeAll() {
1868
+ for (const [ch, entry] of channels) drop(ch, entry);
1869
+ },
1870
+ isPaused(ch) {
1871
+ return channels.get(ch)?.paused ?? false;
1872
+ }
1873
+ };
1874
+ }
1875
+
1668
1876
  // src/rpc/ai.ts
1669
1877
  async function credential(params) {
1670
1878
  let prefix;
@@ -1720,6 +1928,33 @@ async function pasteFile(params) {
1720
1928
  return { path: path12 };
1721
1929
  }
1722
1930
 
1931
+ // src/rpc/sim.ts
1932
+ function checkUdid(udid2) {
1933
+ if (!UDID_RE2.test(udid2)) throw new RpcFailure("invalid", "invalid udid");
1934
+ }
1935
+ function scriptFailure(r, what) {
1936
+ if (r.timedOut) return new RpcFailure("timeout", `${what} timed out`);
1937
+ if (r.error === "enoent") return new RpcFailure("internal", "/bin/sh not found");
1938
+ if (r.code !== 0) {
1939
+ const why2 = r.stderr.trim().split("\n")[0] || r.stdout.trim().split("\n")[0] || `${what} exited with code ${r.code}`;
1940
+ return /tmux: (command )?not found/.test(r.stderr) ? new RpcFailure("no_tmux", "tmux not found") : new RpcFailure("failed", why2);
1941
+ }
1942
+ return null;
1943
+ }
1944
+ async function list2(_params) {
1945
+ const r = await sh(SIMCTL_LIST_SCRIPT, { timeoutMs: 14e3 });
1946
+ const failure = scriptFailure(r, "sim.list");
1947
+ if (failure) throw failure;
1948
+ return { stdout: r.stdout };
1949
+ }
1950
+ async function boot(params) {
1951
+ checkUdid(params.udid);
1952
+ const r = await sh(SIMCTL_BOOT_SCRIPT, { timeoutMs: 59e3, env: { ...agentEnv(), UDID: params.udid } });
1953
+ const failure = scriptFailure(r, "sim.boot");
1954
+ if (failure) throw failure;
1955
+ return { stdout: r.stdout + r.stderr };
1956
+ }
1957
+
1723
1958
  // src/rpc/tmux.ts
1724
1959
  import { randomUUID } from "crypto";
1725
1960
  var ENTER_PAUSE_MS = 300;
@@ -1729,7 +1964,7 @@ function processFailure2(r) {
1729
1964
  if (r.error === "maxbuffer") return new RpcFailure("internal", "output too large");
1730
1965
  return null;
1731
1966
  }
1732
- async function list2(_params) {
1967
+ async function list3(_params) {
1733
1968
  const r = await run(tmuxPath(), ["list-sessions", "-F", "#{session_name}"]);
1734
1969
  const failure = processFailure2(r);
1735
1970
  if (failure) throw failure;
@@ -2036,7 +2271,7 @@ async function status3() {
2036
2271
  }
2037
2272
 
2038
2273
  // src/version.ts
2039
- var AGENT_VERSION = "0.4.4";
2274
+ var AGENT_VERSION = "0.5.0";
2040
2275
 
2041
2276
  // src/rpc/update.ts
2042
2277
  var PACKAGE = "@termhub/agent";
@@ -2100,9 +2335,52 @@ async function doUpdate(version, deps) {
2100
2335
  }
2101
2336
  var update = (params) => updateAgent(params);
2102
2337
 
2338
+ // src/rpc/wda.ts
2339
+ function env(vars) {
2340
+ return { ...agentEnv(), ...vars };
2341
+ }
2342
+ async function runnerStart(params) {
2343
+ checkUdid(params.udid);
2344
+ if (!isWdaPort(params.wda_port) || !isWdaPort(params.mjpeg_port)) throw new RpcFailure("invalid", "port outside the WDA ranges");
2345
+ const vars = { SESSION: runnerSessionName(params.udid), UDID: params.udid, WDA_PORT: String(params.wda_port), MJPEG_PORT: String(params.mjpeg_port) };
2346
+ const r = await sh(WDA_RUNNER_START_SCRIPT, { timeoutMs: 9e3, env: env(vars) });
2347
+ if (r.code !== 0 && r.stderr.includes("duplicate session")) return { started: false };
2348
+ const failure = scriptFailure(r, "wda.runner.start");
2349
+ if (failure) throw failure;
2350
+ return { started: true };
2351
+ }
2352
+ async function runnerAlive(params) {
2353
+ checkUdid(params.udid);
2354
+ const r = await sh(WDA_RUNNER_ALIVE_SCRIPT, { env: env({ SESSION: runnerSessionName(params.udid) }) });
2355
+ const failure = scriptFailure(r, "wda.runner.alive");
2356
+ if (failure) throw failure;
2357
+ return { alive: r.stdout.includes("yes") };
2358
+ }
2359
+ async function runnerTail(params) {
2360
+ checkUdid(params.udid);
2361
+ const r = await sh(WDA_RUNNER_TAIL_SCRIPT, { env: env({ SESSION: runnerSessionName(params.udid), LINES: String(params.lines) }) });
2362
+ const failure = scriptFailure(r, "wda.runner.tail");
2363
+ if (failure) throw failure;
2364
+ return { lines: r.stdout.split("\n").filter((l) => l.trim()) };
2365
+ }
2366
+ async function setupStart(_params) {
2367
+ const r = await sh(WDA_SETUP_START_SCRIPT, { timeoutMs: 9e3 });
2368
+ const failure = scriptFailure(r, "wda.setup.start");
2369
+ if (failure) throw failure;
2370
+ if (r.stdout.includes("STARTED:no")) return { started: false };
2371
+ if (r.stdout.includes("STARTED:yes")) return { started: true };
2372
+ throw new RpcFailure("failed", r.stderr.trim().split("\n")[0] || "setup did not start");
2373
+ }
2374
+ async function setupState(_params) {
2375
+ const r = await sh(WDA_SETUP_STATE_SCRIPT);
2376
+ const failure = scriptFailure(r, "wda.setup.state");
2377
+ if (failure) throw failure;
2378
+ return { stdout: r.stdout };
2379
+ }
2380
+
2103
2381
  // src/rpc/index.ts
2104
2382
  var handlers = {
2105
- "tmux.list": list2,
2383
+ "tmux.list": list3,
2106
2384
  "tmux.kill": kill,
2107
2385
  "tmux.capture": capture,
2108
2386
  "tmux.ensure": ensure,
@@ -2116,7 +2394,14 @@ var handlers = {
2116
2394
  "file.paste": pasteFile,
2117
2395
  "hooks.install": install,
2118
2396
  "hooks.uninstall": uninstall,
2119
- "agent.update": update
2397
+ "agent.update": update,
2398
+ "sim.list": list2,
2399
+ "sim.boot": boot,
2400
+ "wda.runner.start": runnerStart,
2401
+ "wda.runner.alive": runnerAlive,
2402
+ "wda.runner.tail": runnerTail,
2403
+ "wda.setup.start": setupStart,
2404
+ "wda.setup.state": setupState
2120
2405
  };
2121
2406
 
2122
2407
  // src/run.ts
@@ -2126,6 +2411,9 @@ function detectOs(platform = process.platform) {
2126
2411
  return null;
2127
2412
  }
2128
2413
  var CAPABILITIES = [CAPABILITY_CLAUDE, CAPABILITY_CLAUDE_SYSTEM_PROMPT];
2414
+ function capabilitiesFor(osName) {
2415
+ return osName === "macos" ? [...CAPABILITIES, CAPABILITY_SIM] : [...CAPABILITIES];
2416
+ }
2129
2417
  async function buildHello(osName) {
2130
2418
  let tools = [];
2131
2419
  try {
@@ -2141,7 +2429,7 @@ async function buildHello(osName) {
2141
2429
  hostname: os6.hostname(),
2142
2430
  tmux: tools.includes("tmux"),
2143
2431
  tools,
2144
- capabilities: CAPABILITIES
2432
+ capabilities: capabilitiesFor(osName)
2145
2433
  };
2146
2434
  }
2147
2435
  var REVOKED_MESSAGE = "Token inv\xE1lido ou revogado";
@@ -2159,7 +2447,7 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
2159
2447
  {
2160
2448
  url: config.url,
2161
2449
  token: config.token,
2162
- hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], capabilities: CAPABILITIES, probe: true },
2450
+ hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], capabilities: capabilitiesFor(osName), probe: true },
2163
2451
  onServerMessage: () => {
2164
2452
  },
2165
2453
  onStream: () => {
@@ -2202,7 +2490,8 @@ async function runAgent(config, opts) {
2202
2490
  else if (!helper.executable) opts.log("spawn-helper is not executable and could not be fixed", { path: helper.path, error: helper.error });
2203
2491
  const pty = createPtyManager({ log: opts.log });
2204
2492
  const claude = createClaudeManager({ log: opts.log });
2205
- const dispatch = createDispatcher({ handlers, pty, claude, log: opts.log });
2493
+ const tcp = createTcpManager({ log: opts.log });
2494
+ const dispatch = createDispatcher({ handlers, pty, claude, tcp, log: opts.log });
2206
2495
  const healHooks = () => {
2207
2496
  heal().then((dirs) => {
2208
2497
  if (dirs.length) opts.log("monitor hooks repaired", { dirs: dirs.length });
@@ -2219,12 +2508,13 @@ async function runAgent(config, opts) {
2219
2508
  // A frame belongs to whichever manager holds that channel: the claude one says so, and
2220
2509
  // anything it does not own is a terminal's.
2221
2510
  onStream: (ch, data) => {
2222
- if (!claude.write(ch, data)) pty.write(ch, data);
2511
+ if (!claude.write(ch, data) && !tcp.write(ch, data)) pty.write(ch, data);
2223
2512
  },
2224
2513
  onConnect: healHooks,
2225
2514
  onDisconnect: () => {
2226
2515
  pty.closeAll();
2227
2516
  claude.closeAll();
2517
+ tcp.closeAll();
2228
2518
  },
2229
2519
  log: opts.log
2230
2520
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
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,