granttap-mcp 0.8.2 → 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.
package/README.md CHANGED
@@ -205,6 +205,18 @@ The bounded encrypted protocol preserves:
205
205
  An optional bounded `errorClass` may describe an error category. Full tool
206
206
  error payloads are not copied into usage telemetry by default.
207
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
+
208
220
  ### One computer, whatever the network calls it
209
221
 
210
222
  The Mesh keys a computer by an identity written down once, on first use, in
@@ -229,11 +241,17 @@ Mesh show it. A `UserPromptSubmit` hook, installed beside the approval hook by
229
241
  `granttap setup`, adds the unread journal to the next prompt of the live
230
242
  session together with the Mesh brief — the other live Tasks in the Project,
231
243
  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.
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.
237
255
 
238
256
  ### Tool versions and updates from the phone
239
257
 
@@ -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,
@@ -5,17 +5,40 @@ const run = promisify(execFile);
5
5
 
6
6
  export type ProcessRow = {
7
7
  pid: number;
8
+ /** The parent, so a shell or a node worker an agent spawned counts as that agent's. */
9
+ ppid?: number;
8
10
  cpuPercent: number;
9
11
  rssBytes: number;
12
+ /** The full command line, arguments included. */
10
13
  command: string;
14
+ /**
15
+ * The executable's own path, from `ps -o comm`. A path with a space in it —
16
+ * `~/Library/Application Support/Claude/…/claude` — cannot be recovered from
17
+ * the command line by splitting on whitespace, which is how the Claude the
18
+ * desktop app runs went unattributed for as long as it did.
19
+ */
20
+ executable?: string;
21
+ };
22
+
23
+ /** One kind of process an agent runs, by executable: `node` ×43, `zsh` ×10. */
24
+ export type ProcessGroup = {
25
+ name: string;
26
+ count: number;
27
+ cpuPercent: number;
28
+ memoryBytes: number;
11
29
  };
12
30
 
13
31
  export type AgentProcessLoad = {
14
32
  processes: number;
15
33
  cpuPercent: number;
16
34
  memoryBytes: number;
35
+ /** The heaviest kinds of process, so "Claude" can be read as what it runs. */
36
+ groups?: ProcessGroup[];
17
37
  };
18
38
 
39
+ const MAX_GROUPS = 8;
40
+ const INTERPRETERS = /^(node|bun|deno|python3?|npx)$/;
41
+
19
42
  const AGENT_EXECUTABLES: ReadonlyArray<readonly [string, readonly string[]]> = [
20
43
  ["claude", ["claude"]],
21
44
  ["codex", ["codex"]],
@@ -23,10 +46,21 @@ const AGENT_EXECUTABLES: ReadonlyArray<readonly [string, readonly string[]]> = [
23
46
  ["grok", ["grok"]],
24
47
  ];
25
48
 
26
- /** Parse macOS ps output while ignoring headings and malformed rows. */
49
+ /** Parse `pid [ppid] pcpu rss remainder` lines while ignoring headings and malformed rows. */
27
50
  export function parsePsOutput(stdout: string): ProcessRow[] {
28
51
  const rows: ProcessRow[] = [];
29
52
  for (const line of stdout.split("\n")) {
53
+ const withParent = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
54
+ if (withParent) {
55
+ rows.push({
56
+ pid: Number(withParent[1]),
57
+ ppid: Number(withParent[2]),
58
+ cpuPercent: Number(withParent[3]),
59
+ rssBytes: Number(withParent[4]) * 1024,
60
+ command: withParent[5]!,
61
+ });
62
+ continue;
63
+ }
30
64
  const match = line.trim().match(/^(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
31
65
  if (!match) continue;
32
66
  rows.push({
@@ -39,23 +73,70 @@ export function parsePsOutput(stdout: string): ProcessRow[] {
39
73
  return rows;
40
74
  }
41
75
 
76
+ /** Parse `pid remainder` lines into a map, for a second listing joined by pid. */
77
+ export function parsePidListing(stdout: string): Map<number, string> {
78
+ const out = new Map<number, string>();
79
+ for (const line of stdout.split("\n")) {
80
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
81
+ if (match) out.set(Number(match[1]), match[2]!);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Rows from the executable listing, with each command line joined in by pid. */
87
+ export function withCommandLines(rows: readonly ProcessRow[], commands: ReadonlyMap<number, string>): ProcessRow[] {
88
+ return rows.map((row) => ({
89
+ ...row,
90
+ executable: row.executable ?? row.command,
91
+ command: commands.get(row.pid) ?? row.command,
92
+ }));
93
+ }
94
+
95
+ /** The executable path of a row: stated, else the command line's first word. */
96
+ export function executableOf(row: Pick<ProcessRow, "command" | "executable">): string {
97
+ return row.executable ?? (row.command.trim().split(/\s+/)[0] ?? "");
98
+ }
99
+
100
+ function leafOf(path: string): string {
101
+ return path.split("/").pop() ?? path;
102
+ }
103
+
104
+ /** What to call a process: the executable's own name, wherever it lives. */
105
+ export function processGroupName(row: Pick<ProcessRow, "command" | "executable"> | string): string {
106
+ const executable = typeof row === "string" ? row : executableOf(row);
107
+ // npm retitles itself "npm run test:coverage"; the kind of process is npm.
108
+ const leaf = leafOf(executable).replace(/^-/, "").split(/\s+/)[0] ?? "";
109
+ return (leaf || "process").slice(0, 64);
110
+ }
111
+
42
112
  /** Exclude desktop chat apps and framework helpers that collide with CLI names. */
43
- function isDesktopAppHelper(command: string): boolean {
44
- if (command.includes("/Contents/Resources/")) return false;
45
- if (command.includes(".app/Contents/Frameworks/")) return true;
46
- return /\/(Claude|ChatGPT)\.app\//.test(command);
113
+ function isDesktopAppHelper(path: string): boolean {
114
+ if (path.includes("/Contents/Resources/")) return false;
115
+ if (path.includes(".app/Contents/Frameworks/")) return true;
116
+ return /\/(Claude|ChatGPT)\.app\//.test(path);
117
+ }
118
+
119
+ /** The arguments after the executable, when the command line repeats it. */
120
+ function argumentsOf(row: Pick<ProcessRow, "command" | "executable">): string[] {
121
+ const command = row.command.trim();
122
+ const executable = row.executable?.trim();
123
+ if (executable && command.startsWith(executable)) {
124
+ return command.slice(executable.length).trim().split(/\s+/).filter(Boolean);
125
+ }
126
+ return command.split(/\s+/).slice(1);
47
127
  }
48
128
 
49
- function agentForCommand(command: string): string | undefined {
50
- const words = command.trim().split(/\s+/);
51
- const program = words[0] ?? "";
52
- const leaf = program.split("/").pop()?.toLowerCase() ?? "";
53
- if (!leaf || isDesktopAppHelper(command)) return undefined;
129
+ function agentForRow(row: Pick<ProcessRow, "command" | "executable">): string | undefined {
130
+ const executable = executableOf(row);
131
+ const leaf = leafOf(executable).toLowerCase();
132
+ if (!leaf || isDesktopAppHelper(executable) || isDesktopAppHelper(row.command)) return undefined;
54
133
  for (const [agent, executables] of AGENT_EXECUTABLES) {
55
134
  if (executables.includes(leaf)) return agent;
56
135
  }
57
- if (/^(node|bun|deno|python3?|npx)$/.test(leaf)) {
58
- const scriptLeaf = (words[1] ?? "").split("/").pop()?.toLowerCase() ?? "";
136
+ if (INTERPRETERS.test(leaf)) {
137
+ const script = argumentsOf(row)[0] ?? "";
138
+ // `codex.js` is codex; the extension is how node was asked, not what ran.
139
+ const scriptLeaf = leafOf(script).toLowerCase().replace(/\.(m?js|cjs|ts|py)$/, "");
59
140
  for (const [agent, executables] of AGENT_EXECUTABLES) {
60
141
  if (executables.includes(scriptLeaf)) return agent;
61
142
  }
@@ -63,32 +144,83 @@ function agentForCommand(command: string): string | undefined {
63
144
  return undefined;
64
145
  }
65
146
 
147
+ /**
148
+ * Which agent a process belongs to: the one it is, or the one that spawned
149
+ * it. A Bash call, a node worker, a search — an agent's work is mostly done
150
+ * by its children, and counting only the agent binary itself read "Claude:
151
+ * one process" while forty of its children were the load.
152
+ */
153
+ function attributeByAncestry(rows: readonly ProcessRow[]): Map<number, string> {
154
+ const direct = new Map<number, string | undefined>();
155
+ const parent = new Map<number, number>();
156
+ for (const row of rows) {
157
+ direct.set(row.pid, agentForRow(row));
158
+ if (row.ppid != null && row.ppid !== row.pid) parent.set(row.pid, row.ppid);
159
+ }
160
+ const resolved = new Map<number, string>();
161
+ for (const row of rows) {
162
+ let pid: number | undefined = row.pid;
163
+ const chain: number[] = [];
164
+ let agent: string | undefined;
165
+ for (let depth = 0; pid != null && depth < 32; depth += 1) {
166
+ const known = resolved.get(pid) ?? direct.get(pid);
167
+ if (known) { agent = known; break; }
168
+ chain.push(pid);
169
+ pid = parent.get(pid);
170
+ }
171
+ if (!agent) continue;
172
+ for (const member of chain) resolved.set(member, agent);
173
+ resolved.set(row.pid, agent);
174
+ }
175
+ return resolved;
176
+ }
177
+
66
178
  export function attributeProcesses(
67
179
  rows: readonly ProcessRow[],
68
180
  ): Record<string, AgentProcessLoad> {
69
181
  const byAgent: Record<string, AgentProcessLoad> = {};
182
+ const groupsByAgent: Record<string, Map<string, ProcessGroup>> = {};
183
+ const owners = attributeByAncestry(rows);
70
184
  for (const row of rows) {
71
- const agent = agentForCommand(row.command);
185
+ const agent = owners.get(row.pid);
72
186
  if (!agent) continue;
73
- const current = byAgent[agent] ?? { processes: 0, cpuPercent: 0, memoryBytes: 0 };
187
+ const current = byAgent[agent] ?? { processes: 0, cpuPercent: 0, memoryBytes: 0, groups: [] };
74
188
  byAgent[agent] = {
75
189
  processes: current.processes + 1,
76
190
  cpuPercent: Math.round((current.cpuPercent + row.cpuPercent) * 100) / 100,
77
191
  memoryBytes: current.memoryBytes + row.rssBytes,
192
+ groups: [],
78
193
  };
194
+ const groups = (groupsByAgent[agent] ??= new Map());
195
+ const name = processGroupName(row);
196
+ const group = groups.get(name) ?? { name, count: 0, cpuPercent: 0, memoryBytes: 0 };
197
+ groups.set(name, {
198
+ name,
199
+ count: group.count + 1,
200
+ cpuPercent: Math.round((group.cpuPercent + row.cpuPercent) * 100) / 100,
201
+ memoryBytes: group.memoryBytes + row.rssBytes,
202
+ });
203
+ }
204
+ for (const [agent, groups] of Object.entries(groupsByAgent)) {
205
+ byAgent[agent]!.groups = [...groups.values()]
206
+ .sort((a, b) => (b.cpuPercent - a.cpuPercent) || (b.memoryBytes - a.memoryBytes) || (b.count - a.count))
207
+ .slice(0, MAX_GROUPS);
79
208
  }
80
209
  return byAgent;
81
210
  }
82
211
 
83
- /** Read process rows asynchronously so sampling cannot starve the relay socket. */
212
+ /**
213
+ * Read process rows asynchronously so sampling cannot starve the relay socket.
214
+ * Two listings: the executable path on its own, then the command line, joined
215
+ * by pid — one listing cannot carry both once a path has a space in it.
216
+ */
84
217
  export async function sampleProcessRows(): Promise<ProcessRow[]> {
85
218
  try {
86
- const { stdout } = await run("ps", ["-Ao", "pid=,pcpu=,rss=,command="], {
87
- encoding: "utf8",
88
- maxBuffer: 4_000_000,
89
- timeout: 5_000,
90
- });
91
- return parsePsOutput(String(stdout));
219
+ const [{ stdout: executables }, { stdout: commands }] = await Promise.all([
220
+ run("ps", ["-Ao", "pid=,ppid=,pcpu=,rss=,comm="], { encoding: "utf8", maxBuffer: 4_000_000, timeout: 5_000 }),
221
+ run("ps", ["-Ao", "pid=,command="], { encoding: "utf8", maxBuffer: 4_000_000, timeout: 5_000 }),
222
+ ]);
223
+ return withCommandLines(parsePsOutput(String(executables)), parsePidListing(String(commands)));
92
224
  } catch {
93
225
  return [];
94
226
  }
@@ -213,6 +213,7 @@ export function linkSessionsToProjects(
213
213
  computerId,
214
214
  workspace: cwd,
215
215
  repositoryId: repository.canonicalRepositoryId,
216
+ activeAt: session.lastActivityAt,
216
217
  branch: session.branch,
217
218
  worktree: repository.worktree,
218
219
  uncommitted: hasUncommittedWork(repository.worktree ?? cwd),
@@ -40,30 +40,65 @@ function taskTitle(snapshot: MeshSnapshot, taskId: string): string {
40
40
  return compactText(snapshot.tasks.find((task) => task.taskId === taskId)?.title ?? taskId, 80);
41
41
  }
42
42
 
43
+ /** An open execution is live when its chat did something within the hour. */
44
+ export const LIVE_WINDOW_MS = 60 * 60_000;
45
+
46
+ /**
47
+ * When the chat last did anything. A computer that predates `activeAt` still
48
+ * says when it last observed the execution, and a record nobody has touched
49
+ * in an hour is not live either way.
50
+ */
51
+ function lastSeen(execution: MeshSnapshot["executions"][number]): number {
52
+ return execution.activeAt ?? execution.updatedAt ?? execution.startedAt;
53
+ }
54
+
55
+ function isLive(execution: MeshSnapshot["executions"][number], now: number): boolean {
56
+ return execution.endedAt == null && now - lastSeen(execution) <= LIVE_WINDOW_MS;
57
+ }
58
+
43
59
  /** Where a Task is being worked right now: its live executions. */
44
- function liveWork(snapshot: MeshSnapshot, taskId: string): MeshSnapshot["executions"] {
45
- return snapshot.executions.filter((item) => item.taskId === taskId && item.endedAt == null);
60
+ function liveWork(snapshot: MeshSnapshot, taskId: string, now = Date.now()): MeshSnapshot["executions"] {
61
+ return snapshot.executions.filter((item) => item.taskId === taskId && isLive(item, now));
62
+ }
63
+
64
+ /** Open but quiet: the chat exists and has not done anything for an hour. */
65
+ function idleWork(snapshot: MeshSnapshot, taskId: string, now = Date.now()): MeshSnapshot["executions"] {
66
+ return snapshot.executions.filter((item) => item.taskId === taskId && item.endedAt == null && !isLive(item, now));
67
+ }
68
+
69
+ function ago(at: number, now: number): string {
70
+ const minutes = Math.max(0, Math.round((now - at) / 60_000));
71
+ if (minutes < 1) return "just now";
72
+ if (minutes < 60) return `${minutes} min ago`;
73
+ const hours = Math.round(minutes / 60);
74
+ return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} d ago`;
46
75
  }
47
76
 
48
77
  /** The whole Project as markdown. */
49
78
  export function meshMap(snapshot: MeshSnapshot, now = Date.now()): string {
50
79
  const lines: string[] = [];
51
- const live = snapshot.executions.filter((item) => item.endedAt == null);
80
+ const live = snapshot.executions.filter((item) => isLive(item, now));
81
+ const idle = snapshot.executions.filter((item) => item.endedAt == null && !isLive(item, now));
52
82
  lines.push(`# Project Mesh — ${snapshot.project.name}`);
53
83
  lines.push("");
54
- lines.push(`_${snapshot.tasks.length} Task${snapshot.tasks.length === 1 ? "" : "s"} · ${live.length} live execution${live.length === 1 ? "" : "s"} · ${new Date(now).toISOString()}_`);
84
+ lines.push(`_${snapshot.tasks.length} Task${snapshot.tasks.length === 1 ? "" : "s"} · ${live.length} live execution${live.length === 1 ? "" : "s"}${idle.length ? ` · ${idle.length} idle` : ""} · ${new Date(now).toISOString()}_`);
55
85
 
56
86
  lines.push("", "## Tasks", "");
57
87
  const tasks = [...snapshot.tasks].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_MAP_TASKS);
58
88
  if (tasks.length === 0) lines.push("- (none)");
59
89
  for (const task of tasks) {
60
- const work = liveWork(snapshot, task.taskId).map((item) => {
90
+ const work = liveWork(snapshot, task.taskId, now).map((item) => {
61
91
  const repository = repositoryName(executionRepository(item, snapshot), snapshot);
62
- return `${item.provider} on ${item.computerId}${repository ? ` in ${repository}` : ""}`;
92
+ const when = item.activeAt != null ? `, active ${ago(item.activeAt, now)}` : "";
93
+ return `${item.provider} on ${item.computerId}${repository ? ` in ${repository}` : ""}${when}`;
63
94
  });
95
+ const quiet = idleWork(snapshot, task.taskId, now);
96
+ const idleNote = work.length === 0 && quiet.length > 0
97
+ ? `; idle${quiet[0] ? ` since ${ago(lastSeen(quiet[0]), now)}` : ""}`
98
+ : "";
64
99
  const latest = [...snapshot.events].reverse().find((event) => event.taskId === task.taskId && eventLine(event));
65
100
  const tail = latest ? ` — ${eventLine(latest)}` : "";
66
- lines.push(`- **${taskTitle(snapshot, task.taskId)}** — ${task.state}${work.length ? `; ${work.join(", ")}` : ""}${tail}`);
101
+ lines.push(`- **${taskTitle(snapshot, task.taskId)}** — ${task.state}${work.length ? `; ${work.join(", ")}` : idleNote}${tail}`);
67
102
  }
68
103
 
69
104
  const byModule = new Map<string, string[]>();
@@ -110,18 +145,24 @@ export function meshMap(snapshot: MeshSnapshot, now = Date.now()): string {
110
145
  }
111
146
 
112
147
  /** The lines of the map that matter to one Task right now; empty when nothing does. */
113
- export function meshBrief(snapshot: MeshSnapshot, taskId: string): string[] {
148
+ export function meshBrief(snapshot: MeshSnapshot, taskId: string, now = Date.now()): string[] {
114
149
  const lines: string[] = [];
115
150
  const others = snapshot.tasks.filter((task) =>
116
- task.taskId !== taskId && liveWork(snapshot, task.taskId).length > 0);
151
+ task.taskId !== taskId && liveWork(snapshot, task.taskId, now).length > 0);
152
+ const idle = snapshot.tasks.filter((task) =>
153
+ task.taskId !== taskId && liveWork(snapshot, task.taskId, now).length === 0 && idleWork(snapshot, task.taskId, now).length > 0);
117
154
  if (others.length > 0) {
118
155
  const named = others.slice(0, 5).map((task) => {
119
- const execution = liveWork(snapshot, task.taskId)[0];
156
+ const execution = liveWork(snapshot, task.taskId, now)[0];
120
157
  const repository = execution ? repositoryName(executionRepository(execution, snapshot), snapshot) : undefined;
121
- return `${taskTitle(snapshot, task.taskId)}${repository ? ` (${repository})` : ""}`;
158
+ const when = execution?.activeAt != null ? `, ${ago(execution.activeAt, now)}` : "";
159
+ return `${taskTitle(snapshot, task.taskId)}${repository ? ` (${repository}${when})` : when ? ` (${when.slice(2)})` : ""}`;
122
160
  });
123
161
  const more = others.length > 5 ? ` (+${others.length - 5})` : "";
124
- lines.push(`Also live in this Project: ${named.join("; ")}${more}.`);
162
+ const quiet = idle.length > 0 ? ` ${idle.length} other chat${idle.length === 1 ? "" : "s"} here ${idle.length === 1 ? "is" : "are"} open but idle.` : "";
163
+ lines.push(`Also active in this Project within the hour: ${named.join("; ")}${more}.${quiet}`);
164
+ } else if (idle.length > 0) {
165
+ lines.push(`${idle.length} other chat${idle.length === 1 ? "" : "s"} in this Project ${idle.length === 1 ? "is" : "are"} open but idle for over an hour.`);
125
166
  }
126
167
  const neighbours = scopedNeighbours(snapshot, taskId).slice(0, 5);
127
168
  for (const { claim, kind } of neighbours) {
@@ -7,7 +7,7 @@
7
7
  * `UserPromptSubmit` hook to add to the prompt — the unread journal first,
8
8
  * then the Mesh brief — so the model coordinates without being told to look.
9
9
  */
10
- import { executionCapabilityFor, liveExecutionScope } from "./capability";
10
+ import { liveExecutionScope } from "./capability";
11
11
  import { describeRun, markRunsDelivered, unreadRuns, type RunRecord } from "./journal";
12
12
  import { meshBrief } from "./map";
13
13
  import type { MeshSnapshot } from "../../../../packages/protocol/schema";
@@ -19,7 +19,6 @@ export type PromptContextDeps = {
19
19
  unread: (sessionId: string) => RunRecord[];
20
20
  markDelivered: (sessionId: string, at: number) => void;
21
21
  scope: (sessionId: string) => { snapshot: MeshSnapshot; taskId: string } | undefined;
22
- capability: (sessionId: string) => string | undefined;
23
22
  };
24
23
 
25
24
  const liveDeps: PromptContextDeps = {
@@ -29,7 +28,6 @@ const liveDeps: PromptContextDeps = {
29
28
  const scope = liveExecutionScope(sessionId);
30
29
  return scope ? { snapshot: scope.snapshot, taskId: scope.execution.taskId } : undefined;
31
30
  },
32
- capability: (sessionId) => executionCapabilityFor(sessionId)?.token,
33
31
  };
34
32
 
35
33
  function clock(at: number): string {
@@ -59,12 +57,13 @@ export function promptContext(
59
57
  }
60
58
  const scope = deps.scope(sessionId);
61
59
  if (scope) {
62
- const brief = meshBrief(scope.snapshot, scope.taskId);
60
+ const brief = meshBrief(scope.snapshot, scope.taskId, now);
63
61
  if (brief.length > 0) {
64
62
  if (lines.length > 0) lines.push("");
65
63
  lines.push(`Project Mesh «${scope.snapshot.project.name}»:`, ...brief.map((line) => `- ${line}`));
66
- const token = deps.capability(sessionId);
67
- if (token) lines.push(`Full map: read the MCP resource granttap://mesh/${token}/map`);
64
+ // Listed by name, not by token: Claude Code reads only listed resources,
65
+ // and the server finds the chat from its own environment.
66
+ lines.push("Full map: read the granttap MCP resource granttap://mesh/map");
68
67
  }
69
68
  }
70
69
  if (lines.length === 0) return undefined;
@@ -390,10 +390,27 @@ export function claudeCapabilityUsage(
390
390
  const pending = new Map<string, PendingCapabilityTool>();
391
391
  const out: CapabilityObservation[] = [];
392
392
 
393
+ let commandOrdinal = 0;
393
394
  for (const line of lines) {
394
395
  const row = safeParse(line);
395
- if (!row || !Array.isArray(row.message?.content)) continue;
396
+ if (!row) continue;
396
397
  const rowAt = ts(row.timestamp);
398
+ // A skill the person invoked as a slash command never becomes a tool call;
399
+ // the host writes it as a user turn. It is a skill used all the same.
400
+ const command = slashCommandSkill(row);
401
+ if (command) {
402
+ commandOrdinal += 1;
403
+ rememberCapabilityObservation(out, {
404
+ sourceId: `${sourceThreadId}:command:${typeof row.uuid === "string" ? row.uuid : commandOrdinal}`,
405
+ sessionId: session.sessionId,
406
+ toolName: `/${command}`,
407
+ skill: command,
408
+ createdAt: rowAt || session.lastActivityAt,
409
+ outcome: "success",
410
+ });
411
+ continue;
412
+ }
413
+ if (!Array.isArray(row.message?.content)) continue;
397
414
  for (const block of row.message.content as any[]) {
398
415
  if (
399
416
  block?.type === "tool_use" &&
@@ -439,6 +456,31 @@ export function claudeCapabilityUsage(
439
456
  return out;
440
457
  }
441
458
 
459
+ /**
460
+ * Housekeeping commands are the terminal's own, not a skill anyone wrote:
461
+ * counting `/clear` as a skill would put the tool's plumbing in the usage.
462
+ */
463
+ const BUILTIN_COMMANDS = new Set([
464
+ "clear", "compact", "help", "model", "cost", "status", "config", "doctor", "login", "logout",
465
+ "memory", "permissions", "hooks", "mcp", "agents", "exit", "quit", "bug", "vim", "terminal-setup",
466
+ "resume", "continue", "init", "context", "release-notes", "upgrade", "fast", "theme",
467
+ ]);
468
+
469
+ /** The skill a user turn invoked as a slash command, when it is one. */
470
+ export function slashCommandSkill(row: any): string | undefined {
471
+ if (row?.type !== "user" && row?.message?.role !== "user") return undefined;
472
+ const content = row.message?.content;
473
+ const text = typeof content === "string"
474
+ ? content
475
+ : Array.isArray(content)
476
+ ? content.find((block: any) => block?.type === "text")?.text
477
+ : undefined;
478
+ if (typeof text !== "string" || !text.trimStart().startsWith("<command-name>")) return undefined;
479
+ const name = /<command-name>\s*\/?([A-Za-z0-9][A-Za-z0-9_:.-]{0,79})\s*<\/command-name>/.exec(text)?.[1];
480
+ if (!name || BUILTIN_COMMANDS.has(name.toLowerCase())) return undefined;
481
+ return name;
482
+ }
483
+
442
484
  function appendClaudeActivity(
443
485
  out: ActivityEntry[],
444
486
  seen: Set<string>,
@@ -161,17 +161,43 @@ function boundedErrorClass(value: string | undefined): string | undefined {
161
161
  return clean || undefined;
162
162
  }
163
163
 
164
+ /**
165
+ * The command a shell call ran, by its first real word: `npm`, `git`, `rg` —
166
+ * not `Bash`. A usage screen that listed every shell call as Bash could not
167
+ * say which tool was slow or failing, which is the only thing it is for.
168
+ */
169
+ export function commandName(preview: string | undefined | null): string | undefined {
170
+ if (!preview) return undefined;
171
+ const prefixes = new Set(["sudo", "env", "exec", "time", "nohup", "command", "builtin", "xargs"]);
172
+ for (const segment of preview.split(/\s*(?:&&|\|\||;|\|)\s*/)) {
173
+ const words = segment.trim().split(/\s+/).filter(Boolean);
174
+ let index = 0;
175
+ while (index < words.length) {
176
+ const word = words[index]!;
177
+ if (word === "cd") { index += 2; continue; }
178
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || prefixes.has(word)) { index += 1; continue; }
179
+ break;
180
+ }
181
+ const word = words[index];
182
+ if (!word) continue;
183
+ const leaf = word.split("/").pop() ?? word;
184
+ if (/^[A-Za-z0-9._+-]{1,40}$/.test(leaf) && !/^[-.]/.test(leaf)) return leaf;
185
+ }
186
+ return undefined;
187
+ }
188
+
164
189
  export function toObservedCapability(
165
190
  observation: CapabilityObservation,
166
191
  ): ObservedCapability {
167
192
  const kind = observation.mcpServer ? "mcp" : observation.skill ? "skill" : "cli";
168
193
  const toolName = observation.toolName.trim().slice(0, 240);
194
+ const commandPreview =
195
+ kind === "cli" ? commandPreviewFromInput(observation.commandPreview) ?? undefined : undefined;
169
196
  return {
170
197
  kind,
171
- name: (observation.mcpServer ?? observation.skill ?? toolName).trim().slice(0, 160),
198
+ name: (observation.mcpServer ?? observation.skill ?? commandName(commandPreview) ?? toolName).trim().slice(0, 160),
172
199
  toolName,
173
- commandPreview:
174
- kind === "cli" ? commandPreviewFromInput(observation.commandPreview) ?? undefined : undefined,
200
+ commandPreview,
175
201
  estimatedContextTokens: observation.estimatedContextTokens,
176
202
  estimatedBaselineTokens: observation.estimatedBaselineTokens,
177
203
  durationMs: observation.durationMs,
@@ -203,7 +229,9 @@ export function toRemoteCapabilityUsageEvent(
203
229
  : observation.cli
204
230
  ? "cli"
205
231
  : null;
206
- const name = (observation.mcpServer ?? observation.skill ?? observation.toolName).trim();
232
+ const remotePreview =
233
+ kind === "cli" ? commandPreviewFromInput(observation.commandPreview) ?? undefined : undefined;
234
+ const name = (observation.mcpServer ?? observation.skill ?? commandName(remotePreview) ?? observation.toolName).trim();
207
235
  const toolName = observation.toolName.trim();
208
236
  const sessionId = observation.sessionId.trim();
209
237
  if (!kind || !name || !toolName || !sessionId || sessionId.length > 256) {
@@ -1,14 +1,38 @@
1
1
  import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { resolveExecutionCapability } from "../../../bridge/src/mesh/capability";
2
+ import {
3
+ executionCapabilityFor,
4
+ liveExecutionScope,
5
+ resolveExecutionCapability,
6
+ } from "../../../bridge/src/mesh/capability";
3
7
  import { scopedMeshView } from "../../../bridge/src/mesh/scoped-view";
4
- import { liveExecutionScope } from "../../../bridge/src/mesh/capability";
5
8
  import { meshMap } from "../../../bridge/src/mesh/map";
6
9
  import { isMeshEnabled } from "../../../bridge/src/config/runtime";
7
10
 
8
11
  const MESH_URI = "granttap://mesh/current";
12
+ export const MAP_URI = "granttap://mesh/map";
9
13
  const SCOPE_HINT =
10
- "Project Mesh reads are scoped to one execution. Call notify to receive this "
11
- + "session's granttap://mesh/<capability> URI, then read that URI.";
14
+ "Project Mesh reads are scoped to one execution. In Claude Code, read "
15
+ + `${MAP_URI}. Elsewhere, call notify to receive this session's `
16
+ + "granttap://mesh/<capability> URI, then read that URI.";
17
+
18
+ /**
19
+ * The chat this server belongs to.
20
+ *
21
+ * Claude Code starts one MCP server per chat and hands it the chat's id in
22
+ * the environment. Nothing said over the connection can change that, which
23
+ * makes it the one identity a resource read — which no hook attributes — can
24
+ * trust. Other providers set nothing here and keep using minted capabilities.
25
+ */
26
+ export function sessionFromEnvironment(env: NodeJS.ProcessEnv = process.env): string | undefined {
27
+ const id = env.CLAUDE_CODE_SESSION_ID?.trim() ?? "";
28
+ return /^[A-Za-z0-9._-]{8,128}$/.test(id) ? id : undefined;
29
+ }
30
+
31
+ /** This chat's Project as a page of markdown, or how to get one. */
32
+ function mapFor(sessionId: string | undefined): string {
33
+ const scope = isMeshEnabled() && sessionId ? liveExecutionScope(sessionId) : undefined;
34
+ return scope ? meshMap(scope.snapshot) : `# Project Mesh\n\n${SCOPE_HINT}\n`;
35
+ }
12
36
 
13
37
  function json(uri: string, value: unknown) {
14
38
  return {
@@ -34,11 +58,34 @@ export function registerMeshResource(server: McpServer): void {
34
58
  description: "How to obtain this execution's scoped coordination state.",
35
59
  mimeType: "application/json",
36
60
  },
37
- async (uri) => json(uri.href, {
38
- schema: "granttap.mesh-scope-hint.v1",
39
- enabled: isMeshEnabled(),
40
- scoped: false,
41
- hint: SCOPE_HINT,
61
+ async (uri) => {
62
+ // A server that knows its chat serves that chat's own scope here.
63
+ const sessionId = sessionFromEnvironment();
64
+ const capability = isMeshEnabled() && sessionId ? executionCapabilityFor(sessionId) : undefined;
65
+ const view = capability ? scopedMeshView(capability) : undefined;
66
+ if (view) return json(uri.href, { ...view, enabled: true, scoped: true });
67
+ return json(uri.href, {
68
+ schema: "granttap.mesh-scope-hint.v1",
69
+ enabled: isMeshEnabled(),
70
+ scoped: false,
71
+ hint: SCOPE_HINT,
72
+ });
73
+ },
74
+ );
75
+
76
+ // Listed, so a client that only reads listed resources — Claude Code's
77
+ // does — can open it straight from the prompt hook's one line.
78
+ server.registerResource(
79
+ "project-mesh-map",
80
+ MAP_URI,
81
+ {
82
+ title: "GrantTap Project Mesh map",
83
+ description: "This chat's Project as a readable map: Tasks, who edits what, the other side of each "
84
+ + "repository, what just happened. Transcripts are never included.",
85
+ mimeType: "text/markdown",
86
+ },
87
+ async (uri) => ({
88
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: mapFor(sessionFromEnvironment()) }],
42
89
  }),
43
90
  );
44
91
 
@@ -67,10 +114,10 @@ export function registerMeshResource(server: McpServer): void {
67
114
  // The same Project as one page of markdown: Tasks, who edits which module,
68
115
  // the other side of each repository, dependencies, and what just happened.
69
116
  server.registerResource(
70
- "project-mesh-map",
117
+ "project-mesh-map-scoped",
71
118
  new ResourceTemplate("granttap://mesh/{capability}/map", { list: undefined }),
72
119
  {
73
- title: "GrantTap Project Mesh map",
120
+ title: "GrantTap Project Mesh map (scoped)",
74
121
  description: "This execution's Project as a readable map. Transcripts are never included.",
75
122
  mimeType: "text/markdown",
76
123
  },
@@ -80,7 +127,7 @@ export function registerMeshResource(server: McpServer): void {
80
127
  const scope = resolved ? liveExecutionScope(resolved.sessionId) : undefined;
81
128
  const text = scope && scope.snapshot.projectId === resolved?.projectId
82
129
  ? meshMap(scope.snapshot)
83
- : `# Project Mesh\n\n${SCOPE_HINT}\n`;
130
+ : mapFor(undefined);
84
131
  return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
85
132
  },
86
133
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,11 +5,23 @@ const Amount = z.number().nonnegative().catch(0);
5
5
  const Count = z.number().int().nonnegative().catch(0);
6
6
 
7
7
  /** Measurements for one local coding-agent process family. */
8
+ /** One kind of process an agent runs — `node` ×43, `zsh` ×10 — and what it costs. */
9
+ export const ProcessGroupLoad = z.object({
10
+ name: z.string().trim().min(1).max(64),
11
+ count: Count,
12
+ cpuPercent: Amount,
13
+ memoryBytes: Amount,
14
+ });
15
+ export type ProcessGroupLoad = z.infer<typeof ProcessGroupLoad>;
16
+
8
17
  export const AgentLoadSample = z.object({
9
18
  agent: AgentId,
10
19
  processes: Count,
11
20
  cpuPercent: Amount,
12
21
  memoryBytes: Amount,
22
+ // The heaviest kinds of process behind the agent's number, so the phone
23
+ // can say what "Claude" is running, not only that it is running.
24
+ topProcesses: z.array(ProcessGroupLoad).max(8).optional(),
13
25
  sessions: Count,
14
26
  scanMs: Amount,
15
27
  tokensRecent: Amount,
@@ -70,6 +70,10 @@ export const ExecutionSessionLink = z.object({
70
70
  // When those facts were last observed, so a late snapshot cannot replace a
71
71
  // fresh reading with a stale one.
72
72
  updatedAt: z.number().nonnegative().optional(),
73
+ // When the chat itself last did anything. An execution stays open while its
74
+ // chat exists, which is not the same as the chat being alive: without this,
75
+ // every idle chat of the week read as live work.
76
+ activeAt: z.number().nonnegative().optional(),
73
77
  startedAt: z.number().nonnegative(),
74
78
  endedAt: z.number().nonnegative().optional(),
75
79
  }).strict().superRefine((value, ctx) => {