granttap-mcp 0.8.20 → 0.8.21

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.
@@ -3,6 +3,8 @@ import { join } from "node:path";
3
3
  import { RelayClient } from "../../../../../packages/core/relay-client";
4
4
  import { configDir, loadConfig, machineConfigPath } from "../../config";
5
5
  import { EngineSupervisor } from "../../engine/runtime/engine-supervisor";
6
+ import { meshSnapshots } from "../../mesh/runtime";
7
+ import { recoverLocalGraphBindings } from "../../mesh/runtime/graph/binding-sync";
6
8
  import { startSessionMonitor } from "../../monitor";
7
9
  import { controllerPeerGate } from "../../pairing/controllers";
8
10
 
@@ -38,6 +40,13 @@ process.on("SIGINT", stop);
38
40
  process.on("SIGTERM", stop);
39
41
 
40
42
  void engine.ensureAvailable().then((health) => {
43
+ if (health.state === "healthy") {
44
+ // A new Engine binary starts with its own registry. Re-admit existing,
45
+ // locally verified Git bindings so linked repositories have a graph again.
46
+ void recoverLocalGraphBindings(meshSnapshots())
47
+ .then(() => monitor.publish())
48
+ .catch(() => undefined);
49
+ }
41
50
  if (health.state !== "disabled" && health.state !== "healthy") {
42
51
  const reason = health.reason ? `: ${health.reason}` : "";
43
52
  process.stderr.write(
@@ -45,6 +45,16 @@ export type EngineRepositoryGraph = {
45
45
  status: "SUPPORTED" | "CANDIDATE" | "CONTRADICTED" | "INSUFFICIENT_EVIDENCE";
46
46
  evidence: string[]; contradictions: string[]; unknowns: string[];
47
47
  }>;
48
+ code_map?: {
49
+ files: Array<{ path: string; language: string | null; line_count: number | null;
50
+ symbols: Array<{ id: string; label: string; kind: string;
51
+ start_line: number; line_count: number }> }>;
52
+ externals?: Array<{ id: string; label: string; kind: string }>;
53
+ roads: Array<{ source: string; target: string; relation: string }>;
54
+ total_files: number;
55
+ total_externals?: number;
56
+ truncated: boolean;
57
+ } | null;
48
58
  nodes: Array<{ id: string; kind: string; label: string }>;
49
59
  relations: Array<{ source: string; target: string; relation: string; evidence_count?: number }>;
50
60
  total_nodes: number;
@@ -148,6 +148,7 @@ function parseRepositoryGraph(value: unknown): void {
148
148
  }
149
149
  }
150
150
  }
151
+ if (graph.code_map != null) parseCodeMap(graph.code_map);
151
152
  if (!Array.isArray(graph.nodes) || graph.nodes.length > 256
152
153
  || !Array.isArray(graph.relations) || graph.relations.length > 512
153
154
  || !Number.isSafeInteger(graph.total_nodes) || Number(graph.total_nodes) < 0
@@ -170,6 +171,55 @@ function parseRepositoryGraph(value: unknown): void {
170
171
  }
171
172
  }
172
173
 
174
+ function parseCodeMap(value: unknown): void {
175
+ const map = requireObject(value, "repository code map");
176
+ if (!Array.isArray(map.files) || map.files.length > 650
177
+ || (map.externals != null && (!Array.isArray(map.externals) || map.externals.length > 24))
178
+ || !Array.isArray(map.roads) || map.roads.length > 1_200
179
+ || !Number.isSafeInteger(map.total_files) || Number(map.total_files) < map.files.length
180
+ || (map.total_externals != null && !Number.isSafeInteger(map.total_externals))
181
+ || Number(map.total_externals ?? 0) < (Array.isArray(map.externals) ? map.externals.length : 0)
182
+ || typeof map.truncated !== "boolean") invalidResult();
183
+ const paths = new Set<string>();
184
+ for (const value of map.files) {
185
+ const file = requireObject(value, "repository code file");
186
+ requireBoundedString(file.path, "code path", 512);
187
+ const path = file.path as string;
188
+ if (path.startsWith("/") || path.split("/").includes("..") || paths.has(path)) invalidResult();
189
+ paths.add(path);
190
+ requireOptionalString(file.language, "code language", 64);
191
+ if (file.line_count != null
192
+ && (!Number.isSafeInteger(file.line_count) || Number(file.line_count) < 1)) invalidResult();
193
+ if (!Array.isArray(file.symbols) || file.symbols.length > 72) invalidResult();
194
+ for (const value of file.symbols) {
195
+ const symbol = requireObject(value, "code symbol");
196
+ requireBoundedString(symbol.id, "code symbol id", 512);
197
+ requireBoundedString(symbol.label, "code symbol label", 160);
198
+ requireBoundedString(symbol.kind, "code symbol kind", 64);
199
+ if (!Number.isSafeInteger(symbol.start_line) || Number(symbol.start_line) < 1
200
+ || !Number.isSafeInteger(symbol.line_count) || Number(symbol.line_count) < 1) invalidResult();
201
+ }
202
+ }
203
+ for (const value of (map.externals ?? []) as unknown[]) {
204
+ const external = requireObject(value, "repository external resource");
205
+ requireBoundedString(external.id, "external id", 512);
206
+ requireBoundedString(external.label, "external label", 160);
207
+ requireBoundedString(external.kind, "external kind", 64);
208
+ const id = external.id as string;
209
+ if (!id.startsWith("ext:") || id.length < 5 || paths.has(id)) invalidResult();
210
+ paths.add(id);
211
+ }
212
+ for (const value of map.roads) {
213
+ const road = requireObject(value, "code road");
214
+ requireBoundedString(road.source, "road source", 512);
215
+ requireBoundedString(road.target, "road target", 512);
216
+ const source = road.source as string;
217
+ const target = road.target as string;
218
+ requireBoundedString(road.relation, "road relation", 64);
219
+ if (!paths.has(source) || !paths.has(target)) invalidResult();
220
+ }
221
+ }
222
+
173
223
  function parseBackbone(value: unknown): void {
174
224
  const backbone = requireObject(value, "project backbone");
175
225
  requireBoundedString(backbone.project_id, "project_id", 128);
@@ -15,6 +15,7 @@ import type {
15
15
  EngineContextEvidence,
16
16
  } from "../protocol/engine-protocol";
17
17
  import { RepositoryGraphJobs } from "./repository-graph-jobs";
18
+ import { fitRepositoryGraph } from "./repository-graph/fit";
18
19
 
19
20
  export type LocalProjectBinding = {
20
21
  summary: ProjectBindingSummary;
@@ -26,8 +27,8 @@ export type LocalProjectBinding = {
26
27
 
27
28
  let sharedClient: EngineClient | undefined;
28
29
  const pendingBindingSyncs = new Map<string, Promise<boolean>>();
29
- const MAX_REPOSITORY_GRAPH_WIRE_BYTES = 128 * 1_024;
30
- const MAX_SINGLE_GRAPH_WIRE_BYTES = 48 * 1_024;
30
+ const MAX_REPOSITORY_GRAPH_WIRE_BYTES = 256 * 1_024;
31
+ const MAX_SINGLE_GRAPH_WIRE_BYTES = 192 * 1_024;
31
32
  const repositoryGraphJobs = new RepositoryGraphJobs<ProjectRepositoryGraph>();
32
33
 
33
34
  export async function syncProjectBinding(
@@ -211,6 +212,20 @@ async function analyzeRepositoryGraph(
211
212
  analysisId: graph.analysis_id ?? undefined,
212
213
  analysisStatus: graph.analysis_status,
213
214
  architectureHypotheses: graph.architecture_hypotheses,
215
+ codeMap: graph.code_map ? {
216
+ files: graph.code_map.files.map((file) => ({
217
+ path: file.path, language: file.language, lineCount: file.line_count,
218
+ symbols: file.symbols.map((symbol) => ({
219
+ id: symbol.id, label: symbol.label, kind: symbol.kind,
220
+ startLine: symbol.start_line, lineCount: symbol.line_count,
221
+ })),
222
+ })),
223
+ externals: graph.code_map.externals ?? [],
224
+ roads: graph.code_map.roads,
225
+ totalFiles: graph.code_map.total_files,
226
+ totalExternals: graph.code_map.total_externals ?? 0,
227
+ truncated: graph.code_map.truncated,
228
+ } : undefined,
214
229
  nodes: graph.nodes,
215
230
  relations: graph.relations.map((edge) => ({
216
231
  source: edge.source, target: edge.target, relation: edge.relation,
@@ -249,27 +264,6 @@ function boundRepositoryGraphs(graphs: ProjectRepositoryGraph[]): ProjectReposit
249
264
  return result;
250
265
  }
251
266
 
252
- function fitRepositoryGraph(
253
- graph: ProjectRepositoryGraph,
254
- maxBytes: number,
255
- ): ProjectRepositoryGraph | undefined {
256
- let nodes = graph.nodes;
257
- let relations = graph.relations;
258
- let bounded = graph;
259
- while (Buffer.byteLength(JSON.stringify(bounded), "utf8") > maxBytes
260
- && (nodes.length > 8 || relations.length > 8)) {
261
- if (relations.length >= nodes.length && relations.length > 8) {
262
- relations = relations.slice(0, Math.max(8, Math.floor(relations.length / 2)));
263
- } else if (nodes.length > 8) {
264
- nodes = nodes.slice(0, Math.max(8, Math.floor(nodes.length / 2)));
265
- const ids = new Set(nodes.map((node) => node.id));
266
- relations = relations.filter((edge) => ids.has(edge.source) && ids.has(edge.target));
267
- }
268
- bounded = { ...graph, nodes, relations, truncated: true };
269
- }
270
- return Buffer.byteLength(JSON.stringify(bounded), "utf8") <= maxBytes ? bounded : undefined;
271
- }
272
-
273
267
  function defaultClient(): EngineClient {
274
268
  sharedClient ??= new EngineClient({ socketPath: join(configDir(), "engine.sock") });
275
269
  return sharedClient;
@@ -0,0 +1,86 @@
1
+ import type { ProjectRepositoryGraph } from "../../../../../../packages/protocol/schema";
2
+
3
+ type CodeMap = NonNullable<ProjectRepositoryGraph["codeMap"]>;
4
+ type Relation = ProjectRepositoryGraph["relations"][number];
5
+
6
+ /** Keep a navigable code city and the strongest architecture evidence in the snapshot envelope. */
7
+ export function fitRepositoryGraph(
8
+ graph: ProjectRepositoryGraph,
9
+ maxBytes: number,
10
+ ): ProjectRepositoryGraph | undefined {
11
+ let bounded = graph;
12
+ const bytes = () => Buffer.byteLength(JSON.stringify(bounded), "utf8");
13
+ if (bytes() <= maxBytes) return bounded;
14
+ if (bounded.relations.length > 128) {
15
+ bounded = { ...bounded, relations: strongestRelations(bounded.relations, 128), truncated: true };
16
+ }
17
+ while (bytes() > maxBytes) {
18
+ const map = bounded.codeMap;
19
+ if (map?.files.some((file) => file.symbols.length > 0)) {
20
+ bounded = { ...bounded, codeMap: trimSymbols(map) };
21
+ } else if (bounded.relations.length > 8) {
22
+ bounded = { ...bounded, relations: strongestRelations(bounded.relations,
23
+ Math.ceil(bounded.relations.length / 2)), truncated: true };
24
+ } else if (bounded.nodes.length > 8) {
25
+ const nodes = bounded.nodes.slice(0, Math.ceil(bounded.nodes.length / 2));
26
+ const ids = new Set(nodes.map((node) => node.id));
27
+ bounded = { ...bounded, nodes, relations: bounded.relations.filter((edge) =>
28
+ ids.has(edge.source) && ids.has(edge.target)), truncated: true };
29
+ } else if (map && map.roads.length > 16) {
30
+ bounded = { ...bounded, codeMap: { ...map, truncated: true,
31
+ roads: map.roads.slice(0, Math.ceil(map.roads.length / 2)) } };
32
+ } else if (map && map.files.length > 8) {
33
+ bounded = { ...bounded, codeMap: trimFiles(map) };
34
+ } else if (map) {
35
+ bounded = { ...bounded, codeMap: undefined };
36
+ } else {
37
+ return undefined;
38
+ }
39
+ }
40
+ return bounded;
41
+ }
42
+
43
+ function strongestRelations(relations: Relation[], limit: number): Relation[] {
44
+ return relations.map((edge, index) => ({ edge, index }))
45
+ .sort((left, right) => (right.edge.evidenceCount ?? 0) - (left.edge.evidenceCount ?? 0)
46
+ || left.index - right.index)
47
+ .slice(0, limit).sort((left, right) => left.index - right.index)
48
+ .map(({ edge }) => edge);
49
+ }
50
+
51
+ function roadDegree(map: CodeMap): Map<string, number> {
52
+ const degree = new Map<string, number>();
53
+ for (const road of map.roads) {
54
+ degree.set(road.source, (degree.get(road.source) ?? 0) + 1);
55
+ degree.set(road.target, (degree.get(road.target) ?? 0) + 1);
56
+ }
57
+ return degree;
58
+ }
59
+
60
+ function rankedFiles(map: CodeMap): CodeMap["files"] {
61
+ const degree = roadDegree(map);
62
+ return [...map.files].sort((left, right) =>
63
+ (degree.get(right.path) ?? 0) - (degree.get(left.path) ?? 0)
64
+ || (right.lineCount ?? 0) - (left.lineCount ?? 0)
65
+ || left.path.localeCompare(right.path));
66
+ }
67
+
68
+ function trimSymbols(map: CodeMap): CodeMap {
69
+ const ranked = rankedFiles(map).filter((file) => file.symbols.length > 0);
70
+ const keep = new Set(ranked.slice(0, Math.floor(ranked.length / 2)).map((file) => file.path));
71
+ return { ...map, truncated: true, files: map.files.map((file) => {
72
+ if (!keep.has(file.path)) return { ...file, symbols: [] };
73
+ if (ranked.length === 1) return { ...file, symbols: file.symbols.slice(0,
74
+ Math.floor(file.symbols.length / 2)) };
75
+ return file;
76
+ }) };
77
+ }
78
+
79
+ function trimFiles(map: CodeMap): CodeMap {
80
+ const files = rankedFiles(map).slice(0, Math.ceil(map.files.length / 2))
81
+ .sort((left, right) => left.path.localeCompare(right.path));
82
+ const paths = new Set(files.map((file) => file.path));
83
+ for (const external of map.externals ?? []) paths.add(external.id);
84
+ return { ...map, files, truncated: true,
85
+ roads: map.roads.filter((road) => paths.has(road.source) && paths.has(road.target)) };
86
+ }
@@ -0,0 +1,23 @@
1
+ import type { SessionInfo } from "../../../../../../packages/protocol/schema";
2
+ import { scanSessionHistory, scanSessions } from "../../../sessions";
3
+ import { deduplicateNativeSessions } from "../capabilities/session-discovery";
4
+
5
+ type Readers = {
6
+ history: () => SessionInfo[];
7
+ live: () => SessionInfo[];
8
+ };
9
+
10
+ /** Mesh keeps old Task identities, while current provider state stays fresh. */
11
+ export function createMeshSessionCatalog(readers: Readers = {
12
+ history: scanSessionHistory,
13
+ live: () => scanSessions().sessions,
14
+ }) {
15
+ let history: SessionInfo[] | undefined;
16
+ return {
17
+ sessions(): SessionInfo[] {
18
+ history ??= readers.history();
19
+ return deduplicateNativeSessions(history, readers.live());
20
+ },
21
+ invalidateHistory(): void { history = undefined; },
22
+ };
23
+ }
@@ -17,15 +17,27 @@ export async function refreshLocalGraphBindings(
17
17
  const candidates = (snapshot.bindings ?? []).filter((binding) =>
18
18
  binding.endpointId === localEndpoint && binding.available && binding.localPathHint);
19
19
  await Promise.all(candidates.map(async (binding) => {
20
- const path = binding.localPathHint!;
21
- const facts = dependencies.inspect(path);
22
- if (!facts.worktree || resolve(facts.root) !== resolve(path)
23
- || facts.canonicalRepositoryId !== binding.repositoryId) return;
24
- await dependencies.sync(snapshot.project, {
25
- summary: binding,
26
- localRoot: facts.root,
27
- canonicalRemote: facts.baseRemote,
28
- lastSeenAt: Date.now(),
29
- });
20
+ try {
21
+ const path = binding.localPathHint!;
22
+ const facts = dependencies.inspect(path);
23
+ if (!facts.worktree || resolve(facts.root) !== resolve(path)
24
+ || facts.canonicalRepositoryId !== binding.repositoryId) return;
25
+ await dependencies.sync(snapshot.project, {
26
+ summary: binding,
27
+ localRoot: facts.root,
28
+ canonicalRemote: facts.baseRemote,
29
+ lastSeenAt: Date.now(),
30
+ });
31
+ } catch { /* A stale checkout cannot prevent other verified bindings from recovery. */ }
30
32
  }));
31
33
  }
34
+
35
+ /** Rehydrate previously admitted Git checkouts after Engine restart. */
36
+ export async function recoverLocalGraphBindings(
37
+ snapshots: MeshSnapshot[],
38
+ dependencies?: Parameters<typeof refreshLocalGraphBindings>[1],
39
+ ): Promise<void> {
40
+ for (const snapshot of snapshots) {
41
+ await refreshLocalGraphBindings(snapshot, dependencies);
42
+ }
43
+ }
@@ -24,7 +24,6 @@ import {
24
24
  deliverToSession,
25
25
  } from "../../reply";
26
26
  import { isProviderEnabled } from "../../config/runtime";
27
- import { scanSessionHistory, scanSessions } from "../../sessions";
28
27
  import { sendMeshPayload } from "../../host/session-keys";
29
28
  import { linkSessionsToProjects, workingTreeState } from "../catalog";
30
29
  import { catalogFromSessions } from "../catalog/models";
@@ -42,21 +41,23 @@ import { localMeshStore } from "../local-remote/local";
42
41
  import { createHandoffWorktree, repositoryHasCommit } from "./worktree";
43
42
  import { fetchRevision, pushBranch } from "../local-remote/remote";
44
43
  import { projectSessionCapabilityInventory, projectExecutionCapabilitySessions } from "./capabilities";
45
- import { deduplicateNativeSessions } from "./capabilities/session-discovery";
46
44
  import { refreshLocalGraphBindings } from "./graph/binding-sync";
45
+ import { createMeshSessionCatalog } from "./catalog/session-catalog";
47
46
 
48
47
  export type { MeshRuntimeDependencies };
49
48
 
50
49
  /** Who handed the payload in: the relay (the phone, another computer) or an agent's tool call. */
51
50
  export type MeshPayloadOrigin = "relay" | "agent";
52
51
 
53
- function discoveredSessions(): SessionInfo[] {
54
- return deduplicateNativeSessions(scanSessionHistory(), scanSessions().sessions);
52
+ const sessionCatalog = createMeshSessionCatalog();
53
+
54
+ export function invalidateMeshSessionHistory(): void {
55
+ sessionCatalog.invalidateHistory();
55
56
  }
56
57
 
57
58
  const defaultDependencies: MeshRuntimeDependencies = {
58
59
  store: localMeshStore,
59
- sessions: discoveredSessions,
60
+ sessions: sessionCatalog.sessions,
60
61
  capabilityInventory: projectSessionCapabilityInventory,
61
62
  computer: () => computerId(),
62
63
  now: Date.now,
@@ -10,6 +10,7 @@ import {
10
10
  handleMeshPayload,
11
11
  prepareMeshHandoff,
12
12
  requestProjectCapability,
13
+ invalidateMeshSessionHistory,
13
14
  } from "../../mesh/runtime";
14
15
  import { releaseClaimByPerson, releaseResult } from "../../mesh/admin";
15
16
  import { handleInvocationQuery } from "../../engine/invocation/query";
@@ -72,11 +73,7 @@ export async function handleMonitorMessage(
72
73
  void publishSessionEvents(client, payload.sessionId, undefined, payload.threadId).catch(() => false);
73
74
  return true;
74
75
  } else if (payload.type === "sessions.refresh") {
75
- // Pull-to-refresh must include a newly scanned history snapshot; otherwise
76
- // a phone that cleared local state can receive an apparently empty tick.
77
- void publish(true).catch(() => {});
78
- if (payload.graphProjectId && loadRuntimeConfig().meshEnabled) requestProjectGraphRefresh(client, payload.graphProjectId);
79
- return true;
76
+ return handleInboundRefresh(client, payload, publish);
80
77
  } else if (payload.type === "sessions.history.query") {
81
78
  await publishHistoryPage(client, payload);
82
79
  return true;
@@ -135,6 +132,20 @@ export async function handleMonitorMessage(
135
132
  return false;
136
133
  }
137
134
 
135
+ function handleInboundRefresh(
136
+ client: RelayClient, payload: Extract<Payload, { type: "sessions.refresh" }>,
137
+ publish: (forceHistory?: boolean) => Promise<void>,
138
+ ): boolean {
139
+ // An explicit refresh may pay for the full history scan; routine Mesh
140
+ // snapshots use the cached history and fresh live sessions.
141
+ invalidateMeshSessionHistory();
142
+ void publish(true).catch(() => {});
143
+ if (payload.graphProjectId && loadRuntimeConfig().meshEnabled) {
144
+ requestProjectGraphRefresh(client, payload.graphProjectId);
145
+ }
146
+ return true;
147
+ }
148
+
138
149
  function handleInboundToolUpdate(
139
150
  client: RelayClient, payload: Extract<Payload, { type: "tool.update" }>,
140
151
  publish: (forceHistory?: boolean) => Promise<void>,
@@ -6,5 +6,11 @@ This module prepares phone attachments, routes selected MCP/skill intent, and ru
6
6
  handoff resumes the same Task on another agent or computer. Providers without an
7
7
  implemented remote-start path fail closed instead of reporting parity.
8
8
 
9
+ On POSIX, a managed provider command owns a process group. Phone pause and
10
+ timeouts signal that group, so local descendants that remain in it are also
11
+ requested to stop. The stop count records accepted signals, not confirmed exit
12
+ of every descendant. Windows uses the direct-child stop path; remote jobs and
13
+ resources that outlive the agent process need their own cancellation receipts.
14
+
9
15
  License: this module is distributed under the MIT License
10
16
  in the repository-root `LICENSE` file.
@@ -36,14 +36,30 @@ function track(key: string, child: ChildProcess): () => void {
36
36
  };
37
37
  }
38
38
 
39
+ function signalOwnedProcess(child: ChildProcess, signal: NodeJS.Signals): boolean {
40
+ if (process.platform !== "win32" && child.pid && child.pid > 0) {
41
+ try {
42
+ // POSIX detached children lead a new process group. Signal descendants
43
+ // as well as the provider CLI, without touching the monitor's group.
44
+ process.kill(-child.pid, signal);
45
+ return true;
46
+ } catch {
47
+ // The group may already have exited; fall back to the direct child.
48
+ }
49
+ }
50
+ return child.kill(signal);
51
+ }
52
+
39
53
  /** Request a stop for owned child processes; count signals accepted, not exits. */
40
54
  export function abortProcesses(key: string): number {
41
55
  const set = running.get(key);
42
56
  if (!set) return 0;
43
57
  let stopped = 0;
44
58
  for (const child of set) {
45
- (child as ChildProcess & { granttapStopped?: boolean }).granttapStopped = true;
46
- if (child.kill("SIGTERM")) stopped += 1;
59
+ if (signalOwnedProcess(child, "SIGTERM")) {
60
+ (child as ChildProcess & { granttapStopped?: boolean }).granttapStopped = true;
61
+ stopped += 1;
62
+ }
47
63
  }
48
64
  return stopped;
49
65
  }
@@ -70,6 +86,7 @@ export function runProcess(
70
86
  // hand a background run the journal kept for the live session.
71
87
  env: { ...process.env, GRANTTAP_DELIVERY: "1", ...projectEnvForCwd(cwd) },
72
88
  stdio: [stdin == null ? "ignore" : "pipe", "pipe", "pipe"],
89
+ detached: process.platform !== "win32",
73
90
  });
74
91
  } catch (error) {
75
92
  resolve({ ok: false, error: `${command} did not start: ${(error as Error).message}` });
@@ -89,7 +106,7 @@ export function runProcess(
89
106
  };
90
107
  const timer = setTimeout(() => {
91
108
  if (done) return;
92
- child.kill("SIGKILL");
109
+ signalOwnedProcess(child, "SIGKILL");
93
110
  finish({ ok: false, error: `${command} did not respond within ${Math.round(timeoutMs / 1000)}s.` });
94
111
  }, timeoutMs);
95
112
  child.stdout?.on("data", (chunk) => { stdout += chunk; });
@@ -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.19</code> and <code>granttap setup</code> locally.
41
+ <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.21</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,7 +1,7 @@
1
1
  {
2
2
  "name": "granttap",
3
3
  "displayName": "GrantTap",
4
- "version": "0.1.12",
4
+ "version": "0.1.14",
5
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.",
6
6
  "author": {
7
7
  "name": "GrantTap",
@@ -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.19
13
+ npm install -g granttap-mcp@0.8.21
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -49,10 +49,10 @@ import the public repository once and reload Cursor. The plugin adds `/connect`,
49
49
  skill, and the exact-correlation rule; `granttap setup` configures the local
50
50
  MCP endpoint separately.
51
51
 
52
- Publish by pushing this repository. Keep `name` as `granttap` and the GitHub URL
53
- as `sergii-ziborov/granttap-mcp`. That updates the existing listing. Do not
54
- submit Cursor Marketplace or cursor.directory again — a second submit is a
55
- second card. The only Cursor marketplace file is the repo-root
52
+ Pushing this repository updates the plugin source. Cursor reviews marketplace
53
+ updates separately, so confirm the displayed version after review. Keep `name`
54
+ as `granttap` and the GitHub URL as `sergii-ziborov/granttap-mcp`; do not create
55
+ a second listing. The only Cursor marketplace file is the repo-root
56
56
  `.cursor-plugin/marketplace.json`, and it lists this plugin once.
57
57
 
58
58
  If Customize or Marketplace shows both a lowercase **granttap** cube and an
@@ -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.19\";\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.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"
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.19";
10
+ const PACKAGE = "granttap-mcp@0.8.21";
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;
@@ -14,8 +14,8 @@ GrantTap in Cursor Customize → MCPs.
14
14
  Cursor Cloud Agents cannot reach `http://127.0.0.1`. An HTTP MCP entry
15
15
  there — leftover user Customize → MCPs, or an old plugin `mcp.json` — is why
16
16
  Cloud shows **Error · fetch failed**. The Marketplace plugin uses stdio
17
- (`node -e` so cwd `$HOME` still starts GrantTap; on Windows that bootstrap
18
- runs `cmd.exe /c npx`).
17
+ (`node -e` so cwd `$HOME` still starts GrantTap; on Windows the bootstrap
18
+ launches npm's JavaScript entry point through Node).
19
19
  Authorize in the browser on `granttap.com/connect`; that page talks only to
20
20
  the website, not to loopback. Pairing stays on the connection card, last
21
21
  resort a QR there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.20",
3
+ "version": "0.8.21",
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,44 @@
1
+ import { z } from "zod";
2
+
3
+ const CodePath = z.string().trim().min(1).max(512).refine((value) =>
4
+ !value.startsWith("/") && !value.split("/").includes(".."), "repository-relative path required");
5
+
6
+ export const ProjectCodeMap = z.object({
7
+ files: z.array(z.object({
8
+ path: CodePath,
9
+ language: z.string().trim().min(1).max(64).nullable(),
10
+ lineCount: z.number().int().positive().nullable(),
11
+ symbols: z.array(z.object({
12
+ id: z.string().trim().min(1).max(512),
13
+ label: z.string().max(160),
14
+ kind: z.string().trim().min(1).max(64),
15
+ startLine: z.number().int().positive(),
16
+ lineCount: z.number().int().positive(),
17
+ }).strict()).max(72),
18
+ }).strict()).max(650),
19
+ externals: z.array(z.object({
20
+ id: z.string().startsWith("ext:").min(5).max(512),
21
+ label: z.string().max(160),
22
+ kind: z.string().trim().min(1).max(64),
23
+ }).strict()).max(24).optional(),
24
+ roads: z.array(z.object({
25
+ source: CodePath,
26
+ target: CodePath,
27
+ relation: z.string().trim().min(1).max(64),
28
+ }).strict()).max(1_200),
29
+ totalFiles: z.number().int().nonnegative(),
30
+ totalExternals: z.number().int().nonnegative().optional(),
31
+ truncated: z.boolean(),
32
+ }).strict().superRefine((value, ctx) => {
33
+ const paths = new Set(value.files.map((file) => file.path));
34
+ const externals = new Set((value.externals ?? []).map((node) => node.id));
35
+ const references = new Set([...paths, ...externals]);
36
+ if (paths.size !== value.files.length || value.totalFiles < value.files.length
37
+ || externals.size !== (value.externals?.length ?? 0)
38
+ || (value.totalExternals ?? 0) < (value.externals?.length ?? 0)
39
+ || value.roads.some((road) => !references.has(road.source) || !references.has(road.target))) {
40
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid code tower references" });
41
+ }
42
+ });
43
+
44
+ export type ProjectCodeMap = z.infer<typeof ProjectCodeMap>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ProjectCodeMap } from "./graph/code-map";
2
3
  import { Identifier, Label, MeshProvider } from "./endpoint";
3
4
  import {
4
5
  ProjectEnvironment,
@@ -161,6 +162,7 @@ export const ProjectRepositoryGraph = z.object({
161
162
  contradictions: z.array(z.string().max(240)).max(16),
162
163
  unknowns: z.array(z.string().max(160)).max(4),
163
164
  }).strict()).max(8).optional(),
165
+ codeMap: ProjectCodeMap.optional(),
164
166
  nodes: z.array(z.object({
165
167
  id: z.string().trim().min(1).max(512),
166
168
  kind: z.string().trim().min(1).max(64),