@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 +11 -3
- package/dist/claudeCode.js +81 -0
- package/dist/index.js +105 -26
- package/dist/mcpServer.js +17 -2
- package/dist/server.js +53 -2
- package/dist/tray.js +142 -0
- package/package.json +4 -2
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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:
|
|
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
|
-
|
|
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 :
|
|
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.
|
|
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"
|