@kontextmind/kxm 0.7.17 → 0.7.19
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/.claude-plugin/marketplace.json +1 -1
- package/docs/configuration.md +17 -1
- package/docs/getting-started.md +4 -1
- package/docs/kxm-handbook.md +6 -2
- package/package.json +8 -6
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +484 -458
- package/plugins/kxm/dist/core.js +3 -2
- package/plugins/kxm/dist/extension.js +28841 -5273
- package/plugins/kxm/dist/mcp-server.js +21 -10
- package/plugins/kxm/dist/runtime.js +43 -39
- package/plugins/kxm/dist/server.js +43 -39
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +40 -37
- package/plugins/kxm/package.json +4 -1
- package/plugins/kxm/src/cli/hub.ts +3 -2
- package/plugins/kxm/src/cli.ts +3 -2
- package/plugins/kxm/src/config.ts +20 -0
- package/plugins/kxm/src/extension.ts +46 -3
- package/plugins/kxm/src/hub-autostart.ts +214 -0
- package/plugins/kxm/src/mcp-server.ts +3 -3
- package/plugins/kxm/src/project-name.ts +20 -0
- package/plugins/kxm/src/providers/claude-bridge/LICENSE +21 -0
- package/plugins/kxm/src/providers/claude-bridge/README.md +18 -0
- package/plugins/kxm/src/providers/claude-bridge/config.ts +53 -0
- package/plugins/kxm/src/providers/claude-bridge/convert.ts +180 -0
- package/plugins/kxm/src/providers/claude-bridge/index.ts +28 -0
- package/plugins/kxm/src/providers/claude-bridge/mcp-server.ts +59 -0
- package/plugins/kxm/src/providers/claude-bridge/models.ts +166 -0
- package/plugins/kxm/src/providers/claude-bridge/pi-compat.ts +215 -0
- package/plugins/kxm/src/providers/claude-bridge/prompt-stream.ts +89 -0
- package/plugins/kxm/src/providers/claude-bridge/provider.ts +286 -0
- package/plugins/kxm/src/providers/claude-bridge/query-state.ts +72 -0
- package/plugins/kxm/src/providers/claude-bridge/redact.ts +26 -0
- package/plugins/kxm/src/providers/claude-bridge/register.ts +124 -0
- package/plugins/kxm/src/providers/claude-bridge/stream.ts +390 -0
- package/plugins/kxm/src/providers/claude-bridge/tools.ts +140 -0
- package/plugins/kxm/src/redact.ts +3 -2
- package/scripts/build-runtime.mjs +5 -1
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { AGENT_COMMANDS, enforceToolPolicy } from "./commands.ts";
|
|
6
6
|
import { HubClient, HubHttpError } from "./client.ts";
|
|
7
|
+
import { loadKxmConfig } from "./config.ts";
|
|
8
|
+
import { ensureHubRunning, hubAutoStartMode } from "./hub-autostart.ts";
|
|
9
|
+
import { readHubEnvRecord } from "./hub-env.ts";
|
|
10
|
+
import { defaultProjectName } from "./project-name.ts";
|
|
7
11
|
import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
|
|
8
12
|
import {
|
|
9
13
|
antigravityRegistrationNotice,
|
|
10
14
|
registerAntigravityProvider,
|
|
11
15
|
type AntigravityRegistration,
|
|
12
16
|
} from "./providers/antigravity/register.ts";
|
|
17
|
+
import {
|
|
18
|
+
claudeBridgeRegistrationNotice,
|
|
19
|
+
registerClaudeBridgeProvider,
|
|
20
|
+
type ClaudeBridgeRegistration,
|
|
21
|
+
} from "./providers/claude-bridge/register.ts";
|
|
13
22
|
import { areaForTool, classifyFailure, diagnosticEvidence, diagnosticSummary, type Diagnostic } from "./diagnostics.ts";
|
|
14
23
|
import {
|
|
15
24
|
MAX_CONTENT_CHARS,
|
|
@@ -166,6 +175,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
166
175
|
let client: HubClient | undefined;
|
|
167
176
|
let nousReport: NousRegistrationReport | undefined;
|
|
168
177
|
let antigravityReport: AntigravityRegistration | undefined;
|
|
178
|
+
let claudeBridgeReport: ClaudeBridgeRegistration | undefined;
|
|
169
179
|
let pending: MessageRecord[] = [];
|
|
170
180
|
let activatingInbound: MessageRecord | undefined;
|
|
171
181
|
let awaitingActivation: MessageRecord | undefined;
|
|
@@ -648,6 +658,8 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
648
658
|
shuttingDown = false;
|
|
649
659
|
const antigravityNotice = antigravityRegistrationNotice(antigravityReport, pi);
|
|
650
660
|
if (antigravityNotice) ctx.ui.notify(antigravityNotice.message, antigravityNotice.type);
|
|
661
|
+
const claudeBridgeNotice = claudeBridgeRegistrationNotice(claudeBridgeReport, pi);
|
|
662
|
+
if (claudeBridgeNotice) ctx.ui.notify(claudeBridgeNotice.message, claudeBridgeNotice.type);
|
|
651
663
|
if (nousReport?.guidance.length) {
|
|
652
664
|
for (const item of nousReport.guidance) {
|
|
653
665
|
ctx.ui.notify(item.message, item.level);
|
|
@@ -665,7 +677,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
665
677
|
}
|
|
666
678
|
currentPiSessionId = ctx.sessionManager?.getSessionId();
|
|
667
679
|
const serverUrl = process.env.KXM_SERVER_URL ?? "http://127.0.0.1:7331";
|
|
668
|
-
const project =
|
|
680
|
+
const project = defaultProjectName(ctx.cwd, process.env);
|
|
669
681
|
const name = process.env.KXM_AGENT_NAME ?? pi.getSessionName() ?? `pi-${process.pid}`;
|
|
670
682
|
agentName = name;
|
|
671
683
|
projectName = project;
|
|
@@ -673,12 +685,42 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
673
685
|
removeMatchingLegacyRecoveryContext();
|
|
674
686
|
const purpose = process.env.KXM_AGENT_PURPOSE ?? "General-purpose coding agent";
|
|
675
687
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
688
|
+
let autoStartToken: string | undefined;
|
|
689
|
+
try {
|
|
690
|
+
const config = loadKxmConfig(ctx.cwd);
|
|
691
|
+
if (hubAutoStartMode(config) === "background") {
|
|
692
|
+
const ensured = await ensureHubRunning({ config, cwd: ctx.cwd });
|
|
693
|
+
if (ensured.status === "started") {
|
|
694
|
+
autoStartToken = ensured.authToken;
|
|
695
|
+
ctx.ui.notify(
|
|
696
|
+
`kxm hub started in the background (pid ${ensured.pid}); logs: ${ensured.logPath}`
|
|
697
|
+
+ (ensured.authTokenSource === "generated" ? "; new admin token generated and persisted to user state" : ""),
|
|
698
|
+
"info",
|
|
699
|
+
);
|
|
700
|
+
} else if (ensured.status === "failed") {
|
|
701
|
+
ctx.ui.notify(`kxm hub auto-start failed: ${ensured.reason} (log: ${ensured.logPath})`, "warning");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} catch (error) {
|
|
705
|
+
ctx.ui.notify(`kxm hub auto-start failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
706
|
+
}
|
|
707
|
+
// Authenticate with the explicit env token first, then the token resolved
|
|
708
|
+
// by auto-start, then the credential persisted for this machine's hubs.
|
|
709
|
+
const envAuthToken = process.env.KXM_AUTH_TOKEN?.trim();
|
|
710
|
+
let hubAuthToken = envAuthToken || autoStartToken;
|
|
711
|
+
if (!hubAuthToken) {
|
|
712
|
+
try {
|
|
713
|
+
hubAuthToken = readHubEnvRecord()?.authToken?.trim() || undefined;
|
|
714
|
+
} catch (error) {
|
|
715
|
+
ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
716
|
+
}
|
|
717
|
+
}
|
|
676
718
|
client = new HubClient({
|
|
677
719
|
serverUrl,
|
|
678
720
|
name,
|
|
679
721
|
purpose,
|
|
680
722
|
project,
|
|
681
|
-
...(
|
|
723
|
+
...(hubAuthToken ? { authToken: hubAuthToken } : {}),
|
|
682
724
|
...(model ? { model } : {}),
|
|
683
725
|
});
|
|
684
726
|
notify = (message, type) => ctx.ui.notify(message, type);
|
|
@@ -933,6 +975,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
933
975
|
});
|
|
934
976
|
|
|
935
977
|
antigravityReport = registerAntigravityProvider(pi);
|
|
978
|
+
claudeBridgeReport = registerClaudeBridgeProvider(pi);
|
|
936
979
|
return nousFactoryWork(pi, (report) => {
|
|
937
980
|
nousReport = report;
|
|
938
981
|
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { probeHubHealth, readHubBinding } from "./hub-binding.ts";
|
|
6
|
+
import { resolveHubCredentials } from "./hub-env.ts";
|
|
7
|
+
import { redactSecrets } from "./redact.ts";
|
|
8
|
+
|
|
9
|
+
/** Background hub auto-start for harness extensions (Pi TUI, one-shot CLIs).
|
|
10
|
+
*
|
|
11
|
+
* On extension load: reuse a healthy bound hub, then a live local `hub.pid`
|
|
12
|
+
* claim, and only then spawn the detached `kxm-hub.mjs` wrapper. Credentials
|
|
13
|
+
* are resolved before launch, so a first run generates the admin token and
|
|
14
|
+
* persists it (`0600`) under the user state root, exactly as
|
|
15
|
+
* `kxm hub start` does. The single-hub-per-user invariants (exclusive PID
|
|
16
|
+
* claim, reclaimed dead wrappers) come from the wrapper itself. */
|
|
17
|
+
|
|
18
|
+
export const HUB_AUTOSTART_LOG_NAME = "hub-autostart.log";
|
|
19
|
+
const CLAIM_POLL_MS = 50;
|
|
20
|
+
const DEFAULT_CLAIM_TIMEOUT_MS = 3000;
|
|
21
|
+
|
|
22
|
+
export type HubAutoStartMode = "off" | "background";
|
|
23
|
+
|
|
24
|
+
export type EnsureHubResult =
|
|
25
|
+
| { status: "disabled" }
|
|
26
|
+
| { status: "bound-healthy"; url: string }
|
|
27
|
+
| { status: "url-healthy"; url: string }
|
|
28
|
+
| { status: "claim-alive"; pid: number }
|
|
29
|
+
| { status: "started"; pid: number; logPath: string; authToken: string | undefined; authTokenSource: string }
|
|
30
|
+
| { status: "failed"; reason: string; logPath: string };
|
|
31
|
+
|
|
32
|
+
export interface HubSpawnOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
env: NodeJS.ProcessEnv;
|
|
35
|
+
stdio: ["ignore", number, number];
|
|
36
|
+
detached: boolean;
|
|
37
|
+
windowsHide: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HubSpawned {
|
|
41
|
+
pid?: number | undefined;
|
|
42
|
+
once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type HubSpawner = (command: string, args: readonly string[], options: HubSpawnOptions) => HubSpawned;
|
|
46
|
+
|
|
47
|
+
export interface EnsureHubOptions {
|
|
48
|
+
config: { hub?: { autoStart?: unknown } } | undefined;
|
|
49
|
+
cwd: string;
|
|
50
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
51
|
+
fetchImpl?: typeof fetch | undefined;
|
|
52
|
+
spawner?: HubSpawner | undefined;
|
|
53
|
+
processExists?: ((pid: number) => boolean) | undefined;
|
|
54
|
+
scriptPath?: string | undefined;
|
|
55
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
56
|
+
now?: (() => number) | undefined;
|
|
57
|
+
claimTimeoutMs?: number | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve the configured auto-start mode; unknown values fail closed to the
|
|
61
|
+
* default so a typo never silently disables (or re-enables) hub startup. */
|
|
62
|
+
export function hubAutoStartMode(config: { hub?: { autoStart?: unknown } } | undefined): HubAutoStartMode {
|
|
63
|
+
const raw = config?.hub?.autoStart;
|
|
64
|
+
return raw === "off" || raw === "background" ? raw : "background";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultProcessExists(pid: number): boolean {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HubClaimSnapshot {
|
|
77
|
+
pid: number;
|
|
78
|
+
serverPid: number | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read the workspace hub PID claim. A missing or malformed claim is not
|
|
82
|
+
* live; the wrapper fails closed on malformed claims when it starts, so the
|
|
83
|
+
* extension must not try to clean claims up itself. */
|
|
84
|
+
export function readLiveHubClaim(stateDir: string, processExists: (pid: number) => boolean = defaultProcessExists): HubClaimSnapshot | undefined {
|
|
85
|
+
let parsed: unknown;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(readFileSync(join(stateDir, "hub.pid"), "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
92
|
+
const record = parsed as { version?: unknown; role?: unknown; pid?: unknown; serverPid?: unknown };
|
|
93
|
+
if (record.version !== 1 || record.role !== "hub" || !Number.isInteger(record.pid)) return undefined;
|
|
94
|
+
const pid = record.pid as number;
|
|
95
|
+
if (pid <= 0 || !processExists(pid)) return undefined;
|
|
96
|
+
return {
|
|
97
|
+
pid,
|
|
98
|
+
...(Number.isInteger(record.serverPid) && (record.serverPid as number) > 0 ? { serverPid: record.serverPid as number } : { serverPid: undefined }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The hub wrapper script next to the checked-out KXM repo. The extension and
|
|
103
|
+
* the CLI both resolve the same path so auto-start launches the identical
|
|
104
|
+
* supervision wrapper as `kxm hub start`. */
|
|
105
|
+
export function defaultHubWrapperScriptPath(moduleUrl: string = import.meta.url): string {
|
|
106
|
+
return resolve(dirname(fileURLToPath(moduleUrl)), "../../../scripts/kxm-hub.mjs");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultHubSpawner(command: string, args: readonly string[], options: HubSpawnOptions): HubSpawned {
|
|
110
|
+
const child = spawn(command, [...args], options);
|
|
111
|
+
child.unref();
|
|
112
|
+
return child;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function logTail(logPath: string, maxChars = 2000): string {
|
|
116
|
+
try {
|
|
117
|
+
return redactSecrets(readFileSync(logPath, "utf8").trim().slice(-maxChars));
|
|
118
|
+
} catch {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Ensure a hub is available, starting a detached one only when no healthy
|
|
124
|
+
* bound hub and no live local claim exists. Never throws for ordinary
|
|
125
|
+
* failures; structured results let callers notify without blocking load. */
|
|
126
|
+
export async function ensureHubRunning(options: EnsureHubOptions): Promise<EnsureHubResult> {
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
if (hubAutoStartMode(options.config) !== "background") return { status: "disabled" };
|
|
129
|
+
|
|
130
|
+
// 1. A bound hub that answers health is authoritative — reuse it.
|
|
131
|
+
const binding = readHubBinding(env);
|
|
132
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
133
|
+
if (binding) {
|
|
134
|
+
const { health } = await probeHubHealth(binding.url, fetchImpl);
|
|
135
|
+
if (health === "on") return { status: "bound-healthy", url: binding.url };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. A live local claim means a hub (started any way) already owns this
|
|
139
|
+
// workspace state — do not start a second one.
|
|
140
|
+
const workdir = resolve(env.KXM_WORKDIR?.trim() || options.cwd);
|
|
141
|
+
const workspaceDir = resolve(workdir, env.KXM_WORKSPACE_DIR?.trim() || ".kxm");
|
|
142
|
+
const stateDir = resolve(workdir, env.KXM_STATE_DIR?.trim() || join(workspaceDir, "state"));
|
|
143
|
+
const logsDir = resolve(workdir, env.KXM_LOGS_DIR?.trim() || join(workspaceDir, "logs"));
|
|
144
|
+
const processExists = options.processExists ?? defaultProcessExists;
|
|
145
|
+
const live = readLiveHubClaim(stateDir, processExists);
|
|
146
|
+
if (live) return { status: "claim-alive", pid: live.pid };
|
|
147
|
+
|
|
148
|
+
// 3. A hub already serving the configured URL (for example one launched
|
|
149
|
+
// directly by a harness, without a claim or binding) is reused too: the
|
|
150
|
+
// client will connect there regardless, so spawning a duplicate would
|
|
151
|
+
// only fail to bind the same port.
|
|
152
|
+
const serverUrl = (env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331").replace(/\/+$/, "");
|
|
153
|
+
if (serverUrl !== binding?.url) {
|
|
154
|
+
const { health } = await probeHubHealth(serverUrl, fetchImpl);
|
|
155
|
+
if (health === "on") return { status: "url-healthy", url: serverUrl };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. Resolve credentials before launch so the key exists by the time the
|
|
159
|
+
// wrapper starts: env wins, then the persisted user-state file, then a
|
|
160
|
+
// generated token persisted with 0600 for later restarts and workers.
|
|
161
|
+
const credentials = resolveHubCredentials({ env });
|
|
162
|
+
const logPath = join(logsDir, HUB_AUTOSTART_LOG_NAME);
|
|
163
|
+
mkdirSync(logsDir, { recursive: true });
|
|
164
|
+
const logFd = openSync(logPath, "a");
|
|
165
|
+
let child: HubSpawned;
|
|
166
|
+
try {
|
|
167
|
+
child = (options.spawner ?? defaultHubSpawner)(process.execPath, [options.scriptPath ?? defaultHubWrapperScriptPath()], {
|
|
168
|
+
cwd: workdir,
|
|
169
|
+
env: {
|
|
170
|
+
...env,
|
|
171
|
+
KXM_WORKDIR: workdir,
|
|
172
|
+
KXM_WORKSPACE_DIR: workspaceDir,
|
|
173
|
+
KXM_STATE_DIR: stateDir,
|
|
174
|
+
KXM_LOGS_DIR: logsDir,
|
|
175
|
+
...(credentials.authToken ? { KXM_AUTH_TOKEN: credentials.authToken } : {}),
|
|
176
|
+
},
|
|
177
|
+
stdio: ["ignore", logFd, logFd],
|
|
178
|
+
detached: process.platform !== "win32",
|
|
179
|
+
windowsHide: true,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(logFd);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 4. Verify the wrapper claimed the hub. A simultaneous loader may have won
|
|
186
|
+
// the exclusive claim first; that is a live hub, not a failure.
|
|
187
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolveSleep) => setTimeout(resolveSleep, ms)));
|
|
188
|
+
const now = options.now ?? Date.now;
|
|
189
|
+
const timeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
|
|
190
|
+
let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
|
191
|
+
child.once("exit", (code, signal) => {
|
|
192
|
+
earlyExit = { code, signal };
|
|
193
|
+
});
|
|
194
|
+
const deadline = now() + timeoutMs;
|
|
195
|
+
for (;;) {
|
|
196
|
+
if (earlyExit) {
|
|
197
|
+
// A simultaneous loader may have won the exclusive claim first; that
|
|
198
|
+
// is a live hub, not a failure.
|
|
199
|
+
const raced = readLiveHubClaim(stateDir, processExists);
|
|
200
|
+
if (raced) return { status: "claim-alive", pid: raced.pid };
|
|
201
|
+
const detail = logTail(logPath);
|
|
202
|
+
const reason = `hub wrapper exited before claiming the hub (code ${earlyExit.code ?? "null"}${earlyExit.signal ? `, signal ${earlyExit.signal}` : ""})${detail ? `: ${detail}` : ""}`;
|
|
203
|
+
return { status: "failed", reason, logPath };
|
|
204
|
+
}
|
|
205
|
+
const claimed = readLiveHubClaim(stateDir, processExists);
|
|
206
|
+
if (claimed) return { status: "started", pid: claimed.pid, logPath, authToken: credentials.authToken, authTokenSource: credentials.authTokenSource };
|
|
207
|
+
if (now() >= deadline) {
|
|
208
|
+
return child.pid
|
|
209
|
+
? { status: "started", pid: child.pid, logPath, authToken: credentials.authToken, authTokenSource: credentials.authTokenSource }
|
|
210
|
+
: { status: "failed", reason: `hub wrapper did not claim the hub within ${timeoutMs} ms`, logPath };
|
|
211
|
+
}
|
|
212
|
+
await sleep(CLAIM_POLL_MS);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
4
|
import { HubClient } from "./client.ts";
|
|
5
|
+
import { defaultProjectName } from "./project-name.ts";
|
|
6
6
|
import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
|
|
7
7
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
8
8
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
9
9
|
|
|
10
|
-
const VERSION = "0.7.
|
|
10
|
+
const VERSION = "0.7.19";
|
|
11
11
|
const inbox = new Map<string, MessageRecord>();
|
|
12
12
|
const notifiedInbox = new Set<string>();
|
|
13
13
|
let meshClient: HubClient | undefined;
|
|
@@ -88,7 +88,7 @@ async function ensureClient(): Promise<HubClient> {
|
|
|
88
88
|
serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
|
|
89
89
|
name: process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`,
|
|
90
90
|
purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
|
|
91
|
-
project: process.env
|
|
91
|
+
project: defaultProjectName(projectDir, process.env),
|
|
92
92
|
model: "claude-code",
|
|
93
93
|
...(authToken ? { authToken } : {}),
|
|
94
94
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Default project identity for a workspace.
|
|
5
|
+
*
|
|
6
|
+
* Precedence: `KXM_PROJECT` environment, then the workspace `package.json`
|
|
7
|
+
* `name`, then the workspace directory name. The package.json read is
|
|
8
|
+
* best-effort and never walks upward: a missing, malformed, or name-less
|
|
9
|
+
* file falls through to the directory name. */
|
|
10
|
+
export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
11
|
+
const fromEnv = env.KXM_PROJECT?.trim();
|
|
12
|
+
if (fromEnv) return fromEnv;
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
|
|
15
|
+
if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
|
|
16
|
+
} catch {
|
|
17
|
+
// No readable package.json: fall through to the directory name.
|
|
18
|
+
}
|
|
19
|
+
return basename(cwd);
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eli Dickinson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Claude-bridge provider (vendored)
|
|
2
|
+
|
|
3
|
+
Provider-only subset of `pi-claude-bridge` 0.7.0, built into the kxm plugin.
|
|
4
|
+
|
|
5
|
+
- Upstream: [elidickinson/pi-claude-bridge](https://github.com/elidickinson/pi-claude-bridge)
|
|
6
|
+
- npm: `pi-claude-bridge@0.7.0`
|
|
7
|
+
- License: MIT, Copyright (c) 2026 Eli Dickinson — see `LICENSE`
|
|
8
|
+
- Author: Eli Dickinson
|
|
9
|
+
|
|
10
|
+
The Pi provider id remains `claude-bridge` so existing `claude login` /
|
|
11
|
+
subscription credentials carry over. This slice vendors model registration
|
|
12
|
+
(Opus/Sonnet/Haiku/Fable ids the plugin actually registers), Anthropic Agent
|
|
13
|
+
SDK streaming, thinking/effort mapping, and the in-process MCP bridge that
|
|
14
|
+
exposes Pi tools to Claude Code.
|
|
15
|
+
|
|
16
|
+
Not vendored here: the AskClaude tool, user-facing MCP/UI panels, TUI extras,
|
|
17
|
+
session JSONL rebuild (`cc-session-io`), and skills/prompt-capture forwarding.
|
|
18
|
+
No `@earendil-works/pi-ai` import.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { LongContextSettings } from "./models.ts";
|
|
5
|
+
|
|
6
|
+
export interface ClaudeBridgeConfig {
|
|
7
|
+
provider?: {
|
|
8
|
+
strictMcpConfig?: boolean;
|
|
9
|
+
autoMemoryEnabled?: boolean;
|
|
10
|
+
pathToClaudeCodeExecutable?: string;
|
|
11
|
+
plan?: "pro" | "max";
|
|
12
|
+
longContextExtraUsage?: boolean;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function tryParseJson(path: string): Partial<ClaudeBridgeConfig> {
|
|
17
|
+
if (!existsSync(path)) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(path, "utf-8")) as Partial<ClaudeBridgeConfig>;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error(`claude-bridge: failed to parse ${path}: ${error}`);
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function globalConfigPath(home = homedir()): string {
|
|
27
|
+
return join(home, ".pi", "agent", "claude-bridge.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function projectConfigPath(cwd: string): string {
|
|
31
|
+
return join(cwd, ".pi", "claude-bridge.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadConfig(cwd: string, home = homedir()): ClaudeBridgeConfig {
|
|
35
|
+
const global = tryParseJson(globalConfigPath(home));
|
|
36
|
+
const project = tryParseJson(projectConfigPath(cwd));
|
|
37
|
+
return {
|
|
38
|
+
provider: { ...global.provider, ...project.provider },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function longContextFromConfig(config: ClaudeBridgeConfig): LongContextSettings {
|
|
43
|
+
return {
|
|
44
|
+
plan: config.provider?.plan === "max" ? "max" : "pro",
|
|
45
|
+
longContextExtraUsage: Boolean(config.provider?.longContextExtraUsage),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function claudeCodeSettings(provider: ClaudeBridgeConfig["provider"] = {}): {
|
|
50
|
+
autoMemoryEnabled: boolean;
|
|
51
|
+
} {
|
|
52
|
+
return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
|
|
53
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @ts-nocheck — vendored snapshot; host tsconfig is stricter than upstream.
|
|
2
|
+
import type { ImageContent, Message, TextContent, UserMessage } from "./pi-compat.ts";
|
|
3
|
+
import { PROVIDER_ID } from "./models.ts";
|
|
4
|
+
import { MCP_TOOL_PREFIX, mapPiToolNameToSdk, sanitizeToolId } from "./tools.ts";
|
|
5
|
+
|
|
6
|
+
export { PROVIDER_ID };
|
|
7
|
+
|
|
8
|
+
export function messageContentToText(
|
|
9
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
10
|
+
): string {
|
|
11
|
+
if (typeof content === "string") return content;
|
|
12
|
+
if (!Array.isArray(content)) return "";
|
|
13
|
+
const parts = [];
|
|
14
|
+
let hasText = false;
|
|
15
|
+
for (const block of content) {
|
|
16
|
+
if (block.type === "text" && block.text) {
|
|
17
|
+
parts.push(block.text);
|
|
18
|
+
hasText = true;
|
|
19
|
+
} else if (block.type !== "text" && block.type !== "image") {
|
|
20
|
+
parts.push(`[${block.type}]`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return hasText ? parts.join("\n") : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolResultContent(
|
|
27
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
28
|
+
): string | Array<Record<string, unknown>> {
|
|
29
|
+
if (typeof content === "string" || !Array.isArray(content)) return messageContentToText(content) || "";
|
|
30
|
+
const images = content.filter((b) => b.type === "image" && b.data && b.mimeType);
|
|
31
|
+
if (!images.length) return messageContentToText(content) || "";
|
|
32
|
+
const blocks: Array<Record<string, unknown>> = [];
|
|
33
|
+
for (const block of content) {
|
|
34
|
+
if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
|
|
35
|
+
else if (block.type === "image" && block.data && block.mimeType) {
|
|
36
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
|
|
37
|
+
} else if (block.type !== "text" && block.type !== "image") {
|
|
38
|
+
blocks.push({ type: "text", text: `[${block.type}]` });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return blocks;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DroppedContent = {
|
|
45
|
+
thinking: number;
|
|
46
|
+
abortedTurns: number;
|
|
47
|
+
providers: Set<string>;
|
|
48
|
+
other: Map<string, number>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type AnthropicMessage = { role: string; content: unknown };
|
|
52
|
+
|
|
53
|
+
export function convertPiMessages(
|
|
54
|
+
messages: Message[],
|
|
55
|
+
customToolNameToSdk?: Map<string, string>,
|
|
56
|
+
): { anthropicMessages: AnthropicMessage[]; sanitizedIds: Map<string, string>; dropped: DroppedContent } {
|
|
57
|
+
const anthropicMessages: AnthropicMessage[] = [];
|
|
58
|
+
const sanitizedIds = new Map<string, string>();
|
|
59
|
+
const dropped: DroppedContent = { thinking: 0, abortedTurns: 0, providers: new Set(), other: new Map() };
|
|
60
|
+
let turnResults: { role: "user"; content: Array<Record<string, unknown>> } | null = null;
|
|
61
|
+
let turnAssistantIdx: number | null = null;
|
|
62
|
+
|
|
63
|
+
for (const msg of messages) {
|
|
64
|
+
if (msg.role === "user") {
|
|
65
|
+
if (typeof msg.content === "string") {
|
|
66
|
+
anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
|
|
67
|
+
} else if (Array.isArray(msg.content)) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (const block of msg.content) {
|
|
70
|
+
if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
|
|
71
|
+
else if (block.type === "image" && block.data && block.mimeType) {
|
|
72
|
+
parts.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
|
|
76
|
+
} else {
|
|
77
|
+
anthropicMessages.push({ role: "user", content: "[empty]" });
|
|
78
|
+
}
|
|
79
|
+
} else if (msg.role === "assistant") {
|
|
80
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
81
|
+
const blocks = [];
|
|
82
|
+
for (const block of content) {
|
|
83
|
+
if (block.type === "text" && block.text) {
|
|
84
|
+
blocks.push({ type: "text", text: block.text });
|
|
85
|
+
} else if (block.type === "thinking") {
|
|
86
|
+
const sig = block.thinkingSignature;
|
|
87
|
+
if (msg.provider === PROVIDER_ID && sig) {
|
|
88
|
+
blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
|
|
89
|
+
} else {
|
|
90
|
+
dropped.thinking++;
|
|
91
|
+
dropped.providers.add(msg.provider ?? "unknown");
|
|
92
|
+
}
|
|
93
|
+
} else if (block.type === "toolCall") {
|
|
94
|
+
const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
|
|
95
|
+
blocks.push({
|
|
96
|
+
type: "tool_use",
|
|
97
|
+
id: sanitizeToolId(block.id, sanitizedIds),
|
|
98
|
+
name: toolName,
|
|
99
|
+
input: block.arguments ?? {},
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
dropped.other.set(block.type, (dropped.other.get(block.type) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!content.length) {
|
|
106
|
+
dropped.abortedTurns++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
|
|
110
|
+
turnResults = null;
|
|
111
|
+
turnAssistantIdx = anthropicMessages.length;
|
|
112
|
+
anthropicMessages.push({ role: "assistant", content: blocks });
|
|
113
|
+
} else if (msg.role === "toolResult") {
|
|
114
|
+
const block = {
|
|
115
|
+
type: "tool_result",
|
|
116
|
+
tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds),
|
|
117
|
+
content: toolResultContent(msg.content),
|
|
118
|
+
is_error: msg.isError,
|
|
119
|
+
};
|
|
120
|
+
if (turnResults) {
|
|
121
|
+
turnResults.content.push(block);
|
|
122
|
+
} else {
|
|
123
|
+
turnResults = { role: "user", content: [block] };
|
|
124
|
+
anthropicMessages.splice(turnAssistantIdx === null ? anthropicMessages.length : turnAssistantIdx + 1, 0, turnResults);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { anthropicMessages, sanitizedIds, dropped };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function turnStart(messages: Message[]): number {
|
|
133
|
+
let i = messages.length;
|
|
134
|
+
while (i > 0 && messages[i - 1].role === "user") i--;
|
|
135
|
+
return i;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function extractUserPrompt(messages: Message[]): string | null {
|
|
139
|
+
const turn = messages.slice(turnStart(messages)) as UserMessage[];
|
|
140
|
+
if (turn.length === 0) return null;
|
|
141
|
+
return turn
|
|
142
|
+
.map((m) => (typeof m.content === "string" ? m.content : messageContentToText(m.content)))
|
|
143
|
+
.filter((text) => text)
|
|
144
|
+
.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function extractUserPromptBlocks(
|
|
148
|
+
messages: Message[],
|
|
149
|
+
): Array<{ type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }> | null {
|
|
150
|
+
const turn = messages.slice(turnStart(messages)) as UserMessage[];
|
|
151
|
+
if (turn.length === 0) return null;
|
|
152
|
+
let hasImage = false;
|
|
153
|
+
const blocks = [];
|
|
154
|
+
for (const message of turn) {
|
|
155
|
+
const content: Array<TextContent | ImageContent> =
|
|
156
|
+
typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content;
|
|
157
|
+
if (!Array.isArray(content)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`extractUserPromptBlocks: user message content must be a string or block array, got ${typeof content}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const block of content) {
|
|
163
|
+
if (block.type === "text" && block.text) {
|
|
164
|
+
blocks.push({ type: "text", text: block.text });
|
|
165
|
+
} else if (block.type === "image") {
|
|
166
|
+
if (!block.data || !block.mimeType) continue;
|
|
167
|
+
hasImage = true;
|
|
168
|
+
blocks.push({
|
|
169
|
+
type: "image",
|
|
170
|
+
source: { type: "base64" as const, media_type: block.mimeType, data: block.data },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return hasImage ? blocks : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function alreadySdkToolName(name: string): boolean {
|
|
179
|
+
return name.toLowerCase().startsWith(MCP_TOOL_PREFIX);
|
|
180
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CLAUDE_BRIDGE_API,
|
|
3
|
+
CLAUDE_BRIDGE_MODELS,
|
|
4
|
+
MODEL_IDS_IN_ORDER,
|
|
5
|
+
PROVIDER_ID,
|
|
6
|
+
PROVIDER_NAME,
|
|
7
|
+
applyLongContext,
|
|
8
|
+
claudeCodeModelId,
|
|
9
|
+
registeredClaudeBridgeModels,
|
|
10
|
+
resolveClaudeCodeRuntimeModel,
|
|
11
|
+
resolveEffort,
|
|
12
|
+
resolveModel,
|
|
13
|
+
REASONING_TO_EFFORT,
|
|
14
|
+
} from "./models.ts";
|
|
15
|
+
export {
|
|
16
|
+
CLAUDE_BRIDGE_DOUBLE_REGISTRATION_WARNING,
|
|
17
|
+
CLAUDE_BRIDGE_HOST_PROBE_METHODS,
|
|
18
|
+
STANDALONE_CLAUDE_BRIDGE_TOOLS,
|
|
19
|
+
claudeBridgeRegistrationNotice,
|
|
20
|
+
claudeBridgeStandaloneToolsPresent,
|
|
21
|
+
registerClaudeBridgeProvider,
|
|
22
|
+
shouldSkipClaudeBridgeRegistration,
|
|
23
|
+
type ClaudeBridgeRegistration,
|
|
24
|
+
} from "./register.ts";
|
|
25
|
+
export { consumeQuery, processAssistantMessage, processStreamEvent } from "./stream.ts";
|
|
26
|
+
export { streamClaudeBridge, resetClaudeBridgeSession } from "./provider.ts";
|
|
27
|
+
export { convertPiMessages } from "./convert.ts";
|
|
28
|
+
export { looksLikeClaudeBridgeSecret, redactClaudeBridgeSecrets } from "./redact.ts";
|