@rubytech/create-realagent 1.0.709 → 1.0.712
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 +38 -3
- package/package.json +2 -2
- package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts +53 -0
- package/payload/platform/lib/mcp-spawn-tee/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/mcp-spawn-tee/dist/index.js +132 -0
- package/payload/platform/lib/mcp-spawn-tee/dist/index.js.map +1 -0
- package/payload/platform/lib/mcp-spawn-tee/src/index.ts +134 -0
- package/payload/platform/lib/mcp-spawn-tee/tsconfig.json +8 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/docs/references/plugins-guide.md +12 -4
- package/payload/platform/plugins/linkedin-import/PLUGIN.md +1 -0
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +26 -5
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +53 -82
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +42 -49
- package/payload/platform/plugins/memory/PLUGIN.md +1 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +48 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js +34 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts +10 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js +22 -3
- package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +33 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +229 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/package.json +3 -1
- package/payload/platform/plugins/memory/mcp/scripts/boot-smoke.sh +69 -0
- package/payload/platform/plugins/memory/references/graph-primitives.md +22 -0
- package/payload/platform/plugins/memory/references/schema-base.md +1 -1
- package/payload/platform/scripts/redact-install-logs.sh +85 -0
- package/payload/platform/scripts/setup.sh +20 -3
- package/payload/platform/scripts/verify-skill-tool-surface.sh +255 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +6 -2
- package/payload/server/chunk-A5K3CFMI.js +12297 -0
- package/payload/server/chunk-U5JPRUYZ.js +12298 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{graph-BRD96pKD.js → graph-DJ7IfYHV.js} +12 -12
- package/payload/server/public/graph.html +1 -1
- package/payload/server/server.js +49 -28
package/dist/index.js
CHANGED
|
@@ -125,7 +125,15 @@ function shell(command, args, options) {
|
|
|
125
125
|
const cmd = options?.sudo ? "sudo" : command;
|
|
126
126
|
const cmdArgs = options?.sudo ? [command, ...args] : args;
|
|
127
127
|
const start = Date.now();
|
|
128
|
-
|
|
128
|
+
// Redaction (Task 744): callers handling secrets pass redact: true so the
|
|
129
|
+
// wrapper records the command name only, not the secret-bearing args. The
|
|
130
|
+
// child process still receives the real args via spawnSync below; only the
|
|
131
|
+
// install log line is sanitised. The grep-able audit shape stays:
|
|
132
|
+
// > sudo neo4j-admin dbms set-initial-password [REDACTED]
|
|
133
|
+
const loggedArgs = options?.redact
|
|
134
|
+
? `${cmdArgs.slice(0, options?.sudo ? 4 : 3).join(" ")} [REDACTED]`
|
|
135
|
+
: cmdArgs.join(" ");
|
|
136
|
+
logFile(`> ${cmd} ${loggedArgs}${options?.cwd ? ` [cwd: ${options.cwd}]` : ""}`);
|
|
129
137
|
const result = spawnSync(cmd, cmdArgs, {
|
|
130
138
|
stdio: "inherit",
|
|
131
139
|
timeout: options?.timeout ?? 300_000,
|
|
@@ -690,7 +698,7 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j") {
|
|
|
690
698
|
}
|
|
691
699
|
else {
|
|
692
700
|
console.log(" [privileged] neo4j-admin dbms");
|
|
693
|
-
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true });
|
|
701
|
+
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true, redact: true });
|
|
694
702
|
}
|
|
695
703
|
console.log(" [privileged] systemctl start");
|
|
696
704
|
shell("systemctl", ["start", serviceName], { sudo: true });
|
|
@@ -707,6 +715,29 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j") {
|
|
|
707
715
|
}
|
|
708
716
|
return password;
|
|
709
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* Task 744 — scrub plaintext neo4j passwords from pre-fix install-*.log files.
|
|
720
|
+
* Calls platform/scripts/redact-install-logs.sh against the installer's LOG_DIR.
|
|
721
|
+
* The script is idempotent; re-running on clean logs is a no-op. Failures here
|
|
722
|
+
* are non-fatal — credential redaction is best-effort cleanup, not a blocker
|
|
723
|
+
* for installation.
|
|
724
|
+
*/
|
|
725
|
+
function redactInstallLogs() {
|
|
726
|
+
const script = resolve(INSTALL_DIR, "platform/scripts/redact-install-logs.sh");
|
|
727
|
+
if (!existsSync(script)) {
|
|
728
|
+
logFile("[redact-install-logs] script not found at " + script + " — skipping");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const r = spawnSync("bash", [script, "--dir", LOG_DIR], {
|
|
732
|
+
stdio: "pipe",
|
|
733
|
+
encoding: "utf-8",
|
|
734
|
+
timeout: 30_000,
|
|
735
|
+
});
|
|
736
|
+
if (r.stdout)
|
|
737
|
+
logFile(r.stdout.trim());
|
|
738
|
+
if (r.status !== 0 && r.stderr)
|
|
739
|
+
logFile("[redact-install-logs] WARN " + r.stderr.trim());
|
|
740
|
+
}
|
|
710
741
|
/** Check Neo4j has a working password. Called AFTER deploy so config is in place. */
|
|
711
742
|
function ensureNeo4jPassword() {
|
|
712
743
|
const passwordFile = join(INSTALL_DIR, "platform/config/.neo4j-password");
|
|
@@ -794,7 +825,7 @@ function installNeo4j() {
|
|
|
794
825
|
mkdirSync(configDir, { recursive: true });
|
|
795
826
|
writeFileSync(join(configDir, ".neo4j-password"), password, { mode: 0o600 });
|
|
796
827
|
console.log(" [privileged] neo4j-admin dbms");
|
|
797
|
-
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true });
|
|
828
|
+
shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true, redact: true });
|
|
798
829
|
console.log(" [privileged] systemctl enable");
|
|
799
830
|
shell("systemctl", ["enable", "neo4j"], { sudo: true });
|
|
800
831
|
console.log(" [privileged] systemctl start");
|
|
@@ -2148,6 +2179,10 @@ try {
|
|
|
2148
2179
|
installCloudflared();
|
|
2149
2180
|
installWhisperCpp();
|
|
2150
2181
|
deployPayload(); // Must happen before ensureNeo4jPassword — restores config backup
|
|
2182
|
+
// Task 744: scrub plaintext neo4j passwords from any pre-fix install-*.log.
|
|
2183
|
+
// Idempotent — re-running on already-redacted logs is a no-op. Runs after
|
|
2184
|
+
// payload deploy so the bundled redact-install-logs.sh is on disk.
|
|
2185
|
+
redactInstallLogs();
|
|
2151
2186
|
ensureNeo4jPassword(); // Now config/.neo4j-password is available if it existed before
|
|
2152
2187
|
provisionRemoteSessionSecret(); // Task 653: shared HMAC key readable by maxy-edge + maxy-ui
|
|
2153
2188
|
buildPlatform();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubytech/create-realagent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.712",
|
|
4
4
|
"description": "Install Real Agent — Built for agents. By agents.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-realagent": "./dist/index.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"bundle": "node scripts/bundle.js",
|
|
12
12
|
"test": "npm run build && node --test 'dist/__tests__/*.test.js'",
|
|
13
|
-
"prepublishOnly": "node ../../platform/ui/scripts/check-route-wiring.mjs && node ../../platform/ui/scripts/check-edge-admin-routes.mjs && npm run build && node --test 'dist/__tests__/*.test.js' && chmod +x dist/index.js && npm run bundle && node ../../platform/ui/scripts/check-bundle-node-imports.mjs --dir=./payload/server/public/assets"
|
|
13
|
+
"prepublishOnly": "bash ../../platform/scripts/verify-skill-tool-surface.sh && node ../../platform/ui/scripts/check-route-wiring.mjs && node ../../platform/ui/scripts/check-edge-admin-routes.mjs && npm run build && node --test 'dist/__tests__/*.test.js' && chmod +x dist/index.js && npm run bundle && node ../../platform/ui/scripts/check-bundle-node-imports.mjs --dir=./payload/server/public/assets"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP spawn-tee — parent-side stderr capture wrapper (Task 743).
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's `--mcp-config` accepts `{command, args, env}` descriptors and
|
|
6
|
+
* spawns each MCP server itself; the platform never holds a ChildProcess
|
|
7
|
+
* handle. The in-process `mcp-stderr-tee` patches `process.stderr.write` from
|
|
8
|
+
* inside the MCP server, but its writes go through `createWriteStream` —
|
|
9
|
+
* async, buffered. A synchronous module-load throw (e.g. schema-loader's
|
|
10
|
+
* line-168 width check on memory) calls `process.exit(1)` before the buffer
|
|
11
|
+
* flushes, so the per-server log file is empty and the platform's
|
|
12
|
+
* `[mcp-init-error] tail="(no stderr file)"` probe is operationally useless.
|
|
13
|
+
* That class shipped as the chronic memory-MCP silent-fail loop fixed by
|
|
14
|
+
* Task 743 (and hit graph in Task 560 — solved there with per-plugin
|
|
15
|
+
* `appendFileSync` discipline that this wrapper now generalises).
|
|
16
|
+
*
|
|
17
|
+
* The wrapper sits between Claude Code and the real MCP server: it spawns
|
|
18
|
+
* the real entry with `stdio: ['inherit', 'inherit', 'pipe']`, then
|
|
19
|
+
* synchronously appends every child stderr chunk to
|
|
20
|
+
* `${LOG_DIR}/mcp-${name}-stderr-<date>.log`. Synchronous writes survive
|
|
21
|
+
* `process.exit` because the kernel queues the syscall before the call
|
|
22
|
+
* returns. The chunk is also written to the wrapper's own stderr so
|
|
23
|
+
* Claude Code's existing stderr consumer is unchanged — the mechanism is
|
|
24
|
+
* additive, not interceptive.
|
|
25
|
+
*
|
|
26
|
+
* Claude Code CLI
|
|
27
|
+
* │ spawns
|
|
28
|
+
* ▼
|
|
29
|
+
* wrapper (this file) — argv[2] = real entry, env.MCP_SPAWN_TEE_NAME = name
|
|
30
|
+
* │ spawns child with stdio:['inherit','inherit','pipe']
|
|
31
|
+
* ▼
|
|
32
|
+
* child = node <real-entry>
|
|
33
|
+
* │
|
|
34
|
+
* ├──▶ stdin/stdout: inherited from wrapper (Claude Code pipe)
|
|
35
|
+
* └──▶ stderr → wrapper.on('data', chunk =>)
|
|
36
|
+
* ├──▶ appendFileSync(${LOG_DIR}/mcp-${name}-stderr-<date>.log)
|
|
37
|
+
* └──▶ process.stderr.write(chunk) → Claude Code consumer
|
|
38
|
+
*
|
|
39
|
+
* The wrapper catches:
|
|
40
|
+
* - MODULE_NOT_FOUND on the entry script itself (success criterion #3:
|
|
41
|
+
* `mv plugins/memory/mcp/dist plugins/memory/mcp/dist-broken && reboot`
|
|
42
|
+
* leaves the cause on disk).
|
|
43
|
+
* - Synchronous throws during module load before the in-process tee runs.
|
|
44
|
+
* - Any stderr a normally-running plugin emits (steady-state telemetry
|
|
45
|
+
* remains visible too — the in-process tee handles per-line stream-log
|
|
46
|
+
* prefixing; this wrapper handles the raw per-server file).
|
|
47
|
+
*
|
|
48
|
+
* SIGTERM/SIGINT propagation: forwarded to the child so a Claude Code
|
|
49
|
+
* shutdown does not orphan the MCP server. Child exit code is propagated
|
|
50
|
+
* verbatim so upstream (Claude Code) sees the real outcome.
|
|
51
|
+
*/
|
|
52
|
+
export {};
|
|
53
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* MCP spawn-tee — parent-side stderr capture wrapper (Task 743).
|
|
5
|
+
*
|
|
6
|
+
* Claude Code's `--mcp-config` accepts `{command, args, env}` descriptors and
|
|
7
|
+
* spawns each MCP server itself; the platform never holds a ChildProcess
|
|
8
|
+
* handle. The in-process `mcp-stderr-tee` patches `process.stderr.write` from
|
|
9
|
+
* inside the MCP server, but its writes go through `createWriteStream` —
|
|
10
|
+
* async, buffered. A synchronous module-load throw (e.g. schema-loader's
|
|
11
|
+
* line-168 width check on memory) calls `process.exit(1)` before the buffer
|
|
12
|
+
* flushes, so the per-server log file is empty and the platform's
|
|
13
|
+
* `[mcp-init-error] tail="(no stderr file)"` probe is operationally useless.
|
|
14
|
+
* That class shipped as the chronic memory-MCP silent-fail loop fixed by
|
|
15
|
+
* Task 743 (and hit graph in Task 560 — solved there with per-plugin
|
|
16
|
+
* `appendFileSync` discipline that this wrapper now generalises).
|
|
17
|
+
*
|
|
18
|
+
* The wrapper sits between Claude Code and the real MCP server: it spawns
|
|
19
|
+
* the real entry with `stdio: ['inherit', 'inherit', 'pipe']`, then
|
|
20
|
+
* synchronously appends every child stderr chunk to
|
|
21
|
+
* `${LOG_DIR}/mcp-${name}-stderr-<date>.log`. Synchronous writes survive
|
|
22
|
+
* `process.exit` because the kernel queues the syscall before the call
|
|
23
|
+
* returns. The chunk is also written to the wrapper's own stderr so
|
|
24
|
+
* Claude Code's existing stderr consumer is unchanged — the mechanism is
|
|
25
|
+
* additive, not interceptive.
|
|
26
|
+
*
|
|
27
|
+
* Claude Code CLI
|
|
28
|
+
* │ spawns
|
|
29
|
+
* ▼
|
|
30
|
+
* wrapper (this file) — argv[2] = real entry, env.MCP_SPAWN_TEE_NAME = name
|
|
31
|
+
* │ spawns child with stdio:['inherit','inherit','pipe']
|
|
32
|
+
* ▼
|
|
33
|
+
* child = node <real-entry>
|
|
34
|
+
* │
|
|
35
|
+
* ├──▶ stdin/stdout: inherited from wrapper (Claude Code pipe)
|
|
36
|
+
* └──▶ stderr → wrapper.on('data', chunk =>)
|
|
37
|
+
* ├──▶ appendFileSync(${LOG_DIR}/mcp-${name}-stderr-<date>.log)
|
|
38
|
+
* └──▶ process.stderr.write(chunk) → Claude Code consumer
|
|
39
|
+
*
|
|
40
|
+
* The wrapper catches:
|
|
41
|
+
* - MODULE_NOT_FOUND on the entry script itself (success criterion #3:
|
|
42
|
+
* `mv plugins/memory/mcp/dist plugins/memory/mcp/dist-broken && reboot`
|
|
43
|
+
* leaves the cause on disk).
|
|
44
|
+
* - Synchronous throws during module load before the in-process tee runs.
|
|
45
|
+
* - Any stderr a normally-running plugin emits (steady-state telemetry
|
|
46
|
+
* remains visible too — the in-process tee handles per-line stream-log
|
|
47
|
+
* prefixing; this wrapper handles the raw per-server file).
|
|
48
|
+
*
|
|
49
|
+
* SIGTERM/SIGINT propagation: forwarded to the child so a Claude Code
|
|
50
|
+
* shutdown does not orphan the MCP server. Child exit code is propagated
|
|
51
|
+
* verbatim so upstream (Claude Code) sees the real outcome.
|
|
52
|
+
*/
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
const node_child_process_1 = require("node:child_process");
|
|
55
|
+
const node_fs_1 = require("node:fs");
|
|
56
|
+
const node_path_1 = require("node:path");
|
|
57
|
+
const SERVER_NAME = process.env.MCP_SPAWN_TEE_NAME ?? "unknown";
|
|
58
|
+
const LOG_DIR = process.env.LOG_DIR;
|
|
59
|
+
const ENTRY = process.argv[2];
|
|
60
|
+
// Sync-emit: appendFileSync to per-server log + stderr passthrough. Used for
|
|
61
|
+
// wrapper-internal diagnostics (attach line, errors) and for forwarding child
|
|
62
|
+
// stderr chunks. Each destination wrapped independently — an unwritable log
|
|
63
|
+
// must not mask the primary output.
|
|
64
|
+
function syncEmitLine(line) {
|
|
65
|
+
if (LOG_DIR) {
|
|
66
|
+
try {
|
|
67
|
+
(0, node_fs_1.mkdirSync)(LOG_DIR, { recursive: true });
|
|
68
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
69
|
+
(0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(LOG_DIR, `mcp-${SERVER_NAME}-stderr-${date}.log`), line);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* unwritable destination — preserve primary failure */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
process.stderr.write(line);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* stderr closed — nothing else to do */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!ENTRY) {
|
|
83
|
+
syncEmitLine(`[mcp-spawn-tee-error] server=${SERVER_NAME} reason="no entry given (argv[2] missing)"\n`);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [ENTRY], {
|
|
87
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
88
|
+
env: process.env,
|
|
89
|
+
});
|
|
90
|
+
syncEmitLine(`[mcp-spawn-tee-attached] server=${SERVER_NAME} pid=${child.pid ?? -1} entry=${ENTRY}\n`);
|
|
91
|
+
child.on("error", (err) => {
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
syncEmitLine(`[mcp-spawn-tee-error] server=${SERVER_NAME} reason=${JSON.stringify(`spawn error: ${msg}`)}\n`);
|
|
94
|
+
process.exit(127);
|
|
95
|
+
});
|
|
96
|
+
if (child.stderr) {
|
|
97
|
+
child.stderr.on("data", (chunk) => {
|
|
98
|
+
if (LOG_DIR) {
|
|
99
|
+
try {
|
|
100
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
101
|
+
(0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(LOG_DIR, `mcp-${SERVER_NAME}-stderr-${date}.log`), chunk);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* unwritable destination — preserve passthrough */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
process.stderr.write(chunk);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* stderr closed — nothing else to do */
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Forward SIGTERM/SIGINT so Claude Code shutdown doesn't orphan the child.
|
|
116
|
+
const forward = (signal) => {
|
|
117
|
+
if (!child.killed)
|
|
118
|
+
child.kill(signal);
|
|
119
|
+
};
|
|
120
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
121
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
122
|
+
child.on("exit", (code, signal) => {
|
|
123
|
+
if (signal) {
|
|
124
|
+
syncEmitLine(`[mcp-spawn-tee-exit] server=${SERVER_NAME} pid=${child.pid ?? -1} signal=${signal}\n`);
|
|
125
|
+
process.exit(128 + (code ?? 0));
|
|
126
|
+
}
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
syncEmitLine(`[mcp-spawn-tee-exit] server=${SERVER_NAME} pid=${child.pid ?? -1} code=${code}\n`);
|
|
129
|
+
}
|
|
130
|
+
process.exit(code ?? 0);
|
|
131
|
+
});
|
|
132
|
+
//# 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,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP spawn-tee — parent-side stderr capture wrapper (Task 743).
|
|
4
|
+
*
|
|
5
|
+
* Claude Code's `--mcp-config` accepts `{command, args, env}` descriptors and
|
|
6
|
+
* spawns each MCP server itself; the platform never holds a ChildProcess
|
|
7
|
+
* handle. The in-process `mcp-stderr-tee` patches `process.stderr.write` from
|
|
8
|
+
* inside the MCP server, but its writes go through `createWriteStream` —
|
|
9
|
+
* async, buffered. A synchronous module-load throw (e.g. schema-loader's
|
|
10
|
+
* line-168 width check on memory) calls `process.exit(1)` before the buffer
|
|
11
|
+
* flushes, so the per-server log file is empty and the platform's
|
|
12
|
+
* `[mcp-init-error] tail="(no stderr file)"` probe is operationally useless.
|
|
13
|
+
* That class shipped as the chronic memory-MCP silent-fail loop fixed by
|
|
14
|
+
* Task 743 (and hit graph in Task 560 — solved there with per-plugin
|
|
15
|
+
* `appendFileSync` discipline that this wrapper now generalises).
|
|
16
|
+
*
|
|
17
|
+
* The wrapper sits between Claude Code and the real MCP server: it spawns
|
|
18
|
+
* the real entry with `stdio: ['inherit', 'inherit', 'pipe']`, then
|
|
19
|
+
* synchronously appends every child stderr chunk to
|
|
20
|
+
* `${LOG_DIR}/mcp-${name}-stderr-<date>.log`. Synchronous writes survive
|
|
21
|
+
* `process.exit` because the kernel queues the syscall before the call
|
|
22
|
+
* returns. The chunk is also written to the wrapper's own stderr so
|
|
23
|
+
* Claude Code's existing stderr consumer is unchanged — the mechanism is
|
|
24
|
+
* additive, not interceptive.
|
|
25
|
+
*
|
|
26
|
+
* Claude Code CLI
|
|
27
|
+
* │ spawns
|
|
28
|
+
* ▼
|
|
29
|
+
* wrapper (this file) — argv[2] = real entry, env.MCP_SPAWN_TEE_NAME = name
|
|
30
|
+
* │ spawns child with stdio:['inherit','inherit','pipe']
|
|
31
|
+
* ▼
|
|
32
|
+
* child = node <real-entry>
|
|
33
|
+
* │
|
|
34
|
+
* ├──▶ stdin/stdout: inherited from wrapper (Claude Code pipe)
|
|
35
|
+
* └──▶ stderr → wrapper.on('data', chunk =>)
|
|
36
|
+
* ├──▶ appendFileSync(${LOG_DIR}/mcp-${name}-stderr-<date>.log)
|
|
37
|
+
* └──▶ process.stderr.write(chunk) → Claude Code consumer
|
|
38
|
+
*
|
|
39
|
+
* The wrapper catches:
|
|
40
|
+
* - MODULE_NOT_FOUND on the entry script itself (success criterion #3:
|
|
41
|
+
* `mv plugins/memory/mcp/dist plugins/memory/mcp/dist-broken && reboot`
|
|
42
|
+
* leaves the cause on disk).
|
|
43
|
+
* - Synchronous throws during module load before the in-process tee runs.
|
|
44
|
+
* - Any stderr a normally-running plugin emits (steady-state telemetry
|
|
45
|
+
* remains visible too — the in-process tee handles per-line stream-log
|
|
46
|
+
* prefixing; this wrapper handles the raw per-server file).
|
|
47
|
+
*
|
|
48
|
+
* SIGTERM/SIGINT propagation: forwarded to the child so a Claude Code
|
|
49
|
+
* shutdown does not orphan the MCP server. Child exit code is propagated
|
|
50
|
+
* verbatim so upstream (Claude Code) sees the real outcome.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { spawn } from "node:child_process";
|
|
54
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
55
|
+
import { resolve } from "node:path";
|
|
56
|
+
|
|
57
|
+
const SERVER_NAME = process.env.MCP_SPAWN_TEE_NAME ?? "unknown";
|
|
58
|
+
const LOG_DIR = process.env.LOG_DIR;
|
|
59
|
+
const ENTRY = process.argv[2];
|
|
60
|
+
|
|
61
|
+
// Sync-emit: appendFileSync to per-server log + stderr passthrough. Used for
|
|
62
|
+
// wrapper-internal diagnostics (attach line, errors) and for forwarding child
|
|
63
|
+
// stderr chunks. Each destination wrapped independently — an unwritable log
|
|
64
|
+
// must not mask the primary output.
|
|
65
|
+
function syncEmitLine(line: string): void {
|
|
66
|
+
if (LOG_DIR) {
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
69
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
70
|
+
appendFileSync(resolve(LOG_DIR, `mcp-${SERVER_NAME}-stderr-${date}.log`), line);
|
|
71
|
+
} catch {
|
|
72
|
+
/* unwritable destination — preserve primary failure */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
process.stderr.write(line);
|
|
77
|
+
} catch {
|
|
78
|
+
/* stderr closed — nothing else to do */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!ENTRY) {
|
|
83
|
+
syncEmitLine(`[mcp-spawn-tee-error] server=${SERVER_NAME} reason="no entry given (argv[2] missing)"\n`);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const child = spawn(process.execPath, [ENTRY], {
|
|
88
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
89
|
+
env: process.env,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
syncEmitLine(`[mcp-spawn-tee-attached] server=${SERVER_NAME} pid=${child.pid ?? -1} entry=${ENTRY}\n`);
|
|
93
|
+
|
|
94
|
+
child.on("error", (err) => {
|
|
95
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
+
syncEmitLine(`[mcp-spawn-tee-error] server=${SERVER_NAME} reason=${JSON.stringify(`spawn error: ${msg}`)}\n`);
|
|
97
|
+
process.exit(127);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (child.stderr) {
|
|
101
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
102
|
+
if (LOG_DIR) {
|
|
103
|
+
try {
|
|
104
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
105
|
+
appendFileSync(resolve(LOG_DIR, `mcp-${SERVER_NAME}-stderr-${date}.log`), chunk);
|
|
106
|
+
} catch {
|
|
107
|
+
/* unwritable destination — preserve passthrough */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
process.stderr.write(chunk);
|
|
112
|
+
} catch {
|
|
113
|
+
/* stderr closed — nothing else to do */
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Forward SIGTERM/SIGINT so Claude Code shutdown doesn't orphan the child.
|
|
119
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
120
|
+
if (!child.killed) child.kill(signal);
|
|
121
|
+
};
|
|
122
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
123
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
124
|
+
|
|
125
|
+
child.on("exit", (code, signal) => {
|
|
126
|
+
if (signal) {
|
|
127
|
+
syncEmitLine(`[mcp-spawn-tee-exit] server=${SERVER_NAME} pid=${child.pid ?? -1} signal=${signal}\n`);
|
|
128
|
+
process.exit(128 + (code ?? 0));
|
|
129
|
+
}
|
|
130
|
+
if (code !== 0) {
|
|
131
|
+
syncEmitLine(`[mcp-spawn-tee-exit] server=${SERVER_NAME} pid=${child.pid ?? -1} code=${code}\n`);
|
|
132
|
+
}
|
|
133
|
+
process.exit(code ?? 0);
|
|
134
|
+
});
|
|
@@ -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/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-write/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/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-write/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/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-write/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/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-write/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",
|
|
@@ -115,10 +115,18 @@ After this, every `console.error("[your-tool] ...")` from any tool in the plugin
|
|
|
115
115
|
|
|
116
116
|
**Main-subprocess stderr (Task 535).** The same teeing pattern applies to the main Claude Code subprocess's stderr — every line lands in the per-conversation stream log as `[subproc-stderr] …`, with lifecycle markers `[subproc-stderr-tee-attached] pid=…` and `[subproc-stderr-tee-detached] pid=… bytes=N lines=N`. A `bytes=0 lines=0` detach means the tee was attached but the subprocess emitted nothing on stderr — which is the normal state today, because the Claude Code CLI is a bundled Bun runtime binary that does not honour Node's `NODE_DEBUG` env var. The platform records this explicitly with one line per spawn: `[subproc-debug-unavailable] reason=bundled-bun-binary-ignores-node-debug pid=… cli=claude`. A reader who finds a `[spawn]` without these markers should treat that as a regression of the tee infrastructure, not as silence.
|
|
117
117
|
|
|
118
|
-
## Failure-path observability contract (Task 560)
|
|
118
|
+
## Failure-path observability contract (Task 560 + Task 743)
|
|
119
119
|
|
|
120
|
-
The `initStderrTee` wrapper writes to the per-conversation stream log and per-server raw file via `createWriteStream` — async, buffered. Any diagnostic `console.error(…)` followed by an immediate `process.exit(…)` is lost: the event loop never drains the WriteStream before the process terminates.
|
|
120
|
+
The `initStderrTee` wrapper writes to the per-conversation stream log and per-server raw file via `createWriteStream` — async, buffered. Any diagnostic `console.error(…)` followed by an immediate `process.exit(…)` is lost: the event loop never drains the WriteStream before the process terminates. Same race for any synchronous module-load throw: Node's uncaught-exception handler writes the stack to raw fd 2 and exits before the patched async stream flushes. The platform's `[mcp-init-error] tail="(no stderr file)"` line — operationally useless — is the public symptom of this race.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
**Two layers now close the gap, each load-bearing on its own:**
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
1. **Plugin-side sync-write discipline.** Plugins that call `process.exit()` during module load (rare — `graph-mcp` is the in-tree example; it spawns a child at boot to proxy upstream stdio) use `fs.appendFileSync` at every named exit path to guarantee the cause lands in both log destinations before exit. Lines follow the `[mcp:<name>] [<plugin-prefix>] <cause>` format so existing `grep '[mcp:<name>]'` investigator paths work. Each destination is wrapped in its own try/catch — an unwritable log must not mask the primary failure. This is the discipline propagated from Task 560 to any plugin author who knows their failure paths.
|
|
125
|
+
|
|
126
|
+
2. **Parent-side `mcp-spawn-tee` wrapper (Task 743).** Every node-based core MCP server is spawned via the `lib/mcp-spawn-tee` wrapper rather than `node <entry>` directly. The wrapper spawns the real entry with `stdio: ['inherit', 'inherit', 'pipe']` and writes child stderr chunks to `${LOG_DIR}/mcp-${name}-stderr-<date>.log` via `appendFileSync` while passing the same chunks through to its own stderr (Claude Code's consumer is unchanged). Synchronous `appendFileSync` survives `process.exit`, so the per-server file captures even (a) module-load throws before `initStderrTee` runs, (b) `MODULE_NOT_FOUND` on the entry script itself, and (c) anything else a plugin author missed. The wrapper writes `[mcp-spawn-tee-attached] server=<name> pid=<n>` on attach and forwards SIGTERM/SIGINT to the child. This is the layer that makes capture independent of plugin discipline. Playwright stays unwrapped because it spawns via `npx`, not `node`.
|
|
127
|
+
|
|
128
|
+
A third layer closes the same gap from the platform side: when `claude-agent.ts` observes an `init` event with any MCP server reporting `status:"failed"`, it reads the last 512 bytes of `${LOG_DIR}/mcp-<name>-stderr-<date>.log` and emits `[mcp-init-error] server=<name> tail=<quoted>` into the stream log. Absent file → `tail="(no stderr file)"`; empty file → `tail="(empty)"`. With the spawn-tee wrapper now interposing on every core MCP, `tail="(no stderr file)"` post-Task-743 means the wrapper itself is broken — file follow-up.
|
|
129
|
+
|
|
130
|
+
**Signal inventory after a failed session:** `[init] FAILED MCP servers: <names>` (names), `[mcp-init-error] server=<name> tail=…` (cause for each, from the platform's tail probe), `[mcp-spawn-tee-attached] server=<name> pid=<n>` (proof the wrapper attached), `[mcp-spawn-tee-exit] server=<name> code=<n>|signal=<s>` (proof the wrapper saw the exit), and optionally `[mcp:<name>] [<plugin>] …` from plugin-side sync-writes. Their union gives the investigator three independent sources for the same failure.
|
|
131
|
+
|
|
132
|
+
**Boot-smoke as publish-time gate (Task 743).** The memory MCP carries `scripts/boot-smoke.sh` that spawns `dist/index.js` with stub env, sleeps 2s, asserts `kill -0 <pid>`, and reports `[boot-smoke] memory ok|FAILED tail=<n-lines>`. Wired to `prepublish` in `plugins/memory/mcp/package.json`. The pattern is propagatable to other plugin MCPs — it's deliberately not generalised yet because each plugin's stub-env requirements differ (memory needs ACCOUNT_ID + PLATFORM_ROOT + NEO4J_URI + SESSION_ID; others differ).
|
|
@@ -42,7 +42,7 @@ When the owner is an external Person (non-operator archive), the anchor is the c
|
|
|
42
42
|
|
|
43
43
|
## Invariants
|
|
44
44
|
|
|
45
|
-
1. **Schema first.** The LinkedIn additions (`person_linkedin_url` index, `:Credential` constraint) live in [`platform/neo4j/schema.cypher`](../../../../neo4j/schema.cypher) and are applied by `platform/scripts/seed-neo4j.sh` on every install / upgrade.
|
|
45
|
+
1. **Schema first.** The LinkedIn additions (`person_linkedin_url` index, `:Credential` constraint) live in [`platform/neo4j/schema.cypher`](../../../../neo4j/schema.cypher) and are applied by `platform/scripts/seed-neo4j.sh` on every install / upgrade. The skill assumes the schema has been seeded; it does not bootstrap schema itself. If a constraint or index is missing, the operator re-runs `seed-neo4j.sh` from the installer — schema-bootstrap is installer-side, never agent-side.
|
|
46
46
|
2. **Owner confirmed first.** No reference runs until `$ownerUserId` (or `$ownerPersonId`) is persisted and echo-confirmed. The reference set is parameterised — no hard-coded owner.
|
|
47
47
|
3. **Natural edges only.** Every edge written is one the CSV actually expresses. `Connections.csv` encodes "I am connected on LinkedIn to this person" — that becomes `CONNECTED_ON_LINKEDIN`. No synthetic attach-to-owner pattern bolted onto rows that don't describe a relationship to the owner.
|
|
48
48
|
4. **Reuse Maxy labels.** Schema-extension is last resort. The LinkedIn set maps onto existing labels wherever semantics align:
|
|
@@ -60,10 +60,31 @@ When the owner is an external Person (non-operator archive), the anchor is the c
|
|
|
60
60
|
|
|
61
61
|
## Execution model
|
|
62
62
|
|
|
63
|
-
1.
|
|
64
|
-
2.
|
|
65
|
-
3.
|
|
66
|
-
4.
|
|
63
|
+
1. Run the owner-confirmation flow, persist `$ownerUserId` / `$ownerPersonId`. The owner identity resolves to a single `ownerNodeId` (elementId of the AdminUser or external Person) used in every write call.
|
|
64
|
+
2. For each file the operator approves, load its reference, parse the CSV into typed `rows[]` matching the reference's row schema.
|
|
65
|
+
3. **Selective-ingest gate.** Before invoking any write tool, check the parsed row count against the reference's `selectiveIngestThreshold`. If the count exceeds the threshold, pause and ask the operator to filter the import along the natural axes named in the reference (for `Connections.csv`: Company, Position, Connected On). Apply the filter to `rows[]` before continuing. Compress on write, never after — a 5,000-row blanket import is a landfill, a 200-row filtered import is signal. See [§Selective-ingest](#selective-ingest-threshold-bulk-archives).
|
|
66
|
+
4. Invoke the deterministic write tool the reference names. For all archive references this is `mcp__memory__memory-archive-write` with `{archiveType, ownerNodeId, rows}` — the Cypher body is fixed server-side per `archiveType`, so the agent supplies parsed rows, never Cypher. The tool batches rows at 500 per transaction internally.
|
|
67
|
+
5. After each file emit `[linkedin-import] file=<name> rows=<n> created=<n> matched=<n> ms=<elapsed>` using the counters returned by the write tool.
|
|
68
|
+
|
|
69
|
+
**Doctrine:** raw Cypher and `cypher-shell` invocations are forbidden in this skill and its references. Writes route through `mcp__memory__memory-archive-write` (bulk archives) or `mcp__memory__memory-write` / `mcp__memory__memory-update` (single-node enrichments like `profile.md`). If a CSV needs a write shape no current MCP tool supports, file a task to extend `memory-archive-write` with a new `archiveType` handler — never improvise via Bash. See [database-operator's LOUD-FAIL prerogative](../../../../templates/specialists/agents/database-operator.md#prerogatives).
|
|
70
|
+
|
|
71
|
+
## Selective-ingest threshold (bulk archives)
|
|
72
|
+
|
|
73
|
+
A LinkedIn export typically contains 3,000–10,000 connections. Writing all of them in one shot defeats compression-on-write — most rows will never be queried, and the noise compounds with every subsequent ingest. The skill compresses by interrogating the operator before bulk writes.
|
|
74
|
+
|
|
75
|
+
**Threshold:** when a parsed reference's `rows[]` exceeds **100 rows**, pause and ask the operator to filter along the reference's natural axes before invoking the write tool.
|
|
76
|
+
|
|
77
|
+
For `Connections.csv` the natural filter axes are:
|
|
78
|
+
|
|
79
|
+
- **Company** — "only people at LargeCorp", "only Female Founders Fund alumni"
|
|
80
|
+
- **Position** — "only Partners", "only Engineering Managers"
|
|
81
|
+
- **Connected On** (date range) — "only my last two years", "since 2024-01-01"
|
|
82
|
+
|
|
83
|
+
The operator picks one axis or a combination. The agent applies the filter to `rows[]` and writes only the filtered subset.
|
|
84
|
+
|
|
85
|
+
**Re-importing is idempotent.** Coming back later with a wider filter (`"add anyone at LargeCorp"`, `"include 2022 too"`) hits the same `linkedinUrl` natural key — existing `:Person` nodes are matched and updated; only the new-only delta is created. The operator can grow the slice over time without dedup work.
|
|
86
|
+
|
|
87
|
+
**Why the threshold lives in the skill, not the server.** Different archive types have different "interesting" thresholds — 100 LinkedIn connections is a lot; 100 LinkedIn skills is small. The MCP tool accepts whatever rows are passed; the conversational gate is the skill's responsibility.
|
|
67
88
|
|
|
68
89
|
## File roster
|
|
69
90
|
|