@termhub/agent 0.5.0 → 0.5.2
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 +78 -32
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -60,7 +60,12 @@ var def = (params, result, timeoutMs = DEFAULT_TIMEOUT) => ({ params, result, ti
|
|
|
60
60
|
var RPC = {
|
|
61
61
|
"tmux.list": def(z.object({}), z.object({ sessions: z.array(sessionName) })),
|
|
62
62
|
"tmux.kill": def(z.object({ session: sessionName }), z.object({ killed: z.boolean() })),
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* `escapes`: keep the SGR attributes (`capture-pane -e`) so the server can tell dimmed text from typed
|
|
65
|
+
* text (since agent 0.5.2). An older agent strips the unknown param and answers plain text with no
|
|
66
|
+
* `escapes` in the result — which is how the server knows.
|
|
67
|
+
*/
|
|
68
|
+
"tmux.capture": def(z.object({ session: sessionName, lines: z.number().int().min(1).max(5e3), escapes: z.boolean().optional() }), z.object({ text: z.string(), escapes: z.boolean().optional() })),
|
|
64
69
|
/** Idempotent: creates the detached session in `cwd` when it is missing. `created` says whether it had to. */
|
|
65
70
|
"tmux.ensure": def(z.object({ session: sessionName, cwd: machinePath }), z.object({ created: z.boolean() }), 1e4),
|
|
66
71
|
/**
|
|
@@ -653,7 +658,8 @@ function claudeConfigDirs(accountDirs) {
|
|
|
653
658
|
function expandHome(dir, home) {
|
|
654
659
|
return dir.startsWith("~/") ? `${home}/${dir.slice(2)}` : dir;
|
|
655
660
|
}
|
|
656
|
-
var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"];
|
|
661
|
+
var CLAUDE_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "Notification", "Stop", "SessionEnd"];
|
|
662
|
+
var CLAUDE_TOOL_EVENTS = /* @__PURE__ */ new Set(["PreToolUse", "PermissionRequest"]);
|
|
657
663
|
var CURSOR_HOOK_EVENTS = ["sessionStart", "beforeSubmitPrompt", "afterAgentResponse", "stop", "sessionEnd"];
|
|
658
664
|
var HOOK_SCRIPT = `#!/bin/sh
|
|
659
665
|
# termhub monitor hook \u2014 installed by termhub; forwards Claude Code / Codex / Cursor CLI hook
|
|
@@ -674,9 +680,21 @@ if [ "$TOOL" = codex ]; then EVENT="$2"; else EVENT=$(cat 2>/dev/null); fi
|
|
|
674
680
|
# screen, and only when the pair changed since the last one for this session \u2014 twenty edits in a row
|
|
675
681
|
# are one request as long as the verb stays the same (a new verb mid-run is a new request). The marker is per tmux
|
|
676
682
|
# session, under TMPDIR, with the session name reduced to filename-safe characters.
|
|
683
|
+
# AskUserQuestion is the one exception (below).
|
|
677
684
|
MARK="\${TMPDIR:-/tmp}/termhub-hook-$(printf '%s' "$SESSION" | tr -c 'A-Za-z0-9_-' '_')"
|
|
678
|
-
|
|
679
|
-
|
|
685
|
+
# The branch below is picked on the event's OWN hook_event_name \u2014 the FIRST "hook_event_name" key of
|
|
686
|
+
# the payload (Claude Code serialises it before tool_input, same reasoning as tool_name below) \u2014
|
|
687
|
+
# never a value a substring search could find nested inside a tool's input (e.g. a PermissionRequest
|
|
688
|
+
# whose tool_input happened to contain the text "hook_event_name":"PreToolUse").
|
|
689
|
+
KIND_REST=\${EVENT#*'"hook_event_name"'}
|
|
690
|
+
if [ "$KIND_REST" != "$EVENT" ]; then
|
|
691
|
+
KIND_REST=\${KIND_REST#*'"'}
|
|
692
|
+
KIND=\${KIND_REST%%'"'*}
|
|
693
|
+
else
|
|
694
|
+
KIND=
|
|
695
|
+
fi
|
|
696
|
+
case "$KIND" in
|
|
697
|
+
PreToolUse)
|
|
680
698
|
# The event's own tool name is the FIRST "tool_name" of the payload (Claude Code serialises it
|
|
681
699
|
# before tool_input), so the shortest prefix is cut \u2014 a "tool_name" nested in a tool's input
|
|
682
700
|
# must not win. Only letters, digits, "_", "." and "-" are posted (a bare Claude Code tool name,
|
|
@@ -688,35 +706,63 @@ case "$EVENT" in
|
|
|
688
706
|
REST=\${REST#*'"'}
|
|
689
707
|
NAME=\${REST%%'"'*}
|
|
690
708
|
case "$NAME" in '' | *[!A-Za-z0-9_.-]*) exit 0 ;; esac
|
|
691
|
-
#
|
|
692
|
-
#
|
|
693
|
-
#
|
|
694
|
-
#
|
|
695
|
-
#
|
|
696
|
-
#
|
|
697
|
-
# and
|
|
698
|
-
#
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
case "$VERB" in *[!A-Za-z]*) VERB= ;; esac
|
|
706
|
-
[ "\${#VERB}" -le 24 ] || VERB=
|
|
707
|
-
KEY="$NAME\${VERB:+ $VERB}"
|
|
708
|
-
[ "$(cat "$MARK" 2>/dev/null)" = "$KEY" ] && exit 0
|
|
709
|
-
printf '%s' "$KEY" 2>/dev/null > "$MARK"
|
|
710
|
-
if [ -n "$VERB" ]; then
|
|
711
|
-
EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s","verb":"%s"}' "$NAME" "$VERB")
|
|
712
|
-
else
|
|
713
|
-
EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
|
|
709
|
+
# AskUserQuestion's input is the question itself, written to be shown to the person (spec
|
|
710
|
+
# 2026-09-25 \xA74.1): the whole event goes as it came \u2014 the server keeps tool_use_id and
|
|
711
|
+
# tool_input and drops the rest \u2014 and the marker is neither read nor written, so two questions
|
|
712
|
+
# in a row are two questions. NAME is real only when $REST (everything from the first "tool_name"
|
|
713
|
+
# on) holds no SECOND "tool_name": a real question never repeats that key, so a second one means
|
|
714
|
+
# the first was actually nested inside another tool's tool_input (tool_input serialised before
|
|
715
|
+
# tool_name) and NAME does not name the real tool \u2014 fall back to the ordinary name-only path below
|
|
716
|
+
# (which, worst case, mislabels that one event; it never forwards the input).
|
|
717
|
+
ASK=false
|
|
718
|
+
if [ "$NAME" = AskUserQuestion ]; then
|
|
719
|
+
case "$REST" in
|
|
720
|
+
*'"tool_name"'*) ;;
|
|
721
|
+
*) ASK=true ;;
|
|
722
|
+
esac
|
|
714
723
|
fi
|
|
724
|
+
if [ "$ASK" != true ]; then
|
|
725
|
+
# Claude Code's spinner verb ("\u273B Moonwalking\u2026 (12s \xB7 esc to interrupt)"): the visible pane is
|
|
726
|
+
# read here, on the machine, and only the verb may leave it \u2014 one word of 2 to 24 ASCII letters
|
|
727
|
+
# right after a spinner glyph at column 0 and a single space, immediately followed by "\u2026" or
|
|
728
|
+
# "...", then the end of the line or a space. Column 0 because Claude Code draws its spinner
|
|
729
|
+
# there, while a draft in the input box or indented tool output can look just like one. The
|
|
730
|
+
# lowest such line of the last 24 non-blank rows wins (the live spinner sits above the todo list
|
|
731
|
+
# and the input box; blank rows under a short session are skipped). Both grep and sed run under
|
|
732
|
+
# LC_ALL=C: bytes the locale calls invalid then neither trip grep's "binary file matches" (which
|
|
733
|
+
# would also swallow the rest of the screen) nor sed's "illegal byte sequence" on macOS, and
|
|
734
|
+
# the match works the same on GNU, BSD and busybox. ASCII only on purpose: a customised verb
|
|
735
|
+
# with accents is dropped rather than half-matched. The case below checks the result again, so
|
|
736
|
+
# the hand-built JSON only ever gets letters.
|
|
737
|
+
VERB=$(tmux capture-pane -p -t "$TMUX_PANE" 2>/dev/null | LC_ALL=C grep -v '^[[:space:]]*$' 2>/dev/null | tail -n 24 |
|
|
738
|
+
LC_ALL=C sed -n -E 's/^(\xB7|\u2722|\u2733|\u2736|\u273B|\u273D|\\*) ([A-Za-z]{2,24})(\u2026|\\.\\.\\.)( .*)?$/\\2/p' | tail -n 1)
|
|
739
|
+
case "$VERB" in *[!A-Za-z]*) VERB= ;; esac
|
|
740
|
+
[ "\${#VERB}" -le 24 ] || VERB=
|
|
741
|
+
KEY="$NAME\${VERB:+ $VERB}"
|
|
742
|
+
[ "$(cat "$MARK" 2>/dev/null)" = "$KEY" ] && exit 0
|
|
743
|
+
printf '%s' "$KEY" 2>/dev/null > "$MARK"
|
|
744
|
+
if [ -n "$VERB" ]; then
|
|
745
|
+
EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s","verb":"%s"}' "$NAME" "$VERB")
|
|
746
|
+
else
|
|
747
|
+
EVENT=$(printf '{"hook_event_name":"PreToolUse","tool_name":"%s"}' "$NAME")
|
|
748
|
+
fi
|
|
749
|
+
fi
|
|
750
|
+
;;
|
|
751
|
+
PermissionRequest)
|
|
752
|
+
# A permission prompt: only the tool's name travels, exactly like a tool call (never its input,
|
|
753
|
+
# never the suggestions). AskUserQuestion's own prompt is dropped \u2014 its PreToolUse already carried
|
|
754
|
+
# the question. Same first-"tool_name" rule and character set as above.
|
|
755
|
+
REST=\${EVENT#*'"tool_name"'}
|
|
756
|
+
[ "$REST" != "$EVENT" ] || exit 0
|
|
757
|
+
REST=\${REST#*'"'}
|
|
758
|
+
NAME=\${REST%%'"'*}
|
|
759
|
+
case "$NAME" in '' | *[!A-Za-z0-9_.-]* | AskUserQuestion) exit 0 ;; esac
|
|
760
|
+
EVENT=$(printf '{"hook_event_name":"PermissionRequest","tool_name":"%s"}' "$NAME")
|
|
715
761
|
;;
|
|
716
762
|
# A new turn starts fresh, and so does an answered notification: a permission prompt takes the tab
|
|
717
763
|
# out of working, and the tool the person approves is the same one that set the marker, so without
|
|
718
764
|
# this reset the retry is suppressed and nothing says the tab is working again.
|
|
719
|
-
|
|
765
|
+
SessionStart | UserPromptSubmit | Notification)
|
|
720
766
|
rm -f "$MARK"
|
|
721
767
|
;;
|
|
722
768
|
esac
|
|
@@ -753,7 +799,7 @@ function mergeClaudeSettings(current, scriptPath, shown = "~/.claude/settings.js
|
|
|
753
799
|
const list4 = Array.isArray(next[event]) ? next[event] : [];
|
|
754
800
|
const others = list4.filter((e) => !isOurs(e));
|
|
755
801
|
const entry = { hooks: [{ type: "command", command: `${scriptPath} claude`, timeout: 10 }] };
|
|
756
|
-
if (event
|
|
802
|
+
if (CLAUDE_TOOL_EVENTS.has(event))
|
|
757
803
|
entry.matcher = "*";
|
|
758
804
|
others.push(entry);
|
|
759
805
|
next[event] = others;
|
|
@@ -1979,11 +2025,11 @@ async function kill(params) {
|
|
|
1979
2025
|
return { killed: r.code === 0 };
|
|
1980
2026
|
}
|
|
1981
2027
|
async function capture(params) {
|
|
1982
|
-
const r = await run(tmuxPath(), ["capture-pane", "-p", "-S", `-${params.lines}`, "-t", `=${params.session}:`]);
|
|
2028
|
+
const r = await run(tmuxPath(), ["capture-pane", "-p", ...params.escapes ? ["-e"] : [], "-S", `-${params.lines}`, "-t", `=${params.session}:`]);
|
|
1983
2029
|
const failure = processFailure2(r);
|
|
1984
2030
|
if (failure) throw failure;
|
|
1985
2031
|
if (r.code !== 0) throw new RpcFailure("notfound", "session not found");
|
|
1986
|
-
return { text: r.stdout };
|
|
2032
|
+
return params.escapes ? { text: r.stdout, escapes: true } : { text: r.stdout };
|
|
1987
2033
|
}
|
|
1988
2034
|
var pane = (session) => `=${session}:`;
|
|
1989
2035
|
var why = (stderr, fallback) => stderr.trim().split("\n")[0] || fallback;
|
|
@@ -2271,7 +2317,7 @@ async function status3() {
|
|
|
2271
2317
|
}
|
|
2272
2318
|
|
|
2273
2319
|
// src/version.ts
|
|
2274
|
-
var AGENT_VERSION = "0.5.
|
|
2320
|
+
var AGENT_VERSION = "0.5.2";
|
|
2275
2321
|
|
|
2276
2322
|
// src/rpc/update.ts
|
|
2277
2323
|
var PACKAGE = "@termhub/agent";
|
package/package.json
CHANGED