granttap-mcp 0.8.5 → 0.8.7

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.
Files changed (38) hide show
  1. package/README.md +103 -1
  2. package/apps/bridge/src/attachment-store.ts +39 -1
  3. package/apps/bridge/src/mesh/admin.ts +65 -0
  4. package/apps/bridge/src/mesh/store-state.ts +18 -1
  5. package/apps/bridge/src/mesh/store-sync.ts +11 -3
  6. package/apps/bridge/src/mesh/store.ts +135 -33
  7. package/apps/bridge/src/monitor.ts +12 -1
  8. package/apps/bridge/src/publication/git-reader.ts +42 -0
  9. package/apps/bridge/src/publication/types.ts +33 -0
  10. package/apps/bridge/src/session-keys.ts +2 -1
  11. package/apps/mcp/src/create-server.ts +4 -1
  12. package/apps/mcp/src/mcp-tools/README.md +3 -1
  13. package/apps/mcp/src/mcp-tools/connect.ts +60 -3
  14. package/apps/mcp/src/mcp-tools/connection-widget.ts +69 -0
  15. package/apps/mcp/src/mcp-tools/interaction.ts +203 -105
  16. package/apps/mcp/src/mcp-tools/operation-ledger.ts +116 -0
  17. package/apps/mcp/src/package-version.ts +17 -0
  18. package/docs/images/iphone-chat.png +0 -0
  19. package/docs/images/iphone-claude-tasks.png +0 -0
  20. package/docs/images/iphone-command-center.png +0 -0
  21. package/docs/images/iphone-governance.png +0 -0
  22. package/docs/images/iphone-handoff.png +0 -0
  23. package/docs/images/iphone-invite.png +0 -0
  24. package/docs/images/iphone-join-project.png +0 -0
  25. package/docs/images/iphone-mcp-usage.png +0 -0
  26. package/docs/images/iphone-member-detail.png +0 -0
  27. package/docs/images/iphone-members-shared.png +0 -0
  28. package/docs/images/iphone-members.png +0 -0
  29. package/docs/images/iphone-photo-preview.png +0 -0
  30. package/docs/images/iphone-project-mesh.png +0 -0
  31. package/docs/images/iphone-projects-shared.png +0 -0
  32. package/docs/images/iphone-projects.png +0 -0
  33. package/docs/images/iphone-report.png +0 -0
  34. package/docs/images/iphone-security-settings.png +0 -0
  35. package/docs/images/iphone-task-route.png +0 -0
  36. package/package.json +1 -1
  37. package/packages/protocol/messages/mesh.ts +50 -0
  38. package/packages/protocol/schema.ts +4 -0
package/README.md CHANGED
@@ -55,6 +55,15 @@ Mesh resource. Full transcripts and hidden reasoning are never mesh payloads.
55
55
  Claims have TTLs; a colliding claim is rejected before it is recorded so agents
56
56
  can choose different work or contact the owner before escalating to Needs You.
57
57
 
58
+ <p align="center">
59
+ <img src="docs/images/iphone-projects-shared.png" width="200" alt="Projects: one owned by this phone, one shared by another">
60
+ <img src="docs/images/iphone-project-mesh.png" width="200" alt="A Project: Governance, members and computers, Mesh status, repositories, Tasks">
61
+ <img src="docs/images/iphone-task-route.png" width="200" alt="A Task: its executions, its claims, and who else is in its files">
62
+ <img src="docs/images/iphone-handoff.png" width="200" alt="Task handoff: destination, push, and readiness checks">
63
+ </p>
64
+
65
+ <p align="center"><em>Projects, one of them shared by another phone · a Project with its Governance, members, computers, repositories, and Tasks · a Task with its executions and claims · a handoff with its readiness checks</em></p>
66
+
58
67
  For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
59
68
  own session and sees the exact call. GrantTap attributes every `notify` to the
60
69
  session that really made it and publishes the event only for that execution —
@@ -124,6 +133,59 @@ of a topic it produces, the caller of an API it changes), and the scoped
124
133
  `granttap://mesh/{capability}` resource gives the agent the same `peers`,
125
134
  `otherSide`, and `neighbours` so it can coordinate before it commits.
126
135
 
136
+ ### Members, roles, and computers of their own
137
+
138
+ A Project is shared from the phone that owns it, and that phone stays the
139
+ hub: nothing a member does reaches a computer without passing through it.
140
+ *Invite a person* makes a one-time code, good for fifteen minutes; the other
141
+ phone scans it under *Projects → Join a Project*, and the Project arrives
142
+ there with the role the owner chose — Viewer, Member, or Admin — and the four
143
+ answers under it: see the Project's chats, write to them, post to the
144
+ Project, edit Governance. Each answer is checked on the owner's phone before
145
+ a message, a pause, a handoff, or a release is forwarded, and a refusal comes
146
+ back to the member's phone as a message of its own, naming the rule. Changing
147
+ a role takes effect at once; removing a member stops the forwarding at once,
148
+ though nothing already seen can be recalled.
149
+
150
+ <p align="center">
151
+ <img src="docs/images/iphone-members.png" width="200" alt="Members and computers of a Project, seen by its owner">
152
+ <img src="docs/images/iphone-invite.png" width="200" alt="Invite a person: name, role, and the four answers under it">
153
+ <img src="docs/images/iphone-join-project.png" width="200" alt="Join a Project from another phone: scan the invite or paste it">
154
+ <img src="docs/images/iphone-members-shared.png" width="200" alt="A shared Project on the member's phone, with a computer of their own to add">
155
+ </p>
156
+
157
+ <p align="center"><em>The owner's members and computers · an invite with its role · joining from another phone · the shared Project as the member sees it, with a computer of their own to add</em></p>
158
+
159
+ A member works in the Project with computers of their own. Adding one hands
160
+ it the Project's mesh key over the pairing the member's phone already trusts,
161
+ and from then on that computer takes part in the mesh like any other: its
162
+ chats are the Project's, its claims are seen by every other computer, and a
163
+ Task can be handed to it.
164
+
165
+ ### A claim released by the person
166
+
167
+ A claim outlives an agent that crashed or was closed, and the files it names
168
+ stay fenced off until it expires. Touch and hold a claim on the Task screen
169
+ to release it yourself. The phone tells the computer that holds the claim,
170
+ and the computer answers with a result of its own: released, or refused with
171
+ the reason — no such claim on this computer, a claim that belongs to another
172
+ Project, a role that does not allow it, or no computer to ask. A refusal puts
173
+ the claim back on the phone with that reason beside it, so what the phone
174
+ shows is what the mesh holds. A release is written to the store as a
175
+ tombstone, so a snapshot or a late event from a computer that was away cannot
176
+ bring the claim back.
177
+
178
+ ### Task reports
179
+
180
+ <p align="center">
181
+ <img src="docs/images/iphone-report.png" width="200" alt="A Task report: figures first, then every table, as PDF or CSV">
182
+ </p>
183
+
184
+ A Task is reported from the phone as a PDF to read and forward, or a CSV with
185
+ every table: tokens, tool calls, wrong turns, CPU time, peak memory, and wall
186
+ time, by tool and by execution. Nothing leaves the phone until you choose
187
+ where it goes.
188
+
127
189
  ### Grok Bot as a scoped Mesh participant
128
190
 
129
191
  Grok Bot is a persistent agent, not a coding-agent integration. The iPhone
@@ -145,6 +207,28 @@ history stays on the device.
145
207
  Installation and production use require Authorized Access under the
146
208
  [GrantTap Commercial Source License](LICENSE).
147
209
 
210
+ Install the GrantTap plugin directly from this repository's marketplace:
211
+
212
+ ```bash
213
+ # Codex
214
+ codex plugin marketplace add sergii-ziborov/granttap-mcp
215
+ codex plugin add granttap@granttap
216
+
217
+ # Claude Code
218
+ claude plugin marketplace add sergii-ziborov/granttap-mcp
219
+ claude plugin install granttap@granttap
220
+ ```
221
+
222
+ Open GrantTap from the plugin page or ask `Show my GrantTap pairing QR.` Codex
223
+ renders an interactive connection card with pairing status, a one-time QR,
224
+ copy-link fallback, and confirmed reconnect controls. The same tools still
225
+ return readable MCP content in clients that do not render app UI. Detailed
226
+ plugin instructions are in
227
+ [`plugins/granttap/README.md`](plugins/granttap/README.md).
228
+
229
+ For the full background helper and provider hooks, install the CLI and run
230
+ setup:
231
+
148
232
  ```bash
149
233
  npm install -g granttap-mcp
150
234
  granttap setup
@@ -188,16 +272,19 @@ trust.
188
272
 
189
273
  ## MCP contract
190
274
 
191
- `tools/list` returns exactly four public tools:
275
+ `tools/list` returns exactly five public tools:
192
276
 
193
277
  | Tool | Contract |
194
278
  | --- | --- |
195
279
  | `connect` | Reuse the existing production pairing or return a one-time QR |
280
+ | `reconnect` | Replace the pairing after explicit confirmation and return a fresh one-time QR |
196
281
  | `notify` | Send a non-blocking status update of at most 2,000 characters |
197
282
  | `ask_yes_no` | Ask a yes/no question and wait for the explicit answer |
198
283
  | `ask` | Ask an open question and wait for typed or spoken text |
199
284
 
200
285
  MCP `connect` accepts no custom routing, replacement, or key-rotation input.
286
+ `reconnect` is declared destructive and requires `confirmed: true`; relay
287
+ acceptance happens before the working local pairing is replaced.
201
288
  Setup is CLI-only because it changes provider configuration and must not be
202
289
  available to a model through prompt injection.
203
290
 
@@ -205,6 +292,14 @@ available to a model through prompt injection.
205
292
  not add a fifth MCP tool or grant any global setup capability, and it publishes
206
293
  only for the execution whose provider hook attributed the call.
207
294
 
295
+ `notify`, `ask_yes_no`, and `ask` accept an `operationId`. A named call is
296
+ remembered for fifteen minutes under the chat that made it, the tool, and the
297
+ arguments: a retry returns the answer already given instead of asking again,
298
+ a retry that arrives while the first call is still waiting waits for the same
299
+ answer, and the same name used for other arguments is refused. One chat's
300
+ name is never another's — the ledger is keyed by the execution the provider
301
+ hook attributed, and the hook carries the `operationId` with the call.
302
+
208
303
  Provider-native approvals and mobile continuation require the matching local
209
304
  adapter. MCP registration alone is never reported as proof that an integration
210
305
  is ready.
@@ -331,6 +426,13 @@ Legacy custom levels remain compatible but are not part of the primary flow.
331
426
 
332
427
  ## Project Governance
333
428
 
429
+ <p align="center">
430
+ <img src="docs/images/iphone-governance.png" width="200" alt="Project Governance: one answer per kind of capability, and the enforcement it reached">
431
+ <img src="docs/images/iphone-member-detail.png" width="200" alt="A member: role, answers, and removal">
432
+ </p>
433
+
434
+ <p align="center"><em>Governance for the whole Project · a member's answers, changed at once</em></p>
435
+
334
436
  Capabilities are decided per Project, not per task. A policy names an effect —
335
437
  `allow`, `ask`, or `deny` — for each kind (skills, MCP servers, shell and
336
438
  scripts, file writes, deploy, network), and may name one capability alone: one
@@ -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,45 @@ 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
+ const bytes = Buffer.byteLength(body, "utf8");
51
+ if (bytes > MAX_STAGED_BYTES) return false;
52
+ makeRoom(dir, bytes, id);
53
+ writeFileSync(join(dir, `${id}.json`), body, { mode: 0o600 });
43
54
  return true;
44
55
  }
45
56
 
57
+ function makeRoom(dir: string, incoming: number, incomingId: string): void {
58
+ let staged: Array<{ path: string; size: number; mtimeMs: number }> = [];
59
+ try {
60
+ staged = readdirSync(dir)
61
+ .filter((name) => name.endsWith(".json") && name !== `${incomingId}.json`)
62
+ .flatMap((name) => {
63
+ try {
64
+ const stat = statSync(join(dir, name));
65
+ return [{ path: join(dir, name), size: stat.size, mtimeMs: stat.mtimeMs }];
66
+ } catch {
67
+ return [];
68
+ }
69
+ })
70
+ .sort((left, right) => left.mtimeMs - right.mtimeMs);
71
+ } catch {
72
+ return;
73
+ }
74
+ let count = staged.length;
75
+ let bytes = staged.reduce((total, item) => total + item.size, 0);
76
+ for (const item of staged) {
77
+ if (count < MAX_STAGED_ATTACHMENTS && bytes + incoming <= MAX_STAGED_BYTES) break;
78
+ rmSync(item.path, { force: true });
79
+ count -= 1;
80
+ bytes -= item.size;
81
+ }
82
+ }
83
+
46
84
  /** The attachment the message named, taken off disk; nothing when it never came. */
47
85
  export function takeAttachment(attachmentId: string, room?: string, now = Date.now()): UserAttachment | undefined {
48
86
  const id = safeId(attachmentId);
@@ -0,0 +1,65 @@
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 {
12
+ MeshClaimRelease,
13
+ MeshClaimReleaseResult,
14
+ ResourceClaim,
15
+ } from "../../../../packages/protocol/schema";
16
+ import type { MeshStore } from "./store";
17
+
18
+ export type PersonRelease =
19
+ | { released: true; claim: ResourceClaim }
20
+ | { released: false; reason: "unknown_claim" | "other_project" };
21
+
22
+ /** The answer the phone that asked is given, done or refused and why. */
23
+ export function releaseResult(
24
+ request: MeshClaimRelease,
25
+ outcome: PersonRelease,
26
+ now = Date.now(),
27
+ ): MeshClaimReleaseResult {
28
+ return {
29
+ type: "mesh.claim.release.result",
30
+ sessionId: request.projectId,
31
+ projectId: request.projectId,
32
+ claimId: request.claimId,
33
+ ok: outcome.released,
34
+ ...(outcome.released ? {} : { reason: outcome.reason, detail: describe(outcome.reason) }),
35
+ ...(request.requestId ? { requestId: request.requestId } : {}),
36
+ generatedAt: now,
37
+ };
38
+ }
39
+
40
+ function describe(reason: "unknown_claim" | "other_project"): string {
41
+ return reason === "unknown_claim"
42
+ ? "No such claim on this computer; it may already be gone."
43
+ : "That claim belongs to another Project.";
44
+ }
45
+
46
+ export function releaseClaimByPerson(
47
+ store: MeshStore,
48
+ request: MeshClaimRelease,
49
+ log: (line: string) => void = (line) => process.stderr.write(`[monitor] mesh: ${line}\n`),
50
+ ): PersonRelease {
51
+ // The claim must be one of this Project's: a claim id is not a secret, and
52
+ // a Project's person does not reach into another Project with it.
53
+ const inProject = store.snapshot(request.projectId)?.claims.find((item) => item.claimId === request.claimId);
54
+ if (!inProject) {
55
+ const elsewhere = store.activeClaims().some((item) => item.claimId === request.claimId);
56
+ log(`release of ${request.claimId} refused: ${elsewhere ? "not in this Project" : "no such claim"}`);
57
+ return { released: false, reason: elsewhere ? "other_project" : "unknown_claim" };
58
+ }
59
+ store.releaseClaim(request.claimId);
60
+ log(
61
+ `claim ${request.claimId} on ${inProject.resource} held by ${inProject.ownerSessionId} `
62
+ + `released by the person${request.reason ? `: ${request.reason}` : ""}`,
63
+ );
64
+ return { released: true, claim: inProject };
65
+ }
@@ -39,6 +39,19 @@ export const CapsuleMigration = z.object({
39
39
  }).strict();
40
40
  export type CapsuleMigration = z.infer<typeof CapsuleMigration>;
41
41
 
42
+ /**
43
+ * A claim that was released, on record until the claim itself would have
44
+ * expired. Snapshots merge claims by id, so a computer that was away, or a
45
+ * copy that came round through another phone, would otherwise bring a
46
+ * released claim back as if nothing had happened.
47
+ */
48
+ export const ReleasedClaim = z.object({
49
+ claimId: z.string().min(1).max(128),
50
+ releasedAt: z.number().nonnegative(),
51
+ expiresAt: z.number().positive(),
52
+ }).strict();
53
+ export type ReleasedClaim = z.infer<typeof ReleasedClaim>;
54
+
42
55
  export type StoreState = {
43
56
  version: 1;
44
57
  projects: ProjectValue[];
@@ -51,15 +64,17 @@ export type StoreState = {
51
64
  events: MeshEventValue[];
52
65
  receipts: ReceiptValue[];
53
66
  migrations: CapsuleMigration[];
67
+ releasedClaims: ReleasedClaim[];
54
68
  };
55
69
 
56
70
  const EMPTY: StoreState = {
57
71
  version: 1, projects: [], bindings: [], peers: [], tasks: [], executions: [], claims: [],
58
- dependencies: [], events: [], receipts: [], migrations: [],
72
+ dependencies: [], events: [], receipts: [], migrations: [], releasedClaims: [],
59
73
  };
60
74
 
61
75
  export const MAX_STORE_PEERS = 256;
62
76
  export const MAX_STORE_MIGRATIONS = 64;
77
+ export const MAX_RELEASED_CLAIMS = 256;
63
78
 
64
79
  function parsedArray<T>(value: unknown, schema: { safeParse: (input: unknown) => { success: boolean; data?: T } }): T[] {
65
80
  if (!Array.isArray(value)) return [];
@@ -117,6 +132,7 @@ export function readStoreState(path: string): StoreLoad {
117
132
  events: parsedArray(value.events, MeshEvent),
118
133
  receipts: parsedArray(value.receipts, HandoffReceipt),
119
134
  migrations: parsedArray(value.migrations, CapsuleMigration).slice(-MAX_STORE_MIGRATIONS),
135
+ releasedClaims: parsedArray(value.releasedClaims, ReleasedClaim).slice(-MAX_RELEASED_CLAIMS),
120
136
  }),
121
137
  };
122
138
  } catch {
@@ -224,6 +240,7 @@ function collapseSplitChats(state: StoreState): StoreState {
224
240
  // A receipt decides who owns a chat, so it must name the surviving Task and its capsule.
225
241
  receipts: state.receipts.map((item) => ({ ...scoped(item), capsuleHash: rehash(item.capsuleHash) })),
226
242
  migrations: [...migrations.values()].slice(-MAX_STORE_MIGRATIONS),
243
+ releasedClaims: state.releasedClaims,
227
244
  };
228
245
  }
229
246
 
@@ -21,18 +21,25 @@ import type {
21
21
  } from "../../../../packages/protocol/schema";
22
22
  import { preferExecution, preferTask } from "./convergence";
23
23
  import { integrationPeerKey } from "./other-side";
24
- import { MAX_STORE_MIGRATIONS, MAX_STORE_PEERS, type StoreState } from "./store-state";
24
+ import {
25
+ MAX_RELEASED_CLAIMS, MAX_STORE_MIGRATIONS, MAX_STORE_PEERS, type StoreState,
26
+ } from "./store-state";
25
27
 
26
28
  type Collection = Exclude<keyof StoreState, "version">;
27
29
 
28
30
  const COLLECTIONS: Collection[] = [
29
31
  "projects", "bindings", "peers", "tasks", "executions", "claims", "dependencies", "events",
30
- "receipts", "migrations",
32
+ "receipts", "migrations", "releasedClaims",
31
33
  ];
32
34
  const BOUNDS: Partial<Record<Collection, number>> = {
33
35
  peers: MAX_STORE_PEERS, events: 512, receipts: 256, migrations: MAX_STORE_MIGRATIONS,
36
+ releasedClaims: MAX_RELEASED_CLAIMS,
34
37
  };
35
- export const LOCK_WAIT_MS = 8_000;
38
+ /**
39
+ * A write takes milliseconds, so a lock held for seconds belongs to a
40
+ * process that is stuck; waiting longer than this only stalls this one.
41
+ */
42
+ export const LOCK_WAIT_MS = 2_000;
36
43
  const LOCK_POLL_MS = 5;
37
44
 
38
45
  function rowKey(name: Collection, item: unknown): string {
@@ -48,6 +55,7 @@ function rowKey(name: Collection, item: unknown): string {
48
55
  case "events": return row.eventId ?? "";
49
56
  case "receipts": return row.capsuleHash ?? "";
50
57
  case "migrations": return row.capsuleHashFrom ?? "";
58
+ case "releasedClaims": return row.claimId ?? "";
51
59
  }
52
60
  }
53
61
 
@@ -96,27 +96,63 @@ export class MeshStore {
96
96
  */
97
97
  private save(): boolean {
98
98
  try {
99
- withStoreLock(this.path, () => {
100
- const delta = storeDelta(this.baseline, this.state);
101
- let merged = this.state;
99
+ withStoreLock(this.path, () => this.syncAndWriteUnderLock(), { waitMs: this.lockWaitMs });
100
+ return true;
101
+ } catch (error) {
102
+ if (!(error instanceof StoreLockError)) throw error;
103
+ this.unsaved = true;
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Lay what this process changed over what is on disk, under the lock the
110
+ * caller already holds, and write the result.
111
+ */
112
+ private syncAndWriteUnderLock(): void {
113
+ const delta = storeDelta(this.baseline, this.state);
114
+ let merged = this.state;
115
+ if (storeFingerprint(this.path) !== this.synced) {
116
+ const disk = readStoreState(this.path);
117
+ if (disk.status === "ok") {
118
+ merged = deltaIsEmpty(delta) ? disk.state : applyStoreDelta(disk.state, delta);
119
+ } else if (disk.status === "corrupt" || disk.status === "too_large") {
120
+ setAsideStore(this.path, this.now());
121
+ }
122
+ }
123
+ writeStoreState(this.path, merged);
124
+ this.state = merged;
125
+ this.baseline = structuredClone(merged);
126
+ this.synced = storeFingerprint(this.path);
127
+ }
128
+
129
+ /**
130
+ * Check and change as one: what `run` reads is what it changes, and the
131
+ * change is on disk before anyone is told. Under the lock the file is
132
+ * read again first, so another process's write between this process's
133
+ * last look and now is seen. When the lock cannot be taken in time nothing
134
+ * is changed, and the caller says so instead of answering as if it were.
135
+ */
136
+ transact<T>(run: () => T): { applied: true; value: T } | { applied: false } {
137
+ try {
138
+ return withStoreLock(this.path, () => {
139
+ // Take in the disk as it is now, with what this process still owes laid over.
102
140
  if (storeFingerprint(this.path) !== this.synced) {
103
- const disk = readStoreState(this.path);
104
- if (disk.status === "ok") {
105
- merged = deltaIsEmpty(delta) ? disk.state : applyStoreDelta(disk.state, delta);
106
- } else if (disk.status === "corrupt" || disk.status === "too_large") {
107
- setAsideStore(this.path, this.now());
141
+ const loaded = readStoreState(this.path);
142
+ if (loaded.status === "ok" || loaded.status === "missing") {
143
+ const pending = storeDelta(this.baseline, this.state);
144
+ this.state = deltaIsEmpty(pending) ? loaded.state : applyStoreDelta(loaded.state, pending);
145
+ this.baseline = structuredClone(loaded.state);
146
+ this.synced = storeFingerprint(this.path);
108
147
  }
109
148
  }
110
- writeStoreState(this.path, merged);
111
- this.state = merged;
112
- this.baseline = structuredClone(merged);
113
- this.synced = storeFingerprint(this.path);
149
+ const value = run();
150
+ this.syncAndWriteUnderLock();
151
+ return { applied: true as const, value };
114
152
  }, { waitMs: this.lockWaitMs });
115
- return true;
116
153
  } catch (error) {
117
154
  if (!(error instanceof StoreLockError)) throw error;
118
- this.unsaved = true;
119
- return false;
155
+ return { applied: false };
120
156
  }
121
157
  }
122
158
 
@@ -339,41 +375,99 @@ export class MeshStore {
339
375
  claim(input: ResourceClaimValue): void {
340
376
  this.sync();
341
377
  const claim = ResourceClaim.parse(input);
378
+ if (this.isReleased(claim.claimId)) return;
342
379
  this.state.claims = this.state.claims.filter((item) => item.claimId !== claim.claimId);
343
380
  this.state.claims.push(claim);
344
381
  this.save();
345
382
  }
346
383
 
384
+ /**
385
+ * Whether a claim was released and is still remembered as such: a copy of
386
+ * it arriving late, from a computer that was away or a phone that carried
387
+ * it round, is not the claim coming back.
388
+ */
389
+ private isReleased(claimId: string, at = this.now()): boolean {
390
+ return this.state.releasedClaims.some((item) => item.claimId === claimId && item.expiresAt > at);
391
+ }
392
+
393
+ /** Write a release down, for as long as the claim itself would have lasted. */
394
+ private remember(released: ResourceClaimValue[]): void {
395
+ const at = this.now();
396
+ const kept = this.state.releasedClaims.filter((item) =>
397
+ item.expiresAt > at && !released.some((claim) => claim.claimId === item.claimId));
398
+ this.state.releasedClaims = [
399
+ ...kept,
400
+ ...released.map((claim) => ({ claimId: claim.claimId, releasedAt: at, expiresAt: Math.max(claim.expiresAt, at + 1) })),
401
+ ].slice(-256);
402
+ }
403
+
347
404
  releaseClaim(claimId: string, ownerSessionId?: string): boolean {
348
405
  this.sync();
349
- const before = this.state.claims.length;
350
- this.state.claims = this.state.claims.filter((claim) =>
351
- claim.claimId !== claimId || (ownerSessionId != null && claim.ownerSessionId !== ownerSessionId));
352
- if (this.state.claims.length === before) return false;
406
+ const released = this.state.claims.filter((claim) =>
407
+ claim.claimId === claimId && (ownerSessionId == null || claim.ownerSessionId === ownerSessionId));
408
+ if (released.length === 0) return false;
409
+ this.state.claims = this.state.claims.filter((claim) => !released.includes(claim));
410
+ this.remember(released);
353
411
  this.save();
354
412
  return true;
355
413
  }
356
414
 
357
415
  releaseClaimsByOwners(ownerSessionIds: ReadonlySet<string>): number {
358
416
  this.sync();
359
- const before = this.state.claims.length;
360
- this.state.claims = this.state.claims.filter((claim) =>
361
- !ownerSessionIds.has(claim.ownerSessionId));
362
- const removed = before - this.state.claims.length;
363
- if (removed > 0) this.save();
364
- return removed;
417
+ const released = this.state.claims.filter((claim) => ownerSessionIds.has(claim.ownerSessionId));
418
+ if (released.length === 0) return 0;
419
+ this.state.claims = this.state.claims.filter((claim) => !released.includes(claim));
420
+ this.remember(released);
421
+ this.save();
422
+ return released.length;
365
423
  }
366
424
 
367
425
  activeClaims(at = this.now()): ResourceClaimValue[] {
368
426
  this.sync();
369
- const active = this.state.claims.filter((claim) => claim.expiresAt > at);
370
- if (active.length !== this.state.claims.length) {
427
+ const active = this.liveClaims(at);
428
+ const tombstones = this.state.releasedClaims.filter((item) => item.expiresAt > at);
429
+ if (active.length !== this.state.claims.length || tombstones.length !== this.state.releasedClaims.length) {
371
430
  this.state.claims = active;
431
+ this.state.releasedClaims = tombstones;
372
432
  this.save();
373
433
  }
374
434
  return [...active];
375
435
  }
376
436
 
437
+ /** The claims that hold at `at`, read without writing anything. */
438
+ private liveClaims(at: number): ResourceClaimValue[] {
439
+ return this.state.claims.filter((claim) => claim.expiresAt > at);
440
+ }
441
+
442
+ /**
443
+ * A claim event from an agent, checked and recorded as one step under the
444
+ * store's lock: the conflict it is checked against is the state it is
445
+ * written into, so two agents claiming one file at once cannot both be
446
+ * told the file was free. Not applied at all when the lock is not taken
447
+ * in time, and the caller says so.
448
+ */
449
+ acceptClaimEvent(input: MeshEventValue): { applied: false } | { applied: true; accepted: boolean; conflict?: ResourceClaimValue } {
450
+ const parsed = MeshEvent.safeParse(input);
451
+ if (!parsed.success || parsed.data.eventType !== "RESOURCE_CLAIM" || !parsed.data.payload.claim) {
452
+ return { applied: true, accepted: false };
453
+ }
454
+ const event = parsed.data;
455
+ const claim = event.payload.claim!;
456
+ const result = this.transact((): { accepted: boolean; conflict?: ResourceClaimValue } => {
457
+ const conflict = this.liveClaims(this.now()).find((item) =>
458
+ item.projectId === event.projectId
459
+ && item.ownerSessionId !== event.sourceSessionId
460
+ && resourceOverlap(item.resource, claim.resource));
461
+ if (conflict) return { accepted: false, conflict };
462
+ if (this.state.events.some((item) => item.eventId === event.eventId)) return { accepted: false };
463
+ this.state.events.push(event);
464
+ this.state.events = this.state.events.slice(-512);
465
+ this.applyEvent(event);
466
+ return { accepted: true };
467
+ });
468
+ return result.applied ? { applied: true, ...result.value } : { applied: false };
469
+ }
470
+
377
471
  conflicts(projectId: string, ownerSessionId: string, resource: string): ResourceClaimValue[] {
378
472
  this.sync();
379
473
  return this.activeClaims().filter((claim) =>
@@ -407,6 +501,7 @@ export class MeshStore {
407
501
  observeClaim(input: ResourceClaimValue): boolean {
408
502
  this.sync();
409
503
  const claim = ResourceClaim.parse(input);
504
+ if (this.isReleased(claim.claimId)) return false;
410
505
  const previous = this.state.claims.find((item) => item.claimId === claim.claimId);
411
506
  if (previous && previous.ownerSessionId === claim.ownerSessionId
412
507
  && previous.expiresAt >= claim.expiresAt) return false;
@@ -450,18 +545,22 @@ export class MeshStore {
450
545
  }
451
546
 
452
547
  private applyEvent(event: MeshEventValue): void {
453
- if (event.eventType === "RESOURCE_CLAIM" && event.payload.claim) {
548
+ if (event.eventType === "RESOURCE_CLAIM" && event.payload.claim && !this.isReleased(event.payload.claim.claimId)) {
454
549
  this.state.claims = mergeBy(this.state.claims, [event.payload.claim], (item) => item.claimId);
455
550
  }
456
551
  if (event.eventType === "RESOURCE_RELEASE" && event.payload.claimId) {
457
552
  // Only the owner releases its claim, and only from inside the claim's
458
553
  // own Task and Project: a claim id is not a secret, and a chat that
459
554
  // learned one must not be able to clear someone else's hold on a file.
460
- this.state.claims = this.state.claims.filter((item) =>
461
- item.claimId !== event.payload.claimId
462
- || item.ownerSessionId !== event.sourceSessionId
463
- || item.projectId !== event.projectId
464
- || item.taskId !== event.taskId);
555
+ const released = this.state.claims.filter((item) =>
556
+ item.claimId === event.payload.claimId
557
+ && item.ownerSessionId === event.sourceSessionId
558
+ && item.projectId === event.projectId
559
+ && item.taskId === event.taskId);
560
+ if (released.length > 0) {
561
+ this.state.claims = this.state.claims.filter((item) => !released.includes(item));
562
+ this.remember(released);
563
+ }
465
564
  }
466
565
  if (event.eventType === "DEPENDENCY" && event.payload.dependsOnTaskId) {
467
566
  this.state.dependencies = mergeBy(this.state.dependencies, [{
@@ -559,6 +658,9 @@ export class MeshStore {
559
658
  mergeSnapshot(input: SnapshotValue): void {
560
659
  this.sync();
561
660
  mergeSnapshotState(this.state, input);
661
+ // A released claim does not come back with a snapshot that still has it.
662
+ const at = this.now();
663
+ this.state.claims = this.state.claims.filter((claim) => !this.isReleased(claim.claimId, at));
562
664
  this.save();
563
665
  }
564
666
  }
@@ -50,9 +50,10 @@ import { noteDeliveredRun } from "./mesh/run-digest";
50
50
  const DELIVERY_TIMEOUT_MS = 10 * 60_000;
51
51
  import { refreshMcpLoad } from "./machine-load/mcp-load-refresh";
52
52
  import { approvalsStatus } from "./approval-state";
53
- import { primeSessionKeys, sendSessionPayload } from "./session-keys";
53
+ import { primeSessionKeys, sendProjectPayload, sendSessionPayload } from "./session-keys";
54
54
  import { sendMeshPayload } from "./session-keys";
55
55
  import { handleMeshPayload, meshCatalog, meshSnapshots, prepareMeshHandoff } from "./mesh/runtime";
56
+ import { releaseClaimByPerson, releaseResult } 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";
@@ -208,6 +209,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
208
209
 
209
210
  const snapshot = (includeHistory: boolean, forceHistory = false): SessionsStatus => {
210
211
  sweepAttachments();
212
+ // A change the store's lock held back is written on the next tick.
213
+ if (loadRuntimeConfig().meshEnabled) localMeshStore().flush();
211
214
  const { sessions, tokensRecent } = scanSessions();
212
215
  const runtime = loadRuntimeConfig();
213
216
  return {
@@ -398,6 +401,14 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
398
401
  const prepared = await prepareMeshHandoff(client, payload);
399
402
  if (prepared) void publish().catch(() => {});
400
403
  return prepared;
404
+ } else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
405
+ // The person's own authority, not an owner's event: written down, then
406
+ // applied, and answered either way, so a refusal is seen where it was asked.
407
+ const outcome = releaseClaimByPerson(localMeshStore(), payload);
408
+ await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
409
+ .catch(() => {});
410
+ if (outcome.released) void publish().catch(() => {});
411
+ return true;
401
412
  }
402
413
  return false;
403
414
  });