@towles/tool 0.0.106 → 0.0.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +2 -1
- package/plugins/tt-agentboard/README.md +160 -0
- package/plugins/tt-agentboard/apps/server/package.json +20 -0
- package/plugins/tt-agentboard/apps/server/src/main.ts +60 -0
- package/plugins/tt-agentboard/apps/tui/build.ts +11 -0
- package/plugins/tt-agentboard/apps/tui/bunfig.toml +1 -0
- package/plugins/tt-agentboard/apps/tui/package.json +23 -0
- package/plugins/tt-agentboard/apps/tui/scripts/sessionizer.sh +36 -0
- package/plugins/tt-agentboard/apps/tui/src/components/DetailPanel.tsx +350 -0
- package/plugins/tt-agentboard/apps/tui/src/components/DiffStats.tsx +33 -0
- package/plugins/tt-agentboard/apps/tui/src/components/SessionCard.tsx +177 -0
- package/plugins/tt-agentboard/apps/tui/src/components/StatusBar.tsx +49 -0
- package/plugins/tt-agentboard/apps/tui/src/constants.ts +46 -0
- package/plugins/tt-agentboard/apps/tui/src/detail-panel-height.ts +21 -0
- package/plugins/tt-agentboard/apps/tui/src/index.tsx +880 -0
- package/plugins/tt-agentboard/apps/tui/src/mux-context.ts +61 -0
- package/plugins/tt-agentboard/apps/tui/tsconfig.json +15 -0
- package/plugins/tt-agentboard/bun.lock +444 -0
- package/plugins/tt-agentboard/package.json +26 -0
- package/plugins/tt-agentboard/packages/mux-tmux/package.json +14 -0
- package/plugins/tt-agentboard/packages/mux-tmux/src/client.ts +550 -0
- package/plugins/tt-agentboard/packages/mux-tmux/src/index.ts +18 -0
- package/plugins/tt-agentboard/packages/mux-tmux/src/provider.ts +259 -0
- package/plugins/tt-agentboard/packages/mux-tmux/tsconfig.json +13 -0
- package/plugins/tt-agentboard/packages/runtime/package.json +14 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/tracker.ts +233 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/amp.ts +316 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/claude-code.ts +374 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/codex.ts +364 -0
- package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/opencode.ts +249 -0
- package/plugins/tt-agentboard/packages/runtime/src/config.ts +70 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/agent-watcher.ts +38 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/agent.ts +16 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/index.ts +3 -0
- package/plugins/tt-agentboard/packages/runtime/src/contracts/mux.ts +148 -0
- package/plugins/tt-agentboard/packages/runtime/src/debug.ts +19 -0
- package/plugins/tt-agentboard/packages/runtime/src/index.ts +69 -0
- package/plugins/tt-agentboard/packages/runtime/src/mux/detect.ts +20 -0
- package/plugins/tt-agentboard/packages/runtime/src/mux/registry.ts +45 -0
- package/plugins/tt-agentboard/packages/runtime/src/plugins/loader.ts +152 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/context.ts +112 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/git-info.ts +164 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/index.ts +1753 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/launcher.ts +71 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/metadata-store.ts +86 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/pane-scanner.ts +327 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/port-scanner.ts +155 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/session-order.ts +127 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-manager.ts +232 -0
- package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-width-sync.ts +66 -0
- package/plugins/tt-agentboard/packages/runtime/src/shared.ts +179 -0
- package/plugins/tt-agentboard/packages/runtime/src/themes.ts +750 -0
- package/plugins/tt-agentboard/packages/runtime/test/config.test.ts +83 -0
- package/plugins/tt-agentboard/packages/runtime/test/tracker.test.ts +172 -0
- package/plugins/tt-agentboard/packages/runtime/tsconfig.json +13 -0
- package/plugins/tt-agentboard/tsconfig.json +19 -0
- package/plugins/tt-auto-claude/.claude-plugin/plugin.json +8 -0
- package/plugins/tt-auto-claude/commands/create-issue.md +20 -0
- package/plugins/tt-auto-claude/commands/list.md +21 -0
- package/plugins/tt-auto-claude/skills/auto-claude/SKILL.md +71 -0
- package/plugins/tt-core/.claude-plugin/plugin.json +8 -0
- package/plugins/tt-core/README.md +18 -0
- package/plugins/tt-core/commands/improve-architecture.md +66 -0
- package/plugins/tt-core/commands/interview-me.md +38 -0
- package/plugins/tt-core/commands/prd-to-issues.md +49 -0
- package/plugins/tt-core/commands/refine-text.md +30 -0
- package/plugins/tt-core/commands/task.md +37 -0
- package/plugins/tt-core/commands/tdd.md +69 -0
- package/plugins/tt-core/commands/write-prd.md +69 -0
- package/plugins/tt-core/promptfooconfig.interview-me.yaml +155 -0
- package/plugins/tt-core/promptfooconfig.refine-text.yaml +242 -0
- package/plugins/tt-core/promptfooconfig.tdd.yaml +144 -0
- package/plugins/tt-core/promptfooconfig.write-prd.yaml +145 -0
- package/plugins/tt-core/skills/towles-tool/SKILL.md +35 -0
- package/src/commands/agentboard.ts +19 -2
|
@@ -0,0 +1,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
|
+
}
|