granttap-mcp 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/SECURITY.md +10 -0
- package/apps/bridge/src/bin/claude-hook.ts +11 -0
- package/apps/bridge/src/bin/codex-hook.ts +9 -0
- package/apps/bridge/src/bin/cursor-mcp-hook.ts +8 -0
- package/apps/bridge/src/mesh/README.md +15 -4
- package/apps/bridge/src/mesh/call-scope.ts +135 -0
- package/apps/bridge/src/mesh/capability.ts +135 -0
- package/apps/bridge/src/mesh/catalog.ts +17 -0
- package/apps/bridge/src/mesh/readiness.ts +72 -0
- package/apps/bridge/src/mesh/runtime-dependencies.ts +37 -0
- package/apps/bridge/src/mesh/runtime-handoff.ts +167 -0
- package/apps/bridge/src/mesh/runtime.ts +19 -144
- package/apps/bridge/src/mesh/scoped-view.ts +73 -0
- package/apps/bridge/src/mesh/worktree.ts +13 -0
- package/apps/mcp/src/create-server.ts +1 -1
- package/apps/mcp/src/mcp-tools/README.md +9 -3
- package/apps/mcp/src/mcp-tools/interaction.ts +68 -10
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +49 -16
- package/apps/mcp/src/mesh-server.ts +1 -1
- package/package.json +2 -2
- package/packages/protocol/messages/mesh.ts +3 -0
package/README.md
CHANGED
|
@@ -45,12 +45,22 @@ Project Mesh adds stable Project and Task identity above provider-native
|
|
|
45
45
|
sessions. A Task can retain its `taskId` across multiple executions, agents,
|
|
46
46
|
and computers while each native session remains intact.
|
|
47
47
|
|
|
48
|
-
Agents
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
Agents publish bounded progress, dependency, question, answer, claim, conflict,
|
|
49
|
+
and completion events through the existing `notify` tool, and read state from a
|
|
50
|
+
Mesh resource. Full transcripts and hidden reasoning are never mesh payloads.
|
|
51
|
+
Claims have TTLs; a colliding claim is rejected before it is recorded so agents
|
|
52
|
+
can choose different work or contact the owner before escalating to Needs You.
|
|
53
|
+
|
|
54
|
+
Both directions are scoped to the calling execution. The provider hook already
|
|
55
|
+
runs inside the agent's own session and sees the exact call, so GrantTap
|
|
56
|
+
attributes every `notify` to the session that really made it and publishes the
|
|
57
|
+
event only for that execution — a model that learned another session's id
|
|
58
|
+
cannot act in its name. `granttap://mesh/current` therefore carries no Project
|
|
59
|
+
data: an attributed call returns this execution's opaque
|
|
60
|
+
`granttap://mesh/<capability>` URI, and only that URI serves its Project, Task,
|
|
61
|
+
owners, claims, dependencies, and relevant events. Ownership transfer stays out
|
|
62
|
+
of the tool surface entirely: `HANDOFF_ACCEPTED` and `HANDOFF_REJECTED` are
|
|
63
|
+
decided by the runtime after receipt verification.
|
|
54
64
|
|
|
55
65
|
The first executable handoff path is Claude Code ↔ Codex across linked
|
|
56
66
|
computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
|
|
@@ -59,6 +69,14 @@ starts the target execution, and returns a receipt bound to the exact capsule.
|
|
|
59
69
|
Cursor and Grok Build use the same provider-neutral schema and discovery model;
|
|
60
70
|
unsupported remote-start paths fail closed instead of claiming parity.
|
|
61
71
|
|
|
72
|
+
A capsule carries facts, not files. A handoff from a checkout with uncommitted
|
|
73
|
+
changes is refused — "This task has uncommitted changes. Commit or checkpoint
|
|
74
|
+
them before moving the task." — instead of silently continuing the Task from
|
|
75
|
+
committed state and leaving that work behind. The destination is refused just
|
|
76
|
+
as explicitly when the named commit is not on its computer, or when the
|
|
77
|
+
capsule's own resource claims overlap another execution's; GrantTap never
|
|
78
|
+
pushes or fetches on its own.
|
|
79
|
+
|
|
62
80
|
### Grok Bot as a scoped Mesh participant
|
|
63
81
|
|
|
64
82
|
Grok Bot is a persistent agent, not a coding-agent integration. The iPhone
|
|
@@ -127,7 +145,8 @@ Setup is CLI-only because it changes provider configuration and must not be
|
|
|
127
145
|
available to a model through prompt injection.
|
|
128
146
|
|
|
129
147
|
`notify` may alternatively carry one bounded task-scoped Mesh event. This does
|
|
130
|
-
not add a fifth MCP tool or grant any global setup capability
|
|
148
|
+
not add a fifth MCP tool or grant any global setup capability, and it publishes
|
|
149
|
+
only for the execution whose provider hook attributed the call.
|
|
131
150
|
|
|
132
151
|
Provider-native approvals and mobile continuation require the matching local
|
|
133
152
|
adapter. MCP registration alone is never reported as proof that an integration
|
package/SECURITY.md
CHANGED
|
@@ -49,6 +49,16 @@ whose independent keys were granted to it. The protection is against relay,
|
|
|
49
49
|
database/network compromise, other pairings, and disclosure of a different
|
|
50
50
|
task key—not against an endpoint already authorized for that task.
|
|
51
51
|
|
|
52
|
+
Project Mesh reads and writes are scoped to one execution. The MCP server
|
|
53
|
+
cannot see which agent called it, so the provider hook — which runs inside the
|
|
54
|
+
session and receives the exact arguments — records a single-use attribution for
|
|
55
|
+
that call, and the runtime publishes the event for that session only. A model
|
|
56
|
+
that learned another execution's session id, Project, or Task cannot publish in
|
|
57
|
+
its name, and the unscoped `granttap://mesh/current` URI carries no Project
|
|
58
|
+
data at all. The opaque capability returned to an attributed caller names one
|
|
59
|
+
Project, Task, session and provider, expires, and never authorizes ownership
|
|
60
|
+
transfer.
|
|
61
|
+
|
|
52
62
|
Task Capsules are strict, bounded schemas containing goal, explicit status,
|
|
53
63
|
repository/commit facts, changed-file names, tests, dependencies, claims,
|
|
54
64
|
remaining work, and explicit decisions. Unknown fields are rejected; hidden
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
machineConfigPath,
|
|
24
24
|
shouldAutoAcceptTool,
|
|
25
25
|
} from "../config";
|
|
26
|
+
import { recordAttributedCall } from "../mesh/call-scope";
|
|
26
27
|
import { classifyAction } from "../policy";
|
|
27
28
|
|
|
28
29
|
async function readStdin(): Promise<string> {
|
|
@@ -51,6 +52,16 @@ async function main(): Promise<void> {
|
|
|
51
52
|
|
|
52
53
|
if (!isProviderEnabled("claude")) return;
|
|
53
54
|
|
|
55
|
+
// Attribute this exact GrantTap call to the session that made it. The MCP
|
|
56
|
+
// server cannot see the caller, so without this a model could publish Mesh
|
|
57
|
+
// events in another execution's name.
|
|
58
|
+
recordAttributedCall({
|
|
59
|
+
provider: "claude",
|
|
60
|
+
sessionId: input.session_id,
|
|
61
|
+
toolName: input.tool_name,
|
|
62
|
+
args: input.tool_input,
|
|
63
|
+
});
|
|
64
|
+
|
|
54
65
|
const blocked = blockedSessionCapability(
|
|
55
66
|
input.session_id,
|
|
56
67
|
input.tool_name,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
machineConfigPath,
|
|
24
24
|
shouldAutoAcceptTool,
|
|
25
25
|
} from "../config";
|
|
26
|
+
import { recordAttributedCall } from "../mesh/call-scope";
|
|
26
27
|
import { classifyAction } from "../policy";
|
|
27
28
|
|
|
28
29
|
async function readStdin(): Promise<string> {
|
|
@@ -52,6 +53,14 @@ async function main(): Promise<void> {
|
|
|
52
53
|
|
|
53
54
|
if (!isProviderEnabled("codex")) return;
|
|
54
55
|
|
|
56
|
+
// See claude-hook.ts: the MCP server trusts this attribution, never the model.
|
|
57
|
+
recordAttributedCall({
|
|
58
|
+
provider: "codex",
|
|
59
|
+
sessionId: input.session_id,
|
|
60
|
+
toolName: input.tool_name,
|
|
61
|
+
args: input.tool_input,
|
|
62
|
+
});
|
|
63
|
+
|
|
55
64
|
const blocked = blockedSessionCapability(
|
|
56
65
|
input.session_id,
|
|
57
66
|
input.tool_name,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolveCursorMcpServer,
|
|
18
18
|
type CursorMcpHookInput,
|
|
19
19
|
} from "../cursor-mcp-policy";
|
|
20
|
+
import { recordAttributedCall } from "../mesh/call-scope";
|
|
20
21
|
import { cursorRootSessionId } from "../sessions/cursor";
|
|
21
22
|
|
|
22
23
|
async function readStdin(): Promise<string> {
|
|
@@ -47,6 +48,13 @@ async function main(): Promise<void> {
|
|
|
47
48
|
}
|
|
48
49
|
const rawSessionId = cursorConversationId(input);
|
|
49
50
|
const sessionId = cursorRootSessionId(rawSessionId) ?? rawSessionId;
|
|
51
|
+
// See claude-hook.ts: Mesh events are published only for the calling session.
|
|
52
|
+
recordAttributedCall({
|
|
53
|
+
provider: "cursor",
|
|
54
|
+
sessionId,
|
|
55
|
+
toolName: input.tool_name,
|
|
56
|
+
args: input.tool_input,
|
|
57
|
+
});
|
|
50
58
|
const server = resolveCursorMcpServer(input);
|
|
51
59
|
const blocked = blockedSessionMcpServer(sessionId, server);
|
|
52
60
|
if (blocked) {
|
|
@@ -13,10 +13,21 @@ Codex is started. The provider abstraction already admits Cursor and Grok Build
|
|
|
13
13
|
without changing the core contract, while their unsupported phase-one start
|
|
14
14
|
paths fail closed.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
`call-scope.ts` and `capability.ts` own caller identity. The MCP server cannot
|
|
17
|
+
see which agent called it, so each provider hook records a single-use
|
|
18
|
+
attribution for the exact call it observed inside its own session, and the
|
|
19
|
+
runtime derives the publishing execution from that instead of from anything the
|
|
20
|
+
model supplied. `capability.ts` mints the opaque per-execution token that
|
|
21
|
+
`scoped-view.ts` answers with, so a read returns one Project rather than
|
|
22
|
+
everything this computer knows. Structured events use the existing `notify`
|
|
23
|
+
tool so the public MCP tool allowlist remains exactly `connect`, `notify`,
|
|
24
|
+
`ask_yes_no`, and `ask`. Setup and global provider configuration remain
|
|
25
|
+
CLI-only.
|
|
26
|
+
|
|
27
|
+
`readiness.ts` decides whether a handoff may leave at all. A capsule transfers
|
|
28
|
+
committed facts, so uncommitted work blocks the handoff instead of silently
|
|
29
|
+
staying behind, and the destination refuses just as explicitly when it lacks the
|
|
30
|
+
named commit or when the capsule's resource claims overlap another execution.
|
|
20
31
|
|
|
21
32
|
Every event is schema-bounded and sent under an independent task key; snapshots
|
|
22
33
|
use an independent project key. Resource claims are advisory, expire without a
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted attribution for GrantTap MCP calls.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server sees a tool call but not which agent session made it, so a
|
|
5
|
+
* model that learns another execution's session id could otherwise publish
|
|
6
|
+
* events in its name. Provider hooks do know the session: they run inside it
|
|
7
|
+
* and receive the exact arguments. Each hook records a single-use, short-lived
|
|
8
|
+
* attribution keyed by those arguments, and the MCP server consumes it instead
|
|
9
|
+
* of trusting anything the model claimed.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { configDir } from "../config";
|
|
16
|
+
|
|
17
|
+
const CALL_TTL_MS = 120_000;
|
|
18
|
+
const MAX_RECORDS = 32;
|
|
19
|
+
|
|
20
|
+
const AttributedCall = z.object({
|
|
21
|
+
key: z.string().regex(/^[0-9a-f]{64}$/),
|
|
22
|
+
provider: z.enum(["claude", "codex", "cursor", "grok"]),
|
|
23
|
+
sessionId: z.string().trim().min(1).max(256),
|
|
24
|
+
expiresAt: z.number().positive(),
|
|
25
|
+
}).strict();
|
|
26
|
+
export type AttributedCall = z.infer<typeof AttributedCall>;
|
|
27
|
+
|
|
28
|
+
const CallFile = z.object({
|
|
29
|
+
version: z.literal(1),
|
|
30
|
+
calls: z.array(AttributedCall).max(MAX_RECORDS),
|
|
31
|
+
}).strict();
|
|
32
|
+
|
|
33
|
+
/** Normalize exactly like the wire schemas so hook and server agree byte for byte. */
|
|
34
|
+
function canonical(value: unknown): unknown {
|
|
35
|
+
if (typeof value === "string") return value.trim();
|
|
36
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
37
|
+
if (value && typeof value === "object") {
|
|
38
|
+
const out: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
40
|
+
const item = (value as Record<string, unknown>)[key];
|
|
41
|
+
if (item !== undefined && item !== null) out[key] = canonical(item);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The GrantTap tool name as each provider spells it, reduced to one token. */
|
|
49
|
+
export function grantTapToolName(rawToolName: unknown): string | undefined {
|
|
50
|
+
const raw = String(rawToolName ?? "").trim();
|
|
51
|
+
const tool = raw.includes("__") ? raw.split("__").at(-1)! : raw;
|
|
52
|
+
return ["notify", "connect", "ask", "ask_yes_no"].includes(tool) ? tool : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function meshCallKey(tool: string, args: unknown): string {
|
|
56
|
+
return createHash("sha256")
|
|
57
|
+
.update(JSON.stringify({ tool, args: canonical(args) }))
|
|
58
|
+
.digest("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function callFilePath(): string {
|
|
62
|
+
return join(configDir(), "mesh-tool-calls.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readCalls(now: number): AttributedCall[] {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = CallFile.safeParse(JSON.parse(readFileSync(callFilePath(), "utf8")));
|
|
68
|
+
if (!parsed.success) return [];
|
|
69
|
+
return parsed.data.calls.filter((call) => call.expiresAt > now);
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeCalls(calls: AttributedCall[]): void {
|
|
76
|
+
const path = callFilePath();
|
|
77
|
+
mkdirSync(configDir(), { recursive: true });
|
|
78
|
+
writeFileSync(
|
|
79
|
+
path,
|
|
80
|
+
`${JSON.stringify(CallFile.parse({ version: 1, calls: calls.slice(-MAX_RECORDS) }), null, 2)}\n`,
|
|
81
|
+
{ mode: 0o600 },
|
|
82
|
+
);
|
|
83
|
+
chmodSync(path, 0o600);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Record that this provider session is about to make one GrantTap tool call.
|
|
88
|
+
* Hooks call this before returning their decision; it never blocks the hook.
|
|
89
|
+
*/
|
|
90
|
+
export function recordAttributedCall(input: {
|
|
91
|
+
provider: AttributedCall["provider"];
|
|
92
|
+
sessionId: unknown;
|
|
93
|
+
toolName: unknown;
|
|
94
|
+
args: unknown;
|
|
95
|
+
now?: number;
|
|
96
|
+
}): void {
|
|
97
|
+
const tool = grantTapToolName(input.toolName);
|
|
98
|
+
const sessionId = String(input.sessionId ?? "").trim();
|
|
99
|
+
if (tool !== "notify" || !sessionId || sessionId.length > 256) return;
|
|
100
|
+
const now = input.now ?? Date.now();
|
|
101
|
+
try {
|
|
102
|
+
const call = AttributedCall.parse({
|
|
103
|
+
key: meshCallKey(tool, input.args),
|
|
104
|
+
provider: input.provider,
|
|
105
|
+
sessionId,
|
|
106
|
+
expiresAt: now + CALL_TTL_MS,
|
|
107
|
+
});
|
|
108
|
+
const kept = readCalls(now).filter((item) =>
|
|
109
|
+
item.key !== call.key || item.sessionId !== call.sessionId);
|
|
110
|
+
writeCalls([...kept, call]);
|
|
111
|
+
} catch {
|
|
112
|
+
// Attribution is best effort inside the hook; the MCP server fails closed.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve the session that actually made this call, then retire the record.
|
|
118
|
+
* Two live sessions claiming the same arguments are ambiguous and rejected.
|
|
119
|
+
*/
|
|
120
|
+
export function consumeAttributedCall(
|
|
121
|
+
tool: string,
|
|
122
|
+
args: unknown,
|
|
123
|
+
now = Date.now(),
|
|
124
|
+
): AttributedCall | undefined {
|
|
125
|
+
const key = meshCallKey(tool, args);
|
|
126
|
+
const live = readCalls(now);
|
|
127
|
+
const matches = live.filter((call) => call.key === key);
|
|
128
|
+
const sessions = new Set(matches.map((call) => call.sessionId));
|
|
129
|
+
if (matches.length === 0 || sessions.size !== 1) {
|
|
130
|
+
if (matches.length > 0) writeCalls(live.filter((call) => call.key !== key));
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
writeCalls(live.filter((call) => call.key !== key));
|
|
134
|
+
return matches[0];
|
|
135
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque per-execution Mesh capabilities.
|
|
3
|
+
*
|
|
4
|
+
* A capability names exactly one live execution: its Project, its Task, its
|
|
5
|
+
* provider and its native session. It is minted only for a call the provider
|
|
6
|
+
* hook already attributed to that session, and it is returned only into that
|
|
7
|
+
* session's own tool result, so one agent can never obtain another's scope.
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import {
|
|
14
|
+
MeshEventType,
|
|
15
|
+
type ExecutionSessionLink,
|
|
16
|
+
type MeshSnapshot,
|
|
17
|
+
} from "../../../../packages/protocol/schema";
|
|
18
|
+
import { configDir } from "../config";
|
|
19
|
+
import { isProviderEnabled } from "../config/runtime";
|
|
20
|
+
import { localMeshStore } from "./local";
|
|
21
|
+
|
|
22
|
+
const CAPABILITY_TTL_MS = 12 * 60 * 60_000;
|
|
23
|
+
const MAX_CAPABILITIES = 64;
|
|
24
|
+
|
|
25
|
+
/** Ownership transfer is decided by the runtime after receipt checks, never by a tool call. */
|
|
26
|
+
const PUBLISHABLE_EVENT_TYPES = MeshEventType.options.filter((type) =>
|
|
27
|
+
type !== "HANDOFF_ACCEPTED" && type !== "HANDOFF_REJECTED");
|
|
28
|
+
|
|
29
|
+
const ExecutionCapability = z.object({
|
|
30
|
+
token: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
|
|
31
|
+
provider: z.enum(["claude", "codex", "cursor", "grok"]),
|
|
32
|
+
sessionId: z.string().trim().min(1).max(256),
|
|
33
|
+
projectId: z.string().trim().min(1).max(128),
|
|
34
|
+
taskId: z.string().trim().min(1).max(128),
|
|
35
|
+
allowedEventTypes: z.array(MeshEventType).min(1),
|
|
36
|
+
issuedAt: z.number().nonnegative(),
|
|
37
|
+
expiresAt: z.number().positive(),
|
|
38
|
+
}).strict();
|
|
39
|
+
export type ExecutionCapability = z.infer<typeof ExecutionCapability>;
|
|
40
|
+
|
|
41
|
+
const CapabilityFile = z.object({
|
|
42
|
+
version: z.literal(1),
|
|
43
|
+
capabilities: z.array(ExecutionCapability).max(MAX_CAPABILITIES),
|
|
44
|
+
}).strict();
|
|
45
|
+
|
|
46
|
+
export function meshCapabilityPath(): string {
|
|
47
|
+
return join(configDir(), "mesh-capabilities.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readCapabilities(now: number): ExecutionCapability[] {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = CapabilityFile.safeParse(JSON.parse(readFileSync(meshCapabilityPath(), "utf8")));
|
|
53
|
+
if (!parsed.success) return [];
|
|
54
|
+
return parsed.data.capabilities.filter((item) => item.expiresAt > now);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeCapabilities(capabilities: ExecutionCapability[]): void {
|
|
61
|
+
const path = meshCapabilityPath();
|
|
62
|
+
mkdirSync(configDir(), { recursive: true });
|
|
63
|
+
writeFileSync(
|
|
64
|
+
path,
|
|
65
|
+
`${JSON.stringify(
|
|
66
|
+
CapabilityFile.parse({ version: 1, capabilities: capabilities.slice(-MAX_CAPABILITIES) }),
|
|
67
|
+
null,
|
|
68
|
+
2,
|
|
69
|
+
)}\n`,
|
|
70
|
+
{ mode: 0o600 },
|
|
71
|
+
);
|
|
72
|
+
chmodSync(path, 0o600);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ExecutionScope = {
|
|
76
|
+
execution: ExecutionSessionLink;
|
|
77
|
+
snapshot: MeshSnapshot;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** The one live, enabled coding-agent execution that owns this native session. */
|
|
81
|
+
export function liveExecutionScope(sessionId: string): ExecutionScope | undefined {
|
|
82
|
+
const store = localMeshStore();
|
|
83
|
+
for (const projectId of store.projectIds()) {
|
|
84
|
+
const snapshot = store.snapshot(projectId);
|
|
85
|
+
const execution = snapshot?.executions.find((item) =>
|
|
86
|
+
item.sessionId === sessionId && item.endedAt == null);
|
|
87
|
+
if (!snapshot || !execution) continue;
|
|
88
|
+
if (execution.provider === "grok_bot" || !isProviderEnabled(execution.provider)) return undefined;
|
|
89
|
+
return { execution, snapshot };
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mint or reuse the capability for one attributed session. */
|
|
95
|
+
export function executionCapabilityFor(
|
|
96
|
+
sessionId: string,
|
|
97
|
+
now = Date.now(),
|
|
98
|
+
): ExecutionCapability | undefined {
|
|
99
|
+
const scope = liveExecutionScope(sessionId);
|
|
100
|
+
if (!scope) return undefined;
|
|
101
|
+
const live = readCapabilities(now);
|
|
102
|
+
const existing = live.find((item) =>
|
|
103
|
+
item.sessionId === sessionId
|
|
104
|
+
&& item.taskId === scope.execution.taskId
|
|
105
|
+
&& item.projectId === scope.snapshot.projectId);
|
|
106
|
+
if (existing) return existing;
|
|
107
|
+
const capability = ExecutionCapability.parse({
|
|
108
|
+
token: randomBytes(32).toString("base64url"),
|
|
109
|
+
provider: scope.execution.provider,
|
|
110
|
+
sessionId,
|
|
111
|
+
projectId: scope.snapshot.projectId,
|
|
112
|
+
taskId: scope.execution.taskId,
|
|
113
|
+
allowedEventTypes: PUBLISHABLE_EVENT_TYPES,
|
|
114
|
+
issuedAt: now,
|
|
115
|
+
expiresAt: now + CAPABILITY_TTL_MS,
|
|
116
|
+
});
|
|
117
|
+
writeCapabilities([...live.filter((item) => item.sessionId !== sessionId), capability]);
|
|
118
|
+
return capability;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolve a presented capability; an ended or disabled execution resolves to nothing. */
|
|
122
|
+
export function resolveExecutionCapability(
|
|
123
|
+
token: unknown,
|
|
124
|
+
now = Date.now(),
|
|
125
|
+
): ExecutionCapability | undefined {
|
|
126
|
+
const value = String(token ?? "").trim();
|
|
127
|
+
if (!value) return undefined;
|
|
128
|
+
const capability = readCapabilities(now).find((item) => item.token === value);
|
|
129
|
+
if (!capability) return undefined;
|
|
130
|
+
const scope = liveExecutionScope(capability.sessionId);
|
|
131
|
+
if (!scope
|
|
132
|
+
|| scope.snapshot.projectId !== capability.projectId
|
|
133
|
+
|| scope.execution.taskId !== capability.taskId) return undefined;
|
|
134
|
+
return capability;
|
|
135
|
+
}
|
|
@@ -29,6 +29,22 @@ function git(cwd: string, args: string[]): string | undefined {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether this checkout has changes that a commit-based handoff would leave
|
|
34
|
+
* behind. `undefined` means the probe could not answer, which callers must not
|
|
35
|
+
* read as "clean".
|
|
36
|
+
*/
|
|
37
|
+
export function hasUncommittedWork(cwd: string): boolean | undefined {
|
|
38
|
+
try {
|
|
39
|
+
execFileSync("git", ["-C", cwd, "diff", "--quiet", "HEAD"], {
|
|
40
|
+
stdio: "ignore", timeout: 2_000,
|
|
41
|
+
});
|
|
42
|
+
return false;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return (error as { status?: number }).status === 1 ? true : undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
export function inspectRepository(cwd: string): RepositoryFacts {
|
|
33
49
|
const cached = repositoryCache.get(cwd);
|
|
34
50
|
if (cached) return cached;
|
|
@@ -98,6 +114,7 @@ export function linkSessionsToProjects(
|
|
|
98
114
|
workspace: cwd,
|
|
99
115
|
branch: session.branch,
|
|
100
116
|
worktree: repository.worktree,
|
|
117
|
+
uncommitted: hasUncommittedWork(repository.worktree ?? cwd),
|
|
101
118
|
startedAt: session.startedAt,
|
|
102
119
|
});
|
|
103
120
|
return { ...session, projectId, taskId, computerId, worktree: repository.worktree };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff readiness.
|
|
3
|
+
*
|
|
4
|
+
* A Task Capsule carries facts, not files: the destination starts a worktree
|
|
5
|
+
* from a commit. Anything still uncommitted stays on the source computer even
|
|
6
|
+
* though the Task visibly continues elsewhere, so a handoff that would lose it
|
|
7
|
+
* is refused instead of silently narrowing to committed state.
|
|
8
|
+
*/
|
|
9
|
+
import type { ResourceClaim, TaskCapsule } from "../../../../packages/protocol/schema";
|
|
10
|
+
|
|
11
|
+
export const UNCOMMITTED_WORK_REASON =
|
|
12
|
+
"This task has uncommitted changes. Commit or checkpoint them before moving the task.";
|
|
13
|
+
|
|
14
|
+
export type HandoffCheckId = "repository" | "workingTree" | "claims" | "targetAgent";
|
|
15
|
+
|
|
16
|
+
export type HandoffCheck = {
|
|
17
|
+
id: HandoffCheckId;
|
|
18
|
+
ready: boolean;
|
|
19
|
+
detail: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type HandoffReadiness = {
|
|
23
|
+
ready: boolean;
|
|
24
|
+
blockedReason?: string;
|
|
25
|
+
checks: HandoffCheck[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type HandoffReadinessInput = {
|
|
29
|
+
capsule?: TaskCapsule;
|
|
30
|
+
targetProviderEnabled: boolean;
|
|
31
|
+
conflicts: ResourceClaim[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function check(id: HandoffCheckId, ready: boolean, detail: string): HandoffCheck {
|
|
35
|
+
return { id, ready, detail };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every blocking condition the source computer can answer before publishing. */
|
|
39
|
+
export function handoffReadiness(input: HandoffReadinessInput): HandoffReadiness {
|
|
40
|
+
const { capsule } = input;
|
|
41
|
+
const conflict = input.conflicts.at(0);
|
|
42
|
+
const checks: HandoffCheck[] = [
|
|
43
|
+
check(
|
|
44
|
+
"repository",
|
|
45
|
+
Boolean(capsule),
|
|
46
|
+
capsule
|
|
47
|
+
? `Commit ${capsule.latestCommit ?? capsule.baseSha} in ${capsule.repository}`
|
|
48
|
+
: "No git repository, task, or commit was found for this execution.",
|
|
49
|
+
),
|
|
50
|
+
check(
|
|
51
|
+
"workingTree",
|
|
52
|
+
Boolean(capsule) && capsule?.dirtyDiffHash == null,
|
|
53
|
+
capsule?.dirtyDiffHash == null ? "Clean" : UNCOMMITTED_WORK_REASON,
|
|
54
|
+
),
|
|
55
|
+
check(
|
|
56
|
+
"claims",
|
|
57
|
+
!conflict,
|
|
58
|
+
conflict
|
|
59
|
+
? `${conflict.ownerSessionId} currently claims ${conflict.resource}.`
|
|
60
|
+
: "No overlapping resource claims",
|
|
61
|
+
),
|
|
62
|
+
check(
|
|
63
|
+
"targetAgent",
|
|
64
|
+
input.targetProviderEnabled,
|
|
65
|
+
input.targetProviderEnabled
|
|
66
|
+
? "Ready"
|
|
67
|
+
: "The target agent is disabled in GrantTap Settings.",
|
|
68
|
+
),
|
|
69
|
+
];
|
|
70
|
+
const blocked = checks.find((item) => !item.ready);
|
|
71
|
+
return { ready: !blocked, blockedReason: blocked?.detail, checks };
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The seams the Mesh runtime needs from this computer: state, sessions, git, and transport. */
|
|
2
|
+
import type { RelayClient } from "../../../../packages/core/relay-client";
|
|
3
|
+
import type {
|
|
4
|
+
MeshEvent,
|
|
5
|
+
MeshProvider,
|
|
6
|
+
MeshSnapshot,
|
|
7
|
+
SessionInfo,
|
|
8
|
+
} from "../../../../packages/protocol/schema";
|
|
9
|
+
import type { createCodexSession, deliverToSession } from "../reply";
|
|
10
|
+
import type { MeshStore } from "./store";
|
|
11
|
+
import type { createHandoffWorktree } from "./worktree";
|
|
12
|
+
|
|
13
|
+
type StartResult = Awaited<ReturnType<typeof createCodexSession>>;
|
|
14
|
+
type WorktreeResult = NonNullable<ReturnType<typeof createHandoffWorktree>>;
|
|
15
|
+
|
|
16
|
+
export type MeshRuntimeDependencies = {
|
|
17
|
+
store: () => MeshStore;
|
|
18
|
+
sessions: () => SessionInfo[];
|
|
19
|
+
computer: () => string;
|
|
20
|
+
now: () => number;
|
|
21
|
+
eventId: () => string;
|
|
22
|
+
providerEnabled: (provider: Exclude<MeshProvider, "grok_bot">) => boolean;
|
|
23
|
+
start: (provider: MeshProvider, prompt: string, cwd: string) => Promise<StartResult>;
|
|
24
|
+
deliver: typeof deliverToSession;
|
|
25
|
+
send: (
|
|
26
|
+
client: RelayClient,
|
|
27
|
+
payload: MeshEvent | MeshSnapshot,
|
|
28
|
+
options: { ttlMs: number; wake?: boolean },
|
|
29
|
+
) => Promise<void>;
|
|
30
|
+
worktree: (
|
|
31
|
+
repository: string,
|
|
32
|
+
taskId: string,
|
|
33
|
+
provider: MeshProvider,
|
|
34
|
+
revision: string,
|
|
35
|
+
) => WorktreeResult | undefined | null;
|
|
36
|
+
hasCommit: (repository: string, revision: string) => boolean;
|
|
37
|
+
};
|