@towles/tool 0.0.106 → 0.0.108
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/README.md +7 -1
- package/package.json +2 -1
- package/plugins/tt-agentboard/README.md +160 -0
- package/plugins/tt-agentboard/apps/server/package.json +20 -0
- package/plugins/tt-agentboard/apps/server/src/main.ts +60 -0
- package/plugins/tt-agentboard/apps/tui/build.ts +11 -0
- package/plugins/tt-agentboard/apps/tui/bunfig.toml +1 -0
- package/plugins/tt-agentboard/apps/tui/package.json +23 -0
- package/plugins/tt-agentboard/apps/tui/scripts/sessionizer.sh +36 -0
- package/plugins/tt-agentboard/apps/tui/src/components/DetailPanel.tsx +350 -0
- package/plugins/tt-agentboard/apps/tui/src/components/DiffStats.tsx +33 -0
- package/plugins/tt-agentboard/apps/tui/src/components/SessionCard.tsx +177 -0
- package/plugins/tt-agentboard/apps/tui/src/components/StatusBar.tsx +49 -0
- package/plugins/tt-agentboard/apps/tui/src/constants.ts +46 -0
- package/plugins/tt-agentboard/apps/tui/src/detail-panel-height.ts +21 -0
- package/plugins/tt-agentboard/apps/tui/src/index.tsx +880 -0
- package/plugins/tt-agentboard/apps/tui/src/mux-context.ts +61 -0
- package/plugins/tt-agentboard/apps/tui/tsconfig.json +15 -0
- package/plugins/tt-agentboard/bun.lock +444 -0
- package/plugins/tt-agentboard/package.json +26 -0
- package/plugins/tt-agentboard/packages/mux-tmux/package.json +14 -0
- package/plugins/tt-agentboard/packages/mux-tmux/src/client.ts +550 -0
- package/plugins/tt-agentboard/packages/mux-tmux/src/index.ts +18 -0
- package/plugins/tt-agentboard/packages/mux-tmux/src/provider.ts +259 -0
- package/plugins/tt-agentboard/packages/mux-tmux/tsconfig.json +13 -0
- package/plugins/tt-agentboard/packages/runtime/package.json +14 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/tracker.ts +233 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/amp.ts +316 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/claude-code.ts +374 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/codex.ts +364 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/opencode.ts +249 -0
- package/plugins/tt-agentboard/packages/runtime/src/config.ts +70 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/agent-watcher.ts +38 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/agent.ts +16 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/index.ts +3 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/mux.ts +148 -0
- package/plugins/tt-agentboard/packages/runtime/src/debug.ts +19 -0
- package/plugins/tt-agentboard/packages/runtime/src/index.ts +69 -0
- package/plugins/tt-agentboard/packages/runtime/src/mux/detect.ts +20 -0
- package/plugins/tt-agentboard/packages/runtime/src/mux/registry.ts +45 -0
- package/plugins/tt-agentboard/packages/runtime/src/plugins/loader.ts +152 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/context.ts +112 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/git-info.ts +164 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/index.ts +1753 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/launcher.ts +71 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/metadata-store.ts +86 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/pane-scanner.ts +327 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/port-scanner.ts +155 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/session-order.ts +127 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-manager.ts +232 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-width-sync.ts +66 -0
- package/plugins/tt-agentboard/packages/runtime/src/shared.ts +179 -0
- package/plugins/tt-agentboard/packages/runtime/src/themes.ts +750 -0
- package/plugins/tt-agentboard/packages/runtime/test/config.test.ts +83 -0
- package/plugins/tt-agentboard/packages/runtime/test/tracker.test.ts +172 -0
- package/plugins/tt-agentboard/packages/runtime/tsconfig.json +13 -0
- package/plugins/tt-agentboard/tsconfig.json +19 -0
- package/plugins/tt-auto-claude/.claude-plugin/plugin.json +8 -0
- package/plugins/tt-auto-claude/commands/create-issue.md +20 -0
- package/plugins/tt-auto-claude/commands/list.md +21 -0
- package/plugins/tt-auto-claude/skills/auto-claude/SKILL.md +71 -0
- package/plugins/tt-core/.claude-plugin/plugin.json +8 -0
- package/plugins/tt-core/README.md +18 -0
- package/plugins/tt-core/commands/improve-architecture.md +66 -0
- package/plugins/tt-core/commands/interview-me.md +38 -0
- package/plugins/tt-core/commands/prd-to-issues.md +49 -0
- package/plugins/tt-core/commands/refine-text.md +30 -0
- package/plugins/tt-core/commands/task.md +37 -0
- package/plugins/tt-core/commands/tdd.md +69 -0
- package/plugins/tt-core/commands/write-prd.md +69 -0
- package/plugins/tt-core/promptfooconfig.interview-me.yaml +155 -0
- package/plugins/tt-core/promptfooconfig.refine-text.yaml +242 -0
- package/plugins/tt-core/promptfooconfig.tdd.yaml +144 -0
- package/plugins/tt-core/promptfooconfig.write-prd.yaml +145 -0
- package/plugins/tt-core/skills/towles-tool/SKILL.md +35 -0
- package/src/commands/agentboard.ts +19 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import { SERVER_PORT, SERVER_HOST, PID_FILE } from "../shared";
|
|
5
|
+
import { SERVER_ERR_LOG } from "../debug";
|
|
6
|
+
|
|
7
|
+
function isProcessAlive(pid: number): boolean {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function isPortOpen(host: string, port: number, timeoutMs = 200): Promise<boolean> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const socket = connect({ host, port });
|
|
19
|
+
const timer = setTimeout(() => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(false);
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
socket.on("connect", () => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
socket.destroy();
|
|
26
|
+
resolve(true);
|
|
27
|
+
});
|
|
28
|
+
socket.on("error", () => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
resolve(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveAgentboardDir(): string {
|
|
36
|
+
if (process.env.TT_AGENTBOARD_DIR) return process.env.TT_AGENTBOARD_DIR;
|
|
37
|
+
// Walk up from packages/runtime/src/server/ to the plugin root
|
|
38
|
+
return new URL("../../../..", import.meta.url).pathname;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveServerEntryPath(pluginDir: string): string {
|
|
42
|
+
return join(pluginDir, "apps", "server", "src", "main.ts");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function ensureServer(): Promise<void> {
|
|
46
|
+
if (existsSync(PID_FILE)) {
|
|
47
|
+
const pid = Number.parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
48
|
+
if (!Number.isNaN(pid) && isProcessAlive(pid) && (await isPortOpen(SERVER_HOST, SERVER_PORT))) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pluginDir = resolveAgentboardDir();
|
|
54
|
+
const serverPath = resolveServerEntryPath(pluginDir);
|
|
55
|
+
|
|
56
|
+
const proc = Bun.spawn([process.execPath, "run", serverPath], {
|
|
57
|
+
stdio: ["ignore", "ignore", Bun.file(SERVER_ERR_LOG)],
|
|
58
|
+
cwd: pluginDir,
|
|
59
|
+
env: { ...process.env, TT_AGENTBOARD_DIR: pluginDir },
|
|
60
|
+
});
|
|
61
|
+
proc.unref();
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < 60; i++) {
|
|
64
|
+
await Bun.sleep(50);
|
|
65
|
+
if (await isPortOpen(SERVER_HOST, SERVER_PORT, 100)) return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const errLog = existsSync(SERVER_ERR_LOG) ? readFileSync(SERVER_ERR_LOG, "utf-8").trim() : "";
|
|
69
|
+
const detail = errLog || `No error output. Check ${SERVER_ERR_LOG}`;
|
|
70
|
+
throw new Error(`AgentBoard server failed to start:\n${detail}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { MetadataTone, SessionMetadata } from "../shared";
|
|
2
|
+
|
|
3
|
+
const MAX_LOGS = 50;
|
|
4
|
+
const MAX_MESSAGE_LENGTH = 500;
|
|
5
|
+
|
|
6
|
+
function truncate(s: string, max: number = MAX_MESSAGE_LENGTH): string {
|
|
7
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SessionMetadataStore {
|
|
11
|
+
private store = new Map<string, SessionMetadata>();
|
|
12
|
+
|
|
13
|
+
private getOrCreate(session: string): SessionMetadata {
|
|
14
|
+
let meta = this.store.get(session);
|
|
15
|
+
if (!meta) {
|
|
16
|
+
meta = { status: null, progress: null, logs: [] };
|
|
17
|
+
this.store.set(session, meta);
|
|
18
|
+
}
|
|
19
|
+
return meta;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get(session: string): SessionMetadata | null {
|
|
23
|
+
const meta = this.store.get(session);
|
|
24
|
+
if (!meta) return null;
|
|
25
|
+
// Return null if everything is empty
|
|
26
|
+
if (!meta.status && !meta.progress && meta.logs.length === 0) return null;
|
|
27
|
+
return meta;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setStatus(session: string, status: { text: string; tone?: MetadataTone } | null): void {
|
|
31
|
+
if (!status) {
|
|
32
|
+
const meta = this.store.get(session);
|
|
33
|
+
if (meta) meta.status = null;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const meta = this.getOrCreate(session);
|
|
37
|
+
meta.status = { text: truncate(status.text, 100), tone: status.tone, ts: Date.now() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setProgress(
|
|
41
|
+
session: string,
|
|
42
|
+
progress: { current?: number; total?: number; percent?: number; label?: string } | null,
|
|
43
|
+
): void {
|
|
44
|
+
if (!progress) {
|
|
45
|
+
const meta = this.store.get(session);
|
|
46
|
+
if (meta) meta.progress = null;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const meta = this.getOrCreate(session);
|
|
50
|
+
meta.progress = {
|
|
51
|
+
current: progress.current,
|
|
52
|
+
total: progress.total,
|
|
53
|
+
percent: progress.percent,
|
|
54
|
+
label: progress.label ? truncate(progress.label, 100) : undefined,
|
|
55
|
+
ts: Date.now(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
appendLog(
|
|
60
|
+
session: string,
|
|
61
|
+
entry: { message: string; tone?: MetadataTone; source?: string },
|
|
62
|
+
): void {
|
|
63
|
+
const meta = this.getOrCreate(session);
|
|
64
|
+
meta.logs.push({
|
|
65
|
+
message: truncate(entry.message),
|
|
66
|
+
tone: entry.tone,
|
|
67
|
+
source: entry.source ? truncate(entry.source, 50) : undefined,
|
|
68
|
+
ts: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
if (meta.logs.length > MAX_LOGS) {
|
|
71
|
+
meta.logs = meta.logs.slice(meta.logs.length - MAX_LOGS);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
clearLogs(session: string): void {
|
|
76
|
+
const meta = this.store.get(session);
|
|
77
|
+
if (meta) meta.logs = [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Remove metadata for sessions that no longer exist */
|
|
81
|
+
pruneSessions(validNames: Set<string>): void {
|
|
82
|
+
for (const name of this.store.keys()) {
|
|
83
|
+
if (!validNames.has(name)) this.store.delete(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { AgentStatus } from "../contracts/agent";
|
|
5
|
+
import type { SidebarPane } from "../contracts/mux";
|
|
6
|
+
import type { ServerContext, PaneAgentPresence } from "./context";
|
|
7
|
+
import { shell } from "./git-info";
|
|
8
|
+
|
|
9
|
+
const AGENT_TITLE_PATTERNS: Record<string, string[]> = {
|
|
10
|
+
amp: ["amp"],
|
|
11
|
+
"claude-code": ["claude"],
|
|
12
|
+
codex: ["codex"],
|
|
13
|
+
opencode: ["opencode"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Build parent->children map from a single ps snapshot (avoids per-pane pgrep calls). */
|
|
17
|
+
function buildProcessTree(): { childrenOf: Map<number, number[]>; commOf: Map<number, string> } {
|
|
18
|
+
const childrenOf = new Map<number, number[]>();
|
|
19
|
+
const commOf = new Map<number, string>();
|
|
20
|
+
const psResult = Bun.spawnSync(["ps", "-eo", "pid=,ppid=,comm="], {
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
});
|
|
24
|
+
for (const line of psResult.stdout.toString().trim().split("\n")) {
|
|
25
|
+
const parts = line.trim().split(/\s+/);
|
|
26
|
+
if (parts.length < 3) continue;
|
|
27
|
+
const pid = Number.parseInt(parts[0], 10);
|
|
28
|
+
const ppid = Number.parseInt(parts[1], 10);
|
|
29
|
+
const comm = parts.slice(2).join(" ").toLowerCase();
|
|
30
|
+
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
|
|
31
|
+
commOf.set(pid, comm);
|
|
32
|
+
let arr = childrenOf.get(ppid);
|
|
33
|
+
if (!arr) {
|
|
34
|
+
arr = [];
|
|
35
|
+
childrenOf.set(ppid, arr);
|
|
36
|
+
}
|
|
37
|
+
arr.push(pid);
|
|
38
|
+
}
|
|
39
|
+
return { childrenOf, commOf };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Walk up to 3 levels of child processes using a pre-built process tree. */
|
|
43
|
+
function matchProcessTreeFast(
|
|
44
|
+
pid: number,
|
|
45
|
+
patterns: string[],
|
|
46
|
+
tree: ReturnType<typeof buildProcessTree>,
|
|
47
|
+
depth = 0,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (depth > 2) return false;
|
|
50
|
+
const children = tree.childrenOf.get(pid);
|
|
51
|
+
if (!children) return false;
|
|
52
|
+
for (const childPid of children) {
|
|
53
|
+
const comm = tree.commOf.get(childPid);
|
|
54
|
+
if (comm && patterns.some((pat) => comm.includes(pat))) return true;
|
|
55
|
+
if (matchProcessTreeFast(childPid, patterns, tree, depth + 1)) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Find child PID matching a name pattern using pre-built process tree. */
|
|
61
|
+
function findChildPidFast(
|
|
62
|
+
pid: number,
|
|
63
|
+
name: string,
|
|
64
|
+
tree: ReturnType<typeof buildProcessTree>,
|
|
65
|
+
depth = 0,
|
|
66
|
+
): number | undefined {
|
|
67
|
+
if (depth > 2) return undefined;
|
|
68
|
+
const children = tree.childrenOf.get(pid);
|
|
69
|
+
if (!children) return undefined;
|
|
70
|
+
for (const childPid of children) {
|
|
71
|
+
const comm = tree.commOf.get(childPid);
|
|
72
|
+
if (comm?.includes(name)) return childPid;
|
|
73
|
+
const found = findChildPidFast(childPid, name, tree, depth + 1);
|
|
74
|
+
if (found) return found;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolve threadId/threadName for an amp pane from its title. */
|
|
80
|
+
function resolveAmpPaneInfo(title: string): { threadId?: string; threadName?: string } {
|
|
81
|
+
if (!title.toLowerCase().startsWith("amp - ")) return {};
|
|
82
|
+
const rest = title.slice(6);
|
|
83
|
+
const dashIdx = rest.lastIndexOf(" - ");
|
|
84
|
+
const threadName = dashIdx > 0 ? rest.slice(0, dashIdx) : rest;
|
|
85
|
+
return { threadName: threadName || undefined };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolve threadId/threadName/status for a Claude Code pane via ~/.claude/sessions/<pid>.json + journal. */
|
|
89
|
+
function resolveClaudeCodePaneInfo(
|
|
90
|
+
panePid: number,
|
|
91
|
+
tree: ReturnType<typeof buildProcessTree>,
|
|
92
|
+
): { threadId?: string; threadName?: string; status?: AgentStatus } {
|
|
93
|
+
const agentPid = findChildPidFast(panePid, "claude", tree);
|
|
94
|
+
if (!agentPid) return {};
|
|
95
|
+
const sessionsDir = join(homedir(), ".claude", "sessions");
|
|
96
|
+
try {
|
|
97
|
+
const data = JSON.parse(readFileSync(join(sessionsDir, `${agentPid}.json`), "utf-8"));
|
|
98
|
+
const threadId: string | undefined = data.sessionId;
|
|
99
|
+
if (!threadId) return {};
|
|
100
|
+
const journalInfo = resolveClaudeCodeJournalInfo(threadId);
|
|
101
|
+
return { threadId, ...journalInfo };
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read the JSONL journal to extract thread name and current status. */
|
|
108
|
+
function resolveClaudeCodeJournalInfo(threadId: string): {
|
|
109
|
+
threadName?: string;
|
|
110
|
+
status?: AgentStatus;
|
|
111
|
+
} {
|
|
112
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
113
|
+
try {
|
|
114
|
+
const dirs = require("node:fs").readdirSync(projectsDir) as string[];
|
|
115
|
+
for (const dir of dirs) {
|
|
116
|
+
const filePath = join(projectsDir, dir, `${threadId}.jsonl`);
|
|
117
|
+
try {
|
|
118
|
+
const text = readFileSync(filePath, "utf-8");
|
|
119
|
+
const lines = text.split("\n").filter(Boolean);
|
|
120
|
+
let threadName: string | undefined;
|
|
121
|
+
let lastStatus: AgentStatus = "idle";
|
|
122
|
+
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
try {
|
|
125
|
+
const entry = JSON.parse(line);
|
|
126
|
+
const msg = entry.message;
|
|
127
|
+
if (!msg?.role) continue;
|
|
128
|
+
|
|
129
|
+
if (!threadName && msg.role === "user") {
|
|
130
|
+
const content = msg.content;
|
|
131
|
+
let t: string | undefined;
|
|
132
|
+
if (typeof content === "string") t = content;
|
|
133
|
+
else if (Array.isArray(content))
|
|
134
|
+
t = content.find((c: any) => c.type === "text" && c.text)?.text;
|
|
135
|
+
if (t && !t.startsWith("<") && !t.startsWith("{")) threadName = t.slice(0, 80);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (msg.role === "assistant") {
|
|
139
|
+
const items = Array.isArray(msg.content) ? msg.content : [];
|
|
140
|
+
lastStatus = items.some((c: any) => c.type === "tool_use") ? "running" : "done";
|
|
141
|
+
} else if (msg.role === "user") {
|
|
142
|
+
lastStatus = "running";
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { threadName, status: lastStatus };
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolve threadId for a Codex pane via logs_1.sqlite. */
|
|
159
|
+
function resolveCodexPaneInfo(
|
|
160
|
+
panePid: number,
|
|
161
|
+
tree: ReturnType<typeof buildProcessTree>,
|
|
162
|
+
): { threadId?: string; threadName?: string } {
|
|
163
|
+
const agentPid = findChildPidFast(panePid, "codex", tree);
|
|
164
|
+
if (!agentPid) return {};
|
|
165
|
+
const dbPath = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "logs_1.sqlite");
|
|
166
|
+
let db: any;
|
|
167
|
+
try {
|
|
168
|
+
const { Database } = require("bun:sqlite");
|
|
169
|
+
db = new Database(dbPath, { readonly: true });
|
|
170
|
+
} catch {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const row = db
|
|
175
|
+
.query(
|
|
176
|
+
`SELECT thread_id FROM logs WHERE process_uuid LIKE ? AND thread_id IS NOT NULL ORDER BY ts DESC LIMIT 1`,
|
|
177
|
+
)
|
|
178
|
+
.get(`pid:${agentPid}:%`);
|
|
179
|
+
if (row?.thread_id) return { threadId: row.thread_id };
|
|
180
|
+
} catch {
|
|
181
|
+
} finally {
|
|
182
|
+
try {
|
|
183
|
+
db.close();
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Scan all panes across all tmux sessions and identify running agents.
|
|
190
|
+
* Uses a single `tmux list-panes -a` call for efficiency. */
|
|
191
|
+
function scanAllTmuxPaneAgents(
|
|
192
|
+
listSidebarPanesByProvider: () => { panes: SidebarPane[] }[],
|
|
193
|
+
): Map<string, Map<string, PaneAgentPresence>> {
|
|
194
|
+
const result = new Map<string, Map<string, PaneAgentPresence>>();
|
|
195
|
+
|
|
196
|
+
const raw = shell([
|
|
197
|
+
"tmux",
|
|
198
|
+
"list-panes",
|
|
199
|
+
"-a",
|
|
200
|
+
"-F",
|
|
201
|
+
"#{session_name}|#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_title}",
|
|
202
|
+
]);
|
|
203
|
+
if (!raw) return result;
|
|
204
|
+
|
|
205
|
+
const panes = raw
|
|
206
|
+
.split("\n")
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.map((line) => {
|
|
209
|
+
const idx1 = line.indexOf("|");
|
|
210
|
+
const idx2 = line.indexOf("|", idx1 + 1);
|
|
211
|
+
const idx3 = line.indexOf("|", idx2 + 1);
|
|
212
|
+
const idx4 = line.indexOf("|", idx3 + 1);
|
|
213
|
+
return {
|
|
214
|
+
session: line.slice(0, idx1),
|
|
215
|
+
id: line.slice(idx1 + 1, idx2),
|
|
216
|
+
pid: Number.parseInt(line.slice(idx2 + 1, idx3), 10),
|
|
217
|
+
cmd: line.slice(idx3 + 1, idx4),
|
|
218
|
+
title: line.slice(idx4 + 1),
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Exclude sidebar panes
|
|
223
|
+
const sidebarPaneIds = new Set<string>();
|
|
224
|
+
for (const { panes: sbPanes } of listSidebarPanesByProvider()) {
|
|
225
|
+
for (const sb of sbPanes) sidebarPaneIds.add(sb.paneId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nonSidebar = panes.filter((p) => !sidebarPaneIds.has(p.id));
|
|
229
|
+
if (nonSidebar.length === 0) return result;
|
|
230
|
+
|
|
231
|
+
// Build process tree once for all panes
|
|
232
|
+
const tree = buildProcessTree();
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
|
|
235
|
+
for (const pane of nonSidebar) {
|
|
236
|
+
for (const [agentName, patterns] of Object.entries(AGENT_TITLE_PATTERNS)) {
|
|
237
|
+
if (!matchProcessTreeFast(pane.pid, patterns, tree)) continue;
|
|
238
|
+
|
|
239
|
+
let threadId: string | undefined;
|
|
240
|
+
let threadName: string | undefined;
|
|
241
|
+
let status: AgentStatus | undefined;
|
|
242
|
+
|
|
243
|
+
if (agentName === "amp") {
|
|
244
|
+
const info = resolveAmpPaneInfo(pane.title);
|
|
245
|
+
threadName = info.threadName;
|
|
246
|
+
} else if (agentName === "claude-code") {
|
|
247
|
+
const info = resolveClaudeCodePaneInfo(pane.pid, tree);
|
|
248
|
+
threadId = info.threadId;
|
|
249
|
+
threadName = info.threadName;
|
|
250
|
+
status = info.status;
|
|
251
|
+
} else if (agentName === "codex") {
|
|
252
|
+
const info = resolveCodexPaneInfo(pane.pid, tree);
|
|
253
|
+
threadId = info.threadId;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const key = `${agentName}:pane:${pane.id}`;
|
|
257
|
+
let sessionAgents = result.get(pane.session);
|
|
258
|
+
if (!sessionAgents) {
|
|
259
|
+
sessionAgents = new Map();
|
|
260
|
+
result.set(pane.session, sessionAgents);
|
|
261
|
+
}
|
|
262
|
+
sessionAgents.set(key, {
|
|
263
|
+
agent: agentName,
|
|
264
|
+
session: pane.session,
|
|
265
|
+
paneId: pane.id,
|
|
266
|
+
threadId,
|
|
267
|
+
threadName,
|
|
268
|
+
status,
|
|
269
|
+
lastSeenTs: now,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Refresh pane agent cache for all tmux sessions. */
|
|
278
|
+
export function refreshPaneAgents(ctx: ServerContext): void {
|
|
279
|
+
const hasTmux = ctx.allProviders.some((p) => p.name === "tmux");
|
|
280
|
+
if (!hasTmux) {
|
|
281
|
+
if (ctx.paneAgentsBySession.size > 0) {
|
|
282
|
+
ctx.paneAgentsBySession.clear();
|
|
283
|
+
ctx.tracker.setPinnedInstancesMulti(new Map());
|
|
284
|
+
ctx.broadcastState();
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const nextBySession = scanAllTmuxPaneAgents(ctx.listSidebarPanesByProvider);
|
|
290
|
+
const allPinnedKeys = new Map<string, string[]>();
|
|
291
|
+
for (const [session, agents] of nextBySession) {
|
|
292
|
+
allPinnedKeys.set(session, [...agents.keys()]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check if anything changed
|
|
296
|
+
let changed = ctx.paneAgentsBySession.size !== nextBySession.size;
|
|
297
|
+
if (!changed) {
|
|
298
|
+
for (const [session, agents] of nextBySession) {
|
|
299
|
+
const prev = ctx.paneAgentsBySession.get(session);
|
|
300
|
+
if (!prev || prev.size !== agents.size) {
|
|
301
|
+
changed = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
for (const key of agents.keys()) {
|
|
305
|
+
if (!prev.has(key)) {
|
|
306
|
+
changed = true;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (changed) break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
ctx.paneAgentsBySession = nextBySession;
|
|
315
|
+
ctx.tracker.setPinnedInstancesMulti(allPinnedKeys);
|
|
316
|
+
|
|
317
|
+
if (changed) ctx.broadcastState();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const PANE_SCAN_INTERVAL_MS = 3_000;
|
|
321
|
+
|
|
322
|
+
export function startPaneScan(ctx: ServerContext): ReturnType<typeof setInterval> {
|
|
323
|
+
return setInterval(() => {
|
|
324
|
+
if (ctx.clientCount === 0) return;
|
|
325
|
+
refreshPaneAgents(ctx);
|
|
326
|
+
}, PANE_SCAN_INTERVAL_MS);
|
|
327
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { debugLog } from "../debug";
|
|
2
|
+
|
|
3
|
+
// Global port snapshot — refreshed by the port poll timer, read by computeState.
|
|
4
|
+
// Runs lsof + ps once for ALL sessions instead of per-session.
|
|
5
|
+
let portSnapshot = new Map<string, number[]>();
|
|
6
|
+
|
|
7
|
+
export function refreshPortSnapshot(sessionNames: string[]): boolean {
|
|
8
|
+
try {
|
|
9
|
+
// 1. Gather pane PIDs for all sessions in one tmux call per session
|
|
10
|
+
const panePidsBySession = new Map<string, number[]>();
|
|
11
|
+
for (const name of sessionNames) {
|
|
12
|
+
const r = Bun.spawnSync(["tmux", "list-panes", "-s", "-t", name, "-F", "#{pane_pid}"], {
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
});
|
|
16
|
+
const pids = r.stdout
|
|
17
|
+
.toString()
|
|
18
|
+
.trim()
|
|
19
|
+
.split("\n")
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(Number)
|
|
22
|
+
.filter((n) => !Number.isNaN(n));
|
|
23
|
+
if (pids.length > 0) panePidsBySession.set(name, pids);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (panePidsBySession.size === 0) {
|
|
27
|
+
portSnapshot = new Map();
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Build parent->children map from a single ps call
|
|
32
|
+
const childrenOf = new Map<number, number[]>();
|
|
33
|
+
const psResult = Bun.spawnSync(["ps", "-eo", "pid=,ppid="], { stdout: "pipe", stderr: "pipe" });
|
|
34
|
+
for (const line of psResult.stdout.toString().trim().split("\n")) {
|
|
35
|
+
const parts = line.trim().split(/\s+/);
|
|
36
|
+
if (parts.length < 2) continue;
|
|
37
|
+
const pid = Number.parseInt(parts[0], 10);
|
|
38
|
+
const ppid = Number.parseInt(parts[1], 10);
|
|
39
|
+
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
|
|
40
|
+
let arr = childrenOf.get(ppid);
|
|
41
|
+
if (!arr) {
|
|
42
|
+
arr = [];
|
|
43
|
+
childrenOf.set(ppid, arr);
|
|
44
|
+
}
|
|
45
|
+
arr.push(pid);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. BFS from pane PIDs to get full descendant tree per session
|
|
49
|
+
const pidToSessions = new Map<number, string[]>();
|
|
50
|
+
for (const [name, panePids] of panePidsBySession) {
|
|
51
|
+
const allPids = new Set<number>(panePids);
|
|
52
|
+
const queue = [...panePids];
|
|
53
|
+
while (queue.length > 0) {
|
|
54
|
+
const pid = queue.pop()!;
|
|
55
|
+
const kids = childrenOf.get(pid);
|
|
56
|
+
if (!kids) continue;
|
|
57
|
+
for (const kid of kids) {
|
|
58
|
+
if (!allPids.has(kid)) {
|
|
59
|
+
allPids.add(kid);
|
|
60
|
+
queue.push(kid);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const pid of allPids) {
|
|
65
|
+
let arr = pidToSessions.get(pid);
|
|
66
|
+
if (!arr) {
|
|
67
|
+
arr = [];
|
|
68
|
+
pidToSessions.set(pid, arr);
|
|
69
|
+
}
|
|
70
|
+
arr.push(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. Single lsof call for all listening TCP ports
|
|
75
|
+
const lsofResult = Bun.spawnSync(["lsof", "-iTCP", "-sTCP:LISTEN", "-nP", "-F", "pn"], {
|
|
76
|
+
stdout: "pipe",
|
|
77
|
+
stderr: "pipe",
|
|
78
|
+
});
|
|
79
|
+
if (lsofResult.exitCode !== 0) {
|
|
80
|
+
debugLog("ports", "lsof failed", {
|
|
81
|
+
exitCode: lsofResult.exitCode,
|
|
82
|
+
stderr: lsofResult.stderr.toString().slice(0, 200),
|
|
83
|
+
});
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 5. Parse and attribute ports to sessions
|
|
88
|
+
const sessionPorts = new Map<string, Set<number>>();
|
|
89
|
+
let currentPid = 0;
|
|
90
|
+
for (const line of lsofResult.stdout.toString().split("\n")) {
|
|
91
|
+
if (line.startsWith("p")) {
|
|
92
|
+
currentPid = Number.parseInt(line.slice(1), 10);
|
|
93
|
+
} else if (line.startsWith("n")) {
|
|
94
|
+
const sessions = pidToSessions.get(currentPid);
|
|
95
|
+
if (!sessions) continue;
|
|
96
|
+
const match = line.match(/:(\d+)$/);
|
|
97
|
+
if (!match) continue;
|
|
98
|
+
const port = Number.parseInt(match[1], 10);
|
|
99
|
+
if (Number.isNaN(port)) continue;
|
|
100
|
+
for (const name of sessions) {
|
|
101
|
+
let set = sessionPorts.get(name);
|
|
102
|
+
if (!set) {
|
|
103
|
+
set = new Set();
|
|
104
|
+
sessionPorts.set(name, set);
|
|
105
|
+
}
|
|
106
|
+
set.add(port);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 6. Build the new snapshot
|
|
112
|
+
const next = new Map<string, number[]>();
|
|
113
|
+
for (const name of sessionNames) {
|
|
114
|
+
const set = sessionPorts.get(name);
|
|
115
|
+
next.set(name, set ? [...set].sort((a, b) => a - b) : []);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const changed = !mapsEqual(portSnapshot, next);
|
|
119
|
+
portSnapshot = next;
|
|
120
|
+
return changed;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
debugLog("ports", "refreshPortSnapshot failed", { error: String(err) });
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mapsEqual(a: Map<string, number[]>, b: Map<string, number[]>): boolean {
|
|
128
|
+
if (a.size !== b.size) return false;
|
|
129
|
+
for (const [k, v] of a) {
|
|
130
|
+
const bv = b.get(k);
|
|
131
|
+
if (!bv || bv.length !== v.length || v.some((n, i) => n !== bv[i])) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getSessionPorts(sessionName: string): number[] {
|
|
137
|
+
return portSnapshot.get(sessionName) ?? [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function startPortPoll(ctx: {
|
|
141
|
+
lastState: { sessions: { name: string }[] } | null;
|
|
142
|
+
clientCount: number;
|
|
143
|
+
broadcastState: () => void;
|
|
144
|
+
}): ReturnType<typeof setInterval> {
|
|
145
|
+
// Run initial snapshot immediately so first broadcast has ports
|
|
146
|
+
if (ctx.lastState) {
|
|
147
|
+
refreshPortSnapshot(ctx.lastState.sessions.map((s) => s.name));
|
|
148
|
+
}
|
|
149
|
+
const PORT_POLL_INTERVAL_MS = 10_000;
|
|
150
|
+
return setInterval(() => {
|
|
151
|
+
if (!ctx.lastState || ctx.clientCount === 0) return;
|
|
152
|
+
const changed = refreshPortSnapshot(ctx.lastState.sessions.map((s) => s.name));
|
|
153
|
+
if (changed) ctx.broadcastState();
|
|
154
|
+
}, PORT_POLL_INTERVAL_MS);
|
|
155
|
+
}
|