@termhub/agent 0.4.3 → 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 +331 -32
  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,8 +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";
124
+ var CAPABILITY_CLAUDE_SYSTEM_PROMPT = "claude.system_prompt";
125
+ var CAPABILITY_SIM = "sim";
105
126
  var helloMessage = z2.object({
106
127
  type: z2.literal("hello"),
107
128
  protocol: z2.number().int().min(1),
@@ -144,19 +165,26 @@ var claudeOpenParams = z2.object({
144
165
  // opening a channel to the user's own machine is exactly the trust boundary this grant
145
166
  // belongs at, so there is no separate, later place to hand it over.
146
167
  token: z2.string(),
147
- model: z2.string().nullable().optional()
168
+ model: z2.string().nullable().optional(),
169
+ // Project chats only: the server-composed focus text forwarded onto the CLI's argv (see
170
+ // `CAPABILITY_CLAUDE_SYSTEM_PROMPT`). Absent for the account-wide chat, whose argv must not change.
171
+ append_system_prompt: z2.string().max(4e3).nullable().optional()
148
172
  });
173
+ var tcpOpenParams = z2.object({ port: wdaPort }).strict();
149
174
  var openPty = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("pty"), params: ptyOpenParams });
150
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 });
151
177
  var serverMessage = z2.union([
152
178
  z2.object({ type: z2.literal("rpc"), id: rpcId, method: rpcMethod, params: z2.unknown() }),
153
179
  openPty,
154
180
  openClaude,
181
+ openTcp,
155
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) }),
156
183
  z2.object({ type: z2.literal("close"), ch: channel })
157
184
  ]);
158
185
 
159
186
  // src/client.ts
187
+ var MAX_STREAM_PAYLOAD = MAX_FRAME - HEADER_BYTES;
160
188
  var RevokedError = class extends Error {
161
189
  };
162
190
  var ProtocolMismatchError = class extends Error {
@@ -245,7 +273,14 @@ function connectOnce(opts, signal) {
245
273
  }, opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS);
246
274
  socket = {
247
275
  sendControl: (msg) => ws.send(encodeFrame(CONTROL_CHANNEL, JSON.stringify(msg))),
248
- 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
249
284
  };
250
285
  const hello = { type: "hello", protocol: PROTOCOL_VERSION, ...opts.hello };
251
286
  socket.sendControl(hello);
@@ -715,8 +750,8 @@ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.js
715
750
  throw new Error(`${shown}: o campo "hooks" n\xE3o \xE9 um objeto`);
716
751
  const next = hooks ?? {};
717
752
  for (const event of CLAUDE_HOOK_EVENTS) {
718
- const list3 = Array.isArray(next[event]) ? next[event] : [];
719
- const others = list3.filter((e) => !isOurs(e));
753
+ const list4 = Array.isArray(next[event]) ? next[event] : [];
754
+ const others = list4.filter((e) => !isOurs(e));
720
755
  const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
721
756
  if (event === "PreToolUse")
722
757
  entry.matcher = "*";
@@ -856,6 +891,47 @@ function configDirsFromRc(text) {
856
891
  return out;
857
892
  }
858
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
+
859
935
  // src/claude-dirs.ts
860
936
  import { readdir, readFile, stat } from "fs/promises";
861
937
  import path2 from "path";
@@ -883,8 +959,8 @@ async function fileNames(dir) {
883
959
  }
884
960
  async function candidateNames(home) {
885
961
  try {
886
- const list3 = await readdir(home, { withFileTypes: true });
887
- 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);
888
964
  } catch {
889
965
  return [];
890
966
  }
@@ -929,12 +1005,12 @@ function tmuxPath() {
929
1005
  return process.env.TMUX_PATH || "tmux";
930
1006
  }
931
1007
  function run(file, args, opts = {}) {
932
- const { timeoutMs = DEFAULT_TIMEOUT_MS, input, env = agentEnv() } = opts;
1008
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, input, env: env2 = agentEnv() } = opts;
933
1009
  return new Promise((resolve) => {
934
1010
  const child = execFile(
935
1011
  file,
936
1012
  args,
937
- { timeout: timeoutMs, env, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
1013
+ { timeout: timeoutMs, env: env2, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" },
938
1014
  (err, stdout, stderr) => {
939
1015
  const e = err;
940
1016
  resolve({
@@ -1056,9 +1132,9 @@ async function install(params, home = os3.homedir()) {
1056
1132
  }
1057
1133
  async function heal(home = os3.homedir()) {
1058
1134
  const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
1059
- const env = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
1135
+ const env2 = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
1060
1136
  const script = await readOrEmpty2(scriptPath);
1061
- if (!env.trim() || !script) return [];
1137
+ if (!env2.trim() || !script) return [];
1062
1138
  if (script !== HOOK_SCRIPT) await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
1063
1139
  const steps = [healClaudeDirs, healCursor, healCodex];
1064
1140
  const healed = [];
@@ -1191,7 +1267,11 @@ function buildClaudeArgs(spec) {
1191
1267
  "mcp__termhub__*",
1192
1268
  "--disallowed-tools",
1193
1269
  DISALLOWED_TOOLS,
1194
- ...spec.model ? ["--model", spec.model] : []
1270
+ ...spec.model ? ["--model", spec.model] : [],
1271
+ // Last, and only when set: the account-wide chat's argv stays exactly what it was. It is our own
1272
+ // server-composed text (a project's name, key and paths), never the user's prompt, which still
1273
+ // travels on stdin only.
1274
+ ...spec.append_system_prompt ? ["--append-system-prompt", spec.append_system_prompt] : []
1195
1275
  ];
1196
1276
  }
1197
1277
  function mcpConfig(url, token) {
@@ -1244,18 +1324,19 @@ function createClaudeManager(deps) {
1244
1324
  const runDir = dir;
1245
1325
  socket.sendControl({ type: "opened", ch });
1246
1326
  deps.log("claude run starting", { ch, resume: params.resume, model: params.model ?? null });
1247
- const env = { ...deps.env ?? agentEnv() };
1248
- if (params.config_dir === null) delete env.CLAUDE_CONFIG_DIR;
1249
- 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;
1250
1330
  const args = buildClaudeArgs({
1251
1331
  session_id: params.session_id,
1252
1332
  resume: params.resume,
1253
1333
  mcp_config_path: mcpConfigPath,
1254
- model: params.model ?? null
1334
+ model: params.model ?? null,
1335
+ append_system_prompt: params.append_system_prompt ?? null
1255
1336
  });
1256
1337
  let child;
1257
1338
  try {
1258
- child = spawn(CLI, args, { env, stdio: ["pipe", "pipe", "pipe"], detached: true });
1339
+ child = spawn(CLI, args, { env: env2, stdio: ["pipe", "pipe", "pipe"], detached: true });
1259
1340
  } catch (err) {
1260
1341
  deps.log("claude run could not be started", { ch, cli: CLI, error: message(err) });
1261
1342
  rmSync(runDir, { recursive: true, force: true });
@@ -1454,7 +1535,7 @@ function createDispatcher(deps) {
1454
1535
  void handleRpc(msg, socket, deps.handlers, deps.log);
1455
1536
  break;
1456
1537
  case "open": {
1457
- 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);
1458
1539
  opened.catch((err) => {
1459
1540
  deps.log(`${msg.kind}.open rejected unexpectedly`, { ch: msg.ch, error: err instanceof Error ? err.message : String(err) });
1460
1541
  });
@@ -1466,6 +1547,7 @@ function createDispatcher(deps) {
1466
1547
  case "close":
1467
1548
  deps.pty.close(msg.ch);
1468
1549
  deps.claude.close(msg.ch);
1550
+ deps.tcp.close(msg.ch);
1469
1551
  break;
1470
1552
  }
1471
1553
  };
@@ -1557,13 +1639,13 @@ function createPtyManager(deps) {
1557
1639
  try {
1558
1640
  ({ cols, rows } = clampSize(params));
1559
1641
  const cwd = resolveCwd(params.cwd);
1560
- const env = {
1642
+ const env2 = {
1561
1643
  ...ptyEnv(agentEnv(), process.env.SHELL ?? "/bin/sh"),
1562
1644
  TERMHUB_TAB_ID: params.session,
1563
1645
  TERMHUB_SESSION: params.session
1564
1646
  };
1565
1647
  const spawn2 = await resolveSpawn();
1566
- 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 });
1567
1649
  try {
1568
1650
  proc = spawnTmux();
1569
1651
  } catch (err) {
@@ -1656,6 +1738,141 @@ function createPtyManager(deps) {
1656
1738
  };
1657
1739
  }
1658
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
+
1659
1876
  // src/rpc/ai.ts
1660
1877
  async function credential(params) {
1661
1878
  let prefix;
@@ -1711,6 +1928,33 @@ async function pasteFile(params) {
1711
1928
  return { path: path12 };
1712
1929
  }
1713
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
+
1714
1958
  // src/rpc/tmux.ts
1715
1959
  import { randomUUID } from "crypto";
1716
1960
  var ENTER_PAUSE_MS = 300;
@@ -1720,7 +1964,7 @@ function processFailure2(r) {
1720
1964
  if (r.error === "maxbuffer") return new RpcFailure("internal", "output too large");
1721
1965
  return null;
1722
1966
  }
1723
- async function list2(_params) {
1967
+ async function list3(_params) {
1724
1968
  const r = await run(tmuxPath(), ["list-sessions", "-F", "#{session_name}"]);
1725
1969
  const failure = processFailure2(r);
1726
1970
  if (failure) throw failure;
@@ -2027,7 +2271,7 @@ async function status3() {
2027
2271
  }
2028
2272
 
2029
2273
  // src/version.ts
2030
- var AGENT_VERSION = "0.4.3";
2274
+ var AGENT_VERSION = "0.5.0";
2031
2275
 
2032
2276
  // src/rpc/update.ts
2033
2277
  var PACKAGE = "@termhub/agent";
@@ -2091,9 +2335,52 @@ async function doUpdate(version, deps) {
2091
2335
  }
2092
2336
  var update = (params) => updateAgent(params);
2093
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
+
2094
2381
  // src/rpc/index.ts
2095
2382
  var handlers = {
2096
- "tmux.list": list2,
2383
+ "tmux.list": list3,
2097
2384
  "tmux.kill": kill,
2098
2385
  "tmux.capture": capture,
2099
2386
  "tmux.ensure": ensure,
@@ -2107,7 +2394,14 @@ var handlers = {
2107
2394
  "file.paste": pasteFile,
2108
2395
  "hooks.install": install,
2109
2396
  "hooks.uninstall": uninstall,
2110
- "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
2111
2405
  };
2112
2406
 
2113
2407
  // src/run.ts
@@ -2116,7 +2410,10 @@ function detectOs(platform = process.platform) {
2116
2410
  if (platform === "linux") return "linux";
2117
2411
  return null;
2118
2412
  }
2119
- var CAPABILITIES = [CAPABILITY_CLAUDE];
2413
+ var CAPABILITIES = [CAPABILITY_CLAUDE, CAPABILITY_CLAUDE_SYSTEM_PROMPT];
2414
+ function capabilitiesFor(osName) {
2415
+ return osName === "macos" ? [...CAPABILITIES, CAPABILITY_SIM] : [...CAPABILITIES];
2416
+ }
2120
2417
  async function buildHello(osName) {
2121
2418
  let tools = [];
2122
2419
  try {
@@ -2132,7 +2429,7 @@ async function buildHello(osName) {
2132
2429
  hostname: os6.hostname(),
2133
2430
  tmux: tools.includes("tmux"),
2134
2431
  tools,
2135
- capabilities: CAPABILITIES
2432
+ capabilities: capabilitiesFor(osName)
2136
2433
  };
2137
2434
  }
2138
2435
  var REVOKED_MESSAGE = "Token inv\xE1lido ou revogado";
@@ -2150,7 +2447,7 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
2150
2447
  {
2151
2448
  url: config.url,
2152
2449
  token: config.token,
2153
- 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 },
2154
2451
  onServerMessage: () => {
2155
2452
  },
2156
2453
  onStream: () => {
@@ -2193,7 +2490,8 @@ async function runAgent(config, opts) {
2193
2490
  else if (!helper.executable) opts.log("spawn-helper is not executable and could not be fixed", { path: helper.path, error: helper.error });
2194
2491
  const pty = createPtyManager({ log: opts.log });
2195
2492
  const claude = createClaudeManager({ log: opts.log });
2196
- 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 });
2197
2495
  const healHooks = () => {
2198
2496
  heal().then((dirs) => {
2199
2497
  if (dirs.length) opts.log("monitor hooks repaired", { dirs: dirs.length });
@@ -2210,12 +2508,13 @@ async function runAgent(config, opts) {
2210
2508
  // A frame belongs to whichever manager holds that channel: the claude one says so, and
2211
2509
  // anything it does not own is a terminal's.
2212
2510
  onStream: (ch, data) => {
2213
- if (!claude.write(ch, data)) pty.write(ch, data);
2511
+ if (!claude.write(ch, data) && !tcp.write(ch, data)) pty.write(ch, data);
2214
2512
  },
2215
2513
  onConnect: healHooks,
2216
2514
  onDisconnect: () => {
2217
2515
  pty.closeAll();
2218
2516
  claude.closeAll();
2517
+ tcp.closeAll();
2219
2518
  },
2220
2519
  log: opts.log
2221
2520
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termhub/agent",
3
- "version": "0.4.3",
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,