granttap-mcp 0.8.70 → 0.8.71

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.
@@ -5,7 +5,15 @@ import { inspectRepository } from "../../catalog";
5
5
  import { computerId } from "../../identity/computer";
6
6
  import { matchesLocalCheckout } from "./checkout-identity";
7
7
 
8
- /** Re-admit this computer's verified checkouts before an explicit Graph build. */
8
+ /**
9
+ * Re-admit this computer's verified checkouts before an explicit Graph build.
10
+ *
11
+ * Only a repository whose Graph is genuinely unavailable is reported. A plain
12
+ * folder an agent ran in, a subfolder of a repository, a removed checkout and
13
+ * a former identity of a checkout another binding already describes are not
14
+ * repositories with a broken Graph; reporting them filled Mesh Health with
15
+ * errors nobody could act on.
16
+ */
9
17
  export async function refreshLocalGraphBindings(
10
18
  snapshot: MeshSnapshot,
11
19
  dependencies: {
@@ -18,19 +26,23 @@ export async function refreshLocalGraphBindings(
18
26
  const candidates = (snapshot.bindings ?? []).filter((binding) =>
19
27
  binding.endpointId === localEndpoint && binding.available && binding.localPathHint);
20
28
  const failures: ProjectRepositoryGraph[] = [];
21
- await Promise.all(candidates.map(async (binding) => {
29
+ const checkouts = candidates.flatMap((binding) => {
30
+ const facts = readCheckout(dependencies.inspect, binding.localPathHint!);
31
+ return facts ? [{ binding, facts, real: realpathSync(facts.root) }] : [];
32
+ });
33
+ await Promise.all(checkouts.map(async ({ binding, facts, real }) => {
34
+ const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
35
+ if (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity) {
36
+ const described = checkouts.some((other) => other.real === real
37
+ && (other.binding.repositoryId === facts.canonicalRepositoryId
38
+ || matchesLocalCheckout(other.binding.repositoryId, facts.root)));
39
+ if (!described) failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
40
+ revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
41
+ analysisErrorCode: "REPOSITORY_IDENTITY_MISMATCH",
42
+ nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false });
43
+ return;
44
+ }
22
45
  try {
23
- const path = binding.localPathHint!;
24
- const facts = dependencies.inspect(path);
25
- const pathIdentity = matchesLocalCheckout(binding.repositoryId, facts.root);
26
- if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)
27
- || (facts.canonicalRepositoryId !== binding.repositoryId && !pathIdentity)) {
28
- failures.push({ projectId: snapshot.projectId, repositoryId: binding.repositoryId,
29
- revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
30
- analysisErrorCode: facts.worktree ? "REPOSITORY_IDENTITY_MISMATCH" : "REPOSITORY_IDENTITY_UNVERIFIED",
31
- nodes: [], relations: [], totalNodes: 0, totalRelations: 0, truncated: false });
32
- return;
33
- }
34
46
  const admitted = await dependencies.sync(snapshot.project, {
35
47
  summary: binding,
36
48
  localRoot: facts.root,
@@ -45,6 +57,17 @@ export async function refreshLocalGraphBindings(
45
57
  return failures;
46
58
  }
47
59
 
60
+ /** A Git checkout rooted exactly at the bound path, or nothing a Graph can be built from. */
61
+ function readCheckout(inspect: typeof inspectRepository, path: string) {
62
+ try {
63
+ const facts = inspect(path);
64
+ if (!facts.worktree || realpathSync(facts.root) !== realpathSync(path)) return undefined;
65
+ return facts;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
48
71
  function admissionFailure(snapshot: MeshSnapshot, binding: NonNullable<MeshSnapshot["bindings"]>[number]): ProjectRepositoryGraph {
49
72
  return { projectId: snapshot.projectId, repositoryId: binding.repositoryId,
50
73
  revision: "unverified", weavatrixVersion: "unknown", analysisStatus: "UNAVAILABLE",
@@ -15,6 +15,10 @@ beside `mesh-publication`. This module uses the repository Apache License 2.0.
15
15
  `message-delivery/index.ts` reserves the durable delivery lease, then
16
16
  emits a non-terminal processing receipt only after workspace, attachment and
17
17
  provider validation. Duplicate validated requests replay that receipt without
18
- starting another provider run. The eleven-minute lease covers the ten-minute
19
- provider timeout. New-task startup events are status updates; the correlated
20
- result is terminal. Delivery integration tests live in `tests/delivery`.
18
+ starting another provider run. A message is delivered once the agent has
19
+ taken it: `message-delivery/acceptance.ts` reports an immediate failure as that
20
+ failure and accepts a run still going after a short window, so the
21
+ eleven-minute lease only has to cover that window. The answer, or the reason a
22
+ long run stopped, reaches the chat when the run ends; unattended runs are
23
+ bounded at six hours. New-task startup events are status updates; the
24
+ correlated result is terminal. Delivery integration tests live in `tests/delivery`.
@@ -4,8 +4,6 @@ import type { RelayClient } from "../../../../../packages/core/relay-client";
4
4
  import { pruneAttachments, takeAttachment } from "../../delivery/attachment-store";
5
5
  import { sendSessionPayload } from "../../host/session-keys";
6
6
 
7
- export const DELIVERY_TIMEOUT_MS = 10 * 60_000;
8
-
9
7
  export async function sendDeliveryReceipt(
10
8
  client: RelayClient,
11
9
  messageId: string,
@@ -9,6 +9,7 @@ import {
9
9
  createCodexSession,
10
10
  createCursorSession,
11
11
  createGrokSession,
12
+ type ReplyResult,
12
13
  } from "../../reply";
13
14
  import { acceptInstanceEpoch } from "../../host/instance-epoch";
14
15
  import { evaluateCreateTask } from "../../mesh/tasks/create-task";
@@ -16,7 +17,7 @@ import { recordDelegation } from "../../mesh/handoff/delegation-loop";
16
17
  import { enqueuePinnedTask } from "../../mesh/tasks/queue";
17
18
  import { sendSessionPayload } from "../../host/session-keys";
18
19
  import { resolveMessageAttachments } from "./receipts";
19
- import { DELIVERY_TIMEOUT_MS } from "./receipts";
20
+ import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
20
21
 
21
22
  export type TaskCreateAdmission =
22
23
  | { status: "accepted" }
@@ -99,15 +100,16 @@ export async function handleTaskCreate(
99
100
  cursor: createCursorSession,
100
101
  grok: createGrokSession,
101
102
  }[agent];
102
- const result = await create(
103
- message.text, message.cwd, DELIVERY_TIMEOUT_MS, resolved.attachments, message.model,
103
+ const run = create(
104
+ message.text, message.cwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model,
104
105
  message.operationId,
105
106
  );
106
- if (result.ok) {
107
- await say(result.text, result.sessionId, true);
108
- } else {
109
- await say(`Could not create a ${displayName} task: ${result.error}`, undefined, true);
110
- return { status: "rejected", error: "Provider delivery failed." };
111
- }
112
- return { status: "accepted" };
107
+ const settle = (result: ReplyResult, late: boolean) => result.ok
108
+ ? say(result.text, result.sessionId, true)
109
+ : say(late ? `The ${displayName} task stopped: ${result.error}`
110
+ : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
111
+ const early = await acceptWhenStarted(run, (result) => settle(result, true));
112
+ if (early === "started") return { status: "accepted" };
113
+ await settle(early, false);
114
+ return early.ok ? { status: "accepted" } : { status: "rejected", error: "Provider delivery failed." };
113
115
  }
@@ -10,6 +10,7 @@ import {
10
10
  createCursorSession,
11
11
  createGrokSession,
12
12
  deliverToSession,
13
+ type ReplyResult,
13
14
  } from "../../reply";
14
15
  import { noteDeliveredRun } from "../../mesh/delivery/run-digest";
15
16
  import { evaluateCreateTask } from "../../mesh/tasks/create-task";
@@ -17,7 +18,7 @@ import { sendSessionPayload } from "../../host/session-keys";
17
18
  import { scanSessions } from "../../sessions";
18
19
  import { agentEventForUserMessage, sendDeliveryReceipt } from "./receipts";
19
20
  import { resolveMessageAttachments } from "./receipts";
20
- import { DELIVERY_TIMEOUT_MS } from "./receipts";
21
+ import { acceptWhenStarted, PHONE_RUN_LIMIT_MS } from "../message-delivery/acceptance";
21
22
 
22
23
  async function startNewAgentTask(input: {
23
24
  client: RelayClient;
@@ -66,16 +67,17 @@ async function startNewAgentTask(input: {
66
67
  cursor: createCursorSession,
67
68
  grok: createGrokSession,
68
69
  }[agent];
69
- const result = await create(
70
- message.text, requestedCwd, DELIVERY_TIMEOUT_MS, resolved.attachments, message.model, undefined, message.effort,
70
+ const run = create(
71
+ message.text, requestedCwd, PHONE_RUN_LIMIT_MS, resolved.attachments, message.model, undefined, message.effort,
71
72
  );
72
- if (result.ok) {
73
- await say(result.text, result.sessionId, true);
74
- return "accepted";
75
- } else {
76
- await say(`Could not create a ${displayName} task: ${result.error}`, undefined, true);
77
- return "rejected";
78
- }
73
+ const settle = (result: ReplyResult, late: boolean) => result.ok
74
+ ? say(result.text, result.sessionId, true)
75
+ : say(late ? `The ${displayName} task stopped: ${result.error}`
76
+ : `Could not create a ${displayName} task: ${result.error}`, undefined, true);
77
+ const early = await acceptWhenStarted(run, (result) => settle(result, true));
78
+ if (early === "started") return "accepted";
79
+ await settle(early, false);
80
+ return early.ok ? "accepted" : "rejected";
79
81
  }
80
82
 
81
83
  export async function handleUserMessage(
@@ -125,32 +127,34 @@ export async function handleUserMessage(
125
127
  // marks the person's bubble, and the next words in the chat are the answer.
126
128
  await onProcessing?.();
127
129
  const startedAt = Date.now();
128
- const result = await deliverToSession(target, message.text, DELIVERY_TIMEOUT_MS, attachments, {
130
+ const run = deliverToSession(target, message.text, PHONE_RUN_LIMIT_MS, attachments, {
129
131
  preferredMcp: message.preferredMcp,
130
132
  skill: message.skill,
131
133
  model: message.model,
132
134
  permissionMode: message.permissionMode,
133
135
  effort: message.effort,
134
136
  });
135
- // The run's turns are in the transcript, but a session holding this chat
136
- // open never sees them; the journal is how it finds out on its next prompt,
137
- // and the Task carries the same digest as progress.
138
- const noted = noteDeliveredRun({
139
- session: target,
140
- prompt: message.text,
141
- result,
142
- startedAt,
143
- endedAt: Date.now(),
144
- });
145
- if (noted?.event) {
146
- await sendSessionPayload(client, noted.event, noted.event.taskId, "phone", { ttlMs: 24 * 60 * 60_000 })
147
- .catch(() => {});
148
- }
149
- if (result.ok) {
150
- await say(result.text, result.sessionId ?? target.sessionId, true);
151
- return "accepted";
152
- } else {
153
- await say(`Could not deliver the message: ${result.error}`, target.sessionId, true);
154
- return "rejected";
155
- }
137
+ const settle = async (result: ReplyResult, late: boolean) => {
138
+ // The run's turns are in the transcript, but a session holding this chat
139
+ // open never sees them; the journal is how it finds out on its next prompt,
140
+ // and the Task carries the same digest as progress.
141
+ const noted = noteDeliveredRun({
142
+ session: target,
143
+ prompt: message.text,
144
+ result,
145
+ startedAt,
146
+ endedAt: Date.now(),
147
+ });
148
+ if (noted?.event) {
149
+ await sendSessionPayload(client, noted.event, noted.event.taskId, "phone", { ttlMs: 24 * 60 * 60_000 })
150
+ .catch(() => {});
151
+ }
152
+ if (result.ok) await say(result.text, result.sessionId ?? target.sessionId, true);
153
+ else await say(late ? `The run stopped: ${result.error}` : `Could not deliver the message: ${result.error}`,
154
+ target.sessionId, true);
155
+ };
156
+ const early = await acceptWhenStarted(run, (result) => settle(result, true));
157
+ if (early === "started") return "accepted";
158
+ await settle(early, false);
159
+ return early.ok ? "accepted" : "rejected";
156
160
  }
@@ -5,6 +5,9 @@ sequence for non-correlated phone messages. Validation happens before the first
5
5
  processing receipt, and duplicate validated messages never start another run.
6
6
  Terminal responses remain correlated to the original message id.
7
7
 
8
+ `acceptance.ts` decides when a phone message counts as delivered: when the agent
9
+ has taken it, not when its whole turn ends. Tests live in `tests/acceptance.test.ts`.
10
+
8
11
  Integration tests: `tests/delivery/processing-receipt.test.ts`; attachment rejection
9
12
  coverage: `tests/attachment/attachment-uploads.test.ts`. See the monitor README.
10
13
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * When a message from the phone counts as delivered.
3
+ *
4
+ * Delivery used to mean "the agent finished its whole turn within ten
5
+ * minutes": a longer turn was killed at the deadline and the person's bubble
6
+ * turned "Not delivered" although the agent had taken the message and was
7
+ * working on it. Delivered now means the agent took it. A run that fails at
8
+ * once (paused, transferred, a CLI that will not start) is still that
9
+ * failure; a run that is still going after a short window is delivered, and
10
+ * its answer — or the reason it stopped — reaches the chat when it ends.
11
+ */
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;
15
+
16
+ /** A safety bound for an unattended turn; the person stops a run with Stop, not with a deadline. */
17
+ export const PHONE_RUN_LIMIT_MS = 6 * 60 * 60_000;
18
+
19
+ export async function acceptWhenStarted<T>(
20
+ run: Promise<T>,
21
+ finish: (result: T) => Promise<unknown>,
22
+ windowMs = ACCEPTANCE_WINDOW_MS,
23
+ ): Promise<T | "started"> {
24
+ let timer: NodeJS.Timeout | undefined;
25
+ const started = new Promise<"started">((resolve) => {
26
+ timer = setTimeout(() => resolve("started"), windowMs);
27
+ });
28
+ const first = await Promise.race([run, started]);
29
+ clearTimeout(timer);
30
+ if (first !== "started") return first;
31
+ void run.then(finish).catch(() => {});
32
+ return "started";
33
+ }
@@ -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.70
13
+ npm install -g granttap-mcp@0.8.71
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.70\";\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.71\";\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.70";
10
+ const PACKAGE = "granttap-mcp@0.8.71";
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.70",
3
+ "version": "0.8.71",
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": {