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 +23 -5
- package/apps/bridge/src/machine-load/index.ts +1 -0
- package/apps/bridge/src/machine-load/process-sampler.ts +153 -21
- package/apps/bridge/src/mesh/catalog.ts +1 -0
- package/apps/bridge/src/mesh/map.ts +53 -12
- package/apps/bridge/src/mesh/prompt-context.ts +5 -6
- package/apps/bridge/src/sessions/claude.ts +43 -1
- package/apps/bridge/src/sessions/telemetry.ts +32 -4
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +59 -12
- package/package.json +1 -1
- package/packages/protocol/messages/machine.ts +12 -0
- package/packages/protocol/messages/mesh.ts +4 -0
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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(
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
return /\/(Claude|ChatGPT)\.app\//.test(
|
|
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
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
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 (
|
|
58
|
-
const
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
87
|
-
encoding: "utf8",
|
|
88
|
-
maxBuffer: 4_000_000,
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(", ")}` :
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
|
11
|
-
+
|
|
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) =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
:
|
|
130
|
+
: mapFor(undefined);
|
|
84
131
|
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
|
|
85
132
|
},
|
|
86
133
|
);
|
package/package.json
CHANGED
|
@@ -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) => {
|