granttap-mcp 0.8.5 → 0.8.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.
@@ -13,6 +13,9 @@ import type { UserAttachment, UserAttachmentUpload } from "../../../packages/pro
13
13
  import { configDir } from "./config";
14
14
 
15
15
  export const ATTACHMENT_TTL_MS = 2 * 60 * 60_000;
16
+ /** How many attachments may wait for their messages at once, and how much disk they may take. */
17
+ export const MAX_STAGED_ATTACHMENTS = 32;
18
+ export const MAX_STAGED_BYTES = 48 * 1_024 * 1_024;
16
19
 
17
20
  function directory(): string {
18
21
  const dir = join(configDir(), "attachments");
@@ -39,10 +42,44 @@ export function storeAttachment(upload: UserAttachmentUpload, room?: string, now
39
42
  name: upload.name, mimeType: upload.mimeType, data: upload.data, receivedAt: now,
40
43
  ...(room ? { room } : {}),
41
44
  };
42
- writeFileSync(join(dir, `${id}.json`), JSON.stringify(record), { mode: 0o600 });
45
+ const body = JSON.stringify(record);
46
+ // The staging area is bounded. One attachment too large for it is refused
47
+ // (the message that names it is rejected and the phone sends it inline);
48
+ // otherwise the oldest waiting ones make room, since a message that never
49
+ // came is the likeliest reason they are still here.
50
+ if (body.length > MAX_STAGED_BYTES) return false;
51
+ makeRoom(dir, body.length, id);
52
+ writeFileSync(join(dir, `${id}.json`), body, { mode: 0o600 });
43
53
  return true;
44
54
  }
45
55
 
56
+ function makeRoom(dir: string, incoming: number, incomingId: string): void {
57
+ let staged: Array<{ path: string; size: number; mtimeMs: number }> = [];
58
+ try {
59
+ staged = readdirSync(dir)
60
+ .filter((name) => name.endsWith(".json") && name !== `${incomingId}.json`)
61
+ .flatMap((name) => {
62
+ try {
63
+ const stat = statSync(join(dir, name));
64
+ return [{ path: join(dir, name), size: stat.size, mtimeMs: stat.mtimeMs }];
65
+ } catch {
66
+ return [];
67
+ }
68
+ })
69
+ .sort((left, right) => left.mtimeMs - right.mtimeMs);
70
+ } catch {
71
+ return;
72
+ }
73
+ let count = staged.length;
74
+ let bytes = staged.reduce((total, item) => total + item.size, 0);
75
+ for (const item of staged) {
76
+ if (count < MAX_STAGED_ATTACHMENTS && bytes + incoming <= MAX_STAGED_BYTES) break;
77
+ rmSync(item.path, { force: true });
78
+ count -= 1;
79
+ bytes -= item.size;
80
+ }
81
+ }
82
+
46
83
  /** The attachment the message named, taken off disk; nothing when it never came. */
47
84
  export function takeAttachment(attachmentId: string, room?: string, now = Date.now()): UserAttachment | undefined {
48
85
  const id = safeId(attachmentId);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What the person may do to the mesh that no agent may.
3
+ *
4
+ * A claim is released by its owner, and only its owner: that is what keeps
5
+ * one agent from clearing another's hold on a file. It also means a claim
6
+ * whose owner died, or will not let go, stays until it expires. The person
7
+ * is not an owner and is not bound by that rule; they are the authority the
8
+ * rule protects. A release from the phone is therefore a command of its
9
+ * own, checked against the Project it names, and written down.
10
+ */
11
+ import type { MeshClaimRelease, ResourceClaim } from "../../../../packages/protocol/schema";
12
+ import type { MeshStore } from "./store";
13
+
14
+ export type PersonRelease =
15
+ | { released: true; claim: ResourceClaim }
16
+ | { released: false; reason: "unknown_claim" | "other_project" };
17
+
18
+ export function releaseClaimByPerson(
19
+ store: MeshStore,
20
+ request: MeshClaimRelease,
21
+ log: (line: string) => void = (line) => process.stderr.write(`[monitor] mesh: ${line}\n`),
22
+ ): PersonRelease {
23
+ // The claim must be one of this Project's: a claim id is not a secret, and
24
+ // a Project's person does not reach into another Project with it.
25
+ const inProject = store.snapshot(request.projectId)?.claims.find((item) => item.claimId === request.claimId);
26
+ if (!inProject) {
27
+ const elsewhere = store.activeClaims().some((item) => item.claimId === request.claimId);
28
+ log(`release of ${request.claimId} refused: ${elsewhere ? "not in this Project" : "no such claim"}`);
29
+ return { released: false, reason: elsewhere ? "other_project" : "unknown_claim" };
30
+ }
31
+ store.releaseClaim(request.claimId);
32
+ log(
33
+ `claim ${request.claimId} on ${inProject.resource} held by ${inProject.ownerSessionId} `
34
+ + `released by the person${request.reason ? `: ${request.reason}` : ""}`,
35
+ );
36
+ return { released: true, claim: inProject };
37
+ }
@@ -53,6 +53,7 @@ import { approvalsStatus } from "./approval-state";
53
53
  import { primeSessionKeys, sendSessionPayload } from "./session-keys";
54
54
  import { sendMeshPayload } from "./session-keys";
55
55
  import { handleMeshPayload, meshCatalog, meshSnapshots, prepareMeshHandoff } from "./mesh/runtime";
56
+ import { releaseClaimByPerson } from "./mesh/admin";
56
57
  import { deriveObservedClaims } from "./mesh/observed-claims";
57
58
  import { localMeshStore } from "./mesh/local";
58
59
  import { cachedSessionActivity } from "./monitor-session-activity";
@@ -398,6 +399,11 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
398
399
  const prepared = await prepareMeshHandoff(client, payload);
399
400
  if (prepared) void publish().catch(() => {});
400
401
  return prepared;
402
+ } else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
403
+ // The person's own authority, not an owner's event: written down, then applied.
404
+ const outcome = releaseClaimByPerson(localMeshStore(), payload);
405
+ if (outcome.released) void publish().catch(() => {});
406
+ return true;
401
407
  }
402
408
  return false;
403
409
  });
@@ -19,6 +19,7 @@ import { handleMeshPayload } from "../../../bridge/src/mesh/runtime";
19
19
  import { sendMeshPayload } from "../../../bridge/src/session-keys";
20
20
  import { isMeshEnabled, isProviderEnabled } from "../../../bridge/src/config/runtime";
21
21
  import { sessionFromEnvironment } from "./mesh-resource";
22
+ import { OperationLedger, type OperationRecord } from "./operation-ledger";
22
23
  import {
23
24
  askOpenQuestionOutcome,
24
25
  askYesNoOutcome,
@@ -31,6 +32,11 @@ import {
31
32
  const NOT_PAIRED =
32
33
  "GrantTap is not paired on this machine. Pair the desktop bridge with the GrantTap app first.";
33
34
  const question = z.string().min(1).max(8_000).describe("The question to ask");
35
+ const operationId = z.string().trim().min(1).max(128).optional().describe(
36
+ "A name of your own for this call. Retrying with the same operationId repeats nothing that "
37
+ + "already happened: an event is not published twice, a person is not asked twice, and only "
38
+ + "the part that failed is done again.",
39
+ );
34
40
  const meshEventInput = z.object({
35
41
  capability: z.string().trim().min(1).max(128).optional(),
36
42
  projectId: z.string().trim().min(1).max(128),
@@ -47,6 +53,9 @@ const UNATTRIBUTED =
47
53
  + "are published only for the execution that made the call, so its provider hook must "
48
54
  + "be installed and trusted (granttap setup).";
49
55
 
56
+ /** One ledger per server, which is one per chat: a retry from the same chat finds its record. */
57
+ const ledger = new OperationLedger();
58
+
50
59
  /**
51
60
  * What a call came to, in a form a model can branch on.
52
61
  *
@@ -54,84 +63,122 @@ const UNATTRIBUTED =
54
63
  * structured result is the contract: a status that keeps "sent", "published"
55
64
  * and "refused" apart, a decision that is null when nobody answered, and the
56
65
  * id of a recorded event so a retry can be told from a duplicate. A call
57
- * that errors changed nothing.
66
+ * that errors changed nothing; a call that changed something and then failed
67
+ * says exactly which part is still owed.
58
68
  */
59
69
  const notifyOutput = {
60
70
  status: z.enum(["sent", "published", "claim_rejected"]).describe(
61
71
  "sent: only a status text went to the phone; published: the Mesh event was recorded on this "
62
72
  + "computer and sent; claim_rejected: the claim conflicts with another execution's and was not recorded",
63
73
  ),
64
- messageSent: z.boolean().describe("Whether a status text was handed to the relay for the phone"),
74
+ messageSent: z.boolean().describe("Whether the status text was handed to the relay for the phone"),
65
75
  meshEventId: z.string().optional().describe("The id of the Mesh event as recorded (for claim_rejected, the CONFLICT event)"),
66
76
  conflict: z.object({
67
77
  ownerSessionId: z.string(),
68
78
  resource: z.string(),
69
79
  }).optional().describe("Who holds the resource this claim collided with"),
70
80
  scopedResource: z.string().optional().describe("This execution's scoped Mesh resource URI, when the call was attributed"),
81
+ error: z.string().optional().describe("The part of the call that failed after the rest had happened; retry with the same operationId"),
82
+ replayed: z.boolean().optional().describe("This answer repeats an earlier call with the same operationId"),
71
83
  };
72
84
  const yesNoOutput = {
73
85
  status: z.enum(["answered", "timed_out"]).describe("timed_out: nobody answered before the wait ended"),
74
86
  decision: z.enum(["yes", "no"]).nullable().describe("The person's answer; null when nobody answered. A timeout is not a refusal."),
87
+ replayed: z.boolean().optional().describe("This answer repeats an earlier call with the same operationId"),
75
88
  };
76
89
  const openOutput = {
77
90
  status: z.enum(["answered", "timed_out"]).describe("timed_out: nobody answered before the wait ended"),
78
91
  answer: z.string().nullable().describe("The person's words; null when nobody answered"),
92
+ replayed: z.boolean().optional().describe("This answer repeats an earlier call with the same operationId"),
79
93
  };
80
94
 
95
+ function answered(text: string, outcome: Record<string, unknown>) {
96
+ return { content: [{ type: "text" as const, text }], structuredContent: outcome };
97
+ }
98
+
99
+ function replayed(record: OperationRecord) {
100
+ return answered(`${record.text}\n(replayed: this operationId was already handled)`, { ...record.outcome, replayed: true });
101
+ }
102
+
81
103
  export function registerInteractionTools(server: McpServer): void {
82
104
  server.registerTool(
83
105
  "notify",
84
106
  {
85
107
  description:
86
108
  "Send a non-blocking status to the user, or publish one bounded task-scoped Project Mesh event. "
87
- + "The result says whether the text was sent and whether the event was recorded; an error means nothing happened. "
109
+ + "The result says whether the text was sent and whether the event was recorded; an error means nothing happened, "
110
+ + "and a result with an error field says which part is still owed. Give the call an operationId to make retries safe. "
88
111
  + "A handoff is started by the person from the phone, never by an event published here.",
89
112
  inputSchema: {
90
113
  message: z.string().min(1).max(2_000).describe("Optional text to show on the user's devices").optional(),
91
114
  meshEvent: meshEventInput.describe("Optional structured coordination event; never include hidden reasoning").optional(),
115
+ operationId,
92
116
  },
93
117
  outputSchema: notifyOutput,
94
118
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
95
119
  },
96
- async ({ message, meshEvent }) => {
120
+ async ({ message, meshEvent, operationId: operation }) => {
97
121
  if (!message && !meshEvent) return {
98
122
  isError: true,
99
123
  content: [{ type: "text" as const, text: "Provide message or meshEvent." }],
100
124
  };
101
125
  const client = await relay();
102
126
  if (!client) return notPaired();
103
- // The provider hook already saw this exact call inside its own session.
104
- const attributed = consumeAttributedCall("notify", { message, meshEvent }, Date.now(), sessionFromEnvironment());
105
- // The event first: its checks can refuse the whole call, and a status
106
- // sent before a refusal would report work that did not happen.
107
- const published = meshEvent
108
- ? await publishMeshEvent(client, meshEvent, attributed?.sessionId)
109
- : undefined;
110
- if (message) {
111
- await client.send(
112
- { type: "agent.event", text: message, kind: "status", createdAt: Date.now() },
113
- "phone",
114
- { ttlMs: 15 * 60_000, wake: true },
115
- );
116
- }
117
- const capability = isMeshEnabled() && attributed
118
- ? executionCapabilityFor(attributed.sessionId)
119
- : undefined;
120
- const scopedResource = capability ? `granttap://mesh/${capability.token}` : undefined;
121
- const text = published?.text ?? "sent to phone";
122
- return {
123
- content: [{
124
- type: "text",
125
- text: scopedResource ? `${text}\nScoped Mesh state: ${scopedResource}` : text,
126
- }],
127
- structuredContent: {
127
+ const previous = operation ? ledger.recall("notify", operation) : undefined;
128
+ if (previous && (previous.pending !== "message" || !message)) return replayed(previous);
129
+ let outcome: Record<string, unknown>;
130
+ let text: string;
131
+ let changedSomething = Boolean(previous);
132
+ if (previous) {
133
+ // The event went out last time; only the status text is still owed.
134
+ outcome = { ...previous.outcome };
135
+ text = previous.text;
136
+ } else {
137
+ // The provider hook already saw this exact call inside its own session.
138
+ const attributed = consumeAttributedCall("notify", { message, meshEvent }, Date.now(), sessionFromEnvironment());
139
+ // The event first: its checks can refuse the whole call, and a status
140
+ // sent before a refusal would report work that did not happen.
141
+ const published = meshEvent
142
+ ? await publishMeshEvent(client, meshEvent, attributed?.sessionId)
143
+ : undefined;
144
+ changedSomething = published != null;
145
+ const capability = isMeshEnabled() && attributed
146
+ ? executionCapabilityFor(attributed.sessionId)
147
+ : undefined;
148
+ const scopedResource = capability ? `granttap://mesh/${capability.token}` : undefined;
149
+ text = published?.text ?? "sent to phone";
150
+ if (scopedResource) text = `${text}\nScoped Mesh state: ${scopedResource}`;
151
+ outcome = {
128
152
  status: published?.status ?? "sent",
129
- messageSent: Boolean(message),
153
+ messageSent: false,
130
154
  ...(published ? { meshEventId: published.eventId } : {}),
131
155
  ...(published?.conflict ? { conflict: published.conflict } : {}),
132
156
  ...(scopedResource ? { scopedResource } : {}),
133
- },
134
- };
157
+ };
158
+ }
159
+ if (message) {
160
+ try {
161
+ await client.send(
162
+ { type: "agent.event", text: message, kind: "status", createdAt: Date.now() },
163
+ "phone",
164
+ { ttlMs: 15 * 60_000, wake: true },
165
+ );
166
+ outcome.messageSent = true;
167
+ delete outcome.error;
168
+ } catch (error) {
169
+ // Nothing else happened: the whole call failed, and says so.
170
+ if (!changedSomething) throw error;
171
+ const detail = error instanceof Error ? error.message : String(error);
172
+ outcome.messageSent = false;
173
+ outcome.error = `status text not sent: ${detail}`;
174
+ const partial = `${text}\nStatus text not sent (${detail}). The Mesh event is recorded; `
175
+ + "retry with the same operationId to send only the text.";
176
+ if (operation) ledger.remember("notify", operation, { text, outcome, pending: "message" });
177
+ return answered(partial, { ...outcome, ...(previous ? { replayed: true } : {}) });
178
+ }
179
+ }
180
+ if (operation) ledger.remember("notify", operation, { text, outcome });
181
+ return answered(text, { ...outcome, ...(previous ? { replayed: true } : {}) });
135
182
  },
136
183
  );
137
184
  server.registerTool(
@@ -140,12 +187,21 @@ export function registerInteractionTools(server: McpServer): void {
140
187
  description:
141
188
  "Ask the user a yes/no question on their phone/watch and wait for the tap. Returns 'yes' or 'no' "
142
189
  + "when they answered, or 'no-answer (timeout)' when nobody answered in time. A timeout is not a "
143
- + "refusal: do not treat it as 'no', and do not treat it as permission.",
144
- inputSchema: { question: question.describe("A question answerable with yes/no") },
190
+ + "refusal: do not treat it as 'no', and do not treat it as permission. With an operationId, a retry "
191
+ + "returns the answer already given instead of asking again.",
192
+ inputSchema: { question: question.describe("A question answerable with yes/no"), operationId },
145
193
  outputSchema: yesNoOutput,
146
194
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
147
195
  },
148
- async ({ question: text }) => answerYesNo(text, interactionScope("ask_yes_no", text)),
196
+ async ({ question: text, operationId: operation }) => {
197
+ const previous = operation ? ledger.recall("ask_yes_no", operation) : undefined;
198
+ if (previous) return replayed(previous);
199
+ const result = await answerYesNo(text, interactionScope("ask_yes_no", text));
200
+ if (operation && !result.isError && result.structuredContent) {
201
+ ledger.remember("ask_yes_no", operation, { text: result.content[0]!.text, outcome: result.structuredContent });
202
+ }
203
+ return result;
204
+ },
149
205
  );
150
206
  server.registerTool(
151
207
  "ask",
@@ -153,12 +209,20 @@ export function registerInteractionTools(server: McpServer): void {
153
209
  description:
154
210
  "Ask the user an open question on their phone/watch and wait for their spoken or typed reply. "
155
211
  + "Returns their answer text, or 'no-answer (timeout)' when nobody answered in time; a timeout is "
156
- + "not an answer.",
157
- inputSchema: { question },
212
+ + "not an answer. With an operationId, a retry returns the answer already given instead of asking again.",
213
+ inputSchema: { question, operationId },
158
214
  outputSchema: openOutput,
159
215
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
160
216
  },
161
- async ({ question: text }) => answerOpenQuestion(text, interactionScope("ask", text)),
217
+ async ({ question: text, operationId: operation }) => {
218
+ const previous = operation ? ledger.recall("ask", operation) : undefined;
219
+ if (previous) return replayed(previous);
220
+ const result = await answerOpenQuestion(text, interactionScope("ask", text));
221
+ if (operation && !result.isError && result.structuredContent) {
222
+ ledger.remember("ask", operation, { text: result.content[0]!.text, outcome: result.structuredContent });
223
+ }
224
+ return result;
225
+ },
162
226
  );
163
227
  }
164
228
 
@@ -271,7 +335,7 @@ async function publishMeshEvent(
271
335
 
272
336
  /** Not paired is an error, not an outcome: nothing was sent, nothing was asked. */
273
337
  function notPaired() {
274
- return { isError: true, content: [{ type: "text" as const, text: NOT_PAIRED }] };
338
+ return { isError: true as const, content: [{ type: "text" as const, text: NOT_PAIRED }] };
275
339
  }
276
340
 
277
341
  function interactionScope(tool: "ask" | "ask_yes_no", text: string): TaskInteractionScope | undefined {
@@ -288,22 +352,22 @@ function interactionScope(tool: "ask" | "ask_yes_no", text: string): TaskInterac
288
352
  };
289
353
  }
290
354
 
291
- async function answerYesNo(questionText: string, scope?: TaskInteractionScope) {
355
+ type ToolAnswer = {
356
+ isError?: boolean;
357
+ content: Array<{ type: "text"; text: string }>;
358
+ structuredContent?: Record<string, unknown>;
359
+ };
360
+
361
+ async function answerYesNo(questionText: string, scope?: TaskInteractionScope): Promise<ToolAnswer> {
292
362
  const client = await relay();
293
363
  if (!client) return notPaired();
294
364
  const outcome = await askYesNoOutcome(client, questionText, undefined, scope);
295
- return {
296
- content: [{ type: "text" as const, text: yesNoText(outcome) }],
297
- structuredContent: outcome,
298
- };
365
+ return answered(yesNoText(outcome), outcome);
299
366
  }
300
367
 
301
- async function answerOpenQuestion(questionText: string, scope?: TaskInteractionScope) {
368
+ async function answerOpenQuestion(questionText: string, scope?: TaskInteractionScope): Promise<ToolAnswer> {
302
369
  const client = await relay();
303
370
  if (!client) return notPaired();
304
371
  const outcome = await askOpenQuestionOutcome(client, questionText, undefined, scope);
305
- return {
306
- content: [{ type: "text" as const, text: openAnswerText(outcome) }],
307
- structuredContent: outcome,
308
- };
372
+ return answered(openAnswerText(outcome), outcome);
309
373
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a call already did, by the name the model gave it.
3
+ *
4
+ * A tool call is not a transaction: a Mesh event can be recorded and sent
5
+ * before the status text fails on the wire, and a model that retries the
6
+ * whole call would publish the event twice — or ask the person the same
7
+ * question again. Given an operationId, the server remembers what each part
8
+ * of a call came to and, on the same id again, does only what is still
9
+ * undone, answering with the whole.
10
+ */
11
+ export type OperationRecord = {
12
+ at: number;
13
+ tool: string;
14
+ text: string;
15
+ outcome: Record<string, unknown>;
16
+ /** The part of the call that has not happened yet, to be done on the retry. */
17
+ pending?: "message";
18
+ };
19
+
20
+ export const OPERATION_TTL_MS = 15 * 60_000;
21
+ const MAX_OPERATIONS = 64;
22
+
23
+ export class OperationLedger {
24
+ private readonly records = new Map<string, OperationRecord>();
25
+
26
+ constructor(private readonly ttlMs = OPERATION_TTL_MS) {}
27
+
28
+ private key(tool: string, operationId: string): string {
29
+ return `${tool}\0${operationId}`;
30
+ }
31
+
32
+ recall(tool: string, operationId: string, now = Date.now()): OperationRecord | undefined {
33
+ const record = this.records.get(this.key(tool, operationId));
34
+ if (!record) return undefined;
35
+ if (now - record.at > this.ttlMs) {
36
+ this.records.delete(this.key(tool, operationId));
37
+ return undefined;
38
+ }
39
+ return record;
40
+ }
41
+
42
+ remember(tool: string, operationId: string, record: Omit<OperationRecord, "at" | "tool">, now = Date.now()): void {
43
+ for (const [key, item] of this.records) {
44
+ if (now - item.at > this.ttlMs) this.records.delete(key);
45
+ }
46
+ this.records.set(this.key(tool, operationId), { ...record, at: now, tool });
47
+ while (this.records.size > MAX_OPERATIONS) {
48
+ const oldest = this.records.keys().next().value;
49
+ if (oldest == null) break;
50
+ this.records.delete(oldest);
51
+ }
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -247,6 +247,28 @@ export const MeshEvent = z.object({
247
247
  });
248
248
  export type MeshEvent = z.infer<typeof MeshEvent>;
249
249
 
250
+ /**
251
+ * The person releases a claim that is nobody's to release by ownership: an
252
+ * agent that died holding a file, or one that will not let go. The phone
253
+ * sends it to each computer of the Project under the Project's key. It is
254
+ * not a RESOURCE_RELEASE event, which only an owner may make; it is the
255
+ * person's own authority, and the computer writes down that it was used.
256
+ */
257
+ export const MeshClaimRelease = z.object({
258
+ type: z.literal("mesh.claim.release"),
259
+ sessionId: Identifier,
260
+ projectId: Identifier,
261
+ claimId: Identifier,
262
+ reason: Detail.optional(),
263
+ requestId: Identifier.optional(),
264
+ createdAt: z.number().nonnegative(),
265
+ }).strict().superRefine((value, ctx) => {
266
+ if (value.sessionId !== value.projectId) {
267
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["sessionId"], message: "project scope mismatch" });
268
+ }
269
+ });
270
+ export type MeshClaimRelease = z.infer<typeof MeshClaimRelease>;
271
+
250
272
  export const MeshHandoffPrepare = z.object({
251
273
  type: z.literal("mesh.handoff.prepare"),
252
274
  sessionId: Identifier,
@@ -21,6 +21,7 @@ import { AgentId, Role } from "./messages/primitives";
21
21
  import {
22
22
  MeshEndpointPolicy,
23
23
  MeshEvent,
24
+ MeshClaimRelease,
24
25
  MeshHandoffPrepare,
25
26
  MeshSnapshot,
26
27
  } from "./messages/mesh";
@@ -116,6 +117,7 @@ export const Payload = z.union([
116
117
  ToolUpdate,
117
118
  ToolUpdateResult,
118
119
  MeshEvent,
120
+ MeshClaimRelease,
119
121
  MeshHandoffPrepare,
120
122
  MeshSnapshot,
121
123
  MeshEndpointPolicy,