Skip to content

Commit 2a8e4c0

Browse files
hyperpolymathclaude
andcommitted
feat(local-coord-mcp): per-window peer ID disambiguation via context field
Running 3 Claude Code windows on one box previously collided in peer IDs like claude-7f3a / claude-b2c1 / claude-4e9d — readable but nothing said which window was which. Adding an optional context disambiguator so peer_id becomes claude-7f3a@007-lang / claude-b2c1@aerie / claude-4e9d@eclipse-repos. Changes: - ffi: Peer gains context [32]u8 + context_len. New exports coord_set_context (alphanumeric/hyphen/underscore only, max 32, rollback on bad input) and coord_read_peer_context. coord_register clears context on slot reuse. Tests cover valid/invalid context + slot reuse cleanup + coord_find_peer_by_suffix happy/miss paths. - adapter: coord_register accepts optional `context` JSON field; on validation failure the half-registered peer is deregistered so the caller can retry cleanly. renderPeerId + extractSuffix helpers keep peer_id format consistent across register/list/send. coord_list_peers returns context as a separate field AND embedded in peer_id. coord_send target parser handles both <kind>-<4hex> and <kind>-<4hex>@<context> forms. - cartridge.ncl + cartridge.json: declare the new optional context field. - mcp-bridge/lib/tools.js: surface context in coord_register input schema. - version: 0.1.0 -> 0.2.0 (covers dispatch completion in 5d57daa + this). Follows the supervision architecture in memory/project_coord_supervision_architecture.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5d57daa commit 2a8e4c0

5 files changed

Lines changed: 185 additions & 12 deletions

File tree

cartridges/local-coord-mcp/adapter/local_coord_adapter.zig

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ fn parseToken(token_hex: []const u8, out: *[16]u8) bool {
4343
return true;
4444
}
4545

46+
/// Render a peer_id into the caller buffer. Format is `<kind>-<4hex>` when
47+
/// ctx is empty, `<kind>-<4hex>@<context>` when set. Returns the slice of
48+
/// buf actually used.
49+
fn renderPeerId(buf: []u8, kind_str: []const u8, suffix: []const u8, ctx: []const u8) ![]u8 {
50+
if (ctx.len == 0) {
51+
return try std.fmt.bufPrint(buf, "{s}-{s}", .{ kind_str, suffix });
52+
}
53+
return try std.fmt.bufPrint(buf, "{s}-{s}@{s}", .{ kind_str, suffix, ctx });
54+
}
55+
56+
/// Extract the 4-char hex suffix from a target peer_id string. Format is
57+
/// `<kind>-<4hex>` or `<kind>-<4hex>@<context>`. Returns null if malformed.
58+
fn extractSuffix(target: []const u8) ?[]const u8 {
59+
// Find the last '-' before any '@' — the 4 hex chars follow it.
60+
const at_pos = std.mem.indexOfScalar(u8, target, '@') orelse target.len;
61+
const left = target[0..at_pos];
62+
const dash_pos = std.mem.lastIndexOfScalar(u8, left, '-') orelse return null;
63+
const suffix = left[dash_pos + 1 ..];
64+
if (suffix.len != 4) return null;
65+
return suffix;
66+
}
67+
4668
fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.Allocator) Response {
4769
if (std.mem.eql(u8, tool, "coord_register")) {
4870
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
@@ -55,19 +77,37 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
5577
if (std.mem.eql(u8, kind_str, "gemini")) kind = 1;
5678
if (std.mem.eql(u8, kind_str, "copilot")) kind = 2;
5779

80+
// Optional context for per-window disambiguation.
81+
const ctx_str: []const u8 = blk: {
82+
const ctx_val = parsed.value.object.get("context") orelse break :blk "";
83+
break :blk ctx_val.string;
84+
};
85+
5886
var token: [16]u8 = undefined;
5987
var suffix: [4]u8 = undefined;
6088
const idx = ffi.coord_register(kind, &token, &suffix);
6189
if (idx < 0) return .{ .status = 500, .body = errJson(resp, "registry full") };
6290

91+
if (ctx_str.len > 0) {
92+
const set_rc = ffi.coord_set_context(&token, 16, ctx_str.ptr, @intCast(ctx_str.len));
93+
if (set_rc < 0) {
94+
// Rollback: deregister the half-registered peer so the caller can retry cleanly.
95+
_ = ffi.coord_deregister(&token, 16);
96+
return .{ .status = 400, .body = errJson(resp, "invalid context (alphanumeric/hyphen/underscore only, max 32 bytes)") };
97+
}
98+
}
99+
63100
var token_hex: [32]u8 = undefined;
64101
const hex_chars = "0123456789abcdef";
65102
for (token, 0..) |b, i| {
66103
token_hex[i * 2] = hex_chars[b >> 4];
67104
token_hex[i * 2 + 1] = hex_chars[b & 0x0f];
68105
}
69106

70-
const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"peer_id\":\"{s}-{s}\",\"token\":\"{s}\"}}", .{ kind_str, suffix, token_hex }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
107+
var peer_id_buf: [96]u8 = undefined;
108+
const peer_id = renderPeerId(&peer_id_buf, kind_str, &suffix, ctx_str) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") };
109+
110+
const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"peer_id\":\"{s}\",\"token\":\"{s}\"}}", .{ peer_id, token_hex }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
71111
return .{ .status = 200, .body = body_out };
72112
}
73113

@@ -108,9 +148,16 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
108148
const status_len = ffi.coord_read_peer_status(i, &status_buf, @intCast(status_buf.len));
109149
const status_slice: []const u8 = if (status_len > 0) status_buf[0..@intCast(status_len)] else "";
110150

151+
var ctx_buf: [32]u8 = undefined;
152+
const ctx_len = ffi.coord_read_peer_context(i, &ctx_buf, @intCast(ctx_buf.len));
153+
const ctx_slice: []const u8 = if (ctx_len > 0) ctx_buf[0..@intCast(ctx_len)] else "";
154+
155+
var peer_id_buf: [96]u8 = undefined;
156+
const peer_id = renderPeerId(&peer_id_buf, kindName(kind_val), suffix, ctx_slice) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") };
157+
111158
if (written_idx > 0) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
112-
std.fmt.format(w, "{{\"peer_id\":\"{s}-{s}\",\"kind\":\"{s}\",\"state\":\"{s}\",\"status\":\"{s}\"}}", .{
113-
kindName(kind_val), suffix, kindName(kind_val), stateName(state), status_slice,
159+
std.fmt.format(w, "{{\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"state\":\"{s}\",\"context\":\"{s}\",\"status\":\"{s}\"}}", .{
160+
peer_id, kindName(kind_val), stateName(state), ctx_slice, status_slice,
114161
}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
115162
written_idx += 1;
116163
}
@@ -132,9 +179,8 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
132179
const target_str = target_val.string;
133180
var target_idx: i32 = -1;
134181
if (!std.mem.eql(u8, target_str, "*")) {
135-
// Peer ID format: "<kind>-<suffix>". Extract suffix (last 4 chars).
136-
if (target_str.len < 5) return .{ .status = 400, .body = errJson(resp, "invalid target format") };
137-
const suffix = target_str[target_str.len - 4 ..];
182+
// Peer ID format: "<kind>-<4hex>" or "<kind>-<4hex>@<context>".
183+
const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid target format — expected <kind>-<4hex>[@<context>]") };
138184
target_idx = ffi.coord_find_peer_by_suffix(suffix.ptr);
139185
if (target_idx < 0) return .{ .status = 404, .body = errJson(resp, "target peer not found") };
140186
}

cartridges/local-coord-mcp/cartridge.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"tier": "Ayo",
2929
"tools": [
3030
{
31-
"description": "Register this instance as a coordination peer. Returns a hybrid peer ID (e.g. claude-7f3a) and a session token for all subsequent calls.",
31+
"description": "Register this instance as a coordination peer. Returns a peer ID and a session token for all subsequent calls. Optional `context` (alphanumeric + hyphen/underscore, max 32 bytes) disambiguates multiple windows of the same client_kind — peer_id becomes <kind>-<4hex>@<context>.",
3232
"inputSchema": {
3333
"properties": {
3434
"client_kind": {
@@ -40,6 +40,10 @@
4040
"custom"
4141
],
4242
"type": "string"
43+
},
44+
"context": {
45+
"description": "Optional per-window disambiguator (repo name, tty label). Alphanumeric/hyphen/underscore only, max 32 chars.",
46+
"type": "string"
4347
}
4448
},
4549
"required": [
@@ -150,5 +154,5 @@
150154
"name": "coord_status"
151155
}
152156
],
153-
"version": "0.1.0"
157+
"version": "0.2.0"
154158
}

cartridges/local-coord-mcp/cartridge.ncl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
spdx = "PMPL-1.0-or-later",
1212
copyright = "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>",
1313
name = "local-coord-mcp",
14-
version = "0.1.0",
14+
version = "0.2.0",
1515
description = "Localhost multi-instance coordination — peer discovery, message passing, and task claiming for parallel AI sessions on the same machine",
1616
domain = "ai",
1717
tier = "Ayo",
@@ -35,7 +35,7 @@
3535
tools = [
3636
{
3737
name = "coord_register",
38-
description = "Register this instance as a coordination peer. Returns a hybrid peer ID (e.g. claude-7f3a) and a session token for all subsequent calls.",
38+
description = "Register this instance as a coordination peer. Returns a peer ID and a session token for all subsequent calls. Optional `context` (alphanumeric + hyphen/underscore, max 32 bytes) disambiguates multiple windows of the same client_kind — peer_id becomes <kind>-<4hex>@<context>.",
3939
inputSchema = {
4040
type = "object",
4141
properties = {
@@ -44,6 +44,10 @@
4444
description = "Client type: claude, gemini, copilot, or custom",
4545
enum = ["claude", "gemini", "copilot", "custom"],
4646
},
47+
context = {
48+
type = "string",
49+
description = "Optional per-window disambiguator (repo name, tty label). Alphanumeric/hyphen/underscore only, max 32 chars.",
50+
},
4751
},
4852
required = ["client_kind"],
4953
},

cartridges/local-coord-mcp/ffi/local_coord_ffi.zig

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ pub const ClaimResult = enum(c_int) {
6060
// Peer Registry
6161
// ═══════════════════════════════════════════════════════════════════════
6262

63+
/// Per-window context disambiguator. Short label (e.g. repo name, tty-hash)
64+
/// appended to peer_id as `<kind>-<4hex>@<context>`. Optional — empty means
65+
/// the old `<kind>-<4hex>` form. Alphanumeric + hyphens only; enforced in
66+
/// coord_set_context.
67+
const MAX_CONTEXT: usize = 32;
68+
6369
const Peer = struct {
6470
active: bool,
6571
kind: ClientKind,
@@ -75,6 +81,9 @@ const Peer = struct {
7581
// Status string
7682
status: [256]u8,
7783
status_len: u16,
84+
// Context disambiguator (repo / tty / window label)
85+
context: [MAX_CONTEXT]u8,
86+
context_len: u8,
7887
};
7988

8089
const empty_peer = Peer{
@@ -90,6 +99,8 @@ const empty_peer = Peer{
9099
.inbox_count = 0,
91100
.status = [_]u8{0} ** 256,
92101
.status_len = 0,
102+
.context = [_]u8{0} ** MAX_CONTEXT,
103+
.context_len = 0,
93104
};
94105

95106
var peers: [MAX_PEERS]Peer = [_]Peer{empty_peer} ** MAX_PEERS;
@@ -211,6 +222,7 @@ pub export fn coord_register(kind: c_int, token_out: [*]u8, suffix_out: [*]u8) c
211222
p.inbox_tail = 0;
212223
p.inbox_count = 0;
213224
p.status_len = 0;
225+
p.context_len = 0; // reset on slot reuse
214226

215227
// Copy token and suffix to caller
216228
@memcpy(token_out[0..TOKEN_LEN], &p.token);
@@ -222,6 +234,51 @@ pub export fn coord_register(kind: c_int, token_out: [*]u8, suffix_out: [*]u8) c
222234
return -1; // registry full
223235
}
224236

237+
/// Set a context disambiguator for this peer (repo name, tty hash, window
238+
/// label). Must be alphanumeric or hyphen, max MAX_CONTEXT bytes — anything
239+
/// else returns -2 and the existing context is untouched.
240+
pub export fn coord_set_context(
241+
token_ptr: [*]const u8,
242+
token_len: c_int,
243+
ctx_ptr: [*]const u8,
244+
ctx_len: c_int,
245+
) c_int {
246+
mutex.lock();
247+
defer mutex.unlock();
248+
249+
const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1;
250+
const clen: usize = @intCast(ctx_len);
251+
if (clen > MAX_CONTEXT) return -2;
252+
253+
// Validate: alphanum + hyphen + underscore only.
254+
var k: usize = 0;
255+
while (k < clen) : (k += 1) {
256+
const c = ctx_ptr[k];
257+
const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'z') or
258+
(c >= 'A' and c <= 'Z') or c == '-' or c == '_';
259+
if (!ok) return -2;
260+
}
261+
262+
if (clen > 0) @memcpy(peers[idx].context[0..clen], ctx_ptr[0..clen]);
263+
peers[idx].context_len = @intCast(clen);
264+
return 0;
265+
}
266+
267+
/// Read a peer's context disambiguator. Writes up to out_cap bytes into out.
268+
/// Returns context length on success, 0 if unset, -1 if peer index out of
269+
/// range / inactive. Caller token is not required — context is broadcast-
270+
/// visible by design (it's how other peers identify which window this is).
271+
pub export fn coord_read_peer_context(peer_idx: c_int, out: [*]u8, out_cap: c_int) c_int {
272+
mutex.lock();
273+
defer mutex.unlock();
274+
if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1;
275+
const p = &peers[@intCast(peer_idx)];
276+
if (!p.active) return -1;
277+
const clen: usize = @min(@as(usize, p.context_len), @as(usize, @intCast(out_cap)));
278+
if (clen > 0) @memcpy(out[0..clen], p.context[0..clen]);
279+
return @intCast(clen);
280+
}
281+
225282
/// Deregister a peer. Releases any claims it holds.
226283
pub export fn coord_deregister(token_ptr: [*]const u8, token_len: c_int) c_int {
227284
mutex.lock();
@@ -438,7 +495,7 @@ pub export fn boj_cartridge_name() [*:0]const u8 {
438495
}
439496

440497
pub export fn boj_cartridge_version() [*:0]const u8 {
441-
return "0.1.0";
498+
return "0.2.0";
442499
}
443500

444501
// ═══════════════════════════════════════════════════════════════════════
@@ -636,6 +693,67 @@ test "broadcast message" {
636693
coord_reset();
637694
}
638695

696+
test "set and read peer context" {
697+
coord_reset();
698+
var tok: [TOKEN_LEN]u8 = undefined;
699+
var suf: [4]u8 = undefined;
700+
const idx = coord_register(0, &tok, &suf);
701+
try std.testing.expect(idx >= 0);
702+
703+
// Initially empty
704+
var ctx_buf: [MAX_CONTEXT]u8 = undefined;
705+
const empty = coord_read_peer_context(idx, &ctx_buf, @intCast(ctx_buf.len));
706+
try std.testing.expectEqual(@as(c_int, 0), empty);
707+
708+
// Set a valid context
709+
const ctx = "007-lang";
710+
const set_ok = coord_set_context(&tok, TOKEN_LEN, ctx.ptr, @intCast(ctx.len));
711+
try std.testing.expectEqual(@as(c_int, 0), set_ok);
712+
713+
// Read it back
714+
const read_len = coord_read_peer_context(idx, &ctx_buf, @intCast(ctx_buf.len));
715+
try std.testing.expectEqual(@as(c_int, @intCast(ctx.len)), read_len);
716+
try std.testing.expect(std.mem.eql(u8, ctx_buf[0..ctx.len], ctx));
717+
718+
// Bad context (spaces) rejected
719+
const bad = "has space";
720+
const rc_bad = coord_set_context(&tok, TOKEN_LEN, bad.ptr, @intCast(bad.len));
721+
try std.testing.expectEqual(@as(c_int, -2), rc_bad);
722+
723+
// Original context untouched after rejection
724+
const reread = coord_read_peer_context(idx, &ctx_buf, @intCast(ctx_buf.len));
725+
try std.testing.expectEqual(@as(c_int, @intCast(ctx.len)), reread);
726+
727+
// Slot reuse clears context
728+
_ = coord_deregister(&tok, TOKEN_LEN);
729+
var tok2: [TOKEN_LEN]u8 = undefined;
730+
const idx2 = coord_register(0, &tok2, &suf);
731+
// Same slot likely re-used; context should be zeroed
732+
const after = coord_read_peer_context(idx2, &ctx_buf, @intCast(ctx_buf.len));
733+
try std.testing.expectEqual(@as(c_int, 0), after);
734+
735+
coord_reset();
736+
}
737+
738+
test "find peer by suffix" {
739+
coord_reset();
740+
var tok: [TOKEN_LEN]u8 = undefined;
741+
var suf: [4]u8 = undefined;
742+
const idx = coord_register(0, &tok, &suf);
743+
try std.testing.expect(idx >= 0);
744+
745+
// Lookup should find it
746+
const found = coord_find_peer_by_suffix(&suf);
747+
try std.testing.expectEqual(@as(c_int, idx), found);
748+
749+
// Unknown suffix returns -1
750+
const miss = [4]u8{ 'z', 'z', 'z', 'z' };
751+
const not_found = coord_find_peer_by_suffix(&miss);
752+
try std.testing.expectEqual(@as(c_int, -1), not_found);
753+
754+
coord_reset();
755+
}
756+
639757
test "deregister releases claims" {
640758
coord_reset();
641759
var tok1: [TOKEN_LEN]u8 = undefined;

mcp-bridge/lib/tools.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,12 @@ function buildToolList() {
267267
// Local coordination (localhost multi-instance AI coordination — local-coord-mcp cartridge)
268268
tools.push({
269269
name: "coord_register",
270-
description: "Register this AI instance as a coordination peer on localhost. Returns a hybrid peer ID (e.g. claude-7f3a) and a session token for all subsequent calls. Loopback-only, never exposed beyond 127.0.0.1.",
270+
description: "Register this AI instance as a coordination peer on localhost. Returns a peer ID and a session token for all subsequent calls. Loopback-only, never exposed beyond 127.0.0.1. Pass the optional `context` (repo name, tty tag, or similar) to disambiguate multiple windows of the same client_kind on one machine — peer_id becomes <kind>-<4hex>@<context> rather than just <kind>-<4hex>.",
271271
inputSchema: {
272272
type: "object",
273273
properties: {
274274
client_kind: { type: "string", enum: ["claude", "gemini", "copilot", "custom"], description: "Client type prefix for the peer ID" },
275+
context: { type: "string", description: "Optional disambiguator, e.g. current repo name. Alphanumeric + hyphen/underscore, max 32 bytes. Absent = old <kind>-<4hex> form.", maxLength: 32 },
275276
},
276277
required: ["client_kind"],
277278
},

0 commit comments

Comments
 (0)