@mindbridgeio/muse 0.1.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 +35 -0
- package/bin/mindbridge-muse.mjs +212 -0
- package/dist/agent-hooks.mjs +233 -0
- package/dist/agent-install.mjs +62 -0
- package/dist/agent-oauth.mjs +984 -0
- package/dist/oauth-hook.mjs +9 -0
- package/dist/project-metadata.mjs +159 -0
- package/hooks/hooks.json +52 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @mindbridgeio/muse
|
|
2
|
+
|
|
3
|
+
MindBridge MCP tools and lifecycle hooks for Muse, using durable OAuth.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx -y @mindbridgeio/muse@latest setup
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The command writes the `streamable_http` MCP server and native lifecycle hooks
|
|
10
|
+
into `~/.config/muse/settings.json` (or `$XDG_CONFIG_HOME/muse/settings.json`),
|
|
11
|
+
starts Muse's MCP login, then signs lifecycle hooks in through the system
|
|
12
|
+
keychain. It uses `https://memory.ifelse.io` by default; pass
|
|
13
|
+
`--server-url https://…` or set `MINDBRIDGE_MCP_SERVER_URL` for a nonsecret
|
|
14
|
+
server override.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx -y @mindbridgeio/muse@latest auth status
|
|
18
|
+
npx -y @mindbridgeio/muse@latest auth logout
|
|
19
|
+
npx -y @mindbridgeio/muse@latest doctor
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No Bearer [REDACTED] is placed in Muse configuration, command arguments, or an
|
|
23
|
+
environment variable. A keychain is required; setup fails safely when one is
|
|
24
|
+
unavailable.
|
|
25
|
+
|
|
26
|
+
Setup validates the server, Muse CLI, and keychain before changing config. If
|
|
27
|
+
MindBridge is temporarily unavailable, hooks remain non-blocking and replay
|
|
28
|
+
redacted pending events from a bounded local outbox. `doctor --json` reports
|
|
29
|
+
MCP configuration, lifecycle OAuth, and outbox health. The MCP server is
|
|
30
|
+
registered as `optional` so a MindBridge outage can never abort a session.
|
|
31
|
+
|
|
32
|
+
New sessions are tagged automatically from Muse's working directory. Git
|
|
33
|
+
repositories use a credential-free remote identity; non-Git directories use a
|
|
34
|
+
private install-scoped local identity. Recall prefers the current project and
|
|
35
|
+
then fills from the wider MindBridge workspace.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { cp, mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
agentHookStateRoot,
|
|
11
|
+
createOAuthClient,
|
|
12
|
+
copyKeyringRuntime,
|
|
13
|
+
oauthReason,
|
|
14
|
+
outboxStatus,
|
|
15
|
+
preflightServer,
|
|
16
|
+
readServerConfig,
|
|
17
|
+
resolveServerURL,
|
|
18
|
+
saveServerConfig,
|
|
19
|
+
} from "../dist/agent-oauth.mjs";
|
|
20
|
+
import { atomicWrite, phase, readJSON, runCommand, withoutManagedHooks } from "../dist/agent-install.mjs";
|
|
21
|
+
|
|
22
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
23
|
+
const MANAGED_HOOK_STATUSES = ["loading mindbridge memory", "recording mindbridge context", "recording mindbridge response", "finalizing mindbridge context"];
|
|
24
|
+
|
|
25
|
+
export function defaultMuseHome(env = process.env) {
|
|
26
|
+
if (env.MUSE_CONFIG_DIR) return env.MUSE_CONFIG_DIR;
|
|
27
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "muse");
|
|
28
|
+
return join(homedir(), ".config", "muse");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function installSettings(existing, hookPath, serverURL) {
|
|
32
|
+
const hooks = { ...(existing?.hooks && typeof existing.hooks === "object" ? existing.hooks : {}) };
|
|
33
|
+
const specs = {
|
|
34
|
+
SessionStart: ["Loading MindBridge memory", 30],
|
|
35
|
+
UserPromptSubmit: ["Recording MindBridge context", 10],
|
|
36
|
+
Stop: ["Recording MindBridge response", 10],
|
|
37
|
+
SessionEnd: ["Finalizing MindBridge context", 30],
|
|
38
|
+
};
|
|
39
|
+
for (const [event, [statusMessage, timeout]] of Object.entries(specs)) {
|
|
40
|
+
hooks[event] = [
|
|
41
|
+
...withoutManagedHooks(hooks[event], MANAGED_HOOK_STATUSES),
|
|
42
|
+
{ hooks: [{ type: "command", command: `node ${JSON.stringify(hookPath)} muse`, timeout, statusMessage }] },
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
// Muse has no `mcp add` command; the server entry is written directly. It
|
|
46
|
+
// stays optional so a memory-server outage can never abort a session.
|
|
47
|
+
const mcpServers = { ...(existing?.mcpServers && typeof existing.mcpServers === "object" ? existing.mcpServers : {}) };
|
|
48
|
+
mcpServers.mindbridge = { transport: "streamable_http", url: `${serverURL}/mcp`, mode: "optional" };
|
|
49
|
+
return { ...existing, schema_version: 1, hooks, mcpServers };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unavailable(error) {
|
|
53
|
+
return error?.code === "ENOENT";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function hostLogin(commandRunner) {
|
|
57
|
+
try {
|
|
58
|
+
await commandRunner("muse", ["mcp", "login", "mindbridge"]);
|
|
59
|
+
return "started";
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (unavailable(error)) return "unavailable";
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function setup({
|
|
67
|
+
museHome = defaultMuseHome(),
|
|
68
|
+
packageRoot = PACKAGE_ROOT,
|
|
69
|
+
env = process.env,
|
|
70
|
+
serverURL,
|
|
71
|
+
configRoot,
|
|
72
|
+
commandRunner = runCommand,
|
|
73
|
+
copyRuntime = copyKeyringRuntime,
|
|
74
|
+
preflight = preflightServer,
|
|
75
|
+
oauthClientFactory = (url) => createOAuthClient({ host: "muse", serverURL: url, env, configRoot }),
|
|
76
|
+
} = {}) {
|
|
77
|
+
const resolvedServerURL = await resolveServerURL({ host: "muse", serverURL, env, configRoot });
|
|
78
|
+
const oauth = oauthClientFactory(resolvedServerURL);
|
|
79
|
+
await phase("server OAuth discovery", () => preflight(resolvedServerURL));
|
|
80
|
+
await phase("Muse CLI preflight", () => commandRunner("muse", ["mcp", "--help"]));
|
|
81
|
+
await phase("lifecycle keychain preflight", () => oauth.status());
|
|
82
|
+
const installRoot = join(museHome, "mindbridge");
|
|
83
|
+
const distSource = join(packageRoot, "dist");
|
|
84
|
+
const distTarget = join(installRoot, "dist");
|
|
85
|
+
const hookPath = join(distTarget, "oauth-hook.mjs");
|
|
86
|
+
await mkdir(installRoot, { recursive: true, mode: 0o700 });
|
|
87
|
+
await cp(distSource, distTarget, { recursive: true, force: true });
|
|
88
|
+
await copyRuntime({ targetRoot: installRoot });
|
|
89
|
+
|
|
90
|
+
const settingsPath = join(museHome, "settings.json");
|
|
91
|
+
await atomicWrite(settingsPath, `${JSON.stringify(installSettings(await readJSON(settingsPath, {}), hookPath, resolvedServerURL), null, 2)}\n`);
|
|
92
|
+
await saveServerConfig("muse", resolvedServerURL, { configRoot, env });
|
|
93
|
+
const mcpLogin = await phase("Muse MCP authorization", () => hostLogin(commandRunner));
|
|
94
|
+
await phase("lifecycle-hook authorization", () => oauth.authenticate());
|
|
95
|
+
return { museHome, settingsPath, hookPath, serverURL: resolvedServerURL, mcpLogin };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const install = setup;
|
|
99
|
+
|
|
100
|
+
export async function authStatus({ env = process.env, serverURL, configRoot, oauthClientFactory } = {}) {
|
|
101
|
+
const resolvedServerURL = await resolveServerURL({ host: "muse", serverURL, env, configRoot });
|
|
102
|
+
const client = (oauthClientFactory || ((url) => createOAuthClient({ host: "muse", serverURL: url, env, configRoot })))(resolvedServerURL);
|
|
103
|
+
const stored = await client.status();
|
|
104
|
+
if (!stored.authenticated || !client.check) return { serverURL: resolvedServerURL, ...stored };
|
|
105
|
+
try {
|
|
106
|
+
await client.check();
|
|
107
|
+
return { serverURL: resolvedServerURL, ...stored, healthy: true };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return { serverURL: resolvedServerURL, ...stored, healthy: false, reason: oauthReason(error) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function doctor({
|
|
114
|
+
museHome = defaultMuseHome(),
|
|
115
|
+
env = process.env,
|
|
116
|
+
serverURL,
|
|
117
|
+
configRoot,
|
|
118
|
+
commandRunner = runCommand,
|
|
119
|
+
preflight = preflightServer,
|
|
120
|
+
oauthClientFactory = (url) => createOAuthClient({ host: "muse", serverURL: url, env, configRoot }),
|
|
121
|
+
} = {}) {
|
|
122
|
+
const resolvedServerURL = await resolveServerURL({ host: "muse", serverURL, env, configRoot });
|
|
123
|
+
const checks = {};
|
|
124
|
+
try { await preflight(resolvedServerURL); checks.server = "pass"; } catch (error) { checks.server = oauthReason(error); }
|
|
125
|
+
try { await commandRunner("muse", ["--version"]); checks.host = "pass"; } catch { checks.host = "muse_unavailable"; }
|
|
126
|
+
try {
|
|
127
|
+
const settings = JSON.parse(await readFile(join(museHome, "settings.json"), "utf8"));
|
|
128
|
+
const serialized = JSON.stringify(settings.hooks || {});
|
|
129
|
+
checks.installation = serialized.includes("mindbridge") && settings.mcpServers?.mindbridge?.url === `${resolvedServerURL}/mcp` ? "pass" : "stale";
|
|
130
|
+
} catch { checks.installation = "missing"; }
|
|
131
|
+
try { await oauthClientFactory(resolvedServerURL).check(); checks.lifecycleAuth = "pass"; } catch (error) { checks.lifecycleAuth = oauthReason(error); }
|
|
132
|
+
const outbox = await outboxStatus(agentHookStateRoot({ env }));
|
|
133
|
+
return {
|
|
134
|
+
ok: Object.values(checks).every((value) => value === "pass") && outbox.losses === 0,
|
|
135
|
+
serverURL: resolvedServerURL,
|
|
136
|
+
checks,
|
|
137
|
+
outbox,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function authLogout({ env = process.env, serverURL, configRoot, oauthClientFactory } = {}) {
|
|
142
|
+
const resolvedServerURL = await resolveServerURL({ host: "muse", serverURL, env, configRoot });
|
|
143
|
+
const client = (oauthClientFactory || ((url) => createOAuthClient({ host: "muse", serverURL: url, env, configRoot })))(resolvedServerURL);
|
|
144
|
+
return { serverURL: resolvedServerURL, loggedOut: await client.logout() };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseArgs(args) {
|
|
148
|
+
let serverURL;
|
|
149
|
+
const rest = [];
|
|
150
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
151
|
+
if (args[index] === "--server-url" && args[index + 1]) {
|
|
152
|
+
serverURL = args[index + 1];
|
|
153
|
+
index += 1;
|
|
154
|
+
} else rest.push(args[index]);
|
|
155
|
+
}
|
|
156
|
+
return { serverURL, rest };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function main() {
|
|
160
|
+
const { serverURL, rest } = parseArgs(process.argv.slice(2));
|
|
161
|
+
if (rest[0] === "setup" || rest[0] === "install") {
|
|
162
|
+
const result = await setup({ serverURL });
|
|
163
|
+
console.log(`MindBridge setup complete for Muse (${result.serverURL}).`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (rest[0] === "auth" && rest[1] === "status") {
|
|
167
|
+
const result = await authStatus({ serverURL });
|
|
168
|
+
const status = !result.authenticated
|
|
169
|
+
? "signed out"
|
|
170
|
+
: result.healthy
|
|
171
|
+
? "signed in (healthy)"
|
|
172
|
+
: `signed in but needs repair (${result.reason || "health_check_failed"})`;
|
|
173
|
+
console.log(`MindBridge lifecycle OAuth: ${status}`);
|
|
174
|
+
if (!result.authenticated || !result.healthy) process.exitCode = 1;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (rest[0] === "auth" && rest[1] === "logout") {
|
|
178
|
+
await authLogout({ serverURL });
|
|
179
|
+
console.log("MindBridge lifecycle OAuth: signed out");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (rest[0] === "doctor") {
|
|
183
|
+
const result = await doctor({ serverURL });
|
|
184
|
+
if (rest.includes("--json")) console.log(JSON.stringify(result));
|
|
185
|
+
else {
|
|
186
|
+
console.log(`MindBridge Muse doctor: ${result.ok ? "healthy" : "needs attention"}`);
|
|
187
|
+
for (const [name, status] of Object.entries(result.checks)) console.log(`${status === "pass" ? "PASS" : "FAIL"} ${name}: ${status}`);
|
|
188
|
+
console.log(`Outbox: ${result.outbox.pending} pending across ${result.outbox.sessions} sessions`);
|
|
189
|
+
}
|
|
190
|
+
if (!result.ok) process.exitCode = 1;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
console.error("Usage: mindbridge-muse setup [--server-url URL] | doctor [--json] | auth <status|logout>");
|
|
194
|
+
process.exitCode = 2;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isMainModule() {
|
|
198
|
+
if (!process.argv[1]) return false;
|
|
199
|
+
try {
|
|
200
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (isMainModule()) {
|
|
207
|
+
main().catch((error) => {
|
|
208
|
+
const detail = error?.phase || oauthReason(error);
|
|
209
|
+
console.error(`MindBridge Muse setup failed (${detail}). Run mindbridge-muse setup in an interactive desktop session.`);
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import {
|
|
2
|
+
agentHookStateRoot,
|
|
3
|
+
createOAuthClient,
|
|
4
|
+
createOAuthSessionClient,
|
|
5
|
+
oauthReason,
|
|
6
|
+
resolveServerURL,
|
|
7
|
+
} from "./agent-oauth.mjs";
|
|
8
|
+
import { resolveProjectMetadata } from "./project-metadata.mjs";
|
|
9
|
+
|
|
10
|
+
const HOOK_START_AUTH_TIMEOUT_MS = 25_000;
|
|
11
|
+
const HOOK_EVENT_AUTH_TIMEOUT_MS = 8_000;
|
|
12
|
+
|
|
13
|
+
function text(value) {
|
|
14
|
+
return typeof value === "string" ? value.trim() : "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SECRET_PATTERNS = [
|
|
18
|
+
/\b(?:api[\s_-]?key|access[\s_-]?token|auth[\s_-]?token|password|passwd|secret|token)\b\s*[:=]\s*\S{8,}/gi,
|
|
19
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
|
20
|
+
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
|
|
21
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
22
|
+
/-----BEGIN PRIVATE KEY-----/gi,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function redact(content) {
|
|
26
|
+
return SECRET_PATTERNS.reduce((value, pattern) => value.replace(pattern, "[REDACTED]"), content);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hookExternalRef(host, event) {
|
|
30
|
+
const sessionID = text(event.session_id) || text(event.sessionId);
|
|
31
|
+
return sessionID ? `${host}:session=${encodeURIComponent(sessionID)}` : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hookNotice(event, reason, reinjected) {
|
|
35
|
+
if (reinjected.has(text(event.source))) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
hookSpecificOutput: {
|
|
38
|
+
hookEventName: "SessionStart",
|
|
39
|
+
additionalContext: `MindBridge: this session is not being recorded (${reason}).`,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hookFailure(reason, executable) {
|
|
45
|
+
if ([
|
|
46
|
+
"authentication_required",
|
|
47
|
+
"authorization_cancelled",
|
|
48
|
+
"authorization_denied",
|
|
49
|
+
"authorization_timed_out",
|
|
50
|
+
"browser_unavailable",
|
|
51
|
+
"keychain_unavailable",
|
|
52
|
+
"refresh_rejected",
|
|
53
|
+
"unauthorized",
|
|
54
|
+
].includes(reason)) return `run ${executable} setup`;
|
|
55
|
+
return reason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function logProjectMetadataOmitted(options, host) {
|
|
59
|
+
try {
|
|
60
|
+
(options.logger || ((record) => process.stderr.write(`${JSON.stringify(record)}\n`)))({
|
|
61
|
+
component: "mindbridge-agent-hook",
|
|
62
|
+
event: "project_metadata_omitted",
|
|
63
|
+
host,
|
|
64
|
+
reason: "detection_failed",
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Diagnostics must not affect the host runtime.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createOAuthHookRuntime({ host, adapterName, endEvent, endReason, reinjected, executable, ...options }) {
|
|
72
|
+
let remote;
|
|
73
|
+
const stateRoot = agentHookStateRoot(options);
|
|
74
|
+
async function client(authorizationTimeoutMs) {
|
|
75
|
+
remote ??= (async () => {
|
|
76
|
+
const serverURL = await resolveServerURL({ host, env: options.env, configRoot: options.configRoot, home: options.home });
|
|
77
|
+
const oauth = options.oauth || createOAuthClient({
|
|
78
|
+
host,
|
|
79
|
+
serverURL,
|
|
80
|
+
env: options.env,
|
|
81
|
+
configRoot: options.configRoot,
|
|
82
|
+
stateRoot: options.stateRoot,
|
|
83
|
+
keyring: options.keyring,
|
|
84
|
+
keyringLoader: options.keyringLoader,
|
|
85
|
+
fetchImpl: options.fetchImpl,
|
|
86
|
+
browser: options.browser,
|
|
87
|
+
authorizationTimeoutMs,
|
|
88
|
+
requestTimeoutMs: Math.max(1_000, Math.min(6_000, Math.floor(authorizationTimeoutMs / 2) - 250)),
|
|
89
|
+
});
|
|
90
|
+
return createOAuthSessionClient({ serverURL, oauth, stateRoot });
|
|
91
|
+
})();
|
|
92
|
+
return remote;
|
|
93
|
+
}
|
|
94
|
+
async function startPayload(event, externalRef) {
|
|
95
|
+
const payload = {
|
|
96
|
+
externalRef,
|
|
97
|
+
host,
|
|
98
|
+
userName: "MindBridge User",
|
|
99
|
+
agentName: adapterName,
|
|
100
|
+
adapterName,
|
|
101
|
+
adapterRef: `${host}:hook`,
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
const metadata = await (options.projectMetadataResolver || resolveProjectMetadata)({
|
|
105
|
+
cwd: text(event.cwd),
|
|
106
|
+
projectRoot: host === "claude" ? text((options.env || process.env).CLAUDE_PROJECT_DIR) : "",
|
|
107
|
+
stateRoot,
|
|
108
|
+
runGit: options.runGit,
|
|
109
|
+
});
|
|
110
|
+
if (metadata) payload.metadata = metadata;
|
|
111
|
+
else logProjectMetadataOmitted(options, host);
|
|
112
|
+
} catch {
|
|
113
|
+
logProjectMetadataOmitted(options, host);
|
|
114
|
+
}
|
|
115
|
+
return payload;
|
|
116
|
+
}
|
|
117
|
+
async function invoke(operation, payload, authorizationTimeoutMs) {
|
|
118
|
+
try {
|
|
119
|
+
return await (await client(authorizationTimeoutMs))[operation](payload);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { error: oauthReason(error), status: error?.status };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function appendMessage(event, externalRef, role, content, prefix, authorizationTimeoutMs) {
|
|
125
|
+
const turn = text(event.turn_id) || text(event.turnId);
|
|
126
|
+
const append = () => invoke("append", {
|
|
127
|
+
externalRef,
|
|
128
|
+
role,
|
|
129
|
+
eventType: "message",
|
|
130
|
+
content: redact(content),
|
|
131
|
+
messageRef: turn ? `${prefix}:${turn}` : "",
|
|
132
|
+
}, authorizationTimeoutMs);
|
|
133
|
+
let result = await append();
|
|
134
|
+
if (result?.error === "missing_session_state") {
|
|
135
|
+
const started = await invoke("start", await startPayload(event, externalRef), authorizationTimeoutMs);
|
|
136
|
+
if (!started?.error) result = await append();
|
|
137
|
+
} else if (result?.error === "session_ended" || result?.status === 409) {
|
|
138
|
+
const appendWasQueued = result.status === 409;
|
|
139
|
+
result = await invoke("reopen", { externalRef }, authorizationTimeoutMs);
|
|
140
|
+
if (!appendWasQueued && !result?.error) result = await append();
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
async handle(event = {}) {
|
|
146
|
+
const externalRef = hookExternalRef(host, event);
|
|
147
|
+
const name = text(event.hook_event_name) || text(event.hookEventName);
|
|
148
|
+
if (!externalRef) return undefined;
|
|
149
|
+
const authorizationTimeoutMs = options.authorizationTimeoutMs ?? (name === "SessionStart" ? HOOK_START_AUTH_TIMEOUT_MS : HOOK_EVENT_AUTH_TIMEOUT_MS);
|
|
150
|
+
if (name === "SessionStart") {
|
|
151
|
+
await (await client(authorizationTimeoutMs)).flushAll({ excludeExternalRef: externalRef, limit: 1 });
|
|
152
|
+
const result = await invoke("start", await startPayload(event, externalRef), authorizationTimeoutMs);
|
|
153
|
+
if (!result || result.error) return hookNotice(event, hookFailure(result?.error, executable), reinjected);
|
|
154
|
+
if (!result.prompt_block || reinjected.has(text(event.source))) return undefined;
|
|
155
|
+
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.prompt_block } };
|
|
156
|
+
}
|
|
157
|
+
if (name === "UserPromptSubmit") {
|
|
158
|
+
const content = text(event.prompt);
|
|
159
|
+
if (!content) return undefined;
|
|
160
|
+
await appendMessage(event, externalRef, "user", content, "in", authorizationTimeoutMs);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
let appendSucceeded = true;
|
|
164
|
+
if (name === "Stop") {
|
|
165
|
+
const content = text(event.last_assistant_message) || text(event.lastAssistantMessage);
|
|
166
|
+
if (content) {
|
|
167
|
+
const result = await appendMessage(event, externalRef, "assistant", content, "out", authorizationTimeoutMs);
|
|
168
|
+
appendSucceeded = Boolean(result && !result.error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (name === endEvent && appendSucceeded) await invoke("end", { externalRef, reason: endReason(event) }, authorizationTimeoutMs);
|
|
172
|
+
return undefined;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createCodexOAuthRuntime(options = {}) {
|
|
178
|
+
return createOAuthHookRuntime({
|
|
179
|
+
...options,
|
|
180
|
+
host: "codex",
|
|
181
|
+
adapterName: "Codex",
|
|
182
|
+
executable: "npx -y @mindbridgeio/codex@latest",
|
|
183
|
+
endEvent: "Stop",
|
|
184
|
+
endReason: () => "stop",
|
|
185
|
+
reinjected: new Set(["compact"]),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createClaudeOAuthRuntime(options = {}) {
|
|
190
|
+
return createOAuthHookRuntime({
|
|
191
|
+
...options,
|
|
192
|
+
host: "claude",
|
|
193
|
+
adapterName: "Claude",
|
|
194
|
+
executable: "npx -y @mindbridgeio/claude@latest",
|
|
195
|
+
endEvent: "SessionEnd",
|
|
196
|
+
endReason: (event) => text(event.reason) || "session_end",
|
|
197
|
+
reinjected: new Set(["compact", "resume"]),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createMuseOAuthRuntime(options = {}) {
|
|
202
|
+
return createOAuthHookRuntime({
|
|
203
|
+
...options,
|
|
204
|
+
host: "muse",
|
|
205
|
+
adapterName: "Muse",
|
|
206
|
+
executable: "npx -y @mindbridgeio/muse@latest",
|
|
207
|
+
endEvent: "SessionEnd",
|
|
208
|
+
endReason: (event) => text(event.reason) || "session_end",
|
|
209
|
+
reinjected: new Set(["compact", "resume"]),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Shared stdin/stdout runner. Hooks always fail safely and never block a host. */
|
|
214
|
+
export async function runOAuthHook(createRuntime) {
|
|
215
|
+
let event = {};
|
|
216
|
+
try {
|
|
217
|
+
let raw = "";
|
|
218
|
+
process.stdin.setEncoding("utf8");
|
|
219
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
220
|
+
if (raw.trim()) event = JSON.parse(raw);
|
|
221
|
+
} catch {
|
|
222
|
+
event = {};
|
|
223
|
+
}
|
|
224
|
+
let output;
|
|
225
|
+
try {
|
|
226
|
+
output = await createRuntime().handle(event);
|
|
227
|
+
} catch {
|
|
228
|
+
output = undefined;
|
|
229
|
+
}
|
|
230
|
+
process.stdout.on("error", () => process.exit(0));
|
|
231
|
+
if (output) process.stdout.write(JSON.stringify(output), () => process.exit(0));
|
|
232
|
+
else process.exit(0);
|
|
233
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function readJSON(path, fallback) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if (error?.code === "ENOENT") return fallback;
|
|
10
|
+
throw new Error(`cannot read ${path}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function atomicWrite(path, content) {
|
|
15
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
16
|
+
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
|
|
17
|
+
try {
|
|
18
|
+
await writeFile(temp, content, { mode: 0o600 });
|
|
19
|
+
await rename(temp, path);
|
|
20
|
+
} finally {
|
|
21
|
+
await unlink(temp).catch(() => {});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function managedHook(hook, statuses) {
|
|
26
|
+
const command = typeof hook?.command === "string" ? hook.command.toLowerCase() : "";
|
|
27
|
+
const status = typeof hook?.statusMessage === "string" ? hook.statusMessage.toLowerCase() : "";
|
|
28
|
+
return (command.includes("mindbridge") && command.includes("hook.mjs")) || statuses.includes(status);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withoutManagedHooks(groups, statuses) {
|
|
32
|
+
if (!Array.isArray(groups)) return [];
|
|
33
|
+
return groups.flatMap((group) => {
|
|
34
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
35
|
+
const hooks = group.hooks.filter((hook) => !managedHook(hook, statuses));
|
|
36
|
+
return hooks.length ? [{ ...group, hooks }] : [];
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function runCommand(command, args) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const child = spawn(command, args, { stdio: "inherit", shell: false, windowsHide: true });
|
|
43
|
+
child.once("error", reject);
|
|
44
|
+
child.once("close", (code) => {
|
|
45
|
+
if (code === 0) resolve();
|
|
46
|
+
else {
|
|
47
|
+
const error = new Error("host command failed");
|
|
48
|
+
error.exitCode = code;
|
|
49
|
+
reject(error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function phase(name, operation) {
|
|
56
|
+
try {
|
|
57
|
+
return await operation();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
error.phase = name;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|