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/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.3";
10
- var KITH_PROTOCOL_VERSION = "1.2.0";
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
- for (const [client, file] of [["claude", ".mcp.json"], ["cursor", ".cursor/mcp.json"], ["codex", ".codex/config.toml"]]) {
925
- const target = path5.join(root, file);
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(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");
929
941
  const text = fs5.readFileSync(target, "utf8");
930
- const config = client === "codex" ? parseToml(text) : JSON.parse(text);
931
- 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;
932
944
  if (!server) continue;
933
945
  configured++;
934
- 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");
935
- } catch {
936
- 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`);
937
956
  }
938
957
  }
939
- check("mcp-configuration", configured > 0, "Configure the clients you use with kith init; diagnostics do not change client settings");
940
- 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");
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
- `(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)`
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
- async executeWithMutex(params) {
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 intentRes = engine2.beginWork({
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 res = engine2.locks.acquireLocks(args);
4112
- 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
+ }
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 = now, fingerprint = status;
5002
+ let modified, fingerprint = status;
4830
5003
  try {
4831
5004
  const stat = fs19.lstatSync(path17.join(repo, file));
4832
- modified = Math.min(now, stat.mtimeMs);
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 (!previous || previous.fingerprint !== fingerprint) {
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 os2 from "os";
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 = os2.homedir(), options = {}) {
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} [EDIT] ${path19.basename(e.repoPath)}/${e.filePath}`);
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"]) : 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,
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 res = engine.beginWork({
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 = 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
+ }
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 res = await engine.broker.executeWithMutex({
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> 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)
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
  `);