kith-coord 0.1.2 → 0.1.3
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/CHANGELOG.md +8 -0
- package/README.md +5 -1
- package/bin/kith-menubar +0 -0
- package/dist/cli/index.js +598 -92
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +16 -6
- package/dist/index.js.map +1 -1
- package/docs/integrations.md +13 -2
- package/docs/protocol/tools.v1.json +53 -1
- package/docs/protocol.md +3 -1
- package/docs/usage.md +9 -0
- package/package.json +7 -4
- package/server.json +4 -4
package/dist/cli/index.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import fs22 from "fs";
|
|
5
|
+
import path19 from "path";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
|
|
3
8
|
// src/version.ts
|
|
4
|
-
var KITH_VERSION = "0.1.
|
|
5
|
-
var KITH_PROTOCOL_VERSION = "1.
|
|
9
|
+
var KITH_VERSION = "0.1.3";
|
|
10
|
+
var KITH_PROTOCOL_VERSION = "1.2.0";
|
|
6
11
|
|
|
7
12
|
// src/cli/setup-ledger.ts
|
|
8
13
|
import fs2 from "fs";
|
|
@@ -55,6 +60,9 @@ function repositoryFile(root, file) {
|
|
|
55
60
|
}
|
|
56
61
|
function discoverRepository(input) {
|
|
57
62
|
const directory = fs.realpathSync.native(input);
|
|
63
|
+
if (directory === path.parse(directory).root) {
|
|
64
|
+
throw new Error("Cannot coordinate a filesystem root; select an explicit project with --repo.");
|
|
65
|
+
}
|
|
58
66
|
if (!fs.statSync(directory).isDirectory()) throw new Error(`Not a directory: ${input}`);
|
|
59
67
|
const git2 = (...args) => execFileSync("git", ["rev-parse", ...args], {
|
|
60
68
|
cwd: directory,
|
|
@@ -988,6 +996,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
988
996
|
static DEFAULT_LEASE_MS = 5 * 60 * 1e3;
|
|
989
997
|
capabilities = /* @__PURE__ */ new Map();
|
|
990
998
|
static ownWindowsStart = null;
|
|
999
|
+
static inspectionFailure = null;
|
|
991
1000
|
static validateLease(duration) {
|
|
992
1001
|
if (!Number.isSafeInteger(duration) || duration < 100 || duration > 864e5) {
|
|
993
1002
|
throw new Error("Lease duration must be an integer between 100 and 86400000 milliseconds");
|
|
@@ -996,6 +1005,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
996
1005
|
}
|
|
997
1006
|
/** A real kernel process identity; tests must not supply liveness sentinels. */
|
|
998
1007
|
static getProcessStartTime(pid) {
|
|
1008
|
+
this.inspectionFailure = null;
|
|
999
1009
|
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
1000
1010
|
try {
|
|
1001
1011
|
if (process.platform === "linux") {
|
|
@@ -1013,9 +1023,12 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1013
1023
|
"-Command",
|
|
1014
1024
|
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)`
|
|
1015
1025
|
],
|
|
1016
|
-
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout:
|
|
1026
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3, windowsHide: true }
|
|
1017
1027
|
).trim();
|
|
1018
|
-
if (!/^\d+$/.test(ticks))
|
|
1028
|
+
if (!/^\d+$/.test(ticks)) {
|
|
1029
|
+
this.inspectionFailure = "invalid kernel creation-time response";
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1019
1032
|
const identity = `windows:${ticks}`;
|
|
1020
1033
|
if (pid === process.pid) this.ownWindowsStart = identity;
|
|
1021
1034
|
return identity;
|
|
@@ -1027,7 +1040,9 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1027
1040
|
env: { ...process.env, LC_ALL: "C" }
|
|
1028
1041
|
}).trim();
|
|
1029
1042
|
return out || null;
|
|
1030
|
-
} catch {
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
const failure = error;
|
|
1045
|
+
this.inspectionFailure = [failure.code, failure.status === void 0 ? null : `exit ${failure.status}`, failure.signal].filter(Boolean).join(", ") || "inspection unavailable";
|
|
1031
1046
|
return null;
|
|
1032
1047
|
}
|
|
1033
1048
|
}
|
|
@@ -1057,7 +1072,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1057
1072
|
if (params.sessionToken !== void 0) throw new SessionOwnershipError(params.id);
|
|
1058
1073
|
const pid = params.pid ?? process.pid;
|
|
1059
1074
|
const start = _FencingAndAgentManager.getProcessStartTime(pid);
|
|
1060
|
-
if (!start) throw new Error(`Cannot determine start time of live session owner PID ${pid}`);
|
|
1075
|
+
if (!start) throw new Error(`Cannot determine start time of live session owner PID ${pid} (${_FencingAndAgentManager.inspectionFailure ?? "inspection unavailable"})`);
|
|
1061
1076
|
const now = Date.now();
|
|
1062
1077
|
const epoch = this.coordDb.nextFencingToken();
|
|
1063
1078
|
const token = crypto2.randomBytes(32).toString("base64url");
|
|
@@ -3917,73 +3932,105 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3917
3932
|
import { z } from "zod";
|
|
3918
3933
|
import fs16 from "fs";
|
|
3919
3934
|
import path15 from "path";
|
|
3920
|
-
function
|
|
3921
|
-
const
|
|
3935
|
+
function buildKithMcpServer(repoRoot, pinned) {
|
|
3936
|
+
const pinnedRoot = pinned ? discoverRepository(repoRoot).checkoutRoot : void 0;
|
|
3922
3937
|
const server = new McpServer({
|
|
3923
3938
|
name: "kith-coordinator",
|
|
3924
3939
|
version: KITH_VERSION
|
|
3925
|
-
}, { instructions: "Kith coordinates work claims, isolated worktrees, local resources and verified integration for cooperating coding agents. State stays local; no hosted service or model API key is required. Recommended workflow: one isolated worktree per parallel agent. Call kith_begin_work with isolateWorktree:true and explicit scopeGlobs; edit only the returned worktreePath, call kith_record_file_edit, run literal argv with kith_run_with_mutex, and finish with kith_complete_work plus a project verificationCmd. Heartbeat before the session expires. " + (process.env.KITH_AGENT_ID ? `This server shares a session with CLI/hooks. Use agentId '${process.env.KITH_AGENT_ID}'. Its inherited capability is handled internally; do not request or log it.` : "Start with kith_begin_work or kith_start_session and a unique agentId. This connection retains its granted capability. Omit sessionToken on subsequent calls on this connection; only another connection needs it to resume that ID. Initial grant/resume replies contain the token and client transcripts may record it. Keep transcripts private.") });
|
|
3926
|
-
const tokenFor = (args) => args.sessionToken ?? (process.env.KITH_AGENT_ID === args.agentId ? process.env.KITH_SESSION_TOKEN : void 0);
|
|
3927
|
-
const owned = /* @__PURE__ */ new Set();
|
|
3928
|
-
const register = (args) => {
|
|
3929
|
-
const result = engine.fencing.registerAgent({
|
|
3930
|
-
id: args.agentId,
|
|
3931
|
-
client: args.client ?? "mcp",
|
|
3932
|
-
sessionToken: tokenFor(args),
|
|
3933
|
-
leaseDurationMs: args.leaseDurationMs
|
|
3934
|
-
});
|
|
3935
|
-
if (result.agent.pid === process.pid) owned.add(args.agentId);
|
|
3936
|
-
return result;
|
|
3937
|
-
};
|
|
3938
|
-
const authorize = (args) => {
|
|
3939
|
-
const token = tokenFor(args);
|
|
3940
|
-
if (token) engine.fencing.resumeSession(args.agentId, token);
|
|
3941
|
-
engine.fencing.heartbeat(args.agentId);
|
|
3942
|
-
};
|
|
3940
|
+
}, { instructions: "Kith coordinates work claims, isolated worktrees, local resources and verified integration for cooperating coding agents. State stays local; no hosted service or model API key is required. The optional repository argument selects an absolute Git checkout for each call on a global connection. Keep passing the original checkout, even while editing its returned worktree. If no project context is available, ask for a repository path; never substitute HOME or a filesystem root. Each call is routed independently; there is no shared current project. Recommended workflow: one isolated worktree per parallel agent. Call kith_begin_work with isolateWorktree:true and explicit scopeGlobs; edit only the returned worktreePath, call kith_record_file_edit, run literal argv with kith_run_with_mutex, and finish with kith_complete_work plus a project verificationCmd. Heartbeat before the session expires. " + (repoRoot && process.env.KITH_AGENT_ID ? `This server shares a session with CLI/hooks. Use agentId '${process.env.KITH_AGENT_ID}'. Its inherited capability is handled internally; do not request or log it.` : "Start with kith_begin_work or kith_start_session and a unique agentId. This connection retains its granted capability. Omit sessionToken on subsequent calls on this connection; only another connection needs it to resume that ID. Initial grant/resume replies contain the token and client transcripts may record it. Keep transcripts private.") });
|
|
3943
3941
|
const disconnected = new AbortController();
|
|
3944
3942
|
const commands = /* @__PURE__ */ new Set();
|
|
3945
|
-
const
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3943
|
+
const createContext = (root, inheritCapability) => {
|
|
3944
|
+
const engine2 = new KithEngine(root);
|
|
3945
|
+
const owned = /* @__PURE__ */ new Set();
|
|
3946
|
+
const tokenFor = (args) => args.sessionToken ?? (inheritCapability && process.env.KITH_AGENT_ID === args.agentId ? process.env.KITH_SESSION_TOKEN : void 0);
|
|
3947
|
+
const register = (args) => {
|
|
3948
|
+
const result = engine2.fencing.registerAgent({
|
|
3949
|
+
id: args.agentId,
|
|
3950
|
+
client: args.client ?? "mcp",
|
|
3951
|
+
sessionToken: tokenFor(args),
|
|
3952
|
+
leaseDurationMs: args.leaseDurationMs
|
|
3953
|
+
});
|
|
3954
|
+
if (result.agent.pid === process.pid) owned.add(args.agentId);
|
|
3955
|
+
return result;
|
|
3956
|
+
};
|
|
3957
|
+
const authorize = (args) => {
|
|
3958
|
+
const token = tokenFor(args);
|
|
3959
|
+
if (token) engine2.fencing.resumeSession(args.agentId, token);
|
|
3960
|
+
engine2.fencing.heartbeat(args.agentId);
|
|
3961
|
+
};
|
|
3962
|
+
const formatReply = (agentId, payload) => {
|
|
3963
|
+
const delta = agentId ? engine2.consumeAgentDelta(agentId).formattedFooter : "";
|
|
3964
|
+
const data = payload;
|
|
3965
|
+
return { content: [
|
|
3966
|
+
{ type: "text", text: JSON.stringify(data, null, 2) },
|
|
3967
|
+
...delta ? [{ type: "text", text: delta }] : []
|
|
3968
|
+
], structuredContent: data, isError: data.ok === false };
|
|
3969
|
+
};
|
|
3970
|
+
const close = () => {
|
|
3971
|
+
for (const id of owned) {
|
|
3972
|
+
try {
|
|
3973
|
+
engine2.fencing.abandonAgentSession(id);
|
|
3974
|
+
} catch {
|
|
3975
|
+
}
|
|
3950
3976
|
}
|
|
3951
|
-
|
|
3952
|
-
|
|
3977
|
+
engine2.close();
|
|
3978
|
+
};
|
|
3979
|
+
return { engine: engine2, register, authorize, formatReply, close };
|
|
3980
|
+
};
|
|
3981
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
3982
|
+
const contextFor = (repository) => {
|
|
3983
|
+
if (repository !== void 0 && !path15.isAbsolute(repository)) throw new Error("repository must be an absolute Git checkout path.");
|
|
3984
|
+
const target = repository ?? repoRoot;
|
|
3985
|
+
if (!target) throw new Error("No project selected. Pass repository with the absolute Git checkout path for this task.");
|
|
3986
|
+
const layout = discoverRepository(target);
|
|
3987
|
+
if (pinnedRoot && layout.checkoutRoot !== pinnedRoot) throw new Error("This connection is pinned with --repo. Use a global connection to select another repository.");
|
|
3988
|
+
if (!pinned && !layout.commonGitDir) throw new Error("Global connections require a Git checkout; do not use HOME or a parent directory as a project.");
|
|
3989
|
+
let context = contexts.get(layout.checkoutRoot);
|
|
3990
|
+
if (!context) {
|
|
3991
|
+
context = createContext(layout.checkoutRoot, layout.checkoutRoot === (pinnedRoot ?? repoRoot));
|
|
3992
|
+
contexts.set(layout.checkoutRoot, context);
|
|
3993
|
+
}
|
|
3994
|
+
return context;
|
|
3995
|
+
};
|
|
3996
|
+
const engine = pinned ? contextFor().engine : void 0;
|
|
3997
|
+
const tool = (name, description, schema, handler) => {
|
|
3998
|
+
const inputSchema = { ...schema, repository: z.string().optional().describe("Absolute Git checkout path. Required on global connections without project context. Always use the same original checkout for a task.") };
|
|
3999
|
+
server.tool(
|
|
4000
|
+
name,
|
|
4001
|
+
description,
|
|
4002
|
+
inputSchema,
|
|
4003
|
+
async (args, extra) => {
|
|
4004
|
+
const { repository, ...input } = args;
|
|
4005
|
+
return handler(input, extra, contextFor(repository));
|
|
4006
|
+
}
|
|
4007
|
+
);
|
|
3953
4008
|
};
|
|
3954
4009
|
let closing = false;
|
|
3955
4010
|
server.server.onclose = () => {
|
|
3956
4011
|
if (closing) return;
|
|
3957
4012
|
closing = true;
|
|
3958
4013
|
disconnected.abort();
|
|
4014
|
+
const cleanup = () => {
|
|
4015
|
+
for (const context of contexts.values()) context.close();
|
|
4016
|
+
contexts.clear();
|
|
4017
|
+
};
|
|
3959
4018
|
if (commands.size) void Promise.allSettled([...commands]).then(cleanup);
|
|
3960
4019
|
else cleanup();
|
|
3961
4020
|
};
|
|
3962
|
-
|
|
3963
|
-
const delta = agentId ? engine.consumeAgentDelta(agentId).formattedFooter : "";
|
|
3964
|
-
const data = payload;
|
|
3965
|
-
return {
|
|
3966
|
-
content: [
|
|
3967
|
-
{ type: "text", text: JSON.stringify(data, null, 2) },
|
|
3968
|
-
...delta ? [{ type: "text", text: delta }] : []
|
|
3969
|
-
],
|
|
3970
|
-
structuredContent: data,
|
|
3971
|
-
isError: data.ok === false
|
|
3972
|
-
};
|
|
3973
|
-
};
|
|
3974
|
-
server.tool("kith_start_session", "Create a capability-owned session or resume one with its token.", {
|
|
4021
|
+
tool("kith_start_session", "Create a capability-owned session or resume one with its token.", {
|
|
3975
4022
|
agentId: z.string(),
|
|
3976
4023
|
sessionToken: z.string().optional(),
|
|
3977
4024
|
leaseDurationMs: z.number().optional()
|
|
3978
|
-
}, async (args) => formatReply(void 0, { ok: true, ...register(args) }));
|
|
3979
|
-
|
|
4025
|
+
}, async (args, extra, { engine: engine2, register, authorize, formatReply }) => formatReply(void 0, { ok: true, ...register(args) }));
|
|
4026
|
+
tool("kith_heartbeat", "Renew an active, owned session. Expired sessions cannot be revived.", {
|
|
3980
4027
|
agentId: z.string(),
|
|
3981
4028
|
sessionToken: z.string().optional()
|
|
3982
|
-
}, async (args) => {
|
|
4029
|
+
}, async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
3983
4030
|
authorize(args);
|
|
3984
|
-
return formatReply(args.agentId, { ok: true, agent:
|
|
4031
|
+
return formatReply(args.agentId, { ok: true, agent: engine2.fencing.getAgent(args.agentId) });
|
|
3985
4032
|
});
|
|
3986
|
-
|
|
4033
|
+
tool(
|
|
3987
4034
|
"kith_begin_work",
|
|
3988
4035
|
"Register an agent session, verify no scope lock conflicts exist with sibling agents, acquire monotonic fencing-token locks, and optionally create an isolated Git worktree.",
|
|
3989
4036
|
{
|
|
@@ -3995,19 +4042,19 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
3995
4042
|
scopeGlobs: z.array(z.string()).describe('File paths or glob patterns this agent will edit (e.g. ["src/auth/**"])'),
|
|
3996
4043
|
isolateWorktree: z.boolean().optional().describe("Recommended: true, giving each parallel agent a separate Git worktree")
|
|
3997
4044
|
},
|
|
3998
|
-
async (args) => {
|
|
4045
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
3999
4046
|
const reg = register(args);
|
|
4000
|
-
const intentRes =
|
|
4047
|
+
const intentRes = engine2.beginWork({
|
|
4001
4048
|
agentId: args.agentId,
|
|
4002
4049
|
title: args.title,
|
|
4003
4050
|
intentSummary: args.intentSummary,
|
|
4004
4051
|
scopeGlobs: args.scopeGlobs,
|
|
4005
4052
|
isolateWorktree: args.isolateWorktree
|
|
4006
4053
|
});
|
|
4007
|
-
const contracts =
|
|
4054
|
+
const contracts = engine2.depGraph.listContracts().slice(0, 30);
|
|
4008
4055
|
return formatReply(args.agentId, {
|
|
4009
4056
|
ok: intentRes.ok,
|
|
4010
|
-
agent:
|
|
4057
|
+
agent: engine2.fencing.getAgent(args.agentId),
|
|
4011
4058
|
sessionToken: reg.sessionToken,
|
|
4012
4059
|
fencingToken: intentRes.fencingToken,
|
|
4013
4060
|
task: intentRes.task,
|
|
@@ -4019,7 +4066,7 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4019
4066
|
});
|
|
4020
4067
|
}
|
|
4021
4068
|
);
|
|
4022
|
-
|
|
4069
|
+
tool(
|
|
4023
4070
|
"kith_claim_next_task",
|
|
4024
4071
|
"Atomically claim the highest-priority unblocked task from the DAG task queue and lock its declared file scope.",
|
|
4025
4072
|
{
|
|
@@ -4027,13 +4074,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4027
4074
|
sessionToken: z.string().optional(),
|
|
4028
4075
|
client: z.string().optional()
|
|
4029
4076
|
},
|
|
4030
|
-
async (args) => {
|
|
4077
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4031
4078
|
const session = register(args);
|
|
4032
|
-
const res =
|
|
4079
|
+
const res = engine2.intents.claimNextTask(args.agentId);
|
|
4033
4080
|
return formatReply(args.agentId, { ...res, sessionToken: session.sessionToken });
|
|
4034
4081
|
}
|
|
4035
4082
|
);
|
|
4036
|
-
|
|
4083
|
+
tool(
|
|
4037
4084
|
"kith_add_task",
|
|
4038
4085
|
"Add a task to the orchestrated DAG queue with optional task dependencies and file scope globs.",
|
|
4039
4086
|
{
|
|
@@ -4044,12 +4091,12 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4044
4091
|
dependsOn: z.array(z.string()).optional(),
|
|
4045
4092
|
scopeGlobs: z.array(z.string()).optional()
|
|
4046
4093
|
},
|
|
4047
|
-
async (args) => {
|
|
4048
|
-
const task =
|
|
4094
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4095
|
+
const task = engine2.intents.addTask(args);
|
|
4049
4096
|
return formatReply(void 0, { ok: true, task });
|
|
4050
4097
|
}
|
|
4051
4098
|
);
|
|
4052
|
-
|
|
4099
|
+
tool(
|
|
4053
4100
|
"kith_check_and_lock_files",
|
|
4054
4101
|
"Acquire exclusive or shared fencing-token locks on file paths, globs, or symbols.",
|
|
4055
4102
|
{
|
|
@@ -4059,13 +4106,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4059
4106
|
mode: z.enum(["exclusive", "shared"]).optional(),
|
|
4060
4107
|
reason: z.string().optional()
|
|
4061
4108
|
},
|
|
4062
|
-
async (args) => {
|
|
4109
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4063
4110
|
authorize(args);
|
|
4064
|
-
const res =
|
|
4111
|
+
const res = engine2.locks.acquireLocks(args);
|
|
4065
4112
|
return formatReply(args.agentId, res);
|
|
4066
4113
|
}
|
|
4067
4114
|
);
|
|
4068
|
-
|
|
4115
|
+
tool(
|
|
4069
4116
|
"kith_record_file_edit",
|
|
4070
4117
|
"Analyze a modified file for supported contract signatures, flag exact name/kind matches in other files as non-blocking reuse hints, check transitive import blast radius, and run a live dirty-tree git merge-tree conflict check.",
|
|
4071
4118
|
{
|
|
@@ -4074,9 +4121,9 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4074
4121
|
filePath: z.string(),
|
|
4075
4122
|
content: z.string().optional()
|
|
4076
4123
|
},
|
|
4077
|
-
async (args) => {
|
|
4124
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4078
4125
|
authorize(args);
|
|
4079
|
-
const res =
|
|
4126
|
+
const res = engine2.processPostFileEdit({
|
|
4080
4127
|
agentId: args.agentId,
|
|
4081
4128
|
filePath: args.filePath,
|
|
4082
4129
|
content: args.content
|
|
@@ -4084,7 +4131,7 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4084
4131
|
return formatReply(args.agentId, res);
|
|
4085
4132
|
}
|
|
4086
4133
|
);
|
|
4087
|
-
|
|
4134
|
+
tool(
|
|
4088
4135
|
"kith_publish_contract",
|
|
4089
4136
|
"Explicitly publish a shared interface, function signature, API route, or schema to the Contract Blackboard.",
|
|
4090
4137
|
{
|
|
@@ -4096,13 +4143,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4096
4143
|
signature: z.string(),
|
|
4097
4144
|
docComment: z.string().optional()
|
|
4098
4145
|
},
|
|
4099
|
-
async (args) => {
|
|
4146
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4100
4147
|
authorize(args);
|
|
4101
|
-
const contract =
|
|
4148
|
+
const contract = engine2.depGraph.publishExplicitContract(args);
|
|
4102
4149
|
return formatReply(args.agentId, { ok: true, contract });
|
|
4103
4150
|
}
|
|
4104
4151
|
);
|
|
4105
|
-
|
|
4152
|
+
tool(
|
|
4106
4153
|
"kith_acquire_port",
|
|
4107
4154
|
"Reserve a local TCP port among cooperating Kith clients; start the service promptly and handle unrelated bind races.",
|
|
4108
4155
|
{
|
|
@@ -4111,13 +4158,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4111
4158
|
serviceName: z.string(),
|
|
4112
4159
|
basePort: z.number().optional()
|
|
4113
4160
|
},
|
|
4114
|
-
async (args) => {
|
|
4161
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4115
4162
|
authorize(args);
|
|
4116
|
-
const portRes = await
|
|
4163
|
+
const portRes = await engine2.broker.acquirePort(args);
|
|
4117
4164
|
return formatReply(args.agentId, portRes);
|
|
4118
4165
|
}
|
|
4119
4166
|
);
|
|
4120
|
-
|
|
4167
|
+
tool(
|
|
4121
4168
|
"kith_run_with_mutex",
|
|
4122
4169
|
"Run an argv command under a renewable named mutex, using the MCP server process permissions. Useful when a sandboxed client shell cannot inspect process identities. Requires an owned session. Cancellation or connection loss stops the supervised command before release. This is command execution, not a filesystem sandbox.",
|
|
4123
4170
|
{
|
|
@@ -4131,15 +4178,15 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4131
4178
|
commandTimeoutMs: z.number().int().min(1).max(6e5).optional(),
|
|
4132
4179
|
maxOutputBytes: z.number().int().min(1).max(1048576).optional()
|
|
4133
4180
|
},
|
|
4134
|
-
async (args, extra) => {
|
|
4181
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4135
4182
|
authorize(args);
|
|
4136
|
-
const root = fs16.realpathSync(
|
|
4183
|
+
const root = fs16.realpathSync(engine2.fencing.getAgent(args.agentId)?.worktree_path ?? engine2.coordDb.repoRoot);
|
|
4137
4184
|
const cwd = fs16.realpathSync(path15.resolve(root, args.cwd ?? "."));
|
|
4138
4185
|
const relative = path15.relative(root, cwd);
|
|
4139
4186
|
if (relative === ".." || relative.startsWith(".." + path15.sep) || path15.isAbsolute(relative)) {
|
|
4140
4187
|
throw new Error("Command cwd must be inside the registered worktree or repository");
|
|
4141
4188
|
}
|
|
4142
|
-
const execution =
|
|
4189
|
+
const execution = engine2.broker.executeWithMutex({
|
|
4143
4190
|
agentId: args.agentId,
|
|
4144
4191
|
mutexName: args.mutexName,
|
|
4145
4192
|
command: args.command,
|
|
@@ -4159,7 +4206,7 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4159
4206
|
}
|
|
4160
4207
|
}
|
|
4161
4208
|
);
|
|
4162
|
-
|
|
4209
|
+
tool(
|
|
4163
4210
|
"kith_get_status",
|
|
4164
4211
|
"Retrieve coordination state, FIFO waiters and guarded commands, plus worktree paths, actual branches, dirty-file overlaps, commit age and ahead/behind against the current integration target HEAD. Normal engine startup may reap expired ownership.",
|
|
4165
4212
|
{
|
|
@@ -4167,9 +4214,9 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4167
4214
|
sessionToken: z.string().optional(),
|
|
4168
4215
|
includeContracts: z.boolean().optional().describe("Include the full contract registry. Default false keeps frequent status polls compact; contractCount always reports its size.")
|
|
4169
4216
|
},
|
|
4170
|
-
async (args) => {
|
|
4217
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4171
4218
|
if (args.agentId) authorize({ ...args, agentId: args.agentId });
|
|
4172
|
-
const state =
|
|
4219
|
+
const state = engine2.getStatus();
|
|
4173
4220
|
return formatReply(args.agentId, {
|
|
4174
4221
|
...state,
|
|
4175
4222
|
contracts: args.includeContracts ? state.contracts : [],
|
|
@@ -4178,16 +4225,16 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4178
4225
|
});
|
|
4179
4226
|
}
|
|
4180
4227
|
);
|
|
4181
|
-
|
|
4228
|
+
tool(
|
|
4182
4229
|
"kith_preview_integrate",
|
|
4183
4230
|
"Preview exact Git conflict paths for a committed registered worktree against the current integration target. Refuses dirty inputs; never checkpoints or changes source/target files. A clean preview does not run project verification.",
|
|
4184
4231
|
{ agentId: z.string(), sessionToken: z.string().optional() },
|
|
4185
|
-
async (args) => {
|
|
4232
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4186
4233
|
authorize(args);
|
|
4187
|
-
return formatReply(args.agentId,
|
|
4234
|
+
return formatReply(args.agentId, engine2.previewIntegration(args.agentId));
|
|
4188
4235
|
}
|
|
4189
4236
|
);
|
|
4190
|
-
|
|
4237
|
+
tool(
|
|
4191
4238
|
"kith_complete_work",
|
|
4192
4239
|
"Complete an agent task, optionally run the Speculative Merge Train to verify and merge its worktree branch, and release all locks and resources.",
|
|
4193
4240
|
{
|
|
@@ -4198,16 +4245,25 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4198
4245
|
mergeBranch: z.string().optional().describe("Source branch to integrate, not the destination. Defaults to the registered agent branch; destination is the selected repository checkout current branch. Omit for claims-only work without a registered branch."),
|
|
4199
4246
|
verificationCmd: z.string().optional().describe('Command to run in speculative merge tree (e.g. "npm run typecheck")')
|
|
4200
4247
|
},
|
|
4201
|
-
async (args) => {
|
|
4248
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4202
4249
|
authorize(args);
|
|
4203
|
-
const result =
|
|
4250
|
+
const result = engine2.completeWork(args);
|
|
4204
4251
|
return formatReply(result.ok ? void 0 : args.agentId, result);
|
|
4205
4252
|
}
|
|
4206
4253
|
);
|
|
4207
4254
|
return { server, engine };
|
|
4208
4255
|
}
|
|
4209
|
-
async function runKithMcpStdioServer(repoRoot = process.cwd()) {
|
|
4210
|
-
|
|
4256
|
+
async function runKithMcpStdioServer(repoRoot = process.cwd(), pinned = true) {
|
|
4257
|
+
let defaultRoot = repoRoot;
|
|
4258
|
+
if (!pinned) {
|
|
4259
|
+
try {
|
|
4260
|
+
const layout = discoverRepository(repoRoot);
|
|
4261
|
+
defaultRoot = layout.commonGitDir ? layout.checkoutRoot : void 0;
|
|
4262
|
+
} catch {
|
|
4263
|
+
defaultRoot = void 0;
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
const { server } = buildKithMcpServer(defaultRoot, pinned);
|
|
4211
4267
|
const transport = new StdioServerTransport();
|
|
4212
4268
|
let closing = false;
|
|
4213
4269
|
const close = () => {
|
|
@@ -4642,6 +4698,356 @@ esac
|
|
|
4642
4698
|
return report;
|
|
4643
4699
|
}
|
|
4644
4700
|
|
|
4701
|
+
// src/core/watcher.ts
|
|
4702
|
+
import fs19 from "fs";
|
|
4703
|
+
import path17 from "path";
|
|
4704
|
+
import { createHash } from "crypto";
|
|
4705
|
+
import { execFile, execFileSync as execFileSync10 } from "child_process";
|
|
4706
|
+
import { promisify } from "util";
|
|
4707
|
+
var execFileAsync = promisify(execFile);
|
|
4708
|
+
function contentIdentity(repo, file) {
|
|
4709
|
+
const target = path17.join(repo, file);
|
|
4710
|
+
let stat;
|
|
4711
|
+
try {
|
|
4712
|
+
stat = fs19.lstatSync(target);
|
|
4713
|
+
} catch (error) {
|
|
4714
|
+
if (error.code === "ENOENT") return "absent";
|
|
4715
|
+
throw error;
|
|
4716
|
+
}
|
|
4717
|
+
const parent = fs19.realpathSync(path17.dirname(target));
|
|
4718
|
+
const relative = path17.relative(repo, parent);
|
|
4719
|
+
if (relative === ".." || relative.startsWith(`..${path17.sep}`) || path17.isAbsolute(relative)) throw new Error(`Path redirected outside checkout: ${file}`);
|
|
4720
|
+
if (stat.isSymbolicLink()) return `link:${fs19.readlinkSync(target)}`;
|
|
4721
|
+
if (!stat.isFile() || stat.size > 8 * 1024 * 1024) return null;
|
|
4722
|
+
const fd = fs19.openSync(target, fs19.constants.O_RDONLY | (fs19.constants.O_NOFOLLOW ?? 0));
|
|
4723
|
+
try {
|
|
4724
|
+
const before = fs19.fstatSync(fd);
|
|
4725
|
+
if (before.ino !== stat.ino || before.dev !== stat.dev || before.size !== stat.size || before.mtimeMs !== stat.mtimeMs) throw new Error(`File changed during comparison: ${file}`);
|
|
4726
|
+
const hash = createHash("sha256");
|
|
4727
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
4728
|
+
for (let offset = 0; offset < before.size; ) {
|
|
4729
|
+
const count = fs19.readSync(fd, buffer, 0, Math.min(buffer.length, before.size - offset), offset);
|
|
4730
|
+
if (!count) throw new Error(`File changed during comparison: ${file}`);
|
|
4731
|
+
hash.update(buffer.subarray(0, count));
|
|
4732
|
+
offset += count;
|
|
4733
|
+
}
|
|
4734
|
+
const after = fs19.fstatSync(fd);
|
|
4735
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`File changed during comparison: ${file}`);
|
|
4736
|
+
return `file:${before.mode & 73}:${hash.digest("hex")}`;
|
|
4737
|
+
} finally {
|
|
4738
|
+
fs19.closeSync(fd);
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
var PassiveConflictWatcher = class _PassiveConflictWatcher {
|
|
4742
|
+
constructor(rootDir, options = {}) {
|
|
4743
|
+
this.rootDir = rootDir;
|
|
4744
|
+
this.options = options;
|
|
4745
|
+
if (options.intervalMs !== void 0 && (!Number.isFinite(options.intervalMs) || options.intervalMs < 100)) throw new Error("Watch interval must be at least 100ms.");
|
|
4746
|
+
}
|
|
4747
|
+
rootDir;
|
|
4748
|
+
options;
|
|
4749
|
+
isWatching = false;
|
|
4750
|
+
watchTimer = null;
|
|
4751
|
+
recentEdits = /* @__PURE__ */ new Map();
|
|
4752
|
+
activeCollisions = /* @__PURE__ */ new Map();
|
|
4753
|
+
repositories = /* @__PURE__ */ new Map();
|
|
4754
|
+
errors = [];
|
|
4755
|
+
static notifyMac(title, message) {
|
|
4756
|
+
if (process.platform !== "darwin") return;
|
|
4757
|
+
try {
|
|
4758
|
+
execFileSync10("osascript", ["-e", "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run", title, message], { stdio: "ignore", timeout: 2e3 });
|
|
4759
|
+
} catch {
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
discoverGitRepos() {
|
|
4763
|
+
this.errors = [];
|
|
4764
|
+
this.repositories.clear();
|
|
4765
|
+
const candidates = [];
|
|
4766
|
+
const marker = (dir) => {
|
|
4767
|
+
try {
|
|
4768
|
+
fs19.lstatSync(path17.join(dir, ".git"));
|
|
4769
|
+
return true;
|
|
4770
|
+
} catch (error) {
|
|
4771
|
+
if (error.code === "ENOENT") return false;
|
|
4772
|
+
throw error;
|
|
4773
|
+
}
|
|
4774
|
+
};
|
|
4775
|
+
try {
|
|
4776
|
+
const root = fs19.realpathSync(this.rootDir);
|
|
4777
|
+
if (marker(root)) candidates.push(root);
|
|
4778
|
+
for (const entry of fs19.readdirSync(root, { withFileTypes: true })) {
|
|
4779
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
4780
|
+
const sub = path17.join(root, entry.name);
|
|
4781
|
+
if (marker(sub)) candidates.push(sub);
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
} catch (error) {
|
|
4785
|
+
this.errors.push({ repository: this.rootDir, message: `Cannot discover repositories: ${error instanceof Error ? error.message : String(error)}` });
|
|
4786
|
+
}
|
|
4787
|
+
const inspected = /* @__PURE__ */ new Set();
|
|
4788
|
+
for (const candidate of candidates) {
|
|
4789
|
+
try {
|
|
4790
|
+
const layout = discoverRepository(candidate);
|
|
4791
|
+
if (!layout.commonGitDir) throw new Error("Git repository unavailable");
|
|
4792
|
+
this.repositories.set(layout.checkoutRoot, layout.commonGitDir);
|
|
4793
|
+
if (inspected.has(layout.commonGitDir)) continue;
|
|
4794
|
+
inspected.add(layout.commonGitDir);
|
|
4795
|
+
const listing = execFileSync10("git", ["worktree", "list", "--porcelain", "-z"], { cwd: layout.checkoutRoot, encoding: "utf8", timeout: 4e3, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
4796
|
+
for (const record of listing.split("\0")) if (record.startsWith("worktree ")) {
|
|
4797
|
+
const linked = discoverRepository(record.slice(9));
|
|
4798
|
+
if (linked.commonGitDir !== layout.commonGitDir) throw new Error("Worktree repository identity changed during discovery");
|
|
4799
|
+
this.repositories.set(linked.checkoutRoot, layout.commonGitDir);
|
|
4800
|
+
}
|
|
4801
|
+
} catch (error) {
|
|
4802
|
+
this.errors.push({ repository: candidate, message: `Cannot inspect repository: ${error instanceof Error ? error.message : String(error)}` });
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
if (!candidates.length && !this.errors.length) this.errors.push({ repository: this.rootDir, message: "No Git repositories found in this directory or its immediate children." });
|
|
4806
|
+
return [...this.repositories.keys()];
|
|
4807
|
+
}
|
|
4808
|
+
async scanOnce() {
|
|
4809
|
+
const repos = this.discoverGitRepos();
|
|
4810
|
+
const now = Date.now();
|
|
4811
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4812
|
+
const overlaps = /* @__PURE__ */ new Map();
|
|
4813
|
+
for (const repo of repos) {
|
|
4814
|
+
try {
|
|
4815
|
+
const { stdout } = await execFileAsync("git", ["-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: repo, timeout: 4e3, maxBuffer: 4 * 1024 * 1024 });
|
|
4816
|
+
const records = stdout.split("\0");
|
|
4817
|
+
const dirty = /* @__PURE__ */ new Map();
|
|
4818
|
+
for (let i = 0; i < records.length; i++) {
|
|
4819
|
+
const record = records[i];
|
|
4820
|
+
if (!record) continue;
|
|
4821
|
+
const status = record.slice(0, 2);
|
|
4822
|
+
dirty.set(record.slice(3), status);
|
|
4823
|
+
if (/[RC]/.test(status)) dirty.set(records[++i], status);
|
|
4824
|
+
}
|
|
4825
|
+
for (const [file, status] of dirty) {
|
|
4826
|
+
if (!file || file === ".coord" || file.startsWith(".coord/")) continue;
|
|
4827
|
+
const key = JSON.stringify([repo, file]);
|
|
4828
|
+
seen.add(key);
|
|
4829
|
+
let modified = now, fingerprint = status;
|
|
4830
|
+
try {
|
|
4831
|
+
const stat = fs19.lstatSync(path17.join(repo, file));
|
|
4832
|
+
modified = Math.min(now, stat.mtimeMs);
|
|
4833
|
+
fingerprint += `:${stat.mtimeMs}:${stat.size}`;
|
|
4834
|
+
} catch (error) {
|
|
4835
|
+
if (error.code !== "ENOENT") throw error;
|
|
4836
|
+
}
|
|
4837
|
+
const previous = this.recentEdits.get(key);
|
|
4838
|
+
if (!previous || previous.fingerprint !== fingerprint) {
|
|
4839
|
+
this.recentEdits.set(key, { repo, fingerprint, timestamp: modified });
|
|
4840
|
+
this.options.onFileChange?.({ repoPath: repo, filePath: file, timestamp: modified });
|
|
4841
|
+
}
|
|
4842
|
+
const identity = JSON.stringify([this.repositories.get(repo), file]);
|
|
4843
|
+
const overlap = overlaps.get(identity) ?? { file, repos: [] };
|
|
4844
|
+
overlap.repos.push(repo);
|
|
4845
|
+
overlaps.set(identity, overlap);
|
|
4846
|
+
}
|
|
4847
|
+
} catch (error) {
|
|
4848
|
+
this.errors.push({ repository: repo, message: `Git scan unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
4849
|
+
}
|
|
4850
|
+
}
|
|
4851
|
+
if (!this.errors.length) {
|
|
4852
|
+
for (const key of this.recentEdits.keys()) if (!seen.has(key)) this.recentEdits.delete(key);
|
|
4853
|
+
}
|
|
4854
|
+
const current = /* @__PURE__ */ new Map();
|
|
4855
|
+
for (const [id, overlap] of overlaps) if (overlap.repos.length > 1) {
|
|
4856
|
+
let identical = false;
|
|
4857
|
+
try {
|
|
4858
|
+
const identities = overlap.repos.map((repo) => contentIdentity(repo, overlap.file));
|
|
4859
|
+
identical = identities.every((value) => value !== null && value === identities[0]);
|
|
4860
|
+
} catch (error) {
|
|
4861
|
+
this.errors.push({ repository: overlap.repos.join(", "), message: `Cannot compare ${overlap.file}: ${error instanceof Error ? error.message : String(error)}` });
|
|
4862
|
+
}
|
|
4863
|
+
if (identical) continue;
|
|
4864
|
+
const previous = this.activeCollisions.get(id);
|
|
4865
|
+
const alert = {
|
|
4866
|
+
id,
|
|
4867
|
+
filePath: overlap.file,
|
|
4868
|
+
repos: overlap.repos,
|
|
4869
|
+
severity: "collision",
|
|
4870
|
+
timestamp: previous?.timestamp ?? now,
|
|
4871
|
+
message: `Shared dirty path '${overlap.file}' has different or unverified contents in linked worktrees: ${overlap.repos.join(", ")}. This is not a proven merge conflict.`
|
|
4872
|
+
};
|
|
4873
|
+
current.set(id, alert);
|
|
4874
|
+
if (!previous) {
|
|
4875
|
+
this.options.onCollision?.(alert);
|
|
4876
|
+
if (this.options.notify) _PassiveConflictWatcher.notifyMac("Kith worktree overlap", alert.message);
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
this.activeCollisions = this.errors.length ? new Map([...this.activeCollisions, ...current]) : current;
|
|
4880
|
+
if (this.errors.length) this.options.onScanError?.(this.errors);
|
|
4881
|
+
return this.getActiveCollisions();
|
|
4882
|
+
}
|
|
4883
|
+
start() {
|
|
4884
|
+
if (this.isWatching) return;
|
|
4885
|
+
this.isWatching = true;
|
|
4886
|
+
const tick = async () => {
|
|
4887
|
+
try {
|
|
4888
|
+
await this.scanOnce();
|
|
4889
|
+
} catch (error) {
|
|
4890
|
+
this.errors = [{ repository: this.rootDir, message: String(error) }];
|
|
4891
|
+
this.options.onScanError?.(this.errors);
|
|
4892
|
+
}
|
|
4893
|
+
if (this.isWatching) this.watchTimer = setTimeout(tick, this.options.intervalMs ?? 3e3);
|
|
4894
|
+
};
|
|
4895
|
+
void tick();
|
|
4896
|
+
}
|
|
4897
|
+
stop() {
|
|
4898
|
+
this.isWatching = false;
|
|
4899
|
+
if (this.watchTimer) clearTimeout(this.watchTimer);
|
|
4900
|
+
this.watchTimer = null;
|
|
4901
|
+
}
|
|
4902
|
+
getScanState() {
|
|
4903
|
+
return { complete: this.errors.length === 0, errors: [...this.errors], repos: [...this.repositories.keys()] };
|
|
4904
|
+
}
|
|
4905
|
+
getActiveCollisions() {
|
|
4906
|
+
return [...this.activeCollisions.values()];
|
|
4907
|
+
}
|
|
4908
|
+
getRecentEdits() {
|
|
4909
|
+
const now = Date.now();
|
|
4910
|
+
return [...this.recentEdits].map(([key, value]) => ({ key: `${value.repo}/${JSON.parse(key)[1]}`, repo: value.repo, ageSec: Math.max(0, Math.round((now - value.timestamp) / 1e3)) })).filter((value) => value.ageSec < 300).sort((a, b) => a.ageSec - b.ageSec);
|
|
4911
|
+
}
|
|
4912
|
+
};
|
|
4913
|
+
|
|
4914
|
+
// src/cli/setup-auto.ts
|
|
4915
|
+
import fs21 from "fs";
|
|
4916
|
+
import path18 from "path";
|
|
4917
|
+
import os2 from "os";
|
|
4918
|
+
import { parse as parseToml3, stringify as stringifyToml2 } from "smol-toml";
|
|
4919
|
+
|
|
4920
|
+
// src/cli/runtime.ts
|
|
4921
|
+
import fs20 from "fs";
|
|
4922
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4923
|
+
function installedCliPath() {
|
|
4924
|
+
for (const relative of ["./index.js", "../../dist/cli/index.js"]) {
|
|
4925
|
+
const file = fileURLToPath3(new URL(relative, import.meta.url));
|
|
4926
|
+
if (fs20.existsSync(file)) return fs20.realpathSync(file);
|
|
4927
|
+
}
|
|
4928
|
+
throw new Error("Build or install Kith before configuring clients.");
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4931
|
+
// src/cli/setup-auto.ts
|
|
4932
|
+
function object2(value, label) {
|
|
4933
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object; no configuration was written.`);
|
|
4934
|
+
return value;
|
|
4935
|
+
}
|
|
4936
|
+
function ownedLauncher(value) {
|
|
4937
|
+
if (value.type !== void 0 && value.type !== "stdio") return false;
|
|
4938
|
+
if (typeof value.command !== "string" || !/^node(?:\.exe)?$/i.test(path18.basename(value.command)) || !Array.isArray(value.args) || value.args[1] !== "mcp" || ![2, 4].includes(value.args.length) || value.args.length === 4 && value.args[2] !== "--repo") return false;
|
|
4939
|
+
try {
|
|
4940
|
+
const entry = fs21.realpathSync(value.args[0]);
|
|
4941
|
+
return path18.basename(entry) === "index.js" && path18.basename(path18.dirname(entry)) === "cli" && JSON.parse(fs21.readFileSync(path18.resolve(entry, "../../../package.json"), "utf8")).name === "kith-coord";
|
|
4942
|
+
} catch {
|
|
4943
|
+
return false;
|
|
4944
|
+
}
|
|
4945
|
+
}
|
|
4946
|
+
function autoConfigureAllClients(targetWorkspace = process.cwd(), home = os2.homedir(), options = {}) {
|
|
4947
|
+
const target = physicalPath(targetWorkspace);
|
|
4948
|
+
const layout = target === path18.parse(target).root ? void 0 : discoverRepository(target);
|
|
4949
|
+
const workspace = layout?.checkoutRoot ?? target;
|
|
4950
|
+
home = physicalPath(home);
|
|
4951
|
+
const server = { command: process.execPath, args: [installedCliPath(), "mcp"] };
|
|
4952
|
+
const projectServer = { ...server, args: [...server.args, "--repo", workspace] };
|
|
4953
|
+
const results = [];
|
|
4954
|
+
const groups = /* @__PURE__ */ new Map();
|
|
4955
|
+
const warnings = [];
|
|
4956
|
+
const queue = (root, file, content) => {
|
|
4957
|
+
if (!isWithin(root, file)) throw new Error(`Configuration is outside its setup root: ${file}`);
|
|
4958
|
+
if (fs21.existsSync(file) && fs21.readFileSync(file, "utf8") === content) return;
|
|
4959
|
+
const group = groups.get(root) ?? { coordDir: root === workspace && layout ? layout.coordDir : path18.join(root, ".coord"), plan: /* @__PURE__ */ new Map() };
|
|
4960
|
+
if (physicalPath(group.coordDir) !== group.coordDir) throw new Error("Setup backup directory must not be redirected.");
|
|
4961
|
+
group.plan.set(path18.relative(root, file), { content, mode: 384 });
|
|
4962
|
+
groups.set(root, group);
|
|
4963
|
+
};
|
|
4964
|
+
const safePath = (file) => {
|
|
4965
|
+
if (physicalPath(file) !== file) throw new Error(`Configuration path is redirected: ${file}; no configuration was written.`);
|
|
4966
|
+
};
|
|
4967
|
+
const jsonClient = (clientName, file, root, found, projectScoped = false) => {
|
|
4968
|
+
const selectedServer = projectScoped ? projectServer : server;
|
|
4969
|
+
if (!found) {
|
|
4970
|
+
results.push({ clientName, configPath: file, found: false, configured: false, notes: "No existing client configuration found" });
|
|
4971
|
+
return;
|
|
4972
|
+
}
|
|
4973
|
+
safePath(file);
|
|
4974
|
+
const cfg = fs21.existsSync(file) ? object2(JSON.parse(fs21.readFileSync(file, "utf8")), file) : {};
|
|
4975
|
+
const servers = object2(cfg.mcpServers ?? {}, `${file} mcpServers`);
|
|
4976
|
+
const previous = servers.kith === void 0 ? void 0 : object2(servers.kith, `${file} kith`);
|
|
4977
|
+
const exact = previous?.command === selectedServer.command && JSON.stringify(previous.args) === JSON.stringify(selectedServer.args);
|
|
4978
|
+
if (previous && !exact && !ownedLauncher(previous)) {
|
|
4979
|
+
results.push({ clientName, configPath: file, found: true, configured: false, notes: "Custom Kith server preserved; review its repository and launcher manually" });
|
|
4980
|
+
return;
|
|
4981
|
+
}
|
|
4982
|
+
cfg.mcpServers = { ...servers, kith: { ...previous, ...selectedServer } };
|
|
4983
|
+
queue(root, file, JSON.stringify(cfg, null, 2) + "\n");
|
|
4984
|
+
results.push({ clientName, configPath: file, found: true, configured: true, notes: `${options.dryRun ? "Would configure" : "Configured"} Kith ${projectScoped ? `for ${workspace}` : "for per-task repository selection"}; existing settings preserved` });
|
|
4985
|
+
};
|
|
4986
|
+
const desktop = process.platform === "win32" ? path18.join(process.env.APPDATA ?? path18.join(home, "AppData/Roaming"), "Claude/claude_desktop_config.json") : process.platform === "darwin" ? path18.join(home, "Library/Application Support/Claude/claude_desktop_config.json") : path18.join(home, ".config/Claude/claude_desktop_config.json");
|
|
4987
|
+
jsonClient("Claude Desktop App", desktop, home, fs21.existsSync(desktop));
|
|
4988
|
+
jsonClient(
|
|
4989
|
+
"Claude Code CLI",
|
|
4990
|
+
path18.join(home, ".claude.json"),
|
|
4991
|
+
home,
|
|
4992
|
+
fs21.existsSync(path18.join(home, ".claude.json")) || fs21.existsSync(path18.join(home, ".claude"))
|
|
4993
|
+
);
|
|
4994
|
+
const codex = path18.join(home, ".codex/config.toml");
|
|
4995
|
+
if (fs21.existsSync(path18.dirname(codex))) {
|
|
4996
|
+
safePath(codex);
|
|
4997
|
+
const original = fs21.existsSync(codex) ? fs21.readFileSync(codex, "utf8") : "";
|
|
4998
|
+
const cfg = parseToml3(original), servers = object2(cfg.mcp_servers ?? {}, "Codex MCP servers");
|
|
4999
|
+
const previous = servers.kith === void 0 ? void 0 : object2(servers.kith, "Codex Kith server");
|
|
5000
|
+
let content = original;
|
|
5001
|
+
let configured = true;
|
|
5002
|
+
const exact = previous?.command === server.command && JSON.stringify(previous.args) === JSON.stringify(server.args);
|
|
5003
|
+
if (!previous) {
|
|
5004
|
+
content += (content && !content.endsWith("\n") ? "\n" : "") + "\n" + stringifyToml2({ mcp_servers: { kith: { ...server, env_vars: ["KITH_AGENT_ID", "KITH_SESSION_TOKEN"] } } });
|
|
5005
|
+
} else if (!exact) {
|
|
5006
|
+
const block = /(^\[mcp_servers\.kith\][^\S\r\n]*\r?\n)([\s\S]*?)(?=^\[|$(?![\s\S]))/m;
|
|
5007
|
+
const match = original.match(block);
|
|
5008
|
+
if (ownedLauncher(previous) && match && /^command\s*=\s*"(?:[^"\\\r\n]|\\.)*"\s*$/m.test(match[2]) && /^args\s*=\s*\[[^\r\n]*\]\s*$/m.test(match[2])) {
|
|
5009
|
+
const fields = [`command = ${JSON.stringify(server.command)}`, `args = ${JSON.stringify(server.args)}`];
|
|
5010
|
+
const updated = match[2].replace(/^command\s*=.*$/m, fields[0]).replace(/^args\s*=.*$/m, fields[1]);
|
|
5011
|
+
content = original.replace(block, () => match[1] + updated);
|
|
5012
|
+
} else configured = false;
|
|
5013
|
+
}
|
|
5014
|
+
parseToml3(content);
|
|
5015
|
+
if (configured) queue(home, codex, content);
|
|
5016
|
+
results.push({
|
|
5017
|
+
clientName: "Codex",
|
|
5018
|
+
found: true,
|
|
5019
|
+
configPath: codex,
|
|
5020
|
+
configured,
|
|
5021
|
+
notes: configured ? `${options.dryRun ? "Would configure" : "Configured"} Kith for per-task repository selection; other TOML preserved` : "Custom Kith server preserved; review its repository and launcher manually"
|
|
5022
|
+
});
|
|
5023
|
+
}
|
|
5024
|
+
jsonClient("Cursor", path18.join(workspace, ".cursor/mcp.json"), workspace, Boolean(layout?.commonGitDir), true);
|
|
5025
|
+
const antigravity = path18.join(home, ".gemini/config/mcp_config.json");
|
|
5026
|
+
jsonClient("Antigravity", antigravity, home, fs21.existsSync(path18.dirname(antigravity)));
|
|
5027
|
+
for (const [root] of groups) {
|
|
5028
|
+
if (!fs21.existsSync(path18.join(root, ".git"))) continue;
|
|
5029
|
+
const file = path18.join(root, ".gitignore");
|
|
5030
|
+
safePath(file);
|
|
5031
|
+
const original = fs21.existsSync(file) ? fs21.readFileSync(file, "utf8") : "";
|
|
5032
|
+
if (!original.split(/\r?\n/).some((line) => [".coord/", "/.coord/"].includes(line.trim()))) {
|
|
5033
|
+
queue(root, file, original + (original && !original.endsWith("\n") ? "\n" : "") + "\n# Kith private setup backups\n.coord/\n");
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
if (!options.dryRun && groups.size) {
|
|
5037
|
+
const engine = layout?.commonGitDir ? new KithEngine(workspace) : void 0;
|
|
5038
|
+
try {
|
|
5039
|
+
for (const [root, group] of groups) {
|
|
5040
|
+
fs21.mkdirSync(group.coordDir, { recursive: true, mode: 448 });
|
|
5041
|
+
applySetupPlan(root, group.coordDir, group.plan, warnings);
|
|
5042
|
+
}
|
|
5043
|
+
} finally {
|
|
5044
|
+
engine?.close();
|
|
5045
|
+
}
|
|
5046
|
+
}
|
|
5047
|
+
for (const warning of warnings) results.push({ clientName: "Setup backup", found: true, configured: false, configPath: "", notes: warning });
|
|
5048
|
+
return results;
|
|
5049
|
+
}
|
|
5050
|
+
|
|
4645
5051
|
// src/cli/index.ts
|
|
4646
5052
|
function parseFlags(argv) {
|
|
4647
5053
|
const dashDashIdx = argv.indexOf("--");
|
|
@@ -4658,7 +5064,7 @@ function parseFlags(argv) {
|
|
|
4658
5064
|
} else {
|
|
4659
5065
|
const key = arg.slice(2);
|
|
4660
5066
|
const next = mainArgs[i + 1];
|
|
4661
|
-
if (!["worktree", "raw", "dry-run", "help", "version", "shell", "new", "mcp-only", "offline"].includes(key) && next && !next.startsWith("--")) {
|
|
5067
|
+
if (!["worktree", "raw", "dry-run", "help", "version", "shell", "new", "mcp-only", "offline", "once", "json", "quiet", "no-notify"].includes(key) && next && !next.startsWith("--")) {
|
|
4662
5068
|
flags[key] = next;
|
|
4663
5069
|
i++;
|
|
4664
5070
|
} else {
|
|
@@ -4675,7 +5081,8 @@ async function main() {
|
|
|
4675
5081
|
const { positional, flags, passthroughCmd } = parseFlags(process.argv.slice(2));
|
|
4676
5082
|
const cmd = positional[0] ?? "help";
|
|
4677
5083
|
const subCmd = positional[1];
|
|
4678
|
-
const
|
|
5084
|
+
const targetFromPositional = ["init", "setup", "watch", "menubar", "bar", "tray", "doctor", "recover"].includes(cmd) && positional[1] ? path19.resolve(positional[1]) : void 0;
|
|
5085
|
+
const repoRoot = typeof flags.repo === "string" ? path19.resolve(flags.repo) : targetFromPositional ?? (cmd === "mcp" ? process.env.CLAUDE_PROJECT_DIR : void 0) ?? process.cwd();
|
|
4679
5086
|
if (flags.version || cmd === "version") {
|
|
4680
5087
|
console.log(KITH_VERSION);
|
|
4681
5088
|
return;
|
|
@@ -4699,7 +5106,7 @@ async function main() {
|
|
|
4699
5106
|
return;
|
|
4700
5107
|
}
|
|
4701
5108
|
if (cmd === "mcp") {
|
|
4702
|
-
await runKithMcpStdioServer(repoRoot);
|
|
5109
|
+
await runKithMcpStdioServer(repoRoot, typeof flags.repo === "string");
|
|
4703
5110
|
return;
|
|
4704
5111
|
}
|
|
4705
5112
|
if (cmd === "uninstall") {
|
|
@@ -4720,6 +5127,102 @@ async function main() {
|
|
|
4720
5127
|
process.exitCode = state.state === "unavailable" ? 1 : 0;
|
|
4721
5128
|
return;
|
|
4722
5129
|
}
|
|
5130
|
+
if (cmd === "setup" || cmd === "auto-detect") {
|
|
5131
|
+
const targetDir = positional[1] ? path19.resolve(positional[1]) : repoRoot;
|
|
5132
|
+
const results = autoConfigureAllClients(targetDir, void 0, { dryRun: Boolean(flags["dry-run"]) });
|
|
5133
|
+
console.log(`
|
|
5134
|
+
\u2728 Kith Auto-Detect & Setup for ${targetDir}
|
|
5135
|
+
`);
|
|
5136
|
+
for (const r of results) {
|
|
5137
|
+
const statusIcon = r.configured ? "\u2713" : r.found ? "!" : "\u2717";
|
|
5138
|
+
console.log(` ${statusIcon} ${r.clientName.padEnd(24)} -> ${r.notes}`);
|
|
5139
|
+
}
|
|
5140
|
+
if (results.some((r) => r.found && !r.configured)) process.exitCode = 1;
|
|
5141
|
+
console.log("\nExisting hooks and permissions are unchanged. Restart configured clients; review any skipped entries.\n");
|
|
5142
|
+
return;
|
|
5143
|
+
}
|
|
5144
|
+
if (cmd === "menubar" || cmd === "bar" || cmd === "tray") {
|
|
5145
|
+
const targetDir = positional[1] ? path19.resolve(positional[1]) : repoRoot;
|
|
5146
|
+
if (process.platform !== "darwin") throw new Error("The menu-bar view requires macOS. Use kith watch on this platform.");
|
|
5147
|
+
const menubarBin = path19.resolve(path19.dirname(installedCliPath()), "../../bin/kith-menubar");
|
|
5148
|
+
if (!fs22.existsSync(menubarBin)) throw new Error("Menu-bar binary is missing; reinstall the package or run pnpm build:menubar in the source checkout.");
|
|
5149
|
+
await new Promise((resolve, reject) => {
|
|
5150
|
+
const child = spawn(menubarBin, [fs22.realpathSync(targetDir), process.execPath, installedCliPath()], { detached: true, stdio: "ignore" });
|
|
5151
|
+
child.once("error", reject);
|
|
5152
|
+
child.once("spawn", () => {
|
|
5153
|
+
child.unref();
|
|
5154
|
+
resolve();
|
|
5155
|
+
});
|
|
5156
|
+
});
|
|
5157
|
+
console.log(`Kith menu-bar view launched for ${targetDir}.`);
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
5160
|
+
if (cmd === "watch" || cmd === "monitor") {
|
|
5161
|
+
const targetDir = positional[1] ? path19.resolve(positional[1]) : repoRoot;
|
|
5162
|
+
const notify = flags.notify !== "false" && !flags["no-notify"];
|
|
5163
|
+
const intervalMs = typeof flags.interval === "string" ? Number(flags.interval) : 2500;
|
|
5164
|
+
const isRaw = Boolean(flags.raw || flags.json);
|
|
5165
|
+
let lastScanError = "";
|
|
5166
|
+
const watcher = new PassiveConflictWatcher(targetDir, {
|
|
5167
|
+
intervalMs,
|
|
5168
|
+
notify: !isRaw && notify,
|
|
5169
|
+
onScanError: (errors) => {
|
|
5170
|
+
const message = errors.map((error) => `${error.repository}: ${error.message}`).join("\n");
|
|
5171
|
+
if (!flags.once && message !== lastScanError) console.error(`Scan unavailable:
|
|
5172
|
+
${message}`);
|
|
5173
|
+
lastScanError = message;
|
|
5174
|
+
},
|
|
5175
|
+
onCollision: (alert) => {
|
|
5176
|
+
if (!isRaw) {
|
|
5177
|
+
console.log(`
|
|
5178
|
+
\u26A0\uFE0F [WORKTREE OVERLAP] ${alert.message}`);
|
|
5179
|
+
}
|
|
5180
|
+
},
|
|
5181
|
+
onFileChange: (e) => {
|
|
5182
|
+
if (!flags.quiet && !isRaw) {
|
|
5183
|
+
console.log(`\u{1F4DD} [EDIT] ${path19.basename(e.repoPath)}/${e.filePath}`);
|
|
5184
|
+
}
|
|
5185
|
+
}
|
|
5186
|
+
});
|
|
5187
|
+
if (flags.once) {
|
|
5188
|
+
const alerts = await watcher.scanOnce();
|
|
5189
|
+
const state = watcher.getScanState();
|
|
5190
|
+
const repos = state.repos;
|
|
5191
|
+
const edits = watcher.getRecentEdits();
|
|
5192
|
+
if (!state.complete) process.exitCode = 1;
|
|
5193
|
+
if (flags.raw || flags.json) {
|
|
5194
|
+
console.log(JSON.stringify({ ...state, recentEdits: edits, collisions: alerts }, null, 2));
|
|
5195
|
+
return;
|
|
5196
|
+
}
|
|
5197
|
+
console.log(`
|
|
5198
|
+
\u{1F4E1} Kith Passive Radar: ${targetDir}`);
|
|
5199
|
+
console.log(` Repositories (${repos.length}): ${repos.map((r) => path19.basename(r)).join(", ")}`);
|
|
5200
|
+
console.log(` Recent Modified Files: ${edits.length}`);
|
|
5201
|
+
if (!state.complete) {
|
|
5202
|
+
console.error(`Scan unavailable:
|
|
5203
|
+
${state.errors.map((error) => error.message).join("\n")}`);
|
|
5204
|
+
} else if (alerts.length === 0) {
|
|
5205
|
+
console.log("\nNo differing or unverified shared dirty paths observed. Identical copies are excluded; compatibility is not verified.\n");
|
|
5206
|
+
} else {
|
|
5207
|
+
console.log(`
|
|
5208
|
+
\u26A0\uFE0F Observed worktree overlaps (${alerts.length}):`);
|
|
5209
|
+
for (const a of alerts) {
|
|
5210
|
+
console.log(` \u26A0\uFE0F ${a.message}`);
|
|
5211
|
+
console.log(` Files: ${a.filePath}`);
|
|
5212
|
+
}
|
|
5213
|
+
console.log("");
|
|
5214
|
+
}
|
|
5215
|
+
return;
|
|
5216
|
+
}
|
|
5217
|
+
console.log(`
|
|
5218
|
+
\u{1F440} Kith Passive Conflict Radar watching: ${targetDir}`);
|
|
5219
|
+
console.log(` Read-only overlap observations; this does not identify agents or verify compatibility. Press Ctrl+C to stop.
|
|
5220
|
+
`);
|
|
5221
|
+
watcher.start();
|
|
5222
|
+
await new Promise(() => {
|
|
5223
|
+
});
|
|
5224
|
+
return;
|
|
5225
|
+
}
|
|
4723
5226
|
const commands = ["session", "status", "begin", "complete", "heartbeat", "task", "lock", "contract", "worktree", "port", "mutex", "hook"];
|
|
4724
5227
|
if (!commands.includes(cmd)) throw new Error(`Unknown command '${cmd}'. Run kith help.`);
|
|
4725
5228
|
const subcommands = {
|
|
@@ -5036,7 +5539,10 @@ Usage:
|
|
|
5036
5539
|
kith uninstall [--dry-run] Restore unchanged tracked setup files; retain work and recovery state
|
|
5037
5540
|
kith doctor Check runtime, PATH, configuration, sessions and worktree health without writes
|
|
5038
5541
|
kith recover Inspect retained work, expired claims and recovery records offline
|
|
5039
|
-
kith
|
|
5542
|
+
kith setup [checkout] [--dry-run] Configure detected clients; preserve hooks and custom settings
|
|
5543
|
+
kith watch [checkout] [--once --json] Observe linked-worktree path overlaps without enforcement
|
|
5544
|
+
kith menubar [checkout] Optional read-only macOS view
|
|
5545
|
+
kith mcp --repo <checkout> Start stdio MCP server bound to a project
|
|
5040
5546
|
kith begin --agent <id> --title <t> --scope <g> Declare intent, check overlaps, lock scope
|
|
5041
5547
|
kith complete --agent <id> [--merge <branch>] Run Speculative Merge Train & release locks
|
|
5042
5548
|
kith task add|claim|list Manage orchestrated DAG task queue
|