@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/dist/regions.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
// A "horizontal rule" line is one made mostly of box-drawing horizontals — the
|
|
5
|
+
// borders Claude/Codex draw around their input box and section separators.
|
|
6
|
+
const RULE_CHARS = /[─━╌╍]/g;
|
|
7
|
+
function isRuleLine(line) {
|
|
8
|
+
const matches = line.match(RULE_CHARS);
|
|
9
|
+
return matches !== null && matches.length >= 3;
|
|
10
|
+
}
|
|
11
|
+
function indicesOfRuleLines(lines) {
|
|
12
|
+
const idx = [];
|
|
13
|
+
for (let i = 0; i < lines.length; i++) {
|
|
14
|
+
if (isRuleLine(lines[i]))
|
|
15
|
+
idx.push(i);
|
|
16
|
+
}
|
|
17
|
+
return idx;
|
|
18
|
+
}
|
|
19
|
+
// Everything below the last horizontal rule — isolates the live prompt area
|
|
20
|
+
// from scrollback above it.
|
|
21
|
+
export function afterLastHorizontalRule(lines) {
|
|
22
|
+
const rules = indicesOfRuleLines(lines);
|
|
23
|
+
if (rules.length === 0)
|
|
24
|
+
return lines;
|
|
25
|
+
const last = rules[rules.length - 1];
|
|
26
|
+
return lines.slice(last + 1);
|
|
27
|
+
}
|
|
28
|
+
// The interior of the input box: the lines between the last two horizontal
|
|
29
|
+
// rules (the box's top and bottom borders). Falls back to the tail if there
|
|
30
|
+
// aren't two borders to bracket.
|
|
31
|
+
export function promptBoxBody(lines) {
|
|
32
|
+
const rules = indicesOfRuleLines(lines);
|
|
33
|
+
if (rules.length < 2)
|
|
34
|
+
return bottomNonEmptyLines(lines, 3);
|
|
35
|
+
const bottom = rules[rules.length - 1];
|
|
36
|
+
const top = rules[rules.length - 2];
|
|
37
|
+
return lines.slice(top + 1, bottom);
|
|
38
|
+
}
|
|
39
|
+
// The last N lines that aren't blank.
|
|
40
|
+
export function bottomNonEmptyLines(lines, n) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
43
|
+
if (lines[i].trim().length > 0)
|
|
44
|
+
out.push(lines[i]);
|
|
45
|
+
}
|
|
46
|
+
return out.reverse();
|
|
47
|
+
}
|
|
48
|
+
export function extractRegion(lines, region) {
|
|
49
|
+
if (typeof region === "object") {
|
|
50
|
+
return bottomNonEmptyLines(lines, region.bottom_non_empty_lines).join("\n");
|
|
51
|
+
}
|
|
52
|
+
switch (region) {
|
|
53
|
+
case "full":
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
case "after_last_horizontal_rule":
|
|
56
|
+
return afterLastHorizontalRule(lines).join("\n");
|
|
57
|
+
case "prompt_box_body":
|
|
58
|
+
return promptBoxBody(lines).join("\n");
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
5
|
+
export const color = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
6
|
+
export const dim = (s) => color("2", s);
|
|
7
|
+
export const bold = (s) => color("1", s);
|
|
8
|
+
// Braille spinner frames for the "working" state (classic dots animation).
|
|
9
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
+
// The state indicator: a distinct *shape* per state (so it reads even without
|
|
11
|
+
// color) plus color. "working" animates through the spinner; `frame` advances it.
|
|
12
|
+
export function stateGlyph(state, frame = 0) {
|
|
13
|
+
switch (state) {
|
|
14
|
+
case "blocked":
|
|
15
|
+
return color("31", "●"); // red filled — needs you
|
|
16
|
+
case "working":
|
|
17
|
+
return color("33", SPINNER[frame % SPINNER.length]); // yellow spinner
|
|
18
|
+
case "idle":
|
|
19
|
+
return color("32", "○"); // green hollow — waiting for input
|
|
20
|
+
case "unknown":
|
|
21
|
+
return color("90", "◌"); // grey dotted — unknown
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function gitCell(git) {
|
|
25
|
+
if (!git || !git.branch)
|
|
26
|
+
return dim("—");
|
|
27
|
+
let s = git.branch;
|
|
28
|
+
const marks = [];
|
|
29
|
+
if (git.ahead)
|
|
30
|
+
marks.push(`↑${git.ahead}`);
|
|
31
|
+
if (git.behind)
|
|
32
|
+
marks.push(`↓${git.behind}`);
|
|
33
|
+
if (git.dirty)
|
|
34
|
+
marks.push(color("33", "✱"));
|
|
35
|
+
if (marks.length)
|
|
36
|
+
s += " " + marks.join(" ");
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
// Pad by *visible* width, ignoring ANSI escapes.
|
|
40
|
+
export function pad(s, width) {
|
|
41
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
42
|
+
return s + " ".repeat(Math.max(0, width - visible));
|
|
43
|
+
}
|
|
44
|
+
const STATE_ORDER = {
|
|
45
|
+
blocked: 0,
|
|
46
|
+
working: 1,
|
|
47
|
+
idle: 2,
|
|
48
|
+
unknown: 3,
|
|
49
|
+
};
|
|
50
|
+
// Group agents by tmux session (insertion order = tmux's session order), with
|
|
51
|
+
// each session's agents sorted blocked→working→idle so the ones needing you
|
|
52
|
+
// float to the top.
|
|
53
|
+
export function groupBySession(statuses) {
|
|
54
|
+
const groups = new Map();
|
|
55
|
+
for (const s of statuses) {
|
|
56
|
+
const key = s.pane.sessionName;
|
|
57
|
+
let list = groups.get(key);
|
|
58
|
+
if (!list) {
|
|
59
|
+
list = [];
|
|
60
|
+
groups.set(key, list);
|
|
61
|
+
}
|
|
62
|
+
list.push(s);
|
|
63
|
+
}
|
|
64
|
+
return [...groups.entries()].map(([session, agents]) => ({
|
|
65
|
+
session,
|
|
66
|
+
agents: agents.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
// One agent row: state glyph + pane id + agent + git. The glyph's shape+color
|
|
70
|
+
// carries the state (● red blocked, spinner yellow working, ○ green idle); the
|
|
71
|
+
// pane id is the jump reference. `cursor` renders a caret; `frame` animates the
|
|
72
|
+
// working spinner.
|
|
73
|
+
export function agentRow(s, cursor = false, frame = 0) {
|
|
74
|
+
const caret = cursor ? bold("❯ ") : " ";
|
|
75
|
+
return (caret +
|
|
76
|
+
`${stateGlyph(s.state, frame)} ` +
|
|
77
|
+
pad(s.pane.id, 6) +
|
|
78
|
+
pad(bold(s.agent), 10) +
|
|
79
|
+
gitCell(s.git));
|
|
80
|
+
}
|
|
81
|
+
export function renderGrouped(statuses, frame = 0) {
|
|
82
|
+
if (statuses.length === 0) {
|
|
83
|
+
return dim("No AI agents detected in any tmux session.");
|
|
84
|
+
}
|
|
85
|
+
const lines = [];
|
|
86
|
+
for (const { session, agents } of groupBySession(statuses)) {
|
|
87
|
+
const blocked = agents.filter((a) => a.state === "blocked").length;
|
|
88
|
+
const suffix = blocked ? color("31", ` · ${blocked} blocked`) : "";
|
|
89
|
+
lines.push(bold(`▸ ${session}`) + dim(` (${agents.length})`) + suffix);
|
|
90
|
+
for (const a of agents)
|
|
91
|
+
lines.push(agentRow(a, false, frame));
|
|
92
|
+
lines.push("");
|
|
93
|
+
}
|
|
94
|
+
if (lines[lines.length - 1] === "")
|
|
95
|
+
lines.pop();
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
export function renderJson(statuses) {
|
|
99
|
+
return JSON.stringify(statuses.map((s) => ({
|
|
100
|
+
pane: s.pane.id,
|
|
101
|
+
agent: s.agent,
|
|
102
|
+
state: s.state,
|
|
103
|
+
matchedRule: s.matchedRule,
|
|
104
|
+
session: s.pane.sessionName,
|
|
105
|
+
window: s.pane.windowName,
|
|
106
|
+
path: s.pane.path,
|
|
107
|
+
git: s.git,
|
|
108
|
+
})), null, 2);
|
|
109
|
+
}
|
|
110
|
+
export const CLEAR_SCREEN = "\x1b[2J\x1b[H";
|
|
111
|
+
export function summaryLine(statuses) {
|
|
112
|
+
const counts = { blocked: 0, working: 0, idle: 0, unknown: 0 };
|
|
113
|
+
for (const s of statuses)
|
|
114
|
+
counts[s.state]++;
|
|
115
|
+
const sessions = new Set(statuses.map((s) => s.pane.sessionName)).size;
|
|
116
|
+
return dim(`${statuses.length} agent(s) across ${sessions} session(s) — ` +
|
|
117
|
+
`${counts.blocked} blocked, ${counts.working} working, ${counts.idle} idle`);
|
|
118
|
+
}
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import { capturePane, listPanes } from "./tmux.js";
|
|
5
|
+
import { createGitCache } from "./git.js";
|
|
6
|
+
import { resolveState } from "./detect.js";
|
|
7
|
+
export async function scan(opts, memory) {
|
|
8
|
+
const panes = await listPanes({ all: opts.all });
|
|
9
|
+
const gitCache = createGitCache();
|
|
10
|
+
const nextMemory = new Map();
|
|
11
|
+
// Resolve concurrently but keep results in tmux pane order (session → window →
|
|
12
|
+
// pane) so the grouped view is stable across refreshes rather than ordered by
|
|
13
|
+
// whichever capture-pane happened to finish first.
|
|
14
|
+
const resolved = await Promise.all(panes.map(async (pane) => {
|
|
15
|
+
const lines = await capturePane(pane.id);
|
|
16
|
+
const resolution = resolveState(pane, lines, memory.get(pane.id));
|
|
17
|
+
if (!resolution)
|
|
18
|
+
return null; // not an agent pane
|
|
19
|
+
nextMemory.set(pane.id, resolution.memory);
|
|
20
|
+
const git = await gitCache(pane.path);
|
|
21
|
+
return {
|
|
22
|
+
pane,
|
|
23
|
+
agent: resolution.agent,
|
|
24
|
+
state: resolution.state,
|
|
25
|
+
matchedRule: resolution.matchedRule,
|
|
26
|
+
git,
|
|
27
|
+
};
|
|
28
|
+
}));
|
|
29
|
+
const statuses = resolved.filter((s) => s !== null);
|
|
30
|
+
return { statuses, memory: nextMemory };
|
|
31
|
+
}
|
|
32
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
// Fire-once: sample twice with a short delay so "working" can be detected via
|
|
34
|
+
// content change even without a persistent watcher.
|
|
35
|
+
export async function scanOnce(opts, delayMs) {
|
|
36
|
+
let memory = new Map();
|
|
37
|
+
({ memory } = await scan(opts, memory));
|
|
38
|
+
await sleep(delayMs);
|
|
39
|
+
const { statuses } = await scan(opts, memory);
|
|
40
|
+
return statuses;
|
|
41
|
+
}
|
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
async function tmux(args) {
|
|
8
|
+
const { stdout } = await exec("tmux", args, { maxBuffer: 16 * 1024 * 1024 });
|
|
9
|
+
return stdout;
|
|
10
|
+
}
|
|
11
|
+
// tmux sanitizes ANY control character in -F output (including our separator and
|
|
12
|
+
// even newlines) to "_", so we can't pack multiple free-text fields into one
|
|
13
|
+
// formatted line. Instead we query one field per call, each paired with the
|
|
14
|
+
// pane id. pane_id is always "%<digits>" — it can't contain the "|" delimiter or
|
|
15
|
+
// a newline — so we split once on the first "|" and the value may then contain
|
|
16
|
+
// anything (spaces, "|", unicode) without ambiguity. Keying the merge by id
|
|
17
|
+
// makes it robust to the pane set changing between calls.
|
|
18
|
+
async function column(scope, field) {
|
|
19
|
+
const stdout = await tmux(["list-panes", ...scope, "-F", `#{pane_id}|${field}`]);
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const line of stdout.split("\n")) {
|
|
22
|
+
if (!line)
|
|
23
|
+
continue;
|
|
24
|
+
const i = line.indexOf("|");
|
|
25
|
+
if (i < 0)
|
|
26
|
+
continue;
|
|
27
|
+
rows.push([line.slice(0, i), line.slice(i + 1)]);
|
|
28
|
+
}
|
|
29
|
+
return rows;
|
|
30
|
+
}
|
|
31
|
+
export async function isServerRunning() {
|
|
32
|
+
try {
|
|
33
|
+
await tmux(["list-panes", "-a"]);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Enumerate panes. Scope defaults to the current session when run inside tmux,
|
|
41
|
+
// or all sessions with `all: true` (or when run outside tmux).
|
|
42
|
+
export async function listPanes(opts) {
|
|
43
|
+
const insideTmux = Boolean(process.env.TMUX);
|
|
44
|
+
const scope = opts.all || !insideTmux ? ["-a"] : ["-s"];
|
|
45
|
+
const [commands, pids, paths, titles, sessions, windows, windowIdx, actives] = await Promise.all([
|
|
46
|
+
column(scope, "#{pane_current_command}"),
|
|
47
|
+
column(scope, "#{pane_pid}"),
|
|
48
|
+
column(scope, "#{pane_current_path}"),
|
|
49
|
+
column(scope, "#{pane_title}"),
|
|
50
|
+
column(scope, "#{session_name}"),
|
|
51
|
+
column(scope, "#{window_name}"),
|
|
52
|
+
column(scope, "#{window_index}"),
|
|
53
|
+
column(scope, "#{pane_active}"),
|
|
54
|
+
]);
|
|
55
|
+
// Seed one PaneInfo per id from the first column, then fill from the rest.
|
|
56
|
+
// Keyed by id, so a pane appearing/vanishing mid-scan can't misalign fields.
|
|
57
|
+
const byId = new Map();
|
|
58
|
+
for (const [id] of commands) {
|
|
59
|
+
if (!byId.has(id)) {
|
|
60
|
+
byId.set(id, {
|
|
61
|
+
id,
|
|
62
|
+
command: "",
|
|
63
|
+
pid: 0,
|
|
64
|
+
path: "",
|
|
65
|
+
title: "",
|
|
66
|
+
sessionName: "",
|
|
67
|
+
windowName: "",
|
|
68
|
+
windowIndex: "",
|
|
69
|
+
active: false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const fill = (rows, set) => {
|
|
74
|
+
for (const [id, value] of rows) {
|
|
75
|
+
const p = byId.get(id);
|
|
76
|
+
if (p)
|
|
77
|
+
set(p, value);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
fill(commands, (p, v) => (p.command = v));
|
|
81
|
+
fill(pids, (p, v) => (p.pid = Number(v) || 0));
|
|
82
|
+
fill(paths, (p, v) => (p.path = v));
|
|
83
|
+
fill(titles, (p, v) => (p.title = v));
|
|
84
|
+
fill(sessions, (p, v) => (p.sessionName = v));
|
|
85
|
+
fill(windows, (p, v) => (p.windowName = v));
|
|
86
|
+
fill(windowIdx, (p, v) => (p.windowIndex = v));
|
|
87
|
+
fill(actives, (p, v) => (p.active = v === "1"));
|
|
88
|
+
return [...byId.values()];
|
|
89
|
+
}
|
|
90
|
+
// Capture the visible screen of a pane as plain text lines (no escape codes).
|
|
91
|
+
// -p prints to stdout; trailing blank lines trimmed.
|
|
92
|
+
export async function capturePane(paneId) {
|
|
93
|
+
const stdout = await tmux(["capture-pane", "-p", "-t", paneId]);
|
|
94
|
+
return stdout.replace(/\n+$/, "").split("\n");
|
|
95
|
+
}
|
|
96
|
+
// Move the *currently attached* tmux client to a pane: switch its session, then
|
|
97
|
+
// select the window and pane. A pane id (%N) resolves up to its window/session,
|
|
98
|
+
// so we only need the session name for switch-client. Use when already inside
|
|
99
|
+
// tmux (process.env.TMUX set).
|
|
100
|
+
export async function switchToPane(sessionName, paneId) {
|
|
101
|
+
await tmux(["switch-client", "-t", sessionName]);
|
|
102
|
+
await tmux(["select-window", "-t", paneId]);
|
|
103
|
+
await tmux(["select-pane", "-t", paneId]);
|
|
104
|
+
}
|
|
105
|
+
// Like switchToPane, but moves a *specific* client (by its tty) rather than the
|
|
106
|
+
// current one. This is how the dashboard in one window drives another window:
|
|
107
|
+
// select-window/select-pane set the agent session's active window+pane server-
|
|
108
|
+
// side, and switch-client -c points that other client at it.
|
|
109
|
+
export async function switchClientToPane(clientTty, sessionName, paneId) {
|
|
110
|
+
await tmux(["switch-client", "-c", clientTty, "-t", sessionName]);
|
|
111
|
+
await tmux(["select-window", "-t", paneId]);
|
|
112
|
+
await tmux(["select-pane", "-t", paneId]);
|
|
113
|
+
}
|
|
114
|
+
// All attached clients. Uses the same "id | value" split trick as listPanes to
|
|
115
|
+
// dodge tmux's control-character sanitization; client_tty never contains "|".
|
|
116
|
+
export async function listClients() {
|
|
117
|
+
let stdout = "";
|
|
118
|
+
try {
|
|
119
|
+
stdout = await tmux(["list-clients", "-F", "#{client_tty}|#{client_session}"]);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const clients = [];
|
|
125
|
+
for (const line of stdout.split("\n")) {
|
|
126
|
+
if (!line)
|
|
127
|
+
continue;
|
|
128
|
+
const i = line.indexOf("|");
|
|
129
|
+
if (i < 0)
|
|
130
|
+
continue;
|
|
131
|
+
clients.push({ tty: line.slice(0, i), session: line.slice(i + 1) });
|
|
132
|
+
}
|
|
133
|
+
return clients;
|
|
134
|
+
}
|
|
135
|
+
// The tty of the client this process is running under (best effort). Used to
|
|
136
|
+
// tell "this window" apart from the others when choosing a jump target.
|
|
137
|
+
export async function selfClientTty() {
|
|
138
|
+
try {
|
|
139
|
+
const tty = (await tmux(["display-message", "-p", "#{client_tty}"])).trim();
|
|
140
|
+
return tty || null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Pre-select the target window/pane on the server (no client needed), so a
|
|
147
|
+
// subsequent `tmux attach` lands with that pane active. Use when NOT inside
|
|
148
|
+
// tmux — the caller then execs `tmux attach-session -t <sessionName>`.
|
|
149
|
+
export async function preselectPane(paneId) {
|
|
150
|
+
await tmux(["select-window", "-t", paneId]);
|
|
151
|
+
await tmux(["select-pane", "-t", paneId]);
|
|
152
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sandropadin/tend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight status detector for AI coding agents running inside tmux. Reports blocked / working / idle per pane by scraping the terminal, plus git branch state.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tend": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"AGENTS.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node src/index.ts",
|
|
18
|
+
"watch": "node src/index.ts watch",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"build": "tsc -p tsconfig.build.json",
|
|
21
|
+
"prepare": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"typescript": "^5.9.2"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
|
|
9
|
+
import { extractRegion } from "./regions.ts";
|
|
10
|
+
import { manifestFor, MANIFESTS } from "./manifests.ts";
|
|
11
|
+
import type { AgentState, Manifest, PaneInfo, Rule } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export function identifyAgent(pane: PaneInfo, captureText: string): string | null {
|
|
14
|
+
const cmd = pane.command.toLowerCase();
|
|
15
|
+
// 1. Exact / substring command-name match (e.g. comm is literally "claude").
|
|
16
|
+
for (const m of MANIFESTS) {
|
|
17
|
+
if (m.match.some((name) => cmd === name || cmd.includes(name))) return m.agent;
|
|
18
|
+
}
|
|
19
|
+
// 2. Command-pattern match — catches Claude Code, whose comm is its version
|
|
20
|
+
// string (e.g. "2.1.200"). Most reliable: independent of screen scroll.
|
|
21
|
+
for (const m of MANIFESTS) {
|
|
22
|
+
if (m.commandPattern && new RegExp(m.commandPattern).test(pane.command)) {
|
|
23
|
+
return m.agent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// 3. Screen signature — for agents launched under a generic comm (node/python)
|
|
27
|
+
// whose version-rename doesn't apply. Matches persistent on-screen chrome.
|
|
28
|
+
const hay = captureText.toLowerCase();
|
|
29
|
+
for (const m of MANIFESTS) {
|
|
30
|
+
if (m.signature.some((sig) => hay.includes(sig.toLowerCase()))) return m.agent;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ruleMatches(rule: Rule, regionText: string): boolean {
|
|
36
|
+
const hay = regionText.toLowerCase();
|
|
37
|
+
if (rule.contains && !rule.contains.every((s) => hay.includes(s.toLowerCase()))) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (rule.anyContains && !rule.anyContains.some((s) => hay.includes(s.toLowerCase()))) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (rule.not && rule.not.some((s) => hay.includes(s.toLowerCase()))) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (rule.regex) {
|
|
47
|
+
const re = new RegExp(rule.regex, "im");
|
|
48
|
+
if (!re.test(regionText)) return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RuleVerdict {
|
|
54
|
+
state: AgentState;
|
|
55
|
+
ruleId: string | null;
|
|
56
|
+
hold: boolean; // skipStateUpdate matched — keep previous state
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Evaluate the manifest against captured lines; highest-priority match wins.
|
|
60
|
+
function evaluateRules(manifest: Manifest, lines: string[]): RuleVerdict {
|
|
61
|
+
const sorted = [...manifest.rules].sort((a, b) => b.priority - a.priority);
|
|
62
|
+
for (const rule of sorted) {
|
|
63
|
+
const regionText = extractRegion(lines, rule.region);
|
|
64
|
+
if (ruleMatches(rule, regionText)) {
|
|
65
|
+
return { state: rule.state, ruleId: rule.id, hold: rule.skipStateUpdate === true };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { state: "unknown", ruleId: null, hold: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Per-pane memory the watcher threads across ticks. Fire-once mode simulates it
|
|
72
|
+
// by sampling a pane twice with a short delay (see collectOnce).
|
|
73
|
+
export interface PaneMemory {
|
|
74
|
+
lastCaptureHash: string;
|
|
75
|
+
lastState: AgentState;
|
|
76
|
+
pendingIdle: boolean; // idle seen once; require a second read before committing
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Resolution {
|
|
80
|
+
agent: string;
|
|
81
|
+
state: AgentState;
|
|
82
|
+
matchedRule: string | null;
|
|
83
|
+
memory: PaneMemory;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hashLines(lines: string[]): string {
|
|
87
|
+
// Cheap content fingerprint — we only need "did it change", not crypto.
|
|
88
|
+
let h = 0;
|
|
89
|
+
const s = lines.join("\n");
|
|
90
|
+
for (let i = 0; i < s.length; i++) {
|
|
91
|
+
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
92
|
+
}
|
|
93
|
+
return `${s.length}:${h}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function resolveState(
|
|
97
|
+
pane: PaneInfo,
|
|
98
|
+
lines: string[],
|
|
99
|
+
prev: PaneMemory | undefined,
|
|
100
|
+
): Resolution | null {
|
|
101
|
+
const captureText = lines.join("\n");
|
|
102
|
+
const agent = identifyAgent(pane, captureText);
|
|
103
|
+
if (agent === null) return null;
|
|
104
|
+
|
|
105
|
+
const manifest = manifestFor(agent);
|
|
106
|
+
const verdict = manifest
|
|
107
|
+
? evaluateRules(manifest, lines)
|
|
108
|
+
: { state: "unknown" as AgentState, ruleId: null, hold: false };
|
|
109
|
+
|
|
110
|
+
const hash = hashLines(lines);
|
|
111
|
+
const changed = prev !== undefined && prev.lastCaptureHash !== hash;
|
|
112
|
+
const previousState: AgentState = prev?.lastState ?? "unknown";
|
|
113
|
+
|
|
114
|
+
let state: AgentState;
|
|
115
|
+
let pendingIdle = false;
|
|
116
|
+
|
|
117
|
+
// "blocked" must be earned by an active match every scan — never inherited.
|
|
118
|
+
// Holding it (on scrollback or an ambiguous frame) is exactly what keeps a
|
|
119
|
+
// stale "1 blocked" on the board after you've answered the prompt.
|
|
120
|
+
const keep = (prevState: AgentState): AgentState =>
|
|
121
|
+
prevState === "unknown" || prevState === "blocked" ? "idle" : prevState;
|
|
122
|
+
|
|
123
|
+
if (verdict.hold) {
|
|
124
|
+
// Scrollback / menu screen: don't author state, keep what we had.
|
|
125
|
+
state = keep(previousState);
|
|
126
|
+
} else if (verdict.state === "blocked") {
|
|
127
|
+
state = "blocked"; // strong signal, wins immediately
|
|
128
|
+
} else if (changed) {
|
|
129
|
+
state = "working"; // PTY activity is the authority for "working"
|
|
130
|
+
} else if (verdict.state === "working") {
|
|
131
|
+
state = "working"; // explicit interrupt-hint on screen
|
|
132
|
+
} else if (verdict.state === "idle") {
|
|
133
|
+
// Debounce: require idle to persist for two reads before committing, so a
|
|
134
|
+
// single quiet frame between output bursts doesn't flip us to idle.
|
|
135
|
+
if (previousState === "working" && prev?.pendingIdle !== true) {
|
|
136
|
+
state = "working";
|
|
137
|
+
pendingIdle = true;
|
|
138
|
+
} else {
|
|
139
|
+
state = "idle";
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Known agent, nothing matched: fall back without inheriting a stale block.
|
|
143
|
+
state = keep(previousState);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
agent,
|
|
148
|
+
state,
|
|
149
|
+
matchedRule: verdict.ruleId,
|
|
150
|
+
memory: { lastCaptureHash: hash, lastState: state, pendingIdle },
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import type { GitStatus } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
async function git(cwd: string, args: string[]): Promise<string | null> {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await exec("git", ["-C", cwd, ...args], {
|
|
14
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
15
|
+
});
|
|
16
|
+
return stdout.trim();
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function repoRoot(cwd: string): Promise<string | null> {
|
|
23
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function computeStatus(cwd: string): Promise<GitStatus | null> {
|
|
27
|
+
const branch = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
28
|
+
if (branch === null) return null; // not a git repo
|
|
29
|
+
|
|
30
|
+
const porcelain = await git(cwd, ["status", "--porcelain"]);
|
|
31
|
+
const dirty = porcelain !== null && porcelain.length > 0;
|
|
32
|
+
|
|
33
|
+
let ahead = 0;
|
|
34
|
+
let behind = 0;
|
|
35
|
+
const counts = await git(cwd, [
|
|
36
|
+
"rev-list",
|
|
37
|
+
"--left-right",
|
|
38
|
+
"--count",
|
|
39
|
+
"HEAD...@{upstream}",
|
|
40
|
+
]);
|
|
41
|
+
if (counts) {
|
|
42
|
+
const [a, b] = counts.split(/\s+/);
|
|
43
|
+
ahead = Number(a) || 0;
|
|
44
|
+
behind = Number(b) || 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { branch: branch === "HEAD" ? null : branch, dirty, ahead, behind };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Per-tick cache keyed by repo root, so panes sharing a repo share the answer.
|
|
51
|
+
export function createGitCache() {
|
|
52
|
+
const byRoot = new Map<string, Promise<GitStatus | null>>();
|
|
53
|
+
return async function statusFor(cwd: string): Promise<GitStatus | null> {
|
|
54
|
+
const root = await repoRoot(cwd);
|
|
55
|
+
if (root === null) return null;
|
|
56
|
+
let pending = byRoot.get(root);
|
|
57
|
+
if (!pending) {
|
|
58
|
+
// Compute from the repo root so all panes agree regardless of subdir.
|
|
59
|
+
pending = computeStatus(root);
|
|
60
|
+
byRoot.set(root, pending);
|
|
61
|
+
}
|
|
62
|
+
return pending;
|
|
63
|
+
};
|
|
64
|
+
}
|