granttap-mcp 0.8.21 → 0.8.23
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 +23 -1
- package/apps/bridge/src/bin/cli/connect.ts +2 -1
- package/apps/bridge/src/bin/cli/phone.ts +54 -0
- package/apps/bridge/src/capabilities/README.md +5 -0
- package/apps/bridge/src/cortex/integration.ts +10 -7
- package/apps/bridge/src/engine/protocol/engine-memory-protocol.ts +3 -0
- package/apps/bridge/src/engine/runtime/engine-memory.ts +56 -34
- package/apps/bridge/src/mesh/catalog/project/observations.ts +7 -0
- package/apps/bridge/src/mesh/context/env.ts +32 -2
- package/apps/bridge/src/mesh/context/project.ts +13 -2
- package/apps/bridge/src/mesh/knowledge/README.md +14 -0
- package/apps/bridge/src/mesh/knowledge/active.ts +64 -0
- package/apps/bridge/src/mesh/knowledge/write.ts +45 -0
- package/apps/bridge/src/mesh/runtime/index.ts +13 -2
- package/apps/bridge/src/mesh/snapshot/scoped-view.ts +5 -1
- package/apps/bridge/src/mesh/store/index.ts +10 -1
- package/apps/bridge/src/monitor/controller-knowledge/index.ts +51 -0
- package/apps/bridge/src/monitor/handlers/inbound.ts +21 -7
- package/apps/bridge/src/monitor/support/runtime.ts +1 -1
- package/apps/bridge/src/pairing/controller-invites.ts +92 -0
- package/apps/mcp/src/connection-center/bridge.js +7 -0
- package/apps/mcp/src/connection-center/widget.html +1 -1
- package/apps/mcp/src/mcp-tools/connect/widget.ts +1 -1
- package/bin/granttap-mcp.mjs +6 -2
- package/cursor-plugin/.cursor-plugin/plugin.json +2 -2
- package/cursor-plugin/README.md +7 -4
- package/cursor-plugin/commands/connect.md +4 -3
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/skills/connect/SKILL.md +8 -7
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/docs/images/iphone-health-code-towers.png +0 -0
- package/docs/images/iphone-linked-projects.png +0 -0
- package/docs/images/iphone-weavatrix-graph.png +0 -0
- package/package.json +2 -2
- package/packages/core/relay-client.ts +21 -0
- package/packages/protocol/messages/README.md +6 -0
- package/packages/protocol/messages/device/controller-pairing.ts +32 -0
- package/packages/protocol/messages/mesh/knowledge/index.ts +1 -0
- package/packages/protocol/messages/mesh/knowledge/write.ts +27 -0
- package/packages/protocol/messages/mesh/snapshot.ts +6 -0
- package/packages/protocol/schema.ts +8 -0
|
@@ -31,6 +31,7 @@ import { loadExecutionPolicy } from "../runtime/execution-policy";
|
|
|
31
31
|
import { loadEnvironment, redactEnvironment } from "../context/env";
|
|
32
32
|
import { loadRestrictions } from "../restrictions";
|
|
33
33
|
import { MeshStoreBase } from "./base";
|
|
34
|
+
import { activeProjectKnowledge, supersededProjectKnowledgeIds } from "../knowledge/active";
|
|
34
35
|
import {
|
|
35
36
|
projectCapabilityRequests,
|
|
36
37
|
} from "../catalog/project/requests";
|
|
@@ -268,6 +269,12 @@ export class MeshStore extends MeshStoreBase {
|
|
|
268
269
|
return this.state.events.filter((event) => event.projectId === projectId).slice(-128);
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
/** Retained, structured evidence for a one-time Memory backfill. */
|
|
273
|
+
historyEventsForProject(projectId: string): MeshEventValue[] {
|
|
274
|
+
this.sync();
|
|
275
|
+
return this.state.events.filter((event) => event.projectId === projectId);
|
|
276
|
+
}
|
|
277
|
+
|
|
271
278
|
projectIds(): string[] {
|
|
272
279
|
this.sync();
|
|
273
280
|
return this.state.projects.map((project) => project.projectId);
|
|
@@ -331,6 +338,7 @@ export class MeshStore extends MeshStoreBase {
|
|
|
331
338
|
const execution = loadExecutionPolicy(projectId);
|
|
332
339
|
const restrictions = loadRestrictions(projectId);
|
|
333
340
|
const environment = redactEnvironment(loadEnvironment(projectId));
|
|
341
|
+
const correctedIds = supersededProjectKnowledgeIds(projectId, this.state.knowledge);
|
|
334
342
|
return MeshSnapshot.parse({
|
|
335
343
|
type: "mesh.snapshot",
|
|
336
344
|
sessionId: projectId,
|
|
@@ -360,8 +368,9 @@ export class MeshStore extends MeshStoreBase {
|
|
|
360
368
|
claims: this.activeClaims().filter((item) => item.projectId === projectId && taskIds.has(item.taskId)).slice(-128),
|
|
361
369
|
dependencies: this.state.dependencies.filter((item) => taskIds.has(item.taskId)).slice(-128),
|
|
362
370
|
events: this.eventsForProject(projectId).filter((event) => taskIds.has(event.taskId)),
|
|
363
|
-
knowledge: this.state.knowledge
|
|
371
|
+
knowledge: activeProjectKnowledge(projectId, this.state.knowledge)
|
|
364
372
|
.slice(-16).reverse(),
|
|
373
|
+
...(correctedIds.length > 0 ? { supersededKnowledgeRecordIds: correctedIds } : {}),
|
|
365
374
|
generatedAt: this.now(),
|
|
366
375
|
});
|
|
367
376
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
2
|
+
import type { ControllerPairRequest, KnowledgeWrite } from "../../../../../packages/protocol/schema";
|
|
3
|
+
import { loadRuntimeConfig } from "../../config";
|
|
4
|
+
import { loadConfig, machineConfigPath } from "../../config";
|
|
5
|
+
import { localMeshStore } from "../../mesh/local-remote/local";
|
|
6
|
+
import { createOneTimePairing, parkedPairingHalves } from "../../pairing";
|
|
7
|
+
import { issueControllerOffer } from "../../pairing/controller-invites";
|
|
8
|
+
import { handleKnowledgeWrite } from "../../mesh/knowledge/write";
|
|
9
|
+
import { recordProjectKnowledge } from "../../engine/runtime/engine-memory";
|
|
10
|
+
import { computerId } from "../../mesh/identity/computer";
|
|
11
|
+
|
|
12
|
+
/** The requester's device key scopes the new controller code, not Project access. */
|
|
13
|
+
export async function handleControllerPairRequest(
|
|
14
|
+
client: RelayClient, request: ControllerPairRequest, peerPublicKey?: string,
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
if (!peerPublicKey || !parkedPairingHalves()) return false;
|
|
17
|
+
const machine = loadConfig(machineConfigPath());
|
|
18
|
+
if (machine.room !== client.room) return false;
|
|
19
|
+
return issueControllerOffer(client, peerPublicKey, request, async () => {
|
|
20
|
+
const pairing = await createOneTimePairing(machine.relayUrl, {
|
|
21
|
+
installHooks: false, addController: true,
|
|
22
|
+
});
|
|
23
|
+
return { room: pairing.machineCfg.room, peerPublicKey: pairing.phoneCfg.myPublicKey,
|
|
24
|
+
uri: pairing.qrPayload };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A phone gets a recorded receipt only after the local Engine commits Memory. */
|
|
29
|
+
export async function handleInboundKnowledgeWrite(
|
|
30
|
+
client: RelayClient, request: KnowledgeWrite, peerPublicKey: string | undefined,
|
|
31
|
+
publish: () => Promise<void>,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
if (!peerPublicKey) return false;
|
|
34
|
+
const endpointId = computerId();
|
|
35
|
+
let recorded = false;
|
|
36
|
+
const handled = await handleKnowledgeWrite(request, {
|
|
37
|
+
now: Date.now, endpointId,
|
|
38
|
+
snapshot: (projectId) => {
|
|
39
|
+
if (!loadRuntimeConfig().meshEnabled) return undefined;
|
|
40
|
+
const snapshot = localMeshStore().snapshot(projectId, endpointId);
|
|
41
|
+
return snapshot && { tasks: snapshot.tasks, bindings: snapshot.bindings ?? [] };
|
|
42
|
+
},
|
|
43
|
+
record: async (entry) => {
|
|
44
|
+
recorded = await recordProjectKnowledge(entry);
|
|
45
|
+
return recorded;
|
|
46
|
+
},
|
|
47
|
+
send: (result) => client.sendToPeer(result, peerPublicKey),
|
|
48
|
+
});
|
|
49
|
+
if (recorded) void publish().catch(() => {});
|
|
50
|
+
return handled;
|
|
51
|
+
}
|
|
@@ -34,6 +34,7 @@ import { handleUserMessage } from "./user-message";
|
|
|
34
34
|
import { publishSessionEvents } from "./catalog";
|
|
35
35
|
import { publishHistoryPage } from "../history/pages";
|
|
36
36
|
import { requestProjectGraphRefresh } from "../graph-refresh";
|
|
37
|
+
import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
|
|
37
38
|
|
|
38
39
|
export async function handleMonitorMessage(
|
|
39
40
|
client: RelayClient,
|
|
@@ -42,6 +43,7 @@ export async function handleMonitorMessage(
|
|
|
42
43
|
leadership: { acquire: () => boolean };
|
|
43
44
|
subscriptions: Set<string>;
|
|
44
45
|
publish: (forceHistory?: boolean) => Promise<void>;
|
|
46
|
+
peerPublicKey?: string;
|
|
45
47
|
},
|
|
46
48
|
): Promise<boolean> {
|
|
47
49
|
const { leadership, subscriptions, publish } = input;
|
|
@@ -52,6 +54,12 @@ export async function handleMonitorMessage(
|
|
|
52
54
|
// Codex may start one MCP server per open task. Exactly one instance owns
|
|
53
55
|
// phone routing, so a single phone message can never create duplicate tasks.
|
|
54
56
|
if (!leadership.acquire()) return false;
|
|
57
|
+
if (payload.type === "controller.pair.request") {
|
|
58
|
+
return handleControllerPairRequest(client, payload, input.peerPublicKey);
|
|
59
|
+
}
|
|
60
|
+
if (payload.type === "knowledge.write") {
|
|
61
|
+
return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
|
|
62
|
+
}
|
|
55
63
|
if (payload.type === "user.message") {
|
|
56
64
|
return handleInboundUserMessage(client, payload, publish);
|
|
57
65
|
} else if (payload.type === "user.attachment") {
|
|
@@ -121,17 +129,23 @@ export async function handleMonitorMessage(
|
|
|
121
129
|
if (prepared) void publish().catch(() => {});
|
|
122
130
|
return prepared;
|
|
123
131
|
} else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
|
|
124
|
-
|
|
125
|
-
// applied, and answered either way, so a refusal is seen where it was asked.
|
|
126
|
-
const outcome = releaseClaimByPerson(localMeshStore(), payload);
|
|
127
|
-
await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
|
|
128
|
-
.catch(() => {});
|
|
129
|
-
if (outcome.released) void publish().catch(() => {});
|
|
130
|
-
return true;
|
|
132
|
+
return handleInboundClaimRelease(client, payload, publish);
|
|
131
133
|
}
|
|
132
134
|
return false;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
async function handleInboundClaimRelease(
|
|
138
|
+
client: RelayClient, payload: Extract<Payload, { type: "mesh.claim.release" }>,
|
|
139
|
+
publish: (forceHistory?: boolean) => Promise<void>,
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
// The person's authority is recorded and answered even when release fails.
|
|
142
|
+
const outcome = releaseClaimByPerson(localMeshStore(), payload);
|
|
143
|
+
await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
|
|
144
|
+
.catch(() => {});
|
|
145
|
+
if (outcome.released) void publish().catch(() => {});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
function handleInboundRefresh(
|
|
136
150
|
client: RelayClient, payload: Extract<Payload, { type: "sessions.refresh" }>,
|
|
137
151
|
publish: (forceHistory?: boolean) => Promise<void>,
|
|
@@ -273,7 +273,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
273
273
|
if (payload.type === "hello" && payload.role === "phone") {
|
|
274
274
|
confirmPendingController(client.room, peerPublicKey);
|
|
275
275
|
}
|
|
276
|
-
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish });
|
|
276
|
+
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
const stopCatalog = startPublishLoop({
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ControllerPairOffer, ControllerPairRequest } from "../../../../packages/protocol/schema";
|
|
4
|
+
import { configDir } from "../config/runtime/paths";
|
|
5
|
+
import { writePrivateFile } from "../config/access/write-private";
|
|
6
|
+
|
|
7
|
+
type Record = { requestId: string; requesterKey: string; offer: ControllerPairOffer };
|
|
8
|
+
type Transport = {
|
|
9
|
+
room: string;
|
|
10
|
+
addControllerPeer: (key: string) => void;
|
|
11
|
+
sendToPeer: (offer: ControllerPairOffer, key: string) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
type Mint = () => Promise<{ room: string; peerPublicKey: string; uri: string }>;
|
|
14
|
+
|
|
15
|
+
const pending = new Map<string, Promise<boolean>>();
|
|
16
|
+
let issuance = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
function journalPath(): string { return join(configDir(), "controller-pair-offers.json"); }
|
|
19
|
+
|
|
20
|
+
function load(now: number): Record[] | null {
|
|
21
|
+
try {
|
|
22
|
+
const parsed: unknown = JSON.parse(readFileSync(journalPath(), "utf8"));
|
|
23
|
+
if (!Array.isArray(parsed) || parsed.length > 16) return null;
|
|
24
|
+
const valid = (value: unknown): value is Record => {
|
|
25
|
+
if (!value || typeof value !== "object") return false;
|
|
26
|
+
const record = value as Partial<Record>;
|
|
27
|
+
return typeof record.requestId === "string" && typeof record.requesterKey === "string"
|
|
28
|
+
&& typeof record.offer?.expiresAt === "number" && typeof record.offer.uri === "string"
|
|
29
|
+
&& record.offer.status === "ready";
|
|
30
|
+
};
|
|
31
|
+
if (!parsed.every(valid)) return null;
|
|
32
|
+
return parsed.filter((record: Record) => (record.offer.expiresAt ?? 0) > now);
|
|
33
|
+
} catch { return existsSync(journalPath()) ? null : []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persist(records: Record[]): void {
|
|
37
|
+
writePrivateFile(journalPath(), `${JSON.stringify(records)}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rejected(requestId: string, room: string, reason: string, now: number): ControllerPairOffer {
|
|
41
|
+
return { type: "controller.pair.offer", requestId, room, status: "rejected", reason,
|
|
42
|
+
createdAt: now };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function issue(
|
|
46
|
+
client: Transport, requesterKey: string, request: ControllerPairRequest, mint: Mint,
|
|
47
|
+
now: number,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const records = load(now);
|
|
50
|
+
let offer: ControllerPairOffer;
|
|
51
|
+
if (!records) {
|
|
52
|
+
offer = rejected(request.requestId, client.room, "Pairing journal needs repair on the computer.", now);
|
|
53
|
+
} else {
|
|
54
|
+
const prior = records.find((item) => item.requestId === request.requestId);
|
|
55
|
+
if (prior && prior.requesterKey !== requesterKey) {
|
|
56
|
+
offer = rejected(request.requestId, client.room, "This request belongs to another controller.", now);
|
|
57
|
+
} else if (prior) {
|
|
58
|
+
offer = prior.offer;
|
|
59
|
+
} else if (Math.abs(now - request.createdAt) > 5 * 60_000) {
|
|
60
|
+
offer = rejected(request.requestId, client.room, "Request expired. Try again.", now);
|
|
61
|
+
} else {
|
|
62
|
+
try {
|
|
63
|
+
const created = await mint();
|
|
64
|
+
if (created.room !== client.room) throw new Error("room changed");
|
|
65
|
+
client.addControllerPeer(created.peerPublicKey);
|
|
66
|
+
offer = { type: "controller.pair.offer", requestId: request.requestId,
|
|
67
|
+
room: client.room, status: "ready", uri: created.uri,
|
|
68
|
+
expiresAt: now + 15 * 60_000, createdAt: now };
|
|
69
|
+
persist([...records, { requestId: request.requestId, requesterKey, offer }].slice(-16));
|
|
70
|
+
} catch {
|
|
71
|
+
offer = rejected(request.requestId, client.room, "Could not create a controller code.", now);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await client.sendToPeer(offer, requesterKey);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Durable, scoped offer: a relay retry cannot mint a second controller key. */
|
|
80
|
+
export function issueControllerOffer(
|
|
81
|
+
client: Transport, requesterKey: string, request: ControllerPairRequest, mint: Mint,
|
|
82
|
+
now = Date.now(),
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
const id = `${client.room}:${request.requestId}`;
|
|
85
|
+
const existing = pending.get(id);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
const work = issuance.then(() => issue(client, requesterKey, request, mint, now));
|
|
88
|
+
issuance = work.then(() => undefined, () => undefined);
|
|
89
|
+
pending.set(id, work);
|
|
90
|
+
void work.finally(() => { pending.delete(id); }).catch(() => undefined);
|
|
91
|
+
return work;
|
|
92
|
+
}
|
|
@@ -43,6 +43,13 @@ async function initializeBridge() {
|
|
|
43
43
|
});
|
|
44
44
|
if (initialized?.protocolVersion !== "2026-01-26") throw new Error("Unsupported UI protocol");
|
|
45
45
|
post({ method: "ui/notifications/initialized" });
|
|
46
|
+
// The compatibility API is an explicit tool-call capability. In Codex it
|
|
47
|
+
// avoids an iframe proxy that can initialize but time out on tools/call.
|
|
48
|
+
legacyBridge = typeof window.openai?.callTool === "function";
|
|
49
|
+
if (!legacyBridge && !initialized.hostCapabilities?.serverTools) {
|
|
50
|
+
message("This host cannot call GrantTap tools from the card. Ask the agent to open connection status or add a device.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
46
53
|
bridgeReady = true;
|
|
47
54
|
} catch {
|
|
48
55
|
if (!window.openai?.callTool) {
|
|
@@ -38,7 +38,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
|
|
|
38
38
|
<h3>Provider readiness</h3><ul id="providers"></ul>
|
|
39
39
|
<p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
|
|
40
40
|
Provider sign-in is separate.</p>
|
|
41
|
-
<p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.
|
|
41
|
+
<p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.23</code> and <code>granttap setup</code> locally.
|
|
42
42
|
A saved pairing alone does not prove your phone or the background helper is online.</p>
|
|
43
43
|
<p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
|
|
44
44
|
<a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
|
|
3
|
-
export const CONNECTION_WIDGET_URI = "ui://granttap/connection/
|
|
3
|
+
export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v3.html";
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
|
package/bin/granttap-mcp.mjs
CHANGED
|
@@ -16,6 +16,8 @@ const usage = [
|
|
|
16
16
|
" granttap setup Detect agents and install or repair GrantTap",
|
|
17
17
|
" granttap status [--json] Show pairing, helper, and provider readiness",
|
|
18
18
|
" granttap connect [--relay <url>] Pair this computer with iPhone/Apple Watch",
|
|
19
|
+
" granttap phone add Show a QR for another controller phone",
|
|
20
|
+
" granttap phone reconnect Show a QR to restore the same phone",
|
|
19
21
|
" granttap reset [--yes] Reset this computer's phone pairing",
|
|
20
22
|
" granttap mesh connect <invite> Redeem a one-time Grok Bot Mesh invite",
|
|
21
23
|
" granttap restrictions check [dir] Check files against Mesh / .granttap restrictions",
|
|
@@ -36,6 +38,8 @@ if (command === "mcp") {
|
|
|
36
38
|
entry = join(root, "apps", "bridge", "src", "bin", "cli", "setup.ts");
|
|
37
39
|
} else if (command === "connect") {
|
|
38
40
|
entry = join(root, "apps", "bridge", "src", "bin", "cli", "connect.ts");
|
|
41
|
+
} else if (command === "phone" && ["add", "reconnect"].includes(argument)) {
|
|
42
|
+
entry = join(root, "apps", "bridge", "src", "bin", "cli", "phone.ts");
|
|
39
43
|
} else if (command === "status") {
|
|
40
44
|
entry = join(root, "apps", "mcp", "src", "bin", "status.ts");
|
|
41
45
|
} else if (command === "reset") {
|
|
@@ -100,8 +104,8 @@ if (!preflight || !loader) {
|
|
|
100
104
|
process.exit(1);
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
const forwardedArgs = ["connect", "status", "reset", "mesh", "setup", "restrictions"].includes(command)
|
|
104
|
-
? (command === "mesh" || command === "restrictions" ? commandArgs.slice(1) : commandArgs)
|
|
107
|
+
const forwardedArgs = ["connect", "status", "reset", "mesh", "setup", "restrictions", "phone"].includes(command)
|
|
108
|
+
? (command === "mesh" || command === "restrictions" || command === "phone" ? commandArgs.slice(1) : commandArgs)
|
|
105
109
|
: [];
|
|
106
110
|
const child = spawn(
|
|
107
111
|
process.execPath,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granttap",
|
|
3
3
|
"displayName": "GrantTap",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "0.1.16",
|
|
5
|
+
"description": "GrantTap pairs this computer with iPhone. The connection card shows a one-time QR when Cursor supports interactive MCP Apps; an interactive terminal command is available if it does not. The website approves the coding app.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "GrantTap",
|
|
8
8
|
"email": "support@granttap.com",
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.23
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
|
@@ -22,9 +22,12 @@ The commands have separate jobs:
|
|
|
22
22
|
2. `granttap setup` also installs the background task-sync helper.
|
|
23
23
|
3. `granttap status` performs a read-only readiness check.
|
|
24
24
|
|
|
25
|
-
After `granttap setup`,
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
After `granttap setup`, open the GrantTap connection card in Cursor for **Add a
|
|
26
|
+
device**, **Add another device**, or **Reconnect**. Scan its QR in the phone
|
|
27
|
+
app. If Cursor does not render or operate the card, run `granttap phone add`
|
|
28
|
+
in your own interactive terminal; it never prints the QR to an agent log.
|
|
29
|
+
`granttap.com/connect` approves the coding app and shows phone observations.
|
|
30
|
+
Cloud **Error · fetch
|
|
28
31
|
failed** is a leftover HTTP MCP at `http://127.0.0.1:17342/mcp` — either a
|
|
29
32
|
user Customize → MCPs row or an old plugin `mcp.json`. The current plugin is
|
|
30
33
|
stdio (`node -e`) so Cloud never fetches loopback.
|
|
@@ -7,6 +7,7 @@ Call GrantTap tools yourself. Do not ask the person to pair in this chat.
|
|
|
7
7
|
1. Call `connection_status`.
|
|
8
8
|
2. If this computer is not paired, call `connect`.
|
|
9
9
|
3. Do not print a pairing URI, token, or QR in chat.
|
|
10
|
-
4.
|
|
11
|
-
granttap.com/connect
|
|
12
|
-
app
|
|
10
|
+
4. The GrantTap connection card shows the device list and one-time QR when
|
|
11
|
+
Cursor supports interactive MCP Apps. `granttap.com/connect` approves the
|
|
12
|
+
coding app but does not display a pairing QR. If the card cannot call tools,
|
|
13
|
+
tell the person to run `granttap phone add` in their own terminal.
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.23\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -12,19 +12,20 @@ The person does not pair in chat. You connect this computer by calling tools.
|
|
|
12
12
|
1. Call `connection_status`.
|
|
13
13
|
2. If this computer is not paired, call `connect`.
|
|
14
14
|
3. Never print a pairing URI, manual token, or QR in chat.
|
|
15
|
-
4. Never
|
|
16
|
-
|
|
15
|
+
4. Never put a private pairing QR in this conversation. If the host cannot
|
|
16
|
+
call tools from the connection card, explain the local interactive-terminal
|
|
17
|
+
fallback: `granttap phone add` or `granttap phone reconnect`.
|
|
17
18
|
|
|
18
19
|
A saved pairing is reused. Do not call `reconnect` unless they confirmed
|
|
19
20
|
Add another device or Reconnect in plugin settings.
|
|
20
21
|
|
|
21
22
|
## Settings (the person)
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
computer.
|
|
24
|
+
The GrantTap connection card in this host shows the device list and one-time
|
|
25
|
+
QR under Add a device, Add another device, or Reconnect when the host supports
|
|
26
|
+
interactive MCP Apps. GrantTap on iPhone scans it in Settings → Connections.
|
|
27
|
+
granttap.com/connect handles coding-app approval and phone observations, but
|
|
28
|
+
does not display a pairing QR. All coding apps on this computer share one room.
|
|
28
29
|
|
|
29
30
|
For an interactive question, send the same complete prompt to Cursor and
|
|
30
31
|
GrantTap under one exact correlation. The first answer with that correlation
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.23";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granttap-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.23",
|
|
4
4
|
"description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"setup": "node bin/granttap-mcp.mjs setup",
|
|
26
26
|
"status": "node bin/granttap-mcp.mjs status",
|
|
27
27
|
"test": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 node scripts/quality/run-tests.mjs",
|
|
28
|
-
"test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/payload/types.ts' --exclude 'apps/bridge/src/mesh/runtime/dependencies.ts' --check-coverage --lines
|
|
28
|
+
"test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/payload/types.ts' --exclude 'apps/bridge/src/mesh/runtime/dependencies.ts' --check-coverage --lines 90 --statements 90 --functions 90 --branches 85 node scripts/quality/run-tests.mjs",
|
|
29
29
|
"inventory": "node scripts/quality/source-inventory.mjs",
|
|
30
30
|
"structure": "node scripts/quality/structure.mjs",
|
|
31
31
|
"package:allowlist": "node scripts/quality/package-allowlist.mjs",
|
|
@@ -238,6 +238,27 @@ export class RelayClient {
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Add a newly issued controller without waiting for the monitor to restart. */
|
|
242
|
+
addControllerPeer(peerPublicKey: string): void {
|
|
243
|
+
if (this.cfg.role !== "machine" || !this.peerIsAllowed(peerPublicKey)) {
|
|
244
|
+
throw new Error("controller peer is not authorized");
|
|
245
|
+
}
|
|
246
|
+
const peers = this.cfg.extraPeerPublicKeys ?? [];
|
|
247
|
+
if (peerPublicKey !== this.cfg.peerPublicKey && !peers.includes(peerPublicKey)) {
|
|
248
|
+
if (peers.length >= 16) throw new Error("controller device limit reached");
|
|
249
|
+
this.cfg.extraPeerPublicKeys = [...peers, peerPublicKey];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Keep a controller invitation visible only to the requesting phone key. */
|
|
254
|
+
async sendToPeer(payload: Payload, peerPublicKey: string, options: SendOptions = {}): Promise<void> {
|
|
255
|
+
const ws = this.ws;
|
|
256
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
|
|
257
|
+
if (!peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys).includes(peerPublicKey)
|
|
258
|
+
|| !this.peerIsAllowed(peerPublicKey)) throw new Error("controller peer is not authorized");
|
|
259
|
+
ws.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), options));
|
|
260
|
+
}
|
|
261
|
+
|
|
241
262
|
setSessionKey(sessionId: string, key: string): void {
|
|
242
263
|
this.sessionKeys.set(sessionId, key);
|
|
243
264
|
}
|
|
@@ -20,6 +20,12 @@ sealed with the same independent Project key as Mesh snapshots. Coverage keeps
|
|
|
20
20
|
`enforced`, `observed`, `unsupported`, and `unknown` distinct so clients never
|
|
21
21
|
claim a provider can block an action when it cannot.
|
|
22
22
|
|
|
23
|
+
`device/controller-pairing.ts` lets an authenticated controller ask its linked
|
|
24
|
+
computer for another one-time controller credential. The reply is sealed only
|
|
25
|
+
to the requester. It is device-network pairing, not Project membership.
|
|
26
|
+
`mesh/knowledge/write.ts` carries a Task-bound human decision or correction and a
|
|
27
|
+
separate Engine-confirmed result.
|
|
28
|
+
|
|
23
29
|
Tests are discoverable in `tests/core.test.ts` and the feature-level bridge and MCP suites.
|
|
24
30
|
|
|
25
31
|
License: this module is distributed under the MIT License in the repository-root `LICENSE` file.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const RequestId = z.string().uuid();
|
|
4
|
+
|
|
5
|
+
/** A trusted phone asks its paired computer to mint a separate controller key. */
|
|
6
|
+
export const ControllerPairRequest = z.object({
|
|
7
|
+
type: z.literal("controller.pair.request"),
|
|
8
|
+
requestId: RequestId,
|
|
9
|
+
createdAt: z.number().nonnegative(),
|
|
10
|
+
}).strict();
|
|
11
|
+
export type ControllerPairRequest = z.infer<typeof ControllerPairRequest>;
|
|
12
|
+
|
|
13
|
+
/** Sent only through the authenticated device box, never a Project channel. */
|
|
14
|
+
export const ControllerPairOffer = z.object({
|
|
15
|
+
type: z.literal("controller.pair.offer"),
|
|
16
|
+
requestId: RequestId,
|
|
17
|
+
room: z.string().regex(/^[a-f0-9]{16,64}$/),
|
|
18
|
+
status: z.enum(["ready", "rejected"]),
|
|
19
|
+
uri: z.string().max(1024).optional(),
|
|
20
|
+
expiresAt: z.number().nonnegative().optional(),
|
|
21
|
+
reason: z.string().max(200).optional(),
|
|
22
|
+
createdAt: z.number().nonnegative(),
|
|
23
|
+
}).strict().superRefine((offer, ctx) => {
|
|
24
|
+
if (offer.status === "ready" && (!offer.uri?.startsWith("granttap://pair-v2?")
|
|
25
|
+
|| !offer.expiresAt || offer.expiresAt <= offer.createdAt)) {
|
|
26
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ready offer needs a valid URI and expiry" });
|
|
27
|
+
}
|
|
28
|
+
if (offer.status === "rejected" && offer.uri) {
|
|
29
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "rejected offer cannot carry a URI" });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
export type ControllerPairOffer = z.infer<typeof ControllerPairOffer>;
|
|
@@ -13,6 +13,7 @@ export const ProjectKnowledgeRecord = z.object({
|
|
|
13
13
|
visibility: z.enum(["task", "project"]),
|
|
14
14
|
repositoryId: z.string().trim().min(1).max(512).optional(),
|
|
15
15
|
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/i).optional(),
|
|
16
|
+
supersedesRecordId: Identifier.optional(),
|
|
16
17
|
recordedAt: z.number().int().nonnegative(),
|
|
17
18
|
streamVersion: z.number().int().nonnegative(),
|
|
18
19
|
}).strict();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Identifier } from "../endpoint";
|
|
3
|
+
|
|
4
|
+
/** A person's explicit statement, never inferred from a chat title. */
|
|
5
|
+
export const KnowledgeWrite = z.object({
|
|
6
|
+
type: z.literal("knowledge.write"),
|
|
7
|
+
projectId: Identifier,
|
|
8
|
+
taskId: Identifier,
|
|
9
|
+
recordId: Identifier,
|
|
10
|
+
content: z.string().trim().min(1).max(4_096),
|
|
11
|
+
repositoryId: z.string().trim().min(1).max(512).optional(),
|
|
12
|
+
supersedesRecordId: Identifier.optional(),
|
|
13
|
+
createdAt: z.number().int().nonnegative(),
|
|
14
|
+
}).strict().refine((request) => request.recordId !== request.supersedesRecordId,
|
|
15
|
+
"a decision cannot supersede itself");
|
|
16
|
+
export type KnowledgeWrite = z.infer<typeof KnowledgeWrite>;
|
|
17
|
+
|
|
18
|
+
export const KnowledgeWriteResult = z.object({
|
|
19
|
+
type: z.literal("knowledge.write.result"),
|
|
20
|
+
projectId: Identifier,
|
|
21
|
+
taskId: Identifier,
|
|
22
|
+
recordId: Identifier,
|
|
23
|
+
status: z.enum(["recorded", "rejected"]),
|
|
24
|
+
reason: z.string().max(200).optional(),
|
|
25
|
+
createdAt: z.number().int().nonnegative(),
|
|
26
|
+
}).strict();
|
|
27
|
+
export type KnowledgeWriteResult = z.infer<typeof KnowledgeWriteResult>;
|
|
@@ -239,6 +239,7 @@ export const MeshSnapshot = z.object({
|
|
|
239
239
|
repositoryGraphs: z.array(ProjectRepositoryGraph).max(64).optional(),
|
|
240
240
|
cortex: z.array(ProjectCortexIntegration).max(32).optional(),
|
|
241
241
|
knowledge: z.array(ProjectKnowledgeRecord).max(32).optional(),
|
|
242
|
+
supersededKnowledgeRecordIds: z.array(Identifier).max(128).optional(),
|
|
242
243
|
tasks: z.array(MeshTask).max(64),
|
|
243
244
|
executions: z.array(ExecutionSessionLink).max(128),
|
|
244
245
|
claims: z.array(ResourceClaim).max(128),
|
|
@@ -295,6 +296,11 @@ export const MeshSnapshot = z.object({
|
|
|
295
296
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["repositoryGraphs"], message: "invalid repository graph scope" });
|
|
296
297
|
}
|
|
297
298
|
const knowledgeIds = new Set(value.knowledge?.map((item) => item.recordId));
|
|
299
|
+
const supersededKnowledgeIds = new Set(value.supersededKnowledgeRecordIds);
|
|
300
|
+
if (supersededKnowledgeIds.size !== (value.supersededKnowledgeRecordIds?.length ?? 0)) {
|
|
301
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["supersededKnowledgeRecordIds"],
|
|
302
|
+
message: "duplicate superseded knowledge identity" });
|
|
303
|
+
}
|
|
298
304
|
if ((value.knowledge?.length ?? 0) !== knowledgeIds.size
|
|
299
305
|
|| value.knowledge?.some((item) => item.projectId !== value.projectId
|
|
300
306
|
|| item.visibility !== "project")) {
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
} from "./messages/mesh";
|
|
32
32
|
import { MachineLoad } from "./messages/machine";
|
|
33
33
|
import { PairingJoin } from "./messages/pairing-join";
|
|
34
|
+
import { ControllerPairOffer, ControllerPairRequest } from "./messages/device/controller-pairing";
|
|
35
|
+
import { KnowledgeWrite, KnowledgeWriteResult } from "./messages/mesh/knowledge/write";
|
|
34
36
|
import { MeshInvocationPage, MeshInvocationQuery } from "./messages/mesh/invocations";
|
|
35
37
|
import {
|
|
36
38
|
ProjectPolicyAck,
|
|
@@ -63,6 +65,8 @@ export * from "./messages/capabilities";
|
|
|
63
65
|
export * from "./messages/interaction";
|
|
64
66
|
export * from "./messages/machine";
|
|
65
67
|
export * from "./messages/pairing-join";
|
|
68
|
+
export * from "./messages/device/controller-pairing";
|
|
69
|
+
export * from "./messages/mesh/knowledge/write";
|
|
66
70
|
export * from "./messages/mesh";
|
|
67
71
|
export * from "./messages/mesh/invocations";
|
|
68
72
|
export * from "./messages/primitives";
|
|
@@ -147,6 +151,10 @@ export const Payload = z.union([
|
|
|
147
151
|
MachineHeartbeat,
|
|
148
152
|
MachineLoad,
|
|
149
153
|
PairingJoin,
|
|
154
|
+
ControllerPairRequest,
|
|
155
|
+
ControllerPairOffer,
|
|
156
|
+
KnowledgeWrite,
|
|
157
|
+
KnowledgeWriteResult,
|
|
150
158
|
]).superRefine((payload, ctx) => {
|
|
151
159
|
if (payload.type !== "user.message") return;
|
|
152
160
|
const encodedCharacters = payload.attachments?.reduce(
|