@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
@@ -0,0 +1,212 @@
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
+ /** Opens Mono like `t3` does: the installed desktop app, the dev Electron shell, or the browser. */
57
+ export function openApp(hash = "") {
58
+ const installed = ["/Applications/Mono.app", path.join(HOME, "Applications", "Mono.app")].find((p) => fs.existsSync(p));
59
+ if (installed) return execFileSync("open", ["-a", installed]), "desktop";
60
+ const electron = REPO_ROOT && path.join(REPO_ROOT, "node_modules", ".bin", "electron");
61
+ if (electron && fs.existsSync(electron)) {
62
+ // Terminals inside Electron apps (T3 Code, VS Code) export ELECTRON_RUN_AS_NODE, which would start Electron as plain Node.
63
+ const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
64
+ spawn(electron, [path.join(REPO_ROOT, "apps", "desktop")], { detached: true, stdio: "ignore", env: { ...env, MONO_HASH: hash } }).unref();
65
+ return "desktop";
66
+ }
67
+ execFileSync("open", [`${APP_URL}/${hash}`]);
68
+ return "browser";
69
+ }
70
+
71
+ /**
72
+ * `npx @famonfa/mono` runs from npm's cache, which can be wiped, while the server has to outlive it
73
+ * (launchd, remote access). So npx only bootstraps: it installs the same version into ~/.mono/runtime
74
+ * once and hands every command to that copy.
75
+ */
76
+ export function runFromRuntime() {
77
+ if (!EPHEMERAL || process.env.MONO_NO_BOOTSTRAP) return;
78
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
79
+ const runtime = path.join(HOME, ".mono", "runtime");
80
+ const installed = path.join(runtime, "node_modules", ...pkg.name.split("/"));
81
+ let current = null;
82
+ try {
83
+ current = JSON.parse(fs.readFileSync(path.join(installed, "package.json"), "utf8")).version;
84
+ } catch {}
85
+ if (current !== pkg.version) {
86
+ console.log(current ? `Actualizando Mono ${current} → ${pkg.version}…` : `Instalando Mono ${pkg.version}…`);
87
+ fs.mkdirSync(runtime, { recursive: true });
88
+ if (!fs.existsSync(path.join(runtime, "package.json"))) fs.writeFileSync(path.join(runtime, "package.json"), '{ "private": true }\n');
89
+ execFileSync("npm", ["install", "--prefix", runtime, "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", process.env.MONO_RUNTIME_SPEC ?? `${pkg.name}@${pkg.version}`], { stdio: "inherit" });
90
+ }
91
+ const result = spawnSync(process.execPath, [path.join(installed, "bin", "mono.mjs"), ...process.argv.slice(2)], { stdio: "inherit", env: { ...process.env, MONO_NO_BOOTSTRAP: "1" } });
92
+ process.exit(result.status ?? 1);
93
+ }
94
+
95
+ export async function launch(hash) {
96
+ await ensureServer();
97
+ const where = openApp(hash);
98
+ console.log(`🐒 Mono en ${APP_URL} (${where === "desktop" ? "app de escritorio" : "navegador"})`);
99
+ }
100
+
101
+ /**
102
+ * Pairs this machine with a relay so the phone/web can reach it: the relay hands out a short code,
103
+ * you approve it signed in with Google, and the relay returns a host token saved in ~/.mono.
104
+ */
105
+ export async function connect(relayArg, name = os.hostname().replace(/\.local$/, "")) {
106
+ const relay = new URL(relayArg ?? readRemote()?.relay ?? DEFAULT_RELAY).origin;
107
+ const post = async (route, body) => {
108
+ const res = await fetch(new URL(route, relay), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
109
+ const json = await res.json().catch(() => ({}));
110
+ if (!res.ok) throw new Error(json.error ?? `${res.status} ${res.statusText}`);
111
+ return json;
112
+ };
113
+ const { code, pollToken, url } = await post("/_mono/pair/start", { name });
114
+ console.log(`\nAbrí este link y entrá con Google para conectar "${name}":\n\n ${url}\n\nCódigo: ${code}\n`);
115
+ try {
116
+ execFileSync("open", [url]);
117
+ } catch {}
118
+ const deadline = Date.now() + 10 * 60_000;
119
+ while (Date.now() < deadline) {
120
+ await new Promise((r) => setTimeout(r, 2000));
121
+ const result = await post("/_mono/pair/poll", { pollToken });
122
+ if (result.status === "approved") {
123
+ writeRemote({ relay, token: result.token, hostId: result.hostId, email: result.email, name });
124
+ // Remote access only helps if the server is still up after a reboot.
125
+ if (!fs.existsSync(PLIST) && !REPO_ROOT) installService();
126
+ await ensureServer();
127
+ console.log(`✓ ${name} conectado a ${relay} como ${result.email}. Abrí ${relay} desde el celular.`);
128
+ return;
129
+ }
130
+ if (result.status !== "pending") throw new Error("El código expiró o fue rechazado");
131
+ }
132
+ throw new Error("Se venció el tiempo para aprobar el código");
133
+ }
134
+
135
+ export function disconnect() {
136
+ writeRemote(null);
137
+ console.log("✓ Desconectado del relay");
138
+ }
139
+
140
+ /** How to spell commands back to the user: npx users don't have `mono` on their PATH. */
141
+ const CMD = process.env.MONO_NO_BOOTSTRAP ? "npx @famonfa/mono" : "mono";
142
+
143
+ export async function status() {
144
+ const remote = readRemote();
145
+ let live = null;
146
+ try {
147
+ live = JSON.parse(fs.readFileSync(STATUS_FILE, "utf8"));
148
+ } catch {}
149
+ console.log(`Server: ${(await isUp()) ? `✓ ${APP_URL}` : `✗ apagado (corré \`${CMD}\`)`}`);
150
+ console.log(`Servicio: ${fs.existsSync(PLIST) ? "instalado (arranca solo al iniciar sesión)" : `no instalado (\`${CMD} service install\`)`}`);
151
+ if (!remote) return console.log(`Remoto: sin conectar (\`${CMD} connect\`)`);
152
+ console.log(`Remoto: ${remote.relay} · ${remote.email} · ${live?.state ?? "?"}${live?.reason ? ` (${live.reason})` : ""}`);
153
+ }
154
+
155
+ /** Runs the server at login via launchd and puts `mono`/`ws` on PATH. */
156
+ export function installService({ ifMissing = false } = {}) {
157
+ // The desktop app asks with ifMissing so it never replaces a service someone set up from a checkout.
158
+ if (ifMissing && fs.existsSync(PLIST)) return console.log(`✓ Servicio ${SERVICE} ya instalado`);
159
+ if (EPHEMERAL) throw new Error("Corré `npx @famonfa/mono service install`");
160
+ setup();
161
+ // npm's global install already puts mono/ws on PATH; a checkout links them into ~/.local/bin.
162
+ if (REPO_ROOT) {
163
+ const bin = path.join(HOME, ".local", "bin");
164
+ fs.mkdirSync(bin, { recursive: true });
165
+ for (const name of ["mono", "ws"]) {
166
+ fs.rmSync(path.join(bin, name), { force: true });
167
+ fs.symlinkSync(CLI, path.join(bin, name));
168
+ }
169
+ }
170
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
171
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
172
+ <plist version="1.0">
173
+ <dict>
174
+ <key>Label</key><string>${SERVICE}</string>
175
+ <key>ProgramArguments</key>
176
+ <array><string>${process.execPath}</string><string>${SERVER_MAIN}</string></array>
177
+ <key>WorkingDirectory</key><string>${HOME}</string>
178
+ <key>EnvironmentVariables</key>
179
+ <dict><key>PATH</key><string>${PATH}</string>${process.versions.electron ? "<key>ELECTRON_RUN_AS_NODE</key><string>1</string>" : ""}</dict>
180
+ <key>RunAtLoad</key><true/>
181
+ <key>KeepAlive</key><true/>
182
+ <key>StandardOutPath</key><string>${LOG}</string>
183
+ <key>StandardErrorPath</key><string>${LOG}</string>
184
+ </dict>
185
+ </plist>
186
+ `;
187
+ fs.mkdirSync(path.dirname(PLIST), { recursive: true });
188
+ fs.writeFileSync(PLIST, plist);
189
+ const domain = `gui/${process.getuid()}`;
190
+ try {
191
+ execFileSync("launchctl", ["bootout", `${domain}/${SERVICE}`], { stdio: "ignore" });
192
+ } catch {}
193
+ // bootout finishes asynchronously; bootstrap fails with EIO until the old job is gone.
194
+ for (let attempt = 0; ; attempt++) {
195
+ try {
196
+ execFileSync("launchctl", ["bootstrap", domain, PLIST], { stdio: "pipe" });
197
+ break;
198
+ } catch (error) {
199
+ if (attempt >= 10) throw error;
200
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
201
+ }
202
+ }
203
+ console.log(`✓ Servicio ${SERVICE} → ${SERVER_MAIN}`);
204
+ }
205
+
206
+ export function uninstallService() {
207
+ try {
208
+ execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${SERVICE}`], { stdio: "ignore" });
209
+ } catch {}
210
+ fs.rmSync(PLIST, { force: true });
211
+ console.log(`✓ Servicio ${SERVICE} desinstalado`);
212
+ }
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
+ }
@@ -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
+ }