granttap-mcp 0.8.1 → 0.8.3

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 (53) hide show
  1. package/README.md +115 -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/machine-load/index.ts +1 -0
  10. package/apps/bridge/src/machine-load/process-sampler.ts +153 -21
  11. package/apps/bridge/src/mesh/capsule.ts +25 -1
  12. package/apps/bridge/src/mesh/catalog.ts +12 -0
  13. package/apps/bridge/src/mesh/checkpoint.ts +52 -0
  14. package/apps/bridge/src/mesh/computer-identity.ts +96 -0
  15. package/apps/bridge/src/mesh/integration-map.ts +122 -0
  16. package/apps/bridge/src/mesh/journal.ts +121 -0
  17. package/apps/bridge/src/mesh/map.ts +182 -0
  18. package/apps/bridge/src/mesh/observed-claims.ts +50 -0
  19. package/apps/bridge/src/mesh/observed-writes.ts +73 -0
  20. package/apps/bridge/src/mesh/other-side.ts +137 -0
  21. package/apps/bridge/src/mesh/prompt-context.ts +73 -0
  22. package/apps/bridge/src/mesh/readiness.ts +9 -1
  23. package/apps/bridge/src/mesh/run-digest.ts +118 -0
  24. package/apps/bridge/src/mesh/runtime.ts +13 -4
  25. package/apps/bridge/src/mesh/scoped-view.ts +32 -1
  26. package/apps/bridge/src/mesh/snapshot-merge.ts +4 -1
  27. package/apps/bridge/src/mesh/store-state.ts +88 -3
  28. package/apps/bridge/src/mesh/store-support.ts +39 -0
  29. package/apps/bridge/src/mesh/store.ts +130 -4
  30. package/apps/bridge/src/monitor.ts +46 -8
  31. package/apps/bridge/src/policy/decision-log.ts +57 -0
  32. package/apps/bridge/src/policy/effective-action.ts +2 -2
  33. package/apps/bridge/src/reply/process.ts +3 -0
  34. package/apps/bridge/src/reply.ts +12 -5
  35. package/apps/bridge/src/sessions/activity-helpers.ts +62 -4
  36. package/apps/bridge/src/sessions/claude.ts +47 -1
  37. package/apps/bridge/src/sessions/codex.ts +4 -0
  38. package/apps/bridge/src/sessions/cursor/transcripts.ts +4 -0
  39. package/apps/bridge/src/sessions/telemetry.ts +32 -4
  40. package/apps/bridge/src/sessions.ts +16 -1
  41. package/apps/bridge/src/tools/update-handler.ts +23 -0
  42. package/apps/bridge/src/tools/updater.ts +265 -0
  43. package/apps/bridge/src/tools/version.ts +113 -0
  44. package/apps/mcp/src/http-service/common.ts +16 -1
  45. package/apps/mcp/src/mcp-tools/mesh-resource.ts +78 -8
  46. package/bin/granttap-mcp.mjs +1 -1
  47. package/package.json +1 -1
  48. package/packages/core/relay-client-types.ts +9 -0
  49. package/packages/core/relay-client.ts +50 -0
  50. package/packages/protocol/messages/machine.ts +12 -0
  51. package/packages/protocol/messages/mesh.ts +36 -0
  52. package/packages/protocol/messages/sessions.ts +32 -0
  53. 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,69 @@ 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
+ A shell call is named by the command it ran — `npm`, `git`, `xcodebuild` —
209
+ with the tool that ran it kept beside the name, so the usage screen can say
210
+ which tool was slow or failing rather than listing every call as Bash.
211
+
212
+ Machine load attributes to an agent everything the agent started — its
213
+ shells, its node workers, the build a shell ran — found through the process
214
+ tree, and names the heaviest kinds of process it runs (`node` ×19, `zsh` ×3)
215
+ so the phone can say what an agent is doing, not only that it is. The
216
+ executable path and the command line are read separately and joined by pid,
217
+ because a path with a space in it (`~/Library/Application Support/Claude/…`)
218
+ cannot be recovered from a command line split on whitespace.
219
+
220
+ ### One computer, whatever the network calls it
221
+
222
+ The Mesh keys a computer by an identity written down once, on first use, in
223
+ `computer.json` in the config directory — the hostname it had then — and
224
+ keeps it. A Mac renamed by the network it joins ("Mac.lan" at home,
225
+ "Serhiis-MacBook-Pro.local" elsewhere) used to become a second computer with
226
+ its own open executions and repository bindings; now every later hostname is
227
+ remembered as a former name of the same machine, its leftover executions are
228
+ closed and its bindings marked unavailable, and the current hostname stays
229
+ what people see. `GRANTTAP_COMPUTER_ID` overrides the stored id.
230
+
231
+ ### Run journal and prompt-time context
232
+
233
+ A message from the phone is answered by a fresh `claude -p --resume` of the
234
+ same chat. Its turns land in the transcript, but a session holding that chat
235
+ open never sees them — its context was built before they happened. The runtime
236
+ therefore journals every delivery: what was asked (without the attachment
237
+ note), what came back, which files were written, how many tool calls it took,
238
+ and whether the run was cut off by the ten-minute delivery limit. The Task
239
+ carries the same digest as `TASK_PROGRESS`, so the phone's timeline and the
240
+ Mesh show it. A `UserPromptSubmit` hook, installed beside the approval hook by
241
+ `granttap setup`, adds the unread journal to the next prompt of the live
242
+ session together with the Mesh brief — the other live Tasks in the Project,
243
+ who is in the same file or module, the other side of the repository, and any
244
+ question still unanswered — and names the MCP resource `granttap://mesh/map`,
245
+ one page of markdown with the whole Project: Tasks, who edits which module,
246
+ the other side of each repository, dependencies, and what just happened. That
247
+ resource is listed, because Claude Code reads only listed resources, and it is
248
+ scoped without a token: Claude Code starts one MCP server per chat and hands
249
+ it the chat's id in `CLAUDE_CODE_SESSION_ID`, which nothing said over the
250
+ connection can change. `granttap://mesh/current` serves the same chat's scoped
251
+ state there. Other providers keep the tokened `granttap://mesh/{capability}`
252
+ and `…/map` forms from an attributed `notify`. Background runs themselves
253
+ receive nothing from the hook; the journal is kept for the session a person
254
+ is in.
255
+
256
+ ### Tool versions and updates from the phone
257
+
258
+ The status also names each provider's command-line tool as it answers on this
259
+ computer — its version, how it is kept current, and, for Claude Code, whether a
260
+ newer copy already sits on the disk (the Claude desktop app keeps its own; the
261
+ runtime uses the newest one it finds). A tool that lags the rest of the
262
+ environment fails in ways the phone can only report, so the phone can ask this
263
+ computer to update one: the phone names only the tool, and the command is the
264
+ runtime's, fixed by how the tool was installed — `claude update`, `agent
265
+ update`, `grok update`, the npm that owns the tool's prefix, or Homebrew. The
266
+ runtime never downloads a tool itself; a tool installed by an installer script
267
+ is left to a trusted terminal, with the command spelled out in the answer. The
268
+ result — version before and after, the updater's own output — comes back as
269
+ `tool.update.result`.
270
+
185
271
  Cost is reported as attributed rather than measured, because that is what it
186
272
  is. A call is read back from the transcript once it has finished, so it can
187
273
  never be measured directly: an MCP server outlives its calls and is sampled
@@ -206,6 +292,34 @@ The user-facing approval modes map to the existing runtime policy:
206
292
 
207
293
  Legacy custom levels remain compatible but are not part of the primary flow.
208
294
 
295
+ ## Project Governance
296
+
297
+ Capabilities are decided per Project, not per task. A policy names an effect —
298
+ `allow`, `ask`, or `deny` — for each kind (skills, MCP servers, shell and
299
+ scripts, file writes, deploy, network), and may name one capability alone: one
300
+ MCP server can be forbidden without forbidding every server. A named rule wins
301
+ over its kind, and a global deny always wins over a Project.
302
+
303
+ The phone authors the policy and hands it to every Project computer through the
304
+ relay, which holds the encrypted packet until each computer reads its mailbox;
305
+ a computer that was asleep receives it when it returns. Each computer applies
306
+ the policy through the GrantTap Engine, acknowledges the revision it enforces,
307
+ and reports coverage — enforced, observed only, unsupported, or unknown — per
308
+ capability kind, so the phone shows what is actually in force rather than what
309
+ was sent. Revision zero is a Project with no policy yet, and it is reported so
310
+ the first policy can be written.
311
+
312
+ Evaluation happens in the provider hook before the action runs, with a deadline
313
+ long enough for a busy machine to answer. A missed answer falls back to the
314
+ legacy GrantTap gate rather than to a silent allow, and content never crosses
315
+ to the engine: it receives a capability fingerprint, not the command or file.
316
+
317
+ A refusal is said where the action was. Each one is written down for its chat,
318
+ and the timeline carries it as a status row naming the rule and the reason,
319
+ beside the call it stopped. On the phone a rule is written where the need for
320
+ it appears: touch and hold a tool on the Project page, or use the Governance
321
+ menu on a tool's call history, to allow, ask, or deny it for the Project.
322
+
209
323
  ## Relay boundary
210
324
 
211
325
  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 } {
@@ -84,6 +84,7 @@ export function buildMachineLoad(input: {
84
84
  processes: input.processes[agent]?.processes ?? 0,
85
85
  cpuPercent: input.processes[agent]?.cpuPercent ?? 0,
86
86
  memoryBytes: input.processes[agent]?.memoryBytes ?? 0,
87
+ topProcesses: input.processes[agent]?.groups ?? [],
87
88
  sessions: sessionsByAgent.get(agent) ?? 0,
88
89
  scanMs: scanCost[agent]?.durationMs ?? 0,
89
90
  tokensRecent: tokensByAgent.get(agent) ?? 0,