granttap-mcp 0.8.6 → 0.8.8
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/README.md +116 -1
- package/apps/bridge/src/attachment-store.ts +3 -2
- package/apps/bridge/src/config/pairing.ts +11 -2
- package/apps/bridge/src/mesh/admin.ts +29 -1
- package/apps/bridge/src/mesh/store-state.ts +18 -1
- package/apps/bridge/src/mesh/store-sync.ts +11 -3
- package/apps/bridge/src/mesh/store.ts +135 -33
- package/apps/bridge/src/monitor.ts +8 -3
- package/apps/bridge/src/pairing.ts +2 -1
- package/apps/bridge/src/publication/git-reader.ts +42 -0
- package/apps/bridge/src/publication/types.ts +33 -0
- package/apps/bridge/src/session-keys.ts +2 -1
- package/apps/mcp/src/create-server.ts +4 -1
- package/apps/mcp/src/mcp-tools/README.md +3 -1
- package/apps/mcp/src/mcp-tools/connect.ts +60 -3
- package/apps/mcp/src/mcp-tools/connection-widget.ts +69 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +173 -139
- package/apps/mcp/src/mcp-tools/operation-ledger.ts +73 -10
- package/apps/mcp/src/package-version.ts +17 -0
- package/cursor-plugin/.cursor-plugin/marketplace.json +4 -2
- package/cursor-plugin/.cursor-plugin/plugin.json +2 -1
- package/cursor-plugin/README.md +10 -7
- package/docs/images/iphone-chat.png +0 -0
- package/docs/images/iphone-claude-tasks.png +0 -0
- package/docs/images/iphone-command-center.png +0 -0
- package/docs/images/iphone-governance.png +0 -0
- package/docs/images/iphone-handoff.png +0 -0
- package/docs/images/iphone-invite.png +0 -0
- package/docs/images/iphone-join-project.png +0 -0
- package/docs/images/iphone-mcp-usage.png +0 -0
- package/docs/images/iphone-member-detail.png +0 -0
- package/docs/images/iphone-members-shared.png +0 -0
- package/docs/images/iphone-members.png +0 -0
- package/docs/images/iphone-photo-preview.png +0 -0
- package/docs/images/iphone-project-mesh.png +0 -0
- package/docs/images/iphone-projects-shared.png +0 -0
- package/docs/images/iphone-projects.png +0 -0
- package/docs/images/iphone-report.png +0 -0
- package/docs/images/iphone-security-settings.png +0 -0
- package/docs/images/iphone-task-route.png +0 -0
- package/package.json +1 -1
- package/packages/protocol/messages/mesh.ts +28 -0
- package/packages/protocol/schema.ts +2 -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,41 @@ 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
|
+
# Grok Build
|
|
222
|
+
grok plugin marketplace add sergii-ziborov/granttap-mcp
|
|
223
|
+
grok plugin install granttap --trust
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Install **GrantTap** from the Cursor plugin marketplace, then open
|
|
227
|
+
**Cursor Settings → MCP → GrantTap → Authorize**. From a source checkout the
|
|
228
|
+
same plugin can be linked locally:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
mkdir -p "$HOME/.cursor/plugins/local"
|
|
232
|
+
ln -sfn "$PWD/cursor-plugin" "$HOME/.cursor/plugins/local/granttap"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Open GrantTap from the plugin page or ask `Show my GrantTap pairing QR.` Codex
|
|
236
|
+
renders an interactive connection card with pairing status, a one-time QR,
|
|
237
|
+
copy-link fallback, and confirmed reconnect controls. The same tools still
|
|
238
|
+
return readable MCP content in clients that do not render app UI. Detailed
|
|
239
|
+
plugin instructions are in
|
|
240
|
+
[`plugins/granttap/README.md`](plugins/granttap/README.md).
|
|
241
|
+
|
|
242
|
+
For the full background helper and provider hooks, install the CLI and run
|
|
243
|
+
setup:
|
|
244
|
+
|
|
148
245
|
```bash
|
|
149
246
|
npm install -g granttap-mcp
|
|
150
247
|
granttap setup
|
|
@@ -188,16 +285,19 @@ trust.
|
|
|
188
285
|
|
|
189
286
|
## MCP contract
|
|
190
287
|
|
|
191
|
-
`tools/list` returns exactly
|
|
288
|
+
`tools/list` returns exactly five public tools:
|
|
192
289
|
|
|
193
290
|
| Tool | Contract |
|
|
194
291
|
| --- | --- |
|
|
195
292
|
| `connect` | Reuse the existing production pairing or return a one-time QR |
|
|
293
|
+
| `reconnect` | Replace the pairing after explicit confirmation and return a fresh one-time QR |
|
|
196
294
|
| `notify` | Send a non-blocking status update of at most 2,000 characters |
|
|
197
295
|
| `ask_yes_no` | Ask a yes/no question and wait for the explicit answer |
|
|
198
296
|
| `ask` | Ask an open question and wait for typed or spoken text |
|
|
199
297
|
|
|
200
298
|
MCP `connect` accepts no custom routing, replacement, or key-rotation input.
|
|
299
|
+
`reconnect` is declared destructive and requires `confirmed: true`; relay
|
|
300
|
+
acceptance happens before the working local pairing is replaced.
|
|
201
301
|
Setup is CLI-only because it changes provider configuration and must not be
|
|
202
302
|
available to a model through prompt injection.
|
|
203
303
|
|
|
@@ -205,6 +305,14 @@ available to a model through prompt injection.
|
|
|
205
305
|
not add a fifth MCP tool or grant any global setup capability, and it publishes
|
|
206
306
|
only for the execution whose provider hook attributed the call.
|
|
207
307
|
|
|
308
|
+
`notify`, `ask_yes_no`, and `ask` accept an `operationId`. A named call is
|
|
309
|
+
remembered for fifteen minutes under the chat that made it, the tool, and the
|
|
310
|
+
arguments: a retry returns the answer already given instead of asking again,
|
|
311
|
+
a retry that arrives while the first call is still waiting waits for the same
|
|
312
|
+
answer, and the same name used for other arguments is refused. One chat's
|
|
313
|
+
name is never another's — the ledger is keyed by the execution the provider
|
|
314
|
+
hook attributed, and the hook carries the `operationId` with the call.
|
|
315
|
+
|
|
208
316
|
Provider-native approvals and mobile continuation require the matching local
|
|
209
317
|
adapter. MCP registration alone is never reported as proof that an integration
|
|
210
318
|
is ready.
|
|
@@ -331,6 +439,13 @@ Legacy custom levels remain compatible but are not part of the primary flow.
|
|
|
331
439
|
|
|
332
440
|
## Project Governance
|
|
333
441
|
|
|
442
|
+
<p align="center">
|
|
443
|
+
<img src="docs/images/iphone-governance.png" width="200" alt="Project Governance: one answer per kind of capability, and the enforcement it reached">
|
|
444
|
+
<img src="docs/images/iphone-member-detail.png" width="200" alt="A member: role, answers, and removal">
|
|
445
|
+
</p>
|
|
446
|
+
|
|
447
|
+
<p align="center"><em>Governance for the whole Project · a member's answers, changed at once</em></p>
|
|
448
|
+
|
|
334
449
|
Capabilities are decided per Project, not per task. A policy names an effect —
|
|
335
450
|
`allow`, `ask`, or `deny` — for each kind (skills, MCP servers, shell and
|
|
336
451
|
scripts, file writes, deploy, network), and may name one capability alone: one
|
|
@@ -47,8 +47,9 @@ export function storeAttachment(upload: UserAttachmentUpload, room?: string, now
|
|
|
47
47
|
// (the message that names it is rejected and the phone sends it inline);
|
|
48
48
|
// otherwise the oldest waiting ones make room, since a message that never
|
|
49
49
|
// came is the likeliest reason they are still here.
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
51
|
+
if (bytes > MAX_STAGED_BYTES) return false;
|
|
52
|
+
makeRoom(dir, bytes, id);
|
|
52
53
|
writeFileSync(join(dir, `${id}.json`), body, { mode: 0o600 });
|
|
53
54
|
return true;
|
|
54
55
|
}
|
|
@@ -4,6 +4,9 @@ import { generateKeyPair, randomId } from "../../../../packages/core/crypto";
|
|
|
4
4
|
import type { PeerConfig } from "../../../../packages/core/relay-client";
|
|
5
5
|
import { configDir } from "./paths";
|
|
6
6
|
|
|
7
|
+
export const DEFAULT_RELAY_URL = "wss://relay.granttap.com";
|
|
8
|
+
export const LEGACY_RELAY_URL = "wss://granttap-relay.sergii-ziborov.workers.dev";
|
|
9
|
+
|
|
7
10
|
export function saveConfig(path: string, cfg: PeerConfig): void {
|
|
8
11
|
mkdirSync(configDir(), { recursive: true });
|
|
9
12
|
if (existsSync(path)) {
|
|
@@ -25,7 +28,12 @@ export function saveConfig(path: string, cfg: PeerConfig): void {
|
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export function loadConfig(path: string): PeerConfig {
|
|
28
|
-
|
|
31
|
+
const cfg = JSON.parse(readFileSync(path, "utf8")) as PeerConfig;
|
|
32
|
+
if (cfg.relayUrl === LEGACY_RELAY_URL) {
|
|
33
|
+
cfg.relayUrl = DEFAULT_RELAY_URL;
|
|
34
|
+
saveConfig(path, cfg);
|
|
35
|
+
}
|
|
36
|
+
return cfg;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
const b64url = (value: string): string =>
|
|
@@ -54,7 +62,8 @@ export function normalizeRelayUrl(value: string): string {
|
|
|
54
62
|
if (url.username || url.password || url.hash || url.search) {
|
|
55
63
|
throw new Error("relay URL must not contain credentials, a query, or a fragment");
|
|
56
64
|
}
|
|
57
|
-
|
|
65
|
+
const normalized = url.toString().replace(/\/$/, "");
|
|
66
|
+
return normalized === LEGACY_RELAY_URL ? DEFAULT_RELAY_URL : normalized;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
function validPairingKey(value: string): boolean {
|
|
@@ -8,13 +8,41 @@
|
|
|
8
8
|
* rule protects. A release from the phone is therefore a command of its
|
|
9
9
|
* own, checked against the Project it names, and written down.
|
|
10
10
|
*/
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
MeshClaimRelease,
|
|
13
|
+
MeshClaimReleaseResult,
|
|
14
|
+
ResourceClaim,
|
|
15
|
+
} from "../../../../packages/protocol/schema";
|
|
12
16
|
import type { MeshStore } from "./store";
|
|
13
17
|
|
|
14
18
|
export type PersonRelease =
|
|
15
19
|
| { released: true; claim: ResourceClaim }
|
|
16
20
|
| { released: false; reason: "unknown_claim" | "other_project" };
|
|
17
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
|
+
|
|
18
46
|
export function releaseClaimByPerson(
|
|
19
47
|
store: MeshStore,
|
|
20
48
|
request: MeshClaimRelease,
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
this.
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return
|
|
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.
|
|
370
|
-
|
|
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
|
-
|
|
461
|
-
item.claimId
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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,10 +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 } from "./mesh/admin";
|
|
56
|
+
import { releaseClaimByPerson, releaseResult } from "./mesh/admin";
|
|
57
57
|
import { deriveObservedClaims } from "./mesh/observed-claims";
|
|
58
58
|
import { localMeshStore } from "./mesh/local";
|
|
59
59
|
import { cachedSessionActivity } from "./monitor-session-activity";
|
|
@@ -209,6 +209,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
209
209
|
|
|
210
210
|
const snapshot = (includeHistory: boolean, forceHistory = false): SessionsStatus => {
|
|
211
211
|
sweepAttachments();
|
|
212
|
+
// A change the store's lock held back is written on the next tick.
|
|
213
|
+
if (loadRuntimeConfig().meshEnabled) localMeshStore().flush();
|
|
212
214
|
const { sessions, tokensRecent } = scanSessions();
|
|
213
215
|
const runtime = loadRuntimeConfig();
|
|
214
216
|
return {
|
|
@@ -400,8 +402,11 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
400
402
|
if (prepared) void publish().catch(() => {});
|
|
401
403
|
return prepared;
|
|
402
404
|
} else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
|
|
403
|
-
// The person's own authority, not an owner's event: written down, then
|
|
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.
|
|
404
407
|
const outcome = releaseClaimByPerson(localMeshStore(), payload);
|
|
408
|
+
await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
|
|
409
|
+
.catch(() => {});
|
|
405
410
|
if (outcome.released) void publish().catch(() => {});
|
|
406
411
|
return true;
|
|
407
412
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./install";
|
|
9
9
|
import {
|
|
10
10
|
createPairing,
|
|
11
|
+
DEFAULT_RELAY_URL,
|
|
11
12
|
loadConfig,
|
|
12
13
|
machineConfigPath,
|
|
13
14
|
normalizeRelayUrl,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
} from "./config";
|
|
17
18
|
import type { PeerConfig } from "../../../packages/core/relay-client";
|
|
18
19
|
|
|
19
|
-
export const DEFAULT_RELAY =
|
|
20
|
+
export const DEFAULT_RELAY = DEFAULT_RELAY_URL;
|
|
20
21
|
export const PAIRING_CODE_TTL_MINUTES = 15;
|
|
21
22
|
|
|
22
23
|
function validConfig(config: PeerConfig, role: PeerConfig["role"]): boolean {
|