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.
Files changed (49) hide show
  1. package/README.md +97 -1
  2. package/apps/bridge/src/bin/claude-hook.ts +8 -0
  3. package/apps/bridge/src/bin/claude-prompt-hook.ts +40 -0
  4. package/apps/bridge/src/bin/codex-policy-hook.ts +6 -0
  5. package/apps/bridge/src/bin/cursor-hook.ts +7 -0
  6. package/apps/bridge/src/bin/cursor-mcp-hook.ts +7 -0
  7. package/apps/bridge/src/claude-bin.ts +112 -0
  8. package/apps/bridge/src/install.ts +48 -7
  9. package/apps/bridge/src/mesh/capsule.ts +25 -1
  10. package/apps/bridge/src/mesh/catalog.ts +11 -0
  11. package/apps/bridge/src/mesh/checkpoint.ts +52 -0
  12. package/apps/bridge/src/mesh/computer-identity.ts +96 -0
  13. package/apps/bridge/src/mesh/integration-map.ts +122 -0
  14. package/apps/bridge/src/mesh/journal.ts +121 -0
  15. package/apps/bridge/src/mesh/map.ts +141 -0
  16. package/apps/bridge/src/mesh/observed-claims.ts +50 -0
  17. package/apps/bridge/src/mesh/observed-writes.ts +73 -0
  18. package/apps/bridge/src/mesh/other-side.ts +137 -0
  19. package/apps/bridge/src/mesh/prompt-context.ts +74 -0
  20. package/apps/bridge/src/mesh/readiness.ts +9 -1
  21. package/apps/bridge/src/mesh/run-digest.ts +118 -0
  22. package/apps/bridge/src/mesh/runtime.ts +13 -4
  23. package/apps/bridge/src/mesh/scoped-view.ts +32 -1
  24. package/apps/bridge/src/mesh/snapshot-merge.ts +4 -1
  25. package/apps/bridge/src/mesh/store-state.ts +88 -3
  26. package/apps/bridge/src/mesh/store-support.ts +39 -0
  27. package/apps/bridge/src/mesh/store.ts +130 -4
  28. package/apps/bridge/src/monitor.ts +46 -8
  29. package/apps/bridge/src/policy/decision-log.ts +57 -0
  30. package/apps/bridge/src/policy/effective-action.ts +2 -2
  31. package/apps/bridge/src/reply/process.ts +3 -0
  32. package/apps/bridge/src/reply.ts +12 -5
  33. package/apps/bridge/src/sessions/activity-helpers.ts +62 -4
  34. package/apps/bridge/src/sessions/claude.ts +4 -0
  35. package/apps/bridge/src/sessions/codex.ts +4 -0
  36. package/apps/bridge/src/sessions/cursor/transcripts.ts +4 -0
  37. package/apps/bridge/src/sessions.ts +16 -1
  38. package/apps/bridge/src/tools/update-handler.ts +23 -0
  39. package/apps/bridge/src/tools/updater.ts +265 -0
  40. package/apps/bridge/src/tools/version.ts +113 -0
  41. package/apps/mcp/src/http-service/common.ts +16 -1
  42. package/apps/mcp/src/mcp-tools/mesh-resource.ts +23 -0
  43. package/bin/granttap-mcp.mjs +1 -1
  44. package/package.json +1 -1
  45. package/packages/core/relay-client-types.ts +9 -0
  46. package/packages/core/relay-client.ts +50 -0
  47. package/packages/protocol/messages/mesh.ts +32 -0
  48. package/packages/protocol/messages/sessions.ts +32 -0
  49. 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. The destination is refused just
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 = process.env.GRANTTAP_CLAUDE_BIN ?? process.env.NODVOX_CLAUDE_BIN ?? "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
- return { status: "installed", detail: `${path}, GrantTap hook and matcher repaired` };
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
- const parsed = TaskCapsule.safeParse(capsule);
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
+ }