@towles/tool 0.0.107 → 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.
Files changed (76) 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/commands/agentboard.ts +19 -2
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Codex agent watcher
3
+ *
4
+ * Watches Codex transcript files under ~/.codex/sessions/ (or $CODEX_HOME/sessions),
5
+ * determines agent status from the latest transcript events, and emits events
6
+ * mapped to mux sessions via the working directory captured in turn_context.
7
+ *
8
+ * Detection uses a recursive fs.watch when available plus a periodic poll to
9
+ * catch missed writes and new files.
10
+ */
11
+
12
+ import { watch } from "node:fs";
13
+ import type { FSWatcher } from "node:fs";
14
+ import { readdir, stat } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { basename, join } from "node:path";
17
+ import type { AgentStatus } from "../../contracts/agent";
18
+ import type { AgentWatcher, AgentWatcherContext } from "../../contracts/agent-watcher";
19
+
20
+ interface CodexEntry {
21
+ type?: string;
22
+ payload?: {
23
+ type?: string;
24
+ role?: string;
25
+ phase?: string;
26
+ cwd?: string;
27
+ message?: string;
28
+ content?: Array<{ type?: string; text?: string }>;
29
+ };
30
+ }
31
+
32
+ interface SessionSnapshot {
33
+ status: AgentStatus;
34
+ fileSize: number;
35
+ projectDir?: string;
36
+ threadName?: string;
37
+ }
38
+
39
+ const POLL_MS = 2000;
40
+ const STALE_MS = 5 * 60 * 1000;
41
+ const THREAD_NAME_MAX = 80;
42
+
43
+ function assistantStatus(phase?: string): AgentStatus {
44
+ return phase === "commentary" ? "running" : "done";
45
+ }
46
+
47
+ export function determineStatus(entry: CodexEntry): AgentStatus | null {
48
+ const payload = entry.payload;
49
+ if (!payload) return null;
50
+
51
+ if (entry.type === "event_msg") {
52
+ switch (payload.type) {
53
+ case "task_complete":
54
+ return "done";
55
+ case "turn_aborted":
56
+ return "interrupted";
57
+ case "user_message":
58
+ return "running";
59
+ case "agent_message":
60
+ return assistantStatus(payload.phase);
61
+ case "error":
62
+ return "error";
63
+ default:
64
+ return null;
65
+ }
66
+ }
67
+
68
+ if (entry.type === "response_item") {
69
+ if (payload.type === "message") {
70
+ if (payload.role === "user") return "running";
71
+ if (payload.role === "assistant") return assistantStatus(payload.phase);
72
+ return null;
73
+ }
74
+
75
+ if (
76
+ payload.type === "function_call" ||
77
+ payload.type === "function_call_output" ||
78
+ payload.type === "reasoning"
79
+ ) {
80
+ return "running";
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ function parseThreadId(filePath: string): string {
88
+ const name = basename(filePath, ".jsonl");
89
+ return name.match(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i)?.[0] ?? name;
90
+ }
91
+
92
+ function normalizeThreadName(text: string | undefined): string | undefined {
93
+ if (!text) return undefined;
94
+ const line = text
95
+ .split("\n")
96
+ .map((part) => part.trim())
97
+ .find(Boolean);
98
+ return line ? line.slice(0, THREAD_NAME_MAX) : undefined;
99
+ }
100
+
101
+ function extractThreadName(entry: CodexEntry): string | undefined {
102
+ const payload = entry.payload;
103
+ if (!payload) return undefined;
104
+
105
+ if (entry.type === "event_msg" && payload.type === "user_message") {
106
+ return normalizeThreadName(payload.message);
107
+ }
108
+
109
+ if (entry.type === "response_item" && payload.type === "message" && payload.role === "user") {
110
+ const text = Array.isArray(payload.content)
111
+ ? payload.content
112
+ .filter((item) => item?.type === "input_text")
113
+ .map((item) => item.text ?? "")
114
+ .join("\n")
115
+ : undefined;
116
+ const candidate = normalizeThreadName(text);
117
+ if (!candidate) return undefined;
118
+ if (candidate.startsWith("# AGENTS.md") || candidate.startsWith("<environment_context>"))
119
+ return undefined;
120
+ return candidate;
121
+ }
122
+
123
+ return undefined;
124
+ }
125
+
126
+ function applyEntries(
127
+ text: string,
128
+ base: SessionSnapshot,
129
+ indexedThreadName?: string,
130
+ ): SessionSnapshot {
131
+ let status = base.status;
132
+ let projectDir = base.projectDir;
133
+ let threadName = indexedThreadName ?? base.threadName;
134
+
135
+ for (const rawLine of text.split("\n")) {
136
+ if (!rawLine.trim()) continue;
137
+
138
+ let entry: CodexEntry;
139
+ try {
140
+ entry = JSON.parse(rawLine);
141
+ } catch {
142
+ continue;
143
+ }
144
+
145
+ if (!projectDir && entry.type === "turn_context" && typeof entry.payload?.cwd === "string") {
146
+ projectDir = entry.payload.cwd;
147
+ }
148
+
149
+ if (!threadName) {
150
+ threadName = extractThreadName(entry);
151
+ }
152
+
153
+ const nextStatus = determineStatus(entry);
154
+ if (nextStatus) {
155
+ status = nextStatus;
156
+ }
157
+ }
158
+
159
+ return { ...base, status, projectDir, threadName };
160
+ }
161
+
162
+ async function collectSessionFiles(dir: string): Promise<string[]> {
163
+ let entries;
164
+ try {
165
+ entries = await readdir(dir, { withFileTypes: true });
166
+ } catch {
167
+ return [];
168
+ }
169
+
170
+ const files: string[] = [];
171
+ for (const entry of entries) {
172
+ const fullPath = join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ files.push(...(await collectSessionFiles(fullPath)));
175
+ continue;
176
+ }
177
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
178
+ files.push(fullPath);
179
+ }
180
+ }
181
+
182
+ return files;
183
+ }
184
+
185
+ export class CodexAgentWatcher implements AgentWatcher {
186
+ readonly name = "codex";
187
+
188
+ private sessions = new Map<string, SessionSnapshot>();
189
+ private threadNames = new Map<string, string>();
190
+ private fsWatcher: FSWatcher | null = null;
191
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
192
+ private ctx: AgentWatcherContext | null = null;
193
+ private sessionsDir: string;
194
+ private sessionIndexFile: string;
195
+ private scanning = false;
196
+ private seeded = false;
197
+
198
+ constructor() {
199
+ const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
200
+ this.sessionsDir = join(codexHome, "sessions");
201
+ this.sessionIndexFile = join(codexHome, "session_index.jsonl");
202
+ }
203
+
204
+ start(ctx: AgentWatcherContext): void {
205
+ this.ctx = ctx;
206
+ this.setupWatch();
207
+ setTimeout(() => this.scan(), 50);
208
+ this.pollTimer = setInterval(() => this.scan(), POLL_MS);
209
+ }
210
+
211
+ stop(): void {
212
+ if (this.fsWatcher) {
213
+ try {
214
+ this.fsWatcher.close();
215
+ } catch {}
216
+ this.fsWatcher = null;
217
+ }
218
+ if (this.pollTimer) {
219
+ clearInterval(this.pollTimer);
220
+ this.pollTimer = null;
221
+ }
222
+ this.ctx = null;
223
+ }
224
+
225
+ private async loadThreadIndex(): Promise<void> {
226
+ let text: string;
227
+ try {
228
+ text = await Bun.file(this.sessionIndexFile).text();
229
+ } catch {
230
+ return;
231
+ }
232
+
233
+ const names = new Map<string, string>();
234
+ for (const line of text.split("\n")) {
235
+ if (!line.trim()) continue;
236
+ try {
237
+ const entry = JSON.parse(line) as { id?: string; thread_name?: string };
238
+ if (entry.id && entry.thread_name) {
239
+ names.set(entry.id, entry.thread_name);
240
+ }
241
+ } catch {}
242
+ }
243
+
244
+ this.threadNames = names;
245
+ }
246
+
247
+ private async processFile(filePath: string): Promise<void> {
248
+ if (!this.ctx) return;
249
+
250
+ let fileStat;
251
+ try {
252
+ fileStat = await stat(filePath);
253
+ } catch {
254
+ return;
255
+ }
256
+
257
+ const threadId = parseThreadId(filePath);
258
+ const prev = this.sessions.get(threadId);
259
+
260
+ if (prev && fileStat.size === prev.fileSize) return;
261
+
262
+ const indexedThreadName = this.threadNames.get(threadId);
263
+ let nextSnapshot: SessionSnapshot;
264
+
265
+ if (prev && fileStat.size > prev.fileSize) {
266
+ let text: string;
267
+ try {
268
+ const buf = await Bun.file(filePath).arrayBuffer();
269
+ text = new TextDecoder().decode(new Uint8Array(buf).subarray(prev.fileSize, fileStat.size));
270
+ } catch {
271
+ return;
272
+ }
273
+
274
+ nextSnapshot = applyEntries(text, { ...prev, fileSize: fileStat.size }, indexedThreadName);
275
+ } else {
276
+ let text: string;
277
+ try {
278
+ text = await Bun.file(filePath).text();
279
+ } catch {
280
+ return;
281
+ }
282
+
283
+ nextSnapshot = applyEntries(
284
+ text,
285
+ { status: "idle", fileSize: fileStat.size },
286
+ indexedThreadName,
287
+ );
288
+ }
289
+
290
+ this.sessions.set(threadId, nextSnapshot);
291
+
292
+ if (!this.seeded) return;
293
+
294
+ const prevStatus = prev?.status;
295
+ if (nextSnapshot.status === prevStatus) return;
296
+
297
+ const session = nextSnapshot.projectDir
298
+ ? this.ctx.resolveSession(nextSnapshot.projectDir)
299
+ : null;
300
+ if (!session) return;
301
+ if (!prev && nextSnapshot.status === "idle") return;
302
+
303
+ this.ctx.emit({
304
+ agent: "codex",
305
+ session,
306
+ status: nextSnapshot.status,
307
+ ts: Date.now(),
308
+ threadId,
309
+ ...(nextSnapshot.threadName && { threadName: nextSnapshot.threadName }),
310
+ });
311
+ }
312
+
313
+ private async scan(): Promise<void> {
314
+ if (this.scanning || !this.ctx) return;
315
+ this.scanning = true;
316
+
317
+ try {
318
+ await this.loadThreadIndex();
319
+
320
+ const files = await collectSessionFiles(this.sessionsDir);
321
+ const now = Date.now();
322
+
323
+ for (const filePath of files) {
324
+ let fileStat;
325
+ try {
326
+ fileStat = await stat(filePath);
327
+ } catch {
328
+ continue;
329
+ }
330
+
331
+ if (now - fileStat.mtimeMs > STALE_MS) continue;
332
+ await this.processFile(filePath);
333
+ }
334
+ } finally {
335
+ if (!this.seeded) {
336
+ this.seeded = true;
337
+ // Emit seeded sessions with non-idle status (like amp watcher does)
338
+ for (const [threadId, snapshot] of this.sessions) {
339
+ if (snapshot.status === "idle" || !snapshot.projectDir) continue;
340
+ const session = this.ctx?.resolveSession(snapshot.projectDir);
341
+ if (!session) continue;
342
+ this.ctx?.emit({
343
+ agent: "codex",
344
+ session,
345
+ status: snapshot.status,
346
+ ts: Date.now(),
347
+ threadId,
348
+ ...(snapshot.threadName && { threadName: snapshot.threadName }),
349
+ });
350
+ }
351
+ }
352
+ this.scanning = false;
353
+ }
354
+ }
355
+
356
+ private setupWatch(): void {
357
+ try {
358
+ this.fsWatcher = watch(this.sessionsDir, { recursive: true }, (_eventType, filename) => {
359
+ if (!filename?.endsWith(".jsonl")) return;
360
+ this.processFile(join(this.sessionsDir, filename));
361
+ });
362
+ } catch {}
363
+ }
364
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * OpenCode agent watcher
3
+ *
4
+ * Polls the OpenCode SQLite database (~/.local/share/opencode/opencode.db)
5
+ * to determine agent status and emits events mapped to mux sessions
6
+ * via the `directory` field on each OpenCode session row.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { AgentStatus } from "../../contracts/agent";
13
+ import type { AgentWatcher, AgentWatcherContext } from "../../contracts/agent-watcher";
14
+
15
+ // --- Types ---
16
+
17
+ interface SessionRow {
18
+ id: string;
19
+ title: string | null;
20
+ directory: string;
21
+ time_updated: number;
22
+ }
23
+
24
+ interface MessageRow {
25
+ id: string;
26
+ data: string;
27
+ }
28
+
29
+ interface MessageData {
30
+ role?: string;
31
+ finish?: string;
32
+ }
33
+
34
+ interface PartData {
35
+ type?: string;
36
+ }
37
+
38
+ const POLL_MS = 3000;
39
+ const STALE_MS = 5 * 60 * 1000;
40
+
41
+ // --- Status detection ---
42
+
43
+ export function determineStatus(msg: MessageData | null, parts: PartData[]): AgentStatus {
44
+ if (!msg) return "idle";
45
+
46
+ if (msg.role === "assistant") {
47
+ if (msg.finish === "tool-calls") return "running";
48
+ if (parts.some((p) => p.type === "tool")) return "running";
49
+ return "done";
50
+ }
51
+
52
+ if (msg.role === "user") return "running";
53
+
54
+ return "idle";
55
+ }
56
+
57
+ // --- Watcher implementation ---
58
+
59
+ export class OpenCodeAgentWatcher implements AgentWatcher {
60
+ readonly name = "opencode";
61
+
62
+ private sessionTimestamps = new Map<string, number>();
63
+ private sessionStatuses = new Map<string, AgentStatus>();
64
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
65
+ private ctx: AgentWatcherContext | null = null;
66
+ private db: any = null;
67
+ private dbPath: string;
68
+
69
+ constructor() {
70
+ this.dbPath =
71
+ process.env.OPENCODE_DB_PATH ?? join(homedir(), ".local", "share", "opencode", "opencode.db");
72
+ }
73
+
74
+ private polling = false;
75
+
76
+ start(ctx: AgentWatcherContext): void {
77
+ this.ctx = ctx;
78
+ setTimeout(() => this.poll(), 50);
79
+ this.pollTimer = setInterval(() => this.poll(), POLL_MS);
80
+ }
81
+
82
+ stop(): void {
83
+ if (this.pollTimer) {
84
+ clearInterval(this.pollTimer);
85
+ this.pollTimer = null;
86
+ }
87
+ try {
88
+ this.db?.close();
89
+ } catch {}
90
+ this.db = null;
91
+ this.ctx = null;
92
+ }
93
+
94
+ private openDb(): boolean {
95
+ if (this.db) return true;
96
+ if (!existsSync(this.dbPath)) return false;
97
+ try {
98
+ // Dynamic import to avoid hard dependency on bun:sqlite at module level
99
+ const { Database } = require("bun:sqlite");
100
+ this.db = new Database(this.dbPath, { readonly: true });
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ private seeded = false;
108
+
109
+ private poll(): void {
110
+ if (!this.ctx || this.polling) return;
111
+ this.polling = true;
112
+
113
+ try {
114
+ if (!this.openDb()) return;
115
+
116
+ let sessions: SessionRow[];
117
+ const staleThreshold = Date.now() - STALE_MS;
118
+ try {
119
+ sessions = this.db
120
+ .query(
121
+ `SELECT id, title, directory, time_updated FROM session WHERE time_updated > ? ORDER BY time_updated DESC`,
122
+ )
123
+ .all(staleThreshold);
124
+ } catch {
125
+ try {
126
+ this.db.close();
127
+ } catch {}
128
+ this.db = null;
129
+ return;
130
+ }
131
+
132
+ // First poll: record timestamps, then emit current state for recent sessions.
133
+ if (!this.seeded) {
134
+ for (const row of sessions) {
135
+ this.sessionTimestamps.set(row.id, row.time_updated);
136
+ }
137
+ this.seeded = true;
138
+
139
+ // Emit seeded sessions by reading their current status (like amp watcher does)
140
+ for (const row of sessions) {
141
+ let lastMsg: MessageRow | null = null;
142
+ let lastParts: PartData[] = [];
143
+ try {
144
+ lastMsg = this.db
145
+ .query(
146
+ `SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 1`,
147
+ )
148
+ .get(row.id);
149
+ if (lastMsg) {
150
+ const partRows: { data: string }[] = this.db
151
+ .query(`SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC`)
152
+ .all(lastMsg.id);
153
+ for (const pr of partRows) {
154
+ try {
155
+ lastParts.push(JSON.parse(pr.data));
156
+ } catch {}
157
+ }
158
+ }
159
+ } catch {
160
+ continue;
161
+ }
162
+
163
+ let lastMsgData: MessageData | null = null;
164
+ if (lastMsg) {
165
+ try {
166
+ lastMsgData = JSON.parse(lastMsg.data);
167
+ } catch {}
168
+ }
169
+
170
+ const status = determineStatus(lastMsgData, lastParts);
171
+ if (status === "idle") continue;
172
+ this.sessionStatuses.set(row.id, status);
173
+
174
+ const session = this.ctx.resolveSession(row.directory);
175
+ if (!session) continue;
176
+
177
+ this.ctx.emit({
178
+ agent: "opencode",
179
+ session,
180
+ status,
181
+ ts: Date.now(),
182
+ threadId: row.id,
183
+ ...(row.title && { threadName: row.title }),
184
+ });
185
+ }
186
+ return;
187
+ }
188
+
189
+ for (const row of sessions) {
190
+ const prev = this.sessionTimestamps.get(row.id);
191
+ if (prev === row.time_updated) continue;
192
+ this.sessionTimestamps.set(row.id, row.time_updated);
193
+
194
+ // Only emit if we had a previous timestamp — a change means
195
+ // OpenCode is actively writing to this session right now.
196
+ if (prev === undefined) continue;
197
+
198
+ let lastMsg: MessageRow | null = null;
199
+ let lastParts: PartData[] = [];
200
+ try {
201
+ lastMsg = this.db
202
+ .query(
203
+ `SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 1`,
204
+ )
205
+ .get(row.id);
206
+
207
+ if (lastMsg) {
208
+ const partRows: { data: string }[] = this.db
209
+ .query(`SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC`)
210
+ .all(lastMsg.id);
211
+ for (const pr of partRows) {
212
+ try {
213
+ lastParts.push(JSON.parse(pr.data));
214
+ } catch {}
215
+ }
216
+ }
217
+ } catch {
218
+ continue;
219
+ }
220
+
221
+ let lastMsgData: MessageData | null = null;
222
+ if (lastMsg) {
223
+ try {
224
+ lastMsgData = JSON.parse(lastMsg.data);
225
+ } catch {}
226
+ }
227
+
228
+ const status = determineStatus(lastMsgData, lastParts);
229
+ const prevStatus = this.sessionStatuses.get(row.id);
230
+ if (prevStatus === status) continue;
231
+ this.sessionStatuses.set(row.id, status);
232
+
233
+ const session = this.ctx.resolveSession(row.directory);
234
+ if (!session) continue;
235
+
236
+ this.ctx.emit({
237
+ agent: "opencode",
238
+ session,
239
+ status,
240
+ ts: Date.now(),
241
+ threadId: row.id,
242
+ ...(row.title && { threadName: row.title }),
243
+ });
244
+ }
245
+ } finally {
246
+ this.polling = false;
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { PartialTheme } from "./themes";
5
+
6
+ export interface AgentboardConfig {
7
+ /** Explicit mux provider name (overrides auto-detect) */
8
+ mux?: string;
9
+ /** Custom server port */
10
+ port?: number;
11
+ /** Community plugin package names to load */
12
+ plugins: string[];
13
+ /** Theme: builtin name (e.g. "catppuccin-latte") or partial inline theme object */
14
+ theme?: string | PartialTheme;
15
+ /** Sidebar column width (default 26) */
16
+ sidebarWidth?: number;
17
+ /** Sidebar position relative to the terminal window (default "left") */
18
+ sidebarPosition?: "left" | "right";
19
+ /** Tmux prefix key for sidebar toggle (default "s") */
20
+ keybinding?: string;
21
+ /** Persisted detail panel heights keyed by mux session name */
22
+ detailPanelHeights?: Record<string, number>;
23
+ }
24
+
25
+ const DEFAULTS: AgentboardConfig = {
26
+ plugins: [],
27
+ };
28
+
29
+ /**
30
+ * Load config from ~/.config/towles-tool/agentboard/config.json
31
+ * @param homeDir — override home directory (for testing)
32
+ */
33
+ export function loadConfig(homeDir?: string): AgentboardConfig {
34
+ const home = homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
35
+ const configPath = join(home, ".config", "towles-tool", "agentboard", "config.json");
36
+
37
+ if (!existsSync(configPath)) {
38
+ return { ...DEFAULTS };
39
+ }
40
+
41
+ try {
42
+ const raw = readFileSync(configPath, "utf-8");
43
+ const parsed = JSON.parse(raw) as Partial<AgentboardConfig>;
44
+ return {
45
+ ...DEFAULTS,
46
+ ...parsed,
47
+ plugins: parsed.plugins ?? DEFAULTS.plugins,
48
+ };
49
+ } catch {
50
+ return { ...DEFAULTS };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Save partial config updates to ~/.config/towles-tool/agentboard/config.json
56
+ * Merges with existing config on disk to preserve fields.
57
+ * @param updates — partial config fields to write
58
+ * @param homeDir — override home directory (for testing)
59
+ */
60
+ export function saveConfig(updates: Partial<AgentboardConfig>, homeDir?: string): void {
61
+ const home = homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
62
+ const configDir = join(home, ".config", "towles-tool", "agentboard");
63
+ const configPath = join(configDir, "config.json");
64
+
65
+ const existing = loadConfig(homeDir);
66
+ const merged = { ...existing, ...updates };
67
+
68
+ mkdirSync(configDir, { recursive: true });
69
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
70
+ }