@termhub/agent 0.2.4 → 0.4.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.
- package/dist/cli.js +339 -29
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -56,8 +56,12 @@ var RPC = {
|
|
|
56
56
|
"tmux.capture": def(z.object({ session: sessionName, lines: z.number().int().min(1).max(5e3) }), z.object({ text: z.string() })),
|
|
57
57
|
/** Idempotent: creates the detached session in `cwd` when it is missing. `created` says whether it had to. */
|
|
58
58
|
"tmux.ensure": def(z.object({ session: sessionName, cwd: machinePath }), z.object({ created: z.boolean() }), 1e4),
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Types `text` literally, then (with `enter`) presses Enter on its own after a short pause.
|
|
61
|
+
* `paste`: deliver `text` as a tmux buffer paste instead of typed keystrokes, so a TUI reads
|
|
62
|
+
* an embedded newline as part of the pasted text rather than as Enter (since agent 0.3.0).
|
|
63
|
+
*/
|
|
64
|
+
"tmux.sendText": def(z.object({ session: sessionName, text: z.string().max(TEXT_MAX_CHARS), enter: z.boolean(), paste: z.boolean().optional() }), z.object({ sent: z.literal(true) }), 1e4),
|
|
61
65
|
"tmux.sendKey": def(z.object({ session: sessionName, key: tmuxKey }), z.object({ sent: z.literal(true) }), 1e4),
|
|
62
66
|
"tools.detect": def(z.object({}), z.object({ os: z.string().nullable(), tools: z.array(z.string().max(32)) })),
|
|
63
67
|
"hw.probe": def(z.object({}), z.object({ stdout: z.string() }), 15e3),
|
|
@@ -89,6 +93,8 @@ var PROTOCOL_VERSION = 1;
|
|
|
89
93
|
var CLOSE = { UNAUTHORIZED: 4401, CONFLICT: 4409, VIOLATION: 1008 };
|
|
90
94
|
var channel = z2.number().int().min(1).max(4294967295);
|
|
91
95
|
var rpcId = z2.string().min(1).max(64);
|
|
96
|
+
var closedReason = z2.enum(["cli_missing", "run_failed", "killed", "missing_session", "cli_rejected"]);
|
|
97
|
+
var CAPABILITY_CLAUDE = "claude";
|
|
92
98
|
var helloMessage = z2.object({
|
|
93
99
|
type: z2.literal("hello"),
|
|
94
100
|
protocol: z2.number().int().min(1),
|
|
@@ -101,19 +107,44 @@ var helloMessage = z2.object({
|
|
|
101
107
|
/** `status`/`doctor` reachability check: the server validates the token and protocol as
|
|
102
108
|
* usual but does not attach — it answers by closing 1000 `probe-ok`, so a probe never
|
|
103
109
|
* replaces the machine's live session. */
|
|
104
|
-
probe: z2.boolean().optional()
|
|
110
|
+
probe: z2.boolean().optional(),
|
|
111
|
+
// What this agent understands beyond the baseline `pty` channel (e.g. `claude`, for a
|
|
112
|
+
// headless Claude run). Optional, defaulting to [] — every agent already in the field sent
|
|
113
|
+
// `hello` before this field existed, so it must keep parsing, reading as "no capabilities"
|
|
114
|
+
// rather than failing validation or coming back `undefined`. The server decides whether it
|
|
115
|
+
// may open a `claude` channel from this list, before it ever tries.
|
|
116
|
+
capabilities: z2.array(z2.string().max(64)).max(32).default([])
|
|
105
117
|
});
|
|
106
118
|
var agentMessage = z2.discriminatedUnion("type", [
|
|
107
119
|
helloMessage,
|
|
108
120
|
z2.object({ type: z2.literal("rpc_result"), id: rpcId, ok: z2.boolean(), result: z2.unknown().optional(), error: rpcErrorSchema.optional() }),
|
|
109
121
|
z2.object({ type: z2.literal("opened"), ch: channel }),
|
|
110
122
|
z2.object({ type: z2.literal("open_error"), ch: channel, error: rpcErrorSchema }),
|
|
111
|
-
|
|
123
|
+
// `reason` is absent for a clean exit and for every pty close today; a headless claude run
|
|
124
|
+
// (task 3) sets it so the server can render the same failure the same way every time.
|
|
125
|
+
z2.object({ type: z2.literal("closed"), ch: channel, code: z2.number().int().nullable(), reason: closedReason.optional() })
|
|
112
126
|
]);
|
|
113
127
|
var ptyOpenParams = z2.object({ session: sessionName, cwd: machinePath, cols: z2.number().int().min(2).max(500), rows: z2.number().int().min(2).max(200) });
|
|
114
|
-
var
|
|
128
|
+
var claudeOpenParams = z2.object({
|
|
129
|
+
session_id: z2.string(),
|
|
130
|
+
resume: z2.boolean(),
|
|
131
|
+
// null selects the machine's default Claude account; a path names a specific one.
|
|
132
|
+
config_dir: z2.string().nullable(),
|
|
133
|
+
mcp_url: z2.string(),
|
|
134
|
+
// The user's own token for this run, minted by the server per run and revoked the moment
|
|
135
|
+
// the next one starts. It travels here, in the frame that opens the channel, rather than
|
|
136
|
+
// over the channel itself, because the channel doesn't exist yet when the agent needs it —
|
|
137
|
+
// opening a channel to the user's own machine is exactly the trust boundary this grant
|
|
138
|
+
// belongs at, so there is no separate, later place to hand it over.
|
|
139
|
+
token: z2.string(),
|
|
140
|
+
model: z2.string().nullable().optional()
|
|
141
|
+
});
|
|
142
|
+
var openPty = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("pty"), params: ptyOpenParams });
|
|
143
|
+
var openClaude = z2.object({ type: z2.literal("open"), ch: channel, kind: z2.literal("claude"), params: claudeOpenParams });
|
|
144
|
+
var serverMessage = z2.union([
|
|
115
145
|
z2.object({ type: z2.literal("rpc"), id: rpcId, method: rpcMethod, params: z2.unknown() }),
|
|
116
|
-
|
|
146
|
+
openPty,
|
|
147
|
+
openClaude,
|
|
117
148
|
z2.object({ type: z2.literal("resize"), ch: channel, cols: z2.number().int().min(2).max(500), rows: z2.number().int().min(2).max(200) }),
|
|
118
149
|
z2.object({ type: z2.literal("close"), ch: channel })
|
|
119
150
|
]);
|
|
@@ -762,8 +793,8 @@ import { execFile } from "child_process";
|
|
|
762
793
|
import os2 from "os";
|
|
763
794
|
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
764
795
|
var RpcFailure = class extends Error {
|
|
765
|
-
constructor(code,
|
|
766
|
-
super(
|
|
796
|
+
constructor(code, message2, path12) {
|
|
797
|
+
super(message2);
|
|
767
798
|
this.code = code;
|
|
768
799
|
this.path = path12;
|
|
769
800
|
}
|
|
@@ -859,8 +890,8 @@ async function install(params, home = os3.homedir()) {
|
|
|
859
890
|
try {
|
|
860
891
|
merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath) });
|
|
861
892
|
} catch (err) {
|
|
862
|
-
const
|
|
863
|
-
throw new RpcFailure("failed",
|
|
893
|
+
const message2 = err instanceof SyntaxError || err instanceof Error && err.message.includes("n\xE3o \xE9 um objeto JSON") ? `${target.shown} n\xE3o \xE9 JSON v\xE1lido` : err instanceof Error ? err.message : String(err);
|
|
894
|
+
throw new RpcFailure("failed", message2, relPath(target.shown));
|
|
864
895
|
}
|
|
865
896
|
}
|
|
866
897
|
const hasCodex = await isDir2(path3.join(home, CODEX_DIR_REL));
|
|
@@ -942,6 +973,252 @@ async function uninstall(params, home = os3.homedir()) {
|
|
|
942
973
|
return { removed: true };
|
|
943
974
|
}
|
|
944
975
|
|
|
976
|
+
// ../../packages/claude-cli/dist/index.js
|
|
977
|
+
var DISALLOWED_TOOLS = "Bash,Read,Write,Edit,WebFetch,WebSearch";
|
|
978
|
+
function buildClaudeArgs(spec) {
|
|
979
|
+
return [
|
|
980
|
+
"-p",
|
|
981
|
+
// Exactly one of the two, never both: the CLI answers "--session-id can only be used with
|
|
982
|
+
// --continue or --resume if --fork-session is also specified" and exits 1 before doing any
|
|
983
|
+
// work, which broke every message after the first. --session-id is how the server names a new
|
|
984
|
+
// session; --resume is how it continues one it already named.
|
|
985
|
+
...spec.resume ? ["--resume", spec.session_id] : ["--session-id", spec.session_id],
|
|
986
|
+
"--output-format",
|
|
987
|
+
"stream-json",
|
|
988
|
+
// required by the CLI: with --print, --output-format=stream-json refuses to run without it
|
|
989
|
+
// ("Error: When using --print, --output-format=stream-json requires --verbose"). It only
|
|
990
|
+
// changes what the CLI writes to stdout, never logging the prompt.
|
|
991
|
+
"--verbose",
|
|
992
|
+
"--include-partial-messages",
|
|
993
|
+
"--mcp-config",
|
|
994
|
+
spec.mcp_config_path,
|
|
995
|
+
"--strict-mcp-config",
|
|
996
|
+
"--allowed-tools",
|
|
997
|
+
"mcp__termhub__*",
|
|
998
|
+
"--disallowed-tools",
|
|
999
|
+
DISALLOWED_TOOLS,
|
|
1000
|
+
...spec.model ? ["--model", spec.model] : []
|
|
1001
|
+
];
|
|
1002
|
+
}
|
|
1003
|
+
function mcpConfig(url, token) {
|
|
1004
|
+
return JSON.stringify({ mcpServers: { termhub: { type: "http", url, headers: { Authorization: `Bearer ${token}` } } } });
|
|
1005
|
+
}
|
|
1006
|
+
function classifyFailure(stderr) {
|
|
1007
|
+
if (/No conversation found/i.test(stderr))
|
|
1008
|
+
return "missing_session";
|
|
1009
|
+
if (/^Error: --/m.test(stderr))
|
|
1010
|
+
return "cli_rejected";
|
|
1011
|
+
return "run_failed";
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/claude/run.ts
|
|
1015
|
+
import { spawn } from "child_process";
|
|
1016
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
1017
|
+
import { tmpdir } from "os";
|
|
1018
|
+
import { join } from "path";
|
|
1019
|
+
var DEFAULT_TIMEOUT_MS2 = 10 * 60 * 1e3;
|
|
1020
|
+
var KILL_GRACE_MS = 2e3;
|
|
1021
|
+
var PROMPT_TIMEOUT_MS = 3e4;
|
|
1022
|
+
var MAX_LINE_BYTES = MAX_FRAME - HEADER_BYTES - 1;
|
|
1023
|
+
var STDERR_TAIL_BYTES = 4e3;
|
|
1024
|
+
var CLI = "claude";
|
|
1025
|
+
function message(err) {
|
|
1026
|
+
return err instanceof Error ? err.message : String(err);
|
|
1027
|
+
}
|
|
1028
|
+
function createClaudeManager(deps) {
|
|
1029
|
+
const runs = /* @__PURE__ */ new Map();
|
|
1030
|
+
return {
|
|
1031
|
+
// Synchronous from end to end (the promise is for symmetry with the PTY manager), so no
|
|
1032
|
+
// `close` for this channel can interleave before the run is in `runs` and killable.
|
|
1033
|
+
async open(ch, params, socket) {
|
|
1034
|
+
if (runs.has(ch)) {
|
|
1035
|
+
socket.sendControl({ type: "open_error", ch, error: { code: "invalid", message: "channel in use" } });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
let dir;
|
|
1039
|
+
let mcpConfigPath;
|
|
1040
|
+
try {
|
|
1041
|
+
dir = mkdtempSync(join(deps.tmpDir ?? tmpdir(), "termhub-claude-"));
|
|
1042
|
+
mcpConfigPath = join(dir, "termhub-mcp.json");
|
|
1043
|
+
writeFileSync(mcpConfigPath, mcpConfig(params.mcp_url, params.token), { mode: 384 });
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
deps.log("claude run could not prepare its mcp config", { ch, error: message(err) });
|
|
1046
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
1047
|
+
socket.sendControl({ type: "open_error", ch, error: { code: "internal", message: "failed to prepare the claude run" } });
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const runDir = dir;
|
|
1051
|
+
socket.sendControl({ type: "opened", ch });
|
|
1052
|
+
deps.log("claude run starting", { ch, resume: params.resume, model: params.model ?? null });
|
|
1053
|
+
const env = { ...deps.env ?? agentEnv() };
|
|
1054
|
+
if (params.config_dir === null) delete env.CLAUDE_CONFIG_DIR;
|
|
1055
|
+
else env.CLAUDE_CONFIG_DIR = params.config_dir;
|
|
1056
|
+
const args = buildClaudeArgs({
|
|
1057
|
+
session_id: params.session_id,
|
|
1058
|
+
resume: params.resume,
|
|
1059
|
+
mcp_config_path: mcpConfigPath,
|
|
1060
|
+
model: params.model ?? null
|
|
1061
|
+
});
|
|
1062
|
+
let child;
|
|
1063
|
+
try {
|
|
1064
|
+
child = spawn(CLI, args, { env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
deps.log("claude run could not be started", { ch, cli: CLI, error: message(err) });
|
|
1067
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
1068
|
+
socket.sendControl({ type: "closed", ch, code: null, reason: "run_failed" });
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
let settled = false;
|
|
1072
|
+
let escalation;
|
|
1073
|
+
let stderr = "";
|
|
1074
|
+
let stderrBytes = 0;
|
|
1075
|
+
let buffer = "";
|
|
1076
|
+
let overlong = false;
|
|
1077
|
+
let droppedLines = 0;
|
|
1078
|
+
function signalRun(signal) {
|
|
1079
|
+
try {
|
|
1080
|
+
if (child.pid) process.kill(-child.pid, signal);
|
|
1081
|
+
else child.kill(signal);
|
|
1082
|
+
} catch {
|
|
1083
|
+
try {
|
|
1084
|
+
child.kill(signal);
|
|
1085
|
+
} catch {
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
function killRun(hard = false) {
|
|
1090
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
1091
|
+
signalRun("SIGTERM");
|
|
1092
|
+
if (hard) {
|
|
1093
|
+
signalRun("SIGKILL");
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (escalation) return;
|
|
1097
|
+
escalation = setTimeout(() => signalRun("SIGKILL"), KILL_GRACE_MS);
|
|
1098
|
+
escalation.unref();
|
|
1099
|
+
}
|
|
1100
|
+
function settle(code, reason, notify = true) {
|
|
1101
|
+
if (settled) return;
|
|
1102
|
+
settled = true;
|
|
1103
|
+
clearTimeout(timer);
|
|
1104
|
+
clearTimeout(promptTimer);
|
|
1105
|
+
if (runs.get(ch) === run2) runs.delete(ch);
|
|
1106
|
+
try {
|
|
1107
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
deps.log("claude run dir could not be removed", { ch, error: message(err) });
|
|
1110
|
+
}
|
|
1111
|
+
if (!notify) return;
|
|
1112
|
+
try {
|
|
1113
|
+
socket.sendControl(reason ? { type: "closed", ch, code, reason } : { type: "closed", ch, code });
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
deps.log("claude closed send failed", { ch, error: message(err) });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function emitLine(line) {
|
|
1119
|
+
if (!line.trim()) return;
|
|
1120
|
+
if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) {
|
|
1121
|
+
droppedLines += 1;
|
|
1122
|
+
deps.log("claude stdout line too large to frame, dropped", { ch, bytes: Buffer.byteLength(line, "utf8") });
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
sendLine(line);
|
|
1126
|
+
}
|
|
1127
|
+
function sendLine(line) {
|
|
1128
|
+
if (settled) return;
|
|
1129
|
+
try {
|
|
1130
|
+
socket.sendStream(ch, Buffer.from(`${line}
|
|
1131
|
+
`, "utf8"));
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
deps.log("claude stream send failed", { ch, error: message(err) });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const timer = setTimeout(() => {
|
|
1137
|
+
deps.log("claude run timed out", { ch, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2 });
|
|
1138
|
+
killRun();
|
|
1139
|
+
}, deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2);
|
|
1140
|
+
timer.unref();
|
|
1141
|
+
const promptTimer = setTimeout(() => {
|
|
1142
|
+
deps.log("claude run got no prompt", { ch, promptTimeoutMs: deps.promptTimeoutMs ?? PROMPT_TIMEOUT_MS });
|
|
1143
|
+
killRun();
|
|
1144
|
+
settle(null, "run_failed");
|
|
1145
|
+
}, deps.promptTimeoutMs ?? PROMPT_TIMEOUT_MS);
|
|
1146
|
+
promptTimer.unref();
|
|
1147
|
+
const run2 = {
|
|
1148
|
+
child,
|
|
1149
|
+
promptSent: false,
|
|
1150
|
+
kill: killRun,
|
|
1151
|
+
promptArrived: () => clearTimeout(promptTimer),
|
|
1152
|
+
settle
|
|
1153
|
+
};
|
|
1154
|
+
runs.set(ch, run2);
|
|
1155
|
+
child.stdin?.on("error", () => {
|
|
1156
|
+
});
|
|
1157
|
+
child.stdout?.setEncoding("utf8");
|
|
1158
|
+
child.stdout?.on("data", (chunk) => {
|
|
1159
|
+
buffer += chunk;
|
|
1160
|
+
for (; ; ) {
|
|
1161
|
+
const nl = buffer.indexOf("\n");
|
|
1162
|
+
if (nl === -1) break;
|
|
1163
|
+
const line = buffer.slice(0, nl);
|
|
1164
|
+
buffer = buffer.slice(nl + 1);
|
|
1165
|
+
if (overlong) overlong = false;
|
|
1166
|
+
else emitLine(line);
|
|
1167
|
+
}
|
|
1168
|
+
if (Buffer.byteLength(buffer, "utf8") <= MAX_LINE_BYTES) return;
|
|
1169
|
+
if (!overlong) {
|
|
1170
|
+
overlong = true;
|
|
1171
|
+
droppedLines += 1;
|
|
1172
|
+
deps.log("claude stdout line too large to frame, dropped", { ch, bytes: Buffer.byteLength(buffer, "utf8") });
|
|
1173
|
+
}
|
|
1174
|
+
buffer = "";
|
|
1175
|
+
});
|
|
1176
|
+
child.stderr?.on("data", (data) => {
|
|
1177
|
+
stderrBytes += data.length;
|
|
1178
|
+
stderr = (stderr + data.toString("utf8")).slice(-STDERR_TAIL_BYTES);
|
|
1179
|
+
});
|
|
1180
|
+
child.on("error", (err) => {
|
|
1181
|
+
const missing = err.code === "ENOENT";
|
|
1182
|
+
deps.log(missing ? `claude cli not found on this machine (${CLI})` : "claude run failed to start", { ch, cli: CLI, error: err.message });
|
|
1183
|
+
killRun();
|
|
1184
|
+
settle(null, missing ? "cli_missing" : "run_failed");
|
|
1185
|
+
});
|
|
1186
|
+
child.on("close", (code) => {
|
|
1187
|
+
if (escalation) clearTimeout(escalation);
|
|
1188
|
+
if (!overlong) emitLine(buffer);
|
|
1189
|
+
buffer = "";
|
|
1190
|
+
deps.log("claude run ended", { ch, code, stderrBytes, droppedLines });
|
|
1191
|
+
if (code === 0) settle(0);
|
|
1192
|
+
else settle(code, classifyFailure(stderr));
|
|
1193
|
+
});
|
|
1194
|
+
},
|
|
1195
|
+
write(ch, data) {
|
|
1196
|
+
const run2 = runs.get(ch);
|
|
1197
|
+
if (!run2) return false;
|
|
1198
|
+
if (run2.promptSent) {
|
|
1199
|
+
deps.log("extra data on a claude channel ignored", { ch, bytes: data.length });
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
run2.promptSent = true;
|
|
1203
|
+
run2.promptArrived();
|
|
1204
|
+
run2.child.stdin?.end(data);
|
|
1205
|
+
return true;
|
|
1206
|
+
},
|
|
1207
|
+
close(ch) {
|
|
1208
|
+
const run2 = runs.get(ch);
|
|
1209
|
+
if (!run2) return;
|
|
1210
|
+
run2.kill();
|
|
1211
|
+
run2.settle(null, "killed");
|
|
1212
|
+
},
|
|
1213
|
+
closeAll() {
|
|
1214
|
+
for (const run2 of [...runs.values()]) {
|
|
1215
|
+
run2.kill(true);
|
|
1216
|
+
run2.settle(null, "killed", false);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
945
1222
|
// src/dispatch.ts
|
|
946
1223
|
async function handleRpc(msg, socket, handlers2, log2) {
|
|
947
1224
|
const method = msg.method;
|
|
@@ -982,16 +1259,19 @@ function createDispatcher(deps) {
|
|
|
982
1259
|
case "rpc":
|
|
983
1260
|
void handleRpc(msg, socket, deps.handlers, deps.log);
|
|
984
1261
|
break;
|
|
985
|
-
case "open":
|
|
986
|
-
deps.
|
|
987
|
-
|
|
1262
|
+
case "open": {
|
|
1263
|
+
const opened = msg.kind === "claude" ? deps.claude.open(msg.ch, msg.params, socket) : deps.pty.open(msg.ch, msg.params, socket);
|
|
1264
|
+
opened.catch((err) => {
|
|
1265
|
+
deps.log(`${msg.kind}.open rejected unexpectedly`, { ch: msg.ch, error: err instanceof Error ? err.message : String(err) });
|
|
988
1266
|
});
|
|
989
1267
|
break;
|
|
1268
|
+
}
|
|
990
1269
|
case "resize":
|
|
991
1270
|
deps.pty.resize(msg.ch, msg.cols, msg.rows);
|
|
992
1271
|
break;
|
|
993
1272
|
case "close":
|
|
994
1273
|
deps.pty.close(msg.ch);
|
|
1274
|
+
deps.claude.close(msg.ch);
|
|
995
1275
|
break;
|
|
996
1276
|
}
|
|
997
1277
|
};
|
|
@@ -1056,8 +1336,8 @@ function isSpawnHelperFailure(err) {
|
|
|
1056
1336
|
function isEnoent2(err) {
|
|
1057
1337
|
const e = err;
|
|
1058
1338
|
if (e?.code === "ENOENT") return true;
|
|
1059
|
-
const
|
|
1060
|
-
return /ENOENT/.test(
|
|
1339
|
+
const message2 = e instanceof Error ? e.message : String(err);
|
|
1340
|
+
return /ENOENT/.test(message2);
|
|
1061
1341
|
}
|
|
1062
1342
|
function createPtyManager(deps) {
|
|
1063
1343
|
const procs = /* @__PURE__ */ new Map();
|
|
@@ -1088,8 +1368,8 @@ function createPtyManager(deps) {
|
|
|
1088
1368
|
TERMHUB_TAB_ID: params.session,
|
|
1089
1369
|
TERMHUB_SESSION: params.session
|
|
1090
1370
|
};
|
|
1091
|
-
const
|
|
1092
|
-
const spawnTmux = () =>
|
|
1371
|
+
const spawn2 = await resolveSpawn();
|
|
1372
|
+
const spawnTmux = () => spawn2(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env });
|
|
1093
1373
|
try {
|
|
1094
1374
|
proc = spawnTmux();
|
|
1095
1375
|
} catch (err) {
|
|
@@ -1238,6 +1518,7 @@ async function pasteFile(params) {
|
|
|
1238
1518
|
}
|
|
1239
1519
|
|
|
1240
1520
|
// src/rpc/tmux.ts
|
|
1521
|
+
import { randomUUID } from "crypto";
|
|
1241
1522
|
var ENTER_PAUSE_MS = 300;
|
|
1242
1523
|
function processFailure2(r) {
|
|
1243
1524
|
if (r.timedOut) return new RpcFailure("timeout", "tmux timed out");
|
|
@@ -1279,12 +1560,31 @@ async function ensure(params) {
|
|
|
1279
1560
|
if (made.code !== 0) throw new RpcFailure("failed", why(made.stderr, "tmux new-session falhou"), params.cwd);
|
|
1280
1561
|
return { created: true };
|
|
1281
1562
|
}
|
|
1563
|
+
async function pasteText(session, text) {
|
|
1564
|
+
const bufferName = `termhub-paste-${randomUUID()}`;
|
|
1565
|
+
try {
|
|
1566
|
+
const loaded = await run(tmuxPath(), ["load-buffer", "-b", bufferName, "-"], { input: Buffer.from(text, "utf8") });
|
|
1567
|
+
const loadFailure = processFailure2(loaded);
|
|
1568
|
+
if (loadFailure) throw loadFailure;
|
|
1569
|
+
if (loaded.code !== 0) throw new RpcFailure("internal", why(loaded.stderr, "tmux load-buffer failed"));
|
|
1570
|
+
const pasted = await run(tmuxPath(), ["paste-buffer", "-p", "-d", "-b", bufferName, "-t", pane(session)]);
|
|
1571
|
+
const pasteFailure = processFailure2(pasted);
|
|
1572
|
+
if (pasteFailure) throw pasteFailure;
|
|
1573
|
+
if (pasted.code !== 0) throw new RpcFailure("notfound", why(pasted.stderr, "session not found"));
|
|
1574
|
+
} finally {
|
|
1575
|
+
await run(tmuxPath(), ["delete-buffer", "-b", bufferName]);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1282
1578
|
async function sendText(params) {
|
|
1283
1579
|
if (params.text) {
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1580
|
+
if (params.paste) {
|
|
1581
|
+
await pasteText(params.session, params.text);
|
|
1582
|
+
} else {
|
|
1583
|
+
const typed = await run(tmuxPath(), ["send-keys", "-t", pane(params.session), "-l", "--", params.text]);
|
|
1584
|
+
const failure = processFailure2(typed);
|
|
1585
|
+
if (failure) throw failure;
|
|
1586
|
+
if (typed.code !== 0) throw new RpcFailure("notfound", why(typed.stderr, "session not found"));
|
|
1587
|
+
}
|
|
1288
1588
|
if (params.enter) await new Promise((r) => setTimeout(r, ENTER_PAUSE_MS));
|
|
1289
1589
|
}
|
|
1290
1590
|
if (params.enter) {
|
|
@@ -1533,7 +1833,7 @@ async function status3() {
|
|
|
1533
1833
|
}
|
|
1534
1834
|
|
|
1535
1835
|
// src/version.ts
|
|
1536
|
-
var AGENT_VERSION = "0.
|
|
1836
|
+
var AGENT_VERSION = "0.4.0";
|
|
1537
1837
|
|
|
1538
1838
|
// src/rpc/update.ts
|
|
1539
1839
|
var PACKAGE = "@termhub/agent";
|
|
@@ -1622,6 +1922,7 @@ function detectOs(platform = process.platform) {
|
|
|
1622
1922
|
if (platform === "linux") return "linux";
|
|
1623
1923
|
return null;
|
|
1624
1924
|
}
|
|
1925
|
+
var CAPABILITIES = [CAPABILITY_CLAUDE];
|
|
1625
1926
|
async function buildHello(osName) {
|
|
1626
1927
|
let tools = [];
|
|
1627
1928
|
try {
|
|
@@ -1636,7 +1937,8 @@ async function buildHello(osName) {
|
|
|
1636
1937
|
arch: process.arch,
|
|
1637
1938
|
hostname: os6.hostname(),
|
|
1638
1939
|
tmux: tools.includes("tmux"),
|
|
1639
|
-
tools
|
|
1940
|
+
tools,
|
|
1941
|
+
capabilities: CAPABILITIES
|
|
1640
1942
|
};
|
|
1641
1943
|
}
|
|
1642
1944
|
var REVOKED_MESSAGE = "Token inv\xE1lido ou revogado";
|
|
@@ -1654,7 +1956,7 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
|
|
|
1654
1956
|
{
|
|
1655
1957
|
url: config.url,
|
|
1656
1958
|
token: config.token,
|
|
1657
|
-
hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], probe: true },
|
|
1959
|
+
hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], capabilities: CAPABILITIES, probe: true },
|
|
1658
1960
|
onServerMessage: () => {
|
|
1659
1961
|
},
|
|
1660
1962
|
onStream: () => {
|
|
@@ -1680,8 +1982,8 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
|
|
|
1680
1982
|
controller.abort();
|
|
1681
1983
|
}
|
|
1682
1984
|
}
|
|
1683
|
-
async function exitWithoutRestart(
|
|
1684
|
-
console.error(
|
|
1985
|
+
async function exitWithoutRestart(message2) {
|
|
1986
|
+
console.error(message2);
|
|
1685
1987
|
await stopRestartLoop();
|
|
1686
1988
|
process.exit(78);
|
|
1687
1989
|
}
|
|
@@ -1696,7 +1998,8 @@ async function runAgent(config, opts) {
|
|
|
1696
1998
|
if (helper.repaired) opts.log("spawn-helper exec bit repaired", { path: helper.path });
|
|
1697
1999
|
else if (!helper.executable) opts.log("spawn-helper is not executable and could not be fixed", { path: helper.path, error: helper.error });
|
|
1698
2000
|
const pty = createPtyManager({ log: opts.log });
|
|
1699
|
-
const
|
|
2001
|
+
const claude = createClaudeManager({ log: opts.log });
|
|
2002
|
+
const dispatch = createDispatcher({ handlers, pty, claude, log: opts.log });
|
|
1700
2003
|
const healHooks = () => {
|
|
1701
2004
|
heal().then((dirs) => {
|
|
1702
2005
|
if (dirs.length) opts.log("monitor hooks repaired", { dirs: dirs.length });
|
|
@@ -1710,9 +2013,16 @@ async function runAgent(config, opts) {
|
|
|
1710
2013
|
token: config.token,
|
|
1711
2014
|
hello,
|
|
1712
2015
|
onServerMessage: dispatch,
|
|
1713
|
-
|
|
2016
|
+
// A frame belongs to whichever manager holds that channel: the claude one says so, and
|
|
2017
|
+
// anything it does not own is a terminal's.
|
|
2018
|
+
onStream: (ch, data) => {
|
|
2019
|
+
if (!claude.write(ch, data)) pty.write(ch, data);
|
|
2020
|
+
},
|
|
1714
2021
|
onConnect: healHooks,
|
|
1715
|
-
onDisconnect: () =>
|
|
2022
|
+
onDisconnect: () => {
|
|
2023
|
+
pty.closeAll();
|
|
2024
|
+
claude.closeAll();
|
|
2025
|
+
},
|
|
1716
2026
|
log: opts.log
|
|
1717
2027
|
},
|
|
1718
2028
|
opts.signal
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@termhub/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@termhub/agent-protocol": "*",
|
|
37
|
+
"@termhub/claude-cli": "*",
|
|
37
38
|
"@termhub/machine-ops": "*",
|
|
38
39
|
"@types/node": "^22.10.5",
|
|
39
40
|
"@types/ws": "^8.5.13",
|