@tarfa/cli 0.0.0 → 0.1.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/package.json CHANGED
@@ -1,15 +1,32 @@
1
1
  {
2
2
  "name": "@tarfa/cli",
3
- "version": "0.0.0",
4
- "description": "Tarfa: reach your coding-agent terminal sessions from your phone. Placeholder; the first release is coming.",
5
- "license": "MIT",
6
- "author": "abumalick",
3
+ "version": "0.1.1",
4
+ "description": "Tarfa agent and CLI: serves your Claude Code terminal sessions (tmux workspaces) to the Tarfa phone app.",
7
5
  "repository": {
8
6
  "type": "git",
9
- "url": "git+https://github.com/abumalick/perch.git"
7
+ "url": "https://github.com/abumalick/tarfa",
8
+ "directory": "packages/agent"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "bin": {
16
+ "tarfa": "./dist/cli.js",
17
+ "perch": "./dist/cli.js"
10
18
  },
11
19
  "files": [
12
- "README.md"
20
+ "dist",
21
+ "src/hooks/tarfa-hook.sh",
22
+ "src/hooks/tarfa-guard.sh",
23
+ "templates"
13
24
  ],
14
- "bin": {}
15
- }
25
+ "dependencies": {
26
+ "node-pty": "1.2.0-beta.18",
27
+ "qr": "^0.7.0",
28
+ "ws": "^8.18.0",
29
+ "zod": "^3.25.76"
30
+ },
31
+ "scripts": {}
32
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bash
2
+ # Tarfa PreToolUse guard for Claude Code and Codex: refuse Bash commands that kill processes by
3
+ # name or en masse from inside a workspace, and the plain commands that would post into another
4
+ # session's message socket or set the tmux options Tarfa reads a workspace's orchestrator and
5
+ # Codex thread from (docs/MESSAGING.md).
6
+ #
7
+ # The tmux server, the agent and every workspace's CLI run as the same user, so a pattern kill
8
+ # from any one workspace (`pkill -f x` matches nearly every command line) takes all of them down
9
+ # at once. Nothing at the OS level can stop a process signalling its own user's processes, so
10
+ # this refuses the command before it runs. It is a guard against accidents, not a security
11
+ # boundary: a determined command can always get past a text match.
12
+ #
13
+ # Only commands run inside a tmux pane are checked. A quoted string is one argument, so a grep
14
+ # pattern or a commit message that names these commands passes; a `sh -c` script is checked as
15
+ # a command line of its own. A heredoc body is read as commands, and an apostrophe in one can
16
+ # throw the quote tracking off for the rest of the line: the cost of a text match.
17
+ [ -n "${TMUX_PANE:-}" ] || exit 0
18
+ [ -t 0 ] && exit 0
19
+ payload=$(cat) || exit 0
20
+
21
+ # No jq on every agent machine: take tool_input.command from the JSON by hand.
22
+ re='"command"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"'
23
+ [[ $payload =~ $re ]] || exit 0
24
+ cmd=${BASH_REMATCH[1]}
25
+ cmd=${cmd//\\\\/$'\x01'}
26
+ cmd=${cmd//\\n/$'\n'}
27
+ cmd=${cmd//\\t/ }
28
+ cmd=${cmd//\\\"/\"}
29
+ cmd=${cmd//$'\x01'/\\}
30
+
31
+ deny() {
32
+ printf 'Blocked by Tarfa: %s The tmux server, the Tarfa agent and every other workspace run as this same user, so a kill by name or en masse can end all of them at once. Look first with `pgrep -af <pattern>`, then `kill <pid>` the explicit pids that belong to this task.\n' "$1" >&2
33
+ exit 2
34
+ }
35
+
36
+ refuse() {
37
+ printf 'Blocked by Tarfa: %s Every workspace runs as this same user, so a receiving session cannot tell such a message from a real one. Message a workspace with `tarfa send <id> "…"` and start a child with `tarfa spawn --child`.\n' "$1" >&2
38
+ exit 2
39
+ }
40
+
41
+ word_end='([[:space:])}"'"'"'`]|$)'
42
+ # tmux accepts any unambiguous prefix of a command name; kill-ser is the shortest for kill-server.
43
+ kill_server_re="[[:space:]]kill-ser[a-z]*$word_end"
44
+ kill_others_re="[[:space:]]kill-session([[:space:]].*)?[[:space:]]-a$word_end"
45
+ shell_c_re='^([^[:space:]]*/)?(ba|z|da)?sh([[:space:]]+-[a-zA-Z]+)*[[:space:]]+-[a-zA-Z]*c[[:space:]]+'
46
+ has_socket_flag() { [[ $1 =~ (^|[[:space:]])-[A-Za-z]*[SL]([[:space:]]|[^[:space:]]|$) ]]; }
47
+
48
+ # Another session's message socket: Claude Code's, the Tarfa inbox Pi reads, the Codex app server.
49
+ inbox_re='(cc-socks/|\.(tarfa|perch)/inbox/|app-server-control/)'
50
+ # tmux takes `set`, `setw` and any prefix of `set-option` or `set-window-option`; a window option
51
+ # answers `#{@tarfa_parent}` as well as a session option does.
52
+ reparent_re='[[:space:]](set|setw|set-o[a-z]*|set-w[a-z-]*)[[:space:]].*@(tarfa|perch)_(parent|codex_thread)([^[:alnum:]_]|$)'
53
+
54
+ # A pid list computed from a name is a kill by name.
55
+ pgrep_kill_re='(^|[^[:alnum:]_-])kill[[:space:]][^;&|]*(\$\(|`)[[:space:]]*(pgrep|pidof)([[:space:]]|$)'
56
+ [[ $cmd =~ $pgrep_kill_re ]] && deny 'kill $(pgrep …) kills by name.'
57
+
58
+ # One simple command per line: split on separators outside quotes, so a quoted `a|pkill|b`
59
+ # (a grep pattern, a commit message) stays one argument. `$(` and a backquote split inside
60
+ # double quotes too, since they run there. Newlines inside quotes become spaces.
61
+ segments() {
62
+ printf '%s' "$1" | LC_ALL=C awk -v sq="'" '
63
+ BEGIN { RS = "\001" }
64
+ {
65
+ n = length($0); q = ""; out = ""
66
+ for (i = 1; i <= n; i++) {
67
+ c = substr($0, i, 1); c2 = substr($0, i, 2)
68
+ if (q == sq) { if (c == sq) q = ""; out = out (c == "\n" ? " " : c); continue }
69
+ if (c == "\\") { out = out (c2 == "\\\n" ? " " : c2); i++; continue }
70
+ if (c2 == "$(") { print out; out = ""; i++; continue }
71
+ if (c == "`") { print out; out = ""; continue }
72
+ if (q == "\"") { if (c == "\"") q = ""; out = out (c == "\n" ? " " : c); continue }
73
+ if (c == sq || c == "\"") { q = c; out = out c; continue }
74
+ if (c2 == "&&" || c2 == "||") { print out; out = ""; i++; continue }
75
+ if (c == ";" || c == "|" || c == "&" || c == "\n") { print out; out = ""; continue }
76
+ out = out c
77
+ }
78
+ print out
79
+ }'
80
+ }
81
+
82
+ check() {
83
+ local seg s word payload args i
84
+ while IFS= read -r seg; do
85
+ s="$seg"
86
+ # Strip what can precede the command word: grouping, keywords, VAR=value, wrappers, and a
87
+ # `sh -c '` prefix (later commands inside the wrapper arrive as their own segments).
88
+ while :; do
89
+ s="${s#"${s%%[![:space:]]*}"}"
90
+ case "$s" in
91
+ '('* | '{'* | '!'* | \'* | '"'* | '`'*) s="${s:1}" ;;
92
+ then\ * | do\ * | else\ * | time\ * | exec\ * | sudo\ * | command\ * | nohup\ * | nice\ * | setsid\ *) s="${s#* }" ;;
93
+ timeout\ *)
94
+ s="${s#timeout }"
95
+ while [[ $s =~ ^[[:space:]]*(-[^[:space:]]+)[[:space:]]+ ]]; do s="${s:${#BASH_REMATCH[0]}}"; done
96
+ [[ $s =~ ^[[:space:]]*[0-9.]+[smhd]?[[:space:]]+ ]] && s="${s:${#BASH_REMATCH[0]}}"
97
+ ;;
98
+ env\ *)
99
+ s="${s#env }"
100
+ while [[ $s =~ ^[[:space:]]*(-u[[:space:]]+[^[:space:]]+|-[^[:space:]]+)[[:space:]]+ ]]; do
101
+ s="${s:${#BASH_REMATCH[0]}}"
102
+ done
103
+ ;;
104
+ *)
105
+ if [[ $s =~ $shell_c_re ]]; then
106
+ s="${s:${#BASH_REMATCH[0]}}"
107
+ # A quoted script is a command line of its own: check every command in it.
108
+ case "$s" in
109
+ \'*) payload="${s:1}"; check "${payload%%\'*}"; continue 2 ;;
110
+ '"'*) payload="${s:1}"; check "${payload%%\"*}"; continue 2 ;;
111
+ esac
112
+ elif [[ $s =~ ^[A-Za-z_][A-Za-z0-9_]*=(\"[^\"]*\"|\'[^\']*\'|[^[:space:]]*)[[:space:]]+ ]]; then
113
+ s="${s:${#BASH_REMATCH[0]}}"
114
+ else
115
+ break
116
+ fi
117
+ ;;
118
+ esac
119
+ done
120
+
121
+ word=${s%%[[:space:]]*}
122
+ word=${word##*/}
123
+ word=${word%%[\"\']*}
124
+ case "$word" in
125
+ pkill) deny 'pkill kills by name.' ;;
126
+ killall) deny 'killall kills by name.' ;;
127
+ xargs) [[ $s =~ [[:space:]]kill([[:space:]]|$) ]] && deny 'xargs kill kills a computed pid list.' ;;
128
+ kill)
129
+ # `-1` as a target (not the first argument, where it names a signal) is every process.
130
+ read -ra args <<< "${s#kill}"
131
+ for ((i = 1; i < ${#args[@]}; i++)); do
132
+ [ "${args[i]}" = "-1" ] && deny 'kill -1 signals every process you own.'
133
+ done
134
+ ;;
135
+ nc | nc.* | ncat | netcat | socat | curl | websocat)
136
+ [[ $s =~ $inbox_re ]] && refuse "$word would write to another session's message socket directly, past the header and sender check that tarfa send puts on every message."
137
+ ;;
138
+ tmux)
139
+ has_socket_flag "$s" && continue
140
+ [[ $s =~ $reparent_re ]] && refuse 'Setting @tarfa_parent or @tarfa_codex_thread by hand changes which workspace the agent treats as an orchestrator or a Codex thread, so messages would carry authority nobody granted.'
141
+ [[ $s =~ $kill_server_re ]] && deny 'tmux kill-server without -S/-L ends the shared tmux server every workspace runs in.'
142
+ [[ $s =~ $kill_others_re ]] && deny 'tmux kill-session -a kills every other workspace; kill one by its exact name with -t '\''=<name>'\''.'
143
+ ;;
144
+ esac
145
+ done < <(segments "$1")
146
+ }
147
+
148
+ check "$cmd"
149
+ exit 0
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # Tarfa hook for Claude Code and Codex: notify the local agent of an attention event.
3
+ # Usage: tarfa-hook.sh <Event> <port> [agent] (the CLI's JSON payload arrives on stdin)
4
+ # `agent` names a CLI other than Claude Code (`codex`). It is spliced into the JSON body without
5
+ # escaping, so only a plain lowercase name is passed on.
6
+ event="$1"
7
+ port="${2:-8787}"
8
+ agent_field=""
9
+ [[ "${3:-}" =~ ^[a-z]+$ ]] && agent_field=",\"agent\":\"$3\""
10
+ # The workspace is the tmux session of the pane this hook runs in, and the hook names no session
11
+ # unless it can prove which one that is. TMUX and TMUX_PANE alone prove nothing: Codex runs its
12
+ # hooks in a shared background server that kept the environment of whichever pane started it,
13
+ # and an untargeted `tmux display-message` falls back to the most recently active session. Either
14
+ # would stamp the status onto an unrelated workspace, so the pane's own process must be one of
15
+ # this script's ancestors.
16
+ pane_session() {
17
+ [ -z "${TMUX:-}" ] || [ -z "${TMUX_PANE:-}" ] && return 1
18
+ local pane_pid session pid
19
+ read -r pane_pid session < <(tmux display-message -p -t "${TMUX_PANE}" '#{pane_pid} #S' 2>/dev/null)
20
+ [ -z "${session:-}" ] && return 1
21
+ pid=$$
22
+ for _ in $(seq 1 32); do
23
+ [ "${pid}" = "${pane_pid}" ] && break
24
+ pid="$(ps -o ppid= -p "${pid}" 2>/dev/null | tr -d ' ')"
25
+ [ -z "${pid}" ] || [ "${pid}" -le 1 ] && return 1
26
+ done
27
+ [ "${pid}" = "${pane_pid}" ] || return 1
28
+ printf '%s' "${session}"
29
+ }
30
+ # Claude's payload is forwarded verbatim as a nested object so the agent can read
31
+ # `background_tasks` without this script needing jq (not installed on every agent machine) or
32
+ # any bash JSON parsing. The tty check keeps a hand-run hook from blocking forever on stdin.
33
+ payload=null
34
+ [ -t 0 ] || payload="$(cat)"
35
+ [ -z "$payload" ] && payload=null
36
+ if session="$(pane_session)"; then
37
+ target="\"sessionName\":\"${session}\""
38
+ elif [ "${3:-}" = codex ] && [[ "${payload}" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([0-9A-Za-z-]+)\" ]]; then
39
+ # A Codex bound to the app-server daemon runs hooks in the daemon, under no pane; its
40
+ # session_id is the thread id the agent recorded for the workspace, and the agent maps it.
41
+ # Only id characters are taken, since the value is spliced into the body unescaped.
42
+ target="\"thread\":\"${BASH_REMATCH[1]}\""
43
+ else
44
+ exit 0
45
+ fi
46
+ token=""
47
+ [ -r "${HOME}/.tarfa/token" ] && token="$(cat "${HOME}/.tarfa/token")"
48
+ # Every local user can read a process's argv, so the secret goes in as a header file and the body
49
+ # on stdin: `printf` is a shell builtin and puts neither on any command line.
50
+ printf '{%s,"event":"%s"%s,"claude":%s}' "${target}" "${event}" "${agent_field}" "${payload}" |
51
+ curl -s -m 2 -X POST "http://127.0.0.1:${port}/hooks" \
52
+ -H 'content-type: application/json' \
53
+ -H @<(printf 'authorization: Bearer %s\n' "${token}") \
54
+ --data-binary @- >/dev/null 2>&1 || true
55
+ exit 0
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key><string>app.tarfa.agent</string>
6
+ <key>ProgramArguments</key>
7
+ <array>
8
+ AGENT_ARGUMENTS
9
+ </array>
10
+ <key>RunAtLoad</key><true/>
11
+ <key>KeepAlive</key><true/>
12
+ <key>StandardOutPath</key><string>LOG_DIR/agent.log</string>
13
+ <key>StandardErrorPath</key><string>LOG_DIR/agent.err.log</string>
14
+ <key>EnvironmentVariables</key>
15
+ <dict>
16
+ <key>PATH</key>
17
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
18
+ </dict>
19
+ </dict>
20
+ </plist>
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=Tarfa agent
3
+ After=network-online.target
4
+
5
+ [Service]
6
+ ExecStart=AGENT_COMMAND
7
+ Restart=always
8
+ RestartSec=3
9
+ # The agent spawns the tmux server as a child, so it lives in this unit's
10
+ # cgroup. With the default KillMode=control-group a restart would SIGTERM the
11
+ # whole cgroup and kill every workspace session. KillMode=process stops only
12
+ # the agent, leaving the tmux server and its sessions running across restarts.
13
+ KillMode=process
14
+
15
+ [Install]
16
+ WantedBy=default.target
@@ -0,0 +1,49 @@
1
+ #!/bin/sh
2
+ # The agent service's stable entry point: the unit or plist names this file, never a versioned
3
+ # path, so upgrading the package changes what runs without touching the service definition.
4
+ set -eu
5
+ # Kept apart from $HOME: agent.env is sourced below, and a HOME= line there must not move the lookups.
6
+ home="$HOME"
7
+ state="$home/.tarfa"
8
+ if [ ! -f "$state/agent.env" ]; then
9
+ echo "tarfa-agent: $state/agent.env is missing: run tarfa setup" >&2
10
+ exit 1
11
+ fi
12
+ set -a
13
+ # shellcheck disable=SC1091
14
+ . "$state/agent.env"
15
+ set +a
16
+ TARFA_AGENT_ENV="$state/agent.env"
17
+ export TARFA_AGENT_ENV
18
+
19
+ # A checkout named by agent-source runs instead of the release (deploy/serve-agent.sh writes it).
20
+ # A path that is gone, say a removed worktree, must not crash-loop the service.
21
+ if [ -f "$state/agent-source" ]; then
22
+ checkout=""
23
+ IFS= read -r checkout < "$state/agent-source" || true
24
+ if [ -n "$checkout" ] && [ -f "$checkout/deploy/run-agent.sh" ]; then
25
+ exec bash "$checkout/deploy/run-agent.sh"
26
+ fi
27
+ echo "tarfa-agent: $state/agent-source names $checkout, which has no deploy/run-agent.sh; running the released agent" >&2
28
+ fi
29
+
30
+ # Service managers do not inherit an interactive PATH, so find mise as deploy/run-agent.sh does.
31
+ # From $home, so no project's mise.toml picks the runtime.
32
+ cd "$home"
33
+ # `mise exec` would install every tool missing from the global config before starting the agent:
34
+ # slow, and a tool that cannot install would block it for good. So run the installed package with
35
+ # its node directly, as the tarfa wrapper does, and keep mise's shims on PATH for what the agent's
36
+ # panes and child tools run.
37
+ PATH="$home/.local/bin:${MISE_DATA_DIR:-$home/.local/share/mise}/shims:$PATH"
38
+ export PATH
39
+ for mise_bin in "$home/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mise; do
40
+ if [ -x "$mise_bin" ]; then
41
+ install="$("$mise_bin" --cd "$home" where npm:@tarfa/cli 2>/dev/null || true)"
42
+ node_home="$("$mise_bin" --cd "$home" where node 2>/dev/null || true)"
43
+ if [ -n "$install" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ] && [ -f "$install/bin/tarfa" ]; then
44
+ exec "$node_home/bin/node" "$install/bin/tarfa" serve
45
+ fi
46
+ exec "$mise_bin" exec -- tarfa serve
47
+ fi
48
+ done
49
+ exec tarfa serve
@@ -0,0 +1,48 @@
1
+ #!/bin/sh
2
+ # tarfa-cli-wrapper (managed by tarfa): written by `tarfa setup` and `tarfa apply`, replaced when
3
+ # it differs. Runs the installed @tarfa/cli from the caller's own folder, so `tarfa spawn .` means
4
+ # the caller's project, whatever mise config that folder holds.
5
+ #
6
+ # The install and its node are looked up from $HOME: a mise shim would take the caller's folder's
7
+ # mise.toml, which dies with a trust error in an untrusted folder and runs Tarfa under that
8
+ # project's node in a trusted one.
9
+ set -u
10
+
11
+ mise_bin=""
12
+ for candidate in "$HOME/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mise; do
13
+ if [ -x "$candidate" ]; then
14
+ mise_bin="$candidate"
15
+ break
16
+ fi
17
+ done
18
+ if [ -z "$mise_bin" ]; then
19
+ mise_bin="$(command -v mise 2>/dev/null || true)"
20
+ fi
21
+
22
+ if [ -n "$mise_bin" ]; then
23
+ install="$("$mise_bin" --cd "$HOME" where npm:@tarfa/cli 2>/dev/null || true)"
24
+ node_home="$("$mise_bin" --cd "$HOME" where node 2>/dev/null || true)"
25
+ if [ -n "$install" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ] && [ -f "$install/bin/tarfa" ]; then
26
+ exec "$node_home/bin/node" "$install/bin/tarfa" "$@"
27
+ fi
28
+ fi
29
+
30
+ # No mise, or it knows no install: the next tarfa on PATH that is not this file.
31
+ if [ -n "${TARFA_CLI_WRAPPER:-}" ]; then
32
+ echo "tarfa: no installed @tarfa/cli found (mise use -g npm:@tarfa/cli)" >&2
33
+ exit 1
34
+ fi
35
+ TARFA_CLI_WRAPPER=1
36
+ export TARFA_CLI_WRAPPER
37
+ old_ifs="$IFS"
38
+ IFS=:
39
+ for dir in $PATH; do
40
+ candidate="${dir:-.}/tarfa"
41
+ if [ -f "$candidate" ] && [ -x "$candidate" ] && ! [ "$candidate" -ef "$0" ]; then
42
+ IFS="$old_ifs"
43
+ exec "$candidate" "$@"
44
+ fi
45
+ done
46
+ IFS="$old_ifs"
47
+ echo "tarfa: no installed @tarfa/cli found (mise use -g npm:@tarfa/cli)" >&2
48
+ exit 1
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @tarfa/cli
2
-
3
- Placeholder for the Tarfa CLI and agent. The first release is coming.