kith-coord 0.1.3 → 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 +12 -0
- package/README.md +25 -3
- package/bin/kith-menubar +0 -0
- package/dist/cli/index.js +273 -43
- 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 +55 -15
- package/dist/index.js +96 -5
- package/dist/index.js.map +1 -1
- package/docs/client-validation.md +5 -0
- package/docs/integrations.md +5 -3
- package/docs/protocol/tools.v1.json +73 -1
- package/docs/protocol.md +8 -2
- package/docs/reliability.md +3 -0
- package/docs/roadmap.md +12 -33
- package/docs/usage.md +59 -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 +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -6,8 +6,8 @@ import path19 from "path";
|
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
|
|
8
8
|
// src/version.ts
|
|
9
|
-
var KITH_VERSION = "0.1.
|
|
10
|
-
var KITH_PROTOCOL_VERSION = "1.
|
|
9
|
+
var KITH_VERSION = "0.1.4";
|
|
10
|
+
var KITH_PROTOCOL_VERSION = "1.3.0";
|
|
11
11
|
|
|
12
12
|
// src/cli/setup-ledger.ts
|
|
13
13
|
import fs2 from "fs";
|
|
@@ -243,6 +243,7 @@ function uninstallKith(directory = process.cwd(), dryRun = false) {
|
|
|
243
243
|
// src/cli/doctor.ts
|
|
244
244
|
import fs5 from "fs";
|
|
245
245
|
import path5 from "path";
|
|
246
|
+
import os2 from "os";
|
|
246
247
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
247
248
|
import { parse as parseToml } from "smol-toml";
|
|
248
249
|
|
|
@@ -895,7 +896,7 @@ function recoveryReport(directory = process.cwd()) {
|
|
|
895
896
|
}
|
|
896
897
|
|
|
897
898
|
// src/cli/doctor.ts
|
|
898
|
-
function doctorReport(directory = process.cwd()) {
|
|
899
|
+
function doctorReport(directory = process.cwd(), home = os2.homedir()) {
|
|
899
900
|
const state = readCoordinationState(directory), root = state.repository.root;
|
|
900
901
|
const checks = [];
|
|
901
902
|
const check = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
@@ -920,24 +921,42 @@ function doctorReport(directory = process.cwd()) {
|
|
|
920
921
|
}
|
|
921
922
|
});
|
|
922
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
|
+
];
|
|
923
934
|
let configured = 0;
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
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);
|
|
927
939
|
try {
|
|
928
|
-
if (!isWithin(
|
|
940
|
+
if (!isWithin(physicalPath(base), physicalPath(target))) throw new Error("Configuration is redirected outside its setup root");
|
|
929
941
|
const text = fs5.readFileSync(target, "utf8");
|
|
930
|
-
const config =
|
|
931
|
-
const server =
|
|
942
|
+
const config = toml ? parseToml(text) : JSON.parse(text);
|
|
943
|
+
const server = toml ? config.mcp_servers?.kith : config.mcpServers?.kith;
|
|
932
944
|
if (!server) continue;
|
|
933
945
|
configured++;
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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`);
|
|
937
956
|
}
|
|
938
957
|
}
|
|
939
|
-
check("mcp-configuration", configured > 0, "
|
|
940
|
-
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");
|
|
941
960
|
let nativeGuards = false;
|
|
942
961
|
for (const [file, event] of [[".claude/settings.json", "PreToolUse"], [".cursor/hooks.json", "preToolUse"]]) {
|
|
943
962
|
const target = path5.join(root, file);
|
|
@@ -1021,7 +1040,7 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1021
1040
|
"-NoProfile",
|
|
1022
1041
|
"-NonInteractive",
|
|
1023
1042
|
"-Command",
|
|
1024
|
-
`(
|
|
1043
|
+
`([System.Diagnostics.Process]::GetProcessById(${pid})).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)`
|
|
1025
1044
|
],
|
|
1026
1045
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3, windowsHide: true }
|
|
1027
1046
|
).trim();
|
|
@@ -1201,6 +1220,37 @@ var FencingAndAgentManager = class _FencingAndAgentManager {
|
|
|
1201
1220
|
}
|
|
1202
1221
|
};
|
|
1203
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
|
+
|
|
1204
1254
|
// src/core/engine.ts
|
|
1205
1255
|
import fs15 from "fs";
|
|
1206
1256
|
import path14 from "path";
|
|
@@ -2119,6 +2169,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2119
2169
|
const leaseMs = FencingAndAgentManager.validateLease(params.leaseDurationMs ?? FencingAndAgentManager.DEFAULT_LEASE_MS);
|
|
2120
2170
|
const now = Date.now();
|
|
2121
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");
|
|
2122
2173
|
const resourceKey = `${params.agentId}:${params.serviceName}`;
|
|
2123
2174
|
const existing = this.getResource("port", resourceKey);
|
|
2124
2175
|
if (existing && existing.expires_at > now) {
|
|
@@ -2253,8 +2304,44 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2253
2304
|
return true;
|
|
2254
2305
|
});
|
|
2255
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
|
+
}
|
|
2256
2340
|
/** Renew leases while a detached, recorded process group runs; stop it before releasing. */
|
|
2257
|
-
|
|
2341
|
+
executeWithMutex(params) {
|
|
2342
|
+
return this.executeGuarded(params);
|
|
2343
|
+
}
|
|
2344
|
+
async executeGuarded(params, lifecycle) {
|
|
2258
2345
|
if (process.platform === "win32") throw new Error("Supervised mutex commands currently require macOS or Linux process groups");
|
|
2259
2346
|
if (!params.command.length) throw new Error("A command is required");
|
|
2260
2347
|
const leaseMs = FencingAndAgentManager.validateLease(params.leaseDurationMs ?? FencingAndAgentManager.DEFAULT_LEASE_MS);
|
|
@@ -2299,6 +2386,8 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2299
2386
|
await delay(Math.min(50, Math.max(10, leaseMs / 4)));
|
|
2300
2387
|
}
|
|
2301
2388
|
const waitedMs = Date.now() - started;
|
|
2389
|
+
const prepared = await lifecycle?.prepare();
|
|
2390
|
+
if (params.signal?.aborted) throw abortError();
|
|
2302
2391
|
const candidates = ["./command-worker.js", "../command-worker.js", "../../dist/command-worker.js"].map((name) => fileURLToPath(new URL(name, import.meta.url)));
|
|
2303
2392
|
const script = candidates.find((name) => fs10.existsSync(name));
|
|
2304
2393
|
if (!script) throw new Error("Missing command worker. Build Kith before running supervised commands.");
|
|
@@ -2341,6 +2430,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2341
2430
|
try {
|
|
2342
2431
|
this.fencing.heartbeat(params.agentId);
|
|
2343
2432
|
this.renewMutex({ ...params, fencingToken: run.fencing_token });
|
|
2433
|
+
lifecycle?.renew();
|
|
2344
2434
|
} catch (err) {
|
|
2345
2435
|
stop(err instanceof Error ? err : new Error(String(err)));
|
|
2346
2436
|
}
|
|
@@ -2368,8 +2458,9 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2368
2458
|
run.worker_start = identity;
|
|
2369
2459
|
workerIdentity = identity;
|
|
2370
2460
|
this.renewMutex({ ...params, fencingToken: run.fencing_token });
|
|
2461
|
+
lifecycle?.renew();
|
|
2371
2462
|
});
|
|
2372
|
-
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 });
|
|
2373
2464
|
} catch (err) {
|
|
2374
2465
|
stop(err instanceof Error ? err : new Error(String(err)));
|
|
2375
2466
|
}
|
|
@@ -2417,6 +2508,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2417
2508
|
this.coordDb.db.prepare("DELETE FROM mutex_waiters WHERE mutex_name=? AND request_id=? AND session_epoch=?").run(params.mutexName, requestId, epoch);
|
|
2418
2509
|
if (run && !this.runIsAlive(run)) this.clearRun(run);
|
|
2419
2510
|
if (run && run.worker_pid === null) this.clearRun(run);
|
|
2511
|
+
if (!this.getRun(params.mutexName)) lifecycle?.release();
|
|
2420
2512
|
});
|
|
2421
2513
|
} finally {
|
|
2422
2514
|
this.activeExecutions--;
|
|
@@ -3749,6 +3841,7 @@ var CoWWorktreeManager = class _CoWWorktreeManager {
|
|
|
3749
3841
|
|
|
3750
3842
|
// src/core/engine.ts
|
|
3751
3843
|
var KithEngine = class {
|
|
3844
|
+
activeClaimWaits = 0;
|
|
3752
3845
|
coordDb;
|
|
3753
3846
|
fencing;
|
|
3754
3847
|
locks;
|
|
@@ -3901,6 +3994,22 @@ ${lines.join("\n")}` : "";
|
|
|
3901
3994
|
const worktree = intent.ok && params.isolateWorktree ? this.worktrees.spawnWorktree({ agentId: params.agentId }) : null;
|
|
3902
3995
|
return { ...intent, worktree };
|
|
3903
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
|
+
}
|
|
3904
4013
|
completeWork(params) {
|
|
3905
4014
|
this.fencing.assertSession(params.agentId);
|
|
3906
4015
|
const agent = this.fencing.getAgent(params.agentId);
|
|
@@ -3921,6 +4030,7 @@ ${lines.join("\n")}` : "";
|
|
|
3921
4030
|
return { ok: true, completedTasks, mergeResult };
|
|
3922
4031
|
}
|
|
3923
4032
|
close() {
|
|
4033
|
+
if (this.activeClaimWaits) throw new Error("Await or cancel active file claim waits before closing Kith");
|
|
3924
4034
|
if (this.broker.hasActiveCommands()) throw new Error("Await or cancel active mutex commands before closing Kith");
|
|
3925
4035
|
this.coordDb.close();
|
|
3926
4036
|
}
|
|
@@ -4040,17 +4150,30 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4040
4150
|
title: z.string().describe("Short title of the task or intent"),
|
|
4041
4151
|
intentSummary: z.string().optional().describe("Detailed description of what will be built or modified"),
|
|
4042
4152
|
scopeGlobs: z.array(z.string()).describe('File paths or glob patterns this agent will edit (e.g. ["src/auth/**"])'),
|
|
4043
|
-
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")
|
|
4044
4155
|
},
|
|
4045
4156
|
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4046
4157
|
const reg = register(args);
|
|
4047
|
-
const
|
|
4158
|
+
const options = {
|
|
4048
4159
|
agentId: args.agentId,
|
|
4049
4160
|
title: args.title,
|
|
4050
4161
|
intentSummary: args.intentSummary,
|
|
4051
4162
|
scopeGlobs: args.scopeGlobs,
|
|
4052
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])
|
|
4053
4169
|
});
|
|
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
|
+
}
|
|
4054
4177
|
const contracts = engine2.depGraph.listContracts().slice(0, 30);
|
|
4055
4178
|
return formatReply(args.agentId, {
|
|
4056
4179
|
ok: intentRes.ok,
|
|
@@ -4062,6 +4185,7 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4062
4185
|
worktree: intentRes.worktree,
|
|
4063
4186
|
lockConflicts: intentRes.lockConflicts,
|
|
4064
4187
|
activeSiblingIntents: intentRes.activeSiblingIntents,
|
|
4188
|
+
..."waitedMs" in intentRes && "waitTimedOut" in intentRes ? { waitedMs: intentRes.waitedMs, waitTimedOut: intentRes.waitTimedOut } : {},
|
|
4065
4189
|
publishedContracts: contracts
|
|
4066
4190
|
});
|
|
4067
4191
|
}
|
|
@@ -4104,12 +4228,22 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4104
4228
|
sessionToken: z.string().optional(),
|
|
4105
4229
|
patterns: z.array(z.string()),
|
|
4106
4230
|
mode: z.enum(["exclusive", "shared"]).optional(),
|
|
4107
|
-
reason: z.string().optional()
|
|
4231
|
+
reason: z.string().optional(),
|
|
4232
|
+
waitTimeoutMs: z.number().int().min(0).max(6e5).optional()
|
|
4108
4233
|
},
|
|
4109
4234
|
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4110
4235
|
authorize(args);
|
|
4111
|
-
const
|
|
4112
|
-
|
|
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
|
+
}
|
|
4113
4247
|
}
|
|
4114
4248
|
);
|
|
4115
4249
|
tool(
|
|
@@ -4206,6 +4340,45 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4206
4340
|
}
|
|
4207
4341
|
}
|
|
4208
4342
|
);
|
|
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
|
+
);
|
|
4209
4382
|
tool(
|
|
4210
4383
|
"kith_get_status",
|
|
4211
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.",
|
|
@@ -4613,7 +4786,7 @@ Instruction protocol: ${KITH_PROTOCOL_VERSION}.
|
|
|
4613
4786
|
|
|
4614
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.
|
|
4615
4788
|
|
|
4616
|
-
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\`.
|
|
4617
4790
|
2. Edit only the returned \`worktree.worktreePath\`. Report changed files through \`kith_record_file_edit\` on that connection.
|
|
4618
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.
|
|
4619
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\`.
|
|
@@ -4628,7 +4801,7 @@ If Claude/Cursor edit hooks or CLI commands are also enabled, launch the client
|
|
|
4628
4801
|
}
|
|
4629
4802
|
content = content.includes("<!-- KITH:START -->") ? content.replace(/<!-- KITH:START -->[\s\S]*?<!-- KITH:END -->/, () => block) : content.trimEnd() + "\n\n" + block + "\n";
|
|
4630
4803
|
add("AGENTS.md", content);
|
|
4631
|
-
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');
|
|
4632
4805
|
}
|
|
4633
4806
|
if (selected.has("git")) {
|
|
4634
4807
|
try {
|
|
@@ -4826,16 +4999,17 @@ var PassiveConflictWatcher = class _PassiveConflictWatcher {
|
|
|
4826
4999
|
if (!file || file === ".coord" || file.startsWith(".coord/")) continue;
|
|
4827
5000
|
const key = JSON.stringify([repo, file]);
|
|
4828
5001
|
seen.add(key);
|
|
4829
|
-
let modified
|
|
5002
|
+
let modified, fingerprint = status;
|
|
4830
5003
|
try {
|
|
4831
5004
|
const stat = fs19.lstatSync(path17.join(repo, file));
|
|
4832
|
-
|
|
5005
|
+
if (Number.isFinite(stat.mtimeMs) && stat.mtimeMs <= now) modified = stat.mtimeMs;
|
|
4833
5006
|
fingerprint += `:${stat.mtimeMs}:${stat.size}`;
|
|
4834
5007
|
} catch (error) {
|
|
4835
5008
|
if (error.code !== "ENOENT") throw error;
|
|
4836
5009
|
}
|
|
4837
5010
|
const previous = this.recentEdits.get(key);
|
|
4838
|
-
if (
|
|
5011
|
+
if (modified === void 0) this.recentEdits.delete(key);
|
|
5012
|
+
else if (!previous || previous.fingerprint !== fingerprint) {
|
|
4839
5013
|
this.recentEdits.set(key, { repo, fingerprint, timestamp: modified });
|
|
4840
5014
|
this.options.onFileChange?.({ repoPath: repo, filePath: file, timestamp: modified });
|
|
4841
5015
|
}
|
|
@@ -4914,7 +5088,7 @@ var PassiveConflictWatcher = class _PassiveConflictWatcher {
|
|
|
4914
5088
|
// src/cli/setup-auto.ts
|
|
4915
5089
|
import fs21 from "fs";
|
|
4916
5090
|
import path18 from "path";
|
|
4917
|
-
import
|
|
5091
|
+
import os3 from "os";
|
|
4918
5092
|
import { parse as parseToml3, stringify as stringifyToml2 } from "smol-toml";
|
|
4919
5093
|
|
|
4920
5094
|
// src/cli/runtime.ts
|
|
@@ -4943,7 +5117,7 @@ function ownedLauncher(value) {
|
|
|
4943
5117
|
return false;
|
|
4944
5118
|
}
|
|
4945
5119
|
}
|
|
4946
|
-
function autoConfigureAllClients(targetWorkspace = process.cwd(), home =
|
|
5120
|
+
function autoConfigureAllClients(targetWorkspace = process.cwd(), home = os3.homedir(), options = {}) {
|
|
4947
5121
|
const target = physicalPath(targetWorkspace);
|
|
4948
5122
|
const layout = target === path18.parse(target).root ? void 0 : discoverRepository(target);
|
|
4949
5123
|
const workspace = layout?.checkoutRoot ?? target;
|
|
@@ -5064,7 +5238,7 @@ function parseFlags(argv) {
|
|
|
5064
5238
|
} else {
|
|
5065
5239
|
const key = arg.slice(2);
|
|
5066
5240
|
const next = mainArgs[i + 1];
|
|
5067
|
-
if (!["worktree", "raw", "dry-run", "help", "version", "shell", "new", "mcp-only", "offline", "once", "json", "quiet", "no-notify"].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("--")) {
|
|
5068
5242
|
flags[key] = next;
|
|
5069
5243
|
i++;
|
|
5070
5244
|
} else {
|
|
@@ -5077,10 +5251,38 @@ function parseFlags(argv) {
|
|
|
5077
5251
|
}
|
|
5078
5252
|
return { positional, flags, passthroughCmd };
|
|
5079
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
|
+
}
|
|
5080
5277
|
async function main() {
|
|
5081
5278
|
const { positional, flags, passthroughCmd } = parseFlags(process.argv.slice(2));
|
|
5082
5279
|
const cmd = positional[0] ?? "help";
|
|
5083
5280
|
const subCmd = positional[1];
|
|
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
|
+
};
|
|
5084
5286
|
const targetFromPositional = ["init", "setup", "watch", "menubar", "bar", "tray", "doctor", "recover"].includes(cmd) && positional[1] ? path19.resolve(positional[1]) : void 0;
|
|
5085
5287
|
const repoRoot = typeof flags.repo === "string" ? path19.resolve(flags.repo) : targetFromPositional ?? (cmd === "mcp" ? process.env.CLAUDE_PROJECT_DIR : void 0) ?? process.cwd();
|
|
5086
5288
|
if (flags.version || cmd === "version") {
|
|
@@ -5168,7 +5370,7 @@ async function main() {
|
|
|
5168
5370
|
notify: !isRaw && notify,
|
|
5169
5371
|
onScanError: (errors) => {
|
|
5170
5372
|
const message = errors.map((error) => `${error.repository}: ${error.message}`).join("\n");
|
|
5171
|
-
if (!flags.once && message !== lastScanError) console.error(`Scan unavailable:
|
|
5373
|
+
if (!flags.once && message !== lastScanError) console.error(`Scan unavailable; retained overlaps may be stale:
|
|
5172
5374
|
${message}`);
|
|
5173
5375
|
lastScanError = message;
|
|
5174
5376
|
},
|
|
@@ -5180,7 +5382,7 @@ ${message}`);
|
|
|
5180
5382
|
},
|
|
5181
5383
|
onFileChange: (e) => {
|
|
5182
5384
|
if (!flags.quiet && !isRaw) {
|
|
5183
|
-
console.log(`\u{1F4DD} [
|
|
5385
|
+
console.log(`\u{1F4DD} [FILE MTIME] ${path19.basename(e.repoPath)}/${e.filePath}`);
|
|
5184
5386
|
}
|
|
5185
5387
|
}
|
|
5186
5388
|
});
|
|
@@ -5223,7 +5425,7 @@ ${state.errors.map((error) => error.message).join("\n")}`);
|
|
|
5223
5425
|
});
|
|
5224
5426
|
return;
|
|
5225
5427
|
}
|
|
5226
|
-
const commands = ["session", "status", "begin", "complete", "heartbeat", "task", "lock", "contract", "worktree", "port", "mutex", "hook"];
|
|
5428
|
+
const commands = ["session", "status", "begin", "complete", "heartbeat", "task", "lock", "contract", "worktree", "port", "dev", "mutex", "hook"];
|
|
5227
5429
|
if (!commands.includes(cmd)) throw new Error(`Unknown command '${cmd}'. Run kith help.`);
|
|
5228
5430
|
const subcommands = {
|
|
5229
5431
|
session: ["start", "end"],
|
|
@@ -5246,7 +5448,7 @@ ${state.errors.map((error) => error.message).join("\n")}`);
|
|
|
5246
5448
|
const register = () => engine.fencing.registerAgent({
|
|
5247
5449
|
id: agentId,
|
|
5248
5450
|
client: String(flags.client ?? "cli"),
|
|
5249
|
-
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,
|
|
5250
5452
|
sessionToken,
|
|
5251
5453
|
leaseDurationMs: typeof flags["lease-ms"] === "string" ? Number(flags["lease-ms"]) : void 0
|
|
5252
5454
|
});
|
|
@@ -5285,13 +5487,22 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
5285
5487
|
const scopeRaw = typeof flags.scope === "string" ? flags.scope : "";
|
|
5286
5488
|
const scopeGlobs = scopeRaw ? scopeRaw.split(",").map((s) => s.trim()) : ["**"];
|
|
5287
5489
|
const session = register();
|
|
5288
|
-
const
|
|
5490
|
+
const options = {
|
|
5289
5491
|
agentId,
|
|
5290
5492
|
title,
|
|
5291
5493
|
intentSummary: typeof flags.summary === "string" ? flags.summary : title,
|
|
5292
5494
|
scopeGlobs,
|
|
5293
5495
|
isolateWorktree: Boolean(flags.worktree)
|
|
5294
|
-
}
|
|
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
|
+
}
|
|
5295
5506
|
console.log(JSON.stringify({ ...res, sessionToken: session.sessionToken }, null, 2));
|
|
5296
5507
|
process.exitCode = res.ok ? 0 : 1;
|
|
5297
5508
|
return;
|
|
@@ -5337,7 +5548,16 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
5337
5548
|
if (subCmd === "acquire") {
|
|
5338
5549
|
const patterns = String(flags.patterns ?? flags.scope ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
5339
5550
|
const session = register();
|
|
5340
|
-
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
|
+
}
|
|
5341
5561
|
console.log(JSON.stringify({ ...res, sessionToken: session.sessionToken }, null, 2));
|
|
5342
5562
|
process.exitCode = res.ok ? 0 : 1;
|
|
5343
5563
|
return;
|
|
@@ -5405,9 +5625,10 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
5405
5625
|
}
|
|
5406
5626
|
return;
|
|
5407
5627
|
}
|
|
5408
|
-
if (cmd === "mutex") {
|
|
5628
|
+
if (cmd === "mutex" || cmd === "dev") {
|
|
5409
5629
|
const mutexName = String(flags.name ?? "default-mutex");
|
|
5410
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");
|
|
5411
5632
|
if (!commandToRun) {
|
|
5412
5633
|
console.error("Error: provide a command after `--` for `kith mutex`");
|
|
5413
5634
|
process.exitCode = 1;
|
|
@@ -5425,17 +5646,24 @@ export KITH_SESSION_TOKEN=${quote(session.sessionToken)}`);
|
|
|
5425
5646
|
process.on("SIGINT", sigint);
|
|
5426
5647
|
process.on("SIGTERM", sigterm);
|
|
5427
5648
|
try {
|
|
5428
|
-
const
|
|
5649
|
+
const shared = {
|
|
5429
5650
|
agentId,
|
|
5430
|
-
mutexName,
|
|
5431
|
-
command: commandToRun,
|
|
5432
5651
|
signal: controller.signal,
|
|
5433
5652
|
leaseDurationMs: typeof flags["lease-ms"] === "string" ? Number(flags["lease-ms"]) : void 0,
|
|
5434
5653
|
timeoutMs: typeof flags["wait-ms"] === "string" ? Number(flags["wait-ms"]) : void 0,
|
|
5435
5654
|
commandTimeoutMs: typeof flags["timeout-ms"] === "string" ? Number(flags["timeout-ms"]) : void 0,
|
|
5436
5655
|
onStdout: (chunk) => process.stdout.write(chunk),
|
|
5437
5656
|
onStderr: (chunk) => process.stderr.write(chunk)
|
|
5438
|
-
}
|
|
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 });
|
|
5439
5667
|
process.exitCode = res.exitCode;
|
|
5440
5668
|
} catch (err) {
|
|
5441
5669
|
if (interrupted) process.exitCode = interrupted === "SIGINT" ? 130 : 143;
|
|
@@ -5549,7 +5777,9 @@ Usage:
|
|
|
5549
5777
|
kith lock acquire|release|list Manage fencing-token file/glob locks
|
|
5550
5778
|
kith contract publish|list Manage Contract Blackboard
|
|
5551
5779
|
kith worktree spawn|remove|list|preview Manage isolated Git worktrees
|
|
5552
|
-
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)
|
|
5553
5783
|
kith mutex --agent <id> --name <m> -- <cmd> Serialize commands via mutex
|
|
5554
5784
|
kith hook pre|post|pre-commit Tool & git hook handlers
|
|
5555
5785
|
`);
|