@famonfa/mono 0.0.0-stage → 0.3.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.
Files changed (64) hide show
  1. package/bin/mono.mjs +211 -0
  2. package/node_modules/@anthropic-ai/claude-agent-sdk/LICENSE.md +1 -0
  3. package/node_modules/@anthropic-ai/claude-agent-sdk/README.md +69 -0
  4. package/node_modules/@anthropic-ai/claude-agent-sdk/agentSdkTypes.d.ts +1 -0
  5. package/node_modules/@anthropic-ai/claude-agent-sdk/bridge.d.ts +396 -0
  6. package/node_modules/@anthropic-ai/claude-agent-sdk/bridge.mjs +142 -0
  7. package/node_modules/@anthropic-ai/claude-agent-sdk/browser-sdk.d.ts +208 -0
  8. package/node_modules/@anthropic-ai/claude-agent-sdk/browser-sdk.js +145 -0
  9. package/node_modules/@anthropic-ai/claude-agent-sdk/core-36zyfan5.mjs +52 -0
  10. package/node_modules/@anthropic-ai/claude-agent-sdk/core-5682eevd.mjs +27 -0
  11. package/node_modules/@anthropic-ai/claude-agent-sdk/core-nne0e2ds.mjs +40 -0
  12. package/node_modules/@anthropic-ai/claude-agent-sdk/core-xpxe8nzb.mjs +26 -0
  13. package/node_modules/@anthropic-ai/claude-agent-sdk/core.d.ts +22 -0
  14. package/node_modules/@anthropic-ai/claude-agent-sdk/core.mjs +34 -0
  15. package/node_modules/@anthropic-ai/claude-agent-sdk/extractFromBunfs.d.ts +1 -0
  16. package/node_modules/@anthropic-ai/claude-agent-sdk/extractFromBunfs.js +156 -0
  17. package/node_modules/@anthropic-ai/claude-agent-sdk/manifest.json +82 -0
  18. package/node_modules/@anthropic-ai/claude-agent-sdk/manifest.zst.json +90 -0
  19. package/node_modules/@anthropic-ai/claude-agent-sdk/package.json +94 -0
  20. package/node_modules/@anthropic-ai/claude-agent-sdk/sdk-tools.d.ts +4222 -0
  21. package/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts +10277 -0
  22. package/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs +169 -0
  23. package/node_modules/ws/LICENSE +20 -0
  24. package/node_modules/ws/README.md +548 -0
  25. package/node_modules/ws/browser.js +8 -0
  26. package/node_modules/ws/index.js +22 -0
  27. package/node_modules/ws/lib/buffer-util.js +131 -0
  28. package/node_modules/ws/lib/constants.js +19 -0
  29. package/node_modules/ws/lib/event-target.js +292 -0
  30. package/node_modules/ws/lib/extension.js +203 -0
  31. package/node_modules/ws/lib/limiter.js +55 -0
  32. package/node_modules/ws/lib/permessage-deflate.js +530 -0
  33. package/node_modules/ws/lib/receiver.js +743 -0
  34. package/node_modules/ws/lib/sender.js +607 -0
  35. package/node_modules/ws/lib/stream.js +161 -0
  36. package/node_modules/ws/lib/subprotocol.js +62 -0
  37. package/node_modules/ws/lib/validation.js +152 -0
  38. package/node_modules/ws/lib/websocket-server.js +562 -0
  39. package/node_modules/ws/lib/websocket.js +1421 -0
  40. package/node_modules/ws/package.json +74 -0
  41. package/node_modules/ws/wrapper.mjs +21 -0
  42. package/package.json +126 -3
  43. package/skills/workspace/SKILL.md +108 -0
  44. package/src/api.mjs +298 -0
  45. package/src/chats.mjs +446 -0
  46. package/src/commands.mjs +100 -0
  47. package/src/groups.mjs +84 -0
  48. package/src/launcher.mjs +276 -0
  49. package/src/main.mjs +42 -0
  50. package/src/paths.mjs +44 -0
  51. package/src/projects.mjs +199 -0
  52. package/src/refresh.mjs +124 -0
  53. package/src/remote.mjs +137 -0
  54. package/src/sessions.mjs +422 -0
  55. package/src/store.mjs +253 -0
  56. package/web/apple-touch-icon.png +0 -0
  57. package/web/assets/index-DGdmgZ2P.css +1 -0
  58. package/web/assets/index-TOP7citk.js +55 -0
  59. package/web/icon-192.png +0 -0
  60. package/web/icon-512.png +0 -0
  61. package/web/icon.svg +1 -0
  62. package/web/index.html +19 -0
  63. package/web/manifest.webmanifest +13 -0
  64. package/README.md +0 -3
@@ -0,0 +1,124 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import * as store from "./store.mjs";
6
+
7
+ const HOME = os.homedir();
8
+ const RUN_DIR = path.join(os.tmpdir(), "ws-refresh");
9
+ const JOBS_DIR = path.join(store.DATA_DIR, ".jobs");
10
+ const PATH = [path.join(HOME, ".local/bin"), path.join(HOME, ".npm-global/bin"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", process.env.PATH].join(":");
11
+
12
+ /**
13
+ * Read-mostly allowlist: the headless session can inspect PRs and load workspace data through `ws`,
14
+ * but cannot change repos. acceptEdits limits file writes to its own temp working directory.
15
+ */
16
+ const ALLOWED_TOOLS = [
17
+ "Bash(ws:*)",
18
+ "Bash(gh pr view:*)",
19
+ "Bash(gh pr checks:*)",
20
+ "Bash(gh pr diff:*)",
21
+ "Bash(git log:*)",
22
+ "Bash(git diff:*)",
23
+ "Bash(git show:*)",
24
+ "Read",
25
+ "Glob",
26
+ "Grep",
27
+ "Skill",
28
+ ];
29
+
30
+ function prompt(task, since) {
31
+ return [
32
+ `Actualizá la tarea ${task.id} ("${task.title}") de Mono usando el skill workspace en modo actualización.`,
33
+ `1. Corré \`ws show ${task.id}\` y \`ws get ${task.id} <board>\` para ver cada board; reusá los ids de nodos existentes.`,
34
+ `2. La fuente principal es lo que hicieron los agentes: corré \`ws sessions ${task.id}${since ? ` --since ${since}` : ""}\` para ver la actividad reciente de las sesiones y subagentes vinculados.`,
35
+ "3. Actualizá sobre todo el board de testeo con resultados reales que aparezcan ahí (pass/fail solo con evidencia; lo demás queda todo). Si aparecen bugs o hallazgos nuevos, agregalos como notas o tarjetas con status risk. Actualizá estado, TL;DR o resumen solo si cambiaron de verdad.",
36
+ "4. Board `ui`: corré `gh pr diff` de los PRs de la tarea. Si el diff toca archivos de UI (.tsx, .css, .css.ts, pantallas/componentes) y la tarea NO tiene un board `ui`, crealo (layout lanes, una lane por pantalla o estado). Usá imágenes ya existentes en los assets de la tarea (`ws show`) como nodos image; si no hay capturas, describí cada pantalla/estado con nodos card (new/changed) a partir del diff e indicá que faltan capturas. Si el board `ui` ya existe, solo actualizalo con --patch. Si el diff no toca UI, no lo crees.",
37
+ "5. Escribí los JSON en el directorio actual. Los boards existentes cargalos SIEMPRE con `ws board <tarea> <board> <archivo> --patch` (agrega/actualiza, nunca borra nodos existentes; mandá solo los nodos nuevos o cambiados); un board nuevo se crea sin --patch. No toques repos, no publiques nada en GitHub ni en ningún otro servicio.",
38
+ "6. Terminá con 2 líneas: qué cambiaste y qué requiere atención.",
39
+ ].join("\n");
40
+ }
41
+
42
+ const jobFile = (taskId) => (store.assertSafe(taskId), path.join(JOBS_DIR, `${taskId}.json`));
43
+
44
+ function readJob(taskId) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(jobFile(taskId), "utf8"));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function writeJob(job) {
53
+ fs.mkdirSync(JOBS_DIR, { recursive: true });
54
+ fs.writeFileSync(jobFile(job.taskId), JSON.stringify(job, null, 2));
55
+ }
56
+
57
+ function isAlive(pid) {
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ const tail = (file) => {
67
+ try {
68
+ return fs.readFileSync(file, "utf8").slice(-4000);
69
+ } catch {
70
+ return "";
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Job state lives on disk (pid, log, exit-code file) so it survives dev-server restarts,
76
+ * which happen whenever a server module changes or the launchd service restarts.
77
+ */
78
+ function resolveJob(job) {
79
+ if (job.status !== "running") return job;
80
+ if (fs.existsSync(job.exitFile)) {
81
+ const code = Number(fs.readFileSync(job.exitFile, "utf8").trim());
82
+ const finished = { ...job, status: code === 0 ? "done" : "failed", exitCode: code, finishedAt: fs.statSync(job.exitFile).mtime.toISOString() };
83
+ writeJob(finished);
84
+ if (code === 0) store.saveTask(job.taskId, { refreshedAt: job.startedAt });
85
+ return finished;
86
+ }
87
+ if (!isAlive(job.pid)) {
88
+ const lost = { ...job, status: "failed", exitCode: null, finishedAt: new Date().toISOString() };
89
+ writeJob(lost);
90
+ return lost;
91
+ }
92
+ return job;
93
+ }
94
+
95
+ export function startRefresh(taskId) {
96
+ const task = store.getTask(taskId);
97
+ if (!task) throw new Error(`No existe la tarea ${taskId}`);
98
+ const current = readJob(taskId);
99
+ if (current && resolveJob(current).status === "running") return refreshStatus(taskId);
100
+
101
+ const cwd = path.join(RUN_DIR, `${taskId}-${Date.now()}`);
102
+ fs.mkdirSync(cwd, { recursive: true });
103
+ const logFile = path.join(cwd, "refresh.log");
104
+ const exitFile = path.join(cwd, "exit-code");
105
+ const log = fs.openSync(logFile, "a");
106
+ const args = ["-p", prompt(task, task.refreshedAt), "--permission-mode", "acceptEdits", "--allowedTools", ...ALLOWED_TOOLS];
107
+ const child = spawn("/bin/sh", ["-c", 'claude "$@"; echo $? > "$0"', exitFile, ...args], {
108
+ cwd,
109
+ env: { ...process.env, PATH },
110
+ stdio: ["ignore", log, log],
111
+ detached: true,
112
+ });
113
+ child.unref();
114
+ const job = { taskId, status: "running", startedAt: new Date().toISOString(), pid: child.pid, cwd, logFile, exitFile };
115
+ writeJob(job);
116
+ return refreshStatus(taskId);
117
+ }
118
+
119
+ export function refreshStatus(taskId) {
120
+ const job = readJob(taskId);
121
+ if (!job) return { status: "never" };
122
+ const resolved = resolveJob(job);
123
+ return { status: resolved.status, startedAt: resolved.startedAt, finishedAt: resolved.finishedAt, exitCode: resolved.exitCode, output: tail(resolved.logFile) };
124
+ }
package/src/remote.mjs ADDED
@@ -0,0 +1,137 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import WebSocket from "ws";
6
+
7
+ import { CONFIG_DIR } from "./paths.mjs";
8
+
9
+ export { CONFIG_DIR };
10
+ export const REMOTE_FILE = path.join(CONFIG_DIR, "remote.json");
11
+
12
+ export function readRemote() {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(REMOTE_FILE, "utf8"));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function writeRemote(config) {
21
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
22
+ if (config) fs.writeFileSync(REMOTE_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
23
+ else fs.rmSync(REMOTE_FILE, { force: true });
24
+ }
25
+
26
+ /** Status the relay connection publishes for `mono status`. */
27
+ export const STATUS_FILE = path.join(CONFIG_DIR, "remote-status.json");
28
+
29
+ export function remoteStatus() {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(STATUS_FILE, "utf8"));
32
+ } catch {
33
+ return { state: "off" };
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Keeps an outbound WebSocket to the relay (`mono connect`) so the phone or any browser can use this
39
+ * machine without opening ports. The relay sends HTTP requests over the socket; they are replayed
40
+ * against the local server as if they came from localhost, so the API's own checks still apply.
41
+ * Server events are pushed up as SSE frames for the relay to fan out.
42
+ */
43
+ export function startRemote({ port }) {
44
+ let socket = null;
45
+ let retry = 1000;
46
+ let config = null;
47
+
48
+ const writeStatus = (status) => {
49
+ try {
50
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
51
+ fs.writeFileSync(STATUS_FILE, JSON.stringify({ ...status, relay: config?.relay ?? null, at: new Date().toISOString() }));
52
+ } catch {}
53
+ };
54
+
55
+ function connect() {
56
+ config = readRemote();
57
+ if (!config?.relay || !config?.token) return writeStatus({ state: "off" });
58
+ const url = new URL("/_mono/host", config.relay);
59
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
60
+ const ws = new WebSocket(url, { maxPayload: 64 * 1024 * 1024 });
61
+ socket = ws;
62
+ let alive = true;
63
+ const heartbeat = setInterval(() => {
64
+ if (!alive) return ws.terminate();
65
+ alive = false;
66
+ ws.ping();
67
+ }, 30_000);
68
+ ws.on("pong", () => (alive = true));
69
+ ws.on("open", () => ws.send(JSON.stringify({ type: "hello", token: config.token, name: config.name ?? os.hostname() })));
70
+ ws.on("message", (raw) => {
71
+ let msg;
72
+ try {
73
+ msg = JSON.parse(raw);
74
+ } catch {
75
+ return;
76
+ }
77
+ if (msg.type === "welcome") {
78
+ retry = 1000;
79
+ console.log(`Mono conectado al relay ${config.relay} como ${msg.email}`);
80
+ writeStatus({ state: "connected", email: msg.email, hostId: msg.hostId });
81
+ }
82
+ if (msg.type === "rejected") {
83
+ console.error(`El relay rechazó esta máquina: ${msg.reason}`);
84
+ writeStatus({ state: "rejected", reason: msg.reason });
85
+ retry = 5 * 60_000;
86
+ }
87
+ if (msg.type === "req") proxy(ws, msg, port);
88
+ });
89
+ ws.on("error", (error) => writeStatus({ state: "error", reason: error.message }));
90
+ ws.on("close", () => {
91
+ clearInterval(heartbeat);
92
+ if (socket !== ws) return;
93
+ socket = null;
94
+ setTimeout(connect, retry);
95
+ retry = Math.min(retry * 2, 5 * 60_000);
96
+ });
97
+ }
98
+
99
+ // `mono connect` / `mono disconnect` rewrite the config file; pick it up without a restart.
100
+ fs.watchFile(REMOTE_FILE, { interval: 2000 }, () => {
101
+ const previous = socket;
102
+ socket = null;
103
+ previous?.close();
104
+ retry = 1000;
105
+ connect();
106
+ });
107
+ connect();
108
+
109
+ return {
110
+ forward(frame) {
111
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "event", frame }));
112
+ },
113
+ };
114
+ }
115
+
116
+ function proxy(ws, msg, port) {
117
+ const headers = { host: `localhost:${port}` };
118
+ if (msg.headers?.["content-type"]) headers["content-type"] = msg.headers["content-type"];
119
+ if (msg.method !== "GET" && msg.method !== "HEAD") headers.origin = `http://localhost:${port}`;
120
+ const body = msg.body ? Buffer.from(msg.body, "base64") : null;
121
+ if (body) headers["content-length"] = body.length;
122
+
123
+ const reply = (status, resHeaders, chunks) => {
124
+ if (ws.readyState !== WebSocket.OPEN) return;
125
+ ws.send(JSON.stringify({ type: "res", id: msg.id, status, headers: resHeaders, body: Buffer.concat(chunks).toString("base64") }));
126
+ };
127
+
128
+ const req = http.request({ host: "127.0.0.1", port, method: msg.method, path: msg.path, headers }, (res) => {
129
+ const chunks = [];
130
+ res.on("data", (chunk) => chunks.push(chunk));
131
+ res.on("end", () => reply(res.statusCode, pick(res.headers, ["content-type", "cache-control"]), chunks));
132
+ });
133
+ req.on("error", (error) => reply(502, { "content-type": "application/json" }, [Buffer.from(JSON.stringify({ error: error.message }))]));
134
+ req.end(body ?? undefined);
135
+ }
136
+
137
+ const pick = (headers, keys) => Object.fromEntries(keys.filter((k) => headers[k]).map((k) => [k, headers[k]]));
@@ -0,0 +1,422 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
6
+
7
+ const STALE_TURN_MS = 15 * 60_000;
8
+ const RECENT_MS = 2 * 60 * 60_000;
9
+ const TAIL_BYTES = 4 * 1024 * 1024;
10
+ const MENTION = /\b(IME-\d{3,5}|PR-\d{2,5})\b/g;
11
+
12
+ /** Incremental per-file state: only bytes appended since the last read are parsed. */
13
+ const metaCache = new Map();
14
+
15
+ function readFrom(file, start, end) {
16
+ const fd = fs.openSync(file, "r");
17
+ try {
18
+ const buf = Buffer.alloc(end - start);
19
+ fs.readSync(fd, buf, 0, buf.length, start);
20
+ return buf;
21
+ } finally {
22
+ fs.closeSync(fd);
23
+ }
24
+ }
25
+
26
+ function splitLines(buf) {
27
+ const lines = [];
28
+ let from = 0;
29
+ for (let i = 0; i < buf.length; i++) {
30
+ if (buf[i] === 10) {
31
+ lines.push(buf.subarray(from, i).toString("utf8"));
32
+ from = i + 1;
33
+ }
34
+ }
35
+ return { lines, rest: buf.subarray(from) };
36
+ }
37
+
38
+ function parse(line) {
39
+ if (!line) return null;
40
+ try {
41
+ return JSON.parse(line);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ const truncate = (text, max) => (text && text.length > max ? `${text.slice(0, max)}…` : text);
48
+
49
+ function toolTitle(name, input = {}) {
50
+ return input.description || input.title || input.file_path || input.pattern || input.query || input.url || input.skill || (typeof input.command === "string" ? input.command : "") || "";
51
+ }
52
+
53
+ function toolDetail(name, input = {}) {
54
+ if (typeof input.command === "string") return input.command;
55
+ if (input.prompt) return input.prompt;
56
+ if (input.file_path && (input.old_string || input.content)) return `${input.file_path}\n\n${input.new_string ?? input.content ?? ""}`;
57
+ return JSON.stringify(input, null, 2);
58
+ }
59
+
60
+ function textOf(content) {
61
+ if (typeof content === "string") return content;
62
+ if (Array.isArray(content)) return content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
63
+ return "";
64
+ }
65
+
66
+ const IMAGE_CACHE_NOTE = /^(\s*\[Image: source: [^\]]+\]\s*)+$/;
67
+
68
+ function isSystemText(text) {
69
+ if (IMAGE_CACHE_NOTE.test(text)) return true;
70
+ return /^\s*(<(task-notification|system-reminder|local-command|command-|bash-|user-memory|ide_)|Base directory for this skill:|Caveat: The messages below)/.test(text);
71
+ }
72
+
73
+ function emptyMeta(file) {
74
+ return {
75
+ file,
76
+ title: null,
77
+ customTitle: null,
78
+ cwd: null,
79
+ firstCwd: null,
80
+ gitBranch: null,
81
+ model: null,
82
+ contextTokens: 0,
83
+ peakContext: 0,
84
+ prLinks: new Set(),
85
+ mentions: new Set(),
86
+ firstTs: null,
87
+ lastTs: null,
88
+ firstPrompt: null,
89
+ lastPrompt: null,
90
+ lastText: null,
91
+ lastAction: null,
92
+ turnOpen: false,
93
+ turns: 0,
94
+ };
95
+ }
96
+
97
+ function applyRecord(meta, rec) {
98
+ if (rec.timestamp) {
99
+ meta.firstTs ??= rec.timestamp;
100
+ if (!meta.lastTs || rec.timestamp > meta.lastTs) meta.lastTs = rec.timestamp;
101
+ }
102
+ switch (rec.type) {
103
+ case "ai-title":
104
+ if (rec.aiTitle) meta.title = rec.aiTitle;
105
+ break;
106
+ case "custom-title":
107
+ if (rec.customTitle) meta.customTitle = rec.customTitle;
108
+ break;
109
+ case "summary":
110
+ if (rec.summary && !meta.title) meta.title = rec.summary;
111
+ break;
112
+ case "pr-link":
113
+ if (rec.prUrl) meta.prLinks.add(rec.prUrl);
114
+ break;
115
+ case "user": {
116
+ if (rec.cwd) {
117
+ meta.firstCwd ??= rec.cwd;
118
+ meta.cwd = rec.cwd;
119
+ }
120
+ if (rec.gitBranch) meta.gitBranch = rec.gitBranch;
121
+ const content = rec.message?.content;
122
+ const typedByUser = typeof content === "string" || (Array.isArray(content) && !content.some((c) => c.type === "tool_result"));
123
+ const text = textOf(content);
124
+ if (typedByUser || (Array.isArray(content) && content.some((c) => c.type === "tool_result"))) meta.turnOpen = true;
125
+ if (typedByUser && text.includes("<command-name>")) {
126
+ const command = commandEvent(text);
127
+ meta.firstPrompt ??= truncate(`/${command.name} ${command.text}`.trim(), 120);
128
+ meta.turns++;
129
+ } else if (typedByUser && text && !isSystemText(text)) {
130
+ meta.firstPrompt ??= truncate(text.trim(), 120);
131
+ meta.lastPrompt = truncate(text.trim(), 280);
132
+ meta.turns++;
133
+ for (const m of text.slice(0, 4000).matchAll(MENTION)) meta.mentions.add(m[1]);
134
+ }
135
+ break;
136
+ }
137
+ case "assistant": {
138
+ if (rec.message?.model && !rec.message.model.startsWith("<")) meta.model = rec.message.model;
139
+ const u = rec.message?.usage;
140
+ if (u && !rec.isSidechain && rec.message?.model !== "<synthetic>") {
141
+ const used = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
142
+ if (used > 0) {
143
+ meta.contextTokens = used;
144
+ meta.peakContext = Math.max(meta.peakContext, used);
145
+ }
146
+ }
147
+ if (rec.message?.stop_reason) meta.turnOpen = rec.message.stop_reason === "tool_use";
148
+ for (const block of rec.message?.content ?? []) {
149
+ if (block.type === "text" && block.text?.trim()) meta.lastText = truncate(block.text.trim(), 400);
150
+ if (block.type === "tool_use") meta.lastAction = { tool: block.name, title: truncate(toolTitle(block.name, block.input), 160), ts: rec.timestamp };
151
+ }
152
+ break;
153
+ }
154
+ }
155
+ }
156
+
157
+ function readMeta(file) {
158
+ const stat = fs.statSync(file);
159
+ let entry = metaCache.get(file);
160
+ if (!entry || stat.size < entry.offset) entry = { offset: 0, rest: Buffer.alloc(0), meta: emptyMeta(file) };
161
+ if (stat.size > entry.offset) {
162
+ const chunk = Buffer.concat([entry.rest, readFrom(file, entry.offset, stat.size)]);
163
+ const { lines, rest } = splitLines(chunk);
164
+ for (const line of lines) {
165
+ const rec = parse(line);
166
+ if (rec) applyRecord(entry.meta, rec);
167
+ }
168
+ entry.offset = stat.size;
169
+ entry.rest = rest;
170
+ }
171
+ entry.mtimeMs = stat.mtimeMs;
172
+ metaCache.set(file, entry);
173
+ return { ...entry.meta, mtimeMs: stat.mtimeMs, size: stat.size };
174
+ }
175
+
176
+ /**
177
+ * A turn is open from a user message or tool result until an assistant message ends with a stop
178
+ * reason other than tool_use. Turns silent for 15 minutes count as stale (crashed or abandoned).
179
+ */
180
+ function statusFor(meta, now = Date.now()) {
181
+ const age = now - meta.mtimeMs;
182
+ if (meta.turnOpen && age < STALE_TURN_MS) return "working";
183
+ if (age < RECENT_MS) return "idle";
184
+ return "inactive";
185
+ }
186
+
187
+ function projectLabel(cwd, dir) {
188
+ const source = cwd ?? dir;
189
+ const refresh = source.match(/ws-refresh\/([^/]+?)-\d+$/);
190
+ if (refresh) return `Refresco IA · ${refresh[1]}`;
191
+ const worktree = source.match(/\.worktrees\/([^/]+)\/([^/]+)/);
192
+ if (worktree) return `${worktree[1]} · ${worktree[2]}`;
193
+ return path.basename(source);
194
+ }
195
+
196
+ function listSubagents(sessionFile) {
197
+ const dir = path.join(sessionFile.replace(/\.jsonl$/, ""), "subagents");
198
+ let files;
199
+ try {
200
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
201
+ } catch {
202
+ return [];
203
+ }
204
+ return files
205
+ .map((f) => {
206
+ const file = path.join(dir, f);
207
+ const id = f.replace(/\.jsonl$/, "").replace(/^agent-/, "");
208
+ let info = {};
209
+ try {
210
+ info = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
211
+ } catch {}
212
+ const meta = readMeta(file);
213
+ return {
214
+ id,
215
+ description: info.description ?? meta.lastPrompt ?? id,
216
+ agentType: info.agentType,
217
+ toolUseId: info.toolUseId,
218
+ status: statusFor(meta),
219
+ lastTs: meta.lastTs,
220
+ lastAction: meta.lastAction,
221
+ lastText: meta.lastText,
222
+ };
223
+ })
224
+ .sort((a, b) => (b.lastTs ?? "").localeCompare(a.lastTs ?? ""));
225
+ }
226
+
227
+ function sessionFiles(maxAgeMs) {
228
+ const now = Date.now();
229
+ const out = [];
230
+ let dirs = [];
231
+ try {
232
+ dirs = fs.readdirSync(PROJECTS_DIR);
233
+ } catch {
234
+ return out;
235
+ }
236
+ for (const dir of dirs) {
237
+ const full = path.join(PROJECTS_DIR, dir);
238
+ let entries;
239
+ try {
240
+ entries = fs.readdirSync(full);
241
+ } catch {
242
+ continue;
243
+ }
244
+ for (const f of entries) {
245
+ if (!f.endsWith(".jsonl")) continue;
246
+ const file = path.join(full, f);
247
+ const stat = fs.statSync(file);
248
+ if (now - stat.mtimeMs <= maxAgeMs) out.push({ id: f.replace(/\.jsonl$/, ""), dir, file });
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ function summarize({ id, dir, file }, withSubagents = true) {
255
+ const meta = readMeta(file);
256
+ const subagents = withSubagents ? listSubagents(file) : [];
257
+ const anySubWorking = subagents.some((s) => s.status === "working");
258
+ const status = anySubWorking ? "working" : statusFor(meta);
259
+ return {
260
+ id,
261
+ title: meta.customTitle ?? meta.title ?? meta.firstPrompt ?? "Sesión sin título",
262
+ project: projectLabel(meta.cwd, dir),
263
+ cwd: meta.cwd,
264
+ firstCwd: meta.firstCwd,
265
+ gitBranch: meta.gitBranch,
266
+ model: meta.model,
267
+ contextTokens: meta.contextTokens,
268
+ // The transcript doesn't record the window; assume 200k unless the session already went past it (1M).
269
+ contextWindow: meta.peakContext > 200_000 ? 1_000_000 : 200_000,
270
+ prLinks: [...meta.prLinks],
271
+ mentions: [...meta.mentions],
272
+ firstTs: meta.firstTs,
273
+ lastTs: meta.lastTs,
274
+ status,
275
+ lastAction: meta.lastAction,
276
+ lastText: meta.lastText,
277
+ lastPrompt: meta.lastPrompt,
278
+ turns: meta.turns,
279
+ subagents: subagents.map(({ toolUseId, ...s }) => s),
280
+ };
281
+ }
282
+
283
+ export function listSessions({ days = 3 } = {}) {
284
+ return sessionFiles(days * 24 * 60 * 60_000)
285
+ .map((s) => summarize(s))
286
+ .sort((a, b) => (b.lastTs ?? "").localeCompare(a.lastTs ?? ""));
287
+ }
288
+
289
+ function findSessionFile(id) {
290
+ if (!/^[\w-]+$/.test(id)) return null;
291
+ for (const dir of fs.readdirSync(PROJECTS_DIR)) {
292
+ const file = path.join(PROJECTS_DIR, dir, `${id}.jsonl`);
293
+ if (fs.existsSync(file)) return { id, dir, file };
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /** A slash command the user typed (`/pr-plan foo`), which Claude Code records as tagged text. */
299
+ function commandEvent(text, ts) {
300
+ const name = text.match(/<command-name>\/?([^<]+)<\/command-name>/)?.[1]?.trim() ?? "comando";
301
+ const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1]?.trim() ?? "";
302
+ return { kind: "command", ts, name, text: args };
303
+ }
304
+
305
+ function toEvents(rec, agentsByToolUse) {
306
+ const ts = rec.timestamp;
307
+ const events = [];
308
+ if (rec.type === "user") {
309
+ const content = rec.message?.content;
310
+ if (typeof content === "string") {
311
+ if (content.startsWith("<task-notification")) {
312
+ const summary = content.match(/<summary>([\s\S]*?)<\/summary>/)?.[1];
313
+ events.push({ kind: "notice", ts, text: truncate(summary ?? "Notificación de tarea en background", 300) });
314
+ } else if (content.includes("<command-name>")) {
315
+ events.push(commandEvent(content, ts));
316
+ } else if (!isSystemText(content)) {
317
+ events.push({ kind: "prompt", ts, text: truncate(content, 6000) });
318
+ }
319
+ } else if (Array.isArray(content)) {
320
+ const texts = [];
321
+ let images = 0;
322
+ for (const block of content) {
323
+ if (block.type === "tool_result") {
324
+ const text = Array.isArray(block.content) ? block.content.map((c) => (c.type === "text" ? c.text : c.type === "image" ? "[imagen]" : "")).join("\n") : String(block.content ?? "");
325
+ events.push({ kind: "result", ts, toolUseId: block.tool_use_id, isError: Boolean(block.is_error), text: truncate(text, 4000) });
326
+ } else if (block.type === "text" && block.text?.includes("<command-name>")) {
327
+ events.push(commandEvent(block.text, ts));
328
+ } else if (block.type === "text" && block.text && !isSystemText(block.text)) {
329
+ texts.push(block.text);
330
+ } else if (block.type === "image") {
331
+ images++;
332
+ }
333
+ }
334
+ if (texts.length || images) events.push({ kind: "prompt", ts, text: truncate(texts.join("\n\n"), 6000), images });
335
+ }
336
+ } else if (rec.type === "assistant") {
337
+ for (const block of rec.message?.content ?? []) {
338
+ if (block.type === "text" && block.text?.trim()) events.push({ kind: "text", ts, text: truncate(block.text, 12000) });
339
+ if (block.type === "tool_use") {
340
+ events.push({
341
+ kind: "tool",
342
+ ts,
343
+ id: block.id,
344
+ name: block.name,
345
+ title: truncate(toolTitle(block.name, block.input), 200),
346
+ detail: truncate(toolDetail(block.name, block.input), 4000),
347
+ agentId: agentsByToolUse?.get(block.id),
348
+ });
349
+ }
350
+ }
351
+ } else if (rec.type === "system" && rec.subtype === "compact_boundary") {
352
+ events.push({ kind: "notice", ts, text: "Contexto compactado" });
353
+ }
354
+ return events;
355
+ }
356
+
357
+ /** Returns timeline events after `since` (byte offset), or the tail of the file when `since` is omitted. */
358
+ export function readSessionEvents(id, { since, agent } = {}) {
359
+ const session = findSessionFile(id);
360
+ if (!session) return null;
361
+ const subagents = listSubagents(session.file);
362
+ const file = agent ? path.join(session.file.replace(/\.jsonl$/, ""), "subagents", `agent-${agent}.jsonl`) : session.file;
363
+ if (agent && (!/^[\w-]+$/.test(agent) || !fs.existsSync(file))) return null;
364
+
365
+ const agentsByToolUse = new Map();
366
+ const subDir = path.join(session.file.replace(/\.jsonl$/, ""), "subagents");
367
+ for (const s of subagents) {
368
+ try {
369
+ const info = JSON.parse(fs.readFileSync(path.join(subDir, `agent-${s.id}.meta.json`), "utf8"));
370
+ if (info.toolUseId) agentsByToolUse.set(info.toolUseId, s.id);
371
+ } catch {}
372
+ }
373
+
374
+ const size = fs.statSync(file).size;
375
+ const start = since !== undefined ? Math.min(Number(since), size) : Math.max(0, size - TAIL_BYTES);
376
+ const { lines, rest } = splitLines(readFrom(file, start, size));
377
+ if (since === undefined && start > 0) lines.shift();
378
+ const events = [];
379
+ for (const line of lines) {
380
+ const rec = parse(line);
381
+ if (rec) events.push(...toEvents(rec, agentsByToolUse));
382
+ }
383
+ return {
384
+ session: summarize(session),
385
+ events: since === undefined ? events.slice(-600) : events,
386
+ cursor: size - rest.length,
387
+ truncated: since === undefined && start > 0,
388
+ };
389
+ }
390
+
391
+ /** Sessions whose PRs, branch, worktree or mentions point at the given task. */
392
+ export function sessionsForTask(task, sessions = listSessions()) {
393
+ const prUrls = new Set();
394
+ const prNumbers = new Set();
395
+ for (const r of task.repos ?? []) {
396
+ if (r.pr) {
397
+ prUrls.add(r.pr);
398
+ prNumbers.add(r.pr.split("/").pop());
399
+ }
400
+ }
401
+ for (const pr of task.relatedPrs ?? []) prNumbers.add(String(pr).split("/").pop());
402
+ const branches = new Set((task.repos ?? []).map((r) => r.branch).filter((b) => b && !["main", "develop", "master"].includes(b)));
403
+ return sessions.filter(
404
+ (s) =>
405
+ (task.sessionIds ?? []).includes(s.id) ||
406
+ s.prLinks.some((url) => prUrls.has(url) || prNumbers.has(url.split("/").pop())) ||
407
+ (s.gitBranch && branches.has(s.gitBranch)) ||
408
+ s.mentions.includes(task.id) ||
409
+ (s.cwd ?? "").includes(`/.worktrees/${task.id}/`),
410
+ );
411
+ }
412
+
413
+ export function sessionIdFromPath(relPath) {
414
+ const parts = relPath.split(path.sep);
415
+ if (parts.length < 2) return null;
416
+ return parts[1].replace(/\.jsonl$/, "");
417
+ }
418
+
419
+ export function sessionCwd(id) {
420
+ const session = findSessionFile(id);
421
+ return session ? readMeta(session.file).firstCwd : null;
422
+ }