granttap-mcp 0.8.72 → 0.8.74

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.
@@ -16,6 +16,7 @@ import { noteDeliveredRun } from "../../mesh/delivery/run-digest";
16
16
  import { evaluateCreateTask } from "../../mesh/tasks/create-task";
17
17
  import { sendSessionPayload } from "../../host/session-keys";
18
18
  import { scanSessions } from "../../sessions";
19
+ import { publishedSession } from "../../session-catalog";
19
20
  import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
20
21
  import { resolveMessageAttachments } from "./receipts";
21
22
  import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
@@ -104,7 +105,11 @@ export async function handleUserMessage(
104
105
  }
105
106
  const attachments = resolved.attachments;
106
107
 
107
- const target = scanSessions().sessions.find((session) => session.sessionId === message.sessionId);
108
+ // The catalog this monitor last published already names the chat. A full
109
+ // provider scan blocks the event loop for seconds on a large transcript set,
110
+ // so it runs only for a chat newer than that catalog.
111
+ const target = publishedSession(client, message.sessionId)
112
+ ?? scanSessions().sessions.find((session) => session.sessionId === message.sessionId);
108
113
  if (!target) {
109
114
  await say("This task is no longer available on the computer.", message.sessionId);
110
115
  return "rejected";
@@ -10,8 +10,12 @@
10
10
  * its answer — or the reason it stopped — reaches the chat when it ends.
11
11
  */
12
12
 
13
- /** Long enough for a provider CLI to refuse at once, short enough not to keep the phone waiting. */
14
- export const ACCEPTANCE_WINDOW_MS = 15_000;
13
+ /**
14
+ * Long enough for a provider CLI to refuse at once (missing chat, bad flag,
15
+ * not signed in), short enough that the message leaves the phone's queue
16
+ * while the person is still looking at it.
17
+ */
18
+ export const ACCEPTANCE_WINDOW_MS = 3_000;
15
19
 
16
20
  /** A safety bound for an unattended turn; the person stops a run with Stop, not with a deadline. */
17
21
  export const PHONE_RUN_LIMIT_MS = 6 * 60 * 60_000;
@@ -7,3 +7,7 @@ public entry point. Characterization coverage lives in `tests/sessions.test.ts`,
7
7
 
8
8
  License: this module is distributed under the Apache License 2.0
9
9
  in the repository-root `LICENSE` file.
10
+
11
+ `attention/` supplements JSONL messages with the native composer's visible
12
+ question cards and warnings. Missing hooks or a text-only transcript must not
13
+ hide the prompt or choices. Native storage is read-only and bounded.
@@ -2,10 +2,11 @@ import { readFileSync } from "node:fs";
2
2
  import type { ActivityEntry, ChildThreadInfo, SessionInfo } from "../../../../../packages/protocol/schema";
3
3
  import { classifyTool, estimateTokens, pushEntry, toolSummary } from "../support/activity-helpers";
4
4
  import { childEntryFields } from "../support/child-threads";
5
- import { safeParse, stateFor } from "../support/common";
5
+ import { safeParse, stateFor, ts } from "../support/common";
6
6
  import type { CapabilityObservation } from "../telemetry";
7
7
  import { cursorFiles, cursorUsage, scanCursor } from "./scan";
8
8
  import { textBlocks } from "./transcripts";
9
+ import { cursorNativeAttention, cursorQuestionText, cursorNoticeText } from "./attention";
9
10
 
10
11
  function appendFileActivity(input: {
11
12
  out: ActivityEntry[];
@@ -31,8 +32,14 @@ function appendFileActivity(input: {
31
32
  lines.forEach((line, index) => {
32
33
  const item = safeParse(line);
33
34
  if (!item) return;
34
- const createdAt = (typeof item.timestamp === "number" ? item.timestamp : 0)
35
+ const createdAt = ts(item.timestamp ?? item.message?.timestamp)
35
36
  || baseTime + (indexOffset + index) * 1_000;
37
+ const notices = [item, ...(Array.isArray(item.message?.content) ? item.message.content : [])];
38
+ notices.forEach((row, ordinal) => {
39
+ const notice = cursorNoticeText(row);
40
+ if (notice) pushEntry({ fullText, out, seen, sessionId, kind: "status", text: notice,
41
+ createdAt, ordinal: (indexOffset + index) * 100 + ordinal, extras: childFields });
42
+ });
36
43
  if (item.role === "user") {
37
44
  const text = textBlocks(item.message?.content).join("\n").trim();
38
45
  if (!text) return;
@@ -53,7 +60,7 @@ function appendFileActivity(input: {
53
60
  if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
54
61
  pushEntry({ fullText, out: out, seen: seen, sessionId: sessionId, kind: "message", text: block.text, createdAt: createdAt, ordinal: sequence, extras: childFields, idOverride: entryId(createdAt, sequence) });
55
62
  } else if (block?.type === "tool_use") {
56
- pushEntry({ fullText, out: out, seen: seen, sessionId: sessionId, kind: "tool", text: toolSummary(block.name, block.input), createdAt: createdAt, ordinal: sequence, extras: {
63
+ pushEntry({ fullText, out: out, seen: seen, sessionId: sessionId, kind: cursorQuestionText(block.name, block.input) ? "message" : "tool", text: cursorQuestionText(block.name, block.input) ?? toolSummary(block.name, block.input), createdAt: createdAt, ordinal: sequence, extras: {
57
64
  ...childFields,
58
65
  ...classifyTool(block.name, block.input),
59
66
  estimatedContextTokens: estimateTokens(block.input),
@@ -85,6 +92,9 @@ export function cursorActivity(session: SessionInfo, fullText = false): Activity
85
92
  baseTime: base, indexOffset: index * 10_000, child,
86
93
  });
87
94
  });
95
+ for (const entry of cursorNativeAttention(session)) {
96
+ if (!out.some(existing => existing.text === entry.text)) out.push(entry);
97
+ }
88
98
  return out.sort((a, b) => a.createdAt - b.createdAt);
89
99
  }
90
100
 
@@ -0,0 +1,12 @@
1
+ # Cursor attention
2
+
3
+ Public entries: `cursorQuestionText`, `cursorNoticeText`, and `cursorNativeAttention`.
4
+ They project user-visible Cursor questions, their choices, and warnings into the
5
+ existing chat transcript. Cursor sometimes keeps question cards in its local
6
+ composer database without writing them to the JSONL transcript. The native reader
7
+ uses read-only indexed lookups of the exact composer's most recent 128 bubbles,
8
+ with a one-second timeout and bounded results. It never reads thought blocks,
9
+ changes native answers, or represents a displayed question as answered.
10
+
11
+ Tests exercise the public projection and an isolated SQLite database. Distributed
12
+ under the repository Apache License 2.0.
@@ -0,0 +1,3 @@
1
+ /** Public projection of Cursor's user-visible questions and warnings. */
2
+ export { cursorQuestionText, cursorNoticeText } from './presentation';
3
+ export { cursorNativeAttention } from './native';
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ActivityEntry, SessionInfo } from '../../../../../../packages/protocol/schema';
6
+ import { cursorQuestionText, cursorNoticeText } from './presentation';
7
+
8
+ type NativeRow = { id: string; at?: string | number; name?: string; params?: unknown; warning?: unknown };
9
+ const sql = (value: string) => "'" + value.replaceAll("'", "''") + "'";
10
+
11
+ function decoded(value: unknown): unknown {
12
+ if (typeof value !== 'string') return value;
13
+ try { return JSON.parse(value); } catch { return undefined; }
14
+ }
15
+
16
+ /** Exact indexed composer/bubble reads. No database scan and no writes to Cursor's state. */
17
+ export function cursorNativeAttention(session: SessionInfo, db = process.env.GRANTTAP_CURSOR_STATE_DB
18
+ ?? join(homedir(), 'Library/Application Support/Cursor/User/globalStorage/state.vscdb')): ActivityEntry[] {
19
+ if (!existsSync(db) || !session.sessionId || session.sessionId.length > 256) return [];
20
+ try {
21
+ const query = `WITH composer AS (SELECT value FROM cursorDiskKV WHERE key=${sql('composerData:' + session.sessionId)}),
22
+ headers AS (SELECT json_extract(j.value,'$.bubbleId') AS id, json_extract(j.value,'$.createdAt') AS at
23
+ FROM composer, json_each(composer.value,'$.fullConversationHeadersOnly') j
24
+ ORDER BY CAST(j.key AS INTEGER) DESC LIMIT 128)
25
+ SELECT h.id, coalesce(json_extract(b.value,'$.createdAt'),h.at) AS at,
26
+ json_extract(b.value,'$.toolFormerData.name') AS name,
27
+ CASE WHEN lower(json_extract(b.value,'$.toolFormerData.name')) IN ('ask_question','askquestion','askuserquestion')
28
+ THEN json_extract(b.value,'$.toolFormerData.params') END AS params,
29
+ coalesce(json_extract(b.value,'$.errorDetails.error.details.detail'),
30
+ json_extract(b.value,'$.errorDetails.error.details.title'),
31
+ json_extract(b.value,'$.errorDetails.message'),json_extract(b.value,'$.warning.message')) AS warning
32
+ FROM headers h JOIN cursorDiskKV b ON b.key=${sql('bubbleId:' + session.sessionId + ':')} || h.id;`;
33
+ const raw = execFileSync('sqlite3', ['-readonly', '-json', db, query], {
34
+ encoding: 'utf8', timeout: 1_000, maxBuffer: 2 * 1_024 * 1_024, stdio: ['ignore', 'pipe', 'ignore'],
35
+ });
36
+ const rows: NativeRow[] = raw.trim() ? JSON.parse(raw) : [];
37
+ return rows.flatMap(row => {
38
+ const question = cursorQuestionText(row.name, decoded(row.params));
39
+ const notice = cursorNoticeText({ type: 'warning', message: row.warning });
40
+ const text = question ?? notice;
41
+ if (!text || typeof row.id !== 'string' || row.id.length > 256) return [];
42
+ const at = typeof row.at === 'number' ? row.at : Date.parse(row.at ?? '');
43
+ return [{ id: `cursor-attention:${session.sessionId}:${row.id}`,
44
+ kind: question ? 'message' as const : 'status' as const, text,
45
+ createdAt: Number.isFinite(at) ? at : session.lastActivityAt }];
46
+ }).sort((a, b) => a.createdAt - b.createdAt);
47
+ } catch { return []; }
48
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ const text = z.string().trim().min(1).max(2_000);
4
+ const option = z.union([z.string().trim().min(1).max(512), z.object({ label: z.string().trim().min(1).max(512) })]);
5
+ const questions = z.object({ title: z.string().max(160).optional(), questions: z.array(z.object({
6
+ prompt: text.optional(), question: text.optional(), options: z.array(option).max(8).optional(),
7
+ })).min(1).max(4) });
8
+
9
+ /** Only the visible question and options; native reasoning and raw tool inputs stay private. */
10
+ export function cursorQuestionText(name: unknown, input: unknown): string | undefined {
11
+ if (typeof name !== 'string' || !['askquestion', 'ask_question', 'askuserquestion'].includes(name.toLowerCase())) return;
12
+ const parsed = questions.safeParse(input);
13
+ if (!parsed.success || parsed.data.questions.some(q => !q.prompt && !q.question)) return;
14
+ const parts = parsed.data.questions.map(q => [q.prompt ?? q.question!,
15
+ ...(q.options ?? []).map(o => '• ' + (typeof o === 'string' ? o : o.label))].join('\n'));
16
+ return [parsed.data.title, ...parts].filter(Boolean).join('\n\n').slice(0, 16_384);
17
+ }
18
+
19
+ /** Explicit user-visible notices only, never an arbitrary diagnostic or thought block. */
20
+ export function cursorNoticeText(item: unknown): string | undefined {
21
+ if (!item || typeof item !== 'object') return;
22
+ const row = item as Record<string, unknown>;
23
+ if (row.type === 'tool_result' && row.is_error === true) {
24
+ const content = typeof row.content === 'string' ? row.content : Array.isArray(row.content)
25
+ ? row.content.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n') : undefined;
26
+ return typeof content === 'string' && content.trim() ? content.trim().slice(0, 4_000) : undefined;
27
+ }
28
+ if (!['warning', 'error'].includes(String(row.type))) return;
29
+ const message = typeof row.message === 'string' ? row.message
30
+ : row.message && typeof row.message === 'object' ? (row.message as Record<string, unknown>).content : row.text;
31
+ return typeof message === 'string' && message.trim() ? message.trim().slice(0, 4_000) : undefined;
32
+ }
@@ -258,6 +258,7 @@ export function composerActivityAt(composer: ComposerRow): number {
258
258
 
259
259
  export function composerState(status: string | undefined, lastActivityAt: number): SessionState {
260
260
  const value = (status ?? "").toLowerCase();
261
+ if (["waiting", "waiting_for_user", "awaiting_user_response"].includes(value)) return "waiting";
261
262
  if (["generating", "continuing", "applying", "running", "in_progress", "active"].includes(value)) {
262
263
  return "working";
263
264
  }
@@ -31,9 +31,21 @@ type ClaudeParseState = {
31
31
  tokensLastTurn: number;
32
32
  contextTokensUsed?: number;
33
33
  repositoryPaths: string[];
34
+ turnEnded: boolean;
34
35
  };
35
36
 
37
+ /** Stop reasons after which Claude Code waits for the person's next prompt. */
38
+ const FINISHED_TURN = new Set(["end_turn", "stop_sequence", "max_tokens"]);
39
+
40
+ /** Metadata, progress and subagent lines do not start or end the chat's own turn. */
41
+ function noteTurnBoundary(d: any, state: ClaudeParseState): void {
42
+ if (d.isSidechain === true) return;
43
+ if (d.type === "user" || d.type === "queue-operation") state.turnEnded = false;
44
+ else if (d.type === "assistant") state.turnEnded = FINISHED_TURN.has(d.message?.stop_reason);
45
+ }
46
+
36
47
  function applyClaudeLine(d: any, state: ClaudeParseState): void {
48
+ noteTurnBoundary(d, state);
37
49
  state.repositoryPaths.push(...claudeRepositoryPaths(d));
38
50
  state.repositoryPaths = state.repositoryPaths.slice(-64);
39
51
  if (d.sessionId && !state.sessionId) state.sessionId = String(d.sessionId);
@@ -119,7 +131,7 @@ export function parseClaudeFile(file: string): {
119
131
  state:
120
132
  cached.session.childThreads?.some((child) => stateFor(child.lastActivityAt) === "working")
121
133
  ? "working" as const
122
- : stateFor(cached.session.lastActivityAt),
134
+ : cached.turnEnded ? "idle" as const : stateFor(cached.session.lastActivityAt),
123
135
  childThreads: cached.session.childThreads?.map((child) => ({
124
136
  ...child,
125
137
  state: stateFor(child.lastActivityAt),
@@ -136,7 +148,7 @@ export function parseClaudeFile(file: string): {
136
148
  const lines = text.split("\n");
137
149
  const state: ClaudeParseState = {
138
150
  sessionId: "", startedAt: 0, lastActivityAt: 0, lastMessageAt: 0,
139
- tokensSession: 0, tokensLastTurn: 0, repositoryPaths: [],
151
+ tokensSession: 0, tokensLastTurn: 0, repositoryPaths: [], turnEnded: false,
140
152
  };
141
153
  for (const line of lines) {
142
154
  if (!line) continue;
@@ -153,7 +165,7 @@ export function parseClaudeFile(file: string): {
153
165
  branch: state.branch,
154
166
  model: state.model,
155
167
  summary: state.summary,
156
- state: stateFor(state.lastActivityAt),
168
+ state: state.turnEnded ? "idle" : stateFor(state.lastActivityAt),
157
169
  startedAt: state.startedAt || state.lastActivityAt,
158
170
  lastActivityAt: state.lastActivityAt,
159
171
  ...(state.lastMessageAt ? { lastMessageAt: state.lastMessageAt } : {}),
@@ -182,6 +194,7 @@ export function parseClaudeFile(file: string): {
182
194
  session,
183
195
  tokensSession: session.tokensSession,
184
196
  observations,
197
+ turnEnded: state.turnEnded,
185
198
  });
186
199
  return { session, tokensSession: session.tokensSession, childPaths: actualChildPaths };
187
200
  }
@@ -51,6 +51,8 @@ export type CachedClaudeSummary = {
51
51
  session: SessionInfo;
52
52
  tokensSession: number;
53
53
  observations: CapabilityObservation[];
54
+ /** The chat's own last turn ended; recent writes no longer mean it is working. */
55
+ turnEnded?: boolean;
54
56
  };
55
57
 
56
58
  export const claudeSummaryCache = new Map<string, CachedClaudeSummary>();
@@ -9,7 +9,7 @@ import { TaskDispatchInbox } from '../index';
9
9
  import { TaskDispatchWorker } from './index';
10
10
  import { relayTaskJobRemote } from './remote';
11
11
  import { NativeTaskQueueSender } from '../sender';
12
- import { taskDispatchReady } from './readiness';
12
+ import { queuedCatalogRefreshDue, taskDispatchReady } from './readiness';
13
13
 
14
14
  /** Only the persistent monitor advertises durable submission capability. */
15
15
  export function startTaskDispatchMonitor(input: {
@@ -17,7 +17,7 @@ export function startTaskDispatchMonitor(input: {
17
17
  peerAllowed: (peer: string) => boolean; publish: () => Promise<void>;
18
18
  }): () => void {
19
19
  const inbox = new TaskDispatchInbox(join(configDir(), 'task-dispatch'), input.config.room);
20
- let stopped = false, reportedAt = 0, maintainedAt = 0;
20
+ let stopped = false, reportedAt = 0, maintainedAt = 0, refreshedAt = 0;
21
21
  const sender = new NativeTaskQueueSender({ root: join(configDir(), 'native-task-queue'), config: input.config });
22
22
  const peerAllowed = (peer: string) => peer === input.config.myPublicKey || input.peerAllowed(peer);
23
23
  const worker = new TaskDispatchWorker({ inbox, remote: relayTaskJobRemote(input.config),
@@ -29,9 +29,15 @@ export function startTaskDispatchMonitor(input: {
29
29
  const catalog = publishedCatalog(input.client);
30
30
  const session = [...(catalog?.sessions ?? []), ...(catalog?.history ?? [])]
31
31
  .find(item => item.sessionId === job.sessionId);
32
- return taskDispatchReady(job, { session, generatedAt: catalog?.generatedAt,
32
+ const observation = { session, generatedAt: catalog?.generatedAt,
33
33
  inFlight: isDeliveryInFlight(job.sessionId ?? `__new_${job.operation.agent ?? 'codex'}_task__`),
34
- paused: !!job.sessionId && isSessionPaused(job.sessionId) });
34
+ paused: !!job.sessionId && isSessionPaused(job.sessionId) };
35
+ const ready = taskDispatchReady(job, observation);
36
+ if (!ready && queuedCatalogRefreshDue(job, observation, refreshedAt)) {
37
+ refreshedAt = Date.now();
38
+ void input.publish().catch(() => {});
39
+ }
40
+ return ready;
35
41
  },
36
42
  deliver: async (job, beforeStart) => {
37
43
  const outcome = job.operation.type === 'user.message'
@@ -12,3 +12,15 @@ export function taskDispatchReady(job: TaskDispatch, observation: {
12
12
  // which returns a definite rejection rather than keeping a lost task forever.
13
13
  return !observation.session || observation.session.state === 'idle';
14
14
  }
15
+
16
+ /** The catalog loop runs every 30 s; a waiting queued message should not. */
17
+ export const QUEUED_CATALOG_REFRESH_MS = 10_000;
18
+
19
+ /** One rate-limited catalog refresh lets a queued job see its chat go idle sooner. */
20
+ export function queuedCatalogRefreshDue(job: TaskDispatch, observation: {
21
+ generatedAt?: number; inFlight: boolean; paused: boolean;
22
+ }, lastRefreshAt: number, now = Date.now()): boolean {
23
+ if (!job.sessionId || job.deliveryMode === 'immediate' || observation.inFlight || observation.paused) return false;
24
+ return now - (observation.generatedAt ?? 0) >= QUEUED_CATALOG_REFRESH_MS
25
+ && now - lastRefreshAt >= QUEUED_CATALOG_REFRESH_MS;
26
+ }
@@ -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.72
13
+ npm install -g granttap-mcp@0.8.74
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.72\";\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.74\";\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.72";
10
+ const PACKAGE = "granttap-mcp@0.8.74";
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.72",
3
+ "version": "0.8.74",
4
4
  "description": "Apache-2.0 computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {