@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/src/pick.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
|
|
13
|
+
import { scan, type ScanOptions } from "./scan.ts";
|
|
14
|
+
import { insideTmux, jumpToPane, listClients, selfClientTty, type TmuxClient } from "./nav.ts";
|
|
15
|
+
import { agentRow, bold, dim, groupBySession, summaryLine } from "./render.ts";
|
|
16
|
+
import type { PaneMemory } from "./detect.ts";
|
|
17
|
+
import type { AgentStatus } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
const ALT_ON = "\x1b[?1049h";
|
|
20
|
+
const ALT_OFF = "\x1b[?1049l";
|
|
21
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
22
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
23
|
+
const CURSOR_HOME = "\x1b[H"; // move to top-left without clearing (no flicker)
|
|
24
|
+
const CLEAR_EOL = "\x1b[K"; // erase from cursor to end of *line*
|
|
25
|
+
const CLEAR_BELOW = "\x1b[J"; // erase from cursor to end of screen
|
|
26
|
+
const ANIM_MS = 90; // spinner tick — faster than the scan interval
|
|
27
|
+
|
|
28
|
+
const shortTty = (tty: string) => tty.replace(/^\/dev\//, "");
|
|
29
|
+
|
|
30
|
+
export async function runPicker(
|
|
31
|
+
opts: ScanOptions,
|
|
32
|
+
intervalMs: number,
|
|
33
|
+
initialTarget?: string,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
let statuses: AgentStatus[] = [];
|
|
36
|
+
let memory = new Map<string, PaneMemory>();
|
|
37
|
+
let cursorPaneId: string | null = null; // track selection by id across rescans
|
|
38
|
+
let clients: TmuxClient[] = [];
|
|
39
|
+
let targetTty: string | null = initialTarget ?? null; // null = this window
|
|
40
|
+
let selfTty: string | null = null;
|
|
41
|
+
let done = false;
|
|
42
|
+
let statusMsg = ""; // transient line (e.g. "sent … to …")
|
|
43
|
+
let frame = 0; // spinner animation frame
|
|
44
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
45
|
+
let animTimer: ReturnType<typeof setInterval> | undefined;
|
|
46
|
+
|
|
47
|
+
const out = process.stdout;
|
|
48
|
+
const input = process.stdin;
|
|
49
|
+
|
|
50
|
+
// Flattened, display-ordered list of selectable agent rows (grouped order).
|
|
51
|
+
const selectable = (): AgentStatus[] => groupBySession(statuses).flatMap((g) => g.agents);
|
|
52
|
+
const otherClients = (): TmuxClient[] => clients.filter((c) => c.tty !== selfTty);
|
|
53
|
+
|
|
54
|
+
const currentIndex = (): number => {
|
|
55
|
+
const list = selectable();
|
|
56
|
+
if (list.length === 0) return -1;
|
|
57
|
+
const i = list.findIndex((s) => s.pane.id === cursorPaneId);
|
|
58
|
+
return i >= 0 ? i : 0;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const moveCursor = (delta: number) => {
|
|
62
|
+
const list = selectable();
|
|
63
|
+
if (list.length === 0) return;
|
|
64
|
+
const next = Math.min(list.length - 1, Math.max(0, currentIndex() + delta));
|
|
65
|
+
cursorPaneId = list[next]!.pane.id;
|
|
66
|
+
statusMsg = ""; // clear the note once you start moving again
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Cycle the jump target: this window → each other client → back.
|
|
70
|
+
const cycleTarget = () => {
|
|
71
|
+
const others = otherClients();
|
|
72
|
+
if (others.length === 0) {
|
|
73
|
+
// Nothing to target — tell the user how to get a second window instead of
|
|
74
|
+
// silently doing nothing.
|
|
75
|
+
statusMsg =
|
|
76
|
+
clients.length <= 1
|
|
77
|
+
? "no other tmux client — run `tmux attach` in another terminal window"
|
|
78
|
+
: "no other client detected (couldn't tell this window apart)";
|
|
79
|
+
render();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const cycle: Array<string | null> = [null, ...others.map((c) => c.tty)];
|
|
83
|
+
let idx = cycle.indexOf(targetTty);
|
|
84
|
+
if (idx < 0) idx = 0;
|
|
85
|
+
targetTty = cycle[(idx + 1) % cycle.length] ?? null;
|
|
86
|
+
statusMsg = "";
|
|
87
|
+
render();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const targetLabel = (): string => {
|
|
91
|
+
if (!targetTty) return "this window";
|
|
92
|
+
const c = clients.find((x) => x.tty === targetTty);
|
|
93
|
+
return shortTty(targetTty) + (c ? ` · ${c.session}` : "");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const render = () => {
|
|
97
|
+
const groups = groupBySession(statuses);
|
|
98
|
+
const selectedId = selectable()[currentIndex()]?.pane.id ?? null;
|
|
99
|
+
const others = otherClients().length;
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
lines.push(
|
|
102
|
+
bold("tend") + dim(" ↑/↓ move · enter jump · o target · r refresh · q quit"),
|
|
103
|
+
);
|
|
104
|
+
// Show where Enter opens, plus how many other windows are available to target.
|
|
105
|
+
const windowsNote = others > 0 ? ` (${others} other window${others > 1 ? "s" : ""})` : "";
|
|
106
|
+
lines.push(dim(`opens in: ${targetLabel()}${windowsNote}`) + (statusMsg ? dim(` ${statusMsg}`) : ""));
|
|
107
|
+
if (groups.length === 0) {
|
|
108
|
+
lines.push(dim("No AI agents detected in any tmux session."));
|
|
109
|
+
} else {
|
|
110
|
+
for (const { session, agents } of groups) {
|
|
111
|
+
const blocked = agents.filter((a) => a.state === "blocked").length;
|
|
112
|
+
const suffix = blocked ? ` \x1b[31m· ${blocked} blocked\x1b[0m` : "";
|
|
113
|
+
lines.push(bold(`▸ ${session}`) + dim(` (${agents.length})`) + suffix);
|
|
114
|
+
for (const a of agents) {
|
|
115
|
+
lines.push(agentRow(a, a.pane.id === selectedId, frame));
|
|
116
|
+
}
|
|
117
|
+
lines.push("");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.push(summaryLine(statuses));
|
|
121
|
+
// Home + overwrite, erasing each line's tail (CLEAR_EOL) so a line that got
|
|
122
|
+
// shorter doesn't leave ghost text (e.g. a stale "· 1 blocked" suffix), plus
|
|
123
|
+
// CLEAR_BELOW for when this frame has fewer lines. No full-screen wipe, so
|
|
124
|
+
// the ~90ms spinner repaints stay flicker-free.
|
|
125
|
+
out.write(CURSOR_HOME + lines.map((l) => l + CLEAR_EOL).join("\n") + "\n" + CLEAR_BELOW);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const refresh = async () => {
|
|
129
|
+
const result = await scan(opts, memory);
|
|
130
|
+
memory = result.memory;
|
|
131
|
+
statuses = result.statuses;
|
|
132
|
+
clients = await listClients();
|
|
133
|
+
// If our chosen target client went away, fall back to this window.
|
|
134
|
+
if (targetTty && !clients.some((c) => c.tty === targetTty)) targetTty = null;
|
|
135
|
+
if (cursorPaneId === null) cursorPaneId = selectable()[0]?.pane.id ?? null;
|
|
136
|
+
if (!done) render();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const cleanup = () => {
|
|
140
|
+
if (timer) clearInterval(timer);
|
|
141
|
+
if (animTimer) clearInterval(animTimer);
|
|
142
|
+
input.setRawMode?.(false);
|
|
143
|
+
input.pause();
|
|
144
|
+
input.removeListener("data", onKey);
|
|
145
|
+
out.write(SHOW_CURSOR + ALT_OFF);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const jump = async () => {
|
|
149
|
+
const target = selectable()[currentIndex()];
|
|
150
|
+
if (!target) return;
|
|
151
|
+
|
|
152
|
+
// Targeting another client: move that window, keep the dashboard put.
|
|
153
|
+
if (targetTty) {
|
|
154
|
+
try {
|
|
155
|
+
await jumpToPane(target.pane, { client: targetTty });
|
|
156
|
+
statusMsg = `→ sent ${target.agent} (${target.pane.id}) to ${shortTty(targetTty)}`;
|
|
157
|
+
} catch {
|
|
158
|
+
statusMsg = `⚠ ${shortTty(targetTty)} unavailable`;
|
|
159
|
+
}
|
|
160
|
+
render();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Targeting this window, inside tmux: switch our client but keep running.
|
|
165
|
+
if (insideTmux()) {
|
|
166
|
+
await jumpToPane(target.pane);
|
|
167
|
+
statusMsg = `→ jumped to ${target.agent} in ${target.pane.sessionName} (${target.pane.id})`;
|
|
168
|
+
render();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// This window, outside tmux: attach takes over the terminal → tear down.
|
|
173
|
+
done = true;
|
|
174
|
+
cleanup();
|
|
175
|
+
await jumpToPane(target.pane);
|
|
176
|
+
process.exit(0);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function onKey(buf: Buffer) {
|
|
180
|
+
const key = buf.toString("utf8");
|
|
181
|
+
if (key === "\x03" || key === "q" || key === "\x1b") {
|
|
182
|
+
done = true;
|
|
183
|
+
cleanup();
|
|
184
|
+
process.exit(0);
|
|
185
|
+
} else if (key === "\r" || key === "\n") {
|
|
186
|
+
void jump();
|
|
187
|
+
} else if (key === "\x1b[A" || key === "k") {
|
|
188
|
+
moveCursor(-1);
|
|
189
|
+
render();
|
|
190
|
+
} else if (key === "\x1b[B" || key === "j") {
|
|
191
|
+
moveCursor(1);
|
|
192
|
+
render();
|
|
193
|
+
} else if (key === "o" || key === "\t") {
|
|
194
|
+
cycleTarget();
|
|
195
|
+
} else if (key === "r") {
|
|
196
|
+
void refresh();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Setup terminal. We only have a "self" client to exclude when we're actually
|
|
201
|
+
// running inside a tmux client; from a plain terminal every attached client is
|
|
202
|
+
// a legitimate target (and tmux would otherwise report one of them as "self").
|
|
203
|
+
selfTty = insideTmux() ? await selfClientTty() : null;
|
|
204
|
+
out.write(ALT_ON + HIDE_CURSOR);
|
|
205
|
+
input.setRawMode?.(true);
|
|
206
|
+
input.resume();
|
|
207
|
+
input.setEncoding?.("utf8");
|
|
208
|
+
input.on("data", onKey);
|
|
209
|
+
process.on("SIGINT", () => {
|
|
210
|
+
done = true;
|
|
211
|
+
cleanup();
|
|
212
|
+
process.exit(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await refresh();
|
|
216
|
+
timer = setInterval(() => void refresh(), intervalMs);
|
|
217
|
+
// Advance the spinner only while something is working, so an all-idle board
|
|
218
|
+
// stays quiet (no needless repaints).
|
|
219
|
+
animTimer = setInterval(() => {
|
|
220
|
+
if (done || !statuses.some((s) => s.state === "working")) return;
|
|
221
|
+
frame++;
|
|
222
|
+
render();
|
|
223
|
+
}, ANIM_MS);
|
|
224
|
+
}
|
package/src/regions.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Region extractors: pure string functions that slice the captured pane text
|
|
2
|
+
// down to the part a rule cares about. Keeping these tiny and pure is what makes
|
|
3
|
+
// the rule engine testable.
|
|
4
|
+
|
|
5
|
+
import type { Region } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// A "horizontal rule" line is one made mostly of box-drawing horizontals — the
|
|
8
|
+
// borders Claude/Codex draw around their input box and section separators.
|
|
9
|
+
const RULE_CHARS = /[─━╌╍]/g;
|
|
10
|
+
|
|
11
|
+
function isRuleLine(line: string): boolean {
|
|
12
|
+
const matches = line.match(RULE_CHARS);
|
|
13
|
+
return matches !== null && matches.length >= 3;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function indicesOfRuleLines(lines: string[]): number[] {
|
|
17
|
+
const idx: number[] = [];
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
if (isRuleLine(lines[i]!)) idx.push(i);
|
|
20
|
+
}
|
|
21
|
+
return idx;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Everything below the last horizontal rule — isolates the live prompt area
|
|
25
|
+
// from scrollback above it.
|
|
26
|
+
export function afterLastHorizontalRule(lines: string[]): string[] {
|
|
27
|
+
const rules = indicesOfRuleLines(lines);
|
|
28
|
+
if (rules.length === 0) return lines;
|
|
29
|
+
const last = rules[rules.length - 1]!;
|
|
30
|
+
return lines.slice(last + 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The interior of the input box: the lines between the last two horizontal
|
|
34
|
+
// rules (the box's top and bottom borders). Falls back to the tail if there
|
|
35
|
+
// aren't two borders to bracket.
|
|
36
|
+
export function promptBoxBody(lines: string[]): string[] {
|
|
37
|
+
const rules = indicesOfRuleLines(lines);
|
|
38
|
+
if (rules.length < 2) return bottomNonEmptyLines(lines, 3);
|
|
39
|
+
const bottom = rules[rules.length - 1]!;
|
|
40
|
+
const top = rules[rules.length - 2]!;
|
|
41
|
+
return lines.slice(top + 1, bottom);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The last N lines that aren't blank.
|
|
45
|
+
export function bottomNonEmptyLines(lines: string[], n: number): string[] {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
48
|
+
if (lines[i]!.trim().length > 0) out.push(lines[i]!);
|
|
49
|
+
}
|
|
50
|
+
return out.reverse();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractRegion(lines: string[], region: Region): string {
|
|
54
|
+
if (typeof region === "object") {
|
|
55
|
+
return bottomNonEmptyLines(lines, region.bottom_non_empty_lines).join("\n");
|
|
56
|
+
}
|
|
57
|
+
switch (region) {
|
|
58
|
+
case "full":
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
case "after_last_horizontal_rule":
|
|
61
|
+
return afterLastHorizontalRule(lines).join("\n");
|
|
62
|
+
case "prompt_box_body":
|
|
63
|
+
return promptBoxBody(lines).join("\n");
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Terminal rendering: agents grouped by tmux session, with colored state dots
|
|
2
|
+
// and a git column. No dependencies — raw ANSI. Falls back to plain text when
|
|
3
|
+
// stdout isn't a TTY (piped) or NO_COLOR is set.
|
|
4
|
+
|
|
5
|
+
import type { AgentState, AgentStatus, GitStatus } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
8
|
+
|
|
9
|
+
export const color = (code: string, s: string) =>
|
|
10
|
+
useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
11
|
+
export const dim = (s: string) => color("2", s);
|
|
12
|
+
export const bold = (s: string) => color("1", s);
|
|
13
|
+
|
|
14
|
+
// Braille spinner frames for the "working" state (classic dots animation).
|
|
15
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
+
|
|
17
|
+
// The state indicator: a distinct *shape* per state (so it reads even without
|
|
18
|
+
// color) plus color. "working" animates through the spinner; `frame` advances it.
|
|
19
|
+
export function stateGlyph(state: AgentState, frame = 0): string {
|
|
20
|
+
switch (state) {
|
|
21
|
+
case "blocked":
|
|
22
|
+
return color("31", "●"); // red filled — needs you
|
|
23
|
+
case "working":
|
|
24
|
+
return color("33", SPINNER[frame % SPINNER.length]!); // yellow spinner
|
|
25
|
+
case "idle":
|
|
26
|
+
return color("32", "○"); // green hollow — waiting for input
|
|
27
|
+
case "unknown":
|
|
28
|
+
return color("90", "◌"); // grey dotted — unknown
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function gitCell(git: GitStatus | null): string {
|
|
33
|
+
if (!git || !git.branch) return dim("—");
|
|
34
|
+
let s = git.branch;
|
|
35
|
+
const marks: string[] = [];
|
|
36
|
+
if (git.ahead) marks.push(`↑${git.ahead}`);
|
|
37
|
+
if (git.behind) marks.push(`↓${git.behind}`);
|
|
38
|
+
if (git.dirty) marks.push(color("33", "✱"));
|
|
39
|
+
if (marks.length) s += " " + marks.join(" ");
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pad by *visible* width, ignoring ANSI escapes.
|
|
44
|
+
export function pad(s: string, width: number): string {
|
|
45
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
46
|
+
return s + " ".repeat(Math.max(0, width - visible));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const STATE_ORDER: Record<AgentState, number> = {
|
|
50
|
+
blocked: 0,
|
|
51
|
+
working: 1,
|
|
52
|
+
idle: 2,
|
|
53
|
+
unknown: 3,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface SessionGroup {
|
|
57
|
+
session: string;
|
|
58
|
+
agents: AgentStatus[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Group agents by tmux session (insertion order = tmux's session order), with
|
|
62
|
+
// each session's agents sorted blocked→working→idle so the ones needing you
|
|
63
|
+
// float to the top.
|
|
64
|
+
export function groupBySession(statuses: AgentStatus[]): SessionGroup[] {
|
|
65
|
+
const groups = new Map<string, AgentStatus[]>();
|
|
66
|
+
for (const s of statuses) {
|
|
67
|
+
const key = s.pane.sessionName;
|
|
68
|
+
let list = groups.get(key);
|
|
69
|
+
if (!list) {
|
|
70
|
+
list = [];
|
|
71
|
+
groups.set(key, list);
|
|
72
|
+
}
|
|
73
|
+
list.push(s);
|
|
74
|
+
}
|
|
75
|
+
return [...groups.entries()].map(([session, agents]) => ({
|
|
76
|
+
session,
|
|
77
|
+
agents: agents.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One agent row: state glyph + pane id + agent + git. The glyph's shape+color
|
|
82
|
+
// carries the state (● red blocked, spinner yellow working, ○ green idle); the
|
|
83
|
+
// pane id is the jump reference. `cursor` renders a caret; `frame` animates the
|
|
84
|
+
// working spinner.
|
|
85
|
+
export function agentRow(s: AgentStatus, cursor = false, frame = 0): string {
|
|
86
|
+
const caret = cursor ? bold("❯ ") : " ";
|
|
87
|
+
return (
|
|
88
|
+
caret +
|
|
89
|
+
`${stateGlyph(s.state, frame)} ` +
|
|
90
|
+
pad(s.pane.id, 6) +
|
|
91
|
+
pad(bold(s.agent), 10) +
|
|
92
|
+
gitCell(s.git)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderGrouped(statuses: AgentStatus[], frame = 0): string {
|
|
97
|
+
if (statuses.length === 0) {
|
|
98
|
+
return dim("No AI agents detected in any tmux session.");
|
|
99
|
+
}
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
for (const { session, agents } of groupBySession(statuses)) {
|
|
102
|
+
const blocked = agents.filter((a) => a.state === "blocked").length;
|
|
103
|
+
const suffix = blocked ? color("31", ` · ${blocked} blocked`) : "";
|
|
104
|
+
lines.push(bold(`▸ ${session}`) + dim(` (${agents.length})`) + suffix);
|
|
105
|
+
for (const a of agents) lines.push(agentRow(a, false, frame));
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function renderJson(statuses: AgentStatus[]): string {
|
|
113
|
+
return JSON.stringify(
|
|
114
|
+
statuses.map((s) => ({
|
|
115
|
+
pane: s.pane.id,
|
|
116
|
+
agent: s.agent,
|
|
117
|
+
state: s.state,
|
|
118
|
+
matchedRule: s.matchedRule,
|
|
119
|
+
session: s.pane.sessionName,
|
|
120
|
+
window: s.pane.windowName,
|
|
121
|
+
path: s.pane.path,
|
|
122
|
+
git: s.git,
|
|
123
|
+
})),
|
|
124
|
+
null,
|
|
125
|
+
2,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const CLEAR_SCREEN = "\x1b[2J\x1b[H";
|
|
130
|
+
|
|
131
|
+
export function summaryLine(statuses: AgentStatus[]): string {
|
|
132
|
+
const counts = { blocked: 0, working: 0, idle: 0, unknown: 0 };
|
|
133
|
+
for (const s of statuses) counts[s.state]++;
|
|
134
|
+
const sessions = new Set(statuses.map((s) => s.pane.sessionName)).size;
|
|
135
|
+
return dim(
|
|
136
|
+
`${statuses.length} agent(s) across ${sessions} session(s) — ` +
|
|
137
|
+
`${counts.blocked} blocked, ${counts.working} working, ${counts.idle} idle`,
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/scan.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// One detection pass over every pane. Shared by the list, watch, and picker
|
|
2
|
+
// modes. Threads per-pane memory across calls so "working" (content changed
|
|
3
|
+
// since last look) and idle-debounce work in the live modes.
|
|
4
|
+
|
|
5
|
+
import { capturePane, listPanes } from "./tmux.ts";
|
|
6
|
+
import { createGitCache } from "./git.ts";
|
|
7
|
+
import { resolveState, type PaneMemory } from "./detect.ts";
|
|
8
|
+
import type { AgentStatus } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export interface ScanOptions {
|
|
11
|
+
all: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function scan(
|
|
15
|
+
opts: ScanOptions,
|
|
16
|
+
memory: Map<string, PaneMemory>,
|
|
17
|
+
): Promise<{ statuses: AgentStatus[]; memory: Map<string, PaneMemory> }> {
|
|
18
|
+
const panes = await listPanes({ all: opts.all });
|
|
19
|
+
const gitCache = createGitCache();
|
|
20
|
+
const nextMemory = new Map<string, PaneMemory>();
|
|
21
|
+
|
|
22
|
+
// Resolve concurrently but keep results in tmux pane order (session → window →
|
|
23
|
+
// pane) so the grouped view is stable across refreshes rather than ordered by
|
|
24
|
+
// whichever capture-pane happened to finish first.
|
|
25
|
+
const resolved = await Promise.all(
|
|
26
|
+
panes.map(async (pane): Promise<AgentStatus | null> => {
|
|
27
|
+
const lines = await capturePane(pane.id);
|
|
28
|
+
const resolution = resolveState(pane, lines, memory.get(pane.id));
|
|
29
|
+
if (!resolution) return null; // not an agent pane
|
|
30
|
+
nextMemory.set(pane.id, resolution.memory);
|
|
31
|
+
const git = await gitCache(pane.path);
|
|
32
|
+
return {
|
|
33
|
+
pane,
|
|
34
|
+
agent: resolution.agent,
|
|
35
|
+
state: resolution.state,
|
|
36
|
+
matchedRule: resolution.matchedRule,
|
|
37
|
+
git,
|
|
38
|
+
};
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
const statuses = resolved.filter((s): s is AgentStatus => s !== null);
|
|
42
|
+
return { statuses, memory: nextMemory };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
|
|
47
|
+
// Fire-once: sample twice with a short delay so "working" can be detected via
|
|
48
|
+
// content change even without a persistent watcher.
|
|
49
|
+
export async function scanOnce(
|
|
50
|
+
opts: ScanOptions,
|
|
51
|
+
delayMs: number,
|
|
52
|
+
): Promise<AgentStatus[]> {
|
|
53
|
+
let memory = new Map<string, PaneMemory>();
|
|
54
|
+
({ memory } = await scan(opts, memory));
|
|
55
|
+
await sleep(delayMs);
|
|
56
|
+
const { statuses } = await scan(opts, memory);
|
|
57
|
+
return statuses;
|
|
58
|
+
}
|
package/src/tmux.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// tmux glue. tmux has already done the hard part for us: #{pane_current_command}
|
|
2
|
+
// is the foreground process's comm, and capture-pane hands us the rendered
|
|
3
|
+
// screen for free. We just ask nicely.
|
|
4
|
+
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import type { PaneInfo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
async function tmux(args: string[]): Promise<string> {
|
|
12
|
+
const { stdout } = await exec("tmux", args, { maxBuffer: 16 * 1024 * 1024 });
|
|
13
|
+
return stdout;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// tmux sanitizes ANY control character in -F output (including our separator and
|
|
17
|
+
// even newlines) to "_", so we can't pack multiple free-text fields into one
|
|
18
|
+
// formatted line. Instead we query one field per call, each paired with the
|
|
19
|
+
// pane id. pane_id is always "%<digits>" — it can't contain the "|" delimiter or
|
|
20
|
+
// a newline — so we split once on the first "|" and the value may then contain
|
|
21
|
+
// anything (spaces, "|", unicode) without ambiguity. Keying the merge by id
|
|
22
|
+
// makes it robust to the pane set changing between calls.
|
|
23
|
+
async function column(
|
|
24
|
+
scope: string[],
|
|
25
|
+
field: string,
|
|
26
|
+
): Promise<Array<[string, string]>> {
|
|
27
|
+
const stdout = await tmux(["list-panes", ...scope, "-F", `#{pane_id}|${field}`]);
|
|
28
|
+
const rows: Array<[string, string]> = [];
|
|
29
|
+
for (const line of stdout.split("\n")) {
|
|
30
|
+
if (!line) continue;
|
|
31
|
+
const i = line.indexOf("|");
|
|
32
|
+
if (i < 0) continue;
|
|
33
|
+
rows.push([line.slice(0, i), line.slice(i + 1)]);
|
|
34
|
+
}
|
|
35
|
+
return rows;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function isServerRunning(): Promise<boolean> {
|
|
39
|
+
try {
|
|
40
|
+
await tmux(["list-panes", "-a"]);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Enumerate panes. Scope defaults to the current session when run inside tmux,
|
|
48
|
+
// or all sessions with `all: true` (or when run outside tmux).
|
|
49
|
+
export async function listPanes(opts: { all: boolean }): Promise<PaneInfo[]> {
|
|
50
|
+
const insideTmux = Boolean(process.env.TMUX);
|
|
51
|
+
const scope = opts.all || !insideTmux ? ["-a"] : ["-s"];
|
|
52
|
+
|
|
53
|
+
const [commands, pids, paths, titles, sessions, windows, windowIdx, actives] =
|
|
54
|
+
await Promise.all([
|
|
55
|
+
column(scope, "#{pane_current_command}"),
|
|
56
|
+
column(scope, "#{pane_pid}"),
|
|
57
|
+
column(scope, "#{pane_current_path}"),
|
|
58
|
+
column(scope, "#{pane_title}"),
|
|
59
|
+
column(scope, "#{session_name}"),
|
|
60
|
+
column(scope, "#{window_name}"),
|
|
61
|
+
column(scope, "#{window_index}"),
|
|
62
|
+
column(scope, "#{pane_active}"),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// Seed one PaneInfo per id from the first column, then fill from the rest.
|
|
66
|
+
// Keyed by id, so a pane appearing/vanishing mid-scan can't misalign fields.
|
|
67
|
+
const byId = new Map<string, PaneInfo>();
|
|
68
|
+
for (const [id] of commands) {
|
|
69
|
+
if (!byId.has(id)) {
|
|
70
|
+
byId.set(id, {
|
|
71
|
+
id,
|
|
72
|
+
command: "",
|
|
73
|
+
pid: 0,
|
|
74
|
+
path: "",
|
|
75
|
+
title: "",
|
|
76
|
+
sessionName: "",
|
|
77
|
+
windowName: "",
|
|
78
|
+
windowIndex: "",
|
|
79
|
+
active: false,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const fill = (rows: Array<[string, string]>, set: (p: PaneInfo, v: string) => void) => {
|
|
84
|
+
for (const [id, value] of rows) {
|
|
85
|
+
const p = byId.get(id);
|
|
86
|
+
if (p) set(p, value);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
fill(commands, (p, v) => (p.command = v));
|
|
90
|
+
fill(pids, (p, v) => (p.pid = Number(v) || 0));
|
|
91
|
+
fill(paths, (p, v) => (p.path = v));
|
|
92
|
+
fill(titles, (p, v) => (p.title = v));
|
|
93
|
+
fill(sessions, (p, v) => (p.sessionName = v));
|
|
94
|
+
fill(windows, (p, v) => (p.windowName = v));
|
|
95
|
+
fill(windowIdx, (p, v) => (p.windowIndex = v));
|
|
96
|
+
fill(actives, (p, v) => (p.active = v === "1"));
|
|
97
|
+
|
|
98
|
+
return [...byId.values()];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Capture the visible screen of a pane as plain text lines (no escape codes).
|
|
102
|
+
// -p prints to stdout; trailing blank lines trimmed.
|
|
103
|
+
export async function capturePane(paneId: string): Promise<string[]> {
|
|
104
|
+
const stdout = await tmux(["capture-pane", "-p", "-t", paneId]);
|
|
105
|
+
return stdout.replace(/\n+$/, "").split("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Move the *currently attached* tmux client to a pane: switch its session, then
|
|
109
|
+
// select the window and pane. A pane id (%N) resolves up to its window/session,
|
|
110
|
+
// so we only need the session name for switch-client. Use when already inside
|
|
111
|
+
// tmux (process.env.TMUX set).
|
|
112
|
+
export async function switchToPane(sessionName: string, paneId: string): Promise<void> {
|
|
113
|
+
await tmux(["switch-client", "-t", sessionName]);
|
|
114
|
+
await tmux(["select-window", "-t", paneId]);
|
|
115
|
+
await tmux(["select-pane", "-t", paneId]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Like switchToPane, but moves a *specific* client (by its tty) rather than the
|
|
119
|
+
// current one. This is how the dashboard in one window drives another window:
|
|
120
|
+
// select-window/select-pane set the agent session's active window+pane server-
|
|
121
|
+
// side, and switch-client -c points that other client at it.
|
|
122
|
+
export async function switchClientToPane(
|
|
123
|
+
clientTty: string,
|
|
124
|
+
sessionName: string,
|
|
125
|
+
paneId: string,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
await tmux(["switch-client", "-c", clientTty, "-t", sessionName]);
|
|
128
|
+
await tmux(["select-window", "-t", paneId]);
|
|
129
|
+
await tmux(["select-pane", "-t", paneId]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface TmuxClient {
|
|
133
|
+
tty: string; // client_tty, e.g. "/dev/ttys004" — the client's identity
|
|
134
|
+
session: string; // session it's currently attached to
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// All attached clients. Uses the same "id | value" split trick as listPanes to
|
|
138
|
+
// dodge tmux's control-character sanitization; client_tty never contains "|".
|
|
139
|
+
export async function listClients(): Promise<TmuxClient[]> {
|
|
140
|
+
let stdout = "";
|
|
141
|
+
try {
|
|
142
|
+
stdout = await tmux(["list-clients", "-F", "#{client_tty}|#{client_session}"]);
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const clients: TmuxClient[] = [];
|
|
147
|
+
for (const line of stdout.split("\n")) {
|
|
148
|
+
if (!line) continue;
|
|
149
|
+
const i = line.indexOf("|");
|
|
150
|
+
if (i < 0) continue;
|
|
151
|
+
clients.push({ tty: line.slice(0, i), session: line.slice(i + 1) });
|
|
152
|
+
}
|
|
153
|
+
return clients;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The tty of the client this process is running under (best effort). Used to
|
|
157
|
+
// tell "this window" apart from the others when choosing a jump target.
|
|
158
|
+
export async function selfClientTty(): Promise<string | null> {
|
|
159
|
+
try {
|
|
160
|
+
const tty = (await tmux(["display-message", "-p", "#{client_tty}"])).trim();
|
|
161
|
+
return tty || null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Pre-select the target window/pane on the server (no client needed), so a
|
|
168
|
+
// subsequent `tmux attach` lands with that pane active. Use when NOT inside
|
|
169
|
+
// tmux — the caller then execs `tmux attach-session -t <sessionName>`.
|
|
170
|
+
export async function preselectPane(paneId: string): Promise<void> {
|
|
171
|
+
await tmux(["select-window", "-t", paneId]);
|
|
172
|
+
await tmux(["select-pane", "-t", paneId]);
|
|
173
|
+
}
|