@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,259 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MuxProviderV1,
|
|
3
|
+
MuxSessionInfo,
|
|
4
|
+
ActiveWindow,
|
|
5
|
+
SidebarPane,
|
|
6
|
+
SidebarPosition,
|
|
7
|
+
WindowCapable,
|
|
8
|
+
SidebarCapable,
|
|
9
|
+
BatchCapable,
|
|
10
|
+
} from "@tt-agentboard/runtime";
|
|
11
|
+
import { TmuxClient } from "./client";
|
|
12
|
+
import { debugLog } from "@tt-agentboard/runtime";
|
|
13
|
+
|
|
14
|
+
/** Settings for creating a tmux provider (ai-sdk style) */
|
|
15
|
+
export interface TmuxProviderSettings {
|
|
16
|
+
/** Override the provider name */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** Override the TmuxClient instance (for testing) */
|
|
19
|
+
client?: TmuxClient;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function plog(msg: string, data?: Record<string, unknown>) {
|
|
23
|
+
debugLog("provider", msg, data);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STASH_SESSION = "_ab_stash";
|
|
27
|
+
const SIDEBAR_PANE_TITLE = "agentboard-sidebar";
|
|
28
|
+
|
|
29
|
+
export class TmuxProvider implements MuxProviderV1, WindowCapable, SidebarCapable, BatchCapable {
|
|
30
|
+
readonly specificationVersion = "v1" as const;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
private readonly tmux: TmuxClient;
|
|
33
|
+
|
|
34
|
+
constructor(settings?: TmuxProviderSettings) {
|
|
35
|
+
this.name = settings?.name ?? "tmux";
|
|
36
|
+
this.tmux = settings?.client ?? new TmuxClient();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
listSessions(): MuxSessionInfo[] {
|
|
40
|
+
const sessions = this.tmux.listSessions().filter((s) => s.name !== STASH_SESSION);
|
|
41
|
+
const activeDirs = this.tmux.getActiveSessionDirs();
|
|
42
|
+
return sessions.map((s) => ({
|
|
43
|
+
name: s.name,
|
|
44
|
+
createdAt: s.createdAt,
|
|
45
|
+
dir: activeDirs.get(s.name) ?? s.dir,
|
|
46
|
+
windows: s.windowCount,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
switchSession(name: string, clientTty?: string): void {
|
|
51
|
+
this.tmux.switchClient(name, clientTty ? { clientTty } : undefined);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getCurrentSession(): string | null {
|
|
55
|
+
return this.tmux.getCurrentSession();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getSessionDir(name: string): string {
|
|
59
|
+
return this.tmux.getSessionDir(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getPaneCount(name: string): number {
|
|
63
|
+
return this.tmux.getPaneCount(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getClientTty(): string {
|
|
67
|
+
return this.tmux.getClientTty();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
createSession(name?: string, dir?: string): void {
|
|
71
|
+
this.tmux.newSession({ name, cwd: dir });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
killSession(name: string): void {
|
|
75
|
+
this.tmux.killSession(name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setupHooks(serverHost: string, serverPort: number): void {
|
|
79
|
+
const base = `http://${serverHost}:${serverPort}`;
|
|
80
|
+
const hookPost = (path: string, data?: string) => {
|
|
81
|
+
// #{q:...} escapes shell metacharacters in each tmux variable.
|
|
82
|
+
// Use escaped double quotes (\") inside the outer run-shell "..." to wrap the body.
|
|
83
|
+
const body = data ? ` -d \\"${data}\\"` : "";
|
|
84
|
+
return `run-shell -b "curl -s -o /dev/null -X POST ${base}${path}${body} >/dev/null 2>&1 || true"`;
|
|
85
|
+
};
|
|
86
|
+
// tmux expands #{} formats at hook-fire time — no need for $(tmux display-message)
|
|
87
|
+
// #{q:...} shell-escapes each value to prevent injection from session names etc.
|
|
88
|
+
const focusCmd = hookPost("/focus", "#{q:client_tty}|#{q:session_name}|#{q:window_id}");
|
|
89
|
+
const refreshCmd = hookPost("/refresh");
|
|
90
|
+
const resizeCmd = hookPost("/resize-sidebars");
|
|
91
|
+
const resizePaneCmd = hookPost(
|
|
92
|
+
"/resize-sidebars",
|
|
93
|
+
"#{q:pane_id}|#{q:session_name}|#{q:window_id}|#{q:pane_width}|#{q:window_width}",
|
|
94
|
+
);
|
|
95
|
+
const ensureCmd = hookPost(
|
|
96
|
+
"/ensure-sidebar",
|
|
97
|
+
"#{q:client_tty}|#{q:session_name}|#{q:window_id}",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// client-session-changed: update focus AND ensure sidebar in the new session's window
|
|
101
|
+
this.tmux.setGlobalHook("client-session-changed", `${focusCmd} ; ${ensureCmd}`);
|
|
102
|
+
this.tmux.setGlobalHook("session-created", refreshCmd);
|
|
103
|
+
this.tmux.setGlobalHook("session-closed", refreshCmd);
|
|
104
|
+
this.tmux.setGlobalHook("client-resized", resizeCmd);
|
|
105
|
+
this.tmux.setGlobalHook("after-select-window", ensureCmd);
|
|
106
|
+
this.tmux.setGlobalHook("after-new-window", ensureCmd);
|
|
107
|
+
this.tmux.setGlobalHook("after-resize-pane", resizePaneCmd);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
cleanupHooks(): void {
|
|
111
|
+
this.tmux.unsetGlobalHook("client-session-changed");
|
|
112
|
+
this.tmux.unsetGlobalHook("session-created");
|
|
113
|
+
this.tmux.unsetGlobalHook("session-closed");
|
|
114
|
+
this.tmux.unsetGlobalHook("client-resized");
|
|
115
|
+
this.tmux.unsetGlobalHook("after-select-window");
|
|
116
|
+
this.tmux.unsetGlobalHook("after-new-window");
|
|
117
|
+
this.tmux.unsetGlobalHook("after-resize-pane");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getAllPaneCounts(): Map<string, number> {
|
|
121
|
+
return this.tmux.getAllPaneCounts();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
listActiveWindows(): ActiveWindow[] {
|
|
125
|
+
return this.tmux
|
|
126
|
+
.listWindows()
|
|
127
|
+
.filter((w) => w.active && w.sessionName !== STASH_SESSION)
|
|
128
|
+
.map((w) => ({ id: w.id, sessionName: w.sessionName, active: w.active }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getCurrentWindowId(): string | null {
|
|
132
|
+
return this.tmux.getCurrentWindowId() || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
cleanupSidebar(): void {
|
|
136
|
+
// Kill the stash session used for hiding sidebar panes
|
|
137
|
+
this.tmux.killSession(STASH_SESSION);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
listSidebarPanes(sessionName?: string): SidebarPane[] {
|
|
141
|
+
const panes = sessionName
|
|
142
|
+
? this.tmux.listPanes({ scope: "session", target: sessionName })
|
|
143
|
+
: this.tmux.listPanes();
|
|
144
|
+
const windowWidths = new Map<string, number>();
|
|
145
|
+
for (const pane of panes) {
|
|
146
|
+
windowWidths.set(
|
|
147
|
+
pane.windowId,
|
|
148
|
+
Math.max(windowWidths.get(pane.windowId) ?? 0, pane.right + 1),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return panes
|
|
153
|
+
.filter((p) => p.title === SIDEBAR_PANE_TITLE && p.sessionName !== STASH_SESSION)
|
|
154
|
+
.map((p) => ({
|
|
155
|
+
paneId: p.id,
|
|
156
|
+
sessionName: p.sessionName,
|
|
157
|
+
windowId: p.windowId,
|
|
158
|
+
width: p.width,
|
|
159
|
+
windowWidth: windowWidths.get(p.windowId),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Ensure the invisible stash session exists for hiding sidebar panes */
|
|
164
|
+
private ensureStash(): void {
|
|
165
|
+
const r = this.tmux.run(["has-session", "-t", STASH_SESSION]);
|
|
166
|
+
if (!r.ok) {
|
|
167
|
+
this.tmux.rawRun(["new-session", "-d", "-s", STASH_SESSION, "-x", "80", "-y", "24"]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
spawnSidebar(
|
|
172
|
+
sessionName: string,
|
|
173
|
+
windowId: string,
|
|
174
|
+
width: number,
|
|
175
|
+
position: SidebarPosition,
|
|
176
|
+
): string | null {
|
|
177
|
+
// Find the edge pane to split against
|
|
178
|
+
const panes = this.tmux.listPanes({ scope: "window", target: windowId });
|
|
179
|
+
plog("spawnSidebar", { windowId, paneCount: panes.length });
|
|
180
|
+
if (panes.length === 0) return null;
|
|
181
|
+
|
|
182
|
+
const targetPane =
|
|
183
|
+
position === "left"
|
|
184
|
+
? panes.reduce((a, b) => (a.left <= b.left ? a : b))
|
|
185
|
+
: panes.reduce((a, b) => (a.right >= b.right ? a : b));
|
|
186
|
+
|
|
187
|
+
// --- Try to restore a stashed sidebar pane ---
|
|
188
|
+
try {
|
|
189
|
+
const stashPanes = this.tmux.listPanes({ scope: "session", target: STASH_SESSION });
|
|
190
|
+
const stashedPane = stashPanes.find((p) => p.title === SIDEBAR_PANE_TITLE);
|
|
191
|
+
if (stashedPane) {
|
|
192
|
+
plog("spawnSidebar: restoring from stash", {
|
|
193
|
+
paneId: stashedPane.id,
|
|
194
|
+
target: targetPane.id,
|
|
195
|
+
});
|
|
196
|
+
const joinFlag = position === "left" ? "-hb" : "-h";
|
|
197
|
+
this.tmux.rawRun([
|
|
198
|
+
"join-pane",
|
|
199
|
+
joinFlag,
|
|
200
|
+
"-f",
|
|
201
|
+
"-l",
|
|
202
|
+
String(width),
|
|
203
|
+
"-s",
|
|
204
|
+
stashedPane.id,
|
|
205
|
+
"-t",
|
|
206
|
+
targetPane.id,
|
|
207
|
+
]);
|
|
208
|
+
this.tmux.setPaneTitle(stashedPane.id, SIDEBAR_PANE_TITLE);
|
|
209
|
+
// Do NOT selectPane here — same as fresh spawns. The TUI's
|
|
210
|
+
// restoreTerminalModes fires on focus-in after join-pane, generating
|
|
211
|
+
// capability query responses. Refocusing the main pane immediately
|
|
212
|
+
// causes those responses to leak as garbage escape sequences.
|
|
213
|
+
return stashedPane.id;
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
/* stash session doesn't exist yet — spawn fresh */
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- No stashed pane, spawn fresh ---
|
|
220
|
+
plog("spawnSidebar: spawning new", { target: targetPane.id, width, position });
|
|
221
|
+
const newPane = this.tmux.splitWindow({
|
|
222
|
+
target: targetPane.id,
|
|
223
|
+
direction: "horizontal",
|
|
224
|
+
before: position === "left",
|
|
225
|
+
fullWindow: true,
|
|
226
|
+
size: width,
|
|
227
|
+
command: `REFOCUS_WINDOW=${windowId} exec tt agentboard tui`,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!newPane) {
|
|
231
|
+
plog("spawnSidebar: splitWindow FAILED");
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.tmux.setPaneTitle(newPane.id, SIDEBAR_PANE_TITLE);
|
|
236
|
+
// Do NOT selectPane here for fresh spawns — the TUI's refocusMainPane()
|
|
237
|
+
// handles it after terminal capability detection finishes. Refocusing
|
|
238
|
+
// immediately causes capability query responses (DECRPM, DA1, Kitty
|
|
239
|
+
// graphics) to be routed to the main pane as garbage escape sequences.
|
|
240
|
+
return newPane.id;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
hideSidebar(paneId: string): void {
|
|
244
|
+
this.ensureStash();
|
|
245
|
+
// Ensure the stash window is large enough to accept another pane.
|
|
246
|
+
// join-pane fails with "pane too small" when stash panes fill up.
|
|
247
|
+
this.tmux.rawRun(["resize-window", "-t", `${STASH_SESSION}:`, "-x", "200", "-y", "200"]);
|
|
248
|
+
plog("hideSidebar: stashing pane", { paneId });
|
|
249
|
+
this.tmux.rawRun(["join-pane", "-d", "-s", paneId, "-t", `${STASH_SESSION}:`]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
killSidebarPane(paneId: string): void {
|
|
253
|
+
this.tmux.killPane(paneId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
resizeSidebarPane(paneId: string, width: number): void {
|
|
257
|
+
this.tmux.resizePane(paneId, { width });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tt-agentboard/runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/bun": "latest",
|
|
12
|
+
"typescript": "^5"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { AgentEvent } from "../contracts/agent";
|
|
2
|
+
import { TERMINAL_STATUSES } from "../contracts/agent";
|
|
3
|
+
|
|
4
|
+
const MAX_EVENT_TIMESTAMPS = 30;
|
|
5
|
+
const TERMINAL_PRUNE_MS = 5 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
const STATUS_PRIORITY: Record<string, number> = {
|
|
8
|
+
running: 5,
|
|
9
|
+
error: 4,
|
|
10
|
+
interrupted: 3,
|
|
11
|
+
waiting: 2,
|
|
12
|
+
done: 1,
|
|
13
|
+
idle: 0,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function instanceKey(agent: string, threadId?: string): string {
|
|
17
|
+
return threadId ? `${agent}:${threadId}` : agent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class AgentTracker {
|
|
21
|
+
// Outer key: session name, inner key: instance key (agent or agent:threadId)
|
|
22
|
+
private instances = new Map<string, Map<string, AgentEvent>>();
|
|
23
|
+
private eventTimestamps = new Map<string, number[]>();
|
|
24
|
+
// Per-instance unseen tracking: "session\0instanceKey"
|
|
25
|
+
private unseenInstances = new Set<string>();
|
|
26
|
+
private active = new Set<string>();
|
|
27
|
+
// Pinned instances: agents backed by a live pane process — exempt from pruning
|
|
28
|
+
private pinnedKeys = new Map<string, Set<string>>(); // session → Set<instanceKey>
|
|
29
|
+
|
|
30
|
+
private unseenKey(session: string, key: string): string {
|
|
31
|
+
return `${session}\0${key}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
applyEvent(event: AgentEvent, options?: { seed?: boolean }): void {
|
|
35
|
+
const key = instanceKey(event.agent, event.threadId);
|
|
36
|
+
|
|
37
|
+
// Store instance
|
|
38
|
+
let sessionInstances = this.instances.get(event.session);
|
|
39
|
+
if (!sessionInstances) {
|
|
40
|
+
sessionInstances = new Map();
|
|
41
|
+
this.instances.set(event.session, sessionInstances);
|
|
42
|
+
}
|
|
43
|
+
sessionInstances.set(key, event);
|
|
44
|
+
|
|
45
|
+
// Track event timestamps
|
|
46
|
+
let timestamps = this.eventTimestamps.get(event.session);
|
|
47
|
+
if (!timestamps) {
|
|
48
|
+
timestamps = [];
|
|
49
|
+
this.eventTimestamps.set(event.session, timestamps);
|
|
50
|
+
}
|
|
51
|
+
timestamps.push(event.ts);
|
|
52
|
+
if (timestamps.length > MAX_EVENT_TIMESTAMPS) {
|
|
53
|
+
timestamps.splice(0, timestamps.length - MAX_EVENT_TIMESTAMPS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Per-instance unseen tracking
|
|
57
|
+
// Seeded events always mark as unseen (they represent state from before the user connected)
|
|
58
|
+
const ukey = this.unseenKey(event.session, key);
|
|
59
|
+
if (TERMINAL_STATUSES.has(event.status)) {
|
|
60
|
+
if (options?.seed || !this.active.has(event.session)) {
|
|
61
|
+
this.unseenInstances.add(ukey);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// Non-terminal status for this instance = user is interacting, mark seen
|
|
65
|
+
this.unseenInstances.delete(ukey);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Returns the most important agent state for backward compat */
|
|
70
|
+
getState(session: string): AgentEvent | null {
|
|
71
|
+
const sessionInstances = this.instances.get(session);
|
|
72
|
+
if (!sessionInstances || sessionInstances.size === 0) return null;
|
|
73
|
+
|
|
74
|
+
let best: AgentEvent | null = null;
|
|
75
|
+
let bestPriority = -1;
|
|
76
|
+
for (const event of sessionInstances.values()) {
|
|
77
|
+
const p = STATUS_PRIORITY[event.status] ?? 0;
|
|
78
|
+
if (p > bestPriority) {
|
|
79
|
+
bestPriority = p;
|
|
80
|
+
best = event;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return best;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns all agent instances for a session, with unseen flag stamped */
|
|
87
|
+
getAgents(session: string): AgentEvent[] {
|
|
88
|
+
const sessionInstances = this.instances.get(session);
|
|
89
|
+
if (!sessionInstances) return [];
|
|
90
|
+
return [...sessionInstances.values()]
|
|
91
|
+
.map((event) => {
|
|
92
|
+
const key = instanceKey(event.agent, event.threadId);
|
|
93
|
+
const isUnseen = this.unseenInstances.has(this.unseenKey(session, key));
|
|
94
|
+
return isUnseen ? { ...event, unseen: true } : event;
|
|
95
|
+
})
|
|
96
|
+
.sort((a, b) => b.ts - a.ts);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns recent event timestamps for sparkline rendering */
|
|
100
|
+
getEventTimestamps(session: string): number[] {
|
|
101
|
+
return this.eventTimestamps.get(session) ?? [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
markSeen(session: string): boolean {
|
|
105
|
+
const hadUnseen = this.isUnseen(session);
|
|
106
|
+
if (!hadUnseen) return false;
|
|
107
|
+
|
|
108
|
+
// Clear unseen flags for all instances — keep the instances themselves
|
|
109
|
+
// (pruneTerminal will remove seen terminal instances after timeout)
|
|
110
|
+
const sessionInstances = this.instances.get(session);
|
|
111
|
+
if (sessionInstances) {
|
|
112
|
+
for (const key of sessionInstances.keys()) {
|
|
113
|
+
this.unseenInstances.delete(this.unseenKey(session, key));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dismiss(session: string, agent: string, threadId?: string): boolean {
|
|
120
|
+
const sessionInstances = this.instances.get(session);
|
|
121
|
+
if (!sessionInstances) return false;
|
|
122
|
+
|
|
123
|
+
const key = instanceKey(agent, threadId);
|
|
124
|
+
const removed = sessionInstances.delete(key);
|
|
125
|
+
if (!removed) return false;
|
|
126
|
+
|
|
127
|
+
this.unseenInstances.delete(this.unseenKey(session, key));
|
|
128
|
+
if (sessionInstances.size === 0) {
|
|
129
|
+
this.instances.delete(session);
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pruneStuck(timeoutMs: number): void {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
for (const [session, sessionInstances] of this.instances) {
|
|
137
|
+
for (const [key, event] of sessionInstances) {
|
|
138
|
+
if (event.status === "running" && now - event.ts > timeoutMs) {
|
|
139
|
+
if (this.isPinned(session, key)) continue;
|
|
140
|
+
sessionInstances.delete(key);
|
|
141
|
+
this.unseenInstances.delete(this.unseenKey(session, key));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (sessionInstances.size === 0) {
|
|
145
|
+
this.instances.delete(session);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Auto-prune terminal instances older than timeout, but only if instance is not unseen or pinned */
|
|
151
|
+
pruneTerminal(): void {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
for (const [session, sessionInstances] of this.instances) {
|
|
154
|
+
for (const [key, event] of sessionInstances) {
|
|
155
|
+
if (!TERMINAL_STATUSES.has(event.status)) continue;
|
|
156
|
+
const ukey = this.unseenKey(session, key);
|
|
157
|
+
if (this.unseenInstances.has(ukey)) continue; // Don't prune unseen — user hasn't looked yet
|
|
158
|
+
if (this.isPinned(session, key)) continue; // Don't prune agents backed by live panes
|
|
159
|
+
if (now - event.ts > TERMINAL_PRUNE_MS) {
|
|
160
|
+
sessionInstances.delete(key);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (sessionInstances.size === 0) {
|
|
164
|
+
this.instances.delete(session);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
isUnseen(session: string): boolean {
|
|
170
|
+
// Session is unseen if any instance within it is unseen
|
|
171
|
+
const sessionInstances = this.instances.get(session);
|
|
172
|
+
if (!sessionInstances) return false;
|
|
173
|
+
for (const key of sessionInstances.keys()) {
|
|
174
|
+
if (this.unseenInstances.has(this.unseenKey(session, key))) return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
getUnseen(): string[] {
|
|
180
|
+
// Derive session-level unseen from per-instance tracking
|
|
181
|
+
const sessions = new Set<string>();
|
|
182
|
+
for (const ukey of this.unseenInstances) {
|
|
183
|
+
sessions.add(ukey.split("\0")[0]!);
|
|
184
|
+
}
|
|
185
|
+
return [...sessions];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
handleFocus(session: string): boolean {
|
|
189
|
+
this.active.clear();
|
|
190
|
+
this.active.add(session);
|
|
191
|
+
|
|
192
|
+
const hadUnseen = this.isUnseen(session);
|
|
193
|
+
if (hadUnseen) {
|
|
194
|
+
// Clear unseen flags — keep terminal instances visible (as "seen")
|
|
195
|
+
// pruneTerminal will clean them up after timeout
|
|
196
|
+
const sessionInstances = this.instances.get(session);
|
|
197
|
+
if (sessionInstances) {
|
|
198
|
+
for (const key of sessionInstances.keys()) {
|
|
199
|
+
this.unseenInstances.delete(this.unseenKey(session, key));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return hadUnseen;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setActiveSessions(sessions: string[]): void {
|
|
207
|
+
this.active.clear();
|
|
208
|
+
for (const s of sessions) this.active.add(s);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Update the set of pinned instance keys for a session (live pane-backed agents). */
|
|
212
|
+
setPinnedInstances(session: string | null, keys: string[]): void {
|
|
213
|
+
this.pinnedKeys.clear();
|
|
214
|
+
if (session && keys.length > 0) {
|
|
215
|
+
this.pinnedKeys.set(session, new Set(keys));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Update pinned instance keys for multiple sessions at once. */
|
|
220
|
+
setPinnedInstancesMulti(keysBySession: Map<string, string[]>): void {
|
|
221
|
+
this.pinnedKeys.clear();
|
|
222
|
+
for (const [session, keys] of keysBySession) {
|
|
223
|
+
if (keys.length > 0) {
|
|
224
|
+
this.pinnedKeys.set(session, new Set(keys));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Check if an instance is pinned (backed by a live pane process). */
|
|
230
|
+
isPinned(session: string, key: string): boolean {
|
|
231
|
+
return this.pinnedKeys.get(session)?.has(key) ?? false;
|
|
232
|
+
}
|
|
233
|
+
}
|