granttap-mcp 0.8.34 → 0.8.36

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 CHANGED
@@ -4,16 +4,18 @@
4
4
  [![CI](https://github.com/sergii-ziborov/granttap-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sergii-ziborov/granttap-mcp/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- GrantTap is a distributed Project control system for local coding agents.
7
+ > **See what your coding agents are doing. Step in when they need you.**
8
8
 
9
- > See what your coding agents are doing. Step in when they need you.
9
+ GrantTap connects local Claude Code and Codex work to a live control center on
10
+ Mac, iPhone, iPad, and Apple Watch. See the Task and its latest activity, answer
11
+ an approval or question with context, and continue the same goal as sessions or
12
+ computers change. Cursor and Grok Build join the view where their local
13
+ integrations support it.
10
14
 
11
- This repository is the canonical machine runtime: CLI, MCP server, provider
12
- hooks, local adapters, and TypeScript wire schemas. It connects the Mac,
13
- iPhone, iPad, and Apple Watch product surfaces to local agents, Project Mesh,
14
- Governance, auto-accept, and Engine-backed evidence. Agents and provider
15
- credentials stay on your computer. Native controller traffic is end-to-end
16
- encrypted.
15
+ This repository supplies the local runtime: CLI, MCP server, provider hooks,
16
+ adapters, and wire contracts. It powers Project Mesh, Governance, and observed
17
+ usage while agents and provider credentials stay on your computer. Controller
18
+ traffic is end-to-end encrypted.
17
19
 
18
20
  The MCP runtime, plugins, and CLI are open source under the MIT License.
19
21
  The Apple apps, Engine, relay, and website are separately licensed commercial
@@ -23,6 +25,28 @@ products or source. Their licenses do not change this repository's MIT grant.
23
25
  [Security model](SECURITY.md) ·
24
26
  [Relay source](https://github.com/sergii-ziborov/granttap-relay)
25
27
 
28
+ ## Account Mesh and devices
29
+
30
+ The passkey Account Mesh is the person's relay space. It exists with zero
31
+ computers and can contain many computers and Project Meshes. Create or join it
32
+ from **Devices** in the iPhone, iPad, or Mac app, then use the same passkey in
33
+ the MCP connection card to attach this computer. A previously linked computer
34
+ reuses its machine identity; reconnecting does not create a duplicate. The
35
+ local helper reports the Mac's user-assigned Computer Name and updates its
36
+ account display name when that name changes. Device names are display labels,
37
+ not routing keys: each Task keeps the computer route selected by its Mesh.
38
+
39
+ Install the local helper separately from the Mac App Store app:
40
+
41
+ ```bash
42
+ npm install -g granttap-mcp
43
+ granttap setup
44
+ ```
45
+
46
+ The Mac app can join the Account Mesh before the helper is present and links it
47
+ when the installed helper becomes available. A one-time QR still provides direct
48
+ device pairing without a passkey account.
49
+
26
50
  ## Mac, iPhone, iPad, and Apple Watch
27
51
 
28
52
  <p align="center">
@@ -36,11 +60,13 @@ products or source. Their licenses do not change this repository's MIT grant.
36
60
  <img src="docs/images/apple-watch-approval.png" width="180" alt="GrantTap approval on Apple Watch">
37
61
  </p>
38
62
 
63
+ <p align="center"><em>GrantTap iPhone and Apple Watch screens with deterministic sample work; no private user tasks are shown.</em></p>
64
+
39
65
  ## Supported providers
40
66
 
41
- - Primary: Claude Code and Codex.
42
- - Beta: Cursor.
43
- - Experimental where available: Grok Build.
67
+ - Claude Code and Codex: primary local control and continuation paths.
68
+ - Cursor: Task visibility and supported local controls, with narrower coverage.
69
+ - Grok Build: observable work where the installed runtime exposes it.
44
70
 
45
71
  GrantTap reports the depth each provider actually exposes. Visibility does not
46
72
  imply deterministic remote blocking or full mobile continuation.
@@ -147,7 +173,7 @@ computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
147
173
  requires local phone authorization, creates a separate target branch/worktree,
148
174
  starts the target execution, and returns a receipt bound to the exact capsule.
149
175
  Cursor uses the same provider-neutral schema and trusted caller attribution.
150
- Grok Build remains Experimental and observable where available, but does not
176
+ Grok Build remains observable where available, but does not
151
177
  yet expose a trusted caller hook, so agent-authored scoped Mesh events are not
152
178
  offered for it. Unsupported remote-start paths fail closed instead of claiming
153
179
  parity.
@@ -1,4 +1,4 @@
1
- import { hostname } from "node:os";
1
+ import { computerDisplayName } from "../../../../packages/core/computer-name";
2
2
  import { loadAccountLink, saveAccountLink } from "./link";
3
3
 
4
4
  const TOKEN = /^[A-Za-z0-9_-]{43}$/;
@@ -6,7 +6,8 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-
6
6
 
7
7
  /** Native Mac app authorizes this MCP with its account session over protected loopback. */
8
8
  export async function enrollMacAccount(accountToken: string, origin: string,
9
- request: typeof fetch = fetch): Promise<{ accountId: string; machineId: string }> {
9
+ request: typeof fetch = fetch,
10
+ computerName: () => string = computerDisplayName): Promise<{ accountId: string; machineId: string }> {
10
11
  if (!TOKEN.test(accountToken) || new URL(origin).protocol !== "https:") {
11
12
  throw new Error("Invalid account authorization.");
12
13
  }
@@ -22,19 +23,29 @@ export async function enrollMacAccount(accountToken: string, origin: string,
22
23
  if (existing.accountId !== me.accountId) {
23
24
  throw new Error("This Mac is linked to a different account. Revoke it first.");
24
25
  }
25
- const linked = await request(`${origin}/api/account/machines`, {
26
- headers, signal: AbortSignal.timeout(8_000), redirect: "error",
26
+ const linked = await request(`${origin}/api/account/machine/identity`, {
27
+ headers: { authorization: `Bearer ${existing.machineToken}` },
28
+ signal: AbortSignal.timeout(8_000), redirect: "error",
27
29
  });
28
- if (!linked.ok) throw new Error("Account computer list is unavailable.");
29
- const list = await linked.json() as { machines?: Array<{ id?: string }> };
30
- if (!Array.isArray(list.machines)) throw new Error("Invalid account computer list.");
31
- if (list.machines.some(machine => machine.id === existing.machineId)) {
30
+ if (linked.status !== 401 && !linked.ok) throw new Error("Account machine is unavailable.");
31
+ if (linked.ok) {
32
+ const identity = await linked.json() as { accountId?: string; machineId?: string };
33
+ if (identity.accountId !== me.accountId || identity.machineId !== existing.machineId) {
34
+ throw new Error("Account machine identity changed unexpectedly.");
35
+ }
36
+ const renamed = await request(`${origin}/api/account/machine/self`, {
37
+ method: "PATCH", headers: { authorization: `Bearer ${existing.machineToken}`,
38
+ "content-type": "application/json" },
39
+ body: JSON.stringify({ name: computerName() }),
40
+ signal: AbortSignal.timeout(8_000), redirect: "error",
41
+ });
42
+ if (!renamed.ok) throw new Error("Account computer name could not be updated.");
32
43
  return { accountId: existing.accountId, machineId: existing.machineId };
33
44
  }
34
45
  }
35
46
  const registration = await request(`${origin}/api/account/machines`, {
36
47
  method: "POST", headers: { ...headers, "content-type": "application/json" },
37
- body: JSON.stringify({ name: hostname().slice(0, 80) }),
48
+ body: JSON.stringify({ name: computerName() }),
38
49
  signal: AbortSignal.timeout(8_000), redirect: "error",
39
50
  });
40
51
  if (registration.status !== 201 && registration.status !== 200) {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { hostname } from "node:os";
2
+ import { computerDisplayName } from "../../../../../packages/core/computer-name";
3
3
  import { generateKeyPair, randomId } from "../../../../../packages/core/crypto";
4
4
  import type { PeerConfig } from "../../../../../packages/core/relay-client";
5
5
  import { configDir } from "../runtime/paths";
@@ -129,7 +129,7 @@ export function createPairing(relayUrl: string): {
129
129
  relayUrl,
130
130
  room,
131
131
  role: "machine",
132
- deviceName: hostname(),
132
+ deviceName: computerDisplayName(),
133
133
  senderId: randomId(4),
134
134
  myPublicKey: machine.publicKey,
135
135
  mySecretKey: machine.secretKey,
@@ -1,4 +1,4 @@
1
- import { hostname } from "node:os";
1
+ import { computerDisplayName } from "../../../../packages/core/computer-name";
2
2
  import type {
3
3
  MachineLoad,
4
4
  SessionsStatus,
@@ -78,7 +78,7 @@ export function buildMachineLoad(input: {
78
78
  ]);
79
79
  return {
80
80
  type: "machine.load",
81
- machine: input.machine ?? hostname(),
81
+ machine: input.machine ?? computerDisplayName(),
82
82
  monitorCpuPercent: input.self.cpuPercent,
83
83
  monitorMemoryBytes: input.self.memoryBytes,
84
84
  agents: [...agents]
@@ -1,4 +1,4 @@
1
- import { hostname } from "node:os";
1
+ import { computerDisplayName } from "../../../../../packages/core/computer-name";
2
2
  import type { RelayClient } from "../../../../../packages/core/relay-client";
3
3
 
4
4
  /**
@@ -20,7 +20,7 @@ export const HEARTBEAT_INTERVAL_MS = Number(
20
20
  */
21
21
  export async function publishHeartbeat(client: RelayClient): Promise<void> {
22
22
  await client.send(
23
- { type: "machine.heartbeat", machine: hostname(), createdAt: Date.now() },
23
+ { type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now() },
24
24
  "phone",
25
25
  { ttlMs: HEARTBEAT_INTERVAL_MS * 3, reliable: false },
26
26
  );
@@ -38,7 +38,7 @@ export const HELD_HEARTBEAT_TTL_MS = 60_000;
38
38
 
39
39
  export async function publishHeldHeartbeat(client: RelayClient): Promise<void> {
40
40
  await client.send(
41
- { type: "machine.heartbeat", machine: hostname(), createdAt: Date.now() },
41
+ { type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now() },
42
42
  "phone",
43
43
  { ttlMs: HELD_HEARTBEAT_TTL_MS, reliable: true },
44
44
  );
@@ -1,5 +1,5 @@
1
1
  import { refreshCodexHooks } from "../../codex-hook-trust";
2
- import { hostname } from "node:os";
2
+ import { computerDisplayName } from "../../../../../packages/core/computer-name";
3
3
  import { RelayClient } from "../../../../../packages/core/relay-client";
4
4
  import type { Payload, SessionInfo, SessionsStatus } from "../../../../../packages/protocol/schema";
5
5
  import { loadRuntimeConfig } from "../../config";
@@ -18,6 +18,7 @@ import { deriveObservedClaims } from "../../mesh/observed/claims";
18
18
  import { ingestRuntimeInvocations } from "../../engine/invocation/ingest";
19
19
  import { HEARTBEAT_INTERVAL_MS, publishHeartbeat } from "./heartbeat";
20
20
  import { confirmPendingController } from "../../pairing/controllers";
21
+ import { recordControllerName } from "../../pairing/presence";
21
22
  import { startPublishLoop } from "./publish-loop";
22
23
  import { singleFlightPublisher } from "./single-flight";
23
24
  import { createMachineLoadPublisher } from "../../machine-load";
@@ -81,7 +82,7 @@ function monitorSnapshot(input: {
81
82
  const runtime = loadRuntimeConfig();
82
83
  return {
83
84
  type: "sessions.status",
84
- machine: hostname(),
85
+ machine: computerDisplayName(),
85
86
  sessions: decorateSessions(sessions, subscriptions),
86
87
  history: includeHistory
87
88
  ? decorateSessions(boundedCatalogHistory(history(forceHistory), subscriptions), subscriptions)
@@ -218,6 +219,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
218
219
  const off = client.onMessage(async (payload: Payload, peerPublicKey) => {
219
220
  if (payload.type === "hello" && payload.role === "phone") {
220
221
  confirmPendingController(client.room, peerPublicKey);
222
+ recordControllerName(peerPublicKey, payload.deviceName);
221
223
  }
222
224
  return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
223
225
  });
@@ -1,10 +1,12 @@
1
1
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { configDir } from "../config/runtime/paths";
4
+ import { writePrivateFile } from "../config/access/write-private";
4
5
 
5
6
  type PresenceState = {
6
7
  phoneLastSeenAt?: number;
7
8
  controllers?: Record<string, number>;
9
+ names?: Record<string, string>;
8
10
  };
9
11
 
10
12
  function presencePath(): string {
@@ -31,6 +33,25 @@ export function readControllerLastSeenAt(peerPublicKey: string): number | null {
31
33
  return typeof at === "number" && Number.isFinite(at) ? at : null;
32
34
  }
33
35
 
36
+ export function readControllerName(peerPublicKey: string): string | null {
37
+ const name = readPresence().names?.[peerPublicKey];
38
+ return typeof name === "string" && name.length > 0 && name.length <= 80
39
+ && !/^(phone|machine|iphone|ipad)$/i.test(name) ? name : null;
40
+ }
41
+
42
+ /** A controller chooses its own visible label; never infer it from a pairing role. */
43
+ export function recordControllerName(peerPublicKey: string, value: string): boolean {
44
+ const name = value.trim();
45
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(peerPublicKey)
46
+ || !name || name.length > 80 || /[\x00-\x1f\x7f]/.test(name)
47
+ || /^(phone|machine|iphone|ipad)$/i.test(name)) return false;
48
+ const previous = readPresence();
49
+ const names = { ...previous.names, [peerPublicKey]: name };
50
+ const bounded = Object.fromEntries(Object.entries(names).slice(-32));
51
+ writePrivateFile(presencePath(), JSON.stringify({ ...previous, names: bounded }));
52
+ return true;
53
+ }
54
+
34
55
  export function hasControllerPresence(): boolean {
35
56
  return Object.keys(readPresence().controllers ?? {}).length > 0;
36
57
  }
@@ -93,7 +93,6 @@ export function runProcess(
93
93
  return;
94
94
  }
95
95
  const untrack = key ? track(key, child) : () => {};
96
- if (stdin != null) child.stdin?.end(stdin);
97
96
  let stdout = "";
98
97
  let stderr = "";
99
98
  let done = false;
@@ -134,5 +133,13 @@ export function runProcess(
134
133
  }` });
135
134
  }
136
135
  });
136
+ if (stdin != null) {
137
+ child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
138
+ // A CLI can finish with a valid result before it reads the whole prompt.
139
+ // Its closed pipe must not become an uncaught exception in the bridge.
140
+ if (error.code !== "EPIPE") finish({ ok: false, error: `${command}: ${error.message}` });
141
+ });
142
+ child.stdin?.end(stdin);
143
+ }
137
144
  });
138
145
  }
@@ -23,11 +23,12 @@ checks parent messages, refreshes pending pairing while visible, and keeps
23
23
  copyable transfer material out of model-visible text and persistent UI storage.
24
24
  Non-UI clients can still use the tools and display the returned QR image.
25
25
 
26
- The connection card itself does not log in to an account. The Codex-owned
27
- plugin management page is not this UI. The optional Mac passkey choice lives
28
- in the browser authorization page for the loopback HTTP MCP server. It grants
29
- the coding app an MCP token, without creating a phone pairing or changing the
30
- local provider runtime.
26
+ Connect with passkey on the card creates a short-lived, authenticated website
27
+ request. A fresh passkey assertion on granttap.com links this Mac to the user's
28
+ account; the site returns a machine token only to the local MCP watcher, which
29
+ saves it privately. The QR pairing room and provider runtime remain unchanged.
30
+ The separate coding-app OAuth page can also use passkey to authorize that MCP
31
+ client. The Codex-owned plugin management page is not this card.
31
32
 
32
33
  Tests in `tests/` exercise isolated keys, a loopback encrypted phone connection,
33
34
  expiry, non-mutating status, and actual DOM button/host-message behavior.
@@ -72,6 +72,7 @@ async function call(name, args = {}) {
72
72
  : await request("tools/call", { name, arguments: args });
73
73
  if (result?.isError) message("Could not complete the request. Refresh status and check the relay before retrying.");
74
74
  else { render(result); message("Status updated."); }
75
+ return result;
75
76
  } catch {
76
77
  message("No successful response. Refresh status before retrying; pairing may already have changed.");
77
78
  } finally {
@@ -0,0 +1,27 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { buildConnectSnapshot } from "../oauth/session/connect-snapshot";
3
+ import { publishConnectRequestRetry, watchConnectDecision, websiteOrigin } from "../oauth/consent/website-session";
4
+
5
+ const REQUEST_LIFETIME_MS = 15 * 60_000;
6
+ let pending: { url: string; expiresAt: number } | null = null;
7
+
8
+ /** Start a browser passkey ceremony for this Mac. No account credential enters MCP output. */
9
+ export async function startPasskeyAccountLink(): Promise<string> {
10
+ if (pending && pending.expiresAt > Date.now()) return pending.url;
11
+ const origin = websiteOrigin();
12
+ if (!origin) throw new Error("GrantTap account service is unavailable.");
13
+ const id = randomUUID();
14
+ const secret = randomBytes(32).toString("base64url");
15
+ const snapshot = () => ({ ...buildConnectSnapshot("GrantTap MCP"), purpose: "account-link" as const });
16
+ if (!await publishConnectRequestRetry(origin, id, snapshot(), 3, secret)) {
17
+ throw new Error("GrantTap account service did not accept the connection request.");
18
+ }
19
+ watchConnectDecision(origin, id, snapshot, (approved, method) => {
20
+ if (!approved || method !== "passkey") throw new Error("Use the account passkey to link this Mac.");
21
+ pending = null;
22
+ return { redirectUrl: `${origin}/account` };
23
+ }, { requestSecret: secret });
24
+ const url = `${origin}/connect#request=${id}`;
25
+ pending = { url, expiresAt: Date.now() + REQUEST_LIFETIME_MS };
26
+ return url;
27
+ }
@@ -1,11 +1,12 @@
1
1
  import { z } from "zod";
2
- import { hostname } from "node:os";
2
+ import { computerDisplayName } from "../../../../packages/core/computer-name";
3
3
  import { loadConfig } from "../../../bridge/src/config";
4
4
  import { isMachineConfigured, listPairedPhones, readOnlyMachineConfigPath } from "../status/pairing-status";
5
5
  import { inspectProviderStatusSnapshot } from "../status/provider-status";
6
6
  import { packageVersion } from "../status/package-version";
7
7
  import { connectionRuntimeStatus } from "../mcp-tools/connect/relay";
8
8
  import { beginEnrollment, cancelEnrollment, enrollmentIsOpen } from "./enrollment";
9
+ import { loadAccountLink } from "../../../bridge/src/account-recovery/link";
9
10
 
10
11
  export const connectionOutput = {
11
12
  status: z.enum(["disconnected", "paired", "pairing", "expired", "connected"]),
@@ -23,6 +24,7 @@ export const connectionOutput = {
23
24
  expiresInMinutes: z.number().int().positive().nullable(),
24
25
  providers: z.array(z.object({ id: z.string(), status: z.string(), detail: z.string() })),
25
26
  roomPrefix: z.string(),
27
+ accountLinkSaved: z.boolean(),
26
28
  };
27
29
 
28
30
  export type PendingCode = {
@@ -66,7 +68,7 @@ export class ConnectionState {
66
68
  return {
67
69
  structuredContent: {
68
70
  status,
69
- computer: hostname(),
71
+ computer: computerDisplayName(),
70
72
  version: packageVersion(),
71
73
  relay: config ? new URL(config.relayUrl).host : "",
72
74
  relayStatus: runtime.relayStatus,
@@ -76,6 +78,7 @@ export class ConnectionState {
76
78
  expiresInMinutes: code ? Math.max(1, Math.ceil((code.expiresAt - now) / 60_000)) : null,
77
79
  providers: inspectProviderStatusSnapshot().providers.map(({ id, status, detail }) => ({ id, status, detail })),
78
80
  roomPrefix: config?.room ? config.room.slice(0, 8) : "",
81
+ accountLinkSaved: loadAccountLink() !== null,
79
82
  },
80
83
  _meta: { granttap: code ? { pairingUri: code.pairingUri, qrDataUrl: code.qrDataUrl } : {} },
81
84
  };
@@ -21,6 +21,16 @@ function render(value) {
21
21
  current = state;
22
22
  clearCode();
23
23
  const meta = result._meta?.granttap ?? {};
24
+ const passkeyUrl = typeof meta.passkeyUrl === "string" && /^https:\/\/granttap\.com\/connect#request=[0-9a-f-]{36}$/i.test(meta.passkeyUrl)
25
+ ? meta.passkeyUrl : "";
26
+ $("passkey-link").classList.toggle("hidden", !passkeyUrl || state.accountLinkSaved === true);
27
+ if (passkeyUrl) $("passkey-link").setAttribute("href", passkeyUrl);
28
+ else $("passkey-link").removeAttribute("href");
29
+ $("account-status").textContent = state.accountLinkSaved === true
30
+ ? "Saved account link on this Mac. Current server access is checked when it is used."
31
+ : "No account linked on this Mac. A passkey can link it without changing the QR pairing.";
32
+ $("passkey").textContent = state.accountLinkSaved === true
33
+ ? "Reauthorize with passkey" : "Connect with passkey";
24
34
  const labels = {
25
35
  disconnected: ["Not paired", "Add a device", "Tap Add a device. Scan the QR in GrantTap on iPhone, iPad, or Android."],
26
36
  pairing: ["Waiting for a device", "Scan this QR", "Keep this card open. The QR is only here — not in chat."],
@@ -91,6 +101,7 @@ $("connect").addEventListener("click", () => {
91
101
  call("connect");
92
102
  });
93
103
  $("refresh").addEventListener("click", () => call("connection_status"));
104
+ $("passkey").addEventListener("click", () => call("connect_with_passkey"));
94
105
  $("reconnect").addEventListener("click", () => {
95
106
  pendingMode = "reconnect";
96
107
  $("confirm-copy").textContent = "Reconnect shows a new QR for the existing controller device in this room.";
@@ -27,7 +27,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
27
27
  </svg>
28
28
  </div><div><div class="title">GrantTap</div><div class="status"><span class="dot"></span><span id="status">Checking connection…</span></div></div></header>
29
29
  <section class="body">
30
- <div class="copy"><h2 id="heading">Add a device</h2><p id="detail">Scan the QR in GrantTap on iPhone, iPad, or Android. It appears here as soon as pairing starts.</p><ul class="phones" id="phones"></ul><p class="empty" id="phones-empty">No saved pairing on this Mac yet.</p><div class="actions"><button class="primary" id="connect">Add a device</button><button id="reconnect">Reconnect</button><button id="new-qr" class="hidden">Create a new QR</button><button id="refresh">Refresh status</button><button id="copy" class="hidden">Copy pairing link</button></div><div class="message small" id="message" role="status"></div></div>
30
+ <div class="copy"><h2 id="heading">Add a device</h2><p id="detail">Scan the QR in GrantTap on iPhone, iPad, or Android. It appears here as soon as pairing starts.</p><ul class="phones" id="phones"></ul><p class="empty" id="phones-empty">No saved pairing on this Mac yet.</p><div class="actions"><button class="primary" id="connect">Add a device</button><button id="reconnect">Reconnect</button><button id="new-qr" class="hidden">Create a new QR</button><button id="refresh">Refresh status</button><button id="copy" class="hidden">Copy pairing link</button></div><p id="account-status" class="small">Checking account link…</p><div class="actions"><button id="passkey">Connect with passkey</button><a id="passkey-link" class="hidden" target="_blank" rel="noopener noreferrer">Continue on granttap.com</a></div><div class="message small" id="message" role="status"></div></div>
31
31
  <img id="qr" class="hidden" alt="GrantTap pairing QR" />
32
32
  </section>
33
33
  <p id="expiry" class="small" aria-live="polite"></p>
@@ -36,9 +36,8 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
36
36
  <dt>Relay observed by this MCP</dt><dd id="relay">Not checked</dd>
37
37
  <dt>Last encrypted message from iPhone</dt><dd id="phone">Not observed</dd></dl>
38
38
  <h3>Provider readiness</h3><ul id="providers"></ul>
39
- <p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
40
- Provider sign-in is separate.</p>
41
- <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.23</code> and <code>granttap setup</code> locally.
39
+ <p>QR pairs a phone directly. A passkey links this Mac to your GrantTap account so your signed-in phone can discover its computers. Provider sign-in is separate.</p>
40
+ <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.36</code> and <code>granttap setup</code> locally.
42
41
  A saved pairing alone does not prove your phone or the background helper is online.</p>
43
42
  <p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
44
43
  <a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
@@ -1,4 +1,4 @@
1
- import { hostname } from "node:os";
1
+ import { computerDisplayName } from "../../../../../packages/core/computer-name";
2
2
  import {
3
3
  sampleAgentProcesses, type AgentProcessLoad,
4
4
  } from "../../../../bridge/src/machine-load/host/process-sampler";
@@ -12,7 +12,7 @@ export async function desktopMachineLoad(
12
12
  return {
13
13
  operation: "desktop.machine_load" as const,
14
14
  source: "process_sample" as const,
15
- computer: hostname(),
15
+ computer: computerDisplayName(),
16
16
  observed_at: now(),
17
17
  agents: Object.entries(processes)
18
18
  .filter(([agent]) => agent.length > 0)
@@ -1,4 +1,4 @@
1
- import { hostname } from "node:os";
1
+ import { computerDisplayName } from "../../../../../packages/core/computer-name";
2
2
  import { loadConfig } from "../../../../bridge/src/config";
3
3
  import { reloadPairingHelper } from "../../../../bridge/src/install";
4
4
  import { createOneTimePairing, DEFAULT_RELAY, PAIRING_CODE_TTL_MINUTES } from "../../../../bridge/src/pairing";
@@ -37,7 +37,7 @@ export class DesktopControllerEnrollment {
37
37
 
38
38
  snapshot(now = Date.now()): DesktopControllerCode {
39
39
  const result: DesktopControllerCode = {
40
- operation: "desktop.controller_enrollment", status: "idle", computer: hostname(),
40
+ operation: "desktop.controller_enrollment", status: "idle", computer: computerDisplayName(),
41
41
  };
42
42
  if (!this.pending) return result;
43
43
  if (!isMachineConfigured()) { this.pending = null; return result; }
@@ -77,7 +77,8 @@ export class DesktopControllerEnrollment {
77
77
  this.onPairingChanged();
78
78
  return this.snapshot();
79
79
  } catch {
80
- return { operation: "desktop.controller_enrollment", status: "unavailable", computer: hostname() };
80
+ return { operation: "desktop.controller_enrollment", status: "unavailable",
81
+ computer: computerDisplayName() };
81
82
  }
82
83
  }
83
84
  }
@@ -7,6 +7,7 @@ import { isMachineConfigured } from "../../status/pairing-status";
7
7
  import { ConnectionState, connectionOutput } from "../../connection-center/state";
8
8
  import { CONNECTION_WIDGET_URI } from "./widget";
9
9
  import { resetRelay, relay } from "./relay";
10
+ import { startPasskeyAccountLink } from "../../connection-center/passkey";
10
11
 
11
12
  const widgetMeta = {
12
13
  ui: { resourceUri: CONNECTION_WIDGET_URI, visibility: ["model", "app"] },
@@ -39,6 +40,24 @@ export function registerConnectTool(server: McpServer): void {
39
40
  annotations: { ...changes, readOnlyHint: true, idempotentHint: true },
40
41
  _meta: widgetMeta,
41
42
  }, async () => connectionResult(state));
43
+ server.registerTool("connect_with_passkey", {
44
+ title: "Link this Mac to a GrantTap account",
45
+ description: "Open a short-lived GrantTap account passkey request for this Mac. The passkey is entered only on granttap.com; existing QR pairings remain unchanged.",
46
+ inputSchema: {},
47
+ outputSchema: connectionOutput,
48
+ annotations: changes,
49
+ _meta: widgetMeta,
50
+ }, async () => {
51
+ try {
52
+ const passkeyUrl = await startPasskeyAccountLink();
53
+ const result = connectionResult(state);
54
+ const meta = result._meta?.granttap;
55
+ const granttap = meta && typeof meta === "object" ? meta : {};
56
+ return { ...result, _meta: { granttap: { ...granttap, passkeyUrl } } };
57
+ } catch {
58
+ return { isError: true, content: [{ type: "text", text: "GrantTap could not start passkey linking. Check the account service and try again." }] };
59
+ }
60
+ });
42
61
  server.registerTool(
43
62
  "connect",
44
63
  {
@@ -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/v3.html";
3
+ export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v4.html";
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
 
@@ -13,7 +13,7 @@ export function registerConnectionWidget(server: McpServer): void {
13
13
  CONNECTION_WIDGET_URI,
14
14
  {
15
15
  title: "GrantTap connection center",
16
- description: "GrantTap card: devices in this pairing room, Add a device, and the QR on this card only.",
16
+ description: "GrantTap card: QR device pairing, Mac account passkey linking, and connection status.",
17
17
  mimeType: "text/html;profile=mcp-app",
18
18
  },
19
19
  async (uri) => ({
@@ -23,7 +23,7 @@ export function registerConnectionWidget(server: McpServer): void {
23
23
  text: html,
24
24
  _meta: {
25
25
  ui: { prefersBorder: true, permissions: { clipboardWrite: {} }, csp: { connectDomains: [], resourceDomains: [] } },
26
- "openai/widgetDescription": "List devices in this pairing room and add one with a QR that stays on this card.",
26
+ "openai/widgetDescription": "Pair a device by QR or link this Mac to your account with passkey.",
27
27
  "openai/widgetPrefersBorder": true,
28
28
  },
29
29
  }],
@@ -2,7 +2,7 @@ import { codexHooksReady } from "../../../../bridge/src/codex-hook-trust";
2
2
  /**
3
3
  * Public consent snapshot for granttap.com. No pairing keys, tokens, or codes.
4
4
  */
5
- import { hostname } from "node:os";
5
+ import { computerDisplayName } from "../../../../../packages/core/computer-name";
6
6
  import { inspectAgentIntegrations } from "../../../../bridge/src/install";
7
7
  import { loadConfig } from "../../../../bridge/src/config";
8
8
  import { computerId } from "../../../../bridge/src/mesh/identity/computer";
@@ -49,7 +49,7 @@ export function buildConnectSnapshot(clientName?: string): ConnectSnapshot {
49
49
  const runtime = connectionRuntimeStatus(room || undefined);
50
50
  return {
51
51
  clientName: publicClientName(clientName),
52
- computerName: hostname(),
52
+ computerName: computerDisplayName(),
53
53
  paired,
54
54
  passkeyCapable: true,
55
55
  machineId: loadAccountLink()?.machineId,
@@ -70,7 +70,7 @@ export function buildConnectSnapshot(clientName?: string): ConnectSnapshot {
70
70
 
71
71
  /** Computers and open tasks only. No transcripts, keys, or room ids. */
72
72
  export function publicMeshSummary(): ConnectMesh {
73
- const thisComputer = hostname().trim().slice(0, 80) || "This Mac";
73
+ const thisComputer = computerDisplayName();
74
74
  try {
75
75
  const store = localMeshStore();
76
76
  const names = new Set<string>([thisComputer, computerId().slice(0, 80)]);
@@ -1,8 +1,9 @@
1
- import { hostname } from "node:os";
1
+ import { computerDisplayName } from "../../../../packages/core/computer-name";
2
2
  import { loadConfig } from "../../../bridge/src/config";
3
3
  import { connectionRuntimeStatus } from "../mcp-tools/connect/relay";
4
4
  import { packageVersion } from "./package-version";
5
5
  import { computerId } from "../../../bridge/src/mesh/identity/computer";
6
+ import { loadAccountLink } from "../../../bridge/src/account-recovery/link";
6
7
  import {
7
8
  isMachineConfigured, listPairedPhones, phoneReachability, readOnlyMachineConfigPath,
8
9
  } from "./pairing-status";
@@ -18,8 +19,9 @@ export function desktopStatusSnapshot(desktopEngineSocket?: string) {
18
19
  schema: "granttap.desktop-status.v1" as const,
19
20
  ok: true,
20
21
  service: "granttap-mcp" as const,
21
- computer: hostname(),
22
+ computer: computerDisplayName(),
22
23
  endpointId: computerId(),
24
+ accountLinkSaved: loadAccountLink() !== null,
23
25
  version: packageVersion(),
24
26
  paired: configured,
25
27
  phoneReachability: phoneReachability(phones),
@@ -3,7 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { loadConfig } from "../../../bridge/src/config";
6
- import { hasControllerPresence, readControllerLastSeenAt, readPhoneLastSeenAt } from "../../../bridge/src/pairing/presence";
6
+ import { hasControllerPresence, readControllerLastSeenAt, readControllerName,
7
+ readPhoneLastSeenAt } from "../../../bridge/src/pairing/presence";
7
8
  import { controllerPeerGate } from "../../../bridge/src/pairing/controllers";
8
9
 
9
10
  export type PairedPhone = {
@@ -59,14 +60,15 @@ export function listPairedPhones(phoneLastSeenAt: number | null = null, now = Da
59
60
  value != null && (best == null || value > best) ? value : best
60
61
  ), null);
61
62
  const seen = lastSeenAt != null && now - lastSeenAt < 60_000;
62
- const primary: PairedPhone = { name, status: seen ? "seen" : "paired", lastSeenAt };
63
+ const primary: PairedPhone = { name: readControllerName(machine.peerPublicKey) ?? name,
64
+ status: seen ? "seen" : "paired", lastSeenAt };
63
65
  const allowed = controllerPeerGate(machine);
64
66
  const additional = (machine.extraPeerPublicKeys ?? [])
65
67
  .filter((key) => key && key !== machine.peerPublicKey && allowed(key))
66
68
  .map((key, index): PairedPhone => {
67
69
  const at = readControllerLastSeenAt(key);
68
70
  const suffix = createHash("sha256").update(key).digest("hex").slice(0, 6);
69
- return { name: `Controller ${index + 2} · ${suffix}`,
71
+ return { name: readControllerName(key) ?? `Controller ${index + 2} · ${suffix}`,
70
72
  status: at != null && now - at < 60_000 ? "seen" : "paired", lastSeenAt: at };
71
73
  });
72
74
  return [primary, ...additional];
@@ -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.34
13
+ npm install -g granttap-mcp@0.8.36
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -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.34\";\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.36\";\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
  }
@@ -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.34";
10
+ const PACKAGE = "granttap-mcp@0.8.36";
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.34",
3
+ "version": "0.8.36",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+
4
+ type NameSources = {
5
+ platform?: NodeJS.Platform;
6
+ host?: string;
7
+ override?: string;
8
+ macName?: () => string;
9
+ };
10
+
11
+ function usableName(value: string | undefined): string | null {
12
+ const name = value?.trim();
13
+ return name && name.length <= 80 && !/[\x00-\x1f\x7f]/.test(name) ? name : null;
14
+ }
15
+
16
+ /** User-visible computer name; network hostname remains only a fallback. */
17
+ export function computerDisplayName(sources: NameSources = {}): string {
18
+ const override = usableName(sources.override ?? process.env.GRANTTAP_COMPUTER_NAME);
19
+ if (override) return override;
20
+ if ((sources.platform ?? process.platform) === "darwin") {
21
+ try {
22
+ const macName = sources.macName ?? (() => execFileSync(
23
+ "/usr/sbin/scutil", ["--get", "ComputerName"],
24
+ { encoding: "utf8", timeout: 2_000, maxBuffer: 4_096 },
25
+ ));
26
+ const name = usableName(macName());
27
+ if (name) return name;
28
+ } catch { /* Computer Name may be unset; preserve the network fallback. */ }
29
+ }
30
+ return usableName(sources.host ?? hostname()) ?? "Computer";
31
+ }