@termhub/agent 0.3.0 → 0.4.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.
- package/dist/cli.js +345 -26
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -93,6 +93,8 @@ var PROTOCOL_VERSION = 1;
|
|
|
93
93
|
var CLOSE = { UNAUTHORIZED: 4401, CONFLICT: 4409, VIOLATION: 1008 };
|
|
94
94
|
var channel = z2.number().int().min(1).max(4294967295);
|
|
95
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";
|
|
96
98
|
var helloMessage = z2.object({
|
|
97
99
|
type: z2.literal("hello"),
|
|
98
100
|
protocol: z2.number().int().min(1),
|
|
@@ -105,19 +107,44 @@ var helloMessage = z2.object({
|
|
|
105
107
|
/** `status`/`doctor` reachability check: the server validates the token and protocol as
|
|
106
108
|
* usual but does not attach — it answers by closing 1000 `probe-ok`, so a probe never
|
|
107
109
|
* replaces the machine's live session. */
|
|
108
|
-
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([])
|
|
109
117
|
});
|
|
110
118
|
var agentMessage = z2.discriminatedUnion("type", [
|
|
111
119
|
helloMessage,
|
|
112
120
|
z2.object({ type: z2.literal("rpc_result"), id: rpcId, ok: z2.boolean(), result: z2.unknown().optional(), error: rpcErrorSchema.optional() }),
|
|
113
121
|
z2.object({ type: z2.literal("opened"), ch: channel }),
|
|
114
122
|
z2.object({ type: z2.literal("open_error"), ch: channel, error: rpcErrorSchema }),
|
|
115
|
-
|
|
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() })
|
|
116
126
|
]);
|
|
117
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) });
|
|
118
|
-
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([
|
|
119
145
|
z2.object({ type: z2.literal("rpc"), id: rpcId, method: rpcMethod, params: z2.unknown() }),
|
|
120
|
-
|
|
146
|
+
openPty,
|
|
147
|
+
openClaude,
|
|
121
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) }),
|
|
122
149
|
z2.object({ type: z2.literal("close"), ch: channel })
|
|
123
150
|
]);
|
|
@@ -584,7 +611,7 @@ function claudeConfigDirs(accountDirs) {
|
|
|
584
611
|
function expandHome(dir, home) {
|
|
585
612
|
return dir.startsWith("~/") ? `${home}/${dir.slice(2)}` : dir;
|
|
586
613
|
}
|
|
587
|
-
var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Notification", "Stop", "SessionEnd"];
|
|
614
|
+
var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"];
|
|
588
615
|
var HOOK_SCRIPT = `#!/bin/sh
|
|
589
616
|
# termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex hook events to
|
|
590
617
|
# termhub tagged with the tmux session, so the app knows which tab is waiting for you.
|
|
@@ -599,6 +626,34 @@ SESSION=$(tmux display-message -p -t "$TMUX_PANE" '#{session_name}' 2>/dev/null)
|
|
|
599
626
|
[ -n "$SESSION" ] || exit 0
|
|
600
627
|
if [ "$TOOL" = codex ]; then EVENT="$2"; else EVENT=$(cat 2>/dev/null); fi
|
|
601
628
|
[ -n "$EVENT" ] || EVENT='{}'
|
|
629
|
+
# Tool calls: only the tool's name travels (never its input), and only when it changed since the
|
|
630
|
+
# last one for this session \u2014 twenty edits in a row are one request. The marker is per tmux
|
|
631
|
+
# session, under TMPDIR, with the session name reduced to filename-safe characters.
|
|
632
|
+
MARK="\${TMPDIR:-/tmp}/termhub-hook-$(printf '%s' "$SESSION" | tr -c 'A-Za-z0-9_-' '_')"
|
|
633
|
+
case "$EVENT" in
|
|
634
|
+
*'"hook_event_name":"PreToolUse"'*|*'"hook_event_name": "PreToolUse"'*)
|
|
635
|
+
# The event's own tool name is the FIRST "tool_name" of the payload (Claude Code serialises it
|
|
636
|
+
# before tool_input), so the shortest prefix is cut \u2014 a "tool_name" nested in a tool's input
|
|
637
|
+
# must not win. Only letters, digits, "_", "." and "-" are posted (a bare Claude Code tool name,
|
|
638
|
+
# or an MCP tool name such as mcp__claude-in-chrome__click): anything else (a number, a name with
|
|
639
|
+
# a quote or a backslash) is dropped rather than sent \u2014 those are the only characters the
|
|
640
|
+
# hand-built JSON body below cannot survive as-is.
|
|
641
|
+
REST=\${EVENT#*'"tool_name"'}
|
|
642
|
+
[ "$REST" != "$EVENT" ] || exit 0
|
|
643
|
+
REST=\${REST#*'"'}
|
|
644
|
+
NAME=\${REST%%'"'*}
|
|
645
|
+
case "$NAME" in '' | *[!A-Za-z0-9_.-]*) exit 0 ;; esac
|
|
646
|
+
[ "$(cat "$MARK" 2>/dev/null)" = "$NAME" ] && exit 0
|
|
647
|
+
printf '%s' "$NAME" 2>/dev/null > "$MARK"
|
|
648
|
+
EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
|
|
649
|
+
;;
|
|
650
|
+
# A new turn starts fresh, and so does an answered notification: a permission prompt takes the tab
|
|
651
|
+
# out of working, and the tool the person approves is the same one that set the marker, so without
|
|
652
|
+
# this reset the retry is suppressed and nothing says the tab is working again.
|
|
653
|
+
*'"hook_event_name":"SessionStart"'*|*'"hook_event_name": "SessionStart"'*|*'"hook_event_name":"UserPromptSubmit"'*|*'"hook_event_name": "UserPromptSubmit"'*|*'"hook_event_name":"Notification"'*|*'"hook_event_name": "Notification"'*)
|
|
654
|
+
rm -f "$MARK"
|
|
655
|
+
;;
|
|
656
|
+
esac
|
|
602
657
|
{ printf '{"tool":"%s","session":"%s","event":' "$TOOL" "$SESSION"; printf '%s' "$EVENT"; printf '}'; } |
|
|
603
658
|
curl -s -m 5 -o /dev/null -X POST "$TERMHUB_HOOK_URL" \\
|
|
604
659
|
-H "authorization: Bearer $TERMHUB_HOOK_TOKEN" -H 'content-type: application/json' --data-binary @- >/dev/null 2>&1 &
|
|
@@ -622,7 +677,10 @@ function mergeClaudeSettings(current, scriptPath) {
|
|
|
622
677
|
for (const event of CLAUDE_HOOK_EVENTS) {
|
|
623
678
|
const list3 = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
624
679
|
const others = list3.filter((e) => !isOurs(e));
|
|
625
|
-
|
|
680
|
+
const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
|
|
681
|
+
if (event === "PreToolUse")
|
|
682
|
+
entry.matcher = "*";
|
|
683
|
+
others.push(entry);
|
|
626
684
|
hooks[event] = others;
|
|
627
685
|
}
|
|
628
686
|
settings.hooks = hooks;
|
|
@@ -766,8 +824,8 @@ import { execFile } from "child_process";
|
|
|
766
824
|
import os2 from "os";
|
|
767
825
|
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
768
826
|
var RpcFailure = class extends Error {
|
|
769
|
-
constructor(code,
|
|
770
|
-
super(
|
|
827
|
+
constructor(code, message2, path12) {
|
|
828
|
+
super(message2);
|
|
771
829
|
this.code = code;
|
|
772
830
|
this.path = path12;
|
|
773
831
|
}
|
|
@@ -863,8 +921,8 @@ async function install(params, home = os3.homedir()) {
|
|
|
863
921
|
try {
|
|
864
922
|
merged.push({ target, body: mergeClaudeSettings(await readOrEmpty2(target.file), scriptPath) });
|
|
865
923
|
} catch (err) {
|
|
866
|
-
const
|
|
867
|
-
throw new RpcFailure("failed",
|
|
924
|
+
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);
|
|
925
|
+
throw new RpcFailure("failed", message2, relPath(target.shown));
|
|
868
926
|
}
|
|
869
927
|
}
|
|
870
928
|
const hasCodex = await isDir2(path3.join(home, CODEX_DIR_REL));
|
|
@@ -898,7 +956,9 @@ async function install(params, home = os3.homedir()) {
|
|
|
898
956
|
async function heal(home = os3.homedir()) {
|
|
899
957
|
const scriptPath = path3.join(home, HOOK_SCRIPT_REL);
|
|
900
958
|
const env = await readOrEmpty2(path3.join(home, HOOK_ENV_REL));
|
|
901
|
-
|
|
959
|
+
const script = await readOrEmpty2(scriptPath);
|
|
960
|
+
if (!env.trim() || !script) return [];
|
|
961
|
+
if (script !== HOOK_SCRIPT) await writeAtomic(scriptPath, HOOK_SCRIPT, 493);
|
|
902
962
|
const healed = [];
|
|
903
963
|
for (const dir of await discoverClaudeDirs(home)) {
|
|
904
964
|
const file = path3.join(expandHome(dir, home), "settings.json");
|
|
@@ -946,6 +1006,252 @@ async function uninstall(params, home = os3.homedir()) {
|
|
|
946
1006
|
return { removed: true };
|
|
947
1007
|
}
|
|
948
1008
|
|
|
1009
|
+
// ../../packages/claude-cli/dist/index.js
|
|
1010
|
+
var DISALLOWED_TOOLS = "Bash,Read,Write,Edit,WebFetch,WebSearch";
|
|
1011
|
+
function buildClaudeArgs(spec) {
|
|
1012
|
+
return [
|
|
1013
|
+
"-p",
|
|
1014
|
+
// Exactly one of the two, never both: the CLI answers "--session-id can only be used with
|
|
1015
|
+
// --continue or --resume if --fork-session is also specified" and exits 1 before doing any
|
|
1016
|
+
// work, which broke every message after the first. --session-id is how the server names a new
|
|
1017
|
+
// session; --resume is how it continues one it already named.
|
|
1018
|
+
...spec.resume ? ["--resume", spec.session_id] : ["--session-id", spec.session_id],
|
|
1019
|
+
"--output-format",
|
|
1020
|
+
"stream-json",
|
|
1021
|
+
// required by the CLI: with --print, --output-format=stream-json refuses to run without it
|
|
1022
|
+
// ("Error: When using --print, --output-format=stream-json requires --verbose"). It only
|
|
1023
|
+
// changes what the CLI writes to stdout, never logging the prompt.
|
|
1024
|
+
"--verbose",
|
|
1025
|
+
"--include-partial-messages",
|
|
1026
|
+
"--mcp-config",
|
|
1027
|
+
spec.mcp_config_path,
|
|
1028
|
+
"--strict-mcp-config",
|
|
1029
|
+
"--allowed-tools",
|
|
1030
|
+
"mcp__termhub__*",
|
|
1031
|
+
"--disallowed-tools",
|
|
1032
|
+
DISALLOWED_TOOLS,
|
|
1033
|
+
...spec.model ? ["--model", spec.model] : []
|
|
1034
|
+
];
|
|
1035
|
+
}
|
|
1036
|
+
function mcpConfig(url, token) {
|
|
1037
|
+
return JSON.stringify({ mcpServers: { termhub: { type: "http", url, headers: { Authorization: `Bearer ${token}` } } } });
|
|
1038
|
+
}
|
|
1039
|
+
function classifyFailure(stderr) {
|
|
1040
|
+
if (/No conversation found/i.test(stderr))
|
|
1041
|
+
return "missing_session";
|
|
1042
|
+
if (/^Error: --/m.test(stderr))
|
|
1043
|
+
return "cli_rejected";
|
|
1044
|
+
return "run_failed";
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// src/claude/run.ts
|
|
1048
|
+
import { spawn } from "child_process";
|
|
1049
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
1050
|
+
import { tmpdir } from "os";
|
|
1051
|
+
import { join } from "path";
|
|
1052
|
+
var DEFAULT_TIMEOUT_MS2 = 10 * 60 * 1e3;
|
|
1053
|
+
var KILL_GRACE_MS = 2e3;
|
|
1054
|
+
var PROMPT_TIMEOUT_MS = 3e4;
|
|
1055
|
+
var MAX_LINE_BYTES = MAX_FRAME - HEADER_BYTES - 1;
|
|
1056
|
+
var STDERR_TAIL_BYTES = 4e3;
|
|
1057
|
+
var CLI = "claude";
|
|
1058
|
+
function message(err) {
|
|
1059
|
+
return err instanceof Error ? err.message : String(err);
|
|
1060
|
+
}
|
|
1061
|
+
function createClaudeManager(deps) {
|
|
1062
|
+
const runs = /* @__PURE__ */ new Map();
|
|
1063
|
+
return {
|
|
1064
|
+
// Synchronous from end to end (the promise is for symmetry with the PTY manager), so no
|
|
1065
|
+
// `close` for this channel can interleave before the run is in `runs` and killable.
|
|
1066
|
+
async open(ch, params, socket) {
|
|
1067
|
+
if (runs.has(ch)) {
|
|
1068
|
+
socket.sendControl({ type: "open_error", ch, error: { code: "invalid", message: "channel in use" } });
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
let dir;
|
|
1072
|
+
let mcpConfigPath;
|
|
1073
|
+
try {
|
|
1074
|
+
dir = mkdtempSync(join(deps.tmpDir ?? tmpdir(), "termhub-claude-"));
|
|
1075
|
+
mcpConfigPath = join(dir, "termhub-mcp.json");
|
|
1076
|
+
writeFileSync(mcpConfigPath, mcpConfig(params.mcp_url, params.token), { mode: 384 });
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
deps.log("claude run could not prepare its mcp config", { ch, error: message(err) });
|
|
1079
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
1080
|
+
socket.sendControl({ type: "open_error", ch, error: { code: "internal", message: "failed to prepare the claude run" } });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const runDir = dir;
|
|
1084
|
+
socket.sendControl({ type: "opened", ch });
|
|
1085
|
+
deps.log("claude run starting", { ch, resume: params.resume, model: params.model ?? null });
|
|
1086
|
+
const env = { ...deps.env ?? agentEnv() };
|
|
1087
|
+
if (params.config_dir === null) delete env.CLAUDE_CONFIG_DIR;
|
|
1088
|
+
else env.CLAUDE_CONFIG_DIR = params.config_dir;
|
|
1089
|
+
const args = buildClaudeArgs({
|
|
1090
|
+
session_id: params.session_id,
|
|
1091
|
+
resume: params.resume,
|
|
1092
|
+
mcp_config_path: mcpConfigPath,
|
|
1093
|
+
model: params.model ?? null
|
|
1094
|
+
});
|
|
1095
|
+
let child;
|
|
1096
|
+
try {
|
|
1097
|
+
child = spawn(CLI, args, { env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
deps.log("claude run could not be started", { ch, cli: CLI, error: message(err) });
|
|
1100
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
1101
|
+
socket.sendControl({ type: "closed", ch, code: null, reason: "run_failed" });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
let settled = false;
|
|
1105
|
+
let escalation;
|
|
1106
|
+
let stderr = "";
|
|
1107
|
+
let stderrBytes = 0;
|
|
1108
|
+
let buffer = "";
|
|
1109
|
+
let overlong = false;
|
|
1110
|
+
let droppedLines = 0;
|
|
1111
|
+
function signalRun(signal) {
|
|
1112
|
+
try {
|
|
1113
|
+
if (child.pid) process.kill(-child.pid, signal);
|
|
1114
|
+
else child.kill(signal);
|
|
1115
|
+
} catch {
|
|
1116
|
+
try {
|
|
1117
|
+
child.kill(signal);
|
|
1118
|
+
} catch {
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function killRun(hard = false) {
|
|
1123
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
1124
|
+
signalRun("SIGTERM");
|
|
1125
|
+
if (hard) {
|
|
1126
|
+
signalRun("SIGKILL");
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (escalation) return;
|
|
1130
|
+
escalation = setTimeout(() => signalRun("SIGKILL"), KILL_GRACE_MS);
|
|
1131
|
+
escalation.unref();
|
|
1132
|
+
}
|
|
1133
|
+
function settle(code, reason, notify = true) {
|
|
1134
|
+
if (settled) return;
|
|
1135
|
+
settled = true;
|
|
1136
|
+
clearTimeout(timer);
|
|
1137
|
+
clearTimeout(promptTimer);
|
|
1138
|
+
if (runs.get(ch) === run2) runs.delete(ch);
|
|
1139
|
+
try {
|
|
1140
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
deps.log("claude run dir could not be removed", { ch, error: message(err) });
|
|
1143
|
+
}
|
|
1144
|
+
if (!notify) return;
|
|
1145
|
+
try {
|
|
1146
|
+
socket.sendControl(reason ? { type: "closed", ch, code, reason } : { type: "closed", ch, code });
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
deps.log("claude closed send failed", { ch, error: message(err) });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
function emitLine(line) {
|
|
1152
|
+
if (!line.trim()) return;
|
|
1153
|
+
if (Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES) {
|
|
1154
|
+
droppedLines += 1;
|
|
1155
|
+
deps.log("claude stdout line too large to frame, dropped", { ch, bytes: Buffer.byteLength(line, "utf8") });
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
sendLine(line);
|
|
1159
|
+
}
|
|
1160
|
+
function sendLine(line) {
|
|
1161
|
+
if (settled) return;
|
|
1162
|
+
try {
|
|
1163
|
+
socket.sendStream(ch, Buffer.from(`${line}
|
|
1164
|
+
`, "utf8"));
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
deps.log("claude stream send failed", { ch, error: message(err) });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const timer = setTimeout(() => {
|
|
1170
|
+
deps.log("claude run timed out", { ch, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2 });
|
|
1171
|
+
killRun();
|
|
1172
|
+
}, deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2);
|
|
1173
|
+
timer.unref();
|
|
1174
|
+
const promptTimer = setTimeout(() => {
|
|
1175
|
+
deps.log("claude run got no prompt", { ch, promptTimeoutMs: deps.promptTimeoutMs ?? PROMPT_TIMEOUT_MS });
|
|
1176
|
+
killRun();
|
|
1177
|
+
settle(null, "run_failed");
|
|
1178
|
+
}, deps.promptTimeoutMs ?? PROMPT_TIMEOUT_MS);
|
|
1179
|
+
promptTimer.unref();
|
|
1180
|
+
const run2 = {
|
|
1181
|
+
child,
|
|
1182
|
+
promptSent: false,
|
|
1183
|
+
kill: killRun,
|
|
1184
|
+
promptArrived: () => clearTimeout(promptTimer),
|
|
1185
|
+
settle
|
|
1186
|
+
};
|
|
1187
|
+
runs.set(ch, run2);
|
|
1188
|
+
child.stdin?.on("error", () => {
|
|
1189
|
+
});
|
|
1190
|
+
child.stdout?.setEncoding("utf8");
|
|
1191
|
+
child.stdout?.on("data", (chunk) => {
|
|
1192
|
+
buffer += chunk;
|
|
1193
|
+
for (; ; ) {
|
|
1194
|
+
const nl = buffer.indexOf("\n");
|
|
1195
|
+
if (nl === -1) break;
|
|
1196
|
+
const line = buffer.slice(0, nl);
|
|
1197
|
+
buffer = buffer.slice(nl + 1);
|
|
1198
|
+
if (overlong) overlong = false;
|
|
1199
|
+
else emitLine(line);
|
|
1200
|
+
}
|
|
1201
|
+
if (Buffer.byteLength(buffer, "utf8") <= MAX_LINE_BYTES) return;
|
|
1202
|
+
if (!overlong) {
|
|
1203
|
+
overlong = true;
|
|
1204
|
+
droppedLines += 1;
|
|
1205
|
+
deps.log("claude stdout line too large to frame, dropped", { ch, bytes: Buffer.byteLength(buffer, "utf8") });
|
|
1206
|
+
}
|
|
1207
|
+
buffer = "";
|
|
1208
|
+
});
|
|
1209
|
+
child.stderr?.on("data", (data) => {
|
|
1210
|
+
stderrBytes += data.length;
|
|
1211
|
+
stderr = (stderr + data.toString("utf8")).slice(-STDERR_TAIL_BYTES);
|
|
1212
|
+
});
|
|
1213
|
+
child.on("error", (err) => {
|
|
1214
|
+
const missing = err.code === "ENOENT";
|
|
1215
|
+
deps.log(missing ? `claude cli not found on this machine (${CLI})` : "claude run failed to start", { ch, cli: CLI, error: err.message });
|
|
1216
|
+
killRun();
|
|
1217
|
+
settle(null, missing ? "cli_missing" : "run_failed");
|
|
1218
|
+
});
|
|
1219
|
+
child.on("close", (code) => {
|
|
1220
|
+
if (escalation) clearTimeout(escalation);
|
|
1221
|
+
if (!overlong) emitLine(buffer);
|
|
1222
|
+
buffer = "";
|
|
1223
|
+
deps.log("claude run ended", { ch, code, stderrBytes, droppedLines });
|
|
1224
|
+
if (code === 0) settle(0);
|
|
1225
|
+
else settle(code, classifyFailure(stderr));
|
|
1226
|
+
});
|
|
1227
|
+
},
|
|
1228
|
+
write(ch, data) {
|
|
1229
|
+
const run2 = runs.get(ch);
|
|
1230
|
+
if (!run2) return false;
|
|
1231
|
+
if (run2.promptSent) {
|
|
1232
|
+
deps.log("extra data on a claude channel ignored", { ch, bytes: data.length });
|
|
1233
|
+
return true;
|
|
1234
|
+
}
|
|
1235
|
+
run2.promptSent = true;
|
|
1236
|
+
run2.promptArrived();
|
|
1237
|
+
run2.child.stdin?.end(data);
|
|
1238
|
+
return true;
|
|
1239
|
+
},
|
|
1240
|
+
close(ch) {
|
|
1241
|
+
const run2 = runs.get(ch);
|
|
1242
|
+
if (!run2) return;
|
|
1243
|
+
run2.kill();
|
|
1244
|
+
run2.settle(null, "killed");
|
|
1245
|
+
},
|
|
1246
|
+
closeAll() {
|
|
1247
|
+
for (const run2 of [...runs.values()]) {
|
|
1248
|
+
run2.kill(true);
|
|
1249
|
+
run2.settle(null, "killed", false);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
949
1255
|
// src/dispatch.ts
|
|
950
1256
|
async function handleRpc(msg, socket, handlers2, log2) {
|
|
951
1257
|
const method = msg.method;
|
|
@@ -986,16 +1292,19 @@ function createDispatcher(deps) {
|
|
|
986
1292
|
case "rpc":
|
|
987
1293
|
void handleRpc(msg, socket, deps.handlers, deps.log);
|
|
988
1294
|
break;
|
|
989
|
-
case "open":
|
|
990
|
-
deps.
|
|
991
|
-
|
|
1295
|
+
case "open": {
|
|
1296
|
+
const opened = msg.kind === "claude" ? deps.claude.open(msg.ch, msg.params, socket) : deps.pty.open(msg.ch, msg.params, socket);
|
|
1297
|
+
opened.catch((err) => {
|
|
1298
|
+
deps.log(`${msg.kind}.open rejected unexpectedly`, { ch: msg.ch, error: err instanceof Error ? err.message : String(err) });
|
|
992
1299
|
});
|
|
993
1300
|
break;
|
|
1301
|
+
}
|
|
994
1302
|
case "resize":
|
|
995
1303
|
deps.pty.resize(msg.ch, msg.cols, msg.rows);
|
|
996
1304
|
break;
|
|
997
1305
|
case "close":
|
|
998
1306
|
deps.pty.close(msg.ch);
|
|
1307
|
+
deps.claude.close(msg.ch);
|
|
999
1308
|
break;
|
|
1000
1309
|
}
|
|
1001
1310
|
};
|
|
@@ -1060,8 +1369,8 @@ function isSpawnHelperFailure(err) {
|
|
|
1060
1369
|
function isEnoent2(err) {
|
|
1061
1370
|
const e = err;
|
|
1062
1371
|
if (e?.code === "ENOENT") return true;
|
|
1063
|
-
const
|
|
1064
|
-
return /ENOENT/.test(
|
|
1372
|
+
const message2 = e instanceof Error ? e.message : String(err);
|
|
1373
|
+
return /ENOENT/.test(message2);
|
|
1065
1374
|
}
|
|
1066
1375
|
function createPtyManager(deps) {
|
|
1067
1376
|
const procs = /* @__PURE__ */ new Map();
|
|
@@ -1092,8 +1401,8 @@ function createPtyManager(deps) {
|
|
|
1092
1401
|
TERMHUB_TAB_ID: params.session,
|
|
1093
1402
|
TERMHUB_SESSION: params.session
|
|
1094
1403
|
};
|
|
1095
|
-
const
|
|
1096
|
-
const spawnTmux = () =>
|
|
1404
|
+
const spawn2 = await resolveSpawn();
|
|
1405
|
+
const spawnTmux = () => spawn2(tmux, ["-u", "new-session", "-A", "-s", params.session, "-c", cwd], { name: "xterm-256color", cols, rows, cwd, env });
|
|
1097
1406
|
try {
|
|
1098
1407
|
proc = spawnTmux();
|
|
1099
1408
|
} catch (err) {
|
|
@@ -1557,7 +1866,7 @@ async function status3() {
|
|
|
1557
1866
|
}
|
|
1558
1867
|
|
|
1559
1868
|
// src/version.ts
|
|
1560
|
-
var AGENT_VERSION = "0.
|
|
1869
|
+
var AGENT_VERSION = "0.4.1";
|
|
1561
1870
|
|
|
1562
1871
|
// src/rpc/update.ts
|
|
1563
1872
|
var PACKAGE = "@termhub/agent";
|
|
@@ -1646,6 +1955,7 @@ function detectOs(platform = process.platform) {
|
|
|
1646
1955
|
if (platform === "linux") return "linux";
|
|
1647
1956
|
return null;
|
|
1648
1957
|
}
|
|
1958
|
+
var CAPABILITIES = [CAPABILITY_CLAUDE];
|
|
1649
1959
|
async function buildHello(osName) {
|
|
1650
1960
|
let tools = [];
|
|
1651
1961
|
try {
|
|
@@ -1660,7 +1970,8 @@ async function buildHello(osName) {
|
|
|
1660
1970
|
arch: process.arch,
|
|
1661
1971
|
hostname: os6.hostname(),
|
|
1662
1972
|
tmux: tools.includes("tmux"),
|
|
1663
|
-
tools
|
|
1973
|
+
tools,
|
|
1974
|
+
capabilities: CAPABILITIES
|
|
1664
1975
|
};
|
|
1665
1976
|
}
|
|
1666
1977
|
var REVOKED_MESSAGE = "Token inv\xE1lido ou revogado";
|
|
@@ -1678,7 +1989,7 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
|
|
|
1678
1989
|
{
|
|
1679
1990
|
url: config.url,
|
|
1680
1991
|
token: config.token,
|
|
1681
|
-
hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], probe: true },
|
|
1992
|
+
hello: { agent_version: AGENT_VERSION, os: osName, arch: process.arch, hostname: os6.hostname(), tmux: false, tools: [], capabilities: CAPABILITIES, probe: true },
|
|
1682
1993
|
onServerMessage: () => {
|
|
1683
1994
|
},
|
|
1684
1995
|
onStream: () => {
|
|
@@ -1704,8 +2015,8 @@ async function checkServerConnection(config, timeoutMs = 5e3) {
|
|
|
1704
2015
|
controller.abort();
|
|
1705
2016
|
}
|
|
1706
2017
|
}
|
|
1707
|
-
async function exitWithoutRestart(
|
|
1708
|
-
console.error(
|
|
2018
|
+
async function exitWithoutRestart(message2) {
|
|
2019
|
+
console.error(message2);
|
|
1709
2020
|
await stopRestartLoop();
|
|
1710
2021
|
process.exit(78);
|
|
1711
2022
|
}
|
|
@@ -1720,7 +2031,8 @@ async function runAgent(config, opts) {
|
|
|
1720
2031
|
if (helper.repaired) opts.log("spawn-helper exec bit repaired", { path: helper.path });
|
|
1721
2032
|
else if (!helper.executable) opts.log("spawn-helper is not executable and could not be fixed", { path: helper.path, error: helper.error });
|
|
1722
2033
|
const pty = createPtyManager({ log: opts.log });
|
|
1723
|
-
const
|
|
2034
|
+
const claude = createClaudeManager({ log: opts.log });
|
|
2035
|
+
const dispatch = createDispatcher({ handlers, pty, claude, log: opts.log });
|
|
1724
2036
|
const healHooks = () => {
|
|
1725
2037
|
heal().then((dirs) => {
|
|
1726
2038
|
if (dirs.length) opts.log("monitor hooks repaired", { dirs: dirs.length });
|
|
@@ -1734,9 +2046,16 @@ async function runAgent(config, opts) {
|
|
|
1734
2046
|
token: config.token,
|
|
1735
2047
|
hello,
|
|
1736
2048
|
onServerMessage: dispatch,
|
|
1737
|
-
|
|
2049
|
+
// A frame belongs to whichever manager holds that channel: the claude one says so, and
|
|
2050
|
+
// anything it does not own is a terminal's.
|
|
2051
|
+
onStream: (ch, data) => {
|
|
2052
|
+
if (!claude.write(ch, data)) pty.write(ch, data);
|
|
2053
|
+
},
|
|
1738
2054
|
onConnect: healHooks,
|
|
1739
|
-
onDisconnect: () =>
|
|
2055
|
+
onDisconnect: () => {
|
|
2056
|
+
pty.closeAll();
|
|
2057
|
+
claude.closeAll();
|
|
2058
|
+
},
|
|
1740
2059
|
log: opts.log
|
|
1741
2060
|
},
|
|
1742
2061
|
opts.signal
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@termhub/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,
|
|
@@ -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",
|