kith-coord 0.1.2 → 0.1.4
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 +20 -0
- package/README.md +28 -2
- package/bin/kith-menubar +0 -0
- package/dist/cli/index.js +859 -123
- package/dist/cli/index.js.map +1 -1
- package/dist/command-worker.js +1 -1
- package/dist/command-worker.js.map +1 -1
- package/dist/index.d.ts +56 -15
- package/dist/index.js +110 -9
- package/dist/index.js.map +1 -1
- package/docs/client-validation.md +5 -0
- package/docs/integrations.md +16 -3
- package/docs/protocol/tools.v1.json +125 -1
- package/docs/protocol.md +10 -2
- package/docs/reliability.md +3 -0
- package/docs/roadmap.md +12 -33
- package/docs/usage.md +68 -0
- package/docs/validation/2026-10-22-product-claude.json +38 -0
- package/docs/validation/2026-10-22-product-codex.json +76 -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.4";
|
|
10
|
+
var KITH_PROTOCOL_VERSION = "1.3.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,
|
|
@@ -235,6 +243,7 @@ function uninstallKith(directory = process.cwd(), dryRun = false) {
|
|
|
235
243
|
// src/cli/doctor.ts
|
|
236
244
|
import fs5 from "fs";
|
|
237
245
|
import path5 from "path";
|
|
246
|
+
import os2 from "os";
|
|
238
247
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
239
248
|
import { parse as parseToml } from "smol-toml";
|
|
240
249
|
|
|
@@ -887,7 +896,7 @@ function recoveryReport(directory = process.cwd()) {
|
|
|
887
896
|
}
|
|
888
897
|
|
|
889
898
|
// src/cli/doctor.ts
|
|
890
|
-
function doctorReport(directory = process.cwd()) {
|
|
899
|
+
function doctorReport(directory = process.cwd(), home = os2.homedir()) {
|
|
891
900
|
const state = readCoordinationState(directory), root = state.repository.root;
|
|
892
901
|
const checks = [];
|
|
893
902
|
const check = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
@@ -912,24 +921,42 @@ function doctorReport(directory = process.cwd()) {
|
|
|
912
921
|
}
|
|
913
922
|
});
|
|
914
923
|
};
|
|
924
|
+
const desktop = process.platform === "win32" ? path5.join(home === os2.homedir() ? process.env.APPDATA ?? path5.join(home, "AppData/Roaming") : path5.join(home, "AppData/Roaming"), "Claude/claude_desktop_config.json") : process.platform === "darwin" ? path5.join(home, "Library/Application Support/Claude/claude_desktop_config.json") : path5.join(home, ".config/Claude/claude_desktop_config.json");
|
|
925
|
+
const files = [
|
|
926
|
+
["claude", path5.join(root, ".mcp.json"), false, root],
|
|
927
|
+
["cursor", path5.join(root, ".cursor/mcp.json"), false, root],
|
|
928
|
+
["codex", path5.join(root, ".codex/config.toml"), true, root],
|
|
929
|
+
["claude-global", path5.join(home, ".claude.json"), false, home],
|
|
930
|
+
["claude-desktop", desktop, false, path5.dirname(desktop)],
|
|
931
|
+
["codex-global", path5.join(home, ".codex/config.toml"), true, home],
|
|
932
|
+
["antigravity", path5.join(home, ".gemini/config/mcp_config.json"), false, home]
|
|
933
|
+
];
|
|
915
934
|
let configured = 0;
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (!fs5.existsSync(target)) continue;
|
|
935
|
+
const inspected = /* @__PURE__ */ new Set();
|
|
936
|
+
for (const [client, target, toml, base] of files) {
|
|
937
|
+
if (inspected.has(target) || !fs5.existsSync(target)) continue;
|
|
938
|
+
inspected.add(target);
|
|
919
939
|
try {
|
|
920
|
-
if (!isWithin(
|
|
940
|
+
if (!isWithin(physicalPath(base), physicalPath(target))) throw new Error("Configuration is redirected outside its setup root");
|
|
921
941
|
const text = fs5.readFileSync(target, "utf8");
|
|
922
|
-
const config =
|
|
923
|
-
const server =
|
|
942
|
+
const config = toml ? parseToml(text) : JSON.parse(text);
|
|
943
|
+
const server = toml ? config.mcp_servers?.kith : config.mcpServers?.kith;
|
|
924
944
|
if (!server) continue;
|
|
925
945
|
configured++;
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
946
|
+
if (typeof server.command !== "string" || !Array.isArray(server.args) || !server.args.every((a) => typeof a === "string") || !executable(server.command))
|
|
947
|
+
throw new Error("Kith launcher is missing or not executable; rerun setup from the installed version");
|
|
948
|
+
if (/^node(?:\.exe)?$/i.test(path5.basename(server.command)) && (!server.args[0] || !fs5.existsSync(server.args[0]) || !fs5.statSync(server.args[0]).isFile()))
|
|
949
|
+
throw new Error("Kith CLI entry is missing; rerun setup after moving or reinstalling Node/Kith");
|
|
950
|
+
const pin = server.args.indexOf("--repo");
|
|
951
|
+
if (pin >= 0 && (typeof server.args[pin + 1] !== "string" || !path5.isAbsolute(server.args[pin + 1]) || physicalPath(server.args[pin + 1]) !== root))
|
|
952
|
+
throw new Error("Kith launcher is pinned to a different checkout; use that checkout or review its setup");
|
|
953
|
+
check(`${client}-mcp`, true, "Launcher files are present; native connection, project trust and tool approval are not verified by doctor");
|
|
954
|
+
} catch (error) {
|
|
955
|
+
check(`${client}-mcp`, false, `${target}: ${error instanceof Error ? error.message : String(error)}; configuration preserved`);
|
|
929
956
|
}
|
|
930
957
|
}
|
|
931
|
-
check("mcp-configuration", configured > 0, "
|
|
932
|
-
check("coordination-state", state.state
|
|
958
|
+
check("mcp-configuration", configured > 0, "Use global kith setup or project kith init for your client; configuration alone does not establish a connection");
|
|
959
|
+
check("coordination-state", state.state !== "unavailable", state.state === "ready" ? "Schema and counters validated on a private snapshot" : state.error ?? "No project state yet; the first owned task initializes it. No state was created by doctor");
|
|
933
960
|
let nativeGuards = false;
|
|
934
961
|
for (const [file, event] of [[".claude/settings.json", "PreToolUse"], [".cursor/hooks.json", "preToolUse"]]) {
|
|
935
962
|
const target = path5.join(root, file);
|
|
@@ -988,6 +1015,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
988
1015
|
static DEFAULT_LEASE_MS = 5 * 60 * 1e3;
|
|
989
1016
|
capabilities = /* @__PURE__ */ new Map();
|
|
990
1017
|
static ownWindowsStart = null;
|
|
1018
|
+
static inspectionFailure = null;
|
|
991
1019
|
static validateLease(duration) {
|
|
992
1020
|
if (!Number.isSafeInteger(duration) || duration < 100 || duration > 864e5) {
|
|
993
1021
|
throw new Error("Lease duration must be an integer between 100 and 86400000 milliseconds");
|
|
@@ -996,6 +1024,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
996
1024
|
}
|
|
997
1025
|
/** A real kernel process identity; tests must not supply liveness sentinels. */
|
|
998
1026
|
static getProcessStartTime(pid) {
|
|
1027
|
+
this.inspectionFailure = null;
|
|
999
1028
|
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
1000
1029
|
try {
|
|
1001
1030
|
if (process.platform === "linux") {
|
|
@@ -1011,11 +1040,14 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1011
1040
|
"-NoProfile",
|
|
1012
1041
|
"-NonInteractive",
|
|
1013
1042
|
"-Command",
|
|
1014
|
-
`(
|
|
1043
|
+
`([System.Diagnostics.Process]::GetProcessById(${pid})).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)`
|
|
1015
1044
|
],
|
|
1016
|
-
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout:
|
|
1045
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3, windowsHide: true }
|
|
1017
1046
|
).trim();
|
|
1018
|
-
if (!/^\d+$/.test(ticks))
|
|
1047
|
+
if (!/^\d+$/.test(ticks)) {
|
|
1048
|
+
this.inspectionFailure = "invalid kernel creation-time response";
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1019
1051
|
const identity = `windows:${ticks}`;
|
|
1020
1052
|
if (pid === process.pid) this.ownWindowsStart = identity;
|
|
1021
1053
|
return identity;
|
|
@@ -1027,7 +1059,9 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1027
1059
|
env: { ...process.env, LC_ALL: "C" }
|
|
1028
1060
|
}).trim();
|
|
1029
1061
|
return out || null;
|
|
1030
|
-
} catch {
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
const failure = error;
|
|
1064
|
+
this.inspectionFailure = [failure.code, failure.status === void 0 ? null : `exit ${failure.status}`, failure.signal].filter(Boolean).join(", ") || "inspection unavailable";
|
|
1031
1065
|
return null;
|
|
1032
1066
|
}
|
|
1033
1067
|
}
|
|
@@ -1057,7 +1091,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1057
1091
|
if (params.sessionToken !== void 0) throw new SessionOwnershipError(params.id);
|
|
1058
1092
|
const pid = params.pid ?? process.pid;
|
|
1059
1093
|
const start = _FencingAndAgentManager.getProcessStartTime(pid);
|
|
1060
|
-
if (!start) throw new Error(`Cannot determine start time of live session owner PID ${pid}`);
|
|
1094
|
+
if (!start) throw new Error(`Cannot determine start time of live session owner PID ${pid} (${_FencingAndAgentManager.inspectionFailure ?? "inspection unavailable"})`);
|
|
1061
1095
|
const now = Date.now();
|
|
1062
1096
|
const epoch = this.coordDb.nextFencingToken();
|
|
1063
1097
|
const token = crypto2.randomBytes(32).toString("base64url");
|
|
@@ -1186,6 +1220,37 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1186
1220
|
}
|
|
1187
1221
|
};
|
|
1188
1222
|
|
|
1223
|
+
// src/core/claim-wait.ts
|
|
1224
|
+
async function waitForClaim(params) {
|
|
1225
|
+
const { waitTimeoutMs, signal } = params;
|
|
1226
|
+
if (!Number.isSafeInteger(waitTimeoutMs) || waitTimeoutMs < 0 || waitTimeoutMs > 6e5) {
|
|
1227
|
+
throw new Error("waitTimeoutMs must be an integer between 0 and 600000");
|
|
1228
|
+
}
|
|
1229
|
+
const started = performance.now();
|
|
1230
|
+
let previous;
|
|
1231
|
+
for (; ; ) {
|
|
1232
|
+
signal?.throwIfAborted();
|
|
1233
|
+
const elapsed = Math.round(performance.now() - started);
|
|
1234
|
+
if (previous && elapsed >= waitTimeoutMs) return { ...previous, waitedMs: elapsed, waitTimedOut: true };
|
|
1235
|
+
params.heartbeat();
|
|
1236
|
+
const result = params.attempt();
|
|
1237
|
+
const waitedMs = Math.round(performance.now() - started);
|
|
1238
|
+
if (result.ok || waitedMs >= waitTimeoutMs) return { ...result, waitedMs, waitTimedOut: !result.ok };
|
|
1239
|
+
previous = result;
|
|
1240
|
+
await new Promise((resolve, reject) => {
|
|
1241
|
+
const abort = () => {
|
|
1242
|
+
clearTimeout(timer);
|
|
1243
|
+
reject(signal.reason);
|
|
1244
|
+
};
|
|
1245
|
+
const timer = setTimeout(() => {
|
|
1246
|
+
signal?.removeEventListener("abort", abort);
|
|
1247
|
+
resolve();
|
|
1248
|
+
}, Math.min(100, waitTimeoutMs - waitedMs));
|
|
1249
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1189
1254
|
// src/core/engine.ts
|
|
1190
1255
|
import fs15 from "fs";
|
|
1191
1256
|
import path14 from "path";
|
|
@@ -2104,6 +2169,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2104
2169
|
const leaseMs = FencingAndAgentManager.validateLease(params.leaseDurationMs ?? FencingAndAgentManager.DEFAULT_LEASE_MS);
|
|
2105
2170
|
const now = Date.now();
|
|
2106
2171
|
const expiresAt = now + leaseMs;
|
|
2172
|
+
if (!Number.isInteger(basePort) || basePort < 1 || basePort > 65535) throw new Error("basePort must be an integer between 1 and 65535");
|
|
2107
2173
|
const resourceKey = `${params.agentId}:${params.serviceName}`;
|
|
2108
2174
|
const existing = this.getResource("port", resourceKey);
|
|
2109
2175
|
if (existing && existing.expires_at > now) {
|
|
@@ -2238,8 +2304,44 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2238
2304
|
return true;
|
|
2239
2305
|
});
|
|
2240
2306
|
}
|
|
2307
|
+
/** Launch on the allocated port, without interpreting arguments as shell code. */
|
|
2308
|
+
async executeWithPort(params) {
|
|
2309
|
+
if (!params.command.length || !params.command[0]) throw new Error("A command is required");
|
|
2310
|
+
if (!params.serviceName || params.serviceName.length > 128) throw new Error("serviceName must contain 1 to 128 characters");
|
|
2311
|
+
const mutexName = `dev-port:${params.agentId}:${params.serviceName}`;
|
|
2312
|
+
let allocation;
|
|
2313
|
+
const leaseMs = FencingAndAgentManager.validateLease(params.leaseDurationMs ?? FencingAndAgentManager.DEFAULT_LEASE_MS);
|
|
2314
|
+
const result = await this.executeGuarded({ ...params, mutexName }, {
|
|
2315
|
+
prepare: async () => {
|
|
2316
|
+
allocation = await this.acquirePort({ ...params, basePort: params.basePort ?? 3e3 });
|
|
2317
|
+
params.onPort?.(allocation.port);
|
|
2318
|
+
return {
|
|
2319
|
+
command: params.command.map((arg) => arg.replaceAll("{port}", String(allocation.port))),
|
|
2320
|
+
env: { PORT: String(allocation.port), KITH_PORT: String(allocation.port) }
|
|
2321
|
+
};
|
|
2322
|
+
},
|
|
2323
|
+
renew: () => {
|
|
2324
|
+
if (!allocation) return;
|
|
2325
|
+
this.coordDb.withTransaction(() => {
|
|
2326
|
+
this.fencing.assertSession(params.agentId);
|
|
2327
|
+
const resource = this.getResource("port", `${params.agentId}:${params.serviceName}`);
|
|
2328
|
+
if (!resource || resource.fencing_token !== allocation.fencingToken || resource.expires_at <= Date.now()) {
|
|
2329
|
+
throw new Error("Dev server port lease lost");
|
|
2330
|
+
}
|
|
2331
|
+
this.coordDb.db.prepare("UPDATE resources SET expires_at=? WHERE id=?").run(Date.now() + leaseMs, resource.id);
|
|
2332
|
+
});
|
|
2333
|
+
},
|
|
2334
|
+
release: () => {
|
|
2335
|
+
if (allocation) this.coordDb.db.prepare("DELETE FROM resources WHERE type='port' AND name=? AND fencing_token=?").run(`${params.agentId}:${params.serviceName}`, allocation.fencingToken);
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
return { ...result, port: allocation.port };
|
|
2339
|
+
}
|
|
2241
2340
|
/** Renew leases while a detached, recorded process group runs; stop it before releasing. */
|
|
2242
|
-
|
|
2341
|
+
executeWithMutex(params) {
|
|
2342
|
+
return this.executeGuarded(params);
|
|
2343
|
+
}
|
|
2344
|
+
async executeGuarded(params, lifecycle) {
|
|
2243
2345
|
if (process.platform === "win32") throw new Error("Supervised mutex commands currently require macOS or Linux process groups");
|
|
2244
2346
|
if (!params.command.length) throw new Error("A command is required");
|
|
2245
2347
|
const leaseMs = FencingAndAgentManager.validateLease(params.leaseDurationMs ?? FencingAndAgentManager.DEFAULT_LEASE_MS);
|
|
@@ -2284,6 +2386,8 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2284
2386
|
await delay(Math.min(50, Math.max(10, leaseMs / 4)));
|
|
2285
2387
|
}
|
|
2286
2388
|
const waitedMs = Date.now() - started;
|
|
2389
|
+
const prepared = await lifecycle?.prepare();
|
|
2390
|
+
if (params.signal?.aborted) throw abortError();
|
|
2287
2391
|
const candidates = ["./command-worker.js", "../command-worker.js", "../../dist/command-worker.js"].map((name) => fileURLToPath(new URL(name, import.meta.url)));
|
|
2288
2392
|
const script = candidates.find((name) => fs10.existsSync(name));
|
|
2289
2393
|
if (!script) throw new Error("Missing command worker. Build Kith before running supervised commands.");
|
|
@@ -2326,6 +2430,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2326
2430
|
try {
|
|
2327
2431
|
this.fencing.heartbeat(params.agentId);
|
|
2328
2432
|
this.renewMutex({ ...params, fencingToken: run.fencing_token });
|
|
2433
|
+
lifecycle?.renew();
|
|
2329
2434
|
} catch (err) {
|
|
2330
2435
|
stop(err instanceof Error ? err : new Error(String(err)));
|
|
2331
2436
|
}
|
|
@@ -2353,8 +2458,9 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2353
2458
|
run.worker_start = identity;
|
|
2354
2459
|
workerIdentity = identity;
|
|
2355
2460
|
this.renewMutex({ ...params, fencingToken: run.fencing_token });
|
|
2461
|
+
lifecycle?.renew();
|
|
2356
2462
|
});
|
|
2357
|
-
worker.send({ command: params.command, cwd: params.cwd ?? this.coordDb.repoRoot, mutexName: params.mutexName });
|
|
2463
|
+
worker.send({ command: prepared?.command ?? params.command, env: prepared?.env, cwd: params.cwd ?? this.coordDb.repoRoot, mutexName: params.mutexName });
|
|
2358
2464
|
} catch (err) {
|
|
2359
2465
|
stop(err instanceof Error ? err : new Error(String(err)));
|
|
2360
2466
|
}
|
|
@@ -2402,6 +2508,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2402
2508
|
this.coordDb.db.prepare("DELETE FROM mutex_waiters WHERE mutex_name=? AND request_id=? AND session_epoch=?").run(params.mutexName, requestId, epoch);
|
|
2403
2509
|
if (run && !this.runIsAlive(run)) this.clearRun(run);
|
|
2404
2510
|
if (run && run.worker_pid === null) this.clearRun(run);
|
|
2511
|
+
if (!this.getRun(params.mutexName)) lifecycle?.release();
|
|
2405
2512
|
});
|
|
2406
2513
|
} finally {
|
|
2407
2514
|
this.activeExecutions--;
|
|
@@ -3734,6 +3841,7 @@ var CoWWorktreeManager = class _CoWWorktreeManager {
|
|
|
3734
3841
|
|
|
3735
3842
|
// src/core/engine.ts
|
|
3736
3843
|
var KithEngine = class {
|
|
3844
|
+
activeClaimWaits = 0;
|
|
3737
3845
|
coordDb;
|
|
3738
3846
|
fencing;
|
|
3739
3847
|
locks;
|
|
@@ -3886,6 +3994,22 @@ ${lines.join("\n")}` : "";
|
|
|
3886
3994
|
const worktree = intent.ok && params.isolateWorktree ? this.worktrees.spawnWorktree({ agentId: params.agentId }) : null;
|
|
3887
3995
|
return { ...intent, worktree };
|
|
3888
3996
|
}
|
|
3997
|
+
async beginWorkWhenAvailable(params) {
|
|
3998
|
+
return this.waitForOwnedClaim(params, () => this.beginWork(params));
|
|
3999
|
+
}
|
|
4000
|
+
async acquireLocksWhenAvailable(params) {
|
|
4001
|
+
return this.waitForOwnedClaim(params, () => this.locks.acquireLocks(params));
|
|
4002
|
+
}
|
|
4003
|
+
async waitForOwnedClaim(params, attempt) {
|
|
4004
|
+
this.activeClaimWaits++;
|
|
4005
|
+
try {
|
|
4006
|
+
return await waitForClaim({ ...params, attempt, heartbeat: () => {
|
|
4007
|
+
this.fencing.heartbeat(params.agentId);
|
|
4008
|
+
} });
|
|
4009
|
+
} finally {
|
|
4010
|
+
this.activeClaimWaits--;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
3889
4013
|
completeWork(params) {
|
|
3890
4014
|
this.fencing.assertSession(params.agentId);
|
|
3891
4015
|
const agent = this.fencing.getAgent(params.agentId);
|
|
@@ -3906,6 +4030,7 @@ ${lines.join("\n")}` : "";
|
|
|
3906
4030
|
return { ok: true, completedTasks, mergeResult };
|
|
3907
4031
|
}
|
|
3908
4032
|
close() {
|
|
4033
|
+
if (this.activeClaimWaits) throw new Error("Await or cancel active file claim waits before closing Kith");
|
|
3909
4034
|
if (this.broker.hasActiveCommands()) throw new Error("Await or cancel active mutex commands before closing Kith");
|
|
3910
4035
|
this.coordDb.close();
|
|
3911
4036
|
}
|
|
@@ -3917,73 +4042,105 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3917
4042
|
import { z } from "zod";
|
|
3918
4043
|
import fs16 from "fs";
|
|
3919
4044
|
import path15 from "path";
|
|
3920
|
-
function
|
|
3921
|
-
const
|
|
4045
|
+
function buildKithMcpServer(repoRoot, pinned) {
|
|
4046
|
+
const pinnedRoot = pinned ? discoverRepository(repoRoot).checkoutRoot : void 0;
|
|
3922
4047
|
const server = new McpServer({
|
|
3923
4048
|
name: "kith-coordinator",
|
|
3924
4049
|
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
|
-
};
|
|
4050
|
+
}, { 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
4051
|
const disconnected = new AbortController();
|
|
3944
4052
|
const commands = /* @__PURE__ */ new Set();
|
|
3945
|
-
const
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
4053
|
+
const createContext = (root, inheritCapability) => {
|
|
4054
|
+
const engine2 = new KithEngine(root);
|
|
4055
|
+
const owned = /* @__PURE__ */ new Set();
|
|
4056
|
+
const tokenFor = (args) => args.sessionToken ?? (inheritCapability && process.env.KITH_AGENT_ID === args.agentId ? process.env.KITH_SESSION_TOKEN : void 0);
|
|
4057
|
+
const register = (args) => {
|
|
4058
|
+
const result = engine2.fencing.registerAgent({
|
|
4059
|
+
id: args.agentId,
|
|
4060
|
+
client: args.client ?? "mcp",
|
|
4061
|
+
sessionToken: tokenFor(args),
|
|
4062
|
+
leaseDurationMs: args.leaseDurationMs
|
|
4063
|
+
});
|
|
4064
|
+
if (result.agent.pid === process.pid) owned.add(args.agentId);
|
|
4065
|
+
return result;
|
|
4066
|
+
};
|
|
4067
|
+
const authorize = (args) => {
|
|
4068
|
+
const token = tokenFor(args);
|
|
4069
|
+
if (token) engine2.fencing.resumeSession(args.agentId, token);
|
|
4070
|
+
engine2.fencing.heartbeat(args.agentId);
|
|
4071
|
+
};
|
|
4072
|
+
const formatReply = (agentId, payload) => {
|
|
4073
|
+
const delta = agentId ? engine2.consumeAgentDelta(agentId).formattedFooter : "";
|
|
4074
|
+
const data = payload;
|
|
4075
|
+
return { content: [
|
|
4076
|
+
{ type: "text", text: JSON.stringify(data, null, 2) },
|
|
4077
|
+
...delta ? [{ type: "text", text: delta }] : []
|
|
4078
|
+
], structuredContent: data, isError: data.ok === false };
|
|
4079
|
+
};
|
|
4080
|
+
const close = () => {
|
|
4081
|
+
for (const id of owned) {
|
|
4082
|
+
try {
|
|
4083
|
+
engine2.fencing.abandonAgentSession(id);
|
|
4084
|
+
} catch {
|
|
4085
|
+
}
|
|
3950
4086
|
}
|
|
3951
|
-
|
|
3952
|
-
|
|
4087
|
+
engine2.close();
|
|
4088
|
+
};
|
|
4089
|
+
return { engine: engine2, register, authorize, formatReply, close };
|
|
4090
|
+
};
|
|
4091
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
4092
|
+
const contextFor = (repository) => {
|
|
4093
|
+
if (repository !== void 0 && !path15.isAbsolute(repository)) throw new Error("repository must be an absolute Git checkout path.");
|
|
4094
|
+
const target = repository ?? repoRoot;
|
|
4095
|
+
if (!target) throw new Error("No project selected. Pass repository with the absolute Git checkout path for this task.");
|
|
4096
|
+
const layout = discoverRepository(target);
|
|
4097
|
+
if (pinnedRoot && layout.checkoutRoot !== pinnedRoot) throw new Error("This connection is pinned with --repo. Use a global connection to select another repository.");
|
|
4098
|
+
if (!pinned && !layout.commonGitDir) throw new Error("Global connections require a Git checkout; do not use HOME or a parent directory as a project.");
|
|
4099
|
+
let context = contexts.get(layout.checkoutRoot);
|
|
4100
|
+
if (!context) {
|
|
4101
|
+
context = createContext(layout.checkoutRoot, layout.checkoutRoot === (pinnedRoot ?? repoRoot));
|
|
4102
|
+
contexts.set(layout.checkoutRoot, context);
|
|
4103
|
+
}
|
|
4104
|
+
return context;
|
|
4105
|
+
};
|
|
4106
|
+
const engine = pinned ? contextFor().engine : void 0;
|
|
4107
|
+
const tool = (name, description, schema, handler) => {
|
|
4108
|
+
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.") };
|
|
4109
|
+
server.tool(
|
|
4110
|
+
name,
|
|
4111
|
+
description,
|
|
4112
|
+
inputSchema,
|
|
4113
|
+
async (args, extra) => {
|
|
4114
|
+
const { repository, ...input } = args;
|
|
4115
|
+
return handler(input, extra, contextFor(repository));
|
|
4116
|
+
}
|
|
4117
|
+
);
|
|
3953
4118
|
};
|
|
3954
4119
|
let closing = false;
|
|
3955
4120
|
server.server.onclose = () => {
|
|
3956
4121
|
if (closing) return;
|
|
3957
4122
|
closing = true;
|
|
3958
4123
|
disconnected.abort();
|
|
4124
|
+
const cleanup = () => {
|
|
4125
|
+
for (const context of contexts.values()) context.close();
|
|
4126
|
+
contexts.clear();
|
|
4127
|
+
};
|
|
3959
4128
|
if (commands.size) void Promise.allSettled([...commands]).then(cleanup);
|
|
3960
4129
|
else cleanup();
|
|
3961
4130
|
};
|
|
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.", {
|
|
4131
|
+
tool("kith_start_session", "Create a capability-owned session or resume one with its token.", {
|
|
3975
4132
|
agentId: z.string(),
|
|
3976
4133
|
sessionToken: z.string().optional(),
|
|
3977
4134
|
leaseDurationMs: z.number().optional()
|
|
3978
|
-
}, async (args) => formatReply(void 0, { ok: true, ...register(args) }));
|
|
3979
|
-
|
|
4135
|
+
}, async (args, extra, { engine: engine2, register, authorize, formatReply }) => formatReply(void 0, { ok: true, ...register(args) }));
|
|
4136
|
+
tool("kith_heartbeat", "Renew an active, owned session. Expired sessions cannot be revived.", {
|
|
3980
4137
|
agentId: z.string(),
|
|
3981
4138
|
sessionToken: z.string().optional()
|
|
3982
|
-
}, async (args) => {
|
|
4139
|
+
}, async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
3983
4140
|
authorize(args);
|
|
3984
|
-
return formatReply(args.agentId, { ok: true, agent:
|
|
4141
|
+
return formatReply(args.agentId, { ok: true, agent: engine2.fencing.getAgent(args.agentId) });
|
|
3985
4142
|
});
|
|
3986
|
-
|
|
4143
|
+
tool(
|
|
3987
4144
|
"kith_begin_work",
|
|
3988
4145
|
"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
4146
|
{
|
|
@@ -3993,21 +4150,34 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
3993
4150
|
title: z.string().describe("Short title of the task or intent"),
|
|
3994
4151
|
intentSummary: z.string().optional().describe("Detailed description of what will be built or modified"),
|
|
3995
4152
|
scopeGlobs: z.array(z.string()).describe('File paths or glob patterns this agent will edit (e.g. ["src/auth/**"])'),
|
|
3996
|
-
isolateWorktree: z.boolean().optional().describe("Recommended: true, giving each parallel agent a separate Git worktree")
|
|
4153
|
+
isolateWorktree: z.boolean().optional().describe("Recommended: true, giving each parallel agent a separate Git worktree"),
|
|
4154
|
+
waitTimeoutMs: z.number().int().min(0).max(6e5).optional().describe("Wait up to this many milliseconds for the whole scope; default fail-fast, not a FIFO file queue")
|
|
3997
4155
|
},
|
|
3998
|
-
async (args) => {
|
|
4156
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
3999
4157
|
const reg = register(args);
|
|
4000
|
-
const
|
|
4158
|
+
const options = {
|
|
4001
4159
|
agentId: args.agentId,
|
|
4002
4160
|
title: args.title,
|
|
4003
4161
|
intentSummary: args.intentSummary,
|
|
4004
4162
|
scopeGlobs: args.scopeGlobs,
|
|
4005
4163
|
isolateWorktree: args.isolateWorktree
|
|
4164
|
+
};
|
|
4165
|
+
const execution = args.waitTimeoutMs === void 0 ? void 0 : engine2.beginWorkWhenAvailable({
|
|
4166
|
+
...options,
|
|
4167
|
+
waitTimeoutMs: args.waitTimeoutMs,
|
|
4168
|
+
signal: AbortSignal.any([disconnected.signal, extra.signal])
|
|
4006
4169
|
});
|
|
4007
|
-
|
|
4170
|
+
if (execution) commands.add(execution);
|
|
4171
|
+
let intentRes;
|
|
4172
|
+
try {
|
|
4173
|
+
intentRes = execution ? await execution : engine2.beginWork(options);
|
|
4174
|
+
} finally {
|
|
4175
|
+
if (execution) commands.delete(execution);
|
|
4176
|
+
}
|
|
4177
|
+
const contracts = engine2.depGraph.listContracts().slice(0, 30);
|
|
4008
4178
|
return formatReply(args.agentId, {
|
|
4009
4179
|
ok: intentRes.ok,
|
|
4010
|
-
agent:
|
|
4180
|
+
agent: engine2.fencing.getAgent(args.agentId),
|
|
4011
4181
|
sessionToken: reg.sessionToken,
|
|
4012
4182
|
fencingToken: intentRes.fencingToken,
|
|
4013
4183
|
task: intentRes.task,
|
|
@@ -4015,11 +4185,12 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4015
4185
|
worktree: intentRes.worktree,
|
|
4016
4186
|
lockConflicts: intentRes.lockConflicts,
|
|
4017
4187
|
activeSiblingIntents: intentRes.activeSiblingIntents,
|
|
4188
|
+
..."waitedMs" in intentRes && "waitTimedOut" in intentRes ? { waitedMs: intentRes.waitedMs, waitTimedOut: intentRes.waitTimedOut } : {},
|
|
4018
4189
|
publishedContracts: contracts
|
|
4019
4190
|
});
|
|
4020
4191
|
}
|
|
4021
4192
|
);
|
|
4022
|
-
|
|
4193
|
+
tool(
|
|
4023
4194
|
"kith_claim_next_task",
|
|
4024
4195
|
"Atomically claim the highest-priority unblocked task from the DAG task queue and lock its declared file scope.",
|
|
4025
4196
|
{
|
|
@@ -4027,13 +4198,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4027
4198
|
sessionToken: z.string().optional(),
|
|
4028
4199
|
client: z.string().optional()
|
|
4029
4200
|
},
|
|
4030
|
-
async (args) => {
|
|
4201
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4031
4202
|
const session = register(args);
|
|
4032
|
-
const res =
|
|
4203
|
+
const res = engine2.intents.claimNextTask(args.agentId);
|
|
4033
4204
|
return formatReply(args.agentId, { ...res, sessionToken: session.sessionToken });
|
|
4034
4205
|
}
|
|
4035
4206
|
);
|
|
4036
|
-
|
|
4207
|
+
tool(
|
|
4037
4208
|
"kith_add_task",
|
|
4038
4209
|
"Add a task to the orchestrated DAG queue with optional task dependencies and file scope globs.",
|
|
4039
4210
|
{
|
|
@@ -4044,12 +4215,12 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4044
4215
|
dependsOn: z.array(z.string()).optional(),
|
|
4045
4216
|
scopeGlobs: z.array(z.string()).optional()
|
|
4046
4217
|
},
|
|
4047
|
-
async (args) => {
|
|
4048
|
-
const task =
|
|
4218
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4219
|
+
const task = engine2.intents.addTask(args);
|
|
4049
4220
|
return formatReply(void 0, { ok: true, task });
|
|
4050
4221
|
}
|
|
4051
4222
|
);
|
|
4052
|
-
|
|
4223
|
+
tool(
|
|
4053
4224
|
"kith_check_and_lock_files",
|
|
4054
4225
|
"Acquire exclusive or shared fencing-token locks on file paths, globs, or symbols.",
|
|
4055
4226
|
{
|
|
@@ -4057,15 +4228,25 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4057
4228
|
sessionToken: z.string().optional(),
|
|
4058
4229
|
patterns: z.array(z.string()),
|
|
4059
4230
|
mode: z.enum(["exclusive", "shared"]).optional(),
|
|
4060
|
-
reason: z.string().optional()
|
|
4231
|
+
reason: z.string().optional(),
|
|
4232
|
+
waitTimeoutMs: z.number().int().min(0).max(6e5).optional()
|
|
4061
4233
|
},
|
|
4062
|
-
async (args) => {
|
|
4234
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4063
4235
|
authorize(args);
|
|
4064
|
-
const
|
|
4065
|
-
|
|
4236
|
+
const execution = args.waitTimeoutMs === void 0 ? void 0 : engine2.acquireLocksWhenAvailable({
|
|
4237
|
+
...args,
|
|
4238
|
+
waitTimeoutMs: args.waitTimeoutMs,
|
|
4239
|
+
signal: AbortSignal.any([disconnected.signal, extra.signal])
|
|
4240
|
+
});
|
|
4241
|
+
if (execution) commands.add(execution);
|
|
4242
|
+
try {
|
|
4243
|
+
return formatReply(args.agentId, execution ? await execution : engine2.locks.acquireLocks(args));
|
|
4244
|
+
} finally {
|
|
4245
|
+
if (execution) commands.delete(execution);
|
|
4246
|
+
}
|
|
4066
4247
|
}
|
|
4067
4248
|
);
|
|
4068
|
-
|
|
4249
|
+
tool(
|
|
4069
4250
|
"kith_record_file_edit",
|
|
4070
4251
|
"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
4252
|
{
|
|
@@ -4074,9 +4255,9 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4074
4255
|
filePath: z.string(),
|
|
4075
4256
|
content: z.string().optional()
|
|
4076
4257
|
},
|
|
4077
|
-
async (args) => {
|
|
4258
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4078
4259
|
authorize(args);
|
|
4079
|
-
const res =
|
|
4260
|
+
const res = engine2.processPostFileEdit({
|
|
4080
4261
|
agentId: args.agentId,
|
|
4081
4262
|
filePath: args.filePath,
|
|
4082
4263
|
content: args.content
|
|
@@ -4084,7 +4265,7 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4084
4265
|
return formatReply(args.agentId, res);
|
|
4085
4266
|
}
|
|
4086
4267
|
);
|
|
4087
|
-
|
|
4268
|
+
tool(
|
|
4088
4269
|
"kith_publish_contract",
|
|
4089
4270
|
"Explicitly publish a shared interface, function signature, API route, or schema to the Contract Blackboard.",
|
|
4090
4271
|
{
|
|
@@ -4096,13 +4277,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4096
4277
|
signature: z.string(),
|
|
4097
4278
|
docComment: z.string().optional()
|
|
4098
4279
|
},
|
|
4099
|
-
async (args) => {
|
|
4280
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4100
4281
|
authorize(args);
|
|
4101
|
-
const contract =
|
|
4282
|
+
const contract = engine2.depGraph.publishExplicitContract(args);
|
|
4102
4283
|
return formatReply(args.agentId, { ok: true, contract });
|
|
4103
4284
|
}
|
|
4104
4285
|
);
|
|
4105
|
-
|
|
4286
|
+
tool(
|
|
4106
4287
|
"kith_acquire_port",
|
|
4107
4288
|
"Reserve a local TCP port among cooperating Kith clients; start the service promptly and handle unrelated bind races.",
|
|
4108
4289
|
{
|
|
@@ -4111,13 +4292,13 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4111
4292
|
serviceName: z.string(),
|
|
4112
4293
|
basePort: z.number().optional()
|
|
4113
4294
|
},
|
|
4114
|
-
async (args) => {
|
|
4295
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4115
4296
|
authorize(args);
|
|
4116
|
-
const portRes = await
|
|
4297
|
+
const portRes = await engine2.broker.acquirePort(args);
|
|
4117
4298
|
return formatReply(args.agentId, portRes);
|
|
4118
4299
|
}
|
|
4119
4300
|
);
|
|
4120
|
-
|
|
4301
|
+
tool(
|
|
4121
4302
|
"kith_run_with_mutex",
|
|
4122
4303
|
"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
4304
|
{
|
|
@@ -4131,15 +4312,15 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4131
4312
|
commandTimeoutMs: z.number().int().min(1).max(6e5).optional(),
|
|
4132
4313
|
maxOutputBytes: z.number().int().min(1).max(1048576).optional()
|
|
4133
4314
|
},
|
|
4134
|
-
async (args, extra) => {
|
|
4315
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4135
4316
|
authorize(args);
|
|
4136
|
-
const root = fs16.realpathSync(
|
|
4317
|
+
const root = fs16.realpathSync(engine2.fencing.getAgent(args.agentId)?.worktree_path ?? engine2.coordDb.repoRoot);
|
|
4137
4318
|
const cwd = fs16.realpathSync(path15.resolve(root, args.cwd ?? "."));
|
|
4138
4319
|
const relative = path15.relative(root, cwd);
|
|
4139
4320
|
if (relative === ".." || relative.startsWith(".." + path15.sep) || path15.isAbsolute(relative)) {
|
|
4140
4321
|
throw new Error("Command cwd must be inside the registered worktree or repository");
|
|
4141
4322
|
}
|
|
4142
|
-
const execution =
|
|
4323
|
+
const execution = engine2.broker.executeWithMutex({
|
|
4143
4324
|
agentId: args.agentId,
|
|
4144
4325
|
mutexName: args.mutexName,
|
|
4145
4326
|
command: args.command,
|
|
@@ -4159,7 +4340,46 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4159
4340
|
}
|
|
4160
4341
|
}
|
|
4161
4342
|
);
|
|
4162
|
-
|
|
4343
|
+
tool(
|
|
4344
|
+
"kith_run_dev_server",
|
|
4345
|
+
"Run a literal argv command with an allocated port in PORT/KITH_PORT and substituted {port} arguments. Renews the lease while running; cancellation/disconnect stops the process group before release. Uses coordinator permissions. Requires macOS/Linux; no implicit shell or readiness guarantee.",
|
|
4346
|
+
{
|
|
4347
|
+
agentId: z.string(),
|
|
4348
|
+
sessionToken: z.string().optional(),
|
|
4349
|
+
serviceName: z.string().min(1).max(128),
|
|
4350
|
+
command: z.array(z.string()).min(1),
|
|
4351
|
+
basePort: z.number().int().min(1).max(65535).optional(),
|
|
4352
|
+
cwd: z.string().optional(),
|
|
4353
|
+
leaseDurationMs: z.number().int().min(100).max(864e5).optional(),
|
|
4354
|
+
commandTimeoutMs: z.number().int().min(1).max(6e5).optional(),
|
|
4355
|
+
maxOutputBytes: z.number().int().min(1).max(1048576).optional()
|
|
4356
|
+
},
|
|
4357
|
+
async (args, extra, { engine: engine2, authorize, formatReply }) => {
|
|
4358
|
+
authorize(args);
|
|
4359
|
+
const root = fs16.realpathSync(engine2.fencing.getAgent(args.agentId)?.worktree_path ?? engine2.coordDb.repoRoot);
|
|
4360
|
+
const cwd = fs16.realpathSync(path15.resolve(root, args.cwd ?? "."));
|
|
4361
|
+
const relative = path15.relative(root, cwd);
|
|
4362
|
+
if (relative === ".." || relative.startsWith(".." + path15.sep) || path15.isAbsolute(relative)) {
|
|
4363
|
+
throw new Error("Command cwd must be inside the registered worktree or repository");
|
|
4364
|
+
}
|
|
4365
|
+
const execution = engine2.broker.executeWithPort({
|
|
4366
|
+
...args,
|
|
4367
|
+
cwd,
|
|
4368
|
+
basePort: args.basePort ?? 3e3,
|
|
4369
|
+
commandTimeoutMs: args.commandTimeoutMs ?? 6e4,
|
|
4370
|
+
maxOutputBytes: args.maxOutputBytes ?? 65536,
|
|
4371
|
+
signal: AbortSignal.any([disconnected.signal, extra.signal])
|
|
4372
|
+
});
|
|
4373
|
+
commands.add(execution);
|
|
4374
|
+
try {
|
|
4375
|
+
const result = await execution;
|
|
4376
|
+
return formatReply(args.agentId, { ok: result.exitCode === 0, ...result });
|
|
4377
|
+
} finally {
|
|
4378
|
+
commands.delete(execution);
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
);
|
|
4382
|
+
tool(
|
|
4163
4383
|
"kith_get_status",
|
|
4164
4384
|
"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
4385
|
{
|
|
@@ -4167,9 +4387,9 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4167
4387
|
sessionToken: z.string().optional(),
|
|
4168
4388
|
includeContracts: z.boolean().optional().describe("Include the full contract registry. Default false keeps frequent status polls compact; contractCount always reports its size.")
|
|
4169
4389
|
},
|
|
4170
|
-
async (args) => {
|
|
4390
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4171
4391
|
if (args.agentId) authorize({ ...args, agentId: args.agentId });
|
|
4172
|
-
const state =
|
|
4392
|
+
const state = engine2.getStatus();
|
|
4173
4393
|
return formatReply(args.agentId, {
|
|
4174
4394
|
...state,
|
|
4175
4395
|
contracts: args.includeContracts ? state.contracts : [],
|
|
@@ -4178,16 +4398,16 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4178
4398
|
});
|
|
4179
4399
|
}
|
|
4180
4400
|
);
|
|
4181
|
-
|
|
4401
|
+
tool(
|
|
4182
4402
|
"kith_preview_integrate",
|
|
4183
4403
|
"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
4404
|
{ agentId: z.string(), sessionToken: z.string().optional() },
|
|
4185
|
-
async (args) => {
|
|
4405
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4186
4406
|
authorize(args);
|
|
4187
|
-
return formatReply(args.agentId,
|
|
4407
|
+
return formatReply(args.agentId, engine2.previewIntegration(args.agentId));
|
|
4188
4408
|
}
|
|
4189
4409
|
);
|
|
4190
|
-
|
|
4410
|
+
tool(
|
|
4191
4411
|
"kith_complete_work",
|
|
4192
4412
|
"Complete an agent task, optionally run the Speculative Merge Train to verify and merge its worktree branch, and release all locks and resources.",
|
|
4193
4413
|
{
|
|
@@ -4198,16 +4418,25 @@ function createKithMcpServer(repoRoot = process.cwd()) {
|
|
|
4198
4418
|
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
4419
|
verificationCmd: z.string().optional().describe('Command to run in speculative merge tree (e.g. "npm run typecheck")')
|
|
4200
4420
|
},
|
|
4201
|
-
async (args) => {
|
|
4421
|
+
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4202
4422
|
authorize(args);
|
|
4203
|
-
const result =
|
|
4423
|
+
const result = engine2.completeWork(args);
|
|
4204
4424
|
return formatReply(result.ok ? void 0 : args.agentId, result);
|
|
4205
4425
|
}
|
|
4206
4426
|
);
|
|
4207
4427
|
return { server, engine };
|
|
4208
4428
|
}
|
|
4209
|
-
async function runKithMcpStdioServer(repoRoot = process.cwd()) {
|
|
4210
|
-
|
|
4429
|
+
async function runKithMcpStdioServer(repoRoot = process.cwd(), pinned = true) {
|
|
4430
|
+
let defaultRoot = repoRoot;
|
|
4431
|
+
if (!pinned) {
|
|
4432
|
+
try {
|
|
4433
|
+
const layout = discoverRepository(repoRoot);
|
|
4434
|
+
defaultRoot = layout.commonGitDir ? layout.checkoutRoot : void 0;
|
|
4435
|
+
} catch {
|
|
4436
|
+
defaultRoot = void 0;
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
const { server } = buildKithMcpServer(defaultRoot, pinned);
|
|
4211
4440
|
const transport = new StdioServerTransport();
|
|
4212
4441
|
let closing = false;
|
|
4213
4442
|
const close = () => {
|
|
@@ -4557,7 +4786,7 @@ Instruction protocol: ${KITH_PROTOCOL_VERSION}.
|
|
|
4557
4786
|
|
|
4558
4787
|
Use Kith MCP to claim work, isolate changes in worktrees, share local resources and verify integration. Keep one isolated worktree per parallel agent and keep the owning MCP connection open.
|
|
4559
4788
|
|
|
4560
|
-
1. Call \`kith_begin_work\` with a unique \`agentId\`, an explicit \`scopeGlobs\` array and \`isolateWorktree: true\`.
|
|
4789
|
+
1. Select the absolute Git repository path for this task. Include \`repository\` on calls from a global desktop connection; a pinned connection must stay in its configured checkout. Call \`kith_begin_work\` with a unique \`agentId\`, an explicit \`scopeGlobs\` array and \`isolateWorktree: true\`.
|
|
4561
4790
|
2. Edit only the returned \`worktree.worktreePath\`. Report changed files through \`kith_record_file_edit\` on that connection.
|
|
4562
4791
|
3. Run serialized commands with \`kith_run_with_mutex\`: pass a literal executable/argument array. Its cwd defaults to the registered worktree. Commands run with server permissions and require client approval.
|
|
4563
4792
|
4. Renew idle sessions with \`kith_heartbeat\` before their lease expires (every two minutes for the default five-minute lease; sooner for a shorter lease). Inspect \`kith_get_status\`.
|
|
@@ -4572,7 +4801,7 @@ If Claude/Cursor edit hooks or CLI commands are also enabled, launch the client
|
|
|
4572
4801
|
}
|
|
4573
4802
|
content = content.includes("<!-- KITH:START -->") ? content.replace(/<!-- KITH:START -->[\s\S]*?<!-- KITH:END -->/, () => block) : content.trimEnd() + "\n\n" + block + "\n";
|
|
4574
4803
|
add("AGENTS.md", content);
|
|
4575
|
-
add(".agent/skills/kith/SKILL.md", '---\nname: kith\ndescription: Coordinate parallel coding work through Kith MCP and isolated worktrees.\n---\n\nPrefer the Kith MCP tools on one persistent connection. Begin with kith_begin_work using a unique agentId, explicit scopeGlobs and isolateWorktree:true. Edit the returned worktreePath, report files with kith_record_file_edit, run literal command arrays with kith_run_with_mutex, heartbeat before expiry and finish with kith_complete_work plus a project verificationCmd. Inspect kith_get_status. Keep capabilities private. If CLI or native hooks are enabled, first launch the client from a persistent shell with the same KITH_AGENT_ID/KITH_SESSION_TOKEN and --owner-pid "$$". Stateless shell calls cannot retain that setup. Locks require cooperating clients.\n');
|
|
4804
|
+
add(".agent/skills/kith/SKILL.md", '---\nname: kith\ndescription: Coordinate parallel coding work through Kith MCP and isolated worktrees.\n---\n\nPrefer the Kith MCP tools on one persistent connection. Begin with kith_begin_work using a unique agentId, explicit scopeGlobs and isolateWorktree:true. Edit the returned worktreePath, report files with kith_record_file_edit, run literal command arrays with kith_run_with_mutex, heartbeat before expiry and finish with kith_complete_work plus a project verificationCmd. Inspect kith_get_status. Keep capabilities private. If CLI or native hooks are enabled, first launch the client from a persistent shell with the same KITH_AGENT_ID/KITH_SESSION_TOKEN and --owner-pid "$$". Stateless shell calls cannot retain that setup. For global desktop connections, include the absolute Git repository in each call; never guess from the server startup directory. Configured tools do not establish ownership until begin-work succeeds. Locks require cooperating clients.\n');
|
|
4576
4805
|
}
|
|
4577
4806
|
if (selected.has("git")) {
|
|
4578
4807
|
try {
|
|
@@ -4642,6 +4871,357 @@ esac
|
|
|
4642
4871
|
return report;
|
|
4643
4872
|
}
|
|
4644
4873
|
|
|
4874
|
+
// src/core/watcher.ts
|
|
4875
|
+
import fs19 from "fs";
|
|
4876
|
+
import path17 from "path";
|
|
4877
|
+
import { createHash } from "crypto";
|
|
4878
|
+
import { execFile, execFileSync as execFileSync10 } from "child_process";
|
|
4879
|
+
import { promisify } from "util";
|
|
4880
|
+
var execFileAsync = promisify(execFile);
|
|
4881
|
+
function contentIdentity(repo, file) {
|
|
4882
|
+
const target = path17.join(repo, file);
|
|
4883
|
+
let stat;
|
|
4884
|
+
try {
|
|
4885
|
+
stat = fs19.lstatSync(target);
|
|
4886
|
+
} catch (error) {
|
|
4887
|
+
if (error.code === "ENOENT") return "absent";
|
|
4888
|
+
throw error;
|
|
4889
|
+
}
|
|
4890
|
+
const parent = fs19.realpathSync(path17.dirname(target));
|
|
4891
|
+
const relative = path17.relative(repo, parent);
|
|
4892
|
+
if (relative === ".." || relative.startsWith(`..${path17.sep}`) || path17.isAbsolute(relative)) throw new Error(`Path redirected outside checkout: ${file}`);
|
|
4893
|
+
if (stat.isSymbolicLink()) return `link:${fs19.readlinkSync(target)}`;
|
|
4894
|
+
if (!stat.isFile() || stat.size > 8 * 1024 * 1024) return null;
|
|
4895
|
+
const fd = fs19.openSync(target, fs19.constants.O_RDONLY | (fs19.constants.O_NOFOLLOW ?? 0));
|
|
4896
|
+
try {
|
|
4897
|
+
const before = fs19.fstatSync(fd);
|
|
4898
|
+
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}`);
|
|
4899
|
+
const hash = createHash("sha256");
|
|
4900
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
4901
|
+
for (let offset = 0; offset < before.size; ) {
|
|
4902
|
+
const count = fs19.readSync(fd, buffer, 0, Math.min(buffer.length, before.size - offset), offset);
|
|
4903
|
+
if (!count) throw new Error(`File changed during comparison: ${file}`);
|
|
4904
|
+
hash.update(buffer.subarray(0, count));
|
|
4905
|
+
offset += count;
|
|
4906
|
+
}
|
|
4907
|
+
const after = fs19.fstatSync(fd);
|
|
4908
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`File changed during comparison: ${file}`);
|
|
4909
|
+
return `file:${before.mode & 73}:${hash.digest("hex")}`;
|
|
4910
|
+
} finally {
|
|
4911
|
+
fs19.closeSync(fd);
|
|
4912
|
+
}
|
|
4913
|
+
}
|
|
4914
|
+
var PassiveConflictWatcher = class _PassiveConflictWatcher {
|
|
4915
|
+
constructor(rootDir, options = {}) {
|
|
4916
|
+
this.rootDir = rootDir;
|
|
4917
|
+
this.options = options;
|
|
4918
|
+
if (options.intervalMs !== void 0 && (!Number.isFinite(options.intervalMs) || options.intervalMs < 100)) throw new Error("Watch interval must be at least 100ms.");
|
|
4919
|
+
}
|
|
4920
|
+
rootDir;
|
|
4921
|
+
options;
|
|
4922
|
+
isWatching = false;
|
|
4923
|
+
watchTimer = null;
|
|
4924
|
+
recentEdits = /* @__PURE__ */ new Map();
|
|
4925
|
+
activeCollisions = /* @__PURE__ */ new Map();
|
|
4926
|
+
repositories = /* @__PURE__ */ new Map();
|
|
4927
|
+
errors = [];
|
|
4928
|
+
static notifyMac(title, message) {
|
|
4929
|
+
if (process.platform !== "darwin") return;
|
|
4930
|
+
try {
|
|
4931
|
+
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 });
|
|
4932
|
+
} catch {
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
discoverGitRepos() {
|
|
4936
|
+
this.errors = [];
|
|
4937
|
+
this.repositories.clear();
|
|
4938
|
+
const candidates = [];
|
|
4939
|
+
const marker = (dir) => {
|
|
4940
|
+
try {
|
|
4941
|
+
fs19.lstatSync(path17.join(dir, ".git"));
|
|
4942
|
+
return true;
|
|
4943
|
+
} catch (error) {
|
|
4944
|
+
if (error.code === "ENOENT") return false;
|
|
4945
|
+
throw error;
|
|
4946
|
+
}
|
|
4947
|
+
};
|
|
4948
|
+
try {
|
|
4949
|
+
const root = fs19.realpathSync(this.rootDir);
|
|
4950
|
+
if (marker(root)) candidates.push(root);
|
|
4951
|
+
for (const entry of fs19.readdirSync(root, { withFileTypes: true })) {
|
|
4952
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
4953
|
+
const sub = path17.join(root, entry.name);
|
|
4954
|
+
if (marker(sub)) candidates.push(sub);
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
} catch (error) {
|
|
4958
|
+
this.errors.push({ repository: this.rootDir, message: `Cannot discover repositories: ${error instanceof Error ? error.message : String(error)}` });
|
|
4959
|
+
}
|
|
4960
|
+
const inspected = /* @__PURE__ */ new Set();
|
|
4961
|
+
for (const candidate of candidates) {
|
|
4962
|
+
try {
|
|
4963
|
+
const layout = discoverRepository(candidate);
|
|
4964
|
+
if (!layout.commonGitDir) throw new Error("Git repository unavailable");
|
|
4965
|
+
this.repositories.set(layout.checkoutRoot, layout.commonGitDir);
|
|
4966
|
+
if (inspected.has(layout.commonGitDir)) continue;
|
|
4967
|
+
inspected.add(layout.commonGitDir);
|
|
4968
|
+
const listing = execFileSync10("git", ["worktree", "list", "--porcelain", "-z"], { cwd: layout.checkoutRoot, encoding: "utf8", timeout: 4e3, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
4969
|
+
for (const record of listing.split("\0")) if (record.startsWith("worktree ")) {
|
|
4970
|
+
const linked = discoverRepository(record.slice(9));
|
|
4971
|
+
if (linked.commonGitDir !== layout.commonGitDir) throw new Error("Worktree repository identity changed during discovery");
|
|
4972
|
+
this.repositories.set(linked.checkoutRoot, layout.commonGitDir);
|
|
4973
|
+
}
|
|
4974
|
+
} catch (error) {
|
|
4975
|
+
this.errors.push({ repository: candidate, message: `Cannot inspect repository: ${error instanceof Error ? error.message : String(error)}` });
|
|
4976
|
+
}
|
|
4977
|
+
}
|
|
4978
|
+
if (!candidates.length && !this.errors.length) this.errors.push({ repository: this.rootDir, message: "No Git repositories found in this directory or its immediate children." });
|
|
4979
|
+
return [...this.repositories.keys()];
|
|
4980
|
+
}
|
|
4981
|
+
async scanOnce() {
|
|
4982
|
+
const repos = this.discoverGitRepos();
|
|
4983
|
+
const now = Date.now();
|
|
4984
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4985
|
+
const overlaps = /* @__PURE__ */ new Map();
|
|
4986
|
+
for (const repo of repos) {
|
|
4987
|
+
try {
|
|
4988
|
+
const { stdout } = await execFileAsync("git", ["-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: repo, timeout: 4e3, maxBuffer: 4 * 1024 * 1024 });
|
|
4989
|
+
const records = stdout.split("\0");
|
|
4990
|
+
const dirty = /* @__PURE__ */ new Map();
|
|
4991
|
+
for (let i = 0; i < records.length; i++) {
|
|
4992
|
+
const record = records[i];
|
|
4993
|
+
if (!record) continue;
|
|
4994
|
+
const status = record.slice(0, 2);
|
|
4995
|
+
dirty.set(record.slice(3), status);
|
|
4996
|
+
if (/[RC]/.test(status)) dirty.set(records[++i], status);
|
|
4997
|
+
}
|
|
4998
|
+
for (const [file, status] of dirty) {
|
|
4999
|
+
if (!file || file === ".coord" || file.startsWith(".coord/")) continue;
|
|
5000
|
+
const key = JSON.stringify([repo, file]);
|
|
5001
|
+
seen.add(key);
|
|
5002
|
+
let modified, fingerprint = status;
|
|
5003
|
+
try {
|
|
5004
|
+
const stat = fs19.lstatSync(path17.join(repo, file));
|
|
5005
|
+
if (Number.isFinite(stat.mtimeMs) && stat.mtimeMs <= now) modified = stat.mtimeMs;
|
|
5006
|
+
fingerprint += `:${stat.mtimeMs}:${stat.size}`;
|
|
5007
|
+
} catch (error) {
|
|
5008
|
+
if (error.code !== "ENOENT") throw error;
|
|
5009
|
+
}
|
|
5010
|
+
const previous = this.recentEdits.get(key);
|
|
5011
|
+
if (modified === void 0) this.recentEdits.delete(key);
|
|
5012
|
+
else if (!previous || previous.fingerprint !== fingerprint) {
|
|
5013
|
+
this.recentEdits.set(key, { repo, fingerprint, timestamp: modified });
|
|
5014
|
+
this.options.onFileChange?.({ repoPath: repo, filePath: file, timestamp: modified });
|
|
5015
|
+
}
|
|
5016
|
+
const identity = JSON.stringify([this.repositories.get(repo), file]);
|
|
5017
|
+
const overlap = overlaps.get(identity) ?? { file, repos: [] };
|
|
5018
|
+
overlap.repos.push(repo);
|
|
5019
|
+
overlaps.set(identity, overlap);
|
|
5020
|
+
}
|
|
5021
|
+
} catch (error) {
|
|
5022
|
+
this.errors.push({ repository: repo, message: `Git scan unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
if (!this.errors.length) {
|
|
5026
|
+
for (const key of this.recentEdits.keys()) if (!seen.has(key)) this.recentEdits.delete(key);
|
|
5027
|
+
}
|
|
5028
|
+
const current = /* @__PURE__ */ new Map();
|
|
5029
|
+
for (const [id, overlap] of overlaps) if (overlap.repos.length > 1) {
|
|
5030
|
+
let identical = false;
|
|
5031
|
+
try {
|
|
5032
|
+
const identities = overlap.repos.map((repo) => contentIdentity(repo, overlap.file));
|
|
5033
|
+
identical = identities.every((value) => value !== null && value === identities[0]);
|
|
5034
|
+
} catch (error) {
|
|
5035
|
+
this.errors.push({ repository: overlap.repos.join(", "), message: `Cannot compare ${overlap.file}: ${error instanceof Error ? error.message : String(error)}` });
|
|
5036
|
+
}
|
|
5037
|
+
if (identical) continue;
|
|
5038
|
+
const previous = this.activeCollisions.get(id);
|
|
5039
|
+
const alert = {
|
|
5040
|
+
id,
|
|
5041
|
+
filePath: overlap.file,
|
|
5042
|
+
repos: overlap.repos,
|
|
5043
|
+
severity: "collision",
|
|
5044
|
+
timestamp: previous?.timestamp ?? now,
|
|
5045
|
+
message: `Shared dirty path '${overlap.file}' has different or unverified contents in linked worktrees: ${overlap.repos.join(", ")}. This is not a proven merge conflict.`
|
|
5046
|
+
};
|
|
5047
|
+
current.set(id, alert);
|
|
5048
|
+
if (!previous) {
|
|
5049
|
+
this.options.onCollision?.(alert);
|
|
5050
|
+
if (this.options.notify) _PassiveConflictWatcher.notifyMac("Kith worktree overlap", alert.message);
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
this.activeCollisions = this.errors.length ? new Map([...this.activeCollisions, ...current]) : current;
|
|
5054
|
+
if (this.errors.length) this.options.onScanError?.(this.errors);
|
|
5055
|
+
return this.getActiveCollisions();
|
|
5056
|
+
}
|
|
5057
|
+
start() {
|
|
5058
|
+
if (this.isWatching) return;
|
|
5059
|
+
this.isWatching = true;
|
|
5060
|
+
const tick = async () => {
|
|
5061
|
+
try {
|
|
5062
|
+
await this.scanOnce();
|
|
5063
|
+
} catch (error) {
|
|
5064
|
+
this.errors = [{ repository: this.rootDir, message: String(error) }];
|
|
5065
|
+
this.options.onScanError?.(this.errors);
|
|
5066
|
+
}
|
|
5067
|
+
if (this.isWatching) this.watchTimer = setTimeout(tick, this.options.intervalMs ?? 3e3);
|
|
5068
|
+
};
|
|
5069
|
+
void tick();
|
|
5070
|
+
}
|
|
5071
|
+
stop() {
|
|
5072
|
+
this.isWatching = false;
|
|
5073
|
+
if (this.watchTimer) clearTimeout(this.watchTimer);
|
|
5074
|
+
this.watchTimer = null;
|
|
5075
|
+
}
|
|
5076
|
+
getScanState() {
|
|
5077
|
+
return { complete: this.errors.length === 0, errors: [...this.errors], repos: [...this.repositories.keys()] };
|
|
5078
|
+
}
|
|
5079
|
+
getActiveCollisions() {
|
|
5080
|
+
return [...this.activeCollisions.values()];
|
|
5081
|
+
}
|
|
5082
|
+
getRecentEdits() {
|
|
5083
|
+
const now = Date.now();
|
|
5084
|
+
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);
|
|
5085
|
+
}
|
|
5086
|
+
};
|
|
5087
|
+
|
|
5088
|
+
// src/cli/setup-auto.ts
|
|
5089
|
+
import fs21 from "fs";
|
|
5090
|
+
import path18 from "path";
|
|
5091
|
+
import os3 from "os";
|
|
5092
|
+
import { parse as parseToml3, stringify as stringifyToml2 } from "smol-toml";
|
|
5093
|
+
|
|
5094
|
+
// src/cli/runtime.ts
|
|
5095
|
+
import fs20 from "fs";
|
|
5096
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
5097
|
+
function installedCliPath() {
|
|
5098
|
+
for (const relative of ["./index.js", "../../dist/cli/index.js"]) {
|
|
5099
|
+
const file = fileURLToPath3(new URL(relative, import.meta.url));
|
|
5100
|
+
if (fs20.existsSync(file)) return fs20.realpathSync(file);
|
|
5101
|
+
}
|
|
5102
|
+
throw new Error("Build or install Kith before configuring clients.");
|
|
5103
|
+
}
|
|
5104
|
+
|
|
5105
|
+
// src/cli/setup-auto.ts
|
|
5106
|
+
function object2(value, label) {
|
|
5107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object; no configuration was written.`);
|
|
5108
|
+
return value;
|
|
5109
|
+
}
|
|
5110
|
+
function ownedLauncher(value) {
|
|
5111
|
+
if (value.type !== void 0 && value.type !== "stdio") return false;
|
|
5112
|
+
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;
|
|
5113
|
+
try {
|
|
5114
|
+
const entry = fs21.realpathSync(value.args[0]);
|
|
5115
|
+
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";
|
|
5116
|
+
} catch {
|
|
5117
|
+
return false;
|
|
5118
|
+
}
|
|
5119
|
+
}
|
|
5120
|
+
function autoConfigureAllClients(targetWorkspace = process.cwd(), home = os3.homedir(), options = {}) {
|
|
5121
|
+
const target = physicalPath(targetWorkspace);
|
|
5122
|
+
const layout = target === path18.parse(target).root ? void 0 : discoverRepository(target);
|
|
5123
|
+
const workspace = layout?.checkoutRoot ?? target;
|
|
5124
|
+
home = physicalPath(home);
|
|
5125
|
+
const server = { command: process.execPath, args: [installedCliPath(), "mcp"] };
|
|
5126
|
+
const projectServer = { ...server, args: [...server.args, "--repo", workspace] };
|
|
5127
|
+
const results = [];
|
|
5128
|
+
const groups = /* @__PURE__ */ new Map();
|
|
5129
|
+
const warnings = [];
|
|
5130
|
+
const queue = (root, file, content) => {
|
|
5131
|
+
if (!isWithin(root, file)) throw new Error(`Configuration is outside its setup root: ${file}`);
|
|
5132
|
+
if (fs21.existsSync(file) && fs21.readFileSync(file, "utf8") === content) return;
|
|
5133
|
+
const group = groups.get(root) ?? { coordDir: root === workspace && layout ? layout.coordDir : path18.join(root, ".coord"), plan: /* @__PURE__ */ new Map() };
|
|
5134
|
+
if (physicalPath(group.coordDir) !== group.coordDir) throw new Error("Setup backup directory must not be redirected.");
|
|
5135
|
+
group.plan.set(path18.relative(root, file), { content, mode: 384 });
|
|
5136
|
+
groups.set(root, group);
|
|
5137
|
+
};
|
|
5138
|
+
const safePath = (file) => {
|
|
5139
|
+
if (physicalPath(file) !== file) throw new Error(`Configuration path is redirected: ${file}; no configuration was written.`);
|
|
5140
|
+
};
|
|
5141
|
+
const jsonClient = (clientName, file, root, found, projectScoped = false) => {
|
|
5142
|
+
const selectedServer = projectScoped ? projectServer : server;
|
|
5143
|
+
if (!found) {
|
|
5144
|
+
results.push({ clientName, configPath: file, found: false, configured: false, notes: "No existing client configuration found" });
|
|
5145
|
+
return;
|
|
5146
|
+
}
|
|
5147
|
+
safePath(file);
|
|
5148
|
+
const cfg = fs21.existsSync(file) ? object2(JSON.parse(fs21.readFileSync(file, "utf8")), file) : {};
|
|
5149
|
+
const servers = object2(cfg.mcpServers ?? {}, `${file} mcpServers`);
|
|
5150
|
+
const previous = servers.kith === void 0 ? void 0 : object2(servers.kith, `${file} kith`);
|
|
5151
|
+
const exact = previous?.command === selectedServer.command && JSON.stringify(previous.args) === JSON.stringify(selectedServer.args);
|
|
5152
|
+
if (previous && !exact && !ownedLauncher(previous)) {
|
|
5153
|
+
results.push({ clientName, configPath: file, found: true, configured: false, notes: "Custom Kith server preserved; review its repository and launcher manually" });
|
|
5154
|
+
return;
|
|
5155
|
+
}
|
|
5156
|
+
cfg.mcpServers = { ...servers, kith: { ...previous, ...selectedServer } };
|
|
5157
|
+
queue(root, file, JSON.stringify(cfg, null, 2) + "\n");
|
|
5158
|
+
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` });
|
|
5159
|
+
};
|
|
5160
|
+
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");
|
|
5161
|
+
jsonClient("Claude Desktop App", desktop, home, fs21.existsSync(desktop));
|
|
5162
|
+
jsonClient(
|
|
5163
|
+
"Claude Code CLI",
|
|
5164
|
+
path18.join(home, ".claude.json"),
|
|
5165
|
+
home,
|
|
5166
|
+
fs21.existsSync(path18.join(home, ".claude.json")) || fs21.existsSync(path18.join(home, ".claude"))
|
|
5167
|
+
);
|
|
5168
|
+
const codex = path18.join(home, ".codex/config.toml");
|
|
5169
|
+
if (fs21.existsSync(path18.dirname(codex))) {
|
|
5170
|
+
safePath(codex);
|
|
5171
|
+
const original = fs21.existsSync(codex) ? fs21.readFileSync(codex, "utf8") : "";
|
|
5172
|
+
const cfg = parseToml3(original), servers = object2(cfg.mcp_servers ?? {}, "Codex MCP servers");
|
|
5173
|
+
const previous = servers.kith === void 0 ? void 0 : object2(servers.kith, "Codex Kith server");
|
|
5174
|
+
let content = original;
|
|
5175
|
+
let configured = true;
|
|
5176
|
+
const exact = previous?.command === server.command && JSON.stringify(previous.args) === JSON.stringify(server.args);
|
|
5177
|
+
if (!previous) {
|
|
5178
|
+
content += (content && !content.endsWith("\n") ? "\n" : "") + "\n" + stringifyToml2({ mcp_servers: { kith: { ...server, env_vars: ["KITH_AGENT_ID", "KITH_SESSION_TOKEN"] } } });
|
|
5179
|
+
} else if (!exact) {
|
|
5180
|
+
const block = /(^\[mcp_servers\.kith\][^\S\r\n]*\r?\n)([\s\S]*?)(?=^\[|$(?![\s\S]))/m;
|
|
5181
|
+
const match = original.match(block);
|
|
5182
|
+
if (ownedLauncher(previous) && match && /^command\s*=\s*"(?:[^"\\\r\n]|\\.)*"\s*$/m.test(match[2]) && /^args\s*=\s*\[[^\r\n]*\]\s*$/m.test(match[2])) {
|
|
5183
|
+
const fields = [`command = ${JSON.stringify(server.command)}`, `args = ${JSON.stringify(server.args)}`];
|
|
5184
|
+
const updated = match[2].replace(/^command\s*=.*$/m, fields[0]).replace(/^args\s*=.*$/m, fields[1]);
|
|
5185
|
+
content = original.replace(block, () => match[1] + updated);
|
|
5186
|
+
} else configured = false;
|
|
5187
|
+
}
|
|
5188
|
+
parseToml3(content);
|
|
5189
|
+
if (configured) queue(home, codex, content);
|
|
5190
|
+
results.push({
|
|
5191
|
+
clientName: "Codex",
|
|
5192
|
+
found: true,
|
|
5193
|
+
configPath: codex,
|
|
5194
|
+
configured,
|
|
5195
|
+
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"
|
|
5196
|
+
});
|
|
5197
|
+
}
|
|
5198
|
+
jsonClient("Cursor", path18.join(workspace, ".cursor/mcp.json"), workspace, Boolean(layout?.commonGitDir), true);
|
|
5199
|
+
const antigravity = path18.join(home, ".gemini/config/mcp_config.json");
|
|
5200
|
+
jsonClient("Antigravity", antigravity, home, fs21.existsSync(path18.dirname(antigravity)));
|
|
5201
|
+
for (const [root] of groups) {
|
|
5202
|
+
if (!fs21.existsSync(path18.join(root, ".git"))) continue;
|
|
5203
|
+
const file = path18.join(root, ".gitignore");
|
|
5204
|
+
safePath(file);
|
|
5205
|
+
const original = fs21.existsSync(file) ? fs21.readFileSync(file, "utf8") : "";
|
|
5206
|
+
if (!original.split(/\r?\n/).some((line) => [".coord/", "/.coord/"].includes(line.trim()))) {
|
|
5207
|
+
queue(root, file, original + (original && !original.endsWith("\n") ? "\n" : "") + "\n# Kith private setup backups\n.coord/\n");
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
5210
|
+
if (!options.dryRun && groups.size) {
|
|
5211
|
+
const engine = layout?.commonGitDir ? new KithEngine(workspace) : void 0;
|
|
5212
|
+
try {
|
|
5213
|
+
for (const [root, group] of groups) {
|
|
5214
|
+
fs21.mkdirSync(group.coordDir, { recursive: true, mode: 448 });
|
|
5215
|
+
applySetupPlan(root, group.coordDir, group.plan, warnings);
|
|
5216
|
+
}
|
|
5217
|
+
} finally {
|
|
5218
|
+
engine?.close();
|
|
5219
|
+
}
|
|
5220
|
+
}
|
|
5221
|
+
for (const warning of warnings) results.push({ clientName: "Setup backup", found: true, configured: false, configPath: "", notes: warning });
|
|
5222
|
+
return results;
|
|
5223
|
+
}
|
|
5224
|
+
|
|
4645
5225
|
// src/cli/index.ts
|
|
4646
5226
|
function parseFlags(argv) {
|
|
4647
5227
|
const dashDashIdx = argv.indexOf("--");
|
|
@@ -4658,7 +5238,7 @@ function parseFlags(argv) {
|
|
|
4658
5238
|
} else {
|
|
4659
5239
|
const key = arg.slice(2);
|
|
4660
5240
|
const next = mainArgs[i + 1];
|
|
4661
|
-
if (!["worktree", "raw", "dry-run", "help", "version", "shell", "new", "mcp-only", "offline"].includes(key) && next && !next.startsWith("--")) {
|
|
5241
|
+
if (!["worktree", "wait", "raw", "dry-run", "help", "version", "shell", "new", "mcp-only", "offline", "once", "json", "quiet", "no-notify"].includes(key) && next && !next.startsWith("--")) {
|
|
4662
5242
|
flags[key] = next;
|
|
4663
5243
|
i++;
|
|
4664
5244
|
} else {
|
|
@@ -4671,11 +5251,40 @@ function parseFlags(argv) {
|
|
|
4671
5251
|
}
|
|
4672
5252
|
return { positional, flags, passthroughCmd };
|
|
4673
5253
|
}
|
|
5254
|
+
async function interruptible(run) {
|
|
5255
|
+
const controller = new AbortController();
|
|
5256
|
+
let interrupted;
|
|
5257
|
+
const sigint = () => {
|
|
5258
|
+
interrupted = "SIGINT";
|
|
5259
|
+
controller.abort();
|
|
5260
|
+
};
|
|
5261
|
+
const sigterm = () => {
|
|
5262
|
+
interrupted = "SIGTERM";
|
|
5263
|
+
controller.abort();
|
|
5264
|
+
};
|
|
5265
|
+
process.on("SIGINT", sigint);
|
|
5266
|
+
process.on("SIGTERM", sigterm);
|
|
5267
|
+
try {
|
|
5268
|
+
return await run(controller.signal);
|
|
5269
|
+
} catch (err) {
|
|
5270
|
+
if (!interrupted) throw err;
|
|
5271
|
+
process.exitCode = interrupted === "SIGINT" ? 130 : 143;
|
|
5272
|
+
} finally {
|
|
5273
|
+
process.off("SIGINT", sigint);
|
|
5274
|
+
process.off("SIGTERM", sigterm);
|
|
5275
|
+
}
|
|
5276
|
+
}
|
|
4674
5277
|
async function main() {
|
|
4675
5278
|
const { positional, flags, passthroughCmd } = parseFlags(process.argv.slice(2));
|
|
4676
5279
|
const cmd = positional[0] ?? "help";
|
|
4677
5280
|
const subCmd = positional[1];
|
|
4678
|
-
const
|
|
5281
|
+
const claimWaitTimeout = () => {
|
|
5282
|
+
const value = flags["wait-ms"];
|
|
5283
|
+
if (value !== void 0 && typeof value !== "string") throw new Error("--wait-ms requires a millisecond value");
|
|
5284
|
+
return value === void 0 ? 6e4 : Number(value);
|
|
5285
|
+
};
|
|
5286
|
+
const targetFromPositional = ["init", "setup", "watch", "menubar", "bar", "tray", "doctor", "recover"].includes(cmd) && positional[1] ? path19.resolve(positional[1]) : void 0;
|
|
5287
|
+
const repoRoot = typeof flags.repo === "string" ? path19.resolve(flags.repo) : targetFromPositional ?? (cmd === "mcp" ? process.env.CLAUDE_PROJECT_DIR : void 0) ?? process.cwd();
|
|
4679
5288
|
if (flags.version || cmd === "version") {
|
|
4680
5289
|
console.log(KITH_VERSION);
|
|
4681
5290
|
return;
|
|
@@ -4699,7 +5308,7 @@ async function main() {
|
|
|
4699
5308
|
return;
|
|
4700
5309
|
}
|
|
4701
5310
|
if (cmd === "mcp") {
|
|
4702
|
-
await runKithMcpStdioServer(repoRoot);
|
|
5311
|
+
await runKithMcpStdioServer(repoRoot, typeof flags.repo === "string");
|
|
4703
5312
|
return;
|
|
4704
5313
|
}
|
|
4705
5314
|
if (cmd === "uninstall") {
|
|
@@ -4720,7 +5329,103 @@ async function main() {
|
|
|
4720
5329
|
process.exitCode = state.state === "unavailable" ? 1 : 0;
|
|
4721
5330
|
return;
|
|
4722
5331
|
}
|
|
4723
|
-
|
|
5332
|
+
if (cmd === "setup" || cmd === "auto-detect") {
|
|
5333
|
+
const targetDir = positional[1] ? path19.resolve(positional[1]) : repoRoot;
|
|
5334
|
+
const results = autoConfigureAllClients(targetDir, void 0, { dryRun: Boolean(flags["dry-run"]) });
|
|
5335
|
+
console.log(`
|
|
5336
|
+
\u2728 Kith Auto-Detect & Setup for ${targetDir}
|
|
5337
|
+
`);
|
|
5338
|
+
for (const r of results) {
|
|
5339
|
+
const statusIcon = r.configured ? "\u2713" : r.found ? "!" : "\u2717";
|
|
5340
|
+
console.log(` ${statusIcon} ${r.clientName.padEnd(24)} -> ${r.notes}`);
|
|
5341
|
+
}
|
|
5342
|
+
if (results.some((r) => r.found && !r.configured)) process.exitCode = 1;
|
|
5343
|
+
console.log("\nExisting hooks and permissions are unchanged. Restart configured clients; review any skipped entries.\n");
|
|
5344
|
+
return;
|
|
5345
|
+
}
|
|
5346
|
+
if (cmd === "menubar" || cmd === "bar" || cmd === "tray") {
|
|
5347
|
+
const targetDir = positional[1] ? path19.resolve(positional[1]) : repoRoot;
|
|
5348
|
+
if (process.platform !== "darwin") throw new Error("The menu-bar view requires macOS. Use kith watch on this platform.");
|
|
5349
|
+
const menubarBin = path19.resolve(path19.dirname(installedCliPath()), "../../bin/kith-menubar");
|
|
5350
|
+
if (!fs22.existsSync(menubarBin)) throw new Error("Menu-bar binary is missing; reinstall the package or run pnpm build:menubar in the source checkout.");
|
|
5351
|
+
await new Promise((resolve, reject) => {
|
|
5352
|
+
const child = spawn(menubarBin, [fs22.realpathSync(targetDir), process.execPath, installedCliPath()], { detached: true, stdio: "ignore" });
|
|
5353
|
+
child.once("error", reject);
|
|
5354
|
+
child.once("spawn", () => {
|
|
5355
|
+
child.unref();
|
|
5356
|
+
resolve();
|
|
5357
|
+
});
|
|
5358
|
+
});
|
|
5359
|
+
console.log(`Kith menu-bar view launched for ${targetDir}.`);
|
|
5360
|
+
return;
|
|
5361
|
+
}
|
|
5362
|
+
if (cmd === "watch" || cmd === "monitor") {
|
|
5363
|
+
const targetDir = positional[1] ? path19.resolve(positional[1]) : repoRoot;
|
|
5364
|
+
const notify = flags.notify !== "false" && !flags["no-notify"];
|
|
5365
|
+
const intervalMs = typeof flags.interval === "string" ? Number(flags.interval) : 2500;
|
|
5366
|
+
const isRaw = Boolean(flags.raw || flags.json);
|
|
5367
|
+
let lastScanError = "";
|
|
5368
|
+
const watcher = new PassiveConflictWatcher(targetDir, {
|
|
5369
|
+
intervalMs,
|
|
5370
|
+
notify: !isRaw && notify,
|
|
5371
|
+
onScanError: (errors) => {
|
|
5372
|
+
const message = errors.map((error) => `${error.repository}: ${error.message}`).join("\n");
|
|
5373
|
+
if (!flags.once && message !== lastScanError) console.error(`Scan unavailable; retained overlaps may be stale:
|
|
5374
|
+
${message}`);
|
|
5375
|
+
lastScanError = message;
|
|
5376
|
+
},
|
|
5377
|
+
onCollision: (alert) => {
|
|
5378
|
+
if (!isRaw) {
|
|
5379
|
+
console.log(`
|
|
5380
|
+
\u26A0\uFE0F [WORKTREE OVERLAP] ${alert.message}`);
|
|
5381
|
+
}
|
|
5382
|
+
},
|
|
5383
|
+
onFileChange: (e) => {
|
|
5384
|
+
if (!flags.quiet && !isRaw) {
|
|
5385
|
+
console.log(`\u{1F4DD} [FILE MTIME] ${path19.basename(e.repoPath)}/${e.filePath}`);
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
5388
|
+
});
|
|
5389
|
+
if (flags.once) {
|
|
5390
|
+
const alerts = await watcher.scanOnce();
|
|
5391
|
+
const state = watcher.getScanState();
|
|
5392
|
+
const repos = state.repos;
|
|
5393
|
+
const edits = watcher.getRecentEdits();
|
|
5394
|
+
if (!state.complete) process.exitCode = 1;
|
|
5395
|
+
if (flags.raw || flags.json) {
|
|
5396
|
+
console.log(JSON.stringify({ ...state, recentEdits: edits, collisions: alerts }, null, 2));
|
|
5397
|
+
return;
|
|
5398
|
+
}
|
|
5399
|
+
console.log(`
|
|
5400
|
+
\u{1F4E1} Kith Passive Radar: ${targetDir}`);
|
|
5401
|
+
console.log(` Repositories (${repos.length}): ${repos.map((r) => path19.basename(r)).join(", ")}`);
|
|
5402
|
+
console.log(` Recent Modified Files: ${edits.length}`);
|
|
5403
|
+
if (!state.complete) {
|
|
5404
|
+
console.error(`Scan unavailable:
|
|
5405
|
+
${state.errors.map((error) => error.message).join("\n")}`);
|
|
5406
|
+
} else if (alerts.length === 0) {
|
|
5407
|
+
console.log("\nNo differing or unverified shared dirty paths observed. Identical copies are excluded; compatibility is not verified.\n");
|
|
5408
|
+
} else {
|
|
5409
|
+
console.log(`
|
|
5410
|
+
\u26A0\uFE0F Observed worktree overlaps (${alerts.length}):`);
|
|
5411
|
+
for (const a of alerts) {
|
|
5412
|
+
console.log(` \u26A0\uFE0F ${a.message}`);
|
|
5413
|
+
console.log(` Files: ${a.filePath}`);
|
|
5414
|
+
}
|
|
5415
|
+
console.log("");
|
|
5416
|
+
}
|
|
5417
|
+
return;
|
|
5418
|
+
}
|
|
5419
|
+
console.log(`
|
|
5420
|
+
\u{1F440} Kith Passive Conflict Radar watching: ${targetDir}`);
|
|
5421
|
+
console.log(` Read-only overlap observations; this does not identify agents or verify compatibility. Press Ctrl+C to stop.
|
|
5422
|
+
`);
|
|
5423
|
+
watcher.start();
|
|
5424
|
+
await new Promise(() => {
|
|
5425
|
+
});
|
|
5426
|
+
return;
|
|
5427
|
+
}
|
|
5428
|
+
const commands = ["session", "status", "begin", "complete", "heartbeat", "task", "lock", "contract", "worktree", "port", "dev", "mutex", "hook"];
|
|
4724
5429
|
if (!commands.includes(cmd)) throw new Error(`Unknown command '${cmd}'. Run kith help.`);
|
|
4725
5430
|
const subcommands = {
|
|
4726
5431
|
session: ["start", "end"],
|
|
@@ -4743,7 +5448,7 @@ async function main() {
|
|
|
4743
5448
|
const register = () => engine.fencing.registerAgent({
|
|
4744
5449
|
id: agentId,
|
|
4745
5450
|
client: String(flags.client ?? "cli"),
|
|
4746
|
-
pid: typeof flags["owner-pid"] === "string" ? Number(flags["owner-pid"]) :
|
|
5451
|
+
pid: typeof flags["owner-pid"] === "string" ? Number(flags["owner-pid"]) : ["mutex", "dev"].includes(cmd) && !sessionToken ? process.pid : process.ppid,
|
|
4747
5452
|
sessionToken,
|
|
4748
5453
|
leaseDurationMs: typeof flags["lease-ms"] === "string" ? Number(flags["lease-ms"]) : void 0
|
|
4749
5454
|
});
|
|
@@ -4782,13 +5487,22 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
4782
5487
|
const scopeRaw = typeof flags.scope === "string" ? flags.scope : "";
|
|
4783
5488
|
const scopeGlobs = scopeRaw ? scopeRaw.split(",").map((s) => s.trim()) : ["**"];
|
|
4784
5489
|
const session = register();
|
|
4785
|
-
const
|
|
5490
|
+
const options = {
|
|
4786
5491
|
agentId,
|
|
4787
5492
|
title,
|
|
4788
5493
|
intentSummary: typeof flags.summary === "string" ? flags.summary : title,
|
|
4789
5494
|
scopeGlobs,
|
|
4790
5495
|
isolateWorktree: Boolean(flags.worktree)
|
|
4791
|
-
}
|
|
5496
|
+
};
|
|
5497
|
+
const res = flags.wait || flags["wait-ms"] !== void 0 ? await interruptible((signal) => engine.beginWorkWhenAvailable({
|
|
5498
|
+
...options,
|
|
5499
|
+
signal,
|
|
5500
|
+
waitTimeoutMs: claimWaitTimeout()
|
|
5501
|
+
})) : engine.beginWork(options);
|
|
5502
|
+
if (!res) {
|
|
5503
|
+
if (!sessionToken) engine.fencing.abandonAgentSession(agentId);
|
|
5504
|
+
return;
|
|
5505
|
+
}
|
|
4792
5506
|
console.log(JSON.stringify({ ...res, sessionToken: session.sessionToken }, null, 2));
|
|
4793
5507
|
process.exitCode = res.ok ? 0 : 1;
|
|
4794
5508
|
return;
|
|
@@ -4834,7 +5548,16 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
4834
5548
|
if (subCmd === "acquire") {
|
|
4835
5549
|
const patterns = String(flags.patterns ?? flags.scope ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
4836
5550
|
const session = register();
|
|
4837
|
-
const res =
|
|
5551
|
+
const res = flags.wait || flags["wait-ms"] !== void 0 ? await interruptible((signal) => engine.acquireLocksWhenAvailable({
|
|
5552
|
+
agentId,
|
|
5553
|
+
patterns,
|
|
5554
|
+
signal,
|
|
5555
|
+
waitTimeoutMs: claimWaitTimeout()
|
|
5556
|
+
})) : engine.locks.acquireLocks({ agentId, patterns });
|
|
5557
|
+
if (!res) {
|
|
5558
|
+
if (!sessionToken) engine.fencing.abandonAgentSession(agentId);
|
|
5559
|
+
return;
|
|
5560
|
+
}
|
|
4838
5561
|
console.log(JSON.stringify({ ...res, sessionToken: session.sessionToken }, null, 2));
|
|
4839
5562
|
process.exitCode = res.ok ? 0 : 1;
|
|
4840
5563
|
return;
|
|
@@ -4902,9 +5625,10 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
4902
5625
|
}
|
|
4903
5626
|
return;
|
|
4904
5627
|
}
|
|
4905
|
-
if (cmd === "mutex") {
|
|
5628
|
+
if (cmd === "mutex" || cmd === "dev") {
|
|
4906
5629
|
const mutexName = String(flags.name ?? "default-mutex");
|
|
4907
5630
|
const commandToRun = passthroughCmd.length ? passthroughCmd : String(flags.cmd ?? "");
|
|
5631
|
+
if (cmd === "dev" && (!passthroughCmd.length || !passthroughCmd[0])) throw new Error("Provide a literal command after `--` for kith dev");
|
|
4908
5632
|
if (!commandToRun) {
|
|
4909
5633
|
console.error("Error: provide a command after `--` for `kith mutex`");
|
|
4910
5634
|
process.exitCode = 1;
|
|
@@ -4922,17 +5646,24 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
4922
5646
|
process.on("SIGINT", sigint);
|
|
4923
5647
|
process.on("SIGTERM", sigterm);
|
|
4924
5648
|
try {
|
|
4925
|
-
const
|
|
5649
|
+
const shared = {
|
|
4926
5650
|
agentId,
|
|
4927
|
-
mutexName,
|
|
4928
|
-
command: commandToRun,
|
|
4929
5651
|
signal: controller.signal,
|
|
4930
5652
|
leaseDurationMs: typeof flags["lease-ms"] === "string" ? Number(flags["lease-ms"]) : void 0,
|
|
4931
5653
|
timeoutMs: typeof flags["wait-ms"] === "string" ? Number(flags["wait-ms"]) : void 0,
|
|
4932
5654
|
commandTimeoutMs: typeof flags["timeout-ms"] === "string" ? Number(flags["timeout-ms"]) : void 0,
|
|
4933
5655
|
onStdout: (chunk) => process.stdout.write(chunk),
|
|
4934
5656
|
onStderr: (chunk) => process.stderr.write(chunk)
|
|
4935
|
-
}
|
|
5657
|
+
};
|
|
5658
|
+
const res = cmd === "dev" ? await engine.broker.executeWithPort({
|
|
5659
|
+
...shared,
|
|
5660
|
+
command: passthroughCmd,
|
|
5661
|
+
serviceName: String(flags.service ?? "web"),
|
|
5662
|
+
basePort: flags.base === void 0 ? 3e3 : Number(flags.base),
|
|
5663
|
+
cwd: path19.resolve(engine.fencing.getAgent(agentId)?.worktree_path ?? engine.coordDb.repoRoot, typeof flags.cwd === "string" ? flags.cwd : "."),
|
|
5664
|
+
onPort: (port) => process.stderr.write(`Kith: ${String(flags.service ?? "web")} allocated port ${port} (PORT, KITH_PORT, {port})
|
|
5665
|
+
`)
|
|
5666
|
+
}) : await engine.broker.executeWithMutex({ ...shared, mutexName, command: commandToRun });
|
|
4936
5667
|
process.exitCode = res.exitCode;
|
|
4937
5668
|
} catch (err) {
|
|
4938
5669
|
if (interrupted) process.exitCode = interrupted === "SIGINT" ? 130 : 143;
|
|
@@ -5036,14 +5767,19 @@ Usage:
|
|
|
5036
5767
|
kith uninstall [--dry-run] Restore unchanged tracked setup files; retain work and recovery state
|
|
5037
5768
|
kith doctor Check runtime, PATH, configuration, sessions and worktree health without writes
|
|
5038
5769
|
kith recover Inspect retained work, expired claims and recovery records offline
|
|
5039
|
-
kith
|
|
5770
|
+
kith setup [checkout] [--dry-run] Configure detected clients; preserve hooks and custom settings
|
|
5771
|
+
kith watch [checkout] [--once --json] Observe linked-worktree path overlaps without enforcement
|
|
5772
|
+
kith menubar [checkout] Optional read-only macOS view
|
|
5773
|
+
kith mcp --repo <checkout> Start stdio MCP server bound to a project
|
|
5040
5774
|
kith begin --agent <id> --title <t> --scope <g> Declare intent, check overlaps, lock scope
|
|
5041
5775
|
kith complete --agent <id> [--merge <branch>] Run Speculative Merge Train & release locks
|
|
5042
5776
|
kith task add|claim|list Manage orchestrated DAG task queue
|
|
5043
5777
|
kith lock acquire|release|list Manage fencing-token file/glob locks
|
|
5044
5778
|
kith contract publish|list Manage Contract Blackboard
|
|
5045
5779
|
kith worktree spawn|remove|list|preview Manage isolated Git worktrees
|
|
5046
|
-
kith port --agent <id> --service <name>
|
|
5780
|
+
kith port --agent <id> --service <name> Reserve an available TCP port
|
|
5781
|
+
kith dev --agent <id> -- <cmd> Run with PORT and {port} argument substitution
|
|
5782
|
+
kith begin/lock acquire ... --wait --wait-ms 60000 Wait for a scope claim (bounded, cancellable)
|
|
5047
5783
|
kith mutex --agent <id> --name <m> -- <cmd> Serialize commands via mutex
|
|
5048
5784
|
kith hook pre|post|pre-commit Tool & git hook handlers
|
|
5049
5785
|
`);
|