granttap-mcp 0.8.88 → 0.8.90
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/apps/bridge/src/capabilities/README.md +6 -0
- package/apps/bridge/src/capabilities/metadata.ts +4 -1
- package/apps/bridge/src/capabilities/types.ts +1 -1
- package/apps/bridge/src/mesh/admin/capsule.ts +10 -1
- package/apps/bridge/src/mesh/catalog/binding-state.ts +5 -0
- package/apps/bridge/src/mesh/catalog/index.ts +8 -2
- package/apps/bridge/src/mesh/catalog/project/capabilities.ts +6 -0
- package/apps/bridge/src/mesh/catalog/project/configured-mcp.ts +6 -0
- package/apps/bridge/src/mesh/runtime/index.ts +2 -105
- package/apps/bridge/src/mesh/runtime/prepared-transfer/README.md +26 -0
- package/apps/bridge/src/mesh/runtime/prepared-transfer/index.ts +6 -4
- package/apps/bridge/src/mesh/runtime/prepared-transfer/prepare.ts +93 -0
- package/apps/bridge/src/mesh/runtime/prepared-transfer/source-binding.ts +24 -0
- package/apps/bridge/src/mesh/runtime/prepared-transfer/source.ts +45 -0
- package/apps/bridge/src/task-transfer/runtime/commands.ts +7 -5
- package/apps/mcp/src/desktop/delivery/task-handoff.ts +1 -0
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/protocol/messages/capabilities.ts +1 -0
- package/packages/protocol/messages/mesh/events.ts +1 -0
- package/packages/protocol/messages/mesh/snapshot.ts +4 -0
|
@@ -11,4 +11,10 @@ Catalog observations and Project requests do not install a skill or native MCP
|
|
|
11
11
|
configuration on another host. Availability requires a separate native apply
|
|
12
12
|
and independently observed initialization; that rollout is still unfinished.
|
|
13
13
|
|
|
14
|
+
MCP descriptions come from `initialize.serverInfo.description`, trimmed and
|
|
15
|
+
bounded to 4,000 characters. Descriptions, validated icons and website metadata
|
|
16
|
+
travel with both session and Project catalogs; conflicting native implementations
|
|
17
|
+
do not borrow an implementation's description or icon. These are display data,
|
|
18
|
+
not instructions or evidence that a capability was used.
|
|
19
|
+
|
|
14
20
|
License: this module is distributed under the GrantTap Commercial Source License in the repository-root `LICENSE` file.
|
|
@@ -117,9 +117,12 @@ function stringRecord(value: unknown): Record<string, string> {
|
|
|
117
117
|
async function normalizeServerMetadata(info: Implementation | undefined, descriptor: McpDescriptor): Promise<ServerMetadata | undefined> {
|
|
118
118
|
if (!info) return undefined;
|
|
119
119
|
const title = typeof info.title === "string" && info.title.trim() ? info.title.trim().slice(0, 160) : undefined;
|
|
120
|
+
const description = typeof info.description === "string" && info.description.trim()
|
|
121
|
+
? info.description.trim().slice(0, 4_000) : undefined;
|
|
120
122
|
const website = typeof info.websiteUrl === "string" ? safeHttpsUrl(info.websiteUrl) : undefined;
|
|
121
123
|
const version = typeof info.version === "string" && info.version.trim() ? info.version.trim().slice(0, 80) : undefined;
|
|
122
|
-
return { title,
|
|
124
|
+
return { title, ...(description ? { description } : {}), websiteUrl: website?.toString(),
|
|
125
|
+
version, icons: await normalizedIcons(info, descriptor), metadataSource: "mcp" };
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
async function normalizedIcons(info: Implementation, descriptor: McpDescriptor): Promise<NonNullable<McpServerInfo["icons"]> | undefined> {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
3
4
|
import {
|
|
4
5
|
TaskCapsule,
|
|
5
6
|
type MeshHandoffPrepare,
|
|
@@ -42,6 +43,11 @@ function changedFiles(cwd: string): string[] {
|
|
|
42
43
|
return [...new Set(files)].slice(0, 64);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function sameCheckout(path: string | undefined, cwd: string): boolean {
|
|
47
|
+
try { return path != null && realpathSync(path) === realpathSync(cwd); }
|
|
48
|
+
catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
/** The identity of the uncommitted changes, so a later reading can tell they are the same. */
|
|
46
52
|
export function dirtyDiffHash(cwd: string): string | undefined {
|
|
47
53
|
const diff = git(cwd, ["diff", "--binary", "HEAD"]);
|
|
@@ -74,6 +80,9 @@ export function buildTaskCapsule(
|
|
|
74
80
|
if (!head) return undefined;
|
|
75
81
|
const tree = workingTreeState(cwd);
|
|
76
82
|
const files = changedFiles(cwd);
|
|
83
|
+
const sourceBinding = snapshot.bindings?.find(binding => binding.endpointId === computerId
|
|
84
|
+
&& binding.available && sameCheckout(binding.localPathHint, cwd)
|
|
85
|
+
&& (!request.sourceBindingId || binding.bindingId === request.sourceBindingId));
|
|
77
86
|
const capsule = {
|
|
78
87
|
taskId: request.taskId,
|
|
79
88
|
goal: task.goal,
|
|
@@ -85,7 +94,7 @@ export function buildTaskCapsule(
|
|
|
85
94
|
targetComputer: request.targetComputer,
|
|
86
95
|
targetModel: request.targetModel,
|
|
87
96
|
userComment: request.userComment,
|
|
88
|
-
repository: project.canonicalRepositoryId,
|
|
97
|
+
repository: sourceBinding?.repositoryId ?? project.canonicalRepositoryId,
|
|
89
98
|
baseSha: head,
|
|
90
99
|
branch: session.branch ?? git(cwd, ["branch", "--show-current"]),
|
|
91
100
|
latestCommit: head,
|
|
@@ -69,6 +69,11 @@ export function workspaceForRepository(
|
|
|
69
69
|
canonicalRepositoryId: string,
|
|
70
70
|
computerId?: string,
|
|
71
71
|
): string | undefined {
|
|
72
|
+
// A Project can contain several repositories. Its first execution may be
|
|
73
|
+
// rooted in their parent folder, so an exact owned binding takes precedence.
|
|
74
|
+
const binding = state.bindings.find(item => item.repositoryId === canonicalRepositoryId
|
|
75
|
+
&& item.available && (computerId == null || item.endpointId === computerId));
|
|
76
|
+
if (binding?.localPathHint) return binding.localPathHint;
|
|
72
77
|
const projectIds = new Set([
|
|
73
78
|
...state.bindings
|
|
74
79
|
.filter((binding) => binding.repositoryId === canonicalRepositoryId)
|
|
@@ -139,6 +139,11 @@ export function linkSessionsToProjects(
|
|
|
139
139
|
// Share one consistent reading within this catalog batch, then discard it.
|
|
140
140
|
// A new Git checkout or origin must be discovered on the next refresh.
|
|
141
141
|
const repositories = new Map<string, RepositoryFacts>();
|
|
142
|
+
const workingTrees = new Map<string, boolean | undefined>();
|
|
143
|
+
const inspectWorkingTree = (cwd: string): boolean | undefined => {
|
|
144
|
+
if (!workingTrees.has(cwd)) workingTrees.set(cwd, hasUncommittedWork(cwd));
|
|
145
|
+
return workingTrees.get(cwd);
|
|
146
|
+
};
|
|
142
147
|
const inspectOnce = (cwd: string): RepositoryFacts => {
|
|
143
148
|
let facts = repositories.get(cwd);
|
|
144
149
|
if (!facts) {
|
|
@@ -147,7 +152,7 @@ export function linkSessionsToProjects(
|
|
|
147
152
|
}
|
|
148
153
|
return facts;
|
|
149
154
|
};
|
|
150
|
-
return visible.map((session) => linkSession({ store, session, computerId, inspect: inspectOnce }));
|
|
155
|
+
return visible.map((session) => linkSession({ store, session, computerId, inspect: inspectOnce, inspectWorkingTree }));
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
function linkSession(input: {
|
|
@@ -155,6 +160,7 @@ function linkSession(input: {
|
|
|
155
160
|
session: SessionInfo;
|
|
156
161
|
computerId: string;
|
|
157
162
|
inspect: (cwd: string) => RepositoryFacts;
|
|
163
|
+
inspectWorkingTree: (cwd: string) => boolean | undefined;
|
|
158
164
|
}): SessionInfo {
|
|
159
165
|
const { store, session, computerId, inspect } = input;
|
|
160
166
|
const agent = provider(session.agent);
|
|
@@ -228,7 +234,7 @@ function linkSession(input: {
|
|
|
228
234
|
state: session.state, continuedAt: session.transfer?.continuedAt,
|
|
229
235
|
branch: repository.branch ?? session.branch,
|
|
230
236
|
worktree: repository.worktree,
|
|
231
|
-
uncommitted:
|
|
237
|
+
uncommitted: input.inspectWorkingTree(repository.worktree ?? cwd),
|
|
232
238
|
startedAt: session.startedAt,
|
|
233
239
|
});
|
|
234
240
|
return { ...session, projectId, taskId, computerId, worktree: repository.worktree };
|
|
@@ -30,10 +30,16 @@ export function projectMcpServers(
|
|
|
30
30
|
current.configuredEnabled &&= server.configuredEnabled;
|
|
31
31
|
current.allowed &&= server.allowed;
|
|
32
32
|
current.title ??= server.title;
|
|
33
|
+
if (server.description) current.description ??= server.description;
|
|
34
|
+
if (server.websiteUrl) current.websiteUrl ??= server.websiteUrl;
|
|
35
|
+
if (server.icons) current.icons ??= server.icons;
|
|
33
36
|
} else {
|
|
34
37
|
catalog.set(key, {
|
|
35
38
|
name: server.name,
|
|
36
39
|
title: server.title,
|
|
40
|
+
...(server.description ? { description: server.description } : {}),
|
|
41
|
+
...(server.websiteUrl ? { websiteUrl: server.websiteUrl } : {}),
|
|
42
|
+
...(server.icons ? { icons: server.icons } : {}),
|
|
37
43
|
provider,
|
|
38
44
|
endpointId: session.computerId,
|
|
39
45
|
configuredEnabled: server.configuredEnabled,
|
|
@@ -16,6 +16,7 @@ export function configuredProjectMcpServers(
|
|
|
16
16
|
const implementation = server.configDigest ?? "";
|
|
17
17
|
const value: ProjectMcpServer = {
|
|
18
18
|
name: server.name, title: server.title, provider, endpointId,
|
|
19
|
+
description: server.description, websiteUrl: server.websiteUrl, icons: server.icons,
|
|
19
20
|
configuredEnabled: server.configuredEnabled, allowed: server.allowed,
|
|
20
21
|
authStatus: server.authStatus, version: server.version,
|
|
21
22
|
configDigest: server.configDigest,
|
|
@@ -29,6 +30,7 @@ export function configuredProjectMcpServers(
|
|
|
29
30
|
// Two admitted workspaces disagree. Avoid claiming either version
|
|
30
31
|
// as the Project's selected implementation.
|
|
31
32
|
rows.set(key, { ...value, version: undefined, configDigest: undefined,
|
|
33
|
+
description: undefined, websiteUrl: undefined, icons: undefined,
|
|
32
34
|
authStatus: "conflict" });
|
|
33
35
|
} else if (!prior) rows.set(key, value);
|
|
34
36
|
if (!prior) implementations.set(key, implementation);
|
|
@@ -63,6 +65,7 @@ export function mergeConfiguredMcpServers(
|
|
|
63
65
|
|| (configuredRow.configDigest != null || execution.configDigest != null)
|
|
64
66
|
&& configuredRow.configDigest !== execution.configDigest) {
|
|
65
67
|
rows.set(key, { ...configuredRow, version: undefined, configDigest: undefined,
|
|
68
|
+
description: undefined, websiteUrl: undefined, icons: undefined,
|
|
66
69
|
authStatus: "conflict",
|
|
67
70
|
sessionIds: execution.sessionIds });
|
|
68
71
|
continue;
|
|
@@ -70,6 +73,9 @@ export function mergeConfiguredMcpServers(
|
|
|
70
73
|
rows.set(key, {
|
|
71
74
|
...configuredRow, ...execution,
|
|
72
75
|
version: execution.version ?? configuredRow.version,
|
|
76
|
+
description: execution.description ?? configuredRow.description,
|
|
77
|
+
websiteUrl: execution.websiteUrl ?? configuredRow.websiteUrl,
|
|
78
|
+
icons: execution.icons ?? configuredRow.icons,
|
|
73
79
|
configDigest: execution.configDigest ?? configuredRow.configDigest,
|
|
74
80
|
authStatus: execution.authStatus ?? configuredRow.authStatus,
|
|
75
81
|
});
|
|
@@ -21,13 +21,10 @@ import {
|
|
|
21
21
|
} from "../../reply";
|
|
22
22
|
import { isProviderEnabled } from "../../config/runtime";
|
|
23
23
|
import { sendMeshPayload } from "../../host/session-keys";
|
|
24
|
-
import { linkSessionsToProjects
|
|
24
|
+
import { linkSessionsToProjects } from "../catalog";
|
|
25
25
|
import { catalogFromEndpoint } from "../catalog/models";
|
|
26
26
|
import { saveProjectCapabilityRequest } from "../catalog/project/requests";
|
|
27
27
|
import { computerId } from "../identity/computer";
|
|
28
|
-
import { createCheckpoint } from "../admin/checkpoint";
|
|
29
|
-
import { buildTaskCapsule } from "../admin/capsule";
|
|
30
|
-
import { handoffReadiness } from "../tasks/readiness";
|
|
31
28
|
import { createHandoffFlow } from "./handoff";
|
|
32
29
|
import type { MeshRuntimeDependencies } from "./dependencies";
|
|
33
30
|
import { localMeshStore } from "../local-remote/local";
|
|
@@ -37,9 +34,8 @@ import { createHandoffWorktree, repositoryHasCommit } from "./worktree";
|
|
|
37
34
|
import { fetchRevision, pushBranch } from "../local-remote/remote";
|
|
38
35
|
import { projectSessionCapabilityInventory } from "./capabilities";
|
|
39
36
|
import { createMeshSessionCatalog } from "./catalog/session-catalog";
|
|
40
|
-
import {
|
|
37
|
+
import { prepareHandoff } from "./prepared-transfer/prepare";
|
|
41
38
|
import { sourceCheckoutUnchanged } from './prepared-transfer/source-checkout';
|
|
42
|
-
import { prepareCloudHandoff } from "./cloud-handoff";
|
|
43
39
|
|
|
44
40
|
export type { MeshRuntimeDependencies };
|
|
45
41
|
|
|
@@ -84,105 +80,6 @@ const defaultDependencies: MeshRuntimeDependencies = {
|
|
|
84
80
|
}),
|
|
85
81
|
};
|
|
86
82
|
|
|
87
|
-
async function prepareHandoff(
|
|
88
|
-
deps: MeshRuntimeDependencies,
|
|
89
|
-
flow: ReturnType<typeof createHandoffFlow>,
|
|
90
|
-
client: RelayClient,
|
|
91
|
-
request: MeshHandoffPrepare,
|
|
92
|
-
catalog: (sessions: SessionInfo[]) => SessionInfo[],
|
|
93
|
-
): Promise<boolean> {
|
|
94
|
-
const { blockHandoff, acceptHandoff } = flow;
|
|
95
|
-
const sessions = catalog(deps.sessions());
|
|
96
|
-
const session = sessions.find((item) => item.sessionId === request.sessionId);
|
|
97
|
-
if (!session || session.projectId !== request.projectId || session.taskId !== request.taskId) {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
if (request.prepareOnly && session.state === "working") {
|
|
101
|
-
await blockHandoff(client, request, "Pause or finish the current turn before transferring this Task.");
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
const cwd = session.worktree ?? session.cwd;
|
|
105
|
-
const made = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
|
|
106
|
-
? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, request.createdAt)
|
|
107
|
-
: undefined;
|
|
108
|
-
const shared = made && cwd
|
|
109
|
-
? sessions.some((other) =>
|
|
110
|
-
other.sessionId !== session.sessionId
|
|
111
|
-
&& other.taskId != null && other.taskId !== request.taskId
|
|
112
|
-
&& (other.worktree ?? other.cwd) === cwd)
|
|
113
|
-
: false;
|
|
114
|
-
const checkpoint = made && shared ? { ...made, status: "requires_review" as const } : made;
|
|
115
|
-
let capsule = buildTaskCapsule(deps.store(), session, request, deps.computer(), checkpoint);
|
|
116
|
-
const readiness = handoffReadiness({
|
|
117
|
-
capsule,
|
|
118
|
-
targetProviderEnabled: request.targetProvider === "grok_bot"
|
|
119
|
-
|| deps.providerEnabled(request.targetProvider),
|
|
120
|
-
conflicts: (capsule?.resourceClaims ?? []).flatMap((resource) =>
|
|
121
|
-
deps.store().conflicts(request.projectId, request.sessionId, resource, {
|
|
122
|
-
repositoryId: capsule?.repository, endpointId: deps.computer(), worktree: cwd,
|
|
123
|
-
}, true)),
|
|
124
|
-
moduleOverlaps: (capsule?.resourceClaims ?? []).flatMap((resource) =>
|
|
125
|
-
deps.store().moduleOverlaps(request.projectId, request.sessionId, resource, {
|
|
126
|
-
repositoryId: capsule?.repository, endpointId: deps.computer(), worktree: cwd,
|
|
127
|
-
})),
|
|
128
|
-
});
|
|
129
|
-
if (!readiness.ready) {
|
|
130
|
-
await blockHandoff(client, request, readiness.blockedReason ?? "Handoff is not ready.");
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
if (!capsule) return false;
|
|
134
|
-
if (request.cloud) return prepareCloudHandoff({ deps, block: blockHandoff, client, request, session, capsule });
|
|
135
|
-
let transferContext: string | undefined;
|
|
136
|
-
let confirmContext: (() => Promise<boolean>) | undefined;
|
|
137
|
-
if (request.prepareOnly) {
|
|
138
|
-
try {
|
|
139
|
-
const captured = await capturePreparedHandoff(deps, session, capsule);
|
|
140
|
-
transferContext = captured.transferContext;
|
|
141
|
-
capsule = captured.capsule;
|
|
142
|
-
confirmContext = captured.confirm;
|
|
143
|
-
} catch (error) {
|
|
144
|
-
await blockHandoff(client, request, error instanceof Error ? error.message : "The complete transfer context is unavailable.");
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
const local = request.targetComputer === deps.computer();
|
|
149
|
-
if (request.push && !local && cwd) {
|
|
150
|
-
const pushed = deps.push(cwd, capsule.branch);
|
|
151
|
-
if (!pushed.ok) {
|
|
152
|
-
await blockHandoff(client, request, pushed.error);
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const createdAt = deps.now();
|
|
157
|
-
const event: MeshEvent = {
|
|
158
|
-
type: "mesh.event",
|
|
159
|
-
sessionId: request.taskId,
|
|
160
|
-
eventId: deps.eventId(),
|
|
161
|
-
projectId: request.projectId,
|
|
162
|
-
taskId: request.taskId,
|
|
163
|
-
sourceSessionId: request.sessionId,
|
|
164
|
-
eventType: "HANDOFF_REQUEST",
|
|
165
|
-
createdAt,
|
|
166
|
-
expiresAt: createdAt + 60 * 60_000,
|
|
167
|
-
payload: { capsule, transferContext },
|
|
168
|
-
};
|
|
169
|
-
if (request.prepareOnly && !local && deps.dispatchPreparedTransfer) {
|
|
170
|
-
try {
|
|
171
|
-
if (await deps.dispatchPreparedTransfer(event, session, confirmContext!)) {
|
|
172
|
-
await deps.send(client, preparedHandoffMetadata(event), { ttlMs: 60 * 60_000, wake: true }).catch(() => {});
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
await blockHandoff(client, request, 'Project computer transfer is not configured for this destination. Connect its Project in GrantTap.');
|
|
176
|
-
} catch (error) {
|
|
177
|
-
await blockHandoff(client, request, error instanceof Error ? error.message : 'The transfer could not be saved.');
|
|
178
|
-
}
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
deps.store().acceptEvent(event);
|
|
182
|
-
await deps.send(client, request.prepareOnly ? preparedHandoffMetadata(event) : event, { ttlMs: 60 * 60_000, wake: true });
|
|
183
|
-
if (local) await acceptHandoff(client, event);
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
83
|
|
|
187
84
|
export function createMeshRuntime(deps: MeshRuntimeDependencies) {
|
|
188
85
|
const flow = createHandoffFlow(deps);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Preparing a Task transfer
|
|
2
|
+
|
|
3
|
+
`prepare.ts` prepares one scoped transfer through the Mesh runtime's public
|
|
4
|
+
entry point. `source.ts` checks the source facts and the explicit checkpoint
|
|
5
|
+
choice; `index.ts` captures and confirms complete visible context.
|
|
6
|
+
|
|
7
|
+
A missing source chat sends a scoped `TASK_BLOCKED` result so the controller can
|
|
8
|
+
explain the refusal. Preparing does not launch the destination provider. Work
|
|
9
|
+
starts when the person sends the prepared chat its first message.
|
|
10
|
+
|
|
11
|
+
Catalog batches share a working-tree probe for chats in the same checkout.
|
|
12
|
+
The next batch reads it again, including unknown results, so this never hides
|
|
13
|
+
new uncommitted work behind a persistent cache.
|
|
14
|
+
|
|
15
|
+
For a chat started above its repositories, `source-binding.ts` resolves an
|
|
16
|
+
explicit `sourceBindingId` only within the owning endpoint's available Project
|
|
17
|
+
bindings. It verifies the actual Git root and identity before capturing context.
|
|
18
|
+
An exact repository binding takes precedence over a Project's parent execution
|
|
19
|
+
folder. Preparing keeps the native source folder unchanged and launches no
|
|
20
|
+
provider. Rejected preparations also publish their retained Project state.
|
|
21
|
+
|
|
22
|
+
Tests: `tests/metadata.test.ts`, `tests/source-checkout.test.ts`,
|
|
23
|
+
`tests/mesh/mesh-handoff-safety.test.ts`, and catalog
|
|
24
|
+
`tests/working-tree-batch.test.ts`.
|
|
25
|
+
|
|
26
|
+
Licensed under the GrantTap Commercial Source License. See LICENSE and NOTICE.
|
|
@@ -9,7 +9,8 @@ export function preparedHandoffMetadata(event: MeshEvent): MeshEvent {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/** Retains the original archive facts so reconfirmation hashes the same conversation. */
|
|
12
|
-
export async function capturePreparedHandoff(deps: MeshRuntimeDependencies, session: SessionInfo,
|
|
12
|
+
export async function capturePreparedHandoff(deps: MeshRuntimeDependencies, session: SessionInfo,
|
|
13
|
+
facts: TaskCapsule, native = session) {
|
|
13
14
|
const capture = deps.captureTransferContext ?? captureTransferContext;
|
|
14
15
|
const captured = await capture(session, facts);
|
|
15
16
|
const task = deps.store().task(facts.taskId);
|
|
@@ -19,9 +20,10 @@ export async function capturePreparedHandoff(deps: MeshRuntimeDependencies, sess
|
|
|
19
20
|
confirm: async () => {
|
|
20
21
|
const current = deps.sessions().find(row => row.sessionId === session.sessionId);
|
|
21
22
|
if (!current || current.state === 'working' || current.model !== session.model || current.effort !== session.effort
|
|
22
|
-
|| (current.worktree ?? current.cwd) !== (
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
|| (current.worktree ?? current.cwd) !== (native.worktree ?? native.cwd)) return false;
|
|
24
|
+
const resolved = { ...current, worktree: session.worktree, branch: session.branch };
|
|
25
|
+
if (deps.sourceCaptureCurrent && !deps.sourceCaptureCurrent(resolved, facts)) return false;
|
|
26
|
+
try { return (await capture(resolved, facts)).hash === captured.hash; }
|
|
25
27
|
catch { return false; }
|
|
26
28
|
} };
|
|
27
29
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { RelayClient } from "../../../../../../packages/core/relay-client";
|
|
2
|
+
import type { MeshEvent, MeshHandoffPrepare, SessionInfo } from "../../../../../../packages/protocol/schema";
|
|
3
|
+
import type { MeshRuntimeDependencies } from "../dependencies";
|
|
4
|
+
import { createHandoffFlow } from "../handoff";
|
|
5
|
+
import { prepareCloudHandoff } from "../cloud-handoff";
|
|
6
|
+
import { capturePreparedHandoff, preparedHandoffMetadata } from ".";
|
|
7
|
+
import { prepareSourceCapsule } from "./source";
|
|
8
|
+
import { resolveHandoffSource } from "./source-binding";
|
|
9
|
+
|
|
10
|
+
export async function prepareHandoff(
|
|
11
|
+
deps: MeshRuntimeDependencies,
|
|
12
|
+
flow: ReturnType<typeof createHandoffFlow>,
|
|
13
|
+
client: RelayClient,
|
|
14
|
+
request: MeshHandoffPrepare,
|
|
15
|
+
catalog: (sessions: SessionInfo[]) => SessionInfo[],
|
|
16
|
+
): Promise<boolean> {
|
|
17
|
+
const { blockHandoff, acceptHandoff } = flow;
|
|
18
|
+
const sessions = catalog(deps.sessions());
|
|
19
|
+
const native = sessions.find((item) => item.sessionId === request.sessionId);
|
|
20
|
+
let session = native;
|
|
21
|
+
if (!session) {
|
|
22
|
+
const task = deps.store().task(request.taskId);
|
|
23
|
+
if (task?.projectId === request.projectId && task.ownerSessionId === request.sessionId) {
|
|
24
|
+
await blockHandoff(client, request, "The source chat is no longer available. Refresh this Task on its computer and retry.");
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (session.projectId !== request.projectId || session.taskId !== request.taskId) return false;
|
|
29
|
+
try { session = resolveHandoffSource(deps.store(), session, request, deps.computer()); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
await blockHandoff(client, request, (error as Error).message);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (request.prepareOnly && session.state === "working") {
|
|
35
|
+
await blockHandoff(client, request, "Pause or finish the current turn before transferring this Task.");
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const cwd = session.worktree ?? session.cwd;
|
|
39
|
+
let capsule = await prepareSourceCapsule({ deps, blockHandoff, client, request, session, sessions });
|
|
40
|
+
if (!capsule) return false;
|
|
41
|
+
if (request.cloud) return prepareCloudHandoff({ deps, block: blockHandoff, client, request, session, capsule });
|
|
42
|
+
let transferContext: string | undefined;
|
|
43
|
+
let confirmContext: (() => Promise<boolean>) | undefined;
|
|
44
|
+
if (request.prepareOnly) {
|
|
45
|
+
try {
|
|
46
|
+
const captured = await capturePreparedHandoff(deps, session, capsule, native);
|
|
47
|
+
transferContext = captured.transferContext;
|
|
48
|
+
capsule = captured.capsule;
|
|
49
|
+
confirmContext = captured.confirm;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await blockHandoff(client, request, error instanceof Error ? error.message : "The complete transfer context is unavailable.");
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const local = request.targetComputer === deps.computer();
|
|
56
|
+
if (request.push && !local && cwd) {
|
|
57
|
+
const pushed = deps.push(cwd, capsule.branch);
|
|
58
|
+
if (!pushed.ok) {
|
|
59
|
+
await blockHandoff(client, request, pushed.error);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const createdAt = deps.now();
|
|
64
|
+
const event: MeshEvent = {
|
|
65
|
+
type: "mesh.event",
|
|
66
|
+
sessionId: request.taskId,
|
|
67
|
+
eventId: deps.eventId(),
|
|
68
|
+
projectId: request.projectId,
|
|
69
|
+
taskId: request.taskId,
|
|
70
|
+
sourceSessionId: request.sessionId,
|
|
71
|
+
eventType: "HANDOFF_REQUEST",
|
|
72
|
+
createdAt,
|
|
73
|
+
expiresAt: createdAt + 60 * 60_000,
|
|
74
|
+
payload: { capsule, transferContext },
|
|
75
|
+
};
|
|
76
|
+
if (request.prepareOnly && !local && deps.dispatchPreparedTransfer) {
|
|
77
|
+
try {
|
|
78
|
+
if (await deps.dispatchPreparedTransfer(event, session, confirmContext!)) {
|
|
79
|
+
await deps.send(client, preparedHandoffMetadata(event), { ttlMs: 60 * 60_000, wake: true }).catch(() => {});
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
await blockHandoff(client, request, 'Project computer transfer is not configured for this destination. Connect its Project in GrantTap.');
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await blockHandoff(client, request, error instanceof Error ? error.message : 'The transfer could not be saved.');
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
deps.store().acceptEvent(event);
|
|
89
|
+
await deps.send(client, request.prepareOnly ? preparedHandoffMetadata(event) : event, { ttlMs: 60 * 60_000, wake: true });
|
|
90
|
+
if (local) await acceptHandoff(client, event);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import type { MeshHandoffPrepare, SessionInfo } from '../../../../../../packages/protocol/schema';
|
|
3
|
+
import { inspectRepository } from '../../catalog';
|
|
4
|
+
import type { MeshStore } from '../../store';
|
|
5
|
+
import { matchesLocalCheckout } from '../graph/checkout-identity';
|
|
6
|
+
|
|
7
|
+
/** The controller chooses an already admitted binding, never an arbitrary path. */
|
|
8
|
+
export function resolveHandoffSource(store: MeshStore, session: SessionInfo,
|
|
9
|
+
request: MeshHandoffPrepare, computer: string): SessionInfo {
|
|
10
|
+
if (!request.sourceBindingId) return session;
|
|
11
|
+
const binding = store.snapshot(request.projectId)?.bindings?.find(row =>
|
|
12
|
+
row.bindingId === request.sourceBindingId && row.projectId === request.projectId
|
|
13
|
+
&& row.endpointId === computer && row.available);
|
|
14
|
+
if (binding?.localPathHint) {
|
|
15
|
+
const facts = inspectRepository(binding.localPathHint);
|
|
16
|
+
try {
|
|
17
|
+
if (facts.worktree && facts.revision && realpathSync(facts.root) === realpathSync(binding.localPathHint)
|
|
18
|
+
&& (facts.canonicalRepositoryId === binding.repositoryId || matchesLocalCheckout(binding.repositoryId, facts.root))) {
|
|
19
|
+
return { ...session, worktree: facts.root, branch: facts.branch };
|
|
20
|
+
}
|
|
21
|
+
} catch { /* A removed checkout is not an admitted replacement. */ }
|
|
22
|
+
}
|
|
23
|
+
throw new Error('The selected repository is not available on this computer. Refresh the Project and choose its checkout again.');
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RelayClient } from "../../../../../../packages/core/relay-client";
|
|
2
|
+
import type { MeshHandoffPrepare, SessionInfo } from "../../../../../../packages/protocol/schema";
|
|
3
|
+
import type { MeshRuntimeDependencies } from "../dependencies";
|
|
4
|
+
import type { createHandoffFlow } from "../handoff";
|
|
5
|
+
import { workingTreeState } from "../../catalog";
|
|
6
|
+
import { createCheckpoint } from "../../admin/checkpoint";
|
|
7
|
+
import { buildTaskCapsule } from "../../admin/capsule";
|
|
8
|
+
import { handoffReadiness } from "../../tasks/readiness";
|
|
9
|
+
|
|
10
|
+
/** Read source facts once, preserving the person's checkpoint choice. */
|
|
11
|
+
export async function prepareSourceCapsule(input: { deps: MeshRuntimeDependencies;
|
|
12
|
+
blockHandoff: ReturnType<typeof createHandoffFlow>["blockHandoff"]; client: RelayClient;
|
|
13
|
+
request: MeshHandoffPrepare; session: SessionInfo; sessions: SessionInfo[] }) {
|
|
14
|
+
const { deps, blockHandoff, client, request, session, sessions } = input;
|
|
15
|
+
const cwd = session.worktree ?? session.cwd;
|
|
16
|
+
const made = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
|
|
17
|
+
? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, request.createdAt)
|
|
18
|
+
: undefined;
|
|
19
|
+
const shared = made && cwd
|
|
20
|
+
? sessions.some((other) =>
|
|
21
|
+
other.sessionId !== session.sessionId
|
|
22
|
+
&& other.taskId != null && other.taskId !== request.taskId
|
|
23
|
+
&& (other.worktree ?? other.cwd) === cwd)
|
|
24
|
+
: false;
|
|
25
|
+
const checkpoint = made && shared ? { ...made, status: "requires_review" as const } : made;
|
|
26
|
+
const capsule = buildTaskCapsule(deps.store(), session, request, deps.computer(), checkpoint);
|
|
27
|
+
const readiness = handoffReadiness({
|
|
28
|
+
capsule,
|
|
29
|
+
targetProviderEnabled: request.targetProvider === "grok_bot"
|
|
30
|
+
|| deps.providerEnabled(request.targetProvider),
|
|
31
|
+
conflicts: (capsule?.resourceClaims ?? []).flatMap((resource) =>
|
|
32
|
+
deps.store().conflicts(request.projectId, request.sessionId, resource, {
|
|
33
|
+
repositoryId: capsule?.repository, endpointId: deps.computer(), worktree: cwd,
|
|
34
|
+
}, true)),
|
|
35
|
+
moduleOverlaps: (capsule?.resourceClaims ?? []).flatMap((resource) =>
|
|
36
|
+
deps.store().moduleOverlaps(request.projectId, request.sessionId, resource, {
|
|
37
|
+
repositoryId: capsule?.repository, endpointId: deps.computer(), worktree: cwd,
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
if (!readiness.ready) {
|
|
41
|
+
await blockHandoff(client, request, readiness.blockedReason ?? "Handoff is not ready.");
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return capsule;
|
|
45
|
+
}
|
|
@@ -4,9 +4,11 @@ import { prepareMeshHandoff } from '../../mesh/runtime';
|
|
|
4
4
|
import { handleTransferContinuation } from './continuation';
|
|
5
5
|
|
|
6
6
|
export async function handleTaskTransferCommand(client: RelayClient,
|
|
7
|
-
command: MeshHandoffPrepare | TaskTransferContinue,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if
|
|
11
|
-
|
|
7
|
+
command: MeshHandoffPrepare | TaskTransferContinue, publish: () => void,
|
|
8
|
+
prepare = prepareMeshHandoff): Promise<boolean> {
|
|
9
|
+
if (command.type === 'task.transfer.continue') return handleTransferContinuation(client, command, publish);
|
|
10
|
+
// Rejections are durable state too. Publish them even if the direct result
|
|
11
|
+
// could not cross the socket, so reconnecting controllers can explain them.
|
|
12
|
+
try { return await prepare(client, command); }
|
|
13
|
+
finally { publish(); }
|
|
12
14
|
}
|
|
@@ -38,6 +38,7 @@ export async function desktopTaskHandoff(
|
|
|
38
38
|
sessionId: row.session_id, projectId: row.project_id, taskId: row.task_id,
|
|
39
39
|
targetProvider: row.target_provider, targetComputer: row.target_computer,
|
|
40
40
|
targetModel: row.target_model, userComment: row.user_comment,
|
|
41
|
+
...(row.source_binding_id === undefined ? {} : { sourceBindingId: row.source_binding_id }),
|
|
41
42
|
checkpoint: row.checkpoint, push: row.push, createdAt: Date.now(),
|
|
42
43
|
...(row.cloud === true ? { cloud: {
|
|
43
44
|
...(row.cloud_environment === undefined ? {} : { environment: row.cloud_environment }),
|
package/cursor-plugin/README.md
CHANGED
|
@@ -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.
|
|
13
|
+
npm install -g granttap-mcp@0.8.90
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -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.
|
|
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.90\";\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 22.13.0+ 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.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.90";
|
|
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
|
@@ -183,6 +183,7 @@ export const McpServerInfo = z.object({
|
|
|
183
183
|
allowed: z.boolean(),
|
|
184
184
|
authStatus: z.string().optional(),
|
|
185
185
|
title: z.string().max(160).optional(),
|
|
186
|
+
description: z.string().max(4_000).optional(),
|
|
186
187
|
websiteUrl: z.string().url().optional(),
|
|
187
188
|
version: z.string().max(80).optional(),
|
|
188
189
|
configDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
@@ -206,6 +206,7 @@ export const MeshClaimReleaseResult = z.object({
|
|
|
206
206
|
export type MeshClaimReleaseResult = z.infer<typeof MeshClaimReleaseResult>;
|
|
207
207
|
|
|
208
208
|
export const MeshHandoffPrepare = z.object({
|
|
209
|
+
sourceBindingId: Identifier.optional(),
|
|
209
210
|
prepareOnly: z.boolean().optional(),
|
|
210
211
|
type: z.literal("mesh.handoff.prepare"),
|
|
211
212
|
sessionId: Identifier,
|
|
@@ -3,6 +3,7 @@ import { SharedSkill, EndpointModelCatalog } from "./catalog/provider";
|
|
|
3
3
|
export * from "./catalog/provider";
|
|
4
4
|
import { ProjectCodeMap } from "./graph/code-map";
|
|
5
5
|
import { Identifier, Label, MeshProvider } from "./endpoint";
|
|
6
|
+
import { McpIcon } from "../capabilities";
|
|
6
7
|
import {
|
|
7
8
|
ProjectEnvironment,
|
|
8
9
|
ProjectExecutionPolicy,
|
|
@@ -44,6 +45,9 @@ export type IntegrationPeer = z.infer<typeof IntegrationPeer>;
|
|
|
44
45
|
export const ProjectMcpServer = z.object({
|
|
45
46
|
name: Label,
|
|
46
47
|
title: Label.optional(),
|
|
48
|
+
description: z.string().max(4_000).optional(),
|
|
49
|
+
websiteUrl: z.string().url().optional(),
|
|
50
|
+
icons: z.array(McpIcon).max(2).optional(),
|
|
47
51
|
provider: MeshProvider,
|
|
48
52
|
endpointId: Identifier,
|
|
49
53
|
configuredEnabled: z.boolean(),
|