granttap-mcp 0.8.31 → 0.8.33

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
@@ -509,12 +509,14 @@ is left to a trusted terminal, with the command spelled out in the answer. The
509
509
  result — version before and after, the updater's own output — comes back as
510
510
  `tool.update.result`.
511
511
 
512
- Cost is reported as attributed rather than measured, because that is what it
513
- is. A call is read back from the transcript once it has finished, so it can
514
- never be measured directly: an MCP server outlives its calls and is sampled
515
- directly, while a built-in tool leaves nothing behind and is costed from the
516
- samples that fall inside its own start and end. A call with no sample near it
517
- reports nothing rather than a number borrowed from another moment.
512
+ GrantTap derives usage from native provider transcripts and local OS samples;
513
+ it does not ask an agent to write a resource report. An MCP server can be
514
+ sampled directly while it runs. For a completed CLI call, a unique observed
515
+ process tree yields measured CPU time and peak RSS. If only agent-wide samples
516
+ cover a built-in tool call, GrantTap labels its divided share as attributed.
517
+ Ambiguous, unsampled, and historical calls remain unknown rather than borrowing
518
+ a number from another moment. Estimated context tokens are labeled separately
519
+ from provider-reported model tokens.
518
520
 
519
521
  ## Local enforcement
520
522
 
@@ -2,8 +2,12 @@
2
2
 
3
3
  `link.ts` persists the local Mac's opt-in account machine credential in its
4
4
  private GrantTap config directory. The Mac app can enroll through its existing
5
- authorized loopback connection, or the provider MCP consent flow can enroll
6
- after a verified passkey assertion. The credential lets `poller.ts` answer
5
+ authorized loopback connection; the provider MCP consent flow can enroll after
6
+ a verified passkey assertion. `phone-link.ts` lets a QR-paired phone grant a
7
+ machine-scoped credential through the existing encrypted device room after
8
+ passkey sign-in. The Mac verifies it against the account service before saving
9
+ and never receives the phone's account session token. The credential lets
10
+ `poller.ts` answer
7
11
  short-lived, account-authorized phone requests. `offer.ts` creates a new phone
8
12
  controller key in the current room and seals its pairing half to the phone's
9
13
  ephemeral public key. The website stores only the machine token hash and sealed
@@ -0,0 +1,28 @@
1
+ import type { AccountMachineLink } from "../../../../packages/protocol/schema";
2
+ import { loadAccountLink, saveAccountLink } from "./link";
3
+
4
+ /** A trusted QR room can grant only a machine credential, never an account session. */
5
+ export async function acceptPhoneAccountLink(
6
+ link: AccountMachineLink, origin: string, request: typeof fetch = fetch,
7
+ ): Promise<boolean> {
8
+ if (new URL(origin).protocol !== "https:") return false;
9
+ const current = loadAccountLink();
10
+ if (current && current.accountId !== link.accountId) return false;
11
+ const response = await request(`${origin}/api/account/machine/identity`, {
12
+ headers: { authorization: `Bearer ${link.machineToken}`, accept: "application/json" },
13
+ signal: AbortSignal.timeout(8_000), redirect: "error",
14
+ });
15
+ if (!response.ok) return false;
16
+ const identity = await response.json() as { accountId?: string; machineId?: string };
17
+ if (identity.accountId !== link.accountId || identity.machineId !== link.machineId) return false;
18
+ if (current && current.machineId !== link.machineId) {
19
+ const revoked = await request(`${origin}/api/account/machine/self`, {
20
+ method: "DELETE",
21
+ headers: { authorization: `Bearer ${current.machineToken}`, accept: "application/json" },
22
+ signal: AbortSignal.timeout(8_000), redirect: "error",
23
+ });
24
+ if (!revoked.ok && revoked.status !== 401) return false;
25
+ }
26
+ return saveAccountLink({ accountId: link.accountId,
27
+ machineId: link.machineId, machineToken: link.machineToken });
28
+ }
@@ -36,6 +36,7 @@ import { publishSessionEvents } from "./catalog";
36
36
  import { publishHistoryPage } from "../history/pages";
37
37
  import { requestProjectGraphRefresh } from "../graph-refresh";
38
38
  import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
39
+ import { acceptPhoneAccountLink } from "../../account-recovery/phone-link";
39
40
 
40
41
  export async function handleMonitorMessage(
41
42
  client: RelayClient,
@@ -55,8 +56,8 @@ export async function handleMonitorMessage(
55
56
  // Codex may start one MCP server per open task. Exactly one instance owns
56
57
  // phone routing, so a single phone message can never create duplicate tasks.
57
58
  if (!leadership.acquire()) return false;
58
- if (payload.type === "controller.pair.request") {
59
- return handleControllerPairRequest(client, payload, input.peerPublicKey);
59
+ if (payload.type === "controller.pair.request" || payload.type === "account.machine.link") {
60
+ return handleDeviceEnrollment(client, payload, input.peerPublicKey);
60
61
  }
61
62
  if (payload.type === "provider.hook.trust") {
62
63
  return handleHookTrust(client, payload, input.peerPublicKey, publish);
@@ -138,6 +139,19 @@ export async function handleMonitorMessage(
138
139
  return false;
139
140
  }
140
141
 
142
+ function handleDeviceEnrollment(
143
+ client: RelayClient,
144
+ payload: Extract<Payload, { type: "account.machine.link" | "controller.pair.request" }>,
145
+ peerPublicKey?: string,
146
+ ): Promise<boolean> {
147
+ if (payload.type === "controller.pair.request") {
148
+ return handleControllerPairRequest(client, payload, peerPublicKey);
149
+ }
150
+ if (!peerPublicKey) return Promise.resolve(false);
151
+ const origin = process.env.GRANTTAP_WEBSITE_ORIGIN || "https://granttap.com";
152
+ return acceptPhoneAccountLink(payload, origin).catch(() => false);
153
+ }
154
+
141
155
  async function handleInboundClaimRelease(
142
156
  client: RelayClient, payload: Extract<Payload, { type: "mesh.claim.release" }>,
143
157
  publish: (forceHistory?: boolean) => Promise<void>,
@@ -1,4 +1,5 @@
1
1
  import { chmod, mkdtemp, rm } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { createServer, type Socket } from "node:net";
@@ -30,6 +31,10 @@ export const DESKTOP_OPERATIONS = new Set([
30
31
  "desktop.network_status", "desktop.network_configure", "desktop.own_relay",
31
32
  ]);
32
33
 
34
+ export function windowsDesktopPipePath(id: string): string {
35
+ return String.raw`\\.\pipe\granttap-desktop-${id}`;
36
+ }
37
+
33
38
  export function desktopOperationTimeout(operation: unknown, input: unknown): number {
34
39
  if (operation === "desktop.mesh_snapshot"
35
40
  && (input as { refresh_graph?: unknown } | undefined)?.refresh_graph === "true") return 120_000;
@@ -57,8 +62,10 @@ export async function startDesktopEngineBridge(options: {
57
62
  client?: Pick<EngineClient, "request" | "close">;
58
63
  onPairingChanged?: () => void;
59
64
  } = {}): Promise<{ socketPath: string; close: () => Promise<void> }> {
60
- const directory = await mkdtemp(join(tmpdir(), "granttap-desktop-"));
61
- const socketPath = join(directory, "engine.sock");
65
+ const directory = process.platform === "win32" ? null
66
+ : await mkdtemp(join(tmpdir(), "granttap-desktop-"));
67
+ const socketPath = directory ? join(directory, "engine.sock")
68
+ : windowsDesktopPipePath(randomUUID());
62
69
  const engine = options.client ?? new EngineClient({
63
70
  socketPath: options.engineSocketPath ?? join(configDir(), "engine.sock"),
64
71
  });
@@ -121,14 +128,15 @@ export async function startDesktopEngineBridge(options: {
121
128
  try {
122
129
  await new Promise<void>((resolve, reject) => {
123
130
  server.once("error", reject);
124
- server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); });
131
+ server.listen({ path: socketPath, readableAll: false, writableAll: false },
132
+ () => { server.removeListener("error", reject); resolve(); });
125
133
  });
126
- await chmod(socketPath, 0o600);
134
+ if (directory) await chmod(socketPath, 0o600);
127
135
  } catch (error) {
128
136
  activity.close();
129
137
  enrichment.close();
130
138
  engine.close();
131
- await rm(directory, { recursive: true, force: true });
139
+ if (directory) await rm(directory, { recursive: true, force: true });
132
140
  throw error;
133
141
  }
134
142
  return {
@@ -140,7 +148,7 @@ export async function startDesktopEngineBridge(options: {
140
148
  controllerEnrollment.close();
141
149
  await new Promise<void>((resolve) => server.close(() => resolve()));
142
150
  engine.close();
143
- await rm(directory, { recursive: true, force: true });
151
+ if (directory) await rm(directory, { recursive: true, force: true });
144
152
  },
145
153
  };
146
154
  }
@@ -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.31
13
+ npm install -g granttap-mcp@0.8.33
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.31\";\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.33\";\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.31";
10
+ const PACKAGE = "granttap-mcp@0.8.33";
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.31",
3
+ "version": "0.8.33",
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,11 @@
1
+ import { z } from "zod";
2
+
3
+ /** A QR-paired phone grants this computer a machine-scoped account credential. */
4
+ export const AccountMachineLink = z.object({
5
+ type: z.literal("account.machine.link"),
6
+ accountId: z.string().uuid(),
7
+ machineId: z.string().uuid(),
8
+ machineToken: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
9
+ createdAt: z.number().nonnegative(),
10
+ }).strict();
11
+ export type AccountMachineLink = z.infer<typeof AccountMachineLink>;
@@ -33,6 +33,7 @@ import {
33
33
  import { MachineLoad } from "./messages/machine";
34
34
  import { PairingJoin } from "./messages/pairing-join";
35
35
  import { ControllerPairOffer, ControllerPairRequest } from "./messages/device/controller-pairing";
36
+ import { AccountMachineLink } from "./messages/device/account-machine-link";
36
37
  import { KnowledgeWrite, KnowledgeWriteResult } from "./messages/mesh/knowledge/write";
37
38
  import { MeshInvocationPage, MeshInvocationQuery } from "./messages/mesh/invocations";
38
39
  import {
@@ -68,6 +69,7 @@ export * from "./messages/interaction";
68
69
  export * from "./messages/machine";
69
70
  export * from "./messages/pairing-join";
70
71
  export * from "./messages/device/controller-pairing";
72
+ export * from "./messages/device/account-machine-link";
71
73
  export * from "./messages/mesh/knowledge/write";
72
74
  export * from "./messages/mesh";
73
75
  export * from "./messages/mesh/invocations";
@@ -157,6 +159,7 @@ export const Payload = z.union([
157
159
  PairingJoin,
158
160
  ControllerPairRequest,
159
161
  ControllerPairOffer,
162
+ AccountMachineLink,
160
163
  KnowledgeWrite,
161
164
  KnowledgeWriteResult,
162
165
  ]).superRefine((payload, ctx) => {