@rubytech/create-realagent 1.0.628 → 1.0.631

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/dist/index.js CHANGED
@@ -384,7 +384,10 @@ function installClaudeCode() {
384
384
  let needsInstall = true;
385
385
  if (commandExists("claude")) {
386
386
  try {
387
- const installed = execFileSync("claude", ["--version"], { encoding: "utf-8", timeout: 10_000 }).trim();
387
+ // `claude --version` prints "2.1.114 (Claude Code)" extract the semver so
388
+ // the equality check against `npm view` (which returns bare "2.1.114") works.
389
+ const rawVersion = execFileSync("claude", ["--version"], { encoding: "utf-8", timeout: 10_000 }).trim();
390
+ const installed = rawVersion.match(/^(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/)?.[1] ?? rawVersion;
388
391
  let latest = null;
389
392
  try {
390
393
  latest = execFileSync("npm", ["view", "@anthropic-ai/claude-code", "version"], { encoding: "utf-8", timeout: 30_000 }).trim();
@@ -408,9 +411,19 @@ function installClaudeCode() {
408
411
  log("3", TOTAL, "Installing Claude Code...");
409
412
  }
410
413
  if (needsInstall) {
411
- console.log(" This may take 15–30 minutes on Raspberry Pi...");
412
- shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: true, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
413
- 3, 30);
414
+ // `npm install -g` needs write access to the global prefix, which on Linux is
415
+ // root-owned by default so we run it under sudo. When sudo requires a password
416
+ // and the installer is running non-interactively (e.g. systemd-run --scope on
417
+ // upgrade), sudo fails instantly. Skip the upgrade in that case; the running
418
+ // installation is assumed adequate. Matches the apt-get skip in step 1.
419
+ if (isLinux() && !canSudo()) {
420
+ console.log(" Skipping Claude Code upgrade (sudo unavailable non-interactively — keeping installed version)");
421
+ }
422
+ else {
423
+ console.log(" This may take 15–30 minutes on Raspberry Pi...");
424
+ shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: true, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
425
+ 3, 30);
426
+ }
414
427
  }
415
428
  console.log(" Registering claude-plugins-official marketplace...");
416
429
  const marketplaceList = spawnSync("claude", ["plugin", "marketplace", "list"], { stdio: "pipe", encoding: "utf-8" });
@@ -813,6 +826,39 @@ function installOllama(embedModel) {
813
826
  logFile(` Pulling embedding model: ${embedModel}`);
814
827
  shellRetry("ollama", ["pull", embedModel], { timeout: 600_000 }, 3, 15);
815
828
  }
829
+ // Task 557 — uv/uvx bootstrap. Required by the `graph` MCP server which
830
+ // spawns `uvx mcp-neo4j-cypher@0.6.0 --transport stdio`. Idempotent: when
831
+ // uvx is already on PATH (or under $HOME/.local/bin) we skip. Non-fatal on
832
+ // failure — the installer continues; the graph server will loudly fail
833
+ // at session start with a clear "uvx not found" error, which is retriable
834
+ // via a second installer run with network access.
835
+ function installUv() {
836
+ if (commandExists("uvx")) {
837
+ logFile(" uv: already installed");
838
+ console.log(" uv/uvx already installed.");
839
+ return;
840
+ }
841
+ console.log(" Installing uv (Python tool runner — required by Neo4j MCP server)...");
842
+ logFile(" uv: installing via astral.sh installer");
843
+ const result = spawnSync("bash", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh -s -- -y"], { stdio: "inherit" });
844
+ if (result.status !== 0) {
845
+ console.error(` WARNING: uv install exited ${result.status} — graph MCP server will fail at session start until this is retried`);
846
+ logFile(` WARNING: uv install failed with status ${result.status}`);
847
+ return;
848
+ }
849
+ // The installer writes uvx to $HOME/.local/bin — add it to PATH for the
850
+ // remainder of this install so commandExists("uvx") works downstream.
851
+ const localBin = `${process.env.HOME}/.local/bin`;
852
+ if (process.env.PATH && !process.env.PATH.includes(localBin)) {
853
+ process.env.PATH = `${localBin}:${process.env.PATH}`;
854
+ }
855
+ if (commandExists("uvx")) {
856
+ logFile(" uv: install succeeded; uvx on PATH");
857
+ }
858
+ else {
859
+ console.error(" WARNING: uv installed but uvx not on PATH — check $HOME/.local/bin");
860
+ }
861
+ }
816
862
  function installCloudflared() {
817
863
  if (commandExists("cloudflared")) {
818
864
  log("6", TOTAL, "Cloudflared already installed.");
@@ -1937,6 +1983,7 @@ try {
1937
1983
  installNeo4j();
1938
1984
  setupDedicatedNeo4j();
1939
1985
  installOllama(EMBED_MODEL);
1986
+ installUv();
1940
1987
  installCloudflared();
1941
1988
  installWhisperCpp();
1942
1989
  deployPayload(); // Must happen before ensureNeo4jPassword — restores config backup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.628",
3
+ "version": "1.0.631",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Graph MCP shim — adopts upstream `mcp-neo4j-cypher` per brand.
4
+ *
5
+ * Task 557. Claude Code spawns this file as the `graph` MCP server; it
6
+ * starts the upstream Python server under `uvx` and proxies the JSON-RPC
7
+ * stdio stream byte-for-byte. While proxying, it:
8
+ * 1. Resolves `NEO4J_URI/USERNAME/PASSWORD` from the brand's own env
9
+ * (falls back to `${PLATFORM_ROOT}/config/.neo4j-password`, matching
10
+ * plugins/memory/mcp/src/lib/neo4j.ts:10-21).
11
+ * 2. Installs the Maxy stderr tee so upstream stderr lands in the
12
+ * per-conversation stream log alongside every other plugin.
13
+ * 3. Locks the upstream server into read-only mode (`NEO4J_READ_ONLY=true`)
14
+ * and a stable Maxy namespace (`NEO4J_NAMESPACE=maxy-graph`).
15
+ * 4. Parses JSON-RPC lines on the request path to record the cypher and
16
+ * start time per `id`, and on the response path to emit one
17
+ * `[graph-query] op=... brand=... port=... cypher="..." rows=... ms=...`
18
+ * line per tool call. Forwarding is byte-accurate — the parser
19
+ * observes, never mutates.
20
+ *
21
+ * Isolation model per MAXY-PRD.md:627 and create-maxy src/index.ts:504-824:
22
+ * each brand already owns its own Neo4j instance on its own port with its
23
+ * own password. This shim trusts that — no query-layer tenant filtering.
24
+ */
25
+ export {};
26
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;GAsBG"}
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Graph MCP shim — adopts upstream `mcp-neo4j-cypher` per brand.
5
+ *
6
+ * Task 557. Claude Code spawns this file as the `graph` MCP server; it
7
+ * starts the upstream Python server under `uvx` and proxies the JSON-RPC
8
+ * stdio stream byte-for-byte. While proxying, it:
9
+ * 1. Resolves `NEO4J_URI/USERNAME/PASSWORD` from the brand's own env
10
+ * (falls back to `${PLATFORM_ROOT}/config/.neo4j-password`, matching
11
+ * plugins/memory/mcp/src/lib/neo4j.ts:10-21).
12
+ * 2. Installs the Maxy stderr tee so upstream stderr lands in the
13
+ * per-conversation stream log alongside every other plugin.
14
+ * 3. Locks the upstream server into read-only mode (`NEO4J_READ_ONLY=true`)
15
+ * and a stable Maxy namespace (`NEO4J_NAMESPACE=maxy-graph`).
16
+ * 4. Parses JSON-RPC lines on the request path to record the cypher and
17
+ * start time per `id`, and on the response path to emit one
18
+ * `[graph-query] op=... brand=... port=... cypher="..." rows=... ms=...`
19
+ * line per tool call. Forwarding is byte-accurate — the parser
20
+ * observes, never mutates.
21
+ *
22
+ * Isolation model per MAXY-PRD.md:627 and create-maxy src/index.ts:504-824:
23
+ * each brand already owns its own Neo4j instance on its own port with its
24
+ * own password. This shim trusts that — no query-layer tenant filtering.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ const node_child_process_1 = require("node:child_process");
28
+ const node_fs_1 = require("node:fs");
29
+ const node_path_1 = require("node:path");
30
+ const node_string_decoder_1 = require("node:string_decoder");
31
+ const index_js_1 = require("../../mcp-stderr-tee/dist/index.js");
32
+ const SERVER_NAME = "graph";
33
+ const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
34
+ (0, index_js_1.initStderrTee)(SERVER_NAME);
35
+ function resolvePassword() {
36
+ if (process.env.NEO4J_PASSWORD)
37
+ return process.env.NEO4J_PASSWORD;
38
+ // __dirname points at `lib/graph-mcp/dist` after compilation; the platform
39
+ // root is three levels up (lib/graph-mcp/dist -> lib/graph-mcp -> lib -> platform).
40
+ const root = process.env.PLATFORM_ROOT ?? (0, node_path_1.resolve)(__dirname, "../../..");
41
+ const file = (0, node_path_1.resolve)(root, "config/.neo4j-password");
42
+ try {
43
+ return (0, node_fs_1.readFileSync)(file, "utf-8").trim();
44
+ }
45
+ catch {
46
+ console.error(`[graph-mcp] password unavailable — file=${file} env=NEO4J_PASSWORD (neither set)`);
47
+ process.exit(1);
48
+ }
49
+ }
50
+ const brand = process.env.BRAND ?? "maxy";
51
+ const neo4jUri = process.env.NEO4J_URI ?? "bolt://localhost:7687";
52
+ const neo4jUser = process.env.NEO4J_USERNAME ?? process.env.NEO4J_USER ?? "neo4j";
53
+ const neo4jPassword = resolvePassword();
54
+ const portMatch = /:(\d+)$/.exec(neo4jUri);
55
+ const neo4jPort = portMatch ? portMatch[1] : "?";
56
+ const namespace = process.env.NEO4J_NAMESPACE ?? "maxy-graph";
57
+ const readOnly = process.env.NEO4J_READ_ONLY ?? "true";
58
+ const responseTokenLimit = process.env.NEO4J_RESPONSE_TOKEN_LIMIT ?? "20000";
59
+ const childEnv = {
60
+ ...process.env,
61
+ NEO4J_URI: neo4jUri,
62
+ NEO4J_URL: neo4jUri,
63
+ NEO4J_USERNAME: neo4jUser,
64
+ NEO4J_PASSWORD: neo4jPassword,
65
+ NEO4J_READ_ONLY: readOnly,
66
+ NEO4J_NAMESPACE: namespace,
67
+ NEO4J_RESPONSE_TOKEN_LIMIT: responseTokenLimit,
68
+ };
69
+ console.error(`[graph-mcp] boot brand=${brand} uri=${neo4jUri} user=${neo4jUser} ` +
70
+ `namespace=${namespace} readOnly=${readOnly} tokenLimit=${responseTokenLimit}`);
71
+ const child = (0, node_child_process_1.spawn)("uvx", [UPSTREAM_PACKAGE, "--transport", "stdio"], {
72
+ stdio: ["pipe", "pipe", "pipe"],
73
+ env: childEnv,
74
+ });
75
+ child.on("spawn", () => {
76
+ console.error(`[graph-mcp] spawned ${UPSTREAM_PACKAGE} pid=${child.pid}`);
77
+ });
78
+ child.on("error", (err) => {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ console.error(`[graph-mcp] spawn error: ${msg}`);
81
+ if (err.code === "ENOENT") {
82
+ console.error("[graph-mcp] uvx not found on PATH — install uv: " +
83
+ "curl -LsSf https://astral.sh/uv/install.sh | sh -s -- -y");
84
+ }
85
+ process.exit(127);
86
+ });
87
+ child.on("exit", (code, signal) => {
88
+ console.error(`[graph-mcp] uvx exited code=${code ?? "null"} signal=${signal ?? "null"}`);
89
+ process.exit(code ?? (signal ? 1 : 0));
90
+ });
91
+ // Forward child stderr to our own stderr so the stderr tee picks it up.
92
+ child.stderr.on("data", (chunk) => {
93
+ process.stderr.write(chunk);
94
+ });
95
+ const pending = new Map();
96
+ function truncate(s, n) {
97
+ return s.length <= n ? s : `${s.slice(0, n)}...`;
98
+ }
99
+ function stripNamespace(toolName) {
100
+ if (!toolName)
101
+ return "(unknown)";
102
+ const prefix = `${namespace}_`;
103
+ return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
104
+ }
105
+ function extractCypher(args) {
106
+ if (!args)
107
+ return null;
108
+ const q = args["query"] ?? args["cypher"];
109
+ return typeof q === "string" ? truncate(q.replace(/\s+/g, " ").trim(), 80) : null;
110
+ }
111
+ function countRows(result) {
112
+ if (!result?.content || !Array.isArray(result.content))
113
+ return "?";
114
+ const text = result.content[0]?.text ?? "";
115
+ const rowsMatch = /(\d+)\s+(?:rows?|records?)/i.exec(text);
116
+ if (rowsMatch)
117
+ return rowsMatch[1];
118
+ return String(result.content.length);
119
+ }
120
+ function onRequestLine(line) {
121
+ try {
122
+ const msg = JSON.parse(line);
123
+ if (msg.method === "tools/call" && msg.id !== undefined) {
124
+ pending.set(msg.id, {
125
+ method: stripNamespace(msg.params?.name),
126
+ cypherPrefix: extractCypher(msg.params?.arguments),
127
+ startMs: Date.now(),
128
+ });
129
+ }
130
+ }
131
+ catch {
132
+ // Non-JSON / partial line — forward untouched, don't log.
133
+ }
134
+ }
135
+ function onResponseLine(line) {
136
+ try {
137
+ const msg = JSON.parse(line);
138
+ if (msg.id === undefined || !pending.has(msg.id))
139
+ return;
140
+ const p = pending.get(msg.id);
141
+ pending.delete(msg.id);
142
+ const elapsed = Date.now() - p.startMs;
143
+ const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
144
+ if (msg.error) {
145
+ const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
146
+ console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ms=${elapsed}`);
147
+ }
148
+ else {
149
+ const rows = countRows(msg.result);
150
+ console.error(`[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ms=${elapsed}`);
151
+ }
152
+ }
153
+ catch {
154
+ // Non-JSON — forward untouched.
155
+ }
156
+ }
157
+ function makeLineSplitter(onLine) {
158
+ const decoder = new node_string_decoder_1.StringDecoder("utf8");
159
+ let buf = "";
160
+ return (chunk) => {
161
+ buf += decoder.write(chunk);
162
+ let idx;
163
+ while ((idx = buf.indexOf("\n")) !== -1) {
164
+ const line = buf.slice(0, idx);
165
+ buf = buf.slice(idx + 1);
166
+ if (line.length > 0)
167
+ onLine(line);
168
+ }
169
+ };
170
+ }
171
+ const splitRequest = makeLineSplitter(onRequestLine);
172
+ process.stdin.on("data", (chunk) => {
173
+ splitRequest(chunk);
174
+ child.stdin.write(chunk);
175
+ });
176
+ process.stdin.on("end", () => {
177
+ child.stdin.end();
178
+ });
179
+ const splitResponse = makeLineSplitter(onResponseLine);
180
+ child.stdout.on("data", (chunk) => {
181
+ splitResponse(chunk);
182
+ process.stdout.write(chunk);
183
+ });
184
+ child.stdout.on("end", () => {
185
+ process.stdout.end();
186
+ });
187
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
188
+ process.on(sig, () => {
189
+ console.error(`[graph-mcp] ${sig} received — forwarding to child`);
190
+ child.kill(sig);
191
+ });
192
+ }
193
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Graph MCP shim — adopts upstream `mcp-neo4j-cypher` per brand.
4
+ *
5
+ * Task 557. Claude Code spawns this file as the `graph` MCP server; it
6
+ * starts the upstream Python server under `uvx` and proxies the JSON-RPC
7
+ * stdio stream byte-for-byte. While proxying, it:
8
+ * 1. Resolves `NEO4J_URI/USERNAME/PASSWORD` from the brand's own env
9
+ * (falls back to `${PLATFORM_ROOT}/config/.neo4j-password`, matching
10
+ * plugins/memory/mcp/src/lib/neo4j.ts:10-21).
11
+ * 2. Installs the Maxy stderr tee so upstream stderr lands in the
12
+ * per-conversation stream log alongside every other plugin.
13
+ * 3. Locks the upstream server into read-only mode (`NEO4J_READ_ONLY=true`)
14
+ * and a stable Maxy namespace (`NEO4J_NAMESPACE=maxy-graph`).
15
+ * 4. Parses JSON-RPC lines on the request path to record the cypher and
16
+ * start time per `id`, and on the response path to emit one
17
+ * `[graph-query] op=... brand=... port=... cypher="..." rows=... ms=...`
18
+ * line per tool call. Forwarding is byte-accurate — the parser
19
+ * observes, never mutates.
20
+ *
21
+ * Isolation model per MAXY-PRD.md:627 and create-maxy src/index.ts:504-824:
22
+ * each brand already owns its own Neo4j instance on its own port with its
23
+ * own password. This shim trusts that — no query-layer tenant filtering.
24
+ */
25
+
26
+ import { spawn } from "node:child_process";
27
+ import { readFileSync } from "node:fs";
28
+ import { resolve } from "node:path";
29
+ import { StringDecoder } from "node:string_decoder";
30
+ import { initStderrTee } from "../../mcp-stderr-tee/dist/index.js";
31
+
32
+ const SERVER_NAME = "graph";
33
+ const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
34
+
35
+ initStderrTee(SERVER_NAME);
36
+
37
+ function resolvePassword(): string {
38
+ if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
39
+ // __dirname points at `lib/graph-mcp/dist` after compilation; the platform
40
+ // root is three levels up (lib/graph-mcp/dist -> lib/graph-mcp -> lib -> platform).
41
+ const root = process.env.PLATFORM_ROOT ?? resolve(__dirname, "../../..");
42
+ const file = resolve(root, "config/.neo4j-password");
43
+ try {
44
+ return readFileSync(file, "utf-8").trim();
45
+ } catch {
46
+ console.error(`[graph-mcp] password unavailable — file=${file} env=NEO4J_PASSWORD (neither set)`);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ const brand = process.env.BRAND ?? "maxy";
52
+ const neo4jUri = process.env.NEO4J_URI ?? "bolt://localhost:7687";
53
+ const neo4jUser = process.env.NEO4J_USERNAME ?? process.env.NEO4J_USER ?? "neo4j";
54
+ const neo4jPassword = resolvePassword();
55
+ const portMatch = /:(\d+)$/.exec(neo4jUri);
56
+ const neo4jPort = portMatch ? portMatch[1] : "?";
57
+ const namespace = process.env.NEO4J_NAMESPACE ?? "maxy-graph";
58
+ const readOnly = process.env.NEO4J_READ_ONLY ?? "true";
59
+ const responseTokenLimit = process.env.NEO4J_RESPONSE_TOKEN_LIMIT ?? "20000";
60
+
61
+ const childEnv: NodeJS.ProcessEnv = {
62
+ ...process.env,
63
+ NEO4J_URI: neo4jUri,
64
+ NEO4J_URL: neo4jUri,
65
+ NEO4J_USERNAME: neo4jUser,
66
+ NEO4J_PASSWORD: neo4jPassword,
67
+ NEO4J_READ_ONLY: readOnly,
68
+ NEO4J_NAMESPACE: namespace,
69
+ NEO4J_RESPONSE_TOKEN_LIMIT: responseTokenLimit,
70
+ };
71
+
72
+ console.error(
73
+ `[graph-mcp] boot brand=${brand} uri=${neo4jUri} user=${neo4jUser} ` +
74
+ `namespace=${namespace} readOnly=${readOnly} tokenLimit=${responseTokenLimit}`,
75
+ );
76
+
77
+ const child = spawn("uvx", [UPSTREAM_PACKAGE, "--transport", "stdio"], {
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ env: childEnv,
80
+ });
81
+
82
+ child.on("spawn", () => {
83
+ console.error(`[graph-mcp] spawned ${UPSTREAM_PACKAGE} pid=${child.pid}`);
84
+ });
85
+
86
+ child.on("error", (err) => {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ console.error(`[graph-mcp] spawn error: ${msg}`);
89
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
90
+ console.error(
91
+ "[graph-mcp] uvx not found on PATH — install uv: " +
92
+ "curl -LsSf https://astral.sh/uv/install.sh | sh -s -- -y",
93
+ );
94
+ }
95
+ process.exit(127);
96
+ });
97
+
98
+ child.on("exit", (code, signal) => {
99
+ console.error(`[graph-mcp] uvx exited code=${code ?? "null"} signal=${signal ?? "null"}`);
100
+ process.exit(code ?? (signal ? 1 : 0));
101
+ });
102
+
103
+ // Forward child stderr to our own stderr so the stderr tee picks it up.
104
+ child.stderr.on("data", (chunk: Buffer) => {
105
+ process.stderr.write(chunk);
106
+ });
107
+
108
+ // --- JSON-RPC call correlation ---
109
+ // tools/call is the only method we time; initialize and tools/list are noise.
110
+ interface PendingCall {
111
+ method: string;
112
+ cypherPrefix: string | null;
113
+ startMs: number;
114
+ }
115
+ const pending = new Map<string | number, PendingCall>();
116
+
117
+ function truncate(s: string, n: number): string {
118
+ return s.length <= n ? s : `${s.slice(0, n)}...`;
119
+ }
120
+
121
+ function stripNamespace(toolName: string | undefined): string {
122
+ if (!toolName) return "(unknown)";
123
+ const prefix = `${namespace}_`;
124
+ return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
125
+ }
126
+
127
+ type JsonRpcMessage = {
128
+ id?: string | number;
129
+ method?: string;
130
+ params?: { name?: string; arguments?: Record<string, unknown> };
131
+ result?: { content?: Array<{ text?: string; type?: string }> };
132
+ error?: { message?: string; code?: number };
133
+ };
134
+
135
+ function extractCypher(args: Record<string, unknown> | undefined): string | null {
136
+ if (!args) return null;
137
+ const q = args["query"] ?? args["cypher"];
138
+ return typeof q === "string" ? truncate(q.replace(/\s+/g, " ").trim(), 80) : null;
139
+ }
140
+
141
+ function countRows(result: JsonRpcMessage["result"]): string {
142
+ if (!result?.content || !Array.isArray(result.content)) return "?";
143
+ const text = result.content[0]?.text ?? "";
144
+ const rowsMatch = /(\d+)\s+(?:rows?|records?)/i.exec(text);
145
+ if (rowsMatch) return rowsMatch[1];
146
+ return String(result.content.length);
147
+ }
148
+
149
+ function onRequestLine(line: string): void {
150
+ try {
151
+ const msg = JSON.parse(line) as JsonRpcMessage;
152
+ if (msg.method === "tools/call" && msg.id !== undefined) {
153
+ pending.set(msg.id, {
154
+ method: stripNamespace(msg.params?.name),
155
+ cypherPrefix: extractCypher(msg.params?.arguments),
156
+ startMs: Date.now(),
157
+ });
158
+ }
159
+ } catch {
160
+ // Non-JSON / partial line — forward untouched, don't log.
161
+ }
162
+ }
163
+
164
+ function onResponseLine(line: string): void {
165
+ try {
166
+ const msg = JSON.parse(line) as JsonRpcMessage;
167
+ if (msg.id === undefined || !pending.has(msg.id)) return;
168
+ const p = pending.get(msg.id)!;
169
+ pending.delete(msg.id);
170
+ const elapsed = Date.now() - p.startMs;
171
+ const cypherField = p.cypherPrefix ? `cypher="${p.cypherPrefix.replace(/"/g, "'")}"` : "";
172
+ if (msg.error) {
173
+ const errText = (msg.error.message ?? JSON.stringify(msg.error)).replace(/"/g, "'");
174
+ console.error(
175
+ `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} error="${errText}" ms=${elapsed}`,
176
+ );
177
+ } else {
178
+ const rows = countRows(msg.result);
179
+ console.error(
180
+ `[graph-query] op=${p.method} brand=${brand} port=${neo4jPort} ${cypherField} rows=${rows} ms=${elapsed}`,
181
+ );
182
+ }
183
+ } catch {
184
+ // Non-JSON — forward untouched.
185
+ }
186
+ }
187
+
188
+ function makeLineSplitter(onLine: (line: string) => void): (chunk: Buffer) => void {
189
+ const decoder = new StringDecoder("utf8");
190
+ let buf = "";
191
+ return (chunk: Buffer) => {
192
+ buf += decoder.write(chunk);
193
+ let idx: number;
194
+ while ((idx = buf.indexOf("\n")) !== -1) {
195
+ const line = buf.slice(0, idx);
196
+ buf = buf.slice(idx + 1);
197
+ if (line.length > 0) onLine(line);
198
+ }
199
+ };
200
+ }
201
+
202
+ const splitRequest = makeLineSplitter(onRequestLine);
203
+ process.stdin.on("data", (chunk: Buffer) => {
204
+ splitRequest(chunk);
205
+ child.stdin.write(chunk);
206
+ });
207
+ process.stdin.on("end", () => {
208
+ child.stdin.end();
209
+ });
210
+
211
+ const splitResponse = makeLineSplitter(onResponseLine);
212
+ child.stdout.on("data", (chunk: Buffer) => {
213
+ splitResponse(chunk);
214
+ process.stdout.write(chunk);
215
+ });
216
+ child.stdout.on("end", () => {
217
+ process.stdout.end();
218
+ });
219
+
220
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
221
+ process.on(sig, () => {
222
+ console.error(`[graph-mcp] ${sig} received — forwarding to child`);
223
+ child.kill(sig);
224
+ });
225
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -6,8 +6,8 @@
6
6
  "plugins/*/mcp"
7
7
  ],
8
8
  "scripts": {
9
- "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
- "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json",
9
+ "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
+ "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json",
11
11
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
12
12
  "build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
13
13
  "build:telegram": "tsc -p plugins/telegram/mcp/tsconfig.json",
@@ -298,13 +298,24 @@ Prints the UUID from Step 3. If it prints empty or null, the heredoc's env expan
298
298
 
299
299
  You do **not** run `cloudflared` manually. The brand's existing user-space systemd unit (`~/.config/systemd/user/${BRAND}.service`) declares `ExecStartPre=/home/<user>/${BRAND}/platform/scripts/resume-tunnel.sh`, and that pre-start script reads `${CFG_DIR}/tunnel.state` and `${CFG_DIR}/config.yml` (the files Steps 5 and 5b just wrote) and spawns the connector in the user's cgroup. Restarting the brand service is what picks up the new config.
300
300
 
301
- > **Note:** When walking through by hand you run this step yourself. The automation script `platform/plugins/cloudflare/scripts/setup-tunnel.sh` runs it for you — the script is autonomous and completes the deployment, including the service restart and post-restart verification (ps-grep for the connector + curl each subdomain). If you used the script, this step is already done.
301
+ > **Note:** When walking through by hand you run this step yourself. The automation script `platform/plugins/cloudflare/scripts/setup-tunnel.sh` runs it for you — with a critical twist documented below. If you used the script, this step is already done and the service will restart a few seconds after the script exits.
302
302
 
303
303
 
304
304
  ```
305
305
  systemctl --user restart "${BRAND}.service"
306
306
  ```
307
307
 
308
+ **Why the script dispatches the restart via `systemd-run` instead of a direct `systemctl restart` (Task 558):** when the admin agent invokes `setup-tunnel.sh` via the Bash tool, the script runs *inside* `${BRAND}.service`'s cgroup. A direct `systemctl --user restart ${BRAND}.service` from that cgroup tells systemd to SIGTERM the entire cgroup — the node server, the claude subprocess, the Bash child, and the script itself all die simultaneously. cgroup membership is inherited: `setsid`, `nohup`, `disown`, and `&` all stay in the caller's cgroup, and `systemd-run --scope` runs in the caller's scope. Only `systemd-run --user --unit=<name> --on-active=<N>s` creates a genuinely new transient unit with its own cgroup. The script uses that primitive to arm the restart a few seconds after its own exit:
309
+
310
+ ```
311
+ systemd-run --user --unit=maxy-tunnel-restart-<nonce>.service --on-active=3s --collect \
312
+ /bin/systemctl --user restart "${BRAND}.service"
313
+ ```
314
+
315
+ The script then emits `[setup-tunnel] step=service-restart-dispatched` and `step=service-restart-armed exit=0` in the per-conversation stream log so operators see exactly when the restart was scheduled, exits 0, and the transient timer fires from outside the service's cgroup — semantically identical to this manual runbook's `systemctl --user restart`.
316
+
317
+ When walking through manually you do **not** need `systemd-run` — your SSH shell already lives in a separate user-scope cgroup (`user@<uid>.service`), so the direct `systemctl restart` does not kill the caller. The script's extra indirection only matters when the caller *is* the service being restarted.
318
+
308
319
  **Why:** `resume-tunnel.sh` is the deterministic, brand-scoped spawner. Running `cloudflared` manually duplicates the connector (two processes for one tunnel) and races the brand service on every service restart. The service path is the only correct production path.
309
320
 
310
321
  **Success:**
@@ -27,7 +27,10 @@ set -euo pipefail
27
27
  # --------------------------------------------------------------------------
28
28
 
29
29
  # shellcheck source=_stream-log.sh
30
- source "$(dirname "${BASH_SOURCE[0]}")/_stream-log.sh"
30
+ # Resolve symlinks before dirname — ~/reset-tunnel.sh is installed as a symlink
31
+ # (see packages/create-maxy/src/index.ts:installTunnelScripts), so the raw
32
+ # BASH_SOURCE[0] points at $HOME, not the scripts directory where _stream-log.sh lives.
33
+ source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_stream-log.sh"
31
34
  require_stream_log_path reset-tunnel
32
35
 
33
36
  if [ "$#" -lt 1 ]; then