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.
Files changed (41) hide show
  1. package/README.md +23 -1
  2. package/apps/bridge/src/bin/cli/connect.ts +2 -1
  3. package/apps/bridge/src/bin/cli/phone.ts +54 -0
  4. package/apps/bridge/src/capabilities/README.md +5 -0
  5. package/apps/bridge/src/cortex/integration.ts +10 -7
  6. package/apps/bridge/src/engine/protocol/engine-memory-protocol.ts +3 -0
  7. package/apps/bridge/src/engine/runtime/engine-memory.ts +56 -34
  8. package/apps/bridge/src/mesh/catalog/project/observations.ts +7 -0
  9. package/apps/bridge/src/mesh/context/env.ts +32 -2
  10. package/apps/bridge/src/mesh/context/project.ts +13 -2
  11. package/apps/bridge/src/mesh/knowledge/README.md +14 -0
  12. package/apps/bridge/src/mesh/knowledge/active.ts +64 -0
  13. package/apps/bridge/src/mesh/knowledge/write.ts +45 -0
  14. package/apps/bridge/src/mesh/runtime/index.ts +13 -2
  15. package/apps/bridge/src/mesh/snapshot/scoped-view.ts +5 -1
  16. package/apps/bridge/src/mesh/store/index.ts +10 -1
  17. package/apps/bridge/src/monitor/controller-knowledge/index.ts +51 -0
  18. package/apps/bridge/src/monitor/handlers/inbound.ts +21 -7
  19. package/apps/bridge/src/monitor/support/runtime.ts +1 -1
  20. package/apps/bridge/src/pairing/controller-invites.ts +92 -0
  21. package/apps/mcp/src/connection-center/bridge.js +7 -0
  22. package/apps/mcp/src/connection-center/widget.html +1 -1
  23. package/apps/mcp/src/mcp-tools/connect/widget.ts +1 -1
  24. package/bin/granttap-mcp.mjs +6 -2
  25. package/cursor-plugin/.cursor-plugin/plugin.json +2 -2
  26. package/cursor-plugin/README.md +7 -4
  27. package/cursor-plugin/commands/connect.md +4 -3
  28. package/cursor-plugin/mcp.json +1 -1
  29. package/cursor-plugin/skills/connect/SKILL.md +8 -7
  30. package/cursor-plugin/stdio-bootstrap.cjs +1 -1
  31. package/docs/images/iphone-health-code-towers.png +0 -0
  32. package/docs/images/iphone-linked-projects.png +0 -0
  33. package/docs/images/iphone-weavatrix-graph.png +0 -0
  34. package/package.json +2 -2
  35. package/packages/core/relay-client.ts +21 -0
  36. package/packages/protocol/messages/README.md +6 -0
  37. package/packages/protocol/messages/device/controller-pairing.ts +32 -0
  38. package/packages/protocol/messages/mesh/knowledge/index.ts +1 -0
  39. package/packages/protocol/messages/mesh/knowledge/write.ts +27 -0
  40. package/packages/protocol/messages/mesh/snapshot.ts +6 -0
  41. 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.filter((item) => item.projectId === projectId)
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
- // The person's own authority, not an owner's event: written down, then
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.21</code> and <code>granttap setup</code> locally.
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/v2.html";
3
+ export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v3.html";
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
 
@@ -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.14",
5
- "description": "The agent connects this computer in chat. Authorize in plugin settings opens the device window. Scan the QR in GrantTap — that scan authorizes. Device list and Add another stay there and in the GrantTap app.",
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",
@@ -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.21
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`, pair on `granttap.com/connect` — Approve this coding
26
- app, Reconnect, or Add another. A saved pairing still opens that page. Scan
27
- in the GrantTap app only when a new device joins. Cloud **Error · fetch
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. Device list, Approve, Add another device, and Reconnect belong on
11
- granttap.com/connect, in GrantTap plugin settings, and in the GrantTap
12
- app — not in chat.
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.
@@ -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.21\";\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"
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 tell the person to scan something in this conversation, open a website,
16
- or type a setup command.
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
- Device list, Approve, Add another device, and Reconnect live on
24
- granttap.com/connect, in the GrantTap plugin settings of this host, and in
25
- the GrantTap app. Authenticate opens that page even when this computer is
26
- already paired. The same pairing room is shared across those hosts on this
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.21";
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.21",
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 95 --statements 95 --functions 95 --branches 85 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 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(