@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/src/index.ts ADDED
@@ -0,0 +1,341 @@
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
+
15
+ import { capturePane, listClients, listPanes, selfClientTty } from "./tmux.ts";
16
+ import { identifyAgent } from "./detect.ts";
17
+ import { extractRegion } from "./regions.ts";
18
+ import { manifestFor } from "./manifests.ts";
19
+ import { scan, scanOnce, type ScanOptions } from "./scan.ts";
20
+ import { runPicker } from "./pick.ts";
21
+ import { insideTmux, jumpToPane } from "./nav.ts";
22
+ import type { PaneMemory } from "./detect.ts";
23
+ import {
24
+ CLEAR_SCREEN,
25
+ dim,
26
+ renderGrouped,
27
+ renderJson,
28
+ summaryLine,
29
+ } from "./render.ts";
30
+
31
+ interface Options {
32
+ // "default" = dashboard in a terminal, snapshot when piped. "watch" = the
33
+ // dashboard, explicitly requested (repaints even when piped).
34
+ mode: "default" | "watch" | "jump" | "debug" | "clients";
35
+ json: boolean;
36
+ all: boolean;
37
+ once: boolean; // force a one-off snapshot even on an interactive terminal
38
+ readonly: boolean;
39
+ interval: number;
40
+ onceDelay: number;
41
+ target?: string; // pane id for jump/debug
42
+ targetClient?: string; // --to <tty>: open jumps in this client (window)
43
+ other: boolean; // --other: open jumps in the one other attached client
44
+ }
45
+
46
+ function parseArgs(argv: string[]): Options {
47
+ const opts: Options = {
48
+ mode: "default",
49
+ json: false,
50
+ all: true, // scan every session by default (global view across all sessions)
51
+ once: false,
52
+ readonly: false,
53
+ interval: 800,
54
+ onceDelay: 350,
55
+ other: false,
56
+ };
57
+ const args = [...argv];
58
+ while (args.length) {
59
+ const a = args.shift()!;
60
+ switch (a) {
61
+ case "watch":
62
+ case "pick": // alias — watch is the selectable dashboard
63
+ case "select":
64
+ opts.mode = "watch";
65
+ break;
66
+ case "clients":
67
+ opts.mode = "clients";
68
+ break;
69
+ case "--once":
70
+ case "ls":
71
+ opts.once = true;
72
+ break;
73
+ case "--readonly":
74
+ opts.readonly = true;
75
+ break;
76
+ case "--to":
77
+ opts.targetClient = args.shift();
78
+ break;
79
+ case "--other":
80
+ opts.other = true;
81
+ break;
82
+ case "jump":
83
+ opts.mode = "jump";
84
+ opts.target = args.shift();
85
+ break;
86
+ case "--debug":
87
+ opts.mode = "debug";
88
+ opts.target = args.shift();
89
+ break;
90
+ case "--json":
91
+ opts.json = true;
92
+ break;
93
+ case "--current":
94
+ case "-s":
95
+ opts.all = false;
96
+ break;
97
+ case "--all":
98
+ case "-a":
99
+ opts.all = true;
100
+ break;
101
+ case "--interval":
102
+ opts.interval = Number(args.shift()) || opts.interval;
103
+ break;
104
+ case "--once-delay":
105
+ opts.onceDelay = Number(args.shift()) || opts.onceDelay;
106
+ break;
107
+ case "-h":
108
+ case "--help":
109
+ printHelp();
110
+ process.exit(0);
111
+ default:
112
+ process.stderr.write(`tend: unknown argument "${a}"\n`);
113
+ process.exit(2);
114
+ }
115
+ }
116
+ return opts;
117
+ }
118
+
119
+ function printHelp(): void {
120
+ process.stdout.write(
121
+ [
122
+ "tend — status of AI coding agents across your tmux sessions",
123
+ "",
124
+ "Usage:",
125
+ " tend live, selectable dashboard — ↑/↓ move, enter jumps",
126
+ " (falls back to a one-off snapshot when piped)",
127
+ " tend --once print a grouped snapshot and exit",
128
+ " tend jump <pane> jump to a pane id (e.g. jump %3)",
129
+ " tend clients list attached tmux clients (terminal windows)",
130
+ " tend --json machine-readable snapshot",
131
+ " tend --current limit to the current session",
132
+ " tend --readonly dashboard without selection (display-only pane)",
133
+ " tend --debug <pane> dump extracted regions for a pane (rule tuning)",
134
+ "",
135
+ "Open jumps in another window (keep the dashboard put):",
136
+ " In the dashboard, press `o` to cycle where Enter opens: this window →",
137
+ " each other attached client → back. Or preset it:",
138
+ " --to <tty> open jumps in that client (see `tend clients`)",
139
+ " --other open jumps in the one other attached client",
140
+ "",
141
+ "Options:",
142
+ " --interval <ms> dashboard refresh interval (default 800)",
143
+ " --once-delay <ms> activity sampling window for --once (default 350)",
144
+ "",
145
+ ].join("\n"),
146
+ );
147
+ }
148
+
149
+ const scanOpts = (o: Options): ScanOptions => ({ all: o.all });
150
+
151
+ async function runOnce(opts: Options): Promise<void> {
152
+ const statuses = await scanOnce(scanOpts(opts), opts.onceDelay);
153
+ if (opts.json) {
154
+ process.stdout.write(renderJson(statuses) + "\n");
155
+ } else {
156
+ process.stdout.write(renderGrouped(statuses) + "\n\n" + summaryLine(statuses) + "\n");
157
+ }
158
+ }
159
+
160
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
161
+
162
+ async function runWatch(opts: Options): Promise<void> {
163
+ let memory = new Map<string, PaneMemory>();
164
+ process.on("SIGINT", () => {
165
+ process.stdout.write("\x1b[?25h\n");
166
+ process.exit(0);
167
+ });
168
+ process.stdout.write("\x1b[?25l"); // hide cursor
169
+ let frame = 0;
170
+ for (;;) {
171
+ const result = await scan(scanOpts(opts), memory);
172
+ memory = result.memory;
173
+ const header = new Date().toLocaleTimeString();
174
+ process.stdout.write(
175
+ CLEAR_SCREEN +
176
+ `tend · ${header}\n\n` +
177
+ renderGrouped(result.statuses, frame++) +
178
+ "\n\n" +
179
+ summaryLine(result.statuses) +
180
+ "\n" +
181
+ dim("Ctrl-C to exit") +
182
+ "\n",
183
+ );
184
+ await sleep(opts.interval);
185
+ }
186
+ }
187
+
188
+ // Resolve which client (window) a jump should open in, from --to / --other.
189
+ // Returns a client tty, or null to use the current window. Exits with guidance
190
+ // when --other is ambiguous.
191
+ async function resolveTargetClient(opts: Options): Promise<string | null> {
192
+ if (opts.targetClient) return opts.targetClient;
193
+ if (!opts.other) return null;
194
+ const clients = await listClients();
195
+ const self = insideTmux() ? await selfClientTty() : null;
196
+ const others = clients.filter((c) => c.tty !== self);
197
+ if (others.length === 1) return others[0]!.tty;
198
+ if (others.length === 0) {
199
+ process.stderr.write("tend: --other found no other attached client.\n");
200
+ process.exit(1);
201
+ }
202
+ process.stderr.write(
203
+ "tend: --other is ambiguous — multiple other clients attached:\n" +
204
+ others.map((c) => ` ${c.tty} (${c.session})`).join("\n") +
205
+ "\nPick one with --to <tty>.\n",
206
+ );
207
+ process.exit(1);
208
+ }
209
+
210
+ async function runClients(): Promise<void> {
211
+ const clients = await listClients();
212
+ const self = insideTmux() ? await selfClientTty() : null;
213
+ if (clients.length === 0) {
214
+ process.stdout.write(dim("No tmux clients attached.\n"));
215
+ return;
216
+ }
217
+ for (const c of clients) {
218
+ const mark = c.tty === self ? dim(" (this window)") : "";
219
+ process.stdout.write(`${c.tty} → ${c.session}${mark}\n`);
220
+ }
221
+ }
222
+
223
+ async function runJump(opts: Options): Promise<void> {
224
+ const target = opts.target;
225
+ if (!target) {
226
+ process.stderr.write("tend jump requires a pane id, e.g. jump %3\n");
227
+ process.exit(2);
228
+ }
229
+ const wanted = target.startsWith("%") ? target : `%${target}`;
230
+ const panes = await listPanes({ all: true });
231
+ const pane = panes.find((p) => p.id === wanted);
232
+ if (!pane) {
233
+ process.stderr.write(`tend: pane "${target}" not found\n`);
234
+ process.exit(1);
235
+ }
236
+ const client = await resolveTargetClient(opts);
237
+ await jumpToPane(pane, client ? { client } : {});
238
+ const where = client ? `${client}` : `${pane.sessionName} (${pane.id})`;
239
+ process.stdout.write(dim(`→ ${where}\n`));
240
+ }
241
+
242
+ // Debug: show what each region extractor pulls from a pane so you can see why a
243
+ // rule did or didn't match, then iterate on manifests.ts.
244
+ async function runDebug(opts: Options): Promise<void> {
245
+ const target = opts.target;
246
+ if (!target) {
247
+ process.stderr.write("tend --debug requires a pane id, e.g. --debug %3\n");
248
+ process.exit(2);
249
+ }
250
+ const wanted = target.startsWith("%") ? target : `%${target}`;
251
+ const panes = await listPanes({ all: true });
252
+ const pane = panes.find((p) => p.id === wanted);
253
+ if (!pane) {
254
+ process.stderr.write(`tend: pane "${target}" not found\n`);
255
+ process.exit(1);
256
+ }
257
+ const lines = await capturePane(pane.id);
258
+ const agent = identifyAgent(pane, lines.join("\n"));
259
+ process.stdout.write(
260
+ `pane ${pane.id} command=${pane.command} agent=${agent ?? "(none)"}\n`,
261
+ );
262
+ const regions = [
263
+ "full",
264
+ "after_last_horizontal_rule",
265
+ "prompt_box_body",
266
+ { bottom_non_empty_lines: 6 },
267
+ ] as const;
268
+ for (const region of regions) {
269
+ const label = typeof region === "object" ? "bottom_non_empty_lines(6)" : region;
270
+ process.stdout.write(`\n\x1b[1m── region: ${label} ──\x1b[0m\n`);
271
+ process.stdout.write(extractRegion(lines, region) + "\n");
272
+ }
273
+ if (agent) {
274
+ const manifest = manifestFor(agent);
275
+ process.stdout.write(`\n\x1b[1m── manifest rules for ${agent} ──\x1b[0m\n`);
276
+ for (const rule of manifest?.rules ?? []) {
277
+ const regionText = extractRegion(lines, rule.region);
278
+ const matched = ruleWouldMatch(rule, regionText);
279
+ const mark = matched ? "\x1b[32m✓\x1b[0m" : "\x1b[90m·\x1b[0m";
280
+ process.stdout.write(` ${mark} [${rule.priority}] ${rule.id} → ${rule.state}\n`);
281
+ }
282
+ }
283
+ }
284
+
285
+ // Small duplicate of the engine's predicate, kept here so --debug has no need to
286
+ // export internals. If you change ruleMatches in detect.ts, mirror it here.
287
+ function ruleWouldMatch(
288
+ rule: { contains?: string[]; anyContains?: string[]; not?: string[]; regex?: string },
289
+ regionText: string,
290
+ ): boolean {
291
+ const hay = regionText.toLowerCase();
292
+ if (rule.contains && !rule.contains.every((s) => hay.includes(s.toLowerCase()))) return false;
293
+ if (rule.anyContains && !rule.anyContains.some((s) => hay.includes(s.toLowerCase()))) return false;
294
+ if (rule.not && rule.not.some((s) => hay.includes(s.toLowerCase()))) return false;
295
+ if (rule.regex && !new RegExp(rule.regex, "im").test(regionText)) return false;
296
+ return true;
297
+ }
298
+
299
+ async function main(): Promise<void> {
300
+ const opts = parseArgs(process.argv.slice(2));
301
+ switch (opts.mode) {
302
+ case "default":
303
+ case "watch": {
304
+ // A snapshot is forced by --json / --once, and is also the right default
305
+ // when output is piped (so `tend | grep` and `tend > file` behave).
306
+ if (opts.json || opts.once) {
307
+ await runOnce(opts);
308
+ break;
309
+ }
310
+ const interactiveTty =
311
+ process.stdin.isTTY === true && process.stdout.isTTY === true;
312
+ if (interactiveTty && !opts.readonly) {
313
+ // Optional preset target window from --to/--other; `o` cycles it live.
314
+ const initialTarget =
315
+ opts.targetClient ?? (opts.other ? await resolveTargetClient(opts) : null);
316
+ await runPicker(scanOpts(opts), opts.interval, initialTarget ?? undefined);
317
+ } else if (opts.readonly) {
318
+ await runWatch(opts); // display-only repaint (explicit)
319
+ } else if (opts.mode === "watch") {
320
+ await runWatch(opts); // `tend watch` piped → repaint loop (explicit)
321
+ } else {
322
+ await runOnce(opts); // bare `tend` piped → one-off snapshot
323
+ }
324
+ break;
325
+ }
326
+ case "jump":
327
+ await runJump(opts);
328
+ break;
329
+ case "clients":
330
+ await runClients();
331
+ break;
332
+ case "debug":
333
+ await runDebug(opts);
334
+ break;
335
+ }
336
+ }
337
+
338
+ main().catch((err: unknown) => {
339
+ process.stderr.write(`tend: ${err instanceof Error ? err.message : String(err)}\n`);
340
+ process.exit(1);
341
+ });
@@ -0,0 +1,139 @@
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
+
12
+ import type { Manifest } from "./types.ts";
13
+
14
+ const claude: Manifest = {
15
+ agent: "claude",
16
+ match: ["claude"],
17
+ // Claude Code renames its process comm to its version (e.g. "2.1.200"), so the
18
+ // literal name never appears — this catches it regardless of screen state.
19
+ commandPattern: "^\\d+\\.\\d+\\.\\d+",
20
+ // Persistent on-screen chrome, for the rare case comm is generic (`node`).
21
+ // These are strings Claude keeps visible in SOME state during a live session —
22
+ // the mode footer, the shortcuts hint, the interrupt hint, the permission
23
+ // prompt — NOT the welcome banner, which scrolls away mid-conversation.
24
+ signature: [
25
+ "shift+tab to cycle", // mode footer (auto/plan mode)
26
+ "? for shortcuts", // default footer
27
+ "esc to interrupt", // working footer
28
+ "for agents", // "← for agents" footer
29
+ "Do you want to proceed?", // permission prompt
30
+ "No, and tell Claude", // permission prompt option
31
+ "Claude Code", // welcome banner (fresh session)
32
+ ],
33
+ rules: [
34
+ // Scrollback / transcript / picker screens don't reflect live state — hold.
35
+ {
36
+ id: "transcript_or_picker",
37
+ state: "unknown",
38
+ priority: 950,
39
+ region: "full",
40
+ anyContains: ["(END)", "Select a model", "Choose a model", "─ Transcript ─"],
41
+ skipStateUpdate: true,
42
+ },
43
+ // Blocked = a *live* permission/approval prompt: the selection cursor (❯)
44
+ // sits on a numbered choice while Claude waits for you. Keying on the live
45
+ // cursor (not the question text) is deliberate — the cursor vanishes the
46
+ // instant you answer, so a prompt lingering in the scroll buffer no longer
47
+ // counts as blocked. `not` guards against a stale answered prompt whose text
48
+ // is still on screen but whose cursor has moved on.
49
+ {
50
+ id: "selection_prompt",
51
+ state: "blocked",
52
+ priority: 900,
53
+ region: { bottom_non_empty_lines: 15 },
54
+ regex: "^\\s*❯\\s+\\d+\\.\\s", // e.g. "❯ 1. Yes"
55
+ not: ["esc to interrupt"], // if it's generating, it isn't waiting on you
56
+ },
57
+ // Actively generating — Claude shows an interrupt hint while working.
58
+ {
59
+ id: "working_interrupt_hint",
60
+ state: "working",
61
+ priority: 700,
62
+ region: { bottom_non_empty_lines: 8 },
63
+ anyContains: ["esc to interrupt", "(esc to interrupt)"],
64
+ },
65
+ // Empty prompt box with the caret and no menu chrome = idle, awaiting input.
66
+ {
67
+ id: "idle_prompt",
68
+ state: "idle",
69
+ priority: 100,
70
+ region: "prompt_box_body",
71
+ regex: "^\\s*[>❯]",
72
+ not: ["esc to interrupt", "1. Yes", "Do you want"],
73
+ },
74
+ ],
75
+ };
76
+
77
+ const codex: Manifest = {
78
+ agent: "codex",
79
+ match: ["codex"],
80
+ signature: ["OpenAI Codex", "Codex CLI", "codex>"],
81
+ rules: [
82
+ // Blocked = a *live* prompt Codex is waiting on — a command-approval dialog
83
+ // or a selectable question (request_user_input). Both render as a numbered
84
+ // list with the live selection cursor `›` (U+203A, Codex's analogue of
85
+ // Claude's `❯`) on one choice. Key on that cursor row, NOT footer text:
86
+ // Codex keeps "esc to interrupt" in the prompt footer even while blocked
87
+ // (e.g. "enter to submit answer | esc to interrupt"), so the interrupt hint
88
+ // can't distinguish blocked from working — only the live cursor can. Like
89
+ // Claude's ❯ cursor, it vanishes the instant you answer, so a prompt left in
90
+ // scrollback stops counting as blocked.
91
+ {
92
+ id: "selection_prompt",
93
+ state: "blocked",
94
+ priority: 900,
95
+ region: { bottom_non_empty_lines: 20 },
96
+ regex: "^\\s*›\\s+\\d+\\.\\s", // e.g. "› 1. Yes"
97
+ },
98
+ // Belt-and-suspenders: explicit approval/question wording, for a prompt
99
+ // whose numbered cursor row isn't in the captured window (long command
100
+ // preview pushing it out of view).
101
+ {
102
+ id: "approval_prompt",
103
+ state: "blocked",
104
+ priority: 890,
105
+ region: { bottom_non_empty_lines: 20 },
106
+ anyContains: [
107
+ "Allow command",
108
+ "wants to run",
109
+ "Do you want to apply",
110
+ "enter to submit answer", // selectable-question footer
111
+ ],
112
+ },
113
+ {
114
+ id: "working_interrupt_hint",
115
+ state: "working",
116
+ priority: 700,
117
+ region: { bottom_non_empty_lines: 8 },
118
+ anyContains: ["Esc to interrupt", "esc to interrupt", "Working", "Thinking"],
119
+ },
120
+ {
121
+ id: "idle_prompt",
122
+ state: "idle",
123
+ priority: 100,
124
+ region: "prompt_box_body",
125
+ regex: "^\\s*[>›❯▌]", // Codex's input marker is `›` (U+203A)
126
+ not: ["Esc to interrupt", "Allow command", "enter to submit answer"],
127
+ },
128
+ ],
129
+ };
130
+
131
+ // Generic fallback for any other terminal agent: no screen rules, so its state
132
+ // comes purely from PTY activity (content changed since last tick = working).
133
+ // Add signatures/rules here as you bring more agents into the fold.
134
+ export const MANIFESTS: Manifest[] = [claude, codex];
135
+
136
+ // Ordered agent-name → manifest lookup used by the detector.
137
+ export function manifestFor(agent: string): Manifest | undefined {
138
+ return MANIFESTS.find((m) => m.agent === agent);
139
+ }
package/src/nav.ts ADDED
@@ -0,0 +1,44 @@
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
+
5
+ import { spawn } from "node:child_process";
6
+ import { preselectPane, switchClientToPane, switchToPane } from "./tmux.ts";
7
+ import type { PaneInfo } from "./types.ts";
8
+
9
+ export { listClients, selfClientTty, type TmuxClient } from "./tmux.ts";
10
+
11
+ export function insideTmux(): boolean {
12
+ return Boolean(process.env.TMUX);
13
+ }
14
+
15
+ export interface JumpOptions {
16
+ // Send the jump to this client (tty) instead of our own — e.g. open the agent
17
+ // in a different terminal window while the dashboard stays put.
18
+ client?: string;
19
+ }
20
+
21
+ // Jump the terminal to an agent's pane.
22
+ // - opts.client set → move that other client to the pane (dashboard stays).
23
+ // - inside tmux → switch our own client and resolve immediately.
24
+ // - outside tmux → pre-select the pane, then `tmux attach` (blocks until
25
+ // the user detaches).
26
+ export async function jumpToPane(pane: PaneInfo, opts: JumpOptions = {}): Promise<void> {
27
+ const { sessionName, id: paneId } = pane;
28
+ if (opts.client) {
29
+ await switchClientToPane(opts.client, sessionName, paneId);
30
+ return;
31
+ }
32
+ if (insideTmux()) {
33
+ await switchToPane(sessionName, paneId);
34
+ return;
35
+ }
36
+ await preselectPane(paneId);
37
+ await new Promise<void>((resolve, reject) => {
38
+ const child = spawn("tmux", ["attach-session", "-t", sessionName], {
39
+ stdio: "inherit",
40
+ });
41
+ child.on("close", () => resolve());
42
+ child.on("error", reject);
43
+ });
44
+ }