granttap-mcp 0.8.35 → 0.8.37

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
@@ -25,6 +25,28 @@ products or source. Their licenses do not change this repository's MIT grant.
25
25
  [Security model](SECURITY.md) ·
26
26
  [Relay source](https://github.com/sergii-ziborov/granttap-relay)
27
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
+
28
50
  ## Mac, iPhone, iPad, and Apple Watch
29
51
 
30
52
  <p align="center">
@@ -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
  }
@@ -1,5 +1,5 @@
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";
@@ -68,7 +68,7 @@ export class ConnectionState {
68
68
  return {
69
69
  structuredContent: {
70
70
  status,
71
- computer: hostname(),
71
+ computer: computerDisplayName(),
72
72
  version: packageVersion(),
73
73
  relay: config ? new URL(config.relayUrl).host : "",
74
74
  relayStatus: runtime.relayStatus,
@@ -37,7 +37,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
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
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.35</code> and <code>granttap setup</code> locally.
40
+ <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.37</code> and <code>granttap setup</code> locally.
41
41
  A saved pairing alone does not prove your phone or the background helper is online.</p>
42
42
  <p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
43
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)
@@ -2,8 +2,10 @@
2
2
 
3
3
  `routes.ts` exposes the existing private desktop bridge on loopback for the
4
4
  sandboxed Mac App Store client. This is a desktop-only authorization grant,
5
- separate from provider MCP OAuth. A local human consent page, same-origin POST,
6
- one-time code and S256 PKCE are required. The callback is fixed to
5
+ separate from provider MCP OAuth. Without a passkey account, a local human
6
+ consent page, a matching one-time consent cookie, one-time code and S256 PKCE
7
+ are required. An explicit foreign Origin is rejected; native browser sessions
8
+ may send an opaque Origin. The callback is fixed to
7
9
  `granttap://desktop-auth`; there is no user-selected redirect. Pending consent
8
10
  and codes expire after five minutes. A private file stores only token hashes,
9
11
  up to eight active grants, expiring after 30 days. The app keeps its token in
@@ -16,6 +18,11 @@ provider broader MCP scope. The installer/runtime remains outside the sandbox.
16
18
  `/desktop/account/link` uses the same native grant, verifies the app's fresh
17
19
  passkey account session with granttap.com, and stores a revocable machine
18
20
  credential locally. It returns only the account and machine identifiers.
21
+ `/desktop/account/authorize` exchanges the Mac app's passkey account session
22
+ for the same scoped local grant. The relay verifies the account and existing
23
+ machine identity, or enrolls this Mac if it has not been linked. The account
24
+ session remains in the app Keychain; cross-origin browser requests cannot read
25
+ it or submit JSON to the loopback service.
19
26
  Tests exercise real HTTP and Unix sockets, consent forgery rejection, PKCE,
20
27
  one-use codes, expiration, restart and authenticated forwarding.
21
28
 
@@ -44,6 +44,15 @@ export class DesktopNativeAccess {
44
44
  this.codes.delete(code);
45
45
  if (!row || row.expires <= this.now() || !/^[A-Za-z0-9_-]{43,128}$/.test(verifier)
46
46
  || digest(verifier) !== row.challenge) throw new Error('Invalid native code');
47
+ return this.issueGrant();
48
+ }
49
+
50
+ /** Called only after the local route verifies a passkey account with the relay. */
51
+ issueVerifiedAccountGrant(): string {
52
+ return this.issueGrant();
53
+ }
54
+
55
+ private issueGrant(): string {
47
56
  const token = random();
48
57
  const grants = this.grants().filter(grant => grant.expires > this.now()).slice(-7);
49
58
  grants.push({ digest: digest(token), expires: this.now() + 30 * 24 * 3_600_000 });
@@ -7,7 +7,7 @@ import { enrollMacAccount } from '../../../../bridge/src/account-recovery/enroll
7
7
  import { websiteOrigin } from '../../oauth/consent/website-session';
8
8
 
9
9
  export function installDesktopNativeRoutes(app: Express, socketPath: string, origin: string,
10
- access = new DesktopNativeAccess()): void {
10
+ access = new DesktopNativeAccess(), enroll = enrollMacAccount): void {
11
11
  app.use('/desktop', (_req, res, next) => {
12
12
  res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer',
13
13
  'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; form-action 'self'; frame-ancestors 'none'" });
@@ -26,7 +26,9 @@ export function installDesktopNativeRoutes(app: Express, socketPath: string, ori
26
26
  });
27
27
  app.post('/desktop/approve', (req, res) => {
28
28
  const confirmation = String(req.body?.confirmation ?? '');
29
- if (req.headers.origin !== origin || !confirmation ||
29
+ const requestOrigin = req.headers.origin;
30
+ if ((requestOrigin !== undefined && requestOrigin !== 'null' && requestOrigin !== origin)
31
+ || !confirmation ||
30
32
  !(req.headers.cookie ?? '').split(';').some(cookie => cookie.trim() === `granttap_desktop_consent=${confirmation}`)) {
31
33
  res.status(403).json({ error: 'Native approval requires this local consent page' }); return;
32
34
  }
@@ -53,6 +55,14 @@ export function installDesktopNativeRoutes(app: Express, socketPath: string, ori
53
55
  res.status(403).json({ error: 'Account link could not be verified' });
54
56
  }
55
57
  });
58
+ app.post('/desktop/account/authorize', express.json({ limit: '2kb' }), async (req, res) => {
59
+ try {
60
+ await enroll(String(req.body?.accountToken ?? ''), websiteOrigin() ?? 'https://granttap.com');
61
+ res.json({ access_token: access.issueVerifiedAccountGrant() });
62
+ } catch {
63
+ res.status(403).json({ error: 'Passkey account could not authorize this Mac' });
64
+ }
65
+ });
56
66
  app.post('/desktop/invoke', (req, res, next) => {
57
67
  const auth = String(req.headers.authorization ?? '');
58
68
  if (!auth.startsWith('Bearer ') || !access.verify(auth.slice(7))) {
@@ -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
  }
@@ -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.35
13
+ npm install -g granttap-mcp@0.8.37
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.35\";\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.37\";\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.35";
10
+ const PACKAGE = "granttap-mcp@0.8.37";
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.35",
3
+ "version": "0.8.37",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  "prepublishOnly": "npm run inventory && npm run structure -- --strict && npm run package:allowlist && npm run test:coverage && npm run typecheck"
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.30.0",
36
+ "@modelcontextprotocol/sdk": "^1.32.1",
37
37
  "express": "^5.2.1",
38
38
  "qrcode": "^1.5.4",
39
39
  "tsx": "^4.23.4",
@@ -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
+ }