granttap-mcp 0.6.10 → 0.7.0
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 +52 -3
- package/SECURITY.md +20 -0
- package/apps/bridge/src/bin/claude-hook.ts +3 -13
- package/apps/bridge/src/bin/codex-hook.ts +3 -0
- package/apps/bridge/src/bin/codex-policy-hook.ts +2 -1
- package/apps/bridge/src/bin/cursor-after-shell.ts +5 -1
- package/apps/bridge/src/bin/cursor-hook.ts +5 -0
- package/apps/bridge/src/bin/cursor-mcp-hook.ts +5 -0
- package/apps/bridge/src/bin/mesh-connect.ts +27 -0
- package/apps/bridge/src/bin/setup.ts +1 -0
- package/apps/bridge/src/config/runtime.ts +34 -1
- package/apps/bridge/src/install.ts +9 -1
- package/apps/bridge/src/mesh/README.md +25 -0
- package/apps/bridge/src/mesh/attention.ts +19 -0
- package/apps/bridge/src/mesh/capsule.ts +86 -0
- package/apps/bridge/src/mesh/catalog.ts +105 -0
- package/apps/bridge/src/mesh/endpoint.ts +179 -0
- package/apps/bridge/src/mesh/handoff.ts +37 -0
- package/apps/bridge/src/mesh/identity.ts +39 -0
- package/apps/bridge/src/mesh/local.ts +18 -0
- package/apps/bridge/src/mesh/runtime.ts +278 -0
- package/apps/bridge/src/mesh/store-support.ts +23 -0
- package/apps/bridge/src/mesh/store.ts +297 -0
- package/apps/bridge/src/mesh/worktree.ts +38 -0
- package/apps/bridge/src/monitor.ts +68 -9
- package/apps/bridge/src/reply/README.md +4 -0
- package/apps/bridge/src/reply/provider-headless.ts +96 -0
- package/apps/bridge/src/reply.ts +35 -2
- package/apps/bridge/src/session-keys.ts +23 -1
- package/apps/bridge/src/sessions/common.ts +3 -5
- package/apps/bridge/src/sessions/grok.ts +179 -0
- package/apps/bridge/src/sessions.ts +11 -13
- package/apps/mcp/src/bin/status.ts +3 -1
- package/apps/mcp/src/create-server.ts +3 -1
- package/apps/mcp/src/mcp-tools/README.md +11 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +102 -9
- package/apps/mcp/src/mcp-tools/mesh-actions.ts +224 -0
- package/apps/mcp/src/mcp-tools/mesh-relay.ts +66 -0
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +31 -0
- package/apps/mcp/src/mcp-tools/mesh-tools.ts +103 -0
- package/apps/mcp/src/mesh-server.ts +13 -0
- package/apps/mcp/src/mesh-stdio.ts +5 -0
- package/apps/mcp/src/oauth/consent-page.ts +5 -2
- package/apps/mcp/src/provider-status.ts +19 -4
- package/bin/granttap-mcp.mjs +8 -1
- package/package.json +4 -5
- package/packages/core/relay-client.ts +3 -2
- package/packages/protocol/messages/README.md +7 -0
- package/packages/protocol/messages/interaction.ts +2 -1
- package/packages/protocol/messages/mesh-endpoint.ts +68 -0
- package/packages/protocol/messages/mesh.ts +247 -0
- package/packages/protocol/messages/primitives.ts +3 -0
- package/packages/protocol/messages/sessions.ts +28 -1
- package/packages/protocol/schema.ts +15 -1
- package/apps/bridge/src/sessions/copilot-capabilities.ts +0 -127
- package/apps/bridge/src/sessions/copilot.ts +0 -283
package/README.md
CHANGED
|
@@ -34,11 +34,47 @@ end-to-end encrypted.
|
|
|
34
34
|
|
|
35
35
|
- Primary: Claude Code and Codex.
|
|
36
36
|
- Beta: Cursor.
|
|
37
|
-
- Experimental where available:
|
|
37
|
+
- Experimental where available: Grok Build.
|
|
38
38
|
|
|
39
39
|
GrantTap reports the depth each provider actually exposes. Visibility does not
|
|
40
40
|
imply deterministic remote blocking or full mobile continuation.
|
|
41
41
|
|
|
42
|
+
## Project Mesh and task handoff
|
|
43
|
+
|
|
44
|
+
Project Mesh adds stable Project and Task identity above provider-native
|
|
45
|
+
sessions. A Task can retain its `taskId` across multiple executions, agents,
|
|
46
|
+
and computers while each native session remains intact.
|
|
47
|
+
|
|
48
|
+
Agents can read the compact `granttap://mesh/current` MCP resource and publish
|
|
49
|
+
bounded progress, dependency, question, answer, claim, conflict, and completion
|
|
50
|
+
events through the existing `notify` tool. Full transcripts and hidden
|
|
51
|
+
reasoning are never mesh payloads. Claims have TTLs; a colliding claim is
|
|
52
|
+
rejected before it is recorded so agents can choose different work or contact
|
|
53
|
+
the owner before escalating to Needs You.
|
|
54
|
+
|
|
55
|
+
The first executable handoff path is Claude Code ↔ Codex across linked
|
|
56
|
+
computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
|
|
57
|
+
requires local phone authorization, creates a separate target branch/worktree,
|
|
58
|
+
starts the target execution, and returns a receipt bound to the exact capsule.
|
|
59
|
+
Cursor and Grok Build use the same provider-neutral schema and discovery model;
|
|
60
|
+
unsupported remote-start paths fail closed instead of claiming parity.
|
|
61
|
+
|
|
62
|
+
### Grok Bot as a scoped Mesh participant
|
|
63
|
+
|
|
64
|
+
Grok Bot is a persistent agent, not a coding-agent integration. The iPhone
|
|
65
|
+
issues a one-time encrypted Mesh Invite scoped to the Projects you select, and
|
|
66
|
+
the invite is redeemed on the trusted CLI:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
granttap mesh connect <one-time-invite>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Grok Bot then runs `granttap internal mesh-mcp`, a separate scoped MCP server
|
|
73
|
+
that exposes only the twelve task-scoped Mesh operations. It cannot create
|
|
74
|
+
invites, change the relay, expand Project scope, or reach `setup`; revoking the
|
|
75
|
+
endpoint from the iPhone stops new Mesh operations immediately while local Task
|
|
76
|
+
history stays on the device.
|
|
77
|
+
|
|
42
78
|
## Install
|
|
43
79
|
|
|
44
80
|
```bash
|
|
@@ -58,6 +94,7 @@ granttap setup
|
|
|
58
94
|
granttap status [--json]
|
|
59
95
|
granttap connect [--relay <wss-url>]
|
|
60
96
|
granttap reset [--yes]
|
|
97
|
+
granttap mesh connect <one-time-invite>
|
|
61
98
|
```
|
|
62
99
|
|
|
63
100
|
`connect` reuses a valid pairing. If none exists, it creates a one-time QR.
|
|
@@ -89,6 +126,9 @@ MCP `connect` accepts no custom routing, replacement, or key-rotation input.
|
|
|
89
126
|
Setup is CLI-only because it changes provider configuration and must not be
|
|
90
127
|
available to a model through prompt injection.
|
|
91
128
|
|
|
129
|
+
`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.
|
|
131
|
+
|
|
92
132
|
Provider-native approvals and mobile continuation require the matching local
|
|
93
133
|
adapter. MCP registration alone is never reported as proof that an integration
|
|
94
134
|
is ready.
|
|
@@ -141,10 +181,19 @@ npm install
|
|
|
141
181
|
npm run typecheck
|
|
142
182
|
npm test
|
|
143
183
|
npm run package:allowlist
|
|
184
|
+
npm run test:coverage # macOS only — see below
|
|
144
185
|
```
|
|
145
186
|
|
|
187
|
+
`npm test` runs everywhere. Both suites run with an isolated `HOME`, so a
|
|
188
|
+
developer's own Claude/Codex/Cursor data can never inflate a local result: the
|
|
189
|
+
numbers on this machine and in CI are the same. The coverage contract is
|
|
190
|
+
measured on macOS because the background helper, the Cursor OAuth service, and
|
|
191
|
+
the installer only execute there, so a Linux percentage understates real
|
|
192
|
+
runtime coverage. CI runs the cross-platform suite on Linux and the coverage
|
|
193
|
+
gate on macOS, and `npm publish` enforces the same gate through
|
|
194
|
+
`prepublishOnly`.
|
|
195
|
+
|
|
146
196
|
Do not publish from a dirty checkout or before the package allowlist, tests,
|
|
147
197
|
typecheck, and release checks pass.
|
|
148
198
|
|
|
149
|
-
GrantTap is not affiliated with Anthropic, OpenAI, Apple,
|
|
150
|
-
GitHub, or xAI.
|
|
199
|
+
GrantTap is not affiliated with Anthropic, OpenAI, Apple, Anysphere, or xAI.
|
package/SECURITY.md
CHANGED
|
@@ -27,6 +27,9 @@ GrantTap does not send them to the relay.
|
|
|
27
27
|
transfer key. The relay receives the mailbox ID and ciphertext, not the key.
|
|
28
28
|
- Each attached task has an independent 256-bit task key. One task key cannot
|
|
29
29
|
decrypt another task.
|
|
30
|
+
- Project Mesh snapshots have independent project keys. Cross-computer events
|
|
31
|
+
remain under their Task key, and a linked destination receives either key
|
|
32
|
+
only through an explicit phone-mediated route.
|
|
30
33
|
- Task keys travel only inside the authenticated device channel and are stored
|
|
31
34
|
in device-protected storage.
|
|
32
35
|
- Native phone/watch payloads remain authenticated ciphertext across the
|
|
@@ -46,6 +49,14 @@ whose independent keys were granted to it. The protection is against relay,
|
|
|
46
49
|
database/network compromise, other pairings, and disclosure of a different
|
|
47
50
|
task key—not against an endpoint already authorized for that task.
|
|
48
51
|
|
|
52
|
+
Task Capsules are strict, bounded schemas containing goal, explicit status,
|
|
53
|
+
repository/commit facts, changed-file names, tests, dependencies, claims,
|
|
54
|
+
remaining work, and explicit decisions. Unknown fields are rejected; hidden
|
|
55
|
+
reasoning and transcript replication are outside the contract. Handoff
|
|
56
|
+
acceptance includes a SHA-256 receipt over a canonical capsule and the source,
|
|
57
|
+
target, Task identity, and acceptance time. Stale claims expire, replayed event
|
|
58
|
+
IDs are ignored, and destination routing is explicit.
|
|
59
|
+
|
|
49
60
|
## Runtime controls
|
|
50
61
|
|
|
51
62
|
Provider hooks enforce policy on the computer. Global provider deny always
|
|
@@ -57,5 +68,14 @@ background services. The public MCP server can only connect an existing or new
|
|
|
57
68
|
phone pairing, notify, and ask bounded questions; it cannot reconfigure the
|
|
58
69
|
machine or select a custom relay.
|
|
59
70
|
|
|
71
|
+
A Grok Bot endpoint is a separate scoped identity. Its one-time invite is
|
|
72
|
+
created only on the iPhone, stored on the relay as ciphertext, redeemed only by
|
|
73
|
+
the trusted `granttap mesh connect` CLI, and written to disk with `0600`
|
|
74
|
+
permissions. Its MCP server exposes only task-scoped Mesh operations bound to
|
|
75
|
+
one credential: every call re-checks endpoint, credential, policy revision,
|
|
76
|
+
expiry, actor, Project scope, and operation, and a revoked or disabled endpoint
|
|
77
|
+
fails closed. Invite creation, actor enablement, Project scope, and revocation
|
|
78
|
+
stay in the iPhone UI and the trusted CLI, never in a model-callable tool.
|
|
79
|
+
|
|
60
80
|
Relay vulnerabilities should be reported through the
|
|
61
81
|
[GrantTap relay advisory form](https://github.com/sergii-ziborov/granttap-relay/security/advisories/new).
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
autoAcceptLevelFor,
|
|
19
19
|
blockedSessionCapability,
|
|
20
20
|
isGatingSkipped,
|
|
21
|
+
isProviderEnabled,
|
|
21
22
|
loadConfig,
|
|
22
23
|
machineConfigPath,
|
|
23
24
|
shouldAutoAcceptTool,
|
|
@@ -48,6 +49,8 @@ async function main(): Promise<void> {
|
|
|
48
49
|
return;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
if (!isProviderEnabled("claude")) return;
|
|
53
|
+
|
|
51
54
|
const blocked = blockedSessionCapability(
|
|
52
55
|
input.session_id,
|
|
53
56
|
input.tool_name,
|
|
@@ -69,19 +72,6 @@ async function main(): Promise<void> {
|
|
|
69
72
|
// Claude already granted this chat unconditional tool access. GrantTap's
|
|
70
73
|
// explicit MCP/skill/CLI blocks above still win, but phone approval must not
|
|
71
74
|
// re-prompt a call Claude is intentionally running in bypass mode.
|
|
72
|
-
// TEMPORARY DIAGNOSTIC: record only permission-related metadata, never the
|
|
73
|
-
// tool input or any prompt text. Remove once the bypass field name is known.
|
|
74
|
-
try {
|
|
75
|
-
const { appendFileSync } = await import("node:fs");
|
|
76
|
-
appendFileSync("/tmp/granttap-hook-probe.log", JSON.stringify({
|
|
77
|
-
keys: Object.keys(input),
|
|
78
|
-
permission_mode: (input as Record<string, unknown>).permission_mode,
|
|
79
|
-
permissionMode: (input as Record<string, unknown>).permissionMode,
|
|
80
|
-
tool_name: (input as Record<string, unknown>).tool_name,
|
|
81
|
-
session_id: String((input as Record<string, unknown>).session_id ?? "").slice(0, 8),
|
|
82
|
-
}) + "\n");
|
|
83
|
-
} catch { /* diagnostics must never block a tool call */ }
|
|
84
|
-
|
|
85
75
|
if (input.permission_mode === "bypassPermissions") return;
|
|
86
76
|
|
|
87
77
|
// Gating paused, or this session is exempt → abstain (empty output = Claude
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
autoAcceptLevelFor,
|
|
19
19
|
blockedSessionCapability,
|
|
20
20
|
isGatingSkipped,
|
|
21
|
+
isProviderEnabled,
|
|
21
22
|
loadConfig,
|
|
22
23
|
machineConfigPath,
|
|
23
24
|
shouldAutoAcceptTool,
|
|
@@ -49,6 +50,8 @@ async function main(): Promise<void> {
|
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
if (!isProviderEnabled("codex")) return;
|
|
54
|
+
|
|
52
55
|
const blocked = blockedSessionCapability(
|
|
53
56
|
input.session_id,
|
|
54
57
|
input.tool_name,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
/** Deny-only Codex PreToolUse policy for exact per-chat capability switches. */
|
|
3
3
|
import type { HookInput } from "../adapters";
|
|
4
|
-
import { blockedSessionCapability } from "../config";
|
|
4
|
+
import { blockedSessionCapability, isProviderEnabled } from "../config";
|
|
5
5
|
|
|
6
6
|
async function readStdin(): Promise<string> {
|
|
7
7
|
const chunks: Buffer[] = [];
|
|
@@ -10,6 +10,7 @@ async function readStdin(): Promise<string> {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
async function main(): Promise<void> {
|
|
13
|
+
if (!isProviderEnabled("codex")) return;
|
|
13
14
|
let input: HookInput;
|
|
14
15
|
try {
|
|
15
16
|
input = JSON.parse(await readStdin()) as HookInput;
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
resolvedFromOutcome,
|
|
8
8
|
sendApprovalResolved,
|
|
9
9
|
} from "../approval-state";
|
|
10
|
-
import { loadConfig, machineConfigPath } from "../config";
|
|
10
|
+
import { isProviderEnabled, loadConfig, machineConfigPath } from "../config";
|
|
11
11
|
import { cursorRootSessionId } from "../sessions/cursor";
|
|
12
12
|
|
|
13
13
|
const SHELL_TOOLS = new Set([
|
|
@@ -32,6 +32,10 @@ async function readStdin(): Promise<string> {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
async function main(): Promise<void> {
|
|
35
|
+
if (!isProviderEnabled("cursor")) {
|
|
36
|
+
process.stdout.write("{}\n");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
35
39
|
const raw = await readStdin().catch(() => "");
|
|
36
40
|
let input: {
|
|
37
41
|
conversation_id?: string;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
autoAcceptLevelFor,
|
|
11
11
|
blockedSessionCapability,
|
|
12
12
|
isGatingSkipped,
|
|
13
|
+
isProviderEnabled,
|
|
13
14
|
loadConfig,
|
|
14
15
|
machineConfigPath,
|
|
15
16
|
shouldAutoAcceptCursorShell,
|
|
@@ -38,6 +39,10 @@ async function main(): Promise<void> {
|
|
|
38
39
|
nativeAsk("GrantTap could not read this shell call; use Cursor approval.");
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
42
|
+
if (!isProviderEnabled("cursor")) {
|
|
43
|
+
nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
41
46
|
const rawSessionId = input.conversation_id ?? input.session_id;
|
|
42
47
|
const sessionId = cursorRootSessionId(rawSessionId) ?? rawSessionId;
|
|
43
48
|
const blocked = blockedSessionCapability(
|
|
@@ -7,6 +7,7 @@ import { isUnanswered, requestApproval } from "../approval";
|
|
|
7
7
|
import {
|
|
8
8
|
blockedSessionMcpServer,
|
|
9
9
|
isGatingSkipped,
|
|
10
|
+
isProviderEnabled,
|
|
10
11
|
loadConfig,
|
|
11
12
|
machineConfigPath,
|
|
12
13
|
shouldAutoAcceptTool,
|
|
@@ -40,6 +41,10 @@ async function main(): Promise<void> {
|
|
|
40
41
|
nativeAsk("GrantTap could not correlate this MCP call; use Cursor approval.");
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
44
|
+
if (!isProviderEnabled("cursor")) {
|
|
45
|
+
nativeAsk("GrantTap monitoring for Cursor is disabled; use Cursor approval.");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
43
48
|
const rawSessionId = cursorConversationId(input);
|
|
44
49
|
const sessionId = cursorRootSessionId(rawSessionId) ?? rawSessionId;
|
|
45
50
|
const server = resolveCursorMcpServer(input);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { connectGrokBotInvite } from "../mesh/endpoint";
|
|
2
|
+
|
|
3
|
+
function inviteArgument(args: string[]): string {
|
|
4
|
+
if (args.length === 1 && !["--help", "-h"].includes(args[0]!)) return args[0]!;
|
|
5
|
+
process.stderr.write("Usage: granttap mesh connect <one-time-invite>\n");
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const invite = inviteArgument(process.argv.slice(2));
|
|
10
|
+
void connectGrokBotInvite(invite).then((bundle) => {
|
|
11
|
+
process.stdout.write([
|
|
12
|
+
"GrantTap Project Mesh",
|
|
13
|
+
"",
|
|
14
|
+
`Grok Bot endpoint Connected (${bundle.endpoint.displayName})`,
|
|
15
|
+
`Actors ${bundle.actors.length}`,
|
|
16
|
+
`Allowed projects ${bundle.credential.projectIds.length}`,
|
|
17
|
+
"",
|
|
18
|
+
"Configure Grok Bot to run: granttap internal mesh-mcp",
|
|
19
|
+
"The scoped MCP server cannot create invites, change relay, or expand Project access.",
|
|
20
|
+
"",
|
|
21
|
+
].join("\n"));
|
|
22
|
+
}).catch((error: unknown) => {
|
|
23
|
+
process.stderr.write(`[granttap] Grok Bot connection failed: ${
|
|
24
|
+
error instanceof Error ? error.message : String(error)
|
|
25
|
+
}\n`);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
});
|
|
@@ -79,6 +79,7 @@ async function main(): Promise<void> {
|
|
|
79
79
|
? codexHook.status === "manual" ? "Needs attention" : "Needs hook trust"
|
|
80
80
|
: "Not installed"}`,
|
|
81
81
|
`Cursor ${before.cursor.installed ? `Beta · ${cursorReady ? "Authorize in Cursor" : "Needs repair"}` : "Not installed"}`,
|
|
82
|
+
`Grok Build ${installed.has("grok") ? "Ready" : "Not installed"}`,
|
|
82
83
|
"",
|
|
83
84
|
paired && installed.has("codex")
|
|
84
85
|
? `Next: ${CODEX_TRUST_INSTRUCTION}`
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AgentAccess,
|
|
4
|
+
CodingAgent,
|
|
5
|
+
ProviderRuntimeSettings,
|
|
6
|
+
} from "../../../../packages/protocol/schema";
|
|
3
7
|
import {
|
|
4
8
|
classifyAction,
|
|
5
9
|
isAutoAcceptLevel,
|
|
@@ -19,6 +23,8 @@ export type RuntimeConfig = {
|
|
|
19
23
|
sessionMcpDisabled: Record<string, string[]>;
|
|
20
24
|
sessionSkillsDisabled: Record<string, string[]>;
|
|
21
25
|
sessionShellDisabled: string[];
|
|
26
|
+
providerSettings: ProviderRuntimeSettings;
|
|
27
|
+
meshEnabled: boolean;
|
|
22
28
|
};
|
|
23
29
|
|
|
24
30
|
const DEFAULT_RUNTIME: RuntimeConfig = {
|
|
@@ -31,8 +37,23 @@ const DEFAULT_RUNTIME: RuntimeConfig = {
|
|
|
31
37
|
sessionMcpDisabled: {},
|
|
32
38
|
sessionSkillsDisabled: {},
|
|
33
39
|
sessionShellDisabled: [],
|
|
40
|
+
providerSettings: { claude: true, codex: true, cursor: true, grok: true },
|
|
41
|
+
meshEnabled: true,
|
|
34
42
|
};
|
|
35
43
|
|
|
44
|
+
function parseProviderSettings(raw: unknown): ProviderRuntimeSettings {
|
|
45
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
46
|
+
return { ...DEFAULT_RUNTIME.providerSettings };
|
|
47
|
+
}
|
|
48
|
+
const value = raw as Record<string, unknown>;
|
|
49
|
+
return {
|
|
50
|
+
claude: value.claude !== false,
|
|
51
|
+
codex: value.codex !== false,
|
|
52
|
+
cursor: value.cursor !== false,
|
|
53
|
+
grok: value.grok !== false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
export function boundedIdentifier(raw: unknown, maxLength: number): string | null {
|
|
37
58
|
if (typeof raw !== "string") return null;
|
|
38
59
|
const value = raw.trim();
|
|
@@ -96,6 +117,8 @@ export function loadRuntimeConfig(): RuntimeConfig {
|
|
|
96
117
|
.filter((sessionId): sessionId is string => sessionId != null),
|
|
97
118
|
)]
|
|
98
119
|
: [],
|
|
120
|
+
providerSettings: parseProviderSettings(raw.providerSettings),
|
|
121
|
+
meshEnabled: raw.meshEnabled !== false,
|
|
99
122
|
};
|
|
100
123
|
} catch {
|
|
101
124
|
return {
|
|
@@ -105,10 +128,20 @@ export function loadRuntimeConfig(): RuntimeConfig {
|
|
|
105
128
|
sessionMcpDisabled: {},
|
|
106
129
|
sessionSkillsDisabled: {},
|
|
107
130
|
sessionShellDisabled: [],
|
|
131
|
+
providerSettings: { ...DEFAULT_RUNTIME.providerSettings },
|
|
132
|
+
meshEnabled: true,
|
|
108
133
|
};
|
|
109
134
|
}
|
|
110
135
|
}
|
|
111
136
|
|
|
137
|
+
export function isProviderEnabled(provider: CodingAgent): boolean {
|
|
138
|
+
return loadRuntimeConfig().providerSettings[provider];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isMeshEnabled(): boolean {
|
|
142
|
+
return loadRuntimeConfig().meshEnabled;
|
|
143
|
+
}
|
|
144
|
+
|
|
112
145
|
export function saveRuntimeConfig(cfg: Partial<RuntimeConfig>): void {
|
|
113
146
|
mkdirSync(configDir(), { recursive: true });
|
|
114
147
|
const merged: RuntimeConfig = { ...loadRuntimeConfig(), ...cfg };
|
|
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import { delimiter, dirname, join } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
|
+
import type { CodingAgent } from "../../../packages/protocol/schema";
|
|
25
26
|
import { configDir } from "./config";
|
|
26
27
|
|
|
27
28
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
@@ -54,7 +55,7 @@ export const CODEX_TRUST_INSTRUCTION =
|
|
|
54
55
|
"Open /hooks in Codex, review and trust both GrantTap hooks, then restart Codex.";
|
|
55
56
|
|
|
56
57
|
export type AgentIntegrationStatus = {
|
|
57
|
-
agent:
|
|
58
|
+
agent: CodingAgent;
|
|
58
59
|
installed: boolean;
|
|
59
60
|
hookConfigured: boolean;
|
|
60
61
|
};
|
|
@@ -242,9 +243,16 @@ export function inspectCursorIntegration(): CursorIntegrationStatus {
|
|
|
242
243
|
export function inspectAgentIntegrations(): AgentIntegrationStatus[] {
|
|
243
244
|
const codex = process.env.GRANTTAP_CODEX_BIN ?? process.env.NODVOX_CODEX_BIN ?? "codex";
|
|
244
245
|
const claude = process.env.GRANTTAP_CLAUDE_BIN ?? process.env.NODVOX_CLAUDE_BIN ?? "claude";
|
|
246
|
+
const cursor = process.env.GRANTTAP_CURSOR_AGENT_BIN ?? "cursor-agent";
|
|
247
|
+
const grok = process.env.GRANTTAP_GROK_BIN ?? "grok";
|
|
248
|
+
const cursorStatus = inspectCursorIntegration();
|
|
245
249
|
return [
|
|
246
250
|
{ agent: "codex", installed: executableAvailable(codex), hookConfigured: codexHookConfigured() },
|
|
247
251
|
{ agent: "claude", installed: executableAvailable(claude), hookConfigured: claudeHookConfigured() },
|
|
252
|
+
{ agent: "cursor", installed: executableAvailable(cursor), hookConfigured: cursorStatus.hookConfigured },
|
|
253
|
+
// Grok Build's headless session contract is direct; GrantTap does not
|
|
254
|
+
// claim an approval hook exists when none has been installed.
|
|
255
|
+
{ agent: "grok", installed: executableAvailable(grok), hookConfigured: false },
|
|
248
256
|
];
|
|
249
257
|
}
|
|
250
258
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Project Mesh runtime
|
|
2
|
+
|
|
3
|
+
This module links discovered native sessions to stable Project and Task
|
|
4
|
+
identities, persists bounded structured events, tracks expiring resource
|
|
5
|
+
claims and dependencies, and executes authorized task handoffs.
|
|
6
|
+
|
|
7
|
+
`runtime.ts` is the public runtime entry point. `catalog.ts` derives repository
|
|
8
|
+
identity without replacing provider-native session discovery. `store.ts`
|
|
9
|
+
deduplicates replayed events and expires stale claims. `capsule.ts` reads only
|
|
10
|
+
explicit git and task facts; transcripts and hidden reasoning are never copied.
|
|
11
|
+
`worktree.ts` creates a separate target branch/worktree before Claude Code or
|
|
12
|
+
Codex is started. The provider abstraction already admits Cursor and Grok Build
|
|
13
|
+
without changing the core contract, while their unsupported phase-one start
|
|
14
|
+
paths fail closed.
|
|
15
|
+
|
|
16
|
+
The read-only MCP resource `granttap://mesh/current` exposes compact local state
|
|
17
|
+
to an agent. Structured events use the existing `notify` tool so the public MCP
|
|
18
|
+
tool allowlist remains exactly `connect`, `notify`, `ask_yes_no`, and `ask`.
|
|
19
|
+
Setup and global provider configuration remain CLI-only.
|
|
20
|
+
|
|
21
|
+
Every event is schema-bounded and sent under an independent task key; snapshots
|
|
22
|
+
use an independent project key. Resource claims are advisory, expire without a
|
|
23
|
+
heartbeat, and a colliding claim is rejected before it is recorded. The agent
|
|
24
|
+
may choose other work or ask the current owner; the conflict reaches the human
|
|
25
|
+
only after explicit `needsUser` escalation.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MeshEventType } from "../../../../packages/protocol/schema";
|
|
2
|
+
|
|
3
|
+
type AttentionFacts = {
|
|
4
|
+
category?: unknown;
|
|
5
|
+
resolved?: unknown;
|
|
6
|
+
needsUser?: unknown;
|
|
7
|
+
failed?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Only deterministic, product-approved reasons may enter the human inbox. */
|
|
11
|
+
export function classifyHumanAttention(type: MeshEventType, facts: AttentionFacts): boolean {
|
|
12
|
+
if (type === "AGENT_QUESTION") {
|
|
13
|
+
return ["product", "business", "security", "destructive"].includes(String(facts.category));
|
|
14
|
+
}
|
|
15
|
+
if (type === "CONFLICT") return facts.resolved !== true && facts.needsUser === true;
|
|
16
|
+
if (type === "TASK_BLOCKED") return facts.needsUser === true;
|
|
17
|
+
if (type === "HANDOFF_REJECTED") return facts.failed === true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
TaskCapsule,
|
|
5
|
+
type MeshHandoffPrepare,
|
|
6
|
+
type SessionInfo,
|
|
7
|
+
type TaskCapsule as CapsuleValue,
|
|
8
|
+
} from "../../../../packages/protocol/schema";
|
|
9
|
+
import type { MeshStore } from "./store";
|
|
10
|
+
|
|
11
|
+
function git(cwd: string, args: string[]): string | undefined {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
14
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3_000,
|
|
15
|
+
maxBuffer: 512 * 1_024,
|
|
16
|
+
}).trim() || undefined;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gitRaw(cwd: string, args: string[]): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
25
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3_000,
|
|
26
|
+
maxBuffer: 512 * 1_024,
|
|
27
|
+
}) || undefined;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function changedFiles(cwd: string): string[] {
|
|
34
|
+
const status = gitRaw(cwd, ["status", "--porcelain=v1", "-z"]);
|
|
35
|
+
if (!status) return [];
|
|
36
|
+
const files = status.split("\0").flatMap((row) => {
|
|
37
|
+
const value = row.length > 3 ? row.slice(3).trim() : "";
|
|
38
|
+
return value ? [value.split(" -> ").at(-1)!] : [];
|
|
39
|
+
});
|
|
40
|
+
return [...new Set(files)].slice(0, 64);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function dirtyDiffHash(cwd: string): string | undefined {
|
|
44
|
+
const diff = git(cwd, ["diff", "--binary", "HEAD"]);
|
|
45
|
+
return diff ? createHash("sha256").update(diff).digest("hex") : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildTaskCapsule(
|
|
49
|
+
store: MeshStore,
|
|
50
|
+
session: SessionInfo,
|
|
51
|
+
request: MeshHandoffPrepare,
|
|
52
|
+
computerId: string,
|
|
53
|
+
): CapsuleValue | undefined {
|
|
54
|
+
const cwd = session.worktree ?? session.cwd;
|
|
55
|
+
const snapshot = store.snapshot(request.projectId);
|
|
56
|
+
const task = snapshot?.tasks.find((item) => item.taskId === request.taskId);
|
|
57
|
+
const project = snapshot?.project;
|
|
58
|
+
if (!cwd || !task || !project || session.taskId !== request.taskId) return undefined;
|
|
59
|
+
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
60
|
+
if (!head) return undefined;
|
|
61
|
+
const capsule = {
|
|
62
|
+
taskId: request.taskId,
|
|
63
|
+
goal: task.goal,
|
|
64
|
+
currentStatus: session.summary ?? session.title ?? task.title,
|
|
65
|
+
sourceProvider: session.agent,
|
|
66
|
+
sourceComputer: computerId,
|
|
67
|
+
targetProvider: request.targetProvider,
|
|
68
|
+
targetActorId: request.targetActorId,
|
|
69
|
+
targetComputer: request.targetComputer,
|
|
70
|
+
repository: project.canonicalRepositoryId,
|
|
71
|
+
baseSha: head,
|
|
72
|
+
branch: session.branch ?? git(cwd, ["branch", "--show-current"]),
|
|
73
|
+
latestCommit: head,
|
|
74
|
+
dirtyDiffHash: dirtyDiffHash(cwd),
|
|
75
|
+
filesChanged: changedFiles(cwd),
|
|
76
|
+
dependencies: snapshot.dependencies
|
|
77
|
+
.filter((item) => item.taskId === request.taskId).map((item) => item.dependsOnTaskId).slice(0, 32),
|
|
78
|
+
resourceClaims: snapshot.claims
|
|
79
|
+
.filter((item) => item.taskId === request.taskId).map((item) => item.resource).slice(0, 64),
|
|
80
|
+
remainingWork: ["Continue the task from the current committed state."],
|
|
81
|
+
importantDecisions: [],
|
|
82
|
+
createdAt: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
const parsed = TaskCapsule.safeParse(capsule);
|
|
85
|
+
return parsed.success ? parsed.data : undefined;
|
|
86
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
MeshProvider,
|
|
5
|
+
MeshTask,
|
|
6
|
+
Project,
|
|
7
|
+
SessionInfo,
|
|
8
|
+
TaskState,
|
|
9
|
+
} from "../../../../packages/protocol/schema";
|
|
10
|
+
import { canonicalRepositoryIdentity, projectIdentity, taskIdentity } from "./identity";
|
|
11
|
+
import type { MeshStore } from "./store";
|
|
12
|
+
|
|
13
|
+
export type RepositoryFacts = {
|
|
14
|
+
root: string;
|
|
15
|
+
canonicalRepositoryId: string;
|
|
16
|
+
baseRemote?: string;
|
|
17
|
+
worktree?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const repositoryCache = new Map<string, RepositoryFacts>();
|
|
21
|
+
|
|
22
|
+
function git(cwd: string, args: string[]): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
25
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2_000,
|
|
26
|
+
}).trim() || undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function inspectRepository(cwd: string): RepositoryFacts {
|
|
33
|
+
const cached = repositoryCache.get(cwd);
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
const root = git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd;
|
|
36
|
+
const baseRemote = git(root, ["remote", "get-url", "origin"]);
|
|
37
|
+
const facts = {
|
|
38
|
+
root,
|
|
39
|
+
baseRemote,
|
|
40
|
+
canonicalRepositoryId: canonicalRepositoryIdentity(baseRemote, root),
|
|
41
|
+
worktree: git(root, ["rev-parse", "--show-toplevel"]),
|
|
42
|
+
};
|
|
43
|
+
repositoryCache.set(cwd, facts);
|
|
44
|
+
return facts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function provider(value: string): MeshProvider | undefined {
|
|
48
|
+
return ["claude", "codex", "cursor", "grok"].includes(value)
|
|
49
|
+
? value as MeshProvider
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function taskState(session: SessionInfo): TaskState {
|
|
54
|
+
if (session.state === "waiting") return "needs_user";
|
|
55
|
+
if (session.state === "working") return "working";
|
|
56
|
+
return "planned";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function linkSessionsToProjects(
|
|
60
|
+
store: MeshStore,
|
|
61
|
+
sessions: SessionInfo[],
|
|
62
|
+
computerId: string,
|
|
63
|
+
inspect: (cwd: string) => RepositoryFacts = inspectRepository,
|
|
64
|
+
): SessionInfo[] {
|
|
65
|
+
return sessions.map((session) => {
|
|
66
|
+
const agent = provider(session.agent);
|
|
67
|
+
const cwd = session.cwd?.trim();
|
|
68
|
+
if (!agent || !cwd) return session;
|
|
69
|
+
const repository = inspect(cwd);
|
|
70
|
+
const projectId = projectIdentity(repository.canonicalRepositoryId);
|
|
71
|
+
const project: Project = {
|
|
72
|
+
projectId,
|
|
73
|
+
name: basename(repository.root) || "Project",
|
|
74
|
+
repositoryRoot: repository.root,
|
|
75
|
+
canonicalRepositoryId: repository.canonicalRepositoryId,
|
|
76
|
+
baseRemote: repository.baseRemote,
|
|
77
|
+
createdAt: session.startedAt,
|
|
78
|
+
};
|
|
79
|
+
store.upsertProject(project);
|
|
80
|
+
const knownTask = store.taskForExecution(computerId, agent, session.sessionId);
|
|
81
|
+
const taskId = knownTask ?? taskIdentity(projectId, agent, session.sessionId);
|
|
82
|
+
const task: MeshTask = {
|
|
83
|
+
taskId,
|
|
84
|
+
projectId,
|
|
85
|
+
title: (session.title ?? session.summary ?? `${agent} task`).slice(0, 160),
|
|
86
|
+
goal: (session.summary ?? session.title ?? "Continue this coding task").slice(0, 1_000),
|
|
87
|
+
state: taskState(session),
|
|
88
|
+
ownerSessionId: session.sessionId,
|
|
89
|
+
createdAt: session.startedAt,
|
|
90
|
+
updatedAt: session.lastActivityAt,
|
|
91
|
+
};
|
|
92
|
+
store.upsertTask(task);
|
|
93
|
+
store.linkExecution({
|
|
94
|
+
taskId,
|
|
95
|
+
sessionId: session.sessionId,
|
|
96
|
+
provider: agent,
|
|
97
|
+
computerId,
|
|
98
|
+
workspace: cwd,
|
|
99
|
+
branch: session.branch,
|
|
100
|
+
worktree: repository.worktree,
|
|
101
|
+
startedAt: session.startedAt,
|
|
102
|
+
});
|
|
103
|
+
return { ...session, projectId, taskId, computerId, worktree: repository.worktree };
|
|
104
|
+
});
|
|
105
|
+
}
|