@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,276 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { PKG_DIR, REPO_ROOT, writeConfig } from "./paths.mjs";
6
+ import { STATUS_FILE, readRemote, writeRemote } from "./remote.mjs";
7
+
8
+ const HOME = os.homedir();
9
+ export const PORT = Number(process.env.PORT ?? 4600);
10
+ export const APP_URL = `http://localhost:${PORT}`;
11
+ /** The shared relay everyone signs in to; override with MONO_RELAY or `mono connect <url>`. */
12
+ export const DEFAULT_RELAY = process.env.MONO_RELAY ?? "https://mono.155-181-39-178.sslip.io";
13
+ const SERVER_MAIN = path.join(PKG_DIR, "src", "main.mjs");
14
+ const CLI = path.join(PKG_DIR, "bin", "mono.mjs");
15
+ /** `npx` runs from a cache folder that can disappear, so nothing long-lived should point at it. */
16
+ const EPHEMERAL = PKG_DIR.includes(`${path.sep}_npx${path.sep}`);
17
+ const BUNDLED_SKILL = path.join(PKG_DIR, "skills", "workspace");
18
+ const SKILL_DIR = path.join(HOME, ".claude", "skills", "workspace");
19
+ const LOG = path.join(HOME, "Library", "Logs", "mono.log");
20
+ const SERVICE = "com.famonfa.mono";
21
+ const PLIST = path.join(HOME, "Library", "LaunchAgents", `${SERVICE}.plist`);
22
+ const PATH = [path.join(HOME, ".local/bin"), path.join(HOME, ".npm-global/bin"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":");
23
+
24
+ export async function isUp() {
25
+ try {
26
+ const res = await fetch(`${APP_URL}/api/health`, { signal: AbortSignal.timeout(800) });
27
+ return res.ok && (await res.json()).app === "mono";
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Lets the desktop app find the server without hardcoding a path, and gives Claude Code the
35
+ * `/workspace` skill that "Actualizar con IA" relies on (never overwriting one you already have).
36
+ */
37
+ function setup() {
38
+ if (!EPHEMERAL) writeConfig({ server: SERVER_MAIN, node: process.execPath });
39
+ if (fs.existsSync(BUNDLED_SKILL) && !fs.existsSync(SKILL_DIR)) fs.cpSync(BUNDLED_SKILL, SKILL_DIR, { recursive: true });
40
+ }
41
+
42
+ /** Starts the server in the background unless it (or the launchd service) is already up. */
43
+ export async function ensureServer() {
44
+ setup();
45
+ if (await isUp()) return;
46
+ fs.mkdirSync(path.dirname(LOG), { recursive: true });
47
+ const out = fs.openSync(LOG, "a");
48
+ spawn(process.execPath, [SERVER_MAIN], { cwd: HOME, detached: true, stdio: ["ignore", out, out], env: { ...process.env, PATH: `${PATH}:${process.env.PATH}` } }).unref();
49
+ for (let i = 0; i < 40; i++) {
50
+ await new Promise((r) => setTimeout(r, 250));
51
+ if (await isUp()) return;
52
+ }
53
+ throw new Error(`El server no arrancó; mirá ${LOG}`);
54
+ }
55
+
56
+ const USER_APP = path.join(HOME, "Applications", "Mono.app");
57
+
58
+ function appVersion(app) {
59
+ try {
60
+ return execFileSync("plutil", ["-extract", "CFBundleShortVersionString", "raw", "-o", "-", path.join(app, "Contents", "Info.plist")], { encoding: "utf8" }).trim();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Installs (or updates) the desktop app into ~/Applications. Downloaded here rather than in a
68
+ * browser, the app carries no quarantine flag, so macOS opens it without the "unverified" warning.
69
+ */
70
+ async function installDesktop() {
71
+ const wanted = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8")).version;
72
+ const existing = ["/Applications/Mono.app", USER_APP].find((p) => fs.existsSync(p));
73
+ if (existing && appVersion(existing) === wanted) return existing;
74
+
75
+ const url = `${DEFAULT_RELAY}/_mono/download/Mono-${process.arch === "arm64" ? "arm64" : "x64"}.zip`;
76
+ const res = await fetch(url);
77
+ if (!res.ok) throw new Error(`no pude bajar la app (${res.status})`);
78
+ const total = Number(res.headers.get("content-length")) || 0;
79
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "mono-app-"));
80
+ const zip = path.join(work, "Mono.zip");
81
+ const out = fs.createWriteStream(zip);
82
+ let done = 0;
83
+ for await (const chunk of res.body) {
84
+ out.write(chunk);
85
+ done += chunk.length;
86
+ if (total && process.stdout.isTTY) process.stdout.write(`\r${existing ? "Actualizando" : "Bajando"} la app de Mono… ${Math.round((done / total) * 100)}%`);
87
+ }
88
+ await new Promise((resolve) => out.end(resolve));
89
+ if (process.stdout.isTTY) process.stdout.write("\n");
90
+ execFileSync("ditto", ["-x", "-k", zip, work]);
91
+
92
+ fs.mkdirSync(path.dirname(USER_APP), { recursive: true });
93
+ if (existing) {
94
+ // macOS only lets Finder remove installed apps; the shell gets a half-deleted bundle.
95
+ try {
96
+ execFileSync("osascript", ["-e", `tell application "Finder" to delete (POSIX file "${existing}" as alias)`], { stdio: "ignore" });
97
+ } catch {
98
+ throw new Error(`no pude reemplazar ${existing}: tiralo a la papelera y volvé a correr el comando`);
99
+ }
100
+ }
101
+ fs.renameSync(path.join(work, "Mono.app"), USER_APP);
102
+ fs.rmSync(work, { recursive: true, force: true });
103
+ return USER_APP;
104
+ }
105
+
106
+ /**
107
+ * Opens Mono in its desktop app, installing it on first run. `--web` (or a failed download) falls
108
+ * back to the browser; a monorepo checkout uses its dev Electron shell.
109
+ */
110
+ export async function openApp({ hash = "", web = false } = {}) {
111
+ const electron = REPO_ROOT && path.join(REPO_ROOT, "node_modules", ".bin", "electron");
112
+ if (!web && electron && fs.existsSync(electron)) {
113
+ // Terminals inside Electron-based apps export ELECTRON_RUN_AS_NODE, which would start Electron as plain Node.
114
+ const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
115
+ spawn(electron, [path.join(REPO_ROOT, "apps", "desktop")], { detached: true, stdio: "ignore", env: { ...env, MONO_HASH: hash } }).unref();
116
+ return "desktop";
117
+ }
118
+ if (!web && process.platform === "darwin") {
119
+ try {
120
+ const app = await installDesktop();
121
+ // `open` forwards the caller's environment; from a terminal inside an Electron-based app that
122
+ // includes ELECTRON_RUN_AS_NODE, which would make Mono start as plain Node and quit.
123
+ const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
124
+ execFileSync("open", ["-a", app], { env });
125
+ return "desktop";
126
+ } catch (error) {
127
+ console.log(`No pude abrir la app de escritorio (${error.message}); la abro en el navegador.`);
128
+ }
129
+ }
130
+ execFileSync("open", [`${APP_URL}/${hash}`]);
131
+ return "browser";
132
+ }
133
+
134
+ export async function launch({ hash, web } = {}) {
135
+ await ensureServer();
136
+ const where = await openApp({ hash, web });
137
+ console.log(where === "desktop" ? "🐒 Mono abierto" : `🐒 Mono en ${APP_URL}`);
138
+ }
139
+
140
+ /**
141
+ * `npx @famonfa/mono` runs from npm's cache, which can be wiped, while the server has to outlive it
142
+ * (launchd, remote access). So npx only bootstraps: it installs the same version into ~/.mono/runtime
143
+ * once and hands every command to that copy.
144
+ */
145
+ export function runFromRuntime() {
146
+ if (!EPHEMERAL || process.env.MONO_NO_BOOTSTRAP) return;
147
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
148
+ const runtime = path.join(HOME, ".mono", "runtime");
149
+ const installed = path.join(runtime, "node_modules", ...pkg.name.split("/"));
150
+ let current = null;
151
+ try {
152
+ current = JSON.parse(fs.readFileSync(path.join(installed, "package.json"), "utf8")).version;
153
+ } catch {}
154
+ if (current !== pkg.version) {
155
+ console.log(current ? `Actualizando Mono ${current} → ${pkg.version}…` : `Instalando Mono ${pkg.version}…`);
156
+ fs.mkdirSync(runtime, { recursive: true });
157
+ if (!fs.existsSync(path.join(runtime, "package.json"))) fs.writeFileSync(path.join(runtime, "package.json"), '{ "private": true }\n');
158
+ execFileSync("npm", ["install", "--prefix", runtime, "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", process.env.MONO_RUNTIME_SPEC ?? `${pkg.name}@${pkg.version}`], { stdio: "inherit" });
159
+ }
160
+ const result = spawnSync(process.execPath, [path.join(installed, "bin", "mono.mjs"), ...process.argv.slice(2)], { stdio: "inherit", env: { ...process.env, MONO_NO_BOOTSTRAP: "1" } });
161
+ process.exit(result.status ?? 1);
162
+ }
163
+
164
+
165
+ /**
166
+ * Pairs this machine with a relay so the phone/web can reach it: the relay hands out a short code,
167
+ * you approve it signed in with Google, and the relay returns a host token saved in ~/.mono.
168
+ */
169
+ export async function connect(relayArg, name = os.hostname().replace(/\.local$/, "")) {
170
+ const relay = new URL(relayArg ?? readRemote()?.relay ?? DEFAULT_RELAY).origin;
171
+ const post = async (route, body) => {
172
+ const res = await fetch(new URL(route, relay), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
173
+ const json = await res.json().catch(() => ({}));
174
+ if (!res.ok) throw new Error(json.error ?? `${res.status} ${res.statusText}`);
175
+ return json;
176
+ };
177
+ const { code, pollToken, url } = await post("/_mono/pair/start", { name });
178
+ console.log(`\nAbrí este link y entrá con Google para conectar "${name}":\n\n ${url}\n\nCódigo: ${code}\n`);
179
+ try {
180
+ execFileSync("open", [url]);
181
+ } catch {}
182
+ const deadline = Date.now() + 10 * 60_000;
183
+ while (Date.now() < deadline) {
184
+ await new Promise((r) => setTimeout(r, 2000));
185
+ const result = await post("/_mono/pair/poll", { pollToken });
186
+ if (result.status === "approved") {
187
+ writeRemote({ relay, token: result.token, hostId: result.hostId, email: result.email, name });
188
+ // Remote access only helps if the server is still up after a reboot.
189
+ if (!fs.existsSync(PLIST) && !REPO_ROOT) installService();
190
+ await ensureServer();
191
+ console.log(`✓ ${name} conectado a ${relay} como ${result.email}. Abrí ${relay} desde el celular.`);
192
+ return;
193
+ }
194
+ if (result.status !== "pending") throw new Error("El código expiró o fue rechazado");
195
+ }
196
+ throw new Error("Se venció el tiempo para aprobar el código");
197
+ }
198
+
199
+ export function disconnect() {
200
+ writeRemote(null);
201
+ console.log("✓ Desconectado del relay");
202
+ }
203
+
204
+ /** How to spell commands back to the user: npx users don't have `mono` on their PATH. */
205
+ const CMD = process.env.MONO_NO_BOOTSTRAP ? "npx @famonfa/mono" : "mono";
206
+
207
+ export async function status() {
208
+ const remote = readRemote();
209
+ let live = null;
210
+ try {
211
+ live = JSON.parse(fs.readFileSync(STATUS_FILE, "utf8"));
212
+ } catch {}
213
+ console.log(`Server: ${(await isUp()) ? `✓ ${APP_URL}` : `✗ apagado (corré \`${CMD}\`)`}`);
214
+ console.log(`Servicio: ${fs.existsSync(PLIST) ? "instalado (arranca solo al iniciar sesión)" : `no instalado (\`${CMD} service install\`)`}`);
215
+ if (!remote) return console.log(`Remoto: sin conectar (\`${CMD} connect\`)`);
216
+ console.log(`Remoto: ${remote.relay} · ${remote.email} · ${live?.state ?? "?"}${live?.reason ? ` (${live.reason})` : ""}`);
217
+ }
218
+
219
+ /** Runs the server at login via launchd and puts `mono`/`ws` on PATH. */
220
+ export function installService({ ifMissing = false } = {}) {
221
+ // The desktop app asks with ifMissing so it never replaces a service someone set up from a checkout.
222
+ if (ifMissing && fs.existsSync(PLIST)) return console.log(`✓ Servicio ${SERVICE} ya instalado`);
223
+ if (EPHEMERAL) throw new Error("Corré `npx @famonfa/mono service install`");
224
+ setup();
225
+ // npm's global install already puts mono/ws on PATH; a checkout links them into ~/.local/bin.
226
+ if (REPO_ROOT) {
227
+ const bin = path.join(HOME, ".local", "bin");
228
+ fs.mkdirSync(bin, { recursive: true });
229
+ for (const name of ["mono", "ws"]) {
230
+ fs.rmSync(path.join(bin, name), { force: true });
231
+ fs.symlinkSync(CLI, path.join(bin, name));
232
+ }
233
+ }
234
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
235
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
236
+ <plist version="1.0">
237
+ <dict>
238
+ <key>Label</key><string>${SERVICE}</string>
239
+ <key>ProgramArguments</key>
240
+ <array><string>${process.execPath}</string><string>${SERVER_MAIN}</string></array>
241
+ <key>WorkingDirectory</key><string>${HOME}</string>
242
+ <key>EnvironmentVariables</key>
243
+ <dict><key>PATH</key><string>${PATH}</string>${process.versions.electron ? "<key>ELECTRON_RUN_AS_NODE</key><string>1</string>" : ""}</dict>
244
+ <key>RunAtLoad</key><true/>
245
+ <key>KeepAlive</key><true/>
246
+ <key>StandardOutPath</key><string>${LOG}</string>
247
+ <key>StandardErrorPath</key><string>${LOG}</string>
248
+ </dict>
249
+ </plist>
250
+ `;
251
+ fs.mkdirSync(path.dirname(PLIST), { recursive: true });
252
+ fs.writeFileSync(PLIST, plist);
253
+ const domain = `gui/${process.getuid()}`;
254
+ try {
255
+ execFileSync("launchctl", ["bootout", `${domain}/${SERVICE}`], { stdio: "ignore" });
256
+ } catch {}
257
+ // bootout finishes asynchronously; bootstrap fails with EIO until the old job is gone.
258
+ for (let attempt = 0; ; attempt++) {
259
+ try {
260
+ execFileSync("launchctl", ["bootstrap", domain, PLIST], { stdio: "pipe" });
261
+ break;
262
+ } catch (error) {
263
+ if (attempt >= 10) throw error;
264
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
265
+ }
266
+ }
267
+ console.log(`✓ Servicio ${SERVICE} → ${SERVER_MAIN}`);
268
+ }
269
+
270
+ export function uninstallService() {
271
+ try {
272
+ execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE}`], { stdio: "ignore" });
273
+ } catch {}
274
+ fs.rmSync(PLIST, { force: true });
275
+ console.log(`✓ Servicio ${SERVICE} desinstalado`);
276
+ }
package/src/main.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import http from "node:http";
2
+ import { createApi, serveStatic } from "./api.mjs";
3
+ import { startRemote } from "./remote.mjs";
4
+ import { WEB_DIST as DIST } from "./paths.mjs";
5
+
6
+ const PORT = Number(process.env.PORT ?? 4600);
7
+ const HOST = "127.0.0.1";
8
+
9
+ const clients = new Set();
10
+ const relay = startRemote({ port: PORT });
11
+
12
+ function broadcast(event, data) {
13
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
14
+ for (const res of clients) res.write(frame);
15
+ relay.forward(frame);
16
+ }
17
+
18
+ const api = createApi(broadcast);
19
+
20
+ const server = http.createServer((req, res) => {
21
+ if (req.url === "/api/events") {
22
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
23
+ res.write("retry: 2000\n\n");
24
+ clients.add(res);
25
+ const ping = setInterval(() => res.write(": ping\n\n"), 25_000);
26
+ req.on("close", () => {
27
+ clearInterval(ping);
28
+ clients.delete(res);
29
+ });
30
+ return;
31
+ }
32
+ api(req, res, () => {
33
+ const rel = decodeURIComponent(req.url.split("?")[0]).replace(/^\/+/, "");
34
+ if (rel && serveStatic(res, DIST, rel)) return;
35
+ if (!serveStatic(res, DIST, "index.html")) {
36
+ res.statusCode = 503;
37
+ res.end("Falta compilar la app: npm run build");
38
+ }
39
+ });
40
+ });
41
+
42
+ server.listen(PORT, HOST, () => console.log(`Mono en http://localhost:${PORT}`));
package/src/paths.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Where things live. Mono runs either from the monorepo checkout (`~/mono`, during development) or
8
+ * from the npm package (`npm i -g @famonfa/mono`); both resolve here so nothing else cares which.
9
+ */
10
+
11
+ const HOME = os.homedir();
12
+ export const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const repo = path.resolve(PKG_DIR, "../..");
14
+ /** The monorepo root when running from a checkout, otherwise null. */
15
+ export const REPO_ROOT = fs.existsSync(path.join(repo, "apps", "web", "package.json")) ? repo : null;
16
+
17
+ export const CONFIG_DIR = path.join(HOME, ".mono");
18
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
19
+
20
+ export function readConfig() {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ export function writeConfig(patch) {
29
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
30
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...readConfig(), ...patch }, null, 2));
31
+ }
32
+
33
+ /** The built UI: bundled into the package, or the web app's build output in a checkout. */
34
+ export const WEB_DIST = [path.join(PKG_DIR, "web"), REPO_ROOT && path.join(REPO_ROOT, "apps", "web", "dist")].filter(Boolean).find((dir) => fs.existsSync(path.join(dir, "index.html"))) ?? path.join(PKG_DIR, "web");
35
+
36
+ /** Tasks, boards and uploads. A checkout keeps its historical `data/` folder; installs use ~/.mono/data. */
37
+ export const DATA_DIR =
38
+ process.env.MONO_DATA_DIR ?? readConfig().dataDir ?? (REPO_ROOT && fs.existsSync(path.join(REPO_ROOT, "data")) ? path.join(REPO_ROOT, "data") : path.join(CONFIG_DIR, "data"));
39
+
40
+ /** Where chats start when no folder is picked (`defaultCwd` in ~/.mono/config.json; home otherwise). */
41
+ export function defaultCwd() {
42
+ const dir = readConfig().defaultCwd;
43
+ return dir && fs.existsSync(dir) ? dir : HOME;
44
+ }
@@ -0,0 +1,199 @@
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 { DATA_DIR } from "./store.mjs";
6
+
7
+ const T3_DB = path.join(os.homedir(), ".t3", "userdata", "state.sqlite");
8
+ const CLAUDE_CONFIG = path.join(os.homedir(), ".claude.json");
9
+ const OVERRIDES_FILE = path.join(DATA_DIR, "projects.json");
10
+ const CACHE_MS = 30_000;
11
+ const FAVICONS = ["favicon.svg", "favicon.png", "favicon.ico", "public/favicon.svg", "public/favicon.png", "public/favicon.ico", "assets/favicon.svg", "assets/favicon.png", "app/favicon.ico", "static/favicon.ico"];
12
+ const COLORS = ["#eb5757", "#f2994a", "#d9a23b", "#4cb782", "#26b5ce", "#5e6ad2", "#bb87fc", "#e15a97"];
13
+
14
+ let cache = { at: 0, projects: [] };
15
+
16
+ function realpath(p) {
17
+ try {
18
+ return fs.realpathSync(p);
19
+ } catch {
20
+ return p;
21
+ }
22
+ }
23
+
24
+ function slug(text) {
25
+ return text
26
+ .toLowerCase()
27
+ .normalize("NFD")
28
+ .replace(/[̀-ͯ]/g, "")
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-|-$/g, "");
31
+ }
32
+
33
+ function hashColor(text) {
34
+ let h = 0;
35
+ for (const ch of text) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
36
+ return COLORS[h % COLORS.length];
37
+ }
38
+
39
+ export function readOverrides() {
40
+ try {
41
+ return JSON.parse(fs.readFileSync(OVERRIDES_FILE, "utf8"));
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /** Projects T3 Code knows about (read-only), so both apps share the same list of directories. */
48
+ function readT3Projects() {
49
+ if (!fs.existsSync(T3_DB)) return [];
50
+ try {
51
+ const out = execFileSync("sqlite3", ["-readonly", "-json", `file:${T3_DB}?mode=ro`, "select title, workspace_root as root from projection_projects where deleted_at is null order by created_at"], {
52
+ encoding: "utf8",
53
+ timeout: 5000,
54
+ });
55
+ return JSON.parse(out || "[]");
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /** Without T3 Code: the git repos Claude Code has been used in (keys of `projects` in ~/.claude.json). */
62
+ function readClaudeProjects() {
63
+ try {
64
+ const dirs = Object.keys(JSON.parse(fs.readFileSync(CLAUDE_CONFIG, "utf8")).projects ?? {});
65
+ return dirs.filter((dir) => dir !== os.homedir() && fs.existsSync(path.join(dir, ".git"))).map((root) => ({ title: path.basename(root), root }));
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ /** Every project T3 knows, including ones nested inside another (internal: folder pickers, launch roots). */
72
+ export function allProjects({ fresh = false } = {}) {
73
+ if (!fresh && Date.now() - cache.at < CACHE_MS) return cache.projects;
74
+ const overrides = readOverrides();
75
+ const seen = new Set();
76
+ const projects = [];
77
+ const t3 = readT3Projects();
78
+ for (const { title, root } of [...(t3.length ? t3 : readClaudeProjects()), ...(overrides.extra ?? [])]) {
79
+ const real = realpath(root);
80
+ if (seen.has(real) || !fs.existsSync(real)) continue;
81
+ seen.add(real);
82
+ const custom = overrides[real] ?? {};
83
+ let id = slug(title) || slug(path.basename(real));
84
+ while (projects.some((p) => p.id === id)) id = `${id}-2`;
85
+ const favicon = FAVICONS.map((f) => path.join(real, f)).find((f) => fs.existsSync(f));
86
+ const name = custom.title ?? title;
87
+ projects.push({
88
+ id,
89
+ title: name,
90
+ root: real,
91
+ initials: (name[0] + name[name.length - 1]).toUpperCase(),
92
+ color: custom.color ?? hashColor(name),
93
+ favicon: custom.color ? undefined : favicon,
94
+ });
95
+ }
96
+ for (const project of projects) project.parentId = parentOf(project, projects)?.id ?? null;
97
+ cache = { at: Date.now(), projects };
98
+ return projects;
99
+ }
100
+
101
+ const HOME_DIR = realpath(os.homedir());
102
+ const isInside = (dir, root) => dir === root || dir.startsWith(root + path.sep);
103
+
104
+ /**
105
+ * The nearest project that contains this one (avocado contains instamenu, KDS…). The home folder is a
106
+ * catch-all project, so it never acts as a parent.
107
+ */
108
+ function parentOf(project, projects) {
109
+ return projects
110
+ .filter((p) => p !== project && p.root !== HOME_DIR && isInside(project.root, p.root))
111
+ .sort((a, b) => b.root.length - a.root.length)[0];
112
+ }
113
+
114
+ /**
115
+ * Projects shown in the app: only outermost ones. A repo inside avocado (instamenu, KDS…) is a folder of
116
+ * avocado, not a project of its own — avocado is the single home for that family and holds the skills.
117
+ */
118
+ export function listProjects(opts) {
119
+ return allProjects(opts).filter((p) => !p.parentId);
120
+ }
121
+
122
+ function topProjectId(id) {
123
+ const projects = allProjects();
124
+ let current = projects.find((p) => p.id === id);
125
+ while (current?.parentId) current = projects.find((p) => p.id === current.parentId);
126
+ return current?.id ?? null;
127
+ }
128
+
129
+ export function descendantIds(projectId) {
130
+ const projects = allProjects();
131
+ const ids = new Set([projectId]);
132
+ let grew = true;
133
+ while (grew) {
134
+ grew = false;
135
+ for (const p of projects) if (p.parentId && ids.has(p.parentId) && !ids.has(p.id)) (ids.add(p.id), (grew = true));
136
+ }
137
+ return ids;
138
+ }
139
+
140
+ /**
141
+ * Where a session must start so it loads the shared skills and CLAUDE.md: the outermost project that
142
+ * contains the folder (avocado for anything under it). Claude Code doesn't load skills from parent folders.
143
+ */
144
+ export function launchRootFor(dir) {
145
+ const real = realpath(dir);
146
+ const main = mainRepoRoot(real) ?? real;
147
+ const containing = allProjects().filter((p) => p.root !== HOME_DIR && (isInside(real, p.root) || isInside(main, p.root)));
148
+ return containing.sort((a, b) => a.root.length - b.root.length)[0]?.root ?? real;
149
+ }
150
+
151
+ export function saveOverride(projectId, patch) {
152
+ const project = listProjects().find((p) => p.id === projectId);
153
+ if (!project) throw new Error(`No existe el proyecto ${projectId}`);
154
+ const overrides = readOverrides();
155
+ overrides[project.root] = { ...overrides[project.root], ...patch };
156
+ fs.mkdirSync(path.dirname(OVERRIDES_FILE), { recursive: true });
157
+ fs.writeFileSync(OVERRIDES_FILE, JSON.stringify(overrides, null, 2));
158
+ return allProjects({ fresh: true }).find((p) => p.root === project.root);
159
+ }
160
+
161
+ const repoRootCache = new Map();
162
+
163
+ /** For a path inside a git worktree, the main checkout it belongs to (so `.worktrees/X/KDS` counts as KDS). */
164
+ function mainRepoRoot(dir) {
165
+ if (repoRootCache.has(dir)) return repoRootCache.get(dir);
166
+ let root = null;
167
+ try {
168
+ const common = execFileSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
169
+ if (common.endsWith(`${path.sep}.git`)) root = realpath(path.dirname(common));
170
+ } catch {}
171
+ repoRootCache.set(dir, root);
172
+ return root;
173
+ }
174
+
175
+ export function projectForPath(dir) {
176
+ if (!dir) return null;
177
+ const real = realpath(dir);
178
+ const candidates = [mainRepoRoot(real), real].filter(Boolean);
179
+ const projects = listProjects();
180
+ for (const candidate of candidates) {
181
+ const match = projects
182
+ .filter((p) => candidate === p.root || candidate.startsWith(p.root + path.sep))
183
+ .sort((a, b) => b.root.length - a.root.length)[0];
184
+ if (match) return match.id;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /** Explicit `projects` on the task win; otherwise repos are matched to projects by folder or title. Nested ones roll up to their outermost project. */
190
+ export function projectsForTask(task) {
191
+ const projects = allProjects();
192
+ const explicit = Array.isArray(task.projects) && task.projects.length ? task.projects : null;
193
+ const ids = explicit ?? (task.repos ?? []).map((repo) => projects.find((p) => path.basename(p.root) === repo.name || p.title === repo.name)?.id);
194
+ return [...new Set(ids.map((id) => id && topProjectId(id)).filter(Boolean))];
195
+ }
196
+
197
+ export function projectIcon(projectId) {
198
+ return allProjects().find((p) => p.id === projectId)?.favicon ?? null;
199
+ }