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/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.2";
5
- var KITH_PROTOCOL_VERSION = "1.1.0";
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
- for (const [client, file] of [["claude", ".mcp.json"], ["cursor", ".cursor/mcp.json"], ["codex", ".codex/config.toml"]]) {
917
- const target = path5.join(root, file);
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(root, physicalPath(target))) throw new Error("Configuration is redirected outside the checkout");
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 = client === "codex" ? parseToml(text) : JSON.parse(text);
923
- const server = client === "codex" ? config.mcp_servers?.kith : config.mcpServers?.kith;
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
- check(`${client}-mcp`, typeof server.command === "string" && Array.isArray(server.args) && executable(server.command), "Kith stdio launcher must be executable by absolute path or PATH; native client trust and MCP discovery require client validation");
927
- } catch {
928
- check(`${client}-mcp`, false, `Cannot parse ${file}; existing configuration was preserved`);
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, "Configure the clients you use with kith init; diagnostics do not change client settings");
932
- check("coordination-state", state.state === "ready", state.state === "ready" ? "Schema and counters validated on a private snapshot" : state.error ?? "No coordination state; run kith init before starting agents");
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
- `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)`
1043
+ `([System.Diagnostics.Process]::GetProcessById(${pid})).StartTime.ToUniversalTime().Ticks.ToString([System.Globalization.CultureInfo]::InvariantCulture)`
1015
1044
  ],
1016
- { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3, windowsHide: true }
1045
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3, windowsHide: true }
1017
1046
  ).trim();
1018
- if (!/^\d+$/.test(ticks)) return null;
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
- async executeWithMutex(params) {
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 createKithMcpServer(repoRoot = process.cwd()) {
3921
- const engine = new KithEngine(repoRoot);
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 cleanup = () => {
3946
- for (const id of owned) {
3947
- try {
3948
- engine.fencing.abandonAgentSession(id);
3949
- } catch {
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
- engine.close();
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
- const formatReply = (agentId, payload) => {
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
- server.tool("kith_heartbeat", "Renew an active, owned session. Expired sessions cannot be revived.", {
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: engine.fencing.getAgent(args.agentId) });
4141
+ return formatReply(args.agentId, { ok: true, agent: engine2.fencing.getAgent(args.agentId) });
3985
4142
  });
3986
- server.tool(
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 intentRes = engine.beginWork({
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
- const contracts = engine.depGraph.listContracts().slice(0, 30);
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: engine.fencing.getAgent(args.agentId),
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
- server.tool(
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 = engine.intents.claimNextTask(args.agentId);
4203
+ const res = engine2.intents.claimNextTask(args.agentId);
4033
4204
  return formatReply(args.agentId, { ...res, sessionToken: session.sessionToken });
4034
4205
  }
4035
4206
  );
4036
- server.tool(
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 = engine.intents.addTask(args);
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
- server.tool(
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 res = engine.locks.acquireLocks(args);
4065
- return formatReply(args.agentId, res);
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
- server.tool(
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 = engine.processPostFileEdit({
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
- server.tool(
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 = engine.depGraph.publishExplicitContract(args);
4282
+ const contract = engine2.depGraph.publishExplicitContract(args);
4102
4283
  return formatReply(args.agentId, { ok: true, contract });
4103
4284
  }
4104
4285
  );
4105
- server.tool(
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 engine.broker.acquirePort(args);
4297
+ const portRes = await engine2.broker.acquirePort(args);
4117
4298
  return formatReply(args.agentId, portRes);
4118
4299
  }
4119
4300
  );
4120
- server.tool(
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(engine.fencing.getAgent(args.agentId)?.worktree_path ?? engine.coordDb.repoRoot);
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 = engine.broker.executeWithMutex({
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
- server.tool(
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 = engine.getStatus();
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
- server.tool(
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, engine.previewIntegration(args.agentId));
4407
+ return formatReply(args.agentId, engine2.previewIntegration(args.agentId));
4188
4408
  }
4189
4409
  );
4190
- server.tool(
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 = engine.completeWork(args);
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
- const { server } = createKithMcpServer(repoRoot);
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 repoRoot = typeof flags.repo === "string" ? flags.repo : process.cwd();
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
- const commands = ["session", "status", "begin", "complete", "heartbeat", "task", "lock", "contract", "worktree", "port", "mutex", "hook"];
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"]) : cmd === "mutex" && !sessionToken ? process.pid : process.ppid,
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 res = engine.beginWork({
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 = engine.locks.acquireLocks({ agentId, patterns });
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 res = await engine.broker.executeWithMutex({
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 mcp Start stdio MCP server
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> Lease collision-free TCP port
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
  `);