@sandropadin/tend 0.1.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/AGENTS.md +160 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/dist/detect.js +123 -0
- package/dist/git.js +57 -0
- package/dist/index.js +303 -0
- package/dist/manifests.js +133 -0
- package/dist/nav.js +33 -0
- package/dist/pick.js +214 -0
- package/dist/regions.js +60 -0
- package/dist/render.js +118 -0
- package/dist/scan.js +41 -0
- package/dist/tmux.js +152 -0
- package/dist/types.js +4 -0
- package/package.json +31 -0
- package/src/detect.ts +152 -0
- package/src/git.ts +64 -0
- package/src/index.ts +341 -0
- package/src/manifests.ts +139 -0
- package/src/nav.ts +44 -0
- package/src/pick.ts +224 -0
- package/src/regions.ts +65 -0
- package/src/render.ts +139 -0
- package/src/scan.ts +58 -0
- package/src/tmux.ts +173 -0
- package/src/types.ts +73 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# AGENTS.md — working on tend
|
|
2
|
+
|
|
3
|
+
Context for AI agents (and humans) modifying this repo. Read this before touching
|
|
4
|
+
detection, tmux glue, or the dashboard. User-facing usage lives in
|
|
5
|
+
[README.md](README.md); this file is the *why* and the *gotchas*.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
`tend` reports the status of AI coding agents (Claude Code, Codex, …) running
|
|
10
|
+
inside **tmux** panes — blocked / working / idle — grouped by session, with a
|
|
11
|
+
live selectable dashboard that can jump you (or another window) to an agent. It
|
|
12
|
+
deliberately does **not** multiplex, split, or manage windows — tmux already
|
|
13
|
+
does that.
|
|
14
|
+
|
|
15
|
+
## Running & dev loop
|
|
16
|
+
|
|
17
|
+
- **Local dev needs no build.** Node ≥ 22.18 runs the TypeScript directly:
|
|
18
|
+
`node src/index.ts`. Don't add a bundler or a dev-time transpile step.
|
|
19
|
+
- **Distribution DOES need a build — don't remove it.** Node refuses to
|
|
20
|
+
type-strip files under `node_modules`
|
|
21
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so an installed/published
|
|
22
|
+
package can't run the raw `.ts`. `npm run build` (`tsconfig.build.json`) emits
|
|
23
|
+
`dist/` with `./x.ts` import specifiers rewritten to `./x.js`; `bin` points at
|
|
24
|
+
`dist/index.js`; the `prepare` hook builds it automatically on install / git
|
|
25
|
+
install / publish. `dist/` is gitignored (built on demand, never committed).
|
|
26
|
+
- Typecheck with `npx tsc --noEmit` (it's `noEmit`; tsc is only a checker here).
|
|
27
|
+
- **Zero runtime dependencies.** The only deps are dev-only `@types/node` +
|
|
28
|
+
`typescript`. Keep it that way — everything is Node stdlib + shelling out to
|
|
29
|
+
`tmux`/`git`. Don't reach for a TUI/ANSI/arg-parsing library.
|
|
30
|
+
- The tool must be run where a tmux server is reachable. Inside tmux it scans the
|
|
31
|
+
current session by default; outside (or with no `--current`) it scans all.
|
|
32
|
+
|
|
33
|
+
## Architecture (one job per file)
|
|
34
|
+
|
|
35
|
+
| File | Role |
|
|
36
|
+
|------|------|
|
|
37
|
+
| `src/types.ts` | Data model: `AgentState`, `Rule`, `Manifest`, `PaneInfo`, `AgentStatus`. |
|
|
38
|
+
| `src/regions.ts` | Region extractors — pure string slicers over captured pane text. |
|
|
39
|
+
| `src/manifests.ts` | **The detection rules you tune.** Per-agent identity + state patterns. |
|
|
40
|
+
| `src/detect.ts` | Engine: `identifyAgent` + `resolveState` (signal arbitration). |
|
|
41
|
+
| `src/tmux.ts` | All tmux shelling: list panes, capture, switch/select clients, list clients. |
|
|
42
|
+
| `src/git.ts` | Git branch/dirty/ahead-behind via the `git` binary, cached per repo root. |
|
|
43
|
+
| `src/scan.ts` | One detection pass over all panes; shared by every mode. |
|
|
44
|
+
| `src/nav.ts` | Jump logic (switch-client / attach / target another client). |
|
|
45
|
+
| `src/render.ts` | Grouped/JSON output, state glyph + spinner, ANSI helpers. |
|
|
46
|
+
| `src/pick.ts` | Interactive dashboard (raw-mode TUI): keys, animation, target cycling. |
|
|
47
|
+
| `src/index.ts` | CLI arg parsing + mode dispatch. |
|
|
48
|
+
|
|
49
|
+
Data flow: `scan()` → `listPanes()` (tmux) → per pane `capturePane()` +
|
|
50
|
+
`resolveState()` → `AgentStatus[]` → `render*` / `pick`.
|
|
51
|
+
|
|
52
|
+
## The detection model
|
|
53
|
+
|
|
54
|
+
Two independent halves, both driven by **data, not code**:
|
|
55
|
+
|
|
56
|
+
1. **Identity** (`identifyAgent`): match `pane_current_command` against a
|
|
57
|
+
manifest's `match` names → its `commandPattern` regex → screen `signature`
|
|
58
|
+
substrings. Highest-confidence source wins.
|
|
59
|
+
2. **State** (`resolveState` + manifest `rules`): capture the pane's rendered
|
|
60
|
+
screen, run the agent's rules by `priority` (highest match wins) over a
|
|
61
|
+
`region` of the screen; then arbitrate against PTY activity.
|
|
62
|
+
|
|
63
|
+
Arbitration order in `resolveState` (do not casually reorder):
|
|
64
|
+
`skipStateUpdate` (hold prev) → **blocked** (active match only) → **working**
|
|
65
|
+
(content changed since last scan) → working (on-screen interrupt hint) → **idle**
|
|
66
|
+
(debounced: must persist two scans) → else keep previous.
|
|
67
|
+
|
|
68
|
+
## Invariants & gotchas (each was a real bug — don't reintroduce)
|
|
69
|
+
|
|
70
|
+
- **tmux sanitizes control chars in `-F` output to `_`** — including a chosen
|
|
71
|
+
field separator *and even newlines*. So you cannot pack multiple fields into
|
|
72
|
+
one `-F` line. `listPanes` queries **one field per call**, each paired with
|
|
73
|
+
`#{pane_id}` and split on the first `|` (pane ids are `%<digits>`, never
|
|
74
|
+
contain `|`), then merges by id. Keep this pattern for any new pane/client
|
|
75
|
+
field. (`src/tmux.ts`)
|
|
76
|
+
- **Claude Code renames its process comm to its version string** (e.g.
|
|
77
|
+
`2.1.200`), so `pane_current_command` is *not* `claude`. Identity relies on the
|
|
78
|
+
semver `commandPattern` (`^\d+\.\d+\.\d+`). Screen `signature`s must be
|
|
79
|
+
*persistent* chrome (mode footer, `esc to interrupt`, permission prompt) — the
|
|
80
|
+
welcome banner scrolls off mid-session and must not be relied on. (`manifests.ts`)
|
|
81
|
+
- **Blocked must key on the LIVE selection cursor** (`^\s*❯\s+\d+\.`), not the
|
|
82
|
+
question text. The prompt text ("Do you want to proceed?") lingers in the
|
|
83
|
+
scroll buffer after you answer; the `❯ 1.` cursor disappears the instant you
|
|
84
|
+
answer. **Blocked is also never inherited** — `resolveState`'s `keep()` demotes
|
|
85
|
+
a held `blocked` to `idle`, so a stale block can't persist. (`detect.ts`,
|
|
86
|
+
`manifests.ts`)
|
|
87
|
+
- **Dashboard redraw must erase each line's tail (`\x1b[K`)**, not just clear
|
|
88
|
+
below the last line. Otherwise a line that gets *shorter* (e.g. a `· 1 blocked`
|
|
89
|
+
suffix that goes away) leaves ghost text. Don't switch back to a full-screen
|
|
90
|
+
`\x1b[2J` clear — it flickers against the ~90ms spinner tick. (`pick.ts`)
|
|
91
|
+
- **The spinner animates on its own ~90ms timer, separate from the 800ms scan**,
|
|
92
|
+
and only ticks while something is `working` (an all-idle board stays quiet). Do
|
|
93
|
+
not scan tmux on the animation tick — `render()` only rebuilds strings.
|
|
94
|
+
- **Cross-window jump uses `switch-client -c <client_tty>`.** tmux is
|
|
95
|
+
client-server: each attached terminal is a client identified by its tty.
|
|
96
|
+
`selfClientTty()` is only meaningful **inside** tmux — guard it with
|
|
97
|
+
`insideTmux()`, or a plain-terminal dashboard will mislabel the one real client
|
|
98
|
+
as "self" and find no targets. (`nav.ts`, `pick.ts`, `index.ts`)
|
|
99
|
+
- **Jump behavior is context-dependent:** targeting another client, or self
|
|
100
|
+
inside tmux, keeps the dashboard alive; self *outside* tmux means `tmux attach`
|
|
101
|
+
(takes over the terminal), so there it exits. (`nav.ts::jumpToPane`)
|
|
102
|
+
- **`scan()` preserves tmux pane order** (resolve concurrently, then filter in
|
|
103
|
+
order) so the grouped view is stable across refreshes — don't `push` in
|
|
104
|
+
async-completion order.
|
|
105
|
+
|
|
106
|
+
## Changing detection rules (the common task)
|
|
107
|
+
|
|
108
|
+
Agent TUIs drift, so `manifests.ts` is expected to need tuning. Workflow:
|
|
109
|
+
|
|
110
|
+
1. `node src/index.ts --debug <pane>` against a live agent pane. It prints what
|
|
111
|
+
each region extractor pulls out and marks which rules ✓ match.
|
|
112
|
+
2. Adjust the rule's `region` / `contains` / `anyContains` / `regex` / `not`.
|
|
113
|
+
Rules are `{ id, state, priority, region, … }`; highest matching priority
|
|
114
|
+
wins. Regions: `full`, `after_last_horizontal_rule`, `prompt_box_body`,
|
|
115
|
+
`{ bottom_non_empty_lines: N }` (see `regions.ts`).
|
|
116
|
+
3. Prefer matching the **live** interactive element over lingering text (see the
|
|
117
|
+
blocked invariant above), and set `skipStateUpdate: true` for scrollback /
|
|
118
|
+
picker screens that shouldn't author state.
|
|
119
|
+
|
|
120
|
+
## Testing (no framework — use throwaway tmux servers)
|
|
121
|
+
|
|
122
|
+
There's no test runner; verify against **isolated tmux servers** on a dedicated
|
|
123
|
+
socket so you never touch the user's real session:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
SOCK=/tmp/tend-test.sock
|
|
127
|
+
tmux -S "$SOCK" new-session -d -s demo -n runner
|
|
128
|
+
# simulate an agent by printing its TUI into a pane:
|
|
129
|
+
tmux -S "$SOCK" new-window -t demo -n agent
|
|
130
|
+
tmux -S "$SOCK" send-keys -t demo:agent "clear; printf '%s\n' 'Do you want to proceed?' '❯ 1. Yes' ' 2. No'" Enter
|
|
131
|
+
# run tend FROM INSIDE that server (so its plain `tmux` calls hit $SOCK):
|
|
132
|
+
tmux -S "$SOCK" send-keys -t demo:runner "node /abs/path/src/index.ts --once --json > /tmp/out.json 2>&1" Enter
|
|
133
|
+
tmux -S "$SOCK" kill-server # always clean up
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Key testing facts:
|
|
137
|
+
- tend always talks to the **default** socket via plain `tmux`; to test against
|
|
138
|
+
`-S <socket>` you must run it *inside* a pane of that server (its `$TMUX` then
|
|
139
|
+
points there).
|
|
140
|
+
- The interactive dashboard reads a real PTY, so drive it with
|
|
141
|
+
`tmux send-keys -t <pane> Down` / `Enter` / `o` / `q` and read it back with
|
|
142
|
+
`tmux capture-pane -p`.
|
|
143
|
+
- **Cross-window / `switch-client` tests need ≥2 real clients.** Attach them via a
|
|
144
|
+
*second* tmux server whose panes run `tmux -S <first-socket> attach -t <sess>`
|
|
145
|
+
(a plain `&`-backgrounded `attach` won't get a sized PTY on macOS).
|
|
146
|
+
- `capture-pane -p` strips color; assert on glyphs/text, use `--debug` or logic
|
|
147
|
+
for color/state.
|
|
148
|
+
|
|
149
|
+
## Conventions
|
|
150
|
+
|
|
151
|
+
- TypeScript with `erasableSyntaxOnly` + `verbatimModuleSyntax` — no enums,
|
|
152
|
+
no namespaces, no param-properties; `import type` for types; **import with the
|
|
153
|
+
`.ts` extension** (`./tmux.ts`). Target/module per `tsconfig.json` (NodeNext).
|
|
154
|
+
- Comments explain the *why* (especially the invariants above), not the *what*.
|
|
155
|
+
- Keep the CLI's snapshot path (`--once`/`--json`/piped) side-effect-free and
|
|
156
|
+
exiting; only the dashboard holds the terminal.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
`tend` is MIT — see `LICENSE`. All the code here is original; keep it that way.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandro Padin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# tend
|
|
2
|
+
|
|
3
|
+
Status of the AI coding agents running inside your **tmux** session — at a glance.
|
|
4
|
+
|
|
5
|
+
`tend` scans your tmux panes, figures out which ones are AI agents (Claude
|
|
6
|
+
Code, Codex, …), and reports whether each is **blocked** (waiting on you),
|
|
7
|
+
**working**, or **idle** — plus the git branch state of each pane's directory.
|
|
8
|
+
|
|
9
|
+
It's small and focused: it does **not** multiplex, split panes, manage windows,
|
|
10
|
+
or persist sessions — tmux already does all of that. It answers one question:
|
|
11
|
+
*what are my agents doing right now?*
|
|
12
|
+
|
|
13
|
+
A state glyph carries each agent's state — shape *and* color, so it reads even
|
|
14
|
+
without color: **`●` red = blocked**, **`⠧` yellow (animated spinner) = working**,
|
|
15
|
+
**`○` green = idle**, `◌` grey = unknown.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
▸ api (1)
|
|
19
|
+
● %1 claude feature/x ↑2 ✱
|
|
20
|
+
▸ web (2)
|
|
21
|
+
⠧ %5 claude main
|
|
22
|
+
○ %0 codex main ✱
|
|
23
|
+
3 agent(s) across 2 session(s) — 1 blocked, 1 working, 1 idle
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
`tend` needs **tmux** and **Node ≥ 20**. Install it from npm:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install -g @spadin/tend # global — puts `tend` on your PATH
|
|
32
|
+
# or run it without installing:
|
|
33
|
+
npx @spadin/tend
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**From source** (to hack on it) — Node ≥ 22.18 runs the TypeScript directly, no
|
|
37
|
+
build step:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
git clone https://github.com/spadin/tend.git && cd tend
|
|
41
|
+
npm install # dev-only deps (types); the tool itself is stdlib-only
|
|
42
|
+
node src/index.ts # run it, or `npm link` to put `tend` on your PATH
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
By default it scans **every tmux session** and groups agents by session:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
tend # live, selectable dashboard (default in a terminal)
|
|
51
|
+
tend --once # print a grouped snapshot and exit
|
|
52
|
+
tend jump %3 # jump straight to a pane id
|
|
53
|
+
tend jump %3 --to T # open pane %3 in another window (client tty T)
|
|
54
|
+
tend clients # list attached tmux clients (terminal windows)
|
|
55
|
+
tend --json # machine-readable snapshot (for status bars, scripts)
|
|
56
|
+
tend --current # limit to the current session only
|
|
57
|
+
tend --readonly # dashboard without selection (display-only pane)
|
|
58
|
+
tend --debug %3 # dump what each region extractor sees, for rule tuning
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Bare `tend` opens the dashboard when run in a terminal, but **falls back to a
|
|
62
|
+
one-off snapshot when its output is piped or redirected** — so `tend | grep`
|
|
63
|
+
and `tend > file` still behave like a snapshot. `--json` and `--once` always
|
|
64
|
+
snapshot.
|
|
65
|
+
|
|
66
|
+
Options: `--interval <ms>` (dashboard refresh, default 800), `--once-delay <ms>`
|
|
67
|
+
(snapshot activity-sampling window, default 350).
|
|
68
|
+
|
|
69
|
+
### The dashboard
|
|
70
|
+
|
|
71
|
+
`tend` (or `tend watch`) is a live, grouped list of every agent across every
|
|
72
|
+
session — and it's **selectable**, so it doubles as a navigator: monitor and
|
|
73
|
+
jump in one surface.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
tend ↑/↓ move · enter jump · o target · r refresh · q quit
|
|
77
|
+
opens in: this window
|
|
78
|
+
|
|
79
|
+
▸ alpha (1)
|
|
80
|
+
❯ ⠹ %1 claude main ✱
|
|
81
|
+
▸ beta (2) · 1 blocked
|
|
82
|
+
● %2 claude main ✱
|
|
83
|
+
○ %3 claude main ✱
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The glyph shape + color carries the state (`●` red blocked, `⠹` yellow working —
|
|
87
|
+
an animated spinner, `○` green idle), so you never need the word.
|
|
88
|
+
|
|
89
|
+
- **↑/↓** (or `j`/`k`) move the cursor over agents, **Enter** jumps to that pane,
|
|
90
|
+
**r** refreshes, **q**/**Esc**/**Ctrl-C** quits. It re-scans on an interval so
|
|
91
|
+
states stay current.
|
|
92
|
+
- **Jumping keeps the dashboard alive.** Inside tmux, Enter `switch-client`s your
|
|
93
|
+
attached client to the agent's pane but leaves the monitor running in its own
|
|
94
|
+
pane — a persistent surface you can switch back to, not a one-shot chooser.
|
|
95
|
+
(Outside tmux, jumping means `tmux attach`, which takes over the terminal, so
|
|
96
|
+
there it exits when you detach.)
|
|
97
|
+
- `--readonly` (or piping the output) gives the old non-interactive repaint, for
|
|
98
|
+
a display-only status pane.
|
|
99
|
+
- `tend jump %3` jumps non-interactively — handy to bind to a tmux key.
|
|
100
|
+
|
|
101
|
+
### Opening agents in another window
|
|
102
|
+
|
|
103
|
+
Run the dashboard in one terminal window and keep a second window for actually
|
|
104
|
+
working — then open blocked agents *over there* while the dashboard stays put.
|
|
105
|
+
This works because tmux is client-server: each terminal window that attaches is a
|
|
106
|
+
separate **client**, and tend can move a specific one.
|
|
107
|
+
|
|
108
|
+
> **The second window must be an attached tmux client on the same server.** In
|
|
109
|
+
> that other terminal, run `tmux attach` (or `tmux new -s scratch`). A plain
|
|
110
|
+
> shell that isn't running tmux — or one attached to a different socket
|
|
111
|
+
> (`tmux -L`/`-S …`) — is not a client and won't appear as a target. Check with
|
|
112
|
+
> `tend clients`: if it lists only one line, `o` has nowhere to go.
|
|
113
|
+
|
|
114
|
+
- In the dashboard, press **`o`** to cycle where Enter opens the agent: **this
|
|
115
|
+
window → each other attached client → back**. The header shows the current
|
|
116
|
+
target (`opens in: ttys023 · scratch`). Pick a blocked agent, hit Enter, and it
|
|
117
|
+
opens in that other window while the dashboard never moves.
|
|
118
|
+
- `tend clients` lists the attached windows (their ttys and current sessions),
|
|
119
|
+
marking the current one.
|
|
120
|
+
- Preset the target instead of cycling: `--other` (the one other attached client)
|
|
121
|
+
or `--to <tty>` (a specific one). Works for both the dashboard and
|
|
122
|
+
`tend jump %3 --to /dev/ttys023`.
|
|
123
|
+
|
|
124
|
+
So the setup you'd use: `tend` in window A (your monitor), a plain shell or
|
|
125
|
+
scratch tmux session in window B. Watch A, press `o` once to aim at B, and every
|
|
126
|
+
Enter sends the selected agent to B.
|
|
127
|
+
|
|
128
|
+
### tmux status-bar integration
|
|
129
|
+
|
|
130
|
+
```tmux
|
|
131
|
+
set -g status-right "#(cd #{pane_current_path} && tend --json | jq -r '...')"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Or bind a key to a quick popup:
|
|
135
|
+
|
|
136
|
+
```tmux
|
|
137
|
+
bind-key g display-popup -E "tend; read"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## How it works
|
|
141
|
+
|
|
142
|
+
Two signals, arbitrated:
|
|
143
|
+
|
|
144
|
+
1. **Which agent is in a pane** — tmux's `#{pane_current_command}` gives the
|
|
145
|
+
foreground process name. Note Claude Code renames its process to its **version
|
|
146
|
+
string**
|
|
147
|
+
(e.g. `2.1.200`), so we match a semver `commandPattern`, not the literal
|
|
148
|
+
name. This is the most reliable signal because it's present no matter what's
|
|
149
|
+
scrolled into view. If the command is generic (`node`), we fall back to a
|
|
150
|
+
**screen signature** — persistent footer chrome (`shift+tab to cycle`,
|
|
151
|
+
`esc to interrupt`), not the welcome banner, which scrolls away.
|
|
152
|
+
|
|
153
|
+
2. **What state it's in** — we `capture-pane` the rendered screen and run
|
|
154
|
+
declarative **manifest rules** (see [`src/manifests.ts`](src/manifests.ts))
|
|
155
|
+
against slices of it. Highest-priority matching rule wins.
|
|
156
|
+
|
|
157
|
+
Arbitration (in [`src/detect.ts`](src/detect.ts)):
|
|
158
|
+
|
|
159
|
+
- **blocked** comes from the screen and is *strong* — it wins immediately. It's
|
|
160
|
+
deliberately strict (only a positive match of a known approval UI) so you
|
|
161
|
+
don't get false "needs you" alarms; everything unmatched falls back to idle.
|
|
162
|
+
- **working** comes from **activity** — the pane's content changed since the
|
|
163
|
+
last look (we diff `capture-pane` snapshots). A visible "esc to interrupt"
|
|
164
|
+
hint is a secondary signal.
|
|
165
|
+
- **idle** is debounced: it must persist across two reads, so a single quiet
|
|
166
|
+
frame mid-task doesn't flap the status.
|
|
167
|
+
- Scrollback / model-picker / transcript screens are recognized and **hold** the
|
|
168
|
+
previous state instead of authoring a bogus one.
|
|
169
|
+
|
|
170
|
+
Git facts come from shelling out to the real `git` binary (no libgit2), memoized
|
|
171
|
+
per repo root within each scan so N panes in one repo cost one set of calls.
|
|
172
|
+
|
|
173
|
+
## Tuning the rules
|
|
174
|
+
|
|
175
|
+
The detection is **data, not code**. Each
|
|
176
|
+
rule is `{ id, state, priority, region, contains?, anyContains?, regex?, not? }`.
|
|
177
|
+
When an agent's TUI changes, or you add a new agent, edit
|
|
178
|
+
[`src/manifests.ts`](src/manifests.ts) — no engine changes needed.
|
|
179
|
+
|
|
180
|
+
Workflow: run `tend --debug <pane>` against a live agent pane. It prints the
|
|
181
|
+
text each region extractor (`full`, `after_last_horizontal_rule`,
|
|
182
|
+
`prompt_box_body`, `bottom_non_empty_lines`) pulls out, and marks which rules
|
|
183
|
+
match. Adjust patterns until the ✓ marks line up with reality.
|
|
184
|
+
|
|
185
|
+
The shipped Claude/Codex rules are a **starting point** — TUIs vary by version,
|
|
186
|
+
so expect to tune them against your own agents.
|
|
187
|
+
|
|
188
|
+
## Layout
|
|
189
|
+
|
|
190
|
+
| File | Role |
|
|
191
|
+
|------|------|
|
|
192
|
+
| `src/types.ts` | Data model (states, rules, manifests) |
|
|
193
|
+
| `src/regions.ts` | Region extractors — pure string slicers |
|
|
194
|
+
| `src/manifests.ts` | **The rules you tune** — per-agent detection patterns |
|
|
195
|
+
| `src/tmux.ts` | tmux glue (`list-panes`, `capture-pane`, switch/select) |
|
|
196
|
+
| `src/git.ts` | Git status via the `git` binary, cached per repo |
|
|
197
|
+
| `src/detect.ts` | The engine: identify agent + arbitrate state |
|
|
198
|
+
| `src/scan.ts` | One detection pass over all panes (shared by all modes) |
|
|
199
|
+
| `src/nav.ts` | Jump to a pane (switch-client inside tmux / attach outside) |
|
|
200
|
+
| `src/render.ts` | Grouped / JSON output |
|
|
201
|
+
| `src/pick.ts` | Interactive selectable dashboard (raw-mode TUI) |
|
|
202
|
+
| `src/index.ts` | CLI (once / watch / jump / debug) |
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT — see [LICENSE](LICENSE). All original code; use it however you like.
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The detection engine: identify the agent in a pane, then resolve its state by
|
|
2
|
+
// arbitrating three signals:
|
|
3
|
+
// 1. blocked/idle come from the screen (manifest rules),
|
|
4
|
+
// 2. working comes from PTY activity (content changed since last tick),
|
|
5
|
+
// 3. skipStateUpdate screens (scrollback/menus) hold the previous state.
|
|
6
|
+
// Blocked is strong and wins; otherwise activity beats a stale idle read; idle
|
|
7
|
+
// is debounced so a quiet frame mid-task doesn't flap the status.
|
|
8
|
+
import { extractRegion } from "./regions.js";
|
|
9
|
+
import { manifestFor, MANIFESTS } from "./manifests.js";
|
|
10
|
+
export function identifyAgent(pane, captureText) {
|
|
11
|
+
const cmd = pane.command.toLowerCase();
|
|
12
|
+
// 1. Exact / substring command-name match (e.g. comm is literally "claude").
|
|
13
|
+
for (const m of MANIFESTS) {
|
|
14
|
+
if (m.match.some((name) => cmd === name || cmd.includes(name)))
|
|
15
|
+
return m.agent;
|
|
16
|
+
}
|
|
17
|
+
// 2. Command-pattern match — catches Claude Code, whose comm is its version
|
|
18
|
+
// string (e.g. "2.1.200"). Most reliable: independent of screen scroll.
|
|
19
|
+
for (const m of MANIFESTS) {
|
|
20
|
+
if (m.commandPattern && new RegExp(m.commandPattern).test(pane.command)) {
|
|
21
|
+
return m.agent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// 3. Screen signature — for agents launched under a generic comm (node/python)
|
|
25
|
+
// whose version-rename doesn't apply. Matches persistent on-screen chrome.
|
|
26
|
+
const hay = captureText.toLowerCase();
|
|
27
|
+
for (const m of MANIFESTS) {
|
|
28
|
+
if (m.signature.some((sig) => hay.includes(sig.toLowerCase())))
|
|
29
|
+
return m.agent;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function ruleMatches(rule, regionText) {
|
|
34
|
+
const hay = regionText.toLowerCase();
|
|
35
|
+
if (rule.contains && !rule.contains.every((s) => hay.includes(s.toLowerCase()))) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (rule.anyContains && !rule.anyContains.some((s) => hay.includes(s.toLowerCase()))) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (rule.not && rule.not.some((s) => hay.includes(s.toLowerCase()))) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (rule.regex) {
|
|
45
|
+
const re = new RegExp(rule.regex, "im");
|
|
46
|
+
if (!re.test(regionText))
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
// Evaluate the manifest against captured lines; highest-priority match wins.
|
|
52
|
+
function evaluateRules(manifest, lines) {
|
|
53
|
+
const sorted = [...manifest.rules].sort((a, b) => b.priority - a.priority);
|
|
54
|
+
for (const rule of sorted) {
|
|
55
|
+
const regionText = extractRegion(lines, rule.region);
|
|
56
|
+
if (ruleMatches(rule, regionText)) {
|
|
57
|
+
return { state: rule.state, ruleId: rule.id, hold: rule.skipStateUpdate === true };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { state: "unknown", ruleId: null, hold: false };
|
|
61
|
+
}
|
|
62
|
+
function hashLines(lines) {
|
|
63
|
+
// Cheap content fingerprint — we only need "did it change", not crypto.
|
|
64
|
+
let h = 0;
|
|
65
|
+
const s = lines.join("\n");
|
|
66
|
+
for (let i = 0; i < s.length; i++) {
|
|
67
|
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
68
|
+
}
|
|
69
|
+
return `${s.length}:${h}`;
|
|
70
|
+
}
|
|
71
|
+
export function resolveState(pane, lines, prev) {
|
|
72
|
+
const captureText = lines.join("\n");
|
|
73
|
+
const agent = identifyAgent(pane, captureText);
|
|
74
|
+
if (agent === null)
|
|
75
|
+
return null;
|
|
76
|
+
const manifest = manifestFor(agent);
|
|
77
|
+
const verdict = manifest
|
|
78
|
+
? evaluateRules(manifest, lines)
|
|
79
|
+
: { state: "unknown", ruleId: null, hold: false };
|
|
80
|
+
const hash = hashLines(lines);
|
|
81
|
+
const changed = prev !== undefined && prev.lastCaptureHash !== hash;
|
|
82
|
+
const previousState = prev?.lastState ?? "unknown";
|
|
83
|
+
let state;
|
|
84
|
+
let pendingIdle = false;
|
|
85
|
+
// "blocked" must be earned by an active match every scan — never inherited.
|
|
86
|
+
// Holding it (on scrollback or an ambiguous frame) is exactly what keeps a
|
|
87
|
+
// stale "1 blocked" on the board after you've answered the prompt.
|
|
88
|
+
const keep = (prevState) => prevState === "unknown" || prevState === "blocked" ? "idle" : prevState;
|
|
89
|
+
if (verdict.hold) {
|
|
90
|
+
// Scrollback / menu screen: don't author state, keep what we had.
|
|
91
|
+
state = keep(previousState);
|
|
92
|
+
}
|
|
93
|
+
else if (verdict.state === "blocked") {
|
|
94
|
+
state = "blocked"; // strong signal, wins immediately
|
|
95
|
+
}
|
|
96
|
+
else if (changed) {
|
|
97
|
+
state = "working"; // PTY activity is the authority for "working"
|
|
98
|
+
}
|
|
99
|
+
else if (verdict.state === "working") {
|
|
100
|
+
state = "working"; // explicit interrupt-hint on screen
|
|
101
|
+
}
|
|
102
|
+
else if (verdict.state === "idle") {
|
|
103
|
+
// Debounce: require idle to persist for two reads before committing, so a
|
|
104
|
+
// single quiet frame between output bursts doesn't flip us to idle.
|
|
105
|
+
if (previousState === "working" && prev?.pendingIdle !== true) {
|
|
106
|
+
state = "working";
|
|
107
|
+
pendingIdle = true;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
state = "idle";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// Known agent, nothing matched: fall back without inheriting a stale block.
|
|
115
|
+
state = keep(previousState);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
agent,
|
|
119
|
+
state,
|
|
120
|
+
matchedRule: verdict.ruleId,
|
|
121
|
+
memory: { lastCaptureHash: hash, lastState: state, pendingIdle },
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Git status by shelling out to the real `git` binary (no libgit2 dependency).
|
|
2
|
+
// Cheap enough for a handful of panes; results are memoized per repo root within
|
|
3
|
+
// a single tick so N panes in one repo cost one set of calls.
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
async function git(cwd, args) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await exec("git", ["-C", cwd, ...args], {
|
|
10
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
11
|
+
});
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function repoRoot(cwd) {
|
|
19
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
20
|
+
}
|
|
21
|
+
async function computeStatus(cwd) {
|
|
22
|
+
const branch = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
23
|
+
if (branch === null)
|
|
24
|
+
return null; // not a git repo
|
|
25
|
+
const porcelain = await git(cwd, ["status", "--porcelain"]);
|
|
26
|
+
const dirty = porcelain !== null && porcelain.length > 0;
|
|
27
|
+
let ahead = 0;
|
|
28
|
+
let behind = 0;
|
|
29
|
+
const counts = await git(cwd, [
|
|
30
|
+
"rev-list",
|
|
31
|
+
"--left-right",
|
|
32
|
+
"--count",
|
|
33
|
+
"HEAD...@{upstream}",
|
|
34
|
+
]);
|
|
35
|
+
if (counts) {
|
|
36
|
+
const [a, b] = counts.split(/\s+/);
|
|
37
|
+
ahead = Number(a) || 0;
|
|
38
|
+
behind = Number(b) || 0;
|
|
39
|
+
}
|
|
40
|
+
return { branch: branch === "HEAD" ? null : branch, dirty, ahead, behind };
|
|
41
|
+
}
|
|
42
|
+
// Per-tick cache keyed by repo root, so panes sharing a repo share the answer.
|
|
43
|
+
export function createGitCache() {
|
|
44
|
+
const byRoot = new Map();
|
|
45
|
+
return async function statusFor(cwd) {
|
|
46
|
+
const root = await repoRoot(cwd);
|
|
47
|
+
if (root === null)
|
|
48
|
+
return null;
|
|
49
|
+
let pending = byRoot.get(root);
|
|
50
|
+
if (!pending) {
|
|
51
|
+
// Compute from the repo root so all panes agree regardless of subdir.
|
|
52
|
+
pending = computeStatus(root);
|
|
53
|
+
byRoot.set(root, pending);
|
|
54
|
+
}
|
|
55
|
+
return pending;
|
|
56
|
+
};
|
|
57
|
+
}
|