@famonfa/mono 0.0.0-stage → 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.
- package/bin/mono.mjs +210 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/LICENSE.md +1 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/README.md +69 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/agentSdkTypes.d.ts +1 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/bridge.d.ts +396 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/bridge.mjs +142 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/browser-sdk.d.ts +208 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/browser-sdk.js +145 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/core-36zyfan5.mjs +52 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/core-5682eevd.mjs +27 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/core-nne0e2ds.mjs +40 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/core-xpxe8nzb.mjs +26 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/core.d.ts +22 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/core.mjs +34 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/extractFromBunfs.d.ts +1 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/extractFromBunfs.js +156 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/manifest.json +82 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/manifest.zst.json +90 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/package.json +94 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/sdk-tools.d.ts +4222 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts +10277 -0
- package/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs +169 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/node_modules/ws/lib/receiver.js +743 -0
- package/node_modules/ws/lib/sender.js +607 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +562 -0
- package/node_modules/ws/lib/websocket.js +1421 -0
- package/node_modules/ws/package.json +74 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/package.json +126 -3
- package/skills/workspace/SKILL.md +108 -0
- package/src/api.mjs +279 -0
- package/src/chats.mjs +419 -0
- package/src/commands.mjs +100 -0
- package/src/groups.mjs +84 -0
- package/src/launcher.mjs +212 -0
- package/src/main.mjs +42 -0
- package/src/paths.mjs +44 -0
- package/src/projects.mjs +199 -0
- package/src/refresh.mjs +124 -0
- package/src/remote.mjs +129 -0
- package/src/sessions.mjs +422 -0
- package/src/store.mjs +253 -0
- package/web/assets/index-BMbPwlF6.js +55 -0
- package/web/assets/index-Ke-WlOuz.css +1 -0
- package/web/icon.svg +1 -0
- package/web/index.html +17 -0
- package/web/manifest.webmanifest +9 -0
- package/README.md +0 -3
package/src/remote.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
/**
|
|
30
|
+
* Keeps an outbound WebSocket to the relay (`mono connect`) so the phone or any browser can use this
|
|
31
|
+
* machine without opening ports. The relay sends HTTP requests over the socket; they are replayed
|
|
32
|
+
* against the local server as if they came from localhost, so the API's own checks still apply.
|
|
33
|
+
* Server events are pushed up as SSE frames for the relay to fan out.
|
|
34
|
+
*/
|
|
35
|
+
export function startRemote({ port }) {
|
|
36
|
+
let socket = null;
|
|
37
|
+
let retry = 1000;
|
|
38
|
+
let config = null;
|
|
39
|
+
|
|
40
|
+
const writeStatus = (status) => {
|
|
41
|
+
try {
|
|
42
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
43
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify({ ...status, relay: config?.relay ?? null, at: new Date().toISOString() }));
|
|
44
|
+
} catch {}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function connect() {
|
|
48
|
+
config = readRemote();
|
|
49
|
+
if (!config?.relay || !config?.token) return writeStatus({ state: "off" });
|
|
50
|
+
const url = new URL("/_mono/host", config.relay);
|
|
51
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
52
|
+
const ws = new WebSocket(url, { maxPayload: 64 * 1024 * 1024 });
|
|
53
|
+
socket = ws;
|
|
54
|
+
let alive = true;
|
|
55
|
+
const heartbeat = setInterval(() => {
|
|
56
|
+
if (!alive) return ws.terminate();
|
|
57
|
+
alive = false;
|
|
58
|
+
ws.ping();
|
|
59
|
+
}, 30_000);
|
|
60
|
+
ws.on("pong", () => (alive = true));
|
|
61
|
+
ws.on("open", () => ws.send(JSON.stringify({ type: "hello", token: config.token, name: config.name ?? os.hostname() })));
|
|
62
|
+
ws.on("message", (raw) => {
|
|
63
|
+
let msg;
|
|
64
|
+
try {
|
|
65
|
+
msg = JSON.parse(raw);
|
|
66
|
+
} catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (msg.type === "welcome") {
|
|
70
|
+
retry = 1000;
|
|
71
|
+
console.log(`Mono conectado al relay ${config.relay} como ${msg.email}`);
|
|
72
|
+
writeStatus({ state: "connected", email: msg.email, hostId: msg.hostId });
|
|
73
|
+
}
|
|
74
|
+
if (msg.type === "rejected") {
|
|
75
|
+
console.error(`El relay rechazó esta máquina: ${msg.reason}`);
|
|
76
|
+
writeStatus({ state: "rejected", reason: msg.reason });
|
|
77
|
+
retry = 5 * 60_000;
|
|
78
|
+
}
|
|
79
|
+
if (msg.type === "req") proxy(ws, msg, port);
|
|
80
|
+
});
|
|
81
|
+
ws.on("error", (error) => writeStatus({ state: "error", reason: error.message }));
|
|
82
|
+
ws.on("close", () => {
|
|
83
|
+
clearInterval(heartbeat);
|
|
84
|
+
if (socket !== ws) return;
|
|
85
|
+
socket = null;
|
|
86
|
+
setTimeout(connect, retry);
|
|
87
|
+
retry = Math.min(retry * 2, 5 * 60_000);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `mono connect` / `mono disconnect` rewrite the config file; pick it up without a restart.
|
|
92
|
+
fs.watchFile(REMOTE_FILE, { interval: 2000 }, () => {
|
|
93
|
+
const previous = socket;
|
|
94
|
+
socket = null;
|
|
95
|
+
previous?.close();
|
|
96
|
+
retry = 1000;
|
|
97
|
+
connect();
|
|
98
|
+
});
|
|
99
|
+
connect();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
forward(frame) {
|
|
103
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "event", frame }));
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function proxy(ws, msg, port) {
|
|
109
|
+
const headers = { host: `localhost:${port}` };
|
|
110
|
+
if (msg.headers?.["content-type"]) headers["content-type"] = msg.headers["content-type"];
|
|
111
|
+
if (msg.method !== "GET" && msg.method !== "HEAD") headers.origin = `http://localhost:${port}`;
|
|
112
|
+
const body = msg.body ? Buffer.from(msg.body, "base64") : null;
|
|
113
|
+
if (body) headers["content-length"] = body.length;
|
|
114
|
+
|
|
115
|
+
const reply = (status, resHeaders, chunks) => {
|
|
116
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
117
|
+
ws.send(JSON.stringify({ type: "res", id: msg.id, status, headers: resHeaders, body: Buffer.concat(chunks).toString("base64") }));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const req = http.request({ host: "127.0.0.1", port, method: msg.method, path: msg.path, headers }, (res) => {
|
|
121
|
+
const chunks = [];
|
|
122
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
123
|
+
res.on("end", () => reply(res.statusCode, pick(res.headers, ["content-type", "cache-control"]), chunks));
|
|
124
|
+
});
|
|
125
|
+
req.on("error", (error) => reply(502, { "content-type": "application/json" }, [Buffer.from(JSON.stringify({ error: error.message }))]));
|
|
126
|
+
req.end(body ?? undefined);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pick = (headers, keys) => Object.fromEntries(keys.filter((k) => headers[k]).map((k) => [k, headers[k]]));
|
package/src/sessions.mjs
ADDED
|
@@ -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
|
+
}
|