kith-coord 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +49 -7
- package/dist/cli/index.js +238 -29
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +21 -9
- package/dist/index.js.map +1 -1
- package/docs/client-validation.md +16 -2
- package/docs/integrations.md +9 -3
- package/docs/protocol/tools.v1.json +74 -1
- package/docs/protocol.md +37 -4
- package/docs/reliability.md +9 -1
- package/docs/validation/2026-10-22-editor-chats.json +88 -0
- package/docs/validation/2026-10-22-editor-marketplaces.json +84 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.6 — 2026-10-23
|
|
4
|
+
|
|
5
|
+
- Add optional MCP operation keys with a capability-bound durable result journal in the existing database. Lost replies replay without repeating mutations; concurrent duplicates report in-progress, and interrupted/unrecorded outcomes refuse automatic re-execution. Historical replies never revive expired/completed sessions or authorize a replacement generation.
|
|
6
|
+
- Add structured operational failure codes and retryability while preserving existing result fields. Distinguish claim/mutex wait contention from command deadlines, partial effects, ownership rejection and failed integration; preserve bounded command output on cancellation/timeouts.
|
|
7
|
+
- Advance protocol to 1.4 and storage schema to 3. Transactional journal migration retains schema-2 ownership and recovery data; interrupted upgrades and missing journal state fail safely. No new runtime dependency, service or editor configuration changes.
|
|
8
|
+
|
|
3
9
|
## 0.1.5 — 2026-10-22
|
|
4
10
|
|
|
5
11
|
- Group read-only radar observations by checkout, distinguishing current recorded Kith leases, retained sessions, unregistered checkouts and unavailable ownership. Label counts as paths to review before integration, with full checkout/owner details and preservation guidance. Reuse the same snapshot in the optional editor view without extra status subprocesses. No ownership, recovery or cleanup behavior changes.
|
package/README.md
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://github.com/connorhriley/kith/actions/workflows/ci.yml"><img src="https://github.com/connorhriley/kith/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
7
|
+
<a href="https://github.com/connorhriley/kith/actions/workflows/windows.yml"><img src="https://github.com/connorhriley/kith/actions/workflows/windows.yml/badge.svg" alt="Windows validation"></a>
|
|
7
8
|
<a href="https://www.npmjs.com/package/kith-coord"><img src="https://img.shields.io/npm/v/kith-coord" alt="npm version"></a>
|
|
8
9
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-22C55E" alt="MIT license"></a>
|
|
9
10
|
</p>
|
|
10
11
|
|
|
11
12
|
<p align="center">
|
|
12
|
-
<a href="#quick-start">Quick start</a> · <a href="#see-it-work">Demo</a> · <a href="docs/integrations.md">Integrations</a> · <a href="docs/usage.md">CLI & API</a> · <a href="CONTRIBUTING.md">Contribute</a>
|
|
13
|
+
<a href="#quick-start">Quick start</a> · <a href="https://marketplace.visualstudio.com/items?itemName=kith-coord.kith">VS Code</a> · <a href="https://open-vsx.org/extension/kith-coord/kith">Cursor / Open VSX</a> · <a href="#see-it-work">Demo</a> · <a href="docs/integrations.md">Integrations</a> · <a href="docs/usage.md">CLI & API</a> · <a href="CONTRIBUTING.md">Contribute</a>
|
|
13
14
|
</p>
|
|
14
15
|
|
|
15
16
|
**Local coordination for cooperating coding agents.**
|
|
@@ -28,7 +29,7 @@ Kith gives cooperating agents shared local state: who owns a task, which files a
|
|
|
28
29
|
| Give agents separate working files | Git worktrees with an explicit integration step |
|
|
29
30
|
| Check work before integration | Your project's verification command |
|
|
30
31
|
| Share local tools | Port reservations and supervised command mutexes |
|
|
31
|
-
| See what is happening | Session/task state through
|
|
32
|
+
| See what is happening | Session/task state through CLI/MCP; optional read-only editor and worktree views |
|
|
32
33
|
|
|
33
34
|
Kith fits workflows where you already launch multiple coding agents and want them to coordinate. It does not choose models, write prompts, or launch a fleet for you. Worktrees separate files; Kith adds shared ownership and resource coordination.
|
|
34
35
|
|
|
@@ -36,14 +37,43 @@ The [reliability contract](docs/reliability.md) defines ownership, verification,
|
|
|
36
37
|
|
|
37
38
|
## Quick start
|
|
38
39
|
|
|
39
|
-
Requires **Git 2.38+** and **Node.js 22.13+ on 22.x or Node.js 24+**. Full workflows target macOS and Linux.
|
|
40
|
+
Requires **Git 2.38+** and **Node.js 22.13+ on 22.x or Node.js 24+**. Full workflows target macOS and Linux. Native Windows MCP checks cover claims, worktrees, ports, integration and recovery; supervised mutex commands and shell hooks require macOS/Linux or WSL. See the [support evidence](docs/client-validation.md#native-windows).
|
|
40
41
|
|
|
41
42
|
Git recording and integration require a commit identity configured for the repository (`user.name` and `user.email`). Kith does not change your global Git identity.
|
|
42
43
|
|
|
44
|
+
**Upgrading to 0.1.6:** Stop existing Kith connections first. Back up stopped coordination state (including SQLite sidecars) and retain worktrees; upgrade all coordinators sharing a repository together. This release migrates storage schema 2 to 3, which older binaries refuse. Existing custom launchers are preserved; review their pinned version before reconnecting. See [recovery guidance](docs/reliability.md#recovery).
|
|
45
|
+
|
|
46
|
+
### VS Code or Cursor
|
|
47
|
+
|
|
48
|
+
Install **Kith** (`kith-coord.kith`) from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kith-coord.kith) or [Cursor / Open VSX](https://open-vsx.org/extension/kith-coord/kith). You can also search for `kith-coord.kith` in the editor's Extensions view.
|
|
49
|
+
|
|
50
|
+
To select release **0.1.6** explicitly with an editor CLI already on your `PATH`:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
code --install-extension kith-coord.kith@0.1.6
|
|
54
|
+
cursor --install-extension kith-coord.kith@0.1.6
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Use the command for your editor. A marketplace lookup can temporarily select an older version; the [extension guide](extensions/editor/README.md#install) also links the published 0.1.6 VSIX.
|
|
58
|
+
|
|
59
|
+
Open a trusted local Git checkout and approve Kith's MCP server through the editor. The extension bundles the coordination server, detects Node, and preserves existing MCP entries, hooks and tool permissions. **No global npm install is needed.** Each agent still needs to use Kith's tools; installing the extension does not enforce claims on other writers.
|
|
60
|
+
|
|
61
|
+
Start with a scoped task:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Use Kith to begin an isolated task in this repository with a unique agent ID.
|
|
65
|
+
Claim only the files this task needs, edit the returned worktree, and report edits.
|
|
66
|
+
Run the project checks and complete through Kith with that verification command.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The [extension guide](extensions/editor/README.md) covers settings and existing configurations; [recorded editor checks](docs/client-validation.md#editor-marketplaces-and-signed-in-chats) distinguish installation, native MCP registration and signed-in task results.
|
|
70
|
+
|
|
71
|
+
### CLI and other MCP clients
|
|
72
|
+
|
|
43
73
|
Install the release:
|
|
44
74
|
|
|
45
75
|
```sh
|
|
46
|
-
npm install --global kith-coord@0.1.
|
|
76
|
+
npm install --global kith-coord@0.1.6
|
|
47
77
|
```
|
|
48
78
|
|
|
49
79
|
Choose the setup scope your client needs:
|
|
@@ -98,14 +128,15 @@ Default setup installs MCP configuration for Claude, Codex and Cursor plus agent
|
|
|
98
128
|
|
|
99
129
|
| Client | Setup and validation |
|
|
100
130
|
| --- | --- |
|
|
131
|
+
| VS Code | Marketplace extension; signed-in Copilot parallel task completed on VS Code 1.143.0 |
|
|
132
|
+
| Cursor | Marketplace extension and native MCP registration checked on 3.24.18; signed-in task's final retry pending after client usage limit; native CLI workflow separately validated |
|
|
101
133
|
| Claude Code | MCP; optional edit hooks need shared session setup; recorded native CLI runs |
|
|
102
134
|
| Codex | Project MCP configuration; recorded native CLI runs with configuration overrides |
|
|
103
|
-
| Cursor | Native CLI workflow and fail-closed edit hooks validated; GUI discovery/trust unvalidated |
|
|
104
135
|
| Other MCP clients | stdio server via `kith mcp`; client-specific validation required |
|
|
105
136
|
|
|
106
|
-
|
|
137
|
+
The [editor extension](extensions/editor/README.md) offers a bundled MCP server and a small read-only status indicator. Existing custom Kith entries take precedence, so review their launcher if a connection fails. The editor retains control of server approval and tool permissions.
|
|
107
138
|
|
|
108
|
-
Native checks are version-specific
|
|
139
|
+
Native checks are version-specific; the recorded GUI task used published extension 0.1.4. Later client or extension releases need their own checks. See [integration setup](docs/integrations.md), [recorded validation](docs/client-validation.md), and [session ownership](docs/sessions-and-mutexes.md) before mixing hooks, CLI commands, and MCP clients.
|
|
109
140
|
|
|
110
141
|
The npm package is **`kith-coord`**, with the command name **`kith`**. For a pinned npx MCP launcher, see [registry setup](docs/integrations.md#registry-installation). The unrelated npm package `kith` is not this project.
|
|
111
142
|
|
|
@@ -121,6 +152,17 @@ kith lock acquire --agent worker-1 --scope package.json --wait --wait-ms 30000
|
|
|
121
152
|
|
|
122
153
|
See [dev-server launch and waiting claims](docs/usage.md#launch-a-dev-server-on-its-allocated-port) for framework flags, session setup, cancellation, and MCP usage.
|
|
123
154
|
|
|
155
|
+
## Optional read-only monitoring
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
kith watch /path/to/workspace --once --json
|
|
159
|
+
kith menubar /path/to/workspace # macOS 12+, Intel or Apple Silicon
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The watcher compares uncommitted paths across linked worktrees of each repository; a workspace directory can contain several repositories. The editor and macOS menu show paths to review, checkout details and recorded active versus retained sessions. Retained work may still be unfinished: keep it until reviewed.
|
|
163
|
+
|
|
164
|
+
These are observations, not proof of a merge conflict or the identity of a process writing a file. Unavailable scans stay unavailable. Claims, resource coordination and verified integration remain the MCP/CLI workflow. See [observation limits](docs/usage.md#optional-read-only-worktree-observations).
|
|
165
|
+
|
|
124
166
|
## How a task moves through Kith
|
|
125
167
|
|
|
126
168
|
1. **Begin through MCP.** Call `kith_begin_work` with a unique `agentId`, explicit `scopeGlobs`, and `isolateWorktree: true`. The connection retains session ownership; no shell exports are needed for this MCP-only workflow.
|
package/dist/cli/index.js
CHANGED
|
@@ -6,8 +6,8 @@ import path19 from "path";
|
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
|
|
8
8
|
// src/version.ts
|
|
9
|
-
var KITH_VERSION = "0.1.
|
|
10
|
-
var KITH_PROTOCOL_VERSION = "1.
|
|
9
|
+
var KITH_VERSION = "0.1.6";
|
|
10
|
+
var KITH_PROTOCOL_VERSION = "1.4.0";
|
|
11
11
|
|
|
12
12
|
// src/cli/setup-ledger.ts
|
|
13
13
|
import fs2 from "fs";
|
|
@@ -343,7 +343,7 @@ var CoordDatabase = class _CoordDatabase {
|
|
|
343
343
|
}
|
|
344
344
|
if (!tables.some((table) => table.name === "meta")) throw new Error("Missing coordination schema metadata. Preserve the database before repair.");
|
|
345
345
|
const row = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
|
|
346
|
-
if (row?.value !== "1" && row?.value !== "2") {
|
|
346
|
+
if (row?.value !== "1" && row?.value !== "2" && row?.value !== "3") {
|
|
347
347
|
throw new Error(`Unsupported coordination schema version: ${String(row?.value)}. Preserve the database before upgrading or repairing it.`);
|
|
348
348
|
}
|
|
349
349
|
const version = Number(row.value);
|
|
@@ -358,7 +358,8 @@ var CoordDatabase = class _CoordDatabase {
|
|
|
358
358
|
"resources",
|
|
359
359
|
"quarantine",
|
|
360
360
|
"events",
|
|
361
|
-
...version
|
|
361
|
+
...version >= 2 ? ["agent_sessions", "mutex_waiters", "mutex_runs"] : [],
|
|
362
|
+
...version >= 3 ? ["mcp_operations"] : []
|
|
362
363
|
];
|
|
363
364
|
if (required.some((name) => !tables.some((table) => table.name === name))) {
|
|
364
365
|
throw new Error("Incomplete coordination schema. Preserve the database before repair.");
|
|
@@ -368,11 +369,12 @@ var CoordDatabase = class _CoordDatabase {
|
|
|
368
369
|
tasks: "status,owner_agent_id,fencing_token,scope_globs_json,depends_on_json",
|
|
369
370
|
locks: "owner_agent_id,pattern,mode,fencing_token,expires_at",
|
|
370
371
|
resources: "type,name,owner_agent_id,fencing_token,expires_at",
|
|
371
|
-
...version
|
|
372
|
+
...version >= 2 ? {
|
|
372
373
|
agent_sessions: "agent_id,token_hash,epoch",
|
|
373
374
|
mutex_waiters: "ticket,mutex_name,request_id,owner_agent_id,session_epoch,waiter_pid,waiter_start,expires_at",
|
|
374
375
|
mutex_runs: "mutex_name,run_id,owner_agent_id,session_epoch,fencing_token,supervisor_pid,supervisor_start,worker_pid,worker_start"
|
|
375
|
-
} : {}
|
|
376
|
+
} : {},
|
|
377
|
+
...version >= 3 ? { mcp_operations: "operation_key,request_hash,agent_id,session_epoch,capability_hash,owner_pid,owner_start,result_json,created_at" } : {}
|
|
376
378
|
};
|
|
377
379
|
for (const [table, fields] of Object.entries(columns)) {
|
|
378
380
|
try {
|
|
@@ -387,7 +389,8 @@ var CoordDatabase = class _CoordDatabase {
|
|
|
387
389
|
"SELECT fencing_token FROM tasks",
|
|
388
390
|
"SELECT fencing_token FROM resources",
|
|
389
391
|
"SELECT fencing_token FROM merge_queue",
|
|
390
|
-
...version
|
|
392
|
+
...version >= 2 ? ["SELECT epoch FROM agent_sessions", "SELECT session_epoch FROM mutex_waiters", "SELECT fencing_token FROM mutex_runs"] : [],
|
|
393
|
+
...version >= 3 ? ["SELECT session_epoch FROM mcp_operations"] : []
|
|
391
394
|
];
|
|
392
395
|
if (db.prepare(`SELECT 1 FROM (${tokens.join(" UNION ALL ")}) WHERE token IS NOT NULL AND (typeof(token)!='integer' OR token<0 OR token>?) LIMIT 1`).get(epoch)) {
|
|
393
396
|
throw new Error("Invalid or regressed fencing counter. Preserve the database before repair.");
|
|
@@ -565,6 +568,14 @@ var CoordDatabase = class _CoordDatabase {
|
|
|
565
568
|
worker_pid INTEGER, worker_start TEXT
|
|
566
569
|
);
|
|
567
570
|
`);
|
|
571
|
+
this.db.exec(`
|
|
572
|
+
CREATE TABLE IF NOT EXISTS mcp_operations (
|
|
573
|
+
operation_key TEXT PRIMARY KEY, request_hash TEXT NOT NULL,
|
|
574
|
+
agent_id TEXT NOT NULL, session_epoch INTEGER NOT NULL, capability_hash TEXT NOT NULL,
|
|
575
|
+
owner_pid INTEGER NOT NULL, owner_start TEXT NOT NULL,
|
|
576
|
+
result_json TEXT, created_at INTEGER NOT NULL
|
|
577
|
+
);
|
|
578
|
+
`);
|
|
568
579
|
if (version < 2) {
|
|
569
580
|
this.db.exec(`
|
|
570
581
|
UPDATE tasks SET status='queued', owner_agent_id=NULL, fencing_token=NULL
|
|
@@ -576,6 +587,7 @@ var CoordDatabase = class _CoordDatabase {
|
|
|
576
587
|
UPDATE meta SET value='2' WHERE key='schema_version';
|
|
577
588
|
`);
|
|
578
589
|
}
|
|
590
|
+
this.db.exec("UPDATE meta SET value='3' WHERE key='schema_version'");
|
|
579
591
|
});
|
|
580
592
|
}
|
|
581
593
|
/**
|
|
@@ -849,7 +861,7 @@ function readCoordinationState(directory = process.cwd(), options = {}) {
|
|
|
849
861
|
if (JSON.stringify(before) !== JSON.stringify(files.map(identity))) throw new Error("Coordination state changed during snapshot; stop writers or retry");
|
|
850
862
|
db = new DatabaseSync2(path4.join(scratch, "state.db"), { readOnly: true });
|
|
851
863
|
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000; BEGIN");
|
|
852
|
-
if (CoordDatabase.inspectSchema(db)
|
|
864
|
+
if (CoordDatabase.inspectSchema(db) < 2) throw new Error("Offline observation requires schema 2 or newer; preserve state and upgrade with clients stopped");
|
|
853
865
|
const all = (sql) => db.prepare(sql).all();
|
|
854
866
|
const agents = all("SELECT * FROM agents ORDER BY registered_at DESC");
|
|
855
867
|
if (options.ownersOnly) {
|
|
@@ -1622,10 +1634,10 @@ var LockEngine = class _LockEngine {
|
|
|
1622
1634
|
static normalizePattern(repoRoot, rawPattern) {
|
|
1623
1635
|
if (rawPattern.startsWith("file:")) return _LockEngine.literalScope(_LockEngine.normalizeFile(repoRoot, rawPattern.slice(5)));
|
|
1624
1636
|
if (rawPattern.startsWith("symbol:")) {
|
|
1625
|
-
const
|
|
1626
|
-
const name = rawPattern.slice(
|
|
1627
|
-
if (
|
|
1628
|
-
const file = _LockEngine.normalizeFile(repoRoot, rawPattern.slice(7,
|
|
1637
|
+
const hash2 = rawPattern.indexOf("#");
|
|
1638
|
+
const name = rawPattern.slice(hash2 + 1);
|
|
1639
|
+
if (hash2 < 0 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) throw new Error("Symbol scope must include #symbolName");
|
|
1640
|
+
const file = _LockEngine.normalizeFile(repoRoot, rawPattern.slice(7, hash2));
|
|
1629
1641
|
return `symbol:${file}#${name}`;
|
|
1630
1642
|
}
|
|
1631
1643
|
if (!rawPattern || /[\\\x00-\x1f\x7f]/u.test(rawPattern)) throw new Error(`Invalid repository scope: ${rawPattern}`);
|
|
@@ -1880,9 +1892,9 @@ var LockEngine = class _LockEngine {
|
|
|
1880
1892
|
return !this.pathCase.get(root);
|
|
1881
1893
|
});
|
|
1882
1894
|
const fold = (scope) => {
|
|
1883
|
-
const
|
|
1884
|
-
const file =
|
|
1885
|
-
return file.normalize("NFC").toLowerCase().replace(/ς/g, "\u03C3") + (
|
|
1895
|
+
const hash2 = scope.startsWith("symbol:") ? scope.lastIndexOf("#") : -1;
|
|
1896
|
+
const file = hash2 < 0 ? scope : scope.slice(0, hash2);
|
|
1897
|
+
return file.normalize("NFC").toLowerCase().replace(/ς/g, "\u03C3") + (hash2 < 0 ? "" : scope.slice(hash2));
|
|
1886
1898
|
};
|
|
1887
1899
|
const overlap = (a, b) => _LockEngine.patternsOverlap(a, b) || insensitive && _LockEngine.patternsOverlap(fold(a), fold(b));
|
|
1888
1900
|
return overlap(requested, existing) || overlap(requested, normalized) || this.wildcardHasAliases(requested) || this.wildcardHasAliases(normalized);
|
|
@@ -2481,7 +2493,7 @@ var ResourceBroker = class _ResourceBroker {
|
|
|
2481
2493
|
worker.once("error", reject);
|
|
2482
2494
|
}), stopped]);
|
|
2483
2495
|
if (params.signal?.aborted) failure ??= abortError();
|
|
2484
|
-
if (failure) throw failure;
|
|
2496
|
+
if (failure) throw Object.assign(failure, { stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString() });
|
|
2485
2497
|
if (!outcome) throw new Error("Command worker exited without a result");
|
|
2486
2498
|
if (outcome.error) throw new Error(outcome.error);
|
|
2487
2499
|
return { stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), exitCode: outcome.exitCode, waitedMs };
|
|
@@ -4046,25 +4058,188 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4046
4058
|
import { z } from "zod";
|
|
4047
4059
|
import fs16 from "fs";
|
|
4048
4060
|
import path15 from "path";
|
|
4061
|
+
|
|
4062
|
+
// src/mcp/retries.ts
|
|
4063
|
+
import crypto11 from "crypto";
|
|
4064
|
+
var RetryError = class extends Error {
|
|
4065
|
+
constructor(code, message, retryable = false) {
|
|
4066
|
+
super(message);
|
|
4067
|
+
this.code = code;
|
|
4068
|
+
this.retryable = retryable;
|
|
4069
|
+
}
|
|
4070
|
+
code;
|
|
4071
|
+
retryable;
|
|
4072
|
+
};
|
|
4073
|
+
function failureData(error) {
|
|
4074
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4075
|
+
let code = "OPERATION_FAILED", retryable = false;
|
|
4076
|
+
if (error instanceof RetryError) {
|
|
4077
|
+
code = error.code;
|
|
4078
|
+
retryable = error.retryable;
|
|
4079
|
+
} else if (error instanceof SessionOwnershipError) code = "SESSION_OWNERSHIP";
|
|
4080
|
+
else if (error instanceof StaleFencingTokenError) code = "STALE_FENCING_TOKEN";
|
|
4081
|
+
else if (error instanceof Error && error.name === "AbortError") code = "CANCELLED";
|
|
4082
|
+
else if (message.startsWith("Timed out waiting for mutex ")) {
|
|
4083
|
+
code = "MUTEX_WAIT_TIMEOUT";
|
|
4084
|
+
retryable = true;
|
|
4085
|
+
} else if (message === "Mutex command execution timed out") code = "COMMAND_TIMEOUT";
|
|
4086
|
+
else if (message === "Mutex command output limit exceeded") code = "COMMAND_OUTPUT_LIMIT";
|
|
4087
|
+
else if (/SQLITE_BUSY|database is locked/.test(message)) code = "DATABASE_BUSY";
|
|
4088
|
+
else if (/schema|coordination database|fencing counter|recovery state/i.test(message)) code = "STATE_UNAVAILABLE";
|
|
4089
|
+
else if (/repository|checkout path|project selected|connection is pinned|Command cwd|Invalid agent ID|Lease duration|Unsupported.*pattern/i.test(message)) code = "INVALID_REQUEST";
|
|
4090
|
+
const details = error;
|
|
4091
|
+
return {
|
|
4092
|
+
ok: false,
|
|
4093
|
+
error: message,
|
|
4094
|
+
failure: {
|
|
4095
|
+
code,
|
|
4096
|
+
message,
|
|
4097
|
+
retryable,
|
|
4098
|
+
...retryable ? { retryAfterMs: 100 } : {}
|
|
4099
|
+
},
|
|
4100
|
+
...typeof details?.stdout === "string" ? { stdout: details.stdout } : {},
|
|
4101
|
+
...typeof details?.stderr === "string" ? { stderr: details.stderr } : {}
|
|
4102
|
+
};
|
|
4103
|
+
}
|
|
4104
|
+
function classifyResult(data) {
|
|
4105
|
+
if (data.ok !== false || data.failure) return data;
|
|
4106
|
+
if (data.mergeResult) return { ...data, failure: {
|
|
4107
|
+
code: "INTEGRATION_FAILED",
|
|
4108
|
+
retryable: false,
|
|
4109
|
+
message: "Inspect mergeResult.diagnostics and repair or preserve the source worktree before a new completion attempt."
|
|
4110
|
+
} };
|
|
4111
|
+
if (Array.isArray(data.lockConflicts) || Array.isArray(data.conflicts)) return { ...data, failure: {
|
|
4112
|
+
code: data.waitTimedOut ? "CLAIM_WAIT_TIMEOUT" : "CLAIM_CONFLICT",
|
|
4113
|
+
retryable: true,
|
|
4114
|
+
retryAfterMs: 100,
|
|
4115
|
+
message: "Scope is owned by another session. Back off before a new attempt (with a new operationKey if used)."
|
|
4116
|
+
} };
|
|
4117
|
+
if (typeof data.exitCode === "number") return { ...data, failure: {
|
|
4118
|
+
code: "COMMAND_FAILED",
|
|
4119
|
+
retryable: false,
|
|
4120
|
+
message: "Inspect stdout, stderr and exitCode; commands may have partial effects. Repair before a new attempt."
|
|
4121
|
+
} };
|
|
4122
|
+
return { ...data, failure: {
|
|
4123
|
+
code: "OPERATION_FAILED",
|
|
4124
|
+
retryable: false,
|
|
4125
|
+
message: typeof data.error === "string" ? data.error : "Inspect the result before a new attempt."
|
|
4126
|
+
} };
|
|
4127
|
+
}
|
|
4128
|
+
function toolReply(data, footer = []) {
|
|
4129
|
+
data = classifyResult(data);
|
|
4130
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }, ...footer], structuredContent: data, isError: data.ok === false };
|
|
4131
|
+
}
|
|
4132
|
+
var hash = (text) => crypto11.createHash("sha256").update(text).digest("hex");
|
|
4133
|
+
function canonical(value) {
|
|
4134
|
+
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
|
|
4135
|
+
if (value && typeof value === "object") return "{" + Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => JSON.stringify(k) + ":" + canonical(v)).join(",") + "}";
|
|
4136
|
+
return JSON.stringify(value);
|
|
4137
|
+
}
|
|
4138
|
+
var OperationJournal = class _OperationJournal {
|
|
4139
|
+
constructor(db) {
|
|
4140
|
+
this.db = db;
|
|
4141
|
+
}
|
|
4142
|
+
db;
|
|
4143
|
+
static undurable = /* @__PURE__ */ new Set();
|
|
4144
|
+
async execute(params) {
|
|
4145
|
+
const { operationKey: _key, sessionToken: _token, ...input } = params.input;
|
|
4146
|
+
const localKey = this.db.dbPath + ":" + params.key;
|
|
4147
|
+
const requestHash = hash(canonical({ tool: params.tool, checkout: this.db.repoRoot, input }));
|
|
4148
|
+
const operation = (state, replayed) => ({ key: params.key, state, replayed });
|
|
4149
|
+
const previous = this.db.withTransaction(() => {
|
|
4150
|
+
const row = this.db.db.prepare("SELECT * FROM mcp_operations WHERE operation_key=?").get(params.key);
|
|
4151
|
+
if (row) {
|
|
4152
|
+
const token = params.token();
|
|
4153
|
+
if (!token || row.agent_id !== params.agentId || hash(token) !== row.capability_hash) throw new SessionOwnershipError(params.agentId ?? "");
|
|
4154
|
+
if (row.request_hash !== requestHash) throw new RetryError("OPERATION_KEY_REUSED", "operationKey already identifies a different tool, checkout or input. Use a new key for new work.");
|
|
4155
|
+
return row;
|
|
4156
|
+
}
|
|
4157
|
+
const identity = params.authorize();
|
|
4158
|
+
const start = FencingAndAgentManager.getProcessStartTime(process.pid);
|
|
4159
|
+
if (!start) throw new RetryError("STATE_UNAVAILABLE", "Cannot identify operation owner; no operation was started.");
|
|
4160
|
+
this.db.db.prepare(`INSERT INTO mcp_operations
|
|
4161
|
+
(operation_key,request_hash,agent_id,session_epoch,capability_hash,owner_pid,owner_start,result_json,created_at)
|
|
4162
|
+
VALUES (?,?,?,?,?,?,?,NULL,?)`).run(
|
|
4163
|
+
params.key,
|
|
4164
|
+
requestHash,
|
|
4165
|
+
identity.agentId,
|
|
4166
|
+
identity.sessionEpoch,
|
|
4167
|
+
hash(identity.token),
|
|
4168
|
+
process.pid,
|
|
4169
|
+
start,
|
|
4170
|
+
Date.now()
|
|
4171
|
+
);
|
|
4172
|
+
return void 0;
|
|
4173
|
+
});
|
|
4174
|
+
if (previous) {
|
|
4175
|
+
if (previous.result_json !== null) {
|
|
4176
|
+
let saved2;
|
|
4177
|
+
try {
|
|
4178
|
+
saved2 = JSON.parse(previous.result_json);
|
|
4179
|
+
if (!saved2.data || typeof saved2.data !== "object" || Array.isArray(saved2.data) || typeof saved2.grantsToken !== "boolean") throw new Error();
|
|
4180
|
+
} catch {
|
|
4181
|
+
throw new RetryError("STATE_UNAVAILABLE", "Unreadable operation result. Preserve coordination state before repair.");
|
|
4182
|
+
}
|
|
4183
|
+
return toolReply({ ...saved2.data, ...saved2.grantsToken ? { sessionToken: params.token() } : {}, operation: operation("completed", true) });
|
|
4184
|
+
}
|
|
4185
|
+
const alive = !_OperationJournal.undurable.has(localKey) && FencingAndAgentManager.isProcessAlive(previous.owner_pid, previous.owner_start);
|
|
4186
|
+
return toolReply({
|
|
4187
|
+
...failureData(new RetryError(
|
|
4188
|
+
alive ? "OPERATION_IN_PROGRESS" : "OPERATION_OUTCOME_UNKNOWN",
|
|
4189
|
+
alive ? "Operation has not returned a durable result. Retry this same key after backoff; do not start a duplicate." : "Operation result was not recorded after its owner stopped or persistence failed. Inspect status, worktrees and external effects before deliberately starting new work with a new key.",
|
|
4190
|
+
alive
|
|
4191
|
+
)),
|
|
4192
|
+
operation: operation(alive ? "running" : "unknown", true)
|
|
4193
|
+
});
|
|
4194
|
+
}
|
|
4195
|
+
let reply;
|
|
4196
|
+
try {
|
|
4197
|
+
reply = await params.run();
|
|
4198
|
+
} catch (error) {
|
|
4199
|
+
reply = toolReply(failureData(error));
|
|
4200
|
+
}
|
|
4201
|
+
const data = reply.structuredContent;
|
|
4202
|
+
const { sessionToken, ...saved } = data;
|
|
4203
|
+
try {
|
|
4204
|
+
const result = this.db.db.prepare("UPDATE mcp_operations SET result_json=? WHERE operation_key=? AND result_json IS NULL").run(JSON.stringify({ data: saved, grantsToken: typeof sessionToken === "string" }), params.key);
|
|
4205
|
+
if (Number(result.changes) !== 1) throw new Error("Operation result was not recorded");
|
|
4206
|
+
} catch {
|
|
4207
|
+
_OperationJournal.undurable.add(localKey);
|
|
4208
|
+
return toolReply({
|
|
4209
|
+
...failureData(new RetryError(
|
|
4210
|
+
"OPERATION_OUTCOME_UNKNOWN",
|
|
4211
|
+
"The operation may have completed but its result could not be recorded. Preserve state and inspect effects; do not repeat it automatically."
|
|
4212
|
+
)),
|
|
4213
|
+
operation: operation("unknown", false)
|
|
4214
|
+
});
|
|
4215
|
+
}
|
|
4216
|
+
return toolReply({ ...data, operation: operation("completed", false) }, reply.content.slice(1));
|
|
4217
|
+
}
|
|
4218
|
+
};
|
|
4219
|
+
|
|
4220
|
+
// src/mcp/server.ts
|
|
4049
4221
|
function buildKithMcpServer(repoRoot, pinned) {
|
|
4050
4222
|
const pinnedRoot = pinned ? discoverRepository(repoRoot).checkoutRoot : void 0;
|
|
4051
4223
|
const server = new McpServer({
|
|
4052
4224
|
name: "kith-coordinator",
|
|
4053
4225
|
version: KITH_VERSION
|
|
4054
|
-
}, { 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.") });
|
|
4226
|
+
}, { 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. For mutations, generate a unique operationKey and reuse it only to recover that exact attempt after a timeout. Replay is a historical result, never a renewed claim. Inspect failure.code and failure.retryable; an unknown command outcome requires inspection, not automatic re-execution. " + (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.") });
|
|
4055
4227
|
const disconnected = new AbortController();
|
|
4056
4228
|
const commands = /* @__PURE__ */ new Set();
|
|
4057
4229
|
const createContext = (root, inheritCapability) => {
|
|
4058
4230
|
const engine2 = new KithEngine(root);
|
|
4059
4231
|
const owned = /* @__PURE__ */ new Set();
|
|
4060
|
-
const
|
|
4232
|
+
const capabilities = /* @__PURE__ */ new Map();
|
|
4233
|
+
const journal = new OperationJournal(engine2.coordDb);
|
|
4234
|
+
const tokenFor = (args) => args.sessionToken ?? (inheritCapability && process.env.KITH_AGENT_ID === args.agentId ? process.env.KITH_SESSION_TOKEN : capabilities.get(args.agentId));
|
|
4061
4235
|
const register = (args) => {
|
|
4062
4236
|
const result = engine2.fencing.registerAgent({
|
|
4063
4237
|
id: args.agentId,
|
|
4064
4238
|
client: args.client ?? "mcp",
|
|
4065
|
-
sessionToken:
|
|
4239
|
+
sessionToken: args.sessionToken ?? (inheritCapability && process.env.KITH_AGENT_ID === args.agentId ? process.env.KITH_SESSION_TOKEN : void 0),
|
|
4066
4240
|
leaseDurationMs: args.leaseDurationMs
|
|
4067
4241
|
});
|
|
4242
|
+
capabilities.set(args.agentId, result.sessionToken);
|
|
4068
4243
|
if (result.agent.pid === process.pid) owned.add(args.agentId);
|
|
4069
4244
|
return result;
|
|
4070
4245
|
};
|
|
@@ -4076,10 +4251,7 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4076
4251
|
const formatReply = (agentId, payload) => {
|
|
4077
4252
|
const delta = agentId ? engine2.consumeAgentDelta(agentId).formattedFooter : "";
|
|
4078
4253
|
const data = payload;
|
|
4079
|
-
return {
|
|
4080
|
-
{ type: "text", text: JSON.stringify(data, null, 2) },
|
|
4081
|
-
...delta ? [{ type: "text", text: delta }] : []
|
|
4082
|
-
], structuredContent: data, isError: data.ok === false };
|
|
4254
|
+
return toolReply(data, delta ? [{ type: "text", text: delta }] : []);
|
|
4083
4255
|
};
|
|
4084
4256
|
const close = () => {
|
|
4085
4257
|
for (const id of owned) {
|
|
@@ -4090,7 +4262,7 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4090
4262
|
}
|
|
4091
4263
|
engine2.close();
|
|
4092
4264
|
};
|
|
4093
|
-
return { engine: engine2, register, authorize, formatReply, close };
|
|
4265
|
+
return { engine: engine2, register, authorize, formatReply, close, journal, tokenFor };
|
|
4094
4266
|
};
|
|
4095
4267
|
const contexts = /* @__PURE__ */ new Map();
|
|
4096
4268
|
const contextFor = (repository) => {
|
|
@@ -4109,14 +4281,48 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4109
4281
|
};
|
|
4110
4282
|
const engine = pinned ? contextFor().engine : void 0;
|
|
4111
4283
|
const tool = (name, description, schema, handler) => {
|
|
4112
|
-
const
|
|
4284
|
+
const keyed = !["kith_get_status", "kith_preview_integrate", "kith_heartbeat"].includes(name);
|
|
4285
|
+
const inputSchema = { ...schema, ...keyed ? { operationKey: z.string().min(1).max(128).optional().describe("Unique client-generated key for this exact mutation. Reuse only to recover its result; never an ownership credential. Retried failures are snapshots; use a new key after repair or backoff.") } : {}, 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.") };
|
|
4113
4286
|
server.tool(
|
|
4114
4287
|
name,
|
|
4115
4288
|
description,
|
|
4116
4289
|
inputSchema,
|
|
4117
4290
|
async (args, extra) => {
|
|
4118
4291
|
const { repository, ...input } = args;
|
|
4119
|
-
|
|
4292
|
+
const execution = (async () => {
|
|
4293
|
+
try {
|
|
4294
|
+
const context = contextFor(repository);
|
|
4295
|
+
const run = async () => {
|
|
4296
|
+
extra.signal.throwIfAborted();
|
|
4297
|
+
return handler(input, extra, context);
|
|
4298
|
+
};
|
|
4299
|
+
if (!keyed || input.operationKey === void 0) return await run();
|
|
4300
|
+
const identity = input;
|
|
4301
|
+
return await context.journal.execute({
|
|
4302
|
+
key: input.operationKey,
|
|
4303
|
+
tool: name,
|
|
4304
|
+
input,
|
|
4305
|
+
agentId: identity.agentId,
|
|
4306
|
+
token: () => identity.agentId ? context.tokenFor({ ...identity, agentId: identity.agentId }) : void 0,
|
|
4307
|
+
authorize: () => {
|
|
4308
|
+
if (!identity.agentId) throw new RetryError("INVALID_REQUEST", "Keyed mutations require agentId and an owned session. Start a session first.");
|
|
4309
|
+
const args2 = { ...identity, agentId: identity.agentId };
|
|
4310
|
+
if (["kith_start_session", "kith_begin_work", "kith_claim_next_task"].includes(name)) context.register(args2);
|
|
4311
|
+
else context.authorize(args2);
|
|
4312
|
+
return { agentId: identity.agentId, sessionEpoch: context.engine.fencing.assertSession(identity.agentId), token: context.tokenFor(args2) };
|
|
4313
|
+
},
|
|
4314
|
+
run
|
|
4315
|
+
});
|
|
4316
|
+
} catch (error) {
|
|
4317
|
+
return toolReply(failureData(error));
|
|
4318
|
+
}
|
|
4319
|
+
})();
|
|
4320
|
+
commands.add(execution);
|
|
4321
|
+
try {
|
|
4322
|
+
return await execution;
|
|
4323
|
+
} finally {
|
|
4324
|
+
commands.delete(execution);
|
|
4325
|
+
}
|
|
4120
4326
|
}
|
|
4121
4327
|
);
|
|
4122
4328
|
};
|
|
@@ -4213,6 +4419,8 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4213
4419
|
"Add a task to the orchestrated DAG queue with optional task dependencies and file scope globs.",
|
|
4214
4420
|
{
|
|
4215
4421
|
id: z.string().optional(),
|
|
4422
|
+
agentId: z.string().optional().describe("Required for operationKey; identifies an already owned session."),
|
|
4423
|
+
sessionToken: z.string().optional(),
|
|
4216
4424
|
title: z.string(),
|
|
4217
4425
|
intentSummary: z.string().optional(),
|
|
4218
4426
|
priority: z.number().optional(),
|
|
@@ -4220,6 +4428,7 @@ function buildKithMcpServer(repoRoot, pinned) {
|
|
|
4220
4428
|
scopeGlobs: z.array(z.string()).optional()
|
|
4221
4429
|
},
|
|
4222
4430
|
async (args, extra, { engine: engine2, register, authorize, formatReply }) => {
|
|
4431
|
+
if (args.agentId) authorize({ ...args, agentId: args.agentId });
|
|
4223
4432
|
const task = engine2.intents.addTask(args);
|
|
4224
4433
|
return formatReply(void 0, { ok: true, task });
|
|
4225
4434
|
}
|
|
@@ -4900,17 +5109,17 @@ function contentIdentity(repo, file) {
|
|
|
4900
5109
|
try {
|
|
4901
5110
|
const before = fs19.fstatSync(fd);
|
|
4902
5111
|
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}`);
|
|
4903
|
-
const
|
|
5112
|
+
const hash2 = createHash("sha256");
|
|
4904
5113
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
4905
5114
|
for (let offset = 0; offset < before.size; ) {
|
|
4906
5115
|
const count = fs19.readSync(fd, buffer, 0, Math.min(buffer.length, before.size - offset), offset);
|
|
4907
5116
|
if (!count) throw new Error(`File changed during comparison: ${file}`);
|
|
4908
|
-
|
|
5117
|
+
hash2.update(buffer.subarray(0, count));
|
|
4909
5118
|
offset += count;
|
|
4910
5119
|
}
|
|
4911
5120
|
const after = fs19.fstatSync(fd);
|
|
4912
5121
|
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`File changed during comparison: ${file}`);
|
|
4913
|
-
return `file:${before.mode & 73}:${
|
|
5122
|
+
return `file:${before.mode & 73}:${hash2.digest("hex")}`;
|
|
4914
5123
|
} finally {
|
|
4915
5124
|
fs19.closeSync(fd);
|
|
4916
5125
|
}
|