granttap-mcp 0.8.1 → 0.8.2
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 +97 -1
- package/apps/bridge/src/bin/claude-hook.ts +8 -0
- package/apps/bridge/src/bin/claude-prompt-hook.ts +40 -0
- package/apps/bridge/src/bin/codex-policy-hook.ts +6 -0
- package/apps/bridge/src/bin/cursor-hook.ts +7 -0
- package/apps/bridge/src/bin/cursor-mcp-hook.ts +7 -0
- package/apps/bridge/src/claude-bin.ts +112 -0
- package/apps/bridge/src/install.ts +48 -7
- package/apps/bridge/src/mesh/capsule.ts +25 -1
- package/apps/bridge/src/mesh/catalog.ts +11 -0
- package/apps/bridge/src/mesh/checkpoint.ts +52 -0
- package/apps/bridge/src/mesh/computer-identity.ts +96 -0
- package/apps/bridge/src/mesh/integration-map.ts +122 -0
- package/apps/bridge/src/mesh/journal.ts +121 -0
- package/apps/bridge/src/mesh/map.ts +141 -0
- package/apps/bridge/src/mesh/observed-claims.ts +50 -0
- package/apps/bridge/src/mesh/observed-writes.ts +73 -0
- package/apps/bridge/src/mesh/other-side.ts +137 -0
- package/apps/bridge/src/mesh/prompt-context.ts +74 -0
- package/apps/bridge/src/mesh/readiness.ts +9 -1
- package/apps/bridge/src/mesh/run-digest.ts +118 -0
- package/apps/bridge/src/mesh/runtime.ts +13 -4
- package/apps/bridge/src/mesh/scoped-view.ts +32 -1
- package/apps/bridge/src/mesh/snapshot-merge.ts +4 -1
- package/apps/bridge/src/mesh/store-state.ts +88 -3
- package/apps/bridge/src/mesh/store-support.ts +39 -0
- package/apps/bridge/src/mesh/store.ts +130 -4
- package/apps/bridge/src/monitor.ts +46 -8
- package/apps/bridge/src/policy/decision-log.ts +57 -0
- package/apps/bridge/src/policy/effective-action.ts +2 -2
- package/apps/bridge/src/reply/process.ts +3 -0
- package/apps/bridge/src/reply.ts +12 -5
- package/apps/bridge/src/sessions/activity-helpers.ts +62 -4
- package/apps/bridge/src/sessions/claude.ts +4 -0
- package/apps/bridge/src/sessions/codex.ts +4 -0
- package/apps/bridge/src/sessions/cursor/transcripts.ts +4 -0
- package/apps/bridge/src/sessions.ts +16 -1
- package/apps/bridge/src/tools/update-handler.ts +23 -0
- package/apps/bridge/src/tools/updater.ts +265 -0
- package/apps/bridge/src/tools/version.ts +113 -0
- package/apps/mcp/src/http-service/common.ts +16 -1
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +23 -0
- package/bin/granttap-mcp.mjs +1 -1
- package/package.json +1 -1
- package/packages/core/relay-client-types.ts +9 -0
- package/packages/core/relay-client.ts +50 -0
- package/packages/protocol/messages/mesh.ts +32 -0
- package/packages/protocol/messages/sessions.ts +32 -0
- package/packages/protocol/schema.ts +4 -0
package/README.md
CHANGED
|
@@ -78,11 +78,34 @@ parity.
|
|
|
78
78
|
A capsule carries facts, not files. A handoff from a checkout with uncommitted
|
|
79
79
|
changes is refused — "This task has uncommitted changes. Commit or checkpoint
|
|
80
80
|
them before moving the task." — instead of silently continuing the Task from
|
|
81
|
-
committed state and leaving that work behind.
|
|
81
|
+
committed state and leaving that work behind. Asked to checkpoint from the
|
|
82
|
+
phone, the source computer commits everything to `granttap/checkpoint/<task>`
|
|
83
|
+
from a temporary index, so HEAD, the current branch, and the working tree stay
|
|
84
|
+
exactly as the agent left them, and the capsule carries that commit. Nothing is
|
|
85
|
+
pushed; the destination says so if the commit has not reached it.
|
|
86
|
+
|
|
87
|
+
Claims do not wait for an agent to announce them. Every edit an agent makes is
|
|
88
|
+
visible in its transcript, so the runtime derives an intent claim from each
|
|
89
|
+
recent write — marked as seen rather than said, and expiring ten minutes after
|
|
90
|
+
the writing stops. Overlap is judged twice: the same file is a conflict, and
|
|
91
|
+
the same module is the warning that comes before it. A module is recognised
|
|
92
|
+
from the path alone, so every computer and the phone reach the same answer,
|
|
93
|
+
and the Task screen names who else is in this Task's files or modules while it
|
|
94
|
+
can still be avoided. The destination is refused just
|
|
82
95
|
as explicitly when the named commit is not on its computer, or when the
|
|
83
96
|
capsule's own resource claims overlap another execution's; GrantTap never
|
|
84
97
|
pushes or fetches on its own.
|
|
85
98
|
|
|
99
|
+
A Project usually binds more than one repository, and a bound repository can
|
|
100
|
+
say which of the others sit on the far side of its databases, topics, and APIs:
|
|
101
|
+
commit a [`WEAVATRIX.md`](https://github.com/Weavatrix/weavatrix-md) next to the
|
|
102
|
+
README and the runtime reads it — only the edges it states, nothing inferred —
|
|
103
|
+
and publishes them with the Project. The Task screen then names another Task
|
|
104
|
+
that is working on the other side of a contract this Task touches (the consumer
|
|
105
|
+
of a topic it produces, the caller of an API it changes), and the scoped
|
|
106
|
+
`granttap://mesh/{capability}` resource gives the agent the same `peers`,
|
|
107
|
+
`otherSide`, and `neighbours` so it can coordinate before it commits.
|
|
108
|
+
|
|
86
109
|
### Grok Bot as a scoped Mesh participant
|
|
87
110
|
|
|
88
111
|
Grok Bot is a persistent agent, not a coding-agent integration. The iPhone
|
|
@@ -182,6 +205,51 @@ The bounded encrypted protocol preserves:
|
|
|
182
205
|
An optional bounded `errorClass` may describe an error category. Full tool
|
|
183
206
|
error payloads are not copied into usage telemetry by default.
|
|
184
207
|
|
|
208
|
+
### One computer, whatever the network calls it
|
|
209
|
+
|
|
210
|
+
The Mesh keys a computer by an identity written down once, on first use, in
|
|
211
|
+
`computer.json` in the config directory — the hostname it had then — and
|
|
212
|
+
keeps it. A Mac renamed by the network it joins ("Mac.lan" at home,
|
|
213
|
+
"Serhiis-MacBook-Pro.local" elsewhere) used to become a second computer with
|
|
214
|
+
its own open executions and repository bindings; now every later hostname is
|
|
215
|
+
remembered as a former name of the same machine, its leftover executions are
|
|
216
|
+
closed and its bindings marked unavailable, and the current hostname stays
|
|
217
|
+
what people see. `GRANTTAP_COMPUTER_ID` overrides the stored id.
|
|
218
|
+
|
|
219
|
+
### Run journal and prompt-time context
|
|
220
|
+
|
|
221
|
+
A message from the phone is answered by a fresh `claude -p --resume` of the
|
|
222
|
+
same chat. Its turns land in the transcript, but a session holding that chat
|
|
223
|
+
open never sees them — its context was built before they happened. The runtime
|
|
224
|
+
therefore journals every delivery: what was asked (without the attachment
|
|
225
|
+
note), what came back, which files were written, how many tool calls it took,
|
|
226
|
+
and whether the run was cut off by the ten-minute delivery limit. The Task
|
|
227
|
+
carries the same digest as `TASK_PROGRESS`, so the phone's timeline and the
|
|
228
|
+
Mesh show it. A `UserPromptSubmit` hook, installed beside the approval hook by
|
|
229
|
+
`granttap setup`, adds the unread journal to the next prompt of the live
|
|
230
|
+
session together with the Mesh brief — the other live Tasks in the Project,
|
|
231
|
+
who is in the same file or module, the other side of the repository, and any
|
|
232
|
+
question still unanswered — and names the MCP resource
|
|
233
|
+
`granttap://mesh/{capability}/map`, one page of markdown with the whole
|
|
234
|
+
Project: Tasks, who edits which module, the other side of each repository,
|
|
235
|
+
dependencies, and what just happened. Background runs themselves receive
|
|
236
|
+
nothing from the hook; the journal is kept for the session a person is in.
|
|
237
|
+
|
|
238
|
+
### Tool versions and updates from the phone
|
|
239
|
+
|
|
240
|
+
The status also names each provider's command-line tool as it answers on this
|
|
241
|
+
computer — its version, how it is kept current, and, for Claude Code, whether a
|
|
242
|
+
newer copy already sits on the disk (the Claude desktop app keeps its own; the
|
|
243
|
+
runtime uses the newest one it finds). A tool that lags the rest of the
|
|
244
|
+
environment fails in ways the phone can only report, so the phone can ask this
|
|
245
|
+
computer to update one: the phone names only the tool, and the command is the
|
|
246
|
+
runtime's, fixed by how the tool was installed — `claude update`, `agent
|
|
247
|
+
update`, `grok update`, the npm that owns the tool's prefix, or Homebrew. The
|
|
248
|
+
runtime never downloads a tool itself; a tool installed by an installer script
|
|
249
|
+
is left to a trusted terminal, with the command spelled out in the answer. The
|
|
250
|
+
result — version before and after, the updater's own output — comes back as
|
|
251
|
+
`tool.update.result`.
|
|
252
|
+
|
|
185
253
|
Cost is reported as attributed rather than measured, because that is what it
|
|
186
254
|
is. A call is read back from the transcript once it has finished, so it can
|
|
187
255
|
never be measured directly: an MCP server outlives its calls and is sampled
|
|
@@ -206,6 +274,34 @@ The user-facing approval modes map to the existing runtime policy:
|
|
|
206
274
|
|
|
207
275
|
Legacy custom levels remain compatible but are not part of the primary flow.
|
|
208
276
|
|
|
277
|
+
## Project Governance
|
|
278
|
+
|
|
279
|
+
Capabilities are decided per Project, not per task. A policy names an effect —
|
|
280
|
+
`allow`, `ask`, or `deny` — for each kind (skills, MCP servers, shell and
|
|
281
|
+
scripts, file writes, deploy, network), and may name one capability alone: one
|
|
282
|
+
MCP server can be forbidden without forbidding every server. A named rule wins
|
|
283
|
+
over its kind, and a global deny always wins over a Project.
|
|
284
|
+
|
|
285
|
+
The phone authors the policy and hands it to every Project computer through the
|
|
286
|
+
relay, which holds the encrypted packet until each computer reads its mailbox;
|
|
287
|
+
a computer that was asleep receives it when it returns. Each computer applies
|
|
288
|
+
the policy through the GrantTap Engine, acknowledges the revision it enforces,
|
|
289
|
+
and reports coverage — enforced, observed only, unsupported, or unknown — per
|
|
290
|
+
capability kind, so the phone shows what is actually in force rather than what
|
|
291
|
+
was sent. Revision zero is a Project with no policy yet, and it is reported so
|
|
292
|
+
the first policy can be written.
|
|
293
|
+
|
|
294
|
+
Evaluation happens in the provider hook before the action runs, with a deadline
|
|
295
|
+
long enough for a busy machine to answer. A missed answer falls back to the
|
|
296
|
+
legacy GrantTap gate rather than to a silent allow, and content never crosses
|
|
297
|
+
to the engine: it receives a capability fingerprint, not the command or file.
|
|
298
|
+
|
|
299
|
+
A refusal is said where the action was. Each one is written down for its chat,
|
|
300
|
+
and the timeline carries it as a status row naming the rule and the reason,
|
|
301
|
+
beside the call it stopped. On the phone a rule is written where the need for
|
|
302
|
+
it appears: touch and hold a tool on the Project page, or use the Governance
|
|
303
|
+
menu on a tool's call history, to allow, ask, or deny it for the Project.
|
|
304
|
+
|
|
209
305
|
## Relay boundary
|
|
210
306
|
|
|
211
307
|
Pairing and task keys are generated locally. The relay receives opaque routing
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Fails closed: any error or timeout denies the tool call rather than letting it
|
|
13
13
|
* through unattended — unless GrantTap auto-accept allows it locally.
|
|
14
14
|
*/
|
|
15
|
+
import { recordProjectDecision } from "../policy/decision-log";
|
|
15
16
|
import { claudeToRequest, decisionToClaudeOutput, type HookInput } from "../adapters";
|
|
16
17
|
import { isUnanswered, requestApproval } from "../approval";
|
|
17
18
|
import {
|
|
@@ -82,6 +83,13 @@ async function handlePreToolUse(input: HookInput): Promise<void> {
|
|
|
82
83
|
legacyDenyReason: blocked?.reason,
|
|
83
84
|
});
|
|
84
85
|
if (projectDecision.effect === "deny") {
|
|
86
|
+
// Say so where the action was, not only to the agent.
|
|
87
|
+
if (input.session_id) {
|
|
88
|
+
recordProjectDecision(input.session_id, {
|
|
89
|
+
at: Date.now(), toolName: input.tool_name ?? "tool", reason: projectDecision.reason,
|
|
90
|
+
ruleId: projectDecision.rule_id,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
85
93
|
writePermission("deny", projectDecision.reason);
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code UserPromptSubmit hook entry point.
|
|
4
|
+
*
|
|
5
|
+
* Registered in ~/.claude/settings.json so that when a person submits a
|
|
6
|
+
* prompt, this program adds what the chat could not know by itself: the
|
|
7
|
+
* background runs that answered phone messages in it, and the Mesh brief for
|
|
8
|
+
* its Task. It never blocks a prompt — anything wrong, and it adds nothing.
|
|
9
|
+
*
|
|
10
|
+
* Reads: UserPromptSubmit JSON on stdin ({ session_id, prompt, cwd, ... })
|
|
11
|
+
* Writes: { hookSpecificOutput: { hookEventName, additionalContext } } on stdout
|
|
12
|
+
*/
|
|
13
|
+
import { promptContext } from "../mesh/prompt-context";
|
|
14
|
+
|
|
15
|
+
async function readStdin(): Promise<string> {
|
|
16
|
+
const chunks: Buffer[] = [];
|
|
17
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
18
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function main(): Promise<void> {
|
|
22
|
+
// A background run answering the phone is itself a prompt submission; it is
|
|
23
|
+
// not the live session the journal is kept for.
|
|
24
|
+
if (process.env.GRANTTAP_DELIVERY) return;
|
|
25
|
+
const raw = await readStdin();
|
|
26
|
+
let input: { session_id?: unknown };
|
|
27
|
+
try {
|
|
28
|
+
input = JSON.parse(raw) as { session_id?: unknown };
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const sessionId = typeof input.session_id === "string" ? input.session_id.trim() : "";
|
|
33
|
+
const text = promptContext(sessionId);
|
|
34
|
+
if (!text) return;
|
|
35
|
+
process.stdout.write(JSON.stringify({
|
|
36
|
+
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: text },
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
main().catch(() => undefined).finally(() => process.exit(0));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
/** Codex PreToolUse: deterministic deny or one-shot Project ASK handoff. */
|
|
3
|
+
import { recordProjectDecision } from "../policy/decision-log";
|
|
3
4
|
import type { HookInput } from "../adapters";
|
|
4
5
|
import { blockedSessionCapability, isProviderEnabled } from "../config";
|
|
5
6
|
import { recordCodexProjectAsk } from "../policy/codex-project-ask";
|
|
@@ -39,6 +40,11 @@ async function main(): Promise<void> {
|
|
|
39
40
|
legacyDenyReason: blocked?.reason,
|
|
40
41
|
});
|
|
41
42
|
if (decision.effect === "deny") {
|
|
43
|
+
if (input.session_id) {
|
|
44
|
+
recordProjectDecision(input.session_id, {
|
|
45
|
+
at: Date.now(), toolName: input.tool_name ?? "tool", reason: decision.reason, ruleId: decision.rule_id,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
42
48
|
deny(decision.reason);
|
|
43
49
|
return;
|
|
44
50
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
/** Cursor beforeShellExecution: exact chat block, otherwise phone/native approval. */
|
|
3
|
+
import { recordProjectDecision } from "../policy/decision-log";
|
|
3
4
|
import {
|
|
4
5
|
cursorToRequest,
|
|
5
6
|
decisionToCursorOutput,
|
|
@@ -83,6 +84,12 @@ async function handleShell(input: CursorHookInput): Promise<void> {
|
|
|
83
84
|
legacyDenyReason: blocked?.reason,
|
|
84
85
|
});
|
|
85
86
|
if (projectDecision.effect === "deny") {
|
|
87
|
+
if (sessionId) {
|
|
88
|
+
recordProjectDecision(sessionId, {
|
|
89
|
+
at: Date.now(), toolName: input.tool_name ?? "Shell",
|
|
90
|
+
reason: projectDecision.reason, ruleId: projectDecision.rule_id,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
86
93
|
deny(projectDecision.reason);
|
|
87
94
|
return;
|
|
88
95
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
/** Cursor beforeMCPExecution: exact server/chat block, otherwise phone/native approval. */
|
|
3
|
+
import { recordProjectDecision } from "../policy/decision-log";
|
|
3
4
|
import { randomId } from "../../../../packages/core/crypto";
|
|
4
5
|
import type { ApprovalRequest } from "../../../../packages/protocol/schema";
|
|
5
6
|
import { decisionToCursorOutput } from "../adapters";
|
|
@@ -97,6 +98,12 @@ async function handleMcp(input: CursorMcpHookInput): Promise<void> {
|
|
|
97
98
|
legacyDenyReason: blocked?.reason,
|
|
98
99
|
});
|
|
99
100
|
if (projectDecision.effect === "deny") {
|
|
101
|
+
if (sessionId) {
|
|
102
|
+
recordProjectDecision(sessionId, {
|
|
103
|
+
at: Date.now(), toolName: server ? `mcp__${server}__${toolName}` : toolName,
|
|
104
|
+
reason: projectDecision.reason, ruleId: projectDecision.rule_id,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
100
107
|
deny(projectDecision.reason);
|
|
101
108
|
return;
|
|
102
109
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Claude Code answers the phone.
|
|
3
|
+
*
|
|
4
|
+
* A chat the user opened in the Claude desktop app was written by the Claude
|
|
5
|
+
* Code the app ships, and an older `claude` on PATH cannot resume it: the API
|
|
6
|
+
* refuses with "Claude Code X does not support model version Y". The app keeps
|
|
7
|
+
* its own copies of Claude Code on this Mac, and the native installer keeps
|
|
8
|
+
* every version it ever installed, so the newest one present is the one to
|
|
9
|
+
* use. Nothing is downloaded or updated here — the helper never changes the
|
|
10
|
+
* user's tools — only chosen. An explicit `GRANTTAP_CLAUDE_BIN` still wins.
|
|
11
|
+
*/
|
|
12
|
+
import { accessSync, constants, readdirSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { delimiter, join } from "node:path";
|
|
15
|
+
import { binaryVersion } from "./tools/version";
|
|
16
|
+
|
|
17
|
+
export type ClaudeBinary = { path: string; version?: string };
|
|
18
|
+
|
|
19
|
+
function executable(path: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
accessSync(path, constants.X_OK);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `2.1.260` from an installer's directory name such as `2.1.260`. */
|
|
29
|
+
export function versionOf(segment: string): string | undefined {
|
|
30
|
+
return /^v?(\d+\.\d+\.\d+)$/.exec(segment.trim())?.[1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function compareVersions(left: string, right: string): number {
|
|
34
|
+
const a = left.split(".").map(Number);
|
|
35
|
+
const b = right.split(".").map(Number);
|
|
36
|
+
for (let index = 0; index < 3; index += 1) {
|
|
37
|
+
const delta = (a[index] ?? 0) - (b[index] ?? 0);
|
|
38
|
+
if (delta !== 0) return delta;
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function executableOnPath(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
44
|
+
for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
45
|
+
const candidate = join(dir, command);
|
|
46
|
+
if (executable(candidate)) return candidate;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function versioned(path: string): ClaudeBinary {
|
|
52
|
+
// The native installer and the app state the version in the path; any other
|
|
53
|
+
// install is asked once and remembered until the binary changes.
|
|
54
|
+
const version = binaryVersion(path);
|
|
55
|
+
return version ? { path, version } : { path };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function children(dir: string): string[] {
|
|
59
|
+
try {
|
|
60
|
+
return readdirSync(dir);
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every Claude Code this Mac keeps, newest first; PATH's own `claude` included. */
|
|
67
|
+
export function installedClaudeBinaries(
|
|
68
|
+
home = homedir(),
|
|
69
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
70
|
+
): ClaudeBinary[] {
|
|
71
|
+
const found: ClaudeBinary[] = [];
|
|
72
|
+
const fromPath = executableOnPath("claude", env);
|
|
73
|
+
if (fromPath) found.push(versioned(fromPath));
|
|
74
|
+
const installer = join(home, ".local", "share", "claude", "versions");
|
|
75
|
+
for (const name of children(installer)) {
|
|
76
|
+
const path = join(installer, name);
|
|
77
|
+
if (versionOf(name) && executable(path)) found.push({ path, version: versionOf(name) });
|
|
78
|
+
}
|
|
79
|
+
const app = join(home, "Library", "Application Support", "Claude", "claude-code");
|
|
80
|
+
for (const name of children(app)) {
|
|
81
|
+
const path = join(app, name, "claude.app", "Contents", "MacOS", "claude");
|
|
82
|
+
if (versionOf(name) && executable(path)) found.push({ path, version: versionOf(name) });
|
|
83
|
+
}
|
|
84
|
+
return found.sort((left, right) => {
|
|
85
|
+
if (left.version && right.version) return compareVersions(right.version, left.version);
|
|
86
|
+
return left.version ? -1 : right.version ? 1 : 0;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The Claude Code to run: the explicit override, else the newest installed,
|
|
92
|
+
* else plain `claude` for PATH to resolve.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveClaudeBinary(
|
|
95
|
+
home = homedir(),
|
|
96
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
97
|
+
): ClaudeBinary {
|
|
98
|
+
const override = env.GRANTTAP_CLAUDE_BIN ?? env.NODVOX_CLAUDE_BIN;
|
|
99
|
+
if (override) return versioned(override);
|
|
100
|
+
return installedClaudeBinaries(home, env)[0] ?? { path: "claude" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The failure a too-old Claude Code reports, with the way out attached: the
|
|
105
|
+
* version that answered, and the one command that replaces it.
|
|
106
|
+
*/
|
|
107
|
+
export function explainClaudeFailure(error: string, binary: ClaudeBinary): string {
|
|
108
|
+
if (!/does not support|not supported|outdated|update claude code|upgrade/i.test(error)) return error;
|
|
109
|
+
const which = binary.version ? `Claude Code ${binary.version}` : "This Claude Code";
|
|
110
|
+
return `${error.trim()} — ${which} at ${binary.path} is older than this chat needs. `
|
|
111
|
+
+ "Run `claude update` on this Mac, or open the chat in the Claude app once so its newer Claude Code is present.";
|
|
112
|
+
}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
readFileSync,
|
|
19
19
|
writeFileSync,
|
|
20
20
|
} from "node:fs";
|
|
21
|
+
import { resolveClaudeBinary } from "./claude-bin";
|
|
22
|
+
import { describeCommand, inspectTools, updatingTools } from "./tools/updater";
|
|
21
23
|
import { resolveCursorAgentBin } from "./reply/cursor-agent-bin";
|
|
22
24
|
import { homedir } from "node:os";
|
|
23
25
|
import { delimiter, dirname, join } from "node:path";
|
|
@@ -31,6 +33,7 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..")
|
|
|
31
33
|
|
|
32
34
|
export type HookRoute =
|
|
33
35
|
| "claude"
|
|
36
|
+
| "claude-prompt"
|
|
34
37
|
| "codex"
|
|
35
38
|
| "codex-policy"
|
|
36
39
|
| "cursor"
|
|
@@ -60,6 +63,10 @@ export type AgentIntegrationStatus = {
|
|
|
60
63
|
agent: CodingAgent;
|
|
61
64
|
installed: boolean;
|
|
62
65
|
hookConfigured: boolean;
|
|
66
|
+
version?: string;
|
|
67
|
+
updateCommand?: string;
|
|
68
|
+
newerOnThisMac?: string;
|
|
69
|
+
updating?: boolean;
|
|
63
70
|
};
|
|
64
71
|
|
|
65
72
|
function executableAvailable(command: string): boolean {
|
|
@@ -244,17 +251,28 @@ export function inspectCursorIntegration(): CursorIntegrationStatus {
|
|
|
244
251
|
/** Read-only capability check used by phone/watch connection states. */
|
|
245
252
|
export function inspectAgentIntegrations(): AgentIntegrationStatus[] {
|
|
246
253
|
const codex = process.env.GRANTTAP_CODEX_BIN ?? process.env.NODVOX_CODEX_BIN ?? "codex";
|
|
247
|
-
const claude =
|
|
254
|
+
const claude = resolveClaudeBinary().path;
|
|
248
255
|
const cursor = process.env.GRANTTAP_CURSOR_AGENT_BIN ?? resolveCursorAgentBin();
|
|
249
256
|
const grok = process.env.GRANTTAP_GROK_BIN ?? "grok";
|
|
250
257
|
const cursorStatus = inspectCursorIntegration();
|
|
258
|
+
const tools = new Map(inspectTools().map((tool) => [tool.agent, tool]));
|
|
259
|
+
const updating = new Set(updatingTools());
|
|
260
|
+
const tool = (agent: CodingAgent): Partial<AgentIntegrationStatus> => {
|
|
261
|
+
const status = tools.get(agent);
|
|
262
|
+
const extra: Partial<AgentIntegrationStatus> = {};
|
|
263
|
+
if (status?.version) extra.version = status.version;
|
|
264
|
+
if (status?.update) extra.updateCommand = describeCommand(status.update);
|
|
265
|
+
if (status?.newerOnThisMac) extra.newerOnThisMac = status.newerOnThisMac;
|
|
266
|
+
if (updating.has(agent)) extra.updating = true;
|
|
267
|
+
return extra;
|
|
268
|
+
};
|
|
251
269
|
return [
|
|
252
|
-
{ agent: "codex", installed: executableAvailable(codex), hookConfigured: codexHookConfigured() },
|
|
253
|
-
{ agent: "claude", installed: executableAvailable(claude), hookConfigured: claudeHookConfigured() },
|
|
254
|
-
{ agent: "cursor", installed: executableAvailable(cursor), hookConfigured: cursorStatus.hookConfigured },
|
|
270
|
+
{ agent: "codex", installed: executableAvailable(codex), hookConfigured: codexHookConfigured(), ...tool("codex") },
|
|
271
|
+
{ agent: "claude", installed: executableAvailable(claude), hookConfigured: claudeHookConfigured(), ...tool("claude") },
|
|
272
|
+
{ agent: "cursor", installed: executableAvailable(cursor), hookConfigured: cursorStatus.hookConfigured, ...tool("cursor") },
|
|
255
273
|
// Grok Build's headless session contract is direct; GrantTap does not
|
|
256
274
|
// claim an approval hook exists when none has been installed.
|
|
257
|
-
{ agent: "grok", installed: executableAvailable(grok), hookConfigured: false },
|
|
275
|
+
{ agent: "grok", installed: executableAvailable(grok), hookConfigured: false, ...tool("grok") },
|
|
258
276
|
];
|
|
259
277
|
}
|
|
260
278
|
|
|
@@ -550,6 +568,10 @@ export function installClaudeHook(): InstallResult {
|
|
|
550
568
|
}
|
|
551
569
|
}
|
|
552
570
|
|
|
571
|
+
// The prompt hook rides along with the approval hook: what the chat could
|
|
572
|
+
// not know by itself — background runs in it, the Mesh around it — is added
|
|
573
|
+
// to each prompt. It never blocks; anything wrong, and it adds nothing.
|
|
574
|
+
const promptHookChanged = ensureClaudePromptHook(settings);
|
|
553
575
|
const entries: any[] = (((settings.hooks ??= {}).PreToolUse ??= []) as any[]);
|
|
554
576
|
const currentCommand = hookCommand("claude");
|
|
555
577
|
const presentEntry = entries.find((entry) =>
|
|
@@ -563,11 +585,14 @@ export function installClaudeHook(): InstallResult {
|
|
|
563
585
|
if (present && presentEntry) {
|
|
564
586
|
const matcherChanged = repairClaudeMatcher(presentEntry);
|
|
565
587
|
const commandChanged = present.command !== currentCommand;
|
|
566
|
-
if (!matcherChanged && !commandChanged) return { status: "already", detail: path };
|
|
588
|
+
if (!matcherChanged && !commandChanged && !promptHookChanged) return { status: "already", detail: path };
|
|
567
589
|
backupOnce(path);
|
|
568
590
|
present.command = currentCommand;
|
|
569
591
|
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
|
|
570
|
-
|
|
592
|
+
const detail = !matcherChanged && !commandChanged
|
|
593
|
+
? `${path}, GrantTap prompt hook added`
|
|
594
|
+
: `${path}, GrantTap hook and matcher repaired`;
|
|
595
|
+
return { status: "installed", detail };
|
|
571
596
|
}
|
|
572
597
|
|
|
573
598
|
const legacyEntry = entries.find((entry) =>
|
|
@@ -597,6 +622,22 @@ export function installClaudeHook(): InstallResult {
|
|
|
597
622
|
return { status: "installed", detail: path };
|
|
598
623
|
}
|
|
599
624
|
|
|
625
|
+
/** Make sure the UserPromptSubmit hook is registered once, at the current command. */
|
|
626
|
+
function ensureClaudePromptHook(settings: any): boolean {
|
|
627
|
+
const entries: any[] = (((settings.hooks ??= {}).UserPromptSubmit ??= []) as any[]);
|
|
628
|
+
const command = hookCommand("claude-prompt");
|
|
629
|
+
const present = entries
|
|
630
|
+
.flatMap((entry) => (Array.isArray(entry?.hooks) ? entry.hooks : []))
|
|
631
|
+
.find((hook: any) => commandHasRoute(hook?.command, "claude-prompt"));
|
|
632
|
+
if (present) {
|
|
633
|
+
if (present.command === command) return false;
|
|
634
|
+
present.command = command;
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
entries.push({ hooks: [{ type: "command", command, timeout: 10 }] });
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
600
641
|
// --------------------------------------------------------------------- Codex
|
|
601
642
|
|
|
602
643
|
function rewriteDisabledCodexHooks(config: string): { config: string; changed: boolean } {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "../../../../packages/protocol/schema";
|
|
9
9
|
import { workingTreeState } from "./catalog";
|
|
10
10
|
import type { MeshStore } from "./store";
|
|
11
|
+
import type { Checkpoint } from "./checkpoint";
|
|
11
12
|
|
|
12
13
|
function git(cwd: string, args: string[]): string | undefined {
|
|
13
14
|
try {
|
|
@@ -51,6 +52,7 @@ export function buildTaskCapsule(
|
|
|
51
52
|
session: SessionInfo,
|
|
52
53
|
request: MeshHandoffPrepare,
|
|
53
54
|
computerId: string,
|
|
55
|
+
checkpoint?: Checkpoint,
|
|
54
56
|
): CapsuleValue | undefined {
|
|
55
57
|
const cwd = session.worktree ?? session.cwd;
|
|
56
58
|
const snapshot = store.snapshot(request.projectId);
|
|
@@ -83,6 +85,28 @@ export function buildTaskCapsule(
|
|
|
83
85
|
importantDecisions: [],
|
|
84
86
|
createdAt: Date.now(),
|
|
85
87
|
};
|
|
86
|
-
|
|
88
|
+
// A checkpoint commit is the fact the capsule carries instead of the dirty
|
|
89
|
+
// tree: it holds every change the tree had, so relative to it the tree is
|
|
90
|
+
// clean. Whether the destination has that commit is its own check.
|
|
91
|
+
const described = checkpoint
|
|
92
|
+
? {
|
|
93
|
+
...capsule,
|
|
94
|
+
latestCommit: checkpoint.sha,
|
|
95
|
+
branch: checkpoint.branch,
|
|
96
|
+
dirtyDiffHash: undefined,
|
|
97
|
+
workingTree: "clean" as const,
|
|
98
|
+
filesChanged: changedFilesBetween(cwd, head, checkpoint.sha),
|
|
99
|
+
remainingWork: [
|
|
100
|
+
`Continue from checkpoint ${checkpoint.sha.slice(0, 12)} on ${checkpoint.branch}.`,
|
|
101
|
+
`Push ${checkpoint.branch} from ${computerId} if the destination does not have it.`,
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
: capsule;
|
|
105
|
+
const parsed = TaskCapsule.safeParse(described);
|
|
87
106
|
return parsed.success ? parsed.data : undefined;
|
|
88
107
|
}
|
|
108
|
+
|
|
109
|
+
function changedFilesBetween(cwd: string, from: string, to: string): string[] {
|
|
110
|
+
const listed = git(cwd, ["diff", "--name-only", `${from}..${to}`]) ?? "";
|
|
111
|
+
return [...new Set(listed.split("\n").map((line) => line.trim()).filter(Boolean))].slice(0, 64);
|
|
112
|
+
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
sanitizedRepositoryRemote,
|
|
15
15
|
taskIdentity,
|
|
16
16
|
} from "./identity";
|
|
17
|
+
import { formerComputerNames } from "./computer-identity";
|
|
18
|
+
import { readIntegrationMap } from "./integration-map";
|
|
17
19
|
import type { MeshStore } from "./store";
|
|
18
20
|
import { syncProjectBinding } from "../engine/engine-projects";
|
|
19
21
|
|
|
@@ -119,7 +121,12 @@ export function linkSessionsToProjects(
|
|
|
119
121
|
sessions: SessionInfo[],
|
|
120
122
|
computerId: string,
|
|
121
123
|
inspect: (cwd: string) => RepositoryFacts = inspectRepository,
|
|
124
|
+
formerNames: string[] = formerComputerNames(),
|
|
122
125
|
): SessionInfo[] {
|
|
126
|
+
// Records kept under a name this computer used to go by are this computer's
|
|
127
|
+
// leftovers, not a second machine: their executions are over and their
|
|
128
|
+
// bindings unavailable.
|
|
129
|
+
store.retireComputerNames(computerId, formerNames);
|
|
123
130
|
// A vanished session never says it ended, so sweep first: an execution left
|
|
124
131
|
// open holds the Task, keeps its old title, and keeps its last state.
|
|
125
132
|
store.closeVanishedExecutions(
|
|
@@ -172,6 +179,9 @@ export function linkSessionsToProjects(
|
|
|
172
179
|
// same pass, which is what an escaping throw did.
|
|
173
180
|
try {
|
|
174
181
|
store.upsertBinding(binding);
|
|
182
|
+
store.recordIntegrationPeers(
|
|
183
|
+
projectId, repository.canonicalRepositoryId, readIntegrationMap(repository.root),
|
|
184
|
+
);
|
|
175
185
|
void syncProjectBinding(project, {
|
|
176
186
|
summary: binding,
|
|
177
187
|
localRoot: repository.root,
|
|
@@ -202,6 +212,7 @@ export function linkSessionsToProjects(
|
|
|
202
212
|
provider: agent,
|
|
203
213
|
computerId,
|
|
204
214
|
workspace: cwd,
|
|
215
|
+
repositoryId: repository.canonicalRepositoryId,
|
|
205
216
|
branch: session.branch,
|
|
206
217
|
worktree: repository.worktree,
|
|
207
218
|
uncommitted: hasUncommittedWork(repository.worktree ?? cwd),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Commit everything uncommitted to a checkpoint branch, touching nothing else.
|
|
8
|
+
*
|
|
9
|
+
* A Task Capsule carries a commit, so uncommitted work used to block the move
|
|
10
|
+
* until the person committed by hand. This makes the commit for them — on a
|
|
11
|
+
* branch of its own, from a temporary index, so HEAD, the current branch, and
|
|
12
|
+
* the working tree are exactly as the agent left them. Nothing is pushed:
|
|
13
|
+
* publishing a branch is the person's decision, and the destination says so
|
|
14
|
+
* if the commit has not reached it yet.
|
|
15
|
+
*/
|
|
16
|
+
export type Checkpoint = { sha: string; branch: string; files: number };
|
|
17
|
+
|
|
18
|
+
export const CHECKPOINT_BRANCH_PREFIX = "granttap/checkpoint/";
|
|
19
|
+
|
|
20
|
+
function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env): string {
|
|
21
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
22
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30_000, env,
|
|
23
|
+
maxBuffer: 16 * 1_024 * 1_024,
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkpointBranchName(taskId: string): string {
|
|
28
|
+
return CHECKPOINT_BRANCH_PREFIX + taskId.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 64);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createCheckpoint(cwd: string, taskId: string, title: string): Checkpoint | undefined {
|
|
32
|
+
const scratch = mkdtempSync(join(tmpdir(), "granttap-checkpoint-"));
|
|
33
|
+
try {
|
|
34
|
+
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
35
|
+
const env = { ...process.env, GIT_INDEX_FILE: join(scratch, "index") };
|
|
36
|
+
// Start the scratch index from HEAD so deletions are recorded too.
|
|
37
|
+
git(cwd, ["read-tree", "HEAD"], env);
|
|
38
|
+
git(cwd, ["add", "-A"], env);
|
|
39
|
+
const tree = git(cwd, ["write-tree"], env);
|
|
40
|
+
if (tree === git(cwd, ["rev-parse", "HEAD^{tree}"])) return undefined; // nothing to keep
|
|
41
|
+
const message = `GrantTap checkpoint: ${title.replace(/\s+/g, " ").slice(0, 120)}`;
|
|
42
|
+
const sha = git(cwd, ["commit-tree", tree, "-p", head, "-m", message], env);
|
|
43
|
+
const branch = checkpointBranchName(taskId);
|
|
44
|
+
git(cwd, ["branch", "-f", branch, sha]);
|
|
45
|
+
const files = git(cwd, ["diff", "--name-only", `${head}..${sha}`]).split("\n").filter(Boolean).length;
|
|
46
|
+
return { sha, branch, files };
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
} finally {
|
|
50
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|