@lyric_dev/data-loom 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,9 +59,9 @@ data-loom autostart status # is autostart registered?
59
59
  data-loom autostart disable # remove the login item (does not stop a running daemon)
60
60
  ```
61
61
 
62
- `enable` also starts the daemon immediately; pass `--no-start` to only register for next login. The login item is per-user and needs no admin rights — a Startup-folder shortcut on Windows, a LaunchAgent on macOS, an XDG autostart entry on Linux.
62
+ `enable` also starts the daemon immediately **and registers DataLoom with Claude Code** (the same as `data-loom connect claude-code`), so the always-on path both hosts the MCP endpoint and points Claude Code at it in one command. Pass `--no-start` to only register for next login, or `--no-connect` to skip the Claude Code registration. The Claude Code step is best-effort — if the `claude` CLI isn't found, `enable` warns and still sets up the login item and daemon. The login item is per-user and needs no admin rights — a Startup-folder shortcut on Windows, a LaunchAgent on macOS, an XDG autostart entry on Linux.
63
63
 
64
- Everything is reversible: `data-loom stop`, `data-loom autostart disable`, and `data-loom disconnect claude-desktop` (below) undo each side effect, and each is idempotent.
64
+ Everything is reversible: `data-loom stop`, `data-loom autostart disable`, `data-loom disconnect claude-code`, and `data-loom disconnect claude-desktop` (below) undo each side effect, and each is idempotent.
65
65
 
66
66
  ## Run from source (development)
67
67
 
@@ -85,12 +85,20 @@ npm start # serves the current directory's project
85
85
 
86
86
  The running daemon also **hosts an MCP server** over HTTP, so your own Claude session determines and applies the order of interdependent proposals — DataLoom holds no API key and the reasoning runs under your authenticated Claude. One registration serves **every** project; the target project is resolved per call (an explicit `project` argument, falling back to whatever the dashboard has selected).
87
87
 
88
- 1. Register it once, globally — no per-project setup:
88
+ 1. Register it once, globally — no per-project setup. The easy way:
89
+
90
+ ```
91
+ data-loom connect claude-code
92
+ ```
93
+
94
+ This registers DataLoom's loopback endpoint with Claude Code at user scope via Claude Code's own CLI (it runs `claude mcp add` for you; DataLoom never edits `~/.claude.json` itself). Start a new Claude Code session to pick it up, and remove it any time with `data-loom disconnect claude-code`. If the `claude` CLI isn't on your PATH, the command prints the manual line to run instead:
89
95
 
90
96
  ```
91
97
  claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
92
98
  ```
93
99
 
100
+ Either way, if you enable always-on autostart (below), `data-loom autostart enable` already runs this registration for you — so a fresh install can be one command.
101
+
94
102
  The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `data-loom start` or `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
95
103
 
96
104
  **Claude Desktop** reaches the same daemon — register it with:
@@ -0,0 +1,81 @@
1
+ // Claude Code integration: register DataLoom's loopback MCP endpoint with
2
+ // Claude Code at user (global) scope, so the same running daemon that serves
3
+ // the dashboard also serves a Claude Code session.
4
+ //
5
+ // Unlike the Claude Desktop integration (which edits a small, DataLoom-owned
6
+ // config file directly), this registers through Claude Code's OWN CLI
7
+ // (`claude mcp add`/`remove`). DataLoom never reads or writes `~/.claude.json`
8
+ // itself — that file holds OAuth tokens and per-project history and is written
9
+ // live by any running session, so its one owner (the `claude` CLI) serializes
10
+ // the write. When the `claude` CLI is missing, connect degrades to printing the
11
+ // manual command rather than failing hard.
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { mcpUrl } from "./paths.js";
15
+ const execFileP = promisify(execFile);
16
+ const KEY = "data-loom";
17
+ /** On Windows the CLI is a `.cmd`/`.ps1` shim, so it must be invoked through a shell. */
18
+ function runClaude(args) {
19
+ return execFileP("claude", args, { shell: process.platform === "win32" });
20
+ }
21
+ /** The registration command a user can run by hand when the `claude` CLI is absent. */
22
+ export function manualCommand() {
23
+ return `claude mcp add --transport http --scope user ${KEY} ${mcpUrl()}`;
24
+ }
25
+ /** True iff the `claude` CLI is invocable (resolves its version). */
26
+ async function claudeAvailable() {
27
+ try {
28
+ await runClaude(["--version"]);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** Remove any existing user-scope registration; tolerates "not registered". */
36
+ async function removeRegistration() {
37
+ try {
38
+ await runClaude(["mcp", "remove", "--scope", "user", KEY]);
39
+ }
40
+ catch {
41
+ /* not registered (or nothing to remove) — idempotent no-op */
42
+ }
43
+ }
44
+ /**
45
+ * Register DataLoom with Claude Code at user scope, pointing at the daemon's
46
+ * loopback HTTP MCP endpoint. Idempotent upsert: remove any prior entry, then
47
+ * add, so exactly one current entry remains. Returns false (with printed
48
+ * guidance) when the `claude` CLI is unavailable, so callers can degrade
49
+ * gracefully instead of failing.
50
+ */
51
+ export async function connect() {
52
+ if (!(await claudeAvailable())) {
53
+ console.log("[data-loom] the `claude` CLI was not found — cannot register automatically.\n" +
54
+ `[data-loom] register it by hand once with:\n ${manualCommand()}`);
55
+ return false;
56
+ }
57
+ await removeRegistration();
58
+ await runClaude(["mcp", "add", "--transport", "http", "--scope", "user", KEY, mcpUrl()]);
59
+ return true;
60
+ }
61
+ /**
62
+ * Remove DataLoom's user-scope registration from Claude Code. Idempotent —
63
+ * reports whether anything was actually registered. Throws only if the `claude`
64
+ * CLI itself is unavailable (nothing could have been registered without it).
65
+ */
66
+ export async function disconnect() {
67
+ if (!(await claudeAvailable())) {
68
+ throw new Error("the `claude` CLI was not found — nothing could have been registered");
69
+ }
70
+ // `claude mcp get <name>` exits non-zero when the entry is absent; use it to
71
+ // report removed-vs-nothing without parsing `list` output.
72
+ let existed = true;
73
+ try {
74
+ await runClaude(["mcp", "get", KEY]);
75
+ }
76
+ catch {
77
+ existed = false;
78
+ }
79
+ await removeRegistration();
80
+ return { removed: existed };
81
+ }
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import { HOST, PORT } from "./paths.js";
16
16
  import * as lifecycle from "./lifecycle.js";
17
17
  import * as autostart from "./autostart.js";
18
18
  import * as claudeDesktop from "./claudeDesktop.js";
19
+ import * as claudeCode from "./claudeCode.js";
20
+ import { initTray } from "./tray.js";
19
21
  const host = HOST;
20
22
  const port = PORT;
21
23
  // Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
@@ -46,28 +48,53 @@ async function main() {
46
48
  else {
47
49
  console.log("[data-loom] no openspec project found at launch — open the dashboard and pick one");
48
50
  }
49
- server = await startServer({
50
- publicDir: resolvePublicDir(),
51
- host,
52
- port,
53
- getRoadmap: () => session?.model ?? null,
54
- getMcp,
55
- checkMcp,
56
- getProjects,
57
- selectProject,
58
- getCurrentProject: () => session?.project ?? null,
59
- });
60
- console.log(`[data-loom] dashboard ready at http://${host}:${server.port}`);
51
+ try {
52
+ server = await startServer({
53
+ publicDir: resolvePublicDir(),
54
+ host,
55
+ port,
56
+ getRoadmap: () => session?.model ?? null,
57
+ getMcp,
58
+ checkMcp,
59
+ getProjects,
60
+ selectProject,
61
+ getCurrentProject: () => session?.project ?? null,
62
+ });
63
+ }
64
+ catch (err) {
65
+ if (err.code === "EADDRINUSE") {
66
+ // A DataLoom daemon is already on this port (e.g. the background one).
67
+ // That's fine — point at the running instance instead of crashing.
68
+ console.log(`[data-loom] already running at http://${host}:${port} — using that instance.`);
69
+ openBrowser(`http://${host}:${port}`);
70
+ session?.stopWatch(); // release the watcher started for this aborted launch
71
+ return;
72
+ }
73
+ throw err;
74
+ }
75
+ const url = `http://${host}:${server.port}`;
76
+ console.log(`[data-loom] dashboard ready at ${url}`);
61
77
  if (session)
62
78
  console.log(`[data-loom] project: ${session.project}`);
63
- openBrowser(`http://${host}:${server.port}`);
79
+ openBrowser(url);
80
+ // Tray icon: an ambient "DataLoom is running" indicator (essential in the
81
+ // detached mode, which has no console). Guarded no-op where unavailable.
82
+ let tray = { dispose: () => { } };
64
83
  const shutdown = () => {
84
+ tray.dispose();
65
85
  session?.stopWatch();
66
86
  server?.close();
67
87
  process.exit(0);
68
88
  };
69
89
  process.on("SIGINT", shutdown);
70
90
  process.on("SIGTERM", shutdown);
91
+ tray = initTray({
92
+ url,
93
+ onOpen: () => launchBrowser(url),
94
+ onCopy: () => copyToClipboard(url),
95
+ onStop: shutdown,
96
+ log: (msg) => console.error(msg),
97
+ });
71
98
  }
72
99
  async function buildSession(project) {
73
100
  const client = new OpenSpecClient(project);
@@ -132,6 +159,10 @@ function openBrowser(url) {
132
159
  // terminal, so never pop a browser for them (nor when explicitly suppressed).
133
160
  if (process.env.DATA_LOOM_NO_OPEN || process.env.DATA_LOOM_DETACHED)
134
161
  return;
162
+ launchBrowser(url);
163
+ }
164
+ /** Open a URL in the default browser unconditionally (used by the tray). */
165
+ function launchBrowser(url) {
135
166
  try {
136
167
  if (process.platform === "win32") {
137
168
  spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
@@ -147,6 +178,21 @@ function openBrowser(url) {
147
178
  /* non-fatal — the URL is already logged */
148
179
  }
149
180
  }
181
+ /** Best-effort copy of text to the system clipboard; failure is silent. */
182
+ function copyToClipboard(text) {
183
+ try {
184
+ const proc = process.platform === "win32"
185
+ ? spawn("clip")
186
+ : process.platform === "darwin"
187
+ ? spawn("pbcopy")
188
+ : spawn("xclip", ["-selection", "clipboard"]);
189
+ proc.on("error", () => { });
190
+ proc.stdin?.end(text);
191
+ }
192
+ catch {
193
+ /* no clipboard tool available — non-fatal */
194
+ }
195
+ }
150
196
  // ---- CLI verb dispatch -----------------------------------------------------
151
197
  // A leading reserved verb selects a lifecycle/autostart/integration command;
152
198
  // anything else falls through to the foreground daemon (with argv[2] as the
@@ -161,6 +207,18 @@ async function runAutostart(rest) {
161
207
  // opt out with --no-start.
162
208
  if (!rest.includes("--no-start"))
163
209
  await lifecycle.start();
210
+ // ...and points Claude Code at it, so the always-on path both hosts and
211
+ // registers the MCP endpoint. Best-effort: a missing `claude` CLI must not
212
+ // fail the enable (the login item + daemon already succeeded). Opt out with
213
+ // --no-connect.
214
+ if (!rest.includes("--no-connect")) {
215
+ try {
216
+ await claudeCode.connect();
217
+ }
218
+ catch (err) {
219
+ console.warn(`[data-loom] could not register with Claude Code (continuing): ${err instanceof Error ? err.message : err}`);
220
+ }
221
+ }
164
222
  return;
165
223
  }
166
224
  if (sub === "disable") {
@@ -174,23 +232,44 @@ async function runAutostart(rest) {
174
232
  : "[data-loom] autostart is not enabled");
175
233
  return;
176
234
  }
177
- throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start]");
235
+ throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start] [--no-connect]");
178
236
  }
179
237
  async function runConnect(rest) {
180
- if (rest[0] !== "claude-desktop")
181
- throw new Error("usage: data-loom connect claude-desktop [--bridge]");
182
- const bridge = rest.includes("--bridge");
183
- const path = await claudeDesktop.connect({ bridge });
184
- console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
185
- console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
238
+ const target = rest[0];
239
+ if (target === "claude-code") {
240
+ const registered = await claudeCode.connect();
241
+ if (registered) {
242
+ console.log("[data-loom] registered DataLoom in Claude Code (user scope, native HTTP)");
243
+ console.log("[data-loom] start a new Claude Code session (or /mcp reconnect) to pick it up; DataLoom must be running to serve the tools.");
244
+ }
245
+ return;
246
+ }
247
+ if (target === "claude-desktop") {
248
+ const bridge = rest.includes("--bridge");
249
+ const path = await claudeDesktop.connect({ bridge });
250
+ console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
251
+ console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
252
+ return;
253
+ }
254
+ throw new Error("usage: data-loom connect <claude-code|claude-desktop [--bridge]>");
186
255
  }
187
256
  async function runDisconnect(rest) {
188
- if (rest[0] !== "claude-desktop")
189
- throw new Error("usage: data-loom disconnect claude-desktop");
190
- const { path, removed } = await claudeDesktop.disconnect();
191
- console.log(removed
192
- ? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
193
- : "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
257
+ const target = rest[0];
258
+ if (target === "claude-code") {
259
+ const { removed } = await claudeCode.disconnect();
260
+ console.log(removed
261
+ ? "[data-loom] removed DataLoom from Claude Code"
262
+ : "[data-loom] DataLoom was not registered in Claude Code — nothing to remove");
263
+ return;
264
+ }
265
+ if (target === "claude-desktop") {
266
+ const { path, removed } = await claudeDesktop.disconnect();
267
+ console.log(removed
268
+ ? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
269
+ : "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
270
+ return;
271
+ }
272
+ throw new Error("usage: data-loom disconnect <claude-code|claude-desktop>");
194
273
  }
195
274
  async function runCli(argv) {
196
275
  const [verb, ...rest] = argv;
package/dist/mcpServer.js CHANGED
@@ -7,9 +7,11 @@
7
7
  // server serves every project: the target project is resolved per call from an
8
8
  // explicit `project` argument, falling back to the daemon's current dashboard
9
9
  // selection. The server holds no single project frozen for its lifetime.
10
+ import { readFileSync } from "node:fs";
10
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
11
12
  import { homedir } from "node:os";
12
- import { join, resolve } from "node:path";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
13
15
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
16
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
17
  import { OpenSpecClient } from "./openspecClient.js";
@@ -23,6 +25,19 @@ import { discoverProjects, isViewableProject } from "./projects.js";
23
25
  */
24
26
  class ToolError extends Error {
25
27
  }
28
+ // The advertised server version — read from the package manifest shipped next
29
+ // to the compiled code, so it can never drift from the released version again
30
+ // (it was hardcoded and stuck at 0.4.1 through the 0.5.0 release).
31
+ const VERSION = (() => {
32
+ try {
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
35
+ return manifest.version ?? "0.0.0";
36
+ }
37
+ catch {
38
+ return "0.0.0";
39
+ }
40
+ })();
26
41
  // Advertised to the client on connect. The "confirm before writing" gate lives
27
42
  // here, in the agent's behavior — the server cannot verify a human approved.
28
43
  const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
@@ -75,7 +90,7 @@ const PROJECT_ARG = {
75
90
  * the dashboard selection is the single fallback source of project truth.
76
91
  */
77
92
  export function createMcpServer(deps) {
78
- const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
93
+ const server = new Server({ name: "data-loom", version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
79
94
  // Resolve the target project for a call: explicit arg, then dashboard
80
95
  // selection, then an instructive error. Validated as a real workspace before
81
96
  // any read or write.
package/dist/server.js CHANGED
@@ -117,6 +117,16 @@ export async function startServer(opts) {
117
117
  sendError(res, 413, "request body too large");
118
118
  return;
119
119
  }
120
+ if (e instanceof InvalidJsonError) {
121
+ // A body that never parsed is the caller's encoding fault — answer with
122
+ // a JSON-RPC parse error, not a 500. The classic trigger is a Windows
123
+ // path with unescaped backslashes ("D:\projects\…" — \p is not a valid
124
+ // JSON escape), so name that in the hint. The detail only ever echoes
125
+ // the parser's complaint about the caller's own body, never host state.
126
+ if (!res.headersSent)
127
+ sendRpcParseError(res, e.message);
128
+ return;
129
+ }
120
130
  // Unexpected failures stay in the server log; the client gets nothing
121
131
  // about the host (no paths, no stack).
122
132
  console.error("[data-loom] mcp request error:", e);
@@ -174,6 +184,16 @@ export async function startServer(opts) {
174
184
  server: http,
175
185
  verifyClient: (info) => isAllowed(info.req),
176
186
  });
187
+ // ws re-emits the underlying server's 'error'; without a listener a bind
188
+ // failure (EADDRINUSE) becomes an unhandled 'error' event that crashes the
189
+ // process. The listen() promise below surfaces startup bind errors to the
190
+ // caller, so ignore those here (avoids a scary duplicate stack on the normal
191
+ // "already running" path); log only unexpected post-startup socket errors.
192
+ wss.on("error", (err) => {
193
+ if (err.code !== "EADDRINUSE") {
194
+ console.error("[data-loom] websocket error:", err);
195
+ }
196
+ });
177
197
  wss.on("connection", async (ws) => {
178
198
  const roadmap = getRoadmap();
179
199
  if (roadmap)
@@ -186,7 +206,18 @@ export async function startServer(opts) {
186
206
  /* project list optional */
187
207
  }
188
208
  });
189
- await new Promise((ready) => http.listen(port, host, ready));
209
+ // Reject (rather than crash) if the port is taken — the caller decides how to
210
+ // report an already-running instance. After a clean listen, later server
211
+ // errors are logged, not fatal.
212
+ await new Promise((resolve, reject) => {
213
+ const onError = (err) => reject(err);
214
+ http.once("error", onError);
215
+ http.listen(port, host, () => {
216
+ http.removeListener("error", onError);
217
+ http.on("error", (err) => console.error("[data-loom] http server error:", err));
218
+ resolve();
219
+ });
220
+ });
190
221
  const actualPort = http.address()?.port ?? port;
191
222
  setAllowed(actualPort);
192
223
  return {
@@ -207,9 +238,29 @@ function sendError(res, code, message) {
207
238
  res.writeHead(code, { "content-type": MIME[".json"] });
208
239
  res.end(JSON.stringify({ error: message }));
209
240
  }
241
+ /**
242
+ * Answer a request whose body was not valid JSON with a JSON-RPC parse error
243
+ * (-32700) over HTTP 400, per the Streamable-HTTP spec. `detail` is the JSON
244
+ * parser's message about the caller's own payload — safe to echo, and the hint
245
+ * names the most common cause on Windows (unescaped path backslashes).
246
+ */
247
+ function sendRpcParseError(res, detail) {
248
+ res.writeHead(400, { "content-type": MIME[".json"] });
249
+ res.end(JSON.stringify({
250
+ jsonrpc: "2.0",
251
+ error: {
252
+ code: -32700,
253
+ message: `Parse error: request body is not valid JSON (${detail}). If the payload contains Windows paths, JSON-escape the backslashes ("D:\\\\projects\\\\…") or use forward slashes.`,
254
+ },
255
+ id: null,
256
+ }));
257
+ }
210
258
  /** Raised when a request body exceeds the cap; mapped to HTTP 413. */
211
259
  class PayloadTooLargeError extends Error {
212
260
  }
261
+ /** Raised when a request body is not parseable JSON; mapped to HTTP 400 / -32700. */
262
+ class InvalidJsonError extends Error {
263
+ }
213
264
  /** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
214
265
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
215
266
  /** Read and JSON-parse a request body; returns undefined for an empty body. */
@@ -239,7 +290,7 @@ function readJsonBody(req) {
239
290
  resolve(JSON.parse(data));
240
291
  }
241
292
  catch (e) {
242
- reject(e instanceof Error ? e : new Error("invalid JSON body"));
293
+ reject(new InvalidJsonError(e instanceof Error ? e.message : "invalid JSON body"));
243
294
  }
244
295
  });
245
296
  req.on("error", reject);
package/dist/tray.js ADDED
@@ -0,0 +1,142 @@
1
+ // System-tray presence for the running daemon — a glanceable "is DataLoom
2
+ // running?" signal for the detached/background mode that has no console or
3
+ // window. Windows-first and dependency-free: a hidden PowerShell helper draws
4
+ // the DataLoom mark, shows a tooltip, and offers Open / Copy / Stop; menu
5
+ // clicks arrive back as stdout lines. Any failure (non-Windows, no PowerShell,
6
+ // headless/session-0, DATA_LOOM_NO_TRAY) is a silent no-op, so the tray never
7
+ // affects the daemon's ability to serve (mirror-not-launcher, DataLoom-only).
8
+ import { spawn } from "node:child_process";
9
+ const NOOP = { dispose: () => { } };
10
+ /**
11
+ * Show a tray icon for the running daemon. Returns a disposer. Never throws:
12
+ * on any unsupported/headless host or failure it returns a no-op tray so the
13
+ * caller can wire it unconditionally.
14
+ */
15
+ export function initTray(opts) {
16
+ if (process.env.DATA_LOOM_NO_TRAY)
17
+ return NOOP;
18
+ // Windows-first: elsewhere (and headless) we degrade to no tray.
19
+ if (process.platform !== "win32")
20
+ return NOOP;
21
+ try {
22
+ return startWindowsTray(opts);
23
+ }
24
+ catch (err) {
25
+ opts.log?.(`[data-loom] tray unavailable (continuing without it): ${err instanceof Error ? err.message : err}`);
26
+ return NOOP;
27
+ }
28
+ }
29
+ function startWindowsTray(opts) {
30
+ const script = buildScript(opts.url, process.pid);
31
+ // -EncodedCommand takes base64 of the UTF-16LE script, sidestepping all shell
32
+ // quoting. powershell.exe (Windows PowerShell 5.1) runs STA, which WinForms
33
+ // / NotifyIcon require.
34
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
35
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
36
+ let disposed = false;
37
+ child.stdout?.setEncoding("utf8");
38
+ let buf = "";
39
+ child.stdout?.on("data", (chunk) => {
40
+ buf += chunk;
41
+ let nl;
42
+ while ((nl = buf.indexOf("\n")) >= 0) {
43
+ const line = buf.slice(0, nl).trim();
44
+ buf = buf.slice(nl + 1);
45
+ if (!line)
46
+ continue;
47
+ if (line === "open")
48
+ opts.onOpen();
49
+ else if (line === "copy")
50
+ opts.onCopy();
51
+ else if (line === "stop")
52
+ opts.onStop();
53
+ }
54
+ });
55
+ child.stderr?.setEncoding("utf8");
56
+ child.stderr?.on("data", (chunk) => {
57
+ // PowerShell serializes its progress/verbose streams to stderr as CLIXML
58
+ // when piped (e.g. "Preparing modules for first use") — benign noise. Only
59
+ // surface genuine error records.
60
+ if (!/S="error"/i.test(chunk))
61
+ return;
62
+ opts.log?.(`[data-loom] tray helper error: ${chunk.trim()}`);
63
+ });
64
+ child.on("error", (err) => opts.log?.(`[data-loom] tray helper error: ${err.message}`));
65
+ return {
66
+ dispose() {
67
+ if (disposed)
68
+ return;
69
+ disposed = true;
70
+ // Intentionally not killed here: the helper polls for its parent (this
71
+ // daemon) and, once we exit, disposes its own NotifyIcon and quits — a
72
+ // clean icon removal. A hard kill would skip that and leave a ghost icon
73
+ // in the tray until the user hovers it. dispose() is only called during
74
+ // shutdown, which exits the process immediately afterward.
75
+ try {
76
+ child.stdout?.removeAllListeners();
77
+ child.stderr?.removeAllListeners();
78
+ }
79
+ catch {
80
+ /* nothing to clean up */
81
+ }
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * The PowerShell helper. Built as joined lines (no backticks, so it survives a
87
+ * JS template context) with the URL and parent PID inlined. It draws the woven
88
+ * DataLoom mark, shows a NotifyIcon with a tooltip and a context menu, reports
89
+ * menu clicks on stdout, and self-terminates (removing its icon) as soon as the
90
+ * parent daemon is gone.
91
+ */
92
+ function buildScript(url, parentPid) {
93
+ const safeUrl = url.replace(/'/g, "''"); // single-quote escape for PS literal
94
+ return [
95
+ "$ErrorActionPreference = 'Stop'",
96
+ "$ProgressPreference = 'SilentlyContinue'",
97
+ "Add-Type -AssemblyName System.Windows.Forms",
98
+ "Add-Type -AssemblyName System.Drawing",
99
+ "$url = '" + safeUrl + "'",
100
+ "$parent = " + String(parentPid),
101
+ // Draw the woven-lattice mark (faint horizontals + accent-blue verticals).
102
+ "$bmp = New-Object System.Drawing.Bitmap 32,32",
103
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
104
+ "$g.SmoothingMode = 'AntiAlias'",
105
+ "$g.Clear([System.Drawing.Color]::Transparent)",
106
+ "$accent = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,37,99,184)), 2.4",
107
+ "$faint = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255,144,163,190)), 2.4",
108
+ "foreach ($y in 7,16,25) { $g.DrawLine($faint, 6, $y, 26, $y) }",
109
+ "foreach ($x in 8,16,24) { $g.DrawLine($accent, $x, 6, $x, 26) }",
110
+ "$g.Dispose()",
111
+ "$icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())",
112
+ // Tray icon + tooltip.
113
+ "$notify = New-Object System.Windows.Forms.NotifyIcon",
114
+ "$notify.Icon = $icon",
115
+ "$notify.Text = 'DataLoom - running - ' + $url",
116
+ "$notify.Visible = $true",
117
+ // Context menu — DataLoom-only actions, reported to the daemon on stdout.
118
+ "$menu = New-Object System.Windows.Forms.ContextMenuStrip",
119
+ "$mOpen = $menu.Items.Add('Open Dashboard')",
120
+ "$mOpen.add_Click({ try { [Console]::Out.WriteLine('open'); [Console]::Out.Flush() } catch {} })",
121
+ "$mCopy = $menu.Items.Add('Copy URL')",
122
+ "$mCopy.add_Click({ try { [Console]::Out.WriteLine('copy'); [Console]::Out.Flush() } catch {} })",
123
+ "$mStop = $menu.Items.Add('Stop DataLoom')",
124
+ "$mStop.add_Click({ try { [Console]::Out.WriteLine('stop'); [Console]::Out.Flush() } catch {} })",
125
+ "$notify.ContextMenuStrip = $menu",
126
+ // Clean removal of the icon, however we exit.
127
+ "$cleanup = { try { $notify.Visible = $false } catch {}; try { $notify.Dispose() } catch {}; try { $icon.Dispose() } catch {}; try { $bmp.Dispose() } catch {} }",
128
+ // Pump WinForms messages (so the icon shows and the menu responds) while the
129
+ // parent daemon is alive; when it goes, remove our icon and quit. A manual
130
+ // DoEvents loop keeps $parent/$cleanup in the main scope, avoiding the scope
131
+ // pitfalls of .NET event-handler scriptblocks. The menu handlers above use
132
+ // only string literals, so they capture nothing.
133
+ "try {",
134
+ " while (Get-Process -Id $parent -ErrorAction SilentlyContinue) {",
135
+ " [System.Windows.Forms.Application]::DoEvents()",
136
+ " Start-Sleep -Milliseconds 150",
137
+ " }",
138
+ "} finally {",
139
+ " & $cleanup",
140
+ "}",
141
+ ].join("\n");
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,9 @@
34
34
  "build": "tsc",
35
35
  "start": "node dist/index.js",
36
36
  "dev": "tsc && node --watch dist/index.js",
37
- "prepublishOnly": "npm run build"
37
+ "prepublishOnly": "npm run build",
38
+ "sync-global": "node scripts/sync-global.mjs",
39
+ "postpublish": "npm run sync-global"
38
40
  },
39
41
  "publishConfig": {
40
42
  "access": "public"