@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/dist/index.js ADDED
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ // tend — status of AI coding agents across your tmux sessions.
3
+ //
4
+ // tend list agents grouped by session (all sessions) once
5
+ // tend watch live-updating grouped list
6
+ // tend pick interactive picker — select an agent and jump to it
7
+ // tend jump <pane> jump straight to a pane id (e.g. jump %3)
8
+ // tend --json machine-readable output (once)
9
+ // tend --current limit to the current session (default: all sessions)
10
+ // tend --debug <pane> dump each region's extracted text, for rule tuning
11
+ //
12
+ // Flags: --interval <ms> (watch/pick refresh, default 800),
13
+ // --once-delay <ms> (fire-once activity sampling window, default 350).
14
+ import { capturePane, listClients, listPanes, selfClientTty } from "./tmux.js";
15
+ import { identifyAgent } from "./detect.js";
16
+ import { extractRegion } from "./regions.js";
17
+ import { manifestFor } from "./manifests.js";
18
+ import { scan, scanOnce } from "./scan.js";
19
+ import { runPicker } from "./pick.js";
20
+ import { insideTmux, jumpToPane } from "./nav.js";
21
+ import { CLEAR_SCREEN, dim, renderGrouped, renderJson, summaryLine, } from "./render.js";
22
+ function parseArgs(argv) {
23
+ const opts = {
24
+ mode: "default",
25
+ json: false,
26
+ all: true, // scan every session by default (global view across all sessions)
27
+ once: false,
28
+ readonly: false,
29
+ interval: 800,
30
+ onceDelay: 350,
31
+ other: false,
32
+ };
33
+ const args = [...argv];
34
+ while (args.length) {
35
+ const a = args.shift();
36
+ switch (a) {
37
+ case "watch":
38
+ case "pick": // alias — watch is the selectable dashboard
39
+ case "select":
40
+ opts.mode = "watch";
41
+ break;
42
+ case "clients":
43
+ opts.mode = "clients";
44
+ break;
45
+ case "--once":
46
+ case "ls":
47
+ opts.once = true;
48
+ break;
49
+ case "--readonly":
50
+ opts.readonly = true;
51
+ break;
52
+ case "--to":
53
+ opts.targetClient = args.shift();
54
+ break;
55
+ case "--other":
56
+ opts.other = true;
57
+ break;
58
+ case "jump":
59
+ opts.mode = "jump";
60
+ opts.target = args.shift();
61
+ break;
62
+ case "--debug":
63
+ opts.mode = "debug";
64
+ opts.target = args.shift();
65
+ break;
66
+ case "--json":
67
+ opts.json = true;
68
+ break;
69
+ case "--current":
70
+ case "-s":
71
+ opts.all = false;
72
+ break;
73
+ case "--all":
74
+ case "-a":
75
+ opts.all = true;
76
+ break;
77
+ case "--interval":
78
+ opts.interval = Number(args.shift()) || opts.interval;
79
+ break;
80
+ case "--once-delay":
81
+ opts.onceDelay = Number(args.shift()) || opts.onceDelay;
82
+ break;
83
+ case "-h":
84
+ case "--help":
85
+ printHelp();
86
+ process.exit(0);
87
+ default:
88
+ process.stderr.write(`tend: unknown argument "${a}"\n`);
89
+ process.exit(2);
90
+ }
91
+ }
92
+ return opts;
93
+ }
94
+ function printHelp() {
95
+ process.stdout.write([
96
+ "tend — status of AI coding agents across your tmux sessions",
97
+ "",
98
+ "Usage:",
99
+ " tend live, selectable dashboard — ↑/↓ move, enter jumps",
100
+ " (falls back to a one-off snapshot when piped)",
101
+ " tend --once print a grouped snapshot and exit",
102
+ " tend jump <pane> jump to a pane id (e.g. jump %3)",
103
+ " tend clients list attached tmux clients (terminal windows)",
104
+ " tend --json machine-readable snapshot",
105
+ " tend --current limit to the current session",
106
+ " tend --readonly dashboard without selection (display-only pane)",
107
+ " tend --debug <pane> dump extracted regions for a pane (rule tuning)",
108
+ "",
109
+ "Open jumps in another window (keep the dashboard put):",
110
+ " In the dashboard, press `o` to cycle where Enter opens: this window →",
111
+ " each other attached client → back. Or preset it:",
112
+ " --to <tty> open jumps in that client (see `tend clients`)",
113
+ " --other open jumps in the one other attached client",
114
+ "",
115
+ "Options:",
116
+ " --interval <ms> dashboard refresh interval (default 800)",
117
+ " --once-delay <ms> activity sampling window for --once (default 350)",
118
+ "",
119
+ ].join("\n"));
120
+ }
121
+ const scanOpts = (o) => ({ all: o.all });
122
+ async function runOnce(opts) {
123
+ const statuses = await scanOnce(scanOpts(opts), opts.onceDelay);
124
+ if (opts.json) {
125
+ process.stdout.write(renderJson(statuses) + "\n");
126
+ }
127
+ else {
128
+ process.stdout.write(renderGrouped(statuses) + "\n\n" + summaryLine(statuses) + "\n");
129
+ }
130
+ }
131
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
132
+ async function runWatch(opts) {
133
+ let memory = new Map();
134
+ process.on("SIGINT", () => {
135
+ process.stdout.write("\x1b[?25h\n");
136
+ process.exit(0);
137
+ });
138
+ process.stdout.write("\x1b[?25l"); // hide cursor
139
+ let frame = 0;
140
+ for (;;) {
141
+ const result = await scan(scanOpts(opts), memory);
142
+ memory = result.memory;
143
+ const header = new Date().toLocaleTimeString();
144
+ process.stdout.write(CLEAR_SCREEN +
145
+ `tend · ${header}\n\n` +
146
+ renderGrouped(result.statuses, frame++) +
147
+ "\n\n" +
148
+ summaryLine(result.statuses) +
149
+ "\n" +
150
+ dim("Ctrl-C to exit") +
151
+ "\n");
152
+ await sleep(opts.interval);
153
+ }
154
+ }
155
+ // Resolve which client (window) a jump should open in, from --to / --other.
156
+ // Returns a client tty, or null to use the current window. Exits with guidance
157
+ // when --other is ambiguous.
158
+ async function resolveTargetClient(opts) {
159
+ if (opts.targetClient)
160
+ return opts.targetClient;
161
+ if (!opts.other)
162
+ return null;
163
+ const clients = await listClients();
164
+ const self = insideTmux() ? await selfClientTty() : null;
165
+ const others = clients.filter((c) => c.tty !== self);
166
+ if (others.length === 1)
167
+ return others[0].tty;
168
+ if (others.length === 0) {
169
+ process.stderr.write("tend: --other found no other attached client.\n");
170
+ process.exit(1);
171
+ }
172
+ process.stderr.write("tend: --other is ambiguous — multiple other clients attached:\n" +
173
+ others.map((c) => ` ${c.tty} (${c.session})`).join("\n") +
174
+ "\nPick one with --to <tty>.\n");
175
+ process.exit(1);
176
+ }
177
+ async function runClients() {
178
+ const clients = await listClients();
179
+ const self = insideTmux() ? await selfClientTty() : null;
180
+ if (clients.length === 0) {
181
+ process.stdout.write(dim("No tmux clients attached.\n"));
182
+ return;
183
+ }
184
+ for (const c of clients) {
185
+ const mark = c.tty === self ? dim(" (this window)") : "";
186
+ process.stdout.write(`${c.tty} → ${c.session}${mark}\n`);
187
+ }
188
+ }
189
+ async function runJump(opts) {
190
+ const target = opts.target;
191
+ if (!target) {
192
+ process.stderr.write("tend jump requires a pane id, e.g. jump %3\n");
193
+ process.exit(2);
194
+ }
195
+ const wanted = target.startsWith("%") ? target : `%${target}`;
196
+ const panes = await listPanes({ all: true });
197
+ const pane = panes.find((p) => p.id === wanted);
198
+ if (!pane) {
199
+ process.stderr.write(`tend: pane "${target}" not found\n`);
200
+ process.exit(1);
201
+ }
202
+ const client = await resolveTargetClient(opts);
203
+ await jumpToPane(pane, client ? { client } : {});
204
+ const where = client ? `${client}` : `${pane.sessionName} (${pane.id})`;
205
+ process.stdout.write(dim(`→ ${where}\n`));
206
+ }
207
+ // Debug: show what each region extractor pulls from a pane so you can see why a
208
+ // rule did or didn't match, then iterate on manifests.ts.
209
+ async function runDebug(opts) {
210
+ const target = opts.target;
211
+ if (!target) {
212
+ process.stderr.write("tend --debug requires a pane id, e.g. --debug %3\n");
213
+ process.exit(2);
214
+ }
215
+ const wanted = target.startsWith("%") ? target : `%${target}`;
216
+ const panes = await listPanes({ all: true });
217
+ const pane = panes.find((p) => p.id === wanted);
218
+ if (!pane) {
219
+ process.stderr.write(`tend: pane "${target}" not found\n`);
220
+ process.exit(1);
221
+ }
222
+ const lines = await capturePane(pane.id);
223
+ const agent = identifyAgent(pane, lines.join("\n"));
224
+ process.stdout.write(`pane ${pane.id} command=${pane.command} agent=${agent ?? "(none)"}\n`);
225
+ const regions = [
226
+ "full",
227
+ "after_last_horizontal_rule",
228
+ "prompt_box_body",
229
+ { bottom_non_empty_lines: 6 },
230
+ ];
231
+ for (const region of regions) {
232
+ const label = typeof region === "object" ? "bottom_non_empty_lines(6)" : region;
233
+ process.stdout.write(`\n\x1b[1m── region: ${label} ──\x1b[0m\n`);
234
+ process.stdout.write(extractRegion(lines, region) + "\n");
235
+ }
236
+ if (agent) {
237
+ const manifest = manifestFor(agent);
238
+ process.stdout.write(`\n\x1b[1m── manifest rules for ${agent} ──\x1b[0m\n`);
239
+ for (const rule of manifest?.rules ?? []) {
240
+ const regionText = extractRegion(lines, rule.region);
241
+ const matched = ruleWouldMatch(rule, regionText);
242
+ const mark = matched ? "\x1b[32m✓\x1b[0m" : "\x1b[90m·\x1b[0m";
243
+ process.stdout.write(` ${mark} [${rule.priority}] ${rule.id} → ${rule.state}\n`);
244
+ }
245
+ }
246
+ }
247
+ // Small duplicate of the engine's predicate, kept here so --debug has no need to
248
+ // export internals. If you change ruleMatches in detect.ts, mirror it here.
249
+ function ruleWouldMatch(rule, regionText) {
250
+ const hay = regionText.toLowerCase();
251
+ if (rule.contains && !rule.contains.every((s) => hay.includes(s.toLowerCase())))
252
+ return false;
253
+ if (rule.anyContains && !rule.anyContains.some((s) => hay.includes(s.toLowerCase())))
254
+ return false;
255
+ if (rule.not && rule.not.some((s) => hay.includes(s.toLowerCase())))
256
+ return false;
257
+ if (rule.regex && !new RegExp(rule.regex, "im").test(regionText))
258
+ return false;
259
+ return true;
260
+ }
261
+ async function main() {
262
+ const opts = parseArgs(process.argv.slice(2));
263
+ switch (opts.mode) {
264
+ case "default":
265
+ case "watch": {
266
+ // A snapshot is forced by --json / --once, and is also the right default
267
+ // when output is piped (so `tend | grep` and `tend > file` behave).
268
+ if (opts.json || opts.once) {
269
+ await runOnce(opts);
270
+ break;
271
+ }
272
+ const interactiveTty = process.stdin.isTTY === true && process.stdout.isTTY === true;
273
+ if (interactiveTty && !opts.readonly) {
274
+ // Optional preset target window from --to/--other; `o` cycles it live.
275
+ const initialTarget = opts.targetClient ?? (opts.other ? await resolveTargetClient(opts) : null);
276
+ await runPicker(scanOpts(opts), opts.interval, initialTarget ?? undefined);
277
+ }
278
+ else if (opts.readonly) {
279
+ await runWatch(opts); // display-only repaint (explicit)
280
+ }
281
+ else if (opts.mode === "watch") {
282
+ await runWatch(opts); // `tend watch` piped → repaint loop (explicit)
283
+ }
284
+ else {
285
+ await runOnce(opts); // bare `tend` piped → one-off snapshot
286
+ }
287
+ break;
288
+ }
289
+ case "jump":
290
+ await runJump(opts);
291
+ break;
292
+ case "clients":
293
+ await runClients();
294
+ break;
295
+ case "debug":
296
+ await runDebug(opts);
297
+ break;
298
+ }
299
+ }
300
+ main().catch((err) => {
301
+ process.stderr.write(`tend: ${err instanceof Error ? err.message : String(err)}\n`);
302
+ process.exit(1);
303
+ });
@@ -0,0 +1,133 @@
1
+ // Agent manifests — the declarative rule sets. This is the file you tune.
2
+ //
3
+ // Detection philosophy: read what's already on the screen rather than demand
4
+ // agents speak a protocol. Each rule matches a region of the
5
+ // captured terminal; the highest-priority match wins. "blocked" is deliberately
6
+ // strict (only fires on a positive match of a known approval UI) so you don't
7
+ // get false "needs you" alarms; everything else falls back to idle.
8
+ //
9
+ // These patterns are a starting point. Run `tend --debug <pane>` against a
10
+ // live pane to see exactly what text each region extracts, then adjust.
11
+ const claude = {
12
+ agent: "claude",
13
+ match: ["claude"],
14
+ // Claude Code renames its process comm to its version (e.g. "2.1.200"), so the
15
+ // literal name never appears — this catches it regardless of screen state.
16
+ commandPattern: "^\\d+\\.\\d+\\.\\d+",
17
+ // Persistent on-screen chrome, for the rare case comm is generic (`node`).
18
+ // These are strings Claude keeps visible in SOME state during a live session —
19
+ // the mode footer, the shortcuts hint, the interrupt hint, the permission
20
+ // prompt — NOT the welcome banner, which scrolls away mid-conversation.
21
+ signature: [
22
+ "shift+tab to cycle", // mode footer (auto/plan mode)
23
+ "? for shortcuts", // default footer
24
+ "esc to interrupt", // working footer
25
+ "for agents", // "← for agents" footer
26
+ "Do you want to proceed?", // permission prompt
27
+ "No, and tell Claude", // permission prompt option
28
+ "Claude Code", // welcome banner (fresh session)
29
+ ],
30
+ rules: [
31
+ // Scrollback / transcript / picker screens don't reflect live state — hold.
32
+ {
33
+ id: "transcript_or_picker",
34
+ state: "unknown",
35
+ priority: 950,
36
+ region: "full",
37
+ anyContains: ["(END)", "Select a model", "Choose a model", "─ Transcript ─"],
38
+ skipStateUpdate: true,
39
+ },
40
+ // Blocked = a *live* permission/approval prompt: the selection cursor (❯)
41
+ // sits on a numbered choice while Claude waits for you. Keying on the live
42
+ // cursor (not the question text) is deliberate — the cursor vanishes the
43
+ // instant you answer, so a prompt lingering in the scroll buffer no longer
44
+ // counts as blocked. `not` guards against a stale answered prompt whose text
45
+ // is still on screen but whose cursor has moved on.
46
+ {
47
+ id: "selection_prompt",
48
+ state: "blocked",
49
+ priority: 900,
50
+ region: { bottom_non_empty_lines: 15 },
51
+ regex: "^\\s*❯\\s+\\d+\\.\\s", // e.g. "❯ 1. Yes"
52
+ not: ["esc to interrupt"], // if it's generating, it isn't waiting on you
53
+ },
54
+ // Actively generating — Claude shows an interrupt hint while working.
55
+ {
56
+ id: "working_interrupt_hint",
57
+ state: "working",
58
+ priority: 700,
59
+ region: { bottom_non_empty_lines: 8 },
60
+ anyContains: ["esc to interrupt", "(esc to interrupt)"],
61
+ },
62
+ // Empty prompt box with the caret and no menu chrome = idle, awaiting input.
63
+ {
64
+ id: "idle_prompt",
65
+ state: "idle",
66
+ priority: 100,
67
+ region: "prompt_box_body",
68
+ regex: "^\\s*[>❯]",
69
+ not: ["esc to interrupt", "1. Yes", "Do you want"],
70
+ },
71
+ ],
72
+ };
73
+ const codex = {
74
+ agent: "codex",
75
+ match: ["codex"],
76
+ signature: ["OpenAI Codex", "Codex CLI", "codex>"],
77
+ rules: [
78
+ // Blocked = a *live* prompt Codex is waiting on — a command-approval dialog
79
+ // or a selectable question (request_user_input). Both render as a numbered
80
+ // list with the live selection cursor `›` (U+203A, Codex's analogue of
81
+ // Claude's `❯`) on one choice. Key on that cursor row, NOT footer text:
82
+ // Codex keeps "esc to interrupt" in the prompt footer even while blocked
83
+ // (e.g. "enter to submit answer | esc to interrupt"), so the interrupt hint
84
+ // can't distinguish blocked from working — only the live cursor can. Like
85
+ // Claude's ❯ cursor, it vanishes the instant you answer, so a prompt left in
86
+ // scrollback stops counting as blocked.
87
+ {
88
+ id: "selection_prompt",
89
+ state: "blocked",
90
+ priority: 900,
91
+ region: { bottom_non_empty_lines: 20 },
92
+ regex: "^\\s*›\\s+\\d+\\.\\s", // e.g. "› 1. Yes"
93
+ },
94
+ // Belt-and-suspenders: explicit approval/question wording, for a prompt
95
+ // whose numbered cursor row isn't in the captured window (long command
96
+ // preview pushing it out of view).
97
+ {
98
+ id: "approval_prompt",
99
+ state: "blocked",
100
+ priority: 890,
101
+ region: { bottom_non_empty_lines: 20 },
102
+ anyContains: [
103
+ "Allow command",
104
+ "wants to run",
105
+ "Do you want to apply",
106
+ "enter to submit answer", // selectable-question footer
107
+ ],
108
+ },
109
+ {
110
+ id: "working_interrupt_hint",
111
+ state: "working",
112
+ priority: 700,
113
+ region: { bottom_non_empty_lines: 8 },
114
+ anyContains: ["Esc to interrupt", "esc to interrupt", "Working", "Thinking"],
115
+ },
116
+ {
117
+ id: "idle_prompt",
118
+ state: "idle",
119
+ priority: 100,
120
+ region: "prompt_box_body",
121
+ regex: "^\\s*[>›❯▌]", // Codex's input marker is `›` (U+203A)
122
+ not: ["Esc to interrupt", "Allow command", "enter to submit answer"],
123
+ },
124
+ ],
125
+ };
126
+ // Generic fallback for any other terminal agent: no screen rules, so its state
127
+ // comes purely from PTY activity (content changed since last tick = working).
128
+ // Add signatures/rules here as you bring more agents into the fold.
129
+ export const MANIFESTS = [claude, codex];
130
+ // Ordered agent-name → manifest lookup used by the detector.
131
+ export function manifestFor(agent) {
132
+ return MANIFESTS.find((m) => m.agent === agent);
133
+ }
package/dist/nav.js ADDED
@@ -0,0 +1,33 @@
1
+ // Jump the terminal to an agent's pane. Inside tmux we switch the attached
2
+ // client instantly; outside tmux we pre-select the pane on the server and then
3
+ // hand the terminal to `tmux attach`.
4
+ import { spawn } from "node:child_process";
5
+ import { preselectPane, switchClientToPane, switchToPane } from "./tmux.js";
6
+ export { listClients, selfClientTty } from "./tmux.js";
7
+ export function insideTmux() {
8
+ return Boolean(process.env.TMUX);
9
+ }
10
+ // Jump the terminal to an agent's pane.
11
+ // - opts.client set → move that other client to the pane (dashboard stays).
12
+ // - inside tmux → switch our own client and resolve immediately.
13
+ // - outside tmux → pre-select the pane, then `tmux attach` (blocks until
14
+ // the user detaches).
15
+ export async function jumpToPane(pane, opts = {}) {
16
+ const { sessionName, id: paneId } = pane;
17
+ if (opts.client) {
18
+ await switchClientToPane(opts.client, sessionName, paneId);
19
+ return;
20
+ }
21
+ if (insideTmux()) {
22
+ await switchToPane(sessionName, paneId);
23
+ return;
24
+ }
25
+ await preselectPane(paneId);
26
+ await new Promise((resolve, reject) => {
27
+ const child = spawn("tmux", ["attach-session", "-t", sessionName], {
28
+ stdio: "inherit",
29
+ });
30
+ child.on("close", () => resolve());
31
+ child.on("error", reject);
32
+ });
33
+ }
package/dist/pick.js ADDED
@@ -0,0 +1,214 @@
1
+ // Interactive live dashboard: a grouped list of agents across all tmux sessions
2
+ // that re-scans on an interval AND lets you act on it. Arrow keys (or j/k) move
3
+ // the cursor; Enter jumps to that pane; o cycles where the jump opens; r
4
+ // refreshes; q / Esc / Ctrl-C quits.
5
+ //
6
+ // Jump target: by default Enter opens the agent in *this* window (the one the
7
+ // dashboard runs in), keeping the dashboard alive in its own pane. Press `o` to
8
+ // cycle the target to another attached client (another terminal window), so you
9
+ // can watch here and open blocked agents over there — the dashboard never moves.
10
+ // (When targeting this window from a plain terminal — not inside tmux — jumping
11
+ // means `tmux attach`, which takes over the terminal, so there we exit.)
12
+ import { scan } from "./scan.js";
13
+ import { insideTmux, jumpToPane, listClients, selfClientTty } from "./nav.js";
14
+ import { agentRow, bold, dim, groupBySession, summaryLine } from "./render.js";
15
+ const ALT_ON = "\x1b[?1049h";
16
+ const ALT_OFF = "\x1b[?1049l";
17
+ const HIDE_CURSOR = "\x1b[?25l";
18
+ const SHOW_CURSOR = "\x1b[?25h";
19
+ const CURSOR_HOME = "\x1b[H"; // move to top-left without clearing (no flicker)
20
+ const CLEAR_EOL = "\x1b[K"; // erase from cursor to end of *line*
21
+ const CLEAR_BELOW = "\x1b[J"; // erase from cursor to end of screen
22
+ const ANIM_MS = 90; // spinner tick — faster than the scan interval
23
+ const shortTty = (tty) => tty.replace(/^\/dev\//, "");
24
+ export async function runPicker(opts, intervalMs, initialTarget) {
25
+ let statuses = [];
26
+ let memory = new Map();
27
+ let cursorPaneId = null; // track selection by id across rescans
28
+ let clients = [];
29
+ let targetTty = initialTarget ?? null; // null = this window
30
+ let selfTty = null;
31
+ let done = false;
32
+ let statusMsg = ""; // transient line (e.g. "sent … to …")
33
+ let frame = 0; // spinner animation frame
34
+ let timer;
35
+ let animTimer;
36
+ const out = process.stdout;
37
+ const input = process.stdin;
38
+ // Flattened, display-ordered list of selectable agent rows (grouped order).
39
+ const selectable = () => groupBySession(statuses).flatMap((g) => g.agents);
40
+ const otherClients = () => clients.filter((c) => c.tty !== selfTty);
41
+ const currentIndex = () => {
42
+ const list = selectable();
43
+ if (list.length === 0)
44
+ return -1;
45
+ const i = list.findIndex((s) => s.pane.id === cursorPaneId);
46
+ return i >= 0 ? i : 0;
47
+ };
48
+ const moveCursor = (delta) => {
49
+ const list = selectable();
50
+ if (list.length === 0)
51
+ return;
52
+ const next = Math.min(list.length - 1, Math.max(0, currentIndex() + delta));
53
+ cursorPaneId = list[next].pane.id;
54
+ statusMsg = ""; // clear the note once you start moving again
55
+ };
56
+ // Cycle the jump target: this window → each other client → back.
57
+ const cycleTarget = () => {
58
+ const others = otherClients();
59
+ if (others.length === 0) {
60
+ // Nothing to target — tell the user how to get a second window instead of
61
+ // silently doing nothing.
62
+ statusMsg =
63
+ clients.length <= 1
64
+ ? "no other tmux client — run `tmux attach` in another terminal window"
65
+ : "no other client detected (couldn't tell this window apart)";
66
+ render();
67
+ return;
68
+ }
69
+ const cycle = [null, ...others.map((c) => c.tty)];
70
+ let idx = cycle.indexOf(targetTty);
71
+ if (idx < 0)
72
+ idx = 0;
73
+ targetTty = cycle[(idx + 1) % cycle.length] ?? null;
74
+ statusMsg = "";
75
+ render();
76
+ };
77
+ const targetLabel = () => {
78
+ if (!targetTty)
79
+ return "this window";
80
+ const c = clients.find((x) => x.tty === targetTty);
81
+ return shortTty(targetTty) + (c ? ` · ${c.session}` : "");
82
+ };
83
+ const render = () => {
84
+ const groups = groupBySession(statuses);
85
+ const selectedId = selectable()[currentIndex()]?.pane.id ?? null;
86
+ const others = otherClients().length;
87
+ const lines = [];
88
+ lines.push(bold("tend") + dim(" ↑/↓ move · enter jump · o target · r refresh · q quit"));
89
+ // Show where Enter opens, plus how many other windows are available to target.
90
+ const windowsNote = others > 0 ? ` (${others} other window${others > 1 ? "s" : ""})` : "";
91
+ lines.push(dim(`opens in: ${targetLabel()}${windowsNote}`) + (statusMsg ? dim(` ${statusMsg}`) : ""));
92
+ if (groups.length === 0) {
93
+ lines.push(dim("No AI agents detected in any tmux session."));
94
+ }
95
+ else {
96
+ for (const { session, agents } of groups) {
97
+ const blocked = agents.filter((a) => a.state === "blocked").length;
98
+ const suffix = blocked ? ` \x1b[31m· ${blocked} blocked\x1b[0m` : "";
99
+ lines.push(bold(`▸ ${session}`) + dim(` (${agents.length})`) + suffix);
100
+ for (const a of agents) {
101
+ lines.push(agentRow(a, a.pane.id === selectedId, frame));
102
+ }
103
+ lines.push("");
104
+ }
105
+ }
106
+ lines.push(summaryLine(statuses));
107
+ // Home + overwrite, erasing each line's tail (CLEAR_EOL) so a line that got
108
+ // shorter doesn't leave ghost text (e.g. a stale "· 1 blocked" suffix), plus
109
+ // CLEAR_BELOW for when this frame has fewer lines. No full-screen wipe, so
110
+ // the ~90ms spinner repaints stay flicker-free.
111
+ out.write(CURSOR_HOME + lines.map((l) => l + CLEAR_EOL).join("\n") + "\n" + CLEAR_BELOW);
112
+ };
113
+ const refresh = async () => {
114
+ const result = await scan(opts, memory);
115
+ memory = result.memory;
116
+ statuses = result.statuses;
117
+ clients = await listClients();
118
+ // If our chosen target client went away, fall back to this window.
119
+ if (targetTty && !clients.some((c) => c.tty === targetTty))
120
+ targetTty = null;
121
+ if (cursorPaneId === null)
122
+ cursorPaneId = selectable()[0]?.pane.id ?? null;
123
+ if (!done)
124
+ render();
125
+ };
126
+ const cleanup = () => {
127
+ if (timer)
128
+ clearInterval(timer);
129
+ if (animTimer)
130
+ clearInterval(animTimer);
131
+ input.setRawMode?.(false);
132
+ input.pause();
133
+ input.removeListener("data", onKey);
134
+ out.write(SHOW_CURSOR + ALT_OFF);
135
+ };
136
+ const jump = async () => {
137
+ const target = selectable()[currentIndex()];
138
+ if (!target)
139
+ return;
140
+ // Targeting another client: move that window, keep the dashboard put.
141
+ if (targetTty) {
142
+ try {
143
+ await jumpToPane(target.pane, { client: targetTty });
144
+ statusMsg = `→ sent ${target.agent} (${target.pane.id}) to ${shortTty(targetTty)}`;
145
+ }
146
+ catch {
147
+ statusMsg = `⚠ ${shortTty(targetTty)} unavailable`;
148
+ }
149
+ render();
150
+ return;
151
+ }
152
+ // Targeting this window, inside tmux: switch our client but keep running.
153
+ if (insideTmux()) {
154
+ await jumpToPane(target.pane);
155
+ statusMsg = `→ jumped to ${target.agent} in ${target.pane.sessionName} (${target.pane.id})`;
156
+ render();
157
+ return;
158
+ }
159
+ // This window, outside tmux: attach takes over the terminal → tear down.
160
+ done = true;
161
+ cleanup();
162
+ await jumpToPane(target.pane);
163
+ process.exit(0);
164
+ };
165
+ function onKey(buf) {
166
+ const key = buf.toString("utf8");
167
+ if (key === "\x03" || key === "q" || key === "\x1b") {
168
+ done = true;
169
+ cleanup();
170
+ process.exit(0);
171
+ }
172
+ else if (key === "\r" || key === "\n") {
173
+ void jump();
174
+ }
175
+ else if (key === "\x1b[A" || key === "k") {
176
+ moveCursor(-1);
177
+ render();
178
+ }
179
+ else if (key === "\x1b[B" || key === "j") {
180
+ moveCursor(1);
181
+ render();
182
+ }
183
+ else if (key === "o" || key === "\t") {
184
+ cycleTarget();
185
+ }
186
+ else if (key === "r") {
187
+ void refresh();
188
+ }
189
+ }
190
+ // Setup terminal. We only have a "self" client to exclude when we're actually
191
+ // running inside a tmux client; from a plain terminal every attached client is
192
+ // a legitimate target (and tmux would otherwise report one of them as "self").
193
+ selfTty = insideTmux() ? await selfClientTty() : null;
194
+ out.write(ALT_ON + HIDE_CURSOR);
195
+ input.setRawMode?.(true);
196
+ input.resume();
197
+ input.setEncoding?.("utf8");
198
+ input.on("data", onKey);
199
+ process.on("SIGINT", () => {
200
+ done = true;
201
+ cleanup();
202
+ process.exit(0);
203
+ });
204
+ await refresh();
205
+ timer = setInterval(() => void refresh(), intervalMs);
206
+ // Advance the spinner only while something is working, so an all-idle board
207
+ // stays quiet (no needless repaints).
208
+ animTimer = setInterval(() => {
209
+ if (done || !statuses.some((s) => s.state === "working"))
210
+ return;
211
+ frame++;
212
+ render();
213
+ }, ANIM_MS);
214
+ }