@towles/tool 0.0.107 → 0.0.109

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.
Files changed (77) hide show
  1. package/README.md +7 -1
  2. package/package.json +2 -1
  3. package/plugins/tt-agentboard/README.md +160 -0
  4. package/plugins/tt-agentboard/apps/server/package.json +20 -0
  5. package/plugins/tt-agentboard/apps/server/src/main.ts +60 -0
  6. package/plugins/tt-agentboard/apps/tui/build.ts +11 -0
  7. package/plugins/tt-agentboard/apps/tui/bunfig.toml +1 -0
  8. package/plugins/tt-agentboard/apps/tui/package.json +23 -0
  9. package/plugins/tt-agentboard/apps/tui/scripts/sessionizer.sh +36 -0
  10. package/plugins/tt-agentboard/apps/tui/src/components/DetailPanel.tsx +350 -0
  11. package/plugins/tt-agentboard/apps/tui/src/components/DiffStats.tsx +33 -0
  12. package/plugins/tt-agentboard/apps/tui/src/components/SessionCard.tsx +177 -0
  13. package/plugins/tt-agentboard/apps/tui/src/components/StatusBar.tsx +49 -0
  14. package/plugins/tt-agentboard/apps/tui/src/constants.ts +46 -0
  15. package/plugins/tt-agentboard/apps/tui/src/detail-panel-height.ts +21 -0
  16. package/plugins/tt-agentboard/apps/tui/src/index.tsx +880 -0
  17. package/plugins/tt-agentboard/apps/tui/src/mux-context.ts +61 -0
  18. package/plugins/tt-agentboard/apps/tui/tsconfig.json +15 -0
  19. package/plugins/tt-agentboard/bun.lock +444 -0
  20. package/plugins/tt-agentboard/package.json +26 -0
  21. package/plugins/tt-agentboard/packages/mux-tmux/package.json +14 -0
  22. package/plugins/tt-agentboard/packages/mux-tmux/src/client.ts +550 -0
  23. package/plugins/tt-agentboard/packages/mux-tmux/src/index.ts +18 -0
  24. package/plugins/tt-agentboard/packages/mux-tmux/src/provider.ts +259 -0
  25. package/plugins/tt-agentboard/packages/mux-tmux/tsconfig.json +13 -0
  26. package/plugins/tt-agentboard/packages/runtime/package.json +14 -0
  27. package/plugins/tt-agentboard/packages/runtime/src/agents/tracker.ts +233 -0
  28. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/amp.ts +316 -0
  29. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/claude-code.ts +374 -0
  30. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/codex.ts +364 -0
  31. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/opencode.ts +249 -0
  32. package/plugins/tt-agentboard/packages/runtime/src/config.ts +70 -0
  33. package/plugins/tt-agentboard/packages/runtime/src/contracts/agent-watcher.ts +38 -0
  34. package/plugins/tt-agentboard/packages/runtime/src/contracts/agent.ts +16 -0
  35. package/plugins/tt-agentboard/packages/runtime/src/contracts/index.ts +3 -0
  36. package/plugins/tt-agentboard/packages/runtime/src/contracts/mux.ts +148 -0
  37. package/plugins/tt-agentboard/packages/runtime/src/debug.ts +19 -0
  38. package/plugins/tt-agentboard/packages/runtime/src/index.ts +69 -0
  39. package/plugins/tt-agentboard/packages/runtime/src/mux/detect.ts +20 -0
  40. package/plugins/tt-agentboard/packages/runtime/src/mux/registry.ts +45 -0
  41. package/plugins/tt-agentboard/packages/runtime/src/plugins/loader.ts +152 -0
  42. package/plugins/tt-agentboard/packages/runtime/src/server/context.ts +112 -0
  43. package/plugins/tt-agentboard/packages/runtime/src/server/git-info.ts +164 -0
  44. package/plugins/tt-agentboard/packages/runtime/src/server/index.ts +1753 -0
  45. package/plugins/tt-agentboard/packages/runtime/src/server/launcher.ts +71 -0
  46. package/plugins/tt-agentboard/packages/runtime/src/server/metadata-store.ts +86 -0
  47. package/plugins/tt-agentboard/packages/runtime/src/server/pane-scanner.ts +327 -0
  48. package/plugins/tt-agentboard/packages/runtime/src/server/port-scanner.ts +155 -0
  49. package/plugins/tt-agentboard/packages/runtime/src/server/session-order.ts +127 -0
  50. package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-manager.ts +232 -0
  51. package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-width-sync.ts +66 -0
  52. package/plugins/tt-agentboard/packages/runtime/src/shared.ts +179 -0
  53. package/plugins/tt-agentboard/packages/runtime/src/themes.ts +750 -0
  54. package/plugins/tt-agentboard/packages/runtime/test/config.test.ts +83 -0
  55. package/plugins/tt-agentboard/packages/runtime/test/tracker.test.ts +172 -0
  56. package/plugins/tt-agentboard/packages/runtime/tsconfig.json +13 -0
  57. package/plugins/tt-agentboard/tsconfig.json +19 -0
  58. package/plugins/tt-auto-claude/.claude-plugin/plugin.json +8 -0
  59. package/plugins/tt-auto-claude/commands/create-issue.md +20 -0
  60. package/plugins/tt-auto-claude/commands/list.md +21 -0
  61. package/plugins/tt-auto-claude/skills/auto-claude/SKILL.md +71 -0
  62. package/plugins/tt-core/.claude-plugin/plugin.json +8 -0
  63. package/plugins/tt-core/README.md +18 -0
  64. package/plugins/tt-core/commands/improve-architecture.md +66 -0
  65. package/plugins/tt-core/commands/interview-me.md +38 -0
  66. package/plugins/tt-core/commands/prd-to-issues.md +49 -0
  67. package/plugins/tt-core/commands/refine-text.md +30 -0
  68. package/plugins/tt-core/commands/task.md +37 -0
  69. package/plugins/tt-core/commands/tdd.md +69 -0
  70. package/plugins/tt-core/commands/write-prd.md +69 -0
  71. package/plugins/tt-core/promptfooconfig.interview-me.yaml +155 -0
  72. package/plugins/tt-core/promptfooconfig.refine-text.yaml +242 -0
  73. package/plugins/tt-core/promptfooconfig.tdd.yaml +144 -0
  74. package/plugins/tt-core/promptfooconfig.write-prd.yaml +145 -0
  75. package/plugins/tt-core/skills/towles-tool/SKILL.md +35 -0
  76. package/src/cli.ts +2 -1
  77. package/src/commands/agentboard.ts +19 -2
@@ -0,0 +1,1753 @@
1
+ import { readFileSync, statSync, unlinkSync, writeFileSync, appendFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { MuxProvider } from "../contracts/mux";
5
+ import { isFullSidebarCapable, isBatchCapable } from "../contracts/mux";
6
+ import type { AgentEvent } from "../contracts/agent";
7
+ import type { AgentWatcher, AgentWatcherContext } from "../contracts/agent-watcher";
8
+ import { AgentTracker, instanceKey } from "../agents/tracker";
9
+ import { SessionOrder } from "./session-order";
10
+ import { SessionMetadataStore } from "./metadata-store";
11
+ import { loadConfig, saveConfig } from "../config";
12
+ import { resolveSidebarWidthFromResizeContext, snapshotSidebarWindows } from "./sidebar-width-sync";
13
+ import type { SidebarResizeContext, SidebarResizeSuppression } from "./sidebar-width-sync";
14
+ import { shell, getGitInfo, syncGitWatchers, teardownGitWatchers } from "./git-info";
15
+ import { refreshPortSnapshot, getSessionPorts } from "./port-scanner";
16
+ import {
17
+ SERVER_PORT,
18
+ SERVER_HOST,
19
+ PID_FILE,
20
+ SERVER_IDLE_TIMEOUT_MS,
21
+ STUCK_RUNNING_TIMEOUT_MS,
22
+ } from "../shared";
23
+ import type { ServerState, SessionData, ClientCommand, FocusUpdate, MetadataTone } from "../shared";
24
+
25
+ const VALID_TONES = new Set<string>(["neutral", "info", "success", "warn", "error"]);
26
+ function parseTone(v: unknown): MetadataTone | undefined {
27
+ return typeof v === "string" && VALID_TONES.has(v) ? (v as MetadataTone) : undefined;
28
+ }
29
+
30
+ // --- Debug logger ---
31
+
32
+ const DEBUG_LOG = "/tmp/agentboard-debug.log";
33
+ const DEBUG_ENABLED = !!process.env.TT_AGENTBOARD_DEBUG;
34
+
35
+ function log(category: string, msg: string, data?: Record<string, unknown>) {
36
+ if (!DEBUG_ENABLED) return;
37
+ const ts = new Date().toISOString().slice(11, 23);
38
+ const extra = data ? " " + JSON.stringify(data) : "";
39
+ const line = `[${ts}] [${category}] ${msg}${extra}\n`;
40
+ try {
41
+ appendFileSync(DEBUG_LOG, line);
42
+ } catch {}
43
+ }
44
+
45
+ // shell, getGitInfo, invalidateGitCache imported from ./git-info
46
+
47
+ // refreshPortSnapshot, getSessionPorts imported from ./port-scanner
48
+
49
+ // syncGitWatchers, teardownGitWatchers imported from ./git-info
50
+
51
+ // --- Server startup ---
52
+
53
+ export function startServer(
54
+ mux: MuxProvider,
55
+ extraProviders?: MuxProvider[],
56
+ watchers?: AgentWatcher[],
57
+ ): void {
58
+ const allProviders = [mux, ...(extraProviders ?? [])];
59
+ const allWatchers = watchers ?? [];
60
+ const tracker = new AgentTracker();
61
+ const metadataStore = new SessionMetadataStore();
62
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
63
+ const sessionOrderPath = join(home, ".config", "towles-tool", "agentboard", "session-order.json");
64
+ const sessionOrder = new SessionOrder(sessionOrderPath);
65
+
66
+ // Clear previous log on server start
67
+ try {
68
+ writeFileSync(DEBUG_LOG, "");
69
+ } catch {}
70
+ log("server", "starting", { providers: allProviders.map((p) => p.name) });
71
+
72
+ // Load initial theme from config
73
+ const config = loadConfig();
74
+ let currentTheme: string | undefined =
75
+ typeof config.theme === "string" ? config.theme : undefined;
76
+ let sidebarWidth = config.sidebarWidth ?? 26;
77
+ let sidebarPosition: "left" | "right" = config.sidebarPosition ?? "left";
78
+ let sidebarVisible = false;
79
+
80
+ log("server", "config loaded", {
81
+ sidebarWidth,
82
+ sidebarPosition,
83
+ theme: currentTheme,
84
+ configKeys: Object.keys(config),
85
+ });
86
+
87
+ // Bootstrap active sessions
88
+ const currentSession = mux.getCurrentSession();
89
+ if (currentSession) {
90
+ tracker.setActiveSessions([currentSession]);
91
+ }
92
+
93
+ // --- Agent watcher context ---
94
+
95
+ let watcherBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
96
+
97
+ function debouncedBroadcast() {
98
+ if (watcherBroadcastTimer) return;
99
+ watcherBroadcastTimer = setTimeout(() => {
100
+ watcherBroadcastTimer = null;
101
+ broadcastState();
102
+ }, 200);
103
+ }
104
+
105
+ // Cache for dir→session resolution (rebuilt per scan cycle)
106
+ let dirSessionCache: Map<string, string> | null = null;
107
+ let dirSessionCacheTs = 0;
108
+ const DIR_CACHE_TTL = 5000;
109
+
110
+ function getDirSessionMap(): Map<string, string> {
111
+ const now = Date.now();
112
+ if (dirSessionCache && now - dirSessionCacheTs < DIR_CACHE_TTL) return dirSessionCache;
113
+ const map = new Map<string, string>();
114
+ for (const p of allProviders) {
115
+ for (const s of p.listSessions()) {
116
+ if (s.dir) map.set(s.dir, s.name);
117
+ }
118
+ }
119
+ dirSessionCache = map;
120
+ dirSessionCacheTs = now;
121
+ return map;
122
+ }
123
+
124
+ /** Encode a path the same way Claude Code does: replace `/` with `-`. */
125
+ function encodeProjectDir(dir: string): string {
126
+ return dir.replace(/\//g, "-");
127
+ }
128
+
129
+ const watcherCtx: AgentWatcherContext = {
130
+ resolveSession(projectDir: string): string | null {
131
+ const map = getDirSessionMap();
132
+ const direct = map.get(projectDir);
133
+ if (direct) return direct;
134
+ for (const [dir, name] of map) {
135
+ if (projectDir.startsWith(dir + "/") || dir.startsWith(projectDir + "/")) return name;
136
+ }
137
+ // Fallback: the decoded projectDir may be wrong due to dash ambiguity.
138
+ // Re-encode each known session dir and check if the encoded form matches
139
+ // as a prefix of the (still-encoded) input.
140
+ const encoded = encodeProjectDir(projectDir);
141
+ for (const [dir, name] of map) {
142
+ const encodedDir = encodeProjectDir(dir);
143
+ if (encoded.startsWith(encodedDir) || encodedDir.startsWith(encoded)) return name;
144
+ }
145
+ return null;
146
+ },
147
+ emit(event: AgentEvent) {
148
+ tracker.applyEvent(event, { seed: !watchersSeeded });
149
+ debouncedBroadcast();
150
+ },
151
+ };
152
+
153
+ // Flag to track when initial watcher seeding is complete
154
+ let watchersSeeded = false;
155
+ setTimeout(() => {
156
+ watchersSeeded = true;
157
+ // Re-apply focus for the current session to clear seed-unseen flags
158
+ // (handleFocus already ran before seed events arrived)
159
+ const current = getCurrentSession();
160
+ if (current && tracker.handleFocus(current)) {
161
+ broadcastState();
162
+ }
163
+ }, 3000);
164
+
165
+ let focusedSession: string | null = null;
166
+ let lastState: ServerState | null = null;
167
+ let clientCount = 0;
168
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
169
+ const clientTtys = new WeakMap<object, string>();
170
+ const clientSessionNames = new WeakMap<object, string>();
171
+ const sessionProviders = new Map<string, MuxProvider>();
172
+ // Map session name → client TTY (from hook context, for multi-client setups)
173
+ const clientTtyBySession = new Map<string, string>();
174
+
175
+ function getCurrentSession(): string | null {
176
+ // Try all providers until one returns a session
177
+ for (const p of allProviders) {
178
+ const result = p.getCurrentSession();
179
+ if (result) {
180
+ log("getCurrentSession", "result", { result, provider: p.name });
181
+ return result;
182
+ }
183
+ }
184
+ log("getCurrentSession", "no provider returned a session");
185
+ return null;
186
+ }
187
+
188
+ /** Merge pane-detected agents into watcher-provided agents for a session.
189
+ * Watcher events take precedence — pane presence only adds synthetic entries
190
+ * for agents that aren't already tracked by watchers. */
191
+ function mergeAgentsWithPanePresence(
192
+ sessionName: string,
193
+ watcherAgents: AgentEvent[],
194
+ ): AgentEvent[] {
195
+ const paneAgents = paneAgentsBySession.get(sessionName);
196
+ if (!paneAgents || paneAgents.size === 0) return watcherAgents;
197
+
198
+ const result = [...watcherAgents];
199
+ // Build a set of tracked agent:threadId keys for matching
200
+ const trackedByKey = new Set(watcherAgents.map((a) => instanceKey(a.agent, a.threadId)));
201
+ // Also track which agent names + threadIds are covered by watchers
202
+ const trackedThreadIds = new Set(
203
+ watcherAgents.filter((a) => a.threadId).map((a) => `${a.agent}:${a.threadId}`),
204
+ );
205
+
206
+ for (const [, presence] of paneAgents) {
207
+ // If the pane scanner resolved a threadId, check if watcher already tracks it
208
+ if (presence.threadId && trackedThreadIds.has(`${presence.agent}:${presence.threadId}`))
209
+ continue;
210
+ // Check by instanceKey as well
211
+ if (trackedByKey.has(instanceKey(presence.agent, presence.threadId))) continue;
212
+ // If we have no threadId from pane scan and watcher tracks any instance of this agent, skip
213
+ if (!presence.threadId && watcherAgents.some((a) => a.agent === presence.agent)) continue;
214
+
215
+ result.push({
216
+ agent: presence.agent,
217
+ session: sessionName,
218
+ status: presence.status ?? "idle",
219
+ ts: presence.lastSeenTs,
220
+ threadId: presence.threadId,
221
+ threadName: presence.threadName,
222
+ paneId: presence.paneId,
223
+ });
224
+ }
225
+
226
+ return result;
227
+ }
228
+
229
+ function computeState(): ServerState {
230
+ // Merge sessions from all providers
231
+ const allMuxSessions: (import("../contracts/mux").MuxSessionInfo & {
232
+ provider: MuxProvider;
233
+ })[] = [];
234
+ for (const p of allProviders) {
235
+ for (const s of p.listSessions()) {
236
+ allMuxSessions.push({ ...s, provider: p });
237
+ }
238
+ }
239
+ allMuxSessions.sort((a, b) => {
240
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
241
+ return a.name.localeCompare(b.name);
242
+ });
243
+
244
+ const currentSession = getCurrentSession();
245
+
246
+ // Sync custom ordering with current session list
247
+ sessionOrder.sync(allMuxSessions.map((s) => s.name));
248
+ if (currentSession) {
249
+ sessionOrder.show(currentSession);
250
+ }
251
+
252
+ // Apply custom ordering
253
+ const orderedNames = sessionOrder.apply(allMuxSessions.map((s) => s.name));
254
+ const sessionByName = new Map(allMuxSessions.map((s) => [s.name, s]));
255
+ const orderedMuxSessions = orderedNames.map((n) => sessionByName.get(n)!);
256
+
257
+ // Batch pane counts per provider (uses BatchCapable type guard)
258
+ const paneCountMaps = new Map<MuxProvider, Map<string, number>>();
259
+ for (const p of allProviders) {
260
+ if (isBatchCapable(p)) {
261
+ paneCountMaps.set(p, p.getAllPaneCounts());
262
+ }
263
+ }
264
+
265
+ const sessions: SessionData[] = orderedMuxSessions.map(
266
+ ({ name, createdAt, windows, dir, provider }) => {
267
+ sessionProviders.set(name, provider);
268
+ const git = getGitInfo(dir);
269
+ const providerPaneCounts = paneCountMaps.get(provider);
270
+ const panes = providerPaneCounts?.get(name) ?? provider.getPaneCount(name);
271
+
272
+ let uptime = "";
273
+ const diff = Math.floor(Date.now() / 1000) - createdAt;
274
+ if (!Number.isNaN(diff) && diff >= 0) {
275
+ const days = Math.floor(diff / 86400);
276
+ const hours = Math.floor((diff % 86400) / 3600);
277
+ const mins = Math.floor((diff % 3600) / 60);
278
+ if (days > 0) uptime = `${days}d${hours}h`;
279
+ else if (hours > 0) uptime = `${hours}h${mins}m`;
280
+ else uptime = `${mins}m`;
281
+ }
282
+
283
+ return {
284
+ name,
285
+ createdAt,
286
+ dir,
287
+ branch: git.branch,
288
+ dirty: git.dirty,
289
+ isWorktree: git.isWorktree,
290
+ filesChanged: git.filesChanged,
291
+ linesAdded: git.linesAdded,
292
+ linesRemoved: git.linesRemoved,
293
+ commitsDelta: git.commitsDelta,
294
+ unseen: tracker.isUnseen(name),
295
+ panes,
296
+ ports: getSessionPorts(name),
297
+ windows,
298
+ uptime,
299
+ agentState: tracker.getState(name),
300
+ agents: mergeAgentsWithPanePresence(name, tracker.getAgents(name)),
301
+ eventTimestamps: tracker.getEventTimestamps(name),
302
+ metadata: metadataStore.get(name),
303
+ };
304
+ },
305
+ );
306
+
307
+ metadataStore.pruneSessions(new Set(sessions.map((s) => s.name)));
308
+
309
+ if (sessions.length === 0) {
310
+ focusedSession = null;
311
+ } else if (!focusedSession || !sessions.some((s) => s.name === focusedSession)) {
312
+ focusedSession = sessions.find((s) => s.name === currentSession)?.name ?? sessions[0]!.name;
313
+ }
314
+
315
+ return {
316
+ type: "state",
317
+ sessions,
318
+ focusedSession,
319
+ currentSession,
320
+ theme: currentTheme,
321
+ sidebarWidth,
322
+ ts: Date.now(),
323
+ };
324
+ }
325
+
326
+ let broadcastPending = false;
327
+
328
+ function broadcastState() {
329
+ if (broadcastPending) return;
330
+ broadcastPending = true;
331
+ queueMicrotask(() => {
332
+ broadcastPending = false;
333
+ broadcastStateImmediate();
334
+ });
335
+ }
336
+
337
+ function broadcastStateImmediate() {
338
+ invalidateCurrentSessionCache();
339
+ tracker.pruneStuck(STUCK_RUNNING_TIMEOUT_MS);
340
+ tracker.pruneTerminal();
341
+ lastState = computeState();
342
+ syncGitWatchers(lastState.sessions, broadcastState);
343
+ const msg = JSON.stringify(lastState);
344
+ server.publish("sidebar", msg);
345
+ }
346
+
347
+ // Lightweight current-session cache — avoids a tmux subprocess per focus update
348
+ let cachedCurrentSession: string | null = null;
349
+ let cachedCurrentSessionTs = 0;
350
+ const CURRENT_SESSION_CACHE_TTL = 500; // ms — short TTL, just enough to coalesce rapid switches
351
+
352
+ function getCachedCurrentSession(): string | null {
353
+ const now = Date.now();
354
+ if (now - cachedCurrentSessionTs < CURRENT_SESSION_CACHE_TTL) return cachedCurrentSession;
355
+ cachedCurrentSession = getCurrentSession();
356
+ cachedCurrentSessionTs = now;
357
+ return cachedCurrentSession;
358
+ }
359
+
360
+ function invalidateCurrentSessionCache(): void {
361
+ cachedCurrentSessionTs = 0;
362
+ }
363
+
364
+ function broadcastFocusOnly(sender?: any) {
365
+ if (!lastState) return;
366
+ const currentSession = getCachedCurrentSession();
367
+ lastState = { ...lastState, focusedSession, currentSession };
368
+ const msg: FocusUpdate = { type: "focus", focusedSession, currentSession };
369
+ const payload = JSON.stringify(msg);
370
+ if (sender) {
371
+ sender.publish("sidebar", payload);
372
+ } else {
373
+ server.publish("sidebar", payload);
374
+ }
375
+ }
376
+
377
+ function moveFocus(delta: -1 | 1, sender?: any) {
378
+ if (!lastState || lastState.sessions.length === 0) return;
379
+ const sessions = lastState.sessions;
380
+ const currentIdx = sessions.findIndex((s) => s.name === focusedSession);
381
+ const newIdx = Math.max(
382
+ 0,
383
+ Math.min(sessions.length - 1, (currentIdx === -1 ? 0 : currentIdx) + delta),
384
+ );
385
+ focusedSession = sessions[newIdx]!.name;
386
+ broadcastFocusOnly(sender);
387
+ }
388
+
389
+ function setFocus(name: string, sender?: any) {
390
+ if (lastState && lastState.sessions.some((s) => s.name === name)) {
391
+ focusedSession = name;
392
+ broadcastFocusOnly(sender);
393
+ }
394
+ }
395
+
396
+ function handleFocus(name: string): void {
397
+ focusedSession = name;
398
+ invalidateCurrentSessionCache();
399
+ // Rescan pane agents when session focus changes
400
+ refreshPaneAgents();
401
+ const hadUnseen = tracker.handleFocus(name);
402
+ if (hadUnseen && lastState) {
403
+ // Patch unseen flags in-place — avoids a full computeState with many subprocesses
404
+ const currentSession = getCachedCurrentSession();
405
+ const updatedSessions = lastState.sessions.map((s) => {
406
+ if (s.name !== name) return s;
407
+ return {
408
+ ...s,
409
+ unseen: false,
410
+ agents: s.agents.map((a) => ({ ...a, unseen: false })),
411
+ };
412
+ });
413
+ lastState = { ...lastState, sessions: updatedSessions, focusedSession, currentSession };
414
+ server.publish("sidebar", JSON.stringify(lastState));
415
+ } else if (hadUnseen) {
416
+ broadcastState();
417
+ } else {
418
+ broadcastFocusOnly();
419
+ }
420
+ }
421
+
422
+ function switchToVisibleIndex(index: number, clientTty?: string): void {
423
+ if (!lastState) {
424
+ broadcastState();
425
+ }
426
+
427
+ if (!lastState) return;
428
+
429
+ const idx = index - 1;
430
+ if (idx < 0 || idx >= lastState.sessions.length) return;
431
+
432
+ const name = lastState.sessions[idx]!.name;
433
+ const p = sessionProviders.get(name) ?? mux;
434
+ p.switchSession(name, clientTty);
435
+ }
436
+
437
+ // --- Sidebar management ---
438
+
439
+ function getProvidersWithSidebar() {
440
+ return allProviders.filter(isFullSidebarCapable);
441
+ }
442
+
443
+ /** Parse "clientTty|session|windowId" or legacy "session:windowId" context from POST body */
444
+ function parseContext(
445
+ body: string,
446
+ ): { clientTty?: string; session: string; windowId: string } | null {
447
+ const trimmed = body
448
+ .trim()
449
+ .replace(/^"+|"+$/g, "")
450
+ .replace(/^'+|'+$/g, "");
451
+
452
+ // New format: pipe-separated "clientTty|session|windowId"
453
+ const pipeParts = trimmed.split("|");
454
+ if (pipeParts.length === 3 && pipeParts[1] && pipeParts[2]) {
455
+ const ctx = {
456
+ clientTty: pipeParts[0] || undefined,
457
+ session: pipeParts[1],
458
+ windowId: pipeParts[2],
459
+ };
460
+ if (ctx.clientTty && ctx.session) {
461
+ clientTtyBySession.set(ctx.session, ctx.clientTty);
462
+ }
463
+ return ctx;
464
+ }
465
+
466
+ // Legacy format: "session:windowId"
467
+ const colonIdx = trimmed.indexOf(":");
468
+ if (colonIdx < 1) return null;
469
+ const session = trimmed.slice(0, colonIdx);
470
+ const windowId = trimmed.slice(colonIdx + 1);
471
+ if (!session || !windowId) return null;
472
+ return { session, windowId };
473
+ }
474
+
475
+ function parseResizeContext(body: string): SidebarResizeContext | null {
476
+ const trimmed = body
477
+ .trim()
478
+ .replace(/^"+|"+$/g, "")
479
+ .replace(/^'+|'+$/g, "");
480
+ if (!trimmed) return null;
481
+
482
+ const [paneId, sessionName, windowId, widthRaw, windowWidthRaw] = trimmed.split("|");
483
+ if (!paneId) return null;
484
+
485
+ const width = Number.parseInt(widthRaw ?? "", 10);
486
+ const windowWidth = Number.parseInt(windowWidthRaw ?? "", 10);
487
+
488
+ return {
489
+ paneId,
490
+ sessionName: sessionName || undefined,
491
+ windowId: windowId || undefined,
492
+ width: Number.isNaN(width) ? undefined : width,
493
+ windowWidth: Number.isNaN(windowWidth) ? undefined : windowWidth,
494
+ };
495
+ }
496
+
497
+ // Short-lived cache for sidebar pane listings — avoid repeated tmux list-panes -a
498
+ let sidebarPaneCache: ReturnType<typeof listSidebarPanesByProviderUncached> | null = null;
499
+ let sidebarPaneCacheTs = 0;
500
+ const SIDEBAR_PANE_CACHE_TTL = 300; // ms
501
+
502
+ function listSidebarPanesByProviderUncached() {
503
+ return getProvidersWithSidebar().map((provider) => ({
504
+ provider,
505
+ panes: provider.listSidebarPanes(),
506
+ }));
507
+ }
508
+
509
+ function listSidebarPanesByProvider() {
510
+ const now = Date.now();
511
+ if (sidebarPaneCache && now - sidebarPaneCacheTs < SIDEBAR_PANE_CACHE_TTL)
512
+ return sidebarPaneCache;
513
+ sidebarPaneCache = listSidebarPanesByProviderUncached();
514
+ sidebarPaneCacheTs = now;
515
+ return sidebarPaneCache;
516
+ }
517
+
518
+ function invalidateSidebarPaneCache(): void {
519
+ sidebarPaneCache = null;
520
+ sidebarPaneCacheTs = 0;
521
+ }
522
+
523
+ const pendingSidebarSpawns = new Set<string>();
524
+ const suppressedSidebarResizeAcks = new Map<string, SidebarResizeSuppression>();
525
+ let sidebarSnapshots = new Map<string, { width?: number; windowWidth?: number }>();
526
+ let pendingSidebarResize: ReturnType<typeof setTimeout> | null = null;
527
+
528
+ function scheduleSidebarResize(ctx?: SidebarResizeContext): void {
529
+ resizeSidebars(ctx);
530
+ if (pendingSidebarResize) clearTimeout(pendingSidebarResize);
531
+ // tmux can finish layout changes slightly after the pane appears.
532
+ pendingSidebarResize = setTimeout(() => {
533
+ pendingSidebarResize = null;
534
+ resizeSidebars();
535
+ }, 120);
536
+ }
537
+
538
+ function toggleSidebar(ctx?: { session: string; windowId: string }): void {
539
+ const providers = getProvidersWithSidebar();
540
+ if (providers.length === 0) {
541
+ log("toggle", "SKIP — no providers with sidebar methods");
542
+ return;
543
+ }
544
+
545
+ invalidateSidebarPaneCache();
546
+ if (sidebarVisible) {
547
+ for (const p of providers) {
548
+ const panes = p.listSidebarPanes();
549
+ log("toggle", "OFF — hiding panes", { provider: p.name, count: panes.length });
550
+ for (const pane of panes) {
551
+ p.hideSidebar(pane.paneId);
552
+ }
553
+ }
554
+ sidebarVisible = false;
555
+ } else {
556
+ sidebarVisible = true;
557
+ for (const p of providers) {
558
+ const allWindows = p.listActiveWindows();
559
+ log("toggle", "ON — spawning in active windows", {
560
+ provider: p.name,
561
+ count: allWindows.length,
562
+ });
563
+ for (const w of allWindows) {
564
+ ensureSidebarInWindow(p, { session: w.sessionName, windowId: w.id });
565
+ }
566
+ }
567
+ scheduleSidebarResize();
568
+ server.publish("sidebar", JSON.stringify({ type: "re-identify" }));
569
+ }
570
+ log("toggle", "done", { sidebarVisible });
571
+ }
572
+
573
+ function ensureSidebarInWindow(
574
+ provider?: ReturnType<typeof getProvidersWithSidebar>[number],
575
+ ctx?: { session: string; windowId: string },
576
+ ): void {
577
+ // If no specific provider, try to find one for the session
578
+ const p =
579
+ provider ??
580
+ (() => {
581
+ const providers = getProvidersWithSidebar();
582
+ if (ctx?.session) {
583
+ const sessionProvider = sessionProviders.get(ctx.session);
584
+ return providers.find((pp) => pp === sessionProvider) ?? providers[0];
585
+ }
586
+ return providers[0];
587
+ })();
588
+ if (!p || !sidebarVisible) {
589
+ log("ensure", "SKIP", { hasProvider: !!p, sidebarVisible });
590
+ return;
591
+ }
592
+
593
+ const curSession = ctx?.session ?? getCurrentSession();
594
+ if (!curSession) {
595
+ log("ensure", "SKIP — no current session");
596
+ return;
597
+ }
598
+
599
+ const windowId = ctx?.windowId ?? p.getCurrentWindowId();
600
+ if (!windowId) {
601
+ log("ensure", "SKIP — could not get window_id");
602
+ return;
603
+ }
604
+
605
+ const spawnKey = `${p.name}:${windowId}`;
606
+ if (pendingSidebarSpawns.has(spawnKey)) {
607
+ log("ensure", "SKIP — spawn already in progress", { curSession, windowId, provider: p.name });
608
+ return;
609
+ }
610
+
611
+ // Use cached pane listing to avoid redundant tmux list-panes -a calls
612
+ const allPanesByProvider = listSidebarPanesByProvider();
613
+ const providerEntry = allPanesByProvider.find((e) => e.provider === p);
614
+ const existingPanes = providerEntry?.panes ?? [];
615
+ const hasInWindow = existingPanes.some((ep) => ep.windowId === windowId);
616
+ log("ensure", "checking window", {
617
+ curSession,
618
+ windowId,
619
+ existingPanes: existingPanes.length,
620
+ hasInWindow,
621
+ paneIds: existingPanes.map((x) => `${x.paneId}@${x.windowId}`),
622
+ });
623
+
624
+ if (!hasInWindow) {
625
+ invalidateSidebarPaneCache();
626
+ pendingSidebarSpawns.add(spawnKey);
627
+ log("ensure", "SPAWNING sidebar", {
628
+ curSession,
629
+ windowId,
630
+ sidebarWidth,
631
+ sidebarPosition,
632
+ });
633
+ try {
634
+ const newPaneId = p.spawnSidebar(curSession, windowId, sidebarWidth, sidebarPosition);
635
+ log("ensure", "spawn result", { newPaneId });
636
+ // Do NOT refocus the main pane here — the TUI handles it.
637
+ // For fresh spawns, the TUI refocuses after capability detection.
638
+ // For stash restores, the TUI refocuses after restoreTerminalModes
639
+ // responses settle. Refocusing immediately from the server causes
640
+ // capability query responses to leak as garbage escape sequences.
641
+ } finally {
642
+ pendingSidebarSpawns.delete(spawnKey);
643
+ }
644
+ // Only schedule resize when we actually spawned — layout changed
645
+ scheduleSidebarResize();
646
+ }
647
+ // When sidebar already exists, no layout change — skip resize
648
+ }
649
+
650
+ // Debounced ensure-sidebar — collapses rapid hook-fired calls during fast
651
+ // session switching into a single check after switching settles.
652
+ let ensureSidebarTimer: ReturnType<typeof setTimeout> | null = null;
653
+ let ensureSidebarPendingCtx: { session: string; windowId: string } | undefined;
654
+
655
+ function debouncedEnsureSidebar(ctx?: { session: string; windowId: string }): void {
656
+ if (ctx) ensureSidebarPendingCtx = ctx;
657
+ if (ensureSidebarTimer) clearTimeout(ensureSidebarTimer);
658
+ ensureSidebarTimer = setTimeout(() => {
659
+ ensureSidebarTimer = null;
660
+ const nextCtx = ensureSidebarPendingCtx;
661
+ ensureSidebarPendingCtx = undefined;
662
+ ensureSidebarInWindow(undefined, nextCtx);
663
+ }, 150);
664
+ }
665
+
666
+ function quitAll(): void {
667
+ log("quit", "killing all sidebar panes");
668
+ for (const p of getProvidersWithSidebar()) {
669
+ const panes = p.listSidebarPanes();
670
+ log("quit", "found panes to kill", { provider: p.name, count: panes.length });
671
+ for (const pane of panes) {
672
+ p.killSidebarPane(pane.paneId);
673
+ }
674
+ }
675
+ // Provider-specific cleanup (uses type guard)
676
+ for (const p of getProvidersWithSidebar()) {
677
+ p.cleanupSidebar();
678
+ }
679
+ server.publish("sidebar", JSON.stringify({ type: "quit" }));
680
+ sidebarVisible = false;
681
+ cleanup();
682
+ process.exit(0);
683
+ }
684
+
685
+ // --- Sidebar resize enforcement ---
686
+
687
+ function resizeSidebars(ctx?: SidebarResizeContext) {
688
+ if (ctx?.paneId) invalidateSidebarPaneCache();
689
+ const panesByProvider = listSidebarPanesByProvider();
690
+ const allPanes = panesByProvider.flatMap(({ panes }) => panes);
691
+
692
+ if (allPanes.length === 0) {
693
+ sidebarSnapshots = new Map();
694
+ return;
695
+ }
696
+
697
+ const nextSidebarWidth = resolveSidebarWidthFromResizeContext({
698
+ ctx,
699
+ panes: allPanes,
700
+ previousByWindow: sidebarSnapshots,
701
+ suppressedByPane: suppressedSidebarResizeAcks,
702
+ });
703
+
704
+ if (nextSidebarWidth != null && nextSidebarWidth !== sidebarWidth) {
705
+ sidebarWidth = nextSidebarWidth;
706
+ saveConfig({ sidebarWidth });
707
+ log("resize", "adopted sidebar width from pane resize", {
708
+ paneId: ctx?.paneId ?? null,
709
+ sessionName: ctx?.sessionName ?? null,
710
+ windowId: ctx?.windowId ?? null,
711
+ sidebarWidth,
712
+ });
713
+ broadcastState();
714
+ }
715
+
716
+ const now = Date.now();
717
+ for (const { provider, panes } of panesByProvider) {
718
+ log("resize", "enforcing width on all panes", {
719
+ provider: provider.name,
720
+ sidebarWidth,
721
+ count: panes.length,
722
+ triggerPaneId: ctx?.paneId ?? null,
723
+ });
724
+ for (const pane of panes) {
725
+ if (pane.width === sidebarWidth) continue;
726
+ suppressedSidebarResizeAcks.set(pane.paneId, {
727
+ width: sidebarWidth,
728
+ expiresAt: now + 1_000,
729
+ });
730
+ provider.resizeSidebarPane(pane.paneId, sidebarWidth);
731
+ }
732
+ }
733
+
734
+ // After resizing, invalidate the sidebar pane cache since widths changed,
735
+ // and use the already-fetched list for the snapshot (avoid another tmux call).
736
+ if (panesByProvider.some(({ panes }) => panes.some((pane) => pane.width !== sidebarWidth))) {
737
+ invalidateSidebarPaneCache();
738
+ }
739
+ sidebarSnapshots = snapshotSidebarWindows(allPanes);
740
+ }
741
+
742
+ // --- Focus agent pane (click-to-focus from TUI) ---
743
+
744
+ /** Walk up to 3 levels of child processes looking for a command matching any pattern */
745
+ function matchProcessTree(pid: string, patterns: string[], depth = 0): boolean {
746
+ if (depth > 2) return false;
747
+ const children = shell(["pgrep", "-P", pid]);
748
+ if (!children) return false;
749
+ for (const childPid of children.split("\n")) {
750
+ const trimmed = childPid.trim();
751
+ if (!trimmed) continue;
752
+ const childCmd = shell(["ps", "-p", trimmed, "-o", "comm="]);
753
+ if (childCmd && patterns.some((pat) => childCmd.toLowerCase().includes(pat))) return true;
754
+ if (matchProcessTree(trimmed, patterns, depth + 1)) return true;
755
+ }
756
+ return false;
757
+ }
758
+
759
+ const AGENT_TITLE_PATTERNS: Record<string, string[]> = {
760
+ amp: ["amp"],
761
+ "claude-code": ["claude"],
762
+ codex: ["codex"],
763
+ opencode: ["opencode"],
764
+ };
765
+
766
+ const PANE_HIGHLIGHT_BORDER = "fg=#fab387,bold";
767
+ const PANE_HIGHLIGHT_MS = 300;
768
+ const pendingHighlightResets = new Map<string, ReturnType<typeof setTimeout>>();
769
+
770
+ /** Walk child processes (up to 3 levels) to find a process matching `name`, returning its PID. */
771
+ function findChildPid(pid: string, name: string, depth = 0): string | undefined {
772
+ if (depth > 2) return undefined;
773
+ const children = shell(["pgrep", "-P", pid]);
774
+ if (!children) return undefined;
775
+ for (const childPid of children.split("\n")) {
776
+ const trimmed = childPid.trim();
777
+ if (!trimmed) continue;
778
+ const childCmd = shell(["ps", "-p", trimmed, "-o", "comm="]);
779
+ if (childCmd?.trim().toLowerCase().includes(name)) return trimmed;
780
+ const found = findChildPid(trimmed, name, depth + 1);
781
+ if (found) return found;
782
+ }
783
+ return undefined;
784
+ }
785
+
786
+ type PaneEntry = { id: string; pid: string; cmd: string; title: string };
787
+
788
+ /** Claude Code: ~/.claude/sessions/<pid>.json → sessionId */
789
+ function resolveClaudeCodePane(panes: PaneEntry[], threadId: string): string | undefined {
790
+ const sessionsDir = join(homedir(), ".claude", "sessions");
791
+ for (const pane of panes) {
792
+ const agentPid = findChildPid(pane.pid, "claude");
793
+ if (!agentPid) continue;
794
+ try {
795
+ const data = JSON.parse(readFileSync(join(sessionsDir, `${agentPid}.json`), "utf-8"));
796
+ if (data.sessionId === threadId) return pane.id;
797
+ } catch {}
798
+ }
799
+ return undefined;
800
+ }
801
+
802
+ /** Codex: logs_1.sqlite process_uuid='pid:<PID>:*' → thread_id */
803
+ function resolveCodexPane(panes: PaneEntry[], threadId: string): string | undefined {
804
+ const dbPath = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "logs_1.sqlite");
805
+ let db: any;
806
+ try {
807
+ const { Database } = require("bun:sqlite");
808
+ db = new Database(dbPath, { readonly: true });
809
+ } catch {
810
+ return undefined;
811
+ }
812
+
813
+ try {
814
+ for (const pane of panes) {
815
+ const agentPid = findChildPid(pane.pid, "codex");
816
+ if (!agentPid) continue;
817
+ const row = db
818
+ .query(
819
+ `SELECT thread_id FROM logs WHERE process_uuid LIKE ? AND thread_id IS NOT NULL ORDER BY ts DESC LIMIT 1`,
820
+ )
821
+ .get(`pid:${agentPid}:%`);
822
+ if (row?.thread_id === threadId) return pane.id;
823
+ }
824
+ } finally {
825
+ try {
826
+ db.close();
827
+ } catch {}
828
+ }
829
+ return undefined;
830
+ }
831
+
832
+ /** OpenCode: lsof → log file → grep session ID */
833
+ function resolveOpenCodePane(panes: PaneEntry[], threadId: string): string | undefined {
834
+ for (const pane of panes) {
835
+ const agentPid = findChildPid(pane.pid, "opencode");
836
+ if (!agentPid) continue;
837
+ const lsofOut = shell(["lsof", "-p", agentPid]);
838
+ if (!lsofOut) continue;
839
+ // Find the log file path from open file descriptors
840
+ const logLine = lsofOut
841
+ .split("\n")
842
+ .find((l) => l.includes("/opencode/log/") && l.endsWith(".log"));
843
+ if (!logLine) continue;
844
+ // Extract absolute path — lsof NAME column starts at the last recognized path
845
+ const pathMatch = logLine.match(/\s(\/\S+\.log)$/);
846
+ if (!pathMatch) continue;
847
+ try {
848
+ const logText = readFileSync(pathMatch[1], "utf-8");
849
+ const match = logText.match(/ses_[A-Za-z0-9]+/);
850
+ if (match?.[0] === threadId) return pane.id;
851
+ } catch {}
852
+ }
853
+ return undefined;
854
+ }
855
+
856
+ /** Resolve a tmux pane ID for an agent using all available resolution strategies. */
857
+ function resolveAgentPaneId(
858
+ sessionName: string,
859
+ agentName: string,
860
+ threadId?: string,
861
+ threadName?: string,
862
+ ): string | undefined {
863
+ const p = sessionProviders.get(sessionName) ?? mux;
864
+ if (p.name !== "tmux") return undefined;
865
+
866
+ const patterns = AGENT_TITLE_PATTERNS[agentName];
867
+ if (!patterns) return undefined;
868
+
869
+ const raw = shell([
870
+ "tmux",
871
+ "list-panes",
872
+ "-t",
873
+ sessionName,
874
+ "-F",
875
+ "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_title}",
876
+ ]);
877
+ if (!raw) return undefined;
878
+
879
+ const panes = raw.split("\n").map((line) => {
880
+ const idx1 = line.indexOf("|");
881
+ const idx2 = line.indexOf("|", idx1 + 1);
882
+ const idx3 = line.indexOf("|", idx2 + 1);
883
+ return {
884
+ id: line.slice(0, idx1),
885
+ pid: line.slice(idx1 + 1, idx2),
886
+ cmd: line.slice(idx2 + 1, idx3),
887
+ title: line.slice(idx3 + 1),
888
+ };
889
+ });
890
+
891
+ const sidebarPaneIds = new Set<string>();
892
+ for (const { panes: sbPanes } of listSidebarPanesByProvider()) {
893
+ for (const sb of sbPanes) sidebarPaneIds.add(sb.paneId);
894
+ }
895
+ const nonSidebar = panes.filter((p) => !sidebarPaneIds.has(p.id));
896
+
897
+ let targetPaneId: string | undefined;
898
+
899
+ if (agentName === "claude-code" && threadId) {
900
+ targetPaneId = resolveClaudeCodePane(nonSidebar, threadId);
901
+ }
902
+ if (!targetPaneId && agentName === "amp" && threadName) {
903
+ targetPaneId = nonSidebar.find(
904
+ (p) => p.title.toLowerCase().startsWith("amp - ") && p.title.includes(threadName),
905
+ )?.id;
906
+ }
907
+ if (!targetPaneId && agentName === "codex" && threadId) {
908
+ targetPaneId = resolveCodexPane(nonSidebar, threadId);
909
+ }
910
+ if (!targetPaneId && agentName === "opencode" && threadId) {
911
+ targetPaneId = resolveOpenCodePane(nonSidebar, threadId);
912
+ }
913
+ if (!targetPaneId) {
914
+ targetPaneId = nonSidebar.find((p) =>
915
+ patterns.some((pat) => p.title.toLowerCase().includes(pat)),
916
+ )?.id;
917
+ }
918
+ if (!targetPaneId) {
919
+ for (const pane of nonSidebar) {
920
+ if (matchProcessTree(pane.pid, patterns)) {
921
+ targetPaneId = pane.id;
922
+ break;
923
+ }
924
+ }
925
+ }
926
+ return targetPaneId;
927
+ }
928
+
929
+ function focusAgentPane(
930
+ sessionName: string,
931
+ agentName: string,
932
+ threadId?: string,
933
+ threadName?: string,
934
+ ): void {
935
+ log("focus-agent-pane", "received", { sessionName, agentName, threadId, threadName });
936
+ const targetPaneId = resolveAgentPaneId(sessionName, agentName, threadId, threadName);
937
+ if (!targetPaneId) return;
938
+
939
+ log("focus-agent-pane", "focusing", { sessionName, agentName, paneId: targetPaneId });
940
+ shell(["tmux", "select-pane", "-t", targetPaneId]);
941
+
942
+ const existing = pendingHighlightResets.get(targetPaneId);
943
+ if (existing) clearTimeout(existing);
944
+
945
+ shell([
946
+ "tmux",
947
+ "set-option",
948
+ "-p",
949
+ "-t",
950
+ targetPaneId,
951
+ "pane-active-border-style",
952
+ PANE_HIGHLIGHT_BORDER,
953
+ ]);
954
+ shell(["tmux", "select-pane", "-t", targetPaneId, "-P", "bg=#2a2a4a"]);
955
+ pendingHighlightResets.set(
956
+ targetPaneId,
957
+ setTimeout(() => {
958
+ shell(["tmux", "set-option", "-p", "-t", targetPaneId, "-u", "pane-active-border-style"]);
959
+ shell(["tmux", "select-pane", "-t", targetPaneId, "-P", ""]);
960
+ pendingHighlightResets.delete(targetPaneId);
961
+ }, PANE_HIGHLIGHT_MS),
962
+ );
963
+ }
964
+
965
+ function killAgentPane(
966
+ sessionName: string,
967
+ agentName: string,
968
+ threadId?: string,
969
+ threadName?: string,
970
+ ): void {
971
+ log("kill-agent-pane", "received", { sessionName, agentName, threadId, threadName });
972
+ const targetPaneId = resolveAgentPaneId(sessionName, agentName, threadId, threadName);
973
+ if (!targetPaneId) return;
974
+
975
+ log("kill-agent-pane", "killing", { sessionName, agentName, paneId: targetPaneId });
976
+ shell(["tmux", "kill-pane", "-t", targetPaneId]);
977
+ }
978
+
979
+ // --- Pane agent scanning (detect agents running in current session panes) ---
980
+
981
+ interface PaneAgentPresence {
982
+ agent: string;
983
+ session: string;
984
+ paneId: string;
985
+ threadId?: string;
986
+ threadName?: string;
987
+ status?: import("../contracts/agent").AgentStatus;
988
+ lastSeenTs: number;
989
+ }
990
+
991
+ // Pane agent presence per session: sessionName → Map<instanceKey, PaneAgentPresence>
992
+ let paneAgentsBySession = new Map<string, Map<string, PaneAgentPresence>>();
993
+
994
+ /** Build parent→children map from a single ps snapshot (avoids per-pane pgrep calls). */
995
+ function buildProcessTree(): { childrenOf: Map<number, number[]>; commOf: Map<number, string> } {
996
+ const childrenOf = new Map<number, number[]>();
997
+ const commOf = new Map<number, string>();
998
+ const psResult = Bun.spawnSync(["ps", "-eo", "pid=,ppid=,comm="], {
999
+ stdout: "pipe",
1000
+ stderr: "pipe",
1001
+ });
1002
+ for (const line of psResult.stdout.toString().trim().split("\n")) {
1003
+ const parts = line.trim().split(/\s+/);
1004
+ if (parts.length < 3) continue;
1005
+ const pid = Number.parseInt(parts[0], 10);
1006
+ const ppid = Number.parseInt(parts[1], 10);
1007
+ const comm = parts.slice(2).join(" ").toLowerCase();
1008
+ if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
1009
+ commOf.set(pid, comm);
1010
+ let arr = childrenOf.get(ppid);
1011
+ if (!arr) {
1012
+ arr = [];
1013
+ childrenOf.set(ppid, arr);
1014
+ }
1015
+ arr.push(pid);
1016
+ }
1017
+ return { childrenOf, commOf };
1018
+ }
1019
+
1020
+ /** Walk up to 3 levels of child processes using a pre-built process tree. */
1021
+ function matchProcessTreeFast(
1022
+ pid: number,
1023
+ patterns: string[],
1024
+ tree: ReturnType<typeof buildProcessTree>,
1025
+ depth = 0,
1026
+ ): boolean {
1027
+ if (depth > 2) return false;
1028
+ const children = tree.childrenOf.get(pid);
1029
+ if (!children) return false;
1030
+ for (const childPid of children) {
1031
+ const comm = tree.commOf.get(childPid);
1032
+ if (comm && patterns.some((pat) => comm.includes(pat))) return true;
1033
+ if (matchProcessTreeFast(childPid, patterns, tree, depth + 1)) return true;
1034
+ }
1035
+ return false;
1036
+ }
1037
+
1038
+ /** Find child PID matching a name pattern using pre-built process tree. */
1039
+ function findChildPidFast(
1040
+ pid: number,
1041
+ name: string,
1042
+ tree: ReturnType<typeof buildProcessTree>,
1043
+ depth = 0,
1044
+ ): number | undefined {
1045
+ if (depth > 2) return undefined;
1046
+ const children = tree.childrenOf.get(pid);
1047
+ if (!children) return undefined;
1048
+ for (const childPid of children) {
1049
+ const comm = tree.commOf.get(childPid);
1050
+ if (comm?.includes(name)) return childPid;
1051
+ const found = findChildPidFast(childPid, name, tree, depth + 1);
1052
+ if (found) return found;
1053
+ }
1054
+ return undefined;
1055
+ }
1056
+
1057
+ /** Resolve threadId/threadName for an amp pane from its title. */
1058
+ function resolveAmpPaneInfo(title: string): { threadId?: string; threadName?: string } {
1059
+ // Amp pane title format: "amp - <threadName> - <dir>"
1060
+ if (!title.toLowerCase().startsWith("amp - ")) return {};
1061
+ const rest = title.slice(6);
1062
+ const dashIdx = rest.lastIndexOf(" - ");
1063
+ const threadName = dashIdx > 0 ? rest.slice(0, dashIdx) : rest;
1064
+ return { threadName: threadName || undefined };
1065
+ }
1066
+
1067
+ /** Resolve threadId/threadName/status for a Claude Code pane via ~/.claude/sessions/<pid>.json + journal. */
1068
+ function resolveClaudeCodePaneInfo(
1069
+ panePid: number,
1070
+ tree: ReturnType<typeof buildProcessTree>,
1071
+ ): { threadId?: string; threadName?: string; status?: import("../contracts/agent").AgentStatus } {
1072
+ const agentPid = findChildPidFast(panePid, "claude", tree);
1073
+ if (!agentPid) return {};
1074
+ const sessionsDir = join(homedir(), ".claude", "sessions");
1075
+ try {
1076
+ const data = JSON.parse(readFileSync(join(sessionsDir, `${agentPid}.json`), "utf-8"));
1077
+ const threadId: string | undefined = data.sessionId;
1078
+ if (!threadId) return {};
1079
+ // Try to get thread name and status from the journal
1080
+ const journalInfo = resolveClaudeCodeJournalInfo(threadId);
1081
+ return { threadId, ...journalInfo };
1082
+ } catch {
1083
+ return {};
1084
+ }
1085
+ }
1086
+
1087
+ /** Read the JSONL journal to extract thread name and current status. */
1088
+ function resolveClaudeCodeJournalInfo(threadId: string): {
1089
+ threadName?: string;
1090
+ status?: import("../contracts/agent").AgentStatus;
1091
+ } {
1092
+ const projectsDir = join(homedir(), ".claude", "projects");
1093
+ try {
1094
+ const dirs = require("node:fs").readdirSync(projectsDir) as string[];
1095
+ for (const dir of dirs) {
1096
+ const filePath = join(projectsDir, dir, `${threadId}.jsonl`);
1097
+ try {
1098
+ const text = readFileSync(filePath, "utf-8");
1099
+ const lines = text.split("\n").filter(Boolean);
1100
+ let threadName: string | undefined;
1101
+ let lastStatus: import("../contracts/agent").AgentStatus = "idle";
1102
+
1103
+ for (const line of lines) {
1104
+ try {
1105
+ const entry = JSON.parse(line);
1106
+ const msg = entry.message;
1107
+ if (!msg?.role) continue;
1108
+
1109
+ // Extract thread name from first user message
1110
+ if (!threadName && msg.role === "user") {
1111
+ const content = msg.content;
1112
+ let t: string | undefined;
1113
+ if (typeof content === "string") t = content;
1114
+ else if (Array.isArray(content))
1115
+ t = content.find((c: any) => c.type === "text" && c.text)?.text;
1116
+ if (t && !t.startsWith("<") && !t.startsWith("{")) threadName = t.slice(0, 80);
1117
+ }
1118
+
1119
+ // Determine status from last entry (same logic as ClaudeCodeAgentWatcher)
1120
+ if (msg.role === "assistant") {
1121
+ const items = Array.isArray(msg.content) ? msg.content : [];
1122
+ lastStatus = items.some((c: any) => c.type === "tool_use") ? "running" : "done";
1123
+ } else if (msg.role === "user") {
1124
+ lastStatus = "running";
1125
+ }
1126
+ } catch {
1127
+ continue;
1128
+ }
1129
+ }
1130
+
1131
+ // If status is "running" but journal hasn't been written to recently,
1132
+ // the Claude process likely exited — downgrade to "idle".
1133
+ if (lastStatus === "running") {
1134
+ try {
1135
+ const mtime = statSync(filePath).mtimeMs;
1136
+ if (Date.now() - mtime > 10_000) lastStatus = "idle";
1137
+ } catch {}
1138
+ }
1139
+
1140
+ return { threadName, status: lastStatus };
1141
+ } catch {
1142
+ continue;
1143
+ }
1144
+ }
1145
+ } catch {}
1146
+ return {};
1147
+ }
1148
+
1149
+ /** Resolve threadId for a Codex pane via logs_1.sqlite. */
1150
+ function resolveCodexPaneInfo(
1151
+ panePid: number,
1152
+ tree: ReturnType<typeof buildProcessTree>,
1153
+ ): { threadId?: string; threadName?: string } {
1154
+ const agentPid = findChildPidFast(panePid, "codex", tree);
1155
+ if (!agentPid) return {};
1156
+ const dbPath = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "logs_1.sqlite");
1157
+ let db: any;
1158
+ try {
1159
+ const { Database } = require("bun:sqlite");
1160
+ db = new Database(dbPath, { readonly: true });
1161
+ } catch {
1162
+ return {};
1163
+ }
1164
+ try {
1165
+ const row = db
1166
+ .query(
1167
+ `SELECT thread_id FROM logs WHERE process_uuid LIKE ? AND thread_id IS NOT NULL ORDER BY ts DESC LIMIT 1`,
1168
+ )
1169
+ .get(`pid:${agentPid}:%`);
1170
+ if (row?.thread_id) return { threadId: row.thread_id };
1171
+ } catch {
1172
+ } finally {
1173
+ try {
1174
+ db.close();
1175
+ } catch {}
1176
+ }
1177
+ return {};
1178
+ }
1179
+
1180
+ /** Scan all panes across all tmux sessions and identify running agents.
1181
+ * Uses a single `tmux list-panes -a` call for efficiency. */
1182
+ function scanAllTmuxPaneAgents(): Map<string, Map<string, PaneAgentPresence>> {
1183
+ const result = new Map<string, Map<string, PaneAgentPresence>>();
1184
+
1185
+ const raw = shell([
1186
+ "tmux",
1187
+ "list-panes",
1188
+ "-a",
1189
+ "-F",
1190
+ "#{session_name}|#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_title}",
1191
+ ]);
1192
+ if (!raw) return result;
1193
+
1194
+ const panes = raw
1195
+ .split("\n")
1196
+ .filter(Boolean)
1197
+ .map((line) => {
1198
+ const idx1 = line.indexOf("|");
1199
+ const idx2 = line.indexOf("|", idx1 + 1);
1200
+ const idx3 = line.indexOf("|", idx2 + 1);
1201
+ const idx4 = line.indexOf("|", idx3 + 1);
1202
+ return {
1203
+ session: line.slice(0, idx1),
1204
+ id: line.slice(idx1 + 1, idx2),
1205
+ pid: Number.parseInt(line.slice(idx2 + 1, idx3), 10),
1206
+ cmd: line.slice(idx3 + 1, idx4),
1207
+ title: line.slice(idx4 + 1),
1208
+ };
1209
+ });
1210
+
1211
+ // Exclude sidebar panes
1212
+ const sidebarPaneIds = new Set<string>();
1213
+ for (const { panes: sbPanes } of listSidebarPanesByProvider()) {
1214
+ for (const sb of sbPanes) sidebarPaneIds.add(sb.paneId);
1215
+ }
1216
+
1217
+ const nonSidebar = panes.filter((p) => !sidebarPaneIds.has(p.id));
1218
+ if (nonSidebar.length === 0) return result;
1219
+
1220
+ // Build process tree once for all panes
1221
+ const tree = buildProcessTree();
1222
+ const now = Date.now();
1223
+
1224
+ for (const pane of nonSidebar) {
1225
+ for (const [agentName, patterns] of Object.entries(AGENT_TITLE_PATTERNS)) {
1226
+ // Only use process tree matching — title matching produces false positives
1227
+ // (e.g. an Amp thread named "Detect Claude session names" matches "claude")
1228
+ if (!matchProcessTreeFast(pane.pid, patterns, tree)) continue;
1229
+
1230
+ let threadId: string | undefined;
1231
+ let threadName: string | undefined;
1232
+ let status: import("../contracts/agent").AgentStatus | undefined;
1233
+
1234
+ // Resolve thread info per agent type
1235
+ if (agentName === "amp") {
1236
+ const info = resolveAmpPaneInfo(pane.title);
1237
+ threadName = info.threadName;
1238
+ } else if (agentName === "claude-code") {
1239
+ const info = resolveClaudeCodePaneInfo(pane.pid, tree);
1240
+ threadId = info.threadId;
1241
+ threadName = info.threadName;
1242
+ status = info.status;
1243
+ } else if (agentName === "codex") {
1244
+ const info = resolveCodexPaneInfo(pane.pid, tree);
1245
+ threadId = info.threadId;
1246
+ }
1247
+
1248
+ const key = `${agentName}:pane:${pane.id}`;
1249
+ let sessionAgents = result.get(pane.session);
1250
+ if (!sessionAgents) {
1251
+ sessionAgents = new Map();
1252
+ result.set(pane.session, sessionAgents);
1253
+ }
1254
+ sessionAgents.set(key, {
1255
+ agent: agentName,
1256
+ session: pane.session,
1257
+ paneId: pane.id,
1258
+ threadId,
1259
+ threadName,
1260
+ status,
1261
+ lastSeenTs: now,
1262
+ });
1263
+ }
1264
+ }
1265
+
1266
+ return result;
1267
+ }
1268
+
1269
+ /** Refresh pane agent cache for all tmux sessions. */
1270
+ function refreshPaneAgents(): void {
1271
+ // Check if any provider is tmux
1272
+ const hasTmux = allProviders.some((p) => p.name === "tmux");
1273
+ if (!hasTmux) {
1274
+ if (paneAgentsBySession.size > 0) {
1275
+ paneAgentsBySession.clear();
1276
+ tracker.setPinnedInstancesMulti(new Map());
1277
+ broadcastState();
1278
+ }
1279
+ return;
1280
+ }
1281
+
1282
+ const nextBySession = scanAllTmuxPaneAgents();
1283
+ const allPinnedKeys = new Map<string, string[]>();
1284
+ for (const [session, agents] of nextBySession) {
1285
+ allPinnedKeys.set(session, [...agents.keys()]);
1286
+ }
1287
+
1288
+ // Check if anything changed
1289
+ let changed = paneAgentsBySession.size !== nextBySession.size;
1290
+ if (!changed) {
1291
+ for (const [session, agents] of nextBySession) {
1292
+ const prev = paneAgentsBySession.get(session);
1293
+ if (!prev || prev.size !== agents.size) {
1294
+ changed = true;
1295
+ break;
1296
+ }
1297
+ for (const key of agents.keys()) {
1298
+ if (!prev.has(key)) {
1299
+ changed = true;
1300
+ break;
1301
+ }
1302
+ }
1303
+ if (changed) break;
1304
+ }
1305
+ }
1306
+
1307
+ paneAgentsBySession = nextBySession;
1308
+
1309
+ // Update tracker pinning for all sessions
1310
+ tracker.setPinnedInstancesMulti(allPinnedKeys);
1311
+
1312
+ if (changed) broadcastState();
1313
+ }
1314
+
1315
+ // --- Pane agent polling (detect agents in current session every 3s) ---
1316
+
1317
+ const PANE_SCAN_INTERVAL_MS = 3_000;
1318
+ let paneScanTimer: ReturnType<typeof setInterval> | null = null;
1319
+
1320
+ function startPaneScan() {
1321
+ paneScanTimer = setInterval(() => {
1322
+ if (clientCount === 0) return;
1323
+ refreshPaneAgents();
1324
+ }, PANE_SCAN_INTERVAL_MS);
1325
+ }
1326
+
1327
+ function handleCommand(cmd: ClientCommand, ws: any) {
1328
+ switch (cmd.type) {
1329
+ case "identify":
1330
+ clientTtys.set(ws, cmd.clientTty);
1331
+ break;
1332
+ case "switch-session": {
1333
+ // Resolve TTY: hook-derived (authoritative) > client-provided > stored
1334
+ const clientSess = clientSessionNames.get(ws);
1335
+ const tty =
1336
+ (clientSess ? clientTtyBySession.get(clientSess) : undefined) ??
1337
+ cmd.clientTty ??
1338
+ clientTtys.get(ws);
1339
+ log("switch-session", "switching", { target: cmd.name, tty, clientSess });
1340
+ const p = sessionProviders.get(cmd.name) ?? mux;
1341
+
1342
+ p.switchSession(cmd.name, tty);
1343
+
1344
+ // Optimistic server-side focus update — so other TUI instances see the
1345
+ // change immediately via broadcastFocusOnly, without waiting for the
1346
+ // tmux hook round-trip. The hook's /focus POST will reconcile if needed.
1347
+ focusedSession = cmd.name;
1348
+ cachedCurrentSession = cmd.name;
1349
+ cachedCurrentSessionTs = Date.now();
1350
+ const hadUnseen = tracker.handleFocus(cmd.name);
1351
+ if (hadUnseen) {
1352
+ broadcastState();
1353
+ } else {
1354
+ broadcastFocusOnly();
1355
+ }
1356
+
1357
+ break;
1358
+ }
1359
+ case "switch-index": {
1360
+ const clientSess = clientSessionNames.get(ws);
1361
+ const tty =
1362
+ (clientSess ? clientTtyBySession.get(clientSess) : undefined) ?? clientTtys.get(ws);
1363
+ switchToVisibleIndex(cmd.index, tty);
1364
+ break;
1365
+ }
1366
+ case "new-session":
1367
+ mux.createSession();
1368
+ broadcastState();
1369
+ break;
1370
+ case "hide-session":
1371
+ sessionOrder.hide(cmd.name);
1372
+ broadcastState();
1373
+ break;
1374
+ case "show-all-sessions":
1375
+ sessionOrder.showAll();
1376
+ broadcastState();
1377
+ break;
1378
+ case "kill-session": {
1379
+ const p = sessionProviders.get(cmd.name) ?? mux;
1380
+ p.killSession(cmd.name);
1381
+ broadcastState();
1382
+ break;
1383
+ }
1384
+ case "reorder-session":
1385
+ sessionOrder.reorder(cmd.name, cmd.delta);
1386
+ broadcastState();
1387
+ break;
1388
+ case "refresh":
1389
+ broadcastState();
1390
+ break;
1391
+ case "move-focus":
1392
+ moveFocus(cmd.delta, ws);
1393
+ break;
1394
+ case "focus-session":
1395
+ setFocus(cmd.name, ws);
1396
+ break;
1397
+ case "mark-seen":
1398
+ if (tracker.markSeen(cmd.name)) broadcastState();
1399
+ break;
1400
+ case "dismiss-agent":
1401
+ if (tracker.dismiss(cmd.session, cmd.agent, cmd.threadId)) broadcastState();
1402
+ break;
1403
+ case "set-theme":
1404
+ currentTheme = cmd.theme;
1405
+ saveConfig({ theme: cmd.theme });
1406
+ broadcastState();
1407
+ break;
1408
+ case "report-width":
1409
+ // No-op: sidebar width is config-only, not auto-saved from drag
1410
+ break;
1411
+ case "quit":
1412
+ quitAll();
1413
+ break;
1414
+ case "identify-pane":
1415
+ // Store this client's session, reply with session + authoritative client TTY
1416
+ clientSessionNames.set(ws, cmd.sessionName);
1417
+ ws.send(
1418
+ JSON.stringify({
1419
+ type: "your-session",
1420
+ name: cmd.sessionName,
1421
+ clientTty: clientTtyBySession.get(cmd.sessionName) ?? null,
1422
+ }),
1423
+ );
1424
+ break;
1425
+ case "focus-agent-pane":
1426
+ log("handleCommand", "focus-agent-pane received", {
1427
+ session: cmd.session,
1428
+ agent: cmd.agent,
1429
+ threadId: cmd.threadId,
1430
+ threadName: cmd.threadName,
1431
+ });
1432
+ focusAgentPane(cmd.session, cmd.agent, cmd.threadId, cmd.threadName);
1433
+ break;
1434
+ case "kill-agent-pane":
1435
+ log("handleCommand", "kill-agent-pane received", {
1436
+ session: cmd.session,
1437
+ agent: cmd.agent,
1438
+ threadId: cmd.threadId,
1439
+ threadName: cmd.threadName,
1440
+ });
1441
+ killAgentPane(cmd.session, cmd.agent, cmd.threadId, cmd.threadName);
1442
+ break;
1443
+ }
1444
+ }
1445
+
1446
+ // --- Port polling (detect new/stopped listeners every 10s) ---
1447
+
1448
+ const PORT_POLL_INTERVAL_MS = 10_000;
1449
+ let portPollTimer: ReturnType<typeof setInterval> | null = null;
1450
+
1451
+ function startPortPoll() {
1452
+ // Run initial snapshot immediately so first broadcast has ports
1453
+ if (lastState) {
1454
+ refreshPortSnapshot(lastState.sessions.map((s) => s.name));
1455
+ }
1456
+ portPollTimer = setInterval(() => {
1457
+ if (!lastState || clientCount === 0) return;
1458
+ const changed = refreshPortSnapshot(lastState.sessions.map((s) => s.name));
1459
+ if (changed) broadcastState();
1460
+ }, PORT_POLL_INTERVAL_MS);
1461
+ }
1462
+
1463
+ function cleanup() {
1464
+ for (const w of allWatchers) w.stop();
1465
+ if (watcherBroadcastTimer) clearTimeout(watcherBroadcastTimer);
1466
+ teardownGitWatchers();
1467
+ if (portPollTimer) clearInterval(portPollTimer);
1468
+ if (paneScanTimer) clearInterval(paneScanTimer);
1469
+ if (pendingSidebarResize) clearTimeout(pendingSidebarResize);
1470
+ for (const timer of pendingHighlightResets.values()) clearTimeout(timer);
1471
+ pendingHighlightResets.clear();
1472
+ if (idleTimer) clearTimeout(idleTimer);
1473
+ try {
1474
+ unlinkSync(PID_FILE);
1475
+ } catch {}
1476
+ for (const p of allProviders) p.cleanupHooks();
1477
+ }
1478
+
1479
+ // --- Write PID + start server ---
1480
+
1481
+ writeFileSync(PID_FILE, String(process.pid));
1482
+
1483
+ const server = Bun.serve({
1484
+ port: SERVER_PORT,
1485
+ hostname: SERVER_HOST,
1486
+ async fetch(req, server) {
1487
+ const url = new URL(req.url);
1488
+
1489
+ // Any HTTP request proves tmux hooks are still active — reset idle timer
1490
+ if (idleTimer) {
1491
+ clearTimeout(idleTimer);
1492
+ idleTimer = null;
1493
+ }
1494
+
1495
+ if (req.method === "POST" && url.pathname === "/refresh") {
1496
+ broadcastState();
1497
+ return new Response("ok", { status: 200 });
1498
+ }
1499
+
1500
+ if (req.method === "POST" && url.pathname === "/resize-sidebars") {
1501
+ const body = await req.text();
1502
+ const ctx = parseResizeContext(body) ?? undefined;
1503
+ log("http", "POST /resize-sidebars", { sidebarWidth, ctx });
1504
+ scheduleSidebarResize(ctx);
1505
+ return new Response("ok", { status: 200 });
1506
+ }
1507
+
1508
+ if (req.method === "POST" && url.pathname === "/focus") {
1509
+ try {
1510
+ const body = await req.text();
1511
+ const ctx = parseContext(body);
1512
+ if (ctx) {
1513
+ handleFocus(ctx.session);
1514
+ } else {
1515
+ // Legacy: body is just the session name
1516
+ const name = body.trim().replace(/^"+|"+$/g, "");
1517
+ if (name) handleFocus(name);
1518
+ }
1519
+ } catch {}
1520
+ return new Response("ok", { status: 200 });
1521
+ }
1522
+
1523
+ if (req.method === "POST" && url.pathname === "/toggle") {
1524
+ try {
1525
+ const body = await req.text();
1526
+ const ctx = parseContext(body) ?? undefined;
1527
+ log("http", "POST /toggle", { ctx });
1528
+ toggleSidebar(ctx);
1529
+ broadcastState();
1530
+ } catch {}
1531
+ return new Response("ok", { status: 200 });
1532
+ }
1533
+
1534
+ if (req.method === "POST" && url.pathname === "/quit") {
1535
+ log("http", "POST /quit");
1536
+ quitAll();
1537
+ return new Response("ok", { status: 200 });
1538
+ }
1539
+
1540
+ if (req.method === "POST" && url.pathname === "/switch-index") {
1541
+ try {
1542
+ const index = Number.parseInt(url.searchParams.get("index") ?? "", 10);
1543
+ if (Number.isNaN(index)) {
1544
+ return new Response("missing index", { status: 400 });
1545
+ }
1546
+ const body = await req.text();
1547
+ const ctx = parseContext(body) ?? undefined;
1548
+ log("http", "POST /switch-index", { index, ctx });
1549
+ switchToVisibleIndex(index, ctx?.clientTty);
1550
+ } catch {}
1551
+ return new Response("ok", { status: 200 });
1552
+ }
1553
+
1554
+ if (req.method === "POST" && url.pathname === "/ensure-sidebar") {
1555
+ try {
1556
+ const body = await req.text();
1557
+ const ctx = parseContext(body) ?? undefined;
1558
+ log("http", "POST /ensure-sidebar", { sidebarVisible, ctx });
1559
+ // Debounce ensure-sidebar during rapid switching — intermediate sessions
1560
+ // don't need full sidebar validation immediately.
1561
+ debouncedEnsureSidebar(ctx ?? undefined);
1562
+ } catch {}
1563
+ return new Response("ok", { status: 200 });
1564
+ }
1565
+
1566
+ if (req.method === "POST" && url.pathname === "/set-status") {
1567
+ try {
1568
+ const body = (await req.json()) as {
1569
+ session?: string;
1570
+ text?: string | null;
1571
+ tone?: string;
1572
+ };
1573
+ if (!body.session || typeof body.session !== "string") {
1574
+ return new Response("missing session", { status: 400 });
1575
+ }
1576
+ if (body.text === null || body.text === undefined) {
1577
+ metadataStore.setStatus(body.session, null);
1578
+ } else if (typeof body.text !== "string") {
1579
+ return new Response("text must be a string or null", { status: 400 });
1580
+ } else {
1581
+ metadataStore.setStatus(body.session, { text: body.text, tone: parseTone(body.tone) });
1582
+ }
1583
+ broadcastState();
1584
+ return new Response(null, { status: 204 });
1585
+ } catch {
1586
+ return new Response("invalid json", { status: 400 });
1587
+ }
1588
+ }
1589
+
1590
+ if (req.method === "POST" && url.pathname === "/set-progress") {
1591
+ try {
1592
+ const body = (await req.json()) as {
1593
+ session?: string;
1594
+ current?: number;
1595
+ total?: number;
1596
+ percent?: number;
1597
+ label?: string;
1598
+ clear?: boolean;
1599
+ };
1600
+ if (!body.session || typeof body.session !== "string") {
1601
+ return new Response("missing session", { status: 400 });
1602
+ }
1603
+ if (body.clear) {
1604
+ metadataStore.setProgress(body.session, null);
1605
+ } else {
1606
+ metadataStore.setProgress(body.session, {
1607
+ current: body.current,
1608
+ total: body.total,
1609
+ percent: body.percent,
1610
+ label: body.label,
1611
+ });
1612
+ }
1613
+ broadcastState();
1614
+ return new Response(null, { status: 204 });
1615
+ } catch {
1616
+ return new Response("invalid json", { status: 400 });
1617
+ }
1618
+ }
1619
+
1620
+ if (req.method === "POST" && url.pathname === "/log") {
1621
+ try {
1622
+ const body = (await req.json()) as {
1623
+ session?: string;
1624
+ message?: string;
1625
+ tone?: string;
1626
+ source?: string;
1627
+ };
1628
+ if (!body.session || typeof body.session !== "string") {
1629
+ return new Response("missing session", { status: 400 });
1630
+ }
1631
+ if (!body.message || typeof body.message !== "string") {
1632
+ return new Response("missing message", { status: 400 });
1633
+ }
1634
+ metadataStore.appendLog(body.session, {
1635
+ message: body.message,
1636
+ tone: parseTone(body.tone),
1637
+ source: body.source,
1638
+ });
1639
+ broadcastState();
1640
+ return new Response(null, { status: 204 });
1641
+ } catch {
1642
+ return new Response("invalid json", { status: 400 });
1643
+ }
1644
+ }
1645
+
1646
+ if (req.method === "POST" && url.pathname === "/clear-log") {
1647
+ try {
1648
+ const body = (await req.json()) as { session?: string };
1649
+ if (!body.session || typeof body.session !== "string") {
1650
+ return new Response("missing session", { status: 400 });
1651
+ }
1652
+ metadataStore.clearLogs(body.session);
1653
+ broadcastState();
1654
+ return new Response(null, { status: 204 });
1655
+ } catch {
1656
+ return new Response("invalid json", { status: 400 });
1657
+ }
1658
+ }
1659
+
1660
+ if (server.upgrade(req, { data: {} })) return;
1661
+ return Response.json({
1662
+ name: "agentboard server",
1663
+ routes: [
1664
+ "POST /refresh",
1665
+ "POST /resize-sidebars",
1666
+ "POST /focus",
1667
+ "POST /toggle",
1668
+ "POST /quit",
1669
+ "POST /switch-index?index=N",
1670
+ "POST /ensure-sidebar",
1671
+ "POST /set-status",
1672
+ "POST /set-progress",
1673
+ "POST /log",
1674
+ "POST /clear-log",
1675
+ "WS /",
1676
+ ],
1677
+ });
1678
+ },
1679
+ websocket: {
1680
+ open(ws) {
1681
+ ws.subscribe("sidebar");
1682
+ clientCount++;
1683
+ log("ws", "client connected", { clientCount });
1684
+ if (idleTimer) {
1685
+ clearTimeout(idleTimer);
1686
+ idleTimer = null;
1687
+ }
1688
+ if (lastState) {
1689
+ ws.send(JSON.stringify(lastState));
1690
+ } else {
1691
+ broadcastState();
1692
+ }
1693
+ },
1694
+ close(ws) {
1695
+ ws.unsubscribe("sidebar");
1696
+ clientCount--;
1697
+ if (clientCount < 0) clientCount = 0;
1698
+ log("ws", "client disconnected", { clientCount });
1699
+ if (clientCount === 0 && !idleTimer) {
1700
+ log("ws", "no clients remaining, starting idle timer", {
1701
+ timeoutMs: SERVER_IDLE_TIMEOUT_MS,
1702
+ });
1703
+ idleTimer = setTimeout(() => {
1704
+ log("ws", "idle timeout reached, shutting down");
1705
+ quitAll();
1706
+ }, SERVER_IDLE_TIMEOUT_MS);
1707
+ }
1708
+ },
1709
+ message(ws, msg) {
1710
+ try {
1711
+ const cmd = JSON.parse(msg as string) as ClientCommand;
1712
+ log("ws", "command", { type: cmd.type });
1713
+ handleCommand(cmd, ws);
1714
+ } catch {}
1715
+ },
1716
+ },
1717
+ });
1718
+
1719
+ // --- Bootstrap ---
1720
+
1721
+ for (const p of allProviders) p.setupHooks(SERVER_HOST, SERVER_PORT);
1722
+ // Seed port snapshot before first broadcast so clients see ports immediately
1723
+ {
1724
+ const allMuxSessions: string[] = [];
1725
+ for (const p of allProviders) {
1726
+ for (const s of p.listSessions()) allMuxSessions.push(s.name);
1727
+ }
1728
+ refreshPortSnapshot(allMuxSessions);
1729
+ }
1730
+ broadcastState();
1731
+ startPortPoll();
1732
+ startPaneScan();
1733
+ // Run initial pane scan
1734
+ refreshPaneAgents();
1735
+
1736
+ // Start agent watchers after server is ready
1737
+ for (const w of allWatchers) {
1738
+ w.start(watcherCtx);
1739
+ log("server", `agent watcher started: ${w.name}`);
1740
+ }
1741
+
1742
+ process.on("SIGINT", () => {
1743
+ cleanup();
1744
+ process.exit(0);
1745
+ });
1746
+ process.on("SIGTERM", () => {
1747
+ cleanup();
1748
+ process.exit(0);
1749
+ });
1750
+
1751
+ const names = allProviders.map((p) => p.name).join(", ");
1752
+ console.log(`agentboard server listening on ${SERVER_HOST}:${SERVER_PORT} (mux: ${names})`);
1753
+ }