@lyric_dev/data-loom 0.5.0 → 0.7.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 +46 -16
- package/dist/autostart.js +223 -36
- package/dist/claudeCode.js +144 -0
- package/dist/index.js +257 -33
- package/dist/lifecycle.js +116 -2
- package/dist/mcpServer.js +30 -24
- package/dist/mcpShim.js +144 -0
- package/dist/paths.js +6 -0
- package/dist/server.js +63 -3
- package/dist/tray.js +142 -0
- package/dist/version.js +18 -0
- package/dist/weaveAlias.js +66 -0
- package/package.json +4 -2
package/dist/mcpShim.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// MCP stdio shim: spawned by a client (typically Claude Code's stdio
|
|
2
|
+
// registration), it ensures the daemon is running, then proxies MCP traffic
|
|
3
|
+
// between its own stdio and the daemon's loopback HTTP `/mcp` endpoint for the
|
|
4
|
+
// life of the client session.
|
|
5
|
+
//
|
|
6
|
+
// Pure proxy — no tools, prompts, or sessions of its own. The daemon remains
|
|
7
|
+
// the single MCP server and source of truth (including per-call project
|
|
8
|
+
// resolution); this file only launches (if needed) and forwards raw JSON-RPC
|
|
9
|
+
// messages both ways.
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
13
|
+
import { isInitializeRequest, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import * as lifecycle from "./lifecycle.js";
|
|
15
|
+
import { mcpUrl, logFile } from "./paths.js";
|
|
16
|
+
const LAUNCH_TIMEOUT_MS = 10_000;
|
|
17
|
+
const POLL_INTERVAL_MS = 300;
|
|
18
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the daemon answers on the loopback port, starting it (through the
|
|
21
|
+
* normal detached lifecycle path) if it does not. Bounded wait — never hangs.
|
|
22
|
+
* A concurrent second shim racing to start is harmless: the daemon's own
|
|
23
|
+
* single-instance guard (the port probe in lifecycle.start) means at most one
|
|
24
|
+
* daemon results, and both shims' polls succeed against it.
|
|
25
|
+
*/
|
|
26
|
+
async function ensureDaemonUp() {
|
|
27
|
+
if (await lifecycle.isRunning())
|
|
28
|
+
return { ok: true };
|
|
29
|
+
await lifecycle.start();
|
|
30
|
+
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
if (await lifecycle.isRunning())
|
|
33
|
+
return { ok: true };
|
|
34
|
+
await delay(POLL_INTERVAL_MS);
|
|
35
|
+
}
|
|
36
|
+
return { ok: false, diagnosis: await diagnoseFailure() };
|
|
37
|
+
}
|
|
38
|
+
/** Best-effort explanation of why the daemon never came up, from its own log tail. */
|
|
39
|
+
async function diagnoseFailure() {
|
|
40
|
+
let tail = "";
|
|
41
|
+
try {
|
|
42
|
+
tail = (await readFile(logFile(), "utf8")).slice(-4000);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* no log yet — daemon likely never got far enough to write one */
|
|
46
|
+
}
|
|
47
|
+
if (/openspec.*was not found/i.test(tail)) {
|
|
48
|
+
return ("DataLoom could not start: the `openspec` CLI is missing. Install it (e.g. `npm i -g openspec`), then retry. " +
|
|
49
|
+
`Log: ${logFile()}`);
|
|
50
|
+
}
|
|
51
|
+
return ("DataLoom's daemon did not come up in time. Run `data-loom status` to check what's wrong, and see the log at " +
|
|
52
|
+
logFile());
|
|
53
|
+
}
|
|
54
|
+
function extractId(msg) {
|
|
55
|
+
return "id" in msg ? msg.id : undefined;
|
|
56
|
+
}
|
|
57
|
+
function errorResponse(id, message) {
|
|
58
|
+
return {
|
|
59
|
+
jsonrpc: "2.0",
|
|
60
|
+
id,
|
|
61
|
+
error: { code: ErrorCode.InternalError, message },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* stdout is the MCP transport's wire — reserved exclusively for framed
|
|
66
|
+
* JSON-RPC messages. Reused helpers here (lifecycle.start/isRunning) predate
|
|
67
|
+
* the shim and log human-readable status with console.log, which writes to
|
|
68
|
+
* that same stdout; left alone, those lines interleave with JSON-RPC frames
|
|
69
|
+
* and corrupt the client's stream. Redirect the stdout-writing console
|
|
70
|
+
* methods to stderr for this process's whole lifetime (console.warn/error
|
|
71
|
+
* already go to stderr).
|
|
72
|
+
*/
|
|
73
|
+
function keepStdoutForTransportOnly() {
|
|
74
|
+
console.log = console.error;
|
|
75
|
+
console.info = console.error;
|
|
76
|
+
console.debug = console.error;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Entry point for `data-loom mcp-shim`. Never resolves until the client
|
|
80
|
+
* session ends (or the launch fails and the handshake has been answered) —
|
|
81
|
+
* the caller (index.ts) just awaits and lets the process exit naturally.
|
|
82
|
+
*/
|
|
83
|
+
export async function runShim() {
|
|
84
|
+
keepStdoutForTransportOnly();
|
|
85
|
+
const stdio = new StdioServerTransport();
|
|
86
|
+
// Buffer whatever arrives while the daemon is (maybe) still starting, so
|
|
87
|
+
// nothing sent before the proxy is wired up is lost.
|
|
88
|
+
const pending = [];
|
|
89
|
+
stdio.onmessage = (msg) => {
|
|
90
|
+
pending.push(msg);
|
|
91
|
+
};
|
|
92
|
+
stdio.onerror = (err) => console.error("[data-loom mcp-shim] stdio error:", err);
|
|
93
|
+
await stdio.start();
|
|
94
|
+
const readiness = await ensureDaemonUp();
|
|
95
|
+
if (!readiness.ok) {
|
|
96
|
+
const initMsg = pending.find((m) => isInitializeRequest(m));
|
|
97
|
+
const id = initMsg ? extractId(initMsg) : undefined;
|
|
98
|
+
if (id !== undefined) {
|
|
99
|
+
await stdio.send(errorResponse(id, readiness.diagnosis));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// No handshake to answer yet — still surface the failure, just not as
|
|
103
|
+
// an MCP response. Never exit without saying why.
|
|
104
|
+
console.error(`[data-loom mcp-shim] ${readiness.diagnosis}`);
|
|
105
|
+
}
|
|
106
|
+
await stdio.close();
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const client = new StreamableHTTPClientTransport(new URL(mcpUrl()));
|
|
111
|
+
let closing = false;
|
|
112
|
+
// The shim's whole job ends with its client session — one side closing
|
|
113
|
+
// (client disconnects, or the daemon connection drops) tears down the
|
|
114
|
+
// other, but the daemon itself is never touched.
|
|
115
|
+
const closeBoth = () => {
|
|
116
|
+
if (closing)
|
|
117
|
+
return;
|
|
118
|
+
closing = true;
|
|
119
|
+
void stdio.close();
|
|
120
|
+
void client.close();
|
|
121
|
+
process.exit(0);
|
|
122
|
+
};
|
|
123
|
+
client.onmessage = (msg) => {
|
|
124
|
+
void stdio.send(msg);
|
|
125
|
+
};
|
|
126
|
+
client.onerror = (err) => console.error("[data-loom mcp-shim] daemon connection error:", err);
|
|
127
|
+
client.onclose = closeBoth;
|
|
128
|
+
stdio.onerror = (err) => console.error("[data-loom mcp-shim] stdio error:", err);
|
|
129
|
+
stdio.onclose = closeBoth;
|
|
130
|
+
// StdioServerTransport only ever calls onclose when *we* call close() — it
|
|
131
|
+
// does not itself listen for stdin ending. The client process closing its
|
|
132
|
+
// end of the pipe (the real "session ended" signal) only shows up as
|
|
133
|
+
// stdin's own 'end'/'close' event, so that is what actually has to trigger
|
|
134
|
+
// teardown here.
|
|
135
|
+
process.stdin.on("end", closeBoth);
|
|
136
|
+
process.stdin.on("close", closeBoth);
|
|
137
|
+
await client.start();
|
|
138
|
+
for (const msg of pending)
|
|
139
|
+
await client.send(msg);
|
|
140
|
+
// From here on, every message from the client goes straight through.
|
|
141
|
+
stdio.onmessage = (msg) => {
|
|
142
|
+
void client.send(msg);
|
|
143
|
+
};
|
|
144
|
+
}
|
package/dist/paths.js
CHANGED
|
@@ -31,6 +31,12 @@ export function stateDir() {
|
|
|
31
31
|
}
|
|
32
32
|
export const pidFile = () => join(stateDir(), "daemon.pid");
|
|
33
33
|
export const logFile = () => join(stateDir(), "daemon.log");
|
|
34
|
+
/**
|
|
35
|
+
* The stable launcher OS supervisors invoke (Scheduled Task / LaunchAgent /
|
|
36
|
+
* systemd unit) — a fixed path that never changes across Node version or
|
|
37
|
+
* package-location churn, unlike the `node` + script paths it wraps.
|
|
38
|
+
*/
|
|
39
|
+
export const launcherFile = () => join(stateDir(), process.platform === "win32" ? "daemon.cmd" : "daemon.sh");
|
|
34
40
|
/** Create the state dir on demand; safe to call repeatedly. */
|
|
35
41
|
export async function ensureStateDir() {
|
|
36
42
|
const dir = stateDir();
|
package/dist/server.js
CHANGED
|
@@ -15,7 +15,7 @@ const MIME = {
|
|
|
15
15
|
".svg": "image/svg+xml",
|
|
16
16
|
};
|
|
17
17
|
export async function startServer(opts) {
|
|
18
|
-
const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject } = opts;
|
|
18
|
+
const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject, onShutdown } = opts;
|
|
19
19
|
let wss;
|
|
20
20
|
const broadcast = (msg) => {
|
|
21
21
|
const data = JSON.stringify(msg);
|
|
@@ -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);
|
|
@@ -137,6 +147,15 @@ export async function startServer(opts) {
|
|
|
137
147
|
return sendJson(res, getMcp());
|
|
138
148
|
if (url.pathname === "/api/projects")
|
|
139
149
|
return sendJson(res, await getProjects());
|
|
150
|
+
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
151
|
+
// Loopback-only (isAllowed already ran) graceful stop. Answer first,
|
|
152
|
+
// then trigger shutdown once the response has flushed so the caller
|
|
153
|
+
// gets its 200 before the process exits.
|
|
154
|
+
res.writeHead(200, { "content-type": MIME[".json"] });
|
|
155
|
+
res.end(JSON.stringify({ stopping: true }));
|
|
156
|
+
res.on("finish", () => setImmediate(() => onShutdown?.()));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
140
159
|
if (url.pathname === "/api/mcp/check" && req.method === "POST") {
|
|
141
160
|
const server = await checkMcp(url.searchParams.get("name") ?? "");
|
|
142
161
|
if (!server)
|
|
@@ -174,6 +193,16 @@ export async function startServer(opts) {
|
|
|
174
193
|
server: http,
|
|
175
194
|
verifyClient: (info) => isAllowed(info.req),
|
|
176
195
|
});
|
|
196
|
+
// ws re-emits the underlying server's 'error'; without a listener a bind
|
|
197
|
+
// failure (EADDRINUSE) becomes an unhandled 'error' event that crashes the
|
|
198
|
+
// process. The listen() promise below surfaces startup bind errors to the
|
|
199
|
+
// caller, so ignore those here (avoids a scary duplicate stack on the normal
|
|
200
|
+
// "already running" path); log only unexpected post-startup socket errors.
|
|
201
|
+
wss.on("error", (err) => {
|
|
202
|
+
if (err.code !== "EADDRINUSE") {
|
|
203
|
+
console.error("[data-loom] websocket error:", err);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
177
206
|
wss.on("connection", async (ws) => {
|
|
178
207
|
const roadmap = getRoadmap();
|
|
179
208
|
if (roadmap)
|
|
@@ -186,7 +215,18 @@ export async function startServer(opts) {
|
|
|
186
215
|
/* project list optional */
|
|
187
216
|
}
|
|
188
217
|
});
|
|
189
|
-
|
|
218
|
+
// Reject (rather than crash) if the port is taken — the caller decides how to
|
|
219
|
+
// report an already-running instance. After a clean listen, later server
|
|
220
|
+
// errors are logged, not fatal.
|
|
221
|
+
await new Promise((resolve, reject) => {
|
|
222
|
+
const onError = (err) => reject(err);
|
|
223
|
+
http.once("error", onError);
|
|
224
|
+
http.listen(port, host, () => {
|
|
225
|
+
http.removeListener("error", onError);
|
|
226
|
+
http.on("error", (err) => console.error("[data-loom] http server error:", err));
|
|
227
|
+
resolve();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
190
230
|
const actualPort = http.address()?.port ?? port;
|
|
191
231
|
setAllowed(actualPort);
|
|
192
232
|
return {
|
|
@@ -207,9 +247,29 @@ function sendError(res, code, message) {
|
|
|
207
247
|
res.writeHead(code, { "content-type": MIME[".json"] });
|
|
208
248
|
res.end(JSON.stringify({ error: message }));
|
|
209
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Answer a request whose body was not valid JSON with a JSON-RPC parse error
|
|
252
|
+
* (-32700) over HTTP 400, per the Streamable-HTTP spec. `detail` is the JSON
|
|
253
|
+
* parser's message about the caller's own payload — safe to echo, and the hint
|
|
254
|
+
* names the most common cause on Windows (unescaped path backslashes).
|
|
255
|
+
*/
|
|
256
|
+
function sendRpcParseError(res, detail) {
|
|
257
|
+
res.writeHead(400, { "content-type": MIME[".json"] });
|
|
258
|
+
res.end(JSON.stringify({
|
|
259
|
+
jsonrpc: "2.0",
|
|
260
|
+
error: {
|
|
261
|
+
code: -32700,
|
|
262
|
+
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.`,
|
|
263
|
+
},
|
|
264
|
+
id: null,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
210
267
|
/** Raised when a request body exceeds the cap; mapped to HTTP 413. */
|
|
211
268
|
class PayloadTooLargeError extends Error {
|
|
212
269
|
}
|
|
270
|
+
/** Raised when a request body is not parseable JSON; mapped to HTTP 400 / -32700. */
|
|
271
|
+
class InvalidJsonError extends Error {
|
|
272
|
+
}
|
|
213
273
|
/** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
|
|
214
274
|
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
215
275
|
/** Read and JSON-parse a request body; returns undefined for an empty body. */
|
|
@@ -239,7 +299,7 @@ function readJsonBody(req) {
|
|
|
239
299
|
resolve(JSON.parse(data));
|
|
240
300
|
}
|
|
241
301
|
catch (e) {
|
|
242
|
-
reject(e instanceof Error ? e :
|
|
302
|
+
reject(new InvalidJsonError(e instanceof Error ? e.message : "invalid JSON body"));
|
|
243
303
|
}
|
|
244
304
|
});
|
|
245
305
|
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/dist/version.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The advertised package version — read from the manifest shipped next to the
|
|
2
|
+
// compiled code, so it can never drift from the released version again (it was
|
|
3
|
+
// hardcoded in mcpServer.ts and stuck at 0.4.1 through the 0.5.0 release).
|
|
4
|
+
// Shared by the MCP server (server version), the weave alias (version stamp),
|
|
5
|
+
// and the startup refresh (comparing stamps).
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
export const VERSION = (() => {
|
|
10
|
+
try {
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
13
|
+
return manifest.version ?? "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared provisioning for the `/loom:weave` alias: a thin, version-stamped
|
|
2
|
+
// command file that delegates to the data-loom MCP server's `weave` prompt
|
|
3
|
+
// (see mcpServer.ts) instead of carrying the workflow text itself. Written by
|
|
4
|
+
// the install_weave_skill tool, `connect claude-code`, and healed on daemon
|
|
5
|
+
// startup — all three must agree on path, stamp format, and template, so it
|
|
6
|
+
// lives here rather than duplicated across callers.
|
|
7
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
export const WEAVE_ALIAS_PATH = join(homedir(), ".claude", "commands", "loom", "weave.md");
|
|
11
|
+
const STAMP_RE = /<!--\s*data-loom:version\s+(\S+?)\s*-->/;
|
|
12
|
+
function buildAlias(version) {
|
|
13
|
+
return `---
|
|
14
|
+
name: "Loom: Weave"
|
|
15
|
+
description: "Review the open proposals and weave their dependency order via the data-loom MCP server"
|
|
16
|
+
category: Workflow
|
|
17
|
+
tags: [loom, dependencies, mcp, review]
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<!-- data-loom:version ${version} -->
|
|
21
|
+
|
|
22
|
+
Fetch the **data-loom** MCP server's \`weave\` prompt (\`prompts/get\` with name \`weave\`) and follow it exactly — that prompt is this workflow's single source of truth and always matches the running server.
|
|
23
|
+
|
|
24
|
+
**If the data-loom tools or the \`weave\` prompt are unreachable:** the daemon is almost certainly not running or not registered in this session. Tell the user to check \`data-loom status\`, start it with \`data-loom start\` if it isn't running, and register it with \`data-loom connect claude-code\` if it isn't registered — then retry.
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
async function readAlias() {
|
|
28
|
+
try {
|
|
29
|
+
return await readFile(WEAVE_ALIAS_PATH, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Write the current alias, creating its parent directory if absent. Returns the path written. */
|
|
36
|
+
export async function provisionWeaveAlias(version) {
|
|
37
|
+
await mkdir(dirname(WEAVE_ALIAS_PATH), { recursive: true });
|
|
38
|
+
await writeFile(WEAVE_ALIAS_PATH, buildAlias(version));
|
|
39
|
+
return WEAVE_ALIAS_PATH;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* On daemon startup: rewrite an existing alias whose version stamp is missing
|
|
43
|
+
* or does not match the running version (covers both a prior version's alias
|
|
44
|
+
* and a pre-prompt full-text install, which predates the stamp). Never creates
|
|
45
|
+
* the file — absence means the user hasn't opted in via connect or the tool.
|
|
46
|
+
*/
|
|
47
|
+
export async function refreshWeaveAliasIfOutdated(version) {
|
|
48
|
+
const text = await readAlias();
|
|
49
|
+
if (text === null)
|
|
50
|
+
return;
|
|
51
|
+
if (text.match(STAMP_RE)?.[1] === version)
|
|
52
|
+
return;
|
|
53
|
+
await provisionWeaveAlias(version);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Remove the alias only when it carries a data-loom version stamp — our
|
|
57
|
+
* positive signal of ownership. A file with no stamp at all (or no file) is
|
|
58
|
+
* left untouched. Returns whether a file was removed.
|
|
59
|
+
*/
|
|
60
|
+
export async function removeWeaveAliasIfOurs() {
|
|
61
|
+
const text = await readAlias();
|
|
62
|
+
if (text === null || !STAMP_RE.test(text))
|
|
63
|
+
return false;
|
|
64
|
+
await unlink(WEAVE_ALIAS_PATH);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyric_dev/data-loom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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"
|