@ohmaseclaro/fleetwatch 0.1.0

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.
@@ -0,0 +1,578 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import chokidar from "chokidar";
5
+ import { parseLineMulti, parseTitleLine } from "./jsonl.js";
6
+ import { attachmentStore } from "./attachmentStore.js";
7
+ import { readDelta, readEntireFile, readHeadTail, readTailLines } from "./tail.js";
8
+ import { decodeProjectDir } from "./projectPath.js";
9
+ import { BaseProvider } from "./providers/base.js";
10
+ const HOME = os.homedir();
11
+ /** Default location — most installs use this. */
12
+ export const CLAUDE_PROJECTS_DIR = path.join(HOME, ".claude", "projects");
13
+ export const COWORK_DIR = path.join(HOME, "Library", "Application Support", "Claude", "local-agent-mode-sessions");
14
+ export const HISTORY_FILE = path.join(HOME, ".claude", "history.jsonl");
15
+ /**
16
+ * Quick-and-cheap predicate: does a JSONL file look like a Claude-family
17
+ * session log (Claude Code OR Cowork — they share the format but differ in
18
+ * casing)? Reads at most the first KB and checks for shape markers:
19
+ *
20
+ * - `type: user/assistant/...` (always present)
21
+ * - `message: {...}` (the canonical event envelope) OR any of the
22
+ * known id fields (camelCase from Claude Code, snake_case from Cowork)
23
+ *
24
+ * False negatives (we don't recognize a real Claude file) are rare; false
25
+ * positives are unlikely because random JSONL files don't carry the exact
26
+ * type values we look for.
27
+ */
28
+ async function looksLikeClaudeJsonl(file) {
29
+ let fh = null;
30
+ try {
31
+ fh = await fs.open(file, "r");
32
+ const buf = Buffer.alloc(1024);
33
+ await fh.read(buf, 0, buf.length, 0);
34
+ const text = buf.toString("utf8");
35
+ const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
36
+ if (!firstLine.startsWith("{"))
37
+ return false;
38
+ const hasKnownType = /"type"\s*:\s*"(user|assistant|summary|tool_use|tool_result)"/.test(firstLine);
39
+ if (!hasKnownType)
40
+ return false;
41
+ // Accept any of the id-shape fields we've seen across Claude Code,
42
+ // Cowork, and skill subagent variants.
43
+ const hasIdMarker = /"message"\s*:/.test(firstLine) ||
44
+ /"parentUuid"/.test(firstLine) ||
45
+ /"promptId"/.test(firstLine) ||
46
+ /"sessionId"/.test(firstLine) ||
47
+ /"session_id"/.test(firstLine) ||
48
+ /"parent_uuid"/.test(firstLine) ||
49
+ /"parent_tool_use_id"/.test(firstLine);
50
+ return hasIdMarker;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ finally {
56
+ await fh?.close();
57
+ }
58
+ }
59
+ /**
60
+ * Verify a directory looks like a Claude/Cowork sessions root: contains at
61
+ * least one *.jsonl file (possibly nested) whose first line passes
62
+ * looksLikeClaudeJsonl. Walks only 3 levels deep to stay fast.
63
+ */
64
+ async function verifyClaudeStyleDir(dir, maxDepth = 3) {
65
+ const found = await findFirstJsonl(dir, maxDepth, 0);
66
+ if (!found)
67
+ return false;
68
+ return looksLikeClaudeJsonl(found);
69
+ }
70
+ async function findFirstJsonl(dir, maxDepth, depth) {
71
+ if (depth > maxDepth)
72
+ return null;
73
+ let entries;
74
+ try {
75
+ entries = (await fs.readdir(dir, { withFileTypes: true }));
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ for (const entry of entries) {
81
+ const full = path.join(dir, entry.name);
82
+ if (entry.isFile() && entry.name.endsWith(".jsonl"))
83
+ return full;
84
+ if (entry.isDirectory()) {
85
+ const sub = await findFirstJsonl(full, maxDepth, depth + 1);
86
+ if (sub)
87
+ return sub;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Tails Claude Code (~/.claude/projects) AND Cowork JSONL files. Both share
94
+ * the same on-disk format so they're handled by one provider — sessions
95
+ * are emitted with their correct `source` (claude-code | cowork) field.
96
+ */
97
+ export class Watcher extends BaseProvider {
98
+ info = {
99
+ id: "claude-code",
100
+ displayName: "Claude",
101
+ description: "Claude Code sessions in ~/.claude/projects (and Cowork sessions if present).",
102
+ accentColor: "var(--accent)",
103
+ };
104
+ files = new Map();
105
+ projectWatcher = null;
106
+ coworkWatcher = null;
107
+ historyWatcher = null;
108
+ historyState = null;
109
+ projectByPath = new Map();
110
+ excludePathPrefixes;
111
+ constructor(opts) {
112
+ super(opts);
113
+ this.excludePathPrefixes = opts.excludePathPrefixes ?? [];
114
+ }
115
+ /** Resolved at start() — null when the default isn't there and no fallback exists. */
116
+ claudeDir = null;
117
+ coworkDir = null;
118
+ historyFile = null;
119
+ async onStart() {
120
+ // --- Discover Claude Code projects dir ---------------------------------
121
+ this.claudeDir = await this.discover({
122
+ label: "Claude projects dir",
123
+ candidates: [
124
+ process.env.CLAUDE_PROJECTS_DIR,
125
+ CLAUDE_PROJECTS_DIR, // ~/.claude/projects (default)
126
+ path.join(HOME, ".config", "claude", "projects"), // XDG-ish (Linux)
127
+ path.join(HOME, "Library", "Application Support", "Claude", "projects"),
128
+ ],
129
+ searchRoots: [HOME],
130
+ searchName: "projects",
131
+ pathMustContain: ".claude",
132
+ // Generous depth: handle non-standard installs like
133
+ // ~/Workspaces/something/.claude/projects without being so wide that
134
+ // we walk huge user trees.
135
+ searchMaxDepth: 6,
136
+ verify: async (p) => {
137
+ // Must be a directory AND look like Claude session data.
138
+ try {
139
+ const st = await fs.stat(p);
140
+ if (!st.isDirectory())
141
+ return false;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ return verifyClaudeStyleDir(p);
147
+ },
148
+ });
149
+ if (this.claudeDir) {
150
+ await fs.mkdir(this.claudeDir, { recursive: true }).catch(() => { });
151
+ await this.initialScan(this.claudeDir, "claude-code");
152
+ }
153
+ else {
154
+ this.log(`no Claude projects dir found — Claude Code sessions disabled`);
155
+ }
156
+ // --- Discover Cowork dir (always optional) -----------------------------
157
+ this.coworkDir = await this.discover({
158
+ label: "Cowork sessions dir",
159
+ candidates: [
160
+ process.env.COWORK_DIR,
161
+ COWORK_DIR,
162
+ path.join(HOME, ".config", "claude", "local-agent-mode-sessions"),
163
+ ],
164
+ searchRoots: [
165
+ path.join(HOME, "Library", "Application Support"),
166
+ path.join(HOME, ".config"),
167
+ ],
168
+ searchName: "local-agent-mode-sessions",
169
+ pathMustContain: "local-agent-mode-sessions",
170
+ searchMaxDepth: 5,
171
+ verify: async (p) => {
172
+ try {
173
+ const st = await fs.stat(p);
174
+ if (!st.isDirectory())
175
+ return false;
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ // Cowork shares Claude's JSONL format — same verifier. The
181
+ // pathMustContain constraint above is what keeps us from picking up
182
+ // Claude Code's dir by mistake. Cowork nests deeper than Claude
183
+ // (install-id/install-id/local_*/<uuid>.jsonl), so allow more depth.
184
+ return verifyClaudeStyleDir(p, 5);
185
+ },
186
+ });
187
+ if (this.coworkDir) {
188
+ await this.initialScan(this.coworkDir, "cowork");
189
+ }
190
+ // --- Discover the history.jsonl file (cwd → preview ordering) ----------
191
+ const defaultHistory = this.claudeDir
192
+ ? path.join(path.dirname(this.claudeDir), "history.jsonl")
193
+ : HISTORY_FILE;
194
+ this.historyFile = await this.discover({
195
+ label: "Claude history.jsonl",
196
+ candidates: [
197
+ process.env.CLAUDE_HISTORY_FILE,
198
+ defaultHistory,
199
+ HISTORY_FILE,
200
+ ],
201
+ verify: async (p) => {
202
+ try {
203
+ const st = await fs.stat(p);
204
+ return st.isFile();
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ },
210
+ });
211
+ if (this.historyFile) {
212
+ await this.primeHistory();
213
+ }
214
+ // --- Wire chokidar watchers for everything we discovered ---------------
215
+ if (this.claudeDir) {
216
+ this.projectWatcher = chokidar
217
+ .watch(this.claudeDir, {
218
+ ignored: (p) => p.endsWith(".tmp") || p.includes("/.DS_Store"),
219
+ persistent: true,
220
+ ignoreInitial: true,
221
+ depth: 10,
222
+ })
223
+ .on("add", (p) => this.onFileChange(p, "claude-code", "add"))
224
+ .on("change", (p) => this.onFileChange(p, "claude-code", "change"))
225
+ .on("unlink", (p) => this.onFileRemoved(p))
226
+ .on("error", (err) => this.log(`watcher error: ${err.message}`));
227
+ }
228
+ if (this.coworkDir) {
229
+ this.coworkWatcher = chokidar
230
+ .watch(this.coworkDir, {
231
+ ignored: (p) => p.endsWith(".tmp") || p.includes("/.DS_Store"),
232
+ persistent: true,
233
+ ignoreInitial: true,
234
+ depth: 10,
235
+ })
236
+ .on("add", (p) => this.onFileChange(p, "cowork", "add"))
237
+ .on("change", (p) => this.onFileChange(p, "cowork", "change"))
238
+ .on("error", (err) => this.log(`cowork watch error: ${err.message}`));
239
+ }
240
+ if (this.historyFile) {
241
+ this.historyWatcher = chokidar
242
+ .watch(this.historyFile, {
243
+ persistent: true,
244
+ ignoreInitial: true,
245
+ })
246
+ .on("add", () => this.readHistoryDelta())
247
+ .on("change", () => this.readHistoryDelta())
248
+ .on("error", (err) => this.log(`history watch error: ${err.message}`));
249
+ }
250
+ // 1s tick for status recompute (handles idle / awaiting-user transitions)
251
+ setInterval(() => this.registry.recomputeAll(), 1000).unref();
252
+ // If we found absolutely nothing, surface that clearly to the operator.
253
+ if (!this.claudeDir && !this.coworkDir) {
254
+ this.skipStartup("no Claude or Cowork data found in any candidate location");
255
+ }
256
+ }
257
+ async onStop() {
258
+ await this.projectWatcher?.close();
259
+ await this.coworkWatcher?.close();
260
+ await this.historyWatcher?.close();
261
+ }
262
+ async initialScan(root, source) {
263
+ const found = [];
264
+ await walk(root, found);
265
+ for (const file of found) {
266
+ if (!file.endsWith(".jsonl"))
267
+ continue;
268
+ try {
269
+ await this.onFileChange(file, source, "initial");
270
+ }
271
+ catch (err) {
272
+ this.log(`[watcher initial] ${file}: ${err.message}`);
273
+ }
274
+ }
275
+ }
276
+ async primeHistory() {
277
+ if (!this.historyFile)
278
+ return;
279
+ try {
280
+ const { state, lines } = await readEntireFile(this.historyFile);
281
+ this.historyState = state;
282
+ for (const line of lines)
283
+ this.consumeHistoryLine(line);
284
+ }
285
+ catch {
286
+ // history file may not exist on a fresh machine
287
+ this.historyState = null;
288
+ }
289
+ }
290
+ async readHistoryDelta() {
291
+ if (!this.historyFile)
292
+ return;
293
+ if (!this.historyState) {
294
+ await this.primeHistory();
295
+ return;
296
+ }
297
+ try {
298
+ const { newState, lines, rotated } = await readDelta(this.historyFile, this.historyState);
299
+ this.historyState = newState;
300
+ const toConsume = rotated ? lines : lines;
301
+ for (const line of toConsume)
302
+ this.consumeHistoryLine(line);
303
+ }
304
+ catch (err) {
305
+ this.log(`history readDelta failed: ${err.message}`);
306
+ }
307
+ }
308
+ consumeHistoryLine(line) {
309
+ if (!line)
310
+ return;
311
+ let raw;
312
+ try {
313
+ raw = JSON.parse(line);
314
+ }
315
+ catch {
316
+ return;
317
+ }
318
+ if (typeof raw !== "object" || raw === null)
319
+ return;
320
+ if (typeof raw.display !== "string" || typeof raw.timestamp !== "number")
321
+ return;
322
+ const sessionId = typeof raw.sessionId === "string" ? raw.sessionId : null;
323
+ const project = typeof raw.project === "string" ? raw.project : null;
324
+ if (!sessionId || !project)
325
+ return;
326
+ // Skip slash commands as user-message-equivalents
327
+ const display = raw.display.trim();
328
+ if (display.startsWith("/"))
329
+ return;
330
+ const preview = display.length > 120 ? display.slice(0, 117) + "…" : display;
331
+ this.registry.setUserMessageFromHistory(project, sessionId, raw.timestamp, preview);
332
+ }
333
+ async onFileChange(filePath, source, reason) {
334
+ if (!filePath.endsWith(".jsonl"))
335
+ return;
336
+ if (this.shouldExclude(filePath))
337
+ return;
338
+ const sessionId = path.basename(filePath, ".jsonl");
339
+ if (!isUuid(sessionId))
340
+ return;
341
+ // Determine project info.
342
+ const projectInfo = this.projectInfoForFile(filePath, source);
343
+ if (this.excludePathPrefixes.some((p) => projectInfo.projectPath.startsWith(p)))
344
+ return;
345
+ const isSubagent = filePath.includes("/subagents/");
346
+ const parentSessionId = isSubagent ? extractParentSessionId(filePath) : undefined;
347
+ this.registry.upsertMeta(sessionId, {
348
+ filePath,
349
+ projectPath: projectInfo.projectPath,
350
+ projectLabel: projectInfo.projectLabel,
351
+ source,
352
+ isSubagent,
353
+ parentSessionId,
354
+ });
355
+ let entry = this.files.get(filePath);
356
+ if (!entry) {
357
+ entry = {
358
+ filePath,
359
+ sessionId,
360
+ source,
361
+ projectPath: projectInfo.projectPath,
362
+ projectLabel: projectInfo.projectLabel,
363
+ isSubagent,
364
+ parentSessionId,
365
+ state: null,
366
+ pending: false,
367
+ queued: false,
368
+ };
369
+ this.files.set(filePath, entry);
370
+ }
371
+ if (entry.pending) {
372
+ entry.queued = true;
373
+ return;
374
+ }
375
+ entry.pending = true;
376
+ try {
377
+ do {
378
+ entry.queued = false;
379
+ await this.consumeFile(entry, reason === "initial");
380
+ } while (entry.queued);
381
+ }
382
+ finally {
383
+ entry.pending = false;
384
+ }
385
+ }
386
+ /**
387
+ * Fast backfill: read only the last 200 lines of the JSONL file instead of
388
+ * the whole file. This makes opening a session instant regardless of file size.
389
+ * The ring buffer holds 800 events so 200 raw lines gives plenty of headroom
390
+ * for multi-event lines and filtered-out metadata.
391
+ */
392
+ async backfillSession(sessionId) {
393
+ let filePath;
394
+ for (const [fp, entry] of this.files) {
395
+ if (entry.sessionId === sessionId) {
396
+ filePath = fp;
397
+ break;
398
+ }
399
+ }
400
+ if (!filePath)
401
+ return;
402
+ const fileEntry = this.files.get(filePath);
403
+ if (!fileEntry)
404
+ return;
405
+ try {
406
+ const { lines, state } = await readTailLines(filePath, 200);
407
+ // Update the tail state so subsequent readDelta calls are correct.
408
+ fileEntry.state = state;
409
+ for (const line of lines)
410
+ this.dispatchLine(fileEntry.sessionId, line);
411
+ }
412
+ catch (err) {
413
+ this.log(`[watcher backfill] ${filePath}: ${err.message}`);
414
+ }
415
+ }
416
+ async consumeFile(entry, initial) {
417
+ if (entry.state === null) {
418
+ try {
419
+ if (initial) {
420
+ // Metadata-only scan: just enough lines to derive status + title.
421
+ // Avoids loading megabytes from old sessions on startup.
422
+ const { headLines, tailLines, state } = await readHeadTail(entry.filePath, 20, 40);
423
+ entry.state = state;
424
+ // Parse head for title / metadata (no events buffered unless subscribed)
425
+ for (const line of headLines)
426
+ this.dispatchLine(entry.sessionId, line, true);
427
+ // Parse tail for status derivation
428
+ for (const line of tailLines)
429
+ this.dispatchLine(entry.sessionId, line, true);
430
+ }
431
+ else {
432
+ const { state, lines } = await readEntireFile(entry.filePath);
433
+ entry.state = state;
434
+ for (const line of lines)
435
+ this.dispatchLine(entry.sessionId, line);
436
+ }
437
+ }
438
+ catch (err) {
439
+ this.log(`[watcher] failed to read ${entry.filePath}: ${err.message}`);
440
+ }
441
+ return;
442
+ }
443
+ try {
444
+ const { newState, lines } = await readDelta(entry.filePath, entry.state);
445
+ entry.state = newState;
446
+ for (const line of lines)
447
+ this.dispatchLine(entry.sessionId, line);
448
+ }
449
+ catch (err) {
450
+ this.log(`[watcher] failed to tail ${entry.filePath}: ${err.message}`);
451
+ }
452
+ }
453
+ dispatchLine(sessionId, line, metaOnly = false) {
454
+ // Always check for title metadata lines.
455
+ const titlePatch = parseTitleLine(line);
456
+ if (titlePatch) {
457
+ this.registry.setTitle(sessionId, titlePatch);
458
+ return; // title lines are metadata only, not events
459
+ }
460
+ const events = parseLineMulti(line, sessionId, {
461
+ storeImage: (buf, mediaType) => attachmentStore.put(buf, mediaType),
462
+ });
463
+ for (const ev of events) {
464
+ // Capture cwd / git branch from raw line for richer metadata.
465
+ try {
466
+ const raw = JSON.parse(line);
467
+ if (typeof raw?.cwd === "string" && raw.cwd.length > 0) {
468
+ const meta = this.registry.get(sessionId);
469
+ const fileEntry = Array.from(this.files.values()).find((e) => e.sessionId === sessionId);
470
+ if (meta && meta.projectPath !== raw.cwd) {
471
+ const label = labelFromPath(raw.cwd);
472
+ this.registry.upsertMeta(sessionId, {
473
+ filePath: fileEntry?.filePath ?? "",
474
+ projectPath: raw.cwd,
475
+ projectLabel: label,
476
+ source: meta.source,
477
+ isSubagent: meta.isSubagent,
478
+ parentSessionId: meta.parentSessionId,
479
+ gitBranch: typeof raw.gitBranch === "string" ? raw.gitBranch : undefined,
480
+ });
481
+ }
482
+ else if (meta && typeof raw.gitBranch === "string" && meta.gitBranch !== raw.gitBranch) {
483
+ this.registry.upsertMeta(sessionId, {
484
+ filePath: fileEntry?.filePath ?? meta.projectPath,
485
+ projectPath: meta.projectPath,
486
+ projectLabel: meta.projectLabel,
487
+ source: meta.source,
488
+ isSubagent: meta.isSubagent,
489
+ parentSessionId: meta.parentSessionId,
490
+ gitBranch: raw.gitBranch,
491
+ });
492
+ }
493
+ }
494
+ }
495
+ catch { }
496
+ // appendEvent is a no-op for unsubscribed sessions (no event buffering).
497
+ this.registry.appendEvent(sessionId, ev);
498
+ }
499
+ }
500
+ onFileRemoved(filePath) {
501
+ const entry = this.files.get(filePath);
502
+ if (!entry)
503
+ return;
504
+ this.files.delete(filePath);
505
+ this.registry.remove(entry.sessionId);
506
+ }
507
+ projectInfoForFile(filePath, source) {
508
+ if (source === "claude-code") {
509
+ // Decode the encoded project segment relative to the discovered Claude
510
+ // projects dir (or the default constant when discovery hasn't run yet).
511
+ const root = this.claudeDir ?? CLAUDE_PROJECTS_DIR;
512
+ const rel = path.relative(root, filePath);
513
+ const encoded = rel.split(path.sep)[0];
514
+ if (this.projectByPath.has(encoded))
515
+ return this.projectByPath.get(encoded);
516
+ const info = decodeProjectDir(encoded);
517
+ this.projectByPath.set(encoded, info);
518
+ return info;
519
+ }
520
+ // Cowork: path is e.g. <coworkDir>/<install-id>/<install-id>/local_<id>/<uuid>.jsonl
521
+ // Use the deepest folder name as label.
522
+ const dir = path.dirname(filePath);
523
+ const folder = path.basename(dir);
524
+ return { projectPath: dir, projectLabel: `cowork / ${folder}` };
525
+ }
526
+ shouldExclude(filePath) {
527
+ if (this.excludePathPrefixes.length === 0)
528
+ return false;
529
+ return this.excludePathPrefixes.some((p) => filePath.startsWith(p));
530
+ }
531
+ }
532
+ function isUuid(s) {
533
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
534
+ }
535
+ function extractParentSessionId(filePath) {
536
+ // .../<parentUuid>/subagents/agent-<uuid>.jsonl (heuristic)
537
+ const parts = filePath.split(path.sep);
538
+ const idx = parts.lastIndexOf("subagents");
539
+ if (idx > 0) {
540
+ const candidate = parts[idx - 1];
541
+ if (isUuid(candidate))
542
+ return candidate;
543
+ }
544
+ return undefined;
545
+ }
546
+ function labelFromPath(p) {
547
+ const segs = p.split("/").filter(Boolean);
548
+ if (segs.length >= 2)
549
+ return `${segs[segs.length - 2]} / ${segs[segs.length - 1]}`;
550
+ return segs[segs.length - 1] ?? p;
551
+ }
552
+ async function stat(p) {
553
+ try {
554
+ return await fs.stat(p);
555
+ }
556
+ catch {
557
+ return null;
558
+ }
559
+ }
560
+ async function walk(root, out) {
561
+ let entries;
562
+ try {
563
+ entries = (await fs.readdir(root, { withFileTypes: true }));
564
+ }
565
+ catch {
566
+ return;
567
+ }
568
+ for (const entry of entries) {
569
+ const full = path.join(root, String(entry.name));
570
+ if (entry.isDirectory()) {
571
+ await walk(full, out);
572
+ }
573
+ else if (entry.isFile()) {
574
+ out.push(full);
575
+ }
576
+ }
577
+ }
578
+ //# sourceMappingURL=watcher.js.map
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--font-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.bottom-6{bottom:1.5rem}.left-0{left:0}.left-1{left:.25rem}.right-0{right:0}.right-3{right:.75rem}.right-6{right:1.5rem}.top-0{top:0}.top-1{top:.25rem}.top-3{top:.75rem}.z-20{z-index:20}.z-50{z-index:50}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-2{height:.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.max-h-\[85vh\]{max-height:85vh}.min-h-full{min-height:100%}.w-1{width:.25rem}.w-2{width:.5rem}.w-5{width:1.25rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[88\%\]{max-width:88%}.max-w-\[92\%\]{max-width:92%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:var(--radius)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:.375rem}.rounded-t-2xl{border-top-left-radius:1rem;border-top-right-radius:1rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.p-5{padding:1.25rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-0\.5{padding-bottom:.125rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-2\.5{padding-bottom:.625rem}.pb-4{padding-bottom:1rem}.pl-3{padding-left:.75rem}.pr-4{padding-right:1rem}.pt-1{padding-top:.25rem}.pt-16{padding-top:4rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.underline{text-decoration-line:underline}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--bg: #1F1D18;--bg-elevated: #26241E;--bg-subtle: #2B2922;--text: #F0EEE6;--text-muted: #B5AE9D;--text-faint: #80796A;--accent: #D97757;--accent-soft: #4A2E22;--accent-bg: #3A2A22;--border: #38352D;--border-strong: #4A463C;--status-running: #4ADE80;--status-tool: #FACC15;--status-waiting: #94A3B8;--status-idle: #80796A;--status-error: #F87171;--radius-sm: 8px;--radius: 12px;--radius-lg: 16px;--shadow-sm: 0 1px 3px rgba(0, 0, 0, .3);--shadow: 0 2px 16px rgba(0, 0, 0, .4);--font-sans: "Inter", -apple-system, "SF Pro Text", system-ui, sans-serif;--font-serif: "Tiempos Text", Georgia, serif;--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace}@media (prefers-color-scheme: light){:root{--bg: #FAF9F5;--bg-elevated: #FFFFFF;--bg-subtle: #F0EEE6;--text: #3D3929;--text-muted: #6B6555;--text-faint: #9B9486;--accent: #C96442;--accent-soft: #E7B9A5;--accent-bg: #FBEFE9;--border: #E6E2D6;--border-strong: #C9C3B3;--status-running: #2F855A;--status-tool: #B7791F;--status-waiting: #4A5568;--status-idle: #9B9486;--status-error: #C53030;--shadow-sm: 0 1px 2px rgba(45, 38, 25, .04);--shadow: 0 2px 12px rgba(45, 38, 25, .08)}}*{box-sizing:border-box}html,body,#root{margin:0;padding:0;min-height:100%;height:100%}body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overscroll-behavior-y:contain;-webkit-tap-highlight-color:transparent}#root{display:flex;flex-direction:column}.safe-top{padding-top:max(env(safe-area-inset-top,0px),.5rem)}.safe-bottom{padding-bottom:max(env(safe-area-inset-bottom,0px),.5rem)}.safe-x{padding-left:env(safe-area-inset-left,0px);padding-right:env(safe-area-inset-right,0px)}@keyframes pulse-dot{0%,to{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.8)}}.pulse-dot{animation:pulse-dot 1.4s ease-in-out infinite}@keyframes slide-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.slide-in{animation:slide-in .2s ease-out}button,a{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:4px}::-webkit-scrollbar-track{background:transparent}.no-scrollbar{scrollbar-width:none;-ms-overflow-style:none}.no-scrollbar::-webkit-scrollbar{display:none}input:focus,textarea:focus,button:focus-visible{outline:none;border-color:var(--accent)!important;box-shadow:0 0 0 2px var(--accent-bg)}.prose-claude{font-family:var(--font-serif);line-height:1.55;font-size:.95rem;white-space:pre-wrap;word-break:break-word}.prose-claude code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-subtle);padding:1px 5px;border-radius:4px}.prose-claude pre{background:var(--bg-subtle);padding:10px 12px;border-radius:var(--radius-sm);overflow-x:auto;font-size:.82rem;margin:6px 0}.hover\:opacity-80:hover{opacity:.8}.focus\:border-accent:focus{border-color:var(--accent)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.active\:bg-bg-subtle:active{background-color:var(--bg-subtle)}@media (min-width: 640px){.sm\:max-w-md{max-width:28rem}.sm\:items-center{align-items:center}.sm\:rounded-2xl{border-radius:1rem}}