@towles/tool 0.0.106 → 0.0.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,316 @@
1
+ /**
2
+ * Amp agent watcher
3
+ *
4
+ * Watches ~/.local/share/amp/threads/ for JSON file changes,
5
+ * determines agent status from the last message, and emits events
6
+ * mapped to mux sessions via the project directory in each thread.
7
+ *
8
+ * All file I/O is async to avoid blocking the server event loop.
9
+ */
10
+
11
+ import { watch } from "node:fs";
12
+ import type { FSWatcher } from "node:fs";
13
+ import { readdir, stat } from "node:fs/promises";
14
+ import { join, basename } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import type { AgentStatus } from "../../contracts/agent";
17
+ import type { AgentWatcher, AgentWatcherContext } from "../../contracts/agent-watcher";
18
+
19
+ // --- Thread file types ---
20
+
21
+ interface MessageState {
22
+ type?: string;
23
+ stopReason?: string;
24
+ }
25
+
26
+ interface Message {
27
+ role?: string;
28
+ state?: MessageState;
29
+ }
30
+
31
+ interface ThreadSnapshot {
32
+ status: AgentStatus;
33
+ version: number;
34
+ title?: string;
35
+ projectDir?: string;
36
+ mtimeMs: number;
37
+ }
38
+
39
+ const STALE_MS = 5 * 60 * 1000;
40
+ const POLL_MS = 2000;
41
+
42
+ // --- Status detection ---
43
+
44
+ export function determineStatus(
45
+ lastMsg: { role?: string; state?: MessageState } | null,
46
+ ): AgentStatus {
47
+ if (!lastMsg?.role) return "idle";
48
+
49
+ if (lastMsg.role === "user") return "running";
50
+
51
+ if (lastMsg.role === "assistant") {
52
+ const state = lastMsg.state;
53
+ if (!state) return "running";
54
+ if (state.type === "streaming") return "running";
55
+ if (state.type === "cancelled" || state.type === "aborted" || state.type === "interrupted")
56
+ return "interrupted";
57
+ if (state.type === "error" || state.type === "errored" || state.type === "failed")
58
+ return "error";
59
+ if (state.type === "complete") {
60
+ if (state.stopReason === "tool_use") return "running";
61
+ if (state.stopReason === "end_turn") return "done";
62
+ // Amp uses other stop reasons such as max_tokens for terminal failures.
63
+ return "error";
64
+ }
65
+ return "waiting";
66
+ }
67
+
68
+ return "idle";
69
+ }
70
+
71
+ // --- Async thread file parsing ---
72
+
73
+ async function parseThreadFile(filePath: string): Promise<{
74
+ version: number;
75
+ title?: string;
76
+ projectDir?: string;
77
+ lastMessage: Message | null;
78
+ } | null> {
79
+ try {
80
+ const raw = await Bun.file(filePath).text();
81
+ const thread = JSON.parse(raw);
82
+ const messages = thread.messages ?? [];
83
+ const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
84
+ const uri: string = thread.env?.initial?.trees?.[0]?.uri ?? "";
85
+ const projectDir = uri.startsWith("file://") ? uri.slice(7) : undefined;
86
+
87
+ return {
88
+ version: thread.v ?? 0,
89
+ title: thread.title || undefined,
90
+ projectDir,
91
+ lastMessage: lastMsg ? { role: lastMsg.role, state: lastMsg.state } : null,
92
+ };
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // --- Watcher implementation ---
99
+
100
+ export class AmpAgentWatcher implements AgentWatcher {
101
+ readonly name = "amp";
102
+
103
+ private threads = new Map<string, ThreadSnapshot>();
104
+ private fsWatcher: FSWatcher | null = null;
105
+ private sessionWatcher: FSWatcher | null = null;
106
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
107
+ private ctx: AgentWatcherContext | null = null;
108
+ private threadsDir: string;
109
+ private sessionFile: string;
110
+ private scanning = false;
111
+ private seeded = false;
112
+ private lastFocusedThread: string | null = null;
113
+
114
+ constructor() {
115
+ const dataDir = join(homedir(), ".local", "share", "amp");
116
+ this.threadsDir = join(dataDir, "threads");
117
+ this.sessionFile = join(dataDir, "session.json");
118
+ }
119
+
120
+ start(ctx: AgentWatcherContext): void {
121
+ this.ctx = ctx;
122
+ this.setupWatch();
123
+ this.setupSessionWatch();
124
+ setTimeout(() => this.scan(), 50);
125
+ this.pollTimer = setInterval(() => this.scan(), POLL_MS);
126
+ }
127
+
128
+ stop(): void {
129
+ if (this.fsWatcher) {
130
+ try {
131
+ this.fsWatcher.close();
132
+ } catch {}
133
+ this.fsWatcher = null;
134
+ }
135
+ if (this.sessionWatcher) {
136
+ try {
137
+ this.sessionWatcher.close();
138
+ } catch {}
139
+ this.sessionWatcher = null;
140
+ }
141
+ if (this.pollTimer) {
142
+ clearInterval(this.pollTimer);
143
+ this.pollTimer = null;
144
+ }
145
+ this.ctx = null;
146
+ }
147
+
148
+ private emitThread(threadId: string, snapshot: ThreadSnapshot): boolean {
149
+ if (!this.ctx || !snapshot.projectDir || snapshot.status === "idle") return false;
150
+
151
+ const session = this.ctx.resolveSession(snapshot.projectDir);
152
+ if (!session || session === "unknown") return false;
153
+
154
+ this.ctx.emit({
155
+ agent: "amp",
156
+ session,
157
+ status: snapshot.status,
158
+ ts: Date.now(),
159
+ threadId,
160
+ threadName: snapshot.title,
161
+ });
162
+ return true;
163
+ }
164
+
165
+ private async processThread(filePath: string): Promise<boolean> {
166
+ if (!this.ctx) return false;
167
+
168
+ let fileStat;
169
+ try {
170
+ fileStat = await stat(filePath);
171
+ } catch {
172
+ return false;
173
+ }
174
+
175
+ const threadId = basename(filePath, ".json");
176
+ const prev = this.threads.get(threadId);
177
+
178
+ // Quick mtime check — skip if file hasn't changed since we last saw this version
179
+ if (prev && fileStat.mtimeMs <= prev.mtimeMs) return false;
180
+
181
+ const parsed = await parseThreadFile(filePath);
182
+ if (!parsed) return false;
183
+
184
+ const status = determineStatus(parsed.lastMessage);
185
+ const statusChanged = prev?.status !== status;
186
+ const titleChanged = prev?.title !== parsed.title;
187
+ const projectDirChanged = prev?.projectDir !== parsed.projectDir;
188
+
189
+ if (
190
+ prev &&
191
+ parsed.version === prev.version &&
192
+ !statusChanged &&
193
+ !titleChanged &&
194
+ !projectDirChanged
195
+ ) {
196
+ // Update mtime even if version unchanged to avoid re-reading
197
+ prev.mtimeMs = fileStat.mtimeMs;
198
+ return false;
199
+ }
200
+
201
+ const snapshot: ThreadSnapshot = {
202
+ status,
203
+ version: parsed.version,
204
+ title: parsed.title,
205
+ projectDir: parsed.projectDir,
206
+ mtimeMs: fileStat.mtimeMs,
207
+ };
208
+ this.threads.set(threadId, snapshot);
209
+
210
+ // Seed mode: record state without emitting
211
+ if (!this.seeded) return false;
212
+
213
+ return (statusChanged || titleChanged) && this.emitThread(threadId, snapshot);
214
+ }
215
+
216
+ private async scan(): Promise<void> {
217
+ if (this.scanning || !this.ctx) return;
218
+ this.scanning = true;
219
+ const initialSeed = !this.seeded;
220
+
221
+ try {
222
+ let files: string[];
223
+ try {
224
+ files = await readdir(this.threadsDir);
225
+ } catch {
226
+ return;
227
+ }
228
+
229
+ const now = Date.now();
230
+ for (const file of files) {
231
+ if (!file.startsWith("T-") || !file.endsWith(".json")) continue;
232
+ const filePath = join(this.threadsDir, file);
233
+ let fileStat;
234
+ try {
235
+ fileStat = await stat(filePath);
236
+ } catch {
237
+ continue;
238
+ }
239
+ if (now - fileStat.mtimeMs > STALE_MS) continue;
240
+ await this.processThread(filePath);
241
+ }
242
+ } finally {
243
+ if (initialSeed) {
244
+ this.seeded = true;
245
+ for (const [threadId, snapshot] of this.threads) {
246
+ this.emitThread(threadId, snapshot);
247
+ }
248
+ }
249
+ this.scanning = false;
250
+ }
251
+ }
252
+
253
+ private setupWatch(): void {
254
+ try {
255
+ this.fsWatcher = watch(this.threadsDir, (_eventType, filename) => {
256
+ if (!filename?.startsWith("T-") || !filename.endsWith(".json")) return;
257
+ this.processThread(join(this.threadsDir, filename));
258
+ });
259
+ } catch {
260
+ // fs.watch failed; polling handles it
261
+ }
262
+ }
263
+
264
+ /** Watch Amp's session.json for lastThreadId changes — thread-level "seen" signal */
265
+ private setupSessionWatch(): void {
266
+ // Seed the initial focused thread
267
+ this.checkSessionFocus();
268
+
269
+ try {
270
+ this.sessionWatcher = watch(this.sessionFile, () => {
271
+ this.checkSessionFocus();
272
+ });
273
+ } catch {
274
+ // session.json doesn't exist yet or can't be watched; ignore
275
+ }
276
+ }
277
+
278
+ /** Read session.json and emit "idle" for a terminal thread the user has focused in Amp */
279
+ private async checkSessionFocus(): Promise<void> {
280
+ if (!this.ctx || !this.seeded) return;
281
+
282
+ try {
283
+ const raw = await Bun.file(this.sessionFile).text();
284
+ const session = JSON.parse(raw);
285
+ const threadId: string | undefined = session.lastThreadId;
286
+ if (!threadId || threadId === this.lastFocusedThread) return;
287
+
288
+ this.lastFocusedThread = threadId;
289
+
290
+ // If this thread is tracked and in a terminal state, the user just "saw" it
291
+ const snapshot = this.threads.get(threadId);
292
+ if (!snapshot || !snapshot.projectDir) return;
293
+ if (
294
+ snapshot.status !== "done" &&
295
+ snapshot.status !== "error" &&
296
+ snapshot.status !== "interrupted"
297
+ )
298
+ return;
299
+
300
+ const muxSession = this.ctx.resolveSession(snapshot.projectDir);
301
+ if (!muxSession || muxSession === "unknown") return;
302
+
303
+ // Emit "idle" to clear the unseen flag for this specific thread
304
+ this.ctx.emit({
305
+ agent: "amp",
306
+ session: muxSession,
307
+ status: "idle",
308
+ ts: Date.now(),
309
+ threadId,
310
+ threadName: snapshot.title,
311
+ });
312
+ } catch {
313
+ // session.json unreadable; ignore
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Claude Code agent watcher
3
+ *
4
+ * Watches ~/.claude/projects/ for JSONL file changes,
5
+ * determines agent status from journal entries, and emits events
6
+ * mapped to mux sessions via the project directory encoded in folder names.
7
+ *
8
+ * Directory structure: ~/.claude/projects/<encoded-path>/<session-id>.jsonl
9
+ * Encoded path: /Users/foo/myproject → -Users-foo-myproject
10
+ *
11
+ * All file I/O is async to avoid blocking the server event loop.
12
+ */
13
+
14
+ import { watch } from "node:fs";
15
+ import type { FSWatcher } from "node:fs";
16
+ import { readdir, stat } from "node:fs/promises";
17
+ import { join, basename } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import type { AgentStatus } from "../../contracts/agent";
20
+ import type { AgentWatcher, AgentWatcherContext } from "../../contracts/agent-watcher";
21
+
22
+ // --- Types ---
23
+
24
+ interface ContentItem {
25
+ type?: string;
26
+ text?: string;
27
+ }
28
+
29
+ interface JournalEntry {
30
+ type?: string;
31
+ message?: {
32
+ role?: string;
33
+ content?: ContentItem[] | string;
34
+ };
35
+ }
36
+
37
+ interface SessionState {
38
+ status: AgentStatus;
39
+ fileSize: number;
40
+ threadName?: string;
41
+ projectDir?: string;
42
+ }
43
+
44
+ const POLL_MS = 2000;
45
+ const STALE_MS = 5 * 60 * 1000;
46
+
47
+ // --- Status detection ---
48
+
49
+ export function determineStatus(entry: JournalEntry): AgentStatus {
50
+ const msg = entry.message;
51
+ if (!msg?.role) return "idle";
52
+
53
+ const content = msg.content;
54
+ const items: ContentItem[] = Array.isArray(content)
55
+ ? content
56
+ : typeof content === "string"
57
+ ? [{ type: "text", text: content }]
58
+ : [];
59
+
60
+ if (msg.role === "assistant") {
61
+ const hasToolUse = items.some((c) => c.type === "tool_use");
62
+ return hasToolUse ? "running" : "done";
63
+ }
64
+
65
+ if (msg.role === "user") return "running";
66
+
67
+ return "idle";
68
+ }
69
+
70
+ function extractThreadName(entry: JournalEntry): string | undefined {
71
+ const msg = entry.message;
72
+ if (msg?.role !== "user") return undefined;
73
+
74
+ const content = msg.content;
75
+ let text: string | undefined;
76
+
77
+ if (typeof content === "string") {
78
+ text = content;
79
+ } else if (Array.isArray(content)) {
80
+ text = content.find((c) => c.type === "text" && c.text)?.text;
81
+ }
82
+
83
+ if (!text) return undefined;
84
+ // Skip system/internal messages
85
+ if (text.startsWith("<") || text.startsWith("{")) return undefined;
86
+ return text.slice(0, 80);
87
+ }
88
+
89
+ /** Decode Claude's encoded project dir name back to a path.
90
+ * Claude Code encodes `/` as `-` with no escape for literal dashes,
91
+ * so paths like `/home/user/my-project` are ambiguous with `/home/user/my/project`.
92
+ * This is a known Claude Code limitation. */
93
+ function decodeProjectDir(encoded: string): string {
94
+ return encoded.replace(/-/g, "/");
95
+ }
96
+
97
+ // --- Watcher implementation ---
98
+
99
+ export class ClaudeCodeAgentWatcher implements AgentWatcher {
100
+ readonly name = "claude-code";
101
+
102
+ private sessions = new Map<string, SessionState>();
103
+ private fsWatchers: FSWatcher[] = [];
104
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
105
+ private ctx: AgentWatcherContext | null = null;
106
+ private projectsDir: string;
107
+ private scanning = false;
108
+ private seeded = false;
109
+
110
+ constructor() {
111
+ this.projectsDir = join(homedir(), ".claude", "projects");
112
+ }
113
+
114
+ start(ctx: AgentWatcherContext): void {
115
+ this.ctx = ctx;
116
+ this.setupWatchers();
117
+ setTimeout(() => this.scan(), 50);
118
+ this.pollTimer = setInterval(() => this.scan(), POLL_MS);
119
+ }
120
+
121
+ stop(): void {
122
+ for (const w of this.fsWatchers) {
123
+ try {
124
+ w.close();
125
+ } catch {}
126
+ }
127
+ this.fsWatchers = [];
128
+ if (this.pollTimer) {
129
+ clearInterval(this.pollTimer);
130
+ this.pollTimer = null;
131
+ }
132
+ this.ctx = null;
133
+ }
134
+
135
+ private async processFile(filePath: string, projectDir: string): Promise<void> {
136
+ if (!this.ctx) return;
137
+
138
+ let size: number;
139
+ try {
140
+ size = (await stat(filePath)).size;
141
+ } catch {
142
+ return;
143
+ }
144
+
145
+ const threadId = basename(filePath, ".jsonl");
146
+ const prev = this.sessions.get(threadId);
147
+
148
+ if (prev && size === prev.fileSize) return;
149
+
150
+ // Seed mode: read last entry to capture real status for post-seed emit
151
+ if (!this.seeded) {
152
+ let text: string;
153
+ try {
154
+ text = await Bun.file(filePath).text();
155
+ } catch {
156
+ return;
157
+ }
158
+
159
+ const lines = text.split("\n").filter(Boolean);
160
+ let latestStatus: AgentStatus = "idle";
161
+ let threadName: string | undefined;
162
+
163
+ for (const line of lines) {
164
+ let entry: JournalEntry;
165
+ try {
166
+ entry = JSON.parse(line);
167
+ } catch {
168
+ continue;
169
+ }
170
+ if (!threadName) {
171
+ const name = extractThreadName(entry);
172
+ if (name) threadName = name;
173
+ }
174
+ latestStatus = determineStatus(entry);
175
+ }
176
+
177
+ // If "running" but journal file is stale, the process likely exited
178
+ if (latestStatus === "running") {
179
+ try {
180
+ const mtime = (await stat(filePath)).mtimeMs;
181
+ if (Date.now() - mtime > 10_000) latestStatus = "idle";
182
+ } catch {}
183
+ }
184
+
185
+ this.sessions.set(threadId, { status: latestStatus, fileSize: size, threadName, projectDir });
186
+ return;
187
+ }
188
+
189
+ const offset = prev?.fileSize ?? 0;
190
+ if (size <= offset) return;
191
+
192
+ let text: string;
193
+ try {
194
+ const buf = await Bun.file(filePath).arrayBuffer();
195
+ text = new TextDecoder().decode(new Uint8Array(buf).subarray(offset, size));
196
+ } catch {
197
+ return;
198
+ }
199
+
200
+ const lines = text.split("\n").filter(Boolean);
201
+ let latestStatus: AgentStatus = prev?.status ?? "idle";
202
+ let threadName = prev?.threadName;
203
+
204
+ for (const line of lines) {
205
+ let entry: JournalEntry;
206
+ try {
207
+ entry = JSON.parse(line);
208
+ } catch {
209
+ continue;
210
+ }
211
+
212
+ if (!threadName) {
213
+ const name = extractThreadName(entry);
214
+ if (name) threadName = name;
215
+ }
216
+
217
+ latestStatus = determineStatus(entry);
218
+ }
219
+
220
+ const prevStatus = prev?.status;
221
+ this.sessions.set(threadId, { status: latestStatus, fileSize: size, threadName, projectDir });
222
+
223
+ if (latestStatus !== prevStatus) {
224
+ const session = this.ctx.resolveSession(projectDir);
225
+ if (session) {
226
+ this.ctx.emit({
227
+ agent: "claude-code",
228
+ session,
229
+ status: latestStatus,
230
+ ts: Date.now(),
231
+ threadId,
232
+ threadName,
233
+ });
234
+ }
235
+ }
236
+ }
237
+
238
+ private async scan(): Promise<void> {
239
+ if (this.scanning || !this.ctx) return;
240
+ this.scanning = true;
241
+
242
+ try {
243
+ let dirs: string[];
244
+ try {
245
+ dirs = await readdir(this.projectsDir);
246
+ } catch {
247
+ return;
248
+ }
249
+ const now = Date.now();
250
+
251
+ for (const dir of dirs) {
252
+ const dirPath = join(this.projectsDir, dir);
253
+ try {
254
+ if (!(await stat(dirPath)).isDirectory()) continue;
255
+ } catch {
256
+ continue;
257
+ }
258
+
259
+ const projectDir = decodeProjectDir(dir);
260
+
261
+ let files: string[];
262
+ try {
263
+ files = await readdir(dirPath);
264
+ } catch {
265
+ continue;
266
+ }
267
+
268
+ for (const file of files) {
269
+ if (!file.endsWith(".jsonl")) continue;
270
+ const filePath = join(dirPath, file);
271
+ let fileStat;
272
+ try {
273
+ fileStat = await stat(filePath);
274
+ } catch {
275
+ continue;
276
+ }
277
+ if (now - fileStat.mtimeMs > STALE_MS) continue;
278
+ // Lazily watch dirs that become active
279
+ this.watchDir(dirPath);
280
+ await this.processFile(filePath, projectDir);
281
+ }
282
+ }
283
+ } finally {
284
+ if (!this.seeded) {
285
+ this.seeded = true;
286
+ // Emit seeded sessions with non-idle status (like amp watcher does)
287
+ for (const [threadId, state] of this.sessions) {
288
+ if (state.status === "idle" || !state.projectDir) continue;
289
+ const session = this.ctx?.resolveSession(state.projectDir);
290
+ if (!session) continue;
291
+ this.ctx?.emit({
292
+ agent: "claude-code",
293
+ session,
294
+ status: state.status,
295
+ ts: Date.now(),
296
+ threadId,
297
+ threadName: state.threadName,
298
+ });
299
+ }
300
+ }
301
+ this.scanning = false;
302
+ }
303
+ }
304
+
305
+ private watchedDirs = new Set<string>();
306
+
307
+ private watchDir(dirPath: string): void {
308
+ if (this.watchedDirs.has(dirPath)) return;
309
+ const projectDir = decodeProjectDir(basename(dirPath));
310
+ try {
311
+ const w = watch(dirPath, (_eventType, filename) => {
312
+ if (!filename?.endsWith(".jsonl")) return;
313
+ this.processFile(join(dirPath, filename), projectDir);
314
+ });
315
+ this.fsWatchers.push(w);
316
+ this.watchedDirs.add(dirPath);
317
+ } catch {}
318
+ }
319
+
320
+ private hasRecentFiles(dirPath: string): boolean {
321
+ const fs = require("node:fs") as typeof import("node:fs");
322
+ try {
323
+ const files = fs.readdirSync(dirPath);
324
+ const now = Date.now();
325
+ for (const file of files) {
326
+ if (!file.endsWith(".jsonl")) continue;
327
+ try {
328
+ const s = fs.statSync(join(dirPath, file));
329
+ if (now - s.mtimeMs < STALE_MS) return true;
330
+ } catch {}
331
+ }
332
+ } catch {}
333
+ return false;
334
+ }
335
+
336
+ private setupWatchers(): void {
337
+ let dirs: string[];
338
+ try {
339
+ dirs = require("node:fs").readdirSync(this.projectsDir);
340
+ } catch {
341
+ return;
342
+ }
343
+
344
+ const fs = require("node:fs") as typeof import("node:fs");
345
+ for (const dir of dirs) {
346
+ const dirPath = join(this.projectsDir, dir);
347
+ try {
348
+ if (!fs.statSync(dirPath).isDirectory()) continue;
349
+ } catch {
350
+ continue;
351
+ }
352
+
353
+ // Only watch directories that have recently-modified files
354
+ if (this.hasRecentFiles(dirPath)) {
355
+ this.watchDir(dirPath);
356
+ }
357
+ }
358
+
359
+ // Watch projects dir for new project directories
360
+ try {
361
+ const w = watch(this.projectsDir, (eventType, filename) => {
362
+ if (eventType !== "rename" || !filename) return;
363
+ const dirPath = join(this.projectsDir, filename);
364
+ try {
365
+ if (!fs.statSync(dirPath).isDirectory()) return;
366
+ } catch {
367
+ return;
368
+ }
369
+ this.watchDir(dirPath);
370
+ });
371
+ this.fsWatchers.push(w);
372
+ } catch {}
373
+ }
374
+ }