@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.
Files changed (61) hide show
  1. package/bin/mono.mjs +210 -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 +279 -0
  45. package/src/chats.mjs +419 -0
  46. package/src/commands.mjs +100 -0
  47. package/src/groups.mjs +84 -0
  48. package/src/launcher.mjs +212 -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 +129 -0
  54. package/src/sessions.mjs +422 -0
  55. package/src/store.mjs +253 -0
  56. package/web/assets/index-BMbPwlF6.js +55 -0
  57. package/web/assets/index-Ke-WlOuz.css +1 -0
  58. package/web/icon.svg +1 -0
  59. package/web/index.html +17 -0
  60. package/web/manifest.webmanifest +9 -0
  61. package/README.md +0 -3
package/src/chats.mjs ADDED
@@ -0,0 +1,419 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { query } from "@anthropic-ai/claude-agent-sdk";
6
+ import { defaultCwd } from "./paths.mjs";
7
+ import * as store from "./store.mjs";
8
+ import { sessionCwd } from "./sessions.mjs";
9
+ import { allProjects, descendantIds, launchRootFor } from "./projects.mjs";
10
+
11
+ const HOME = os.homedir();
12
+
13
+ /** Folders typed by hand in the picker may start with `~`. */
14
+ export const expandHome = (dir) => (dir === "~" || dir.startsWith("~/") ? path.join(HOME, dir.slice(1)) : dir);
15
+ const IDLE_CLOSE_MS = 20 * 60_000;
16
+ /** Full access by default (like T3 Code); the other modes stay selectable per session. */
17
+ const DEFAULT_PERMISSION_MODE = "bypassPermissions";
18
+ const PERMISSION_MODES = new Set(["bypassPermissions", "acceptEdits", "default", "plan"]);
19
+ const modeOrDefault = (mode) => (PERMISSION_MODES.has(mode) ? mode : DEFAULT_PERMISSION_MODE);
20
+ /** Apps opened from the Dock don't get the shell's PATH, so also ask a login shell (covers nvm and friends). */
21
+ function findClaude() {
22
+ const known = [path.join(HOME, ".local/bin/claude"), path.join(HOME, ".claude/local/claude"), path.join(HOME, ".npm-global/bin/claude"), "/opt/homebrew/bin/claude", "/usr/local/bin/claude"].find((p) => fs.existsSync(p));
23
+ if (known) return known;
24
+ try {
25
+ const found = execFileSync(process.env.SHELL || "/bin/zsh", ["-lc", "command -v claude"], { encoding: "utf8", timeout: 5000 }).trim();
26
+ return found && fs.existsSync(found) ? found : undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ const CLAUDE_BIN = findClaude();
32
+ 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(":");
33
+
34
+ /** Chats this server is driving, keyed by Claude session id. */
35
+ const chats = new Map();
36
+ let notify = () => {};
37
+
38
+ export function onChatChange(fn) {
39
+ notify = fn;
40
+ }
41
+
42
+ class InputQueue {
43
+ constructor() {
44
+ this.items = [];
45
+ this.waiters = [];
46
+ this.closed = false;
47
+ }
48
+ push(item) {
49
+ const waiter = this.waiters.shift();
50
+ if (waiter) waiter({ value: item, done: false });
51
+ else this.items.push(item);
52
+ }
53
+ close() {
54
+ this.closed = true;
55
+ for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true });
56
+ }
57
+ [Symbol.asyncIterator]() {
58
+ return {
59
+ next: () => {
60
+ if (this.items.length) return Promise.resolve({ value: this.items.shift(), done: false });
61
+ if (this.closed) return Promise.resolve({ value: undefined, done: true });
62
+ return new Promise((resolve) => this.waiters.push(resolve));
63
+ },
64
+ };
65
+ }
66
+ }
67
+
68
+ export const UPLOADS_DIR = path.join(store.DATA_DIR, "uploads");
69
+ const INLINE_IMAGE = /^image\/(png|jpeg|gif|webp)$/;
70
+ const MAX_INLINE_IMAGE_BYTES = 5 * 1024 * 1024;
71
+
72
+ /** Only files the workspace itself stored can be attached, so a request can't smuggle arbitrary paths. */
73
+ function resolveAttachment(file) {
74
+ const real = path.resolve(file.path);
75
+ if (!real.startsWith(UPLOADS_DIR + path.sep) || !fs.existsSync(real)) throw new Error(`Adjunto inválido: ${file.name}`);
76
+ return { ...file, path: real, size: fs.statSync(real).size };
77
+ }
78
+
79
+ /**
80
+ * Images go inline so the model sees them; every attachment is also listed by path so
81
+ * Claude can open non-image files (PDFs, CSVs, code) with its own tools.
82
+ */
83
+ function userMessage(text, attachments = []) {
84
+ if (!attachments.length) return { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, origin: { kind: "human" } };
85
+ const files = attachments.map(resolveAttachment);
86
+ const listing = `${text}\n\nArchivos adjuntos:\n${files.map((f) => `- ${f.path}`).join("\n")}`;
87
+ const images = files
88
+ .filter((f) => INLINE_IMAGE.test(f.type) && f.size <= MAX_INLINE_IMAGE_BYTES)
89
+ .map((f) => ({ type: "image", source: { type: "base64", media_type: f.type, data: fs.readFileSync(f.path).toString("base64") } }));
90
+ return { type: "user", message: { role: "user", content: [{ type: "text", text: listing }, ...images] }, parent_tool_use_id: null, origin: { kind: "human" } };
91
+ }
92
+
93
+ export function saveUpload({ name, type, data }) {
94
+ if (!name || !data) throw new Error("Falta el archivo");
95
+ const dir = path.join(UPLOADS_DIR, `${new Date().toISOString().slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}`);
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ const safe = path.basename(name).replace(/[^\w.-]+/g, "-").slice(0, 120) || "archivo";
98
+ const file = path.join(dir, safe);
99
+ fs.writeFileSync(file, Buffer.from(data, "base64"));
100
+ return { path: file, name, type: type || "application/octet-stream", size: fs.statSync(file).size };
101
+ }
102
+
103
+ const T3_MODELS = path.join(HOME, ".t3", "caches", "claudeAgent.json");
104
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultracode"];
105
+
106
+ /** Same model list T3 Code shows for this account, with each model's effort levels. */
107
+ export function listModels() {
108
+ try {
109
+ const cache = JSON.parse(fs.readFileSync(T3_MODELS, "utf8"));
110
+ return cache.models.map((m) => {
111
+ const effort = m.capabilities?.optionDescriptors?.find((d) => d.id === "effort");
112
+ return {
113
+ id: m.slug,
114
+ name: m.name,
115
+ efforts: (effort?.options ?? []).filter((o) => EFFORTS.includes(o.id)).map((o) => ({ id: o.id, label: o.label, isDefault: Boolean(o.isDefault) })),
116
+ };
117
+ });
118
+ } catch {
119
+ return [{ id: "claude-opus-5-5", name: "Claude Opus 5.5", efforts: ["low", "medium", "high", "xhigh", "max"].map((id) => ({ id, label: id, isDefault: id === "medium" })) }];
120
+ }
121
+ }
122
+
123
+ const effortOptions = (effort) =>
124
+ !effort ? {} : effort === "ultracode" ? { effort: "xhigh", settings: { ultracode: true } } : { effort };
125
+
126
+ function focusContext(launchCwd, focusCwd) {
127
+ if (!focusCwd || focusCwd === launchCwd) return null;
128
+ return `Carpeta de trabajo de esta sesión: ${focusCwd}\nLa sesión arranca en ${launchCwd} para cargar sus skills y su CLAUDE.md, pero hacé los cambios, builds y tests en la carpeta de trabajo (usá rutas absolutas o \`cd\` a ella en cada comando).`;
129
+ }
130
+
131
+ function taskContext(task) {
132
+ const lines = [
133
+ `Estás trabajando en la tarea ${task.id} — "${task.title}" de Mono (http://localhost:4600/#/t/${task.id}).`,
134
+ task.tldr?.problema && `Problema: ${task.tldr.problema}`,
135
+ task.tldr?.solucion && `Solución: ${task.tldr.solucion}`,
136
+ task.repos?.length && `Repos/PRs: ${task.repos.map((r) => `${r.name}${r.branch ? ` (${r.branch})` : ""}${r.pr ? ` ${r.pr}` : ""}`).join(", ")}`,
137
+ task.boards?.length && `Boards: ${task.boards.map((b) => b.id).join(", ")} — leelos con \`ws get ${task.id} <board>\`.`,
138
+ `Cuando termines un bloque de trabajo relevante (tests, fixes, hallazgos), actualizá el workspace con el skill workspace usando \`ws board ... --patch\`.`,
139
+ ];
140
+ return lines.filter(Boolean).join("\n");
141
+ }
142
+
143
+ /** Worktrees that belong to a task: matching branch names or `.worktrees/<task>/`, plus the avocado root as fallback. */
144
+ /** The project's own folder plus every git worktree of it, for sessions started without a task. */
145
+ function gitWorktrees(root) {
146
+ const found = [{ path: root, repo: path.basename(root), branch: null, root: true }];
147
+ try {
148
+ const out = execFileSync("git", ["-C", root, "worktree", "list", "--porcelain"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
149
+ for (const block of out.split("\n\n")) {
150
+ const wtPath = block.match(/^worktree (.+)$/m)?.[1];
151
+ const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
152
+ if (wtPath && fs.realpathSync(wtPath) !== root) found.push({ path: wtPath, repo: path.basename(root), branch: branch ?? null });
153
+ }
154
+ } catch {}
155
+ return found;
156
+ }
157
+
158
+ /** A project's folder and worktrees, followed by those of the projects nested in it (avocado → instamenu, KDS…). */
159
+ export function worktreesForProject(projectId) {
160
+ const ids = descendantIds(projectId);
161
+ const projects = allProjects();
162
+ const top = projects.find((p) => p.id === projectId);
163
+ if (!top) return [];
164
+ const roots = [top.root, ...projects.filter((p) => p.id !== projectId && ids.has(p.id)).map((p) => p.root)];
165
+ for (const entry of fs.readdirSync(top.root, { withFileTypes: true })) {
166
+ const dir = path.join(top.root, entry.name);
167
+ if (entry.isDirectory() && !entry.name.startsWith(".") && fs.existsSync(path.join(dir, ".git")) && !roots.includes(dir)) roots.push(dir);
168
+ }
169
+ const all = roots.flatMap((root) => gitWorktrees(root));
170
+ return [...all.filter((w) => w.root), ...all.filter((w) => !w.root)];
171
+ }
172
+
173
+ /** Git repos a task can touch: its projects, the repos nested in them, and projects named like its repos. */
174
+ function taskRepoDirs(task, projectRoots) {
175
+ const names = new Set((task?.repos ?? []).map((r) => r.name));
176
+ const dirs = new Set(projectRoots);
177
+ for (const root of projectRoots) {
178
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
179
+ if (entry.isDirectory() && !entry.name.startsWith(".") && fs.existsSync(path.join(root, entry.name, ".git"))) dirs.add(path.join(root, entry.name));
180
+ }
181
+ }
182
+ for (const p of allProjects()) if (names.has(path.basename(p.root))) dirs.add(p.root);
183
+ return [...dirs].filter((dir) => fs.existsSync(path.join(dir, ".git")));
184
+ }
185
+
186
+ export function worktreesForTask(task, projectRoots = []) {
187
+ const branches = new Set((task?.repos ?? []).map((r) => r.branch).filter(Boolean));
188
+ const found = [];
189
+ for (const dir of taskRepoDirs(task, projectRoots)) {
190
+ const repo = path.basename(dir);
191
+ let out = "";
192
+ try {
193
+ out = execFileSync("git", ["-C", dir, "worktree", "list", "--porcelain"], { encoding: "utf8", timeout: 5000 });
194
+ } catch {
195
+ continue;
196
+ }
197
+ for (const block of out.split("\n\n")) {
198
+ const wtPath = block.match(/^worktree (.+)$/m)?.[1];
199
+ const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
200
+ if (!wtPath || wtPath === dir) continue;
201
+ if ((branch && branches.has(branch)) || (task && wtPath.includes(`/.worktrees/${task.id}/`))) found.push({ path: wtPath, repo, branch });
202
+ }
203
+ }
204
+ for (const root of projectRoots) {
205
+ if (!found.some((f) => f.path === root)) found.push({ path: root, repo: path.basename(root), branch: null, root: true });
206
+ }
207
+ const fallback = defaultCwd();
208
+ if (!found.length) found.push({ path: fallback, repo: path.basename(fallback), branch: null, root: true });
209
+ return found;
210
+ }
211
+
212
+ function publicState(chat) {
213
+ return {
214
+ sessionId: chat.sessionId,
215
+ live: !chat.closed,
216
+ state: chat.state,
217
+ permissionMode: chat.permissionMode,
218
+ model: chat.model,
219
+ effort: chat.effort,
220
+ cwd: chat.cwd,
221
+ taskId: chat.taskId,
222
+ pending: [...chat.pending.values()].map(({ resolve, ...p }) => p),
223
+ error: chat.error,
224
+ };
225
+ }
226
+
227
+ function scheduleIdleClose(chat) {
228
+ clearTimeout(chat.idleTimer);
229
+ if (chat.state !== "idle") return;
230
+ chat.idleTimer = setTimeout(() => chat.input.close(), IDLE_CLOSE_MS);
231
+ }
232
+
233
+ function setState(chat, state) {
234
+ chat.state = state;
235
+ scheduleIdleClose(chat);
236
+ notify(chat.sessionId);
237
+ }
238
+
239
+ const FOCUS_FILE = path.join(store.DATA_DIR, "session-focus.json");
240
+
241
+ export function readSessionFocus() {
242
+ try {
243
+ return JSON.parse(fs.readFileSync(FOCUS_FILE, "utf8"));
244
+ } catch {
245
+ return {};
246
+ }
247
+ }
248
+
249
+ function saveSessionFocus(sessionId, focus) {
250
+ const all = readSessionFocus();
251
+ all[sessionId] = focus;
252
+ fs.writeFileSync(FOCUS_FILE, JSON.stringify(all, null, 2));
253
+ }
254
+
255
+ function linkSessionToTask(taskId, sessionId) {
256
+ if (!taskId) return;
257
+ const task = store.getTask(taskId);
258
+ if (!task) return;
259
+ const ids = new Set(task.sessionIds ?? []);
260
+ if (ids.has(sessionId)) return;
261
+ store.saveTask(taskId, { sessionIds: [...ids, sessionId] });
262
+ }
263
+
264
+ /**
265
+ * Starts (or resumes) a Claude Code session driven from the workspace. Resolves with the session id
266
+ * once the CLI reports it, so the UI can navigate to the live feed straight away.
267
+ */
268
+ export function startChat({ taskId, cwd, prompt, resume, model, effort, permissionMode, attachments = [] }) {
269
+ const mode = modeOrDefault(permissionMode);
270
+ if (!CLAUDE_BIN) throw new Error("No encontré Claude Code en esta Mac. Instalalo (https://docs.claude.com/en/docs/claude-code/setup) y reiniciá Mono.");
271
+ if (resume && chats.get(resume) && !chats.get(resume).closed) throw new Error("La sesión ya está abierta en el workspace");
272
+ const task = taskId ? store.getTask(taskId) : null;
273
+ const focus = cwd ? expandHome(cwd) : defaultCwd();
274
+ if (!path.isAbsolute(focus) || !fs.statSync(focus, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`No existe la carpeta ${focus}`);
275
+ const workdir = resume ? focus : launchRootFor(focus);
276
+ const append = [task && taskContext(task), !resume && focusContext(workdir, focus)].filter(Boolean).join("\n\n");
277
+
278
+ const input = new InputQueue();
279
+ input.push(userMessage(prompt, attachments));
280
+ const chat = { sessionId: resume ?? null, taskId, cwd: focus, launchCwd: workdir, model, effort, permissionMode: mode, state: "running", pending: new Map(), input, closed: false, q: null };
281
+ let reqCounter = 0;
282
+
283
+ const q = query({
284
+ prompt: input,
285
+ options: {
286
+ ...(workdir ? { cwd: workdir } : {}),
287
+ ...(resume ? { resume } : {}),
288
+ permissionMode: mode,
289
+ allowDangerouslySkipPermissions: true,
290
+ ...(model ? { model } : {}),
291
+ ...effortOptions(effort),
292
+ settingSources: ["user", "project", "local"],
293
+ systemPrompt: { type: "preset", preset: "claude_code", ...(append ? { append } : {}) },
294
+ ...(CLAUDE_BIN ? { pathToClaudeCodeExecutable: CLAUDE_BIN } : {}),
295
+ env: { ...process.env, PATH },
296
+ canUseTool: (toolName, toolInput, { signal, suggestions, decisionReason, blockedPath }) =>
297
+ new Promise((resolve) => {
298
+ const id = `req-${++reqCounter}`;
299
+ chat.pending.set(id, { id, toolName, input: toolInput, decisionReason, blockedPath, canAlwaysAllow: Boolean(suggestions?.length), suggestions, resolve });
300
+ setState(chat, "requires_action");
301
+ signal.addEventListener("abort", () => {
302
+ if (!chat.pending.delete(id)) return;
303
+ resolve({ behavior: "deny", message: "Cancelado" });
304
+ notify(chat.sessionId);
305
+ });
306
+ }),
307
+ },
308
+ });
309
+ chat.q = q;
310
+
311
+ return new Promise((resolveStart, rejectStart) => {
312
+ let started = Boolean(resume);
313
+ if (resume) {
314
+ chats.set(resume, chat);
315
+ resolveStart(publicState(chat));
316
+ }
317
+ (async () => {
318
+ try {
319
+ for await (const msg of q) {
320
+ if (msg.session_id && !chat.sessionId) chat.sessionId = msg.session_id;
321
+ if (msg.type === "system" && msg.subtype === "init") {
322
+ chats.set(chat.sessionId, chat);
323
+ if (!resume) saveSessionFocus(chat.sessionId, focus);
324
+ linkSessionToTask(taskId, chat.sessionId);
325
+ if (!started) {
326
+ started = true;
327
+ resolveStart(publicState(chat));
328
+ }
329
+ notify(chat.sessionId);
330
+ } else if (msg.type === "system" && msg.subtype === "session_state_changed") {
331
+ setState(chat, msg.state);
332
+ } else if (msg.type === "result") {
333
+ if (chat.pending.size === 0) setState(chat, "idle");
334
+ if (msg.subtype !== "success") chat.error = msg.subtype;
335
+ }
336
+ }
337
+ } catch (error) {
338
+ chat.error = String(error?.message ?? error);
339
+ if (!started) rejectStart(error);
340
+ } finally {
341
+ chat.closed = true;
342
+ chat.state = "idle";
343
+ clearTimeout(chat.idleTimer);
344
+ for (const p of chat.pending.values()) p.resolve({ behavior: "deny", message: "Sesión cerrada" });
345
+ chat.pending.clear();
346
+ notify(chat.sessionId);
347
+ }
348
+ })();
349
+ });
350
+ }
351
+
352
+ export function sendMessage(sessionId, text, { workingSessions = [], model, effort, permissionMode, attachments = [] } = {}) {
353
+ const chat = chats.get(sessionId);
354
+ if (chat && !chat.closed) {
355
+ chat.input.push(userMessage(text, attachments));
356
+ setState(chat, "running");
357
+ return Promise.resolve(publicState(chat));
358
+ }
359
+ if (workingSessions.includes(sessionId)) {
360
+ throw new Error("Esta sesión está trabajando en otra app. Esperá a que quede en pausa para escribirle desde acá.");
361
+ }
362
+ const cwd = sessionCwd(sessionId);
363
+ if (!cwd) throw new Error("No encontré la carpeta original de esta sesión");
364
+ return startChat({ resume: sessionId, cwd, prompt: text, taskId: chat?.taskId, model, effort, permissionMode, attachments });
365
+ }
366
+
367
+ export function chatState(sessionId) {
368
+ const chat = chats.get(sessionId);
369
+ return chat ? publicState(chat) : { sessionId, live: false, state: "idle", pending: [] };
370
+ }
371
+
372
+ export async function interrupt(sessionId) {
373
+ const chat = chats.get(sessionId);
374
+ if (!chat || chat.closed) throw new Error("La sesión no está abierta en el workspace");
375
+ for (const p of chat.pending.values()) p.resolve({ behavior: "deny", message: "Interrumpido por el usuario", interrupt: true });
376
+ chat.pending.clear();
377
+ await chat.q.interrupt();
378
+ setState(chat, "idle");
379
+ return publicState(chat);
380
+ }
381
+
382
+ /** `answers` (question text → chosen label(s)) is how AskUserQuestion gets the user's choices back. */
383
+ export function answerPermission(sessionId, requestId, { allow, always, message, answers }) {
384
+ const chat = chats.get(sessionId);
385
+ const req = chat?.pending.get(requestId);
386
+ if (!req) throw new Error("El pedido de permiso ya no está pendiente");
387
+ chat.pending.delete(requestId);
388
+ req.resolve(
389
+ allow
390
+ ? { behavior: "allow", updatedInput: answers ? { ...req.input, answers } : req.input, ...(always && req.suggestions ? { updatedPermissions: req.suggestions } : {}) }
391
+ : { behavior: "deny", message: message || "El usuario rechazó este paso" },
392
+ );
393
+ setState(chat, chat.pending.size ? "requires_action" : "running");
394
+ return publicState(chat);
395
+ }
396
+
397
+
398
+ export async function configure(sessionId, { model, effort, permissionMode }) {
399
+ const chat = chats.get(sessionId);
400
+ if (!chat || chat.closed) return { sessionId, live: false, state: "idle", pending: [], model, effort, permissionMode };
401
+ if (permissionMode && PERMISSION_MODES.has(permissionMode) && permissionMode !== chat.permissionMode) {
402
+ await chat.q.setPermissionMode(permissionMode);
403
+ chat.permissionMode = permissionMode;
404
+ }
405
+ if (model && model !== chat.model) {
406
+ await chat.q.setModel(model);
407
+ chat.model = model;
408
+ }
409
+ if (effort && effort !== chat.effort) {
410
+ await chat.q.applyFlagSettings(effort === "ultracode" ? { effortLevel: "xhigh", ultracode: true } : { effortLevel: effort, ultracode: false });
411
+ chat.effort = effort;
412
+ }
413
+ notify(sessionId);
414
+ return publicState(chat);
415
+ }
416
+
417
+ export function liveChatIds() {
418
+ return [...chats.values()].filter((c) => !c.closed).map((c) => c.sessionId);
419
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { launchRootFor } from "./projects.mjs";
5
+
6
+ const HOME = os.homedir();
7
+ const CACHE_MS = 60_000;
8
+ const BUILTINS = [
9
+ { name: "compact", description: "Compactar la conversación para liberar contexto", source: "builtin" },
10
+ { name: "context", description: "Ver cuánto contexto está usando la sesión", source: "builtin" },
11
+ ];
12
+
13
+ const cache = new Map();
14
+
15
+ function frontmatter(file) {
16
+ let head = "";
17
+ try {
18
+ const fd = fs.openSync(file, "r");
19
+ const buf = Buffer.alloc(4096);
20
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
21
+ fs.closeSync(fd);
22
+ head = buf.subarray(0, n).toString("utf8");
23
+ } catch {
24
+ return {};
25
+ }
26
+ const block = head.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
27
+ const field = (key) => block.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim().replace(/^["']|["']$/g, "");
28
+ const firstLine = head.replace(/^---[\s\S]*?---\n/, "").split("\n").find((l) => l.trim() && !l.startsWith("#"));
29
+ return { name: field("name"), description: field("description") ?? firstLine?.trim() };
30
+ }
31
+
32
+ const short = (text) => (text && text.length > 160 ? `${text.slice(0, 157)}…` : text ?? "");
33
+
34
+ function skillsIn(dir, source, prefix = "") {
35
+ let entries = [];
36
+ try {
37
+ entries = fs.readdirSync(dir, { withFileTypes: true });
38
+ } catch {
39
+ return [];
40
+ }
41
+ return entries
42
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
43
+ .map((e) => path.join(dir, e.name, "SKILL.md"))
44
+ .filter((f) => fs.existsSync(f))
45
+ .map((f) => {
46
+ const meta = frontmatter(f);
47
+ return { name: prefix + (meta.name ?? path.basename(path.dirname(f))), description: short(meta.description), source };
48
+ });
49
+ }
50
+
51
+ function commandsIn(dir, source, prefix = "") {
52
+ let files = [];
53
+ try {
54
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
55
+ } catch {
56
+ return [];
57
+ }
58
+ return files.map((f) => ({ name: prefix + f.replace(/\.md$/, ""), description: short(frontmatter(path.join(dir, f)).description), source }));
59
+ }
60
+
61
+ function pluginEntries() {
62
+ let enabled = {};
63
+ try {
64
+ enabled = JSON.parse(fs.readFileSync(path.join(HOME, ".claude", "settings.json"), "utf8")).enabledPlugins ?? {};
65
+ } catch {}
66
+ const out = [];
67
+ for (const [key, on] of Object.entries(enabled)) {
68
+ if (!on) continue;
69
+ const [plugin, marketplace] = key.split("@");
70
+ const base = path.join(HOME, ".claude", "plugins", "marketplaces", marketplace ?? "");
71
+ for (const root of [path.join(base, "plugins", plugin), base]) {
72
+ const found = [...skillsIn(path.join(root, "skills"), "plugin", `${plugin}:`), ...commandsIn(path.join(root, "commands"), "plugin", `${plugin}:`)];
73
+ if (found.length) {
74
+ out.push(...found);
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Slash commands a session started from `cwd` can use: project skills/commands win over personal ones, then plugins and built-ins. */
83
+ export function listCommands(cwd) {
84
+ const root = cwd ? launchRootFor(cwd) : null;
85
+ const key = root ?? "";
86
+ const hit = cache.get(key);
87
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.list;
88
+
89
+ const all = [
90
+ ...(root ? [...skillsIn(path.join(root, ".claude", "skills"), "project"), ...commandsIn(path.join(root, ".claude", "commands"), "project")] : []),
91
+ ...skillsIn(path.join(HOME, ".claude", "skills"), "user"),
92
+ ...commandsIn(path.join(HOME, ".claude", "commands"), "user"),
93
+ ...pluginEntries(),
94
+ ...BUILTINS,
95
+ ];
96
+ const seen = new Set();
97
+ const list = all.filter((c) => c.name && !seen.has(c.name) && seen.add(c.name));
98
+ cache.set(key, { at: Date.now(), list });
99
+ return list;
100
+ }
package/src/groups.mjs ADDED
@@ -0,0 +1,84 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { DATA_DIR, readJson, writeJson } from "./store.mjs";
4
+
5
+ export const GROUPS_FILE = path.join(DATA_DIR, "groups.json");
6
+
7
+ export const PANE_SIZE = { w: 600, h: 680 };
8
+ const PANE_GAP = 48;
9
+
10
+ const readAll = () => readJson(GROUPS_FILE, { groups: [] }).groups ?? [];
11
+ const writeAll = (groups) => writeJson(GROUPS_FILE, { groups });
12
+
13
+ export function listGroups() {
14
+ return readAll().sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
15
+ }
16
+
17
+ /** Finds a group by id or, for the CLI, by its name (case-insensitive). */
18
+ export function findGroup(ref) {
19
+ const needle = String(ref ?? "").toLowerCase();
20
+ return readAll().find((g) => g.id === ref || g.name.toLowerCase() === needle) ?? null;
21
+ }
22
+
23
+ function mutate(id, change) {
24
+ const groups = readAll();
25
+ const group = groups.find((g) => g.id === id);
26
+ if (!group) throw new Error(`No existe el grupo ${id}`);
27
+ change(group);
28
+ group.updatedAt = new Date().toISOString();
29
+ writeAll(groups);
30
+ return group;
31
+ }
32
+
33
+ /** New panes go to the right of the rightmost one, aligned to the top row. */
34
+ function nextSlot(panes) {
35
+ if (!panes.length) return { x: 0, y: 0 };
36
+ const right = Math.max(...panes.map((p) => p.x + p.w));
37
+ const top = Math.min(...panes.map((p) => p.y));
38
+ return { x: right + PANE_GAP, y: top };
39
+ }
40
+
41
+ function addPane(group, sessionId, title) {
42
+ if (group.panes.some((p) => p.sessionId === sessionId)) return;
43
+ group.panes.push({ sessionId, title: title || undefined, ...nextSlot(group.panes), ...PANE_SIZE });
44
+ }
45
+
46
+ export function createGroup({ name, sessions = [] }) {
47
+ const clean = String(name ?? "").trim();
48
+ if (!clean) throw new Error("Poné un nombre para el grupo");
49
+ const groups = readAll();
50
+ if (groups.some((g) => g.name.toLowerCase() === clean.toLowerCase())) throw new Error(`Ya existe el grupo "${clean}"`);
51
+ const now = new Date().toISOString();
52
+ const group = { id: crypto.randomUUID().slice(0, 8), name: clean, createdAt: now, updatedAt: now, panes: [] };
53
+ for (const s of sessions) addPane(group, s.sessionId, s.title);
54
+ groups.push(group);
55
+ writeAll(groups);
56
+ return group;
57
+ }
58
+
59
+ export function updateGroup(id, { name, panes }) {
60
+ return mutate(id, (group) => {
61
+ if (typeof name === "string" && name.trim()) group.name = name.trim();
62
+ if (Array.isArray(panes)) {
63
+ const known = new Map(group.panes.map((p) => [p.sessionId, p]));
64
+ group.panes = panes
65
+ .filter((p) => known.has(p.sessionId))
66
+ .map((p) => ({ ...known.get(p.sessionId), x: Math.round(p.x), y: Math.round(p.y), w: Math.round(p.w), h: Math.round(p.h) }));
67
+ }
68
+ });
69
+ }
70
+
71
+ export function deleteGroup(id) {
72
+ writeAll(readAll().filter((g) => g.id !== id));
73
+ }
74
+
75
+ export function addSession(id, sessionId, title) {
76
+ if (!sessionId) throw new Error("Falta la sesión");
77
+ return mutate(id, (group) => addPane(group, sessionId, title));
78
+ }
79
+
80
+ export function removeSession(id, sessionId) {
81
+ return mutate(id, (group) => {
82
+ group.panes = group.panes.filter((p) => p.sessionId !== sessionId);
83
+ });
84
+ }